diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index a0ef2de1..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(git push:*)", - "Bash(pnpm --filter @swifta/web build:*)" - ] - } -} diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..3c8acda7 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,99 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "en-US" + +reviews: + # Keep automated reviews enabled. Financial and provider changes require + # correctness-focused feedback, even when a PR is otherwise small. + profile: "chill" + auto_review: + enabled: true + drafts: true + instructions: | + You are reviewing Twizrr, a Nigerian social-commerce marketplace. Focus on + correctness, security, financial safety, and architectural boundaries over + minor styling or docstring nits. Do not report a concern unless you can + describe a concrete failure scenario from the changed code. + + 1. Financial correctness and ledger + - All persisted money is BigInt in kobo. Never use float/Number for + financial calculation or JSON audit metadata; serialize JSON amounts as + decimal strings. + - LedgerEntry is append-only and is the source of truth for money + movement. Completion ledger entries require both a provider-confirmed + outcome and the committed guarded state transition. + - Check idempotency and concurrency for payment, refund, payout, escrow, + settlement, webhook, retry, reconciliation, outbox, and notification + changes. Duplicate delivery must not duplicate a provider submission, + ledger entry, durable notification, or completion transition. + - Flag external provider calls inside Prisma transactions and unguarded + read-modify-write state transitions. + + 2. Provider architecture and switching + - Domain services must depend on Twizrr-owned provider contracts, not raw + provider SDKs, clients, request payloads, response shapes, or status + strings. + - New operations use the configured provider only. Existing payment, + refund, payout, and settlement operations remain immutably bound to the + provider and provider reference recorded when they were created. + - Never silently fall back from one financial provider to another. A + provider switch must not collapse, overwrite, or orphan prior provider + attempts/references needed for webhook or reconciliation handling. + - Provider adapters must normalize errors and statuses. Raw provider + payloads, secrets, bank details, credentials, and headers must not + reach domain DTOs, events, logs, or public responses. + + 3. Payments, refunds, payouts, and webhooks + - Every explicit payment/refund/payout webhook must fail closed when raw + body or signature data is absent, malformed, or invalid. Verify the + provider-specific signature before any order, payout, refund, or ledger + state change. + - Do not mark REFUND_COMPLETED or PAYOUT_COMPLETED before a confirmed + provider success. Submitted, processing, timeout, and ambiguous states + require reconciliation or manual review, never a blind retry. + - Verify that provider references remain searchable in provider-neutral + admin, dispute, payout, and reconciliation views. + - Preserve payout holds and dispute/settlement safeguards. A settlement + leg may only act through its own approved, deterministic path. + + 4. Database and Prisma + - Use transactions for related database writes, but never include + external provider calls in them. + - Check for lost updates, race conditions, missing unique constraints, + unstable query ordering used for idempotency, and non-deterministic + legacy aggregation keys. + - Review migrations for checkout safety on populated tables. Flag + destructive drift, unsafe locking/index creation, and modified applied + migrations. Do not suggest db push for shared environments. + + 5. Security and privacy + - Every non-public controller route needs JwtAuthGuard and appropriate + ownership/role checks. Admin routes need the appropriate OPERATOR or + SUPER_ADMIN protection. + - Never expose or log passwords, OTPs, cookies, Authorization headers, + access/refresh tokens, raw request bodies/headers, provider payloads, + bank information, delivery addresses, delivery codes, raw NIN/selfies, + or private prompt values. + - Request security context and coarse IP geo are risk/audit signals only: + do not turn them into blocking, GPS collection, fingerprinting, or + automatic enforcement without an explicit product decision. + + 6. Realtime, notifications, and delivery + - PostgreSQL/REST remains authoritative. Outbox events and Socket.IO are + durable bridges/cache-invalidation hints, never the source of truth. + - Check deterministic outbox and notification dedupe keys. Pending or + submitted states must not send copy claiming money or delivery is + complete. + - Shipping provider adapters verify and normalize webhooks; delivery + domain services own guarded state changes. Delivery OTP, address, phone, + notes, and raw provider payloads must never appear in logs, outbox, + notification metadata, or socket payloads. + + 7. Product boundaries + - Preserve escrow protection for every order. Do not reintroduce direct + seller payment, supplier/B2B/RFQ/wholesale/trade-financing concepts, or + store-management flows in WhatsApp. + - Keep KYB tier rules unchanged: Tier 1 and Tier 2 are active MVP flows. + Provider abstractions may support future CAC/Tier 3 capability but must + not activate a new Tier 3 business flow without explicit approval. + - Frontend changes must be mobile-first, have loading/error states, and + use Twizrr language. Do not introduce emojis in UI or WhatsApp copy. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..ad47dfb8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,41 @@ +.git +.gitignore + +# Dependencies +node_modules +**/node_modules + +# Build and cache outputs +.turbo +**/.turbo +.next +**/.next +dist +**/dist +coverage +**/coverage + +# Environment files and local secrets +.env +.env.* +**/.env +**/.env.* + +# Logs and temporary files +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +tmp +temp +*.tmp + +# OS/editor files +.DS_Store +Thumbs.db +*.swp +*.swo +.idea +.vscode diff --git a/.env.example b/.env.example deleted file mode 100644 index df7cd939..00000000 --- a/.env.example +++ /dev/null @@ -1,52 +0,0 @@ -# WhatsApp integration credentials -WHATSAPP_PHONE_NUMBER_ID="" -WHATSAPP_ACCESS_TOKEN="" -WHATSAPP_VERIFY_TOKEN="your_verify_token" -WHATSAPP_APP_SECRET="your_app_secret" - -# ─────────────────────────────── -# DATABASE -# ─────────────────────────────── -# Local: postgresql://postgres:postgres@localhost:5432/hardware_os -# Prod: Use Supabase connection string (Settings → Database → Connection string → URI) -DATABASE_URL=postgresql://postgres:postgres@localhost:5432/hardware_os - -# ─────────────────────────────── -# REDIS -# ─────────────────────────────── -# Local: redis://localhost:6379 -# Prod: Use managed Redis (Render Redis, Upstash, Railway Redis) -REDIS_URL=redis://localhost:6379 - -# ─────────────────────────────── -# AUTH (JWT) -# ─────────────────────────────── -# Prod: Use 64+ character random strings (openssl rand -hex 32) -JWT_ACCESS_SECRET=change-me-access-secret-64-chars -JWT_REFRESH_SECRET=change-me-refresh-secret-64-chars -JWT_ACCESS_TTL=15m -JWT_REFRESH_TTL=7d - -# ─────────────────────────────── -# PAYSTACK -# ─────────────────────────────── -# Get keys from: https://dashboard.paystack.com/#/settings/developers -PAYSTACK_SECRET_KEY=sk_test_xxxxx -PAYSTACK_PUBLIC_KEY=pk_test_xxxxx -PAYSTACK_WEBHOOK_SECRET=whsec_xxxxx -PAYSTACK_BASE_URL=https://api.paystack.co - -# ─────────────────────────────── -# EMAIL (Resend) -# ─────────────────────────────── -EMAIL_PROVIDER=resend -RESEND_API_KEY=re_xxxxx -EMAIL_FROM=noreply@hardwareos.ng - -# ─────────────────────────────── -# APP -# ─────────────────────────────── -NODE_ENV=development -PORT=4000 -FRONTEND_URL=http://localhost:3000 -CORS_ORIGINS=http://localhost:3000 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..bcde7ec4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @onerandomdevv diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 188e6905..8aba9972 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,47 +1,48 @@ ## What does this PR do? - - -## Module(s) affected - - -- [ ] Auth -- [ ] Merchant -- [ ] Product -- [ ] RFQ -- [ ] Quote -- [ ] Order -- [ ] Payment -- [ ] Inventory -- [ ] Notification -- [ ] Frontend -- [ ] Shared -- [ ] Infrastructure + ## Type of change - [ ] New feature - [ ] Bug fix -- [ ] Refactor +- [ ] Refactor / cleanup +- [ ] Database migration included +- [ ] Chore / maintenance - [ ] Documentation -- [ ] Tests -## Checklist +## Area affected -- [ ] My code builds without errors (`pnpm build`) -- [ ] I've tested this locally -- [ ] I haven't added features outside V1 scope -- [ ] All money values use BigInt kobo (not floats) -- [ ] Merchant-scoped queries include merchantId filter -- [ ] No direct inventory mutations (events only) -- [ ] No business logic in controllers (services only) -- [ ] No `.env` files or secrets in this PR -- [ ] No `console.log` left in code (except health/dev logging) +- [ ] Backend +- [ ] Web +- [ ] WhatsApp +- [ ] Shared package +- [ ] Database / Prisma +- [ ] GitHub / CI / infrastructure -## How to test +## How to test this - +1. +2. +3. +Expected result: -## Screenshots (if frontend) +## Pre-commit checklist - +- [ ] Backend lint/type/build pass when backend is affected +- [ ] Web lint/type/build pass when web is affected +- [ ] Shared package build passes when shared is affected +- [ ] No `console.log` left in production code +- [ ] No secrets or `.env` files committed +- [ ] No new `any` types added +- [ ] No non-MVP legacy features reintroduced +- [ ] All money values are BigInt kobo, never float +- [ ] Paystack webhook changes verify HMAC before processing +- [ ] Database migrations are Prisma migrations, not `db push` +- [ ] Database migrations are backward-compatible or risk is documented + +## Screenshots + + + +## Notes for reviewer \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a10f6cbc..896ba3cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,16 +2,47 @@ name: CI on: push: - branches: [dev, main] + branches: ["**"] pull_request: - branches: [dev] + branches: [dev, staging, beta, main] jobs: - build: + lint-build: name: Lint & Build runs-on: ubuntu-latest + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_DB: twizrr_ci + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d twizrr_ci" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 env: - DATABASE_URL: "postgresql://localhost:5432/dummy" + NODE_ENV: test + PORT: 4000 + DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/twizrr_ci?schema=public + DIRECT_URL: postgresql://postgres:postgres@127.0.0.1:5432/twizrr_ci?schema=public + REDIS_URL: redis://127.0.0.1:6379 + JWT_SECRET: ci-jwt-secret + ONBOARDING_OTP_SECRET: ci-onboarding-otp-secret + RESEND_API_KEY: re_ci_runtime_smoke + CORS_ORIGINS: http://localhost:3000 steps: - name: Checkout @@ -27,13 +58,92 @@ jobs: cache: pnpm - name: Install dependencies - run: pnpm install --no-frozen-lockfile + run: pnpm install --frozen-lockfile + + - name: Build shared package + run: pnpm --filter @twizrr/shared build - name: Validate Prisma schema - run: pnpm --filter @swifta/backend exec prisma validate --schema=prisma/schema.prisma + working-directory: apps/backend + run: npx prisma validate --schema=prisma/schema.prisma - name: Generate Prisma client - run: pnpm --filter @swifta/backend exec prisma generate --schema=prisma/schema.prisma + working-directory: apps/backend + run: npx prisma generate + + - name: Apply database migrations + working-directory: apps/backend + run: npx prisma migrate deploy + + - name: Lint backend + working-directory: apps/backend + run: pnpm run lint + + - name: Typecheck backend + working-directory: apps/backend + run: npx tsc --noEmit + + - name: Build backend + working-directory: apps/backend + run: pnpm run build + + - name: Runtime smoke GET /health + working-directory: apps/backend + shell: bash + run: | + set -euo pipefail + pnpm run start > /tmp/twizrr-backend.log 2>&1 & + backend_pid=$! + trap 'kill "$backend_pid" 2>/dev/null || true; cat /tmp/twizrr-backend.log' EXIT + + for _ in {1..60}; do + if ! kill -0 "$backend_pid" 2>/dev/null; then + echo "Backend exited before /health became available" + exit 1 + fi + + status="$(curl -s -o /tmp/twizrr-health.json -w "%{http_code}" http://127.0.0.1:4000/health || true)" + if [ "$status" = "200" ]; then + cat /tmp/twizrr-health.json + exit 0 + fi + + sleep 2 + done + + echo "Backend did not return 200 from /health in time" + exit 1 + + web: + name: Web Lint, Typecheck & Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build shared package + run: pnpm --filter @twizrr/shared build + + - name: Lint web + working-directory: apps/web + run: pnpm run lint + + - name: Typecheck web + working-directory: apps/web + run: npx tsc --noEmit - - name: Build all packages - run: pnpm build + - name: Test web + working-directory: apps/web + run: pnpm run test diff --git a/.github/workflows/neon_workflow.yml b/.github/workflows/neon_workflow.yml new file mode 100644 index 00000000..bfe0a1eb --- /dev/null +++ b/.github/workflows/neon_workflow.yml @@ -0,0 +1,145 @@ +name: Create/Delete Branch for Pull Request + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - closed + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + +jobs: + setup: + name: Setup + outputs: + branch: ${{ steps.branch_name.outputs.current_branch }} + runs-on: ubuntu-latest + steps: + - name: Get branch name + id: branch_name + uses: tj-actions/branch-names@v8 + + create_neon_branch: + name: Create Neon Branch + outputs: + db_url: ${{ steps.create_neon_branch.outputs.db_url }} + db_url_pooled: ${{ steps.create_neon_branch.outputs.db_url_pooled }} + needs: setup + if: | + github.event_name == 'pull_request' && ( + github.event.action == 'synchronize' + || github.event.action == 'opened' + || github.event.action == 'reopened') + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Get branch expiration date as an env variable (2 weeks from now) + id: get_expiration_date + run: echo "EXPIRES_AT=$(date -u --date '+14 days' +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_ENV" + + # create-branch-action reuses a branch with the same name. Reset it on + # subsequent PR commits so a failed Prisma migration cannot poison later + # preview checks. This branch is ephemeral and never a shared environment. + - name: Reset existing Neon preview branch + if: github.event.action == 'synchronize' + continue-on-error: true + uses: neondatabase/reset-branch-action@v1 + with: + project_id: ${{ vars.NEON_PROJECT_ID }} + branch: preview/pr-${{ github.event.number }}-${{ needs.setup.outputs.branch }} + parent: true + api_key: ${{ secrets.NEON_API_KEY }} + + - name: Create Neon Branch + id: create_neon_branch + uses: neondatabase/create-branch-action@v6 + with: + project_id: ${{ vars.NEON_PROJECT_ID }} + branch_name: preview/pr-${{ github.event.number }}-${{ needs.setup.outputs.branch }} + api_key: ${{ secrets.NEON_API_KEY }} + expires_at: ${{ env.EXPIRES_AT }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Validate preview database URLs + shell: bash + env: + DATABASE_URL: ${{ steps.create_neon_branch.outputs.db_url_pooled }} + DIRECT_URL: ${{ steps.create_neon_branch.outputs.db_url }} + run: | + if [ -z "$DATABASE_URL" ]; then + echo "Preview pooled DATABASE_URL was not returned by Neon." + exit 1 + fi + if [ -z "$DIRECT_URL" ]; then + echo "Preview direct DIRECT_URL was not returned by Neon." + exit 1 + fi + + - name: Apply Prisma migrations to preview branch + env: + DATABASE_URL: ${{ steps.create_neon_branch.outputs.db_url_pooled }} + DIRECT_URL: ${{ steps.create_neon_branch.outputs.db_url }} + shell: bash + run: | + for attempt in 1 2 3 4 5; do + if pnpm --filter @twizrr/backend exec prisma migrate deploy --config=prisma.config.ts --schema=prisma/schema.prisma; then + exit 0 + fi + + if [ "$attempt" -eq 5 ]; then + exit 1 + fi + + sleep_seconds=$((attempt * 5)) + echo "Migration failed; retrying in ${sleep_seconds}s..." + sleep "$sleep_seconds" + done + + - name: Verify preview migration status + env: + DATABASE_URL: ${{ steps.create_neon_branch.outputs.db_url_pooled }} + DIRECT_URL: ${{ steps.create_neon_branch.outputs.db_url }} + shell: bash + run: | + for attempt in 1 2 3 4 5; do + if pnpm --filter @twizrr/backend exec prisma migrate status --config=prisma.config.ts --schema=prisma/schema.prisma; then + exit 0 + fi + + if [ "$attempt" -eq 5 ]; then + exit 1 + fi + + sleep_seconds=$((attempt * 5)) + echo "Migration status check failed; retrying in ${sleep_seconds}s..." + sleep "$sleep_seconds" + done + + delete_neon_branch: + name: Delete Neon Branch + needs: setup + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - name: Delete Neon Branch + uses: neondatabase/delete-branch-action@v3 + with: + project_id: ${{ vars.NEON_PROJECT_ID }} + branch: preview/pr-${{ github.event.number }}-${{ needs.setup.outputs.branch }} + api_key: ${{ secrets.NEON_API_KEY }} diff --git a/.github/workflows/prisma-migrate.yml b/.github/workflows/prisma-migrate.yml new file mode 100644 index 00000000..1a3ca414 --- /dev/null +++ b/.github/workflows/prisma-migrate.yml @@ -0,0 +1,56 @@ +name: Prisma Migrate Deploy + +on: + push: + branches: [dev, staging, beta, main] + paths: + - "apps/backend/prisma/**" + - "apps/backend/prisma.config.ts" + workflow_dispatch: + +concurrency: + group: prisma-migrate-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + migrate: + name: Apply Prisma migrations + runs-on: ubuntu-latest + environment: ${{ github.ref_name == 'main' && 'production' || github.ref_name == 'beta' && 'beta' || github.ref_name == 'staging' && 'staging' || 'development' }} + env: + DATABASE_URL: ${{ secrets.NEON_DATABASE_URL }} + DIRECT_URL: ${{ secrets.NEON_DIRECT_URL }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Validate database secrets + shell: bash + run: | + if [ -z "$DATABASE_URL" ]; then + echo "NEON_DATABASE_URL secret is required." + exit 1 + fi + if [ -z "$DIRECT_URL" ]; then + echo "NEON_DIRECT_URL secret is required for migrations." + exit 1 + fi + + - name: Apply migrations + run: pnpm --filter @twizrr/backend exec prisma migrate deploy --schema=prisma/schema.prisma + + - name: Verify migration status + run: pnpm --filter @twizrr/backend exec prisma migrate status --schema=prisma/schema.prisma diff --git a/.github/workflows/promotion-guard.yml b/.github/workflows/promotion-guard.yml new file mode 100644 index 00000000..23c3b9d4 --- /dev/null +++ b/.github/workflows/promotion-guard.yml @@ -0,0 +1,82 @@ +name: Promotion Guard + +# Enforces the promotion ladder so environment branches can only receive merges +# from the tier directly below them: +# +# dev -> beta -> main +# +# (dev is the team integration + final-QA environment; beta is a disposable +# infra rehearsal for selected testers; main is production. Staging was dropped — +# per-PR preview envs + dev cover pre-beta testing.) +# +# GitHub rulesets can require a review and status checks, but they cannot +# restrict which *source* branch a PR merges from. This guard fills that gap: +# it fails any PR into a protected branch whose head is not the allowed source. +# Mark the "guard" check as a required status check in each branch's ruleset +# (beta, main) for it to actually block a merge. +# +# PRs into a protected branch must come from a branch in this repo (not a fork). +# Emergency bypass: add the `hotfix` label to skip the ladder — allowed only on +# PRs targeting `main`, for a critical fix that must land out of order. + +on: + pull_request: + branches: [main, beta] + types: [opened, reopened, synchronize, edited, labeled, unlabeled] + +permissions: + contents: read + +jobs: + guard: + name: guard + runs-on: ubuntu-latest + steps: + - name: Check promotion source branch + env: + BASE: ${{ github.base_ref }} + HEAD: ${{ github.head_ref }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + REPO: ${{ github.repository }} + # contains() over the label-name array is an exact element match, so a + # label like "needs hotfix review" does NOT count as the hotfix label. + IS_HOTFIX: ${{ contains(github.event.pull_request.labels.*.name, 'hotfix') }} + shell: bash + run: | + set -euo pipefail + + # Allowed source for each protected target branch. + declare -A ALLOWED=( [main]="beta" [beta]="dev" ) + expected="${ALLOWED[$BASE]:-}" + + echo "PR: $HEAD -> $BASE (from $HEAD_REPO)" + + if [ -z "$expected" ]; then + echo "::notice::No promotion rule defined for base '$BASE'; nothing to enforce." + exit 0 + fi + + # Promotions and hotfixes to protected branches must originate from a + # branch in this repository, never a fork — otherwise a fork branch + # named 'dev' could satisfy 'staging <- dev' and skip the ladder. + if [ "$HEAD_REPO" != "$REPO" ]; then + echo "::error::PRs into '$BASE' must come from a branch in '$REPO', not a fork ('$HEAD_REPO')." + exit 1 + fi + + # Emergency bypass with the 'hotfix' label — permitted only into main. + if [ "$IS_HOTFIX" = "true" ]; then + if [ "$BASE" != "main" ]; then + echo "::error::The 'hotfix' label may only bypass the ladder on PRs into 'main', not '$BASE'." + exit 1 + fi + echo "::warning::'hotfix' label present — bypassing the promotion ladder for 'main'." + exit 0 + fi + + if [ "$HEAD" != "$expected" ]; then + echo "::error::Promotion ladder violation: '$BASE' only accepts merges from '$expected', but this PR is from '$HEAD'. Follow the ladder dev -> beta -> main, or add the 'hotfix' label (main only) to bypass." + exit 1 + fi + + echo "OK: '$BASE' is correctly receiving a promotion from '$expected'." diff --git a/.gitignore b/.gitignore index 6e358768..7944e244 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ dist/ *.tsbuildinfo apps/backend/src/prisma/generated/ +# TypeScript build artifacts in source directories (prevent accidental commits) +packages/*/src/**/*.js +packages/*/src/**/*.js.map + # Environment files .env .env.local @@ -33,10 +37,95 @@ Thumbs.db # Package manager .pnpm-store/ +# Vercel CLI link/state (per-machine — never commit) +.vercel + # Test coverage coverage/ # Confidential Documentation ENGINEERING_AUDIT_REPORT.md HARDWARE_OS_Backend_Fullstack_Guide.md -CLAUDE.md \ No newline at end of file + +# AI assistant local files - do not commit +CLAUDE.md +GEMINI.md +.cursorrules +PROMPTS.md +DESIGN.md +CLI_HANDBOOK.md +.claude +.clawpatch/ + +# Seed scripts - local utility only, not for production +scripts/seed-admin.ts + +# twizrr system documents — added per task, removed after task +# Only TWIZRR_DEVELOPMENT_RULES.md and TWIZRR_MVP_SCOPE.md are +# permanently tracked. All other TWIZRR_*.md files are temporary. +TWIZRR_PROJECT_OVERVIEW.md +TWIZRR_STORE_SYSTEM.md +TWIZRR_PRODUCT_POST_INVENTORY.md +TWIZRR_INVENTORY_MANAGEMENT.md +TWIZRR_ORDER_MANAGEMENT.md +TWIZRR_PAYMENT_SYSTEM.md +TWIZRR_RECEIPTS.md +TWIZRR_USER_SYSTEM.md +TWIZRR_ONBOARDING_SYSTEM.md +TWIZRR_FEED_ALGORITHM.md +TWIZRR_CHAT_SYSTEM.md +TWIZRR_REVIEWS_RATINGS.md +TWIZRR_DELIVERY_SYSTEM.md +TWIZRR_INSPECTION_SYSTEM.md +TWIZRR_DROPS_SYSTEM.md +TWIZRR_BUYER_PROTECTION.md +TWIZRR_DISPUTE_RESOLUTION.md +TWIZRR_CONTENT_MODERATION.md +TWIZRR_ROLE_SYSTEM.md +TWIZRR_REFERRAL_SYSTEM.md +TWIZRR_NOTIFICATION_SYSTEM.md +TWIZRR_EMAIL_SYSTEM.md +TWIZRR_USSD_SMS_SYSTEM.md +TWIZRR_WHATSAPP_AI.md +TWIZRR_DATA_ARCHITECTURE.md +TWIZRR_BRAND_LANGUAGE.md +TWIZRR_DESIGN_SYSTEM.md +TWIZRR_NAVIGATION_SYSTEM.md +TWIZRR_SCALE_ARCHITECTURE.md +TWIZRR_STORAGE_INFRASTRUCTURE.md +TWIZRR_SESSION_HANDOFF.md +TWIZRR_CODEBASE_AUDIT_PROMPT.md +TWIZRR_CODEBASE_CLEANUP_PROMPT.md +TWIZRR_ASSETS_AUDIT_REPORT.md +TWIZRR_WEB_ASSETS_CLEANUP_PROMPT.md +TWIZRR_ENV_SETUP_PROMPT.md +TWIZRR_PROJECT_SETUP_PROMPT.md +TWIZRR_AGENT_TASK_PROMPT_TEMPLATE.md +TWIZRR_DEVELOPER_RESPONSIBILITIES.md +TWIZRR_BACKEND_TASKS.md +TWIZRR_WEB_TASKS.md +TWIZRR_WHATSAPP_TASKS.md +TWIZRR_CLEANUP_REPORT.md +TWIZRR_REALTIME_ARCHITECTURE.md +TWIZRR_REMAINING_BALANCE_COLLECTION_DESIGN.md +TWIZRR_SETTLEMENT_ROLLOUT_READINESS.md +TWIZRR_SHOPPER_WALLET_OVERPAYMENT_DESIGN.md +Twizrr_StorePass.md +TWIZRR_MONNIFY_PROVIDER_ASSESSMENT.md +TWIZRR_MERCHANT_EARNINGS_WALLET_DESIGN.md +TWIZRR_MEDIA_ARCHITECTURE.md +TWIZRR_MEDIA_STORAGE.md +TWIZRR_PAYMENT_EXCEPTION_SANDBOX_RUNBOOK.md +TWIZRR_PAYMENT_AMOUNT_EXCEPTION_DESIGN.md +TWIZRR_PLATFORM_FEE_ANALYSIS.md + +# Audit and report files +*_AUDIT_REPORT.md +*_CLEANUP_REPORT.md +*_HANDOFF.md + +# Neon CLI / agent skills tooling (local setup artifacts) +.neon +.agents/ +skills-lock.json + diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..713474ba --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +npx lint-staged +pnpm --filter @twizrr/backend run typecheck diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..fadb745c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,1298 @@ +# twizrr — AI Assistant Instructions + +> Read this file before touching any code. Every decision in this project flows from this document. +> This is the single source of truth for the entire codebase. +> If something is not documented here — ask before assuming. Never guess. + +--- + +## 0. Project Overview + +**What this project is:** +twizrr is a Nigerian social commerce marketplace. Stores post products, lifestyle images, and short text posts to a social feed — shoppers discover and buy through the feed and through a WhatsApp AI shopping assistant. Every transaction is escrow-protected: money is held until the shopper confirms delivery using a 6-digit code, then released automatically to the store's bank account. + +**How the system works:** +A NestJS 10 backend API serves two clients — a Next.js 14 web app and a WhatsApp AI assistant. The backend handles all business logic: products, orders, escrow payments via Paystack, payout automation via BullMQ, and AI product search via Vertex AI embeddings stored in pgvector. The web app serves store owners (store workspace) and shoppers (discovery + checkout). WhatsApp serves shoppers only — it is not used for store management. + +**The core feature:** +Protected-payment social commerce. Store lists product → shopper discovers via feed or WhatsApp AI → shopper pays → payment is protected by twizrr Buyer Protection → store prepares order → delivery is handled by twizrr → shopper confirms delivery with a delivery code → store receives payment automatically. + +**External services:** +- **Paystack** — card payments, Dedicated Virtual Accounts (DVA), bank transfer payouts to stores +- **Shipbubble** — nationwide logistics, delivery booking and tracking +- **Meta WhatsApp Cloud API** — WhatsApp webhook, 6 pre-approved template messages +- **Gemini 2.5 Flash** — WhatsApp AI conversation and intent parsing +- **Google Cloud Vision API** — image labeling for WhatsApp image search + content moderation (SafeSearch) +- **Vertex AI Multimodal Embeddings** — 1408-dim product vectors stored in pgvector for semantic search +- **Africa's Talking** — USSD checkout, SMS fallback +- **Resend** — transactional email (order confirmations, OTPs, payout receipts) +- **Cloudinary** — image CDN (product photos, store logos, post images) +- **Google Places Autocomplete API** — store address input during setup +- **Prembly** — NIN verification (Tier 2 KYB) and CAC verification (Tier 3 KYB) +- **Neon** — managed PostgreSQL with pgvector extension and PgBouncer connection pooling +- **Upstash** — managed Redis for caching, BullMQ job queues, rate limiting + +**What this project is NOT:** +- Not a B2B or wholesale platform — twizrr is B2C only +- Not a hardware or building materials marketplace — general fashion, electronics, food, beauty, home goods +- Not a WhatsApp management tool — WhatsApp is for shoppers buying, not for store owners managing +- Not Swifta — the old codebase was called Swifta. twizrr is a complete rebrand and rebuild +- Not building supplier features — the SupplierProfile model and any B2B flows are out of scope +- Not building RFQ, Quote, TradeFinancing, or any legacy modules from the Swifta codebase + +--- + +## 1. What This Project Is + +Official social commerce platform for **twizrr** (CODEDDEVS TECHNOLOGY LTD). + +- **URL:** twizrr.com +- **API:** api.twizrr.com +- **Audience:** Nigerian shoppers (buying via WhatsApp and web) and Nigerian store owners (selling via the web store workspace) +- **Purpose:** Trust infrastructure for Nigerian social commerce — escrow payments, verified stores, and WhatsApp-native shopping +- **Tone:** Warm, professional, and direct. No pidgin. No slang. No emojis in UI or WhatsApp messages — icons only. +- **Critical constraint:** Every transaction must be escrow-protected. Money never goes directly from shopper to store. Paystack webhook HMAC must be verified on every payment callback before any order status changes. + +--- + +## 2. Tech Stack + +Do not change any of these without explicit instruction. + +| Layer | Choice | +|---|---| +| Backend framework | NestJS 10 + TypeScript strict mode | +| Frontend framework | Next.js 14 (App Router) + TypeScript | +| Mobile (Phase 3) | Expo SDK 52 + Expo Router v4 — DO NOT BUILD YET | +| Runtime | Node.js 20+ | +| Database | Neon PostgreSQL 16 + pgvector extension | +| ORM | Prisma (migrate deploy on shared envs — never db push) | +| Cache + Queues | Upstash Redis + BullMQ | +| Auth | JWT (HttpOnly cookies) + custom JwtAuthGuard | +| Payments | Paystack (checkout, DVA, transfers, refunds) | +| Logistics | Shipbubble API | +| Email | Resend | +| SMS / USSD | Africa's Talking | +| Images | Cloudinary (with Cloud Vision SafeSearch before upload) | +| AI — conversation | Gemini 2.5 Flash (via Google AI SDK) | +| AI — vision | Google Cloud Vision API | +| AI — embeddings | Vertex AI Multimodal Embeddings (1408-dim) | +| AI — search | pgvector (cosine similarity on ProductEmbedding table) | +| KYB verification | Prembly API (NIN + CAC) | +| Address input | Google Places Autocomplete API | +| Hosting — backend | Provider-neutral Node.js 20+ service with long-running process support for queues | +| Hosting — frontend | Provider-neutral Next.js 14 host | +| Package manager | pnpm — NEVER use npm or yarn | +| Monorepo | Turborepo + pnpm workspaces | +| Package scope | @twizrr/ | +| PR review | CodeRabbit (automated) | + +--- + +## 3. Design System + +> Design system is defined in TWIZRR_DESIGN_SYSTEM.md. +> Final brand assets (logo, exact tokens) are pending from AbdulRaseed (Brand Designer). +> Do not start frontend visual work until TWIZRR_DESIGN_SYSTEM.md is marked final. +> Below are the confirmed values for use in all current development. + +### Brand Colors + +``` +Saffron: #E8A020 — primary CTA buttons, active states, escrow pill, saffron accents +Espresso: #1C1208 — headings, dark backgrounds, store mode header +Berry: #C2185B — Tier 3 verified badge, accent highlight +Mocha: #6B5B4E — Tier 2 verified badge +Bianca: #FBF7F2 — page background (light mode) +``` + +### Typography + +| Element | Font | Weight | Notes | +|---|---|---|---| +| Headings | Syne | 700 | Load via next/font/google | +| Body / UI | Cabinet Grotesque | 400 / 500 | Load via next/font/google | +| Prices | Cabinet Grotesque (system) | 700 | Naira amounts only | + +### Design Direction + +- **No emojis anywhere** — icons only in all UI surfaces (web, mobile, WhatsApp messages) +- **Icons** — use Lucide React for all icons. Never inline custom SVG unless it's the twizrr logo +- **Colours** — always use CSS variables (design tokens), never hardcoded hex values +- **Buttons** — primary: Saffron background + Espresso text. Secondary: outlined +- **Spacing** — base unit 4px. Use Tailwind spacing scale consistently +- **Radius** — small elements 6px, cards 12px, modals 16px, images 20px +- **Mobile first** — all layouts start at 375px. Minimum touch target 44×44px +- **Dark mode** — Phase 3. Do not implement dark mode in MVP + +### Logo + +- Primary logo SVG: `public/logo/logo.svg` — full brand lockup (ZZ mark + TWIZRR wordmark); use for brand-heavy surfaces such as auth, landing, empty/error states, and hero placements +- Wordmark SVG: `public/logo/wordmark.svg` — text-only horizontal wordmark; use in headers/nav where space is tight +- Mark SVG: `public/logo/mark.svg` — icon-only ZZ mark; use in favicons, loaders, compact buttons, and small spaces + +--- + +## 4. Folder Structure + +``` +twizrr/ # monorepo root +├── apps/ +│ ├── backend/ # NestJS 10 API +│ │ ├── src/ +│ │ │ │ +│ │ │ ├── core/ # PLATFORM INFRASTRUCTURE — no business logic +│ │ │ │ ├── config/ # ConfigModule per service (app, db, redis, paystack...) +│ │ │ │ ├── prisma/ # PrismaService (extends PrismaClient) +│ │ │ │ ├── redis/ # RedisService wrapper (Redis protocol) +│ │ │ │ ├── logger/ # nestjs-pino structured logging +│ │ │ │ ├── throttler/ # ThrottlerModule setup (rate limiting) +│ │ │ │ ├── health/ # GET /health — UptimeRobot pings this +│ │ │ │ └── core.module.ts # imports + exports all of the above +│ │ │ │ +│ │ │ ├── channels/ # EXTERNAL ENTRY POINTS — not business logic +│ │ │ │ ├── whatsapp/ # Meta WhatsApp Cloud API channel (WIZZA) +│ │ │ │ │ ├── whatsapp.module.ts +│ │ │ │ │ ├── whatsapp.controller.ts # GET + POST /whatsapp/webhook +│ │ │ │ │ ├── whatsapp.processor.ts # BullMQ consumer (async processing) +│ │ │ │ │ ├── whatsapp.service.ts # message routing + intent handling +│ │ │ │ │ ├── whatsapp-session.service.ts # consent + session per phone +│ │ │ │ │ ├── whatsapp-intent.service.ts # Gemini function-calling +│ │ │ │ │ ├── whatsapp-auth.service.ts # WhatsApp account linking flows +│ │ │ │ │ ├── whatsapp-product-discovery.service.ts # text discovery + canonical URLs +│ │ │ │ │ ├── whatsapp-search-ranking.ts # hybrid ranking helpers +│ │ │ │ │ ├── image-search.service.ts # embedding-first image search +│ │ │ │ │ ├── whatsapp-interactive.service.ts # list messages, buttons, text sends +│ │ │ │ │ ├── whatsapp-analytics.service.ts # WhatsAppAnalytics v2 logging +│ │ │ │ │ ├── whatsapp-logger.service.ts +│ │ │ │ │ ├── whatsapp.constants.ts +│ │ │ │ │ ├── whatsapp.utils.ts # phone normalize + log masking +│ │ │ │ │ └── AGENTS.md # WhatsApp-specific build instructions +│ │ │ │ │ +│ │ │ │ └── ussd/ # Africa's Talking USSD channel +│ │ │ │ ├── ussd.module.ts +│ │ │ │ ├── ussd.controller.ts # POST /ussd/callback +│ │ │ │ └── ussd.service.ts +│ │ │ │ +│ │ │ ├── integrations/ # THIRD-PARTY PROVIDER CLIENTS +│ │ │ │ ├── paystack/ # Paystack — checkout, DVA, transfers, refunds +│ │ │ │ │ ├── paystack.module.ts +│ │ │ │ │ ├── paystack.client.ts # all Paystack API calls (one place) +│ │ │ │ │ └── paystack.types.ts +│ │ │ │ ├── cloudinary/ # Image CDN + upload +│ │ │ │ │ ├── cloudinary.module.ts +│ │ │ │ │ └── cloudinary.client.ts +│ │ │ │ ├── resend/ # Transactional email +│ │ │ │ │ ├── resend.module.ts +│ │ │ │ │ └── resend.client.ts +│ │ │ │ ├── shipbubble/ # Logistics + delivery booking +│ │ │ │ │ ├── shipbubble.module.ts +│ │ │ │ │ └── shipbubble.client.ts +│ │ │ │ ├── africastalking/ # SMS + USSD +│ │ │ │ │ ├── africastalking.module.ts +│ │ │ │ │ └── africastalking.client.ts +│ │ │ │ ├── prembly/ # KYB — NIN + CAC verification +│ │ │ │ │ ├── prembly.module.ts +│ │ │ │ │ └── prembly.client.ts +│ │ │ │ ├── google-places/ # Store address autocomplete +│ │ │ │ │ ├── google-places.module.ts +│ │ │ │ │ └── google-places.client.ts +│ │ │ │ └── ai/ # All AI provider clients (one place) +│ │ │ │ ├── ai.module.ts +│ │ │ │ ├── gemini.client.ts # Gemini 2.5 Flash +│ │ │ │ ├── vision.client.ts # Google Cloud Vision API +│ │ │ │ └── vertex.client.ts # Vertex AI Multimodal Embeddings +│ │ │ │ +│ │ │ ├── domains/ # BUSINESS LOGIC — grouped by domain area +│ │ │ │ │ +│ │ │ │ ├── commerce/ # Products, posts, feed, search, sourcing +│ │ │ │ │ ├── product/ # Product CRUD, variants, stock display +│ │ │ │ │ ├── inventory/ # InventoryEvent (append-only), ProductStockCache +│ │ │ │ │ ├── sourced-product/ # Dropship sourcing — floor enforcement +│ │ │ │ │ ├── post/ # PRODUCT_POST, IMAGE_POST, GIST, TWIZZ +│ │ │ │ │ ├── feed/ # Algorithm: For You, Following, Stores, Explore +│ │ │ │ │ ├── search/ # Hybrid: pgvector + tsvector + metadata + trust +│ │ │ │ │ ├── size-guide/ # Platform-managed guides (seeded — store owners cannot create) +│ │ │ │ │ └── commerce.module.ts +│ │ │ │ │ +│ │ │ │ ├── orders/ # Full order lifecycle +│ │ │ │ │ ├── order/ # State machine: PENDING_PAYMENT → COMPLETED +│ │ │ │ │ ├── cart/ # CartItem management +│ │ │ │ │ ├── delivery/ # Shipbubble + delivery preview API +│ │ │ │ │ ├── dispute/ # Dispute filing, 72hr window, OPERATOR queue +│ │ │ │ │ └── orders.module.ts +│ │ │ │ │ +│ │ │ │ ├── money/ # ALL money movement — single domain boundary +│ │ │ │ │ ├── ledger/ # LedgerEntry (append-only source of truth) +│ │ │ │ │ ├── payment/ # Paystack checkout, DVA, webhook processing +│ │ │ │ │ ├── payout/ # Bank transfers to stores (BullMQ — never sync) +│ │ │ │ │ ├── escrow/ # Escrow hold + release logic +│ │ │ │ │ ├── refund/ # Refund triggers + Paystack Refunds API +│ │ │ │ │ └── money.module.ts +│ │ │ │ │ +│ │ │ │ ├── users/ # Users, stores, trust, verification +│ │ │ │ │ ├── auth/ # JWT, signup, OTP, refresh tokens, sessions +│ │ │ │ │ ├── user/ # User CRUD, profile, measurements, addresses +│ │ │ │ │ ├── store/ # StoreProfile CRUD, settings, KYB flows +│ │ │ │ │ ├── verification/ # KYB — Prembly NIN + CAC flows +│ │ │ │ │ ├── follow/ # Follow relationships (users + stores) +│ │ │ │ │ └── users.module.ts +│ │ │ │ │ +│ │ │ │ ├── social/ # Social interactions and communications +│ │ │ │ │ ├── notification/ # In-app notifications + email dispatch +│ │ │ │ │ ├── realtime/ # socket.io push gateway (notification:new today; chat rides on it later) +│ │ │ │ │ ├── chat/ # NOT BUILT YET — placeholder; will use realtime/ gateway +│ │ │ │ │ └── social.module.ts +│ │ │ │ │ +│ │ │ │ └── platform/ # Operational and admin concerns +│ │ │ │ ├── admin/ # OPERATOR + SUPER_ADMIN tools, AuditLog +│ │ │ │ ├── moderation/ # Cloud Vision decisions + OPERATOR review queue +│ │ │ │ ├── upload/ # Cloudinary — ALWAYS runs moderation first +│ │ │ │ ├── embedding/ # Vertex AI embedding generation + pgvector storage +│ │ │ │ └── platform.module.ts +│ │ │ │ +│ │ │ ├── queue/ # BULLMQ — all queues and processors +│ │ │ │ ├── queue.constants.ts # named queue string constants +│ │ │ │ ├── queue.module.ts # registers all 16 BullMQ queues +│ │ │ │ └── processors/ # one processor file per queue +│ │ │ │ ├── embedding.processor.ts +│ │ │ │ ├── payout.processor.ts +│ │ │ │ ├── payout-retry.processor.ts +│ │ │ │ ├── notification.processor.ts +│ │ │ │ ├── auto-confirm.processor.ts +│ │ │ │ ├── auto-confirm-warning.processor.ts +│ │ │ │ ├── floor-check.processor.ts +│ │ │ │ ├── moderation.processor.ts +│ │ │ │ ├── order-expiry.processor.ts +│ │ │ │ ├── restock-alert.processor.ts +│ │ │ │ ├── reconciliation.processor.ts +│ │ │ │ ├── review-prompt.processor.ts +│ │ │ │ ├── session-cleanup.processor.ts +│ │ │ │ └── floor-check.processor.ts +│ │ │ │ +│ │ │ ├── common/ # SHARED UTILITIES — no business logic +│ │ │ │ ├── guards/ +│ │ │ │ │ ├── jwt-auth.guard.ts +│ │ │ │ │ └── roles.guard.ts +│ │ │ │ ├── decorators/ +│ │ │ │ │ ├── current-user.decorator.ts +│ │ │ │ │ └── roles.decorator.ts +│ │ │ │ ├── filters/ +│ │ │ │ │ └── global-exception.filter.ts +│ │ │ │ ├── interceptors/ +│ │ │ │ │ └── response-transform.interceptor.ts +│ │ │ │ ├── pipes/ +│ │ │ │ │ └── validation.pipe.ts +│ │ │ │ └── dto/ +│ │ │ │ └── pagination.dto.ts +│ │ │ │ +│ │ │ └── app.module.ts # CLEAN TOP-LEVEL COMPOSITION ONLY +│ │ │ # imports: CoreModule, QueueModule, +│ │ │ # CommerceDomainModule, OrdersDomainModule, +│ │ │ # MoneyDomainModule, UsersTrustDomainModule, +│ │ │ # SocialDomainModule, PlatformDomainModule, +│ │ │ # WhatsAppModule, USSDModule +│ │ │ +│ │ ├── prisma/ +│ │ │ ├── schema.prisma # source of truth for all models +│ │ │ └── seed.ts # seeds SizeGuide records at first deployment +│ │ └── AGENTS.md # backend-specific instructions — read after root +│ │ +│ └── web/ # Next.js 14 App Router +│ ├── src/ +│ │ ├── app/ # all routes (App Router) +│ │ │ ├── (public)/ # no auth: /, /explore, /p/[code], /@[handle] +│ │ │ ├── (auth)/ # /login, /register, /verify-email +│ │ │ ├── (shopper)/ # /buyer/* — authenticated shopper pages +│ │ │ └── (store)/ # /store/* — authenticated store owner pages +│ │ ├── components/ # shared UI components +│ │ ├── hooks/ # custom React hooks +│ │ ├── lib/ # API client, utilities, constants +│ │ └── styles/ # global CSS + design tokens +│ └── AGENTS.md # web-specific instructions — read after root +│ +├── packages/ +│ └── shared/ # @twizrr/shared +│ └── src/ +│ ├── types/ # shared TypeScript types +│ ├── enums/ # shared enums +│ └── dtos/ # shared DTOs +│ +├── AGENTS.md # THIS FILE — read first, always +├── turbo.json +├── pnpm-workspace.yaml +└── docker-compose.yml # local PostgreSQL + Redis +``` + +### Architecture Rationale + +``` +FOUR LAYERS — each with a clear purpose: + +core/ PLATFORM INFRASTRUCTURE + Config, Prisma, Redis, Logger, Throttler, Health + No business logic. Setup once, reused everywhere. + AppModule stays clean — not polluted with infra. + +channels/ EXTERNAL ENTRY POINTS + WhatsApp and USSD are channels, not features. + They receive external input and route it to domains. + Phase 3: extract whatsapp/ to a dedicated worker service + with zero refactoring — boundaries are already correct. + +integrations/ THIRD-PARTY PROVIDER CLIENTS + All Paystack API calls live in integrations/paystack/ + All AI API calls live in integrations/ai/ + Provider changes (API version, key rotation, replacement) + affect exactly one file. Business logic is untouched. + Mocking in tests: mock the integration client — done. + +domains/ BUSINESS LOGIC — grouped by domain + commerce/ — products, posts, feed, search, sourcing + orders/ — order lifecycle, cart, delivery, disputes + money/ — ledger (single source of truth), payments, + payouts, escrow, refunds + users/ — auth, profiles, stores, KYB, follows + social/ — notifications, chat + platform/ — admin, moderation, upload, embeddings + + AppModule imports core, queues, and the domain aggregators. + New engineers understand the system in 10 minutes. + +money/ DOMAIN IS ESPECIALLY CRITICAL: + LedgerEntry is the single source of truth for all money. + payment/, payout/, escrow/, refund/ all write through + ledger/LedgerService — never create LedgerEntry directly. + Reconciliation, audit, and compliance all query one table. + This is non-negotiable for financial correctness. +``` + +--- + +## 5. Database Schema + +> Full schema is defined in `apps/backend/prisma/schema.prisma`. +> Full model documentation is in `TWIZRR_DATA_ARCHITECTURE.md`. +> Below are the critical models and rules every agent must know. + +### Critical Schema Rules + +``` +ALL monetary values: BigInt in kobo (never Float) + ₦24,500 = 2450000n + Display as Naira ONLY in frontend — never in backend + +ALL primary keys: UUID (cuid() in Prisma) + +ALL phone numbers: E.164 format (+2348012345678) + +LedgerEntry: APPEND-ONLY — never UPDATE or DELETE any row + Row-level security enforces this at PostgreSQL level + +InventoryEvent: APPEND-ONLY — never UPDATE or DELETE any row + +SizeGuide records: NEVER created by store owners + Seeded by prisma/seed.ts at deployment only + +SourcedProduct.sellingPriceKobo: + MUST be >= product.dropshipperPriceKobo OR product.retailPriceKobo + DB CHECK constraint enforces this + API must also validate before insert/update + Error code: PRICE_BELOW_FLOOR + +Order.orderCode format: TWZ-XXXXXX (6 random digits) + e.g. TWZ-384729 + Never use any other format + +StoreProfile.storeType (DIGITAL | PHYSICAL): + NEVER return this field in any shopper-facing API response + Internal only — affects routing and logistics, not public display + +SourcedProduct.physicalStoreId and physicalProductId: + NEVER return in any shopper-facing API response + Physical store is always invisible to shoppers +``` + +### Key Models (summary) + +``` +User — universal account (shopper + store owner) +StoreProfile — attached to User when they open a store +Product — store's listed item (listing IS inventory) +ProductVariant — size + color combinations per product +ProductImage — up to 5 images per product, with variant assignment +ProductSizeGuideConfig — links product to platform size guide +ProductEmbedding — 1408-dim Vertex AI vector in pgvector +InventoryEvent — append-only stock event log +ProductStockCache — fast current stock reads per variant +SourcedProduct — digital store's listing of a physical store product +Post — PRODUCT_POST | IMAGE_POST | GIST | TWIZZ +Order — full order lifecycle +CartItem — shopper's cart (persisted to DB) +Payment — Paystack payment record +Payout — bank transfer to store +LedgerEntry — append-only financial audit log +SizeGuide — platform-managed (seeded, not store-created) +ModerationLog — every Cloud Vision decision recorded +WhatsAppAnalytics — AI interaction signals (captured from day 1) +AuditLog — every OPERATOR/ADMIN action recorded +``` + +--- + +## 6. Environment Variables + +```bash +# DATABASE +DATABASE_URL= # Neon pooled connection (app queries via PgBouncer) +DIRECT_URL= # Neon direct connection (migrations only — never for app) + +# REDIS +REDIS_URL= # Redis protocol URL (redis:// or rediss://) for BullMQ + caching + rate limiting + +# AUTH +JWT_SECRET= # JWT signing secret — rotate if exposed +JWT_EXPIRES_IN= # e.g. 15m (access token) +JWT_REFRESH_EXPIRES_IN= # e.g. 7d (refresh token) + +# PAYSTACK +PAYSTACK_SECRET_KEY= # Live secret key — server-side only, never client +PAYSTACK_PUBLIC_KEY= # Publishable key — safe for client +PAYSTACK_WEBHOOK_SECRET=# HMAC signature secret for webhook verification + +# CLOUDINARY +CLOUDINARY_CLOUD_NAME= # Cloud name +CLOUDINARY_API_KEY= # API key +CLOUDINARY_API_SECRET= # API secret — server-side only + +# GOOGLE AI +GEMINI_API_KEY= # From Google AI Studio — Gemini 2.5 Flash +GOOGLE_CLOUD_PROJECT= # GCP project ID (twizrr-ai) +GOOGLE_APPLICATION_CREDENTIALS= # Path to service account JSON + +# META / WHATSAPP +WHATSAPP_TOKEN= # Meta Cloud API access token +WHATSAPP_APP_SECRET= # For webhook HMAC verification +WHATSAPP_VERIFY_TOKEN= # Webhook verification token (set in Meta dashboard) +WHATSAPP_PHONE_NUMBER_ID= # The phone number ID for the WhatsApp account + +# AFRICA'S TALKING +AT_USERNAME= # Africa's Talking username +AT_API_KEY= # Africa's Talking API key +AT_SENDER_ID= # SMS sender ID (e.g. twizrr) + +# RESEND +RESEND_API_KEY= # Resend email API key + +# PREMBLY +PREMBLY_API_KEY= # Prembly KYB API key +PREMBLY_APP_ID= # Prembly app ID + +# SHIPBUBBLE +SHIPBUBBLE_API_KEY= # Shipbubble logistics API key + +# GOOGLE PLACES +GOOGLE_PLACES_API_KEY= # For store address autocomplete + +# APP +NODE_ENV= # development | staging | production +PORT= # Backend port (default 4000) +CORS_ORIGINS= # Comma-separated allowed origins +APP_URL= # Backend base URL (e.g. https://api.twizrr.com) +WEB_URL= # Frontend base URL (e.g. https://twizrr.com) +``` + +--- + +## 7. API Routes + +> Full route map is in `apps/backend/AGENTS.md`. +> All routes follow this envelope: + +```json +// Success +{ "success": true, "data": {}, "message": "optional" } + +// Error +{ "success": false, "message": "Human-readable error", "code": "ERROR_CODE" } +``` + +### Route Prefix Groups + +``` +/auth/* — signup, login, OTP, refresh token (public) +/users/* — user profile, measurements, addresses (protected) +/stores/* — store CRUD, settings, KYB (protected — store owners) +/products/* — product CRUD, search (mixed public/protected) +/sourced/* — sourcing catalogue, SourcedProduct CRUD (protected — digital stores) +/posts/* — post CRUD, feed (mixed) +/feed/* — personalised feed algorithm (protected) +/search/* — hybrid product/store/post search (mixed) +/cart/* — CartItem management (protected — shoppers) +/orders/* — order lifecycle (protected) +/payments/* — Paystack checkout, DVA (protected) +/payouts/* — payout history, bank details (protected — store owners) +/delivery/* — Shipbubble, delivery preview (mixed) +/whatsapp/* — webhook handler (public — HMAC verified) +/upload/* — Cloudinary upload with moderation (protected) +/notifications/* — in-app notifications (protected; live push via socket.io `notification:new`) +/chat/* — NOT BUILT YET (will be REST + the socket.io realtime gateway) +/verification/* — KYB flows, Prembly (protected — store owners) +/disputes/* — dispute filing and management (protected) +/admin/* — OPERATOR + SUPER_ADMIN only +/health — health check (public — UptimeRobot) +``` + +--- + +## 8. Route Protection + +```typescript +// Auth is NOT a global guard in NestJS +// Routes without @UseGuards(JwtAuthGuard) are PUBLIC by default +// The only global guard is ThrottlerGuard (rate limiting) + +// Protected route pattern: +@UseGuards(JwtAuthGuard) +@Get('profile') +getProfile(@CurrentUser() user: User) { } + +// Public route (no guard needed — just no decorator): +@Get('products') +getProducts() { } + +// Role-based: +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.SUPER_ADMIN) +@Get('admin/users') +getUsers() { } + +// Rate limit bypass (for webhooks): +@SkipThrottle() +@Post('whatsapp/webhook') +handleWebhook() { } + +// Roles in system: +// USER — all regular accounts (shoppers + store owners) +// OPERATOR — moderation, disputes, payout monitoring +// SUPER_ADMIN — full platform control +// SUPPORT — read-only access +``` + +--- + +## 9. MVP Scope + +> Full MVP specification is in `TWIZRR_MVP_SCOPE.md`. +> Everything in that document must work before launch. +> Below is the quick reference for agents. + +### Build in MVP (do build these): +- User signup + store setup +- Product listing (4-screen form with variants, size guide, specs) +- Source Products catalogue + SourcedProduct (dropship flow) +- Paystack escrow payments (card + DVA) +- Order lifecycle + dispatch + 6-digit delivery OTP +- WhatsApp AI assistant (Gemini + Cloud Vision + Vertex AI + pgvector) +- 6 Meta WhatsApp template messages +- Cloud Vision SafeSearch moderation (before every Cloudinary upload) +- Vertex AI embedding generation (BullMQ job after product listed) +- KYB verification (Tier 1 auto, Tier 2 NIN via Prembly) +- Store mode workspace (Home/feed, My Store, Products, Orders, Payouts, Analytics, Settings) +- User profile menu (Orders, Cart, Wishlist, Saved, Profile, Settings, etc.) +- Shipbubble delivery integration + delivery preview on product page +- Platform-managed size guides (SizeGuide seeded at deployment) +- Basic dispute filing +- In-app notifications + email (Resend) +- Admin moderation queue + dispute resolution tools + +### Do NOT build in MVP (these are deferred): +- Mobile app (Expo) — web only at MVP +- Twizz (video posts) — show "Coming Soon" tab only +- Review system — deferred (needs order history first) +- Inspection system — Mustakheem manually verifies at MVP +- Premium subscription — show "Coming Soon" in store menu +- Push notifications — email + WhatsApp templates only +- Size recommendation (user measurements → system suggests) +- Advanced analytics charts — basic numbers only +- Drops creation system — entry point only, not functional +- Tier 3 CAC verification (Prembly) — Tier 1 + Tier 2 only at MVP +- SMS fallback — Africa's Talking SMS deferred to Phase 3 +- Supplier features (B2B) — never, not in any phase +- RFQ, Quote, TradeFinancing modules — delete from codebase + +--- + +## 10. Business Rules + +> These are non-negotiable. Violating these will cause financial or trust damage. + +``` +RULE 1 — MONEY IS ALWAYS BIGINT KOBO: + All monetary values: BigInt in kobo + ₦24,500 = 2450000n (BigInt) + Never use Float, Decimal, or Number for money + Convert to Naira ONLY in frontend for display + +RULE 2 — PAYSTACK WEBHOOK MUST BE VERIFIED: + Every Paystack callback: verify HMAC signature FIRST + If signature invalid: return 400, log, do nothing + Never process payment events without verification + Idempotency keys on all Payment and Payout records + +RULE 3 — ESCROW ON EVERY ORDER: + Money never goes directly to store + All payments: held in twizrr Paystack balance + Released only: after 6-digit OTP confirmed OR 48hr auto-confirm + +RULE 3A — TWIZRR OWNS DELIVERY: + All marketplace delivery is Twizrr-managed by default + Checkout does not expose old store-delivery or personal-delivery choices + Checkout requires a delivery address and uses User.phone as the default primary delivery phone + Checkout may collect an optional secondary delivery phone + Shopper delivery phones are private platform data + Store owners do not see shopper phone numbers by default + Delivery phone/address may be shared only with approved logistics providers when required + Store owners prepare orders and trigger Ready for pickup / Book delivery through twizrr + Delivery fee is calculated by twizrr from fulfillment pickup point to shopper dropoff + Physical product pickup = physical store pickup address + Dropship pickup = source physical supplier pickup address, never digital dropshipper address + +RULE 4 — STORE TYPE IS NEVER PUBLIC: + StoreProfile.storeType (DIGITAL | PHYSICAL) + Remove from ALL shopper-facing API responses + DTOs must exclude this field for public endpoints + Violation exposes business model to competitors + +RULE 5 — PHYSICAL STORE IS ALWAYS INVISIBLE FOR SOURCED PRODUCTS: + SourcedProduct.physicalStoreId: NEVER in shopper-facing response + SourcedProduct.physicalProductId: NEVER in shopper-facing response + Shopper always sees the digital store as seller + +RULE 6 — DROPSHIPPER PRICE FLOOR (HARD RULE): + SourcedProduct.sellingPriceKobo MUST be >= floor + Floor = product.dropshipperPriceKobo OR product.retailPriceKobo + Enforce at: + 1. DB: CHECK constraint in schema + 2. API: validate before insert/update, throw 400 PRICE_BELOW_FLOOR + 3. Frontend: input minimum + button disabled state + +RULE 7 — PLATFORM FEE ON DROPSHIP ORDERS: + 2% on FULL order value — charged ONCE + Deducted from digital store margin ONLY + Physical store receives full wholesale amount (no fee) + +RULE 8 — CONTENT MODERATION BEFORE CLOUDINARY: + Every image upload must go through Cloud Vision SafeSearch FIRST + BLOCKED images: reject before Cloudinary upload + SENSITIVE: upload with flag — blur for adults, hide from isMinor users + SAFE: upload normally + Never skip this step for any image + +RULE 9 — WHATSAPP = SHOPPING ONLY: + WhatsApp AI serves shoppers — never store management + Store owners: web store workspace only + Never add store management endpoints to WhatsApp module + +RULE 10 — NO EMOJIS ANYWHERE: + UI: icons only (Lucide React) + WhatsApp messages: plain text only + Push notifications: text only + Email subjects: text only + Violation breaks brand consistency + +RULE 11 — LEDER ENTRIES AND INVENTORY EVENTS ARE APPEND-ONLY: + Never UPDATE or DELETE rows in LedgerEntry or InventoryEvent + These are financial + audit records + Row-level security enforces this at DB level + +RULE 12 — PRISMA MIGRATIONS: + Development: npx prisma migrate dev --name description + Shared envs (staging, beta, production): npx prisma migrate deploy ONLY + NEVER: npx prisma db push on any shared environment + NEVER: npx prisma migrate dev on staging/beta/production + NEVER: run prisma seed on production +``` + +--- + +## 11. Naming Conventions + +``` +PACKAGE SCOPE: @twizrr/ (not @swifta/, not @hardware-os/) +BRAND NAME: twizrr (lowercase — not Twizrr, not TWIZRR) + In headings/UI: twizrr (always lowercase in copy) + In code: Twizrr (PascalCase for class names) + +DATABASE: + Column names: snake_case via Prisma @@map() + Table names: snake_case via Prisma @@map() + Model names: PascalCase in schema.prisma + +TYPESCRIPT: + Variables: camelCase + Types/Interfaces: PascalCase + Enums: PascalCase (e.g. OrderStatus.PAID) + Enum values: SCREAMING_SNAKE_CASE (e.g. PENDING_PAYMENT) + Constants: SCREAMING_SNAKE_CASE + +API ROUTES: kebab-case (/store/source-products, /buyer/orders) +GIT BRANCHES: kebab-case (feat/product-listing, fix/payout-retry) +ENV VARIABLES: SCREAMING_SNAKE_CASE + +NestJS MODULES: + Service: ProductService + Controller: ProductController + Module: ProductModule + DTO: CreateProductDto, UpdateProductDto + Guard: JwtAuthGuard, RolesGuard + Interceptor: ResponseTransformInterceptor + +MONEY FIELDS: Always suffix with Kobo (retailPriceKobo, payoutAmountKobo) +IDs: Always suffix with Id (storeId, productId, orderId) +BOOLEANS: Prefix with is/has/can/should (isMinor, hasVariants, isActive) +DATES: Suffix with At (createdAt, verifiedAt, dispatchedAt) +``` + +--- + +## 12. Coding Rules + +1. **TypeScript strict mode always** — no `any` types. Use `unknown` with type guards when needed. + +2. **Money is always BigInt** — never Float, Decimal, or Number for monetary values. Store in kobo. Display as Naira in frontend only. + +3. **No `console.log` in production code** — backend: use NestJS Logger (pino). Frontend: `if (process.env.NODE_ENV === 'development')` guard. + +4. **Validate all input** — backend: `class-validator` + `class-transformer` on every DTO. Frontend: Zod schemas. Never trust client input. + +5. **pnpm only** — never use npm or yarn. If you see a `package-lock.json` or `yarn.lock` — delete it. + +6. **Auth check first** — on every protected endpoint, verify JWT before any business logic runs. + +7. **Paystack HMAC before any payment processing** — verify signature on every webhook call. Reject and log if invalid. + +8. **Cloud Vision before Cloudinary** — every image upload must be moderated first. Never upload to Cloudinary without a SAFE or SENSITIVE result from Cloud Vision. + +9. **Use ORM for all database queries** — Prisma only. No raw SQL unless absolutely necessary and approved. Document any raw SQL with a comment explaining why. + +10. **All API responses use the envelope format:** + ```json + { "success": true, "data": {}, "message": "optional" } + { "success": false, "message": "error description", "code": "ERROR_CODE" } + ``` + The `ResponseTransformInterceptor` handles this automatically. Use `@Res()` only when you need to bypass it (e.g. USSD callbacks, WhatsApp webhook verification). + +11. **BullMQ for all async jobs** — never do payouts, notifications, or embeddings synchronously. Always queue them. + +12. **Idempotency keys on payments** — every Payment and Payout record must have an idempotency key. Check before processing to prevent duplicates. + +13. **Use design tokens, not hardcoded values** — frontend: CSS variables only. Never hardcode hex colours, font sizes, or spacing values. + +14. **Mobile-first CSS** — all layouts start at 375px. Use `sm:`, `md:`, `lg:` Tailwind breakpoints for wider layouts. + +15. **No emojis anywhere** — icons only in UI (Lucide React). Plain text in WhatsApp messages. Zero tolerance. + +16. **Server Components by default in Next.js** — use `'use client'` only when component needs interactivity (hooks, event handlers, browser APIs). Do not add `'use client'` to pages or layouts unless required. + +17. **Environment variables** — never hardcode any service URL, API key, or secret. Always use `process.env.VARIABLE_NAME`. All required vars must be in `.env.example`. + +18. **Before committing — run all 6 checks:** + ```bash + cd apps/backend && pnpm run lint && npx tsc --noEmit && pnpm run build + cd apps/web && pnpm run lint && npx tsc --noEmit && pnpm run build + ``` + All six must pass with zero errors before opening a PR. + +19. **Never push directly to `main` or `dev`** — always use feature branches. PRs only. + +20. **If something is not in AGENTS.md or the system documents — ask before building it.** Do not invent features, schemas, or business logic. The 30 system documents are the source of truth. + +--- + +## 13. NestJS — Critical Patterns + +```typescript +// GLOBAL MIDDLEWARE (main.ts) — do not change without instruction: +// BigInt JSON serialization fix +BigInt.prototype.toJSON = function() { return this.toString() } +// Raw body for Paystack HMAC +app.use(rawBodyMiddleware()) +// Structured logging +app.useLogger(logger) // nestjs-pino +// Validation +app.useGlobalPipes(new AppValidationPipe()) // whitelist: true, transform: true +// Exception filter +app.useGlobalFilters(new GlobalExceptionFilter()) // P2002→409, P2025→404 +// Response envelope +app.useGlobalInterceptors(new ResponseTransformInterceptor()) +// Rate limiting (global guard) +app.useGlobalGuards(new ThrottlerGuard()) // 100 req/min globally +// Security headers +app.use(helmet()) +// Cookie parser +app.use(cookieParser()) +// CORS +app.enableCors({ origin: CORS_ORIGINS, credentials: true }) + +// BYPASSING ResponseTransformInterceptor: +// Use @Res() and res.send() directly (USSD callback, WhatsApp webhook) + +// BYPASSING ThrottlerGuard: +@SkipThrottle() + +// PRISMA ERROR CODES TO HANDLE: +// P2002 → 409 Conflict (unique constraint) +// P2025 → 404 Not Found +// P2003 → 400 Foreign key constraint + +// NEON CONNECTION: +// App queries: DATABASE_URL (pooled via PgBouncer) +// Migrations: DIRECT_URL (direct — never pool for migrations) +``` + +--- + +## 14. BullMQ Queues + +> All async jobs go through BullMQ. Never do these synchronously. + +| Queue name | Purpose | +|---|---| +| `embedding-generation` | Vertex AI embedding after product listed | +| `payout` | Paystack bank transfer to store | +| `payout-retry` | Retry failed payouts (4 attempts) | +| `notification` | In-app + email notification dispatch | +| `whatsapp` | Async WhatsApp message processing | +| `auto-confirm` | Auto-confirm order 48hrs after dispatch | +| `auto-confirm-warning` | Warning notification 12hrs before auto-confirm | +| `floor-check` | Check SourcedProduct prices when floor changes | +| `moderation` | Async moderation review queue for OPERATOR | +| `delivery-estimate` | Cache delivery estimates from Shipbubble | +| `reconciliation` | Hourly Paystack balance vs LedgerEntry check | +| `restock-alert` | Notify store when variant hits low stock threshold | +| `order-expiry` | Cancel PENDING_PAYMENT orders after 30 minutes | +| `review-prompt` | Prompt shopper to review 24hrs after COMPLETED | +| `session-cleanup` | Remove expired sessions from DB | +| `audit-flush` | Flush AuditLog buffer to DB | + +--- + +## 15. Data Flows + +### Product Listed → Searchable on WhatsApp + +``` +Store owner publishes product (POST /products) + ↓ +Product + variants created in DB +InventoryEvent (INITIAL_STOCK) per variant logged +ProductStockCache populated +Post (PRODUCT_POST) created with taggedProductId + ↓ +BullMQ: embedding-generation job queued + ↓ +Vertex AI Multimodal Embeddings API called +1408-dim vector stored in ProductEmbedding table +Product.embeddingReady = true + ↓ +Product now appears in: +- Web feed (immediately on publish) +- Web search (immediately) +- WhatsApp AI hybrid search (after embeddingReady = true, ~30s) +``` + +### Shopper Buys via WhatsApp + +``` +Shopper sends message/photo to WhatsApp + ↓ +Meta webhook → POST /whatsapp/webhook (HMAC verified) +Gemini 2.5 Flash: intent parsed, filters extracted +Text query: Vertex AI text embedding generated for the query +Image query: Cloud Vision labels extracted (context/fallback) + + Vertex AI multimodal image embedding (primary signal) + ↓ +Text discovery: hybrid ranking contract + final_score = vector_similarity * 0.5 + text_rank * 0.2 + + store_performance * 0.2 + tier_boost * 0.1 +Image discovery: pgvector image-embedding similarity (primary) +Tier 0 stores excluded always + ↓ +WhatsApp List Message + canonical product links + /stores/{storeHandle}/p/{productCode} +Shopper selects product (list row, number, or title — Redis recent results) +Shopper completes checkout on the web product page + ↓ +Shopper pays via Paystack +Paystack webhook → POST /payments/webhook (HMAC verified) +Order status: PENDING_PAYMENT → PAID +LedgerEntry: PAYMENT_IN logged +Escrow holds full amount + ↓ +BullMQ: auto-confirm warning job (DISPATCHED + 36hrs) +BullMQ: auto-confirm job (DISPATCHED + 48hrs) +WhatsApp template: order_confirmed sent to shopper +Store notified: new order (in-app + email) + ↓ +Store marks ready for pickup → Twizrr books delivery → Order: DISPATCHED +Shopper: WhatsApp template order_dispatched + ↓ +Shopper taps [I received it] OR enters 6-digit code +Order: DISPATCHED → DELIVERED → COMPLETED +LedgerEntry: ESCROW_RELEASE logged + ↓ +BullMQ: payout job queued +Paystack Transfer API → store bank account +LedgerEntry: PAYOUT logged +WhatsApp template: payment_received sent to store +``` + +### Image Upload (Product Photo or Post Image) + +``` +Client uploads image to POST /upload + ↓ +Cloud Vision SafeSearch API called FIRST + ↓ +BLOCKED → reject (400), log to ModerationLog, return error to client +SENSITIVE → upload to Cloudinary with sensitive flag, + return URL with moderationStatus: SENSITIVE +SAFE → upload to Cloudinary normally, + return URL with moderationStatus: SAFE + ↓ +Client receives Cloudinary URL +Uses URL in product/post creation payload +``` + +### Dropship Order → Two Payouts + +``` +Shopper buys from digital store (@yabafashion) → pays ₦40,000 + ↓ +System creates 2 linked Orders: + Order A: shopper ↔ @yabafashion (customer-facing, ₦40,000) + Order B: @yabafashion ↔ @balogunstore (fulfillment, ₦28,000) + ↓ +@balogunstore notified: "New fulfillment order — prepare for pickup" +Twizrr-managed delivery picks up from @balogunstore and delivers to shopper + ↓ +Shopper confirms delivery + ↓ +BullMQ: payout job #1 + Paystack Transfer → @balogunstore bank (₦28,000, no fee) + LedgerEntry: PAYOUT for physical store + +BullMQ: payout job #2 + Platform fee: 2% × ₦40,000 = ₦800 + Digital store receives: ₦40,000 - ₦28,000 - ₦800 = ₦11,200 + Paystack Transfer → @yabafashion bank (₦11,200) + LedgerEntry: PAYOUT for digital store +``` + +--- + +## 16. WhatsApp AI (WIZZA) Rules + +``` +WHAT IT IS: WIZZA — Twizrr's AI shopping assistant on WhatsApp, not a bot +PURPOSE: help shoppers search, discover, and buy products +NOT FOR: store management, order dispatch, inventory updates +Store-management requests are redirected: + "WIZZA is for shopping. Manage your store from your twizrr store workspace." + +CONVERSATION CONTEXT: +- Every session must start with consent message (NDPR compliance) +- Shopper must accept before any product search begins +- Session stored in WhatsAppSession model (linked to User via phone) + +RESPONSE RULES: +- Plain text ONLY — no emojis, no markdown, no bullet points in messages +- Gemini function-calling ONLY — never free-form text responses to shoppers +- If search returns 0 results: acknowledge + suggest alternatives +- If AI fails: graceful fallback to text keyword search — never leave shopper stranded + +TEMPLATE MESSAGES (6 pre-approved — submit to Meta before launch): + twizrr_onboarding_consent — first message to new shopper + twizrr_order_confirmed — after payment succeeds + twizrr_order_dispatched — after store marks dispatched + twizrr_confirm_delivery — with [Yes, I received it] button + twizrr_payment_received — to store owner on payout + twizrr_account_linked — when shopper links WhatsApp to account +``` + +### Current WhatsApp/WIZZA Discovery Architecture + +``` +TEXT DISCOVERY RANKING (implemented — approved hybrid contract): + final_score = vector_similarity * 0.5 + text_rank * 0.2 + + store_performance * 0.2 + tier_boost * 0.1 + + vector_similarity — pgvector cosine similarity between the Vertex AI + text embedding of the query and product embeddings + text_rank — normalized title/name/description/category match + store_performance — order completion rate normalized to [0, 1]; + stores with insufficient history default to 0.5 + (new eligible stores are never penalized to zero) + tier_boost — TIER_2 = 1.0, TIER_1 = 0.5 + + Weights are configurable via env and validated at startup: + SEARCH_WEIGHT_VECTOR=0.5 + SEARCH_WEIGHT_TEXT=0.2 + SEARCH_WEIGHT_PERFORMANCE=0.2 + SEARCH_WEIGHT_TIER=0.1 + Each weight must be numeric and >= 0; the four weights must sum to 1.0 + (tiny float tolerance). Invalid weights fail startup. + +IMAGE SEARCH (implemented — embedding-first with SafeSearch screening): + 1. Download shopper image from Meta (never saved to disk) + 2. SafeSearch screening BEFORE any discovery — block on LIKELY or + VERY_LIKELY for adult/violence/racy (same thresholds as upload + moderation; POSSIBLE and below never block; medical/spoof are + not blocking categories unless the upload policy changes). + Blocked → safe refusal copy, no labels/embedding/search/Redis + write; analytics record errorType IMAGE_BLOCKED_UNSAFE with + zeroResults false, vectorSearchUsed false, embeddingModel null. + Screening errors fail open (masked log, search continues). + 3. Cloud Vision label/object/text extraction (context + fallback only) + 4. Vertex AI multimodal image embedding (1408-dim) — PRIMARY signal + 5. pgvector similarity retrieval over product embeddings + 6. Eligibility re-validated (active/public/in-stock rules, Tier 0 excluded) + 7. Embedding failure degrades gracefully to safe label fallback; + label failure does not block vector search + +TIER 0 EXCLUSION: always filter WHERE store.tier != 'TIER_0' +No exceptions — Tier 0 stores never appear in any WIZZA result + +REDIS RECENT-PRODUCT SELECTION: + The last shown results (text or image search) are cached per phone. + Shoppers can pick a result by list row tap, number ("2", "the first + one"), or product title. The cached payload holds safe public fields + only — never listingCode, sourceCode, physical/source store data, + or cost/margin fields. + +ANALYTICS V2: + Every discovery interaction records honest values for: + vectorSearchUsed, embeddingModel, productsRankedCount, + productsShown/productsShownCount, zeroResults (true only when a real + search returned nothing — never for embedding/label errors), and + fallback/error state. No raw image data, secrets, or prompt values. +``` + +### Public Product Identity and URL Model + +``` +CANONICAL PRODUCT URL (the only public product link format): + /stores/{storeHandle}/p/{productCode} + +NATIVE products: native public productCode + selling store handle +SOURCED listings: sourced listing public productCode + the + digital/reseller store handle + +NEVER use as public URL tokens or in WIZZA replies: + productId, sourcedProductId, sourceCode, listingCode, + physicalProductId, physicalStoreId + (sourceCode is internal/store-owner-facing only) + +If a safe canonical URL cannot be built (missing handle or productCode): + send no link — never fabricate one or fall back to internal ids. +``` + +### Sourced Listing Shopper-Visibility Model + +``` +Sourced listings appear to shoppers as normal products sold by the +digital/reseller Twizrr store. The digital store is the seller context +for display name, handle, URLs, and ranking (store performance + tier). + +Source/supplier/dropship/physical-store relationships are INTERNAL: +- never expose supplier/source store handles, ids, contact, or address +- never expose cost, margin, linked fulfilment, or source inventory +- never use source/supplier/dropship/partner-store/physical-store + wording in shopper copy +- store type (DIGITAL | PHYSICAL) remains invisible to shoppers +``` + +### Store Identity Privacy + +``` +storeName — PUBLIC store display name +storeHandle — PUBLIC username / URL handle (canonical URLs use this) +businessName — PRIVATE internal/legal/KYB/admin/bank identity; + never selected or exposed in WIZZA shopper-facing replies + +Shopper-facing store display fallback: + storeName → storeHandle → "twizrr store" +``` + +--- + +## 17. GitHub Workflow + +### Branch structure + +``` +main → production (twizrr.com / api.twizrr.com) +staging → pre-production testing +dev → integration (all feature PRs merge here first) +feature/* → individual features (branch from dev) +fix/* → bug fixes (branch from dev) +chore/* → config, tooling, cleanup +``` + +### How to contribute + +```bash +# 1. Always branch from dev: +git checkout dev && git pull origin dev +git checkout -b feat/product-listing-form + +# 2. Build the feature + +# 3. Run all 6 checks before committing: +cd apps/backend && pnpm run lint && npx tsc --noEmit && pnpm run build +cd apps/web && pnpm run lint && npx tsc --noEmit && pnpm run build + +# 4. Open PR: feat/* → dev +# CI must pass, CodeRabbit reviews automatically +# Human approval required before merge + +# 5. Merged to dev → deploys to staging for QA + +# 6. PR: dev → main → deploys to production +``` + +### Branch protection + +- `main`, `staging`, `dev` — require PR, CI passing, reviewer approval, no direct pushes +- Feature branches — push freely + +### Commit message format (Conventional Commits) + +``` +feat(scope): new feature +fix(scope): bug fix +perf(scope): performance improvement +chore(scope): config, deps, tooling +refactor(scope): restructure, no behaviour change +docs(scope): documentation only +test(scope): tests only + +Examples: +feat(product): add variant image assignment to listing form +fix(payout): handle Paystack transfer timeout correctly +chore(deps): upgrade Prisma to 5.15 +``` + +--- + +## 18. Error Handling + +### Response envelope + +```typescript +// All responses (handled by ResponseTransformInterceptor): +{ success: true, data: T, message?: string } +{ success: false, message: string, code?: string } + +// Common error codes: +PRICE_BELOW_FLOOR // SourcedProduct price below cost +INSUFFICIENT_STOCK // Variant out of stock +WEBHOOK_INVALID // Paystack HMAC mismatch +STORE_TYPE_MISMATCH // Wrong store type for action +TIER_REQUIREMENT_FAILED // Store doesn't meet tier requirements +OTP_INVALID // Wrong 6-digit delivery code +OTP_EXPIRED // Delivery code expired (48hr) +RATE_LIMIT_EXCEEDED // Too many requests +``` + +### NestJS exception pattern + +```typescript +// Always use NestJS built-in exceptions: +throw new BadRequestException({ message: 'Price below minimum', code: 'PRICE_BELOW_FLOOR' }) +throw new NotFoundException({ message: 'Product not found' }) +throw new UnauthorizedException({ message: 'Invalid token' }) +throw new ForbiddenException({ message: 'Store owners only' }) +throw new ConflictException({ message: 'Handle already taken' }) + +// GlobalExceptionFilter catches Prisma errors: +// P2002 (unique) → 409 +// P2025 (not found) → 404 +// P2003 (foreign key) → 400 +``` + +--- + +## 19. Security + +- Never commit secrets — `.env` files are gitignored +- All PRs require human review before merging to dev or main +- Auth, schema changes, and payment logic need explicit approval before coding agents proceed +- Never auto-merge agent-generated code that touches payments, webhooks, or auth +- Rotate keys immediately if any credential is exposed +- Paystack webhook: always verify HMAC — reject if invalid, log to AuditLog +- Rate limiting: ThrottlerGuard global (100/min) + per-endpoint limits in TWIZRR_DEVELOPMENT_RULES.md +- CORS: only allow origins in CORS_ORIGINS env var — never wildcard `*` on shared environments +- Security contact: aliameen@codeddevs.com + +--- + +## 20. Codebase History (Important) + +``` +This codebase was previously the Swifta project. +Swifta was a B2B hardware marketplace that pivoted to twizrr. + +WHAT THIS MEANS FOR YOU (coding agent): +- You may encounter old file names, comments, or variables referencing "Swifta" +- You may find modules for: RFQ, Quote, TradeFinancing, Supplier, hardware categories +- You may find schema models that no longer apply +- You may find route structures that differ from what AGENTS.md describes + +WHAT TO DO: +- Follow AGENTS.md — not the old code +- If you see a conflict between the old code and AGENTS.md: AGENTS.md wins +- Do not extend, use, or reference any of these old modules: + rfq/, quote/, trade-financing/, supplier/ +- The cleanup task document will handle removing old code formally +- For now: build new features in new modules, do not modify legacy code + +PACKAGE SCOPE HISTORY: +- Old code may use @swifta/ or @hardware-os/ +- All new code must use @twizrr/ +- Do not rename existing references unless cleanup task says so +``` + +--- + +## 21. Per-App Instructions + +``` +After reading this root AGENTS.md — read your app's AGENTS.md: + + apps/backend/AGENTS.md — full NestJS module map, Prisma schema + patterns, BullMQ job details, API routes + apps/web/AGENTS.md — Next.js App Router structure, all pages, + component patterns, design token usage +``` + +--- + +## 22. Company Details + +| Field | Value | +|---|---| +| Company | CODEDDEVS TECHNOLOGY LTD | +| Location | Lagos, Nigeria | +| Website | codeddevs.com | +| Product | twizrr — twizrr.com | +| Social | @twizrrapp (all platforms) | +| Support email | support@twizrr.com | +| Dev email | aliameen@codeddevs.com | + +--- + +*Last updated: June 2026* +*Project: twizrr* +*Maintained by: CODEDDEVS TECHNOLOGY LTD* diff --git a/DESIGN.md b/DESIGN.md deleted file mode 100644 index 431f9952..00000000 --- a/DESIGN.md +++ /dev/null @@ -1,67 +0,0 @@ -This Swifta V1 Design System Specification serves as the technical blueprint for implementing the high-contrast, utilitarian B2B platform across mobile and desktop. - -1. Core Philosophy & Aesthetic Rules -Mission: A "No-Nonsense" industrial tool for high-value commerce. The UI must feel as stable and heavy as the materials (cement, iron) being sold. -Theme: Strict Light Mode. High-contrast (minimum 4.5:1 ratio). -Visual Language: -Borders: Solid 1px or 1.5px borders (#D1D5DB or #E5E7EB). No "soft" borders. -Corners: Sharp (4px radius max) to convey precision and industrial strength. -Shadows: Submerged or flat shadows only. Use box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1). No large, blurry elevations. -Effects: Zero Glassmorphism, Zero Bubbly/Rounded UI, Zero gradients. - -2. Color Palette -Backgrounds: Primary: #FFFFFF (White), Secondary: #F9FAFB (Light Gray). -Brand Primary: #1E3A8A (Deep Industrial Blue) or #C2410C (Industrial Orange) for Call-to-Actions (CTAs). -Status Colors: -Success/Paid: #15803D (Forest Green). -Alert/Low Stock: #B91C1C (Strong Red). -In-Transit: #0369A1 (Steel Blue). -Typography: Primary: #111827 (Almost Black), Secondary: #4B5563 (Slate Gray). - -3. Layout & Navigation Patterns -Mobile Navigation: 5-item bottom bar (Catalogue, Quotes, Orders, Messages, Profile). -Desktop Navigation: 240px fixed left-side vertical navigation rail. Persistent and solid. -Command Center (State Machine): All order-related screens follow a strict 4-stage pipeline: -Pending Quotes: Negotiation phase. -Awaiting Dispatch: Escrow funded, stock preparation. -In Transit: Material moving, driver on-site. -Payout Completed: Transaction closed. - -4. Component Specifications -The Quote Card (RFQ Thread): -A structured container with explicit fields: Unit Price, Delivery Fee, Total, and Expiry Date. -Must use tabular alignment for numbers to look like an official invoice. -The OTP Pad (Escrow Release): -High-contrast 6-digit input. Each digit in a separate box with a 2px border. -Large, touch-friendly targets for use in outdoor, dusty construction environments. -The Data Table (Inventory/Management): -Dense, high-information rows. -Sticky headers. High-contrast row-hover state (#F3F4F6). -No "hidden" actions. Every row should have visible 'Edit' or 'Update' buttons. - -5. Critical Product Rules -No Price Visibility: Prices are never shown in the Public Catalogue. They only appear within the private Negotiation Thread or on a Quote Card. -CTA Priority: The primary action is always 'Request Quote' or 'Send RFQ'. There is no 'Cart' functionality. - -The Typography Scale & Text Hierarchy has been defined to ensure maximum legibility for merchants and contractors, even in high-glare outdoor environments: - -Typography Scale & Text Hierarchy: A technical documentation screen that specifies an industrial font system. It uses a clean, bold sans-serif for headings and a monospaced font for data-heavy fields (like SKUs and Naira amounts) to ensure tabular alignment and precision. -Swifta V1 Typography Scale -Role Desktop Size Mobile Size Weight Line Height Usage -H1 - Dashboard Title 32px 24px 700 (Bold) 1.2 Main Page Titles -H2 - Section Header 24px 20px 600 (Semi) 1.3 Kanban Columns, Card Titles -H3 - Card Subhead 18px 16px 600 (Semi) 1.4 Product Names, Escrow Balance -Body - Standard 16px 16px 400 (Reg) 1.5 Chat Threads, Product Details -Body - Small 14px 14px 400 (Reg) 1.5 Merchant Names, Metadata -Data / Monospace 14px 14px 500 (Med) 1.0 SKUs, Prices, OTP Codes -Button Text 16px 16px 600 (Semi) 1.0 Request Quote, Generate OTP -Implementation Note: Use Inter or System Sans-Serif for standard text and Roboto Mono or IBM Plex Mono for currency and SKU data to maintain that "banking/logistics" feel. - -6. Tech Stack & Implementation Standards -Next.js Architecture: -- Global layouts (MerchantHeader, MerchantSidebar) reside in `apps/web/src/components/layout/`. -- Merchant Dashboard modular components (KanbanColumn, KanbanRfqCard, KanbanOrderCard) must perfectly replicate the B2B Stitch design and reside in `apps/web/src/components/merchant/dashboard/`. -- Page views (like `page.tsx`) should act as data-fetching containers (`useMerchantDashboard`) and layout shells, passing data down to pure presentation components without injecting redundant old KPI grids unless explicitly designed. - -BigInt Serialization: -Our NestJS backend and shared types use `BigInt` for high-precision financial data (Naira/Kobo). In API boundaries and testing containers, ensure `BigInt.prototype.toJSON` is properly patched or manually converted to strings, as `JSON.stringify` natively throws errors on BigInt values. When extracting values on the frontend, always parse `totalAmountKobo` into a formatting utility (like `formatKobo` from `@swifta/shared`) rather than displaying raw integers. diff --git a/README.md b/README.md index d8d409c9..5e13cea2 100644 --- a/README.md +++ b/README.md @@ -1,210 +1,242 @@ -# Swifta +# twizrr -**Nigeria's WhatsApp-Native E-Commerce Platform** +twizrr is a Nigerian social commerce marketplace for shoppers and store owners. +Stores publish products and posts, shoppers discover items through the web app +and WhatsApp, and every checkout is protected by twizrr Buyer Protection. -Buy and sell anything on WhatsApp with escrow payment protection. Discover products, follow merchants, and shop securely — all from the app you already use. +This repository is a pnpm/Turborepo monorepo with: -🌐 **Website:** [swifta.store](https://swifta.store) -📱 **WhatsApp:** Message Swifta to start shopping +- `apps/backend` - NestJS API for auth, stores, products, orders, payments, delivery, notifications, WhatsApp, and admin operations. +- `apps/web` - Next.js App Router web app for the public landing site, shopper app, store workspace, and admin surfaces. +- `packages/shared` - shared TypeScript types, DTOs, and enums. ---- +## Requirements -## What is Swifta? +- Node.js 20+ +- pnpm +- PostgreSQL 16 with pgvector enabled +- Redis using a Redis protocol URL, `redis://` or `rediss://` +- Provider accounts for the integrations you enable locally -Swifta is a social commerce marketplace where buyers discover products through a scrollable feed, follow their favorite merchants, and purchase with escrow-protected payments. Merchants list products, manage orders, and get paid instantly to their bank account. +Do not use `npm` or `yarn` for this repo. -The platform works on two channels: -- **WhatsApp** — the primary channel for buyers. Search, buy, pay, track, and confirm delivery without leaving WhatsApp. -- **Web** — a social media-style product feed for discovery, plus full dashboards for merchants to manage their business. +## Local Setup -Every transaction is protected by escrow. The buyer's money is held securely until they confirm delivery with an OTP code. No trust required — the system handles it. +Install dependencies from the repo root: ---- +```powershell +pnpm install +``` -## How It Works +Start local infrastructure if you want Docker-managed services: -### For Buyers +```powershell +docker compose up -d postgres redis +``` -1. Browse the product feed or search for what you need -2. Follow (star) merchants you like — their products appear in your personalized feed -3. Tap **Buy Now** or add to cart -4. Pay securely via Paystack — your money is held in escrow -5. Track your delivery in real-time -6. Enter your OTP code to confirm receipt -7. Merchant gets paid. Rate your experience. +Docker is optional for local development. You can also point local env files at +managed PostgreSQL and Redis services. -### For Merchants +Copy env examples and fill only local/dev values: -1. Create your business page with a unique username -2. List products with photos, prices, and descriptions -3. Receive orders with instant WhatsApp notifications -4. Pack and dispatch — buyer gets tracking updates -5. Money lands in your bank account automatically after delivery confirmation +```powershell +Copy-Item apps/backend/.env.example apps/backend/.env.local +Copy-Item apps/web/.env.example apps/web/.env.local +``` -### On WhatsApp +Do not commit `.env.local` or real secrets. -Buyers can do everything through the Swifta WhatsApp AI assistant: -- **Text search:** "I need a phone case" → get matching products -- **Image search:** Send a photo of any product → AI identifies it and finds matches -- **Purchase:** Select, pay, track, and confirm — all in the chat -- **Merchant management:** Merchants check sales, update prices, and dispatch orders via WhatsApp +## Environment Notes ---- +Backend: -## Key Features +- `DATABASE_URL` is the pooled app connection string. +- `DIRECT_URL` is the direct database connection string for Prisma migrations. +- `REDIS_URL` must be `redis://...` or `rediss://...`. +- Do not use Upstash REST `https://...` URLs as `REDIS_URL`. -**Social Commerce Feed** — Instagram-style scrollable product feed. Follow merchants. Discover products. Buy in one tap. +Web: -**Escrow Payments** — Buyer's money is protected until delivery is confirmed via OTP. Powered by Paystack. +- `NEXT_PUBLIC_API_URL` points to the backend API base URL. +- `NEXT_PUBLIC_WEB_URL` is the product-app web origin. +- `NEXT_PUBLIC_MARKETING_URL` is the public marketing origin. +- `NEXT_PUBLIC_MARKETPLACE_ENABLED=false` keeps unfinished app/marketplace routes gated on public pre-launch environments. -**WhatsApp AI Assistant** — Three specialized bots (buyer, merchant, supplier) powered by Google Gemini with function-calling for intent parsing. +Use `apps/backend/.env.example` and `apps/web/.env.example` as the source for +supported env names. -**AI Image Search** — Send a product photo via WhatsApp. Google Cloud Vision identifies it, searches the catalogue, and returns matching products. +## Database -**Instant Bank Payouts** — Delivery confirmed → merchant's money is automatically transferred to their bank account via Paystack Transfers. +Validate the schema: -**Verified Merchants** — Tiered verification system (Unverified → Basic → Verified → Trusted) with document review and performance tracking. +```powershell +cd apps/backend +pnpm exec prisma validate --schema=prisma/schema.prisma +``` -**Multi-Category Marketplace** — Electronics, Fashion, Building Materials, Home & Kitchen, Health & Beauty, Auto Parts, Agriculture, Food & Groceries, and more. +Generate Prisma client: -**Ratings & Reviews** — Post-delivery review prompts via WhatsApp. Merchant ratings displayed on profiles and product cards. +```powershell +cd apps/backend +pnpm exec prisma generate --schema=prisma/schema.prisma +``` -**Real-Time Tracking** — Order status updates pushed to buyer via WhatsApp and in-app notifications. +Apply migrations to a shared/dev/staging/production database: -**Business Pages** — Every merchant gets a shareable profile page with their products, ratings, and verification status. +```powershell +cd apps/backend +pnpm exec prisma migrate deploy --schema=prisma/schema.prisma +``` ---- +Use `migrate dev` only for local migration authoring. Never use `db push` for +shared Twizrr environments. -## Tech Stack +## Run Locally -### Architecture +Backend: -``` -swifta/ -├── apps/ -│ ├── backend/ → NestJS 10 modular API -│ └── web/ → Next.js 14 App Router -├── packages/ -│ └── shared/ → Shared TypeScript types, DTOs, enums -├── docker-compose.yml → Local PostgreSQL & Redis -└── pnpm-workspace.yaml → Monorepo workspace config +```powershell +cd apps/backend +pnpm run dev ``` -### Core Technologies +Web: -| Layer | Technology | -|-------|-----------| -| Frontend | Next.js 14, React 18, Tailwind CSS, React Query, React Hook Form, Zod | -| Backend | NestJS 10, TypeScript, Prisma ORM | -| Database | PostgreSQL 16 (Supabase managed) | -| Cache & Queues | Redis (Upstash) + BullMQ | -| Payments | Paystack (Checkout, Transfers, Webhooks, Bank Resolution) | -| Messaging | Meta WhatsApp Business Cloud API (Interactive Messages) | -| AI | Google Gemini 2.0 Flash (intent parsing), Google Cloud Vision (image search) | -| Email | Resend | -| SMS | Africa's Talking | -| Image CDN | Cloudinary (with auto-optimization transforms) | -| Monitoring | Sentry (error tracking), Vercel Speed Insights | -| Deployment | Vercel (frontend), Render (backend), Supabase (database), Upstash (Redis) | +```powershell +cd apps/web +pnpm run dev +``` + +Default local URLs: -### Key Architecture Decisions +- Web: `http://localhost:3000` +- Backend: `http://localhost:4000` +- Backend health: `http://localhost:4000/health` -- **Monorepo** with Turborepo and pnpm workspaces -- **Append-only event tables** for inventory tracking (InventoryEvent → ProductStockCache) -- **All money stored as BigInt** (kobo) — no floating-point currency math -- **UUID primary keys** everywhere -- **BullMQ** for async job processing (payouts, notifications, auto-confirmation timers) -- **JWT authentication** with role-based access control (roles: Buyer, Merchant, Supplier) -- **Paystack webhook signature verification** on all payment callbacks -- **WhatsApp function-calling only** — AI can call predefined functions, never generates free-form responses to users +## Build ---- +Build the full workspace from the repo root: -## User Roles +```powershell +pnpm run build +``` -| Role | Access | -|------|--------| -| **Buyer** | Browse catalogue, purchase products, track orders, rate merchants | -| **Merchant** | List products, manage orders, receive payouts, manage business page. Can toggle to buyer mode to purchase from other merchants | -| **Supplier** | Wholesale product management, merchant orders (B2B — coming soon) | +Build only the backend and its workspace dependencies: ---- +```powershell +pnpm exec turbo run build --filter=@twizrr/backend... +``` -## Quick Start +Build only the web app and its workspace dependencies: -### Prerequisites +```powershell +pnpm exec turbo run build --filter=@twizrr/web... +``` -- Node.js >= 20 -- pnpm >= 8 (`npm install -g pnpm`) -- Docker Desktop (for local PostgreSQL & Redis) +Using Turbo for filtered builds matters because shared packages must be compiled +before dependent apps. -### Setup +## Deployment Model -```bash -# Clone the repository -git clone -cd swifta +Twizrr should stay deployment-provider-neutral. The codebase should not depend +on one host-specific configuration file or one provider-specific runtime path. -# Install dependencies -pnpm install +### Backend -# Start local database and Redis -docker-compose up -d +Deploy `apps/backend` as a Node.js 20+ service that can run the NestJS API and +the BullMQ processors required by the app. -# Set up environment variables -# Copy .env.example to .env in apps/backend and apps/web -# Fill in: Paystack keys, database URL, Redis URL, Gemini API key, etc. +Recommended generic backend lifecycle: -# Run database migrations and seed +```powershell +pnpm install --frozen-lockfile +pnpm exec turbo run build --filter=@twizrr/backend... cd apps/backend -npx prisma migrate dev -npx prisma db seed -cd ../.. - -# Start development servers -pnpm --filter @swifta/backend dev # Backend → http://localhost:4000 -pnpm --filter @swifta/web dev # Frontend → http://localhost:3000 +pnpm exec prisma migrate deploy --schema=prisma/schema.prisma +pnpm run start:prod ``` ---- +Backend host requirements: -## Security +- Runs Node.js 20+. +- Provides persistent process execution for the API. +- Provides environment variables securely. +- Connects to managed PostgreSQL and managed Redis. +- Exposes `/health` for readiness checks. +- Does not assume Redis is available at `localhost` in deployed environments. -- **Escrow payment protection** — buyer funds held until OTP-verified delivery -- **JWT authentication** with HttpOnly cookies and refresh token rotation -- **Role-based access control** with guard decorators on every endpoint -- **Paystack webhook signature verification** on all payment callbacks -- **WhatsApp AI guardrails** — function-calling only, no free-form AI responses -- **Rate limiting** via @nestjs/throttler on API endpoints -- **Input sanitization** against XSS on all user inputs -- **Staff access token system** — operators onboarded via secure token-based workflow -- **Audit logging** on admin actions +If an HTTP serverless platform is used for the API, BullMQ workers still need a +separate long-running worker process. Background queues should not rely on a +short-lived serverless request lifecycle. ---- +### Frontend -## Roadmap +Deploy `apps/web` as a Next.js 14 app. -**Current (V5):** Social commerce marketplace with WhatsApp AI, escrow payments, multi-category catalogue, ratings, and merchant business pages. +Recommended generic web lifecycle: -**Next (V6):** Security hardening (Prembly identity verification), Paystack Dedicated Virtual Accounts (eliminate payment link browser hop), dispute resolution center, admin dashboard upgrades, price intelligence, dark mode. +```powershell +pnpm install --frozen-lockfile +pnpm exec turbo run build --filter=@twizrr/web... +``` + +The same `apps/web` app can serve both public marketing and product-app domains +by changing env values. Do not create a separate marketing repo or a separate +marketing app unless the architecture changes explicitly. -**Future:** B2B supplier marketplace, trade financing, SwiftCoins loyalty program, group buying, Remotion video generation for merchant marketing. +### Domains ---- +- `twizrr.com` is the public marketing/information site. +- `app.twizrr.com` is the product app. +- `api.twizrr.com` points to the active backend API host. -## Team +The domain names are stable product boundaries; the infrastructure provider +behind each domain can change. -Built by **codedDevs** — a software development team based in Lagos, Nigeria. +## Deployment Checklist -- **Kareem Aliameen** — Product & Engineering Lead -- **Yusuf Saheed** — CTO & Engineering Lead +Before promoting a shared environment: +1. Confirm required env vars are present for backend and web. +2. Run Prisma migrations with `migrate deploy`. +3. Build with Turbo so workspace dependencies compile first. +4. Confirm backend `/health` is reachable. +5. Confirm Redis uses `redis://` or `rediss://`. +6. Confirm CORS origins match the web domains for that environment. +7. Confirm no `.env.local`, real secrets, private prompt values, or private invite links are committed. -📧 codeddevs.team@gmail.com -🐙 [github.com/coded-devs](https://github.com/coded-devs) +## Validation ---- +Common local checks: + +```powershell +pnpm run lint +pnpm run build +git diff --check +``` + +For backend-specific changes, also run: + +```powershell +cd apps/backend +pnpm run typecheck +pnpm exec prisma validate --schema=prisma/schema.prisma +pnpm exec prisma generate --schema=prisma/schema.prisma +``` + +For web-specific changes, also run: + +```powershell +cd apps/web +pnpm run typecheck +pnpm run build +``` -## License +## Safety Rules -Proprietary. All rights reserved. \ No newline at end of file +- Money is stored in kobo, never floating-point Naira values. +- Paystack webhooks must be verified before changing payment/order state. +- Delivery fees are not store revenue. +- Product and store uploads must follow the Cloudinary folder policy. +- WhatsApp is shopper-facing only; store management belongs in the web app. +- No real secrets, `.env.local` values, private prompt values, or private WhatsApp invite links should be committed. \ No newline at end of file diff --git a/TWIZRR_DEVELOPMENT_RULES.md b/TWIZRR_DEVELOPMENT_RULES.md new file mode 100644 index 00000000..7016ec5c --- /dev/null +++ b/TWIZRR_DEVELOPMENT_RULES.md @@ -0,0 +1,1233 @@ +# twizrr — Development Rules +> Last updated: May 2026 +> Status: Final — approved by Aliameen (CEO) +> Purpose: Single source of truth for how twizrr is built — GitHub workflow, branch rules, commit standards, CI/CD pipeline, Neon database workflow, migration rules, code style, pre-deployment checklist, and hotfix process. +> Related documents: TWIZRR_STORAGE_INFRASTRUCTURE.md | TWIZRR_SCALE_ARCHITECTURE.md | TWIZRR_DATA_ARCHITECTURE.md + +--- + +## Repository + +``` +ORGANIZATION: coded-devs +REPOSITORY: coded-devs/twizrr (monorepo) +PACKAGE MANAGER: pnpm@9.0.0 +BUILD SYSTEM: Turborepo + +MONOREPO STRUCTURE: +twizrr/ +├── apps/ +│ ├── backend/ NestJS 10 API (@twizrr/backend) +│ ├── web/ Next.js 14 frontend (@twizrr/web) +│ └── mobile/ Expo React Native (@twizrr/mobile) ← TO BE CREATED +├── packages/ +│ └── shared/ Shared types, enums, utils (@twizrr/shared) +├── .github/ +│ ├── workflows/ +│ │ ├── ci.yml Lint + build on all branches +│ │ ├── neon_workflow.yml Auto Neon branch per PR +│ │ └── prisma-migrate.yml Auto migrate on schema changes +│ └── pull_request_template.md +├── .husky/ Pre-commit hooks (lint-staged) +├── .coderabbit.yaml CodeRabbit automated PR review config +├── scripts/ +│ └── security-sentinel.js Custom security checks in lint-staged +├── docker-compose.yml Local PostgreSQL + Redis +├── turbo.json +├── pnpm-workspace.yaml +└── tsconfig.base.json + +PACKAGE SCOPE: @twizrr/ (everywhere — never @swifta/ or @hardware-os/) + +CURRENT KNOWN ISSUES TO RESOLVE BEFORE NEXT DEPLOY: +├── Neon dev branch: 24 migrations pending — resolve before running CI migrations +├── main is 61 commits behind dev — do not merge until migration state confirmed +├── Prisma Migrate Deploy needs production GitHub environment secrets configured +└── See PROJECT_AUDIT_REPORT.md for full issue list +``` + +--- + +## 1. Branch Structure + +``` +PERMANENT BRANCHES (protected — never deleted): + +main +└── Production environment (twizrr.com) + Source of truth for what is live + Only receives merges from beta + Auto-deploys to Render (backend) + Vercel (frontend) + NO direct push — ever — no exceptions + +beta +└── Beta environment (beta.twizrr.com) + Real users — real money — real data + Only receives merges from staging + Auto-deploys to beta Render + Vercel + +staging +└── Staging environment (staging.twizrr.com) + Fake/seed data — Paystack test keys + Only receives merges from dev + Auto-deploys to staging Render + Vercel + Mustakheem (COO) QA tests here + +dev +└── Integration environment (dev.twizrr.com) + Where all feature work lands first + Team reviews and tests here before staging + Auto-deploys to dev Render + Vercel + +WORKING BRANCHES (created per task — deleted after merge): + +TYPE PREFIX PURPOSE +────────────────────────────────────────────────── +feat/ New feature or capability +fix/ Bug fix +chore/ Maintenance, deps, config, cleanup +refactor/ Code restructure (no behavior change) +hotfix/ Urgent production fix — branches from main + +NAMING FORMAT: {type}/{short-description} +├── All lowercase +├── Hyphens not underscores +├── 3-5 words — descriptive but concise +└── Always branch from dev (hotfix branches from main) + +GOOD EXAMPLES: +├── feat/whatsapp-ai-hybrid-search +├── feat/chat-websocket-module +├── feat/expo-mobile-scaffold +├── fix/dva-payment-timeout +├── fix/feed-tier0-exclusion +├── chore/delete-supplier-module +├── chore/rename-merchant-to-store +├── refactor/order-event-driven +└── hotfix/payout-amount-calculation + +BAD EXAMPLES: +├── feature/new-stuff too vague +├── fix no description +├── FEAT/WhatsApp_AI wrong case + underscores +└── working-on-this no type prefix +``` + +--- + +## 2. Code Flow — From Idea to Production + +``` +STANDARD FEATURE FLOW: + +1. Branch from dev: + git checkout dev + git pull origin dev + git checkout -b feat/your-feature-name + +2. Write code: + AI assistance for complex logic is acceptable + Review all AI-generated code before committing + Test locally before any commit + +3. Before every commit — run all verification commands: + See "Pre-Commit Verification" section below + +4. Commit with clear message: + See "Commit Messages" section below + +5. Push and open PR to dev: + git push origin feat/your-feature-name + Open PR: feat/your-feature-name → dev + Fill in the PR template completely + +6. Automated checks run: + Neon creates preview database branch automatically + CodeRabbit reviews the code automatically + CI runs lint + build + +7. Human review: + Other engineer reviews + PR approved → merge to dev + Feature branch deleted + +8. Promote through environments: + dev → staging (when dev is stable and QA-ready) + staging → beta (after Mustakheem QA passes) + beta → main (after 5+ days stable beta) + +TOTAL FLOW: +feat/branch → dev → staging → beta → main +``` + +--- + +## 3. Pre-Commit Verification + +``` +MANDATORY BEFORE EVERY COMMIT — ALL SIX COMMANDS MUST PASS: + +Backend: +cd apps/backend +pnpm run lint ← zero errors required +npx tsc --noEmit ← zero TypeScript errors required +pnpm run build ← clean build required + +Frontend: +cd apps/web +pnpm run lint ← zero errors required +npx tsc --noEmit ← zero TypeScript errors required +pnpm run build ← clean build required + +ALL SIX MUST PASS. Not five. Not "mostly". All six. +If any fail → fix the issue → run all six again. +Never commit with known lint or TypeScript errors. + +WHAT HUSKY + LINT-STAGED RUNS AUTOMATICALLY: +└── On every git commit attempt: + ├── ESLint --fix on changed .ts files + ├── security-sentinel.js (custom security checks) + └── Prettier --write on changed files + + This catches most issues automatically. + The six commands above are still required before pushing. + +SHARED PACKAGE: +└── If packages/shared is changed: + cd packages/shared && pnpm run build + Run this in addition to backend + web checks + shared types must build before backend/web can use them +``` + +--- + +## 4. Commit Messages + +``` +FORMAT: Conventional Commits with app scope + +{type}({scope}): {short description} + +Optional body (for complex changes): +- what changed +- why it changed +- anything the reviewer should know + +TYPES: +├── feat new feature +├── fix bug fix +├── chore maintenance, deps, cleanup +├── refactor restructure without behavior change +├── docs documentation only +└── perf performance improvement + +SCOPES — use the app/layer the change affects: +├── backend backend-only change +├── web frontend-only change +├── mobile mobile app change +├── shared shared package change +├── infra CI, Docker, GitHub Actions, render.yaml +├── db Prisma schema or migration +└── (no scope) cross-cutting change affecting multiple apps + +RULES: +├── Present tense: "add" not "added" +├── Lowercase first letter after colon +├── No period at end +├── Under 72 characters on the first line +└── Be specific — never "fix: bug" or "feat: stuff" + +GOOD EXAMPLES: +├── feat(backend): add ProductEmbedding model and pgvector index +├── feat(web): add home feed tab with infinite scroll +├── feat(mobile): scaffold Expo project with Expo Router v4 +├── feat(shared): add ChatMessage and ChatThread type definitions +├── fix(backend): resolve DVA expiry race condition on order cancel +├── fix(web): remove dead wholesale and procurement pages +├── chore(backend): delete supplier and trade-financing modules +├── chore(db): add OrderStatusHistory append-only migration +├── chore(infra): update CI to run on all branch pushes +├── refactor(backend): rename MerchantProfile to StoreProfile +└── perf(backend): precompute product embeddings via BullMQ + +BAD EXAMPLES: +├── fixed stuff no type, vague +├── WIP never commit WIP to shared branches +├── feat: Add stuff wrong case +└── update meaningless +``` + +--- + +## 5. Pull Request Rules + +``` +PR TEMPLATE — fill in every section (exists at .github/pull_request_template.md): + +## What does this PR do? +[One paragraph — what changed and why] + +## Type of change +- [ ] New feature +- [ ] Bug fix +- [ ] Refactor / cleanup +- [ ] Database migration included +- [ ] Chore / maintenance + +## How to test this +1. [Step-by-step instructions for the reviewer] +2. ... +3. Expected result: ... + +## Pre-commit checklist +- [ ] All 6 lint/type/build commands pass +- [ ] No console.log left in production code +- [ ] No hardcoded values (all in env vars) +- [ ] No secrets committed +- [ ] No `any` types added (existing ones are tech debt — don't add more) +- [ ] Database migration tested locally +- [ ] Backward-compatible migration (no breaking schema changes) + +## Screenshots (required for UI changes) +[Before / After] + +PR RULES: +├── Every PR must have a description — no empty PRs +├── Every PR must target dev (hotfix PRs target main only) +├── PR template must be filled in completely +├── CI must be green before merge (lint + build) +├── CodeRabbit review must be read and addressed +└── PRs must be reviewed within 24 hours of opening + Small team — don't let PRs sit for days + +PR SIZE — keep PRs focused: +├── One feature or one fix per PR +├── Ideal: under 400 lines changed +│ Larger PRs are harder to review thoroughly +│ If a feature is large: break into smaller PRs +│ PR 1: database migration +│ PR 2: backend service +│ PR 3: frontend UI +└── Exception: scaffolding/rename PRs — flag in description + +REVIEW REQUIREMENTS BY TARGET BRANCH: +├── → dev: 1 approval (self-review acceptable for solo work) +├── → staging: 1 approval (either Aliameen or Saheed) +├── → beta: BOTH Aliameen AND Saheed must approve +│ Real users + real money — no exceptions +└── → main: BOTH Aliameen AND Saheed must approve + No exceptions — ever + +HOTFIX EXCEPTION: +└── 1 approval sufficient (urgency overrides) + Must document WHY hotfix was needed in PR description + Must backport to dev immediately after merge to main +``` + +--- + +## 6. Branch Protection Rules + +``` +CONFIGURE IN GITHUB: +Settings → Branches → Branch protection rules + +MAIN: +├── Require pull request before merging: ✅ +├── Required approvals: 2 (both Aliameen + Saheed) +├── Dismiss stale reviews on new commits: ✅ +├── Require status checks: ci / lint-build ✅ +├── Require branches up to date before merge: ✅ +├── No direct pushes: ✅ (never bypassed) +└── No force pushes: ✅ + +BETA: +├── Require pull request: ✅ +├── Required approvals: 2 +├── Require status checks: ci / lint-build ✅ +└── No direct pushes: ✅ + +STAGING: +├── Require pull request: ✅ +├── Required approvals: 1 +├── Require status checks: ci / lint-build ✅ +└── No direct pushes: ✅ + +DEV: +├── Require pull request: ✅ +├── Required approvals: 1 (self-review acceptable) +├── Require status checks: ci / lint-build ✅ +└── No direct pushes: ✅ + +FEATURE BRANCHES (feat/*, fix/*, etc.): +└── No protection — push freely + These are your personal working space +``` + +--- + +## 7. GitHub Actions — CI Pipeline + +``` +CURRENT STATE (from audit): +└── ci.yml runs on: push to dev and main, PRs targeting dev only + NEEDS UPDATING: must run on all branches and all PR targets + +UPDATED CI CONFIGURATION (.github/workflows/ci.yml): + +name: CI + +on: + push: + branches: ['**'] ← run on ALL branch pushes + pull_request: + branches: [dev, staging, beta, main] ← run on PRs to all protected + +jobs: + lint-build: + name: Lint & Build + runs-on: ubuntu-latest + env: + DATABASE_URL: "postgresql://localhost:5432/dummy" + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Validate Prisma schema + run: pnpm --filter @twizrr/backend exec prisma validate + --schema=prisma/schema.prisma + + - name: Generate Prisma client + run: pnpm --filter @twizrr/backend exec prisma generate + --schema=prisma/schema.prisma + + - name: Lint all packages + run: pnpm lint + + - name: Build all packages + run: pnpm build + +WHAT CI ENFORCES: +├── ESLint passes with zero errors +├── TypeScript compiles with zero type errors +├── All packages build successfully +└── Prisma schema is valid + +RED CI = CANNOT MERGE (branch protection enforces this) +GREEN CI = all checks passed in a clean fresh environment +"It works on my machine" is not sufficient — CI must be green +``` + +--- + +## 8. Neon Database Workflow + +``` +NEON BRANCH → GITHUB ENVIRONMENT MAPPING: + +Neon branch GitHub environment Used by +───────────────────────────────────────────────────────────── +production production main + beta deploys +dev development dev + local +staging (create this) staging deploys +preview/pr-N-name (auto-created) PR preview deploys + +CURRENT KNOWN ISSUE: +└── Neon dev branch reports all 24 migrations as pending + This means the dev branch has no Prisma migration history + DO NOT run prisma migrate deploy blindly + + RESOLVE FIRST: + 1. Determine if dev Neon branch is empty, schema-only, or data-bearing + 2. If empty → run: pnpm exec prisma migrate deploy on dev branch + 3. If schema-only (tables exist but no history) → baseline: + pnpm exec prisma migrate resolve --applied {migration_name} + for each of the 24 migrations + 4. Verify: pnpm exec prisma migrate status (should show 0 pending) + 5. THEN re-run Prisma Migrate Deploy GitHub Action on dev + +CONNECTION STRING RULES (non-negotiable): +├── DATABASE_URL: Neon POOLED connection string +│ Contains "-pooler" in the hostname +│ Used by: NestJS Prisma client for ALL application queries +│ Used by: Render/Vercel at runtime +│ +└── DIRECT_URL: Neon DIRECT connection string + Does NOT contain "-pooler" in the hostname + Used by: Prisma CLI migrations ONLY + Used by: prisma migrate deploy in GitHub Actions + NEVER use direct for application queries (connection limit) + +PRISMA CONFIG (apps/backend/prisma.config.ts): +└── Reads: DIRECT_URL + This ensures Prisma CLI always uses the direct connection + +NEON PREVIEW BRANCHES (auto per PR): +└── neon_workflow.yml handles this automatically: + PR opened → Neon creates: preview/pr-{N}-{branch-name} + PR closed → Neon deletes the preview branch + Branch TTL: 14 days (auto-expires if PR forgotten) + + Requires in GitHub repository: + ├── Secret: NEON_API_KEY + └── Variable: NEON_PROJECT_ID + +GITHUB ENVIRONMENT SECRETS REQUIRED: + +GitHub environment "development": +├── NEON_DATABASE_URL: pooled Neon dev branch URL +├── NEON_DIRECT_URL: direct Neon dev branch URL +└── Restrict to: dev branch only + +GitHub environment "production": +├── NEON_DATABASE_URL: pooled Neon production branch URL +├── NEON_DIRECT_URL: direct Neon production branch URL +└── Restrict to: main branch only + +Workflow/runtime mapping: +├── DATABASE_URL = secrets.NEON_DATABASE_URL +└── DIRECT_URL = secrets.NEON_DIRECT_URL + +STAGING ENVIRONMENT (to be created): +└── Add GitHub environment "staging" + Add NEON_DATABASE_URL + NEON_DIRECT_URL for Neon staging branch + Restrict to: staging branch only +``` + +--- + +## 9. Database Migration Rules + +``` +MIGRATIONS CONTROL PRODUCTION DATA. +ONE MISTAKE = DATA LOSS OR PRODUCTION DOWNTIME. +These rules are non-negotiable. + +WHICH COMMAND FOR WHICH ENVIRONMENT: + +LOCAL / DEV BRANCH: +└── npx prisma migrate dev --name descriptive_name + Creates new migration file AND applies it + Name must be descriptive: + ✅ add_store_embedding_table + ✅ rename_merchant_profile_to_store_profile + ✅ add_chat_thread_and_message_models + ❌ migration1 + ❌ fix + ❌ update + +ALL SHARED ENVIRONMENTS (staging / beta / production): +└── npx prisma migrate deploy + Applies pending migrations only — does not create new ones + Run via GitHub Actions (prisma-migrate.yml) automatically + On push to dev → applies to Neon dev branch + On push to main → applies to Neon production branch + +ABSOLUTELY NEVER: +├── npx prisma db push (skips migration files — leaves no history) +├── npx prisma db push --accept-data-loss (destroys data silently) +├── npx prisma migrate dev on any shared environment +├── Manual table edits in Neon console on production/staging +└── Running migrations without understanding what they do + +BACKWARDS-COMPATIBLE MIGRATION PATTERN: +└── Never make a breaking change in one migration. + Use the expand-contract pattern: + + WRONG — one migration that renames a column: + └── ALTER TABLE stores RENAME COLUMN merchant_id TO store_id; + This breaks any running code that uses merchant_id + Production goes down between deploy and migration + + RIGHT — three separate deploys: + └── Step 1 (migration): ADD COLUMN store_id + Code still uses merchant_id — everything works + + Step 2 (code + migration): UPDATE stores SET store_id = merchant_id + Both columns exist — code updated to use store_id + + Step 3 (migration): DROP COLUMN merchant_id + Old column removed safely — no code references it + +MIGRATION CHECKLIST (required before any PR with migrations): +├── [ ] Migration tested on local dev environment +├── [ ] Schema diff reviewed in Neon GitHub PR comment +├── [ ] Backwards-compatible (no breaking changes in single deploy) +├── [ ] Data preserved (no accidental data loss) +├── [ ] Old data migrated if column/table changed (not just dropped) +└── [ ] Rollback plan documented in PR description: + "If this migration fails in production: [specific steps]" + +PRISMA .ENV DUPLICATE — DELETED: +└── apps/backend/prisma/.env has been deleted + The duplicate caused confusion and security risk + Prisma reads from apps/backend/.env.local only + apps/backend/.gitignore includes: prisma/.env + Never recreate it +``` + +--- + +## 10. Environment Variables + +``` +ABSOLUTE RULES — NO EXCEPTIONS: + +NEVER commit secrets to GitHub: +└── No API keys, database URLs, JWT secrets, webhook secrets + .env and .env.local files: always in .gitignore + If a secret is accidentally committed: + → Rotate it immediately (assume compromised) + → git history keeps it even after deletion + → Contact the service provider to invalidate old key + → Prevent in future: ESLint plugin no-secrets is configured + +.env.example IS committed (no real values): +└── Shows every required env var with placeholder: + DATABASE_URL=postgresql://user:pass@host/db + PAYSTACK_SECRET_KEY=sk_live_xxxxxxxxxxxx + New developer reads this to know what to set up + +ENVIRONMENT VARIABLE NAMING: +└── SCREAMING_SNAKE_CASE always + Backend prefix: none (NODE_ENV, DATABASE_URL etc.) + Frontend public prefix: NEXT_PUBLIC_ (exposed to browser) + Frontend server-only: no NEXT_PUBLIC_ prefix + +BACKEND ENVIRONMENT VARIABLES (complete list): + +INFRASTRUCTURE: +├── NODE_ENV development | staging | production +├── PORT 4000 (local) +├── DATABASE_URL Neon POOLED connection string +├── DIRECT_URL Neon DIRECT connection string (migrations only) +├── REDIS_URL Redis protocol URL (redis:// or rediss://) +└── FRONTEND_URL https://twizrr.com (or env-specific) + +SECURITY & AUTH: +├── JWT_ACCESS_SECRET openssl rand -hex 32 +├── JWT_REFRESH_SECRET openssl rand -hex 32 +├── JWT_ACCESS_TTL 15m +└── JWT_REFRESH_TTL 7d + +CORS: +└── CORS_ORIGINS Comma-separated allowed origins + Local: http://localhost:3000,http://localhost:3001 + Staging: https://staging.twizrr.com + Production: https://twizrr.com,https://beta.twizrr.com + NEVER: * in any shared environment + +PAYMENTS (PAYSTACK): +├── PAYSTACK_SECRET_KEY sk_test_... (dev/staging) | sk_live_... (beta/prod) +├── PAYSTACK_PUBLIC_KEY pk_test_... (dev/staging) | pk_live_... (beta/prod) +└── PAYSTACK_WEBHOOK_SECRET from Paystack dashboard + +CLOUDINARY: +├── CLOUDINARY_CLOUD_NAME +├── CLOUDINARY_API_KEY +└── CLOUDINARY_API_SECRET + +EMAIL (RESEND): +├── RESEND_API_KEY +├── EMAIL_FROM noreply@twizrr.com +└── WAITLIST_NOTIFY_EMAIL team@twizrr.com + +SMS (AFRICA'S TALKING): +├── AT_USERNAME +├── AT_API_KEY +└── AT_SENDER_ID + +AI (GOOGLE): +├── GOOGLE_CLOUD_API_KEY for Cloud Vision API +├── GEMINI_API_KEY for Gemini 2.5 Flash +├── GEMINI_MODEL gemini-2.5-flash +└── VERTEX_AI_API_KEY for Multimodal Embeddings API ← ADD THIS + +WHATSAPP: +├── WHATSAPP_BOT_NUMBER E.164 format +├── WHATSAPP_PHONE_NUMBER_ID from Meta dashboard +├── WHATSAPP_ACCESS_TOKEN permanent system user token +├── WHATSAPP_VERIFY_TOKEN webhook verify token +└── WHATSAPP_APP_SECRET for webhook HMAC validation + +WHATSAPP SYSTEM PROMPTS: +└── Store in env vars (not hardcoded in code) + WHATSAPP_BUYER_SYSTEM_PROMPT + (WhatsApp merchant prompt: Phase 3) + +PLATFORM CONFIG (configurable — not hardcoded): +# Commission as a fraction of subtotal, keyed by merchant verificationTier. +# Higher verification earns a lower fee. UNVERIFIED is base/fallback. +# TIER_3 is configured ahead of the enum gaining that value (unreachable). +├── PLATFORM_FEE_UNVERIFIED 0.02 (2%, default / base) +├── PLATFORM_FEE_TIER_1 0.015 (1.5%) +├── PLATFORM_FEE_TIER_2 0.01 (1%) +├── PLATFORM_FEE_TIER_3 0.005 (0.5%, future — no matching tier yet) +# Commission is charged on the subtotal only up to this cap (kobo). Past it the +# fee freezes so large orders aren't over-charged; the per-tier rate still +# applies so higher tiers stay cheaper. commission = rate × min(subtotal, cap). +├── PLATFORM_FEE_CAP_ORDER_KOBO 50000000 (₦500,000) +├── AUTO_CONFIRMATION_HOURS 48 +├── AUTO_CONFIRM_REMINDER_FIRST_HOURS 24 +├── AUTO_CONFIRM_REMINDER_FINAL_HOURS 12 +├── AUTO_CONFIRM_DISPUTE_WINDOW_HOURS 72 +├── ESCROW_WINDOW_HOURS 48 +├── OTP_EXPIRY_EMAIL_MINUTES 15 +├── OTP_EXPIRY_AUTH_MINUTES 15 +└── OTP_EXPIRY_WHATSAPP_MINUTES 15 + +SYSTEM: +├── ADMIN_BOOTSTRAP_EMAIL +├── ADMIN_BOOTSTRAP_PASSWORD +└── CORS_ORIGINS + +REMOVE FROM ENV (decisions made): +└── GEMINI_VISION_MODEL → being removed (Gemini Vision never used) + IMAGE_SEARCH_FALLBACK → being removed (replaced by hybrid search) + GEMINI_VISION_MODEL → being removed + TRADE_FINANCING_COMMISSION_PERCENTAGE → module being deleted + FINANCING_PARTNER → module being deleted + +PAYSTACK_BASE_URL DECISION: +└── Investigate paystack.config.ts before removing + If it only wraps the default https://api.paystack.co: + → Remove from .env.example and config + If it allows switching to a mock server for testing: + → Keep but rename to PAYSTACK_API_URL for clarity + +FRONTEND ENVIRONMENT VARIABLES: +├── NEXT_PUBLIC_API_URL https://api.twizrr.com +├── NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY pk_test_... or pk_live_... +├── NEXT_PUBLIC_WHATSAPP_BOT_NUMBER displayed in UI +└── NEXT_PUBLIC_PLATFORM_FEE displayed in UI (base rate, 0.02) + +PER-ENVIRONMENT PAYSTACK KEY DISCIPLINE: +├── Local, Dev, Staging: TEST keys only (sk_test_...) +└── Beta, Production: LIVE keys only (sk_live_...) + NEVER mix test and live keys in the same environment +``` + +--- + +## 11. Code Style Rules + +``` +TYPESCRIPT: +├── strict: true in all tsconfig.json files — always +├── No `any` types (existing ones are tech debt — never add new ones) +│ Use proper types or `unknown` with type guard +│ If you think you need `any`: stop and define the type properly +├── No non-null assertions (!.) — handle null cases explicitly +├── Explicit return types on service methods +└── Import from @twizrr/shared for shared types (never duplicate) + +NESTJS BACKEND: +├── No console.log — use NestJS Logger (pino) +│ backend: this.logger.log('message', { context, orderId }) +├── class-validator + class-transformer on every DTO +│ Every endpoint input must be validated +│ Never trust raw request body directly +├── All API responses via ResponseTransformInterceptor: +│ { success: true, data: T } on success +│ { success: false, message: string, code?: string } on error +├── Thin controllers: controllers handle HTTP only +│ Route, parse request, call service, return response +│ NO business logic in controllers — ever +├── Thick services: all business logic lives here +├── No raw SQL: Prisma ORM only +│ Exception: Prisma.$queryRaw for complex queries +│ Always use: Prisma.sql`...` (parameterized — never string concat) +├── No circular imports between modules +│ Use forwardRef() only when truly unavoidable +│ Prefer event-driven (EventEmitter) to break cycles +└── New inbound channels → src/channels/ (not src/modules/) + New third-party providers → src/integrations/ (not src/modules/) + Business logic → src/modules/ only + AppModule stays thin — import domain modules, not feature modules + +NEXT.JS FRONTEND: +├── Tailwind CSS only — no inline styles, no CSS modules +├── Design tokens from globals.css — never hardcoded colors +│ bg-surface not bg-white +│ text-foreground not text-slate-900 +│ border-border not border-gray-200 +├── Mobile-first — all layouts work at 375px minimum +├── Touch targets: minimum 44×44px on all interactive elements +├── Input font size: minimum 16px (prevents iOS auto-zoom) +├── No `any` in API layer — use @twizrr/shared types +│ supplier.api.ts with `any` return types = tech debt to fix +├── React Query for all data fetching — no manual useState + useEffect +└── NO EMOJIS anywhere in the UI — icons only (absolute rule) + Use lucide-react for all icons (already in tech stack) + Success states → CheckCircle icon (not ✅) + Navigation items → icons only + Product cards → icons only + Buttons → icons only (no emoji prefixes) + EXCEPTION: WhatsApp AI messages only (messaging context) + +GENERAL: +├── No TODO comments committed (fix it or create a Notion task) +├── No commented-out code committed +│ If you're disabling code: delete it (git history preserves it) +├── Meaningful names: no a, b, x, temp, data as variable names +│ Exception: standard iterator i in a loop +├── Functions do ONE thing +│ If a function does two things: split it into two functions +└── Export only what needs to be exported + Default export for components, named exports for utilities +``` + +--- + +## 12. What Gets Deleted From the Codebase + +``` +DECISIONS MADE — CLEAN UP IN DEDICATED PRs: + +BACKEND MODULES TO DELETE (chore/delete-dead-modules): +├── apps/backend/src/modules/supplier/ +│ Entire module: controller, service, DTOs, module file +│ Reason: B2B supplier features — not built for twizrr Phase 2 +│ +├── apps/backend/src/modules/trade-financing/ +│ Entire module: mock clients, controller, service, DTOs +│ Reason: BNPL/trade financing — mock only, not relevant +│ +└── apps/backend/src/channels/whatsapp/whatsapp-supplier.service.ts + apps/backend/src/channels/whatsapp/whatsapp-supplier-channel.module.ts + apps/backend/src/channels/whatsapp/whatsapp-supplier-intent.service.ts + Reason: Supplier WhatsApp bot — not for Phase 2 + +FRONTEND PAGES — FULL RESET COMPLETED: +└── The entire apps/web/src/ was reset to a clean slate in + chore/web-frontend-reset (May 2026). + All old Swifta frontend code was deleted. + The frontend is now being rebuilt from scratch per + TWIZRR_WEB_TASKS.md using these route groups: + (public)/ — landing, explore, product detail, store page + (auth)/ — login, register, verify-email + (shopper)/ — /buyer/* authenticated shopper pages + (store)/ — /store/* authenticated store owner pages + There is no (dashboard) route group — it was part of + the old Swifta architecture and no longer exists. + +FILES TO DELETE: +├── apps/backend/prisma/.env (duplicate of .env.local — already decided) +├── precommit.ps1 (legacy Windows script — Husky handles pre-commit) +└── apps/backend/audit-backend.json (audit file — not code) + +HOW TO DELETE: +└── One chore PR per group above (don't mix deletions with features) + Create PR → verify CI still passes → merge to dev + Test: pnpm run lint && pnpm run build must still pass after deletion + +DOMAIN MODULE DOMAIN MAPPING AFTER DELETION: +└── commerce.module.ts: remove supplier import + channels.module.ts: remove supplier WhatsApp import + money.module.ts: remove trade-financing import + Verify: app still boots after each deletion +``` + +--- + +## 13. The Full Rename — Merchant → Store + +``` +DECISION: Full rename throughout the codebase. +Internal code, database, and API routes all use "store" naming. +This is a major chore — done in a dedicated series of PRs. + +SCOPE OF RENAME: + +DATABASE (Prisma schema — backwards-compatible migration): +├── MerchantProfile model → StoreProfile +├── merchantId fields → storeId +├── merchantProfile relations → storeProfile +├── MerchantSlugHistory → StoreHandleHistory +├── MerchantWaitlist → StoreWaitlist +└── All @map("merchant_*") → @map("store_*") + +BACKEND MODULES: +├── src/modules/merchant/ → src/modules/store/ +├── MerchantService → StoreService +├── MerchantController → StoreController +├── MerchantModule → StoreModule +├── merchant-analytics.service.ts → store-analytics.service.ts +└── All DTO files: UpdateMerchantDto → UpdateStoreDto etc. + +ENUMS: +├── UserRole.MERCHANT → UserRole.STORE_OWNER +└── All references updated throughout codebase + +API ROUTES: +├── /merchant/* → /store/* (all merchant routes) +└── Update frontend API clients accordingly + +COMMON/DECORATORS: +└── current-merchant.decorator.ts → current-store.decorator.ts + @CurrentMerchant() → @CurrentStore() + +FRONTEND: +├── All /merchant/* routes → /store/* routes +├── MerchantProfile type → StoreProfile +└── All "merchant" display text already uses "Store" (design system) + Only internal variable names need updating + +MIGRATION STRATEGY (backwards-compatible): +└── Step 1 — Database: ADD new store_* columns alongside merchant_* columns + Step 2 — Code: update code to read/write store_* columns + Step 3 — Database: DROP merchant_* columns (once code is fully updated) + Total: 3 separate PRs minimum + +PR ORDER FOR THIS RENAME: +1. chore(db): add store_profile table (mirrors merchant_profile) +2. refactor(backend): rename merchant module to store module +3. refactor(web): update frontend routes and types +4. chore(db): drop merchant_profile table (after all code updated) +5. chore(shared): rename merchant types to store types + +BRANCHING: feat/rename-merchant-to-store (long-running branch) +└── Sub-PRs merge into this branch + Final PR merges the whole rename into dev +``` + +--- + +## 14. Hotfix Workflow + +``` +WHEN: Critical production bug that cannot wait for normal flow + Payment broken, data leaking, orders failing to create etc. + +HOTFIX PROCESS: + +STEP 1 — Branch from main (NOT dev): +└── git checkout main + git pull origin main + git checkout -b hotfix/critical-bug-description + +STEP 2 — Fix ONLY the bug: +└── Minimal change to fix the issue + No refactoring, no new features — fix only + Run all 6 lint/type/build commands + +STEP 3 — PR directly to main: +└── Open PR: hotfix/critical-bug-description → main + Title: "HOTFIX: [description of what broke and the fix]" + Description must include: + ├── What broke (symptoms) + ├── Root cause + ├── What the fix does + ├── How to verify it worked + └── Why it couldn't wait for normal flow + 1 approval sufficient (urgency exception) + Merge as soon as CI passes + 1 approval + +STEP 4 — IMMEDIATELY backport to dev: +└── git checkout dev + git pull origin dev + git cherry-pick {hotfix commit hash} + git push origin dev + + OR: open a separate PR from hotfix branch → dev + + WHY: if you don't backport: + Next normal dev → main deploy UNDOES the hotfix + The bug comes back + This happens constantly to teams who forget to backport + +STEP 5 — Database migration if involved: +└── Apply to main Neon branch first (production) + Then apply to dev and staging Neon branches + Use: npx prisma migrate deploy (not migrate dev) + +HOTFIX EXAMPLES: +├── Payout sending wrong amount to stores +├── Paystack webhook not verifying signature +├── Orders failing to create for all users +├── JWT tokens not validating correctly +└── Data from one user leaking to another + +DEPLOY WINDOW EXCEPTION: +└── Hotfixes can deploy any time — don't wait for off-peak + The bug is already affecting live users + Speed matters more than timing +``` + +--- + +## 15. Pre-Deployment Checklist + +``` +Based on the pre-deployment checklist philosophy: +"If any item is 'we'll do it later' — that's the one that breaks first." + +RUN THIS BEFORE MERGING STAGING → BETA (first real users): +AND BEFORE MERGING BETA → MAIN (full launch): + +SECURITY: +├── [ ] Authorization: every endpoint has ownership check +│ Not just auth — actual ownership of the resource +│ /orders/:id → verify order.storeId = req.user.storeId +├── [ ] OTP/reset tokens: 15-minute TTL enforced, single-use +├── [ ] Input validation: class-validator on ALL DTOs (no `any` bypass) +├── [ ] CORS: locked to production domains — NOT * +│ Verify CORS_ORIGINS env var value before deploy +├── [ ] Rate limiting: verified on all critical endpoints +│ Login: 10/15min, reset: 3/hour, OTP: 5 attempts then lock +├── [ ] Paystack webhook: HMAC signature verification on every call +│ Invalid signature → 401, logged for operator review +└── [ ] Error handling: no stack traces or internals exposed to client + Trigger each error type on staging and verify clean response + +PERFORMANCE: +├── [ ] Indexes: EXPLAIN ANALYZE on top 10 most frequent queries +│ Any Seq Scan on large tables → add index +│ Run against staging with realistic data volume +├── [ ] pgvector ivfflat index: created on product_embeddings table +│ Without this: vector search is unusably slow +├── [ ] Product embeddings: backfill complete (embeddingReady = true) +│ All active products have embeddings before going live +├── [ ] Redis caching: cache-aside pattern active +└── [ ] Pagination: no unbounded list endpoints returning all records + +FINANCIAL: +├── [ ] Payout guard: amount never exceeds available escrow balance +├── [ ] Fee calculation: verified with automated test +│ Create order → confirm → check payout = amount - fees +├── [ ] LedgerEntry immutability: UPDATE on ledger_entries must fail +│ Test this explicitly — row-level security configured? +└── [ ] Reconciliation: cron job running, ReconciliationLog being created + +OBSERVABILITY: +├── [ ] Structured Pino logs: JSON format on all NestJS services +├── [ ] Correlation IDs: X-Correlation-ID on all requests +├── [ ] Business events logged: order.paid, payout.sent, payout.failed +└── [ ] Security events logged: invalid webhook signature → operator review + +ENVIRONMENT VARIABLES: +├── [ ] All required env vars set in Render (production values) +├── [ ] Paystack LIVE keys (not test keys) — sk_live_... +├── [ ] CORS_ORIGINS set to production domains (not localhost) +├── [ ] DATABASE_URL = Neon production branch (pooled connection) +├── [ ] DIRECT_URL = Neon production branch (direct connection) +└── [ ] NODE_ENV = production + +WHATSAPP AI: +├── [ ] Consent message fires for new phone numbers +│ Test: message the bot from a new number +├── [ ] English-only enforcement working +│ Test: send a Yoruba or French message +├── [ ] Tier 0 stores excluded from all search results +│ Test: search for a Tier 0 store — must not appear +├── [ ] Image search working: send a photo → returns products +├── [ ] Product embeddings backfill complete (see above) +└── [ ] VERTEX_AI_API_KEY configured in production env vars + +DATABASE: +├── [ ] Prisma migrate status = 0 pending migrations on production +├── [ ] All migrations applied cleanly (no errors in migration log) +└── [ ] Neon HA (High Availability) enabled on production branch + +ROLLBACK: +├── [ ] Staging is healthy before promoting to beta/main +├── [ ] Beta is stable (5+ days, no critical bugs) before promoting to main +├── [ ] Render rollback procedure known by both Aliameen and Saheed +│ Render dashboard → Deployments → select previous → Rollback +├── [ ] Neon PITR confirmed available on production branch +└── [ ] Deployment during low-traffic window: 10pm - 6am WAT + Unless hotfix — then deploy immediately regardless of time + +FINAL CHECK: +└── Run the happy path manually on staging/beta: + 1. Register a new shopper account + 2. Browse products in the feed + 3. Add to cart + 4. Checkout and pay (test card or real card on beta) + 5. Store owner marks order ready for pickup / twizrr books delivery + 6. Shopper confirms delivery (delivery code or button) + 7. Payment processed to store + + If all 7 steps work: deploy is safe. + If any step fails: do not deploy to main. +``` + +--- + +## 16. Stale Branch Cleanup + +``` +CURRENT STATE: 60+ stale branches in the repository +These clutter the repo and make active work hard to find. + +BRANCHES TO DELETE (all merged or superseded): +└── All origin/feat/v1-*, v2-*, v3-*, v4-*, v5-*, v6-* branches + All origin/feature/* branches (completed features) + All origin/fix/* branches (completed fixes) + origin/rebrand/swifttrade + origin/rebrand/swifttrade-v5-modernization + origin/rename/swifta-to-twizrr + origin/samer (personal branch) + origin/fix-build-and-seed + All other merged branches + +EXCEPTION — KEEP: +├── origin/main ✅ +└── origin/dev ✅ + +TO CREATE: +├── staging branch: git checkout -b staging dev && git push origin staging +└── beta branch: git checkout -b beta dev && git push origin beta + +HOW TO BULK DELETE: +└── Review each branch in GitHub (Insights → Network or branch list) + Delete individually from GitHub UI (Branches → filter → delete) + OR: use GitHub CLI: + gh api repos/coded-devs/twizrr/branches --paginate | + jq '.[].name' | grep -v 'main\|dev' | xargs -I {} gh api + -X DELETE repos/coded-devs/twizrr/git/refs/heads/{} + + Review carefully before running bulk delete + Verify each branch is truly merged before deleting +``` + +--- + +## 17. Key Engineering Rules + +``` +1. Package scope is @twizrr/ everywhere + Never @swifta/, never @hardware-os/ + +2. All money stored as BigInt in kobo — never Float + Convert to Naira ONLY at display time in the frontend + ₦24,500 = 2450000n (BigInt kobo) + +3. LedgerEntry is append-only — no UPDATE or DELETE ever + Row-level security enforces this at PostgreSQL level + Application user has INSERT only on ledger_entries table + +4. DATABASE_URL = Neon pooled connection string (contains -pooler) + DIRECT_URL = Neon direct string (no pooler, migrations only) + Never swap these — causes connection pool exhaustion or migration failure + +5. Prisma migrations on shared environments: + ONLY: npx prisma migrate deploy + NEVER: prisma migrate dev (creates new migrations in prod) + NEVER: prisma db push (no migration history created) + NEVER: prisma db push --accept-data-loss (destroys data) + +6. All async jobs go through BullMQ — never synchronous: + Payouts, notifications, receipts, embeddings — all via BullMQ + Synchronous processing = single point of failure + blocking + +7. Webhook handlers respond 200 OK within 5 seconds + All heavy processing queued via BullMQ + Paystack and Meta retry on non-200 — never process inline + +8. Paystack webhook: HMAC signature verified on every request + Invalid signature → 401, log for operator review + Never process a payment event without signature verification + +9. No business logic in controllers + Controllers: parse request → call service → return response + Services: all business rules, validation, domain logic + +10. New inbound channels → apps/backend/src/channels/ + New third-party providers → apps/backend/src/integrations/ + Business logic → apps/backend/src/modules/ + AppModule stays thin — imports domain modules only + +11. Never use Gemini Vision for image analysis + Cloud Vision API for labeling + Vertex AI Multimodal Embeddings for vectorization + Gemini 2.5 Flash for conversation and intent parsing only + +12. Product embeddings are precomputed — never at search time + embedding_generation BullMQ job fires on product events + Search only queries embeddingReady = true records + +13. All search uses hybrid retrieval — never single-signal: + Vector similarity + text search + metadata filter + store signals + Single-signal search (keyword only) = poor shopping results + WIZZA text discovery uses the approved contract: + final_score = vector_similarity * 0.5 + text_rank * 0.2 + + store_performance * 0.2 + tier_boost * 0.1 + (SEARCH_WEIGHT_* env vars — numeric, >= 0, must sum to 1.0) + WIZZA image search is embedding-first: Vertex multimodal image + embedding similarity is the primary signal; labels are fallback + Inbound shopper images are SafeSearch-screened before discovery + (block on LIKELY/VERY_LIKELY adult/violence/racy; fail open on + screening errors; blocked images never reach embedding/search) + +14. User interaction signals collected from day one: + productsShown, productSelected, zeroResults, sessionConverted, + vectorSearchUsed, embeddingModel, productsRankedCount, errorType + Recorded honestly: zeroResults only when a real search returned + nothing — never for embedding/label/provider errors + This data cannot be backfilled — capture it even if unused initially + +15. CORS_ORIGINS env var must never be * in any shared environment + Local only: * or localhost origins + All shared environments: explicit domain list + +16. Never commit secrets — .env and .env.local always in .gitignore + If accidentally committed: rotate immediately, assume compromised + +17. All TypeScript must compile clean — npx tsc --noEmit = 0 errors + Never bypass with @ts-ignore except as documented exception + Never add new `any` types to the codebase + +18. Neon preview branches auto-created per PR via neon_workflow.yml + Auto-deleted when PR closes + Never manually create preview branches + +19. Paystack test keys (sk_test_...): Local, Dev, Staging only + Paystack live keys (sk_live_...): Beta, Production only + Mixing these is a critical error — platform charge real money + +20. Deployment window for production: 10pm - 6am WAT + Lowest traffic for Nigerian users — minimum impact if issues arise + Hotfixes: deploy immediately regardless of time +``` + +--- + +## Cross-References + +``` +Storage and hosting infrastructure (Neon, Render, Vercel, environments): +→ TWIZRR_STORAGE_INFRASTRUCTURE.md + +Scale architecture (scale-ready patterns, stage roadmap): +→ TWIZRR_SCALE_ARCHITECTURE.md + +Data architecture (models, indexes, retention, pgvector): +→ TWIZRR_DATA_ARCHITECTURE.md + +WhatsApp AI system (hybrid search, embeddings, Gemini): +→ TWIZRR_WHATSAPP_AI.md + +Pre-deployment checklist philosophy: +→ Pre-Deployment Checklist document (archived in Notion) + +Payment and financial rules (LedgerEntry, payouts): +→ TWIZRR_PAYMENT_SYSTEM.md + +Notification system (BullMQ queue list): +→ TWIZRR_NOTIFICATION_SYSTEM.md +``` + +--- + +*This document is the single source of truth for how twizrr is developed. Every engineering decision related to git workflow, branch management, commit standards, CI/CD, database migrations, environment variables, code style, and deployment must reference this document. Read this before writing any code or opening any PR.* diff --git a/TWIZRR_DISPUTE_SETTLEMENT.md b/TWIZRR_DISPUTE_SETTLEMENT.md new file mode 100644 index 00000000..90b99c2e --- /dev/null +++ b/TWIZRR_DISPUTE_SETTLEMENT.md @@ -0,0 +1,179 @@ +# twizrr — Dispute Financial Settlement (Phase 5) + +> Converts an approved dispute outcome into safe, auditable financial execution. +> Read `TWIZRR_DISPUTE_RESOLUTION.md` (Phase 4) first. + +## The core principle + +``` +Dispute decision ≠ Financial settlement completion +``` + +Resolving a dispute records a **decision** and a durable **plan**. Money moves +**asynchronously and separately**, and a provider **accepting** a request is +never treated as the provider **completing** it. + +## Flow + +``` +Admin resolves dispute + → settlement plan + legs saved in the SAME resolveDispute transaction + → execute requested through the outbox (only if execution enabled) + → buyer refund and/or merchant payout submitted to the provider + → provider status reconciled on an interval + → ledger updated only after confirmed financial outcomes + → settlement rolled up to COMPLETED +``` + +## Settlement model + +- **DisputeSettlement** — one per dispute (`idempotencyKey = dispute:{id}:settlement`). + Holds `capturedAmountKobo`, `buyerRefundAmountKobo`, `merchantPayoutAmountKobo`, + `platformRetainedAmountKobo`, and a `status`. +- **DisputeSettlementLeg** — one `BUYER_REFUND` and/or one `MERCHANT_PAYOUT`. + `@@unique([settlementId, type])` guarantees a retry can never create a second + refund/transfer. Stores only safe provider ids (`providerReference`, + `providerOperationId`) — never raw provider payloads, bank data, or evidence. + +### Statuses + +- Settlement: `PENDING → PROCESSING → PARTIALLY_COMPLETED → COMPLETED`, plus + `FAILED`, `RECONCILIATION_REQUIRED`, `MANUAL_REVIEW`. +- Leg: `PENDING → PROCESSING → SUBMITTED → COMPLETED`, plus `FAILED`, + `RECONCILIATION_REQUIRED`, `MANUAL_REVIEW`. + +## Amount rules + +- **Full shopper win** — refund = full available captured funds; payout = 0. +- **Store win** — payout = normal order payout clamped to available; refund = 0. +- **Partial** — operator must enter two positive amounts; + `refund + payout ≤ available`; `platformRetained = available − refund − payout`. + Zero-value splits, over-available splits, negatives, and decimal/float naira + are all rejected. Amounts are kobo integer strings parsed to BigInt server-side. + +## Provider acceptance vs completion + +- **Refund**: provider `pending/processing` → leg `SUBMITTED`; only a confirmed + `processed` writes `REFUND_COMPLETED`. Ambiguous/opaque failures → + `RECONCILIATION_REQUIRED` (never a blind second refund). +- **Payout**: transfer `pending/otp/processing` → payout `SUBMITTED`; only a + verified `success` writes `PAYOUT_COMPLETED` and notifies. "Payout received" + is never sent before confirmation. + +### Refunds for multi-collection payments + +A buyer-refund leg may own multiple `DisputeSettlementRefundOperation` rows +when the order was funded by an underpayment plus a remaining-balance checkout. +The operation allocation is deterministic and newest-collection-first, which +uses the fewest provider calls for a partial refund. Every operation stores one +source payment-attempt ID, original provider, provider transaction reference, +approved BigInt-kobo amount, deterministic idempotency key, and normalized +status. Raw provider payloads are never stored. + +One payment attempt can be reserved by only one settlement refund operation. +Duplicate workers claim each child with a guarded database update. The parent +leg becomes `COMPLETED` only when every child is provider-confirmed; only then +does the existing `settlement-leg:{legId}:refund-completed` ledger entry commit. +Thus split provider operations do not create multiple order-level refund ledger +debits or multiple buyer completion notifications. + +Existing settlements created before this model retain the legacy single-leg +execution path for rolling-deploy compatibility. + +## Payout holds (escrow protection) + +A payout is refused (DB-enforced, not just via queue removal) when: the order is +in `DISPUTE`, `disputeStatus = PENDING`, a settlement is +PENDING/PROCESSING/RECONCILIATION/MANUAL_REVIEW, the payout is `onHold`, or the +dispute window is still open. Errors: `PAYOUT_BLOCKED_BY_DISPUTE`, +`PAYOUT_BLOCKED_BY_SETTLEMENT`, `PAYOUT_BLOCKED_BY_DISPUTE_WINDOW`. A +merchant-payout settlement leg bypasses the hold for **that exact leg only**. + +## Ledger invariants + +Idempotency keys tie completion entries to a leg: +`settlement-leg:{legId}:refund-completed`, `settlement-leg:{legId}:payout-completed` +(with `LedgerEntry.settlementLegId`). Target invariant per order: + +``` +captured funds − confirmed refunds − confirmed merchant payouts + − retained platform amount = remaining unsettled funds +``` + +Historical ledger rows are append-only — never edited or deleted. + +## Manual-review states + +- A merchant payout already **completed** and the decision now requires + **refunding the shopper** → settlement `MANUAL_REVIEW`, no auto-reversal, no + platform-funded refund, no execute event emitted. +- Provider outcome ambiguous → `RECONCILIATION_REQUIRED`, resolved by the poller + or an operator, never a blind retry. + +## Phase 7 production-readiness gate + +Settlement safety is verified against disposable PostgreSQL with real guarded +updates and unique constraints. The suite covers duplicate outbox delivery, +duplicate provider completion, concurrent roll-up, and completion-ledger +idempotency. See `TWIZRR_SETTLEMENT_ROLLOUT_READINESS.md` for the checklist, +staged enablement plan, monitoring gates, and rollback. + +No operator action may mark money movement complete without confirmed provider +state and the matching ledger entry. + +## Feature flags + +``` +DISPUTE_SETTLEMENT_EXECUTION_ENABLED=false # plan-only until enabled; no money moves +DISPUTE_SETTLEMENT_RECONCILIATION_ENABLED=true +DISPUTE_SETTLEMENT_RECONCILIATION_INTERVAL_MS=60000 +DISPUTE_SETTLEMENT_RECONCILIATION_BATCH_SIZE=25 +``` + +Rollout: ship with execution disabled (plans/legs are still created and +auditable). Enable per environment once verified against Paystack test mode. + +## Operational runbook + +| Symptom | Action | +|---|---| +| Refund stuck `SUBMITTED` | Reconciliation polls `getRefund`; confirm the refund exists in Paystack. | +| Refund `RECONCILIATION_REQUIRED` | Poller re-checks provider status; if permanently opaque, operator reviews. | +| Transfer stuck `SUBMITTED` | Reconciliation `verifyTransfer`; check the store's bank/recipient. | +| Provider timeout, unknown result | Leg is `RECONCILIATION_REQUIRED` — do NOT re-submit; let the poller resolve. | +| Completed payout before shopper-win | Settlement is `MANUAL_REVIEW`; recover funds out-of-band, then close manually. | +| Ledger mismatch | Recompute the invariant from `ledger_entries` for the order; do not edit rows. | +``` + +## Phase 6 operations and visibility + +- `dispute:settlement-updated` v1 is a cache-invalidation hint for the buyer, + merchant, and active SUPER_ADMIN users. It contains identifiers, statuses and + a timestamp only; it never carries money, provider references, bank data, or + resolution notes. +- Durable notifications are appended in the same transaction as an authoritative + leg/settlement state transition. `SUBMITTED` says *processing*; only confirmed + completion after its corresponding ledger entry says *completed*. +- SUPER_ADMIN settlement operations may reconcile already-submitted provider + operations, move an incomplete settlement to manual review with an audit + reason, or retry only a pending/confirmed-rejected leg. Submitted, processing, + reconciliation-required, and other ambiguous states must be reconciled, not + retried. +- Buyer and merchant settlement views expose only their own approved amount and + safe status/timestamps. Counterparty amounts, provider data, and admin notes + remain internal. + +**No operator action may mark money movement complete without confirmed provider +state and the matching ledger entry.** + +### Rollout checklist + +1. Keep `DISPUTE_SETTLEMENT_EXECUTION_ENABLED=false` in every shared + environment until Paystack test-mode reconciliation, outbox delivery and + ledger checks have been observed. +2. Confirm pending/submitted/reconciliation/manual-review counts and the oldest + pending settlement before enabling a controlled test environment. +3. Reconcile uncertain states; do not use retry as a substitute for provider + confirmation. +4. Re-disable execution and investigate any ledger invariant mismatch before + processing another settlement. diff --git a/TWIZRR_MEDIA_ARCHITECTURE.md b/TWIZRR_MEDIA_ARCHITECTURE.md new file mode 100644 index 00000000..3364c710 --- /dev/null +++ b/TWIZRR_MEDIA_ARCHITECTURE.md @@ -0,0 +1,292 @@ +# Twizrr — Media Architecture + +How Twizrr stores, processes, protects, and serves media (images now; audio & +video later) as a **scalable, provider-agnostic layer** — not a single vendor +doing everything. + +> **Status:** architecture + roadmap (design intent + phased plan), not a +> command runbook. Media is Twizrr's core differentiator, so the *seams* are +> designed now even where the features come later. + +--- + +## 1. The principle everything hangs on + +> **Own the storage layer. Decouple storage → processing → delivery. Reference +> media by neutral IDs, never by a provider's URL.** + +Cloudinary today does *all three* (storage + transforms + CDN) and your code +stores its URLs + `publicId` directly on records. That's a lock-in: you can't +change any layer without a data migration. The fix is to put a **neutral Asset +record + a media resolver** between your app and whatever provider holds the +bytes. Then swapping a provider is a config change, not a rewrite — which is +exactly how large media platforms stay flexible for years. + +--- + +## 2. Target architecture + +```text + Client (web / app) + │ 1. ask backend for an upload ticket + ▼ + NestJS backend ──2. create MediaAsset(PENDING) + presigned/direct upload URL──┐ + │ │ + │ 3. client uploads BYTES DIRECTLY to storage (never through the backend) │ + ▼ ▼ + ┌────────────────── Storage / processing (by media kind) ──────────────────┐ + │ Images/Audio → Cloudflare R2 (object storage, no egress, S3-compatible) │ + │ Video → Mux (direct upload → encode → adaptive HLS) │ + └───────────────────────────────────────────────────────────────────────────┘ + │ 4. provider webhook → backend marks MediaAsset READY (+ dimensions/duration) + ▼ + Moderation pipeline (scan on upload → auto-decision → human review queue) + │ + ▼ + Delivery + Images → Cloudflare Images / imgix (variants at the edge) + Video → Mux playback (signed HLS) ── all behind Cloudflare CDN ──▶ users +``` + +**The app never holds media bytes and never hardcodes a provider URL.** It talks +to an Asset record and a resolver. + +--- + +## 3. The Asset model (system of record) + +One neutral table in Postgres is the source of truth. Providers are an +implementation detail stored *on* the record. + +```prisma +enum MediaKind { IMAGE VIDEO AUDIO } +enum MediaProvider { R2 MUX CLOUDINARY } // where the bytes live +enum MediaStatus { PENDING_UPLOAD UPLOADED PROCESSING READY FAILED DELETED } +enum MediaVisibility { PUBLIC RESTRICTED } // designed in from day one +enum ModerationState { PENDING APPROVED FLAGGED REJECTED } + +model MediaAsset { + id String @id @default(cuid()) + ownerId String // user/store that uploaded + kind MediaKind + provider MediaProvider + status MediaStatus @default(PENDING_UPLOAD) + visibility MediaVisibility @default(PUBLIC) + moderation ModerationState @default(PENDING) + + storageKey String? // R2 object key (images/audio) + + // Provider refs. Video needs MORE than one id, so don't collapse to a single + // string: Mux returns an upload_id (track the direct upload), an asset_id + // (management + webhook reconciliation), and a playback_id (signed streaming). + providerUploadId String? // Mux upload_id — reconcile the direct upload + providerAssetId String? // Mux asset_id / Cloudinary publicId — management + providerPlaybackId String? // Mux playback_id — video delivery/signing only + + mimeType String? + bytes BigInt? + width Int? + height Int? + durationMs Int? // video/audio + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([ownerId]) + @@index([status]) + @@index([moderation]) +} +``` + +Products/posts/etc. reference `MediaAsset.id` (via a join), **not** a URL. The +old `imageUrl` / `cloudinaryPublicId` columns become a compatibility shim during +migration (§9), then retire. + +### The media resolver +A single service builds delivery URLs so the rest of the app never knows the +provider. **It also enforces access** — signing a URL is *not* authorization: + +```text +resolveUrl(principal, asset, { size? }) → string + 1. authorize(principal, asset) // ownership / ACL check — REQUIRED, always, + // before any URL or token is built + 2. build the URL, signed when asset.visibility = RESTRICTED: + R2 image → Cloudflare Images/imgix variant (signed URL if RESTRICTED) + Mux video → HLS URL from providerPlaybackId (signed JWT if RESTRICTED) + Cloudinary→ legacy URL (during migration only) +``` + +> **Signing ≠ authorization.** `visibility = RESTRICTED` only selects the +> *delivery* mechanism (signed URL / Mux token). It does **not** decide whether +> *this* caller may see the asset. The resolver must take an **authenticated +> principal** and pass the ownership/ACL check **before** issuing anything — +> otherwise anyone who can guess/resolve an asset ID gets access. + +Change providers → change the resolver, not the callers. + +--- + +## 4. Upload flow — direct-to-storage (the scaling backbone) + +**Never buffer media through the backend.** Your current `multer memoryStorage()` +holds the whole file in the NestJS box's RAM — fine for small photos, fatal for +video. Replace it everywhere with **direct-to-storage**: + +1. Client → backend: "I want to upload a {image|video}, type X, size Y." +2. Backend: validate quota/permissions → create `MediaAsset(PENDING_UPLOAD)` → + return a **short-lived upload ticket**: + - **R2 (image/audio):** a presigned `PUT` URL with a **strict policy** — + enforced `content-type`, **max size**, short **expiry** (e.g. 5 min). + - **Mux (video):** a Mux **direct-upload URL** (Mux issues it; client uploads to Mux). +3. Client uploads the **bytes straight to R2/Mux** — bypassing your servers entirely. +4. Provider **webhook** → backend: mark `READY`, fill `width/height/durationMs`, + kick off moderation. + +This is the single most important design decision for scale — it makes uploads +independent of your compute, and it's mandatory before video/audio. + +> **Presign policy is security-critical:** without an enforced content-type + +> size cap + expiry, a presigned URL is an open door for arbitrary/huge uploads. + +--- + +## 5. Delivery & access model (designed for both, day one) + +Commerce media is public today, but a social app *will* have private/restricted +content. Bake the seam in now — it's cheap now, painful to retrofit: + +- **PUBLIC** assets: served straight from the CDN (Cloudflare in front), + cacheable, hotlink-protected at the edge. +- **RESTRICTED** assets: served via **signed URLs / tokens** with expiry: + - Images → Cloudflare Images / imgix **signed URLs**. + - Video → **Mux signed playback** (JWT playback tokens). +- The resolver decides signed-vs-public from `MediaAsset.visibility`, so callers + never think about it. Everything is fronted by Cloudflare for global caching + DDoS. +- **Authorization happens first, in the resolver** (§3) — a signed URL is only + the delivery mechanism for a request that has *already* passed the ownership/ACL + check. Never treat "it's a signed URL" as "the caller is allowed." + +--- + +## 6. Images — now + +- **Store** originals in **Cloudflare R2** (durable, S3-compatible, no egress). +- **Serve/transform** via **Cloudflare Images** (cohesive) or **imgix** + (best-in-class transforms) reading from R2 — resize/format/quality at the edge, + named variants (thumb / card / full). +- Moderation runs on the original at upload (§8). + +--- + +## 7. Video — later (lead: **Mux**) + +**Never DIY transcoding/streaming.** Use **Mux**: +- **Direct upload** → Mux encodes to **adaptive HLS** automatically. +- Store the Mux ids in their own fields — `providerUploadId`, `providerAssetId`, + `providerPlaybackId` — and set the direct-upload **`passthrough`** to the + `MediaAsset.id` so webhooks reconcile back cleanly. +- **Signed playback** (JWT, from `providerPlaybackId`) gives the RESTRICTED path + — but only *after* the resolver's ACL check (§3). +- Webhooks (`video.asset.ready`, errors) drive `status`; thumbnails/animated + previews come built-in; **Mux Data** gives QoE analytics. + +Mux's model *is* this architecture — direct upload, async ready-webhook, signed +delivery — so it drops into the Asset + resolver + moderation seams with no rework. + +**Alternative considered — Cloudflare Stream:** cohesive with the rest of the +Cloudflare stack and simpler pricing; weaker analytics/DX than Mux. Documented as +the fallback if single-vendor cohesion ever outweighs Mux's DX. + +--- + +## 8. Audio — later + +Simplest kind: store in **R2**, transcode to a web codec (AAC/Opus), serve via +CDN with the same public/signed rules. (Mux/Stream can also host audio if you +want unified processing — decide when the feature lands.) + +--- + +## 9. Moderation at scale (first-class, not a footnote) + +Media is the biggest win **and** the biggest responsibility. UGC images — and +*especially* video/audio — carry legal + safety obligations (abusive/illegal +content), and video/audio moderation is much harder than images. + +Pipeline, provider-agnostic: +1. **Scan on upload** (on the `READY` webhook, before the asset is publicly + resolvable): automated classification (nudity/violence/abuse; **known-CSAM + hash matching is mandatory** for UGC). `moderation = PENDING` blocks public delivery. +2. **Auto-decision:** clearly safe → `APPROVED`; clearly disallowed → `REJECTED` + (quarantine, never served); uncertain → `FLAGGED`. +3. **Human review queue** for `FLAGGED` — an admin surface to approve/reject. +4. The **resolver refuses** to serve anything not `APPROVED` when required. + +Keep this decoupled from the storage provider so it survives provider swaps. +Your existing `TWIZRR_CONTENT_MODERATION` logic is the starting point — this +extends it to run *on the pipeline* and to cover video/audio. + +--- + +## 10. Migration off Cloudinary (phased, no big-bang) + +1. **Introduce the seam.** Add `MediaAsset` + the resolver. New image uploads go + to **R2 + direct upload**; the resolver serves both new (R2) and legacy + (Cloudinary) assets. *No user-visible change.* +2. **Backfill.** Copy existing Cloudinary assets → R2, populate `MediaAsset`, + flip their resolver source. Cloudinary stays read-only as a safety net. +3. **Cut delivery** to Cloudflare Images/imgix; verify variants + signed URLs. +4. **Retire Cloudinary** once nothing resolves to it and a soak passes. +5. **Add video (Mux)** and **audio** as *new* asset kinds on the same seams — + plug-in, not rebuild. + +--- + +## 11. Build now vs. design now, build later + +**Build now (the seams — do these before/around launch):** +- `MediaAsset` model + resolver. +- **R2** bucket + **direct-to-storage uploads** (replace `memoryStorage`). +- Public/RESTRICTED visibility flag wired through the resolver. +- Moderation running on the upload pipeline for images. + +**Design now, build later (don't over-build):** +- **Video (Mux)** and **audio** — stand these up when the features are real, not + before. The seams above mean adding them is additive. +- Signed-playback, advanced variants, analytics — layer in with the features. + +> The goal is *not* to build video infra today — it's to make sure that when +> video lands, it's a new asset kind on an existing spine, not a re-architecture. + +--- + +## 12. Provider matrix / decision log + +| Concern | Choice | Alternative | Why | +| --- | --- | --- | --- | +| Object storage | **Cloudflare R2** | S3 / B2 | No egress fees; S3-compatible; already all-in on Cloudflare | +| Image delivery/transform | **Cloudflare Images** or **imgix** | Cloudinary | Decoupled from storage; edge variants | +| **Video** | **Mux** | Cloudflare Stream | Best DX/analytics; direct-upload + signed playback fit the seams | +| Audio | R2 + CDN | Mux/Stream | Simple; unify later if useful | +| System of record | **`MediaAsset` in Postgres/Neon** | provider URLs | Provider-agnostic; swappable | +| Uploads | **Direct-to-storage (presigned/Mux direct)** | backend proxy | Scales; mandatory for video | +| Moderation | **Own pipeline** on the upload flow | provider-only | Survives provider swaps; legal obligation | + +--- + +## 13. Relation to the rest of the system + +- **VPS:** none — media never touches the box, on any host. The box stays stateless. +- **Neon:** holds only the **`MediaAsset` metadata**, never bytes. +- **Cloudflare:** fronts all delivery (CDN/DDoS) and provides R2 + Images. +- **Cost:** a secondary concern per current priorities — chosen for performance + and longevity; R2's no-egress model keeps it sane at scale anyway. + +--- + +## 14. Open decisions to confirm + +- Image delivery: **Cloudflare Images vs imgix** (pick when building §6). +- Exact moderation vendor(s) for automated scanning + CSAM hash matching. +- Whether audio unifies onto Mux or stays R2 + a transcode step. +- Presign policy specifics (max sizes per kind, expiry windows, allowed types). diff --git a/TWIZRR_MEDIA_STORAGE.md b/TWIZRR_MEDIA_STORAGE.md new file mode 100644 index 00000000..2d31670e --- /dev/null +++ b/TWIZRR_MEDIA_STORAGE.md @@ -0,0 +1,246 @@ +# twizrr Media Storage Policy + +This document records the current Cloudinary image storage contract for twizrr. +It is intentionally focused on uploaded media and does not change checkout, +payments, delivery, search, WhatsApp, or verification tier behavior. + +## Canonical Storage + +- Cloudinary is the canonical storage for user, store, post, product, and + non-core marketing images used by the application. +- Repo `public/` assets are for stable brand/system assets such as official + logos, favicons, and small system placeholders that are intentionally bundled + with the app. +- Non-core landing and campaign visuals belong in Cloudinary under + `twizrr/marketing/landing`, not in repo `public/`. +- Private verification or KYB images must never be rendered in public shopper + surfaces. +- Production uploads are backend-controlled. The browser sends multipart files + to `POST /upload/image`; the backend runs Cloud Vision moderation before + uploading to Cloudinary. + +## Required Environment Variables + +Backend only: + +- `CLOUDINARY_CLOUD_NAME` +- `CLOUDINARY_API_KEY` +- `CLOUDINARY_API_SECRET` + +Web: + +- `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME` + +The web value is a public cloud name used by Next/Image remote pattern matching. +Do not expose Cloudinary API keys or API secrets in web env vars. + +## Folder Policy + +Active backend uploads use authenticated-user scoped folders so clients cannot +choose arbitrary Cloudinary paths. + +```text +twizrr/ + app/ + users/ + avatars/{userId} + stores/ + avatars/{userId} + banners/{userId} + products/ + physical/{userId} + digital/{userId} + sourced/{userId} + posts/{userId} + marketing/ + landing/ + social/ + system/ + placeholders/ +``` + +Notes: + +- User IDs are normalized to safe folder segments before use. +- Official app logo assets remain in repo `apps/web/public/logo/*`. +- Landing hero images, landing mockups, landing illustrations, screenshots, + campaign visuals, and social marketing images should be stored in + `twizrr/marketing/landing` or `twizrr/marketing/social`. +- Landing and marketing pages must not use user-upload folders under + `twizrr/app/...`. +- Client-supplied `contentId` is used only for moderation log context, not for + Cloudinary folder selection. +- `contentId` must not control authorization, ownership, folder routing, or + product/post linking decisions. +- Product images currently upload before the final product ID exists, so the MVP + folder is scoped by authenticated store owner. A later cleanup can move new + product uploads to `products/physical/{storeId}/{productId}` once the upload + flow becomes product-aware. +- Sourced listing custom image uploads are not currently a separate upload path; + if added later, use `twizrr/app/products/sourced/{storeId}/{listingId}` or a + compatible backend-controlled folder. + +### Post and Feed Images + +The canonical Cloudinary folder for post/feed images is: + +```text +twizrr/app/posts/{userId} +``` + +This folder is used for: + +- General store posts. +- Image-only posts. +- Announcement and brand update posts. +- Inventory-linked posts. +- Product-tagged posts that redirect shoppers to a product detail page. +- Draft post images. +- Published post images. + +Do not create separate draft or published post-media folders such as: + +```text +twizrr/app/posts/drafts/{userId} +twizrr/app/posts/published/{userId} +``` + +Cloudinary folders describe asset category and owner, not app visibility state. +Draft, published, archived, and moderation visibility belong in the database and +application state. Publishing a draft must not require moving or renaming the +Cloudinary asset. + +Current schema names differ from the future conceptual model: + +- `Post.type` currently uses `PostType` values such as `PRODUCT_POST`, + `IMAGE_POST`, `GIST`, and `TWIZZ`. +- `Post.isActive` and `Post.moderationStatus` currently represent visibility and + moderation state; there is no dedicated `Post.status` or `publishedAt` field + yet. +- Product-tagged posts currently use `Post.taggedProductId`. +- Post image references live in `PostImage.url` and + `PostImage.cloudinaryPublicId`. + +For tagged product posts, product linking and redirect behavior must be +database-owned. Cloudinary folder names must never be used as the source of +truth for product relationships. Use current or future DB fields such as +`Post.taggedProductId`, `Post.type`, `Post.isActive`, `Post.moderationStatus`, +`PostImage.url`, and `PostImage.cloudinaryPublicId`. + +Post images are separate from product images. Product images remain under +product image folders such as: + +```text +twizrr/app/products/physical/{userId} +twizrr/app/products/digital/{userId} +twizrr/app/products/sourced/{userId} +``` + +The active upload service currently routes `PRODUCT_FEED` and `PRODUCT_DETAIL` +to `twizrr/app/products/physical/{userId}`. Digital and sourced product image +folders are reserved policy paths for supported product flows. + +## Service Ownership Boundaries + +Inventory and product services own: + +- Products. +- Product inventory data. +- Product image references. + +Post and feed services own: + +- Posts. +- Captions and post text. +- Draft, published, active, archived, and moderation visibility state. +- Tagged product relationships. +- Post image references. + +The upload service owns: + +- Image MIME and size validation. +- Cloudinary folder routing. +- Cloud Vision moderation context. +- Returning a safe rendered image URL and Cloudinary public ID. + +The upload service does not own product linking, post visibility, checkout +behavior, or authorization decisions beyond validating the authenticated upload +request. + +## Validation Policy + +Current active upload limits: + +- Supported image MIME types: `image/jpeg`, `image/png`, `image/webp` +- Max upload size: 5 MB +- Moderation: Cloud Vision must be configured and must run before Cloudinary + upload. +- Blocked images are logged to `ModerationLog` without a Cloudinary ID and are + not uploaded. + +The product listing UI and backend currently cap product images at 5. Keep web +and backend caps aligned if this changes. + +## Rendering Policy + +- Use `next/image` where practical for public/user-facing image rendering. +- Next/Image should only allow the configured Cloudinary cloud name under + `/image/upload/**`, plus intentionally approved external placeholder/avatar + hosts. +- Missing product, store, and profile images should render approved placeholders + or token-based fallback states. +- Alt text should describe the visible object without exposing private + fulfillment relationships. + +## Database Field Standard + +Current storage standard: + +- `User.profilePhotoUrl`, `StoreProfile.logoUrl`, and `StoreProfile.bannerUrl` + store rendered HTTPS image URLs. +- `Product.imageUrl` stores the default product image URL. +- `ProductImage` stores product image rendered HTTPS URLs and optional + `cloudinaryPublicId`. +- `PostImage` stores post image rendered HTTPS URLs and optional + `cloudinaryPublicId`. +- `SourcedProduct.customImages` stores structured image objects in JSON: + `{ url, cloudinaryPublicId, altText }`. +- `ModerationLog` stores `imageUrl` and optional `cloudinaryId` for audit. + +Do not store local filesystem paths, base64 image blobs, Cloudinary API secrets, +or signed admin URLs in image fields. + +## Replacement and Deletion + +Current behavior updates DB references when a user, store, product, or sourced +listing image changes. Full Cloudinary orphan cleanup is not yet centralized +because several profile/store fields store URLs without a persisted public ID. + +Deletion cleanup must be best-effort and must never corrupt DB state if a +Cloudinary delete fails. Do not delete a Cloudinary asset unless the backend can +prove it is no longer referenced. + +## Manual QA Checklist + +- Upload and replace a user profile picture. +- Upload and replace a store avatar and banner. +- Upload product images and confirm dashboard/public product rendering. +- Create a draft general post with an image and confirm it lands under + `twizrr/app/posts/{userId}`. +- Publish the draft and confirm the image stays in the same Cloudinary folder. +- Confirm feed visibility changes because database state changes, not because + the asset moved. +- Create a product-tagged post and confirm the post image lands under + `twizrr/app/posts/{userId}`. +- Confirm product redirect behavior comes from the DB product relation, not from + the image path. +- Create a product with product images and confirm product images land under + product image folders, not post folders. +- Try unsupported file types and oversized images. +- Confirm client-supplied form fields cannot alter Cloudinary folders. +- Confirm private verification/KYB images are not rendered publicly. +- Confirm official brand marks render from `/logo/*`. +- Confirm landing and campaign visuals render from Cloudinary marketing folders, + not user/store/product upload folders. +- Confirm repo `public/` is not accumulating landing/campaign-heavy images. +- Inspect Cloudinary folders with test uploads only. diff --git a/TWIZRR_MERCHANT_EARNINGS_WALLET_DESIGN.md b/TWIZRR_MERCHANT_EARNINGS_WALLET_DESIGN.md new file mode 100644 index 00000000..e61acaba --- /dev/null +++ b/TWIZRR_MERCHANT_EARNINGS_WALLET_DESIGN.md @@ -0,0 +1,500 @@ +# Twizrr Store Earnings Balance Design + +> Status: Approved architecture design; no stored-value wallet is implemented. +> Date: 19 July 2026 +> Related: `TWIZRR_DISPUTE_SETTLEMENT.md`, +> `TWIZRR_PROVIDER_ARCHITECTURE.md`, and +> `TWIZRR_MONNIFY_PROVIDER_ASSESSMENT.md`. + +## 1. Decision + +Twizrr will present store owners with a **Store Earnings** balance derived from +Twizrr's append-only ledger and authoritative payout, payout-request, dispute, +and settlement records. + +This is not: + +- a Monnify, Paystack, Nomba, or other provider wallet; +- a bank account issued to a store owner; +- stored value that can be topped up, transferred to another user, or spent; +- a replacement for escrow; +- a new source of truth for money; +- permission to release funds before delivery and dispute safeguards pass. + +The word `wallet` may remain an internal roadmap shorthand. Product copy should +use **Store Earnings**, **Available for payout**, **Protected earnings**, and +**Payout history**. This avoids implying that Twizrr provides a regulated +consumer wallet or deposit account. + +## 2. Why the balance is Twizrr-led + +Twizrr determines when an order creates a store entitlement, which fees the +store bears, whether a dispute holds that entitlement, and whether a payout was +confirmed. A payment provider only reports provider-side operations and its own +treasury balance. + +Therefore: + +```text +Twizrr append-only ledger + authoritative operational state + -> store earnings projection + -> store/admin REST views + +Provider balance and statements + -> platform reconciliation only + -> never a store earnings balance +``` + +Changing the active checkout provider affects only new payment operations. It must not +change a store's balance or re-own an existing payout. Existing operations +continue through their immutable recorded provider. + +## 3. Current coverage verified in the repository + +| Area | Current source | What works | Gap before calling it Store Earnings | +| --- | --- | --- | --- | +| Protected-order summary | `OrderService.getMerchantSummary` and `GET /orders/summary` | Shows pending release, paid out, failed, and order counts | It combines orders, payouts, and ledger fallbacks; it is not the canonical ledger projection and converts kobo through `Number` | +| Store payout history | `PayoutService.listByStore` and `GET /payouts` | Returns safe store-owned payout rows | Limited to 50 rows and does not expose a canonical balance breakdown | +| Available-balance guard | `LedgerService.getMerchantAvailableBalance` | Credits released order entitlement and reserves active payout requests/in-flight payouts | It is used for payout authorization but is not exposed as the store summary | +| Manual payout request | `PaymentService.requestPayout` and `PayoutRequest` | Uses the canonical earnings projection under a PostgreSQL advisory lock; retries are idempotent per store/key | `PayoutRequest.payoutId` is the optional unique ownership link; an admin cannot financially process an unlinked request | +| Normal payouts | `Payout`, `PayoutAttempt`, and payout ledger entries | Provider ownership and attempts are durable; ambiguous submissions reconcile | Display must count an operation once across initiated, submitted, completed, and failed milestones | +| Dispute settlement | `DisputeSettlement` and `DisputeSettlementLeg` | Approved refund/payout amounts and reconciliation states are durable | Settlement payouts need their own display treatment and must not be credited as ordinary withdrawable earnings | +| Provider treasury | Provider dashboards/APIs | Useful for platform liquidity reconciliation | Must never be shown as, or used to calculate, an individual store's earnings | + +No `StoreWallet`, `MerchantWallet`, external wallet ID, or provider wallet +balance currently exists in Prisma. That is correct for this design. + +## 4. Terminology and balance buckets + +All amounts are non-negative `BigInt` kobo internally and decimal strings in +JSON. Naira formatting happens only in clients. + +| Bucket | Meaning | Withdrawable? | +| --- | --- | --- | +| `protectedKobo` | Expected net store entitlement from paid orders still protected by escrow, fulfillment, delivery, or the ordinary dispute window, with no active dispute/settlement review | No | +| `availableKobo` | Released store entitlement not reserved by a request and not assigned to an in-flight or completed payout | Yes, subject to current payout rules | +| `reservedKobo` | Amount reserved by accepted `PayoutRequest` records or approved `PENDING` payout rows that have not yet become an in-flight provider payout | No | +| `processingKobo` | Payouts in `PROCESSING`, `SUBMITTED`, or `RECONCILIATION_REQUIRED`; money may already have moved | No | +| `paidOutKobo` | Provider-confirmed payout completion represented once by the completion ledger identity | Already paid | +| `underReviewKobo` | Store entitlement affected by an active dispute or settlement `RECONCILIATION_REQUIRED`/`MANUAL_REVIEW` state and not already committed to an in-flight or completed provider payout | No | + +Every unit of entitlement belongs to one bucket. Apply this precedence from +highest to lowest: `paidOutKobo`, `processingKobo`, `underReviewKobo`, +`reservedKobo`, `availableKobo`, then `protectedKobo`. Therefore an amount under +active dispute/settlement review is never also counted as protected, available, +or reserved. An already submitted or completed payout remains processing or +paid out because the provider operation may already have moved money; its +related dispute exposure is shown separately to admins rather than counted +again in `underReviewKobo`. + +`FAILED` is not a balance bucket. Funds return to `availableKobo` only when the +provider outcome is a confirmed, retry-safe failure and the payout reservation +is released atomically. They do not remain held for an additional admin-review +window. An ambiguous timeout stays in `processingKobo` (or +`underReviewKobo` when the underlying dispute/settlement itself requires +review) until reconciliation. + +`pending payment` is also not earnings. An order for which Twizrr has not +confirmed shopper funds must not appear in any earnings bucket. + +## 5. Authoritative event mapping + +### Protected earnings + +A successful payment creates escrow, not withdrawable store earnings. The +protected display may show the order's current net store entitlement, but the +amount remains unavailable until an authoritative escrow release or approved +settlement outcome. + +For a normal order, the net entitlement uses the existing payout calculation: + +```text +gross order amount +- delivery fee +- merchant-borne platform fee +- dropshipper cost where applicable += normal store payout entitlement +``` + +The platform fee is borne by the store. The shopper pays product subtotal plus +delivery and is not charged the platform fee. + +### Available earnings + +For normal order flow, one unique `ESCROW_HELD / RELEASE` milestone creates the +store's released entitlement. Duplicate release events must not create another +credit. `LedgerService.calculateOrderPayout` remains the approved calculation +for the entitlement amount. + +### Payout reservation and processing + +An amount moves through exactly one payout state at a time: + +```text +available + -> reserved by payout request + -> processing/submitted/reconciliation required + -> paid out + +confirmed provider rejection + -> available again +``` + +Provider acceptance is not payout completion. `SUBMITTED`, OTP, processing, +timeouts, and unknown states remain unavailable until reconciliation proves a +terminal result. + +### Refunds and disputes + +A refund operation must affect a store balance once, not once at initiation and +again at completion. Before implementing the projection, refund entries need a +stable operation identity: `settlementLegId` where present, otherwise a durable +refund/provider reference or future `refundId`. + +- A refund before store entitlement release reduces protected earnings. +- A confirmed refund after release reduces the appropriate released entitlement + only according to the approved settlement/recovery path. +- `REFUND_INITIATED` reserves or marks funds under review; it is not a second + completed economic debit. +- A completed payout followed by a shopper-refund requirement remains manual + review under the merged settlement rules. The wallet must not silently create + a negative balance or fund a refund from another store's earnings. + +### Settlement merchant payout + +An approved `MERCHANT_PAYOUT` settlement leg is a direct settlement operation. +It is displayed as processing/paid according to the leg and payout states. It +does not first become ordinary `availableKobo`, because that would permit a +second payout request for the same approved amount. + +## 6. Formal projection invariants + +For each normal released entitlement: + +```text +released entitlement += available + + active request reservation + + in-flight payout + + confirmed payout + + under-review released entitlement + + confirmed merchant-borne reversal or adjustment +``` + +In this per-entitlement equation, `under-review released entitlement` is the +portion classified in `underReviewKobo`. An unreleased entitlement placed under +review is still shown only in `underReviewKobo`, but it is not part of the +released-entitlement population being reconciled by this equation. + +Each payout operation contributes to exactly one of reservation, in-flight, +confirmed, or retry-safe-failed state. Milestone ledger rows for the same +`payoutId` do not create multiple debits. + +For each dispute settlement: + +```text +confirmed buyer refund ++ confirmed merchant payout ++ platform-retained amount += captured settlement amount +``` + +Additional system invariants: + +```text +availableKobo >= 0 +all bucket amounts >= 0 +provider wallet balance is not an operand +shopper delivery fees are not store earnings +merchant-borne platform fees are not shopper charges +one provider operation cannot appear in two buckets +one settlement payout cannot also become a normal payout request +``` + +Any invariant failure is an operational blocker. The API must return a safe +unavailable/error state and alert admins; it must not clamp a negative value to +zero and hide the discrepancy. + +## 7. Implemented backend boundary + +The focused Twizrr-owned projection service is implemented at: + +```text +apps/backend/src/domains/money/earnings/store-earnings.service.ts +``` + +Implemented contract: + +```typescript +type StoreEarningsSummary = { + currency: "NGN"; + protectedKobo: string; + availableKobo: string; + reservedKobo: string; + processingKobo: string; + paidOutKobo: string; + underReviewKobo: string; + asOf: string; +}; +``` + +Implemented authenticated route: + +```text +GET /store/earnings/summary +``` + +The transaction-history route remains a later UI/API enhancement. Only the +authenticated owner may read their store's projection. Admin access must use a +separate protected endpoint and must be audited where it triggers an operation. + +The service owns projection logic. Controllers, web clients, and provider +adapters must not recalculate balances. + +## 8. Read model and schema decision + +### First implementation + +Build and test a canonical query/projection over existing authoritative rows. +Use it for both the display endpoint and payout-request authorization. This +removes the current split between `OrderService.getMerchantSummary` and +`LedgerService.getMerchantAvailableBalance`. + +The query must be bounded and supported by store, entry-type, payout, status, +and creation-time indexes. It must not materialize every historical ledger row +in application memory for a high-volume store. + +### Scale projection + +If query plans show that ledger aggregation cannot meet the latency target, add +a rebuildable `StoreEarningsSnapshot` read model in a separate migration. It is +a cache/projection, never the financial source of truth. Updates must occur from +authoritative committed events with deterministic identities, and an admin job +must be able to rebuild and compare it against the ledger. + +Do not add a mutable `balanceKobo` field to `StoreProfile`. + +### Implemented payout-request schema contract + +- A required, capped `idempotencyKey` is stored on `PayoutRequest` and + PostgreSQL enforces + `@@unique([storeId, idempotencyKey])`. The key is scoped to the authenticated + store, so two stores may use the same opaque client key without sharing an + operation. +- A payout request can link to the exact payout/operation that consumes it with a + nullable unique relation (for example `payoutId String? @unique`) before a + request can progress from reservation to provider payout. +- Provide stable identity for non-settlement refund operations so initiation + and completion roll up once. +- Preserve immutable payout provider/attempt ownership already in place. + +The backward-compatible migration backfills existing requests with deterministic +legacy keys, validates the non-null constraint, and creates the unique indexes +online. Focused service tests cover retries and store-scoped concurrency; a +disposable PostgreSQL concurrency suite remains required before changing the +manual payout-request feature into automatic execution. + +## 9. Concurrency and payout authorization + +Balance display may be eventually refreshed, but payout authorization must be +strongly consistent. + +When accepting a payout request: + +1. Require a capped opaque idempotency key from the authenticated store. +2. Open a PostgreSQL transaction. +3. Acquire the store-scoped advisory lock or equivalent row lock. The lock only + serializes balance authorization; it is not the idempotency guarantee. +4. Look up `(storeId, idempotencyKey)`. If it exists with the same normalized + amount and destination intent, return that committed reservation. If the key + was reused for different input, return an idempotency conflict. +5. Calculate the canonical available amount from authoritative committed rows + and re-read active reservations inside the same transaction. +6. Create one durable reservation. Enforce the compound unique constraint in + PostgreSQL so retries or future code paths cannot create a duplicate. +7. If the insert observes a unique conflict, load and validate the existing + `(storeId, idempotencyKey)` reservation and return it rather than creating a + second row. +8. Commit before notifying admins or returning success. + +Tests must run two independent requests against PostgreSQL and prove that both +cannot reserve the same available kobo. + +No in-process mutex, Redis lock, UI-disabled button, or cached snapshot is +sufficient for financial authorization. + +## 10. API and privacy rules + +Store-facing APIs may return: + +- bucket amounts as decimal kobo strings; +- safe order codes and payout status; +- payout provider display name after an operation is created; +- submitted/completed timestamps; +- safe product/store-facing descriptions. + +They must not return: + +- provider treasury or platform wallet balances; +- another store's earnings or payout data; +- full bank account numbers; +- provider credentials or raw provider payloads; +- payment authorization data; +- shopper payment details; +- internal settlement notes or raw reconciliation errors. + +REST remains the source of truth. A future realtime event may invalidate the +earnings query, but must not carry balances or financial details in its socket +payload. + +## 11. Store experience + +The existing Payouts page should evolve without implying a bank wallet: + +```text +Store Earnings + Available for payout + Protected earnings + Processing + Under review + Paid out + +Payout account +Payout history +``` + +Copy must explain: + +- protected earnings are awaiting order/dispute safeguards; +- available earnings can be requested or paid under current payout policy; +- processing may include a transfer awaiting provider confirmation; +- under-review funds require Twizrr support review; +- a confirmed failed payout says the funds are available again; it must not use + that copy for timeouts, unknown states, or reconciliation-required payouts; +- provider confirmation determines paid-out status. + +Do not add top up, send money, pay another user, cash out to arbitrary accounts, +wallet PIN, wallet account number, or spend-from-balance controls. + +## 12. Admin and reconciliation visibility + +SUPER_ADMIN should eventually see: + +- the six Twizrr balance buckets; +- the ledger entries and operation IDs contributing to each bucket; +- provider-bound payout attempts and safe references; +- projection-versus-ledger mismatch status; +- oldest processing/reconciliation-required operation; +- payout request reservation ownership. + +Admin controls must continue to use existing payout/reconciliation services. +No wallet screen may offer force credit, force debit, force complete, or manual +balance editing. Corrections are new append-only ledger entries under an +explicit, separately approved adjustment design. + +## 13. Monitoring gates + +Track at minimum: + +- negative or invariant-failing projections; +- available balance mismatches between display and payout authorization; +- processing payout age; +- reconciliation-required payout count; +- orphaned payout-request reservations; +- completed payout without one completion ledger identity; +- duplicate completion ledger identities; +- provider treasury reconciliation mismatch at the platform level. + +An invariant failure blocks wallet rollout and new payout requests until +reviewed. It does not authorize editing historical ledger rows. + +## 14. Implementation roadmap + +### Implemented on this branch: canonical projection, summary, and reservation safety + +- `StoreEarningsService` owns BigInt-only mutually exclusive bucket logic. +- `GET /store/earnings/summary` exposes owner-only decimal kobo strings. +- Payout authorization uses the same projection returned by the read API. +- `PayoutRequest` enforces store-scoped idempotency and returns the committed + reservation on a matching retry. +- The existing payouts page reads the canonical summary and displays all six + buckets with failure/reconciliation-safe copy. + +### Next PR: PostgreSQL invariant and concurrency proof + +- Prove concurrent store-scoped payout reservations on disposable PostgreSQL. +- Prove failed-versus-ambiguous payout bucket transitions with committed rows. +- Validate query plans and indexes with realistic store history volume. +- Add duplicate ledger/provider-event projection regression coverage. + +### Later PR: Store earnings transaction history + +- Add the cursor-paginated transaction endpoint and store-facing timeline. +- Keep bank account and provider payout history separate. +- Do not add stored-value wallet controls. + +### Later PR: Admin earnings reconciliation visibility + +- Add SUPER_ADMIN projection/ledger comparison. +- Surface orphaned reservations and stale processing operations. +- Reuse existing payout reconciliation actions; add no force-complete action. + +### Optional later PR: Materialized projection + +- Only after production query-plan evidence. +- Add rebuildable snapshot/outbox projection with drift detection. +- Keep payout authorization ledger-verified inside PostgreSQL. + +## 15. Required tests for implementation + +- captured shopper funds are protected, not available; +- one escrow release creates one store entitlement; +- merchant-borne platform fee reduces store entitlement, not shopper charge; +- delivery fee and dropshipper cost are excluded correctly; +- payout initiation and completion reduce available funds once; +- confirmed payout failure returns the reservation once; +- a confirmed retry-safe payout failure moves the amount to available and does + not leave it in processing or under review; +- ambiguous payout remains unavailable; +- refund initiation and completion affect earnings once; +- settlement payout never becomes both available and directly paid; +- active dispute/settlement holds remain unavailable; +- two concurrent payout requests cannot overspend one balance; +- duplicate ledger/provider events do not change the projection twice; +- all API money values are decimal kobo strings; +- no provider balance, raw bank data, or shopper payment detail is returned. + +## 16. Manual QA checklist + +- A paid but undelivered order appears only under protected earnings. +- A released order moves to available exactly once. +- Requesting payout moves the amount from available to reserved. +- Provider submission moves it to processing. +- Confirmed provider success moves it to paid out. +- Confirmed rejection returns it to available. +- Ambiguous timeout stays unavailable and shows safe review copy. +- A dispute moves affected funds only to under review; it is not also counted + as protected, available, or reserved. +- A settlement payout appears once and cannot be requested again. +- Bank details remain masked and are managed separately. +- Changing payout provider does not change historical balances or operations. +- No screen offers top-up, peer transfer, arbitrary withdrawal, or wallet PIN. + +## 17. Open product and operations decisions + +These decisions are required before implementing payout-request UX changes: + +- Keep the current admin-reviewed payout request, move to scheduled automatic + payout, or support both? +- Is there a minimum payout amount or payout schedule? +- What support copy and SLA apply to reconciliation-required payouts? +- Should the product name remain `Store Earnings` in every UI surface? + +None of these decisions change the core rule: the append-only Twizrr ledger is +authoritative, and a provider wallet is never the store's earnings balance. + +## 18. Scope confirmation + +This design introduces no schema, API, UI, provider, payout, settlement, +checkout, or ledger behavior change. It does not enable external wallets, +stored value, top-ups, peer transfers, shopper wallets, or provider fallback. diff --git a/TWIZRR_MONNIFY_IDENTITY_VERIFICATION_ASSESSMENT.md b/TWIZRR_MONNIFY_IDENTITY_VERIFICATION_ASSESSMENT.md new file mode 100644 index 00000000..ef437996 --- /dev/null +++ b/TWIZRR_MONNIFY_IDENTITY_VERIFICATION_ASSESSMENT.md @@ -0,0 +1,162 @@ +# Twizrr Monnify Identity Verification Assessment + +**Assessment date:** 18 July 2026 + +**Status:** Completed - no provider implementation approved + +**Decision:** Retain Prembly as Twizrr's active identity verification provider. + +## 1. Executive decision + +Monnify is not currently a safe drop-in replacement for Twizrr's existing `IdentityVerificationProvider` contract. + +Twizrr currently requires one provider boundary to support: + +- NIN verification; +- NIN selfie/face matching; and +- CAC/business registration verification. + +Monnify's public documentation reviewed for this assessment describes NIN verification, BVN verification, BVN/account-name matching, and bank-account name enquiry. It does not document a selfie/face-match operation or CAC/business-registration verification that would satisfy the current Twizrr contract. + +The approved action is therefore: + +- keep `IDENTITY_VERIFICATION_PROVIDER=prembly`; +- do not add a `MonnifyIdentityVerificationProvider` yet; +- do not split a single verification attempt across Prembly and Monnify; +- do not add automatic provider fallback; and +- reassess only after Monnify supplies an approved contract covering the required checks, testability, privacy controls, and operational behaviour. + +## 2. Official sources reviewed + +- [Monnify customer verification guide](https://developers.monnify.com/docs/verification-api/verifying-your-customers) +- [Monnify API reference](https://developers.monnify.com/api) +- [Monnify BVN/NIN integration guide](https://developers.monnify.com/docs/integration-guide-bvn-nin-update) + +The assessment relies on public Monnify documentation. Any private or account-specific capability must be confirmed through a written Monnify commercial and technical contract before implementation. + +## 3. Current Twizrr verification contract + +| Capability | Current Twizrr requirement | Current provider | Monnify public coverage | Decision | +| --- | --- | --- | --- | --- | +| NIN verification | Verify submitted NIN before selfie progression | Prembly | Documented, but live-only | Insufficient on its own | +| NIN selfie/face match | Compare the verified identity with the submitted selfie | Prembly | Not found in reviewed public documentation | Blocking gap | +| CAC/business verification | Verify business registration for the relevant KYB tier | Prembly | Not found in reviewed public documentation | Blocking gap | +| Normalized statuses | Map provider outcomes to Twizrr-owned statuses | Prembly adapter | Could be normalized | Feasible only after capability gaps close | +| Provider references | Retain safe provider-owned audit references | Prembly adapter | Likely feasible | Must remain internal and provider-bound | +| Attempt limits and tier rules | Twizrr-owned business behaviour | Twizrr domain | Not a provider concern | Must remain unchanged | + +## 4. Monnify capabilities and likely Twizrr use + +| Monnify capability | Availability described publicly | Potential Twizrr use | Boundary decision | +| --- | --- | --- | --- | +| Bank account name enquiry | Sandbox and live | Validate merchant payout-bank details | Belongs to the payout/bank-account boundary, not KYB identity verification | +| BVN/account-name match | Live only | Optional merchant-bank ownership signal | Future narrow capability; not a replacement for NIN face match or CAC | +| BVN details verification | Live only; successful requests are documented as charged | Future regulated verification if product/legal requirements approve it | Do not add without a dedicated product, legal, consent, and retention decision | +| NIN verification | Live only; successful requests are documented as charged | Could perform the initial NIN check | Does not fulfil the complete current Twizrr verification contract | +| NIN selfie/face match | Not found in reviewed public documentation | Required by current Twizrr flow | Do not implement a Monnify adapter without an approved equivalent | +| CAC/business verification | Not found in reviewed public documentation | Required by the current interface and future higher-tier KYB | Do not implement a Monnify adapter without an approved equivalent | + +The public guide states that successful BVN information requests cost NGN 10 and successful NIN requests cost NGN 60, and that requests can fail when the Monnify wallet balance is insufficient. Those are operational dependencies that would need monitoring and rollout gates if Monnify verification is ever adopted. + +## 5. Sandbox and API-contract limitations + +The public verification guide describes name enquiry as available in sandbox and live environments, while NIN and BVN verification are described as live-only. Twizrr must not prove a sensitive verification integration for the first time against real customer identity data. + +The reviewed Monnify documentation also shows inconsistent NIN endpoint naming: + +- the integration guide demonstrates `/api/v1/vas/nin-verification`; while +- the API reference lists `/api/v1/vas/nin-details`. + +This must be resolved in writing with Monnify before implementation. Twizrr must not guess which contract is authoritative. + +## 6. Provider ownership and retry rules + +If Monnify verification is introduced later, each verification attempt must record its immutable provider ownership. An attempt created under Prembly must continue through Prembly; changing the environment selector must affect only new attempts. + +Required future rules: + +- no automatic fallback between Prembly and Monnify; +- no provider switching midway through an attempt; +- no duplicate paid verification call caused by a server retry; +- timeouts remain an unknown/retryable provider outcome unless the current contract proves rejection; +- provider references remain scoped to the provider that created them; and +- Twizrr continues to own tier rules, attempt limits, status transitions, audit policy, and user-facing copy. + +## Provider-owned attempt safety implemented after this assessment + +Twizrr now creates a durable `StoreVerificationAttempt` before submitting a +NIN or NIN face-match request. Each attempt stores its immutable provider, +check type, Twizrr idempotency key, normalized outcome, safe provider +reference when available, and safe failure code. It never stores a raw NIN, +selfie bytes, or provider payload. + +Only one unresolved attempt of a given check type can exist for a store. A +duplicate request therefore observes the existing operation and never calls +Prembly again. Confirmed provider failures close the attempt and preserve the +existing retry/attempt-limit rules. Timeouts or non-final provider outcomes +move to `RECONCILIATION_REQUIRED` and remain blocked from blind resubmission. + +This applies the provider-ownership rule independently of Monnify: an attempt +created under Prembly remains Prembly-owned even if a future provider selector +changes. There is no automatic cross-provider fallback. + +## 7. Privacy and storage assessment + +Any future adapter must never log, emit, or expose: + +- raw NIN or BVN; +- selfie bytes or URLs outside the approved verification boundary; +- CAC documents; +- raw provider payloads; +- provider credentials; +- cookies, authorization headers, or auth tokens; or +- identity data in outbox, notification, DomainEvent, or realtime payloads. + +The current Twizrr verification flow persists the raw NIN on `StoreProfile` after successful face matching. That is existing behaviour, not introduced by this assessment. It should receive a separate privacy, encryption, retention, and access-control review before adding another identity provider. This documentation PR deliberately does not change the schema or current KYB behaviour. + +## 8. Architecture options considered + +### Option A - Replace Prembly with Monnify + +Rejected. Publicly documented Monnify capabilities do not cover the complete current Twizrr contract. + +### Option B - Split one verification journey across providers + +Not recommended now. Using Monnify for NIN and Prembly for face match/CAC would introduce cross-provider identity correlation, more paid calls, harder reconciliation, and ambiguous ownership of a verification attempt. + +### Option C - Keep Prembly as the active provider + +Approved. This preserves current behaviour and the existing provider-neutral domain boundary. + +### Option D - Introduce narrower provider contracts later + +Potential future direction, only if product needs it: + +- `NinVerificationProvider`; +- `FaceMatchProvider`; +- `BusinessRegistrationVerificationProvider`; and +- bank-account ownership verification within the payout domain. + +This split should be a deliberate architecture change, not an adapter workaround. + +## 9. Gates before reconsidering implementation + +- [ ] Monnify confirms the authoritative NIN endpoint and request/response contract. +- [ ] Monnify provides a safe non-production test path or approved synthetic test identities. +- [ ] Required face-match capability is contractually available, or Twizrr explicitly approves narrower provider interfaces. +- [ ] Required CAC/business-verification capability is contractually available, or Twizrr explicitly approves narrower provider interfaces. +- [ ] Product/legal approves BVN/NIN purpose, consent, retention, and deletion rules. +- [ ] Provider pricing, wallet-funding alerts, rate limits, and outage behaviour are documented. +- [ ] Immutable per-attempt provider ownership and idempotency design is approved. +- [ ] Raw identity storage in the existing Twizrr flow has completed its privacy/security review. +- [ ] Sandbox and production rollout gates are approved. + +## 10. Implementation result + +This assessment adds no provider adapter, environment variable, migration, API change, tier-rule change, or frontend change. Prembly remains the only configured and usable identity verification provider. + +Follow-up completed on 19 July 2026: the merchant earnings wallet design is +documented in `TWIZRR_MERCHANT_EARNINGS_WALLET_DESIGN.md`. It remains an +internal ledger/settlement design and must not be confused with issuing +consumer bank wallets. Functional wallet implementation is still deferred to +focused PRs. diff --git a/TWIZRR_MONNIFY_PROVIDER_ASSESSMENT.md b/TWIZRR_MONNIFY_PROVIDER_ASSESSMENT.md new file mode 100644 index 00000000..1c5b2747 --- /dev/null +++ b/TWIZRR_MONNIFY_PROVIDER_ASSESSMENT.md @@ -0,0 +1,661 @@ +# TWIZRR Monnify Provider Assessment + +## Purpose + +This document records the current assessment of Monnify by Moniepoint as a +future Twizrr provider. It is a planning document only. It does not approve a +provider migration, enable live money movement, or change Twizrr's escrow, +ledger, payout, refund, KYB, or checkout behaviour. + +Monnify is a Nigerian payments platform with APIs for collections, transfers, +refunds, reconciliation, recurring payments, reserved accounts, wallets, +verification, bills, and webhooks. Its official API catalogue is available at +[Monnify Documentation](https://developers.monnify.com/), +[Accept Payments](https://developers.monnify.com/docs/collections), and the +[API Reference](https://developers.monnify.com/api). + +## Current Twizrr Provider Setup + +| Contract | Active provider / adapter | Notes | +| --- | --- | --- | +| `PaymentProvider` | Paystack / `PaystackPaymentProvider` | `PAYMENT_PROVIDER=paystack`; Nomba, Flutterwave, and Moniepoint are reserved but not implemented for shopper payments. | +| `PayoutProvider` | Paystack active/default; `MonnifyPayoutProvider` sandbox-gated | Store bank setup follows the active checkout provider for new stores; every payout remains bound to the provider on the original successful payment. | +| `SubscriptionBillingProvider` | Nomba / `NombaSubscriptionBillingProvider` | `SUBSCRIPTION_BILLING_PROVIDER=nomba`; used for StorePass checkout and recurring/tokenized-card billing. | +| `RefundProvider` | Paystack active; `MonnifyRefundProvider` sandbox-gated | Refunds are selected from the immutable original-payment provider, not a global selector. | +| `ShippingProvider` | Shipbubble / `ShipbubbleShippingProvider` | `SHIPPING_PROVIDER=shipbubble`; quotes, bookings, tracking, and verified webhook normalization. | +| `EmailProvider` | Resend / `ResendEmailProvider` | `EMAIL_PROVIDER=resend`; auth OTP/reset and notification email delivery. | +| `IdentityVerificationProvider` | Prembly / `PremblyIdentityVerificationProvider` | `IDENTITY_VERIFICATION_PROVIDER=prembly`; NIN, supported face-match, and CAC verification. | + +The current provider mix is Paystack by default for payments, payouts, and +refunds; Nomba for StorePass billing; Shipbubble for shipping; Resend for +email; and Prembly for KYB. Monnify payment and refund adapters exist but are +disabled by default pending independent sandbox and operational gates. + +## Monnify API Catalogue and Twizrr Fit + +| Monnify capability | APIs / functions | Potential Twizrr use | Recommendation | +| --- | --- | --- | --- | +| Authentication | API-key/secret login; short-lived bearer token | Internal provider-adapter authentication and token caching | Required for every Monnify integration | +| Checkout | Hosted checkout page, checkout API, Web SDK | Alternative buyer-payment checkout under `PaymentProvider` | High priority if Monnify payments are added | +| One-time collections | Card and bank-transfer payment collection | Buyer pays for an order into Twizrr's escrow flow | High priority | +| Payment links | Create shareable payment links | Admin-generated invoices or assisted/offline order-payment collection | Useful later | +| Invoices | Static and dynamic invoices | Formal payment requests, especially for StorePass or admin-assisted collections | Useful later | +| Pay with Bank | Moniepoint bank-payment flow | Low-friction Nigerian bank checkout option | Evaluate with checkout implementation | +| Offline pay-ins | Agent-assisted cash payment collection | Could support cash-to-digital collection, but materially expands reconciliation and fraud/operations scope | Not MVP | +| International payments | International collection methods | Future cross-border shopper payments | Not current scope | +| Transaction verification | Verify transaction/reference and retrieve status | Webhook fallback, payment confirmation, and reconciliation | Required | +| Payment reconciliation | Query and reconcile payment state | Must be part of `PaymentProvider` before enabling Monnify live | Required | +| Refunds | Initiate and track refunds | `RefundProvider` for buyer-win disputes and settlements | High priority after payments | +| Transaction splitting / subaccounts | Split a collection between platform/subaccounts | Tempting for marketplace fees, but can conflict with Twizrr escrow if merchant money moves before delivery confirmation | Do **not** use for escrow payouts | +| Reserved / virtual accounts | Create and manage customer-specific virtual bank accounts | Alternative DVA/bank-transfer checkout and recurring collection | High value; design under `PaymentProvider` | +| Direct debit / mandates | Mandate setup and recurring debit | Future StorePass subscriptions | Later; needs consent, mandate lifecycle, retries, and compliance handling | +| Card tokenization | Store a provider token after a successful payment, charge later | StorePass recurring billing or one-click authorised renewals | Potentially useful; never expose or store raw card data | +| Recurring retry handling | Retry and failure handling for recurring charges | StorePass dunning and retry policy | Useful once recurring is live | +| Single transfers | Transfer to a bank account or wallet | `PayoutProvider` for merchant payouts | High priority if Monnify becomes a payout provider | +| Bulk transfers | Batch disbursements, batch status, and transaction lookup | Finance/admin batch operations, not normal order-by-order escrow release | Later | +| Paycode / offline payouts | Cash withdrawal through an agent using a code | Could support offline merchant payout, but adds a distinct high-risk cash-out workflow | Out of scope | +| Bank list | Supported banks and bank codes | Payout-account setup and recipient validation | Required with Monnify payouts | +| Account-name enquiry | Validate recipient account number/bank code and return account name | Merchant bank-account verification before payout setup | High priority | +| BVN/account matching | Confirm BVN + bank account + name consistency | Stronger merchant payout/KYB verification signal | Optional; live-only and paid | +| BVN details matching | Validate supplied BVN information | KYB enhancement | Optional; do not replace existing tier rules casually | +| NIN verification | Validate NIN information | Could augment `IdentityVerificationProvider` | Useful, but Prembly remains needed for face-match/CAC coverage | +| Wallets | Create wallets; get balances, lists, statements; wallet transfers | Internal treasury/segregated operational balances | Do not introduce without finance/legal design and ledger reconciliation | +| Bill payment | Categories, billers, products, customer validation, vend, and requery | Airtime, data, electricity, cable TV, education, and betting | Separate future product; not marketplace core | +| Webhooks | Payment, transfer/payout, recurring, and other provider events | Verified, normalized state updates for payment/refund/payout reconciliation | Required for every live Monnify money flow | +| Developer tooling | Sandbox, test cards, SDKs, supported-bank list, and error catalogue | Integration testing and operational readiness | Required during implementation | + +Monnify's documentation groups these capabilities into accept payments, +recurring payments, payment management, transfers/payouts, bills, wallets, +verification APIs, and webhooks. + +## Recommended Monnify Provider Roadmap + +### 1. `MonnifyPaymentProvider` + +Implement as a selectable `PaymentProvider`, leaving Paystack as the default +until Monnify is independently validated in sandbox and production rollout. + +Scope: + +- Checkout initialization. +- Payment verification. +- Verified webhook normalization. +- Reconciliation. +- Reserved-account / bank-transfer support where appropriate. + +Rules: + +- Use stable Twizrr payment references and idempotency keys. +- Verify and normalize webhooks before domain handling. +- PostgreSQL remains the source of truth. +- A provider callback or SDK callback must never be treated as authoritative + without guarded persistence and reconciliation. + +### 2. `MonnifyPayoutProvider` + +Implement as a selectable `PayoutProvider`. + +Scope: + +- Supported-bank lookup. +- Account-name enquiry. +- Recipient/account validation. +- Admin-approved merchant transfer submission. +- Transfer-status lookup, webhook normalization, and reconciliation. + +Rules: + +- Preserve payout holds, dispute blocks, and settlement-specific payout rules. +- Provider acceptance is not payout completion. +- A payout is complete only after provider-confirmed success and committed + ledger completion. +- Preserve safe ambiguous-transfer handling: never blindly retry an operation + that may have reached the provider. + +Monnify's transfer APIs send payouts from a Monnify wallet to Nigerian bank +accounts. Their disbursement activation, static-IP allowlisting, and MFA/OTP +requirements need explicit operational design before live use. See +[Monnify Transfers](https://developers.monnify.com/docs/disbursements) and +[Monnify Bulk Transfers](https://developers.monnify.com/docs/disbursements/bulk-transfers). + +### 3. `MonnifyRefundProvider` + +Implement as a selectable `RefundProvider`, used only through the existing +refund and dispute-settlement engine. + +Scope: + +- Refund initiation. +- Provider-status lookup. +- Refund webhook/reconciliation handling. + +Rules: + +- Never write `REFUND_COMPLETED` before confirmed provider success. +- Never write the refund-completion ledger entry before confirmed provider + success and the status transition commit together. +- No provider-specific state may bypass the existing settlement, outbox, + reconciliation, or ledger safeguards. + +### 4. `MonnifyIdentityVerificationProvider` + +Begin with account-name enquiry for payout safety, then evaluate BVN/NIN checks +only after a product, privacy, and policy decision. + +Scope: + +- Bank-account name enquiry. +- Optional BVN and account matching. +- Optional NIN verification. + +Rules: + +- Keep Prembly active for existing NIN, face-match, and CAC coverage. +- Monnify does not replace the CAC or face-match portions of Twizrr's current + identity-verification needs. +- Do not change KYB tiers, attempt limits, public responses, or verification + decision rules in a provider-adapter PR. +- Do not log or expose raw BVN, raw NIN, provider payloads, or credentials. + +Monnify documents bank-account name enquiry plus BVN and NIN verification. +Some verification products are live-only and paid. See +[Monnify Customer Verification](https://developers.monnify.com/docs/verification-api/verifying-your-customers). + +## Wallet Decision + +### Product boundary + +Merchant "wallet" should be a Twizrr earnings view, not a Monnify customer +wallet and not a new stored-value product. + +```text +Twizrr LedgerEntry records + captured / held / available / paid / refunded + +Merchant dashboard displays + pending earnings + held by dispute + available for payout + paid out + +Monnify handles + payment collection, verified payout execution, refunds, and reconciliation +``` + +There is no requirement for merchant top-up, withdrawal balance, peer transfer, +or a separate external wallet account. + +### Wallet models considered + +| Option | Assessment | Reason | +| --- | --- | --- | +| Platform operations wallet | Good future addition | A Monnify wallet can be the controlled funding source for payouts/refunds while the Twizrr ledger remains the financial source of truth. | +| Merchant earnings balance | Good, but Twizrr-led | Display pending, held, available-for-payout, and paid-out values from Twizrr's append-only ledger. Do not give each merchant a real external wallet first. | +| Shopper stored-value wallet | Not now | It adds top-up, withdrawal, KYC, fraud, support, reconciliation, policy, and compliance responsibilities beyond the current escrow marketplace. | + +### Why a Monnify customer wallet is not the first step + +Monnify's wallet creation feature is available only on request and ordinarily +requires a valid BVN matching the customer's date of birth. It can return a +Moniepoint account number for funding and supports debit through transfer APIs. +That is a real customer-money product, not simply a merchant-dashboard balance. +See [Monnify Wallet Creation](https://developers.monnify.com/docs/wallets/create-wallet). + +Reserved accounts have a similar distinction: Monnify describes them as +customer-dedicated virtual accounts for transfer collection and wallet top-ups. +They can require BVN/NIN and support funding restrictions and transaction +limits. See [Monnify Customer Reserved Accounts](https://developers.monnify.com/docs/collections/recurring-payments/reserved-accounts). + +### Safe future use of Monnify wallets + +The first wallet-related capability worth considering is a read-only, +SUPER_ADMIN operational view of: + +- Provider wallet balance. +- Provider wallet statement. +- Reconciliation differences against Twizrr's ledger. + +This must never be shown to a shopper or merchant as their available earnings. + +## Non-Negotiable Financial Rules + +- Twizrr's append-only ledger remains authoritative for Twizrr balances and + merchant earnings. +- A raw Monnify wallet/provider balance is never the merchant's earnings + balance. +- Do not use Monnify transaction splitting, subaccounts, or wallet transfers to + release merchant funds at checkout; doing so would break the delivery-protected + escrow promise. +- Do not introduce shopper top-ups, peer transfers, withdrawals, bills, cash + paycodes, or agent cash flows as part of a provider-abstraction PR. +- Every money-moving integration needs stable Twizrr idempotency references, + verified webhook handling, guarded database transitions, and reconciliation. +- Do not mark payment, payout, or refund completion until provider confirmation + and the matching ledger entry commit. +- Secrets, bearer tokens, raw provider payloads, raw headers, bank details, and + sensitive identity values must not enter logs, events, public DTOs, or socket + payloads. + +## Configuration Direction + +New checkout providers may be selected deliberately, while money movement stays +bound to the provider stored with the original operation: + +```text +PAYMENT_PROVIDER=paystack | monnify +IDENTITY_VERIFICATION_PROVIDER=prembly +``` + +There is intentionally no independent `PAYOUT_PROVIDER` or `REFUND_PROVIDER` +selector. A payout and refund use the immutable provider recorded on the +original successful payment, so a provider setting change cannot reroute an +existing financial operation. This permits gradual migration without a fallback path: Twizrr could use Monnify for new checkout +or payout operations while existing Paystack records continue through Paystack. + +## Proposed PR Sequence + +1. `feat(money): add Monnify payment provider` +2. `feat(money): add Monnify payout provider` +3. `feat(money): add Monnify refund provider` +4. `feat(verification): add Monnify identity verification provider` +5. `feat(admin): add provider wallet reconciliation visibility` +6. `chore(finance): assess customer-wallet product and compliance requirements` + +Only after an explicit product and compliance decision should Twizrr consider: + +```text +feat(wallet): add restricted customer funding-account foundation +``` + +## Implementation Requirements for Every Monnify Provider PR + +- Add a narrow Twizrr-owned provider contract or adapter implementation; do not + expose Monnify client/SDK/response shapes to domain services. +- Keep Paystack/Prembly active defaults until the individual Monnify capability + is approved and validated. +- Use sandbox credentials and mocked tests; do not call live APIs in automated + tests. +- Verify and normalize each Monnify webhook before domain handling. +- Preserve existing public response shapes and Twizrr user-facing language. +- Add provider error normalization and safe structured logs. +- Add idempotency, stale-state, duplicate-webhook, and reconciliation coverage + for every external money write. +- Do not change payment, payout, settlement, order, escrow, or KYB business + rules merely to fit a provider API. + +## References + +- [Monnify Documentation](https://developers.monnify.com/) +- [Monnify API Reference](https://developers.monnify.com/api) +- [Monnify Accept Payments](https://developers.monnify.com/docs/collections) +- [Monnify Transfers/Payouts](https://developers.monnify.com/docs/disbursements) +- [Monnify Customer Verification](https://developers.monnify.com/docs/verification-api/verifying-your-customers) +- [Monnify Wallet Creation](https://developers.monnify.com/docs/wallets/create-wallet) +- [Monnify Customer Reserved Accounts](https://developers.monnify.com/docs/collections/recurring-payments/reserved-accounts) + +## Potential Twizrr Shopper Wallet + +### Product concept + +Monnify can create a digital wallet for a customer and return a Moniepoint account number for it. A shopper can fund the wallet by making a bank transfer to that account number. Monnify can report wallet balances and transactions, and its transfer APIs can debit a wallet account. Wallet creation is available by request and normally requires a valid BVN plus matching date of birth, subject to Monnify's business review. See [Monnify Wallet Creation](https://developers.monnify.com/docs/wallets/create-wallet) and [Wallet Balance](https://developers.monnify.com/docs/wallets/wallet-balance). + +```text +Shopper signs up for Twizrr Wallet + -> Twizrr requests a Monnify customer wallet + -> Monnify returns a dedicated Moniepoint account number + -> Shopper transfers money into that account from any bank app + -> Monnify confirms the funding + -> Twizrr confirms and displays an available balance + +Shopper sees a product in the feed + -> taps Buy with Twizrr Wallet + -> confirms the purchase + -> Twizrr requests a wallet debit to its controlled collection/escrow account + -> Monnify confirms the debit + -> Twizrr records captured payment and escrow in its ledger + -> merchant is paid only after delivery confirmation +``` + +This can make checkout feel in-app: the shopper sees a balance and confirms a payment within Twizrr rather than repeatedly entering bank details. + +### Existing Moniepoint account versus a Twizrr-associated wallet + +Funding a Twizrr-associated wallet from a Moniepoint account is different from linking an existing personal Moniepoint account. + +| Capability | Current conclusion | +| --- | --- | +| Issue a new Twizrr-associated Monnify wallet | Monnify documents wallet creation and returning a Moniepoint account number. | +| Fund it from an existing Moniepoint or any Nigerian bank account | Supported by ordinary bank transfer to the issued account number. | +| Read a shopper's existing personal Moniepoint-account balance | Do not assume this is available. | +| Directly debit a shopper's existing personal Moniepoint account after it is connected | Do not assume this is available; it needs explicit Monnify product/API confirmation, consent, and authorization rules. | +| Use an issued wallet balance for a Twizrr purchase | Potentially possible, but it requires a dedicated payment flow, not a generic transfer shortcut. | + +No official documentation reviewed for this assessment promises an OAuth-style "connect your existing Moniepoint personal account" flow. Twizrr must confirm that capability directly with Monnify before designing around it. + +### Checkout without switching apps + +**Embedded Monnify checkout** is the safe near-term path. A shopper taps Buy and Twizrr opens Monnify checkout in the web experience. The shopper may select card, bank transfer, USSD, phone, or another supported method. It can feel mostly in-app, although a bank can still require authentication in its own app. Monnify's Web SDK is intended for this integrated checkout experience. See [Monnify Checkout](https://developers.monnify.com/docs/monnify-checkout/). + +**Stored Twizrr wallet balance** is a later path. Once a shopper deliberately funds a Twizrr-associated wallet, checkout could show: + +```text +Pay with Twizrr Balance +Available: ?18,500 +Order total: ?12,000 +[Confirm payment] +``` + +A logged-in session alone must never silently debit money. The flow needs a deliberate confirmation and an agreed step-up control, such as a transaction PIN, device confirmation, OTP, or another approved provider authorization mechanism. + +```text +Shopper wallet + -> Twizrr controlled collection / escrow account + -> held in Twizrr ledger as captured funds + -> merchant payout only after delivery confirmation +``` + +This is prohibited because it bypasses Twizrr Buyer Protection: + +```text +Shopper wallet -> merchant wallet/account +``` + +### Balance boundaries + +| Surface | What it represents | Source of truth | +| --- | --- | --- | +| Merchant earnings | Pending, held, available-for-payout, and paid-out earnings | Twizrr append-only ledger | +| Shopper Twizrr balance | Funds deliberately placed in a Twizrr-associated wallet | Monnify wallet plus Twizrr's mirrored, reconciled wallet ledger | +| Escrow per order | Funds captured for one order pending delivery/dispute outcome | Twizrr payment and ledger records | + +### Required design work before a shopper wallet + +A shopper wallet is a real stored-value product. Before implementation, Twizrr must design and approve wallet funding and verified crediting; checkout authorization; low-balance handling; refunds to wallet versus original payment route; reversals and ambiguous-debit reconciliation; transaction history; fraud controls and recovery; support and complaints; KYC/privacy/operational/policy requirements; transaction limits; and strict reconciliation between Monnify and Twizrr. Monnify's reserved accounts are documented for wallet top-ups and can support limits and funding-source restrictions. See [Customer Reserved Accounts](https://developers.monnify.com/docs/collections/recurring-payments/reserved-accounts). + +### Recommended shopper-wallet rollout + +1. Build `MonnifyPaymentProvider` first. Embedded checkout reduces app switching and proves token management, webhooks, verification, reconciliation, and money safety. +2. Build `MonnifyPayoutProvider` and `MonnifyRefundProvider` to prove the full escrow lifecycle remains safe with Monnify. +3. Request a wallet product and technical review from Monnify before writing wallet code: activation, direct wallet collection, shopper debit authorization, refunds/reversals, webhooks, KYC/BVN/NIN, limits, fees, settlement timing, liability, and support model. +4. If approved, start with a constrained foundation: create wallet, show balance and statement, allow funding by transfer, and do not add withdrawal, peer transfer, bills, cash-out, or Pay with Twizrr Balance until debit authorization and reconciliation are proven. + +This path supports the desired feed-to-purchase experience without turning Twizrr into a loosely controlled wallet product. + +## Monnify Dashboard and Twizrr + +The Monnify dashboard is the external control plane for any Monnify provider that Twizrr adds. It does not connect to Twizrr automatically. It is where Twizrr obtains credentials, activates products, configures provider-side webhooks, and monitors provider-side transactions. + +Until `MonnifyPaymentProvider`, `MonnifyPayoutProvider`, `MonnifyRefundProvider`, or `MonnifyIdentityVerificationProvider` exists, do not add Twizrr webhook URLs or copy credentials into the repository. + +| Dashboard area | Relation to future Twizrr work | What to do now | +| --- | --- | --- | +| Developers -> API keys / contracts | Supplies API key, secret, contract code, and sandbox/live credentials for the Monnify adapter | Familiarise yourself; never paste secrets into chat, code, or committed `.env` files | +| Sandbox vs live mode | Twizrr will use sandbox first; sandbox and live credentials/contracts are separate | Start in sandbox only | +| Developers -> Webhook URLs | Future endpoints for payment completion, refunds, disbursements, and settlement notifications | Leave unset until backend webhook handlers exist and are publicly reachable | +| Settings -> Bank accounts / settlement | Where Monnify sends money settled to Twizrr's business account | Confirm this is the correct Twizrr business account before live activation | +| Contracts setup | Determines collection configuration, contract code, and payment settings | Do not enable split payments for marketplace escrow | +| Disbursements | Powers future merchant payouts through `MonnifyPayoutProvider` | Requires Monnify activation and server-IP allowlisting before live use | +| Wallets | Future shopper-wallet capability: create customer wallets, retrieve balances/statements | Explore only; wallet access is by request and requires a separate product/compliance decision | +| Verification APIs | Future account-name enquiry, optional BVN/NIN checks | Useful later; do not change existing Prembly KYB flow yet | +| Transactions / settlements | Provider records for support and reconciliation | Helpful operationally, but Twizrr's PostgreSQL ledger remains authoritative for Twizrr balances | + +### Expected implementation sequence + +```text +Monnify sandbox account + -> build and test Monnify adapter locally with sandbox credentials + -> add verified webhook endpoints + -> configure sandbox webhook URLs in dashboard + -> prove reconciliation/idempotency + -> complete operational approval + -> use separate live credentials and live webhook URLs +``` + +For a shopper wallet, the dashboard matters later because Monnify can create and manage customer wallets, retrieve balances, and generate statements. That is not a dashboard toggle: it needs provider approval, wallet KYC requirements, backend reconciliation, buyer authorization before debits, and a full customer-money support model. Monnify says wallet creation is available on request, and its wallet creation flow normally uses BVN/date-of-birth checks. See [Monnify Wallet Documentation](https://developers.monnify.com/docs/wallets/create-wallet). + +### Useful dashboard areas to inspect now + +1. Sandbox API credentials and contract code, without sharing them. +2. Whether the account is in sandbox versus live mode. +3. Developers/Webhook URLs, only to understand available event categories. +4. Disbursement and wallet activation requirements. +5. Settlement bank-account configuration. +6. Supported-bank and transaction settings. + +When implementation begins, sandbox values belong only in a private local environment, for example: + +```text +MONNIFY_API_KEY= +MONNIFY_SECRET_KEY= +MONNIFY_CONTRACT_CODE= +MONNIFY_BASE_URL=https://sandbox.monnify.com +``` + +Only empty placeholders may appear in `.env.example`; never real values. Monnify's live checklist confirms that live credentials, contract codes, webhook configuration, settlement account review, and disbursement IP allowlisting are separate production concerns. See [Monnify Going Live](https://developers.monnify.com/docs/live). + +# Monnify Provider Implementation Roadmap + +## Guiding rule + +A money operation is permanently bound to the provider that created it. + +- A Paystack payment is verified, refunded, and reconciled through Paystack. +- A Monnify payment is verified, refunded, and reconciled through Monnify. +- A provider timeout becomes reconciliation work, not a request to try another provider. +- Existing Paystack records remain supported after Monnify goes live. + +This prevents duplicate charges, double refunds, and mismatched ledger reconciliation. + +## Phase A — Monnify readiness and product decisions + +Before implementation: + +1. Confirm Monnify live-account approval, contract code, API credentials, webhook setup, and disbursement/refund access. +2. Confirm whether production transfers require MFA/OTP. Twizrr automated merchant payouts need an approved operational approach before Monnify transfers can be enabled. +3. Confirm static outbound backend IPs for Monnify disbursement allow-listing. +4. Confirm refund activation. Monnify documents refunds as separately activated and notes that virtual-account payments cannot be refunded back to that virtual account without a valid alternate destination. +5. Establish sandbox-only credentials and test webhook URLs. Never place live credentials in development or CI. + +Deliverable: an approved Monnify integration checklist, not application code yet. + +## Phase B — Shared provider contract hardening + +Before adding Monnify adapters, confirm existing Twizrr-owned contracts represent the necessary lifecycle without provider-specific fields: + +- `PaymentProvider`: initialize checkout, verify transaction, and verify/normalize payment webhooks. +- `PayoutProvider`: validate a recipient, initiate a transfer with a stable Twizrr reference, fetch transfer status, and verify/normalize transfer webhooks. +- `RefundProvider`: initiate a refund with a stable Twizrr reference, fetch refund status, and verify/normalize refund webhooks. +- `IdentityVerificationProvider`: only if Monnify offers approved capabilities that fulfil Twizrr KYB requirements. + +Every money record must retain the provider selected at creation. It must never be switched later. + +## Phase C — `MonnifyPaymentProvider` + +Build the adapter behind `PaymentProvider`. + +- Initialize Monnify checkout or bank-transfer payment. +- Persist the Twizrr order reference with the Monnify transaction reference. +- Verify every successful transaction server-to-server before marking payment successful. +- Verify the production `monnify-signature` HMAC-SHA512 webhook signature. +- Dedupe webhook deliveries and use guarded payment transitions. +- Route asynchronous work through the existing outbox; do not mutate order state directly in the webhook controller. + +Roll out through sandbox tests, allowlisted internal orders, limited checkout exposure, then general availability after reconciliation metrics are healthy. + +## Phase D — `MonnifyRefundProvider` + +Build this only after the payment adapter is stable. + +- Initiate refunds with deterministic Twizrr refund references. +- Treat accepted or pending refunds as `SUBMITTED`, never completed. +- Confirm completion through a verified webhook and/or provider status query. +- Write `REFUND_COMPLETED` ledger entries only after provider-confirmed completion. +- Treat ambiguous timeouts as reconciliation-required; never resubmit blindly. +- Handle Monnify's virtual-account refund destination limitation. + +The dispute settlement engine remains authoritative for approved amounts, ledger rules, and state transitions. The adapter translates provider operations only. + +### Implementation status + +Implemented in `feat(refund): add Monnify refund provider adapter`: + +- `MonnifyRefundProvider` behind the Twizrr `RefundProvider` contract. +- Stable `twz-refund-{settlementLegId}` refund references for settlement + submissions. +- Provider binding from the original `Payment.provider`; no global refund + selector and no fallback to a different provider. +- Monnify transaction-reference use for Monnify-originated payments, with + server-side refund lookup for reconciliation. +- A verified `POST /refunds/webhook/monnify` endpoint that only wakes the + existing reconciliation path; it never completes money movement directly. +- `MONNIFY_REFUND_ENABLED=false` default, requiring API key, secret, and base + URL when enabled. It gates new submissions only, not reconciliation. + +Open rollout requirements remain: confirm refund activation in the Monnify +account, validate the full sandbox refund/webhook flow, and decide a safe +alternate-destination product flow for virtual-account payments. Until that +destination flow exists, unavailable or unsafe automated refunds remain in +reconciliation/manual review rather than being re-routed or blindly retried. + +### Completion-safety test coverage + +`test(refund): prove Monnify refund completion safety` extends the guarded +PostgreSQL settlement suite with a Monnify-originated payment. It proves that a +submitted Monnify refund keeps its Monnify transaction/refund references, +writes no `REFUND_COMPLETED` entry before confirmed status, and still produces +one completion ledger entry when concurrent workers confirm the same result. + +Run it only against a disposable local pgvector PostgreSQL database: + +```text +SETTLEMENT_TEST_DATABASE_URL=postgresql://.../twizrr_settlement_test +pnpm --dir apps/backend exec prisma migrate deploy --config prisma.settlement-test.config.ts +pnpm --dir apps/backend run test:settlement:postgres +``` + +The suite refuses `DATABASE_URL`, shared Neon, staging, and production-looking +hosts. It is evidence for sandbox rollout only; it does not enable +`MONNIFY_REFUND_ENABLED` or make a live provider request. + +## Phase E — `MonnifyPayoutProvider` + +This is the highest-risk adapter and follows payment/refund production evidence. + +- Use bank lookup/name enquiry before transfer. +- Submit transfers with stable Twizrr payout references. +- Map Monnify pending, MFA, success, failed, and reversed statuses to Twizrr normalized payout states. +- Require reconciliation after every ambiguous provider outcome. +- Verify and dedupe disbursement webhooks. +- Never permit retry from `SUBMITTED`, `PROCESSING`, or reconciliation-required states. + +Do not move an existing Paystack-originated operation to Monnify. New payouts +derive their provider from the successful payment that funded their order. + +Implemented in `feat(payout): add Monnify payout provider adapter`: + +- `MonnifyPayoutProvider` behind the Twizrr-owned `PayoutProvider` contract. +- Bank lookup, account-name enquiry, single disbursement submission, and + provider-status lookup through the shared authenticated Monnify client. +- Payout ownership derived from the successful payment provider, with no + fallback or cross-provider settlement, and `MONNIFY_PAYOUT_ENABLED=false` by + default. +- Provider-operation rejection when Monnify payout execution is disabled; the + adapter has no Paystack fallback and complete API/source-account configuration + is required when `MONNIFY_PAYOUT_ENABLED=true`. +- Immutable payout provider/destination binding and separate `PayoutAttempt` + records for every provider submission reference. +- Pending, processing, unknown, and `PENDING_AUTHORIZATION` results remain + submitted for reconciliation; only provider-confirmed success completes the + ledger movement. +- HMAC-verified disbursement webhooks wake reconciliation by immutable attempt + reference and never update money state directly. +- Provider-neutral payout references in the SUPER_ADMIN payout API. + +Open rollout requirements remain: activate disbursements with Monnify, confirm +the approved source account, register production static outbound IPs, obtain +approval to disable disbursement MFA for Twizrr's automated API account, run a +manual sandbox transfer/reconciliation exercise, and complete the dedicated +transfer concurrency/ledger proof before any live enablement. + +### Transfer reconciliation safety coverage + +`test(payout): prove Monnify transfer reconciliation safety` extends the +disposable PostgreSQL settlement suite through the real `PayoutService`. It +proves that concurrent workers create one immutable Monnify payout attempt and +one provider submission; concurrent successful reconciliation produces one +`PAYOUT_COMPLETED` ledger entry, one completion domain event, and one merchant +notification; duplicate verified webhook wake-ups do not complete money +movement; ambiguous submission timeouts remain reconciliation-only; and a +later global provider selection change cannot move an existing Monnify payout +to Paystack. + +Run this evidence only against the local disposable pgvector database: + +```text +SETTLEMENT_TEST_DATABASE_URL=postgresql://.../twizrr_settlement_test +pnpm --dir apps/backend exec prisma migrate deploy --config prisma.settlement-test.config.ts +pnpm --dir apps/backend run test:settlement:postgres +``` + +The suite refuses `DATABASE_URL`, shared Neon, staging, production-looking +hosts, and non-test database names. This is automated safety evidence only. It +does not enable `MONNIFY_PAYOUT_ENABLED`, approve production transfer MFA +changes, register outbound IPs, or replace the required manual Monnify sandbox +exercise. + +## Phase F — Monnify identity-verification assessment + +Assessment completed on 18 July 2026. Monnify's reviewed public documentation covers NIN, BVN, account-name matching, and name enquiry, but does not document the face-match and CAC capabilities required by Twizrr's current provider contract. Prembly therefore remains the active provider and no `MonnifyIdentityVerificationProvider` is approved. See `TWIZRR_MONNIFY_IDENTITY_VERIFICATION_ASSESSMENT.md` for the capability matrix, privacy findings, and reconsideration gates. + +## Phase G — dashboard and webhook operations + +Configure separate sandbox and live credentials. Add Transaction Completion webhooks first; add Refund Completion, Disbursement, and Settlement webhooks only when those features are enabled. Restrict dashboard access, register static outbound IPs for transfers, and rotate credentials through a controlled process. + +## Phase H — tests and rollout gates + +Every adapter needs unit mapping tests, duplicate-webhook tests, concurrent reconciliation tests, idempotency tests, ledger-invariant tests, signature-rejection tests, and sandbox integration tests with no live funds. + +Recommended feature flags: + +```env +PAYMENT_PROVIDER=paystack +MONNIFY_PAYMENT_ENABLED=false +MONNIFY_REFUND_ENABLED=false +MONNIFY_PAYOUT_ENABLED=false +``` + +Do not set `PAYMENT_PROVIDER=monnify` until the payment adapter passes its +rollout gates. Refund and payout execution gates remain independently controlled, +but any operation they execute remains bound to the original payment provider. + +## Suggested PR sequence + +1. `chore(monnify): document provider onboarding and rollout gates` +2. `feat(payment): add Monnify payment provider adapter` +3. `test(payment): prove Monnify webhook and reconciliation idempotency` +4. `feat(refund): add Monnify refund provider adapter` +5. `test(refund): prove Monnify refund completion safety` +6. `feat(payout): add Monnify payout provider adapter` +7. `test(payout): prove Monnify transfer reconciliation safety` +8. `chore(verification): assess Monnify identity capabilities against Twizrr KYB` +9. `feat(wallet): design merchant earnings wallet separately` - design completed + +The store earnings balance design is documented in +`TWIZRR_MERCHANT_EARNINGS_WALLET_DESIGN.md`. It is a Twizrr-led, ledger-derived +earnings view rather than a Monnify customer wallet or stored-value product. +Functional implementation remains a separate series of focused PRs and must +not be bundled into a provider switch. + +## Monnify payment adapter implementation status + +The payment adapter is introduced as a sandbox-gated hosted-checkout adapter. +`MONNIFY_PAYMENT_ENABLED` defaults to `false`; no environment may switch +`PAYMENT_PROVIDER=monnify` until the corresponding sandbox and operational +gates pass. Payments persist their immutable provider owner and a neutral +provider reference, so a later configuration change cannot reroute an existing +payment to another provider. There is no automatic provider fallback. + +Production Monnify webhooks must validate `monnify-signature` using +`MONNIFY_SECRET_KEY`. Monnify documents that sandbox webhooks do not include +that signature, so unsigned sandbox webhooks are deliberately ignored and +server-side transaction verification is used for sandbox confirmation. diff --git a/TWIZRR_MVP_SCOPE.md b/TWIZRR_MVP_SCOPE.md new file mode 100644 index 00000000..ed509257 --- /dev/null +++ b/TWIZRR_MVP_SCOPE.md @@ -0,0 +1,964 @@ +# twizrr — MVP Scope +> Last updated: May 2026 +> Purpose: The complete specification of what must be built +> for a fully working MVP launch. Everything in this document +> must work end-to-end before launch. Nothing here is optional. +> Deferred items are explicitly listed at the end. + +--- + +## MVP Definition + +``` +THE FULL LOOP MUST WORK: +Store lists product → Shopper discovers via WhatsApp AI +→ Shopper pays (Paystack) → Payment is protected +→ Store prepares order → delivery is handled by twizrr +→ Shopper confirms delivery with a delivery code +→ Store receives payment automatically + +SUCCESS METRIC: +└── 50+ completed orders in the first month + Real stores, real shoppers, real payments, real payouts + If the full loop works — the MVP works + +LAUNCH STRATEGY: +└── Week 1-2: Onboard 10 Balogun Market physical stores + 50+ products listed, allowDropship enabled + Week 3-4: Onboard 3 Yaba dropship digital stores + Products sourced, customised, priced + Week 5-6: WhatsApp AI activated + 50 shoppers invited (friends, family, testers) + Week 8+: Public Lagos launch + +GEOGRAPHIC SCOPE: Lagos only +LAUNCH CLUSTERS: +├── Balogun: supplier/source inventory cluster +└── Yaba: youth/student/dropshipper/shoppers cluster +CHANNEL SPLIT: +├── WhatsApp: shoppers discover and buy +└── Web: store owners manage and list +``` + +--- + +## 1. Authentication and Onboarding + +### User Signup +``` +METHODS: Google OAuth | Apple Sign In | Email + password +FLOW (8 screens): +├── Sign up method selection +├── Email verification (OTP — 15 min TTL) +├── Phone number (E.164 format — +234XXXXXXXXXX) +├── Date of birth (age gate) +│ Under 13: blocked ("You must be 13+ to join twizrr") +│ 13-17: can shop, cannot open store +│ 18+: full access including store creation +├── Username @handle (7-day change lock after signup) +├── Interests selection (feeds personalised feed) +├── Profile photo + bio (optional — prompts appear later) +└── Store prompt (18+ only): "Do you want to open a store?" + +DATABASE: +└── User model fields: + id, email, phone, username, displayName, bio (max 180 chars), + profilePhotoUrl, dateOfBirth, isMinor (computed), + emailVerified, phoneVerified, createdAt + bodyMeasurements: Json? (null at signup) + deliveryAddresses: Json? (null at signup) +``` + +### Store Setup +``` +TRIGGERED: during onboarding prompt OR Profile Menu → [Manage Store] +AVAILABLE TO: users 18+ only (enforced at API level) + +FLOW (6 screens): +├── Store type selection: +│ DIGITAL STORE: no physical location +│ PHYSICAL STORE: has a real business location +│ +├── Identity (store handle + name): +│ @handle (separate from personal @username — 7-day lock) +│ Store name (max 50 chars) +│ +├── Business details: +│ Business category (dropdown) +│ Product sub-categories (multi-select) +│ Digital store: home address (Google Maps — internal only; never a delivery pickup point) +│ Physical store: business address (Google Maps — public) +│ +├── Bank details: +│ Bank name (dropdown — Nigerian banks) +│ Account number (10 digits) +│ Paystack Resolve: auto-fetches account name +│ Store owner confirms account name before proceeding +│ +├── Store profile: +│ Store logo (upload — Cloud Vision moderation before Cloudinary) +│ Store banner (upload — same moderation pipeline) +│ Store bio (max 180 chars) +│ +└── Go live → store created → dashboard opened + +DATABASE: +└── StoreProfile model fields: + id, userId, storeHandle, storeName, storeType (DIGITAL|PHYSICAL), + bio, logoUrl, bannerUrl, businessCategory, + businessAddress: Json? (physical stores) + homeAddress: Json? (digital stores — internal only) + bankName, accountNumber, accountName, paystackRecipientCode, + tier (TIER_0 → TIER_1 → TIER_2 → TIER_3), + isOpen (default true), showOwnerPublicly (default true), + allowDropship (global toggle — physical stores), + ninVerified, cacVerified, addressVerified, + dispatchTimeAvgHours, orderCompletionRate, disputeCountLast6Months +``` + +--- + +## 2. Product Listing + +### Product Listing Form (4 screens) + +``` +SCREEN 1 — PHOTOS: +├── Upload 1-5 images +├── Cloud Vision SafeSearch runs before Cloudinary upload: +│ BLOCKED: rejected immediately ("This image violates our policy") +│ SENSITIVE: uploaded with flag (shown blurred) +│ SAFE: uploaded normally +├── Drag to reorder (first = default feed thumbnail) +└── Variant image assignment (if colors defined in Screen 3): + Tap photo → assign to [Red] [Blue] [Black] [All] + "All" = shown for all colors (fallback) + +SCREEN 2 — PRODUCT DETAILS: +├── Product name (max 100 chars — required) +├── Description (max 1,000 chars — required) +├── Platform category (required — 20 options) +│ Drives: feed algorithm + WhatsApp AI search +├── Product sub-category (shown for fashion/footwear — drives size guide): +│ CLOTHING_WOMEN | CLOTHING_MEN | CLOTHING_CHILDREN | CLOTHING_UNISEX +│ FOOTWEAR_WOMEN | FOOTWEAR_MEN | FOOTWEAR_CHILDREN +├── Store tags (max 3 — optional): +│ e.g. "Ankara", "Dress", "Lagos Made" +│ Creates filter chips on store Products tab +├── Product details / specifications (optional — free-form): +│ Key-value pairs: Material: 100% Cotton, Care: Hand wash +│ Max 20 rows, attribute max 50 chars, value max 200 chars +│ Shown in "Product Details >" modal on product page +├── SKU (optional — store's own reference code, shown publicly) +└── Bundle footwear toggle: "This product includes shoes" + If ON: footwear size guide also shown on product page + +SCREEN 3 — VARIANTS AND PRICING: +├── VARIANTS TOGGLE: "This product has multiple sizes or colors" +│ IF OFF: single stock quantity input +│ IF ON: +│ Dimension 1 type: Color | Pattern | Style +│ Values: [Red] [Blue] [Black] + chips +│ Dimension 2 type: Size | Weight | Volume +│ Values: [S] [M] [L] [XL] + chips +│ Auto-generated combinations table: +│ Variant | Price (₦) | Stock | Active +│ Red-S | 35,000 | 5 | [ON] +│ Red-M | 35,000 | 8 | [ON] +│ ... +│ Pre-fill all prices: [Use ₦35,000 for all] shortcut +│ Active toggle: OFF = variant hidden from shoppers +│ Stock 0: shown as "Out of Stock" (greyed — still visible) +│ +├── RETAIL PRICE (required): +│ ₦[input] — shown publicly on product page +│ +├── COMPARE-AT PRICE (optional — discount badge): +│ ₦[input] — shows as crossed-out price +│ Badge auto-calculated: "SAVE X%" +│ +├── VOLUME PRICING (optional — for bulk buyers): +│ [+ Add volume tier] +│ Tier: Buy [N] or more → ₦[price] each +│ Shown as table on product page, auto-applied at cart +│ +├── NOT SOLD INDIVIDUALLY (optional toggle): +│ Minimum quantity: [N] units +│ Hides buy button from regular shoppers +│ Visible to: bulk buyers (who select min qty) + sourcing dropshippers +│ +└── ALLOW SOURCING (physical stores only): + Toggle: "Allow other stores to source this product" + Dropshipper price: ₦[input] (optional — floor for sourcing) + If empty: dropshippers pay retail price + +SCREEN 4 — SIZE GUIDE AND PUBLISH: +├── SIZE GUIDE PREVIEW: +│ Shows platform guide for selected sub-category +│ "My sizing is different from standard" toggle +│ If ON: store enters own measurements per size +│ +├── MODEL MEASUREMENTS (optional): +│ Height, bust, waist, hips (cm) +│ Which size the model is wearing +│ Shown at bottom of size guide: "Model is wearing: M" +│ +└── PUBLISH OPTIONS: + [Publish Now] — product goes live immediately + [Save as Draft] — saved but not visible to shoppers + + ON PUBLISH: + ├── Product + variants created in database + ├── InventoryEvent (INITIAL_STOCK) per variant logged + ├── ProductStockCache populated + ├── Post created (PRODUCT_POST with taggedProductId) + ├── BullMQ: embedding_generation job queued + │ Vertex AI: image + text → 1408-dim vector stored in pgvector + │ Product.embeddingReady = true (~30 seconds) + └── Product appears in feed + searchable immediately +``` + +### Size Guide System (Platform-Managed) +``` +TWO SEPARATE GUIDE TYPES: + +CLOTHING SIZE GUIDE (interactive body diagram + table): +└── Size chips: tap → measurements update on body diagram live + CM / IN toggle + Standard / UK system toggle + Table: Size | Bust | Waist | Hips | Height + Model info strip (if store provided model measurements) + "How to Measure" tab: 4 measurements + numbered body diagram + +FOOTWEAR SIZE GUIDE (table only — no body diagram): +└── Size system switcher: EU | UK | US (dropdown) + CM / IN toggle (for foot length column) + Table: EU | UK | US | Foot length (cm) + "How to Measure" tab: foot length only + +BUNDLE (clothing + shoes): +└── Three tabs: [Clothing] [Footwear] [How to Measure] + Clothing size drives the variant at MVP + Shoe size stated in product description + +SEEDED AT DEPLOYMENT: +└── SizeGuide records created by prisma/seed.ts + Store owners do NOT create size guides + Store owners can override with own measurements (optional) +``` + +--- + +## 3. Source Products (Digital Stores) + +``` +CATALOGUE (visible to digital store owners in store mode only): +└── All physical store products where allowDropship = true + Filtered to: Lagos stores (relevant at MVP) + + FILTERS: Category chips (horizontal scroll) | [In Stock] toggle + SORT: Trending | Newest | Price: Low-High | Price: High-Low + + EACH PRODUCT CARD SHOWS: + ├── Product image + name + ├── Physical store @handle (visible to dropshipper — never to shoppers) + ├── Physical store verification badge + ├── Your cost: dropshipperPriceKobo (or retailPrice if no discount) + ├── Retail price: what physical store charges shoppers directly + ├── twizrr suggested sell price: cost × category markup + ├── Stock: real-time from ProductStockCache + └── Variant summary: available sizes + colors + +SOURCING A PRODUCT: +└── [Source This Product] → bottom sheet + ├── Your cost: ₦28,000 (read-only) + ├── twizrr suggested: ₦36,500 (guidance only) + ├── Your selling price: ₦[input] (must be ≥ cost floor) + │ Hard floor enforced: DB + API + frontend minimum + │ Cannot save below floor — button disabled + └── Live margin: "Your margin: ₦X · After 2% fee: ₦X net" + + [Confirm] → SourcedProduct record created + Product appears in their Products tab immediately + +CUSTOMISATION (after sourcing): +├── Custom name (their own marketing copy) +├── Custom description (their own copy) +├── Custom photos (their own images — OR use original) +└── Their own volume pricing tiers (separate from physical store's) + +CANNOT CHANGE: +├── Variants (inherited from physical store) +└── Stock count (real-time synced from physical store) + +FLOOR CHANGE (physical store raises dropshipper price): +└── BullMQ job: check_sourced_product_floors + If sellingPrice < new floor: + SourcedProduct.isActive = false + Dropshipper notified: "Update your selling price to continue selling" + New orders blocked until updated +``` + +--- + +## 4. Shopping and Discovery + +### Product Detail Page +``` +├── Image gallery (1-5 images, variant image mapping) +│ Tap color → gallery switches to assigned images +├── IN STOCK / OUT OF STOCK badge + SKU +├── Product name (bold) +├── Price: ₦185,000 (monospace) +│ Compare-at: ₦210,000 crossed out + "SAVE 12%" badge (if set) +├── Rating: X.X stars + review count + views this week +├── Store card: logo + name + followers + rating + [Follow] +├── Description + "Read more" (truncated at 3 lines) +├── Product Details > link (if productDetails not null) +│ Opens bottom sheet: attribute-value table +├── Variants: color chips + size chips +│ Tap color → images switch + OOS variants greyed +│ Tap size → stock badge updates +├── [Size Guide] link (if productSubCategory is set) +│ Bottom sheet: Clothing or Footwear guide +│ For bundles: [Clothing] [Footwear] [How to Measure] tabs +├── Delivery options preview (before checkout): +│ Delivery estimate handled by twizrr from fulfillment pickup point to shopper dropoff +│ Physical store pickup = physical store pickup address +│ Dropship pickup = source physical supplier pickup address (never digital home) +│ MVP may use Lagos selected-zone pricing before full live logistics pricing +│ Cache: Redis 30-min TTL per productId + shopperCity +├── twizrr Buyer Protection badge +├── Review section (scrollable) +└── Sticky bottom bar: [Add to Cart] [Buy Now — ₦185,000] +``` + +### Checkout Flows (3 entry points) +``` +1. CART → CHECKOUT: + Cart page (from Profile Menu → Cart) + Products + variants + quantities listed + [Proceed to Checkout] → confirm address + delivery phone → select payment → confirm + +2. PRODUCT DETAIL → BUY NOW: + [Buy Now — ₦185,000] button + Variant MUST be selected first ("Please select a size") + Skips cart entirely → address + delivery phone + payment → confirm + Cart untouched + +3. FEED CARD → QUICK BUY: + [Quick Buy] on product post in feed + Never leaves the feed + Bottom sheet: variant select + address + delivery phone + payment method + [Confirm] + +CHECKOUT DELIVERY POLICY: +├── Checkout does not expose old store-delivery or personal-delivery choices +├── Checkout requires delivery address +├── User.phone is the default primary delivery phone +├── Shopper may add an optional secondary delivery phone +├── Shopper delivery phones are private platform data +├── Delivery phone/address may be shared only with approved logistics providers +├── Delivery fee is calculated by twizrr from pickup point to shopper dropoff +└── Shopper pays subtotal + delivery fee only + The platform fee is deducted from the store's proceeds, never added to + the shopper's checkout total. + +ALL FLOWS END AT ORDER CONFIRMED SCREEN: +└── Large checkmark + "Order Placed!" + "We're letting [Store Name] know. + Your delivery code is ready — keep it private." + [twizrr Buyer Protection — payment protected] pill + Order summary (#TWZ-XXXXXX + product + variant + amount) + Estimated delivery + delivery address + payment method + "What Happens Next" (3 numbered Buyer Protection education steps) + [Track Order →] primary CTA (full-width saffron button) + [Continue Shopping] secondary text link +``` + +### Feed and Explore +``` +4 FEED TABS: +├── For You: personalised (UserInterestProfile + FeedInteraction signals) +├── Following: chronological (followed stores/users only) +├── Stores: store discovery + category browse +└── Explore: platform-wide trending + +EXPLORE TAB (5 sections): +├── Trending Hashtags: top 10 by weeklyCount (updated hourly) +├── Trending Products: top 10 by viewCountWeekly (updated daily) +├── New Verified Stores: recently Tier 1+ verified (last 30 days) +├── Trending Posts: top 30 by engagement score (7-day window) +└── Browse by Category: all 20 platform categories + +SEARCH PAGE (from search bar): +└── 4 tabs: Products | Stores | Posts | People + Products: hybrid search (pgvector + tsvector + metadata + trust) + Filter: category, price range, location, in-stock, tier + Tier 0 stores: EXCLUDED from all search results always +``` + +--- + +## 5. Order Management + +### Order Lifecycle +``` +STATES: +PENDING_PAYMENT → PAID → DISPATCHED → DELIVERED → COMPLETED + ↘ CANCELLED (at any pre-dispatch stage) + ↘ DISPUTE (during 72hr window) + +ORDER CODE FORMAT: TWZ-XXXXXX (6 random digits — e.g. TWZ-384729) + +ITEMS JSON (snapshot at order time): +└── { productId, variantId, variantName ("Red - Size M"), + variantAttributes { color, size }, quantity, unitPriceKobo } + Variant snapshot prevents broken orders if variant is later deleted + +AUTO-CONFIRM: +└── BullMQ job scheduled at DISPATCHED + 48 hours + Warning notification: DISPATCHED + 36 hours ("12 hours left") + If order still DISPATCHED at +48hrs: auto-confirmed, payout triggered +``` + +### Order Tracking (Shopper View) +``` +ACCESSED FROM: Profile Menu → Orders → tap order +OR: [Track Order →] on Order Confirmed screen + +FILTER TABS: All | Processing | Dispatched | Delivered | Returns + +VERTICAL TIMELINE STEPPER: +● Order Placed ✓ [timestamp] (green when done) +● Processing ✓ [timestamp] +● Dispatched ✓ [timestamp] +○ Delivered → Est. [time] (saffron = active step) +○ Completed → Pending (grey) + +ACTION SECTION (changes by status): +├── PAID/PROCESSING: [Cancel Order] +├── DISPATCHED: 6-digit code in boxes + [Track Shipment] +│ "Share this code ONLY when item arrives" +├── DELIVERED: [Confirm I Received It] + [Raise Dispute] +│ OR: 6-digit code input boxes +└── COMPLETED: [Leave a Review] + [Buy Again] + Dispute window countdown (72hrs) +``` + +### Order Management (Store View) +``` +STORE MODE → Orders tab + +FILTER TABS: All | Needs Action | Active | Completed | Cancelled +"Needs Action" has a badge count (PAID orders needing preparation/pickup) + +EACH ORDER CARD: +├── #TWZ-XXXXXX · [time ago] +├── Product thumbnail + name + shopper handle + variant +├── ₦[amount] + status badge +└── "Buyer Protection: payment protected until delivery" + +TWIZRR-MANAGED DELIVERY FLOW (tap PAID order → [Ready for pickup]): +├── Store accepts/prepares the order +├── Store confirms the order is ready for pickup +├── Twizrr books/assigns approved logistics from pickup point to shopper dropoff +├── Store owner does not arrange off-platform delivery with the shopper +├── Store owner does not see shopper phone numbers by default +└── Shopper notified when delivery is booked/dispatched (WhatsApp template + in-app) +``` + +--- + +## 6. Payments + +### Paystack Integration +``` +PAYMENT METHODS AT MVP: +├── Card (Paystack Checkout — card, bank transfer, USSD via Paystack) +└── DVA (Dedicated Virtual Account — bank transfer to personal account) + Each buyer gets a unique Wema Bank account number + Transfer from banking app → Paystack webhook confirms → order processed + Zero browser hop for returning buyers + +WEBHOOK (CRITICAL): +└── All Paystack callbacks: HMAC signature verified before processing + Never process payment event without verification + Idempotency keys on all payment records (prevent duplicates) +``` + +### Payout System +``` +TRIGGERED: delivery confirmed OR auto-confirm (48hrs) + +FLOW: +└── LedgerEntry: ESCROW_RELEASE logged + BullMQ: payout job queued (never synchronous) + Paystack Transfer API → store bank account + LedgerEntry: PAYOUT logged + Store notified: WhatsApp template + push + email + +DROPSHIP PAYOUT SPLIT: +└── Physical store: full wholesale amount (no platform fee deducted) + Digital store: margin minus 2% of full order value + Two separate Paystack Transfer API calls + Platform fee charged ONCE on full order — never twice + +FAILED PAYOUT: +└── 4 automatic retry attempts (immediate → 5min → 30min → 2hrs) + After all retries fail: payout.status = FAILED + Store owner notified with [Update Bank Details] CTA + Phase 2: OPERATOR manually retries from admin dashboard + +PLATFORM FEES: +├── Tier 1: 2% per order +├── Tier 2: 1.5% per order +└── Tier 3: 1% per order +``` + +--- + +## 7. WhatsApp AI Assistant (WIZZA) + +``` +SHOPPING ONLY — store management never via WhatsApp +Store-management requests get the exact refusal copy: +"WIZZA is for shopping. Manage your store from your twizrr dashboard." + +FLOW: +└── Shopper messages twizrr WhatsApp number + If new: consent message sent first (NDPR compliance) + Shopper accepts → session begins + + TEXT QUERY: "Red sneakers under 30k Lagos" + → Gemini 2.5 Flash: intent parsed, filters extracted + → Approved hybrid ranking contract: + final_score = vector_similarity * 0.5 + text_rank * 0.2 + + store_performance * 0.2 + tier_boost * 0.1 + (weights configurable via SEARCH_WEIGHT_* env vars, must sum + to 1.0; new stores default to store_performance = 0.5) + → Tier 0 stores excluded always + → Top results returned as WhatsApp List Message + canonical links + + IMAGE QUERY: shopper sends product photo + → Cloud Vision SafeSearch screens the image BEFORE discovery + (block on LIKELY/VERY_LIKELY adult/violence/racy; + blocked images get a safe refusal and no search runs; + screening errors fail open and search continues) + → Vertex AI multimodal image embedding (1408-dim) — PRIMARY signal + → Cloud Vision labels — context and fallback only + → pgvector similarity retrieval over product embeddings + → Embedding failure degrades gracefully to safe label fallback + → Results returned as WhatsApp List Message + canonical links + + SHOPPER SELECTS PRODUCT: + → Selection by list row, number, or title (Redis recent results) + → Canonical product link shared: + /stores/{storeHandle}/p/{productCode} + (sourced listings use the digital store handle and the + listing's public productCode — source/supplier data stays + internal and invisible to shoppers) + → Shopper completes checkout on the web product page + → Shopper pays → webhook confirms → order created + → order_confirmed template sent + +6 REQUIRED META TEMPLATES (submit 4+ weeks before launch): +├── twizrr_onboarding_consent +├── twizrr_order_confirmed +├── twizrr_order_dispatched +├── twizrr_confirm_delivery +├── twizrr_payment_received +└── twizrr_account_linked + +ALL TEMPLATES: plain text only — no emojis +``` + +--- + +## 8. Content System + +### Post Types +``` +PRODUCT_POST: +└── Created via product listing flow + Commerce-first: [Add to Cart] + [Buy Now] on feed card + Comments: DISABLED (enforced at API — 405 response) + Save: adds product to shopper's Wishlist + Max 5 images + +IMAGE_POST: +└── Store creates content (lifestyle, announcements) + Optional product tag (taggedProductId — one product) + If tagged: product strip shown at bottom of card + Comments: ENABLED + Max 10 images + +GIST: +└── Short text post (max 240 chars) — stores and users + Comments: ENABLED + No images (Phase 2) + +TWIZZ: +└── COMING SOON — tab shown but disabled + "Coming Soon" screen on tap +``` + +### Content Moderation +``` +ALL IMAGES MODERATED BEFORE CLOUDINARY: +└── POST /media/moderate → Cloud Vision SafeSearch + SAFE: Cloudinary upload proceeds + SENSITIVE: uploaded with moderationStatus = SENSITIVE + Shown blurred to adults + Hidden entirely from users where isMinor = true + BLOCKED: rejected before upload + ModerationLog created + OPERATOR reviews within 24 hours + +APPLIES TO: product images, store logos/banners, post images, + profile photos, chat images + +ISMINOR ENFORCEMENT: +└── user.isMinor computed from dateOfBirth at every API response + Not a stored flag — computed in real-time + Cannot be bypassed by client modifications +``` + +--- + +## 9. Store Dashboard (MVP Scope) + +### What Gets Built +``` +5-TAB STORE MODE NAV: +├── Dashboard ← centrepiece +├── Products +├── Orders +├── Messages +└── My Store + +DASHBOARD: +├── Escrow Balance Card (full width — centrepiece): +│ Total | Available | Pending release | Tap → Payouts +├── Stats grid (2×2): Orders | Revenue | Inventory | Tier +├── Quick Actions (horizontal scroll): +│ Add Product | Source Products | Orders | +│ Analytics | New Post | Create Drop | Share Store +├── Recent Orders (last 5) with escrow context per card +└── [Browse Feed →] shortcut (feed in store mode — view only) + +PRODUCTS PAGE: +├── Tabs: Active | Draft | Out of Stock | Hidden | Sourced +├── Filter chips from store tags +├── Product grid with [+ Add Product] FAB +└── Per-product: edit, hide, update stock (inline) + +ORDERS PAGE: +├── Tabs: All | Needs Action | Active | Completed | Cancelled +└── Order detail with full dispatch flow + +MESSAGES PAGE: +└── All shopper conversations (order-linked context) + +MY STORE PAGE: +└── Full public store preview + Tap sections to edit + [≡] → Store Menu Drawer + +STORE MENU DRAWER: +├── STORE: Store Profile | Source Products | Drops | Collections +├── PERFORMANCE: Analytics | Verification (KYB) +├── EARNINGS: Payouts | Premium (Coming Soon) +└── SUPPORT: Help Center | Seller Agreement + Bottom: [← Back to Shopping] +``` + +--- + +## 10. User Profile Menu + +``` +THREE-BAR ICON ON OWN PROFILE PAGE → MENU DRAWER + +MENU STRUCTURE: +├── SHOPPING +│ Orders → filter tabs: All | Processing | Dispatched | Delivered | Returns +│ Cart → items + [Proceed to Checkout] +│ Wishlist → starred products (2-column grid) +│ Saved → saved posts ([All] [Images] [Gists] sub-tabs) +│ +├── ACCOUNT +│ Profile → edit name, @username (7-day lock), bio (180 chars), photo +│ Settings → phone/email/password, linked accounts, +│ privacy & security, notifications +│ Measurements & Delivery Info → +│ Body measurements (bust, waist, hips, height — in CM) +│ Foot measurements (EU shoe size + foot length) +│ Default delivery address (Google Maps + label) +│ Reviews → [Ready to Review (X)] [Reviewed (X)] tabs +│ +└── SUPPORT + Help Center → FAQ sections + Email/WhatsApp support contact + +BOTTOM OF DRAWER (store owners only): +└── [Manage Store] → switches to store mode +``` + +--- + +## 11. KYB Verification (Store Mode) + +``` +LOCATION: Store Mode → Store Menu Drawer → Verification + +TIER 0 → TIER 1 (automatic): +└── Requirements: + ├── Email verified (done at signup) + ├── Phone verified (Settings → verify phone) + └── Bank account added (store setup) + All three complete → auto-upgrade fires + No button, no review needed + +TIER 1 → TIER 2 (Prembly API — automated): +└── Requirements (all must be met to start NIN): + ├── 3 completed orders + ├── 0 disputes + └── Profile photo added + + NIN VERIFICATION FLOW: + Step 1: Enter NIN (11 digits) + Step 2: Take selfie → Prembly compares to NIN photo + Step 3: VERIFIED → tier auto-upgrades + FAILED (3 attempts) → contact support + +TIER 2 → TIER 3 (semi-manual): +└── Requirements: + ├── CAC RC Number → Prembly checks CAC database + ├── Address verification (physical: agent visit; digital: utility bill) + ├── 20 completed orders + ├── 4.0+ average rating + └── 3 months on platform + Manual review: 1-3 business days for address/physical verification + +ALL KYB DATA: on StoreProfile model (never User model) +``` + +--- + +## 12. Notifications (MVP) + +``` +EMAIL (Resend — primary at MVP): +├── Order confirmed (shopper) +├── Order dispatched (shopper) +├── Order completed + payout sent (store) +├── Payout failed (store) +├── Dispute filed (both parties) +└── OTP codes (all auth flows) + +WHATSAPP TEMPLATES (6 — submitted to Meta before launch): +├── Order confirmed → shopper +├── Order dispatched → shopper +├── Confirm delivery (with [Yes I received it] button) → shopper +├── Payment received → store +├── Account linked → shopper +└── Onboarding consent → new shopper + +IN-APP NOTIFICATIONS: +├── Order status updates +├── POST_LIKED (in-app only — not push) +├── POST_COMMENTED (stored for in-app centre) +├── NEW_FOLLOWER (stored) +└── Store alerts (low stock, payout failed) + +PUSH NOTIFICATIONS: Phase 3 +SMS FALLBACK: Phase 3 +``` + +--- + +## 13. Backend Architecture (MVP Modules) + +``` +NESTJS MODULES TO BUILD: +├── auth/ JWT auth, registration, OTP, sessions +├── user/ User CRUD, profile, measurements, addresses +├── store/ Store CRUD, settings, KYB flows, sourcing catalogue +├── product/ Product CRUD, search, variants, stock +├── inventory/ InventoryEvent, ProductStockCache, restock +├── sourced-product/ SourcedProduct CRUD, floor enforcement, sync +├── post/ Post CRUD (PRODUCT_POST, IMAGE_POST, GIST) +├── feed/ Feed algorithm, For You, Following, Explore +├── search/ Hybrid search (pgvector + tsvector + metadata) +├── cart/ CartItem management +├── order/ Order lifecycle, state machine, dispatch +├── payment/ Paystack checkout, DVA, webhooks +├── payout/ Payout processor, BullMQ jobs, bank transfers +├── escrow/ LedgerEntry, escrow release, reconciliation +├── delivery/ Shipbubble integration, delivery preview API +├── whatsapp/ WhatsApp webhook, AI assistant, templates +├── moderation/ Cloud Vision SafeSearch, ModerationLog +├── embedding/ Vertex AI embedding generation, pgvector storage +├── notification/ In-app notifications, email dispatch +├── chat/ Socket.io real-time messaging +├── verification/ KYB flows, Prembly API integration +├── dispute/ Dispute filing, status, OPERATOR queue +├── size-guide/ SizeGuide seeding, ProductSizeGuideConfig +├── upload/ Cloudinary uploads (with moderation pre-check) +├── health/ Health check endpoint (UptimeRobot pings /health) +└── admin/ OPERATOR + SUPER_ADMIN tools, AuditLog + +GLOBAL MIDDLEWARE (all in main.ts): +├── BigInt.prototype.toJSON → toString (prevents JSON serialization errors) +├── rawBody: true (Paystack HMAC verification) +├── nestjs-pino (structured JSON logging) +├── AppValidationPipe (whitelist: true, transform: true) +├── GlobalExceptionFilter (Prisma P2002→409, P2025→404) +├── ResponseTransformInterceptor ({ success, data, message }) +├── ThrottlerGuard as APP_GUARD (rate limiting) +├── Helmet (security headers) +├── CookieParser (HttpOnly JWT cookies) +└── CORS (CORS_ORIGINS env var, credentials: true) +``` + +--- + +## 14. Frontend Pages (MVP) + +``` +PUBLIC PAGES (no auth required): +├── / Landing page +├── /explore Product discovery feed +├── /p/[code] Product detail page +├── /@[handle] Store page (public) +├── /c/[category] Category browse +└── /about | /terms | /privacy | /help + +AUTH PAGES: +├── /login +├── /register +├── /forgot-password +└── /verify-email + +SHOPPER PAGES (authenticated): +├── /buyer/feed Personalised feed +├── /buyer/cart Shopping cart +├── /buyer/checkout/[id] Checkout flow +├── /buyer/orders Order list + tracking +├── /buyer/orders/[id] Order detail page +├── /buyer/saved Saved posts +├── /buyer/wishlist Starred products +└── /buyer/profile Profile settings + +STORE PAGES (authenticated — store owners): +├── /store/dashboard Dashboard (escrow card + stats + actions) +├── /store/products Product management +├── /store/products/new Product listing form (4 screens) +├── /store/orders Order management +├── /store/orders/[id] Order detail + dispatch +├── /store/messages Shopper conversations +├── /store/my-store Public store preview +├── /store/payouts Payout history + bank details +├── /store/settings Store profile + account settings +└── /store/verification KYB verification flows + +ADMIN PAGES: +├── /admin/dashboard Platform KPIs +├── /admin/orders Global order view +├── /admin/disputes Dispute queue +├── /admin/moderation Content moderation queue +├── /admin/payouts Payout monitoring + retry +└── /admin/stores Store management +``` + +--- + +## 15. Database Models (Complete List) + +``` +USER AND AUTH: +User, Session, OTPCode, FollowRelation + +STORE: +StoreProfile, StoreVerificationLog + +PRODUCT: +Product, ProductVariant, ProductImage, ProductSizeGuideConfig, +ProductEmbedding, SourcedProduct + +INVENTORY: +InventoryEvent, ProductStockCache + +CONTENT: +Post, PostImage, Hashtag, PostHashtag, Like, CommentLike, +Comment, StoreCollection, StoreNotificationSubscription + +ORDERS: +Order, CartItem + +PAYMENTS: +Payment, Payout, LedgerEntry, DVAAccount + +DELIVERY: +DeliveryBooking, DeliveryAddress + +REVIEWS: +Review, ReviewImage, ReviewHelpful + +CHAT: +Conversation, Message + +NOTIFICATIONS: +Notification, PushToken + +MODERATION: +ModerationLog + +SUPPORT: +SupportTicket + +WHATSAPP: +WhatsAppLink, WhatsAppAnalytics, WhatsAppSession + +AI: +ProductEmbedding (pgvector — 1408 dims) + +PLATFORM: +SizeGuide, AuditLog, ReconciliationLog +``` + +--- + +## What Is Deferred (Not in MVP) + +``` +DEFERRED TO PHASE 3: +├── Mobile app (Expo) — web works on mobile browser at MVP +├── Twizz (video) — "Coming Soon" in UI, not built +├── Review system — needs sufficient order history first +├── Inspection system (₦500) — Mustakheem manually verifies at MVP +├── Premium subscription — "Coming Soon" in store menu +├── Push notifications — email + WhatsApp templates sufficient +├── Size recommendation — user enters measurements → system suggests +├── Advanced analytics (charts) — basic numbers only at MVP +├── Drops creation — documented, UI entry shown, not built +├── Tier 3 CAC verification — Prembly integration deferred +├── SMS fallback — Africa's Talking SMS at Phase 3 +├── In-app support chat — email/WhatsApp contact at MVP +├── Referral system — Phase 4 +├── Reranking model — needs Phase 2 interaction data first +└── Nigeria-wide delivery — Lagos only at MVP + +DEFERRED POST-MVP (Phase 4+): +├── Multiple stores per user account +├── Group buying for store owners +├── Merchant-to-merchant trading +├── BNPL / financial services +├── International expansion +└── Custom AI moderation model +``` + +--- + +*This document defines the full working MVP. +Everything above must function end-to-end before launch. +Anything not listed is out of scope for the initial build.* diff --git a/TWIZRR_PAYMENT_AMOUNT_EXCEPTION_DESIGN.md b/TWIZRR_PAYMENT_AMOUNT_EXCEPTION_DESIGN.md new file mode 100644 index 00000000..bd4e76d1 --- /dev/null +++ b/TWIZRR_PAYMENT_AMOUNT_EXCEPTION_DESIGN.md @@ -0,0 +1,209 @@ +# TWIZRR Payment Amount Exception Design + +## Purpose + +This document records Twizrr's approved direction for payments where a provider confirms that the shopper paid an amount different from the order amount. + +It is a design and implementation roadmap. It does not permit an order to be +marked paid from a mismatched amount. The narrowly scoped overpayment-refund +operation described below is feature-gated and disabled by default. + +## Current safe behaviour + +Twizrr requires an exact kobo match before treating an order as paid. + +| Situation | Current system behaviour | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Shopper pays the exact amount | Payment becomes `SUCCESS`; the order moves from `PENDING_PAYMENT` to `PAID`; the ledger records captured funds; the normal escrow and delivery flow continues. | +| Shopper underpays | The order is not marked paid. For Monnify, `PARTIALLY_PAID` becomes `RECONCILIATION_REQUIRED`. For any provider that reports a successful payment with a lower amount, the exact-amount check also moves it to reconciliation. | +| Shopper overpays | The order is not marked paid. Monnify `OVERPAID` becomes `RECONCILIATION_REQUIRED`; an otherwise-successful provider result with a higher amount does the same. | +| Provider reports pending or processing | The payment stays pending; no order transition or ledger receipt entry is created. | +| Provider reports a confirmed failure | The payment attempt is failed and the order remains unpaid. | + +When an amount mismatch occurs: + +- `Payment.status` becomes `RECONCILIATION_REQUIRED`. +- The associated payment attempt is marked for reconciliation. +- Twizrr records the expected and provider-reported amounts safely as decimal kobo strings in internal payment-event and audit metadata. +- The order remains `PENDING_PAYMENT`. +- No normal `PAYMENT_RECEIVED` ledger entry is written. +- No merchant payout, escrow release, delivery progression, or payment-success flow is triggered. +- The shopper cannot simply create a fresh checkout on top of the unresolved collection; Twizrr verifies the existing payment first and returns a reconciliation-required conflict. + +This is intentionally fail-safe. The current system has no dedicated product workflow for resolving underpayments or overpayments. It must not automatically credit, refund, or accept the discrepancy. + +## Product and financial decision + +Twizrr will keep the current fail-safe order state and build a small, explicit payment-exception flow. The system must not silently accept an underpayment, treat an overpayment as merchant earnings, or automatically refund without confirmed provider state. + +### 1. Keep the order unpaid until the exact order amount is settled + +This is the required default. + +- No payout, delivery, merchant entitlement, or order-success side effect may begin from a mismatched payment. +- Only the exact approved order amount may become the order's escrowed funds. +- No admin action may force an order to `PAID` merely because a provider shows a collection. + +### 2. Record mismatched collections as an internal payment exception + +The actual collected amount must be represented explicitly, rather than only in event metadata. Twizrr should create a provider-bound, immutable payment exception record and corresponding append-only ledger treatment for money that was received but is not yet allocated to a successfully paid order. + +The record should include only safe financial and operational data: + +- expected amount in BigInt kobo; +- actual collected amount in BigInt kobo; +- exception type: `UNDERPAID` or `OVERPAID`; +- immutable payment provider and provider reference; +- safe provider transaction reference; +- resolution status; +- timestamps and safe audit timeline. + +The normal `PAYMENT_RECEIVED` ledger entry must not be used for this state, because it would incorrectly imply that the order is paid. The eventual ledger design must instead express money received but unallocated, while preserving Twizrr's append-only ledger invariants. + +### 3. Controlled resolution rules + +#### Underpayment + +- Do not mark the order paid. +- Do not permit the merchant to ship. +- Initially, SUPER_ADMIN/support operations may either refund the partial collection through the original provider after confirmation and let the shopper restart checkout, or leave the case in reconciliation/manual review while the provider outcome is uncertain. +- A future "pay remaining balance" flow is deliberately deferred until Twizrr has a proper multi-collection accounting model. Adding it prematurely could double-count payments or allocate money from the wrong provider operation. + +#### Overpayment + +- Do not give the excess to the merchant. +- Until Twizrr has a multi-collection allocation model, do not allocate any + part of the mismatched collection to escrow. Reverse the **full provider- + confirmed collected amount** through the same provider, then leave the order + unpaid for a future clean checkout. +- This conservative full reversal prevents a partial allocation from combining + with a later checkout, duplicating escrow, or misrouting provider funds. +- Only provider-confirmed completion plus the matching append-only ledger + transaction resolves the refund. +- If provider submission is ambiguous and Twizrr has no safe provider operation + ID to reconcile, keep the exception in manual review. Never route the refund + to a different provider or blindly submit another one. + +## Provider continuity + +Every financial operation remains permanently bound to the provider that created the original payment. + +- A Paystack payment is verified, reconciled, refunded, and paid out through Paystack. +- A Monnify payment is verified, reconciled, refunded, and paid out through Monnify. +- A provider timeout becomes reconciliation work, not a reason to retry through another provider. +- Existing records remain supported after a controlled checkout-provider change. + +There is no provider fallback for money movement. Provider-specific refund limits or destination requirements are operational constraints, not permission to reroute money. + +## Admin reconciliation queue + +SUPER_ADMIN needs a dedicated payment-exception queue that safely shows: + +- order and safe payment reference; +- expected and received kobo amount; +- provider and normalized provider status; +- `UNDERPAID` or `OVERPAID` classification; +- current resolution state; +- safe audit history. + +Allowed actions must be limited to guarded operations: + +- reconcile against the original provider; +- initiate an approved provider-bound refund only when provider state makes it safe; +- record a manual resolution/review reason. + +The queue must not provide a "force order paid" action. It must not expose raw provider payloads, credentials, full bank details, payment authorization data, or private user data. + +## Shopper communication + +Shopper-facing wording must remain neutral and must not instruct a shopper to pay again while funds may already be collected. + +Recommended copy: + +> We received a payment amount that does not match this order. Your order has not been confirmed yet. Please do not make another payment while we review it. + +Any future remaining-balance action must be shown only after Twizrr has built the multi-collection accounting model and provider-specific collection handling. + +## Recommended implementation roadmap + +1. `feat(payment): add payment amount exception records and ledger treatment` + - Add provider-bound, immutable exception records. + - Define append-only unallocated-money ledger treatment. + - Preserve exact BigInt kobo values and provider references. + - Add reconciliation and duplicate-provider-event tests. + +2. `feat(admin): add payment reconciliation exception queue` + - Add SUPER_ADMIN-only list/detail APIs and review UI. + - Show safe expected-versus-received amounts and provider status. + - Add guarded reconcile and manual-review actions only. + +3. `feat(payment): add provider-bound overpayment refund resolution` + - Implemented: one immutable full-refund plan per eligible overpayment, + permanently bound to the original payment provider. + - A guarded outbox worker submits exactly once; provider acceptance becomes + `SUBMITTED`, never completion. + - Confirmed provider completion writes the matching ledger entry in the same + transaction as payment/exception resolution. Ambiguous or unsupported + cases remain in reconciliation/manual review. + - `PAYMENT_AMOUNT_EXCEPTION_REFUND_EXECUTION_ENABLED=false` is the committed + default. While disabled, the admin action refuses to create a refundable + operation, avoiding stranded pending plans. + +4. `feat(payment): design and implement safe remaining-balance collection` + - Implemented behind + `PAYMENT_REMAINING_BALANCE_COLLECTION_ENABLED=false` by default. + - Each provider attempt owns an immutable expected collection amount; the + remainder is always collected through the original provider. + - Confirmed completion atomically reclassifies the first unallocated + collection, records one full-order receipt and escrow hold, resolves the + exception, and marks the order paid. + - Buyer-refund settlements for multi-collection orders route to manual + review until split provider-refund operations are implemented and proven. + - See `TWIZRR_REMAINING_BALANCE_COLLECTION_DESIGN.md` for the invariant and + rollout gate. + +5. `feat(refund): add split-collection refund operations` + - Implemented: one immutable provider-bound refund operation per confirmed + collection, reserved by source payment attempt. + - The settlement refund leg rolls up only after every operation is + provider-confirmed; it retains one aggregate completion ledger identity + and one durable completion notification. + - Ambiguous outcomes remain reconciliation-only and confirmed rejection + retries reuse the same operation and deterministic provider reference. + - Remaining-balance collection remains disabled by default until both + provider sandbox flows and PostgreSQL concurrency evidence pass. + +## Implementation progress + +Implemented: + +- immutable, provider-bound payment amount exception records and unallocated + collection ledger entries; +- SUPER_ADMIN payment-exception queue and safe detail view; +- provider-bound, observation-only re-check action with an admin audit trail; +- reason-required manual-review transition. +- provider-bound full overpayment-refund plans, guarded outbox execution, + reconciliation polling, and verified Monnify webhook reconciliation hints; +- immutable refund evidence, confirmed-only `REFUND_COMPLETED` ledger entries, + and safe SUPER_ADMIN queue visibility. +- provider-bound remaining-balance checkout foundation, exact BigInt-kobo + aggregation, durable buyer action notification, and shopper order action UI. +- provider-bound split-collection settlement refund operations and aggregate + completion/ledger roll-up. + +Not implemented by the review queue: + +- force-payment or order completion; +- automatic crediting or remaining-balance collection; +- cross-provider financial operations. + +## Non-negotiable safeguards + +- All monetary values are BigInt kobo; never Float or JavaScript `Number`. +- PostgreSQL and the append-only ledger remain authoritative. +- Provider webhooks are verified before they can trigger reconciliation work. +- Provider acceptance is not completion. +- No order is marked paid without an approved exact settlement of its amount. +- No merchant payout or escrow release occurs while a payment exception is unresolved. +- No financial operation is rerouted to a different provider. +- Raw provider payloads, bank details, credentials, authorization data, tokens, and private request data never enter public DTOs, logs, realtime payloads, or unsafe metadata. diff --git a/TWIZRR_PAYMENT_EXCEPTION_SANDBOX_RUNBOOK.md b/TWIZRR_PAYMENT_EXCEPTION_SANDBOX_RUNBOOK.md new file mode 100644 index 00000000..2afa3a68 --- /dev/null +++ b/TWIZRR_PAYMENT_EXCEPTION_SANDBOX_RUNBOOK.md @@ -0,0 +1,285 @@ +# TWIZRR Payment Exception Sandbox Runbook + +**Status:** Manual validation required +**Date:** 20 July 2026 +**Production execution:** Disabled + +This runbook validates underpayment, overpayment, remaining-balance collection, +and split-collection refunds against Paystack and Monnify sandboxes. It does +not authorize live execution or change any feature flag. + +## Safety preconditions + +Run only in an isolated sandbox environment with a disposable or explicitly +isolated database. Never use shared Neon, staging, or production data. + +The environment must keep: + +```env +PAYMENT_REMAINING_BALANCE_COLLECTION_ENABLED=false +PAYMENT_AMOUNT_EXCEPTION_REFUND_EXECUTION_ENABLED=false +DISPUTE_SETTLEMENT_EXECUTION_ENABLED=false +``` + +For a controlled sandbox execution run, enable only the flag under test and +record who approved the change. Restore it to `false` immediately after the +case completes. Never enable settlement execution merely to test payment +exceptions. + +Use sandbox credentials only. Do not paste API keys, webhook secrets, card +numbers, OTPs, bank details, or raw provider payloads into tickets, commits, +logs, screenshots, or this document. + +## Required test identities + +Create isolated test identities for: + +- shopper; +- store owner and store; +- SUPER_ADMIN reviewer; +- Paystack sandbox payment method; +- Monnify sandbox payment method; +- a disposable order for each scenario. + +Record only masked references and timestamps in the evidence sheet. Provider +references may be recorded in a restricted operational system, but never in a +public issue or source file. + +## Evidence sheet + +For every case record: + +```text +Case ID: +Provider: PAYSTACK | MONNIFY +Collection method: card | bank transfer | reserved account | other +Environment: +Order reference (masked): +Payment reference (masked): +Payment attempt references (masked): +Exception ID: +Refund operation IDs: +Expected amount (kobo): +Received amount (kobo): +Refunded amount (kobo): +Provider status transitions: +Twizrr status transitions: +Ledger entry IDs/types: +Notification/outbox IDs: +Webhook/reconciliation timestamps: +Operator: +Result: PASS | FAIL | BLOCKED +Failure notes: +``` + +Use decimal kobo strings for money. Do not use floating-point arithmetic when +checking amounts. + +## Case matrix + +### A. Exact payment baseline + +For each provider and each supported collection method: + +1. Create a paid order with an exact expected amount. +2. Confirm one successful payment attempt and one normal receipt. +3. Verify the order becomes paid exactly once. +4. Verify no amount exception is created. +5. Verify the original provider binding remains unchanged. + +Expected result: existing exact-payment behavior is unchanged. + +### B. Underpayment detection with execution disabled + +For Paystack and Monnify: + +1. Start an order for total `T`. +2. Complete a provider transaction for `R`, where `R < T`. +3. Verify the order remains unpaid/pending payment. +4. Verify an immutable `UNDERPAID` exception records expected and received + amounts, provider, and provider references. +5. Verify no escrow allocation, merchant payout, or completion receipt occurs. +6. Verify the shopper receives safe action-required messaging only when the + remaining-balance flag is enabled; otherwise the case remains review-only. + +Expected invariant: + +```text +remaining balance = T - R +``` + +No provider refund or second collection should occur while execution is +disabled. + +### C. Remaining-balance continuation + +Run this only in an isolated provider sandbox with +`PAYMENT_REMAINING_BALANCE_COLLECTION_ENABLED=true`. + +For Paystack and Monnify: + +1. Begin with a confirmed underpayment `R` against total `T`. +2. Use the shopper's Complete payment action to create a new attempt for + exactly `T - R` through the original provider. +3. Confirm the second provider transaction. +4. Verify both attempts remain immutable and provider-bound. +5. Verify exactly one full payment receipt and escrow hold are written. +6. Verify the exception resolves and the order transitions to paid once. +7. Repeat the Complete payment request and duplicate provider callback. +8. Verify no second active remainder attempt, receipt, or escrow allocation is + created. + +Expected invariant: + +```text +initial confirmed collection + remaining confirmed collection = T +``` + +Restore the flag to `false` after the case. + +### D. Overpayment detection + +For Paystack and Monnify: + +1. Start an order for expected amount `T`. +2. Complete a provider transaction for `R`, where `R > T`. +3. Verify the order does not become paid. +4. Verify an immutable `OVERPAID` exception records the mismatch. +5. Verify the received amount remains unallocated and does not enter escrow or + merchant earnings. +6. Verify the SUPER_ADMIN exception queue shows the case safely. + +Expected result: the order remains pending until a deliberate resolution. Do +not silently keep the expected amount and move only the excess elsewhere. + +### E. Provider-bound overpayment refund + +Run this only in an isolated sandbox with +`PAYMENT_AMOUNT_EXCEPTION_REFUND_EXECUTION_ENABLED=true`. + +For each provider: + +1. Use an eligible overpayment exception from case D. +2. Create one guarded full-refund plan from the SUPER_ADMIN flow. +3. Verify the refund operation is bound to the original provider and reference. +4. Confirm provider submission and verify it becomes `SUBMITTED`, not complete, + until the provider confirms completion. +5. Reconcile provider success. +6. Verify exactly one refund-completed ledger entry and exception resolution. +7. Repeat the outbox delivery, webhook, and reconciliation calls. +8. Verify no duplicate provider refund, ledger entry, notification, or + completion transition occurs. + +If the provider returns an ambiguous timeout, verify the operation becomes +reconciliation-required and cannot be blindly retried. + +Restore the flag to `false` after the case. + +### F. Split-collection refund + +Run after cases C and E have produced a fully completed two-collection order. + +1. Open and resolve a buyer-favoring dispute for the completed order. +2. Verify one immutable refund operation exists per confirmed payment attempt. +3. Verify every operation uses the original provider and its own transaction + reference. +4. Execute duplicate outbox deliveries concurrently. +5. Confirm child refunds independently and reconcile them concurrently. +6. Verify the parent refund leg completes only after every child completes. +7. Verify one aggregate `REFUND_COMPLETED` ledger entry and one completion + notification. +8. Repeat child callbacks and reconciliation polls. + +Expected invariant: + +```text +sum(confirmed child refunds) = approved aggregate refund +``` + +No child operation may be submitted more than once. + +### G. Failure and recovery cases + +For both providers, exercise: + +- provider rejection before acceptance; +- timeout before provider submission; +- ambiguous timeout after the request may have reached the provider; +- provider pending/processing response; +- duplicate success callback; +- stale reconciliation worker; +- provider configuration change after the operation was created. + +Expected results: + +- confirmed rejection may be retry-eligible; +- ambiguous or submitted states require reconciliation; +- no failed-state retry may create a second provider operation; +- original provider binding wins over current environment selection; +- no completion ledger entry exists before confirmed provider success. + +## Database and ledger verification + +After each case, inspect the authoritative database and append-only ledger. +Verify: + +```text +No duplicate provider operation for one payment attempt +No duplicate payment receipt +No duplicate escrow allocation +No duplicate refund completion +No merchant payout from an unresolved exception +No completion entry for submitted, failed, or ambiguous operations +``` + +Do not edit ledger rows to make a sandbox case pass. Reset only disposable test +data after evidence is captured. + +## Go/no-go gate + +Do not enable shared-environment execution unless all applicable items are +complete: + +```text +[ ] Paystack exact-payment baseline passed +[ ] Monnify exact-payment baseline passed +[ ] Paystack underpayment detection passed +[ ] Monnify underpayment detection passed +[ ] Paystack remaining-balance continuation passed +[ ] Monnify remaining-balance continuation passed +[ ] Paystack overpayment detection passed +[ ] Monnify overpayment detection passed +[ ] Paystack overpayment refund passed +[ ] Monnify overpayment refund passed +[ ] Paystack split-collection refund passed +[ ] Monnify split-collection refund passed +[ ] Duplicate callbacks and workers passed +[ ] Ambiguous timeout reconciliation passed +[ ] Provider continuity after configuration change passed +[ ] Ledger and provider references reconciled +[ ] Reconciliation/manual-review owners assigned +[ ] Rollback procedure reviewed +[ ] Evidence stored in restricted operations location +``` + +Any ledger-invariant failure, duplicate provider submission, or unexplained +provider reference blocks rollout. + +## Rollback + +Set all payment-exception and settlement execution flags to `false`. + +Do not abandon operations already submitted to a provider. Continue polling and +reconciling submitted operations until their final provider state is known. +Do not retry ambiguous operations blindly. + +## Scope exclusions + +This runbook does not implement: + +- shopper wallets; +- automatic overpayment allocation; +- cross-provider refunds or transfers; +- live provider execution; +- changes to checkout, payout, settlement, or order business rules; +- new payment providers. diff --git a/TWIZRR_PLATFORM_FEE_ANALYSIS.md b/TWIZRR_PLATFORM_FEE_ANALYSIS.md new file mode 100644 index 00000000..d6f0265e --- /dev/null +++ b/TWIZRR_PLATFORM_FEE_ANALYSIS.md @@ -0,0 +1,215 @@ +# Platform Fee vs Paystack — Margin Analysis & Remediation + +_Last updated: 2026-07-24_ + +## TL;DR + +twizrr's platform commission is **not too high — it is structurally underwater**. +The merchant's payout is calculated as `gross − delivery − platformFee`, but the +**Paystack processing fee is never captured, recorded, or deducted anywhere**. As a +result twizrr silently absorbs 100% of Paystack's cut out of its own commission, and +that cut is roughly the same size as (or larger than) the entire platform fee. On most +realistic orders twizrr **loses money per transaction**, and the loss is _worse_ for the +most-verified, lowest-fee stores. + +The project's own docs already say merchants should bear this fee +(`TWIZRR_PAYMENT_SYSTEM.md:32` — _"Store owners bear Paystack transaction fees … +deducted from store owner payout alongside platform fee"_). That behaviour was never +implemented. This document records the evidence, the numbers, and the fix. + +--- + +## 1. Current fee model (as built) + +Commission is keyed on the merchant's `verificationTier` (see `platform.config.ts`, +wired up 2026-07-24): + +| Tier | Fee (fraction of **subtotal**) | +|------|-------------------------------| +| UNVERIFIED | 2.0% | +| TIER_1 | 1.5% | +| TIER_2 | 1.0% | +| TIER_3 | 0.5% (future — enum has no TIER_3 yet) | + +- The fee is a percentage of the **item subtotal only** (delivery excluded). +- The fee is **merchant-borne**: `platformFeeBearer = MERCHANT`. The shopper pays + `subtotal + delivery`; the fee is snapshotted on the order and deducted from the + merchant's payout. +- Merchant payout (`ledger.service.ts` `calculateOrderPayout`): + `payout = gross − delivery − platformFee − processorFee − dropshipperCost`. + +### Commission cap (added 2026-07-24) + +Commission is charged on the subtotal **only up to a cap threshold**, then freezes: + +``` +commission = rate × min(subtotal, PLATFORM_FEE_CAP_ORDER_KOBO) # default ₦500,000 +``` + +Rationale: a pure percentage is unbounded, so on very large orders the merchant's +absolute cost keeps climbing while Paystack's fee has already capped at ₦2,000 — big +sellers feel over-charged. Capping the **order value the rate applies to** (rather than +the fee amount) freezes commission past ₦500,000 *while preserving the tier discount at +every size*: a ₦1M order still costs a TIER_2 store less than an UNVERIFIED one +(₦5,000 vs ₦10,000). A flat *fee* cap was rejected because it collapses all tiers to the +same amount on big orders, cancelling the "higher tier = cheaper" incentive. + +We deliberately kept the **percentage** model rather than switching to flat per-order +fees: flat fees below our ~₦75–100 payout cost would reintroduce a per-order loss, +misalign fee with escrow risk (which scales with order value), and gut revenue at scale. +The percentage model is already progressive — a ₦2,000 order pays ~₦30 — so small sellers +already pay very little. `platformFeePercent` still stores the nominal tier rate; on a +capped order the effective rate is lower, and `platformFeeKobo` is the authoritative +charged amount. + +**Fix (2026-07-24):** `Order.platformFeePercent` was an `Int`, which cannot hold the +fractional tier rates (1.5%, 0.5%) — a float write would have crashed order creation for +any TIER_1/TIER_2 store. Widened to `Decimal(5,2)` (migration +`20260724140000_...`; `int → numeric` is a lossless cast, `2 → 2.00`). The store order +detail shows the rate, marking it "(capped)" when the actual fee is below the nominal +rate on the subtotal. Store fee display derives from the amount, not a hardcoded rate. + +## 2. The gap — Paystack fee is absent end-to-end + +Traced the full money path. There is **no processor fee** at any checkpoint: + +| Checkpoint | File | Finding | +|-----------|------|---------| +| Webhook parse | `integrations/paystack/paystack-payment.provider.ts` `parseWebhookEvent` | Paystack sends `data.fees` on every charge; parser extracts only `reference` + event id and **discards `fees`**. | +| Ledger entry types | `prisma/schema.prisma` | No `PAYSTACK_FEE` / `PROCESSOR_FEE` / `GATEWAY_FEE` entry type exists. | +| Model fields | schema | No `paystackFeeKobo` / `processorFeeKobo` field on Payment or Order. | +| Happy-path payout | `money/ledger/ledger.service.ts` `calculateOrderPayout` | `payout = gross − delivery − platformFee − dropshipperCost`. No processor fee. | +| Dispute settlement | `money/settlement/settlement-amounts.ts` | `platformRetained = available − payout`. No processor fee. | + +**Consequence:** the cash twizrr actually receives from Paystack is +`total − paystackFee`, but it pays the merchant `total − delivery − platformFee` and the +courier `delivery`. So twizrr's real retained margin is: + +``` +twizrr net = platformFee − paystackFee +``` + +## 3. Paystack pricing (Nigeria, local) + +`1.5% + ₦100`, the flat `₦100` waived for transactions under `₦2,500`, capped at +`₦2,000`. Charged on the **total** (subtotal + delivery). (DVA/bank-transfer pricing can +differ; this analysis uses the standard local-card schedule as representative.) + +The structural mismatch: **twizrr charges X% of _subtotal_; Paystack charges ~1.5% of +_total_ + ₦100.** At an equal rate twizrr is always behind by +`1.5% × delivery + ₦100 ≈ ₦122` per order, until Paystack's ₦2,000 cap lets the fee +catch up on large orders. + +## 4. Net margin per order (`platformFee − paystackFee`) + +Delivery assumed ₦1,500. + +| Subtotal | UNVERIFIED 2% | TIER_1 1.5% | TIER_2 1% | TIER_3 0.5% | +|---------:|--------------:|------------:|----------:|------------:| +| ₦5,000 | −₦97 | −₦122 | −₦147 | −₦172 | +| ₦10,000 | −₦72 | −₦122 | −₦172 | −₦222 | +| ₦20,000 | −₦22 | −₦122 | −₦222 | −₦322 | +| ₦50,000 | +₦128 | −₦122 | −₦372 | −₦622 | +| ₦94,000 | +₦348 | −₦122 | −₦592 | −₦1,062 | +| ₦150,000 | +₦1,000 | +₦250 | −₦500 | −₦1,250 | + +**Break-even subtotal (fee finally covers Paystack):** + +| Tier | Break-even subtotal | +|------|--------------------| +| UNVERIFIED (2%) | ₦24,500+ | +| TIER_1 (1.5%) | ₦133,500+ | +| TIER_2 (1%) | ₦200,000+ | +| TIER_3 (0.5%) | ₦400,000+ | + +Observations (these hold **below both caps** — see the qualification after): +- **TIER_1 loses a flat ₦122 on every order** up to ~₦133k (the delivery + flat-₦100 gap). +- The **more verified / loyal the store, the more twizrr loses** — Tier 2/3 are negative + across all *mid-range* order sizes. +- **Bigger orders lose _more_ at Tier 2/3** — *until the caps bite*, because Paystack scales + with order size while the discounted fee shrinks. + +**Cap qualification.** Both sides are capped: commission at `rate × min(subtotal, ₦500,000)` +and Paystack's collection fee at ₦2,000. Above those thresholds the commission keeps growing +to its ₦5,000 (Tier 2) / ₦2,500 (Tier 3) ceiling while Paystack is frozen at ₦2,000, so the +margin turns **positive** on very large orders. Example — ₦1,000,000 Tier 2 subtotal: +commission ₦5,000 vs Paystack ~₦2,000 ⇒ **+₦3,000**, not a larger loss. The "bigger orders +lose more" claim therefore applies only in the mid-range band beneath the Paystack cap. + +(Reproduce with the Paystack formula in §3 against +`platformFee = tierRate × min(subtotal, ₦500,000)`.) + +## 5. Remediation options + +1. **Merchant bears Paystack (matches the doc) — chosen, implemented first.** + Capture the real `fees` from the webhook, persist it, and subtract it in + `calculateOrderPayout`. twizrr's 1–2% then becomes clean margin; merchant total burden + ≈ 3% at Tier 1 — still far below Jumia (~5–15%), Etsy (~6.5%+), eBay (~13%). +2. **Reprice to track Paystack:** base the fee on **total** (not subtotal) and add a small + flat component, e.g. `1.5% × total + ₦100`, so it structurally tracks processing cost. +3. **Floor tier discounts above processing cost:** discounting below ~1.5% guarantees a + loss. Apply tier discounts to twizrr's _margin above_ Paystack, not the gross rate. +4. **Deliberate loss-leader:** keep near-zero commission to pull sellers off + Instagram/WhatsApp and monetise via StorePass / promoted listings / delivery markup / + financing — but then do not _also_ discount to 0.5%. + +## 6. Implementation plan for option 1 (merchant bears Paystack) + +- [x] Capture `fees` (kobo) from the **verified** Paystack charge (authoritative, since + the code re-verifies) and expose it on `VerifyPaymentResult.feesKobo`. + (`paystack.types.ts`, `payment-provider.interface.ts`, `paystack-payment.provider.ts`.) +- [x] Persist the captured processor fee on `Order.processorFeeKobo` at confirmation time + inside the same transaction that marks the order PAID (`payment.service.ts`). +- [x] Subtract the processor fee in `calculateOrderPayout`: + `payout = gross − delivery − platformFee − processorFee − dropshipperCost` + (`ledger.service.ts`); added `processorFeeKobo` to `OrderPayoutBreakdown` and the + order selects that feed it (`store-earnings.service.ts`, ledger available-balance). +- [x] Legacy/missing fee → 0 (nullable column, default null), so historical payouts are + unaffected and never break. +- [x] Tests: processor-fee payout deduction + legacy (null → 0) fallback in + `ledger.service.spec.ts`. 100 money-domain specs pass. +- [x] Migration `20260724120000_add_order_processor_fee_kobo` (nullable `BIGINT`, + backward-compatible ADD COLUMN). +- [x] `TWIZRR_PAYMENT_SYSTEM.md` updated to describe the now-real deduction. + +### Follow-ups + +- [x] **`PROVIDER_FEE` ledger entry** for auditability. Added the `PROVIDER_FEE` + `LedgerEntryType` (migration `20260724130000_...`) and `LedgerService.recordProviderFee`, + recorded in the same confirmation transaction as `PAYMENT_RECEIVED` using the captured + `verification.feesKobo`. Written with direction **`INFO`** so it is **balance-neutral**: + both `getOrderBalance` and `getMerchantAvailableBalance` ignore entry types outside + their credit/debit sets, and the merchant already bears the fee via the reduced payout + amount. Investigated the settlement reconciliation — it reconciles transfer/refund leg + *statuses* with the provider, not a global ledger-sum-vs-Paystack-balance invariant, so + an audit entry cannot destabilise it. Tests assert the entry shape and that a + `PROVIDER_FEE` entry does not change the order escrow balance. + + - **Why balance-neutral is correct (verified), not just a limitation.** The processor fee + is _already excluded_ from every merchant money decision without the `PROVIDER_FEE` + entry, so making that entry balance-affecting would **double-subtract** the fee. Verified + at code level: + - `getMerchantAvailableBalance` credits a released order with + `calculateOrderPayout(order).payoutAmountKobo` (`ledger.service.ts` ~L502), which is + `gross − delivery − platformFee − processorFee − dropshipperCost`. The withdrawable + balance therefore **already nets out `processorFeeKobo`** — it never includes the gross + proceeds. Refund/settlement amounts are bounded by this net available balance, so the + processor fee cannot cause over-refund or over-settlement. + - Settlement amounts additionally come from **explicit** `buyerRefundAmountKobo` / + `merchantPayoutAmountKobo` inputs (`settlement-amounts.ts`), not from a ledger residual. + - The only quantity that stays gross is `getOrderBalance`, and it has **no callers** in + the settlement/refund/dispute/payout paths (its sole reference is a comment) — so its + overstatement drives no money decision. + - _Remaining cleanup (cosmetic, not correctness):_ `getOrderBalance` still reports a gross + per-order residual. Making it fee-aware (a balance-affecting `DEBIT` at capture, plus its + interaction with refund-fee handling) is a reporting-only tidy-up left for a dedicated + pass; no money decision depends on it today. +- [x] Surface the processor fee as its own line in the store order payout breakdown UI. + Threaded `processorFeeKobo` through the store-orders web type + API parse and added a + "Payment processing fee (deducted from proceeds)" line to `StoreOrderDetailClient`'s + PaymentCard (shown only when a fee was captured). The backend already returns it — + `getById` exposes the full order and `sanitizeStoreOwnerOrder` does not strip it. + +_Migration note:_ `DATABASE_URL` is shared Neon — never `migrate dev`. The migration SQL +was authored by hand (a backward-compatible `ADD COLUMN`); apply it via the project's +`migrate diff`/deploy workflow against the real database. diff --git a/TWIZRR_PROVIDER_ARCHITECTURE.md b/TWIZRR_PROVIDER_ARCHITECTURE.md new file mode 100644 index 00000000..1214836a --- /dev/null +++ b/TWIZRR_PROVIDER_ARCHITECTURE.md @@ -0,0 +1,714 @@ +# TWIZRR Provider Architecture + +## Purpose + +This document defines Twizrr's provider abstraction standard for third-party integrations. + +Twizrr's business logic should not depend directly on Paystack, Nomba, Resend, Cloudinary, Shipbubble, Gemini, Vertex AI, Prembly, or any other external provider. Domain services should depend on Twizrr-owned provider interfaces, and each third-party API should be implemented as an adapter behind those interfaces. + +This standard is meant to guide future engineers and AI coding agents when adding, changing, or migrating integrations. + +## Core Principle + +Twizrr owns the business logic and provider contracts. Third-party APIs are adapters. + +Domain services must depend on Twizrr-owned interfaces, not third-party SDKs or provider-specific clients. + +```txt +Good: +PaymentService -> PaymentProvider + +Bad: +PaymentService -> PaystackClient directly +``` + +```txt +Good: +NotificationService -> EmailProvider + +Bad: +NotificationService -> ResendClient directly +``` + +```txt +Good: +SearchService -> EmbeddingProvider / IntentParserProvider + +Bad: +SearchService -> GeminiClient or VertexClient directly +``` + +## Why This Standard Exists + +Twizrr uses providers for money movement, identity verification, email, messaging, storage, AI, address validation, delivery, and other critical operations. These providers can change over time. + +The provider architecture exists so that: + +- Business rules remain stable when a provider API changes. +- Twizrr can add Nomba, Flutterwave, Moniepoint, SendGrid, Termii, Smile ID, or another provider without rewriting domain logic. +- Domain tests can mock Twizrr contracts instead of mocking raw provider SDKs. +- Provider-specific retries, headers, payloads, status codes, and response shapes stay contained. +- User-facing copy and API errors stay in Twizrr language. +- Money, webhook, and reconciliation flows remain auditable and idempotent. + +## What Counts as a Provider + +A provider is any external service that Twizrr calls or receives events from to complete a business capability. + +Examples include: + +- Payment processors and checkout providers. +- Bank transfer and payout providers. +- Email and SMS providers. +- Cloud storage and image CDN providers. +- AI model providers and embedding providers. +- Logistics providers. +- Identity and business verification providers. +- Address, maps, and places providers. +- Subscription billing providers. + +Internal Twizrr services are not providers. Prisma, Redis, and BullMQ are infrastructure dependencies and may have their own wrappers, but they are not business provider contracts in this sense. + +## Provider Architecture Pattern + +The standard shape is: + +```txt +Domain Service + -> Domain-owned Provider Interface + -> Provider Adapter A + -> Provider Adapter B + -> Provider Adapter C +``` + +Example: + +```txt +PayoutService + -> PayoutProvider + -> PaystackPayoutProvider + -> MonnifyPayoutProvider +``` + +The domain service only knows the methods and normalized objects defined by Twizrr. The adapter knows how to call the external provider and translate provider-specific behavior into Twizrr behavior. + +## Payment Provider Ownership and Migration + +`PaymentProvider` selection controls only newly initialized payments. Every +payment record persists its immutable provider owner and provider reference, so +a later environment change cannot send an existing Paystack operation to +Monnify or vice versa. There is no automatic provider fallback. + +Provider checkout retries use immutable `PaymentAttempt` rows. The parent +`Payment` keeps its original provider binding; each fresh provider reference is +stored as a separate attempt so delayed webhooks and manual verification can +still resolve older checkouts. One successful attempt supersedes the other +pending attempts. If another attempt is later confirmed as collected, Twizrr +records a reconciliation exception and does not write a second payment ledger +credit. + +Twizrr persists the parent payment and its first attempt before submitting the +reference to the selected provider. The provider transaction identifier may be +bound once after the provider accepts that durable reference, but it cannot be +rotated afterward. An initialization timeout or a failure to persist the +provider response moves the operation to reconciliation instead of permitting a +blind second submission. + +### Production provider switching + +Changing `PAYMENT_PROVIDER` is a controlled operational change, not routine +runtime routing and not failover. Before switching providers, operators must: + +1. Stop or drain new checkout initialization on the current deployment. +2. Inventory pending, processing, and reconciliation-required operations for + the current provider. +3. Reconcile ambiguous operations and allow already-submitted operations to + reach a terminal or explicitly managed state. +4. Keep the old provider credentials and verified webhook endpoint active for + delayed callbacks, refunds, and reconciliation. +5. Validate the new provider in sandbox, then deploy the selector change so it + affects new payments only. +6. Monitor payment, webhook, reconciliation, and ledger exceptions during the + rollout, with a documented rollback decision. + +Existing payments always continue through their recorded provider adapter. +Twizrr must never silently retry an existing operation through a different +provider. Emergency switching may stop new submissions immediately, but it +must not abandon or re-submit in-flight operations from the old provider. + +Collected-but-discrepant results such as Monnify `OVERPAID` and +`PARTIALLY_PAID`, unknown provider states, and confirmed amount mismatches move +to `RECONCILIATION_REQUIRED`. They are never treated as ordinary failures or as +permission to start another checkout. Pending results remain pending. + +The current Monnify payment adapter is sandbox-gated behind +`MONNIFY_PAYMENT_ENABLED=false` by default. It uses Monnify-hosted checkout, +server-side status and amount verification, and verified production webhooks. +Enabling the adapter requires a complete API key, secret key, contract code, +and base URL at startup. Both authentication and transaction requests have +bounded deadlines, and the provider JSON-number boundary rejects values that +cannot preserve exact kobo. +Sandbox webhooks are not accepted unsigned; the sandbox flow uses server-side +verification because Monnify documents that its signature header is absent in +sandbox. + +### Refund provider ownership + +Refund selection is not a global `REFUND_PROVIDER` switch. A refund is bound to +the immutable provider recorded on the original successful payment. This keeps +Paystack-originated payments on Paystack and Monnify-originated payments on +Monnify even after a controlled checkout-provider change. + +`MonnifyRefundProvider` is sandbox-gated behind +`MONNIFY_REFUND_ENABLED=false` by default. That flag permits new automated +refund submissions for Monnify payments only; it never stops reconciliation of +an already-submitted Monnify refund. The adapter uses a stable Twizrr refund +reference, server-side refund-status lookup, and production HMAC-SHA512 +`monnify-signature` verification using `MONNIFY_SECRET_KEY`. + +A refund webhook is only a verified prompt for server-side reconciliation. It +never directly completes a settlement leg, changes payment/order state, or +writes a ledger entry. `REFUND_COMPLETED` remains possible only after a +confirmed provider result and the matching guarded ledger/state transaction. +Unsigned webhook traffic, including sandbox traffic without a signature, is +ignored. Raw provider payloads, bank details, and refund destination data do +not enter domain events, logs, or public DTOs. + +## Domain-Owned Interfaces + +Provider interfaces belong to the Twizrr domain that owns the business capability. + +The domain interface defines what Twizrr needs, not what a provider happens to expose. + +Rules: + +- Put the interface near the domain service that consumes it. +- Keep the interface narrow and capability-based. +- Use Twizrr names and Twizrr objects in the interface. +- Return normalized response types. +- Throw or return normalized provider errors. +- Do not include provider-specific request payloads, enum names, webhook event names, or raw SDK response types. + +Bad: + +```txt +apps/backend/src/integrations/paystack/payment-provider.interface.ts +``` + +Good: + +```txt +apps/backend/src/domains/money/payment/providers/payment-provider.interface.ts +``` + +## Adapter-Owned Implementations + +Provider adapters implement Twizrr-owned interfaces and live in provider integration areas when practical. + +Adapters own: + +- Provider SDK/client calls. +- Provider auth and headers. +- Provider request payloads. +- Provider response parsing. +- Provider status code mapping. +- Provider retry behavior where appropriate. +- Provider-specific logging context. +- Provider webhook signature verification helpers. + +Domain services receive normalized Twizrr objects only. Raw provider payloads should not leak into domain services. + +## Folder Structure Standard + +Preferred payment structure: + +```txt +apps/backend/src/domains/money/payment/providers/payment-provider.interface.ts +apps/backend/src/integrations/paystack/paystack-payment.provider.ts +apps/backend/src/integrations/nomba/nomba-payment.provider.ts +``` + +Preferred payout structure: + +```txt +apps/backend/src/domains/money/payout/providers/payout-provider.interface.ts +apps/backend/src/integrations/paystack/paystack-payout.provider.ts +apps/backend/src/integrations/nomba/nomba-payout.provider.ts +``` + +Preferred email structure: + +```txt +apps/backend/src/domains/communications/email/providers/email-provider.interface.ts +apps/backend/src/integrations/resend/resend-email.provider.ts +``` + +Preferred storage structure: + +```txt +apps/backend/src/domains/platform/storage/providers/storage-provider.interface.ts +apps/backend/src/integrations/cloudinary/cloudinary-storage.provider.ts +``` + +If an existing domain structure differs, migrate gradually. Do not do a broad folder move only to satisfy this document. + +Important rule: Paystack, Nomba, Resend, Cloudinary, or any third-party integration must not own the domain interface. The Twizrr domain owns the interface. The integration implements it. + +## Error Normalization + +Provider-specific errors must be mapped into normalized Twizrr provider errors. + +Example normalized error codes: + +```ts +type ProviderErrorCode = + | "PROVIDER_TIMEOUT" + | "PROVIDER_UNAVAILABLE" + | "PROVIDER_AUTH_FAILED" + | "PROVIDER_RATE_LIMITED" + | "PROVIDER_INVALID_REQUEST" + | "PROVIDER_REJECTED" + | "PROVIDER_UNKNOWN"; +``` + +Rules: + +- Domain services should not branch on raw Paystack, Nomba, Resend, Cloudinary, Shipbubble, Prembly, Gemini, Vertex, or Google error shapes. +- Provider adapters should log enough provider context for debugging. +- Logs must include Twizrr references where possible, such as order reference, payment reference, payout id, store id, or webhook event id. +- User-facing errors must remain safe and use Twizrr language. +- Secrets, tokens, raw credentials, signed URLs, private prompt values, and sensitive headers must never be logged. + +## Webhook Normalization + +Webhooks need a separate normalization path. A provider webhook is not a Twizrr domain event until the adapter verifies, dedupes, and translates it. + +Pattern: + +```txt +External Provider Webhook + -> Provider-specific webhook controller/adapter + -> verify signature + -> dedupe/idempotency check + -> normalize event + -> emit/call Twizrr domain handler +``` + +Domain handlers should receive normalized events, not raw provider payloads. + +Example: + +```ts +type PaymentProviderEvent = + | { + type: "PAYMENT_SUCCEEDED"; + provider: "PAYSTACK" | "NOMBA"; + reference: string; + amountKobo: bigint; + occurredAt: Date; + rawEventId: string; + } + | { + type: "PAYMENT_FAILED"; + provider: "PAYSTACK" | "NOMBA"; + reference: string; + reason?: string; + occurredAt: Date; + rawEventId: string; + }; +``` + +`PaymentService` should not need to know Paystack's `charge.success` or Nomba's `payment_success`. Adapters translate those provider events into Twizrr events. + +## Idempotency and Reconciliation + +Provider adapters for external writes must support idempotency where the provider allows it, and domain services must preserve stable Twizrr references. + +For money and delivery flows, provider work must include: + +- Idempotency keys for external writes. +- Stable Twizrr references such as order, payment, payout, subscription, delivery, or store references. +- Provider reference mapping. +- Webhook dedupe. +- Reconciliation jobs where money is involved. +- Structured logs tagged with Twizrr reference and provider reference. +- Safe retry behavior that does not duplicate money movement, notifications, bookings, or user-visible side effects. + +For Nomba integrations specifically, respect these principles: + +- Kobo amount convention. +- HMAC webhook verification. +- Request and event idempotency. +- Transaction reconciliation. +- Token caching. +- Merchant, sub-account, or store references where relevant. + +Do not hardcode secrets, exact credentials, or private provider values in docs, tests, logs, or code. + +## Testing Strategy + +Provider work should be tested at the contract boundary. + +Recommended layers: + +- Domain service tests mock the Twizrr provider interface. +- Adapter tests mock the provider HTTP client or SDK and assert request/response translation. +- Webhook tests assert signature verification, dedupe, and event normalization. +- Money tests assert idempotency, ledger correctness, and reconciliation behavior. +- Error tests assert raw provider errors become normalized Twizrr errors. + +Avoid tests that require live provider credentials unless they are explicitly marked as sandbox/manual tests. + +## Migration Strategy + +New integrations must follow this provider architecture immediately. + +Existing stable integrations do not need to be rewritten immediately. Migrate existing direct-client usage gradually when: + +- The provider is being touched for feature work. +- A bugfix already requires changing the integration surface. +- A second provider is being added for the same capability. +- Provider behavior is becoming hard to test safely. +- Provider-specific details are leaking into domain logic. + +Avoid big bang rewrites. Add tests around the existing behavior before replacing direct clients with provider interfaces. Thin compatibility facades are acceptable when they reduce risk during migration. + +## Current Implementation Status + +Current implemented or started provider abstraction: + +- `PaymentProvider` +- `PaystackPaymentProvider` +- Config-driven `PAYMENT_PROVIDER` selection (`paystack` default/active; `monnify` is sandbox-gated; `nomba` and `flutterwave` remain reserved for future adapters) +- `RefundProvider` +- `PaystackRefundProvider` for Paystack-originated payments +- `MonnifyRefundProvider` for Monnify-originated payments, gated by `MONNIFY_REFUND_ENABLED=false` by default +- `PayoutProvider` +- `PaystackPayoutProvider` +- `MonnifyPayoutProvider`, gated by `MONNIFY_PAYOUT_ENABLED=false` by default +- Payout execution derives from the immutable provider on each successful + payment (`Paystack payment -> Paystack payout`; `Monnify payment -> Monnify + payout`), with no automatic fallback or cross-provider settlement +- `PayoutAccountService` +- `SubscriptionBillingProvider` foundation for StorePass +- `ShippingProvider` for delivery quotes, booking, and verified webhook normalization +- `ShipbubbleShippingProvider` as the active `SHIPPING_PROVIDER=shipbubble` adapter +- `IdentityVerificationProvider` for NIN, face-match, and CAC checks +- `PremblyIdentityVerificationProvider` as the active `IDENTITY_VERIFICATION_PROVIDER=prembly` adapter +- `EmailProvider` for Twizrr-controlled transactional email templates and variables +- `ResendEmailProvider` as the active `EMAIL_PROVIDER=resend` adapter +- Config-driven `SUBSCRIPTION_BILLING_PROVIDER` selection (`nomba` default reserved value; current provider is a safe not-implemented foundation until a real adapter is added) +- Store earnings balance architecture documented as a Twizrr-led ledger + projection. Provider treasury/wallet balances are explicitly excluded from + store earnings calculations; see `TWIZRR_MERCHANT_EARNINGS_WALLET_DESIGN.md`. + +Current payout-provider work includes: + +- Store bank verification routed through payout account service. +- Legacy merchant bank verification routed through payout account service. +- Payout queue behavior changed so queued payouts prepare records for admin review. +- Explicit `releasePayout(...)` path through the payout's immutable provider. +- Admin payout processing wired through `PayoutService`. +- `bankVerifiedAt` added to `StoreProfile` schema and migration. +- Immutable provider ownership and destination snapshots on each payout. +- Append-only `PayoutAttempt` references so retrying a confirmed rejection does + not overwrite delayed provider callbacks from an earlier attempt. +- Verified Monnify disbursement webhooks that only wake server-side + reconciliation; webhooks never complete payouts or write the ledger directly. + +### Payout provider ownership and rollout + +Each payout derives its provider from the immutable successful `Payment.provider` +for its order. Every payout persists that provider, a validated destination +snapshot, stable provider reference, and provider operation identifier. Existing +Paystack payouts continue through Paystack and existing Monnify payouts continue through Monnify after a +configuration change. There is no automatic or emergency fallback that moves +an in-flight operation between providers. + +`MONNIFY_PAYOUT_ENABLED=false` remains the committed default. Selecting +Monnify while that gate is disabled fails startup. Live enablement additionally +requires Monnify disbursement activation, an approved source account, +registered static outbound IPs, and Monnify-approved disabling of transfer MFA +for Twizrr's automated API account. `PENDING_AUTHORIZATION` remains a submitted +state requiring provider-side authorization/reconciliation; Twizrr never +collects or relays a Monnify transfer OTP. + +Provider acceptance is not payout completion. Pending, processing, OTP, and +unknown outcomes remain `SUBMITTED` and are reconciled. Only a confirmed +provider success can commit `PAYOUT_COMPLETED`; ambiguous failures are never +blindly retried. A verified webhook only schedules reconciliation, preserving +PostgreSQL and the append-only ledger as the source of truth. + +This is the first implemented example of the provider pattern. It is not the final shape for every provider category, and future provider categories should still be designed around their own Twizrr domain use cases. + +## Recommended Migration Order + +The next recommended provider abstraction is: + +- `PaymentProvider` + +Reason: Twizrr currently uses Paystack for shopper checkout and payment flows, but Twizrr is also participating in the Nomba Hackathon and building StorePass, a Nomba-powered subscription engine for stores. + +Nomba can support payment-related APIs beyond StorePass, including checkout, virtual accounts, webhooks, transfers, tokenized or recurring payments, and reconciliation. Even if Paystack remains the active checkout provider for now, payment logic should be structured so Twizrr can later support Nomba, Flutterwave, Moniepoint, or another provider without rewriting checkout, order, and payment business rules. + +Recommended order: + +1. `PaymentProvider` for shopper checkout, payment initialization, payment verification, virtual accounts, and payment webhooks. +2. `SubscriptionBillingProvider` for StorePass and recurring billing capability. +3. `ShippingProvider` around Shipbubble delivery booking and tracking. +4. `StorageProvider` and `ImageModerationProvider` around Cloudinary and Google Vision upload/moderation flow. +5. Narrow AI provider contracts such as `IntentParserProvider`, `ChatCompletionProvider`, `EmbeddingProvider`, and `ImageLabelingProvider`. + +`PaymentProvider` is now present for shopper checkout/payment flows, with Paystack as the active/default adapter. Nomba, Flutterwave, and Moniepoint payment adapters are reserved for future PRs and are not active yet. + +### Web checkout and dedicated virtual accounts + +The web checkout uses the selected `PaymentProvider` hosted checkout flow. It +does not expose the legacy Paystack Dedicated Virtual Account (DVA) option. +The DVA backend endpoints remain available for a future WIZZA-specific transfer +flow, but must not be presented as a web payment method while they are backed by +the Paystack-specific `DvaService`. Provider changes must not silently route a +Monnify web checkout through the Paystack DVA flow. + +`SubscriptionBillingProvider` is the StorePass billing contract. StorePass subscription billing should use this provider category, not `PaymentProvider`, so subscription state, entitlements, plan access, Store Mode UI, and analytics remain Twizrr-owned while external billing providers stay behind adapters. + +Next PR: `feat(storepass): add Nomba subscription billing provider`. + +## Anti-Overengineering Rules + +- Do not abstract too early or too broadly. +- Design provider interfaces around Twizrr's actual use cases, not the union of every provider's features. +- Do not create one giant interface with methods most providers cannot implement. +- Prefer narrow interfaces by capability. +- Do not leak provider-specific concepts into domain contracts unless Twizrr has adopted them as domain concepts. +- Do not migrate stable integrations only for aesthetic reasons. +- Do not block MVP delivery with large provider rewrites. +- Provider abstraction should reduce coupling, not add ceremony. + +## Provider Category Examples + +### PaymentProvider + +Possible adapters: + +- Paystack +- Monnify +- Nomba +- Flutterwave +- Moniepoint + +Typical Twizrr capabilities: + +- Initialize checkout payment. +- Verify payment. +- Create or manage virtual account payments. +- Normalize payment webhooks. +- Support reconciliation. + +### PayoutProvider + +Possible adapters: + +- Paystack +- Monnify +- Nomba +- Flutterwave + +Typical Twizrr capabilities: + +- List supported banks. +- Resolve payout account names. +- Create transfer recipients. +- Initiate admin-approved transfers. +- Normalize transfer webhooks and transfer status. + +### EmailProvider + +Possible adapters: + +- Resend +- SendGrid +- Mailgun +- AWS SES + +Typical Twizrr capabilities: + +- Send transactional email. +- Normalize delivery failures. +- Support template or plain email abstractions without exposing provider templates to business logic. + +### SmsProvider + +Possible adapters: + +- Africa's Talking +- Termii +- Twilio + +Typical Twizrr capabilities: + +- Send OTP messages. +- Send delivery or account safety messages. +- Normalize send failures and rate limits. + +### StorageProvider + +Possible adapters: + +- Cloudinary +- Amazon S3 +- Cloudflare R2 + +Typical Twizrr capabilities: + +- Store public media. +- Store private verification media where allowed. +- Delete or revoke media where policy permits. +- Return normalized public or private asset references. + +### ImageModerationProvider + +Possible adapters: + +- Google Vision +- AWS Rekognition + +Typical Twizrr capabilities: + +- Run SafeSearch or equivalent image checks. +- Return normalized moderation decisions. +- Keep provider labels and confidence scores out of user-facing copy unless explicitly approved. + +### AI Provider Contracts + +Avoid one huge generic `AiProvider` if narrower interfaces are cleaner. + +Prefer narrow use-case contracts: + +- `IntentParserProvider` +- `ChatCompletionProvider` +- `EmbeddingProvider` +- `ImageLabelingProvider` +- `ImageModerationProvider` + +Possible implementations: + +- Gemini +- OpenAI +- Anthropic +- Groq +- Vertex AI +- Google Vision + +### ShippingProvider + +Possible adapters: + +- Shipbubble +- Future logistics providers + +Typical Twizrr capabilities: + +- Fetch delivery rates. +- Book internal/admin delivery. +- Fetch tracking status. +- Normalize delivery webhooks. + +### IdentityVerificationProvider + +Possible adapters: + +- Prembly +- Smile ID +- Dojah + +Typical Twizrr capabilities: + +- Verify NIN or identity checks. +- Verify CAC or business checks. +- Normalize verification status. +- Keep raw identity payloads out of domain services and logs. + +Current Monnify decision: + +- Prembly remains the active identity verification provider. +- Monnify is not an approved drop-in adapter because its reviewed public contract does not document the face-match and CAC capabilities required by Twizrr. +- Do not split one verification attempt across providers or add automatic provider fallback. +- See `TWIZRR_MONNIFY_IDENTITY_VERIFICATION_ASSESSMENT.md` for the assessment and future gates. + +Identity verification attempts are durable and provider-owned. Twizrr creates +one `StoreVerificationAttempt` before a NIN or face-match provider call, with +an active-operation uniqueness guard to prevent duplicate paid calls. Each +attempt retains its selected provider, Twizrr idempotency key, normalized +status, safe provider reference where available, and safe failure code. + +Raw NINs, selfie bytes, CAC documents, and raw provider payloads do not enter +attempt records. A confirmed rejection closes an attempt under the existing +verification retry and failure-limit rules. A timeout or non-final outcome is +held for reconciliation and cannot be blindly resubmitted or moved to another +provider. + +### Identity-verification reconciliation + +An operator can inspect `RECONCILIATION_REQUIRED` identity-verification +attempts and perform a provider-reference status recheck. Reconciliation calls +the provider's existing-operation status endpoint only; it must never submit a +new NIN, face-match, or CAC verification request. The returned reference must +equal the immutable reference recorded on the Twizrr attempt before a state can +change. A missing, mismatched, unsupported, or expired-session reference moves +the attempt to `MANUAL_REVIEW` rather than allowing a blind retry. + +The reconciliation queue and action are SUPER_ADMIN-only and create safe audit +records. They expose normalized check/status/failure data and safe provider +references, never raw NIN values, biometric material, or provider payloads. + +### SubscriptionBillingProvider + +Possible adapters: + +- Nomba +- Paystack +- Flutterwave +- Future recurring billing providers + +Typical Twizrr capabilities: + +- Create or map store billing customers. +- Initialize StorePass subscription payments. +- Verify subscription payments. +- Verify and parse subscription billing webhooks. +- Normalize subscription payment and cancellation events. + +StorePass business logic remains outside the adapter: Twizrr owns subscription state, entitlements, plan access, Store Mode UI, analytics, reconciliation hooks, and idempotency/dedupe policy. + +## Rules for Future Work + +- New provider-backed features must introduce or reuse a Twizrr-owned provider interface. +- Domain services must not import provider clients directly when a provider interface exists for that capability. +- Raw provider payloads must not be stored unless there is a clear audit/reconciliation reason. +- Stored raw provider payloads must be treated as internal and never returned to frontend or public APIs. +- Money-related providers must use kobo values and BigInt-safe handling. +- Money-related providers must have idempotency and reconciliation notes in the PR. +- Webhook work must include signature verification and event dedupe. +- Provider adapters must not log secrets, signed URLs, credentials, tokens, private prompt values, or sensitive headers. +- UI and API copy must use Twizrr product language, not provider language. + +## PR Checklist for Provider Work + +Use this checklist when opening provider abstraction PRs: + +- The domain owns the provider interface. +- The provider adapter implements the domain interface. +- Domain services depend on the interface, not the third-party client. +- Provider-specific payloads and response shapes stay inside the adapter. +- Provider errors are normalized before reaching domain services. +- Webhooks verify signatures before domain handling. +- Webhooks are deduped with a stable provider event id or Twizrr reference. +- External writes use idempotency keys where supported. +- Money values use kobo and BigInt-safe handling. +- Provider references are mapped to Twizrr references for reconciliation. +- Tests cover domain behavior with mocked provider interfaces. +- Tests cover adapter mapping for provider-specific behavior where practical. +- No secrets, tokens, signed URLs, private prompt values, or credentials are logged or committed. +- Existing behavior is preserved unless the PR explicitly states a behavior change. +- The PR is scoped to one provider category unless a broader change is explicitly approved. diff --git a/TWIZRR_REALTIME_ARCHITECTURE.md b/TWIZRR_REALTIME_ARCHITECTURE.md new file mode 100644 index 00000000..7fe3ab6e --- /dev/null +++ b/TWIZRR_REALTIME_ARCHITECTURE.md @@ -0,0 +1,326 @@ +# TWIZRR Real-Time Architecture + +## Current Rule + +PostgreSQL stores truth. REST APIs read and mutate durable state. Socket.IO only announces that a client should refetch. BullMQ remains for background work. Verified provider webhooks remain the trusted source for external events such as payments and delivery callbacks. + +Socket.IO must never confirm payment state, create order state, replace REST reads, or act as a job queue. + +## Settlement visibility + +`dispute:settlement-updated` is a versioned cache-invalidation hint for the +buyer, merchant, and active SUPER_ADMIN users. Its payload contains only the +settlement, dispute, and order IDs, safe statuses, and `updatedAt`; amounts, +provider references, bank data, errors, and notes remain REST-only. The event is +appended to the transactional outbox with the authoritative settlement or leg +state change, so a rolled-back settlement change produces no socket hint. + +## Phase 1 Scope + +Implemented in this phase: + +- Shared realtime event contracts in `@twizrr/shared`. +- Central Socket.IO handshake authentication. +- Active-user validation against PostgreSQL before a socket is accepted. +- Authenticated socket identity stored on `socket.data.userId`. +- Derived user rooms only: `user:{userId}`. +- Minimal typed `notification:new` delivery hint payload. +- Explicit Socket.IO path configuration. +- Optional Redis Socket.IO adapter controlled by env. +- Frontend reconnect synchronisation through REST query invalidation. +- Socket-focused unit tests. + +Not implemented in this phase: + +- Transactional outbox. +- New business socket events. +- Order, payment, delivery, verification, moderation, or social-feed rooms. +- Chat. +- Presence. +- Typing indicators. +- Read receipts. + +## Current Event + +`notification:new` + +Payload shape: + +```ts +RealtimeEventEnvelope<"notification:new", NotificationCreatedData> +``` + +The notification data includes only: + +- `notificationId` +- `type` +- `createdAt` + +It intentionally excludes notification body text, payment references, bank data, phone numbers, email addresses, tokens, full order data, and sensitive metadata. + +## Current Room + +`user:{userId}` + +The room name is derived server-side from the authenticated socket identity. Clients cannot request arbitrary rooms. There are no `join-room`, `subscribe`, `join-store`, `join-order`, or chat room handlers. + +## Authentication Flow + +```text +Socket.IO handshake + SocketAuthService reads twizrr_access_token from HttpOnly cookie + JwtService verifies the JWT with the HTTP access-token secret + AccessTokenUserService reloads the user from PostgreSQL + user must exist, be active, and not be deleted + identity is attached to socket.data.userId + gateway joins user:{userId} +``` + +Safe rejection codes: + +- `SOCKET_AUTH_REQUIRED` +- `SOCKET_AUTH_INVALID` +- `SOCKET_AUTH_EXPIRED` +- `SOCKET_USER_INACTIVE` + +Raw JWTs, cookies, Redis URLs, notification bodies, email addresses, phone numbers, and full payloads must not be logged. + +## Configuration + +Backend: + +```text +SOCKET_IO_PATH=/socket.io +SOCKET_IO_REDIS_ADAPTER_ENABLED=false +``` + +Frontend: + +```text +NEXT_PUBLIC_SOCKET_IO_PATH=/socket.io +``` + +The frontend path must match the backend `SOCKET_IO_PATH`. + +`CORS_ORIGINS` remains the canonical backend origin allowlist. Credentials are enabled so the browser can send the HttpOnly auth cookie during the Socket.IO handshake. Do not combine wildcard origins with credentialed production sockets. + +## Redis Adapter + +When `SOCKET_IO_REDIS_ADAPTER_ENABLED=false`, the backend uses the default in-memory Socket.IO adapter and logs that realtime delivery is single-instance. + +When `SOCKET_IO_REDIS_ADAPTER_ENABLED=true`, startup creates separate Redis publisher and subscriber connections from `REDIS_URL` and registers `@socket.io/redis-adapter` with the `twizrr:socket.io` adapter key. + +When Redis adapter mode is enabled and Redis cannot be initialised, startup fails rather than silently falling back to instance-local delivery. + +BullMQ and Socket.IO both use `REDIS_URL`, but Socket.IO uses its own adapter channel key. Do not change BullMQ queue prefixes for this phase. + +## Frontend Reconciliation + +The frontend socket is created only inside the authenticated application shell. It does not connect on marketing or unauthenticated pages. + +On socket connect or reconnect: + +```text +invalidate notification list query +invalidate unread-count query +REST refetches authoritative notification state +``` + +On `notification:new`, the same notification queries are invalidated. The socket payload is not rendered as source data. + +If the socket handshake fails with `SOCKET_AUTH_EXPIRED`, the client uses the existing HTTP refresh endpoint once for that connection cycle and reconnects only after refresh succeeds. It does not send tokens through Socket.IO query params or JavaScript-accessible storage. + +## Deployment Notes + +Production proxies must support: + +- WebSocket upgrades. +- The configured Socket.IO path, currently `/socket.io`. +- Credentialed cross-origin requests from `app.twizrr.com` to `api.twizrr.com`. + +If long-polling remains enabled and multiple backend replicas are used without the Redis adapter, the load balancer may require sticky sessions. With multiple replicas, enable the Redis adapter so room emits can reach sockets connected to other API instances. + +Payment state never comes from Socket.IO. Trusted payment state must continue to originate from verified backend processes such as Paystack webhook verification, database and ledger updates, successful commit, and only then notifications and Socket.IO hints. + + +## Phase 2: Transactional Outbox + +DomainEvent remains the permanent business and audit timeline. OutboxEvent is the durable pending integration side effect recorded in the same PostgreSQL transaction as the business state. + +The outbox relay polls PostgreSQL, claims rows with FOR UPDATE SKIP LOCKED, and runs typed handlers outside the claim transaction. Processing is at-least-once: handlers must be idempotent. A worker lease is recovered after OUTBOX_LOCK_TTL_SECONDS; transient failures use bounded exponential backoff and jitter; malformed or exhausted events move to DEAD. Processed and dead rows are retained for inspection. + +The first pilot is notification.dispatch.requested version 1 for confirmed-payment notifications. The handler submits the existing notification BullMQ job with a deterministic job ID. BullMQ is a handler destination, not the source of truth for discovering work. Notification persistence remains the path that emits the existing notification:new Socket.IO hint; clients refetch the durable REST state. + +Outbox idempotency keys prevent duplicate integration records, and notification dedupeKey prevents duplicate durable notifications during at-least-once processing. This phase does not claim exactly-once delivery. + +### Operations + +Inspect pending, processing, and dead counts through the outbox service. Investigate old PROCESSING rows after the lock TTL and inspect lastError on DEAD rows before any manual operational action. Do not delete or edit rows as a routine recovery mechanism. + +The transactional outbox is not yet used by order, delivery, verification, moderation, payout, refund, dispute, or social activity flows. Those migrations are deferred to later phases. No new Socket.IO business events, store/order rooms, presence, or chat functionality are part of Phase 2. + +## Phase 3: Commerce Event Rollout + +Commerce state remains authoritative in PostgreSQL. Successful transactions append an OutboxEvent; the relay later delivers a typed Socket.IO cache hint. The web client invalidates its durable React Query data and refetches from REST. Duplicate delivery is harmless because no commerce state is rendered directly from the socket payload. + +### Commerce Events + +| Business action | Outbox notification | Realtime event | Recipients | +| --- | --- | --- | --- | +| Payment confirmed | Existing Phase 2 notification dispatch | payment:confirmed | Buyer, merchant owner | +| Order created | Existing behavior | order:created | Buyer, merchant owner | +| Order status changed | Preparing and in-transit dispatches | order:updated | Buyer, merchant owner | +| Shipbubble status update | No new notification by default | delivery:updated | Buyer, merchant owner | + +Merchant realtime recipients are always resolved from StoreProfile.userId; Socket.IO never targets a store ID. + +### Durable Commerce Side Effects + +Order transition and dispatch transactions append order:updated events using the persisted OrderEvent.id as the idempotency source. A new DISPATCHED transition also appends order.auto-confirm.schedule.requested; its outbox handler creates the existing delayed auto-confirm-warning-{orderId} and auto-confirm-{orderId} BullMQ jobs. Redis/BullMQ outages therefore leave the request pending for retry instead of losing the schedule. + +Verified Shipbubble webhooks now apply their guarded booking update and delivery realtime outbox rows in one database transaction. Stale, duplicate, and ignored provider updates produce no outbox row. + +### OTP Security Exception + +The dispatched-order notification still contains the plaintext delivery OTP. It intentionally remains on the existing direct secure notification path. The OTP is not written to an outbox payload, domain-event metadata, notification metadata, logs, or a Socket.IO event. The dispatched transition itself still writes non-sensitive order:updated and auto-confirm scheduling outbox records. + +### Frontend Synchronisation + +The authenticated realtime provider validates envelope type, version, IDs, and timestamps before invalidating data: + +- notification:new: notification list and unread count. +- order:created: buyer and merchant order lists. +- order:updated, payment:confirmed, delivery:updated: order lists and the supplied order detail. +- Connect/reconnect: notifications plus buyer and merchant order lists. + +Payout, dispute, verification, moderation, refunds, and all chat/presence features remain outside Phase 3. + + +## Phase 4: Dispute Lifecycle Events + +Dispute state remains authoritative in PostgreSQL. Every successful dispute +lifecycle change appends its realtime hints and milestone notifications inside +the same database transaction that writes the dispute, order, OrderEvent, and +DomainEvent rows. The relay later delivers the typed Socket.IO cache hints; the +web client invalidates its durable React Query data and refetches from REST. +Duplicate delivery is harmless because no dispute state is rendered from the +socket payload. + +### Event Matrix + +| Action | Realtime event | Notification | Recipients | +| --------------- | ------------------ | ------------------------- | ----------------------- | +| Dispute opened | `dispute:created` | Merchant and admins | Buyer, merchant, admins | +| Store responded | `dispute:updated` | Optional buyer milestone | Buyer, merchant, admins | +| Evidence added | `dispute:updated` | None by default | Buyer, merchant, admins | +| Under review | `dispute:updated` | Existing behaviour if any | Buyer, merchant, admins | +| Resolved | `dispute:resolved` | Buyer and merchant | Buyer, merchant, admins | +| Closed | `dispute:updated` | Existing behaviour if any | Buyer, merchant, admins | + +`dispute:updated` carries an `action` discriminator: `STORE_RESPONDED`, +`EVIDENCE_ADDED`, `UNDER_REVIEW`, or `CLOSED`. + +### Financial Boundary + +``` +Dispute resolution records the decision. +It does not execute the financial settlement in Phase 4. +``` + +Resolution writes the outcome to the Dispute, the OrderEvent, an AuditLog, and a +DomainEvent whose metadata keeps `financialExecution = "DEFERRED"`. Buyer and +merchant resolution notifications record the decision only — the wording says a +refund or payout has been **approved and is pending processing**, never that +money has moved. Phase 4 changes no order balances, ledger entries, escrow, +refunds, or payouts. + +The next finance phase owns the deferred execution: + +- Refund execution +- Payout execution +- Escrow release +- Ledger settlement + +### Recipient Resolution + +- **Buyer** — `dispute.buyerId`. +- **Merchant** — resolved to the real user id via `StoreProfile.userId`. + Socket.IO and outbox notifications never address `StoreProfile.id`. +- **Admins** — active operators, resolved as `User.role = SUPER_ADMIN` with + `isActive = true` and `deletedAt = null`. One deterministic outbox event is + appended per admin. An admin added after the event does not receive the + historical socket event; the durable admin list is always available over REST. + +### Concurrency + +A partial unique index (`disputes_one_active_per_order_key`) enforces a single +active dispute per order at the database level for statuses `OPEN`, +`STORE_RESPONDED`, and `UNDER_REVIEW`. The application `findFirst` check is a +fast, friendly path only; the index closes the race between two simultaneous +open requests. A unique-constraint violation is mapped to the API error +`DISPUTE_ALREADY_OPEN`. + +### Idempotency + +Each dispute lifecycle change persists one DomainEvent and derives every +associated outbox idempotency key and notification dedupe key from that event +id: + +``` +domain-event:{domainEventId}:realtime:{recipientUserId} +domain-event:{domainEventId}:notification:{recipientUserId}:{notificationType} +``` + +Relay retries therefore never create duplicate durable notifications, emails, +unread-count increments, or duplicate socket rows. + +### Sensitive-Data Handling + +Dispute realtime payloads carry only `disputeId`, `orderId`, `status`, the +`action`/`outcome` discriminator, and a timestamp. They never include the +dispute description, resolution notes, evidence URLs / public ids / notes, +buyer or merchant contact details, payment references, bank details, or internal +audit metadata. Notification payloads carry only the safe fields required to +deliver the approved notification (`disputeId`, `orderId`, safe reason category, +`outcome`) — never the free-text description or evidence. + +### UNDER_REVIEW + +`UNDER_REVIEW` is reserved in the shared contract and the dispute lifecycle, but +no backend command currently transitions a dispute into `UNDER_REVIEW`. When +such a command is added, it uses the same transactional pattern and emits +`dispute:updated` with `action = "UNDER_REVIEW"` to the buyer, merchant, and +admins. No new API was invented for Phase 4 solely to produce this transition. + +### Frontend Synchronisation + +The authenticated realtime provider validates envelope type, version, +`disputeId`, `orderId`, status, and timestamp before invalidating data. +Malformed or unsupported-version events are ignored without crashing the +provider. For each valid dispute event the provider invalidates the canonical +dispute root, the admin dispute root, and the associated order lists/detail. On +connect/reconnect it invalidates disputes, admin disputes, orders, and store +orders. The provider is mounted once in the authenticated shopper/store shell +and once in the authenticated admin shell (one socket per admin app lifecycle); +admins join only their own `user:{userId}` room. + +Phase 4 introduces no refund/payout/ledger execution, verification, moderation, +chat, presence, or store/order rooms. + +## Delivery provider webhook boundary + +Delivery-provider webhooks are verified and normalized by the active shipping +adapter before the delivery domain applies a guarded PostgreSQL update. Only a +committed, accepted state change appends the existing minimal `delivery:updated` +outbox hint; clients invalidate and refetch through REST. Provider payloads, +addresses, phones, and delivery OTPs never enter that outbox or socket payload. + +## Phase 6: settlement updates + +`dispute:settlement-updated` version `1` is appended through the transactional +outbox whenever an authoritative settlement or settlement-leg state changes. Its +payload is deliberately limited to `settlementId`, `disputeId`, `orderId`, +settlement/leg status summaries, and `updatedAt`. Clients treat it exclusively +as a cache-invalidation hint and refetch REST data; they must not render socket +payload data as financial truth. Reconnect invalidates settlement, dispute, +order, store-order, and admin settlement roots. diff --git a/TWIZRR_REMAINING_BALANCE_COLLECTION_DESIGN.md b/TWIZRR_REMAINING_BALANCE_COLLECTION_DESIGN.md new file mode 100644 index 00000000..32544ba9 --- /dev/null +++ b/TWIZRR_REMAINING_BALANCE_COLLECTION_DESIGN.md @@ -0,0 +1,144 @@ +# TWIZRR Remaining-Balance Collection Design + +## Purpose + +This document defines the provider-bound, multi-collection flow used when a +provider has confirmed an underpayment for an order. It is the prerequisite for +the shopper-facing **Complete payment** action. It does not permit a partial +collection to start delivery, escrow release, merchant entitlement, or payout. + +## Product behaviour + +1. A provider confirms an initial collection `C` where `0 < C < order total T`. +2. Twizrr records an immutable `UNDERPAID` payment exception and keeps the + order in `PENDING_PAYMENT`. +3. Once that provider-confirmed exception is eligible, the buyer may select + **Complete payment** from the order. Twizrr database-claims and creates one + active checkout for `R = T - C`. +4. The new checkout is permanently bound to the original payment provider. + There is no fallback and no cross-provider collection. +5. Only after the provider confirms the remainder exactly, `C + R = T`, does + Twizrr atomically allocate the first collection, record the full order + receipt, hold escrow, and transition the order to `PAID`. + +The buyer is notified only after the underpayment is provider-confirmed and +remaining-balance collection is enabled. Safe copy is: "We received part of +your payment. Complete the remaining balance to confirm your order." It must +not advertise a disabled action or ask the buyer to pay again while either +collection has an ambiguous provider state. + +## Data model + +`Payment` remains the aggregate payment for the order and keeps its immutable +provider owner. Each newly written `PaymentAttempt` gains an immutable expected +collection amount. Initial attempts use the order total; a remaining-balance +attempt uses `T - C`. The database column remains nullable for the first rolling +deployment so an older application instance cannot be broken mid-deploy; +historical rows are backfilled and the new code safely falls back to the parent +payment total for a legacy null. + +There may be one active remaining-balance attempt per underpayment exception. +A duplicate request never calls the provider again while an attempt exists. A +replacement may be created only after the prior attempt is provider-confirmed +failed and its exception link is safely released. A pending, submitted, +unknown, or reconciliation-required attempt is never replaced. + +## Ledger invariant + +Before completion, the first collection is represented only by: + +```text +PAYMENT_AMOUNT_EXCEPTION_RECEIVED CREDIT C +``` + +It is unallocated money and is not order balance or escrow. + +When the remainder is confirmed, one transaction writes: + +```text +PAYMENT_AMOUNT_EXCEPTION_ALLOCATED DEBIT C +PAYMENT_RECEIVED CREDIT T +PLATFORM_FEE_ASSESSED existing policy amount +ESCROW_HELD existing policy amount +``` + +The allocation debit reclassifies—not refunds or spends—the original +unallocated collection. Thus total provider cash is represented once: + +```text +C - C + T = T +``` + +and the order balance sees exactly one `PAYMENT_RECEIVED` for `T`. No partial +collection is ever placed in escrow. + +## Completion transaction + +The confirmed remainder path must atomically: + +1. guard the remainder attempt from `INITIALIZED` to `SUCCESS`; +2. guard the original exception from eligible underpayment status to `RESOLVED`; +3. guard the aggregate payment from `RECONCILIATION_REQUIRED` to `SUCCESS`; +4. append the allocation and full-order ledger entries using deterministic keys; +5. transition `PENDING_PAYMENT` to `PAID`; +6. append existing payment/order notifications and realtime outbox hints. + +If any guard fails, no new ledger or outbox side effect may be appended. A +duplicate webhook, verification request, or browser retry must observe the +committed result rather than create a second collection or allocation. + +## Safety rules + +- All amounts are `BigInt` kobo; no JavaScript `number` financial conversion. +- `C`, `R`, and `T` must satisfy `C > 0`, `R > 0`, and `C + R = T` exactly. +- A provider result that is pending, unknown, overpaid, underpaid, or ambiguous + keeps the operation in reconciliation/manual review. It never issues another + checkout automatically. +- The original provider owns both collection attempts, verification, refund, + reconciliation, and future payout/refund continuity. +- Admins may inspect and reconcile but cannot force the order paid. +- A remaining-balance checkout is available only to the order buyer and only + while the order is `PENDING_PAYMENT`. +- `PAYMENT_REMAINING_BALANCE_COLLECTION_ENABLED` defaults to `false`. It must + remain disabled until settlement refunds can split a refund across every + confirmed provider collection without double-refunding either transaction. + +## Refund compatibility rollout gate + +Implemented after the foundation: an order completed from two provider +transactions now creates one immutable dispute-settlement refund operation per +confirmed collection. Each operation remains bound to its source +`PaymentAttempt`, provider, provider transaction reference, and approved kobo +amount. The parent buyer-refund leg completes and writes its single aggregate +`REFUND_COMPLETED` ledger entry only after every child operation is confirmed. + +Provider acceptance, ambiguous timeouts, or partial child completion never +complete the parent leg. Failed operations reuse their existing database row +and deterministic refund identity; uncertain operations require +reconciliation and cannot be blindly retried. A payment attempt may be reserved +by only one settlement refund operation at the database level. + +The remaining-balance feature still defaults to disabled. Enablement requires +the split-operation PostgreSQL concurrency suite plus manual Paystack and +Monnify sandbox evidence for the exact collection methods that Twizrr will +offer. Implementation alone is not permission to move live funds. + +The manual evidence procedure is maintained in +`TWIZRR_PAYMENT_EXCEPTION_SANDBOX_RUNBOOK.md`. + +## Tests required before enablement + +- duplicate Complete payment clicks create one active remaining attempt; +- duplicate provider success writes one allocation and one full receipt; +- a stale worker cannot allocate after another worker completes the order; +- failed/ambiguous remaining attempts create no allocation, escrow, or payout; +- provider switching cannot change the original provider binding; +- `C + R = T` for non-round kobo values; +- buyer order UI exposes only the remaining amount and safe status. +- two confirmed collections create two immutable provider refund operations; +- duplicate outbox workers submit each refund operation at most once; +- concurrent child confirmations produce one parent completion, one ledger + entry, and one completion notification; +- a payment attempt cannot be reserved by another refund operation; +- ambiguous child operations remain reconciliation-only with no completion + ledger entry. diff --git a/TWIZRR_SELF_HOST_DEPLOYMENT.md b/TWIZRR_SELF_HOST_DEPLOYMENT.md new file mode 100644 index 00000000..f90f2558 --- /dev/null +++ b/TWIZRR_SELF_HOST_DEPLOYMENT.md @@ -0,0 +1,443 @@ +# Twizrr — Self-Host Deployment Runbook (Openship) + +How Twizrr runs on a self-hosted VPS using **Openship** as the control plane, +with **Cloudflare** in front and the database staying on **managed Neon**. + +> This is an operational runbook — follow it top to bottom. Commands that touch +> Openship internals are marked _(confirm against openship.io/docs)_ because +> those docs were being filled out at time of writing. Everything else is +> verified. + +--- + +## 1. Architecture + +```text + Users (Nigeria & beyond) + │ + Cloudflare ── DNS + CDN + DDoS + WAF (proxied, Full-strict TLS) + │ (origin reachable ONLY from Cloudflare IPs) + ┌────────────┴─────────────────────────────┐ + │ One VPS (Contabo VPS 8 / Hetzner CX43) │ + │ │ + │ Openship control plane + OpenResty edge │ :80/:443 (firewalled to CF) + │ ├─ web (Next.js, apps/web) │ loopback only + │ ├─ app (Next.js, apps/web) │ loopback only + │ ├─ admin (Next.js, apps/web) │ loopback only + │ ├─ backend (NestJS, apps/backend) │ loopback only + │ └─ redis (BullMQ + Socket.IO adapter) │ loopback only + └────────────────────┬───────────────────────┘ + │ + Neon (managed Postgres) ← never on the box +``` + +**Why this shape:** Openship's edge does routing + TLS; Cloudflare in front adds +CDN/DDoS/WAF and lets us lock the origin down. Neon stays managed so the +money-critical data + backups are never our responsibility on the box. + +--- + +## 2. Decisions & guardrails (locked) + +| Decision | Choice | +| --- | --- | +| Control plane | **Openship** (self-hosted, pinned release — never `main`) | +| Database | **Neon** (managed) — the box only runs **stateless** app containers | +| Ingress | **Openship edge + Cloudflare proxied**, origin firewalled to Cloudflare IPs | +| Payments at launch | **Paystack only** — `MONNIFY_*_ENABLED=false` | +| Rollout | **Beta first** (disposable rehearsal box), then a **separate dedicated** production box | +| Environments on the box | **Beta only** to start; prod gets its **own** box once beta proves the stack | + +**The three guardrails that make a young platform safe:** +1. **DB on Neon** — if Openship ever breaks, no customer data was on it. +2. **Pin Openship to a tagged release** (`OPENSHIP_VERSION`), not `main`. +3. **Stand up `beta` first** — a **disposable** infra rehearsal (Paystack **test** + keys, throwaway data) — and run real flows (auth, test checkout, feed, product + pages) before production exists on its own box. + +**Environment model (post-staging-drop):** + +| Tier | Who | Where | Data | +| --- | --- | --- | --- | +| **dev + per-PR previews** | Team — integration & final QA (this replaces staging) | Vercel previews / ephemeral Neon | throwaway | +| **beta** | Selected testers — infra rehearsal of the self-host stack | The VPS (this box) | **disposable**, Paystack test | +| **main / prod** | Real users; early access via **allowlist** | A **separate dedicated** box (later) | real, Paystack live | + +**No beta→prod data migration.** Beta and prod are separate Neon databases and +nothing crosses automatically; beta runs sandbox payments, so its financial state +can never become real money. Real early-access users therefore live on **prod +behind an allowlist** from day one — "exiting beta" just opens the gate, and no +data is ever migrated. The beta box stays a disposable rehearsal. + +--- + +## 3. The spec & provider + +Sized from the app's real needs (Next.js build spike ~2–2.5 GB **on top of** +runtime; 3 frontend instances; `socket.io` WebSocket growth): + +| Target | Spec | Provider option | ~Cost/mo | +| --- | --- | --- | --- | +| **Beta rehearsal (current pick)** | **8 vCPU / 24 GB / 300 GB SSD** | **Contabo Cloud VPS 8** (EU) | ~€14 **month-to-month** (no prepay) | +| Recommended for prod (consistent) | 8 vCPU / 16 GB / 160 GB NVMe | **Hetzner CX43** (EU) | ~€16 incl. IPv4 (~$17) | +| Budget floor | 4 vCPU / 8 GB / 80 GB NVMe | Hetzner CX33 | ~€8 | + +- **Two-box end state:** the current box runs **beta only** (rehearsal); when the + stack is proven, **prod gets its own dedicated box** — either promote this box + to prod-only (fresh OS reinstall) and move beta to a cheap box, or buy a fresh + prod box. Never run prod cohabiting with beta long-term (blast radius, noisy + neighbor, wider attack surface). +- **Contabo notes:** cheap because oversubscribed → variable vCPU/disk performance + and slow provisioning/support. Fine for **beta**. Order **month-to-month, 1 + IPv4** — the beta box needs only **one origin IP**; all its surfaces + (`beta`, `app.beta`, `admin.beta`, `api.beta`) sit behind that single IP via the + edge's host-based routing. (The separate **prod** box later gets its **own** + origin IP.) **Do not prepay 24 months** on a box you may rebuild. If prod later + wants consistent performance, Hetzner is the pick — but then Contabo's numbers + won't perfectly predict prod's. +- **Region:** EU (no budget provider has African DCs; Cloudflare edge serves + static close to Nigerian users; API latency is EU-ish, which is fine). +- **Add the provider's automated snapshots / Auto Backup (~€1–5/mo)** — the box is + a single point of failure; snapshots + Neon data + Openship config = fast + rebuild. (Data itself lives on Neon; box backups only protect config/state.) + +--- + +## 4. Phase 0 — Prerequisites + +- [ ] VPS purchased (spec above), **Ubuntu 24.04 LTS**, SSH key added (no password login). +- [ ] Cloudflare zone `twizrr.com` active (nameservers already moved — see the + Cloudflare setup done separately). +- [ ] Neon connection strings ready for **beta** (`ep-winter-hall`) and later + **production** (`ep-morning-mountain`). Pull with + `npx neonctl connection-string --project-id cold-star-22806085`. +- [ ] A pinned Openship version chosen (latest tagged release, e.g. `v0.3.0`). + +--- + +## 5. Phase 1 — Provision the VPS + base hardening + +SSH in as root, then: + +```bash +# --- system --- +apt update && apt -y upgrade +timedatectl set-timezone UTC + +# --- a non-root sudo user --- +adduser --disabled-password --gecos "" deploy +usermod -aG sudo deploy +rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy + +# --- swap (protects against build OOM spikes) --- +fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile +echo '/swapfile none swap sw 0 0' >> /etc/fstab + +# --- Docker (Openship Compose mode needs it) --- +curl -fsSL https://get.docker.com | sh +usermod -aG docker deploy + +# --- firewall: SSH now; :80/:443 get locked to Cloudflare in Phase 3 --- +apt -y install ufw +ufw default deny incoming +ufw default allow outgoing +ufw allow OpenSSH +ufw --force enable + +# --- raise file-descriptor limits for WebSocket concurrency (socket.io) --- +cat >> /etc/security/limits.conf <<'EOF' +* soft nofile 65535 +* hard nofile 65535 +EOF +``` + +> **`limits.conf` only affects login shells — not Docker containers.** The +> WebSocket-concurrency safeguard is only real if the limit also reaches the +> **backend container**: set `nofile` on the backend service's runtime (Compose +> `ulimits: nofile: 65535`, or the Docker daemon default-ulimit), then verify +> inside the container with `cat /proc/1/limits | grep "open files"`. +> _(Set this via Openship's service config — confirm against openship.io/docs.)_ + +Harden SSH in `/etc/ssh/sshd_config`: `PermitRootLogin no`, `PasswordAuthentication no`, then **validate before restarting so a typo can't lock you out**: + +```bash +sshd -t && systemctl restart ssh # -t aborts the restart if the config is invalid +``` + +Keep your current SSH session open and confirm you can open a **second** session before closing the first. (Optional: `apt install fail2ban`.) + +--- + +## 6. Phase 2 — Install Openship (pinned) + +As the `deploy` user. Openship on Linux+Docker runs in **Compose mode** — it +brings up Postgres, Redis, API, dashboard, and the OpenResty edge, and hosts +your apps on the same box. + +> **That bundled Postgres is Openship's own control-plane state — not your app +> database.** Twizrr's data always lives on **managed Neon** via the backend's +> `DATABASE_URL`/`DIRECT_URL` (§8.4). Never point the app at the local Postgres. +> Likewise, the bundled Redis here is Openship's; the app's Redis is a separate +> internal service (§8.3). And the OpenResty edge stays reachable only through +> the Cloudflare allowlist (Phase 3). + +```bash +curl -fsSL https://get.openship.io | sh # installs the CLI (latest release) +openship up --public-url https://ops.twizrr.com # start as a boot service; serve dashboard on a subdomain +``` + +- `openship up` picks **Compose mode** automatically on Linux+Docker (force with `--compose`). +- The wizard creates the first **admin login** — store it in your password manager. +- **Pin the version** for reproducible upgrades: set `OPENSHIP_VERSION=v0.3.0` in + the Openship `.env` and upgrade only with `openship update` _(confirm exact + pin mechanism against openship.io/docs)_. +- Do **not** use `get.openship.io/dev` — that's the from-source build, explicitly + "not a production path." +- Add a Cloudflare DNS record `ops.twizrr.com → ` (proxied) so the + dashboard is reachable; it's login-gated. + +--- + +## 7. Phase 3 — Cloudflare in front + lock the origin + +Openship's edge terminates TLS with Let's Encrypt on `:80/:443`. Put Cloudflare +in front and restrict the origin to Cloudflare only. + +1. **DNS (Cloudflare dashboard):** point the app hostnames at the VPS IP, **proxied (orange)**: + - `twizrr.com` → A → `` + - `www`, `app`, `admin`, `api`, `ops` → A (or CNAME to apex) → `` + - (These replace the old Vercel/Railway targets **at go-live**, not before — see Phase 8.) +2. **SSL/TLS → Overview → Full (strict).** Openship's LE cert makes this valid. +3. **Lock `:80/:443` to Cloudflare IPs** so no one can hit the origin directly. + Openship's edge runs `network_mode: host`, so it binds the host's ports and + **UFW rules apply to it** — this allowlist is effective for the edge: + +```bash +# allow HTTP/HTTPS only from Cloudflare's published ranges +for ip in $(curl -s https://www.cloudflare.com/ips-v4); do ufw allow from $ip to any port 80,443 proto tcp; done +for ip in $(curl -s https://www.cloudflare.com/ips-v6); do ufw allow from $ip to any port 80,443 proto tcp; done +``` + +> **⚠️ Docker publishes ports *around* UFW.** A container started with a +> published port (`-p 6379:6379`, `ports:` in Compose) inserts `DOCKER` iptables +> rules that **bypass UFW's INPUT chain** — so a UFW "deny" will *not* protect a +> Docker-published port. Two rules follow from this: +> - **Never publish Redis/Postgres** (`6379`/`5432`). Keep them on Openship's +> **internal Docker network** (loopback only) — the app reaches Redis by +> service name, never a host port. (This is why our edge uses host-networking +> and everything else stays internal.) +> - If you *ever must* publish a container port publicly, filter it in the +> **`DOCKER-USER`** iptables chain (or the `ufw-docker` helper), not plain UFW. + +4. **Verify the lock from an external machine** (not the VPS): + +```bash +curl -I --connect-to twizrr.com:443::443 https://twizrr.com # via a non-CF IP → should hang/refuse +nc -vz -w3 6379 # Redis → must be "refused/filtered" +``` + +> **Let's Encrypt + proxied Cloudflare:** Openship uses HTTP-01, which needs :80 +> reachable during issuance. Cloudflare proxied + the allowlist above still let +> CF→origin reach :80, so issuance works. If a cert ever fails to issue, +> temporarily set that hostname to **DNS-only (grey)**, let Openship issue, then +> re-proxy. _(Confirm Openship's cert flow against openship.io/docs.)_ + +--- + +## 8. Phase 4 — Prepare the apps + +### 8.1 One low-risk code change: Next.js standalone output +In `apps/web/next.config.*` add: + +```js +const nextConfig = { + output: "standalone", // smaller image, lower RAM per instance + // ...existing config +}; +``` + +### 8.2 Frontend layout (start simple) +Keep the **current 3 instances** (web / app / admin) for launch — they're +isolated and the 16 GB box has room. Each is the **same repo** (`apps/web`) with +different `NEXT_PUBLIC_*` flags. Future optimization (documented, not for launch): +**consolidate store+app into one runtime by hostname, keep admin separate.** + +### 8.3 Define the services in Openship +Point Openship at the GitHub repo `coded-devs/twizrr` and create **four apps** +(monorepo-aware — it rebuilds only what a push touches). Use an `openship.json` +per app or the dashboard to set the subdirectory, build/start, and port +_(confirm schema against openship.io/docs)_: + +| Openship app | Path | Build / Start | Port | Domain | +| --- | --- | --- | --- | --- | +| `twizrr-web` | `apps/web` | `pnpm build` / `pnpm start` | 3000 | twizrr.com, www | +| `twizrr-app` | `apps/web` | `pnpm build` / `pnpm start` | 3000 | app.twizrr.com | +| `twizrr-admin` | `apps/web` | `pnpm build` / `pnpm start` | 3000 | admin.twizrr.com | +| `twizrr-backend` | `apps/backend` | `pnpm build` / `pnpm start:prod` | 4000 | api.twizrr.com | + +Add a **Redis** service (Openship-managed) for the backend — it's cache + BullMQ +queues + the Socket.IO adapter, not source-of-truth. Enable **AOF persistence** +so queued jobs survive a restart. **Keep it on the internal Docker network only +— never publish `6379` to the host** (see the Docker/UFW warning in Phase 3); the +backend reaches it by service name via `REDIS_URL`. + +### 8.4 Environment variables (per app) +Set these in each Openship app (secrets stay in Openship, never in git). + +**Environment-specific values differ between beta and production** — do not +reuse production domains/keys on beta or the beta smoke test (Phase 5) +will fail (CORS blocks, live charges). Use this matrix for the vars that change: + +| Var | Beta | Production | +| --- | --- | --- | +| `DATABASE_URL` / `DIRECT_URL` | Neon **beta** (`ep-winter-hall`) | Neon **production** (`ep-morning-mountain`) | +| `CORS_ORIGINS` | `https://beta.twizrr.com,https://app.beta.twizrr.com,https://admin.beta.twizrr.com` | `https://twizrr.com,https://app.twizrr.com,https://admin.twizrr.com` | +| `WEB_URL` / `APP_URL` / `FRONTEND_URL` | `https://beta.twizrr.com` / `https://app.beta.twizrr.com` (each var → its surface's full origin) | `https://twizrr.com` / `https://app.twizrr.com` | +| `AUTH_COOKIE_DOMAIN` | `beta.twizrr.com` | `twizrr.com` | +| `PAYSTACK_*` / `NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY` | **test** keys | **live** keys | +| `NEXT_PUBLIC_API_URL` | `https://api.beta.twizrr.com` | `https://api.twizrr.com` | + +**Backend (`twizrr-backend`)** — everything above, plus (same on both envs): +- `REDIS_URL` → the internal Openship Redis service +- `NODE_ENV=production`, `PORT=4000`, `PAYSTACK_WEBHOOK_SECRET`, **`MONNIFY_*_ENABLED=false`** +- `JWT_*`, `CLOUDINARY_*`, `GOOGLE_OAUTH_REDIRECT_URL`, `SOCKET_IO_REDIS_ADAPTER_ENABLED` + +**Frontend (`twizrr-web` / `-app` / `-admin`)** — API URL + Paystack key per the +matrix, plus the surface flags that differ per app: +- `twizrr-web`: `NEXT_PUBLIC_MARKETPLACE_ENABLED=true` +- `twizrr-app`: app-surface flags +- `twizrr-admin`: `NEXT_PUBLIC_ADMIN_ENABLED=true` — **must NOT enable the marketplace surface** (avoids the admin redirect loop) + +--- + +## 9. Phase 5 — Deploy on BETA first + +1. Point the backend's `DATABASE_URL`/`DIRECT_URL` at the **beta Neon branch** (`ep-winter-hall`). +2. Track the **`beta`** git branch in each Openship app (push-to-deploy). +3. Deploy: + +```bash +cd twizrr # your local checkout +openship init # link directory → project (confirm per-app flow in docs) +openship deploy +``` + +4. Attach **beta domains** in Cloudflare (proxied): `beta.twizrr.com`, + `app.beta.twizrr.com`, `admin.beta.twizrr.com`, `api.beta.twizrr.com` → VPS IP. +5. **Smoke test on beta:** + - [ ] `api.beta.twizrr.com/health` OK (Redis reachable) + - [ ] Create/edit a product; publish + post-to-feed + - [ ] Auth (login/signup), feed renders, product detail renders + - [ ] Paystack **test** checkout end-to-end + - [ ] WebSocket/live updates work through Cloudflare (proxied) + +--- + +## 10. Phase 6 — Production go-live + +Only after beta is signed off. **Production runs on its own dedicated box** (§3 +two-box end state) — provision it and run Phases 1–3 on it, or promote the beta +box to prod-only via a fresh OS reinstall (moving beta to a cheap box first). + +1. **Neon:** confirm the production branch (`ep-morning-mountain`) is migrated to + the current schema and seeded (reference data + `bootstrap:admin`). +2. **Backend env → production Neon**; confirm **Paystack live** keys + + `MONNIFY_*_ENABLED=false`; Paystack dashboard webhook → `api.twizrr.com`. +3. Track the **`main`** branch in the production Openship apps; deploy. +4. **Early access via allowlist, not migration.** Real selected users sign up + **directly on prod** behind an allowlist/invite flag — their data is prod data + from day one. **Nothing migrates from beta** (separate DB, sandbox payments). + "Exiting beta" = opening the gate; there is no cutover of user data. +5. **Cut the domains:** in Cloudflare, repoint `twizrr.com`, `www`, `app`, + `admin`, `api` from the old Vercel/Railway targets to the **prod VPS IP** + (proxied). Give `dev` its own host (e.g. `dev.twizrr.com`, team-gated) so it no + longer squats on the apex. +6. **Verify:** `/health`, all three surfaces, a real login, a live checkout. +7. Keep the old Vercel/Railway deploys **parked** (not deleted) for the soak window. + +--- + +## 11. Security & ops reference + +- **Origin:** `:80/:443` firewalled to Cloudflare IPs; everything else loopback-only. SSH key-only, root login off. +- **Redis:** bound to loopback / internal Docker network only — never public. AOF on. +- **Secrets:** in Openship (and GitHub/Neon) — never committed. +- **Backups:** Neon = automatic PITR (DB). VPS = provider snapshots (config/state). +- **Updates:** `openship update` on a pinned version; reboot for kernel patches during low traffic. +- **Monitoring:** enable Openship logs; add uptime checks on `/health` and the three surfaces. + +--- + +## 12. Promotion pipeline mapping + +The ladder is now `dev → beta → main` (staging dropped). The deployer swaps from +Vercel/Railway to Openship: + +| Git branch | Neon branch | Openship target | +| --- | --- | --- | +| `dev` | dev (`ep-round-silence`) | Vercel previews / local (team) | +| `beta` | beta (`ep-winter-hall`) | **beta apps on this VPS** (disposable rehearsal) | +| `main` | production (`ep-morning-mountain`) | **production apps on a dedicated box** (later) | + +> **`staging` is retired.** Its git branch and Neon branch (`ep-mute-glitter`) go +> dormant — keep them parked (Neon scales to zero) or delete once you're sure. +> Per-PR preview envs + `dev` cover pre-beta testing. + +Each Openship app tracks one branch; a push re-runs the pipeline for that +environment. The GitHub **promotion-guard** workflow enforces the `dev → beta → +main` ladder. + +--- + +## 13. Rollback + +**Bad app deploy (no data corruption)** — the common case: +1. Roll back to the previous release in Openship (dashboard/CLI) — _(confirm the + exact rollback command against openship.io/docs)_. +2. Verify `/health` + a real login. Done — no DB action needed. + +**Data-affecting incident (bad migration / corruption)** — order matters: +1. **Freeze writes.** Stop the backend app in Openship (or flip it to a + maintenance/read-only mode) so nothing writes during recovery. +2. **Neon PITR.** Restore the **production** branch to a timestamp just before + the incident — via the Neon console (Branches → production → Restore) or + `neonctl branches restore production @ --project-id cold-star-22806085`. + Prefer restoring **into a new branch** first so the current state is preserved. +3. **Repoint + reconnect.** If you restored into a new branch, update the + backend's `DATABASE_URL`/`DIRECT_URL` to it and redeploy. +4. **Schema-compatibility check.** Confirm the running app's Prisma schema + matches the restored DB (`prisma migrate status`) **before** taking traffic — + a restore to an older point may predate migrations the code expects. +5. **Unfreeze**, verify, resume traffic. + +**Whole box down:** restore the latest VPS snapshot, or provision a fresh box and +re-run Phases 1–2 + reconnect the repo — **data is safe on Neon**, so this is a +compute rebuild, not a data recovery. + +**Full bail-out (during the launch soak only):** the old Vercel/Railway deploys +stay parked; repoint Cloudflare DNS back to them. Note their DB pointer — if +they still target the old dev DB, only do this before real production data +accumulates on the new stack. + +--- + +## 14. Growth / upgrade path + +- **First limits you'll hit:** WebSocket concurrency (RAM + FDs — already raised) + and build-vs-runtime contention. +- **Vertical:** bump the VPS (CX43 → CX53: 16 vCPU / 32 GB) — a resize + reboot. +- **Then split concerns:** dedicated build, or move Redis to managed (Upstash), + or run frontend and backend on separate boxes. +- **Consolidate frontends** (store+app one runtime, admin separate) to reclaim RAM. +- **HA (multi-node)** is a later problem — not needed at launch. + +--- + +## 15. To confirm against openship.io/docs + +The docs were being filled out at time of writing. Confirm before relying on: +- Exact `openship.json` schema (monorepo subdir, build/start, port, per-app env). +- Per-branch / multi-environment mapping (beta vs prod apps). +- Version pinning mechanism (`OPENSHIP_VERSION`) and upgrade/rollback commands. +- Certificate issuance flow with Cloudflare proxied (HTTP-01 timing). +- Whether to use Openship-managed Redis vs an external managed Redis. diff --git a/TWIZRR_SETTLEMENT_ROLLOUT_READINESS.md b/TWIZRR_SETTLEMENT_ROLLOUT_READINESS.md new file mode 100644 index 00000000..c770df9d --- /dev/null +++ b/TWIZRR_SETTLEMENT_ROLLOUT_READINESS.md @@ -0,0 +1,80 @@ +# TWIZRR Settlement Rollout Readiness + +Settlement execution remains disabled by default. Automated checks are evidence +only; manual and production operations must not be marked complete without a +dated owner and evidence link. + +## Automated safety gates + +- [ ] All migrations applied to disposable database +- [ ] Concurrent refund claim proved +- [ ] Concurrent payout claim proved +- [ ] Duplicate refund success proved idempotent (including Monnify-bound refund path) +- [ ] Duplicate payout success proved idempotent +- [ ] Ambiguous timeout proved reconciliation-only (including Monnify refund adapter semantics) +- [ ] Full buyer-win ledger invariant passed +- [ ] Full merchant-win ledger invariant passed +- [ ] Partial settlement invariant passed +- [ ] Escrow double-debit test passed +- [ ] Notification dedupe passed +- [ ] Realtime invalidation passed +- [ ] Execution-disabled behaviour passed + +The CI job **Settlement PostgreSQL Safety** is the authoritative automated gate. +It requires `SETTLEMENT_TEST_DATABASE_URL`, accepts only a local disposable +database named `*settlement*test*`, applies migrations using the dedicated +Prisma config, and never falls back to `DATABASE_URL` or `.env.local`. + +## Manual operational gates + +- [ ] Manual provider sandbox test completed +- [ ] Operations runbook reviewed +- [ ] Rollback procedure reviewed +- [ ] Reconciliation-required and manual-review queues have named owners +- [ ] Monitoring thresholds and escalation contacts are configured + +## Rollout stages + +### Stage 0 — disabled everywhere + +`DISPUTE_SETTLEMENT_EXECUTION_ENABLED=false`. Plans, inspection, and +reconciliation visibility may operate; no new provider submission occurs. + +### Stage 1 — sandbox + +Enable only in an isolated provider sandbox for internal test disputes. + +### Stage 2 — production shadow mode + +Keep execution disabled. Validate plan creation, reconciliation visibility, +outbox health, and operations workflows against production-shaped data. + +### Stage 3 — limited production execution + +Allowlist internal/admin-created test orders only. Review every completion and +every reconciliation-required transition. + +### Stage 4 — small percentage rollout + +Expand only with strict monitoring and staffed manual-review coverage. + +### Stage 5 — general availability + +Requires written operations approval after all prior stages and gates pass. + +## Rollback + +Set `DISPUTE_SETTLEMENT_EXECUTION_ENABLED=false` to stop **new** provider +submissions. Continue reconciliation for operations already submitted to a +provider; never abandon or blindly retry submitted financial operations. + +## Monitoring gates + +Before any enablement, establish baseline and alert thresholds for pending +settlement age, reconciliation-required count, manual-review count, failed +refunds, failed payouts, duplicate-provider events, ledger-invariant failures, +and outbox dead-letter count. + +Any ledger-invariant failure blocks rollout and requires financial-owner review. +No operator action may mark money movement complete without confirmed provider +state and the matching ledger entry. diff --git a/TWIZRR_SHOPPER_WALLET_OVERPAYMENT_DESIGN.md b/TWIZRR_SHOPPER_WALLET_OVERPAYMENT_DESIGN.md new file mode 100644 index 00000000..c1af4e63 --- /dev/null +++ b/TWIZRR_SHOPPER_WALLET_OVERPAYMENT_DESIGN.md @@ -0,0 +1,383 @@ +# TWIZRR Shopper Wallet and Overpayment Credit Design + +**Status:** Future design only +**Date:** 20 July 2026 +**Implementation status:** Not implemented + +## Purpose + +This document records the proposed future use of Monnify customer wallets for +Twizrr shoppers, including an optional wallet-credit resolution for overpaid +orders. + +The proposal does not authorize implementation, production enablement, or the +movement of live funds. Commercial, compliance, provider-sandbox, accounting, +and operational approval are required first. + +## Product decision + +A Twizrr shopper wallet can be a valuable future feature when Monnify becomes +Twizrr's primary money provider. It can let shoppers retain funds safely in +Twizrr and reuse them for later purchases without leaving the shopping flow. + +An overpayment may eventually be resolved through either: + +1. **Refund to the original payment method** — the recommended default. +2. **Credit my Twizrr Wallet** — an optional choice requiring the shopper's + explicit consent. + +Twizrr must never automatically force an overpayment into a wallet. + +## Proposed shopper experience + +When Monnify confirms an overpayment, Twizrr may show safe copy such as: + +> You paid more than the amount required for this order. Choose how you want +> the payment handled: +> +> - Refund the full payment +> - Credit the full payment to my Twizrr Wallet + +The existing safest policy treats the mismatched collection as unallocated and +does not mark the order paid. The future wallet option should therefore credit +the entire mismatched collection or refund it in full. + +Twizrr must not silently accept the expected order amount and move only the +excess into the wallet unless a separate allocation policy is deliberately +designed, reviewed, and approved. + +## Wallet-credit flow + +```text +Monnify confirms overpayment + ↓ +Twizrr records an OVERPAID payment exception + ↓ +Shopper explicitly selects wallet credit + ↓ +Twizrr creates an immutable WalletCreditOperation + ↓ +Monnify confirms the customer-wallet credit + ↓ +Twizrr records the wallet liability in its ledger + ↓ +The payment exception becomes resolved +``` + +The wallet credit must be: + +- bound to the original payment exception; +- bound to the shopper and their Monnify customer wallet; +- idempotent and provider-confirmed; +- recorded as a shopper-wallet liability, not merchant earnings, escrow, or an + order payment; +- spendable only after confirmed provider credit and committed ledger state; +- reconciliation-only when the provider outcome is uncertain; +- protected against duplicate workers, callbacks, and provider events. + +## Refund versus wallet-credit accounting + +A completed refund removes the relevant amount from Twizrr's financial +responsibility. + +A wallet credit does not. Twizrr or its regulated provider still holds money +for the shopper: + +```text +Confirmed shopper wallet balance += money Twizrr and its wallet provider owe to that shopper +``` + +The internal ledger will need dedicated append-only identities, for example: + +```text +SHOPPER_WALLET_CREDIT +SHOPPER_WALLET_DEBIT +SHOPPER_WALLET_WITHDRAWAL +SHOPPER_WALLET_REVERSAL +``` + +Wallet accounting must not reuse these unrelated financial meanings: + +```text +PAYMENT_RECEIVED +REFUND_COMPLETED +ESCROW_RELEASED +PAYOUT_COMPLETED +``` + +Required wallet invariants should include: + +```text +confirmed wallet credits +- confirmed wallet debits +- confirmed withdrawals ++ confirmed reversals += shopper wallet liability +``` + +No wallet balance may be created from an unconfirmed provider result. + +## Provider-continuity rule + +Initial wallet-credit eligibility should require the immutable provider binding +stored on the original payment attempt, not only the provider name in current +configuration: + +```text +Original payment provider binding = MONNIFY +Shopper wallet provider binding = the same MONNIFY binding +Wallet credit operation.paymentBindingId = original payment.paymentBindingId +``` + +A Paystack overpayment should remain on the Paystack refund path. Twizrr should +not silently move Paystack-collected money into a Monnify wallet. Doing so would +introduce cross-provider treasury, settlement, liquidity, and reconciliation +complexity. + +Existing financial operations must remain bound to the provider that accepted +them, even if Twizrr later changes the active provider configuration. + +The binding record must be immutable and must identify the provider account or +contract context used for the operation. A later change to +`PAYMENT_PROVIDER` must not make an old payment eligible for a different wallet +provider. If the wallet binding does not exactly match the original payment +binding, use the original provider's refund path instead. + +## What a Monnify customer wallet means + +A Monnify customer wallet is not automatically the shopper's existing personal +Moniepoint account. + +It is a customer wallet created under Twizrr's Monnify integration. Monnify can +provide an account number for funding the wallet and APIs for retrieving wallet +balances, wallet transactions, and wallet statements. Debits and withdrawals +use Monnify's transfer infrastructure. + +Monnify states that live customer-wallet access is available on request and +requires approval from its relationship manager or sales team. Wallet creation +may require a valid BVN and matching date of birth, subject to Monnify's +business review. + +### BVN and date-of-birth privacy contract + +If Monnify requires BVN and date of birth for wallet onboarding, Twizrr must +handle them as sensitive KYC attributes: + +- collect only the fields required by the approved provider contract; +- explain the purpose and obtain explicit consent before collection; +- record the legal basis, consent timestamp, and provider-purpose version; +- encrypt values at rest using the approved secrets/key-management service; +- never persist them in raw DomainEvent, AuditLog, notification, outbox, or + public DTO metadata; +- never log them, include them in URLs, analytics, support exports, or client + error messages; +- restrict access to minimum verification/admin roles and audit every read; +- retain them only for the approved KYC/compliance period; +- support deletion or irreversible redaction when retention and legal holds end; +- retain only a non-reversible verification reference or masked value when raw + attributes are no longer required. + +The wallet must not be created until required consent and provider verification +state are durably recorded. Product and legal approval is required before +implementation. + +Official references: + +- [Monnify Wallet APIs](https://developers.monnify.com/docs/wallets) +- [Create Wallet](https://developers.monnify.com/docs/wallets/create-wallet) +- [Wallet Balance](https://developers.monnify.com/docs/wallets/wallet-balance) +- [Wallet Statement](https://developers.monnify.com/docs/wallets/wallet-statement) +- [Monnify API Reference](https://developers.monnify.com/api) + +Before implementation, Twizrr must confirm with Monnify exactly how an +overpayment can be moved into a customer wallet. The implementation must not +assume that a collection can be reclassified internally without a separate, +provider-confirmed wallet-credit operation. + +## Responsibilities retained by Twizrr + +Using Monnify does not remove Twizrr's product, security, accounting, or support +responsibilities. Twizrr must own: + +- shopper consent for refund versus wallet credit; +- wallet eligibility and KYC rules; +- wallet authorization, PIN, and MFA experience; +- frozen, restricted, suspended, and closed-wallet behavior; +- withdrawal rules and limits; +- append-only ledger and provider reconciliation; +- idempotency and duplicate-event handling; +- account recovery and compromised-account procedures; +- customer support and dispute handling; +- privacy, retention, and safe admin visibility; +- truthful notification and transaction-status copy. + +The wallet identifier is not an authentication credential. Device identity, IP +address, and risk signals must not independently authorize wallet movement. + +## Checkout use later + +After the wallet foundation is proven, a shopper could buy directly from the +feed using an available Twizrr Wallet balance: + +```text +Shopper selects Buy + ↓ +Twizrr shows order total and wallet balance + ↓ +Shopper explicitly authorizes wallet payment + ↓ +Twizrr creates a PENDING wallet-debit operation with a stable idempotency key + ↓ +Provider confirms wallet debit, or returns an uncertain outcome + ↓ +Twizrr commits the payment and escrow ledger only after confirmation + ↓ +Twizrr writes payment and escrow ledger entries + ↓ +Order becomes paid +``` + +Wallet checkout must retain Buyer Protection and escrow. Wallet funds must not +move directly to the merchant. A wallet debit is not order completion until +Twizrr has committed the corresponding payment and escrow ledger state. + +Wallet payment operations must have explicit states such as `PENDING`, +`SUBMITTED`, `COMPLETED`, `FAILED`, and `RECONCILIATION_REQUIRED`. The operation +and its provider idempotency reference are created before the provider call. +Only a provider-confirmed debit may transition to `COMPLETED` and finalize the +payment/escrow ledger in the same guarded transaction. A timeout or unknown +result moves to reconciliation and must never be blindly retried with a new +debit. Duplicate callbacks and workers must reuse the same operation and cannot +finalize the order twice. + +## Safety and product rules + +Do not: + +- auto-credit overpayments without shopper consent; +- remove original-method refund as an option; +- expose wallet provider secrets or raw payloads; +- treat a submitted wallet operation as completed; +- treat customer wallet balances as platform revenue; +- combine shopper wallets with merchant earnings wallets; +- allow cross-provider movement without an approved treasury design; +- use JavaScript floating-point numbers for wallet money; +- let operators manually mark wallet money movement completed; +- use device identity, IP, or location as proof of wallet ownership; +- call a customer wallet the shopper's personal Moniepoint bank account. + +All monetary values must remain BigInt kobo internally. + +## Recommended roadmap + +### PR 1 — Commercial and compliance approval + +```text +chore(wallet): confirm Monnify customer-wallet approval and compliance scope +``` + +- Confirm live API access and commercial terms with Monnify. +- Confirm BVN, date-of-birth, KYC, limits, settlement, withdrawal, and support + requirements. +- Confirm whether wallet-to-wallet or disbursement-to-wallet credit is the + approved overpayment path. +- Document legal, privacy, abandoned-funds, and consumer-refund obligations. + +### PR 2 — Wallet ledger and provider foundation + +```text +feat(wallet): add shopper wallet ledger and provider abstraction +``` + +- Add Twizrr-owned `ShopperWalletProvider` contracts. +- Add immutable wallet, wallet-operation, and provider-binding records. +- Add wallet liability ledger entries and formal invariants. +- Keep all execution disabled by default. + +### PR 3 — Monnify adapter + +```text +feat(wallet): add Monnify shopper wallet adapter +``` + +- Create and retrieve customer wallets. +- Retrieve balances and statements safely. +- Normalize wallet credits, debits, withdrawals, reversals, and provider + errors. +- Add verified webhook handling and reconciliation. + +### PR 4 — Overpayment-to-wallet resolution + +```text +feat(wallet): add explicit overpayment wallet-credit resolution +``` + +- Add explicit shopper consent and immutable resolution choice. +- Create one provider-bound wallet-credit operation per exception. Enforce a + database unique constraint on `(paymentAmountExceptionId, operationType)` and + persist the provider idempotency reference on that operation. Retries and + callbacks must return the existing operation rather than insert another one. +- Preserve original-method refund. +- Resolve the exception only after confirmed provider credit and committed + ledger liability. + +### PR 5 — Shopper wallet UI + +```text +feat(web): add shopper wallet balance and transaction history +``` + +- Show safe available and pending balances. +- Show wallet-credit, purchase, withdrawal, reversal, and refund history. +- Keep provider references and operational data private. + +### PR 6 — Wallet checkout + +```text +feat(wallet): add wallet checkout +``` + +- Add explicit wallet-payment authorization. +- Preserve escrow and Buyer Protection. +- Prevent duplicate wallet debits and order payment. +- Support insufficient-balance and ambiguous-result reconciliation. + +### PR 7 — Concurrency and accounting proof + +```text +test(wallet): prove wallet concurrency and liability invariants +``` + +- Prove duplicate credits, debits, callbacks, and workers are idempotent. +- Prove wallet liabilities equal confirmed provider balances and movements. +- Prove ambiguous operations cannot be blindly retried. +- Prove wallet checkout cannot debit funds or pay an order twice. +- Prove shopper wallets and merchant earnings remain separate. + +## Rollout gates + +Do not enable live shopper wallets until all of the following have evidence: + +- Monnify has approved Twizrr for live customer-wallet access; +- commercial and transaction costs are documented; +- KYC, limits, PIN/MFA, recovery, restriction, and withdrawal policies are + approved; +- provider sandbox wallet creation, credit, debit, reversal, and statement + flows pass; +- PostgreSQL concurrency and ledger-invariant tests pass; +- reconciliation and manual-review queues have named owners; +- monitoring and escalation thresholds are configured; +- rollback and customer-support procedures are reviewed; +- legal and consumer-protection review is complete. + +## Final recommendation + +The shopper wallet is a good future product direction. It can give shoppers +faster access to reusable funds and later support immediate purchases from the +feed. + +However, original-method refund must remain available, wallet credit must be an +explicit choice, Monnify-to-Monnify provider continuity should be enforced for +the first version, and every wallet balance must be treated as a real financial +liability rather than an ordinary application credit. diff --git a/TWIZRR_WHATSAPP_AI_CONTRACT.md b/TWIZRR_WHATSAPP_AI_CONTRACT.md new file mode 100644 index 00000000..5abfcdd4 --- /dev/null +++ b/TWIZRR_WHATSAPP_AI_CONTRACT.md @@ -0,0 +1,383 @@ +# WIZZA WhatsApp AI Public Contract + +> Status: Public review contract for WIZZA, Twizrr’s AI shopping assistant. +> Scope: WhatsApp shopper assistant behavior, Gemini function contract, backend enforcement, state model, search boundaries, privacy, and tests. +> Runtime source of truth: `apps/backend/src/channels/whatsapp/`. +> Private prompt note: this document defines the public contract. It must not include private system prompt values, secrets, provider keys, or hidden evaluation text. + +--- + +## Assistant Role + +WIZZA is Twizrr’s WhatsApp shopping surface. It helps Nigerian shoppers discover products, understand Twizrr shopping flows, and complete eligible shopper actions through approved backend handlers. + +WIZZA is not a separate marketplace, company, seller, support team, or store-management tool. It must never imply that shoppers are buying from WIZZA. Shoppers buy on Twizrr from stores, with protected payment through twizrr Buyer Protection. + +WIZZA is for shoppers only. Store owners must manage their stores from the twizrr dashboard. + +Canonical identity phrase: + +`WIZZA, Twizrr’s AI shopping assistant` + +Store-management refusal copy: + +`WIZZA is for shopping. Manage your store from your twizrr dashboard.` + +--- + +## State Model + +### Redis Session State + +Redis is for short-lived WhatsApp conversation state only. It may store: + +- conversation state such as awaiting email or awaiting OTP +- short-lived OTP/linking state with TTL +- message deduplication keys +- AI pause/resume flags +- recent interaction context needed to process the next shopper message + +Redis must not be the source of truth for account identity, order ownership, payment state, delivery confirmation, consent audit history, or product inventory. + +Current implementation notes: + +- `WA_SESSION_PREFIX`, `WA_OTP_PREFIX`, and message dedup keys are defined in WhatsApp constants. +- WhatsApp session TTL is currently 30 minutes. +- AI pause state exists for support-style flows. + +### PostgreSQL WhatsAppSession + +`WhatsAppSession` is the current durable per-phone session record. It stores the WhatsApp phone, optional linked `userId`, consent status, consent timestamp, and last message timestamp. + +Current implementation notes: + +- Consent gate is enforced before AI intent parsing, text search, and image search. +- A new or unconsented phone receives the consent prompt first. +- Product discovery must not run before consent. + +### PostgreSQL WhatsAppLink + +`WhatsAppLink` is the durable source of truth for a verified WhatsApp-to-user account link. + +Rules: + +- Protected account actions require an active verified `WhatsAppLink`. +- The backend must not silently match shoppers by `User.phone`. +- A linked WhatsApp phone can differ from `User.phone`. +- `User.phoneVerified` must not be changed by an unrelated WhatsApp link. +- Link creation must happen through an explicit verification flow. + +Current implementation notes: + +- `WhatsAppLink` stores `phone`, `userId`, `linkedAt`, and `isActive`. +- Account/order/checkout/payment/delivery actions must use the link, not raw phone matching. + +### PostgreSQL WhatsAppConsentLog + +`WhatsAppConsentLog` is the durable audit model for consent and revocation events. It is implemented and written by the session service whenever consent is given or declined. + +Current implementation notes: + +- Each event stores `phone`, `isConsentGiven`, `consentVersion`, `consentedAt`, and `source`. +- The live consent gate is still enforced through the session record; the consent log is the audit trail, not the runtime gate. + +--- + +## Capability Matrix + +| Capability | Guest After Consent | Requires Active WhatsAppLink | Current Status | Backend Rule | +| ------------------------------------------------------ | ------------------: | ---------------------------: | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Assistant identity and safe Twizrr help | Yes | No | Partial | Gemini may classify, backend validates safe answer text. | +| Product text search | Yes | No | Implemented | Hybrid-ranked discovery after consent; must not force account linking. | +| Product-like phrases such as "I want to buy iPhone 15" | Yes | No | Implemented guard | Treat as product discovery unless the shopper asks for account-specific action. | +| Image search | Yes | No | Implemented (embedding-first) | Inbound images are SafeSearch-screened before any discovery; clearly unsafe images get a safe refusal. Vertex multimodal image embedding is the primary signal; labels are context/fallback. | +| Browse categories or search again | Yes | No | Implemented | Category lists paginate within Meta's row limit; browse/search-again actions clear stale selection state, and text follow-up enters an explicit pending-search state. | +| Product detail selection | Yes | No | Implemented | Resolves only against Redis-cached recently shown results (list row, number/ordinal, or title); stale selections get a search-again message. | +| Cart | No | Yes | Implemented | Returns a bounded, shopper-safe cart summary and confirmation-protected mutations for the linked user. | +| Saved delivery addresses | No | Yes | Implemented read | Returns bounded address labels and location text for the linked user; IDs and phone numbers are not exposed. | +| Wishlist and saved products | No | Yes | Placeholder/gated | Deterministic link-required response until handlers exist. | +| Checkout handoff | No | Yes | Implemented | Validates and summarizes the live cart, requires explicit confirmation, then returns the configured web `/cart` URL without creating an order or payment. | +| Payment | No | Yes | Web only | Payment remains on the protected Twizrr web checkout path. | +| Order list and order status | No | Yes | Implemented read | Requires an active link and scopes public order references to the linked buyer. | +| Delivery status | No | Yes | Implemented read | Requires an active link and reads tracking only after linked-buyer ownership is established. | +| Delivery confirmation | No | Yes | Implemented write | Requires an active link, linked-buyer ownership, explicit eligible-order selection, and a valid delivery code in a short-lived scoped flow. | +| Disputes, refunds, receipts | No | Yes | Gap/target | Deterministic response until domain-backed handlers exist. | +| Account linking help | Yes | No | Partial | Explain linking safely; verified link creation must use explicit OTP flow. | +| Support handoff | Yes | No | Deterministic | Must not claim live human handoff unless implemented. | +| Store management | No | No | Must refuse | Send the exact store-management refusal copy. | +| Payouts, KYB, admin actions | No | No | Must refuse | These are web/dashboard/admin flows, not WIZZA flows. | +| Out-of-scope non-shopping requests | No | No | Must redirect | Keep response short and shopping-focused. | + +--- + +## Gemini Function Contract + +Gemini classification is advisory. Backend handlers are authoritative. + +Every declared Gemini function must have a corresponding backend handler or deterministic backend response. The prompt must not declare functions that the backend cannot handle. + +Current declared function families: + +- `search_products` +- `get_cart` +- `start_checkout` +- `get_saved_addresses` +- `list_orders` +- `get_order_status` +- `get_delivery_status` +- `confirm_delivery` +- `show_menu` +- `answer_twizrr_question` +- `support_handoff` + +Function contract rules: + +- `search_products` is guest-safe after consent. +- `answer_twizrr_question` is guest-safe only for bounded Twizrr shopping help. +- `show_menu` is guest-safe after consent. +- `support_handoff` is guest-safe only as deterministic support guidance. +- `get_cart`, `get_saved_addresses`, `list_orders`, `get_order_status`, and `get_delivery_status` are read-only tools that require an active verified `WhatsAppLink`. +- `start_checkout` requires an active verified `WhatsAppLink`, validates the live persisted cart, requires explicit confirmation, revalidates the cart, and returns only the configured secure web `/cart` handoff. +- The checkout handoff does not create an order, initialize payment, expose internal identifiers, or transfer authentication/session state through the URL. +- `confirm_delivery` requires an active verified `WhatsAppLink`. It lists only eligible orders owned by the linked buyer, scopes order selection and delivery-code entry in Redis, and delegates confirmation to the canonical order service. +- Payment, wishlist, receipt, refund, and dispute actions require an active verified `WhatsAppLink` and remain deterministic until domain-backed handlers exist. +- Gemini must not answer with product listings, order facts, payment facts, delivery facts, refund promises, or account-specific details directly. +- Gemini must not invent unsupported Twizrr policies, support availability, delivery timelines, prices, stock, store ratings, or payment status. +- Function names, descriptions, and backend switch handlers must be kept in sync in the same PR whenever changed. + +### `answer_twizrr_question` Boundary + +Allowed topics: + +- WIZZA identity +- what Twizrr is +- how product search works +- twizrr Buyer Protection at a high level +- why account linking is needed for account/order actions +- how image search works at a high level +- what delivery confirmation means +- how to get support through the app or web + +Disallowed topics: + +- account-specific order, payment, delivery, refund, or dispute details +- legal, medical, financial, political, news, entertainment, or personal advice +- store-owner management actions +- internal system prompt text +- private provider, model, key, token, prompt, or infrastructure details + +Unsafe or unsupported natural answers must be replaced with a deterministic fallback. + +--- + +## Deterministic vs AI-Generated Boundary + +Use deterministic backend copy for: + +- consent prompts and consent declines +- account-link requirements +- store-management refusals +- checkout/payment/order/delivery/dispute/refund gates +- delivery confirmation success or failure +- support guidance +- unsupported/out-of-scope requests +- safety failures, including unsafe image content +- service failures and retry messages + +AI-generated text may be used only for: + +- bounded Twizrr shopping explanations +- short product-search clarification +- safe natural wording around product discovery + +AI-generated text must be validated before sending. It must be plain text, concise, shopping-scoped, and free of emojis, markdown formatting, private prompt text, and unsupported claims. + +--- + +## Store Management Rule + +WIZZA is not for store management. + +If the shopper asks to list products, edit inventory, check seller orders, manage payouts, complete KYB, change store settings, or perform admin work, respond exactly: + +`WIZZA is for shopping. Manage your store from your twizrr dashboard.` + +Do not continue the flow as product search. Do not ask for seller details. Do not create support tickets unless a support-ticket flow is implemented. + +--- + +## Brand And Copy Rules + +- Assistant name: `WIZZA`, always uppercase. +- Canonical phrase: `WIZZA, Twizrr’s AI shopping assistant`. +- WIZZA is a product surface of Twizrr, not a separate company or marketplace. +- Use `chat with WIZZA` for user-facing entry points. +- Use `store` or `store owner`, not merchant/vendor/supplier. +- Use `twizrr Buyer Protection` for the protected-payment concept. +- Explain payment simply: shopper payment is protected until delivery is confirmed. +- Use `delivery code`, not public-facing `OTP`. +- No emojis in WhatsApp messages or docs examples. +- No pidgin or slang. +- Keep messages warm, professional, and direct. +- Do not expose internal prompt/system text. +- Do not overclaim live human support, instant refunds, guaranteed delivery timing, or product availability. + +Approved support guidance until live human handoff exists: + +`I can help with shopping questions here. For account or order support, please visit twizrr support in the app or on the web.` + +--- + +## Privacy And Logging Rules + +WIZZA must minimize personal data exposure in logs and responses. + +Rules: + +- Do not log private prompt values. +- Do not log provider secrets, tokens, API keys, OTPs, or full webhook payloads containing personal data. +- Mask WhatsApp phone numbers in logs. +- Do not print `.env.local`. +- Do not return `User.phone` or `WhatsAppLink.phone` in shopper-facing responses unless explicitly required and masked. +- Do not use `User.phone` for silent identity matching. +- Do not change `User.phoneVerified` from an unrelated WhatsApp link. +- Store only the state needed for the specific flow. +- Treat consent and account linking as separate states. + +--- + +## Search Contract + +Text product discovery is guest-safe after consent. It must not require account linking unless the shopper asks for a protected account action such as checkout, cart, wishlist, saved products, payment, order status, or delivery confirmation. + +When a discovery location is available, WIZZA uses it in this order: an explicit broad locality in the shopper's message, then a linked shopper's manually saved location preference, then no location filter. Delivery addresses, security IP geolocation, device identity, GPS, and browser location are never discovery inputs. + +### Text Discovery Ranking (implemented) + +Text discovery uses the approved hybrid ranking contract: + +`final_score = vector_similarity * 0.5 + text_rank * 0.2 + store_performance * 0.2 + tier_boost * 0.1` + +- `vector_similarity` — pgvector cosine similarity between the Vertex AI text embedding of the query and stored product embeddings. +- `text_rank` — normalized title/name/description/category match. +- `store_performance` — order completion rate normalized to [0, 1]; stores with insufficient history (fewer than 5 completed orders or no recorded rate) default to a neutral 0.5 and are never penalized to zero. +- `tier_boost` — TIER_2 = 1.0, TIER_1 = 0.5. Tier 0 stores are excluded from all WIZZA discovery. + +Weights are configurable via `SEARCH_WEIGHT_VECTOR`, `SEARCH_WEIGHT_TEXT`, `SEARCH_WEIGHT_PERFORMANCE`, and `SEARCH_WEIGHT_TIER`. Weights must be numeric, non-negative, and sum to 1.0 (tiny float tolerance); invalid weights fail backend startup. + +### Image Search (implemented — embedding-first with SafeSearch screening) + +1. Download image from Meta without saving it to disk. +2. Screen the inbound image with Cloud Vision SafeSearch before any discovery work. Blocked images get the safe refusal copy and never reach labels, embedding, candidate search, or the Redis recent-product cache. +3. Extract Cloud Vision labels/objects/text — context and fallback only. +4. Enrich those labels into deterministic taxonomy hints via the shared taxonomy resolver (`resolveDiscoveryHintsFromLabels`). Taxonomy is a category/subcategory ranking signal only — it never triggers a product to show on its own and adds no external AI calls. +5. Generate a Vertex AI multimodal image embedding (1408-dim) — the primary image-search signal. +6. Retrieve candidates by pgvector similarity, then rank: embedding similarity is primary and taxonomy is only a within-band tie-breaker (similarity is quantized into narrow bands; a candidate in a stronger band always ranks first, so taxonomy never overrides a clearly stronger embedding match). When labels resolve to no confident taxonomy, ordering is identical to pure embedding similarity. +7. Re-validate eligibility (active/public/in-stock rules, Tier 0 excluded, sourced listings under the digital store context) as the final gate. +8. Embedding failure degrades gracefully to safe label fallback; label failure does not block vector search. `zeroResults` is true only when a real search returned no products. +9. Return concise product results and safe follow-up actions. + +SafeSearch screening rules (implemented): + +- Block on `LIKELY` or `VERY_LIKELY` for `adult`, `violence`, or `racy` — the same thresholds as upload moderation. Lower likelihood values (`POSSIBLE`, `UNLIKELY`, `VERY_UNLIKELY`, unknown) never block. `medical` and `spoof` are not blocking categories unless the upload moderation policy changes. +- Blocked shopper copy (no moderation internals, no emojis): `Sorry, I can't search from that image. Please send a clear product photo instead.` +- Blocked analytics are honest: `errorType: IMAGE_BLOCKED_UNSAFE`, `zeroResults: false`, `vectorSearchUsed: false`, `embeddingModel: null`. +- Screening failures fail open: a Vision outage logs safely (masked, no image data) and image search continues — the image is search input only, never stored or shown to others. + +### Result Identity, URLs, and Privacy (implemented) + +- Every product result links with the canonical URL `/stores/{storeHandle}/p/{productCode}`. Native products use their own public `productCode` and selling store handle; sourced listings use the sourced listing's public `productCode` and the digital/reseller store handle. +- Sourced listings appear to shoppers as normal products sold by the digital/reseller Twizrr store. `sourceCode`, `listingCode`, `sourcedProductId`, `physicalProductId`, and `physicalStoreId` never appear in shopper-facing URLs or replies, and no supplier/source/dropship/partner-store/physical-store wording is used. +- Shopper-facing store display fallback is `storeName → storeHandle → "twizrr store"`. `businessName` is internal/legal/KYB identity and is not selected or exposed in WIZZA replies. + +### Recent-Product Selection (implemented) + +- Displayed results (text and image search) are cached in Redis per phone with safe public fields only. Shoppers can select by list row, number/ordinal, or product title; selection resolves only against recently shown results, and stale selections get a search-again message. +- Starting a new text search, opening either category page, or entering account linking clears stale product-selection state before the next flow begins. +- `browse_categories` opens the first category page and `browse_categories_more` opens the next page. Each list stays within Meta's ten-row limit. +- `search_products`, including the image-result text-search follow-up, creates a short-lived pending-search state so the shopper's next text is interpreted as a new product query. +- Category row IDs resolve through the shared product taxonomy before normal eligibility-filtered discovery runs. + +### Channel Delivery Reliability (implemented) + +Inbound webhook contract: + +- Verify Meta HMAC before reading or processing message content. +- Walk every valid entry, change, and supported message in a webhook batch; queue each accepted message independently. +- Deduplicate by Meta message ID in Redis for five minutes and use deterministic BullMQ job IDs. +- Rate limit per normalized WhatsApp phone at 30 accepted messages per one-hour window. +- Return 200 only after all accepted messages have reached BullMQ. If Redis or BullMQ fails, release the deduplication reservation and return 503 so Meta can retry. +- Inbound jobs use three attempts with exponential backoff. + +Outbound notification contract: + +- Notification jobs use bounded BullMQ retries, exponential backoff, deterministic event job IDs, completed-job retention, and retained failed jobs. +- The WhatsApp processor rethrows Meta delivery failures; it must not swallow a failed send and mark the job successful. +- Direct-order receipt and order-dispatched notifications are the currently verified domain producer paths. +- Payment-confirmed and delivery-confirmed processor handlers are not documented as domain-wired until their producers, payloads, and approved outbound-message contracts are confirmed. +- Payout initiation remains an in-app/email event. It must never reuse a delivery-confirmed WhatsApp job. +- The current verified template sender is the OTP/authentication path using the configured `auth_otp` template. Proposed launch templates are not considered approved or runtime-wired without evidence from both Meta configuration and code. + +### Analytics v2 (implemented) + +- Every discovery interaction honestly records `vectorSearchUsed`, `embeddingModel`, `productsRankedCount`, `productsShown`/`productsShownCount`, `zeroResults`, and fallback/error state. No raw image data, secrets, or prompt values are logged. +- Taxonomy-aware zero-result signals: when text/image discovery understood the request (taxonomy resolved with useful confidence) but returned zero eligible products after eligibility filters, a safe internal signal is recorded on the same per-message analytics record (taxonomy category labels, matched terms, confidence, capped normalized query for text only, reason code). Image signals are additionally gated by a direct category eligibility check (native + sourced), so a vector top-N miss is never mistaken for a supply gap. This surfaces category demand/supply gaps and is internal analytics only — never shopper-facing, no ranking/discovery change. Unsafe/blocked images and failed downloads are never counted as product demand, and image search stores no query text, image data, or provider payloads. These signals feed an internal, aggregate-only category demand insights service (admin/operator only at `GET /admin/search/category-demand-insights`) that surfaces which categories have search demand but no eligible supply — no shopper-facing behavior, no store-facing dashboard yet. + +--- + +## Tests Required + +Focused WhatsApp changes should include tests for the behavior they touch. + +Minimum regression coverage for this contract: + +- consent gate runs before AI intent parsing, text search, and image search +- guest product discovery works after consent without account linking +- product-like purchase phrases route to product discovery, not early account blocking +- protected account actions require an active verified `WhatsAppLink` +- linked WhatsApp identity does not silently fall back to `User.phone` +- unrelated WhatsApp links do not change `User.phoneVerified` +- Gemini function declarations and backend handlers stay in sync +- `support_handoff` returns deterministic support guidance and does not claim live human handoff +- store-management requests return the exact store-management refusal copy +- natural Twizrr answers are bounded and validated +- every valid message in a multi-message Meta payload is queued independently +- duplicate Meta message IDs are ignored while Redis/BullMQ intake failures remain retriable +- inbound rate limits are isolated per normalized WhatsApp phone +- outbound Meta failures are rethrown for bounded BullMQ retry +- direct order, dispatch, payment, delivery, and payout events never share misleading job names or payloads +- browse, category pagination, search-again, and image-to-text follow-up clear or replace selection state safely +- numeric selection works only against an active recent product result set +- private prompt values are not logged or exposed +- `.env.local` is not touched + +Docs-only PRs do not need backend build validation unless hooks require it, but they should run diff checks and a staged secret scan. + +--- + +## Known Gaps To Track Separately + +These remain open and must not be documented as complete: + +- domain-backed payment, wishlist, receipt, refund, and dispute handlers +- live human support handoff or support-ticket creation from WhatsApp +- comprehensive logging/privacy review across all remaining WhatsApp paths + +Completed since this contract was first written (now reflected above): + +- linked-buyer delivery confirmation with explicit order selection, scoped delivery-code handling, bounded invalid attempts, and canonical order-state delegation + +- SafeSearch screening of inbound shopper images before image discovery +- durable `WhatsAppConsentLog` audit model and Redis/live session split +- Redis-backed product result selection for rows, numbers, and titles +- text discovery hybrid ranking (approved formula + configurable weights) +- embedding-first image search with graceful label fallback +- canonical product URLs and the discovery-to-web-checkout handoff QA +- public store-name privacy (`businessName` removed from shopper replies) +- WhatsApp analytics v2 capture (vector/model/count/fallback/zero-results) +- WhatsApp-first account linking and outbound phone log masking +- batched webhook intake, message-ID deduplication, per-phone rate limiting, and retriable queue failures +- bounded outbound notification retries and corrected event/job boundaries +- category browsing, search-again, image-to-text follow-up, and stale selection-state handling +- linked cart reads and explicitly confirmed cart mutations +- live-cart checkout validation, one-time confirmation, and secure web `/cart` handoff diff --git a/Twizrr_StorePass.md b/Twizrr_StorePass.md new file mode 100644 index 00000000..06f3e5cf --- /dev/null +++ b/Twizrr_StorePass.md @@ -0,0 +1,107 @@ +# Twizrr StorePass + +**Nomba Hackathon — Subscriptions Engine Track** + +> _"A Nomba-powered subscription engine for Nigerian social-commerce stores."_ + +## Overview + +Twizrr is a Nigerian social-commerce marketplace where store owners list products, manage orders, grow their stores, and sell through social content. Shoppers discover products through the Twizrr app/feed and **WIZZA**, the WhatsApp AI shopping assistant. + +For the Nomba Hackathon, Twizrr is building **StorePass** — a recurring subscription layer that lets stores pay monthly for growth benefits across the platform. Nomba powers the billing infrastructure; Twizrr manages the subscription state, entitlements, and the Store Mode experience. + +> **Important:** WIZZA is shopper-facing on WhatsApp only. Store owners manage their products, subscriptions, analytics, campaigns, and settings inside **Twizrr Store Mode**. StorePass is not a WIZZA dashboard and not a store-management tool inside WhatsApp. + +## Subscription Benefits + +### 1. Twizrr Platform Growth Benefits + +Benefits inside the Twizrr app/web platform: + +- More reach for store posts and products across the Twizrr feed +- Featured product placements +- Featured store placements +- Campaign boost credits +- Better visibility in discovery sections +- Store performance analytics +- Product visibility insights +- Growth recommendations +- Access to premium store tools +- Better support for store growth and conversion + +### 2. WIZZA WhatsApp Discovery Benefits + +These are not store-owner dashboard tools. They are benefits connected to how shoppers discover products through WIZZA on WhatsApp: + +- Subscribed stores receive WIZZA discovery credits +- Products eligible for boosted visibility in relevant WIZZA search results +- Products can appear in sponsored/recommended slots matching shopper intent +- Insights from WIZZA-driven discovery: product views, shopper interest, and conversion signals +- Track how many shoppers discovered products through WIZZA +- More opportunities to be surfaced when shoppers ask WIZZA for matching products + +> **Note:** Paid plans add controlled growth boosts and premium visibility opportunities — not unfair ranking control. Discovery still depends on relevance, trust, product quality, shopper interest, store performance, stock availability, and verification. + +## Plan Structure + +### Free Plan + +- Basic store profile +- Basic product listing +- Normal organic product discovery +- Normal eligibility for WIZZA search when relevant + +### Growth Plan + +- More reach for posts and products +- Featured placement credits +- Campaign boost credits +- Store analytics +- Product visibility insights +- WIZZA discovery credits +- WIZZA-driven product interest insights + +### Pro Plan + +- More growth credits +- Higher featured-placement limits +- Advanced analytics +- More campaign tools +- More WIZZA discovery credits +- Better WIZZA-driven visibility reporting +- Priority support +- More automation for store growth + +## Roles & Responsibilities + +### Nomba's Role + +Nomba powers the subscription billing infrastructure: + +- First subscription payment +- Recurring billing +- Webhook confirmation +- Failed-payment handling & retry logic +- Billing history / invoices +- Subscription state updates + +### Twizrr's Role + +Twizrr manages: + +- Store subscription plan +- Active/inactive subscription status +- StorePass entitlements +- Access control for premium platform benefits +- Access control for WIZZA discovery credits +- Plan upgrades, downgrades, and cancellations +- Store Mode UI for subscriptions +- Analytics showing how StorePass benefits are performing + +--- + +> _"In simple terms, Twizrr StorePass helps Nigerian social-commerce stores pay monthly for measurable growth benefits, while Nomba provides the recurring payment infrastructure that makes the subscription system reliable."_ + +--- + +_Twizrr · A product of CodedDEVS Technology LTD · Lagos, Nigeria_ diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 5179bd9e..58f005f2 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -1,86 +1,359 @@ -# DATABASE -DATABASE_URL="postgresql://user:password@localhost:5432/dbname?schema=public" - -# REDIS -REDIS_URL="redis://localhost:6379" - -# AUTH (JWT) -JWT_ACCESS_SECRET="your-access-secret-64-chars" -JWT_REFRESH_SECRET="your-refresh-secret-64-chars" -JWT_ACCESS_TTL="15m" -JWT_REFRESH_TTL="7d" - -# PAYSTACK -PAYSTACK_SECRET_KEY="sk_test_xxxxx" -PAYSTACK_PUBLIC_KEY="pk_test_xxxxx" -PAYSTACK_WEBHOOK_SECRET="whsec_xxxxx" -PAYSTACK_BASE_URL="https://api.paystack.co" - -# EMAIL (Resend) -RESEND_API_KEY="re_xxxxx" -EMAIL_FROM="noreply@yourdomain.com" - -# AFRICASTALKING -AT_USERNAME="sandbox" -AT_API_KEY="your_at_api_key" -AT_SENDER_ID="your_at_sender_id" - -# APP -NODE_ENV="development" +# ───────────────────────────────────────────── +# twizrr — Backend Environment Variables +# Copy this file to .env.local and fill in values +# Never commit .env.local to git +# ───────────────────────────────────────────── + +# ── APP ────────────────────────────────────── +# development | production. In production this MUST be "production": auth cookies +# are only marked Secure + SameSite=None when NODE_ENV !== "development", which is +# required for the app.twizrr.com -> api.twizrr.com cross-subdomain login to work. +NODE_ENV=development + +# Port the API listens on (binds 0.0.0.0). On Railway/any proxy host, the +# service's public + custom domain TARGET PORT must equal this value. +# A mismatch (e.g. domain -> 8080 while the app listens on 4000) returns +# 502 "Application failed to respond" even though the app started fine. PORT=4000 -FRONTEND_URL="http://localhost:3000" -CORS_ORIGINS="http://localhost:3000" - -# AI & WHATSAPP -GEMINI_API_KEY="your_gemini_api_key" - -# WhatsApp integration credentials (Meta Graph API) -WHATSAPP_PHONE_NUMBER_ID="your_phone_id" -WHATSAPP_ACCESS_TOKEN="your_access_token" -WHATSAPP_VERIFY_TOKEN="your_verify_token" -WHATSAPP_APP_SECRET="your_app_secret" - -# AI Assistant Prompts -WHATSAPP_MERCHANT_SYSTEM_PROMPT="You are SwiftTrade Bot, a friendly AI assistant..." -WHATSAPP_BUYER_SYSTEM_PROMPT="You are the SwiftTrade 'Command Center' AI..." -WHATSAPP_SUPPLIER_SYSTEM_PROMPT="You are SwiftTrade Supplier Bot..." - -# CLOUDINARY -CLOUDINARY_CLOUD_NAME="your_cloud_name" -CLOUDINARY_API_KEY="your_api_key" -CLOUDINARY_API_SECRET="your_api_secret" - -# DATABASE SEEDING -ADMIN_BOOTSTRAP_EMAIL="admin@yourdomain.com" -ADMIN_BOOTSTRAP_PASSWORD="secure_password" -FORCE_BOOTSTRAP_PROMOTE="false" -DEV_DEMO_MERCHANT_PASSWORD="secure_password" - -# IMAGE SEARCH (VISION AI) -GOOGLE_CLOUD_API_KEY="your_google_cloud_api_key" -IMAGE_SEARCH_PRIMARY="cloud_vision" -IMAGE_SEARCH_FALLBACK="gemini" - -# TRADE FINANCING & LOGISTICS -TRADE_FINANCING_COMMISSION_PERCENTAGE=3 -FINANCING_PARTNER="mock" -LOGISTICS_PARTNER="mock_partner" -LOGISTICS_ALLOWED_IPS="" -LOGISTICS_WEBHOOK_SECRET="whsec_xxxxx" - -# PLATFORM FEES (percentage) -PLATFORM_FEE_ESCROW=2 -PLATFORM_FEE_DIRECT_TIER2=1.5 -PLATFORM_FEE_DIRECT_TIER3=1 - -# TIMERS (hours/minutes/seconds) -AUTO_CONFIRMATION_HOURS=72 + +# Backend base URL (this API's own public URL) — NOT the frontend. +# Local: http://localhost:4000 Production: https://api.twizrr.com +APP_URL=http://localhost:4000 + +# Authoritative product-app URL. The loaded backend config (core/config/app.config.ts) +# derives BOTH app.webUrl and app.frontendUrl from this single variable, so WEB_URL +# is what actually drives: +# - auth/OAuth redirects via getWebRedirectUrl() (Google sign-in, /login?error=...) +# - the Paystack payment callback (${WEB_URL}/buyer/orders/payment/callback) +# - password-reset email links (${WEB_URL}/reset-password) +# Must be https and the real app domain in prod — never localhost. +# Local: http://localhost:3000 Production: https://app.twizrr.com +WEB_URL=http://localhost:3000 + +# NOTE: currently NOT read by the running backend. The only module that reads +# FRONTEND_URL (src/config/app.config.ts) is a legacy duplicate that is not loaded; +# the active app config sources app.frontendUrl from WEB_URL (above). Set WEB_URL, +# not this. Retained for compatibility only — if you set it, keep it == WEB_URL. +FRONTEND_URL=http://localhost:3000 +ENABLE_SWAGGER=false + +# ── DATABASE (Neon PostgreSQL) ──────────────── +# Pooled connection — used by app at runtime (via PgBouncer) +DATABASE_URL= + +# Direct connection — used by Prisma CLI for migrations ONLY +# Never use this in application code +DIRECT_URL= + +# ── REDIS (Queues + cache) ──────────────────── +# REDIS_URL must be a Redis protocol URL. BullMQ workers require TCP Redis. +# Local Docker Redis: +# redis://localhost:6379 +# Managed/cloud Redis provider: +# rediss://default:@:6379 +# Do NOT use an Upstash REST HTTPS URL for REDIS_URL. +# +# On Railway (Redis plugin): set this to the reference variable, NOT a literal +# URL, so it always tracks the live Redis password: +# REDIS_URL=${{Redis.REDIS_URL}} +# A hardcoded password goes stale when Redis rotates/regenerates it, which +# floods the logs with "WRONGPASS invalid username-password pair" and 502s +# the whole API (health then reports redis: error). +REDIS_URL= + +# SOCKET.IO REALTIME +# Default Socket.IO endpoint path. The frontend NEXT_PUBLIC_SOCKET_IO_PATH must match. +SOCKET_IO_PATH=/socket.io +# Keep false for single-instance/local. Set true only when REDIS_URL is a Redis +# protocol URL and the backend runs multiple API instances. +SOCKET_IO_REDIS_ADAPTER_ENABLED=false +# ── AUTH (JWT) ──────────────────────────────── +# Generate with: openssl rand -hex 32 or node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" +JWT_SECRET= +JWT_ACCESS_SECRET= +JWT_REFRESH_SECRET= +JWT_EXPIRES_IN=15m +JWT_REFRESH_EXPIRES_IN=7d +JWT_ACCESS_TTL=15m +JWT_REFRESH_TTL=7d +# Leave blank for localhost. In production set this to the shared PARENT/registrable +# domain (twizrr.com) so the HttpOnly auth cookie issued by api.twizrr.com is also +# accepted and sent by app.twizrr.com. +# Do not include a scheme, port, path, or leading dot. +# +# CRITICAL: use the parent domain (twizrr.com) — NOT a specific subdomain. +# A server can only set a cookie for its own domain or a parent. If this is set +# to a sibling subdomain (e.g. app.twizrr.com) the browser SILENTLY DROPS the +# cookie: login returns 200 but no session is stored, so the app keeps bouncing +# the user back to /login. Requires secure + SameSite=None (already handled in +# code for non-development NODE_ENV). +AUTH_COOKIE_DOMAIN= +# Server-side pepper for first-party UUID device IDs. Required to persist device +# hashes; never expose this value to the browser or commit a real value. +DEVICE_ID_HASH_SECRET= +# Required for onboarding/auth and WhatsApp account-linking verification codes. +# Use a strong random secret, set it in local .env.local and deployed backend env, +# and do not reuse JWT_SECRET or any JWT access/refresh secret. +# CI may use a dummy value only for tests and runtime smoke checks. +ONBOARDING_OTP_SECRET= + +# ── CORS ───────────────────────────────────── +# Comma-separated list of allowed origins +# Local: http://localhost:3000,http://admin.localhost:3000 +# Production: https://app.twizrr.com,https://twizrr.com,https://admin.twizrr.com +# The admin console runs on its own host (admin.twizrr.com) and calls this API, +# so include it here alongside the app/marketing origins. +# Never use * on staging or production +CORS_ORIGINS=http://localhost:3000 +# Request IP extraction for audit/security context. +# Keep false locally. Set true only when the backend runs behind trusted proxy +# infrastructure that sets client IP headers, such as Railway/load balancers. +# For the current setup: backend on Railway, web origins on Vercel +# (twizrr.com and app.twizrr.com) -> use standard. +# Use cloudflare only if Cloudflare is the trusted edge in front of the backend. +TRUST_PROXY_HEADERS=false +TRUST_PROXY_HEADER_PROVIDER=standard + +# PROVIDER SELECTION +# Active provider bindings in the current backend: +# PaymentProvider -> Paystack (Monnify sandbox-gated) +# PayoutProvider -> Paystack +# RefundProvider -> immutable original-payment provider (Paystack or Monnify) +# SubscriptionBillingProvider -> Nomba +# ShippingProvider -> Shipbubble +# IdentityVerificationProvider -> Prembly +# EmailProvider -> Resend +# Do not switch a selector to a future provider until its adapter, configuration, +# verified webhook handling, reconciliation, and tests have been added. +# Provider credentials are backend-only. Never expose them through NEXT_PUBLIC_* vars. + +# PAYMENT PROVIDER +# Active/default: paystack. +# Reserved future values: nomba, flutterwave. Selecting one now +# intentionally fails startup until its adapter exists. +PAYMENT_PROVIDER=paystack + +# PAYOUT AND REFUND PROVIDERS +# There is no independent PAYOUT_PROVIDER selector. Each payout and refund is +# bound to the immutable provider recorded on the original successful payment: +# Paystack payment -> Paystack refund/payout; Monnify payment -> Monnify +# refund/payout. Changing PAYMENT_PROVIDER affects only new checkouts and must +# never reroute an existing financial operation or provide a fallback. + +# SUBSCRIPTION BILLING PROVIDER +# Active/default and the only usable provider today: nomba for StorePass. +# `paystack` and `flutterwave` are accepted reserved values, but their adapters +# are not implemented: StorePass calls will fail safely at runtime if selected. +# Do not select either reserved value until its adapter has been added and tested. +SUBSCRIPTION_BILLING_PROVIDER=nomba + +# NOMBA (StorePass subscription billing) +# Only required when SUBSCRIPTION_BILLING_PROVIDER=nomba and the adapter is invoked. +# NOMBA_BASE_URL already includes /v1. Do not commit real credentials. +NOMBA_BASE_URL=https://sandbox.nomba.com/v1 +NOMBA_ACCOUNT_ID= +NOMBA_SUB_ACCOUNT_ID= +NOMBA_CLIENT_ID= +NOMBA_CLIENT_SECRET= +NOMBA_WEBHOOK_SECRET= + +# PAYSTACK (ACTIVE: PaymentProvider, PayoutProvider, RefundProvider) +# Used for shopper checkout, verified payment webhooks, admin-approved payouts, +# dispute/settlement refunds, and reconciliation. +# Use TEST keys for local, dev, and staging +# Use LIVE keys for beta and production ONLY +# Never mix test and live keys in the same environment +PAYSTACK_SECRET_KEY= +PAYSTACK_PUBLIC_KEY= +PAYSTACK_WEBHOOK_SECRET= +PAYSTACK_BASE_URL=https://api.paystack.co + +# MONNIFY (PAYMENT + REFUND + PAYOUT ADAPTERS — DISABLED BY DEFAULT) +# Set PAYMENT_PROVIDER=monnify only with MONNIFY_PAYMENT_ENABLED=true in an +# approved sandbox environment. Keep false in local shared, CI, preview, and +# production environments until payment and operational rollout gates pass. +# Monnify payments and their refunds are provider-bound; there is no Paystack +# fallback. MONNIFY_REFUND_ENABLED gates only new automated refunds for +# Monnify-originated payments. It does not disable reconciliation of an already +# submitted Monnify refund. +# Monnify payouts additionally require an activated disbursement product, the +# approved source account below, static-IP allowlisting for live traffic, and +# provider-approved MFA disablement for Twizrr's automated API payout account. +# Twizrr never handles a Monnify transfer OTP. Do not set +# PAYMENT_PROVIDER=monnify until every payout rollout gate has passed. +MONNIFY_PAYMENT_ENABLED=false +MONNIFY_REFUND_ENABLED=false +MONNIFY_PAYOUT_ENABLED=false +# Keeps full reversals of unallocated overpayments dark by default. This is +# independent of provider capability and requires explicit financial-operations +# rollout approval before it can be enabled. +PAYMENT_AMOUNT_EXCEPTION_REFUND_EXECUTION_ENABLED=false +# Keeps multi-collection underpayment completion dark until split-refund +# operations and sandbox reconciliation have passed their rollout gates. +PAYMENT_REMAINING_BALANCE_COLLECTION_ENABLED=false +MONNIFY_API_KEY= +MONNIFY_SECRET_KEY= +MONNIFY_CONTRACT_CODE= +MONNIFY_DISBURSEMENT_ACCOUNT_NUMBER= +MONNIFY_BASE_URL=https://sandbox.monnify.com +# Production webhook signatures use MONNIFY_SECRET_KEY and monnify-signature. +# Monnify documents no signature header for sandbox webhooks, so unsigned +# sandbox webhooks are intentionally ignored; use server-side verification. + +# ── CLOUDINARY ──────────────────────────────── +# Backend-only signed upload credentials. Do not expose API key or API secret +# through NEXT_PUBLIC_* web env vars. +CLOUDINARY_CLOUD_NAME= +CLOUDINARY_API_KEY= +CLOUDINARY_API_SECRET= + +# ── RESEND (Email) ──────────────────────────── +# RESEND (ACTIVE: EmailProvider) +# Transactional email only. Twizrr owns templates, safe variables, retry/dedupe +# behaviour, and user-facing copy; Resend is the delivery adapter. +EMAIL_PROVIDER=resend +RESEND_API_KEY= +EMAIL_FROM=noreply@twizrr.com + +# ── WHATSAPP (Meta Cloud API) ───────────────── +WHATSAPP_TOKEN= +WHATSAPP_APP_SECRET= +WHATSAPP_VERIFY_TOKEN= +WHATSAPP_PHONE_NUMBER_ID= +WHATSAPP_CONSENT_MESSAGE= +WHATSAPP_SYSTEM_PROMPT= + +# ── AFRICA'S TALKING (SMS + USSD) ──────────── +AT_USERNAME= +AT_API_KEY= +AT_SENDER_ID= +# Optional shared secret for the SMS delivery-report webhook. If set, add it to +# the AT dashboard callback URL as ?token=... — POST /sms/delivery-report rejects +# reports whose token does not match. Leave empty to accept all reports. +AT_DELIVERY_REPORT_SECRET= + +# ── GOOGLE AI ───────────────────────────────── +# Gemini 2.5 Flash — conversation and intent parsing +GEMINI_API_KEY= +GEMINI_MODEL=gemini-2.5-flash + +# Google Cloud Vision API — image labeling and SafeSearch moderation +GOOGLE_CLOUD_API_KEY= + +# Vertex AI Multimodal Embeddings — 1408-dim product vectors +GOOGLE_CLOUD_PROJECT= +GOOGLE_APPLICATION_CREDENTIALS= +VERTEX_AI_LOCATION=us-central1 +VERTEX_AI_MODEL=multimodalembedding@001 + +# ── SEARCH RANKING (WIZZA text product discovery) ─ +# Hybrid ranking weights: +# final_score = vector_similarity * SEARCH_WEIGHT_VECTOR +# + text_rank * SEARCH_WEIGHT_TEXT +# + store_performance * SEARCH_WEIGHT_PERFORMANCE +# + tier_boost * SEARCH_WEIGHT_TIER +# Each weight must be >= 0 and the four weights must sum to 1.0. +# The backend fails to start if the configured weights do not sum to 1.0. +SEARCH_WEIGHT_VECTOR=0.5 +SEARCH_WEIGHT_TEXT=0.2 +SEARCH_WEIGHT_PERFORMANCE=0.2 +SEARCH_WEIGHT_TIER=0.1 + +# ── SHIPBUBBLE (Logistics) ──────────────────── +# SHIPBUBBLE (ACTIVE: ShippingProvider) +# Shipbubble handles quote, booking, tracking, and verified webhook input. +# PostgreSQL remains authoritative; delivery OTPs must never enter provider +# metadata, outbox rows, realtime events, or logs. +SHIPPING_PROVIDER=shipbubble +SHIPBUBBLE_API_KEY= +SHIPBUBBLE_WEBHOOK_SECRET= +SHIPBUBBLE_BASE_URL=https://api.shipbubble.com/v1 +# Provider-sensitive package defaults used only when product/package data is missing. +# Confirm these in the Shipbubble sandbox before provider testing. +SHIPBUBBLE_DEFAULT_CATEGORY_ID=1 +DEFAULT_PACKAGE_WEIGHT_KG=1 + +# ── PREMBLY (KYB Verification) ─────────────── +# PREMBLY (ACTIVE: IdentityVerificationProvider) +# Prembly supports current store verification flows. Keep raw NIN, selfie/CAC +# material, and provider payloads out of logs, events, and public responses. +IDENTITY_VERIFICATION_PROVIDER=prembly +PREMBLY_API_KEY= +PREMBLY_APP_ID= +PREMBLY_BASE_URL=https://api.prembly.com + +# ── GOOGLE PLACES (Address Autocomplete) ───── +GOOGLE_PLACES_API_KEY= +GOOGLE_PLACES_BASE_URL=https://places.googleapis.com/v1 + +# ── GOOGLE OAUTH (Backend redirect flow) ───── +# Google verifies email only. Twizrr onboarding still collects first name, +# last name, date of birth, and verified phone before account completion. +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_OAUTH_REDIRECT_URL=http://localhost:4000/auth/google/callback +GOOGLE_OAUTH_STATE_TTL_SECONDS=600 +GOOGLE_PENDING_ONBOARDING_TTL_SECONDS=900 + +# ── ADMIN BOOTSTRAP ────────────────────────── +# The single SUPER_ADMIN account for the admin console (admin.twizrr.com). +# Set BOTH before creating the first admin. There is no admin signup — this is +# the only way to create the admin. +# +# PRODUCTION (recommended — admin only, no reference/demo data, no local-env +# override): set these in the backend host env, then run once: +# railway run pnpm --filter @twizrr/backend run bootstrap:admin +# (src/prisma/bootstrap-admin-cli.ts — loads local .env WITHOUT override, so +# the injected host DB is always the target) +# +# LOCAL / CI (full reference data + admin): +# pnpm --filter @twizrr/backend run seed +# NOTE: seed.ts loads .env.local with override:true, so DO NOT run the full +# seed via `railway run` — it would target your local DB, not production. +# Use bootstrap:admin for production instead. +# +# Bootstrap behaviour (src/prisma/bootstrap-admin.ts): +# - creates the SUPER_ADMIN from these envs only when no SUPER_ADMIN exists +# - idempotent: repeat runs never re-create or reset the admin password +# - if a user already exists with this email, it is promoted to SUPER_ADMIN +# - the password is only hashed — never logged; keep credentials in env only +ADMIN_BOOTSTRAP_EMAIL= +ADMIN_BOOTSTRAP_PASSWORD= + +# ── PLATFORM CONFIG ─────────────────────────── +# Configurable platform behaviour — do not hardcode these values in code +# Platform commission as a fraction of subtotal, keyed by the merchant's +# verificationTier. Higher verification earns a lower fee. UNVERIFIED is the +# base rate and the fallback for any unknown tier. TIER_3 is configured ahead +# of the VerificationTier enum gaining that value (currently unreachable). +PLATFORM_FEE_UNVERIFIED=0.02 +PLATFORM_FEE_TIER_1=0.015 +PLATFORM_FEE_TIER_2=0.01 +PLATFORM_FEE_TIER_3=0.005 +# Commission is charged on the subtotal only up to this cap (kobo). Past it the +# fee freezes so large orders aren't over-charged; the per-tier rate still +# applies so higher tiers stay cheaper. Default ₦500,000 = 50,000,000 kobo. +PLATFORM_FEE_CAP_ORDER_KOBO=50000000 +AUTO_CONFIRM_HOURS=48 +AUTO_CONFIRMATION_HOURS=48 +AUTO_CONFIRM_REMINDER_FIRST_HOURS=16 +AUTO_CONFIRM_REMINDER_FINAL_HOURS=32 +AUTO_CONFIRM_DISPUTE_WINDOW_HOURS=48 +DISPUTE_WINDOW_HOURS=72 ESCROW_WINDOW_HOURS=24 +OTP_TTL_MINUTES=15 OTP_EXPIRY_EMAIL_MINUTES=10 OTP_EXPIRY_AUTH_MINUTES=15 OTP_EXPIRY_WHATSAPP_MINUTES=5 +SESSION_TTL_SECONDS=86400 ONBOARDING_SESSION_TTL_SECONDS=3600 -# WHATSAPP BOT -WHATSAPP_BOT_NUMBER=2348147846093 -WHATSAPP_WELCOME_MESSAGE="Hi, I'd like to shop on Swifta" + +# TRANSACTIONAL OUTBOX +OUTBOX_RELAY_ENABLED=true +OUTBOX_POLL_INTERVAL_MS=1000 +OUTBOX_BATCH_SIZE=50 +OUTBOX_PROCESSING_CONCURRENCY=5 +OUTBOX_LOCK_TTL_SECONDS=60 +OUTBOX_MAX_ATTEMPTS=10 +OUTBOX_BASE_RETRY_DELAY_MS=2000 diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md new file mode 100644 index 00000000..c5fa1884 --- /dev/null +++ b/apps/backend/AGENTS.md @@ -0,0 +1,2201 @@ +# twizrr — Backend AI Assistant Instructions +# apps/backend/AGENTS.md + +> Read the root AGENTS.md first. This file adds backend-specific detail. +> If root AGENTS.md and this file conflict — root AGENTS.md wins. +> Full system documentation: see TWIZRR_DATA_ARCHITECTURE.md and TWIZRR_MVP_SCOPE.md + +--- + +## 0. Backend Overview + +``` +FRAMEWORK: NestJS 10 + TypeScript strict mode +RUNTIME: Node.js 20+ +DATABASE: Neon PostgreSQL 16 (pgvector extension enabled) +ORM: Prisma 5+ +CACHE: Upstash Redis (BullMQ + caching + rate limiting) +PORT: 4000 (local) / from PORT env var (production) +BASE URL: http://localhost:4000 (local) / api.twizrr.com (production) + +WHAT THE BACKEND DOES: +├── Serves the Next.js web app (REST API) +├── Processes WhatsApp webhooks (Meta Cloud API) +├── Handles Paystack webhooks (payment events) +├── Handles Shipbubble webhooks (delivery events) +├── Runs BullMQ background jobs (16 queues) +├── Generates product embeddings (Vertex AI → pgvector) +└── Powers the WhatsApp AI shopping assistant (Gemini 2.5 Flash) + +WHAT THE BACKEND DOES NOT DO: +├── Server-side render pages (that's Next.js) +├── Handle store management via WhatsApp (shopping only) +└── Store monetary values as Float (always BigInt kobo) + +ARCHITECTURE — FOUR LAYERS: +├── core/ Platform infrastructure (Prisma, Redis, Logger, Config, Health) +├── channels/ External entry points (WhatsApp, USSD) — not business logic +├── integrations/ Third-party provider clients (Paystack, AI, Cloudinary, etc.) +├── domains/ Business logic grouped by domain: +│ commerce/ orders/ money/ users/ social/ platform/ +├── queue/ BullMQ queue definitions + processors +└── common/ Shared guards, decorators, filters, interceptors + +IMPORT PATH CONVENTION: +├── Old path style: src/modules/payment/payment.service.ts ← WRONG — do not use +├── New path style: src/domains/money/payment/payment.service.ts ← CORRECT +├── Channel path: src/channels/whatsapp/whatsapp-intent.service.ts +└── Integration: src/integrations/paystack/paystack.client.ts + +DO NOT CREATE src/modules/ — this is the old flat structure, it does not exist +``` + +--- + +## 1. Global Middleware Stack (main.ts) + +Apply in this exact order — do not change without explicit instruction: + +```typescript +async function bootstrap() { + const app = await NestFactory.create(AppModule, { rawBody: true }) + + // 1. BigInt JSON serialization (must be first) + BigInt.prototype.toJSON = function() { return this.toString() } + + // 2. Structured logging (nestjs-pino) + const logger = app.get(Logger) + app.useLogger(logger) + + // 3. Security headers + app.use(helmet()) + // Relaxed CSP in development only: + // app.use(helmet({ contentSecurityPolicy: false })) + + // 4. Cookie parser + app.use(cookieParser()) + + // 5. CORS + app.enableCors({ + origin: process.env.CORS_ORIGINS?.split(',') ?? [], + credentials: true, + }) + + // 6. Global pipes (validation + transformation) + app.useGlobalPipes(new AppValidationPipe()) + // whitelist: true (strip unknown properties) + // transform: true (auto-transform payloads to DTO instances) + // forbidNonWhitelisted: true + + // 7. Global exception filter + app.useGlobalFilters(new GlobalExceptionFilter()) + // Maps Prisma errors: P2002→409, P2025→404, P2003→400 + + // 8. Response transform interceptor + app.useGlobalInterceptors(new ResponseTransformInterceptor()) + // Wraps all responses: { success: true, data: T, message?: string } + // Bypass with @Res() + res.send() for: USSD, WhatsApp webhook verify + + // 9. Global throttle guard (rate limiting) + const reflector = app.get(Reflector) + app.useGlobalGuards(new ThrottlerGuard(throttlerOptions, storage, reflector)) + // 100 req/min global — per-endpoint limits set with @Throttle() + + await app.listen(process.env.PORT ?? 4000) +} +``` + +--- + +## 2. Domain and Channel Architecture + +> Every feature lives in its correct layer. +> Domain modules: src/domains/{domain}/{feature}/ +> Channel modules: src/channels/{channel}/ +> Integration clients: src/integrations/{provider}/ +> Cross-domain communication: inject services, or use EventEmitter2. +> Never create circular dependencies — use forwardRef() only if unavoidable. + +### App Module Composition (app.module.ts) + +```typescript +// app.module.ts — COMPOSITION ONLY — no business logic here +@Module({ + imports: [ + CoreModule, // infrastructure: Prisma, Redis, Config, Logger, Throttler + QueueModule, // BullMQ: all 16 queues registered here + CommerceDomainModule, // category, product, inventory, sourced-product, post, feed, search, wishlist + OrdersDomainModule, // order, cart, delivery, dispute + MoneyDomainModule, // ledger, payment, payout, escrow, refund + UsersTrustDomainModule, // auth, user, store, buyer/merchant bridges, verification + SocialDomainModule, // social notifications, legacy notification bridge, email + PlatformDomainModule, // admin, domain events, upload/media, embedding, waitlist + ChannelsDomainModule, // WhatsApp and USSD channel entrypoints + ], +}) +export class AppModule {} +``` + +### Integration Clients (src/integrations/) + +``` +RULE: Business services NEVER call third-party APIs directly. + They inject the integration client and call its methods. + This means: provider changes affect exactly one file. + +src/integrations/ +├── paystack/paystack.client.ts +│ Methods: initializePayment(), verifyPayment(), createTransferRecipient(), +│ initiateTransfer(), createDVA(), resolveAccount(), createRefund() +│ Injected by: domains/money/payment/, domains/money/payout/ +│ +├── cloudinary/cloudinary.client.ts +│ Methods: uploadImage(), deleteImage(), generateSignedUrl() +│ Injected by: domains/platform/upload/ +│ +├── resend/resend.client.ts +│ Methods: sendEmail(template, to, variables) +│ Injected by: domains/social/notification/ +│ +├── shipbubble/shipbubble.client.ts +│ Methods: getDeliveryRates(), bookDelivery(), trackShipment() +│ Injected by: domains/orders/delivery/ +│ +├── africastalking/africastalking.client.ts +│ Methods: sendSMS(), initiateUSSD() +│ Injected by: domains/social/notification/, channels/ussd/ +│ +├── prembly/prembly.client.ts +│ Methods: verifyNIN(), verifyCACNumber(), verifyFace() +│ Injected by: domains/users/verification/ +│ +├── google-places/google-places.client.ts +│ Methods: autocomplete(), getPlaceDetails() +│ Injected by: domains/users/store/ +│ +└── ai/ + ├── gemini.client.ts — Gemini 2.5 Flash (conversation + function-calling) + │ Injected by: channels/whatsapp/whatsapp-intent.service.ts + │ + ├── vision.client.ts — Google Cloud Vision API (SafeSearch + Labels) + │ Injected by: domains/platform/upload/ (moderation) + │ channels/whatsapp/image-search.service.ts (image labels) + │ + └── vertex.client.ts — Vertex AI Multimodal Embeddings (1408-dim) + Injected by: domains/platform/embedding/ + channels/whatsapp/whatsapp-product-discovery.service.ts + channels/whatsapp/image-search.service.ts +``` + +### Money Domain — Ledger as Source of Truth + +``` +CRITICAL RULE: +No service creates LedgerEntry directly. +All financial records flow through LedgerService. + +src/domains/money/ +├── ledger/ +│ └── ledger.service.ts +│ Methods: +│ recordPaymentIn(orderId, amountKobo, reference) +│ recordEscrowRelease(orderId, amountKobo) +│ recordPayout(orderId, storeId, amountKobo, reference) +│ recordPayoutFailed(orderId, storeId, amountKobo, reason) +│ recordRefund(orderId, amountKobo, reference) +│ +├── payment/ +│ └── payment.service.ts +│ Injects: PaystackClient, LedgerService +│ On charge.success webhook: calls ledger.recordPaymentIn() +│ +├── payout/ +│ └── payout.service.ts +│ Injects: PaystackClient, LedgerService +│ On payout success: calls ledger.recordPayout() +│ On payout fail: calls ledger.recordPayoutFailed() +│ +├── escrow/ +│ └── escrow.service.ts +│ Injects: LedgerService +│ On delivery confirmed: calls ledger.recordEscrowRelease() +│ Then triggers payout via BullMQ (never directly) +│ +└── refund/ + └── refund.service.ts + Injects: PaystackClient, LedgerService + On refund: calls PaystackClient.createRefund() then ledger.recordRefund() +``` + +### Auth Module (src/domains/users/auth/) + +``` +PURPOSE: User registration, login, token management, OTP verification + +ENDPOINTS: +POST /auth/register/email — register with email + password +POST /auth/register/google — Google OAuth callback +POST /auth/register/apple — Apple Sign In callback +POST /auth/login — email + password login +POST /auth/logout — invalidate refresh token +POST /auth/refresh — exchange refresh token for new access token +POST /auth/otp/send — send OTP to phone number +POST /auth/otp/verify — verify phone OTP (marks phoneVerified = true) +POST /auth/otp/resend — resend OTP (rate limited: 3/hour) +POST /auth/email/verify — verify email via token in email link +POST /auth/forgot-password — send password reset email +POST /auth/reset-password — reset password with token + +TOKENS: +├── Access token: JWT, 15 min TTL, stored in HttpOnly cookie +├── Refresh token: JWT, 7 day TTL, stored in HttpOnly cookie + DB +└── Email verify: UUID token, 24 hour TTL, stored in DB + +RATE LIMITS: +├── POST /auth/login: 10 requests / 15 minutes / IP +├── POST /auth/register: 5 requests / hour / IP +├── POST /auth/forgot-password: 3 requests / hour / email +└── POST /auth/otp/send: 3 requests / hour / phone + +AGE GATE (enforced at registration): +├── Under 13: reject registration entirely +├── 13-17: register normally, isMinor = true (computed from dateOfBirth) +└── 18+: full access + +isMinor COMPUTATION: +└── NOT a stored field — always computed at request time + user.isMinor = differenceInYears(new Date(), user.dateOfBirth) < 18 + Used in: content moderation (SENSITIVE hidden from minors) + store creation (blocked if isMinor) +``` + +### User Module (src/domains/users/user/) + +``` +PURPOSE: User profile management, measurements, addresses + +ENDPOINTS: +GET /users/me — get current user profile +PUT /users/me — update name, username, bio, profilePhotoUrl +GET /users/:username — get any user's public profile +GET /users/me/followers — current user's followers +GET /users/me/following — who current user follows +POST /users/:userId/follow — follow a user +DELETE /users/:userId/follow — unfollow a user +PUT /users/me/measurements — update bodyMeasurements JSON +PUT /users/me/addresses — update deliveryAddresses JSON array + +MODELS: +User { + id String @id @default(cuid()) + email String @unique + phone String? @unique // E.164 format (+2348012345678) + username String @unique // 7-day change lock + displayName String + bio String? // max 180 chars + profilePhotoUrl String? + dateOfBirth DateTime + emailVerified Boolean @default(false) + phoneVerified Boolean @default(false) + role UserRole @default(USER) + bodyMeasurements Json? // { bustCm, waistCm, hipsCm, heightCm, shoeSizeEU, footLengthCm } + deliveryAddresses Json? // [{ id, label, street, line2, city, state, postalCode, isDefault }] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +USERNAME CHANGE LOCK: +└── Track lastUsernameChangedAt on User + If NOW() - lastUsernameChangedAt < 7 days: reject with 400 + Old username reserved for 7 days (no other user can claim it) +``` + +### Store Module (src/domains/users/store/) + +``` +PURPOSE: StoreProfile CRUD, store settings, Open/Closed status + +ENDPOINTS: +POST /stores — create store (18+ only, one per user) +GET /stores/me — get own store +GET /stores/:handle — get any store by @handle (public) +PUT /stores/me — update store profile +PUT /stores/me/status — toggle isOpen (Open/Closed) +PUT /stores/me/bank — update bank account (Paystack Resolve) +GET /stores/me/about — About sheet data (ⓘ icon) +GET /stores/me/stats — dashboard stats + +STORE TYPE VISIBILITY RULE — CRITICAL: +└── storeType (DIGITAL | PHYSICAL) is INTERNAL ONLY + Create a StorePublicDto that OMITS storeType + Use StorePublicDto for ALL public and shopper-facing endpoints + NEVER return storeType in: GET /stores/:handle, product endpoints, + search results, feed data, delivery data, or any shopper response + +MERCHANT NAMING TRANSITION: +├── Legacy internal names such as MerchantModule, MerchantService, +│ CurrentMerchant, and /merchants/* may remain temporarily for API +│ compatibility while the store domain migration is completed. +├── New backend context code should prefer store-named aliases such as +│ CurrentStore, StoreContextMiddleware, and StoreVerifiedGuard. +├── User-facing API messages and copy must say store or store owner, +│ never merchant. +└── Renaming /merchants/* routes or MerchantService/StoreService + consolidation requires a separate compatibility plan and PR. +STORE CAPABILITY RULE: +├── PHYSICAL stores manage native products, sourcing opt-in, pickup details, +│ and store-owner ready-for-pickup/dispatch compatibility actions +├── DIGITAL stores manage sourced listings and source-product pricing +└── Backend mutations enforce store type even when the shared /store web shell + hides an unavailable route or control + +OPEN/CLOSED STATUS: +├── isOpen = false: "Currently closed" banner on store page +├── Orders: still accepted when closed (products still buyable) +├── Chat: auto-reply "We're currently closed, back soon" +└── Different from suspension (suspension is a platform action) + +MODELS: +StoreProfile { + id String @id @default(cuid()) + userId String @unique + storeHandle String @unique + storeName String + storeType StoreType // DIGITAL | PHYSICAL — NEVER returned publicly + bio String? // max 180 chars + logoUrl String? + bannerUrl String? + businessCategory String + businessAddress Json? // physical stores: full address (public) + homeAddress Json? // digital stores: internal only (city+state public) + bankName String? + accountNumber String? + accountName String? + paystackRecipientCode String? + tier StoreTier @default(TIER_0) + isOpen Boolean @default(true) + showOwnerPublicly Boolean @default(true) + allowDropship Boolean @default(false) + ninVerified Boolean @default(false) + cacVerified Boolean @default(false) + addressVerified Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` + +### Product Module (src/domains/commerce/product/) + +``` +PURPOSE: Product CRUD, variant management, stock display + +ENDPOINTS: +POST /products — create product + variants (store owner) +GET /products/:code — get product by productCode (public) +PUT /products/:id — update product (store owner) +DELETE /products/:id — soft delete (status = DELETED) +GET /products — list store's products (store owner) +GET /stores/:handle/products — list store's ACTIVE products (public) + +PRODUCT CREATION FLOW: +1. Validate all fields +2. Run Cloud Vision moderation on each image (via upload module) +3. Create Product record +4. Create ProductVariant records (if hasVariants = true) +5. Create ProductImage records with assignedVariantIds +6. Create InventoryEvent (INITIAL_STOCK) per variant +7. Populate ProductStockCache per variant +8. Create Post (PRODUCT_POST) with taggedProductId = product.id +9. Create ProductSizeGuideConfig (if productSubCategory set) +10. Queue BullMQ job: embedding-generation (via queue.constants.ts) +11. Return product with all relations + +MODELS: +Product { + id String @id @default(cuid()) + productCode String @unique // TWZ-XXXXXX (6 random digits) + storeId String + title String // max 100 chars + description String // max 1000 chars + platformCategory Category + productSubCategory SubCategory? // drives size guide + storeTags String[] // max 3 + productDetails Json? // [{ attribute, value }] max 20 rows + retailPriceKobo BigInt // required + compareAtPriceKobo BigInt? + wholesaleTiers Json? // [{ minQty, priceKobo }] + notSoldIndividually Boolean @default(false) + minimumOrderQty Int? + allowDropship Boolean @default(false) + dropshipperPriceKobo BigInt? + hasVariants Boolean @default(false) + status ProductStatus @default(DRAFT) + isFeatured Boolean @default(false) + sku String? + hideWhenOutOfStock Boolean @default(false) + embeddingReady Boolean @default(false) + embeddingUpdatedAt DateTime? + moderationStatus ModerationStatus @default(PENDING) + viewCountWeekly Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +ProductVariant { + id String @id @default(cuid()) + productId String + colorName String? + sizeName String? + variantLabel String // "Red - M" (auto-generated) + priceOverride BigInt? // null = use product.retailPriceKobo + isActive Boolean @default(true) + createdAt DateTime @default(now()) +} + +ProductImage { + id String @id @default(cuid()) + productId String + url String + order Int // 0 = default thumbnail + isDefault Boolean @default(false) + assignedVariantIds String[] + altText String? + moderationStatus ModerationStatus @default(SAFE) + createdAt DateTime @default(now()) +} +``` + +### Inventory Module (src/domains/commerce/inventory/) + +``` +PURPOSE: Append-only stock events, fast stock reads via cache + +APPEND-ONLY RULE: Never UPDATE or DELETE InventoryEvent — ever + +EVENT TYPES: +INITIAL_STOCK — product first listed +RESTOCK — store manually adds stock +SALE_DEDUCT — order placed (stock decremented immediately) +SALE_RESTORE — order cancelled (stock restored) +RETURN — dispute resolved in buyer's favour +ADJUSTMENT — OPERATOR manual correction (AuditLog required) + +STOCK UPDATE FLOW: +1. Write InventoryEvent (append-only) +2. Update ProductStockCache (fast read snapshot) +3. If all variants = 0 + hideWhenOutOfStock = true: hide product from feed +4. Queue restock-alert job if stock drops below threshold + +MODELS: +InventoryEvent { + id String @id @default(cuid()) + productId String + variantId String? + type EventType + quantity Int // positive = add, negative = deduct + reference String? // orderId for SALE_DEDUCT/SALE_RESTORE + note String? // required for ADJUSTMENT events + createdAt DateTime @default(now()) + // NO updatedAt — append-only +} + +ProductStockCache { + id String @id @default(cuid()) + productId String + variantId String? + stock Int @default(0) + updatedAt DateTime @updatedAt + @@unique([productId, variantId]) +} +``` + +### Sourced Product Module (src/domains/commerce/sourced-product/) + +``` +PURPOSE: Digital store dropship sourcing flow + +ENDPOINTS: +GET /sourced/catalogue — browse physical store products (digital stores only) +POST /sourced — source a product + set selling price +GET /sourced/me — list own sourced products +PUT /sourced/:id — update price or customisation +DELETE /sourced/:id — remove from store + +PRICE FLOOR ENFORCEMENT (3 mandatory layers): +Layer 1 — DB: CHECK constraint (sellingPriceKobo >= floor) +Layer 2 — API: validate in service before insert/update + throw BadRequestException({ code: 'PRICE_BELOW_FLOOR', floor }) +Layer 3 — Frontend: input minimum + button disabled (see web AGENTS.md) + +FLOOR CHANGE: +When product.dropshipperPriceKobo is updated: +→ Queue BullMQ floor-check job +→ Processor finds all SourcedProduct where sellingPrice < new floor +→ Sets isActive = false, priceUpdateRequired = true +→ Notification to dropshipper + +SHOPPER-FACING DTO — always omit: +physicalStoreId, physicalProductId, dropshipStoreId + +MODELS: +SourcedProduct { + id String @id @default(cuid()) + dropshipStoreId String // → StoreProfile (digital) — NEVER public + physicalStoreId String // → StoreProfile (physical) — NEVER public + physicalProductId String // → Product — NEVER public + customName String? + customDescription String? + customImages String[] + sellingPriceKobo BigInt // MUST be >= floor + suggestedPriceKobo BigInt + dropshipperWholesaleTiers Json? + isActive Boolean @default(true) + priceUpdateRequired Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@unique([dropshipStoreId, physicalProductId]) +} +``` + +### Post Module (src/domains/commerce/post/) + +``` +PURPOSE: Post CRUD — PRODUCT_POST, IMAGE_POST, GIST, TWIZZ + +PRODUCT_POST COMMENT RULE — ENFORCED AT SERVICE LEVEL: +└── In CommentService.create(): check post.type before any DB operation + If post.type === PostType.PRODUCT_POST: + throw MethodNotAllowedException({ code: 'METHOD_NOT_ALLOWED', message: 'Comments not available on product posts' }) + This must be enforced in the service — not just in middleware or guards + +CHARACTER LIMITS — enforce in DTOs with class-validator: +├── Caption: @MaxLength(300) +├── GistText: @MaxLength(240) +├── Comment: @MaxLength(300) +└── Hashtags: @ArrayMaxSize(10) + +ENGAGEMENT COUNTERS — DISPLAY SOURCE OF TRUTH: +├── Post.likeCount / commentCount / saveCount are denormalized counters +├── Maintained atomically by PostService write paths: +│ like/unlike, save/unsave, comment add/delete — increment/decrement +│ inside the same transaction as the engagement row write +├── Idempotent likes/saves: create + increment in one transaction; a repeat +│ hits the unique constraint (P2002) and rolls back — never upsert+increment +├── Drift correction: EngagementRecountService (@Cron hourly, set-based +│ recount that only writes rows that actually drifted) +├── Hot read paths (feed, search, post responses) read the columns — +│ NEVER _count / COUNT(*) the relations on a feed-serving query +└── Feed hot-path index: posts(is_active, created_at) — every feed tab + filters is_active and orders by created_at DESC + +MODELS: +Post { + id String @id @default(cuid()) + storeId String? // null for user Gists + userId String + type PostType // PRODUCT_POST | IMAGE_POST | GIST | TWIZZ + caption String? // max 300 chars + gistText String? // max 240 chars (GIST only) + hashtags String[] // stored without # symbol + taggedProductId String? // one product per post + isPinned Boolean @default(false) + pinnedAt DateTime? + audience Audience @default(EVERYONE) + allowComments Boolean @default(true) + likeCount Int @default(0) + commentCount Int @default(0) // always 0 for PRODUCT_POST + shareCount Int @default(0) + saveCount Int @default(0) + moderationStatus ModerationStatus @default(PENDING) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` + +### Feed Module (src/domains/commerce/feed/) + +``` +PURPOSE: For You / Following / Stores / Explore feed tabs + +FOR-YOU CANDIDATE SELECTION — MULTI-SOURCE (as built): +├── Candidates are merged from bounded windows per source, all sharing the +│ same visibility + moderation + cursor filters: +│ a) recency — newest posts; also drives pagination (hasMore/nextCursor) +│ b) followed — recent posts from followed stores/users +│ c) trending — top engagement of the last 7 days (orders by the +│ denormalized likeCount — cheap, index-backed) +│ d) interest — posts matching the viewer's interest tags (category, +│ categoryTag, hashtags; DB-side coarse filter) +├── Merged pool is deduped by id, then scored in memory: +│ recency (inverse-log) + log-dampened engagement + tier boost +│ + follow boost + interest boost, then diversified by type +└── Never reduce candidates to a single recency window — follow and + interest boosts need real candidates to act on + +CACHING: Redis read-through per tab (explore/stores 300s, for-you 60s +per-user). Cursor pagination is createdAt+id — never offset. +``` + +### Order Module (src/domains/orders/order/) + +``` +PURPOSE: Full order lifecycle, state machine, delivery confirmation + +ORDER CODE: TWZ-XXXXXX (6 random digits via crypto.randomInt — check uniqueness) + +STATE MACHINE (strict — no skipping): +PENDING_PAYMENT → PAID → DISPATCHED → DELIVERED → COMPLETED +At any pre-dispatch stage: → CANCELLED +During 72hr window after COMPLETED: → DISPUTE + +ITEMS JSON (snapshot at order time — prevents broken references): +{ + productId: string, + variantId: string | null, + variantName: string | null, // "Red - Size M" + variantAttributes: object | null, // { color: "Red", size: "M" } + quantity: number, + unitPriceKobo: bigint +} + +6-DIGIT DELIVERY OTP: +├── Generated: at PAID → DISPATCHED transition +├── crypto.randomInt(100000, 999999) — always 6 digits +├── Stored: hashed in DB (bcrypt or similar — never plain text) +├── TTL: 48 hours (matches auto-confirm window) +└── Shown: to shopper only (order detail + WhatsApp notification) + +AUTO-CONFIRM (BullMQ): +├── Job: auto-confirm — DISPATCHED + 48 hours +├── Warning job: auto-confirm-warning — DISPATCHED + 36 hours +└── Only fires if order still in DISPATCHED status + +DROPSHIP ORDERS — TWO LINKED ORDERS: +DROPSHIP_CUSTOMER: shopper ↔ digital store (what shopper sees) +DROPSHIP_FULFILLMENT: digital store ↔ physical store (fulfillment) +linkedOrderId: connects the two +Twizrr-managed delivery picks up from the physical source store and delivers to the shopper dropoff address + +MODELS: +Order { + id String @id @default(cuid()) + orderCode String @unique // TWZ-XXXXXX + type OrderType // DIRECT | DROPSHIP_CUSTOMER | DROPSHIP_FULFILLMENT + storeId String + shopperId String + linkedOrderId String? // → Order (dropship link) + status OrderStatus + items Json // snapshot array (see above) + subtotalKobo BigInt + deliveryFeeKobo BigInt + totalKobo BigInt + platformFeeKobo BigInt + deliveryAddress Json + deliveryNote String? + deliveryOtp String? // hashed 6-digit code + otpExpiresAt DateTime? + autoConfirmAt DateTime? + paymentMethod PaymentMethod + paymentReference String? + disputeStatus DisputeStatus @default(NONE) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` + +### Payment Module (src/domains/money/payment/) + +``` +PURPOSE: Paystack checkout init, webhook processing, DVA + +WEBHOOK PROCESSING — CRITICAL ORDER: +1. Verify X-Paystack-Signature HMAC — if invalid: return 200, log, stop + (Return 200 even on invalid — Paystack retries non-200 responses) +2. Parse event type +3. Check idempotency: payment reference already in DB? Skip + return 200 +4. Process event: + charge.success → update Order: PAID, call LedgerService.recordPaymentIn() + transfer.success → update Payout: COMPLETED, call LedgerService.recordPayout() + transfer.failed → update Payout: FAILED, queue payout-retry job +5. Queue BullMQ notifications (never send synchronously) +6. Return 200 + +INJECTS: PaystackClient (from integrations/paystack/), LedgerService +``` + +### Payout Module (src/domains/money/payout/) + +``` +PURPOSE: BullMQ-based payout processing, retry logic + +PAYOUT TRIGGER: BullMQ payout job (queued by escrow module on COMPLETED order) +NEVER TRIGGERED SYNCHRONOUSLY — always via queue + +DIRECT PAYOUT AMOUNTS: +platformFeeKobo = totalKobo × storeFeePercent (0.02, 0.015, or 0.01) +payoutAmountKobo = totalKobo - platformFeeKobo + +DROPSHIP PAYOUT SPLIT: +Physical store: dropshipperPriceKobo (full wholesale — no fee deducted) +Digital store: totalKobo - dropshipperPriceKobo - (totalKobo × 0.02) +Platform fee: 2% of totalKobo — charged ONCE — from digital store only + +RETRY (BullMQ payout-retry queue): +Attempt 1: immediate +Attempt 2: 5 minutes +Attempt 3: 30 minutes +Attempt 4: 2 hours +After 4 failures: status = FAILED, notify store owner + +INJECTS: PaystackClient (from integrations/paystack/), LedgerService + +MODELS: +Payout { + id String @id @default(cuid()) + orderId String + storeId String + amountKobo BigInt + paystackTransferCode String? + paystackReference String? @unique + status PayoutStatus @default(PROCESSING) + idempotencyKey String @unique + retryCount Int @default(0) + failureReason String? + createdAt DateTime @default(now()) + completedAt DateTime? +} + +LedgerEntry { + id String @id @default(cuid()) + type LedgerType // PAYMENT_IN | ESCROW_RELEASE | PAYOUT | PAYOUT_FAILED | REFUND + orderId String + amount BigInt // positive = inflow, negative = outflow + storeId String? + reference String? + note String? + createdAt DateTime @default(now()) + // NO updatedAt — append-only — NEVER UPDATE OR DELETE +} +``` + +### Upload Module (src/domains/platform/upload/) + +``` +PURPOSE: ALL image uploads — ALWAYS moderate via Cloud Vision before Cloudinary + +ENDPOINT: +POST /upload/image — upload single image (protected) + +FLOW — DO NOT SKIP STEP 1: +1. Receive image buffer +2. Inject VisionClient (from integrations/ai/vision.client.ts) +3. Call Cloud Vision SafeSearch API +4. Evaluate result: + VERY_LIKELY or LIKELY for adult/violence/racy → BLOCKED + POSSIBLE for any category → SENSITIVE + Otherwise → SAFE +5. Log to ModerationLog regardless of result +6. If BLOCKED: return 400 { code: 'IMAGE_BLOCKED' } — do NOT proceed +7. Inject CloudinaryClient (from integrations/cloudinary/) +8. Upload image to Cloudinary +9. Return { url, moderationStatus, cloudinaryPublicId } + +INJECTS: VisionClient, CloudinaryClient +``` + +### Embedding Module (src/domains/platform/embedding/) + +``` +PURPOSE: Vertex AI embedding generation — products searchable in WhatsApp AI + +TRIGGERED: Only via BullMQ embedding-generation job (never synchronously) + +FLOW: +1. Receive job: { productId } +2. Fetch product: title, description, category, first product image URL +3. Inject VertexClient (from integrations/ai/vertex.client.ts) +4. Call Vertex AI Multimodal Embeddings API: + Input: image bytes + text string (title + description + category) + Output: Float32Array of 1408 dimensions +5. Store in ProductEmbedding (pgvector): + Prisma raw: INSERT INTO "ProductEmbedding" (embedding) VALUES ($1::vector) +6. Set product.embeddingReady = true, product.embeddingUpdatedAt = NOW() + +INJECTS: VertexClient, PrismaService + +VECTOR SEARCH CONSUMERS — two different ranking contracts: + +1. WEB SEARCH (domains/commerce/search/) — raw pgvector query example: +SELECT p.*, + 1 - (pe.embedding <=> $queryVector::vector) AS similarity +FROM "Product" p +JOIN "ProductEmbedding" pe ON pe."productId" = p.id +JOIN "StoreProfile" s ON s.id = p."storeId" +WHERE p.status = 'ACTIVE' + AND p."embeddingReady" = true + AND s.tier != 'TIER_0' ← Tier 0 ALWAYS excluded + AND [metadata filters] +ORDER BY (similarity * 0.6 + textRank * 0.3 + trustScore * 0.1) DESC +LIMIT 10 + +2. WIZZA TEXT DISCOVERY (channels/whatsapp/) — approved hybrid contract: +final_score = vector_similarity * 0.5 + text_rank * 0.2 + + store_performance * 0.2 + tier_boost * 0.1 +Weights via SEARCH_WEIGHT_* env vars (numeric, >= 0, must sum to 1.0 — +validated at startup in src/config/search.config.ts). New stores with +insufficient history default to store_performance = 0.5. +WIZZA image search ranks by image-embedding similarity (primary signal). +``` + +### Verification Module (src/domains/users/verification/) + +``` +PURPOSE: KYB flows — Tier 1 auto, Tier 2 NIN (Prembly), Tier 3 CAC + +TIER 1 (FULLY AUTOMATIC — no endpoint needed): +└── Event listener: on emailVerified AND phoneVerified AND bankAdded + All three true → store.tier = TIER_1 automatically, no user action needed + +TIER 2 ENDPOINTS: +POST /verification/nin/start — submit NIN (11-digit string) +POST /verification/nin/selfie — submit selfie photo for face match +GET /verification/status — current verification status and requirements + +NIN FLOW: +1. Store submits NIN +2. Inject PremblyClient (from integrations/prembly/) +3. PremblyClient.verifyNIN(nin) → check against NIMC database +4. If valid: prompt for selfie +5. PremblyClient.verifyFace(selfieBuffer, nin) → face comparison +6. If match: store.ninVerified = true +7. Check ALL Tier 2 requirements: + ninVerified AND completedOrders >= 3 AND disputes = 0 AND profilePhoto != null +8. All met → store.tier = TIER_2, send congratulations notification +9. Max 3 NIN attempts before lockout → contact support + +TIER 3 (MVP — basic only): +POST /verification/cac/start — submit CAC RC number +INJECTS: PremblyClient +Physical stores: address verification → flagged for OPERATOR manual review +Digital stores: utility bill upload → OPERATOR review +Full automation is Phase 3 +``` + +### Size Guide Module (src/domains/commerce/size-guide/) + +``` +PURPOSE: Platform-managed size guides — seeded at deployment + +ENDPOINTS: +GET /size-guides/:type — get size guide by type (public) +GET /products/:id/size-guide — size guide config for product (public) +PUT /products/:id/size-guide — store's custom measurements (store owner) + +SEEDING RULE: +SizeGuide records are created ONLY by prisma/seed.ts at first deployment +NEVER via API endpoints — there is no POST /size-guides endpoint +Store owners only interact with ProductSizeGuideConfig (per-product overrides) + +MODELS: +SizeGuide { + id String @id @default(cuid()) + type GuideType @unique // one record per type + sizes Json // array of size objects (clothing or footwear format) + version String // "v1" + isActive Boolean @default(true) +} + +ProductSizeGuideConfig { + id String @id @default(cuid()) + productId String @unique + primaryGuideType GuideType + includesFootwear Boolean @default(false) + footwearGuideType GuideType? + useCustomSizes Boolean @default(false) + customSizes Json? + modelHeight Int? + modelBust Int? + modelWaist Int? + modelHips Int? + modelWearingSize String? +} +``` + +### Notification Module (src/domains/social/notification/) + +``` +PURPOSE: In-app notifications + email dispatch + +EMAIL TEMPLATES (sent via ResendClient from integrations/resend/): +ORDER_CONFIRMED, ORDER_DISPATCHED, ORDER_COMPLETED, +PAYOUT_SENT, PAYOUT_FAILED, DISPUTE_FILED, DISPUTE_RESOLVED, +OTP_EMAIL_VERIFY, OTP_PASSWORD_RESET, STORE_TIER_UPGRADED + +IN-APP TYPES: +ORDER_PAID, ORDER_DISPATCHED, ORDER_COMPLETED, +POST_LIKED, POST_COMMENTED, COMMENT_REPLIED, +NEW_FOLLOWER, TAGGED_PRODUCT_SOLD_VIA_POST, +PAYOUT_SENT, PAYOUT_FAILED, STORE_TIER_UPGRADED, +STORE_NOTIFICATION_NEW_POST, MODERATION_BLOCKED + +INJECTS: ResendClient (from integrations/resend/) +All email dispatch: async via BullMQ notification queue — never synchronous + +REALTIME: every in-app notification write (NotificationService.createInApp +and the legacy processor path) emits `notification:new` to the recipient's +sockets via RealtimeService — best-effort delivery hint, never affects the +stored notification. +``` + +### Realtime Module (src/domains/social/realtime/) — AS BUILT + +``` +PURPOSE: server→client push channel (socket.io) — the realtime spine + +WHAT EXISTS: +├── RealtimeGateway — socket.io attached to the same HTTP server (no extra +│ port). Auth on handshake: verifies the HttpOnly twizrr_access_token +│ cookie with the same JWT secret as JwtAuthGuard; unauthenticated +│ sockets are disconnected immediately. Authenticated sockets join a +│ per-user room `user:{userId}`. +├── RealtimeService — emitter facade (`emitToUser(userId, event, payload)`). +│ Domain services inject THIS, never the gateway. Emits are best-effort +│ no-ops before gateway init. +└── Events today: `notification:new` (payload: id, type, title, body, url, + createdAt). The client treats events as delivery hints and refetches + over REST — the socket is never a data source. + +RULES: +├── No inbound @SubscribeMessage handlers yet — chat will add them here +├── No broadcast-to-all surface — everything is user-addressed rooms +├── CORS: same CORS_ORIGINS env as HTTP; credentials: true so the cookie +│ flows on the handshake (shared .twizrr.com cookie domain) +└── Scale: single instance today. At >1 backend instance, add + @socket.io/redis-adapter (Upstash) in afterInit — config change, + not a redesign. + +NOT BUILT YET: chat (src/domains/social/chat/ is an empty placeholder), +presence, typing indicators, live like-counts (deliberately skipped). +``` + +### Admin Module (src/domains/platform/admin/) + +``` +PURPOSE: OPERATOR and SUPER_ADMIN tools + +ROLE REQUIREMENTS: +├── OPERATOR: moderation queue, disputes, payout monitoring, manual retry +└── SUPER_ADMIN: all OPERATOR access + user management, store suspension, fee config + +ALL ADMIN ACTIONS: logged to AuditLog — required, not optional + +AuditLog { + id String @id @default(cuid()) + action String // "STORE_SUSPENDED", "DISPUTE_RESOLVED" etc. + performedBy String // → User (OPERATOR or SUPER_ADMIN) + targetId String + targetType String // "Store" | "User" | "Order" | "Payout" + reason String // REQUIRED — why was this action taken + before Json? + after Json? + createdAt DateTime @default(now()) + // append-only — never UPDATE or DELETE +} +``` + +``` +PURPOSE: User registration, login, token management, OTP verification + +ENDPOINTS: +POST /auth/register/email — register with email + password +POST /auth/register/google — Google OAuth callback +POST /auth/register/apple — Apple Sign In callback +POST /auth/login — email + password login +POST /auth/logout — invalidate refresh token +POST /auth/refresh — exchange refresh token for new access token +POST /auth/otp/send — send OTP to phone number +POST /auth/otp/verify — verify phone OTP (marks phoneVerified = true) +POST /auth/otp/resend — resend OTP (rate limited: 3/hour) +POST /auth/email/verify — verify email via token in email link +POST /auth/forgot-password — send password reset email +POST /auth/reset-password — reset password with token + +TOKENS: +├── Access token: JWT, 15 min TTL, stored in HttpOnly cookie +├── Refresh token: JWT, 7 day TTL, stored in HttpOnly cookie + DB +└── Email verify: UUID token, 24 hour TTL, stored in DB + +RATE LIMITS: +├── POST /auth/login: 10 requests / 15 minutes / IP +├── POST /auth/register: 5 requests / hour / IP +├── POST /auth/forgot-password: 3 requests / hour / email +└── POST /auth/otp/send: 3 requests / hour / phone + +AGE GATE (enforced at registration): +├── Under 13: reject registration entirely +├── 13-17: register normally, isMinor = true (computed from dateOfBirth) +└── 18+: full access + +isMinor COMPUTATION: +└── NOT a stored field — always computed at query time + user.isMinor = differenceInYears(new Date(), user.dateOfBirth) < 18 + Used in: content moderation (SENSITIVE content hidden for minors) + store creation (blocked if isMinor = true) +``` + +### User Module (src/domains/users/user/) + +``` +PURPOSE: User profile management, personal preferences, measurements, addresses + +ENDPOINTS: +GET /users/me — get current user profile +PUT /users/me — update name, username, bio, profilePhotoUrl +GET /users/:username — get any user's public profile +GET /users/me/followers — current user's followers +GET /users/me/following — who current user follows +POST /users/:userId/follow — follow a user +DELETE /users/:userId/follow — unfollow a user +PUT /users/me/measurements — update bodyMeasurements JSON +PUT /users/me/addresses — update deliveryAddresses JSON array +GET /users/me/addresses — get all saved delivery addresses + +MODELS: +User { + id String @id @default(cuid()) + email String @unique + phone String? @unique // E.164 format (+2348012345678) + username String @unique // 7-day change lock + displayName String + bio String? // max 180 chars + profilePhotoUrl String? + dateOfBirth DateTime + emailVerified Boolean @default(false) + phoneVerified Boolean @default(false) + role UserRole @default(USER) + // bodyMeasurements: Json? { bustCm, waistCm, hipsCm, heightCm, shoeSizeEU, footLengthCm } + // deliveryAddresses: Json? [{ id, label, street, line2, city, state, postalCode, isDefault }] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +USERNAME CHANGE LOCK: +└── Track lastUsernameChangedAt on User + If NOW() - lastUsernameChangedAt < 7 days: reject with 400 + Old username reserved for 7 days (no other user can claim it) +``` + +### Store Module (src/domains/users/store/) + +``` +PURPOSE: StoreProfile CRUD, store settings, Open/Closed status + +ENDPOINTS: +POST /stores — create store (18+ only, one per user) +GET /stores/me — get own store +GET /stores/:handle — get any store by @handle (public) +PUT /stores/me — update store profile +PUT /stores/me/status — toggle isOpen (Open/Closed) +PUT /stores/me/bank — update bank account (Paystack Resolve) +GET /stores/me/about — About sheet data (ⓘ icon) +GET /stores/me/stats — dashboard stats (orders, revenue, inventory, tier) + +STORE CREATION RULES: +├── User must be 18+ (isMinor = false — computed from dateOfBirth) +├── One store per user — reject 409 if StoreProfile exists +├── storeType must be DIGITAL or PHYSICAL — required +├── Physical store: businessAddress required (Google Maps verified) +├── Digital store: homeAddress required (internal — never public) +└── After creation: Tier 0 auto (no verification needed yet) + +STORE TYPE VISIBILITY RULE: +└── storeType is INTERNAL ONLY + Create StorePublicDto that OMITS storeType + Use StorePublicDto for ALL public/shopper-facing endpoints + Never return storeType in: GET /stores/:handle, product pages, search results + +OPEN/CLOSED STATUS: +├── isOpen = false: store page shows "Currently closed" banner +├── Orders: still accepted when closed (shoppers can still buy) +├── Chat: auto-reply "We're currently closed, back soon" +└── Not the same as suspension (suspension = platform action) + +BANK DETAILS: +└── POST /stores/me/bank: + { bankCode, accountNumber } + Call Paystack Resolve API → get account name + Return accountName to client for confirmation + Store only after user confirms: { bankName, accountNumber, accountName, paystackRecipientCode } + Create Paystack Transfer Recipient → store paystackRecipientCode + +MODELS: +StoreProfile { + id String @id @default(cuid()) + userId String @unique + storeHandle String @unique // @chiastyles + storeName String + storeType StoreType // DIGITAL | PHYSICAL — NEVER public + bio String? // max 180 chars + logoUrl String? + bannerUrl String? + businessCategory String + businessAddress Json? // physical stores only — full address shown publicly + homeAddress Json? // digital stores only — city+state shown only + bankName String? + accountNumber String? + accountName String? + paystackRecipientCode String? + tier StoreTier @default(TIER_0) + isOpen Boolean @default(true) + showOwnerPublicly Boolean @default(true) + allowDropship Boolean @default(false) // global toggle — physical stores + ninVerified Boolean @default(false) + cacVerified Boolean @default(false) + addressVerified Boolean @default(false) + // dispatchTimeAvgHours, orderCompletionRate, disputeCountLast6Months + // — computed metrics updated by cron job + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` + +### Product Module (src/domains/commerce/product/) + +``` +PURPOSE: Product CRUD, variant management, stock display, product detail + +ENDPOINTS: +POST /products — create product + variants (store owner only) +GET /products/:code — get product by productCode (public) +PUT /products/:id — update product (store owner only) +DELETE /products/:id — soft delete product (status = DELETED) +GET /products — list store's products (store owner — all) +GET /stores/:handle/products — list store's ACTIVE products (public) +GET /products/:id/size-guide — get size guide for product (public) + +PRODUCT CREATION FLOW (mirrors 4-screen form): +1. Validate all fields +2. Run Cloud Vision moderation on each image — reject if BLOCKED +3. Upload safe/sensitive images to Cloudinary +4. Create Product record +5. Create ProductVariant records (if hasVariants = true) +6. Create ProductImage records with assignedVariantIds +7. Create InventoryEvent (INITIAL_STOCK) per variant +8. Populate ProductStockCache per variant +9. Create Post (PRODUCT_POST) with taggedProductId = product.id +10. Create ProductSizeGuideConfig (if productSubCategory set) +11. Queue BullMQ job: embedding-generation +12. Return product with all relations + +STOCK DISPLAY LOGIC: +├── ALL variants out of stock: show "Out of Stock" +├── SOME variants out of stock: show "In Stock" (greyed variants in UI) +└── ALL variants in stock: show "In Stock" + +OUT-OF-STOCK VARIANT RULE: +├── Still shown to shoppers (greyed chip — unselectable) +├── Product card: stays visible in feed +└── hideWhenOutOfStock = true: product card hidden when all variants = 0 + +MODELS: +Product { + id String @id @default(cuid()) + productCode String @unique // TWZ-XXXXXX (6 random digits) + storeId String // → StoreProfile + title String // max 100 chars + description String // max 1000 chars + platformCategory Category // required — 20 options + productSubCategory SubCategory? // drives size guide + storeTags String[] // max 3 tags + productDetails Json? // [{ attribute, value }] max 20 rows + retailPriceKobo BigInt // required + compareAtPriceKobo BigInt? // optional — for discount badge + wholesaleTiers Json? // [{ minQty, priceKobo }] + notSoldIndividually Boolean @default(false) + minimumOrderQty Int? + allowDropship Boolean @default(false) + dropshipperPriceKobo BigInt? + hasVariants Boolean @default(false) + status ProductStatus @default(DRAFT) + isFeatured Boolean @default(false) + isPinned Boolean @default(false) + sku String? + hideWhenOutOfStock Boolean @default(false) + embeddingReady Boolean @default(false) + embeddingUpdatedAt DateTime? + moderationStatus ModerationStatus @default(PENDING) + viewCountWeekly Int @default(0) + sourcePostId String? // → Post (attribution) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +ProductVariant { + id String @id @default(cuid()) + productId String // → Product + colorName String? // e.g. "Red" + sizeName String? // e.g. "M" + variantLabel String // e.g. "Red - M" (auto-generated) + priceOverride BigInt? // if null: use product.retailPriceKobo + isActive Boolean @default(true) + createdAt DateTime @default(now()) +} + +ProductImage { + id String @id @default(cuid()) + productId String // → Product + url String // Cloudinary URL + order Int // 0 = default thumbnail + isDefault Boolean @default(false) + assignedVariantIds String[] // which variants show this image + altText String? + moderationStatus ModerationStatus @default(SAFE) + createdAt DateTime @default(now()) +} +``` + +### Inventory Module (src/domains/commerce/inventory/) + +``` +PURPOSE: Append-only stock event log, fast stock reads via cache + +APPEND-ONLY RULE: Never UPDATE or DELETE InventoryEvent rows — ever + +EVENT TYPES: +INITIAL_STOCK — product first listed +RESTOCK — store manually adds stock +SALE_DEDUCT — order placed (stock decremented) +SALE_RESTORE — order cancelled (stock restored) +RETURN — disputed order resolved in buyer's favour +ADJUSTMENT — OPERATOR manual correction (with AuditLog entry) + +STOCK UPDATE FLOW: +1. Write InventoryEvent (append-only) +2. Update ProductStockCache (current stock snapshot) +3. If variant stock = 0: check hideWhenOutOfStock flag +4. If all variants = 0 AND store has StoreNotificationSubscriptions: + Queue restock-alert BullMQ job for when stock returns + +MODELS: +InventoryEvent { + id String @id @default(cuid()) + productId String // → Product + variantId String? // → ProductVariant (null = applies to whole product) + type EventType + quantity Int // positive = add, negative = deduct + reference String? // orderId for SALE_DEDUCT / SALE_RESTORE + note String? // for ADJUSTMENT events (required) + createdAt DateTime @default(now()) + // NO updatedAt — append-only +} + +ProductStockCache { + id String @id @default(cuid()) + productId String // → Product + variantId String? // → ProductVariant (null = product total) + stock Int @default(0) + updatedAt DateTime @updatedAt + @@unique([productId, variantId]) +} +``` + +### Sourced Product Module (src/domains/commerce/sourced-product/) + +``` +PURPOSE: Digital store dropship sourcing — browse catalogue, source, customise + +ENDPOINTS: +GET /sourced/catalogue — browse physical store products (digital stores only) +POST /sourced — source a product (set sellingPrice, customise) +GET /sourced/me — list own sourced products +PUT /sourced/:id — update price or customisation +DELETE /sourced/:id — remove sourced product from store + +CATALOGUE ENDPOINT (GET /sourced/catalogue): +└── Only accessible to: users with storeType = DIGITAL + Query: products WHERE allowDropship = true AND stock > 0 + Sorted by: trending (default), newest, price + Filtered by: platformCategory, inStockOnly (default true) + + Response per product includes: + ├── All public product fields + ├── Physical store @handle (visible to dropshipper — not public) + ├── Physical store verification tier + ├── dropshipperPriceKobo (cost — their floor) + ├── retailPriceKobo (what physical store charges shoppers) + ├── Suggested sell price (computed: cost × categoryMarkup) + └── Current stock (from ProductStockCache) + +PRICE FLOOR ENFORCEMENT (3 layers): +Layer 1 — DB: CHECK constraint + sellingPriceKobo >= COALESCE(dropshipperPriceKobo, retailPriceKobo) +Layer 2 — API: validate in service before insert/update + if sellingPriceKobo < floor: throw new BadRequestException({ code: 'PRICE_BELOW_FLOOR', floor }) +Layer 3 — Frontend: input minimum + button disabled (web AGENTS.md) + +FLOOR CHANGE HANDLER: +└── When product.dropshipperPriceKobo is updated: + BullMQ: floor-check job queued + Job finds all SourcedProduct where sellingPrice < new floor + Sets isActive = false, priceUpdateRequired = true + Sends notification to dropshipper + +MODELS: +SourcedProduct { + id String @id @default(cuid()) + dropshipStoreId String // → StoreProfile (digital store) + physicalStoreId String // → StoreProfile (physical store) — NEVER public + physicalProductId String // → Product — NEVER public + customName String? // null = use original + customDescription String? // null = use original + customImages String[] // empty = use original product images + sellingPriceKobo BigInt // MUST be >= floor — enforced at all 3 layers + suggestedPriceKobo BigInt // computed at source time + dropshipperWholesaleTiers Json? // their own volume tiers (separate from physical) + isActive Boolean @default(true) + priceUpdateRequired Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([dropshipStoreId, physicalProductId]) +} + +DTO FOR PUBLIC ENDPOINTS — always exclude: + physicalStoreId, physicalProductId, dropshipStoreId +``` + +### Post Module (src/domains/commerce/post/) + +``` +PURPOSE: Post CRUD for PRODUCT_POST, IMAGE_POST, GIST, TWIZZ + +POST TYPES: +PRODUCT_POST — commerce post (created by product listing flow, not directly) +IMAGE_POST — lifestyle/content photos (up to 10 images) +GIST — short text post (max 240 chars) +TWIZZ — video post (COMING SOON — do not build, return 501) + +ENDPOINTS: +POST /posts — create IMAGE_POST or GIST +GET /posts/:id — get single post (public) +PUT /posts/:id — update post (own store/user only) +DELETE /posts/:id — soft delete post +POST /posts/:id/like — like a post +DELETE /posts/:id/like — unlike +POST /posts/:id/comments — add comment (IMAGE_POST, GIST only) +GET /posts/:id/comments — get comments +DELETE /posts/comments/:commentId — delete own comment +POST /posts/:id/save — save post to Saved Posts +DELETE /posts/:id/save — unsave + +PRODUCT_POST COMMENT RULE: +└── POST /posts/:id/comments where post.type = PRODUCT_POST: + Return 405 Method Not Allowed + { success: false, message: "Comments are not available on product posts", code: "METHOD_NOT_ALLOWED" } + Enforced in CommentService before any DB operation + +GIST RULES: +└── gistText: max 240 chars (validate in DTO with @MaxLength(240)) + No images (Phase 2 — return 400 if images provided with GIST) + +CHARACTER LIMITS (validate in DTOs): +├── Caption: max 300 chars +├── Gist text: max 240 chars +├── Comment: max 300 chars +└── Hashtags: max 10 per post + +MODELS: +Post { + id String @id @default(cuid()) + storeId String? // → StoreProfile (null for user Gists) + userId String // → User (author) + type PostType // PRODUCT_POST | IMAGE_POST | GIST | TWIZZ + caption String? // max 300 chars + gistText String? // max 240 chars (GIST only) + hashtags String[] // stored without # symbol + taggedProductId String? // → Product (one product per post) + sourcePostId String? // → Post (for Retwizz / attribution) + isPinned Boolean @default(false) + pinnedAt DateTime? + location String? + taggedUserIds String[] + audience Audience @default(EVERYONE) + allowComments Boolean @default(true) + viewCount Int @default(0) + likeCount Int @default(0) + commentCount Int @default(0) // always 0 for PRODUCT_POST + shareCount Int @default(0) + saveCount Int @default(0) + moderationStatus ModerationStatus @default(PENDING) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` + +### Order Module (src/domains/orders/order/) + +``` +PURPOSE: Full order lifecycle — creation, state machine, Twizrr-managed delivery handoff, confirmation + +ENDPOINTS: +POST /orders — create order (checkout) +GET /orders/:id — get order detail +GET /orders/me — shopper's own orders (with filter tabs) +GET /store/orders — store's incoming orders (store owner) +PUT /orders/:id/dispatch — legacy dispatch transition; new UX should model Ready for pickup / Book delivery +POST /orders/:id/confirm — shopper confirms delivery with a delivery code +POST /orders/:id/dispute — shopper files dispute + +ORDER CODE GENERATION: +└── TWZ-XXXXXX where XXXXXX = 6 random digits (crypto.randomInt) + e.g. TWZ-384729 + Check uniqueness before saving + +STATE MACHINE (strict — no skipping states): +PENDING_PAYMENT + ↓ (Paystack webhook: payment.success) +PAID + ↓ (store prepares order; Twizrr-managed delivery is booked/dispatched) +DISPATCHED + ↓ (shopper confirms OR 48hr auto-confirm) +DELIVERED + ↓ (auto-transitions immediately after DELIVERED) +COMPLETED + +CANCELLED: from PENDING_PAYMENT or PAID (before dispatch) +DISPUTE: from DELIVERED or COMPLETED (within 72hr window) + +6-DIGIT DELIVERY OTP: +├── Generated: at PAID → DISPATCHED transition +├── Format: 6 random digits (crypto.randomInt(100000, 999999)) +├── TTL: 48 hours (matches auto-confirm window) +├── Storage: hashed in DB (never plain text) +├── Shown to: shopper only (on order detail page and WhatsApp) +└── Used by: shopper enters code → triggers DELIVERED transition + +AUTO-CONFIRM (BullMQ job): +├── Scheduled: at DISPATCHED + 48 hours +├── Warning: at DISPATCHED + 36 hours ("12 hours left" notification) +├── Fires only if: order still in DISPATCHED status +└── On fire: DISPATCHED → DELIVERED → COMPLETED (payout triggered) + +ITEMS JSON (snapshot at order time): +{ + productId: string, + variantId: string | null, + variantName: string | null, // e.g. "Red - Size M" (snapshot) + variantAttributes: object | null, // { color: "Red", size: "M" } + quantity: number, + unitPriceKobo: bigint // price at time of order +} +WHY SNAPSHOT: variant may be deleted later. Order must always show what was ordered. + +DROPSHIP ORDER — TWO LINKED ORDERS: +└── Order type DROPSHIP_CUSTOMER: shopper → digital store + Order type DROPSHIP_FULFILLMENT: digital store → physical store + linkedOrderId: links the two orders + Twizrr-managed delivery picks up from the source physical store + and delivers to the shopper dropoff address + Physical store never sees shopper's order total — only fulfillment order +``` + +### Payment Module (src/domains/money/payment/) + +``` +PURPOSE: Paystack checkout initialization, webhook processing, DVA management + +ENDPOINTS: +POST /payments/initialize — initialize Paystack checkout +GET /payments/verify/:ref — verify payment (Paystack callback) +POST /payments/webhook — Paystack webhook (HMAC verified — SkipThrottle) +POST /payments/dva/create — create DVA for shopper (Wema Bank) +GET /payments/dva/me — get shopper's DVA details + +WEBHOOK PROCESSING (CRITICAL): +1. Verify HMAC signature (X-Paystack-Signature header) + If invalid: return 200 (Paystack retries on non-200), log warning, do nothing + WHY 200: Paystack retries on any non-200 response +2. Check idempotency key (payment reference) — already processed? Skip +3. Process by event type: + charge.success → update Order to PAID, create LedgerEntry + transfer.success → update Payout to COMPLETED, create LedgerEntry + transfer.failed → update Payout to FAILED, queue payout-retry +4. Queue BullMQ notifications + +DVA (Dedicated Virtual Account): +└── Created per shopper on first checkout (if not already exists) + Paystack Dedicated Virtual Account API (Wema Bank) + Stored on User: dvaAccountNumber, dvaBankName, dvaActive + Shopper transfers from their banking app → Paystack detects → triggers charge.success +``` + +### Payout Module (src/domains/money/payout/) + +``` +PURPOSE: BullMQ-based payout processing, retry logic, bank transfers + +PAYOUT TRIGGER: on COMPLETED order (delivery confirmed) + +DIRECT PAYOUT FLOW: +1. Calculate amounts: + platformFeeKobo = orderTotal × storeFeePct (2%, 1.5%, or 1%) + payoutAmountKobo = orderTotal - platformFeeKobo +2. Create Payout record (status: PROCESSING) +3. Create LedgerEntry (ESCROW_RELEASE) +4. Call Paystack Transfer API +5. On success webhook: update Payout to COMPLETED, create LedgerEntry (PAYOUT) +6. Send notifications + +DROPSHIP PAYOUT SPLIT: +1. Physical store payout: + amount = product.dropshipperPriceKobo (full wholesale — no fee) + Paystack Transfer → physical store Paystack recipient +2. Digital store payout: + grossMargin = orderTotal - product.dropshipperPriceKobo + platformFee = orderTotal × 0.02 (2% on FULL order value) + netPayout = grossMargin - platformFee + Paystack Transfer → digital store Paystack recipient + +RETRY LOGIC (BullMQ payout-retry queue): +├── Attempt 1: immediate +├── Attempt 2: 5 minutes delay +├── Attempt 3: 30 minutes delay +└── Attempt 4: 2 hours delay +After 4 failures: payout.status = FAILED +Notify store owner: "Update bank details to receive payment" + +MODELS: +Payout { + id String @id @default(cuid()) + orderId String // → Order + storeId String // → StoreProfile + amountKobo BigInt + paystackTransferCode String? + paystackReference String? @unique + status PayoutStatus @default(PROCESSING) + idempotencyKey String @unique + retryCount Int @default(0) + failureReason String? + createdAt DateTime @default(now()) + completedAt DateTime? +} + +LedgerEntry { + id String @id @default(cuid()) + type LedgerType // PAYMENT_IN | ESCROW_RELEASE | PAYOUT | PAYOUT_FAILED | REFUND + orderId String // → Order + amount BigInt // positive = inflow, negative = outflow + storeId String? // → StoreProfile (null for PAYMENT_IN) + reference String? // Paystack reference + note String? + createdAt DateTime @default(now()) + // NO updatedAt — append-only +} +``` + +### WhatsApp Channel (src/channels/whatsapp/) — see channels/whatsapp/AGENTS.md + +``` +PURPOSE: WhatsApp webhook handler + AI shopping assistant + +ENDPOINTS: +GET /whatsapp/webhook — Meta webhook verification (token check) +POST /whatsapp/webhook — incoming messages (HMAC verified, SkipThrottle) + +WEBHOOK VERIFICATION (GET): +└── Return hub.challenge query param if hub.verify_token matches env var + Used once by Meta when setting up the webhook + +WEBHOOK HANDLER (POST): +1. Verify X-Hub-Signature-256 HMAC — reject if invalid (return 200 but do nothing) +2. Parse incoming message type: text | image | interactive (button/list reply) +3. Queue BullMQ whatsapp job for async processing +4. Return 200 immediately (Meta requires response within 5 seconds) + +WHATSAPP AI ASSISTANT FLOW: +Step 1: Check WhatsAppSession — has shopper consented? (NDPR) + No consent: send consent template → wait for acceptance → begin session + +Step 2: Parse intent (Gemini 2.5 Flash function-calling) + Function: search_products({ query, category, maxPrice, minPrice, color, location }) + Function: get_product({ productCode }) + Function: get_order_status({ orderCode }) + Function: confirm_delivery({ orderCode, otpCode }) + Gemini uses function-calling ONLY — never returns free-form product listings + +Step 3: Execute function + search_products → WIZZA hybrid ranking contract: + final_score = vector_similarity * 0.5 + text_rank * 0.2 + + store_performance * 0.2 + tier_boost * 0.1 + (weights via SEARCH_WEIGHT_* env vars; Tier 0 always excluded; + new stores default to store_performance = 0.5) + image message → SafeSearch screening first (block on LIKELY/ + VERY_LIKELY adult/violence/racy → safe refusal, no discovery; + screening errors fail open), then embedding-first image search + (Vertex multimodal embedding is the primary signal; + Vision labels = context/fallback) + Numbers/titles after results → resolved from Redis recent results + +Step 4: Format response as Meta Interactive Message + Multiple products: WhatsApp List Message (sections + rows) + + canonical product links: {WEB_URL}/stores/{storeHandle}/p/{productCode} + Sourced listings appear as products sold by the digital store — + never expose sourceCode/listingCode/physical store data + Store display name: storeName → storeHandle → "twizrr store" + (businessName is never selected or shown in shopper replies) + +Step 5: Store interaction in WhatsAppAnalytics (v2 — honest values) + { phone, sessionId, messageType, searchQuery, parsedFilters, + vectorSearchUsed, embeddingModel, productsRankedCount, + productsShown, productsShownCount, zeroResults, errorType, + dropOffStep, sessionConverted, ... } + +RATE LIMIT: +└── 30 messages / hour / phone number (Redis counter) + If exceeded: "You have reached the message limit. Please try again later." + +PLAIN TEXT RULE: +└── ALL WhatsApp messages: plain text only — NO emojis + Gemini system prompt includes: "You use NO emojis — plain text only" + Template messages: no emojis in any field + +MODELS: +WhatsAppSession { + id String @id @default(cuid()) + phone String // E.164 format + userId String? // → User (null until account linked) + consentGiven Boolean @default(false) + consentAt DateTime? + lastMessageAt DateTime? + createdAt DateTime @default(now()) +} + +WhatsAppAnalytics { + id String @id @default(cuid()) + sessionId String // → WhatsAppSession + messageType String // text | image | button_reply | list_reply + productsShown String[] // product IDs shown in response + searchQuery String? + parsedFilters Json? + zeroResults Boolean @default(false) + sessionConverted Boolean @default(false) // did this session lead to an order? + createdAt DateTime @default(now()) +} +``` + +### Embedding Module (src/domains/platform/embedding/) + +``` +PURPOSE: Vertex AI embedding generation + pgvector storage + +TRIGGERED BY: BullMQ embedding-generation job (after product published) +NEVER RUNS: synchronously during product creation (too slow) + +FLOW: +1. Fetch product: title, description, category, images +2. Call Vertex AI Multimodal Embeddings API: + Input: image bytes + text (title + description + category) + Output: 1408-dim float vector +3. Store in ProductEmbedding table (pgvector) +4. Set product.embeddingReady = true +5. Set product.embeddingUpdatedAt = NOW() + +HYBRID SEARCH QUERY (web search domain example — WIZZA text discovery +uses the approved formula below instead): +const results = await prisma.$queryRaw` + SELECT p.*, + 1 - (pe.embedding <=> ${queryVector}::vector) AS similarity, + ts_rank(to_tsvector('english', p.title || ' ' || p.description), + plainto_tsquery(${searchText})) AS textRank + FROM "Product" p + JOIN "ProductEmbedding" pe ON pe."productId" = p.id + JOIN "StoreProfile" s ON s.id = p."storeId" + WHERE p.status = 'ACTIVE' + AND p."embeddingReady" = true + AND s.tier != 'TIER_0' + AND p."platformCategory" = ANY(${categoryFilter}::text[]) + AND p."retailPriceKobo" BETWEEN ${minPrice} AND ${maxPrice} + ORDER BY (similarity * 0.6 + textRank * 0.3 + trustScore * 0.1) DESC + LIMIT 10 +` + +WIZZA TEXT DISCOVERY RANKING (channels/whatsapp/ — approved contract): +final_score = vector_similarity * 0.5 + text_rank * 0.2 + + store_performance * 0.2 + tier_boost * 0.1 +Configurable via SEARCH_WEIGHT_VECTOR / SEARCH_WEIGHT_TEXT / +SEARCH_WEIGHT_PERFORMANCE / SEARCH_WEIGHT_TIER (must sum to 1.0). + +MODELS: +ProductEmbedding { + id String @id @default(cuid()) + productId String @unique // → Product + embedding Unsupported("vector(1408)") // pgvector + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` + +### Upload Module (src/domains/platform/upload/) + +``` +PURPOSE: Handle all image uploads — ALWAYS moderate before Cloudinary + +ENDPOINT: +POST /upload/image — upload single image (protected) + +FLOW (NEVER SKIP STEP 1): +1. Receive image buffer +2. Call Google Cloud Vision SafeSearch API +3. Evaluate result: + VERY_LIKELY or LIKELY for adult/violence/racy → BLOCKED + POSSIBLE for any category → SENSITIVE + Otherwise → SAFE +4. Log to ModerationLog regardless of result +5. If BLOCKED: return 400, do NOT upload to Cloudinary +6. Upload to Cloudinary (SAFE or SENSITIVE) +7. Return { url, moderationStatus, cloudinaryPublicId } + +CLOUDINARY TRANSFORMS: +├── Product feed images: q_auto,f_auto,w_400 +├── Product detail images: q_auto,f_auto,w_800 +├── Store logo: q_auto,f_auto,w_200,h_200,c_fill +├── Store banner: q_auto,f_auto,w_1200,h_400,c_fill +└── Profile photo: q_auto,f_auto,w_200,h_200,c_fill,g_face + +SENSITIVE CONTENT: +└── Uploaded with tag: "sensitive" + Cloudinary returns URL normally + moderationStatus = SENSITIVE stored in DB + Frontend applies blur filter based on moderationStatus + If user.isMinor = true: hide entirely (not just blur) +``` + +### Moderation Module (src/domains/platform/moderation/) + +``` +PURPOSE: Content moderation log, OPERATOR review queue + +MODELS: +ModerationLog { + id String @id @default(cuid()) + contentType String // product_image | post_image | profile_photo | store_logo + contentId String // productId | postId | userId | storeId + imageUrl String + cloudinaryId String? + decision ModerationDecision // SAFE | SENSITIVE | BLOCKED + confidence Json // Cloud Vision scores per category + reviewedBy String? // → User (OPERATOR who reviewed, if manual) + reviewNote String? + appealStatus String? // PENDING | APPROVED | REJECTED + createdAt DateTime @default(now()) +} +``` + +### Verification Module (src/domains/users/verification/) + +``` +PURPOSE: KYB verification flows — Tier 1 auto, Tier 2 NIN, Tier 3 CAC + +TIER 1 (AUTOMATIC — no endpoint needed): +└── Check event listener: + On: emailVerified = true AND phoneVerified = true AND bank added + → Set store.tier = TIER_1 automatically + No manual review, no endpoint, no user action needed beyond those 3 steps + +TIER 2 ENDPOINTS: +POST /verification/nin/start — submit NIN number +POST /verification/nin/selfie — submit selfie photo for face match +GET /verification/status — get current verification status + +NIN FLOW: +1. Store submits NIN (11-digit string) +2. Call Prembly NIN API: verify NIN against NIMC database +3. If valid: prompt for selfie +4. Selfie submitted → Prembly face verification API +5. If face matches: ninVerified = true +6. Check all Tier 2 requirements: + ninVerified + completedOrders >= 3 + disputes = 0 + profilePhoto != null +7. All met → store.tier = TIER_2 (auto-upgrade) +8. Send congratulations notification + +NIN ATTEMPT LIMIT: 3 attempts before lockout ("contact support") + +TIER 3 ENDPOINTS (MVP — basic only): +POST /verification/cac/start — submit CAC RC number +POST /verification/address/start — submit address verification request + +NOTE: Full Tier 3 automation is Phase 3. At MVP: +CAC check via Prembly API runs automatically +Address verification for physical stores → flags for OPERATOR manual review +Address verification for digital stores → accepts utility bill upload +``` + +### Size Guide Module (src/domains/commerce/size-guide/) + +``` +PURPOSE: Platform-managed size guides — seeded at deployment + +ENDPOINTS: +GET /size-guides/:type — get size guide by type (public) +GET /products/:id/size-guide — get size guide config for a product (public) +PUT /products/:id/size-guide — update store's custom measurements (store owner) + +SEEDING (prisma/seed.ts — runs once at first deployment): +└── Creates SizeGuide records for all 7 types: + CLOTHING_WOMEN, CLOTHING_MEN, CLOTHING_CHILDREN, CLOTHING_UNISEX, + FOOTWEAR_WOMEN, FOOTWEAR_MEN, FOOTWEAR_CHILDREN + + NEVER create SizeGuide records via API + Store owners only create ProductSizeGuideConfig (per-product override) + +MODELS: +SizeGuide { + id String @id @default(cuid()) + type GuideType @unique // one record per type + sizes Json // array of size objects (clothing or footwear format) + version String // "v1" — increment when data updated + isActive Boolean @default(true) +} + +ProductSizeGuideConfig { + id String @id @default(cuid()) + productId String @unique // → Product + primaryGuideType GuideType + includesFootwear Boolean @default(false) + footwearGuideType GuideType? + useCustomSizes Boolean @default(false) + customSizes Json? // same format as SizeGuide.sizes + modelHeight Int? // cm + modelBust Int? // cm + modelWaist Int? // cm + modelHips Int? // cm + modelWearingSize String? +} +``` + +### Notification Module (src/domains/social/notification/) + +``` +PURPOSE: In-app notifications + email dispatch + +ENDPOINTS: +GET /notifications — get current user's notifications (paginated) +PUT /notifications/:id/read — mark one as read +PUT /notifications/read-all — mark all as read +GET /notifications/unread-count — badge count for bell icon + +EMAIL (via Resend — all async via BullMQ): +ORDER_CONFIRMED — to shopper (order details + escrow info) +ORDER_DISPATCHED — to shopper (tracking info) +ORDER_COMPLETED — to shopper (receipt + review prompt) +PAYOUT_SENT — to store owner (amount + order ref) +PAYOUT_FAILED — to store owner (update bank details CTA) +DISPUTE_FILED — to store owner (72hr response window) +DISPUTE_RESOLVED — to both parties (outcome) +OTP_EMAIL_VERIFY — to new user (email verification) +OTP_PASSWORD_RESET — to user (password reset link) +STORE_TIER_UPGRADED — to store owner (congratulations + new benefits) + +IN-APP NOTIFICATION TYPES: +ORDER_PAID, ORDER_DISPATCHED, ORDER_COMPLETED, +POST_LIKED, POST_COMMENTED, COMMENT_REPLIED, +USER_MENTIONED_IN_POST, USER_MENTIONED_IN_COMMENT, +NEW_FOLLOWER, TAGGED_PRODUCT_SOLD_VIA_POST, +PAYOUT_SENT, PAYOUT_FAILED, +STORE_NOTIFICATION_NEW_POST (bell subscription), +MODERATION_BLOCKED, STORE_TIER_UPGRADED +``` + +### Admin Module (src/domains/platform/admin/) + +``` +PURPOSE: OPERATOR and SUPER_ADMIN tools + +ROLE REQUIREMENTS: +├── OPERATOR: moderation queue, dispute resolution, payout monitoring, manual payout retry +└── SUPER_ADMIN: all OPERATOR access + user management, store suspension, fee configuration + +KEY ENDPOINTS: +GET /admin/moderation/queue — OPERATOR: flagged content for review +PUT /admin/moderation/:id — OPERATOR: approve or reject content +GET /admin/disputes — OPERATOR: open dispute queue +PUT /admin/disputes/:id/resolve — OPERATOR: resolve dispute (refund or release) +PUT /admin/payouts/:id/retry — OPERATOR: manually retry failed payout +GET /admin/stores — SUPER_ADMIN: all stores +PUT /admin/stores/:id/suspend — SUPER_ADMIN: suspend store +GET /admin/users — SUPER_ADMIN: all users +POST /admin/stores/:id/tier — SUPER_ADMIN: manually set tier (with reason) + +ALL ADMIN ACTIONS: logged to AuditLog +AuditLog { + id String @id @default(cuid()) + action String // e.g. "STORE_SUSPENDED", "DISPUTE_RESOLVED" + performedBy String // → User (OPERATOR or SUPER_ADMIN) + targetId String // entityId affected + targetType String // "Store" | "User" | "Order" | "Payout" + reason String // required — why was this action taken + before Json? // state before action + after Json? // state after action + createdAt DateTime @default(now()) +} +``` + +--- + +## 3. BullMQ Queue Configuration + +```typescript +// src/queue/queue.constants.ts — single source of truth for queue names +export const QUEUE = { + EMBEDDING: 'embedding-generation', + PAYOUT: 'payout', + PAYOUT_RETRY: 'payout-retry', + NOTIFICATION: 'notification', + WHATSAPP: 'whatsapp', + AUTO_CONFIRM: 'auto-confirm', + AUTO_CONFIRM_WARNING: 'auto-confirm-warning', + FLOOR_CHECK: 'floor-check', + MODERATION: 'moderation', + DELIVERY_ESTIMATE: 'delivery-estimate', + RECONCILIATION: 'reconciliation', + RESTOCK_ALERT: 'restock-alert', + ORDER_EXPIRY: 'order-expiry', + REVIEW_PROMPT: 'review-prompt', + SESSION_CLEANUP: 'session-cleanup', + AUDIT_FLUSH: 'audit-flush', +} as const + +// All processors live in: src/queue/processors/ +// Each processor file: {queue-name}.processor.ts +// Never hardcode queue name strings — always use QUEUE.EMBEDDING etc. +``` + +--- + +## 4. Rate Limiting (Per-Endpoint) + +```typescript +// Global: 100 req/min via ThrottlerGuard APP_GUARD +// Override per-endpoint with @Throttle(): + +@Throttle({ default: { limit: 10, ttl: 900_000 } }) // 10/15min/IP +POST /auth/login + +@Throttle({ default: { limit: 5, ttl: 3_600_000 } }) // 5/hour/IP +POST /auth/register + +@Throttle({ default: { limit: 3, ttl: 3_600_000 } }) // 3/hour/email +POST /auth/forgot-password +POST /auth/otp/send + +@Throttle({ default: { limit: 20, ttl: 3_600_000 } }) // 20/hour/user +POST /posts + +@Throttle({ default: { limit: 10, ttl: 3_600_000 } }) // 10/hour/user +POST /products + +@Throttle({ default: { limit: 60, ttl: 60_000 } }) // 60/min/user +GET /search/* + +@Throttle({ default: { limit: 10, ttl: 3_600_000 } }) // 10/hour/user +POST /orders + +// Webhooks — NEVER rate limited: +@SkipThrottle() // WhatsApp: rate limiting handled internally per phone number +POST /whatsapp/webhook + +@SkipThrottle() // Paystack: must never be rate limited +POST /payments/webhook +``` + +--- + +## 5. Delivery Module (src/domains/orders/delivery/) + +``` +PURPOSE: Twizrr-managed delivery policy, Shipbubble integration, and delivery preview API + +ENDPOINTS: +GET /delivery/estimates — delivery preview for product page + Query: ?productId=xxx&shopperState=Lagos&shopperCity=Lekki + Cache: Redis 30-min TTL per (productId + shopperState + shopperCity) + +POST /delivery/book — book approved logistics after store marks Ready for pickup +POST /delivery/webhook — Shipbubble delivery status events + +DELIVERY POLICY — CRITICAL: +All marketplace delivery is Twizrr-managed by default. +Checkout does not expose old store-delivery or personal-delivery choices. +Checkout requires delivery address and uses User.phone as the default +primary delivery phone; checkout may collect an optional secondary phone. +Shopper delivery phones are private platform data and must not be returned +to store owners by default. +Delivery phone/address may be shared only with approved logistics providers +when required for fulfillment. +Store owners accept/prepare orders and trigger Ready for pickup / Book delivery +through Twizrr; they do not arrange off-platform delivery with shoppers. +Delivery fee is calculated by Twizrr from pickup point to shopper dropoff. +MVP may start with Lagos selected-zone pricing before full live logistics pricing. + +PICKUP POINT RULE — CRITICAL: +For PHYSICAL store order: use the physical store pickup/business address +For DROPSHIP order: use the source physical supplier pickup/business address +NEVER use the digital dropshipper's homeAddress for delivery routing +homeAddress is private and internal only + +LAUNCH CLUSTERS: +Balogun = supplier/source inventory cluster +Yaba = youth/student/dropshipper/shoppers cluster + +INJECTS: ShipbubbleClient (from integrations/shipbubble/) + +DELIVERY PREVIEW RESPONSE FORMAT: +[ + { + type: 'SAME_DAY', + label: 'Same-day delivery — Lagos', + priceKobo: 250000, + eta: 'Today by 6:00 PM', + available: true, + carrierId: null + }, + { + type: 'NATIONWIDE', + label: 'Nationwide — GIG Logistics', + priceKobo: 420000, + eta: '1-2 days', + available: true, + carrierId: 'shipbubble_carrier_id' + } +] + +SAME-DAY AVAILABILITY CONDITIONS (all must be true): +├── Store is PHYSICAL (digital stores cannot do same-day) +├── Physical store city matches shopper city +├── Current time is before 12:00 PM WAT +└── Shipbubble same-day available for this route +``` + +--- + +## 6. Dispute Module (src/domains/orders/dispute/) + +``` +PURPOSE: Dispute filing, 2-stage resolution + +ENDPOINTS: +POST /orders/:id/dispute — shopper files dispute (within 72hr window) +GET /disputes/me — shopper's disputes +GET /store/disputes — store's disputes +GET /admin/disputes — OPERATOR: all open disputes +PUT /admin/disputes/:id/resolve — OPERATOR: resolve dispute + +DISPUTE WINDOW: +└── Shopper can file within 72 hours of order.completedAt + After 72 hours: throw 409 ConflictException({ code: 'DISPUTE_WINDOW_CLOSED' }) + +STATES: OPEN → IN_REVIEW → RESOLVED_BUYER | RESOLVED_STORE + +ON DISPUTE FILED: +├── order.disputeStatus = OPEN +├── Cancel any pending BullMQ payout job (if not yet processed) +├── If payout already sent: flag for OPERATOR +├── Store notified: "Dispute filed — respond within 48 hours" +└── AuditLog entry created + +ON RESOLUTION: +RESOLVED_BUYER → queue BullMQ refund job +RESOLVED_STORE → queue BullMQ payout job +Both outcomes: both parties notified via notification module +``` + +--- + +## 7. Key Engineering Decisions + +``` +NEVER use raw SQL unless: +└── pgvector operations (Prisma has no vector support — raw SQL required) + Complex CTEs not expressible in Prisma + Document with inline comment: // raw SQL: reason here + +NEVER make these calls synchronously in request handlers: +└── Vertex AI embeddings → BullMQ (QUEUE.EMBEDDING) + Paystack payouts → BullMQ (QUEUE.PAYOUT) + Email/notifications → BullMQ (QUEUE.NOTIFICATION) + WhatsApp messages → BullMQ (QUEUE.WHATSAPP) + Exceptions (blocking by design): + └── Paystack HMAC verification — must be synchronous (security) + Cloud Vision moderation — must be synchronous (before upload) + +DATABASE CONNECTIONS: +└── App queries: DATABASE_URL (pooled via Neon PgBouncer) — ALWAYS + Migrations: DIRECT_URL (direct connection) — migrations ONLY + NEVER use DIRECT_URL for runtime Prisma queries + +CURSOR PAGINATION (not offset): +└── Products, orders, feed, search: cursor-based pagination + Cursor = createdAt DESC + id as tiebreaker + Offset is only acceptable for small admin lists (< 100 items) + +SOFT DELETES: +└── Products: status = DELETED (never hard delete — order items reference products) + Posts: isActive = false + Users: isActive = false + anonymise email (NDPR compliance) + Orders: NEVER delete + LedgerEntry: NEVER delete + InventoryEvent: NEVER delete + +IMPORT RULE — INTEGRATION CLIENTS: +└── Never import Paystack SDK directly into a domain service + Always inject PaystackClient from integrations/paystack/ + Same rule for all third-party SDKs + Domain services are provider-agnostic — integrations handle providers + +MONEY WRITE RULE: +└── Never create LedgerEntry directly in payment, payout, or escrow services + Always call LedgerService methods (recordPaymentIn, recordPayout, etc.) + LedgerService is the single point of write for all financial records +``` + +--- + +## 8. Before You Write Any Code + +``` +CHECKLIST — run before starting any task: + +□ Have you read the root AGENTS.md? +□ Have you read apps/backend/AGENTS.md (this file)? +□ Does this feature exist in TWIZRR_MVP_SCOPE.md? + If NO: do not build it — ask first +□ Which layer does this belong to? + Infrastructure (core/) | Channel (channels/) | + Integration client (integrations/) | Business logic (domains/) +□ Which domain? commerce/ orders/ money/ users/ social/ platform/ +□ Are you injecting integration clients correctly? + (not importing third-party SDKs directly into domain services) +□ Are all money writes going through LedgerService? +□ Does any response return storeType or physicalStoreId? + If YES: remove — violates store type visibility rule +□ Does any response return SourcedProduct physical store fields? + If YES: remove — physical store must be invisible to shoppers +□ Is money handled as BigInt kobo? + If NO: fix — never Float +□ Is Paystack HMAC verified before processing payment webhook? +□ Does image upload call Cloud Vision before Cloudinary? +□ Does any user-facing text contain emoji? + If YES: remove — icons only, no exceptions +□ Have all 6 pre-commit checks passed? + cd apps/backend && pnpm lint && npx tsc --noEmit && pnpm build + +IF ANYTHING IS UNCLEAR: stop and ask. Never guess. +``` + +--- + +*Last updated: June 2026* +*Project: twizrr — apps/backend* +*Maintained by: CODEDDEVS TECHNOLOGY LTD* diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 81f12d87..522b9c18 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -3,11 +3,11 @@ FROM node:20-alpine AS builder WORKDIR /app -RUN npm install -g turbo +RUN corepack enable && corepack prepare pnpm@9.0.0 --activate COPY . . -RUN turbo prune --scope=@swifta/backend --docker +RUN pnpm dlx turbo@2.8.9 prune --scope=@twizrr/backend --docker # ── Stage 2: Install deps + build ── FROM node:20-alpine AS installer @@ -17,15 +17,15 @@ WORKDIR /app COPY --from=builder /app/out/json/ . COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml -RUN npm install -g pnpm && pnpm install --frozen-lockfile +RUN corepack enable && corepack prepare pnpm@9.0.0 --activate && pnpm install --frozen-lockfile COPY --from=builder /app/out/full/ . # Generate Prisma Client -RUN npx prisma generate --schema=apps/backend/prisma/schema.prisma +RUN pnpm exec prisma generate --schema=apps/backend/prisma/schema.prisma # Build backend + shared -RUN pnpm turbo run build --filter=@swifta/backend... +RUN pnpm turbo run build --filter=@twizrr/backend... # ── Stage 3: Slim production runner ── FROM node:20-alpine AS runner @@ -48,4 +48,4 @@ EXPOSE 4000 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:4000/health || exit 1 -CMD ["node", "apps/backend/dist/main"] +CMD ["node", "apps/backend/dist/apps/backend/src/main.js"] diff --git a/apps/backend/README.md b/apps/backend/README.md deleted file mode 100644 index cd84f707..00000000 --- a/apps/backend/README.md +++ /dev/null @@ -1,246 +0,0 @@ -# Swifta — Backend - -NestJS modular monolith API server for Swifta. - ---- - -## Tech Stack - -- **NestJS** (Express adapter) — framework -- **Prisma** — ORM and migrations -- **PostgreSQL 16** — database (Supabase-managed in prod) -- **Redis 7** — sessions + job queue -- **BullMQ** — async background jobs -- **Paystack** — payment processing -- **Passport + JWT** — authentication - ---- - -## Getting Started - -```bash -# From monorepo root -docker-compose up -d # Start Postgres + Redis -cp .env.example apps/backend/.env # Configure environment -cd apps/backend -npx prisma migrate dev # Run migrations -npx prisma db seed # Seed test data -pnpm dev # Start on http://localhost:4000 -``` - ---- - -## Project Structure - -``` -src/ -├── main.ts # App bootstrap (CORS, helmet, validation pipe) -├── app.module.ts # Root module — imports all domain modules -│ -├── config/ # Environment variable loading -│ ├── app.config.ts # PORT, NODE_ENV, FRONTEND_URL -│ ├── database.config.ts # DATABASE_URL -│ ├── redis.config.ts # REDIS_URL -│ ├── paystack.config.ts # Paystack keys -│ └── jwt.config.ts # JWT secrets and TTLs -│ -├── common/ # Cross-cutting concerns -│ ├── decorators/ # @CurrentUser, @CurrentMerchant, @Roles, @IdempotencyKey -│ ├── guards/ # JwtAuthGuard, RolesGuard, MerchantVerifiedGuard -│ ├── middleware/ # MerchantContextMiddleware (extracts merchantId from JWT) -│ ├── filters/ # GlobalExceptionFilter (consistent error responses) -│ ├── pipes/ # ValidationPipe (auto DTO validation) -│ └── interceptors/ # ResponseTransformInterceptor (wraps in ApiResponse) -│ -├── prisma/ # Database -│ ├── schema.prisma # 13 models — source of truth -│ ├── seed.ts # Test data -│ └── migrations/ # Migration history -│ -├── redis/ # Redis client module -├── queue/ # BullMQ registration -├── health/ # GET /health endpoint -│ -└── modules/ # Domain modules (all business logic) - ├── auth/ # Register, login, JWT, refresh, logout - ├── merchant/ # Profile CRUD, onboarding - ├── product/ # Product CRUD, catalogue, soft delete - ├── rfq/ # RFQ creation, expiry cron, cancellation - ├── quote/ # Submit, accept (atomic transaction), decline - ├── order/ # State machine, dispatch, OTP, event logging - ├── payment/ # Paystack init, webhook, verify, payout - ├── inventory/ # Event append, stock cache, reserve/release - └── notification/ # Trigger service, BullMQ processor, email -``` - ---- - -## Domain Modules - -### Module Dependency Flow - -``` -Auth → Merchant/Product → RFQ → Quote → Order → Payment - │ -Notification ← called by all modules ──────────────┘ -Inventory ← called by Quote + Order -``` - -No circular dependencies. Dependencies flow one direction. - -### Module Responsibilities - -| Module | Exports | Key Endpoints | -| ---------------- | -------------------------- | ----------------------------------------------------------------------------- | -| **Auth** | — | `POST /auth/register, login, refresh, logout` | -| **Merchant** | MerchantService | `GET/PATCH /merchants/me`, `GET /merchants/:id` | -| **Product** | ProductService | `CRUD /products`, `GET /products/catalogue` | -| **RFQ** | RFQService | `POST /rfqs`, `GET /rfqs`, `GET /rfqs/merchant` | -| **Quote** | — | `POST /quotes`, `POST /quotes/:id/accept\|decline` | -| **Order** | OrderService | `GET /orders`, `POST /orders/:id/dispatch\|confirm-delivery\|cancel\|dispute` | -| **Payment** | — | `POST /payments/initialize`, `POST /payments/webhook` | -| **Inventory** | InventoryService | `GET /inventory/:productId`, `POST /inventory/adjust` | -| **Notification** | NotificationTriggerService | `GET /notifications`, `PATCH /notifications/:id/read` | - ---- - -## Database - -### Models (13 total) - -User, MerchantProfile, Product, RFQ, Quote, Order, OrderEvent, Payment, PaymentEvent, InventoryEvent, ProductStockCache, Notification - -### Key Rules - -- All IDs: UUID v4 -- All money: BigInt in kobo (₦1 = 100 kobo) -- Append-only tables: OrderEvent, PaymentEvent, InventoryEvent — NO updates, NO deletes -- Inventory: never mutated directly, derived from events -- Table names: snake_case via `@@map()` -- All foreign keys have `@@index()` - -### Commands - -```bash -npx prisma studio # Visual DB browser -npx prisma migrate dev # Create + apply migration -npx prisma generate # Regenerate client -npx prisma migrate reset # Drop + recreate DB -npx prisma db seed # Seed test data -npx prisma validate # Validate schema -``` - ---- - -## Order State Machine - -``` -PENDING_PAYMENT → PAID → DISPATCHED → DELIVERED → COMPLETED - ↓ ↓ ↓ - CANCELLED CANCELLED DISPUTE -``` - -| From | To | Triggered By | -| --------------- | ---------- | ------------------------------ | -| PENDING_PAYMENT | PAID | System (Paystack webhook) | -| PENDING_PAYMENT | CANCELLED | Buyer | -| PAID | DISPATCHED | Merchant (generates OTP) | -| PAID | CANCELLED | Merchant (triggers refund) | -| DISPATCHED | DELIVERED | Buyer (OTP verified) | -| DISPATCHED | DISPUTE | Buyer | -| DELIVERED | COMPLETED | System (auto, triggers payout) | - -Enforced by `order-state-machine.ts`. Every transition creates an OrderEvent. - ---- - -## Payment Flow (Paystack) - -1. `POST /payments/initialize` → calls Paystack API, returns authorization URL -2. Frontend opens Paystack popup → buyer pays -3. Paystack sends `POST /payments/webhook` → HMAC-SHA512 verified -4. Backend verifies via Paystack verify API → updates Payment + Order -5. On delivery confirmation → Paystack Transfer API pays merchant - -**Idempotency:** - -- Initialize: keyed on orderId (same order → same reference) -- Webhook: checks if already SUCCESS (duplicates ignored) -- Payout: keyed on orderId + PAYOUT direction (one payout per order) - -**Webhook endpoint has NO JWT guard** — protected by signature verification only. - ---- - -## Authentication - -- Access token: JWT, 15 min TTL, contains `{ sub, email, role, merchantId }` -- Refresh token: JWT, 7 day TTL, stored in Redis, rotated on use -- Logout: deletes refresh token from Redis -- Guards: `JwtAuthGuard` → `RolesGuard` → `MerchantVerifiedGuard` -- `MerchantContextMiddleware`: extracts merchantId from JWT, attaches to request - ---- - -## API Response Format - -**Success:** - -```json -{ "success": true, "data": { ... } } -``` - -**Paginated:** - -```json -{ "success": true, "data": [...], "meta": { "page": 1, "limit": 20, "total": 47 } } -``` - -**Error:** - -```json -{ "error": "Quote has expired", "code": "QUOTE_EXPIRED", "statusCode": 400 } -``` - ---- - -## Notification Triggers - -| Event | Recipients | -| ------------------ | ---------- | -| New RFQ | Merchant | -| Quote submitted | Buyer | -| Quote accepted | Merchant | -| Quote declined | Merchant | -| RFQ expired | Buyer | -| Payment confirmed | Both | -| Order cancelled | Both | -| Order dispatched | Buyer | -| Delivery confirmed | Merchant | -| Payout initiated | Merchant | - ---- - -## Testing - -```bash -pnpm test # Unit tests -pnpm test:e2e # End-to-end tests - -# Paystack webhook testing -paystack listen --forward-to localhost:4000/payments/webhook - -# Test card: 4084 0840 8408 4081 (any expiry, any CVV, OTP: 123456) -``` - ---- - -## Rules - -1. Never mutate inventory directly — always append an InventoryEvent -2. Never skip the state machine — use `validateTransition()` -3. Never query merchant data without merchantId filter -4. Never store money as float — BigInt kobo only -5. Never put business logic in controllers — delegate to services -6. Never process webhooks without signature verification -7. Never return raw Prisma errors — GlobalExceptionFilter formats them diff --git a/apps/backend/inspect-db.js b/apps/backend/inspect-db.js deleted file mode 100644 index fd025968..00000000 --- a/apps/backend/inspect-db.js +++ /dev/null @@ -1,41 +0,0 @@ -const { Pool } = require('pg'); -const DATABASE_URL = "postgresql://postgres.hfdngfvkgahiwmwtcyed:3swiftadevs2026@aws-1-eu-central-1.pooler.supabase.com:5432/postgres"; - -async function inspect() { - const pool = new Pool({ connectionString: DATABASE_URL }); - try { - const client = await pool.connect(); - - const fs = require('fs'); - const results = {}; - - console.log('Inspecting merchant_profiles...'); - const cols = await client.query(` - SELECT column_name, data_type, is_nullable, column_default - FROM information_schema.columns - WHERE table_name = 'merchant_profiles' AND table_schema = 'public' - ORDER BY ordinal_position; - `); - results.merchant_profiles = cols.rows; - - console.log('Inspecting users...'); - const userCols = await client.query(` - SELECT column_name, data_type, is_nullable, column_default - FROM information_schema.columns - WHERE table_name = 'users' AND table_schema = 'public' - ORDER BY ordinal_position; - `); - results.users = userCols.rows; - - fs.writeFileSync('db_inspection.json', JSON.stringify(results, null, 2)); - console.log('Inspection results saved to db_inspection.json'); - - client.release(); - } catch (err) { - console.error('Inspection failed:', err); - } finally { - await pool.end(); - } -} - -inspect(); diff --git a/apps/backend/nest-cli.json b/apps/backend/nest-cli.json index f9aa683b..6d3b269d 100644 --- a/apps/backend/nest-cli.json +++ b/apps/backend/nest-cli.json @@ -2,6 +2,7 @@ "$schema": "https://json.schemastore.org/nest-cli", "collection": "@nestjs/schematics", "sourceRoot": "src", + "entryFile": "apps/backend/src/main", "compilerOptions": { "deleteOutDir": true } diff --git a/apps/backend/package.json b/apps/backend/package.json index df7e1fa0..e58a50e3 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -1,27 +1,32 @@ { - "name": "@swifta/backend", + "name": "@twizrr/backend", "version": "0.0.1", - "description": "Swifta Backend API", + "description": "Twizrr Backend API", "author": "", "private": true, "license": "UNLICENSED", "scripts": { - "prebuild": "prisma generate", + "prebuild": "node node_modules/prisma/build/index.js generate --schema=prisma/schema.prisma", "build": "nest build", + "typecheck": "node node_modules/prisma/build/index.js generate --schema=prisma/schema.prisma && tsc --noEmit", + "prisma:validate": "node node_modules/prisma/build/index.js validate --schema=prisma/schema.prisma", + "prisma:generate": "node node_modules/prisma/build/index.js generate --schema=prisma/schema.prisma", "format": "prettier --write \"src/**/*.ts\"", - "start": "nest start", + "start": "node dist/apps/backend/src/main", "dev": "nest start --watch", "start:debug": "nest start --debug --watch", - "start:prod": "node dist/src/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "start:prod": "node dist/apps/backend/src/main", + "lint": "eslint --ext .ts src test --fix", "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", + "test:settlement:postgres": "jest --config ./test/jest-settlement-postgres.json --runInBand", "migrate:dev": "prisma migrate dev", "migrate:deploy": "prisma migrate deploy", - "seed": "ts-node src/prisma/seed.ts" + "seed": "ts-node src/prisma/seed.ts", + "bootstrap:admin": "ts-node src/prisma/bootstrap-admin-cli.ts" }, "dependencies": { "@google/generative-ai": "^0.24.1", @@ -32,14 +37,20 @@ "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.2.0", "@nestjs/mapped-types": "^2.1.0", + "@nestjs/microservices": "^10.4.15", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.22", + "@nestjs/platform-socket.io": "^10", "@nestjs/schedule": "^6.1.1", "@nestjs/serve-static": "^5.0.4", + "@nestjs/swagger": "^11.2.6", + "@nestjs/terminus": "^10.2.1", "@nestjs/throttler": "^6.5.0", + "@nestjs/websockets": "^10", "@prisma/adapter-pg": "^7.5.0", "@prisma/client": "^7.5.0", - "@swifta/shared": "workspace:*", + "@socket.io/redis-adapter": "^8.3.0", + "@twizrr/shared": "workspace:*", "africastalking": "^0.7.9", "bcrypt": "^5.1.1", "bullmq": "^5.69.3", @@ -49,9 +60,11 @@ "class-validator": "^0.14.1", "cloudinary": "^2.9.0", "cookie-parser": "^1.4.7", + "dotenv": "^17.4.2", "express": "^5.2.1", "helmet": "^7.1.0", "ioredis": "^5.3.2", + "joi": "^18.1.2", "multer": "^2.0.2", "nestjs-pino": "^4.6.0", "passport": "^0.7.0", @@ -63,7 +76,8 @@ "reflect-metadata": "^0.2.0", "resend": "^6.9.2", "rxjs": "^7.8.1", - "uuid": "^9.0.1" + "socket.io": "^4.8.3", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -79,13 +93,15 @@ "@types/node": "^20.3.1", "@types/passport-jwt": "^4.0.1", "@types/pdfkit": "^0.17.5", + "@types/pg": "8.11.11", "@types/supertest": "^6.0.0", - "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "eslint": "^8.42.0", "eslint-config-prettier": "^9.0.0", + "eslint-plugin-no-secrets": "^2.3.3", "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-security": "^2.1.1", "jest": "^29.5.0", "prettier": "^3.0.0", "prisma": "^7.5.0", @@ -116,6 +132,9 @@ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", - "testEnvironment": "node" + "testEnvironment": "node", + "moduleNameMapper": { + "^@twizrr/shared$": "/../../../packages/shared/src" + } } } diff --git a/apps/backend/prisma.config.ts b/apps/backend/prisma.config.ts index 48b40c46..8b6a002a 100644 --- a/apps/backend/prisma.config.ts +++ b/apps/backend/prisma.config.ts @@ -1,7 +1,14 @@ -import { defineConfig } from '@prisma/config'; +import { defineConfig } from "@prisma/config"; +import * as dotenv from "dotenv"; + +dotenv.config({ path: ".env" }); +dotenv.config({ path: ".env.local", override: true }); export default defineConfig({ + migrations: { + seed: "ts-node src/prisma/seed.ts", + }, datasource: { - url: process.env.DATABASE_URL, + url: process.env.DIRECT_URL, }, }); diff --git a/apps/backend/prisma.settlement-test.config.ts b/apps/backend/prisma.settlement-test.config.ts new file mode 100644 index 00000000..ed176aa3 --- /dev/null +++ b/apps/backend/prisma.settlement-test.config.ts @@ -0,0 +1,57 @@ +import { defineConfig } from "@prisma/config"; + +/** + * Prisma configuration used exclusively by the settlement PostgreSQL safety + * suites. It deliberately does not load .env or .env.local: those files may + * point at a shared Neon database and must never be used by destructive tests. + */ +function getSettlementTestDatabaseUrl(): string { + const value = process.env.SETTLEMENT_TEST_DATABASE_URL?.trim(); + if (!value) { + throw new Error( + "SETTLEMENT_TEST_DATABASE_URL is required for settlement integration tests", + ); + } + + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error( + "SETTLEMENT_TEST_DATABASE_URL must be a valid PostgreSQL URL", + ); + } + + const hostname = url.hostname.toLowerCase(); + const database = url.pathname.toLowerCase(); + const localHosts = new Set(["localhost", "127.0.0.1", "::1", "postgres"]); + const looksShared = + hostname.includes("neon") || + hostname.includes("prod") || + hostname.includes("staging") || + hostname.includes("shared"); + + if ( + !["postgres:", "postgresql:"].includes(url.protocol) || + looksShared || + !localHosts.has(hostname) || + !database.includes("settlement") || + !database.includes("test") + ) { + throw new Error( + "SETTLEMENT_TEST_DATABASE_URL must target a local disposable database whose name contains settlement and test", + ); + } + + return value; +} + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + }, + datasource: { + url: getSettlementTestDatabaseUrl(), + }, +}); diff --git a/apps/backend/prisma/MIGRATIONS.md b/apps/backend/prisma/MIGRATIONS.md new file mode 100644 index 00000000..22d039ec --- /dev/null +++ b/apps/backend/prisma/MIGRATIONS.md @@ -0,0 +1,20 @@ +# Migration Discipline + +Prisma migration files are append-only history. Do not delete or edit an applied +migration after it has reached Neon, Render, Vercel preview data, or any shared +environment. + +Use this flow for schema changes: + +1. Change `schema.prisma`. +2. Create a new migration folder with Prisma or a hand-written SQL migration. +3. Review generated SQL for destructive operations before applying it. +4. Test against a disposable Neon branch when data may be affected. +5. Apply with `pnpm exec prisma migrate deploy`. +6. Verify with `pnpm exec prisma migrate status --schema prisma/schema.prisma`. +7. If production drift exists, fix it with a new repair migration. Do not mark a + migration as applied unless the matching SQL has truly been applied. + +Destructive changes, such as dropping tables, columns, enum values, or foreign +keys, should live in their own cleanup migration after data ownership has been +confirmed. diff --git a/apps/backend/prisma/migrations/20260306142423_v3_scale_and_trust/migration.sql b/apps/backend/prisma/migrations/20260306142423_v3_scale_and_trust/migration.sql index 9e369485..90d99211 100644 --- a/apps/backend/prisma/migrations/20260306142423_v3_scale_and_trust/migration.sql +++ b/apps/backend/prisma/migrations/20260306142423_v3_scale_and_trust/migration.sql @@ -1,5 +1,5 @@ -- CreateEnum -CREATE TYPE "VerificationTier" AS ENUM ('UNVERIFIED', 'BASIC', 'VERIFIED', 'TRUSTED'); +CREATE TYPE "VerificationTier" AS ENUM ('UNVERIFIED', 'BASIC', 'VERIFIED', 'TRUSTED', 'TIER_1', 'TIER_2', 'TIER_3'); -- CreateEnum CREATE TYPE "PaymentMethod" AS ENUM ('ESCROW', 'DIRECT'); diff --git a/apps/backend/prisma/migrations/20260316124500_safe_tier_renaming/migration.sql b/apps/backend/prisma/migrations/20260316124500_safe_tier_renaming/migration.sql index 0fbacd84..257b8965 100644 --- a/apps/backend/prisma/migrations/20260316124500_safe_tier_renaming/migration.sql +++ b/apps/backend/prisma/migrations/20260316124500_safe_tier_renaming/migration.sql @@ -1,9 +1,11 @@ --- Safe migration for VerificationTier enum rename --- 1. Ensure new enum values exist on the Postgres type VerificationTier +-- 1. Ensure new enum values already exist on the Postgres type VerificationTier +-- Safeguard: Ensure new enum values exist even if previous migration edits were skipped ALTER TYPE "VerificationTier" ADD VALUE IF NOT EXISTS 'TIER_1'; ALTER TYPE "VerificationTier" ADD VALUE IF NOT EXISTS 'TIER_2'; ALTER TYPE "VerificationTier" ADD VALUE IF NOT EXISTS 'TIER_3'; +-- (Added via history fix in 20260306142423_v3_scale_and_trust) + -- 2. Add temporary column verification_tier_new on merchant_profiles table ALTER TABLE "merchant_profiles" ADD COLUMN IF NOT EXISTS "verification_tier_new" "VerificationTier" DEFAULT 'UNVERIFIED'; diff --git a/apps/backend/prisma/migrations/20260318043500_add_short_description/migration.sql b/apps/backend/prisma/migrations/20260318043500_add_short_description/migration.sql index 8bafb9af..99c673cd 100644 Binary files a/apps/backend/prisma/migrations/20260318043500_add_short_description/migration.sql and b/apps/backend/prisma/migrations/20260318043500_add_short_description/migration.sql differ diff --git a/apps/backend/prisma/migrations/20260508233000_neon_drift_repair/migration.sql b/apps/backend/prisma/migrations/20260508233000_neon_drift_repair/migration.sql new file mode 100644 index 00000000..f4af47a4 --- /dev/null +++ b/apps/backend/prisma/migrations/20260508233000_neon_drift_repair/migration.sql @@ -0,0 +1,253 @@ +/* + Neon drift repair. + + This migration is intentionally non-destructive. The live Neon schema has + migrations marked as applied while several later schema changes are absent. + Add only the columns, tables, indexes, foreign keys, and enum values required + by the current Prisma schema. Keep legacy RFQ/quote tables and columns intact. +*/ + +-- --------------------------------------------------------------------------- +-- Enums +-- --------------------------------------------------------------------------- +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'PriceType') THEN + CREATE TYPE "PriceType" AS ENUM ('RETAIL', 'WHOLESALE'); + END IF; +END $$; + +ALTER TYPE "OrderDisputeStatus" ADD VALUE IF NOT EXISTS 'RESOLVED_BUYER'; +ALTER TYPE "OrderDisputeStatus" ADD VALUE IF NOT EXISTS 'RESOLVED_MERCHANT'; +ALTER TYPE "OrderStatus" ADD VALUE IF NOT EXISTS 'REFUND_PENDING'; +ALTER TYPE "PayoutStatus" ADD VALUE IF NOT EXISTS 'CANCELLED'; +ALTER TYPE "VerificationTier" ADD VALUE IF NOT EXISTS 'TIER_1'; +ALTER TYPE "VerificationTier" ADD VALUE IF NOT EXISTS 'TIER_2'; +ALTER TYPE "VerificationTier" ADD VALUE IF NOT EXISTS 'TIER_3'; + +-- Align existing rows with the current application tier names. The old enum +-- labels are left in the database type for now to avoid a risky enum rebuild. +UPDATE "merchant_profiles" +SET "verification_tier" = CASE + WHEN "verification_tier"::text = 'BASIC' THEN 'TIER_1'::"VerificationTier" + WHEN "verification_tier"::text = 'VERIFIED' THEN 'TIER_2'::"VerificationTier" + WHEN "verification_tier"::text = 'TRUSTED' THEN 'TIER_3'::"VerificationTier" + ELSE "verification_tier" +END +WHERE "verification_tier"::text IN ('BASIC', 'VERIFIED', 'TRUSTED'); + +-- --------------------------------------------------------------------------- +-- Existing tables: additive repairs +-- --------------------------------------------------------------------------- +ALTER TABLE "buyer_profiles" + ADD COLUMN IF NOT EXISTS "dva_account_name" TEXT, + ADD COLUMN IF NOT EXISTS "dva_account_number" TEXT, + ADD COLUMN IF NOT EXISTS "dva_active" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "dva_bank_name" TEXT, + ADD COLUMN IF NOT EXISTS "dva_bank_slug" TEXT, + ADD COLUMN IF NOT EXISTS "paystack_customer_code" TEXT, + ADD COLUMN IF NOT EXISTS "paystack_customer_id" TEXT, + ALTER COLUMN "buyer_type" SET DEFAULT 'CONSUMER'; + +ALTER TABLE "cart_items" + ADD COLUMN IF NOT EXISTS "price_type" "PriceType" NOT NULL DEFAULT 'RETAIL'; + +ALTER TABLE "merchant_profiles" + ADD COLUMN IF NOT EXISTS "address_verified_via" TEXT, + ADD COLUMN IF NOT EXISTS "cac_verified_via" TEXT, + ADD COLUMN IF NOT EXISTS "cover_image" TEXT, + ADD COLUMN IF NOT EXISTS "description" TEXT, + ADD COLUMN IF NOT EXISTS "guarantor_verified" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "last_slug_change_at" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "nin_number" TEXT, + ADD COLUMN IF NOT EXISTS "nin_verified" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "nin_verified_at" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "nin_verified_via" TEXT, + ADD COLUMN IF NOT EXISTS "notification_preferences" JSONB DEFAULT '{}', + ADD COLUMN IF NOT EXISTS "profile_image" TEXT, + ADD COLUMN IF NOT EXISTS "slug" TEXT, + ADD COLUMN IF NOT EXISTS "social_links" JSONB DEFAULT '{}', + ADD COLUMN IF NOT EXISTS "tier_upgraded_at" TIMESTAMP(3); + +ALTER TABLE "orders" + ADD COLUMN IF NOT EXISTS "delivery_details" JSONB, + ADD COLUMN IF NOT EXISTS "dispatched_at" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "items" JSONB, + ADD COLUMN IF NOT EXISTS "metadata" JSONB; + +ALTER TABLE "products" + ADD COLUMN IF NOT EXISTS "is_seeded" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "min_order_quantity_consumer" INTEGER NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS "processing_days" INTEGER, + ADD COLUMN IF NOT EXISTS "product_code" TEXT, + ADD COLUMN IF NOT EXISTS "weight_kg" DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS "wholesale_discount_percent" DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS "wholesale_price_kobo" BIGINT; + +ALTER TABLE "reviews" + ADD COLUMN IF NOT EXISTS "image_url" TEXT; + +ALTER TABLE "verification_requests" + ADD COLUMN IF NOT EXISTS "nin_number" TEXT, + ADD COLUMN IF NOT EXISTS "target_tier" "VerificationTier" NOT NULL DEFAULT 'TIER_2'; + +-- --------------------------------------------------------------------------- +-- New tables expected by the current schema +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS "saved_products" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "product_id" UUID NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "saved_products_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "merchant_slug_history" ( + "id" UUID NOT NULL, + "merchant_profile_id" UUID NOT NULL, + "old_slug" TEXT NOT NULL, + "new_slug" TEXT NOT NULL, + "changed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "merchant_slug_history_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "whatsapp_supplier_links" ( + "id" UUID NOT NULL, + "phone" TEXT NOT NULL, + "linked_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "supplier_id" UUID NOT NULL, + CONSTRAINT "whatsapp_supplier_links_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "follows" ( + "id" UUID NOT NULL, + "follower_id" UUID NOT NULL, + "merchant_id" UUID NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "follows_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "merchant_waitlists" ( + "id" UUID NOT NULL, + "business_name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "phone" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "merchant_waitlists_pkey" PRIMARY KEY ("id") +); + +-- --------------------------------------------------------------------------- +-- Indexes +-- --------------------------------------------------------------------------- +DROP INDEX IF EXISTS "cart_items_buyer_id_product_id_key"; +CREATE UNIQUE INDEX IF NOT EXISTS "cart_items_buyer_id_product_id_price_type_key" + ON "cart_items"("buyer_id", "product_id", "price_type"); + +CREATE INDEX IF NOT EXISTS "merchant_profiles_slug_idx" + ON "merchant_profiles"("slug"); + +CREATE INDEX IF NOT EXISTS "orders_merchant_id_status_idx" + ON "orders"("merchant_id", "status"); + +CREATE INDEX IF NOT EXISTS "orders_buyer_id_status_idx" + ON "orders"("buyer_id", "status"); + +CREATE INDEX IF NOT EXISTS "products_product_code_idx" + ON "products"("product_code"); + +CREATE INDEX IF NOT EXISTS "products_category_id_idx" + ON "products"("category_id"); + +CREATE INDEX IF NOT EXISTS "products_is_active_created_at_idx" + ON "products"("is_active", "created_at"); + +CREATE INDEX IF NOT EXISTS "saved_products_user_id_idx" + ON "saved_products"("user_id"); + +CREATE UNIQUE INDEX IF NOT EXISTS "saved_products_user_id_product_id_key" + ON "saved_products"("user_id", "product_id"); + +CREATE UNIQUE INDEX IF NOT EXISTS "merchant_slug_history_old_slug_key" + ON "merchant_slug_history"("old_slug"); + +CREATE INDEX IF NOT EXISTS "merchant_slug_history_merchant_profile_id_idx" + ON "merchant_slug_history"("merchant_profile_id"); + +CREATE UNIQUE INDEX IF NOT EXISTS "whatsapp_supplier_links_phone_key" + ON "whatsapp_supplier_links"("phone"); + +CREATE UNIQUE INDEX IF NOT EXISTS "whatsapp_supplier_links_supplier_id_key" + ON "whatsapp_supplier_links"("supplier_id"); + +CREATE INDEX IF NOT EXISTS "follows_follower_id_idx" + ON "follows"("follower_id"); + +CREATE INDEX IF NOT EXISTS "follows_merchant_id_idx" + ON "follows"("merchant_id"); + +CREATE UNIQUE INDEX IF NOT EXISTS "follows_follower_id_merchant_id_key" + ON "follows"("follower_id", "merchant_id"); + +CREATE UNIQUE INDEX IF NOT EXISTS "merchant_waitlists_email_key" + ON "merchant_waitlists"("email"); + +-- --------------------------------------------------------------------------- +-- Foreign keys +-- --------------------------------------------------------------------------- +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'saved_products_product_id_fkey' + ) THEN + ALTER TABLE "saved_products" + ADD CONSTRAINT "saved_products_product_id_fkey" + FOREIGN KEY ("product_id") REFERENCES "products"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'saved_products_user_id_fkey' + ) THEN + ALTER TABLE "saved_products" + ADD CONSTRAINT "saved_products_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'merchant_slug_history_merchant_profile_id_fkey' + ) THEN + ALTER TABLE "merchant_slug_history" + ADD CONSTRAINT "merchant_slug_history_merchant_profile_id_fkey" + FOREIGN KEY ("merchant_profile_id") REFERENCES "merchant_profiles"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'whatsapp_supplier_links_supplier_id_fkey' + ) THEN + ALTER TABLE "whatsapp_supplier_links" + ADD CONSTRAINT "whatsapp_supplier_links_supplier_id_fkey" + FOREIGN KEY ("supplier_id") REFERENCES "supplier_profiles"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'follows_follower_id_fkey' + ) THEN + ALTER TABLE "follows" + ADD CONSTRAINT "follows_follower_id_fkey" + FOREIGN KEY ("follower_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'follows_merchant_id_fkey' + ) THEN + ALTER TABLE "follows" + ADD CONSTRAINT "follows_merchant_id_fkey" + FOREIGN KEY ("merchant_id") REFERENCES "merchant_profiles"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; diff --git a/apps/backend/prisma/migrations/20260509003000_add_ledger_entries/migration.sql b/apps/backend/prisma/migrations/20260509003000_add_ledger_entries/migration.sql new file mode 100644 index 00000000..405c4c3e --- /dev/null +++ b/apps/backend/prisma/migrations/20260509003000_add_ledger_entries/migration.sql @@ -0,0 +1,85 @@ +-- Durable money ledger entries. +-- This is additive and append-only. It does not alter existing money tables. + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'LedgerEntryType') THEN + CREATE TYPE "LedgerEntryType" AS ENUM ( + 'CHECKOUT_CREATED', + 'PAYMENT_INITIALIZED', + 'PAYMENT_RECEIVED', + 'PLATFORM_FEE_ASSESSED', + 'ESCROW_HELD', + 'PAYOUT_INITIATED', + 'PAYOUT_FAILED', + 'PAYOUT_COMPLETED', + 'REFUND_INITIATED', + 'REFUND_COMPLETED' + ); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'LedgerDirection') THEN + CREATE TYPE "LedgerDirection" AS ENUM ( + 'CREDIT', + 'DEBIT', + 'HOLD', + 'RELEASE', + 'INFO' + ); + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS "ledger_entries" ( + "id" UUID NOT NULL, + "entry_type" "LedgerEntryType" NOT NULL, + "direction" "LedgerDirection" NOT NULL, + "amount_kobo" BIGINT NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'NGN', + "order_id" UUID, + "payment_id" UUID, + "payout_id" UUID, + "merchant_id" UUID, + "user_id" UUID, + "reference" TEXT, + "metadata" JSONB DEFAULT '{}', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "ledger_entries_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "ledger_entries_order_id_idx" ON "ledger_entries"("order_id"); +CREATE INDEX IF NOT EXISTS "ledger_entries_payment_id_idx" ON "ledger_entries"("payment_id"); +CREATE INDEX IF NOT EXISTS "ledger_entries_payout_id_idx" ON "ledger_entries"("payout_id"); +CREATE INDEX IF NOT EXISTS "ledger_entries_merchant_id_idx" ON "ledger_entries"("merchant_id"); +CREATE INDEX IF NOT EXISTS "ledger_entries_user_id_idx" ON "ledger_entries"("user_id"); +CREATE INDEX IF NOT EXISTS "ledger_entries_entry_type_idx" ON "ledger_entries"("entry_type"); +CREATE INDEX IF NOT EXISTS "ledger_entries_created_at_idx" ON "ledger_entries"("created_at"); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'ledger_entries_order_id_fkey' + ) THEN + ALTER TABLE "ledger_entries" + ADD CONSTRAINT "ledger_entries_order_id_fkey" + FOREIGN KEY ("order_id") REFERENCES "orders"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'ledger_entries_payment_id_fkey' + ) THEN + ALTER TABLE "ledger_entries" + ADD CONSTRAINT "ledger_entries_payment_id_fkey" + FOREIGN KEY ("payment_id") REFERENCES "payments"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'ledger_entries_payout_id_fkey' + ) THEN + ALTER TABLE "ledger_entries" + ADD CONSTRAINT "ledger_entries_payout_id_fkey" + FOREIGN KEY ("payout_id") REFERENCES "payouts"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; diff --git a/apps/backend/prisma/migrations/20260509004500_add_ledger_idempotency_key/migration.sql b/apps/backend/prisma/migrations/20260509004500_add_ledger_idempotency_key/migration.sql new file mode 100644 index 00000000..359592ce --- /dev/null +++ b/apps/backend/prisma/migrations/20260509004500_add_ledger_idempotency_key/migration.sql @@ -0,0 +1,15 @@ +-- Make ledger writes retry-safe. Existing rows may keep NULL keys; all new +-- writes should provide a deterministic idempotency key. + +ALTER TABLE "ledger_entries" + ADD COLUMN IF NOT EXISTS "idempotency_key" TEXT; + +UPDATE "ledger_entries" +SET "idempotency_key" = 'legacy:' || "id"::text +WHERE "idempotency_key" IS NULL; + +ALTER TABLE "ledger_entries" + ALTER COLUMN "idempotency_key" SET NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS "ledger_entries_idempotency_key_key" + ON "ledger_entries"("idempotency_key"); diff --git a/apps/backend/prisma/migrations/20260509022000_add_webhook_events/migration.sql b/apps/backend/prisma/migrations/20260509022000_add_webhook_events/migration.sql new file mode 100644 index 00000000..aad182c5 --- /dev/null +++ b/apps/backend/prisma/migrations/20260509022000_add_webhook_events/migration.sql @@ -0,0 +1,28 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'WebhookStatus') THEN + CREATE TYPE "WebhookStatus" AS ENUM ('PROCESSING', 'PROCESSED', 'FAILED'); + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS "webhook_events" ( + "id" UUID NOT NULL, + "provider" TEXT NOT NULL, + "event_id" TEXT NOT NULL, + "event_type" TEXT NOT NULL, + "reference" TEXT, + "status" "WebhookStatus" NOT NULL DEFAULT 'PROCESSING', + "payload" JSONB NOT NULL, + "error" TEXT, + "retry_count" INTEGER NOT NULL DEFAULT 0, + "received_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "processed_at" TIMESTAMP(3), + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "webhook_events_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "webhook_events_provider_event_id_key" ON "webhook_events"("provider", "event_id"); +CREATE INDEX IF NOT EXISTS "webhook_events_provider_event_type_idx" ON "webhook_events"("provider", "event_type"); +CREATE INDEX IF NOT EXISTS "webhook_events_provider_status_idx" ON "webhook_events"("provider", "status"); +CREATE INDEX IF NOT EXISTS "webhook_events_reference_idx" ON "webhook_events"("reference"); diff --git a/apps/backend/prisma/migrations/20260509023000_add_payout_active_guard/migration.sql b/apps/backend/prisma/migrations/20260509023000_add_payout_active_guard/migration.sql new file mode 100644 index 00000000..6f967d5c --- /dev/null +++ b/apps/backend/prisma/migrations/20260509023000_add_payout_active_guard/migration.sql @@ -0,0 +1,7 @@ +-- Prevent duplicate active payout processing for the same order. +-- Failed and cancelled payouts may coexist for audit/history, but only one +-- processing or completed payout should exist per order. + +CREATE UNIQUE INDEX IF NOT EXISTS "payouts_one_active_per_order_key" + ON "payouts"("order_id") + WHERE "status" IN ('PROCESSING', 'COMPLETED'); diff --git a/apps/backend/prisma/migrations/20260509024000_drop_redundant_payout_active_guard/migration.sql b/apps/backend/prisma/migrations/20260509024000_drop_redundant_payout_active_guard/migration.sql new file mode 100644 index 00000000..88f88b5d --- /dev/null +++ b/apps/backend/prisma/migrations/20260509024000_drop_redundant_payout_active_guard/migration.sql @@ -0,0 +1,5 @@ +-- The payouts table already has a full unique index on order_id from the +-- original escrow migration. Drop the later partial index to avoid redundant +-- write overhead while keeping migration history append-only. + +DROP INDEX IF EXISTS "payouts_one_active_per_order_key"; diff --git a/apps/backend/prisma/migrations/20260518183000_cleanup_mvp_schema_store_refactor/migration.sql b/apps/backend/prisma/migrations/20260518183000_cleanup_mvp_schema_store_refactor/migration.sql new file mode 100644 index 00000000..bf0c3c32 --- /dev/null +++ b/apps/backend/prisma/migrations/20260518183000_cleanup_mvp_schema_store_refactor/migration.sql @@ -0,0 +1,274 @@ +-- MVP cleanup: +-- - Preserve merchant/store data by renaming merchant tables/columns to store. +-- - Remove legacy B2B/supplier/RFQ/reorder/review/BNPL tables. +-- - Collapse public account roles to USER plus internal staff roles. +-- - Move Dedicated Virtual Account fields from buyer_profiles onto users. + +-- Copy buyer DVA fields to users before buyer_profiles is removed. +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "paystack_customer_id" TEXT, + ADD COLUMN IF NOT EXISTS "paystack_customer_code" TEXT, + ADD COLUMN IF NOT EXISTS "dva_account_number" TEXT, + ADD COLUMN IF NOT EXISTS "dva_account_name" TEXT, + ADD COLUMN IF NOT EXISTS "dva_bank_name" TEXT, + ADD COLUMN IF NOT EXISTS "dva_bank_slug" TEXT, + ADD COLUMN IF NOT EXISTS "dva_active" BOOLEAN NOT NULL DEFAULT false; + +UPDATE "users" u +SET + "paystack_customer_id" = COALESCE(u."paystack_customer_id", bp."paystack_customer_id"), + "paystack_customer_code" = COALESCE(u."paystack_customer_code", bp."paystack_customer_code"), + "dva_account_number" = COALESCE(u."dva_account_number", bp."dva_account_number"), + "dva_account_name" = COALESCE(u."dva_account_name", bp."dva_account_name"), + "dva_bank_name" = COALESCE(u."dva_bank_name", bp."dva_bank_name"), + "dva_bank_slug" = COALESCE(u."dva_bank_slug", bp."dva_bank_slug"), + "dva_active" = COALESCE(bp."dva_active", u."dva_active") +FROM "buyer_profiles" bp +WHERE bp."user_id" = u."id"; + +CREATE UNIQUE INDEX IF NOT EXISTS "users_paystack_customer_code_key" + ON "users"("paystack_customer_code") + WHERE "paystack_customer_code" IS NOT NULL; + +-- Enum-backed legacy labels are mapped while rebuilding each enum below. + +-- Drop legacy tables before table/column renames so old foreign keys do not block cleanup. +DROP TABLE IF EXISTS "shared_quotes" CASCADE; +DROP TABLE IF EXISTS "quotes" CASCADE; +DROP TABLE IF EXISTS "rfqs" CASCADE; +DROP TABLE IF EXISTS "reviews" CASCADE; +DROP TABLE IF EXISTS "reorder_reminders" CASCADE; +DROP TABLE IF EXISTS "credit_applications" CASCADE; +DROP TABLE IF EXISTS "bnpl_waitlists" CASCADE; +DROP TABLE IF EXISTS "whatsapp_buyer_links" CASCADE; +DROP TABLE IF EXISTS "whatsapp_supplier_links" CASCADE; +DROP TABLE IF EXISTS "supplier_products" CASCADE; +DROP TABLE IF EXISTS "supplier_profiles" CASCADE; +DROP TABLE IF EXISTS "merchant_waitlists" CASCADE; +DROP TABLE IF EXISTS "buyer_profiles" CASCADE; + +-- Remove legacy supplier columns/checks from orders. +ALTER TABLE "orders" DROP CONSTRAINT IF EXISTS "order_source_check"; +ALTER TABLE "orders" DROP CONSTRAINT IF EXISTS "orders_supplier_id_fkey"; +ALTER TABLE "orders" DROP CONSTRAINT IF EXISTS "orders_supplier_product_id_fkey"; +ALTER TABLE "orders" DROP COLUMN IF EXISTS "supplier_id"; +ALTER TABLE "orders" DROP COLUMN IF EXISTS "supplier_product_id"; + +-- Rename merchant tables to store tables without dropping data. +ALTER TABLE IF EXISTS "merchant_profiles" RENAME TO "store_profiles"; +ALTER TABLE IF EXISTS "merchant_slug_history" RENAME TO "store_slug_history"; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'store_slug_history' AND column_name = 'merchant_profile_id' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'store_slug_history' AND column_name = 'store_profile_id' + ) THEN + ALTER TABLE "store_slug_history" RENAME COLUMN "merchant_profile_id" TO "store_profile_id"; + END IF; +END $$; + +-- Rename merchant_id columns to store_id across current MVP tables. +DO $$ +DECLARE + rename_pairs text[][] := ARRAY[ + ARRAY['products', 'merchant_id', 'store_id'], + ARRAY['orders', 'merchant_id', 'store_id'], + ARRAY['inventory_events', 'merchant_id', 'store_id'], + ARRAY['payouts', 'merchant_id', 'store_id'], + ARRAY['payout_requests', 'merchant_id', 'store_id'], + ARRAY['verification_requests', 'merchant_id', 'store_id'], + ARRAY['ledger_entries', 'merchant_id', 'store_id'], + ARRAY['follows', 'merchant_id', 'store_id'] + ]; + pair text[]; +BEGIN + FOREACH pair SLICE 1 IN ARRAY rename_pairs LOOP + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = pair[1] AND column_name = pair[2] + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = pair[1] AND column_name = pair[3] + ) THEN + EXECUTE format('ALTER TABLE %I RENAME COLUMN %I TO %I', pair[1], pair[2], pair[3]); + END IF; + END LOOP; +END $$; + +-- Rebuild enums to remove legacy labels. +CREATE TYPE "UserRole_new" AS ENUM ('USER', 'SUPER_ADMIN', 'OPERATOR', 'SUPPORT'); +ALTER TABLE "users" + ALTER COLUMN "role" TYPE "UserRole_new" + USING ( + CASE + WHEN "role"::text IN ('BUYER', 'MERCHANT', 'SUPPLIER') THEN 'USER' + ELSE "role"::text + END + )::"UserRole_new"; +ALTER TABLE "staff_access_tokens" + ALTER COLUMN "role" TYPE "UserRole_new" + USING ( + CASE + WHEN "role"::text IN ('BUYER', 'MERCHANT', 'SUPPLIER') THEN 'USER' + ELSE "role"::text + END + )::"UserRole_new"; +DROP TYPE "UserRole"; +ALTER TYPE "UserRole_new" RENAME TO "UserRole"; + +CREATE TYPE "OrderDisputeStatus_new" AS ENUM ('NONE', 'PENDING', 'RESOLVED', 'RESOLVED_SHOPPER', 'RESOLVED_STORE'); +ALTER TABLE "orders" + ALTER COLUMN "dispute_status" DROP DEFAULT, + ALTER COLUMN "dispute_status" TYPE "OrderDisputeStatus_new" + USING ( + CASE + WHEN "dispute_status"::text = 'RESOLVED_BUYER' THEN 'RESOLVED_SHOPPER' + WHEN "dispute_status"::text = 'RESOLVED_MERCHANT' THEN 'RESOLVED_STORE' + ELSE "dispute_status"::text + END + )::"OrderDisputeStatus_new", + ALTER COLUMN "dispute_status" SET DEFAULT 'NONE'; +DROP TYPE "OrderDisputeStatus"; +ALTER TYPE "OrderDisputeStatus_new" RENAME TO "OrderDisputeStatus"; + +CREATE TYPE "VerificationTier_new" AS ENUM ('UNVERIFIED', 'TIER_1', 'TIER_2', 'TIER_3'); +ALTER TABLE "store_profiles" + ALTER COLUMN "verification_tier" DROP DEFAULT, + ALTER COLUMN "verification_tier" TYPE "VerificationTier_new" + USING ( + CASE + WHEN "verification_tier"::text = 'BASIC' THEN 'TIER_1' + WHEN "verification_tier"::text = 'VERIFIED' THEN 'TIER_2' + WHEN "verification_tier"::text = 'TRUSTED' THEN 'TIER_3' + ELSE "verification_tier"::text + END + )::"VerificationTier_new", + ALTER COLUMN "verification_tier" SET DEFAULT 'UNVERIFIED'; +ALTER TABLE "verification_requests" + ALTER COLUMN "target_tier" DROP DEFAULT, + ALTER COLUMN "target_tier" TYPE "VerificationTier_new" + USING ( + CASE + WHEN "target_tier"::text = 'BASIC' THEN 'TIER_1' + WHEN "target_tier"::text = 'VERIFIED' THEN 'TIER_2' + WHEN "target_tier"::text = 'TRUSTED' THEN 'TIER_3' + ELSE "target_tier"::text + END + )::"VerificationTier_new", + ALTER COLUMN "target_tier" SET DEFAULT 'TIER_2'; +DROP TYPE "VerificationTier"; +ALTER TYPE "VerificationTier_new" RENAME TO "VerificationTier"; + +CREATE TYPE "DeliveryMethod_new" AS ENUM ('STORE_DELIVERY', 'PLATFORM_LOGISTICS'); +ALTER TABLE "orders" + ALTER COLUMN "delivery_method" TYPE "DeliveryMethod_new" + USING ( + CASE + WHEN "delivery_method"::text = 'MERCHANT_DELIVERY' THEN 'STORE_DELIVERY' + ELSE "delivery_method"::text + END + )::"DeliveryMethod_new"; +ALTER TABLE "delivery_bookings" + ALTER COLUMN "method" TYPE "DeliveryMethod_new" + USING ( + CASE + WHEN "method"::text = 'MERCHANT_DELIVERY' THEN 'STORE_DELIVERY' + ELSE "method"::text + END + )::"DeliveryMethod_new"; +DROP TYPE "DeliveryMethod"; +ALTER TYPE "DeliveryMethod_new" RENAME TO "DeliveryMethod"; + +DROP TYPE IF EXISTS "CreditStatus"; +DROP TYPE IF EXISTS "ReorderReminderStatus"; +DROP TYPE IF EXISTS "RFQStatus"; +DROP TYPE IF EXISTS "QuoteStatus"; +DROP TYPE IF EXISTS "SharedQuoteStatus"; + +-- Replace old merchant-named indexes with store-named indexes. +DROP INDEX IF EXISTS "products_merchant_id_idx"; +DROP INDEX IF EXISTS "orders_merchant_id_idx"; +DROP INDEX IF EXISTS "orders_merchant_id_status_idx"; +DROP INDEX IF EXISTS "inventory_events_merchant_id_idx"; +DROP INDEX IF EXISTS "payout_requests_merchant_id_idx"; +DROP INDEX IF EXISTS "verification_requests_merchant_id_idx"; +DROP INDEX IF EXISTS "ledger_entries_merchant_id_idx"; +DROP INDEX IF EXISTS "follows_merchant_id_idx"; +DROP INDEX IF EXISTS "follows_follower_id_merchant_id_key"; +DROP INDEX IF EXISTS "merchant_slug_history_old_slug_key"; +DROP INDEX IF EXISTS "merchant_slug_history_merchant_profile_id_idx"; +DROP INDEX IF EXISTS "merchant_profiles_slug_idx"; + +CREATE INDEX IF NOT EXISTS "products_store_id_idx" ON "products"("store_id"); +CREATE INDEX IF NOT EXISTS "orders_store_id_idx" ON "orders"("store_id"); +CREATE INDEX IF NOT EXISTS "orders_store_id_status_idx" ON "orders"("store_id", "status"); +CREATE INDEX IF NOT EXISTS "inventory_events_store_id_idx" ON "inventory_events"("store_id"); +CREATE INDEX IF NOT EXISTS "payout_requests_store_id_idx" ON "payout_requests"("store_id"); +CREATE INDEX IF NOT EXISTS "verification_requests_store_id_idx" ON "verification_requests"("store_id"); +CREATE INDEX IF NOT EXISTS "ledger_entries_store_id_idx" ON "ledger_entries"("store_id"); +CREATE INDEX IF NOT EXISTS "follows_store_id_idx" ON "follows"("store_id"); +CREATE UNIQUE INDEX IF NOT EXISTS "follows_follower_id_store_id_key" ON "follows"("follower_id", "store_id"); +CREATE UNIQUE INDEX IF NOT EXISTS "store_slug_history_old_slug_key" ON "store_slug_history"("old_slug"); +CREATE INDEX IF NOT EXISTS "store_slug_history_store_profile_id_idx" ON "store_slug_history"("store_profile_id"); +CREATE INDEX IF NOT EXISTS "store_profiles_slug_idx" ON "store_profiles"("slug"); + +-- Recreate foreign keys with store naming. +ALTER TABLE "products" DROP CONSTRAINT IF EXISTS "products_merchant_id_fkey"; +ALTER TABLE "products" DROP CONSTRAINT IF EXISTS "products_store_id_fkey"; +ALTER TABLE "products" + ADD CONSTRAINT "products_store_id_fkey" + FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "orders" DROP CONSTRAINT IF EXISTS "orders_merchant_id_fkey"; +ALTER TABLE "orders" DROP CONSTRAINT IF EXISTS "orders_store_id_fkey"; +ALTER TABLE "orders" + ADD CONSTRAINT "orders_store_id_fkey" + FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "inventory_events" DROP CONSTRAINT IF EXISTS "inventory_events_merchant_id_fkey"; +ALTER TABLE "inventory_events" DROP CONSTRAINT IF EXISTS "inventory_events_store_id_fkey"; +ALTER TABLE "inventory_events" + ADD CONSTRAINT "inventory_events_store_id_fkey" + FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "payouts" DROP CONSTRAINT IF EXISTS "payouts_merchant_id_fkey"; +ALTER TABLE "payouts" DROP CONSTRAINT IF EXISTS "payouts_store_id_fkey"; +ALTER TABLE "payouts" + ADD CONSTRAINT "payouts_store_id_fkey" + FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "payout_requests" DROP CONSTRAINT IF EXISTS "payout_requests_merchant_id_fkey"; +ALTER TABLE "payout_requests" DROP CONSTRAINT IF EXISTS "payout_requests_store_id_fkey"; +ALTER TABLE "payout_requests" + ADD CONSTRAINT "payout_requests_store_id_fkey" + FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "verification_requests" DROP CONSTRAINT IF EXISTS "verification_requests_merchant_id_fkey"; +ALTER TABLE "verification_requests" DROP CONSTRAINT IF EXISTS "verification_requests_store_id_fkey"; +ALTER TABLE "verification_requests" + ADD CONSTRAINT "verification_requests_store_id_fkey" + FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "follows" DROP CONSTRAINT IF EXISTS "follows_merchant_id_fkey"; +ALTER TABLE "follows" DROP CONSTRAINT IF EXISTS "follows_store_id_fkey"; +ALTER TABLE "follows" + ADD CONSTRAINT "follows_store_id_fkey" + FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "store_slug_history" DROP CONSTRAINT IF EXISTS "merchant_slug_history_merchant_profile_id_fkey"; +ALTER TABLE "store_slug_history" DROP CONSTRAINT IF EXISTS "store_slug_history_store_profile_id_fkey"; +ALTER TABLE "store_slug_history" + ADD CONSTRAINT "store_slug_history_store_profile_id_fkey" + FOREIGN KEY ("store_profile_id") REFERENCES "store_profiles"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260519090000_remove_non_mvp_contradictions/migration.sql b/apps/backend/prisma/migrations/20260519090000_remove_non_mvp_contradictions/migration.sql new file mode 100644 index 00000000..eedfb455 --- /dev/null +++ b/apps/backend/prisma/migrations/20260519090000_remove_non_mvp_contradictions/migration.sql @@ -0,0 +1,44 @@ +-- Remove non-MVP payment and verification paths. +-- All orders are escrow-protected in MVP, and Tier 3/CAC is deferred. + +UPDATE "store_profiles" +SET "verification_tier" = 'TIER_2' +WHERE "verification_tier" = 'TIER_3'; + +UPDATE "verification_requests" +SET "target_tier" = 'TIER_2' +WHERE "target_tier" = 'TIER_3'; + +ALTER TABLE "orders" DROP COLUMN IF EXISTS "payment_method"; +DROP TYPE IF EXISTS "PaymentMethod"; + +ALTER TABLE "store_profiles" DROP COLUMN IF EXISTS "cac_number"; +ALTER TABLE "store_profiles" DROP COLUMN IF EXISTS "cac_document_url"; +ALTER TABLE "store_profiles" DROP COLUMN IF EXISTS "cac_verified"; +ALTER TABLE "store_profiles" DROP COLUMN IF EXISTS "cac_verified_via"; +ALTER TABLE "verification_requests" DROP COLUMN IF EXISTS "cac_cert_url"; +ALTER TABLE "verification_requests" DROP COLUMN IF EXISTS "proof_of_address_url"; + +ALTER TABLE "store_profiles" ALTER COLUMN "verification_tier" DROP DEFAULT; +ALTER TABLE "verification_requests" ALTER COLUMN "target_tier" DROP DEFAULT; + +ALTER TYPE "VerificationTier" RENAME TO "VerificationTier_old"; +CREATE TYPE "VerificationTier" AS ENUM ('UNVERIFIED', 'TIER_1', 'TIER_2'); + +ALTER TABLE "store_profiles" + ALTER COLUMN "verification_tier" + TYPE "VerificationTier" + USING "verification_tier"::text::"VerificationTier"; + +ALTER TABLE "verification_requests" + ALTER COLUMN "target_tier" + TYPE "VerificationTier" + USING "target_tier"::text::"VerificationTier"; + +ALTER TABLE "store_profiles" + ALTER COLUMN "verification_tier" SET DEFAULT 'UNVERIFIED'; + +ALTER TABLE "verification_requests" + ALTER COLUMN "target_tier" SET DEFAULT 'TIER_2'; + +DROP TYPE "VerificationTier_old"; diff --git a/apps/backend/prisma/migrations/20260521190000_mvp_schema_baseline/migration.sql b/apps/backend/prisma/migrations/20260521190000_mvp_schema_baseline/migration.sql new file mode 100644 index 00000000..e5b48c7b --- /dev/null +++ b/apps/backend/prisma/migrations/20260521190000_mvp_schema_baseline/migration.sql @@ -0,0 +1,1164 @@ +-- Required for ProductEmbedding.embedding vector(1408). +CREATE EXTENSION IF NOT EXISTS vector; + +-- CreateEnum +CREATE TYPE "StoreType" AS ENUM ('DIGITAL', 'PHYSICAL'); + +-- CreateEnum +CREATE TYPE "StoreTier" AS ENUM ('TIER_0', 'TIER_1', 'TIER_2'); + +-- CreateEnum +CREATE TYPE "ProductStatus" AS ENUM ('DRAFT', 'ACTIVE', 'HIDDEN', 'OUT_OF_STOCK', 'DELETED'); + +-- CreateEnum +CREATE TYPE "PostType" AS ENUM ('PRODUCT_POST', 'IMAGE_POST', 'GIST', 'TWIZZ'); + +-- CreateEnum +CREATE TYPE "ModerationStatus" AS ENUM ('PENDING', 'SAFE', 'SENSITIVE', 'BLOCKED', 'APPROVED', 'REJECTED'); + +-- CreateEnum +CREATE TYPE "NotificationType" AS ENUM ('ORDER_PAID', 'ORDER_DISPATCHED', 'ORDER_COMPLETED', 'POST_LIKED', 'POST_COMMENTED', 'COMMENT_REPLIED', 'USER_MENTIONED_IN_POST', 'USER_MENTIONED_IN_COMMENT', 'NEW_FOLLOWER', 'TAGGED_PRODUCT_SOLD_VIA_POST', 'PAYOUT_SENT', 'PAYOUT_FAILED', 'STORE_NOTIFICATION_NEW_POST', 'MODERATION_BLOCKED', 'STORE_TIER_UPGRADED'); + +-- CreateEnum +CREATE TYPE "VerificationStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'FAILED'); + +-- CreateEnum +CREATE TYPE "VerificationType" AS ENUM ('TIER_1_AUTO', 'NIN', 'ADDRESS', 'BANK'); + +-- CreateEnum +CREATE TYPE "GuideType" AS ENUM ('CLOTHING_WOMEN', 'CLOTHING_MEN', 'CLOTHING_CHILDREN', 'CLOTHING_UNISEX', 'FOOTWEAR_WOMEN', 'FOOTWEAR_MEN', 'FOOTWEAR_CHILDREN'); + +-- CreateEnum +CREATE TYPE "OTPPurpose" AS ENUM ('EMAIL_VERIFY', 'PHONE_VERIFY', 'PASSWORD_RESET'); + +-- CreateEnum +CREATE TYPE "FollowTargetType" AS ENUM ('USER', 'STORE'); + +-- CreateEnum +CREATE TYPE "DVAStatus" AS ENUM ('ACTIVE', 'PAID', 'EXPIRED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "SupportStatus" AS ENUM ('OPEN', 'IN_REVIEW', 'RESOLVED', 'CLOSED'); + +-- CreateEnum +CREATE TYPE "ReconciliationStatus" AS ENUM ('BALANCED', 'DISCREPANCY', 'INVESTIGATING', 'RESOLVED'); + +-- DropForeignKey +ALTER TABLE "admin_profiles" DROP CONSTRAINT "admin_profiles_user_id_fkey"; + +-- DropForeignKey +ALTER TABLE "audit_logs" DROP CONSTRAINT "audit_logs_user_id_fkey"; + +-- DropForeignKey +ALTER TABLE "cart_items" DROP CONSTRAINT "cart_items_buyer_id_fkey"; + +-- DropForeignKey +ALTER TABLE "cart_items" DROP CONSTRAINT "cart_items_product_id_fkey"; + +-- DropForeignKey +ALTER TABLE "categories" DROP CONSTRAINT "categories_parent_id_fkey"; + +-- DropForeignKey +ALTER TABLE "delivery_bookings" DROP CONSTRAINT "delivery_bookings_order_id_fkey"; + +-- DropForeignKey +ALTER TABLE "follows" DROP CONSTRAINT "follows_follower_id_fkey"; + +-- DropForeignKey +ALTER TABLE "follows" DROP CONSTRAINT "follows_store_id_fkey"; + +-- DropForeignKey +ALTER TABLE "inventory_events" DROP CONSTRAINT "inventory_events_product_id_fkey"; + +-- DropForeignKey +ALTER TABLE "inventory_events" DROP CONSTRAINT "inventory_events_store_id_fkey"; + +-- DropForeignKey +ALTER TABLE "ledger_entries" DROP CONSTRAINT "ledger_entries_order_id_fkey"; + +-- DropForeignKey +ALTER TABLE "ledger_entries" DROP CONSTRAINT "ledger_entries_payment_id_fkey"; + +-- DropForeignKey +ALTER TABLE "ledger_entries" DROP CONSTRAINT "ledger_entries_payout_id_fkey"; + +-- DropForeignKey +ALTER TABLE "notifications" DROP CONSTRAINT "notifications_user_id_fkey"; + +-- DropForeignKey +ALTER TABLE "order_events" DROP CONSTRAINT "order_events_order_id_fkey"; + +-- DropForeignKey +ALTER TABLE "order_events" DROP CONSTRAINT "order_events_triggered_by_fkey"; + +-- DropForeignKey +ALTER TABLE "order_tracking" DROP CONSTRAINT "order_tracking_order_id_fkey"; + +-- DropForeignKey +ALTER TABLE "orders" DROP CONSTRAINT "orders_buyer_id_fkey"; + +-- DropForeignKey +ALTER TABLE "orders" DROP CONSTRAINT "orders_product_id_fkey"; + +-- DropForeignKey +ALTER TABLE "orders" DROP CONSTRAINT "orders_store_id_fkey"; + +-- DropForeignKey +ALTER TABLE "payment_events" DROP CONSTRAINT "payment_events_payment_id_fkey"; + +-- DropForeignKey +ALTER TABLE "payments" DROP CONSTRAINT "payments_order_id_fkey"; + +-- DropForeignKey +ALTER TABLE "payout_requests" DROP CONSTRAINT "payout_requests_store_id_fkey"; + +-- DropForeignKey +ALTER TABLE "payouts" DROP CONSTRAINT "payouts_order_id_fkey"; + +-- DropForeignKey +ALTER TABLE "payouts" DROP CONSTRAINT "payouts_store_id_fkey"; + +-- DropForeignKey +ALTER TABLE "product_stock_cache" DROP CONSTRAINT "product_stock_cache_product_id_fkey"; + +-- DropForeignKey +ALTER TABLE "products" DROP CONSTRAINT "products_category_id_fkey"; + +-- DropForeignKey +ALTER TABLE "products" DROP CONSTRAINT "products_store_id_fkey"; + +-- DropForeignKey +ALTER TABLE "saved_products" DROP CONSTRAINT "saved_products_product_id_fkey"; + +-- DropForeignKey +ALTER TABLE "saved_products" DROP CONSTRAINT "saved_products_user_id_fkey"; + +-- DropForeignKey +ALTER TABLE "staff_access_tokens" DROP CONSTRAINT "staff_access_tokens_created_by_fkey"; + +-- DropForeignKey +ALTER TABLE "store_profiles" DROP CONSTRAINT "merchant_profiles_user_id_fkey"; + +-- DropForeignKey +ALTER TABLE "store_slug_history" DROP CONSTRAINT "store_slug_history_store_profile_id_fkey"; + +-- DropForeignKey +ALTER TABLE "verification_requests" DROP CONSTRAINT "verification_requests_reviewed_by_fkey"; + +-- DropForeignKey +ALTER TABLE "verification_requests" DROP CONSTRAINT "verification_requests_store_id_fkey"; + +-- DropForeignKey +ALTER TABLE "whatsapp_links" DROP CONSTRAINT "whatsapp_links_user_id_fkey"; + +-- AlterTable +ALTER TABLE "admin_profiles" DROP CONSTRAINT "admin_profiles_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "user_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "admin_profiles_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "audit_logs" DROP CONSTRAINT "audit_logs_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "user_id" SET DATA TYPE TEXT, +ALTER COLUMN "target_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "cart_items" DROP CONSTRAINT "cart_items_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "buyer_id" SET DATA TYPE TEXT, +ALTER COLUMN "product_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "cart_items_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "categories" DROP CONSTRAINT "categories_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "parent_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "categories_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "delivery_bookings" DROP CONSTRAINT "delivery_bookings_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "order_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "delivery_bookings_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "follows" DROP CONSTRAINT "follows_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "follower_id" SET DATA TYPE TEXT, +ALTER COLUMN "store_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "follows_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "inventory_events" DROP CONSTRAINT "inventory_events_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "product_id" SET DATA TYPE TEXT, +ALTER COLUMN "store_id" SET DATA TYPE TEXT, +ALTER COLUMN "reference_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "inventory_events_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "ledger_entries" DROP CONSTRAINT "ledger_entries_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "order_id" SET DATA TYPE TEXT, +ALTER COLUMN "payment_id" SET DATA TYPE TEXT, +ALTER COLUMN "payout_id" SET DATA TYPE TEXT, +ALTER COLUMN "store_id" SET DATA TYPE TEXT, +ALTER COLUMN "user_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "ledger_entries_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "notifications" DROP CONSTRAINT "notifications_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "user_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "notifications_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "onboarding_sessions" DROP CONSTRAINT "onboarding_sessions_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ADD CONSTRAINT "onboarding_sessions_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "order_events" DROP CONSTRAINT "order_events_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "order_id" SET DATA TYPE TEXT, +ALTER COLUMN "triggered_by" SET DATA TYPE TEXT, +ADD CONSTRAINT "order_events_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "order_tracking" DROP CONSTRAINT "order_tracking_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "order_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "order_tracking_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "orders" DROP CONSTRAINT "orders_pkey", +ADD COLUMN "order_code" TEXT, +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "quote_id" SET DATA TYPE TEXT, +ALTER COLUMN "buyer_id" SET DATA TYPE TEXT, +ALTER COLUMN "store_id" SET DATA TYPE TEXT, +ALTER COLUMN "product_id" SET DATA TYPE TEXT, +ALTER COLUMN "platform_fee_percent" SET DATA TYPE INTEGER, +ADD CONSTRAINT "orders_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "payment_events" DROP CONSTRAINT "payment_events_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "payment_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "payment_events_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "payments" DROP CONSTRAINT "payments_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "order_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "payments_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "payout_requests" DROP CONSTRAINT "payout_requests_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "store_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "payout_requests_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "payouts" DROP CONSTRAINT "payouts_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "order_id" SET DATA TYPE TEXT, +ALTER COLUMN "store_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "payouts_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "product_associations" DROP CONSTRAINT "product_associations_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "strength" SET DATA TYPE INTEGER, +ADD CONSTRAINT "product_associations_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "product_stock_cache" DROP CONSTRAINT "product_stock_cache_pkey", +ADD COLUMN "low_stock_threshold" INTEGER NOT NULL DEFAULT 5, +ADD COLUMN "quantity" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "variant_id" TEXT, +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "product_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "product_stock_cache_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "products" DROP CONSTRAINT "products_pkey", +ADD COLUMN "allow_dropship" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "compare_at_price_kobo" BIGINT, +ADD COLUMN "dropshipper_price_kobo" BIGINT, +ADD COLUMN "embedding_ready" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "embedding_updated_at" TIMESTAMP(3), +ADD COLUMN "has_variants" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "hide_when_out_of_stock" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "is_featured" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "minimum_order_qty" INTEGER, +ADD COLUMN "moderation_status" "ModerationStatus" NOT NULL DEFAULT 'PENDING', +ADD COLUMN "not_sold_individually" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "platform_category" TEXT, +ADD COLUMN "product_details" JSONB, +ADD COLUMN "product_sub_category" "GuideType", +ADD COLUMN "sku" TEXT, +ADD COLUMN "status" "ProductStatus" NOT NULL DEFAULT 'DRAFT', +ADD COLUMN "store_tags" TEXT[] DEFAULT ARRAY[]::TEXT[], +ADD COLUMN "title" TEXT, +ADD COLUMN "view_count_weekly" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "wholesale_tiers" JSONB, +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "store_id" SET DATA TYPE TEXT, +ALTER COLUMN "category_id" SET DATA TYPE TEXT, +ALTER COLUMN "weight_kg" SET DATA TYPE INTEGER, +ALTER COLUMN "wholesale_discount_percent" SET DATA TYPE INTEGER, +ADD CONSTRAINT "products_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "saved_products" DROP CONSTRAINT "saved_products_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "user_id" SET DATA TYPE TEXT, +ALTER COLUMN "product_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "saved_products_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "staff_access_tokens" DROP CONSTRAINT "staff_access_tokens_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "created_by" SET DATA TYPE TEXT, +ADD CONSTRAINT "staff_access_tokens_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "store_profiles" DROP CONSTRAINT "merchant_profiles_pkey", +DROP COLUMN "quote_count", +ADD COLUMN "account_name" TEXT, +ADD COLUMN "account_number" TEXT, +ADD COLUMN "allow_dropship" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "bank_name" TEXT, +ADD COLUMN "banner_url" TEXT, +ADD COLUMN "bio" TEXT, +ADD COLUMN "business_category" TEXT, +ADD COLUMN "completed_orders" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "dispatch_time_avg_hours" INTEGER, +ADD COLUMN "dispute_count_last_6_months" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "home_address" JSONB, +ADD COLUMN "is_open" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "logo_url" TEXT, +ADD COLUMN "order_completion_rate_bps" INTEGER, +ADD COLUMN "show_owner_publicly" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "store_handle" TEXT, +ADD COLUMN "store_name" TEXT, +ADD COLUMN "store_type" "StoreType" NOT NULL DEFAULT 'PHYSICAL', +ADD COLUMN "tier" "StoreTier" NOT NULL DEFAULT 'TIER_0', +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "user_id" SET DATA TYPE TEXT, +ALTER COLUMN "average_rating" SET DATA TYPE INTEGER, +ADD CONSTRAINT "store_profiles_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "store_slug_history" DROP CONSTRAINT "merchant_slug_history_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "store_profile_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "store_slug_history_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "users" DROP CONSTRAINT "users_pkey", +ADD COLUMN "bio" TEXT, +ADD COLUMN "body_measurements" JSONB, +ADD COLUMN "date_of_birth" TIMESTAMP(3), +ADD COLUMN "delivery_addresses" JSONB, +ADD COLUMN "display_name" TEXT, +ADD COLUMN "interests" TEXT[] DEFAULT ARRAY[]::TEXT[], +ADD COLUMN "is_active" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "last_username_changed_at" TIMESTAMP(3), +ADD COLUMN "profile_photo_url" TEXT, +ADD COLUMN "username" TEXT, +ALTER COLUMN "id" SET DATA TYPE TEXT, +ADD CONSTRAINT "users_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "verification_requests" DROP CONSTRAINT "verification_requests_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "store_id" SET DATA TYPE TEXT, +ALTER COLUMN "reviewed_by" SET DATA TYPE TEXT, +ADD CONSTRAINT "verification_requests_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "webhook_events" DROP CONSTRAINT "webhook_events_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ADD CONSTRAINT "webhook_events_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "whatsapp_links" DROP CONSTRAINT "whatsapp_links_pkey", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "user_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "whatsapp_links_pkey" PRIMARY KEY ("id"); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "refresh_token_hash" TEXT NOT NULL, + "user_agent" TEXT, + "ip_address" TEXT, + "expires_at" TIMESTAMP(3) NOT NULL, + "revoked_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "otp_codes" ( + "id" TEXT NOT NULL, + "user_id" TEXT, + "email" TEXT, + "phone" TEXT, + "code_hash" TEXT NOT NULL, + "purpose" "OTPPurpose" NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "consumed_at" TIMESTAMP(3), + "attempts" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "otp_codes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "follow_relations" ( + "id" TEXT NOT NULL, + "follower_id" TEXT NOT NULL, + "target_user_id" TEXT, + "target_store_id" TEXT, + "target_type" "FollowTargetType" NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "follow_relations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "store_verification_logs" ( + "id" TEXT NOT NULL, + "store_id" TEXT NOT NULL, + "verification_type" "VerificationType" NOT NULL, + "status" "VerificationStatus" NOT NULL DEFAULT 'PENDING', + "provider" TEXT, + "provider_reference" TEXT, + "metadata" JSONB, + "reviewed_by" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "store_verification_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "product_variants" ( + "id" TEXT NOT NULL, + "product_id" TEXT NOT NULL, + "color_name" TEXT, + "size_name" TEXT, + "variant_label" TEXT NOT NULL, + "price_override_kobo" BIGINT, + "sku" TEXT, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "product_variants_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "product_images" ( + "id" TEXT NOT NULL, + "product_id" TEXT NOT NULL, + "url" TEXT NOT NULL, + "order" INTEGER NOT NULL DEFAULT 0, + "is_default" BOOLEAN NOT NULL DEFAULT false, + "assigned_variant_ids" TEXT[] DEFAULT ARRAY[]::TEXT[], + "alt_text" TEXT, + "moderation_status" "ModerationStatus" NOT NULL DEFAULT 'SAFE', + "cloudinary_public_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "product_images_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "product_size_guide_configs" ( + "id" TEXT NOT NULL, + "product_id" TEXT NOT NULL, + "primary_guide_type" "GuideType" NOT NULL, + "includes_footwear" BOOLEAN NOT NULL DEFAULT false, + "footwear_guide_type" "GuideType", + "use_custom_sizes" BOOLEAN NOT NULL DEFAULT false, + "custom_sizes" JSONB, + "model_height_cm" INTEGER, + "model_bust_cm" INTEGER, + "model_waist_cm" INTEGER, + "model_hips_cm" INTEGER, + "model_wearing_size" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "product_size_guide_configs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "product_embeddings" ( + "id" TEXT NOT NULL, + "product_id" TEXT NOT NULL, + "embedding" vector(1408) NOT NULL, + "embedding_model" TEXT NOT NULL DEFAULT 'vertex-ai-multimodal-v1', + "embedding_ready" BOOLEAN NOT NULL DEFAULT false, + "embedding_updated_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "product_embeddings_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sourced_products" ( + "id" TEXT NOT NULL, + "digital_store_id" TEXT NOT NULL, + "physical_store_id" TEXT NOT NULL, + "physical_product_id" TEXT NOT NULL, + "custom_title" TEXT, + "custom_description" TEXT, + "custom_images" JSONB, + "selling_price_kobo" BIGINT NOT NULL, + "floor_price_kobo" BIGINT NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "sourced_products_pkey" PRIMARY KEY ("id") +); + +-- Enforce the dropship floor at the database level. +ALTER TABLE "sourced_products" +ADD CONSTRAINT "sourced_products_price_floor_check" +CHECK ("selling_price_kobo" >= "floor_price_kobo"); + +-- CreateTable +CREATE TABLE "posts" ( + "id" TEXT NOT NULL, + "author_id" TEXT, + "store_id" TEXT, + "type" "PostType" NOT NULL, + "text" TEXT, + "tagged_product_id" TEXT, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "moderation_status" "ModerationStatus" NOT NULL DEFAULT 'PENDING', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "posts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "post_images" ( + "id" TEXT NOT NULL, + "post_id" TEXT NOT NULL, + "url" TEXT NOT NULL, + "order" INTEGER NOT NULL DEFAULT 0, + "moderation_status" "ModerationStatus" NOT NULL DEFAULT 'SAFE', + "cloudinary_public_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "post_images_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "hashtags" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "post_count" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "hashtags_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "post_hashtags" ( + "id" TEXT NOT NULL, + "post_id" TEXT NOT NULL, + "hashtag_id" TEXT NOT NULL, + + CONSTRAINT "post_hashtags_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "likes" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "post_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "likes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "comments" ( + "id" TEXT NOT NULL, + "post_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "parent_id" TEXT, + "body" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "comments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "comment_likes" ( + "id" TEXT NOT NULL, + "comment_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "comment_likes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "store_collections" ( + "id" TEXT NOT NULL, + "store_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "product_ids" TEXT[] DEFAULT ARRAY[]::TEXT[], + "sort_order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "store_collections_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "store_notification_subscriptions" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "store_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "store_notification_subscriptions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "dva_accounts" ( + "id" TEXT NOT NULL, + "user_id" TEXT, + "order_id" TEXT, + "paystack_customer_code" TEXT, + "account_number" TEXT NOT NULL, + "account_name" TEXT NOT NULL, + "bank_name" TEXT NOT NULL, + "bank_slug" TEXT, + "status" "DVAStatus" NOT NULL DEFAULT 'ACTIVE', + "expires_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "dva_accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "delivery_addresses" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "label" TEXT NOT NULL, + "street" TEXT NOT NULL, + "line2" TEXT, + "city" TEXT NOT NULL, + "state" TEXT NOT NULL, + "postal_code" TEXT, + "is_default" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "delivery_addresses_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "conversations" ( + "id" TEXT NOT NULL, + "buyer_id" TEXT NOT NULL, + "store_id" TEXT NOT NULL, + "order_id" TEXT, + "last_message_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "conversations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "messages" ( + "id" TEXT NOT NULL, + "conversation_id" TEXT NOT NULL, + "sender_id" TEXT NOT NULL, + "body" TEXT, + "attachment_url" TEXT, + "read_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "messages_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "push_tokens" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "platform" TEXT NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "push_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "moderation_logs" ( + "id" TEXT NOT NULL, + "content_type" TEXT NOT NULL, + "content_id" TEXT NOT NULL, + "image_url" TEXT NOT NULL, + "cloudinary_id" TEXT, + "decision" "ModerationStatus" NOT NULL, + "confidence" JSONB, + "reviewed_by" TEXT, + "review_note" TEXT, + "appeal_status" "VerificationStatus", + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "moderation_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "support_tickets" ( + "id" TEXT NOT NULL, + "user_id" TEXT, + "subject" TEXT NOT NULL, + "body" TEXT NOT NULL, + "status" "SupportStatus" NOT NULL DEFAULT 'OPEN', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "support_tickets_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "whatsapp_analytics" ( + "id" TEXT NOT NULL, + "session_id" TEXT NOT NULL, + "message_type" TEXT NOT NULL, + "products_shown" TEXT[] DEFAULT ARRAY[]::TEXT[], + "product_selected" TEXT, + "search_query" TEXT, + "parsed_filters" JSONB, + "zero_results" BOOLEAN NOT NULL DEFAULT false, + "session_converted" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "whatsapp_analytics_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "whatsapp_sessions" ( + "id" TEXT NOT NULL, + "phone" TEXT NOT NULL, + "user_id" TEXT, + "consent_given" BOOLEAN NOT NULL DEFAULT false, + "consent_at" TIMESTAMP(3), + "last_message_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "whatsapp_sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "size_guides" ( + "id" TEXT NOT NULL, + "type" "GuideType" NOT NULL, + "sizes" JSONB NOT NULL, + "version" TEXT NOT NULL DEFAULT 'v1', + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "size_guides_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "reconciliation_logs" ( + "id" TEXT NOT NULL, + "paystack_balance_kobo" BIGINT NOT NULL, + "ledger_escrow_kobo" BIGINT NOT NULL, + "difference_kobo" BIGINT NOT NULL, + "status" "ReconciliationStatus" NOT NULL DEFAULT 'BALANCED', + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "reconciliation_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id"); + +-- CreateIndex +CREATE INDEX "sessions_expires_at_idx" ON "sessions"("expires_at"); + +-- CreateIndex +CREATE INDEX "otp_codes_email_purpose_idx" ON "otp_codes"("email", "purpose"); + +-- CreateIndex +CREATE INDEX "otp_codes_phone_purpose_idx" ON "otp_codes"("phone", "purpose"); + +-- CreateIndex +CREATE INDEX "otp_codes_user_id_idx" ON "otp_codes"("user_id"); + +-- CreateIndex +CREATE INDEX "follow_relations_target_store_id_idx" ON "follow_relations"("target_store_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "follow_relations_follower_id_target_user_id_key" ON "follow_relations"("follower_id", "target_user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "follow_relations_follower_id_target_store_id_key" ON "follow_relations"("follower_id", "target_store_id"); + +-- CreateIndex +CREATE INDEX "store_verification_logs_store_id_idx" ON "store_verification_logs"("store_id"); + +-- CreateIndex +CREATE INDEX "store_verification_logs_status_idx" ON "store_verification_logs"("status"); + +-- CreateIndex +CREATE INDEX "product_variants_product_id_idx" ON "product_variants"("product_id"); + +-- CreateIndex +CREATE INDEX "product_images_product_id_idx" ON "product_images"("product_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "product_size_guide_configs_product_id_key" ON "product_size_guide_configs"("product_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "product_embeddings_product_id_key" ON "product_embeddings"("product_id"); + +-- pgvector approximate nearest-neighbour index for hybrid product search. +CREATE INDEX "product_embeddings_embedding_ivfflat_idx" +ON "product_embeddings" +USING ivfflat ("embedding" vector_cosine_ops) +WITH (lists = 100); + +-- CreateIndex +CREATE INDEX "sourced_products_physical_store_id_idx" ON "sourced_products"("physical_store_id"); + +-- CreateIndex +CREATE INDEX "sourced_products_is_active_idx" ON "sourced_products"("is_active"); + +-- CreateIndex +CREATE UNIQUE INDEX "sourced_products_digital_store_id_physical_product_id_key" ON "sourced_products"("digital_store_id", "physical_product_id"); + +-- CreateIndex +CREATE INDEX "posts_store_id_created_at_idx" ON "posts"("store_id", "created_at"); + +-- CreateIndex +CREATE INDEX "posts_tagged_product_id_idx" ON "posts"("tagged_product_id"); + +-- CreateIndex +CREATE INDEX "post_images_post_id_idx" ON "post_images"("post_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "hashtags_name_key" ON "hashtags"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "post_hashtags_post_id_hashtag_id_key" ON "post_hashtags"("post_id", "hashtag_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "likes_user_id_post_id_key" ON "likes"("user_id", "post_id"); + +-- CreateIndex +CREATE INDEX "comments_post_id_idx" ON "comments"("post_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "comment_likes_comment_id_user_id_key" ON "comment_likes"("comment_id", "user_id"); + +-- CreateIndex +CREATE INDEX "store_collections_store_id_idx" ON "store_collections"("store_id"); + +-- CreateIndex +CREATE INDEX "store_notification_subscriptions_store_id_idx" ON "store_notification_subscriptions"("store_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "store_notification_subscriptions_user_id_store_id_key" ON "store_notification_subscriptions"("user_id", "store_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "dva_accounts_order_id_key" ON "dva_accounts"("order_id"); + +-- CreateIndex +CREATE INDEX "dva_accounts_user_id_idx" ON "dva_accounts"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "dva_accounts_account_number_bank_name_key" ON "dva_accounts"("account_number", "bank_name"); + +-- CreateIndex +CREATE INDEX "delivery_addresses_user_id_idx" ON "delivery_addresses"("user_id"); + +-- CreateIndex +CREATE INDEX "conversations_buyer_id_idx" ON "conversations"("buyer_id"); + +-- CreateIndex +CREATE INDEX "conversations_store_id_idx" ON "conversations"("store_id"); + +-- CreateIndex +CREATE INDEX "messages_conversation_id_created_at_idx" ON "messages"("conversation_id", "created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens"("token"); + +-- CreateIndex +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens"("user_id"); + +-- CreateIndex +CREATE INDEX "moderation_logs_content_type_content_id_idx" ON "moderation_logs"("content_type", "content_id"); + +-- CreateIndex +CREATE INDEX "support_tickets_status_idx" ON "support_tickets"("status"); + +-- CreateIndex +CREATE INDEX "whatsapp_analytics_session_id_idx" ON "whatsapp_analytics"("session_id"); + +-- CreateIndex +CREATE INDEX "whatsapp_sessions_user_id_idx" ON "whatsapp_sessions"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "whatsapp_sessions_phone_key" ON "whatsapp_sessions"("phone"); + +-- CreateIndex +CREATE UNIQUE INDEX "size_guides_type_key" ON "size_guides"("type"); + +-- CreateIndex +CREATE INDEX "reconciliation_logs_status_created_at_idx" ON "reconciliation_logs"("status", "created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "orders_order_code_key" ON "orders"("order_code"); + +-- CreateIndex +CREATE INDEX "product_stock_cache_variant_id_idx" ON "product_stock_cache"("variant_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "product_stock_cache_product_id_variant_id_key" ON "product_stock_cache"("product_id", "variant_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "products_product_code_key" ON "products"("product_code"); + +-- CreateIndex +CREATE UNIQUE INDEX "store_profiles_store_handle_key" ON "store_profiles"("store_handle"); + +-- CreateIndex +CREATE INDEX "store_profiles_store_handle_idx" ON "store_profiles"("store_handle"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_username_key" ON "users"("username"); + +-- AddForeignKey +ALTER TABLE "admin_profiles" ADD CONSTRAINT "admin_profiles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventory_events" ADD CONSTRAINT "inventory_events_store_id_fkey" FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventory_events" ADD CONSTRAINT "inventory_events_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "store_profiles" ADD CONSTRAINT "store_profiles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_events" ADD CONSTRAINT "order_events_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_events" ADD CONSTRAINT "order_events_triggered_by_fkey" FOREIGN KEY ("triggered_by") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "orders" ADD CONSTRAINT "orders_buyer_id_fkey" FOREIGN KEY ("buyer_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "orders" ADD CONSTRAINT "orders_store_id_fkey" FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "orders" ADD CONSTRAINT "orders_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "payouts" ADD CONSTRAINT "payouts_store_id_fkey" FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "payouts" ADD CONSTRAINT "payouts_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cart_items" ADD CONSTRAINT "cart_items_buyer_id_fkey" FOREIGN KEY ("buyer_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cart_items" ADD CONSTRAINT "cart_items_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "saved_products" ADD CONSTRAINT "saved_products_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "saved_products" ADD CONSTRAINT "saved_products_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "payment_events" ADD CONSTRAINT "payment_events_payment_id_fkey" FOREIGN KEY ("payment_id") REFERENCES "payments"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "payments" ADD CONSTRAINT "payments_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ledger_entries" ADD CONSTRAINT "ledger_entries_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ledger_entries" ADD CONSTRAINT "ledger_entries_payment_id_fkey" FOREIGN KEY ("payment_id") REFERENCES "payments"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ledger_entries" ADD CONSTRAINT "ledger_entries_payout_id_fkey" FOREIGN KEY ("payout_id") REFERENCES "payouts"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "payout_requests" ADD CONSTRAINT "payout_requests_store_id_fkey" FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "store_slug_history" ADD CONSTRAINT "store_slug_history_store_profile_id_fkey" FOREIGN KEY ("store_profile_id") REFERENCES "store_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "product_stock_cache" ADD CONSTRAINT "product_stock_cache_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "products" ADD CONSTRAINT "products_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "products" ADD CONSTRAINT "products_store_id_fkey" FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "staff_access_tokens" ADD CONSTRAINT "staff_access_tokens_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "whatsapp_links" ADD CONSTRAINT "whatsapp_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "verification_requests" ADD CONSTRAINT "verification_requests_store_id_fkey" FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "verification_requests" ADD CONSTRAINT "verification_requests_reviewed_by_fkey" FOREIGN KEY ("reviewed_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_tracking" ADD CONSTRAINT "order_tracking_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "delivery_bookings" ADD CONSTRAINT "delivery_bookings_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "categories" ADD CONSTRAINT "categories_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "follows" ADD CONSTRAINT "follows_follower_id_fkey" FOREIGN KEY ("follower_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "follows" ADD CONSTRAINT "follows_store_id_fkey" FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "otp_codes" ADD CONSTRAINT "otp_codes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "follow_relations" ADD CONSTRAINT "follow_relations_follower_id_fkey" FOREIGN KEY ("follower_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "follow_relations" ADD CONSTRAINT "follow_relations_target_user_id_fkey" FOREIGN KEY ("target_user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "product_variants" ADD CONSTRAINT "product_variants_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "product_images" ADD CONSTRAINT "product_images_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "product_size_guide_configs" ADD CONSTRAINT "product_size_guide_configs_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "product_embeddings" ADD CONSTRAINT "product_embeddings_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sourced_products" ADD CONSTRAINT "sourced_products_physical_product_id_fkey" FOREIGN KEY ("physical_product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "posts" ADD CONSTRAINT "posts_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "posts" ADD CONSTRAINT "posts_tagged_product_id_fkey" FOREIGN KEY ("tagged_product_id") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "post_images" ADD CONSTRAINT "post_images_post_id_fkey" FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "post_hashtags" ADD CONSTRAINT "post_hashtags_post_id_fkey" FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "post_hashtags" ADD CONSTRAINT "post_hashtags_hashtag_id_fkey" FOREIGN KEY ("hashtag_id") REFERENCES "hashtags"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "likes" ADD CONSTRAINT "likes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "likes" ADD CONSTRAINT "likes_post_id_fkey" FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "comments" ADD CONSTRAINT "comments_post_id_fkey" FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "comments" ADD CONSTRAINT "comments_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "comments"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "comment_likes" ADD CONSTRAINT "comment_likes_comment_id_fkey" FOREIGN KEY ("comment_id") REFERENCES "comments"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "comment_likes" ADD CONSTRAINT "comment_likes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "store_notification_subscriptions" ADD CONSTRAINT "store_notification_subscriptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "delivery_addresses" ADD CONSTRAINT "delivery_addresses_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "conversations" ADD CONSTRAINT "conversations_buyer_id_fkey" FOREIGN KEY ("buyer_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "push_tokens" ADD CONSTRAINT "push_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "moderation_logs" ADD CONSTRAINT "moderation_logs_reviewed_by_fkey" FOREIGN KEY ("reviewed_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "support_tickets" ADD CONSTRAINT "support_tickets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "whatsapp_sessions" ADD CONSTRAINT "whatsapp_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- RenameIndex +ALTER INDEX "merchant_profiles_user_id_key" RENAME TO "store_profiles_user_id_key"; diff --git a/apps/backend/prisma/migrations/20260521203000_add_schema_integrity_constraints/migration.sql b/apps/backend/prisma/migrations/20260521203000_add_schema_integrity_constraints/migration.sql new file mode 100644 index 00000000..cc28b474 --- /dev/null +++ b/apps/backend/prisma/migrations/20260521203000_add_schema_integrity_constraints/migration.sql @@ -0,0 +1,59 @@ +-- CodeRabbit follow-up: align Prisma relations with database integrity constraints. + +-- Ensure all orders have a required MVP order code before enforcing NOT NULL. +-- The dev database was reset for the B-03 baseline, so this is expected to be a no-op locally. +UPDATE "orders" +SET "order_code" = 'TWZ-' || LPAD(FLOOR(RANDOM() * 1000000)::TEXT, 6, '0') +WHERE "order_code" IS NULL; + +ALTER TABLE "orders" ALTER COLUMN "order_code" SET NOT NULL; + +-- Remove redundant non-unique index; the unique store_handle index covers lookups. +DROP INDEX IF EXISTS "store_profiles_store_handle_idx"; + +-- Enforce the FollowRelation discriminated target shape. +ALTER TABLE "follow_relations" +ADD CONSTRAINT "follow_relations_target_check" +CHECK ( + ("target_type" = 'USER' AND "target_user_id" IS NOT NULL AND "target_store_id" IS NULL) OR + ("target_type" = 'STORE' AND "target_store_id" IS NOT NULL AND "target_user_id" IS NULL) +); + +-- Add missing store-target FK for follows. +ALTER TABLE "follow_relations" +ADD CONSTRAINT "follow_relations_target_store_id_fkey" +FOREIGN KEY ("target_store_id") REFERENCES "store_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Add missing store FKs for sourced products. +ALTER TABLE "sourced_products" +ADD CONSTRAINT "sourced_products_digital_store_id_fkey" +FOREIGN KEY ("digital_store_id") REFERENCES "store_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "sourced_products" +ADD CONSTRAINT "sourced_products_physical_store_id_fkey" +FOREIGN KEY ("physical_store_id") REFERENCES "store_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Add missing store/account/session FKs for MVP support tables. +ALTER TABLE "store_collections" +ADD CONSTRAINT "store_collections_store_id_fkey" +FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "store_notification_subscriptions" +ADD CONSTRAINT "store_notification_subscriptions_store_id_fkey" +FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "dva_accounts" +ADD CONSTRAINT "dva_accounts_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "dva_accounts" +ADD CONSTRAINT "dva_accounts_order_id_fkey" +FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "conversations" +ADD CONSTRAINT "conversations_store_id_fkey" +FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "whatsapp_analytics" +ADD CONSTRAINT "whatsapp_analytics_session_id_fkey" +FOREIGN KEY ("session_id") REFERENCES "whatsapp_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260521204500_add_order_code_default/migration.sql b/apps/backend/prisma/migrations/20260521204500_add_order_code_default/migration.sql new file mode 100644 index 00000000..497a31cc --- /dev/null +++ b/apps/backend/prisma/migrations/20260521204500_add_order_code_default/migration.sql @@ -0,0 +1,3 @@ +-- Keep Order.orderCode required while allowing existing create paths to rely on the database. +ALTER TABLE "orders" +ALTER COLUMN "order_code" SET DEFAULT 'TWZ-' || LPAD(FLOOR(RANDOM() * 1000000)::TEXT, 6, '0'); diff --git a/apps/backend/prisma/migrations/20260521211247_add_saved_posts/migration.sql b/apps/backend/prisma/migrations/20260521211247_add_saved_posts/migration.sql new file mode 100644 index 00000000..45db0d78 --- /dev/null +++ b/apps/backend/prisma/migrations/20260521211247_add_saved_posts/migration.sql @@ -0,0 +1,24 @@ +-- CreateTable +CREATE TABLE "saved_posts" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "post_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "saved_posts_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "saved_posts_user_id_idx" ON "saved_posts"("user_id"); + +-- CreateIndex +CREATE INDEX "saved_posts_post_id_idx" ON "saved_posts"("post_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "saved_posts_user_id_post_id_key" ON "saved_posts"("user_id", "post_id"); + +-- AddForeignKey +ALTER TABLE "saved_posts" ADD CONSTRAINT "saved_posts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "saved_posts" ADD CONSTRAINT "saved_posts_post_id_fkey" FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260521213000_add_variant_inventory_stock_cache/migration.sql b/apps/backend/prisma/migrations/20260521213000_add_variant_inventory_stock_cache/migration.sql new file mode 100644 index 00000000..9d5b727f --- /dev/null +++ b/apps/backend/prisma/migrations/20260521213000_add_variant_inventory_stock_cache/migration.sql @@ -0,0 +1,55 @@ +ALTER TYPE "InventoryEventType" ADD VALUE IF NOT EXISTS 'INITIAL_STOCK'; +ALTER TYPE "InventoryEventType" ADD VALUE IF NOT EXISTS 'RESTOCK'; +ALTER TYPE "InventoryEventType" ADD VALUE IF NOT EXISTS 'SALE_DEDUCT'; +ALTER TYPE "InventoryEventType" ADD VALUE IF NOT EXISTS 'SALE_RESTORE'; + +ALTER TABLE "inventory_events" + ADD COLUMN IF NOT EXISTS "variant_id" TEXT; + +DROP INDEX IF EXISTS "product_stock_cache_product_id_key"; + +CREATE INDEX IF NOT EXISTS "inventory_events_variant_id_idx" + ON "inventory_events"("variant_id"); + +CREATE INDEX IF NOT EXISTS "product_stock_cache_product_id_idx" + ON "product_stock_cache"("product_id"); + +CREATE UNIQUE INDEX IF NOT EXISTS "product_stock_cache_product_id_null_variant_key" + ON "product_stock_cache"("product_id") + WHERE "variant_id" IS NULL; + +UPDATE "product_stock_cache" +SET "quantity" = "stock" +WHERE "quantity" IS DISTINCT FROM "stock"; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'inventory_events_variant_id_fkey' + ) THEN + ALTER TABLE "inventory_events" + ADD CONSTRAINT "inventory_events_variant_id_fkey" + FOREIGN KEY ("variant_id") + REFERENCES "product_variants"("id") + ON DELETE RESTRICT + ON UPDATE CASCADE; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'product_stock_cache_variant_id_fkey' + ) THEN + ALTER TABLE "product_stock_cache" + ADD CONSTRAINT "product_stock_cache_variant_id_fkey" + FOREIGN KEY ("variant_id") + REFERENCES "product_variants"("id") + ON DELETE CASCADE + ON UPDATE CASCADE; + END IF; +END $$; diff --git a/apps/backend/prisma/migrations/20260522120000_add_dropship_order_pairing/migration.sql b/apps/backend/prisma/migrations/20260522120000_add_dropship_order_pairing/migration.sql new file mode 100644 index 00000000..7c48547d --- /dev/null +++ b/apps/backend/prisma/migrations/20260522120000_add_dropship_order_pairing/migration.sql @@ -0,0 +1,39 @@ +-- B-17a: Dropship Order Pairing +-- +-- Backward-compatible: +-- * `OrderType` enum added; existing orders default to 'DIRECT'. +-- * `linked_order_id`, `sourced_product_id`, `dropshipper_cost_kobo`, +-- `digital_margin_kobo` are nullable and unset on existing rows. +-- * No drops, no destructive changes. + +-- CreateEnum +CREATE TYPE "OrderType" AS ENUM ('DIRECT', 'DROPSHIP_CUSTOMER', 'DROPSHIP_FULFILLMENT'); + +-- AlterTable +ALTER TABLE "orders" + ADD COLUMN "order_type" "OrderType" NOT NULL DEFAULT 'DIRECT', + ADD COLUMN "linked_order_id" TEXT, + ADD COLUMN "sourced_product_id" TEXT, + ADD COLUMN "dropshipper_cost_kobo" BIGINT, + ADD COLUMN "digital_margin_kobo" BIGINT; + +-- CreateIndex (unique pairing — each customer order points to at most one fulfillment order and vice versa) +CREATE UNIQUE INDEX "orders_linked_order_id_key" ON "orders"("linked_order_id"); + +-- CreateIndex +CREATE INDEX "orders_order_type_idx" ON "orders"("order_type"); + +-- CreateIndex +CREATE INDEX "orders_sourced_product_id_idx" ON "orders"("sourced_product_id"); + +-- AddForeignKey +ALTER TABLE "orders" + ADD CONSTRAINT "orders_linked_order_id_fkey" + FOREIGN KEY ("linked_order_id") REFERENCES "orders"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "orders" + ADD CONSTRAINT "orders_sourced_product_id_fkey" + FOREIGN KEY ("sourced_product_id") REFERENCES "sourced_products"("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260523060000_add_direct_order_disputes/migration.sql b/apps/backend/prisma/migrations/20260523060000_add_direct_order_disputes/migration.sql new file mode 100644 index 00000000..9f9eac71 --- /dev/null +++ b/apps/backend/prisma/migrations/20260523060000_add_direct_order_disputes/migration.sql @@ -0,0 +1,86 @@ +-- CreateEnum +CREATE TYPE "DisputeStatus" AS ENUM ('OPEN', 'STORE_RESPONDED', 'UNDER_REVIEW', 'RESOLVED_BUYER', 'RESOLVED_SELLER', 'RESOLVED_PARTIAL', 'CLOSED'); + +-- CreateEnum +CREATE TYPE "DisputeActorType" AS ENUM ('BUYER', 'STORE', 'OPERATOR'); + +-- CreateEnum +CREATE TYPE "DisputeResolutionOutcome" AS ENUM ('BUYER_WINS', 'SELLER_WINS', 'PARTIAL'); + +-- CreateTable +CREATE TABLE "disputes" ( + "id" TEXT NOT NULL, + "order_id" TEXT NOT NULL, + "buyer_id" TEXT NOT NULL, + "store_id" TEXT NOT NULL, + "status" "DisputeStatus" NOT NULL DEFAULT 'OPEN', + "reason" TEXT NOT NULL, + "description" TEXT NOT NULL, + "buyer_requested_outcome" TEXT, + "store_response" TEXT, + "resolution_outcome" "DisputeResolutionOutcome", + "resolution_notes" TEXT, + "resolved_by_id" TEXT, + "resolved_at" TIMESTAMP(3), + "closed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "disputes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "dispute_evidence" ( + "id" TEXT NOT NULL, + "dispute_id" TEXT NOT NULL, + "actor_id" TEXT NOT NULL, + "actor_type" "DisputeActorType" NOT NULL, + "url" TEXT NOT NULL, + "public_id" TEXT, + "note" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "dispute_evidence_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "disputes_order_active_unique_idx" ON "disputes"("order_id") WHERE "status" IN ('OPEN', 'STORE_RESPONDED', 'UNDER_REVIEW'); + +-- CreateIndex +CREATE INDEX "disputes_buyer_id_idx" ON "disputes"("buyer_id"); + +-- CreateIndex +CREATE INDEX "disputes_store_id_idx" ON "disputes"("store_id"); + +-- CreateIndex +CREATE INDEX "disputes_status_idx" ON "disputes"("status"); + +-- CreateIndex +CREATE INDEX "disputes_created_at_idx" ON "disputes"("created_at"); + +-- CreateIndex +CREATE INDEX "dispute_evidence_dispute_id_idx" ON "dispute_evidence"("dispute_id"); + +-- CreateIndex +CREATE INDEX "dispute_evidence_actor_id_idx" ON "dispute_evidence"("actor_id"); + +-- CreateIndex +CREATE INDEX "dispute_evidence_actor_type_idx" ON "dispute_evidence"("actor_type"); + +-- AddForeignKey +ALTER TABLE "disputes" ADD CONSTRAINT "disputes_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "disputes" ADD CONSTRAINT "disputes_buyer_id_fkey" FOREIGN KEY ("buyer_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "disputes" ADD CONSTRAINT "disputes_resolved_by_id_fkey" FOREIGN KEY ("resolved_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "disputes" ADD CONSTRAINT "disputes_store_id_fkey" FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dispute_evidence" ADD CONSTRAINT "dispute_evidence_dispute_id_fkey" FOREIGN KEY ("dispute_id") REFERENCES "disputes"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dispute_evidence" ADD CONSTRAINT "dispute_evidence_actor_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260523070000_add_store_business_address_details/migration.sql b/apps/backend/prisma/migrations/20260523070000_add_store_business_address_details/migration.sql new file mode 100644 index 00000000..d68e7643 --- /dev/null +++ b/apps/backend/prisma/migrations/20260523070000_add_store_business_address_details/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "store_profiles" +ADD COLUMN "business_address_details" JSONB; diff --git a/apps/backend/prisma/migrations/20260523080000_add_nin_verification_tracking/migration.sql b/apps/backend/prisma/migrations/20260523080000_add_nin_verification_tracking/migration.sql new file mode 100644 index 00000000..62b05194 --- /dev/null +++ b/apps/backend/prisma/migrations/20260523080000_add_nin_verification_tracking/migration.sql @@ -0,0 +1,17 @@ +-- B-22: KYB Verification — NIN Prembly tracking +-- +-- Backward-compatible: +-- * All new columns are nullable / defaulted. +-- * No existing rows are touched (defaults populate retroactively). +-- * No drops, no destructive changes. +-- +-- Fields added: +-- nin_verification_status — "NONE" | "PENDING_SELFIE" | "VERIFIED" | "LOCKED" +-- nin_verification_attempts — combined NIN + face failure counter +-- nin_verification_locked_at — timestamp set when attempts hits 3 + +-- AlterTable +ALTER TABLE "store_profiles" + ADD COLUMN "nin_verification_status" TEXT DEFAULT 'NONE', + ADD COLUMN "nin_verification_attempts" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "nin_verification_locked_at" TIMESTAMP(3); diff --git a/apps/backend/prisma/migrations/20260601090000_add_domain_events/migration.sql b/apps/backend/prisma/migrations/20260601090000_add_domain_events/migration.sql new file mode 100644 index 00000000..b95b3ab4 --- /dev/null +++ b/apps/backend/prisma/migrations/20260601090000_add_domain_events/migration.sql @@ -0,0 +1,31 @@ +-- CreateTable +CREATE TABLE "domain_events" ( + "id" TEXT NOT NULL, + "aggregate_type" TEXT NOT NULL, + "aggregate_id" TEXT NOT NULL, + "event_type" TEXT NOT NULL, + "actor_type" TEXT NOT NULL, + "actor_id" TEXT, + "source" TEXT NOT NULL, + "metadata" JSONB NOT NULL DEFAULT '{}', + "correlation_id" TEXT, + "idempotency_key" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "domain_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "domain_events_idempotency_key_key" ON "domain_events"("idempotency_key"); + +-- CreateIndex +CREATE INDEX "domain_events_aggregate_type_aggregate_id_created_at_idx" ON "domain_events"("aggregate_type", "aggregate_id", "created_at"); + +-- CreateIndex +CREATE INDEX "domain_events_event_type_created_at_idx" ON "domain_events"("event_type", "created_at"); + +-- CreateIndex +CREATE INDEX "domain_events_correlation_id_idx" ON "domain_events"("correlation_id"); + +-- CreateIndex +CREATE INDEX "domain_events_actor_type_actor_id_idx" ON "domain_events"("actor_type", "actor_id"); diff --git a/apps/backend/prisma/migrations/20260606115000_add_delivery_contact_address_snapshots/migration.sql b/apps/backend/prisma/migrations/20260606115000_add_delivery_contact_address_snapshots/migration.sql new file mode 100644 index 00000000..624ddb15 --- /dev/null +++ b/apps/backend/prisma/migrations/20260606115000_add_delivery_contact_address_snapshots/migration.sql @@ -0,0 +1,26 @@ +-- Add Twizrr-managed delivery contact, address snapshot, and MVP fee metadata. +-- Existing delivery fields remain for backward compatibility. + +ALTER TABLE "orders" + ADD COLUMN "delivery_primary_phone" TEXT, + ADD COLUMN "delivery_secondary_phone" TEXT, + ADD COLUMN "delivery_address_snapshot" JSONB, + ADD COLUMN "pickup_address_snapshot" JSONB, + ADD COLUMN "pickup_store_id" TEXT, + ADD COLUMN "source_store_id" TEXT, + ADD COLUMN "delivery_fee_source" TEXT, + ADD COLUMN "delivery_fee_rule" TEXT, + ADD COLUMN "pickup_zone" TEXT, + ADD COLUMN "delivery_zone" TEXT; + +ALTER TABLE "orders" + ADD CONSTRAINT "orders_delivery_primary_phone_e164_chk" + CHECK ( + "delivery_primary_phone" IS NULL + OR "delivery_primary_phone" ~ '^\+[1-9][0-9]{1,14}$' + ), + ADD CONSTRAINT "orders_delivery_secondary_phone_e164_chk" + CHECK ( + "delivery_secondary_phone" IS NULL + OR "delivery_secondary_phone" ~ '^\+[1-9][0-9]{1,14}$' + ); diff --git a/apps/backend/prisma/migrations/20260606170000_add_ready_for_pickup_order_status/migration.sql b/apps/backend/prisma/migrations/20260606170000_add_ready_for_pickup_order_status/migration.sql new file mode 100644 index 00000000..d57bc74a --- /dev/null +++ b/apps/backend/prisma/migrations/20260606170000_add_ready_for_pickup_order_status/migration.sql @@ -0,0 +1 @@ +ALTER TYPE "OrderStatus" ADD VALUE IF NOT EXISTS 'READY_FOR_PICKUP' BEFORE 'DISPATCHED'; diff --git a/apps/backend/prisma/migrations/20260608120000_add_auth_provider_accounts/migration.sql b/apps/backend/prisma/migrations/20260608120000_add_auth_provider_accounts/migration.sql new file mode 100644 index 00000000..b5a5f3f1 --- /dev/null +++ b/apps/backend/prisma/migrations/20260608120000_add_auth_provider_accounts/migration.sql @@ -0,0 +1,29 @@ +CREATE TYPE "AuthProvider" AS ENUM ('GOOGLE'); + +CREATE TABLE "auth_provider_accounts" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "provider" "AuthProvider" NOT NULL, + "provider_user_id" TEXT NOT NULL, + "provider_email" TEXT NOT NULL, + "is_provider_email_verified" BOOLEAN NOT NULL DEFAULT false, + "provider_avatar_url" TEXT, + "linked_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "auth_provider_accounts_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "auth_provider_accounts_provider_provider_user_id_key" +ON "auth_provider_accounts"("provider", "provider_user_id"); + +CREATE UNIQUE INDEX "auth_provider_accounts_user_id_provider_key" +ON "auth_provider_accounts"("user_id", "provider"); + +CREATE INDEX "auth_provider_accounts_provider_email_idx" +ON "auth_provider_accounts"("provider_email"); + +ALTER TABLE "auth_provider_accounts" +ADD CONSTRAINT "auth_provider_accounts_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260608143000_make_user_password_hash_nullable/migration.sql b/apps/backend/prisma/migrations/20260608143000_make_user_password_hash_nullable/migration.sql new file mode 100644 index 00000000..43e34cfc --- /dev/null +++ b/apps/backend/prisma/migrations/20260608143000_make_user_password_hash_nullable/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ALTER COLUMN "password_hash" DROP NOT NULL; diff --git a/apps/backend/prisma/migrations/20260609193000_add_whatsapp_consent_logs/migration.sql b/apps/backend/prisma/migrations/20260609193000_add_whatsapp_consent_logs/migration.sql new file mode 100644 index 00000000..9033581a --- /dev/null +++ b/apps/backend/prisma/migrations/20260609193000_add_whatsapp_consent_logs/migration.sql @@ -0,0 +1,14 @@ +CREATE TABLE "whatsapp_consent_logs" ( + "id" TEXT NOT NULL, + "phone" TEXT NOT NULL, + "consent_given" BOOLEAN NOT NULL, + "consent_version" TEXT NOT NULL DEFAULT 'v1', + "consented_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "source" TEXT NOT NULL DEFAULT 'whatsapp', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "whatsapp_consent_logs_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "whatsapp_consent_logs_phone_idx" ON "whatsapp_consent_logs"("phone"); +CREATE INDEX "whatsapp_consent_logs_consented_at_idx" ON "whatsapp_consent_logs"("consented_at"); diff --git a/apps/backend/prisma/migrations/20260611120000_add_whatsapp_analytics_v2_fields/migration.sql b/apps/backend/prisma/migrations/20260611120000_add_whatsapp_analytics_v2_fields/migration.sql new file mode 100644 index 00000000..e3f17dfa --- /dev/null +++ b/apps/backend/prisma/migrations/20260611120000_add_whatsapp_analytics_v2_fields/migration.sql @@ -0,0 +1,39 @@ +-- AlterTable +ALTER TABLE "whatsapp_analytics" +ADD COLUMN "phone" TEXT NOT NULL DEFAULT '', +ADD COLUMN "user_id" TEXT, +ADD COLUMN "conversation_turn" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "flow_at_start" TEXT, +ADD COLUMN "flow_at_end" TEXT, +ADD COLUMN "intent_classified" TEXT, +ADD COLUMN "intent_successful" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "image_provided" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "products_shown_count" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "product_ordered" TEXT, +ADD COLUMN "category_inferred" TEXT, +ADD COLUMN "vector_search_used" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "embedding_model" TEXT, +ADD COLUMN "products_ranked_count" INTEGER, +ADD COLUMN "drop_off_step" TEXT, +ADD COLUMN "escalated_to_human" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "gemini_response_ms" INTEGER, +ADD COLUMN "gemini_model" TEXT, +ADD COLUMN "error_type" TEXT; + +-- Backfill +UPDATE "whatsapp_analytics" +SET "products_shown_count" = COALESCE(cardinality("products_shown"), 0); + +-- CreateIndex +CREATE INDEX "whatsapp_analytics_phone_created_at_idx" ON "whatsapp_analytics"("phone", "created_at"); + +-- CreateIndex +CREATE INDEX "whatsapp_analytics_created_at_idx" ON "whatsapp_analytics"("created_at"); + +-- CreateIndex +CREATE INDEX "whatsapp_analytics_error_type_idx" ON "whatsapp_analytics"("error_type"); + +-- AddForeignKey +ALTER TABLE "whatsapp_analytics" +ADD CONSTRAINT "whatsapp_analytics_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260611133000_add_sourced_listing_code/migration.sql b/apps/backend/prisma/migrations/20260611133000_add_sourced_listing_code/migration.sql new file mode 100644 index 00000000..724c34ee --- /dev/null +++ b/apps/backend/prisma/migrations/20260611133000_add_sourced_listing_code/migration.sql @@ -0,0 +1,29 @@ +-- Add a public-safe listing code for sourced products. Existing rows are +-- backfilled deterministically from their sourced listing id so shared links +-- stop depending on raw primary keys after this migration. +ALTER TABLE "sourced_products" +ADD COLUMN "listing_code" TEXT; + +WITH seeded AS ( + SELECT + "id", + 'SRC-' || UPPER(SUBSTRING(MD5("id") FROM 1 FOR 10)) AS base_code, + ROW_NUMBER() OVER ( + PARTITION BY UPPER(SUBSTRING(MD5("id") FROM 1 FOR 10)) + ORDER BY "id" + ) AS rn + FROM "sourced_products" + WHERE "listing_code" IS NULL +) +UPDATE "sourced_products" sp +SET "listing_code" = CASE + WHEN seeded.rn = 1 THEN seeded.base_code + ELSE seeded.base_code || '-' || seeded.rn::text +END +FROM seeded +WHERE sp."id" = seeded."id"; + +ALTER TABLE "sourced_products" +ALTER COLUMN "listing_code" SET NOT NULL; + +CREATE UNIQUE INDEX "sourced_products_listing_code_key" ON "sourced_products"("listing_code"); diff --git a/apps/backend/prisma/migrations/20260611154500_add_sourced_public_product_codes/migration.sql b/apps/backend/prisma/migrations/20260611154500_add_sourced_public_product_codes/migration.sql new file mode 100644 index 00000000..63b8cfcc --- /dev/null +++ b/apps/backend/prisma/migrations/20260611154500_add_sourced_public_product_codes/migration.sql @@ -0,0 +1,65 @@ +-- Add internal source inventory codes and public sourced listing product codes. +ALTER TABLE "products" ADD COLUMN "source_code" TEXT; +ALTER TABLE "sourced_products" ADD COLUMN "product_code" TEXT; + +-- Source codes are internal/store-owner-facing and only needed for products +-- that are currently eligible for sourcing. +WITH source_seed AS ( + SELECT + "id", + 'SRC-' || UPPER(SUBSTRING(MD5('source:' || "id") FROM 1 FOR 10)) AS "base_code", + ROW_NUMBER() OVER ( + PARTITION BY 'SRC-' || UPPER(SUBSTRING(MD5('source:' || "id") FROM 1 FOR 10)) + ORDER BY "id" + ) AS "code_rank" + FROM "products" + WHERE "allow_dropship" = true + AND "source_code" IS NULL +), +source_resolved AS ( + SELECT + "id", + CASE + WHEN "code_rank" = 1 THEN "base_code" + ELSE "base_code" || '-' || "code_rank"::text + END AS "generated_code" + FROM source_seed +) +UPDATE "products" AS p +SET "source_code" = source_resolved."generated_code" +FROM source_resolved +WHERE p."id" = source_resolved."id"; + +-- Public sourced listing codes are normal product-family codes and intentionally +-- do not include source/dropship markers. Use a longer deterministic backfill +-- value so existing rows cannot collide with native TWZ-###### codes. +WITH sourced_seed AS ( + SELECT + sp."id", + 'TWZ-' || UPPER(SUBSTRING(MD5('sourced-public:' || sp."id") FROM 1 FOR 12)) AS "base_code", + ROW_NUMBER() OVER ( + PARTITION BY 'TWZ-' || UPPER(SUBSTRING(MD5('sourced-public:' || sp."id") FROM 1 FOR 12)) + ORDER BY sp."id" + ) AS "code_rank" + FROM "sourced_products" sp + WHERE sp."product_code" IS NULL +), +sourced_resolved AS ( + SELECT + sourced_seed."id", + CASE + WHEN sourced_seed."code_rank" = 1 AND p."id" IS NULL THEN sourced_seed."base_code" + ELSE sourced_seed."base_code" || '-' || sourced_seed."code_rank"::text + END AS "generated_code" + FROM sourced_seed + LEFT JOIN "products" p ON p."product_code" = sourced_seed."base_code" +) +UPDATE "sourced_products" AS sp +SET "product_code" = sourced_resolved."generated_code" +FROM sourced_resolved +WHERE sp."id" = sourced_resolved."id"; + +ALTER TABLE "sourced_products" ALTER COLUMN "product_code" SET NOT NULL; + +CREATE UNIQUE INDEX "products_source_code_key" ON "products"("source_code"); +CREATE UNIQUE INDEX "sourced_products_product_code_key" ON "sourced_products"("product_code"); diff --git a/apps/backend/prisma/migrations/20260616060000_add_waitlist_subscribers/migration.sql b/apps/backend/prisma/migrations/20260616060000_add_waitlist_subscribers/migration.sql new file mode 100644 index 00000000..a6750511 --- /dev/null +++ b/apps/backend/prisma/migrations/20260616060000_add_waitlist_subscribers/migration.sql @@ -0,0 +1,19 @@ +CREATE TABLE "waitlist_subscribers" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "source" TEXT NOT NULL DEFAULT 'other', + "marketing_consent" BOOLEAN NOT NULL, + "consent_text" TEXT NOT NULL, + "consent_version" TEXT NOT NULL, + "whatsapp_invite_clicked_at" TIMESTAMP(3), + "unsubscribed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "waitlist_subscribers_pkey" PRIMARY KEY ("id"), + CONSTRAINT "waitlist_subscribers_email_normalized_check" CHECK ("email" = lower(btrim("email"))) +); + +CREATE UNIQUE INDEX "waitlist_subscribers_email_key" ON "waitlist_subscribers"("email"); +CREATE INDEX "waitlist_subscribers_source_idx" ON "waitlist_subscribers"("source"); +CREATE INDEX "waitlist_subscribers_marketing_consent_idx" ON "waitlist_subscribers"("marketing_consent"); diff --git a/apps/backend/prisma/migrations/20260628170000_add_store_business_phone/migration.sql b/apps/backend/prisma/migrations/20260628170000_add_store_business_phone/migration.sql new file mode 100644 index 00000000..dcdc6de8 --- /dev/null +++ b/apps/backend/prisma/migrations/20260628170000_add_store_business_phone/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE "store_profiles" ADD COLUMN "business_phone" TEXT; +ALTER TABLE "store_profiles" ADD COLUMN "business_phone_verified" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "store_profiles" ADD COLUMN "business_phone_verified_at" TIMESTAMP(3); +ALTER TABLE "store_profiles" ADD COLUMN "business_phone_change_count" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "store_profiles" ADD COLUMN "business_phone_change_window_started_at" TIMESTAMP(3); +ALTER TYPE "OTPPurpose" ADD VALUE IF NOT EXISTS 'STORE_BUSINESS_PHONE_VERIFY'; \ No newline at end of file diff --git a/apps/backend/prisma/migrations/20260701090000_add_store_bank_verified_at/migration.sql b/apps/backend/prisma/migrations/20260701090000_add_store_bank_verified_at/migration.sql new file mode 100644 index 00000000..8b9b7f6d --- /dev/null +++ b/apps/backend/prisma/migrations/20260701090000_add_store_bank_verified_at/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "store_profiles" ADD COLUMN "bank_verified_at" TIMESTAMP(3); \ No newline at end of file diff --git a/apps/backend/prisma/migrations/20260705120000_storepass_domain_models/migration.sql b/apps/backend/prisma/migrations/20260705120000_storepass_domain_models/migration.sql new file mode 100644 index 00000000..88b0af75 --- /dev/null +++ b/apps/backend/prisma/migrations/20260705120000_storepass_domain_models/migration.sql @@ -0,0 +1,304 @@ +-- CreateEnum +CREATE TYPE "StorePassPlanCode" AS ENUM ('FREE', 'GROWTH', 'PRO'); + +-- CreateEnum +CREATE TYPE "StorePassBillingInterval" AS ENUM ('MONTHLY'); + +-- CreateEnum +CREATE TYPE "StorePassSubscriptionStatus" AS ENUM ('FREE', 'INACTIVE', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'CANCELLED_AT_PERIOD_END'); + +-- CreateEnum +CREATE TYPE "StorePassInvoiceStatus" AS ENUM ('DRAFT', 'PENDING', 'PAID', 'FAILED', 'VOID', 'REFUNDED'); + +-- CreateEnum +CREATE TYPE "StorePassBillingAttemptStatus" AS ENUM ('PENDING', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "StorePassPaymentProvider" AS ENUM ('NOMBA'); + +-- CreateEnum +CREATE TYPE "StorePassPaymentMethodType" AS ENUM ('CARD_TOKEN'); + +-- CreateEnum +CREATE TYPE "StorePassEntitlementType" AS ENUM ('FEATURED_PLACEMENT_CREDIT', 'CAMPAIGN_BOOST_CREDIT', 'WIZZA_DISCOVERY_CREDIT', 'ANALYTICS_ACCESS', 'PRODUCT_VISIBILITY_INSIGHTS', 'PRIORITY_SUPPORT'); + +-- CreateEnum +CREATE TYPE "StorePassEntitlementLedgerType" AS ENUM ('GRANT', 'USE', 'EXPIRE', 'REVOKE', 'ADJUST'); + +-- CreateTable +CREATE TABLE "storepass_plans" ( + "id" TEXT NOT NULL, + "code" "StorePassPlanCode" NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "price_kobo" BIGINT NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'NGN', + "interval" "StorePassBillingInterval" NOT NULL DEFAULT 'MONTHLY', + "is_active" BOOLEAN NOT NULL DEFAULT true, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "benefits" JSONB DEFAULT '{}', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "storepass_plans_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "storepass_subscriptions" ( + "id" TEXT NOT NULL, + "store_id" TEXT NOT NULL, + "owner_user_id" TEXT NOT NULL, + "plan_id" TEXT NOT NULL, + "plan_code_snapshot" "StorePassPlanCode" NOT NULL, + "status" "StorePassSubscriptionStatus" NOT NULL DEFAULT 'FREE', + "current_period_start" TIMESTAMP(3), + "current_period_end" TIMESTAMP(3), + "cancel_at_period_end" BOOLEAN NOT NULL DEFAULT false, + "cancelled_at" TIMESTAMP(3), + "past_due_at" TIMESTAMP(3), + "last_paid_at" TIMESTAMP(3), + "next_billing_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "storepass_subscriptions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "storepass_invoices" ( + "id" TEXT NOT NULL, + "store_id" TEXT NOT NULL, + "subscription_id" TEXT, + "plan_id" TEXT NOT NULL, + "plan_code_snapshot" "StorePassPlanCode" NOT NULL, + "amount_kobo" BIGINT NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'NGN', + "status" "StorePassInvoiceStatus" NOT NULL DEFAULT 'DRAFT', + "billing_period_start" TIMESTAMP(3) NOT NULL, + "billing_period_end" TIMESTAMP(3) NOT NULL, + "due_at" TIMESTAMP(3), + "paid_at" TIMESTAMP(3), + "voided_at" TIMESTAMP(3), + "failed_at" TIMESTAMP(3), + "paid_by_user_id" TEXT, + "paid_by_user_email_snapshot" TEXT, + "paid_by_username_snapshot" TEXT, + "paid_by_display_name_snapshot" TEXT, + "store_handle_snapshot" TEXT, + "store_name_snapshot" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "storepass_invoices_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "storepass_billing_attempts" ( + "id" TEXT NOT NULL, + "invoice_id" TEXT NOT NULL, + "store_id" TEXT NOT NULL, + "subscription_id" TEXT, + "provider" "StorePassPaymentProvider" NOT NULL DEFAULT 'NOMBA', + "status" "StorePassBillingAttemptStatus" NOT NULL DEFAULT 'PENDING', + "amount_kobo" BIGINT NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'NGN', + "order_reference" TEXT NOT NULL, + "idempotency_key" TEXT NOT NULL, + "attempt_number" INTEGER NOT NULL DEFAULT 1, + "checkout_url" TEXT, + "provider_transaction_id" TEXT, + "provider_response_code" TEXT, + "provider_response_message" TEXT, + "failure_reason" TEXT, + "started_at" TIMESTAMP(3), + "succeeded_at" TIMESTAMP(3), + "failed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "storepass_billing_attempts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "storepass_payment_methods" ( + "id" TEXT NOT NULL, + "store_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "provider" "StorePassPaymentProvider" NOT NULL DEFAULT 'NOMBA', + "type" "StorePassPaymentMethodType" NOT NULL DEFAULT 'CARD_TOKEN', + "nomba_token_key" TEXT, + "card_type" TEXT, + "masked_card_pan" TEXT, + "token_expiry_month" INTEGER, + "token_expiry_year" INTEGER, + "is_default" BOOLEAN NOT NULL DEFAULT false, + "revoked_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "storepass_payment_methods_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "storepass_entitlements" ( + "id" TEXT NOT NULL, + "store_id" TEXT NOT NULL, + "subscription_id" TEXT, + "plan_code_snapshot" "StorePassPlanCode" NOT NULL, + "entitlement_type" "StorePassEntitlementType" NOT NULL, + "balance" INTEGER, + "is_enabled" BOOLEAN NOT NULL DEFAULT false, + "period_start" TIMESTAMP(3), + "period_end" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "storepass_entitlements_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "storepass_entitlement_ledger" ( + "id" TEXT NOT NULL, + "store_id" TEXT NOT NULL, + "subscription_id" TEXT, + "entitlement_id" TEXT, + "type" "StorePassEntitlementLedgerType" NOT NULL, + "entitlement_type" "StorePassEntitlementType" NOT NULL, + "amount" INTEGER, + "balance_before" INTEGER, + "balance_after" INTEGER, + "reason" TEXT, + "reference_type" TEXT, + "reference_id" TEXT, + "created_by_user_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "storepass_entitlement_ledger_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "storepass_plans_code_key" ON "storepass_plans"("code"); + +-- CreateIndex +CREATE INDEX "storepass_plans_is_active_idx" ON "storepass_plans"("is_active"); + +-- CreateIndex +CREATE UNIQUE INDEX "storepass_subscriptions_store_id_key" ON "storepass_subscriptions"("store_id"); + +-- CreateIndex +CREATE INDEX "storepass_subscriptions_store_id_idx" ON "storepass_subscriptions"("store_id"); + +-- CreateIndex +CREATE INDEX "storepass_subscriptions_status_idx" ON "storepass_subscriptions"("status"); + +-- CreateIndex +CREATE INDEX "storepass_subscriptions_plan_code_snapshot_idx" ON "storepass_subscriptions"("plan_code_snapshot"); + +-- CreateIndex +CREATE INDEX "storepass_invoices_store_id_idx" ON "storepass_invoices"("store_id"); + +-- CreateIndex +CREATE INDEX "storepass_invoices_subscription_id_idx" ON "storepass_invoices"("subscription_id"); + +-- CreateIndex +CREATE INDEX "storepass_invoices_status_idx" ON "storepass_invoices"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "storepass_billing_attempts_order_reference_key" ON "storepass_billing_attempts"("order_reference"); + +-- CreateIndex +CREATE UNIQUE INDEX "storepass_billing_attempts_idempotency_key_key" ON "storepass_billing_attempts"("idempotency_key"); + +-- CreateIndex +CREATE INDEX "storepass_billing_attempts_invoice_id_idx" ON "storepass_billing_attempts"("invoice_id"); + +-- CreateIndex +CREATE INDEX "storepass_billing_attempts_store_id_idx" ON "storepass_billing_attempts"("store_id"); + +-- CreateIndex +CREATE INDEX "storepass_billing_attempts_subscription_id_idx" ON "storepass_billing_attempts"("subscription_id"); + +-- CreateIndex +CREATE INDEX "storepass_billing_attempts_status_idx" ON "storepass_billing_attempts"("status"); + +-- CreateIndex +CREATE INDEX "storepass_payment_methods_store_id_idx" ON "storepass_payment_methods"("store_id"); + +-- CreateIndex +CREATE INDEX "storepass_payment_methods_user_id_idx" ON "storepass_payment_methods"("user_id"); + +-- CreateIndex +CREATE INDEX "storepass_entitlements_store_id_idx" ON "storepass_entitlements"("store_id"); + +-- CreateIndex +CREATE INDEX "storepass_entitlements_subscription_id_idx" ON "storepass_entitlements"("subscription_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "storepass_entitlements_store_id_entitlement_type_key" ON "storepass_entitlements"("store_id", "entitlement_type"); + +-- CreateIndex +CREATE INDEX "storepass_entitlement_ledger_store_id_idx" ON "storepass_entitlement_ledger"("store_id"); + +-- CreateIndex +CREATE INDEX "storepass_entitlement_ledger_subscription_id_idx" ON "storepass_entitlement_ledger"("subscription_id"); + +-- CreateIndex +CREATE INDEX "storepass_entitlement_ledger_entitlement_id_idx" ON "storepass_entitlement_ledger"("entitlement_id"); + +-- CreateIndex +CREATE INDEX "storepass_entitlement_ledger_type_idx" ON "storepass_entitlement_ledger"("type"); + +-- AddForeignKey +ALTER TABLE "storepass_subscriptions" ADD CONSTRAINT "storepass_subscriptions_store_id_fkey" FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "storepass_subscriptions" ADD CONSTRAINT "storepass_subscriptions_owner_user_id_fkey" FOREIGN KEY ("owner_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "storepass_subscriptions" ADD CONSTRAINT "storepass_subscriptions_plan_id_fkey" FOREIGN KEY ("plan_id") REFERENCES "storepass_plans"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "storepass_invoices" ADD CONSTRAINT "storepass_invoices_store_id_fkey" FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "storepass_invoices" ADD CONSTRAINT "storepass_invoices_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "storepass_subscriptions"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "storepass_invoices" ADD CONSTRAINT "storepass_invoices_plan_id_fkey" FOREIGN KEY ("plan_id") REFERENCES "storepass_plans"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "storepass_invoices" ADD CONSTRAINT "storepass_invoices_paid_by_user_id_fkey" FOREIGN KEY ("paid_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "storepass_billing_attempts" ADD CONSTRAINT "storepass_billing_attempts_invoice_id_fkey" FOREIGN KEY ("invoice_id") REFERENCES "storepass_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "storepass_billing_attempts" ADD CONSTRAINT "storepass_billing_attempts_store_id_fkey" FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "storepass_billing_attempts" ADD CONSTRAINT "storepass_billing_attempts_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "storepass_subscriptions"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "storepass_payment_methods" ADD CONSTRAINT "storepass_payment_methods_store_id_fkey" FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "storepass_payment_methods" ADD CONSTRAINT "storepass_payment_methods_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "storepass_entitlements" ADD CONSTRAINT "storepass_entitlements_store_id_fkey" FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "storepass_entitlements" ADD CONSTRAINT "storepass_entitlements_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "storepass_subscriptions"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "storepass_entitlement_ledger" ADD CONSTRAINT "storepass_entitlement_ledger_store_id_fkey" FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "storepass_entitlement_ledger" ADD CONSTRAINT "storepass_entitlement_ledger_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "storepass_subscriptions"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "storepass_entitlement_ledger" ADD CONSTRAINT "storepass_entitlement_ledger_entitlement_id_fkey" FOREIGN KEY ("entitlement_id") REFERENCES "storepass_entitlements"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "storepass_entitlement_ledger" ADD CONSTRAINT "storepass_entitlement_ledger_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/apps/backend/prisma/migrations/20260706130000_add_storepass_billing_events/migration.sql b/apps/backend/prisma/migrations/20260706130000_add_storepass_billing_events/migration.sql new file mode 100644 index 00000000..df51d0cc --- /dev/null +++ b/apps/backend/prisma/migrations/20260706130000_add_storepass_billing_events/migration.sql @@ -0,0 +1,41 @@ +-- CreateEnum +CREATE TYPE "StorePassBillingEventStatus" AS ENUM ('RECEIVED', 'PROCESSING', 'PROCESSED', 'DUPLICATE', 'FAILED', 'IGNORED'); + +-- CreateTable +CREATE TABLE "storepass_billing_events" ( + "id" TEXT NOT NULL, + "provider" "StorePassPaymentProvider" NOT NULL DEFAULT 'NOMBA', + "provider_request_id" TEXT NOT NULL, + "event_type" TEXT NOT NULL, + "order_reference" TEXT, + "provider_transaction_id" TEXT, + "billing_attempt_id" TEXT, + "invoice_id" TEXT, + "subscription_id" TEXT, + "store_id" TEXT, + "processing_status" "StorePassBillingEventStatus" NOT NULL DEFAULT 'RECEIVED', + "failure_reason" TEXT, + "payload_hash" TEXT, + "sanitized_payload" JSONB, + "received_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "processed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "storepass_billing_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "storepass_billing_events_provider_request_id_key" ON "storepass_billing_events"("provider_request_id"); + +-- CreateIndex +CREATE INDEX "storepass_billing_events_provider_event_type_idx" ON "storepass_billing_events"("provider", "event_type"); + +-- CreateIndex +CREATE INDEX "storepass_billing_events_processing_status_idx" ON "storepass_billing_events"("processing_status"); + +-- CreateIndex +CREATE INDEX "storepass_billing_events_order_reference_idx" ON "storepass_billing_events"("order_reference"); + +-- CreateIndex +CREATE INDEX "storepass_billing_events_billing_attempt_id_idx" ON "storepass_billing_events"("billing_attempt_id"); diff --git a/apps/backend/prisma/migrations/20260706140000_admin_super_admin_only/migration.sql b/apps/backend/prisma/migrations/20260706140000_admin_super_admin_only/migration.sql new file mode 100644 index 00000000..2fe4a7ea --- /dev/null +++ b/apps/backend/prisma/migrations/20260706140000_admin_super_admin_only/migration.sql @@ -0,0 +1,25 @@ +-- Twizrr admin is SUPER_ADMIN-only. +-- +-- Remove the legacy multi-role staff model: the OPERATOR and SUPPORT roles and +-- the staff access-token onboarding table. Existing OPERATOR/SUPPORT accounts are +-- reassigned to USER before the enum is contracted; SUPER_ADMIN accounts are +-- unaffected, and the bootstrap SUPER_ADMIN path continues to work. +-- +-- Backward-compatible and data-safe: no columns are dropped or renamed on kept +-- tables, and the conversion runs before the enum value removal. + +-- 1. Reassign any existing OPERATOR/SUPPORT accounts to USER. +UPDATE "users" SET "role" = 'USER' WHERE "role" IN ('OPERATOR', 'SUPPORT'); + +-- 2. Drop the legacy staff access-token table (staff onboarding removed). +-- Dropping it first also removes the only other column typed UserRole, so the +-- enum recreation below only has to convert "users"."role". +DROP TABLE IF EXISTS "staff_access_tokens"; + +-- 3. Contract the UserRole enum to the supported values. PostgreSQL cannot drop +-- enum values in place, so recreate the type and re-point the column at it. +ALTER TYPE "UserRole" RENAME TO "UserRole_old"; +CREATE TYPE "UserRole" AS ENUM ('USER', 'SUPER_ADMIN'); +ALTER TABLE "users" + ALTER COLUMN "role" TYPE "UserRole" USING ("role"::text::"UserRole"); +DROP TYPE "UserRole_old"; diff --git a/apps/backend/prisma/migrations/20260707100000_storepass_renewal_reconciliation/migration.sql b/apps/backend/prisma/migrations/20260707100000_storepass_renewal_reconciliation/migration.sql new file mode 100644 index 00000000..63212d41 --- /dev/null +++ b/apps/backend/prisma/migrations/20260707100000_storepass_renewal_reconciliation/migration.sql @@ -0,0 +1,18 @@ +-- AlterTable +ALTER TABLE "storepass_billing_attempts" + ADD COLUMN "retry_count" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "max_retries" INTEGER NOT NULL DEFAULT 3, + ADD COLUMN "next_retry_at" TIMESTAMP(3), + ADD COLUMN "last_retry_at" TIMESTAMP(3), + ADD COLUMN "last_reconciled_at" TIMESTAMP(3), + ADD COLUMN "reconciliation_status" TEXT, + ADD COLUMN "needs_review" BOOLEAN NOT NULL DEFAULT false; + +-- CreateIndex +CREATE INDEX "storepass_billing_attempts_next_retry_at_idx" ON "storepass_billing_attempts"("next_retry_at"); + +-- CreateIndex +CREATE INDEX "storepass_billing_attempts_last_reconciled_at_idx" ON "storepass_billing_attempts"("last_reconciled_at"); + +-- CreateIndex +CREATE INDEX "storepass_billing_attempts_needs_review_idx" ON "storepass_billing_attempts"("needs_review"); diff --git a/apps/backend/prisma/migrations/20260710120000_add_user_alternative_delivery_phone/migration.sql b/apps/backend/prisma/migrations/20260710120000_add_user_alternative_delivery_phone/migration.sql new file mode 100644 index 00000000..7a369359 --- /dev/null +++ b/apps/backend/prisma/migrations/20260710120000_add_user_alternative_delivery_phone/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "alternative_delivery_phone" TEXT; diff --git a/apps/backend/prisma/migrations/20260711160000_post_engagement_counters_and_feed_index/migration.sql b/apps/backend/prisma/migrations/20260711160000_post_engagement_counters_and_feed_index/migration.sql new file mode 100644 index 00000000..ee08a9fd --- /dev/null +++ b/apps/backend/prisma/migrations/20260711160000_post_engagement_counters_and_feed_index/migration.sql @@ -0,0 +1,20 @@ +-- Denormalized engagement counters on posts. +-- Feeds previously computed likes/comments/saves via COUNT(*) over the +-- relation tables for every post row on every page render. These columns are +-- the display source of truth: incremented/decremented atomically by the +-- PostService write paths and drift-corrected hourly by +-- EngagementRecountService. +ALTER TABLE "posts" ADD COLUMN "like_count" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "posts" ADD COLUMN "comment_count" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "posts" ADD COLUMN "save_count" INTEGER NOT NULL DEFAULT 0; + +-- Backfill from the existing engagement rows (one-time, set-based). +UPDATE "posts" p SET + "like_count" = (SELECT COUNT(*) FROM "likes" l WHERE l."post_id" = p."id"), + "comment_count" = (SELECT COUNT(*) FROM "comments" c WHERE c."post_id" = p."id"), + "save_count" = (SELECT COUNT(*) FROM "saved_posts" s WHERE s."post_id" = p."id"); + +-- Feed hot-path index: every feed tab filters is_active = true and orders by +-- created_at DESC. Without this the planner sorts a filtered scan as the +-- posts table grows. +CREATE INDEX "posts_is_active_created_at_idx" ON "posts"("is_active", "created_at"); diff --git a/apps/backend/prisma/migrations/20260712100000_add_transactional_outbox/migration.sql b/apps/backend/prisma/migrations/20260712100000_add_transactional_outbox/migration.sql new file mode 100644 index 00000000..d1974826 --- /dev/null +++ b/apps/backend/prisma/migrations/20260712100000_add_transactional_outbox/migration.sql @@ -0,0 +1,30 @@ +CREATE TYPE "OutboxStatus" AS ENUM ('PENDING', 'PROCESSING', 'PROCESSED', 'DEAD'); + +CREATE TABLE "outbox_events" ( + "id" TEXT NOT NULL, + "topic" TEXT NOT NULL, + "version" INTEGER NOT NULL DEFAULT 1, + "aggregate_type" TEXT, + "aggregate_id" TEXT, + "payload" JSONB NOT NULL, + "status" "OutboxStatus" NOT NULL DEFAULT 'PENDING', + "idempotency_key" TEXT NOT NULL, + "attempts" INTEGER NOT NULL DEFAULT 0, + "available_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "locked_at" TIMESTAMP(3), + "locked_by" TEXT, + "processed_at" TIMESTAMP(3), + "last_error" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "outbox_events_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "notifications" ADD COLUMN "dedupe_key" TEXT; + +CREATE UNIQUE INDEX "outbox_events_idempotency_key_key" ON "outbox_events"("idempotency_key"); +CREATE UNIQUE INDEX "notifications_dedupe_key_key" ON "notifications"("dedupe_key"); +CREATE INDEX "outbox_events_status_available_at_created_at_idx" ON "outbox_events"("status", "available_at", "created_at"); +CREATE INDEX "outbox_events_locked_at_idx" ON "outbox_events"("locked_at"); +CREATE INDEX "outbox_events_aggregate_type_aggregate_id_created_at_idx" ON "outbox_events"("aggregate_type", "aggregate_id", "created_at"); \ No newline at end of file diff --git a/apps/backend/prisma/migrations/20260712180000_add_active_dispute_unique_index/migration.sql b/apps/backend/prisma/migrations/20260712180000_add_active_dispute_unique_index/migration.sql new file mode 100644 index 00000000..b8d8452f --- /dev/null +++ b/apps/backend/prisma/migrations/20260712180000_add_active_dispute_unique_index/migration.sql @@ -0,0 +1,10 @@ +-- Enforce a single active dispute per order at the database level. +-- Resolved/closed disputes may coexist for audit/history, but only one +-- OPEN/STORE_RESPONDED/UNDER_REVIEW dispute may exist per order at a time. +-- This closes the race the application-level findFirst check cannot: +-- two simultaneous open requests can otherwise both pass the check and insert. +-- A unique-constraint violation (P2002) here is mapped to DISPUTE_ALREADY_OPEN. + +CREATE UNIQUE INDEX IF NOT EXISTS "disputes_one_active_per_order_key" + ON "disputes"("order_id") + WHERE "status" IN ('OPEN', 'STORE_RESPONDED', 'UNDER_REVIEW'); diff --git a/apps/backend/prisma/migrations/20260712200000_add_notification_audience/migration.sql b/apps/backend/prisma/migrations/20260712200000_add_notification_audience/migration.sql new file mode 100644 index 00000000..900207a1 --- /dev/null +++ b/apps/backend/prisma/migrations/20260712200000_add_notification_audience/migration.sql @@ -0,0 +1,48 @@ +-- Partition notifications by identity (shopper vs store owner). +-- A twizrr account is both a shopper and (optionally) a store owner; the app +-- switches modes. Each mode's notifications page + unread badge must only +-- reflect that identity, so every notification carries an audience. + +-- CreateEnum +CREATE TYPE "NotificationAudience" AS ENUM ('SHOPPER', 'STORE'); + +-- AlterTable: default new rows to SHOPPER; store-directed writes set STORE. +ALTER TABLE "notifications" + ADD COLUMN "audience" "NotificationAudience" NOT NULL DEFAULT 'SHOPPER'; + +-- Backfill existing rows to STORE where they are store-directed. The reliable +-- historical signal is metadata.isMerchantId (set on every store-directed +-- legacy trigger); store-only notification types and /store/* deep links are +-- included as belt-and-braces. Dual-audience types (PAYMENT_CONFIRMED, +-- DELIVERY_CONFIRMED, DISPUTE_RESOLVED, ORDER_AUTO_CONFIRMED) are only flipped +-- for the merchant copy via isMerchantId — the shopper copy stays SHOPPER. +UPDATE "notifications" +SET "audience" = 'STORE' +WHERE + ("metadata" ->> 'isMerchantId') = 'true' + OR "url" LIKE '/store/%' + OR "type" IN ( + 'PAYOUT_INITIATED', + 'PAYOUT_COMPLETED', + 'PAYOUT_FAILED', + 'PAYOUT_SENT', + 'PAYOUT_REQUESTED', + 'PAYOUT_REQUEST_RECEIVED', + 'ORDER_PURCHASE_RECEIVED', + 'NEW_STORE_SUBMISSION', + 'STORE_VERIFIED', + 'STORE_REJECTED', + 'STORE_TIER_UPGRADED', + 'TIER_UPGRADED', + 'VERIFICATION_APPROVED', + 'VERIFICATION_REJECTED', + 'MODERATION_BLOCKED' + ); + +-- CreateIndex +CREATE INDEX "notifications_user_id_audience_created_at_idx" + ON "notifications"("user_id", "audience", "created_at"); + +-- CreateIndex +CREATE INDEX "notifications_user_id_audience_is_read_idx" + ON "notifications"("user_id", "audience", "is_read"); diff --git a/apps/backend/prisma/migrations/20260713100000_add_post_notification_subscriptions/migration.sql b/apps/backend/prisma/migrations/20260713100000_add_post_notification_subscriptions/migration.sql new file mode 100644 index 00000000..a33397ce --- /dev/null +++ b/apps/backend/prisma/migrations/20260713100000_add_post_notification_subscriptions/migration.sql @@ -0,0 +1,44 @@ +-- Post-notification subscriptions ("bell"): a follower opts in to a poster's +-- new posts. Store bells carry a scope (social / product / all); personal +-- profile bells are on/off. Row existence = bell on. Requires a follow. + +-- CreateEnum +CREATE TYPE "PostNotificationScope" AS ENUM ('SOCIAL', 'PRODUCT', 'ALL'); + +-- AlterTable: existing store subscriptions default to all posts. +ALTER TABLE "store_notification_subscriptions" + ADD COLUMN "scope" "PostNotificationScope" NOT NULL DEFAULT 'ALL'; + +-- CreateIndex +CREATE INDEX "store_notification_subscriptions_store_id_scope_idx" + ON "store_notification_subscriptions"("store_id", "scope"); + +-- CreateTable +CREATE TABLE "profile_post_subscriptions" ( + "id" TEXT NOT NULL, + "subscriber_id" TEXT NOT NULL, + "target_user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "profile_post_subscriptions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "profile_post_subscriptions_subscriber_id_target_user_id_key" + ON "profile_post_subscriptions"("subscriber_id", "target_user_id"); + +-- CreateIndex +CREATE INDEX "profile_post_subscriptions_target_user_id_idx" + ON "profile_post_subscriptions"("target_user_id"); + +-- AddForeignKey +ALTER TABLE "profile_post_subscriptions" + ADD CONSTRAINT "profile_post_subscriptions_subscriber_id_fkey" + FOREIGN KEY ("subscriber_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "profile_post_subscriptions" + ADD CONSTRAINT "profile_post_subscriptions_target_user_id_fkey" + FOREIGN KEY ("target_user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260716190000_add_user_devices/migration.sql b/apps/backend/prisma/migrations/20260716190000_add_user_devices/migration.sql new file mode 100644 index 00000000..e6146137 --- /dev/null +++ b/apps/backend/prisma/migrations/20260716190000_add_user_devices/migration.sql @@ -0,0 +1,32 @@ +-- First-party random device identity records. The raw browser UUID is never +-- persisted; device_id_hash is a server-peppered HMAC. +CREATE TABLE "user_devices" ( + "id" TEXT NOT NULL, + "user_id" TEXT, + "device_id_hash" TEXT NOT NULL, + "first_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_seen_at" TIMESTAMP(3) NOT NULL, + "first_seen_ip" TEXT, + "last_seen_ip" TEXT, + "first_user_agent" TEXT, + "last_user_agent" TEXT, + "device_type" TEXT, + "first_request_id" TEXT, + "last_request_id" TEXT, + "is_trusted" BOOLEAN NOT NULL DEFAULT false, + "revoked_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_devices_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "user_devices_user_id_idx" ON "user_devices"("user_id"); +CREATE INDEX "user_devices_device_id_hash_idx" ON "user_devices"("device_id_hash"); +CREATE UNIQUE INDEX "user_devices_user_id_device_id_hash_key" + ON "user_devices"("user_id", "device_id_hash"); + +ALTER TABLE "user_devices" + ADD CONSTRAINT "user_devices_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260716210000_add_user_location_preferences/migration.sql b/apps/backend/prisma/migrations/20260716210000_add_user_location_preferences/migration.sql new file mode 100644 index 00000000..54363dd2 --- /dev/null +++ b/apps/backend/prisma/migrations/20260716210000_add_user_location_preferences/migration.sql @@ -0,0 +1,33 @@ +-- Discovery-only, user-controlled location preference. This intentionally +-- contains no street address, delivery instructions, or coordinates. +CREATE TABLE "user_location_preferences" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "country_code" TEXT NOT NULL, + "country_name" TEXT, + "state" TEXT, + "city" TEXT, + "area" TEXT, + "label" TEXT, + "source" TEXT NOT NULL DEFAULT 'manual', + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_location_preferences_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "user_location_preferences_user_id_key" +ON "user_location_preferences"("user_id"); +CREATE INDEX "user_location_preferences_country_code_idx" +ON "user_location_preferences"("country_code"); +CREATE INDEX "user_location_preferences_state_idx" +ON "user_location_preferences"("state"); +CREATE INDEX "user_location_preferences_city_idx" +ON "user_location_preferences"("city"); +CREATE INDEX "user_location_preferences_area_idx" +ON "user_location_preferences"("area"); + +ALTER TABLE "user_location_preferences" +ADD CONSTRAINT "user_location_preferences_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260717120000_dispute_financial_settlement/migration.sql b/apps/backend/prisma/migrations/20260717120000_dispute_financial_settlement/migration.sql new file mode 100644 index 00000000..5e8e09fd --- /dev/null +++ b/apps/backend/prisma/migrations/20260717120000_dispute_financial_settlement/migration.sql @@ -0,0 +1,131 @@ +-- CreateEnum +CREATE TYPE "DisputeSettlementStatus" AS ENUM ('PENDING', 'PROCESSING', 'PARTIALLY_COMPLETED', 'COMPLETED', 'FAILED', 'RECONCILIATION_REQUIRED', 'MANUAL_REVIEW'); + +-- CreateEnum +CREATE TYPE "DisputeSettlementLegType" AS ENUM ('BUYER_REFUND', 'MERCHANT_PAYOUT'); + +-- CreateEnum +CREATE TYPE "DisputeSettlementLegStatus" AS ENUM ('PENDING', 'PROCESSING', 'SUBMITTED', 'COMPLETED', 'FAILED', 'RECONCILIATION_REQUIRED', 'MANUAL_REVIEW'); + +-- AlterEnum +ALTER TYPE "OrderStatus" ADD VALUE 'REFUNDED'; + +-- AlterEnum +ALTER TYPE "PaymentStatus" ADD VALUE 'PARTIALLY_REFUNDED'; + +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "PayoutStatus" ADD VALUE 'SUBMITTED'; +ALTER TYPE "PayoutStatus" ADD VALUE 'RECONCILIATION_REQUIRED'; + +-- AlterTable +ALTER TABLE "ledger_entries" ADD COLUMN "settlement_leg_id" TEXT; + +-- AlterTable +ALTER TABLE "payouts" ADD COLUMN "hold_reason" TEXT, +ADD COLUMN "next_reconcile_at" TIMESTAMP(3), +ADD COLUMN "on_hold" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "paystack_reference" TEXT, +ADD COLUMN "provider_status" TEXT, +ADD COLUMN "settlement_leg_id" TEXT, +ADD COLUMN "submitted_at" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "dispute_settlements" ( + "id" TEXT NOT NULL, + "dispute_id" TEXT NOT NULL, + "order_id" TEXT NOT NULL, + "outcome" "DisputeResolutionOutcome" NOT NULL, + "status" "DisputeSettlementStatus" NOT NULL DEFAULT 'PENDING', + "currency" TEXT NOT NULL DEFAULT 'NGN', + "captured_amount_kobo" BIGINT NOT NULL, + "buyer_refund_amount_kobo" BIGINT NOT NULL DEFAULT 0, + "merchant_payout_amount_kobo" BIGINT NOT NULL DEFAULT 0, + "platform_retained_amount_kobo" BIGINT NOT NULL DEFAULT 0, + "created_by" TEXT NOT NULL, + "idempotency_key" TEXT NOT NULL, + "started_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "failure_reason" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "dispute_settlements_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "dispute_settlement_legs" ( + "id" TEXT NOT NULL, + "settlement_id" TEXT NOT NULL, + "type" "DisputeSettlementLegType" NOT NULL, + "status" "DisputeSettlementLegStatus" NOT NULL DEFAULT 'PENDING', + "amount_kobo" BIGINT NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'NGN', + "provider" TEXT, + "provider_reference" TEXT, + "provider_operation_id" TEXT, + "attempts" INTEGER NOT NULL DEFAULT 0, + "last_error" TEXT, + "submitted_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "next_reconcile_at" TIMESTAMP(3), + "idempotency_key" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "dispute_settlement_legs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "dispute_settlements_dispute_id_key" ON "dispute_settlements"("dispute_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "dispute_settlements_idempotency_key_key" ON "dispute_settlements"("idempotency_key"); + +-- CreateIndex +CREATE INDEX "dispute_settlements_status_created_at_idx" ON "dispute_settlements"("status", "created_at"); + +-- CreateIndex +CREATE INDEX "dispute_settlements_order_id_idx" ON "dispute_settlements"("order_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "dispute_settlement_legs_idempotency_key_key" ON "dispute_settlement_legs"("idempotency_key"); + +-- CreateIndex +CREATE INDEX "dispute_settlement_legs_status_next_reconcile_at_idx" ON "dispute_settlement_legs"("status", "next_reconcile_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "dispute_settlement_legs_settlement_id_type_key" ON "dispute_settlement_legs"("settlement_id", "type"); + +-- CreateIndex +CREATE INDEX "ledger_entries_settlement_leg_id_idx" ON "ledger_entries"("settlement_leg_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "payouts_paystack_reference_key" ON "payouts"("paystack_reference"); + +-- CreateIndex +CREATE UNIQUE INDEX "payouts_settlement_leg_id_key" ON "payouts"("settlement_leg_id"); + +-- CreateIndex +CREATE INDEX "payouts_status_next_reconcile_at_idx" ON "payouts"("status", "next_reconcile_at"); + +-- AddForeignKey +ALTER TABLE "payouts" ADD CONSTRAINT "payouts_settlement_leg_id_fkey" FOREIGN KEY ("settlement_leg_id") REFERENCES "dispute_settlement_legs"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dispute_settlements" ADD CONSTRAINT "dispute_settlements_dispute_id_fkey" FOREIGN KEY ("dispute_id") REFERENCES "disputes"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dispute_settlements" ADD CONSTRAINT "dispute_settlements_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dispute_settlement_legs" ADD CONSTRAINT "dispute_settlement_legs_settlement_id_fkey" FOREIGN KEY ("settlement_id") REFERENCES "dispute_settlements"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ledger_entries" ADD CONSTRAINT "ledger_entries_settlement_leg_id_fkey" FOREIGN KEY ("settlement_leg_id") REFERENCES "dispute_settlement_legs"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260718103000_add_payment_provider_binding/migration.sql b/apps/backend/prisma/migrations/20260718103000_add_payment_provider_binding/migration.sql new file mode 100644 index 00000000..6b474821 --- /dev/null +++ b/apps/backend/prisma/migrations/20260718103000_add_payment_provider_binding/migration.sql @@ -0,0 +1,24 @@ +-- A payment must remain bound to the provider that initialized it. This makes +-- later provider configuration changes unable to route existing operations to +-- the wrong provider. +CREATE TYPE "PaymentProviderName" AS ENUM ('PAYSTACK', 'MONNIFY'); + +ALTER TABLE "payments" + ADD COLUMN "provider" "PaymentProviderName" NOT NULL DEFAULT 'PAYSTACK', + ADD COLUMN "provider_reference" TEXT, + ADD COLUMN "provider_transaction_reference" TEXT; + +UPDATE "payments" +SET "provider_reference" = "paystack_reference" +WHERE "provider_reference" IS NULL; + +ALTER TABLE "payments" + ALTER COLUMN "provider_reference" SET NOT NULL, + ALTER COLUMN "paystack_reference" DROP NOT NULL; + +CREATE UNIQUE INDEX "payments_provider_reference_key" + ON "payments"("provider_reference"); +CREATE UNIQUE INDEX "payments_provider_provider_transaction_reference_key" + ON "payments"("provider", "provider_transaction_reference"); +CREATE INDEX "payments_provider_provider_reference_idx" + ON "payments"("provider", "provider_reference"); diff --git a/apps/backend/prisma/migrations/20260718140000_enforce_ledger_append_only/migration.sql b/apps/backend/prisma/migrations/20260718140000_enforce_ledger_append_only/migration.sql new file mode 100644 index 00000000..22560cf7 --- /dev/null +++ b/apps/backend/prisma/migrations/20260718140000_enforce_ledger_append_only/migration.sql @@ -0,0 +1,18 @@ +-- Financial ledger rows are immutable once written. Reversals must be new +-- entries; corrections must never mutate or delete the original record. +CREATE OR REPLACE FUNCTION "prevent_ledger_entry_mutation"() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'ledger_entries is append-only; create a compensating entry instead' + USING ERRCODE = '55000'; +END; +$$; + +DROP TRIGGER IF EXISTS "ledger_entries_append_only" ON "ledger_entries"; + +CREATE TRIGGER "ledger_entries_append_only" +BEFORE UPDATE OR DELETE ON "ledger_entries" +FOR EACH ROW +EXECUTE FUNCTION "prevent_ledger_entry_mutation"(); diff --git a/apps/backend/prisma/migrations/20260718150000_add_payment_attempts_and_reconciliation_states/migration.sql b/apps/backend/prisma/migrations/20260718150000_add_payment_attempts_and_reconciliation_states/migration.sql new file mode 100644 index 00000000..ca7bed90 --- /dev/null +++ b/apps/backend/prisma/migrations/20260718150000_add_payment_attempts_and_reconciliation_states/migration.sql @@ -0,0 +1,75 @@ +CREATE TYPE "PaymentAttemptStatus" AS ENUM ( + 'INITIALIZED', 'SUCCESS', 'FAILED', 'SUPERSEDED', 'RECONCILIATION_REQUIRED' +); + +ALTER TYPE "PaymentStatus" ADD VALUE IF NOT EXISTS 'RECONCILIATION_REQUIRED'; +ALTER TYPE "PaymentStatus" ADD VALUE IF NOT EXISTS 'MANUAL_REVIEW'; + +CREATE TABLE "payment_attempts" ( + "id" TEXT NOT NULL, + "payment_id" TEXT NOT NULL, + "provider" "PaymentProviderName" NOT NULL, + "provider_reference" TEXT NOT NULL, + "provider_transaction_reference" TEXT, + "status" "PaymentAttemptStatus" NOT NULL DEFAULT 'INITIALIZED', + "initialized_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "verified_at" TIMESTAMP(3), + "superseded_at" TIMESTAMP(3), + "reconciliation_required_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "payment_attempts_pkey" PRIMARY KEY ("id") +); + +INSERT INTO "payment_attempts" ( + "id", + "payment_id", + "provider", + "provider_reference", + "provider_transaction_reference", + "status", + "initialized_at", + "verified_at" +) +SELECT + 'legacy-attempt-' || "id", + "id", + "provider", + "provider_reference", + "provider_transaction_reference", + CASE + WHEN "status" IN ('SUCCESS', 'REFUNDED', 'PARTIALLY_REFUNDED') + THEN 'SUCCESS'::"PaymentAttemptStatus" + WHEN "status" = 'FAILED' + THEN 'FAILED'::"PaymentAttemptStatus" + ELSE 'INITIALIZED'::"PaymentAttemptStatus" + END, + "created_at", + "verified_at" +FROM "payments"; + +CREATE UNIQUE INDEX "payment_attempts_provider_reference_key" ON "payment_attempts"("provider_reference"); +CREATE INDEX "payment_attempts_payment_id_status_idx" ON "payment_attempts"("payment_id", "status"); +CREATE INDEX "payment_attempts_provider_provider_reference_idx" ON "payment_attempts"("provider", "provider_reference"); +ALTER TABLE "payment_attempts" ADD CONSTRAINT "payment_attempts_payment_id_fkey" + FOREIGN KEY ("payment_id") REFERENCES "payments"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- A Payment is the immutable provider-selection record. Retry references live +-- in payment_attempts; changing the parent binding would orphan late webhooks. +CREATE OR REPLACE FUNCTION "enforce_payment_provider_binding_immutable"() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW."provider" IS DISTINCT FROM OLD."provider" + OR NEW."provider_reference" IS DISTINCT FROM OLD."provider_reference" + OR NEW."provider_transaction_reference" IS DISTINCT FROM OLD."provider_transaction_reference" + OR NEW."paystack_reference" IS DISTINCT FROM OLD."paystack_reference" THEN + RAISE EXCEPTION 'Payment provider binding is immutable; create a payment attempt instead'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER "payments_provider_binding_immutable" +BEFORE UPDATE ON "payments" +FOR EACH ROW +EXECUTE FUNCTION "enforce_payment_provider_binding_immutable"(); diff --git a/apps/backend/prisma/migrations/20260718160000_allow_initial_payment_transaction_reference/migration.sql b/apps/backend/prisma/migrations/20260718160000_allow_initial_payment_transaction_reference/migration.sql new file mode 100644 index 00000000..6f475127 --- /dev/null +++ b/apps/backend/prisma/migrations/20260718160000_allow_initial_payment_transaction_reference/migration.sql @@ -0,0 +1,18 @@ +-- The provider transaction ID is returned only after Twizrr has submitted an +-- already-persisted payment reference. Permit that one null-to-value binding, +-- while keeping provider ownership and every subsequent binding immutable. +CREATE OR REPLACE FUNCTION "enforce_payment_provider_binding_immutable"() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW."provider" IS DISTINCT FROM OLD."provider" + OR NEW."provider_reference" IS DISTINCT FROM OLD."provider_reference" + OR NEW."paystack_reference" IS DISTINCT FROM OLD."paystack_reference" + OR ( + OLD."provider_transaction_reference" IS NOT NULL + AND NEW."provider_transaction_reference" IS DISTINCT FROM OLD."provider_transaction_reference" + ) THEN + RAISE EXCEPTION 'Payment provider binding is immutable; create a payment attempt instead'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/apps/backend/prisma/migrations/20260718170000_add_payout_provider_binding/migration.sql b/apps/backend/prisma/migrations/20260718170000_add_payout_provider_binding/migration.sql new file mode 100644 index 00000000..4e18a725 --- /dev/null +++ b/apps/backend/prisma/migrations/20260718170000_add_payout_provider_binding/migration.sql @@ -0,0 +1,73 @@ +-- Provider-neutral payout binding. Existing payout rows are Paystack-owned; +-- retain their legacy references while copying them into neutral columns for +-- provider-safe reconciliation and support tooling. +BEGIN; + +CREATE TYPE "PayoutProviderName" AS ENUM ('PAYSTACK', 'MONNIFY'); +CREATE TYPE "PayoutProviderAttemptStatus" AS ENUM ('PROCESSING', 'SUBMITTED', 'COMPLETED', 'FAILED', 'RECONCILIATION_REQUIRED'); + +ALTER TABLE "payouts" + ADD COLUMN "provider" "PayoutProviderName" NOT NULL DEFAULT 'PAYSTACK', + ADD COLUMN "provider_reference" TEXT, + ADD COLUMN "provider_operation_id" TEXT, + ADD COLUMN "destination_bank_code" TEXT, + ADD COLUMN "destination_account_number" TEXT, + ADD COLUMN "destination_account_name" TEXT, + ADD COLUMN "destination_recipient_reference" TEXT; + +ALTER TABLE "store_profiles" + ADD COLUMN "payout_recipient_provider" "PayoutProviderName", + ADD COLUMN "payout_recipient_reference" TEXT; + +UPDATE "store_profiles" +SET + "payout_recipient_provider" = 'PAYSTACK', + "payout_recipient_reference" = "paystack_recipient_code" +WHERE "paystack_recipient_code" IS NOT NULL; + +UPDATE "payouts" +SET + "provider_reference" = "paystack_reference", + "provider_operation_id" = "paystack_transfer_code" +WHERE "provider_reference" IS NULL; + +UPDATE "payouts" AS "payout" +SET "destination_recipient_reference" = "store"."paystack_recipient_code" +FROM "store_profiles" AS "store" +WHERE "payout"."store_id" = "store"."id" + AND "payout"."destination_recipient_reference" IS NULL; + +CREATE TABLE "payout_attempts" ( + "id" TEXT NOT NULL, + "payout_id" TEXT NOT NULL, + "provider" "PayoutProviderName" NOT NULL, + "provider_reference" TEXT NOT NULL, + "provider_operation_id" TEXT, + "status" "PayoutProviderAttemptStatus" NOT NULL DEFAULT 'PROCESSING', + "failure_reason" TEXT, + "submitted_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "payout_attempts_pkey" PRIMARY KEY ("id"), + CONSTRAINT "payout_attempts_payout_id_fkey" FOREIGN KEY ("payout_id") REFERENCES "payouts"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +INSERT INTO "payout_attempts" ( + "id", "payout_id", "provider", "provider_reference", + "provider_operation_id", "status", "submitted_at", "completed_at", + "created_at", "updated_at" +) +SELECT + 'legacy-' || "id", "id", 'PAYSTACK'::"PayoutProviderName", + "paystack_reference", "paystack_transfer_code", + CASE + WHEN "status" = 'COMPLETED' THEN 'COMPLETED'::"PayoutProviderAttemptStatus" + WHEN "status" = 'FAILED' THEN 'FAILED'::"PayoutProviderAttemptStatus" + ELSE 'SUBMITTED'::"PayoutProviderAttemptStatus" + END, + "submitted_at", "completed_at", "created_at", "created_at" +FROM "payouts" +WHERE "paystack_reference" IS NOT NULL; + +COMMIT; diff --git a/apps/backend/prisma/migrations/20260718171000_add_payout_provider_indexes/migration.sql b/apps/backend/prisma/migrations/20260718171000_add_payout_provider_indexes/migration.sql new file mode 100644 index 00000000..c5b7626d --- /dev/null +++ b/apps/backend/prisma/migrations/20260718171000_add_payout_provider_indexes/migration.sql @@ -0,0 +1,11 @@ +-- Keep online index construction separate from the transactional payout +-- schema/backfill migration. IF NOT EXISTS makes a retry safe if PostgreSQL +-- completed an index build before the migration runner lost its connection. +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "payouts_provider_provider_reference_key" + ON "payouts"("provider", "provider_reference"); + +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "payout_attempts_provider_provider_reference_key" + ON "payout_attempts"("provider", "provider_reference"); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS "payout_attempts_payout_id_status_idx" + ON "payout_attempts"("payout_id", "status"); diff --git a/apps/backend/prisma/migrations/20260719120000_make_platform_fee_merchant_borne/migration.sql b/apps/backend/prisma/migrations/20260719120000_make_platform_fee_merchant_borne/migration.sql new file mode 100644 index 00000000..97eb6a27 --- /dev/null +++ b/apps/backend/prisma/migrations/20260719120000_make_platform_fee_merchant_borne/migration.sql @@ -0,0 +1,10 @@ +-- Existing orders were priced with the platform fee in the shopper total. +-- Preserve that immutable financial contract while making merchant-borne +-- fees the default for every order created after this migration. +CREATE TYPE "PlatformFeeBearer" AS ENUM ('SHOPPER', 'MERCHANT'); + +ALTER TABLE "orders" +ADD COLUMN "platform_fee_bearer" "PlatformFeeBearer" NOT NULL DEFAULT 'SHOPPER'; + +ALTER TABLE "orders" +ALTER COLUMN "platform_fee_bearer" SET DEFAULT 'MERCHANT'; diff --git a/apps/backend/prisma/migrations/20260719130000_add_store_verification_attempts/migration.sql b/apps/backend/prisma/migrations/20260719130000_add_store_verification_attempts/migration.sql new file mode 100644 index 00000000..e0e95424 --- /dev/null +++ b/apps/backend/prisma/migrations/20260719130000_add_store_verification_attempts/migration.sql @@ -0,0 +1,45 @@ +-- A durable verification attempt is created before a paid provider request. +-- active_key is unique while an outcome is unresolved, preventing concurrent +-- submissions from making duplicate Prembly verification calls. +CREATE TYPE "IdentityVerificationCheckType" AS ENUM ('NIN', 'NIN_FACE_MATCH', 'CAC'); + +CREATE TYPE "IdentityVerificationAttemptStatus" AS ENUM ( + 'SUBMITTED', + 'VERIFIED', + 'FAILED', + 'RECONCILIATION_REQUIRED', + 'MANUAL_REVIEW' +); + +CREATE TABLE "store_verification_attempts" ( + "id" TEXT NOT NULL, + "store_id" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "check_type" "IdentityVerificationCheckType" NOT NULL, + "status" "IdentityVerificationAttemptStatus" NOT NULL DEFAULT 'SUBMITTED', + "idempotency_key" TEXT NOT NULL, + "active_key" TEXT, + "provider_reference" TEXT, + "failure_code" TEXT, + "submitted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "store_verification_attempts_pkey" PRIMARY KEY ("id"), + CONSTRAINT "store_verification_attempts_store_id_fkey" + FOREIGN KEY ("store_id") REFERENCES "store_profiles"("id") + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX "store_verification_attempts_idempotency_key_key" + ON "store_verification_attempts"("idempotency_key"); + +CREATE UNIQUE INDEX "store_verification_attempts_active_key_key" + ON "store_verification_attempts"("active_key"); + +CREATE INDEX "store_verification_attempts_store_id_check_type_created_at_idx" + ON "store_verification_attempts"("store_id", "check_type", "created_at"); + +CREATE INDEX "store_verification_attempts_provider_provider_reference_idx" + ON "store_verification_attempts"("provider", "provider_reference"); diff --git a/apps/backend/prisma/migrations/20260719140000_add_payout_request_idempotency/migration.sql b/apps/backend/prisma/migrations/20260719140000_add_payout_request_idempotency/migration.sql new file mode 100644 index 00000000..cf9ae3e4 --- /dev/null +++ b/apps/backend/prisma/migrations/20260719140000_add_payout_request_idempotency/migration.sql @@ -0,0 +1,21 @@ +-- Add reservation identity and an optional immutable link to the payout that +-- consumes the reservation. Existing requests receive a deterministic legacy +-- key; no financial state is rewritten. +ALTER TABLE "payout_requests" + ADD COLUMN "idempotency_key" VARCHAR(128), + ADD COLUMN "payout_id" TEXT; + +UPDATE "payout_requests" +SET "idempotency_key" = 'legacy:' || "id" +WHERE "idempotency_key" IS NULL; + +-- Add the checks without scanning existing rows. Validation deliberately runs +-- in the next migration, after this migration's table locks have been released. +ALTER TABLE "payout_requests" + ADD CONSTRAINT "payout_requests_idempotency_key_not_null" + CHECK ("idempotency_key" IS NOT NULL) NOT VALID; + +ALTER TABLE "payout_requests" + ADD CONSTRAINT "payout_requests_payout_id_fkey" + FOREIGN KEY ("payout_id") REFERENCES "payouts"("id") + ON DELETE SET NULL ON UPDATE CASCADE NOT VALID; diff --git a/apps/backend/prisma/migrations/20260719140500_validate_payout_request_idempotency_constraints/migration.sql b/apps/backend/prisma/migrations/20260719140500_validate_payout_request_idempotency_constraints/migration.sql new file mode 100644 index 00000000..c97415f3 --- /dev/null +++ b/apps/backend/prisma/migrations/20260719140500_validate_payout_request_idempotency_constraints/migration.sql @@ -0,0 +1,33 @@ +-- Run validation only after the preceding migration has committed, so the +-- short ADD CONSTRAINT table lock is not retained through validation scans. +-- The conditional form also supports existing ephemeral preview branches that +-- applied the earlier migration version, where these constraints were already +-- validated and the temporary check was removed. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'payout_requests_idempotency_key_not_null' + AND conrelid = 'payout_requests'::regclass + ) THEN + ALTER TABLE "payout_requests" + VALIDATE CONSTRAINT "payout_requests_idempotency_key_not_null"; + END IF; + + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'payout_requests_payout_id_fkey' + AND conrelid = 'payout_requests'::regclass + ) THEN + ALTER TABLE "payout_requests" + VALIDATE CONSTRAINT "payout_requests_payout_id_fkey"; + END IF; +END $$; + +-- The validated check lets PostgreSQL apply SET NOT NULL without rescanning +-- the table. The temporary check is no longer needed afterwards. +ALTER TABLE "payout_requests" + ALTER COLUMN "idempotency_key" SET NOT NULL; + +ALTER TABLE "payout_requests" + DROP CONSTRAINT IF EXISTS "payout_requests_idempotency_key_not_null"; diff --git a/apps/backend/prisma/migrations/20260719141000_add_payout_request_idempotency_indexes/migration.sql b/apps/backend/prisma/migrations/20260719141000_add_payout_request_idempotency_indexes/migration.sql new file mode 100644 index 00000000..0b09347f --- /dev/null +++ b/apps/backend/prisma/migrations/20260719141000_add_payout_request_idempotency_indexes/migration.sql @@ -0,0 +1,13 @@ +-- Build reservation identities online. Prisma recognizes these indexes as the +-- model's compound and single-field unique constraints. +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS + "payout_requests_store_id_idempotency_key_key" + ON "payout_requests"("store_id", "idempotency_key"); + +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS + "payout_requests_payout_id_key" + ON "payout_requests"("payout_id"); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS + "payout_requests_store_id_status_idx" + ON "payout_requests"("store_id", "status"); diff --git a/apps/backend/prisma/migrations/20260719150000_add_payment_amount_exceptions/migration.sql b/apps/backend/prisma/migrations/20260719150000_add_payment_amount_exceptions/migration.sql new file mode 100644 index 00000000..aa0eb3a9 --- /dev/null +++ b/apps/backend/prisma/migrations/20260719150000_add_payment_amount_exceptions/migration.sql @@ -0,0 +1,78 @@ +CREATE TYPE "PaymentAmountExceptionType" AS ENUM ('UNDERPAID', 'OVERPAID'); +CREATE TYPE "PaymentAmountExceptionStatus" AS ENUM ( + 'RECONCILIATION_REQUIRED', + 'MANUAL_REVIEW', + 'RESOLVED' +); + +ALTER TYPE "LedgerEntryType" ADD VALUE IF NOT EXISTS 'PAYMENT_AMOUNT_EXCEPTION_RECEIVED'; + +CREATE TABLE "payment_amount_exceptions" ( + "id" TEXT NOT NULL, + "payment_id" TEXT NOT NULL, + "payment_attempt_id" TEXT NOT NULL, + "provider" "PaymentProviderName" NOT NULL, + "provider_reference" TEXT NOT NULL, + "provider_transaction_reference" TEXT, + "expected_amount_kobo" BIGINT NOT NULL, + "received_amount_kobo" BIGINT NOT NULL, + "exception_type" "PaymentAmountExceptionType" NOT NULL, + "status" "PaymentAmountExceptionStatus" NOT NULL DEFAULT 'RECONCILIATION_REQUIRED', + "detected_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "payment_amount_exceptions_pkey" PRIMARY KEY ("id"), + CONSTRAINT "payment_amount_exceptions_payment_id_fkey" + FOREIGN KEY ("payment_id") REFERENCES "payments"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "payment_amount_exceptions_payment_attempt_id_fkey" + FOREIGN KEY ("payment_attempt_id") REFERENCES "payment_attempts"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "payment_amount_exceptions_amounts_differ_check" + CHECK ("expected_amount_kobo" <> "received_amount_kobo"), + CONSTRAINT "payment_amount_exceptions_amounts_positive_check" + CHECK ("expected_amount_kobo" > 0 AND "received_amount_kobo" > 0) +); + +CREATE UNIQUE INDEX "payment_amount_exceptions_payment_attempt_id_key" + ON "payment_amount_exceptions"("payment_attempt_id"); +CREATE UNIQUE INDEX "payment_amount_exceptions_provider_provider_reference_key" + ON "payment_amount_exceptions"("provider", "provider_reference"); +CREATE INDEX "payment_amount_exceptions_payment_id_status_idx" + ON "payment_amount_exceptions"("payment_id", "status"); +CREATE INDEX "payment_amount_exceptions_provider_provider_transaction_reference_idx" + ON "payment_amount_exceptions"("provider", "provider_transaction_reference"); + +ALTER TABLE "ledger_entries" + ADD COLUMN "payment_amount_exception_id" TEXT; +ALTER TABLE "ledger_entries" + ADD CONSTRAINT "ledger_entries_payment_amount_exception_id_fkey" + FOREIGN KEY ("payment_amount_exception_id") + REFERENCES "payment_amount_exceptions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +CREATE INDEX "ledger_entries_payment_amount_exception_id_idx" + ON "ledger_entries"("payment_amount_exception_id"); + +-- Financial evidence for an amount mismatch must never be rewritten. A later +-- resolution workflow may only move status and record separate ledger entries. +CREATE OR REPLACE FUNCTION "enforce_payment_amount_exception_immutable"() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW."payment_id" IS DISTINCT FROM OLD."payment_id" + OR NEW."payment_attempt_id" IS DISTINCT FROM OLD."payment_attempt_id" + OR NEW."provider" IS DISTINCT FROM OLD."provider" + OR NEW."provider_reference" IS DISTINCT FROM OLD."provider_reference" + OR NEW."provider_transaction_reference" IS DISTINCT FROM OLD."provider_transaction_reference" + OR NEW."expected_amount_kobo" IS DISTINCT FROM OLD."expected_amount_kobo" + OR NEW."received_amount_kobo" IS DISTINCT FROM OLD."received_amount_kobo" + OR NEW."exception_type" IS DISTINCT FROM OLD."exception_type" + OR NEW."detected_at" IS DISTINCT FROM OLD."detected_at" + OR NEW."created_at" IS DISTINCT FROM OLD."created_at" THEN + RAISE EXCEPTION 'Payment amount exception financial evidence is immutable'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER "payment_amount_exceptions_immutable" +BEFORE UPDATE ON "payment_amount_exceptions" +FOR EACH ROW +EXECUTE FUNCTION "enforce_payment_amount_exception_immutable"(); diff --git a/apps/backend/prisma/migrations/20260719160000_add_payment_amount_exception_refunds/migration.sql b/apps/backend/prisma/migrations/20260719160000_add_payment_amount_exception_refunds/migration.sql new file mode 100644 index 00000000..8fb1c51f --- /dev/null +++ b/apps/backend/prisma/migrations/20260719160000_add_payment_amount_exception_refunds/migration.sql @@ -0,0 +1,66 @@ +-- Amount-exception collections are not order payments. A completed refund +-- reverses the entire unallocated collection, so no portion can reach escrow. +ALTER TYPE "PaymentAmountExceptionStatus" ADD VALUE IF NOT EXISTS 'REFUND_PENDING'; +ALTER TYPE "PaymentAmountExceptionStatus" ADD VALUE IF NOT EXISTS 'REFUND_SUBMITTED'; + +CREATE TYPE "PaymentAmountExceptionRefundStatus" AS ENUM ( + 'PENDING', + 'PROCESSING', + 'SUBMITTED', + 'COMPLETED', + 'FAILED', + 'RECONCILIATION_REQUIRED', + 'MANUAL_REVIEW' +); + +CREATE TABLE "payment_amount_exception_refunds" ( + "id" TEXT NOT NULL, + "payment_amount_exception_id" TEXT NOT NULL, + "provider" "PaymentProviderName" NOT NULL, + "transaction_reference" TEXT NOT NULL, + "amount_kobo" BIGINT NOT NULL, + "status" "PaymentAmountExceptionRefundStatus" NOT NULL DEFAULT 'PENDING', + "provider_refund_id" TEXT, + "attempts" INTEGER NOT NULL DEFAULT 0, + "failure_summary" TEXT, + "submitted_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "next_reconcile_at" TIMESTAMP(3), + "created_by" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "payment_amount_exception_refunds_pkey" PRIMARY KEY ("id"), + CONSTRAINT "payment_amount_exception_refunds_payment_amount_exception_id_fkey" + FOREIGN KEY ("payment_amount_exception_id") REFERENCES "payment_amount_exceptions"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "payment_amount_exception_refunds_amount_positive_check" + CHECK ("amount_kobo" > 0) +); + +CREATE UNIQUE INDEX "payment_amount_exception_refunds_payment_amount_exception_id_key" + ON "payment_amount_exception_refunds"("payment_amount_exception_id"); +CREATE UNIQUE INDEX "payment_amount_exception_refunds_provider_provider_refund_id_key" + ON "payment_amount_exception_refunds"("provider", "provider_refund_id"); +CREATE INDEX "payment_amount_exception_refunds_status_next_reconcile_at_idx" + ON "payment_amount_exception_refunds"("status", "next_reconcile_at"); + +-- Provider/amount evidence is immutable once the plan exists. Status and +-- provider outcome fields remain mutable through the guarded lifecycle. +CREATE OR REPLACE FUNCTION prevent_payment_amount_exception_refund_evidence_update() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.payment_amount_exception_id IS DISTINCT FROM OLD.payment_amount_exception_id + OR NEW.provider IS DISTINCT FROM OLD.provider + OR NEW.transaction_reference IS DISTINCT FROM OLD.transaction_reference + OR NEW.amount_kobo IS DISTINCT FROM OLD.amount_kobo + OR NEW.created_by IS DISTINCT FROM OLD.created_by + OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN + RAISE EXCEPTION 'Payment amount exception refund evidence is immutable'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER payment_amount_exception_refunds_immutable_evidence_trigger +BEFORE UPDATE ON "payment_amount_exception_refunds" +FOR EACH ROW EXECUTE FUNCTION prevent_payment_amount_exception_refund_evidence_update(); diff --git a/apps/backend/prisma/migrations/20260719170000_add_remaining_balance_collection/migration.sql b/apps/backend/prisma/migrations/20260719170000_add_remaining_balance_collection/migration.sql new file mode 100644 index 00000000..db9c31e4 --- /dev/null +++ b/apps/backend/prisma/migrations/20260719170000_add_remaining_balance_collection/migration.sql @@ -0,0 +1,24 @@ +ALTER TYPE "PaymentAmountExceptionStatus" ADD VALUE IF NOT EXISTS 'REMAINING_BALANCE_PENDING' AFTER 'RECONCILIATION_REQUIRED'; +ALTER TYPE "LedgerEntryType" ADD VALUE IF NOT EXISTS 'PAYMENT_AMOUNT_EXCEPTION_ALLOCATED' AFTER 'PAYMENT_AMOUNT_EXCEPTION_RECEIVED'; +ALTER TYPE "NotificationType" ADD VALUE IF NOT EXISTS 'PAYMENT_ACTION_REQUIRED' BEFORE 'ORDER_PAID'; + +ALTER TABLE "payment_attempts" +ADD COLUMN "expected_amount_kobo" BIGINT; + +UPDATE "payment_attempts" AS attempt +SET "expected_amount_kobo" = payment."amount_kobo" +FROM "payments" AS payment +WHERE payment."id" = attempt."payment_id"; + +-- Keep the column nullable for this rollout so older backend instances can +-- continue creating attempts during a rolling deployment. New code always +-- writes the immutable amount; a later cleanup may enforce NOT NULL after the +-- old application version is fully retired. + +ALTER TABLE "payment_amount_exceptions" +ADD COLUMN "remaining_balance_attempt_id" TEXT; + +ALTER TABLE "payment_amount_exceptions" +ADD CONSTRAINT "payment_amount_exceptions_remaining_balance_attempt_id_fkey" +FOREIGN KEY ("remaining_balance_attempt_id") REFERENCES "payment_attempts"("id") +ON DELETE RESTRICT ON UPDATE CASCADE NOT VALID; diff --git a/apps/backend/prisma/migrations/20260719170500_validate_remaining_balance_collection/migration.sql b/apps/backend/prisma/migrations/20260719170500_validate_remaining_balance_collection/migration.sql new file mode 100644 index 00000000..2c090a3a --- /dev/null +++ b/apps/backend/prisma/migrations/20260719170500_validate_remaining_balance_collection/migration.sql @@ -0,0 +1,5 @@ +-- Validate the staged foreign key after the schema/backfill migration releases +-- its initial table locks. expected_amount_kobo intentionally remains nullable +-- for rolling-deployment compatibility; all new writers populate it. +ALTER TABLE "payment_amount_exceptions" +VALIDATE CONSTRAINT "payment_amount_exceptions_remaining_balance_attempt_id_fkey"; diff --git a/apps/backend/prisma/migrations/20260719171000_add_remaining_balance_collection_index/migration.sql b/apps/backend/prisma/migrations/20260719171000_add_remaining_balance_collection_index/migration.sql new file mode 100644 index 00000000..3592eb07 --- /dev/null +++ b/apps/backend/prisma/migrations/20260719171000_add_remaining_balance_collection_index/migration.sql @@ -0,0 +1,5 @@ +-- Build the reservation identity online. Keep this in its own migration so +-- PostgreSQL can execute CREATE INDEX CONCURRENTLY outside other DDL work. +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS + "payment_amount_exceptions_remaining_balance_attempt_id_key" + ON "payment_amount_exceptions"("remaining_balance_attempt_id"); diff --git a/apps/backend/prisma/migrations/20260720100000_add_split_collection_refund_operations/migration.sql b/apps/backend/prisma/migrations/20260720100000_add_split_collection_refund_operations/migration.sql new file mode 100644 index 00000000..673c7487 --- /dev/null +++ b/apps/backend/prisma/migrations/20260720100000_add_split_collection_refund_operations/migration.sql @@ -0,0 +1,47 @@ +CREATE TYPE "DisputeSettlementRefundOperationStatus" AS ENUM ( + 'PENDING', + 'PROCESSING', + 'SUBMITTED', + 'COMPLETED', + 'FAILED', + 'RECONCILIATION_REQUIRED', + 'MANUAL_REVIEW' +); + +CREATE TABLE "dispute_settlement_refund_operations" ( + "id" TEXT NOT NULL, + "settlement_leg_id" TEXT NOT NULL, + "payment_attempt_id" TEXT NOT NULL, + "provider" "PaymentProviderName" NOT NULL, + "transaction_reference" TEXT NOT NULL, + "amount_kobo" BIGINT NOT NULL, + "status" "DisputeSettlementRefundOperationStatus" NOT NULL DEFAULT 'PENDING', + "provider_refund_id" TEXT, + "attempts" INTEGER NOT NULL DEFAULT 0, + "failure_summary" TEXT, + "submitted_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "next_reconcile_at" TIMESTAMP(3), + "idempotency_key" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "dispute_settlement_refund_operations_pkey" PRIMARY KEY ("id"), + CONSTRAINT "dispute_settlement_refund_operations_amount_positive_check" + CHECK ("amount_kobo" > 0), + CONSTRAINT "dispute_settlement_refund_operations_settlement_leg_id_fkey" + FOREIGN KEY ("settlement_leg_id") REFERENCES "dispute_settlement_legs"("id") + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "dispute_settlement_refund_operations_payment_attempt_id_fkey" + FOREIGN KEY ("payment_attempt_id") REFERENCES "payment_attempts"("id") + ON DELETE RESTRICT ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX "dispute_settlement_refund_operations_idempotency_key_key" + ON "dispute_settlement_refund_operations"("idempotency_key"); +CREATE UNIQUE INDEX "dispute_settlement_refund_operations_payment_attempt_id_key" + ON "dispute_settlement_refund_operations"("payment_attempt_id"); +CREATE UNIQUE INDEX "dispute_settlement_refund_operations_provider_refund_key" + ON "dispute_settlement_refund_operations"("provider", "provider_refund_id"); +CREATE INDEX "dispute_settlement_refund_operations_status_reconcile_idx" + ON "dispute_settlement_refund_operations"("status", "next_reconcile_at"); diff --git a/apps/backend/prisma/migrations/20260724120000_add_order_processor_fee_kobo/migration.sql b/apps/backend/prisma/migrations/20260724120000_add_order_processor_fee_kobo/migration.sql new file mode 100644 index 00000000..0b475074 --- /dev/null +++ b/apps/backend/prisma/migrations/20260724120000_add_order_processor_fee_kobo/migration.sql @@ -0,0 +1,6 @@ +-- Payment-processor (e.g. Paystack) fee captured from the verified charge, in +-- kobo. Snapshotted at payment confirmation and deducted from the merchant +-- payout so the merchant bears processing cost. Nullable and default-null so +-- existing orders are unaffected (payout math treats null as 0 — the platform +-- absorbed the fee for those historical orders). +ALTER TABLE "orders" ADD COLUMN "processor_fee_kobo" BIGINT; diff --git a/apps/backend/prisma/migrations/20260724130000_add_provider_fee_ledger_entry_type/migration.sql b/apps/backend/prisma/migrations/20260724130000_add_provider_fee_ledger_entry_type/migration.sql new file mode 100644 index 00000000..e38ac9ff --- /dev/null +++ b/apps/backend/prisma/migrations/20260724130000_add_provider_fee_ledger_entry_type/migration.sql @@ -0,0 +1,5 @@ +-- Audit-only ledger entry type for the payment-processor (Paystack) fee the +-- merchant bore on a captured charge. Recorded with direction INFO so it is +-- balance-neutral — the fee is already netted out of the merchant payout +-- amount; this value exists so processing cost is queryable per store/period. +ALTER TYPE "LedgerEntryType" ADD VALUE IF NOT EXISTS 'PROVIDER_FEE'; diff --git a/apps/backend/prisma/migrations/20260724140000_add_order_verified_at_and_decimal_fee_percent/migration.sql b/apps/backend/prisma/migrations/20260724140000_add_order_verified_at_and_decimal_fee_percent/migration.sql new file mode 100644 index 00000000..1e2e55f1 --- /dev/null +++ b/apps/backend/prisma/migrations/20260724140000_add_order_verified_at_and_decimal_fee_percent/migration.sql @@ -0,0 +1,10 @@ +-- Operator/admin inspection timestamp. Surfaces to the store as the +-- "Verified by twizrr" step. Nullable/default-null so existing orders are +-- unaffected (they simply show the step as not-yet-verified). +ALTER TABLE "orders" ADD COLUMN "verified_at" TIMESTAMP(3); + +-- Widen the nominal commission-rate snapshot from an integer to a decimal so +-- fractional tier rates (1.5%, 0.5%) can be stored. int -> numeric is a safe, +-- lossless cast for existing values (2 -> 2.00). +ALTER TABLE "orders" + ALTER COLUMN "platform_fee_percent" TYPE DECIMAL(5, 2); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 15dad68d..fcae9d4f 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -7,8 +7,8 @@ datasource db { } model AdminProfile { - id String @id @default(uuid()) @db.Uuid - userId String @unique @map("user_id") @db.Uuid + id String @id @default(cuid()) + userId String @unique @map("user_id") department String? accessLevel String @default("STANDARD") @map("access_level") createdAt DateTime @default(now()) @map("created_at") @@ -20,105 +20,184 @@ model AdminProfile { } model InventoryEvent { - id String @id @default(uuid()) @db.Uuid - productId String @map("product_id") @db.Uuid - merchantId String @map("merchant_id") @db.Uuid - eventType InventoryEventType @map("event_type") - quantity Int - referenceId String? @map("reference_id") @db.Uuid - notes String? - createdAt DateTime @default(now()) @map("created_at") - merchantProfile MerchantProfile @relation(fields: [merchantId], references: [id]) - product Product @relation(fields: [productId], references: [id]) - - @@index([merchantId]) + id String @id @default(cuid()) + productId String @map("product_id") + variantId String? @map("variant_id") + storeId String @map("store_id") + eventType InventoryEventType @map("event_type") + quantity Int + referenceId String? @map("reference_id") + notes String? + createdAt DateTime @default(now()) @map("created_at") + storeProfile StoreProfile @relation(fields: [storeId], references: [id]) + product Product @relation(fields: [productId], references: [id]) + variant ProductVariant? @relation(fields: [variantId], references: [id], onDelete: Restrict) + + @@index([storeId]) @@index([productId]) + @@index([variantId]) @@map("inventory_events") } -model MerchantProfile { - id String @id @default(uuid()) @db.Uuid - userId String @unique @map("user_id") @db.Uuid - slug String? - businessName String @map("business_name") - businessAddress String? @map("business_address") - cacNumber String? @map("cac_number") - bankCode String? @map("bank_code") - onboardingStep Int @default(1) @map("onboarding_step") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - businessType String? @map("business_type") - estYear String? @map("est_year") - category String? - taxId String? @map("tax_id") - warehouseLocation String? @map("warehouse_location") - distributionCenter String? @map("distribution_center") - warehouseCapacity String? @map("warehouse_capacity") - cacDocumentUrl String? @map("cac_document_url") - addressVerified Boolean @default(false) @map("address_verified") - bankAccountNumber String? @map("bank_account_number") - bankVerified Boolean @default(false) @map("bank_verified") - cacVerified Boolean @default(false) @map("cac_verified") - guarantorVerified Boolean @default(false) @map("guarantor_verified") - profileImage String? @map("profile_image") - coverImage String? @map("cover_image") - paystackRecipientCode String? @map("paystack_recipient_code") - responseTimeTotal BigInt @default(0) @map("response_time_total") - settlementAccountName String? @map("settlement_account_name") - verifiedAt DateTime? @map("verified_at") - averageRating Float? @map("average_rating") - reviewCount Int @default(0) @map("review_count") - description String? - socialLinks Json? @default("{}") @map("social_links") - lastSlugChangeAt DateTime? @map("last_slug_change_at") - notificationPreferences Json? @default("{}") @map("notification_preferences") - verificationTier VerificationTier @default(UNVERIFIED) @map("verification_tier") - tierUpgradedAt DateTime? @map("tier_upgraded_at") - ninNumber String? @map("nin_number") - ninVerified Boolean @default(false) @map("nin_verified") - ninVerifiedAt DateTime? @map("nin_verified_at") - ninVerifiedVia String? @map("nin_verified_via") - cacVerifiedVia String? @map("cac_verified_via") - addressVerifiedVia String? @map("address_verified_via") - creditApplications CreditApplication[] - followers Follow[] @relation("MerchantFollowers") - inventoryEvents InventoryEvent[] - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - slugHistory MerchantSlugHistory[] - orders Order[] - payoutRequests PayoutRequest[] - payouts Payout[] - products Product[] - reorderReminders ReorderReminder[] - reviews Review[] - verificationRequests VerificationRequest[] +model StoreProfile { + id String @id @default(cuid()) + userId String @unique @map("user_id") + storeHandle String? @unique @map("store_handle") + storeName String? @map("store_name") + storeType StoreType @default(PHYSICAL) @map("store_type") + bio String? + logoUrl String? @map("logo_url") + bannerUrl String? @map("banner_url") + businessCategory String? @map("business_category") + businessPhone String? @map("business_phone") + businessPhoneVerified Boolean @default(false) @map("business_phone_verified") + businessPhoneVerifiedAt DateTime? @map("business_phone_verified_at") + businessPhoneChangeCount Int @default(0) @map("business_phone_change_count") + businessPhoneChangeWindowStartedAt DateTime? @map("business_phone_change_window_started_at") + homeAddress Json? @map("home_address") + bankName String? @map("bank_name") + accountNumber String? @map("account_number") + accountName String? @map("account_name") + tier StoreTier @default(TIER_0) + isOpen Boolean @default(true) @map("is_open") + showOwnerPublicly Boolean @default(true) @map("show_owner_publicly") + allowDropship Boolean @default(false) @map("allow_dropship") + completedOrders Int @default(0) @map("completed_orders") + disputeCountLast6Months Int @default(0) @map("dispute_count_last_6_months") + dispatchTimeAvgHours Int? @map("dispatch_time_avg_hours") + orderCompletionRateBps Int? @map("order_completion_rate_bps") + slug String? + businessName String @map("business_name") + businessAddress String? @map("business_address") + businessAddressDetails Json? @map("business_address_details") + bankCode String? @map("bank_code") + onboardingStep Int @default(1) @map("onboarding_step") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + businessType String? @map("business_type") + estYear String? @map("est_year") + category String? + taxId String? @map("tax_id") + warehouseLocation String? @map("warehouse_location") + distributionCenter String? @map("distribution_center") + warehouseCapacity String? @map("warehouse_capacity") + addressVerified Boolean @default(false) @map("address_verified") + bankAccountNumber String? @map("bank_account_number") + bankVerified Boolean @default(false) @map("bank_verified") + bankVerifiedAt DateTime? @map("bank_verified_at") + guarantorVerified Boolean @default(false) @map("guarantor_verified") + profileImage String? @map("profile_image") + coverImage String? @map("cover_image") + paystackRecipientCode String? @map("paystack_recipient_code") + payoutRecipientProvider PayoutProviderName? @map("payout_recipient_provider") + payoutRecipientReference String? @map("payout_recipient_reference") + responseTimeTotal BigInt @default(0) @map("response_time_total") + settlementAccountName String? @map("settlement_account_name") + verifiedAt DateTime? @map("verified_at") + averageRating Int? @map("average_rating") + reviewCount Int @default(0) @map("review_count") + description String? + socialLinks Json? @default("{}") @map("social_links") + lastSlugChangeAt DateTime? @map("last_slug_change_at") + notificationPreferences Json? @default("{}") @map("notification_preferences") + verificationTier VerificationTier @default(UNVERIFIED) @map("verification_tier") + tierUpgradedAt DateTime? @map("tier_upgraded_at") + ninNumber String? @map("nin_number") + ninVerified Boolean @default(false) @map("nin_verified") + ninVerifiedAt DateTime? @map("nin_verified_at") + ninVerifiedVia String? @map("nin_verified_via") + ninVerificationStatus String? @default("NONE") @map("nin_verification_status") + ninVerificationAttempts Int @default(0) @map("nin_verification_attempts") + ninVerificationLockedAt DateTime? @map("nin_verification_locked_at") + addressVerifiedVia String? @map("address_verified_via") + followers Follow[] @relation("StoreFollowers") + targetedFollowRelations FollowRelation[] @relation("TargetStoreRelations") + inventoryEvents InventoryEvent[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + slugHistory StoreSlugHistory[] + orders Order[] + conversations Conversation[] + collections StoreCollection[] + notificationSubscribers StoreNotificationSubscription[] + digitalSourcedProducts SourcedProduct[] @relation("DigitalStoreSourcedProducts") + physicalSourcedProducts SourcedProduct[] @relation("PhysicalStoreSourcedProducts") + payoutRequests PayoutRequest[] + payouts Payout[] + products Product[] + verificationAttempts StoreVerificationAttempt[] + verificationRequests VerificationRequest[] + disputes Dispute[] + storePassSubscription StorePassSubscription? + storePassInvoices StorePassInvoice[] + storePassBillingAttempts StorePassBillingAttempt[] + storePassPaymentMethods StorePassPaymentMethod[] + storePassEntitlements StorePassEntitlement[] + storePassEntitlementLedger StorePassEntitlementLedger[] + storePosts Post[] @relation("StorePosts") @@index([slug]) - @@map("merchant_profiles") + @@map("store_profiles") } model Notification { - id String @id @default(uuid()) @db.Uuid - userId String @map("user_id") @db.Uuid + id String @id @default(cuid()) + userId String @map("user_id") type String title String body String channel NotificationChannel - isRead Boolean @default(false) @map("is_read") + audience NotificationAudience @default(SHOPPER) + isRead Boolean @default(false) @map("is_read") metadata Json? - createdAt DateTime @default(now()) @map("created_at") + createdAt DateTime @default(now()) @map("created_at") url String? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + dedupeKey String? @unique @map("dedupe_key") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@index([userId, isRead]) + @@index([userId, audience, createdAt]) + @@index([userId, audience, isRead]) @@map("notifications") } +// Which identity a notification belongs to. A twizrr account is both a shopper +// and (optionally) a store owner; the app switches between shopping and store +// mode. Notifications are partitioned so each mode's page + unread badge only +// reflects that identity. +enum NotificationAudience { + SHOPPER + STORE +} + +model OutboxEvent { + id String @id @default(cuid()) + topic String + version Int @default(1) + aggregateType String? @map("aggregate_type") + aggregateId String? @map("aggregate_id") + payload Json + status OutboxStatus @default(PENDING) + idempotencyKey String @unique @map("idempotency_key") + attempts Int @default(0) + availableAt DateTime @default(now()) @map("available_at") + lockedAt DateTime? @map("locked_at") + lockedBy String? @map("locked_by") + processedAt DateTime? @map("processed_at") + lastError String? @map("last_error") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([status, availableAt, createdAt]) + @@index([lockedAt]) + @@index([aggregateType, aggregateId, createdAt]) + @@map("outbox_events") +} + model OrderEvent { - id String @id @default(uuid()) @db.Uuid - orderId String @map("order_id") @db.Uuid - triggeredBy String @map("triggered_by") @db.Uuid + id String @id @default(cuid()) + orderId String @map("order_id") + triggeredBy String @map("triggered_by") metadata Json? createdAt DateTime @default(now()) @map("created_at") fromStatus OrderStatus? @map("from_status") @@ -131,80 +210,164 @@ model OrderEvent { } model Order { - id String @id @default(uuid()) @db.Uuid - quoteId String? @unique @map("quote_id") @db.Uuid - buyerId String @map("buyer_id") @db.Uuid - merchantId String? @map("merchant_id") @db.Uuid - totalAmountKobo BigInt @map("total_amount_kobo") - deliveryFeeKobo BigInt @map("delivery_fee_kobo") - platformFeeKobo BigInt? @map("platform_fee_kobo") - platformFeePercent Float? @map("platform_fee_percent") - currency String @default("NGN") - status OrderStatus @default(PENDING_PAYMENT) - deliveryOtp String? @map("delivery_otp") - idempotencyKey String @unique @map("idempotency_key") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - dispatchedAt DateTime? @map("dispatched_at") - deliveryAddress String? @map("delivery_address") - deliveryDetails Json? @map("delivery_details") - disputeReason String? @map("dispute_reason") - disputeStatus OrderDisputeStatus @default(NONE) @map("dispute_status") - payoutStatus PayoutStatus @default(PENDING) @map("payout_status") - productId String? @map("product_id") @db.Uuid - quantity Int? - unitPriceKobo BigInt? @map("unit_price_kobo") - paymentMethod PaymentMethod @default(ESCROW) @map("payment_method") - deliveryMethod DeliveryMethod? @map("delivery_method") - disputeWindowEndsAt DateTime? @map("dispute_window_ends_at") - supplierId String? @map("supplier_id") @db.Uuid - supplierProductId String? @map("supplier_product_id") @db.Uuid - items Json? @map("items") - creditApplication CreditApplication? - deliveryBooking DeliveryBooking? - orderEvents OrderEvent[] - trackingEvents OrderTracking[] - metadata Json? @map("metadata") - user User @relation(fields: [buyerId], references: [id], onDelete: Cascade) - merchantProfile MerchantProfile? @relation(fields: [merchantId], references: [id]) - product Product? @relation(fields: [productId], references: [id]) - supplierProfile SupplierProfile? @relation(fields: [supplierId], references: [id]) - supplierProduct SupplierProduct? @relation(fields: [supplierProductId], references: [id]) - payments Payment[] - payout Payout? - reorderReminder ReorderReminder? - review Review? + id String @id @default(cuid()) + orderCode String @unique @default(dbgenerated()) @map("order_code") + quoteId String? @unique @map("quote_id") + buyerId String @map("buyer_id") + storeId String? @map("store_id") + totalAmountKobo BigInt @map("total_amount_kobo") + deliveryFeeKobo BigInt @map("delivery_fee_kobo") + platformFeeKobo BigInt? @map("platform_fee_kobo") + // Nominal commission rate snapshot for display/audit (e.g. 2, 1.5, 1, 0.5). + // Decimal so fractional tier rates fit; the authoritative charge is + // platformFeeKobo, and on a capped order the effective rate is lower. + platformFeePercent Decimal? @db.Decimal(5, 2) @map("platform_fee_percent") + // Payment-processor (e.g. Paystack) fee captured from the verified charge, in + // kobo. Snapshotted at payment confirmation and deducted from merchant payout + // so the merchant bears processing cost. Null on legacy orders (treated as 0). + processorFeeKobo BigInt? @map("processor_fee_kobo") + // Immutable pricing-policy snapshot. Existing orders are backfilled as + // SHOPPER; new orders deduct the fee from merchant proceeds. + platformFeeBearer PlatformFeeBearer @default(MERCHANT) @map("platform_fee_bearer") + currency String @default("NGN") + status OrderStatus @default(PENDING_PAYMENT) + deliveryOtp String? @map("delivery_otp") + idempotencyKey String @unique @map("idempotency_key") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + dispatchedAt DateTime? @map("dispatched_at") + // Set by a twizrr operator/admin when the physical item passes inspection + // (after pickup, before/at dispatch). Surfaces to the store as the + // "Verified by twizrr" step — the point the store is cleared for the order. + verifiedAt DateTime? @map("verified_at") + deliveryAddress String? @map("delivery_address") + deliveryDetails Json? @map("delivery_details") + deliveryPrimaryPhone String? @map("delivery_primary_phone") + deliverySecondaryPhone String? @map("delivery_secondary_phone") + deliveryAddressSnapshot Json? @map("delivery_address_snapshot") + pickupAddressSnapshot Json? @map("pickup_address_snapshot") + pickupStoreId String? @map("pickup_store_id") + sourceStoreId String? @map("source_store_id") + deliveryFeeSource String? @map("delivery_fee_source") + deliveryFeeRule String? @map("delivery_fee_rule") + pickupZone String? @map("pickup_zone") + deliveryZone String? @map("delivery_zone") + disputeReason String? @map("dispute_reason") + disputeStatus OrderDisputeStatus @default(NONE) @map("dispute_status") + payoutStatus PayoutStatus @default(PENDING) @map("payout_status") + productId String? @map("product_id") + quantity Int? + unitPriceKobo BigInt? @map("unit_price_kobo") + deliveryMethod DeliveryMethod? @map("delivery_method") + disputeWindowEndsAt DateTime? @map("dispute_window_ends_at") + items Json? @map("items") + orderType OrderType @default(DIRECT) @map("order_type") + linkedOrderId String? @unique @map("linked_order_id") + sourcedProductId String? @map("sourced_product_id") + dropshipperCostKobo BigInt? @map("dropshipper_cost_kobo") + digitalMarginKobo BigInt? @map("digital_margin_kobo") + deliveryBooking DeliveryBooking? + ledgerEntries LedgerEntry[] + orderEvents OrderEvent[] + trackingEvents OrderTracking[] + metadata Json? @map("metadata") + user User @relation(fields: [buyerId], references: [id], onDelete: Cascade) + storeProfile StoreProfile? @relation(fields: [storeId], references: [id]) + product Product? @relation(fields: [productId], references: [id]) + sourcedProduct SourcedProduct? @relation(fields: [sourcedProductId], references: [id], onDelete: SetNull) + linkedOrder Order? @relation("LinkedOrderPair", fields: [linkedOrderId], references: [id], onDelete: SetNull) + linkedFromOrder Order? @relation("LinkedOrderPair") + payments Payment[] + payout Payout? + dvaAccount DVAAccount? + disputes Dispute[] + disputeSettlements DisputeSettlement[] @@index([buyerId]) - @@index([merchantId]) + @@index([storeId]) @@index([status]) - @@index([merchantId, status]) + @@index([storeId, status]) @@index([buyerId, status]) + @@index([orderType]) + @@index([sourcedProductId]) @@map("orders") } model Payout { - id String @id @default(uuid()) @db.Uuid - orderId String @unique @map("order_id") @db.Uuid - merchantId String @map("merchant_id") @db.Uuid - amountKobo BigInt @map("amount_kobo") - platformFeeKobo BigInt @map("platform_fee_kobo") - paystackTransferCode String? @map("paystack_transfer_code") - status PayoutStatus @default(PENDING) - initiatedAt DateTime? @map("initiated_at") - completedAt DateTime? @map("completed_at") - failureReason String? @map("failure_reason") - createdAt DateTime @default(now()) @map("created_at") - merchant MerchantProfile @relation(fields: [merchantId], references: [id]) - order Order @relation(fields: [orderId], references: [id]) - + id String @id @default(cuid()) + orderId String @unique @map("order_id") + storeId String @map("store_id") + amountKobo BigInt @map("amount_kobo") + platformFeeKobo BigInt @map("platform_fee_kobo") + paystackTransferCode String? @map("paystack_transfer_code") + // Stable transfer reference used for provider verification and reconciliation. + paystackReference String? @unique @map("paystack_reference") + // Provider ownership is immutable once a payout row exists. It prevents a + // later checkout-provider configuration change from re-routing an approved or + // already-submitted payout through another provider. + provider PayoutProviderName @default(PAYSTACK) + providerReference String? @map("provider_reference") + providerOperationId String? @map("provider_operation_id") + // Snapshot the destination used for this payout. Monnify transfers use bank + // details directly, so reading the mutable store bank record at release time + // could otherwise redirect a pending payout after a store updates its bank. + destinationBankCode String? @map("destination_bank_code") + destinationAccountNumber String? @map("destination_account_number") + destinationAccountName String? @map("destination_account_name") + destinationRecipientReference String? @map("destination_recipient_reference") + status PayoutStatus @default(PENDING) + // Normalized provider transfer status (PENDING | OTP | PROCESSING | SUCCESS + // | FAILED). Populated after a transfer is submitted; drives the + // acceptance-is-not-completion rule. + providerStatus String? @map("provider_status") + // Database-enforced dispute hold. A held payout can never progress to a + // provider transfer regardless of queue state. + onHold Boolean @default(false) @map("on_hold") + holdReason String? @map("hold_reason") + // Set on payouts created as a merchant-payout settlement leg so the normal + // dispute hold is bypassed for that exact approved leg only. + settlementLegId String? @unique @map("settlement_leg_id") + initiatedAt DateTime? @map("initiated_at") + submittedAt DateTime? @map("submitted_at") + nextReconcileAt DateTime? @map("next_reconcile_at") + completedAt DateTime? @map("completed_at") + failureReason String? @map("failure_reason") + createdAt DateTime @default(now()) @map("created_at") + ledgerEntries LedgerEntry[] + attempts PayoutAttempt[] + store StoreProfile @relation(fields: [storeId], references: [id]) + order Order @relation(fields: [orderId], references: [id]) + settlementLeg DisputeSettlementLeg? @relation(fields: [settlementLegId], references: [id]) + payoutRequest PayoutRequest? + + @@unique([provider, providerReference]) + @@index([status, nextReconcileAt]) @@map("payouts") } +model PayoutAttempt { + id String @id @default(cuid()) + payoutId String @map("payout_id") + provider PayoutProviderName + providerReference String @map("provider_reference") + providerOperationId String? @map("provider_operation_id") + status PayoutProviderAttemptStatus @default(PROCESSING) + failureReason String? @map("failure_reason") + submittedAt DateTime? @map("submitted_at") + completedAt DateTime? @map("completed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + payout Payout @relation(fields: [payoutId], references: [id], onDelete: Cascade) + + @@unique([provider, providerReference]) + @@index([payoutId, status]) + @@map("payout_attempts") +} + model CartItem { - id String @id @default(uuid()) @db.Uuid - buyerId String @map("buyer_id") @db.Uuid - productId String @map("product_id") @db.Uuid + id String @id @default(cuid()) + buyerId String @map("buyer_id") + productId String @map("product_id") quantity Int priceType PriceType @default(RETAIL) @map("price_type") createdAt DateTime @default(now()) @map("created_at") @@ -216,10 +379,150 @@ model CartItem { @@map("cart_items") } +model Dispute { + id String @id @default(cuid()) + orderId String @map("order_id") + buyerId String @map("buyer_id") + storeId String @map("store_id") + status DisputeStatus @default(OPEN) + reason String + description String + buyerRequestedOutcome String? @map("buyer_requested_outcome") + storeResponse String? @map("store_response") + resolutionOutcome DisputeResolutionOutcome? @map("resolution_outcome") + resolutionNotes String? @map("resolution_notes") + resolvedById String? @map("resolved_by_id") + resolvedAt DateTime? @map("resolved_at") + closedAt DateTime? @map("closed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + buyer User @relation("BuyerDisputes", fields: [buyerId], references: [id], onDelete: Cascade) + resolver User? @relation("ResolvedDisputes", fields: [resolvedById], references: [id]) + store StoreProfile @relation(fields: [storeId], references: [id]) + evidence DisputeEvidence[] + settlement DisputeSettlement? + + @@index([buyerId]) + @@index([storeId]) + @@index([status]) + @@index([createdAt]) + @@map("disputes") +} + +model DisputeEvidence { + id String @id @default(cuid()) + disputeId String @map("dispute_id") + actorId String @map("actor_id") + actorType DisputeActorType @map("actor_type") + url String + publicId String? @map("public_id") + note String? + createdAt DateTime @default(now()) @map("created_at") + dispute Dispute @relation(fields: [disputeId], references: [id], onDelete: Cascade) + actor User @relation("DisputeEvidenceActor", fields: [actorId], references: [id], onDelete: Cascade) + + @@index([disputeId]) + @@index([actorId]) + @@index([actorType]) + @@map("dispute_evidence") +} + +// Phase 5 — durable financial settlement header for a resolved dispute. Created +// inside the resolveDispute transaction, one per dispute. Money movement is +// executed asynchronously through the outbox; this row is the plan of record. +model DisputeSettlement { + id String @id @default(cuid()) + disputeId String @unique @map("dispute_id") + orderId String @map("order_id") + outcome DisputeResolutionOutcome + status DisputeSettlementStatus @default(PENDING) + currency String @default("NGN") + capturedAmountKobo BigInt @map("captured_amount_kobo") + buyerRefundAmountKobo BigInt @default(0) @map("buyer_refund_amount_kobo") + merchantPayoutAmountKobo BigInt @default(0) @map("merchant_payout_amount_kobo") + platformRetainedAmountKobo BigInt @default(0) @map("platform_retained_amount_kobo") + createdBy String @map("created_by") + idempotencyKey String @unique @map("idempotency_key") + startedAt DateTime? @map("started_at") + completedAt DateTime? @map("completed_at") + failureReason String? @map("failure_reason") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + dispute Dispute @relation(fields: [disputeId], references: [id], onDelete: Cascade) + order Order @relation(fields: [orderId], references: [id]) + legs DisputeSettlementLeg[] + + @@index([status, createdAt]) + @@index([orderId]) + @@map("dispute_settlements") +} + +// One executable money-movement leg (buyer refund or merchant payout). Provider +// identifiers are stored, never raw provider payloads. The unique +// (settlementId, type) pair guarantees at most one refund and one payout leg +// per settlement, so retries can never create a duplicate refund/transfer. +model DisputeSettlementLeg { + id String @id @default(cuid()) + settlementId String @map("settlement_id") + type DisputeSettlementLegType + status DisputeSettlementLegStatus @default(PENDING) + amountKobo BigInt @map("amount_kobo") + currency String @default("NGN") + provider String? + providerReference String? @map("provider_reference") + providerOperationId String? @map("provider_operation_id") + attempts Int @default(0) + lastError String? @map("last_error") + submittedAt DateTime? @map("submitted_at") + completedAt DateTime? @map("completed_at") + nextReconcileAt DateTime? @map("next_reconcile_at") + idempotencyKey String @unique @map("idempotency_key") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + settlement DisputeSettlement @relation(fields: [settlementId], references: [id], onDelete: Cascade) + ledgerEntries LedgerEntry[] + refundOperations DisputeSettlementRefundOperation[] + payout Payout? + + @@unique([settlementId, type]) + @@index([status, nextReconcileAt]) + @@map("dispute_settlement_legs") +} + +// One immutable provider refund operation for one confirmed collection that +// contributes to a buyer-refund settlement leg. A multi-collection payment +// creates one row per source attempt; the parent leg completes only after all +// rows are provider-confirmed. Provider acceptance is never completion. +model DisputeSettlementRefundOperation { + id String @id @default(cuid()) + settlementLegId String @map("settlement_leg_id") + paymentAttemptId String @unique @map("payment_attempt_id") + provider PaymentProviderName + transactionReference String @map("transaction_reference") + amountKobo BigInt @map("amount_kobo") + status DisputeSettlementRefundOperationStatus @default(PENDING) + providerRefundId String? @map("provider_refund_id") + attempts Int @default(0) + failureSummary String? @map("failure_summary") + submittedAt DateTime? @map("submitted_at") + completedAt DateTime? @map("completed_at") + nextReconcileAt DateTime? @map("next_reconcile_at") + idempotencyKey String @unique @map("idempotency_key") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + settlementLeg DisputeSettlementLeg @relation(fields: [settlementLegId], references: [id], onDelete: Cascade) + paymentAttempt PaymentAttempt @relation(fields: [paymentAttemptId], references: [id], onDelete: Restrict) + + @@unique([provider, providerRefundId], map: "dispute_settlement_refund_operations_provider_refund_key") + @@index([status, nextReconcileAt], map: "dispute_settlement_refund_operations_status_reconcile_idx") + @@map("dispute_settlement_refund_operations") +} + model SavedProduct { - id String @id @default(uuid()) @db.Uuid - userId String @map("user_id") @db.Uuid - productId String @map("product_id") @db.Uuid + id String @id @default(cuid()) + userId String @map("user_id") + productId String @map("product_id") createdAt DateTime @default(now()) @map("created_at") product Product @relation(fields: [productId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -229,9 +532,23 @@ model SavedProduct { @@map("saved_products") } +model SavedPost { + id String @id @default(cuid()) + userId String @map("user_id") + postId String @map("post_id") + createdAt DateTime @default(now()) @map("created_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + + @@unique([userId, postId]) + @@index([userId]) + @@index([postId]) + @@map("saved_posts") +} + model PaymentEvent { - id String @id @default(uuid()) @db.Uuid - paymentId String @map("payment_id") @db.Uuid + id String @id @default(cuid()) + paymentId String @map("payment_id") eventType String @map("event_type") payload Json createdAt DateTime @default(now()) @map("created_at") @@ -241,165 +558,429 @@ model PaymentEvent { @@map("payment_events") } -model Payment { - id String @id @default(uuid()) @db.Uuid - orderId String @map("order_id") @db.Uuid - paystackReference String @unique @map("paystack_reference") - paystackTransferRef String? @map("paystack_transfer_ref") - amountKobo BigInt @map("amount_kobo") - currency String @default("NGN") - status PaymentStatus @default(INITIALIZED) - direction PaymentDirection - idempotencyKey String @unique @map("idempotency_key") - verifiedAt DateTime? @map("verified_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - paymentEvents PaymentEvent[] - order Order @relation(fields: [orderId], references: [id]) +model WebhookEvent { + id String @id @default(cuid()) + provider String + eventId String @map("event_id") + eventType String @map("event_type") + reference String? + status WebhookStatus @default(PROCESSING) + payload Json + error String? + retryCount Int @default(0) @map("retry_count") + receivedAt DateTime @default(now()) @map("received_at") + processedAt DateTime? @map("processed_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + @@unique([provider, eventId]) + @@index([provider, eventType]) + @@index([provider, status]) + @@index([reference]) + @@map("webhook_events") +} +model Payment { + id String @id @default(cuid()) + orderId String @map("order_id") + // Immutable provider owner selected when the payment is initialized. It is + // never inferred from the current environment at verification time. + provider PaymentProviderName @default(PAYSTACK) + providerReference String @unique @map("provider_reference") + providerTransactionReference String? @map("provider_transaction_reference") + // Legacy Paystack reference retained for existing payout/refund compatibility. + // New non-Paystack payments leave this null and use providerReference instead. + paystackReference String? @unique @map("paystack_reference") + paystackTransferRef String? @map("paystack_transfer_ref") + amountKobo BigInt @map("amount_kobo") + currency String @default("NGN") + status PaymentStatus @default(INITIALIZED) + direction PaymentDirection + idempotencyKey String @unique @map("idempotency_key") + verifiedAt DateTime? @map("verified_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + ledgerEntries LedgerEntry[] + paymentEvents PaymentEvent[] + attempts PaymentAttempt[] + amountExceptions PaymentAmountException[] + order Order @relation(fields: [orderId], references: [id]) + + @@unique([provider, providerTransactionReference]) @@index([orderId]) + @@index([provider, providerReference]) @@map("payments") } +model PaymentAttempt { + id String @id @default(cuid()) + paymentId String @map("payment_id") + provider PaymentProviderName + providerReference String @unique @map("provider_reference") + providerTransactionReference String? @map("provider_transaction_reference") + // Immutable amount this exact provider checkout is allowed to collect. + // Initial/retry attempts use the order total; an approved underpayment + // continuation uses only the remaining balance. + expectedAmountKobo BigInt? @map("expected_amount_kobo") + status PaymentAttemptStatus @default(INITIALIZED) + initializedAt DateTime @default(now()) @map("initialized_at") + verifiedAt DateTime? @map("verified_at") + supersededAt DateTime? @map("superseded_at") + reconciliationRequiredAt DateTime? @map("reconciliation_required_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + payment Payment @relation(fields: [paymentId], references: [id], onDelete: Cascade) + amountException PaymentAmountException? @relation("AmountExceptionSourceAttempt") + remainingBalanceException PaymentAmountException? @relation("AmountExceptionRemainingAttempt") + settlementRefundOperations DisputeSettlementRefundOperation[] + + @@index([paymentId, status]) + @@index([provider, providerReference]) + @@map("payment_attempts") +} + +// A provider-confirmed amount mismatch is an unallocated collection, not an +// order payment. Its provider binding and financial evidence are immutable; +// a later guarded workflow may only progress the resolution status. +model PaymentAmountException { + id String @id @default(cuid()) + paymentId String @map("payment_id") + paymentAttemptId String @unique @map("payment_attempt_id") + remainingBalanceAttemptId String? @unique @map("remaining_balance_attempt_id") + provider PaymentProviderName + providerReference String @map("provider_reference") + providerTransactionReference String? @map("provider_transaction_reference") + expectedAmountKobo BigInt @map("expected_amount_kobo") + receivedAmountKobo BigInt @map("received_amount_kobo") + exceptionType PaymentAmountExceptionType @map("exception_type") + status PaymentAmountExceptionStatus @default(RECONCILIATION_REQUIRED) + detectedAt DateTime @default(now()) @map("detected_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + payment Payment @relation(fields: [paymentId], references: [id], onDelete: Restrict) + paymentAttempt PaymentAttempt @relation("AmountExceptionSourceAttempt", fields: [paymentAttemptId], references: [id], onDelete: Restrict) + remainingBalanceAttempt PaymentAttempt? @relation("AmountExceptionRemainingAttempt", fields: [remainingBalanceAttemptId], references: [id], onDelete: Restrict) + ledgerEntries LedgerEntry[] + refund PaymentAmountExceptionRefund? + + @@unique([provider, providerReference]) + @@index([paymentId, status]) + @@index([provider, providerTransactionReference]) + @@map("payment_amount_exceptions") +} + +// A full reversal of an overpaid, unallocated collection. This intentionally +// does not reuse dispute-settlement legs: no part of an amount mismatch is +// escrowed or allocated to an order before a future multi-collection design. +model PaymentAmountExceptionRefund { + id String @id @default(cuid()) + paymentAmountExceptionId String @unique @map("payment_amount_exception_id") + provider PaymentProviderName + transactionReference String @map("transaction_reference") + amountKobo BigInt @map("amount_kobo") + status PaymentAmountExceptionRefundStatus @default(PENDING) + providerRefundId String? @map("provider_refund_id") + attempts Int @default(0) + failureSummary String? @map("failure_summary") + submittedAt DateTime? @map("submitted_at") + completedAt DateTime? @map("completed_at") + nextReconcileAt DateTime? @map("next_reconcile_at") + createdBy String @map("created_by") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + paymentAmountException PaymentAmountException @relation(fields: [paymentAmountExceptionId], references: [id], onDelete: Restrict) + + @@unique([provider, providerRefundId]) + @@index([status, nextReconcileAt]) + @@map("payment_amount_exception_refunds") +} + +model LedgerEntry { + id String @id @default(cuid()) + entryType LedgerEntryType @map("entry_type") + direction LedgerDirection + amountKobo BigInt @map("amount_kobo") + currency String @default("NGN") + orderId String? @map("order_id") + paymentId String? @map("payment_id") + payoutId String? @map("payout_id") + storeId String? @map("store_id") + userId String? @map("user_id") + reference String? + // Correlates settlement refund/payout completion entries back to the exact + // settlement leg, so reconciliation can prove a leg is settled without + // re-parsing references. Null for all non-settlement ledger movement. + settlementLegId String? @map("settlement_leg_id") + paymentAmountExceptionId String? @map("payment_amount_exception_id") + idempotencyKey String @unique @map("idempotency_key") + metadata Json? @default("{}") + createdAt DateTime @default(now()) @map("created_at") + order Order? @relation(fields: [orderId], references: [id]) + payment Payment? @relation(fields: [paymentId], references: [id]) + payout Payout? @relation(fields: [payoutId], references: [id]) + settlementLeg DisputeSettlementLeg? @relation(fields: [settlementLegId], references: [id]) + paymentAmountException PaymentAmountException? @relation(fields: [paymentAmountExceptionId], references: [id]) + + @@index([orderId]) + @@index([paymentId]) + @@index([payoutId]) + @@index([storeId]) + @@index([userId]) + @@index([entryType]) + @@index([settlementLegId]) + @@index([paymentAmountExceptionId]) + @@index([createdAt]) + @@map("ledger_entries") +} + model PayoutRequest { - id String @id @default(uuid()) @db.Uuid - merchantId String @map("merchant_id") @db.Uuid - amountKobo BigInt @map("amount_kobo") - status PayoutRequestStatus @default(PENDING) - bankName String? @map("bank_name") - accountNumber String? @map("account_number") - accountName String? @map("account_name") - processedAt DateTime? @map("processed_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - merchantProfile MerchantProfile @relation(fields: [merchantId], references: [id]) - - @@index([merchantId]) + id String @id @default(cuid()) + storeId String @map("store_id") + idempotencyKey String @map("idempotency_key") @db.VarChar(128) + payoutId String? @unique @map("payout_id") + amountKobo BigInt @map("amount_kobo") + status PayoutRequestStatus @default(PENDING) + bankName String? @map("bank_name") + accountNumber String? @map("account_number") + accountName String? @map("account_name") + processedAt DateTime? @map("processed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + storeProfile StoreProfile @relation(fields: [storeId], references: [id]) + payout Payout? @relation(fields: [payoutId], references: [id]) + + @@unique([storeId, idempotencyKey]) + @@index([storeId]) @@index([status]) + @@index([storeId, status]) @@map("payout_requests") } -model MerchantSlugHistory { - id String @id @default(uuid()) @db.Uuid - merchantProfileId String @map("merchant_profile_id") @db.Uuid - oldSlug String @unique @map("old_slug") - newSlug String @map("new_slug") - changedAt DateTime @default(now()) @map("changed_at") - merchantProfile MerchantProfile @relation(fields: [merchantProfileId], references: [id], onDelete: Cascade) +model StoreSlugHistory { + id String @id @default(cuid()) + storeProfileId String @map("store_profile_id") + oldSlug String @unique @map("old_slug") + newSlug String @map("new_slug") + changedAt DateTime @default(now()) @map("changed_at") + storeProfile StoreProfile @relation(fields: [storeProfileId], references: [id], onDelete: Cascade) - @@index([merchantProfileId]) - @@map("merchant_slug_history") + @@index([storeProfileId]) + @@map("store_slug_history") } model ProductStockCache { - id String @id @default(uuid()) @db.Uuid - productId String @unique @map("product_id") @db.Uuid - stock Int @default(0) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - product Product @relation(fields: [productId], references: [id]) - + id String @id @default(cuid()) + productId String @map("product_id") + variantId String? @map("variant_id") + stock Int @default(0) + quantity Int @default(0) + lowStockThreshold Int @default(5) @map("low_stock_threshold") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + product Product @relation(fields: [productId], references: [id]) + variant ProductVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade) + + @@unique([productId, variantId]) + @@index([productId]) + @@index([variantId]) @@map("product_stock_cache") } model Product { - id String @id @default(uuid()) @db.Uuid - merchantId String @map("merchant_id") @db.Uuid - productCode String? @map("product_code") + id String @id @default(cuid()) + storeId String @map("store_id") + productCode String? @unique @map("product_code") + sourceCode String? @unique @map("source_code") + title String? + platformCategory String? @map("platform_category") + productSubCategory GuideType? @map("product_sub_category") + storeTags String[] @default([]) @map("store_tags") + productDetails Json? @map("product_details") + compareAtPriceKobo BigInt? @map("compare_at_price_kobo") + wholesaleTiers Json? @map("wholesale_tiers") + notSoldIndividually Boolean @default(false) @map("not_sold_individually") + minimumOrderQty Int? @map("minimum_order_qty") + allowDropship Boolean @default(false) @map("allow_dropship") + dropshipperPriceKobo BigInt? @map("dropshipper_price_kobo") + hasVariants Boolean @default(false) @map("has_variants") + status ProductStatus @default(DRAFT) + isFeatured Boolean @default(false) @map("is_featured") + sku String? + hideWhenOutOfStock Boolean @default(false) @map("hide_when_out_of_stock") + embeddingReady Boolean @default(false) @map("embedding_ready") + embeddingUpdatedAt DateTime? @map("embedding_updated_at") + moderationStatus ModerationStatus @default(PENDING) @map("moderation_status") + viewCountWeekly Int @default(0) @map("view_count_weekly") name String - shortDescription String? @map("short_description") + shortDescription String? @map("short_description") description String? unit String - categoryTag String @map("category_tag") - minOrderQuantity Int @default(1) @map("min_order_quantity") - isActive Boolean @default(true) @map("is_active") - isSeeded Boolean @default(false) @map("is_seeded") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") - imageUrl String? @map("image_url") - categoryId String @map("category_id") @db.Uuid - pricePerUnitKobo BigInt? @map("price_per_unit_kobo") - wholesalePriceKobo BigInt? @map("wholesale_price_kobo") - retailPriceKobo BigInt? @map("retail_price_kobo") - wholesaleDiscountPercent Float? @map("wholesale_discount_percent") - minOrderQuantityConsumer Int @default(1) @map("min_order_quantity_consumer") - attributes Json? @default("{}") - warehouseLocation String? @map("warehouse_location") - processingDays Int? @map("processing_days") - weightKg Float? @map("weight_kg") + categoryTag String @map("category_tag") + minOrderQuantity Int @default(1) @map("min_order_quantity") + isActive Boolean @default(true) @map("is_active") + isSeeded Boolean @default(false) @map("is_seeded") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + imageUrl String? @map("image_url") + categoryId String @map("category_id") + pricePerUnitKobo BigInt? @map("price_per_unit_kobo") + wholesalePriceKobo BigInt? @map("wholesale_price_kobo") + retailPriceKobo BigInt? @map("retail_price_kobo") + wholesaleDiscountPercent Int? @map("wholesale_discount_percent") + minOrderQuantityConsumer Int @default(1) @map("min_order_quantity_consumer") + attributes Json? @default("{}") + warehouseLocation String? @map("warehouse_location") + processingDays Int? @map("processing_days") + weightKg Int? @map("weight_kg") cartItems CartItem[] inventoryEvents InventoryEvent[] orders Order[] - productStockCache ProductStockCache? - category Category @relation(fields: [categoryId], references: [id]) - merchantProfile MerchantProfile @relation(fields: [merchantId], references: [id]) + productStockCaches ProductStockCache[] + category Category @relation(fields: [categoryId], references: [id]) + storeProfile StoreProfile @relation(fields: [storeId], references: [id]) savedByUsers SavedProduct[] + variants ProductVariant[] + images ProductImage[] + sizeGuideConfig ProductSizeGuideConfig? + embedding ProductEmbedding? + sourceListings SourcedProduct[] @relation("PhysicalProductSources") + taggedPosts Post[] @relation("TaggedProductPosts") @@index([categoryTag]) - @@index([merchantId]) + @@index([storeId]) @@index([productCode]) @@index([categoryId]) @@index([isActive, createdAt]) @@map("products") } -model StaffAccessToken { - id String @id @default(uuid()) @db.Uuid - role UserRole - tokenHash String @map("token_hash") - label String? - isActive Boolean @default(true) @map("is_active") - createdBy String @map("created_by") @db.Uuid - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - user User @relation(fields: [createdBy], references: [id]) +model User { + id String @id @default(cuid()) + email String @unique + phone String @unique + username String? @unique + displayName String? @map("display_name") + bio String? + profilePhotoUrl String? @map("profile_photo_url") + dateOfBirth DateTime? @map("date_of_birth") + lastUsernameChangedAt DateTime? @map("last_username_changed_at") + bodyMeasurements Json? @map("body_measurements") + deliveryAddresses Json? @map("delivery_addresses") + alternativeDeliveryPhone String? @map("alternative_delivery_phone") + interests String[] @default([]) + isActive Boolean @default(true) @map("is_active") + passwordHash String? @map("password_hash") + role UserRole + emailVerified Boolean @default(false) @map("email_verified") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + resetToken String? @map("reset_token") + resetTokenExpiry DateTime? @map("reset_token_expiry") + firstName String @map("first_name") + middleName String? @map("middle_name") + lastName String @map("last_name") + phoneVerified Boolean @default(false) @map("phone_verified") + paystackCustomerId String? @map("paystack_customer_id") + paystackCustomerCode String? @unique @map("paystack_customer_code") + dvaAccountNumber String? @map("dva_account_number") + dvaAccountName String? @map("dva_account_name") + dvaBankName String? @map("dva_bank_name") + dvaBankSlug String? @map("dva_bank_slug") + dvaActive Boolean @default(false) @map("dva_active") + adminProfile AdminProfile? + auditLogs AuditLog[] + cartItems CartItem[] @relation("UserCartItems") + follows Follow[] @relation("UserFollows") + storeProfile StoreProfile? + notifications Notification[] + orderEvents OrderEvent[] + orders Order[] + savedProducts SavedProduct[] + savedPosts SavedPost[] + buyerDisputes Dispute[] @relation("BuyerDisputes") + resolvedDisputes Dispute[] @relation("ResolvedDisputes") + disputeEvidence DisputeEvidence[] @relation("DisputeEvidenceActor") + reviewedVerificationRequests VerificationRequest[] @relation("VerificationRequestsReviewed") + authProviderAccounts AuthProviderAccount[] + whatsappLink WhatsAppLink? + sessions Session[] + devices UserDevice[] + otpCodes OTPCode[] + dvaAccounts DVAAccount[] + followingRelations FollowRelation[] @relation("FollowerRelations") + followerRelations FollowRelation[] @relation("TargetUserRelations") + deliveryAddressRows DeliveryAddress[] + locationPreference UserLocationPreference? + conversationsAsBuyer Conversation[] @relation("BuyerConversations") + messages Message[] + pushTokens PushToken[] + comments Comment[] + commentLikes CommentLike[] + likes Like[] + notificationSubscriptions StoreNotificationSubscription[] + profilePostSubscriptions ProfilePostSubscription[] @relation("ProfilePostSubscriber") + profilePostSubscribers ProfilePostSubscription[] @relation("ProfilePostTarget") + posts Post[] @relation("PostAuthor") + supportTickets SupportTicket[] + reviewedModerationLogs ModerationLog[] @relation("ModerationReviewedBy") + whatsappSessions WhatsAppSession[] + whatsappAnalytics WhatsAppAnalytics[] + storePassSubscriptions StorePassSubscription[] + storePassInvoicesPaid StorePassInvoice[] + storePassPaymentMethods StorePassPaymentMethod[] + storePassEntitlementLedgerEntries StorePassEntitlementLedger[] - @@map("staff_access_tokens") + @@map("users") } -model User { - id String @id @default(uuid()) @db.Uuid - email String @unique - phone String @unique - passwordHash String @map("password_hash") - role UserRole - emailVerified Boolean @default(false) @map("email_verified") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") - resetToken String? @map("reset_token") - resetTokenExpiry DateTime? @map("reset_token_expiry") - firstName String @map("first_name") - middleName String? @map("middle_name") - lastName String @map("last_name") - phoneVerified Boolean @default(false) @map("phone_verified") - adminProfile AdminProfile? - auditLogs AuditLog[] - bnplWaitlist BnplWaitlist? - buyerProfile BuyerProfile? - cartItems CartItem[] @relation("UserCartItems") - creditApplications CreditApplication[] - follows Follow[] @relation("UserFollows") - merchantProfile MerchantProfile? - notifications Notification[] - orderEvents OrderEvent[] - orders Order[] - reorderReminders ReorderReminder[] - savedProducts SavedProduct[] - staffAccessTokens StaffAccessToken[] - supplierProfile SupplierProfile? - reviewedVerificationRequests VerificationRequest[] @relation("VerificationRequestsReviewed") - whatsappBuyerLink WhatsAppBuyerLink? - whatsappLink WhatsAppLink? +// Discovery-only preference. Delivery addresses remain the checkout and +// logistics source of truth and must not be copied here automatically. +model UserLocationPreference { + id String @id @default(cuid()) + userId String @unique @map("user_id") + countryCode String @map("country_code") + countryName String? @map("country_name") + state String? + city String? + area String? + label String? + source String @default("manual") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([countryCode]) + @@index([state]) + @@index([city]) + @@index([area]) + @@map("user_location_preferences") +} - @@map("users") +model AuthProviderAccount { + id String @id @default(cuid()) + userId String @map("user_id") + provider AuthProvider + providerUserId String @map("provider_user_id") + providerEmail String @map("provider_email") + isProviderEmailVerified Boolean @default(false) @map("is_provider_email_verified") + providerAvatarUrl String? @map("provider_avatar_url") + linkedAt DateTime @default(now()) @map("linked_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerUserId]) + @@unique([userId, provider]) + @@index([providerEmail]) + @@map("auth_provider_accounts") } model ProductAssociation { - id String @id @default(uuid()) @db.Uuid + id String @id @default(cuid()) productCategoryA String @map("product_category_a") productCategoryB String @map("product_category_b") - strength Float + strength Int promptText String @map("prompt_text") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @@ -408,61 +989,70 @@ model ProductAssociation { @@map("product_associations") } -model ReorderReminder { - id String @id @default(uuid()) @db.Uuid - orderId String @unique @map("order_id") @db.Uuid - buyerId String @map("buyer_id") @db.Uuid - merchantId String @map("merchant_id") @db.Uuid - productCategory String @map("product_category") - productName String @map("product_name") - originalQuantity Int @map("original_quantity") - remindAt DateTime @map("remind_at") - status ReorderReminderStatus @default(PENDING) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - user User @relation(fields: [buyerId], references: [id], onDelete: Cascade) - merchantProfile MerchantProfile @relation(fields: [merchantId], references: [id]) - order Order @relation(fields: [orderId], references: [id]) - - @@index([status, remindAt]) - @@index([buyerId]) - @@map("reorder_reminders") +model WaitlistSubscriber { + id String @id @default(cuid()) + email String @unique + source String @default("other") + marketingConsent Boolean @map("marketing_consent") + consentText String @map("consent_text") + consentVersion String @map("consent_version") + whatsappInviteClickedAt DateTime? @map("whatsapp_invite_clicked_at") + unsubscribedAt DateTime? @map("unsubscribed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + @@index([source]) + @@index([marketingConsent]) + @@map("waitlist_subscribers") } model WhatsAppLink { - id String @id @default(uuid()) @db.Uuid + id String @id @default(cuid()) phone String @unique linkedAt DateTime @default(now()) @map("linked_at") isActive Boolean @default(true) @map("is_active") - userId String @unique @map("user_id") @db.Uuid + userId String @unique @map("user_id") user User @relation(fields: [userId], references: [id]) @@map("whatsapp_links") } +model WhatsAppConsentLog { + id String @id @default(cuid()) + phone String + isConsentGiven Boolean @map("consent_given") + consentVersion String @default("v1") @map("consent_version") + consentedAt DateTime @default(now()) @map("consented_at") + source String @default("whatsapp") + createdAt DateTime @default(now()) @map("created_at") + + @@index([phone]) + @@index([consentedAt]) + @@map("whatsapp_consent_logs") +} + model VerificationRequest { - id String @id @default(uuid()) @db.Uuid - merchantId String @map("merchant_id") @db.Uuid + id String @id @default(cuid()) + storeId String @map("store_id") governmentIdUrl String? @map("government_id_url") ninNumber String? @map("nin_number") - cacCertUrl String? @map("cac_cert_url") - reviewedBy String? @map("reviewed_by") @db.Uuid + reviewedBy String? @map("reviewed_by") reviewedAt DateTime? @map("reviewed_at") rejectionReason String? @map("rejection_reason") createdAt DateTime @default(now()) @map("created_at") idType VerificationIdType? @map("id_type") status VerificationRequestStatus @default(PENDING) targetTier VerificationTier @default(TIER_2) @map("target_tier") - merchant MerchantProfile @relation(fields: [merchantId], references: [id]) + store StoreProfile @relation(fields: [storeId], references: [id]) reviewer User? @relation("VerificationRequestsReviewed", fields: [reviewedBy], references: [id]) - @@index([merchantId]) + @@index([storeId]) @@map("verification_requests") } model OrderTracking { - id String @id @default(uuid()) @db.Uuid - orderId String @map("order_id") @db.Uuid + id String @id @default(cuid()) + orderId String @map("order_id") note String? createdAt DateTime @default(now()) @map("created_at") status OrderStatus @@ -472,36 +1062,12 @@ model OrderTracking { @@map("order_tracking") } -model CreditApplication { - id String @id @default(uuid()) @db.Uuid - buyerId String @map("buyer_id") @db.Uuid - orderId String @unique @map("order_id") @db.Uuid - requestedAmount BigInt @map("requested_amount_kobo") - tenure Int - status CreditStatus @default(PENDING) - partnerRef String? @map("partner_ref") - approvedAmount BigInt? @map("approved_amount_kobo") - interestRate Float? @map("interest_rate") - createdAt DateTime @default(now()) @map("created_at") - commissionKobo BigInt @default(0) @map("commission_kobo") - merchantId String? @map("merchant_id") @db.Uuid - partnerDisbursementRef String? @map("partner_disbursement_ref") - partnerName String @default("mock") @map("partner_name") - buyer User @relation(fields: [buyerId], references: [id]) - merchant MerchantProfile? @relation(fields: [merchantId], references: [id]) - order Order @relation(fields: [orderId], references: [id]) - - @@index([buyerId]) - @@index([merchantId]) - @@map("credit_applications") -} - model AuditLog { - id String @id @default(uuid()) @db.Uuid - userId String @map("user_id") @db.Uuid + id String @id @default(cuid()) + userId String @map("user_id") action String targetType String @map("target_type") - targetId String @map("target_id") @db.Uuid + targetId String @map("target_id") metadata Json? createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id]) @@ -511,20 +1077,29 @@ model AuditLog { @@map("audit_logs") } -model BnplWaitlist { - id String @id @default(uuid()) @db.Uuid - userId String @unique @map("user_id") @db.Uuid - email String - phone String? - createdAt DateTime @default(now()) @map("created_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@map("bnpl_waitlists") +model DomainEvent { + id String @id @default(cuid()) + aggregateType String @map("aggregate_type") + aggregateId String @map("aggregate_id") + eventType String @map("event_type") + actorType String @map("actor_type") + actorId String? @map("actor_id") + source String + metadata Json @default("{}") + correlationId String? @map("correlation_id") + idempotencyKey String? @unique @map("idempotency_key") + createdAt DateTime @default(now()) @map("created_at") + + @@index([aggregateType, aggregateId, createdAt]) + @@index([eventType, createdAt]) + @@index([correlationId]) + @@index([actorType, actorId]) + @@map("domain_events") } model DeliveryBooking { - id String @id @default(uuid()) @db.Uuid - orderId String @unique @map("order_id") @db.Uuid + id String @id @default(cuid()) + orderId String @unique @map("order_id") method DeliveryMethod partnerName String? @map("partner_name") partnerRef String? @map("partner_ref") @@ -543,55 +1118,11 @@ model DeliveryBooking { @@map("delivery_bookings") } -model SupplierProfile { - id String @id @default(uuid()) @db.Uuid - userId String @unique @map("user_id") @db.Uuid - companyName String @map("company_name") - companyAddress String @map("company_address") - cacNumber String? @map("cac_number") - createdAt DateTime @default(now()) @map("created_at") - isVerified Boolean @default(false) @map("is_verified") - orders Order[] - products SupplierProduct[] - user User @relation(fields: [userId], references: [id]) - whatsappSupplierLink WhatsAppSupplierLink? - - @@map("supplier_profiles") -} - -model SupplierProduct { - id String @id @default(uuid()) @db.Uuid - supplierId String @map("supplier_id") @db.Uuid - name String - category String - description String? - wholesalePriceKobo BigInt @map("wholesale_price_kobo") - minOrderQty Int @map("min_order_qty") - unit String - createdAt DateTime @default(now()) @map("created_at") - isActive Boolean @default(true) @map("is_active") - orders Order[] - supplier SupplierProfile @relation(fields: [supplierId], references: [id]) - - @@map("supplier_products") -} - -model WhatsAppBuyerLink { - id String @id @default(uuid()) @db.Uuid - phone String @unique - buyerId String @unique @map("buyer_id") @db.Uuid - linkedAt DateTime @default(now()) @map("linked_at") - isActive Boolean @default(true) @map("is_active") - buyer User @relation(fields: [buyerId], references: [id]) - - @@map("whatsapp_buyer_links") -} - model Category { - id String @id @default(uuid()) @db.Uuid + id String @id @default(cuid()) name String @unique slug String @unique - parentId String? @map("parent_id") @db.Uuid + parentId String? @map("parent_id") icon String? attributes Json? @default("[]") isActive Boolean @default(true) @@ -604,24 +1135,8 @@ model Category { @@map("categories") } -model Review { - id String @id @default(uuid()) @db.Uuid - orderId String @unique @map("order_id") @db.Uuid - buyerId String @map("buyer_id") @db.Uuid - merchantId String @map("merchant_id") @db.Uuid - rating Int - comment String? - imageUrl String? @map("image_url") - createdAt DateTime @default(now()) @map("created_at") - buyer BuyerProfile @relation(fields: [buyerId], references: [userId]) - merchant MerchantProfile @relation(fields: [merchantId], references: [id]) - order Order @relation(fields: [orderId], references: [id]) - - @@map("reviews") -} - model OnboardingSession { - id String @id @default(uuid()) @db.Uuid + id String @id @default(cuid()) phone String @unique userType String step String @@ -632,53 +1147,600 @@ model OnboardingSession { @@map("onboarding_sessions") } -model BuyerProfile { - id String @id @default(uuid()) @db.Uuid - userId String @unique @map("user_id") @db.Uuid - businessName String? @map("business_name") - businessAddress String? @map("business_address") - buyerType String @default("CONSUMER") @map("buyer_type") - onboardingStep Int @default(1) @map("onboarding_step") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - reviews Review[] +model Follow { + id String @id @default(cuid()) + followerId String @map("follower_id") + storeId String @map("store_id") + createdAt DateTime @default(now()) @map("created_at") + follower User @relation("UserFollows", fields: [followerId], references: [id], onDelete: Cascade) + store StoreProfile @relation("StoreFollowers", fields: [storeId], references: [id], onDelete: Cascade) + + @@unique([followerId, storeId]) + @@index([followerId]) + @@index([storeId]) + @@map("follows") +} - // Paystack DVA fields - paystackCustomerId String? @map("paystack_customer_id") - paystackCustomerCode String? @map("paystack_customer_code") - dvaAccountNumber String? @map("dva_account_number") - dvaAccountName String? @map("dva_account_name") - dvaBankName String? @map("dva_bank_name") - dvaBankSlug String? @map("dva_bank_slug") - dvaActive Boolean @default(false) @map("dva_active") +model Session { + id String @id @default(cuid()) + userId String @map("user_id") + refreshTokenHash String @map("refresh_token_hash") + userAgent String? @map("user_agent") + ipAddress String? @map("ip_address") + expiresAt DateTime @map("expires_at") + revokedAt DateTime? @map("revoked_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@map("buyer_profiles") + @@index([userId]) + @@index([expiresAt]) + @@map("sessions") } -model WhatsAppSupplierLink { - id String @id @default(uuid()) @db.Uuid - phone String @unique - linkedAt DateTime @default(now()) @map("linked_at") - isActive Boolean @default(true) @map("is_active") - supplierId String @unique @map("supplier_id") @db.Uuid - supplier SupplierProfile @relation(fields: [supplierId], references: [id]) +model UserDevice { + id String @id @default(cuid()) + userId String? @map("user_id") + deviceIdHash String @map("device_id_hash") + firstSeenAt DateTime @default(now()) @map("first_seen_at") + lastSeenAt DateTime @updatedAt @map("last_seen_at") + firstSeenIp String? @map("first_seen_ip") + lastSeenIp String? @map("last_seen_ip") + firstUserAgent String? @map("first_user_agent") + lastUserAgent String? @map("last_user_agent") + deviceType String? @map("device_type") + firstRequestId String? @map("first_request_id") + lastRequestId String? @map("last_request_id") + isTrusted Boolean @default(false) @map("is_trusted") + revokedAt DateTime? @map("revoked_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@unique([userId, deviceIdHash]) + @@index([userId]) + @@index([deviceIdHash]) + @@map("user_devices") +} - @@map("whatsapp_supplier_links") +model OTPCode { + id String @id @default(cuid()) + userId String? @map("user_id") + email String? + phone String? + codeHash String @map("code_hash") + purpose OTPPurpose + expiresAt DateTime @map("expires_at") + consumedAt DateTime? @map("consumed_at") + attempts Int @default(0) + createdAt DateTime @default(now()) @map("created_at") + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([email, purpose]) + @@index([phone, purpose]) + @@index([userId]) + @@map("otp_codes") } -model Follow { - id String @id @default(uuid()) @db.Uuid - followerId String @map("follower_id") @db.Uuid - merchantId String @map("merchant_id") @db.Uuid - createdAt DateTime @default(now()) @map("created_at") - follower User @relation("UserFollows", fields: [followerId], references: [id], onDelete: Cascade) - merchant MerchantProfile @relation("MerchantFollowers", fields: [merchantId], references: [id], onDelete: Cascade) - - @@unique([followerId, merchantId]) - @@index([followerId]) - @@index([merchantId]) - @@map("follows") +model FollowRelation { + id String @id @default(cuid()) + followerId String @map("follower_id") + targetUserId String? @map("target_user_id") + targetStoreId String? @map("target_store_id") + targetType FollowTargetType @map("target_type") + createdAt DateTime @default(now()) @map("created_at") + follower User @relation("FollowerRelations", fields: [followerId], references: [id], onDelete: Cascade) + targetUser User? @relation("TargetUserRelations", fields: [targetUserId], references: [id], onDelete: Cascade) + targetStore StoreProfile? @relation("TargetStoreRelations", fields: [targetStoreId], references: [id], onDelete: Cascade) + + @@unique([followerId, targetUserId]) + @@unique([followerId, targetStoreId]) + @@index([targetStoreId]) + @@map("follow_relations") +} + +model StoreVerificationLog { + id String @id @default(cuid()) + storeId String @map("store_id") + verificationType VerificationType @map("verification_type") + status VerificationStatus @default(PENDING) + provider String? + providerReference String? @map("provider_reference") + metadata Json? + reviewedBy String? @map("reviewed_by") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([storeId]) + @@index([status]) + @@map("store_verification_logs") +} + +/// A durable, provider-owned record of one externally submitted KYB check. +/// This is intentionally separate from StoreVerificationLog: an attempt is +/// created before calling a provider so a retry cannot create a second paid +/// request while the first provider outcome is still unknown. +model StoreVerificationAttempt { + id String @id @default(cuid()) + storeId String @map("store_id") + provider String + checkType IdentityVerificationCheckType @map("check_type") + status IdentityVerificationAttemptStatus @default(SUBMITTED) + idempotencyKey String @unique @map("idempotency_key") + activeKey String? @unique @map("active_key") + providerReference String? @map("provider_reference") + failureCode String? @map("failure_code") + submittedAt DateTime @default(now()) @map("submitted_at") + completedAt DateTime? @map("completed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + store StoreProfile @relation(fields: [storeId], references: [id], onDelete: Cascade) + + @@index([storeId, checkType, createdAt]) + @@index([provider, providerReference]) + @@map("store_verification_attempts") +} + +model ProductVariant { + id String @id @default(cuid()) + productId String @map("product_id") + colorName String? @map("color_name") + sizeName String? @map("size_name") + variantLabel String @map("variant_label") + priceOverrideKobo BigInt? @map("price_override_kobo") + sku String? + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + inventoryEvents InventoryEvent[] + stockCaches ProductStockCache[] + + @@index([productId]) + @@map("product_variants") +} + +model ProductImage { + id String @id @default(cuid()) + productId String @map("product_id") + url String + order Int @default(0) + isDefault Boolean @default(false) @map("is_default") + assignedVariantIds String[] @default([]) @map("assigned_variant_ids") + altText String? @map("alt_text") + moderationStatus ModerationStatus @default(SAFE) @map("moderation_status") + cloudinaryPublicId String? @map("cloudinary_public_id") + createdAt DateTime @default(now()) @map("created_at") + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + + @@index([productId]) + @@map("product_images") +} + +model ProductSizeGuideConfig { + id String @id @default(cuid()) + productId String @unique @map("product_id") + primaryGuideType GuideType @map("primary_guide_type") + includesFootwear Boolean @default(false) @map("includes_footwear") + footwearGuideType GuideType? @map("footwear_guide_type") + useCustomSizes Boolean @default(false) @map("use_custom_sizes") + customSizes Json? @map("custom_sizes") + modelHeightCm Int? @map("model_height_cm") + modelBustCm Int? @map("model_bust_cm") + modelWaistCm Int? @map("model_waist_cm") + modelHipsCm Int? @map("model_hips_cm") + modelWearingSize String? @map("model_wearing_size") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + + @@map("product_size_guide_configs") +} + +model ProductEmbedding { + id String @id @default(cuid()) + productId String @unique @map("product_id") + embedding Unsupported("vector(1408)") + embeddingModel String @default("vertex-ai-multimodal-v1") @map("embedding_model") + embeddingReady Boolean @default(false) @map("embedding_ready") + embeddingUpdatedAt DateTime? @map("embedding_updated_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + + @@map("product_embeddings") +} + +model SourcedProduct { + id String @id @default(cuid()) + listingCode String @unique @map("listing_code") + productCode String @unique @map("product_code") + digitalStoreId String @map("digital_store_id") + physicalStoreId String @map("physical_store_id") + physicalProductId String @map("physical_product_id") + customTitle String? @map("custom_title") + customDescription String? @map("custom_description") + customImages Json? @map("custom_images") + sellingPriceKobo BigInt @map("selling_price_kobo") + floorPriceKobo BigInt @map("floor_price_kobo") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + physicalProduct Product @relation("PhysicalProductSources", fields: [physicalProductId], references: [id]) + digitalStore StoreProfile @relation("DigitalStoreSourcedProducts", fields: [digitalStoreId], references: [id], onDelete: Cascade) + physicalStore StoreProfile @relation("PhysicalStoreSourcedProducts", fields: [physicalStoreId], references: [id], onDelete: Restrict) + orders Order[] + + @@unique([digitalStoreId, physicalProductId]) + @@index([physicalStoreId]) + @@index([isActive]) + @@map("sourced_products") +} + +model Post { + id String @id @default(cuid()) + authorId String? @map("author_id") + storeId String? @map("store_id") + type PostType + text String? + taggedProductId String? @map("tagged_product_id") + isActive Boolean @default(true) @map("is_active") + moderationStatus ModerationStatus @default(PENDING) @map("moderation_status") + // Denormalized engagement counters — the display source of truth for feeds + // and post responses. Maintained atomically by PostService write paths and + // drift-corrected hourly by EngagementRecountService. Hot read paths must + // never COUNT(*) the relations. + likeCount Int @default(0) @map("like_count") + commentCount Int @default(0) @map("comment_count") + saveCount Int @default(0) @map("save_count") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + author User? @relation("PostAuthor", fields: [authorId], references: [id], onDelete: SetNull) + store StoreProfile? @relation("StorePosts", fields: [storeId], references: [id], onDelete: SetNull) + taggedProduct Product? @relation("TaggedProductPosts", fields: [taggedProductId], references: [id], onDelete: SetNull) + images PostImage[] + hashtags PostHashtag[] + likes Like[] + comments Comment[] + saves SavedPost[] + + @@index([storeId, createdAt]) + @@index([taggedProductId]) + // Feed hot path: every feed tab filters is_active = true and orders by + // created_at DESC — this index gives ordered access for that scan. + @@index([isActive, createdAt]) + @@map("posts") +} + +model PostImage { + id String @id @default(cuid()) + postId String @map("post_id") + url String + order Int @default(0) + moderationStatus ModerationStatus @default(SAFE) @map("moderation_status") + cloudinaryPublicId String? @map("cloudinary_public_id") + createdAt DateTime @default(now()) @map("created_at") + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + + @@index([postId]) + @@map("post_images") +} + +model Hashtag { + id String @id @default(cuid()) + name String @unique + postCount Int @default(0) @map("post_count") + createdAt DateTime @default(now()) @map("created_at") + posts PostHashtag[] + + @@map("hashtags") +} + +model PostHashtag { + id String @id @default(cuid()) + postId String @map("post_id") + hashtagId String @map("hashtag_id") + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + hashtag Hashtag @relation(fields: [hashtagId], references: [id], onDelete: Cascade) + + @@unique([postId, hashtagId]) + @@map("post_hashtags") +} + +model Like { + id String @id @default(cuid()) + userId String @map("user_id") + postId String @map("post_id") + createdAt DateTime @default(now()) @map("created_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + + @@unique([userId, postId]) + @@map("likes") +} + +model Comment { + id String @id @default(cuid()) + postId String @map("post_id") + userId String @map("user_id") + parentId String? @map("parent_id") + body String + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + parent Comment? @relation("CommentReplies", fields: [parentId], references: [id]) + replies Comment[] @relation("CommentReplies") + likes CommentLike[] + + @@index([postId]) + @@map("comments") +} + +model CommentLike { + id String @id @default(cuid()) + commentId String @map("comment_id") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([commentId, userId]) + @@map("comment_likes") +} + +model StoreCollection { + id String @id @default(cuid()) + storeId String @map("store_id") + name String + productIds String[] @default([]) @map("product_ids") + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + store StoreProfile @relation(fields: [storeId], references: [id], onDelete: Cascade) + + @@index([storeId]) + @@map("store_collections") +} + +// A follower's opt-in to a store's new-post notifications ("bell"). Requires an +// existing follow. scope selects which posts notify: social posts, product +// posts, or all. Row existence = bell on. +model StoreNotificationSubscription { + id String @id @default(cuid()) + userId String @map("user_id") + storeId String @map("store_id") + scope PostNotificationScope @default(ALL) + createdAt DateTime @default(now()) @map("created_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + store StoreProfile @relation(fields: [storeId], references: [id], onDelete: Cascade) + + @@unique([userId, storeId]) + @@index([storeId]) + @@index([storeId, scope]) + @@map("store_notification_subscriptions") +} + +// A follower's opt-in to a personal profile's new-post notifications. Personal +// profiles only post social content, so there is no scope — row existence = +// bell on. Requires an existing follow. +model ProfilePostSubscription { + id String @id @default(cuid()) + subscriberId String @map("subscriber_id") + targetUserId String @map("target_user_id") + createdAt DateTime @default(now()) @map("created_at") + subscriber User @relation("ProfilePostSubscriber", fields: [subscriberId], references: [id], onDelete: Cascade) + targetUser User @relation("ProfilePostTarget", fields: [targetUserId], references: [id], onDelete: Cascade) + + @@unique([subscriberId, targetUserId]) + @@index([targetUserId]) + @@map("profile_post_subscriptions") +} + +enum PostNotificationScope { + SOCIAL + PRODUCT + ALL +} + +model DVAAccount { + id String @id @default(cuid()) + userId String? @map("user_id") + orderId String? @unique @map("order_id") + paystackCustomerCode String? @map("paystack_customer_code") + accountNumber String @map("account_number") + accountName String @map("account_name") + bankName String @map("bank_name") + bankSlug String? @map("bank_slug") + status DVAStatus @default(ACTIVE) + expiresAt DateTime? @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull) + + @@unique([accountNumber, bankName]) + @@index([userId]) + @@map("dva_accounts") +} + +model DeliveryAddress { + id String @id @default(cuid()) + userId String @map("user_id") + label String + street String + line2 String? + city String + state String + postalCode String? @map("postal_code") + isDefault Boolean @default(false) @map("is_default") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("delivery_addresses") +} + +model Conversation { + id String @id @default(cuid()) + buyerId String @map("buyer_id") + storeId String @map("store_id") + orderId String? @map("order_id") + lastMessageAt DateTime? @map("last_message_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + buyer User @relation("BuyerConversations", fields: [buyerId], references: [id], onDelete: Cascade) + store StoreProfile @relation(fields: [storeId], references: [id], onDelete: Cascade) + messages Message[] + + @@index([buyerId]) + @@index([storeId]) + @@map("conversations") +} + +model Message { + id String @id @default(cuid()) + conversationId String @map("conversation_id") + senderId String @map("sender_id") + body String? + attachmentUrl String? @map("attachment_url") + readAt DateTime? @map("read_at") + createdAt DateTime @default(now()) @map("created_at") + conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + sender User @relation(fields: [senderId], references: [id], onDelete: Cascade) + + @@index([conversationId, createdAt]) + @@map("messages") +} + +model PushToken { + id String @id @default(cuid()) + userId String @map("user_id") + token String @unique + platform String + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("push_tokens") +} + +model ModerationLog { + id String @id @default(cuid()) + contentType String @map("content_type") + contentId String @map("content_id") + imageUrl String @map("image_url") + cloudinaryId String? @map("cloudinary_id") + decision ModerationStatus + confidence Json? + reviewedBy String? @map("reviewed_by") + reviewNote String? @map("review_note") + appealStatus VerificationStatus? @map("appeal_status") + createdAt DateTime @default(now()) @map("created_at") + reviewer User? @relation("ModerationReviewedBy", fields: [reviewedBy], references: [id]) + + @@index([contentType, contentId]) + @@map("moderation_logs") +} + +model SupportTicket { + id String @id @default(cuid()) + userId String? @map("user_id") + subject String + body String + status SupportStatus @default(OPEN) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([status]) + @@map("support_tickets") +} + +model WhatsAppAnalytics { + id String @id @default(cuid()) + sessionId String @map("session_id") + phone String @default("") + userId String? @map("user_id") + messageType String @map("message_type") + conversationTurn Int @default(0) @map("conversation_turn") + flowAtStart String? @map("flow_at_start") + flowAtEnd String? @map("flow_at_end") + intentClassified String? @map("intent_classified") + intentSuccessful Boolean @default(false) @map("intent_successful") + imageProvided Boolean @default(false) @map("image_provided") + productsShown String[] @default([]) @map("products_shown") + productsShownCount Int @default(0) @map("products_shown_count") + productSelected String? @map("product_selected") + productOrdered String? @map("product_ordered") + searchQuery String? @map("search_query") + parsedFilters Json? @map("parsed_filters") + categoryInferred String? @map("category_inferred") + vectorSearchUsed Boolean @default(false) @map("vector_search_used") + embeddingModel String? @map("embedding_model") + productsRankedCount Int? @map("products_ranked_count") + zeroResults Boolean @default(false) @map("zero_results") + sessionConverted Boolean @default(false) @map("session_converted") + dropOffStep String? @map("drop_off_step") + escalatedToHuman Boolean @default(false) @map("escalated_to_human") + geminiResponseMs Int? @map("gemini_response_ms") + geminiModel String? @map("gemini_model") + errorType String? @map("error_type") + createdAt DateTime @default(now()) @map("created_at") + session WhatsAppSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([sessionId]) + @@index([phone, createdAt]) + @@index([createdAt]) + @@index([errorType]) + @@map("whatsapp_analytics") +} + +model WhatsAppSession { + id String @id @default(cuid()) + phone String + userId String? @map("user_id") + consentGiven Boolean @default(false) @map("consent_given") + consentAt DateTime? @map("consent_at") + lastMessageAt DateTime? @map("last_message_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + analytics WhatsAppAnalytics[] + + @@unique([phone]) + @@index([userId]) + @@map("whatsapp_sessions") +} + +model SizeGuide { + id String @id @default(cuid()) + type GuideType @unique + sizes Json + version String @default("v1") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("size_guides") +} + +model ReconciliationLog { + id String @id @default(cuid()) + paystackBalanceKobo BigInt @map("paystack_balance_kobo") + ledgerEscrowKobo BigInt @map("ledger_escrow_kobo") + differenceKobo BigInt @map("difference_kobo") + status ReconciliationStatus @default(BALANCED) + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + + @@index([status, createdAt]) + @@map("reconciliation_logs") } enum PriceType { @@ -686,6 +1748,131 @@ enum PriceType { WHOLESALE } +enum StoreType { + DIGITAL + PHYSICAL +} + +enum StoreTier { + TIER_0 + TIER_1 + TIER_2 +} + +enum ProductStatus { + DRAFT + ACTIVE + HIDDEN + OUT_OF_STOCK + DELETED +} + +enum PostType { + PRODUCT_POST + IMAGE_POST + GIST + TWIZZ +} + +enum ModerationStatus { + PENDING + SAFE + SENSITIVE + BLOCKED + APPROVED + REJECTED +} + +enum NotificationType { + PAYMENT_ACTION_REQUIRED + ORDER_PAID + ORDER_DISPATCHED + ORDER_COMPLETED + POST_LIKED + POST_COMMENTED + COMMENT_REPLIED + USER_MENTIONED_IN_POST + USER_MENTIONED_IN_COMMENT + NEW_FOLLOWER + TAGGED_PRODUCT_SOLD_VIA_POST + PAYOUT_SENT + PAYOUT_FAILED + STORE_NOTIFICATION_NEW_POST + MODERATION_BLOCKED + STORE_TIER_UPGRADED +} + +enum VerificationStatus { + PENDING + APPROVED + REJECTED + FAILED +} + +enum VerificationType { + TIER_1_AUTO + NIN + ADDRESS + BANK +} + +enum IdentityVerificationCheckType { + NIN + NIN_FACE_MATCH + CAC +} + +enum IdentityVerificationAttemptStatus { + SUBMITTED + VERIFIED + FAILED + RECONCILIATION_REQUIRED + MANUAL_REVIEW +} + +enum GuideType { + CLOTHING_WOMEN + CLOTHING_MEN + CLOTHING_CHILDREN + CLOTHING_UNISEX + FOOTWEAR_WOMEN + FOOTWEAR_MEN + FOOTWEAR_CHILDREN +} + +enum OTPPurpose { + EMAIL_VERIFY + PHONE_VERIFY + STORE_BUSINESS_PHONE_VERIFY + PASSWORD_RESET +} + +enum FollowTargetType { + USER + STORE +} + +enum DVAStatus { + ACTIVE + PAID + EXPIRED + CANCELLED +} + +enum SupportStatus { + OPEN + IN_REVIEW + RESOLVED + CLOSED +} + +enum ReconciliationStatus { + BALANCED + DISCREPANCY + INVESTIGATING + RESOLVED +} + enum ApprovalStatus { PENDING APPROVED @@ -693,6 +1880,10 @@ enum ApprovalStatus { } enum InventoryEventType { + INITIAL_STOCK + RESTOCK + SALE_DEDUCT + SALE_RESTORE STOCK_IN STOCK_OUT ADJUSTMENT @@ -700,14 +1891,33 @@ enum InventoryEventType { ORDER_RELEASED } +enum OutboxStatus { + PENDING + PROCESSING + PROCESSED + DEAD +} + enum NotificationChannel { IN_APP EMAIL } +enum OrderType { + DIRECT + DROPSHIP_CUSTOMER + DROPSHIP_FULFILLMENT +} + +enum PlatformFeeBearer { + SHOPPER + MERCHANT +} + enum OrderStatus { PENDING_PAYMENT PAID + READY_FOR_PICKUP DISPATCHED DELIVERED COMPLETED @@ -715,6 +1925,8 @@ enum OrderStatus { DISPUTE PREPARING IN_TRANSIT + REFUND_PENDING + REFUNDED } enum PaymentDirection { @@ -724,9 +1936,82 @@ enum PaymentDirection { enum PaymentStatus { INITIALIZED + RECONCILIATION_REQUIRED + MANUAL_REVIEW SUCCESS FAILED REFUNDED + PARTIALLY_REFUNDED +} + +enum PaymentAttemptStatus { + INITIALIZED + SUCCESS + FAILED + SUPERSEDED + RECONCILIATION_REQUIRED +} + +enum PaymentAmountExceptionType { + UNDERPAID + OVERPAID +} + +enum PaymentAmountExceptionStatus { + RECONCILIATION_REQUIRED + REMAINING_BALANCE_PENDING + REFUND_PENDING + REFUND_SUBMITTED + MANUAL_REVIEW + RESOLVED +} + +enum PaymentAmountExceptionRefundStatus { + PENDING + PROCESSING + SUBMITTED + COMPLETED + FAILED + RECONCILIATION_REQUIRED + MANUAL_REVIEW +} + +enum PaymentProviderName { + PAYSTACK + MONNIFY +} + +enum LedgerEntryType { + CHECKOUT_CREATED + PAYMENT_INITIALIZED + PAYMENT_AMOUNT_EXCEPTION_RECEIVED + PAYMENT_AMOUNT_EXCEPTION_ALLOCATED + PAYMENT_RECEIVED + PLATFORM_FEE_ASSESSED + // Audit-only (INFO) record of the payment-processor fee the merchant bore on + // a captured charge. Balance-neutral: the fee is already netted out of the + // merchant payout amount; this entry exists so processing cost is queryable. + PROVIDER_FEE + ESCROW_HELD + PAYOUT_INITIATED + PAYOUT_FAILED + PAYOUT_COMPLETED + REFUND_INITIATED + REFUND_COMPLETED +} + +enum LedgerDirection { + CREDIT + DEBIT + HOLD + RELEASE + INFO +} + +enum WebhookStatus { + PROCESSING + PROCESSED + FAILED } enum PayoutRequestStatus { @@ -740,55 +2025,114 @@ enum PayoutRequestStatus { enum PayoutStatus { PENDING PROCESSING + // Provider accepted the transfer request but has not confirmed final + // settlement (Paystack PENDING / OTP / PROCESSING). Never treated as + // COMPLETED — a verified success is required first. + SUBMITTED COMPLETED FAILED + // Provider outcome is ambiguous (timeout / unknown). Handled by transfer + // reconciliation, never a blind retry. + RECONCILIATION_REQUIRED + CANCELLED } -enum UserRole { - BUYER - MERCHANT - SUPER_ADMIN - OPERATOR - SUPPORT - SUPPLIER +enum PayoutProviderName { + PAYSTACK + MONNIFY } -enum ReorderReminderStatus { - PENDING - SENT - DISMISSED - REORDERED +enum PayoutProviderAttemptStatus { + PROCESSING + SUBMITTED + COMPLETED + FAILED + RECONCILIATION_REQUIRED +} + +enum AuthProvider { + GOOGLE +} + +enum UserRole { + USER + SUPER_ADMIN } enum OrderDisputeStatus { NONE PENDING RESOLVED + RESOLVED_SHOPPER + RESOLVED_STORE } -enum VerificationTier { - UNVERIFIED - BASIC - VERIFIED - TRUSTED - TIER_1 - TIER_2 - TIER_3 +enum DisputeStatus { + OPEN + STORE_RESPONDED + UNDER_REVIEW + RESOLVED_BUYER + RESOLVED_SELLER + RESOLVED_PARTIAL + CLOSED } -enum PaymentMethod { - ESCROW - DIRECT +enum DisputeActorType { + BUYER + STORE + OPERATOR +} + +enum DisputeResolutionOutcome { + BUYER_WINS + SELLER_WINS + PARTIAL } -enum CreditStatus { +// Phase 5 — dispute financial settlement. A settlement header owns one or two +// legs (buyer refund, merchant payout). Provider acceptance is never treated as +// completion: legs move to COMPLETED only after a confirmed provider outcome, +// and ambiguous outcomes go to RECONCILIATION_REQUIRED / MANUAL_REVIEW instead +// of blind retries. +enum DisputeSettlementStatus { PENDING - APPROVED - REJECTED - DISBURSED - REPAYING + PROCESSING + PARTIALLY_COMPLETED + COMPLETED + FAILED + RECONCILIATION_REQUIRED + MANUAL_REVIEW +} + +enum DisputeSettlementLegType { + BUYER_REFUND + MERCHANT_PAYOUT +} + +enum DisputeSettlementLegStatus { + PENDING + PROCESSING + SUBMITTED + COMPLETED + FAILED + RECONCILIATION_REQUIRED + MANUAL_REVIEW +} + +enum DisputeSettlementRefundOperationStatus { + PENDING + PROCESSING + SUBMITTED COMPLETED - DEFAULTED + FAILED + RECONCILIATION_REQUIRED + MANUAL_REVIEW +} + +enum VerificationTier { + UNVERIFIED + TIER_1 + TIER_2 } enum VerificationIdType { @@ -804,7 +2148,7 @@ enum VerificationRequestStatus { } enum DeliveryMethod { - MERCHANT_DELIVERY + STORE_DELIVERY PLATFORM_LOGISTICS } @@ -817,3 +2161,319 @@ enum DeliveryStatus { DELIVERED FAILED } + +// ───────────────────────────────────────────────────────────── +// StorePass — Twizrr-owned monthly subscription engine for stores. +// Store owners manage StorePass in Store Mode. Nomba is the future +// billing rail only; this domain performs no external calls. +// ───────────────────────────────────────────────────────────── + +enum StorePassPlanCode { + FREE + GROWTH + PRO +} + +enum StorePassBillingInterval { + MONTHLY +} + +enum StorePassSubscriptionStatus { + FREE + INACTIVE + ACTIVE + PAST_DUE + CANCELLED + CANCELLED_AT_PERIOD_END +} + +enum StorePassInvoiceStatus { + DRAFT + PENDING + PAID + FAILED + VOID + REFUNDED +} + +enum StorePassBillingAttemptStatus { + PENDING + PROCESSING + SUCCEEDED + FAILED + CANCELLED +} + +enum StorePassPaymentProvider { + NOMBA +} + +enum StorePassPaymentMethodType { + CARD_TOKEN +} + +enum StorePassEntitlementType { + FEATURED_PLACEMENT_CREDIT + CAMPAIGN_BOOST_CREDIT + WIZZA_DISCOVERY_CREDIT + ANALYTICS_ACCESS + PRODUCT_VISIBILITY_INSIGHTS + PRIORITY_SUPPORT +} + +enum StorePassEntitlementLedgerType { + GRANT + USE + EXPIRE + REVOKE + ADJUST +} + +enum StorePassBillingEventStatus { + RECEIVED + PROCESSING + PROCESSED + DUPLICATE + FAILED + IGNORED +} + +model StorePassPlan { + id String @id @default(cuid()) + code StorePassPlanCode @unique + name String + description String? + priceKobo BigInt @map("price_kobo") + currency String @default("NGN") + interval StorePassBillingInterval @default(MONTHLY) + isActive Boolean @default(true) @map("is_active") + sortOrder Int @default(0) @map("sort_order") + benefits Json? @default("{}") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + subscriptions StorePassSubscription[] + invoices StorePassInvoice[] + + @@index([isActive]) + @@map("storepass_plans") +} + +model StorePassSubscription { + id String @id @default(cuid()) + storeId String @unique @map("store_id") + ownerUserId String @map("owner_user_id") + planId String @map("plan_id") + planCodeSnapshot StorePassPlanCode @map("plan_code_snapshot") + status StorePassSubscriptionStatus @default(FREE) + currentPeriodStart DateTime? @map("current_period_start") + currentPeriodEnd DateTime? @map("current_period_end") + cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end") + cancelledAt DateTime? @map("cancelled_at") + pastDueAt DateTime? @map("past_due_at") + lastPaidAt DateTime? @map("last_paid_at") + nextBillingAt DateTime? @map("next_billing_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + store StoreProfile @relation(fields: [storeId], references: [id], onDelete: Cascade) + ownerUser User @relation(fields: [ownerUserId], references: [id]) + plan StorePassPlan @relation(fields: [planId], references: [id]) + invoices StorePassInvoice[] + billingAttempts StorePassBillingAttempt[] + entitlements StorePassEntitlement[] + ledgerEntries StorePassEntitlementLedger[] + + @@index([storeId]) + @@index([status]) + @@index([planCodeSnapshot]) + @@map("storepass_subscriptions") +} + +model StorePassInvoice { + id String @id @default(cuid()) + storeId String @map("store_id") + subscriptionId String? @map("subscription_id") + planId String @map("plan_id") + planCodeSnapshot StorePassPlanCode @map("plan_code_snapshot") + amountKobo BigInt @map("amount_kobo") + currency String @default("NGN") + status StorePassInvoiceStatus @default(DRAFT) + billingPeriodStart DateTime @map("billing_period_start") + billingPeriodEnd DateTime @map("billing_period_end") + dueAt DateTime? @map("due_at") + paidAt DateTime? @map("paid_at") + voidedAt DateTime? @map("voided_at") + failedAt DateTime? @map("failed_at") + paidByUserId String? @map("paid_by_user_id") + paidByUserEmailSnapshot String? @map("paid_by_user_email_snapshot") + paidByUsernameSnapshot String? @map("paid_by_username_snapshot") + paidByDisplayNameSnapshot String? @map("paid_by_display_name_snapshot") + storeHandleSnapshot String? @map("store_handle_snapshot") + storeNameSnapshot String? @map("store_name_snapshot") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + store StoreProfile @relation(fields: [storeId], references: [id], onDelete: Cascade) + subscription StorePassSubscription? @relation(fields: [subscriptionId], references: [id]) + plan StorePassPlan @relation(fields: [planId], references: [id]) + paidByUser User? @relation(fields: [paidByUserId], references: [id]) + billingAttempts StorePassBillingAttempt[] + + @@index([storeId]) + @@index([subscriptionId]) + @@index([status]) + @@map("storepass_invoices") +} + +model StorePassBillingAttempt { + id String @id @default(cuid()) + invoiceId String @map("invoice_id") + storeId String @map("store_id") + subscriptionId String? @map("subscription_id") + provider StorePassPaymentProvider @default(NOMBA) + status StorePassBillingAttemptStatus @default(PENDING) + amountKobo BigInt @map("amount_kobo") + currency String @default("NGN") + orderReference String @unique @map("order_reference") + idempotencyKey String @unique @map("idempotency_key") + attemptNumber Int @default(1) @map("attempt_number") + checkoutUrl String? @map("checkout_url") + providerTransactionId String? @map("provider_transaction_id") + providerResponseCode String? @map("provider_response_code") + providerResponseMessage String? @map("provider_response_message") + failureReason String? @map("failure_reason") + retryCount Int @default(0) @map("retry_count") + maxRetries Int @default(3) @map("max_retries") + nextRetryAt DateTime? @map("next_retry_at") + lastRetryAt DateTime? @map("last_retry_at") + lastReconciledAt DateTime? @map("last_reconciled_at") + reconciliationStatus String? @map("reconciliation_status") + needsReview Boolean @default(false) @map("needs_review") + startedAt DateTime? @map("started_at") + succeededAt DateTime? @map("succeeded_at") + failedAt DateTime? @map("failed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + invoice StorePassInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + store StoreProfile @relation(fields: [storeId], references: [id], onDelete: Cascade) + subscription StorePassSubscription? @relation(fields: [subscriptionId], references: [id]) + + @@index([invoiceId]) + @@index([storeId]) + @@index([subscriptionId]) + @@index([status]) + @@index([nextRetryAt]) + @@index([lastReconciledAt]) + @@index([needsReview]) + @@map("storepass_billing_attempts") +} + +model StorePassPaymentMethod { + id String @id @default(cuid()) + storeId String @map("store_id") + userId String @map("user_id") + provider StorePassPaymentProvider @default(NOMBA) + type StorePassPaymentMethodType @default(CARD_TOKEN) + nombaTokenKey String? @map("nomba_token_key") + cardType String? @map("card_type") + maskedCardPan String? @map("masked_card_pan") + tokenExpiryMonth Int? @map("token_expiry_month") + tokenExpiryYear Int? @map("token_expiry_year") + isDefault Boolean @default(false) @map("is_default") + revokedAt DateTime? @map("revoked_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + store StoreProfile @relation(fields: [storeId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) + + @@index([storeId]) + @@index([userId]) + @@map("storepass_payment_methods") +} + +model StorePassEntitlement { + id String @id @default(cuid()) + storeId String @map("store_id") + subscriptionId String? @map("subscription_id") + planCodeSnapshot StorePassPlanCode @map("plan_code_snapshot") + entitlementType StorePassEntitlementType @map("entitlement_type") + balance Int? + isEnabled Boolean @default(false) @map("is_enabled") + periodStart DateTime? @map("period_start") + periodEnd DateTime? @map("period_end") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + store StoreProfile @relation(fields: [storeId], references: [id], onDelete: Cascade) + subscription StorePassSubscription? @relation(fields: [subscriptionId], references: [id]) + ledgerEntries StorePassEntitlementLedger[] + + @@unique([storeId, entitlementType]) + @@index([storeId]) + @@index([subscriptionId]) + @@map("storepass_entitlements") +} + +model StorePassEntitlementLedger { + id String @id @default(cuid()) + storeId String @map("store_id") + subscriptionId String? @map("subscription_id") + entitlementId String? @map("entitlement_id") + type StorePassEntitlementLedgerType + entitlementType StorePassEntitlementType @map("entitlement_type") + amount Int? + balanceBefore Int? @map("balance_before") + balanceAfter Int? @map("balance_after") + reason String? + referenceType String? @map("reference_type") + referenceId String? @map("reference_id") + createdByUserId String? @map("created_by_user_id") + createdAt DateTime @default(now()) @map("created_at") + + store StoreProfile @relation(fields: [storeId], references: [id], onDelete: Cascade) + subscription StorePassSubscription? @relation(fields: [subscriptionId], references: [id]) + entitlement StorePassEntitlement? @relation(fields: [entitlementId], references: [id]) + createdByUser User? @relation(fields: [createdByUserId], references: [id]) + + @@index([storeId]) + @@index([subscriptionId]) + @@index([entitlementId]) + @@index([type]) + @@map("storepass_entitlement_ledger") +} + +/// Deduplicated provider (Nomba) subscription-billing webhook events. +/// Twizrr never activates a subscription or grants entitlements from an +/// unverified webhook — this table records verified events and makes +/// processing idempotent via the unique providerRequestId constraint. +/// Sensitive payloads (raw body, secrets, headers, card PAN/CVV/OTP/PIN) are +/// never stored; only a hash and a sanitized field subset are retained. +model StorePassBillingEvent { + id String @id @default(cuid()) + provider StorePassPaymentProvider @default(NOMBA) + providerRequestId String @unique @map("provider_request_id") + eventType String @map("event_type") + orderReference String? @map("order_reference") + providerTransactionId String? @map("provider_transaction_id") + billingAttemptId String? @map("billing_attempt_id") + invoiceId String? @map("invoice_id") + subscriptionId String? @map("subscription_id") + storeId String? @map("store_id") + processingStatus StorePassBillingEventStatus @default(RECEIVED) @map("processing_status") + failureReason String? @map("failure_reason") + payloadHash String? @map("payload_hash") + sanitizedPayload Json? @map("sanitized_payload") + receivedAt DateTime @default(now()) @map("received_at") + processedAt DateTime? @map("processed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + @@index([provider, eventType]) + @@index([processingStatus]) + @@index([orderReference]) + @@index([billingAttemptId]) + @@map("storepass_billing_events") +} diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 9ac7da38..3e1ee0b5 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -1,185 +1,33 @@ -import { - Module, - MiddlewareConsumer, - RequestMethod, - Logger, -} from "@nestjs/common"; -import { ConfigModule, ConfigService } from "@nestjs/config"; -import { ServeStaticModule } from "@nestjs/serve-static"; -import { ScheduleModule } from "@nestjs/schedule"; -import { CacheModule } from "@nestjs/cache-manager"; -import { redisStore } from "cache-manager-ioredis-yet"; -import { join } from "path"; +import { Module } from "@nestjs/common"; -import configuration from "./config/app.config"; -import databaseConfig from "./config/database.config"; -import redisConfig from "./config/redis.config"; -import jwtConfig from "./config/jwt.config"; -import paystackConfig from "./config/paystack.config"; -import africastalkingConfig from "./config/africastalking.config"; -import whatsappConfig from "./config/whatsapp.config"; - -import { PrismaModule } from "./prisma/prisma.module"; -import { RedisModule } from "./redis/redis.module"; +import { RequestSecurityContextMiddleware } from "./common/security/request-security-context.middleware"; +import { DeviceIdentityModule } from "./common/security/device-identity.module"; +import { CoreModule } from "./core/core.module"; import { QueueModule } from "./queue/queue.module"; -import { HealthModule } from "./health/health.module"; - -import { AuthModule } from "./modules/auth/auth.module"; -import { MerchantModule } from "./modules/merchant/merchant.module"; -import { ProductModule } from "./modules/product/product.module"; -import { PaymentModule } from "./modules/payment/payment.module"; -import { InventoryModule } from "./modules/inventory/inventory.module"; -import { NotificationModule } from "./modules/notification/notification.module"; -import { EmailModule } from "./modules/email/email.module"; -import { UploadModule } from "./modules/upload/upload.module"; -import { AdminModule } from "./modules/admin/admin.module"; -import { WhatsAppModule } from "./modules/whatsapp/whatsapp.module"; -import { PayoutModule } from "./modules/payout/payout.module"; -import { TradeFinancingModule } from "./modules/trade-financing/trade-financing.module"; -import { LogisticsModule } from "./modules/logistics/logistics.module"; -import { SupplierModule } from "./modules/supplier/supplier.module"; -import { CategoryModule } from "./modules/category/category.module"; -import { ReviewModule } from "./modules/review/review.module"; -import { BuyerModule } from "./modules/buyer/buyer.module"; -import { CartModule } from "./modules/cart/cart.module"; -import { WishlistModule } from "./modules/wishlist/wishlist.module"; -import { UssdModule } from "./modules/ussd/ussd.module"; -import { DvaModule } from "./modules/dva/dva.module"; - -import { LoggerModule } from "./common/logger/logger.module"; - -import { MerchantContextMiddleware } from "./common/middleware/merchant-context.middleware"; - -import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler"; -import { APP_GUARD } from "@nestjs/core"; -function sanitizeRedisUrl(url: string | undefined): string | undefined { - if (!url) return url; - let cleanUrl = url.trim(); - if (cleanUrl.startsWith("REDIS_URL=")) { - cleanUrl = cleanUrl.substring("REDIS_URL=".length); - } - if (cleanUrl.startsWith('"') && cleanUrl.endsWith('"')) { - cleanUrl = cleanUrl.substring(1, cleanUrl.length - 1); - } - if (cleanUrl.startsWith("'") && cleanUrl.endsWith("'")) { - cleanUrl = cleanUrl.substring(1, cleanUrl.length - 1); - } - return cleanUrl.trim(); -} +import { ChannelsDomainModule } from "./domains/channels.module"; +import { CommerceDomainModule } from "./domains/commerce.module"; +import { MoneyDomainModule } from "./domains/money.module"; +import { OrdersDomainModule } from "./domains/orders.module"; +import { PlatformDomainModule } from "./domains/platform.module"; +import { SocialDomainModule } from "./domains/social.module"; +import { UsersTrustDomainModule } from "./domains/users-trust.module"; @Module({ imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [ - configuration, - databaseConfig, - redisConfig, - jwtConfig, - paystackConfig, - africastalkingConfig, - whatsappConfig, - ], - }), - LoggerModule, - ScheduleModule.forRoot(), - CacheModule.registerAsync({ - isGlobal: true, - imports: [ConfigModule], - inject: [ConfigService], - useFactory: async (configService: ConfigService) => { - const rawUrl = configService.get("redis.url"); - const urlString = sanitizeRedisUrl(rawUrl); - - if (!urlString) { - Logger.warn( - "REDIS_URL not found for CacheModule, falling back to localhost", - ); - const store = await redisStore({ - host: "127.0.0.1", - port: 6379, - family: 0, - ttl: 60 * 1000, - enableReadyCheck: false, - }); - return { store }; - } - - try { - const parsedUrl = new URL(urlString); - const store = await redisStore({ - host: parsedUrl.hostname, - port: parseInt(parsedUrl.port, 10) || 6379, - password: parsedUrl.password || undefined, - username: parsedUrl.username || undefined, - tls: - parsedUrl.protocol === "rediss:" - ? { rejectUnauthorized: false } - : undefined, - family: 0, - ttl: 60 * 1000, - enableReadyCheck: false, - }); - return { store }; - } catch (error: any) { - Logger.error( - `CacheModule failed to parse REDIS_URL prefix: ${urlString.substring(0, 15)}...`, - ); - throw new Error( - `Invalid REDIS_URL for CacheModule: ${error.message}`, - ); - } - }, - }), - ServeStaticModule.forRoot({ - rootPath: join(__dirname, "..", "..", "uploads"), - serveRoot: "/uploads", - }), - ThrottlerModule.forRoot([ - { - ttl: 60000, - limit: 60, - }, - ]), - PrismaModule, - RedisModule, + CoreModule, + DeviceIdentityModule, QueueModule, - HealthModule, - AuthModule, - MerchantModule, - ProductModule, - PaymentModule, - InventoryModule, - NotificationModule, - EmailModule, - UploadModule, - AdminModule, - WhatsAppModule, - PayoutModule, - TradeFinancingModule, - LogisticsModule, - SupplierModule, - CategoryModule, - ReviewModule, - BuyerModule, - CartModule, - WishlistModule, - UssdModule, - DvaModule, + CommerceDomainModule, + OrdersDomainModule, + MoneyDomainModule, + UsersTrustDomainModule, + SocialDomainModule, + ChannelsDomainModule, + PlatformDomainModule, ], + controllers: [], - providers: [ - { - provide: APP_GUARD, - useClass: ThrottlerGuard, - }, - ], + providers: [RequestSecurityContextMiddleware], }) -export class AppModule { - configure(consumer: MiddlewareConsumer) { - consumer - .apply(MerchantContextMiddleware) - .forRoutes({ path: "*", method: RequestMethod.ALL }); - } -} +export class AppModule {} diff --git a/apps/backend/src/modules/ussd/ussd.controller.ts b/apps/backend/src/channels/ussd/ussd.controller.ts similarity index 100% rename from apps/backend/src/modules/ussd/ussd.controller.ts rename to apps/backend/src/channels/ussd/ussd.controller.ts diff --git a/apps/backend/src/channels/ussd/ussd.dto.ts b/apps/backend/src/channels/ussd/ussd.dto.ts new file mode 100644 index 00000000..1bccbd68 --- /dev/null +++ b/apps/backend/src/channels/ussd/ussd.dto.ts @@ -0,0 +1,16 @@ +import { IsString, IsOptional } from "class-validator"; + +export class UssdCallbackDto { + @IsString() + sessionId!: string; + + @IsString() + phoneNumber!: string; + + @IsString() + serviceCode!: string; + + @IsString() + @IsOptional() + text!: string; +} diff --git a/apps/backend/src/channels/ussd/ussd.module.ts b/apps/backend/src/channels/ussd/ussd.module.ts new file mode 100644 index 00000000..af5dbb2c --- /dev/null +++ b/apps/backend/src/channels/ussd/ussd.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { UssdController } from "./ussd.controller"; +import { UssdService } from "./ussd.service"; +import { PrismaModule } from "../../prisma/prisma.module"; +import { PaymentModule } from "../../domains/money/payment/payment.module"; +import { NotificationModule } from "../../domains/social/notification/notification.module"; + +@Module({ + imports: [PrismaModule, PaymentModule, NotificationModule], + controllers: [UssdController], + providers: [UssdService], +}) +export class UssdModule {} diff --git a/apps/backend/src/modules/ussd/ussd.service.ts b/apps/backend/src/channels/ussd/ussd.service.ts similarity index 91% rename from apps/backend/src/modules/ussd/ussd.service.ts rename to apps/backend/src/channels/ussd/ussd.service.ts index 7ab6cda1..bd02b29c 100644 --- a/apps/backend/src/modules/ussd/ussd.service.ts +++ b/apps/backend/src/channels/ussd/ussd.service.ts @@ -1,8 +1,8 @@ import { Injectable, Logger } from "@nestjs/common"; import { PrismaService } from "../../prisma/prisma.service"; -import { SmsService } from "../notification/sms.service"; +import { SmsService } from "../../domains/social/notification/sms.service"; import { UssdCallbackDto } from "./ussd.dto"; -import { PaymentService } from "../payment/payment.service"; +import { PaymentService } from "../../domains/money/payment/payment.service"; @Injectable() export class UssdService { @@ -24,7 +24,7 @@ export class UssdService { try { // Level 0: Welcome menu if (level === 0) { - return "CON Welcome to Swifta\n1. Pay for an order\n2. Check order status"; + return "CON Welcome to twizrr\n1. Pay for an order\n2. Check order status"; } const mainChoice = inputs[0]; @@ -52,7 +52,7 @@ export class UssdService { // Level 1: Ask for phone if (level === 1) { - return "CON Enter the phone number linked to your Swifta account:"; + return "CON Enter the phone number linked to your twizrr account:"; } // Level 2: Show pending orders @@ -61,13 +61,13 @@ export class UssdService { const user = await this.prisma.user.findFirst({ where: { phone } }); if (!user) { - return "END No Swifta account found for this number. Visit swifta.store to register."; + return "END No twizrr account found for this number. Visit twizrr.com to register."; } const orders = await this.getPendingOrders(user.id); if (orders.length === 0) { - return "END You have no pending orders. Visit swifta.store to place an order."; + return "END You have no pending orders. Visit twizrr.com to place an order."; } let response = "CON Your pending orders:\n"; @@ -136,7 +136,7 @@ export class UssdService { try { await this.smsService.sendSms( phone, - `Swifta: Complete your payment of N${this.koboToNaira(order.totalAmountKobo)} here: ${paymentData.authorization_url}`, + `twizrr: Complete your payment of N${this.koboToNaira(order.totalAmountKobo)} here: ${paymentData.authorization_url}`, ); } catch (smsErr) { this.logger.warn( @@ -148,7 +148,7 @@ export class UssdService { return [ `END Payment initiated for Order #${shortId}.`, "You will receive a payment link via SMS shortly.", - "Thank you for using Swifta.", + "Thank you for using twizrr.", ].join("\n"); } @@ -164,14 +164,14 @@ export class UssdService { const level = inputs.length; if (level === 1) { - return "CON Enter the phone number linked to your Swifta account:"; + return "CON Enter the phone number linked to your twizrr account:"; } if (level === 2) { const phone = this.toE164(inputs[1]); const user = await this.prisma.user.findFirst({ where: { phone } }); - if (!user) return "END No Swifta account found for this number."; + if (!user) return "END No twizrr account found for this number."; const orders = await this.prisma.order.findMany({ where: { buyerId: user.id }, diff --git a/apps/backend/src/channels/whatsapp/AGENTS.md b/apps/backend/src/channels/whatsapp/AGENTS.md new file mode 100644 index 00000000..9407308b --- /dev/null +++ b/apps/backend/src/channels/whatsapp/AGENTS.md @@ -0,0 +1,739 @@ +# twizrr — WhatsApp Channel AI Assistant Instructions + +# apps/backend/src/channels/whatsapp/AGENTS.md + +> Read root AGENTS.md first, then apps/backend/AGENTS.md, then this file. +> This file covers ONLY the WhatsApp channel — not the full backend. +> Public prompt/capability contract: see TWIZRR_WHATSAPP_AI_CONTRACT.md + +--- + +## 0. What This Channel Is + +``` +The WhatsApp channel is an external entry point — not a business feature. +It receives messages from Nigerian shoppers via Meta WhatsApp Cloud API, +processes them through the AI assistant pipeline, and returns product +search results, order updates, and checkout links. + +THIS CHANNEL IS FOR SHOPPERS ONLY: +├── Product discovery (text search + image search) +├── Order status and tracking +├── Delivery confirmation (6-digit OTP or button tap) +└── Account linking (connect WhatsApp phone to twizrr account) + +THIS CHANNEL IS NOT FOR: +├── Store management (listing products, checking orders as seller) +├── Payout inquiries from store owners +├── KYB verification +└── Any admin or platform management action + +If a store owner messages the WhatsApp number about their store: +→ Respond exactly: "WIZZA is for shopping. Manage your store from your twizrr dashboard." +→ Do not process as store management request + +LOCATION IN CODEBASE: +└── apps/backend/src/channels/whatsapp/ + This is a channel — not a domain module + Future Phase 3: may be extracted to a separate worker service + The architecture is already prepared for this (no tight coupling) + +INJECTS FROM INTEGRATIONS (never import SDKs directly): +├── GeminiClient from src/integrations/ai/gemini.client.ts +├── VisionClient from src/integrations/ai/vision.client.ts +└── VertexClient from src/integrations/ai/vertex.client.ts +``` + +--- + +## 1. File Structure + +``` +src/channels/whatsapp/ +├── whatsapp.module.ts +├── whatsapp-shared.module.ts ← shared channel providers +├── whatsapp-auth-flows.module.ts ← account-linking flow wiring +├── whatsapp.controller.ts ← webhook endpoint +├── whatsapp.processor.ts ← BullMQ consumer +├── whatsapp.service.ts ← message routing + intent handling +├── whatsapp-session.service.ts ← consent + session per phone number +├── whatsapp-intent.service.ts ← Gemini function-calling + routing +├── whatsapp-auth.service.ts ← WhatsApp-first account linking +├── whatsapp-product-discovery.service.ts ← text discovery, hybrid ranking, +│ canonical URLs, Redis recent results +├── whatsapp-search-ranking.ts ← pure ranking helpers (formula, +│ text rank, performance, tier boost) +├── image-search.service.ts ← embedding-first image search +├── whatsapp-interactive.service.ts ← list messages, reply buttons, text +├── whatsapp-analytics.service.ts ← WhatsAppAnalytics v2 logging +├── whatsapp-logger.service.ts +├── whatsapp.constants.ts ← Redis prefixes, TTLs, copy constants +├── whatsapp.utils.ts ← phone normalization + log masking +└── AGENTS.md ← this file +``` + +--- + +## 2. Webhook Handler (whatsapp.controller.ts) + +``` +GET /whatsapp/webhook + - validates Meta's verify token + - returns the plain challenge on success + - returns 403 on token mismatch + +POST /whatsapp/webhook + - verifies x-hub-signature-256 against the raw request body first + - ignores invalid signatures with a safe 200 response + - walks every entry, change, and supported message in the batch + - accepts text, image, button-reply, and list-reply messages + - normalizes the sender phone before rate limiting or queueing + - reserves the Meta message ID in Redis for five-minute deduplication + - applies the Redis rate limit per normalized phone + - queues each accepted message as its own process-message job + - uses the deterministic job ID wa-inbound-{metaMessageId} + - gives inbound jobs three attempts with exponential backoff + +ACKNOWLEDGEMENT RULE: + Return 200 after every accepted message in the batch has been queued. + If Redis or BullMQ fails before durable queue acceptance, release the + dedup reservation and return 503 so Meta can retry the webhook. + Never acknowledge an unqueued valid message as successfully accepted. + +The controller only validates, deduplicates, rate limits, and queues. +Business processing remains asynchronous in WhatsAppProcessor. +``` + +--- + +## 3. BullMQ Processor (whatsapp.processor.ts) + +``` +INBOUND: + process-message routes the queued text, image, or interactive payload to + WhatsAppService. Consent, linking, discovery, intent, and analytics rules + remain in the channel services. + +OUTBOUND JOBS: + send-direct-order-notification + send-order-dispatched-notification + send-payment-confirmed-notification + send-delivery-confirmed-notification + send-text-message + +The worker must rethrow failures. BullMQ, not an in-method sleep/retry loop, +owns bounded retries and exponential backoff. Notification producers use +deterministic job IDs so the same domain event cannot enqueue misleading +duplicate jobs. + +CURRENT PRODUCER CONTRACT: + - New direct-order notification is wired to its dedicated job. + - Order-dispatched notification is wired to its dedicated job. + - Payment-confirmed and delivery-confirmed processor handlers exist, but + are not domain-wired until their payload and approved outbound-message + contract are confirmed. + - Payout initiation is not a delivery-confirmed event and must never be + sent through the delivery-confirmed job. It currently remains in-app/ + email only. +``` + +--- + +## 4. Session Service (whatsapp-session.service.ts) + +``` +PURPOSE: NDPR consent management + session state per phone number + +MODELS: +WhatsAppSession { + id String @id @default(cuid()) + phone String @unique // E.164 format + userId String? // → User (null until account linked) + consentGiven Boolean @default(false) + consentAt DateTime? + lastMessageAt DateTime? + createdAt DateTime @default(now()) +} + +CONSENT FLOW: +1. New phone number messages twizrr +2. recordInbound(from, userId) → creates/updates session with consentGiven = false +3. Service checks: consentGiven = false → send in-session reply buttons +4. Shopper taps [I Agree] on the interactive consent prompt +5. Interactive reply received → markConsentGiven(phone) → consentGiven = true +6. Next message → normal flow begins + +CONSENT PROMPT: +"Welcome to twizrr Shopping Assistant. + +By continuing, you agree that twizrr may process your messages +to help you find and purchase products. View our privacy policy +at twizrr.com/privacy. + +[I Agree] [No Thanks]" + +If shopper taps [No Thanks]: +→ "No problem. You can start shopping anytime by messaging us again." +→ Session remains with consentGiven = false +→ No further messages sent unless they initiate again + +ACCOUNT LINKING: +└── Shopper can link their WhatsApp to their twizrr account + Command: "link my account" OR via twizrr.com settings + Web-first: sends OTP to WhatsApp → enters OTP on /whatsapp + WhatsApp-first: asks for email → sends OTP to email → replies with OTP + Once verified → WhatsAppLink created and session.userId updated + Once linked: personalised results, order history accessible via WhatsApp +``` + +--- + +## 5. Intent Service (whatsapp-intent.service.ts) + +``` +PURPOSE: Gemini 2.5 Flash function-calling to parse shopper intent + +CRITICAL RULES: +├── Gemini uses FUNCTION-CALLING ONLY — never free-form product listings +├── Plain text responses only — NO emojis, NO markdown, NO bullet points +├── If AI fails: fall back to keyword search — never leave shopper stranded +└── Gemini system prompt includes: "You use NO emojis — plain text only" + +GEMINI SYSTEM PROMPT: +"You are twizrr's shopping assistant for Nigerian shoppers. +You help people find and buy products through WhatsApp. +You are warm, helpful, and conversational. +You speak like a knowledgeable friend — not a system. +You keep messages short (under 150 words per message). +You use NO emojis — plain text only. +You never use numbered menus or robotic lists. +You ONLY help with: shopping, products, stores, orders, delivery, disputes, receipts. +You NEVER discuss: politics, news, personal advice, entertainment, or anything outside twizrr. +If asked about something outside scope: 'I can only help with shopping on twizrr.' +You must call a function for every shopping request — never respond with product details directly." + +DECLARED FUNCTION FAMILIES (registry source of truth: +TWIZRR_WHATSAPP_AI_CONTRACT.md — every declared function must have a +matching backend handler, kept in sync in the same PR): + +search_products — guest-safe after consent (text product discovery) +get_cart — linked shopper read; bounded cart summary +start_checkout — linked shopper confirmation; validates the live cart, + then hands off to secure web checkout +get_saved_addresses — linked shopper read; safe address labels only +list_orders — linked shopper read; recent public order references +get_order_status — requires active verified WhatsAppLink +get_delivery_status — linked shopper read; ownership checked before tracking +confirm_delivery — linked shopper write; explicit order selection and scoped delivery-code state +show_menu — guest-safe after consent +answer_twizrr_question — bounded Twizrr shopping help only +support_handoff — deterministic support guidance + +INTENT ROUTING: +Text "I want red sneakers under 30k" → search_products (product discovery) +Text "What's in my cart?" → get_cart (link-gated) +Text "Proceed to checkout" → start_checkout (link-gated + confirmation) +Text "Show my saved addresses" → get_saved_addresses (link-gated) +Text "Show my recent orders" → list_orders (link-gated) +Text "What's my order status?" → get_order_status (link-gated) +Text "Where is order TWZ-123456?" → get_delivery_status (link-gated) +Text "Confirm delivery for order TWZ-123456" → confirm_delivery (link-gated) +Text "482930" after WIZZA selects that order and requests its delivery code → + handled by the scoped Redis confirmation flow BEFORE Gemini is called +Text "482930" outside that active flow → normal message; never globally intercepted +Text "2" / "the first one" / "that iPhone" after results → + resolved from Redis recent results BEFORE Gemini is called +Text about store management → exact refusal copy: + "WIZZA is for shopping. Manage your store from your twizrr dashboard." +Unclear intent → ask clarifying question (one question only) + +GRACEFUL AI FAILURE: +If Gemini API call fails or times out: +1. Log error (masked phone, no prompt values) +2. Fall back to keyword product discovery — never leave shopper stranded +3. Never: show an error message to the shopper +``` + +--- + +## 6. Image Search (image-search.service.ts) — Embedding-First + +``` +PURPOSE: Embedding-first image product discovery with inbound + SafeSearch screening (both implemented) + +FLOW (when shopper sends a photo): + +STEP 1 — Download image from Meta (MetaWhatsAppClient): +└── Returned as base64 — never saved to disk + +STEP 2 — SafeSearch screening (VisionClient.checkSafetyFromBase64 + + whatsapp-image-safety.ts): +└── Runs BEFORE any discovery work + BLOCK on LIKELY or VERY_LIKELY for adult/violence/racy + (same thresholds as upload moderation; POSSIBLE and below + never block; medical/spoof are not blocking categories + unless the upload policy changes) + Blocked → safe refusal copy, then STOP — no labels, no + embedding, no candidate search, no Redis recent-product write: + "Sorry, I can't search from that image. Please send a clear + product photo instead." + Blocked analytics: errorType IMAGE_BLOCKED_UNSAFE, + zeroResults false, vectorSearchUsed false, embeddingModel null + Screening failure FAILS OPEN: masked warn log, search continues + (image is search input only — never stored or shown to others) + +STEP 3 — Cloud Vision term extraction (VisionClient.detectProductTerms): +└── TEXT_DETECTION + OBJECT_LOCALIZATION + LABEL_DETECTION + Labels are CONTEXT and FALLBACK only — not the primary signal + A Vision failure does not abort the search + +STEP 4 — Vertex AI multimodal image embedding (VertexClient): +└── Input: image base64 (+ labels as optional context text) + Output: 1408-dim vector — matches stored product embeddings + THE PRIMARY image-search signal + +STEP 5 — pgvector candidate retrieval: +└── Top nearest product embeddings by cosine distance, then matched + product ids re-validated through the standard eligibility queries: + native (active/public/not blocked, productCode set, store open, + Tier 0 excluded) and sourced (active listing, open DIGITAL store, + in-stock dropship-enabled physical product) + Results ranked by similarity (recency breaks exact ties) + +GRACEFUL DEGRADATION: +└── Embedding unavailable/failed → label keyword fallback when safe + labels exist (analytics records vectorSearchUsed = false) + Labels failed but embedding ok → vector search continues + Neither available → helpful "couldn't identify" message + zeroResults is true ONLY when a real search returned no products + Failures never crash the reply flow; logs are masked, no secrets + +INJECTS: VisionClient, VertexClient (both from integrations/ai/), + WhatsAppProductDiscoveryService (shared record mapping, + Redis recent results, canonical links) + +CURRENT IMAGE DISCOVERY CONTRACT: + Image discovery is embedding-first. Cloud Vision labels are context and + fallback only. If image embedding plus pgvector search completes with + no eligible vector matches, WIZZA must not replace that result with + label keyword products. Failed media download, unsafe images, Vision + failures, embedding failures, and vector lookup failures fall back + safely with masked logs. + + Native and sourced eligibility filters still apply after vector lookup: + active/public/not blocked, productCode set, open store, Tier 0 excluded, + and sourced listings must have an open DIGITAL selling store plus an + in-stock dropship-enabled PHYSICAL source product. + + WhatsApp output must never include raw image data, Meta media ids, + storage details, raw labels/debug internals, embedding vectors, vector + similarities, ranking scores, source-store private fields, supplier + fulfillment fields, secrets, or private prompt values. +``` + +--- + +## 7. Text Product Discovery (whatsapp-product-discovery.service.ts) + +``` +PURPOSE: WIZZA text product discovery — approved hybrid ranking contract + +PIPELINE (implemented): +1. Fetch eligible candidates (native products + sourced listings) with + the standard public/in-stock rules; Tier 0 stores excluded in every + query. Candidate pool: 25 per source before ranking. +2. Generate a Vertex AI text embedding for the query. +3. Fetch pgvector cosine similarities for the candidates + (sourced listings use their physical product's embedding internally). +4. Score every candidate with the approved formula and return the top 10. + +APPROVED FORMULA: + final_score = vector_similarity * 0.5 + text_rank * 0.2 + + store_performance * 0.2 + tier_boost * 0.1 + +COMPONENTS (whatsapp-search-ranking.ts): + vector_similarity — cosine similarity, clamped to [0, 1] + text_rank — 1.0 phrase in title/name; 0.8 all tokens in + title/name; 0.5 description/category match; + 0.3 partial token match; 0 none + store_performance — orderCompletionRateBps / 10000, clamped; + DEFAULT 0.5 when the store has fewer than 5 + completed orders or no recorded rate + (new stores are never penalized to zero) + tier_boost — TIER_2 = 1.0, TIER_1 = 0.5 + +CONFIGURABLE WEIGHTS (src/config/search.config.ts): + SEARCH_WEIGHT_VECTOR=0.5 + SEARCH_WEIGHT_TEXT=0.2 + SEARCH_WEIGHT_PERFORMANCE=0.2 + SEARCH_WEIGHT_TIER=0.1 + Numeric, >= 0, must sum to 1.0 (float tolerance) — invalid weights + fail backend startup. Documented in apps/backend/.env.example. + +GRACEFUL DEGRADATION: +└── Vertex unconfigured / embedding or similarity lookup fails → + ranking continues without the vector component; + analytics records vectorSearchUsed = false honestly + +SOURCED LISTINGS: +└── Rank under the DIGITAL store context (its performance + tier), + display the digital store, and use the sourced listing's public + productCode — shoppers cannot tell native from sourced + +TIER 0 EXCLUSION — ABSOLUTE RULE: +└── tier != 'TIER_0' is in EVERY query — never removed + No exception, no override, no configuration + Tier 0 stores never appear in WhatsApp search results + +ZERO RESULTS HANDLING: +└── If results.length === 0: + Log to WhatsAppAnalytics: zeroResults = true + Respond with a friendly retry/browse message + Never: "No results found" (too robotic) + Never: leave shopper with no response + +RESULT FORMAT LIMIT: return max 10 products + +CURRENT TEXT DISCOVERY CONTRACT: + WIZZA text discovery uses a query embedding to widen the candidate pool + beyond literal keyword matches. The embedding never bypasses Prisma + eligibility filters and never changes the approved hybrid ranking + formula. + + Prisma eligibility remains the authority for what can appear: + native products must be active/public/not blocked with productCode set, + store open, and Tier 0 excluded. Sourced listings must be active, sold + by an open DIGITAL store, backed by an in-stock dropship-enabled + PHYSICAL product, and Tier 0 excluded. + + The hybrid ranking formula in whatsapp-search-ranking.ts remains the + ranking authority. Do not change ranking weights, vector similarity, + text rank, store performance, or tier boost from docs-only work. + + Redis recent product-selection state is written only for valid + displayed discovery results. Raw embedding vectors, vector candidate + ids, vector similarities, ranking scores, ranking weights, prompt + values, source-store private fields, supplier-only fulfillment fields, + cost/margin internals, and debug details must never appear in WhatsApp + shopper output. +``` + +--- + +## 7a. Canonical Product URLs and Public Product Identity + +``` +EVERY WIZZA product link uses the canonical format: + {WEB_URL}/stores/{storeHandle}/p/{productCode} + +NATIVE products: native public productCode + selling store handle +SOURCED listings: sourced listing public productCode + digital store handle + +NEVER in shopper-facing URLs or WIZZA replies: + productId, sourcedProductId, sourceCode, listingCode, + physicalProductId, physicalStoreId + (sourceCode and listingCode are internal/store-owner-facing only) + +If a safe canonical URL cannot be built: send no link. +Never fabricate URLs or fall back to internal ids. + +STORE IDENTITY PRIVACY: + storeName — public display name ("Sold by {storeName}") + storeHandle — public username/URL handle (canonical URLs use this) + businessName — internal/legal/KYB/admin/bank identity; + never selected or exposed in WIZZA shopper replies + Display fallback: storeName → storeHandle → "twizrr store" + +SHOPPER COPY MUST NEVER CONTAIN: + source/supplier/dropship/partner-store/physical-store wording, + cost/margin/fulfilment internals, supplier contact, internal ids +``` + +--- + +## 7b. Redis Recent-Product Selection + +``` +After each text or image search, the displayed results are cached in +Redis per phone (WA_RECENT_PRODUCTS_PREFIX, session TTL). + +Shoppers can then select a product by: + - tapping the list row (product_result_{rank}) + - sending a number/ordinal: "1", "number 2", "the first one" + - naming the product: "that iPhone" +Selection resolves ONLY against the cached recent results — stale or +unknown selections get a friendly "search again" message. + +CACHED PAYLOAD — SAFE PUBLIC FIELDS ONLY: + rank, listingType, productId (selection mechanics only), public + productCode, title, public store display name, storeHandle, + canonical productUrl, price display +NEVER cached: sourceCode, listingCode, physical/source store data, + cost/margin/fulfilment fields, businessName + +SELECTION SCOPE: + Numeric input is product selection only when there is active recent + product-selection state for that WhatsApp phone. Do not introduce + global numeric interception. A stale, empty, or unrelated numeric + message must fall back safely instead of selecting a product globally. + Text and image discoveries replace the recent selection cache with the + latest valid displayed results. Failed discovery attempts must not + write selection state. + Starting a new search, opening category browsing, requesting the next + category page, or entering account linking clears stale product-selection + state before continuing. +``` + +--- + +## 7c. Discovery Follow-Up Actions + +``` +BROWSE CATEGORIES: + browse_categories clears stale selection state and opens the first + category list. The list uses nine categories plus a "More categories" + row so it stays within Meta's ten-row limit. + +MORE CATEGORIES: + browse_categories_more clears stale selection state and opens the next + category page. + +SEARCH AGAIN / TRY TEXT SEARCH: + search_products clears stale selection state, creates a short-lived + pending text-search state, and asks the shopper for a product name, + category, colour, or style. The same action is used after image-search + follow-up so text input is interpreted as the requested new search. + +CATEGORY SELECTION: + category:{slug} and the legacy category_selection_{slug} IDs resolve + through the shared product taxonomy and run normal eligible discovery. + +These controls are real state transitions. Do not emit an interactive +action without a matching handler and regression coverage. +``` + +--- + +## 7d. Discovery Flow Boundaries and Shopper-Safe Output + +``` +FLOW BOUNDARIES: + Consent is required before AI/search. + Account-linking flow bypasses product discovery parsing. + Protected cart, order, payment, and delivery actions require an active + verified WhatsAppLink. + Discovery-only text and image search remain guest-safe after consent. + WhatsApp is shopping-only and never manages stores. + Store-management prompts must use the exact redirect copy from this doc. + No emojis, markdown, or raw technical output in WIZZA copy. + +SHOPPER-SAFE PRODUCT OUTPUT MUST NEVER EXPOSE: + physicalStoreId + physicalProductId + dropshipperCostKobo + digitalMarginKobo + linkedOrderId + Product.sourceCode + source-store private fields + supplier-only fulfillment fields + raw ranking internals + raw embeddings/vector details + raw image storage internals + private prompt values + +PUBLIC URL CONTRACT: + WhatsApp product links must remain canonical shopper-facing URLs: + {WEB_URL}/stores/{storeHandle}/p/{productCode} + Do not use sourcedProductId, listingCode, sourceCode, physicalProductId, + or physicalStoreId as public WhatsApp link identifiers. +``` + +--- + +## 8. Meta Templates and Interactive Messages + +``` +RUNTIME TEMPLATE SUPPORT: + The current template sender is the OTP/authentication path using the + configured auth_otp template. Do not document proposed launch templates + as approved or runtime-wired unless the Meta account and code both prove it. + +PROACTIVE EVENT MESSAGES: + Domain events must enqueue a distinct WhatsApp job with an event-specific + payload and deterministic job ID. A payout event must never reuse a + delivery event, and an order event must never be inferred from generic + notification text. + + Current verified producer paths: + - direct order received -> send-direct-order-notification + - order dispatched -> send-order-dispatched-notification + + Not yet producer-wired: + - payment confirmed + - delivery confirmed + - payout initiated/completed/failed + + Add these only with an approved Meta/session delivery contract, a dedicated + payload, and tests proving the event cannot be mislabelled. + +INTERACTIVE MESSAGES: + List messages and reply buttons are sent only inside the active customer + service window. They are used for product results, category browsing, + search-again actions, and product selection. + +TEXT MESSAGE RULE: +All text messages: plain text ONLY +No markdown (*bold*, _italic_, ~strikethrough~) +No emojis +No decorative bullet characters +Keep under 150 words per message +``` + +--- + +## 9. Analytics v2 (whatsapp-analytics.service.ts) + +``` +PURPOSE: Log every WhatsApp interaction — captured from day 1 + This data trains future recommendation models (Phase 3+) + +ANALYTICS V2 FIELDS (recorded HONESTLY on every processed message): + sessionId, phone, userId, messageType, conversationTurn, + flowAtStart / flowAtEnd, intentClassified, intentSuccessful, + imageProvided, searchQuery, parsedFilters, categoryInferred, + vectorSearchUsed — true only when an embedding was generated AND + the pgvector lookup succeeded + embeddingModel — the Vertex model name when vector search ran, + otherwise null + productsRankedCount — candidates that entered ranking + productsShown / productsShownCount — what was actually sent + productSelected / productOrdered / sessionConverted + zeroResults — true ONLY when a real search returned no + products; never for embedding/label/download + errors (those keep their own errorType) + dropOffStep, escalatedToHuman, geminiResponseMs, geminiModel, + errorType — honest fallback/error state + +PRIVACY: +└── No raw image data, secrets, OTPs, or prompt values are logged. + Phone numbers are masked in all log lines. + parsedFilters records image terms and label-fallback usage only. + +WHY THIS MATTERS: +└── zeroResults patterns → tells us which product categories are missing + vectorSearchUsed/embeddingModel → measures vector search coverage + parsedFilters patterns → tells us what shoppers want + sessionConverted → measures WhatsApp AI conversion rate + productsShown → measures which products get discovered vs bought + +SESSION CONVERTED: +└── Set to true when an order is created where: + order.shopperId = session.userId AND + order.createdAt is within 24 hours of the session + Updated retroactively by order module via EventEmitter2 +``` + +--- + +## 10. Rate Limiting Per Phone + +``` +IMPLEMENTATION: + Redis key: wa:inbound-rate:{normalizedPhone} + Limit: 30 accepted inbound messages per one-hour window per phone + Operation: atomic increment-with-expiry through RedisService + Location: webhook intake, after message-ID deduplication and before queueing + +The limit is per normalized WhatsApp phone, never global and never based on +Nest ThrottlerGuard. An over-limit message is not queued. Do not log the raw +phone number. +``` + +--- + +## 11. Error Handling and Fallbacks + +``` +PRINCIPLE: Never leave the shopper with no response. + +GEMINI API FAILURE: +└── Catch error → log with structured context for operator review + Fall back to search.service.ts keyword mode + Response: continue normally using keyword results + Shopper never sees: "AI error" or "Something went wrong" + +VERTEX AI FAILURE (image search): +└── Catch error → log with structured context for operator review + If no image embedding was produced but safe labels were extracted, a + label-keyword fallback may run because the primary vector search never + completed. If neither signal is usable, ask the shopper to describe the + product in text. + If embedding and pgvector search complete with no eligible vector match, + do not replace that result with label-keyword products. Labels remain + context/fallback input, not a second result authority. + +SECURE CHECKOUT HANDOFF: +└── WIZZA reads and validates the linked shopper's live persisted cart, + summarizes it, and requires explicit confirmation. On confirmation it + re-reads the cart and returns the configured WEB_URL `/cart` route. + WIZZA never creates an order, initializes payment, or transfers auth, + payment, cart-item, or provider identifiers through the URL. +└── If the cart changed, is empty, or WEB_URL is unavailable, return a safe + retry/review message and do not generate a link. + +META API FAILURE (sending message): +└── Meta client throws to WhatsAppProcessor + WhatsAppProcessor rethrows to BullMQ + Notification jobs use three attempts with exponential backoff starting + at five seconds, deterministic event job IDs, retained failed jobs, and + completed-job retention for deduplication + Never catch and mark a failed outbound delivery as successful + +ZERO RESULTS: +└── Always respond with: "I could not find [query] right now. + Try a different search or visit twizrr.com to browse all products." + Never: silence, error messages, or raw technical output + +UNKNOWN INTERACTIVE REPLY: +└── If button ID or list row ID is not recognised: + "Sorry, something went wrong. What are you looking for?" + Reset to beginning of search flow +``` + +--- + +## 12. Before You Write Any Code + +``` +CHECKLIST for WhatsApp channel: + +□ Read root AGENTS.md +□ Read apps/backend/AGENTS.md +□ Read channels/whatsapp/AGENTS.md (this file) +□ Are you injecting GeminiClient/VisionClient/VertexClient from integrations/ai/? + (never import Google AI SDKs directly into channel services) +□ Does the webhook return 200 only after accepted messages are queued? + (invalid signatures are safely ignored; Redis/BullMQ failures return 503) +□ Is HMAC verification the FIRST thing in the webhook handler? +□ Is all processing async via BullMQ? (never synchronous in controller) +□ Are ALL WhatsApp messages plain text? (no emojis, no markdown) +□ Is every proactive message backed by a confirmed approved/session contract? +□ Does every outbound domain event use its own payload, job name, and job ID? +□ Is Tier 0 excluded from ALL search queries? + (tier != 'TIER_0' in every search — no exceptions) +□ Are you handling zero results gracefully? (friendly message — no errors) +□ Are you logging to WhatsAppAnalytics for every processed message? +□ Does the rate limiter use Redis per phone? (not ThrottlerGuard) +□ Is the session consent checked before any search or response? +□ Have the relevant focused tests, lint, typecheck, and build checks passed? + Use pnpm commands from the repository scripts. + +IF ANYTHING IS UNCLEAR: stop and ask. Never guess. +``` + +--- + +_Last updated: June 2026_ +_Project: twizrr — channels/whatsapp_ +_Maintained by: CODEDDEVS TECHNOLOGY LTD_ diff --git a/apps/backend/src/channels/whatsapp/agent/shopper-agent-discovery-context.types.ts b/apps/backend/src/channels/whatsapp/agent/shopper-agent-discovery-context.types.ts new file mode 100644 index 00000000..315771ae --- /dev/null +++ b/apps/backend/src/channels/whatsapp/agent/shopper-agent-discovery-context.types.ts @@ -0,0 +1,13 @@ +export interface ShopperDiscoveryContextProduct { + rank: number; + title: string; + storeName: string; + priceDisplay?: string; + publicProductUrl?: string; +} + +export interface ShopperDiscoveryContext { + lastQuery: string | null; + source: "text" | "image"; + products: ShopperDiscoveryContextProduct[]; +} diff --git a/apps/backend/src/channels/whatsapp/agent/shopper-agent-orchestrator.service.spec.ts b/apps/backend/src/channels/whatsapp/agent/shopper-agent-orchestrator.service.spec.ts new file mode 100644 index 00000000..1fb88cde --- /dev/null +++ b/apps/backend/src/channels/whatsapp/agent/shopper-agent-orchestrator.service.spec.ts @@ -0,0 +1,103 @@ +import { ShopperAgentOrchestrator } from "./shopper-agent-orchestrator.service"; +import { ShopperAgentPolicyService } from "./shopper-agent-policy.service"; +import { ShopperAgentToolRegistry } from "./shopper-agent-tool.registry"; + +describe("ShopperAgentOrchestrator", () => { + let orchestrator: ShopperAgentOrchestrator; + + beforeEach(() => { + orchestrator = new ShopperAgentOrchestrator( + new ShopperAgentToolRegistry(), + new ShopperAgentPolicyService(), + ); + }); + + it("builds an allowed typed guest discovery plan", () => { + expect( + orchestrator.planIntent({ + functionName: "search_products", + params: { query: "black sneakers" }, + messageText: "Find black sneakers", + linkedUserId: null, + consentGiven: true, + }), + ).toMatchObject({ + kind: "tool", + functionName: "search_products", + tool: { + name: "search_products", + category: "guest", + input: { query: "black sneakers" }, + }, + policy: { + allowed: true, + confirmationRequired: false, + }, + audit: { + event: "shopper_agent_plan", + actionCategory: "guest", + policyDecision: "allowed", + }, + }); + }); + + it("returns a denied plan for an unlinked account read", () => { + expect( + orchestrator.planIntent({ + functionName: "get_order_status", + params: { orderReference: "TWZ-123456" }, + messageText: "Where is TWZ-123456?", + linkedUserId: null, + consentGiven: true, + }), + ).toMatchObject({ + kind: "tool", + functionName: "get_order_status", + policy: { + allowed: false, + reason: "linked_account_required", + }, + audit: { + policyDecision: "denied", + policyReason: "linked_account_required", + }, + }); + }); + + it("keeps safe natural replies in the conversation lane", () => { + expect( + orchestrator.planIntent({ + functionName: "natural_answer", + params: { answer: "WIZZA helps shoppers find products on Twizrr." }, + messageText: "Who are you?", + linkedUserId: null, + consentGiven: true, + }), + ).toMatchObject({ + kind: "conversation", + functionName: "natural_answer", + params: { + answer: "WIZZA helps shoppers find products on Twizrr.", + }, + audit: { + actionCategory: "conversation", + }, + }); + }); + + it("downgrades unknown model actions to the friendly fallback", () => { + expect( + orchestrator.planIntent({ + functionName: "read_private_store_data", + params: { storeId: "private" }, + messageText: "Show private store data", + linkedUserId: "user-1", + consentGiven: true, + }), + ).toMatchObject({ + kind: "conversation", + functionName: "friendly_fallback", + params: {}, + }); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/agent/shopper-agent-orchestrator.service.ts b/apps/backend/src/channels/whatsapp/agent/shopper-agent-orchestrator.service.ts new file mode 100644 index 00000000..388a80fd --- /dev/null +++ b/apps/backend/src/channels/whatsapp/agent/shopper-agent-orchestrator.service.ts @@ -0,0 +1,94 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ShopperAgentPolicyService } from "./shopper-agent-policy.service"; +import { ShopperAgentToolRegistry } from "./shopper-agent-tool.registry"; +import type { + ShopperAgentConversationName, + ShopperAgentPlan, + ShopperAgentPlanInput, + ShopperAgentPolicyDecision, +} from "./shopper-agent.types"; + +const CONVERSATION_ACTIONS = new Set([ + "natural_answer", + "friendly_fallback", +]); + +const CONVERSATION_POLICY: ShopperAgentPolicyDecision = { + allowed: true, + reason: "allowed", + confirmationRequired: false, +}; + +@Injectable() +export class ShopperAgentOrchestrator { + private readonly logger = new Logger(ShopperAgentOrchestrator.name); + + constructor( + private readonly toolRegistry: ShopperAgentToolRegistry, + private readonly policyService: ShopperAgentPolicyService, + ) {} + + planIntent(input: ShopperAgentPlanInput): ShopperAgentPlan { + const tool = this.toolRegistry.createRequest( + input.functionName, + input.params, + input.messageText, + ); + + if (tool) { + const policy = this.policyService.evaluate(tool, { + consentGiven: input.consentGiven, + linkedUserId: input.linkedUserId, + }); + const plan: ShopperAgentPlan = { + kind: "tool", + functionName: tool.name, + params: tool.input, + tool, + policy, + audit: { + event: "shopper_agent_plan", + actionName: tool.name, + actionCategory: tool.category, + policyDecision: policy.allowed ? "allowed" : "denied", + policyReason: policy.reason, + }, + }; + this.logPlan(plan); + return plan; + } + + const functionName = CONVERSATION_ACTIONS.has( + input.functionName as ShopperAgentConversationName, + ) + ? (input.functionName as ShopperAgentConversationName) + : "friendly_fallback"; + const plan: ShopperAgentPlan = { + kind: "conversation", + functionName, + params: functionName === "natural_answer" ? (input.params ?? {}) : {}, + policy: CONVERSATION_POLICY, + audit: { + event: "shopper_agent_plan", + actionName: functionName, + actionCategory: "conversation", + policyDecision: "allowed", + policyReason: "allowed", + }, + }; + this.logPlan(plan); + return plan; + } + + private logPlan(plan: ShopperAgentPlan): void { + this.logger.log( + JSON.stringify({ + event: plan.audit.event, + actionName: plan.audit.actionName, + actionCategory: plan.audit.actionCategory, + policyDecision: plan.audit.policyDecision, + policyReason: plan.audit.policyReason, + }), + ); + } +} diff --git a/apps/backend/src/channels/whatsapp/agent/shopper-agent-policy.service.spec.ts b/apps/backend/src/channels/whatsapp/agent/shopper-agent-policy.service.spec.ts new file mode 100644 index 00000000..e0df0f5d --- /dev/null +++ b/apps/backend/src/channels/whatsapp/agent/shopper-agent-policy.service.spec.ts @@ -0,0 +1,157 @@ +import { ShopperAgentPolicyService } from "./shopper-agent-policy.service"; +import type { ShopperAgentToolRequest } from "./shopper-agent.types"; + +describe("ShopperAgentPolicyService", () => { + const service = new ShopperAgentPolicyService(); + + it("allows consented guest discovery without an account link", () => { + const request: ShopperAgentToolRequest = { + name: "search_products", + category: "guest", + input: { query: "phones" }, + }; + + expect( + service.evaluate(request, { + consentGiven: true, + linkedUserId: null, + }), + ).toEqual({ + allowed: true, + reason: "allowed", + confirmationRequired: false, + }); + }); + + it("blocks every tool before WhatsApp consent", () => { + const request: ShopperAgentToolRequest = { + name: "show_menu", + category: "guest", + input: {}, + }; + + expect( + service.evaluate(request, { + consentGiven: false, + linkedUserId: null, + }), + ).toEqual({ + allowed: false, + reason: "consent_required", + confirmationRequired: false, + }); + }); + + it("requires an active link for account-specific reads", () => { + const request: ShopperAgentToolRequest = { + name: "get_order_status", + category: "linked-read", + input: {}, + }; + + expect( + service.evaluate(request, { + consentGiven: true, + linkedUserId: null, + }), + ).toEqual({ + allowed: false, + reason: "linked_account_required", + confirmationRequired: false, + }); + }); + + it("requires an active link for cart, address, and order-list reads", () => { + for (const name of [ + "get_cart", + "get_saved_addresses", + "list_orders", + "get_delivery_status", + ] as const) { + const input = + name === "get_delivery_status" ? { orderReference: "TWZ-123456" } : {}; + const request = { + name, + category: "linked-read", + input, + } as ShopperAgentToolRequest; + + expect( + service.evaluate(request, { + consentGiven: true, + linkedUserId: null, + }), + ).toMatchObject({ + allowed: false, + reason: "linked_account_required", + }); + } + }); + + it("derives access from the trusted tool name instead of caller metadata", () => { + const forgedRequest = { + name: "get_order_status", + category: "guest", + input: {}, + } as unknown as ShopperAgentToolRequest; + + expect( + service.evaluate(forgedRequest, { + consentGiven: true, + linkedUserId: null, + }), + ).toEqual({ + allowed: false, + reason: "linked_account_required", + confirmationRequired: false, + }); + }); + + it("marks sensitive flows as requiring scoped confirmation", () => { + const request: ShopperAgentToolRequest = { + name: "confirm_delivery", + category: "confirmation-required", + input: { orderReference: "TWZ-123456" }, + }; + + expect( + service.evaluate(request, { + consentGiven: true, + linkedUserId: "user-1", + }), + ).toEqual({ + allowed: true, + reason: "allowed", + confirmationRequired: true, + }); + }); + + it("requires both an active link and confirmation for cart mutations", () => { + const request: ShopperAgentToolRequest = { + name: "add_to_cart", + category: "confirmation-required", + input: { productReference: "first", quantity: 1 }, + }; + + expect( + service.evaluate(request, { + consentGiven: true, + linkedUserId: null, + }), + ).toEqual({ + allowed: false, + reason: "linked_account_required", + confirmationRequired: false, + }); + expect( + service.evaluate(request, { + consentGiven: true, + linkedUserId: "user-1", + }), + ).toEqual({ + allowed: true, + reason: "allowed", + confirmationRequired: true, + }); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/agent/shopper-agent-policy.service.ts b/apps/backend/src/channels/whatsapp/agent/shopper-agent-policy.service.ts new file mode 100644 index 00000000..43124807 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/agent/shopper-agent-policy.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from "@nestjs/common"; +import type { + ShopperAgentPolicyDecision, + ShopperAgentToolRequest, +} from "./shopper-agent.types"; +import { SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME } from "./shopper-agent.types"; + +export interface ShopperAgentPolicyContext { + consentGiven: boolean; + linkedUserId: string | null; +} + +@Injectable() +export class ShopperAgentPolicyService { + evaluate( + request: ShopperAgentToolRequest, + context: ShopperAgentPolicyContext, + ): ShopperAgentPolicyDecision { + const category = SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME[request.name]; + + if (!context.consentGiven) { + return { + allowed: false, + reason: "consent_required", + confirmationRequired: false, + }; + } + + if (category === "guest") { + return { + allowed: true, + reason: "allowed", + confirmationRequired: false, + }; + } + + if (!context.linkedUserId) { + return { + allowed: false, + reason: "linked_account_required", + confirmationRequired: false, + }; + } + + return { + allowed: true, + reason: "allowed", + confirmationRequired: category === "confirmation-required", + }; + } +} diff --git a/apps/backend/src/channels/whatsapp/agent/shopper-agent-tool.registry.spec.ts b/apps/backend/src/channels/whatsapp/agent/shopper-agent-tool.registry.spec.ts new file mode 100644 index 00000000..cd3c4569 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/agent/shopper-agent-tool.registry.spec.ts @@ -0,0 +1,361 @@ +import { + SHOPPER_AGENT_GEMINI_DECLARATIONS, + SHOPPER_AGENT_TOOL_DEFINITIONS, + ShopperAgentToolRegistry, +} from "./shopper-agent-tool.registry"; + +describe("ShopperAgentToolRegistry", () => { + let registry: ShopperAgentToolRegistry; + + beforeEach(() => { + registry = new ShopperAgentToolRegistry(); + }); + + it("keeps Gemini declarations aligned with the typed registry", () => { + expect( + SHOPPER_AGENT_GEMINI_DECLARATIONS.map((declaration) => declaration.name), + ).toEqual(SHOPPER_AGENT_TOOL_DEFINITIONS.map((tool) => tool.name)); + }); + + it("normalizes a product search and falls back to the shopper message", () => { + expect( + registry.createRequest("search_products", {}, " black sneakers "), + ).toEqual({ + name: "search_products", + category: "guest", + input: { query: "black sneakers" }, + }); + }); + + it("rejects empty product-search input", () => { + expect(registry.createRequest("search_products", {}, " ")).toBeNull(); + }); + + it("normalizes contextual product refinement input", () => { + expect( + registry.createRequest( + "refine_product_search", + { + refinement: " show cheaper options ", + productReference: " second ", + }, + "ignored", + ), + ).toEqual({ + name: "refine_product_search", + category: "guest", + input: { + refinement: "show cheaper options", + productReference: "second", + }, + }); + }); + + it("limits product comparison references to displayed-result inputs", () => { + expect( + registry.createRequest( + "compare_products", + { + productReferences: [" first ", "", "second", 3, "third", "fourth"], + }, + "compare these", + ), + ).toEqual({ + name: "compare_products", + category: "guest", + input: { productReferences: ["first", "second", "third"] }, + }); + }); + + it("does not create requests for undeclared Gemini tools", () => { + expect( + registry.createRequest( + "query_prisma_directly", + { table: "users" }, + "show users", + ), + ).toBeNull(); + }); + + it("accepts shopper-safe outputs", () => { + const output = { + status: "sent", + productsShownCount: 2, + publicProductUrls: ["https://app.twizrr.com/stores/example/p/TWZ-123456"], + }; + + expect(() => + registry.assertSafeOutput("search_products", output), + ).not.toThrow(); + }); + + it("accepts shopper-safe refinement and comparison outputs", () => { + expect(() => + registry.assertSafeOutput("refine_product_search", { + status: "no_context", + productsShownCount: 0, + publicProductUrls: [], + }), + ).not.toThrow(); + expect(() => + registry.assertSafeOutput("compare_products", { + status: "sent", + productsComparedCount: 2, + publicProductUrls: [ + "https://app.twizrr.com/stores/example/p/TWZ-123456", + "https://app.twizrr.com/stores/example/p/TWZ-654321", + ], + }), + ).not.toThrow(); + }); + + it("accepts shopper-safe linked-read outputs", () => { + expect(() => + registry.assertSafeOutput("get_cart", { + status: "available", + itemCount: 2, + totalQuantity: 3, + subtotalDisplay: "NGN 25,000.00", + }), + ).not.toThrow(); + expect(() => + registry.assertSafeOutput("get_saved_addresses", { + status: "available", + addressCount: 1, + defaultAddressLabel: "Home", + }), + ).not.toThrow(); + expect(() => + registry.assertSafeOutput("list_orders", { + status: "available", + ordersShownCount: 1, + orderReferences: ["TWZ-123456"], + }), + ).not.toThrow(); + expect(() => + registry.assertSafeOutput("get_delivery_status", { + status: "available", + orderReference: "TWZ-123456", + deliveryStatus: "in transit", + }), + ).not.toThrow(); + }); + + it("normalizes confirmation-protected cart and address inputs", () => { + expect( + registry.createRequest( + "add_to_cart", + { productReference: " second ", quantity: 2 }, + "ignored", + ), + ).toEqual({ + name: "add_to_cart", + category: "confirmation-required", + input: { productReference: "second", quantity: 2 }, + }); + expect( + registry.createRequest( + "update_cart_quantity", + { cartItemReference: " black sneakers ", quantity: 3 }, + "ignored", + ), + ).toEqual({ + name: "update_cart_quantity", + category: "confirmation-required", + input: { cartItemReference: "black sneakers", quantity: 3 }, + }); + expect(registry.createRequest("start_checkout", {}, "ignored")).toEqual({ + name: "start_checkout", + category: "confirmation-required", + input: {}, + }); + expect( + registry.createRequest( + "select_delivery_address", + { addressReference: " home " }, + "ignored", + ), + ).toEqual({ + name: "select_delivery_address", + category: "confirmation-required", + input: { addressReference: "home" }, + }); + }); + + it("normalizes post-purchase inputs without accepting internal identifiers", () => { + expect( + registry.createRequest( + "start_dispute", + { + orderReference: " twz-123456 ", + reason: " Damaged item ", + description: " The item arrived damaged. ", + requestedOutcome: " refund ", + orderId: "private-order-id", + }, + "ignored", + ), + ).toEqual({ + name: "start_dispute", + category: "confirmation-required", + input: { + orderReference: "twz-123456", + reason: "Damaged item", + description: "The item arrived damaged.", + requestedOutcome: "refund", + }, + }); + expect( + registry.createRequest( + "get_refund_status", + { + orderReference: " TWZ-654321 ", + disputeId: "private-dispute-id", + }, + "ignored", + ), + ).toEqual({ + name: "get_refund_status", + category: "linked-read", + input: { orderReference: "TWZ-654321" }, + }); + }); + + it("rejects invalid cart quantities before a tool plan is created", () => { + expect( + registry.createRequest( + "add_to_cart", + { productReference: "first", quantity: 0 }, + "ignored", + ), + ).toBeNull(); + expect( + registry.createRequest( + "update_cart_quantity", + { cartItemReference: "first", quantity: 0 }, + "ignored", + ), + ).toBeNull(); + expect( + registry.createRequest( + "update_cart_quantity", + { cartItemReference: "first", quantity: 100 }, + "ignored", + ), + ).toBeNull(); + }); + + it("accepts only the safe confirmation status contract for cart writes", () => { + expect(() => + registry.assertSafeOutput("add_to_cart", { + status: "confirmation_required", + nextStep: "confirm_or_cancel", + }), + ).not.toThrow(); + expect(() => + registry.assertSafeOutput("start_checkout", { + status: "confirmation_required", + itemCount: 2, + totalQuantity: 3, + subtotalDisplay: "NGN 25,000.00", + nextStep: "confirm_or_cancel", + }), + ).not.toThrow(); + expect(() => + registry.assertSafeOutput("start_checkout", { + status: "confirmation_required", + paymentReference: "private-payment-reference", + }), + ).toThrow("Unsafe start_checkout tool output field: paymentReference"); + expect(() => + registry.assertSafeOutput("remove_from_cart", { + status: "confirmation_required", + cartItemId: "private-cart-item", + }), + ).toThrow("Unsafe remove_from_cart tool output field: cartItemId"); + }); + + it("accepts shopper-safe dispute and refund outputs and rejects internals", () => { + expect(() => + registry.assertSafeOutput("start_dispute", { + status: "confirmation_required", + orderReference: "TWZ-123456", + nextStep: "confirm_or_cancel", + }), + ).not.toThrow(); + expect(() => + registry.assertSafeOutput("get_dispute_status", { + status: "available", + orderReference: "TWZ-123456", + disputeStatus: "under review", + }), + ).not.toThrow(); + expect(() => + registry.assertSafeOutput("get_refund_status", { + status: "available", + orderReference: "TWZ-123456", + refundStatus: "processing", + refundAmountDisplay: "NGN 25,000.00", + }), + ).not.toThrow(); + expect(() => + registry.assertSafeOutput("get_refund_status", { + status: "available", + orderReference: "TWZ-123456", + refundStatus: "processing", + providerReference: "private-provider-reference", + }), + ).toThrow("Unsafe get_refund_status tool output field: providerReference"); + }); + + it("rejects malformed linked-read outputs", () => { + expect(() => + registry.assertSafeOutput("get_cart", { + status: "available", + itemCount: -1, + totalQuantity: 0, + }), + ).toThrow("Unsafe get_cart tool output itemCount"); + expect(() => + registry.assertSafeOutput("list_orders", { + status: "available", + ordersShownCount: 1, + orderReferences: ["TWZ-123456", 42], + }), + ).toThrow("Unsafe list_orders tool output orderReferences"); + }); + + it("rejects outputs with missing required fields", () => { + expect(() => registry.assertSafeOutput("search_products", {})).toThrow( + "Unsafe search_products tool output status", + ); + }); + + it("rejects outputs with invalid runtime value types", () => { + expect(() => + registry.assertSafeOutput("search_products", { + status: "sent", + productsShownCount: "two", + }), + ).toThrow("Unsafe search_products tool output productsShownCount"); + }); + + it("rejects statuses outside the declared tool contract", () => { + expect(() => + registry.assertSafeOutput("get_order_status", { + status: "sent", + }), + ).toThrow("Unsafe get_order_status tool output status"); + }); + + it("rejects private source fields even when nested", () => { + expect(() => + registry.assertSafeOutput("search_products", { + status: "sent", + productsShownCount: 1, + publicProductUrls: [], + result: { physicalStoreId: "private-store-id" }, + }), + ).toThrow("Unsafe shopper-agent output field: physicalStoreId"); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/agent/shopper-agent-tool.registry.ts b/apps/backend/src/channels/whatsapp/agent/shopper-agent-tool.registry.ts new file mode 100644 index 00000000..55688876 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/agent/shopper-agent-tool.registry.ts @@ -0,0 +1,880 @@ +import { Injectable } from "@nestjs/common"; +import type { GeminiFunctionDeclaration } from "../../../integrations/ai/gemini.client"; +import type { + ShopperAgentToolInputMap, + ShopperAgentToolCategoryMap, + ShopperAgentToolName, + ShopperAgentToolOutputMap, + ShopperAgentToolRequest, +} from "./shopper-agent.types"; +import { SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME } from "./shopper-agent.types"; +import { isWhatsAppCartQuantity } from "../whatsapp.constants"; + +interface ShopperAgentToolDefinition { + name: Name; + category: ShopperAgentToolCategoryMap[Name]; + executorKey: + | "product_search" + | "product_refinement" + | "product_comparison" + | "cart_read" + | "cart_add" + | "cart_update" + | "cart_remove" + | "checkout_handoff" + | "address_read" + | "address_select" + | "order_list" + | "order_status" + | "delivery_status" + | "delivery_confirmation" + | "dispute_open" + | "dispute_status" + | "refund_status" + | "menu" + | "support" + | "store_redirect"; + description: string; + parameters: NonNullable; + safeOutputFields: readonly (keyof ShopperAgentToolOutputMap[Name])[]; +} + +type AnyShopperAgentToolDefinition = { + [Name in ShopperAgentToolName]: ShopperAgentToolDefinition; +}[ShopperAgentToolName]; + +const EMPTY_PARAMETERS = { + type: "object" as const, + properties: {}, +}; + +export const SHOPPER_AGENT_TOOL_DEFINITIONS = [ + { + name: "search_products", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.search_products, + executorKey: "product_search", + description: + "Search for products the shopper wants to buy. Use for product names, categories, descriptions, budget, location, or availability requests.", + parameters: { + type: "object" as const, + properties: { + query: { + type: "string", + description: "The shopper's product search text.", + }, + }, + required: ["query"], + }, + safeOutputFields: ["status", "productsShownCount", "publicProductUrls"], + }, + { + name: "refine_product_search", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.refine_product_search, + executorKey: "product_refinement", + description: + "Refine the active product results using a follow-up preference such as cheaper, similar, a colour, a budget, or a location. Use only when recent product context exists.", + parameters: { + type: "object" as const, + properties: { + refinement: { + type: "string", + description: "The shopper's follow-up refinement.", + }, + productReference: { + type: "string", + description: + "Optional result reference such as first, second, number 2, or a displayed product name.", + }, + }, + required: ["refinement"], + }, + safeOutputFields: ["status", "productsShownCount", "publicProductUrls"], + }, + { + name: "compare_products", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.compare_products, + executorKey: "product_comparison", + description: + "Compare two or three products from the active recent result set. References must point to displayed results, never arbitrary internal identifiers.", + parameters: { + type: "object" as const, + properties: { + productReferences: { + type: "array", + items: { type: "string" }, + description: + "Optional displayed result references, such as first and second or product names. Defaults to the first two active results.", + }, + }, + }, + safeOutputFields: ["status", "productsComparedCount", "publicProductUrls"], + }, + { + name: "get_cart", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.get_cart, + executorKey: "cart_read", + description: + "Read the linked shopper's current cart. Use for questions about cart contents, quantities, or cart subtotal.", + parameters: EMPTY_PARAMETERS, + safeOutputFields: [ + "status", + "itemCount", + "totalQuantity", + "subtotalDisplay", + ], + }, + { + name: "add_to_cart", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.add_to_cart, + executorKey: "cart_add", + description: + "Prepare to add a native product from the active recent result set to the linked shopper's cart. The backend must resolve the displayed product and ask for explicit confirmation before changing the cart.", + parameters: { + type: "object" as const, + properties: { + productReference: { + type: "string", + description: + "A displayed result reference such as first, second, number 2, or the product name.", + }, + quantity: { + type: "number", + description: "Whole-number quantity to add. Defaults to 1.", + }, + }, + }, + safeOutputFields: ["status", "nextStep"], + }, + { + name: "update_cart_quantity", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.update_cart_quantity, + executorKey: "cart_update", + description: + "Prepare to change the quantity of an item already in the linked shopper's cart. Resolve only against the shopper's current cart and require explicit confirmation.", + parameters: { + type: "object" as const, + properties: { + cartItemReference: { + type: "string", + description: + "A current cart item reference such as first, second, product name, or displayed product code.", + }, + quantity: { + type: "number", + description: "The new whole-number quantity.", + }, + }, + required: ["quantity"], + }, + safeOutputFields: ["status", "nextStep"], + }, + { + name: "remove_from_cart", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.remove_from_cart, + executorKey: "cart_remove", + description: + "Prepare to remove an item from the linked shopper's current cart. Resolve only against current cart contents and require explicit confirmation.", + parameters: { + type: "object" as const, + properties: { + cartItemReference: { + type: "string", + description: + "A current cart item reference such as first, second, product name, or displayed product code.", + }, + }, + }, + safeOutputFields: ["status", "nextStep"], + }, + { + name: "start_checkout", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.start_checkout, + executorKey: "checkout_handoff", + description: + "Validate and summarize the linked shopper's current cart, then require explicit confirmation before providing the secure Twizrr web checkout handoff. Never initialize payment or create an order in WhatsApp.", + parameters: EMPTY_PARAMETERS, + safeOutputFields: [ + "status", + "itemCount", + "totalQuantity", + "subtotalDisplay", + "handoffUrl", + "nextStep", + ], + }, + { + name: "get_saved_addresses", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.get_saved_addresses, + executorKey: "address_read", + description: + "Read the linked shopper's saved delivery addresses. Return only shopper-safe address labels and formatted locations.", + parameters: EMPTY_PARAMETERS, + safeOutputFields: ["status", "addressCount", "defaultAddressLabel"], + }, + { + name: "select_delivery_address", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.select_delivery_address, + executorKey: "address_select", + description: + "Prepare to select one of the linked shopper's saved delivery addresses for the current shopping session. Resolve only against saved addresses and require explicit confirmation.", + parameters: { + type: "object" as const, + properties: { + addressReference: { + type: "string", + description: + "A saved address label or ordinal such as home, work, first, or second.", + }, + }, + }, + safeOutputFields: ["status", "nextStep"], + }, + { + name: "list_orders", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.list_orders, + executorKey: "order_list", + description: + "List the linked shopper's recent orders using public order codes and shopper-safe statuses.", + parameters: EMPTY_PARAMETERS, + safeOutputFields: ["status", "ordersShownCount", "orderReferences"], + }, + { + name: "get_order_status", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.get_order_status, + executorKey: "order_status", + description: + "Read the linked shopper's order status. Use for tracking, shipment, delivery, or order status questions.", + parameters: { + type: "object" as const, + properties: { + orderReference: { + type: "string", + description: "Optional shopper-facing order code.", + }, + }, + }, + safeOutputFields: [ + "status", + "orderReference", + "orderStatus", + "deliveryStatus", + ], + }, + { + name: "get_delivery_status", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.get_delivery_status, + executorKey: "delivery_status", + description: + "Read delivery progress for one of the linked shopper's orders. Use a shopper-facing order code when supplied.", + parameters: { + type: "object" as const, + properties: { + orderReference: { + type: "string", + description: "Optional shopper-facing order code.", + }, + }, + }, + safeOutputFields: ["status", "orderReference", "deliveryStatus"], + }, + { + name: "confirm_delivery", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.confirm_delivery, + executorKey: "delivery_confirmation", + description: + "Start the linked shopper's explicit delivery-confirmation flow. Never confirm delivery without the scoped confirmation step.", + parameters: { + type: "object" as const, + properties: { + orderReference: { + type: "string", + description: "Optional shopper-facing order code.", + }, + }, + }, + safeOutputFields: [ + "status", + "ordersShownCount", + "orderReference", + "nextStep", + ], + }, + { + name: "start_dispute", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.start_dispute, + executorKey: "dispute_open", + description: + "Prepare to open a dispute for an eligible order owned by the linked shopper. Include the shopper's reason and description when provided. The backend must verify eligibility and require explicit confirmation before opening the dispute.", + parameters: { + type: "object" as const, + properties: { + orderReference: { + type: "string", + description: "Optional shopper-facing order code.", + }, + reason: { + type: "string", + description: + "A short factual reason for the dispute, based only on what the shopper reported.", + }, + description: { + type: "string", + description: + "The shopper's factual description of the order problem.", + }, + requestedOutcome: { + type: "string", + description: + "Optional outcome requested by the shopper, such as a refund or replacement.", + }, + }, + }, + safeOutputFields: ["status", "orderReference", "nextStep"], + }, + { + name: "get_dispute_status", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.get_dispute_status, + executorKey: "dispute_status", + description: + "Read the current dispute status for an order owned by the linked shopper. Use only shopper-facing order codes and safe status text.", + parameters: { + type: "object" as const, + properties: { + orderReference: { + type: "string", + description: "Optional shopper-facing order code.", + }, + }, + }, + safeOutputFields: ["status", "orderReference", "disputeStatus"], + }, + { + name: "get_refund_status", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.get_refund_status, + executorKey: "refund_status", + description: + "Read the real refund settlement status for a disputed order owned by the linked shopper. Never promise a refund or expose provider references.", + parameters: { + type: "object" as const, + properties: { + orderReference: { + type: "string", + description: "Optional shopper-facing order code.", + }, + }, + }, + safeOutputFields: [ + "status", + "orderReference", + "refundStatus", + "refundAmountDisplay", + ], + }, + { + name: "show_menu", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.show_menu, + executorKey: "menu", + description: + "Show the shopper assistant menu when the shopper asks for help or available actions.", + parameters: EMPTY_PARAMETERS, + safeOutputFields: ["status"], + }, + { + name: "support_handoff", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.support_handoff, + executorKey: "support", + description: + "Give deterministic Twizrr support guidance. Do not claim live handoff or immediate agent availability.", + parameters: EMPTY_PARAMETERS, + safeOutputFields: ["status"], + }, + { + name: "store_management_redirect", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.store_management_redirect, + executorKey: "store_redirect", + description: + "Redirect store-management requests to the Twizrr web store workspace because WIZZA is shopper-only.", + parameters: EMPTY_PARAMETERS, + safeOutputFields: ["status"], + }, +] as const satisfies readonly AnyShopperAgentToolDefinition[]; + +export const SHOPPER_AGENT_GEMINI_DECLARATIONS: GeminiFunctionDeclaration[] = + SHOPPER_AGENT_TOOL_DEFINITIONS.map((definition) => ({ + name: definition.name, + description: definition.description, + parameters: definition.parameters, + })); + +const FORBIDDEN_SAFE_OUTPUT_FIELDS = new Set([ + "physicalStoreId", + "physicalProductId", + "dropshipperCostKobo", + "digitalMarginKobo", + "linkedOrderId", + "sourceCode", + "supplierId", + "supplierStoreId", + "embedding", + "vector", + "rankingScore", +]); + +@Injectable() +export class ShopperAgentToolRegistry { + createRequest( + name: string, + params: Record | undefined, + fallbackMessage: string, + ): ShopperAgentToolRequest | null { + const definition = SHOPPER_AGENT_TOOL_DEFINITIONS.find( + (candidate) => candidate.name === name, + ); + + if (!definition) { + return null; + } + + const input = this.parseInput( + definition.name, + params ?? {}, + fallbackMessage, + ); + + if (!input) { + return null; + } + + return { + name: definition.name, + category: definition.category, + input, + } as ShopperAgentToolRequest; + } + + assertSafeOutput( + name: Name, + output: unknown, + ): asserts output is ShopperAgentToolOutputMap[Name] { + if (!this.isRecord(output)) { + throw new Error(`Unsafe ${name} tool output`); + } + + this.assertNoForbiddenFields(output); + const definition = SHOPPER_AGENT_TOOL_DEFINITIONS.find( + (candidate) => candidate.name === name, + ); + const allowedFields = new Set( + definition?.safeOutputFields.map(String) ?? [], + ); + + for (const key of Object.keys(output)) { + if (!allowedFields.has(key)) { + throw new Error(`Unsafe ${name} tool output field: ${key}`); + } + } + + this.assertOutputShape(name, output); + } + + private parseInput( + name: Name, + params: Record, + fallbackMessage: string, + ): ShopperAgentToolInputMap[Name] | null { + switch (name) { + case "search_products": { + const query = + this.optionalString(params.query) || fallbackMessage.trim(); + return (query ? { query } : null) as + | ShopperAgentToolInputMap[Name] + | null; + } + case "refine_product_search": { + const refinement = + this.optionalString(params.refinement) || fallbackMessage.trim(); + const productReference = this.optionalString(params.productReference); + return ( + refinement + ? { + refinement, + ...(productReference ? { productReference } : {}), + } + : null + ) as ShopperAgentToolInputMap[Name] | null; + } + case "compare_products": { + const productReferences = this.optionalStringArray( + params.productReferences, + ); + return ( + productReferences?.length ? { productReferences } : {} + ) as ShopperAgentToolInputMap[Name]; + } + case "get_cart": + case "start_checkout": + case "get_saved_addresses": + case "list_orders": + return {} as ShopperAgentToolInputMap[Name]; + case "add_to_cart": { + const productReference = this.optionalString(params.productReference); + const quantity = + params.quantity === undefined + ? 1 + : this.optionalPositiveInteger(params.quantity); + if (!quantity) { + return null; + } + return { + ...(productReference ? { productReference } : {}), + quantity, + } as ShopperAgentToolInputMap[Name]; + } + case "update_cart_quantity": { + const quantity = this.optionalPositiveInteger(params.quantity); + if (!quantity) { + return null; + } + const cartItemReference = this.optionalString(params.cartItemReference); + return { + ...(cartItemReference ? { cartItemReference } : {}), + quantity, + } as ShopperAgentToolInputMap[Name]; + } + case "remove_from_cart": { + const cartItemReference = this.optionalString(params.cartItemReference); + return ( + cartItemReference ? { cartItemReference } : {} + ) as ShopperAgentToolInputMap[Name]; + } + case "select_delivery_address": { + const addressReference = this.optionalString(params.addressReference); + return ( + addressReference ? { addressReference } : {} + ) as ShopperAgentToolInputMap[Name]; + } + case "get_order_status": + case "get_delivery_status": + case "confirm_delivery": + case "get_dispute_status": + case "get_refund_status": { + const orderReference = this.optionalString(params.orderReference); + return ( + orderReference ? { orderReference } : {} + ) as ShopperAgentToolInputMap[Name]; + } + case "start_dispute": { + const orderReference = this.optionalString(params.orderReference); + const reason = this.optionalString(params.reason); + const description = this.optionalString(params.description); + const requestedOutcome = this.optionalString(params.requestedOutcome); + return { + ...(orderReference ? { orderReference } : {}), + ...(reason ? { reason } : {}), + ...(description ? { description } : {}), + ...(requestedOutcome ? { requestedOutcome } : {}), + } as ShopperAgentToolInputMap[Name]; + } + case "show_menu": + case "support_handoff": + case "store_management_redirect": + return {} as ShopperAgentToolInputMap[Name]; + } + } + + private optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; + } + + private optionalStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const strings = value + .map((item) => this.optionalString(item)) + .filter((item): item is string => Boolean(item)) + .slice(0, 3); + return strings.length ? strings : undefined; + } + + private optionalPositiveInteger(value: unknown): number | undefined { + return isWhatsAppCartQuantity(value) ? value : undefined; + } + + private assertOutputShape( + name: ShopperAgentToolName, + output: Record, + ): void { + switch (name) { + case "search_products": + case "refine_product_search": + this.assertStatus( + name, + output.status, + name === "refine_product_search" + ? ["sent", "no_results", "no_context"] + : ["sent", "no_results"], + ); + if ( + typeof output.productsShownCount !== "number" || + !Number.isInteger(output.productsShownCount) || + output.productsShownCount < 0 + ) { + throw new Error(`Unsafe ${name} tool output productsShownCount`); + } + if ( + output.publicProductUrls !== undefined && + (!Array.isArray(output.publicProductUrls) || + !output.publicProductUrls.every( + (url) => typeof url === "string" && Boolean(url.trim()), + )) + ) { + throw new Error(`Unsafe ${name} tool output publicProductUrls`); + } + return; + case "compare_products": + this.assertStatus(name, output.status, [ + "sent", + "no_context", + "not_enough_products", + ]); + if ( + typeof output.productsComparedCount !== "number" || + !Number.isInteger(output.productsComparedCount) || + output.productsComparedCount < 0 + ) { + throw new Error(`Unsafe ${name} tool output productsComparedCount`); + } + if ( + output.publicProductUrls !== undefined && + (!Array.isArray(output.publicProductUrls) || + !output.publicProductUrls.every( + (url) => typeof url === "string" && Boolean(url.trim()), + )) + ) { + throw new Error(`Unsafe ${name} tool output publicProductUrls`); + } + return; + case "get_cart": + this.assertStatus(name, output.status, ["available", "empty"]); + this.assertNonNegativeIntegers(name, output, [ + "itemCount", + "totalQuantity", + ]); + this.assertOptionalStrings(name, output, ["subtotalDisplay"]); + return; + case "add_to_cart": + this.assertStatus(name, output.status, [ + "confirmation_required", + "no_context", + "unsupported", + "temporarily_unavailable", + ]); + this.assertOptionalStrings(name, output, ["nextStep"]); + return; + case "update_cart_quantity": + case "remove_from_cart": + case "select_delivery_address": + this.assertStatus(name, output.status, [ + "confirmation_required", + "empty", + "not_found", + "temporarily_unavailable", + ]); + this.assertOptionalStrings(name, output, ["nextStep"]); + return; + case "start_checkout": + this.assertStatus(name, output.status, [ + "confirmation_required", + "empty", + "unavailable", + "temporarily_unavailable", + ]); + this.assertOptionalNonNegativeIntegers(name, output, [ + "itemCount", + "totalQuantity", + ]); + this.assertOptionalStrings(name, output, [ + "subtotalDisplay", + "handoffUrl", + "nextStep", + ]); + return; + case "get_saved_addresses": + this.assertStatus(name, output.status, ["available", "empty"]); + this.assertNonNegativeIntegers(name, output, ["addressCount"]); + this.assertOptionalStrings(name, output, ["defaultAddressLabel"]); + return; + case "list_orders": + this.assertStatus(name, output.status, ["available", "empty"]); + this.assertNonNegativeIntegers(name, output, ["ordersShownCount"]); + this.assertOptionalStringArray(name, output, "orderReferences"); + return; + case "get_order_status": + this.assertStatus(name, output.status, ["available", "not_found"]); + this.assertOptionalStrings(name, output, [ + "orderReference", + "orderStatus", + "deliveryStatus", + ]); + return; + case "get_delivery_status": + this.assertStatus(name, output.status, ["available", "not_found"]); + this.assertOptionalStrings(name, output, [ + "orderReference", + "deliveryStatus", + ]); + return; + case "confirm_delivery": + this.assertStatus(name, output.status, [ + "selection_required", + "code_required", + "no_eligible_order", + "not_found", + "temporarily_unavailable", + ]); + this.assertOptionalNonNegativeIntegers(name, output, [ + "ordersShownCount", + ]); + this.assertOptionalStrings(name, output, [ + "orderReference", + "nextStep", + ]); + return; + case "start_dispute": + this.assertStatus(name, output.status, [ + "confirmation_required", + "details_required", + "not_found", + "not_eligible", + "existing_dispute", + "support_required", + "temporarily_unavailable", + ]); + this.assertOptionalStrings(name, output, [ + "orderReference", + "nextStep", + ]); + return; + case "get_dispute_status": + this.assertStatus(name, output.status, [ + "available", + "none", + "not_found", + ]); + this.assertOptionalStrings(name, output, [ + "orderReference", + "disputeStatus", + ]); + return; + case "get_refund_status": + this.assertStatus(name, output.status, [ + "available", + "none", + "not_found", + ]); + this.assertOptionalStrings(name, output, [ + "orderReference", + "refundStatus", + "refundAmountDisplay", + ]); + return; + case "show_menu": + case "support_handoff": + case "store_management_redirect": + this.assertStatus(name, output.status, ["sent"]); + } + } + + private assertStatus( + name: ShopperAgentToolName, + status: unknown, + allowedStatuses: readonly string[], + ): void { + if (typeof status !== "string" || !allowedStatuses.includes(status)) { + throw new Error(`Unsafe ${name} tool output status`); + } + } + + private assertOptionalStrings( + name: ShopperAgentToolName, + output: Record, + fields: readonly string[], + ): void { + for (const field of fields) { + const value = output[field]; + if (value !== undefined && (typeof value !== "string" || !value.trim())) { + throw new Error(`Unsafe ${name} tool output ${field}`); + } + } + } + + private assertNonNegativeIntegers( + name: ShopperAgentToolName, + output: Record, + fields: readonly string[], + ): void { + for (const field of fields) { + const value = output[field]; + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { + throw new Error(`Unsafe ${name} tool output ${field}`); + } + } + } + + private assertOptionalNonNegativeIntegers( + name: ShopperAgentToolName, + output: Record, + fields: readonly string[], + ): void { + for (const field of fields) { + const value = output[field]; + if ( + value !== undefined && + (typeof value !== "number" || !Number.isInteger(value) || value < 0) + ) { + throw new Error(`Unsafe ${name} tool output ${field}`); + } + } + } + + private assertOptionalStringArray( + name: ShopperAgentToolName, + output: Record, + field: string, + ): void { + const value = output[field]; + if ( + value !== undefined && + (!Array.isArray(value) || + !value.every( + (item) => typeof item === "string" && Boolean(item.trim()), + )) + ) { + throw new Error(`Unsafe ${name} tool output ${field}`); + } + } + + private assertNoForbiddenFields(value: unknown): void { + if (Array.isArray(value)) { + value.forEach((item) => this.assertNoForbiddenFields(item)); + return; + } + + if (!this.isRecord(value)) { + return; + } + + for (const [key, nestedValue] of Object.entries(value)) { + if (FORBIDDEN_SAFE_OUTPUT_FIELDS.has(key)) { + throw new Error(`Unsafe shopper-agent output field: ${key}`); + } + this.assertNoForbiddenFields(nestedValue); + } + } + + private isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); + } +} diff --git a/apps/backend/src/channels/whatsapp/agent/shopper-agent.types.ts b/apps/backend/src/channels/whatsapp/agent/shopper-agent.types.ts new file mode 100644 index 00000000..abeb26fa --- /dev/null +++ b/apps/backend/src/channels/whatsapp/agent/shopper-agent.types.ts @@ -0,0 +1,270 @@ +export type ShopperAgentActionCategory = + | "guest" + | "linked-read" + | "linked-write" + | "confirmation-required"; + +export type ShopperAgentToolName = + | "search_products" + | "refine_product_search" + | "compare_products" + | "get_cart" + | "add_to_cart" + | "update_cart_quantity" + | "remove_from_cart" + | "start_checkout" + | "get_saved_addresses" + | "select_delivery_address" + | "list_orders" + | "get_order_status" + | "get_delivery_status" + | "confirm_delivery" + | "start_dispute" + | "get_dispute_status" + | "get_refund_status" + | "show_menu" + | "support_handoff" + | "store_management_redirect"; + +export const SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME = { + search_products: "guest", + refine_product_search: "guest", + compare_products: "guest", + get_cart: "linked-read", + add_to_cart: "confirmation-required", + update_cart_quantity: "confirmation-required", + remove_from_cart: "confirmation-required", + start_checkout: "confirmation-required", + get_saved_addresses: "linked-read", + select_delivery_address: "confirmation-required", + list_orders: "linked-read", + get_order_status: "linked-read", + get_delivery_status: "linked-read", + confirm_delivery: "confirmation-required", + start_dispute: "confirmation-required", + get_dispute_status: "linked-read", + get_refund_status: "linked-read", + show_menu: "guest", + support_handoff: "guest", + store_management_redirect: "guest", +} as const satisfies Record; + +export type ShopperAgentToolCategoryMap = + typeof SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME; + +export type ShopperAgentConversationName = + | "natural_answer" + | "friendly_fallback"; + +export interface ShopperAgentToolInputMap { + search_products: { query: string }; + refine_product_search: { + refinement: string; + productReference?: string; + }; + compare_products: { productReferences?: string[] }; + get_cart: Record; + add_to_cart: { productReference?: string; quantity: number }; + update_cart_quantity: { cartItemReference?: string; quantity: number }; + remove_from_cart: { cartItemReference?: string }; + start_checkout: Record; + get_saved_addresses: Record; + select_delivery_address: { addressReference?: string }; + list_orders: Record; + get_order_status: { orderReference?: string }; + get_delivery_status: { orderReference?: string }; + confirm_delivery: { orderReference?: string }; + start_dispute: { + orderReference?: string; + reason?: string; + description?: string; + requestedOutcome?: string; + }; + get_dispute_status: { orderReference?: string }; + get_refund_status: { orderReference?: string }; + show_menu: Record; + support_handoff: Record; + store_management_redirect: Record; +} + +export interface ShopperAgentToolOutputMap { + search_products: { + status: "sent" | "no_results"; + productsShownCount: number; + publicProductUrls?: string[]; + }; + refine_product_search: { + status: "sent" | "no_results" | "no_context"; + productsShownCount: number; + publicProductUrls?: string[]; + }; + compare_products: { + status: "sent" | "no_context" | "not_enough_products"; + productsComparedCount: number; + publicProductUrls?: string[]; + }; + get_cart: { + status: "available" | "empty"; + itemCount: number; + totalQuantity: number; + subtotalDisplay?: string; + }; + add_to_cart: { + status: + | "confirmation_required" + | "no_context" + | "unsupported" + | "temporarily_unavailable"; + nextStep?: string; + }; + update_cart_quantity: { + status: + | "confirmation_required" + | "empty" + | "not_found" + | "temporarily_unavailable"; + nextStep?: string; + }; + remove_from_cart: { + status: + | "confirmation_required" + | "empty" + | "not_found" + | "temporarily_unavailable"; + nextStep?: string; + }; + start_checkout: { + status: + | "confirmation_required" + | "empty" + | "unavailable" + | "temporarily_unavailable"; + itemCount?: number; + totalQuantity?: number; + subtotalDisplay?: string; + handoffUrl?: string; + nextStep?: string; + }; + get_saved_addresses: { + status: "available" | "empty"; + addressCount: number; + defaultAddressLabel?: string; + }; + select_delivery_address: { + status: + | "confirmation_required" + | "empty" + | "not_found" + | "temporarily_unavailable"; + nextStep?: string; + }; + list_orders: { + status: "available" | "empty"; + ordersShownCount: number; + orderReferences?: string[]; + }; + get_order_status: { + status: "available" | "not_found"; + orderReference?: string; + orderStatus?: string; + deliveryStatus?: string; + }; + get_delivery_status: { + status: "available" | "not_found"; + orderReference?: string; + deliveryStatus?: string; + }; + confirm_delivery: { + status: + | "selection_required" + | "code_required" + | "no_eligible_order" + | "not_found" + | "temporarily_unavailable"; + ordersShownCount?: number; + orderReference?: string; + nextStep?: string; + }; + start_dispute: { + status: + | "confirmation_required" + | "details_required" + | "not_found" + | "not_eligible" + | "existing_dispute" + | "support_required" + | "temporarily_unavailable"; + orderReference?: string; + nextStep?: string; + }; + get_dispute_status: { + status: "available" | "none" | "not_found"; + orderReference?: string; + disputeStatus?: string; + }; + get_refund_status: { + status: "available" | "none" | "not_found"; + orderReference?: string; + refundStatus?: string; + refundAmountDisplay?: string; + }; + show_menu: { status: "sent" }; + support_handoff: { status: "sent" }; + store_management_redirect: { status: "sent" }; +} + +export type ShopperAgentToolRequest = { + [Name in ShopperAgentToolName]: { + name: Name; + category: ShopperAgentToolCategoryMap[Name]; + input: ShopperAgentToolInputMap[Name]; + }; +}[ShopperAgentToolName]; + +export type ShopperAgentPolicyReason = + | "allowed" + | "consent_required" + | "linked_account_required"; + +export interface ShopperAgentPolicyDecision { + allowed: boolean; + reason: ShopperAgentPolicyReason; + confirmationRequired: boolean; +} + +export interface ShopperAgentAuditEvent { + event: "shopper_agent_plan"; + actionName: ShopperAgentToolName | ShopperAgentConversationName; + actionCategory: ShopperAgentActionCategory | "conversation"; + policyDecision: "allowed" | "denied"; + policyReason: ShopperAgentPolicyReason; +} + +export interface ShopperAgentToolPlan { + kind: "tool"; + functionName: ShopperAgentToolName; + params: Record; + tool: ShopperAgentToolRequest; + policy: ShopperAgentPolicyDecision; + audit: ShopperAgentAuditEvent; +} + +export interface ShopperAgentConversationPlan { + kind: "conversation"; + functionName: ShopperAgentConversationName; + params: Record; + policy: ShopperAgentPolicyDecision; + audit: ShopperAgentAuditEvent; +} + +export type ShopperAgentPlan = + | ShopperAgentToolPlan + | ShopperAgentConversationPlan; + +export interface ShopperAgentPlanInput { + functionName: string; + params?: Record; + messageText: string; + linkedUserId: string | null; + consentGiven: boolean; +} diff --git a/apps/backend/src/channels/whatsapp/image-search.service.spec.ts b/apps/backend/src/channels/whatsapp/image-search.service.spec.ts new file mode 100644 index 00000000..3f889198 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/image-search.service.spec.ts @@ -0,0 +1,945 @@ +import { + ModerationStatus, + Prisma, + ProductStatus, + StoreType, + StoreTier, +} from "@prisma/client"; +import { ImageSearchService } from "./image-search.service"; +import { TaxonomyDiscoveryService } from "../../domains/commerce/search/taxonomy-discovery.service"; + +const expectShopperSafeOutput = (value: unknown) => { + // Mirror the runtime BigInt-to-string serialization (set in main.ts) so + // product records carrying BigInt kobo prices can be scanned for leaks. + const serialized = JSON.stringify(value, (_key, val) => + typeof val === "bigint" ? val.toString() : val, + ); + + expect(serialized).not.toMatch( + /physicalStoreId|physicalProductId|dropshipperCostKobo|digitalMarginKobo|linkedOrderId|sourceCode|embeddingProductId|similarity|score|vector|embedding|base64|image-id/i, + ); + expect(serialized.toLowerCase()).not.toMatch( + /supplier|source store|dropship|partner store|physical store/, + ); +}; + +describe("ImageSearchService", () => { + const prisma = { + product: { + findMany: jest.fn(), + }, + sourcedProduct: { + findMany: jest.fn(), + }, + $queryRaw: jest.fn(), + }; + const interactiveService = { + sendTextMessage: jest.fn(), + sendListMessage: jest.fn(), + sendReplyButtons: jest.fn(), + }; + const metaWhatsAppClient = { + downloadImage: jest.fn(), + }; + const visionClient = { + isConfigured: jest.fn(() => true), + checkSafetyFromBase64: jest.fn(), + detectProductTerms: jest.fn(), + }; + const safeSearchResult = ( + overrides: Partial> = {}, + ) => ({ + adult: "VERY_UNLIKELY", + violence: "VERY_UNLIKELY", + racy: "VERY_UNLIKELY", + ...overrides, + }); + const productDiscoveryService = { + clearDiscoverySelectionState: jest.fn(), + storeRecentSearchResults: jest.fn(), + clearRecentSearchResults: jest.fn(), + sendCanonicalProductLinks: jest.fn(), + toSourcedProductListRecord: jest.fn( + (product: { + id: string; + productCode: string; + physicalProductId?: string; + customTitle?: string | null; + sellingPriceKobo: bigint | number; + createdAt?: Date; + digitalStore?: unknown; + physicalProduct: { name: string; title?: string | null }; + }) => ({ + id: product.id, + listingType: "SOURCED", + productCode: product.productCode, + name: product.customTitle ?? product.physicalProduct.name, + title: product.customTitle ?? product.physicalProduct.title, + retailPriceKobo: product.sellingPriceKobo, + pricePerUnitKobo: product.sellingPriceKobo, + createdAt: product.createdAt, + embeddingProductId: product.physicalProductId, + storeProfile: product.digitalStore, + }), + ), + }; + const vertexClient = { + isConfigured: jest.fn(() => false), + generateImageEmbedding: jest.fn(), + getModelName: jest.fn(() => "multimodalembedding@001"), + }; + + let service: ImageSearchService; + + beforeEach(() => { + jest.clearAllMocks(); + vertexClient.isConfigured.mockReturnValue(false); + vertexClient.getModelName.mockReturnValue("multimodalembedding@001"); + visionClient.isConfigured.mockReturnValue(true); + visionClient.checkSafetyFromBase64.mockResolvedValue(safeSearchResult()); + prisma.sourcedProduct.findMany.mockResolvedValue([]); + service = new ImageSearchService( + prisma as any, + interactiveService as any, + metaWhatsAppClient as any, + visionClient as any, + productDiscoveryService as any, + vertexClient as any, + new TaxonomyDiscoveryService(), + ); + }); + + it("clears stale text-result selection before an image discovery that cannot download", async () => { + metaWhatsAppClient.downloadImage.mockResolvedValue(null); + + await service.handleImageSearch("2348012345678", "image-1"); + + expect( + productDiscoveryService.clearDiscoverySelectionState, + ).toHaveBeenCalledWith("2348012345678"); + expect( + productDiscoveryService.storeRecentSearchResults, + ).not.toHaveBeenCalled(); + }); + + it("uses Cloud Vision terms with public-safe product filters and product codes", async () => { + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + visionClient.detectProductTerms.mockResolvedValue(["sneakers"]); + prisma.product.findMany.mockResolvedValue([ + { + id: "internal-product-id", + productCode: "TWZ-SNK-001", + name: "White Sneakers", + retailPriceKobo: 1500000n, + pricePerUnitKobo: null, + storeProfile: { + businessName: "Sneaker Store Legal Ltd", + storeHandle: "sneakerstore", + storeName: "Sneaker Store NG", + }, + }, + ]); + + await service.handleImageSearch("+2348012345678", "image-id"); + + expect( + productDiscoveryService.clearDiscoverySelectionState, + ).toHaveBeenCalledWith("+2348012345678"); + expect(visionClient.detectProductTerms).toHaveBeenCalledWith( + "base64-image", + ); + expect(prisma.product.findMany).toHaveBeenCalledWith({ + where: { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + productCode: { not: null }, + moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true }, + OR: [ + { + name: { + contains: "sneakers", + mode: Prisma.QueryMode.insensitive, + }, + }, + ], + }, + include: { + storeProfile: { + select: { + storeHandle: true, + storeName: true, + }, + }, + }, + take: 20, + orderBy: { createdAt: "desc" }, + }); + expect(interactiveService.sendListMessage).toHaveBeenLastCalledWith( + "+2348012345678", + 'Matching products for "sneakers":', + "View Products", + [ + { + title: "Matches", + rows: [ + { + id: "product_result_1", + title: "White Sneakers", + description: "NGN 15,000 | Sold by Sneaker Store NG", + }, + ], + }, + ], + ); + const outboundText = [ + ...interactiveService.sendListMessage.mock.calls, + ...interactiveService.sendTextMessage.mock.calls, + ] + .map((call) => JSON.stringify(call.slice(1))) + .join("\n"); + expect(outboundText).not.toContain("Sneaker Store Legal Ltd"); + expect( + productDiscoveryService.storeRecentSearchResults, + ).toHaveBeenCalledWith( + "+2348012345678", + [ + expect.objectContaining({ + id: "internal-product-id", + productCode: "TWZ-SNK-001", + }), + ], + { + lastQuery: "sneakers", + source: "image", + }, + ); + expect( + productDiscoveryService.sendCanonicalProductLinks, + ).toHaveBeenCalledWith("+2348012345678", [ + expect.objectContaining({ productCode: "TWZ-SNK-001" }), + ]); + }); + + it("falls back to storeHandle then twizrr store for missing public store names", async () => { + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + visionClient.detectProductTerms.mockResolvedValue(["sneakers"]); + prisma.product.findMany.mockResolvedValue([ + { + id: "product-1", + productCode: "TWZ-SNK-001", + name: "White Sneakers", + retailPriceKobo: 1500000n, + pricePerUnitKobo: null, + storeProfile: { + businessName: "Sneaker Store Legal Ltd", + storeHandle: "sneakerstore", + storeName: null, + }, + }, + { + id: "product-2", + productCode: "TWZ-SNK-002", + name: "Black Sneakers", + retailPriceKobo: 1800000n, + pricePerUnitKobo: null, + storeProfile: { + businessName: "Sneaker Store Legal Ltd", + storeHandle: null, + storeName: null, + }, + }, + ]); + + await service.handleImageSearch("+2348012345678", "image-id"); + + const rows = interactiveService.sendListMessage.mock.calls[0][3][0].rows; + expect(rows[0].description).toBe("NGN 15,000 | Sold by sneakerstore"); + expect(rows[1].description).toBe("NGN 18,000 | Sold by twizrr store"); + expect(JSON.stringify(rows)).not.toContain("Sneaker Store Legal Ltd"); + }); + + it("does not add markdown emphasis to fallback image-search copy", async () => { + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + visionClient.detectProductTerms.mockResolvedValue(["bag"]); + prisma.product.findMany.mockResolvedValue([]); + + await service.handleImageSearch("+2348012345678", "image-id"); + + expect(interactiveService.sendReplyButtons).toHaveBeenCalledWith( + "+2348012345678", + expect.not.stringContaining("*bag*"), + [ + { id: "browse_categories", title: "Browse Categories" }, + { id: "search_products", title: "Try Text Search" }, + ], + ); + }); + + it("does not mark image download failures as zero-result searches", async () => { + metaWhatsAppClient.downloadImage.mockResolvedValue(null); + + await expect( + service.handleImageSearch("+2348012345678", "image-id"), + ).resolves.toMatchObject({ + zeroResults: false, + intentSuccessful: false, + errorType: "IMAGE_DOWNLOAD_FAILED", + vectorSearchUsed: false, + embeddingModel: null, + }); + expect( + productDiscoveryService.storeRecentSearchResults, + ).not.toHaveBeenCalled(); + }); + + it("widens image vector search when locality has no direct match", async () => { + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + visionClient.detectProductTerms.mockResolvedValue(["sneakers"]); + vertexClient.isConfigured.mockReturnValue(true); + vertexClient.generateImageEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + prisma.$queryRaw.mockResolvedValue([ + { productId: "product-1", similarity: 0.9 }, + ]); + prisma.product.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([ + { + id: "product-1", + productCode: "TWZ-SNK-001", + name: "White Sneakers", + retailPriceKobo: 1500000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "sneakerstore", + storeName: "Sneaker Store NG", + }, + }, + ]); + + const analytics = await service.handleImageSearch( + "+2348012345678", + "image-id", + { + locationText: "Yaba", + source: "user_preference", + countryCode: "NG", + state: "Lagos", + city: "Lagos", + area: "Yaba", + }, + ); + + expect(prisma.$queryRaw).toHaveBeenCalledTimes(2); + expect(prisma.product.findMany.mock.calls[0][0].where.storeProfile).toEqual( + expect.objectContaining({ + businessAddress: { + contains: "Yaba", + mode: Prisma.QueryMode.insensitive, + }, + }), + ); + expect( + prisma.product.findMany.mock.calls[0][0].where.productStockCaches, + ).toEqual({ some: { stock: { gt: 0 } } }); + expect(prisma.product.findMany.mock.calls[1][0].where.storeProfile).toEqual( + { tier: { not: StoreTier.TIER_0 }, isOpen: true }, + ); + expect( + prisma.product.findMany.mock.calls[1][0].where.productStockCaches, + ).toEqual({ some: { stock: { gt: 0 } } }); + expect(analytics).toMatchObject({ + productsShown: ["product-1"], + zeroResults: false, + vectorSearchUsed: true, + }); + expect(interactiveService.sendListMessage).toHaveBeenCalledWith( + "+2348012345678", + "I could not find strong matches in that area, so I widened the search.", + "View Products", + expect.any(Array), + ); + }); + it("uses image embedding similarity as the primary signal when available", async () => { + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + visionClient.detectProductTerms.mockResolvedValue(["sneakers"]); + vertexClient.isConfigured.mockReturnValue(true); + vertexClient.generateImageEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + prisma.$queryRaw.mockResolvedValue([ + { productId: "product-1", similarity: 0.4 }, + { productId: "product-2", similarity: 0.9 }, + ]); + prisma.product.findMany.mockResolvedValue([ + { + id: "product-1", + productCode: "TWZ-SNK-001", + name: "White Sneakers", + retailPriceKobo: 1500000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "sneakerstore", + storeName: "Sneaker Store NG", + }, + }, + { + id: "product-2", + productCode: "TWZ-SNK-002", + name: "Black Sneakers", + retailPriceKobo: 1800000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "sneakerstore", + storeName: "Sneaker Store NG", + }, + }, + ]); + + const analytics = await service.handleImageSearch( + "+2348012345678", + "image-id", + ); + + expect(vertexClient.generateImageEmbedding).toHaveBeenCalledWith( + "base64-image", + "sneakers", + ); + expect(prisma.$queryRaw).toHaveBeenCalledTimes(1); + expect(prisma.product.findMany).toHaveBeenCalledTimes(1); + expect(prisma.product.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: { in: ["product-1", "product-2"] }, + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + productCode: { not: null }, + moderationStatus: { not: ModerationStatus.BLOCKED }, + storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true }, + }), + }), + ); + + // Higher similarity ranks first even though it was returned second. + const storedProducts = + productDiscoveryService.storeRecentSearchResults.mock.calls[0][1]; + expect(storedProducts.map((product: { id: string }) => product.id)).toEqual( + ["product-2", "product-1"], + ); + expect(analytics).toMatchObject({ + searchQuery: "sneakers", + productsShown: ["product-2", "product-1"], + productsRankedCount: 2, + productsShownCount: 2, + zeroResults: false, + intentSuccessful: true, + vectorSearchUsed: true, + embeddingModel: "multimodalembedding@001", + }); + + // Scan only shopper-facing message calls. sendCanonicalProductLinks + // receives internal listing records (which legitimately carry ranking + // metadata like embeddingProductId) and builds safe URLs internally, so it + // is excluded here — matching the sourced-listing test's scan scope. + const outbound = [ + ...interactiveService.sendListMessage.mock.calls, + ...interactiveService.sendTextMessage.mock.calls, + ...interactiveService.sendReplyButtons.mock.calls, + ].map((call) => call.slice(1)); + expectShopperSafeOutput(outbound); + }); + + it("returns sourced listings under the digital store with safe public fields only", async () => { + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + visionClient.detectProductTerms.mockResolvedValue(["sneakers"]); + vertexClient.isConfigured.mockReturnValue(true); + vertexClient.generateImageEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + prisma.$queryRaw.mockResolvedValue([ + { productId: "physical-product-1", similarity: 0.95 }, + ]); + prisma.product.findMany.mockResolvedValue([]); + prisma.sourcedProduct.findMany.mockResolvedValue([ + { + id: "sourced-1", + productCode: "TWZ-839201", + customTitle: "Curated Sneakers", + sellingPriceKobo: 1900000n, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + physicalStoreId: "physical-store-1", + physicalProductId: "physical-product-1", + listingCode: "SRC-483729", + digitalStore: { + storeHandle: "gadgetplug", + storeName: "Gadget Plug", + }, + physicalProduct: { + name: "Wholesale Sneakers", + title: "Wholesale Sneakers", + retailPriceKobo: 1500000n, + }, + }, + ]); + + const analytics = await service.handleImageSearch( + "+2348012345678", + "image-id", + ); + + expect(prisma.sourcedProduct.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + physicalProductId: { in: ["physical-product-1"] }, + isActive: true, + digitalStore: expect.objectContaining({ + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + storeType: StoreType.DIGITAL, + }), + physicalProduct: expect.objectContaining({ + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + allowDropship: true, + moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { + storeType: StoreType.PHYSICAL, + isOpen: true, + allowDropship: true, + }, + }), + }), + }), + ); + + expect(analytics).toMatchObject({ + productsShown: ["sourced-1"], + productsShownCount: 1, + zeroResults: false, + vectorSearchUsed: true, + embeddingModel: "multimodalembedding@001", + }); + + const rows = interactiveService.sendListMessage.mock.calls[0][3][0].rows; + expect(rows[0].title).toBe("Curated Sneakers"); + expect(rows[0].description).toBe("NGN 19,000 | Sold by Gadget Plug"); + + expect( + productDiscoveryService.sendCanonicalProductLinks, + ).toHaveBeenCalledWith("+2348012345678", [ + expect.objectContaining({ + productCode: "TWZ-839201", + listingType: "SOURCED", + storeProfile: expect.objectContaining({ storeHandle: "gadgetplug" }), + }), + ]); + + const allOutbound = [ + ...interactiveService.sendListMessage.mock.calls, + ...interactiveService.sendTextMessage.mock.calls, + ...interactiveService.sendReplyButtons.mock.calls, + ] + .map((call) => JSON.stringify(call.slice(1))) + .join("\n"); + expect(allOutbound).not.toContain("SRC-483729"); + expect(allOutbound).not.toContain("sourced-1"); + expect(allOutbound).not.toContain("physical-product-1"); + expect(allOutbound).not.toContain("physical-store-1"); + expect(allOutbound.toLowerCase()).not.toMatch( + /supplier|source store|dropship|partner store|physical store|wholesale/, + ); + expectShopperSafeOutput([ + ...interactiveService.sendListMessage.mock.calls.map((call) => + call.slice(1), + ), + ...interactiveService.sendTextMessage.mock.calls.map((call) => + call.slice(1), + ), + ...interactiveService.sendReplyButtons.mock.calls.map((call) => + call.slice(1), + ), + ]); + }); + + it("falls back to label search when image embedding fails", async () => { + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + visionClient.detectProductTerms.mockResolvedValue(["sneakers"]); + vertexClient.isConfigured.mockReturnValue(true); + vertexClient.generateImageEmbedding.mockRejectedValue( + new Error("provider down"), + ); + prisma.product.findMany.mockResolvedValue([ + { + id: "product-1", + productCode: "TWZ-SNK-001", + name: "White Sneakers", + retailPriceKobo: 1500000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "sneakerstore", + storeName: "Sneaker Store NG", + }, + }, + ]); + + const analytics = await service.handleImageSearch( + "+2348012345678", + "image-id", + ); + + expect(prisma.$queryRaw).not.toHaveBeenCalled(); + expect(analytics).toMatchObject({ + searchQuery: "sneakers", + productsShown: ["product-1"], + productsShownCount: 1, + zeroResults: false, + intentSuccessful: true, + vectorSearchUsed: false, + embeddingModel: null, + }); + }); + + it("continues with vector search when label extraction fails", async () => { + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + visionClient.detectProductTerms.mockRejectedValue(new Error("vision down")); + vertexClient.isConfigured.mockReturnValue(true); + vertexClient.generateImageEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + prisma.$queryRaw.mockResolvedValue([ + { productId: "product-1", similarity: 0.8 }, + ]); + prisma.product.findMany.mockResolvedValue([ + { + id: "product-1", + productCode: "TWZ-SNK-001", + name: "White Sneakers", + retailPriceKobo: 1500000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "sneakerstore", + storeName: "Sneaker Store NG", + }, + }, + ]); + + const analytics = await service.handleImageSearch( + "+2348012345678", + "image-id", + ); + + expect(vertexClient.generateImageEmbedding).toHaveBeenCalledWith( + "base64-image", + undefined, + ); + expect(interactiveService.sendListMessage).toHaveBeenCalledWith( + "+2348012345678", + "Here are the closest matches I found:", + "View Products", + expect.anything(), + ); + expect(analytics).toMatchObject({ + searchQuery: null, + productsShown: ["product-1"], + zeroResults: false, + intentSuccessful: true, + vectorSearchUsed: true, + embeddingModel: "multimodalembedding@001", + }); + }); + + it("does not mark zeroResults when neither embedding nor labels are available", async () => { + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + visionClient.detectProductTerms.mockResolvedValue(null); + vertexClient.isConfigured.mockReturnValue(true); + vertexClient.generateImageEmbedding.mockRejectedValue( + new Error("provider down"), + ); + + await expect( + service.handleImageSearch("+2348012345678", "image-id"), + ).resolves.toMatchObject({ + zeroResults: false, + intentSuccessful: false, + errorType: "IMAGE_TERMS_NOT_FOUND", + vectorSearchUsed: false, + embeddingModel: null, + }); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "+2348012345678", + "I couldn't identify the product in this photo. Please make sure the item is clearly visible and try sending it again.", + ); + expect(prisma.product.findMany).not.toHaveBeenCalled(); + }); + + it("marks zeroResults only when no products are returned after a real search", async () => { + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + visionClient.detectProductTerms.mockResolvedValue(["bag"]); + vertexClient.isConfigured.mockReturnValue(true); + vertexClient.generateImageEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + prisma.$queryRaw.mockResolvedValue([]); + prisma.product.findMany.mockResolvedValue([]); + + await expect( + service.handleImageSearch("+2348012345678", "image-id"), + ).resolves.toMatchObject({ + zeroResults: true, + intentSuccessful: true, + vectorSearchUsed: true, + embeddingModel: "multimodalembedding@001", + }); + }); + + it("does not replace a completed vector no-match with label keyword results", async () => { + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + visionClient.detectProductTerms.mockResolvedValue(["sneakers"]); + vertexClient.isConfigured.mockReturnValue(true); + vertexClient.generateImageEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + prisma.$queryRaw.mockResolvedValue([]); + prisma.product.findMany.mockResolvedValue([ + { + id: "fallback-product", + productCode: "TWZ-FALL-001", + name: "Popular Bag", + retailPriceKobo: 1200000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "bagstore", + storeName: "Bag Store", + }, + }, + ]); + + await expect( + service.handleImageSearch("+2348012345678", "image-id"), + ).resolves.toMatchObject({ + zeroResults: true, + intentSuccessful: true, + vectorSearchUsed: true, + embeddingModel: "multimodalembedding@001", + productsShown: ["fallback-product"], + productsShownCount: 1, + }); + + expect(prisma.product.findMany).toHaveBeenCalledTimes(1); + expect(prisma.product.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.not.objectContaining({ + OR: expect.anything(), + }), + }), + ); + expect(interactiveService.sendListMessage).toHaveBeenCalledWith( + "+2348012345678", + expect.stringContaining("couldn't find a direct match"), + "Alternative Products", + expect.anything(), + ); + }); + + it("blocks unsafe images before any discovery work runs", async () => { + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + visionClient.checkSafetyFromBase64.mockResolvedValue( + safeSearchResult({ adult: "VERY_LIKELY" }), + ); + vertexClient.isConfigured.mockReturnValue(true); + + const analytics = await service.handleImageSearch( + "+2348012345678", + "image-id", + ); + + expect(visionClient.checkSafetyFromBase64).toHaveBeenCalledWith( + "base64-image", + ); + expect(visionClient.detectProductTerms).not.toHaveBeenCalled(); + expect(vertexClient.generateImageEmbedding).not.toHaveBeenCalled(); + expect(prisma.$queryRaw).not.toHaveBeenCalled(); + expect(prisma.product.findMany).not.toHaveBeenCalled(); + expect(prisma.sourcedProduct.findMany).not.toHaveBeenCalled(); + expect( + productDiscoveryService.storeRecentSearchResults, + ).not.toHaveBeenCalled(); + + expect(interactiveService.sendTextMessage).toHaveBeenLastCalledWith( + "+2348012345678", + "Sorry, I can't search from that image. Please send a clear product photo instead.", + ); + const outboundText = interactiveService.sendTextMessage.mock.calls + .map((call) => JSON.stringify(call.slice(1))) + .join("\n"); + expect(outboundText.toLowerCase()).not.toMatch( + /safesearch|safe search|vision|moderation|adult|racy|violence|policy|blocked/, + ); + + expect(analytics).toMatchObject({ + zeroResults: false, + intentSuccessful: false, + vectorSearchUsed: false, + embeddingModel: null, + errorType: "IMAGE_BLOCKED_UNSAFE", + productsRankedCount: 0, + productsShown: [], + productsShownCount: 0, + }); + }); + + it.each([ + ["adult", "LIKELY"], + ["adult", "VERY_LIKELY"], + ["violence", "LIKELY"], + ["violence", "VERY_LIKELY"], + ["racy", "LIKELY"], + ["racy", "VERY_LIKELY"], + ] as const)("blocks when %s is %s", async (category, likelihood) => { + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + visionClient.checkSafetyFromBase64.mockResolvedValue( + safeSearchResult({ [category]: likelihood }), + ); + + await expect( + service.handleImageSearch("+2348012345678", "image-id"), + ).resolves.toMatchObject({ errorType: "IMAGE_BLOCKED_UNSAFE" }); + expect(visionClient.detectProductTerms).not.toHaveBeenCalled(); + }); + + it.each([ + ["adult", "POSSIBLE"], + ["violence", "UNLIKELY"], + ["racy", "VERY_UNLIKELY"], + ] as const)( + "does not block when %s is %s and continues to discovery", + async (category, likelihood) => { + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + visionClient.checkSafetyFromBase64.mockResolvedValue( + safeSearchResult({ [category]: likelihood }), + ); + visionClient.detectProductTerms.mockResolvedValue(["sneakers"]); + prisma.product.findMany.mockResolvedValue([ + { + id: "product-1", + productCode: "TWZ-SNK-001", + name: "White Sneakers", + retailPriceKobo: 1500000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "sneakerstore", + storeName: "Sneaker Store NG", + }, + }, + ]); + + await expect( + service.handleImageSearch("+2348012345678", "image-id"), + ).resolves.toMatchObject({ + productsShownCount: 1, + zeroResults: false, + intentSuccessful: true, + }); + }, + ); + + it("fails open when SafeSearch screening errors and continues discovery", async () => { + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + visionClient.checkSafetyFromBase64.mockRejectedValue( + new Error("provider down"), + ); + visionClient.detectProductTerms.mockResolvedValue(["sneakers"]); + prisma.product.findMany.mockResolvedValue([ + { + id: "product-1", + productCode: "TWZ-SNK-001", + name: "White Sneakers", + retailPriceKobo: 1500000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "sneakerstore", + storeName: "Sneaker Store NG", + }, + }, + ]); + + await expect( + service.handleImageSearch("+2348012345678", "image-id"), + ).resolves.toMatchObject({ + productsShown: ["product-1"], + productsShownCount: 1, + zeroResults: false, + intentSuccessful: true, + vectorSearchUsed: false, + embeddingModel: null, + }); + }); + + it("skips screening when Vision is not configured", async () => { + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + visionClient.isConfigured.mockReturnValue(false); + visionClient.detectProductTerms.mockResolvedValue(["sneakers"]); + prisma.product.findMany.mockResolvedValue([ + { + id: "product-1", + productCode: "TWZ-SNK-001", + name: "White Sneakers", + retailPriceKobo: 1500000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "sneakerstore", + storeName: "Sneaker Store NG", + }, + }, + ]); + + await expect( + service.handleImageSearch("+2348012345678", "image-id"), + ).resolves.toMatchObject({ + productsShownCount: 1, + intentSuccessful: true, + }); + expect(visionClient.checkSafetyFromBase64).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/image-search.service.ts b/apps/backend/src/channels/whatsapp/image-search.service.ts new file mode 100644 index 00000000..529dc1f0 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/image-search.service.ts @@ -0,0 +1,828 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { + ModerationStatus, + Prisma, + ProductStatus, + StoreTier, + StoreType, +} from "@prisma/client"; +import { VertexClient } from "../../integrations/ai/vertex.client"; +import { VisionClient } from "../../integrations/ai/vision.client"; +import { MetaWhatsAppClient } from "../../integrations/meta-whatsapp/meta-whatsapp.client"; +import { PrismaService } from "../../prisma/prisma.service"; +import { isBlockedInboundImage } from "./whatsapp-image-safety"; +import { WhatsAppInteractiveService } from "./whatsapp-interactive.service"; +import { + WhatsAppProductDiscoveryService, + WhatsAppProductLinkRecord, +} from "./whatsapp-product-discovery.service"; +import { + clampUnit, + computeTaxonomyMatchScore, + TaxonomyRankingHints, +} from "./whatsapp-search-ranking"; +import { maskWhatsAppPhone } from "./whatsapp.utils"; +import { TaxonomyDiscoveryService } from "../../domains/commerce/search/taxonomy-discovery.service"; +import { WizzaDiscoveryLocationContext } from "./wizza-location-resolver.service"; +import { + buildTaxonomyZeroResultSignal, + TaxonomyZeroResultSignal, +} from "../../domains/commerce/search/search-zero-result"; + +export interface WhatsAppImageSearchAnalytics { + searchQuery: string | null; + parsedFilters?: Prisma.InputJsonValue | null; + productsRankedCount: number; + productsShown: string[]; + productsShownCount: number; + zeroResults: boolean; + intentSuccessful: boolean; + vectorSearchUsed: boolean; + embeddingModel: string | null; + errorType?: string | null; + dropOffStep?: string | null; + // Set only when image labels resolved to a confident taxonomy but zero + // eligible products were found. Internal analytics signal — never shopper-facing. + taxonomyZeroResult?: TaxonomyZeroResultSignal | null; +} + +interface ImageVectorSearchOutcome { + products: WhatsAppProductLinkRecord[]; + candidateCount: number; +} + +// Top pgvector matches pulled before listing-level eligibility filtering. +const IMAGE_VECTOR_CANDIDATE_POOL = 30; +const MAX_RESULTS = 10; + +// Similarity band width for taxonomy tie-breaking. Candidates whose cosine +// similarity falls in the same band are considered near-equal and may be +// reordered by taxonomy; candidates in a stronger band always rank first. +const IMAGE_TAXONOMY_SIMILARITY_BAND = 0.05; + +// Shopper-facing copy for blocked images — no moderation internals. +const IMAGE_BLOCKED_MESSAGE = + "Sorry, I can't search from that image. Please send a clear product photo instead."; + +@Injectable() +export class ImageSearchService { + private readonly logger = new Logger(ImageSearchService.name); + + constructor( + private prisma: PrismaService, + private interactiveService: WhatsAppInteractiveService, + private metaWhatsAppClient: MetaWhatsAppClient, + private visionClient: VisionClient, + private productDiscoveryService: WhatsAppProductDiscoveryService, + private vertexClient: VertexClient, + private taxonomyDiscovery: TaxonomyDiscoveryService, + ) {} + + async handleImageSearch( + phone: string, + imageId: string, + locationContext: WizzaDiscoveryLocationContext = { + locationText: null, + source: "none", + countryCode: null, + state: null, + city: null, + area: null, + }, + ): Promise { + try { + // An image search starts a new discovery attempt, even when the image + // cannot be processed. Old numeric selections must not survive it. + await this.productDiscoveryService.clearDiscoverySelectionState(phone); + await this.interactiveService.sendTextMessage( + phone, + "Analyzing image. Please wait...", + ); + + const imageResult = await this.downloadMetaImage(imageId); + if (!imageResult) { + await this.interactiveService.sendTextMessage( + phone, + "Sorry, I couldn't download the image. Please try again.", + ); + return this.emptyImageAnalytics("IMAGE_DOWNLOAD_FAILED"); + } + + const { base64Data } = imageResult; + + const imageBlocked = await this.isInboundImageBlocked(base64Data); + if (imageBlocked) { + await this.interactiveService.sendTextMessage( + phone, + IMAGE_BLOCKED_MESSAGE, + ); + return this.emptyImageAnalytics("IMAGE_BLOCKED_UNSAFE"); + } + + const extractedTerms = await this.analyzeImageSafely(base64Data); + if (extractedTerms) { + this.logger.log("Image search terms extracted"); + } + + // Deterministic taxonomy context derived from Cloud Vision labels/objects/ + // text. This is an additive category/subcategory ranking signal only — the + // Vertex image embedding below remains the primary retrieval signal. When + // no label resolves to the taxonomy, hints are empty and ranking is + // identical to the existing embedding-first behavior. + const taxonomyHints = this.resolveImageTaxonomyHints(extractedTerms); + + const imageEmbedding = await this.generateImageEmbedding( + base64Data, + extractedTerms, + ); + + if (!imageEmbedding && (!extractedTerms || extractedTerms.length === 0)) { + await this.interactiveService.sendTextMessage( + phone, + "I couldn't identify the product in this photo. Please make sure the item is clearly visible and try sending it again.", + ); + return this.emptyImageAnalytics("IMAGE_TERMS_NOT_FOUND"); + } + + let products: WhatsAppProductLinkRecord[] = []; + let candidateCount = 0; + let vectorSearchUsed = false; + let labelSearchUsed = false; + let locationWidened = false; + + if (imageEmbedding) { + const vectorOutcome = await this.searchProductsByImageEmbedding( + imageEmbedding, + taxonomyHints, + locationContext.locationText, + ); + if (vectorOutcome) { + vectorSearchUsed = true; + products = vectorOutcome.products; + candidateCount = vectorOutcome.candidateCount; + } + if (products.length === 0 && locationContext.locationText) { + const widenedVectorOutcome = + await this.searchProductsByImageEmbedding( + imageEmbedding, + taxonomyHints, + null, + ); + if (widenedVectorOutcome) { + vectorSearchUsed = true; + products = widenedVectorOutcome.products; + candidateCount = widenedVectorOutcome.candidateCount; + locationWidened = products.length > 0; + } + } + } + + if ( + products.length === 0 && + !vectorSearchUsed && + extractedTerms && + extractedTerms.length > 0 + ) { + const labelProducts = await this.searchProducts( + extractedTerms, + locationContext.locationText, + ); + if (labelProducts.length > 0) { + labelSearchUsed = true; + products = labelProducts; + candidateCount = labelProducts.length; + } + } + + const searchQuery = extractedTerms?.length + ? extractedTerms.join(" ") + : null; + const parsedFilters = { + imageTerms: extractedTerms ?? [], + vectorSearchUsed, + labelSearchUsed, + locationWidened, + }; + const embeddingModel = vectorSearchUsed + ? this.vertexClient.getModelName() + : null; + + if (products.length === 0) { + // A vector top-N miss is NOT proof of a category supply gap: eligible + // products can exist but fall outside the pgvector candidate pool. So we + // only record a taxonomy gap signal when (a) labels resolve to a + // confident taxonomy AND (b) a direct category eligibility check + // confirms zero eligible products in that category. Resolved from the + // Cloud Vision terms only — no image data / OCR text is persisted as a + // query (rawQuery is null for image search). Analytics only. + const zeroResultHints = + this.taxonomyDiscovery.resolveDiscoveryHintsFromLabels( + extractedTerms, + ); + let taxonomyZeroResult: TaxonomyZeroResultSignal | null = null; + if (zeroResultHints.confidence !== "none") { + const categoryTerms = + this.taxonomyDiscovery.getCategorySearchTerms(zeroResultHints); + const categoryHasSupply = + await this.categoryHasEligibleProducts(categoryTerms); + if (!categoryHasSupply) { + taxonomyZeroResult = buildTaxonomyZeroResultSignal({ + searchMode: "IMAGE", + hints: zeroResultHints, + rawQuery: null, + searchResultCount: 0, + }); + } + } + const matchHint = extractedTerms?.[0]; + const bodyText = matchHint + ? `I identified the item as ${matchHint}, but I couldn't find a direct match right now.\n\nHere are some other popular products you might like instead:` + : `I couldn't find a direct match for your photo right now.\n\nHere are some other popular products you might like instead:`; + + const fallbackProducts = await this.prisma.product.findMany({ + where: { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + productCode: { not: null }, + moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true }, + }, + include: { + storeProfile: { + select: { + storeHandle: true, + storeName: true, + }, + }, + }, + take: 5, + orderBy: { createdAt: "desc" }, + }); + + if (fallbackProducts.length > 0) { + await this.productDiscoveryService.storeRecentSearchResults( + phone, + fallbackProducts, + { lastQuery: searchQuery, source: "image" }, + ); + const rows = fallbackProducts.map((product, index) => ({ + id: `product_result_${index + 1}`, + title: product.name.substring(0, 24), + description: this.formatProductDescription(product).substring( + 0, + 72, + ), + })); + + await this.interactiveService.sendListMessage( + phone, + bodyText, + "Alternative Products", + [ + { title: "Suggested Items", rows }, + { + title: "Other Options", + rows: [ + { + id: "browse_categories", + title: "Browse Categories", + description: "Explore by type", + }, + { + id: "search_products", + title: "Try Text Search", + description: "Search by keyword", + }, + ], + }, + ], + ); + await this.productDiscoveryService.sendCanonicalProductLinks( + phone, + fallbackProducts, + ); + return { + searchQuery, + parsedFilters, + productsRankedCount: candidateCount, + productsShown: fallbackProducts.map((product) => product.id), + productsShownCount: fallbackProducts.length, + zeroResults: true, + intentSuccessful: true, + vectorSearchUsed, + embeddingModel, + taxonomyZeroResult, + }; + } + + const bodyTextFallback = matchHint + ? `I identified the item as ${matchHint}, but I couldn't find a direct match.\n\nWould you like to browse our top categories or search for a different product?` + : `I couldn't find a direct match for your photo.\n\nWould you like to browse our top categories or search for a different product?`; + await this.interactiveService.sendReplyButtons( + phone, + bodyTextFallback, + [ + { id: "browse_categories", title: "Browse Categories" }, + { id: "search_products", title: "Try Text Search" }, + ], + ); + return { + searchQuery, + parsedFilters, + productsRankedCount: candidateCount, + productsShown: [], + productsShownCount: 0, + zeroResults: true, + intentSuccessful: true, + vectorSearchUsed, + embeddingModel, + taxonomyZeroResult, + }; + } + + const displayProducts = products.slice(0, MAX_RESULTS); + await this.productDiscoveryService.storeRecentSearchResults( + phone, + displayProducts, + { lastQuery: searchQuery, source: "image" }, + ); + const rows = displayProducts.map((product, index) => ({ + id: `product_result_${index + 1}`, + title: product.name.substring(0, 24), + description: this.formatProductDescription(product).substring(0, 72), + })); + + const headerText = locationWidened + ? "I could not find strong matches in that area, so I widened the search." + : extractedTerms?.length + ? `Matching products for "${extractedTerms[0]}":` + : "Here are the closest matches I found:"; + + await this.interactiveService.sendListMessage( + phone, + headerText, + "View Products", + [{ title: "Matches", rows }], + ); + await this.productDiscoveryService.sendCanonicalProductLinks( + phone, + displayProducts, + ); + return { + searchQuery, + parsedFilters, + productsRankedCount: candidateCount, + productsShown: displayProducts.map((product) => product.id), + productsShownCount: displayProducts.length, + zeroResults: false, + intentSuccessful: true, + vectorSearchUsed, + embeddingModel, + }; + } catch (error) { + this.logger.error( + `Image search failed for ${maskWhatsAppPhone(phone)}:`, + error, + ); + await this.interactiveService.sendTextMessage( + phone, + "An error occurred during image search. Please try again later.", + ); + return this.emptyImageAnalytics("IMAGE_SEARCH_ERROR"); + } + } + + public async downloadMetaImage( + imageId: string, + ): Promise<{ base64Data: string; mimeType: string } | null> { + return this.metaWhatsAppClient.downloadImage(imageId); + } + + // SafeSearch screening runs before any discovery work. Thresholds live + // in whatsapp-image-safety.ts (block on LIKELY/VERY_LIKELY for + // adult/violence/racy — same policy as upload moderation). + // Screening failures fail open: the image is search input only (never + // stored or shown to anyone else), and the channel pattern is graceful + // degradation — a provider outage must not break shopping. + private async isInboundImageBlocked(base64Data: string): Promise { + if (!this.visionClient.isConfigured()) { + return false; + } + + try { + const safety = await this.visionClient.checkSafetyFromBase64(base64Data); + return isBlockedInboundImage(safety); + } catch (error) { + this.logger.warn( + `Inbound image safety screening failed: ${ + error instanceof Error ? error.message : error + }`, + ); + return false; + } + } + + private async analyzeImageSafely( + base64Data: string, + ): Promise { + try { + return await this.visionClient.detectProductTerms(base64Data); + } catch (error) { + this.logger.warn( + `Image label extraction failed: ${ + error instanceof Error ? error.message : error + }`, + ); + return null; + } + } + + private async generateImageEmbedding( + base64Data: string, + terms: string[] | null, + ): Promise { + if (!this.vertexClient.isConfigured()) { + return null; + } + + try { + return await this.vertexClient.generateImageEmbedding( + base64Data, + terms?.length ? terms.join(" ") : undefined, + ); + } catch (error) { + this.logger.warn( + `Image embedding generation failed: ${ + error instanceof Error ? error.message : error + }`, + ); + return null; + } + } + + // Resolves Cloud Vision label-like terms (labels, localized objects, and OCR + // text) into deterministic taxonomy ranking hints via the shared taxonomy + // discovery helper. No external calls, no synonym/taxonomy logic duplicated + // here. Unknown terms yield "none" confidence, which is a ranking no-op. + private resolveImageTaxonomyHints( + terms: string[] | null, + ): TaxonomyRankingHints { + const hints = this.taxonomyDiscovery.resolveDiscoveryHintsFromLabels(terms); + if (hints.confidence !== "none") { + // Safe log: resolved taxonomy labels come from our own taxonomy tree, not + // raw image content. Never logs the image, base64, or raw Vision payload. + this.logger.log( + `Image taxonomy hints resolved (confidence=${hints.confidence}, subcategories=${hints.subcategoryLabels.length}, parents=${hints.parentCategoryLabels.length})`, + ); + } + return { + parentCategoryLabels: hints.parentCategoryLabels, + subcategoryLabels: hints.subcategoryLabels, + confidence: hints.confidence, + }; + } + + private async searchProductsByImageEmbedding( + embedding: number[], + taxonomyHints: TaxonomyRankingHints, + location: string | null, + ): Promise { + const vectorLiteral = `[${embedding.join(",")}]`; + + let rows: { productId: string; similarity: number }[]; + try { + // Raw SQL is required because Prisma cannot query pgvector + // `Unsupported("vector(1408)")` columns or use the `<=>` cosine + // distance operator through the Prisma query builder. + rows = await this.prisma.$queryRaw< + { productId: string; similarity: number }[] + >(Prisma.sql` + SELECT + "product_id" AS "productId", + (1 - ("embedding" <=> ${vectorLiteral}::vector))::float AS "similarity" + FROM "product_embeddings" + WHERE "embedding_ready" = TRUE + ORDER BY "embedding" <=> ${vectorLiteral}::vector + LIMIT ${IMAGE_VECTOR_CANDIDATE_POOL} + `); + } catch (error) { + this.logger.warn( + `Image vector search failed: ${ + error instanceof Error ? error.message : error + }`, + ); + return null; + } + + if (rows.length === 0) { + return { products: [], candidateCount: 0 }; + } + + const similarityByProductId = new Map( + rows.map((row) => [row.productId, clampUnit(row.similarity)]), + ); + const matchedProductIds = [...similarityByProductId.keys()]; + + const [nativeProducts, sourcedProducts] = await Promise.all([ + this.prisma.product.findMany({ + where: { + id: { in: matchedProductIds }, + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + productCode: { not: null }, + moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + ...(location + ? { + businessAddress: { + contains: location, + mode: Prisma.QueryMode.insensitive, + }, + } + : {}), + }, + }, + include: { + storeProfile: { + select: { + storeHandle: true, + storeName: true, + }, + }, + }, + }), + this.prisma.sourcedProduct.findMany({ + where: { + physicalProductId: { in: matchedProductIds }, + isActive: true, + digitalStore: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + storeType: StoreType.DIGITAL, + }, + ...(location + ? { + physicalStore: { + businessAddress: { + contains: location, + mode: Prisma.QueryMode.insensitive, + }, + }, + } + : {}), + physicalProduct: { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + allowDropship: true, + moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { + storeType: StoreType.PHYSICAL, + isOpen: true, + allowDropship: true, + }, + }, + }, + include: { + digitalStore: { + select: { + storeHandle: true, + storeName: true, + }, + }, + physicalProduct: { + select: { + name: true, + title: true, + shortDescription: true, + description: true, + categoryTag: true, + retailPriceKobo: true, + }, + }, + }, + }), + ]); + + const candidates: WhatsAppProductLinkRecord[] = [ + ...nativeProducts.map((product) => ({ + ...product, + listingType: "NATIVE" as const, + embeddingProductId: product.id, + })), + ...sourcedProducts.map((product) => + this.productDiscoveryService.toSourcedProductListRecord(product), + ), + ]; + + // Image embedding similarity is the PRIMARY image-search signal, and it is + // never overridden by taxonomy. Similarity is quantized into narrow bands + // (IMAGE_TAXONOMY_SIMILARITY_BAND); taxonomy only acts as a tie-breaker + // WITHIN a band, so a broad/noisy Vision label can reorder near-equal + // matches but can never lift a visually weaker product above a clearly + // stronger embedding match. When taxonomy has no confident match, every + // boost is 0 and ordering is pure similarity. Recency breaks exact ties. + const rankByProductId = new Map( + candidates.map((candidate) => { + const similarity = clampUnit( + similarityByProductId.get( + candidate.embeddingProductId ?? candidate.id, + ) ?? 0, + ); + const taxonomyBoost = computeTaxonomyMatchScore(taxonomyHints, { + platformCategory: candidate.platformCategory, + categoryTag: candidate.categoryTag, + }); + return [ + candidate.id, + { + similarity, + band: Math.round(similarity / IMAGE_TAXONOMY_SIMILARITY_BAND), + taxonomyBoost, + }, + ]; + }), + ); + + const neutralRank = { similarity: 0, band: 0, taxonomyBoost: 0 }; + candidates.sort((left, right) => { + const l = rankByProductId.get(left.id) ?? neutralRank; + const r = rankByProductId.get(right.id) ?? neutralRank; + return ( + r.band - l.band || + r.taxonomyBoost - l.taxonomyBoost || + r.similarity - l.similarity || + (right.createdAt?.getTime() ?? 0) - (left.createdAt?.getTime() ?? 0) + ); + }); + + return { + products: candidates.slice(0, MAX_RESULTS), + candidateCount: candidates.length, + }; + } + + // Direct category eligibility check used to confirm a genuine supply gap + // before recording an image taxonomy zero-result signal. Returns true if any + // eligible product (native or sourced/dropship) exists in the resolved + // taxonomy category, so a vector top-N miss is never mistaken for a gap. + // Mirrors the same eligibility gates as image vector search (Tier 0 excluded, + // active/public/in-stock, sourced privacy via physical-product rules). + private async categoryHasEligibleProducts( + categoryTerms: string[], + ): Promise { + if (categoryTerms.length === 0) { + return false; + } + + const nativeCategoryOr: Prisma.ProductWhereInput[] = categoryTerms.flatMap( + (term) => [ + { categoryTag: { contains: term, mode: Prisma.QueryMode.insensitive } }, + { + platformCategory: { + contains: term, + mode: Prisma.QueryMode.insensitive, + }, + }, + ], + ); + + try { + const [nativeMatch, sourcedMatch] = await Promise.all([ + this.prisma.product.findFirst({ + where: { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + productCode: { not: null }, + moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true }, + OR: nativeCategoryOr, + }, + select: { id: true }, + }), + this.prisma.sourcedProduct.findFirst({ + where: { + isActive: true, + digitalStore: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + storeType: StoreType.DIGITAL, + }, + physicalProduct: { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + allowDropship: true, + moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { + storeType: StoreType.PHYSICAL, + isOpen: true, + allowDropship: true, + }, + OR: nativeCategoryOr, + }, + }, + select: { id: true }, + }), + ]); + + return nativeMatch !== null || sourcedMatch !== null; + } catch (error) { + // On a lookup failure, assume supply exists so we do not record a false + // gap signal. Analytics accuracy is never worth a false demand signal. + this.logger.warn( + `Category availability check failed: ${ + error instanceof Error ? error.message : error + }`, + ); + return true; + } + } + + private async searchProducts(terms: string[], location: string | null) { + if (!terms || terms.length === 0) return []; + + const searchConditions = terms.map((term) => ({ + name: { contains: term, mode: Prisma.QueryMode.insensitive }, + })); + + return this.prisma.product.findMany({ + where: { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + productCode: { not: null }, + moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + ...(location + ? { + businessAddress: { + contains: location, + mode: Prisma.QueryMode.insensitive, + }, + } + : {}), + }, + OR: searchConditions, + }, + include: { + storeProfile: { + select: { + storeHandle: true, + storeName: true, + }, + }, + }, + take: 20, + orderBy: { createdAt: "desc" }, + }); + } + + private formatProductDescription(product: { + retailPriceKobo?: bigint | number | null; + pricePerUnitKobo?: bigint | number | null; + storeProfile?: { + storeHandle?: string | null; + storeName?: string | null; + } | null; + }): string { + const amountKobo = product.retailPriceKobo ?? product.pricePerUnitKobo ?? 0; + const amountNaira = Number(amountKobo) / 100; + const price = amountNaira.toLocaleString("en-NG"); + // Public display identity only — never businessName (internal/legal/KYB). + const storeName = + product.storeProfile?.storeName || + product.storeProfile?.storeHandle || + "twizrr store"; + + return `NGN ${price} | Sold by ${storeName}`; + } + + private emptyImageAnalytics(errorType: string): WhatsAppImageSearchAnalytics { + return { + searchQuery: null, + productsRankedCount: 0, + productsShown: [], + productsShownCount: 0, + zeroResults: false, + intentSuccessful: false, + vectorSearchUsed: false, + embeddingModel: null, + errorType, + dropOffStep: "image_search", + }; + } +} diff --git a/apps/backend/src/channels/whatsapp/image-search.taxonomy-enrichment.spec.ts b/apps/backend/src/channels/whatsapp/image-search.taxonomy-enrichment.spec.ts new file mode 100644 index 00000000..64fc5f71 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/image-search.taxonomy-enrichment.spec.ts @@ -0,0 +1,332 @@ +import { ModerationStatus, ProductStatus, StoreTier } from "@prisma/client"; + +import { TaxonomyDiscoveryService } from "../../domains/commerce/search/taxonomy-discovery.service"; +import { ImageSearchService } from "./image-search.service"; + +/** + * Verifies WIZZA image discovery enriches Cloud Vision labels with taxonomy + * hints as an ADDITIVE ranking signal. The Vertex image embedding stays the + * primary retrieval signal; taxonomy only reorders already-eligible candidates + * toward the resolved category. It never replaces embedding-first search, never + * weakens SafeSearch, and never bypasses eligibility (Tier 0 / active / public / + * in-stock / sourced-product privacy). + * + * Two candidates share the same embedding similarity so the taxonomy boost is + * the deciding signal, and the non-matching candidate is intentionally newer so + * it would win the recency tiebreak WITHOUT taxonomy — any reordering is + * therefore attributable to the taxonomy enrichment. + */ +describe("ImageSearchService taxonomy enrichment", () => { + const prisma = { + product: { findMany: jest.fn() }, + sourcedProduct: { findMany: jest.fn() }, + $queryRaw: jest.fn(), + }; + const interactiveService = { + sendTextMessage: jest.fn(), + sendListMessage: jest.fn(), + sendReplyButtons: jest.fn(), + }; + const metaWhatsAppClient = { downloadImage: jest.fn() }; + const visionClient = { + isConfigured: jest.fn(() => true), + checkSafetyFromBase64: jest.fn(), + detectProductTerms: jest.fn(), + }; + const productDiscoveryService = { + clearDiscoverySelectionState: jest.fn(), + storeRecentSearchResults: jest.fn(), + sendCanonicalProductLinks: jest.fn(), + toSourcedProductListRecord: jest.fn(), + }; + const vertexClient = { + isConfigured: jest.fn(() => true), + generateImageEmbedding: jest.fn(), + getModelName: jest.fn(() => "multimodalembedding@001"), + }; + + const safeSearchResult = ( + overrides: Partial> = {}, + ) => ({ + adult: "VERY_UNLIKELY", + violence: "VERY_UNLIKELY", + racy: "VERY_UNLIKELY", + ...overrides, + }); + + const store = { + storeHandle: "ada_store", + storeName: "Ada Store", + tier: StoreTier.TIER_1, + }; + + // Native candidate whose only distinguishing ranking input (given equal + // similarity + identical store) is its category label. + function nativeCandidate(id: string, categoryTag: string, createdAt: Date) { + return { + id, + productCode: `TWZ-${id}`, + name: `Item ${id}`, + categoryTag, + platformCategory: null, + retailPriceKobo: 1500000n, + pricePerUnitKobo: null, + createdAt, + storeProfile: store, + }; + } + + let service: ImageSearchService; + + beforeEach(() => { + jest.clearAllMocks(); + visionClient.isConfigured.mockReturnValue(true); + visionClient.checkSafetyFromBase64.mockResolvedValue(safeSearchResult()); + vertexClient.isConfigured.mockReturnValue(true); + vertexClient.getModelName.mockReturnValue("multimodalembedding@001"); + prisma.sourcedProduct.findMany.mockResolvedValue([]); + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + service = new ImageSearchService( + prisma as any, + interactiveService as any, + metaWhatsAppClient as any, + visionClient as any, + productDiscoveryService as any, + vertexClient as any, + new TaxonomyDiscoveryService(), + ); + }); + + function rankedIds(): string[] { + const displayProducts = + productDiscoveryService.storeRecentSearchResults.mock.calls[0][1]; + return displayProducts.map((product: { id: string }) => product.id); + } + + it("boosts the taxonomy-matching product above a newer neutral product at equal similarity", async () => { + visionClient.detectProductTerms.mockResolvedValue([ + "sneaker", + "footwear", + "shoe", + ]); + vertexClient.generateImageEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + // Equal similarity for both candidates. + prisma.$queryRaw.mockResolvedValue([ + { productId: "sneaker", similarity: 0.7 }, + { productId: "rice", similarity: 0.7 }, + ]); + prisma.product.findMany.mockResolvedValue([ + // "rice" is newer, so without taxonomy it would win the recency tiebreak. + nativeCandidate("rice", "rice", new Date("2026-06-01T00:00:00.000Z")), + nativeCandidate( + "sneaker", + "sneakers", + new Date("2026-01-01T00:00:00.000Z"), + ), + ]); + + await service.handleImageSearch("+2348012345678", "image-id"); + + expect(rankedIds()[0]).toBe("sneaker"); + }); + + it("keeps the embedding as the primary signal when similarity clearly differs", async () => { + visionClient.detectProductTerms.mockResolvedValue(["sneaker", "footwear"]); + vertexClient.generateImageEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + // Neutral product has a much stronger embedding match in a higher + // similarity band; taxonomy is only a within-band tie-breaker and must not + // reorder across bands. + prisma.$queryRaw.mockResolvedValue([ + { productId: "sneaker", similarity: 0.5 }, + { productId: "rice", similarity: 0.9 }, + ]); + prisma.product.findMany.mockResolvedValue([ + nativeCandidate("rice", "rice", new Date("2026-06-01T00:00:00.000Z")), + nativeCandidate( + "sneaker", + "sneakers", + new Date("2026-01-01T00:00:00.000Z"), + ), + ]); + + await service.handleImageSearch("+2348012345678", "image-id"); + + expect(rankedIds()[0]).toBe("rice"); + }); + + it("never lets a taxonomy match override a clearly stronger embedding match", async () => { + visionClient.detectProductTerms.mockResolvedValue(["sneaker", "footwear"]); + vertexClient.generateImageEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + // The taxonomy-matching sneaker (0.80) is a full band below the neutral + // rice (0.99). Taxonomy is only a within-band tie-breaker, so the stronger + // embedding match must still rank first. + prisma.$queryRaw.mockResolvedValue([ + { productId: "sneaker", similarity: 0.8 }, + { productId: "rice", similarity: 0.99 }, + ]); + prisma.product.findMany.mockResolvedValue([ + nativeCandidate("rice", "rice", new Date("2026-01-01T00:00:00.000Z")), + nativeCandidate( + "sneaker", + "sneakers", + new Date("2026-06-01T00:00:00.000Z"), + ), + ]); + + await service.handleImageSearch("+2348012345678", "image-id"); + + expect(rankedIds()[0]).toBe("rice"); + }); + + it("preserves pure-similarity ordering when labels resolve to no taxonomy", async () => { + visionClient.detectProductTerms.mockResolvedValue(["zzzqqq", "gibberish"]); + vertexClient.generateImageEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + prisma.$queryRaw.mockResolvedValue([ + { productId: "sneaker", similarity: 0.7 }, + { productId: "rice", similarity: 0.7 }, + ]); + prisma.product.findMany.mockResolvedValue([ + // Equal similarity + no taxonomy match -> newer "rice" wins on recency. + nativeCandidate("rice", "rice", new Date("2026-06-01T00:00:00.000Z")), + nativeCandidate( + "sneaker", + "sneakers", + new Date("2026-01-01T00:00:00.000Z"), + ), + ]); + + await service.handleImageSearch("+2348012345678", "image-id"); + + expect(rankedIds()[0]).toBe("rice"); + }); + + it("does not run label enrichment, embedding, or cache writes for unsafe images", async () => { + visionClient.checkSafetyFromBase64.mockResolvedValue( + safeSearchResult({ adult: "VERY_LIKELY" }), + ); + + const analytics = await service.handleImageSearch( + "+2348012345678", + "image-id", + ); + + expect(visionClient.detectProductTerms).not.toHaveBeenCalled(); + expect(vertexClient.generateImageEmbedding).not.toHaveBeenCalled(); + expect(prisma.$queryRaw).not.toHaveBeenCalled(); + expect( + productDiscoveryService.storeRecentSearchResults, + ).not.toHaveBeenCalled(); + expect(analytics.errorType).toBe("IMAGE_BLOCKED_UNSAFE"); + }); + + it("keeps Tier 0 exclusion and active/public eligibility on the enriched query", async () => { + visionClient.detectProductTerms.mockResolvedValue(["sneaker"]); + vertexClient.generateImageEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + prisma.$queryRaw.mockResolvedValue([ + { productId: "sneaker", similarity: 0.8 }, + ]); + prisma.product.findMany.mockResolvedValue([ + nativeCandidate( + "sneaker", + "sneakers", + new Date("2026-01-01T00:00:00.000Z"), + ), + ]); + + await service.handleImageSearch("+2348012345678", "image-id"); + + expect(prisma.product.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + productCode: { not: null }, + moderationStatus: { not: ModerationStatus.BLOCKED }, + storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true }, + }), + }), + ); + }); + + it("keeps sourced-product privacy and leaks no ranking internals when taxonomy is active", async () => { + visionClient.detectProductTerms.mockResolvedValue(["sneaker"]); + vertexClient.generateImageEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + prisma.$queryRaw.mockResolvedValue([ + { productId: "physical-product-1", similarity: 0.9 }, + ]); + prisma.product.findMany.mockResolvedValue([]); + prisma.sourcedProduct.findMany.mockResolvedValue([ + { + id: "sourced-1", + productCode: "TWZ-SRC-001", + physicalProductId: "physical-product-1", + physicalStoreId: "physical-store-1", + sellingPriceKobo: 2000000n, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + digitalStore: store, + physicalProduct: { + name: "Sourced Sneaker", + title: "Sourced Sneaker", + categoryTag: "sneakers", + retailPriceKobo: 2000000n, + }, + }, + ]); + productDiscoveryService.toSourcedProductListRecord.mockReturnValue({ + id: "sourced-1", + listingType: "SOURCED", + productCode: "TWZ-SRC-001", + name: "Sourced Sneaker", + categoryTag: "sneakers", + platformCategory: null, + retailPriceKobo: 2000000n, + pricePerUnitKobo: 2000000n, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + embeddingProductId: "physical-product-1", + storeProfile: store, + }); + + await service.handleImageSearch("+2348012345678", "image-id"); + + const shopperFacing = JSON.stringify( + interactiveService.sendListMessage.mock.calls, + ); + expect(shopperFacing).not.toMatch( + /physicalStoreId|physicalProductId|embeddingProductId|platformCategory|categoryTag|similarity|embedding|vector/i, + ); + expect(shopperFacing.toLowerCase()).not.toMatch( + /supplier|physical store|dropship/, + ); + }); + + it("still falls back to label keyword search when embedding generation is unavailable", async () => { + visionClient.detectProductTerms.mockResolvedValue(["sneaker"]); + // No embedding available -> the embedding-first path is skipped entirely. + vertexClient.isConfigured.mockReturnValue(false); + prisma.product.findMany.mockResolvedValue([ + { + id: "fallback-1", + productCode: "TWZ-FB-001", + name: "White Sneakers", + categoryTag: "sneakers", + retailPriceKobo: 1500000n, + pricePerUnitKobo: null, + storeProfile: store, + }, + ]); + + const analytics = await service.handleImageSearch( + "+2348012345678", + "image-id", + ); + + expect(vertexClient.generateImageEmbedding).not.toHaveBeenCalled(); + expect(prisma.$queryRaw).not.toHaveBeenCalled(); + expect(analytics.productsShown).toEqual(["fallback-1"]); + expect(analytics.vectorSearchUsed).toBe(false); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/image-search.zero-result.spec.ts b/apps/backend/src/channels/whatsapp/image-search.zero-result.spec.ts new file mode 100644 index 00000000..252a25bb --- /dev/null +++ b/apps/backend/src/channels/whatsapp/image-search.zero-result.spec.ts @@ -0,0 +1,243 @@ +import { TaxonomyDiscoveryService } from "../../domains/commerce/search/taxonomy-discovery.service"; +import { ImageSearchService } from "./image-search.service"; + +/** + * Verifies WIZZA image discovery emits a taxonomy zero-result analytics signal + * only when SafeSearch allowed the search, discovery ran, labels resolved to a + * confident taxonomy, and zero eligible products were found. Blocked/failed + * images are never counted as product demand, and no image data / OCR text is + * persisted. + */ +describe("ImageSearchService taxonomy zero-result signal", () => { + const prisma = { + product: { findMany: jest.fn(), findFirst: jest.fn() }, + sourcedProduct: { findMany: jest.fn(), findFirst: jest.fn() }, + $queryRaw: jest.fn(), + }; + const interactiveService = { + sendTextMessage: jest.fn(), + sendListMessage: jest.fn(), + sendReplyButtons: jest.fn(), + }; + const metaWhatsAppClient = { downloadImage: jest.fn() }; + const visionClient = { + isConfigured: jest.fn(() => true), + checkSafetyFromBase64: jest.fn(), + detectProductTerms: jest.fn(), + }; + const productDiscoveryService = { + clearDiscoverySelectionState: jest.fn(), + storeRecentSearchResults: jest.fn(), + sendCanonicalProductLinks: jest.fn(), + toSourcedProductListRecord: jest.fn(), + }; + const vertexClient = { + isConfigured: jest.fn(() => false), + generateImageEmbedding: jest.fn(), + getModelName: jest.fn(() => "multimodalembedding@001"), + }; + + const safeSearchResult = ( + overrides: Partial> = {}, + ) => ({ + adult: "VERY_UNLIKELY", + violence: "VERY_UNLIKELY", + racy: "VERY_UNLIKELY", + ...overrides, + }); + + let service: ImageSearchService; + + beforeEach(() => { + jest.clearAllMocks(); + visionClient.isConfigured.mockReturnValue(true); + visionClient.checkSafetyFromBase64.mockResolvedValue(safeSearchResult()); + vertexClient.isConfigured.mockReturnValue(false); + vertexClient.getModelName.mockReturnValue("multimodalembedding@001"); + prisma.sourcedProduct.findMany.mockResolvedValue([]); + // Default: the category has no eligible supply, so a genuine gap is recorded. + prisma.product.findFirst.mockResolvedValue(null); + prisma.sourcedProduct.findFirst.mockResolvedValue(null); + metaWhatsAppClient.downloadImage.mockResolvedValue({ + base64Data: "base64-image", + mimeType: "image/jpeg", + }); + service = new ImageSearchService( + prisma as any, + interactiveService as any, + metaWhatsAppClient as any, + visionClient as any, + productDiscoveryService as any, + vertexClient as any, + new TaxonomyDiscoveryService(), + ); + }); + + it("records a zero-result signal when labels resolve to a taxonomy but no eligible products exist", async () => { + visionClient.detectProductTerms.mockResolvedValue(["sneaker", "footwear"]); + prisma.product.findMany.mockResolvedValue([]); + + const analytics = await service.handleImageSearch( + "+2348012345678", + "image-id", + ); + + expect(analytics.zeroResults).toBe(true); + expect(analytics.taxonomyZeroResult).toMatchObject({ + signal: "WIZZA_SEARCH_ZERO_RESULTS", + channel: "WHATSAPP", + searchMode: "IMAGE", + taxonomySubcategoryLabels: ["Sneakers"], + zeroResults: true, + searchResultCount: 0, + reasonCode: "NO_ELIGIBLE_PRODUCTS", + normalizedQuery: null, + }); + }); + + it("does not record a gap when the vector missed eligible products that exist in the category", async () => { + visionClient.detectProductTerms.mockResolvedValue(["sneaker"]); + vertexClient.isConfigured.mockReturnValue(true); + vertexClient.generateImageEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + // Vector returns no eligible matches (top-N miss) -> products empty, but the + // category actually has eligible supply, so no gap signal must be recorded. + prisma.$queryRaw.mockResolvedValue([]); + prisma.product.findFirst.mockResolvedValue({ id: "existing-sneaker" }); + + const analytics = await service.handleImageSearch( + "+2348012345678", + "image-id", + ); + + expect(analytics.zeroResults).toBe(true); + expect(analytics.taxonomyZeroResult ?? null).toBeNull(); + expect(prisma.product.findFirst).toHaveBeenCalled(); + }); + + it("records a gap only after the category eligibility check confirms zero supply", async () => { + visionClient.detectProductTerms.mockResolvedValue(["sneaker"]); + vertexClient.isConfigured.mockReturnValue(true); + vertexClient.generateImageEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + prisma.$queryRaw.mockResolvedValue([]); + // Both native and sourced category checks confirm no eligible supply. + prisma.product.findFirst.mockResolvedValue(null); + prisma.sourcedProduct.findFirst.mockResolvedValue(null); + + const analytics = await service.handleImageSearch( + "+2348012345678", + "image-id", + ); + + expect(prisma.product.findFirst).toHaveBeenCalled(); + expect(analytics.taxonomyZeroResult).toMatchObject({ + searchMode: "IMAGE", + taxonomySubcategoryLabels: ["Sneakers"], + reasonCode: "NO_ELIGIBLE_PRODUCTS", + }); + }); + + it("does not record a gap when only sourced supply exists in the category", async () => { + visionClient.detectProductTerms.mockResolvedValue(["sneaker"]); + vertexClient.isConfigured.mockReturnValue(true); + vertexClient.generateImageEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + prisma.$queryRaw.mockResolvedValue([]); + prisma.product.findFirst.mockResolvedValue(null); + prisma.sourcedProduct.findFirst.mockResolvedValue({ + id: "sourced-sneaker", + }); + + const analytics = await service.handleImageSearch( + "+2348012345678", + "image-id", + ); + + expect(analytics.taxonomyZeroResult ?? null).toBeNull(); + }); + + it("does not record a signal for images blocked by SafeSearch", async () => { + visionClient.checkSafetyFromBase64.mockResolvedValue( + safeSearchResult({ adult: "VERY_LIKELY" }), + ); + + const analytics = await service.handleImageSearch( + "+2348012345678", + "image-id", + ); + + expect(analytics.errorType).toBe("IMAGE_BLOCKED_UNSAFE"); + expect(analytics.taxonomyZeroResult ?? null).toBeNull(); + expect(visionClient.detectProductTerms).not.toHaveBeenCalled(); + }); + + it("does not record a signal when the image download fails before discovery", async () => { + metaWhatsAppClient.downloadImage.mockResolvedValue(null); + + const analytics = await service.handleImageSearch( + "+2348012345678", + "image-id", + ); + + expect(analytics.errorType).toBe("IMAGE_DOWNLOAD_FAILED"); + expect(analytics.taxonomyZeroResult ?? null).toBeNull(); + }); + + it("does not record a signal when labels resolve to no taxonomy", async () => { + visionClient.detectProductTerms.mockResolvedValue(["zzzqqq", "gibberish"]); + prisma.product.findMany.mockResolvedValue([]); + + const analytics = await service.handleImageSearch( + "+2348012345678", + "image-id", + ); + + expect(analytics.zeroResults).toBe(true); + expect(analytics.taxonomyZeroResult ?? null).toBeNull(); + }); + + it("does not record a signal when eligible products are found", async () => { + visionClient.detectProductTerms.mockResolvedValue(["sneaker"]); + vertexClient.isConfigured.mockReturnValue(true); + vertexClient.generateImageEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + prisma.$queryRaw.mockResolvedValue([{ productId: "p1", similarity: 0.9 }]); + prisma.product.findMany.mockResolvedValue([ + { + id: "p1", + productCode: "TWZ-SNK-1", + name: "Court Classic", + categoryTag: "sneakers", + platformCategory: "Shoes & Footwear", + retailPriceKobo: 1500000n, + pricePerUnitKobo: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + storeProfile: { storeHandle: "ada_store", storeName: "Ada Store" }, + }, + ]); + + const analytics = await service.handleImageSearch( + "+2348012345678", + "image-id", + ); + + expect(analytics.zeroResults).toBe(false); + expect(analytics.taxonomyZeroResult ?? null).toBeNull(); + }); + + it("keeps recorded signal free of image data, OCR query text, and payloads", async () => { + visionClient.detectProductTerms.mockResolvedValue(["power bank"]); + prisma.product.findMany.mockResolvedValue([]); + + const analytics = await service.handleImageSearch( + "+2348012345678", + "image-id", + ); + + const serialized = JSON.stringify(analytics.taxonomyZeroResult); + expect(serialized).not.toMatch( + /base64|image-id|embedding|vector|signedUrl|token|\+234|ocr/i, + ); + expect(analytics.taxonomyZeroResult?.normalizedQuery).toBeNull(); + expect(analytics.taxonomyZeroResult?.taxonomySubcategoryLabels).toContain( + "Power Banks", + ); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-analytics.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-analytics.service.spec.ts new file mode 100644 index 00000000..176edca2 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-analytics.service.spec.ts @@ -0,0 +1,145 @@ +import { Logger } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import type { PrismaService } from "../../prisma/prisma.service"; +import type { RedisService } from "../../redis/redis.service"; +import { WhatsAppAnalyticsService } from "./whatsapp-analytics.service"; + +describe("WhatsAppAnalyticsService", () => { + const prisma = { + whatsAppAnalytics: { + create: jest.fn(), + count: jest.fn(), + }, + }; + const redis = { + incr: jest.fn(), + expire: jest.fn(), + }; + let service: WhatsAppAnalyticsService; + let loggerWarnSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + loggerWarnSpy = jest.spyOn(Logger.prototype, "warn").mockImplementation(); + redis.incr.mockResolvedValue(3); + redis.expire.mockResolvedValue(true); + prisma.whatsAppAnalytics.create.mockResolvedValue({ id: "analytics-1" }); + prisma.whatsAppAnalytics.count.mockResolvedValue(2); + service = new WhatsAppAnalyticsService( + prisma as unknown as PrismaService, + redis as unknown as RedisService, + ); + }); + + afterEach(() => { + loggerWarnSpy.mockRestore(); + }); + + it("records one structured analytics row with Redis conversation turn", async () => { + await service.recordMessage({ + sessionId: "session-1", + phone: "+2348012345678", + userId: "user-1", + messageType: "text", + flowAtStart: "idle", + flowAtEnd: "product_search", + intentClassified: "search_products", + intentSuccessful: true, + searchQuery: "iphone 15", + parsedFilters: { category: "phones" }, + productsRankedCount: 2, + productsShown: ["product-1", "product-2"], + zeroResults: false, + geminiResponseMs: 120, + geminiModel: "gemini-2.5-flash", + }); + + expect(redis.incr).toHaveBeenCalledWith("wa:analytics-turn:+2348012345678"); + expect(redis.expire).toHaveBeenCalledWith( + "wa:analytics-turn:+2348012345678", + 2592000, + ); + expect(prisma.whatsAppAnalytics.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + sessionId: "session-1", + phone: "+2348012345678", + userId: "user-1", + messageType: "text", + conversationTurn: 3, + intentClassified: "search_products", + intentSuccessful: true, + searchQuery: "iphone 15", + productsShown: ["product-1", "product-2"], + productsShownCount: 2, + zeroResults: false, + }), + }); + }); + + it("falls back to a database count when the Redis turn counter fails", async () => { + redis.incr.mockRejectedValue(new Error("redis unavailable")); + + await service.recordMessage({ + sessionId: "session-1", + phone: "+2348012345678", + messageType: "text", + intentClassified: "friendly_fallback", + }); + + expect(prisma.whatsAppAnalytics.count).toHaveBeenCalledWith({ + where: { phone: "+2348012345678" }, + }); + expect(prisma.whatsAppAnalytics.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + conversationTurn: 3, + parsedFilters: Prisma.JsonNull, + }), + }); + expect(loggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "WhatsApp analytics turn counter failed for +234******5678", + ), + ); + }); + + it("keeps the Redis turn when only TTL refresh fails", async () => { + redis.expire.mockRejectedValue(new Error("ttl unavailable")); + + await service.recordMessage({ + sessionId: "session-1", + phone: "+2348012345678", + messageType: "text", + intentClassified: "friendly_fallback", + }); + + expect(prisma.whatsAppAnalytics.count).not.toHaveBeenCalled(); + expect(prisma.whatsAppAnalytics.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + conversationTurn: 3, + }), + }); + expect(loggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "WhatsApp analytics turn TTL refresh failed for +234******5678", + ), + ); + }); + + it("does not throw when analytics persistence fails", async () => { + prisma.whatsAppAnalytics.create.mockRejectedValue(new Error("db down")); + + await expect( + service.recordMessage({ + sessionId: "session-1", + phone: "+2348012345678", + messageType: "text", + }), + ).resolves.toBeUndefined(); + + expect(loggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "WhatsApp analytics write failed for +234******5678", + ), + ); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-analytics.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-analytics.service.ts new file mode 100644 index 00000000..90cd4b76 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-analytics.service.ts @@ -0,0 +1,124 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { PrismaService } from "../../prisma/prisma.service"; +import { RedisService } from "../../redis/redis.service"; +import { maskWhatsAppPhone } from "./whatsapp.utils"; + +const WA_ANALYTICS_TURN_PREFIX = "wa:analytics-turn:"; +const WA_ANALYTICS_TURN_TTL = 30 * 24 * 60 * 60; + +export interface WhatsAppAnalyticsMessageInput { + sessionId: string; + phone: string; + userId?: string | null; + messageType: string; + conversationTurn?: number; + flowAtStart?: string | null; + flowAtEnd?: string | null; + intentClassified?: string | null; + intentSuccessful?: boolean; + imageProvided?: boolean; + searchQuery?: string | null; + parsedFilters?: Prisma.InputJsonValue | null; + categoryInferred?: string | null; + vectorSearchUsed?: boolean; + embeddingModel?: string | null; + productsRankedCount?: number | null; + productsShownCount?: number; + productsShown?: string[]; + productSelected?: string | null; + productOrdered?: string | null; + sessionConverted?: boolean; + zeroResults?: boolean; + dropOffStep?: string | null; + escalatedToHuman?: boolean; + geminiResponseMs?: number | null; + geminiModel?: string | null; + errorType?: string | null; +} + +@Injectable() +export class WhatsAppAnalyticsService { + private readonly logger = new Logger(WhatsAppAnalyticsService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly redisService: RedisService, + ) {} + + async recordMessage(input: WhatsAppAnalyticsMessageInput): Promise { + try { + const conversationTurn = + input.conversationTurn ?? + (await this.nextConversationTurn(input.phone)); + const productsShown = input.productsShown ?? []; + + await this.prisma.whatsAppAnalytics.create({ + data: { + sessionId: input.sessionId, + phone: input.phone, + userId: input.userId ?? null, + messageType: input.messageType, + conversationTurn, + flowAtStart: input.flowAtStart ?? null, + flowAtEnd: input.flowAtEnd ?? null, + intentClassified: input.intentClassified ?? null, + intentSuccessful: input.intentSuccessful ?? false, + imageProvided: input.imageProvided ?? false, + productsShown, + productsShownCount: input.productsShownCount ?? productsShown.length, + productSelected: input.productSelected ?? null, + productOrdered: input.productOrdered ?? null, + searchQuery: input.searchQuery ?? null, + parsedFilters: input.parsedFilters ?? Prisma.JsonNull, + categoryInferred: input.categoryInferred ?? null, + vectorSearchUsed: input.vectorSearchUsed ?? false, + embeddingModel: input.embeddingModel ?? null, + productsRankedCount: input.productsRankedCount ?? null, + zeroResults: input.zeroResults ?? false, + sessionConverted: input.sessionConverted ?? false, + dropOffStep: input.dropOffStep ?? null, + escalatedToHuman: input.escalatedToHuman ?? false, + geminiResponseMs: input.geminiResponseMs ?? null, + geminiModel: input.geminiModel ?? null, + errorType: input.errorType ?? null, + }, + }); + } catch (error) { + this.logger.warn( + `WhatsApp analytics write failed for ${maskWhatsAppPhone(input.phone)}: ${ + error instanceof Error ? error.message : "unknown error" + }`, + ); + } + } + + private async nextConversationTurn(phone: string): Promise { + const key = `${WA_ANALYTICS_TURN_PREFIX}${phone}`; + + try { + const turn = await this.redisService.incr(key); + try { + await this.redisService.expire(key, WA_ANALYTICS_TURN_TTL); + } catch (error) { + this.logger.warn( + `WhatsApp analytics turn TTL refresh failed for ${maskWhatsAppPhone(phone)}: ${ + error instanceof Error ? error.message : "unknown error" + }`, + ); + } + return turn; + } catch (error) { + this.logger.warn( + `WhatsApp analytics turn counter failed for ${maskWhatsAppPhone(phone)}: ${ + error instanceof Error ? error.message : "unknown error" + }`, + ); + } + + const existingCount = await this.prisma.whatsAppAnalytics.count({ + where: { phone }, + }); + return existingCount + 1; + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp-auth-flows.module.ts b/apps/backend/src/channels/whatsapp/whatsapp-auth-flows.module.ts new file mode 100644 index 00000000..a32087af --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-auth-flows.module.ts @@ -0,0 +1,23 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; + +import { EmailModule } from "../../domains/social/email/email.module"; +import { PaystackModule } from "../../integrations/paystack/paystack.module"; +import { PrismaModule } from "../../prisma/prisma.module"; +import { RedisModule } from "../../redis/redis.module"; +import { WhatsAppAuthService } from "./whatsapp-auth.service"; +import { WhatsAppSharedModule } from "./whatsapp-shared.module"; + +@Module({ + imports: [ + ConfigModule, + PrismaModule, + RedisModule, + EmailModule, + PaystackModule, + WhatsAppSharedModule, + ], + providers: [WhatsAppAuthService], + exports: [WhatsAppAuthService], +}) +export class WhatsAppAuthFlowsModule {} diff --git a/apps/backend/src/channels/whatsapp/whatsapp-auth.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-auth.service.spec.ts new file mode 100644 index 00000000..e9736951 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-auth.service.spec.ts @@ -0,0 +1,451 @@ +import { Logger } from "@nestjs/common"; +import type { ConfigService } from "@nestjs/config"; +import type { PrismaService } from "../../prisma/prisma.service"; +import type { RedisService } from "../../redis/redis.service"; +import type { EmailService } from "../../domains/social/email/email.service"; +import { WhatsAppAuthService } from "./whatsapp-auth.service"; +import type { WhatsAppSessionService } from "./whatsapp-session.service"; +import { + LINK_FLOW_TTL, + SessionState, + WA_SESSION_PREFIX, +} from "./whatsapp.constants"; + +describe("WhatsAppAuthService account linking", () => { + interface WhatsAppLinkFixture { + phone: string; + userId: string; + isActive: boolean; + } + + interface UserWhatsAppLinkFixture { + phone: string; + isActive: boolean; + } + + interface UserFixture { + id: string; + firstName: string; + email: string; + phone: string | null; + whatsappLink: UserWhatsAppLinkFixture | null; + } + + interface FindUniqueArgs { + where: { + email?: string; + id?: string; + phone?: string; + }; + } + + const configService = { + get: jest.fn(), + }; + const prisma = { + user: { + findUnique: jest.fn(), + update: jest.fn(), + }, + whatsAppLink: { + findUnique: jest.fn(), + upsert: jest.fn(), + }, + $transaction: jest.fn(), + }; + const redisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + }; + const emailService = { + sendVerificationOTP: jest.fn(), + }; + const whatsappSessionService = { + normalizePhone: jest.fn(), + attachUser: jest.fn(), + }; + + const redisStore = new Map(); + const usersByEmail = new Map(); + const usersById = new Map(); + const linksByPhone = new Map(); + + let service: WhatsAppAuthService; + let loggerLogSpy: jest.SpyInstance; + let loggerErrorSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + redisStore.clear(); + usersByEmail.clear(); + usersById.clear(); + linksByPhone.clear(); + + loggerLogSpy = jest.spyOn(Logger.prototype, "log").mockImplementation(); + loggerErrorSpy = jest.spyOn(Logger.prototype, "error").mockImplementation(); + + configService.get.mockImplementation((key: string) => + key === "app.onboardingOtpSecret" ? "test-secret" : undefined, + ); + whatsappSessionService.normalizePhone.mockImplementation((phone: string) => + phone.startsWith("+") ? phone : `+${phone}`, + ); + redisService.get.mockImplementation( + async (key: string) => redisStore.get(key) ?? null, + ); + redisService.set.mockImplementation( + async (key: string, value: string, _ttl?: number) => { + redisStore.set(key, value); + }, + ); + redisService.del.mockImplementation(async (key: string) => { + redisStore.delete(key); + }); + prisma.user.findUnique.mockImplementation(async (args: FindUniqueArgs) => { + if (args.where.email) { + return usersByEmail.get(args.where.email) ?? null; + } + + if (args.where.id) { + return usersById.get(args.where.id) ?? null; + } + + return null; + }); + prisma.whatsAppLink.findUnique.mockImplementation( + async (args: FindUniqueArgs) => + args.where.phone ? (linksByPhone.get(args.where.phone) ?? null) : null, + ); + prisma.whatsAppLink.upsert.mockResolvedValue({}); + prisma.user.update.mockResolvedValue({}); + prisma.$transaction.mockImplementation(async (queries: unknown[]) => + Promise.all(queries), + ); + emailService.sendVerificationOTP.mockResolvedValue(undefined); + + service = new WhatsAppAuthService( + configService as unknown as ConfigService, + prisma as unknown as PrismaService, + redisService as unknown as RedisService, + emailService as unknown as EmailService, + whatsappSessionService as unknown as WhatsAppSessionService, + ); + }); + + afterEach(() => { + loggerLogSpy.mockRestore(); + loggerErrorSpy.mockRestore(); + }); + + function addUser(overrides: Partial = {}): UserFixture { + const user: UserFixture = { + id: "user-1", + firstName: "Ameen", + email: "ameen@example.com", + phone: "+2348012345678", + whatsappLink: null, + ...overrides, + }; + usersByEmail.set(user.email, user); + usersById.set(user.id, user); + return user; + } + + async function moveToCodeStep(phone = "+2348012345678") { + await service.startLinkingFlow(phone); + const response = await service.handleLinkingFlow( + phone, + "ameen@example.com", + ); + const code = emailService.sendVerificationOTP.mock.calls[0]?.[1]; + return { response, code }; + } + + function readSession(phone = "+2348012345678") { + const raw = redisStore.get(`${WA_SESSION_PREFIX}${phone}`); + return raw ? JSON.parse(raw) : null; + } + + it("requires an onboarding-scoped verification secret", () => { + configService.get.mockImplementation((key: string) => + ["jwt.accessSecret", "JWT_SECRET"].includes(key) + ? "jwt-secret" + : undefined, + ); + + expect( + () => + new WhatsAppAuthService( + configService as unknown as ConfigService, + prisma as unknown as PrismaService, + redisService as unknown as RedisService, + emailService as unknown as EmailService, + whatsappSessionService as unknown as WhatsAppSessionService, + ), + ).toThrow("app.onboardingOtpSecret is required"); + }); + + it("starts a short-lived waiting_for_email linking flow", async () => { + const response = await service.startLinkingFlow("+2348012345678"); + + expect(response).toContain("Reply with the email address"); + expect(redisService.set).toHaveBeenCalledWith( + `${WA_SESSION_PREFIX}+2348012345678`, + expect.stringContaining(SessionState.WAITING_FOR_EMAIL), + LINK_FLOW_TTL, + ); + expect(readSession().state).toBe(SessionState.WAITING_FOR_EMAIL); + }); + + it("detects an active linking flow", async () => { + await service.startLinkingFlow("+2348012345678"); + + await expect(service.hasActiveLinkingFlow("+2348012345678")).resolves.toBe( + true, + ); + }); + + it("asks again when waiting_for_email receives an invalid email", async () => { + await service.startLinkingFlow("+2348012345678"); + + const response = await service.handleLinkingFlow( + "+2348012345678", + "not-an-email", + ); + + expect(response).toContain("valid email address"); + expect(emailService.sendVerificationOTP).not.toHaveBeenCalled(); + expect(readSession().state).toBe(SessionState.WAITING_FOR_EMAIL); + }); + + it("sends a verification code and moves waiting_for_email to waiting_for_code", async () => { + addUser(); + + const { response, code } = await moveToCodeStep(); + const session = readSession(); + + expect(response).toContain("If that email belongs to a Twizrr account"); + expect(response).toContain("verification code"); + expect(emailService.sendVerificationOTP).toHaveBeenCalledWith( + "ameen@example.com", + expect.stringMatching(/^\d{6}$/), + ); + expect(session.state).toBe(SessionState.WAITING_FOR_CODE); + expect(session.data.userId).toBe("user-1"); + expect(session.data.email).toBe("ameen@example.com"); + expect(session.data.codeHash).toMatch(/^[a-f0-9]{64}$/); + expect(session.data.codeHash).not.toBe(code); + expect(session.data.attempts).toBe(0); + }); + + it("uses safe non-enumerating copy for an unknown email and does not create a link", async () => { + await service.startLinkingFlow("+2348012345678"); + + const response = await service.handleLinkingFlow( + "+2348012345678", + "missing@example.com", + ); + + expect(response).toContain("If that email belongs to a Twizrr account"); + expect(response).toContain("create one first"); + expect(response).not.toContain("couldn't find"); + expect(emailService.sendVerificationOTP).not.toHaveBeenCalled(); + expect(prisma.whatsAppLink.upsert).not.toHaveBeenCalled(); + }); + + it("creates WhatsAppLink without storing a second account identity record", async () => { + addUser(); + const { code } = await moveToCodeStep(); + + const response = await service.handleLinkingFlow("+2348012345678", code); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(prisma.whatsAppLink.upsert).toHaveBeenCalledWith({ + where: { userId: "user-1" }, + update: { + phone: "+2348012345678", + isActive: true, + linkedAt: expect.any(Date), + }, + create: { + phone: "+2348012345678", + userId: "user-1", + isActive: true, + }, + }); + expect(response).toBe( + "Your WhatsApp number is now linked to your Twizrr account. You can continue shopping with WIZZA.", + ); + expect(readSession()).toBeNull(); + }); + + it("does not set phoneVerified when linked WhatsApp number differs from User.phone", async () => { + addUser({ phone: "+2348099999999" }); + const { code } = await moveToCodeStep(); + + await service.handleLinkingFlow("+2348012345678", code); + + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("sets phoneVerified when linked WhatsApp number matches User.phone", async () => { + addUser({ phone: "+2348012345678" }); + const { code } = await moveToCodeStep(); + + await service.handleLinkingFlow("+2348012345678", code); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { phoneVerified: true }, + }); + }); + + it("does not create WhatsAppLink when verification code is wrong", async () => { + addUser(); + await moveToCodeStep(); + + const response = await service.handleLinkingFlow( + "+2348012345678", + "000000", + ); + + expect(response).toContain("incorrect"); + expect(prisma.whatsAppLink.upsert).not.toHaveBeenCalled(); + expect(readSession().data.attempts).toBe(1); + }); + + it("clears expired waiting_for_code state safely", async () => { + redisStore.set( + `${WA_SESSION_PREFIX}+2348012345678`, + JSON.stringify({ + state: SessionState.WAITING_FOR_CODE, + data: { + email: "ameen@example.com", + userId: "user-1", + codeHash: "abc", + attempts: 0, + expiresAt: new Date(Date.now() - 1000).toISOString(), + }, + }), + ); + + const response = await service.handleLinkingFlow( + "+2348012345678", + "123456", + ); + + expect(response).toContain("expired"); + expect(readSession()).toBeNull(); + expect(prisma.whatsAppLink.upsert).not.toHaveBeenCalled(); + }); + + it("clears linking state when shopper cancels", async () => { + await service.startLinkingFlow("+2348012345678"); + + const response = await service.handleLinkingFlow( + "+2348012345678", + "cancel", + ); + + expect(response).toContain("cancelled"); + expect(readSession()).toBeNull(); + }); + + it("rejects a WhatsApp number already linked to another user", async () => { + addUser(); + linksByPhone.set("+2348012345678", { + phone: "+2348012345678", + userId: "user-2", + isActive: true, + }); + const { code } = await moveToCodeStep(); + + const response = await service.handleLinkingFlow("+2348012345678", code); + + expect(response).toContain("already linked to another Twizrr account"); + expect(prisma.whatsAppLink.upsert).not.toHaveBeenCalled(); + }); + + it("rejects an inactive WhatsApp number link owned by another user", async () => { + addUser(); + linksByPhone.set("+2348012345678", { + phone: "+2348012345678", + userId: "user-2", + isActive: false, + }); + const { code } = await moveToCodeStep(); + + const response = await service.handleLinkingFlow("+2348012345678", code); + + expect(response).toContain("already linked to another Twizrr account"); + expect(prisma.whatsAppLink.upsert).not.toHaveBeenCalled(); + }); + + it("rejects when the user already has a different active WhatsAppLink", async () => { + addUser({ + whatsappLink: { + phone: "+2348099999999", + isActive: true, + }, + }); + + await service.startLinkingFlow("+2348012345678"); + const response = await service.handleLinkingFlow( + "+2348012345678", + "ameen@example.com", + ); + + expect(response).toContain("manage your linked WhatsApp number"); + expect(emailService.sendVerificationOTP).not.toHaveBeenCalled(); + }); + + it("treats already-linked same-user verification as safe idempotent success", async () => { + addUser({ + whatsappLink: { + phone: "+2348012345678", + isActive: true, + }, + }); + linksByPhone.set("+2348012345678", { + phone: "+2348012345678", + userId: "user-1", + isActive: true, + }); + const { code } = await moveToCodeStep(); + + const response = await service.handleLinkingFlow("+2348012345678", code); + + expect(response).toContain("now linked"); + expect(prisma.whatsAppLink.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1" }, + }), + ); + }); + + it("does not log raw verification codes", async () => { + addUser(); + const { code } = await moveToCodeStep(); + await service.handleLinkingFlow("+2348012345678", code); + + const logOutput = [ + ...loggerLogSpy.mock.calls.flat(), + ...loggerErrorSpy.mock.calls.flat(), + ].join(" "); + expect(logOutput).not.toContain(code); + }); + + it("masks account-linking phone numbers in logs", async () => { + addUser(); + const { code } = await moveToCodeStep(); + await service.handleLinkingFlow("+2348012345678", code); + + const logOutput = [ + ...loggerLogSpy.mock.calls.flat(), + ...loggerErrorSpy.mock.calls.flat(), + ].join(" "); + expect(logOutput).toContain("+234******5678"); + expect(logOutput).not.toContain("+2348012345678"); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-auth.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-auth.service.ts new file mode 100644 index 00000000..a1acc3bf --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-auth.service.ts @@ -0,0 +1,388 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Prisma } from "@prisma/client"; +import { createHmac, randomInt, timingSafeEqual } from "crypto"; +import { PrismaService } from "../../prisma/prisma.service"; +import { RedisService } from "../../redis/redis.service"; +import { EmailService } from "../../domains/social/email/email.service"; +import { + LINK_ACCOUNT_REQUIRED_MESSAGE, + LINK_CANCELLED_MESSAGE, + LINK_CODE_SENT_MESSAGE, + LINK_CODE_TTL, + LINK_CONFLICT_MESSAGE, + LINK_FLOW_TTL, + LINK_SUCCESS_MESSAGE, + LINK_UNKNOWN_EMAIL_MESSAGE, + LINK_USER_HAS_DIFFERENT_PHONE_MESSAGE, + INVALID_LINK_CODE, + LINK_CODE_EXPIRED, + LINKING_WELCOME_MESSAGE, + SessionState, + WA_SESSION_PREFIX, +} from "./whatsapp.constants"; +import { WhatsAppSessionService } from "./whatsapp-session.service"; +import { maskWhatsAppPhone } from "./whatsapp.utils"; + +const MAX_LINK_CODE_ATTEMPTS = 5; + +interface SessionData { + state: SessionState; + data: { + email?: string; + userId?: string; + codeHash?: string; + attempts?: number; + createdAt?: string; + expiresAt?: string; + }; +} + +function maskEmail(email: string): string { + if (!email) return ""; + const [local, domain] = email.split("@"); + if (!domain) return "***"; + if (local.length <= 2) return `${local[0]}***@${domain}`; + return `${local[0]}${local[1]}***@${domain}`; +} + +@Injectable() +export class WhatsAppAuthService { + private readonly logger = new Logger(WhatsAppAuthService.name); + private readonly otpSecret: string; + + constructor( + private configService: ConfigService, + private prisma: PrismaService, + private redisService: RedisService, + private emailService: EmailService, + private whatsappSessionService: WhatsAppSessionService, + ) { + const configuredSecret = this.configService.get( + "app.onboardingOtpSecret", + ); + + if (!configuredSecret?.trim()) { + throw new Error("app.onboardingOtpSecret is required"); + } + + this.otpSecret = configuredSecret; + } + + async resolvePhone(phone: string): Promise { + const normalizedPhone = this.whatsappSessionService.normalizePhone(phone); + + try { + const link = await this.prisma.whatsAppLink.findUnique({ + where: { phone: normalizedPhone }, + select: { userId: true, isActive: true }, + }); + + return link?.isActive ? link.userId : null; + } catch (error) { + this.logger.error( + `Error resolving phone ${maskWhatsAppPhone(phone)}: ${ + error instanceof Error ? error.message : error + }`, + ); + return null; + } + } + + async handleLinkingFlow(phone: string, messageText: string): Promise { + const normalizedPhone = this.whatsappSessionService.normalizePhone(phone); + const sessionKey = `${WA_SESSION_PREFIX}${normalizedPhone}`; + + try { + if (this.isCancelMessage(messageText)) { + await this.redisService.del(sessionKey); + return LINK_CANCELLED_MESSAGE; + } + + const sessionRaw = await this.redisService.get(sessionKey); + + if (!sessionRaw) { + return this.startLinkingFlow(normalizedPhone); + } + + const session: SessionData = JSON.parse(sessionRaw); + + switch (session.state) { + case SessionState.WAITING_FOR_EMAIL: + return this.handleEmailStep(normalizedPhone, messageText, sessionKey); + + case SessionState.WAITING_FOR_CODE: + return this.handleCodeStep( + normalizedPhone, + messageText, + sessionKey, + session, + ); + + default: + await this.redisService.del(sessionKey); + return ( + this.configService.get("whatsapp.welcomeMessage") || + LINKING_WELCOME_MESSAGE + ); + } + } catch (error) { + this.logger.error( + `Error in linking flow for ${maskWhatsAppPhone(phone)}: ${ + error instanceof Error ? error.message : error + }`, + ); + await this.redisService.del(sessionKey); + return ( + this.configService.get("whatsapp.welcomeMessage") || + LINKING_WELCOME_MESSAGE + ); + } + } + + async startLinkingFlow(phone: string): Promise { + const normalizedPhone = this.whatsappSessionService.normalizePhone(phone); + const session: SessionData = { + state: SessionState.WAITING_FOR_EMAIL, + data: { + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + LINK_FLOW_TTL * 1000).toISOString(), + }, + }; + + await this.redisService.set( + `${WA_SESSION_PREFIX}${normalizedPhone}`, + JSON.stringify(session), + LINK_FLOW_TTL, + ); + + return LINK_ACCOUNT_REQUIRED_MESSAGE; + } + + async hasActiveLinkingFlow(phone: string): Promise { + const normalizedPhone = this.whatsappSessionService.normalizePhone(phone); + const sessionRaw = await this.redisService.get( + `${WA_SESSION_PREFIX}${normalizedPhone}`, + ); + + if (!sessionRaw) { + return false; + } + + try { + const session: SessionData = JSON.parse(sessionRaw); + return ( + session.state === SessionState.WAITING_FOR_EMAIL || + session.state === SessionState.WAITING_FOR_CODE + ); + } catch { + return false; + } + } + + private async handleEmailStep( + phone: string, + email: string, + sessionKey: string, + ): Promise { + const emailLower = email.toLowerCase().trim(); + if (!this.isValidEmail(emailLower)) { + return "That doesn't look like a valid email address. Please reply with the email address on your Twizrr account, or type 'cancel' to stop."; + } + + const user = await this.prisma.user.findUnique({ + where: { email: emailLower }, + select: { + id: true, + firstName: true, + email: true, + phone: true, + whatsappLink: { select: { phone: true, isActive: true } }, + }, + }); + + if (!user) { + return LINK_UNKNOWN_EMAIL_MESSAGE; + } + + if (user.whatsappLink?.isActive && user.whatsappLink.phone !== phone) { + return LINK_USER_HAS_DIFFERENT_PHONE_MESSAGE; + } + + const code = this.generateVerificationCode(); + const now = Date.now(); + + try { + await this.emailService.sendVerificationOTP(emailLower, code); + } catch (error) { + this.logger.error( + `Failed to send WhatsApp linking verification email to ${maskEmail(emailLower)}: ${ + error instanceof Error ? error.message : error + }`, + ); + return "I couldn't send the verification email right now. Please try again in a moment."; + } + + const session: SessionData = { + state: SessionState.WAITING_FOR_CODE, + data: { + email: emailLower, + userId: user.id, + codeHash: this.hashVerificationCode(code, emailLower), + attempts: 0, + createdAt: new Date(now).toISOString(), + expiresAt: new Date(now + LINK_CODE_TTL * 1000).toISOString(), + }, + }; + await this.redisService.set( + sessionKey, + JSON.stringify(session), + LINK_CODE_TTL, + ); + + return LINK_CODE_SENT_MESSAGE; + } + + private async handleCodeStep( + phone: string, + codeInput: string, + sessionKey: string, + session: SessionData, + ): Promise { + if ( + !session.data.expiresAt || + Date.parse(session.data.expiresAt) <= Date.now() + ) { + await this.redisService.del(sessionKey); + return LINK_CODE_EXPIRED; + } + + if (!session.data.email || !session.data.userId || !session.data.codeHash) { + await this.redisService.del(sessionKey); + return LINK_CODE_EXPIRED; + } + + const codeClean = codeInput.trim().replace(/\s/g, ""); + if (!/^\d{6}$/.test(codeClean)) { + return "Please reply with the 6-digit verification code, or type 'cancel' to stop."; + } + + if ( + !this.isVerificationCodeMatch( + session.data.codeHash, + codeClean, + session.data.email, + ) + ) { + const attempts = (session.data.attempts ?? 0) + 1; + if (attempts >= MAX_LINK_CODE_ATTEMPTS) { + await this.redisService.del(sessionKey); + return LINK_CODE_EXPIRED; + } + + await this.redisService.set( + sessionKey, + JSON.stringify({ + ...session, + data: { ...session.data, attempts }, + }), + LINK_CODE_TTL, + ); + return INVALID_LINK_CODE; + } + + const user = await this.prisma.user.findUnique({ + where: { id: session.data.userId }, + select: { id: true, phone: true }, + }); + + if (!user) { + await this.redisService.del(sessionKey); + return "I couldn't finish account linking right now. Please start again."; + } + + const existingPhoneLink = await this.prisma.whatsAppLink.findUnique({ + where: { phone }, + select: { userId: true, isActive: true }, + }); + + if (existingPhoneLink && existingPhoneLink.userId !== session.data.userId) { + await this.redisService.del(sessionKey); + return LINK_CONFLICT_MESSAGE; + } + + try { + const transactionOperations: Prisma.PrismaPromise[] = [ + this.prisma.whatsAppLink.upsert({ + where: { userId: session.data.userId }, + update: { phone, isActive: true, linkedAt: new Date() }, + create: { phone, userId: session.data.userId, isActive: true }, + }), + ]; + + if ( + user.phone && + this.whatsappSessionService.normalizePhone(user.phone) === phone + ) { + transactionOperations.push( + this.prisma.user.update({ + where: { id: session.data.userId }, + data: { phoneVerified: true }, + }), + ); + } + + await this.prisma.$transaction(transactionOperations); + } catch (error) { + this.logger.error( + `Failed to create WhatsAppLink for ${maskWhatsAppPhone(phone)}: ${ + error instanceof Error ? error.message : error + }`, + ); + await this.redisService.del(sessionKey); + return "Something went wrong linking your account. Please try again."; + } + + await this.redisService.del(sessionKey); + + this.logger.log( + `WhatsApp linked: phone=${maskWhatsAppPhone(phone)}, userId=${session.data.userId}`, + ); + + return LINK_SUCCESS_MESSAGE; + } + + private isCancelMessage(messageText: string): boolean { + return ["cancel", "stop", "never mind", "nevermind"].includes( + messageText.trim().toLowerCase(), + ); + } + + private isValidEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + } + + private generateVerificationCode(): string { + return randomInt(0, 1_000_000).toString().padStart(6, "0"); + } + + private hashVerificationCode(code: string, email: string): string { + return createHmac("sha256", this.otpSecret) + .update(`whatsapp_link:${email}:${code}`) + .digest("hex"); + } + + private isVerificationCodeMatch( + storedHash: string, + code: string, + email: string, + ): boolean { + const candidateHash = this.hashVerificationCode(code, email); + const stored = Buffer.from(storedHash, "hex"); + const candidate = Buffer.from(candidateHash, "hex"); + + return ( + stored.length === candidate.length && timingSafeEqual(stored, candidate) + ); + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp-delivery-confirmation.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-delivery-confirmation.service.spec.ts new file mode 100644 index 00000000..802e6b82 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-delivery-confirmation.service.spec.ts @@ -0,0 +1,422 @@ +import { BadRequestException } from "@nestjs/common"; +import { OrderStatus } from "@prisma/client"; +import { WhatsAppDeliveryConfirmationService } from "./whatsapp-delivery-confirmation.service"; +import { WhatsAppRetryableActionError } from "./whatsapp-retryable-action.error"; + +describe("WhatsAppDeliveryConfirmationService", () => { + const orderService = { + getByCodeForBuyer: jest.fn(), + listDeliveryConfirmationCandidates: jest.fn(), + confirmDelivery: jest.fn(), + }; + const redisService = { + get: jest.fn(), + getDel: jest.fn(), + set: jest.fn(), + del: jest.fn(), + incrementWithExpiry: jest.fn(), + }; + + let service: WhatsAppDeliveryConfirmationService; + + beforeEach(() => { + jest.clearAllMocks(); + redisService.get.mockResolvedValue(null); + redisService.set.mockResolvedValue(true); + redisService.del.mockResolvedValue(1); + redisService.incrementWithExpiry.mockResolvedValue(1); + service = new WhatsAppDeliveryConfirmationService( + orderService as never, + redisService as never, + ); + }); + + it("scopes an explicitly referenced eligible order before requesting its delivery code", async () => { + orderService.getByCodeForBuyer.mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-123456", + status: OrderStatus.DISPATCHED, + }); + + const result = await service.start("+2348012345678", "buyer-1", { + orderReference: "twz-123456", + }); + + expect(orderService.getByCodeForBuyer).toHaveBeenCalledWith( + "TWZ-123456", + "buyer-1", + ); + expect(redisService.set).toHaveBeenCalledWith( + "wa:delivery-confirmation:+2348012345678", + expect.stringContaining('"step":"enter_code"'), + 600, + ); + expect(result.output).toEqual({ + status: "code_required", + orderReference: "TWZ-123456", + nextStep: "enter_delivery_code", + }); + }); + + it("requires an explicit selection when eligible orders exist", async () => { + orderService.listDeliveryConfirmationCandidates.mockResolvedValue([ + { + id: "order-1", + orderCode: "TWZ-111111", + status: OrderStatus.DISPATCHED, + }, + { + id: "order-2", + orderCode: "TWZ-222222", + status: OrderStatus.IN_TRANSIT, + }, + ]); + + const result = await service.start("+2348012345678", "buyer-1", {}); + + expect(result.output).toEqual({ + status: "selection_required", + ordersShownCount: 2, + nextStep: "select_order", + }); + expect(result.message).toContain("1. TWZ-111111"); + expect(result.message).toContain("2. TWZ-222222"); + expect(redisService.set).toHaveBeenCalledWith( + "wa:delivery-confirmation:+2348012345678", + expect.stringContaining('"step":"select_order"'), + 600, + ); + }); + + it("limits the selectable order list to five entries", async () => { + orderService.listDeliveryConfirmationCandidates.mockResolvedValue( + Array.from({ length: 6 }, (_, index) => ({ + id: `order-${index + 1}`, + orderCode: `TWZ-${String(index + 1).padStart(6, "0")}`, + status: OrderStatus.DISPATCHED, + })), + ); + + const result = await service.start("+2348012345678", "buyer-1", {}); + + expect(result.output).toEqual( + expect.objectContaining({ ordersShownCount: 5 }), + ); + expect(result.message).toContain("5. TWZ-000005"); + expect(result.message).not.toContain("TWZ-000006"); + expect(redisService.set).toHaveBeenCalledWith( + "wa:delivery-confirmation:+2348012345678", + expect.not.stringContaining("TWZ-000006"), + 600, + ); + }); + + it("does not handle numeric input without active delivery state", async () => { + redisService.get.mockResolvedValue(null); + + await expect( + service.handleScopedReply("+2348012345678", "buyer-1", "123456"), + ).resolves.toEqual({ handled: false }); + expect(orderService.confirmDelivery).not.toHaveBeenCalled(); + }); + + it("moves a scoped numeric order selection to the delivery-code step", async () => { + redisService.get + .mockResolvedValueOnce( + JSON.stringify({ + step: "select_order", + userId: "buyer-1", + orders: [ + { id: "order-1", orderReference: "TWZ-111111" }, + { id: "order-2", orderReference: "TWZ-222222" }, + ], + }), + ) + .mockResolvedValueOnce(null); + + const result = await service.handleScopedReply( + "+2348012345678", + "buyer-1", + "2", + ); + + expect(result).toEqual({ + handled: true, + message: + 'Order TWZ-222222 is selected. Reply with its 6-digit delivery code, or type "cancel" to stop.', + }); + expect(redisService.set).toHaveBeenCalledWith( + "wa:delivery-confirmation:+2348012345678", + expect.stringContaining('"orderId":"order-2"'), + 600, + ); + }); + + it("lets non-text messages continue through the normal WhatsApp pipeline", async () => { + redisService.get.mockResolvedValue( + JSON.stringify({ + step: "enter_code", + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + attempts: 0, + }), + ); + + await expect( + service.handleScopedReply("+2348012345678", "buyer-1"), + ).resolves.toEqual({ handled: false }); + expect(redisService.getDel).not.toHaveBeenCalled(); + expect(orderService.confirmDelivery).not.toHaveBeenCalled(); + }); + + it("clears the active flow when the shopper cancels", async () => { + redisService.get.mockResolvedValue( + JSON.stringify({ + step: "select_order", + userId: "buyer-1", + orders: [{ id: "order-1", orderReference: "TWZ-111111" }], + }), + ); + + const result = await service.handleScopedReply( + "+2348012345678", + "buyer-1", + "cancel", + ); + + expect(result).toEqual({ + handled: true, + message: "Delivery confirmation was cancelled.", + }); + expect(redisService.del).toHaveBeenCalledWith( + "wa:delivery-confirmation:+2348012345678", + ); + }); + + it("consumes the scoped state and delegates valid codes to the canonical order service", async () => { + const state = JSON.stringify({ + step: "enter_code", + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + attempts: 0, + }); + redisService.get.mockResolvedValue(state); + redisService.getDel.mockResolvedValue(state); + orderService.confirmDelivery.mockResolvedValue({ + message: "Delivery confirmed", + }); + + const result = await service.handleScopedReply( + "+2348012345678", + "buyer-1", + "654321", + ); + + expect(redisService.getDel).toHaveBeenCalledWith( + "wa:delivery-confirmation:+2348012345678", + ); + expect(orderService.confirmDelivery).toHaveBeenCalledWith( + "buyer-1", + "order-1", + "654321", + ); + expect(result.message).toBe( + "Delivery confirmed for order TWZ-123456. Thank you.", + ); + expect(redisService.del).toHaveBeenCalledWith( + "wa:delivery-confirmation-attempts:buyer-1:order-1", + ); + }); + + it("does not retry an ambiguous confirmation failure after the domain call starts", async () => { + const state = JSON.stringify({ + step: "enter_code", + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + attempts: 0, + }); + redisService.get.mockResolvedValue(state); + redisService.getDel.mockResolvedValue(state); + orderService.confirmDelivery.mockRejectedValue( + new Error("Database timeout"), + ); + + const result = await service.handleScopedReply( + "+2348012345678", + "buyer-1", + "654321", + ); + + expect(result).toEqual({ + handled: true, + message: + "I could not confirm the final status of order TWZ-123456. Check its current order status before trying again.", + }); + expect(redisService.set).not.toHaveBeenCalled(); + expect(redisService.del).toHaveBeenCalledWith( + "wa:delivery-confirmation-attempts:buyer-1:order-1", + ); + }); + + it("does not read delivery state for unrelated messages", async () => { + const result = await service.handleScopedReply( + "+2348012345678", + "buyer-1", + "show me black shoes", + ); + + expect(result).toEqual({ handled: false }); + expect(redisService.get).not.toHaveBeenCalled(); + }); + + it("preserves retry semantics when relevant delivery state cannot be read", async () => { + redisService.get.mockRejectedValueOnce(new Error("Redis unavailable")); + + await expect( + service.handleScopedReply("+2348012345678", "buyer-1", "654321"), + ).rejects.toBeInstanceOf(WhatsAppRetryableActionError); + }); + + it("restores scoped state for a bounded invalid-code retry", async () => { + const state = JSON.stringify({ + step: "enter_code", + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + attempts: 0, + }); + redisService.get.mockResolvedValue(state); + redisService.getDel.mockResolvedValue(state); + orderService.confirmDelivery.mockRejectedValue( + new BadRequestException({ + message: "Invalid delivery code", + code: "OTP_INVALID", + }), + ); + + const result = await service.handleScopedReply( + "+2348012345678", + "buyer-1", + "000000", + ); + + expect(result.message).toContain("2 attempts left"); + expect(redisService.incrementWithExpiry).toHaveBeenCalledWith( + "wa:delivery-confirmation-attempts:buyer-1:order-1", + 172800, + ); + expect(redisService.set).toHaveBeenCalledWith( + "wa:delivery-confirmation:+2348012345678", + expect.stringContaining('"attempts":1'), + 600, + ); + }); + + it("keeps the attempt limit across restarted WhatsApp flows", async () => { + orderService.getByCodeForBuyer.mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-123456", + status: OrderStatus.DISPATCHED, + }); + redisService.get.mockResolvedValue("3"); + + const result = await service.start("+2348012345678", "buyer-1", { + orderReference: "TWZ-123456", + }); + + expect(result.output).toEqual({ + status: "temporarily_unavailable", + orderReference: "TWZ-123456", + }); + expect(redisService.set).not.toHaveBeenCalled(); + }); + + it("does not restore the flow after the final invalid-code attempt", async () => { + const state = JSON.stringify({ + step: "enter_code", + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + attempts: 2, + }); + redisService.get.mockResolvedValue(state); + redisService.getDel.mockResolvedValue(state); + redisService.incrementWithExpiry.mockResolvedValue(3); + orderService.confirmDelivery.mockRejectedValue( + new BadRequestException({ + message: "Invalid delivery code", + code: "OTP_INVALID", + }), + ); + + const result = await service.handleScopedReply( + "+2348012345678", + "buyer-1", + "000000", + ); + + expect(result.message).toContain("Start again"); + expect(redisService.set).not.toHaveBeenCalled(); + }); + + it("fails safely when the atomically consumed state has expired", async () => { + const state = JSON.stringify({ + step: "enter_code", + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + attempts: 0, + }); + redisService.get.mockResolvedValue(state); + redisService.getDel.mockResolvedValue(null); + + const result = await service.handleScopedReply( + "+2348012345678", + "buyer-1", + "654321", + ); + + expect(result.message).toContain("has expired"); + expect(orderService.confirmDelivery).not.toHaveBeenCalled(); + expect(redisService.set).not.toHaveBeenCalled(); + }); + + it("clears state that belongs to a different linked user", async () => { + redisService.get.mockResolvedValue( + JSON.stringify({ + step: "enter_code", + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + attempts: 0, + }), + ); + + const result = await service.handleScopedReply( + "+2348012345678", + "buyer-2", + "654321", + ); + + expect(result.message).toContain("has expired"); + expect(redisService.del).toHaveBeenCalledWith( + "wa:delivery-confirmation:+2348012345678", + ); + expect(orderService.confirmDelivery).not.toHaveBeenCalled(); + }); + + it("does not expose or accept an order owned by another buyer", async () => { + orderService.getByCodeForBuyer.mockResolvedValue(null); + + const result = await service.start("+2348012345678", "buyer-1", { + orderReference: "TWZ-999999", + }); + + expect(result.output.status).toBe("not_found"); + expect(result.message).not.toContain("order-"); + expect(redisService.set).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-delivery-confirmation.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-delivery-confirmation.service.ts new file mode 100644 index 00000000..e26ea788 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-delivery-confirmation.service.ts @@ -0,0 +1,546 @@ +import { + HttpException, + Inject, + Injectable, + Logger, + forwardRef, +} from "@nestjs/common"; +import { OrderStatus } from "@prisma/client"; +import { OrderService } from "../../domains/orders/order/order.service"; +import { RedisService } from "../../redis/redis.service"; +import type { + ShopperAgentToolInputMap, + ShopperAgentToolOutputMap, +} from "./agent/shopper-agent.types"; +import { + DELIVERY_CONFIRMATION_ATTEMPT_TTL, + DELIVERY_CONFIRMATION_MAX_CODE_ATTEMPTS, + DELIVERY_CONFIRMATION_TTL, + WA_DELIVERY_CONFIRMATION_ATTEMPTS_PREFIX, + WA_DELIVERY_CONFIRMATION_PREFIX, +} from "./whatsapp.constants"; +import { WhatsAppRetryableActionError } from "./whatsapp-retryable-action.error"; +import { maskWhatsAppPhone, normalizeWhatsAppPhone } from "./whatsapp.utils"; + +const ORDER_REFERENCE_PATTERN = /^TWZ-\d{6}$/; +const MAX_DELIVERY_CONFIRMATION_CHOICES = 5; + +interface DeliveryOrderReference { + id: string; + orderReference: string; +} + +type PendingDeliveryConfirmation = + | { + step: "select_order"; + userId: string; + orders: DeliveryOrderReference[]; + } + | { + step: "enter_code"; + userId: string; + orderId: string; + orderReference: string; + attempts: number; + }; + +interface DeliveryConfirmationPreparation { + message: string; + output: ShopperAgentToolOutputMap["confirm_delivery"]; +} + +export interface DeliveryConfirmationReplyResult { + handled: boolean; + message?: string; +} + +@Injectable() +export class WhatsAppDeliveryConfirmationService { + private readonly logger = new Logger( + WhatsAppDeliveryConfirmationService.name, + ); + + constructor( + @Inject(forwardRef(() => OrderService)) + private readonly orderService: OrderService, + private readonly redisService: RedisService, + ) {} + + async start( + phone: string, + userId: string, + input: ShopperAgentToolInputMap["confirm_delivery"], + ): Promise { + const requestedReference = this.normalizeOrderReference( + input.orderReference, + ); + + try { + if (requestedReference) { + const order = await this.orderService.getByCodeForBuyer( + requestedReference, + userId, + ); + if (!order) { + return { + message: + "I could not find that order on your linked Twizrr account.", + output: { status: "not_found" }, + }; + } + if (!this.isEligibleStatus(order.status)) { + return { + message: + "That order is not ready for delivery confirmation. Check its current delivery status first.", + output: { + status: "no_eligible_order", + orderReference: order.orderCode, + }, + }; + } + + const attempts = await this.getAttemptCount(userId, order.id); + if (attempts >= DELIVERY_CONFIRMATION_MAX_CODE_ATTEMPTS) { + return this.attemptLimitReached(order.orderCode); + } + + const stored = await this.storeState(phone, { + step: "enter_code", + userId, + orderId: order.id, + orderReference: order.orderCode, + attempts, + }); + if (!stored) { + return this.temporaryFailure(); + } + + return { + message: `Order ${order.orderCode} is selected. Reply with its 6-digit delivery code, or type "cancel" to stop.`, + output: { + status: "code_required", + orderReference: order.orderCode, + nextStep: "enter_delivery_code", + }, + }; + } + + const orders = + await this.orderService.listDeliveryConfirmationCandidates(userId); + if (orders.length === 0) { + return { + message: + "You do not have an order that is ready for delivery confirmation.", + output: { status: "no_eligible_order" }, + }; + } + + const references = orders + .slice(0, MAX_DELIVERY_CONFIRMATION_CHOICES) + .map((order) => ({ + id: order.id, + orderReference: order.orderCode, + })); + const stored = await this.storeState(phone, { + step: "select_order", + userId, + orders: references, + }); + if (!stored) { + return this.temporaryFailure(); + } + + const choices = references + .map((order, index) => `${index + 1}. ${order.orderReference}`) + .join("\n"); + return { + message: `Select the delivered order you want to confirm:\n${choices}\nReply with its number or order code. Type "cancel" to stop.`, + output: { + status: "selection_required", + ordersShownCount: references.length, + nextStep: "select_order", + }, + }; + } catch (error) { + this.logFailure("start", phone, error); + return this.temporaryFailure(); + } + } + + async handleScopedReply( + phone: string, + userId: string | null, + messageText?: string, + ): Promise { + const text = messageText?.trim() ?? ""; + if (!this.isPotentialScopedReply(text)) { + return { handled: false }; + } + + const key = this.stateKey(phone); + let raw: string | null; + try { + raw = await this.redisService.get(key); + } catch (error) { + this.logFailure("state read", phone, error); + throw new WhatsAppRetryableActionError( + "Delivery confirmation state could not be read", + error, + ); + } + + const state = raw ? this.parseState(raw) : null; + if (!state) { + return { handled: false }; + } + if (!userId || state.userId !== userId) { + await this.clearState(key, phone); + return { + handled: true, + message: + "That delivery confirmation has expired. Start the action again.", + }; + } + + if (text.toLowerCase() === "cancel") { + await this.clearState(key, phone); + return { + handled: true, + message: "Delivery confirmation was cancelled.", + }; + } + + if (state.step === "select_order") { + return this.selectOrder(phone, state, text); + } + + return this.submitCode(phone, key, state, text); + } + + private async selectOrder( + phone: string, + state: Extract, + text: string, + ): Promise { + const index = /^\d+$/.test(text) ? Number(text) - 1 : -1; + const normalizedReference = this.normalizeOrderReference(text); + const selected = + (index >= 0 && index < state.orders.length + ? state.orders[index] + : undefined) ?? + state.orders.find( + (order) => order.orderReference === normalizedReference, + ); + + if (!selected) { + return { + handled: true, + message: + 'Choose one of the listed order numbers or reply with its Twizrr order code. Type "cancel" to stop.', + }; + } + + let attempts: number; + try { + attempts = await this.getAttemptCount(state.userId, selected.id); + } catch (error) { + this.logFailure("attempt read", phone, error); + throw new WhatsAppRetryableActionError( + "Delivery confirmation attempts could not be read", + error, + ); + } + if (attempts >= DELIVERY_CONFIRMATION_MAX_CODE_ATTEMPTS) { + return { + handled: true, + message: this.attemptLimitReached(selected.orderReference).message, + }; + } + + const stored = await this.storeState(phone, { + step: "enter_code", + userId: state.userId, + orderId: selected.id, + orderReference: selected.orderReference, + attempts, + }); + if (!stored) { + return { + handled: true, + message: + "I could not save that order selection. Start delivery confirmation again.", + }; + } + + return { + handled: true, + message: `Order ${selected.orderReference} is selected. Reply with its 6-digit delivery code, or type "cancel" to stop.`, + }; + } + + private async submitCode( + phone: string, + key: string, + state: Extract, + text: string, + ): Promise { + if (!/^\d{6}$/.test(text)) { + return { + handled: true, + message: + 'Reply with the 6-digit delivery code for the selected order, or type "cancel" to stop.', + }; + } + + let consumed: string | null; + try { + consumed = await this.redisService.getDel(key); + } catch (error) { + this.logFailure("state consume", phone, error); + throw new WhatsAppRetryableActionError( + "Delivery confirmation state could not be consumed", + error, + ); + } + const consumedState = consumed ? this.parseState(consumed) : null; + if ( + !consumedState || + consumedState.step !== "enter_code" || + consumedState.userId !== state.userId || + consumedState.orderId !== state.orderId + ) { + return { + handled: true, + message: + "That delivery confirmation has expired. Start the action again.", + }; + } + + try { + await this.orderService.confirmDelivery( + consumedState.userId, + consumedState.orderId, + text, + ); + await this.clearAttempts( + consumedState.userId, + consumedState.orderId, + phone, + ); + return { + handled: true, + message: `Delivery confirmed for order ${consumedState.orderReference}. Thank you.`, + }; + } catch (error) { + return this.handleConfirmationError(phone, consumedState, error); + } + } + + private async handleConfirmationError( + phone: string, + state: Extract, + error: unknown, + ): Promise { + const code = this.domainErrorCode(error); + if (code === "OTP_INVALID") { + let attempts: number; + try { + attempts = await this.redisService.incrementWithExpiry( + this.attemptKey(state.userId, state.orderId), + DELIVERY_CONFIRMATION_ATTEMPT_TTL, + ); + } catch (attemptError) { + this.logFailure("attempt increment", phone, attemptError); + await this.storeState(phone, state); + throw new WhatsAppRetryableActionError( + "Delivery confirmation attempts could not be updated", + attemptError, + ); + } + if (attempts >= DELIVERY_CONFIRMATION_MAX_CODE_ATTEMPTS) { + return { + handled: true, + message: + "That delivery code could not be confirmed. Start again when you have the correct code.", + }; + } + await this.storeState(phone, { ...state, attempts }); + return { + handled: true, + message: `That delivery code is incorrect. You have ${DELIVERY_CONFIRMATION_MAX_CODE_ATTEMPTS - attempts} ${DELIVERY_CONFIRMATION_MAX_CODE_ATTEMPTS - attempts === 1 ? "attempt" : "attempts"} left.`, + }; + } + if (code === "OTP_EXPIRED") { + await this.clearAttempts(state.userId, state.orderId, phone); + return { + handled: true, + message: + "That delivery code has expired. Check the order in Twizrr or contact support.", + }; + } + if (error instanceof HttpException && error.getStatus() < 500) { + await this.clearAttempts(state.userId, state.orderId, phone); + return { + handled: true, + message: + "That order can no longer be confirmed from this flow. Check its current order status.", + }; + } + + this.logFailure("confirmation", phone, error); + await this.clearAttempts(state.userId, state.orderId, phone); + return { + handled: true, + message: `I could not confirm the final status of order ${state.orderReference}. Check its current order status before trying again.`, + }; + } + + private isPotentialScopedReply(text: string): boolean { + const normalized = text.trim().toLowerCase(); + return ( + normalized === "cancel" || + /^\d+$/.test(normalized) || + ORDER_REFERENCE_PATTERN.test(normalized.toUpperCase()) + ); + } + + private isEligibleStatus(status: OrderStatus): boolean { + return ( + status === OrderStatus.DISPATCHED || status === OrderStatus.IN_TRANSIT + ); + } + + private normalizeOrderReference(value?: string): string | null { + const match = value?.trim().toUpperCase().match(ORDER_REFERENCE_PATTERN); + return match?.[0] ?? null; + } + + private async storeState( + phone: string, + state: PendingDeliveryConfirmation, + ): Promise { + try { + return await this.redisService.set( + this.stateKey(phone), + JSON.stringify(state), + DELIVERY_CONFIRMATION_TTL, + ); + } catch (error) { + this.logFailure("state write", phone, error); + return false; + } + } + + private parseState(raw: string): PendingDeliveryConfirmation | null { + try { + const value = JSON.parse(raw) as PendingDeliveryConfirmation; + if ( + value?.step === "select_order" && + typeof value.userId === "string" && + Array.isArray(value.orders) && + value.orders.length > 0 && + value.orders.every( + (order) => + typeof order?.id === "string" && + typeof order.orderReference === "string" && + ORDER_REFERENCE_PATTERN.test(order.orderReference), + ) + ) { + return value; + } + if ( + value?.step === "enter_code" && + typeof value.userId === "string" && + typeof value.orderId === "string" && + typeof value.orderReference === "string" && + ORDER_REFERENCE_PATTERN.test(value.orderReference) && + Number.isInteger(value.attempts) && + value.attempts >= 0 && + value.attempts < DELIVERY_CONFIRMATION_MAX_CODE_ATTEMPTS + ) { + return value; + } + return null; + } catch { + return null; + } + } + + private domainErrorCode(error: unknown): string | null { + if (!(error instanceof HttpException)) { + return null; + } + const response = error.getResponse(); + return typeof response === "object" && + response !== null && + "code" in response && + typeof response.code === "string" + ? response.code + : null; + } + + private stateKey(phone: string): string { + return `${WA_DELIVERY_CONFIRMATION_PREFIX}${normalizeWhatsAppPhone(phone)}`; + } + + private attemptKey(userId: string, orderId: string): string { + return `${WA_DELIVERY_CONFIRMATION_ATTEMPTS_PREFIX}${userId}:${orderId}`; + } + + private async getAttemptCount( + userId: string, + orderId: string, + ): Promise { + const raw = await this.redisService.get(this.attemptKey(userId, orderId)); + const attempts = Number(raw); + return Number.isInteger(attempts) && attempts >= 0 ? attempts : 0; + } + + private async clearAttempts( + userId: string, + orderId: string, + phone: string, + ): Promise { + try { + await this.redisService.del(this.attemptKey(userId, orderId)); + } catch (error) { + this.logFailure("attempt clear", phone, error); + } + } + + private async clearState(key: string, phone: string): Promise { + try { + await this.redisService.del(key); + } catch (error) { + this.logFailure("state clear", phone, error); + } + } + + private temporaryFailure(): DeliveryConfirmationPreparation { + return { + message: + "I could not start delivery confirmation right now. Please try again.", + output: { status: "temporarily_unavailable" }, + }; + } + + private attemptLimitReached( + orderReference: string, + ): DeliveryConfirmationPreparation { + return { + message: `Delivery confirmation is temporarily unavailable for order ${orderReference} after too many incorrect code attempts. Check the order in Twizrr or contact support.`, + output: { + status: "temporarily_unavailable", + orderReference, + }, + }; + } + + private logFailure(action: string, phone: string, error: unknown): void { + this.logger.warn( + `WhatsApp delivery confirmation ${action} failed for ${maskWhatsAppPhone(phone)}: ${ + error instanceof Error ? error.message : "unknown error" + }`, + ); + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp-image-safety.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-image-safety.spec.ts new file mode 100644 index 00000000..0cc514be --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-image-safety.spec.ts @@ -0,0 +1,50 @@ +import { SafeSearchLikelihood } from "../../integrations/ai/vision.client"; +import { isBlockedInboundImage } from "./whatsapp-image-safety"; + +const safe = ( + overrides: Partial< + Record<"adult" | "violence" | "racy", SafeSearchLikelihood> + > = {}, +) => ({ + adult: "VERY_UNLIKELY" as SafeSearchLikelihood, + violence: "VERY_UNLIKELY" as SafeSearchLikelihood, + racy: "VERY_UNLIKELY" as SafeSearchLikelihood, + ...overrides, +}); + +describe("whatsapp-image-safety", () => { + it.each([ + ["adult", "VERY_LIKELY"], + ["adult", "LIKELY"], + ["violence", "VERY_LIKELY"], + ["violence", "LIKELY"], + ["racy", "VERY_LIKELY"], + ["racy", "LIKELY"], + ] as const)("blocks when %s is %s", (category, likelihood) => { + expect(isBlockedInboundImage(safe({ [category]: likelihood }))).toBe(true); + }); + + it.each([ + ["adult", "POSSIBLE"], + ["violence", "POSSIBLE"], + ["racy", "POSSIBLE"], + ["adult", "UNLIKELY"], + ["violence", "UNLIKELY"], + ["racy", "UNLIKELY"], + ["adult", "VERY_UNLIKELY"], + ] as const)("does not block when %s is %s", (category, likelihood) => { + expect(isBlockedInboundImage(safe({ [category]: likelihood }))).toBe(false); + }); + + it("does not block a fully safe result", () => { + expect(isBlockedInboundImage(safe())).toBe(false); + }); + + it("blocks when any one category crosses the threshold", () => { + expect( + isBlockedInboundImage( + safe({ adult: "UNLIKELY", violence: "POSSIBLE", racy: "LIKELY" }), + ), + ).toBe(true); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-image-safety.ts b/apps/backend/src/channels/whatsapp/whatsapp-image-safety.ts new file mode 100644 index 00000000..3caa0d63 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-image-safety.ts @@ -0,0 +1,21 @@ +import { + SafeSearchLikelihood, + SafeSearchResult, +} from "../../integrations/ai/vision.client"; + +// Inbound shopper images are screened before any product discovery runs. +// Blocking thresholds mirror the platform upload moderation policy +// (domains/platform/upload/moderation): block on LIKELY or VERY_LIKELY +// for adult, violence, or racy. POSSIBLE and below never block discovery. +const BLOCKING_LIKELIHOODS: ReadonlySet = new Set([ + "LIKELY", + "VERY_LIKELY", +]); + +export function isBlockedInboundImage(safety: SafeSearchResult): boolean { + return ( + BLOCKING_LIKELIHOODS.has(safety.adult) || + BLOCKING_LIKELIHOODS.has(safety.violence) || + BLOCKING_LIKELIHOODS.has(safety.racy) + ); +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts new file mode 100644 index 00000000..d11185ee --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts @@ -0,0 +1,614 @@ +import { ConfigService } from "@nestjs/config"; +import { GeminiClient } from "../../integrations/ai/gemini.client"; +import { + GEMINI_FUNCTION_DECLARATIONS, + MINIMAL_WHATSAPP_SYSTEM_PROMPT, + WHATSAPP_FUNCTION_REGISTRY, +} from "./whatsapp.constants"; +import { WhatsAppIntentService } from "./whatsapp-intent.service"; +import { Logger } from "@nestjs/common"; + +describe("WhatsAppIntentService", () => { + const configService = { + get: jest.fn(), + }; + const geminiClient = { + isConfigured: jest.fn(), + parseFunctionCall: jest.fn(), + generateContent: jest.fn(), + }; + + let service: WhatsAppIntentService; + let loggerLogSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + loggerLogSpy = jest.spyOn(Logger.prototype, "log").mockImplementation(); + configService.get.mockReturnValue("Base WhatsApp prompt."); + geminiClient.isConfigured.mockReturnValue(true); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + }); + + afterEach(() => { + loggerLogSpy.mockRestore(); + }); + + it("uses Gemini function parsing for product action requests", async () => { + geminiClient.parseFunctionCall.mockResolvedValue({ + name: "search_products", + args: { query: "iphone 15" }, + }); + + const intent = await service.parseIntent("I want to buy an iPhone 15"); + + expect(intent).toEqual({ + functionName: "search_products", + params: { query: "iphone 15" }, + }); + expect(geminiClient.parseFunctionCall).toHaveBeenCalledWith({ + message: "I want to buy an iPhone 15", + systemPrompt: expect.stringContaining( + "call one of the provided functions", + ), + functionDeclarations: GEMINI_FUNCTION_DECLARATIONS, + }); + expect( + geminiClient.parseFunctionCall.mock.calls[0][0].systemPrompt, + ).toContain("Base WhatsApp prompt."); + expect( + geminiClient.parseFunctionCall.mock.calls[0][0].systemPrompt, + ).toContain("You use NO emojis - plain text only"); + }); + + it("uses WHATSAPP_SYSTEM_PROMPT when namespaced prompt is missing", async () => { + configService.get.mockImplementation((key: string) => + key === "WHATSAPP_SYSTEM_PROMPT" ? "Raw env prompt fixture." : undefined, + ); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + geminiClient.generateContent.mockResolvedValue( + "Twizrr helps shoppers discover products from Nigerian stores.", + ); + + await service.parseIntent("what is Twizrr?"); + + expect(geminiClient.generateContent.mock.calls[0][1]).toContain( + "Raw env prompt fixture.", + ); + }); + + it("uses the minimal fallback prompt when env prompt is missing", async () => { + configService.get.mockReturnValue(undefined); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + geminiClient.generateContent.mockResolvedValue( + "WIZZA is Twizrr's AI shopping assistant on WhatsApp.", + ); + + await service.parseIntent("who are you?"); + + expect(geminiClient.generateContent.mock.calls[0][1]).toContain( + MINIMAL_WHATSAPP_SYSTEM_PROMPT, + ); + }); + + it("does not log prompt content while reporting prompt presence", () => { + expect(loggerLogSpy).toHaveBeenCalledWith( + "WhatsApp system prompt loaded from env: true", + ); + expect(loggerLogSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Base WhatsApp prompt."), + ); + }); + + it("answers safe Twizrr questions naturally without requiring a function action", async () => { + geminiClient.generateContent.mockResolvedValue( + "Twizrr is a Nigerian social commerce platform where shoppers discover products from stores and shop with protected payment.", + ); + + const intent = await service.parseIntent("what is Twizrr?"); + + expect(intent).toEqual({ + functionName: "natural_answer", + params: { + answer: + "Twizrr is a Nigerian social commerce platform where shoppers discover products from stores and shop with protected payment.", + }, + }); + expect(geminiClient.parseFunctionCall).not.toHaveBeenCalled(); + expect(geminiClient.generateContent).toHaveBeenCalledWith( + "what is Twizrr?", + expect.stringContaining("WIZZA"), + ); + expect(geminiClient.generateContent.mock.calls[0][1]).toContain( + "You use NO emojis - plain text only", + ); + }); + + it("answers safe WIZZA identity questions naturally without requiring a function action", async () => { + geminiClient.generateContent.mockResolvedValue( + "WIZZA is Twizrr's AI shopping assistant on WhatsApp.", + ); + + const intent = await service.parseIntent("what is wizza?"); + + expect(intent).toEqual({ + functionName: "natural_answer", + params: { + answer: "WIZZA is Twizrr's AI shopping assistant on WhatsApp.", + }, + }); + expect(geminiClient.parseFunctionCall).not.toHaveBeenCalled(); + }); + + it("routes general shopping recommendations through real product discovery", async () => { + const intent = await service.parseIntent( + "What should I wear to a wedding?", + ); + + expect(intent).toEqual({ + functionName: "search_products", + params: { + query: "What should I wear to a wedding?", + }, + }); + expect(geminiClient.parseFunctionCall).not.toHaveBeenCalled(); + expect(geminiClient.generateContent).not.toHaveBeenCalled(); + }); + + it("routes contextual comparisons and refinements against active results", async () => { + geminiClient.isConfigured.mockReturnValue(false); + const context = { + lastQuery: "black sneakers", + source: "text" as const, + products: [ + { + rank: 1, + title: "Street Runner", + storeName: "Style Hub", + priceDisplay: "NGN 25,000", + publicProductUrl: "https://twizrr.com/stores/stylehub/p/TWZ-111111", + }, + { + rank: 2, + title: "City Runner", + storeName: "Shoe Room", + priceDisplay: "NGN 30,000", + publicProductUrl: "https://twizrr.com/stores/shoeroom/p/TWZ-222222", + }, + ], + }; + + await expect( + service.parseIntent("compare the first and second", context), + ).resolves.toEqual({ + functionName: "compare_products", + params: { productReferences: ["first", "second"] }, + }); + await expect( + service.parseIntent("show me cheaper options", context), + ).resolves.toEqual({ + functionName: "refine_product_search", + params: { refinement: "show me cheaper options" }, + }); + }); + + it("does not treat standalone budget search as a context refinement", async () => { + geminiClient.isConfigured.mockReturnValue(false); + + await expect(service.parseIntent("find bags under 50k")).resolves.toEqual({ + functionName: "search_products", + params: { query: "find bags under 50k" }, + }); + }); + + it("lets Gemini route a new budget search instead of stale discovery context", async () => { + geminiClient.parseFunctionCall.mockResolvedValue({ + name: "search_products", + args: { query: "bags under 50k" }, + }); + const staleContext = { + lastQuery: "black sneakers", + source: "text" as const, + products: [ + { + rank: 1, + title: "Street Runner", + storeName: "Style Hub", + priceDisplay: "NGN 25,000", + publicProductUrl: "https://twizrr.com/stores/stylehub/p/TWZ-111111", + }, + ], + }; + + await expect( + service.parseIntent("find bags under 50k", staleContext), + ).resolves.toEqual({ + functionName: "search_products", + params: { query: "bags under 50k" }, + }); + expect(geminiClient.parseFunctionCall).toHaveBeenCalled(); + }); + + it("gives Gemini only shopper-safe recent product context", async () => { + geminiClient.parseFunctionCall.mockResolvedValue({ + name: "compare_products", + args: { productReferences: ["first", "second"] }, + }); + const context = { + lastQuery: "phones under 200k", + source: "image" as const, + products: [ + { + rank: 1, + title: "Phone A", + storeName: "Mobile Hub", + priceDisplay: "NGN 190,000", + publicProductUrl: "https://twizrr.com/stores/mobilehub/p/TWZ-111111", + }, + { + rank: 2, + title: "Phone B", + storeName: "Device Shop", + priceDisplay: "NGN 180,000", + publicProductUrl: "https://twizrr.com/stores/deviceshop/p/TWZ-222222", + }, + ], + }; + + await service.parseIntent("Which one has better value?", context); + + const prompt = geminiClient.parseFunctionCall.mock.calls[0][0].systemPrompt; + expect(prompt).toContain("Phone A"); + expect(prompt).toContain("NGN 190,000"); + expect(prompt).toContain( + "https://twizrr.com/stores/mobilehub/p/TWZ-111111", + ); + expect(prompt).not.toMatch( + /physicalStoreId|physicalProductId|sourceCode|dropshipperCostKobo|digitalMarginKobo|embedding|vector|rankingScore/, + ); + }); + + it("answers mild off-topic messages with natural shopping steer-back", async () => { + geminiClient.generateContent.mockResolvedValue( + "You could browse new products, gifts, fashion, or home items on Twizrr.", + ); + + const intent = await service.parseIntent("I'm bored"); + + expect(intent).toEqual({ + functionName: "natural_answer", + params: { + answer: + "You could browse new products, gifts, fashion, or home items on Twizrr.", + }, + }); + expect(geminiClient.parseFunctionCall).not.toHaveBeenCalled(); + }); + + it("steers off-topic sports questions back to shopping naturally", async () => { + geminiClient.generateContent.mockResolvedValue( + "I am mainly here for shopping on Twizrr, but I can help you find football boots, jerseys, or fan gear.", + ); + + const intent = await service.parseIntent("Who won the football match?"); + + expect(intent).toEqual({ + functionName: "natural_answer", + params: { + answer: + "I am mainly here for shopping on Twizrr, but I can help you find football boots, jerseys, or fan gear.", + }, + }); + expect(geminiClient.parseFunctionCall).not.toHaveBeenCalled(); + }); + + it("does not declare broad natural-answer functions as Gemini actions", () => { + expect( + GEMINI_FUNCTION_DECLARATIONS.map((declaration) => declaration.name), + ).not.toContain("answer_twizrr_question"); + }); + + it("keeps Gemini declarations aligned with the function registry metadata", () => { + expect( + GEMINI_FUNCTION_DECLARATIONS.map((declaration) => declaration.name), + ).toEqual(WHATSAPP_FUNCTION_REGISTRY.map((entry) => entry.name)); + for (const entry of WHATSAPP_FUNCTION_REGISTRY) { + expect(entry.category).toMatch( + /^(guest|linked-read|linked-write|confirmation-required)$/, + ); + expect(entry.executorKey).toEqual(expect.any(String)); + expect(entry.executorKey.trim()).not.toHaveLength(0); + expect(entry.safeOutputFields.length).toBeGreaterThan(0); + } + }); + + it("does not overclaim live human handoff in support routing", () => { + const supportDeclaration = GEMINI_FUNCTION_DECLARATIONS.find( + (declaration) => declaration.name === "support_handoff", + ); + + expect(supportDeclaration?.description).toBeDefined(); + expect(supportDeclaration?.description?.toLowerCase()).not.toContain( + "human", + ); + expect(supportDeclaration?.description?.toLowerCase()).not.toContain( + "pause ai", + ); + }); + + it("routes store-management requests to deterministic redirect routing without AI", async () => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + await expect(service.parseIntent("manage my store")).resolves.toEqual({ + functionName: "store_management_redirect", + params: {}, + }); + }); + + it("routes support requests deterministically without AI", async () => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + await expect(service.parseIntent("talk to support")).resolves.toEqual({ + functionName: "support_handoff", + params: {}, + }); + }); + + it.each([ + [ + "dispute status for twz-123456", + "get_dispute_status", + { orderReference: "TWZ-123456" }, + ], + [ + "where is my refund for twz-654321", + "get_refund_status", + { orderReference: "TWZ-654321" }, + ], + ])( + "routes post-purchase status requests through linked reads: %s", + async (text, functionName, params) => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + await expect(service.parseIntent(text)).resolves.toEqual({ + functionName, + params, + }); + }, + ); + + it("asks the dispute tool to collect details when the shopper gives only an action", async () => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + await expect( + service.parseIntent("open a dispute for twz-123456"), + ).resolves.toEqual({ + functionName: "start_dispute", + params: { orderReference: "TWZ-123456" }, + }); + }); + + it("preserves the shopper's factual problem report for dispute review", async () => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + const message = + "Order twz-123456 arrived damaged and the screen is cracked"; + + await expect(service.parseIntent(message)).resolves.toEqual({ + functionName: "start_dispute", + params: { + orderReference: "TWZ-123456", + reason: message, + description: message, + }, + }); + }); + + it("does not route bare menu numbers globally without explicit menu context", async () => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + await expect(service.parseIntent("1")).resolves.toEqual({ + functionName: "friendly_fallback", + params: {}, + }); + await expect(service.parseIntent("2")).resolves.toEqual({ + functionName: "friendly_fallback", + params: {}, + }); + await expect(service.parseIntent("3")).resolves.toEqual({ + functionName: "friendly_fallback", + params: {}, + }); + }); + + it("prioritizes delivery confirmation over generic delivery status fallback", async () => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + await expect(service.parseIntent("confirm delivery")).resolves.toEqual({ + functionName: "confirm_delivery", + params: {}, + }); + }); + + it.each([ + "I received the wrong order", + "I received a damaged order", + "I received my order but one item is missing", + "I did not receive my order", + ])( + "does not treat problem reports as delivery confirmation: %s", + async (text) => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + const intent = await service.parseIntent(text); + + expect(intent.functionName).not.toBe("confirm_delivery"); + }, + ); + + it.each([ + "show me iphone 15", + "find bags under 50k", + "can I get phones here", + ])( + "routes product-like fallback text to product search: %s", + async (text) => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + await expect(service.parseIntent(text)).resolves.toEqual({ + functionName: "search_products", + params: { query: text }, + }); + }, + ); + + it.each([ + ["show my cart", "get_cart"], + ["show my saved delivery addresses", "get_saved_addresses"], + ["list my recent orders", "list_orders"], + ])( + "routes linked shopper read fallback text: %s", + async (text, functionName) => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + await expect(service.parseIntent(text)).resolves.toEqual({ + functionName, + params: {}, + }); + }, + ); + + it.each([ + ["checkout", "start_checkout", {}], + ["proceed to checkout", "start_checkout", {}], + ["pay for my cart", "start_checkout", {}], + ["complete my purchase", "start_checkout", {}], + [ + "add 2 of the first product to my cart", + "add_to_cart", + { productReference: "first", quantity: 2 }, + ], + [ + "change black sneakers quantity to 3", + "update_cart_quantity", + { cartItemReference: "black sneakers", quantity: 3 }, + ], + [ + "remove black sneakers from my cart", + "remove_from_cart", + { cartItemReference: "black sneakers" }, + ], + [ + "use my home delivery address", + "select_delivery_address", + { addressReference: "home" }, + ], + [ + "deliver to my home address", + "select_delivery_address", + { addressReference: "home" }, + ], + ])( + "routes shopper write fallback text through confirmation tools: %s", + async (text, functionName, params) => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + await expect(service.parseIntent(text)).resolves.toEqual({ + functionName, + params, + }); + }, + ); + + it("does not treat selecting a discovery result as checkout", async () => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + const result = await service.parseIntent("buy the second one"); + + expect(result.functionName).not.toBe("start_checkout"); + }); + + it("does not interpret general delivery wording as saved-address selection", async () => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + const result = await service.parseIntent( + "deliver to my place in Ikeja by 5pm", + ); + + expect(result.functionName).not.toBe("select_delivery_address"); + }); + + it("preserves a public order code when routing delivery status", async () => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + await expect( + service.parseIntent("delivery status for twz-123456"), + ).resolves.toEqual({ + functionName: "get_delivery_status", + params: { orderReference: "TWZ-123456" }, + }); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts new file mode 100644 index 00000000..78b13f0e --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts @@ -0,0 +1,721 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + GEMINI_FUNCTION_DECLARATIONS, + isWhatsAppCartQuantity, + MINIMAL_WHATSAPP_SYSTEM_PROMPT, +} from "./whatsapp.constants"; +import { GeminiClient } from "../../integrations/ai/gemini.client"; +import type { ShopperDiscoveryContext } from "./agent/shopper-agent-discovery-context.types"; + +export interface ParsedIntent { + functionName: string; + params: Record; +} + +const FUNCTION_CALL_PROMPT = + "For action requests, call one of the provided functions. Do not answer product, account, order, payment, delivery, support, or store-management actions directly. You use NO emojis - plain text only."; + +const NATURAL_ANSWER_PROMPT = + "Answer safe general questions about Twizrr, WIZZA, product search, image search, account linking, delivery concepts, twizrr Buyer Protection, general shopping advice, or mild off-topic messages that can be gently steered back to shopping. Do not answer news, sports, entertainment, politics, medical, legal, financial, account-specific, order-specific, payment, checkout, cart, wishlist, refund, dispute, delivery confirmation, store-management, private data, or unsupported capability requests directly. For off-topic messages, briefly say WIZZA is mainly for shopping on Twizrr and offer a relevant shopping angle. You use NO emojis - plain text only. Use no markdown, no bullets, and keep the answer under 80 words."; + +/** + * AI-powered intent parsing using Gemini function calling. + * + * Priority: + * 1. Greetings/menu/help route directly to show_menu. + * 2. Store-management requests route to deterministic redirect. + * 3. Safe general Twizrr/WIZZA questions can use a natural answer. + * 4. Action requests go through Gemini function routing. + * 5. AI failures fall back to keyword routing. + */ +@Injectable() +export class WhatsAppIntentService { + private readonly logger = new Logger(WhatsAppIntentService.name); + private readonly privatePromptConfigured: boolean; + + constructor( + private configService: ConfigService, + private geminiClient: GeminiClient, + ) { + this.privatePromptConfigured = Boolean(this.getPrivateSystemPrompt()); + this.logger.log( + `WhatsApp system prompt loaded from env: ${this.privatePromptConfigured}`, + ); + + if (!this.geminiClient.isConfigured()) { + this.logger.warn( + "Gemini API key not configured - AI intent parsing disabled, keyword shortcuts still work", + ); + } + } + + async parseIntent( + messageText: string, + discoveryContext: ShopperDiscoveryContext | null = null, + ): Promise { + const text = messageText.trim(); + + const lower = text.toLowerCase(); + if (["menu", "help", "start"].includes(lower)) { + this.logger.log("Keyword shortcut routed to show_menu"); + return { functionName: "show_menu", params: {} }; + } + + if (this.isStoreManagementRequest(lower)) { + return { functionName: "store_management_redirect", params: {} }; + } + + if (this.isSupportRequest(lower)) { + return { functionName: "support_handoff", params: {} }; + } + + if (this.isGeneralShoppingAdviceQuestion(lower)) { + return { functionName: "search_products", params: { query: text } }; + } + + if (this.isSafeGeneralQuestion(lower)) { + return this.getNaturalAnswer(text); + } + + if (this.geminiClient.isConfigured()) { + try { + this.logger.log("Parsing WhatsApp intent with Gemini"); + const intent = await this.parseWithGemini(text, discoveryContext); + this.logger.log(`Gemini returned intent: ${intent.functionName}`); + return intent; + } catch (error) { + this.logger.error( + `Gemini intent parsing failed: ${error instanceof Error ? error.message : error}`, + ); + return this.basicKeywordMatch(text, discoveryContext); + } + } + + this.logger.log("No AI available, trying keyword intent match"); + return this.basicKeywordMatch(text, discoveryContext); + } + + private basicKeywordMatch( + text: string, + discoveryContext: ShopperDiscoveryContext | null = null, + ): ParsedIntent { + const lower = text.toLowerCase(); + + if (this.isStoreManagementRequest(lower)) { + return { functionName: "store_management_redirect", params: {} }; + } + + if (this.isSupportRequest(lower)) { + return { functionName: "support_handoff", params: {} }; + } + + const orderReference = this.extractOrderReference(text); + + if (this.isDeliveryConfirmationRequest(lower)) { + return { + functionName: "confirm_delivery", + params: orderReference ? { orderReference } : {}, + }; + } + + if (this.isDisputeStatusRequest(lower)) { + return { + functionName: "get_dispute_status", + params: orderReference ? { orderReference } : {}, + }; + } + + if (this.isRefundStatusRequest(lower)) { + return { + functionName: "get_refund_status", + params: orderReference ? { orderReference } : {}, + }; + } + + if (this.isDisputeStartRequest(lower)) { + const details = text.trim(); + return { + functionName: "start_dispute", + params: { + ...(orderReference ? { orderReference } : {}), + ...(this.hasProblemDetails(lower) + ? { + reason: details.slice(0, 120), + description: details.slice(0, 2000), + } + : {}), + }, + }; + } + + if (this.isCheckoutHandoffRequest(lower)) { + return { functionName: "start_checkout", params: {} }; + } + + const addToCart = this.matchAddToCart(text); + if (addToCart) { + return { functionName: "add_to_cart", params: addToCart }; + } + + const quantityUpdate = this.matchCartQuantityUpdate(text); + if (quantityUpdate) { + return { + functionName: "update_cart_quantity", + params: quantityUpdate, + }; + } + + const cartRemoval = this.matchCartRemoval(text); + if (cartRemoval) { + return { functionName: "remove_from_cart", params: cartRemoval }; + } + + const addressSelection = this.matchAddressSelection(text); + if (addressSelection) { + return { + functionName: "select_delivery_address", + params: addressSelection, + }; + } + + if ( + lower.includes("my cart") || + lower.includes("cart contents") || + lower.includes("what is in the cart") || + lower.includes("what's in the cart") + ) { + return { functionName: "get_cart", params: {} }; + } + + if ( + lower.includes("saved address") || + lower.includes("delivery addresses") + ) { + return { functionName: "get_saved_addresses", params: {} }; + } + + if ( + lower.includes("order history") || + lower.includes("recent orders") || + lower.includes("my orders") + ) { + return { functionName: "list_orders", params: {} }; + } + + if ( + lower.includes("delivery status") || + lower.includes("delivery progress") || + lower.includes("shipment") + ) { + return { + functionName: "get_delivery_status", + params: orderReference ? { orderReference } : {}, + }; + } + + const contextualIntent = this.getContextualDiscoveryIntent( + text, + discoveryContext, + ); + if (contextualIntent) { + return contextualIntent; + } + + if ( + lower.includes("track") || + lower.includes("where is my order") || + lower.includes("order status") || + (lower.includes("delivery") && lower.includes("order")) + ) { + return { + functionName: "get_order_status", + params: orderReference ? { orderReference } : {}, + }; + } + + if ( + lower.includes("buy") || + lower.includes("do you have") || + lower.includes("can i get") || + lower.includes("can i order") || + lower.includes("search") || + lower.includes("find") || + lower.includes("product") || + lower.includes("show me") + ) { + return { functionName: "search_products", params: { query: text } }; + } + + if (this.isGeneralShoppingAdviceQuestion(lower)) { + return { functionName: "search_products", params: { query: text } }; + } + + if (this.isSafeGeneralQuestion(lower)) { + return { + functionName: "natural_answer", + params: { + answer: + "WIZZA can help with Twizrr shopping questions, product search, image search, buyer protection, and account linking. Account and order actions need a linked twizrr account.", + }, + }; + } + + if ( + ["hi", "hello", "hey", "good morning", "good evening"].includes(lower) + ) { + return { functionName: "show_menu", params: {} }; + } + + return { functionName: "friendly_fallback", params: {} }; + } + + private extractOrderReference(text: string): string | null { + return text.match(/\bTWZ-\d{6}\b/i)?.[0]?.toUpperCase() ?? null; + } + + private matchAddToCart( + text: string, + ): { productReference?: string; quantity: number } | null { + const match = + /\b(?:add|put)\s+(?:(\d{1,2})\s*(?:x|of)\s+)?(.+?)\s+(?:to|in)\s+(?:my\s+)?cart\b/i.exec( + text, + ); + if (!match) { + return null; + } + + const quantity = match[1] ? Number(match[1]) : 1; + if (!isWhatsAppCartQuantity(quantity)) { + return null; + } + + const productReference = this.cleanActionReference(match[2]); + return { + ...(productReference ? { productReference } : {}), + quantity, + }; + } + + private matchCartQuantityUpdate( + text: string, + ): { cartItemReference?: string; quantity: number } | null { + const match = + /\b(?:set|change|update)\s+(.+?)\s+(?:quantity|qty)\s+(?:to\s+)?(\d{1,2})\b/i.exec( + text, + ) ?? + /\b(?:set|change|update)\s+(?:the\s+)?(?:quantity|qty)\s+(?:of\s+)?(.+?)\s+to\s+(\d{1,2})\b/i.exec( + text, + ); + if (!match) { + return null; + } + + const quantity = Number(match[2]); + if (!isWhatsAppCartQuantity(quantity)) { + return null; + } + + const cartItemReference = this.cleanActionReference(match[1]); + return { + ...(cartItemReference ? { cartItemReference } : {}), + quantity, + }; + } + + private matchCartRemoval( + text: string, + ): { cartItemReference?: string } | null { + const match = /\b(?:remove|delete)\s+(.+?)\s+from\s+(?:my\s+)?cart\b/i.exec( + text, + ); + if (!match) { + return null; + } + + const cartItemReference = this.cleanActionReference(match[1]); + return cartItemReference ? { cartItemReference } : {}; + } + + private matchAddressSelection( + text: string, + ): { addressReference?: string } | null { + const match = + /\b(?:use|select|choose)\s+(.+?)(?:\s+delivery)?\s+address\b/i.exec( + text, + ) ?? /\bdeliver\s+to\s+(.+?)\s+(?:delivery\s+)?address\s*$/i.exec(text); + if (!match) { + return null; + } + + const addressReference = this.cleanActionReference(match[1]); + return addressReference ? { addressReference } : {}; + } + + private cleanActionReference(value?: string): string | undefined { + if (!value) { + return undefined; + } + + const normalized = value + .replace(/\b(?:the|my|item|product)\b/gi, " ") + .replace(/\s+/g, " ") + .trim(); + return /^(?:it|this|that)$/i.test(normalized) + ? undefined + : normalized || undefined; + } + + private async parseWithGemini( + messageText: string, + discoveryContext: ShopperDiscoveryContext | null, + ): Promise { + const systemPrompt = this.getSystemPrompt(); + const contextPrompt = this.buildDiscoveryContextPrompt(discoveryContext); + const functionCallingSystemPrompt = systemPrompt + ? `${systemPrompt}\n\n${FUNCTION_CALL_PROMPT}${contextPrompt}` + : `${FUNCTION_CALL_PROMPT}${contextPrompt}`; + + const functionCall = await this.geminiClient.parseFunctionCall({ + message: messageText, + systemPrompt: functionCallingSystemPrompt, + functionDeclarations: GEMINI_FUNCTION_DECLARATIONS, + }); + + if (!functionCall) { + this.logger.warn("Gemini returned no function call"); + return { functionName: "friendly_fallback", params: {} }; + } + + return { + functionName: functionCall.name, + params: functionCall.args, + }; + } + + private getSystemPrompt(): string { + return this.getPrivateSystemPrompt() || MINIMAL_WHATSAPP_SYSTEM_PROMPT; + } + + private async getNaturalAnswer(messageText: string): Promise { + if (!this.geminiClient.isConfigured()) { + return this.basicKeywordMatch(messageText); + } + + try { + const answer = await this.geminiClient.generateContent( + messageText, + `${this.getSystemPrompt()}\n\n${NATURAL_ANSWER_PROMPT}`, + ); + + return { + functionName: "natural_answer", + params: { answer }, + }; + } catch (error) { + this.logger.warn( + `Gemini natural answer failed: ${ + error instanceof Error ? error.message : error + }`, + ); + return this.basicKeywordMatch(messageText); + } + } + + private isSafeGeneralQuestion(lower: string): boolean { + if ( + this.isProtectedActionRequest(lower) || + this.isStoreManagementRequest(lower) + ) { + return false; + } + + const safeTopicPhrases = [ + "what is twizrr", + "what's twizrr", + "what is wizza", + "what's wizza", + "who are you", + "buyer protection", + "protected payment", + "send a picture", + "send a photo", + "search by picture", + "search by image", + "image search", + "how do i search", + "how to search", + "find products", + "account linking", + "link my account", + "delivery works", + ]; + + return ( + safeTopicPhrases.some((phrase) => lower.includes(phrase)) || + this.isMildOffTopicSteerBack(lower) + ); + } + + private getContextualDiscoveryIntent( + messageText: string, + discoveryContext: ShopperDiscoveryContext | null, + ): ParsedIntent | null { + if (!discoveryContext?.products.length) { + return null; + } + + const lower = messageText.toLowerCase(); + if ( + /\b(?:compare|difference between|which (?:one )?is better)\b/.test(lower) + ) { + return { + functionName: "compare_products", + params: { + productReferences: this.extractProductReferences(messageText), + }, + }; + } + + const refinementPhrases = [ + "similar", + "cheaper", + "less expensive", + "more affordable", + "in black", + "in white", + "in red", + "in blue", + "near me", + "nearby", + "under ", + "below ", + "budget ", + "show another", + "show me another", + ]; + const isRefinement = refinementPhrases.some((phrase) => + lower.includes(phrase), + ); + if (!isRefinement) { + return null; + } + + const references = this.extractProductReferences(messageText); + return { + functionName: "refine_product_search", + params: { + refinement: messageText, + ...(references[0] ? { productReference: references[0] } : {}), + }, + }; + } + + private extractProductReferences(messageText: string): string[] { + const references = messageText.match( + /\b(?:first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth|number\s+(?:10|[1-9])|option\s+(?:10|[1-9]))\b/gi, + ); + return [ + ...new Set((references ?? []).map((value) => value.toLowerCase())), + ].slice(0, 3); + } + + private buildDiscoveryContextPrompt( + context: ShopperDiscoveryContext | null, + ): string { + if (!context?.products.length) { + return "\n\nThere is no active product result context. Do not invent or infer displayed products."; + } + + const products = context.products + .slice(0, 5) + .map((product) => { + const fields = [ + `${product.rank}. ${product.title}`, + `store: ${product.storeName}`, + product.priceDisplay ? `price: ${product.priceDisplay}` : null, + product.publicProductUrl + ? `public URL: ${product.publicProductUrl}` + : null, + ].filter((field): field is string => Boolean(field)); + return fields.join(" | "); + }) + .join("\n"); + const lastQuery = context.lastQuery + ? `Last search: ${context.lastQuery}\n` + : ""; + + return `\n\nActive shopper-safe product context (${context.source}):\n${lastQuery}${products}\nUse refine_product_search for follow-up preferences and compare_products for comparisons. References must point only to these displayed results. Never invent prices, stock, product identifiers, or private store data.`; + } + + private isGeneralShoppingAdviceQuestion(lower: string): boolean { + return [ + "what should i wear", + "what can i wear", + "what should i buy", + "gift ideas", + "outfit", + "wedding", + "party", + "date night", + "fashion advice", + "style advice", + ].some((phrase) => lower.includes(phrase)); + } + + private isMildOffTopicSteerBack(lower: string): boolean { + return [ + "i'm bored", + "im bored", + "i am bored", + "bored", + "football match", + "who won", + ].some((phrase) => lower.includes(phrase)); + } + + private isProtectedActionRequest(lower: string): boolean { + return [ + "track my order", + "where is my order", + "order status", + "confirm delivery", + "delivery code", + "checkout", + "pay", + "payment", + "cart", + "wishlist", + "save this", + "saved products", + "refund", + "dispute", + "profile", + "order history", + ].some((phrase) => lower.includes(phrase)); + } + + private isCheckoutHandoffRequest(lower: string): boolean { + return [ + /\bcheck\s*out\b/, + /\bcheckout\b/, + /\bproceed\s+to\s+(?:secure\s+)?checkout\b/, + /\b(?:pay|checkout)\s+(?:for\s+)?my\s+cart\b/, + /\bcomplete\s+(?:my\s+)?(?:purchase|checkout)\b/, + ].some((pattern) => pattern.test(lower)); + } + + private isSupportRequest(lower: string): boolean { + return [ + "support", + "customer care", + "agent", + "talk to someone", + "talk to a person", + "talk to human", + "talk to support", + "help from support", + ].some((phrase) => lower.includes(phrase)); + } + + private isDeliveryConfirmationRequest(lower: string): boolean { + if (this.hasProblemReportKeyword(lower)) { + return false; + } + + return ( + lower.includes("confirm delivery") || + lower.includes("delivery code") || + (lower.includes("confirm") && lower.includes("delivery")) || + /\breceived( my| the)? order\b/.test(lower) || + /\breceived.*\border\b/.test(lower) + ); + } + + private isDisputeStatusRequest(lower: string): boolean { + return [ + /\bdispute\s+status\b/, + /\b(?:my|the)\s+dispute\s+status\b/, + /\bstatus\s+of\s+(?:my|the)\s+dispute\b/, + /\btrack\s+(?:my|the)\s+dispute\b/, + /\bwhat(?:'s| is)\s+happening\s+with\s+(?:my|the)\s+dispute\b/, + ].some((pattern) => pattern.test(lower)); + } + + private isRefundStatusRequest(lower: string): boolean { + return [ + /\b(?:my|the)\s+refund\s+status\b/, + /\bstatus\s+of\s+(?:my|the)\s+refund\b/, + /\btrack\s+(?:my|the)\s+refund\b/, + /\bwhere\s+is\s+(?:my|the)\s+refund\b/, + /\bhas\s+(?:my|the)\s+refund\s+been\s+(?:sent|processed|completed)\b/, + ].some((pattern) => pattern.test(lower)); + } + + private isDisputeStartRequest(lower: string): boolean { + return ( + /\b(?:open|start|raise|file)\s+(?:a\s+)?dispute\b/.test(lower) || + /\b(?:i\s+)?want\s+(?:a\s+)?refund\b/.test(lower) || + (this.hasProblemDetails(lower) && + /\b(?:order|purchase|item|product|delivery)\b/.test(lower)) + ); + } + + private hasProblemDetails(lower: string): boolean { + return [ + "wrong item", + "incorrect item", + "missing item", + "damaged", + "broken", + "defective", + "not as described", + "never arrived", + "did not arrive", + "didn't arrive", + "incomplete order", + ].some((phrase) => lower.includes(phrase)); + } + + private hasProblemReportKeyword(lower: string): boolean { + return [ + "wrong", + "incorrect", + "missing", + "damaged", + "broken", + "not", + "refund", + "dispute", + "complaint", + "problem", + "issue", + ].some((keyword) => lower.includes(keyword)); + } + + private isStoreManagementRequest(lower: string): boolean { + return [ + "open a store", + "manage my store", + "add product", + "add a product", + "list product", + "list a product", + "check my sales", + "store dashboard", + "store owner", + "seller order", + "inventory", + "payout", + "kyb", + ].some((phrase) => lower.includes(phrase)); + } + + private getPrivateSystemPrompt(): string { + return ( + this.configService.get("whatsapp.systemPrompt") || + this.configService.get("WHATSAPP_SYSTEM_PROMPT") || + "" + ); + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp-interactive.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-interactive.service.spec.ts new file mode 100644 index 00000000..312c25d1 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-interactive.service.spec.ts @@ -0,0 +1,35 @@ +import { Logger } from "@nestjs/common"; +import { WhatsAppInteractiveService } from "./whatsapp-interactive.service"; + +describe("WhatsAppInteractiveService logging", () => { + const metaWhatsAppClient = { + sendMessage: jest.fn(), + }; + + let service: WhatsAppInteractiveService; + let loggerErrorSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + loggerErrorSpy = jest.spyOn(Logger.prototype, "error").mockImplementation(); + service = new WhatsAppInteractiveService(metaWhatsAppClient as never); + }); + + afterEach(() => { + loggerErrorSpy.mockRestore(); + }); + + it("masks outbound recipient phone numbers in send error logs", async () => { + metaWhatsAppClient.sendMessage.mockRejectedValue( + new Error("Meta rejected recipient +2348012345678"), + ); + + await expect( + service.sendTextMessage("+2348012345678", "Hello from WIZZA"), + ).rejects.toThrow("Meta rejected recipient +234******5678"); + + const logOutput = loggerErrorSpy.mock.calls.flat().join(" "); + expect(logOutput).toContain("+234******5678"); + expect(logOutput).not.toContain("+2348012345678"); + }); +}); diff --git a/apps/backend/src/modules/whatsapp/whatsapp-interactive.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-interactive.service.ts similarity index 75% rename from apps/backend/src/modules/whatsapp/whatsapp-interactive.service.ts rename to apps/backend/src/channels/whatsapp/whatsapp-interactive.service.ts index 32e1497a..f3719fb8 100644 --- a/apps/backend/src/modules/whatsapp/whatsapp-interactive.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-interactive.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { META_API_VERSION } from "./whatsapp.constants"; +import { MetaWhatsAppClient } from "../../integrations/meta-whatsapp/meta-whatsapp.client"; +import { maskWhatsAppPhone } from "./whatsapp.utils"; /** * WhatsApp Interactive Message Service @@ -11,24 +11,8 @@ import { META_API_VERSION } from "./whatsapp.constants"; @Injectable() export class WhatsAppInteractiveService { private readonly logger = new Logger(WhatsAppInteractiveService.name); - private readonly phoneNumberId: string; - private readonly accessToken: string; - private readonly apiUrl: string; - constructor(private configService: ConfigService) { - this.phoneNumberId = - this.configService.get("whatsapp.phoneNumberId") || ""; - this.accessToken = - this.configService.get("whatsapp.accessToken") || ""; - - if (!this.phoneNumberId || !this.accessToken) { - this.logger.warn( - "WhatsApp configuration missing! WHATSAPP_PHONE_NUMBER_ID and WHATSAPP_ACCESS_TOKEN are required for WhatsApp features to work.", - ); - } - - this.apiUrl = `https://graph.facebook.com/${META_API_VERSION}/${this.phoneNumberId}/messages`; - } + constructor(private metaWhatsAppClient: MetaWhatsAppClient) {} // ----------------------------------------------------------------------- // Send a plain text message @@ -215,38 +199,19 @@ export class WhatsAppInteractiveService { // Internal: Call Meta Cloud API // ----------------------------------------------------------------------- private async callMetaApi(phone: string, payload: any): Promise { - const toPhone = phone.startsWith("+") ? phone.slice(1) : phone; - payload.to = toPhone; - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10000); - try { - const response = await fetch(this.apiUrl, { - method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - signal: controller.signal as RequestInit["signal"], - }); - - clearTimeout(timeout); - - if (!response.ok) { - const errorBody = await response.text(); - this.logger.error( - `Meta API error (${response.status}) for ${phone}: ${errorBody}`, - ); - throw new Error(`Meta API error: ${response.status} ${errorBody}`); - } + await this.metaWhatsAppClient.sendMessage({ ...payload, to: phone }); } catch (error) { - clearTimeout(timeout); + const safeMessage = this.safeErrorMessage(error); this.logger.error( - `Failed to send WhatsApp message to ${phone}: ${error instanceof Error ? error.message : error}`, + `Failed to send WhatsApp message to ${maskWhatsAppPhone(phone)}: ${safeMessage}`, ); - throw error; + throw new Error(safeMessage); } } + + private safeErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.replace(/\+?\d{8,15}/g, (match) => maskWhatsAppPhone(match)); + } } diff --git a/apps/backend/src/modules/whatsapp/whatsapp-logger.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-logger.service.ts similarity index 86% rename from apps/backend/src/modules/whatsapp/whatsapp-logger.service.ts rename to apps/backend/src/channels/whatsapp/whatsapp-logger.service.ts index e01169fd..3209f1a4 100644 --- a/apps/backend/src/modules/whatsapp/whatsapp-logger.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-logger.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; import { RedisService } from "../../redis/redis.service"; +import { maskWhatsAppPhone } from "./whatsapp.utils"; /** * Specialized logger for WhatsApp-specific metrics and observability. @@ -36,21 +37,13 @@ export class WhatsAppLoggerService { async logSessionEvent(type: string, userId: string, event: string) { this.logger.log( - `Session [${type}] for ${this.maskPhone(userId)}: ${event}`, + `Session [${type}] for ${maskWhatsAppPhone(userId)}: ${event}`, ); // Optional: store session drop-off points in Redis const key = `${this.METRICS_PREFIX}sessions:${type}:dropoffs`; await this.redis.hIncrBy(key, event, 1); } - private maskPhone(phone: any): string { - if (phone == null || typeof phone !== "string") { - return "****"; - } - if (phone.length <= 4) return "****"; - return `****${phone.slice(-4)}`; - } - async getMetrics(category: string, subCategory: string) { const key = `${this.METRICS_PREFIX}${category}:${subCategory}`; return this.redis.hGetAll(key); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-post-purchase.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-post-purchase.service.spec.ts new file mode 100644 index 00000000..a3974c20 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-post-purchase.service.spec.ts @@ -0,0 +1,278 @@ +import { ConflictException } from "@nestjs/common"; +import { WhatsAppPostPurchaseService } from "./whatsapp-post-purchase.service"; + +describe("WhatsAppPostPurchaseService", () => { + const orderService = { + getByCodeForBuyer: jest.fn(), + getLatestByBuyer: jest.fn(), + }; + const disputeService = { + assertBuyerCanOpenDispute: jest.fn(), + openBuyerDispute: jest.fn(), + getBuyerOrderDisputes: jest.fn(), + getBuyerSettlementVisibility: jest.fn(), + }; + const redisService = { + get: jest.fn(), + getDel: jest.fn(), + set: jest.fn(), + del: jest.fn(), + }; + + let service: WhatsAppPostPurchaseService; + + beforeEach(() => { + jest.clearAllMocks(); + orderService.getByCodeForBuyer.mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-123456", + status: "DELIVERED", + }); + orderService.getLatestByBuyer.mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-123456", + status: "DELIVERED", + }); + disputeService.assertBuyerCanOpenDispute.mockResolvedValue(undefined); + disputeService.openBuyerDispute.mockResolvedValue({ id: "dispute-1" }); + disputeService.getBuyerOrderDisputes.mockResolvedValue([]); + disputeService.getBuyerSettlementVisibility.mockResolvedValue(null); + redisService.get.mockResolvedValue(null); + redisService.getDel.mockResolvedValue(null); + redisService.set.mockResolvedValue(true); + redisService.del.mockResolvedValue(1); + + service = new WhatsAppPostPurchaseService( + orderService as never, + disputeService as never, + redisService as never, + ); + }); + + it("collects factual details before preparing a dispute", async () => { + const result = await service.prepareDispute("+2348012345678", "buyer-1", { + orderReference: "TWZ-123456", + }); + + expect(result.output).toEqual({ + status: "details_required", + nextStep: "describe_order_problem", + }); + expect(orderService.getByCodeForBuyer).not.toHaveBeenCalled(); + expect(redisService.set).not.toHaveBeenCalled(); + expect(disputeService.openBuyerDispute).not.toHaveBeenCalled(); + }); + + it("stages an eligible owned dispute without opening it before confirmation", async () => { + const result = await service.prepareDispute("+2348012345678", "buyer-1", { + orderReference: "twz-123456", + reason: "Damaged item", + description: "The screen arrived cracked.", + requestedOutcome: "Refund", + }); + + expect(orderService.getByCodeForBuyer).toHaveBeenCalledWith( + "TWZ-123456", + "buyer-1", + ); + expect(disputeService.assertBuyerCanOpenDispute).toHaveBeenCalledWith( + "order-1", + "buyer-1", + ); + expect(redisService.set).toHaveBeenCalledWith( + "wa:pending-dispute:+2348012345678", + JSON.stringify({ + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + reason: "Damaged item", + description: "The screen arrived cracked.", + requestedOutcome: "Refund", + }), + 300, + ); + expect(disputeService.openBuyerDispute).not.toHaveBeenCalled(); + expect(result.output).toEqual({ + status: "confirmation_required", + orderReference: "TWZ-123456", + nextStep: "confirm_or_cancel", + }); + expect(result.confirmationButtons).toBe(true); + }); + + it("directs an existing dispute to the status flow", async () => { + disputeService.assertBuyerCanOpenDispute.mockRejectedValue( + new ConflictException({ + code: "DISPUTE_ALREADY_OPEN", + message: "An active dispute already exists", + }), + ); + + const result = await service.prepareDispute("+2348012345678", "buyer-1", { + reason: "Damaged item", + description: "The screen arrived cracked.", + }); + + expect(result.output).toEqual({ + status: "existing_dispute", + orderReference: "TWZ-123456", + nextStep: "get_dispute_status", + }); + expect(redisService.set).not.toHaveBeenCalled(); + }); + + it("consumes a pending dispute once before calling the canonical domain service", async () => { + const pending = JSON.stringify({ + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + reason: "Damaged item", + description: "The screen arrived cracked.", + requestedOutcome: "Refund", + }); + redisService.get.mockResolvedValue(pending); + redisService.getDel.mockResolvedValueOnce(pending).mockResolvedValue(null); + + const first = await service.handlePendingReply( + "+2348012345678", + "buyer-1", + "confirm", + ); + const second = await service.handlePendingReply( + "+2348012345678", + "buyer-1", + "confirm", + ); + + expect(redisService.getDel).toHaveBeenCalledTimes(2); + expect(disputeService.openBuyerDispute).toHaveBeenCalledTimes(1); + expect(disputeService.openBuyerDispute).toHaveBeenCalledWith( + "order-1", + "buyer-1", + { + reason: "Damaged item", + description: "The screen arrived cracked.", + buyerRequestedOutcome: "Refund", + }, + ); + expect(first.message).toContain("has been opened"); + expect(second.message).toContain("has expired"); + }); + + it("does not retry an ambiguous dispute failure after the domain call starts", async () => { + const pending = JSON.stringify({ + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + reason: "Damaged item", + description: "The screen arrived cracked.", + }); + redisService.get.mockResolvedValue(pending); + redisService.getDel.mockResolvedValue(pending); + disputeService.openBuyerDispute.mockRejectedValue( + new Error("Database timeout"), + ); + + const result = await service.handlePendingReply( + "+2348012345678", + "buyer-1", + "confirm", + ); + + expect(result.message).toContain( + "Check its dispute status before starting another request", + ); + expect(redisService.set).not.toHaveBeenCalled(); + }); + + it("clears a pending dispute when the shopper moves to another request", async () => { + redisService.get.mockResolvedValue( + JSON.stringify({ + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + reason: "Damaged item", + description: "The screen arrived cracked.", + }), + ); + + const result = await service.handlePendingReply( + "+2348012345678", + "buyer-1", + "show my orders", + ); + + expect(result).toEqual({ handled: false }); + expect(redisService.del).toHaveBeenCalledWith( + "wa:pending-dispute:+2348012345678", + ); + }); + + it("returns a shopper-safe current dispute status", async () => { + disputeService.getBuyerOrderDisputes.mockResolvedValue([ + { + id: "dispute-private-id", + status: "UNDER_REVIEW", + storeId: "private-store-id", + }, + ]); + + const result = await service.getDisputeStatus("buyer-1", { + orderReference: "TWZ-123456", + }); + + expect(result.output).toEqual({ + status: "available", + orderReference: "TWZ-123456", + disputeStatus: "under review", + }); + expect(JSON.stringify(result.output)).not.toContain("private"); + }); + + it("returns real refund settlement state without provider or counterparty data", async () => { + disputeService.getBuyerOrderDisputes.mockResolvedValue([ + { id: "dispute-1", status: "RESOLVED" }, + ]); + disputeService.getBuyerSettlementVisibility.mockResolvedValue({ + outcome: "REFUND_BUYER", + settlementStatus: "PROCESSING", + amountKobo: "2500000", + status: "SUBMITTED", + providerReference: "private-provider-reference", + merchantPayoutAmountKobo: "5000000", + }); + + const result = await service.getRefundStatus("buyer-1", { + orderReference: "TWZ-123456", + }); + + expect(disputeService.getBuyerSettlementVisibility).toHaveBeenCalledWith( + "dispute-1", + "buyer-1", + ); + expect(result.output).toEqual({ + status: "available", + orderReference: "TWZ-123456", + refundStatus: "submitted", + refundAmountDisplay: "NGN 25,000.00", + }); + expect(JSON.stringify(result)).not.toContain("private-provider-reference"); + expect(JSON.stringify(result)).not.toContain("merchantPayoutAmountKobo"); + }); + + it("does not claim a refund when no buyer settlement exists", async () => { + disputeService.getBuyerOrderDisputes.mockResolvedValue([ + { id: "dispute-1", status: "RESOLVED" }, + ]); + + const result = await service.getRefundStatus("buyer-1", { + orderReference: "TWZ-123456", + }); + + expect(result.output).toEqual({ + status: "none", + orderReference: "TWZ-123456", + }); + expect(result.message).toContain("no approved refund recorded"); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-post-purchase.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-post-purchase.service.ts new file mode 100644 index 00000000..ba8e6b65 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-post-purchase.service.ts @@ -0,0 +1,515 @@ +import { + HttpStatus, + Inject, + Injectable, + Logger, + forwardRef, +} from "@nestjs/common"; +import { DisputeService } from "../../domains/orders/dispute/dispute.service"; +import { OrderService } from "../../domains/orders/order/order.service"; +import { RedisService } from "../../redis/redis.service"; +import type { + ShopperAgentToolInputMap, + ShopperAgentToolOutputMap, +} from "./agent/shopper-agent.types"; +import { + SHOPPER_ACTION_CONFIRMATION_TTL, + WA_PENDING_DISPUTE_PREFIX, + WA_SHOPPER_ACTION_CANCEL_ID, + WA_SHOPPER_ACTION_CONFIRM_ID, +} from "./whatsapp.constants"; +import { WhatsAppRetryableActionError } from "./whatsapp-retryable-action.error"; +import { maskWhatsAppPhone, normalizeWhatsAppPhone } from "./whatsapp.utils"; + +interface PostPurchaseResult< + Name extends "start_dispute" | "get_dispute_status" | "get_refund_status", +> { + message: string; + output: ShopperAgentToolOutputMap[Name]; + confirmationButtons?: boolean; +} + +interface PendingDispute { + userId: string; + orderId: string; + orderReference: string; + reason: string; + description: string; + requestedOutcome?: string; +} + +interface PendingDisputeReplyResult { + handled: boolean; + message?: string; +} + +interface BuyerOrder { + id: string; + orderCode: string; + status: string; +} + +@Injectable() +export class WhatsAppPostPurchaseService { + private readonly logger = new Logger(WhatsAppPostPurchaseService.name); + + constructor( + @Inject(forwardRef(() => OrderService)) + private readonly orderService: OrderService, + private readonly disputeService: DisputeService, + private readonly redisService: RedisService, + ) {} + + async prepareDispute( + phone: string, + userId: string, + input: ShopperAgentToolInputMap["start_dispute"], + ): Promise> { + const reason = this.normalizeText(input.reason, 120); + const description = this.normalizeText(input.description, 2000); + if (!reason || !description) { + return { + message: + "Tell me what went wrong with the order before I prepare a dispute. Include what happened and what you want Twizrr to review.", + output: { + status: "details_required", + nextStep: "describe_order_problem", + }, + }; + } + + const order = await this.resolveOrder(userId, input.orderReference); + if (!order) { + return { + message: + "I could not find that order in your linked Twizrr account. Check the order code and try again.", + output: { status: "not_found" }, + }; + } + + try { + await this.disputeService.assertBuyerCanOpenDispute(order.id, userId); + } catch (error) { + return this.mapDisputePreparationError(order.orderCode, error); + } + + const requestedOutcome = this.normalizeText(input.requestedOutcome, 500); + const stored = await this.storePendingDispute(phone, { + userId, + orderId: order.id, + orderReference: order.orderCode, + reason, + description, + ...(requestedOutcome ? { requestedOutcome } : {}), + }); + if (!stored) { + return this.temporaryFailure(); + } + + return { + message: [ + `Open a dispute for order ${order.orderCode}?`, + `Reason: ${reason}`, + "Twizrr will place the order under review. Confirm only if these details are correct.", + ].join("\n"), + output: { + status: "confirmation_required", + orderReference: order.orderCode, + nextStep: "confirm_or_cancel", + }, + confirmationButtons: true, + }; + } + + async getDisputeStatus( + userId: string, + input: ShopperAgentToolInputMap["get_dispute_status"], + ): Promise> { + const order = await this.resolveOrder(userId, input.orderReference); + if (!order) { + return { + message: + "I could not find that order in your linked Twizrr account. Check the order code and try again.", + output: { status: "not_found" }, + }; + } + + const disputes = await this.disputeService.getBuyerOrderDisputes( + order.id, + userId, + ); + const latest = this.firstRecord(disputes); + if (!latest) { + return { + message: `There is no dispute recorded for order ${order.orderCode}.`, + output: { + status: "none", + orderReference: order.orderCode, + }, + }; + } + + const disputeStatus = this.formatStatus(latest.status); + return { + message: `The dispute for order ${order.orderCode} is ${disputeStatus}.`, + output: { + status: "available", + orderReference: order.orderCode, + disputeStatus, + }, + }; + } + + async getRefundStatus( + userId: string, + input: ShopperAgentToolInputMap["get_refund_status"], + ): Promise> { + const order = await this.resolveOrder(userId, input.orderReference); + if (!order) { + return { + message: + "I could not find that order in your linked Twizrr account. Check the order code and try again.", + output: { status: "not_found" }, + }; + } + + const disputes = await this.disputeService.getBuyerOrderDisputes( + order.id, + userId, + ); + const latest = this.firstRecord(disputes); + const disputeId = this.readString(latest?.id); + if (!disputeId) { + return this.noRefund(order.orderCode); + } + + const settlement = await this.disputeService.getBuyerSettlementVisibility( + disputeId, + userId, + ); + if (!settlement || BigInt(settlement.amountKobo) <= 0n) { + return this.noRefund(order.orderCode); + } + + const refundStatus = this.formatStatus( + settlement.status || settlement.settlementStatus, + ); + const refundAmountDisplay = this.formatKobo(settlement.amountKobo); + return { + message: `The refund for order ${order.orderCode} is ${refundStatus}. Amount: ${refundAmountDisplay}.`, + output: { + status: "available", + orderReference: order.orderCode, + refundStatus, + refundAmountDisplay, + }, + }; + } + + async handlePendingReply( + phone: string, + userId: string | null, + messageText?: string, + interactiveReply?: { id: string }, + ): Promise { + const key = this.pendingKey(phone); + const reply = interactiveReply?.id.trim().toLowerCase(); + const text = messageText?.trim().toLowerCase(); + const confirms = + reply === WA_SHOPPER_ACTION_CONFIRM_ID || + text === "confirm" || + text === "yes"; + const cancels = + reply === WA_SHOPPER_ACTION_CANCEL_ID || + text === "cancel" || + text === "no"; + let raw: string | null; + try { + raw = await this.redisService.get(key); + } catch (error) { + this.logFailure("pending dispute read", phone, error); + if (confirms || cancels) { + throw new WhatsAppRetryableActionError( + "Pending dispute could not be read", + error, + ); + } + return { handled: false }; + } + if (!raw) { + return { handled: false }; + } + + if (!confirms && !cancels) { + try { + await this.redisService.del(key); + } catch (error) { + this.logFailure("stale pending dispute clear", phone, error); + } + return { handled: false }; + } + if (cancels) { + try { + await this.redisService.del(key); + } catch (error) { + this.logFailure("pending dispute clear", phone, error); + throw new WhatsAppRetryableActionError( + "Pending dispute could not be cancelled", + error, + ); + } + return { handled: true, message: "The dispute request was cancelled." }; + } + + let consumed: string | null; + try { + consumed = await this.redisService.getDel(key); + } catch (error) { + this.logFailure("pending dispute consume", phone, error); + throw new WhatsAppRetryableActionError( + "Pending dispute could not be consumed", + error, + ); + } + + const pending = consumed ? this.parsePendingDispute(consumed) : null; + if (!pending || !userId || pending.userId !== userId) { + return { + handled: true, + message: "That dispute confirmation has expired. Start it again.", + }; + } + + try { + await this.disputeService.openBuyerDispute( + pending.orderId, + pending.userId, + { + reason: pending.reason, + description: pending.description, + ...(pending.requestedOutcome + ? { buyerRequestedOutcome: pending.requestedOutcome } + : {}), + }, + ); + return { + handled: true, + message: `Your dispute for order ${pending.orderReference} has been opened. Twizrr will review it and notify you when its status changes.`, + }; + } catch (error) { + const safeMessage = this.safeOpenError( + pending.orderReference, + phone, + error, + ); + if (!safeMessage) { + return { + handled: true, + message: `I could not confirm whether the dispute for order ${pending.orderReference} was opened. Check its dispute status before starting another request.`, + }; + } + return { + handled: true, + message: safeMessage, + }; + } + } + + private async resolveOrder( + userId: string, + orderReference?: string, + ): Promise { + if (orderReference) { + return this.orderService.getByCodeForBuyer( + orderReference.trim().toUpperCase(), + userId, + ); + } + + return this.orderService.getLatestByBuyer(userId); + } + + private mapDisputePreparationError( + orderReference: string, + error: unknown, + ): PostPurchaseResult<"start_dispute"> { + const code = this.errorCode(error); + if (code === "DISPUTE_ALREADY_OPEN") { + return { + message: `Order ${orderReference} already has an active dispute. Ask for its dispute status instead.`, + output: { + status: "existing_dispute", + orderReference, + nextStep: "get_dispute_status", + }, + }; + } + if (code === "ORDER_NOT_DISPUTABLE" || code === "DISPUTE_WINDOW_CLOSED") { + return { + message: + "That order is not eligible for a new dispute. Contact Twizrr support if you still need help with it.", + output: { + status: "support_required", + orderReference, + nextStep: "support_handoff", + }, + }; + } + + this.logFailure("dispute eligibility check", orderReference, error); + return this.temporaryFailure(); + } + + private safeOpenError( + orderReference: string, + phone: string, + error: unknown, + ): string | null { + const code = this.errorCode(error); + if (code === "DISPUTE_ALREADY_OPEN") { + return `Order ${orderReference} already has an active dispute. Ask for its dispute status instead.`; + } + if (code === "ORDER_NOT_DISPUTABLE" || code === "DISPUTE_WINDOW_CLOSED") { + return "That order is no longer eligible for a new dispute. Contact Twizrr support if you still need help."; + } + this.logFailure("dispute open", phone, error); + return null; + } + + private noRefund( + orderReference: string, + ): PostPurchaseResult<"get_refund_status"> { + return { + message: `There is no approved refund recorded for order ${orderReference}.`, + output: { + status: "none", + orderReference, + }, + }; + } + + private async storePendingDispute( + phone: string, + pending: PendingDispute, + ): Promise { + try { + await this.redisService.set( + this.pendingKey(phone), + JSON.stringify(pending), + SHOPPER_ACTION_CONFIRMATION_TTL, + ); + return true; + } catch (error) { + this.logFailure("pending dispute write", phone, error); + return false; + } + } + + private parsePendingDispute(value: string): PendingDispute | null { + try { + const parsed: unknown = JSON.parse(value); + if (!this.isRecord(parsed)) { + return null; + } + const userId = this.readString(parsed.userId); + const orderId = this.readString(parsed.orderId); + const orderReference = this.readString(parsed.orderReference); + const reason = this.readString(parsed.reason); + const description = this.readString(parsed.description); + const requestedOutcome = this.readString(parsed.requestedOutcome); + if (!userId || !orderId || !orderReference || !reason || !description) { + return null; + } + return { + userId, + orderId, + orderReference, + reason, + description, + ...(requestedOutcome ? { requestedOutcome } : {}), + }; + } catch { + return null; + } + } + + private firstRecord(value: unknown): Record | null { + if (!Array.isArray(value) || !this.isRecord(value[0])) { + return null; + } + return value[0]; + } + + private temporaryFailure(): PostPurchaseResult<"start_dispute"> { + return { + message: + "I could not prepare that dispute request right now. Please try again.", + output: { status: "temporarily_unavailable" }, + }; + } + + private errorCode(error: unknown): string | null { + if (!this.isRecord(error)) { + return null; + } + if (typeof error.getResponse === "function") { + const response = (error.getResponse as () => unknown)(); + if (this.isRecord(response)) { + return this.readString(response.code); + } + } + const status = + typeof error.getStatus === "function" + ? (error.getStatus as () => unknown)() + : null; + if (status === HttpStatus.NOT_FOUND) { + return "ORDER_NOT_FOUND"; + } + return null; + } + + private formatStatus(value: unknown): string { + return ( + this.readString(value)?.toLowerCase().replace(/_/g, " ") ?? "pending" + ); + } + + private formatKobo(value: string): string { + const kobo = BigInt(value); + const naira = kobo / 100n; + const remainder = (kobo % 100n).toString().padStart(2, "0"); + return `NGN ${naira.toLocaleString("en-NG")}.${remainder}`; + } + + private normalizeText(value: string | undefined, maxLength: number) { + const normalized = value?.trim().replace(/\s+/g, " "); + return normalized ? normalized.slice(0, maxLength) : null; + } + + private pendingKey(phone: string): string { + return `${WA_PENDING_DISPUTE_PREFIX}${normalizeWhatsAppPhone(phone)}`; + } + + private logFailure( + operation: string, + reference: string, + error: unknown, + ): void { + const safeReference = reference.startsWith("+") + ? maskWhatsAppPhone(reference) + : reference; + this.logger.warn( + `${operation} failed for ${safeReference}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + private readString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; + } + + private isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.spec.ts new file mode 100644 index 00000000..060adc66 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.spec.ts @@ -0,0 +1,1923 @@ +import { + ModerationStatus, + Prisma, + ProductStatus, + StoreType, + StoreTier, +} from "@prisma/client"; +import { Logger } from "@nestjs/common"; +import { WhatsAppProductDiscoveryService } from "./whatsapp-product-discovery.service"; +import { TaxonomyDiscoveryService } from "../../domains/commerce/search/taxonomy-discovery.service"; + +const expectShopperSafeOutput = (value: unknown) => { + const serialized = JSON.stringify(value); + + expect(serialized).not.toMatch( + /physicalStoreId|physicalProductId|dropshipperCostKobo|digitalMarginKobo|linkedOrderId|sourceCode|embeddingProductId|similarity|score|vector|embedding/i, + ); + expect(serialized.toLowerCase()).not.toMatch( + /supplier|source store|dropship|partner store|physical store/, + ); +}; + +describe("WhatsAppProductDiscoveryService", () => { + const prisma = { + product: { + findMany: jest.fn(), + findFirst: jest.fn(), + }, + sourcedProduct: { + findMany: jest.fn(), + findFirst: jest.fn(), + }, + $queryRaw: jest.fn(), + }; + const redisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + getDel: jest.fn(), + }; + const interactiveService = { + sendTextMessage: jest.fn(), + sendReplyButtons: jest.fn(), + sendListMessage: jest.fn(), + }; + const configService = { + get: jest.fn((key: string) => + key === "app.webUrl" ? "https://twizrr.com" : undefined, + ), + }; + const vertexClient = { + isConfigured: jest.fn(() => false), + generateTextEmbedding: jest.fn(), + getModelName: jest.fn(() => "multimodalembedding@001"), + }; + + let service: WhatsAppProductDiscoveryService; + let loggerWarnSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + redisService.get.mockResolvedValue(null); + redisService.set.mockResolvedValue(undefined); + redisService.del.mockResolvedValue(undefined); + redisService.getDel.mockResolvedValue(null); + vertexClient.isConfigured.mockReturnValue(false); + vertexClient.getModelName.mockReturnValue("multimodalembedding@001"); + loggerWarnSpy = jest.spyOn(Logger.prototype, "warn").mockImplementation(); + service = new WhatsAppProductDiscoveryService( + prisma as any, + redisService as any, + interactiveService as any, + configService as any, + vertexClient as any, + new TaxonomyDiscoveryService(), + ); + prisma.sourcedProduct.findMany.mockResolvedValue([]); + prisma.sourcedProduct.findFirst.mockResolvedValue(null); + }); + + afterEach(() => { + loggerWarnSpy.mockRestore(); + }); + + it("uses the physical source store, not the digital listing store, for sourced locality", async () => { + prisma.product.findMany.mockResolvedValue([]); + + await service.sendGenericProductSearch("2348012345678", "sneakers", { + locationText: "Lekki", + source: "explicit_query", + countryCode: "NG", + state: "Lagos", + city: "Lagos", + area: "Lekki", + }); + + const sourcedWhere = prisma.sourcedProduct.findMany.mock.calls[0][0].where; + expect(sourcedWhere.digitalStore).toEqual({ + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + storeType: StoreType.DIGITAL, + }); + expect(sourcedWhere.physicalStore).toEqual({ + businessAddress: { + contains: "Lekki", + mode: Prisma.QueryMode.insensitive, + }, + }); + }); + it("sends generic product results without user or personalized filters", async () => { + prisma.product.findMany.mockResolvedValue([ + { + id: "product-1", + productCode: "TWZ-RED-001", + name: "Red Shoes", + retailPriceKobo: 250000n, + pricePerUnitKobo: null, + storeProfile: { + businessName: "Style Hub Legal Ltd", + storeHandle: "stylehub", + storeName: "Style Hub NG", + }, + }, + ]); + + await service.sendGenericProductSearch("2348012345678", "red shoes"); + + expect(prisma.product.findMany).toHaveBeenCalledWith({ + where: { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + productCode: { not: null }, + moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true }, + OR: [ + { + name: { + contains: "red shoes", + mode: Prisma.QueryMode.insensitive, + }, + }, + { + title: { + contains: "red shoes", + mode: Prisma.QueryMode.insensitive, + }, + }, + { + shortDescription: { + contains: "red shoes", + mode: Prisma.QueryMode.insensitive, + }, + }, + { + description: { + contains: "red shoes", + mode: Prisma.QueryMode.insensitive, + }, + }, + { + categoryTag: { + contains: "red shoes", + mode: Prisma.QueryMode.insensitive, + }, + }, + ], + }, + include: { + storeProfile: { + select: { + storeHandle: true, + storeName: true, + tier: true, + completedOrders: true, + orderCompletionRateBps: true, + }, + }, + }, + take: 25, + orderBy: { createdAt: "desc" }, + }); + expect(interactiveService.sendListMessage).toHaveBeenCalledWith( + "2348012345678", + 'Here are products matching "red shoes".', + "View products", + [ + { + title: "Products", + rows: [ + { + id: "product_result_1", + title: "Red Shoes", + description: "NGN 2,500 | Sold by Style Hub NG", + }, + ], + }, + ], + ); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + "Here are the product links:\nRed Shoes: https://twizrr.com/stores/stylehub/p/TWZ-RED-001", + ); + expect(redisService.set).toHaveBeenCalledWith( + "wa:recent-products:+2348012345678", + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-RED-001", + title: "Red Shoes", + storeName: "Style Hub NG", + storeHandle: "stylehub", + productUrl: "https://twizrr.com/stores/stylehub/p/TWZ-RED-001", + priceDisplay: "NGN 2,500", + priceKobo: "250000", + }, + ]), + 900, + ); + expect(redisService.set).toHaveBeenCalledWith( + "wa:discovery-context:+2348012345678", + JSON.stringify({ + lastQuery: "red shoes", + source: "text", + }), + 900, + ); + }); + + it("never uses businessName in shopper-facing replies and falls back to public fields only", async () => { + prisma.product.findMany.mockResolvedValue([ + { + id: "product-1", + productCode: "TWZ-RED-001", + name: "Red Shoes", + retailPriceKobo: 250000n, + pricePerUnitKobo: null, + storeProfile: { + businessName: "Internal Legal Identity Ltd", + storeHandle: "stylehub", + storeName: "Style Hub NG", + }, + }, + { + id: "product-2", + productCode: "TWZ-BLUE-002", + name: "Blue Shoes", + retailPriceKobo: 300000n, + pricePerUnitKobo: null, + storeProfile: { + businessName: "Internal Legal Identity Ltd", + storeHandle: "stylehub", + storeName: null, + }, + }, + { + id: "product-3", + productCode: "TWZ-GRN-003", + name: "Green Shoes", + retailPriceKobo: 350000n, + pricePerUnitKobo: null, + storeProfile: { + businessName: "Internal Legal Identity Ltd", + storeHandle: null, + storeName: null, + }, + }, + ]); + + await service.sendGenericProductSearch("2348012345678", "shoes"); + + const listSections = interactiveService.sendListMessage.mock.calls[0][3]; + const rows = listSections[0].rows; + expect(rows[0].description).toBe("NGN 2,500 | Sold by Style Hub NG"); + expect(rows[1].description).toBe("NGN 3,000 | Sold by stylehub"); + expect(rows[2].description).toBe("NGN 3,500 | Sold by twizrr store"); + + const allOutboundText = [ + ...interactiveService.sendListMessage.mock.calls, + ...interactiveService.sendTextMessage.mock.calls, + ...interactiveService.sendReplyButtons.mock.calls, + ] + .map((call) => JSON.stringify(call.slice(1))) + .join("\n"); + expect(allOutboundText).not.toContain("Internal Legal Identity Ltd"); + + const cachedPayload = JSON.parse(redisService.set.mock.calls[0][1]); + expect(cachedPayload[0].storeName).toBe("Style Hub NG"); + expect(cachedPayload[1].storeName).toBe("stylehub"); + expect(cachedPayload[2].storeName).toBe("twizrr store"); + expect(JSON.stringify(cachedPayload)).not.toContain( + "Internal Legal Identity Ltd", + ); + + expect(cachedPayload[0].productUrl).toBe( + "https://twizrr.com/stores/stylehub/p/TWZ-RED-001", + ); + expect(cachedPayload[2].productUrl).toBeUndefined(); + }); + + it("shares canonical URLs for sourced listing results without source internals", async () => { + prisma.product.findMany.mockResolvedValue([]); + prisma.sourcedProduct.findMany.mockResolvedValue([ + { + id: "sourced-1", + productCode: "TWZ-839201", + customTitle: "Curated Sneakers", + sellingPriceKobo: 1800000n, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + digitalStore: { + businessName: "Gadget Plug Ventures Ltd", + storeHandle: "gadgetplug", + storeName: "Gadget Plug", + }, + physicalStoreId: "physical-store-1", + physicalProductId: "physical-product-1", + listingCode: "SRC-483729", + physicalProduct: { + name: "Supplier Sneakers", + title: "Supplier Sneakers", + retailPriceKobo: 1500000n, + }, + }, + ]); + + await service.sendGenericProductSearch("2348012345678", "sneakers"); + + const linkMessage = interactiveService.sendTextMessage.mock.calls.find( + ([, text]) => String(text).includes("https://twizrr.com/stores/"), + )?.[1] as string; + + expect(linkMessage).toContain( + "https://twizrr.com/stores/gadgetplug/p/TWZ-839201", + ); + expect(linkMessage).not.toContain("SRC-483729"); + expect(linkMessage).not.toContain("sourced-1"); + expect(linkMessage).not.toContain("physical-store-1"); + expect(linkMessage.toLowerCase()).not.toMatch( + /supplier|source store|dropship|partner store|physical store/, + ); + + const cachedPayload = JSON.parse(redisService.set.mock.calls[0][1]); + expect(cachedPayload[0]).toMatchObject({ + listingType: "SOURCED", + productId: "sourced-1", + productCode: "TWZ-839201", + storeName: "Gadget Plug", + storeHandle: "gadgetplug", + productUrl: "https://twizrr.com/stores/gadgetplug/p/TWZ-839201", + }); + expect(JSON.stringify(cachedPayload)).not.toContain("SRC-483729"); + expect(JSON.stringify(cachedPayload)).not.toContain("physical-store-1"); + expect(JSON.stringify(cachedPayload)).not.toContain( + "Gadget Plug Ventures Ltd", + ); + }); + + it("resolves a tapped product list row from recent Redis results", async () => { + redisService.get.mockResolvedValue( + JSON.stringify([ + { + rank: 2, + listingType: "NATIVE", + productId: "product-2", + productCode: "TWZ-BLUE-002", + title: "Blue Shoes", + storeName: "stylehub", + storeHandle: "stylehub", + productUrl: "https://twizrr.com/stores/stylehub/p/TWZ-BLUE-002", + priceDisplay: "NGN 3,000", + }, + ]), + ); + prisma.product.findFirst.mockResolvedValue({ + id: "product-2", + productCode: "TWZ-BLUE-002", + name: "Blue Shoes", + title: "Blue Shoes", + shortDescription: "Clean everyday sneakers", + description: "Clean everyday sneakers with soft sole", + retailPriceKobo: 300000n, + pricePerUnitKobo: null, + storeProfile: { + businessName: "Style Hub Legal Ltd", + storeHandle: "stylehub", + storeName: "Style Hub NG", + }, + }); + + await expect( + service.sendProductSelectionFromRecentResults("2348012345678", { + interactiveReply: { + type: "list_reply", + id: "product_result_2", + title: "Blue Shoes", + }, + }), + ).resolves.toMatchObject({ + handled: true, + intentSuccessful: true, + productSelected: "product-2", + }); + + expect(prisma.product.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: "product-2", + productCode: "TWZ-BLUE-002", + }), + }), + ); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining( + "Open product: https://twizrr.com/stores/stylehub/p/TWZ-BLUE-002", + ), + ); + const detailMessage = interactiveService.sendTextMessage.mock.calls[0][1]; + expect(detailMessage).toContain("Sold by Style Hub NG"); + expect(detailMessage).not.toContain("Style Hub Legal Ltd"); + }); + + it("clears recent selection state when a selected product fails live revalidation", async () => { + redisService.get.mockResolvedValue( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-ONE-001", + title: "Everyday Runner", + storeName: "City Kicks", + storeHandle: "citykicks", + productUrl: "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + priceDisplay: "NGN 30,000", + }, + ]), + ); + prisma.product.findFirst.mockResolvedValue(null); + + await expect( + service.sendProductSelectionFromRecentResults("2348012345678", { + interactiveReply: { + type: "list_reply", + id: "product_result_1", + title: "Everyday Runner", + }, + }), + ).resolves.toMatchObject({ + handled: true, + intentSuccessful: false, + errorType: "STALE_PRODUCT_SELECTION", + }); + + expect(redisService.del).toHaveBeenCalledWith( + "wa:recent-products:+2348012345678", + ); + expect(redisService.del).toHaveBeenCalledWith( + "wa:discovery-context:+2348012345678", + ); + }); + + it("resolves sourced product selections with canonical URLs only", async () => { + redisService.get.mockResolvedValue( + JSON.stringify([ + { + rank: 1, + listingType: "SOURCED", + productId: "sourced-1", + productCode: "TWZ-839201", + title: "Curated Sneakers", + storeName: "gadgetplug", + storeHandle: "gadgetplug", + productUrl: "https://twizrr.com/stores/gadgetplug/p/TWZ-839201", + priceDisplay: "NGN 18,000", + }, + ]), + ); + prisma.sourcedProduct.findFirst.mockResolvedValue({ + id: "sourced-1", + productCode: "TWZ-839201", + customTitle: "Curated Sneakers", + customDescription: "Fresh everyday sneakers", + sellingPriceKobo: 1800000n, + digitalStore: { + businessName: "Gadget Plug Ventures Ltd", + storeHandle: "gadgetplug", + storeName: "Gadget Plug", + }, + physicalProduct: { + name: "Supplier Sneakers", + title: "Supplier Sneakers", + shortDescription: "Hidden source description", + description: "Hidden source description", + retailPriceKobo: 1500000n, + }, + }); + + await expect( + service.sendProductSelectionFromRecentResults("2348012345678", { + messageText: "1", + }), + ).resolves.toMatchObject({ + handled: true, + intentSuccessful: true, + productSelected: "sourced-1", + }); + + const detailMessage = interactiveService.sendTextMessage.mock.calls[0][1]; + expect(detailMessage).toContain( + "Open product: https://twizrr.com/stores/gadgetplug/p/TWZ-839201", + ); + expect(detailMessage).toContain("Sold by Gadget Plug"); + expect(detailMessage).not.toContain("Gadget Plug Ventures Ltd"); + expect(detailMessage).not.toContain("sourced-1"); + expect(detailMessage).not.toContain("SRC-"); + expect(detailMessage.toLowerCase()).not.toMatch( + /supplier|source store|dropship|partner store|physical store/, + ); + expect(prisma.product.findFirst).not.toHaveBeenCalled(); + }); + + it("asks the shopper to search again for stale product list rows", async () => { + redisService.get.mockResolvedValue(null); + + await expect( + service.sendProductSelectionFromRecentResults("2348012345678", { + interactiveReply: { + type: "list_reply", + id: "product_result_1", + title: "Red Shoes", + }, + }), + ).resolves.toMatchObject({ + handled: true, + intentSuccessful: false, + errorType: "MISSING_PRODUCT_SELECTION_CONTEXT", + }); + + expect(prisma.product.findFirst).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + "That product selection is no longer available. Search again and I'll show current options.", + ); + }); + + it("handles invalid product list row IDs without throwing", async () => { + redisService.get.mockResolvedValue("[]"); + + await expect( + service.sendProductSelectionFromRecentResults("2348012345678", { + interactiveReply: { + type: "list_reply", + id: "product_result_invalid", + title: "Invalid", + }, + }), + ).resolves.toMatchObject({ + handled: true, + intentSuccessful: false, + errorType: "MISSING_PRODUCT_SELECTION_CONTEXT", + }); + + expect(prisma.product.findFirst).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + "That product selection is no longer available. Search again and I'll show current options.", + ); + }); + + it.each([ + ["the first one", "product-1"], + ["show me number 2", "product-2"], + ["second", "product-2"], + ["1", "product-1"], + ])("resolves index reference %s from recent results", async (text, id) => { + redisService.get.mockResolvedValue( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-RED-001", + title: "Red Shoes", + storeName: "stylehub", + priceDisplay: "NGN 2,500", + }, + { + rank: 2, + listingType: "NATIVE", + productId: "product-2", + productCode: "TWZ-BLUE-002", + title: "Blue Shoes", + storeName: "stylehub", + priceDisplay: "NGN 3,000", + }, + ]), + ); + prisma.product.findFirst.mockResolvedValue({ + id, + productCode: id === "product-1" ? "TWZ-RED-001" : "TWZ-BLUE-002", + name: id === "product-1" ? "Red Shoes" : "Blue Shoes", + title: id === "product-1" ? "Red Shoes" : "Blue Shoes", + shortDescription: "Fresh product", + description: "Fresh product", + retailPriceKobo: id === "product-1" ? 250000n : 300000n, + pricePerUnitKobo: null, + storeProfile: { + businessName: "Style Hub", + storeHandle: "stylehub", + storeName: "Style Hub NG", + }, + }); + + await expect( + service.sendProductSelectionFromRecentResults("2348012345678", { + messageText: text, + }), + ).resolves.toMatchObject({ + handled: true, + intentSuccessful: true, + productSelected: id, + }); + + expect(prisma.product.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id }), + }), + ); + }); + + it("asks for search context for a bare number without recent results", async () => { + redisService.get.mockResolvedValue(null); + + await expect( + service.sendProductSelectionFromRecentResults("2348012345678", { + messageText: "1", + }), + ).resolves.toMatchObject({ + handled: true, + intentSuccessful: false, + errorType: "MISSING_PRODUCT_SELECTION_CONTEXT", + }); + + expect(prisma.product.findFirst).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + "Search for a product first, then pick a number from the results.", + ); + }); + + it("resolves a clear product-name reference from recent results", async () => { + redisService.get.mockResolvedValue( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-IPH-001", + title: "iPhone 15 Pro", + storeName: "phoneshop", + priceDisplay: "NGN 1,200,000", + }, + ]), + ); + prisma.product.findFirst.mockResolvedValue({ + id: "product-1", + productCode: "TWZ-IPH-001", + name: "iPhone 15 Pro", + title: "iPhone 15 Pro", + shortDescription: "Clean UK-used iPhone", + description: "Clean UK-used iPhone", + retailPriceKobo: 120000000n, + pricePerUnitKobo: null, + storeProfile: { + businessName: "Phone Shop", + storeHandle: "phoneshop", + storeName: "Phone Shop NG", + }, + }); + + await expect( + service.sendProductSelectionFromRecentResults("2348012345678", { + messageText: "that iPhone", + }), + ).resolves.toMatchObject({ + handled: true, + intentSuccessful: true, + productSelected: "product-1", + }); + + expect(prisma.product.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: "product-1" }), + }), + ); + }); + + it("asks the shopper to choose a number for ambiguous product-name references", async () => { + redisService.get.mockResolvedValue( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-IPH-001", + title: "iPhone 15", + storeName: "phoneshop", + priceDisplay: "NGN 900,000", + }, + { + rank: 2, + listingType: "NATIVE", + productId: "product-2", + productCode: "TWZ-IPH-002", + title: "iPhone 15 Pro", + storeName: "phoneshop", + priceDisplay: "NGN 1,200,000", + }, + ]), + ); + + await expect( + service.sendProductSelectionFromRecentResults("2348012345678", { + messageText: "that iPhone", + }), + ).resolves.toMatchObject({ + handled: true, + intentSuccessful: false, + errorType: "AMBIGUOUS_PRODUCT_SELECTION", + }); + + expect(prisma.product.findFirst).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + "I found more than one match. Pick a number from the results.", + ); + }); + + it("falls through when a product-name reference does not match recent results", async () => { + redisService.get.mockResolvedValue( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-SHOE-001", + title: "Red Shoes", + storeName: "stylehub", + priceDisplay: "NGN 2,500", + }, + ]), + ); + + await expect( + service.sendProductSelectionFromRecentResults("2348012345678", { + messageText: "that iPhone", + }), + ).resolves.toMatchObject({ handled: false }); + + expect(prisma.product.findFirst).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).not.toHaveBeenCalled(); + }); + + it("ranks stronger vector matches first when other factors are equal", async () => { + vertexClient.isConfigured.mockReturnValue(true); + vertexClient.generateTextEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + prisma.$queryRaw.mockResolvedValue([ + { productId: "product-1", similarity: 0.2 }, + { productId: "product-2", similarity: 0.9 }, + ]); + prisma.product.findMany.mockResolvedValue([ + { + id: "product-1", + productCode: "TWZ-RED-001", + name: "Red Shoes", + retailPriceKobo: 250000n, + pricePerUnitKobo: null, + storeProfile: { storeHandle: "stylehub", storeName: "Style Hub NG" }, + }, + { + id: "product-2", + productCode: "TWZ-RED-002", + name: "Red Shoes", + retailPriceKobo: 250000n, + pricePerUnitKobo: null, + storeProfile: { storeHandle: "stylehub", storeName: "Style Hub NG" }, + }, + ]); + + const analytics = await service.sendGenericProductSearch( + "2348012345678", + "red shoes", + ); + + expect(vertexClient.generateTextEmbedding).toHaveBeenCalledWith( + "red shoes", + ); + expect(prisma.$queryRaw).toHaveBeenCalledTimes(2); + + const cached = JSON.parse(redisService.set.mock.calls[0][1]); + expect(cached[0].productId).toBe("product-2"); + expect(cached[1].productId).toBe("product-1"); + expect(analytics).toMatchObject({ + productsShown: ["product-2", "product-1"], + productsRankedCount: 2, + productsShownCount: 2, + zeroResults: false, + intentSuccessful: true, + vectorSearchUsed: true, + embeddingModel: "multimodalembedding@001", + }); + }); + + it("includes vector-nearest eligible products in text discovery even without keyword matches", async () => { + vertexClient.isConfigured.mockReturnValue(true); + vertexClient.generateTextEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + prisma.$queryRaw + .mockResolvedValueOnce([{ productId: "semantic-product" }]) + .mockResolvedValueOnce([ + { productId: "semantic-product", similarity: 0.95 }, + ]); + prisma.product.findMany.mockResolvedValue([ + { + id: "semantic-product", + productCode: "TWZ-SEM-001", + name: "Blue Loafers", + title: "Blue Loafers", + shortDescription: "Formal slip-on shoes", + description: "Formal slip-on shoes", + categoryTag: "shoes", + retailPriceKobo: 1500000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "stylehub", + storeName: "Style Hub NG", + tier: StoreTier.TIER_1, + completedOrders: 10, + orderCompletionRateBps: 9000, + }, + }, + ]); + + const analytics = await service.sendGenericProductSearch( + "2348012345678", + "comfortable office footwear", + ); + + expect(prisma.product.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + productCode: { not: null }, + moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + }, + OR: expect.arrayContaining([{ id: { in: ["semantic-product"] } }]), + }), + }), + ); + expect(prisma.sourcedProduct.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + isActive: true, + digitalStore: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + storeType: StoreType.DIGITAL, + }, + physicalProduct: expect.objectContaining({ + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + allowDropship: true, + moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { + storeType: StoreType.PHYSICAL, + isOpen: true, + allowDropship: true, + }, + OR: expect.arrayContaining([{ id: { in: ["semantic-product"] } }]), + }), + }), + }), + ); + expect(prisma.$queryRaw).toHaveBeenCalledTimes(2); + expect(analytics).toMatchObject({ + productsShown: ["semantic-product"], + productsRankedCount: 1, + productsShownCount: 1, + zeroResults: false, + intentSuccessful: true, + vectorSearchUsed: true, + embeddingModel: "multimodalembedding@001", + }); + + const cachedPayload = JSON.parse(redisService.set.mock.calls[0][1]); + expect(cachedPayload[0]).toMatchObject({ + productId: "semantic-product", + productUrl: "https://twizrr.com/stores/stylehub/p/TWZ-SEM-001", + }); + expectShopperSafeOutput(cachedPayload); + + const outbound = [ + ...interactiveService.sendListMessage.mock.calls, + ...interactiveService.sendTextMessage.mock.calls, + ...interactiveService.sendReplyButtons.mock.calls, + ].map((call) => call.slice(1)); + expectShopperSafeOutput(outbound); + }); + + it("uses concise WIZZA capability fallback when text discovery finds no products", async () => { + prisma.product.findMany.mockResolvedValue([]); + prisma.sourcedProduct.findMany.mockResolvedValue([]); + + const analytics = await service.sendGenericProductSearch( + "2348012345678", + "purple rain boots", + ); + + expect(redisService.set).not.toHaveBeenCalled(); + expect(redisService.del).toHaveBeenCalledWith( + "wa:recent-products:+2348012345678", + ); + expect(interactiveService.sendReplyButtons).toHaveBeenCalledWith( + "2348012345678", + 'I couldn\'t find products matching "purple rain boots" right now. Try a different product name or browse categories.', + [ + { id: "browse_categories", title: "Browse" }, + { id: "search_products", title: "Search again" }, + ], + ); + expect( + interactiveService.sendReplyButtons.mock.calls[0][1].length, + ).toBeLessThan(140); + expectShopperSafeOutput(interactiveService.sendReplyButtons.mock.calls); + expect(analytics).toMatchObject({ + productsShown: [], + productsShownCount: 0, + zeroResults: true, + intentSuccessful: true, + }); + }); + + it("applies store performance and tier boost according to the formula", async () => { + prisma.product.findMany.mockResolvedValue([ + { + id: "product-low", + productCode: "TWZ-GOWN-002", + name: "Ankara Gown", + retailPriceKobo: 500000n, + pricePerUnitKobo: null, + createdAt: new Date("2026-02-01T00:00:00.000Z"), + storeProfile: { + storeHandle: "newstore", + storeName: "New Store", + tier: StoreTier.TIER_1, + completedOrders: 1, + orderCompletionRateBps: null, + }, + }, + { + id: "product-high", + productCode: "TWZ-GOWN-001", + name: "Ankara Gown", + retailPriceKobo: 500000n, + pricePerUnitKobo: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + storeProfile: { + storeHandle: "tophub", + storeName: "Top Hub", + tier: StoreTier.TIER_2, + completedOrders: 40, + orderCompletionRateBps: 9500, + }, + }, + ]); + + const analytics = await service.sendGenericProductSearch( + "2348012345678", + "ankara gown", + ); + + // Equal text rank; the older listing wins on performance (0.95 vs the + // 0.5 new-store default) and tier boost (TIER_2 = 1 vs TIER_1 = 0.5). + expect(analytics.productsShown).toEqual(["product-high", "product-low"]); + expect(analytics).toMatchObject({ + vectorSearchUsed: false, + embeddingModel: null, + }); + }); + + it("does not penalize new stores to zero performance", async () => { + prisma.product.findMany.mockResolvedValue([ + { + id: "product-established", + productCode: "TWZ-BELT-001", + name: "Leather Belt", + retailPriceKobo: 300000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "oldstore", + storeName: "Old Store", + completedOrders: 100, + orderCompletionRateBps: 2000, + }, + }, + { + id: "product-new", + productCode: "TWZ-BELT-002", + name: "Leather Belt", + retailPriceKobo: 300000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "freshstore", + storeName: "Fresh Store", + completedOrders: 0, + orderCompletionRateBps: null, + }, + }, + ]); + + const analytics = await service.sendGenericProductSearch( + "2348012345678", + "leather belt", + ); + + // The brand-new store defaults to 0.5 performance and outranks an + // established store with a 0.2 completion rate. + expect(analytics.productsShown).toEqual([ + "product-new", + "product-established", + ]); + }); + + it("excludes Tier 0 stores from text discovery results", async () => { + prisma.product.findMany.mockResolvedValue([ + { + id: "product-tier0", + productCode: "TWZ-T0-001", + name: "Red Shoes", + retailPriceKobo: 250000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "tierzero", + storeName: "Tier Zero Store", + tier: StoreTier.TIER_0, + }, + }, + { + id: "product-ok", + productCode: "TWZ-OK-001", + name: "Red Shoes", + retailPriceKobo: 250000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "okstore", + storeName: "OK Store", + tier: StoreTier.TIER_1, + }, + }, + ]); + + const analytics = await service.sendGenericProductSearch( + "2348012345678", + "red shoes", + ); + + expect(prisma.product.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + storeProfile: expect.objectContaining({ + tier: { not: StoreTier.TIER_0 }, + }), + }), + }), + ); + expect(analytics.productsShown).toEqual(["product-ok"]); + expect(analytics.productsRankedCount).toBe(1); + }); + + it("falls back to non-vector ranking when query embedding fails", async () => { + vertexClient.isConfigured.mockReturnValue(true); + vertexClient.generateTextEmbedding.mockRejectedValue( + new Error("provider down"), + ); + prisma.product.findMany.mockResolvedValue([ + { + id: "product-1", + productCode: "TWZ-RED-001", + name: "Red Shoes", + retailPriceKobo: 250000n, + pricePerUnitKobo: null, + storeProfile: { storeHandle: "stylehub", storeName: "Style Hub NG" }, + }, + ]); + + const analytics = await service.sendGenericProductSearch( + "2348012345678", + "red shoes", + ); + + expect(prisma.$queryRaw).not.toHaveBeenCalled(); + expect(analytics).toMatchObject({ + productsShownCount: 1, + zeroResults: false, + intentSuccessful: true, + vectorSearchUsed: false, + embeddingModel: null, + }); + }); + + it("ranks sourced listings using the digital store selling context", async () => { + prisma.product.findMany.mockResolvedValue([]); + prisma.sourcedProduct.findMany.mockResolvedValue([ + { + id: "sourced-low", + productCode: "TWZ-839202", + customTitle: "Curated Sneakers", + sellingPriceKobo: 1800000n, + createdAt: new Date("2026-02-01T00:00:00.000Z"), + digitalStore: { + storeHandle: "newplug", + storeName: "New Plug", + tier: StoreTier.TIER_1, + completedOrders: 0, + orderCompletionRateBps: null, + }, + physicalStoreId: "physical-store-1", + physicalProductId: "physical-product-1", + listingCode: "SRC-111111", + physicalProduct: { + name: "Wholesale Sneakers", + title: "Wholesale Sneakers", + retailPriceKobo: 1500000n, + }, + }, + { + id: "sourced-high", + productCode: "TWZ-839201", + customTitle: "Curated Sneakers", + sellingPriceKobo: 1900000n, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + digitalStore: { + storeHandle: "gadgetplug", + storeName: "Gadget Plug", + tier: StoreTier.TIER_2, + completedOrders: 30, + orderCompletionRateBps: 9800, + }, + physicalStoreId: "physical-store-2", + physicalProductId: "physical-product-2", + listingCode: "SRC-222222", + physicalProduct: { + name: "Wholesale Sneakers", + title: "Wholesale Sneakers", + retailPriceKobo: 1500000n, + }, + }, + ]); + + const analytics = await service.sendGenericProductSearch( + "2348012345678", + "curated sneakers", + ); + + expect(analytics.productsShown).toEqual(["sourced-high", "sourced-low"]); + + const cached = JSON.parse(redisService.set.mock.calls[0][1]); + expect(cached[0]).toMatchObject({ + productId: "sourced-high", + storeName: "Gadget Plug", + storeHandle: "gadgetplug", + productUrl: "https://twizrr.com/stores/gadgetplug/p/TWZ-839201", + }); + expect(JSON.stringify(cached)).not.toContain("SRC-"); + expect(JSON.stringify(cached)).not.toContain("physical-product"); + expect(JSON.stringify(cached)).not.toContain("physical-store"); + + const allOutbound = [ + ...interactiveService.sendListMessage.mock.calls, + ...interactiveService.sendTextMessage.mock.calls, + ...interactiveService.sendReplyButtons.mock.calls, + ] + .map((call) => JSON.stringify(call.slice(1))) + .join("\n"); + expect(allOutbound).not.toContain("SRC-111111"); + expect(allOutbound).not.toContain("SRC-222222"); + expect(allOutbound).not.toContain("physical-product-1"); + expect(allOutbound.toLowerCase()).not.toMatch( + /supplier|source store|dropship|partner store|physical store|wholesale/, + ); + }); + + it("replaces recent product selection state between discoveries", async () => { + await service.storeRecentSearchResults("2348012345678", [ + { + id: "first-product", + productCode: "TWZ-FIRST", + name: "First Shoes", + retailPriceKobo: 250000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "firststore", + storeName: "First Store", + }, + }, + ]); + + await service.storeRecentSearchResults("2348012345678", [ + { + id: "second-product", + productCode: "TWZ-SECOND", + name: "Second Shoes", + retailPriceKobo: 300000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "secondstore", + storeName: "Second Store", + }, + }, + ]); + + expect(redisService.set).toHaveBeenCalledTimes(2); + expect(redisService.set.mock.calls[0][0]).toBe( + "wa:recent-products:+2348012345678", + ); + expect(redisService.set.mock.calls[1][0]).toBe( + "wa:recent-products:+2348012345678", + ); + + const latestPayload = JSON.parse(redisService.set.mock.calls[1][1]); + expect(latestPayload).toEqual([ + expect.objectContaining({ + productId: "second-product", + productUrl: "https://twizrr.com/stores/secondstore/p/TWZ-SECOND", + }), + ]); + expect(JSON.stringify(latestPayload)).not.toContain("first-product"); + expectShopperSafeOutput(latestPayload); + }); + + it("clears an older result set before a new search returns no products", async () => { + prisma.product.findMany.mockResolvedValue([]); + + await service.sendGenericProductSearch( + "2348012345678", + "unavailable product", + ); + + expect(redisService.del).toHaveBeenCalledWith( + "wa:recent-products:+2348012345678", + ); + expect(interactiveService.sendReplyButtons).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("unavailable product"), + [ + { id: "browse_categories", title: "Browse" }, + { id: "search_products", title: "Search again" }, + ], + ); + }); + + it("starts a one-time text search follow-up state", async () => { + await service.startPendingTextSearch("2348012345678"); + + expect(redisService.set).toHaveBeenCalledWith( + "wa:pending-text-search:+2348012345678", + "search_products", + expect.any(Number), + ); + }); + + it("uses only the one-time search-again state for the next text message", async () => { + redisService.getDel.mockResolvedValueOnce("search_products"); + + await expect( + service.consumePendingTextSearch("2348012345678"), + ).resolves.toBe(true); + await expect( + service.consumePendingTextSearch("2348012345678"), + ).resolves.toBe(false); + + expect(redisService.getDel).toHaveBeenCalledWith( + "wa:pending-text-search:+2348012345678", + ); + }); + + it("fails closed when stale discovery state cannot be cleared", async () => { + redisService.del.mockRejectedValueOnce(new Error("redis unavailable")); + + await expect( + service.clearDiscoverySelectionState("2348012345678"), + ).rejects.toThrow("redis unavailable"); + + expect(loggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Discovery selection state clear failed"), + ); + }); + + it("does not expose raw phone numbers when recent result cache writes fail", async () => { + redisService.set.mockRejectedValue(new Error("redis unavailable")); + + await service.storeRecentSearchResults("2348012345678", [ + { + id: "product-1", + productCode: "TWZ-SHOE-001", + name: "Red Shoes", + retailPriceKobo: 250000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "stylehub", + storeName: "Style Hub NG", + }, + }, + ]); + + expect(loggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("+234******5678"), + ); + expect(loggerWarnSpy).not.toHaveBeenCalledWith( + expect.stringContaining("+2348012345678"), + ); + }); + + it("does not expose raw phone numbers when recent result cache reads fail", async () => { + redisService.get.mockRejectedValue(new Error("redis unavailable")); + + await expect( + service.sendProductSelectionFromRecentResults("2348012345678", { + messageText: "1", + }), + ).resolves.toMatchObject({ + handled: true, + intentSuccessful: false, + errorType: "MISSING_PRODUCT_SELECTION_CONTEXT", + }); + + expect(loggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("+234******5678"), + ); + expect(loggerWarnSpy).not.toHaveBeenCalledWith( + expect.stringContaining("+2348012345678"), + ); + }); + + it("returns only shopper-safe fields in conversational discovery context", async () => { + redisService.get + .mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "SOURCED", + productId: "private-sourced-id", + productCode: "TWZ-839201", + title: "Curated Sneakers", + storeName: "Gadget Plug", + storeHandle: "gadgetplug", + productUrl: "https://twizrr.com/stores/gadgetplug/p/TWZ-839201", + priceDisplay: "NGN 18,000", + priceKobo: "1800000", + }, + ]), + ) + .mockResolvedValueOnce( + JSON.stringify({ lastQuery: "sneakers", source: "image" }), + ); + + const context = await service.getConversationContext("2348012345678"); + + expect(context).toEqual({ + lastQuery: "sneakers", + source: "image", + products: [ + { + rank: 1, + title: "Curated Sneakers", + storeName: "Gadget Plug", + priceDisplay: "NGN 18,000", + publicProductUrl: "https://twizrr.com/stores/gadgetplug/p/TWZ-839201", + }, + ], + }); + expectShopperSafeOutput(context); + expect(JSON.stringify(context)).not.toContain("private-sourced-id"); + }); + + it("uses text as the legacy source when recent results predate context storage", async () => { + redisService.get + .mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-RED-001", + title: "Red Shoes", + storeName: "Style Hub", + }, + ]), + ) + .mockResolvedValueOnce(null); + + await expect( + service.getConversationContext("2348012345678"), + ).resolves.toMatchObject({ + lastQuery: null, + source: "text", + }); + }); + + it("asks for an initial search when a refinement has no recent context", async () => { + redisService.get.mockResolvedValueOnce(null); + + await expect( + service.sendRefinedProductSearch( + "2348012345678", + "show me cheaper options", + undefined, + { + locationText: null, + source: "none", + countryCode: null, + state: null, + city: null, + area: null, + }, + ), + ).resolves.toMatchObject({ + productsShownCount: 0, + dropOffStep: "missing_discovery_context", + }); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Search for a product first"), + ); + expect(prisma.product.findMany).not.toHaveBeenCalled(); + }); + + it("limits cheaper refinements below the referenced product price", async () => { + redisService.get + .mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-RED-001", + title: "Red Shoes", + storeName: "Style Hub", + priceDisplay: "NGN 2,500", + priceKobo: "250000", + }, + ]), + ) + .mockResolvedValueOnce( + JSON.stringify({ lastQuery: "red shoes", source: "text" }), + ); + prisma.product.findMany.mockResolvedValue([]); + + await service.sendRefinedProductSearch( + "2348012345678", + "show me cheaper options", + "first", + { + locationText: null, + source: "none", + countryCode: null, + state: null, + city: null, + area: null, + }, + ); + + expect(prisma.product.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: [ + { + OR: [ + { retailPriceKobo: { lte: 249999n } }, + { pricePerUnitKobo: { lte: 249999n } }, + ], + }, + ], + }), + }), + ); + }); + + it("keeps repeated refinements anchored to the original discovery query", async () => { + const recentResults = JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-RED-001", + title: "Red Shoes", + storeName: "Style Hub", + priceDisplay: "NGN 2,500", + priceKobo: "250000", + }, + ]); + const storedContext = JSON.stringify({ + lastQuery: "red shoes", + source: "text", + }); + redisService.get + .mockResolvedValueOnce(recentResults) + .mockResolvedValueOnce(storedContext) + .mockResolvedValueOnce(recentResults) + .mockResolvedValueOnce(storedContext); + const searchSpy = jest + .spyOn(service, "sendGenericProductSearch") + .mockResolvedValue({ + searchQuery: null, + productsRankedCount: 0, + productsShown: [], + productsShownCount: 0, + zeroResults: true, + intentSuccessful: true, + vectorSearchUsed: false, + embeddingModel: null, + }); + const locationContext = { + locationText: null, + source: "none" as const, + countryCode: null, + state: null, + city: null, + area: null, + }; + + await service.sendRefinedProductSearch( + "2348012345678", + "show me cheaper options", + undefined, + locationContext, + ); + await service.sendRefinedProductSearch( + "2348012345678", + "only black ones", + undefined, + locationContext, + ); + + expect(searchSpy.mock.calls[0][1]).toBe( + "red shoes show me cheaper options", + ); + expect(searchSpy.mock.calls[1][1]).toBe("red shoes only black ones"); + expect(searchSpy.mock.calls[0][4]).toEqual({ + lastQuery: "red shoes", + source: "text", + }); + expect(searchSpy.mock.calls[1][4]).toEqual({ + lastQuery: "red shoes", + source: "text", + }); + }); + + it("does not claim one product is cheaper when live prices are equal", async () => { + redisService.get.mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-ONE-001", + title: "Everyday Runner", + storeName: "City Kicks", + storeHandle: "citykicks", + productUrl: "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + priceDisplay: "NGN 30,000", + priceKobo: "3000000", + }, + { + rank: 2, + listingType: "NATIVE", + productId: "product-2", + productCode: "TWZ-TWO-002", + title: "Premium Runner", + storeName: "Urban Sole", + storeHandle: "urbansole", + productUrl: "https://twizrr.com/stores/urbansole/p/TWZ-TWO-002", + priceDisplay: "NGN 30,000", + priceKobo: "3000000", + }, + ]), + ); + prisma.product.findFirst + .mockResolvedValueOnce({ + id: "product-1", + productCode: "TWZ-ONE-001", + name: "Everyday Runner", + description: "Comfortable daily sneakers", + retailPriceKobo: 3000000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "citykicks", + storeName: "City Kicks", + }, + }) + .mockResolvedValueOnce({ + id: "product-2", + productCode: "TWZ-TWO-002", + name: "Premium Runner", + description: "Premium daily sneakers", + retailPriceKobo: 3000000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "urbansole", + storeName: "Urban Sole", + }, + }); + + await service.sendRecentProductComparison("2348012345678", [ + "first", + "second", + ]); + + const outbound = interactiveService.sendTextMessage.mock.calls[0][1]; + expect(outbound).not.toContain("lower-priced option"); + }); + + it("revalidates live eligible products before sending a shopper-safe comparison", async () => { + redisService.get.mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-ONE-001", + title: "Everyday Runner", + storeName: "City Kicks", + storeHandle: "citykicks", + productUrl: "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + priceDisplay: "NGN 30,000", + priceKobo: 3000000, + }, + { + rank: 2, + listingType: "NATIVE", + productId: "product-2", + productCode: "TWZ-TWO-002", + title: "Premium Runner", + storeName: "Urban Sole", + storeHandle: "urbansole", + productUrl: "https://twizrr.com/stores/urbansole/p/TWZ-TWO-002", + priceDisplay: "NGN 45,000", + priceKobo: 4500000, + }, + ]), + ); + prisma.product.findFirst + .mockResolvedValueOnce({ + id: "product-1", + productCode: "TWZ-ONE-001", + name: "Everyday Runner", + shortDescription: "Comfortable daily sneakers", + description: "Comfortable daily sneakers with a soft sole", + retailPriceKobo: 3000000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "citykicks", + storeName: "City Kicks", + }, + }) + .mockResolvedValueOnce({ + id: "product-2", + productCode: "TWZ-TWO-002", + name: "Premium Runner", + shortDescription: "Premium leather sneakers", + description: "Premium leather sneakers for everyday wear", + retailPriceKobo: 4500000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "urbansole", + storeName: "Urban Sole", + }, + }); + + const result = await service.sendRecentProductComparison("2348012345678", [ + "first", + "second", + ]); + + expect(prisma.product.findFirst).toHaveBeenCalledTimes(2); + expect(result).toEqual({ + handled: true, + productsComparedCount: 2, + publicProductUrls: [ + "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + "https://twizrr.com/stores/urbansole/p/TWZ-TWO-002", + ], + errorType: null, + }); + const outbound = interactiveService.sendTextMessage.mock.calls[0][1]; + expect(outbound).toContain("Everyday Runner"); + expect(outbound).toContain("Premium Runner"); + expect(outbound).toContain( + "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + ); + expect(outbound).toContain("Everyday Runner is the lower-priced option."); + expectShopperSafeOutput(outbound); + }); + + it("clears recent selection state when a comparison no longer has two eligible products", async () => { + redisService.get.mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-ONE-001", + title: "Everyday Runner", + storeName: "City Kicks", + storeHandle: "citykicks", + productUrl: "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + priceDisplay: "NGN 30,000", + }, + { + rank: 2, + listingType: "NATIVE", + productId: "product-2", + productCode: "TWZ-TWO-002", + title: "Premium Runner", + storeName: "Urban Sole", + storeHandle: "urbansole", + productUrl: "https://twizrr.com/stores/urbansole/p/TWZ-TWO-002", + priceDisplay: "NGN 45,000", + }, + ]), + ); + prisma.product.findFirst + .mockResolvedValueOnce({ + id: "product-1", + productCode: "TWZ-ONE-001", + name: "Everyday Runner", + shortDescription: "Comfortable daily sneakers", + description: "Comfortable daily sneakers with a soft sole", + retailPriceKobo: 3000000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "citykicks", + storeName: "City Kicks", + }, + }) + .mockResolvedValueOnce(null); + + await expect( + service.sendRecentProductComparison("2348012345678", ["first", "second"]), + ).resolves.toMatchObject({ + handled: true, + productsComparedCount: 0, + errorType: "STALE_PRODUCT_SELECTION", + }); + + expect(redisService.del).toHaveBeenCalledWith( + "wa:recent-products:+2348012345678", + ); + expect(redisService.del).toHaveBeenCalledWith( + "wa:discovery-context:+2348012345678", + ); + }); + + it("revalidates a recent native result before returning its internal cart id", async () => { + redisService.get.mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-ONE-001", + title: "Everyday Runner", + storeName: "City Kicks", + storeHandle: "citykicks", + productUrl: "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + priceDisplay: "NGN 30,000", + priceKobo: "3000000", + }, + ]), + ); + prisma.product.findFirst.mockResolvedValue({ + id: "product-1", + productCode: "TWZ-ONE-001", + name: "Everyday Runner", + title: null, + variants: [], + }); + + await expect( + service.resolveRecentProductForCart("2348012345678", "first"), + ).resolves.toEqual({ + status: "available", + productId: "product-1", + title: "Everyday Runner", + productCode: "TWZ-ONE-001", + }); + expect(prisma.product.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: "product-1", + productCode: "TWZ-ONE-001", + status: ProductStatus.ACTIVE, + isActive: true, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + }, + }), + select: expect.objectContaining({ + variants: expect.any(Object), + }), + }), + ); + }); + + it("clears recent selection state when a native cart product is no longer eligible", async () => { + redisService.get.mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-ONE-001", + title: "Everyday Runner", + storeName: "City Kicks", + storeHandle: "citykicks", + productUrl: "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + priceDisplay: "NGN 30,000", + priceKobo: "3000000", + }, + ]), + ); + prisma.product.findFirst.mockResolvedValue(null); + + await expect( + service.resolveRecentProductForCart("2348012345678", "first"), + ).resolves.toEqual({ status: "not_found" }); + + expect(redisService.del).toHaveBeenCalledWith( + "wa:recent-products:+2348012345678", + ); + expect(redisService.del).toHaveBeenCalledWith( + "wa:discovery-context:+2348012345678", + ); + }); + + it("keeps sourced listings out of the native WhatsApp cart path", async () => { + redisService.get.mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "SOURCED", + productId: "private-sourced-id", + productCode: "TWZ-SOURCE-001", + title: "Sourced Runner", + storeName: "Digital Kicks", + storeHandle: "digitalkicks", + productUrl: "https://twizrr.com/stores/digitalkicks/p/TWZ-SOURCE-001", + priceDisplay: "NGN 35,000", + priceKobo: "3500000", + }, + ]), + ); + + const result = await service.resolveRecentProductForCart( + "2348012345678", + "first", + ); + + expect(result).toEqual({ + status: "sourced_unsupported", + publicProductUrl: + "https://twizrr.com/stores/digitalkicks/p/TWZ-SOURCE-001", + }); + expect(prisma.product.findFirst).not.toHaveBeenCalled(); + expectShopperSafeOutput(result); + }); + + it("requires variant choice on the canonical web product page", async () => { + redisService.get.mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-ONE-001", + title: "Everyday Runner", + storeName: "City Kicks", + storeHandle: "citykicks", + productUrl: "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + priceDisplay: "NGN 30,000", + priceKobo: "3000000", + }, + ]), + ); + prisma.product.findFirst.mockResolvedValue({ + id: "product-1", + productCode: "TWZ-ONE-001", + name: "Everyday Runner", + title: null, + variants: [{ id: "variant-1" }], + }); + + await expect( + service.resolveRecentProductForCart("2348012345678", "first"), + ).resolves.toEqual({ + status: "variant_unsupported", + publicProductUrl: "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + }); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts new file mode 100644 index 00000000..2e04d637 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts @@ -0,0 +1,2032 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { + ModerationStatus, + Prisma, + ProductStatus, + StoreType, + StoreTier, +} from "@prisma/client"; +import { ConfigService } from "@nestjs/config"; +import { + DEFAULT_SEARCH_RANKING_WEIGHTS, + SearchRankingWeights, +} from "../../config/search.config"; +import { VertexClient } from "../../integrations/ai/vertex.client"; +import { PrismaService } from "../../prisma/prisma.service"; +import { RedisService } from "../../redis/redis.service"; +import { + SESSION_TTL, + WA_DISCOVERY_CONTEXT_PREFIX, + WA_PENDING_TEXT_SEARCH_PREFIX, + WA_RECENT_PRODUCTS_PREFIX, +} from "./whatsapp.constants"; +import { WhatsAppInteractiveService } from "./whatsapp-interactive.service"; +import { + clampUnit, + computeFinalScore, + computeStorePerformance, + computeTaxonomyMatchScore, + computeTextRank, + computeTierBoost, + TaxonomyRankingHints, +} from "./whatsapp-search-ranking"; +import { maskWhatsAppPhone, normalizeWhatsAppPhone } from "./whatsapp.utils"; +import { TaxonomyDiscoveryService } from "../../domains/commerce/search/taxonomy-discovery.service"; +import { + buildTaxonomyZeroResultSignal, + TaxonomyZeroResultSignal, +} from "../../domains/commerce/search/search-zero-result"; +import { WizzaDiscoveryLocationContext } from "./wizza-location-resolver.service"; +import type { ShopperDiscoveryContext } from "./agent/shopper-agent-discovery-context.types"; + +export interface RecentWhatsAppProductResult { + rank: number; + listingType: ProductListingType; + productId: string; + productCode: string | null; + title: string; + storeName: string; + storeHandle?: string; + productUrl?: string; + priceDisplay?: string; + priceKobo?: bigint; +} + +interface ProductSelectionInput { + messageText?: string; + interactiveReply?: { type: string; id: string; title: string }; +} + +interface ProductRowSelection { + rank?: number; + reference?: string; +} + +interface ProductSelectionReference { + listingType: ProductListingType; + productId: string; + productCode: string | null; + storeHandle?: string; + productUrl?: string; +} + +type ProductListingType = "NATIVE" | "SOURCED"; + +export interface WhatsAppProductSearchAnalytics { + searchQuery: string | null; + productsRankedCount: number; + productsShown: string[]; + productsShownCount: number; + zeroResults: boolean; + intentSuccessful: boolean; + vectorSearchUsed: boolean; + embeddingModel: string | null; + dropOffStep?: string | null; + // Set only when taxonomy understood the query but zero eligible products were + // found. Internal analytics signal — never shopper-facing. + taxonomyZeroResult?: TaxonomyZeroResultSignal | null; + locationSource?: WizzaDiscoveryLocationContext["source"]; + locationWidened?: boolean; +} + +export interface WhatsAppProductSelectionAnalytics { + handled: boolean; + intentSuccessful?: boolean; + productSelected?: string | null; + errorType?: string | null; + dropOffStep?: string | null; +} + +export type WhatsAppCartProductResolution = + | { + status: "available"; + productId: string; + title: string; + productCode: string | null; + } + | { + status: + | "no_context" + | "not_found" + | "ambiguous" + | "sourced_unsupported" + | "variant_unsupported"; + publicProductUrl?: string; + }; + +export interface WhatsAppProductLinkRecord { + id: string; + listingType?: ProductListingType; + productCode?: string | null; + name: string; + title?: string | null; + shortDescription?: string | null; + description?: string | null; + categoryTag?: string | null; + retailPriceKobo?: bigint | number | null; + pricePerUnitKobo?: bigint | number | null; + createdAt?: Date; + // Internal ranking metadata — never serialized into shopper-facing + // replies or the Redis recent-results payload. + embeddingProductId?: string; + // Category label used only for deterministic taxonomy ranking; internal. + platformCategory?: string | null; + storeProfile?: { + storeHandle?: string | null; + storeName?: string | null; + tier?: StoreTier | null; + completedOrders?: number | null; + orderCompletionRateBps?: number | null; + } | null; +} + +type ProductListRecord = WhatsAppProductLinkRecord; + +interface ProductSearchOutcome { + products: ProductListRecord[]; + candidateCount: number; + vectorSearchUsed: boolean; + embeddingModel: string | null; +} + +interface ProductSearchFilters { + maxPriceKobo?: bigint; +} + +interface StoredDiscoveryContext { + lastQuery: string | null; + source: "text" | "image"; +} + +export interface WhatsAppProductComparisonAnalytics { + handled: boolean; + productsComparedCount: number; + publicProductUrls: string[]; + errorType?: string | null; +} + +type ProductDetailRecord = ProductListRecord & { + shortDescription?: string | null; + description?: string | null; +}; + +export type SourcedProductSearchRecord = { + id: string; + productCode: string; + physicalProductId?: string; + customTitle?: string | null; + customDescription?: string | null; + sellingPriceKobo: bigint | number; + createdAt: Date; + digitalStore?: { + storeHandle?: string | null; + storeName?: string | null; + tier?: StoreTier | null; + completedOrders?: number | null; + orderCompletionRateBps?: number | null; + } | null; + physicalProduct: { + name: string; + title?: string | null; + shortDescription?: string | null; + description?: string | null; + categoryTag?: string | null; + retailPriceKobo?: bigint | number | null; + }; +}; + +const ORDINAL_INDEX: Record = { + first: 1, + second: 2, + third: 3, + fourth: 4, + fifth: 5, + sixth: 6, + seventh: 7, + eighth: 8, + ninth: 9, + tenth: 10, +}; + +const STALE_SELECTION_MESSAGE = + "That product selection is no longer available. Search again and I'll show current options."; + +const MISSING_SELECTION_CONTEXT_MESSAGE = + "Search for a product first, then pick a number from the results."; + +const AMBIGUOUS_SELECTION_MESSAGE = + "I found more than one match. Pick a number from the results."; + +// Candidates fetched per source (native + sourced) before hybrid ranking. +const RANKING_CANDIDATE_POOL = 25; +const MAX_RESULTS = 10; + +const STORE_RANKING_SELECT = { + storeHandle: true, + storeName: true, + tier: true, + completedOrders: true, + orderCompletionRateBps: true, +} as const; + +@Injectable() +export class WhatsAppProductDiscoveryService { + private readonly logger = new Logger(WhatsAppProductDiscoveryService.name); + + constructor( + private prisma: PrismaService, + private redisService: RedisService, + private interactiveService: WhatsAppInteractiveService, + private configService: ConfigService, + private vertexClient: VertexClient, + private taxonomyDiscovery: TaxonomyDiscoveryService, + ) {} + + async sendGenericProductSearch( + phone: string, + query: string, + locationContext: WizzaDiscoveryLocationContext = { + locationText: null, + source: "none", + countryCode: null, + state: null, + city: null, + area: null, + }, + filters: ProductSearchFilters = {}, + discoveryContext?: StoredDiscoveryContext, + ): Promise { + const searchText = query.trim(); + + // A new search must never leave an older numbered result set or pending + // follow-up selectable. + await this.clearDiscoverySelectionState(phone); + + if (!searchText) { + await this.interactiveService.sendTextMessage( + phone, + "Tell me what you want to find. For example, send a product name, category, colour, or style.", + ); + return { + searchQuery: null, + productsRankedCount: 0, + productsShown: [], + productsShownCount: 0, + zeroResults: false, + intentSuccessful: false, + vectorSearchUsed: false, + embeddingModel: null, + dropOffStep: "missing_search_query", + }; + } + + let { products, candidateCount, vectorSearchUsed, embeddingModel } = + await this.searchProducts( + searchText, + locationContext.locationText, + filters, + ); + let locationWidened = false; + + if (products.length === 0 && locationContext.locationText) { + ({ products, candidateCount, vectorSearchUsed, embeddingModel } = + await this.searchProducts(searchText, null, filters)); + locationWidened = products.length > 0; + } + + if (products.length === 0) { + await this.interactiveService.sendReplyButtons( + phone, + `I couldn't find products matching "${searchText}" right now. Try a different product name or browse categories.`, + [ + { id: "browse_categories", title: "Browse" }, + { id: "search_products", title: "Search again" }, + ], + ); + // Record a taxonomy zero-result signal only when taxonomy understood the + // query. The eligibility filters have already run, so this reflects a real + // eligible-supply gap, not a false signal. Analytics only — the shopper + // response above is unchanged. + const taxonomyZeroResult = buildTaxonomyZeroResultSignal({ + searchMode: "TEXT", + hints: this.taxonomyDiscovery.resolveDiscoveryHints(searchText), + rawQuery: searchText, + searchResultCount: 0, + }); + return { + searchQuery: searchText, + productsRankedCount: 0, + productsShown: [], + productsShownCount: 0, + zeroResults: true, + intentSuccessful: true, + vectorSearchUsed, + embeddingModel, + taxonomyZeroResult, + locationSource: locationContext.source, + locationWidened, + }; + } + + const displayProducts = products.slice(0, MAX_RESULTS); + await this.storeRecentSearchResults( + phone, + displayProducts, + discoveryContext ?? { + lastQuery: searchText, + source: "text", + }, + ); + + const rows = displayProducts.map((product, index) => ({ + id: `product_result_${index + 1}`, + title: product.name.substring(0, 24), + description: this.formatProductDescription(product).substring(0, 72), + })); + + const locationLead = locationWidened + ? "I could not find strong matches in that area, so I widened the search.\n\n" + : locationContext.locationText + ? `Here are options around ${locationContext.locationText}.\n\n` + : ""; + await this.interactiveService.sendListMessage( + phone, + `${locationLead}Here are products matching "${searchText}".`, + "View products", + [{ title: "Products", rows }], + ); + await this.sendCanonicalProductLinks(phone, displayProducts); + + return { + searchQuery: searchText, + productsRankedCount: candidateCount, + productsShown: displayProducts.map((product) => product.id), + productsShownCount: displayProducts.length, + zeroResults: false, + intentSuccessful: true, + vectorSearchUsed, + embeddingModel, + locationSource: locationContext.source, + locationWidened, + }; + } + + async storeRecentSearchResults( + phone: string, + products: ProductListRecord[], + context?: StoredDiscoveryContext, + ): Promise { + const normalizedPhone = normalizeWhatsAppPhone(phone); + const compactResults = products.slice(0, 10).map((product, index) => { + const storeName = this.getStoreName(product.storeProfile); + const storeHandle = this.getStoreHandle(product.storeProfile); + const productUrl = this.buildCanonicalProductUrl( + storeHandle, + product.productCode, + ); + return { + rank: index + 1, + listingType: product.listingType ?? "NATIVE", + productId: product.id, + productCode: product.productCode ?? null, + title: this.getProductTitle(product), + storeName, + ...(storeHandle ? { storeHandle } : {}), + ...(productUrl ? { productUrl } : {}), + priceDisplay: this.formatProductPrice(product), + priceKobo: this.getProductPriceKobo(product).toString(), + }; + }); + + try { + await this.clearPendingTextSearch(normalizedPhone); + await Promise.all([ + this.redisService.set( + `${WA_RECENT_PRODUCTS_PREFIX}${normalizedPhone}`, + JSON.stringify(compactResults), + SESSION_TTL, + ), + ...(context + ? [ + this.redisService.set( + `${WA_DISCOVERY_CONTEXT_PREFIX}${normalizedPhone}`, + JSON.stringify(context), + SESSION_TTL, + ), + ] + : []), + ]); + } catch (error) { + this.logger.warn( + `Recent product result cache write failed for ${maskWhatsAppPhone( + normalizedPhone, + )}: ${error instanceof Error ? error.message : error}`, + ); + } + } + + async consumePendingTextSearch(phone: string): Promise { + const normalizedPhone = normalizeWhatsAppPhone(phone); + + try { + const state = await this.redisService.getDel( + `${WA_PENDING_TEXT_SEARCH_PREFIX}${normalizedPhone}`, + ); + return state === "search_products"; + } catch (error) { + this.logger.warn( + `Pending text search state read failed for ${maskWhatsAppPhone( + normalizedPhone, + )}: ${error instanceof Error ? error.message : error}`, + ); + return false; + } + } + + async clearDiscoverySelectionState(phone: string): Promise { + const normalizedPhone = normalizeWhatsAppPhone(phone); + + try { + await Promise.all([ + this.redisService.del(`${WA_RECENT_PRODUCTS_PREFIX}${normalizedPhone}`), + this.redisService.del( + `${WA_DISCOVERY_CONTEXT_PREFIX}${normalizedPhone}`, + ), + this.redisService.del( + `${WA_PENDING_TEXT_SEARCH_PREFIX}${normalizedPhone}`, + ), + ]); + } catch (error) { + this.logger.warn( + `Discovery selection state clear failed for ${maskWhatsAppPhone( + normalizedPhone, + )}: ${error instanceof Error ? error.message : error}`, + ); + throw error; + } + } + + async startPendingTextSearch(phone: string): Promise { + const normalizedPhone = normalizeWhatsAppPhone(phone); + + try { + await this.redisService.set( + `${WA_PENDING_TEXT_SEARCH_PREFIX}${normalizedPhone}`, + "search_products", + SESSION_TTL, + ); + } catch (error) { + this.logger.warn( + `Pending text search state write failed for ${maskWhatsAppPhone( + normalizedPhone, + )}: ${error instanceof Error ? error.message : error}`, + ); + } + } + + private async clearPendingTextSearch(phone: string): Promise { + try { + await this.redisService.del( + `${WA_PENDING_TEXT_SEARCH_PREFIX}${normalizeWhatsAppPhone(phone)}`, + ); + } catch (error) { + this.logger.warn( + `Pending text search state clear failed for ${maskWhatsAppPhone( + phone, + )}: ${error instanceof Error ? error.message : error}`, + ); + } + } + + async clearRecentSearchResults(phone: string): Promise { + const normalizedPhone = normalizeWhatsAppPhone(phone); + try { + await Promise.all([ + this.redisService.del(`${WA_RECENT_PRODUCTS_PREFIX}${normalizedPhone}`), + this.redisService.del( + `${WA_DISCOVERY_CONTEXT_PREFIX}${normalizedPhone}`, + ), + ]); + } catch (error) { + this.logger.warn( + `Recent product result cache clear failed for ${maskWhatsAppPhone( + normalizedPhone, + )}: ${error instanceof Error ? error.message : error}`, + ); + } + } + + async getConversationContext( + phone: string, + ): Promise { + const normalizedPhone = normalizeWhatsAppPhone(phone); + const products = await this.readRecentSearchResults(normalizedPhone); + if (!products.length) { + return null; + } + + const storedContext = (await this.readStoredDiscoveryContext( + normalizedPhone, + )) ?? { + lastQuery: null, + source: "text" as const, + }; + + return { + ...storedContext, + products: products.map((product) => ({ + rank: product.rank, + title: product.title, + storeName: product.storeName, + ...(product.priceDisplay ? { priceDisplay: product.priceDisplay } : {}), + ...(product.productUrl ? { publicProductUrl: product.productUrl } : {}), + })), + }; + } + + async sendRefinedProductSearch( + phone: string, + refinement: string, + productReference: string | undefined, + locationContext: WizzaDiscoveryLocationContext, + ): Promise { + const recent = await this.readRecentSearchResults(phone); + if (!recent.length) { + await this.interactiveService.sendTextMessage( + phone, + "Search for a product first, then I can show similar, cheaper, colour, budget, or nearby options.", + ); + return { + ...this.emptySearchAnalytics(), + dropOffStep: "missing_discovery_context", + }; + } + + const stored = await this.readStoredDiscoveryContext(phone); + const referenced = productReference + ? this.findRecentReference(recent, productReference) + : null; + const anchor = referenced ?? recent[0]; + const baseQuery = stored?.lastQuery || anchor.title; + const maxPriceKobo = this.resolveMaxPriceKobo(refinement, anchor); + const refinedQuery = `${baseQuery} ${refinement}`.trim(); + + return this.sendGenericProductSearch( + phone, + refinedQuery, + locationContext, + maxPriceKobo ? { maxPriceKobo } : {}, + { + lastQuery: baseQuery, + source: stored?.source ?? "text", + }, + ); + } + + async sendRecentProductComparison( + phone: string, + references: string[] = [], + ): Promise { + const recent = await this.readRecentSearchResults(phone); + if (recent.length < 2) { + await this.interactiveService.sendTextMessage( + phone, + "Search for products first, then ask me to compare two of the results.", + ); + return { + handled: true, + productsComparedCount: 0, + publicProductUrls: [], + errorType: "MISSING_DISCOVERY_CONTEXT", + }; + } + + const selected = ( + references.length + ? references + .map((reference) => this.findRecentReference(recent, reference)) + .filter( + (result): result is RecentWhatsAppProductResult => + result !== null, + ) + : recent.slice(0, 2) + ).filter( + (result, index, results) => + results.findIndex( + (candidate) => + candidate.listingType === result.listingType && + candidate.productId === result.productId, + ) === index, + ); + + if (selected.length < 2) { + await this.interactiveService.sendTextMessage( + phone, + "I could not match two active results to compare. Refer to them by number, such as compare the first and second.", + ); + return { + handled: true, + productsComparedCount: 0, + publicProductUrls: [], + errorType: "AMBIGUOUS_PRODUCT_COMPARISON", + }; + } + + const revalidated = ( + await Promise.all( + selected.slice(0, 3).map(async (result) => { + const reference = this.toProductSelectionReference(result); + const product = + reference.listingType === "SOURCED" + ? await this.findSourcedProductDetail(reference) + : await this.findNativeProductDetail(reference); + return product ? { result, product } : null; + }), + ) + ).filter( + ( + entry, + ): entry is { + result: RecentWhatsAppProductResult; + product: ProductDetailRecord; + } => entry !== null, + ); + + if (revalidated.length < 2) { + await this.clearRecentSearchResults(phone); + await this.sendStaleSelectionMessage(phone); + return { + handled: true, + productsComparedCount: 0, + publicProductUrls: [], + errorType: "STALE_PRODUCT_SELECTION", + }; + } + + const comparisonLines = revalidated.map(({ result, product }, index) => { + const description = ( + product.shortDescription || + product.description || + "No description is available." + ) + .replace(/\s+/g, " ") + .trim() + .substring(0, 100); + const url = result.productUrl ? `\nOpen: ${result.productUrl}` : ""; + return `${index + 1}. ${this.getProductTitle(product)}\n${this.formatProductPrice( + product, + )} from ${this.getStoreName(product.storeProfile)}\n${description}${url}`; + }); + const priced = revalidated + .map(({ product }) => ({ + title: this.getProductTitle(product), + priceKobo: this.getProductPriceKobo(product), + })) + .filter((item) => item.priceKobo > 0n) + .sort((left, right) => + left.priceKobo < right.priceKobo + ? -1 + : left.priceKobo > right.priceKobo + ? 1 + : 0, + ); + const priceSummary = + priced.length > 1 && priced[0].priceKobo < priced[1].priceKobo + ? `\n\n${priced[0].title} is the lower-priced option.` + : ""; + + await this.interactiveService.sendTextMessage( + phone, + `Here is a quick comparison:\n\n${comparisonLines.join( + "\n\n", + )}${priceSummary}`, + ); + + return { + handled: true, + productsComparedCount: revalidated.length, + publicProductUrls: revalidated + .map(({ result }) => result.productUrl) + .filter((url): url is string => Boolean(url)), + errorType: null, + }; + } + + async resolveRecentProductForCart( + phone: string, + productReference?: string, + ): Promise { + const recent = await this.readRecentSearchResults(phone); + if (!recent.length) { + return { status: "no_context" }; + } + + const selected = productReference + ? this.findRecentReference(recent, productReference) + : recent.length === 1 + ? recent[0] + : null; + + if (!selected) { + return { status: "ambiguous" }; + } + + if (selected.listingType === "SOURCED") { + return { + status: "sourced_unsupported", + ...(selected.productUrl + ? { publicProductUrl: selected.productUrl } + : {}), + }; + } + + const product = await this.prisma.product.findFirst({ + where: { + id: selected.productId, + ...(selected.productCode ? { productCode: selected.productCode } : {}), + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + }, + }, + select: { + id: true, + name: true, + title: true, + productCode: true, + variants: { + where: { isActive: true }, + select: { id: true }, + take: 1, + }, + }, + }); + + if (!product) { + await this.clearRecentSearchResults(phone); + return { status: "not_found" }; + } + + if (product.variants.length > 0) { + return { + status: "variant_unsupported", + ...(selected.productUrl + ? { publicProductUrl: selected.productUrl } + : {}), + }; + } + + return { + status: "available", + productId: product.id, + title: product.title || product.name, + productCode: product.productCode, + }; + } + + async sendProductSelectionFromRecentResults( + phone: string, + input: ProductSelectionInput, + ): Promise { + const rowSelection = this.parseProductRowSelection(input.interactiveReply); + if (rowSelection) { + const results = await this.readRecentSearchResults(phone); + if (!results.length) { + await this.sendStaleSelectionMessage(phone); + return { + handled: true, + intentSuccessful: false, + errorType: "MISSING_PRODUCT_SELECTION_CONTEXT", + dropOffStep: "product_selection_missing_context", + }; + } + + const selected = this.findRowSelection(results, rowSelection); + if (!selected) { + await this.sendStaleSelectionMessage(phone); + return { + handled: true, + intentSuccessful: false, + errorType: "STALE_PRODUCT_SELECTION", + dropOffStep: "product_selection_stale", + }; + } + + const sent = await this.sendProductDetails(phone, selected); + return { + handled: true, + intentSuccessful: sent, + productSelected: sent ? selected.productId : null, + errorType: sent ? null : "STALE_PRODUCT_SELECTION", + dropOffStep: sent ? null : "product_selection_stale", + }; + } + + const indexReference = this.parseIndexReference(input.messageText); + if (indexReference !== null) { + const results = await this.readRecentSearchResults(phone); + if (!results.length) { + await this.interactiveService.sendTextMessage( + phone, + MISSING_SELECTION_CONTEXT_MESSAGE, + ); + return { + handled: true, + intentSuccessful: false, + errorType: "MISSING_PRODUCT_SELECTION_CONTEXT", + dropOffStep: "product_selection_missing_context", + }; + } + + const selected = results.find((result) => result.rank === indexReference); + if (!selected) { + await this.interactiveService.sendTextMessage( + phone, + AMBIGUOUS_SELECTION_MESSAGE, + ); + return { + handled: true, + intentSuccessful: false, + errorType: "AMBIGUOUS_PRODUCT_SELECTION", + dropOffStep: "product_selection_ambiguous", + }; + } + + const sent = await this.sendProductDetails(phone, selected); + return { + handled: true, + intentSuccessful: sent, + productSelected: sent ? selected.productId : null, + errorType: sent ? null : "STALE_PRODUCT_SELECTION", + dropOffStep: sent ? null : "product_selection_stale", + }; + } + + const productNameReference = this.extractProductNameReference( + input.messageText, + ); + if (!productNameReference) { + return { handled: false }; + } + + const results = await this.readRecentSearchResults(phone); + if (!results.length) { + return { handled: false }; + } + + const matches = this.findNameMatches(results, productNameReference); + if (matches.length === 1) { + const sent = await this.sendProductDetails(phone, matches[0]); + return { + handled: true, + intentSuccessful: sent, + productSelected: sent ? matches[0].productId : null, + errorType: sent ? null : "STALE_PRODUCT_SELECTION", + dropOffStep: sent ? null : "product_selection_stale", + }; + } + + if (matches.length > 1) { + await this.interactiveService.sendTextMessage( + phone, + AMBIGUOUS_SELECTION_MESSAGE, + ); + return { + handled: true, + intentSuccessful: false, + errorType: "AMBIGUOUS_PRODUCT_SELECTION", + dropOffStep: "product_selection_ambiguous", + }; + } + + return { handled: false }; + } + + private async searchProducts( + query: string, + location: string | null = null, + filters: ProductSearchFilters = {}, + ): Promise { + const queryVector = await this.generateQueryVector(query); + const vectorCandidateProductIds = queryVector + ? await this.fetchVectorCandidateProductIds(queryVector) + : []; + const keywordSearchConditions = + this.buildProductKeywordSearchConditions(query); + const semanticSearchConditions = this.buildProductSemanticSearchConditions( + vectorCandidateProductIds, + ); + + const [ + nativeKeywordProducts, + nativeSemanticProducts, + sourcedKeywordProducts, + sourcedSemanticProducts, + ] = await Promise.all([ + this.prisma.product.findMany({ + where: { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + productCode: { not: null }, + moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + ...this.buildNativePriceFilter(filters.maxPriceKobo), + storeProfile: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + ...(location + ? { + businessAddress: { + contains: location, + mode: Prisma.QueryMode.insensitive, + }, + } + : {}), + }, + OR: keywordSearchConditions, + }, + include: { + storeProfile: { + select: STORE_RANKING_SELECT, + }, + }, + take: RANKING_CANDIDATE_POOL, + orderBy: { createdAt: "desc" }, + }), + semanticSearchConditions.length > 0 + ? this.prisma.product.findMany({ + where: { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + productCode: { not: null }, + moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + ...this.buildNativePriceFilter(filters.maxPriceKobo), + storeProfile: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + ...(location + ? { + businessAddress: { + contains: location, + mode: Prisma.QueryMode.insensitive, + }, + } + : {}), + }, + OR: semanticSearchConditions, + }, + include: { + storeProfile: { + select: STORE_RANKING_SELECT, + }, + }, + take: vectorCandidateProductIds.length, + }) + : Promise.resolve([]), + this.prisma.sourcedProduct.findMany({ + where: { + isActive: true, + ...this.buildSourcedPriceFilter(filters.maxPriceKobo), + digitalStore: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + storeType: StoreType.DIGITAL, + }, + ...(location + ? { + physicalStore: { + businessAddress: { + contains: location, + mode: Prisma.QueryMode.insensitive, + }, + }, + } + : {}), + physicalProduct: { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + allowDropship: true, + moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { + storeType: StoreType.PHYSICAL, + isOpen: true, + allowDropship: true, + }, + OR: keywordSearchConditions, + }, + }, + include: { + digitalStore: { + select: STORE_RANKING_SELECT, + }, + physicalProduct: { + select: { + name: true, + title: true, + shortDescription: true, + description: true, + categoryTag: true, + retailPriceKobo: true, + }, + }, + }, + take: RANKING_CANDIDATE_POOL, + orderBy: { createdAt: "desc" }, + }), + semanticSearchConditions.length > 0 + ? this.prisma.sourcedProduct.findMany({ + where: { + isActive: true, + ...this.buildSourcedPriceFilter(filters.maxPriceKobo), + digitalStore: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + storeType: StoreType.DIGITAL, + }, + ...(location + ? { + physicalStore: { + businessAddress: { + contains: location, + mode: Prisma.QueryMode.insensitive, + }, + }, + } + : {}), + physicalProduct: { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + allowDropship: true, + moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { + storeType: StoreType.PHYSICAL, + isOpen: true, + allowDropship: true, + }, + OR: semanticSearchConditions, + }, + }, + include: { + digitalStore: { + select: STORE_RANKING_SELECT, + }, + physicalProduct: { + select: { + name: true, + title: true, + shortDescription: true, + description: true, + categoryTag: true, + retailPriceKobo: true, + }, + }, + }, + take: vectorCandidateProductIds.length, + }) + : Promise.resolve([]), + ]); + const nativeProducts = this.dedupeById([ + ...nativeSemanticProducts, + ...nativeKeywordProducts, + ]); + const sourcedProducts = this.dedupeById([ + ...sourcedSemanticProducts, + ...sourcedKeywordProducts, + ]); + + const candidates: ProductListRecord[] = [ + ...nativeProducts.map((product) => ({ + ...product, + listingType: "NATIVE" as const, + embeddingProductId: product.id, + })), + ...sourcedProducts.map((product) => + this.toSourcedProductListRecord(product), + ), + // Defensive guard — the queries above already exclude Tier 0 stores. + ].filter( + (candidate) => + candidate.storeProfile?.tier !== StoreTier.TIER_0 && + (filters.maxPriceKobo === undefined || + this.getProductPriceKobo(candidate) <= filters.maxPriceKobo), + ); + + if (candidates.length === 0) { + return { + products: [], + candidateCount: 0, + vectorSearchUsed: false, + embeddingModel: null, + }; + } + + const similarities = queryVector + ? await this.fetchVectorSimilarities(candidates, queryVector) + : null; + const vectorSearchUsed = similarities !== null; + const weights = this.getRankingWeights(); + + // Resolve deterministic taxonomy hints once for the whole query. When the + // query has no confident taxonomy match, every candidate's taxonomy boost + // is 0 and ranking is identical to the existing hybrid contract. + const taxonomyHints = this.resolveTaxonomyRankingHints(query); + + const ranked = candidates + .map((candidate) => ({ + candidate, + score: computeFinalScore(weights, { + vectorSimilarity: + similarities?.get(candidate.embeddingProductId ?? candidate.id) ?? + 0, + textRank: computeTextRank(query, candidate), + storePerformance: computeStorePerformance(candidate.storeProfile), + tierBoost: computeTierBoost(candidate.storeProfile?.tier), + taxonomyBoost: computeTaxonomyMatchScore(taxonomyHints, { + platformCategory: candidate.platformCategory, + categoryTag: candidate.categoryTag, + }), + }), + })) + .sort( + (left, right) => + right.score - left.score || + (right.candidate.createdAt?.getTime() ?? 0) - + (left.candidate.createdAt?.getTime() ?? 0), + ); + + return { + products: ranked.slice(0, MAX_RESULTS).map((entry) => entry.candidate), + candidateCount: candidates.length, + vectorSearchUsed, + embeddingModel: vectorSearchUsed + ? this.vertexClient.getModelName() + : null, + }; + } + + private buildNativePriceFilter( + maxPriceKobo?: bigint, + ): Prisma.ProductWhereInput { + return maxPriceKobo === undefined + ? {} + : { + AND: [ + { + OR: [ + { retailPriceKobo: { lte: maxPriceKobo } }, + { pricePerUnitKobo: { lte: maxPriceKobo } }, + ], + }, + ], + }; + } + + private buildSourcedPriceFilter( + maxPriceKobo?: bigint, + ): Prisma.SourcedProductWhereInput { + return maxPriceKobo === undefined + ? {} + : { + sellingPriceKobo: { + lte: maxPriceKobo, + }, + }; + } + + private buildProductKeywordSearchConditions( + query: string, + ): Prisma.ProductWhereInput[] { + const conditions: Prisma.ProductWhereInput[] = [ + { name: { contains: query, mode: Prisma.QueryMode.insensitive } }, + { title: { contains: query, mode: Prisma.QueryMode.insensitive } }, + { + shortDescription: { + contains: query, + mode: Prisma.QueryMode.insensitive, + }, + }, + { + description: { + contains: query, + mode: Prisma.QueryMode.insensitive, + }, + }, + { + categoryTag: { + contains: query, + mode: Prisma.QueryMode.insensitive, + }, + }, + ]; + + // Deterministic taxonomy signal: map shopper intent (e.g. "wristwatch", + // "kicks", "phone case") to structured category labels and widen recall by + // matching Product category strings. This only adds candidate conditions; + // store tier, active/public, and inventory eligibility are still enforced + // by the surrounding query, and hybrid ranking still decides final order. + const categoryTerms = this.taxonomyDiscovery.getCategorySearchTerms( + this.taxonomyDiscovery.resolveDiscoveryHints(query), + ); + for (const term of categoryTerms) { + conditions.push({ + categoryTag: { contains: term, mode: Prisma.QueryMode.insensitive }, + }); + conditions.push({ + platformCategory: { + contains: term, + mode: Prisma.QueryMode.insensitive, + }, + }); + } + + return conditions; + } + + private buildProductSemanticSearchConditions( + vectorCandidateProductIds: string[], + ): Prisma.ProductWhereInput[] { + const semanticCandidateIds = [...new Set(vectorCandidateProductIds)].filter( + Boolean, + ); + + return semanticCandidateIds.length > 0 + ? [{ id: { in: semanticCandidateIds } }] + : []; + } + + private dedupeById(records: T[]): T[] { + const seen = new Set(); + return records.filter((record) => { + if (seen.has(record.id)) { + return false; + } + + seen.add(record.id); + return true; + }); + } + + private async generateQueryVector(query: string): Promise { + if (!this.vertexClient.isConfigured()) { + return null; + } + + try { + return await this.vertexClient.generateTextEmbedding(query); + } catch (error) { + this.logger.warn( + `Text search query embedding failed: ${ + error instanceof Error ? error.message : error + }`, + ); + return null; + } + } + + private async fetchVectorCandidateProductIds( + queryVector: number[], + ): Promise { + const vectorLiteral = `[${queryVector.join(",")}]`; + + try { + // Raw SQL is required because Prisma cannot query pgvector + // `Unsupported("vector(1408)")` columns or use the `<=>` cosine + // distance operator through the Prisma query builder. + // This widens text discovery beyond literal keyword matches while + // Prisma still enforces public eligibility, store tier, and inventory + // rules in the native and sourced candidate queries. + const rows = await this.prisma.$queryRaw<{ productId: string }[]>( + Prisma.sql` + SELECT "product_id" AS "productId" + FROM "product_embeddings" + WHERE "embedding_ready" = TRUE + ORDER BY "embedding" <=> ${vectorLiteral}::vector ASC + LIMIT ${RANKING_CANDIDATE_POOL * 4} + `, + ); + + return rows + .map((row) => row.productId) + .filter((productId): productId is string => Boolean(productId)); + } catch (error) { + this.logger.warn( + `Vector candidate lookup failed: ${ + error instanceof Error ? error.message : error + }`, + ); + return []; + } + } + + private async fetchVectorSimilarities( + candidates: ProductListRecord[], + queryVector: number[], + ): Promise | null> { + const productIds = [ + ...new Set( + candidates.map( + (candidate) => candidate.embeddingProductId ?? candidate.id, + ), + ), + ]; + const vectorLiteral = `[${queryVector.join(",")}]`; + + try { + // Raw SQL is required because Prisma cannot query pgvector + // `Unsupported("vector(1408)")` columns or use the `<=>` cosine + // distance operator through the Prisma query builder. + const rows = await this.prisma.$queryRaw< + { productId: string; similarity: number }[] + >(Prisma.sql` + SELECT + "product_id" AS "productId", + (1 - ("embedding" <=> ${vectorLiteral}::vector))::float AS "similarity" + FROM "product_embeddings" + WHERE "embedding_ready" = TRUE + AND "product_id" IN (${Prisma.join(productIds)}) + `); + + return new Map( + rows.map((row) => [row.productId, clampUnit(row.similarity)]), + ); + } catch (error) { + this.logger.warn( + `Vector similarity lookup failed: ${ + error instanceof Error ? error.message : error + }`, + ); + return null; + } + } + + private getRankingWeights(): SearchRankingWeights { + return ( + this.configService.get("search.weights") ?? + DEFAULT_SEARCH_RANKING_WEIGHTS + ); + } + + // Resolves the shopper query into taxonomy ranking hints via the shared + // taxonomy discovery helper. Deterministic, no external calls. + private resolveTaxonomyRankingHints(query: string): TaxonomyRankingHints { + const hints = this.taxonomyDiscovery.resolveDiscoveryHints(query); + + return { + parentCategoryLabels: hints.parentCategoryLabels, + subcategoryLabels: hints.subcategoryLabels, + confidence: hints.confidence, + }; + } + + private async sendProductDetails( + phone: string, + reference: ProductSelectionReference, + ): Promise { + const product = + reference.listingType === "SOURCED" + ? await this.findSourcedProductDetail(reference) + : await this.findNativeProductDetail(reference); + + if (!product) { + await this.clearRecentSearchResults(phone); + await this.sendStaleSelectionMessage(phone); + return false; + } + + await this.interactiveService.sendTextMessage( + phone, + this.formatProductDetailMessage(product, reference.productUrl), + ); + return true; + } + + private async findNativeProductDetail( + reference: ProductSelectionReference, + ): Promise { + return this.prisma.product.findFirst({ + where: { + id: reference.productId, + ...(reference.productCode + ? { productCode: reference.productCode } + : {}), + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + }, + }, + include: { + storeProfile: { + select: { + storeHandle: true, + storeName: true, + }, + }, + }, + }); + } + + private async findSourcedProductDetail( + reference: ProductSelectionReference, + ): Promise { + const sourcedProduct = await this.prisma.sourcedProduct.findFirst({ + where: { + id: reference.productId, + ...(reference.productCode + ? { productCode: reference.productCode } + : {}), + isActive: true, + digitalStore: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + storeType: StoreType.DIGITAL, + }, + physicalProduct: { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + allowDropship: true, + moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { + storeType: StoreType.PHYSICAL, + isOpen: true, + allowDropship: true, + }, + }, + }, + include: { + digitalStore: { + select: { + storeHandle: true, + storeName: true, + }, + }, + physicalProduct: { + select: { + name: true, + title: true, + shortDescription: true, + description: true, + retailPriceKobo: true, + }, + }, + }, + }); + + return sourcedProduct + ? this.toSourcedProductDetailRecord(sourcedProduct) + : null; + } + + private async readRecentSearchResults( + phone: string, + ): Promise { + const normalizedPhone = normalizeWhatsAppPhone(phone); + + try { + const cached = await this.redisService.get( + `${WA_RECENT_PRODUCTS_PREFIX}${normalizedPhone}`, + ); + + if (!cached) { + return []; + } + + const parsed: unknown = JSON.parse(cached); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .map((item) => this.toRecentResult(item)) + .filter((item): item is RecentWhatsAppProductResult => item !== null) + .sort((left, right) => left.rank - right.rank); + } catch (error) { + this.logger.warn( + `Recent product result cache read failed for ${maskWhatsAppPhone( + normalizedPhone, + )}: ${error instanceof Error ? error.message : error}`, + ); + return []; + } + } + + private toRecentResult(value: unknown): RecentWhatsAppProductResult | null { + if (!value || typeof value !== "object") { + return null; + } + + const record = value as Record; + const priceKobo = this.parseCachedKobo(record.priceKobo); + if ( + typeof record.rank !== "number" || + !Number.isInteger(record.rank) || + record.rank < 1 || + record.rank > 10 || + typeof record.productId !== "string" || + typeof record.title !== "string" || + typeof record.storeName !== "string" + ) { + return null; + } + + return { + rank: record.rank, + listingType: + record.listingType === "SOURCED" || record.listingType === "NATIVE" + ? record.listingType + : "NATIVE", + productId: record.productId, + productCode: + typeof record.productCode === "string" ? record.productCode : null, + title: record.title, + storeName: record.storeName, + storeHandle: + typeof record.storeHandle === "string" ? record.storeHandle : undefined, + productUrl: + typeof record.productUrl === "string" ? record.productUrl : undefined, + priceDisplay: + typeof record.priceDisplay === "string" + ? record.priceDisplay + : undefined, + ...(priceKobo !== undefined ? { priceKobo } : {}), + }; + } + + private emptySearchAnalytics(): WhatsAppProductSearchAnalytics { + return { + searchQuery: null, + productsRankedCount: 0, + productsShown: [], + productsShownCount: 0, + zeroResults: true, + intentSuccessful: true, + vectorSearchUsed: false, + embeddingModel: null, + }; + } + + private getProductPriceKobo(product: { + retailPriceKobo?: bigint | number | null; + pricePerUnitKobo?: bigint | number | null; + }): bigint { + const value = product.retailPriceKobo ?? product.pricePerUnitKobo ?? 0n; + if (typeof value === "bigint") { + return value >= 0n ? value : 0n; + } + + return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : 0n; + } + + private parseCachedKobo(value: unknown): bigint | undefined { + if (typeof value === "string" && /^\d+$/.test(value)) { + return BigInt(value); + } + + if ( + typeof value === "number" && + Number.isSafeInteger(value) && + value >= 0 + ) { + return BigInt(value); + } + + return undefined; + } + + private isStoredDiscoveryContext( + value: unknown, + ): value is StoredDiscoveryContext { + if (!value || typeof value !== "object") { + return false; + } + + const record = value as Record; + return ( + (record.lastQuery === null || typeof record.lastQuery === "string") && + (record.source === "text" || record.source === "image") + ); + } + + private async readStoredDiscoveryContext( + phone: string, + ): Promise { + const normalizedPhone = normalizeWhatsAppPhone(phone); + + try { + const cached = await this.redisService.get( + `${WA_DISCOVERY_CONTEXT_PREFIX}${normalizedPhone}`, + ); + if (!cached) { + return null; + } + + const parsed: unknown = JSON.parse(cached); + return this.isStoredDiscoveryContext(parsed) ? parsed : null; + } catch (error) { + this.logger.warn( + `Discovery context cache read failed for ${maskWhatsAppPhone( + normalizedPhone, + )}: ${error instanceof Error ? error.message : error}`, + ); + return null; + } + } + + private findRecentReference( + results: RecentWhatsAppProductResult[], + reference: string, + ): RecentWhatsAppProductResult | null { + const normalized = this.normalizeSearchText(reference); + if (!normalized) { + return null; + } + + const numeric = this.parseIndexReference(normalized); + if (numeric) { + return results.find((result) => result.rank === numeric) ?? null; + } + + const ordinal = ORDINAL_INDEX[normalized]; + if (ordinal) { + return results.find((result) => result.rank === ordinal) ?? null; + } + + const tokens = normalized.split(" ").filter(Boolean); + const matches = results.filter((result) => { + const title = this.normalizeSearchText(result.title); + return ( + title.includes(normalized) || + tokens.every((token) => title.includes(token)) + ); + }); + + return matches.length === 1 ? matches[0] : null; + } + + private resolveMaxPriceKobo( + refinement: string, + anchor: RecentWhatsAppProductResult, + ): bigint | undefined { + const normalized = refinement.toLowerCase().replace(/,/g, ""); + const explicitBudget = + /(?:under|below|less than|budget(?: of)?|up to|max(?:imum)?(?: of)?)\s*(?:ngn|n|\u20a6)?\s*(\d+(?:\.\d+)?)\s*([km])?/i.exec( + normalized, + ); + + if (explicitBudget) { + const multiplierKobo = + explicitBudget[2]?.toLowerCase() === "m" + ? 100_000_000n + : explicitBudget[2]?.toLowerCase() === "k" + ? 100_000n + : 100n; + const [wholePart, fractionalPart = ""] = explicitBudget[1].split("."); + const denominator = 10n ** BigInt(fractionalPart.length); + const digits = BigInt(`${wholePart}${fractionalPart}`); + const kobo = (digits * multiplierKobo + denominator / 2n) / denominator; + return kobo > 0n ? kobo : undefined; + } + + if ( + /\b(?:cheaper|less expensive|lower priced|lower price|more affordable)\b/i.test( + normalized, + ) && + anchor.priceKobo !== undefined && + anchor.priceKobo > 1n + ) { + return anchor.priceKobo - 1n; + } + + return undefined; + } + + private parseProductRowSelection(interactiveReply?: { + type: string; + id: string; + title: string; + }): ProductRowSelection | null { + if (interactiveReply?.type !== "list_reply") { + return null; + } + + const id = interactiveReply.id.trim(); + const rankMatch = /^product_result_(\d{1,2})$/i.exec(id); + if (rankMatch) { + return { rank: Number(rankMatch[1]) }; + } + + const legacyMatch = /^product_(.+)$/i.exec(id); + if (legacyMatch) { + return { reference: legacyMatch[1] }; + } + + return null; + } + + private findRowSelection( + results: RecentWhatsAppProductResult[], + selection: ProductRowSelection, + ): ProductSelectionReference | null { + const selected = selection.rank + ? results.find((result) => result.rank === selection.rank) + : results.find( + (result) => + result.productId === selection.reference || + result.productCode === selection.reference, + ); + + return selected ? this.toProductSelectionReference(selected) : null; + } + + private parseIndexReference(messageText?: string): number | null { + const text = messageText?.trim().toLowerCase(); + if (!text) { + return null; + } + + const bareNumberMatch = /^(10|[1-9])$/.exec(text); + if (bareNumberMatch) { + return Number(bareNumberMatch[1]); + } + + const numberedMatch = + /^(?:show me|open|view|see)?\s*(?:the\s*)?(?:number|no|option)\s*(10|[1-9])(?:\s+one)?$/.exec( + text, + ) || /^(?:show me|open|view|see)\s+(10|[1-9])$/.exec(text); + + if (numberedMatch) { + return Number(numberedMatch[1]); + } + + const ordinalMatch = + /^(?:show me|open|view|see)?\s*(?:the\s*)?(first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth)(?:\s+one)?$/.exec( + text, + ); + + if (ordinalMatch) { + return ORDINAL_INDEX[ordinalMatch[1]] ?? null; + } + + return null; + } + + private extractProductNameReference(messageText?: string): string | null { + const text = messageText?.trim(); + if (!text) { + return null; + } + + const match = + /^(?:that|show me(?: the)?|open(?: the)?|view(?: the)?|see(?: the)?)\s+(.+)$/i.exec( + text, + ); + + if (!match) { + return null; + } + + const cleaned = this.normalizeSearchText(match[1]); + return cleaned.length >= 2 ? cleaned : null; + } + + private findNameMatches( + results: RecentWhatsAppProductResult[], + reference: string, + ): ProductSelectionReference[] { + const referenceTokens = reference.split(" ").filter(Boolean); + if (!referenceTokens.length) { + return []; + } + + return results + .filter((result) => { + const title = this.normalizeSearchText(result.title); + return ( + title.includes(reference) || + referenceTokens.every((token) => title.includes(token)) + ); + }) + .map((result) => this.toProductSelectionReference(result)); + } + + private toProductSelectionReference( + result: RecentWhatsAppProductResult, + ): ProductSelectionReference { + return { + listingType: result.listingType, + productId: result.productId, + productCode: result.productCode, + storeHandle: result.storeHandle, + productUrl: + result.productUrl ?? + this.buildCanonicalProductUrl(result.storeHandle, result.productCode) ?? + undefined, + }; + } + + private normalizeSearchText(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .replace(/\b(?:please|product|item|one)\b/g, " ") + .replace(/\s+/g, " ") + .trim(); + } + + private async sendStaleSelectionMessage(phone: string): Promise { + await this.interactiveService.sendTextMessage( + phone, + STALE_SELECTION_MESSAGE, + ); + } + + private formatProductDetailMessage( + product: ProductDetailRecord, + productUrl?: string, + ): string { + const title = this.getProductTitle(product); + const storeName = this.getStoreName(product.storeProfile); + const description = ( + product.shortDescription || + product.description || + "Reply with another search to see more options." + ) + .replace(/\s+/g, " ") + .trim() + .substring(0, 180); + const productCode = product.productCode + ? `\nProduct code: ${product.productCode}` + : ""; + const productLink = + productUrl ?? + this.buildCanonicalProductUrl( + this.getStoreHandle(product.storeProfile), + product.productCode, + ); + const productLinkText = productLink ? `\nOpen product: ${productLink}` : ""; + + return `${title}\nSold by ${storeName}\n${this.formatProductPrice( + product, + )}\n${description}${productCode}${productLinkText}\n\nReply with another search to see more options.`; + } + + private formatProductDescription(product: { + retailPriceKobo?: bigint | number | null; + pricePerUnitKobo?: bigint | number | null; + storeProfile?: { + storeHandle?: string | null; + storeName?: string | null; + } | null; + }): string { + const storeName = this.getStoreName(product.storeProfile); + + return `${this.formatProductPrice(product)} | Sold by ${storeName}`; + } + + private formatProductPrice(product: { + retailPriceKobo?: bigint | number | null; + pricePerUnitKobo?: bigint | number | null; + }): string { + const amountKobo = product.retailPriceKobo ?? product.pricePerUnitKobo ?? 0; + const amountNaira = Number(amountKobo) / 100; + const price = amountNaira.toLocaleString("en-NG"); + return `NGN ${price}`; + } + + private getProductTitle(product: { name: string; title?: string | null }) { + return product.title?.trim() || product.name; + } + + // Shopper-facing display name must use public store identity only — + // businessName is internal/legal/KYB data and must never reach WIZZA replies. + private getStoreName( + store?: { + storeHandle?: string | null; + storeName?: string | null; + } | null, + ): string { + return store?.storeName || store?.storeHandle || "twizrr store"; + } + + private getStoreHandle( + store?: { + storeHandle?: string | null; + storeName?: string | null; + } | null, + ): string | null { + const handle = store?.storeHandle?.trim(); + return handle ? handle.replace(/^@/, "").toLowerCase() : null; + } + + private buildCanonicalProductUrl( + storeHandle?: string | null, + productCode?: string | null, + ): string | null { + if (!storeHandle?.trim() || !productCode?.trim()) { + return null; + } + + const baseUrl = this.getPublicWebBaseUrl(); + if (!baseUrl) { + return null; + } + + const cleanHandle = storeHandle.replace(/^@/, "").trim().toLowerCase(); + const cleanCode = productCode.trim().toUpperCase(); + return `${baseUrl}/stores/${encodeURIComponent(cleanHandle)}/p/${encodeURIComponent(cleanCode)}`; + } + + private getPublicWebBaseUrl(): string | null { + const configured = + this.configService.get("app.webUrl") || + this.configService.get("WEB_URL") || + "http://localhost:3000"; + const trimmed = configured.trim().replace(/\/+$/, ""); + return trimmed.length > 0 ? trimmed : null; + } + + async sendCanonicalProductLinks( + phone: string, + products: WhatsAppProductLinkRecord[], + ): Promise { + const links = products + .map((product) => ({ + title: this.getProductTitle(product).substring(0, 40), + url: this.buildCanonicalProductUrl( + this.getStoreHandle(product.storeProfile), + product.productCode, + ), + })) + .filter((item): item is { title: string; url: string } => + Boolean(item.url), + ); + + if (!links.length) { + return; + } + + await this.interactiveService.sendTextMessage( + phone, + `Here are the product links:\n${links + .map((item) => `${item.title}: ${item.url}`) + .join("\n")}`, + ); + } + + // Public so the image-search channel service can map sourced listings to + // the same shopper-safe record shape without duplicating the mapping. + toSourcedProductListRecord( + product: SourcedProductSearchRecord, + ): ProductListRecord { + return { + id: product.id, + listingType: "SOURCED", + productCode: product.productCode, + name: + product.customTitle ?? this.getProductTitle(product.physicalProduct), + title: product.customTitle ?? product.physicalProduct.title, + shortDescription: + product.customDescription ?? product.physicalProduct.shortDescription, + description: + product.customDescription ?? product.physicalProduct.description, + categoryTag: product.physicalProduct.categoryTag, + retailPriceKobo: product.sellingPriceKobo, + pricePerUnitKobo: product.sellingPriceKobo, + createdAt: product.createdAt, + // Embeddings are stored per physical product; this id is used only for + // the internal similarity lookup and never reaches shopper output. + embeddingProductId: product.physicalProductId, + storeProfile: product.digitalStore, + }; + } + + private toSourcedProductDetailRecord( + product: SourcedProductSearchRecord, + ): ProductDetailRecord { + return { + ...this.toSourcedProductListRecord(product), + shortDescription: + product.customDescription ?? product.physicalProduct.shortDescription, + description: + product.customDescription ?? product.physicalProduct.description, + }; + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.taxonomy-ranking.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.taxonomy-ranking.spec.ts new file mode 100644 index 00000000..48809d91 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.taxonomy-ranking.spec.ts @@ -0,0 +1,234 @@ +import { Logger } from "@nestjs/common"; +import { StoreTier } from "@prisma/client"; + +import { TaxonomyDiscoveryService } from "../../domains/commerce/search/taxonomy-discovery.service"; +import { WhatsAppProductDiscoveryService } from "./whatsapp-product-discovery.service"; + +/** + * Verifies that taxonomy hints act as an additive ranking signal in WIZZA text + * discovery: they reorder candidates toward the resolved category without + * replacing hybrid ranking or weakening eligibility / shopper-safety rules. + * + * Vector search is disabled (vertex not configured) so vector similarity is 0 + * for every candidate; store context is identical across candidates, so the + * hybrid base score ties and the taxonomy boost is the deciding signal. This + * isolates the new behavior deterministically. + */ +describe("WhatsAppProductDiscoveryService taxonomy ranking", () => { + const prisma = { + product: { findMany: jest.fn(), findFirst: jest.fn() }, + sourcedProduct: { findMany: jest.fn(), findFirst: jest.fn() }, + $queryRaw: jest.fn(), + }; + const redisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + getDel: jest.fn(), + }; + const interactiveService = { + sendTextMessage: jest.fn(), + sendReplyButtons: jest.fn(), + sendListMessage: jest.fn(), + }; + const configService = { + get: jest.fn((key: string) => + key === "app.webUrl" ? "https://twizrr.com" : undefined, + ), + }; + const vertexClient = { + isConfigured: jest.fn(() => false), + generateTextEmbedding: jest.fn(), + getModelName: jest.fn(() => "multimodalembedding@001"), + }; + + let service: WhatsAppProductDiscoveryService; + let loggerWarnSpy: jest.SpyInstance; + + const store = { + storeHandle: "ada_store", + storeName: "Ada Store", + tier: StoreTier.TIER_1, + completedOrders: 0, + orderCompletionRateBps: null, + }; + + // Two candidates with identical hybrid base score; only their category differs. + // The one whose category is older by createdAt would win the tiebreak WITHOUT + // taxonomy, so any reordering is attributable to the taxonomy boost. + function nativeCandidate( + id: string, + name: string, + platformCategory: string, + categoryTag: string, + createdAt: Date, + ) { + return { + id, + productCode: `TWZ-${id}`, + name, + title: name, + shortDescription: null, + description: null, + platformCategory, + categoryTag, + retailPriceKobo: 1000000n, + createdAt, + storeProfile: store, + }; + } + + beforeEach(() => { + jest.clearAllMocks(); + loggerWarnSpy = jest.spyOn(Logger.prototype, "warn").mockImplementation(); + service = new WhatsAppProductDiscoveryService( + prisma as any, + redisService as any, + interactiveService as any, + configService as any, + vertexClient as any, + new TaxonomyDiscoveryService(), + ); + prisma.sourcedProduct.findMany.mockResolvedValue([]); + }); + + afterEach(() => loggerWarnSpy.mockRestore()); + + it("ranks the exact-subcategory product first even when a neutral product is newer", async () => { + // "Rice" product is newer, so without taxonomy it would rank first on the + // createdAt tiebreak. The query "show me kicks" resolves to Sneakers. + const sneaker = nativeCandidate( + "sneaker", + "Court Classic", + "Sneakers", + "sneakers", + new Date("2026-01-01T00:00:00.000Z"), + ); + const rice = nativeCandidate( + "rice", + "Premium Grain", + "Rice", + "rice", + new Date("2026-06-01T00:00:00.000Z"), + ); + prisma.product.findMany.mockResolvedValue([rice, sneaker]); + + const analytics = await service.sendGenericProductSearch( + "+2348012345678", + "show me kicks", + ); + + expect(analytics.productsShown[0]).toBe("sneaker"); + }); + + it("does not rank Smartphones above Phone Cases for 'phone case'", async () => { + const phoneCase = nativeCandidate( + "case", + "Slim Cover", + "Phone Cases", + "phone-cases", + new Date("2026-01-01T00:00:00.000Z"), + ); + const smartphone = nativeCandidate( + "phone", + "Slim Cover", // identical name -> identical text rank + "Smartphones", + "smartphones", + new Date("2026-06-01T00:00:00.000Z"), + ); + prisma.product.findMany.mockResolvedValue([smartphone, phoneCase]); + + const analytics = await service.sendGenericProductSearch( + "+2348012345678", + "do you have phone cases", + ); + + expect(analytics.productsShown[0]).toBe("case"); + expect(analytics.productsShown).toEqual(["case", "phone"]); + }); + + it("preserves existing ordering when the query has no taxonomy match", async () => { + const older = nativeCandidate( + "older", + "Generic Widget", + "Rice", + "rice", + new Date("2026-01-01T00:00:00.000Z"), + ); + const newer = nativeCandidate( + "newer", + "Generic Widget", + "Sneakers", + "sneakers", + new Date("2026-06-01T00:00:00.000Z"), + ); + prisma.product.findMany.mockResolvedValue([older, newer]); + + // Unknown query: no taxonomy boost, so the newer product wins the tiebreak + // exactly as it did before this change. + const analytics = await service.sendGenericProductSearch( + "+2348012345678", + "zzzqqq gibberish", + ); + + expect(analytics.productsShown[0]).toBe("newer"); + }); + + it("does not leak internal ranking fields into the cached recent results", async () => { + const sneaker = nativeCandidate( + "sneaker", + "Court Classic", + "Sneakers", + "sneakers", + new Date("2026-01-01T00:00:00.000Z"), + ); + prisma.product.findMany.mockResolvedValue([sneaker]); + + await service.sendGenericProductSearch("+2348012345678", "show me kicks"); + + const cachedPayload = redisService.set.mock.calls + .map((call) => String(call[1])) + .join(""); + expect(cachedPayload).not.toMatch( + /platformCategory|embeddingProductId|categoryTag|physicalProductId|physicalStoreId|score|embedding/i, + ); + }); + + it.each([ + ["I need a wristwatch", "Wristwatches", "Rice"], + ["show me kicks under 50k", "Sneakers", "Rice"], + ["do you have phone cases", "Phone Cases", "Smartphones"], + ["find power banks", "Power Banks", "Rice"], + ["rice in Lagos", "Rice", "Sneakers"], + ["body cream", "Body Care", "Rice"], + ["heels", "Heels", "Rice"], + ["school bag", "School Bags", "Rice"], + ["screen protector", "Screen Protectors", "Smartphones"], + ])( + "ranks the taxonomy-matching product first for '%s'", + async (query, matchingCategory, otherCategory) => { + const match = nativeCandidate( + "match", + "Item A", + matchingCategory, + matchingCategory.toLowerCase().replace(/[^a-z0-9]+/g, "-"), + new Date("2026-01-01T00:00:00.000Z"), + ); + const other = nativeCandidate( + "other", + "Item A", // identical name -> identical text rank + otherCategory, + otherCategory.toLowerCase().replace(/[^a-z0-9]+/g, "-"), + new Date("2026-06-01T00:00:00.000Z"), // newer -> would win without taxonomy + ); + prisma.product.findMany.mockResolvedValue([other, match]); + + const analytics = await service.sendGenericProductSearch( + "+2348012345678", + query, + ); + + expect(analytics.productsShown[0]).toBe("match"); + }, + ); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.taxonomy.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.taxonomy.spec.ts new file mode 100644 index 00000000..f025e2f9 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.taxonomy.spec.ts @@ -0,0 +1,110 @@ +import { Logger } from "@nestjs/common"; +import { StoreTier } from "@prisma/client"; + +import { TaxonomyDiscoveryService } from "../../domains/commerce/search/taxonomy-discovery.service"; +import { WhatsAppProductDiscoveryService } from "./whatsapp-product-discovery.service"; + +/** + * Verifies that taxonomy hints are passed into WIZZA text discovery as extra + * recall conditions, while existing eligibility (Tier 0 exclusion, active/public + * store) is preserved and no account-link gate is introduced for guest-safe + * product discovery after consent. + */ +describe("WhatsAppProductDiscoveryService taxonomy enrichment", () => { + const prisma = { + product: { findMany: jest.fn(), findFirst: jest.fn() }, + sourcedProduct: { findMany: jest.fn(), findFirst: jest.fn() }, + $queryRaw: jest.fn(), + }; + const redisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + getDel: jest.fn(), + }; + const interactiveService = { + sendTextMessage: jest.fn(), + sendReplyButtons: jest.fn(), + sendListMessage: jest.fn(), + }; + const configService = { + get: jest.fn((key: string) => + key === "app.webUrl" ? "https://twizrr.com" : undefined, + ), + }; + const vertexClient = { + isConfigured: jest.fn(() => false), + generateTextEmbedding: jest.fn(), + getModelName: jest.fn(() => "multimodalembedding@001"), + }; + + let service: WhatsAppProductDiscoveryService; + let loggerWarnSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + loggerWarnSpy = jest.spyOn(Logger.prototype, "warn").mockImplementation(); + service = new WhatsAppProductDiscoveryService( + prisma as any, + redisService as any, + interactiveService as any, + configService as any, + vertexClient as any, + new TaxonomyDiscoveryService(), + ); + prisma.product.findMany.mockResolvedValue([]); + prisma.sourcedProduct.findMany.mockResolvedValue([]); + }); + + afterEach(() => loggerWarnSpy.mockRestore()); + + it("passes high-confidence taxonomy category hints into the product query and keeps eligibility", async () => { + await service.sendGenericProductSearch( + "+2348012345678", + "I need a wristwatch", + ); + + expect(prisma.product.findMany).toHaveBeenCalled(); + const where = prisma.product.findMany.mock.calls[0][0].where; + + // Eligibility is still enforced by the surrounding query. + expect(where.storeProfile.tier).toEqual({ not: StoreTier.TIER_0 }); + expect(where.storeProfile.isOpen).toBe(true); + expect(where.status).toBe("ACTIVE"); + expect(where.deletedAt).toBeNull(); + + // Taxonomy hint ("Wristwatches") is added as a recall condition. + const orConditions = where.OR as Array>; + const hasTaxonomyTerm = orConditions.some( + (condition) => + condition.categoryTag?.contains === "Wristwatches" || + condition.platformCategory?.contains === "Wristwatches", + ); + expect(hasTaxonomyTerm).toBe(true); + }); + + it("excludes Tier 0 stores and does not gate discovery on account linking", async () => { + await service.sendGenericProductSearch("+2348012345678", "show me kicks"); + + const where = prisma.product.findMany.mock.calls[0][0].where; + expect(where.storeProfile.tier).toEqual({ not: StoreTier.TIER_0 }); + + // No account-link lookup is performed for guest-safe discovery. + expect(prisma.product.findFirst).not.toHaveBeenCalled(); + // Sourced query keeps the same Tier 0 exclusion on the digital store. + const sourcedWhere = prisma.sourcedProduct.findMany.mock.calls[0][0].where; + expect(sourcedWhere.digitalStore.tier).toEqual({ not: StoreTier.TIER_0 }); + }); + + it("leaves the query untouched for a non-taxonomy search (no over-matching)", async () => { + await service.sendGenericProductSearch( + "+2348012345678", + "zxqw random term", + ); + + const where = prisma.product.findMany.mock.calls[0][0].where; + const orConditions = where.OR as Array>; + // Only the base keyword conditions (name/title/short/description/categoryTag). + expect(orConditions).toHaveLength(5); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.zero-result.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.zero-result.spec.ts new file mode 100644 index 00000000..c1f1ec70 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.zero-result.spec.ts @@ -0,0 +1,148 @@ +import { Logger } from "@nestjs/common"; + +import { TaxonomyDiscoveryService } from "../../domains/commerce/search/taxonomy-discovery.service"; +import { WhatsAppProductDiscoveryService } from "./whatsapp-product-discovery.service"; + +/** + * Verifies WIZZA text discovery emits a taxonomy zero-result analytics signal + * only when taxonomy understood the query AND zero eligible products were found + * (after eligibility filters). Ranking/discovery behavior and shopper copy are + * unchanged — only the returned analytics carries the new signal. + */ +describe("WhatsAppProductDiscoveryService taxonomy zero-result signal", () => { + const prisma = { + product: { findMany: jest.fn(), findFirst: jest.fn() }, + sourcedProduct: { findMany: jest.fn(), findFirst: jest.fn() }, + $queryRaw: jest.fn(), + }; + const redisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + getDel: jest.fn(), + }; + const interactiveService = { + sendTextMessage: jest.fn(), + sendReplyButtons: jest.fn(), + sendListMessage: jest.fn(), + }; + const configService = { + get: jest.fn((key: string) => + key === "app.webUrl" ? "https://twizrr.com" : undefined, + ), + }; + const vertexClient = { + isConfigured: jest.fn(() => false), + generateTextEmbedding: jest.fn(), + getModelName: jest.fn(() => "multimodalembedding@001"), + }; + + let service: WhatsAppProductDiscoveryService; + let loggerWarnSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + loggerWarnSpy = jest.spyOn(Logger.prototype, "warn").mockImplementation(); + service = new WhatsAppProductDiscoveryService( + prisma as any, + redisService as any, + interactiveService as any, + configService as any, + vertexClient as any, + new TaxonomyDiscoveryService(), + ); + prisma.sourcedProduct.findMany.mockResolvedValue([]); + }); + + afterEach(() => loggerWarnSpy.mockRestore()); + + it("records a zero-result signal when taxonomy resolves but no eligible products exist", async () => { + prisma.product.findMany.mockResolvedValue([]); + + const analytics = await service.sendGenericProductSearch( + "+2348012345678", + "I need a wristwatch", + ); + + expect(analytics.zeroResults).toBe(true); + expect(analytics.taxonomyZeroResult).toMatchObject({ + signal: "WIZZA_SEARCH_ZERO_RESULTS", + channel: "WHATSAPP", + searchMode: "TEXT", + taxonomySubcategoryLabels: ["Wristwatches"], + taxonomyParentLabels: ["Watches & Clocks"], + zeroResults: true, + searchResultCount: 0, + reasonCode: "NO_ELIGIBLE_PRODUCTS", + normalizedQuery: "i need a wristwatch", + }); + }); + + it("does not record a signal when eligible products are found", async () => { + prisma.product.findMany.mockResolvedValue([ + { + id: "watch-1", + productCode: "TWZ-WATCH-1", + name: "Classic Watch", + title: "Classic Watch", + platformCategory: "Watches & Clocks", + categoryTag: "wristwatches", + retailPriceKobo: 1500000n, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + storeProfile: { + storeHandle: "ada_store", + storeName: "Ada Store", + tier: "TIER_1", + }, + }, + ]); + + const analytics = await service.sendGenericProductSearch( + "+2348012345678", + "I need a wristwatch", + ); + + expect(analytics.zeroResults).toBe(false); + expect(analytics.taxonomyZeroResult ?? null).toBeNull(); + }); + + it("does not record a product zero-result signal for an unknown/non-product query", async () => { + prisma.product.findMany.mockResolvedValue([]); + + const analytics = await service.sendGenericProductSearch( + "+2348012345678", + "zzzqqq totally unknown gibberish", + ); + + expect(analytics.zeroResults).toBe(true); + expect(analytics.taxonomyZeroResult ?? null).toBeNull(); + }); + + it("does not record a signal for an empty query (never reaches search)", async () => { + const analytics = await service.sendGenericProductSearch( + "+2348012345678", + " ", + ); + + expect(analytics.zeroResults).toBe(false); + expect(analytics.taxonomyZeroResult ?? null).toBeNull(); + expect(prisma.product.findMany).not.toHaveBeenCalled(); + }); + + it("keeps recorded signal metadata free of secrets, phone numbers, and payloads", async () => { + prisma.product.findMany.mockResolvedValue([]); + + const analytics = await service.sendGenericProductSearch( + "+2348012345678", + "power bank", + ); + + const serialized = JSON.stringify(analytics.taxonomyZeroResult); + expect(serialized).not.toMatch( + /token|cookie|authorization|otp|password|base64|embedding|vector|\+234|prompt|physicalStoreId|physicalProductId/i, + ); + expect(analytics.taxonomyZeroResult?.taxonomySubcategoryLabels).toContain( + "Power Banks", + ); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-retryable-action.error.ts b/apps/backend/src/channels/whatsapp/whatsapp-retryable-action.error.ts new file mode 100644 index 00000000..9ba56928 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-retryable-action.error.ts @@ -0,0 +1,9 @@ +export class WhatsAppRetryableActionError extends Error { + readonly cause: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = "WhatsAppRetryableActionError"; + this.cause = cause; + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp-search-ranking.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-search-ranking.spec.ts new file mode 100644 index 00000000..932cb491 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-search-ranking.spec.ts @@ -0,0 +1,272 @@ +import { StoreTier } from "@prisma/client"; + +import { DEFAULT_SEARCH_RANKING_WEIGHTS } from "../../config/search.config"; +import { + DEFAULT_STORE_PERFORMANCE, + TAXONOMY_BOOST_WEIGHT, + clampUnit, + computeFinalScore, + computeStorePerformance, + computeTaxonomyMatchScore, + computeTextRank, + computeTierBoost, + TaxonomyRankingHints, +} from "./whatsapp-search-ranking"; + +describe("whatsapp-search-ranking", () => { + describe("computeFinalScore", () => { + it("applies the approved weights exactly", () => { + const score = computeFinalScore(DEFAULT_SEARCH_RANKING_WEIGHTS, { + vectorSimilarity: 0.9, + textRank: 1, + storePerformance: 0.8, + tierBoost: 0.5, + }); + + expect(score).toBeCloseTo( + 0.9 * 0.5 + 1 * 0.2 + 0.8 * 0.2 + 0.5 * 0.1, + 10, + ); + }); + + it("applies custom weights exactly", () => { + const score = computeFinalScore( + { vector: 0.4, text: 0.3, performance: 0.2, tier: 0.1 }, + { + vectorSimilarity: 1, + textRank: 0.5, + storePerformance: 0.5, + tierBoost: 1, + }, + ); + + expect(score).toBeCloseTo(0.4 + 0.15 + 0.1 + 0.1, 10); + }); + + it("clamps out-of-range components into [0, 1]", () => { + const score = computeFinalScore(DEFAULT_SEARCH_RANKING_WEIGHTS, { + vectorSimilarity: 1.7, + textRank: -0.4, + storePerformance: 2, + tierBoost: -1, + }); + + expect(score).toBeCloseTo(0.5 + 0 + 0.2 + 0, 10); + }); + }); + + describe("computeTextRank", () => { + it("scores a full phrase match in the title highest", () => { + expect( + computeTextRank("red sneakers", { name: "Red Sneakers Classic" }), + ).toBe(1); + }); + + it("scores all tokens present in the title below a phrase match", () => { + expect(computeTextRank("red sneakers", { name: "Sneakers in Red" })).toBe( + 0.8, + ); + }); + + it("scores description-only matches in the middle", () => { + expect( + computeTextRank("red sneakers", { + name: "Court Classic", + description: "Lightweight red sneakers for everyday wear", + }), + ).toBe(0.5); + }); + + it("scores partial token matches lowest", () => { + expect( + computeTextRank("red sneakers", { + name: "Red Dress", + description: "Elegant evening wear", + }), + ).toBe(0.3); + }); + + it("scores zero when nothing matches", () => { + expect( + computeTextRank("red sneakers", { + name: "Blue Mug", + description: "Ceramic kitchenware", + }), + ).toBe(0); + }); + }); + + describe("computeStorePerformance", () => { + it("normalizes order completion rate basis points to [0, 1]", () => { + expect( + computeStorePerformance({ + completedOrders: 25, + orderCompletionRateBps: 9200, + }), + ).toBeCloseTo(0.92, 10); + }); + + it("defaults to 0.5 for new stores with insufficient history", () => { + expect( + computeStorePerformance({ + completedOrders: 2, + orderCompletionRateBps: 10000, + }), + ).toBe(DEFAULT_STORE_PERFORMANCE); + }); + + it("defaults to 0.5 when no completion rate is recorded", () => { + expect( + computeStorePerformance({ + completedOrders: 50, + orderCompletionRateBps: null, + }), + ).toBe(DEFAULT_STORE_PERFORMANCE); + }); + + it("defaults to 0.5 when the store context is missing", () => { + expect(computeStorePerformance(null)).toBe(DEFAULT_STORE_PERFORMANCE); + expect(computeStorePerformance(undefined)).toBe( + DEFAULT_STORE_PERFORMANCE, + ); + }); + + it("clamps out-of-range completion rates", () => { + expect( + computeStorePerformance({ + completedOrders: 10, + orderCompletionRateBps: 12000, + }), + ).toBe(1); + }); + }); + + describe("computeTierBoost", () => { + it("maps tiers to normalized boosts", () => { + expect(computeTierBoost(StoreTier.TIER_2)).toBe(1); + expect(computeTierBoost(StoreTier.TIER_1)).toBe(0.5); + expect(computeTierBoost(StoreTier.TIER_0)).toBe(0); + expect(computeTierBoost(null)).toBe(0); + expect(computeTierBoost(undefined)).toBe(0); + }); + }); + + describe("clampUnit", () => { + it("clamps values into [0, 1] and zeroes non-finite input", () => { + expect(clampUnit(0.4)).toBe(0.4); + expect(clampUnit(-0.2)).toBe(0); + expect(clampUnit(1.4)).toBe(1); + expect(clampUnit(Number.NaN)).toBe(0); + }); + }); + + describe("computeTaxonomyMatchScore", () => { + const kicksHints: TaxonomyRankingHints = { + parentCategoryLabels: ["Shoes & Footwear"], + subcategoryLabels: ["Sneakers"], + confidence: "high", + }; + + it("gives an exact subcategory match the strongest score", () => { + expect( + computeTaxonomyMatchScore(kicksHints, { platformCategory: "Sneakers" }), + ).toBe(1); + }); + + it("gives a parent-only match a smaller score than a subcategory match", () => { + const parentScore = computeTaxonomyMatchScore(kicksHints, { + platformCategory: "Shoes & Footwear", + }); + const subScore = computeTaxonomyMatchScore(kicksHints, { + platformCategory: "Sneakers", + }); + expect(parentScore).toBe(0.5); + expect(parentScore).toBeLessThan(subScore); + }); + + it("resolves category slugs the same as labels", () => { + expect( + computeTaxonomyMatchScore(kicksHints, { categoryTag: "sneakers" }), + ).toBe(1); + }); + + it("scales the score by query confidence", () => { + expect( + computeTaxonomyMatchScore( + { ...kicksHints, confidence: "medium" }, + { platformCategory: "Sneakers" }, + ), + ).toBeCloseTo(0.7, 10); + }); + + it("returns 0 for no confident match or unrelated category", () => { + expect( + computeTaxonomyMatchScore(kicksHints, { platformCategory: "Rice" }), + ).toBe(0); + expect( + computeTaxonomyMatchScore( + { + parentCategoryLabels: [], + subcategoryLabels: [], + confidence: "none", + }, + { platformCategory: "Sneakers" }, + ), + ).toBe(0); + expect( + computeTaxonomyMatchScore(null, { platformCategory: "Sneakers" }), + ).toBe(0); + }); + + it("does not rank a parent (Smartphones) above the exact subcategory (Phone Cases)", () => { + const phoneCaseHints: TaxonomyRankingHints = { + parentCategoryLabels: ["Phones & Tablets"], + subcategoryLabels: ["Phone Cases"], + confidence: "high", + }; + const phoneCaseScore = computeTaxonomyMatchScore(phoneCaseHints, { + platformCategory: "Phone Cases", + }); + const smartphoneScore = computeTaxonomyMatchScore(phoneCaseHints, { + platformCategory: "Smartphones", + }); + expect(phoneCaseScore).toBeGreaterThan(smartphoneScore); + }); + }); + + describe("computeFinalScore taxonomy boost", () => { + it("adds the taxonomy boost on top of the normalized base score", () => { + const base = computeFinalScore(DEFAULT_SEARCH_RANKING_WEIGHTS, { + vectorSimilarity: 0.5, + textRank: 0.5, + storePerformance: 0.5, + tierBoost: 0.5, + }); + const boosted = computeFinalScore(DEFAULT_SEARCH_RANKING_WEIGHTS, { + vectorSimilarity: 0.5, + textRank: 0.5, + storePerformance: 0.5, + tierBoost: 0.5, + taxonomyBoost: 1, + }); + expect(boosted - base).toBeCloseTo(TAXONOMY_BOOST_WEIGHT, 10); + }); + + it("leaves the score unchanged when there is no taxonomy match", () => { + const withZero = computeFinalScore(DEFAULT_SEARCH_RANKING_WEIGHTS, { + vectorSimilarity: 0.9, + textRank: 1, + storePerformance: 0.8, + tierBoost: 0.5, + taxonomyBoost: 0, + }); + const withoutField = computeFinalScore(DEFAULT_SEARCH_RANKING_WEIGHTS, { + vectorSimilarity: 0.9, + textRank: 1, + storePerformance: 0.8, + tierBoost: 0.5, + }); + expect(withZero).toBe(withoutField); + }); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-search-ranking.ts b/apps/backend/src/channels/whatsapp/whatsapp-search-ranking.ts new file mode 100644 index 00000000..55dd02a2 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-search-ranking.ts @@ -0,0 +1,223 @@ +import { StoreTier } from "@prisma/client"; +import { + ProductTaxonomyMatchConfidence, + resolveProductTaxonomyLabel, +} from "@twizrr/shared"; + +import { SearchRankingWeights } from "../../config/search.config"; + +// Approved WIZZA text discovery hybrid ranking contract: +// final_score = vector_similarity * 0.5 + text_rank * 0.2 +// + store_performance * 0.2 + tier_boost * 0.1 +// Weights are configurable via SEARCH_WEIGHT_* (see src/config/search.config.ts). +// +// Taxonomy is an ADDITIONAL deterministic signal applied on top of the hybrid +// score as a small additive boost (see computeTaxonomyMatchScore / +// TAXONOMY_BOOST_WEIGHT). It is intentionally NOT part of the normalized +// SEARCH_WEIGHT_* sum-to-1.0 contract, so it does not change existing env +// weights or the vector/text/store/tier ranking contract. + +// Stores below this completed-order count have insufficient history and +// receive the neutral default instead of a zero performance score. +export const MIN_PERFORMANCE_HISTORY_ORDERS = 5; +export const DEFAULT_STORE_PERFORMANCE = 0.5; +const COMPLETION_RATE_BPS_DENOMINATOR = 10000; + +export interface RankingStoreContext { + tier?: StoreTier | null; + completedOrders?: number | null; + orderCompletionRateBps?: number | null; +} + +export interface RankingTextFields { + name: string; + title?: string | null; + shortDescription?: string | null; + description?: string | null; + categoryTag?: string | null; +} + +export interface RankingScoreComponents { + vectorSimilarity: number; + textRank: number; + storePerformance: number; + tierBoost: number; + // Additive taxonomy signal in [0, 1]; defaults to 0 when there is no + // taxonomy match so the base hybrid contract is unchanged. + taxonomyBoost?: number; +} + +// Additive weight applied to the taxonomy match score. Kept small so taxonomy +// nudges ranking toward the resolved category without overriding a strong +// vector/text signal. Applied OUTSIDE the sum-to-1.0 SEARCH_WEIGHT_* contract. +export const TAXONOMY_BOOST_WEIGHT = 0.2; + +// Match strengths: an exact subcategory match is the strongest signal; a parent +// category match is a smaller boost. Scaled by query-side confidence. +const TAXONOMY_SUBCATEGORY_SCORE = 1; +const TAXONOMY_PARENT_SCORE = 0.5; + +const TAXONOMY_CONFIDENCE_FACTOR: Record< + ProductTaxonomyMatchConfidence, + number +> = { + high: 1, + medium: 0.7, + low: 0.4, + none: 0, +}; + +export interface TaxonomyRankingHints { + parentCategoryLabels: string[]; + subcategoryLabels: string[]; + confidence: ProductTaxonomyMatchConfidence; +} + +export interface RankingCategoryFields { + // Product category label (e.g. "Shoes & Footwear" or "Sneakers"). + platformCategory?: string | null; + // Category slug (e.g. "sneakers"); resolves to the same taxonomy node. + categoryTag?: string | null; +} + +/** + * Deterministic taxonomy match score in [0, 1] for a candidate product against + * the query's resolved taxonomy hints. Returns: + * - subcategory strength when the candidate's category resolves to one of the + * query's subcategory labels (strongest), + * - parent strength when it resolves to one of the query's parent labels, + * - 0 when there is no confident match. + * Reuses the shared taxonomy resolver - no synonym/taxonomy logic is duplicated. + */ +export function computeTaxonomyMatchScore( + hints: TaxonomyRankingHints | null | undefined, + fields: RankingCategoryFields, +): number { + if (!hints || hints.confidence === "none") { + return 0; + } + if ( + hints.subcategoryLabels.length === 0 && + hints.parentCategoryLabels.length === 0 + ) { + return 0; + } + + const subcategorySet = new Set(hints.subcategoryLabels); + const parentSet = new Set(hints.parentCategoryLabels); + const confidenceFactor = TAXONOMY_CONFIDENCE_FACTOR[hints.confidence]; + + let best = 0; + for (const value of [fields.platformCategory, fields.categoryTag]) { + if (!value) { + continue; + } + const match = resolveProductTaxonomyLabel(value); + if (!match) { + continue; + } + if (match.subcategoryLabel && subcategorySet.has(match.subcategoryLabel)) { + best = Math.max(best, TAXONOMY_SUBCATEGORY_SCORE); + } else if (match.parentLabel && parentSet.has(match.parentLabel)) { + best = Math.max(best, TAXONOMY_PARENT_SCORE); + } + } + + return clampUnit(best * confidenceFactor); +} + +export function clampUnit(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.min(1, Math.max(0, value)); +} + +export function computeTextRank( + query: string, + fields: RankingTextFields, +): number { + const normalizedQuery = normalizeRankingText(query); + if (!normalizedQuery) { + return 0; + } + + const tokens = normalizedQuery.split(" ").filter(Boolean); + const primary = normalizeRankingText(`${fields.title ?? ""} ${fields.name}`); + const secondary = normalizeRankingText( + `${fields.shortDescription ?? ""} ${fields.description ?? ""} ${fields.categoryTag ?? ""}`, + ); + + if (primary.includes(normalizedQuery)) { + return 1; + } + if (tokens.every((token) => primary.includes(token))) { + return 0.8; + } + if ( + secondary.includes(normalizedQuery) || + tokens.every((token) => secondary.includes(token)) + ) { + return 0.5; + } + if ( + tokens.some((token) => primary.includes(token) || secondary.includes(token)) + ) { + return 0.3; + } + return 0; +} + +export function computeStorePerformance( + store?: RankingStoreContext | null, +): number { + const completedOrders = store?.completedOrders ?? 0; + const completionRateBps = store?.orderCompletionRateBps; + + if ( + completedOrders < MIN_PERFORMANCE_HISTORY_ORDERS || + completionRateBps === null || + completionRateBps === undefined + ) { + return DEFAULT_STORE_PERFORMANCE; + } + + return clampUnit(completionRateBps / COMPLETION_RATE_BPS_DENOMINATOR); +} + +export function computeTierBoost(tier?: StoreTier | null): number { + switch (tier) { + case StoreTier.TIER_2: + return 1; + case StoreTier.TIER_1: + return 0.5; + default: + return 0; + } +} + +export function computeFinalScore( + weights: SearchRankingWeights, + components: RankingScoreComponents, +): number { + const base = + clampUnit(components.vectorSimilarity) * weights.vector + + clampUnit(components.textRank) * weights.text + + clampUnit(components.storePerformance) * weights.performance + + clampUnit(components.tierBoost) * weights.tier; + + // Taxonomy is additive on top of the normalized base score. It defaults to 0, + // so candidates with no taxonomy match keep their exact existing score. + const taxonomy = + clampUnit(components.taxonomyBoost ?? 0) * TAXONOMY_BOOST_WEIGHT; + + return base + taxonomy; +} + +function normalizeRankingText(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp-session.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-session.service.spec.ts new file mode 100644 index 00000000..70b8c93d --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-session.service.spec.ts @@ -0,0 +1,281 @@ +import { Logger } from "@nestjs/common"; +import type { PrismaService } from "../../prisma/prisma.service"; +import type { RedisService } from "../../redis/redis.service"; +import { WhatsAppSessionService } from "./whatsapp-session.service"; +import { CONSENT_CACHE_TTL, WA_CONSENT_PREFIX } from "./whatsapp.constants"; + +type ConsentLogRecord = { + phone: string; + isConsentGiven: boolean; + consentedAt: Date; +}; + +type FindLatestConsentArgs = { + where: { phone: string }; + orderBy: [{ consentedAt: "desc" }, { id: "desc" }]; + select: { isConsentGiven: true; consentedAt: true }; +}; + +type CreateConsentLogArgs = { + data: { + phone: string; + isConsentGiven: boolean; + consentVersion: "v1"; + source: "whatsapp"; + }; + select: { phone: true; isConsentGiven: true; consentedAt: true }; +}; + +type UpsertSessionArgs = { + where: { phone: string }; + create: { + phone: string; + userId: string | null; + consentGiven: boolean; + consentAt: Date | null; + lastMessageAt: Date; + }; + update: { + userId?: string | null; + consentGiven: boolean; + consentAt?: Date | null; + lastMessageAt: Date; + }; + select: { + id: true; + phone: true; + userId: true; + consentGiven: true; + consentAt: true; + }; +}; + +type SessionRecord = { + id: string; + phone: string; + userId: string | null; + consentGiven: boolean; + consentAt: Date | null; +}; + +describe("WhatsAppSessionService", () => { + const prisma = { + whatsAppConsentLog: { + findFirst: jest.fn< + Promise, + [FindLatestConsentArgs] + >(), + create: jest.fn, [CreateConsentLogArgs]>(), + }, + whatsAppSession: { + upsert: jest.fn, [UpsertSessionArgs]>(), + }, + }; + const redis = { + get: jest.fn, [string]>(), + set: jest.fn, [string, string, number]>(), + }; + + let service: WhatsAppSessionService; + let loggerWarnSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + loggerWarnSpy = jest.spyOn(Logger.prototype, "warn").mockImplementation(); + redis.get.mockResolvedValue(null); + redis.set.mockResolvedValue(true); + prisma.whatsAppConsentLog.findFirst.mockResolvedValue(null); + prisma.whatsAppConsentLog.create.mockImplementation( + async ({ data }: CreateConsentLogArgs) => ({ + phone: data.phone, + isConsentGiven: data.isConsentGiven, + consentedAt: new Date("2026-06-09T00:00:00.000Z"), + }), + ); + prisma.whatsAppSession.upsert.mockImplementation( + async ({ create }: UpsertSessionArgs) => ({ + id: "wa-session-1", + phone: create.phone, + userId: create.userId, + consentGiven: create.consentGiven, + consentAt: create.consentAt, + }), + ); + service = new WhatsAppSessionService( + prisma as unknown as PrismaService, + redis as unknown as RedisService, + ); + }); + + afterEach(() => { + loggerWarnSpy.mockRestore(); + }); + + it("normalizes Nigerian WhatsApp numbers to E.164 format", () => { + expect(service.normalizePhone("2348012345678")).toBe("+2348012345678"); + expect(service.normalizePhone("08012345678")).toBe("+2348012345678"); + expect(service.normalizePhone("+2348012345678")).toBe("+2348012345678"); + }); + + it.each(["", " ", "abc", "+abc", "+", "0"])( + "rejects invalid WhatsApp phone input %p", + (phone) => { + expect(() => service.normalizePhone(phone)).toThrow( + "Invalid WhatsApp phone number.", + ); + }, + ); + + it("reads inbound consent state from the latest durable consent log", async () => { + prisma.whatsAppConsentLog.findFirst.mockResolvedValue({ + phone: "+2348012345678", + isConsentGiven: true, + consentedAt: new Date("2026-06-09T00:00:00.000Z"), + }); + + const state = await service.recordInbound("2348012345678", "user-1"); + + expect(prisma.whatsAppConsentLog.findFirst).toHaveBeenCalledWith({ + where: { phone: "+2348012345678" }, + orderBy: [{ consentedAt: "desc" }, { id: "desc" }], + select: { isConsentGiven: true, consentedAt: true }, + }); + expect(redis.set).toHaveBeenCalledWith( + `${WA_CONSENT_PREFIX}+2348012345678`, + "true", + CONSENT_CACHE_TTL, + ); + expect(state).toEqual({ + id: "wa-session-1", + phone: "+2348012345678", + userId: "user-1", + isConsentGiven: true, + consentAt: new Date("2026-06-09T00:00:00.000Z"), + }); + }); + + it("uses Redis consent cache with TTL before reading durable logs", async () => { + redis.get.mockResolvedValue("true"); + + const state = await service.recordInbound("2348012345678"); + + expect(prisma.whatsAppConsentLog.findFirst).not.toHaveBeenCalled(); + expect(state).toEqual({ + id: "wa-session-1", + phone: "+2348012345678", + userId: null, + isConsentGiven: true, + consentAt: null, + }); + expect(prisma.whatsAppSession.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.not.objectContaining({ + userId: expect.anything(), + consentAt: expect.anything(), + }), + }), + ); + }); + + it("appends a consent grant row and updates Redis without mutating old rows", async () => { + await service.markConsentGiven("2348012345678"); + + expect(prisma.whatsAppConsentLog.create).toHaveBeenCalledWith({ + data: { + phone: "+2348012345678", + isConsentGiven: true, + consentVersion: "v1", + source: "whatsapp", + }, + select: { + phone: true, + isConsentGiven: true, + consentedAt: true, + }, + }); + expect(redis.set).toHaveBeenCalledWith( + `${WA_CONSENT_PREFIX}+2348012345678`, + "true", + CONSENT_CACHE_TTL, + ); + expect(prisma.whatsAppSession.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.not.objectContaining({ + userId: expect.anything(), + }), + }), + ); + }); + + it("appends a consent decline row and updates Redis without mutating old rows", async () => { + await service.markConsentDeclined("2348012345678"); + + expect(prisma.whatsAppConsentLog.create).toHaveBeenCalledWith({ + data: { + phone: "+2348012345678", + isConsentGiven: false, + consentVersion: "v1", + source: "whatsapp", + }, + select: { + phone: true, + isConsentGiven: true, + consentedAt: true, + }, + }); + expect(redis.set).toHaveBeenCalledWith( + `${WA_CONSENT_PREFIX}+2348012345678`, + "false", + CONSENT_CACHE_TTL, + ); + }); + + it("falls back to durable consent logs when Redis cache read fails", async () => { + redis.get.mockRejectedValue(new Error("redis unavailable")); + prisma.whatsAppConsentLog.findFirst.mockResolvedValue({ + phone: "+2348012345678", + isConsentGiven: true, + consentedAt: new Date("2026-06-09T00:00:00.000Z"), + }); + + const state = await service.recordInbound("2348012345678"); + + expect(state.isConsentGiven).toBe(true); + expect(prisma.whatsAppConsentLog.findFirst).toHaveBeenCalled(); + expect(loggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Consent cache read failed for +234******5678"), + ); + }); + + it("does not fail consent append when Redis cache write fails", async () => { + redis.set.mockRejectedValue(new Error("redis unavailable")); + + await expect( + service.markConsentGiven("2348012345678"), + ).resolves.toMatchObject({ + phone: "+2348012345678", + isConsentGiven: true, + }); + expect(prisma.whatsAppConsentLog.create).toHaveBeenCalled(); + expect(loggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Consent cache write failed for +234******5678"), + ); + }); + + it("detects consent accept and decline replies", () => { + expect(service.isConsentAcceptance("I agree")).toBe(true); + expect( + service.isConsentAcceptance(undefined, { + id: "twizrr_consent_accept", + title: "I Agree", + }), + ).toBe(true); + expect(service.isConsentDecline("No Thanks")).toBe(true); + expect( + service.isConsentDecline(undefined, { + id: "twizrr_consent_decline", + title: "No Thanks", + }), + ).toBe(true); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-session.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-session.service.ts new file mode 100644 index 00000000..d602aaab --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-session.service.ts @@ -0,0 +1,221 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { PrismaService } from "../../prisma/prisma.service"; +import { RedisService } from "../../redis/redis.service"; +import { CONSENT_CACHE_TTL, WA_CONSENT_PREFIX } from "./whatsapp.constants"; +import { maskWhatsAppPhone, normalizeWhatsAppPhone } from "./whatsapp.utils"; + +export const WHATSAPP_CONSENT_ACCEPT_ID = "twizrr_consent_accept"; +export const WHATSAPP_CONSENT_DECLINE_ID = "twizrr_consent_decline"; + +export const WHATSAPP_CONSENT_MESSAGE = + "Welcome to twizrr Shopping Assistant.\n\nBy continuing, you agree that twizrr may process your WhatsApp messages to help you find and purchase products. You can stop anytime by replying No Thanks."; + +export const WHATSAPP_CONSENT_DECLINED_MESSAGE = + "No problem. You can start shopping anytime by messaging us again."; + +export interface WhatsAppConsentState { + id: string; + phone: string; + userId: string | null; + isConsentGiven: boolean; + consentAt: Date | null; +} + +@Injectable() +export class WhatsAppSessionService { + private readonly logger = new Logger(WhatsAppSessionService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly redis: RedisService, + ) {} + + normalizePhone(phone: string): string { + return normalizeWhatsAppPhone(phone); + } + + async recordInbound( + phone: string, + userId?: string | null, + ): Promise { + const normalizedPhone = this.normalizePhone(phone); + const cachedConsent = await this.readConsentCache(normalizedPhone); + + if (cachedConsent !== null) { + return this.upsertSessionState(normalizedPhone, cachedConsent, { + userId, + }); + } + + const latestConsent = await this.prisma.whatsAppConsentLog.findFirst({ + where: { phone: normalizedPhone }, + orderBy: [{ consentedAt: "desc" }, { id: "desc" }], + select: { isConsentGiven: true, consentedAt: true }, + }); + const isConsentGiven = latestConsent?.isConsentGiven ?? false; + + await this.writeConsentCache(normalizedPhone, isConsentGiven); + + return this.upsertSessionState(normalizedPhone, isConsentGiven, { + userId, + consentAt: latestConsent?.consentedAt ?? null, + }); + } + + async markConsentGiven(phone: string): Promise { + const normalizedPhone = this.normalizePhone(phone); + return this.appendConsentDecision(normalizedPhone, true); + } + + async markConsentDeclined(phone: string): Promise { + const normalizedPhone = this.normalizePhone(phone); + return this.appendConsentDecision(normalizedPhone, false); + } + + private async appendConsentDecision( + phone: string, + isConsentGiven: boolean, + ): Promise { + const consentLog = await this.prisma.whatsAppConsentLog.create({ + data: { + phone, + isConsentGiven, + consentVersion: "v1", + source: "whatsapp", + }, + select: { + phone: true, + isConsentGiven: true, + consentedAt: true, + }, + }); + + await this.writeConsentCache(phone, isConsentGiven); + + return this.upsertSessionState( + consentLog.phone, + consentLog.isConsentGiven, + { + consentAt: consentLog.consentedAt, + }, + ); + } + + private async upsertSessionState( + phone: string, + isConsentGiven: boolean, + options: { userId?: string | null; consentAt?: Date | null } = {}, + ): Promise { + const { userId, consentAt } = options; + const lastMessageAt = new Date(); + const session = await this.prisma.whatsAppSession.upsert({ + where: { phone }, + create: { + phone, + userId: userId ?? null, + consentGiven: isConsentGiven, + consentAt: consentAt ?? null, + lastMessageAt, + }, + update: { + consentGiven: isConsentGiven, + ...(userId !== undefined ? { userId } : {}), + ...(consentAt !== undefined ? { consentAt } : {}), + lastMessageAt, + }, + select: { + id: true, + phone: true, + userId: true, + consentGiven: true, + consentAt: true, + }, + }); + + return { + id: session.id, + phone: session.phone, + userId: session.userId, + isConsentGiven: session.consentGiven, + consentAt: session.consentAt, + }; + } + + isConsentAcceptance( + messageText?: string, + interactiveReply?: { id: string; title: string }, + ): boolean { + const id = interactiveReply?.id?.toLowerCase(); + const title = interactiveReply?.title?.toLowerCase(); + const text = messageText?.trim().toLowerCase(); + + return ( + id === WHATSAPP_CONSENT_ACCEPT_ID || + title === "i agree" || + text === "i agree" || + text === "agree" || + text === "yes" + ); + } + + isConsentDecline( + messageText?: string, + interactiveReply?: { id: string; title: string }, + ): boolean { + const id = interactiveReply?.id?.toLowerCase(); + const title = interactiveReply?.title?.toLowerCase(); + const text = messageText?.trim().toLowerCase(); + + return ( + id === WHATSAPP_CONSENT_DECLINE_ID || + title === "no thanks" || + text === "no thanks" || + text === "no" || + text === "stop" + ); + } + + private async readConsentCache(phone: string): Promise { + let cached: string | null; + + try { + cached = await this.redis.get(`${WA_CONSENT_PREFIX}${phone}`); + } catch (error) { + this.logger.warn( + `Consent cache read failed for ${maskWhatsAppPhone(phone)}: ${ + error instanceof Error ? error.message : error + }`, + ); + return null; + } + + if (cached === "true") { + return true; + } + + if (cached === "false") { + return false; + } + + return null; + } + + private async writeConsentCache( + phone: string, + isConsentGiven: boolean, + ): Promise { + try { + await this.redis.set( + `${WA_CONSENT_PREFIX}${phone}`, + String(isConsentGiven), + CONSENT_CACHE_TTL, + ); + } catch (error) { + this.logger.warn( + `Consent cache write failed for ${maskWhatsAppPhone(phone)}: ${ + error instanceof Error ? error.message : error + }`, + ); + } + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp-shared.module.ts b/apps/backend/src/channels/whatsapp/whatsapp-shared.module.ts new file mode 100644 index 00000000..9f2ad375 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-shared.module.ts @@ -0,0 +1,62 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; + +import { PrismaModule } from "../../prisma/prisma.module"; +import { RedisModule } from "../../redis/redis.module"; +import { AiModule } from "../../integrations/ai/ai.module"; +import { CloudinaryModule } from "../../integrations/cloudinary/cloudinary.module"; +import { MetaWhatsAppModule } from "../../integrations/meta-whatsapp/meta-whatsapp.module"; +import { TaxonomyDiscoveryModule } from "../../domains/commerce/search/taxonomy-discovery.module"; +import { ImageSearchService } from "./image-search.service"; +import { WhatsAppInteractiveService } from "./whatsapp-interactive.service"; +import { WhatsAppIntentService } from "./whatsapp-intent.service"; +import { WhatsAppLoggerService } from "./whatsapp-logger.service"; +import { WhatsAppProductDiscoveryService } from "./whatsapp-product-discovery.service"; +import { WhatsAppSessionService } from "./whatsapp-session.service"; +import { WhatsAppAnalyticsService } from "./whatsapp-analytics.service"; +import { WizzaLocationResolver } from "./wizza-location-resolver.service"; +import { ShopperAgentOrchestrator } from "./agent/shopper-agent-orchestrator.service"; +import { ShopperAgentPolicyService } from "./agent/shopper-agent-policy.service"; +import { ShopperAgentToolRegistry } from "./agent/shopper-agent-tool.registry"; + +@Module({ + imports: [ + ConfigModule, + PrismaModule, + RedisModule, + AiModule, + CloudinaryModule, + MetaWhatsAppModule, + TaxonomyDiscoveryModule, + ], + providers: [ + WhatsAppInteractiveService, + WhatsAppIntentService, + ImageSearchService, + WhatsAppLoggerService, + WhatsAppProductDiscoveryService, + WhatsAppSessionService, + WhatsAppAnalyticsService, + WizzaLocationResolver, + ShopperAgentToolRegistry, + ShopperAgentPolicyService, + ShopperAgentOrchestrator, + ], + exports: [ + WhatsAppInteractiveService, + WhatsAppIntentService, + ImageSearchService, + WhatsAppLoggerService, + WhatsAppProductDiscoveryService, + WhatsAppSessionService, + WhatsAppAnalyticsService, + WizzaLocationResolver, + ShopperAgentToolRegistry, + ShopperAgentPolicyService, + ShopperAgentOrchestrator, + AiModule, + CloudinaryModule, + MetaWhatsAppModule, + ], +}) +export class WhatsAppSharedModule {} diff --git a/apps/backend/src/channels/whatsapp/whatsapp-shopper-action.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-shopper-action.service.spec.ts new file mode 100644 index 00000000..075074cc --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-shopper-action.service.spec.ts @@ -0,0 +1,489 @@ +import { NotFoundException } from "@nestjs/common"; +import { WhatsAppShopperActionService } from "./whatsapp-shopper-action.service"; +import { WhatsAppRetryableActionError } from "./whatsapp-retryable-action.error"; +import { + WA_SELECTED_DELIVERY_ADDRESS_PREFIX, + WA_SHOPPER_ACTION_CANCEL_ID, + WA_SHOPPER_ACTION_CONFIRM_ID, +} from "./whatsapp.constants"; + +describe("WhatsAppShopperActionService", () => { + const cartService = { + getCart: jest.fn(), + addItemToCart: jest.fn(), + updateItemQuantity: jest.fn(), + removeItemFromCart: jest.fn(), + }; + const userService = { + getAddresses: jest.fn(), + }; + const redisService = { + get: jest.fn(), + getDel: jest.fn(), + set: jest.fn(), + del: jest.fn(), + }; + const productDiscoveryService = { + resolveRecentProductForCart: jest.fn(), + }; + const configService = { + get: jest.fn((key: string) => + key === "app.webUrl" ? "https://app.twizrr.com" : undefined, + ), + }; + + let service: WhatsAppShopperActionService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new WhatsAppShopperActionService( + cartService as never, + userService as never, + redisService as never, + productDiscoveryService as never, + configService as never, + ); + }); + + it("prepares a recent native product without mutating the cart", async () => { + productDiscoveryService.resolveRecentProductForCart.mockResolvedValue({ + status: "available", + productId: "product-1", + title: "Black Sneakers", + productCode: "TWZ-123456", + }); + + const result = await service.prepareAddToCart("+2348012345678", "user-1", { + productReference: "first", + quantity: 2, + }); + + expect( + productDiscoveryService.resolveRecentProductForCart, + ).toHaveBeenCalledWith("+2348012345678", "first"); + expect(redisService.set).toHaveBeenCalledWith( + expect.stringContaining("2348012345678"), + expect.stringContaining('"action":"add_to_cart"'), + 300, + ); + expect(cartService.addItemToCart).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + confirmationButtons: true, + output: { + status: "confirmation_required", + nextStep: "confirm_or_cancel", + }, + }); + }); + + it("does not request confirmation when pending action storage fails", async () => { + productDiscoveryService.resolveRecentProductForCart.mockResolvedValue({ + status: "available", + productId: "product-1", + title: "Black Sneakers", + productCode: "TWZ-123456", + }); + redisService.set.mockRejectedValueOnce(new Error("Redis unavailable")); + + const result = await service.prepareAddToCart("+2348012345678", "user-1", { + productReference: "first", + quantity: 1, + }); + + expect(result).toEqual({ + message: "I could not stage that change, please try again.", + output: { status: "temporarily_unavailable" }, + }); + expect(result).not.toHaveProperty("confirmationButtons"); + expect(cartService.addItemToCart).not.toHaveBeenCalled(); + }); + + it("consumes confirmation once before adding to the cart", async () => { + const pending = JSON.stringify({ + action: "add_to_cart", + userId: "user-1", + productId: "product-1", + productTitle: "Black Sneakers", + quantity: 2, + }); + redisService.get.mockResolvedValue(pending); + redisService.getDel + .mockResolvedValueOnce(pending) + .mockResolvedValueOnce(null); + cartService.addItemToCart.mockResolvedValue({ + items: [{ quantity: 2 }], + }); + + const first = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + undefined, + { id: WA_SHOPPER_ACTION_CONFIRM_ID }, + ); + const second = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + "confirm", + ); + + expect(cartService.addItemToCart).toHaveBeenCalledTimes(1); + expect(cartService.addItemToCart).toHaveBeenCalledWith("user-1", { + productId: "product-1", + quantity: 2, + }); + expect(first.message).toContain("Added Black Sneakers"); + expect(second.message).toContain("expired"); + }); + + it("cancels without applying the pending mutation", async () => { + redisService.get.mockResolvedValue( + JSON.stringify({ + action: "remove_from_cart", + userId: "user-1", + cartItemId: "cart-item-1", + productTitle: "Black Sneakers", + }), + ); + + const result = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + undefined, + { id: WA_SHOPPER_ACTION_CANCEL_ID }, + ); + + expect(redisService.del).toHaveBeenCalled(); + expect(cartService.removeItemFromCart).not.toHaveBeenCalled(); + expect(result).toEqual({ + handled: true, + message: "That action was cancelled.", + }); + }); + + it("preserves retry semantics when pending action storage cannot be read", async () => { + redisService.get.mockRejectedValueOnce(new Error("Redis unavailable")); + + await expect( + service.handlePendingActionReply("+2348012345678", "user-1", "confirm"), + ).rejects.toBeInstanceOf(WhatsAppRetryableActionError); + expect(cartService.addItemToCart).not.toHaveBeenCalled(); + }); + + it("does not retry an ambiguous cart failure after the domain call starts", async () => { + const pending = JSON.stringify({ + action: "add_to_cart", + userId: "user-1", + productId: "product-1", + productTitle: "Black Sneakers", + quantity: 1, + }); + redisService.get.mockResolvedValue(pending); + redisService.getDel.mockResolvedValue(pending); + cartService.addItemToCart.mockRejectedValue(new Error("Database timeout")); + + const result = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + "confirm", + ); + + expect(result).toEqual({ + handled: true, + message: + "I could not confirm whether that action completed. Check your current cart or checkout state before trying again.", + }); + expect(redisService.set).not.toHaveBeenCalled(); + }); + + it("rejects a pending mutation created for a different linked user", async () => { + const pending = JSON.stringify({ + action: "remove_from_cart", + userId: "user-1", + cartItemId: "cart-item-1", + productTitle: "Black Sneakers", + }); + redisService.get.mockResolvedValue(pending); + redisService.getDel.mockResolvedValue(pending); + + const result = await service.handlePendingActionReply( + "+2348012345678", + "user-2", + "confirm", + ); + + expect(result).toEqual({ + handled: true, + message: "That confirmation has expired. Start the cart action again.", + }); + expect(cartService.removeItemFromCart).not.toHaveBeenCalled(); + }); + + it("does not expose domain exception details in shopper-facing errors", async () => { + const pending = JSON.stringify({ + action: "remove_from_cart", + userId: "user-1", + cartItemId: "private-cart-item-id", + productTitle: "Black Sneakers", + }); + redisService.get.mockResolvedValue(pending); + redisService.getDel.mockResolvedValue(pending); + cartService.removeItemFromCart.mockRejectedValue( + new NotFoundException("Cart item private-cart-item-id was not found"), + ); + + const result = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + "confirm", + ); + + expect(result).toEqual({ + handled: true, + message: + "That cart item or product is no longer available. Your cart was not changed.", + }); + expect(result.message).not.toContain("private-cart-item-id"); + }); + + it.each(["sourced_unsupported", "variant_unsupported"] as const)( + "does not prepare unsupported %s products", + async (status) => { + productDiscoveryService.resolveRecentProductForCart.mockResolvedValue({ + status, + publicProductUrl: "https://twizrr.com/stores/store-one/p/TWZ-123456", + }); + + const result = await service.prepareAddToCart( + "+2348012345678", + "user-1", + { quantity: 1 }, + ); + + expect(redisService.set).not.toHaveBeenCalled(); + expect(cartService.addItemToCart).not.toHaveBeenCalled(); + expect(result.output.status).toBe("unsupported"); + expect(result.message).toContain( + "https://twizrr.com/stores/store-one/p/TWZ-123456", + ); + }, + ); + + it("resolves updates only from the linked shopper's current cart", async () => { + cartService.getCart.mockResolvedValue({ + items: [ + { + id: "cart-item-1", + quantity: 1, + product: { + name: "Black Sneakers", + title: "Black Sneakers", + productCode: "TWZ-123456", + }, + }, + ], + }); + + const result = await service.prepareUpdateCartQuantity( + "+2348012345678", + "user-1", + { cartItemReference: "Black Sneakers", quantity: 3 }, + ); + + expect(cartService.getCart).toHaveBeenCalledWith("user-1"); + expect(redisService.set).toHaveBeenCalledWith( + expect.stringContaining("2348012345678"), + expect.stringContaining('"cartItemId":"cart-item-1"'), + 300, + ); + expect(cartService.updateItemQuantity).not.toHaveBeenCalled(); + expect(result.output.status).toBe("confirmation_required"); + }); + + it("selects only a saved address after explicit confirmation", async () => { + userService.getAddresses.mockResolvedValue({ + deliveryAddresses: [ + { + id: "address-1", + label: "Home", + street: "1 Market Road", + city: "Lagos", + state: "Lagos", + isDefault: true, + }, + ], + }); + + const prepared = await service.prepareSelectDeliveryAddress( + "+2348012345678", + "user-1", + { addressReference: "home" }, + ); + const pending = redisService.set.mock.calls[0][1] as string; + redisService.get.mockResolvedValue(pending); + redisService.getDel.mockResolvedValue(pending); + + const confirmed = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + "yes", + ); + + expect(prepared.output.status).toBe("confirmation_required"); + expect(redisService.set).toHaveBeenLastCalledWith( + expect.stringContaining(WA_SELECTED_DELIVERY_ADDRESS_PREFIX), + JSON.stringify({ + userId: "user-1", + addressId: "address-1", + addressLabel: "Home", + }), + 900, + ); + expect(confirmed.message).toContain( + "Home is selected for this shopping session", + ); + }); + + it("clears stale confirmation when the shopper sends another request", async () => { + redisService.get.mockResolvedValue( + JSON.stringify({ + action: "remove_from_cart", + userId: "user-1", + cartItemId: "cart-item-1", + productTitle: "Black Sneakers", + }), + ); + + const result = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + "show me cheaper shoes", + ); + + expect(redisService.del).toHaveBeenCalled(); + expect(result).toEqual({ handled: false }); + expect(cartService.removeItemFromCart).not.toHaveBeenCalled(); + }); + + it("summarizes an available cart before staging checkout", async () => { + cartService.getCart.mockResolvedValue({ + items: [ + { id: "cart-1", quantity: 2, isAvailable: true }, + { id: "cart-2", quantity: 1, isAvailable: true }, + ], + subtotalKobo: "1250000", + }); + + const result = await service.prepareCheckoutHandoff( + "+2348012345678", + "user-1", + ); + + expect(result).toMatchObject({ + confirmationButtons: true, + output: { + status: "confirmation_required", + itemCount: 2, + totalQuantity: 3, + subtotalDisplay: "NGN 12,500.00", + nextStep: "confirm_or_cancel", + }, + }); + expect(redisService.set).toHaveBeenCalledWith( + expect.stringContaining("2348012345678"), + JSON.stringify({ action: "start_checkout", userId: "user-1" }), + 300, + ); + expect(cartService).not.toHaveProperty("checkout"); + }); + + it("does not stage checkout for an empty or unavailable cart", async () => { + cartService.getCart + .mockResolvedValueOnce({ items: [], subtotalKobo: "0" }) + .mockResolvedValueOnce({ + items: [{ id: "cart-1", quantity: 1, isAvailable: false }], + subtotalKobo: "500000", + }); + + const empty = await service.prepareCheckoutHandoff( + "+2348012345678", + "user-1", + ); + const unavailable = await service.prepareCheckoutHandoff( + "+2348012345678", + "user-1", + ); + + expect(empty.output.status).toBe("empty"); + expect(unavailable.output.status).toBe("unavailable"); + expect(redisService.set).not.toHaveBeenCalled(); + }); + + it("revalidates the cart and returns the public web cart only after confirmation", async () => { + const pending = JSON.stringify({ + action: "start_checkout", + userId: "user-1", + }); + redisService.get.mockResolvedValue(pending); + redisService.getDel.mockResolvedValue(pending); + cartService.getCart.mockResolvedValue({ + items: [{ id: "cart-1", quantity: 1, isAvailable: true }], + subtotalKobo: "500000", + }); + + const result = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + "confirm", + ); + + expect(cartService.getCart).toHaveBeenCalledWith("user-1"); + expect(result.message).toContain("https://app.twizrr.com/cart"); + expect(result.message).toContain("Payment is completed on Twizrr web"); + expect(result.message).not.toContain("cart-1"); + }); + + it("does not return a checkout link when the cart changes before confirmation", async () => { + const pending = JSON.stringify({ + action: "start_checkout", + userId: "user-1", + }); + redisService.get.mockResolvedValue(pending); + redisService.getDel.mockResolvedValue(pending); + cartService.getCart.mockResolvedValue({ + items: [{ id: "cart-1", quantity: 1, isAvailable: false }], + subtotalKobo: "500000", + }); + + const result = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + "yes", + ); + + expect(result.message).toContain("cart changed"); + expect(result.message).not.toContain("https://"); + }); + + it("fails safely when the web handoff URL is not configured", async () => { + const pending = JSON.stringify({ + action: "start_checkout", + userId: "user-1", + }); + redisService.get.mockResolvedValue(pending); + redisService.getDel.mockResolvedValue(pending); + cartService.getCart.mockResolvedValue({ + items: [{ id: "cart-1", quantity: 1, isAvailable: true }], + subtotalKobo: "500000", + }); + configService.get.mockReturnValue(undefined); + + const result = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + "confirm", + ); + + expect(result.message).toContain("temporarily unavailable"); + expect(result.message).not.toContain("http"); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-shopper-action.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-shopper-action.service.ts new file mode 100644 index 00000000..5e486938 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-shopper-action.service.ts @@ -0,0 +1,846 @@ +import { + HttpStatus, + Inject, + Injectable, + Logger, + forwardRef, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { CartService } from "../../domains/orders/cart/cart.service"; +import { UserService } from "../../domains/users/user/user.service"; +import { RedisService } from "../../redis/redis.service"; +import type { + ShopperAgentToolInputMap, + ShopperAgentToolOutputMap, +} from "./agent/shopper-agent.types"; +import { + SHOPPER_ACTION_CONFIRMATION_TTL, + SESSION_TTL, + WA_PENDING_SHOPPER_ACTION_PREFIX, + WA_SELECTED_DELIVERY_ADDRESS_PREFIX, + WA_SHOPPER_ACTION_CANCEL_ID, + WA_SHOPPER_ACTION_CONFIRM_ID, + isWhatsAppCartQuantity, +} from "./whatsapp.constants"; +import { WhatsAppProductDiscoveryService } from "./whatsapp-product-discovery.service"; +import { WhatsAppRetryableActionError } from "./whatsapp-retryable-action.error"; +import { maskWhatsAppPhone, normalizeWhatsAppPhone } from "./whatsapp.utils"; + +type ActionToolName = + | "add_to_cart" + | "update_cart_quantity" + | "remove_from_cart" + | "start_checkout" + | "select_delivery_address"; + +type PendingShopperAction = + | { + action: "add_to_cart"; + userId: string; + productId: string; + productTitle: string; + quantity: number; + } + | { + action: "update_cart_quantity"; + userId: string; + cartItemId: string; + productTitle: string; + quantity: number; + } + | { + action: "remove_from_cart"; + userId: string; + cartItemId: string; + productTitle: string; + } + | { + action: "select_delivery_address"; + userId: string; + addressId: string; + addressLabel: string; + } + | { + action: "start_checkout"; + userId: string; + }; + +interface SavedAddress { + id: string; + label: string; + street: string; + city: string; + state: string; + isDefault: boolean; +} + +interface ActionPreparation { + message: string; + output: ShopperAgentToolOutputMap[Name]; + confirmationButtons?: true; +} + +export interface PendingActionReplyResult { + handled: boolean; + message?: string; +} + +@Injectable() +export class WhatsAppShopperActionService { + private readonly logger = new Logger(WhatsAppShopperActionService.name); + + constructor( + @Inject(forwardRef(() => CartService)) + private readonly cartService: CartService, + private readonly userService: UserService, + private readonly redisService: RedisService, + private readonly productDiscoveryService: WhatsAppProductDiscoveryService, + private readonly configService: ConfigService, + ) {} + + async prepareAddToCart( + phone: string, + userId: string, + input: ShopperAgentToolInputMap["add_to_cart"], + ): Promise> { + const product = + await this.productDiscoveryService.resolveRecentProductForCart( + phone, + input.productReference, + ); + + if (product.status === "no_context") { + return { + message: + "Search for a product first, then tell me which result you want to add.", + output: { status: "no_context" }, + }; + } + + if (product.status === "ambiguous") { + return { + message: + "Tell me which displayed result to add, such as add the first one or add number 2.", + output: { status: "no_context" }, + }; + } + + if (product.status === "sourced_unsupported") { + return { + message: product.publicProductUrl + ? `This item is not available for WhatsApp cart yet. Open it to continue: ${product.publicProductUrl}` + : "This item is not available for WhatsApp cart yet. Open it from the latest results to continue.", + output: { status: "unsupported" }, + }; + } + + if (product.status === "variant_unsupported") { + return { + message: product.publicProductUrl + ? `Choose the product option on Twizrr before adding it: ${product.publicProductUrl}` + : "This product needs an option such as size or colour. Open it on Twizrr to choose the option first.", + output: { status: "unsupported" }, + }; + } + + if (product.status === "not_found") { + return { + message: + "That product is no longer available. Search again for current options.", + output: { status: "no_context" }, + }; + } + + if (product.status !== "available") { + return { + message: + "I could not safely match that product. Search again and choose from the current results.", + output: { status: "no_context" }, + }; + } + + const staged = await this.storePendingAction(phone, { + action: "add_to_cart", + userId, + productId: product.productId, + productTitle: product.title, + quantity: input.quantity, + }); + if (!staged) { + return this.stagingFailure(); + } + + return { + message: `Add ${input.quantity} x ${product.title} to your cart?`, + output: { + status: "confirmation_required", + nextStep: "confirm_or_cancel", + }, + confirmationButtons: true, + }; + } + + async prepareUpdateCartQuantity( + phone: string, + userId: string, + input: ShopperAgentToolInputMap["update_cart_quantity"], + ): Promise> { + const target = await this.resolveCartItem(userId, input.cartItemReference); + if (target.status !== "available") { + return { + message: + target.status === "empty" + ? "Your cart is empty." + : "I could not match that to one current cart item. Refer to the item by its product name or position.", + output: { status: target.status }, + }; + } + + const staged = await this.storePendingAction(phone, { + action: "update_cart_quantity", + userId, + cartItemId: target.item.id, + productTitle: target.title, + quantity: input.quantity, + }); + if (!staged) { + return this.stagingFailure(); + } + + return { + message: `Change ${target.title} to quantity ${input.quantity}?`, + output: { + status: "confirmation_required", + nextStep: "confirm_or_cancel", + }, + confirmationButtons: true, + }; + } + + async prepareRemoveFromCart( + phone: string, + userId: string, + input: ShopperAgentToolInputMap["remove_from_cart"], + ): Promise> { + const target = await this.resolveCartItem(userId, input.cartItemReference); + if (target.status !== "available") { + return { + message: + target.status === "empty" + ? "Your cart is empty." + : "I could not match that to one current cart item. Refer to the item by its product name or position.", + output: { status: target.status }, + }; + } + + const staged = await this.storePendingAction(phone, { + action: "remove_from_cart", + userId, + cartItemId: target.item.id, + productTitle: target.title, + }); + if (!staged) { + return this.stagingFailure(); + } + + return { + message: `Remove ${target.title} from your cart?`, + output: { + status: "confirmation_required", + nextStep: "confirm_or_cancel", + }, + confirmationButtons: true, + }; + } + + async prepareSelectDeliveryAddress( + phone: string, + userId: string, + input: ShopperAgentToolInputMap["select_delivery_address"], + ): Promise> { + const addresses = await this.getSavedAddresses(userId); + if (!addresses.length) { + return { + message: + "You do not have a saved delivery address yet. Add one from your Twizrr settings.", + output: { status: "empty" }, + }; + } + + const address = this.resolveAddress(addresses, input.addressReference); + if (!address) { + return { + message: + "I could not match that saved address. Refer to it by label, such as home or work.", + output: { status: "not_found" }, + }; + } + + const staged = await this.storePendingAction(phone, { + action: "select_delivery_address", + userId, + addressId: address.id, + addressLabel: address.label, + }); + if (!staged) { + return this.stagingFailure(); + } + + return { + message: `Use ${address.label}: ${address.street}, ${address.city}, ${address.state} for this shopping session?`, + output: { + status: "confirmation_required", + nextStep: "confirm_or_cancel", + }, + confirmationButtons: true, + }; + } + + async prepareCheckoutHandoff( + phone: string, + userId: string, + ): Promise> { + const cart = await this.cartService.getCart(userId); + const summary = this.checkoutCartSummary(cart); + + if (summary.status === "empty") { + return { + message: "Your cart is empty. Add a product before starting checkout.", + output: { status: "empty" }, + }; + } + + if (summary.status === "unavailable") { + return { + message: + "One or more cart items are no longer available at the current quantity or price. Review your cart on Twizrr before checkout.", + output: { status: "unavailable" }, + }; + } + + const staged = await this.storePendingAction(phone, { + action: "start_checkout", + userId, + }); + if (!staged) { + return this.stagingFailure(); + } + + return { + message: `Your cart has ${summary.totalQuantity} ${summary.totalQuantity === 1 ? "unit" : "units"} across ${summary.itemCount} ${summary.itemCount === 1 ? "item" : "items"}, subtotal ${summary.subtotalDisplay}. Continue to secure checkout on Twizrr?`, + output: { + status: "confirmation_required", + itemCount: summary.itemCount, + totalQuantity: summary.totalQuantity, + subtotalDisplay: summary.subtotalDisplay, + nextStep: "confirm_or_cancel", + }, + confirmationButtons: true, + }; + } + + async handlePendingActionReply( + phone: string, + userId: string | null, + messageText?: string, + interactiveReply?: { id: string }, + ): Promise { + const key = this.pendingActionKey(phone); + const reply = interactiveReply?.id.trim().toLowerCase(); + const text = messageText?.trim().toLowerCase(); + const confirms = + reply === WA_SHOPPER_ACTION_CONFIRM_ID || + text === "confirm" || + text === "yes"; + const cancels = + reply === WA_SHOPPER_ACTION_CANCEL_ID || + text === "cancel" || + text === "no"; + + let raw: string | null; + try { + raw = await this.redisService.get(key); + } catch (error) { + this.logRedisFailure("pending shopper action read", phone, error); + if (confirms || cancels) { + throw new WhatsAppRetryableActionError( + "Pending shopper action could not be read", + error, + ); + } + return { handled: false }; + } + if (!raw) { + return { handled: false }; + } + + if (!confirms && !cancels) { + try { + await this.redisService.del(key); + } catch (error) { + this.logRedisFailure("stale shopper action clear", phone, error); + } + return { handled: false }; + } + + if (cancels) { + try { + await this.redisService.del(key); + } catch (error) { + this.logRedisFailure("shopper action cancellation", phone, error); + throw new WhatsAppRetryableActionError( + "Pending shopper action could not be cancelled", + error, + ); + } + return { handled: true, message: "That action was cancelled." }; + } + + let consumed: string | null; + try { + consumed = await this.redisService.getDel(key); + } catch (error) { + this.logRedisFailure("pending shopper action consume", phone, error); + throw new WhatsAppRetryableActionError( + "Pending shopper action could not be consumed", + error, + ); + } + const action = consumed ? this.parsePendingAction(consumed) : null; + if (!action || !userId || action.userId !== userId) { + return { + handled: true, + message: "That confirmation has expired. Start the cart action again.", + }; + } + + try { + return { + handled: true, + message: await this.executePendingAction(phone, action), + }; + } catch (error) { + const safeMessage = this.safeDomainError(phone, error); + if (!safeMessage) { + return { + handled: true, + message: + "I could not confirm whether that action completed. Check your current cart or checkout state before trying again.", + }; + } + return { + handled: true, + message: safeMessage, + }; + } + } + + private async executePendingAction( + phone: string, + action: PendingShopperAction, + ): Promise { + switch (action.action) { + case "add_to_cart": { + const cart = await this.cartService.addItemToCart(action.userId, { + productId: action.productId, + quantity: action.quantity, + }); + return `Added ${action.productTitle} to your cart. ${this.cartSummary(cart)}`; + } + case "update_cart_quantity": { + const cart = await this.cartService.updateItemQuantity( + action.userId, + action.cartItemId, + { quantity: action.quantity }, + ); + return `Updated ${action.productTitle} to quantity ${action.quantity}. ${this.cartSummary(cart)}`; + } + case "remove_from_cart": { + const cart = await this.cartService.removeItemFromCart( + action.userId, + action.cartItemId, + ); + return `Removed ${action.productTitle} from your cart. ${this.cartSummary(cart)}`; + } + case "select_delivery_address": { + const addresses = await this.getSavedAddresses(action.userId); + const address = addresses.find( + (candidate) => candidate.id === action.addressId, + ); + if (!address) { + return "That saved address is no longer available. Choose another saved address."; + } + try { + await this.redisService.set( + this.selectedAddressKey(phone), + JSON.stringify({ + userId: action.userId, + addressId: address.id, + addressLabel: address.label, + }), + SESSION_TTL, + ); + } catch (error) { + this.logRedisFailure("selected delivery address write", phone, error); + throw error; + } + return `${address.label} is selected for this shopping session.`; + } + case "start_checkout": { + const cart = await this.cartService.getCart(action.userId); + const summary = this.checkoutCartSummary(cart); + if (summary.status === "empty") { + return "Your cart is now empty. Add a product before starting checkout."; + } + if (summary.status === "unavailable") { + return "Your cart changed and one or more items are no longer available at the current quantity or price. Review your cart before checkout."; + } + + const handoffUrl = this.checkoutHandoffUrl(); + if (!handoffUrl) { + return "Secure web checkout is temporarily unavailable. Your cart has not changed. Please try again later."; + } + + return `Continue to secure checkout on Twizrr: ${handoffUrl}\n\nYour cart has ${summary.totalQuantity} ${summary.totalQuantity === 1 ? "unit" : "units"}, subtotal ${summary.subtotalDisplay}. Payment is completed on Twizrr web.`; + } + } + } + + private async resolveCartItem( + userId: string, + reference?: string, + ): Promise< + | { + status: "available"; + item: Awaited>["items"][number]; + title: string; + } + | { status: "empty" | "not_found" } + > { + const cart = await this.cartService.getCart(userId); + if (!cart.items.length) { + return { status: "empty" }; + } + + const matched = this.matchByReference(cart.items, reference, (item) => [ + item.product.title || item.product.name, + item.product.productCode || "", + ]); + if (!matched) { + return { status: "not_found" }; + } + + return { + status: "available", + item: matched, + title: matched.product.title || matched.product.name, + }; + } + + private async getSavedAddresses(userId: string): Promise { + const response = await this.userService.getAddresses(userId); + if (!Array.isArray(response.deliveryAddresses)) { + return []; + } + + return response.deliveryAddresses.flatMap((value) => { + if (!this.isRecord(value)) { + return []; + } + const id = this.readString(value.id); + const label = this.readString(value.label); + const street = this.readString(value.street); + const city = this.readString(value.city); + const state = this.readString(value.state); + if (!id || !label || !street || !city || !state) { + return []; + } + return [ + { + id, + label, + street, + city, + state, + isDefault: value.isDefault === true, + }, + ]; + }); + } + + private resolveAddress( + addresses: SavedAddress[], + reference?: string, + ): SavedAddress | null { + if (!reference) { + return ( + addresses.find((address) => address.isDefault) ?? + (addresses.length === 1 ? addresses[0] : null) + ); + } + + return this.matchByReference(addresses, reference, (address) => [ + address.label, + ]); + } + + private matchByReference( + items: T[], + reference: string | undefined, + searchableValues: (item: T) => string[], + ): T | null { + if (!reference) { + return items.length === 1 ? items[0] : null; + } + + const normalized = this.normalizeReference(reference); + const ordinal = this.referenceIndex(normalized); + if (ordinal !== null) { + return items[ordinal - 1] ?? null; + } + + const matches = items.filter((item) => + searchableValues(item).some((value) => { + const candidate = this.normalizeReference(value); + return ( + candidate === normalized || + candidate.includes(normalized) || + normalized.includes(candidate) + ); + }), + ); + return matches.length === 1 ? matches[0] : null; + } + + private referenceIndex(value: string): number | null { + const ordinals: Record = { + first: 1, + second: 2, + third: 3, + fourth: 4, + fifth: 5, + }; + if (ordinals[value]) { + return ordinals[value]; + } + const numeric = /^(?:number\s*)?([1-5])$/.exec(value); + return numeric ? Number(numeric[1]) : null; + } + + private normalizeReference(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, " ") + .replace(/\b(?:the|item|product|one)\b/g, " ") + .replace(/\s+/g, " ") + .trim(); + } + + private async storePendingAction( + phone: string, + action: PendingShopperAction, + ): Promise { + try { + await this.redisService.set( + this.pendingActionKey(phone), + JSON.stringify(action), + SHOPPER_ACTION_CONFIRMATION_TTL, + ); + return true; + } catch (error) { + this.logRedisFailure("pending shopper action write", phone, error); + return false; + } + } + + private parsePendingAction(value: string): PendingShopperAction | null { + try { + const parsed: unknown = JSON.parse(value); + if (!this.isRecord(parsed) || !this.readString(parsed.userId)) { + return null; + } + + const action = parsed.action; + if ( + action === "add_to_cart" && + this.readString(parsed.productId) && + this.readString(parsed.productTitle) && + isWhatsAppCartQuantity(parsed.quantity) + ) { + return parsed as unknown as PendingShopperAction; + } + if ( + action === "update_cart_quantity" && + this.readString(parsed.cartItemId) && + this.readString(parsed.productTitle) && + isWhatsAppCartQuantity(parsed.quantity) + ) { + return parsed as unknown as PendingShopperAction; + } + if ( + action === "remove_from_cart" && + this.readString(parsed.cartItemId) && + this.readString(parsed.productTitle) + ) { + return parsed as unknown as PendingShopperAction; + } + if ( + action === "select_delivery_address" && + this.readString(parsed.addressId) && + this.readString(parsed.addressLabel) + ) { + return parsed as unknown as PendingShopperAction; + } + if (action === "start_checkout") { + return parsed as unknown as PendingShopperAction; + } + return null; + } catch { + return null; + } + } + + private cartSummary( + cart: Awaited>, + ): string { + const count = cart.items.reduce((total, item) => total + item.quantity, 0); + return count === 0 + ? "Your cart is now empty." + : `Your cart now has ${count} ${count === 1 ? "unit" : "units"}.`; + } + + private checkoutCartSummary( + cart: Awaited>, + ): + | { status: "empty" } + | { status: "unavailable" } + | { + status: "available"; + itemCount: number; + totalQuantity: number; + subtotalDisplay: string; + } { + if (!cart.items.length) { + return { status: "empty" }; + } + + let subtotalKobo: bigint; + try { + subtotalKobo = BigInt(cart.subtotalKobo); + } catch { + return { status: "unavailable" }; + } + + if ( + subtotalKobo <= 0n || + cart.items.some( + (item) => + item.isAvailable === false || !isWhatsAppCartQuantity(item.quantity), + ) + ) { + return { status: "unavailable" }; + } + + return { + status: "available", + itemCount: cart.items.length, + totalQuantity: cart.items.reduce( + (total, item) => total + item.quantity, + 0, + ), + subtotalDisplay: this.formatKobo(subtotalKobo), + }; + } + + private checkoutHandoffUrl(): string | null { + const configuredUrl = + this.configService.get("app.webUrl") || + this.configService.get("app.frontendUrl") || + this.configService.get("WEB_URL"); + if (!configuredUrl) { + return null; + } + + try { + const url = new URL("/cart", configuredUrl); + return url.protocol === "https:" || url.protocol === "http:" + ? url.toString() + : null; + } catch { + return null; + } + } + + private formatKobo(value: bigint): string { + const naira = value / 100n; + const kobo = (value % 100n).toString().padStart(2, "0"); + return `NGN ${naira.toLocaleString("en-NG")}.${kobo}`; + } + + private stagingFailure< + Name extends ActionToolName, + >(): ActionPreparation { + return { + message: "I could not stage that change, please try again.", + output: { + status: "temporarily_unavailable", + } as ShopperAgentToolOutputMap[Name], + }; + } + + private safeDomainError(phone: string, error: unknown): string | null { + this.logger.warn( + `Shopper action failed for ${maskWhatsAppPhone(phone)}: ${this.errorMessage(error)}`, + ); + + const status = this.errorStatus(error); + if (status === HttpStatus.NOT_FOUND) { + return "That cart item or product is no longer available. Your cart was not changed."; + } + if (status === HttpStatus.BAD_REQUEST) { + return "I could not apply that cart change. Check the quantity and availability, then try again. Your cart was not changed."; + } + if (status === HttpStatus.CONFLICT) { + return "Your cart changed before I could apply that request. Review it and try again."; + } + return null; + } + + private errorStatus(error: unknown): number | null { + if (!this.isRecord(error) || typeof error.getStatus !== "function") { + return null; + } + const status = (error.getStatus as () => unknown)(); + return typeof status === "number" ? status : null; + } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } + + private logRedisFailure( + operation: string, + phone: string, + error: unknown, + ): void { + this.logger.warn( + `${operation} failed for ${maskWhatsAppPhone(phone)}: ${this.errorMessage(error)}`, + ); + } + + private pendingActionKey(phone: string): string { + return `${WA_PENDING_SHOPPER_ACTION_PREFIX}${normalizeWhatsAppPhone(phone)}`; + } + + private selectedAddressKey(phone: string): string { + return `${WA_SELECTED_DELIVERY_ADDRESS_PREFIX}${normalizeWhatsAppPhone(phone)}`; + } + + private readString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; + } + + private isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp-shopper-read.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-shopper-read.service.spec.ts new file mode 100644 index 00000000..bb4aedd2 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-shopper-read.service.spec.ts @@ -0,0 +1,192 @@ +import { WhatsAppShopperReadService } from "./whatsapp-shopper-read.service"; + +describe("WhatsAppShopperReadService", () => { + const cartService = { + getCart: jest.fn(), + }; + const orderService = { + listByBuyer: jest.fn(), + getByCodeForBuyer: jest.fn(), + getTracking: jest.fn(), + }; + const userService = { + getAddresses: jest.fn(), + }; + + let service: WhatsAppShopperReadService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new WhatsAppShopperReadService( + cartService as never, + orderService as never, + userService as never, + ); + }); + + it("summarizes the linked shopper cart without returning product IDs", async () => { + cartService.getCart.mockResolvedValue({ + items: [ + { + quantity: 2, + itemTotalKobo: "500000", + product: { name: "Canvas shoes", title: null }, + }, + ], + subtotalKobo: "500000", + }); + + const result = await service.getCart("user-1"); + + expect(cartService.getCart).toHaveBeenCalledWith("user-1"); + expect(result.message).toContain("Canvas shoes x 2 - NGN 5,000.00"); + expect(result.output).toEqual({ + status: "available", + itemCount: 1, + totalQuantity: 2, + subtotalDisplay: "NGN 5,000.00", + }); + expect(JSON.stringify(result)).not.toContain("productId"); + }); + + it("returns a deterministic empty-cart response", async () => { + cartService.getCart.mockResolvedValue({ + items: [], + subtotalKobo: "0", + }); + + await expect(service.getCart("user-1")).resolves.toEqual({ + message: "Your cart is empty.", + output: { status: "empty", itemCount: 0, totalQuantity: 0 }, + }); + }); + + it("shows safe saved-address fields and omits phone metadata", async () => { + userService.getAddresses.mockResolvedValue({ + deliveryAddresses: [ + { + id: "address-1", + label: "Home", + street: "12 Market Road", + city: "Lagos", + state: "Lagos", + isDefault: true, + deliveryPhone: "+2348012345678", + }, + ], + }); + + const result = await service.getSavedAddresses("user-1"); + + expect(userService.getAddresses).toHaveBeenCalledWith("user-1"); + expect(result.message).toContain( + "Home (default): 12 Market Road, Lagos, Lagos", + ); + expect(result.output).toEqual({ + status: "available", + addressCount: 1, + defaultAddressLabel: "Home", + }); + expect(JSON.stringify(result)).not.toContain("+2348012345678"); + expect(JSON.stringify(result)).not.toContain("address-1"); + }); + + it("lists recent orders using only public order references", async () => { + orderService.listByBuyer.mockResolvedValue({ + success: true, + data: [ + { + id: "internal-order-id", + orderCode: "TWZ-123456", + status: "IN_TRANSIT", + product: { name: "Travel bag" }, + physicalStoreId: "private-store", + }, + ], + meta: { page: 1, limit: 5, total: 1, totalPages: 1 }, + }); + + const result = await service.listOrders("user-1"); + + expect(orderService.listByBuyer).toHaveBeenCalledWith("user-1", 1, 5); + expect(result.message).toContain("TWZ-123456: Travel bag - in transit"); + expect(result.output).toEqual({ + status: "available", + ordersShownCount: 1, + orderReferences: ["TWZ-123456"], + }); + expect(JSON.stringify(result)).not.toContain("internal-order-id"); + expect(JSON.stringify(result)).not.toContain("private-store"); + }); + + it("scopes status lookup to the linked shopper and public order code", async () => { + orderService.getByCodeForBuyer.mockResolvedValue({ + id: "internal-order-id", + orderCode: "TWZ-123456", + status: "PAID", + }); + orderService.getTracking.mockResolvedValue([ + { status: "READY_FOR_PICKUP" }, + { status: "IN_TRANSIT" }, + ]); + + const result = await service.getOrderStatus("user-1", { + orderReference: "twz-123456", + }); + + expect(orderService.getByCodeForBuyer).toHaveBeenCalledWith( + "TWZ-123456", + "user-1", + ); + expect(orderService.getTracking).toHaveBeenCalledWith( + "internal-order-id", + "user-1", + ); + expect(result.output).toEqual({ + status: "available", + orderReference: "TWZ-123456", + orderStatus: "paid", + deliveryStatus: "in transit", + }); + }); + + it("uses the latest owned order for delivery status when no code is supplied", async () => { + orderService.listByBuyer.mockResolvedValue({ + success: true, + data: [ + { + id: "order-2", + orderCode: "TWZ-654321", + status: "DISPATCHED", + product: { name: "Phone case" }, + }, + ], + meta: { page: 1, limit: 1, total: 1, totalPages: 1 }, + }); + orderService.getTracking.mockResolvedValue([]); + + const result = await service.getDeliveryStatus("user-1", {}); + + expect(orderService.listByBuyer).toHaveBeenCalledWith("user-1", 1, 1); + expect(result.output).toEqual({ + status: "available", + orderReference: "TWZ-654321", + deliveryStatus: "dispatched", + }); + }); + + it("does not disclose whether an order belongs to someone else", async () => { + orderService.getByCodeForBuyer.mockResolvedValue(null); + + const result = await service.getOrderStatus("user-1", { + orderReference: "TWZ-999999", + }); + + expect(result).toEqual({ + message: + "I could not find that order in your linked Twizrr account. Check the order code and try again.", + output: { status: "not_found" }, + }); + expect(orderService.getTracking).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-shopper-read.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-shopper-read.service.ts new file mode 100644 index 00000000..d3d4c3c6 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-shopper-read.service.ts @@ -0,0 +1,306 @@ +import { Inject, Injectable, forwardRef } from "@nestjs/common"; +import { CartService } from "../../domains/orders/cart/cart.service"; +import { OrderService } from "../../domains/orders/order/order.service"; +import { UserService } from "../../domains/users/user/user.service"; +import type { + ShopperAgentToolInputMap, + ShopperAgentToolOutputMap, +} from "./agent/shopper-agent.types"; + +type ReadToolName = + | "get_cart" + | "get_saved_addresses" + | "list_orders" + | "get_order_status" + | "get_delivery_status"; + +export interface ShopperReadResult { + message: string; + output: ShopperAgentToolOutputMap[Name]; +} + +type BuyerOrdersResponse = Awaited>; + +interface SafeAddress { + label: string; + street: string; + city: string; + state: string; + isDefault: boolean; +} + +@Injectable() +export class WhatsAppShopperReadService { + constructor( + @Inject(forwardRef(() => CartService)) + private readonly cartService: CartService, + @Inject(forwardRef(() => OrderService)) + private readonly orderService: OrderService, + private readonly userService: UserService, + ) {} + + async getCart(userId: string): Promise> { + const cart = await this.cartService.getCart(userId); + const itemCount = cart.items.length; + const totalQuantity = cart.items.reduce( + (total, item) => total + item.quantity, + 0, + ); + + if (itemCount === 0) { + return { + message: "Your cart is empty.", + output: { status: "empty", itemCount: 0, totalQuantity: 0 }, + }; + } + + const itemLines = cart.items.slice(0, 5).map((item) => { + const name = item.product.title || item.product.name; + return `${name} x ${item.quantity} - ${this.formatKobo(item.itemTotalKobo)}`; + }); + const remaining = itemCount - itemLines.length; + const subtotalDisplay = this.formatKobo(cart.subtotalKobo); + + return { + message: [ + `Your cart has ${itemCount} ${itemCount === 1 ? "item" : "items"} (${totalQuantity} total units).`, + ...itemLines, + ...(remaining > 0 ? [`And ${remaining} more.`] : []), + `Subtotal: ${subtotalDisplay}`, + ].join("\n"), + output: { + status: "available", + itemCount, + totalQuantity, + subtotalDisplay, + }, + }; + } + + async getSavedAddresses( + userId: string, + ): Promise> { + const response = await this.userService.getAddresses(userId); + const addresses = this.readAddresses(response.deliveryAddresses); + + if (addresses.length === 0) { + return { + message: + "You do not have a saved delivery address yet. Add one from your Twizrr settings.", + output: { status: "empty", addressCount: 0 }, + }; + } + + const visible = addresses.slice(0, 3); + const lines = visible.map((address) => { + const defaultLabel = address.isDefault ? " (default)" : ""; + return `${address.label}${defaultLabel}: ${address.street}, ${address.city}, ${address.state}`; + }); + const defaultAddress = addresses.find((address) => address.isDefault); + + return { + message: [ + "Your saved delivery addresses:", + ...lines, + ...(addresses.length > visible.length + ? [`And ${addresses.length - visible.length} more in your settings.`] + : []), + ].join("\n"), + output: { + status: "available", + addressCount: addresses.length, + ...(defaultAddress + ? { defaultAddressLabel: defaultAddress.label } + : {}), + }, + }; + } + + async listOrders(userId: string): Promise> { + const response = await this.orderService.listByBuyer(userId, 1, 5); + const orders = this.readOrders(response); + + if (orders.length === 0) { + return { + message: "You do not have any Twizrr orders yet.", + output: { status: "empty", ordersShownCount: 0 }, + }; + } + + return { + message: [ + "Your recent orders:", + ...orders.map( + (order) => + `${order.orderCode}: ${order.productName} - ${this.formatStatus(order.status)}`, + ), + ].join("\n"), + output: { + status: "available", + ordersShownCount: orders.length, + orderReferences: orders.map((order) => order.orderCode), + }, + }; + } + + async getOrderStatus( + userId: string, + input: ShopperAgentToolInputMap["get_order_status"], + ): Promise> { + const order = await this.resolveOrder(userId, input.orderReference); + if (!order) { + return { + message: + "I could not find that order in your linked Twizrr account. Check the order code and try again.", + output: { status: "not_found" }, + }; + } + + const orderStatus = this.formatStatus(order.status); + const deliveryStatus = await this.resolveDeliveryStatus( + userId, + order.id, + order.status, + ); + + return { + message: `Order ${order.orderCode} is ${orderStatus}. Delivery status: ${deliveryStatus}.`, + output: { + status: "available", + orderReference: order.orderCode, + orderStatus, + deliveryStatus, + }, + }; + } + + async getDeliveryStatus( + userId: string, + input: ShopperAgentToolInputMap["get_delivery_status"], + ): Promise> { + const order = await this.resolveOrder(userId, input.orderReference); + if (!order) { + return { + message: + "I could not find that order in your linked Twizrr account. Check the order code and try again.", + output: { status: "not_found" }, + }; + } + + const deliveryStatus = await this.resolveDeliveryStatus( + userId, + order.id, + order.status, + ); + return { + message: `Delivery for order ${order.orderCode} is ${deliveryStatus}.`, + output: { + status: "available", + orderReference: order.orderCode, + deliveryStatus, + }, + }; + } + + private async resolveOrder( + userId: string, + orderReference?: string, + ): Promise<{ + id: string; + orderCode: string; + status: string; + } | null> { + if (orderReference) { + return this.orderService.getByCodeForBuyer( + this.normalizeOrderReference(orderReference), + userId, + ); + } + + const response = await this.orderService.listByBuyer(userId, 1, 1); + return this.readOrders(response)[0] ?? null; + } + + private async resolveDeliveryStatus( + userId: string, + orderId: string, + orderStatus: string, + ): Promise { + const tracking = await this.orderService.getTracking(orderId, userId); + const latest = tracking.at(-1); + return this.formatStatus(latest?.status ?? orderStatus); + } + + private readOrders(response: BuyerOrdersResponse): Array<{ + id: string; + orderCode: string; + status: string; + productName: string; + }> { + return response.data.flatMap((rawOrder) => { + const order = rawOrder as unknown as Record; + const id = this.readString(order.id); + const orderCode = this.readString(order.orderCode); + const status = this.readString(order.status); + if (!id || !orderCode || !status) { + return []; + } + + const product = this.isRecord(order.product) ? order.product : null; + const productName = this.readString(product?.name) || "Twizrr order"; + return [{ id, orderCode, status, productName }]; + }); + } + + private readAddresses(value: unknown): SafeAddress[] { + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap((candidate) => { + if (!this.isRecord(candidate)) { + return []; + } + const label = this.readString(candidate.label); + const street = this.readString(candidate.street); + const city = this.readString(candidate.city); + const state = this.readString(candidate.state); + if (!label || !street || !city || !state) { + return []; + } + return [ + { + label, + street, + city, + state, + isDefault: candidate.isDefault === true, + }, + ]; + }); + } + + private normalizeOrderReference(value: string): string { + return value.trim().toUpperCase(); + } + + private formatStatus(value: unknown): string { + const status = this.readString(value) ?? "unknown"; + return status.toLowerCase().replace(/_/g, " "); + } + + private formatKobo(value: string): string { + const kobo = BigInt(value); + const naira = kobo / 100n; + const remainder = (kobo % 100n).toString().padStart(2, "0"); + return `NGN ${naira.toLocaleString("en-NG")}.${remainder}`; + } + + private readString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; + } + + private isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp.constants.ts b/apps/backend/src/channels/whatsapp/whatsapp.constants.ts new file mode 100644 index 00000000..2285981d --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp.constants.ts @@ -0,0 +1,90 @@ +/** Main menu for the shopper assistant. */ +export const MAIN_MENU = + "I'm WIZZA, Twizrr's shopping assistant. I can help you find products, discover stores, search with a photo, link your account, and help with supported order or delivery questions."; + +/** Friendly fallback when AI cannot determine shopper intent. */ +export const FRIENDLY_FALLBACK = + "I can help you shop on Twizrr, find products or stores, explain Twizrr Buyer Protection, link your account, and help with supported order or delivery questions. Tell me what you're trying to do."; + +/** Fallback copy used only inside the explicit account-linking flow. */ +export const LINKING_WELCOME_MESSAGE = + "To link your Twizrr account, reply with the email address on your account."; +export const LINK_ACCOUNT_REQUIRED_MESSAGE = + "To continue, please link your Twizrr account. Reply with the email address on your Twizrr account, or type 'cancel' to stop."; +export const LINK_CANCELLED_MESSAGE = + "Account linking has been cancelled. You can continue shopping with WIZZA anytime."; +export const LINK_UNKNOWN_EMAIL_MESSAGE = + "If that email belongs to a Twizrr account, I'll send a verification code. Reply with the code here to finish linking. If you do not have a Twizrr account yet, please create one first, then come back here to link WIZZA."; +export const LINK_CODE_SENT_MESSAGE = + "If that email belongs to a Twizrr account, I'll send a verification code. Reply with the code here to finish linking."; +export const LINK_SUCCESS_MESSAGE = + "Your WhatsApp number is now linked to your Twizrr account. You can continue shopping with WIZZA."; +export const LINK_CONFLICT_MESSAGE = + "This WhatsApp number is already linked to another Twizrr account. Please manage your linked WhatsApp number from your Twizrr settings."; +export const LINK_USER_HAS_DIFFERENT_PHONE_MESSAGE = + "This Twizrr account already has a different active WhatsApp number. Please manage your linked WhatsApp number from your Twizrr settings."; + +/** Errors */ +export const INVALID_LINK_CODE = + "That verification code is incorrect. Please check the code and try again, or type 'cancel' to stop."; +export const LINK_CODE_EXPIRED = + "Your verification code has expired. Please start account linking again."; +export const GENERIC_ERROR = `We encountered a problem while processing your request. Please try again or type "menu" to see available options.`; +export const SAFE_NATURAL_ANSWER_FALLBACK = + "I can help with shopping on Twizrr, product discovery, linked cart actions, secure web checkout handoff, Twizrr Buyer Protection, account linking, and supported order, delivery, dispute, or refund-status questions. Payment stays on Twizrr web. Disputes are opened only for eligible orders after you confirm the request."; +export const SUPPORT_HANDOFF_RESPONSE = + "I can help with shopping questions here. For account or order support, please visit twizrr support in the app or on the web."; +export const STORE_MANAGEMENT_REDIRECT = + "WIZZA is for shopping. Manage your store from your twizrr dashboard."; + +export const MINIMAL_WHATSAPP_SYSTEM_PROMPT = + "You are WIZZA, Twizrr's AI shopping assistant on WhatsApp. Twizrr is a Nigerian social commerce platform where shoppers discover products and stores and shop with protected payment through twizrr Buyer Protection. Help shoppers find products, discover stores, understand Buyer Protection, search by text or picture, and continue supported shopping flows. WIZZA is not for store management. Guest shoppers can discover products after consent. Account, order, checkout, cart, wishlist, payment, delivery, refund, dispute, and profile actions require a linked twizrr account and must not be overclaimed. Use no emojis, no markdown, and keep replies short, safe, and grounded."; + +export enum SessionState { + WAITING_FOR_EMAIL = "waiting_for_email", + WAITING_FOR_CODE = "waiting_for_code", +} + +export { + SHOPPER_AGENT_GEMINI_DECLARATIONS as GEMINI_FUNCTION_DECLARATIONS, + SHOPPER_AGENT_TOOL_DEFINITIONS as WHATSAPP_FUNCTION_REGISTRY, +} from "./agent/shopper-agent-tool.registry"; + +export const META_API_VERSION = "v21.0"; +export const WHATSAPP_OTP_TEMPLATE = "auth_otp"; +export const WA_SESSION_PREFIX = "wa:session:"; +export const WA_CONSENT_PREFIX = "wa:consent:"; +export const WA_RECENT_PRODUCTS_PREFIX = "wa:recent-products:"; +export const WA_DISCOVERY_CONTEXT_PREFIX = "wa:discovery-context:"; +export const WA_PENDING_TEXT_SEARCH_PREFIX = "wa:pending-text-search:"; +export const WA_PENDING_SHOPPER_ACTION_PREFIX = "wa:pending-shopper-action:"; +export const WA_PENDING_DISPUTE_PREFIX = "wa:pending-dispute:"; +export const WA_DELIVERY_CONFIRMATION_PREFIX = "wa:delivery-confirmation:"; +export const WA_DELIVERY_CONFIRMATION_ATTEMPTS_PREFIX = + "wa:delivery-confirmation-attempts:"; +export const WA_SELECTED_DELIVERY_ADDRESS_PREFIX = + "wa:selected-delivery-address:"; +export const WA_SHOPPER_ACTION_CONFIRM_ID = "shopper_action_confirm"; +export const WA_SHOPPER_ACTION_CANCEL_ID = "shopper_action_cancel"; +export const SHOPPER_ACTION_CONFIRMATION_TTL = 5 * 60; +export const DELIVERY_CONFIRMATION_TTL = 10 * 60; +export const DELIVERY_CONFIRMATION_ATTEMPT_TTL = 48 * 60 * 60; +export const DELIVERY_CONFIRMATION_MAX_CODE_ATTEMPTS = 3; +export const WA_CART_QUANTITY_MIN = 1; +export const WA_CART_QUANTITY_MAX = 99; +export const isWhatsAppCartQuantity = (value: unknown): value is number => + typeof value === "number" && + Number.isInteger(value) && + value >= WA_CART_QUANTITY_MIN && + value <= WA_CART_QUANTITY_MAX; +export const LINK_FLOW_TTL = 15 * 60; +export const LINK_CODE_TTL = 10 * 60; +export const SESSION_TTL = LINK_FLOW_TTL; +export const CONSENT_CACHE_TTL = 30 * 24 * 60 * 60; +export const WA_MSG_DEDUP_PREFIX = "wa:msg:"; +export const MSG_DEDUP_TTL = 5 * 60; +export const WA_INBOUND_ACTION_OUTCOME_PREFIX = "wa:inbound-action-outcome:"; +export const WA_INBOUND_ACTION_OUTCOME_TTL = 24 * 60 * 60; +export const WA_INBOUND_RATE_LIMIT_PREFIX = "wa:inbound-rate:"; +export const WA_INBOUND_RATE_LIMIT_MAX = 30; +export const WA_INBOUND_RATE_LIMIT_WINDOW = 60 * 60; diff --git a/apps/backend/src/channels/whatsapp/whatsapp.controller.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp.controller.spec.ts new file mode 100644 index 00000000..3abee958 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp.controller.spec.ts @@ -0,0 +1,297 @@ +import * as crypto from "crypto"; +import { ServiceUnavailableException } from "@nestjs/common"; +import { WhatsAppController } from "./whatsapp.controller"; +import { + MSG_DEDUP_TTL, + WA_INBOUND_RATE_LIMIT_PREFIX, + WA_INBOUND_RATE_LIMIT_WINDOW, + WA_MSG_DEDUP_PREFIX, +} from "./whatsapp.constants"; + +describe("WhatsAppController", () => { + const appSecret = "fixture_value"; + const configService = { + get: jest.fn(), + }; + const redisService = { + set: jest.fn(), + del: jest.fn(), + incrementWithExpiry: jest.fn(), + }; + const whatsappQueue = { + add: jest.fn(), + }; + + let controller: WhatsAppController; + + beforeEach(() => { + jest.clearAllMocks(); + configService.get.mockImplementation((key: string) => + key === "whatsapp.appSecret" ? appSecret : undefined, + ); + redisService.set.mockResolvedValue(true); + redisService.incrementWithExpiry.mockResolvedValue(1); + redisService.del.mockResolvedValue(1); + whatsappQueue.add.mockResolvedValue(undefined); + controller = new WhatsAppController( + configService as never, + redisService as never, + whatsappQueue as never, + ); + }); + + it("preserves inbound WhatsApp message.id in the queued processing payload", async () => { + const { body, rawBody, signature } = buildWebhookRequestBody(); + + await controller.handleWebhook({ + body, + rawBody, + headers: { "x-hub-signature-256": signature }, + } as never); + + expect(redisService.set).toHaveBeenCalledWith( + `${WA_MSG_DEDUP_PREFIX}wamid.test-message-1`, + "1", + MSG_DEDUP_TTL, + true, + ); + expect(redisService.incrementWithExpiry).toHaveBeenCalledWith( + `${WA_INBOUND_RATE_LIMIT_PREFIX}+2348012345678`, + WA_INBOUND_RATE_LIMIT_WINDOW, + ); + expect(whatsappQueue.add).toHaveBeenCalledWith( + "process-message", + expect.objectContaining({ + phone: "+2348012345678", + messageText: "find shoes", + messageId: "wamid.test-message-1", + }), + expect.objectContaining({ + jobId: "wa-inbound-wamid.test-message-1", + }), + ); + }); + + it("preserves an image caption separately for location-aware discovery", async () => { + const { body, rawBody, signature } = buildWebhookRequestBody({ + id: "wamid.image-caption-1", + from: "2348012345678", + type: "image", + image: { id: "image-1", caption: "show me shoes in Lekki" }, + }); + + await controller.handleWebhook({ + body, + rawBody, + headers: { "x-hub-signature-256": signature }, + } as never); + + expect(whatsappQueue.add).toHaveBeenCalledWith( + "process-message", + expect.objectContaining({ + imageId: "image-1", + messageText: undefined, + imageCaption: "show me shoes in Lekki", + }), + expect.any(Object), + ); + }); + + it("queues a validated interactive reply from Meta", async () => { + const { body, rawBody, signature } = buildWebhookRequestBody({ + id: "wamid.interactive-1", + from: "2348012345678", + type: "interactive", + interactive: { + button_reply: { id: "product-1", title: "View product" }, + }, + }); + + await controller.handleWebhook({ + body, + rawBody, + headers: { "x-hub-signature-256": signature }, + } as never); + + expect(whatsappQueue.add).toHaveBeenCalledWith( + "process-message", + expect.objectContaining({ + messageId: "wamid.interactive-1", + interactiveReply: { + type: "button_reply", + id: "product-1", + title: "View product", + }, + }), + expect.any(Object), + ); + }); + + it("does not queue duplicate webhook payloads", async () => { + const { body, rawBody, signature } = buildWebhookRequestBody(); + redisService.set.mockResolvedValue(false); + + await controller.handleWebhook({ + body, + rawBody, + headers: { "x-hub-signature-256": signature }, + } as never); + + expect(whatsappQueue.add).not.toHaveBeenCalled(); + expect(redisService.incrementWithExpiry).not.toHaveBeenCalled(); + }); + + it("queues every valid message in a Meta webhook batch", async () => { + const body = { + entry: [ + { + changes: [ + { + value: { + messages: [ + { + id: "wamid.batch-one", + from: "2348012345678", + type: "text", + text: { body: "find shoes" }, + }, + ], + }, + }, + ], + }, + { + changes: [ + { + value: { + messages: [ + { + id: "wamid.batch-two", + from: "2348098765432", + type: "image", + image: { id: "image-batch-two", caption: "find this" }, + }, + ], + }, + }, + ], + }, + ], + }; + const rawBody = Buffer.from(JSON.stringify(body)); + const signature = signWebhookBody(rawBody); + + await controller.handleWebhook({ + body, + rawBody, + headers: { "x-hub-signature-256": signature }, + } as never); + + expect(whatsappQueue.add).toHaveBeenCalledTimes(2); + expect(whatsappQueue.add).toHaveBeenNthCalledWith( + 1, + "process-message", + expect.objectContaining({ messageId: "wamid.batch-one" }), + expect.any(Object), + ); + expect(whatsappQueue.add).toHaveBeenNthCalledWith( + 2, + "process-message", + expect.objectContaining({ messageId: "wamid.batch-two" }), + expect.any(Object), + ); + }); + + it("limits messages per normalized WhatsApp phone number", async () => { + const { body, rawBody, signature } = buildWebhookRequestBody([ + { + id: "wamid.rate-one", + from: "2348012345678", + type: "text", + text: { body: "first" }, + }, + { + id: "wamid.rate-two", + from: "+2348012345678", + type: "text", + text: { body: "second" }, + }, + ]); + redisService.incrementWithExpiry + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(31); + + await controller.handleWebhook({ + body, + rawBody, + headers: { "x-hub-signature-256": signature }, + } as never); + + expect(redisService.incrementWithExpiry).toHaveBeenNthCalledWith( + 1, + `${WA_INBOUND_RATE_LIMIT_PREFIX}+2348012345678`, + WA_INBOUND_RATE_LIMIT_WINDOW, + ); + expect(redisService.incrementWithExpiry).toHaveBeenNthCalledWith( + 2, + `${WA_INBOUND_RATE_LIMIT_PREFIX}+2348012345678`, + WA_INBOUND_RATE_LIMIT_WINDOW, + ); + expect(whatsappQueue.add).toHaveBeenCalledTimes(1); + }); + + it("returns a retryable error and clears the dedup reservation when queueing fails", async () => { + const { body, rawBody, signature } = buildWebhookRequestBody(); + whatsappQueue.add.mockRejectedValue(new Error("Redis unavailable")); + + await expect( + controller.handleWebhook({ + body, + rawBody, + headers: { "x-hub-signature-256": signature }, + } as never), + ).rejects.toMatchObject({ + constructor: ServiceUnavailableException, + response: expect.objectContaining({ statusCode: 503 }), + }); + + expect(redisService.del).toHaveBeenCalledWith( + `${WA_MSG_DEDUP_PREFIX}wamid.test-message-1`, + ); + }); + + function buildWebhookRequestBody( + message: Record | Array> = { + id: "wamid.test-message-1", + from: "2348012345678", + type: "text", + text: { body: "find shoes" }, + }, + ) { + const messages = Array.isArray(message) ? message : [message]; + const body = { + entry: [ + { + changes: [ + { + value: { + messages, + }, + }, + ], + }, + ], + }; + const rawBody = Buffer.from(JSON.stringify(body)); + const signature = signWebhookBody(rawBody); + + return { body, rawBody, signature }; + } + + function signWebhookBody(rawBody: Buffer) { + return ( + "sha256=" + + crypto.createHmac("sha256", appSecret).update(rawBody).digest("hex") + ); + } +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp.controller.ts b/apps/backend/src/channels/whatsapp/whatsapp.controller.ts new file mode 100644 index 00000000..175ad914 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp.controller.ts @@ -0,0 +1,336 @@ +import { + Controller, + Get, + Post, + Query, + Req, + Res, + HttpCode, + Logger, + ServiceUnavailableException, +} from "@nestjs/common"; +import type { RawBodyRequest } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { InjectQueue } from "@nestjs/bullmq"; +import { Queue } from "bullmq"; +import type { Request, Response } from "express"; +import * as crypto from "crypto"; +import { SkipThrottle } from "@nestjs/throttler"; +import { RedisService } from "../../redis/redis.service"; +import { WHATSAPP_QUEUE } from "../../queue/queue.constants"; +import { + MSG_DEDUP_TTL, + WA_INBOUND_RATE_LIMIT_MAX, + WA_INBOUND_RATE_LIMIT_PREFIX, + WA_INBOUND_RATE_LIMIT_WINDOW, + WA_MSG_DEDUP_PREFIX, +} from "./whatsapp.constants"; +import { maskWhatsAppPhone, normalizeWhatsAppPhone } from "./whatsapp.utils"; + +type InteractiveReply = { type: string; id: string; title: string }; + +interface IncomingWhatsAppMessage { + phone: string; + messageId: string; + messageType: "text" | "interactive" | "image"; + messageText?: string; + interactiveReply?: InteractiveReply; + imageId?: string; + imageCaption?: string; +} + +interface MetaIncomingMessage { + id?: unknown; + from?: unknown; + type?: unknown; + text?: { body?: unknown }; + interactive?: { + button_reply?: { id?: unknown; title?: unknown }; + list_reply?: { id?: unknown; title?: unknown }; + }; + image?: { id?: unknown; caption?: unknown }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +@SkipThrottle() +@Controller("whatsapp") +export class WhatsAppController { + private readonly logger = new Logger(WhatsAppController.name); + + constructor( + private configService: ConfigService, + private redisService: RedisService, + @InjectQueue(WHATSAPP_QUEUE) private whatsappQueue: Queue, + ) {} + + @Get("webhook") + verifyWebhook( + @Query("hub.mode") mode: string, + @Query("hub.verify_token") token: string, + @Query("hub.challenge") challenge: string, + @Res() res: Response, + ) { + const verifyToken = this.configService.get("whatsapp.verifyToken"); + + if (mode === "subscribe" && token === verifyToken) { + this.logger.log("WhatsApp webhook verified successfully"); + return res.status(200).send(challenge); + } + + this.logger.warn("WhatsApp webhook verification failed: token mismatch"); + return res.status(403).send("Forbidden"); + } + + @Post("webhook") + @HttpCode(200) + async handleWebhook(@Req() req: RawBodyRequest) { + const signature = req.headers["x-hub-signature-256"] as string; + if (!this.verifySignature(req.rawBody, signature)) { + this.logger.warn("WhatsApp webhook signature verification failed"); + return { status: "ignored" }; + } + + for (const message of this.extractIncomingMessages(req.body)) { + await this.enqueueIncomingMessage(message); + } + + return { status: "ok" }; + } + + private extractIncomingMessages(body: unknown): IncomingWhatsAppMessage[] { + if (!isRecord(body) || !Array.isArray(body.entry)) { + return []; + } + + const messages: IncomingWhatsAppMessage[] = []; + + for (const entry of body.entry) { + if (!isRecord(entry) || !Array.isArray(entry.changes)) { + continue; + } + + for (const change of entry.changes) { + if (!isRecord(change) || !isRecord(change.value)) { + continue; + } + + const rawMessages = change.value.messages; + if (!Array.isArray(rawMessages)) { + continue; + } + + for (const rawMessage of rawMessages) { + const message = this.parseIncomingMessage(rawMessage); + if (message) { + messages.push(message); + } + } + } + } + + return messages; + } + + private parseIncomingMessage( + rawMessage: unknown, + ): IncomingWhatsAppMessage | null { + if (!isRecord(rawMessage)) { + return null; + } + + const message = rawMessage as MetaIncomingMessage; + if ( + typeof message.id !== "string" || + typeof message.from !== "string" || + (message.type !== "text" && + message.type !== "interactive" && + message.type !== "image") + ) { + return null; + } + + let phone: string; + try { + phone = normalizeWhatsAppPhone(message.from); + } catch { + this.logger.warn( + "Ignoring WhatsApp message with an invalid sender phone", + ); + return null; + } + + if (message.type === "text") { + const messageText = + typeof message.text?.body === "string" + ? message.text.body.trim() + : undefined; + + return messageText + ? { phone, messageId: message.id, messageType: "text", messageText } + : null; + } + + if (message.type === "interactive") { + const buttonReply = message.interactive?.button_reply; + if ( + buttonReply && + typeof buttonReply.id === "string" && + typeof buttonReply.title === "string" + ) { + return { + phone, + messageId: message.id, + messageType: "interactive", + interactiveReply: { + type: "button_reply", + id: buttonReply.id, + title: buttonReply.title, + }, + }; + } + + const listReply = message.interactive?.list_reply; + if ( + listReply && + typeof listReply.id === "string" && + typeof listReply.title === "string" + ) { + return { + phone, + messageId: message.id, + messageType: "interactive", + interactiveReply: { + type: "list_reply", + id: listReply.id, + title: listReply.title, + }, + }; + } + + return null; + } + + if (typeof message.image?.id !== "string") { + return null; + } + + return { + phone, + messageId: message.id, + messageType: "image", + imageId: message.image.id, + imageCaption: + typeof message.image.caption === "string" + ? message.image.caption.trim() || undefined + : undefined, + }; + } + + private async enqueueIncomingMessage( + message: IncomingWhatsAppMessage, + ): Promise { + const dedupKey = `${WA_MSG_DEDUP_PREFIX}${message.messageId}`; + let dedupReserved = false; + + try { + dedupReserved = await this.redisService.set( + dedupKey, + "1", + MSG_DEDUP_TTL, + true, + ); + + if (!dedupReserved) { + this.logger.debug(`Duplicate message ignored: ${message.messageId}`); + return; + } + + const withinRateLimit = await this.consumeInboundRateLimit(message.phone); + if (!withinRateLimit) { + this.logger.warn( + `WhatsApp inbound rate limit exceeded for ${maskWhatsAppPhone(message.phone)}`, + ); + return; + } + + await this.whatsappQueue.add( + "process-message", + { + phone: message.phone, + messageText: message.messageText, + messageId: message.messageId, + interactiveReply: message.interactiveReply, + imageId: message.imageId, + imageCaption: message.imageCaption, + }, + { + jobId: `wa-inbound-${message.messageId}`, + attempts: 3, + backoff: { type: "exponential", delay: 3000 }, + removeOnComplete: 100, + removeOnFail: 50, + }, + ); + + this.logger.log( + `Queued WhatsApp message from ${maskWhatsAppPhone(message.phone)}: type=${message.messageType}, hasText=${Boolean(message.messageText)}, interactive=${message.interactiveReply?.id || "none"}`, + ); + } catch (error) { + if (dedupReserved) { + try { + await this.redisService.del(dedupKey); + } catch (cleanupError) { + this.logger.error( + `Failed to clear WhatsApp dedup reservation: ${cleanupError instanceof Error ? cleanupError.message : cleanupError}`, + ); + } + } + + this.logger.error( + `WhatsApp inbound enqueue failed: ${error instanceof Error ? error.message : error}`, + ); + throw new ServiceUnavailableException( + "WhatsApp message processing is temporarily unavailable.", + ); + } + } + + private async consumeInboundRateLimit(phone: string): Promise { + const key = `${WA_INBOUND_RATE_LIMIT_PREFIX}${phone}`; + const count = await this.redisService.incrementWithExpiry( + key, + WA_INBOUND_RATE_LIMIT_WINDOW, + ); + + return count <= WA_INBOUND_RATE_LIMIT_MAX; + } + + private verifySignature( + rawBody: Buffer | undefined, + signature: string | undefined, + ): boolean { + if (!rawBody || !signature) return false; + + const appSecret = this.configService.get("whatsapp.appSecret"); + if (!appSecret) { + this.logger.error("WHATSAPP_APP_SECRET not configured"); + return false; + } + + const expectedSignature = + "sha256=" + + crypto.createHmac("sha256", appSecret).update(rawBody).digest("hex"); + + const signatureBuffer = Buffer.from(signature); + const expectedBuffer = Buffer.from(expectedSignature); + + if (signatureBuffer.length !== expectedBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(signatureBuffer, expectedBuffer); + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp.module.ts b/apps/backend/src/channels/whatsapp/whatsapp.module.ts new file mode 100644 index 00000000..66f50379 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp.module.ts @@ -0,0 +1,63 @@ +import { Module, forwardRef } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { PrismaModule } from "../../prisma/prisma.module"; +import { RedisModule } from "../../redis/redis.module"; +import { QueueModule } from "../../queue/queue.module"; +import { OrderModule } from "../../domains/orders/order/order.module"; +import { CartModule } from "../../domains/orders/cart/cart.module"; +import { DisputeModule } from "../../domains/orders/dispute/dispute.module"; +import { UserModule } from "../../domains/users/user/user.module"; +import { EmailModule } from "../../domains/social/email/email.module"; +import { WhatsAppController } from "./whatsapp.controller"; +import { WhatsAppService } from "./whatsapp.service"; +import { WhatsAppProcessor } from "./whatsapp.processor"; +import { WhatsAppAuthFlowsModule } from "./whatsapp-auth-flows.module"; +import { WhatsAppSharedModule } from "./whatsapp-shared.module"; +import { WhatsAppShopperReadService } from "./whatsapp-shopper-read.service"; +import { WhatsAppShopperActionService } from "./whatsapp-shopper-action.service"; +import { WhatsAppDeliveryConfirmationService } from "./whatsapp-delivery-confirmation.service"; +import { WhatsAppPostPurchaseService } from "./whatsapp-post-purchase.service"; + +/** + * WhatsApp Bot Module + * + * Integrates with Meta's WhatsApp Business Cloud API to provide + * merchants with a conversational interface to twizrr. + * + * Dependencies: + * - PrismaModule: database access (WhatsAppLink, products, orders, etc.) + * - RedisModule: session state for linking flow + message dedup + * - QueueModule: BullMQ for async message processing + * - OrderModule: existing order services + * - EmailModule: OTP delivery for phone linking + * - ConfigModule: WhatsApp API credentials + * + * Note: InventoryModule is @Global() so no explicit import needed. + * Note: NotificationModule is @Global() so no explicit import needed. + */ +@Module({ + imports: [ + ConfigModule, + PrismaModule, + RedisModule, + forwardRef(() => QueueModule), + forwardRef(() => OrderModule), + forwardRef(() => CartModule), + DisputeModule, + forwardRef(() => UserModule), + EmailModule, + WhatsAppSharedModule, + WhatsAppAuthFlowsModule, + ], + controllers: [WhatsAppController], + providers: [ + WhatsAppService, + WhatsAppProcessor, + WhatsAppShopperReadService, + WhatsAppShopperActionService, + WhatsAppDeliveryConfirmationService, + WhatsAppPostPurchaseService, + ], + exports: [WhatsAppService], +}) +export class WhatsAppModule {} diff --git a/apps/backend/src/channels/whatsapp/whatsapp.processor.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp.processor.spec.ts new file mode 100644 index 00000000..52d6e057 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp.processor.spec.ts @@ -0,0 +1,20 @@ +import { WhatsAppProcessor } from "./whatsapp.processor"; + +describe("WhatsAppProcessor", () => { + it("rethrows outbound notification failures so BullMQ can retry them", async () => { + const error = new Error("Meta delivery failed"); + const service = { + sendDirectOrderNotification: jest.fn().mockRejectedValue(error), + }; + const processor = new WhatsAppProcessor(service as never); + + await expect( + processor.process({ + id: "job-1", + name: "send-direct-order-notification", + attemptsMade: 0, + data: { storeId: "store-1", orderData: {} }, + } as never), + ).rejects.toThrow("Meta delivery failed"); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp.processor.ts b/apps/backend/src/channels/whatsapp/whatsapp.processor.ts new file mode 100644 index 00000000..05cdecbd --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp.processor.ts @@ -0,0 +1,103 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Logger } from "@nestjs/common"; +import { Job } from "bullmq"; +import { WhatsAppService } from "./whatsapp.service"; +import { WHATSAPP_QUEUE } from "../../queue/queue.constants"; + +/** + * BullMQ processor for the WhatsApp queue. + * + * Job types: + * - process-message: Incoming WhatsApp message to handle + */ +@Processor(WHATSAPP_QUEUE, { + drainDelay: 30000, // Slightly faster polling for WhatsApp messages, but still much slower than default 5s + stalledInterval: 300000, + lockDuration: 60000, +}) +export class WhatsAppProcessor extends WorkerHost { + private readonly logger = new Logger(WhatsAppProcessor.name); + + constructor(private whatsAppService: WhatsAppService) { + super(); + } + + async process(job: Job): Promise { + try { + this.logger.log( + `Processing WhatsApp job ${job.name} (id=${job.id}, attempt=${job.attemptsMade + 1})`, + ); + switch (job.name) { + case "process-message": { + const { + phone, + messageText, + messageId, + interactiveReply, + imageId, + imageCaption, + } = job.data; + await this.whatsAppService.processMessage( + phone, + messageText, + messageId, + interactiveReply, + imageId, + imageCaption, + ); + break; + } + + case "send-direct-order-notification": { + const { storeId, orderData } = job.data; + await this.whatsAppService.sendDirectOrderNotification( + storeId, + orderData, + ); + break; + } + + case "send-payment-confirmed-notification": { + const { buyerId, orderData } = job.data; + await this.whatsAppService.sendPaymentConfirmedNotification( + buyerId, + orderData, + ); + break; + } + + case "send-order-dispatched-notification": { + const { buyerId, orderData } = job.data; + await this.whatsAppService.sendOrderDispatchedNotification( + buyerId, + orderData, + ); + break; + } + + case "send-text-message": { + const { phone, text } = job.data; + await this.whatsAppService.sendQueuedWhatsAppMessage(phone, text); + break; + } + + case "send-delivery-confirmed-notification": { + const { storeId, payoutData } = job.data; + await this.whatsAppService.sendDeliveryConfirmedNotification( + storeId, + payoutData, + ); + break; + } + + default: + this.logger.warn(`Unknown WhatsApp job type: ${job.name}`); + } + } catch (error) { + this.logger.error( + `WhatsApp job ${job.name} failed: ${error instanceof Error ? error.message : error}`, + ); + throw error; + } + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts new file mode 100644 index 00000000..9ef31579 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts @@ -0,0 +1,2269 @@ +import { WhatsAppService } from "./whatsapp.service"; +import { + WHATSAPP_CONSENT_ACCEPT_ID, + WHATSAPP_CONSENT_DECLINE_ID, + WHATSAPP_CONSENT_DECLINED_MESSAGE, + WHATSAPP_CONSENT_MESSAGE, +} from "./whatsapp-session.service"; +import { + FRIENDLY_FALLBACK, + MAIN_MENU, + SAFE_NATURAL_ANSWER_FALLBACK, + STORE_MANAGEMENT_REDIRECT, + WA_SHOPPER_ACTION_CANCEL_ID, + WA_SHOPPER_ACTION_CONFIRM_ID, +} from "./whatsapp.constants"; + +describe("WhatsAppService", () => { + const configService = { get: jest.fn() }; + const prisma = {}; + const authService = { + resolvePhone: jest.fn(), + handleLinkingFlow: jest.fn(), + hasActiveLinkingFlow: jest.fn(), + startLinkingFlow: jest.fn(), + }; + const intentService = { + parseIntent: jest.fn(), + }; + const shopperAgentOrchestrator = { + planIntent: jest.fn(), + }; + const shopperAgentToolRegistry = { + assertSafeOutput: jest.fn(), + }; + const shopperReadService = { + getCart: jest.fn(), + getSavedAddresses: jest.fn(), + listOrders: jest.fn(), + getOrderStatus: jest.fn(), + getDeliveryStatus: jest.fn(), + }; + const shopperActionService = { + handlePendingActionReply: jest.fn(), + prepareAddToCart: jest.fn(), + prepareUpdateCartQuantity: jest.fn(), + prepareRemoveFromCart: jest.fn(), + prepareCheckoutHandoff: jest.fn(), + prepareSelectDeliveryAddress: jest.fn(), + }; + const deliveryConfirmationService = { + handleScopedReply: jest.fn(), + start: jest.fn(), + }; + const postPurchaseService = { + handlePendingReply: jest.fn(), + prepareDispute: jest.fn(), + getDisputeStatus: jest.fn(), + getRefundStatus: jest.fn(), + }; + const imageSearchService = { + handleImageSearch: jest.fn(), + }; + const productDiscoveryService = { + sendGenericProductSearch: jest.fn(), + sendRefinedProductSearch: jest.fn(), + sendRecentProductComparison: jest.fn(), + getConversationContext: jest.fn(), + sendProductSelectionFromRecentResults: jest.fn(), + consumePendingTextSearch: jest.fn(), + clearDiscoverySelectionState: jest.fn(), + clearRecentSearchResults: jest.fn(), + startPendingTextSearch: jest.fn(), + }; + const redisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + }; + const interactiveService = { + sendTextMessage: jest.fn(), + sendReplyButtons: jest.fn(), + sendListMessage: jest.fn(), + }; + const metaWhatsAppClient = { + sendTextMessage: jest.fn(), + markMessageAsReadWithTyping: jest.fn(), + }; + const whatsappSessionService = { + normalizePhone: jest.fn(), + recordInbound: jest.fn(), + isConsentDecline: jest.fn(), + isConsentAcceptance: jest.fn(), + markConsentGiven: jest.fn(), + markConsentDeclined: jest.fn(), + }; + const whatsappAnalyticsService = { + recordMessage: jest.fn(), + }; + const wizzaLocationResolver = { + resolve: jest.fn(), + }; + + let service: WhatsAppService; + + beforeEach(() => { + jest.clearAllMocks(); + configService.get.mockReturnValue(undefined); + redisService.get.mockResolvedValue(null); + redisService.set.mockResolvedValue(true); + authService.hasActiveLinkingFlow.mockResolvedValue(false); + authService.startLinkingFlow.mockResolvedValue( + "To continue, please link your Twizrr account. Reply with the email address on your Twizrr account, or type 'cancel' to stop.", + ); + authService.handleLinkingFlow.mockResolvedValue( + "If that email belongs to a Twizrr account, I'll send a verification code. Reply with the code here to finish linking.", + ); + metaWhatsAppClient.markMessageAsReadWithTyping.mockResolvedValue(true); + whatsappAnalyticsService.recordMessage.mockResolvedValue(undefined); + wizzaLocationResolver.resolve.mockResolvedValue({ + locationText: null, + source: "none", + countryCode: null, + state: null, + city: null, + area: null, + }); + productDiscoveryService.sendProductSelectionFromRecentResults.mockResolvedValue( + { handled: false }, + ); + productDiscoveryService.getConversationContext.mockResolvedValue(null); + productDiscoveryService.sendRefinedProductSearch.mockResolvedValue({ + intentSuccessful: true, + productsShownCount: 2, + productsShown: ["product-1", "product-2"], + productsRankedCount: 2, + vectorSearchUsed: true, + zeroResults: false, + }); + productDiscoveryService.sendRecentProductComparison.mockResolvedValue({ + handled: true, + productsComparedCount: 2, + publicProductUrls: [ + "https://twizrr.com/stores/one/p/TWZ-111111", + "https://twizrr.com/stores/two/p/TWZ-222222", + ], + errorType: null, + }); + productDiscoveryService.consumePendingTextSearch.mockResolvedValue(false); + shopperAgentOrchestrator.planIntent.mockImplementation( + ({ + functionName, + params, + linkedUserId, + }: { + functionName: string; + params?: Record; + linkedUserId: string | null; + }) => { + const categories: Record = { + search_products: "guest", + refine_product_search: "guest", + compare_products: "guest", + get_cart: "linked-read", + add_to_cart: "confirmation-required", + update_cart_quantity: "confirmation-required", + remove_from_cart: "confirmation-required", + start_checkout: "confirmation-required", + get_saved_addresses: "linked-read", + select_delivery_address: "confirmation-required", + list_orders: "linked-read", + get_order_status: "linked-read", + get_delivery_status: "linked-read", + confirm_delivery: "confirmation-required", + start_dispute: "confirmation-required", + get_dispute_status: "linked-read", + get_refund_status: "linked-read", + show_menu: "guest", + support_handoff: "guest", + store_management_redirect: "guest", + }; + const category = categories[functionName]; + if (!category) { + const conversationName = + functionName === "natural_answer" + ? "natural_answer" + : "friendly_fallback"; + return { + kind: "conversation", + functionName: conversationName, + params: conversationName === "natural_answer" ? (params ?? {}) : {}, + policy: { + allowed: true, + reason: "allowed", + confirmationRequired: false, + }, + audit: { + event: "shopper_agent_plan", + actionName: conversationName, + actionCategory: "conversation", + policyDecision: "allowed", + policyReason: "allowed", + }, + }; + } + + const requiresLink = category !== "guest"; + const allowed = !requiresLink || Boolean(linkedUserId); + return { + kind: "tool", + functionName, + params: params ?? {}, + tool: { name: functionName, category, input: params ?? {} }, + policy: { + allowed, + reason: allowed ? "allowed" : "linked_account_required", + confirmationRequired: + allowed && category === "confirmation-required", + }, + audit: { + event: "shopper_agent_plan", + actionName: functionName, + actionCategory: category, + policyDecision: allowed ? "allowed" : "denied", + policyReason: allowed ? "allowed" : "linked_account_required", + }, + }; + }, + ); + whatsappSessionService.normalizePhone.mockImplementation((phone: string) => + phone.startsWith("+") ? phone : `+${phone}`, + ); + shopperActionService.handlePendingActionReply.mockResolvedValue({ + handled: false, + }); + deliveryConfirmationService.handleScopedReply.mockResolvedValue({ + handled: false, + }); + postPurchaseService.handlePendingReply.mockResolvedValue({ + handled: false, + }); + service = new WhatsAppService( + configService as any, + prisma as any, + authService as any, + intentService as any, + imageSearchService as any, + productDiscoveryService as any, + redisService as any, + interactiveService as any, + metaWhatsAppClient as any, + whatsappSessionService as any, + whatsappAnalyticsService as any, + wizzaLocationResolver as any, + shopperAgentOrchestrator as any, + shopperAgentToolRegistry as never, + shopperReadService as never, + shopperActionService as never, + deliveryConfirmationService as never, + postPurchaseService as never, + ); + }); + + it("creates a persistent session and sends consent before processing assistant intents", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: false, + }); + whatsappSessionService.isConsentDecline.mockReturnValue(false); + whatsappSessionService.isConsentAcceptance.mockReturnValue(false); + + await service.processMessage("2348012345678", "find shoes", "message-1"); + + expect(whatsappSessionService.recordInbound).toHaveBeenCalledWith( + "+2348012345678", + null, + ); + expect(interactiveService.sendReplyButtons).toHaveBeenCalledWith( + "2348012345678", + WHATSAPP_CONSENT_MESSAGE, + [ + { id: WHATSAPP_CONSENT_ACCEPT_ID, title: "I Agree" }, + { id: WHATSAPP_CONSENT_DECLINE_ID, title: "No Thanks" }, + ], + ); + expect( + metaWhatsAppClient.markMessageAsReadWithTyping, + ).not.toHaveBeenCalled(); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect(imageSearchService.handleImageSearch).not.toHaveBeenCalled(); + expect( + productDiscoveryService.sendProductSelectionFromRecentResults, + ).not.toHaveBeenCalled(); + }); + + it("records consent and shows the menu for unlinked phones", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: false, + }); + whatsappSessionService.isConsentDecline.mockReturnValue(false); + whatsappSessionService.isConsentAcceptance.mockReturnValue(true); + whatsappSessionService.markConsentGiven.mockResolvedValue({ + isConsentGiven: true, + }); + + await service.processMessage("2348012345678", undefined, "message-1", { + type: "button_reply", + id: WHATSAPP_CONSENT_ACCEPT_ID, + title: "I Agree", + }); + + expect(whatsappSessionService.markConsentGiven).toHaveBeenCalledWith( + "+2348012345678", + ); + expect(authService.handleLinkingFlow).not.toHaveBeenCalled(); + expect( + metaWhatsAppClient.markMessageAsReadWithTyping, + ).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining(MAIN_MENU), + ); + }); + + it("does not process assistant intents when consent is declined", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: false, + }); + whatsappSessionService.isConsentDecline.mockReturnValue(true); + whatsappSessionService.isConsentAcceptance.mockReturnValue(false); + whatsappSessionService.markConsentDeclined.mockResolvedValue({ + isConsentGiven: false, + }); + + await service.processMessage("2348012345678", "no thanks", "message-1"); + + expect(whatsappSessionService.markConsentDeclined).toHaveBeenCalledWith( + "+2348012345678", + ); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + WHATSAPP_CONSENT_DECLINED_MESSAGE, + ); + expect( + metaWhatsAppClient.markMessageAsReadWithTyping, + ).not.toHaveBeenCalled(); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + }); + + it("does not start WhatsApp-first linking before consent", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: false, + }); + whatsappSessionService.isConsentDecline.mockReturnValue(false); + whatsappSessionService.isConsentAcceptance.mockReturnValue(false); + + await service.processMessage( + "2348012345678", + "link my account", + "message-1", + ); + + expect(authService.startLinkingFlow).not.toHaveBeenCalled(); + expect(authService.handleLinkingFlow).not.toHaveBeenCalled(); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect(interactiveService.sendReplyButtons).toHaveBeenCalledWith( + "2348012345678", + WHATSAPP_CONSENT_MESSAGE, + expect.any(Array), + ); + }); + + it("continues normal assistant flow for linked phones with consent", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "friendly_fallback", + }); + + await service.processMessage("2348012345678", "hello", "message-1"); + + expect(authService.handleLinkingFlow).not.toHaveBeenCalled(); + expect(metaWhatsAppClient.markMessageAsReadWithTyping).toHaveBeenCalledWith( + "message-1", + ); + expect( + metaWhatsAppClient.markMessageAsReadWithTyping.mock + .invocationCallOrder[0], + ).toBeLessThan(intentService.parseIntent.mock.invocationCallOrder[0]); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + FRIENDLY_FALLBACK, + ); + expect(FRIENDLY_FALLBACK).toContain("I can help you shop on Twizrr"); + expect(FRIENDLY_FALLBACK).not.toContain('"I want to buy an iPhone 15"'); + expect(FRIENDLY_FALLBACK).not.toContain("I did not understand"); + }); + + it("allows consented unlinked text search as generic product discovery", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "search_products", + params: { query: "red shoes" }, + }); + + await service.processMessage( + "2348012345678", + "find red shoes", + "message-1", + ); + + expect(authService.handleLinkingFlow).not.toHaveBeenCalled(); + expect(metaWhatsAppClient.markMessageAsReadWithTyping).toHaveBeenCalledWith( + "message-1", + ); + expect( + metaWhatsAppClient.markMessageAsReadWithTyping, + ).toHaveBeenCalledTimes(1); + expect( + metaWhatsAppClient.markMessageAsReadWithTyping.mock + .invocationCallOrder[0], + ).toBeLessThan(intentService.parseIntent.mock.invocationCallOrder[0]); + expect( + metaWhatsAppClient.markMessageAsReadWithTyping.mock + .invocationCallOrder[0], + ).toBeLessThan( + productDiscoveryService.sendGenericProductSearch.mock + .invocationCallOrder[0], + ); + expect( + productDiscoveryService.sendGenericProductSearch, + ).toHaveBeenCalledWith("2348012345678", "red shoes"); + }); + + it("passes explicit shopper location context to discovery", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + userId: null, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "search_products", + params: { query: "sneakers" }, + }); + const locationContext = { + locationText: "Lekki", + source: "explicit_query" as const, + countryCode: null, + state: null, + city: null, + area: null, + }; + wizzaLocationResolver.resolve.mockResolvedValue(locationContext); + + await service.processMessage( + "2348012345678", + "show me sneakers in Lekki", + "message-1", + ); + + expect( + productDiscoveryService.sendGenericProductSearch, + ).toHaveBeenCalledWith("2348012345678", "sneakers", locationContext); + }); + + it("passes recent shopper-safe discovery context into intent parsing", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + userId: null, + }); + const discoveryContext = { + lastQuery: "black sneakers", + source: "text" as const, + products: [ + { + rank: 1, + title: "Black Runner", + storeName: "City Kicks", + priceDisplay: "NGN 45,000", + publicProductUrl: "https://twizrr.com/stores/citykicks/p/TWZ-111111", + }, + ], + }; + productDiscoveryService.getConversationContext.mockResolvedValue( + discoveryContext, + ); + intentService.parseIntent.mockResolvedValue({ + functionName: "natural_answer", + params: { answer: "The first result is the Black Runner." }, + }); + + await service.processMessage( + "2348012345678", + "which one was first?", + "message-1", + ); + + expect(productDiscoveryService.getConversationContext).toHaveBeenCalledWith( + "2348012345678", + ); + expect(intentService.parseIntent).toHaveBeenCalledWith( + "which one was first?", + discoveryContext, + ); + }); + + it("routes contextual refinements through the discovery service", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + userId: null, + }); + productDiscoveryService.getConversationContext.mockResolvedValue({ + lastQuery: "black sneakers", + source: "text", + products: [ + { + rank: 1, + title: "Black Runner", + storeName: "City Kicks", + priceDisplay: "NGN 45,000", + }, + ], + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "refine_product_search", + params: { + refinement: "show me cheaper options in Lekki", + productReference: "first", + }, + }); + const locationContext = { + locationText: "Lekki", + source: "explicit_query" as const, + countryCode: null, + state: "Lagos", + city: "Lagos", + area: "Lekki", + }; + wizzaLocationResolver.resolve.mockResolvedValue(locationContext); + + await service.processMessage( + "2348012345678", + "show me cheaper options in Lekki", + "message-1", + ); + + expect( + productDiscoveryService.sendRefinedProductSearch, + ).toHaveBeenCalledWith( + "2348012345678", + "show me cheaper options in Lekki", + "first", + locationContext, + ); + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + }); + + it("routes product comparisons through recent discovery context", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + userId: null, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "compare_products", + params: { productReferences: ["first", "second"] }, + }); + + await service.processMessage( + "2348012345678", + "compare the first and second", + "message-1", + ); + + expect( + productDiscoveryService.sendRecentProductComparison, + ).toHaveBeenCalledWith("2348012345678", ["first", "second"]); + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + }); + + it("routes an image caption through image discovery without triggering account linking", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + userId: null, + }); + imageSearchService.handleImageSearch.mockResolvedValue({ + searchQuery: null, + parsedFilters: null, + productsRankedCount: 0, + productsShown: [], + productsShownCount: 0, + zeroResults: false, + intentSuccessful: true, + vectorSearchUsed: false, + embeddingModel: null, + }); + + await service.processMessage( + "2348012345678", + undefined, + "message-1", + undefined, + "image-1", + "checkout this in Lekki", + ); + + expect(imageSearchService.handleImageSearch).toHaveBeenCalledWith( + "2348012345678", + "image-1", + ); + expect(authService.startLinkingFlow).not.toHaveBeenCalled(); + }); + + it("resolves product list row selections from recent Redis results before Gemini", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + productDiscoveryService.sendProductSelectionFromRecentResults.mockResolvedValue( + true, + ); + + await service.processMessage("2348012345678", undefined, "message-1", { + type: "list_reply", + id: "product_result_2", + title: "Blue Shoes", + }); + + expect( + productDiscoveryService.sendProductSelectionFromRecentResults, + ).toHaveBeenCalledWith("2348012345678", { + interactiveReply: { + type: "list_reply", + id: "product_result_2", + title: "Blue Shoes", + }, + messageText: undefined, + }); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + }); + + it("opens the category browser from a discovery follow-up without invoking Gemini", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + + await service.processMessage("2348012345678", undefined, "message-1", { + type: "button_reply", + id: "browse_categories", + title: "Browse", + }); + + expect( + productDiscoveryService.clearDiscoverySelectionState, + ).toHaveBeenCalledWith("2348012345678"); + expect(interactiveService.sendListMessage).toHaveBeenCalledWith( + "2348012345678", + "Browse product categories and choose one to see available items.", + "Browse categories", + [ + expect.objectContaining({ + title: "Categories", + rows: expect.arrayContaining([ + expect.objectContaining({ + id: "category:clothing", + title: "Clothing", + }), + expect.objectContaining({ id: "browse_categories_more" }), + ]), + }), + ], + ); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + }); + + it("searches the selected category through the approved discovery path", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + productDiscoveryService.sendGenericProductSearch.mockResolvedValue({ + searchQuery: "Shoes & Footwear", + productsRankedCount: 2, + productsShown: ["product-1"], + productsShownCount: 1, + zeroResults: false, + intentSuccessful: true, + vectorSearchUsed: true, + embeddingModel: "text-embedding-model", + }); + + await service.processMessage("2348012345678", undefined, "message-1", { + type: "list_reply", + id: "category:shoes-footwear", + title: "Shoes & Footwear", + }); + + expect( + productDiscoveryService.sendGenericProductSearch, + ).toHaveBeenCalledWith("2348012345678", "Shoes & Footwear"); + expect(whatsappAnalyticsService.recordMessage).toHaveBeenCalledWith( + expect.objectContaining({ + intentClassified: "category_search", + searchQuery: "Shoes & Footwear", + productsRankedCount: 2, + productsShown: ["product-1"], + productsShownCount: 1, + zeroResults: false, + vectorSearchUsed: true, + embeddingModel: "text-embedding-model", + }), + ); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + }); + + it("starts a fresh text search after an image or zero-result follow-up", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + + await service.processMessage("2348012345678", undefined, "message-1", { + type: "button_reply", + id: "search_products", + title: "Try Text Search", + }); + + expect( + productDiscoveryService.clearDiscoverySelectionState, + ).toHaveBeenCalledWith("2348012345678"); + expect(productDiscoveryService.startPendingTextSearch).toHaveBeenCalledWith( + "2348012345678", + ); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + "Tell me what you want to find. You can send a product name, category, colour, or style.", + ); + expect( + productDiscoveryService.sendProductSelectionFromRecentResults, + ).not.toHaveBeenCalled(); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + }); + + it("resolves natural product index references before Gemini", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + productDiscoveryService.sendProductSelectionFromRecentResults.mockResolvedValue( + true, + ); + + await service.processMessage( + "2348012345678", + "show me number 2", + "message-1", + ); + + expect( + productDiscoveryService.sendProductSelectionFromRecentResults, + ).toHaveBeenCalledWith("2348012345678", { + interactiveReply: undefined, + messageText: "show me number 2", + }); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + }); + + it("routes bare product-selection numbers before Gemini menu shortcuts", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + productDiscoveryService.sendProductSelectionFromRecentResults.mockResolvedValue( + true, + ); + + await service.processMessage("2348012345678", "1", "message-1"); + + expect( + productDiscoveryService.sendProductSelectionFromRecentResults, + ).toHaveBeenCalledWith("2348012345678", { + interactiveReply: undefined, + messageText: "1", + }); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + }); + + it("uses the next text message after search again without sending it through Gemini", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + productDiscoveryService.consumePendingTextSearch.mockResolvedValue(true); + productDiscoveryService.sendGenericProductSearch.mockResolvedValue({ + searchQuery: "blue sneakers", + productsRankedCount: 1, + productsShown: ["product-1"], + productsShownCount: 1, + zeroResults: false, + intentSuccessful: true, + vectorSearchUsed: true, + embeddingModel: "multimodalembedding@001", + }); + + await service.processMessage("2348012345678", "blue sneakers", "message-1"); + + expect( + productDiscoveryService.sendGenericProductSearch, + ).toHaveBeenCalledWith("2348012345678", "blue sneakers"); + expect( + productDiscoveryService.sendProductSelectionFromRecentResults, + ).not.toHaveBeenCalled(); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + }); + + it("lets unmatched product-name references continue to normal flow", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + productDiscoveryService.sendProductSelectionFromRecentResults.mockResolvedValue( + false, + ); + intentService.parseIntent.mockResolvedValue({ + functionName: "search_products", + params: { query: "that iPhone" }, + }); + + await service.processMessage("2348012345678", "that iPhone", "message-1"); + + expect( + productDiscoveryService.sendProductSelectionFromRecentResults, + ).toHaveBeenCalledWith("2348012345678", { + interactiveReply: undefined, + messageText: "that iPhone", + }); + expect(intentService.parseIntent).toHaveBeenCalledWith("that iPhone", null); + expect( + productDiscoveryService.sendGenericProductSearch, + ).toHaveBeenCalledWith("2348012345678", "that iPhone"); + }); + + it("routes active WhatsApp linking flow before Gemini or product discovery", async () => { + authService.resolvePhone.mockResolvedValue(null); + authService.hasActiveLinkingFlow.mockResolvedValue(true); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + + await service.processMessage( + "2348012345678", + "ameen@example.com", + "message-1", + ); + + expect(authService.handleLinkingFlow).toHaveBeenCalledWith( + "+2348012345678", + "ameen@example.com", + ); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + expect( + productDiscoveryService.sendProductSelectionFromRecentResults, + ).not.toHaveBeenCalled(); + expect( + productDiscoveryService.clearDiscoverySelectionState, + ).toHaveBeenCalledWith("2348012345678"); + expect( + productDiscoveryService.consumePendingTextSearch, + ).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("verification code"), + ); + }); + + it("routes active WhatsApp linking codes before product selection parsing", async () => { + authService.resolvePhone.mockResolvedValue(null); + authService.hasActiveLinkingFlow.mockResolvedValue(true); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + authService.handleLinkingFlow.mockResolvedValue( + "Please reply with the 6-digit verification code, or type 'cancel' to stop.", + ); + + await service.processMessage("2348012345678", "1", "message-1"); + + expect(authService.handleLinkingFlow).toHaveBeenCalledWith( + "+2348012345678", + "1", + ); + expect( + productDiscoveryService.sendProductSelectionFromRecentResults, + ).not.toHaveBeenCalled(); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + }); + + it("routes active WhatsApp linking flow before image search even without text", async () => { + authService.resolvePhone.mockResolvedValue(null); + authService.hasActiveLinkingFlow.mockResolvedValue(true); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + authService.handleLinkingFlow.mockResolvedValue( + "Please reply with the 6-digit verification code, or type 'cancel' to stop.", + ); + + await service.processMessage( + "2348012345678", + undefined, + "message-1", + undefined, + "image-1", + ); + + expect(authService.handleLinkingFlow).toHaveBeenCalledWith( + "+2348012345678", + "", + ); + expect(imageSearchService.handleImageSearch).not.toHaveBeenCalled(); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect( + productDiscoveryService.sendProductSelectionFromRecentResults, + ).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("verification code"), + ); + }); + + it("starts WhatsApp-first linking when a consented shopper asks to link account", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + + await service.processMessage( + "2348012345678", + "link my account", + "message-1", + ); + + expect(authService.startLinkingFlow).toHaveBeenCalledWith("+2348012345678"); + expect( + productDiscoveryService.clearDiscoverySelectionState, + ).toHaveBeenCalledWith("2348012345678"); + expect(authService.handleLinkingFlow).not.toHaveBeenCalled(); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Reply with the email address"), + ); + }); + + it("starts WhatsApp-first linking for unlinked protected actions before Gemini", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + + await service.processMessage( + "2348012345678", + "where is my order", + "message-1", + ); + + expect(authService.startLinkingFlow).toHaveBeenCalledWith("+2348012345678"); + expect( + productDiscoveryService.clearDiscoverySelectionState, + ).toHaveBeenCalledWith("2348012345678"); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + }); + + it("allows consented unlinked buy phrasing as product discovery", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "search_products", + params: { query: "iphone 15" }, + }); + + await service.processMessage( + "2348012345678", + "I want to buy iphone 15", + "message-1", + ); + + expect(authService.handleLinkingFlow).not.toHaveBeenCalled(); + expect( + productDiscoveryService.sendGenericProductSearch, + ).toHaveBeenCalledWith("2348012345678", "iphone 15"); + expect(interactiveService.sendTextMessage).not.toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Please link your twizrr account"), + ); + }); + + it("allows consented unlinked product order phrasing as product discovery", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "search_products", + params: { query: "iPhone" }, + }); + + await service.processMessage( + "2348012345678", + "Can I order an iPhone?", + "message-1", + ); + + expect(authService.handleLinkingFlow).not.toHaveBeenCalled(); + expect( + productDiscoveryService.sendGenericProductSearch, + ).toHaveBeenCalledWith("2348012345678", "iPhone"); + expect(interactiveService.sendTextMessage).not.toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Please link your twizrr account"), + ); + }); + + it("does not treat general payment-protection questions as protected actions", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "natural_answer", + params: { + answer: + "twizrr Buyer Protection helps keep payment protected until delivery is confirmed.", + }, + }); + + await service.processMessage( + "2348012345678", + "what is payment protection?", + "message-1", + ); + + expect(authService.startLinkingFlow).not.toHaveBeenCalled(); + expect(intentService.parseIntent).toHaveBeenCalledWith( + "what is payment protection?", + null, + ); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Buyer Protection"), + ); + expect(whatsappAnalyticsService.recordMessage).toHaveBeenCalledWith( + expect.objectContaining({ + intentClassified: "natural_answer", + }), + ); + const recordedAnalytics = + whatsappAnalyticsService.recordMessage.mock.calls.at(-1)?.[0]; + expect(recordedAnalytics).not.toHaveProperty("parsedFilters.shopperAgent"); + }); + + it("does not treat general delivery coverage questions as address selection", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "natural_answer", + params: { + answer: + "Twizrr supports delivery coverage based on the item and route.", + }, + }); + + await service.processMessage( + "2348012345678", + "do you deliver to Lagos?", + "message-1", + ); + + expect(authService.startLinkingFlow).not.toHaveBeenCalled(); + expect(intentService.parseIntent).toHaveBeenCalledWith( + "do you deliver to Lagos?", + null, + ); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("delivery coverage"), + ); + }); + + it("still requires linking for explicit saved-address selection", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + + await service.processMessage( + "2348012345678", + "deliver to my home address", + "message-1", + ); + + expect(authService.startLinkingFlow).toHaveBeenCalledWith("+2348012345678"); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + }); + + it("does not treat wishlist concept questions as protected actions", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "natural_answer", + params: { + answer: + "A wishlist is a place to save products you may want to revisit later.", + }, + }); + + await service.processMessage( + "2348012345678", + "what is a wishlist?", + "message-1", + ); + + expect(authService.startLinkingFlow).not.toHaveBeenCalled(); + expect(intentService.parseIntent).toHaveBeenCalledWith( + "what is a wishlist?", + null, + ); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("wishlist"), + ); + }); + + it("keeps direct buy-for-me requests gated instead of treating them as discovery", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "search_products", + params: { query: "buy this for me" }, + }); + + await service.processMessage( + "2348012345678", + "Buy this for me", + "message-1", + ); + + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Reply with the email address"), + ); + }); + + it("sends validated Gemini natural answers for safe Twizrr questions", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "natural_answer", + params: { + answer: + "Twizrr helps shoppers discover products from Nigerian stores and shop with protected payment.", + }, + }); + + await service.processMessage( + "2348012345678", + "what is Twizrr?", + "message-1", + ); + + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("protected payment"), + ); + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + }); + + it.each([ + "", + " ".repeat(8), + "x".repeat(701), + '{"topic":"creator_company","answer":"Twizrr"}', + "This exposes physicalStoreId to shoppers.", + "Twizrr was founded by Aliameen.", + "This phone is available now for NGN 100000.", + "You can checkout here when you are ready.", + "Pay here now to complete your order.", + "I can place the order for you here.", + "You can buy directly here.", + "I can add it to cart for you.", + "Save it to your wishlist here.", + "You can track your order here.", + "Confirm delivery here with your delivery code.", + "Open a dispute here if there is a problem.", + "I can process a refund for you.", + "I can update your account profile or order data here.", + "Happy to help \u{1F600}", + "- Buyer Protection helps you shop safely", + "* Buyer Protection helps you shop safely", + "• Buyer Protection helps you shop safely", + "1. Search products", + "1) Search products", + "# Twizrr", + "**Twizrr** helps shoppers", + "`internal`", + "> quoted text", + ])("falls back when Gemini natural answer is unsafe: %s", async (answer) => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "natural_answer", + params: { + answer, + }, + }); + + await service.processMessage("2348012345678", "what is this?", "message-1"); + + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + SAFE_NATURAL_ANSWER_FALLBACK, + ); + }); + + it("uses fallback copy that does not overpromise WhatsApp order support", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "natural_answer", + params: { + answer: "You can track your order here once your account is linked.", + }, + }); + + await service.processMessage( + "2348012345678", + "what can WIZZA do?", + "message-1", + ); + + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + SAFE_NATURAL_ANSWER_FALLBACK, + ); + expect(SAFE_NATURAL_ANSWER_FALLBACK).toContain( + "supported order, delivery, dispute, or refund-status questions", + ); + expect(SAFE_NATURAL_ANSWER_FALLBACK).toContain( + "secure web checkout handoff", + ); + expect(SAFE_NATURAL_ANSWER_FALLBACK).not.toContain( + "use your twizrr dashboard for now", + ); + expect(SAFE_NATURAL_ANSWER_FALLBACK).not.toContain( + "order-related questions once your account is linked", + ); + }); + + it("shows WIZZA capability menu copy instead of a generic bot menu", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + productDiscoveryService.sendProductSelectionFromRecentResults.mockResolvedValue( + false, + ); + intentService.parseIntent.mockResolvedValue({ + functionName: "show_menu", + params: {}, + }); + + await service.processMessage("2348012345678", "menu", "message-1"); + + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining(MAIN_MENU), + ); + expect(MAIN_MENU).toContain("I'm WIZZA"); + expect(MAIN_MENU).toContain("find products"); + expect(MAIN_MENU).not.toContain("twizrr shopping assistant."); + }); + + it("sends short plain-text Gemini natural answers unchanged", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + const plainAnswer = + "Twizrr helps you find products from Nigerian stores and shop with Buyer Protection."; + intentService.parseIntent.mockResolvedValue({ + functionName: "natural_answer", + params: { + answer: plainAnswer, + }, + }); + + await service.processMessage( + "2348012345678", + "what is Twizrr?", + "message-1", + ); + + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + plainAnswer, + ); + }); + + it("keeps protected topics out of natural answers and gates order tracking", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "natural_answer", + params: { + answer: "You can track your order here.", + }, + }); + + await service.processMessage( + "2348012345678", + "track my order", + "message-1", + ); + + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Reply with the email address"), + ); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).not.toHaveBeenCalledWith( + "2348012345678", + "You can track your order here.", + ); + }); + + it("sends deterministic redirect for store-management requests", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "store_management_redirect", + params: {}, + }); + + await service.processMessage( + "2348012345678", + "manage my store", + "message-1", + ); + + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + STORE_MANAGEMENT_REDIRECT, + ); + expect(STORE_MANAGEMENT_REDIRECT).toBe( + "WIZZA is for shopping. Manage your store from your twizrr dashboard.", + ); + expect(STORE_MANAGEMENT_REDIRECT).not.toContain("wizza is"); + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + }); + + it("sends a deterministic support response when Gemini selects support handoff", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "support_handoff", + params: {}, + }); + + await service.processMessage( + "2348012345678", + "talk to support", + "message-1", + ); + + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + "I can help with shopping questions here. For account or order support, please visit twizrr support in the app or on the web.", + ); + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + }); + + it("allows consented unlinked image search", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + + await service.processMessage( + "2348012345678", + undefined, + "message-1", + undefined, + "image-1", + ); + + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect(metaWhatsAppClient.markMessageAsReadWithTyping).toHaveBeenCalledWith( + "message-1", + ); + expect( + metaWhatsAppClient.markMessageAsReadWithTyping.mock + .invocationCallOrder[0], + ).toBeLessThan( + imageSearchService.handleImageSearch.mock.invocationCallOrder[0], + ); + expect(imageSearchService.handleImageSearch).toHaveBeenCalledWith( + "2348012345678", + "image-1", + ); + }); + + it("records image-search vector analytics honestly", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + id: "session-1", + userId: null, + isConsentGiven: true, + }); + imageSearchService.handleImageSearch.mockResolvedValue({ + searchQuery: "sneakers", + parsedFilters: { imageTerms: ["sneakers"] }, + productsRankedCount: 3, + productsShown: ["product-1", "product-2"], + productsShownCount: 2, + zeroResults: false, + intentSuccessful: true, + vectorSearchUsed: true, + embeddingModel: "multimodalembedding@001", + }); + + await service.processMessage( + "2348012345678", + undefined, + "message-1", + undefined, + "image-1", + ); + + expect(whatsappAnalyticsService.recordMessage).toHaveBeenCalledWith( + expect.objectContaining({ + intentClassified: "image_search", + searchQuery: "sneakers", + productsRankedCount: 3, + productsShown: ["product-1", "product-2"], + productsShownCount: 2, + zeroResults: false, + vectorSearchUsed: true, + embeddingModel: "multimodalembedding@001", + }), + ); + }); + + it("continues processing when messageId is missing", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "search_products", + params: { query: "red shoes" }, + }); + + await service.processMessage("2348012345678", "find red shoes"); + + expect( + metaWhatsAppClient.markMessageAsReadWithTyping, + ).not.toHaveBeenCalled(); + expect( + productDiscoveryService.sendGenericProductSearch, + ).toHaveBeenCalledWith("2348012345678", "red shoes"); + }); + + it("continues processing when typing indicator fails", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "search_products", + params: { query: "red shoes" }, + }); + metaWhatsAppClient.markMessageAsReadWithTyping.mockRejectedValue( + new Error("Meta unavailable"), + ); + + await service.processMessage( + "2348012345678", + "find red shoes", + "message-1", + ); + + expect(metaWhatsAppClient.markMessageAsReadWithTyping).toHaveBeenCalledWith( + "message-1", + ); + expect( + metaWhatsAppClient.markMessageAsReadWithTyping, + ).toHaveBeenCalledTimes(1); + expect( + productDiscoveryService.sendGenericProductSearch, + ).toHaveBeenCalledWith("2348012345678", "red shoes"); + }); + + it("asks consented unlinked users to link before checkout actions", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "search_products", + params: { query: "checkout this product" }, + }); + + await service.processMessage( + "2348012345678", + "checkout this product", + "message-1", + ); + + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Reply with the email address"), + ); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.not.stringContaining("Reply with your registered email"), + ); + }); + + it("asks consented unlinked users to link before cart actions", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "search_products", + params: { query: "add this to cart" }, + }); + + await service.processMessage( + "2348012345678", + "add this to cart", + "message-1", + ); + + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Reply with the email address"), + ); + }); + + it("asks consented unlinked users to link before order tracking", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "get_order_status", + params: {}, + }); + + await service.processMessage( + "2348012345678", + "where is my order", + "message-1", + ); + + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Reply with the email address"), + ); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.not.stringContaining("Reply with your registered email"), + ); + }); + + it("lets linked users read their order status through the safe adapter", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "get_order_status", + params: { orderReference: "TWZ-123456" }, + }); + shopperReadService.getOrderStatus.mockResolvedValue({ + message: "Order TWZ-123456 is paid. Delivery status: paid.", + output: { + status: "available", + orderReference: "TWZ-123456", + orderStatus: "paid", + deliveryStatus: "paid", + }, + }); + + await service.processMessage( + "2348012345678", + "where is order TWZ-123456", + "message-1", + ); + + expect(shopperReadService.getOrderStatus).toHaveBeenCalledWith("user-1", { + orderReference: "TWZ-123456", + }); + expect(shopperAgentToolRegistry.assertSafeOutput).toHaveBeenCalledWith( + "get_order_status", + expect.objectContaining({ status: "available" }), + ); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + "Order TWZ-123456 is paid. Delivery status: paid.", + ); + }); + + it("lets linked users read a shopper-safe refund status", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "get_refund_status", + params: { orderReference: "TWZ-123456" }, + }); + postPurchaseService.getRefundStatus.mockResolvedValue({ + message: + "The refund for order TWZ-123456 is processing. Amount: NGN 25,000.00.", + output: { + status: "available", + orderReference: "TWZ-123456", + refundStatus: "processing", + amountDisplay: "NGN 25,000.00", + }, + }); + + await service.processMessage( + "2348012345678", + "where is my refund for TWZ-123456", + "message-1", + ); + + expect(postPurchaseService.getRefundStatus).toHaveBeenCalledWith("user-1", { + orderReference: "TWZ-123456", + }); + expect(shopperAgentToolRegistry.assertSafeOutput).toHaveBeenCalledWith( + "get_refund_status", + expect.objectContaining({ status: "available" }), + ); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("processing"), + ); + }); + + it("prepares an eligible dispute and asks for explicit confirmation", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "start_dispute", + params: { + orderReference: "TWZ-123456", + reason: "Damaged item", + description: "The screen arrived cracked.", + }, + }); + postPurchaseService.prepareDispute.mockResolvedValue({ + message: "Open a dispute for order TWZ-123456?", + output: { + status: "confirmation_required", + orderReference: "TWZ-123456", + nextStep: "confirm_or_cancel", + }, + confirmationButtons: true, + }); + + await service.processMessage( + "2348012345678", + "open a dispute for TWZ-123456 because the screen arrived cracked", + "message-1", + ); + + expect(postPurchaseService.prepareDispute).toHaveBeenCalledWith( + "+2348012345678", + "user-1", + expect.objectContaining({ orderReference: "TWZ-123456" }), + ); + expect(interactiveService.sendReplyButtons).toHaveBeenCalledWith( + "2348012345678", + "Open a dispute for order TWZ-123456?", + [ + { id: WA_SHOPPER_ACTION_CONFIRM_ID, title: "Confirm" }, + { id: WA_SHOPPER_ACTION_CANCEL_ID, title: "Cancel" }, + ], + ); + }); + + it("handles a pending dispute confirmation before generic shopper actions", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + postPurchaseService.handlePendingReply.mockResolvedValue({ + handled: true, + message: + "Your dispute for order TWZ-123456 has been opened. Twizrr will review it.", + }); + + await service.processMessage("2348012345678", "confirm", "message-2"); + + expect(postPurchaseService.handlePendingReply).toHaveBeenCalledWith( + "+2348012345678", + "user-1", + "confirm", + undefined, + ); + expect( + shopperActionService.handlePendingActionReply, + ).not.toHaveBeenCalled(); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("has been opened"), + ); + }); + + it("starts the scoped delivery confirmation flow for a linked shopper", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "confirm_delivery", + params: { orderReference: "TWZ-123456" }, + }); + deliveryConfirmationService.start.mockResolvedValue({ + message: + 'Order TWZ-123456 is selected. Reply with its 6-digit delivery code, or type "cancel" to stop.', + output: { + status: "code_required", + orderReference: "TWZ-123456", + nextStep: "enter_delivery_code", + }, + }); + + await service.processMessage( + "2348012345678", + "confirm delivery for TWZ-123456", + "message-1", + ); + + expect(deliveryConfirmationService.start).toHaveBeenCalledWith( + "+2348012345678", + "user-1", + { orderReference: "TWZ-123456" }, + ); + expect(metaWhatsAppClient.markMessageAsReadWithTyping).toHaveBeenCalledWith( + "message-1", + ); + expect(shopperAgentToolRegistry.assertSafeOutput).toHaveBeenCalledWith( + "confirm_delivery", + expect.objectContaining({ status: "code_required" }), + ); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("6-digit delivery code"), + ); + }); + + it("handles a scoped delivery-code reply before parsing another intent", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + deliveryConfirmationService.handleScopedReply.mockResolvedValue({ + handled: true, + message: "Delivery confirmed for order TWZ-123456. Thank you.", + }); + + await service.processMessage("2348012345678", "654321", "message-2"); + + expect(deliveryConfirmationService.handleScopedReply).toHaveBeenCalledWith( + "+2348012345678", + "user-1", + "654321", + ); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect( + shopperActionService.handlePendingActionReply, + ).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + "Delivery confirmed for order TWZ-123456. Thank you.", + ); + }); + + it("replays a committed action outcome without executing the action twice", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + deliveryConfirmationService.handleScopedReply.mockResolvedValue({ + handled: true, + message: "Delivery confirmed for order TWZ-123456. Thank you.", + }); + interactiveService.sendTextMessage.mockRejectedValueOnce( + new Error("Meta unavailable"), + ); + + await expect( + service.processMessage("2348012345678", "654321", "message-2"), + ).rejects.toThrow("Meta unavailable"); + + const cachedOutcome = redisService.set.mock.calls.find( + ([key]) => key === "wa:inbound-action-outcome:+2348012345678:message-2", + )?.[1] as string; + expect(JSON.parse(cachedOutcome)).toEqual({ + message: "Delivery confirmed for order TWZ-123456. Thank you.", + intentClassified: "delivery_confirmation_reply", + flowAtEnd: "delivery_confirmation", + }); + + redisService.get.mockReset(); + redisService.get.mockResolvedValueOnce(cachedOutcome); + interactiveService.sendTextMessage.mockResolvedValue(undefined); + + await service.processMessage("2348012345678", "654321", "message-2"); + + expect(deliveryConfirmationService.handleScopedReply).toHaveBeenCalledTimes( + 1, + ); + expect(interactiveService.sendTextMessage).toHaveBeenLastCalledWith( + "2348012345678", + "Delivery confirmed for order TWZ-123456. Thank you.", + ); + }); + + it("does not retry a committed action when neither caching nor reply delivery succeeds", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + deliveryConfirmationService.handleScopedReply.mockResolvedValue({ + handled: true, + message: "Delivery confirmed for order TWZ-123456. Thank you.", + }); + redisService.set.mockRejectedValueOnce(new Error("Redis unavailable")); + interactiveService.sendTextMessage.mockRejectedValueOnce( + new Error("Meta unavailable"), + ); + + await expect( + service.processMessage("2348012345678", "654321", "message-2"), + ).resolves.toBeUndefined(); + + expect(deliveryConfirmationService.handleScopedReply).toHaveBeenCalledTimes( + 1, + ); + }); + + it("routes linked cart reads without using the old account placeholder", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "get_cart", + params: {}, + }); + shopperReadService.getCart.mockResolvedValue({ + message: "Your cart is empty.", + output: { status: "empty", itemCount: 0, totalQuantity: 0 }, + }); + + await service.processMessage("2348012345678", "show my cart", "message-1"); + + expect(shopperReadService.getCart).toHaveBeenCalledWith("user-1"); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + "Your cart is empty.", + ); + expect(interactiveService.sendTextMessage).not.toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("coming next"), + ); + }); + + it("prepares linked cart writes and asks for explicit confirmation", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "add_to_cart", + params: { productReference: "first", quantity: 2 }, + }); + shopperActionService.prepareAddToCart.mockResolvedValue({ + message: "Add 2 x Black Sneakers to your cart?", + output: { + status: "confirmation_required", + nextStep: "confirm_or_cancel", + }, + confirmationButtons: true, + }); + + await service.processMessage( + "2348012345678", + "add 2 of the first product to my cart", + "message-1", + ); + + expect(shopperActionService.prepareAddToCart).toHaveBeenCalledWith( + "+2348012345678", + "user-1", + { productReference: "first", quantity: 2 }, + ); + expect(shopperAgentToolRegistry.assertSafeOutput).toHaveBeenCalledWith( + "add_to_cart", + { + status: "confirmation_required", + nextStep: "confirm_or_cancel", + }, + ); + expect(interactiveService.sendReplyButtons).toHaveBeenCalledWith( + "2348012345678", + "Add 2 x Black Sneakers to your cart?", + [ + { id: WA_SHOPPER_ACTION_CONFIRM_ID, title: "Confirm" }, + { id: WA_SHOPPER_ACTION_CANCEL_ID, title: "Cancel" }, + ], + ); + }); + + it("prepares a linked checkout handoff and asks for explicit confirmation", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "start_checkout", + params: {}, + }); + shopperActionService.prepareCheckoutHandoff.mockResolvedValue({ + message: + "Your cart has 3 units across 2 items, subtotal NGN 25,000.00. Continue to secure checkout on Twizrr?", + output: { + status: "confirmation_required", + itemCount: 2, + totalQuantity: 3, + subtotalDisplay: "NGN 25,000.00", + nextStep: "confirm_or_cancel", + }, + confirmationButtons: true, + }); + + await service.processMessage( + "2348012345678", + "proceed to checkout", + "message-checkout", + ); + + expect(shopperActionService.prepareCheckoutHandoff).toHaveBeenCalledWith( + "+2348012345678", + "user-1", + ); + expect(shopperAgentToolRegistry.assertSafeOutput).toHaveBeenCalledWith( + "start_checkout", + { + status: "confirmation_required", + itemCount: 2, + totalQuantity: 3, + subtotalDisplay: "NGN 25,000.00", + nextStep: "confirm_or_cancel", + }, + ); + expect(interactiveService.sendReplyButtons).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Continue to secure checkout"), + [ + { id: WA_SHOPPER_ACTION_CONFIRM_ID, title: "Confirm" }, + { id: WA_SHOPPER_ACTION_CANCEL_ID, title: "Cancel" }, + ], + ); + }); + + it("handles a pending confirmation before parsing another intent", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + shopperActionService.handlePendingActionReply.mockResolvedValue({ + handled: true, + message: "Added Black Sneakers to your cart. Your cart now has 1 unit.", + }); + + await service.processMessage("2348012345678", "confirm", "message-confirm"); + + expect(shopperActionService.handlePendingActionReply).toHaveBeenCalledWith( + "+2348012345678", + "user-1", + "confirm", + undefined, + ); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + "Added Black Sneakers to your cart. Your cart now has 1 unit.", + ); + }); + + it("records analytics for product searches without storing non-search text elsewhere", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + id: "session-1", + userId: null, + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "search_products", + params: { query: "iphone 15" }, + }); + productDiscoveryService.sendGenericProductSearch.mockResolvedValue({ + searchQuery: "iphone 15", + productsRankedCount: 2, + productsShown: ["product-1", "product-2"], + productsShownCount: 2, + zeroResults: false, + intentSuccessful: true, + vectorSearchUsed: true, + embeddingModel: "multimodalembedding@001", + }); + + await service.processMessage("2348012345678", "show me iphone 15"); + + expect(whatsappAnalyticsService.recordMessage).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-1", + phone: "+2348012345678", + messageType: "text", + intentClassified: "search_products", + intentSuccessful: true, + searchQuery: "iphone 15", + productsRankedCount: 2, + productsShown: ["product-1", "product-2"], + productsShownCount: 2, + zeroResults: false, + vectorSearchUsed: true, + embeddingModel: "multimodalembedding@001", + }), + ); + }); + + it("records zero-result search analytics", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + id: "session-1", + userId: null, + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "search_products", + params: { query: "purple shoes" }, + }); + productDiscoveryService.sendGenericProductSearch.mockResolvedValue({ + searchQuery: "purple shoes", + productsRankedCount: 0, + productsShown: [], + productsShownCount: 0, + zeroResults: true, + intentSuccessful: true, + vectorSearchUsed: false, + embeddingModel: null, + }); + + await service.processMessage("2348012345678", "purple shoes"); + + expect(whatsappAnalyticsService.recordMessage).toHaveBeenCalledWith( + expect.objectContaining({ + intentClassified: "search_products", + searchQuery: "purple shoes", + productsRankedCount: 0, + productsShown: [], + productsShownCount: 0, + zeroResults: true, + }), + ); + }); + + it("records list-reply product selection analytics", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + id: "session-1", + userId: null, + isConsentGiven: true, + }); + productDiscoveryService.sendProductSelectionFromRecentResults.mockResolvedValue( + { + handled: true, + intentSuccessful: true, + productSelected: "product-2", + }, + ); + + await service.processMessage("2348012345678", undefined, "message-1", { + type: "list_reply", + id: "product_result_2", + title: "Product 2", + }); + + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect(whatsappAnalyticsService.recordMessage).toHaveBeenCalledWith( + expect.objectContaining({ + messageType: "list_reply", + intentClassified: "product_selection", + intentSuccessful: true, + productSelected: "product-2", + }), + ); + }); + + it("does not store raw non-search message text in fallback analytics", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + id: "session-1", + userId: null, + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "friendly_fallback", + params: {}, + }); + + await service.processMessage("2348012345678", "hello my private message"); + + expect(whatsappAnalyticsService.recordMessage).toHaveBeenCalledWith( + expect.objectContaining({ + intentClassified: "friendly_fallback", + }), + ); + expect( + JSON.stringify(whatsappAnalyticsService.recordMessage.mock.calls[0][0]), + ).not.toContain("hello my private message"); + }); + + it("records normalized error type without breaking the generic error response", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + id: "session-1", + userId: null, + isConsentGiven: true, + }); + intentService.parseIntent.mockRejectedValue( + new Error("Gemini unavailable"), + ); + + await service.processMessage("2348012345678", "help me"); + + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + "Something went wrong while processing your message. Please try again.", + ); + expect(whatsappAnalyticsService.recordMessage).toHaveBeenCalledWith( + expect.objectContaining({ + errorType: "INTENT_PARSE_FAILED", + intentSuccessful: false, + }), + ); + }); + + it("does not fail message handling when analytics write fails", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + id: "session-1", + userId: null, + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "show_menu", + params: {}, + }); + whatsappAnalyticsService.recordMessage.mockRejectedValueOnce( + new Error("analytics down"), + ); + + await expect( + service.processMessage("2348012345678", "menu"), + ).resolves.toBeUndefined(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining(MAIN_MENU), + ); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp.service.ts b/apps/backend/src/channels/whatsapp/whatsapp.service.ts new file mode 100644 index 00000000..1a5386ec --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp.service.ts @@ -0,0 +1,1825 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PRODUCT_CATEGORY_TREE } from "@twizrr/shared"; +import { PrismaService } from "../../prisma/prisma.service"; +import { RedisService } from "../../redis/redis.service"; +import { WhatsAppAuthService } from "./whatsapp-auth.service"; +import { WhatsAppIntentService } from "./whatsapp-intent.service"; +import { ImageSearchService } from "./image-search.service"; +import { WhatsAppInteractiveService } from "./whatsapp-interactive.service"; +import { + FRIENDLY_FALLBACK, + MAIN_MENU, + SAFE_NATURAL_ANSWER_FALLBACK, + STORE_MANAGEMENT_REDIRECT, + SUPPORT_HANDOFF_RESPONSE, + WA_INBOUND_ACTION_OUTCOME_PREFIX, + WA_INBOUND_ACTION_OUTCOME_TTL, + WA_SHOPPER_ACTION_CANCEL_ID, + WA_SHOPPER_ACTION_CONFIRM_ID, +} from "./whatsapp.constants"; +import { + WhatsAppProductDiscoveryService, + type WhatsAppProductSearchAnalytics, +} from "./whatsapp-product-discovery.service"; +import { + WHATSAPP_CONSENT_ACCEPT_ID, + WHATSAPP_CONSENT_DECLINE_ID, + WHATSAPP_CONSENT_DECLINED_MESSAGE, + WHATSAPP_CONSENT_MESSAGE, + WhatsAppSessionService, +} from "./whatsapp-session.service"; +import { MetaWhatsAppClient } from "../../integrations/meta-whatsapp/meta-whatsapp.client"; +import { maskWhatsAppPhone } from "./whatsapp.utils"; +import { + WhatsAppAnalyticsMessageInput, + WhatsAppAnalyticsService, +} from "./whatsapp-analytics.service"; +import { + inferredCategoryLabel, + mergeSignalIntoParsedFilters, + TaxonomyZeroResultSignal, +} from "../../domains/commerce/search/search-zero-result"; +import { WizzaLocationResolver } from "./wizza-location-resolver.service"; +import type { WizzaDiscoveryLocationContext } from "./wizza-location-resolver.service"; +import { ShopperAgentOrchestrator } from "./agent/shopper-agent-orchestrator.service"; +import type { + ShopperAgentPlan, + ShopperAgentToolInputMap, +} from "./agent/shopper-agent.types"; +import { ShopperAgentToolRegistry } from "./agent/shopper-agent-tool.registry"; +import { WhatsAppShopperReadService } from "./whatsapp-shopper-read.service"; +import { + type PendingActionReplyResult, + WhatsAppShopperActionService, +} from "./whatsapp-shopper-action.service"; +import { WhatsAppDeliveryConfirmationService } from "./whatsapp-delivery-confirmation.service"; +import { WhatsAppPostPurchaseService } from "./whatsapp-post-purchase.service"; +import { WhatsAppRetryableActionError } from "./whatsapp-retryable-action.error"; + +type ShopperWriteToolName = + | "add_to_cart" + | "update_cart_quantity" + | "remove_from_cart" + | "start_checkout" + | "select_delivery_address"; + +type ShopperWriteToolInput = ShopperAgentToolInputMap[ShopperWriteToolName]; + +type WhatsAppAnalyticsContext = Omit< + WhatsAppAnalyticsMessageInput, + "sessionId" | "phone" | "messageType" +> & + Pick; + +interface PaymentConfirmedNotificationPayload { + orderReference?: string; +} + +interface OrderDispatchedNotificationPayload { + orderReference?: string; + otp?: string; +} + +interface DiscoveryFollowUpOutcome { + handled: boolean; + intentClassified?: string; + flowAtEnd?: string; + searchAnalytics?: WhatsAppProductSearchAnalytics; +} + +interface InboundActionOutcome { + message: string; + intentClassified: string; + flowAtEnd: string; +} + +@Injectable() +export class WhatsAppService { + private readonly logger = new Logger(WhatsAppService.name); + + constructor( + private configService: ConfigService, + private prisma: PrismaService, + private authService: WhatsAppAuthService, + private intentService: WhatsAppIntentService, + private imageSearchService: ImageSearchService, + private productDiscoveryService: WhatsAppProductDiscoveryService, + private redisService: RedisService, + private interactiveService: WhatsAppInteractiveService, + private metaWhatsAppClient: MetaWhatsAppClient, + private whatsappSessionService: WhatsAppSessionService, + private whatsappAnalyticsService: WhatsAppAnalyticsService, + private wizzaLocationResolver: WizzaLocationResolver, + private shopperAgentOrchestrator: ShopperAgentOrchestrator, + private shopperAgentToolRegistry: ShopperAgentToolRegistry, + private shopperReadService: WhatsAppShopperReadService, + private shopperActionService: WhatsAppShopperActionService, + private deliveryConfirmationService: WhatsAppDeliveryConfirmationService, + private postPurchaseService: WhatsAppPostPurchaseService, + ) {} + + async processMessage( + phone: string, + messageText?: string, + messageId?: string, + interactiveReply?: { type: string; id: string; title: string }, + imageId?: string, + imageCaption?: string, + ): Promise { + let analytics: WhatsAppAnalyticsContext | null = null; + let committedActionOutcome: InboundActionOutcome | null = null; + let committedActionOutcomeReplayable = false; + let typingIndicatorShown = false; + const showTypingIndicatorOnce = async () => { + if (typingIndicatorShown) { + return; + } + + typingIndicatorShown = true; + await this.showTypingIndicator(messageId); + }; + + const replayed = await this.replayInboundActionOutcome( + phone, + messageText, + messageId, + interactiveReply, + ); + if (replayed) { + return; + } + + const pauseKey = `ai_paused_${phone}`; + const isPaused = await this.redisService.get(pauseKey); + + if (isPaused) { + if ( + messageText?.toLowerCase().trim() === "resume" || + messageText?.toLowerCase().trim() === "resume ai" + ) { + await this.redisService.del(pauseKey); + await this.interactiveService.sendTextMessage( + phone, + "AI assistance has been resumed. How can I help you today?", + ); + } + return; + } + + try { + const normalizedPhone = this.whatsappSessionService.normalizePhone(phone); + const userId = await this.authService.resolvePhone(normalizedPhone); + const session = await this.whatsappSessionService.recordInbound( + normalizedPhone, + userId, + ); + analytics = { + sessionId: session.id, + phone: normalizedPhone, + userId: session.userId ?? userId ?? null, + messageType: this.getMessageType( + messageText, + interactiveReply, + imageId, + ), + flowAtStart: session.isConsentGiven ? "idle" : "consent", + flowAtEnd: session.isConsentGiven ? "idle" : "consent", + imageProvided: Boolean(imageId), + intentSuccessful: false, + }; + + if (!session.isConsentGiven) { + if ( + this.whatsappSessionService.isConsentDecline( + messageText, + interactiveReply, + ) + ) { + await this.whatsappSessionService.markConsentDeclined( + normalizedPhone, + ); + await this.interactiveService.sendTextMessage( + phone, + WHATSAPP_CONSENT_DECLINED_MESSAGE, + ); + analytics.intentClassified = "consent_declined"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "consent_declined"; + return; + } + + if ( + this.whatsappSessionService.isConsentAcceptance( + messageText, + interactiveReply, + ) + ) { + await this.whatsappSessionService.markConsentGiven(normalizedPhone); + + await this.sendShopperMenu(phone); + analytics.intentClassified = "consent_accepted"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "idle"; + return; + } + + await this.interactiveService.sendReplyButtons( + phone, + WHATSAPP_CONSENT_MESSAGE, + [ + { id: WHATSAPP_CONSENT_ACCEPT_ID, title: "I Agree" }, + { id: WHATSAPP_CONSENT_DECLINE_ID, title: "No Thanks" }, + ], + ); + analytics.intentClassified = "consent_prompt"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "consent"; + return; + } + + if ( + !userId && + (await this.authService.hasActiveLinkingFlow(normalizedPhone)) + ) { + await this.productDiscoveryService.clearDiscoverySelectionState(phone); + await showTypingIndicatorOnce(); + await this.sendLinkingFlowResponse( + phone, + normalizedPhone, + messageText ?? "", + ); + analytics.intentClassified = "account_linking_flow"; + analytics.intentSuccessful = true; + analytics.flowAtStart = "account_linking"; + analytics.flowAtEnd = "account_linking"; + return; + } + + if (!userId && messageText) { + if (this.isLinkAccountRequest(messageText)) { + await this.productDiscoveryService.clearDiscoverySelectionState( + phone, + ); + await showTypingIndicatorOnce(); + await this.sendStartLinkingFlowResponse(phone, normalizedPhone); + analytics.intentClassified = "account_link_start"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "account_linking"; + return; + } + + if (this.isProtectedAccountActionText(messageText)) { + await this.productDiscoveryService.clearDiscoverySelectionState( + phone, + ); + await showTypingIndicatorOnce(); + await this.sendStartLinkingFlowResponse(phone, normalizedPhone); + analytics.intentClassified = "account_link_required"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "account_linking"; + analytics.dropOffStep = "account_link_required"; + return; + } + } + + const deliveryConfirmationReply = + await this.deliveryConfirmationService.handleScopedReply( + normalizedPhone, + session.userId ?? userId ?? null, + messageText, + ); + if (deliveryConfirmationReply.handled) { + await showTypingIndicatorOnce(); + committedActionOutcome = { + message: + deliveryConfirmationReply.message ?? + "That delivery confirmation could not be completed.", + intentClassified: "delivery_confirmation_reply", + flowAtEnd: "delivery_confirmation", + }; + committedActionOutcomeReplayable = + await this.persistInboundActionOutcomeBestEffort( + phone, + messageId, + committedActionOutcome, + ); + await this.interactiveService.sendTextMessage( + phone, + committedActionOutcome.message, + ); + analytics.intentClassified = "delivery_confirmation_reply"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "delivery_confirmation"; + return; + } + + const pendingDisputeReply = + await this.postPurchaseService.handlePendingReply( + normalizedPhone, + session.userId ?? userId ?? null, + messageText, + interactiveReply, + ); + if (pendingDisputeReply.handled) { + await showTypingIndicatorOnce(); + committedActionOutcome = { + message: + pendingDisputeReply.message ?? + "That dispute request is no longer available.", + intentClassified: "dispute_confirmation", + flowAtEnd: "dispute_confirmation", + }; + committedActionOutcomeReplayable = + await this.persistInboundActionOutcomeBestEffort( + phone, + messageId, + committedActionOutcome, + ); + await this.interactiveService.sendTextMessage( + phone, + committedActionOutcome.message, + ); + analytics.intentClassified = "dispute_confirmation"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "dispute_confirmation"; + return; + } + + const pendingActionReply = + await this.shopperActionService.handlePendingActionReply( + normalizedPhone, + session.userId ?? userId ?? null, + messageText, + interactiveReply, + ); + if (pendingActionReply.handled) { + await showTypingIndicatorOnce(); + committedActionOutcome = { + message: + pendingActionReply.message ?? "That action is no longer available.", + intentClassified: "shopper_action_confirmation", + flowAtEnd: "shopper_action_confirmation", + }; + committedActionOutcomeReplayable = + await this.persistInboundActionOutcomeBestEffort( + phone, + messageId, + committedActionOutcome, + ); + await this.sendPendingActionResult(phone, pendingActionReply); + analytics.intentClassified = "shopper_action_confirmation"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "shopper_action_confirmation"; + return; + } + + if (imageId) { + await showTypingIndicatorOnce(); + const locationContext = await this.wizzaLocationResolver.resolve( + imageCaption, + session.userId ?? userId ?? null, + ); + this.applyLocationContext(analytics, locationContext); + const imageAnalytics = + (await (locationContext.source === "none" + ? this.imageSearchService.handleImageSearch(phone, imageId) + : this.imageSearchService.handleImageSearch( + phone, + imageId, + locationContext, + ))) ?? this.emptySearchAnalytics(); + analytics.intentClassified = "image_search"; + analytics.intentSuccessful = imageAnalytics.intentSuccessful; + analytics.flowAtEnd = "image_search"; + analytics.searchQuery = imageAnalytics.searchQuery; + analytics.parsedFilters = imageAnalytics.parsedFilters ?? null; + analytics.productsRankedCount = imageAnalytics.productsRankedCount; + analytics.productsShown = imageAnalytics.productsShown; + analytics.productsShownCount = imageAnalytics.productsShownCount; + analytics.zeroResults = imageAnalytics.zeroResults; + analytics.vectorSearchUsed = imageAnalytics.vectorSearchUsed; + analytics.embeddingModel = imageAnalytics.embeddingModel; + analytics.errorType = imageAnalytics.errorType ?? null; + analytics.dropOffStep = imageAnalytics.dropOffStep ?? null; + this.applyTaxonomyZeroResultSignal( + analytics, + imageAnalytics.taxonomyZeroResult, + ); + return; + } + + if ( + messageText?.trim() && + (await this.productDiscoveryService.consumePendingTextSearch(phone)) + ) { + await showTypingIndicatorOnce(); + analytics.intentClassified = "search_products"; + await this.executeProductSearch( + phone, + messageText, + session.userId ?? userId ?? null, + analytics, + ); + return; + } + + await showTypingIndicatorOnce(); + const discoveryFollowUp = await this.handleDiscoveryFollowUpAction( + phone, + interactiveReply, + ); + if (discoveryFollowUp.handled) { + analytics.intentClassified = discoveryFollowUp.intentClassified; + if (discoveryFollowUp.searchAnalytics) { + this.applyProductSearchAnalytics( + analytics, + discoveryFollowUp.searchAnalytics, + ); + } else { + analytics.intentSuccessful = true; + } + analytics.flowAtEnd = discoveryFollowUp.flowAtEnd; + return; + } + + const productSelection = + await this.productDiscoveryService.sendProductSelectionFromRecentResults( + phone, + { interactiveReply, messageText }, + ); + const handledProductSelection = + typeof productSelection === "boolean" + ? productSelection + : productSelection.handled; + + if (handledProductSelection) { + analytics.intentClassified = "product_selection"; + analytics.intentSuccessful = + typeof productSelection === "boolean" + ? true + : (productSelection.intentSuccessful ?? true); + analytics.productSelected = + typeof productSelection === "boolean" + ? null + : (productSelection.productSelected ?? null); + analytics.errorType = + typeof productSelection === "boolean" + ? null + : (productSelection.errorType ?? null); + analytics.dropOffStep = + typeof productSelection === "boolean" + ? null + : (productSelection.dropOffStep ?? null); + analytics.flowAtEnd = "product_selection"; + return; + } + + const intentStartMs = Date.now(); + const discoveryContext = + await this.productDiscoveryService.getConversationContext(phone); + const parsedIntent = await this.intentService.parseIntent( + messageText || "", + discoveryContext, + ); + const intent = this.shopperAgentOrchestrator.planIntent({ + functionName: parsedIntent.functionName, + params: parsedIntent.params, + messageText: messageText || "", + linkedUserId: session.userId ?? userId ?? null, + consentGiven: session.isConsentGiven, + }); + analytics.geminiResponseMs = Date.now() - intentStartMs; + analytics.geminiModel = this.getGeminiModelName(); + analytics.intentClassified = intent.functionName; + + if (intent.kind === "tool" && !intent.policy.allowed) { + await this.handleDeniedAgentTool(phone, userId, intent, analytics); + return; + } + + if ( + this.isAccountActionRequest(messageText || "", intent.functionName) && + !this.isShopperReadTool(intent.functionName) && + !this.isShopperWriteTool(intent.functionName) && + !this.isPostPurchaseTool(intent.functionName) && + intent.functionName !== "confirm_delivery" + ) { + if (!(await this.requireLinkedAccount(phone, userId))) { + analytics.intentClassified = "account_link_required"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "account_linking"; + analytics.dropOffStep = "account_link_required"; + return; + } + + await this.sendLinkedAccountActionPlaceholder(phone, "commerce"); + analytics.intentSuccessful = true; + analytics.flowAtEnd = "linked_account_action"; + return; + } + + switch (intent.functionName) { + case "search_products": + await showTypingIndicatorOnce(); + await this.executeProductSearch( + phone, + this.getSearchQuery(intent.params, messageText), + session.userId ?? userId ?? null, + analytics, + ); + return; + + case "refine_product_search": { + await showTypingIndicatorOnce(); + const refinement = this.getStringParam( + intent.params, + "refinement", + messageText || "", + ); + const productReference = this.getOptionalStringParam( + intent.params, + "productReference", + ); + const locationContext = await this.wizzaLocationResolver.resolve( + refinement, + session.userId ?? userId ?? null, + ); + this.applyLocationContext(analytics, locationContext); + const searchAnalytics = + await this.productDiscoveryService.sendRefinedProductSearch( + phone, + refinement, + productReference, + locationContext, + ); + this.applyProductSearchAnalytics(analytics, searchAnalytics); + analytics.flowAtEnd = "product_refinement"; + return; + } + + case "compare_products": { + await showTypingIndicatorOnce(); + const comparison = + await this.productDiscoveryService.sendRecentProductComparison( + phone, + this.getStringArrayParam(intent.params, "productReferences"), + ); + analytics.intentSuccessful = + comparison.handled && comparison.productsComparedCount >= 2; + analytics.productsShownCount = comparison.productsComparedCount; + analytics.errorType = comparison.errorType ?? null; + analytics.dropOffStep = + comparison.productsComparedCount >= 2 + ? null + : "product_comparison_context"; + analytics.flowAtEnd = "product_comparison"; + return; + } + + case "get_cart": + case "get_saved_addresses": + case "list_orders": + case "get_order_status": + case "get_delivery_status": + case "get_dispute_status": + case "get_refund_status": { + const linkedUserId = session.userId ?? userId ?? null; + if (!(await this.requireLinkedAccount(phone, linkedUserId))) { + analytics.intentClassified = "account_link_required"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "account_linking"; + analytics.dropOffStep = "account_link_required"; + return; + } + + await showTypingIndicatorOnce(); + const result = await this.executeShopperReadTool( + intent.functionName, + linkedUserId as string, + intent.params, + ); + this.shopperAgentToolRegistry.assertSafeOutput( + intent.functionName, + result.output, + ); + await this.interactiveService.sendTextMessage(phone, result.message); + analytics.intentSuccessful = true; + analytics.flowAtEnd = intent.functionName; + return; + } + + case "start_dispute": { + const linkedUserId = session.userId ?? userId ?? null; + if (!(await this.requireLinkedAccount(phone, linkedUserId))) { + analytics.intentClassified = "account_link_required"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "account_linking"; + analytics.dropOffStep = "account_link_required"; + return; + } + + await showTypingIndicatorOnce(); + const result = await this.postPurchaseService.prepareDispute( + normalizedPhone, + linkedUserId as string, + intent.tool.input as ShopperAgentToolInputMap["start_dispute"], + ); + this.shopperAgentToolRegistry.assertSafeOutput( + intent.functionName, + result.output, + ); + if (result.confirmationButtons) { + await this.interactiveService.sendReplyButtons( + phone, + result.message, + [ + { id: WA_SHOPPER_ACTION_CONFIRM_ID, title: "Confirm" }, + { id: WA_SHOPPER_ACTION_CANCEL_ID, title: "Cancel" }, + ], + ); + } else { + await this.interactiveService.sendTextMessage( + phone, + result.message, + ); + } + analytics.intentSuccessful = true; + analytics.flowAtEnd = intent.functionName; + return; + } + + case "add_to_cart": + case "update_cart_quantity": + case "remove_from_cart": + case "start_checkout": + case "select_delivery_address": { + const linkedUserId = session.userId ?? userId ?? null; + if (!(await this.requireLinkedAccount(phone, linkedUserId))) { + analytics.intentClassified = "account_link_required"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "account_linking"; + analytics.dropOffStep = "account_link_required"; + return; + } + + await showTypingIndicatorOnce(); + const result = await this.executeShopperWriteTool( + intent.functionName, + normalizedPhone, + linkedUserId as string, + intent.tool.input as ShopperWriteToolInput, + ); + this.shopperAgentToolRegistry.assertSafeOutput( + intent.functionName, + result.output, + ); + if (result.confirmationButtons) { + await this.interactiveService.sendReplyButtons( + phone, + result.message, + [ + { id: WA_SHOPPER_ACTION_CONFIRM_ID, title: "Confirm" }, + { id: WA_SHOPPER_ACTION_CANCEL_ID, title: "Cancel" }, + ], + ); + } else { + await this.interactiveService.sendTextMessage( + phone, + result.message, + ); + } + analytics.intentSuccessful = true; + analytics.flowAtEnd = intent.functionName; + return; + } + + case "confirm_delivery": { + const linkedUserId = session.userId ?? userId ?? null; + if (!(await this.requireLinkedAccount(phone, linkedUserId))) { + analytics.intentClassified = "account_link_required"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "account_linking"; + analytics.dropOffStep = "account_link_required"; + return; + } + + await showTypingIndicatorOnce(); + const result = await this.deliveryConfirmationService.start( + normalizedPhone, + linkedUserId as string, + intent.tool.input as ShopperAgentToolInputMap["confirm_delivery"], + ); + this.shopperAgentToolRegistry.assertSafeOutput( + intent.functionName, + result.output, + ); + await this.interactiveService.sendTextMessage(phone, result.message); + analytics.intentSuccessful = true; + analytics.flowAtEnd = intent.functionName; + return; + } + + case "show_menu": + await this.sendShopperMenu(phone); + analytics.intentSuccessful = true; + analytics.flowAtEnd = "menu"; + return; + + case "natural_answer": + await this.interactiveService.sendTextMessage( + phone, + this.getSafeNaturalAnswer(intent.params), + ); + analytics.intentSuccessful = true; + analytics.flowAtEnd = "natural_answer"; + return; + + case "store_management_redirect": + await this.interactiveService.sendTextMessage( + phone, + STORE_MANAGEMENT_REDIRECT, + ); + analytics.intentSuccessful = true; + analytics.flowAtEnd = "store_management_redirect"; + return; + + case "support_handoff": + await this.interactiveService.sendTextMessage( + phone, + SUPPORT_HANDOFF_RESPONSE, + ); + analytics.intentSuccessful = true; + analytics.escalatedToHuman = true; + analytics.flowAtEnd = "support_handoff"; + return; + + case "friendly_fallback": + default: + await this.interactiveService.sendTextMessage( + phone, + FRIENDLY_FALLBACK, + ); + analytics.intentSuccessful = false; + analytics.flowAtEnd = "fallback"; + } + } catch (error) { + if (analytics) { + analytics.errorType = this.normalizeAnalyticsError(error); + analytics.flowAtEnd = "error"; + analytics.intentSuccessful = false; + } + this.logger.error( + `WhatsApp processing failed for ${maskWhatsAppPhone(phone)}: ${ + error instanceof Error ? error.message : error + }`, + ); + if ( + !committedActionOutcome && + error instanceof WhatsAppRetryableActionError + ) { + throw error; + } + if (committedActionOutcome) { + if (committedActionOutcomeReplayable) { + throw error; + } + this.logger.error( + `WhatsApp committed action reply could not be delivered or cached for ${maskWhatsAppPhone(phone)}; suppressing automatic retry to avoid repeating the action`, + ); + return; + } + await this.interactiveService.sendTextMessage( + phone, + "Something went wrong while processing your message. Please try again.", + ); + } finally { + await this.recordAnalyticsBestEffort(analytics); + } + } + + private getStringParam( + params: Record, + key: string, + fallback: string, + ): string { + const value = params[key]; + return typeof value === "string" && value.trim() + ? value.trim() + : fallback.trim(); + } + + private getOptionalStringParam( + params: Record, + key: string, + ): string | undefined { + const value = params[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; + } + + private getStringArrayParam( + params: Record, + key: string, + ): string[] { + const value = params[key]; + if (!Array.isArray(value)) { + return []; + } + + return value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, 3); + } + + private getMessageType( + messageText?: string, + interactiveReply?: { type: string; id: string; title: string }, + imageId?: string, + ): string { + if (imageId) { + return "image"; + } + + if (interactiveReply?.type) { + return interactiveReply.type; + } + + if (messageText !== undefined) { + return "text"; + } + + return "unknown"; + } + + private async handleDiscoveryFollowUpAction( + phone: string, + interactiveReply?: { type: string; id: string; title: string }, + ): Promise { + if (!interactiveReply?.id) { + return { handled: false }; + } + + const actionId = interactiveReply.id.trim().toLowerCase(); + + if (actionId === "browse_categories") { + await this.productDiscoveryService.clearDiscoverySelectionState(phone); + await this.sendCategoryBrowser(phone, 0); + return { + handled: true, + intentClassified: "browse_categories", + flowAtEnd: "category_browser", + }; + } + + if (actionId === "browse_categories_more") { + await this.productDiscoveryService.clearDiscoverySelectionState(phone); + await this.sendCategoryBrowser(phone, 1); + return { + handled: true, + intentClassified: "browse_categories", + flowAtEnd: "category_browser", + }; + } + + if (actionId === "search_products") { + await this.productDiscoveryService.clearDiscoverySelectionState(phone); + await this.productDiscoveryService.startPendingTextSearch(phone); + await this.interactiveService.sendTextMessage( + phone, + "Tell me what you want to find. You can send a product name, category, colour, or style.", + ); + return { + handled: true, + intentClassified: "search_again", + flowAtEnd: "awaiting_product_search", + }; + } + + const category = PRODUCT_CATEGORY_TREE.find((entry) => + [`category:${entry.slug}`, `category_selection_${entry.slug}`].includes( + actionId, + ), + ); + if (!category) { + return { handled: false }; + } + + const searchAnalytics = + (await this.productDiscoveryService.sendGenericProductSearch( + phone, + category.label, + )) ?? this.emptySearchAnalytics(); + return { + handled: true, + intentClassified: "category_search", + flowAtEnd: "product_search", + searchAnalytics, + }; + } + + private async sendCategoryBrowser(phone: string, page: 0 | 1): Promise { + const pageSize = 9; + const categories = PRODUCT_CATEGORY_TREE.slice( + page * pageSize, + (page + 1) * pageSize, + ); + const rows: Array<{ id: string; title: string; description: string }> = + categories.map((category) => ({ + id: `category:${category.slug}`, + title: category.label, + description: "Browse products", + })); + + if (page === 0 && PRODUCT_CATEGORY_TREE.length > pageSize) { + rows.push({ + id: "browse_categories_more", + title: "More categories", + description: "See more product types", + }); + } + + await this.interactiveService.sendListMessage( + phone, + "Browse product categories and choose one to see available items.", + "Browse categories", + [{ title: "Categories", rows }], + ); + } + + private getGeminiModelName(): string { + return ( + this.configService.get("ai.geminiModel") || + this.configService.get("GEMINI_MODEL") || + "gemini-2.5-flash" + ); + } + + private emptySearchAnalytics(): { + searchQuery: string | null; + parsedFilters: null; + productsRankedCount: number; + productsShown: string[]; + productsShownCount: number; + zeroResults: boolean; + intentSuccessful: boolean; + vectorSearchUsed: boolean; + embeddingModel: null; + errorType: null; + dropOffStep: null; + } { + return { + searchQuery: null, + parsedFilters: null, + productsRankedCount: 0, + productsShown: [], + productsShownCount: 0, + zeroResults: false, + intentSuccessful: false, + vectorSearchUsed: false, + embeddingModel: null, + errorType: null, + dropOffStep: null, + }; + } + + private async executeProductSearch( + phone: string, + query: string, + userId: string | null, + analytics: WhatsAppAnalyticsContext, + ): Promise { + const locationContext = await this.wizzaLocationResolver.resolve( + query, + userId, + ); + this.applyLocationContext(analytics, locationContext); + const searchAnalytics = + (await (locationContext.source === "none" + ? this.productDiscoveryService.sendGenericProductSearch(phone, query) + : this.productDiscoveryService.sendGenericProductSearch( + phone, + query, + locationContext, + ))) ?? this.emptySearchAnalytics(); + this.applyProductSearchAnalytics(analytics, searchAnalytics); + analytics.flowAtEnd = "product_search"; + } + + private applyProductSearchAnalytics( + analytics: WhatsAppAnalyticsContext, + searchAnalytics: WhatsAppProductSearchAnalytics, + ): void { + analytics.searchQuery = searchAnalytics.searchQuery; + analytics.productsRankedCount = searchAnalytics.productsRankedCount; + analytics.productsShown = searchAnalytics.productsShown; + analytics.productsShownCount = searchAnalytics.productsShownCount; + analytics.zeroResults = searchAnalytics.zeroResults; + analytics.intentSuccessful = searchAnalytics.intentSuccessful; + analytics.vectorSearchUsed = searchAnalytics.vectorSearchUsed; + analytics.embeddingModel = searchAnalytics.embeddingModel; + analytics.dropOffStep = searchAnalytics.dropOffStep ?? null; + this.applyTaxonomyZeroResultSignal( + analytics, + searchAnalytics.taxonomyZeroResult, + ); + } + + private normalizeAnalyticsError(error: unknown): string { + if (!(error instanceof Error)) { + return "PROCESSING_ERROR"; + } + + const message = error.message.toLowerCase(); + + if (message.includes("gemini") || message.includes("intent")) { + return "INTENT_PARSE_FAILED"; + } + + if (message.includes("image")) { + return "IMAGE_SEARCH_ERROR"; + } + + if (message.includes("link")) { + return "ACCOUNT_LINKING_ERROR"; + } + + return "PROCESSING_ERROR"; + } + + // Enriches the single per-message analytics record with a taxonomy + // zero-result signal. No extra write: it sets categoryInferred and merges the + // signal into parsedFilters on the record already being recorded in `finally`. + private applyTaxonomyZeroResultSignal( + analytics: WhatsAppAnalyticsContext, + signal: TaxonomyZeroResultSignal | null | undefined, + ): void { + if (!signal) { + return; + } + analytics.categoryInferred = inferredCategoryLabel(signal); + analytics.parsedFilters = mergeSignalIntoParsedFilters( + analytics.parsedFilters, + signal, + ); + } + + /** Persist only broad, resolved discovery locality on the existing analytics row. */ + private applyLocationContext( + analytics: WhatsAppAnalyticsContext, + context: WizzaDiscoveryLocationContext, + ): void { + if (context.source === "none") return; + const base = + analytics.parsedFilters && + typeof analytics.parsedFilters === "object" && + !Array.isArray(analytics.parsedFilters) + ? (analytics.parsedFilters as Record) + : {}; + analytics.parsedFilters = { + ...base, + locationContext: { + source: context.source, + countryCode: context.countryCode, + state: context.state, + city: context.city, + area: context.area, + }, + }; + } + + private async recordAnalyticsBestEffort( + analytics: WhatsAppAnalyticsContext | null, + ): Promise { + if (!analytics) { + return; + } + + try { + await this.whatsappAnalyticsService.recordMessage(analytics); + } catch (error) { + this.logger.warn( + `WhatsApp analytics failed for ${maskWhatsAppPhone(analytics.phone)}: ${ + error instanceof Error ? error.message : "unknown error" + }`, + ); + } + } + + private async handleDeniedAgentTool( + phone: string, + userId: string | null, + intent: Extract, + analytics: WhatsAppAnalyticsContext, + ): Promise { + if (intent.policy.reason === "linked_account_required") { + await this.requireLinkedAccount(phone, userId); + analytics.intentClassified = "account_link_required"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "account_linking"; + analytics.dropOffStep = "account_link_required"; + return; + } + + await this.interactiveService.sendTextMessage(phone, FRIENDLY_FALLBACK); + analytics.intentSuccessful = false; + analytics.flowAtEnd = "fallback"; + analytics.dropOffStep = intent.policy.reason; + } + + private isAccountActionRequest( + messageText: string, + functionName?: string, + ): boolean { + if ( + this.isShopperReadTool(functionName) || + this.isShopperWriteTool(functionName) || + this.isPostPurchaseTool(functionName) || + functionName === "confirm_delivery" + ) { + return true; + } + + return this.isProtectedAccountActionText(messageText); + } + + private isShopperWriteTool( + functionName?: string, + ): functionName is ShopperWriteToolName { + return [ + "add_to_cart", + "update_cart_quantity", + "remove_from_cart", + "start_checkout", + "select_delivery_address", + ].includes(functionName ?? ""); + } + + private isShopperReadTool( + functionName?: string, + ): functionName is + | "get_cart" + | "get_saved_addresses" + | "list_orders" + | "get_order_status" + | "get_delivery_status" + | "get_dispute_status" + | "get_refund_status" { + return [ + "get_cart", + "get_saved_addresses", + "list_orders", + "get_order_status", + "get_delivery_status", + "get_dispute_status", + "get_refund_status", + ].includes(functionName ?? ""); + } + + private isPostPurchaseTool( + functionName?: string, + ): functionName is + | "start_dispute" + | "get_dispute_status" + | "get_refund_status" { + return [ + "start_dispute", + "get_dispute_status", + "get_refund_status", + ].includes(functionName ?? ""); + } + + private executeShopperReadTool( + functionName: + | "get_cart" + | "get_saved_addresses" + | "list_orders" + | "get_order_status" + | "get_delivery_status" + | "get_dispute_status" + | "get_refund_status", + userId: string, + params: Record, + ) { + switch (functionName) { + case "get_cart": + return this.shopperReadService.getCart(userId); + case "get_saved_addresses": + return this.shopperReadService.getSavedAddresses(userId); + case "list_orders": + return this.shopperReadService.listOrders(userId); + case "get_order_status": + return this.shopperReadService.getOrderStatus(userId, { + orderReference: this.getOptionalStringParam(params, "orderReference"), + }); + case "get_delivery_status": + return this.shopperReadService.getDeliveryStatus(userId, { + orderReference: this.getOptionalStringParam(params, "orderReference"), + }); + case "get_dispute_status": + return this.postPurchaseService.getDisputeStatus(userId, { + orderReference: this.getOptionalStringParam(params, "orderReference"), + }); + case "get_refund_status": + return this.postPurchaseService.getRefundStatus(userId, { + orderReference: this.getOptionalStringParam(params, "orderReference"), + }); + } + } + + private executeShopperWriteTool( + functionName: ShopperWriteToolName, + phone: string, + userId: string, + input: ShopperWriteToolInput, + ) { + switch (functionName) { + case "add_to_cart": + return this.shopperActionService.prepareAddToCart( + phone, + userId, + input as { + productReference?: string; + quantity: number; + }, + ); + case "update_cart_quantity": + return this.shopperActionService.prepareUpdateCartQuantity( + phone, + userId, + input as { + cartItemReference?: string; + quantity: number; + }, + ); + case "remove_from_cart": + return this.shopperActionService.prepareRemoveFromCart( + phone, + userId, + input as { cartItemReference?: string }, + ); + case "start_checkout": + return this.shopperActionService.prepareCheckoutHandoff(phone, userId); + case "select_delivery_address": + return this.shopperActionService.prepareSelectDeliveryAddress( + phone, + userId, + input as { addressReference?: string }, + ); + } + } + + private async sendPendingActionResult( + phone: string, + result: PendingActionReplyResult, + ): Promise { + await this.interactiveService.sendTextMessage( + phone, + result.message ?? "That action is no longer available.", + ); + } + + private async replayInboundActionOutcome( + phone: string, + messageText?: string, + messageId?: string, + interactiveReply?: { id: string }, + ): Promise { + if ( + !messageId?.trim() || + !this.isPotentialConfirmedActionReply(messageText, interactiveReply) + ) { + return false; + } + + const raw = await this.redisService.get( + this.inboundActionOutcomeKey(phone, messageId), + ); + const outcome = raw ? this.parseInboundActionOutcome(raw) : null; + if (!outcome) { + return false; + } + + await this.interactiveService.sendTextMessage(phone, outcome.message); + return true; + } + + private async persistInboundActionOutcomeBestEffort( + phone: string, + messageId: string | undefined, + outcome: InboundActionOutcome, + ): Promise { + if (!messageId?.trim()) { + return false; + } + + try { + await this.redisService.set( + this.inboundActionOutcomeKey(phone, messageId), + JSON.stringify(outcome), + WA_INBOUND_ACTION_OUTCOME_TTL, + ); + return true; + } catch (error) { + this.logger.warn( + `WhatsApp action outcome caching failed for ${maskWhatsAppPhone(phone)}: ${ + error instanceof Error ? error.message : "unknown error" + }`, + ); + return false; + } + } + + private parseInboundActionOutcome(raw: string): InboundActionOutcome | null { + try { + const value: unknown = JSON.parse(raw); + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const outcome = value as Record; + if ( + typeof outcome.message !== "string" || + !outcome.message.trim() || + typeof outcome.intentClassified !== "string" || + typeof outcome.flowAtEnd !== "string" + ) { + return null; + } + return { + message: outcome.message, + intentClassified: outcome.intentClassified, + flowAtEnd: outcome.flowAtEnd, + }; + } catch { + return null; + } + } + + private isPotentialConfirmedActionReply( + messageText?: string, + interactiveReply?: { id: string }, + ): boolean { + const text = messageText?.trim().toLowerCase(); + const reply = interactiveReply?.id.trim().toLowerCase(); + return ( + text === "confirm" || + text === "yes" || + text === "cancel" || + text === "no" || + Boolean(text && /^\d{6}$/.test(text)) || + reply === WA_SHOPPER_ACTION_CONFIRM_ID || + reply === WA_SHOPPER_ACTION_CANCEL_ID + ); + } + + private inboundActionOutcomeKey(phone: string, messageId: string): string { + return `${WA_INBOUND_ACTION_OUTCOME_PREFIX}${this.whatsappSessionService.normalizePhone(phone)}:${messageId.trim()}`; + } + + private getSearchQuery( + params: Record | undefined, + fallback?: string, + ): string { + const query = params?.query; + return typeof query === "string" && query.trim() ? query : fallback || ""; + } + + private getSafeNaturalAnswer(params: Record): string { + const answer = params.answer; + + if (typeof answer !== "string") { + return SAFE_NATURAL_ANSWER_FALLBACK; + } + + const trimmed = answer.trim(); + return this.isSafeNaturalAnswer(trimmed) + ? trimmed + : SAFE_NATURAL_ANSWER_FALLBACK; + } + + private isSafeNaturalAnswer(answer: string): boolean { + if (!answer || answer.length > 700) { + return false; + } + + if (this.countWords(answer) > 150) { + return false; + } + + if ( + this.looksLikeJson(answer) || + this.containsEmoji(answer) || + this.containsMarkdownOrBullets(answer) + ) { + return false; + } + + return ( + !this.containsForbiddenInternalTerm(answer) && + !this.containsUnapprovedFounderClaim(answer) && + !this.containsUnsupportedCommerceClaim(answer) + ); + } + + private countWords(answer: string): number { + const words = answer.trim().split(/\s+/).filter(Boolean); + return words.length; + } + + private containsMarkdownOrBullets(answer: string): boolean { + return ( + /^\s*[-*•]\s+/m.test(answer) || + /^\s*\d+[.)]\s+/m.test(answer) || + /[`*_#~>]/.test(answer) + ); + } + + private looksLikeJson(answer: string): boolean { + const trimmed = answer.trim(); + return ( + (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) || + /"answer"\s*:|"topic"\s*:/.test(trimmed) + ); + } + + private containsForbiddenInternalTerm(answer: string): boolean { + return [ + "physicalStoreId", + "linkedOrderId", + "sourcedProductId", + "dropshipperCostKobo", + "digitalMarginKobo", + "paystackRecipientCode", + "api key", + "token", + "database url", + ].some((term) => answer.toLowerCase().includes(term.toLowerCase())); + } + + private containsUnapprovedFounderClaim(answer: string): boolean { + const lower = answer.toLowerCase(); + if ( + /\b(aliameen|mustakheem|abdulraseed)\b/i.test(answer) || + /\bfounder\b/.test(lower) + ) { + return true; + } + + return ( + /\b(created by|built by|made by|owned by)\b/.test(lower) && + !answer.includes("CODEDDEVS TECHNOLOGY LTD") + ); + } + + private containsUnsupportedCommerceClaim(answer: string): boolean { + return [ + /\b(in stock|out of stock|available now|we have|we sell)\b/i, + /\b(costs|price is|selling for|available for)\b/i, + /(\u20a6|\bngn\b|\bnaira\b|\bkobo\b)/i, + /\b(arrives on|delivered by|delivery date is)\b/i, + /\bpayment (is|has been) (confirmed|received|failed|pending)\b/i, + /\b(checkout|check out) here\b/i, + /\b(pay here|pay now)\b/i, + /\b(place|complete|create) (the|your|an) order\b/i, + /\bbuy directly here\b/i, + /\badd (it|this|that|the product)?\s*to cart\b/i, + /\bsave (it|this|that|the product)?\s*to (your )?wishlist\b/i, + /\btrack (your|my|an)?\s*order here\b/i, + /\bconfirm delivery here\b/i, + /\bopen (a )?dispute\b/i, + /\bprocess (a )?refund\b/i, + /\bupdate (your )?(account|profile|order data|order)\b/i, + ].some((pattern) => pattern.test(answer)); + } + + private containsEmoji(answer: string): boolean { + return /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(answer); + } + + private async showTypingIndicator(messageId?: string): Promise { + if (!messageId?.trim()) { + return; + } + + try { + await this.metaWhatsAppClient.markMessageAsReadWithTyping(messageId); + } catch (error) { + this.logger.warn( + `WhatsApp typing indicator failed: ${ + error instanceof Error ? error.message : "unknown error" + }`, + ); + } + } + + private async requireLinkedAccount( + phone: string, + userId: string | null, + ): Promise { + if (userId) { + return true; + } + + await this.sendStartLinkingFlowResponse( + phone, + this.whatsappSessionService.normalizePhone(phone), + ); + + return false; + } + + private async sendStartLinkingFlowResponse( + phone: string, + normalizedPhone: string, + ): Promise { + const response = await this.authService.startLinkingFlow(normalizedPhone); + await this.interactiveService.sendTextMessage(phone, response); + } + + private async sendLinkingFlowResponse( + phone: string, + normalizedPhone: string, + messageText: string, + ): Promise { + const response = await this.authService.handleLinkingFlow( + normalizedPhone, + messageText, + ); + await this.interactiveService.sendTextMessage(phone, response); + } + + private isLinkAccountRequest(messageText: string): boolean { + const lower = messageText.toLowerCase(); + return [ + "link my account", + "link account", + "connect my account", + "connect account", + "verify my account", + ].some((phrase) => lower.includes(phrase)); + } + + private isProtectedAccountActionText(messageText: string): boolean { + const lower = messageText.toLowerCase(); + return [ + /\bcheck\s*out\b/, + /\bcheckout\b/, + /\badd\b.{0,60}\bto\s+(?:my\s+)?cart\b/, + /\bput\b.{0,60}\bin\s+(?:my\s+)?cart\b/, + /\b(?:remove|delete)\b.{0,60}\bfrom\s+(?:my\s+)?cart\b/, + /\b(?:set|change|update)\b.{0,60}\b(?:quantity|qty)\b/, + /\b(?:use|select|choose)\b.{0,60}\b(?:delivery\s+)?address\b/, + /\bdeliver\s+to\b.{1,60}\b(?:delivery\s+)?address\b/, + /\bsave\s+(?:this|that|it|the\s+product)\b/, + /\b(?:save|add)\b.{0,60}\bto\s+(?:my\s+)?wishlist\b/, + /\bpay\s+(?:for|now|here)\b/, + /\bmake\s+(?:a\s+)?payment\b/, + /\bpayment\s+(?:status|failed|confirmed|received|pending)\b/, + /\bbuy\s+(?:this|that|it)\s+for\s+me\b/, + /\btrack\s+(?:my|the|this|an)?\s*order\b/, + /\border\s+(?:status|history)\b/, + /\b(?:show|view|check|what(?:'s| is))\s+(?:in\s+)?(?:my\s+)?cart\b/, + /\b(?:show|view|list)\s+(?:my\s+)?saved\s+(?:delivery\s+)?addresses\b/, + /\b(?:show|view|list)\s+(?:my\s+)?(?:recent\s+)?orders\b/, + /\bdelivery\s+(?:status|progress)\b/, + /\bwhere\s+is\s+my\s+order\b/, + /\bconfirm\s+delivery\b/, + /\bdelivery\s+(?:confirmation|code)\b/, + /\b(?:open|start|file|raise)\s+(?:a\s+)?dispute\b/, + /\b(?:get|process|request|start)\s+(?:a\s+)?refund\b/, + /\brefund\s+(?:my|this|that|the)\b/, + /\b(?:update|change|edit)\s+(?:my\s+)?(?:account|profile|order\s+data|order)\b/, + /\baccount\s+(?:support|settings|profile)\b/, + ].some((pattern) => pattern.test(lower)); + } + + private async sendLinkedAccountActionPlaceholder( + phone: string, + action: string, + ): Promise { + const actionLabel = + action === "get_order_status" + ? "check order status" + : action === "confirm_delivery" + ? "confirm delivery" + : "continue with checkout or account actions"; + + await this.interactiveService.sendTextMessage( + phone, + `Your account is linked. To ${actionLabel}, open your twizrr dashboard for now. WhatsApp account actions are coming next.`, + ); + } + + async sendDirectOrderNotification( + storeId: string, + metadata: Record, + ): Promise { + const profile = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + include: { user: { include: { whatsappLink: true } } }, + }); + + const phone = profile?.user?.whatsappLink?.phone; + if (!phone) return; + + const amountKobo = metadata.totalAmountKobo ?? metadata.amountKobo; + const total = + amountKobo !== undefined + ? `\nTotal: ${this.formatNaira(Number(amountKobo))}` + : ""; + const orderReference = metadata.orderReference ?? metadata.orderId; + await this.sendOutboundNotificationMessage( + phone, + `New order received.\nOrder: ${orderReference || "pending"}${total}`, + ); + } + + async sendDeliveryConfirmedNotification( + storeId: string, + metadata: { orderReference: string }, + ): Promise { + const profile = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + include: { user: { include: { whatsappLink: true } } }, + }); + + const phone = profile?.user?.whatsappLink?.phone; + if (!phone) return; + + await this.sendOutboundNotificationMessage( + phone, + `Delivery confirmed for order ${metadata.orderReference}.`, + ); + } + + async sendBuyerLogisticsUpdate( + buyerIdOrPhone: string, + metadataOrOrderId: + | { + orderReference?: string; + status?: string; + trackingUrl?: string; + } + | string, + status?: string, + trackingUrl?: string, + ): Promise { + const metadata = + typeof metadataOrOrderId === "string" + ? { orderReference: metadataOrOrderId, status, trackingUrl } + : metadataOrOrderId; + + const user = await this.prisma.user.findUnique({ + where: { id: buyerIdOrPhone }, + include: { whatsappLink: true }, + }); + + const phone = user?.whatsappLink?.phone ?? buyerIdOrPhone; + if (!phone) return; + + await this.sendWhatsAppMessage( + phone, + `Delivery update${ + metadata.orderReference ? ` for order ${metadata.orderReference}` : "" + }: ${metadata.status || "updated"}${ + metadata.trackingUrl ? `\nTrack: ${metadata.trackingUrl}` : "" + }`, + ); + } + + async sendMerchantLogisticsUpdate( + storeId: string, + metadataOrOrderId: + | { + orderReference?: string; + status?: string; + trackingUrl?: string; + } + | string, + status?: string, + trackingUrl?: string, + ): Promise { + const metadata = + typeof metadataOrOrderId === "string" + ? { orderReference: metadataOrOrderId, status, trackingUrl } + : metadataOrOrderId; + + const profile = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + include: { user: { include: { whatsappLink: true } } }, + }); + + const phone = profile?.user?.whatsappLink?.phone; + if (!phone) return; + + await this.sendWhatsAppMessage( + phone, + `Store delivery update${ + metadata.orderReference ? ` for order ${metadata.orderReference}` : "" + }: ${metadata.status || "updated"}${ + metadata.trackingUrl ? `\nTrack: ${metadata.trackingUrl}` : "" + }`, + ); + } + + async sendPaymentConfirmedNotification( + buyerId: string, + metadata: PaymentConfirmedNotificationPayload, + ): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: buyerId }, + include: { whatsappLink: true }, + }); + + const phone = user?.whatsappLink?.phone; + if (!phone) return; + + await this.sendOutboundNotificationMessage( + phone, + `Payment confirmed for order ${metadata.orderReference || ""}.`, + ); + } + + async sendOrderDispatchedNotification( + buyerId: string, + metadata: OrderDispatchedNotificationPayload, + ): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: buyerId }, + include: { whatsappLink: true }, + }); + + const phone = user?.whatsappLink?.phone; + if (!phone) return; + + if (!metadata?.orderReference || !metadata?.otp) { + this.logger.warn( + `Missing orderReference or OTP in sendOrderDispatchedNotification for ${maskWhatsAppPhone(phone)}. Skipping.`, + ); + return; + } + + await this.sendOutboundNotificationMessage( + phone, + `Order ${metadata.orderReference} is on the way.\nDelivery code: ${metadata.otp}`, + ); + } + + async sendWhatsAppMessage(phone: string, text: string): Promise { + await this.metaWhatsAppClient.sendTextMessage(phone, text, { + throwOnError: false, + }); + } + + async sendQueuedWhatsAppMessage(phone: string, text: string): Promise { + await this.sendOutboundNotificationMessage(phone, text); + } + + private async sendOutboundNotificationMessage( + phone: string, + text: string, + ): Promise { + await this.metaWhatsAppClient.sendTextMessage(phone, text, { + throwOnError: true, + }); + } + + async sendWhatsAppTemplateMessage( + phone: string, + templateName: string, + otpCodeOrParameters: string | Array<{ type: string; text?: string }>, + ): Promise { + const otpCode = Array.isArray(otpCodeOrParameters) + ? otpCodeOrParameters.find((parameter) => parameter.type === "text") + ?.text || "" + : otpCodeOrParameters; + + await this.interactiveService.sendTemplateMessage( + phone, + templateName, + otpCode, + ); + } + + private async sendShopperMenu(phone: string): Promise { + const frontendUrl = + this.configService.get("app.frontendUrl") || + this.configService.get("FRONTEND_URL") || + "https://twizrr.com"; + + await this.interactiveService.sendTextMessage( + phone, + `${MAIN_MENU}\n${frontendUrl}`, + ); + } + + private formatNaira(kobo: number): string { + return new Intl.NumberFormat("en-NG", { + style: "currency", + currency: "NGN", + minimumFractionDigits: 0, + }).format(kobo / 100); + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp.utils.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp.utils.spec.ts new file mode 100644 index 00000000..7acf230d --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp.utils.spec.ts @@ -0,0 +1,18 @@ +import { maskWhatsAppPhone } from "./whatsapp.utils"; + +describe("maskWhatsAppPhone", () => { + it.each([ + ["+2348012345678", "+234******5678"], + ["2348012345678", "234******5678"], + ["+15551234567", "+155******4567"], + ])("masks valid WhatsApp phone %s", (phone, expected) => { + expect(maskWhatsAppPhone(phone)).toBe(expected); + }); + + it.each([null, undefined, "", "1234", "not-a-phone", "+12 34"])( + "returns a generic mask for invalid input %p", + (phone) => { + expect(maskWhatsAppPhone(phone)).toBe("****"); + }, + ); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp.utils.ts b/apps/backend/src/channels/whatsapp/whatsapp.utils.ts new file mode 100644 index 00000000..b6e94ed7 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp.utils.ts @@ -0,0 +1,43 @@ +import { BadRequestException } from "@nestjs/common"; + +const E164_PHONE_REGEX = /^\+[1-9]\d{1,14}$/; + +export function normalizeWhatsAppPhone(phone: string): string { + const cleaned = phone.trim().replace(/[\s\-()]/g, ""); + + if (cleaned === "0") { + throw new BadRequestException("Invalid WhatsApp phone number."); + } + + let normalized: string; + + if (cleaned.startsWith("+")) { + normalized = cleaned; + } else if (cleaned.startsWith("0")) { + normalized = `+234${cleaned.slice(1)}`; + } else { + normalized = `+${cleaned}`; + } + + if (!E164_PHONE_REGEX.test(normalized)) { + throw new BadRequestException("Invalid WhatsApp phone number."); + } + + return normalized; +} + +export function maskWhatsAppPhone(phone?: string | null): string { + if (typeof phone !== "string") { + return "****"; + } + + const trimmed = phone.trim(); + const digits = trimmed.replace(/\D/g, ""); + + if (!/^\+?\d+$/.test(trimmed) || digits.length < 8 || digits.length > 15) { + return "****"; + } + + const prefix = trimmed.startsWith("+") ? "+" : ""; + return `${prefix}${digits.slice(0, 3)}******${digits.slice(-4)}`; +} diff --git a/apps/backend/src/channels/whatsapp/wizza-location-resolver.service.spec.ts b/apps/backend/src/channels/whatsapp/wizza-location-resolver.service.spec.ts new file mode 100644 index 00000000..c375128b --- /dev/null +++ b/apps/backend/src/channels/whatsapp/wizza-location-resolver.service.spec.ts @@ -0,0 +1,70 @@ +import { WizzaLocationResolver } from "./wizza-location-resolver.service"; + +describe("WizzaLocationResolver", () => { + const prisma = { + userLocationPreference: { findUnique: jest.fn() }, + }; + let resolver: WizzaLocationResolver; + + beforeEach(() => { + jest.clearAllMocks(); + resolver = new WizzaLocationResolver(prisma as any); + }); + + it("uses a recognized explicit location before a saved preference", async () => { + const result = await resolver.resolve( + "show me sneakers in Lekki", + "user-1", + ); + + expect(result).toMatchObject({ + locationText: "Lekki", + source: "explicit_query", + }); + expect(prisma.userLocationPreference.findUnique).not.toHaveBeenCalled(); + }); + + it("uses an active linked shopper preference when the query has no location", async () => { + prisma.userLocationPreference.findUnique.mockResolvedValue({ + countryCode: "NG", + state: "Lagos", + city: "Lagos", + area: "Yaba", + isActive: true, + }); + + await expect( + resolver.resolve("show me sneakers", "user-1"), + ).resolves.toEqual({ + locationText: "Yaba", + source: "user_preference", + countryCode: "NG", + state: "Lagos", + city: "Lagos", + area: "Yaba", + }); + }); + + it("does not use a preference for an unlinked shopper or inactive preference", async () => { + await expect( + resolver.resolve("show me sneakers", null), + ).resolves.toMatchObject({ + locationText: null, + source: "none", + }); + + prisma.userLocationPreference.findUnique.mockResolvedValue({ + countryCode: "NG", + state: "Lagos", + city: "Lagos", + area: "Yaba", + isActive: false, + }); + await expect( + resolver.resolve("show me sneakers", "user-1"), + ).resolves.toMatchObject({ + locationText: null, + source: "none", + }); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/wizza-location-resolver.service.ts b/apps/backend/src/channels/whatsapp/wizza-location-resolver.service.ts new file mode 100644 index 00000000..16be3811 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/wizza-location-resolver.service.ts @@ -0,0 +1,130 @@ +import { Injectable } from "@nestjs/common"; + +import { PrismaService } from "../../prisma/prisma.service"; + +export type WizzaDiscoveryLocationSource = + | "explicit_query" + | "user_preference" + | "none"; + +export interface WizzaDiscoveryLocationContext { + locationText: string | null; + source: WizzaDiscoveryLocationSource; + countryCode: string | null; + state: string | null; + city: string | null; + area: string | null; +} + +const NO_DISCOVERY_LOCATION: WizzaDiscoveryLocationContext = { + locationText: null, + source: "none", + countryCode: null, + state: null, + city: null, + area: null, +}; + +// Conservative, broad locality recognition for the initial Nigerian discovery +// release. Unknown phrases intentionally fall back to a saved preference or no +// filter instead of guessing from shopper prose. +const EXPLICIT_LOCATION_NAMES = [ + "Abuja", + "Ajah", + "Benin City", + "Enugu", + "Ikeja", + "Ilorin", + "Ibadan", + "Kaduna", + "Kano", + "Lekki", + "Lagos", + "Maitama", + "Maryland", + "Onitsha", + "Port Harcourt", + "Surulere", + "Victoria Island", + "Wuse", + "Yaba", +] as const; + +@Injectable() +export class WizzaLocationResolver { + constructor(private readonly prisma: PrismaService) {} + + async resolve( + messageText: string | null | undefined, + userId: string | null | undefined, + ): Promise { + const explicitLocation = this.extractExplicitLocation(messageText); + if (explicitLocation) { + return { + ...NO_DISCOVERY_LOCATION, + locationText: explicitLocation, + source: "explicit_query", + }; + } + + if (!userId) { + return NO_DISCOVERY_LOCATION; + } + + const preference = await this.prisma.userLocationPreference.findUnique({ + where: { userId }, + select: { + countryCode: true, + state: true, + city: true, + area: true, + isActive: true, + }, + }); + + if (!preference?.isActive) { + return NO_DISCOVERY_LOCATION; + } + + const locationText = + this.safeLocationPart(preference.area) ?? + this.safeLocationPart(preference.city) ?? + this.safeLocationPart(preference.state); + + if (!locationText) { + return NO_DISCOVERY_LOCATION; + } + + return { + locationText, + source: "user_preference", + countryCode: preference.countryCode, + state: this.safeLocationPart(preference.state), + city: this.safeLocationPart(preference.city), + area: this.safeLocationPart(preference.area), + }; + } + + private extractExplicitLocation( + messageText: string | null | undefined, + ): string | null { + const normalized = messageText?.trim(); + if (!normalized) { + return null; + } + + return ( + EXPLICIT_LOCATION_NAMES.find((location) => { + const escaped = location.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`\\b(?:in|around|near)\\s+${escaped}\\b`, "i").test( + normalized, + ); + }) ?? null + ); + } + + private safeLocationPart(value: string | null): string | null { + const normalized = value?.trim().replace(/\s+/g, " "); + return normalized && normalized.length <= 80 ? normalized : null; + } +} diff --git a/apps/backend/src/common/config/env.validation.spec.ts b/apps/backend/src/common/config/env.validation.spec.ts new file mode 100644 index 00000000..fb8cf687 --- /dev/null +++ b/apps/backend/src/common/config/env.validation.spec.ts @@ -0,0 +1,95 @@ +import { envValidationSchema } from "./env.validation"; + +describe("environment validation", () => { + const requiredBase = { + DATABASE_URL: "postgresql://localhost/twizrr_test", + DIRECT_URL: "postgresql://localhost/twizrr_test", + ONBOARDING_OTP_SECRET: "test-otp-secret", + JWT_ACCESS_SECRET: "test-access-secret", + JWT_REFRESH_SECRET: "test-refresh-secret", + }; + + it("allows Monnify credentials to be absent while the adapter is disabled", () => { + const result = envValidationSchema.validate({ + ...requiredBase, + MONNIFY_PAYMENT_ENABLED: false, + }); + + expect(result.error).toBeUndefined(); + }); + + it("requires every Monnify credential when payment execution is enabled", () => { + const result = envValidationSchema.validate({ + ...requiredBase, + MONNIFY_PAYMENT_ENABLED: true, + }); + + expect(result.error?.message).toContain("MONNIFY_API_KEY"); + }); + + it("requires Monnify API credentials when refund execution is enabled", () => { + const result = envValidationSchema.validate({ + ...requiredBase, + MONNIFY_REFUND_ENABLED: true, + }); + + expect(result.error?.message).toContain("MONNIFY_API_KEY"); + }); + + it("allows refund execution without a checkout contract code", () => { + const result = envValidationSchema.validate({ + ...requiredBase, + MONNIFY_REFUND_ENABLED: true, + MONNIFY_API_KEY: "sandbox-api-key", + MONNIFY_SECRET_KEY: "sandbox-secret-key", + MONNIFY_BASE_URL: "https://sandbox.monnify.test", + }); + + expect(result.error).toBeUndefined(); + }); + + it("requires API credentials and a source account for Monnify payouts", () => { + const missingCredentials = envValidationSchema.validate({ + ...requiredBase, + MONNIFY_PAYOUT_ENABLED: true, + }); + expect(missingCredentials.error?.message).toContain("MONNIFY_API_KEY"); + + const missingSourceAccount = envValidationSchema.validate({ + ...requiredBase, + MONNIFY_PAYOUT_ENABLED: true, + MONNIFY_API_KEY: "sandbox-api-key", + MONNIFY_SECRET_KEY: "sandbox-secret-key", + MONNIFY_BASE_URL: "https://sandbox.monnify.test", + }); + expect(missingSourceAccount.error?.message).toContain( + "MONNIFY_DISBURSEMENT_ACCOUNT_NUMBER", + ); + }); + + it("accepts complete sandbox-only Monnify payout configuration", () => { + const result = envValidationSchema.validate({ + ...requiredBase, + PAYOUT_PROVIDER: "monnify", + MONNIFY_PAYOUT_ENABLED: true, + MONNIFY_API_KEY: "sandbox-api-key", + MONNIFY_SECRET_KEY: "sandbox-secret-key", + MONNIFY_BASE_URL: "https://sandbox.monnify.test", + MONNIFY_DISBURSEMENT_ACCOUNT_NUMBER: "1234567890", + }); + expect(result.error).toBeUndefined(); + }); + + it("accepts a complete enabled Monnify sandbox configuration", () => { + const result = envValidationSchema.validate({ + ...requiredBase, + MONNIFY_PAYMENT_ENABLED: true, + MONNIFY_API_KEY: "sandbox-api-key", + MONNIFY_SECRET_KEY: "sandbox-secret-key", + MONNIFY_CONTRACT_CODE: "sandbox-contract", + MONNIFY_BASE_URL: "https://sandbox.monnify.test", + }); + + expect(result.error).toBeUndefined(); + }); +}); diff --git a/apps/backend/src/common/config/env.validation.ts b/apps/backend/src/common/config/env.validation.ts new file mode 100644 index 00000000..70029eb7 --- /dev/null +++ b/apps/backend/src/common/config/env.validation.ts @@ -0,0 +1,214 @@ +import * as Joi from "joi"; + +const optionalString = Joi.string().empty("").optional(); +const optionalUri = Joi.string().empty("").uri().optional(); +const optionalNumericString = Joi.string() + .empty("") + .pattern(/^\d+(\.\d+)?$/) + .optional(); +const optionalPositiveInteger = Joi.number() + .integer() + .positive() + .empty("") + .optional(); +const optionalPositiveNumber = Joi.number().positive().empty("").optional(); +const optionalSecret = Joi.string().empty("").min(1).optional(); +const redisUrl = Joi.string() + .pattern(/^rediss?:\/\//i) + .empty("") + .optional() + .messages({ + "string.pattern.base": "REDIS_URL must start with redis:// or rediss://", + }); + +export const envValidationSchema = Joi.object({ + NODE_ENV: Joi.string() + .valid("development", "staging", "production", "test", "provision") + .default("development"), + PORT: Joi.number().default(4000), + APP_URL: optionalUri, + WEB_URL: optionalUri, + CORS_ORIGINS: optionalString, + TRUST_PROXY_HEADERS: Joi.boolean() + .truthy("true") + .falsy("false") + .default(false), + TRUST_PROXY_HEADER_PROVIDER: Joi.string() + .valid("standard", "cloudflare") + .default("standard"), + IP_GEOLOCATION_PROVIDER: Joi.string() + .valid("disabled", "trusted_header") + .default("disabled"), + + DATABASE_URL: Joi.string().required(), + DIRECT_URL: Joi.string().required(), + REDIS_URL: redisUrl, + + SOCKET_IO_PATH: Joi.string().empty("").pattern(/^\//).default("/socket.io"), + SOCKET_IO_REDIS_ADAPTER_ENABLED: Joi.boolean() + .truthy("true") + .falsy("false") + .default(false), + OUTBOX_RELAY_ENABLED: Joi.boolean() + .truthy("true") + .falsy("false") + .default(true), + OUTBOX_POLL_INTERVAL_MS: optionalPositiveInteger.default(1000), + OUTBOX_BATCH_SIZE: optionalPositiveInteger.default(50), + OUTBOX_PROCESSING_CONCURRENCY: optionalPositiveInteger.default(5), + OUTBOX_LOCK_TTL_SECONDS: optionalPositiveInteger.default(60), + OUTBOX_MAX_ATTEMPTS: optionalPositiveInteger.default(10), + OUTBOX_BASE_RETRY_DELAY_MS: optionalPositiveInteger.default(2000), + + JWT_SECRET: optionalSecret, + JWT_ACCESS_SECRET: optionalSecret, + JWT_REFRESH_SECRET: optionalSecret, + JWT_EXPIRES_IN: optionalString, + JWT_REFRESH_EXPIRES_IN: optionalString, + JWT_ACCESS_TTL: optionalString, + JWT_REFRESH_TTL: optionalString, + AUTH_COOKIE_DOMAIN: Joi.string().empty("").hostname().optional(), + DEVICE_ID_HASH_SECRET: optionalSecret, + ONBOARDING_OTP_SECRET: Joi.string().empty("").required(), + + // Money provider selection. Reserved values are validated at selection time, + // not here, so unsupported values fail fast where the provider is resolved. + PAYMENT_PROVIDER: optionalString, + // Deprecated compatibility input. Payouts derive from the immutable + // provider on each successful payment and never read this value. + PAYOUT_PROVIDER: Joi.string().valid("paystack", "monnify").optional(), + SUBSCRIPTION_BILLING_PROVIDER: optionalString, + + MONNIFY_PAYMENT_ENABLED: Joi.boolean() + .truthy("true") + .falsy("false") + .default(false), + MONNIFY_REFUND_ENABLED: Joi.boolean() + .truthy("true") + .falsy("false") + .default(false), + MONNIFY_PAYOUT_ENABLED: Joi.boolean() + .truthy("true") + .falsy("false") + .default(false), + PAYMENT_AMOUNT_EXCEPTION_REFUND_EXECUTION_ENABLED: Joi.boolean() + .truthy("true") + .falsy("false") + .default(false), + PAYMENT_REMAINING_BALANCE_COLLECTION_ENABLED: Joi.boolean() + .truthy("true") + .falsy("false") + .default(false), + MONNIFY_API_KEY: optionalString + .when("MONNIFY_PAYMENT_ENABLED", { + is: true, + then: Joi.required(), + }) + .when("MONNIFY_REFUND_ENABLED", { is: true, then: Joi.required() }) + .when("MONNIFY_PAYOUT_ENABLED", { is: true, then: Joi.required() }), + MONNIFY_SECRET_KEY: optionalSecret + .when("MONNIFY_PAYMENT_ENABLED", { + is: true, + then: Joi.required(), + }) + .when("MONNIFY_REFUND_ENABLED", { is: true, then: Joi.required() }) + .when("MONNIFY_PAYOUT_ENABLED", { is: true, then: Joi.required() }), + MONNIFY_CONTRACT_CODE: optionalString.when("MONNIFY_PAYMENT_ENABLED", { + is: true, + then: Joi.required(), + }), + MONNIFY_DISBURSEMENT_ACCOUNT_NUMBER: optionalString.when( + "MONNIFY_PAYOUT_ENABLED", + { is: true, then: Joi.required() }, + ), + MONNIFY_BASE_URL: optionalUri + .when("MONNIFY_PAYMENT_ENABLED", { + is: true, + then: Joi.required(), + }) + .when("MONNIFY_REFUND_ENABLED", { is: true, then: Joi.required() }) + .when("MONNIFY_PAYOUT_ENABLED", { is: true, then: Joi.required() }), + + PAYSTACK_SECRET_KEY: optionalString, + PAYSTACK_PUBLIC_KEY: optionalString, + PAYSTACK_WEBHOOK_SECRET: optionalString, + + // Nomba (StorePass subscription billing). Only required when the Nomba + // adapter is actually invoked, so all values are optional at startup. + NOMBA_BASE_URL: optionalUri, + NOMBA_ACCOUNT_ID: optionalString, + NOMBA_SUB_ACCOUNT_ID: optionalString, + NOMBA_CLIENT_ID: optionalString, + NOMBA_CLIENT_SECRET: optionalSecret, + NOMBA_WEBHOOK_SECRET: optionalSecret, + + CLOUDINARY_CLOUD_NAME: optionalString, + CLOUDINARY_API_KEY: optionalString, + CLOUDINARY_API_SECRET: optionalString, + + RESEND_API_KEY: optionalString, + EMAIL_PROVIDER: Joi.string().valid("resend").default("resend"), + EMAIL_FROM: Joi.string().empty("").email().optional(), + + AT_USERNAME: optionalString, + AT_API_KEY: optionalString, + AT_SENDER_ID: optionalString, + AT_DELIVERY_REPORT_SECRET: optionalString, + + GOOGLE_CLOUD_API_KEY: optionalString, + GOOGLE_CLOUD_PROJECT: optionalString, + GOOGLE_APPLICATION_CREDENTIALS: optionalString, + GEMINI_API_KEY: optionalString, + GEMINI_MODEL: optionalString, + VERTEX_AI_LOCATION: optionalString, + VERTEX_AI_MODEL: optionalString, + + // Hybrid search ranking weights — must sum to 1.0 + // (sum validated in src/config/search.config.ts at startup) + SEARCH_WEIGHT_VECTOR: Joi.number().min(0).empty("").optional(), + SEARCH_WEIGHT_TEXT: Joi.number().min(0).empty("").optional(), + SEARCH_WEIGHT_PERFORMANCE: Joi.number().min(0).empty("").optional(), + SEARCH_WEIGHT_TIER: Joi.number().min(0).empty("").optional(), + + WHATSAPP_BOT_NUMBER: optionalString, + WHATSAPP_PHONE_NUMBER_ID: optionalString, + WHATSAPP_TOKEN: optionalString, + WHATSAPP_VERIFY_TOKEN: optionalString, + WHATSAPP_APP_SECRET: optionalString, + WHATSAPP_CONSENT_MESSAGE: optionalString, + WHATSAPP_SYSTEM_PROMPT: optionalString, + + ADMIN_BOOTSTRAP_EMAIL: Joi.string().empty("").email().optional(), + ADMIN_BOOTSTRAP_PASSWORD: optionalString, + SHIPPING_PROVIDER: Joi.string().valid("shipbubble").default("shipbubble"), + SHIPBUBBLE_API_KEY: optionalString, + SHIPBUBBLE_BASE_URL: optionalUri, + SHIPBUBBLE_WEBHOOK_SECRET: optionalString, + SHIPBUBBLE_DEFAULT_CATEGORY_ID: optionalPositiveInteger, + DEFAULT_PACKAGE_WEIGHT_KG: optionalPositiveNumber, + PREMBLY_API_KEY: optionalString, + IDENTITY_VERIFICATION_PROVIDER: Joi.string() + .valid("prembly") + .default("prembly"), + PREMBLY_APP_ID: optionalString, + PREMBLY_BASE_URL: optionalUri, + GOOGLE_PLACES_API_KEY: optionalString, + GOOGLE_PLACES_BASE_URL: optionalUri, + GOOGLE_CLIENT_ID: optionalString, + GOOGLE_CLIENT_SECRET: optionalSecret, + GOOGLE_OAUTH_REDIRECT_URL: optionalUri, + GOOGLE_OAUTH_STATE_TTL_SECONDS: optionalPositiveInteger, + GOOGLE_PENDING_ONBOARDING_TTL_SECONDS: optionalPositiveInteger, + + PLATFORM_FEE_UNVERIFIED: optionalNumericString, + PLATFORM_FEE_TIER_1: optionalNumericString, + PLATFORM_FEE_TIER_2: optionalNumericString, + PLATFORM_FEE_TIER_3: optionalNumericString, + PLATFORM_FEE_CAP_ORDER_KOBO: optionalNumericString, + AUTO_CONFIRM_HOURS: optionalString, + DISPUTE_WINDOW_HOURS: optionalString, + OTP_TTL_MINUTES: optionalString, + SESSION_TTL_SECONDS: optionalString, +}) + .or("JWT_SECRET", "JWT_ACCESS_SECRET") + .or("JWT_SECRET", "JWT_REFRESH_SECRET"); diff --git a/apps/backend/src/common/decorators/current-merchant.decorator.ts b/apps/backend/src/common/decorators/current-merchant.decorator.ts index e5ebf739..3f6ad55b 100644 --- a/apps/backend/src/common/decorators/current-merchant.decorator.ts +++ b/apps/backend/src/common/decorators/current-merchant.decorator.ts @@ -3,6 +3,6 @@ import { createParamDecorator, ExecutionContext } from "@nestjs/common"; export const CurrentMerchant = createParamDecorator( (data: unknown, ctx: ExecutionContext): string | undefined => { const request = ctx.switchToHttp().getRequest(); - return request.user?.merchantId; + return request.user?.storeId; }, ); diff --git a/apps/backend/src/common/decorators/current-security-context.decorator.ts b/apps/backend/src/common/decorators/current-security-context.decorator.ts new file mode 100644 index 00000000..9e4cb734 --- /dev/null +++ b/apps/backend/src/common/decorators/current-security-context.decorator.ts @@ -0,0 +1,20 @@ +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; +import type { Request } from "express"; + +import type { RequestSecurityContext } from "../security/request-security-context"; + +type RequestWithSecurityContext = Request & { + securityContext?: RequestSecurityContext; +}; + +export function getSecurityContextFromExecutionContext( + ctx: ExecutionContext, +): RequestSecurityContext | undefined { + return ctx.switchToHttp().getRequest() + .securityContext; +} + +export const CurrentSecurityContext = createParamDecorator( + (_data: unknown, ctx: ExecutionContext) => + getSecurityContextFromExecutionContext(ctx), +); diff --git a/apps/backend/src/common/decorators/current-store.decorator.ts b/apps/backend/src/common/decorators/current-store.decorator.ts new file mode 100644 index 00000000..7b60bd51 --- /dev/null +++ b/apps/backend/src/common/decorators/current-store.decorator.ts @@ -0,0 +1 @@ +export { CurrentMerchant as CurrentStore } from "./current-merchant.decorator"; diff --git a/apps/backend/src/common/decorators/roles.decorator.ts b/apps/backend/src/common/decorators/roles.decorator.ts index 2a9bd335..5b1c3a2f 100644 --- a/apps/backend/src/common/decorators/roles.decorator.ts +++ b/apps/backend/src/common/decorators/roles.decorator.ts @@ -1,5 +1,5 @@ import { SetMetadata } from "@nestjs/common"; -import { UserRole } from "@swifta/shared"; +import { UserRole } from "@twizrr/shared"; export const ROLES_KEY = "roles"; export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); diff --git a/apps/backend/src/common/filters/global-exception.filter.ts b/apps/backend/src/common/filters/global-exception.filter.ts index 1c6f70d5..e36a61f4 100644 --- a/apps/backend/src/common/filters/global-exception.filter.ts +++ b/apps/backend/src/common/filters/global-exception.filter.ts @@ -1,54 +1,71 @@ import { - ExceptionFilter, - Catch, ArgumentsHost, + Catch, + ExceptionFilter, HttpException, HttpStatus, Logger, } from "@nestjs/common"; import { Response } from "express"; -import { ApiError } from "@swifta/shared"; -import * as fs from "fs"; -import * as path from "path"; -// Prisma error types — imported by name to avoid hard dependency on @prisma/client at filter level const PRISMA_UNIQUE_CONSTRAINT = "P2002"; const PRISMA_NOT_FOUND = "P2025"; +const PRISMA_FOREIGN_KEY_CONSTRAINT = "P2003"; + +interface ErrorEnvelope { + success: false; + message: string; + code?: string; +} + +interface ExceptionResponseBody { + message?: string | string[]; + code?: string; +} + +interface PrismaKnownError { + code: string; + clientVersion: string; + message?: string; + meta?: { + target?: string[]; + cause?: string; + field_name?: string; + }; +} @Catch() export class GlobalExceptionFilter implements ExceptionFilter { private readonly logger = new Logger(GlobalExceptionFilter.name); - catch(exception: unknown, host: ArgumentsHost) { + catch(exception: unknown, host: ArgumentsHost): void { const ctx = host.switchToHttp(); const response = ctx.getResponse(); let status: number; - let errorMessage: string; - let code: string; + let errorMessage: string | string[]; + let code: string | undefined; if (exception instanceof HttpException) { - // NestJS HTTP exceptions (BadRequest, Unauthorized, etc.) status = exception.getStatus(); const exceptionResponse = exception.getResponse(); - errorMessage = - typeof exceptionResponse === "object" - ? (exceptionResponse as any).message || exception.message - : exceptionResponse; - code = status.toString(); + + if (this.isExceptionResponseBody(exceptionResponse)) { + errorMessage = exceptionResponse.message || exception.message; + code = exceptionResponse.code; + } else { + errorMessage = String(exceptionResponse); + } } else if (this.isPrismaKnownError(exception)) { - // Prisma known request errors (unique constraint, not found, etc.) const prismaResult = this.handlePrismaError(exception); status = prismaResult.status; errorMessage = prismaResult.message; code = prismaResult.code; } else if (this.isPrismaValidationError(exception)) { - // Prisma validation errors (bad query shape) status = HttpStatus.BAD_REQUEST; errorMessage = "Invalid request data"; code = "VALIDATION_ERROR"; } else { - // Unknown errors — log full details, return generic message this.logger.error( { err: @@ -63,21 +80,41 @@ export class GlobalExceptionFilter implements ExceptionFilter { code = "INTERNAL_ERROR"; } - // Flatten array messages (from ValidationPipe) if (Array.isArray(errorMessage)) { errorMessage = errorMessage.join("; "); } - const errorResponse: ApiError = { - statusCode: status, + const errorResponse: ErrorEnvelope = { + success: false, + message: errorMessage, code, - error: errorMessage, }; response.status(status).json(errorResponse); } - private isPrismaKnownError(exception: unknown): boolean { + private isExceptionResponseBody( + value: unknown, + ): value is ExceptionResponseBody { + if (typeof value !== "object" || value === null) { + return false; + } + + const response = value as Record; + const hasValidMessage = + "message" in response && + (typeof response.message === "string" || + (Array.isArray(response.message) && + response.message.every((item) => typeof item === "string"))); + const hasValidCode = + "code" in response && typeof response.code === "string"; + + return hasValidMessage || hasValidCode; + } + + private isPrismaKnownError( + exception: unknown, + ): exception is PrismaKnownError { return ( typeof exception === "object" && exception !== null && @@ -94,7 +131,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { ); } - private handlePrismaError(exception: any): { + private handlePrismaError(exception: PrismaKnownError): { status: number; message: string; code: string; @@ -114,27 +151,18 @@ export class GlobalExceptionFilter implements ExceptionFilter { message: exception.meta?.cause || "Record not found", code: "NOT_FOUND", }; - default: { - // Log the full error to a debug file for capture - try { - const logPath = path.join(process.cwd(), "error_debug.log"); - const logContent = - `\n[${new Date().toISOString()}] PRISMA ERROR: ${exception.code}\n` + - `Message: ${exception.message}\n` + - `Meta: ${JSON.stringify(exception.meta, null, 2)}\n` + - `Stack: ${exception.stack}\n` + - `------------------------------------------\n`; - fs.appendFileSync(logPath, logContent); - } catch (e) { - this.logger.error("Failed to write to error_debug.log", e); - } - + case PRISMA_FOREIGN_KEY_CONSTRAINT: + return { + status: HttpStatus.BAD_REQUEST, + message: "Invalid related record reference", + code: "FOREIGN_KEY_CONSTRAINT", + }; + default: return { status: HttpStatus.BAD_REQUEST, message: "Database operation failed", code: `PRISMA_${exception.code}`, }; - } } } } diff --git a/apps/backend/src/common/guards/merchant-verified.guard.ts b/apps/backend/src/common/guards/merchant-verified.guard.ts index 9e1357ac..9ef20fe9 100644 --- a/apps/backend/src/common/guards/merchant-verified.guard.ts +++ b/apps/backend/src/common/guards/merchant-verified.guard.ts @@ -6,7 +6,7 @@ import { } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import { PrismaService } from "../../prisma/prisma.service"; -import { VerificationTier } from "@swifta/shared"; +import { VerificationTier } from "@twizrr/shared"; @Injectable() export class MerchantVerifiedGuard implements CanActivate { @@ -17,18 +17,18 @@ export class MerchantVerifiedGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); - const merchantId = request.merchantId; + const storeId = request.storeId; - if (!merchantId) { + if (!storeId) { return true; // Not a merchant context or handled by roles guard } - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, + const merchant = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, }); - if (!merchant || merchant.verificationTier !== VerificationTier.TIER_3) { - throw new ForbiddenException("Merchant account not verified"); + if (!merchant || merchant.verificationTier !== VerificationTier.TIER_2) { + throw new ForbiddenException("Store account not verified"); } return true; diff --git a/apps/backend/src/common/guards/roles.guard.ts b/apps/backend/src/common/guards/roles.guard.ts index 126df014..613517a2 100644 --- a/apps/backend/src/common/guards/roles.guard.ts +++ b/apps/backend/src/common/guards/roles.guard.ts @@ -1,6 +1,6 @@ import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; -import { UserRole } from "@swifta/shared"; +import { UserRole } from "@twizrr/shared"; import { ROLES_KEY } from "../decorators/roles.decorator"; @Injectable() diff --git a/apps/backend/src/common/guards/store-verified.guard.ts b/apps/backend/src/common/guards/store-verified.guard.ts new file mode 100644 index 00000000..96c9db95 --- /dev/null +++ b/apps/backend/src/common/guards/store-verified.guard.ts @@ -0,0 +1,3 @@ +import { MerchantVerifiedGuard } from "./merchant-verified.guard"; + +export class StoreVerifiedGuard extends MerchantVerifiedGuard {} diff --git a/apps/backend/src/common/interceptors/response-transform.interceptor.ts b/apps/backend/src/common/interceptors/response-transform.interceptor.ts index 4198414c..8afcd308 100644 --- a/apps/backend/src/common/interceptors/response-transform.interceptor.ts +++ b/apps/backend/src/common/interceptors/response-transform.interceptor.ts @@ -1,12 +1,12 @@ import { + CallHandler, + ExecutionContext, Injectable, NestInterceptor, - ExecutionContext, - CallHandler, } from "@nestjs/common"; import { Observable } from "rxjs"; import { map } from "rxjs/operators"; -import { ApiResponse } from "@swifta/shared"; +import { ApiResponse } from "@twizrr/shared"; @Injectable() export class ResponseTransformInterceptor implements NestInterceptor< @@ -14,12 +14,11 @@ export class ResponseTransformInterceptor implements NestInterceptor< ApiResponse > { intercept( - context: ExecutionContext, + _context: ExecutionContext, next: CallHandler, ): Observable> { return next.handle().pipe( map((data) => { - // Prevent double wrapping if the data already contains the 'success' and 'data' envelope if ( data && typeof data === "object" && @@ -31,7 +30,7 @@ export class ResponseTransformInterceptor implements NestInterceptor< return { success: true, - data: data, + data, }; }), ); diff --git a/apps/backend/src/common/middleware/merchant-context.middleware.ts b/apps/backend/src/common/middleware/merchant-context.middleware.ts index b18b861d..fb57f269 100644 --- a/apps/backend/src/common/middleware/merchant-context.middleware.ts +++ b/apps/backend/src/common/middleware/merchant-context.middleware.ts @@ -1,16 +1,16 @@ import { Injectable, NestMiddleware } from "@nestjs/common"; import { Request, Response, NextFunction } from "express"; -import { UserRole } from "@swifta/shared"; +import { UserRole } from "@twizrr/shared"; @Injectable() export class MerchantContextMiddleware implements NestMiddleware { use(req: Request, res: Response, next: NextFunction) { const user = (req as any).user; - // Extract merchantId directly from JWT payload — no DB query needed. - // The JWT already contains merchantId if the user is a merchant. - if (user && user.role === UserRole.MERCHANT && user.merchantId) { - (req as any).merchantId = user.merchantId; + // Extract storeId directly from JWT payload — no DB query needed. + // The JWT already contains storeId if the user is a merchant. + if (user && user.role === UserRole.USER && user.storeId) { + (req as any).storeId = user.storeId; } next(); diff --git a/apps/backend/src/common/middleware/store-context.middleware.ts b/apps/backend/src/common/middleware/store-context.middleware.ts new file mode 100644 index 00000000..53300b79 --- /dev/null +++ b/apps/backend/src/common/middleware/store-context.middleware.ts @@ -0,0 +1,3 @@ +import { MerchantContextMiddleware } from "./merchant-context.middleware"; + +export class StoreContextMiddleware extends MerchantContextMiddleware {} diff --git a/apps/backend/src/common/pipes/validation.pipe.ts b/apps/backend/src/common/pipes/validation.pipe.ts index 1b82d8de..8baaefc5 100644 --- a/apps/backend/src/common/pipes/validation.pipe.ts +++ b/apps/backend/src/common/pipes/validation.pipe.ts @@ -9,6 +9,7 @@ export class AppValidationPipe extends ValidationPipe { super({ whitelist: true, transform: true, + forbidNonWhitelisted: true, exceptionFactory: (errors: ValidationError[]) => { const messages = errors.map((error) => Object.values(error.constraints || {}).join(", "), diff --git a/apps/backend/src/common/security/access-token-user.service.spec.ts b/apps/backend/src/common/security/access-token-user.service.spec.ts new file mode 100644 index 00000000..2c5c8d4f --- /dev/null +++ b/apps/backend/src/common/security/access-token-user.service.spec.ts @@ -0,0 +1,48 @@ +import { UserRole } from "@prisma/client"; + +import { PrismaService } from "../../core/prisma/prisma.service"; +import { AccessTokenUserService } from "./access-token-user.service"; + +describe("AccessTokenUserService", () => { + it("loads only active, non-deleted users for access tokens", async () => { + const prisma = { + user: { + findFirst: jest.fn().mockResolvedValue({ + id: "user-1", + email: "user@example.com", + role: UserRole.USER, + username: "user", + displayName: "User", + emailVerified: true, + phoneVerified: false, + storeProfile: { id: "store-1" }, + }), + }, + }; + const service = new AccessTokenUserService( + prisma as unknown as PrismaService, + ); + + await expect( + service.findActiveUser({ + sub: "user-1", + email: "user@example.com", + role: UserRole.USER, + jti: "jti-1", + }), + ).resolves.toMatchObject({ + id: "user-1", + sub: "user-1", + storeId: "store-1", + }); + + expect(prisma.user.findFirst).toHaveBeenCalledWith({ + where: { + id: "user-1", + isActive: true, + deletedAt: null, + }, + select: expect.any(Object), + }); + }); +}); diff --git a/apps/backend/src/common/security/access-token-user.service.ts b/apps/backend/src/common/security/access-token-user.service.ts new file mode 100644 index 00000000..f35c7676 --- /dev/null +++ b/apps/backend/src/common/security/access-token-user.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from "@nestjs/common"; + +import { PrismaService } from "../../core/prisma/prisma.service"; +import type { + AccessTokenPayload, + AuthenticatedRequestUser, +} from "../../domains/users/auth/auth.types"; + +const ACTIVE_ACCESS_TOKEN_USER_SELECT = { + id: true, + email: true, + role: true, + username: true, + displayName: true, + emailVerified: true, + phoneVerified: true, + storeProfile: { + select: { id: true }, + }, +} as const; + +@Injectable() +export class AccessTokenUserService { + constructor(private readonly prisma: PrismaService) {} + + async findActiveUser( + payload: AccessTokenPayload, + ): Promise { + if (!payload || typeof payload.sub !== "string" || !payload.sub.trim()) { + return null; + } + + const user = await this.prisma.user.findFirst({ + where: { + id: payload.sub, + isActive: true, + deletedAt: null, + }, + select: ACTIVE_ACCESS_TOKEN_USER_SELECT, + }); + + if (!user) { + return null; + } + + return { + id: user.id, + sub: user.id, + email: user.email, + role: user.role, + storeId: user.storeProfile?.id, + username: user.username, + displayName: user.displayName, + isEmailVerified: user.emailVerified, + isPhoneVerified: user.phoneVerified, + }; + } +} diff --git a/apps/backend/src/common/security/device-identity.module.ts b/apps/backend/src/common/security/device-identity.module.ts new file mode 100644 index 00000000..705d209a --- /dev/null +++ b/apps/backend/src/common/security/device-identity.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; + +import { DeviceIdentityService } from "./device-identity.service"; + +@Module({ + providers: [DeviceIdentityService], + exports: [DeviceIdentityService], +}) +export class DeviceIdentityModule {} diff --git a/apps/backend/src/common/security/device-identity.service.spec.ts b/apps/backend/src/common/security/device-identity.service.spec.ts new file mode 100644 index 00000000..b4883f6a --- /dev/null +++ b/apps/backend/src/common/security/device-identity.service.spec.ts @@ -0,0 +1,105 @@ +import { DeviceIdentityService } from "./device-identity.service"; + +describe("DeviceIdentityService", () => { + const prisma = { + userDevice: { upsert: jest.fn() }, + }; + const config = { get: jest.fn() }; + const context = { + requestId: "req-device-1", + ipAddress: "203.0.113.10", + ipSource: "remote-address" as const, + userAgent: "Mozilla/5.0 test browser", + origin: null, + referer: null, + method: "POST", + path: "/auth/login", + deviceType: "desktop" as const, + geo: null, + deviceIdentity: { + deviceIdHash: "device-hash", + source: "header" as const, + }, + isTrustedProxyHeaderUsed: false, + createdAt: new Date(), + }; + + let service: DeviceIdentityService; + + beforeEach(() => { + jest.clearAllMocks(); + config.get.mockReturnValue("device-pepper"); + service = new DeviceIdentityService(config as never, prisma as never); + }); + + it("accepts UUIDs and hashes them deterministically without retaining the raw value", () => { + const first = service.extractFromHeader( + "8ad05b49-3e63-4f1d-bc4e-c26d1d1a39a4", + ); + const second = service.extractFromHeader( + "8ad05b49-3e63-4f1d-bc4e-c26d1d1a39a4", + ); + const different = service.extractFromHeader( + "225b8430-f282-4422-bcf0-018d8d3b72cb", + ); + + expect(first).toEqual({ + deviceIdHash: expect.any(String), + source: "header", + }); + expect(first.deviceIdHash).toBe(second.deviceIdHash); + expect(first.deviceIdHash).not.toBe(different.deviceIdHash); + expect(JSON.stringify(first)).not.toContain("8ad05b49"); + }); + + it("ignores invalid and missing values safely", () => { + expect(service.extractFromHeader("not-a-device")).toEqual({ + deviceIdHash: null, + source: "invalid", + }); + expect(service.extractFromHeader(undefined)).toEqual({ + deviceIdHash: null, + source: "missing", + }); + }); + + it("upserts a single device record and never sends a raw device ID to Prisma", async () => { + prisma.userDevice.upsert.mockResolvedValue({ + id: "device-record-1", + deviceType: "desktop", + lastSeenAt: new Date("2026-07-16T12:00:00.000Z"), + }); + + const metadata = await service.recordSuccessfulLogin("user-1", context); + + expect(prisma.userDevice.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId_deviceIdHash: { + userId: "user-1", + deviceIdHash: "device-hash", + }, + }, + }), + ); + expect(JSON.stringify(prisma.userDevice.upsert.mock.calls)).not.toContain( + "8ad05b49", + ); + expect(metadata).toEqual({ + deviceHashPresent: true, + deviceRecordId: "device-record-1", + deviceType: "desktop", + lastSeenAt: "2026-07-16T12:00:00.000Z", + }); + }); + + it("does not write a record when no valid device hash is available", async () => { + const metadata = await service.recordSuccessfulLogin("user-1", { + ...context, + deviceIdentity: { deviceIdHash: null, source: "missing" }, + }); + + expect(prisma.userDevice.upsert).not.toHaveBeenCalled(); + expect(metadata.deviceHashPresent).toBe(false); + }); +}); diff --git a/apps/backend/src/common/security/device-identity.service.ts b/apps/backend/src/common/security/device-identity.service.ts new file mode 100644 index 00000000..b73fcb48 --- /dev/null +++ b/apps/backend/src/common/security/device-identity.service.ts @@ -0,0 +1,138 @@ +import { createHmac } from "crypto"; + +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import { PrismaService } from "../../core/prisma/prisma.service"; +import type { RequestSecurityContext } from "./request-security-context"; + +export const TWIZRR_DEVICE_ID_HEADER = "x-twizrr-device-id"; + +export type DeviceIdentitySource = + | "header" + | "missing" + | "invalid" + | "unavailable"; + +export type DeviceIdentityContext = { + deviceIdHash: string | null; + source: DeviceIdentitySource; +}; + +export type DeviceIdentityAuditMetadata = { + deviceHashPresent: boolean; + deviceRecordId: string | null; + deviceType: string | null; + lastSeenAt: string | null; +}; + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const USER_AGENT_SUMMARY_MAX_LENGTH = 120; + +@Injectable() +export class DeviceIdentityService { + private readonly logger = new Logger(DeviceIdentityService.name); + + constructor( + private readonly config: ConfigService, + private readonly prisma: PrismaService, + ) {} + + extractFromHeader(value: unknown): DeviceIdentityContext { + const rawDeviceId = this.firstHeaderValue(value); + if (!rawDeviceId) { + return { deviceIdHash: null, source: "missing" }; + } + if (!UUID_PATTERN.test(rawDeviceId)) { + return { deviceIdHash: null, source: "invalid" }; + } + + const secret = this.config.get("DEVICE_ID_HASH_SECRET"); + if (!secret) { + return { deviceIdHash: null, source: "unavailable" }; + } + + return { + deviceIdHash: createHmac("sha256", secret) + .update(rawDeviceId.toLowerCase()) + .digest("hex"), + source: "header", + }; + } + + async recordSuccessfulLogin( + userId: string, + securityContext?: RequestSecurityContext, + ): Promise { + const identity = securityContext?.deviceIdentity; + const baseMetadata: DeviceIdentityAuditMetadata = { + deviceHashPresent: identity?.deviceIdHash !== null, + deviceRecordId: null, + deviceType: securityContext?.deviceType ?? null, + lastSeenAt: null, + }; + if (!identity?.deviceIdHash) { + return baseMetadata; + } + + try { + const userAgent = this.userAgentSummary(securityContext?.userAgent); + const now = new Date(); + const record = await this.prisma.userDevice.upsert({ + where: { + userId_deviceIdHash: { + userId, + deviceIdHash: identity.deviceIdHash, + }, + }, + create: { + userId, + deviceIdHash: identity.deviceIdHash, + firstSeenIp: securityContext?.ipAddress ?? null, + lastSeenIp: securityContext?.ipAddress ?? null, + firstUserAgent: userAgent, + lastUserAgent: userAgent, + deviceType: securityContext?.deviceType ?? null, + firstRequestId: securityContext?.requestId ?? null, + lastRequestId: securityContext?.requestId ?? null, + firstSeenAt: now, + lastSeenAt: now, + }, + update: { + lastSeenIp: securityContext?.ipAddress ?? null, + lastUserAgent: userAgent, + deviceType: securityContext?.deviceType ?? null, + lastRequestId: securityContext?.requestId ?? null, + lastSeenAt: now, + }, + select: { id: true, deviceType: true, lastSeenAt: true }, + }); + return { + deviceHashPresent: true, + deviceRecordId: record.id, + deviceType: record.deviceType, + lastSeenAt: record.lastSeenAt.toISOString(), + }; + } catch (error) { + this.logger.warn( + `Device identity write failed: requestId=${securityContext?.requestId ?? "none"} error=${this.errorName(error)}`, + ); + return baseMetadata; + } + } + + private firstHeaderValue(value: unknown): string | null { + const first = Array.isArray(value) ? value[0] : value; + return typeof first === "string" ? first.trim() : null; + } + + private userAgentSummary(value: string | null | undefined): string | null { + if (!value) return null; + return value.slice(0, USER_AGENT_SUMMARY_MAX_LENGTH); + } + + private errorName(error: unknown): string { + return error instanceof Error ? error.name : "UnknownError"; + } +} diff --git a/apps/backend/src/common/security/ip-geolocation.service.spec.ts b/apps/backend/src/common/security/ip-geolocation.service.spec.ts new file mode 100644 index 00000000..ff087945 --- /dev/null +++ b/apps/backend/src/common/security/ip-geolocation.service.spec.ts @@ -0,0 +1,78 @@ +import type { Request } from "express"; + +import { IpGeolocationService } from "./ip-geolocation.service"; + +function requestWithHeaders(headers: Record): Request { + return { headers } as Request; +} + +describe("IpGeolocationService", () => { + const service = new IpGeolocationService(); + + it("is disabled by default even when a country header is present", () => { + expect( + service.resolve({ + request: requestWithHeaders({ "cf-ipcountry": "NG" }), + ipAddress: "203.0.113.10", + trustProxyHeaders: true, + trustedProxyHeaderProvider: "cloudflare", + provider: "disabled", + }), + ).toMatchObject({ countryCode: null, source: "disabled" }); + }); + + it("returns safely when the request does not have an IP address", () => { + expect( + service.resolve({ + request: requestWithHeaders({ "cf-ipcountry": "NG" }), + ipAddress: null, + trustProxyHeaders: true, + trustedProxyHeaderProvider: "cloudflare", + provider: "trusted_header", + }), + ).toMatchObject({ countryCode: null, source: "disabled" }); + }); + + it("does not assign a location to private or local IP addresses", () => { + expect( + service.resolve({ + request: requestWithHeaders({ "cf-ipcountry": "NG" }), + ipAddress: "10.0.0.10", + trustProxyHeaders: true, + trustedProxyHeaderProvider: "cloudflare", + provider: "trusted_header", + }), + ).toMatchObject({ countryCode: null, source: "unknown" }); + }); + + it("ignores client country headers unless trusted Cloudflare mode is enabled", () => { + expect( + service.resolve({ + request: requestWithHeaders({ "cf-ipcountry": "NG" }), + ipAddress: "203.0.113.10", + trustProxyHeaders: false, + trustedProxyHeaderProvider: "cloudflare", + provider: "trusted_header", + }), + ).toMatchObject({ countryCode: null, source: "disabled" }); + }); + + it("uses a valid Cloudflare country header only in configured trusted mode", () => { + expect( + service.resolve({ + request: requestWithHeaders({ "cf-ipcountry": "ng" }), + ipAddress: "203.0.113.10", + trustProxyHeaders: true, + trustedProxyHeaderProvider: "cloudflare", + provider: "trusted_header", + }), + ).toEqual({ + countryCode: "NG", + countryName: null, + region: null, + city: null, + source: "trusted_header", + confidence: "medium", + }); + }); +}); diff --git a/apps/backend/src/common/security/ip-geolocation.service.ts b/apps/backend/src/common/security/ip-geolocation.service.ts new file mode 100644 index 00000000..255a6af9 --- /dev/null +++ b/apps/backend/src/common/security/ip-geolocation.service.ts @@ -0,0 +1,83 @@ +import type { Request } from "express"; + +export type IpGeolocationResult = { + countryCode: string | null; + countryName: string | null; + region: string | null; + city: string | null; + source: "disabled" | "trusted_header" | "unknown"; + confidence: "low" | "medium" | "high"; +}; + +const DISABLED_GEOLOCATION: IpGeolocationResult = { + countryCode: null, + countryName: null, + region: null, + city: null, + source: "disabled", + confidence: "low", +}; + +function isPrivateOrLocalIp(ipAddress: string): boolean { + const normalized = ipAddress.toLowerCase(); + + if ( + normalized === "::1" || + normalized.startsWith("fc") || + normalized.startsWith("fd") || + normalized.startsWith("fe80:") + ) { + return true; + } + + const octets = normalized.split(".").map(Number); + if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet))) { + return false; + } + + const [first, second] = octets; + return ( + first === 10 || + first === 127 || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) || + (first === 169 && second === 254) + ); +} + +export class IpGeolocationService { + resolve(input: { + request: Request; + ipAddress: string | null; + trustProxyHeaders: boolean; + trustedProxyHeaderProvider?: string; + provider: string; + }): IpGeolocationResult { + if ( + !input.ipAddress || + input.provider !== "trusted_header" || + !input.trustProxyHeaders || + input.trustedProxyHeaderProvider !== "cloudflare" + ) { + return DISABLED_GEOLOCATION; + } + + if (isPrivateOrLocalIp(input.ipAddress)) { + return { ...DISABLED_GEOLOCATION, source: "unknown" }; + } + + const country = input.request.headers["cf-ipcountry"]; + const code = Array.isArray(country) ? country[0] : country; + if (!code || !/^[A-Za-z]{2}$/.test(code) || code.toUpperCase() === "XX") { + return { ...DISABLED_GEOLOCATION, source: "unknown" }; + } + return { + countryCode: code.toUpperCase(), + countryName: null, + region: null, + city: null, + source: "trusted_header", + confidence: "medium", + }; + } +} diff --git a/apps/backend/src/common/security/request-security-context.middleware.spec.ts b/apps/backend/src/common/security/request-security-context.middleware.spec.ts new file mode 100644 index 00000000..c56c1ce8 --- /dev/null +++ b/apps/backend/src/common/security/request-security-context.middleware.spec.ts @@ -0,0 +1,70 @@ +import type { NextFunction, Request, Response } from "express"; + +import { getSecurityContextFromExecutionContext } from "../decorators/current-security-context.decorator"; +import { RequestSecurityContextMiddleware } from "./request-security-context.middleware"; + +function makeRequest( + overrides: Partial & { + headers?: Record; + socket?: { remoteAddress?: string }; + } = {}, +): Request { + return { + headers: {}, + method: "POST", + originalUrl: "/auth/login", + url: "/auth/login", + socket: { remoteAddress: "10.0.0.10" }, + ...overrides, + } as Request; +} + +describe("RequestSecurityContextMiddleware", () => { + it("attaches context to the request and sets x-request-id response header", () => { + const middleware = new RequestSecurityContextMiddleware( + { get: jest.fn().mockReturnValue("false") } as never, + { + extractFromHeader: jest + .fn() + .mockReturnValue({ deviceIdHash: null, source: "missing" }), + } as never, + ); + const request = makeRequest(); + const response = { setHeader: jest.fn() } as unknown as Response; + const next: NextFunction = jest.fn(); + + middleware.use(request, response, next); + + expect(request.securityContext).toEqual( + expect.objectContaining({ + requestId: expect.any(String), + ipAddress: "10.0.0.10", + method: "POST", + path: "/auth/login", + }), + ); + expect(response.setHeader).toHaveBeenCalledWith( + "x-request-id", + request.securityContext?.requestId, + ); + expect(next).toHaveBeenCalledTimes(1); + }); +}); + +describe("CurrentSecurityContext decorator", () => { + it("returns the same context attached by middleware", () => { + const securityContext = { + requestId: "req-1", + ipAddress: "10.0.0.10", + }; + const ctx = { + switchToHttp: () => ({ + getRequest: () => ({ securityContext }), + }), + }; + + expect(getSecurityContextFromExecutionContext(ctx as never)).toBe( + securityContext, + ); + }); +}); diff --git a/apps/backend/src/common/security/request-security-context.middleware.ts b/apps/backend/src/common/security/request-security-context.middleware.ts new file mode 100644 index 00000000..47eb9049 --- /dev/null +++ b/apps/backend/src/common/security/request-security-context.middleware.ts @@ -0,0 +1,54 @@ +import { Injectable, NestMiddleware } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import type { NextFunction, Request, Response } from "express"; + +import { IpGeolocationService } from "./ip-geolocation.service"; +import { + DeviceIdentityService, + TWIZRR_DEVICE_ID_HEADER, +} from "./device-identity.service"; +import { + buildRequestSecurityContext, + type TrustedProxyHeaderProvider, +} from "./request-security-context"; + +@Injectable() +export class RequestSecurityContextMiddleware implements NestMiddleware { + private readonly ipGeolocation = new IpGeolocationService(); + constructor( + private readonly config: ConfigService, + private readonly deviceIdentity: DeviceIdentityService, + ) {} + + use(request: Request, response: Response, next: NextFunction): void { + const context = buildRequestSecurityContext(request, { + trustProxyHeaders: this.trustProxyHeaders, + trustedProxyHeaderProvider: this.trustedProxyHeaderProvider, + }); + context.geo = this.ipGeolocation.resolve({ + request, + ipAddress: context.ipAddress, + trustProxyHeaders: this.trustProxyHeaders, + trustedProxyHeaderProvider: this.trustedProxyHeaderProvider, + provider: + this.config.get("IP_GEOLOCATION_PROVIDER") ?? "disabled", + }); + context.deviceIdentity = this.deviceIdentity.extractFromHeader( + request.headers[TWIZRR_DEVICE_ID_HEADER], + ); + + request.securityContext = context; + response.setHeader("x-request-id", context.requestId); + next(); + } + + private get trustProxyHeaders(): boolean { + const value = this.config.get("TRUST_PROXY_HEADERS"); + return value === true || value === "true"; + } + + private get trustedProxyHeaderProvider(): TrustedProxyHeaderProvider { + const value = this.config.get("TRUST_PROXY_HEADER_PROVIDER"); + return value === "cloudflare" ? "cloudflare" : "standard"; + } +} diff --git a/apps/backend/src/common/security/request-security-context.spec.ts b/apps/backend/src/common/security/request-security-context.spec.ts new file mode 100644 index 00000000..1dcc080b --- /dev/null +++ b/apps/backend/src/common/security/request-security-context.spec.ts @@ -0,0 +1,259 @@ +import type { Request } from "express"; + +import { + buildRequestSecurityContext, + getRequestSecurityContext, + serializeRequestSecurityContextForAudit, +} from "./request-security-context"; + +type MockRequestOverrides = Omit, "headers" | "socket"> & { + headers?: Record; + socket?: { remoteAddress?: string }; +}; + +function makeRequest(overrides: MockRequestOverrides = {}): Request { + return { + headers: {}, + method: "GET", + originalUrl: "/health", + url: "/health", + socket: { remoteAddress: "10.0.0.10" }, + ...overrides, + } as Request; +} + +describe("request security context", () => { + it("ignores spoofed forwarded headers when TRUST_PROXY_HEADERS is false", () => { + const request = makeRequest({ + headers: { + "cf-connecting-ip": "203.0.113.10", + "x-forwarded-for": "198.51.100.2, 10.0.0.1", + "x-real-ip": "198.51.100.3", + }, + socket: { remoteAddress: "10.0.0.10" }, + }); + + const context = buildRequestSecurityContext(request, { + trustProxyHeaders: false, + }); + + expect(context.ipAddress).toBe("10.0.0.10"); + expect(context.ipSource).toBe("remote-address"); + expect(context.isTrustedProxyHeaderUsed).toBe(false); + }); + + it("uses valid cf-connecting-ip when Cloudflare proxy header mode is explicit", () => { + const request = makeRequest({ + headers: { + "cf-connecting-ip": "203.0.113.10", + "x-forwarded-for": "198.51.100.2", + }, + socket: { remoteAddress: "10.0.0.10" }, + }); + + const context = buildRequestSecurityContext(request, { + trustProxyHeaders: true, + trustedProxyHeaderProvider: "cloudflare", + }); + + expect(context.ipAddress).toBe("203.0.113.10"); + expect(context.ipSource).toBe("cf-connecting-ip"); + expect(context.isTrustedProxyHeaderUsed).toBe(true); + }); + + it("uses first valid x-forwarded-for IP when trusted and Cloudflare header is absent", () => { + const request = makeRequest({ + headers: { + "x-forwarded-for": "198.51.100.2, garbage, 10.0.0.1", + }, + socket: { remoteAddress: "10.0.0.10" }, + }); + + const context = buildRequestSecurityContext(request, { + trustProxyHeaders: true, + }); + + expect(context.ipAddress).toBe("198.51.100.2"); + expect(context.ipSource).toBe("x-forwarded-for"); + }); + + it("ignores invalid forwarded IP values and falls back to remote address", () => { + const request = makeRequest({ + headers: { + "cf-connecting-ip": "203.0.113.10, 198.51.100.2", + "x-forwarded-for": "garbage, also-bad", + "x-real-ip": "not-an-ip", + }, + socket: { remoteAddress: "10.0.0.10" }, + }); + + const context = buildRequestSecurityContext(request, { + trustProxyHeaders: true, + }); + + expect(context.ipAddress).toBe("10.0.0.10"); + expect(context.ipSource).toBe("remote-address"); + expect(context.isTrustedProxyHeaderUsed).toBe(false); + }); + + it("strips query strings and fragments from path and referer", () => { + const request = makeRequest({ + headers: { + referer: + "https://twizrr.com/auth/google/callback?code=sensitive-oauth-code&state=sensitive-oauth-state#frag", + }, + originalUrl: + "/whatsapp/webhook?hub.verify_token=sensitive-verify-token&hub.challenge=123", + url: "/whatsapp/webhook?hub.verify_token=sensitive-verify-token&hub.challenge=123", + }); + + const context = buildRequestSecurityContext(request, { + trustProxyHeaders: false, + }); + + expect(context.path).toBe("/whatsapp/webhook"); + expect(context.referer).toBe("https://twizrr.com/auth/google/callback"); + expect(serializeRequestSecurityContextForAudit(context)).toEqual( + expect.objectContaining({ + path: "/whatsapp/webhook", + referer: "https://twizrr.com/auth/google/callback", + }), + ); + expect( + JSON.stringify(serializeRequestSecurityContextForAudit(context)), + ).not.toMatch(/secret-token|secret-code|secret-state|hub\.verify_token/i); + }); + + it("does not trust cf-connecting-ip in the default trusted proxy mode", () => { + const request = makeRequest({ + headers: { + "cf-connecting-ip": "203.0.113.10", + "x-forwarded-for": "198.51.100.2, 10.0.0.1", + }, + socket: { remoteAddress: "10.0.0.10" }, + }); + + const context = buildRequestSecurityContext(request, { + trustProxyHeaders: true, + }); + + expect(context.ipAddress).toBe("198.51.100.2"); + expect(context.ipSource).toBe("x-forwarded-for"); + }); + + it("trusts cf-connecting-ip only when Cloudflare proxy header mode is explicit", () => { + const request = makeRequest({ + headers: { + "cf-connecting-ip": "203.0.113.10", + "x-forwarded-for": "198.51.100.2", + }, + socket: { remoteAddress: "10.0.0.10" }, + }); + + const context = buildRequestSecurityContext(request, { + trustProxyHeaders: true, + trustedProxyHeaderProvider: "cloudflare", + }); + + expect(context.ipAddress).toBe("203.0.113.10"); + expect(context.ipSource).toBe("cf-connecting-ip"); + }); + + it("normalizes IPv6-mapped IPv4 remote addresses", () => { + const request = makeRequest({ + socket: { remoteAddress: "::ffff:192.0.2.25" }, + }); + + const context = buildRequestSecurityContext(request, { + trustProxyHeaders: false, + }); + + expect(context.ipAddress).toBe("192.0.2.25"); + }); + + it("generates a request ID when missing and preserves safe inbound request IDs", () => { + const generated = buildRequestSecurityContext(makeRequest(), { + trustProxyHeaders: false, + }); + const preserved = buildRequestSecurityContext( + makeRequest({ headers: { "x-request-id": "req_ABC-123.456" } }), + { trustProxyHeaders: false }, + ); + + expect(generated.requestId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(preserved.requestId).toBe("req_ABC-123.456"); + }); + + it("replaces unsafe or excessively long inbound request IDs", () => { + const context = buildRequestSecurityContext( + makeRequest({ headers: { "x-request-id": "x".repeat(129) } }), + { trustProxyHeaders: false }, + ); + + expect(context.requestId).not.toBe("x".repeat(129)); + expect(context.requestId).toHaveLength(36); + }); + + it("returns the context attached to the request", () => { + const request = makeRequest(); + const context = buildRequestSecurityContext(request, { + trustProxyHeaders: false, + }); + request.securityContext = context; + + expect(getRequestSecurityContext(request)).toBe(context); + }); + + it("serializes only safe audit metadata", () => { + const request = makeRequest({ + headers: { + cookie: "access=secret", + authorization: "Bearer secret", + "x-paystack-signature": "secret", + "user-agent": "Mozilla/5.0", + origin: "https://twizrr.com", + referer: "https://twizrr.com/store", + }, + }); + const context = buildRequestSecurityContext(request, { + trustProxyHeaders: false, + }); + + expect(serializeRequestSecurityContextForAudit(context)).toEqual({ + requestId: context.requestId, + ipAddress: "10.0.0.10", + ipSource: "remote-address", + userAgent: "Mozilla/5.0", + origin: "https://twizrr.com", + referer: "https://twizrr.com/store", + method: "GET", + path: "/health", + deviceType: "desktop", + geo: null, + deviceIdentity: { deviceHashPresent: false, source: "missing" }, + }); + expect( + JSON.stringify(serializeRequestSecurityContextForAudit(context)), + ).not.toMatch(/secret|authorization|cookie|signature/i); + }); + + it("caps persisted header values while retaining only the safe fields", () => { + const context = buildRequestSecurityContext( + makeRequest({ + headers: { + "user-agent": "a".repeat(600), + referer: `https://twizrr.com/${"r".repeat(600)}?token=secret`, + }, + }), + { trustProxyHeaders: false }, + ); + + const metadata = serializeRequestSecurityContextForAudit(context); + + expect(metadata.userAgent).toHaveLength(512); + expect(metadata.referer).toHaveLength(512); + expect(JSON.stringify(metadata)).not.toContain("token=secret"); + }); +}); diff --git a/apps/backend/src/common/security/request-security-context.ts b/apps/backend/src/common/security/request-security-context.ts new file mode 100644 index 00000000..fa32e49e --- /dev/null +++ b/apps/backend/src/common/security/request-security-context.ts @@ -0,0 +1,275 @@ +import { randomUUID } from "crypto"; +import { isIP } from "net"; +import type { Request } from "express"; + +import type { DeviceIdentityContext } from "./device-identity.service"; + +export type RequestIpSource = + | "cf-connecting-ip" + | "x-forwarded-for" + | "x-real-ip" + | "remote-address" + | "unknown"; + +export type RequestDeviceType = + | "mobile" + | "tablet" + | "desktop" + | "bot" + | "unknown"; + +export type RequestSecurityContext = { + requestId: string; + ipAddress: string | null; + ipSource: RequestIpSource; + userAgent: string | null; + origin: string | null; + referer: string | null; + method: string; + path: string; + deviceType?: RequestDeviceType; + deviceIdentity?: DeviceIdentityContext; + geo?: import("./ip-geolocation.service").IpGeolocationResult | null; + isTrustedProxyHeaderUsed: boolean; + createdAt: Date; +}; + +export type TrustedProxyHeaderProvider = "standard" | "cloudflare"; + +export type RequestSecurityContextOptions = { + trustProxyHeaders: boolean; + trustedProxyHeaderProvider?: TrustedProxyHeaderProvider; +}; + +export type RequestSecurityAuditMetadata = Omit< + Pick< + RequestSecurityContext, + | "requestId" + | "ipAddress" + | "ipSource" + | "userAgent" + | "origin" + | "referer" + | "method" + | "path" + | "deviceType" + | "geo" + | "deviceIdentity" + >, + "deviceIdentity" +> & { + deviceIdentity: { + deviceHashPresent: boolean; + source: DeviceIdentityContext["source"]; + }; +}; + +const MAX_REQUEST_ID_LENGTH = 128; +const MAX_AUDIT_HEADER_VALUE_LENGTH = 512; +const MAX_AUDIT_PATH_LENGTH = 1024; +const SAFE_REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]+$/; + +export function buildRequestSecurityContext( + request: Request, + options: RequestSecurityContextOptions, +): RequestSecurityContext { + const ipResult = extractClientIp(request, options); + const userAgent = firstHeaderValue(request.headers["user-agent"]); + + return { + requestId: resolveRequestId(request), + ipAddress: ipResult.ipAddress, + ipSource: ipResult.ipSource, + userAgent, + origin: firstHeaderValue(request.headers.origin), + referer: stripUrlQuery(firstHeaderValue(request.headers.referer)), + method: request.method, + path: sanitizeRequestPath(request.originalUrl || request.url), + deviceType: detectDeviceType(userAgent), + deviceIdentity: { deviceIdHash: null, source: "missing" }, + geo: null, + isTrustedProxyHeaderUsed: ipResult.isTrustedProxyHeaderUsed, + createdAt: new Date(), + }; +} + +export function getRequestSecurityContext( + request: Request, +): RequestSecurityContext | undefined { + return request.securityContext; +} + +export function serializeRequestSecurityContextForAudit( + context: RequestSecurityContext, +): RequestSecurityAuditMetadata { + return { + requestId: context.requestId, + ipAddress: context.ipAddress, + ipSource: context.ipSource, + userAgent: context.userAgent, + origin: context.origin, + referer: stripUrlQuery(context.referer), + method: context.method, + path: sanitizeRequestPath(context.path), + deviceType: context.deviceType, + geo: context.geo ?? null, + deviceIdentity: { + deviceHashPresent: context.deviceIdentity?.deviceIdHash !== null, + source: context.deviceIdentity?.source ?? "missing", + }, + }; +} + +function extractClientIp( + request: Request, + options: RequestSecurityContextOptions, +): { + ipAddress: string | null; + ipSource: RequestIpSource; + isTrustedProxyHeaderUsed: boolean; +} { + if (options.trustProxyHeaders) { + if (options.trustedProxyHeaderProvider === "cloudflare") { + const cloudflareIp = parseSingleIp( + firstHeaderValue(request.headers["cf-connecting-ip"]), + ); + if (cloudflareIp) { + return { + ipAddress: cloudflareIp, + ipSource: "cf-connecting-ip", + isTrustedProxyHeaderUsed: true, + }; + } + } + + const forwardedIp = parseForwardedForHeader( + firstHeaderValue(request.headers["x-forwarded-for"]), + ); + if (forwardedIp) { + return { + ipAddress: forwardedIp, + ipSource: "x-forwarded-for", + isTrustedProxyHeaderUsed: true, + }; + } + + const realIp = parseSingleIp( + firstHeaderValue(request.headers["x-real-ip"]), + ); + if (realIp) { + return { + ipAddress: realIp, + ipSource: "x-real-ip", + isTrustedProxyHeaderUsed: true, + }; + } + } + + const remoteIp = parseSingleIp(request.socket?.remoteAddress); + return { + ipAddress: remoteIp, + ipSource: remoteIp ? "remote-address" : "unknown", + isTrustedProxyHeaderUsed: false, + }; +} + +function resolveRequestId(request: Request): string { + const inboundRequestId = firstHeaderValue(request.headers["x-request-id"]); + + if (isSafeRequestId(inboundRequestId)) { + return inboundRequestId; + } + + return randomUUID(); +} + +function isSafeRequestId(value: string | null): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= MAX_REQUEST_ID_LENGTH && + SAFE_REQUEST_ID_PATTERN.test(value) + ); +} + +function sanitizeRequestPath(value: string | undefined): string { + const path = stripUrlQuery(value || "/"); + return path ? truncateAuditValue(path, MAX_AUDIT_PATH_LENGTH) : "/"; +} + +function stripUrlQuery(value: string | null): string | null { + if (!value) { + return null; + } + + const hashIndex = value.indexOf("#"); + const withoutHash = hashIndex >= 0 ? value.slice(0, hashIndex) : value; + const queryIndex = withoutHash.indexOf("?"); + const sanitized = + queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash; + return truncateAuditValue(sanitized, MAX_AUDIT_HEADER_VALUE_LENGTH); +} +function firstHeaderValue(value: unknown): string | null { + if (Array.isArray(value)) { + return typeof value[0] === "string" ? value[0] : null; + } + + return typeof value === "string" && value.length > 0 + ? truncateAuditValue(value, MAX_AUDIT_HEADER_VALUE_LENGTH) + : null; +} + +function truncateAuditValue(value: string, maxLength: number): string { + return value.length > maxLength ? value.slice(0, maxLength) : value; +} + +function parseForwardedForHeader(value: string | null): string | null { + if (!value) { + return null; + } + + for (const part of value.split(",")) { + const ip = parseSingleIp(part.trim()); + if (ip) { + return ip; + } + } + + return null; +} + +function parseSingleIp(value: string | null | undefined): string | null { + if (!value) { + return null; + } + + const normalized = stripIpv6MappedIpv4(value.trim()); + if (normalized.includes(",")) { + return null; + } + + return isIP(normalized) ? normalized : null; +} + +function stripIpv6MappedIpv4(value: string): string { + return value.startsWith("::ffff:") ? value.slice("::ffff:".length) : value; +} + +function detectDeviceType(userAgent: string | null): RequestDeviceType { + if (!userAgent) { + return "unknown"; + } + + const ua = userAgent.toLowerCase(); + if (/bot|crawler|spider|slurp|bingpreview/.test(ua)) { + return "bot"; + } + if (/ipad|tablet/.test(ua)) { + return "tablet"; + } + if (/mobile|android|iphone|ipod/.test(ua)) { + return "mobile"; + } + + return "desktop"; +} diff --git a/apps/backend/src/common/store-context-aliases.spec.ts b/apps/backend/src/common/store-context-aliases.spec.ts new file mode 100644 index 00000000..72ff020a --- /dev/null +++ b/apps/backend/src/common/store-context-aliases.spec.ts @@ -0,0 +1,48 @@ +import { Request, Response } from "express"; +import { UserRole } from "@twizrr/shared"; + +import { CurrentMerchant } from "./decorators/current-merchant.decorator"; +import { CurrentStore } from "./decorators/current-store.decorator"; +import { MerchantVerifiedGuard } from "./guards/merchant-verified.guard"; +import { StoreVerifiedGuard } from "./guards/store-verified.guard"; +import { MerchantContextMiddleware } from "./middleware/merchant-context.middleware"; +import { StoreContextMiddleware } from "./middleware/store-context.middleware"; + +type StoreContextRequest = Request & { + user?: { role: UserRole; storeId?: string }; + storeId?: string; +}; + +describe("store context aliases", () => { + it("exports CurrentStore as the same decorator as CurrentMerchant", () => { + expect(CurrentStore).toBe(CurrentMerchant); + }); + + it("sets storeId through the store context middleware alias", () => { + const request = { + user: { role: UserRole.USER, storeId: "store-1" }, + } as StoreContextRequest; + const next = jest.fn(); + + new StoreContextMiddleware().use(request, {} as Response, next); + + expect(request.storeId).toBe("store-1"); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("keeps store middleware compatible with the merchant middleware", () => { + expect(new StoreContextMiddleware()).toBeInstanceOf( + MerchantContextMiddleware, + ); + }); + + it("keeps store verified guard compatible with the merchant verified guard", () => { + const guard = new StoreVerifiedGuard( + {} as ConstructorParameters[0], + {} as ConstructorParameters[1], + ); + + expect(guard).toBeInstanceOf(MerchantVerifiedGuard); + expect(typeof guard.canActivate).toBe("function"); + }); +}); diff --git a/apps/backend/src/common/types/express.d.ts b/apps/backend/src/common/types/express.d.ts index f87006ad..f04ad23b 100644 --- a/apps/backend/src/common/types/express.d.ts +++ b/apps/backend/src/common/types/express.d.ts @@ -1,10 +1,13 @@ -import { JwtPayload } from "@swifta/shared"; +import { JwtPayload } from "@twizrr/shared"; + +import { RequestSecurityContext } from "../security/request-security-context"; declare global { namespace Express { interface Request { user?: JwtPayload; - merchantId?: string; + storeId?: string; + securityContext?: RequestSecurityContext; } } } diff --git a/apps/backend/src/common/utils/pagination.ts b/apps/backend/src/common/utils/pagination.ts index dc0ff14f..ddb595d0 100644 --- a/apps/backend/src/common/utils/pagination.ts +++ b/apps/backend/src/common/utils/pagination.ts @@ -1,4 +1,4 @@ -import { PaginatedResponse } from "@swifta/shared"; +import { PaginatedResponse } from "@twizrr/shared"; export interface PaginationParams { page: number; diff --git a/apps/backend/src/common/utils/string.util.ts b/apps/backend/src/common/utils/string.util.ts new file mode 100644 index 00000000..4b90cf66 --- /dev/null +++ b/apps/backend/src/common/utils/string.util.ts @@ -0,0 +1,2 @@ +export const trimString = (value: unknown) => + typeof value === "string" ? value.trim() : value; diff --git a/apps/backend/src/config/africastalking.config.ts b/apps/backend/src/config/africastalking.config.ts index 4a201303..fee02c4e 100644 --- a/apps/backend/src/config/africastalking.config.ts +++ b/apps/backend/src/config/africastalking.config.ts @@ -4,4 +4,9 @@ export default registerAs("africastalking", () => ({ username: process.env.AT_USERNAME || "sandbox", apiKey: process.env.AT_API_KEY, senderId: process.env.AT_SENDER_ID, // Useful for custom sender IDs in production + // Optional shared secret embedded in the delivery-report callback URL query + // string. Africa's Talking does not sign delivery reports, so when this is set + // the webhook rejects calls whose ?token= does not match. Leave unset to + // accept all reports (e.g. local development). + deliveryReportSecret: process.env.AT_DELIVERY_REPORT_SECRET, })); diff --git a/apps/backend/src/config/ai.config.ts b/apps/backend/src/config/ai.config.ts new file mode 100644 index 00000000..f5fd9338 --- /dev/null +++ b/apps/backend/src/config/ai.config.ts @@ -0,0 +1,11 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("ai", () => ({ + geminiApiKey: process.env.GEMINI_API_KEY, + geminiModel: process.env.GEMINI_MODEL || "gemini-2.5-flash", + googleCloudApiKey: process.env.GOOGLE_CLOUD_API_KEY, + googleCloudProject: process.env.GOOGLE_CLOUD_PROJECT, + googleApplicationCredentials: process.env.GOOGLE_APPLICATION_CREDENTIALS, + vertexLocation: process.env.VERTEX_AI_LOCATION || "us-central1", + vertexModel: process.env.VERTEX_AI_MODEL || "multimodalembedding@001", +})); diff --git a/apps/backend/src/config/app.config.ts b/apps/backend/src/config/app.config.ts index bd87e753..0594df73 100644 --- a/apps/backend/src/config/app.config.ts +++ b/apps/backend/src/config/app.config.ts @@ -4,6 +4,7 @@ export default registerAs("app", () => ({ port: parseInt(process.env.PORT || "4000", 10), nodeEnv: process.env.NODE_ENV || "development", frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000", + onboardingOtpSecret: process.env.ONBOARDING_OTP_SECRET, corsOrigins: process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(",") : ["http://localhost:3000"], diff --git a/apps/backend/src/config/cloudinary.config.ts b/apps/backend/src/config/cloudinary.config.ts new file mode 100644 index 00000000..182b5c1a --- /dev/null +++ b/apps/backend/src/config/cloudinary.config.ts @@ -0,0 +1,7 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("cloudinary", () => ({ + cloudName: process.env.CLOUDINARY_CLOUD_NAME, + apiKey: process.env.CLOUDINARY_API_KEY, + apiSecret: process.env.CLOUDINARY_API_SECRET, +})); diff --git a/apps/backend/src/config/google-places.config.ts b/apps/backend/src/config/google-places.config.ts new file mode 100644 index 00000000..161653bb --- /dev/null +++ b/apps/backend/src/config/google-places.config.ts @@ -0,0 +1,7 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("googlePlaces", () => ({ + apiKey: process.env.GOOGLE_PLACES_API_KEY, + baseUrl: + process.env.GOOGLE_PLACES_BASE_URL || "https://places.googleapis.com/v1", +})); diff --git a/apps/backend/src/config/jwt.config.ts b/apps/backend/src/config/jwt.config.ts index 5e323fa0..eaae3f0d 100644 --- a/apps/backend/src/config/jwt.config.ts +++ b/apps/backend/src/config/jwt.config.ts @@ -1,8 +1,27 @@ import { registerAs } from "@nestjs/config"; -export default registerAs("jwt", () => ({ - accessSecret: process.env.JWT_ACCESS_SECRET, - refreshSecret: process.env.JWT_REFRESH_SECRET, - accessTtl: process.env.JWT_ACCESS_TTL || "15m", - refreshTtl: process.env.JWT_REFRESH_TTL || "7d", -})); +function resolveRequiredSecret( + name: string, + value: string | undefined, +): string { + if (!value?.trim()) { + throw new Error(`${name} is required`); + } + + return value; +} + +export default registerAs("jwt", () => { + const accessSecret = process.env.JWT_ACCESS_SECRET || process.env.JWT_SECRET; + const refreshSecret = + process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET; + + return { + accessSecret: resolveRequiredSecret("JWT access secret", accessSecret), + refreshSecret: resolveRequiredSecret("JWT refresh secret", refreshSecret), + accessTtl: + process.env.JWT_ACCESS_TTL || process.env.JWT_EXPIRES_IN || "15m", + refreshTtl: + process.env.JWT_REFRESH_TTL || process.env.JWT_REFRESH_EXPIRES_IN || "7d", + }; +}); diff --git a/apps/backend/src/config/monnify.config.ts b/apps/backend/src/config/monnify.config.ts new file mode 100644 index 00000000..1335cf9b --- /dev/null +++ b/apps/backend/src/config/monnify.config.ts @@ -0,0 +1,13 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("monnify", () => ({ + apiKey: process.env.MONNIFY_API_KEY || "", + secretKey: process.env.MONNIFY_SECRET_KEY || "", + contractCode: process.env.MONNIFY_CONTRACT_CODE || "", + disbursementAccountNumber: + process.env.MONNIFY_DISBURSEMENT_ACCOUNT_NUMBER || "", + baseUrl: process.env.MONNIFY_BASE_URL || "", + paymentEnabled: process.env.MONNIFY_PAYMENT_ENABLED === "true", + refundEnabled: process.env.MONNIFY_REFUND_ENABLED === "true", + payoutEnabled: process.env.MONNIFY_PAYOUT_ENABLED === "true", +})); diff --git a/apps/backend/src/config/nomba.config.ts b/apps/backend/src/config/nomba.config.ts new file mode 100644 index 00000000..1a95cab2 --- /dev/null +++ b/apps/backend/src/config/nomba.config.ts @@ -0,0 +1,18 @@ +import { registerAs } from "@nestjs/config"; + +/** + * Nomba configuration for the StorePass subscription billing adapter. + * + * NOMBA_BASE_URL already includes `/v1`, so client paths are relative to it + * (for example `/auth/token/issue`, `/checkout/order`). No real credentials are + * committed; placeholders live in `.env.example`. Nomba env vars are only + * required when the Nomba adapter is actually invoked, not at startup. + */ +export default registerAs("nomba", () => ({ + baseUrl: process.env.NOMBA_BASE_URL || "https://sandbox.nomba.com/v1", + accountId: process.env.NOMBA_ACCOUNT_ID, + subAccountId: process.env.NOMBA_SUB_ACCOUNT_ID, + clientId: process.env.NOMBA_CLIENT_ID, + clientSecret: process.env.NOMBA_CLIENT_SECRET, + webhookSecret: process.env.NOMBA_WEBHOOK_SECRET, +})); diff --git a/apps/backend/src/config/payment-amount-exception-refund.config.ts b/apps/backend/src/config/payment-amount-exception-refund.config.ts new file mode 100644 index 00000000..a49fa20f --- /dev/null +++ b/apps/backend/src/config/payment-amount-exception-refund.config.ts @@ -0,0 +1,18 @@ +const parseBoolean = (value: string | undefined, defaultValue: boolean) => { + if (value === undefined || value === "") return defaultValue; + return value === "true" || value === "1"; +}; + +/** + * New amount-exception refunds remain dark until the financial operations + * rollout explicitly enables them. Reconciliation stays available for a + * previously submitted provider operation regardless of this flag. + */ +export const PaymentAmountExceptionRefundConfig = { + get executionEnabled(): boolean { + return parseBoolean( + process.env.PAYMENT_AMOUNT_EXCEPTION_REFUND_EXECUTION_ENABLED, + false, + ); + }, +} as const; diff --git a/apps/backend/src/config/platform.config.ts b/apps/backend/src/config/platform.config.ts index 4e954961..1814f9a4 100644 --- a/apps/backend/src/config/platform.config.ts +++ b/apps/backend/src/config/platform.config.ts @@ -1,54 +1,148 @@ +import { Logger } from "@nestjs/common"; + +const logger = new Logger("PlatformConfig"); + +const parseEnvInt = (key: string, defaultValue: number): number => { + const val = process.env[key]; + if (!val) return defaultValue; + const parsed = parseInt(val, 10); + if (isNaN(parsed) || parsed <= 0) { + logger.warn( + `Invalid value for ${key}: "${val}". Falling back to ${defaultValue}`, + ); + return defaultValue; + } + return parsed; +}; + +// Platform commission expressed as a fraction of subtotal (e.g. 0.02 = 2%). +const parseFeeFraction = (key: string, defaultValue: number): number => { + const val = process.env[key]; + if (val === undefined || val === "") return defaultValue; + const parsed = parseFloat(val); + if (isNaN(parsed) || parsed < 0 || parsed > 1) { + logger.warn( + `Invalid fee fraction for ${key}: "${val}" (expected 0..1). Falling back to ${defaultValue}`, + ); + return defaultValue; + } + return parsed; +}; + +// Commission keyed by the merchant's verificationTier — higher verification +// earns a lower fee. UNVERIFIED is the base rate and the fallback for any +// unknown tier. TIER_3 is configured ahead of the VerificationTier enum +// gaining that value, so it is currently unreachable but ready to activate. +const platformFeeFractionByTier: Record = { + UNVERIFIED: parseFeeFraction("PLATFORM_FEE_UNVERIFIED", 0.02), + TIER_1: parseFeeFraction("PLATFORM_FEE_TIER_1", 0.015), + TIER_2: parseFeeFraction("PLATFORM_FEE_TIER_2", 0.01), + TIER_3: parseFeeFraction("PLATFORM_FEE_TIER_3", 0.005), +}; + +// Cap on the order subtotal (in kobo) that commission is charged on. Past this +// threshold the fee freezes, so large orders are not over-charged — the per-tier +// rate still applies, so higher tiers keep their discount even on capped orders. +// Default ₦500,000 = 50,000,000 kobo. +const parseCapKobo = (key: string, defaultValue: bigint): bigint => { + const val = process.env[key]; + if (val === undefined || val === "") return defaultValue; + const trimmed = val.trim(); + if (!/^\d+$/.test(trimmed) || BigInt(trimmed) <= 0n) { + logger.warn( + `Invalid kobo cap for ${key}: "${val}" (expected a positive integer). Falling back to ${defaultValue}`, + ); + return defaultValue; + } + return BigInt(trimmed); +}; + +const platformFeeCapOrderKobo = parseCapKobo( + "PLATFORM_FEE_CAP_ORDER_KOBO", + 50_000_000n, +); + +// 1. Parse base timers +const autoConfirmationHours = parseEnvInt("AUTO_CONFIRMATION_HOURS", 72); +const defaultFirst = Math.floor(autoConfirmationHours / 3); +const defaultFinal = Math.floor((autoConfirmationHours * 2) / 3); + +let autoConfirmReminderFirstHours = parseEnvInt( + "AUTO_CONFIRM_REMINDER_FIRST_HOURS", + defaultFirst, +); +let autoConfirmReminderFinalHours = parseEnvInt( + "AUTO_CONFIRM_REMINDER_FINAL_HOURS", + defaultFinal, +); +const autoConfirmDisputeWindowHours = parseEnvInt( + "AUTO_CONFIRM_DISPUTE_WINDOW_HOURS", + 48, +); + +// 2. Validate Invariants +// Ensure reminders are sequential and within the window +if (autoConfirmReminderFirstHours >= autoConfirmReminderFinalHours) { + logger.warn( + `Configuration error: First reminder (${autoConfirmReminderFirstHours}h) must be less than final reminder (${autoConfirmReminderFinalHours}h). Clamping first reminder.`, + ); + autoConfirmReminderFirstHours = Math.floor(autoConfirmReminderFinalHours / 2); +} + +if (autoConfirmReminderFinalHours >= autoConfirmationHours) { + logger.warn( + `Configuration error: Final reminder (${autoConfirmReminderFinalHours}h) must be less than auto-confirmation window (${autoConfirmationHours}h). Clamping reminders.`, + ); + autoConfirmReminderFinalHours = Math.floor((autoConfirmationHours * 2) / 3); + autoConfirmReminderFirstHours = Math.floor(autoConfirmationHours / 3); +} + +// Ensure dispute window is logically sound +if (autoConfirmDisputeWindowHours > autoConfirmationHours) { + logger.warn( + `Configuration error: Dispute window (${autoConfirmDisputeWindowHours}h) is unusually long compared to auto-confirm (${autoConfirmationHours}h).`, + ); +} + export const PlatformConfig = { fees: { - escrowPercent: parseFloat(process.env.PLATFORM_FEE_ESCROW || "2"), - directTier2Percent: parseFloat( - process.env.PLATFORM_FEE_DIRECT_TIER2 || "1.5", - ), - directTier3Percent: parseFloat( - process.env.PLATFORM_FEE_DIRECT_TIER3 || "1", - ), - tradeFinancingCommission: parseFloat( - process.env.TRADE_FINANCING_COMMISSION_PERCENTAGE || "3", - ), + fractionByTier: platformFeeFractionByTier, + // Commission is charged on the subtotal only up to this cap (kobo). + capOrderKobo: platformFeeCapOrderKobo, }, timers: { - autoConfirmationHours: parseInt( - process.env.AUTO_CONFIRMATION_HOURS || "72", - ), - escrowWindowHours: parseInt(process.env.ESCROW_WINDOW_HOURS || "24"), - otpExpiryEmailMinutes: parseInt( - process.env.OTP_EXPIRY_EMAIL_MINUTES || "10", - ), - otpExpiryAuthMinutes: parseInt(process.env.OTP_EXPIRY_AUTH_MINUTES || "15"), - otpExpiryWhatsappMinutes: parseInt( - process.env.OTP_EXPIRY_WHATSAPP_MINUTES || "5", - ), - onboardingSessionTtl: parseInt( - process.env.ONBOARDING_SESSION_TTL_SECONDS || "3600", - ), + autoConfirmationHours, + autoConfirmReminderFirstHours, + autoConfirmReminderFinalHours, + autoConfirmDisputeWindowHours, + escrowWindowHours: parseEnvInt("ESCROW_WINDOW_HOURS", 24), + otpExpiryEmailMinutes: parseEnvInt("OTP_EXPIRY_EMAIL_MINUTES", 10), + otpExpiryAuthMinutes: parseEnvInt("OTP_EXPIRY_AUTH_MINUTES", 15), + otpExpiryWhatsappMinutes: parseEnvInt("OTP_EXPIRY_WHATSAPP_MINUTES", 5), + onboardingSessionTtl: parseEnvInt("ONBOARDING_SESSION_TTL_SECONDS", 3600), + }, + // Fraction of subtotal charged to a merchant on the given verification tier. + getPlatformFeeFraction(tier?: string | null): number { + const key = tier ?? "UNVERIFIED"; + return this.fees.fractionByTier[key] ?? this.fees.fractionByTier.UNVERIFIED; }, - getPlatformFeePercent(tier: string, paymentMethod: string): number { - if (paymentMethod === "DIRECT") { - switch (tier) { - case "TIER_3": - return this.fees.directTier3Percent; - case "TIER_2": - return this.fees.directTier2Percent; - default: - return this.fees.escrowPercent; - } - } - return this.fees.escrowPercent; + // Whole-number percent (e.g. 2, 1.5, 1, 0.5) snapshotted on each order as + // platformFeePercent for display and auditing. + getPlatformFeePercent(tier?: string | null): number { + return this.getPlatformFeeFraction(tier) * 100; }, - calculateFeeKobo( - subtotalKobo: bigint, - tier: string, - paymentMethod: string, - ): bigint { - const percent = this.getPlatformFeePercent(tier, paymentMethod); - // Convert percentage to basis points (e.g., 1.5% -> 150 basis points) - // 1.5% = 1.5 / 100 = 150 / 10000 - const basisPoints = BigInt(Math.round(percent * 100)); - return (subtotalKobo * basisPoints) / 10000n; + calculateFeeKobo(subtotalKobo: bigint, tier?: string | null): bigint { + // Commission applies to the subtotal only up to the cap: past it the fee + // freezes so large orders are not over-charged. The per-tier rate still + // applies, so higher tiers keep their discount even on capped orders. + const chargeableKobo = + subtotalKobo > this.fees.capOrderKobo + ? this.fees.capOrderKobo + : subtotalKobo; + // Fraction -> basis points (e.g. 0.015 -> 150 bps) for integer kobo math. + const basisPoints = BigInt( + Math.round(this.getPlatformFeeFraction(tier) * 10000), + ); + return (chargeableKobo * basisPoints) / 10000n; }, }; diff --git a/apps/backend/src/config/prembly.config.ts b/apps/backend/src/config/prembly.config.ts new file mode 100644 index 00000000..a3122b82 --- /dev/null +++ b/apps/backend/src/config/prembly.config.ts @@ -0,0 +1,8 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("prembly", () => ({ + provider: process.env.IDENTITY_VERIFICATION_PROVIDER || "prembly", + apiKey: process.env.PREMBLY_API_KEY, + appId: process.env.PREMBLY_APP_ID, + baseUrl: process.env.PREMBLY_BASE_URL || "https://api.prembly.com", +})); diff --git a/apps/backend/src/config/redis.config.ts b/apps/backend/src/config/redis.config.ts deleted file mode 100644 index 504cafaa..00000000 --- a/apps/backend/src/config/redis.config.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { registerAs } from "@nestjs/config"; - -export default registerAs("redis", () => ({ - url: process.env.REDIS_URL || "redis://localhost:6379", -})); diff --git a/apps/backend/src/config/resend.config.ts b/apps/backend/src/config/resend.config.ts new file mode 100644 index 00000000..7901fa51 --- /dev/null +++ b/apps/backend/src/config/resend.config.ts @@ -0,0 +1,7 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("resend", () => ({ + provider: process.env.EMAIL_PROVIDER ?? "resend", + apiKey: process.env.RESEND_API_KEY, + fromEmail: process.env.EMAIL_FROM, +})); diff --git a/apps/backend/src/config/search.config.spec.ts b/apps/backend/src/config/search.config.spec.ts new file mode 100644 index 00000000..0db172e5 --- /dev/null +++ b/apps/backend/src/config/search.config.spec.ts @@ -0,0 +1,83 @@ +import { + DEFAULT_SEARCH_RANKING_WEIGHTS, + loadSearchRankingWeights, +} from "./search.config"; + +describe("search.config", () => { + it("returns the approved default weights when no env vars are set", () => { + expect(loadSearchRankingWeights({})).toEqual({ + vector: 0.5, + text: 0.2, + performance: 0.2, + tier: 0.1, + }); + }); + + it("matches the approved hybrid ranking contract defaults", () => { + expect(DEFAULT_SEARCH_RANKING_WEIGHTS).toEqual({ + vector: 0.5, + text: 0.2, + performance: 0.2, + tier: 0.1, + }); + }); + + it("applies env overrides that sum to 1.0", () => { + expect( + loadSearchRankingWeights({ + SEARCH_WEIGHT_VECTOR: "0.4", + SEARCH_WEIGHT_TEXT: "0.3", + SEARCH_WEIGHT_PERFORMANCE: "0.2", + SEARCH_WEIGHT_TIER: "0.1", + }), + ).toEqual({ vector: 0.4, text: 0.3, performance: 0.2, tier: 0.1 }); + }); + + it("falls back to defaults for blank env values", () => { + expect( + loadSearchRankingWeights({ + SEARCH_WEIGHT_VECTOR: "", + SEARCH_WEIGHT_TEXT: " ", + }), + ).toEqual(DEFAULT_SEARCH_RANKING_WEIGHTS); + }); + + it("rejects non-numeric weights", () => { + expect(() => + loadSearchRankingWeights({ SEARCH_WEIGHT_VECTOR: "half" }), + ).toThrow("SEARCH_WEIGHT_VECTOR must be a finite number"); + }); + + it("rejects negative weights", () => { + expect(() => + loadSearchRankingWeights({ + SEARCH_WEIGHT_VECTOR: "-0.1", + SEARCH_WEIGHT_TEXT: "0.8", + SEARCH_WEIGHT_PERFORMANCE: "0.2", + SEARCH_WEIGHT_TIER: "0.1", + }), + ).toThrow("SEARCH_WEIGHT_VECTOR must be greater than or equal to 0"); + }); + + it("rejects weights that do not sum to 1.0", () => { + expect(() => + loadSearchRankingWeights({ + SEARCH_WEIGHT_VECTOR: "0.5", + SEARCH_WEIGHT_TEXT: "0.5", + SEARCH_WEIGHT_PERFORMANCE: "0.2", + SEARCH_WEIGHT_TIER: "0.1", + }), + ).toThrow("Search ranking weights must sum to 1.0"); + }); + + it("tolerates floating-point drift in the weight sum", () => { + expect(() => + loadSearchRankingWeights({ + SEARCH_WEIGHT_VECTOR: "0.5", + SEARCH_WEIGHT_TEXT: "0.2", + SEARCH_WEIGHT_PERFORMANCE: "0.2", + SEARCH_WEIGHT_TIER: "0.1", + }), + ).not.toThrow(); + }); +}); diff --git a/apps/backend/src/config/search.config.ts b/apps/backend/src/config/search.config.ts new file mode 100644 index 00000000..d700f67e --- /dev/null +++ b/apps/backend/src/config/search.config.ts @@ -0,0 +1,87 @@ +import { registerAs } from "@nestjs/config"; + +export interface SearchRankingWeights { + vector: number; + text: number; + performance: number; + tier: number; +} + +export const DEFAULT_SEARCH_RANKING_WEIGHTS: SearchRankingWeights = { + vector: 0.5, + text: 0.2, + performance: 0.2, + tier: 0.1, +}; + +// Tolerance absorbs IEEE 754 drift (e.g. 0.5 + 0.2 + 0.2 + 0.1 !== 1 exactly) +// without letting a genuinely mis-weighted configuration boot. +export const SEARCH_WEIGHT_SUM_TOLERANCE = 1e-6; + +const WEIGHT_ENV_KEYS: Record = { + vector: "SEARCH_WEIGHT_VECTOR", + text: "SEARCH_WEIGHT_TEXT", + performance: "SEARCH_WEIGHT_PERFORMANCE", + tier: "SEARCH_WEIGHT_TIER", +}; + +function parseWeight( + key: string, + fallback: number, + env: NodeJS.ProcessEnv, +): number { + const raw = env[key]?.trim(); + if (raw === undefined || raw === "") { + return fallback; + } + + const parsed = Number(raw); + if (!Number.isFinite(parsed)) { + throw new Error(`${key} must be a finite number`); + } + if (parsed < 0) { + throw new Error(`${key} must be greater than or equal to 0`); + } + return parsed; +} + +export function loadSearchRankingWeights( + env: NodeJS.ProcessEnv = process.env, +): SearchRankingWeights { + const weights: SearchRankingWeights = { + vector: parseWeight( + WEIGHT_ENV_KEYS.vector, + DEFAULT_SEARCH_RANKING_WEIGHTS.vector, + env, + ), + text: parseWeight( + WEIGHT_ENV_KEYS.text, + DEFAULT_SEARCH_RANKING_WEIGHTS.text, + env, + ), + performance: parseWeight( + WEIGHT_ENV_KEYS.performance, + DEFAULT_SEARCH_RANKING_WEIGHTS.performance, + env, + ), + tier: parseWeight( + WEIGHT_ENV_KEYS.tier, + DEFAULT_SEARCH_RANKING_WEIGHTS.tier, + env, + ), + }; + + const sum = + weights.vector + weights.text + weights.performance + weights.tier; + if (Math.abs(sum - 1) > SEARCH_WEIGHT_SUM_TOLERANCE) { + throw new Error( + `Search ranking weights must sum to 1.0 (SEARCH_WEIGHT_VECTOR + SEARCH_WEIGHT_TEXT + SEARCH_WEIGHT_PERFORMANCE + SEARCH_WEIGHT_TIER = ${sum})`, + ); + } + + return weights; +} + +export default registerAs("search", () => ({ + weights: loadSearchRankingWeights(), +})); diff --git a/apps/backend/src/config/settlement.config.ts b/apps/backend/src/config/settlement.config.ts new file mode 100644 index 00000000..b95d5579 --- /dev/null +++ b/apps/backend/src/config/settlement.config.ts @@ -0,0 +1,50 @@ +import { Logger } from "@nestjs/common"; + +const logger = new Logger("SettlementConfig"); + +const parseBool = (key: string, defaultValue: boolean): boolean => { + const val = process.env[key]; + if (val === undefined || val === "") return defaultValue; + return val === "true" || val === "1"; +}; + +const parseInt_ = (key: string, defaultValue: number, min: number): number => { + const val = process.env[key]; + if (!val) return defaultValue; + const parsed = Number.parseInt(val, 10); + if (Number.isNaN(parsed) || parsed < min) { + logger.warn(`Invalid ${key}="${val}"; falling back to ${defaultValue}`); + return defaultValue; + } + return parsed; +}; + +/** + * Phase 5 execution safety flags. + * + * When executionEnabled is false the resolveDispute transaction still creates a + * durable settlement plan and its legs, but NO external refund or transfer is + * ever attempted — the settlement stays safely PENDING and no money moves. This + * lets the plan/record path ship dark and be enabled per environment. + * + * Reconciliation is independent: it only ever polls provider status for legs + * that were already submitted, so it is safe to leave on. + */ +export const SettlementConfig = { + get executionEnabled(): boolean { + return parseBool("DISPUTE_SETTLEMENT_EXECUTION_ENABLED", false); + }, + get reconciliationEnabled(): boolean { + return parseBool("DISPUTE_SETTLEMENT_RECONCILIATION_ENABLED", true); + }, + get reconciliationIntervalMs(): number { + return parseInt_( + "DISPUTE_SETTLEMENT_RECONCILIATION_INTERVAL_MS", + 60_000, + 5_000, + ); + }, + get reconciliationBatchSize(): number { + return parseInt_("DISPUTE_SETTLEMENT_RECONCILIATION_BATCH_SIZE", 25, 1); + }, +} as const; diff --git a/apps/backend/src/config/shipbubble.config.ts b/apps/backend/src/config/shipbubble.config.ts new file mode 100644 index 00000000..64a90036 --- /dev/null +++ b/apps/backend/src/config/shipbubble.config.ts @@ -0,0 +1,26 @@ +import { registerAs } from "@nestjs/config"; + +function parsePositiveInteger(value: string | undefined, fallback: number) { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function parsePositiveNumber(value: string | undefined, fallback: number) { + const parsed = Number.parseFloat(value ?? ""); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +export default registerAs("shipbubble", () => ({ + provider: process.env.SHIPPING_PROVIDER || "shipbubble", + apiKey: process.env.SHIPBUBBLE_API_KEY, + baseUrl: process.env.SHIPBUBBLE_BASE_URL || "https://api.shipbubble.com/v1", + webhookSecret: process.env.SHIPBUBBLE_WEBHOOK_SECRET, + defaultCategoryId: parsePositiveInteger( + process.env.SHIPBUBBLE_DEFAULT_CATEGORY_ID, + 1, + ), + defaultPackageWeightKg: parsePositiveNumber( + process.env.DEFAULT_PACKAGE_WEIGHT_KG, + 1, + ), +})); diff --git a/apps/backend/src/config/whatsapp.config.ts b/apps/backend/src/config/whatsapp.config.ts index 39731cb9..2c2e4615 100644 --- a/apps/backend/src/config/whatsapp.config.ts +++ b/apps/backend/src/config/whatsapp.config.ts @@ -2,11 +2,9 @@ import { registerAs } from "@nestjs/config"; export default registerAs("whatsapp", () => ({ phoneNumberId: process.env.WHATSAPP_PHONE_NUMBER_ID, - accessToken: process.env.WHATSAPP_ACCESS_TOKEN, + accessToken: process.env.WHATSAPP_TOKEN, verifyToken: process.env.WHATSAPP_VERIFY_TOKEN, appSecret: process.env.WHATSAPP_APP_SECRET, - welcomeMessage: process.env.WHATSAPP_WELCOME_MESSAGE, - merchantSystemPrompt: process.env.WHATSAPP_MERCHANT_SYSTEM_PROMPT, - buyerSystemPrompt: process.env.WHATSAPP_BUYER_SYSTEM_PROMPT, - supplierSystemPrompt: process.env.WHATSAPP_SUPPLIER_SYSTEM_PROMPT, + welcomeMessage: process.env.WHATSAPP_CONSENT_MESSAGE, + systemPrompt: process.env.WHATSAPP_SYSTEM_PROMPT, })); diff --git a/apps/backend/src/core/config/app.config.ts b/apps/backend/src/core/config/app.config.ts new file mode 100644 index 00000000..e9cd32e6 --- /dev/null +++ b/apps/backend/src/core/config/app.config.ts @@ -0,0 +1,38 @@ +import { registerAs } from "@nestjs/config"; + +function parseCorsOrigins(value: string | undefined): string[] { + if (!value) { + return ["http://localhost:3000"]; + } + + return value + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); +} + +function parseBoolean( + value: string | undefined, + defaultValue = false, +): boolean { + if (value === undefined || value.trim() === "") { + return defaultValue; + } + + return value.trim().toLowerCase() === "true"; +} + +export default registerAs("app", () => ({ + nodeEnv: process.env.NODE_ENV || "development", + port: Number.parseInt(process.env.PORT || "4000", 10), + appUrl: process.env.APP_URL || "http://localhost:4000", + webUrl: process.env.WEB_URL || "http://localhost:3000", + frontendUrl: process.env.WEB_URL || "http://localhost:3000", + onboardingOtpSecret: process.env.ONBOARDING_OTP_SECRET, + corsOrigins: parseCorsOrigins(process.env.CORS_ORIGINS), + trustProxyHeaders: parseBoolean(process.env.TRUST_PROXY_HEADERS), + trustProxyHeaderProvider: + process.env.TRUST_PROXY_HEADER_PROVIDER === "cloudflare" + ? "cloudflare" + : "standard", +})); diff --git a/apps/backend/src/core/config/database.config.ts b/apps/backend/src/core/config/database.config.ts new file mode 100644 index 00000000..e55b0c1b --- /dev/null +++ b/apps/backend/src/core/config/database.config.ts @@ -0,0 +1,6 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("database", () => ({ + url: process.env.DATABASE_URL, + directUrl: process.env.DIRECT_URL, +})); diff --git a/apps/backend/src/core/config/index.ts b/apps/backend/src/core/config/index.ts new file mode 100644 index 00000000..82862a40 --- /dev/null +++ b/apps/backend/src/core/config/index.ts @@ -0,0 +1,5 @@ +export { default as appConfig } from "./app.config"; +export { default as databaseConfig } from "./database.config"; +export { default as redisConfig } from "./redis.config"; +export { default as realtimeConfig } from "./realtime.config"; +export { default as outboxConfig } from "./outbox.config"; diff --git a/apps/backend/src/core/config/outbox.config.ts b/apps/backend/src/core/config/outbox.config.ts new file mode 100644 index 00000000..d6957923 --- /dev/null +++ b/apps/backend/src/core/config/outbox.config.ts @@ -0,0 +1,39 @@ +import { registerAs } from "@nestjs/config"; + +function parseBoolean( + value: string | undefined, + defaultValue: boolean, +): boolean { + if (value === undefined || value.trim() === "") { + return defaultValue; + } + + return value.trim().toLowerCase() === "true"; +} + +function parsePositiveInteger( + value: string | undefined, + defaultValue: number, +): number { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : defaultValue; +} + +export default registerAs("outbox", () => ({ + relayEnabled: parseBoolean(process.env.OUTBOX_RELAY_ENABLED, true), + pollIntervalMs: parsePositiveInteger( + process.env.OUTBOX_POLL_INTERVAL_MS, + 1000, + ), + batchSize: parsePositiveInteger(process.env.OUTBOX_BATCH_SIZE, 50), + processingConcurrency: parsePositiveInteger( + process.env.OUTBOX_PROCESSING_CONCURRENCY, + 5, + ), + lockTtlSeconds: parsePositiveInteger(process.env.OUTBOX_LOCK_TTL_SECONDS, 60), + maxAttempts: parsePositiveInteger(process.env.OUTBOX_MAX_ATTEMPTS, 10), + baseRetryDelayMs: parsePositiveInteger( + process.env.OUTBOX_BASE_RETRY_DELAY_MS, + 2000, + ), +})); diff --git a/apps/backend/src/core/config/realtime.config.ts b/apps/backend/src/core/config/realtime.config.ts new file mode 100644 index 00000000..b139d78e --- /dev/null +++ b/apps/backend/src/core/config/realtime.config.ts @@ -0,0 +1,20 @@ +import { registerAs } from "@nestjs/config"; + +function parseBoolean( + value: string | undefined, + defaultValue = false, +): boolean { + if (value === undefined || value.trim() === "") { + return defaultValue; + } + + return value.trim().toLowerCase() === "true"; +} + +export default registerAs("realtime", () => ({ + socketIoPath: process.env.SOCKET_IO_PATH || "/socket.io", + redisAdapterEnabled: parseBoolean( + process.env.SOCKET_IO_REDIS_ADAPTER_ENABLED, + false, + ), +})); diff --git a/apps/backend/src/core/config/redis.config.ts b/apps/backend/src/core/config/redis.config.ts new file mode 100644 index 00000000..b10853de --- /dev/null +++ b/apps/backend/src/core/config/redis.config.ts @@ -0,0 +1,5 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("redis", () => ({ + url: process.env.REDIS_URL, +})); diff --git a/apps/backend/src/core/core.module.ts b/apps/backend/src/core/core.module.ts new file mode 100644 index 00000000..add4277a --- /dev/null +++ b/apps/backend/src/core/core.module.ts @@ -0,0 +1,135 @@ +import { CacheModule } from "@nestjs/cache-manager"; +import { Logger, Module } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { ScheduleModule } from "@nestjs/schedule"; +import { redisStore } from "cache-manager-ioredis-yet"; + +import { envValidationSchema } from "../common/config/env.validation"; +import africastalkingConfig from "../config/africastalking.config"; +import aiConfig from "../config/ai.config"; +import cloudinaryConfig from "../config/cloudinary.config"; +import googlePlacesConfig from "../config/google-places.config"; +import jwtConfig from "../config/jwt.config"; +import nombaConfig from "../config/nomba.config"; +import monnifyConfig from "../config/monnify.config"; +import paystackConfig from "../config/paystack.config"; +import premblyConfig from "../config/prembly.config"; +import resendConfig from "../config/resend.config"; +import searchConfig from "../config/search.config"; +import shipbubbleConfig from "../config/shipbubble.config"; +import whatsappConfig from "../config/whatsapp.config"; +import { + appConfig, + databaseConfig, + realtimeConfig, + outboxConfig, + redisConfig, +} from "./config"; +import { HealthModule } from "./health/health.module"; +import { LoggerModule } from "./logger/logger.module"; +import { PrismaModule } from "./prisma/prisma.module"; +import { RedisModule } from "./redis/redis.module"; +import { ThrottlerModule } from "./throttler/throttler.module"; + +function sanitizeRedisUrl(url: string | undefined): string | undefined { + if (!url) { + return undefined; + } + + let cleanUrl = url.trim(); + + if (cleanUrl.startsWith("REDIS_URL=")) { + cleanUrl = cleanUrl.substring("REDIS_URL=".length); + } + + if ( + (cleanUrl.startsWith('"') && cleanUrl.endsWith('"')) || + (cleanUrl.startsWith("'") && cleanUrl.endsWith("'")) + ) { + cleanUrl = cleanUrl.substring(1, cleanUrl.length - 1); + } + + return cleanUrl.trim(); +} + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: [".env.local", ".env"], + load: [ + appConfig, + databaseConfig, + redisConfig, + realtimeConfig, + outboxConfig, + jwtConfig, + paystackConfig, + monnifyConfig, + nombaConfig, + africastalkingConfig, + whatsappConfig, + aiConfig, + cloudinaryConfig, + resendConfig, + shipbubbleConfig, + premblyConfig, + googlePlacesConfig, + searchConfig, + ], + validationSchema: envValidationSchema, + }), + LoggerModule, + ScheduleModule.forRoot(), + CacheModule.registerAsync({ + isGlobal: true, + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (configService: ConfigService) => { + const urlString = sanitizeRedisUrl( + configService.get("redis.url"), + ); + + if (!urlString) { + Logger.warn( + "REDIS_URL not found for CacheModule, falling back to localhost", + ); + const store = await redisStore({ + host: "127.0.0.1", + port: 6379, + family: 0, + ttl: 60 * 1000, + enableReadyCheck: false, + }); + return { store }; + } + + try { + const parsedUrl = new URL(urlString); + const store = await redisStore({ + host: parsedUrl.hostname, + port: Number.parseInt(parsedUrl.port, 10) || 6379, + password: parsedUrl.password || undefined, + username: parsedUrl.username || undefined, + tls: parsedUrl.protocol === "rediss:" ? {} : undefined, + family: 0, + ttl: 60 * 1000, + enableReadyCheck: false, + }); + return { store }; + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "Unknown Redis error"; + Logger.error("CacheModule failed to parse REDIS_URL"); + throw new Error(`Invalid REDIS_URL for CacheModule: ${message}`); + } + }, + }), + ThrottlerModule, + PrismaModule, + RedisModule, + HealthModule, + ], + exports: [PrismaModule, RedisModule, HealthModule], +}) +export class CoreModule {} diff --git a/apps/backend/src/core/health/health.controller.ts b/apps/backend/src/core/health/health.controller.ts new file mode 100644 index 00000000..43c77de1 --- /dev/null +++ b/apps/backend/src/core/health/health.controller.ts @@ -0,0 +1,87 @@ +import { + Controller, + Get, + Logger, + ServiceUnavailableException, +} from "@nestjs/common"; + +import { PrismaService } from "../prisma/prisma.service"; +import { RedisService } from "../redis/redis.service"; + +type DependencyState = "ok" | "down"; + +interface HealthPayload { + status: "ok" | "degraded"; + timestamp: string; + dependencies: { + database: DependencyState; + redis: DependencyState; + }; +} + +@Controller("health") +export class HealthController { + private readonly logger = new Logger(HealthController.name); + + constructor( + private readonly prisma: PrismaService, + private readonly redis: RedisService, + ) {} + + // Liveness + dependency probe. + // + // Returns the standard response envelope: + // 200 { "success": true, "data": { status, timestamp, dependencies } } + // 503 { "success": false, "message": "Health degraded: db=...,redis=...", + // "code": "HEALTH_DEGRADED" } + // + // The dependency map is preserved on the healthy path so operators can + // see both checks pass at a glance. On the degraded path GlobalExceptionFilter + // strips extra response fields, so the per-dependency state is encoded + // into `message` to keep the 503 useful without breaking envelope shape. + @Get() + async check(): Promise { + const dependencies: HealthPayload["dependencies"] = { + database: "down", + redis: "down", + }; + + try { + await this.prisma.$queryRaw`SELECT 1`; + dependencies.database = "ok"; + } catch (error) { + this.logger.warn( + `Health probe: database check failed: ${ + error instanceof Error ? error.message : "unknown error" + }`, + ); + } + + try { + await this.redis.ping(); + dependencies.redis = "ok"; + } catch (error) { + this.logger.warn( + `Health probe: redis check failed: ${ + error instanceof Error ? error.message : "unknown error" + }`, + ); + } + + const allOk = dependencies.database === "ok" && dependencies.redis === "ok"; + const timestamp = new Date().toISOString(); + + if (!allOk) { + throw new ServiceUnavailableException({ + message: `Health degraded: db=${dependencies.database}, redis=${dependencies.redis}`, + code: "HEALTH_DEGRADED", + }); + } + + return { + status: "ok", + timestamp, + dependencies, + }; + } +} diff --git a/apps/backend/src/core/health/health.module.ts b/apps/backend/src/core/health/health.module.ts new file mode 100644 index 00000000..18ff9912 --- /dev/null +++ b/apps/backend/src/core/health/health.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; + +import { HealthController } from "./health.controller"; +import { PrismaModule } from "../prisma/prisma.module"; +import { RedisModule } from "../redis/redis.module"; + +@Module({ + imports: [PrismaModule, RedisModule], + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/apps/backend/src/core/logger/logger.module.ts b/apps/backend/src/core/logger/logger.module.ts new file mode 100644 index 00000000..67bbc69d --- /dev/null +++ b/apps/backend/src/core/logger/logger.module.ts @@ -0,0 +1,46 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { LoggerModule as PinoLoggerModule } from "nestjs-pino"; + +@Module({ + imports: [ + PinoLoggerModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => { + const isProduction = config.get("app.nodeEnv") === "production"; + + return { + forRoutes: [], + pinoHttp: { + autoLogging: true, + transport: isProduction + ? undefined + : { + target: "pino-pretty", + options: { + singleLine: true, + colorize: true, + translateTime: "SYS:standard", + ignore: "pid,hostname", + }, + }, + level: isProduction ? "info" : "debug", + redact: { + paths: [ + "req.headers.authorization", + "req.headers.cookie", + "req.body.password", + "req.body.token", + "req.body.refreshToken", + ], + remove: true, + }, + }, + }; + }, + }), + ], + exports: [PinoLoggerModule], +}) +export class LoggerModule {} diff --git a/apps/backend/src/core/prisma/prisma.module.ts b/apps/backend/src/core/prisma/prisma.module.ts new file mode 100644 index 00000000..58302cc2 --- /dev/null +++ b/apps/backend/src/core/prisma/prisma.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from "@nestjs/common"; + +import { PrismaService } from "./prisma.service"; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/backend/src/core/prisma/prisma.service.ts b/apps/backend/src/core/prisma/prisma.service.ts new file mode 100644 index 00000000..36fb0c39 --- /dev/null +++ b/apps/backend/src/core/prisma/prisma.service.ts @@ -0,0 +1,50 @@ +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from "@nestjs/common"; +import { PrismaClient } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { Pool } from "pg"; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + private readonly logger = new Logger(PrismaService.name); + private readonly pool: Pool; + + constructor() { + const databaseUrl = process.env.DATABASE_URL; + + if (!databaseUrl?.trim()) { + throw new Error("DATABASE_URL is not set"); + } + + const pool = new Pool({ + connectionString: databaseUrl, + max: 20, + idleTimeoutMillis: 30000, + }); + + super({ + adapter: new PrismaPg(pool), + }); + + this.pool = pool; + } + + async onModuleInit(): Promise { + this.logger.log("Connecting to database"); + await this.$connect(); + this.logger.log("Connected to database"); + } + + async onModuleDestroy(): Promise { + await this.$disconnect(); + await this.pool.end(); + this.logger.log("Disconnected from database"); + } +} diff --git a/apps/backend/src/core/redis/redis.module.spec.ts b/apps/backend/src/core/redis/redis.module.spec.ts new file mode 100644 index 00000000..9ded14e9 --- /dev/null +++ b/apps/backend/src/core/redis/redis.module.spec.ts @@ -0,0 +1,98 @@ +import { ConfigModule } from "@nestjs/config"; +import { Test } from "@nestjs/testing"; +import Redis from "ioredis"; + +import redisConfig from "../config/redis.config"; +import { REDIS_CLIENT, RedisModule } from "./redis.module"; + +const redisInstance = { + on: jest.fn(), + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + incr: jest.fn(), + expire: jest.fn(), + ping: jest.fn(), + exists: jest.fn(), + hincrby: jest.fn(), + hgetall: jest.fn(), + keys: jest.fn(), + disconnect: jest.fn(), +}; + +jest.mock("ioredis", () => jest.fn().mockImplementation(() => redisInstance)); + +describe("RedisModule", () => { + const compileWithRedisUrl = async (redisUrl: string | undefined) => { + const previousRedisUrl = process.env.REDIS_URL; + if (redisUrl) { + process.env.REDIS_URL = redisUrl; + } else { + delete process.env.REDIS_URL; + } + + try { + return await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + ignoreEnvFile: true, + isGlobal: true, + load: [redisConfig], + }), + RedisModule, + ], + }).compile(); + } finally { + if (previousRedisUrl) { + process.env.REDIS_URL = previousRedisUrl; + } else { + delete process.env.REDIS_URL; + } + } + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("accepts redis protocol URLs", async () => { + const moduleRef = await compileWithRedisUrl("redis://localhost:6379"); + + expect(moduleRef.get(REDIS_CLIENT)).toBeDefined(); + expect(Redis).toHaveBeenCalledWith( + "redis://localhost:6379", + expect.objectContaining({ tls: undefined }), + ); + + await moduleRef.close(); + }); + + it("accepts rediss protocol URLs", async () => { + const moduleRef = await compileWithRedisUrl( + "rediss://default:pass@example.com:6379", + ); + + expect(moduleRef.get(REDIS_CLIENT)).toBeDefined(); + expect(Redis).toHaveBeenCalledWith( + "rediss://default:pass@example.com:6379", + expect.objectContaining({ tls: {} }), + ); + + await moduleRef.close(); + }); + + it("rejects HTTPS Redis REST URLs", async () => { + await expect( + compileWithRedisUrl("https://example.upstash.io"), + ).rejects.toThrow("Invalid REDIS_URL for RedisModule"); + expect(Redis).not.toHaveBeenCalled(); + }); + + it("does not require a REST token for Redis protocol URLs", async () => { + const moduleRef = await compileWithRedisUrl("redis://localhost:6379"); + + expect(moduleRef.get(REDIS_CLIENT)).toBeDefined(); + + await moduleRef.close(); + }); +}); diff --git a/apps/backend/src/core/redis/redis.module.ts b/apps/backend/src/core/redis/redis.module.ts new file mode 100644 index 00000000..8cbd1f9e --- /dev/null +++ b/apps/backend/src/core/redis/redis.module.ts @@ -0,0 +1,175 @@ +import { Global, Logger, Module } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import Redis from "ioredis"; + +import { RedisClient, RedisService } from "./redis.service"; + +export const REDIS_CLIENT = "REDIS_CLIENT"; + +function sanitizeRedisUrl(url: string | undefined): string | undefined { + if (!url) { + return undefined; + } + + let cleanUrl = url.trim(); + + if (cleanUrl.startsWith("REDIS_URL=")) { + cleanUrl = cleanUrl.substring("REDIS_URL=".length); + } + + if ( + (cleanUrl.startsWith('"') && cleanUrl.endsWith('"')) || + (cleanUrl.startsWith("'") && cleanUrl.endsWith("'")) + ) { + cleanUrl = cleanUrl.substring(1, cleanUrl.length - 1); + } + + return cleanUrl.trim(); +} + +class IoredisClient implements RedisClient { + constructor(private readonly redis: Redis) {} + + async get(key: string): Promise { + return this.redis.get(key); + } + + async getDel(key: string): Promise { + const response = await this.redis.eval( + "local value = redis.call('GET', KEYS[1]); if value then redis.call('DEL', KEYS[1]); end; return value", + 1, + key, + ); + + return typeof response === "string" ? response : null; + } + + async set( + key: string, + value: string, + ttlSeconds?: number, + onlyIfNotExists = false, + ): Promise { + if (typeof ttlSeconds === "number" && onlyIfNotExists) { + return ( + (await this.redis.set(key, value, "EX", ttlSeconds, "NX")) === "OK" + ); + } + + const response = + typeof ttlSeconds === "number" + ? await this.redis.set(key, value, "EX", ttlSeconds) + : await this.redis.set(key, value); + + return response === "OK"; + } + + async del(key: string): Promise { + return this.redis.del(key); + } + + async incr(key: string): Promise { + return this.redis.incr(key); + } + + async expire(key: string, ttlSeconds: number): Promise { + return (await this.redis.expire(key, ttlSeconds)) === 1; + } + + async incrementWithExpiry(key: string, ttlSeconds: number): Promise { + const count = await this.redis.eval( + "local count = redis.call('INCR', KEYS[1]); if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]); end; return count", + 1, + key, + ttlSeconds, + ); + + return typeof count === "number" ? count : Number(count); + } + + async ping(): Promise<"PONG"> { + const response = await this.redis.ping(); + + if (response !== "PONG") { + throw new Error("Redis ping failed"); + } + + return "PONG"; + } + + async exists(key: string): Promise { + return this.redis.exists(key); + } + + async hIncrBy( + key: string, + field: string, + increment: number, + ): Promise { + return this.redis.hincrby(key, field, increment); + } + + async hGetAll(key: string): Promise> { + return this.redis.hgetall(key); + } + + async keys(pattern: string): Promise { + return this.redis.keys(pattern); + } + + disconnect(): void { + this.redis.disconnect(); + } +} + +@Global() +@Module({ + providers: [ + { + provide: REDIS_CLIENT, + inject: [ConfigService], + useFactory: (configService: ConfigService): RedisClient => { + const redisUrl = sanitizeRedisUrl( + configService.get("redis.url"), + ); + + if (!redisUrl) { + Logger.warn( + "REDIS_URL not found in config, falling back to localhost", + ); + + return new IoredisClient( + new Redis("redis://127.0.0.1:6379", { + enableReadyCheck: false, + maxRetriesPerRequest: null, + }), + ); + } + + // RFC 3986 schemes are case-insensitive; match HTTP/HTTPS in + // any casing so a `HTTPS://` URL isn't routed to the TCP client. + if (/^https?:\/\//i.test(redisUrl)) { + throw new Error( + "Invalid REDIS_URL for RedisModule: must use redis:// or rediss:// (TCP), not https:// (REST).", + ); + } + + const redis = new Redis(redisUrl, { + tls: /^rediss:\/\//i.test(redisUrl) ? {} : undefined, + family: 0, + maxRetriesPerRequest: null, + enableReadyCheck: false, + }); + + redis.on("error", (error: Error) => { + Logger.warn(`Redis connection error: ${error.message}`); + }); + + return new IoredisClient(redis); + }, + }, + RedisService, + ], + exports: [REDIS_CLIENT, RedisService], +}) +export class RedisModule {} diff --git a/apps/backend/src/core/redis/redis.service.ts b/apps/backend/src/core/redis/redis.service.ts new file mode 100644 index 00000000..f9ce8e42 --- /dev/null +++ b/apps/backend/src/core/redis/redis.service.ts @@ -0,0 +1,94 @@ +import { Inject, Injectable, OnModuleDestroy } from "@nestjs/common"; + +export interface RedisClient { + get(key: string): Promise; + getDel(key: string): Promise; + set( + key: string, + value: string, + ttlSeconds?: number, + onlyIfNotExists?: boolean, + ): Promise; + del(key: string): Promise; + incr(key: string): Promise; + expire(key: string, ttlSeconds: number): Promise; + incrementWithExpiry(key: string, ttlSeconds: number): Promise; + ping(): Promise<"PONG">; + exists(key: string): Promise; + hIncrBy(key: string, field: string, increment: number): Promise; + hGetAll(key: string): Promise>; + keys(pattern: string): Promise; + disconnect?(): void; +} + +@Injectable() +export class RedisService implements OnModuleDestroy { + constructor(@Inject("REDIS_CLIENT") private readonly redis: RedisClient) {} + + async get(key: string): Promise { + return this.redis.get(key); + } + + async getDel(key: string): Promise { + return this.redis.getDel(key); + } + + async set( + key: string, + value: string, + ttlSeconds?: number, + onlyIfNotExists = false, + ): Promise { + return this.redis.set(key, value, ttlSeconds, onlyIfNotExists); + } + + async del(key: string): Promise { + return this.redis.del(key); + } + + async incr(key: string): Promise { + return this.redis.incr(key); + } + + async expire(key: string, ttlSeconds: number): Promise { + return this.redis.expire(key, ttlSeconds); + } + + async incrementWithExpiry(key: string, ttlSeconds: number): Promise { + return this.redis.incrementWithExpiry(key, ttlSeconds); + } + + async ping(): Promise<"PONG"> { + const response = await this.redis.ping(); + + if (response !== "PONG") { + throw new Error("Redis ping failed"); + } + + return response; + } + + async exists(key: string): Promise { + return this.redis.exists(key); + } + + async hIncrBy( + key: string, + field: string, + increment: number, + ): Promise { + return this.redis.hIncrBy(key, field, increment); + } + + async hGetAll(key: string): Promise> { + return this.redis.hGetAll(key); + } + + async keys(pattern: string): Promise { + return this.redis.keys(pattern); + } + + async onModuleDestroy(): Promise { + this.redis.disconnect?.(); + } +} diff --git a/apps/backend/src/core/throttler/security-throttler.guard.spec.ts b/apps/backend/src/core/throttler/security-throttler.guard.spec.ts new file mode 100644 index 00000000..b4826245 --- /dev/null +++ b/apps/backend/src/core/throttler/security-throttler.guard.spec.ts @@ -0,0 +1,72 @@ +import { ThrottlerException } from "@nestjs/throttler"; + +import { SecurityThrottlerGuard } from "./security-throttler.guard"; + +describe("SecurityThrottlerGuard", () => { + it("records a safe risk signal for throttled sensitive auth routes", async () => { + const emitter = { recordSensitiveAuthAttempt: jest.fn() }; + const moduleRef = { get: jest.fn().mockReturnValue(emitter) }; + const guard = new SecurityThrottlerGuard( + [], + {} as never, + {} as never, + moduleRef as never, + ); + const securityContext = { + requestId: "req-1", + ipAddress: "203.0.113.10", + }; + const context = { + switchToHttp: () => ({ + getRequest: () => ({ + path: "/auth/forgot-password", + securityContext, + }), + }), + }; + + await expect( + ( + guard as unknown as { + throwThrottlingException: ( + context: unknown, + detail: unknown, + ) => Promise; + } + ).throwThrottlingException(context, {}), + ).rejects.toBeInstanceOf(ThrottlerException); + + expect(emitter.recordSensitiveAuthAttempt).toHaveBeenCalledWith({ + signalCode: "RATE_LIMIT_TRIGGERED", + flow: "password_reset", + securityContext, + alwaysRecord: true, + }); + }); + + it("does not emit an auth risk signal for unrelated throttled routes", async () => { + const emitter = { recordSensitiveAuthAttempt: jest.fn() }; + const guard = new SecurityThrottlerGuard( + [], + {} as never, + {} as never, + { get: jest.fn().mockReturnValue(emitter) } as never, + ); + const context = { + switchToHttp: () => ({ getRequest: () => ({ path: "/products" }) }), + }; + + await expect( + ( + guard as unknown as { + throwThrottlingException: ( + context: unknown, + detail: unknown, + ) => Promise; + } + ).throwThrottlingException(context, {}), + ).rejects.toBeInstanceOf(ThrottlerException); + + expect(emitter.recordSensitiveAuthAttempt).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/core/throttler/security-throttler.guard.ts b/apps/backend/src/core/throttler/security-throttler.guard.ts new file mode 100644 index 00000000..cc3b028f --- /dev/null +++ b/apps/backend/src/core/throttler/security-throttler.guard.ts @@ -0,0 +1,103 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ModuleRef, Reflector } from "@nestjs/core"; +import { + InjectThrottlerOptions, + InjectThrottlerStorage, + ThrottlerGuard, + type ThrottlerLimitDetail, + type ThrottlerModuleOptions, + type ThrottlerStorage, +} from "@nestjs/throttler"; +import type { ExecutionContext } from "@nestjs/common"; +import type { Request } from "express"; + +import type { RequestSecurityContext } from "../../common/security/request-security-context"; + +type AuthRiskSignalEmitter = { + recordSensitiveAuthAttempt(input: { + signalCode: "RATE_LIMIT_TRIGGERED"; + flow: + | "email_otp" + | "phone_otp" + | "password_reset" + | "login" + | "registration"; + securityContext?: RequestSecurityContext; + alwaysRecord: true; + }): Promise; +}; + +const AUTH_RISK_SIGNAL_EMITTER = "AUTH_RISK_SIGNAL_EMITTER"; + +@Injectable() +export class SecurityThrottlerGuard extends ThrottlerGuard { + private readonly logger = new Logger(SecurityThrottlerGuard.name); + + constructor( + @InjectThrottlerOptions() options: ThrottlerModuleOptions, + @InjectThrottlerStorage() storageService: ThrottlerStorage, + reflector: Reflector, + private readonly moduleRef: ModuleRef, + ) { + super(options, storageService, reflector); + } + + protected override async throwThrottlingException( + context: ExecutionContext, + throttlerLimitDetail: ThrottlerLimitDetail, + ): Promise { + const request = context.switchToHttp().getRequest(); + const flow = this.flowForPath(request.path || request.originalUrl || ""); + + if (flow) { + try { + const emitter = this.moduleRef.get( + AUTH_RISK_SIGNAL_EMITTER, + { strict: false }, + ); + void emitter.recordSensitiveAuthAttempt({ + signalCode: "RATE_LIMIT_TRIGGERED", + flow, + securityContext: request.securityContext, + alwaysRecord: true, + }); + } catch (error) { + this.logger.warn( + `Rate-limit risk signal unavailable: requestId=${request.securityContext?.requestId ?? "none"} error=${error instanceof Error ? error.name : "UnknownError"}`, + ); + } + } + + await super.throwThrottlingException(context, throttlerLimitDetail); + } + + private flowForPath( + path: string, + ): + | "email_otp" + | "phone_otp" + | "password_reset" + | "login" + | "registration" + | null { + const pathname = path.split("?")[0]; + if (pathname === "/auth/login") return "login"; + if (pathname === "/auth/email/resend") return "email_otp"; + if (pathname === "/auth/otp/send" || pathname === "/auth/otp/resend") { + return "phone_otp"; + } + if ( + pathname === "/auth/forgot-password" || + pathname === "/auth/reset-password" + ) { + return "password_reset"; + } + if ( + pathname === "/auth/register/email" || + pathname.endsWith("/google/onboarding/complete") + ) { + return "registration"; + } + return null; + } +} diff --git a/apps/backend/src/core/throttler/throttler.module.ts b/apps/backend/src/core/throttler/throttler.module.ts new file mode 100644 index 00000000..0127eb2d --- /dev/null +++ b/apps/backend/src/core/throttler/throttler.module.ts @@ -0,0 +1,25 @@ +import { Module } from "@nestjs/common"; +import { APP_GUARD } from "@nestjs/core"; +import { ThrottlerModule as NestThrottlerModule } from "@nestjs/throttler"; + +import { SecurityThrottlerGuard } from "./security-throttler.guard"; + +@Module({ + imports: [ + NestThrottlerModule.forRoot([ + { + ttl: 60000, + limit: 100, + }, + ]), + ], + providers: [ + { + provide: APP_GUARD, + useClass: SecurityThrottlerGuard, + }, + SecurityThrottlerGuard, + ], + exports: [NestThrottlerModule], +}) +export class ThrottlerModule {} diff --git a/apps/backend/src/domains/README.md b/apps/backend/src/domains/README.md new file mode 100644 index 00000000..44006a3c --- /dev/null +++ b/apps/backend/src/domains/README.md @@ -0,0 +1,116 @@ +# Backend Domain Module Ownership + +`AppModule` composes platform infrastructure, queues, and seven domain aggregators. These aggregators are ownership boundaries only; feature modules still own their controllers, providers, and business behavior. + +## Current Domain Map + +- `CommerceDomainModule`: catalogue and discovery surfaces: categories, native products, posts, feed, search, inventory, sourced products, and wishlist. +- `OrdersDomainModule`: order lifecycle and fulfillment surfaces: order, cart, disputes, and delivery. +- `MoneyDomainModule`: money movement: ledger, payment, payout, and DVA. +- `UsersTrustDomainModule`: user identity and store trust: users auth, legacy auth bridge, user profiles, store profiles, buyer/merchant bridges, and verification. +- `SocialDomainModule`: communications: current social notifications, legacy notification triggers/SMS bridge, and email delivery. +- `ChannelsDomainModule`: external channel entrypoints only: WhatsApp and USSD. +- `PlatformDomainModule`: operational platform concerns: admin, domain events, embeddings, upload/media moderation, and waitlist. + +## Boundary Rules + +- Payout belongs to `domains/money/payout` and is exposed through `MoneyDomainModule`. New imports should use the domain payout module/service path, not stale legacy module paths. +- Upload/media belongs to the platform domain. Channels may call upload behavior through explicit feature imports when needed, but channels should not own generic upload modules. +- Upload behavior belongs to `domains/platform/upload`; do not recreate legacy upload modules under `modules/upload`. +- Notifications and email belong to social/communications ownership. Channels can depend on them for delivery, but should not aggregate them as channel entrypoints. +- Channels are inbound adapters and processors for external channels. Do not add generic platform utilities to `ChannelsDomainModule`. +- Orders, cart, delivery, and disputes are grouped under `OrdersDomainModule`, not commerce catalogue ownership. +- Native product behavior belongs to `domains/commerce/product`; do not recreate legacy product modules under `modules/product`. +- Admin is platform/ops ownership and lives at `domains/platform/admin`; do not recreate legacy admin modules under `modules/admin`. +- Verification is users/trust ownership and lives at `domains/users/verification`; do not recreate legacy verification modules under `modules/verification`. +- Users/trust owns identity, store profiles, buyer/merchant bridges, and verification flows. +- Legacy modules may remain while the migration is incremental. Prefer importing through the domain aggregator or current domain path when a domain module exports the provider. + +## Merchant And Store Service Overlap Audit + +The merchant module now lives under `domains/users/store/merchant`, but it remains a compatibility surface. `/merchants/*`, `MerchantModule`, `MerchantController`, `MerchantService`, Prisma field names, and database fields remain unchanged until a separate compatibility migration is planned. + +New backend context code should prefer `CurrentStore`, `StoreContextMiddleware`, and `StoreVerifiedGuard`. Existing `CurrentMerchant`, `MerchantContextMiddleware`, and `MerchantVerifiedGuard` stay available while older order, inventory, and verification surfaces migrate gradually. + +### Responsibility Ownership + +| Responsibility | Current owner | Routes/callers | Recommendation | Risk | +| --- | --- | --- | --- | --- | +| Store creation and owner profile setup | `StoreService` | `POST /stores`, `GET /stores/me` | Remain canonical store-profile owner. | Medium | +| Store profile read/update | Split between `StoreService` and `MerchantService` | `/stores/me`, `/merchants/me` | Move later to `StoreProfileService`; make `MerchantService` delegate. | Medium | +| Store handle/username/slug update | Split: `StoreService` owns `storeHandle`; `MerchantService` owns legacy `slug` history | `/stores/*`, `/merchants/me/username`, `/merchants/lookup/:slug` | Consolidate handle/slug rules later after schema compatibility review. | High | +| Public store lookup and serialization | Split between `StoreService`, `MerchantService`, product/search/feed services | `/stores/:handle`, `/merchants/:id`, product/search/feed callers | Keep `StorePublicDto` as public serialization authority; migrate legacy public profile later. | High | +| Store list/public directory | `MerchantService` and search/feed services | `GET /merchants`, search/feed services | Decide whether directory belongs to search/feed or store profile service later. | Medium | +| Store balance summary | `MerchantService` | `GET /merchants/balance-summary` | Move later to money/dashboard boundary; exclude delivery fee when consolidating. | High | +| Bank resolve/list/update | Split between `StoreService` and `MerchantService` | `/stores/me/bank`, `/merchants/bank/*` | Move later to `StoreBankAccountService`; keep Paystack client behind one owner. | High | +| Store verification submit/status integration | Split between `MerchantService`, `VerificationService`, admin service | `/merchants/me/submit`, `/verification/*`, `/admin/merchants/*` | Store domain should bridge to verification domain; verification rules remain canonical in verification service. | High | +| Store analytics/dashboard metrics | `StoreService` and `MerchantAnalyticsService` | `/stores/me/stats`, `/merchants/me/analytics` | Move later to `StoreAnalyticsService`; reconcile metric definitions first. | Medium | +| Follow/unfollow/is-following | `MerchantService`; user profile follow logic also exists for users | `/merchants/:id/follow`, `/merchants/:id/is-following` | Move store follow behavior later to `StoreFollowService`; keep user follows separate. | Medium | +| Store preferences | `MerchantService` | `PATCH /merchants/me/preferences` | Move later to `StoreSettingsService`. | Low | +| Store context/current-store resolution | Compatibility aliases over merchant context utilities | order, inventory, verification, merchant controller | New code should use store aliases; old imports can migrate incrementally. | Low | +| Store ownership/authorization checks | Context middleware/guards plus per-service Prisma checks | order, inventory, product, verification | Centralize later only after route consumers are mapped. | Medium | +| Store profile public serialization | `StorePublicDto`, `MerchantService.getPublicProfile`, product/search/feed mappers | `/stores/:handle`, `/merchants/:id`, public product/search/feed | Treat `StorePublicDto` as canonical for shopper-safe store profile data. | High | +| Store dashboard/private serialization | `StoreService.toOwnerResponse`, `MerchantService.getProfile` | `/stores/me`, `/merchants/me` | Move later to `StoreProfileService` private serializer. | Medium | + +### Overlap Findings + +| Overlap | Current canonical owner | Callers | Difference | Consolidation timing | +| --- | --- | --- | --- | --- | +| Private store profile responses | `StoreService` for `/stores/*` | Web store setup/profile and compatibility merchant profile | `MerchantService` returns legacy fields such as slug/history and more raw store profile fields. | Defer until frontend clients are migrated or compatibility response is versioned. | +| Public store responses | `StorePublicDto` / `StoreService` | Public store pages, product/search/feed surfaces | `MerchantService.getPublicProfile` can expose legacy fields and gated contact data. | Defer; first define a single shopper-safe public DTO. | +| Bank account setup | Both `StoreService.updateBank` and `MerchantService.updateBankAccount` | `/stores/me/bank`, `/merchants/bank-account` | Different DTO names and response shapes; both create Paystack recipients and trigger tier checks. | Defer; create `StoreBankAccountService` with compatibility adapters. | +| Balance/revenue metrics | `StoreService.getStats` and `MerchantService.getBalanceSummary` | `/stores/me/stats`, `/merchants/balance-summary` | Store stats exclude delivery fee from gross revenue; merchant summary still returns protected-payment style balances. | Defer to money/dashboard audit to avoid financial regressions. | +| Analytics | `MerchantAnalyticsService` and `StoreService.getStats` | `/merchants/me/analytics`, `/stores/me/stats` | Different metric names and date filtering. | Defer; introduce `StoreAnalyticsService` after dashboard contract audit. | +| Verification submission | `VerificationService` is canonical for rules | `/merchants/me/submit`, `/verification/*`, admin review | Merchant submit only advances legacy onboarding/submission and notifies admins. | Defer; merchant facade should eventually call a store verification bridge. | +| Follow behavior | `MerchantService` currently owns store follow endpoints | Public profile/social callers | Store follow rows share the generic follow model with user follows. | Defer; move to `StoreFollowService` with notification expectations documented. | +| Handle/slug uniqueness | `StoreService` owns `storeHandle`; `MerchantService` owns legacy `slug` and history | `/stores/*`, `/merchants/lookup/:slug`, `/merchants/me/username` | Uses different fields and validation rules. | High-risk defer; needs schema/client compatibility plan. | + +### Proposed Future Boundary + +- `StoreProfileService`: profile read/update, handle/slug compatibility, branding/avatar/banner fields, public and private serializers. +- `StoreSettingsService`: preferences, open/closed configuration, sourcing and fulfillment settings. +- `StoreBankAccountService`: bank resolve/list/update, Paystack recipient readiness, and tier-check trigger after bank setup. +- `StoreAnalyticsService`: dashboard stats, date-filtered analytics, and financial metric naming that stays aligned with money-domain rules. +- `StoreFollowService`: follow, unfollow, is-following, and store-follow notification metadata. +- `StoreVerificationBridge`: submit/status integration that calls `VerificationService` and does not own KYB rules. +- `MerchantService`: temporary compatibility facade for `/merchants/*` routes that gradually delegates to store-named services. + +### Compatibility Routes + +These routes remain compatibility routes and must not be renamed in this audit phase: + +- `GET /merchants/me` +- `PATCH /merchants/me` +- `PATCH /merchants/me/username` +- `GET /merchants/lookup/:slug` +- `GET /merchants` +- `GET /merchants/balance-summary` +- `GET /merchants/:id` +- `GET /merchants/bank/resolve` +- `PATCH /merchants/bank-account` +- `GET /merchants/banks/list` +- `POST /merchants/me/submit` +- `GET /merchants/me/analytics` +- `POST /merchants/:id/follow` +- `DELETE /merchants/:id/follow` +- `GET /merchants/:id/is-following` +- `PATCH /merchants/me/preferences` + +Future route migration should introduce `/stores/*` aliases first, keep `/merchants/*` compatibility during a full client migration window, then deprecate old route names only after frontend, mobile, and integration clients are confirmed migrated. + +### Copy Audit + +Visible verification errors should use store language. Internal names such as `MerchantService`, `MerchantController`, method names, DTO file names, and compatibility routes may keep merchant naming until the dedicated compatibility migration. + +## Request Security Context + +Every HTTP request gets a normalized `RequestSecurityContext` at `req.securityContext` and an `x-request-id` response header. The context captures safe request metadata only: request id, validated client IP, IP source, user agent, origin, referer, method, path, coarse device type, trusted-proxy usage, and creation time. + +`TRUST_PROXY_HEADERS=false` is the default. When false, `cf-connecting-ip`, `x-forwarded-for`, and `x-real-ip` are ignored and the socket remote address is used. Set `TRUST_PROXY_HEADERS=true` only behind trusted infrastructure such as Cloudflare, Render, or Vercel. `TRUST_PROXY_HEADER_PROVIDER=standard` trusts standard proxy headers such as `x-forwarded-for`; use `TRUST_PROXY_HEADER_PROVIDER=cloudflare` only when Cloudflare is the trusted edge, because that is the only mode that honors `cf-connecting-ip`. + +Safe audit metadata must never include cookies, authorization headers, access or refresh tokens, webhook signatures, raw provider payloads, raw request bodies, passwords, OTPs, or NIN/CAC verification payloads. IP/location data is only a risk signal, never proof. Future integrations can use this context for auth login/session audit, payout risk review, admin action audit, payment/security investigations, webhook abuse monitoring, coarse geolocation if needed, and suspicious login/new-device alerts. +### Auth Login Audit + +Successful and failed email/password login attempts persist safe security context as auth `DomainEvent` records. Successful login events include the user id, session id, request id, validated IP metadata, user agent, origin, referer, method, path, and coarse device type. Failed login events store the same safe request metadata plus a safe failure reason code and a hash of the attempted identifier, never the raw password or request body. + +Request security context is not stored for every request. IP and location-derived signals are risk signals only, not proof. Never store or log passwords, OTPs, cookies, authorization headers, access tokens, refresh tokens, webhook signatures, raw request bodies, provider payloads, or NIN/CAC sensitive payloads in auth security audit metadata. Follow-ups can use this foundation for suspicious login/new-device alerts, payout request/admin release audit, and admin action audit. diff --git a/apps/backend/src/domains/channels.module.ts b/apps/backend/src/domains/channels.module.ts new file mode 100644 index 00000000..9bef8147 --- /dev/null +++ b/apps/backend/src/domains/channels.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; + +import { UssdModule } from "../channels/ussd/ussd.module"; +import { WhatsAppModule } from "../channels/whatsapp/whatsapp.module"; + +@Module({ + imports: [WhatsAppModule, UssdModule], +}) +export class ChannelsDomainModule {} diff --git a/apps/backend/src/domains/commerce.module.ts b/apps/backend/src/domains/commerce.module.ts new file mode 100644 index 00000000..a58a581e --- /dev/null +++ b/apps/backend/src/domains/commerce.module.ts @@ -0,0 +1,26 @@ +import { Module } from "@nestjs/common"; + +import { CategoryModule } from "./commerce/category/category.module"; +import { WishlistModule } from "./commerce/wishlist/wishlist.module"; +import { FeedModule } from "./commerce/feed/feed.module"; +import { InventoryModule } from "./commerce/inventory/inventory.module"; +import { PostModule } from "./commerce/post/post.module"; +import { ProductModule } from "./commerce/product/product.module"; +import { SearchModule } from "./commerce/search/search.module"; +import { CategoryDemandInsightsModule } from "./commerce/search/category-demand-insights.module"; +import { SourcedProductModule } from "./commerce/sourced-product/sourced-product.module"; + +@Module({ + imports: [ + CategoryModule, + ProductModule, + PostModule, + FeedModule, + SearchModule, + CategoryDemandInsightsModule, + InventoryModule, + SourcedProductModule, + WishlistModule, + ], +}) +export class CommerceDomainModule {} diff --git a/apps/backend/src/domains/commerce/category/category.controller.ts b/apps/backend/src/domains/commerce/category/category.controller.ts new file mode 100644 index 00000000..3e6b17c2 --- /dev/null +++ b/apps/backend/src/domains/commerce/category/category.controller.ts @@ -0,0 +1,53 @@ +import { + Controller, + Get, + Post, + Body, + Put, + Param, + Delete, + UseGuards, +} from "@nestjs/common"; +import { CategoryService } from "./category.service"; +import { CreateCategoryDto } from "./dto/create-category.dto"; +import { UpdateCategoryDto } from "./dto/update-category.dto"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { RolesGuard } from "../../../common/guards/roles.guard"; +import { Roles } from "../../../common/decorators/roles.decorator"; +import { UserRole } from "@twizrr/shared"; + +@Controller("categories") +export class CategoryController { + constructor(private readonly categoryService: CategoryService) {} + + @Get() + findAll() { + return this.categoryService.findTree(); + } + + @Get(":slug") + findBySlug(@Param("slug") slug: string) { + return this.categoryService.findBySlug(slug); + } + + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.SUPER_ADMIN) + create(@Body() dto: CreateCategoryDto) { + return this.categoryService.create(dto); + } + + @Put(":id") + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.SUPER_ADMIN) + update(@Param("id") id: string, @Body() dto: UpdateCategoryDto) { + return this.categoryService.update(id, dto); + } + + @Delete(":id") + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.SUPER_ADMIN) + remove(@Param("id") id: string) { + return this.categoryService.remove(id); + } +} diff --git a/apps/backend/src/modules/category/category.module.ts b/apps/backend/src/domains/commerce/category/category.module.ts similarity index 100% rename from apps/backend/src/modules/category/category.module.ts rename to apps/backend/src/domains/commerce/category/category.module.ts diff --git a/apps/backend/src/modules/category/category.service.ts b/apps/backend/src/domains/commerce/category/category.service.ts similarity index 94% rename from apps/backend/src/modules/category/category.service.ts rename to apps/backend/src/domains/commerce/category/category.service.ts index fd66282e..78ae4a3c 100644 --- a/apps/backend/src/modules/category/category.service.ts +++ b/apps/backend/src/domains/commerce/category/category.service.ts @@ -3,10 +3,10 @@ import { NotFoundException, BadRequestException, } from "@nestjs/common"; -import { PrismaService } from "../../prisma/prisma.service"; +import { PrismaService } from "../../../prisma/prisma.service"; import { CreateCategoryDto } from "./dto/create-category.dto"; import { UpdateCategoryDto } from "./dto/update-category.dto"; -import { slugify } from "../../common/utils/slugify"; +import { slugify } from "../../../common/utils/slugify"; @Injectable() export class CategoryService { @@ -88,9 +88,9 @@ export class CategoryService { return treeWithCounts; } - async findMerchantActiveCategories(merchantId: string) { + async findMerchantActiveCategories(storeId: string) { const products = await this.prisma.product.findMany({ - where: { merchantId, isActive: true, deletedAt: null }, + where: { storeId, isActive: true, deletedAt: null }, select: { categoryId: true }, distinct: ["categoryId"], }); diff --git a/apps/backend/src/modules/category/dto/create-category.dto.ts b/apps/backend/src/domains/commerce/category/dto/create-category.dto.ts similarity index 86% rename from apps/backend/src/modules/category/dto/create-category.dto.ts rename to apps/backend/src/domains/commerce/category/dto/create-category.dto.ts index c0bee821..378fe3c9 100644 --- a/apps/backend/src/modules/category/dto/create-category.dto.ts +++ b/apps/backend/src/domains/commerce/category/dto/create-category.dto.ts @@ -3,20 +3,20 @@ import { IsOptional, IsBoolean, IsInt, - IsUUID, MinLength, } from "class-validator"; export class CreateCategoryDto { @IsString() @MinLength(2) - name: string; + name!: string; @IsString() @IsOptional() slug?: string; - @IsUUID() + // Category ids are cuids, not UUIDs. + @IsString() @IsOptional() parentId?: string; diff --git a/apps/backend/src/modules/category/dto/update-category.dto.ts b/apps/backend/src/domains/commerce/category/dto/update-category.dto.ts similarity index 100% rename from apps/backend/src/modules/category/dto/update-category.dto.ts rename to apps/backend/src/domains/commerce/category/dto/update-category.dto.ts diff --git a/apps/backend/src/domains/commerce/feed/README.md b/apps/backend/src/domains/commerce/feed/README.md new file mode 100644 index 00000000..46715c53 --- /dev/null +++ b/apps/backend/src/domains/commerce/feed/README.md @@ -0,0 +1,4 @@ +# Feed Domain + +Feed ranking and discovery boundary for shopper product and store discovery. +To be built in the commerce domain. diff --git a/apps/backend/src/domains/commerce/feed/dto/feed-query.dto.ts b/apps/backend/src/domains/commerce/feed/dto/feed-query.dto.ts new file mode 100644 index 00000000..6f338b76 --- /dev/null +++ b/apps/backend/src/domains/commerce/feed/dto/feed-query.dto.ts @@ -0,0 +1,56 @@ +import { Type } from "class-transformer"; +import { + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from "class-validator"; + +export const FEED_DEFAULT_LIMIT = 20; +export const FEED_MAX_LIMIT = 50; + +export class FeedQueryDto { + @IsOptional() + @IsString() + @MaxLength(512) + cursor?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(FEED_MAX_LIMIT) + limit?: number; + + @IsOptional() + @IsString() + @MaxLength(64) + category?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + storeId?: string; + + @IsOptional() + @IsString() + @MaxLength(32) + type?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + city?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + state?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + area?: string; +} diff --git a/apps/backend/src/domains/commerce/feed/dto/feed-response.dto.ts b/apps/backend/src/domains/commerce/feed/dto/feed-response.dto.ts new file mode 100644 index 00000000..9398c319 --- /dev/null +++ b/apps/backend/src/domains/commerce/feed/dto/feed-response.dto.ts @@ -0,0 +1,170 @@ +// Feed response types — explicit, narrow shapes used by every endpoint. +// +// Privacy: every mapper builds these from a safe field subset. Internal +// fields (storeType, homeAddress, bank*, physicalStoreId, linkedOrderId, +// sourcedProductId, dropshipperCostKobo, digitalMarginKobo, raw NIN, raw +// User.interests) are never reachable from these shapes. + +import { StorePassPublicBadge } from "../../../money/storepass/types/storepass.types"; + +export type FeedTab = "for-you" | "following" | "stores" | "explore"; + +export type FeedItemType = + | "PRODUCT_POST" + | "IMAGE_POST" + | "GIST" + | "STORE_CARD" + | "PRODUCT_CARD" + | "HASHTAG"; + +export type FeedSection = + | "trending-hashtags" + | "trending-products" + | "new-verified-stores" + | "trending-posts"; + +export interface PublicStoreSummary { + id: string; + handle: string | null; + storeName: string | null; + logoUrl: string | null; + bannerUrl: string | null; + bio: string | null; + tier: string; // StoreTier — Tier 0 stores are filtered out before mapping + verificationTier: string; + followerCount: number; + completedOrders: number; + /** + * Paid StorePass growth-plan badge, or null when the store has no eligible + * badge. Optional so summary builders that do not hydrate it (safe surfaces + * that omit it) stay valid; the frontend treats undefined as "no badge". + */ + storePassBadge?: StorePassPublicBadge | null; +} + +export interface PublicCategorySummary { + id: string; + name: string; + slug: string; + icon: string | null; +} + +export interface PostFeedItem { + type: "PRODUCT_POST" | "IMAGE_POST" | "GIST"; + id: string; + createdAt: string; + text: string | null; + images: { url: string; order: number }[]; + hashtags: string[]; + store: PublicStoreSummary | null; + author: { + id: string | null; + displayName: string | null; + username: string | null; + profilePhotoUrl: string | null; + } | null; + product: PublicProductSummary | null; + engagement: { + likes: number; + comments: number; + saves: number; + // shares intentionally absent: Post has no share_count column and no + // share-tracking endpoint yet. The web treats a missing value as 0. + }; + /** Whether the requesting user has liked this post. False for anonymous feeds. */ + viewerHasLiked: boolean; + /** + * Whether the requesting user has saved this post. For product posts this + * reflects the tagged product being in the user's wishlist; for content posts + * it reflects a saved-post bookmark. False for anonymous feeds. + */ + viewerHasSaved: boolean; + /** + * Whether the requesting user follows the store that made this post. + * False for anonymous feeds, user (non-store) posts, and unfollowed stores. + * Drives the inline Follow chip on feed cards. + */ + viewerFollowsStore: boolean; + /** + * A person the requesting viewer follows who liked this post, if any — + * powers the "Liked by @x and N others" social-proof line. Null when no + * followed user liked it, or for anonymous feeds. Optional so cached + * responses from before this field shipped stay valid (treat undefined as + * null). + */ + likedByPreview?: { + id: string; + displayName: string | null; + username: string | null; + profilePhotoUrl: string | null; + } | null; + section?: FeedSection; +} + +export interface PublicProductSummary { + id: string; + productCode: string | null; + name: string; + shortDescription: string | null; + imageUrl: string | null; + retailPriceKobo: string | null; + compareAtPriceKobo: string | null; + categoryName: string | null; + stock: number; + store: PublicStoreSummary | null; +} + +export interface StoreCardFeedItem { + type: "STORE_CARD"; + id: string; + createdAt: string; + store: PublicStoreSummary; + section?: FeedSection; +} + +export interface ProductCardFeedItem { + type: "PRODUCT_CARD"; + id: string; + createdAt: string; + product: PublicProductSummary; + section?: FeedSection; +} + +export interface HashtagFeedItem { + type: "HASHTAG"; + id: string; + createdAt: string; + name: string; + postCount: number; + section?: FeedSection; +} + +export type FeedItem = + | PostFeedItem + | StoreCardFeedItem + | ProductCardFeedItem + | HashtagFeedItem; + +export type EmptyStateReason = "NO_FOLLOWS" | "NO_RESULTS"; + +export interface FeedResponse { + items: FeedItem[]; + nextCursor: string | null; + hasMore: boolean; + meta: { + tab: FeedTab; + generatedAt: string; + categories?: PublicCategorySummary[]; + emptyState?: EmptyStateReason; + }; +} + +/** + * The authenticated user's saved posts (bookmarks). Same post items as the feed + * so the client can render them with the shared feed card; lighter than + * FeedResponse since it has no tab/scoring meta. + */ +export interface SavedPostsResponse { + items: FeedItem[]; + nextCursor: string | null; +} diff --git a/apps/backend/src/domains/commerce/feed/feed-location-resolver.service.spec.ts b/apps/backend/src/domains/commerce/feed/feed-location-resolver.service.spec.ts new file mode 100644 index 00000000..4a6fdad8 --- /dev/null +++ b/apps/backend/src/domains/commerce/feed/feed-location-resolver.service.spec.ts @@ -0,0 +1,47 @@ +import { FeedLocationResolver } from "./feed-location-resolver.service"; + +describe("FeedLocationResolver", () => { + const prisma = { + userLocationPreference: { findUnique: jest.fn() }, + }; + const resolver = new FeedLocationResolver(prisma as any); + + beforeEach(() => jest.clearAllMocks()); + + it("keeps every explicit locality field for the post query to apply", async () => { + await expect( + resolver.resolve( + { state: " Lagos ", city: "Ikeja", area: "Central" }, + "user-1", + ), + ).resolves.toMatchObject({ + source: "explicit_query", + state: "Lagos", + city: "Ikeja", + area: "Central", + }); + expect(prisma.userLocationPreference.findUnique).not.toHaveBeenCalled(); + }); + + it("uses an active saved preference only without explicit query values", async () => { + prisma.userLocationPreference.findUnique.mockResolvedValue({ + countryCode: "NG", + state: "Lagos", + city: "Lagos", + area: "Yaba", + isActive: true, + }); + await expect(resolver.resolve({}, "user-1")).resolves.toMatchObject({ + source: "user_preference", + area: "Yaba", + city: "Lagos", + state: "Lagos", + }); + }); + + it("keeps guest feeds location-neutral without explicit filters", async () => { + await expect(resolver.resolve({})).resolves.toMatchObject({ + source: "none", + }); + }); +}); diff --git a/apps/backend/src/domains/commerce/feed/feed-location-resolver.service.ts b/apps/backend/src/domains/commerce/feed/feed-location-resolver.service.ts new file mode 100644 index 00000000..abd82740 --- /dev/null +++ b/apps/backend/src/domains/commerce/feed/feed-location-resolver.service.ts @@ -0,0 +1,71 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { FeedQueryDto } from "./dto/feed-query.dto"; + +export interface FeedLocationContext { + source: "explicit_query" | "user_preference" | "none"; + countryCode: string | null; + state: string | null; + city: string | null; + area: string | null; +} + +@Injectable() +export class FeedLocationResolver { + constructor(private readonly prisma: PrismaService) {} + async resolve( + query: FeedQueryDto, + userId?: string, + ): Promise { + const state = this.clean(query.state); + const city = this.clean(query.city); + const area = this.clean(query.area); + if (state || city || area) { + return { + source: "explicit_query", + countryCode: null, + state, + city, + area, + }; + } + if (!userId) + return { + source: "none", + countryCode: null, + state: null, + city: null, + area: null, + }; + const preference = await this.prisma.userLocationPreference.findUnique({ + where: { userId }, + select: { + countryCode: true, + state: true, + city: true, + area: true, + isActive: true, + }, + }); + if (!preference?.isActive) + return { + source: "none", + countryCode: null, + state: null, + city: null, + area: null, + }; + return { + source: "user_preference", + countryCode: preference.countryCode, + state: this.clean(preference.state), + city: this.clean(preference.city), + area: this.clean(preference.area), + }; + } + + private clean(value: string | null | undefined): string | null { + const normalized = value?.trim().replace(/\s+/g, " "); + return normalized && normalized.length <= 80 ? normalized : null; + } +} diff --git a/apps/backend/src/domains/commerce/feed/feed.controller.ts b/apps/backend/src/domains/commerce/feed/feed.controller.ts new file mode 100644 index 00000000..d42280a7 --- /dev/null +++ b/apps/backend/src/domains/commerce/feed/feed.controller.ts @@ -0,0 +1,55 @@ +import { Controller, Get, Query, UseGuards } from "@nestjs/common"; + +import { CurrentUser } from "../../../common/decorators/current-user.decorator"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { AuthenticatedRequestUser } from "../../users/auth/auth.types"; +import { FeedQueryDto } from "./dto/feed-query.dto"; +import { FeedService } from "./feed.service"; + +@Controller("feed") +export class FeedController { + constructor(private readonly feedService: FeedService) {} + + // Authenticated personalized feed. Falls back to explore-style results + // when scoring yields nothing. + @UseGuards(JwtAuthGuard) + @Get("for-you") + getForYou( + @CurrentUser() user: AuthenticatedRequestUser, + @Query() query: FeedQueryDto, + ) { + return this.feedService.getForYouFeed(user.id, query); + } + + // Authenticated chronological feed of followed stores + users. + @UseGuards(JwtAuthGuard) + @Get("following") + getFollowing( + @CurrentUser() user: AuthenticatedRequestUser, + @Query() query: FeedQueryDto, + ) { + return this.feedService.getFollowingFeed(user.id, query); + } + + // Authenticated list of the viewer's saved posts (bookmarks). + @UseGuards(JwtAuthGuard) + @Get("saved") + getSaved( + @CurrentUser() user: AuthenticatedRequestUser, + @Query() query: FeedQueryDto, + ) { + return this.feedService.getSavedPostsFeed(user.id, query); + } + + // Public verified-store discovery list. + @Get("stores") + getStores(@Query() query: FeedQueryDto) { + return this.feedService.getStoresFeed(query); + } + + // Public platform-wide discovery surface. + @Get("explore") + getExplore(@Query() query: FeedQueryDto) { + return this.feedService.getExploreFeed(query); + } +} diff --git a/apps/backend/src/domains/commerce/feed/feed.module.ts b/apps/backend/src/domains/commerce/feed/feed.module.ts new file mode 100644 index 00000000..da49ef5c --- /dev/null +++ b/apps/backend/src/domains/commerce/feed/feed.module.ts @@ -0,0 +1,17 @@ +import { Module } from "@nestjs/common"; + +import { PrismaModule } from "../../../core/prisma/prisma.module"; +import { StorePassModule } from "../../money/storepass/storepass.module"; +import { FeedController } from "./feed.controller"; +import { FeedService } from "./feed.service"; +import { FeedLocationResolver } from "./feed-location-resolver.service"; + +// RedisModule is @Global — RedisService is injected directly without an +// explicit import here. +@Module({ + imports: [PrismaModule, StorePassModule], + controllers: [FeedController], + providers: [FeedService, FeedLocationResolver], + exports: [FeedService], +}) +export class FeedModule {} diff --git a/apps/backend/src/domains/commerce/feed/feed.service.ts b/apps/backend/src/domains/commerce/feed/feed.service.ts new file mode 100644 index 00000000..7e107bcf --- /dev/null +++ b/apps/backend/src/domains/commerce/feed/feed.service.ts @@ -0,0 +1,1279 @@ +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import { + FollowTargetType, + ModerationStatus, + PostType, + Prisma, + StoreTier, +} from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { RedisService } from "../../../core/redis/redis.service"; +import { StorePassService } from "../../money/storepass/storepass.service"; +import { + FEED_DEFAULT_LIMIT, + FEED_MAX_LIMIT, + FeedQueryDto, +} from "./dto/feed-query.dto"; +import { + EmptyStateReason, + FeedItem, + FeedResponse, + FeedSection, + FeedTab, + PostFeedItem, + PublicCategorySummary, + PublicProductSummary, + PublicStoreSummary, + SavedPostsResponse, +} from "./dto/feed-response.dto"; +import { + FeedLocationContext, + FeedLocationResolver, +} from "./feed-location-resolver.service"; + +// Public feeds only surface moderated-safe content. PENDING and SENSITIVE +// are excluded from the MVP public surfaces per the B-24 plan. +const PUBLIC_SAFE_MODERATION: ModerationStatus[] = [ + ModerationStatus.SAFE, + ModerationStatus.APPROVED, +]; + +// Tier 0 is the lowest verification tier; we exclude these stores from +// every shopper-facing feed surface. +const VERIFIED_STORE_TIERS: StoreTier[] = [StoreTier.TIER_1, StoreTier.TIER_2]; + +// Only built-out post types are surfaced. TWIZZ (video) is not yet +// implemented in the platform — surfacing it would leak unfinished UX. +const SHOPPER_POST_TYPES: PostType[] = [ + PostType.PRODUCT_POST, + PostType.IMAGE_POST, + PostType.GIST, +]; + +// Redis TTLs in seconds. Keep narrow — fresh enough for an MVP feed. +const EXPLORE_CACHE_TTL = 300; +const STORES_CACHE_TTL = 300; +const FOR_YOU_CACHE_TTL = 60; + +// How far back the for-you trending candidate source looks. +const TRENDING_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +// Typed include builders — Prisma.validator preserves nested-include typing +// so the resulting PostGetPayload exposes `hashtags[i].hashtag`, etc. +const POST_INCLUDE = Prisma.validator()({ + author: { + select: { + id: true, + displayName: true, + username: true, + profilePhotoUrl: true, + }, + }, + images: { + orderBy: { order: "asc" }, + select: { url: true, order: true, moderationStatus: true }, + }, + hashtags: { include: { hashtag: true } }, + taggedProduct: { + include: { + category: true, + storeProfile: true, + productStockCaches: { + where: { variantId: null }, + take: 1, + select: { stock: true }, + }, + }, + }, + // Engagement comes from the denormalized Post counters (scalars are always + // returned by `include`) — never COUNT(*) the relations on the feed path. +}); + +type PostRow = Prisma.PostGetPayload<{ include: typeof POST_INCLUDE }>; +type PlainStore = Prisma.StoreProfileGetPayload; + +// Decoded cursor shape. createdAt is ISO-string; id is the row id of the +// last returned record for that tab. +interface CursorPayload { + id: string; + createdAt: string; + tab: FeedTab; +} + +interface RankedFeedItem { + item: FeedItem; + score: number; +} + +// A followed user who liked a post — the "Liked by @x" preview actor. +interface LikerPreview { + id: string; + displayName: string | null; + username: string | null; + profilePhotoUrl: string | null; +} + +// Per-viewer engagement state for a page of posts. Empty for anonymous feeds. +interface ViewerPostState { + likedPostIds: Set; + savedPostIds: Set; + wishlistedProductIds: Set; + followedStoreIds: Set; + // postId → one followed user who liked it (most recent), if any. + likedByPreviewByPostId: Map; +} + +const EMPTY_VIEWER_STATE: ViewerPostState = { + likedPostIds: new Set(), + savedPostIds: new Set(), + wishlistedProductIds: new Set(), + followedStoreIds: new Set(), + likedByPreviewByPostId: new Map(), +}; + +@Injectable() +export class FeedService { + private readonly logger = new Logger(FeedService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly redis: RedisService, + private readonly storePass: StorePassService, + private readonly feedLocationResolver: FeedLocationResolver, + ) {} + + // ─── PUBLIC ENTRYPOINTS ──────────────────────────────────────────────────── + + async getFollowingFeed( + userId: string, + query: FeedQueryDto, + ): Promise { + const tab: FeedTab = "following"; + const locationContext = await this.feedLocationResolver.resolve( + query, + userId, + ); + const limit = this.resolveLimit(query.limit); + const cursor = this.decodeCursor(query.cursor, tab); + + // FollowRelation is the canonical follow source — user.service.ts + // writes only to it. The legacy `Follow` model is read-only in + // product.service and is not used for MVP feed. + const follows = await this.prisma.followRelation.findMany({ + where: { followerId: userId }, + select: { + targetUserId: true, + targetStoreId: true, + targetType: true, + }, + }); + const followedUserIds = follows + .filter((f) => f.targetType === FollowTargetType.USER && f.targetUserId) + .map((f) => f.targetUserId as string); + const followedStoreIds = follows + .filter((f) => f.targetType === FollowTargetType.STORE && f.targetStoreId) + .map((f) => f.targetStoreId as string); + + if (followedUserIds.length === 0 && followedStoreIds.length === 0) { + return this.emptyResponse(tab, "NO_FOLLOWS"); + } + + // Filter followed stores to verified tiers — Tier 0 followed stores + // are still kept out of the feed surface. + const verifiedFollowedStoreIds = + await this.filterVerifiedStoreIds(followedStoreIds); + + const where: Prisma.PostWhereInput = { + isActive: true, + moderationStatus: { in: PUBLIC_SAFE_MODERATION }, + type: { in: SHOPPER_POST_TYPES }, + OR: [ + ...(verifiedFollowedStoreIds.length + ? [{ storeId: { in: verifiedFollowedStoreIds } }] + : []), + ...(followedUserIds.length + ? [{ authorId: { in: followedUserIds } }] + : []), + ], + ...this.cursorPostFilter(cursor), + ...this.applyPostQueryFilters(query, locationContext), + }; + + if ((where.OR as unknown[]).length === 0) { + return this.emptyResponse(tab, "NO_RESULTS"); + } + + const posts = await this.prisma.post.findMany({ + where, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: limit + 1, + include: POST_INCLUDE, + }); + + const pagePosts = posts.slice(0, limit); + const [storeMap, viewerState] = await Promise.all([ + this.loadStoresForPosts(posts), + this.loadViewerPostState(userId, pagePosts), + ]); + const items = pagePosts + .map((p) => this.mapPostToFeedItem(p, storeMap, undefined, viewerState)) + .filter((item): item is PostFeedItem => item !== null); + + const hasMore = posts.length > limit; + const lastReturned = posts[limit - 1]; + const nextCursor = hasMore + ? this.encodeCursor({ + id: lastReturned.id, + createdAt: lastReturned.createdAt.toISOString(), + tab, + }) + : null; + + await this.hydrateStorePassBadges(items); + + return { + items, + nextCursor, + hasMore, + meta: { + tab, + generatedAt: new Date().toISOString(), + ...(items.length === 0 ? { emptyState: "NO_RESULTS" as const } : {}), + }, + }; + } + + // Store posts only — product and social posts made by verified stores. + // (Store discovery / "follow a store" lives in the right panel, not here.) + async getStoresFeed(query: FeedQueryDto): Promise { + const tab: FeedTab = "stores"; + const locationContext = await this.feedLocationResolver.resolve(query); + const cacheKey = this.buildCacheKey(tab, query, undefined, locationContext); + const cached = await this.redisGet(cacheKey); + if (cached) return cached; + + const limit = this.resolveLimit(query.limit); + const cursor = this.decodeCursor(query.cursor, tab); + const verifiedStoreIds = await this.loadVerifiedStoreIds(); + + const posts = await this.prisma.post.findMany({ + where: { + isActive: true, + moderationStatus: { in: PUBLIC_SAFE_MODERATION }, + type: { in: SHOPPER_POST_TYPES }, + storeId: { + in: verifiedStoreIds.length + ? verifiedStoreIds + : ["__no_store_match__"], + }, + ...this.cursorPostFilter(cursor), + ...this.applyPostQueryFilters(query, locationContext), + }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: limit + 1, + include: POST_INCLUDE, + }); + + const pagePosts = posts.slice(0, limit); + const storeMap = await this.loadStoresForPosts(pagePosts); + const items = pagePosts + .map((p) => this.mapPostToFeedItem(p, storeMap)) + .filter((item): item is PostFeedItem => item !== null); + + const hasMore = posts.length > limit; + const nextCursor = hasMore + ? this.encodeCursor({ + id: pagePosts[limit - 1].id, + createdAt: pagePosts[limit - 1].createdAt.toISOString(), + tab, + }) + : null; + + await this.hydrateStorePassBadges(items); + + const response: FeedResponse = { + items, + nextCursor, + hasMore, + meta: { + tab, + generatedAt: new Date().toISOString(), + ...(items.length === 0 ? { emptyState: "NO_RESULTS" as const } : {}), + }, + }; + await this.redisSet(cacheKey, response, STORES_CACHE_TTL); + return response; + } + + // Broad discovery stream — recent posts from stores and regular users across + // the platform (not personalized). Store/product discovery cards live in the + // right panel, not in the feed stream. + async getExploreFeed(query: FeedQueryDto): Promise { + const tab: FeedTab = "explore"; + const locationContext = await this.feedLocationResolver.resolve(query); + const cacheKey = this.buildCacheKey(tab, query, undefined, locationContext); + const cached = await this.redisGet(cacheKey); + if (cached) return cached; + + const limit = this.resolveLimit(query.limit); + const cursor = this.decodeCursor(query.cursor, tab); + const verifiedStoreIds = await this.loadVerifiedStoreIds(); + + const [posts, categories] = await Promise.all([ + this.prisma.post.findMany({ + where: { + isActive: true, + moderationStatus: { in: PUBLIC_SAFE_MODERATION }, + AND: [ + this.feedPostVisibility(verifiedStoreIds), + ...(cursor ? [this.cursorPostFilter(cursor)] : []), + this.applyPostQueryFilters(query, locationContext), + ], + }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: limit + 1, + include: POST_INCLUDE, + }), + this.prisma.category.findMany({ + where: { isActive: true }, + orderBy: [{ sortOrder: "asc" }, { name: "asc" }], + take: 24, + }), + ]); + + const pagePosts = posts.slice(0, limit); + const storeMap = await this.loadStoresForPosts(pagePosts); + const items = pagePosts + .map((p) => this.mapPostToFeedItem(p, storeMap)) + .filter((item): item is PostFeedItem => item !== null); + + const hasMore = posts.length > limit; + const nextCursor = hasMore + ? this.encodeCursor({ + id: pagePosts[limit - 1].id, + createdAt: pagePosts[limit - 1].createdAt.toISOString(), + tab, + }) + : null; + + const publicCategories: PublicCategorySummary[] = categories.map((c) => ({ + id: c.id, + name: c.name, + slug: c.slug, + icon: c.icon, + })); + + await this.hydrateStorePassBadges(items); + + const response: FeedResponse = { + items, + nextCursor, + hasMore, + meta: { + tab, + generatedAt: new Date().toISOString(), + categories: publicCategories, + ...(items.length === 0 ? { emptyState: "NO_RESULTS" as const } : {}), + }, + }; + await this.redisSet(cacheKey, response, EXPLORE_CACHE_TTL); + return response; + } + + async getForYouFeed( + userId: string, + query: FeedQueryDto, + ): Promise { + const tab: FeedTab = "for-you"; + const locationContext = await this.feedLocationResolver.resolve( + query, + userId, + ); + const cacheKey = this.buildCacheKey(tab, query, userId, locationContext); + const cached = await this.redisGet(cacheKey); + if (cached) return cached; + + const limit = this.resolveLimit(query.limit); + const cursor = this.decodeCursor(query.cursor, tab); + + // Pull viewer signal: interests + followed stores/users. Interests + // are an internal personalization input — never echoed to the client. + const [viewer, follows, verifiedStoreIds] = await Promise.all([ + this.prisma.user.findUnique({ + where: { id: userId }, + select: { interests: true }, + }), + this.prisma.followRelation.findMany({ + where: { followerId: userId }, + select: { targetUserId: true, targetStoreId: true, targetType: true }, + }), + this.loadVerifiedStoreIds(), + ]); + const followedUserIds = new Set( + follows + .filter((f) => f.targetType === FollowTargetType.USER && f.targetUserId) + .map((f) => f.targetUserId as string), + ); + const followedStoreIds = new Set( + follows + .filter( + (f) => f.targetType === FollowTargetType.STORE && f.targetStoreId, + ) + .map((f) => f.targetStoreId as string), + ); + const interests = (viewer?.interests ?? []).map((i) => i.toLowerCase()); + + const baseWhere: Prisma.PostWhereInput = { + isActive: true, + moderationStatus: { in: PUBLIC_SAFE_MODERATION }, + AND: [ + this.feedPostVisibility(verifiedStoreIds), + ...(cursor ? [this.cursorPostFilter(cursor)] : []), + this.applyPostQueryFilters(query, locationContext), + ], + }; + + // Multi-source candidate selection. A single recency window means the + // ranker can only reorder the newest posts — as volume grows, followed + // stores and interests would never surface. Instead, merge bounded windows + // per source (all share baseWhere: visibility + moderation + cursor): + // a) recency — also drives pagination (hasMore / nextCursor) + // b) followed — recent posts from followed stores/users + // c) trending — top engagement in the last 7 days (cheap via the + // denormalized counters) + // d) interest — posts matching the viewer's interest tags + const windowSize = Math.min(limit * 3, FEED_MAX_LIMIT * 3); + const followedFilters: Prisma.PostWhereInput[] = []; + if (followedStoreIds.size > 0) { + followedFilters.push({ storeId: { in: [...followedStoreIds] } }); + } + if (followedUserIds.size > 0) { + followedFilters.push({ authorId: { in: [...followedUserIds] } }); + } + + const [recencyPosts, followedPosts, trendingPosts, interestPosts] = + await Promise.all([ + this.prisma.post.findMany({ + where: baseWhere, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: windowSize, + include: POST_INCLUDE, + }), + followedFilters.length > 0 + ? this.prisma.post.findMany({ + where: { AND: [baseWhere, { OR: followedFilters }] }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: windowSize, + include: POST_INCLUDE, + }) + : Promise.resolve([]), + this.prisma.post.findMany({ + where: { + AND: [ + baseWhere, + { + createdAt: { + gte: new Date(Date.now() - TRENDING_WINDOW_MS), + }, + }, + ], + }, + orderBy: [{ likeCount: "desc" }, { createdAt: "desc" }], + take: windowSize, + include: POST_INCLUDE, + }), + interests.length > 0 + ? this.prisma.post.findMany({ + where: { + AND: [baseWhere, this.interestCandidateFilter(interests)], + }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: windowSize, + include: POST_INCLUDE, + }) + : Promise.resolve([]), + ]); + + // Merge and dedupe by id; the scorer decides the final order. + const seenPostIds = new Set(); + const posts: typeof recencyPosts = []; + for (const post of [ + ...recencyPosts, + ...followedPosts, + ...trendingPosts, + ...interestPosts, + ]) { + if (!seenPostIds.has(post.id)) { + seenPostIds.add(post.id); + posts.push(post); + } + } + + if (posts.length === 0) { + // Safe fallback per the plan: behave like explore when no scoring + // input produces results. + const explore = await this.getExploreFeed(query); + return { ...explore, meta: { ...explore.meta, tab } }; + } + + const [storeMap, viewerState] = await Promise.all([ + this.loadStoresForPosts(posts), + this.loadViewerPostState(userId, posts), + ]); + const ranked: RankedFeedItem[] = []; + const now = Date.now(); + for (const post of posts) { + const mapped = this.mapPostToFeedItem( + post, + storeMap, + undefined, + viewerState, + ); + if (!mapped) continue; + const recencyHours = + (now - new Date(post.createdAt).getTime()) / (1000 * 60 * 60); + // Inverse-log recency boost, larger for fresher content. + const recency = 1 / Math.log2(2 + recencyHours); + const engagement = + mapped.engagement.likes * 1.0 + + mapped.engagement.comments * 1.5 + + mapped.engagement.saves * 2.0; + const postStore = post.storeId + ? (storeMap.get(post.storeId) ?? null) + : null; + const tierBoost = + postStore?.tier === StoreTier.TIER_2 + ? 1.5 + : postStore?.tier === StoreTier.TIER_1 + ? 1.0 + : 0.0; + const followBoost = + (post.storeId && followedStoreIds.has(post.storeId)) || + (post.authorId && followedUserIds.has(post.authorId)) + ? 2.0 + : 0.0; + const interestBoost = this.scoreInterests(post, postStore, interests); + const locationBoost = this.scoreLocation(postStore, locationContext); + ranked.push({ + item: mapped, + score: + recency * 3 + + Math.log2(1 + engagement) + + tierBoost + + followBoost + + interestBoost + + locationBoost, + }); + } + + ranked.sort((a, b) => b.score - a.score); + const diversified = this.diversifyByType( + ranked.map((r) => r.item), + limit, + ); + + const oldest = + diversified.length > 0 + ? [...diversified].sort((a, b) => + a.createdAt.localeCompare(b.createdAt), + )[0] + : null; + // Pagination is driven by the recency stream: a full recency window means + // there are older posts to walk into, regardless of the other sources. + const hasMore = recencyPosts.length >= windowSize; + const nextCursor = + hasMore && oldest + ? this.encodeCursor({ + id: oldest.id, + createdAt: oldest.createdAt, + tab, + }) + : null; + + await this.hydrateStorePassBadges(diversified); + + const response: FeedResponse = { + items: diversified, + nextCursor, + hasMore, + meta: { + tab, + generatedAt: new Date().toISOString(), + ...(diversified.length === 0 + ? { emptyState: "NO_RESULTS" as const } + : {}), + }, + }; + await this.redisSet(cacheKey, response, FOR_YOU_CACHE_TTL); + return response; + } + + // ─── HELPERS ────────────────────────────────────────────────────────────── + + private resolveLimit(raw: number | undefined): number { + if (raw === undefined || raw === null) return FEED_DEFAULT_LIMIT; + if (raw < 1) return 1; + if (raw > FEED_MAX_LIMIT) return FEED_MAX_LIMIT; + return raw; + } + + private emptyResponse( + tab: FeedTab, + emptyState: EmptyStateReason, + ): FeedResponse { + return { + items: [], + nextCursor: null, + hasMore: false, + meta: { + tab, + generatedAt: new Date().toISOString(), + emptyState, + }, + }; + } + + // Resolve the set of verified store IDs. Used in WHERE filters on Post + // (which has no relation to StoreProfile in the schema). + private async loadVerifiedStoreIds(): Promise { + const rows = await this.prisma.storeProfile.findMany({ + where: { tier: { in: VERIFIED_STORE_TIERS } }, + select: { id: true }, + }); + return rows.map((r) => r.id); + } + + private async filterVerifiedStoreIds(ids: string[]): Promise { + if (ids.length === 0) return []; + const rows = await this.prisma.storeProfile.findMany({ + where: { id: { in: ids }, tier: { in: VERIFIED_STORE_TIERS } }, + select: { id: true }, + }); + return rows.map((r) => r.id); + } + + // Batch-load stores for a set of posts so the mapper can build store + // summaries without N+1 queries. + private async loadStoresForPosts( + posts: PostRow[], + ): Promise> { + const ids = Array.from( + new Set( + posts.map((p) => p.storeId).filter((id): id is string => Boolean(id)), + ), + ); + if (ids.length === 0) return new Map(); + const stores = await this.prisma.storeProfile.findMany({ + where: { id: { in: ids } }, + }); + const map = new Map(); + for (const s of stores) map.set(s.id, s); + return map; + } + + private cursorPostFilter( + cursor: CursorPayload | null, + ): Prisma.PostWhereInput { + if (!cursor) return {}; + const cursorDate = new Date(cursor.createdAt); + return { + OR: [ + { createdAt: { lt: cursorDate } }, + { createdAt: cursorDate, id: { lt: cursor.id } }, + ], + }; + } + + /** + * Which posts may appear on the shopper-facing feeds. This is a social feed + * with commerce embedded, so it surfaces: + * - PRODUCT_POST: only from verified (Tier 1/2) stores — the commerce-trust + * rule keeps Tier 0 store product posts out of the feed. + * - IMAGE_POST / GIST: social posts from anyone (stores or regular users). + */ + private feedPostVisibility( + verifiedStoreIds: string[], + ): Prisma.PostWhereInput { + return { + OR: [ + { + type: PostType.PRODUCT_POST, + storeId: { + in: verifiedStoreIds.length + ? verifiedStoreIds + : ["__no_store_match__"], + }, + }, + { type: { in: [PostType.IMAGE_POST, PostType.GIST] } }, + ], + }; + } + + /** + * DB-side candidate filter for the for-you interest source. Deliberately + * coarser than scoreInterests (which does fuzzy substring matching on the + * loaded rows) — this only needs to pull plausible candidates into the pool; + * the scorer decides how much they matter. + */ + private interestCandidateFilter(interests: string[]): Prisma.PostWhereInput { + return { + OR: [ + { + taggedProduct: { + platformCategory: { in: interests, mode: "insensitive" }, + }, + }, + { + taggedProduct: { + categoryTag: { in: interests, mode: "insensitive" }, + }, + }, + { + hashtags: { + some: { hashtag: { name: { in: interests, mode: "insensitive" } } }, + }, + }, + ], + }; + } + + private applyPostQueryFilters( + query: FeedQueryDto, + locationContext?: FeedLocationContext, + ): Prisma.PostWhereInput { + const filters: Prisma.PostWhereInput[] = []; + if (query.storeId) { + filters.push({ storeId: query.storeId }); + } + if (query.type && SHOPPER_POST_TYPES.includes(query.type as PostType)) { + filters.push({ type: query.type as PostType }); + } + if (query.category) { + filters.push({ + OR: [ + { taggedProduct: { platformCategory: query.category } }, + { taggedProduct: { category: { slug: query.category } } }, + ], + }); + } + if (locationContext?.source === "explicit_query") { + const addressTerms = [ + locationContext.state, + locationContext.city, + locationContext.area, + ].filter((value): value is string => value !== null); + filters.push({ + store: { + AND: addressTerms.map((term) => ({ + businessAddress: { contains: term, mode: "insensitive" }, + })), + }, + }); + } + return filters.length ? { AND: filters } : {}; + } + + private mapStoreToPublic( + store: { + id: string; + storeHandle: string | null; + storeName: string | null; + logoUrl: string | null; + bannerUrl: string | null; + bio: string | null; + tier: StoreTier; + verificationTier: string; + completedOrders: number; + }, + extra: { followerCount: number }, + ): PublicStoreSummary { + // Only safe fields. Explicitly excludes storeType, homeAddress, + // bank*, ninNumber, paystackRecipientCode, NIN attempts, etc. + return { + id: store.id, + handle: store.storeHandle, + storeName: store.storeName, + logoUrl: store.logoUrl, + bannerUrl: store.bannerUrl, + bio: store.bio, + tier: store.tier, + verificationTier: store.verificationTier, + followerCount: extra.followerCount, + completedOrders: store.completedOrders, + }; + } + + /** + * Collects every store summary referenced by a feed item (store cards, post + * headers, and product-card stores) so badges can be hydrated in one batch. + */ + private collectStoreSummaries(items: FeedItem[]): PublicStoreSummary[] { + const summaries: PublicStoreSummary[] = []; + for (const item of items) { + if ("store" in item && item.store) { + summaries.push(item.store); + } + if ("product" in item && item.product?.store) { + summaries.push(item.product.store); + } + } + return summaries; + } + + /** + * Hydrates the safe StorePass badge onto every store summary in a feed page + * using a single batched lookup — no per-store (N+1) query. + */ + /** + * The authenticated user's saved posts (bookmarks) — social posts they saved + * from the feed, newest-saved first. Reuses the feed post mapper so bookmarks + * render identically to feed posts. Product posts are wishlisted (not saved), + * so they do not appear here. + */ + async getSavedPostsFeed( + userId: string, + query: FeedQueryDto, + ): Promise { + const locationContext = await this.feedLocationResolver.resolve( + query, + userId, + ); + const limit = this.resolveLimit(query.limit); + const cursor = this.decodeSavedCursor(query.cursor); + + const savedRows = await this.prisma.savedPost.findMany({ + where: { + userId, + post: { + isActive: true, + moderationStatus: { in: PUBLIC_SAFE_MODERATION }, + type: { in: SHOPPER_POST_TYPES }, + ...this.applyPostQueryFilters(query, locationContext), + }, + ...(cursor + ? { + OR: [ + { createdAt: { lt: cursor.savedAt } }, + { createdAt: cursor.savedAt, id: { lt: cursor.id } }, + ], + } + : {}), + }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: limit + 1, + include: { post: { include: POST_INCLUDE } }, + }); + + const pageRows = savedRows.slice(0, limit); + const pagePosts = pageRows.map((row) => row.post); + const [storeMap, viewerState] = await Promise.all([ + this.loadStoresForPosts(pagePosts), + this.loadViewerPostState(userId, pagePosts), + ]); + const items = pagePosts + .map((post) => + this.mapPostToFeedItem(post, storeMap, undefined, viewerState), + ) + .filter((item): item is PostFeedItem => item !== null); + + await this.hydrateStorePassBadges(items); + + const hasMore = savedRows.length > limit; + const last = pageRows[pageRows.length - 1]; + const nextCursor = + hasMore && last + ? this.encodeSavedCursor({ + savedAt: last.createdAt.toISOString(), + id: last.id, + }) + : null; + + return { items, nextCursor }; + } + + private encodeSavedCursor(payload: { savedAt: string; id: string }): string { + return Buffer.from(JSON.stringify(payload)).toString("base64url"); + } + + private decodeSavedCursor( + cursor?: string, + ): { savedAt: Date; id: string } | null { + if (!cursor) { + return null; + } + try { + const parsed = JSON.parse( + Buffer.from(cursor, "base64url").toString(), + ) as { savedAt?: unknown; id?: unknown }; + if (typeof parsed.savedAt !== "string" || typeof parsed.id !== "string") { + return null; + } + const savedAt = new Date(parsed.savedAt); + if (Number.isNaN(savedAt.getTime())) { + return null; + } + return { savedAt, id: parsed.id }; + } catch { + return null; + } + } + + private async hydrateStorePassBadges(items: FeedItem[]): Promise { + const summaries = this.collectStoreSummaries(items); + if (summaries.length === 0) { + return; + } + const badges = await this.storePass.resolveStorePassPublicBadgesForStores( + summaries.map((summary) => summary.id), + ); + for (const summary of summaries) { + summary.storePassBadge = badges.get(summary.id) ?? null; + } + } + + // Batch-loads the viewer's like / save / wishlist state for a page of posts + // so feed cards render the correct filled state without an N+1 per card. + private async loadViewerPostState( + userId: string, + posts: PostRow[], + ): Promise { + const postIds = posts.map((p) => p.id); + if (postIds.length === 0) return EMPTY_VIEWER_STATE; + + const productIds = Array.from( + new Set( + posts + .filter((p) => p.type === PostType.PRODUCT_POST && p.taggedProductId) + .map((p) => p.taggedProductId as string), + ), + ); + + const storeIds = Array.from( + new Set( + posts.map((p) => p.storeId).filter((id): id is string => Boolean(id)), + ), + ); + + const [likes, saves, wishlist, storeFollows, followedUsers] = + await Promise.all([ + this.prisma.like.findMany({ + where: { userId, postId: { in: postIds } }, + select: { postId: true }, + }), + this.prisma.savedPost.findMany({ + where: { userId, postId: { in: postIds } }, + select: { postId: true }, + }), + productIds.length + ? this.prisma.savedProduct.findMany({ + where: { userId, productId: { in: productIds } }, + select: { productId: true }, + }) + : Promise.resolve([] as { productId: string }[]), + // FollowRelation is the canonical follow source (see getFollowingFeed). + storeIds.length + ? this.prisma.followRelation.findMany({ + where: { followerId: userId, targetStoreId: { in: storeIds } }, + select: { targetStoreId: true }, + }) + : Promise.resolve([] as { targetStoreId: string | null }[]), + // Users the viewer follows — used to surface a followed liker per post. + this.prisma.followRelation.findMany({ + where: { + followerId: userId, + targetType: FollowTargetType.USER, + targetUserId: { not: null }, + }, + select: { targetUserId: true }, + }), + ]); + + // "Liked by @someone-you-follow" — pick, per post, the most recent like + // left by a user the viewer follows. Scoped to the page's postIds so the + // scan stays small even for viewers who follow many people. + const followedUserIds = followedUsers + .map((f) => f.targetUserId) + .filter((id): id is string => Boolean(id)); + const likedByPreviewByPostId = new Map(); + if (followedUserIds.length > 0) { + const followedLikes = await this.prisma.like.findMany({ + where: { postId: { in: postIds }, userId: { in: followedUserIds } }, + orderBy: { createdAt: "desc" }, + select: { + postId: true, + user: { + select: { + id: true, + displayName: true, + username: true, + profilePhotoUrl: true, + }, + }, + }, + }); + for (const like of followedLikes) { + if (like.user && !likedByPreviewByPostId.has(like.postId)) { + likedByPreviewByPostId.set(like.postId, { + id: like.user.id, + displayName: like.user.displayName, + username: like.user.username, + profilePhotoUrl: like.user.profilePhotoUrl, + }); + } + } + } + + return { + likedPostIds: new Set(likes.map((l) => l.postId)), + savedPostIds: new Set(saves.map((s) => s.postId)), + wishlistedProductIds: new Set(wishlist.map((w) => w.productId)), + followedStoreIds: new Set( + storeFollows + .map((f) => f.targetStoreId) + .filter((id): id is string => Boolean(id)), + ), + likedByPreviewByPostId, + }; + } + + private mapPostToFeedItem( + post: PostRow, + storeMap: Map, + section?: FeedSection, + viewerState: ViewerPostState = EMPTY_VIEWER_STATE, + ): PostFeedItem | null { + if (post.type === PostType.TWIZZ) return null; + const safeImages = (post.images ?? []) + .filter( + (img) => + img.moderationStatus === ModerationStatus.SAFE || + img.moderationStatus === ModerationStatus.APPROVED, + ) + .map((img) => ({ url: img.url, order: img.order })); + + const productSummary: PublicProductSummary | null = post.taggedProduct + ? { + id: post.taggedProduct.id, + productCode: post.taggedProduct.productCode, + name: post.taggedProduct.name, + shortDescription: post.taggedProduct.shortDescription, + imageUrl: post.taggedProduct.imageUrl, + retailPriceKobo: + post.taggedProduct.retailPriceKobo?.toString() ?? null, + compareAtPriceKobo: + post.taggedProduct.compareAtPriceKobo?.toString() ?? null, + categoryName: post.taggedProduct.category?.name ?? null, + stock: post.taggedProduct.productStockCaches?.[0]?.stock ?? 0, + store: post.taggedProduct.storeProfile + ? this.mapStoreToPublic(post.taggedProduct.storeProfile, { + followerCount: 0, + }) + : null, + } + : null; + + const postStore = post.storeId + ? (storeMap.get(post.storeId) ?? null) + : null; + return { + type: post.type as "PRODUCT_POST" | "IMAGE_POST" | "GIST", + id: post.id, + createdAt: post.createdAt.toISOString(), + text: post.text, + images: safeImages, + hashtags: (post.hashtags ?? []) + .map((h) => h.hashtag?.name) + .filter((name): name is string => Boolean(name)), + store: postStore + ? this.mapStoreToPublic(postStore, { followerCount: 0 }) + : null, + author: post.author + ? { + id: post.author.id, + displayName: post.author.displayName, + username: post.author.username, + profilePhotoUrl: post.author.profilePhotoUrl, + } + : null, + product: productSummary, + engagement: { + likes: post.likeCount, + comments: post.commentCount, + saves: post.saveCount, + }, + viewerHasLiked: viewerState.likedPostIds.has(post.id), + viewerHasSaved: + post.type === PostType.PRODUCT_POST + ? Boolean( + post.taggedProductId && + viewerState.wishlistedProductIds.has(post.taggedProductId), + ) + : viewerState.savedPostIds.has(post.id), + viewerFollowsStore: Boolean( + post.storeId && viewerState.followedStoreIds.has(post.storeId), + ), + likedByPreview: viewerState.likedByPreviewByPostId.get(post.id) ?? null, + ...(section ? { section } : {}), + }; + } + + private scoreInterests( + post: PostRow, + postStore: PlainStore | null, + interests: string[], + ): number { + if (interests.length === 0) return 0; + const haystack = [ + post.text ?? "", + post.taggedProduct?.platformCategory ?? "", + post.taggedProduct?.categoryTag ?? "", + ...(post.taggedProduct?.storeTags ?? []), + postStore?.businessCategory ?? "", + ...(post.hashtags ?? []).map((h) => h.hashtag?.name ?? ""), + ] + .join(" ") + .toLowerCase(); + let hits = 0; + for (const interest of interests) { + if (interest && haystack.includes(interest)) hits++; + } + return hits === 0 ? 0 : 1 + Math.log2(1 + hits); + } + + private diversifyByType(items: FeedItem[], limit: number): FeedItem[] { + // Round-robin by content type to prevent a single type from dominating. + const buckets = new Map(); + for (const item of items) { + const existing = buckets.get(item.type) ?? []; + existing.push(item); + buckets.set(item.type, existing); + } + const result: FeedItem[] = []; + let progress = true; + while (result.length < limit && progress) { + progress = false; + for (const list of buckets.values()) { + const next = list.shift(); + if (next) { + result.push(next); + progress = true; + if (result.length >= limit) break; + } + } + } + return result; + } + + // ─── CURSOR ─────────────────────────────────────────────────────────────── + + private encodeCursor(payload: CursorPayload): string { + return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); + } + + private decodeCursor( + raw: string | undefined, + expectedTab: FeedTab, + ): CursorPayload | null { + if (!raw) return null; + let parsed: unknown; + try { + parsed = JSON.parse( + Buffer.from(raw, "base64url").toString("utf8"), + ) as unknown; + } catch { + throw new BadRequestException({ + message: "Invalid cursor", + code: "FEED_CURSOR_INVALID", + }); + } + if ( + !parsed || + typeof parsed !== "object" || + typeof (parsed as CursorPayload).id !== "string" || + typeof (parsed as CursorPayload).createdAt !== "string" || + (parsed as CursorPayload).tab !== expectedTab + ) { + throw new BadRequestException({ + message: "Invalid cursor", + code: "FEED_CURSOR_INVALID", + }); + } + const date = new Date((parsed as CursorPayload).createdAt); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException({ + message: "Invalid cursor", + code: "FEED_CURSOR_INVALID", + }); + } + return parsed as CursorPayload; + } + + // ─── REDIS (best-effort) ────────────────────────────────────────────────── + + private buildCacheKey( + tab: FeedTab, + query: FeedQueryDto, + userId?: string, + locationContext?: FeedLocationContext, + ): string { + const parts = [ + `feed:${tab}`, + userId ? `u:${userId}` : "anon", + `lim:${this.resolveLimit(query.limit)}`, + query.cursor ? `c:${query.cursor.slice(0, 64)}` : "c:-", + query.category ? `cat:${query.category}` : "cat:-", + query.storeId ? `s:${query.storeId}` : "s:-", + query.type ? `t:${query.type}` : "t:-", + query.city ? `ci:${query.city}` : "ci:-", + query.state ? `st:${query.state}` : "st:-", + query.area ? `ar:${query.area}` : "ar:-", + `ls:${locationContext?.source ?? "none"}`, + `la:${locationContext?.area ?? "-"}`, + `lc:${locationContext?.city ?? "-"}`, + `lt:${locationContext?.state ?? "-"}`, + ]; + return parts.join("|"); + } + + private scoreLocation( + store: PlainStore | null, + context: FeedLocationContext, + ): number { + if (context.source !== "user_preference" || !store?.businessAddress) + return 0; + const address = store.businessAddress.toLowerCase(); + if (context.area && address.includes(context.area.toLowerCase())) + return 1.5; + if (context.city && address.includes(context.city.toLowerCase())) + return 1.0; + if (context.state && address.includes(context.state.toLowerCase())) + return 0.5; + return 0; + } + + private async redisGet(key: string): Promise { + try { + const raw = await this.redis.get(key); + if (!raw) return null; + return JSON.parse(raw) as FeedResponse; + } catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + this.logger.warn(`Feed cache get failed (${key}): ${message}`); + return null; + } + } + + private async redisSet( + key: string, + value: FeedResponse, + ttlSeconds: number, + ): Promise { + try { + await this.redis.set(key, JSON.stringify(value), ttlSeconds); + } catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + this.logger.warn(`Feed cache set failed (${key}): ${message}`); + } + } +} diff --git a/apps/backend/src/domains/commerce/inventory/dto/adjust-stock.dto.ts b/apps/backend/src/domains/commerce/inventory/dto/adjust-stock.dto.ts new file mode 100644 index 00000000..3b1391a0 --- /dev/null +++ b/apps/backend/src/domains/commerce/inventory/dto/adjust-stock.dto.ts @@ -0,0 +1,49 @@ +import { Type } from "class-transformer"; +import { + IsIn, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + ValidateIf, +} from "class-validator"; +import { InventoryEventType } from "@prisma/client"; + +const ADJUSTABLE_INVENTORY_EVENT_TYPES = [ + InventoryEventType.INITIAL_STOCK, + InventoryEventType.RESTOCK, + InventoryEventType.SALE_DEDUCT, + InventoryEventType.SALE_RESTORE, + InventoryEventType.ADJUSTMENT, +]; + +export class AdjustStockDto { + @IsString() + productId!: string; + + @IsOptional() + @IsString() + variantId?: string; + + @IsIn(ADJUSTABLE_INVENTORY_EVENT_TYPES) + type!: InventoryEventType; + + @Type(() => Number) + @IsInt() + quantity!: number; + + @IsOptional() + @IsString() + @MaxLength(120) + reference?: string; + + @ValidateIf( + (dto: AdjustStockDto) => + dto.type === InventoryEventType.ADJUSTMENT || dto.note !== undefined, + ) + @IsString() + @IsNotEmpty() + @MaxLength(500) + note?: string; +} diff --git a/apps/backend/src/modules/inventory/dto/update-stock.dto.ts b/apps/backend/src/domains/commerce/inventory/dto/update-stock.dto.ts similarity index 89% rename from apps/backend/src/modules/inventory/dto/update-stock.dto.ts rename to apps/backend/src/domains/commerce/inventory/dto/update-stock.dto.ts index 0df47888..458a50c4 100644 --- a/apps/backend/src/modules/inventory/dto/update-stock.dto.ts +++ b/apps/backend/src/domains/commerce/inventory/dto/update-stock.dto.ts @@ -3,7 +3,7 @@ import { IsInt, IsString, IsOptional, IsNotEmpty } from "class-validator"; export class UpdateStockDto { @IsInt() @IsNotEmpty() - quantity: number; + quantity!: number; @IsOptional() @IsString() diff --git a/apps/backend/src/domains/commerce/inventory/inventory.controller.ts b/apps/backend/src/domains/commerce/inventory/inventory.controller.ts new file mode 100644 index 00000000..de156d63 --- /dev/null +++ b/apps/backend/src/domains/commerce/inventory/inventory.controller.ts @@ -0,0 +1,62 @@ +import { + Controller, + Get, + Body, + Param, + UseGuards, + Post, + Query, +} from "@nestjs/common"; +import { InventoryService } from "./inventory.service"; +import { UpdateStockDto } from "./dto/update-stock.dto"; +import { PaginationQueryDto } from "../../../common/dto/pagination-query.dto"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { RolesGuard } from "../../../common/guards/roles.guard"; +import { Roles } from "../../../common/decorators/roles.decorator"; +import { CurrentMerchant } from "../../../common/decorators/current-merchant.decorator"; +import { UserRole } from "@twizrr/shared"; + +@Controller("inventory") +@UseGuards(JwtAuthGuard, RolesGuard) +export class InventoryController { + constructor(private readonly inventoryService: InventoryService) {} + + @Get("products/:productId") + @Roles(UserRole.USER) + getHistory( + @CurrentMerchant() storeId: string, + @Param("productId") productId: string, + @Query() query: PaginationQueryDto, + ) { + return this.inventoryService.getHistory( + storeId, + productId, + query.page, + query.limit, + ); + } + + @Get("products/:productId/stock") + @Roles(UserRole.USER) + getStockLevel( + @CurrentMerchant() storeId: string, + @Param("productId") productId: string, + ) { + return this.inventoryService.getStockLevel(storeId, productId); + } + + @Post("products/:productId/adjust") + @Roles(UserRole.USER) + adjustStock( + @CurrentMerchant() storeId: string, + @Param("productId") productId: string, + @Body() dto: UpdateStockDto, + ) { + return this.inventoryService.manualAdjustment( + storeId, + productId, + dto.quantity, + dto.notes, + ); + } +} diff --git a/apps/backend/src/domains/commerce/inventory/inventory.module.ts b/apps/backend/src/domains/commerce/inventory/inventory.module.ts new file mode 100644 index 00000000..62880d4e --- /dev/null +++ b/apps/backend/src/domains/commerce/inventory/inventory.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; + +import { PrismaModule } from "../../../core/prisma/prisma.module"; +import { InventoryController } from "./inventory.controller"; +import { InventoryService } from "./inventory.service"; + +@Module({ + imports: [PrismaModule], + controllers: [InventoryController], + providers: [InventoryService], + exports: [InventoryService], +}) +export class InventoryModule {} diff --git a/apps/backend/src/domains/commerce/inventory/inventory.service.spec.ts b/apps/backend/src/domains/commerce/inventory/inventory.service.spec.ts new file mode 100644 index 00000000..268291d6 --- /dev/null +++ b/apps/backend/src/domains/commerce/inventory/inventory.service.spec.ts @@ -0,0 +1,87 @@ +import { InventoryService } from "./inventory.service"; + +type StockCacheMock = { + updateMany: jest.Mock; + create: jest.Mock; +}; + +function makeInventoryService(options?: { + cacheExists?: boolean; + hasVariants?: boolean; +}) { + const cacheExists = options?.cacheExists ?? false; + const hasVariants = options?.hasVariants ?? false; + + const productStockCache: StockCacheMock = { + // count 1 => a cache row already exists; 0 => it is missing. + updateMany: jest.fn().mockResolvedValue({ count: cacheExists ? 1 : 0 }), + create: jest.fn().mockResolvedValue({}), + }; + + const tx = { + product: { + findUnique: jest.fn().mockResolvedValue({ + id: "product-1", + storeId: "store-1", + hasVariants, + }), + }, + inventoryEvent: { + create: jest.fn().mockResolvedValue({}), + }, + productStockCache, + }; + + const prisma = { + $transaction: jest.fn(async (cb: (client: typeof tx) => unknown) => cb(tx)), + }; + + const service = new InventoryService(prisma as never); + + return { service, tx, productStockCache }; +} + +describe("InventoryService manual adjustment stock cache seeding", () => { + it("creates the stock cache row when a manual adjustment finds none", async () => { + const { service, productStockCache } = makeInventoryService({ + cacheExists: false, + }); + + await expect( + service.manualAdjustment("store-1", "product-1", 5, "Restock count"), + ).resolves.toEqual({ message: "Stock adjusted" }); + + // The row was missing, so the adjustment must seed it instead of throwing + // STOCK_CACHE_MISSING. + expect(productStockCache.create).toHaveBeenCalledWith({ + data: { productId: "product-1", variantId: null, stock: 5, quantity: 5 }, + }); + }); + + it("increments the existing stock cache row without recreating it", async () => { + const { service, productStockCache } = makeInventoryService({ + cacheExists: true, + }); + + await service.manualAdjustment("store-1", "product-1", 3, "Add stock"); + + expect(productStockCache.updateMany).toHaveBeenCalledTimes(1); + expect(productStockCache.create).not.toHaveBeenCalled(); + }); + + it("does not seed an aggregate row for a variant product", async () => { + const { service, productStockCache } = makeInventoryService({ + cacheExists: false, + hasVariants: true, + }); + + // Seeding a (productId, null) row here would misreport availability, so a + // missing aggregate must surface STOCK_CACHE_MISSING rather than be created. + await expect( + service.manualAdjustment("store-1", "product-1", 5, "Add stock"), + ).rejects.toMatchObject({ + response: expect.objectContaining({ code: "STOCK_CACHE_MISSING" }), + }); + expect(productStockCache.create).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/domains/commerce/inventory/inventory.service.ts b/apps/backend/src/domains/commerce/inventory/inventory.service.ts new file mode 100644 index 00000000..35535e40 --- /dev/null +++ b/apps/backend/src/domains/commerce/inventory/inventory.service.ts @@ -0,0 +1,439 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InventoryEventType, Prisma } from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; + +const STOCK_ADDING_EVENTS = new Set([ + InventoryEventType.INITIAL_STOCK, + InventoryEventType.RESTOCK, +]); + +@Injectable() +export class InventoryService { + constructor(private readonly prisma: PrismaService) {} + async releaseStock( + productId: string, + storeId: string, + quantity: number, + orderId: string, + variantId: string | null = null, + ): Promise { + await this.prisma.$transaction(async (tx) => { + const product = await this.findStoreProduct(tx, storeId, productId); + + await tx.inventoryEvent.create({ + data: { + productId: product.id, + variantId, + storeId, + eventType: InventoryEventType.ORDER_RELEASED, + quantity, + referenceId: orderId, + notes: "Order cancellation release", + }, + }); + + await this.updateStockCacheInTransaction( + tx, + productId, + variantId, + quantity, + true, + ); + }); + } + + async releaseStockBatch( + items: { productId: string; quantity: number }[], + storeId: string, + orderId: string, + ): Promise { + await this.prisma.$transaction(async (tx) => { + for (const item of items) { + const product = await this.findStoreProduct( + tx, + storeId, + item.productId, + ); + + await tx.inventoryEvent.create({ + data: { + productId: product.id, + variantId: null, + storeId, + eventType: InventoryEventType.ORDER_RELEASED, + quantity: item.quantity, + referenceId: orderId, + notes: "Order cancellation release (batch)", + }, + }); + + await this.updateStockCacheInTransaction( + tx, + item.productId, + null, + item.quantity, + true, + ); + } + }); + } + + async manualAdjustment( + storeId: string, + productId: string, + quantity: number, + notes?: string, + tx?: Prisma.TransactionClient, + ): Promise<{ message: string }> { + const run = async (client: Prisma.TransactionClient) => { + const { hasVariants } = await this.findStoreProduct( + client, + storeId, + productId, + ); + // Only seed the missing (productId, null) aggregate row for products + // without variants. Variant products keep their stock on per-variant cache + // rows; a product-level row would misreport availability — listings sum + // every cache and would show the product in stock while checkout, which + // reserves the selected variant's row, still finds zero. + await this.recordEventInTransaction( + client, + productId, + null, + InventoryEventType.ADJUSTMENT, + quantity, + undefined, + notes || "Manual adjustment", + !hasVariants, + ); + }; + + if (tx) { + await run(tx); + } else { + await this.prisma.$transaction(run); + } + + return { message: "Stock adjusted" }; + } + + async getStockLevel(storeId: string, productId: string) { + await this.findStoreProduct(this.prisma, storeId, productId); + + const cache = await this.prisma.productStockCache.findFirst({ + where: { productId, variantId: null }, + }); + + return { + productId, + stock: cache?.stock ?? 0, + updatedAt: cache?.updatedAt ?? null, + }; + } + + async getHistory( + storeId: string, + productId: string, + page: number, + limit: number, + ) { + await this.findStoreProduct(this.prisma, storeId, productId); + + const skip = (page - 1) * limit; + const [data, total] = await Promise.all([ + this.prisma.inventoryEvent.findMany({ + where: { productId }, + skip, + take: limit, + orderBy: { createdAt: "desc" }, + }), + this.prisma.inventoryEvent.count({ where: { productId } }), + ]); + + return { data, meta: { page, limit, total } }; + } + + async recordEvent( + productId: string, + variantId: string | null | undefined, + type: InventoryEventType, + quantity: number, + reference?: string, + note?: string, + ): Promise { + await this.prisma.$transaction((tx) => + this.recordEventInTransaction( + tx, + productId, + variantId ?? null, + type, + quantity, + reference, + note, + ), + ); + } + + async recordEventInTransaction( + tx: Prisma.TransactionClient, + productId: string, + variantId: string | null, + type: InventoryEventType, + quantity: number, + reference?: string, + note?: string, + createCacheIfMissing?: boolean, + ): Promise { + const product = await tx.product.findUnique({ + where: { id: productId }, + select: { id: true, storeId: true }, + }); + + if (!product) { + throw new NotFoundException({ + message: "Product not found", + code: "PRODUCT_NOT_FOUND", + }); + } + + if (variantId !== null && variantId.trim() === "") { + throw new BadRequestException({ + message: "Variant does not belong to this product", + code: "PRODUCT_VARIANT_INVALID", + }); + } + + if (variantId !== null) { + const variant = await tx.productVariant.findFirst({ + where: { id: variantId, productId }, + select: { id: true }, + }); + + if (!variant) { + throw new BadRequestException({ + message: "Variant does not belong to this product", + code: "PRODUCT_VARIANT_INVALID", + }); + } + } + + const delta = this.resolveStockDelta(type, quantity, note); + // A manual ADJUSTMENT is the merchant editing a product's stock. The cache row + // may not exist yet (drafts, variant products, or anything created before the + // row was seeded), so let the caller opt into creating it on a positive delta + // instead of throwing STOCK_CACHE_MISSING. Falls back to the event-type default. + const createIfMissing = + createCacheIfMissing ?? STOCK_ADDING_EVENTS.has(type); + + await tx.inventoryEvent.create({ + data: { + productId, + variantId, + storeId: product.storeId, + eventType: type, + quantity: delta, + referenceId: reference ?? null, + notes: note ?? null, + }, + }); + + await this.updateStockCacheInTransaction( + tx, + productId, + variantId, + delta, + createIfMissing, + ); + } + + async getStock( + productId: string, + variantId?: string | null, + ): Promise { + const cache = await this.prisma.productStockCache.findFirst({ + where: { productId, variantId: variantId ?? null }, + select: { stock: true }, + }); + + return cache?.stock ?? 0; + } + + async updateStockCache( + productId: string, + variantId: string | null | undefined, + delta: number, + ): Promise { + await this.prisma.$transaction((tx) => + this.updateStockCacheInTransaction( + tx, + productId, + variantId ?? null, + delta, + false, + ), + ); + } + + async checkStockAvailable( + productId: string, + variantId: string | null | undefined, + quantity: number, + ): Promise { + if (quantity <= 0) { + return false; + } + + const cache = await this.prisma.productStockCache.findFirst({ + where: { productId, variantId: variantId ?? null }, + select: { stock: true }, + }); + + return (cache?.stock ?? 0) >= quantity; + } + + private async updateStockCacheInTransaction( + tx: Prisma.TransactionClient, + productId: string, + variantId: string | null, + delta: number, + createIfMissing: boolean, + ): Promise { + if (delta < 0) { + const decrement = Math.abs(delta); + const result = await tx.productStockCache.updateMany({ + where: { + productId, + variantId, + stock: { gte: decrement }, + }, + data: { + stock: { decrement }, + quantity: { decrement }, + }, + }); + + if (result.count !== 1) { + throw new BadRequestException({ + message: "Insufficient stock", + code: "INSUFFICIENT_STOCK", + }); + } + + return; + } + + const result = await tx.productStockCache.updateMany({ + where: { productId, variantId }, + data: { + stock: { increment: delta }, + quantity: { increment: delta }, + }, + }); + + if (result.count === 1) { + return; + } + + if (!createIfMissing) { + throw new BadRequestException({ + message: "Stock cache is missing", + code: "STOCK_CACHE_MISSING", + }); + } + + await tx.productStockCache.create({ + data: { + productId, + variantId, + stock: delta, + quantity: delta, + }, + }); + } + + private resolveStockDelta( + type: InventoryEventType, + quantity: number, + note?: string, + ): number { + if (!Number.isInteger(quantity)) { + throw new BadRequestException({ + message: "Stock quantity must be an integer", + code: "STOCK_QUANTITY_INVALID", + }); + } + + switch (type) { + case InventoryEventType.INITIAL_STOCK: + if (quantity < 0) { + throw new BadRequestException({ + message: "Initial stock cannot be negative", + code: "INITIAL_STOCK_INVALID", + }); + } + return quantity; + case InventoryEventType.RESTOCK: + case InventoryEventType.SALE_RESTORE: + if (quantity <= 0) { + throw new BadRequestException({ + message: "Stock quantity must be positive", + code: "STOCK_QUANTITY_INVALID", + }); + } + return quantity; + case InventoryEventType.SALE_DEDUCT: + if (quantity <= 0) { + throw new BadRequestException({ + message: "Stock quantity must be positive", + code: "STOCK_QUANTITY_INVALID", + }); + } + return -quantity; + case InventoryEventType.ADJUSTMENT: + if (quantity === 0 || !note) { + throw new BadRequestException({ + message: "Adjustment requires a non-zero quantity and note", + code: "ADJUSTMENT_INVALID", + }); + } + return quantity; + default: + throw new BadRequestException({ + message: "Unsupported inventory event type", + code: "INVENTORY_EVENT_TYPE_INVALID", + }); + } + } + private async findStoreProduct( + tx: Prisma.TransactionClient | PrismaService, + storeId: string, + productId: string, + ): Promise<{ id: string; hasVariants: boolean }> { + const product = await tx.product.findUnique({ + where: { id: productId }, + select: { id: true, storeId: true, hasVariants: true }, + }); + + if (!product) { + throw new NotFoundException({ + message: "Product not found", + code: "PRODUCT_NOT_FOUND", + }); + } + + if (product.storeId !== storeId) { + throw new ForbiddenException({ + message: "Access denied", + code: "PRODUCT_ACCESS_DENIED", + }); + } + + return { id: product.id, hasVariants: product.hasVariants }; + } +} diff --git a/apps/backend/src/domains/commerce/post/README.md b/apps/backend/src/domains/commerce/post/README.md new file mode 100644 index 00000000..fc3b27b1 --- /dev/null +++ b/apps/backend/src/domains/commerce/post/README.md @@ -0,0 +1,5 @@ +# Post Domain + +Social feed post boundary for product posts, image posts, gists, and future Twizz entries. +To be built in the commerce domain. + diff --git a/apps/backend/src/domains/commerce/post/dto/create-comment.dto.ts b/apps/backend/src/domains/commerce/post/dto/create-comment.dto.ts new file mode 100644 index 00000000..9878ef30 --- /dev/null +++ b/apps/backend/src/domains/commerce/post/dto/create-comment.dto.ts @@ -0,0 +1,15 @@ +import { Transform } from "class-transformer"; +import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator"; + +export class CreateCommentDto { + @Transform(({ value }) => (value == null ? value : String(value))) + @IsString() + @IsNotEmpty() + @MaxLength(300) + body!: string; + + @IsOptional() + @Transform(({ value }) => (value == null ? value : String(value))) + @IsString() + parentId?: string; +} diff --git a/apps/backend/src/domains/commerce/post/dto/create-post.dto.ts b/apps/backend/src/domains/commerce/post/dto/create-post.dto.ts new file mode 100644 index 00000000..d686c367 --- /dev/null +++ b/apps/backend/src/domains/commerce/post/dto/create-post.dto.ts @@ -0,0 +1,61 @@ +import { Type } from "class-transformer"; +import { + ArrayMaxSize, + IsArray, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from "class-validator"; +import { ModerationStatus, PostType } from "@prisma/client"; + +export class PostImageInputDto { + @IsString() + @IsNotEmpty() + @MaxLength(2048) + url!: string; + + @IsOptional() + @IsString() + @MaxLength(255) + cloudinaryPublicId?: string; + + @IsOptional() + @IsEnum(ModerationStatus) + moderationStatus?: ModerationStatus; +} + +export class CreatePostDto { + @IsEnum(PostType) + type!: PostType; + + @IsOptional() + @IsString() + @MaxLength(300) + caption?: string; + + @IsOptional() + @IsString() + @MaxLength(240) + gistText?: string; + + @IsOptional() + @IsString() + taggedProductId?: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(10) + @ValidateNested({ each: true }) + @Type(() => PostImageInputDto) + images?: PostImageInputDto[]; + + @IsOptional() + @IsArray() + @ArrayMaxSize(10) + @IsString({ each: true }) + @MaxLength(60, { each: true }) + hashtags?: string[]; +} diff --git a/apps/backend/src/domains/commerce/post/dto/update-post.dto.ts b/apps/backend/src/domains/commerce/post/dto/update-post.dto.ts new file mode 100644 index 00000000..294e021a --- /dev/null +++ b/apps/backend/src/domains/commerce/post/dto/update-post.dto.ts @@ -0,0 +1,41 @@ +import { Type } from "class-transformer"; +import { + ArrayMaxSize, + IsArray, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from "class-validator"; + +import { PostImageInputDto } from "./create-post.dto"; + +export class UpdatePostDto { + @IsOptional() + @IsString() + @MaxLength(300) + caption?: string; + + @IsOptional() + @IsString() + @MaxLength(240) + gistText?: string; + + @IsOptional() + @IsString() + taggedProductId?: string | null; + + @IsOptional() + @IsArray() + @ArrayMaxSize(10) + @ValidateNested({ each: true }) + @Type(() => PostImageInputDto) + images?: PostImageInputDto[]; + + @IsOptional() + @IsArray() + @ArrayMaxSize(10) + @IsString({ each: true }) + @MaxLength(60, { each: true }) + hashtags?: string[]; +} diff --git a/apps/backend/src/domains/commerce/post/engagement-recount.service.ts b/apps/backend/src/domains/commerce/post/engagement-recount.service.ts new file mode 100644 index 00000000..a4e0bdf5 --- /dev/null +++ b/apps/backend/src/domains/commerce/post/engagement-recount.service.ts @@ -0,0 +1,75 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { Cron, CronExpression } from "@nestjs/schedule"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; + +/** + * Hourly drift correction for the denormalized Post engagement counters. + * + * The write paths in PostService keep like/comment/save counters accurate + * transactionally; this sweep is the safety net for anything that bypasses + * them (cascade deletes from removed users, manual data fixes, missed + * increments during incidents). It follows the repo's @Cron scheduler pattern + * (see StorePassSchedulerService, AdminCronService). + */ +@Injectable() +export class EngagementRecountService { + private readonly logger = new Logger(EngagementRecountService.name); + + constructor(private readonly prisma: PrismaService) {} + + @Cron(CronExpression.EVERY_HOUR) + async recountEngagementCounters(): Promise { + try { + const corrected = await this.recountAll(); + if (corrected > 0) { + this.logger.warn( + `Corrected engagement counter drift on ${corrected} posts`, + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + this.logger.error(`Engagement recount failed: ${message}`); + } + } + + /** + * Set-based recount in a single statement. The WHERE clause limits writes to + * rows that actually drifted, so an in-sync table is read-only work. + * Returns the number of corrected posts. + * + * raw SQL: a set-based UPDATE ... FROM with three aggregate joins is not + * expressible in Prisma; per-row recounts would be N+1 queries. + * NOTE: past ~1M posts, bound this by recent engagement activity instead of + * sweeping the full table. + */ + private async recountAll(): Promise { + const corrected = await this.prisma.$executeRaw` + UPDATE "posts" p + SET "like_count" = agg.like_count, + "comment_count" = agg.comment_count, + "save_count" = agg.save_count + FROM ( + SELECT p2."id", + COALESCE(l.cnt, 0) AS like_count, + COALESCE(c.cnt, 0) AS comment_count, + COALESCE(s.cnt, 0) AS save_count + FROM "posts" p2 + LEFT JOIN ( + SELECT "post_id", COUNT(*)::int AS cnt FROM "likes" GROUP BY "post_id" + ) l ON l."post_id" = p2."id" + LEFT JOIN ( + SELECT "post_id", COUNT(*)::int AS cnt FROM "comments" GROUP BY "post_id" + ) c ON c."post_id" = p2."id" + LEFT JOIN ( + SELECT "post_id", COUNT(*)::int AS cnt FROM "saved_posts" GROUP BY "post_id" + ) s ON s."post_id" = p2."id" + ) agg + WHERE agg."id" = p."id" + AND (p."like_count" <> agg.like_count + OR p."comment_count" <> agg.comment_count + OR p."save_count" <> agg.save_count) + `; + return corrected; + } +} diff --git a/apps/backend/src/domains/commerce/post/post.controller.ts b/apps/backend/src/domains/commerce/post/post.controller.ts new file mode 100644 index 00000000..d34765bf --- /dev/null +++ b/apps/backend/src/domains/commerce/post/post.controller.ts @@ -0,0 +1,116 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + UseGuards, +} from "@nestjs/common"; + +import { CurrentUser } from "../../../common/decorators/current-user.decorator"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { AuthenticatedRequestUser } from "../../users/auth/auth.types"; +import { CreateCommentDto } from "./dto/create-comment.dto"; +import { CreatePostDto } from "./dto/create-post.dto"; +import { UpdatePostDto } from "./dto/update-post.dto"; +import { PostService } from "./post.service"; + +@Controller("posts") +export class PostController { + constructor(private readonly postService: PostService) {} + + @UseGuards(JwtAuthGuard) + @Post() + createPost( + @CurrentUser() user: AuthenticatedRequestUser, + @Body() dto: CreatePostDto, + ) { + return this.postService.createPost(user.id, dto); + } + + @UseGuards(JwtAuthGuard) + @Delete("comments/:id") + deleteComment( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("id") commentId: string, + ) { + return this.postService.deleteComment(user.id, commentId); + } + + @Get(":id/comments") + listComments(@Param("id") postId: string) { + return this.postService.listComments(postId); + } + + @UseGuards(JwtAuthGuard) + @Post(":id/comments") + addComment( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("id") postId: string, + @Body() dto: CreateCommentDto, + ) { + return this.postService.addComment(user.id, postId, dto); + } + + @UseGuards(JwtAuthGuard) + @Post(":id/like") + likePost( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("id") postId: string, + ) { + return this.postService.likePost(user.id, postId); + } + + @UseGuards(JwtAuthGuard) + @Delete(":id/like") + unlikePost( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("id") postId: string, + ) { + return this.postService.unlikePost(user.id, postId); + } + + @UseGuards(JwtAuthGuard) + @Post(":id/save") + savePost( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("id") postId: string, + ) { + return this.postService.savePost(user.id, postId); + } + + @UseGuards(JwtAuthGuard) + @Delete(":id/save") + unsavePost( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("id") postId: string, + ) { + return this.postService.unsavePost(user.id, postId); + } + + @Get(":id") + getPost(@Param("id") postId: string) { + return this.postService.getPost(postId); + } + + @UseGuards(JwtAuthGuard) + @Put(":id") + updatePost( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("id") postId: string, + @Body() dto: UpdatePostDto, + ) { + return this.postService.updatePost(user.id, postId, dto); + } + + @UseGuards(JwtAuthGuard) + @Delete(":id") + deletePost( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("id") postId: string, + ) { + return this.postService.deletePost(user.id, postId); + } +} diff --git a/apps/backend/src/domains/commerce/post/post.module.ts b/apps/backend/src/domains/commerce/post/post.module.ts new file mode 100644 index 00000000..7a7f8699 --- /dev/null +++ b/apps/backend/src/domains/commerce/post/post.module.ts @@ -0,0 +1,16 @@ +import { Module } from "@nestjs/common"; + +import { PrismaModule } from "../../../core/prisma/prisma.module"; +import { NotificationModule } from "../../social/notification/notification.module"; +import { PostSubscriptionModule } from "../../social/post-subscription/post-subscription.module"; +import { EngagementRecountService } from "./engagement-recount.service"; +import { PostController } from "./post.controller"; +import { PostService } from "./post.service"; + +@Module({ + imports: [PrismaModule, NotificationModule, PostSubscriptionModule], + controllers: [PostController], + providers: [PostService, EngagementRecountService], + exports: [PostService], +}) +export class PostModule {} diff --git a/apps/backend/src/domains/commerce/post/post.service.spec.ts b/apps/backend/src/domains/commerce/post/post.service.spec.ts new file mode 100644 index 00000000..347769bc --- /dev/null +++ b/apps/backend/src/domains/commerce/post/post.service.spec.ts @@ -0,0 +1,200 @@ +import { + NotificationAudience, + NotificationType, + PostType, +} from "@prisma/client"; + +import { PostService } from "./post.service"; + +const flushMicrotasks = () => new Promise((resolve) => setImmediate(resolve)); + +function makePostService() { + const prisma = { + post: { + findFirst: jest.fn(), + update: jest.fn().mockResolvedValue(undefined), + findUniqueOrThrow: jest.fn(), + }, + }; + const notifications = { createInApp: jest.fn().mockResolvedValue(undefined) }; + const postFanout = { enqueue: jest.fn().mockResolvedValue(undefined) }; + const service = new PostService( + prisma as never, + notifications as never, + postFanout as never, + ); + return { prisma, service, notifications, postFanout }; +} + +describe("PostService.setProductFeedPostActive", () => { + it("reactivates an existing inactive product post instead of creating a duplicate", async () => { + const { prisma, service } = makePostService(); + prisma.post.findFirst.mockResolvedValue({ id: "post-1", isActive: false }); + prisma.post.findUniqueOrThrow.mockResolvedValue({ + id: "post-1", + authorId: "user-1", + storeId: "store-1", + type: PostType.PRODUCT_POST, + text: "Item", + taggedProductId: "product-1", + isActive: true, + moderationStatus: "SAFE", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + author: null, + taggedProduct: null, + images: [], + hashtags: [], + likeCount: 0, + commentCount: 0, + saveCount: 0, + }); + + const result = await service.setProductFeedPostActive("product-1", true); + + expect(prisma.post.update).toHaveBeenCalledWith({ + where: { id: "post-1" }, + data: { isActive: true }, + }); + expect(result).toMatchObject({ id: "post-1", isActive: true }); + }); + + it("deactivates an active product post when removed from the feed", async () => { + const { prisma, service } = makePostService(); + prisma.post.findFirst.mockResolvedValue({ id: "post-1", isActive: true }); + + const result = await service.setProductFeedPostActive("product-1", false); + + expect(prisma.post.update).toHaveBeenCalledWith({ + where: { id: "post-1" }, + data: { isActive: false }, + }); + expect(result).toBeNull(); + }); + + it("is a no-op when removing a product with no feed post", async () => { + const { prisma, service } = makePostService(); + prisma.post.findFirst.mockResolvedValue(null); + + const result = await service.setProductFeedPostActive("product-1", false); + + expect(prisma.post.update).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it("creates a fresh product post when none has ever existed", async () => { + const { prisma, service } = makePostService(); + prisma.post.findFirst.mockResolvedValue(null); + const ensureSpy = jest + .spyOn(service, "ensureProductPostForProduct") + .mockResolvedValue({ id: "post-new" } as never); + + const result = await service.setProductFeedPostActive("product-1", true); + + expect(ensureSpy).toHaveBeenCalledWith("product-1", prisma); + expect(prisma.post.update).not.toHaveBeenCalled(); + expect(result).toMatchObject({ id: "post-new" }); + }); +}); + +describe("PostService social notifications on like", () => { + const makeService = (post: { + id: string; + authorId: string | null; + storeId: string | null; + type?: PostType; + }) => { + const prisma = { + post: { + findFirst: jest.fn().mockResolvedValue({ + id: post.id, + authorId: post.authorId, + storeId: post.storeId, + type: post.type ?? PostType.IMAGE_POST, + }), + update: jest.fn().mockResolvedValue(undefined), + }, + like: { create: jest.fn().mockResolvedValue(undefined) }, + storeProfile: { + findUnique: jest.fn().mockResolvedValue({ userId: "store-owner-1" }), + }, + user: { + findUnique: jest.fn().mockResolvedValue({ + username: "liker", + displayName: "Liker", + profilePhotoUrl: null, + }), + }, + $transaction: jest.fn( + (arg: unknown): Promise => + Array.isArray(arg) + ? Promise.all(arg) + : (arg as (client: unknown) => Promise)(prisma), + ), + }; + const notifications = { + createInApp: jest.fn().mockResolvedValue(undefined), + }; + const postFanout = { enqueue: jest.fn().mockResolvedValue(undefined) }; + return { + prisma, + notifications, + service: new PostService( + prisma as never, + notifications as never, + postFanout as never, + ), + }; + }; + + it("notifies the store owner in the STORE audience when a store post is liked", async () => { + const { service, notifications } = makeService({ + id: "post-1", + authorId: "store-owner-1", + storeId: "store-1", + }); + + await service.likePost("liker-1", "post-1"); + await flushMicrotasks(); + + expect(notifications.createInApp).toHaveBeenCalledWith( + "store-owner-1", + NotificationType.POST_LIKED, + expect.objectContaining({ postId: "post-1", userId: "liker-1" }), + undefined, + NotificationAudience.STORE, + ); + }); + + it("notifies the author in the SHOPPER audience when a personal post is liked", async () => { + const { service, notifications } = makeService({ + id: "post-2", + authorId: "author-1", + storeId: null, + }); + + await service.likePost("liker-1", "post-2"); + await flushMicrotasks(); + + expect(notifications.createInApp).toHaveBeenCalledWith( + "author-1", + NotificationType.POST_LIKED, + expect.objectContaining({ postId: "post-2", userId: "liker-1" }), + undefined, + NotificationAudience.SHOPPER, + ); + }); + + it("does not notify when a user likes their own post", async () => { + const { service, notifications } = makeService({ + id: "post-3", + authorId: "author-1", + storeId: null, + }); + + await service.likePost("author-1", "post-3"); + await flushMicrotasks(); + + expect(notifications.createInApp).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/domains/commerce/post/post.service.ts b/apps/backend/src/domains/commerce/post/post.service.ts new file mode 100644 index 00000000..7705b61e --- /dev/null +++ b/apps/backend/src/domains/commerce/post/post.service.ts @@ -0,0 +1,1273 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + MethodNotAllowedException, + NotFoundException, + NotImplementedException, +} from "@nestjs/common"; +import { + ModerationStatus, + NotificationAudience, + NotificationType, + PostType, + Prisma, + ProductStatus, +} from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { NotificationService } from "../../social/notification/notification.service"; +import { PostFanoutService } from "../../social/post-subscription/post-fanout.service"; +import { CreateCommentDto } from "./dto/create-comment.dto"; +import { CreatePostDto, PostImageInputDto } from "./dto/create-post.dto"; +import { UpdatePostDto } from "./dto/update-post.dto"; + +const MAX_PRODUCT_POST_IMAGES = 5; + +const POST_IMAGE_SELECT = { + id: true, + url: true, + order: true, + moderationStatus: true, + cloudinaryPublicId: true, + createdAt: true, +} satisfies Prisma.PostImageSelect; + +const SAFE_PRODUCT_STRIP_SELECT = { + id: true, + productCode: true, + name: true, + title: true, + imageUrl: true, + retailPriceKobo: true, + status: true, + isActive: true, +} satisfies Prisma.ProductSelect; + +const POST_RESPONSE_SELECT = { + id: true, + authorId: true, + storeId: true, + type: true, + text: true, + taggedProductId: true, + isActive: true, + moderationStatus: true, + createdAt: true, + updatedAt: true, + author: { + select: { + id: true, + username: true, + displayName: true, + profilePhotoUrl: true, + }, + }, + taggedProduct: { + select: SAFE_PRODUCT_STRIP_SELECT, + }, + images: { + select: POST_IMAGE_SELECT, + orderBy: { order: "asc" }, + }, + hashtags: { + select: { + hashtag: { + select: { name: true }, + }, + }, + orderBy: { hashtag: { name: "asc" } }, + }, + // Denormalized counters are the display source of truth — never COUNT(*) + // the relations on read paths. + likeCount: true, + commentCount: true, + saveCount: true, +} satisfies Prisma.PostSelect; + +const COMMENT_RESPONSE_SELECT = { + id: true, + postId: true, + userId: true, + parentId: true, + body: true, + createdAt: true, + updatedAt: true, + user: { + select: { + id: true, + username: true, + displayName: true, + profilePhotoUrl: true, + }, + }, + _count: { + select: { + replies: true, + likes: true, + }, + }, +} satisfies Prisma.CommentSelect; + +type PrismaClientLike = PrismaService | Prisma.TransactionClient; + +type PostRecord = Prisma.PostGetPayload<{ + select: typeof POST_RESPONSE_SELECT; +}>; + +type CommentRecord = Prisma.CommentGetPayload<{ + select: typeof COMMENT_RESPONSE_SELECT; +}>; + +type SafeProductStripRecord = Prisma.ProductGetPayload<{ + select: typeof SAFE_PRODUCT_STRIP_SELECT; +}>; + +interface UserSummaryResponse { + id: string; + username: string | null; + displayName: string | null; + profilePhotoUrl: string | null; +} + +interface PostImageResponse { + id: string; + url: string; + order: number; + moderationStatus: ModerationStatus; + cloudinaryPublicId: string | null; + createdAt: string; +} + +interface SafeProductStripResponse { + id: string; + productCode: string | null; + name: string; + title: string | null; + imageUrl: string | null; + retailPriceKobo: string | null; + status: ProductStatus; + isActive: boolean; +} + +export interface PostResponse { + id: string; + authorId: string | null; + storeId: string | null; + type: PostType; + text: string | null; + taggedProductId: string | null; + isActive: boolean; + moderationStatus: ModerationStatus; + author: UserSummaryResponse | null; + taggedProduct: SafeProductStripResponse | null; + images: PostImageResponse[]; + hashtags: string[]; + likeCount: number; + commentCount: number; + saveCount: number; + createdAt: string; + updatedAt: string; +} + +export interface CommentResponse { + id: string; + postId: string; + userId: string; + parentId: string | null; + body: string; + author: UserSummaryResponse; + replyCount: number; + likeCount: number; + createdAt: string; + updatedAt: string; +} + +@Injectable() +export class PostService { + constructor( + private readonly prisma: PrismaService, + private readonly notifications: NotificationService, + private readonly postFanout: PostFanoutService, + ) {} + + async createPost(userId: string, dto: CreatePostDto): Promise { + if (dto.type === PostType.PRODUCT_POST) { + throw new BadRequestException({ + message: "Product posts are created through product publishing", + code: "PRODUCT_POST_DIRECT_CREATE_FORBIDDEN", + }); + } + + if (dto.type === PostType.TWIZZ) { + throw new NotImplementedException({ + message: "Twizz posts are not implemented yet", + code: "TWIZZ_NOT_IMPLEMENTED", + }); + } + + if (dto.type === PostType.GIST) { + this.validateGistInput(dto); + } else { + this.validateImagePostInput(dto); + } + + const storeId = await this.getUserStoreId(userId); + const taggedProduct = dto.taggedProductId + ? await this.getActiveTaggedProduct(dto.taggedProductId) + : null; + + const post = await this.prisma.$transaction(async (tx) => { + const createdPost = await tx.post.create({ + data: { + authorId: userId, + storeId, + type: dto.type, + text: + dto.type === PostType.GIST + ? this.normalizeRequiredText(dto.gistText, "GIST_TEXT_REQUIRED") + : this.normalizeOptionalText(dto.caption), + taggedProductId: taggedProduct?.id ?? null, + moderationStatus: this.resolveModerationStatus(dto.images ?? []), + }, + select: { id: true }, + }); + + if (dto.type === PostType.IMAGE_POST) { + await this.replaceImages(tx, createdPost.id, dto.images ?? []); + } + await this.replaceHashtags(tx, createdPost.id, [], dto.hashtags ?? []); + + return tx.post.findUniqueOrThrow({ + where: { id: createdPost.id }, + select: POST_RESPONSE_SELECT, + }); + }); + + // Best-effort mention notifications from the post text — never block. + const mentionText = dto.type === PostType.GIST ? dto.gistText : dto.caption; + void this.notifyMentions( + mentionText, + userId, + post.id, + NotificationType.USER_MENTIONED_IN_POST, + new Set([userId]), + ).catch(() => undefined); + + // Fan out to subscribers who turned on this poster's post notifications. + void this.postFanout.enqueue({ + postId: post.id, + postType: dto.type, + authorId: userId, + storeId, + }); + + return this.toPostResponse(post); + } + + async getPost(postId: string): Promise { + const post = await this.prisma.post.findFirst({ + where: { id: postId, isActive: true }, + select: POST_RESPONSE_SELECT, + }); + + if (!post) { + throw new NotFoundException({ + message: "Post not found", + code: "POST_NOT_FOUND", + }); + } + + return this.toPostResponse(post); + } + + async updatePost( + userId: string, + postId: string, + dto: UpdatePostDto, + ): Promise { + const post = await this.findActivePostForMutation(postId); + await this.assertCanManagePost(userId, post.authorId, post.storeId); + + if (post.type === PostType.PRODUCT_POST) { + throw new BadRequestException({ + message: "Product post content is managed through product updates", + code: "PRODUCT_POST_UPDATE_RESTRICTED", + }); + } + + if (post.type === PostType.TWIZZ) { + throw new NotImplementedException({ + message: "Twizz posts are not implemented yet", + code: "TWIZZ_NOT_IMPLEMENTED", + }); + } + + if (post.type === PostType.GIST && dto.images !== undefined) { + throw new BadRequestException({ + message: "Gist posts cannot include images", + code: "GIST_IMAGES_NOT_ALLOWED", + }); + } + + const taggedProduct = + dto.taggedProductId === undefined || dto.taggedProductId === null + ? null + : await this.getActiveTaggedProduct(dto.taggedProductId); + + const updatedPost = await this.prisma.$transaction(async (tx) => { + const existingHashtags = await this.getCurrentHashtags(tx, postId); + await tx.post.update({ + where: { id: postId }, + data: { + ...(post.type === PostType.GIST && dto.gistText !== undefined + ? { + text: this.normalizeRequiredText( + dto.gistText, + "GIST_TEXT_REQUIRED", + ), + } + : {}), + ...(post.type === PostType.IMAGE_POST && dto.caption !== undefined + ? { text: this.normalizeOptionalText(dto.caption) } + : {}), + ...(dto.taggedProductId !== undefined + ? { taggedProductId: taggedProduct?.id ?? null } + : {}), + ...(dto.images !== undefined + ? { moderationStatus: this.resolveModerationStatus(dto.images) } + : {}), + }, + }); + + if (post.type === PostType.IMAGE_POST && dto.images !== undefined) { + await this.replaceImages(tx, postId, dto.images); + } + + if (dto.hashtags !== undefined) { + await this.replaceHashtags(tx, postId, existingHashtags, dto.hashtags); + } + + return tx.post.findUniqueOrThrow({ + where: { id: postId }, + select: POST_RESPONSE_SELECT, + }); + }); + + return this.toPostResponse(updatedPost); + } + + async deletePost( + userId: string, + postId: string, + ): Promise<{ deleted: true; postId: string }> { + const post = await this.findActivePostForMutation(postId); + await this.assertCanManagePost(userId, post.authorId, post.storeId); + + await this.prisma.post.update({ + where: { id: postId }, + data: { isActive: false }, + }); + + return { deleted: true, postId }; + } + + async likePost( + userId: string, + postId: string, + ): Promise<{ liked: true; postId: string }> { + const post = await this.findActivePostForMutation(postId); + // Create + counter increment succeed or roll back together. A repeat like + // hits the unique constraint (P2002) and the whole transaction rolls back, + // so the counter is only ever incremented once per (user, post). + let created = false; + try { + await this.prisma.$transaction([ + this.prisma.like.create({ data: { userId, postId } }), + this.prisma.post.update({ + where: { id: postId }, + data: { likeCount: { increment: 1 } }, + }), + ]); + created = true; + } catch (error) { + if (!this.isUniqueConstraintError(error)) throw error; + // Already liked — idempotent success, no counter change, no notification. + } + + // Best-effort social notification — only on a genuinely new like, never + // blocking the response. Grouped as "X and N others liked your post". + if (created) { + void this.notifyPostEngagement( + post, + userId, + NotificationType.POST_LIKED, + ).catch(() => undefined); + } + + return { liked: true, postId }; + } + + async unlikePost( + userId: string, + postId: string, + ): Promise<{ liked: false; postId: string }> { + await this.prisma.$transaction(async (tx) => { + const removed = await tx.like.deleteMany({ where: { userId, postId } }); + if (removed.count > 0) { + // Guard keeps the counter from going negative if it has drifted low. + await tx.post.updateMany({ + where: { id: postId, likeCount: { gt: 0 } }, + data: { likeCount: { decrement: 1 } }, + }); + } + }); + return { liked: false, postId }; + } + + async savePost( + userId: string, + postId: string, + ): Promise<{ saved: true; postId: string }> { + await this.findActivePostForMutation(postId); + try { + await this.prisma.$transaction([ + this.prisma.savedPost.create({ data: { userId, postId } }), + this.prisma.post.update({ + where: { id: postId }, + data: { saveCount: { increment: 1 } }, + }), + ]); + } catch (error) { + if (!this.isUniqueConstraintError(error)) throw error; + // Already saved — idempotent success, no counter change. + } + + return { saved: true, postId }; + } + + async unsavePost( + userId: string, + postId: string, + ): Promise<{ saved: false; postId: string }> { + await this.prisma.$transaction(async (tx) => { + const removed = await tx.savedPost.deleteMany({ + where: { userId, postId }, + }); + if (removed.count > 0) { + await tx.post.updateMany({ + where: { id: postId, saveCount: { gt: 0 } }, + data: { saveCount: { decrement: 1 } }, + }); + } + }); + return { saved: false, postId }; + } + + async addComment( + userId: string, + postId: string, + dto: CreateCommentDto, + ): Promise { + const post = await this.findActivePostForMutation(postId); + if (post.type === PostType.PRODUCT_POST) { + throw new MethodNotAllowedException({ + message: "Product posts do not support comments", + code: "PRODUCT_POST_COMMENTS_DISABLED", + }); + } + + const body = this.normalizeRequiredText(dto.body, "COMMENT_BODY_REQUIRED"); + if (dto.parentId) { + await this.assertValidCommentParent(postId, dto.parentId); + } + + const comment = await this.prisma.$transaction(async (tx) => { + const created = await tx.comment.create({ + data: { + postId, + userId, + parentId: dto.parentId ?? null, + body, + }, + select: COMMENT_RESPONSE_SELECT, + }); + await tx.post.update({ + where: { id: postId }, + data: { commentCount: { increment: 1 } }, + }); + return created; + }); + + // Best-effort social notifications — never block the response. + void this.notifyComment(post, userId, dto.parentId ?? null, body).catch( + () => undefined, + ); + + return this.toCommentResponse(comment); + } + + async listComments(postId: string): Promise { + await this.findActivePostForMutation(postId); + const comments = await this.prisma.comment.findMany({ + where: { postId, parentId: null }, + orderBy: { createdAt: "asc" }, + select: COMMENT_RESPONSE_SELECT, + }); + + return comments.map((comment) => this.toCommentResponse(comment)); + } + + async deleteComment( + userId: string, + commentId: string, + ): Promise<{ deleted: true; commentId: string }> { + const comment = await this.prisma.comment.findUnique({ + where: { id: commentId }, + select: { + id: true, + userId: true, + postId: true, + post: { select: { authorId: true, storeId: true } }, + }, + }); + + if (!comment) { + throw new NotFoundException({ + message: "Comment not found", + code: "COMMENT_NOT_FOUND", + }); + } + + if (comment.userId !== userId) { + await this.assertCanManagePost( + userId, + comment.post.authorId, + comment.post.storeId, + ); + } + + // Deleting a parent re-parents replies to top level (SetNull), so exactly + // one comment row is removed — decrement by one, guarded against drift. + await this.prisma.$transaction([ + this.prisma.comment.delete({ where: { id: commentId } }), + this.prisma.post.updateMany({ + where: { id: comment.postId, commentCount: { gt: 0 } }, + data: { commentCount: { decrement: 1 } }, + }), + ]); + return { deleted: true, commentId }; + } + + /** + * Explicit "post to feed" / "remove from feed" control for a product's + * PRODUCT_POST. Publishing a product no longer auto-creates its feed post — + * the store owner decides when it appears on the social feed from inventory. + * + * active = true → ensure a PRODUCT_POST exists and is active (reactivating a + * previously removed one instead of creating a duplicate). + * active = false → soft-remove the existing PRODUCT_POST from the feed + * (isActive = false). The product itself stays live. + */ + async setProductFeedPostActive( + productId: string, + active: boolean, + client: PrismaClientLike = this.prisma, + ): Promise { + const existing = await client.post.findFirst({ + where: { type: PostType.PRODUCT_POST, taggedProductId: productId }, + orderBy: { createdAt: "desc" }, + select: { id: true, isActive: true }, + }); + + if (!active) { + if (existing && existing.isActive) { + await client.post.update({ + where: { id: existing.id }, + data: { isActive: false }, + }); + } + return null; + } + + if (existing) { + const reactivated = !existing.isActive; + if (reactivated) { + await client.post.update({ + where: { id: existing.id }, + data: { isActive: true }, + }); + } + const post = await client.post.findUniqueOrThrow({ + where: { id: existing.id }, + select: POST_RESPONSE_SELECT, + }); + // Notify product-post subscribers only when the post (re)enters the feed. + if (reactivated) { + this.enqueueProductPostFanout(post); + } + return this.toPostResponse(post); + } + + // No PRODUCT_POST has ever been created for this product — create one. + const created = await this.ensureProductPostForProduct(productId, client); + this.enqueueProductPostFanout(created); + return created; + } + + private enqueueProductPostFanout(post: { + id: string; + storeId: string | null; + authorId: string | null; + }): void { + void this.postFanout.enqueue({ + postId: post.id, + postType: PostType.PRODUCT_POST, + authorId: post.authorId, + storeId: post.storeId, + }); + } + + async ensureProductPostForProduct( + productId: string, + client: PrismaClientLike = this.prisma, + ): Promise { + const existingPost = await client.post.findFirst({ + where: { + type: PostType.PRODUCT_POST, + taggedProductId: productId, + isActive: true, + }, + select: POST_RESPONSE_SELECT, + }); + + if (existingPost) { + return this.toPostResponse(existingPost); + } + + const product = await client.product.findFirst({ + where: { + id: productId, + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + }, + select: { + id: true, + storeId: true, + name: true, + title: true, + images: { + select: { + url: true, + moderationStatus: true, + cloudinaryPublicId: true, + order: true, + isDefault: true, + }, + where: { moderationStatus: { not: ModerationStatus.BLOCKED } }, + orderBy: [{ isDefault: "desc" }, { order: "asc" }], + take: MAX_PRODUCT_POST_IMAGES, + }, + storeProfile: { + select: { + userId: true, + }, + }, + }, + }); + + if (!product) { + throw new NotFoundException({ + message: "Published product not found", + code: "PRODUCT_NOT_FOUND", + }); + } + + const createdPost = await client.post.create({ + data: { + authorId: product.storeProfile.userId, + storeId: product.storeId, + type: PostType.PRODUCT_POST, + text: product.title ?? product.name, + taggedProductId: product.id, + moderationStatus: this.resolveModerationStatus(product.images), + images: { + create: product.images.map((image, index) => ({ + url: image.url, + order: index, + moderationStatus: image.moderationStatus, + cloudinaryPublicId: image.cloudinaryPublicId, + })), + }, + }, + select: { id: true }, + }); + + const post = await client.post.findUniqueOrThrow({ + where: { id: createdPost.id }, + select: POST_RESPONSE_SELECT, + }); + + return this.toPostResponse(post); + } + + private validateImagePostInput(dto: CreatePostDto): void { + const caption = this.normalizeOptionalText(dto.caption); + if (!caption && (!dto.images || dto.images.length === 0)) { + throw new BadRequestException({ + message: "Image posts require a caption or image", + code: "IMAGE_POST_CONTENT_REQUIRED", + }); + } + this.assertNoBlockedImages(dto.images ?? []); + } + + private validateGistInput(dto: CreatePostDto): void { + this.normalizeRequiredText(dto.gistText, "GIST_TEXT_REQUIRED"); + if (dto.images && dto.images.length > 0) { + throw new BadRequestException({ + message: "Gist posts cannot include images", + code: "GIST_IMAGES_NOT_ALLOWED", + }); + } + } + + private async getUserStoreId(userId: string): Promise { + const store = await this.prisma.storeProfile.findUnique({ + where: { userId }, + select: { id: true }, + }); + + return store?.id ?? null; + } + + private async getActiveTaggedProduct( + productId: string, + ): Promise { + const product = await this.prisma.product.findFirst({ + where: { + id: productId, + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + }, + select: SAFE_PRODUCT_STRIP_SELECT, + }); + + if (!product) { + throw new NotFoundException({ + message: "Tagged product not found", + code: "TAGGED_PRODUCT_NOT_FOUND", + }); + } + + return product; + } + + // ── Social notifications ────────────────────────────────────────────────── + // + // All best-effort: they never block or roll back the engagement write. A post + // owned by a store routes to the STORE audience (the owner's store-mode + // notifications), otherwise to the author's SHOPPER audience. Mentions and + // replies are always personal (SHOPPER). + + private async notifyPostEngagement( + post: { id: string; authorId: string | null; storeId: string | null }, + actorId: string, + type: NotificationType, + ): Promise { + const recipient = await this.resolvePostRecipient(post); + if (!recipient || recipient.recipientUserId === actorId) { + return; + } + const actor = await this.getActorProfile(actorId); + await this.notifications.createInApp( + recipient.recipientUserId, + type, + { postId: post.id, userId: actorId, ...actor }, + undefined, + recipient.audience, + ); + } + + private async notifyComment( + post: { id: string; authorId: string | null; storeId: string | null }, + commenterId: string, + parentId: string | null, + body: string, + ): Promise { + const notified = new Set([commenterId]); + + if (parentId) { + const parent = await this.prisma.comment.findUnique({ + where: { id: parentId }, + select: { userId: true }, + }); + if (parent?.userId && !notified.has(parent.userId)) { + const actor = await this.getActorProfile(commenterId); + await this.notifications.createInApp( + parent.userId, + NotificationType.COMMENT_REPLIED, + { postId: post.id, userId: commenterId, ...actor }, + undefined, + NotificationAudience.SHOPPER, + ); + notified.add(parent.userId); + } + } else { + const recipient = await this.resolvePostRecipient(post); + if (recipient && !notified.has(recipient.recipientUserId)) { + const actor = await this.getActorProfile(commenterId); + await this.notifications.createInApp( + recipient.recipientUserId, + NotificationType.POST_COMMENTED, + { postId: post.id, userId: commenterId, ...actor }, + undefined, + recipient.audience, + ); + notified.add(recipient.recipientUserId); + } + } + + await this.notifyMentions( + body, + commenterId, + post.id, + NotificationType.USER_MENTIONED_IN_COMMENT, + notified, + ); + } + + private async notifyMentions( + text: string | null | undefined, + actorId: string, + postId: string, + type: NotificationType, + exclude: Set, + ): Promise { + const handles = this.extractMentions(text); + if (handles.length === 0) { + return; + } + const users = await this.prisma.user.findMany({ + where: { username: { in: handles } }, + select: { id: true }, + }); + if (users.length === 0) { + return; + } + const actor = await this.getActorProfile(actorId); + for (const user of users) { + if (user.id === actorId || exclude.has(user.id)) { + continue; + } + await this.notifications.createInApp( + user.id, + type, + { postId, userId: actorId, ...actor }, + undefined, + NotificationAudience.SHOPPER, + ); + exclude.add(user.id); + } + } + + /** Extract unique @handles from free text (2–30 word chars, no leading @@). */ + private extractMentions(text: string | null | undefined): string[] { + if (!text) { + return []; + } + const matches = text.match(/(? match.slice(1)))); + } + + private async resolvePostRecipient(post: { + authorId: string | null; + storeId: string | null; + }): Promise<{ + recipientUserId: string; + audience: NotificationAudience; + } | null> { + if (post.storeId) { + const store = await this.prisma.storeProfile.findUnique({ + where: { id: post.storeId }, + select: { userId: true }, + }); + if (store?.userId) { + return { + recipientUserId: store.userId, + audience: NotificationAudience.STORE, + }; + } + } + if (post.authorId) { + return { + recipientUserId: post.authorId, + audience: NotificationAudience.SHOPPER, + }; + } + return null; + } + + private async getActorProfile(userId: string): Promise<{ + username: string | null; + displayName: string | null; + profilePhotoUrl: string | null; + }> { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { username: true, displayName: true, profilePhotoUrl: true }, + }); + return { + username: user?.username ?? null, + displayName: user?.displayName ?? null, + profilePhotoUrl: user?.profilePhotoUrl ?? null, + }; + } + + private async findActivePostForMutation(postId: string): Promise<{ + id: string; + authorId: string | null; + storeId: string | null; + type: PostType; + }> { + const post = await this.prisma.post.findFirst({ + where: { id: postId, isActive: true }, + select: { id: true, authorId: true, storeId: true, type: true }, + }); + + if (!post) { + throw new NotFoundException({ + message: "Post not found", + code: "POST_NOT_FOUND", + }); + } + + return post; + } + + private async assertCanManagePost( + userId: string, + authorId: string | null, + storeId: string | null, + ): Promise { + if (authorId === userId) { + return; + } + + if (storeId) { + const store = await this.prisma.storeProfile.findUnique({ + where: { userId }, + select: { id: true }, + }); + if (store?.id === storeId) { + return; + } + } + + throw new ForbiddenException({ + message: "You cannot manage this post", + code: "POST_FORBIDDEN", + }); + } + + private async assertValidCommentParent( + postId: string, + parentId: string, + ): Promise { + const parent = await this.prisma.comment.findFirst({ + where: { id: parentId, postId }, + select: { id: true, parentId: true }, + }); + + if (!parent) { + throw new NotFoundException({ + message: "Parent comment not found", + code: "COMMENT_PARENT_NOT_FOUND", + }); + } + + if (parent.parentId) { + throw new BadRequestException({ + message: "Replies can only be one level deep", + code: "COMMENT_REPLY_DEPTH_EXCEEDED", + }); + } + } + + private async replaceImages( + tx: Prisma.TransactionClient, + postId: string, + images: PostImageInputDto[], + ): Promise { + this.assertNoBlockedImages(images); + await tx.postImage.deleteMany({ where: { postId } }); + + for (const [index, image] of images.entries()) { + await tx.postImage.create({ + data: { + postId, + url: image.url, + order: index, + moderationStatus: image.moderationStatus ?? ModerationStatus.SAFE, + cloudinaryPublicId: image.cloudinaryPublicId ?? null, + }, + }); + } + } + + private async getCurrentHashtags( + tx: Prisma.TransactionClient, + postId: string, + ): Promise { + const existing = await tx.postHashtag.findMany({ + where: { postId }, + select: { hashtag: { select: { name: true } } }, + }); + + return existing.map((entry) => entry.hashtag.name); + } + + private async replaceHashtags( + tx: Prisma.TransactionClient, + postId: string, + previousTags: string[], + nextTags: string[], + ): Promise { + const normalizedPrevious = this.normalizeHashtags(previousTags); + const normalizedNext = this.normalizeHashtags(nextTags); + const previousSet = new Set(normalizedPrevious); + const nextSet = new Set(normalizedNext); + const removedTags = normalizedPrevious.filter((tag) => !nextSet.has(tag)); + const addedTags = normalizedNext.filter((tag) => !previousSet.has(tag)); + + await tx.postHashtag.deleteMany({ where: { postId } }); + + for (const tag of removedTags) { + await tx.hashtag.updateMany({ + where: { name: tag, postCount: { gt: 0 } }, + data: { postCount: { decrement: 1 } }, + }); + } + + for (const tag of normalizedNext) { + const hashtag = await tx.hashtag.upsert({ + where: { name: tag }, + update: {}, + create: { name: tag }, + select: { id: true }, + }); + + await tx.postHashtag.create({ + data: { postId, hashtagId: hashtag.id }, + }); + + if (addedTags.includes(tag)) { + await tx.hashtag.update({ + where: { id: hashtag.id }, + data: { postCount: { increment: 1 } }, + }); + } + } + } + + private normalizeHashtags(tags: string[]): string[] { + const normalized = tags + .map((tag) => tag.trim().replace(/^#+/, "").toLowerCase()) + .filter((tag) => tag.length > 0); + return [...new Set(normalized)].slice(0, 10); + } + + private normalizeRequiredText( + value: string | undefined, + code: string, + ): string { + const text = value?.trim(); + if (!text) { + throw new BadRequestException({ + message: "Text is required", + code, + }); + } + return text; + } + + private normalizeOptionalText(value: string | undefined): string | null { + const text = value?.trim(); + return text && text.length > 0 ? text : null; + } + + /** P2002 — the unique constraint hit that makes like/save idempotent. */ + private isUniqueConstraintError(error: unknown): boolean { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ); + } + + private resolveModerationStatus( + images: Array<{ moderationStatus?: ModerationStatus | null }>, + ): ModerationStatus { + if ( + images.some( + (image) => image.moderationStatus === ModerationStatus.SENSITIVE, + ) + ) { + return ModerationStatus.SENSITIVE; + } + return ModerationStatus.SAFE; + } + + private assertNoBlockedImages(images: PostImageInputDto[]): void { + if ( + images.some( + (image) => image.moderationStatus === ModerationStatus.BLOCKED, + ) + ) { + throw new BadRequestException({ + message: "Blocked images cannot be attached to posts", + code: "POST_IMAGE_BLOCKED", + }); + } + } + + private toPostResponse(post: PostRecord): PostResponse { + return { + id: post.id, + authorId: post.authorId, + storeId: post.storeId, + type: post.type, + text: post.text, + taggedProductId: post.taggedProductId, + isActive: post.isActive, + moderationStatus: post.moderationStatus, + author: post.author ? this.toUserSummary(post.author) : null, + taggedProduct: + post.taggedProduct && post.taggedProduct.isActive + ? this.toProductStrip(post.taggedProduct) + : null, + images: post.images.map((image) => ({ + id: image.id, + url: image.url, + order: image.order, + moderationStatus: image.moderationStatus, + cloudinaryPublicId: image.cloudinaryPublicId, + createdAt: image.createdAt.toISOString(), + })), + hashtags: post.hashtags.map((entry) => entry.hashtag.name), + likeCount: post.likeCount, + commentCount: post.type === PostType.PRODUCT_POST ? 0 : post.commentCount, + saveCount: post.saveCount, + createdAt: post.createdAt.toISOString(), + updatedAt: post.updatedAt.toISOString(), + }; + } + + private toCommentResponse(comment: CommentRecord): CommentResponse { + return { + id: comment.id, + postId: comment.postId, + userId: comment.userId, + parentId: comment.parentId, + body: comment.body, + author: this.toUserSummary(comment.user), + replyCount: comment._count.replies, + likeCount: comment._count.likes, + createdAt: comment.createdAt.toISOString(), + updatedAt: comment.updatedAt.toISOString(), + }; + } + + private toUserSummary(user: UserSummaryResponse): UserSummaryResponse { + return { + id: user.id, + username: user.username, + displayName: user.displayName, + profilePhotoUrl: user.profilePhotoUrl, + }; + } + + private toProductStrip( + product: SafeProductStripRecord, + ): SafeProductStripResponse { + return { + id: product.id, + productCode: product.productCode, + name: product.name, + title: product.title, + imageUrl: product.imageUrl, + retailPriceKobo: product.retailPriceKobo?.toString() ?? null, + status: product.status, + isActive: product.isActive, + }; + } + + /** + * Public endpoint to get post engagement data for a product. + * Returns like/save counts and viewer interaction states. + */ + async getProductPostEngagement( + productId: string, + viewerId?: string, + ): Promise<{ + postId: string | null; + likeCount: number; + saveCount: number; + commentCount: number; + viewerHasLiked: boolean; + viewerHasSaved: boolean; + }> { + const post = await this.prisma.post.findFirst({ + where: { + type: PostType.PRODUCT_POST, + taggedProductId: productId, + isActive: true, + }, + select: { + id: true, + likeCount: true, + saveCount: true, + commentCount: true, + }, + }); + + if (!post) { + return { + postId: null, + likeCount: 0, + saveCount: 0, + commentCount: 0, + viewerHasLiked: false, + viewerHasSaved: false, + }; + } + + let viewerHasLiked = false; + let viewerHasSaved = false; + + if (viewerId) { + const [like, savedProduct] = await Promise.all([ + this.prisma.like.findUnique({ + where: { userId_postId: { postId: post.id, userId: viewerId } }, + select: { id: true }, + }), + this.prisma.savedProduct.findUnique({ + where: { userId_productId: { userId: viewerId, productId } }, + select: { id: true }, + }), + ]); + viewerHasLiked = !!like; + viewerHasSaved = !!savedProduct; + } + + return { + postId: post.id, + likeCount: post.likeCount, + saveCount: post.saveCount, + commentCount: post.commentCount, + viewerHasLiked, + viewerHasSaved, + }; + } +} diff --git a/apps/backend/src/domains/commerce/product/dto/create-product.dto.ts b/apps/backend/src/domains/commerce/product/dto/create-product.dto.ts new file mode 100644 index 00000000..5c1e0c5d --- /dev/null +++ b/apps/backend/src/domains/commerce/product/dto/create-product.dto.ts @@ -0,0 +1,303 @@ +import { Type } from "class-transformer"; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsBoolean, + IsEnum, + IsInt, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + Matches, + Max, + MaxLength, + Min, + ValidateNested, +} from "class-validator"; +import { GuideType, ModerationStatus } from "@prisma/client"; + +export class ProductDetailDto { + @IsString() + @IsNotEmpty() + @MaxLength(50) + attribute!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(200) + value!: string; +} + +export class ProductVariantOverrideDto { + @IsOptional() + @IsString() + @MaxLength(50) + colorName?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + sizeName?: string; + + @IsOptional() + @IsString() + @MaxLength(120) + variantLabel?: string; + + @IsOptional() + @Matches(/^\d+$/) + priceOverrideKobo?: string; + + @IsOptional() + @IsString() + @MaxLength(80) + sku?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(999999) + initialStock?: number; +} + +export class ProductVariantOptionsDto { + @IsOptional() + @IsArray() + @ArrayMaxSize(10) + @IsString({ each: true }) + @MaxLength(50, { each: true }) + colors?: string[]; + + @IsOptional() + @IsArray() + @ArrayMaxSize(10) + @IsString({ each: true }) + @MaxLength(50, { each: true }) + sizes?: string[]; + + @IsOptional() + @IsArray() + @ArrayMaxSize(100) + @ValidateNested({ each: true }) + @Type(() => ProductVariantOverrideDto) + overrides?: ProductVariantOverrideDto[]; +} + +export class ProductImageInputDto { + @IsString() + @IsNotEmpty() + @MaxLength(2048) + url!: string; + + @IsOptional() + @IsString() + @MaxLength(255) + cloudinaryPublicId?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(4) + order?: number; + + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @IsOptional() + @IsString() + @MaxLength(180) + altText?: string; + + @IsOptional() + @IsEnum(ModerationStatus) + moderationStatus?: ModerationStatus; + + @IsOptional() + @IsArray() + @ArrayMaxSize(100) + @IsString({ each: true }) + @MaxLength(120, { each: true }) + assignedVariantLabels?: string[]; + + @IsOptional() + @IsArray() + @ArrayMaxSize(100) + @IsString({ each: true }) + assignedVariantIds?: string[]; +} + +export class ProductSizeGuideConfigDto { + @IsOptional() + @IsBoolean() + includesFootwear?: boolean; + + @IsOptional() + @IsEnum(GuideType) + footwearGuideType?: GuideType; + + @IsOptional() + @IsBoolean() + useCustomSizes?: boolean; + + @IsOptional() + @IsObject() + customSizes?: Record; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(300) + modelHeightCm?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(300) + modelBustCm?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(300) + modelWaistCm?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(300) + modelHipsCm?: number; + + @IsOptional() + @IsString() + @MaxLength(30) + modelWearingSize?: string; +} + +export class CreateProductDto { + @IsString() + @IsNotEmpty() + @MaxLength(100) + name!: string; + + // Full product description — optional; the store may publish with just a + // name, price, category and photo, then add a description later. + @IsOptional() + @IsString() + @MaxLength(1000) + description?: string; + + // Caption / subheading shown on the product detail page (never on the card). + @IsOptional() + @IsString() + @MaxLength(180) + shortDescription?: string; + + @IsString() + @IsNotEmpty() + @MaxLength(80) + platformCategory!: string; + + @IsOptional() + @IsEnum(GuideType) + productSubCategory?: GuideType; + + @IsOptional() + @IsArray() + @ArrayMaxSize(3) + @IsString({ each: true }) + @MaxLength(30, { each: true }) + storeTags?: string[]; + + @IsOptional() + @IsArray() + @ArrayMaxSize(20) + @ValidateNested({ each: true }) + @Type(() => ProductDetailDto) + productDetails?: ProductDetailDto[]; + + @IsOptional() + @IsString() + @MaxLength(80) + sku?: string; + + @Matches(/^\d+$/) + retailPriceKobo!: string; + + @IsOptional() + @Matches(/^\d+$/) + compareAtPriceKobo?: string; + + @IsOptional() + @IsBoolean() + allowDropship?: boolean; + + @IsOptional() + @Matches(/^\d+$/) + dropshipperPriceKobo?: string; + + @IsOptional() + @IsBoolean() + publishNow?: boolean; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(999999) + initialStock?: number; + + @IsOptional() + @IsBoolean() + notSoldIndividually?: boolean; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(999999) + minimumOrderQty?: number; + + @IsOptional() + @IsString() + @MaxLength(30) + unit?: string; + + @IsOptional() + @IsObject() + attributes?: Record; + + @IsOptional() + @IsBoolean() + hasVariants?: boolean; + + @IsOptional() + @ValidateNested() + @Type(() => ProductVariantOptionsDto) + variantOptions?: ProductVariantOptionsDto; + + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(5) + @ValidateNested({ each: true }) + @Type(() => ProductImageInputDto) + images?: ProductImageInputDto[]; + + @IsOptional() + @ValidateNested() + @Type(() => ProductSizeGuideConfigDto) + sizeGuideConfig?: ProductSizeGuideConfigDto; +} diff --git a/apps/backend/src/domains/commerce/product/dto/update-product.dto.ts b/apps/backend/src/domains/commerce/product/dto/update-product.dto.ts new file mode 100644 index 00000000..e6af19ac --- /dev/null +++ b/apps/backend/src/domains/commerce/product/dto/update-product.dto.ts @@ -0,0 +1,134 @@ +import { Type } from "class-transformer"; +import { + ArrayMaxSize, + IsArray, + IsBoolean, + IsEnum, + IsInt, + IsObject, + IsOptional, + IsString, + Matches, + Max, + MaxLength, + Min, + ValidateNested, +} from "class-validator"; +import { GuideType, ProductStatus } from "@prisma/client"; + +import { + ProductDetailDto, + ProductImageInputDto, + ProductSizeGuideConfigDto, + ProductVariantOptionsDto, +} from "./create-product.dto"; + +export class UpdateProductDto { + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(1000) + description?: string; + + @IsOptional() + @IsString() + @MaxLength(180) + shortDescription?: string; + + @IsOptional() + @IsString() + @MaxLength(80) + platformCategory?: string; + + @IsOptional() + @IsEnum(GuideType) + productSubCategory?: GuideType; + + @IsOptional() + @IsArray() + @ArrayMaxSize(3) + @IsString({ each: true }) + @MaxLength(30, { each: true }) + storeTags?: string[]; + + @IsOptional() + @IsArray() + @ArrayMaxSize(20) + @ValidateNested({ each: true }) + @Type(() => ProductDetailDto) + productDetails?: ProductDetailDto[]; + + @IsOptional() + @IsString() + @MaxLength(80) + sku?: string; + + @IsOptional() + @Matches(/^\d+$/) + retailPriceKobo?: string; + + @IsOptional() + @Matches(/^\d+$/) + compareAtPriceKobo?: string; + + @IsOptional() + @IsBoolean() + allowDropship?: boolean; + + @IsOptional() + @Matches(/^\d+$/) + dropshipperPriceKobo?: string; + + @IsOptional() + @IsBoolean() + notSoldIndividually?: boolean; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(999999) + minimumOrderQty?: number; + + @IsOptional() + @IsString() + @MaxLength(30) + unit?: string; + + @IsOptional() + @IsObject() + attributes?: Record; + + @IsOptional() + @IsEnum(ProductStatus) + status?: ProductStatus; + + @IsOptional() + @IsBoolean() + isFeatured?: boolean; + + @IsOptional() + @IsBoolean() + hasVariants?: boolean; + + @IsOptional() + @ValidateNested() + @Type(() => ProductVariantOptionsDto) + variantOptions?: ProductVariantOptionsDto; + + @IsOptional() + @IsArray() + @ArrayMaxSize(5) + @ValidateNested({ each: true }) + @Type(() => ProductImageInputDto) + images?: ProductImageInputDto[]; + + @IsOptional() + @ValidateNested() + @Type(() => ProductSizeGuideConfigDto) + sizeGuideConfig?: ProductSizeGuideConfigDto; +} diff --git a/apps/backend/src/domains/commerce/product/product.controller.ts b/apps/backend/src/domains/commerce/product/product.controller.ts new file mode 100644 index 00000000..960c358d --- /dev/null +++ b/apps/backend/src/domains/commerce/product/product.controller.ts @@ -0,0 +1,133 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards, +} from "@nestjs/common"; + +import { CurrentUser } from "../../../common/decorators/current-user.decorator"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { AuthenticatedRequestUser } from "../../users/auth/auth.types"; +import { PostService } from "../post/post.service"; +import { CreateProductDto } from "./dto/create-product.dto"; +import { UpdateProductDto } from "./dto/update-product.dto"; +import { ProductService } from "./product.service"; + +@Controller() +export class ProductController { + constructor( + private readonly productService: ProductService, + private readonly postService: PostService, + ) {} + + @UseGuards(JwtAuthGuard) + @Post("products") + createProduct( + @CurrentUser() user: AuthenticatedRequestUser, + @Body() dto: CreateProductDto, + ) { + return this.productService.createProduct(user.id, dto); + } + + @UseGuards(JwtAuthGuard) + @Get("products") + listOwnProducts( + @CurrentUser() user: AuthenticatedRequestUser, + @Query("status") status?: string, + @Query("inventory") inventory?: string, + ) { + return this.productService.listOwnProducts(user.id, status, inventory); + } + + @UseGuards(JwtAuthGuard) + @Get("products/manage/:id") + getOwnedProduct( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("id") productId: string, + ) { + return this.productService.getOwnedProduct(user.id, productId); + } + + @UseGuards(JwtAuthGuard) + @Get("products/manage/:id/preview") + getOwnedProductPreview( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("id") productId: string, + ) { + return this.productService.getOwnedProductPreview(user.id, productId); + } + + @Get("products/:code") + getPublicProduct(@Param("code") code: string) { + return this.productService.getPublicProduct(code); + } + + @Get("stores/:handle/products/:code") + getPublicStoreProduct( + @Param("handle") handle: string, + @Param("code") code: string, + ) { + return this.productService.getPublicStoreProduct(handle, code); + } + + @Get("products/:id/engagement") + getProductEngagement(@Param("id") productId: string) { + return this.postService.getProductPostEngagement(productId); + } + + @UseGuards(JwtAuthGuard) + @Get("products/:id/engagement/me") + getProductEngagementAuthenticated( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("id") productId: string, + ) { + return this.postService.getProductPostEngagement(productId, user.id); + } + + @UseGuards(JwtAuthGuard) + @Put("products/:id") + updateProduct( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("id") productId: string, + @Body() dto: UpdateProductDto, + ) { + return this.productService.updateProduct(user.id, productId, dto); + } + + @UseGuards(JwtAuthGuard) + @Delete("products/:id") + deleteProduct( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("id") productId: string, + ) { + return this.productService.softDeleteProduct(user.id, productId); + } + + @UseGuards(JwtAuthGuard) + @Post("products/:id/feed-post") + postProductToFeed( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("id") productId: string, + ) { + return this.productService.postProductToFeed(user.id, productId); + } + + @UseGuards(JwtAuthGuard) + @Delete("products/:id/feed-post") + unpostProductFromFeed( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("id") productId: string, + ) { + return this.productService.unpostProductFromFeed(user.id, productId); + } + + @Get("stores/:handle/products") + listPublicStoreProducts(@Param("handle") handle: string) { + return this.productService.listPublicStoreProducts(handle); + } +} diff --git a/apps/backend/src/domains/commerce/product/product.module.ts b/apps/backend/src/domains/commerce/product/product.module.ts new file mode 100644 index 00000000..76050cf0 --- /dev/null +++ b/apps/backend/src/domains/commerce/product/product.module.ts @@ -0,0 +1,23 @@ +import { Module } from "@nestjs/common"; + +import { PrismaModule } from "../../../core/prisma/prisma.module"; +import { QueueModule } from "../../../queue/queue.module"; +import { StorePassModule } from "../../money/storepass/storepass.module"; +import { InventoryModule } from "../inventory/inventory.module"; +import { PostModule } from "../post/post.module"; +import { ProductController } from "./product.controller"; +import { ProductService } from "./product.service"; + +@Module({ + imports: [ + PrismaModule, + InventoryModule, + QueueModule, + PostModule, + StorePassModule, + ], + controllers: [ProductController], + providers: [ProductService], + exports: [ProductService], +}) +export class ProductModule {} diff --git a/apps/backend/src/domains/commerce/product/product.service.spec.ts b/apps/backend/src/domains/commerce/product/product.service.spec.ts new file mode 100644 index 00000000..92fd5d96 --- /dev/null +++ b/apps/backend/src/domains/commerce/product/product.service.spec.ts @@ -0,0 +1,971 @@ +import { getQueueToken } from "@nestjs/bullmq"; +import { Logger } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { + InventoryEventType, + ModerationStatus, + ProductStatus, + StoreTier, + StoreType, +} from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { QUEUE } from "../../../queue/queue.constants"; +import { StorePassService } from "../../money/storepass/storepass.service"; +import { InventoryService } from "../inventory/inventory.service"; +import { PostService } from "../post/post.service"; +import { ProductService } from "./product.service"; + +function makeProductService() { + const prisma = { + storeProfile: { + findFirst: jest.fn().mockResolvedValue({ + id: "digital-store-1", + storeType: StoreType.DIGITAL, + isOpen: true, + }), + }, + product: { + findFirst: jest.fn().mockResolvedValue(null), + findUnique: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + }, + sourcedProduct: { + findFirst: jest.fn().mockResolvedValue(null), + findUnique: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + }, + }; + + const service = new ProductService( + prisma as never, + {} as never, + {} as never, + { resolveStorePassPublicBadge: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + + return { prisma, service }; +} + +describe("ProductService store capability enforcement", () => { + it("rejects native product creation for digital stores", async () => { + const { prisma, service } = makeProductService(); + + await expect( + service.createProduct("digital-owner-1", {} as never), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "NATIVE_PRODUCTS_PHYSICAL_ONLY", + }), + }); + + expect(prisma.product.findFirst).not.toHaveBeenCalled(); + }); + + it("rejects native product updates for digital stores", async () => { + const { prisma, service } = makeProductService(); + + await expect( + service.updateProduct("digital-owner-1", "product-1", {}), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "NATIVE_PRODUCTS_PHYSICAL_ONLY", + }), + }); + + expect(prisma.product.findFirst).not.toHaveBeenCalled(); + }); + + it("rejects native product deletion for digital stores", async () => { + const { prisma, service } = makeProductService(); + + await expect( + service.softDeleteProduct("digital-owner-1", "product-1"), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "NATIVE_PRODUCTS_PHYSICAL_ONLY", + }), + }); + + expect(prisma.product.findFirst).not.toHaveBeenCalled(); + }); + + it("rejects native product management lists for digital stores", async () => { + const { prisma, service } = makeProductService(); + + await expect( + service.listOwnProducts("digital-owner-1"), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "NATIVE_PRODUCTS_PHYSICAL_ONLY", + }), + }); + + expect(prisma.product.findMany).not.toHaveBeenCalled(); + }); +}); + +function makePublicSourcedProduct(overrides: Record = {}) { + return { + id: "sourced-1", + listingCode: "SRC-483729", + productCode: "TWZ-839201", + customTitle: "Curated Sneakers", + customDescription: "A digital store listing for shoppers.", + customImages: null, + sellingPriceKobo: 1800000n, + isActive: true, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + digitalStore: { + userId: "digital-owner-1", + storeHandle: "digital-shop", + storeName: "Digital Shop", + logoUrl: "https://cdn.example.com/digital-logo.jpg", + tier: StoreTier.TIER_1, + isOpen: true, + storeType: StoreType.DIGITAL, + }, + physicalProduct: { + storeProfile: { + userId: "physical-owner-1", + }, + name: "Supplier Sneakers", + title: "Supplier Sneakers", + description: "Supplier-written description.", + shortDescription: "Supplier short description", + retailPriceKobo: 1500000n, + platformCategory: "Fashion", + productSubCategory: null, + storeTags: ["shoes"], + productDetails: null, + imageUrl: "https://cdn.example.com/sneakers.jpg", + category: { + name: "Fashion", + slug: "fashion", + }, + images: [ + { + id: "image-1", + url: "https://cdn.example.com/sneakers.jpg", + order: 0, + isDefault: true, + assignedVariantIds: [], + altText: "Sneakers", + moderationStatus: ModerationStatus.SAFE, + cloudinaryPublicId: "product/sneakers", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }, + ], + sizeGuideConfig: null, + productStockCaches: [{ stock: 4, variantId: null }], + }, + ...overrides, + }; +} +const baseCreateProductDto = { + name: "Leather Bag", + description: "A durable everyday bag.", + platformCategory: "Fashion", + retailPriceKobo: "2500000", + publishNow: true, + initialStock: 8, +}; + +function makeOwnerProduct(overrides: Record = {}) { + return { + id: "product-1", + productCode: "TWZ-123456", + sourceCode: null, + name: "Leather Bag", + title: "Leather Bag", + description: "A durable everyday bag.", + shortDescription: null, + retailPriceKobo: 2500000n, + compareAtPriceKobo: null, + platformCategory: "Fashion", + productSubCategory: null, + storeTags: [], + productDetails: null, + sku: null, + allowDropship: false, + dropshipperPriceKobo: null, + notSoldIndividually: false, + minimumOrderQty: null, + unit: "unit", + attributes: {}, + status: ProductStatus.ACTIVE, + isActive: true, + isFeatured: false, + imageUrl: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + deletedAt: null, + hasVariants: false, + variants: [], + images: [], + sizeGuideConfig: null, + productStockCaches: [{ stock: 8, lowStockThreshold: 5 }], + taggedPosts: [], + ...overrides, + }; +} + +function makeProductCreateHarness() { + const tx = { + product: { + create: jest.fn().mockResolvedValue({ id: "product-1" }), + findUniqueOrThrow: jest.fn().mockResolvedValue(makeOwnerProduct()), + }, + productVariant: { + create: jest.fn(), + }, + productImage: { + create: jest.fn(), + }, + productSizeGuideConfig: { + create: jest.fn(), + }, + }; + const prisma = { + $transaction: jest.fn((callback) => callback(tx)), + storeProfile: { + findFirst: jest.fn().mockResolvedValue({ + id: "store-1", + storeType: StoreType.PHYSICAL, + isOpen: true, + allowDropship: false, + }), + }, + category: { + findFirst: jest.fn().mockResolvedValue({ + id: "category-1", + name: "Fashion", + slug: "fashion", + }), + }, + product: { + findUnique: jest.fn().mockResolvedValue(null), + }, + sourcedProduct: { + findUnique: jest.fn().mockResolvedValue(null), + }, + }; + const inventoryService = { + recordEventInTransaction: jest.fn().mockResolvedValue(undefined), + }; + const postService = { + ensureProductPostForProduct: jest.fn().mockResolvedValue(undefined), + setProductFeedPostActive: jest.fn().mockResolvedValue(null), + }; + const embeddingQueue = { + add: jest.fn().mockResolvedValue(undefined), + }; + const service = new ProductService( + prisma as never, + inventoryService as never, + postService as never, + { resolveStorePassPublicBadge: jest.fn().mockResolvedValue(null) } as never, + embeddingQueue as never, + ); + + return { embeddingQueue, inventoryService, postService, prisma, service, tx }; +} + +describe("ProductService owner inventory stock filters", () => { + it("returns stock summaries and filters high stock as greater than 20", async () => { + const { prisma, service } = makeProductService(); + prisma.storeProfile.findFirst.mockResolvedValueOnce({ + id: "store-1", + storeType: StoreType.PHYSICAL, + isOpen: true, + allowDropship: false, + }); + prisma.product.findMany.mockResolvedValue([ + makeOwnerProduct({ + id: "stock-20", + name: "Exactly twenty", + productStockCaches: [{ stock: 20, lowStockThreshold: 5 }], + }), + makeOwnerProduct({ + id: "stock-21", + name: "Twenty one", + productStockCaches: [{ stock: 21, lowStockThreshold: 5 }], + }), + ]); + + const products = await service.listOwnProducts( + "store-owner-1", + undefined, + "high_stock", + ); + + expect(prisma.product.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: expect.objectContaining({ + productStockCaches: expect.any(Object), + }), + }), + ); + expect(products).toHaveLength(1); + expect(products[0]).toMatchObject({ + id: "stock-21", + stockQuantity: 21, + stockStatus: "HIGH_STOCK", + }); + }); + + it("classifies empty, low, and normal stock from product stock cache rows", async () => { + const { prisma, service } = makeProductService(); + prisma.storeProfile.findFirst.mockResolvedValue({ + id: "store-1", + storeType: StoreType.PHYSICAL, + isOpen: true, + allowDropship: false, + }); + prisma.product.findMany.mockResolvedValue([ + makeOwnerProduct({ + id: "out", + productStockCaches: [], + }), + makeOwnerProduct({ + id: "low", + productStockCaches: [{ stock: 3, lowStockThreshold: 5 }], + }), + makeOwnerProduct({ + id: "in", + productStockCaches: [{ stock: 8, lowStockThreshold: 5 }], + }), + ]); + + const products = await service.listOwnProducts("store-owner-1"); + + expect(products.map((product) => product.stockStatus)).toEqual([ + "OUT_OF_STOCK", + "LOW_STOCK", + "IN_STOCK", + ]); + }); +}); +describe("ProductService product creation inventory atomicity", () => { + it("creates product and initial stock in the same transaction", async () => { + const { inventoryService, postService, prisma, service, tx } = + makeProductCreateHarness(); + + const product = await service.createProduct( + "store-owner-1", + baseCreateProductDto, + ); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(tx.product.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + storeId: "store-1", + status: ProductStatus.ACTIVE, + isActive: true, + }), + }), + ); + expect(inventoryService.recordEventInTransaction).toHaveBeenCalledWith( + tx, + "product-1", + null, + InventoryEventType.INITIAL_STOCK, + 8, + undefined, + "Initial product stock", + ); + // Publishing a product must NOT auto-post it to the feed — that is now an + // explicit inventory action via postProductToFeed(). + expect(postService.ensureProductPostForProduct).not.toHaveBeenCalled(); + expect(postService.setProductFeedPostActive).not.toHaveBeenCalled(); + expect(product).toMatchObject({ + id: "product-1", + productCode: "TWZ-123456", + status: ProductStatus.ACTIVE, + isPostedToFeed: false, + }); + }); + + it("does not enqueue or return a product when initial stock creation fails", async () => { + const { embeddingQueue, inventoryService, service, tx } = + makeProductCreateHarness(); + inventoryService.recordEventInTransaction.mockRejectedValueOnce( + new Error("stock write failed"), + ); + + await expect( + service.createProduct("store-owner-1", baseCreateProductDto), + ).rejects.toThrow("stock write failed"); + + expect(tx.product.create).toHaveBeenCalledTimes(1); + expect(tx.product.findUniqueOrThrow).not.toHaveBeenCalled(); + expect(embeddingQueue.add).not.toHaveBeenCalled(); + }); + + it("skips inventory event creation for draft products with no initial stock", async () => { + const { inventoryService, service } = makeProductCreateHarness(); + + await service.createProduct("store-owner-1", { + ...baseCreateProductDto, + publishNow: false, + initialStock: undefined, + }); + + expect(inventoryService.recordEventInTransaction).not.toHaveBeenCalled(); + }); +}); +describe("ProductService — embedding enqueue", () => { + let service: ProductService; + let queue: { add: jest.Mock }; + + beforeEach(async () => { + queue = { add: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProductService, + { provide: PrismaService, useValue: {} }, + { provide: InventoryService, useValue: {} }, + { provide: PostService, useValue: {} }, + { + provide: StorePassService, + useValue: { + resolveStorePassPublicBadge: jest.fn().mockResolvedValue(null), + }, + }, + { provide: getQueueToken(QUEUE.EMBEDDING), useValue: queue }, + ], + }).compile(); + + service = module.get(ProductService); + }); + + it("encodes the product revision in the jobId and payload", async () => { + queue.add.mockResolvedValue(undefined); + const updatedAt = new Date("2026-05-01T00:00:00.000Z"); + + await ( + service as unknown as { + enqueueEmbedding: ( + productId: string, + productUpdatedAt: Date, + ) => Promise; + } + ).enqueueEmbedding("prod_123", updatedAt); + + expect(queue.add).toHaveBeenCalledTimes(1); + expect(queue.add).toHaveBeenCalledWith( + "generate-product-embedding", + { productId: "prod_123", productUpdatedAt: updatedAt.toISOString() }, + expect.objectContaining({ + jobId: `embedding:prod_123:${updatedAt.getTime()}`, + attempts: 3, + removeOnComplete: true, + }), + ); + }); + + it("swallows enqueue failures so product create/update never crashes", async () => { + queue.add.mockRejectedValue(new Error("redis is down")); + const loggerSpy = jest + .spyOn(Logger.prototype, "error") + .mockImplementation(); + + try { + await expect( + ( + service as unknown as { + enqueueEmbedding: ( + productId: string, + productUpdatedAt: Date, + ) => Promise; + } + ).enqueueEmbedding("prod_456", new Date()), + ).resolves.toBeUndefined(); + + expect(queue.add).toHaveBeenCalledTimes(1); + } finally { + loggerSpy.mockRestore(); + } + }); +}); + +describe("ProductService public sourced listings", () => { + it("generates internal source codes for source-eligible inventory", async () => { + const { prisma, service } = makeProductService(); + prisma.product.findUnique.mockResolvedValue(null); + + const sourceCode = await ( + service as unknown as { generateSourceCode: () => Promise } + ).generateSourceCode(); + + expect(sourceCode).toMatch(/^SRC-\d{6}$/); + expect(prisma.product.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ + where: { sourceCode }, + }), + ); + }); + + it("does not generate native product codes that collide with sourced public product codes", async () => { + const { prisma, service } = makeProductService(); + prisma.product.findUnique.mockResolvedValue(null); + prisma.sourcedProduct.findUnique + .mockResolvedValueOnce({ id: "sourced-existing" }) + .mockResolvedValueOnce(null); + + const productCode = await ( + service as unknown as { generateProductCode: () => Promise } + ).generateProductCode(); + + expect(productCode).toMatch(/^TWZ-\d{6}$/); + expect(prisma.sourcedProduct.findUnique).toHaveBeenCalledTimes(2); + }); + + it("includes sanitized active sourced listings on public digital store pages", async () => { + const { prisma, service } = makeProductService(); + prisma.sourcedProduct.findMany.mockResolvedValue([ + makePublicSourcedProduct(), + ]); + + const products = await service.listPublicStoreProducts("digital-shop"); + + expect(products).toHaveLength(1); + expect(products[0]).toMatchObject({ + listingType: "SOURCED", + productId: "sourced-1", + productCode: "TWZ-839201", + sourcedProductId: "sourced-1", + name: "Curated Sneakers", + description: "A digital store listing for shoppers.", + retailPriceKobo: "1800000", + store: { + handle: "digital-shop", + name: "Digital Shop", + }, + }); + expect(products[0]).not.toHaveProperty("physicalProductId"); + expect(products[0]).not.toHaveProperty("physicalStoreId"); + expect(products[0]).not.toHaveProperty("listingCode"); + expect(products[0]).not.toHaveProperty("sourceCode"); + expect(products[0]).not.toHaveProperty("dropshipperCostKobo"); + expect(products[0]).not.toHaveProperty("digitalMarginKobo"); + }); + + it("limits native public listings to physical stores on store pages", async () => { + const { prisma, service } = makeProductService(); + prisma.sourcedProduct.findMany.mockResolvedValue([ + makePublicSourcedProduct(), + ]); + + const products = await service.listPublicStoreProducts("digital-shop"); + + expect(prisma.product.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + storeProfile: expect.objectContaining({ + storeHandle: "digital-shop", + storeType: StoreType.PHYSICAL, + isOpen: true, + }), + }), + }), + ); + expect(prisma.sourcedProduct.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + digitalStore: expect.objectContaining({ + storeHandle: "digital-shop", + storeType: StoreType.DIGITAL, + isOpen: true, + }), + }), + }), + ); + expect(products.map((product) => product.listingType)).toEqual(["SOURCED"]); + expect(products.map((product) => product.productCode)).toEqual([ + "TWZ-839201", + ]); + }); + + it("limits native public product detail lookup to physical stores", async () => { + const { prisma, service } = makeProductService(); + + await expect(service.getPublicProduct("TWZ-123456")).rejects.toMatchObject({ + response: expect.objectContaining({ code: "PRODUCT_NOT_FOUND" }), + }); + + expect(prisma.product.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + productCode: "TWZ-123456", + storeProfile: { + storeType: StoreType.PHYSICAL, + isOpen: true, + }, + }), + }), + ); + }); + + it("requires native public product detail to be active and not deleted", async () => { + const { prisma, service } = makeProductService(); + + await expect(service.getPublicProduct("TWZ-123456")).rejects.toMatchObject({ + response: expect.objectContaining({ code: "PRODUCT_NOT_FOUND" }), + }); + + expect(prisma.product.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + productCode: "TWZ-123456", + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + }), + }), + ); + }); + + it("excludes self-sourced listings owned by the same user", async () => { + const { prisma, service } = makeProductService(); + prisma.sourcedProduct.findMany.mockResolvedValue([ + makePublicSourcedProduct({ + digitalStore: { + userId: "same-owner", + storeHandle: "digital-shop", + storeName: "Digital Shop", + logoUrl: null, + tier: StoreTier.TIER_1, + isOpen: true, + storeType: StoreType.DIGITAL, + }, + physicalProduct: { + ...makePublicSourcedProduct().physicalProduct, + storeProfile: { + userId: "same-owner", + }, + }, + }), + ]); + + const products = await service.listPublicStoreProducts("digital-shop"); + + expect(products).toEqual([]); + }); + + it("requires source product and source store opt-in for public sourced listings", async () => { + const { prisma, service } = makeProductService(); + + await service.listPublicStoreProducts("digital-shop"); + + expect(prisma.sourcedProduct.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + where: expect.objectContaining({ + digitalStore: expect.objectContaining({ + storeHandle: "digital-shop", + storeType: StoreType.DIGITAL, + isOpen: true, + }), + isActive: true, + physicalProduct: expect.objectContaining({ + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + allowDropship: true, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: expect.objectContaining({ + storeType: StoreType.PHYSICAL, + isOpen: true, + allowDropship: true, + }), + }), + }), + }), + ); + }); + + it("resolves sourced listing detail by public sourced productCode", async () => { + const { prisma, service } = makeProductService(); + prisma.sourcedProduct.findFirst.mockResolvedValue( + makePublicSourcedProduct(), + ); + + const product = await service.getPublicProduct("TWZ-839201"); + + expect(prisma.product.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ productCode: "TWZ-839201" }), + }), + ); + expect(prisma.sourcedProduct.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: [ + { productCode: "TWZ-839201" }, + { listingCode: "TWZ-839201" }, + { id: "TWZ-839201" }, + ], + }), + }), + ); + expect(product).toMatchObject({ + listingType: "SOURCED", + productId: "sourced-1", + productCode: "TWZ-839201", + sourcedProductId: "sourced-1", + name: "Curated Sneakers", + retailPriceKobo: "1800000", + store: { + handle: "digital-shop", + }, + }); + expect(product).not.toHaveProperty("physicalProductId"); + expect(product).not.toHaveProperty("physicalStoreId"); + expect(product).not.toHaveProperty("listingCode"); + expect(product).not.toHaveProperty("sourceCode"); + expect(product).not.toHaveProperty("dropshipperCostKobo"); + expect(product).not.toHaveProperty("digitalMarginKobo"); + expect(product).not.toHaveProperty("linkedOrderId"); + expect(product).not.toHaveProperty("deliveryPrimaryPhone"); + expect(product).not.toHaveProperty("deliverySecondaryPhone"); + expect(product).not.toHaveProperty("deliveryAddressSnapshot"); + expect(product.store).not.toHaveProperty("storeType"); + expect(product.store).not.toHaveProperty("businessAddress"); + }); + + it("keeps legacy listingCode detail fallback while returning normal public productCode", async () => { + const { prisma, service } = makeProductService(); + prisma.sourcedProduct.findFirst.mockResolvedValue( + makePublicSourcedProduct(), + ); + + const product = await service.getPublicProduct("SRC-483729"); + + expect(prisma.sourcedProduct.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: [ + { productCode: "SRC-483729" }, + { listingCode: "SRC-483729" }, + { id: "SRC-483729" }, + ], + }), + }), + ); + expect(product).toMatchObject({ + productId: "sourced-1", + productCode: "TWZ-839201", + sourcedProductId: "sourced-1", + }); + }); + + it("keeps legacy sourcedProductId detail fallback without using it as public code", async () => { + const { prisma, service } = makeProductService(); + prisma.sourcedProduct.findFirst.mockResolvedValue( + makePublicSourcedProduct(), + ); + + const product = await service.getPublicProduct("sourced-1"); + + expect(prisma.sourcedProduct.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: [ + { productCode: "SOURCED-1" }, + { listingCode: "SOURCED-1" }, + { id: "sourced-1" }, + ], + }), + }), + ); + expect(product).toMatchObject({ + productId: "sourced-1", + productCode: "TWZ-839201", + sourcedProductId: "sourced-1", + }); + }); + + it("resolves sourced listing detail by store handle and sourced productCode", async () => { + const { prisma, service } = makeProductService(); + prisma.sourcedProduct.findFirst.mockResolvedValue( + makePublicSourcedProduct(), + ); + + const product = await service.getPublicStoreProduct( + "digital-shop", + "TWZ-839201", + ); + + expect(prisma.sourcedProduct.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + productCode: "TWZ-839201", + digitalStore: expect.objectContaining({ + storeHandle: "digital-shop", + storeType: StoreType.DIGITAL, + }), + }), + }), + ); + expect(product).toMatchObject({ + listingType: "SOURCED", + productCode: "TWZ-839201", + store: { handle: "digital-shop" }, + }); + }); + + it("rejects wrong store handle for sourced public productCode", async () => { + const { prisma, service } = makeProductService(); + prisma.sourcedProduct.findFirst.mockResolvedValue(null); + + await expect( + service.getPublicStoreProduct("wrong-shop", "TWZ-839201"), + ).rejects.toMatchObject({ + response: expect.objectContaining({ code: "PRODUCT_NOT_FOUND" }), + }); + }); + + it("resolves native product code before sourced listing code", async () => { + const { prisma, service } = makeProductService(); + prisma.product.findFirst.mockResolvedValue({ + id: "native-product-1", + productCode: "TWZ-123456", + name: "Native Product", + title: "Native Product", + description: "Native description", + shortDescription: null, + retailPriceKobo: 2000000n, + compareAtPriceKobo: null, + platformCategory: "Fashion", + productSubCategory: null, + storeTags: [], + productDetails: null, + sku: null, + status: ProductStatus.ACTIVE, + imageUrl: null, + isFeatured: false, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + hasVariants: false, + variants: [], + images: [], + sizeGuideConfig: null, + productStockCaches: [{ stock: 5, variantId: null }], + category: { + name: "Fashion", + slug: "fashion", + }, + storeProfile: { + storeHandle: "native-shop", + storeName: "Native Shop", + logoUrl: null, + tier: StoreTier.TIER_1, + isOpen: true, + }, + }); + + const product = await service.getPublicProduct("TWZ-123456"); + + expect(product).toMatchObject({ + listingType: "NATIVE", + productId: "native-product-1", + productCode: "TWZ-123456", + stockQuantity: 5, + inStock: true, + }); + expect(prisma.sourcedProduct.findFirst).not.toHaveBeenCalled(); + }); +}); + +describe("ProductService feed post control", () => { + function makeFeedHarness() { + const setProductFeedPostActive = jest.fn().mockResolvedValue(null); + const prisma = { + storeProfile: { + findFirst: jest.fn().mockResolvedValue({ + id: "store-1", + storeType: StoreType.PHYSICAL, + isOpen: true, + }), + }, + product: { + // findOwnedProduct: first call loads the product, second checks ownership. + findFirst: jest + .fn() + .mockResolvedValueOnce(makeOwnerProduct()) + .mockResolvedValueOnce({ id: "product-1" }), + findUniqueOrThrow: jest + .fn() + .mockResolvedValue( + makeOwnerProduct({ taggedPosts: [{ id: "post-1" }] }), + ), + }, + }; + const postService = { setProductFeedPostActive }; + const service = new ProductService( + prisma as never, + {} as never, + postService as never, + { + resolveStorePassPublicBadge: jest.fn().mockResolvedValue(null), + } as never, + {} as never, + ); + return { prisma, postService, service }; + } + + it("activates the product post and reports isPostedToFeed", async () => { + const { postService, service } = makeFeedHarness(); + + const result = await service.postProductToFeed( + "store-owner-1", + "product-1", + ); + + expect(postService.setProductFeedPostActive).toHaveBeenCalledWith( + "product-1", + true, + ); + expect(result).toMatchObject({ + isPostedToFeed: true, + feedPostId: "post-1", + }); + }); + + it("deactivates the product post when removed from the feed", async () => { + const { postService, service } = makeFeedHarness(); + + await service.unpostProductFromFeed("store-owner-1", "product-1"); + + expect(postService.setProductFeedPostActive).toHaveBeenCalledWith( + "product-1", + false, + ); + }); + + it("refuses to post a draft product to the feed", async () => { + const { postService, service } = makeFeedHarness(); + // Override the product loaded by findOwnedProduct to a DRAFT. + ( + service as unknown as { prisma: { product: { findFirst: jest.Mock } } } + ).prisma.product.findFirst + .mockReset() + .mockResolvedValueOnce( + makeOwnerProduct({ status: ProductStatus.DRAFT, isActive: false }), + ) + .mockResolvedValueOnce({ id: "product-1" }); + + await expect( + service.postProductToFeed("store-owner-1", "product-1"), + ).rejects.toMatchObject({ + response: expect.objectContaining({ code: "PRODUCT_NOT_PUBLISHED" }), + }); + + expect(postService.setProductFeedPostActive).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/domains/commerce/product/product.service.ts b/apps/backend/src/domains/commerce/product/product.service.ts new file mode 100644 index 00000000..17cc0b6d --- /dev/null +++ b/apps/backend/src/domains/commerce/product/product.service.ts @@ -0,0 +1,2166 @@ +import { InjectQueue } from "@nestjs/bullmq"; +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { + GuideType, + InventoryEventType, + ModerationStatus, + PostType, + Prisma, + ProductStatus, + StoreProfile, + StoreType, +} from "@prisma/client"; +import { Queue } from "bullmq"; +import { randomInt } from "crypto"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { QUEUE } from "../../../queue/queue.constants"; +import { StorePassPublicBadge } from "../../money/storepass/types/storepass.types"; +import { StorePassService } from "../../money/storepass/storepass.service"; +import { InventoryService } from "../inventory/inventory.service"; +import { PostService } from "../post/post.service"; +import { + CreateProductDto, + ProductDetailDto, + ProductImageInputDto, + ProductSizeGuideConfigDto, + ProductVariantOptionsDto, +} from "./dto/create-product.dto"; +import { UpdateProductDto } from "./dto/update-product.dto"; + +const MAX_PRODUCT_CODE_ATTEMPTS = 10; +const PUBLIC_PRODUCT_STATUS = ProductStatus.ACTIVE; +const HIGH_STOCK_THRESHOLD = 20; + +const PRODUCT_VARIANT_SELECT = { + id: true, + colorName: true, + sizeName: true, + variantLabel: true, + priceOverrideKobo: true, + sku: true, + isActive: true, + createdAt: true, + updatedAt: true, +} satisfies Prisma.ProductVariantSelect; + +const PRODUCT_IMAGE_SELECT = { + id: true, + url: true, + order: true, + isDefault: true, + assignedVariantIds: true, + altText: true, + moderationStatus: true, + cloudinaryPublicId: true, + createdAt: true, +} satisfies Prisma.ProductImageSelect; + +const PRODUCT_SIZE_GUIDE_SELECT = { + id: true, + primaryGuideType: true, + includesFootwear: true, + footwearGuideType: true, + useCustomSizes: true, + customSizes: true, + modelHeightCm: true, + modelBustCm: true, + modelWaistCm: true, + modelHipsCm: true, + modelWearingSize: true, + createdAt: true, + updatedAt: true, +} satisfies Prisma.ProductSizeGuideConfigSelect; + +const PRODUCT_STOCK_CACHE_SELECT = { + stock: true, + lowStockThreshold: true, +} satisfies Prisma.ProductStockCacheSelect; + +const PRODUCT_OWNER_SELECT = { + id: true, + productCode: true, + sourceCode: true, + name: true, + title: true, + description: true, + shortDescription: true, + retailPriceKobo: true, + compareAtPriceKobo: true, + platformCategory: true, + productSubCategory: true, + storeTags: true, + productDetails: true, + sku: true, + allowDropship: true, + dropshipperPriceKobo: true, + notSoldIndividually: true, + minimumOrderQty: true, + unit: true, + attributes: true, + status: true, + isActive: true, + isFeatured: true, + imageUrl: true, + createdAt: true, + updatedAt: true, + deletedAt: true, + hasVariants: true, + variants: { + select: PRODUCT_VARIANT_SELECT, + orderBy: { createdAt: "asc" }, + }, + images: { + select: PRODUCT_IMAGE_SELECT, + orderBy: { order: "asc" }, + }, + sizeGuideConfig: { + select: PRODUCT_SIZE_GUIDE_SELECT, + }, + productStockCaches: { + select: PRODUCT_STOCK_CACHE_SELECT, + }, + // Active PRODUCT_POST presence drives the inventory "posted to feed" state. + taggedPosts: { + where: { type: PostType.PRODUCT_POST, isActive: true }, + select: { id: true }, + take: 1, + }, +} satisfies Prisma.ProductSelect; + +const PRODUCT_PUBLIC_SELECT = { + id: true, + productCode: true, + name: true, + title: true, + description: true, + shortDescription: true, + retailPriceKobo: true, + compareAtPriceKobo: true, + platformCategory: true, + productSubCategory: true, + storeTags: true, + productDetails: true, + sku: true, + status: true, + imageUrl: true, + isFeatured: true, + createdAt: true, + updatedAt: true, + hasVariants: true, + variants: { + select: PRODUCT_VARIANT_SELECT, + where: { isActive: true }, + orderBy: { createdAt: "asc" }, + }, + images: { + select: PRODUCT_IMAGE_SELECT, + where: { moderationStatus: { not: ModerationStatus.BLOCKED } }, + orderBy: { order: "asc" }, + }, + sizeGuideConfig: { + select: PRODUCT_SIZE_GUIDE_SELECT, + }, + productStockCaches: { + select: { stock: true, variantId: true }, + }, + category: { + select: { + name: true, + slug: true, + }, + }, + storeProfile: { + select: { + id: true, + storeHandle: true, + storeName: true, + logoUrl: true, + tier: true, + isOpen: true, + averageRating: true, + reviewCount: true, + }, + }, +} satisfies Prisma.ProductSelect; + +const SOURCED_PUBLIC_SELECT = { + id: true, + productCode: true, + customTitle: true, + customDescription: true, + customImages: true, + sellingPriceKobo: true, + isActive: true, + createdAt: true, + updatedAt: true, + digitalStore: { + select: { + id: true, + userId: true, + storeHandle: true, + storeName: true, + logoUrl: true, + tier: true, + isOpen: true, + storeType: true, + }, + }, + physicalProduct: { + select: { + storeProfile: { + select: { + userId: true, + }, + }, + name: true, + title: true, + description: true, + shortDescription: true, + retailPriceKobo: true, + platformCategory: true, + productSubCategory: true, + storeTags: true, + productDetails: true, + imageUrl: true, + category: { + select: { + name: true, + slug: true, + }, + }, + images: { + select: PRODUCT_IMAGE_SELECT, + where: { moderationStatus: { not: ModerationStatus.BLOCKED } }, + orderBy: { order: "asc" }, + }, + sizeGuideConfig: { + select: PRODUCT_SIZE_GUIDE_SELECT, + }, + productStockCaches: { + select: { stock: true, variantId: true }, + }, + }, + }, +} satisfies Prisma.SourcedProductSelect; + +type OwnerProductRecord = Prisma.ProductGetPayload<{ + select: typeof PRODUCT_OWNER_SELECT; +}>; + +type PublicProductRecord = Prisma.ProductGetPayload<{ + select: typeof PRODUCT_PUBLIC_SELECT; +}>; + +type PublicSourcedProductRecord = Prisma.SourcedProductGetPayload<{ + select: typeof SOURCED_PUBLIC_SELECT; +}>; + +type ProductVariantRecord = Prisma.ProductVariantGetPayload<{ + select: typeof PRODUCT_VARIANT_SELECT; +}>; + +type ProductCategoryRecord = { + id: string; + name: string; + slug: string; +}; + +type StoreForProductOwnership = Pick< + StoreProfile, + "id" | "storeType" | "isOpen" +>; + +type InventoryStockStatus = + | "OUT_OF_STOCK" + | "LOW_STOCK" + | "IN_STOCK" + | "HIGH_STOCK"; + +type InventoryStockFilter = + | "in_stock" + | "low_stock" + | "out_of_stock" + | "high_stock"; + +interface ProductStockSummary { + stockQuantity: number; + stockStatus: InventoryStockStatus; +} + +interface ProductVariantResponse { + id: string; + colorName: string | null; + sizeName: string | null; + variantLabel: string; + priceOverrideKobo: string | null; + sku: string | null; + isActive: boolean; + /** Current stock for this variant. Present on public product responses. */ + stock?: number; + createdAt: string; + updatedAt: string; +} + +interface ProductImageResponse { + id: string; + url: string; + order: number; + isDefault: boolean; + assignedVariantIds: string[]; + altText: string | null; + moderationStatus: ModerationStatus; + cloudinaryPublicId: string | null; + createdAt: string; +} + +interface ProductSizeGuideConfigResponse { + id: string; + primaryGuideType: GuideType; + includesFootwear: boolean; + footwearGuideType: GuideType | null; + useCustomSizes: boolean; + customSizes: Prisma.JsonValue | null; + modelHeightCm: number | null; + modelBustCm: number | null; + modelWaistCm: number | null; + modelHipsCm: number | null; + modelWearingSize: string | null; + createdAt: string; + updatedAt: string; +} + +interface GeneratedVariantInput { + colorName: string | null; + sizeName: string | null; + variantLabel: string; + priceOverrideKobo: bigint | null; + sku: string | null; + isActive: boolean; + initialStock: number | null; +} + +type ProductSizeGuideWriteData = Omit< + Prisma.ProductSizeGuideConfigUncheckedCreateInput, + "id" | "productId" | "createdAt" | "updatedAt" +>; + +export interface OwnerProductResponse { + id: string; + productCode: string | null; + sourceCode: string | null; + name: string; + title: string | null; + description: string | null; + shortDescription: string | null; + retailPriceKobo: string | null; + compareAtPriceKobo: string | null; + platformCategory: string | null; + productSubCategory: string | null; + storeTags: string[]; + productDetails: Prisma.JsonValue | null; + sku: string | null; + allowDropship: boolean; + dropshipperPriceKobo: string | null; + notSoldIndividually: boolean; + minimumOrderQty: number | null; + unit: string; + attributes: Prisma.JsonValue | null; + status: ProductStatus; + isActive: boolean; + isFeatured: boolean; + imageUrl: string | null; + hasVariants: boolean; + variants: ProductVariantResponse[]; + images: ProductImageResponse[]; + sizeGuideConfig: ProductSizeGuideConfigResponse | null; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + stockQuantity: number; + stockStatus: InventoryStockStatus; + isPostedToFeed: boolean; + feedPostId: string | null; +} + +export interface PublicProductResponse { + listingType: "NATIVE" | "SOURCED"; + productId: string; + productCode: string | null; + sourcedProductId?: string; + name: string; + title: string | null; + description: string | null; + shortDescription: string | null; + retailPriceKobo: string | null; + compareAtPriceKobo: string | null; + platformCategory: string | null; + productSubCategory: string | null; + storeTags: string[]; + productDetails: Prisma.JsonValue | null; + sku: string | null; + status: ProductStatus; + imageUrl: string | null; + isFeatured: boolean; + hasVariants: boolean; + stockQuantity: number; + inStock: boolean; + variants: ProductVariantResponse[]; + images: ProductImageResponse[]; + sizeGuideConfig: ProductSizeGuideConfigResponse | null; + category: { + name: string; + slug: string; + }; + store: { + id: string; + handle: string | null; + name: string | null; + logoUrl: string | null; + tier: string; + isOpen: boolean; + averageRating: number | null; + reviewCount: number; + storePassBadge: StorePassPublicBadge | null; + }; + createdAt: string; + updatedAt: string; +} + +@Injectable() +export class ProductService { + private readonly logger = new Logger(ProductService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly inventoryService: InventoryService, + private readonly postService: PostService, + private readonly storePassService: StorePassService, + @InjectQueue(QUEUE.EMBEDDING) private readonly embeddingQueue: Queue, + ) {} + + async createProduct( + userId: string, + dto: CreateProductDto, + ): Promise { + const store = await this.getRequiredStore(userId); + this.assertCanManageNativeProducts(store); + const category = await this.resolveCategory(dto.platformCategory); + const retailPriceKobo = this.toPositiveBigInt( + dto.retailPriceKobo, + "RETAIL_PRICE_INVALID", + ); + const compareAtPriceKobo = dto.compareAtPriceKobo + ? this.toPositiveBigInt(dto.compareAtPriceKobo, "COMPARE_AT_INVALID") + : null; + const sourcing = this.resolveSourcing(store, { + allowDropship: dto.allowDropship, + dropshipperPriceKobo: dto.dropshipperPriceKobo, + }); + const status = dto.publishNow ? ProductStatus.ACTIVE : ProductStatus.DRAFT; + const productCode = await this.generateProductCode(); + const sourceCode = sourcing.allowDropship + ? await this.generateSourceCode() + : null; + const variants = this.buildVariantInputs( + dto.hasVariants ?? false, + dto.variantOptions, + ); + const images = this.normalizeImages(dto.images); + const sizeGuideConfig = this.resolveSizeGuideConfig( + dto.productSubCategory ?? null, + dto.sizeGuideConfig, + ); + + if (compareAtPriceKobo && compareAtPriceKobo <= retailPriceKobo) { + throw new BadRequestException({ + message: "Compare-at price must be greater than retail price", + code: "COMPARE_AT_PRICE_INVALID", + }); + } + + try { + const product = await this.prisma.$transaction(async (tx) => { + const createdProduct = await tx.product.create({ + data: { + storeId: store.id, + productCode, + sourceCode, + name: dto.name, + title: dto.name, + description: dto.description, + shortDescription: dto.shortDescription ?? null, + retailPriceKobo, + pricePerUnitKobo: retailPriceKobo, + compareAtPriceKobo, + platformCategory: category.name, + categoryTag: category.slug, + categoryId: category.id, + productSubCategory: dto.productSubCategory ?? null, + storeTags: dto.storeTags ?? [], + productDetails: this.toProductDetailsJson(dto.productDetails), + sku: dto.sku ?? null, + allowDropship: sourcing.allowDropship, + dropshipperPriceKobo: sourcing.dropshipperPriceKobo, + notSoldIndividually: dto.notSoldIndividually ?? false, + minimumOrderQty: dto.minimumOrderQty ?? null, + minOrderQuantity: dto.minimumOrderQty ?? 1, + minOrderQuantityConsumer: dto.minimumOrderQty ?? 1, + unit: dto.unit ?? "unit", + attributes: this.toAttributesJson(dto.attributes), + status, + isActive: status === ProductStatus.ACTIVE, + hasVariants: variants.length > 0, + imageUrl: this.getDefaultProductImageUrl(images), + }, + select: { id: true }, + }); + + const createdVariants = await this.createProductVariants( + tx, + createdProduct.id, + variants, + ); + await this.createProductImages( + tx, + createdProduct.id, + images, + createdVariants, + ); + await this.createProductSizeGuideConfig( + tx, + createdProduct.id, + sizeGuideConfig, + ); + await this.initializeProductStock( + tx, + createdProduct.id, + status, + dto.initialStock, + variants, + createdVariants, + ); + + // Publishing a product makes it live/buyable but does NOT auto-post it + // to the social feed. The store owner posts it to the feed explicitly + // from inventory via postProductToFeed(). + + return tx.product.findUniqueOrThrow({ + where: { id: createdProduct.id }, + select: PRODUCT_OWNER_SELECT, + }); + }); + + if (product.status === ProductStatus.ACTIVE) { + await this.enqueueEmbedding(product.id, product.updatedAt); + } + + return this.toOwnerResponse(product); + } catch (error) { + this.handleProductCreateError(error); + } + } + + async getPublicProduct(code: string): Promise { + const product = await this.prisma.product.findFirst({ + where: this.publicProductWhere({ + productCode: this.normalizeProductCode(code), + storeProfile: { + storeType: StoreType.PHYSICAL, + isOpen: true, + }, + }), + select: PRODUCT_PUBLIC_SELECT, + }); + + if (product) { + const response = this.toPublicResponse(product); + response.store.storePassBadge = + await this.storePassService.resolveStorePassPublicBadge( + product.storeProfile.id, + ); + return response; + } + + const sourcedProduct = await this.findPublicSourcedProductByCode(code); + + if (!sourcedProduct) { + throw new NotFoundException({ + message: "Product not found", + code: "PRODUCT_NOT_FOUND", + }); + } + + const response = this.toPublicSourcedResponse(sourcedProduct); + response.store.storePassBadge = + await this.storePassService.resolveStorePassPublicBadge( + sourcedProduct.digitalStore.id, + ); + return response; + } + + async updateProduct( + userId: string, + productId: string, + dto: UpdateProductDto, + ): Promise { + const store = await this.getRequiredStore(userId); + this.assertCanManageNativeProducts(store); + const product = await this.findOwnedProduct(productId, store.id); + const data: Prisma.ProductUpdateInput = {}; + let resolvedCategoryName: string | null = null; + + if (dto.name !== undefined) { + data.name = dto.name; + data.title = dto.name; + } + + if (dto.description !== undefined) { + data.description = dto.description; + } + + if (dto.shortDescription !== undefined) { + data.shortDescription = dto.shortDescription; + } + + if (dto.platformCategory !== undefined) { + const category = await this.resolveCategory(dto.platformCategory); + data.platformCategory = category.name; + data.categoryTag = category.slug; + data.category = { connect: { id: category.id } }; + resolvedCategoryName = category.name; + } + + if (dto.productSubCategory !== undefined) { + data.productSubCategory = dto.productSubCategory; + } + + if (dto.storeTags !== undefined) { + data.storeTags = dto.storeTags; + } + + if (dto.productDetails !== undefined) { + data.productDetails = this.toProductDetailsJson(dto.productDetails); + } + + if (dto.sku !== undefined) { + data.sku = dto.sku; + } + + if (dto.retailPriceKobo !== undefined) { + const retailPriceKobo = this.toPositiveBigInt( + dto.retailPriceKobo, + "RETAIL_PRICE_INVALID", + ); + data.retailPriceKobo = retailPriceKobo; + data.pricePerUnitKobo = retailPriceKobo; + } + + if (dto.compareAtPriceKobo !== undefined) { + data.compareAtPriceKobo = this.toPositiveBigInt( + dto.compareAtPriceKobo, + "COMPARE_AT_INVALID", + ); + } + + if ( + dto.allowDropship !== undefined || + dto.dropshipperPriceKobo !== undefined + ) { + const sourcing = this.resolveSourcing(store, { + allowDropship: dto.allowDropship ?? product.allowDropship, + dropshipperPriceKobo: + dto.dropshipperPriceKobo ?? + product.dropshipperPriceKobo?.toString() ?? + undefined, + }); + data.allowDropship = sourcing.allowDropship; + data.dropshipperPriceKobo = sourcing.dropshipperPriceKobo; + if (sourcing.allowDropship && !product.sourceCode) { + data.sourceCode = await this.generateSourceCode(); + } + } + + if (dto.notSoldIndividually !== undefined) { + data.notSoldIndividually = dto.notSoldIndividually; + } + + if (dto.minimumOrderQty !== undefined) { + data.minimumOrderQty = dto.minimumOrderQty; + data.minOrderQuantity = dto.minimumOrderQty; + data.minOrderQuantityConsumer = dto.minimumOrderQty; + } + + if (dto.unit !== undefined) { + data.unit = dto.unit; + } + + if (dto.attributes !== undefined) { + data.attributes = this.toAttributesJson(dto.attributes); + } + + if (dto.isFeatured !== undefined) { + data.isFeatured = dto.isFeatured; + } + + const variants = + dto.hasVariants !== undefined || dto.variantOptions !== undefined + ? this.buildVariantInputs(dto.hasVariants ?? true, dto.variantOptions) + : null; + const images = + dto.images !== undefined ? this.normalizeImages(dto.images) : null; + const effectiveProductSubCategory = + dto.productSubCategory !== undefined + ? dto.productSubCategory + : product.productSubCategory; + const sizeGuideConfig = + dto.productSubCategory !== undefined || dto.sizeGuideConfig !== undefined + ? this.resolveSizeGuideConfig( + effectiveProductSubCategory, + dto.sizeGuideConfig, + ) + : undefined; + + if (dto.status !== undefined) { + if (dto.status === ProductStatus.DELETED) { + throw new BadRequestException({ + message: "Use the delete endpoint to delete a product", + code: "PRODUCT_DELETE_ENDPOINT_REQUIRED", + }); + } + + data.status = dto.status; + data.isActive = dto.status === ProductStatus.ACTIVE; + } + + const newImageUrl = + images !== null ? this.getDefaultProductImageUrl(images) : null; + const finalStatus = dto.status ?? product.status; + const statusBecameActive = + dto.status !== undefined && + dto.status === ProductStatus.ACTIVE && + product.status !== ProductStatus.ACTIVE; + const embeddingFieldsChanged = + (dto.name !== undefined && dto.name !== product.name) || + (dto.description !== undefined && + dto.description !== product.description) || + (dto.shortDescription !== undefined && + dto.shortDescription !== product.shortDescription) || + (dto.platformCategory !== undefined && + resolvedCategoryName !== product.platformCategory) || + (dto.productSubCategory !== undefined && + dto.productSubCategory !== product.productSubCategory) || + (dto.storeTags !== undefined && + !this.stringArraysEqual(dto.storeTags, product.storeTags)) || + (images !== null && newImageUrl !== product.imageUrl); + const shouldEnqueueEmbedding = + finalStatus === ProductStatus.ACTIVE && + (embeddingFieldsChanged || statusBecameActive); + const shouldResetEmbedding = + finalStatus === ProductStatus.ACTIVE && embeddingFieldsChanged; + + const updatedProduct = await this.prisma.$transaction(async (tx) => { + await tx.product.update({ + where: { id: product.id }, + data: { + ...data, + ...(variants !== null ? { hasVariants: variants.length > 0 } : {}), + ...(images !== null ? { imageUrl: newImageUrl } : {}), + ...(shouldResetEmbedding + ? { embeddingReady: false, embeddingUpdatedAt: null } + : {}), + }, + }); + + if (shouldResetEmbedding) { + await tx.productEmbedding.updateMany({ + where: { productId: product.id }, + data: { embeddingReady: false, embeddingUpdatedAt: null }, + }); + } + + if (variants !== null) { + await tx.productVariant.deleteMany({ + where: { productId: product.id }, + }); + const createdVariants = await this.createProductVariants( + tx, + product.id, + variants, + ); + + if (images !== null) { + await tx.productImage.deleteMany({ + where: { productId: product.id }, + }); + await this.createProductImages( + tx, + product.id, + images, + createdVariants, + ); + } else { + await tx.productImage.updateMany({ + where: { productId: product.id }, + data: { assignedVariantIds: [] }, + }); + } + } else if (images !== null) { + await tx.productImage.deleteMany({ where: { productId: product.id } }); + await this.createProductImages( + tx, + product.id, + images, + product.variants, + ); + } + + if (sizeGuideConfig !== undefined) { + if (sizeGuideConfig) { + await tx.productSizeGuideConfig.upsert({ + where: { productId: product.id }, + update: sizeGuideConfig, + create: { + productId: product.id, + ...sizeGuideConfig, + }, + }); + } else { + await tx.productSizeGuideConfig.deleteMany({ + where: { productId: product.id }, + }); + } + } + + return tx.product.findUniqueOrThrow({ + where: { id: product.id }, + select: PRODUCT_OWNER_SELECT, + }); + }); + + if (shouldEnqueueEmbedding) { + await this.enqueueEmbedding(updatedProduct.id, updatedProduct.updatedAt); + } + + return this.toOwnerResponse(updatedProduct); + } + + async softDeleteProduct( + userId: string, + productId: string, + ): Promise<{ deleted: true; productId: string }> { + const store = await this.getRequiredStore(userId); + this.assertCanManageNativeProducts(store); + const product = await this.findOwnedProduct(productId, store.id); + + await this.prisma.product.update({ + where: { id: product.id }, + data: { + status: ProductStatus.DELETED, + isActive: false, + deletedAt: new Date(), + }, + }); + + return { deleted: true, productId: product.id }; + } + + async postProductToFeed( + userId: string, + productId: string, + ): Promise { + return this.setProductFeedState(userId, productId, true); + } + + async unpostProductFromFeed( + userId: string, + productId: string, + ): Promise { + return this.setProductFeedState(userId, productId, false); + } + + private async setProductFeedState( + userId: string, + productId: string, + active: boolean, + ): Promise { + const store = await this.getRequiredStore(userId); + this.assertCanManageNativeProducts(store); + const product = await this.findOwnedProduct(productId, store.id); + + if (active && product.status !== ProductStatus.ACTIVE) { + throw new BadRequestException({ + message: "Publish the product before posting it to the feed", + code: "PRODUCT_NOT_PUBLISHED", + }); + } + + await this.postService.setProductFeedPostActive(product.id, active); + + const updated = await this.prisma.product.findUniqueOrThrow({ + where: { id: product.id }, + select: PRODUCT_OWNER_SELECT, + }); + return this.toOwnerResponse(updated); + } + + async getOwnedProduct( + userId: string, + productId: string, + ): Promise { + const store = await this.getRequiredStore(userId); + this.assertCanManageNativeProducts(store); + const product = await this.findOwnedProduct(productId, store.id); + return this.toOwnerResponse(product); + } + + /** + * Owner preview of a product in the public shopper-facing shape — used to + * render the real product detail view in the inventory panel. Unlike the + * public endpoint, this ignores status/open constraints so drafts and + * closed-store products can still be previewed by their owner. + */ + async getOwnedProductPreview( + userId: string, + productId: string, + ): Promise { + const store = await this.getRequiredStore(userId); + this.assertCanManageNativeProducts(store); + const product = await this.prisma.product.findFirst({ + where: { id: productId, storeId: store.id, deletedAt: null }, + select: PRODUCT_PUBLIC_SELECT, + }); + + if (!product) { + throw new NotFoundException({ + message: "Product not found", + code: "PRODUCT_NOT_FOUND", + }); + } + + const response = this.toPublicResponse(product); + response.store.storePassBadge = + await this.storePassService.resolveStorePassPublicBadge( + product.storeProfile.id, + ); + return response; + } + + async listOwnProducts( + userId: string, + status?: string, + inventory?: string, + ): Promise { + const store = await this.getRequiredStore(userId); + this.assertCanManageNativeProducts(store); + const requestedStatus = status ? this.parseProductStatus(status) : null; + const requestedInventory = inventory + ? this.parseInventoryStockFilter(inventory) + : null; + const products = await this.prisma.product.findMany({ + where: { + storeId: store.id, + status: requestedStatus ?? { not: ProductStatus.DELETED }, + deletedAt: requestedStatus === ProductStatus.DELETED ? undefined : null, + }, + orderBy: { createdAt: "desc" }, + select: PRODUCT_OWNER_SELECT, + }); + + return products + .map((product) => this.toOwnerResponse(product)) + .filter((product) => + requestedInventory + ? this.matchesInventoryStockFilter(product, requestedInventory) + : true, + ); + } + + async listPublicStoreProducts( + handle: string, + ): Promise { + const cleanHandle = this.normalizeHandle(handle); + const [products, sourcedProducts] = await Promise.all([ + this.prisma.product.findMany({ + where: this.publicProductWhere({ + storeProfile: { + storeHandle: cleanHandle, + storeType: StoreType.PHYSICAL, + isOpen: true, + }, + }), + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + select: PRODUCT_PUBLIC_SELECT, + }), + this.prisma.sourcedProduct.findMany({ + where: this.publicSourcedProductWhere({ + digitalStore: { + storeHandle: cleanHandle, + storeType: StoreType.DIGITAL, + isOpen: true, + }, + }), + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + select: SOURCED_PUBLIC_SELECT, + }), + ]); + + return this.mergePublicProductsByCreatedAtDesc( + products.map((product) => this.toPublicResponse(product)), + sourcedProducts + .filter((sourcedProduct) => this.isNotSelfSourced(sourcedProduct)) + .map((sourcedProduct) => this.toPublicSourcedResponse(sourcedProduct)), + ); + } + + async getPublicStoreProduct( + handle: string, + code: string, + ): Promise { + const cleanHandle = this.normalizeHandle(handle); + const cleanCode = this.normalizeProductCode(code); + const product = await this.prisma.product.findFirst({ + where: this.publicProductWhere({ + productCode: cleanCode, + storeProfile: { + storeHandle: cleanHandle, + storeType: StoreType.PHYSICAL, + isOpen: true, + }, + }), + select: PRODUCT_PUBLIC_SELECT, + }); + + if (product) { + return this.toPublicResponse(product); + } + + const sourcedProduct = await this.prisma.sourcedProduct.findFirst({ + where: this.publicSourcedProductWhere({ + productCode: cleanCode, + digitalStore: { + storeHandle: cleanHandle, + storeType: StoreType.DIGITAL, + isOpen: true, + }, + }), + select: SOURCED_PUBLIC_SELECT, + }); + + if (!sourcedProduct || !this.isNotSelfSourced(sourcedProduct)) { + throw new NotFoundException({ + message: "Product not found", + code: "PRODUCT_NOT_FOUND", + }); + } + + return this.toPublicSourcedResponse(sourcedProduct); + } + + private async findPublicSourcedProductByCode( + code: string, + ): Promise { + const cleanCode = code.trim(); + const sourcedProduct = await this.prisma.sourcedProduct.findFirst({ + where: this.publicSourcedProductWhere({ + // Temporary legacy fallback: PR #424 used SourcedProduct.id and PR #425 + // used listingCode in public URLs before sourced productCode existed. + OR: [ + { productCode: this.normalizeProductCode(cleanCode) }, + { listingCode: this.normalizeProductCode(cleanCode) }, + { id: cleanCode }, + ], + digitalStore: { + storeType: StoreType.DIGITAL, + isOpen: true, + }, + }), + select: SOURCED_PUBLIC_SELECT, + }); + + return sourcedProduct && this.isNotSelfSourced(sourcedProduct) + ? sourcedProduct + : null; + } + + private buildVariantInputs( + hasVariants: boolean, + options: ProductVariantOptionsDto | undefined, + ): GeneratedVariantInput[] { + if (!hasVariants) { + return []; + } + + const colors = this.normalizeVariantValues(options?.colors, "colors"); + const sizes = this.normalizeVariantValues(options?.sizes, "sizes"); + + if (colors.length === 0 && sizes.length === 0) { + throw new BadRequestException({ + message: "At least one variant dimension is required", + code: "PRODUCT_VARIANTS_REQUIRED", + }); + } + + const baseVariants = + colors.length > 0 && sizes.length > 0 + ? colors.flatMap((colorName) => + sizes.map((sizeName) => ({ colorName, sizeName })), + ) + : colors.length > 0 + ? colors.map((colorName) => ({ colorName, sizeName: null })) + : sizes.map((sizeName) => ({ colorName: null, sizeName })); + + return baseVariants.map((variant) => { + const variantLabel = this.buildVariantLabel( + variant.colorName, + variant.sizeName, + ); + const override = options?.overrides?.find((item) => + this.isVariantOverrideMatch(item, variant.colorName, variant.sizeName), + ); + + return { + colorName: variant.colorName, + sizeName: variant.sizeName, + variantLabel, + priceOverrideKobo: override?.priceOverrideKobo + ? this.toPositiveBigInt( + override.priceOverrideKobo, + "VARIANT_PRICE_INVALID", + ) + : null, + sku: override?.sku ?? null, + isActive: override?.isActive ?? true, + initialStock: override?.initialStock ?? null, + }; + }); + } + + private async initializeProductStock( + tx: Prisma.TransactionClient, + productId: string, + status: ProductStatus, + initialStock: number | undefined, + variants: GeneratedVariantInput[], + createdVariants: ProductVariantRecord[], + ): Promise { + if (status !== ProductStatus.ACTIVE) { + return; + } + + if (variants.length === 0) { + if (initialStock === undefined) { + return; + } + + await this.inventoryService.recordEventInTransaction( + tx, + productId, + null, + InventoryEventType.INITIAL_STOCK, + initialStock, + undefined, + "Initial product stock", + ); + return; + } + + for (const [index, variant] of variants.entries()) { + if (variant.initialStock === null) { + continue; + } + + const createdVariant = createdVariants[index]; + await this.inventoryService.recordEventInTransaction( + tx, + productId, + createdVariant.id, + InventoryEventType.INITIAL_STOCK, + variant.initialStock, + undefined, + "Initial variant stock", + ); + } + } + + private async createProductVariants( + tx: Prisma.TransactionClient, + productId: string, + variants: GeneratedVariantInput[], + ): Promise { + const createdVariants: ProductVariantRecord[] = []; + + for (const variant of variants) { + const createdVariant = await tx.productVariant.create({ + data: { + productId, + colorName: variant.colorName, + sizeName: variant.sizeName, + variantLabel: variant.variantLabel, + priceOverrideKobo: variant.priceOverrideKobo, + sku: variant.sku, + isActive: variant.isActive, + }, + select: PRODUCT_VARIANT_SELECT, + }); + createdVariants.push(createdVariant); + } + + return createdVariants; + } + + private normalizeImages( + images: ProductImageInputDto[] | undefined, + ): ProductImageInputDto[] { + if (!images || images.length === 0) { + return []; + } + + const defaultCount = images.filter((image) => image.isDefault).length; + if (defaultCount > 1) { + throw new BadRequestException({ + message: "Only one product image can be the default", + code: "PRODUCT_IMAGE_DEFAULT_INVALID", + }); + } + + return images + .map((image, index) => ({ + ...image, + order: image.order ?? index, + isDefault: image.isDefault ?? (defaultCount === 0 && index === 0), + })) + .sort((left, right) => (left.order ?? 0) - (right.order ?? 0)); + } + + private async createProductImages( + tx: Prisma.TransactionClient, + productId: string, + images: ProductImageInputDto[], + variants: ProductVariantRecord[], + ): Promise { + for (const image of images) { + await tx.productImage.create({ + data: { + productId, + url: image.url, + order: image.order ?? 0, + isDefault: image.isDefault ?? false, + assignedVariantIds: this.resolveAssignedVariantIds(image, variants), + altText: image.altText ?? null, + moderationStatus: image.moderationStatus ?? ModerationStatus.SAFE, + cloudinaryPublicId: image.cloudinaryPublicId ?? null, + }, + }); + } + } + + private getDefaultProductImageUrl( + images: ProductImageInputDto[], + ): string | null { + return ( + images.find((image) => image.isDefault)?.url ?? images[0]?.url ?? null + ); + } + + private resolveSizeGuideConfig( + productSubCategory: GuideType | null, + config: ProductSizeGuideConfigDto | undefined, + ): ProductSizeGuideWriteData | null { + if (!productSubCategory) { + return null; + } + + return { + primaryGuideType: productSubCategory, + includesFootwear: config?.includesFootwear ?? false, + footwearGuideType: config?.footwearGuideType ?? null, + useCustomSizes: config?.useCustomSizes ?? false, + customSizes: config?.customSizes + ? (config.customSizes as Prisma.InputJsonObject) + : Prisma.JsonNull, + modelHeightCm: config?.modelHeightCm ?? null, + modelBustCm: config?.modelBustCm ?? null, + modelWaistCm: config?.modelWaistCm ?? null, + modelHipsCm: config?.modelHipsCm ?? null, + modelWearingSize: config?.modelWearingSize ?? null, + }; + } + + private async createProductSizeGuideConfig( + tx: Prisma.TransactionClient, + productId: string, + config: ProductSizeGuideWriteData | null, + ): Promise { + if (!config) { + return; + } + + await tx.productSizeGuideConfig.create({ + data: { + productId, + ...config, + }, + }); + } + + private normalizeVariantValues( + values: string[] | undefined, + field: string, + ): string[] { + const normalizedValues = (values ?? []) + .map((value) => value.trim()) + .filter((value) => value.length > 0); + const uniqueValues = new Set( + normalizedValues.map((value) => value.toLowerCase()), + ); + + if (uniqueValues.size !== normalizedValues.length) { + throw new BadRequestException({ + message: `Duplicate ${field} are not allowed`, + code: "PRODUCT_VARIANT_DUPLICATE", + }); + } + + return normalizedValues; + } + + private buildVariantLabel( + colorName: string | null, + sizeName: string | null, + ): string { + return [colorName, sizeName].filter(Boolean).join(" - "); + } + + private isVariantOverrideMatch( + override: { + colorName?: string; + sizeName?: string; + variantLabel?: string; + }, + colorName: string | null, + sizeName: string | null, + ): boolean { + const variantLabel = this.buildVariantLabel(colorName, sizeName); + + if (override.variantLabel) { + return ( + this.normalizeLabel(override.variantLabel) === + this.normalizeLabel(variantLabel) + ); + } + + return ( + this.normalizeNullableLabel(override.colorName) === + this.normalizeNullableLabel(colorName) && + this.normalizeNullableLabel(override.sizeName) === + this.normalizeNullableLabel(sizeName) + ); + } + + private resolveAssignedVariantIds( + image: ProductImageInputDto, + variants: ProductVariantRecord[], + ): string[] { + if (image.assignedVariantIds && image.assignedVariantIds.length > 0) { + const validIds = new Set(variants.map((variant) => variant.id)); + const unknownId = image.assignedVariantIds.find( + (id) => !validIds.has(id), + ); + + if (unknownId) { + throw new BadRequestException({ + message: "Image references an unknown product variant", + code: "PRODUCT_IMAGE_VARIANT_INVALID", + }); + } + + return image.assignedVariantIds; + } + + if ( + !image.assignedVariantLabels || + image.assignedVariantLabels.length === 0 + ) { + return []; + } + + return image.assignedVariantLabels.map((label) => { + const match = variants.find( + (variant) => + this.normalizeLabel(variant.variantLabel) === + this.normalizeLabel(label), + ); + + if (!match) { + throw new BadRequestException({ + message: "Image references an unknown product variant", + code: "PRODUCT_IMAGE_VARIANT_INVALID", + }); + } + + return match.id; + }); + } + + private normalizeLabel(value: string): string { + return value.trim().toLowerCase(); + } + + private normalizeNullableLabel(value: string | null | undefined): string { + return value ? this.normalizeLabel(value) : ""; + } + + private async getRequiredStore( + userId: string, + ): Promise { + const store = await this.prisma.storeProfile.findFirst({ + where: { + userId, + }, + select: { + id: true, + storeType: true, + isOpen: true, + }, + }); + + if (!store) { + throw new ForbiddenException({ + message: "A store profile is required to manage products", + code: "STORE_PROFILE_REQUIRED", + }); + } + + return store; + } + + private assertCanManageNativeProducts(store: StoreForProductOwnership): void { + if (store.storeType === StoreType.PHYSICAL) { + return; + } + + throw new ForbiddenException({ + message: "Native product management is available only to physical stores", + code: "NATIVE_PRODUCTS_PHYSICAL_ONLY", + }); + } + + private async findOwnedProduct( + productId: string, + storeId: string, + ): Promise { + const product = await this.prisma.product.findFirst({ + where: { + id: productId, + deletedAt: null, + }, + select: PRODUCT_OWNER_SELECT, + }); + + if (!product) { + throw new NotFoundException({ + message: "Product not found", + code: "PRODUCT_NOT_FOUND", + }); + } + + const ownership = await this.prisma.product.findFirst({ + where: { + id: productId, + storeId, + deletedAt: null, + }, + select: { id: true }, + }); + + if (!ownership) { + throw new ForbiddenException({ + message: "You cannot manage another store's product", + code: "PRODUCT_FORBIDDEN", + }); + } + + return product; + } + + private async resolveCategory(value: string): Promise { + const trimmedValue = value.trim(); + const slug = this.toSlug(trimmedValue); + + const category = await this.prisma.category.findFirst({ + where: { + isActive: true, + OR: [ + { id: trimmedValue }, + { slug: { equals: slug, mode: "insensitive" } }, + { name: { equals: trimmedValue, mode: "insensitive" } }, + ], + }, + select: { + id: true, + name: true, + slug: true, + }, + }); + + if (!category) { + throw new BadRequestException({ + message: "Product category not found", + code: "PRODUCT_CATEGORY_NOT_FOUND", + }); + } + + return category; + } + + private async generateProductCode(): Promise { + for (let attempt = 0; attempt < MAX_PRODUCT_CODE_ATTEMPTS; attempt += 1) { + const code = `TWZ-${randomInt(100000, 1000000)}`; + const existing = await this.prisma.product.findUnique({ + where: { productCode: code }, + select: { id: true }, + }); + const existingSourced = await this.prisma.sourcedProduct.findUnique({ + where: { productCode: code }, + select: { id: true }, + }); + + if (!existing && !existingSourced) { + return code; + } + } + + throw new ConflictException({ + message: "Could not generate a unique product code", + code: "PRODUCT_CODE_COLLISION", + }); + } + + private async generateSourceCode(): Promise { + for (let attempt = 0; attempt < MAX_PRODUCT_CODE_ATTEMPTS; attempt += 1) { + const code = `SRC-${randomInt(100000, 1000000)}`; + const existing = await this.prisma.product.findUnique({ + where: { sourceCode: code }, + select: { id: true }, + }); + + if (!existing) { + return code; + } + } + + throw new ConflictException({ + message: "Could not generate a unique source code", + code: "SOURCE_CODE_COLLISION", + }); + } + + private publicProductWhere( + where: Prisma.ProductWhereInput, + ): Prisma.ProductWhereInput { + return { + ...where, + status: PUBLIC_PRODUCT_STATUS, + isActive: true, + deletedAt: null, + }; + } + + private publicSourcedProductWhere( + where: Prisma.SourcedProductWhereInput, + ): Prisma.SourcedProductWhereInput { + return { + ...where, + isActive: true, + physicalProduct: { + status: PUBLIC_PRODUCT_STATUS, + isActive: true, + deletedAt: null, + allowDropship: true, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { + storeType: StoreType.PHYSICAL, + isOpen: true, + allowDropship: true, + }, + }, + }; + } + + private resolveSourcing( + store: StoreForProductOwnership, + dto: { + allowDropship?: boolean; + dropshipperPriceKobo?: string; + }, + ): { + allowDropship: boolean; + dropshipperPriceKobo: bigint | null; + } { + const allowDropship = dto.allowDropship ?? false; + + if (!allowDropship && dto.dropshipperPriceKobo) { + throw new BadRequestException({ + message: "Dropshipper price requires allowDropship to be enabled", + code: "DROPSHIP_PRICE_NOT_ALLOWED", + }); + } + + if (allowDropship && store.storeType !== StoreType.PHYSICAL) { + throw new BadRequestException({ + message: "Only physical stores can allow sourcing", + code: "SOURCING_PHYSICAL_STORE_REQUIRED", + }); + } + + return { + allowDropship, + dropshipperPriceKobo: dto.dropshipperPriceKobo + ? this.toPositiveBigInt( + dto.dropshipperPriceKobo, + "DROPSHIPPER_PRICE_INVALID", + ) + : null, + }; + } + + private toProductDetailsJson( + details: ProductDetailDto[] | undefined, + ): Prisma.InputJsonValue | typeof Prisma.JsonNull { + if (!details || details.length === 0) { + return Prisma.JsonNull; + } + + return details.map((detail) => ({ + attribute: detail.attribute, + value: detail.value, + })); + } + + private toAttributesJson( + attributes: Record | undefined, + ): Prisma.InputJsonValue { + return attributes ? (attributes as Prisma.InputJsonObject) : {}; + } + + private toPositiveBigInt(value: string, code: string): bigint { + const amount = BigInt(value); + + if (amount <= 0n) { + throw new BadRequestException({ + message: "Money values must be positive kobo integers", + code, + }); + } + + return amount; + } + + private normalizeProductCode(code: string): string { + return code.trim().toUpperCase(); + } + + private parseProductStatus(status: string): ProductStatus { + const normalizedStatus = status.trim().toUpperCase(); + const match = Object.values(ProductStatus).find( + (value) => value === normalizedStatus, + ); + + if (!match) { + throw new BadRequestException({ + message: "Unsupported product status filter", + code: "PRODUCT_STATUS_INVALID", + }); + } + + return match; + } + + private normalizeHandle(handle: string): string { + return handle.replace(/^@/, "").trim().toLowerCase(); + } + + private toSlug(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + } + + private toOwnerResponse(product: OwnerProductRecord): OwnerProductResponse { + return { + id: product.id, + productCode: product.productCode, + sourceCode: product.sourceCode, + name: product.name, + title: product.title, + description: product.description, + shortDescription: product.shortDescription, + retailPriceKobo: product.retailPriceKobo?.toString() ?? null, + compareAtPriceKobo: product.compareAtPriceKobo?.toString() ?? null, + platformCategory: product.platformCategory, + productSubCategory: product.productSubCategory, + storeTags: product.storeTags, + productDetails: product.productDetails, + sku: product.sku, + allowDropship: product.allowDropship, + dropshipperPriceKobo: product.dropshipperPriceKobo?.toString() ?? null, + notSoldIndividually: product.notSoldIndividually, + minimumOrderQty: product.minimumOrderQty, + unit: product.unit, + attributes: product.attributes, + status: product.status, + isActive: product.isActive, + isFeatured: product.isFeatured, + imageUrl: product.imageUrl, + hasVariants: product.hasVariants, + variants: product.variants.map((variant) => + this.toVariantResponse(variant), + ), + images: product.images.map((image) => this.toImageResponse(image)), + sizeGuideConfig: product.sizeGuideConfig + ? this.toSizeGuideConfigResponse(product.sizeGuideConfig) + : null, + createdAt: product.createdAt.toISOString(), + updatedAt: product.updatedAt.toISOString(), + deletedAt: product.deletedAt?.toISOString() ?? null, + isPostedToFeed: product.taggedPosts.length > 0, + feedPostId: product.taggedPosts[0]?.id ?? null, + ...this.toStockSummary(product.productStockCaches), + }; + } + + private parseInventoryStockFilter(value: string): InventoryStockFilter { + const normalized = value.trim().toLowerCase(); + if ( + normalized === "in_stock" || + normalized === "low_stock" || + normalized === "out_of_stock" || + normalized === "high_stock" + ) { + return normalized; + } + + throw new BadRequestException({ + code: "INVALID_INVENTORY_FILTER", + message: "Inventory filter is not supported", + }); + } + + private matchesInventoryStockFilter( + product: Pick, + filter: InventoryStockFilter, + ): boolean { + switch (filter) { + case "in_stock": + return product.stockQuantity > 0; + case "low_stock": + return product.stockStatus === "LOW_STOCK"; + case "out_of_stock": + return product.stockStatus === "OUT_OF_STOCK"; + case "high_stock": + return product.stockStatus === "HIGH_STOCK"; + } + } + + private toStockSummary( + stockCaches: Array<{ stock: number; lowStockThreshold: number }>, + ): ProductStockSummary { + const stockQuantity = stockCaches.reduce( + (total, cache) => total + Math.max(cache.stock, 0), + 0, + ); + const lowStockThreshold = stockCaches.reduce( + (threshold, cache) => Math.max(threshold, cache.lowStockThreshold), + 5, + ); + + if (stockQuantity <= 0) { + return { stockQuantity, stockStatus: "OUT_OF_STOCK" }; + } + + if (stockQuantity <= lowStockThreshold) { + return { stockQuantity, stockStatus: "LOW_STOCK" }; + } + + if (stockQuantity > HIGH_STOCK_THRESHOLD) { + return { stockQuantity, stockStatus: "HIGH_STOCK" }; + } + + return { stockQuantity, stockStatus: "IN_STOCK" }; + } + + private toPublicResponse( + product: PublicProductRecord, + ): PublicProductResponse { + const variantStock = new Map(); + for (const cache of product.productStockCaches) { + if (cache.variantId) { + variantStock.set(cache.variantId, Math.max(cache.stock, 0)); + } + } + const stockQuantity = product.productStockCaches.reduce( + (total, cache) => total + Math.max(cache.stock, 0), + 0, + ); + + return { + listingType: "NATIVE", + productId: product.id, + productCode: product.productCode, + name: product.name, + title: product.title, + description: product.description, + shortDescription: product.shortDescription, + retailPriceKobo: product.retailPriceKobo?.toString() ?? null, + compareAtPriceKobo: product.compareAtPriceKobo?.toString() ?? null, + platformCategory: product.platformCategory, + productSubCategory: product.productSubCategory, + storeTags: product.storeTags, + productDetails: product.productDetails, + sku: product.sku, + status: product.status, + imageUrl: product.imageUrl, + isFeatured: product.isFeatured, + hasVariants: product.hasVariants, + stockQuantity, + inStock: stockQuantity > 0, + variants: product.variants.map((variant) => ({ + ...this.toVariantResponse(variant), + stock: variantStock.get(variant.id) ?? 0, + })), + images: product.images.map((image) => this.toImageResponse(image)), + sizeGuideConfig: product.sizeGuideConfig + ? this.toSizeGuideConfigResponse(product.sizeGuideConfig) + : null, + category: { + name: product.category.name, + slug: product.category.slug, + }, + store: { + id: product.storeProfile.id, + handle: product.storeProfile.storeHandle, + name: product.storeProfile.storeName, + logoUrl: product.storeProfile.logoUrl, + tier: product.storeProfile.tier, + isOpen: product.storeProfile.isOpen, + averageRating: product.storeProfile.averageRating, + reviewCount: product.storeProfile.reviewCount, + storePassBadge: null, + }, + createdAt: product.createdAt.toISOString(), + updatedAt: product.updatedAt.toISOString(), + }; + } + + private toPublicSourcedResponse( + sourcedProduct: PublicSourcedProductRecord, + ): PublicProductResponse { + const physicalProduct = sourcedProduct.physicalProduct; + const customImages = this.parsePublicSourcedImages( + sourcedProduct.customImages, + sourcedProduct, + ); + const category = physicalProduct.category ?? { + name: physicalProduct.platformCategory ?? "Products", + slug: this.toSlug(physicalProduct.platformCategory ?? "products"), + }; + const stockQuantity = physicalProduct.productStockCaches.reduce( + (total, cache) => total + Math.max(cache.stock, 0), + 0, + ); + + return { + listingType: "SOURCED", + productId: sourcedProduct.id, + productCode: sourcedProduct.productCode, + sourcedProductId: sourcedProduct.id, + name: + sourcedProduct.customTitle ?? + physicalProduct.title ?? + physicalProduct.name, + title: sourcedProduct.customTitle ?? physicalProduct.title, + description: + sourcedProduct.customDescription ?? physicalProduct.description, + shortDescription: physicalProduct.shortDescription, + retailPriceKobo: sourcedProduct.sellingPriceKobo.toString(), + compareAtPriceKobo: null, + platformCategory: physicalProduct.platformCategory, + productSubCategory: physicalProduct.productSubCategory, + storeTags: physicalProduct.storeTags, + productDetails: physicalProduct.productDetails, + sku: null, + status: ProductStatus.ACTIVE, + imageUrl: + customImages.find((image) => image.isDefault)?.url ?? + customImages[0]?.url ?? + physicalProduct.imageUrl, + isFeatured: false, + hasVariants: false, + stockQuantity, + inStock: stockQuantity > 0, + variants: [], + images: customImages, + sizeGuideConfig: physicalProduct.sizeGuideConfig + ? this.toSizeGuideConfigResponse(physicalProduct.sizeGuideConfig) + : null, + category, + store: { + id: sourcedProduct.digitalStore.id, + handle: sourcedProduct.digitalStore.storeHandle, + name: sourcedProduct.digitalStore.storeName, + logoUrl: sourcedProduct.digitalStore.logoUrl, + tier: sourcedProduct.digitalStore.tier, + isOpen: sourcedProduct.digitalStore.isOpen, + averageRating: null, + reviewCount: 0, + storePassBadge: null, + }, + createdAt: sourcedProduct.createdAt.toISOString(), + updatedAt: sourcedProduct.updatedAt.toISOString(), + }; + } + + private parsePublicSourcedImages( + value: Prisma.JsonValue | null, + sourcedProduct: PublicSourcedProductRecord, + ): ProductImageResponse[] { + if (!Array.isArray(value)) { + return sourcedProduct.physicalProduct.images.map((image) => + this.toImageResponse(image), + ); + } + + const images: ProductImageResponse[] = []; + + value.forEach((item, index) => { + if (!this.isPublicSourcedImageObject(item)) { + return; + } + + images.push({ + id: `sourced-${sourcedProduct.id}-${index}`, + url: item.url, + order: index, + isDefault: index === 0, + assignedVariantIds: [], + altText: item.altText ?? null, + moderationStatus: ModerationStatus.SAFE, + cloudinaryPublicId: item.cloudinaryPublicId ?? null, + createdAt: sourcedProduct.createdAt.toISOString(), + }); + }); + + return images.length > 0 + ? images + : sourcedProduct.physicalProduct.images.map((image) => + this.toImageResponse(image), + ); + } + + private isPublicSourcedImageObject(value: Prisma.JsonValue): value is { + url: string; + cloudinaryPublicId?: string | null; + altText?: string | null; + } { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + "url" in value && + typeof value.url === "string" && + (!("cloudinaryPublicId" in value) || + value.cloudinaryPublicId === null || + typeof value.cloudinaryPublicId === "string") && + (!("altText" in value) || + value.altText === null || + typeof value.altText === "string") + ); + } + + private isNotSelfSourced( + sourcedProduct: PublicSourcedProductRecord, + ): boolean { + return ( + sourcedProduct.digitalStore.userId !== + sourcedProduct.physicalProduct.storeProfile.userId + ); + } + + private mergePublicProductsByCreatedAtDesc( + nativeProducts: PublicProductResponse[], + sourcedProducts: PublicProductResponse[], + ): PublicProductResponse[] { + const merged: PublicProductResponse[] = []; + let nativeIndex = 0; + let sourcedIndex = 0; + + while ( + nativeIndex < nativeProducts.length || + sourcedIndex < sourcedProducts.length + ) { + const nativeProduct = nativeProducts[nativeIndex]; + const sourcedProduct = sourcedProducts[sourcedIndex]; + + if (!nativeProduct) { + merged.push(sourcedProduct); + sourcedIndex += 1; + continue; + } + + if (!sourcedProduct) { + merged.push(nativeProduct); + nativeIndex += 1; + continue; + } + + if (this.comparePublicProductOrder(nativeProduct, sourcedProduct) <= 0) { + merged.push(nativeProduct); + nativeIndex += 1; + } else { + merged.push(sourcedProduct); + sourcedIndex += 1; + } + } + + return merged; + } + + private comparePublicProductOrder( + left: PublicProductResponse, + right: PublicProductResponse, + ): number { + const leftCreatedAt = new Date(left.createdAt).getTime(); + const rightCreatedAt = new Date(right.createdAt).getTime(); + + if (leftCreatedAt !== rightCreatedAt) { + return rightCreatedAt - leftCreatedAt; + } + + return right.productId.localeCompare(left.productId); + } + + private toVariantResponse( + variant: ProductVariantRecord, + ): ProductVariantResponse { + return { + id: variant.id, + colorName: variant.colorName, + sizeName: variant.sizeName, + variantLabel: variant.variantLabel, + priceOverrideKobo: variant.priceOverrideKobo?.toString() ?? null, + sku: variant.sku, + isActive: variant.isActive, + createdAt: variant.createdAt.toISOString(), + updatedAt: variant.updatedAt.toISOString(), + }; + } + + private toImageResponse( + image: Prisma.ProductImageGetPayload<{ + select: typeof PRODUCT_IMAGE_SELECT; + }>, + ): ProductImageResponse { + return { + id: image.id, + url: image.url, + order: image.order, + isDefault: image.isDefault, + assignedVariantIds: image.assignedVariantIds, + altText: image.altText, + moderationStatus: image.moderationStatus, + cloudinaryPublicId: image.cloudinaryPublicId, + createdAt: image.createdAt.toISOString(), + }; + } + + private toSizeGuideConfigResponse( + config: Prisma.ProductSizeGuideConfigGetPayload<{ + select: typeof PRODUCT_SIZE_GUIDE_SELECT; + }>, + ): ProductSizeGuideConfigResponse { + return { + id: config.id, + primaryGuideType: config.primaryGuideType, + includesFootwear: config.includesFootwear, + footwearGuideType: config.footwearGuideType, + useCustomSizes: config.useCustomSizes, + customSizes: config.customSizes, + modelHeightCm: config.modelHeightCm, + modelBustCm: config.modelBustCm, + modelWaistCm: config.modelWaistCm, + modelHipsCm: config.modelHipsCm, + modelWearingSize: config.modelWearingSize, + createdAt: config.createdAt.toISOString(), + updatedAt: config.updatedAt.toISOString(), + }; + } + + private async enqueueEmbedding( + productId: string, + productUpdatedAt: Date, + ): Promise { + const revision = productUpdatedAt.getTime(); + try { + await this.embeddingQueue.add( + "generate-product-embedding", + { productId, productUpdatedAt: productUpdatedAt.toISOString() }, + { + // Revisioned jobId: BullMQ v5 silently dedupes adds that reuse a + // jobId of any non-removed job (waiting, active, or completed-but- + // retained), so a stable per-product id would drop the new edit + // while an older job is still active. Including updatedAt makes + // every distinct edit produce a distinct jobId. + jobId: `embedding:${productId}:${revision}`, + attempts: 3, + backoff: { type: "exponential", delay: 5000 }, + removeOnComplete: true, + removeOnFail: 100, + }, + ); + } catch (error) { + const errorType = error instanceof Error ? error.name : typeof error; + this.logger.error( + `Failed to enqueue embedding job for product ${productId} (type=${errorType})`, + ); + } + } + + private stringArraysEqual(a: string[], b: string[]): boolean { + if (a === b) { + return true; + } + if (a.length !== b.length) { + return false; + } + for (let index = 0; index < a.length; index += 1) { + if (a[index] !== b[index]) { + return false; + } + } + return true; + } + + private handleProductCreateError(error: unknown): never { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + throw new ConflictException({ + message: "Product conflicts with an existing record", + code: "PRODUCT_CONFLICT", + }); + } + + throw error; + } +} diff --git a/apps/backend/src/domains/commerce/search/README.md b/apps/backend/src/domains/commerce/search/README.md new file mode 100644 index 00000000..ae9e3534 --- /dev/null +++ b/apps/backend/src/domains/commerce/search/README.md @@ -0,0 +1,133 @@ +# Search Domain + +Hybrid search boundary for product, store, and post discovery using text, metadata, trust, and vectors. + +## Taxonomy-aware discovery + +`TaxonomyDiscoveryService` (`taxonomy-discovery.service.ts`) turns shopper product +intent — "wristwatch", "kicks", "phone case", "power bank", "rice" — into +structured category hints using the shared, deterministic taxonomy resolver in +`@twizrr/shared` (`resolveProductTaxonomyIntent`). + +Key rules: + +- **Shared taxonomy remains the single source of truth.** The resolver is built + from `PRODUCT_CATEGORY_TREE` in `packages/shared/src/constants/categories.ts`; + the taxonomy is never duplicated. A small synonym dictionary + (`product-taxonomy-synonyms.ts`) maps shopper language to real taxonomy labels. +- **Taxonomy is a deterministic category/filter/boost signal, not a retrieval + engine.** It improves candidate quality by mapping intent to `categoryTag` / + `platformCategory` search terms. It does **not** replace hybrid (keyword + + vector) search or its ranking. +- **Eligibility rules remain the final safety gate.** Store tier (Tier 0 always + excluded), active/public status, and inventory rules still decide what can be + shown; taxonomy only widens/orders candidates within those rules. +- The helper performs no external calls (no Gemini/Vertex/Cloud Vision), exposes + no internal IDs or private fields, and is safe for guest discovery after consent. + +WIZZA text discovery (`channels/whatsapp/whatsapp-product-discovery.service.ts`) +uses it in two ways: + +1. **Recall** — taxonomy category labels are added as extra keyword conditions so + relevant candidates are retrieved. +2. **Ranking** — taxonomy is an additive signal in the hybrid score + (`whatsapp-search-ranking.ts`, `computeTaxonomyMatchScore` + + `TAXONOMY_BOOST_WEIGHT`). An exact subcategory match gets the strongest boost, + a parent-category match a smaller one, scaled by query-side confidence. An + exact subcategory therefore outranks a broad parent match (e.g. "phone case" + ranks Phone Cases above Smartphones). + +The taxonomy boost is applied **outside** the normalized `SEARCH_WEIGHT_*` +sum-to-1.0 contract, so it adds no env vars and does not change the existing +vector / text / store-performance / tier ranking. When a query has no confident +taxonomy match, every candidate's boost is 0 and ranking is byte-for-byte the +existing hybrid behavior. Eligibility (Tier 0 exclusion, active/public/in-stock, +sourced-product privacy) remains the final safety gate — taxonomy only reorders +candidates already allowed through. + +WIZZA image discovery (`channels/whatsapp/image-search.service.ts`) reuses the +same signal. Cloud Vision labels / localized objects / OCR text are passed +through `TaxonomyDiscoveryService.resolveDiscoveryHintsFromLabels` to derive the +same taxonomy hints, which then feed `computeTaxonomyMatchScore`. The Vertex +image embedding remains the **primary** retrieval signal: similarity is quantized +into narrow bands (`IMAGE_TAXONOMY_SIMILARITY_BAND`) and taxonomy acts only as a +**within-band tie-breaker**. A candidate in a stronger similarity band always +ranks first, so a broad or noisy Vision label can reorder near-equal matches but +can never lift a visually weaker product above a clearly stronger embedding match. +SafeSearch still screens the image before any enrichment or embedding runs; +unsafe images stop immediately and never reach taxonomy, embedding, or the +recent-results cache. When labels resolve to no confident taxonomy, ranking is +identical to the existing embedding-first behavior. + +## Taxonomy zero-result signals + +When WIZZA discovery understands a shopper's intent (taxonomy resolves with +useful confidence) but returns **zero eligible products**, a safe internal +analytics signal is recorded to surface category demand/supply gaps (e.g. +shoppers ask for "wristwatch" but no eligible Wristwatches exist). + +- **Recorded only after eligibility filters run**, so the count reflects a real + eligible-supply gap — not a false signal. Tier 0 exclusion and + active/public/in-stock rules still apply first; sourced-product privacy is + unchanged. +- **Image gaps are verified against actual category supply.** A vector top-N + miss is not proof of a gap, so before recording an image signal a direct + category eligibility check (`categoryHasEligibleProducts`, native + sourced) + must confirm zero eligible products in the resolved category. Text discovery + already runs a category-inclusive keyword query, so its zero is a genuine + category zero. +- **Persistence reuses the existing `WhatsAppAnalytics` record** — no new model + and no migration. The signal sets `categoryInferred` and is merged into the + `parsedFilters` JSON of the single per-message write (no double-write). See + `domains/commerce/search/search-zero-result.ts` + (`buildTaxonomyZeroResultSignal`) — the shared contract the WhatsApp channel + writes and the insights service reads. +- **Analytics only** — no shopper-facing behavior, copy, or ranking changes, and + the signal is never exposed to shoppers. +- **Safe metadata only**: taxonomy parent/subcategory labels, matched terms, + confidence, a capped normalized query (TEXT only), result count, and a + reason code. Image search stores **no** query text, image data, base64, signed + URLs, raw Cloud Vision/Vertex payloads, or embeddings. +- **Not recorded** for: unknown/non-product queries (no confident taxonomy), + store-management refusals, account/order/payment gated actions, or images + blocked by SafeSearch / failed downloads (never counted as product demand). + +## Category demand insights + +`CategoryDemandInsightsService` (`category-demand-insights.service.ts`) turns the +taxonomy zero-result signals above into internal, aggregate-only demand insights: +which categories shoppers searched for but did not find, repeated zero-result +demand by subcategory, and — optionally — how that demand compares to current +eligible supply. + +- **Reads existing analytics.** It aggregates + `WhatsAppAnalytics.parsedFilters.taxonomyZeroResult` rows in a date window — no + new model, no Prisma migration. +- **Aggregate/internal only.** Returns category labels, counts, search-mode + breakdown, unique user/session counts, top matched terms, reason codes, and + time bounds. Never returns raw phones, queries, user PII, request bodies, image + data, provider payloads, or embeddings. +- **Parent resolved from taxonomy.** A subcategory's parent is resolved + deterministically via the shared resolver (`resolveProductTaxonomyLabel`), not + guessed from the flat label arrays. +- **Optional supply context** (`includeSupply`): per returned insight, counts + eligible **native** active/in-stock products and eligible stores using the same + discovery gates (Tier 0 excluded, active/public, in-stock via + `ProductStockCache`, store open). Bounded to the already-limited insight set; + sourced/dropship supply is a follow-up. Supply-lookup failures degrade to zeroed + counts, never throw. +- **Bounded queries.** Default window last 7 days, default limit 20, max 100, and + a hard cap on rows scanned per query — HTTP callers can never run an unbounded + analytics query. + +Exposed internally at `GET /admin/search/category-demand-insights` +(`JwtAuthGuard` + `RolesGuard`, `SUPER_ADMIN` / `OPERATOR` only). Not exposed to +shoppers or store owners. + +### Follow-ups + +- Admin dashboard card/table over these insights. +- Store/category supply-gap recommendations and store-recruitment targeting + (including sourced/dropship supply). +- StorePass growth analytics tie-in (category demand → store recruitment). +- Category-aware campaign opportunities and package/delivery hints. diff --git a/apps/backend/src/domains/commerce/search/category-demand-insights.controller.spec.ts b/apps/backend/src/domains/commerce/search/category-demand-insights.controller.spec.ts new file mode 100644 index 00000000..0fd09bd5 --- /dev/null +++ b/apps/backend/src/domains/commerce/search/category-demand-insights.controller.spec.ts @@ -0,0 +1,69 @@ +import { BadRequestException } from "@nestjs/common"; +import { UserRole } from "@twizrr/shared"; + +import { ROLES_KEY } from "../../../common/decorators/roles.decorator"; +import { CategoryDemandInsightsController } from "./category-demand-insights.controller"; + +describe("CategoryDemandInsightsController", () => { + const insightsService = { getCategoryDemandInsights: jest.fn() }; + let controller: CategoryDemandInsightsController; + + beforeEach(() => { + jest.clearAllMocks(); + insightsService.getCategoryDemandInsights.mockResolvedValue([]); + controller = new CategoryDemandInsightsController(insightsService as any); + }); + + it("is restricted to admin/operator roles", () => { + const roles = Reflect.getMetadata( + ROLES_KEY, + CategoryDemandInsightsController, + ); + expect(roles).toEqual([UserRole.SUPER_ADMIN]); + }); + + it("defaults to the last 7 days and ALL modes when no params are given", async () => { + const before = Date.now(); + await controller.getCategoryDemandInsights({}); + const call = insightsService.getCategoryDemandInsights.mock.calls[0][0]; + + expect(call.searchMode).toBe("ALL"); + expect(call.includeSupply).toBe(false); + const windowMs = call.to.getTime() - call.from.getTime(); + expect(windowMs).toBe(7 * 24 * 60 * 60 * 1000); + expect(call.to.getTime()).toBeGreaterThanOrEqual(before); + }); + + it("passes through explicit params", async () => { + await controller.getCategoryDemandInsights({ + from: "2026-06-01T00:00:00.000Z", + to: "2026-06-08T00:00:00.000Z", + limit: 5, + searchMode: "IMAGE", + includeSupply: true, + }); + const call = insightsService.getCategoryDemandInsights.mock.calls[0][0]; + + expect(call.from).toEqual(new Date("2026-06-01T00:00:00.000Z")); + expect(call.to).toEqual(new Date("2026-06-08T00:00:00.000Z")); + expect(call.limit).toBe(5); + expect(call.searchMode).toBe("IMAGE"); + expect(call.includeSupply).toBe(true); + }); + + it("rejects an inverted date range", async () => { + await expect( + controller.getCategoryDemandInsights({ + from: "2026-06-08T00:00:00.000Z", + to: "2026-06-01T00:00:00.000Z", + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(insightsService.getCategoryDemandInsights).not.toHaveBeenCalled(); + }); + + it("rejects an unparseable date", async () => { + await expect( + controller.getCategoryDemandInsights({ from: "not-a-date" }), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/apps/backend/src/domains/commerce/search/category-demand-insights.controller.ts b/apps/backend/src/domains/commerce/search/category-demand-insights.controller.ts new file mode 100644 index 00000000..7a6a1360 --- /dev/null +++ b/apps/backend/src/domains/commerce/search/category-demand-insights.controller.ts @@ -0,0 +1,72 @@ +import { + BadRequestException, + Controller, + Get, + Query, + UseGuards, +} from "@nestjs/common"; +import { UserRole } from "@twizrr/shared"; + +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { RolesGuard } from "../../../common/guards/roles.guard"; +import { Roles } from "../../../common/decorators/roles.decorator"; +import { + CategoryDemandInsight, + CategoryDemandInsightsService, +} from "./category-demand-insights.service"; +import { CategoryDemandInsightsQueryDto } from "./dto/category-demand-insights.query.dto"; + +const DEFAULT_WINDOW_DAYS = 7; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** + * Internal admin-only category demand insights. Aggregate/internal analytics — + * never exposed to shoppers or store owners. Guarded by JWT + roles + * (SUPER_ADMIN / OPERATOR), matching the existing admin controller convention. + */ +@Controller("admin/search") +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.SUPER_ADMIN) +export class CategoryDemandInsightsController { + constructor( + private readonly insightsService: CategoryDemandInsightsService, + ) {} + + @Get("category-demand-insights") + async getCategoryDemandInsights( + @Query() query: CategoryDemandInsightsQueryDto, + ): Promise { + const { from, to } = this.resolveRange(query.from, query.to); + return this.insightsService.getCategoryDemandInsights({ + from, + to, + limit: query.limit, + searchMode: query.searchMode ?? "ALL", + includeSupply: query.includeSupply ?? false, + }); + } + + private resolveRange( + fromInput?: string, + toInput?: string, + ): { from: Date; to: Date } { + const to = toInput ? new Date(toInput) : new Date(); + const from = fromInput + ? new Date(fromInput) + : new Date(to.getTime() - DEFAULT_WINDOW_DAYS * MS_PER_DAY); + + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) { + throw new BadRequestException({ + message: "Invalid date range", + code: "INVALID_DATE_RANGE", + }); + } + if (from > to) { + throw new BadRequestException({ + message: "`from` must be before `to`", + code: "INVALID_DATE_RANGE", + }); + } + return { from, to }; + } +} diff --git a/apps/backend/src/domains/commerce/search/category-demand-insights.module.ts b/apps/backend/src/domains/commerce/search/category-demand-insights.module.ts new file mode 100644 index 00000000..976063ff --- /dev/null +++ b/apps/backend/src/domains/commerce/search/category-demand-insights.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; + +import { CategoryDemandInsightsController } from "./category-demand-insights.controller"; +import { CategoryDemandInsightsService } from "./category-demand-insights.service"; + +@Module({ + controllers: [CategoryDemandInsightsController], + providers: [CategoryDemandInsightsService], + exports: [CategoryDemandInsightsService], +}) +export class CategoryDemandInsightsModule {} diff --git a/apps/backend/src/domains/commerce/search/category-demand-insights.service.spec.ts b/apps/backend/src/domains/commerce/search/category-demand-insights.service.spec.ts new file mode 100644 index 00000000..178dfc4b --- /dev/null +++ b/apps/backend/src/domains/commerce/search/category-demand-insights.service.spec.ts @@ -0,0 +1,333 @@ +import { CategoryDemandInsightsService } from "./category-demand-insights.service"; + +type Mode = "TEXT" | "IMAGE"; + +function eventRow(opts: { + sub?: string[]; + parents?: string[]; + mode?: Mode; + terms?: string[]; + reason?: string; + userId?: string | null; + sessionId?: string; + createdAt?: Date; +}) { + return { + userId: opts.userId === undefined ? "user-1" : opts.userId, + sessionId: opts.sessionId ?? "session-1", + createdAt: opts.createdAt ?? new Date("2026-07-01T00:00:00.000Z"), + parsedFilters: { + taxonomyZeroResult: { + signal: "WIZZA_SEARCH_ZERO_RESULTS", + channel: "WHATSAPP", + searchMode: opts.mode ?? "TEXT", + taxonomyParentLabels: opts.parents ?? [], + taxonomySubcategoryLabels: opts.sub ?? [], + taxonomyConfidence: "high", + matchedTerms: opts.terms ?? [], + normalizedQuery: null, + searchResultCount: 0, + zeroResults: true, + reasonCode: opts.reason ?? "NO_ELIGIBLE_PRODUCTS", + }, + }, + }; +} + +describe("CategoryDemandInsightsService", () => { + const prisma = { + whatsAppAnalytics: { findMany: jest.fn() }, + product: { count: jest.fn(), groupBy: jest.fn() }, + }; + let service: CategoryDemandInsightsService; + + const range = { + from: new Date("2026-06-25T00:00:00.000Z"), + to: new Date("2026-07-05T00:00:00.000Z"), + }; + + beforeEach(() => { + jest.clearAllMocks(); + service = new CategoryDemandInsightsService(prisma as any); + }); + + it("aggregates zero-result signals by subcategory and resolves the parent from taxonomy", async () => { + prisma.whatsAppAnalytics.findMany.mockResolvedValue([ + eventRow({ sub: ["Wristwatches"], parents: [] }), + eventRow({ sub: ["Wristwatches"], parents: [] }), + ]); + + const [insight] = await service.getCategoryDemandInsights(range); + + expect(insight).toMatchObject({ + parentCategoryLabel: "Watches & Clocks", + subcategoryLabel: "Wristwatches", + zeroResultCount: 2, + }); + }); + + it("aggregates parent-only demand when no subcategory is present", async () => { + prisma.whatsAppAnalytics.findMany.mockResolvedValue([ + eventRow({ sub: [], parents: ["Shoes & Footwear"] }), + ]); + + const [insight] = await service.getCategoryDemandInsights(range); + + expect(insight.parentCategoryLabel).toBe("Shoes & Footwear"); + expect(insight.subcategoryLabel).toBeNull(); + expect(insight.zeroResultCount).toBe(1); + }); + + it("separates TEXT and IMAGE counts", async () => { + prisma.whatsAppAnalytics.findMany.mockResolvedValue([ + eventRow({ sub: ["Sneakers"], mode: "TEXT" }), + eventRow({ sub: ["Sneakers"], mode: "IMAGE" }), + eventRow({ sub: ["Sneakers"], mode: "IMAGE" }), + ]); + + const [insight] = await service.getCategoryDemandInsights(range); + + expect(insight.searchModeBreakdown).toEqual({ text: 1, image: 2 }); + expect(insight.zeroResultCount).toBe(3); + }); + + it("filters to a single search mode when requested", async () => { + prisma.whatsAppAnalytics.findMany.mockResolvedValue([ + eventRow({ sub: ["Sneakers"], mode: "TEXT" }), + eventRow({ sub: ["Sneakers"], mode: "IMAGE" }), + ]); + + const [insight] = await service.getCategoryDemandInsights({ + ...range, + searchMode: "IMAGE", + }); + + expect(insight.searchModeBreakdown).toEqual({ text: 0, image: 1 }); + }); + + it("counts unique users and sessions", async () => { + prisma.whatsAppAnalytics.findMany.mockResolvedValue([ + eventRow({ sub: ["Sneakers"], userId: "u1", sessionId: "s1" }), + eventRow({ sub: ["Sneakers"], userId: "u1", sessionId: "s2" }), + eventRow({ sub: ["Sneakers"], userId: null, sessionId: "s3" }), + ]); + + const [insight] = await service.getCategoryDemandInsights(range); + + expect(insight.uniqueUserCount).toBe(1); + expect(insight.uniqueSessionCount).toBe(3); + }); + + it("aggregates top matched terms and reason codes by frequency", async () => { + prisma.whatsAppAnalytics.findMany.mockResolvedValue([ + eventRow({ + sub: ["Sneakers"], + terms: ["sneaker"], + reason: "NO_ELIGIBLE_PRODUCTS", + }), + eventRow({ + sub: ["Sneakers"], + terms: ["sneaker", "kicks"], + reason: "NO_ELIGIBLE_PRODUCTS", + }), + eventRow({ + sub: ["Sneakers"], + terms: ["sneaker"], + reason: "NO_TAXONOMY_MATCH", + }), + ]); + + const [insight] = await service.getCategoryDemandInsights(range); + + // "sneaker" appears 3x, "kicks" 1x. + expect(insight.topMatchedTerms[0]).toBe("sneaker"); + expect(insight.topMatchedTerms).toEqual( + expect.arrayContaining(["sneaker", "kicks"]), + ); + expect(insight.topReasonCodes[0]).toBe("NO_ELIGIBLE_PRODUCTS"); + }); + + it("tracks firstSeenAt and lastSeenAt", async () => { + prisma.whatsAppAnalytics.findMany.mockResolvedValue([ + eventRow({ + sub: ["Sneakers"], + createdAt: new Date("2026-07-03T00:00:00.000Z"), + }), + eventRow({ + sub: ["Sneakers"], + createdAt: new Date("2026-07-01T00:00:00.000Z"), + }), + ]); + + const [insight] = await service.getCategoryDemandInsights(range); + + expect(insight.firstSeenAt).toEqual(new Date("2026-07-01T00:00:00.000Z")); + expect(insight.lastSeenAt).toEqual(new Date("2026-07-03T00:00:00.000Z")); + }); + + it("sorts by demand and applies the limit", async () => { + prisma.whatsAppAnalytics.findMany.mockResolvedValue([ + eventRow({ sub: ["Sneakers"] }), + eventRow({ sub: ["Sneakers"] }), + eventRow({ sub: ["Sneakers"] }), + eventRow({ sub: ["Wristwatches"] }), + eventRow({ sub: ["Wristwatches"] }), + eventRow({ sub: ["Heels"] }), + ]); + + const insights = await service.getCategoryDemandInsights({ + ...range, + limit: 2, + }); + + expect(insights).toHaveLength(2); + expect(insights[0].subcategoryLabel).toBe("Sneakers"); + expect(insights[1].subcategoryLabel).toBe("Wristwatches"); + }); + + it("caps the limit at the maximum", async () => { + prisma.whatsAppAnalytics.findMany.mockResolvedValue([]); + await service.getCategoryDemandInsights({ ...range, limit: 9999 }); + // Behavior is validated by not throwing + empty set; the DB take cap is + // applied on the query, and the in-memory slice uses the normalized limit. + expect(prisma.whatsAppAnalytics.findMany).toHaveBeenCalledTimes(1); + }); + + it("passes the date range to the query", async () => { + prisma.whatsAppAnalytics.findMany.mockResolvedValue([]); + await service.getCategoryDemandInsights(range); + expect(prisma.whatsAppAnalytics.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + zeroResults: true, + createdAt: { gte: range.from, lte: range.to }, + }), + }), + ); + }); + + it("returns an empty array for no events", async () => { + prisma.whatsAppAnalytics.findMany.mockResolvedValue([]); + expect(await service.getCategoryDemandInsights(range)).toEqual([]); + }); + + it("ignores rows with missing/invalid taxonomy signals without crashing", async () => { + prisma.whatsAppAnalytics.findMany.mockResolvedValue([ + { + userId: "u", + sessionId: "s", + createdAt: new Date(), + parsedFilters: null, + }, + { + userId: "u", + sessionId: "s", + createdAt: new Date(), + parsedFilters: { other: 1 }, + }, + { + userId: "u", + sessionId: "s", + createdAt: new Date(), + parsedFilters: { + taxonomyZeroResult: { + signal: "WIZZA_SEARCH_ZERO_RESULTS", + taxonomyParentLabels: [], + taxonomySubcategoryLabels: [], + }, + }, + }, + eventRow({ sub: ["Sneakers"] }), + ]); + + const insights = await service.getCategoryDemandInsights(range); + expect(insights).toHaveLength(1); + expect(insights[0].subcategoryLabel).toBe("Sneakers"); + }); + + it("does not leak raw phones, queries, or PII in the output", async () => { + prisma.whatsAppAnalytics.findMany.mockResolvedValue([ + eventRow({ + sub: ["Sneakers"], + userId: "user-abc", + sessionId: "sess-xyz", + }), + ]); + + const serialized = JSON.stringify( + await service.getCategoryDemandInsights(range), + ); + expect(serialized).not.toMatch( + /\+234|user-abc|sess-xyz|normalizedQuery|phone|token|prompt/i, + ); + }); + + describe("supply context", () => { + it("attaches active, in-stock, and eligible-store counts when requested", async () => { + prisma.whatsAppAnalytics.findMany.mockResolvedValue([ + eventRow({ sub: ["Sneakers"] }), + ]); + prisma.product.count + .mockResolvedValueOnce(12) // activeProductCount + .mockResolvedValueOnce(7); // inStockProductCount + prisma.product.groupBy.mockResolvedValue([ + { storeId: "a" }, + { storeId: "b" }, + ]); + + const [insight] = await service.getCategoryDemandInsights({ + ...range, + includeSupply: true, + }); + + expect(insight.supply).toEqual({ + activeProductCount: 12, + inStockProductCount: 7, + eligibleStoreCount: 2, + }); + }); + + it("does not run supply queries when not requested", async () => { + prisma.whatsAppAnalytics.findMany.mockResolvedValue([ + eventRow({ sub: ["Sneakers"] }), + ]); + await service.getCategoryDemandInsights(range); + expect(prisma.product.count).not.toHaveBeenCalled(); + expect(prisma.product.groupBy).not.toHaveBeenCalled(); + }); + + it("degrades to zeroed supply when a lookup fails", async () => { + prisma.whatsAppAnalytics.findMany.mockResolvedValue([ + eventRow({ sub: ["Sneakers"] }), + ]); + prisma.product.count.mockRejectedValue(new Error("db down")); + prisma.product.groupBy.mockResolvedValue([]); + + const [insight] = await service.getCategoryDemandInsights({ + ...range, + includeSupply: true, + }); + + expect(insight.supply).toEqual({ + activeProductCount: 0, + inStockProductCount: 0, + eligibleStoreCount: 0, + }); + }); + }); + + describe("getTopZeroResultSubcategories", () => { + it("returns only subcategory-level demand", async () => { + prisma.whatsAppAnalytics.findMany.mockResolvedValue([ + eventRow({ sub: [], parents: ["Shoes & Footwear"] }), + eventRow({ sub: ["Sneakers"] }), + eventRow({ sub: ["Sneakers"] }), + ]); + + const insights = await service.getTopZeroResultSubcategories(range); + + expect(insights).toHaveLength(1); + expect(insights[0].subcategoryLabel).toBe("Sneakers"); + }); + }); +}); diff --git a/apps/backend/src/domains/commerce/search/category-demand-insights.service.ts b/apps/backend/src/domains/commerce/search/category-demand-insights.service.ts new file mode 100644 index 00000000..a1513f34 --- /dev/null +++ b/apps/backend/src/domains/commerce/search/category-demand-insights.service.ts @@ -0,0 +1,447 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { + ModerationStatus, + Prisma, + ProductStatus, + StoreTier, +} from "@prisma/client"; +import { resolveProductTaxonomyLabel } from "@twizrr/shared"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { + SEARCH_ZERO_RESULT_SIGNAL, + SearchZeroResultMode, + TaxonomyZeroResultSignal, +} from "./search-zero-result"; + +/** + * Category demand insights. + * + * Aggregates the taxonomy zero-result signals recorded by WIZZA/search (stored + * on `WhatsAppAnalytics.parsedFilters.taxonomyZeroResult`) into internal, + * aggregate-only category demand insights: which categories shoppers searched + * for but did not find, repeated zero-result demand by subcategory, and + * (optionally) how that demand compares to current eligible supply. + * + * Aggregate/internal only. Reads existing analytics — no new model, no + * migration, no shopper-facing behavior, no ranking/discovery change. Returns + * only category labels, counts, safe normalized matched terms, reason codes, + * search-mode counts, and time bounds — never raw phones, queries, user PII, + * request bodies, image data, provider payloads, or embeddings. + */ + +export type CategoryDemandSearchMode = SearchZeroResultMode | "ALL"; + +export interface CategorySupplyContext { + activeProductCount: number; + inStockProductCount: number; + eligibleStoreCount: number; +} + +export interface CategoryDemandInsight { + parentCategoryLabel: string | null; + subcategoryLabel: string | null; + zeroResultCount: number; + searchModeBreakdown: { text: number; image: number }; + uniqueUserCount: number; + uniqueSessionCount: number; + topMatchedTerms: string[]; + topReasonCodes: string[]; + firstSeenAt: Date | null; + lastSeenAt: Date | null; + supply?: CategorySupplyContext; +} + +export interface CategoryDemandInsightsParams { + from: Date; + to: Date; + limit?: number; + searchMode?: CategoryDemandSearchMode; + includeSupply?: boolean; +} + +export const DEFAULT_INSIGHTS_LIMIT = 20; +export const MAX_INSIGHTS_LIMIT = 100; +const MAX_MATCHED_TERMS = 10; +// Safety cap on rows aggregated per query so an HTTP-triggered call can never +// pull an unbounded number of analytics rows into memory. +const MAX_EVENTS_SCANNED = 10_000; + +// Internal accumulator per (parent, subcategory) key. +interface DemandAccumulator { + parentCategoryLabel: string | null; + subcategoryLabel: string | null; + zeroResultCount: number; + text: number; + image: number; + userIds: Set; + sessionIds: Set; + matchedTerms: Map; + reasonCodes: Map; + firstSeenAt: Date | null; + lastSeenAt: Date | null; +} + +interface ZeroResultEventRow { + userId: string | null; + sessionId: string; + createdAt: Date; + parsedFilters: Prisma.JsonValue | null; +} + +@Injectable() +export class CategoryDemandInsightsService { + private readonly logger = new Logger(CategoryDemandInsightsService.name); + + constructor(private readonly prisma: PrismaService) {} + + async getCategoryDemandInsights( + params: CategoryDemandInsightsParams, + ): Promise { + const limit = this.normalizeLimit(params.limit); + const searchMode = params.searchMode ?? "ALL"; + + const rows = await this.prisma.whatsAppAnalytics.findMany({ + where: { + zeroResults: true, + createdAt: { gte: params.from, lte: params.to }, + parsedFilters: { + path: ["taxonomyZeroResult", "signal"], + equals: SEARCH_ZERO_RESULT_SIGNAL, + }, + }, + select: { + userId: true, + sessionId: true, + createdAt: true, + parsedFilters: true, + }, + orderBy: { createdAt: "desc" }, + take: MAX_EVENTS_SCANNED, + }); + + const accumulators = new Map(); + + for (const row of rows as ZeroResultEventRow[]) { + const signal = this.extractSignal(row.parsedFilters); + if (!signal) { + continue; + } + if (searchMode !== "ALL" && signal.searchMode !== searchMode) { + continue; + } + for (const { parentCategoryLabel, subcategoryLabel } of this.categoryKeys( + signal, + )) { + this.accumulate( + accumulators, + parentCategoryLabel, + subcategoryLabel, + signal, + row, + ); + } + } + + const insights = [...accumulators.values()] + .map((accumulator) => this.toInsight(accumulator)) + .sort( + (a, b) => + b.zeroResultCount - a.zeroResultCount || + (b.lastSeenAt?.getTime() ?? 0) - (a.lastSeenAt?.getTime() ?? 0), + ) + .slice(0, limit); + + if (params.includeSupply) { + await this.attachSupplyContext(insights); + } + + return insights; + } + + /** Convenience view: only subcategory-level demand, most in-demand first. */ + async getTopZeroResultSubcategories(params: { + from: Date; + to: Date; + limit?: number; + includeSupply?: boolean; + }): Promise { + const insights = await this.getCategoryDemandInsights({ + from: params.from, + to: params.to, + // Pull the full window, then filter to subcategory rows before limiting. + limit: MAX_INSIGHTS_LIMIT, + searchMode: "ALL", + includeSupply: false, + }); + + const subcategoryInsights = insights + .filter((insight) => insight.subcategoryLabel !== null) + .slice(0, this.normalizeLimit(params.limit)); + + if (params.includeSupply) { + await this.attachSupplyContext(subcategoryInsights); + } + + return subcategoryInsights; + } + + private normalizeLimit(limit?: number): number { + if (!limit || !Number.isFinite(limit) || limit < 1) { + return DEFAULT_INSIGHTS_LIMIT; + } + return Math.min(Math.floor(limit), MAX_INSIGHTS_LIMIT); + } + + // Safely reads the stored taxonomy zero-result signal from parsedFilters JSON. + private extractSignal( + parsedFilters: Prisma.JsonValue | null, + ): TaxonomyZeroResultSignal | null { + if ( + !parsedFilters || + typeof parsedFilters !== "object" || + Array.isArray(parsedFilters) + ) { + return null; + } + const candidate = (parsedFilters as Record) + .taxonomyZeroResult; + if ( + !candidate || + typeof candidate !== "object" || + Array.isArray(candidate) + ) { + return null; + } + const signal = candidate as Partial; + if (signal.signal !== SEARCH_ZERO_RESULT_SIGNAL) { + return null; + } + return { + signal: SEARCH_ZERO_RESULT_SIGNAL, + channel: "WHATSAPP", + searchMode: signal.searchMode === "IMAGE" ? "IMAGE" : "TEXT", + taxonomyParentLabels: this.safeStringArray(signal.taxonomyParentLabels), + taxonomySubcategoryLabels: this.safeStringArray( + signal.taxonomySubcategoryLabels, + ), + taxonomyConfidence: signal.taxonomyConfidence ?? "none", + matchedTerms: this.safeStringArray(signal.matchedTerms), + normalizedQuery: null, + searchResultCount: 0, + zeroResults: true, + reasonCode: signal.reasonCode ?? "UNKNOWN", + }; + } + + private safeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter( + (item): item is string => typeof item === "string" && item.length > 0, + ); + } + + // Resolves the (parent, subcategory) pairs an event contributes to. A + // subcategory's parent is resolved deterministically from the shared taxonomy + // (single source of truth) rather than guessed from the flat label arrays. + private categoryKeys( + signal: TaxonomyZeroResultSignal, + ): { parentCategoryLabel: string | null; subcategoryLabel: string | null }[] { + const keys = new Map< + string, + { parentCategoryLabel: string | null; subcategoryLabel: string | null } + >(); + + if (signal.taxonomySubcategoryLabels.length > 0) { + for (const subcategoryLabel of signal.taxonomySubcategoryLabels) { + const resolvedParent = + resolveProductTaxonomyLabel(subcategoryLabel)?.parentLabel ?? + signal.taxonomyParentLabels[0] ?? + null; + keys.set(`${resolvedParent}${subcategoryLabel}`, { + parentCategoryLabel: resolvedParent, + subcategoryLabel, + }); + } + } else { + for (const parentCategoryLabel of signal.taxonomyParentLabels) { + keys.set(`${parentCategoryLabel}`, { + parentCategoryLabel, + subcategoryLabel: null, + }); + } + } + + return [...keys.values()]; + } + + private accumulate( + accumulators: Map, + parentCategoryLabel: string | null, + subcategoryLabel: string | null, + signal: TaxonomyZeroResultSignal, + row: ZeroResultEventRow, + ): void { + const key = `${parentCategoryLabel}${subcategoryLabel}`; + let accumulator = accumulators.get(key); + if (!accumulator) { + accumulator = { + parentCategoryLabel, + subcategoryLabel, + zeroResultCount: 0, + text: 0, + image: 0, + userIds: new Set(), + sessionIds: new Set(), + matchedTerms: new Map(), + reasonCodes: new Map(), + firstSeenAt: null, + lastSeenAt: null, + }; + accumulators.set(key, accumulator); + } + + accumulator.zeroResultCount += 1; + if (signal.searchMode === "IMAGE") { + accumulator.image += 1; + } else { + accumulator.text += 1; + } + if (row.userId) { + accumulator.userIds.add(row.userId); + } + if (row.sessionId) { + accumulator.sessionIds.add(row.sessionId); + } + for (const term of signal.matchedTerms) { + accumulator.matchedTerms.set( + term, + (accumulator.matchedTerms.get(term) ?? 0) + 1, + ); + } + accumulator.reasonCodes.set( + signal.reasonCode, + (accumulator.reasonCodes.get(signal.reasonCode) ?? 0) + 1, + ); + if (!accumulator.firstSeenAt || row.createdAt < accumulator.firstSeenAt) { + accumulator.firstSeenAt = row.createdAt; + } + if (!accumulator.lastSeenAt || row.createdAt > accumulator.lastSeenAt) { + accumulator.lastSeenAt = row.createdAt; + } + } + + private toInsight(accumulator: DemandAccumulator): CategoryDemandInsight { + return { + parentCategoryLabel: accumulator.parentCategoryLabel, + subcategoryLabel: accumulator.subcategoryLabel, + zeroResultCount: accumulator.zeroResultCount, + searchModeBreakdown: { text: accumulator.text, image: accumulator.image }, + uniqueUserCount: accumulator.userIds.size, + uniqueSessionCount: accumulator.sessionIds.size, + topMatchedTerms: this.rankByFrequency( + accumulator.matchedTerms, + MAX_MATCHED_TERMS, + ), + topReasonCodes: this.rankByFrequency(accumulator.reasonCodes), + firstSeenAt: accumulator.firstSeenAt, + lastSeenAt: accumulator.lastSeenAt, + }; + } + + private rankByFrequency( + counts: Map, + limit?: number, + ): string[] { + const ranked = [...counts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([value]) => value); + return limit ? ranked.slice(0, limit) : ranked; + } + + // Enriches returned insights with current eligible native supply counts. + // Bounded to the already-limited insight set. Sourced/dropship supply is a + // documented follow-up. Failures degrade to zeroed supply, never throw. + private async attachSupplyContext( + insights: CategoryDemandInsight[], + ): Promise { + await Promise.all( + insights.map(async (insight) => { + insight.supply = await this.getCategorySupply( + insight.subcategoryLabel ?? insight.parentCategoryLabel, + ); + }), + ); + } + + private async getCategorySupply( + categoryLabel: string | null, + ): Promise { + const empty: CategorySupplyContext = { + activeProductCount: 0, + inStockProductCount: 0, + eligibleStoreCount: 0, + }; + if (!categoryLabel) { + return empty; + } + + // Same eligibility gates shopper discovery uses: Tier 0 excluded, active, + // public, not blocked, store open. Category matched on the product's + // category slug/label, matching the WIZZA discovery convention. + const eligibility: Prisma.ProductWhereInput = { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + productCode: { not: null }, + moderationStatus: { not: ModerationStatus.BLOCKED }, + storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true }, + OR: [ + { + categoryTag: { + contains: categoryLabel, + mode: Prisma.QueryMode.insensitive, + }, + }, + { + platformCategory: { + contains: categoryLabel, + mode: Prisma.QueryMode.insensitive, + }, + }, + ], + }; + + try { + const [activeProductCount, inStockProductCount, storeGroups] = + await Promise.all([ + this.prisma.product.count({ where: eligibility }), + this.prisma.product.count({ + where: { + ...eligibility, + productStockCaches: { some: { stock: { gt: 0 } } }, + }, + }), + this.prisma.product.groupBy({ + by: ["storeId"], + where: eligibility, + }), + ]); + + return { + activeProductCount, + inStockProductCount, + eligibleStoreCount: storeGroups.length, + }; + } catch (error) { + this.logger.warn( + `Category supply lookup failed for "${categoryLabel}": ${ + error instanceof Error ? error.message : error + }`, + ); + return empty; + } + } +} diff --git a/apps/backend/src/domains/commerce/search/dto/category-demand-insights.query.dto.ts b/apps/backend/src/domains/commerce/search/dto/category-demand-insights.query.dto.ts new file mode 100644 index 00000000..0b3e0e9d --- /dev/null +++ b/apps/backend/src/domains/commerce/search/dto/category-demand-insights.query.dto.ts @@ -0,0 +1,41 @@ +import { Transform, Type } from "class-transformer"; +import { + IsBoolean, + IsIn, + IsInt, + IsISO8601, + IsOptional, + Max, + Min, +} from "class-validator"; + +/** + * Query params for the internal category demand insights endpoint. All optional + * — the controller applies safe defaults (last 7 days, limit 20) and validates + * the resolved range. Prevents unbounded analytics queries from HTTP. + */ +export class CategoryDemandInsightsQueryDto { + @IsOptional() + @IsISO8601() + from?: string; + + @IsOptional() + @IsISO8601() + to?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @IsIn(["TEXT", "IMAGE", "ALL"]) + searchMode?: "TEXT" | "IMAGE" | "ALL"; + + @IsOptional() + @Transform(({ value }) => value === true || value === "true") + @IsBoolean() + includeSupply?: boolean; +} diff --git a/apps/backend/src/domains/commerce/search/dto/search-query.dto.ts b/apps/backend/src/domains/commerce/search/dto/search-query.dto.ts new file mode 100644 index 00000000..d37da243 --- /dev/null +++ b/apps/backend/src/domains/commerce/search/dto/search-query.dto.ts @@ -0,0 +1,79 @@ +import { Transform, Type } from "class-transformer"; +import { + IsBooleanString, + IsIn, + IsInt, + IsOptional, + IsString, + Matches, + Max, + MaxLength, + Min, +} from "class-validator"; + +const toOptionalString = (value: unknown): unknown => + value == null ? value : String(value); + +export type SearchResultType = + | "products" + | "stores" + | "users" + | "posts" + | "all"; + +export class SearchQueryDto { + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @IsString() + @MaxLength(120) + q?: string; + + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @IsIn(["products", "stores", "users", "posts", "all"]) + type?: SearchResultType; + + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @IsString() + @MaxLength(80) + category?: string; + + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @Matches(/^\d+$/) + minPrice?: string; + + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @Matches(/^\d+$/) + maxPrice?: string; + + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @IsString() + @MaxLength(60) + color?: string; + + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @IsString() + @MaxLength(80) + location?: string; + + @IsOptional() + @IsBooleanString() + inStock?: string; + + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @IsString() + cursor?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + limit?: number; +} diff --git a/apps/backend/src/domains/commerce/search/product-taxonomy.shared.spec.ts b/apps/backend/src/domains/commerce/search/product-taxonomy.shared.spec.ts new file mode 100644 index 00000000..065e3d79 --- /dev/null +++ b/apps/backend/src/domains/commerce/search/product-taxonomy.shared.spec.ts @@ -0,0 +1,110 @@ +import { + isProductCategoryRequiringSizeGuide, + resolveProductTaxonomyIntent, + resolveProductTaxonomyLabel, +} from "@twizrr/shared"; + +// Tests for the shared @twizrr/shared product-taxonomy resolver. They run in the +// backend jest suite because the shared package ships no test runner of its own. + +describe("resolveProductTaxonomyIntent", () => { + it("resolves an exact subcategory label", () => { + const [match] = resolveProductTaxonomyIntent("sneakers"); + expect(match.subcategoryLabel).toBe("Sneakers"); + expect(match.parentLabel).toBe("Shoes & Footwear"); + expect(match.source).toBe("exact_subcategory"); + expect(match.confidence).toBe("high"); + }); + + it("resolves an exact parent category label", () => { + const [match] = resolveProductTaxonomyIntent("bags"); + expect(match.parentLabel).toBe("Bags"); + expect(match.subcategoryLabel).toBeNull(); + expect(match.source).toBe("exact_parent"); + }); + + it("resolves a synonym", () => { + const [match] = resolveProductTaxonomyIntent("kicks"); + expect(match.subcategoryLabel).toBe("Sneakers"); + expect(match.source).toBe("synonym"); + }); + + it("ranks a subcategory match above a parent match", () => { + const matches = resolveProductTaxonomyIntent("handbags bags"); + expect(matches[0].subcategoryLabel).toBe("Handbags"); + expect(matches.some((m) => m.parentLabel === "Bags")).toBe(true); + }); + + it("prefers a phrase synonym over a single-token synonym", () => { + const matches = resolveProductTaxonomyIntent("phone case"); + expect(matches[0].subcategoryLabel).toBe("Phone Cases"); + expect(matches.some((m) => m.subcategoryLabel === "Smartphones")).toBe( + false, + ); + }); + + it("resolves 'screen protector' to Screen Protectors", () => { + const [match] = resolveProductTaxonomyIntent( + "do you sell a screen protector", + ); + expect(match.subcategoryLabel).toBe("Screen Protectors"); + }); + + it("resolves 'I need a wristwatch' to Wristwatches", () => { + const [match] = resolveProductTaxonomyIntent("I need a wristwatch"); + expect(match.subcategoryLabel).toBe("Wristwatches"); + expect(match.parentLabel).toBe("Watches & Clocks"); + }); + + it("resolves 'body cream' safely to Body Care", () => { + const matches = resolveProductTaxonomyIntent("find me body cream"); + expect(matches[0].subcategoryLabel).toBe("Body Care"); + }); + + it("resolves 'rice' to Rice", () => { + const [match] = resolveProductTaxonomyIntent("rice in Lagos"); + expect(match.subcategoryLabel).toBe("Rice"); + expect(match.parentLabel).toBe("Food & Groceries"); + }); + + it("returns no match for an unknown query", () => { + expect( + resolveProductTaxonomyIntent("totally unknown gibberish xyz"), + ).toEqual([]); + }); + + it("returns no match for empty input", () => { + expect(resolveProductTaxonomyIntent(" ")).toEqual([]); + }); +}); + +describe("resolveProductTaxonomyLabel", () => { + it("resolves a stored subcategory label", () => { + expect(resolveProductTaxonomyLabel("Power Banks")?.subcategoryLabel).toBe( + "Power Banks", + ); + }); + + it("resolves a 'Parent / Sub' display label to the subcategory", () => { + expect( + resolveProductTaxonomyLabel("Watches & Clocks / Wristwatches") + ?.subcategoryLabel, + ).toBe("Wristwatches"); + }); + + it("returns null for an unknown label", () => { + expect(resolveProductTaxonomyLabel("Not A Category")).toBeNull(); + }); +}); + +describe("isProductCategoryRequiringSizeGuide", () => { + it("returns true for size-guide categories", () => { + expect(isProductCategoryRequiringSizeGuide("Sneakers")).toBe(true); + expect(isProductCategoryRequiringSizeGuide("Men's Clothing")).toBe(true); + }); + + it("returns false for non-sized categories", () => { + expect(isProductCategoryRequiringSizeGuide("Rice")).toBe(false); + expect(isProductCategoryRequiringSizeGuide("Power Banks")).toBe(false); + }); +}); diff --git a/apps/backend/src/domains/commerce/search/search-zero-result.spec.ts b/apps/backend/src/domains/commerce/search/search-zero-result.spec.ts new file mode 100644 index 00000000..62776c9b --- /dev/null +++ b/apps/backend/src/domains/commerce/search/search-zero-result.spec.ts @@ -0,0 +1,173 @@ +import { + buildTaxonomyZeroResultSignal, + inferredCategoryLabel, + mergeSignalIntoParsedFilters, + SEARCH_ZERO_RESULT_SIGNAL, + TaxonomyHintsLike, + TaxonomyZeroResultSignal, +} from "./search-zero-result"; + +describe("search-zero-result", () => { + const confidentHints: TaxonomyHintsLike = { + parentCategoryLabels: ["Watches & Clocks"], + subcategoryLabels: ["Wristwatches"], + confidence: "high", + matchedTerms: ["wristwatch"], + }; + + describe("buildTaxonomyZeroResultSignal", () => { + it("builds a signal for a confident taxonomy zero-result", () => { + const signal = buildTaxonomyZeroResultSignal({ + searchMode: "TEXT", + hints: confidentHints, + rawQuery: "I need a Wristwatch!", + searchResultCount: 0, + }); + + expect(signal).toEqual({ + signal: SEARCH_ZERO_RESULT_SIGNAL, + channel: "WHATSAPP", + searchMode: "TEXT", + taxonomyParentLabels: ["Watches & Clocks"], + taxonomySubcategoryLabels: ["Wristwatches"], + taxonomyConfidence: "high", + matchedTerms: ["wristwatch"], + normalizedQuery: "i need a wristwatch", + searchResultCount: 0, + zeroResults: true, + reasonCode: "NO_ELIGIBLE_PRODUCTS", + }); + }); + + it("returns null when taxonomy did not understand the request", () => { + expect( + buildTaxonomyZeroResultSignal({ + searchMode: "TEXT", + hints: { + parentCategoryLabels: [], + subcategoryLabels: [], + confidence: "none", + }, + rawQuery: "gaming chair", + }), + ).toBeNull(); + expect( + buildTaxonomyZeroResultSignal({ + searchMode: "TEXT", + hints: null, + rawQuery: "anything", + }), + ).toBeNull(); + }); + + it("returns null when confident but no category labels resolved", () => { + expect( + buildTaxonomyZeroResultSignal({ + searchMode: "TEXT", + hints: { + parentCategoryLabels: [], + subcategoryLabels: [], + confidence: "medium", + }, + }), + ).toBeNull(); + }); + + it("never persists a query for image search, even if one is passed", () => { + const signal = buildTaxonomyZeroResultSignal({ + searchMode: "IMAGE", + hints: { + parentCategoryLabels: ["Shoes & Footwear"], + subcategoryLabels: ["Sneakers"], + confidence: "medium", + matchedTerms: ["sneaker"], + }, + rawQuery: "sensitive ocr text from the photo", + }); + + expect(signal?.normalizedQuery).toBeNull(); + expect(signal?.searchMode).toBe("IMAGE"); + }); + + it("caps labels and terms and de-duplicates them", () => { + const signal = buildTaxonomyZeroResultSignal({ + searchMode: "TEXT", + hints: { + parentCategoryLabels: Array.from({ length: 20 }, (_, i) => `P${i}`), + subcategoryLabels: ["Sneakers", "Sneakers", "Heels"], + confidence: "high", + matchedTerms: Array.from({ length: 30 }, (_, i) => `t${i}`), + }, + rawQuery: "x".repeat(500), + }); + + expect(signal?.taxonomyParentLabels.length).toBeLessThanOrEqual(8); + expect(signal?.taxonomySubcategoryLabels).toEqual(["Sneakers", "Heels"]); + expect(signal?.matchedTerms.length).toBeLessThanOrEqual(12); + expect((signal?.normalizedQuery ?? "").length).toBeLessThanOrEqual(160); + }); + + it("records only safe, taxonomy-derived fields (no secrets/PII/payloads)", () => { + const serialized = JSON.stringify( + buildTaxonomyZeroResultSignal({ + searchMode: "TEXT", + hints: confidentHints, + rawQuery: "wristwatch", + }), + ); + expect(serialized).not.toMatch( + /token|cookie|authorization|otp|password|base64|embedding|vector|signedUrl|phone|\+234|prompt|physicalStoreId|physicalProductId/i, + ); + }); + }); + + describe("inferredCategoryLabel", () => { + it("prefers the most specific subcategory label", () => { + const signal = buildTaxonomyZeroResultSignal({ + searchMode: "TEXT", + hints: confidentHints, + rawQuery: "wristwatch", + }) as TaxonomyZeroResultSignal; + expect(inferredCategoryLabel(signal)).toBe("Wristwatches"); + }); + + it("falls back to the parent label when there is no subcategory", () => { + const signal = buildTaxonomyZeroResultSignal({ + searchMode: "TEXT", + hints: { + parentCategoryLabels: ["Furniture"], + subcategoryLabels: [], + confidence: "high", + }, + }) as TaxonomyZeroResultSignal; + expect(inferredCategoryLabel(signal)).toBe("Furniture"); + }); + }); + + describe("mergeSignalIntoParsedFilters", () => { + const signal = buildTaxonomyZeroResultSignal({ + searchMode: "IMAGE", + hints: confidentHints, + }) as TaxonomyZeroResultSignal; + + it("preserves existing parsedFilters fields (e.g. image imageTerms)", () => { + const merged = mergeSignalIntoParsedFilters( + { imageTerms: ["watch"], vectorSearchUsed: true }, + signal, + ) as Record; + expect(merged.imageTerms).toEqual(["watch"]); + expect(merged.vectorSearchUsed).toBe(true); + expect(merged.taxonomyZeroResult).toEqual(signal); + }); + + it("handles null or non-object existing parsedFilters", () => { + for (const existing of [null, undefined, ["array"], "string"] as any[]) { + const merged = mergeSignalIntoParsedFilters(existing, signal) as Record< + string, + unknown + >; + expect(merged.taxonomyZeroResult).toEqual(signal); + } + }); + }); +}); diff --git a/apps/backend/src/domains/commerce/search/search-zero-result.ts b/apps/backend/src/domains/commerce/search/search-zero-result.ts new file mode 100644 index 00000000..b3ae399c --- /dev/null +++ b/apps/backend/src/domains/commerce/search/search-zero-result.ts @@ -0,0 +1,164 @@ +import { Prisma } from "@prisma/client"; +import { + ProductTaxonomyMatchConfidence, + normalizeProductTaxonomyText, +} from "@twizrr/shared"; + +/** + * Taxonomy-aware zero-result signals — shared search-domain analytics contract. + * + * Records a safe, internal analytics signal when discovery understood what a + * shopper is looking for (taxonomy resolved with useful confidence) but returned + * zero eligible products. This surfaces category demand/supply gaps (e.g. + * shoppers ask for "wristwatch" but no eligible Wristwatches exist). + * + * This contract lives in the search domain so both producers and consumers + * depend on the domain (not on any channel): the WhatsApp channel builds and + * writes the signal, and the category demand insights service reads it. Keeping + * it here preserves the channels -> domains dependency direction and lets the + * search/admin analytics be built or extracted without the WhatsApp channel. + * + * This is analytics only. It never changes ranking, discovery, or shopper copy, + * and it is never exposed to shoppers. It stores only taxonomy-derived, + * category-level fields — never image data, base64, signed URLs, raw Cloud + * Vision/Vertex payloads, embeddings, tokens, cookies, OTPs, or prompt text. + */ + +export type SearchZeroResultChannel = "WHATSAPP"; +export type SearchZeroResultMode = "TEXT" | "IMAGE"; + +// Only reason codes the current implementation can honestly determine are +// emitted. When only the final eligible count is known (the common case), the +// builder uses NO_ELIGIBLE_PRODUCTS. The rest are reserved for future, more +// granular attribution and are intentionally not guessed at today. +export type SearchZeroResultReasonCode = + | "NO_ELIGIBLE_PRODUCTS" + | "NO_ACTIVE_PRODUCTS" + | "NO_IN_STOCK_PRODUCTS" + | "NO_PUBLIC_PRODUCTS" + | "NO_TAXONOMY_MATCH" + | "SEARCH_PROVIDER_FAILED" + | "UNKNOWN"; + +export const SEARCH_ZERO_RESULT_SIGNAL = "WIZZA_SEARCH_ZERO_RESULTS" as const; + +export interface TaxonomyZeroResultSignal { + signal: typeof SEARCH_ZERO_RESULT_SIGNAL; + channel: SearchZeroResultChannel; + searchMode: SearchZeroResultMode; + taxonomyParentLabels: string[]; + taxonomySubcategoryLabels: string[]; + taxonomyConfidence: ProductTaxonomyMatchConfidence; + matchedTerms: string[]; + // Normalized, length-capped query text for TEXT search only. Null for IMAGE + // search — image OCR/label text is never persisted as a query. + normalizedQuery: string | null; + searchResultCount: number; + zeroResults: true; + reasonCode: SearchZeroResultReasonCode; +} + +// Minimal shape the builder needs. Matches TaxonomyDiscoveryHints so callers can +// pass hints from resolveDiscoveryHints / resolveDiscoveryHintsFromLabels. +export interface TaxonomyHintsLike { + parentCategoryLabels: string[]; + subcategoryLabels: string[]; + confidence: ProductTaxonomyMatchConfidence; + matchedTerms?: string[]; +} + +const MAX_LABELS = 8; +const MAX_TERMS = 12; +const MAX_NORMALIZED_QUERY = 160; + +function cap(values: readonly string[] | undefined, max: number): string[] { + return [ + ...new Set( + (values ?? []).filter( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ), + ), + ].slice(0, max); +} + +/** + * Builds a taxonomy zero-result signal, or returns null when it should not be + * recorded. Returns null when taxonomy did not understand the request + * (confidence "none" or no resolved category labels) so unknown / non-product + * queries never produce a false category-demand signal. + */ +export function buildTaxonomyZeroResultSignal(params: { + searchMode: SearchZeroResultMode; + hints: TaxonomyHintsLike | null | undefined; + rawQuery?: string | null; + searchResultCount?: number; + reasonCode?: SearchZeroResultReasonCode; +}): TaxonomyZeroResultSignal | null { + const { hints } = params; + if (!hints || hints.confidence === "none") { + return null; + } + + const taxonomyParentLabels = cap(hints.parentCategoryLabels, MAX_LABELS); + const taxonomySubcategoryLabels = cap(hints.subcategoryLabels, MAX_LABELS); + if ( + taxonomyParentLabels.length === 0 && + taxonomySubcategoryLabels.length === 0 + ) { + return null; + } + + // Only TEXT search persists a query, and only in normalized, capped form — + // consistent with the existing WhatsAppAnalytics.searchQuery policy. + const normalizedQuery = + params.searchMode === "TEXT" && params.rawQuery != null + ? normalizeProductTaxonomyText(params.rawQuery).slice( + 0, + MAX_NORMALIZED_QUERY, + ) || null + : null; + + return { + signal: SEARCH_ZERO_RESULT_SIGNAL, + channel: "WHATSAPP", + searchMode: params.searchMode, + taxonomyParentLabels, + taxonomySubcategoryLabels, + taxonomyConfidence: hints.confidence, + matchedTerms: cap(hints.matchedTerms, MAX_TERMS), + normalizedQuery, + searchResultCount: params.searchResultCount ?? 0, + zeroResults: true, + reasonCode: params.reasonCode ?? "NO_ELIGIBLE_PRODUCTS", + }; +} + +/** The single most specific category label for the analytics categoryInferred field. */ +export function inferredCategoryLabel( + signal: TaxonomyZeroResultSignal, +): string | null { + return ( + signal.taxonomySubcategoryLabels[0] ?? + signal.taxonomyParentLabels[0] ?? + null + ); +} + +/** + * Merges the signal into the existing analytics parsedFilters JSON without + * dropping any fields already present (e.g. image search's imageTerms). + */ +export function mergeSignalIntoParsedFilters( + existing: Prisma.InputJsonValue | null | undefined, + signal: TaxonomyZeroResultSignal, +): Prisma.InputJsonValue { + const base = + existing && typeof existing === "object" && !Array.isArray(existing) + ? (existing as Record) + : {}; + return { + ...base, + taxonomyZeroResult: signal as unknown as Prisma.InputJsonValue, + }; +} diff --git a/apps/backend/src/domains/commerce/search/search.controller.ts b/apps/backend/src/domains/commerce/search/search.controller.ts new file mode 100644 index 00000000..9c85fba8 --- /dev/null +++ b/apps/backend/src/domains/commerce/search/search.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get, Query } from "@nestjs/common"; + +import { SearchQueryDto } from "./dto/search-query.dto"; +import { SearchService } from "./search.service"; + +@Controller("search") +export class SearchController { + constructor(private readonly searchService: SearchService) {} + + @Get() + search(@Query() query: SearchQueryDto) { + return this.searchService.search(query); + } +} diff --git a/apps/backend/src/domains/commerce/search/search.module.ts b/apps/backend/src/domains/commerce/search/search.module.ts new file mode 100644 index 00000000..7e95a8cc --- /dev/null +++ b/apps/backend/src/domains/commerce/search/search.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; + +import { SearchController } from "./search.controller"; +import { SearchService } from "./search.service"; + +@Module({ + controllers: [SearchController], + providers: [SearchService], + exports: [SearchService], +}) +export class SearchModule {} diff --git a/apps/backend/src/domains/commerce/search/search.service.ts b/apps/backend/src/domains/commerce/search/search.service.ts new file mode 100644 index 00000000..5f031f96 --- /dev/null +++ b/apps/backend/src/domains/commerce/search/search.service.ts @@ -0,0 +1,1046 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import { + ModerationStatus, + PostType, + Prisma, + ProductStatus, + StoreTier, + UserRole, +} from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { SearchQueryDto, SearchResultType } from "./dto/search-query.dto"; + +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 50; +const POSTGRES_BIGINT_MAX = 9223372036854775807n; +const VECTOR_DIMENSION = 1408; +const PUBLIC_POST_COUNT_WHERE = { + type: { + in: [PostType.PRODUCT_POST, PostType.IMAGE_POST, PostType.GIST], + }, + isActive: true, + moderationStatus: { not: ModerationStatus.BLOCKED }, + OR: [ + { taggedProductId: null }, + { + taggedProduct: { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + storeProfile: { tier: { not: StoreTier.TIER_0 } }, + }, + }, + ], +} satisfies Prisma.PostWhereInput; + +type SingleSearchType = Exclude; + +interface SearchCursor { + type: SingleSearchType; + id: string; + score?: number; +} + +interface NormalizedSearchParams { + q: string | null; + category: string | null; + minPriceKobo: bigint | null; + maxPriceKobo: bigint | null; + color: string | null; + location: string | null; + inStock: boolean | null; + limit: number; + cursor: SearchCursor | null; +} + +interface SearchProductParams extends NormalizedSearchParams { + queryVector?: number[]; +} + +export interface SearchPage { + items: T[]; + nextCursor: string | null; +} + +export interface GroupedSearchResponse { + products: SearchPage; + stores: SearchPage; + users: SearchPage; + posts: SearchPage; +} + +export interface SingleSearchResponse { + type: SingleSearchType; + items: T[]; + nextCursor: string | null; +} + +interface StoreSummary { + handle: string | null; + name: string | null; + logoUrl: string | null; + tier: StoreTier; +} + +export interface ProductSearchResult { + id: string; + productCode: string | null; + name: string; + title: string | null; + description: string | null; + imageUrl: string | null; + retailPriceKobo: string | null; + compareAtPriceKobo: string | null; + platformCategory: string | null; + categoryTag: string; + productSubCategory: string | null; + store: StoreSummary; + inStock: boolean; + score: number | null; +} + +export interface StoreSearchResult { + id: string; + handle: string | null; + name: string | null; + bio: string | null; + logoUrl: string | null; + bannerUrl: string | null; + businessCategory: string | null; + tier: StoreTier; + followerCount: number; +} + +export interface UserSearchResult { + id: string; + username: string | null; + displayName: string | null; + bio: string | null; + profilePhotoUrl: string | null; + followerCount: number; + followingCount: number; + postCount: number; +} + +export interface PostSearchResult { + id: string; + type: PostType; + text: string | null; + moderationStatus: ModerationStatus; + author: { + id: string; + username: string | null; + displayName: string | null; + profilePhotoUrl: string | null; + } | null; + taggedProduct: { + id: string; + productCode: string | null; + name: string; + title: string | null; + imageUrl: string | null; + retailPriceKobo: string | null; + } | null; + images: { + id: string; + url: string; + order: number; + moderationStatus: ModerationStatus; + }[]; + hashtags: string[]; + likeCount: number; + commentCount: number; + createdAt: string; +} + +type ProductRecord = Prisma.ProductGetPayload<{ + select: typeof PRODUCT_SEARCH_SELECT; +}>; + +type StoreRecord = Prisma.StoreProfileGetPayload<{ + select: typeof STORE_SEARCH_SELECT; +}>; + +type UserRecord = Prisma.UserGetPayload<{ + select: typeof USER_SEARCH_SELECT; +}>; + +type PostRecord = Prisma.PostGetPayload<{ + select: typeof POST_SEARCH_SELECT; +}>; + +interface VectorProductRow { + id: string; + productCode: string | null; + name: string; + title: string | null; + description: string | null; + imageUrl: string | null; + retailPriceKobo: bigint | null; + compareAtPriceKobo: bigint | null; + platformCategory: string | null; + categoryTag: string; + productSubCategory: string | null; + storeHandle: string | null; + storeName: string | null; + storeLogoUrl: string | null; + storeTier: StoreTier; + inStock: boolean; + score: number; +} + +const PRODUCT_SEARCH_SELECT = { + id: true, + productCode: true, + name: true, + title: true, + description: true, + imageUrl: true, + retailPriceKobo: true, + compareAtPriceKobo: true, + platformCategory: true, + categoryTag: true, + productSubCategory: true, + viewCountWeekly: true, + updatedAt: true, + storeProfile: { + select: { + storeHandle: true, + storeName: true, + logoUrl: true, + tier: true, + }, + }, + productStockCaches: { + select: { + stock: true, + quantity: true, + }, + }, +} satisfies Prisma.ProductSelect; + +const STORE_SEARCH_SELECT = { + id: true, + storeHandle: true, + storeName: true, + bio: true, + logoUrl: true, + bannerUrl: true, + businessCategory: true, + tier: true, + _count: { + select: { + followers: true, + }, + }, +} satisfies Prisma.StoreProfileSelect; + +const USER_SEARCH_SELECT = { + id: true, + username: true, + displayName: true, + bio: true, + profilePhotoUrl: true, + _count: { + select: { + followerRelations: true, + followingRelations: true, + posts: { where: PUBLIC_POST_COUNT_WHERE }, + }, + }, +} satisfies Prisma.UserSelect; + +const POST_SEARCH_SELECT = { + id: true, + type: true, + text: true, + moderationStatus: true, + createdAt: true, + author: { + select: { + id: true, + username: true, + displayName: true, + profilePhotoUrl: true, + }, + }, + taggedProduct: { + select: { + id: true, + productCode: true, + name: true, + title: true, + imageUrl: true, + retailPriceKobo: true, + status: true, + isActive: true, + deletedAt: true, + storeProfile: { + select: { + tier: true, + }, + }, + }, + }, + images: { + select: { + id: true, + url: true, + order: true, + moderationStatus: true, + }, + where: { + moderationStatus: { not: ModerationStatus.BLOCKED }, + }, + orderBy: { order: "asc" }, + }, + hashtags: { + select: { + hashtag: { + select: { + name: true, + }, + }, + }, + }, + // Denormalized counters — never COUNT(*) the relations on read paths. + likeCount: true, + commentCount: true, +} satisfies Prisma.PostSelect; + +@Injectable() +export class SearchService { + constructor(private readonly prisma: PrismaService) {} + + async search( + query: SearchQueryDto, + ): Promise< + | GroupedSearchResponse + | SingleSearchResponse + | SingleSearchResponse + | SingleSearchResponse + | SingleSearchResponse + > { + const type = query.type ?? "all"; + const params = this.normalizeQuery(query); + + if (type === "products") { + const page = await this.searchProducts(params); + return { type, ...page }; + } + if (type === "stores") { + const page = await this.searchStores(params); + return { type, ...page }; + } + if (type === "users") { + const page = await this.searchUsers(params); + return { type, ...page }; + } + if (type === "posts") { + const page = await this.searchPosts(params); + return { type, ...page }; + } + + const [products, stores, users, posts] = await Promise.all([ + this.searchProducts({ ...params, cursor: null }), + this.searchStores({ ...params, cursor: null }), + this.searchUsers({ ...params, cursor: null }), + this.searchPosts({ ...params, cursor: null }), + ]); + + return { products, stores, users, posts }; + } + + async searchProducts( + params: SearchProductParams, + ): Promise> { + if (params.queryVector) { + return this.searchProductsByVector(params); + } + return this.searchProductsByKeyword(params); + } + + async searchStores( + params: NormalizedSearchParams, + ): Promise> { + this.assertCursorType(params.cursor, "stores"); + const textFilter: Prisma.StoreProfileWhereInput = params.q + ? { + OR: [ + { + storeName: { + contains: params.q, + mode: Prisma.QueryMode.insensitive, + }, + }, + { + storeHandle: { + contains: params.q, + mode: Prisma.QueryMode.insensitive, + }, + }, + { bio: { contains: params.q, mode: Prisma.QueryMode.insensitive } }, + { + businessCategory: { + contains: params.q, + mode: Prisma.QueryMode.insensitive, + }, + }, + ], + } + : {}; + + const stores: StoreRecord[] = await this.prisma.storeProfile.findMany({ + where: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + ...(params.category + ? { + businessCategory: { + contains: params.category, + mode: Prisma.QueryMode.insensitive, + }, + } + : {}), + ...(params.location + ? { + businessAddress: { + contains: params.location, + mode: Prisma.QueryMode.insensitive, + }, + } + : {}), + ...textFilter, + }, + select: STORE_SEARCH_SELECT, + orderBy: [{ createdAt: "desc" }, { id: "asc" }], + cursor: params.cursor ? { id: params.cursor.id } : undefined, + skip: params.cursor ? 1 : 0, + take: params.limit + 1, + }); + + const page = this.takePage(stores, params.limit); + return { + items: page.items.map((store) => this.toStoreResult(store)), + nextCursor: this.encodeNextCursor("stores", page.next), + }; + } + + async searchUsers( + params: NormalizedSearchParams, + ): Promise> { + this.assertCursorType(params.cursor, "users"); + const textFilter: Prisma.UserWhereInput = params.q + ? { + OR: [ + { + username: { + contains: params.q, + mode: Prisma.QueryMode.insensitive, + }, + }, + { + displayName: { + contains: params.q, + mode: Prisma.QueryMode.insensitive, + }, + }, + { bio: { contains: params.q, mode: Prisma.QueryMode.insensitive } }, + ], + } + : {}; + + const users: UserRecord[] = await this.prisma.user.findMany({ + where: { + role: UserRole.USER, + isActive: true, + deletedAt: null, + username: { not: null }, + ...textFilter, + }, + select: USER_SEARCH_SELECT, + orderBy: [{ createdAt: "desc" }, { id: "asc" }], + cursor: params.cursor ? { id: params.cursor.id } : undefined, + skip: params.cursor ? 1 : 0, + take: params.limit + 1, + }); + + const page = this.takePage(users, params.limit); + return { + items: page.items.map((user) => this.toUserResult(user)), + nextCursor: this.encodeNextCursor("users", page.next), + }; + } + + async searchPosts( + params: NormalizedSearchParams, + ): Promise> { + this.assertCursorType(params.cursor, "posts"); + const tier0StoreIds = await this.getTier0StoreIds(); + const postAndFilters: Prisma.PostWhereInput[] = [ + { + OR: [ + { taggedProductId: null }, + { + taggedProduct: { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + storeProfile: { tier: { not: StoreTier.TIER_0 } }, + }, + }, + ], + }, + ]; + + if (tier0StoreIds.length > 0) { + postAndFilters.push({ + OR: [{ storeId: null }, { storeId: { notIn: tier0StoreIds } }], + }); + } + + if (params.q) { + postAndFilters.push({ + OR: [ + { text: { contains: params.q, mode: "insensitive" } }, + { + hashtags: { + some: { + hashtag: { + name: { contains: this.normalizeHashtag(params.q) }, + }, + }, + }, + }, + { + taggedProduct: { + name: { contains: params.q, mode: "insensitive" }, + }, + }, + { + taggedProduct: { + title: { contains: params.q, mode: "insensitive" }, + }, + }, + ], + }); + } + + const posts = await this.prisma.post.findMany({ + where: { + type: { + in: [PostType.PRODUCT_POST, PostType.IMAGE_POST, PostType.GIST], + }, + isActive: true, + moderationStatus: { not: ModerationStatus.BLOCKED }, + AND: postAndFilters, + }, + select: POST_SEARCH_SELECT, + orderBy: [{ createdAt: "desc" }, { id: "asc" }], + cursor: params.cursor ? { id: params.cursor.id } : undefined, + skip: params.cursor ? 1 : 0, + take: params.limit + 1, + }); + + const page = this.takePage(posts, params.limit); + return { + items: page.items.map((post) => this.toPostResult(post)), + nextCursor: this.encodeNextCursor("posts", page.next), + }; + } + + private async searchProductsByKeyword( + params: NormalizedSearchParams, + ): Promise> { + this.assertCursorType(params.cursor, "products"); + const products = await this.prisma.product.findMany({ + where: this.productWhere(params), + select: PRODUCT_SEARCH_SELECT, + orderBy: [ + { viewCountWeekly: "desc" }, + { updatedAt: "desc" }, + { id: "asc" }, + ], + cursor: params.cursor ? { id: params.cursor.id } : undefined, + skip: params.cursor ? 1 : 0, + take: params.limit + 1, + }); + + const page = this.takePage(products, params.limit); + return { + items: page.items.map((product) => this.toProductResult(product, null)), + nextCursor: this.encodeNextCursor("products", page.next), + }; + } + + private async searchProductsByVector( + params: SearchProductParams, + ): Promise> { + this.assertCursorType(params.cursor, "products"); + const vectorLiteral = this.toVectorLiteral(params.queryVector); + const textQuery = params.q ? `%${params.q}%` : null; + const categoryQuery = params.category ? `%${params.category}%` : null; + const colorQuery = params.color ? `%${params.color}%` : null; + const locationQuery = params.location ? `%${params.location}%` : null; + const cursorScore = params.cursor?.score ?? null; + const cursorId = params.cursor?.id ?? null; + + // Raw SQL is required here because Prisma cannot query pgvector + // `Unsupported("vector(1408)")` columns or use the `<=>` cosine + // distance operator through the Prisma query builder. + const rows = await this.prisma.$queryRaw(Prisma.sql` + WITH ranked_products AS ( + SELECT + p."id", + p."product_code" AS "productCode", + p."name", + p."title", + p."description", + p."image_url" AS "imageUrl", + p."retail_price_kobo" AS "retailPriceKobo", + p."compare_at_price_kobo" AS "compareAtPriceKobo", + p."platform_category" AS "platformCategory", + p."category_tag" AS "categoryTag", + p."product_sub_category"::text AS "productSubCategory", + s."store_handle" AS "storeHandle", + s."store_name" AS "storeName", + s."logo_url" AS "storeLogoUrl", + s."tier" AS "storeTier", + EXISTS ( + SELECT 1 + FROM "product_stock_cache" psc + WHERE psc."product_id" = p."id" + AND (psc."stock" > 0 OR psc."quantity" > 0) + ) AS "inStock", + ( + (1 - (pe."embedding" <=> ${vectorLiteral}::vector)) * 0.6 + + CASE + WHEN ${textQuery}::text IS NOT NULL AND ( + p."name" ILIKE ${textQuery} + OR p."title" ILIKE ${textQuery} + OR p."description" ILIKE ${textQuery} + OR p."category_tag" ILIKE ${textQuery} + OR p."platform_category" ILIKE ${textQuery} + ) THEN 0.3 + ELSE 0 + END + + CASE + WHEN s."tier" = 'TIER_2' THEN 0.1 + WHEN s."tier" = 'TIER_1' THEN 0.05 + ELSE 0 + END + )::float AS "score" + FROM "products" p + JOIN "product_embeddings" pe ON pe."product_id" = p."id" + JOIN "store_profiles" s ON s."id" = p."store_id" + WHERE p."status" = 'ACTIVE' + AND p."is_active" = TRUE + AND p."deleted_at" IS NULL + AND p."moderation_status" != 'BLOCKED' + AND p."embedding_ready" = TRUE + AND pe."embedding_ready" = TRUE + AND s."tier" != 'TIER_0' + AND s."is_open" = TRUE + AND (${categoryQuery}::text IS NULL OR p."category_tag" ILIKE ${categoryQuery} OR p."platform_category" ILIKE ${categoryQuery}) + AND (${params.minPriceKobo}::bigint IS NULL OR p."retail_price_kobo" >= ${params.minPriceKobo}) + AND (${params.maxPriceKobo}::bigint IS NULL OR p."retail_price_kobo" <= ${params.maxPriceKobo}) + AND (${colorQuery}::text IS NULL OR EXISTS ( + SELECT 1 + FROM "product_variants" pv + WHERE pv."product_id" = p."id" + AND pv."is_active" = TRUE + AND pv."color_name" ILIKE ${colorQuery} + )) + AND (${locationQuery}::text IS NULL OR s."business_address" ILIKE ${locationQuery}) + AND (${params.inStock}::boolean IS DISTINCT FROM TRUE OR EXISTS ( + SELECT 1 + FROM "product_stock_cache" psc + WHERE psc."product_id" = p."id" + AND (psc."stock" > 0 OR psc."quantity" > 0) + )) + ) + SELECT * + FROM ranked_products + WHERE ( + ${cursorScore}::float IS NULL + OR "score" < ${cursorScore} + OR ("score" = ${cursorScore} AND "id" > ${cursorId}) + ) + ORDER BY "score" DESC, "id" ASC + LIMIT ${params.limit + 1} + `); + + const page = this.takePage(rows, params.limit); + return { + items: page.items.map((row) => this.toVectorProductResult(row)), + nextCursor: this.encodeNextCursor( + "products", + page.next ? { id: page.next.id, score: page.next.score } : null, + ), + }; + } + + private productWhere( + params: NormalizedSearchParams, + ): Prisma.ProductWhereInput { + return { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + moderationStatus: { not: ModerationStatus.BLOCKED }, + storeProfile: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + ...(params.location + ? { + businessAddress: { + contains: params.location, + mode: "insensitive", + }, + } + : {}), + }, + ...(params.category + ? { + OR: [ + { + categoryTag: { contains: params.category, mode: "insensitive" }, + }, + { + platformCategory: { + contains: params.category, + mode: "insensitive", + }, + }, + ], + } + : {}), + ...(params.q + ? { + AND: [ + { + OR: [ + { name: { contains: params.q, mode: "insensitive" } }, + { title: { contains: params.q, mode: "insensitive" } }, + { description: { contains: params.q, mode: "insensitive" } }, + { + shortDescription: { + contains: params.q, + mode: "insensitive", + }, + }, + { categoryTag: { contains: params.q, mode: "insensitive" } }, + { + platformCategory: { + contains: params.q, + mode: "insensitive", + }, + }, + { storeTags: { has: params.q } }, + ], + }, + ], + } + : {}), + ...(params.minPriceKobo || params.maxPriceKobo + ? { + retailPriceKobo: { + ...(params.minPriceKobo ? { gte: params.minPriceKobo } : {}), + ...(params.maxPriceKobo ? { lte: params.maxPriceKobo } : {}), + }, + } + : {}), + ...(params.color + ? { + variants: { + some: { + isActive: true, + colorName: { contains: params.color, mode: "insensitive" }, + }, + }, + } + : {}), + ...(params.inStock === true + ? { + productStockCaches: { + some: { + OR: [{ stock: { gt: 0 } }, { quantity: { gt: 0 } }], + }, + }, + } + : {}), + }; + } + + private normalizeQuery(query: SearchQueryDto): NormalizedSearchParams { + const minPriceKobo = this.parseKobo(query.minPrice, "MIN_PRICE_INVALID"); + const maxPriceKobo = this.parseKobo(query.maxPrice, "MAX_PRICE_INVALID"); + if ( + minPriceKobo !== null && + maxPriceKobo !== null && + minPriceKobo > maxPriceKobo + ) { + throw new BadRequestException({ + message: "Minimum price cannot be greater than maximum price", + code: "PRICE_RANGE_INVALID", + }); + } + + return { + q: this.normalizeOptionalText(query.q), + category: this.normalizeOptionalText(query.category), + minPriceKobo, + maxPriceKobo, + color: this.normalizeOptionalText(query.color), + location: this.normalizeOptionalText(query.location), + inStock: + query.inStock === undefined + ? null + : query.inStock.toLowerCase() === "true", + limit: Math.min(query.limit ?? DEFAULT_LIMIT, MAX_LIMIT), + cursor: this.decodeCursor(query.cursor), + }; + } + + private normalizeOptionalText(value: string | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; + } + + private normalizeHashtag(value: string): string { + return value.trim().replace(/^#/, "").toLowerCase(); + } + + private parseKobo(value: string | undefined, code: string): bigint | null { + if (value === undefined) { + return null; + } + try { + const parsed = BigInt(value); + if (parsed < 0n || parsed > POSTGRES_BIGINT_MAX) { + throw new Error("out-of-range value"); + } + return parsed; + } catch { + throw new BadRequestException({ + message: "Price filters must be kobo integer strings", + code, + }); + } + } + + private decodeCursor(cursor: string | undefined): SearchCursor | null { + if (!cursor) { + return null; + } + try { + const parsed = JSON.parse( + Buffer.from(cursor, "base64url").toString("utf8"), + ) as unknown; + if (!this.isSearchCursor(parsed)) { + throw new Error("invalid cursor"); + } + return parsed; + } catch { + throw new BadRequestException({ + message: "Search cursor is invalid", + code: "SEARCH_CURSOR_INVALID", + }); + } + } + + private isSearchCursor(value: unknown): value is SearchCursor { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as Record; + return ( + (candidate.type === "products" || + candidate.type === "stores" || + candidate.type === "users" || + candidate.type === "posts") && + typeof candidate.id === "string" && + (candidate.score === undefined || typeof candidate.score === "number") + ); + } + + private assertCursorType( + cursor: SearchCursor | null, + expected: SingleSearchType, + ): void { + if (cursor && cursor.type !== expected) { + throw new BadRequestException({ + message: "Search cursor does not match the requested result type", + code: "SEARCH_CURSOR_TYPE_MISMATCH", + }); + } + } + + private encodeNextCursor( + type: SingleSearchType, + next: { id: string; score?: number } | null, + ): string | null { + if (!next) { + return null; + } + return Buffer.from(JSON.stringify({ type, ...next })).toString("base64url"); + } + + private takePage( + records: T[], + limit: number, + ): { items: T[]; next: T | null } { + return { + items: records.slice(0, limit), + next: records.length > limit ? records[limit] : null, + }; + } + + private toVectorLiteral(embedding: number[] | undefined): string { + if ( + !embedding || + embedding.length !== VECTOR_DIMENSION || + !embedding.every((value) => Number.isFinite(value)) + ) { + throw new BadRequestException({ + message: "Query vector must contain 1408 finite numbers", + code: "QUERY_VECTOR_INVALID", + }); + } + return `[${embedding.join(",")}]`; + } + + private async getTier0StoreIds(): Promise { + const stores = await this.prisma.storeProfile.findMany({ + where: { tier: StoreTier.TIER_0 }, + select: { id: true }, + }); + return stores.map((store) => store.id); + } + + private toProductResult( + product: ProductRecord, + score: number | null, + ): ProductSearchResult { + return { + id: product.id, + productCode: product.productCode, + name: product.name, + title: product.title, + description: product.description, + imageUrl: product.imageUrl, + retailPriceKobo: product.retailPriceKobo?.toString() ?? null, + compareAtPriceKobo: product.compareAtPriceKobo?.toString() ?? null, + platformCategory: product.platformCategory, + categoryTag: product.categoryTag, + productSubCategory: product.productSubCategory, + store: { + handle: product.storeProfile.storeHandle, + name: product.storeProfile.storeName, + logoUrl: product.storeProfile.logoUrl, + tier: product.storeProfile.tier, + }, + inStock: product.productStockCaches.some( + (stock) => stock.stock > 0 || stock.quantity > 0, + ), + score, + }; + } + + private toVectorProductResult(row: VectorProductRow): ProductSearchResult { + return { + id: row.id, + productCode: row.productCode, + name: row.name, + title: row.title, + description: row.description, + imageUrl: row.imageUrl, + retailPriceKobo: row.retailPriceKobo?.toString() ?? null, + compareAtPriceKobo: row.compareAtPriceKobo?.toString() ?? null, + platformCategory: row.platformCategory, + categoryTag: row.categoryTag, + productSubCategory: row.productSubCategory, + store: { + handle: row.storeHandle, + name: row.storeName, + logoUrl: row.storeLogoUrl, + tier: row.storeTier, + }, + inStock: row.inStock, + score: row.score, + }; + } + + private toStoreResult(store: StoreRecord): StoreSearchResult { + return { + id: store.id, + handle: store.storeHandle, + name: store.storeName, + bio: store.bio, + logoUrl: store.logoUrl, + bannerUrl: store.bannerUrl, + businessCategory: store.businessCategory, + tier: store.tier, + followerCount: store._count.followers, + }; + } + + private toUserResult(user: UserRecord): UserSearchResult { + return { + id: user.id, + username: user.username, + displayName: user.displayName, + bio: user.bio, + profilePhotoUrl: user.profilePhotoUrl, + followerCount: user._count.followerRelations, + followingCount: user._count.followingRelations, + postCount: user._count.posts, + }; + } + + private toPostResult(post: PostRecord): PostSearchResult { + const taggedProduct = + post.taggedProduct && + post.taggedProduct.status === ProductStatus.ACTIVE && + post.taggedProduct.isActive && + post.taggedProduct.deletedAt === null && + post.taggedProduct.storeProfile.tier !== StoreTier.TIER_0 + ? { + id: post.taggedProduct.id, + productCode: post.taggedProduct.productCode, + name: post.taggedProduct.name, + title: post.taggedProduct.title, + imageUrl: post.taggedProduct.imageUrl, + retailPriceKobo: + post.taggedProduct.retailPriceKobo?.toString() ?? null, + } + : null; + + return { + id: post.id, + type: post.type, + text: post.text, + moderationStatus: post.moderationStatus, + author: post.author + ? { + id: post.author.id, + username: post.author.username, + displayName: post.author.displayName, + profilePhotoUrl: post.author.profilePhotoUrl, + } + : null, + taggedProduct, + images: post.images.map((image) => ({ + id: image.id, + url: image.url, + order: image.order, + moderationStatus: image.moderationStatus, + })), + hashtags: post.hashtags.map((hashtag) => hashtag.hashtag.name), + likeCount: post.likeCount, + commentCount: post.commentCount, + createdAt: post.createdAt.toISOString(), + }; + } +} diff --git a/apps/backend/src/domains/commerce/search/taxonomy-discovery.module.ts b/apps/backend/src/domains/commerce/search/taxonomy-discovery.module.ts new file mode 100644 index 00000000..397fe134 --- /dev/null +++ b/apps/backend/src/domains/commerce/search/taxonomy-discovery.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; + +import { TaxonomyDiscoveryService } from "./taxonomy-discovery.service"; + +/** + * Provides the deterministic taxonomy discovery helper. It has no external + * dependencies (it wraps the shared @twizrr/shared resolver), so it can be + * imported by both REST search and the WIZZA discovery channel to share one + * category-intent signal. + */ +@Module({ + providers: [TaxonomyDiscoveryService], + exports: [TaxonomyDiscoveryService], +}) +export class TaxonomyDiscoveryModule {} diff --git a/apps/backend/src/domains/commerce/search/taxonomy-discovery.service.spec.ts b/apps/backend/src/domains/commerce/search/taxonomy-discovery.service.spec.ts new file mode 100644 index 00000000..73c18683 --- /dev/null +++ b/apps/backend/src/domains/commerce/search/taxonomy-discovery.service.spec.ts @@ -0,0 +1,114 @@ +import { TaxonomyDiscoveryService } from "./taxonomy-discovery.service"; + +describe("TaxonomyDiscoveryService", () => { + const service = new TaxonomyDiscoveryService(); + + it("returns category hints for a shopper query", () => { + const hints = service.resolveDiscoveryHints("Show me kicks under 50k"); + expect(hints.subcategoryLabels).toContain("Sneakers"); + expect(hints.parentCategoryLabels).toContain("Shoes & Footwear"); + expect(hints.confidence).not.toBe("none"); + expect(hints.matchedTerms.length).toBeGreaterThan(0); + }); + + it("flags size-guide categories in hints", () => { + expect(service.resolveDiscoveryHints("sneakers").requiresSizeGuide).toBe( + true, + ); + expect(service.resolveDiscoveryHints("rice").requiresSizeGuide).toBe(false); + }); + + it("returns empty hints for unknown or empty queries", () => { + for (const query of ["", " ", "totally unknown gibberish xyz"]) { + const hints = service.resolveDiscoveryHints(query); + expect(hints.confidence).toBe("none"); + expect(hints.subcategoryLabels).toEqual([]); + expect(hints.parentCategoryLabels).toEqual([]); + expect(service.getCategorySearchTerms(hints)).toEqual([]); + } + }); + + it("orders search terms with subcategories first", () => { + const hints = service.resolveDiscoveryHints("I need a wristwatch"); + const terms = service.getCategorySearchTerms(hints); + expect(terms[0]).toBe("Wristwatches"); + expect(terms).toContain("Watches & Clocks"); + }); + + it("does not expose internal IDs or private fields", () => { + const serialized = JSON.stringify( + service.resolveDiscoveryHints("I need a phone case and a power bank"), + ); + expect(serialized).not.toMatch( + /"(?:id|slug)"|productId|storeId|physicalStoreId|physicalProductId|embedding|vector/i, + ); + }); + + it("resolves taxonomy hints from image labels (safe hook)", () => { + expect( + service.resolveDiscoveryHintsFromLabels(["watch"]).subcategoryLabels, + ).toContain("Wristwatches"); + expect( + service.resolveDiscoveryHintsFromLabels(["sneaker"]).subcategoryLabels, + ).toContain("Sneakers"); + expect( + service.resolveDiscoveryHintsFromLabels(["phone"]).subcategoryLabels, + ).toContain("Smartphones"); + // "shoe" alone is intentionally not a synonym; it yields no confident hint. + expect(service.resolveDiscoveryHintsFromLabels(["shoe"]).confidence).toBe( + "none", + ); + }); + + // Cloud Vision image label / object / OCR terms feed WIZZA image discovery. + // These assert the resolver turns the terms in the WIZZA image-enrichment + // spec into the expected category context so the enrichment stays honest. + describe("resolveDiscoveryHintsFromLabels — image label examples", () => { + it.each([ + [["watch", "wristwatch", "accessory"], "Wristwatches"], + [["shoe", "sneaker", "footwear"], "Sneakers"], + [["phone case", "mobile phone case"], "Phone Cases"], + [["power bank", "electronics"], "Power Banks"], + [["handbag", "bag", "fashion accessory"], "Handbags"], + [["cream", "skincare"], "Skincare"], + [["body cream", "skincare"], "Body Care"], + [["lotion"], "Lotions"], + [["heels", "footwear"], "Heels"], + [["school bag", "bag"], "School Bags"], + [["screen protector"], "Screen Protectors"], + [["rice", "food grain"], "Rice"], + ])("resolves %j to include %s", (labels, expectedSubcategory) => { + const hints = service.resolveDiscoveryHintsFromLabels(labels); + expect(hints.subcategoryLabels).toContain(expectedSubcategory); + expect(hints.confidence).not.toBe("none"); + }); + + it("returns none/low confidence for unknown labels without throwing", () => { + for (const labels of [ + null, + undefined, + [], + ["zzzqqq", "gibberish", "unrecognized"], + ["", " "], + ] as (string[] | null | undefined)[]) { + const hints = service.resolveDiscoveryHintsFromLabels(labels); + expect(hints.confidence).toBe("none"); + expect(hints.subcategoryLabels).toEqual([]); + expect(hints.parentCategoryLabels).toEqual([]); + } + }); + + it("does not leak internal IDs, slugs, or embeddings for image labels", () => { + const serialized = JSON.stringify( + service.resolveDiscoveryHintsFromLabels([ + "phone case", + "power bank", + "handbag", + ]), + ); + expect(serialized).not.toMatch( + /"(?:id|slug)"|productId|storeId|physicalStoreId|physicalProductId|embedding|vector/i, + ); + }); + }); +}); diff --git a/apps/backend/src/domains/commerce/search/taxonomy-discovery.service.ts b/apps/backend/src/domains/commerce/search/taxonomy-discovery.service.ts new file mode 100644 index 00000000..b021b34c --- /dev/null +++ b/apps/backend/src/domains/commerce/search/taxonomy-discovery.service.ts @@ -0,0 +1,110 @@ +import { Injectable } from "@nestjs/common"; +import { + ProductTaxonomyMatch, + ProductTaxonomyMatchConfidence, + resolveProductTaxonomyIntent, +} from "@twizrr/shared"; + +/** + * Backend taxonomy discovery helper. + * + * Wraps the shared, deterministic product-taxonomy resolver and turns shopper + * intent into search-safe category hints that current Product records can use + * (products store category labels as strings on categoryTag / platformCategory). + * + * This is a deterministic category/filter/boost signal only. It performs no + * external calls, calls no AI provider, touches no payments/orders, and never + * exposes internal IDs or private fields. Backend eligibility rules (store + * tier, active/public/in-stock) remain the final gate on what can be shown. + */ + +export type TaxonomyDiscoveryConfidence = ProductTaxonomyMatchConfidence; + +export interface TaxonomyDiscoveryHints { + parentCategoryLabels: string[]; + subcategoryLabels: string[]; + requiresSizeGuide: boolean; + confidence: TaxonomyDiscoveryConfidence; + matchedTerms: string[]; +} + +const EMPTY_HINTS: TaxonomyDiscoveryHints = { + parentCategoryLabels: [], + subcategoryLabels: [], + requiresSizeGuide: false, + confidence: "none", + matchedTerms: [], +}; + +@Injectable() +export class TaxonomyDiscoveryService { + /** Resolves shopper query text into deterministic category hints. */ + resolveDiscoveryHints( + query: string | null | undefined, + ): TaxonomyDiscoveryHints { + return this.buildHints(resolveProductTaxonomyIntent(query ?? "")); + } + + /** + * Resolves taxonomy hints from image labels (e.g. Cloud Vision labels such as + * "watch", "sneaker", "phone"). Safe hook for future image-discovery + * enrichment; it does not change image-search ranking on its own. + */ + resolveDiscoveryHintsFromLabels( + labels: readonly string[] | null | undefined, + ): TaxonomyDiscoveryHints { + const matches: ProductTaxonomyMatch[] = []; + for (const label of labels ?? []) { + if (typeof label !== "string" || !label.trim()) { + continue; + } + matches.push(...resolveProductTaxonomyIntent(label)); + } + return this.buildHints(matches); + } + + /** + * Unique category and subcategory labels for use as deterministic search + * terms/filters. Subcategory labels come first (more specific). + */ + getCategorySearchTerms(hints: TaxonomyDiscoveryHints): string[] { + return [ + ...new Set([...hints.subcategoryLabels, ...hints.parentCategoryLabels]), + ]; + } + + private buildHints(matches: ProductTaxonomyMatch[]): TaxonomyDiscoveryHints { + if (matches.length === 0) { + return { ...EMPTY_HINTS }; + } + + const parentCategoryLabels = new Set(); + const subcategoryLabels = new Set(); + const matchedTerms = new Set(); + let requiresSizeGuide = false; + + for (const match of matches) { + if (match.parentLabel) { + parentCategoryLabels.add(match.parentLabel); + } + if (match.subcategoryLabel) { + subcategoryLabels.add(match.subcategoryLabel); + } + if (match.matchedTerm) { + matchedTerms.add(match.matchedTerm); + } + if (match.requiresSizeGuide) { + requiresSizeGuide = true; + } + } + + return { + parentCategoryLabels: [...parentCategoryLabels], + subcategoryLabels: [...subcategoryLabels], + requiresSizeGuide, + // Matches are already sorted strongest-first by the shared resolver. + confidence: matches[0].confidence, + matchedTerms: [...matchedTerms], + }; + } +} diff --git a/apps/backend/src/domains/commerce/size-guide/README.md b/apps/backend/src/domains/commerce/size-guide/README.md new file mode 100644 index 00000000..81d2adce --- /dev/null +++ b/apps/backend/src/domains/commerce/size-guide/README.md @@ -0,0 +1,5 @@ +# Size Guide Domain + +Platform-managed size guide boundary. +Records are seeded by deployment and not created directly by store owners. + diff --git a/apps/backend/src/domains/commerce/sourced-product/README.md b/apps/backend/src/domains/commerce/sourced-product/README.md new file mode 100644 index 00000000..c0a81a7e --- /dev/null +++ b/apps/backend/src/domains/commerce/sourced-product/README.md @@ -0,0 +1,5 @@ +# Sourced Product Domain + +Dropship sourcing boundary for digital stores listing physical store products. +To be built in the commerce domain. + diff --git a/apps/backend/src/domains/commerce/sourced-product/dto/source-product.dto.ts b/apps/backend/src/domains/commerce/sourced-product/dto/source-product.dto.ts new file mode 100644 index 00000000..56c8bf50 --- /dev/null +++ b/apps/backend/src/domains/commerce/sourced-product/dto/source-product.dto.ts @@ -0,0 +1,99 @@ +import { Transform, Type } from "class-transformer"; +import { + ArrayMaxSize, + IsArray, + IsBooleanString, + IsIn, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, + ValidateNested, +} from "class-validator"; + +const toOptionalString = (value: unknown): unknown => + value == null ? value : String(value); + +export class SourcedProductImageDto { + @Transform(({ value }) => toOptionalString(value)) + @IsString() + @IsNotEmpty() + @MaxLength(2048) + url!: string; + + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @IsString() + @MaxLength(255) + cloudinaryPublicId?: string; + + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @IsString() + @MaxLength(180) + altText?: string; +} + +export class SourceProductDto { + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @IsString() + @IsNotEmpty() + physicalProductId?: string; + + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @IsString() + @IsNotEmpty() + sourceProductId?: string; + + @Transform(({ value }) => toOptionalString(value)) + @Matches(/^\d+$/) + sellingPriceKobo!: string; + + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @IsString() + @MaxLength(100) + customName?: string; + + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @IsString() + @MaxLength(1000) + customDescription?: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(5) + @ValidateNested({ each: true }) + @Type(() => SourcedProductImageDto) + customPhotos?: SourcedProductImageDto[]; +} + +export class SourcedCatalogueQueryDto { + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @IsString() + @MaxLength(80) + category?: string; + + @IsOptional() + @IsBooleanString() + inStock?: string; + + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @Matches(/^\d+$/) + minPriceKobo?: string; + + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @Matches(/^\d+$/) + maxPriceKobo?: string; + + @IsOptional() + @IsIn(["trending", "newest", "price_asc", "price_desc"]) + sort?: "trending" | "newest" | "price_asc" | "price_desc"; +} diff --git a/apps/backend/src/domains/commerce/sourced-product/dto/sourced-product-public.dto.ts b/apps/backend/src/domains/commerce/sourced-product/dto/sourced-product-public.dto.ts new file mode 100644 index 00000000..a03449c9 --- /dev/null +++ b/apps/backend/src/domains/commerce/sourced-product/dto/sourced-product-public.dto.ts @@ -0,0 +1,8 @@ +export interface SourcedProductPublicDto { + productCode: string | null; + title: string; + description: string | null; + category: string | null; + imageUrl: string | null; + sellingPriceKobo: string; +} diff --git a/apps/backend/src/domains/commerce/sourced-product/dto/sourced-product-response.dto.ts b/apps/backend/src/domains/commerce/sourced-product/dto/sourced-product-response.dto.ts new file mode 100644 index 00000000..6810436a --- /dev/null +++ b/apps/backend/src/domains/commerce/sourced-product/dto/sourced-product-response.dto.ts @@ -0,0 +1,65 @@ +export interface SourcedStockSummaryDto { + hasStockCache: boolean; + availableStock: number; + inStock: boolean; +} + +export interface SourcedProductImageResponseDto { + url: string; + cloudinaryPublicId: string | null; + altText: string | null; +} + +export interface SourcedProductCatalogueItemDto { + sourceProductId: string; + productCode: string | null; + sourceCode: string | null; + title: string; + description: string | null; + category: string | null; + imageUrl: string | null; + images: SourcedProductImageResponseDto[]; + variants: Array<{ + id: string; + label: string; + colorName: string | null; + sizeName: string | null; + }>; + sourceStore: { + handle: string | null; + name: string | null; + tier: string; + }; + dropshipperCostKobo: string; + floorPriceKobo: string; + suggestedPriceKobo: string; + stock: SourcedStockSummaryDto; +} + +export interface OwnSourcedProductDto { + id: string; + listingCode: string; + productCode: string; + customName: string | null; + customDescription: string | null; + customPhotos: SourcedProductImageResponseDto[]; + sellingPriceKobo: string; + floorPriceKobo: string; + isActive: boolean; + priceUpdateRequired: boolean; + sourceProduct: { + productCode: string | null; + title: string; + description: string | null; + category: string | null; + imageUrl: string | null; + sourceStore: { + handle: string | null; + name: string | null; + tier: string; + }; + }; + stock: SourcedStockSummaryDto; + createdAt: string; + updatedAt: string; +} diff --git a/apps/backend/src/domains/commerce/sourced-product/dto/update-sourced-product.dto.ts b/apps/backend/src/domains/commerce/sourced-product/dto/update-sourced-product.dto.ts new file mode 100644 index 00000000..3b4efafa --- /dev/null +++ b/apps/backend/src/domains/commerce/sourced-product/dto/update-sourced-product.dto.ts @@ -0,0 +1,46 @@ +import { Transform, Type } from "class-transformer"; +import { + ArrayMaxSize, + IsArray, + IsBoolean, + IsOptional, + IsString, + Matches, + MaxLength, + ValidateNested, +} from "class-validator"; + +import { SourcedProductImageDto } from "./source-product.dto"; + +const toOptionalString = (value: unknown): unknown => + value == null ? value : String(value); + +export class UpdateSourcedProductDto { + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @Matches(/^\d+$/) + sellingPriceKobo?: string; + + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @IsString() + @MaxLength(100) + customName?: string | null; + + @Transform(({ value }) => toOptionalString(value)) + @IsOptional() + @IsString() + @MaxLength(1000) + customDescription?: string | null; + + @IsOptional() + @IsArray() + @ArrayMaxSize(5) + @ValidateNested({ each: true }) + @Type(() => SourcedProductImageDto) + customPhotos?: SourcedProductImageDto[] | null; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/backend/src/domains/commerce/sourced-product/sourced-product.controller.ts b/apps/backend/src/domains/commerce/sourced-product/sourced-product.controller.ts new file mode 100644 index 00000000..04a25d06 --- /dev/null +++ b/apps/backend/src/domains/commerce/sourced-product/sourced-product.controller.ts @@ -0,0 +1,65 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards, +} from "@nestjs/common"; + +import { CurrentUser } from "../../../common/decorators/current-user.decorator"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { AuthenticatedRequestUser } from "../../users/auth/auth.types"; +import { + SourceProductDto, + SourcedCatalogueQueryDto, +} from "./dto/source-product.dto"; +import { UpdateSourcedProductDto } from "./dto/update-sourced-product.dto"; +import { SourcedProductService } from "./sourced-product.service"; + +@UseGuards(JwtAuthGuard) +@Controller("sourced") +export class SourcedProductController { + constructor(private readonly sourcedProductService: SourcedProductService) {} + + @Get("catalogue") + listCatalogue( + @CurrentUser() user: AuthenticatedRequestUser, + @Query() query: SourcedCatalogueQueryDto, + ) { + return this.sourcedProductService.listCatalogue(user.id, query); + } + + @Post() + sourceProduct( + @CurrentUser() user: AuthenticatedRequestUser, + @Body() dto: SourceProductDto, + ) { + return this.sourcedProductService.sourceProduct(user.id, dto); + } + + @Get("me") + listOwn(@CurrentUser() user: AuthenticatedRequestUser) { + return this.sourcedProductService.listOwn(user.id); + } + + @Put(":id") + update( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("id") id: string, + @Body() dto: UpdateSourcedProductDto, + ) { + return this.sourcedProductService.update(user.id, id, dto); + } + + @Delete(":id") + remove( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("id") id: string, + ) { + return this.sourcedProductService.remove(user.id, id); + } +} diff --git a/apps/backend/src/domains/commerce/sourced-product/sourced-product.module.ts b/apps/backend/src/domains/commerce/sourced-product/sourced-product.module.ts new file mode 100644 index 00000000..326a73be --- /dev/null +++ b/apps/backend/src/domains/commerce/sourced-product/sourced-product.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; + +import { PrismaModule } from "../../../core/prisma/prisma.module"; +import { QueueModule } from "../../../queue/queue.module"; +import { SourcedProductController } from "./sourced-product.controller"; +import { SourcedProductService } from "./sourced-product.service"; + +@Module({ + imports: [PrismaModule, QueueModule], + controllers: [SourcedProductController], + providers: [SourcedProductService], + exports: [SourcedProductService], +}) +export class SourcedProductModule {} diff --git a/apps/backend/src/domains/commerce/sourced-product/sourced-product.service.spec.ts b/apps/backend/src/domains/commerce/sourced-product/sourced-product.service.spec.ts new file mode 100644 index 00000000..64f1109b --- /dev/null +++ b/apps/backend/src/domains/commerce/sourced-product/sourced-product.service.spec.ts @@ -0,0 +1,187 @@ +import { Prisma, ProductStatus, StoreType } from "@prisma/client"; + +import { SourcedProductService } from "./sourced-product.service"; + +function makeService() { + const prisma = { + storeProfile: { + findFirst: jest.fn().mockResolvedValue({ + id: "digital-store-1", + storeType: StoreType.DIGITAL, + }), + }, + product: { + findMany: jest.fn().mockResolvedValue([]), + findFirst: jest.fn(), + findUnique: jest.fn().mockResolvedValue(null), + }, + sourcedProduct: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + }; + const service = new SourcedProductService(prisma as never, {} as never); + return { service, prisma }; +} + +function makeSourceProduct() { + return { + id: "physical-product-1", + productCode: "TWZ-123456", + sourceCode: "SRC-774411", + name: "Supplier Sneakers", + title: "Supplier Sneakers", + description: "Supplier-written description.", + platformCategory: "Fashion", + categoryTag: "fashion", + retailPriceKobo: 1500000n, + dropshipperPriceKobo: 1200000n, + imageUrl: "https://cdn.example.com/sneakers.jpg", + viewCountWeekly: 10, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + storeId: "physical-store-1", + storeProfile: { + id: "physical-store-1", + storeHandle: "supplier-shop", + storeName: "Supplier Shop", + tier: "TIER_1", + storeType: StoreType.PHYSICAL, + isOpen: true, + allowDropship: true, + }, + variants: [], + images: [], + productStockCaches: [{ stock: 5, variantId: null }], + }; +} + +function makeOwnSourcedProduct(overrides: Record = {}) { + return { + id: "sourced-1", + listingCode: "SRC-483729", + productCode: "TWZ-839201", + customTitle: null, + customDescription: null, + customImages: null, + sellingPriceKobo: 1800000n, + floorPriceKobo: 1200000n, + isActive: true, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + physicalProduct: makeSourceProduct(), + ...overrides, + }; +} + +describe("SourcedProductService catalogue opt-in rules", () => { + it("rejects source catalogue access for physical stores", async () => { + const { service, prisma } = makeService(); + prisma.storeProfile.findFirst.mockResolvedValue({ + id: "physical-store-1", + storeType: StoreType.PHYSICAL, + }); + + await expect( + service.listCatalogue("physical-owner-1", {}), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "SOURCING_DIGITAL_ONLY", + }), + }); + + expect(prisma.product.findMany).not.toHaveBeenCalled(); + }); + + it("requires store-level and product-level sourcing opt-in", async () => { + const { service, prisma } = makeService(); + + await service.listCatalogue("digital-owner-1", {}); + + expect(prisma.product.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: ProductStatus.ACTIVE, + allowDropship: true, + storeId: { not: "digital-store-1" }, + storeProfile: expect.objectContaining({ + storeType: StoreType.PHYSICAL, + isOpen: true, + allowDropship: true, + }), + }), + }), + ); + }); + + it("blocks self-sourcing by excluding the requesting digital store", async () => { + const { service, prisma } = makeService(); + + await service.listCatalogue("digital-owner-1", {}); + + expect(prisma.product.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + storeId: { not: "digital-store-1" }, + }), + }), + ); + }); + + it("generates legacy listing code and normal public product code when sourcing a product", async () => { + const { service, prisma } = makeService(); + prisma.product.findFirst.mockResolvedValue(makeSourceProduct()); + prisma.sourcedProduct.findUnique + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + prisma.sourcedProduct.create + .mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError( + "Unique constraint failed on listingCode", + { + code: "P2002", + clientVersion: "test", + meta: { target: ["listingCode"] }, + }, + ), + ) + .mockResolvedValueOnce(makeOwnSourcedProduct()); + + const sourcedProduct = await service.sourceProduct("digital-owner-1", { + physicalProductId: "physical-product-1", + sellingPriceKobo: "1800000", + }); + + expect(prisma.sourcedProduct.create).toHaveBeenCalledTimes(2); + expect(prisma.sourcedProduct.create).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + listingCode: expect.stringMatching(/^SRC-\d{6}$/), + productCode: expect.stringMatching(/^TWZ-\d{6}$/), + physicalProductId: "physical-product-1", + physicalStoreId: "physical-store-1", + }), + }), + ); + expect(sourcedProduct).toMatchObject({ + id: "sourced-1", + listingCode: "SRC-483729", + productCode: "TWZ-839201", + }); + }); + + it("exposes sourceCode only in source catalogue context", async () => { + const { service, prisma } = makeService(); + prisma.product.findMany.mockResolvedValue([makeSourceProduct()]); + + const catalogue = await service.listCatalogue("digital-owner-1", {}); + + expect(catalogue[0]).toMatchObject({ + sourceProductId: "physical-product-1", + productCode: "TWZ-123456", + sourceCode: "SRC-774411", + }); + }); +}); diff --git a/apps/backend/src/domains/commerce/sourced-product/sourced-product.service.ts b/apps/backend/src/domains/commerce/sourced-product/sourced-product.service.ts new file mode 100644 index 00000000..660d641f --- /dev/null +++ b/apps/backend/src/domains/commerce/sourced-product/sourced-product.service.ts @@ -0,0 +1,878 @@ +import { InjectQueue } from "@nestjs/bullmq"; +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { Prisma, ProductStatus, StoreProfile, StoreType } from "@prisma/client"; +import { Queue } from "bullmq"; +import { randomInt } from "crypto"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { QUEUE } from "../../../queue/queue.constants"; +import { + SourceProductDto, + SourcedCatalogueQueryDto, + SourcedProductImageDto, +} from "./dto/source-product.dto"; +import { + OwnSourcedProductDto, + SourcedProductCatalogueItemDto, + SourcedProductImageResponseDto, + SourcedStockSummaryDto, +} from "./dto/sourced-product-response.dto"; +import { UpdateSourcedProductDto } from "./dto/update-sourced-product.dto"; + +const MAX_LISTING_CODE_ATTEMPTS = 10; + +const SOURCE_PRODUCT_SELECT = { + id: true, + productCode: true, + sourceCode: true, + name: true, + title: true, + description: true, + platformCategory: true, + categoryTag: true, + retailPriceKobo: true, + dropshipperPriceKobo: true, + imageUrl: true, + viewCountWeekly: true, + createdAt: true, + storeId: true, + storeProfile: { + select: { + id: true, + storeHandle: true, + storeName: true, + tier: true, + storeType: true, + isOpen: true, + allowDropship: true, + }, + }, + variants: { + where: { isActive: true }, + select: { + id: true, + colorName: true, + sizeName: true, + variantLabel: true, + }, + orderBy: { createdAt: "asc" }, + }, + images: { + where: { moderationStatus: { not: "BLOCKED" } }, + select: { + url: true, + cloudinaryPublicId: true, + altText: true, + isDefault: true, + order: true, + }, + orderBy: { order: "asc" }, + }, + productStockCaches: { + select: { + stock: true, + variantId: true, + }, + }, +} satisfies Prisma.ProductSelect; + +const OWN_SOURCED_PRODUCT_SELECT = { + id: true, + listingCode: true, + productCode: true, + customTitle: true, + customDescription: true, + customImages: true, + sellingPriceKobo: true, + floorPriceKobo: true, + isActive: true, + createdAt: true, + updatedAt: true, + physicalProduct: { + select: SOURCE_PRODUCT_SELECT, + }, +} satisfies Prisma.SourcedProductSelect; + +type SourceProductRecord = Prisma.ProductGetPayload<{ + select: typeof SOURCE_PRODUCT_SELECT; +}>; + +type OwnSourcedProductRecord = Prisma.SourcedProductGetPayload<{ + select: typeof OWN_SOURCED_PRODUCT_SELECT; +}>; + +type DigitalStore = Pick; + +@Injectable() +export class SourcedProductService { + constructor( + private readonly prisma: PrismaService, + @InjectQueue(QUEUE.FLOOR_CHECK) private readonly floorCheckQueue: Queue, + ) {} + + async listCatalogue( + userId: string, + query: SourcedCatalogueQueryDto, + ): Promise { + const digitalStore = await this.getDigitalStore(userId); + const where: Prisma.ProductWhereInput = { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + allowDropship: true, + storeId: { not: digitalStore.id }, + storeProfile: { + storeType: StoreType.PHYSICAL, + isOpen: true, + allowDropship: true, + }, + }; + + if (query.category) { + where.OR = [ + { platformCategory: { equals: query.category, mode: "insensitive" } }, + { categoryTag: { equals: query.category, mode: "insensitive" } }, + ]; + } + + const products = await this.prisma.product.findMany({ + where, + select: SOURCE_PRODUCT_SELECT, + orderBy: this.resolveCatalogueOrder(query.sort), + take: 100, + }); + + const minPrice = query.minPriceKobo + ? this.toPositiveBigInt(query.minPriceKobo, "MIN_PRICE_INVALID") + : null; + const maxPrice = query.maxPriceKobo + ? this.toPositiveBigInt(query.maxPriceKobo, "MAX_PRICE_INVALID") + : null; + + if (minPrice !== null && maxPrice !== null && minPrice > maxPrice) { + throw new BadRequestException({ + message: "Minimum price cannot be greater than maximum price", + code: "PRICE_RANGE_INVALID", + }); + } + + const inStock = + query.inStock === undefined ? null : query.inStock === "true"; + + const items = products + .map((product) => this.toCatalogueItem(product)) + .filter((item) => { + const floor = BigInt(item.floorPriceKobo); + + if (minPrice !== null && floor < minPrice) { + return false; + } + + if (maxPrice !== null && floor > maxPrice) { + return false; + } + + if (inStock !== null && item.stock.inStock !== inStock) { + return false; + } + + return true; + }); + + if (query.sort === "price_asc") { + return items.sort((left, right) => + this.compareBigIntStrings(left.floorPriceKobo, right.floorPriceKobo), + ); + } + + if (query.sort === "price_desc") { + return items.sort((left, right) => + this.compareBigIntStrings(right.floorPriceKobo, left.floorPriceKobo), + ); + } + + return items; + } + + async sourceProduct( + userId: string, + dto: SourceProductDto, + ): Promise { + const digitalStore = await this.getDigitalStore(userId); + const physicalProductId = this.resolvePhysicalProductId(dto); + const physicalProduct = await this.getSourceProductForSourcing( + physicalProductId, + digitalStore.id, + ); + const floorPriceKobo = this.getFloorPrice(physicalProduct); + const sellingPriceKobo = this.toPositiveBigInt( + dto.sellingPriceKobo, + "SELLING_PRICE_INVALID", + ); + + this.assertMeetsFloor(sellingPriceKobo, floorPriceKobo); + + const existing = await this.prisma.sourcedProduct.findUnique({ + where: { + digitalStoreId_physicalProductId: { + digitalStoreId: digitalStore.id, + physicalProductId: physicalProduct.id, + }, + }, + select: { id: true, isActive: true }, + }); + + if (existing?.isActive) { + throw new ConflictException({ + message: "This product is already sourced by your store", + code: "SOURCED_PRODUCT_ALREADY_EXISTS", + }); + } + + const data = { + customTitle: this.optionalText(dto.customName), + customDescription: this.optionalText(dto.customDescription), + customImages: this.toCustomImagesJson(dto.customPhotos), + sellingPriceKobo, + floorPriceKobo, + isActive: true, + }; + + const sourcedProduct = existing + ? await this.reactivateSourcedProduct(existing.id, data) + : await this.createSourcedProduct({ + digitalStoreId: digitalStore.id, + physicalStoreId: physicalProduct.storeId, + physicalProductId: physicalProduct.id, + ...data, + }); + + return this.toOwnResponse(sourcedProduct); + } + + async listOwn(userId: string): Promise { + const digitalStore = await this.getDigitalStore(userId); + const sourcedProducts = await this.prisma.sourcedProduct.findMany({ + where: { digitalStoreId: digitalStore.id }, + select: OWN_SOURCED_PRODUCT_SELECT, + orderBy: { updatedAt: "desc" }, + }); + + return sourcedProducts.map((sourcedProduct) => + this.toOwnResponse(sourcedProduct), + ); + } + + async update( + userId: string, + sourcedProductId: string, + dto: UpdateSourcedProductDto, + ): Promise { + const digitalStore = await this.getDigitalStore(userId); + const sourcedProduct = await this.findOwnedSourcedProduct( + sourcedProductId, + digitalStore.id, + ); + const floorPriceKobo = this.getFloorPrice(sourcedProduct.physicalProduct); + const data: Prisma.SourcedProductUpdateInput = { + floorPriceKobo, + }; + + if (dto.sellingPriceKobo !== undefined) { + const sellingPriceKobo = this.toPositiveBigInt( + dto.sellingPriceKobo, + "SELLING_PRICE_INVALID", + ); + this.assertMeetsFloor(sellingPriceKobo, floorPriceKobo); + data.sellingPriceKobo = sellingPriceKobo; + } else if (dto.isActive === true) { + this.assertMeetsFloor(sourcedProduct.sellingPriceKobo, floorPriceKobo); + } + + if (dto.customName !== undefined) { + data.customTitle = this.optionalText(dto.customName); + } + + if (dto.customDescription !== undefined) { + data.customDescription = this.optionalText(dto.customDescription); + } + + if (dto.customPhotos !== undefined) { + data.customImages = this.toCustomImagesJson(dto.customPhotos ?? []); + } + + if (dto.isActive !== undefined) { + data.isActive = dto.isActive; + } + + const updated = await this.prisma.sourcedProduct.update({ + where: { id: sourcedProduct.id }, + data, + select: OWN_SOURCED_PRODUCT_SELECT, + }); + + return this.toOwnResponse(updated); + } + + async remove( + userId: string, + sourcedProductId: string, + ): Promise<{ id: string }> { + const digitalStore = await this.getDigitalStore(userId); + const sourcedProduct = await this.findOwnedSourcedProduct( + sourcedProductId, + digitalStore.id, + ); + + await this.prisma.sourcedProduct.update({ + where: { id: sourcedProduct.id }, + data: { isActive: false }, + }); + + return { id: sourcedProduct.id }; + } + + async enqueueFloorCheck(physicalProductId: string): Promise { + await this.floorCheckQueue.add( + "check-sourced-product-floors", + { physicalProductId }, + { + jobId: `floor-check:${physicalProductId}:${Date.now()}`, + attempts: 3, + backoff: { type: "exponential", delay: 5000 }, + removeOnComplete: true, + removeOnFail: 100, + }, + ); + } + + async handleFloorPriceChange(physicalProductId: string): Promise<{ + physicalProductId: string; + floorPriceKobo: string; + deactivatedCount: number; + }> { + const product = await this.getActiveSourceProduct(physicalProductId); + const floorPriceKobo = this.getFloorPrice(product); + + const result = await this.prisma.sourcedProduct.updateMany({ + where: { + physicalProductId, + isActive: true, + sellingPriceKobo: { lt: floorPriceKobo }, + }, + data: { + isActive: false, + floorPriceKobo, + }, + }); + + return { + physicalProductId, + floorPriceKobo: floorPriceKobo.toString(), + deactivatedCount: result.count, + }; + } + + private async getDigitalStore(userId: string): Promise { + const store = await this.prisma.storeProfile.findFirst({ + where: { userId }, + select: { + id: true, + storeType: true, + }, + }); + + if (!store) { + throw new ForbiddenException({ + message: "A store profile is required to source products", + code: "STORE_PROFILE_REQUIRED", + }); + } + + if (store.storeType !== StoreType.DIGITAL) { + throw new ForbiddenException({ + message: "Only digital stores can source products", + code: "SOURCING_DIGITAL_ONLY", + }); + } + + return store; + } + + private resolvePhysicalProductId(dto: SourceProductDto): string { + const physicalProductId = + dto.physicalProductId?.trim() || dto.sourceProductId?.trim(); + + if (!physicalProductId) { + throw new BadRequestException({ + message: "A source product id is required", + code: "SOURCE_PRODUCT_REQUIRED", + }); + } + + if ( + dto.physicalProductId && + dto.sourceProductId && + dto.physicalProductId.trim() !== dto.sourceProductId.trim() + ) { + throw new BadRequestException({ + message: "Provide only one source product id", + code: "SOURCE_PRODUCT_ID_CONFLICT", + }); + } + + return physicalProductId; + } + + private async getSourceProductForSourcing( + physicalProductId: string, + digitalStoreId: string, + ): Promise { + const product = await this.getActiveSourceProduct(physicalProductId); + + if (product.storeId === digitalStoreId) { + throw new BadRequestException({ + message: "A store cannot source its own product", + code: "SOURCE_PRODUCT_SELF_NOT_ALLOWED", + }); + } + + if (!this.getStockSummary(product).inStock) { + throw new BadRequestException({ + message: "Source product stock is unavailable", + code: "SOURCE_PRODUCT_STOCK_UNAVAILABLE", + }); + } + + return product; + } + + private async getActiveSourceProduct( + physicalProductId: string, + ): Promise { + const product = await this.prisma.product.findFirst({ + where: { + id: physicalProductId, + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + allowDropship: true, + storeProfile: { + storeType: StoreType.PHYSICAL, + isOpen: true, + allowDropship: true, + }, + }, + select: SOURCE_PRODUCT_SELECT, + }); + + if (!product) { + throw new NotFoundException({ + message: "Source product not found", + code: "SOURCE_PRODUCT_NOT_FOUND", + }); + } + + return product; + } + + private async findOwnedSourcedProduct( + sourcedProductId: string, + digitalStoreId: string, + ): Promise { + const sourcedProduct = await this.prisma.sourcedProduct.findFirst({ + where: { + id: sourcedProductId, + digitalStoreId, + }, + select: OWN_SOURCED_PRODUCT_SELECT, + }); + + if (!sourcedProduct) { + throw new NotFoundException({ + message: "Sourced product not found", + code: "SOURCED_PRODUCT_NOT_FOUND", + }); + } + + return sourcedProduct; + } + + private async createSourcedProduct( + data: Omit< + Prisma.SourcedProductUncheckedCreateInput, + "listingCode" | "productCode" + >, + ): Promise { + return this.withSourcedCodesRetry((codes) => + this.prisma.sourcedProduct.create({ + data: { + ...data, + listingCode: codes.listingCode, + productCode: codes.productCode, + }, + select: OWN_SOURCED_PRODUCT_SELECT, + }), + ); + } + + private async reactivateSourcedProduct( + sourcedProductId: string, + data: Prisma.SourcedProductUpdateInput, + ): Promise { + const existing = await this.prisma.sourcedProduct.findUnique({ + where: { id: sourcedProductId }, + select: { listingCode: true, productCode: true }, + }); + + if (existing?.listingCode && existing.productCode) { + return this.prisma.sourcedProduct.update({ + where: { id: sourcedProductId }, + data, + select: OWN_SOURCED_PRODUCT_SELECT, + }); + } + + return this.withSourcedCodesRetry((codes) => + this.prisma.sourcedProduct.update({ + where: { id: sourcedProductId }, + data: { + ...data, + listingCode: existing?.listingCode ?? codes.listingCode, + productCode: existing?.productCode ?? codes.productCode, + }, + select: OWN_SOURCED_PRODUCT_SELECT, + }), + ); + } + + private async withSourcedCodesRetry( + write: (codes: { + listingCode: string; + productCode: string; + }) => Promise, + ): Promise { + for (let attempt = 0; attempt < MAX_LISTING_CODE_ATTEMPTS; attempt += 1) { + const listingCode = await this.generateListingCode(); + const productCode = await this.generateSourcedProductCode(); + + try { + return await write({ listingCode, productCode }); + } catch (error) { + if (!this.isUniqueSourcedCodeCollision(error)) { + throw error; + } + } + } + + throw new ConflictException({ + message: "Could not generate a unique sourced listing code", + code: "SOURCED_LISTING_CODE_COLLISION", + }); + } + + private async generateListingCode(): Promise { + for (let attempt = 0; attempt < MAX_LISTING_CODE_ATTEMPTS; attempt += 1) { + const listingCode = `SRC-${randomInt(100000, 1000000)}`; + const existing = await this.prisma.sourcedProduct.findUnique({ + where: { listingCode }, + select: { id: true }, + }); + + if (!existing) { + return listingCode; + } + } + + throw new ConflictException({ + message: "Could not generate a unique sourced listing code", + code: "SOURCED_LISTING_CODE_COLLISION", + }); + } + + private async generateSourcedProductCode(): Promise { + for (let attempt = 0; attempt < MAX_LISTING_CODE_ATTEMPTS; attempt += 1) { + const productCode = `TWZ-${randomInt(100000, 1000000)}`; + const [existingSourced, existingNative] = await Promise.all([ + this.prisma.sourcedProduct.findUnique({ + where: { productCode }, + select: { id: true }, + }), + this.prisma.product.findUnique({ + where: { productCode }, + select: { id: true }, + }), + ]); + + if (!existingSourced && !existingNative) { + return productCode; + } + } + + throw new ConflictException({ + message: "Could not generate a unique sourced product code", + code: "SOURCED_PRODUCT_CODE_COLLISION", + }); + } + + private isUniqueSourcedCodeCollision(error: unknown): boolean { + if ( + !(error instanceof Prisma.PrismaClientKnownRequestError) || + error.code !== "P2002" + ) { + return false; + } + + const target = error.meta?.target; + const targetValues = Array.isArray(target) + ? target.map(String) + : typeof target === "string" + ? [target] + : []; + + return ( + targetValues.includes("listing_code") || + targetValues.includes("listingCode") || + targetValues.includes("product_code") || + targetValues.includes("productCode") + ); + } + + private resolveCatalogueOrder( + sort: SourcedCatalogueQueryDto["sort"], + ): Prisma.ProductOrderByWithRelationInput { + if (sort === "trending") { + return { viewCountWeekly: "desc" }; + } + + return { createdAt: "desc" }; + } + + private getFloorPrice(product: SourceProductRecord): bigint { + const floorPriceKobo = + product.dropshipperPriceKobo ?? product.retailPriceKobo; + + if (floorPriceKobo === null) { + throw new BadRequestException({ + message: "Source product has no valid floor price", + code: "SOURCE_PRODUCT_PRICE_MISSING", + }); + } + + return floorPriceKobo; + } + + private assertMeetsFloor( + sellingPriceKobo: bigint, + floorPriceKobo: bigint, + ): void { + if (sellingPriceKobo < floorPriceKobo) { + throw new BadRequestException({ + message: "Selling price is below the source product floor price", + code: "PRICE_BELOW_FLOOR", + floorPriceKobo: floorPriceKobo.toString(), + }); + } + } + + private toPositiveBigInt(value: string, code: string): bigint { + const amount = BigInt(value); + + if (amount <= 0n) { + throw new BadRequestException({ + message: "Money values must be positive kobo integers", + code, + }); + } + + return amount; + } + + private toCatalogueItem( + product: SourceProductRecord, + ): SourcedProductCatalogueItemDto { + const floorPriceKobo = this.getFloorPrice(product); + + return { + sourceProductId: product.id, + productCode: product.productCode, + sourceCode: product.sourceCode, + title: product.title ?? product.name, + description: product.description, + category: product.platformCategory ?? product.categoryTag, + imageUrl: product.imageUrl, + images: this.toImageResponses(product.images), + variants: product.variants.map((variant) => ({ + id: variant.id, + label: variant.variantLabel, + colorName: variant.colorName, + sizeName: variant.sizeName, + })), + sourceStore: { + handle: product.storeProfile.storeHandle, + name: product.storeProfile.storeName, + tier: product.storeProfile.tier, + }, + dropshipperCostKobo: floorPriceKobo.toString(), + floorPriceKobo: floorPriceKobo.toString(), + suggestedPriceKobo: floorPriceKobo.toString(), + stock: this.getStockSummary(product), + }; + } + + private toOwnResponse( + sourcedProduct: OwnSourcedProductRecord, + ): OwnSourcedProductDto { + const currentFloor = this.getFloorPrice(sourcedProduct.physicalProduct); + const customImages = this.parseCustomImages(sourcedProduct.customImages); + + return { + id: sourcedProduct.id, + listingCode: sourcedProduct.listingCode, + productCode: sourcedProduct.productCode, + customName: sourcedProduct.customTitle, + customDescription: sourcedProduct.customDescription, + customPhotos: customImages, + sellingPriceKobo: sourcedProduct.sellingPriceKobo.toString(), + floorPriceKobo: currentFloor.toString(), + isActive: sourcedProduct.isActive, + priceUpdateRequired: sourcedProduct.sellingPriceKobo < currentFloor, + sourceProduct: { + productCode: sourcedProduct.physicalProduct.productCode, + title: + sourcedProduct.physicalProduct.title ?? + sourcedProduct.physicalProduct.name, + description: sourcedProduct.physicalProduct.description, + category: + sourcedProduct.physicalProduct.platformCategory ?? + sourcedProduct.physicalProduct.categoryTag, + imageUrl: sourcedProduct.physicalProduct.imageUrl, + sourceStore: { + handle: sourcedProduct.physicalProduct.storeProfile.storeHandle, + name: sourcedProduct.physicalProduct.storeProfile.storeName, + tier: sourcedProduct.physicalProduct.storeProfile.tier, + }, + }, + stock: this.getStockSummary(sourcedProduct.physicalProduct), + createdAt: sourcedProduct.createdAt.toISOString(), + updatedAt: sourcedProduct.updatedAt.toISOString(), + }; + } + + private getStockSummary( + product: SourceProductRecord, + ): SourcedStockSummaryDto { + if (product.productStockCaches.length === 0) { + return { + hasStockCache: false, + availableStock: 0, + inStock: false, + }; + } + + const availableStock = product.productStockCaches.reduce( + (total, cache) => total + cache.stock, + 0, + ); + + return { + hasStockCache: true, + availableStock, + inStock: availableStock > 0, + }; + } + + private toImageResponses( + images: SourceProductRecord["images"], + ): SourcedProductImageResponseDto[] { + return images.map((image) => ({ + url: image.url, + cloudinaryPublicId: image.cloudinaryPublicId, + altText: image.altText, + })); + } + + private toCustomImagesJson( + images: SourcedProductImageDto[] | undefined, + ): Prisma.InputJsonValue | typeof Prisma.JsonNull { + if (!images || images.length === 0) { + return Prisma.JsonNull; + } + + return images.map((image) => ({ + url: image.url, + cloudinaryPublicId: image.cloudinaryPublicId ?? null, + altText: image.altText ?? null, + })); + } + + private parseCustomImages( + value: Prisma.JsonValue | null, + ): SourcedProductImageResponseDto[] { + if (!Array.isArray(value)) { + return []; + } + + const images: SourcedProductImageResponseDto[] = []; + + for (const item of value) { + if (!this.isImageJsonObject(item)) { + continue; + } + + images.push({ + url: item.url, + cloudinaryPublicId: item.cloudinaryPublicId ?? null, + altText: item.altText ?? null, + }); + } + + return images; + } + + private isImageJsonObject(value: Prisma.JsonValue): value is { + url: string; + cloudinaryPublicId?: string | null; + altText?: string | null; + } { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + "url" in value && + typeof value.url === "string" && + (!("cloudinaryPublicId" in value) || + value.cloudinaryPublicId === null || + typeof value.cloudinaryPublicId === "string") && + (!("altText" in value) || + value.altText === null || + typeof value.altText === "string") + ); + } + + private optionalText(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; + } + + private compareBigIntStrings(left: string, right: string): number { + const leftValue = BigInt(left); + const rightValue = BigInt(right); + + if (leftValue < rightValue) { + return -1; + } + + if (leftValue > rightValue) { + return 1; + } + + return 0; + } +} diff --git a/apps/backend/src/modules/wishlist/wishlist.controller.ts b/apps/backend/src/domains/commerce/wishlist/wishlist.controller.ts similarity index 89% rename from apps/backend/src/modules/wishlist/wishlist.controller.ts rename to apps/backend/src/domains/commerce/wishlist/wishlist.controller.ts index 4252b001..d3548135 100644 --- a/apps/backend/src/modules/wishlist/wishlist.controller.ts +++ b/apps/backend/src/domains/commerce/wishlist/wishlist.controller.ts @@ -1,7 +1,7 @@ import { Controller, Post, Get, Param, UseGuards } from "@nestjs/common"; import { WishlistService } from "./wishlist.service"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { CurrentUser } from "../../common/decorators/current-user.decorator"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { CurrentUser } from "../../../common/decorators/current-user.decorator"; @Controller("wishlist") @UseGuards(JwtAuthGuard) diff --git a/apps/backend/src/modules/wishlist/wishlist.module.ts b/apps/backend/src/domains/commerce/wishlist/wishlist.module.ts similarity index 84% rename from apps/backend/src/modules/wishlist/wishlist.module.ts rename to apps/backend/src/domains/commerce/wishlist/wishlist.module.ts index e7e934b9..5b256413 100644 --- a/apps/backend/src/modules/wishlist/wishlist.module.ts +++ b/apps/backend/src/domains/commerce/wishlist/wishlist.module.ts @@ -1,7 +1,7 @@ import { Module } from "@nestjs/common"; import { WishlistController } from "./wishlist.controller"; import { WishlistService } from "./wishlist.service"; -import { PrismaModule } from "../../prisma/prisma.module"; +import { PrismaModule } from "../../../prisma/prisma.module"; @Module({ imports: [PrismaModule], diff --git a/apps/backend/src/modules/wishlist/wishlist.service.ts b/apps/backend/src/domains/commerce/wishlist/wishlist.service.ts similarity index 77% rename from apps/backend/src/modules/wishlist/wishlist.service.ts rename to apps/backend/src/domains/commerce/wishlist/wishlist.service.ts index 0b266ba7..b955d291 100644 --- a/apps/backend/src/modules/wishlist/wishlist.service.ts +++ b/apps/backend/src/domains/commerce/wishlist/wishlist.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from "@nestjs/common"; -import { PrismaService } from "../../prisma/prisma.service"; +import { PrismaService } from "../../../prisma/prisma.service"; @Injectable() export class WishlistService { @@ -41,17 +41,27 @@ export class WishlistService { include: { product: { include: { - merchantProfile: { + storeProfile: { select: { id: true, businessName: true, + storeName: true, + storeHandle: true, + // logoUrl is the store's public logo; profileImage is a + // separate (often empty) field, so both are exposed and the + // client prefers logoUrl. + logoUrl: true, verificationTier: true, profileImage: true, averageRating: true, reviewCount: true, }, }, - productStockCache: { select: { stock: true } }, + productStockCaches: { + where: { variantId: null }, + take: 1, + select: { stock: true }, + }, category: { select: { name: true, slug: true } }, }, }, @@ -61,8 +71,8 @@ export class WishlistService { return saved.map((s) => ({ ...s.product, savedAt: s.createdAt, - stockCache: s.product.productStockCache, - merchantProfile: s.product.merchantProfile, + stockCache: s.product.productStockCaches[0] ?? null, + storeProfile: s.product.storeProfile, })); } diff --git a/apps/backend/src/domains/domain-composition.spec.ts b/apps/backend/src/domains/domain-composition.spec.ts new file mode 100644 index 00000000..a26d2686 --- /dev/null +++ b/apps/backend/src/domains/domain-composition.spec.ts @@ -0,0 +1,114 @@ +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; + +function readDomainModule(fileName: string): string { + return readFileSync(join(__dirname, fileName), "utf8"); +} + +describe("backend domain module composition", () => { + it("keeps channel aggregation limited to external channel entrypoints", () => { + const source = readDomainModule("channels.module.ts"); + + expect(source).toContain("WhatsAppModule"); + expect(source).toContain("UssdModule"); + expect(source).not.toContain("UploadModule"); + expect(source).not.toContain("EmailModule"); + expect(source).not.toContain("NotificationModule"); + }); + + it("keeps order lifecycle modules under the orders domain", () => { + const ordersSource = readDomainModule("orders.module.ts"); + const commerceSource = readDomainModule("commerce.module.ts"); + const legacyOrderModulePath = join(__dirname, "..", "modules", "order"); + + expect(ordersSource).toContain("./orders/order/order.module"); + expect(ordersSource).toContain("OrderModule"); + expect(ordersSource).toContain("CartModule"); + expect(ordersSource).toContain("DisputeModule"); + expect(ordersSource).toContain("DeliveryModule"); + expect(commerceSource).not.toContain("OrderModule"); + expect(commerceSource).not.toContain("CartModule"); + expect(commerceSource).not.toContain("DisputeModule"); + expect(commerceSource).not.toContain("DeliveryModule"); + expect(existsSync(legacyOrderModulePath)).toBe(false); + }); + + it("keeps money providers composed through the money domain", () => { + const source = readDomainModule("money.module.ts"); + const legacyLedgerModulePath = join(__dirname, "..", "modules", "ledger"); + const legacyPayoutModulePath = join(__dirname, "..", "modules", "payout"); + + expect(source).toContain("LedgerModule"); + expect(source).toContain("PaymentModule"); + expect(source).toContain("PayoutModule"); + expect(existsSync(legacyLedgerModulePath)).toBe(false); + expect(existsSync(legacyPayoutModulePath)).toBe(false); + }); + + it("keeps platform and communications modules in their owning domains", () => { + const platformSource = readDomainModule("platform.module.ts"); + const socialSource = readDomainModule("social.module.ts"); + const legacyEmailModulePath = join(__dirname, "..", "modules", "email"); + const legacyAdminModulePath = join(__dirname, "..", "modules", "admin"); + const legacyVerificationModulePath = join( + __dirname, + "..", + "modules", + "verification", + ); + const legacyNotificationModulePath = join( + __dirname, + "..", + "modules", + "notification", + ); + const usersTrustSource = readDomainModule("users-trust.module.ts"); + + expect(platformSource).toContain("./platform/admin/admin.module"); + expect(platformSource).toContain("AdminModule"); + expect(platformSource).toContain("UploadModule"); + expect(socialSource).toContain("NotificationModule"); + expect(socialSource).toContain("EmailModule"); + expect(existsSync(legacyNotificationModulePath)).toBe(false); + expect(usersTrustSource).toContain( + "./users/verification/verification.module", + ); + expect(existsSync(legacyEmailModulePath)).toBe(false); + expect(existsSync(legacyAdminModulePath)).toBe(false); + expect(existsSync(legacyVerificationModulePath)).toBe(false); + expect(usersTrustSource).not.toContain("AdminModule"); + }); + + it("keeps user auth under the users domain", () => { + const usersTrustSource = readDomainModule("users-trust.module.ts"); + const legacyAuthModulePath = join(__dirname, "..", "modules", "auth"); + + expect(usersTrustSource).toContain("./users/auth/auth.module"); + expect(usersTrustSource).toContain("UsersAuthModule"); + expect(usersTrustSource).not.toContain("../modules/auth/auth.module"); + expect(existsSync(legacyAuthModulePath)).toBe(false); + }); + it("uses commerce domain modules as the single product and inventory owners", () => { + const commerceSource = readDomainModule("commerce.module.ts"); + const legacyProductModulePath = join(__dirname, "..", "modules", "product"); + const legacyInventoryModulePath = join( + __dirname, + "..", + "modules", + "inventory", + ); + + expect(commerceSource).toContain("./commerce/product/product.module"); + expect(commerceSource).toContain("./commerce/inventory/inventory.module"); + expect(existsSync(legacyProductModulePath)).toBe(false); + expect(existsSync(legacyInventoryModulePath)).toBe(false); + }); + + it("uses the platform upload module as the single upload owner", () => { + const platformSource = readDomainModule("platform.module.ts"); + const legacyUploadModulePath = join(__dirname, "..", "modules", "upload"); + + expect(platformSource).toContain("./platform/upload/upload.module"); + expect(existsSync(legacyUploadModulePath)).toBe(false); + }); +}); diff --git a/apps/backend/src/domains/money.module.ts b/apps/backend/src/domains/money.module.ts new file mode 100644 index 00000000..b28f1f90 --- /dev/null +++ b/apps/backend/src/domains/money.module.ts @@ -0,0 +1,24 @@ +import { Module } from "@nestjs/common"; + +import { DvaModule } from "./money/payment/dva/dva.module"; +import { PaymentModule } from "./money/payment/payment.module"; +import { PayoutModule } from "./money/payout/payout.module"; +import { LedgerModule } from "./money/ledger/ledger.module"; +import { SettlementModule } from "./money/settlement/settlement.module"; +import { StorePassModule } from "./money/storepass/storepass.module"; +import { SubscriptionBillingModule } from "./money/subscription-billing/subscription-billing.module"; +import { EarningsModule } from "./money/earnings/earnings.module"; + +@Module({ + imports: [ + LedgerModule, + PaymentModule, + PayoutModule, + SettlementModule, + DvaModule, + StorePassModule, + SubscriptionBillingModule, + EarningsModule, + ], +}) +export class MoneyDomainModule {} diff --git a/apps/backend/src/domains/money/earnings/earnings.module.ts b/apps/backend/src/domains/money/earnings/earnings.module.ts new file mode 100644 index 00000000..054e7680 --- /dev/null +++ b/apps/backend/src/domains/money/earnings/earnings.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; + +import { PrismaModule } from "../../../prisma/prisma.module"; +import { LedgerModule } from "../ledger/ledger.module"; +import { StoreEarningsController } from "./store-earnings.controller"; +import { StoreEarningsService } from "./store-earnings.service"; + +@Module({ + imports: [PrismaModule, LedgerModule], + controllers: [StoreEarningsController], + providers: [StoreEarningsService], + exports: [StoreEarningsService], +}) +export class EarningsModule {} diff --git a/apps/backend/src/domains/money/earnings/store-earnings.controller.ts b/apps/backend/src/domains/money/earnings/store-earnings.controller.ts new file mode 100644 index 00000000..b60406d6 --- /dev/null +++ b/apps/backend/src/domains/money/earnings/store-earnings.controller.ts @@ -0,0 +1,31 @@ +import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common"; +import { UserRole } from "@twizrr/shared"; + +import { CurrentMerchant } from "../../../common/decorators/current-merchant.decorator"; +import { Roles } from "../../../common/decorators/roles.decorator"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { RolesGuard } from "../../../common/guards/roles.guard"; +import { StoreEarningsService } from "./store-earnings.service"; + +@Controller("store/earnings") +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.USER) +export class StoreEarningsController { + constructor(private readonly earningsService: StoreEarningsService) {} + + @Get("summary") + getSummary(@CurrentMerchant() storeId: string | undefined) { + if (!storeId) { + throw new ForbiddenException("Store identity required"); + } + return this.earningsService.getSummary(storeId); + } + + @Get("today") + getToday(@CurrentMerchant() storeId: string | undefined) { + if (!storeId) { + throw new ForbiddenException("Store identity required"); + } + return this.earningsService.getToday(storeId); + } +} diff --git a/apps/backend/src/domains/money/earnings/store-earnings.service.spec.ts b/apps/backend/src/domains/money/earnings/store-earnings.service.spec.ts new file mode 100644 index 00000000..93a0363e --- /dev/null +++ b/apps/backend/src/domains/money/earnings/store-earnings.service.spec.ts @@ -0,0 +1,128 @@ +import { ServiceUnavailableException } from "@nestjs/common"; +import { + DisputeSettlementStatus, + DisputeStatus, + LedgerDirection, + LedgerEntryType, + OrderStatus, + OrderDisputeStatus, + PayoutStatus, +} from "@prisma/client"; + +import { StoreEarningsService } from "./store-earnings.service"; + +const order = (id: string, overrides: Record = {}) => ({ + id, + totalAmountKobo: 1_200n, + deliveryFeeKobo: 100n, + platformFeeKobo: 100n, + dropshipperCostKobo: null, + status: OrderStatus.COMPLETED, + disputeStatus: OrderDisputeStatus.NONE, + payout: null, + disputes: [], + ...overrides, +}); + +describe("StoreEarningsService", () => { + const makeService = ({ + activeRequestKobo = 200n, + }: { activeRequestKobo?: bigint } = {}) => { + const orders = [ + order("protected", { status: OrderStatus.IN_TRANSIT }), + order("available"), + order("failed", { + payout: { amountKobo: 1_000n, status: PayoutStatus.FAILED }, + }), + order("processing", { + payout: { + amountKobo: 1_000n, + status: PayoutStatus.RECONCILIATION_REQUIRED, + }, + }), + order("review", { + disputes: [ + { + status: DisputeStatus.UNDER_REVIEW, + settlement: { + status: DisputeSettlementStatus.MANUAL_REVIEW, + merchantPayoutAmountKobo: 600n, + }, + }, + ], + }), + order("legacy-review", { + status: OrderStatus.DISPUTE, + disputeStatus: OrderDisputeStatus.PENDING, + }), + order("paid", { + payout: { amountKobo: 1_000n, status: PayoutStatus.COMPLETED }, + }), + order("reserved", { + payout: { amountKobo: 1_000n, status: PayoutStatus.PENDING }, + }), + ]; + const prisma = { + order: { + findMany: jest + .fn() + .mockResolvedValueOnce(orders) + .mockResolvedValueOnce([]), + }, + payoutRequest: { + aggregate: jest + .fn() + .mockResolvedValue({ _sum: { amountKobo: activeRequestKobo } }), + }, + ledgerEntry: { + findMany: jest.fn().mockResolvedValue( + ["available", "failed"].map((orderId) => ({ + orderId, + entryType: LedgerEntryType.ESCROW_HELD, + direction: LedgerDirection.RELEASE, + amountKobo: 1_000n, + })), + ), + }, + }; + const ledger = { + calculateOrderPayout: jest.fn(() => ({ payoutAmountKobo: 1_000n })), + }; + return { + service: new StoreEarningsService(prisma as never, ledger as never), + prisma, + }; + }; + + it("classifies every entitlement into one mutually exclusive bucket", async () => { + const { service } = makeService(); + + await expect(service.getSummary("store-1")).resolves.toMatchObject({ + currency: "NGN", + protectedKobo: "1000", + availableKobo: "1800", + reservedKobo: "1200", + processingKobo: "1000", + paidOutKobo: "1000", + underReviewKobo: "2000", + }); + }); + + it("returns a confirmed retry-safe failed payout to available earnings", async () => { + const { service } = makeService({ activeRequestKobo: 0n }); + + const summary = await service.getSummary("store-1"); + + expect(summary.availableKobo).toBe("2000"); + expect(summary.processingKobo).toBe("1000"); + expect(summary.underReviewKobo).toBe("2000"); + }); + + it("fails safely instead of hiding an over-reservation invariant", async () => { + const { service } = makeService({ activeRequestKobo: 2_001n }); + + await expect(service.getSummary("store-1")).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + }); +}); diff --git a/apps/backend/src/domains/money/earnings/store-earnings.service.ts b/apps/backend/src/domains/money/earnings/store-earnings.service.ts new file mode 100644 index 00000000..1f7132ed --- /dev/null +++ b/apps/backend/src/domains/money/earnings/store-earnings.service.ts @@ -0,0 +1,321 @@ +import { + Injectable, + Logger, + ServiceUnavailableException, +} from "@nestjs/common"; +import { + DisputeSettlementStatus, + DisputeStatus, + LedgerDirection, + LedgerEntryType, + OrderDisputeStatus, + OrderStatus, + PayoutStatus, + Prisma, +} from "@prisma/client"; + +import { PrismaService } from "../../../prisma/prisma.service"; +import { LedgerService } from "../ledger/ledger.service"; + +type EarningsClient = PrismaService | Prisma.TransactionClient; + +export interface StoreEarningsSummary { + currency: "NGN"; + protectedKobo: string; + availableKobo: string; + reservedKobo: string; + processingKobo: string; + paidOutKobo: string; + underReviewKobo: string; + asOf: string; +} + +export interface StoreEarningsToday { + currency: "NGN"; + ordersToday: number; + revenueKobo: string; + earnedKobo: string; + awaitingPrep: number; + asOf: string; +} + +const ACTIVE_DISPUTE_STATUSES = new Set([ + DisputeStatus.OPEN, + DisputeStatus.STORE_RESPONDED, + DisputeStatus.UNDER_REVIEW, +]); + +const REVIEW_SETTLEMENT_STATUSES = new Set([ + DisputeSettlementStatus.PENDING, + DisputeSettlementStatus.PROCESSING, + DisputeSettlementStatus.PARTIALLY_COMPLETED, + DisputeSettlementStatus.FAILED, + DisputeSettlementStatus.RECONCILIATION_REQUIRED, + DisputeSettlementStatus.MANUAL_REVIEW, +]); +const PROCESSING_PAYOUT_STATUSES = new Set([ + PayoutStatus.PROCESSING, + PayoutStatus.SUBMITTED, + PayoutStatus.RECONCILIATION_REQUIRED, +]); + +const PROTECTED_ORDER_STATUSES = new Set([ + OrderStatus.PAID, + OrderStatus.PREPARING, + OrderStatus.READY_FOR_PICKUP, + OrderStatus.DISPATCHED, + OrderStatus.IN_TRANSIT, + OrderStatus.DELIVERED, + OrderStatus.COMPLETED, +]); +const EARNINGS_ORDER_BATCH_SIZE = 250; + +@Injectable() +export class StoreEarningsService { + private readonly logger = new Logger(StoreEarningsService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly ledgerService: LedgerService, + ) {} + + async getSummary( + storeId: string, + tx?: Prisma.TransactionClient, + ): Promise { + const client: EarningsClient = tx ?? this.prisma; + const activeRequestAggregate = await client.payoutRequest.aggregate({ + where: { storeId, status: { in: ["PENDING", "PROCESSING"] } }, + _sum: { amountKobo: true }, + }); + + let protectedKobo = 0n; + let availableBeforeRequestsKobo = 0n; + let reservedKobo = 0n; + let processingKobo = 0n; + let paidOutKobo = 0n; + let underReviewKobo = 0n; + let cursor: string | undefined; + + for (;;) { + const orders = await client.order.findMany({ + where: { storeId }, + orderBy: { id: "asc" }, + take: EARNINGS_ORDER_BATCH_SIZE, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + select: { + id: true, + totalAmountKobo: true, + deliveryFeeKobo: true, + platformFeeKobo: true, + processorFeeKobo: true, + dropshipperCostKobo: true, + status: true, + disputeStatus: true, + payout: { + select: { amountKobo: true, status: true }, + }, + disputes: { + orderBy: { createdAt: "desc" }, + take: 1, + select: { + status: true, + settlement: { + select: { + status: true, + }, + }, + }, + }, + }, + }); + if (orders.length === 0) break; + cursor = orders[orders.length - 1].id; + + const ledgerEntries = await client.ledgerEntry.findMany({ + where: { + orderId: { in: orders.map((order) => order.id) }, + entryType: { + in: [LedgerEntryType.ESCROW_HELD, LedgerEntryType.REFUND_COMPLETED], + }, + }, + select: { + orderId: true, + entryType: true, + direction: true, + amountKobo: true, + }, + }); + + const releasedOrderIds = new Set(); + const confirmedRefundByOrder = new Map(); + for (const entry of ledgerEntries) { + if (!entry.orderId) continue; + if ( + entry.entryType === LedgerEntryType.ESCROW_HELD && + entry.direction === LedgerDirection.RELEASE + ) { + releasedOrderIds.add(entry.orderId); + } else if (entry.entryType === LedgerEntryType.REFUND_COMPLETED) { + confirmedRefundByOrder.set( + entry.orderId, + (confirmedRefundByOrder.get(entry.orderId) ?? 0n) + + BigInt(entry.amountKobo), + ); + } + } + + for (const order of orders) { + const entitlement = + this.ledgerService.calculateOrderPayout(order).payoutAmountKobo; + if (entitlement <= 0n) continue; + + const payout = order.payout; + if (payout?.status === PayoutStatus.COMPLETED) { + paidOutKobo += BigInt(payout.amountKobo); + continue; + } + if (payout && PROCESSING_PAYOUT_STATUSES.has(payout.status)) { + processingKobo += BigInt(payout.amountKobo); + continue; + } + + const latestDispute = order.disputes[0]; + const settlement = latestDispute?.settlement; + const isUnderReview = + order.status === OrderStatus.DISPUTE || + order.status === OrderStatus.REFUND_PENDING || + order.disputeStatus === OrderDisputeStatus.PENDING || + (latestDispute && + ACTIVE_DISPUTE_STATUSES.has(latestDispute.status)) || + (settlement && REVIEW_SETTLEMENT_STATUSES.has(settlement.status)); + if (isUnderReview) { + // Show the full store entitlement affected by review. The approved + // merchant leg may be zero (for a buyer-win plan), but that must not + // make the held entitlement disappear from every bucket. + underReviewKobo += entitlement; + continue; + } + + if (payout?.status === PayoutStatus.PENDING) { + reservedKobo += BigInt(payout.amountKobo); + continue; + } + + if (releasedOrderIds.has(order.id)) { + const confirmedRefund = confirmedRefundByOrder.get(order.id) ?? 0n; + if (confirmedRefund > entitlement) { + this.failInvariant( + storeId, + `confirmed refund exceeds store entitlement for order ${order.id}`, + ); + } + availableBeforeRequestsKobo += entitlement - confirmedRefund; + continue; + } + + if (PROTECTED_ORDER_STATUSES.has(order.status)) { + protectedKobo += entitlement; + } + } + } + + const requestReservations = BigInt( + activeRequestAggregate._sum.amountKobo ?? 0, + ); + reservedKobo += requestReservations; + if (requestReservations > availableBeforeRequestsKobo) { + this.failInvariant( + storeId, + "active payout reservations exceed available released entitlement", + ); + } + const availableKobo = availableBeforeRequestsKobo - requestReservations; + + return { + currency: "NGN", + protectedKobo: protectedKobo.toString(), + availableKobo: availableKobo.toString(), + reservedKobo: reservedKobo.toString(), + processingKobo: processingKobo.toString(), + paidOutKobo: paidOutKobo.toString(), + underReviewKobo: underReviewKobo.toString(), + asOf: new Date().toISOString(), + }; + } + + /** + * Lightweight "today" rollup for the store dashboard: paid orders placed + * since local midnight, the item revenue and net earnings on them, and the + * current count of orders awaiting pickup prep (not date-scoped). Uses the + * same payout formula as the balance, so "earned" ties out with the cards. + */ + async getToday(storeId: string): Promise { + // Anchor "today" to Nigeria time (UTC+1, no DST) so the rollup does not + // depend on the container's TZ. A UTC container would otherwise start the + // window at 01:00 WAT and mis-attribute midnight-to-01:00 orders. + const WAT_OFFSET_MS = 60 * 60 * 1000; + const nowWat = new Date(Date.now() + WAT_OFFSET_MS); + const todayStart = new Date( + Date.UTC( + nowWat.getUTCFullYear(), + nowWat.getUTCMonth(), + nowWat.getUTCDate(), + ) - WAT_OFFSET_MS, + ); + + const orders = await this.prisma.order.findMany({ + where: { + storeId, + createdAt: { gte: todayStart }, + status: { + notIn: [OrderStatus.PENDING_PAYMENT, OrderStatus.CANCELLED], + }, + }, + select: { + totalAmountKobo: true, + deliveryFeeKobo: true, + platformFeeKobo: true, + processorFeeKobo: true, + dropshipperCostKobo: true, + }, + }); + + let revenueKobo = 0n; + let earnedKobo = 0n; + for (const order of orders) { + const subtotal = + BigInt(order.totalAmountKobo) - BigInt(order.deliveryFeeKobo ?? 0n); + if (subtotal > 0n) revenueKobo += subtotal; + const earn = + this.ledgerService.calculateOrderPayout(order).payoutAmountKobo; + if (earn > 0n) earnedKobo += earn; + } + + const awaitingPrep = await this.prisma.order.count({ + where: { + storeId, + status: { in: [OrderStatus.PAID, OrderStatus.PREPARING] }, + }, + }); + + return { + currency: "NGN", + ordersToday: orders.length, + revenueKobo: revenueKobo.toString(), + earnedKobo: earnedKobo.toString(), + awaitingPrep, + asOf: new Date().toISOString(), + }; + } + + private failInvariant(storeId: string, reason: string): never { + this.logger.error( + `Store earnings invariant failed for ${storeId}: ${reason}`, + ); + throw new ServiceUnavailableException({ + message: "Store earnings are temporarily unavailable for review.", + code: "STORE_EARNINGS_INVARIANT_FAILED", + }); + } +} diff --git a/apps/backend/src/domains/money/escrow/README.md b/apps/backend/src/domains/money/escrow/README.md new file mode 100644 index 00000000..d6ac0945 --- /dev/null +++ b/apps/backend/src/domains/money/escrow/README.md @@ -0,0 +1,5 @@ +# Escrow Domain + +Escrow hold and release boundary for all protected order payments. +Ledger writes must go through the ledger service. + diff --git a/apps/backend/src/domains/money/ledger/ledger.module.ts b/apps/backend/src/domains/money/ledger/ledger.module.ts new file mode 100644 index 00000000..83186f2a --- /dev/null +++ b/apps/backend/src/domains/money/ledger/ledger.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; + +import { LedgerService } from "./ledger.service"; + +@Module({ + providers: [LedgerService], + exports: [LedgerService], +}) +export class LedgerModule {} diff --git a/apps/backend/src/domains/money/ledger/ledger.service.spec.ts b/apps/backend/src/domains/money/ledger/ledger.service.spec.ts new file mode 100644 index 00000000..86864d29 --- /dev/null +++ b/apps/backend/src/domains/money/ledger/ledger.service.spec.ts @@ -0,0 +1,828 @@ +import { + LedgerDirection, + LedgerEntry, + LedgerEntryType, + Prisma, +} from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { LedgerService } from "./ledger.service"; + +describe("LedgerService", () => { + const prisma = {} as PrismaService; + let service: LedgerService; + const makeLedgerEntry = ( + overrides: Partial = {}, + ): LedgerEntry => ({ + id: "ledger-1", + entryType: LedgerEntryType.PAYOUT_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo: 9_800n, + currency: "NGN", + orderId: "order-1", + paymentId: null, + payoutId: "payout-1", + storeId: null, + userId: null, + reference: "trf-1", + settlementLegId: null, + paymentAmountExceptionId: null, + idempotencyKey: + "PAYOUT_COMPLETED:DEBIT:order-1:no-payment:payout-1:trf-1:9800", + metadata: {}, + createdAt: new Date("2026-05-21T00:00:00.000Z"), + ...overrides, + }); + + const makeUniqueConflict = () => + new Prisma.PrismaClientKnownRequestError( + "Unique constraint failed on the fields: (`idempotency_key`)", + { + code: "P2002", + clientVersion: "7.5.0", + meta: { target: ["idempotency_key"] }, + }, + ); + + beforeEach(() => { + service = new LedgerService(prisma); + }); + + it("calculates escrow checkout totals", () => { + const totals = service.calculateCheckoutTotals({ + subtotalKobo: 10_000n, + deliveryFeeKobo: 1_500n, + merchantTier: "UNVERIFIED", + }); + + expect(totals.platformFeePercent).toBe(2); + expect(totals.platformFeeKobo).toBe(200n); + expect(totals.platformFeeBearer).toBe("MERCHANT"); + expect(totals.totalAmountKobo).toBe(11_500n); + }); + + it("charges lower commission for higher verification tiers", () => { + // UNVERIFIED = 2%, TIER_1 = 1.5%, TIER_2 = 1%. The shopper still pays only + // subtotal + delivery; the fee is snapshotted and deducted from proceeds. + const tier1 = service.calculateCheckoutTotals({ + subtotalKobo: 10_000n, + deliveryFeeKobo: 1_500n, + merchantTier: "TIER_1", + }); + expect(tier1.platformFeePercent).toBe(1.5); + expect(tier1.platformFeeKobo).toBe(150n); + expect(tier1.totalAmountKobo).toBe(11_500n); + + const tier2 = service.calculateCheckoutTotals({ + subtotalKobo: 10_000n, + deliveryFeeKobo: 1_500n, + merchantTier: "TIER_2", + }); + expect(tier2.platformFeePercent).toBe(1); + expect(tier2.platformFeeKobo).toBe(100n); + }); + + it("caps commission past the order-value threshold, keeping tier discounts", () => { + // Default cap is ₦500,000 = 50,000,000 kobo. A ₦600,000 subtotal is charged + // as if it were ₦500,000, so the fee freezes but the per-tier rate still + // applies (TIER_1 1.5% of ₦500,000 = ₦7,500 = 750,000 kobo). + const big = service.calculateCheckoutTotals({ + subtotalKobo: 60_000_000n, + deliveryFeeKobo: 1_500n, + merchantTier: "TIER_1", + }); + expect(big.platformFeeKobo).toBe(750_000n); + // Uncapped would be 1.5% of ₦600,000 = ₦9,000 = 900,000 kobo. + expect(big.platformFeeKobo).toBeLessThan(900_000n); + + // A higher tier on the same capped order still pays less — discount intact. + const bigTier2 = service.calculateCheckoutTotals({ + subtotalKobo: 60_000_000n, + merchantTier: "TIER_2", + }); + expect(bigTier2.platformFeeKobo).toBe(500_000n); // 1% of ₦500,000 + expect(bigTier2.platformFeeKobo).toBeLessThan(big.platformFeeKobo); + + // Exactly at the cap is unchanged from the raw rate. + const atCap = service.calculateCheckoutTotals({ + subtotalKobo: 50_000_000n, + merchantTier: "TIER_1", + }); + expect(atCap.platformFeeKobo).toBe(750_000n); + }); + + it("falls back to the base rate for a missing or unknown tier", () => { + const missing = service.calculateCheckoutTotals({ subtotalKobo: 10_000n }); + expect(missing.platformFeePercent).toBe(2); + expect(missing.platformFeeKobo).toBe(200n); + + const unknown = service.calculateCheckoutTotals({ + subtotalKobo: 10_000n, + merchantTier: "TIER_9", + }); + expect(unknown.platformFeePercent).toBe(2); + expect(unknown.platformFeeKobo).toBe(200n); + }); + + it("keeps a full unallocated collection and its reversal out of the order balance", async () => { + const findMany = jest.fn().mockResolvedValue([ + makeLedgerEntry({ + entryType: LedgerEntryType.PAYMENT_RECEIVED, + direction: LedgerDirection.CREDIT, + amountKobo: 10_000n, + paymentAmountExceptionId: null, + }), + makeLedgerEntry({ + id: "exception-credit", + entryType: LedgerEntryType.PAYMENT_AMOUNT_EXCEPTION_RECEIVED, + direction: LedgerDirection.CREDIT, + amountKobo: 10_001n, + paymentAmountExceptionId: "exception-1", + }), + makeLedgerEntry({ + id: "exception-refund", + entryType: LedgerEntryType.REFUND_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo: 10_001n, + paymentAmountExceptionId: "exception-1", + }), + ]); + const ledgerService = new LedgerService({ + ledgerEntry: { findMany }, + } as never); + + await expect(ledgerService.getOrderBalance("order-1")).resolves.toBe( + 10_000n, + ); + }); + + it("calculates payout using saved platform fee with legacy fallback", () => { + expect( + service.calculateOrderPayout({ + id: "order-with-fee", + totalAmountKobo: 10_000n, + platformFeeKobo: 200n, + }), + ).toMatchObject({ + grossAmountKobo: 10_000n, + platformFeeKobo: 200n, + payoutAmountKobo: 9_800n, + usedLegacyFeeFallback: false, + }); + + expect( + service.calculateOrderPayout({ + id: "legacy-order", + totalAmountKobo: 10_000n, + platformFeeKobo: null, + }), + ).toMatchObject({ + grossAmountKobo: 10_000n, + platformFeeKobo: 0n, + payoutAmountKobo: 10_000n, + usedLegacyFeeFallback: true, + }); + }); + + it("excludes delivery fees from direct store payouts", () => { + expect( + service.calculateOrderPayout({ + id: "direct-order", + totalAmountKobo: 13_200n, + deliveryFeeKobo: 3_000n, + platformFeeKobo: 200n, + }), + ).toMatchObject({ + grossAmountKobo: 13_200n, + deliveryFeeKobo: 3_000n, + platformFeeKobo: 200n, + dropshipperCostKobo: 0n, + payoutAmountKobo: 10_000n, + usedLegacyFeeFallback: false, + }); + }); + + it("deducts the fee from new merchant-borne order proceeds", () => { + expect( + service.calculateOrderPayout({ + id: "merchant-fee-order", + totalAmountKobo: 13_000n, + deliveryFeeKobo: 3_000n, + platformFeeKobo: 200n, + }), + ).toMatchObject({ + payoutAmountKobo: 9_800n, + platformFeeKobo: 200n, + }); + }); + + it("deducts the captured processor fee so the merchant bears it", () => { + // gross 13,000 − delivery 3,000 − platformFee 200 − processorFee 195 = 9,605 + expect( + service.calculateOrderPayout({ + id: "processor-fee-order", + totalAmountKobo: 13_000n, + deliveryFeeKobo: 3_000n, + platformFeeKobo: 200n, + processorFeeKobo: 195n, + }), + ).toMatchObject({ + platformFeeKobo: 200n, + processorFeeKobo: 195n, + payoutAmountKobo: 9_605n, + }); + }); + + it("treats a missing processor fee as zero (legacy orders unaffected)", () => { + const result = service.calculateOrderPayout({ + id: "legacy-no-processor-fee", + totalAmountKobo: 13_000n, + deliveryFeeKobo: 3_000n, + platformFeeKobo: 200n, + // processorFeeKobo omitted — captured before the fee was recorded. + }); + expect(result.processorFeeKobo).toBe(0n); + expect(result.payoutAmountKobo).toBe(9_800n); + }); + + it("records the processor fee as a balance-neutral INFO audit entry", async () => { + const create = jest.fn().mockResolvedValue(makeLedgerEntry()); + const svc = new LedgerService({ ledgerEntry: { create } } as never); + + await svc.recordProviderFee("order-1", 195n, "ref-1", { + paymentId: "payment-1", + storeId: "store-1", + }); + + expect(create).toHaveBeenCalledTimes(1); + expect(create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + entryType: LedgerEntryType.PROVIDER_FEE, + direction: LedgerDirection.INFO, + amountKobo: 195n, + orderId: "order-1", + paymentId: "payment-1", + storeId: "store-1", + reference: "ref-1", + idempotencyKey: "provider-fee:order-1:ref-1", + }), + }); + }); + + it("does not let a PROVIDER_FEE entry affect the order escrow balance", async () => { + // PAYMENT_RECEIVED credit + a PROVIDER_FEE INFO entry: the INFO entry is + // ignored, so the order balance still equals the full captured amount. + const findMany = jest.fn().mockResolvedValue([ + makeLedgerEntry({ + entryType: LedgerEntryType.PAYMENT_RECEIVED, + direction: LedgerDirection.CREDIT, + amountKobo: 10_000n, + }), + makeLedgerEntry({ + id: "provider-fee-1", + entryType: LedgerEntryType.PROVIDER_FEE, + direction: LedgerDirection.INFO, + amountKobo: 195n, + }), + ]); + const svc = new LedgerService({ ledgerEntry: { findMany } } as never); + + await expect(svc.getOrderBalance("order-1")).resolves.toBe(10_000n); + }); + + it("excludes delivery fees from dropship customer payouts", () => { + expect( + service.calculateOrderPayout({ + id: "dropship-customer-order", + totalAmountKobo: 18_000n, + deliveryFeeKobo: 3_000n, + platformFeeKobo: 300n, + dropshipperCostKobo: 10_000n, + }), + ).toMatchObject({ + grossAmountKobo: 18_000n, + deliveryFeeKobo: 3_000n, + platformFeeKobo: 300n, + dropshipperCostKobo: 10_000n, + payoutAmountKobo: 4_700n, + usedLegacyFeeFallback: false, + }); + }); + + it("excludes delivery fees from linked dropship fulfillment payouts", () => { + expect( + service.calculateOrderPayout({ + id: "dropship-fulfillment-order", + totalAmountKobo: 13_000n, + deliveryFeeKobo: 3_000n, + platformFeeKobo: 0n, + }), + ).toMatchObject({ + grossAmountKobo: 13_000n, + deliveryFeeKobo: 3_000n, + platformFeeKobo: 0n, + dropshipperCostKobo: 0n, + payoutAmountKobo: 10_000n, + usedLegacyFeeFallback: false, + }); + }); + + it("records entries with create only", async () => { + const create = jest.fn().mockResolvedValue({ id: "custom-entry" }); + const findUnique = jest.fn(); + const serviceWithPrisma = new LedgerService({ + ledgerEntry: { create, findUnique }, + } as unknown as PrismaService); + + await serviceWithPrisma.recordEntry({ + entryType: LedgerEntryType.PAYOUT_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo: 9_800n, + orderId: "order-1", + payoutId: "payout-1", + reference: "trf-1", + }); + + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + idempotencyKey: + "PAYOUT_COMPLETED:DEBIT:order-1:no-payment:payout-1:trf-1:9800", + }), + }), + ); + }); + + it("records a mismatched collection as unallocated, not as payment received", async () => { + const create = jest.fn().mockResolvedValue({ id: "exception-entry-1" }); + const serviceWithPrisma = new LedgerService({ + ledgerEntry: { create, findUnique: jest.fn() }, + } as unknown as PrismaService); + + await serviceWithPrisma.recordPaymentAmountExceptionReceived({ + paymentAmountExceptionId: "amount-exception-1", + paymentId: "payment-1", + orderId: "order-1", + buyerId: "buyer-1", + storeId: "store-1", + amountKobo: 10_001n, + expectedAmountKobo: 10_000n, + exceptionType: "OVERPAID", + provider: "MONNIFY", + reference: "twz-reference-1", + }); + + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + entryType: LedgerEntryType.PAYMENT_AMOUNT_EXCEPTION_RECEIVED, + direction: LedgerDirection.CREDIT, + amountKobo: 10_001n, + paymentAmountExceptionId: "amount-exception-1", + idempotencyKey: + "payment-amount-exception-received:amount-exception-1", + metadata: expect.objectContaining({ + expectedAmountKobo: "10000", + receivedAmountKobo: "10001", + exceptionType: "OVERPAID", + provider: "MONNIFY", + }), + }), + }), + ); + }); + + it("returns the same unallocated collection entry on a duplicate delivery", async () => { + const existing = makeLedgerEntry({ + entryType: LedgerEntryType.PAYMENT_AMOUNT_EXCEPTION_RECEIVED, + direction: LedgerDirection.CREDIT, + amountKobo: 9_999n, + paymentId: "payment-1", + payoutId: null, + storeId: "store-1", + userId: "buyer-1", + reference: "twz-reference-1", + settlementLegId: null, + paymentAmountExceptionId: "amount-exception-1", + idempotencyKey: "payment-amount-exception-received:amount-exception-1", + }); + const create = jest.fn().mockRejectedValue(makeUniqueConflict()); + const findUnique = jest.fn().mockResolvedValue(existing); + const serviceWithPrisma = new LedgerService({ + ledgerEntry: { create, findUnique }, + } as unknown as PrismaService); + + await expect( + serviceWithPrisma.recordPaymentAmountExceptionReceived({ + paymentAmountExceptionId: "amount-exception-1", + paymentId: "payment-1", + orderId: "order-1", + buyerId: "buyer-1", + storeId: "store-1", + amountKobo: 9_999n, + expectedAmountKobo: 10_000n, + exceptionType: "UNDERPAID", + provider: "PAYSTACK", + reference: "twz-reference-1", + }), + ).resolves.toBe(existing); + }); + + it("attributes escrow releases and refunds to the merchant store", async () => { + const create = jest.fn().mockResolvedValue({ id: "ledger-entry" }); + const serviceWithPrisma = new LedgerService({ + ledgerEntry: { create, findUnique: jest.fn() }, + } as unknown as PrismaService); + + await serviceWithPrisma.recordEscrowRelease("order-1", 10_000n, { + storeId: "store-1", + }); + await serviceWithPrisma.recordRefund("order-1", 2_000n, "refund-1", { + storeId: "store-1", + }); + + expect(create).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + data: expect.objectContaining({ storeId: "store-1" }), + }), + ); + expect(create).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + data: expect.objectContaining({ storeId: "store-1" }), + }), + ); + }); + + it("returns an existing matching entry on duplicate idempotency key", async () => { + const existing = makeLedgerEntry(); + const create = jest.fn().mockRejectedValue(makeUniqueConflict()); + const findUnique = jest.fn().mockResolvedValue(existing); + const update = jest.fn(); + const deleteEntry = jest.fn(); + const upsert = jest.fn(); + const serviceWithPrisma = new LedgerService({ + ledgerEntry: { create, findUnique, update, delete: deleteEntry, upsert }, + } as unknown as PrismaService); + + await expect( + serviceWithPrisma.recordEntry({ + entryType: LedgerEntryType.PAYOUT_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo: 9_800n, + orderId: "order-1", + payoutId: "payout-1", + reference: "trf-1", + }), + ).resolves.toBe(existing); + + expect(findUnique).toHaveBeenCalledWith({ + where: { + idempotencyKey: + "PAYOUT_COMPLETED:DEBIT:order-1:no-payment:payout-1:trf-1:9800", + }, + }); + expect(update).not.toHaveBeenCalled(); + expect(deleteEntry).not.toHaveBeenCalled(); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("rejects duplicate idempotency keys that point to a different entry", async () => { + const existing = makeLedgerEntry({ + amountKobo: 10_000n, + idempotencyKey: "manual-key", + }); + const create = jest.fn().mockRejectedValue(makeUniqueConflict()); + const findUnique = jest.fn().mockResolvedValue(existing); + const serviceWithPrisma = new LedgerService({ + ledgerEntry: { create, findUnique }, + } as unknown as PrismaService); + + await expect( + serviceWithPrisma.recordEntry({ + entryType: LedgerEntryType.PAYOUT_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo: 9_800n, + orderId: "order-1", + payoutId: "payout-1", + reference: "trf-1", + idempotencyKey: "manual-key", + }), + ).rejects.toMatchObject({ + response: { + code: "LEDGER_IDEMPOTENCY_CONFLICT", + }, + }); + }); + + it("rejects an idempotency collision that belongs to another settlement leg", async () => { + const existing = makeLedgerEntry({ + payoutId: null, + settlementLegId: "settlement-leg-a", + idempotencyKey: "settlement-collision", + }); + const create = jest.fn().mockRejectedValue(makeUniqueConflict()); + const findUnique = jest.fn().mockResolvedValue(existing); + const serviceWithPrisma = new LedgerService({ + ledgerEntry: { create, findUnique }, + } as unknown as PrismaService); + + await expect( + serviceWithPrisma.recordEntry({ + entryType: LedgerEntryType.PAYOUT_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo: 9_800n, + orderId: "order-1", + reference: "trf-1", + settlementLegId: "settlement-leg-b", + idempotencyKey: "settlement-collision", + }), + ).rejects.toMatchObject({ + response: { code: "LEDGER_IDEMPOTENCY_CONFLICT" }, + }); + }); + + it("calculates order balance with BigInt arithmetic", async () => { + const findMany = jest.fn().mockResolvedValue([ + { + entryType: LedgerEntryType.PAYMENT_RECEIVED, + direction: LedgerDirection.CREDIT, + amountKobo: 10_000n, + }, + { + entryType: LedgerEntryType.ESCROW_HELD, + direction: LedgerDirection.RELEASE, + amountKobo: 4_000n, + }, + { + entryType: LedgerEntryType.PAYOUT_FAILED, + direction: LedgerDirection.INFO, + amountKobo: 4_000n, + }, + ]); + const serviceWithPrisma = new LedgerService({ + ledgerEntry: { findMany }, + } as unknown as PrismaService); + + await expect(serviceWithPrisma.getOrderBalance("order-1")).resolves.toBe( + 6_000n, + ); + }); + + it("excludes unallocated amount-exception collections from the order balance", async () => { + const findMany = jest.fn().mockResolvedValue([ + { + entryType: LedgerEntryType.PAYMENT_AMOUNT_EXCEPTION_RECEIVED, + direction: LedgerDirection.CREDIT, + amountKobo: 9_999n, + settlementLegId: null, + }, + { + entryType: LedgerEntryType.PAYMENT_RECEIVED, + direction: LedgerDirection.CREDIT, + amountKobo: 10_000n, + settlementLegId: null, + }, + ]); + const serviceWithPrisma = new LedgerService({ + ledgerEntry: { findMany }, + } as unknown as PrismaService); + + await expect(serviceWithPrisma.getOrderBalance("order-1")).resolves.toBe( + 10_000n, + ); + }); + + it("does not double-debit an escrow release and its later payout", async () => { + const findMany = jest.fn().mockResolvedValue([ + { + entryType: LedgerEntryType.PAYMENT_RECEIVED, + direction: LedgerDirection.CREDIT, + amountKobo: 10_003n, + }, + { + entryType: LedgerEntryType.ESCROW_HELD, + direction: LedgerDirection.RELEASE, + amountKobo: 10_003n, + }, + { + entryType: LedgerEntryType.PAYOUT_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo: 9_001n, + }, + ]); + const serviceWithPrisma = new LedgerService({ + ledgerEntry: { findMany }, + } as unknown as PrismaService); + + await expect(serviceWithPrisma.getOrderBalance("order-1")).resolves.toBe( + 0n, + ); + }); + + it("counts a settlement payout even when the order also has a normal escrow release", async () => { + const findMany = jest.fn().mockResolvedValue([ + { + entryType: LedgerEntryType.PAYMENT_RECEIVED, + direction: LedgerDirection.CREDIT, + amountKobo: 10_000n, + settlementLegId: null, + }, + { + entryType: LedgerEntryType.ESCROW_HELD, + direction: LedgerDirection.RELEASE, + amountKobo: 5_000n, + settlementLegId: null, + }, + { + entryType: LedgerEntryType.PAYOUT_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo: 5_000n, + settlementLegId: null, + }, + { + entryType: LedgerEntryType.PAYOUT_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo: 1_000n, + settlementLegId: "settlement-leg-1", + }, + { + entryType: LedgerEntryType.REFUND_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo: 4_000n, + settlementLegId: "settlement-leg-2", + }, + ]); + const serviceWithPrisma = new LedgerService({ + ledgerEntry: { findMany }, + } as unknown as PrismaService); + + await expect(serviceWithPrisma.getOrderBalance("order-1")).resolves.toBe( + 0n, + ); + }); + + it("stores the payment received entry under the merchant store for balance queries", async () => { + const create = jest.fn().mockResolvedValue({ id: "payment-entry" }); + const serviceWithPrisma = new LedgerService({ + ledgerEntry: { create, findUnique: jest.fn() }, + } as unknown as PrismaService); + + await serviceWithPrisma.recordPaymentReceived({ + paymentId: "payment-1", + orderId: "order-1", + buyerId: "buyer-1", + storeId: "store-1", + amountKobo: 10_000n, + platformFeeKobo: 200n, + reference: "payment-reference", + }); + + expect(create.mock.calls[0][0].data).toEqual( + expect.objectContaining({ + entryType: LedgerEntryType.PAYMENT_RECEIVED, + storeId: "store-1", + }), + ); + }); + + it("reclassifies an underpayment with one deterministic allocation debit", async () => { + const create = jest.fn().mockResolvedValue({ id: "allocation-entry" }); + const serviceWithPrisma = new LedgerService({ + ledgerEntry: { create, findUnique: jest.fn() }, + } as unknown as PrismaService); + + await serviceWithPrisma.recordPaymentAmountExceptionAllocated({ + paymentAmountExceptionId: "exception-1", + paymentId: "payment-1", + orderId: "order-1", + buyerId: "buyer-1", + storeId: "store-1", + amountKobo: 4_321n, + reference: "provider-reference", + }); + + expect(create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + entryType: LedgerEntryType.PAYMENT_AMOUNT_EXCEPTION_ALLOCATED, + direction: LedgerDirection.DEBIT, + amountKobo: 4_321n, + paymentAmountExceptionId: "exception-1", + idempotencyKey: "payment-amount-exception-allocated:exception-1", + }), + }); + }); + + it("counts each payout once when calculating store available balance", async () => { + const findMany = jest.fn().mockResolvedValue([ + { + entryType: LedgerEntryType.ESCROW_HELD, + direction: LedgerDirection.RELEASE, + amountKobo: 20_000n, + payoutId: null, + orderId: "order-1", + settlementLegId: null, + order: { + id: "order-1", + totalAmountKobo: 20_000n, + deliveryFeeKobo: 0n, + platformFeeKobo: 1_000n, + dropshipperCostKobo: 0n, + }, + }, + { + entryType: LedgerEntryType.PAYOUT_INITIATED, + direction: LedgerDirection.DEBIT, + amountKobo: 9_000n, + payoutId: "payout-1", + }, + { + entryType: LedgerEntryType.PAYOUT_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo: 9_000n, + payoutId: "payout-1", + }, + { + entryType: LedgerEntryType.PAYOUT_FAILED, + direction: LedgerDirection.INFO, + amountKobo: 3_000n, + payoutId: "payout-2", + }, + { + entryType: LedgerEntryType.REFUND_INITIATED, + direction: LedgerDirection.DEBIT, + amountKobo: 1_000n, + payoutId: null, + }, + ]); + const serviceWithPrisma = new LedgerService({ + ledgerEntry: { findMany }, + } as unknown as PrismaService); + + await expect( + serviceWithPrisma.getMerchantAvailableBalance("store-1", 500n), + ).resolves.toBe(8_500n); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }), + ); + }); + + it("does not make captured escrow funds withdrawable before release", async () => { + const findMany = jest.fn().mockResolvedValue([ + { + entryType: LedgerEntryType.PAYMENT_RECEIVED, + direction: LedgerDirection.CREDIT, + amountKobo: 20_000n, + payoutId: null, + orderId: "order-1", + settlementLegId: null, + order: { + id: "order-1", + totalAmountKobo: 20_000n, + deliveryFeeKobo: 0n, + platformFeeKobo: 1_000n, + dropshipperCostKobo: 0n, + }, + }, + ]); + const serviceWithPrisma = new LedgerService({ + ledgerEntry: { findMany }, + } as unknown as PrismaService); + + await expect( + serviceWithPrisma.getMerchantAvailableBalance("store-1"), + ).resolves.toBe(0n); + }); + + it("does not debit merchant earnings for an unallocated payment-exception refund", async () => { + const findMany = jest.fn().mockResolvedValue([ + { + entryType: LedgerEntryType.REFUND_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo: 10_001n, + payoutId: null, + orderId: "order-1", + settlementLegId: null, + paymentAmountExceptionId: "exception-1", + order: { + id: "order-1", + totalAmountKobo: 10_000n, + deliveryFeeKobo: 0n, + platformFeeKobo: 200n, + dropshipperCostKobo: 0n, + }, + }, + ]); + const serviceWithPrisma = new LedgerService({ + ledgerEntry: { findMany }, + } as unknown as PrismaService); + + await expect( + serviceWithPrisma.getMerchantAvailableBalance("store-1"), + ).resolves.toBe(0n); + }); +}); diff --git a/apps/backend/src/domains/money/ledger/ledger.service.ts b/apps/backend/src/domains/money/ledger/ledger.service.ts new file mode 100644 index 00000000..f823b538 --- /dev/null +++ b/apps/backend/src/domains/money/ledger/ledger.service.ts @@ -0,0 +1,967 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, +} from "@nestjs/common"; +import { + LedgerDirection, + LedgerEntry, + LedgerEntryType, + PlatformFeeBearer, + Prisma, +} from "@prisma/client"; + +import { PlatformConfig } from "../../../config/platform.config"; +import { PrismaService } from "../../../core/prisma/prisma.service"; + +type LedgerClient = PrismaService | Prisma.TransactionClient; + +export interface CheckoutTotalsInput { + subtotalKobo: bigint; + deliveryFeeKobo?: bigint; + merchantTier?: string | null; +} + +export interface CheckoutTotals { + subtotalKobo: bigint; + deliveryFeeKobo: bigint; + platformFeeKobo: bigint; + platformFeePercent: number; + platformFeeBearer: PlatformFeeBearer; + totalAmountKobo: bigint; +} + +export interface OrderPayoutInput { + id?: string; + totalAmountKobo: bigint | number | string; + deliveryFeeKobo?: bigint | number | string | null; + platformFeeKobo?: bigint | number | string | null; + // Payment-processor fee captured at confirmation. Deducted from the merchant + // payout so the merchant bears processing cost. Null/absent on legacy orders + // captured before this was recorded — treated as 0. + processorFeeKobo?: bigint | number | string | null; + // Set on DROPSHIP_CUSTOMER orders so the digital store's payout is the + // shopper-facing total minus the wholesale leg AND the platform fee. + // Unset/null on DIRECT and DROPSHIP_FULFILLMENT orders. + dropshipperCostKobo?: bigint | number | string | null; +} + +export interface OrderPayoutBreakdown { + grossAmountKobo: bigint; + deliveryFeeKobo: bigint; + platformFeeKobo: bigint; + processorFeeKobo: bigint; + dropshipperCostKobo: bigint; + payoutAmountKobo: bigint; + usedLegacyFeeFallback: boolean; +} + +export interface AvailableBalanceInput { + grossOrdersKobo: bigint; + platformFeesKobo: bigint; + completedOrProcessingPayoutsKobo: bigint; + pendingPayoutRequestsKobo: bigint; +} + +export interface LedgerWriteOptions { + tx?: Prisma.TransactionClient; + paymentId?: string | null; + payoutId?: string | null; + storeId?: string | null; + userId?: string | null; + paymentAmountExceptionId?: string | null; + idempotencyKey?: string; + metadata?: Prisma.InputJsonValue; +} + +export interface RecordLedgerEntryInput { + entryType: LedgerEntryType; + direction: LedgerDirection; + amountKobo: bigint; + currency?: string; + orderId?: string | null; + paymentId?: string | null; + payoutId?: string | null; + storeId?: string | null; + userId?: string | null; + reference?: string | null; + // Correlates a settlement refund/payout completion entry to its leg so + // reconciliation can prove a leg is settled without re-parsing references. + settlementLegId?: string | null; + // Collected funds with a mismatched amount remain unallocated. This links + // their audit entry to the immutable payment exception, not to escrow. + paymentAmountExceptionId?: string | null; + idempotencyKey?: string; + metadata?: Prisma.InputJsonValue; +} + +interface RecordPayoutFailedInput { + payoutId?: string | null; + orderId: string; + storeId: string; + amountKobo: bigint; + reason?: string | null; + tx?: Prisma.TransactionClient; + idempotencyKey?: string; + metadata?: Prisma.InputJsonValue; +} + +@Injectable() +export class LedgerService { + private readonly logger = new Logger(LedgerService.name); + + constructor(private readonly prisma: PrismaService) {} + + async recordPaymentIn( + orderId: string, + amountKobo: bigint, + reference: string, + options: LedgerWriteOptions = {}, + ): Promise { + this.assertPositiveAmount(amountKobo); + + return this.recordEntry( + { + entryType: LedgerEntryType.PAYMENT_RECEIVED, + direction: LedgerDirection.CREDIT, + amountKobo, + orderId, + paymentId: options.paymentId, + storeId: options.storeId, + userId: options.userId, + paymentAmountExceptionId: options.paymentAmountExceptionId, + reference, + idempotencyKey: + options.idempotencyKey || `payment-in:${orderId}:${reference}`, + metadata: options.metadata, + }, + options.tx, + ); + } + + // Audit-only record of the payment-processor fee the merchant bore on a + // captured charge. Written with direction INFO so it is balance-neutral — + // getOrderBalance and getMerchantAvailableBalance both ignore entry types + // outside their credit/debit sets, and the fee is already netted out of the + // payout amount. This entry exists purely so processing cost is queryable. + async recordProviderFee( + orderId: string, + amountKobo: bigint, + reference: string, + options: LedgerWriteOptions = {}, + ): Promise { + this.assertPositiveAmount(amountKobo); + + return this.recordEntry( + { + entryType: LedgerEntryType.PROVIDER_FEE, + direction: LedgerDirection.INFO, + amountKobo, + orderId, + paymentId: options.paymentId, + storeId: options.storeId, + userId: options.userId, + reference, + idempotencyKey: + options.idempotencyKey || `provider-fee:${orderId}:${reference}`, + metadata: options.metadata, + }, + options.tx, + ); + } + + async recordEscrowRelease( + orderId: string, + amountKobo: bigint, + options: LedgerWriteOptions = {}, + ): Promise { + this.assertPositiveAmount(amountKobo); + + return this.recordEntry( + { + entryType: LedgerEntryType.ESCROW_HELD, + direction: LedgerDirection.RELEASE, + amountKobo, + orderId, + paymentId: options.paymentId, + payoutId: options.payoutId, + storeId: options.storeId, + userId: options.userId, + idempotencyKey: + options.idempotencyKey || + `escrow-release:${orderId}:${amountKobo.toString()}`, + metadata: options.metadata, + }, + options.tx, + ); + } + + async recordPayout( + orderId: string, + storeId: string, + amountKobo: bigint, + reference: string, + options: LedgerWriteOptions = {}, + ): Promise { + this.assertPositiveAmount(amountKobo); + + return this.recordEntry( + { + entryType: LedgerEntryType.PAYOUT_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo, + orderId, + storeId, + payoutId: options.payoutId, + reference, + idempotencyKey: + options.idempotencyKey || `payout:${orderId}:${storeId}:${reference}`, + metadata: options.metadata, + }, + options.tx, + ); + } + + async recordPayoutFailed( + orderId: string, + storeId: string, + amountKobo: bigint, + reason: string, + options?: LedgerWriteOptions, + ): Promise; + async recordPayoutFailed( + input: RecordPayoutFailedInput, + tx?: Prisma.TransactionClient, + ): Promise; + async recordPayoutFailed( + orderOrInput: string | RecordPayoutFailedInput, + storeIdOrTx?: string | Prisma.TransactionClient, + amountKobo?: bigint, + reason?: string, + options: LedgerWriteOptions = {}, + ): Promise { + const input: RecordPayoutFailedInput = + typeof orderOrInput === "string" + ? this.normalizePayoutFailedPositionalInput( + orderOrInput, + storeIdOrTx, + amountKobo, + reason, + options, + ) + : this.normalizePayoutFailedObjectInput(orderOrInput, storeIdOrTx); + + this.assertNonEmptyString(input.orderId, "orderId"); + this.assertNonEmptyString(input.storeId, "storeId"); + this.assertNonEmptyString(input.reason || "", "reason"); + this.assertPositiveAmount(input.amountKobo); + + return this.recordEntry( + { + entryType: LedgerEntryType.PAYOUT_FAILED, + direction: LedgerDirection.INFO, + amountKobo: input.amountKobo, + orderId: input.orderId, + payoutId: input.payoutId, + storeId: input.storeId, + idempotencyKey: + input.idempotencyKey || + `payout-failed:${input.payoutId || input.orderId}:${input.storeId}`, + metadata: + input.metadata || ({ reason: input.reason || "unknown" } as const), + }, + input.tx, + ); + } + + async recordRefund( + orderId: string, + amountKobo: bigint, + reference: string, + options: LedgerWriteOptions = {}, + ): Promise { + this.assertPositiveAmount(amountKobo); + + return this.recordEntry( + { + entryType: LedgerEntryType.REFUND_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo, + orderId, + paymentId: options.paymentId, + storeId: options.storeId, + userId: options.userId, + paymentAmountExceptionId: options.paymentAmountExceptionId, + reference, + idempotencyKey: + options.idempotencyKey || `refund:${orderId}:${reference}`, + metadata: options.metadata, + }, + options.tx, + ); + } + + async getOrderBalance( + orderId: string, + tx?: Prisma.TransactionClient, + ): Promise { + const client = this.getClient(tx); + const entries = await client.ledgerEntry.findMany({ + where: { orderId }, + select: { + entryType: true, + direction: true, + amountKobo: true, + settlementLegId: true, + paymentAmountExceptionId: true, + }, + }); + + // ESCROW_HELD/RELEASE represents the release of the captured order funds + // from platform escrow. A subsequent PAYOUT_COMPLETED records the external + // transfer of those same released funds, not a second debit of the order's + // captured balance. Settlement payouts do not have an escrow release, so + // they remain the debit for that path. + const hasEscrowRelease = entries.some( + (entry) => + entry.entryType === LedgerEntryType.ESCROW_HELD && + entry.direction === LedgerDirection.RELEASE, + ); + + return entries.reduce((balance, entry) => { + const amountKobo = BigInt(entry.amountKobo); + if (entry.entryType === LedgerEntryType.PAYMENT_RECEIVED) { + return balance + amountKobo; + } + + if ( + (entry.entryType === LedgerEntryType.REFUND_COMPLETED && + !entry.paymentAmountExceptionId) || + (entry.entryType === LedgerEntryType.ESCROW_HELD && + entry.direction === LedgerDirection.RELEASE) || + (entry.entryType === LedgerEntryType.PAYOUT_COMPLETED && + // A normal payout completes the external movement represented by an + // escrow release. A settlement-leg payout is a distinct financial + // movement and must remain a debit even if the order also had an + // earlier normal escrow release. + !(hasEscrowRelease && !entry.settlementLegId)) + ) { + return balance - amountKobo; + } + + return balance; + }, 0n); + } + + calculateCheckoutTotals(input: CheckoutTotalsInput): CheckoutTotals { + const merchantTier = input.merchantTier || "UNVERIFIED"; + const platformFeePercent = + PlatformConfig.getPlatformFeePercent(merchantTier); + const platformFeeKobo = PlatformConfig.calculateFeeKobo( + input.subtotalKobo, + merchantTier, + ); + const deliveryFeeKobo = input.deliveryFeeKobo ?? 0n; + + return { + subtotalKobo: input.subtotalKobo, + deliveryFeeKobo, + platformFeeKobo, + platformFeePercent, + platformFeeBearer: PlatformFeeBearer.MERCHANT, + // The shopper pays only product subtotal plus delivery. The platform + // fee is still snapshotted on the order and deducted from merchant + // proceeds by calculateOrderPayout. + totalAmountKobo: input.subtotalKobo + deliveryFeeKobo, + }; + } + + calculateOrderPayout(order: OrderPayoutInput): OrderPayoutBreakdown { + const grossAmountKobo = BigInt(order.totalAmountKobo); + const deliveryFeeKobo = + order.deliveryFeeKobo === null || order.deliveryFeeKobo === undefined + ? 0n + : BigInt(order.deliveryFeeKobo as bigint | number | string); + const usedLegacyFeeFallback = + order.platformFeeKobo === null || order.platformFeeKobo === undefined; + const platformFeeKobo = usedLegacyFeeFallback + ? 0n + : BigInt(order.platformFeeKobo as bigint | number | string); + // DROPSHIP_CUSTOMER orders carry a wholesale leg that is paid out + // separately to the physical store (via the linked DROPSHIP_FULFILLMENT + // order). Subtract it here so the digital store receives the margin + // minus the platform fee. Direct orders pass dropshipperCostKobo = 0. + const dropshipperCostKobo = + order.dropshipperCostKobo === null || + order.dropshipperCostKobo === undefined + ? 0n + : BigInt(order.dropshipperCostKobo as bigint | number | string); + // Legacy orders captured before processor fees were recorded have null here + // and simply contribute 0 — the platform absorbed that fee historically. + const processorFeeKobo = + order.processorFeeKobo === null || order.processorFeeKobo === undefined + ? 0n + : BigInt(order.processorFeeKobo as bigint | number | string); + const payoutAmountKobo = + grossAmountKobo - + deliveryFeeKobo - + platformFeeKobo - + processorFeeKobo - + dropshipperCostKobo; + + if (usedLegacyFeeFallback) { + this.logger.warn( + `Missing platformFeeKobo on order ${order.id || "unknown"}. Defaulting to 0 for legacy order payout.`, + ); + } + + return { + grossAmountKobo, + deliveryFeeKobo, + platformFeeKobo, + processorFeeKobo, + dropshipperCostKobo, + payoutAmountKobo, + usedLegacyFeeFallback, + }; + } + + calculateAvailableBalance(input: AvailableBalanceInput): bigint { + return ( + input.grossOrdersKobo - + input.platformFeesKobo - + input.completedOrProcessingPayoutsKobo - + input.pendingPayoutRequestsKobo + ); + } + + async getMerchantAvailableBalance( + storeId: string, + pendingPayoutRequestsKobo: bigint = 0n, + tx?: Prisma.TransactionClient, + ): Promise { + const client = this.getClient(tx); + const entries = await client.ledgerEntry.findMany({ + // Historical escrow-release rows may predate direct store attribution, + // so retain the order relation as a safe compatibility scope. + where: { OR: [{ storeId }, { order: { storeId } }] }, + // Keep the legacy payout fallback deterministic. Historical ledger rows + // may not have payoutId populated, so their fallback identity depends on + // a stable position in this append-only timeline. The primary-key + // tiebreaker makes rows created at the same timestamp deterministic. + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { + entryType: true, + direction: true, + amountKobo: true, + payoutId: true, + orderId: true, + settlementLegId: true, + paymentAmountExceptionId: true, + order: { + select: { + id: true, + totalAmountKobo: true, + deliveryFeeKobo: true, + platformFeeKobo: true, + processorFeeKobo: true, + dropshipperCostKobo: true, + }, + }, + }, + }); + + let balance = 0n; + const releasedOrderIds = new Set(); + const payoutStates = new Map< + string, + { + amountKobo: bigint; + initiated: boolean; + completed: boolean; + failed: boolean; + } + >(); + + entries.forEach((entry, index) => { + const amountKobo = BigInt(entry.amountKobo); + + // Captured shopper funds are held in escrow and are not merchant + // earnings. Credit the exact approved order payout only after the + // authoritative escrow RELEASE entry exists. This also excludes delivery + // fees, service fees and dropshipper cost from withdrawable earnings. + if ( + entry.entryType === LedgerEntryType.ESCROW_HELD && + entry.direction === LedgerDirection.RELEASE && + entry.orderId && + entry.order && + !releasedOrderIds.has(entry.orderId) + ) { + releasedOrderIds.add(entry.orderId); + balance += this.calculateOrderPayout(entry.order).payoutAmountKobo; + return; + } + + if ( + entry.entryType === LedgerEntryType.REFUND_INITIATED || + (entry.entryType === LedgerEntryType.REFUND_COMPLETED && + !entry.paymentAmountExceptionId) + ) { + balance -= amountKobo; + return; + } + + if ( + !entry.settlementLegId && + (entry.entryType === LedgerEntryType.PAYOUT_INITIATED || + entry.entryType === LedgerEntryType.PAYOUT_COMPLETED || + entry.entryType === LedgerEntryType.PAYOUT_FAILED) + ) { + const key = entry.payoutId ?? `legacy-payout-${index}`; + const existing = payoutStates.get(key) ?? { + amountKobo, + initiated: false, + completed: false, + failed: false, + }; + + existing.amountKobo = amountKobo; + existing.initiated ||= + entry.entryType === LedgerEntryType.PAYOUT_INITIATED; + existing.completed ||= + entry.entryType === LedgerEntryType.PAYOUT_COMPLETED; + existing.failed ||= entry.entryType === LedgerEntryType.PAYOUT_FAILED; + payoutStates.set(key, existing); + } + }); + + payoutStates.forEach((state) => { + if (state.completed || (state.initiated && !state.failed)) { + balance -= state.amountKobo; + } + }); + + return balance - pendingPayoutRequestsKobo; + } + + async recordEntry( + input: RecordLedgerEntryInput, + tx?: Prisma.TransactionClient, + ): Promise { + if (input.amountKobo < 0n) { + throw new BadRequestException({ + message: "Ledger amount must not be negative", + code: "INVALID_LEDGER_AMOUNT", + }); + } + + const idempotencyKey = + input.idempotencyKey || this.buildLedgerIdempotencyKey(input); + const client = this.getClient(tx); + + try { + return await client.ledgerEntry.create({ + data: { + entryType: input.entryType, + direction: input.direction, + amountKobo: input.amountKobo, + currency: input.currency || "NGN", + orderId: input.orderId || null, + paymentId: input.paymentId || null, + payoutId: input.payoutId || null, + storeId: input.storeId || null, + userId: input.userId || null, + reference: input.reference || null, + settlementLegId: input.settlementLegId || null, + paymentAmountExceptionId: input.paymentAmountExceptionId || null, + idempotencyKey, + metadata: input.metadata || {}, + }, + }); + } catch (error) { + if (this.isUniqueConflict(error)) { + const existing = await client.ledgerEntry.findUnique({ + where: { idempotencyKey }, + }); + if (existing) { + if (!this.matchesExistingEntry(existing, input, idempotencyKey)) { + throw new ConflictException({ + message: + "Ledger idempotency key already exists for another entry", + code: "LEDGER_IDEMPOTENCY_CONFLICT", + }); + } + + return existing; + } + } + + throw error; + } + } + + async recordCheckoutCreated( + input: { + orderId: string; + buyerId: string; + storeId?: string | null; + totals: CheckoutTotals; + idempotencyKey: string; + }, + tx?: Prisma.TransactionClient, + ): Promise { + return this.recordEntry( + { + entryType: LedgerEntryType.CHECKOUT_CREATED, + direction: LedgerDirection.INFO, + amountKobo: input.totals.totalAmountKobo, + orderId: input.orderId, + storeId: input.storeId, + userId: input.buyerId, + reference: input.idempotencyKey, + idempotencyKey: `checkout:${input.orderId}`, + metadata: { + subtotalKobo: input.totals.subtotalKobo.toString(), + deliveryFeeKobo: input.totals.deliveryFeeKobo.toString(), + platformFeeKobo: input.totals.platformFeeKobo.toString(), + platformFeePercent: input.totals.platformFeePercent, + platformFeeBearer: input.totals.platformFeeBearer, + }, + }, + tx, + ); + } + + async recordPaymentInitialized( + input: { + paymentId: string; + orderId: string; + buyerId: string; + storeId?: string | null; + amountKobo: bigint; + reference: string; + }, + tx?: Prisma.TransactionClient, + ): Promise { + return this.recordEntry( + { + entryType: LedgerEntryType.PAYMENT_INITIALIZED, + direction: LedgerDirection.INFO, + amountKobo: input.amountKobo, + orderId: input.orderId, + paymentId: input.paymentId, + storeId: input.storeId, + userId: input.buyerId, + reference: input.reference, + idempotencyKey: `payment-initialized:${input.paymentId}`, + }, + tx, + ); + } + + async recordPaymentReceived( + input: { + paymentId: string; + orderId: string; + buyerId: string; + storeId?: string | null; + amountKobo: bigint; + platformFeeKobo?: bigint | null; + reference: string; + }, + tx?: Prisma.TransactionClient, + ): Promise { + const platformFeeKobo = input.platformFeeKobo ?? 0n; + const entries = [ + await this.recordPaymentIn( + input.orderId, + input.amountKobo, + input.reference, + { + tx, + paymentId: input.paymentId, + storeId: input.storeId, + userId: input.buyerId, + idempotencyKey: `payment-received:${input.paymentId}`, + }, + ), + ]; + + entries.push( + await this.recordEntry( + { + entryType: LedgerEntryType.PLATFORM_FEE_ASSESSED, + direction: LedgerDirection.DEBIT, + amountKobo: platformFeeKobo, + orderId: input.orderId, + paymentId: input.paymentId, + storeId: input.storeId, + userId: input.buyerId, + reference: input.reference, + idempotencyKey: `platform-fee-assessed:${input.paymentId}`, + }, + tx, + ), + ); + + entries.push( + await this.recordEntry( + { + entryType: LedgerEntryType.ESCROW_HELD, + direction: LedgerDirection.HOLD, + amountKobo: input.amountKobo - platformFeeKobo, + orderId: input.orderId, + paymentId: input.paymentId, + storeId: input.storeId, + userId: input.buyerId, + reference: input.reference, + idempotencyKey: `escrow-held:${input.paymentId}`, + }, + tx, + ), + ); + + return entries; + } + + async recordPaymentAmountExceptionReceived( + input: { + paymentAmountExceptionId: string; + paymentId: string; + orderId: string; + buyerId: string; + storeId?: string | null; + amountKobo: bigint; + expectedAmountKobo: bigint; + exceptionType: "UNDERPAID" | "OVERPAID"; + provider: string; + reference: string; + }, + tx?: Prisma.TransactionClient, + ): Promise { + this.assertPositiveAmount(input.amountKobo); + this.assertPositiveAmount(input.expectedAmountKobo); + + return this.recordEntry( + { + entryType: LedgerEntryType.PAYMENT_AMOUNT_EXCEPTION_RECEIVED, + direction: LedgerDirection.CREDIT, + amountKobo: input.amountKobo, + orderId: input.orderId, + paymentId: input.paymentId, + storeId: input.storeId, + userId: input.buyerId, + reference: input.reference, + paymentAmountExceptionId: input.paymentAmountExceptionId, + idempotencyKey: `payment-amount-exception-received:${input.paymentAmountExceptionId}`, + metadata: { + expectedAmountKobo: input.expectedAmountKobo.toString(), + receivedAmountKobo: input.amountKobo.toString(), + exceptionType: input.exceptionType, + provider: input.provider, + }, + }, + tx, + ); + } + + async recordPaymentAmountExceptionAllocated( + input: { + paymentAmountExceptionId: string; + paymentId: string; + orderId: string; + buyerId: string; + storeId?: string | null; + amountKobo: bigint; + reference: string; + }, + tx?: Prisma.TransactionClient, + ): Promise { + this.assertPositiveAmount(input.amountKobo); + + return this.recordEntry( + { + entryType: LedgerEntryType.PAYMENT_AMOUNT_EXCEPTION_ALLOCATED, + direction: LedgerDirection.DEBIT, + amountKobo: input.amountKobo, + orderId: input.orderId, + paymentId: input.paymentId, + storeId: input.storeId, + userId: input.buyerId, + reference: input.reference, + paymentAmountExceptionId: input.paymentAmountExceptionId, + idempotencyKey: `payment-amount-exception-allocated:${input.paymentAmountExceptionId}`, + metadata: { allocation: "ORDER_PAYMENT" }, + }, + tx, + ); + } + + async recordPayoutInitiated( + input: { + payoutId: string; + orderId: string; + storeId: string; + amountKobo: bigint; + platformFeeKobo: bigint; + }, + tx?: Prisma.TransactionClient, + ): Promise { + return this.recordEntry( + { + entryType: LedgerEntryType.PAYOUT_INITIATED, + direction: LedgerDirection.DEBIT, + amountKobo: input.amountKobo, + orderId: input.orderId, + payoutId: input.payoutId, + storeId: input.storeId, + idempotencyKey: `payout-initiated:${input.payoutId}`, + metadata: { + platformFeeKobo: input.platformFeeKobo.toString(), + }, + }, + tx, + ); + } + + async recordPayoutCompleted( + input: { + payoutId: string; + orderId: string; + storeId: string; + amountKobo: bigint; + reference?: string | null; + }, + tx?: Prisma.TransactionClient, + ): Promise { + return this.recordPayout( + input.orderId, + input.storeId, + input.amountKobo, + input.reference || input.payoutId, + { + tx, + payoutId: input.payoutId, + idempotencyKey: `payout-completed:${input.payoutId}`, + }, + ); + } + + async getOrderTimeline(orderId: string): Promise { + return this.prisma.ledgerEntry.findMany({ + where: { orderId }, + orderBy: { createdAt: "asc" }, + }); + } + + private getClient(tx?: Prisma.TransactionClient): LedgerClient { + return tx || this.prisma; + } + + private assertPositiveAmount(amountKobo: bigint): void { + if (typeof amountKobo !== "bigint" || amountKobo <= 0n) { + throw new BadRequestException({ + message: "Ledger amount must be a positive BigInt in kobo", + code: "INVALID_LEDGER_AMOUNT", + }); + } + } + + private assertNonEmptyString(value: string, field: string): void { + if (typeof value !== "string" || value.trim().length === 0) { + throw new BadRequestException({ + message: `${field} is required`, + code: "INVALID_LEDGER_INPUT", + }); + } + } + + private normalizePayoutFailedPositionalInput( + orderId: string, + storeIdOrTx: string | Prisma.TransactionClient | undefined, + amountKobo: bigint | undefined, + reason: string | undefined, + options: LedgerWriteOptions, + ): RecordPayoutFailedInput { + if (typeof storeIdOrTx !== "string") { + throw new BadRequestException({ + message: "storeId is required", + code: "INVALID_LEDGER_INPUT", + }); + } + + if (typeof amountKobo !== "bigint") { + throw new BadRequestException({ + message: "Ledger amount must be a positive BigInt in kobo", + code: "INVALID_LEDGER_AMOUNT", + }); + } + + return { + orderId, + storeId: storeIdOrTx, + amountKobo, + reason, + payoutId: options.payoutId || null, + tx: options.tx, + idempotencyKey: options.idempotencyKey, + metadata: options.metadata, + }; + } + + private normalizePayoutFailedObjectInput( + input: RecordPayoutFailedInput, + txOrStoreId: string | Prisma.TransactionClient | undefined, + ): RecordPayoutFailedInput { + return { + ...input, + tx: + input.tx || (typeof txOrStoreId === "string" ? undefined : txOrStoreId), + }; + } + + private matchesExistingEntry( + existing: LedgerEntry, + input: RecordLedgerEntryInput, + idempotencyKey: string, + ): boolean { + return ( + existing.idempotencyKey === idempotencyKey && + existing.entryType === input.entryType && + existing.direction === input.direction && + BigInt(existing.amountKobo) === input.amountKobo && + existing.orderId === (input.orderId || null) && + existing.paymentId === (input.paymentId || null) && + existing.payoutId === (input.payoutId || null) && + existing.storeId === (input.storeId || null) && + existing.userId === (input.userId || null) && + existing.reference === (input.reference || null) && + existing.currency === (input.currency || "NGN") && + existing.settlementLegId === (input.settlementLegId || null) && + existing.paymentAmountExceptionId === + (input.paymentAmountExceptionId || null) + ); + } + + private buildLedgerIdempotencyKey(input: RecordLedgerEntryInput): string { + return [ + input.entryType, + input.direction, + input.orderId || "no-order", + input.paymentId || "no-payment", + input.payoutId || "no-payout", + input.reference || "no-reference", + input.amountKobo.toString(), + ].join(":"); + } + + private isUniqueConflict( + error: unknown, + ): error is Prisma.PrismaClientKnownRequestError { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ); + } +} diff --git a/apps/backend/src/domains/money/payment/dto/initialize-payment.dto.ts b/apps/backend/src/domains/money/payment/dto/initialize-payment.dto.ts new file mode 100644 index 00000000..ee80aa3e --- /dev/null +++ b/apps/backend/src/domains/money/payment/dto/initialize-payment.dto.ts @@ -0,0 +1,8 @@ +import { IsNotEmpty, IsString } from "class-validator"; + +export class InitializePaymentDto { + // Order ids are Prisma cuids, not UUIDs — validate as a non-empty string. + @IsString() + @IsNotEmpty() + orderId!: string; +} diff --git a/apps/backend/src/domains/money/payment/dto/request-payout.dto.ts b/apps/backend/src/domains/money/payment/dto/request-payout.dto.ts new file mode 100644 index 00000000..191f36b7 --- /dev/null +++ b/apps/backend/src/domains/money/payment/dto/request-payout.dto.ts @@ -0,0 +1,9 @@ +import { IsString, Matches } from "class-validator"; + +export class RequestPayoutDto { + @IsString() + @Matches(/^[1-9]\d*$/, { + message: "amount must be a positive integer kobo string", + }) + amount!: string; +} diff --git a/apps/backend/src/domains/money/payment/dva/dva.controller.ts b/apps/backend/src/domains/money/payment/dva/dva.controller.ts new file mode 100644 index 00000000..d4be821c --- /dev/null +++ b/apps/backend/src/domains/money/payment/dva/dva.controller.ts @@ -0,0 +1,32 @@ +import { + Controller, + Post, + Get, + UseGuards, + HttpCode, + HttpStatus, +} from "@nestjs/common"; +import { DvaService } from "./dva.service"; +import { JwtAuthGuard } from "../../../../common/guards/jwt-auth.guard"; +import { RolesGuard } from "../../../../common/guards/roles.guard"; +import { Roles } from "../../../../common/decorators/roles.decorator"; +import { CurrentUser } from "../../../../common/decorators/current-user.decorator"; +import { UserRole, JwtPayload } from "@twizrr/shared"; + +@Controller("buyer/dva") +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.USER) +export class DvaController { + constructor(private readonly dvaService: DvaService) {} + + @Post("create") + @HttpCode(HttpStatus.CREATED) + async createDva(@CurrentUser() user: JwtPayload) { + return this.dvaService.createDva(user.sub); + } + + @Get() + async getDva(@CurrentUser() user: JwtPayload) { + return this.dvaService.getDva(user.sub); + } +} diff --git a/apps/backend/src/domains/money/payment/dva/dva.module.ts b/apps/backend/src/domains/money/payment/dva/dva.module.ts new file mode 100644 index 00000000..2a3def76 --- /dev/null +++ b/apps/backend/src/domains/money/payment/dva/dva.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { DvaController } from "./dva.controller"; +import { DvaService } from "./dva.service"; +import { PrismaModule } from "../../../../prisma/prisma.module"; +import { PaystackModule } from "../../../../integrations/paystack/paystack.module"; + +@Module({ + imports: [PrismaModule, PaystackModule], + controllers: [DvaController], + providers: [DvaService], + exports: [DvaService], +}) +export class DvaModule {} diff --git a/apps/backend/src/domains/money/payment/dva/dva.service.ts b/apps/backend/src/domains/money/payment/dva/dva.service.ts new file mode 100644 index 00000000..1560f2f5 --- /dev/null +++ b/apps/backend/src/domains/money/payment/dva/dva.service.ts @@ -0,0 +1,224 @@ +import { + Injectable, + Logger, + BadRequestException, + NotFoundException, +} from "@nestjs/common"; +import { PrismaService } from "../../../../prisma/prisma.service"; +import { PaystackClient } from "../../../../integrations/paystack/paystack.client"; + +@Injectable() +export class DvaService { + private readonly logger = new Logger(DvaService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly paystack: PaystackClient, + ) {} + + /** + * Assures a Paystack Customer exists for the user. + * Creates one on Paystack if missing, then saves the ID/Code to DB. + */ + async assureCustomer(userId: string): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundException("User not found"); + } + + if (user.paystackCustomerCode) { + return user.paystackCustomerCode; + } + + this.logger.log(`Creating Paystack customer for user ${userId}`); + + const newCustomer = await this.paystack.createCustomer({ + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + phone: user.phone, + }); + + if (!newCustomer?.status || !newCustomer?.data?.customer_code) { + this.logger.error("Failed to create Paystack customer", newCustomer); + throw new BadRequestException("Could not create Paystack customer"); + } + + const { customer_code, id } = newCustomer.data; + + await this.prisma.user.update({ + where: { id: userId }, + data: { + paystackCustomerCode: customer_code, + paystackCustomerId: String(id), + }, + }); + + return customer_code; + } + + /** + * Creates a Dedicated Virtual Account for the user. + */ + async createDva(userId: string): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundException("User not found"); + } + + if (user.dvaActive && user.dvaAccountNumber) { + // Idempotent: return existing DVA instead of throwing on retry + return { + message: "DVA already exists", + status: "EXISTS", + dva: { + accountNumber: user.dvaAccountNumber, + accountName: user.dvaAccountName, + bankName: user.dvaBankName, + }, + }; + } + + // Ensure customer exists on Paystack + const customerCode = await this.assureCustomer(userId); + + this.logger.log(`Creating DVA for customer ${customerCode}`); + + // Create DVA + const dvaResponse = + await this.paystack.createDedicatedVirtualAccount(customerCode); + + if (!dvaResponse?.status) { + this.logger.error("Failed to create DVA", dvaResponse); + throw new BadRequestException( + "Could not create Dedicated Virtual Account", + ); + } + + // Paystack processes DVAs asynchronously sometimes, but often returns details immediately + // If it returns them, save them immediately. Otherwise, rely on webhooks. + if (dvaResponse.data?.account_number) { + const data = dvaResponse.data; + await this.prisma.user.update({ + where: { id: user.id }, + data: { + dvaAccountNumber: data.account_number, + dvaAccountName: data.account_name, + dvaBankName: data.bank?.name, + dvaBankSlug: data.bank?.slug, + dvaActive: true, + }, + }); + } + + return { + message: "DVA creation initiated successfully", + status: "PENDING_OR_CREATED", + dva: dvaResponse.data + ? { + accountNumber: dvaResponse.data.account_number, + accountName: dvaResponse.data.account_name, + bankName: dvaResponse.data.bank?.name, + } + : null, + }; + } + + /** + * Get the current user's DVA status/details + */ + async getDva(userId: string): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + dvaActive: true, + dvaAccountNumber: true, + dvaAccountName: true, + dvaBankName: true, + }, + }); + + if (!user) { + throw new NotFoundException("User not found"); + } + + return { + active: user.dvaActive, + accountNumber: user.dvaAccountNumber, + accountName: user.dvaAccountName, + bankName: user.dvaBankName, + }; + } + + /** + * Called by the PaymentService webhook handler when a DVA is successfully assigned + */ + async handleDvaAssignSuccess(payload: any): Promise { + const { customer, dedicated_account } = payload; + + if (!customer?.customer_code || !dedicated_account?.account_number) { + this.logger.warn("Invalid DVA success webhook payload", payload); + return; + } + + const user = await this.prisma.user.findFirst({ + where: { paystackCustomerCode: customer.customer_code }, + }); + + if (!user) { + this.logger.warn( + `Received DVA webhook for unknown customer code ${customer.customer_code}`, + ); + return; + } + + await this.prisma.user.update({ + where: { id: user.id }, + data: { + dvaAccountNumber: dedicated_account.account_number, + dvaAccountName: dedicated_account.account_name, + dvaBankName: dedicated_account.bank?.name, + dvaBankSlug: dedicated_account.bank?.slug, + dvaActive: true, + }, + }); + + this.logger.log(`Successfully activated DVA for user ${user.id}`); + } + + /** + * Called by the PaymentService webhook handler when DVA assignment fails + */ + async handleDvaAssignFailed(payload: any): Promise { + const { customer } = payload; + + if (!customer?.customer_code) { + this.logger.warn("Invalid DVA failed webhook payload", payload); + return; + } + + const user = await this.prisma.user.findFirst({ + where: { paystackCustomerCode: customer.customer_code }, + }); + + if (!user) { + return; + } + + // Ideally, we might want a 'failed' state, but setting active=false is sufficient here + await this.prisma.user.update({ + where: { id: user.id }, + data: { + dvaActive: false, + }, + }); + + this.logger.error(`DVA assignment failed for user ${user.id}`); + } +} diff --git a/apps/backend/src/domains/money/payment/payment.controller.ts b/apps/backend/src/domains/money/payment/payment.controller.ts new file mode 100644 index 00000000..ea61d288 --- /dev/null +++ b/apps/backend/src/domains/money/payment/payment.controller.ts @@ -0,0 +1,145 @@ +import { + Controller, + Post, + Body, + UseGuards, + Get, + Query, + Param, + ForbiddenException, + HttpCode, + Req, +} from "@nestjs/common"; +import type { RawBodyRequest } from "@nestjs/common"; +import type { Request } from "express"; +import { SkipThrottle } from "@nestjs/throttler"; +import { PaymentService } from "./payment.service"; +import { InitializePaymentDto } from "./dto/initialize-payment.dto"; +import { RequestPayoutDto } from "./dto/request-payout.dto"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { RolesGuard } from "../../../common/guards/roles.guard"; +import { CurrentUser } from "../../../common/decorators/current-user.decorator"; +import { IdempotencyKey } from "../../../common/decorators/idempotency-key.decorator"; +import { Roles } from "../../../common/decorators/roles.decorator"; +import { DvaService } from "./dva/dva.service"; +import { UserRole, JwtPayload } from "@twizrr/shared"; + +@Controller("payments") +export class PaymentController { + constructor( + private readonly paymentService: PaymentService, + private readonly dvaService: DvaService, + ) {} + + @Post("initialize") + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.USER) + initialize( + @CurrentUser() user: JwtPayload, + @Body() dto: InitializePaymentDto, + @IdempotencyKey() idempotencyKey: string, + ) { + return this.paymentService.initialize(user.sub, dto, idempotencyKey); + } + + @Post("remaining-balance") + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.USER) + initializeRemainingBalance( + @CurrentUser() user: JwtPayload, + @Body() dto: InitializePaymentDto, + @IdempotencyKey() idempotencyKey: string, + ) { + return this.paymentService.initializeRemainingBalance( + user.sub, + dto, + idempotencyKey, + ); + } + + @Post("webhook") + @HttpCode(200) + @SkipThrottle() + async webhook(@Req() req: RawBodyRequest) { + const signature = req.headers["x-paystack-signature"]; + const rawBody = req.rawBody; + return this.paymentService.handleWebhook( + "PAYSTACK", + req.body, + rawBody, + signature, + ); + } + + @Post("webhook/monnify") + @HttpCode(200) + @SkipThrottle() + async monnifyWebhook(@Req() req: RawBodyRequest) { + const signature = req.headers["monnify-signature"]; + return this.paymentService.handleWebhook( + "MONNIFY", + req.body, + req.rawBody, + signature, + ); + } + + @Get("resolve-account") + @UseGuards(JwtAuthGuard) + resolveAccount( + @Query("accountNumber") accountNumber: string, + @Query("bankCode") bankCode: string, + ) { + return this.paymentService.resolveAccount(accountNumber, bankCode); + } + + @Get("banks") + @UseGuards(JwtAuthGuard) + getBanks() { + return this.paymentService.getBanks(); + } + + @Get("verify/:reference") + @UseGuards(JwtAuthGuard) + verifyPayment( + @CurrentUser() user: JwtPayload, + @Param("reference") reference: string, + ) { + return this.paymentService.verifyPayment(user.sub, reference); + } + + @Get("remaining-balance/:orderId") + @UseGuards(JwtAuthGuard) + getRemainingBalance( + @CurrentUser() user: JwtPayload, + @Param("orderId") orderId: string, + ) { + return this.paymentService.getRemainingBalance(user.sub, orderId); + } + + @Post("dva/create") + @UseGuards(JwtAuthGuard) + createDva(@CurrentUser() user: JwtPayload) { + return this.dvaService.createDva(user.sub); + } + + @Get("dva/me") + @UseGuards(JwtAuthGuard) + getMyDva(@CurrentUser() user: JwtPayload) { + return this.dvaService.getDva(user.sub); + } + + @Post("request-payout") + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.USER) + requestPayout( + @CurrentUser() user: JwtPayload, + @Body() dto: RequestPayoutDto, + @IdempotencyKey() idempotencyKey: string | undefined, + ) { + if (!user.storeId) { + throw new ForbiddenException("Store ID not found"); + } + return this.paymentService.requestPayout(user.storeId, dto, idempotencyKey); + } +} diff --git a/apps/backend/src/domains/money/payment/payment.module.ts b/apps/backend/src/domains/money/payment/payment.module.ts new file mode 100644 index 00000000..78ca3493 --- /dev/null +++ b/apps/backend/src/domains/money/payment/payment.module.ts @@ -0,0 +1,73 @@ +import { Module, forwardRef } from "@nestjs/common"; +import { BullModule } from "@nestjs/bullmq"; +import { PaymentService } from "./payment.service"; +import { PaymentController } from "./payment.controller"; +import { PaystackModule } from "../../../integrations/paystack/paystack.module"; +import { MonnifyModule } from "../../../integrations/monnify/monnify.module"; +import { PrismaModule } from "../../../prisma/prisma.module"; +import { OrderModule } from "../../orders/order/order.module"; +import { NotificationModule } from "../../social/notification/notification.module"; +import { DvaModule } from "./dva/dva.module"; +import { LedgerModule } from "../ledger/ledger.module"; +import { DomainEventModule } from "../../platform/domain-event/domain-event.module"; +import { OutboxModule } from "../../platform/outbox/outbox.module"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { PAYOUT_QUEUE } from "../../../queue/queue.constants"; +import { PAYMENT_PROVIDER } from "./providers/payment-provider.interface"; +import { PaystackPaymentProvider } from "../../../integrations/paystack/paystack-payment.provider"; +import { MonnifyPaymentProvider } from "../../../integrations/monnify/monnify-payment.provider"; +import { selectPaymentProvider } from "./providers/payment-provider.selector"; +import { EarningsModule } from "../earnings/earnings.module"; +import { + createPaymentProviderRegistry, + PAYMENT_PROVIDER_REGISTRY, +} from "./providers/payment-provider.registry"; + +@Module({ + imports: [ + PrismaModule, + forwardRef(() => OrderModule), + DvaModule, + forwardRef(() => NotificationModule), + LedgerModule, + DomainEventModule, + OutboxModule, + ConfigModule, + PaystackModule, + MonnifyModule, + EarningsModule, + BullModule.registerQueue({ name: PAYOUT_QUEUE }), + ], + controllers: [PaymentController], + providers: [ + PaymentService, + { + provide: PAYMENT_PROVIDER, + useFactory: ( + config: ConfigService, + paystackProvider: PaystackPaymentProvider, + monnifyProvider: MonnifyPaymentProvider, + ) => + selectPaymentProvider( + config.get("PAYMENT_PROVIDER"), + { paystack: paystackProvider, monnify: monnifyProvider }, + config.get("monnify.paymentEnabled", false), + ), + inject: [ConfigService, PaystackPaymentProvider, MonnifyPaymentProvider], + }, + { + provide: PAYMENT_PROVIDER_REGISTRY, + useFactory: ( + paystackProvider: PaystackPaymentProvider, + monnifyProvider: MonnifyPaymentProvider, + ) => + createPaymentProviderRegistry({ + paystack: paystackProvider, + monnify: monnifyProvider, + }), + inject: [PaystackPaymentProvider, MonnifyPaymentProvider], + }, + ], + exports: [PaymentService], +}) +export class PaymentModule {} diff --git a/apps/backend/src/domains/money/payment/payment.service.spec.ts b/apps/backend/src/domains/money/payment/payment.service.spec.ts new file mode 100644 index 00000000..c077ab8d --- /dev/null +++ b/apps/backend/src/domains/money/payment/payment.service.spec.ts @@ -0,0 +1,2170 @@ +import { + BadRequestException, + ConflictException, + NotFoundException, +} from "@nestjs/common"; +import { validate } from "class-validator"; + +import { OrderStatus, PaymentDirection, PaymentStatus } from "@twizrr/shared"; +import { PaymentAttemptStatus } from "@prisma/client"; + +import { PaymentService } from "./payment.service"; +import { RequestPayoutDto } from "./dto/request-payout.dto"; + +describe("PaymentService verification lifecycle", () => { + const makeService = (overrides: Record = {}) => { + const payment = { + id: "payment-1", + orderId: "order-1", + provider: "PAYSTACK", + providerReference: "tx-reference", + paystackReference: "tx-reference", + amountKobo: 250000n, + status: PaymentStatus.SUCCESS, + order: { + id: "order-1", + buyerId: "buyer-1", + storeId: "store-1", + status: OrderStatus.PAID, + totalAmountKobo: 250000n, + platformFeeKobo: 0n, + items: null, + }, + }; + const orderService = { + transitionBySystem: jest.fn().mockResolvedValue(undefined), + transitionLinkedFulfillment: jest.fn().mockResolvedValue(undefined), + ...((overrides.orderService as Record) ?? {}), + }; + const resolvedPayment = { + ...payment, + ...((overrides.payment as Record) ?? {}), + order: { + ...payment.order, + ...(((overrides.payment as Record) ?? {}).order as + | Record + | undefined), + }, + }; + const prisma = { + payment: { + findUnique: jest.fn().mockResolvedValue(resolvedPayment), + }, + paymentAttempt: { + findUnique: jest.fn().mockResolvedValue({ + id: "attempt-1", + provider: "PAYSTACK", + providerReference: "tx-reference", + status: "SUCCESS", + payment: resolvedPayment, + }), + }, + $transaction: jest.fn(), + ...((overrides.prisma as Record) ?? {}), + }; + const paymentProvider = { + name: "PAYSTACK", + verifyPayment: jest.fn(), + verifyWebhookSignature: jest.fn(), + }; + const service = new PaymentService( + prisma as never, + paymentProvider as never, + orderService as never, + {} as never, + { get: jest.fn() } as never, + {} as never, + {} as never, + {} as never, + {} as never, + { append: jest.fn() } as never, + { getSummary: jest.fn() } as never, + ); + + return { service, prisma, paymentProvider, payment, orderService }; + }; + + it("rejects manual verification when the payment reference belongs to another buyer", async () => { + const { service, paymentProvider } = makeService({ + payment: { + status: PaymentStatus.INITIALIZED, + order: { buyerId: "buyer-2" }, + }, + }); + + await expect( + service.verifyPayment("buyer-1", "tx-reference"), + ).rejects.toMatchObject({ + response: expect.objectContaining({ message: "Access denied" }), + }); + expect(paymentProvider.verifyPayment).not.toHaveBeenCalled(); + }); + + it("treats repeated manual verification of a successful payment as already verified", async () => { + const { service, paymentProvider } = makeService(); + + await expect( + service.verifyPayment("buyer-1", "tx-reference"), + ).resolves.toEqual({ status: "already_verified", paymentId: "payment-1" }); + expect(paymentProvider.verifyPayment).not.toHaveBeenCalled(); + }); + + it("repairs a successful payment whose order is still pending before returning already verified", async () => { + const { service, paymentProvider, orderService } = makeService({ + payment: { + status: PaymentStatus.SUCCESS, + order: { status: OrderStatus.PENDING_PAYMENT }, + }, + }); + + await expect( + service.verifyPayment("buyer-1", "tx-reference"), + ).resolves.toEqual({ status: "already_verified", paymentId: "payment-1" }); + + expect(paymentProvider.verifyPayment).not.toHaveBeenCalled(); + expect(orderService.transitionBySystem).toHaveBeenCalledWith( + "order-1", + OrderStatus.PENDING_PAYMENT, + OrderStatus.PAID, + expect.objectContaining({ + paymentId: "payment-1", + reference: "tx-reference", + action: "payment_success_repair", + }), + ); + }); + + it("inspects a different successful-payment attempt before returning already verified", async () => { + const { service, prisma, payment } = makeService(); + prisma.paymentAttempt.findUnique.mockResolvedValue({ + id: "attempt-late", + provider: "PAYSTACK", + providerReference: "tx-late", + status: "INITIALIZED", + payment, + }); + const inspectSpy = jest + .spyOn( + service as unknown as { + inspectAdditionalPaymentAttempt: ( + payment: unknown, + attempt: unknown, + ) => Promise; + }, + "inspectAdditionalPaymentAttempt", + ) + .mockResolvedValue("reconciliation_required"); + + await expect(service.verifyPayment("buyer-1", "tx-late")).resolves.toEqual({ + status: "requires_review", + paymentId: "payment-1", + }); + expect(inspectSpy).toHaveBeenCalled(); + }); + + it("does not let a webhook bypass manual review", async () => { + const { service } = makeService({ + payment: { status: PaymentStatus.MANUAL_REVIEW }, + }); + const processSpy = jest.spyOn( + service as unknown as { + processSuccessfulPayment: ( + paymentId: string, + reference: string, + ) => Promise; + }, + "processSuccessfulPayment", + ); + + await expect( + ( + service as unknown as { + handleChargeSuccess: ( + reference: string, + provider: "PAYSTACK", + ) => Promise<{ status: string }>; + } + ).handleChargeSuccess("tx-reference", "PAYSTACK"), + ).resolves.toEqual({ status: "requires_review" }); + expect(processSpy).not.toHaveBeenCalled(); + }); + + it("routes a late collection on a failed payment to reconciliation", async () => { + const { service } = makeService({ + payment: { status: PaymentStatus.FAILED }, + }); + const inspectSpy = jest + .spyOn( + service as unknown as { + inspectAdditionalPaymentAttempt: ( + payment: unknown, + attempt: unknown, + ) => Promise; + }, + "inspectAdditionalPaymentAttempt", + ) + .mockResolvedValue("reconciliation_required"); + const processSpy = jest.spyOn( + service as unknown as { + processSuccessfulPayment: ( + paymentId: string, + reference: string, + ) => Promise; + }, + "processSuccessfulPayment", + ); + + await expect( + ( + service as unknown as { + handleChargeSuccess: ( + reference: string, + provider: "PAYSTACK", + ) => Promise<{ status: string }>; + } + ).handleChargeSuccess("tx-reference", "PAYSTACK"), + ).resolves.toEqual({ status: "reconciliation_required" }); + expect(inspectSpy).toHaveBeenCalled(); + expect(processSpy).not.toHaveBeenCalled(); + }); + + it("persists only the safe Monnify webhook fields", () => { + const { service } = makeService(); + const withWebhookSanitizer = service as unknown as { + sanitizeWebhookPayload(payload: unknown): unknown; + }; + + const storedPayload = withWebhookSanitizer.sanitizeWebhookPayload({ + eventType: "SUCCESSFUL_TRANSACTION", + eventData: { + paymentReference: "tx-reference", + transactionReference: "MNFY-transaction", + amountPaid: "2500.50", + currencyCode: "NGN", + destinationAccountInformation: { accountNumber: "0123456789" }, + customer: { email: "buyer@twizrr.test" }, + }, + }); + + expect(storedPayload).toEqual( + expect.objectContaining({ event: "SUCCESSFUL_TRANSACTION" }), + ); + expect(JSON.stringify(storedPayload)).not.toContain("0123456789"); + expect(JSON.stringify(storedPayload)).not.toContain("buyer@twizrr.test"); + }); + + it("fails closed when an explicit provider webhook has no raw body or signature", async () => { + const { service, paymentProvider } = makeService(); + await expect( + service.handleWebhook("PAYSTACK", { event: "charge.success" }), + ).resolves.toEqual({ status: "ignored" }); + expect(paymentProvider.verifyWebhookSignature).not.toHaveBeenCalled(); + }); +}); + +describe("PaymentService order notifications", () => { + type PaymentServiceWithPrivateSuccess = { + processSuccessfulPayment: ( + paymentId: string, + reference: string, + ) => Promise; + calculateStoreNotificationAmount: ( + order: { + totalAmountKobo: bigint; + deliveryFeeKobo?: bigint | null; + platformFeeKobo?: bigint | null; + unitPriceKobo?: bigint | null; + quantity?: number | null; + }, + platformFeeBearer?: "SHOPPER" | "MERCHANT" | null, + ) => bigint; + }; + + const makeSuccessfulPaymentService = ( + overrides: { + paymentUpdateCount?: number; + verificationStatus?: string; + providerAmountKobo?: bigint; + transitionError?: Error; + otherUnresolvedAttemptCount?: number; + reconciliationAttempt?: { id: string } | null; + remainingBalanceEnabled?: boolean; + } = {}, + ) => { + const order = { + id: "order-1", + buyerId: "buyer-1", + storeId: "store-1", + status: OrderStatus.PENDING_PAYMENT, + totalAmountKobo: 260000n, + deliveryFeeKobo: 40000n, + platformFeeKobo: 20000n, + items: null, + productId: "product-1", + quantity: 2, + unitPriceKobo: 100000n, + deliveryAddress: "12 Allen Avenue", + }; + const payment = { + id: "payment-1", + orderId: order.id, + provider: "PAYSTACK", + paystackReference: "tx-reference", + amountKobo: order.totalAmountKobo, + status: PaymentStatus.INITIALIZED, + order, + }; + const tx = { + payment: { + updateMany: jest + .fn() + .mockResolvedValue({ count: overrides.paymentUpdateCount ?? 1 }), + update: jest.fn(), + }, + paymentEvent: { + create: jest.fn().mockResolvedValue({ id: "payment-event-1" }), + }, + paymentAttempt: { + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + count: jest + .fn() + .mockResolvedValue(overrides.otherUnresolvedAttemptCount ?? 0), + findFirst: jest + .fn() + .mockResolvedValue( + overrides.reconciliationAttempt ?? { id: "attempt-1" }, + ), + }, + paymentAmountException: { + upsert: jest.fn().mockResolvedValue({ id: "amount-exception-1" }), + }, + order: { + findUnique: jest.fn().mockResolvedValue({ + id: order.id, + buyerId: order.buyerId, + storeId: order.storeId, + quantity: order.quantity, + productId: order.productId, + unitPriceKobo: order.unitPriceKobo, + totalAmountKobo: order.totalAmountKobo, + deliveryFeeKobo: order.deliveryFeeKobo, + platformFeeKobo: order.platformFeeKobo, + deliveryAddress: order.deliveryAddress, + product: { name: "Canvas Tote" }, + user: { firstName: "Ada", lastName: "Okafor" }, + }), + }, + }; + const prisma = { + payment: { findUnique: jest.fn().mockResolvedValue(payment) }, + paymentAmountException: { findUnique: jest.fn() }, + order: { + findUnique: jest.fn().mockResolvedValue({ + id: order.id, + quantity: order.quantity, + product: { name: "Canvas Tote" }, + user: { firstName: "Ada", lastName: "Okafor" }, + deliveryAddress: order.deliveryAddress, + }), + }, + cartItem: { deleteMany: jest.fn() }, + $transaction: jest.fn((callback: (transaction: typeof tx) => unknown) => + callback(tx), + ), + }; + const paymentProvider = { + name: "PAYSTACK", + verifyPayment: jest.fn().mockResolvedValue({ + status: overrides.verificationStatus ?? "success", + amountKobo: overrides.providerAmountKobo ?? order.totalAmountKobo, + reference: "tx-reference", + currency: "NGN", + gatewayResponse: "Approved", + }), + }; + const orderService = { + transitionBySystemInTransaction: jest + .fn() + .mockImplementation(() => + overrides.transitionError + ? Promise.reject(overrides.transitionError) + : Promise.resolve(undefined), + ), + transitionBySystem: jest.fn().mockResolvedValue(undefined), + transitionLinkedFulfillment: jest.fn().mockResolvedValue(undefined), + }; + const notifications = { + triggerOrderPurchaseConfirmed: jest.fn().mockResolvedValue(undefined), + triggerPaymentConfirmed: jest.fn().mockResolvedValue(undefined), + triggerOrderPurchaseConfirmedWhatsApp: jest + .fn() + .mockResolvedValue(undefined), + }; + const ledgerService = { + recordPaymentReceived: jest.fn().mockResolvedValue(undefined), + recordPaymentAmountExceptionReceived: jest + .fn() + .mockResolvedValue(undefined), + }; + const domainEvents = { + append: jest.fn().mockResolvedValue({ id: "domain-event-1" }), + }; + const outbox = { + append: jest.fn().mockResolvedValue({ id: "outbox-event-1" }), + }; + const service = new PaymentService( + prisma as never, + paymentProvider as never, + orderService as never, + notifications as never, + { + get: jest.fn((key: string, fallback?: unknown) => + key === "PAYMENT_REMAINING_BALANCE_COLLECTION_ENABLED" + ? (overrides.remainingBalanceEnabled ?? false) + : fallback, + ), + } as never, + {} as never, + {} as never, + ledgerService as never, + domainEvents as never, + outbox as never, + { getSummary: jest.fn() } as never, + ); + + return { + service, + prisma, + payment, + order, + tx, + notifications, + paymentProvider, + orderService, + outbox, + ledgerService, + domainEvents, + }; + }; + + it("re-checks an amount exception through its immutable provider without processing the order", async () => { + const { service, prisma, paymentProvider, orderService, ledgerService } = + makeSuccessfulPaymentService({ providerAmountKobo: 259999n }); + prisma.paymentAmountException.findUnique.mockResolvedValue({ + id: "exception-1", + provider: "PAYSTACK", + providerReference: "tx-reference", + receivedAmountKobo: 259999n, + status: "RECONCILIATION_REQUIRED", + }); + + await expect( + service.inspectPaymentAmountException("exception-1"), + ).resolves.toEqual({ + exceptionId: "exception-1", + provider: "PAYSTACK", + status: "RECONCILIATION_REQUIRED", + providerStatus: "success", + observedAmountKobo: "259999", + matchesRecordedAmount: true, + }); + + expect(paymentProvider.verifyPayment).toHaveBeenCalledWith("tx-reference"); + expect(orderService.transitionBySystemInTransaction).not.toHaveBeenCalled(); + expect(ledgerService.recordPaymentReceived).not.toHaveBeenCalled(); + }); + + it("returns a safe gateway error when an amount-exception provider re-check fails", async () => { + const { service, prisma, paymentProvider } = makeSuccessfulPaymentService(); + prisma.paymentAmountException.findUnique.mockResolvedValue({ + id: "exception-1", + provider: "PAYSTACK", + providerReference: "tx-reference", + receivedAmountKobo: 259999n, + status: "RECONCILIATION_REQUIRED", + }); + paymentProvider.verifyPayment.mockRejectedValue( + new Error("provider timeout"), + ); + + await expect( + service.inspectPaymentAmountException("exception-1"), + ).rejects.toMatchObject({ + message: + "Could not reach the payment provider to re-check this exception.", + }); + }); + + it("passes merchandise amount separately for store-facing paid-order notifications", async () => { + const { service, notifications, orderService, outbox } = + makeSuccessfulPaymentService(); + + await ( + service as unknown as PaymentServiceWithPrivateSuccess + ).processSuccessfulPayment("payment-1", "tx-reference"); + + expect(outbox.append).toHaveBeenCalledTimes(3); + expect(outbox.append).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + topic: "notification.dispatch.requested", + idempotencyKey: + "payment:payment-1:notification:buyer-1:purchase-confirmed", + payload: expect.objectContaining({ + recipientUserId: "buyer-1", + notificationType: "ORDER_PURCHASE_CONFIRMED", + data: expect.objectContaining({ + amountKobo: "260000", + storeAmountKobo: "200000", + }), + }), + }), + ); + expect(outbox.append).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + topic: "whatsapp.direct-order-notification.requested", + idempotencyKey: "payment:payment-1:whatsapp:direct-order:store-1", + payload: { + storeId: "store-1", + orderData: { + orderId: "order-1", + productName: "Canvas Tote", + quantity: 2, + amountKobo: "200000", + }, + }, + }), + ); + expect(orderService.transitionBySystemInTransaction).toHaveBeenCalledWith( + expect.anything(), + "order-1", + OrderStatus.PENDING_PAYMENT, + OrderStatus.PAID, + expect.objectContaining({ + paymentId: "payment-1", + reference: "tx-reference", + }), + ); + expect( + notifications.triggerOrderPurchaseConfirmedWhatsApp, + ).not.toHaveBeenCalled(); + }); + + it("does not subtract a merchant-borne platform fee from cart notification merchandise", () => { + const { service } = makeSuccessfulPaymentService(); + const privateService = + service as unknown as PaymentServiceWithPrivateSuccess; + + expect( + privateService.calculateStoreNotificationAmount( + { + totalAmountKobo: 240_000n, + deliveryFeeKobo: 40_000n, + platformFeeKobo: 20_000n, + unitPriceKobo: null, + quantity: null, + }, + "MERCHANT", + ), + ).toBe(200_000n); + }); + + it("subtracts shopper-borne and bearer-less legacy fees from cart notifications", () => { + const { service } = makeSuccessfulPaymentService(); + const privateService = + service as unknown as PaymentServiceWithPrivateSuccess; + const legacyCart = { + totalAmountKobo: 260_000n, + deliveryFeeKobo: 40_000n, + platformFeeKobo: 20_000n, + unitPriceKobo: null, + quantity: null, + }; + + expect( + privateService.calculateStoreNotificationAmount(legacyCart, "SHOPPER"), + ).toBe(200_000n); + expect(privateService.calculateStoreNotificationAmount(legacyCart)).toBe( + 200_000n, + ); + }); + + it("preserves direct-order notification calculations for either fee policy", () => { + const { service } = makeSuccessfulPaymentService(); + const privateService = + service as unknown as PaymentServiceWithPrivateSuccess; + const directOrder = { + totalAmountKobo: 260_000n, + deliveryFeeKobo: 40_000n, + platformFeeKobo: 20_000n, + unitPriceKobo: 100_000n, + quantity: 2, + }; + + expect( + privateService.calculateStoreNotificationAmount(directOrder, "MERCHANT"), + ).toBe(200_000n); + expect( + privateService.calculateStoreNotificationAmount(directOrder, "SHOPPER"), + ).toBe(200_000n); + }); + + it("does not send paid-order notifications when Paystack verification fails", async () => { + const { service, notifications } = makeSuccessfulPaymentService({ + verificationStatus: "failed", + }); + + await ( + service as unknown as PaymentServiceWithPrivateSuccess + ).processSuccessfulPayment("payment-1", "tx-reference"); + + expect(notifications.triggerOrderPurchaseConfirmed).not.toHaveBeenCalled(); + expect(notifications.triggerPaymentConfirmed).not.toHaveBeenCalled(); + }); + + it("moves collected-but-discrepant provider statuses to reconciliation without financial completion", async () => { + const { service, tx, orderService, ledgerService, outbox } = + makeSuccessfulPaymentService({ + verificationStatus: "reconciliation_required", + providerAmountKobo: 270000n, + }); + + await expect( + ( + service as unknown as { + processSuccessfulPayment: ( + paymentId: string, + reference: string, + ) => Promise; + } + ).processSuccessfulPayment("payment-1", "tx-reference"), + ).resolves.toBe("reconciliation_required"); + + expect(tx.payment.updateMany).toHaveBeenCalledWith({ + where: { id: "payment-1", status: PaymentStatus.INITIALIZED }, + data: { status: PaymentStatus.RECONCILIATION_REQUIRED }, + }); + expect(tx.paymentEvent.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + eventType: "RECONCILIATION_REQUIRED", + payload: expect.objectContaining({ + providerAmountKobo: "270000", + expectedAmountKobo: "260000", + }), + }), + }); + expect(orderService.transitionBySystemInTransaction).not.toHaveBeenCalled(); + expect(ledgerService.recordPaymentReceived).not.toHaveBeenCalled(); + expect(tx.paymentAmountException.upsert).toHaveBeenCalledWith({ + where: { paymentAttemptId: "attempt-1" }, + create: expect.objectContaining({ + paymentId: "payment-1", + paymentAttemptId: "attempt-1", + provider: "PAYSTACK", + providerReference: "tx-reference", + expectedAmountKobo: 260000n, + receivedAmountKobo: 270000n, + exceptionType: "OVERPAID", + }), + update: { updatedAt: expect.any(Date) }, + }); + expect( + ledgerService.recordPaymentAmountExceptionReceived, + ).toHaveBeenCalledWith( + expect.objectContaining({ + paymentAmountExceptionId: "amount-exception-1", + amountKobo: 270000n, + expectedAmountKobo: 260000n, + exceptionType: "OVERPAID", + provider: "PAYSTACK", + }), + tx, + ); + expect(outbox.append).not.toHaveBeenCalled(); + }); + + it("routes a confirmed amount mismatch to reconciliation using exact kobo strings", async () => { + const { service, tx, ledgerService } = makeSuccessfulPaymentService({ + providerAmountKobo: 260001n, + }); + + await expect( + ( + service as unknown as { + processSuccessfulPayment: ( + paymentId: string, + reference: string, + ) => Promise; + } + ).processSuccessfulPayment("payment-1", "tx-reference"), + ).resolves.toBe("reconciliation_required"); + + expect(tx.paymentEvent.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + payload: expect.objectContaining({ + reason: "AMOUNT_MISMATCH", + providerAmountKobo: "260001", + expectedAmountKobo: "260000", + }), + }), + }); + expect(ledgerService.recordPaymentReceived).not.toHaveBeenCalled(); + expect( + ledgerService.recordPaymentAmountExceptionReceived, + ).toHaveBeenCalledWith( + expect.objectContaining({ + amountKobo: 260001n, + expectedAmountKobo: 260000n, + exceptionType: "OVERPAID", + }), + tx, + ); + }); + + it("records an underpayment as unallocated funds without confirming the order", async () => { + const { service, tx, orderService, ledgerService, outbox } = + makeSuccessfulPaymentService({ providerAmountKobo: 259999n }); + + await expect( + ( + service as unknown as { + processSuccessfulPayment: ( + paymentId: string, + reference: string, + ) => Promise; + } + ).processSuccessfulPayment("payment-1", "tx-reference"), + ).resolves.toBe("reconciliation_required"); + + expect(tx.paymentAmountException.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + exceptionType: "UNDERPAID", + expectedAmountKobo: 260000n, + receivedAmountKobo: 259999n, + }), + update: { updatedAt: expect.any(Date) }, + }), + ); + expect( + ledgerService.recordPaymentAmountExceptionReceived, + ).toHaveBeenCalledWith( + expect.objectContaining({ + amountKobo: 259999n, + expectedAmountKobo: 260000n, + exceptionType: "UNDERPAID", + }), + tx, + ); + expect(orderService.transitionBySystemInTransaction).not.toHaveBeenCalled(); + expect(ledgerService.recordPaymentReceived).not.toHaveBeenCalled(); + expect(outbox.append).not.toHaveBeenCalled(); + }); + + it("notifies the buyer to complete payment only when collection is enabled", async () => { + const { service, outbox } = makeSuccessfulPaymentService({ + providerAmountKobo: 259999n, + remainingBalanceEnabled: true, + }); + + await expect( + ( + service as unknown as { + processSuccessfulPayment: ( + paymentId: string, + reference: string, + ) => Promise; + } + ).processSuccessfulPayment("payment-1", "tx-reference"), + ).resolves.toBe("reconciliation_required"); + + expect(outbox.append).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + topic: "notification.dispatch.requested", + payload: expect.objectContaining({ + notificationType: "PAYMENT_ACTION_REQUIRED", + data: expect.objectContaining({ remainingAmountKobo: "1" }), + }), + }), + ); + }); + + it("keeps a pending provider attempt pending without failing the payment", async () => { + const { service, tx, orderService, ledgerService } = + makeSuccessfulPaymentService({ verificationStatus: "pending" }); + + await expect( + ( + service as unknown as { + processSuccessfulPayment: ( + paymentId: string, + reference: string, + ) => Promise; + } + ).processSuccessfulPayment("payment-1", "tx-reference"), + ).resolves.toBe("pending"); + + expect(tx.payment.updateMany).not.toHaveBeenCalled(); + expect(orderService.transitionBySystemInTransaction).not.toHaveBeenCalled(); + expect(ledgerService.recordPaymentReceived).not.toHaveBeenCalled(); + }); + + it("keeps the parent in reconciliation when another attempt is unresolved", async () => { + const { service, tx, ledgerService } = makeSuccessfulPaymentService({ + verificationStatus: "failed", + otherUnresolvedAttemptCount: 1, + reconciliationAttempt: { id: "attempt-ambiguous" }, + }); + + await expect( + ( + service as unknown as { + processSuccessfulPayment: ( + paymentId: string, + reference: string, + ) => Promise; + } + ).processSuccessfulPayment("payment-1", "tx-reference"), + ).resolves.toBe("failed"); + + expect(tx.payment.updateMany).toHaveBeenCalledWith({ + where: { id: "payment-1", status: PaymentStatus.INITIALIZED }, + data: { status: PaymentStatus.RECONCILIATION_REQUIRED }, + }); + expect(tx.payment.updateMany).not.toHaveBeenCalledWith( + expect.objectContaining({ data: { status: PaymentStatus.FAILED } }), + ); + expect(ledgerService.recordPaymentReceived).not.toHaveBeenCalled(); + }); + + it("records a second confirmed provider collection for reconciliation without a second ledger credit", async () => { + const { service, payment, tx, ledgerService } = + makeSuccessfulPaymentService(); + + await expect( + ( + service as unknown as { + inspectAdditionalPaymentAttempt: ( + paymentValue: unknown, + attempt: unknown, + ) => Promise; + } + ).inspectAdditionalPaymentAttempt(payment, { + id: "attempt-late", + provider: "PAYSTACK", + providerReference: "tx-late", + }), + ).resolves.toBe("reconciliation_required"); + + expect(tx.paymentAttempt.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: "attempt-late" }), + data: expect.objectContaining({ status: "RECONCILIATION_REQUIRED" }), + }), + ); + expect(tx.paymentEvent.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + eventType: "RECONCILIATION_REQUIRED", + payload: expect.objectContaining({ + reason: "ADDITIONAL_PROVIDER_COLLECTION", + providerAmountKobo: "260000", + }), + }), + }); + expect(ledgerService.recordPaymentReceived).not.toHaveBeenCalled(); + }); + + it("routes an unknown additional-attempt status to reconciliation", async () => { + const { service, payment, tx, paymentProvider, ledgerService } = + makeSuccessfulPaymentService({ verificationStatus: "provider_unknown" }); + + await expect( + ( + service as unknown as { + inspectAdditionalPaymentAttempt: ( + paymentValue: unknown, + attempt: unknown, + ) => Promise; + } + ).inspectAdditionalPaymentAttempt(payment, { + id: "attempt-unknown", + provider: "PAYSTACK", + providerReference: "tx-unknown", + }), + ).resolves.toBe("reconciliation_required"); + + expect(paymentProvider.verifyPayment).toHaveBeenCalledWith("tx-unknown"); + expect(tx.paymentAttempt.updateMany).toHaveBeenCalled(); + expect(ledgerService.recordPaymentReceived).not.toHaveBeenCalled(); + }); + + it("does not duplicate paid-order notifications when payment status update loses the processing race", async () => { + const { service, notifications } = makeSuccessfulPaymentService({ + paymentUpdateCount: 0, + }); + + await expect( + ( + service as unknown as PaymentServiceWithPrivateSuccess + ).processSuccessfulPayment("payment-1", "tx-reference"), + ).rejects.toThrow("Payment already processed by another thread"); + + expect(notifications.triggerOrderPurchaseConfirmed).not.toHaveBeenCalled(); + expect(notifications.triggerPaymentConfirmed).not.toHaveBeenCalled(); + }); + + it("does not commit payment success when the order paid transition fails", async () => { + const transitionError = new Error("order transition failed"); + const { service, notifications, tx } = makeSuccessfulPaymentService({ + transitionError, + }); + + await expect( + ( + service as unknown as PaymentServiceWithPrivateSuccess + ).processSuccessfulPayment("payment-1", "tx-reference"), + ).rejects.toThrow("order transition failed"); + + expect(tx.payment.updateMany).not.toHaveBeenCalled(); + expect(notifications.triggerOrderPurchaseConfirmed).not.toHaveBeenCalled(); + expect(notifications.triggerPaymentConfirmed).not.toHaveBeenCalled(); + }); +}); + +describe("PaymentService domain events", () => { + const makeService = () => { + const order = { + id: "order-1", + buyerId: "buyer-1", + storeId: "store-1", + status: OrderStatus.PENDING_PAYMENT, + totalAmountKobo: 250000n, + currency: "NGN", + }; + const buyer = { id: "buyer-1", email: "buyer@example.com" }; + const payment = { + id: "payment-1", + orderId: "order-1", + paystackReference: "tx-reference", + amountKobo: 250000n, + }; + const tx = { + payment: { + create: jest.fn().mockResolvedValue(payment), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + paymentAttempt: { + create: jest.fn().mockResolvedValue({ id: "attempt-1" }), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + paymentEvent: { + create: jest.fn().mockResolvedValue({ id: "payment-event-1" }), + }, + }; + const prisma = { + order: { findUnique: jest.fn().mockResolvedValue(order) }, + user: { findUnique: jest.fn().mockResolvedValue(buyer) }, + payment: { findFirst: jest.fn().mockResolvedValue(null) }, + $transaction: jest.fn((callback: (transaction: typeof tx) => unknown) => + callback(tx), + ), + }; + const paymentProvider = { + name: "PAYSTACK", + initializePayment: jest.fn().mockResolvedValue({ + authorizationUrl: "https://paymentProvider.example/authorize", + accessCode: "access-code", + reference: "tx-reference", + }), + }; + const config = { + get: jest.fn().mockReturnValue("http://localhost:3000"), + }; + const ledgerService = { + recordPaymentInitialized: jest.fn().mockResolvedValue(undefined), + }; + const domainEvents = { + append: jest.fn().mockResolvedValue({ id: "domain-event-1" }), + }; + + const service = new PaymentService( + prisma as never, + paymentProvider as never, + {} as never, + {} as never, + config as never, + {} as never, + {} as never, + ledgerService as never, + domainEvents as never, + { append: jest.fn() } as never, + { getSummary: jest.fn() } as never, + ); + + return { + service, + order, + tx, + prisma, + paymentProvider, + ledgerService, + domainEvents, + }; + }; + + it("writes payment initialized and order payment pending domain events in the transaction", async () => { + const { + service, + order, + tx, + prisma, + paymentProvider, + ledgerService, + domainEvents, + } = makeService(); + + const result = (await service.initialize( + "buyer-1", + { orderId: order.id }, + "idem-1", + )) as Record; + + expect(paymentProvider.initializePayment).toHaveBeenCalledWith({ + email: "buyer@example.com", + amountKobo: order.totalAmountKobo, + reference: expect.stringMatching(/^tx-/), + callbackUrl: "http://localhost:3000/orders/payment/callback/order-1", + }); + expect(result.reference).toEqual(expect.stringMatching(/^tx-/)); + expect(result.reference).not.toBe("tx-reference"); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(tx.payment.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + orderId: order.id, + provider: "PAYSTACK", + providerReference: expect.stringMatching(/^tx-/), + paystackReference: expect.stringMatching(/^tx-/), + amountKobo: order.totalAmountKobo, + status: PaymentStatus.INITIALIZED, + direction: PaymentDirection.INFLOW, + idempotencyKey: "idem-1", + }), + }); + expect(tx.paymentAttempt.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + paymentId: "payment-1", + provider: "PAYSTACK", + providerReference: expect.stringMatching(/^tx-/), + }), + }); + expect(tx.paymentEvent.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + paymentId: "payment-1", + eventType: "INITIALIZED", + }), + }); + expect(ledgerService.recordPaymentInitialized).toHaveBeenCalledWith( + expect.objectContaining({ + paymentId: "payment-1", + orderId: order.id, + amountKobo: order.totalAmountKobo, + }), + tx, + ); + expect(domainEvents.append).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + aggregateType: "PAYMENT", + aggregateId: "payment-1", + eventType: "PAYMENT_INITIALIZED", + actorType: "USER", + actorId: "buyer-1", + metadata: expect.objectContaining({ + amountKobo: "250000", + orderId: order.id, + }), + }), + ); + expect(domainEvents.append).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + aggregateType: "ORDER", + aggregateId: order.id, + eventType: "PAYMENT_PENDING", + actorType: "USER", + actorId: "buyer-1", + metadata: expect.objectContaining({ + amountKobo: "250000", + paymentId: "payment-1", + }), + }), + ); + }); + + it("persists Monnify as the immutable owner without populating a Paystack reference", async () => { + const { service, order, tx, prisma, paymentProvider } = makeService(); + paymentProvider.name = "MONNIFY"; + paymentProvider.initializePayment.mockResolvedValue({ + authorizationUrl: "https://sandbox.monnify.com/checkout/transaction-1", + accessCode: "MNFY-transaction-1", + reference: "tx-monnify", + providerTransactionReference: "MNFY-transaction-1", + }); + + await service.initialize("buyer-1", { orderId: order.id }, "idem-monnify"); + + expect(tx.payment.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + provider: "MONNIFY", + providerReference: expect.stringMatching(/^tx-/), + idempotencyKey: "idem-monnify", + }), + }); + expect(tx.payment.create.mock.calls[0][0].data).not.toHaveProperty( + "providerTransactionReference", + ); + expect(tx.payment.create.mock.calls[0][0].data).not.toHaveProperty( + "paystackReference", + ); + expect(tx.paymentAttempt.updateMany).toHaveBeenCalledWith({ + where: { + paymentId: "payment-1", + providerReference: expect.stringMatching(/^tx-/), + providerTransactionReference: null, + }, + data: { providerTransactionReference: "MNFY-transaction-1" }, + }); + expect(tx.payment.updateMany).toHaveBeenCalledWith({ + where: { + id: "payment-1", + providerTransactionReference: null, + }, + data: { providerTransactionReference: "MNFY-transaction-1" }, + }); + expect(prisma.$transaction).toHaveBeenCalledTimes(2); + }); + + it("persists the payment attempt before submitting its reference to the provider", async () => { + const { service, order, tx, paymentProvider } = makeService(); + paymentProvider.initializePayment.mockImplementation(async () => { + expect(tx.payment.create).toHaveBeenCalledTimes(1); + expect(tx.paymentAttempt.create).toHaveBeenCalledTimes(1); + return { + authorizationUrl: "https://paymentProvider.example/authorize", + accessCode: "access-code", + reference: "tx-reference", + }; + }); + + await service.initialize("buyer-1", { orderId: order.id }, "idem-order"); + + expect(tx.payment.create.mock.invocationCallOrder[0]).toBeLessThan( + paymentProvider.initializePayment.mock.invocationCallOrder[0], + ); + expect(tx.paymentAttempt.create.mock.invocationCallOrder[0]).toBeLessThan( + paymentProvider.initializePayment.mock.invocationCallOrder[0], + ); + }); + + it("freezes the durable first attempt when provider initialization is ambiguous", async () => { + const { service, order, tx, paymentProvider, domainEvents } = makeService(); + paymentProvider.initializePayment.mockRejectedValue( + new Error("provider response timed out"), + ); + + await expect( + service.initialize("buyer-1", { orderId: order.id }, "idem-timeout"), + ).rejects.toThrow("provider response timed out"); + + expect(tx.payment.create).toHaveBeenCalledTimes(1); + expect(tx.paymentAttempt.create).toHaveBeenCalledTimes(1); + expect(tx.payment.updateMany).toHaveBeenCalledWith({ + where: { id: "payment-1", status: PaymentStatus.INITIALIZED }, + data: { status: PaymentStatus.RECONCILIATION_REQUIRED }, + }); + expect(tx.paymentAttempt.updateMany).toHaveBeenCalledWith({ + where: { + paymentId: "payment-1", + providerReference: expect.stringMatching(/^tx-/), + status: "INITIALIZED", + }, + data: { + status: "RECONCILIATION_REQUIRED", + reconciliationRequiredAt: expect.any(Date), + }, + }); + expect(domainEvents.append).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + eventType: "PAYMENT_RECONCILIATION_REQUIRED", + metadata: expect.objectContaining({ + providerStatus: "INITIALIZATION_OUTCOME_UNKNOWN", + amountKobo: "250000", + }), + }), + ); + }); + + it("does not return checkout data when the accepted provider operation ID cannot be persisted", async () => { + const { service, order, tx, paymentProvider, domainEvents } = makeService(); + paymentProvider.initializePayment.mockResolvedValue({ + authorizationUrl: "https://sandbox.monnify.com/checkout/transaction-1", + accessCode: null, + reference: "tx-provider-echo", + providerTransactionReference: "MNFY-transaction-1", + }); + tx.paymentAttempt.updateMany + .mockRejectedValueOnce(new Error("database write failed")) + .mockResolvedValue({ count: 1 }); + + await expect( + service.initialize("buyer-1", { orderId: order.id }, "idem-db-failure"), + ).rejects.toThrow("database write failed"); + + expect(paymentProvider.initializePayment).toHaveBeenCalledTimes(1); + expect(tx.payment.updateMany).toHaveBeenCalledWith({ + where: { id: "payment-1", status: PaymentStatus.INITIALIZED }, + data: { status: PaymentStatus.RECONCILIATION_REQUIRED }, + }); + expect(domainEvents.append).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + eventType: "PAYMENT_RECONCILIATION_REQUIRED", + metadata: expect.objectContaining({ + providerStatus: "INITIALIZATION_PERSISTENCE_FAILED", + }), + }), + ); + }); +}); + +describe("PaymentService initialization reference reuse", () => { + const makeService = ( + overrides: { + existingPayment?: Record | null; + } = {}, + ) => { + const order = { + id: "order-1", + buyerId: "buyer-1", + storeId: "store-1", + status: OrderStatus.PENDING_PAYMENT, + totalAmountKobo: 250000n, + currency: "NGN", + platformFeeKobo: 0n, + }; + const buyer = { id: "buyer-1", email: "buyer@example.com" }; + const existingPayment = + overrides.existingPayment === undefined + ? { + id: "payment-existing", + orderId: order.id, + provider: "PAYSTACK", + providerReference: "tx-original", + paystackReference: "tx-original", + amountKobo: order.totalAmountKobo, + status: PaymentStatus.INITIALIZED, + direction: PaymentDirection.INFLOW, + attempts: [ + { + id: "attempt-original", + providerReference: "tx-original", + status: "INITIALIZED", + initializedAt: new Date("2026-07-18T00:00:00.000Z"), + }, + ], + } + : overrides.existingPayment; + const prisma = { + order: { findUnique: jest.fn().mockResolvedValue(order) }, + user: { findUnique: jest.fn().mockResolvedValue(buyer) }, + payment: { + findFirst: jest.fn().mockResolvedValue(existingPayment), + update: jest.fn().mockResolvedValue(undefined), + create: jest.fn(), + }, + paymentAttempt: { + create: jest + .fn() + .mockImplementation(({ data }) => + Promise.resolve({ id: "attempt-new", ...data }), + ), + update: jest.fn().mockResolvedValue(undefined), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + $transaction: jest.fn(), + }; + const paymentProvider = { + name: "PAYSTACK", + verifyPayment: jest.fn(), + initializePayment: jest.fn(), + }; + const config = { + get: jest.fn().mockReturnValue("http://localhost:3000"), + }; + const service = new PaymentService( + prisma as never, + paymentProvider as never, + {} as never, + {} as never, + config as never, + {} as never, + {} as never, + {} as never, + { append: jest.fn() } as never, + { append: jest.fn() } as never, + { getSummary: jest.fn() } as never, + ); + + return { service, prisma, paymentProvider, order, existingPayment }; + }; + + it("reuses the existing pending reference instead of overwriting it on re-initialization", async () => { + const { service, prisma, paymentProvider } = makeService(); + // The existing reference has not yet been paid on Paystack. + paymentProvider.verifyPayment.mockResolvedValue({ + status: "abandoned", + amountKobo: 250000n, + reference: "tx-original", + currency: "NGN", + gatewayResponse: "Abandoned", + }); + paymentProvider.initializePayment.mockResolvedValue({ + authorizationUrl: "https://paymentProvider.example/authorize", + accessCode: "access-code", + reference: "tx-original", + }); + + const result = (await service.initialize( + "buyer-1", + { orderId: "order-1" }, + "idem-retry", + )) as Record; + + // The still-payable reference is preserved, not rotated or duplicated. + expect(result.reference).toBe("tx-original"); + expect(result.paymentId).toBe("payment-existing"); + expect(result.message).toBe( + "Resuming pending payment with existing reference", + ); + expect(paymentProvider.initializePayment).toHaveBeenCalledWith({ + email: "buyer@example.com", + amountKobo: 250000n, + reference: "tx-original", + callbackUrl: "http://localhost:3000/orders/payment/callback/order-1", + }); + expect(prisma.payment.update).not.toHaveBeenCalled(); + expect(prisma.payment.create).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("reconciles and reports completion when the existing reference already succeeded on Paystack", async () => { + const { service, prisma, paymentProvider } = makeService(); + paymentProvider.verifyPayment.mockResolvedValue({ + status: "success", + amountKobo: 250000n, + reference: "tx-original", + currency: "NGN", + gatewayResponse: "Approved", + }); + const repairSpy = jest + .spyOn( + service as unknown as { + processSuccessfulPayment: ( + paymentId: string, + reference: string, + ) => Promise; + }, + "processSuccessfulPayment", + ) + .mockResolvedValue(undefined); + + await expect( + service.initialize("buyer-1", { orderId: "order-1" }, "idem-retry"), + ).rejects.toMatchObject({ + response: expect.objectContaining({ code: "PAYMENT_ALREADY_COMPLETED" }), + }); + + expect(repairSpy).toHaveBeenCalledWith("payment-existing", "tx-original"); + // No fresh reference is issued and the existing reference is untouched. + expect(paymentProvider.initializePayment).not.toHaveBeenCalled(); + expect(prisma.payment.update).not.toHaveBeenCalled(); + expect(prisma.payment.create).not.toHaveBeenCalled(); + }); + + it("rotates to a fresh reference only when Paystack rejects reuse of the old one", async () => { + const { service, prisma, paymentProvider } = makeService(); + paymentProvider.verifyPayment.mockResolvedValue({ + status: "abandoned", + amountKobo: 250000n, + reference: "tx-original", + currency: "NGN", + gatewayResponse: "Abandoned", + }); + paymentProvider.initializePayment + .mockRejectedValueOnce(new Error("reference not reusable")) + .mockResolvedValueOnce({ + authorizationUrl: "https://paymentProvider.example/authorize-new", + accessCode: "access-code-new", + reference: "tx-new", + }); + + const result = (await service.initialize( + "buyer-1", + { orderId: "order-1" }, + "idem-retry", + )) as Record; + + expect(result.paymentId).toBe("payment-existing"); + expect(result.reference).toEqual(expect.stringMatching(/^tx-/)); + expect(result.reference).not.toBe("tx-original"); + expect(result.message).toBe( + "Previous reference was no longer usable - issued a fresh reference", + ); + // The original payment reference remains immutable. A new attempt preserves + // both references so a delayed webhook for either checkout is resolvable. + expect(prisma.payment.update).not.toHaveBeenCalled(); + expect(prisma.paymentAttempt.create).toHaveBeenCalledWith({ + data: { + paymentId: "payment-existing", + provider: "PAYSTACK", + providerReference: result.reference, + expectedAmountKobo: 250000n, + }, + }); + expect(prisma.payment.create).not.toHaveBeenCalled(); + }); + + it("creates a new Monnify attempt without losing the original payable reference", async () => { + const { service, prisma, paymentProvider, existingPayment } = makeService(); + Object.assign(existingPayment as object, { + provider: "MONNIFY", + paystackReference: null, + }); + paymentProvider.name = "MONNIFY"; + paymentProvider.verifyPayment.mockResolvedValue({ + status: "pending", + amountKobo: 250000n, + reference: "tx-original", + currency: "NGN", + gatewayResponse: "PENDING", + }); + paymentProvider.initializePayment + .mockRejectedValueOnce(new Error("reference already exists")) + .mockResolvedValueOnce({ + authorizationUrl: "https://sandbox.monnify.test/checkout/new", + accessCode: null, + reference: "provider-echoed-reference", + providerTransactionReference: "MNFY-new", + }); + + const result = (await service.initialize( + "buyer-1", + { orderId: "order-1" }, + "idem-monnify-retry", + )) as Record; + + expect(result.reference).toEqual(expect.stringMatching(/^tx-/)); + expect(prisma.payment.update).not.toHaveBeenCalled(); + expect(prisma.paymentAttempt.create).toHaveBeenCalledWith({ + data: { + paymentId: "payment-existing", + provider: "MONNIFY", + providerReference: result.reference, + expectedAmountKobo: 250000n, + }, + }); + expect(existingPayment).toEqual( + expect.objectContaining({ providerReference: "tx-original" }), + ); + }); + + it("freezes the parent payment when a fresh provider initialization is ambiguous", async () => { + const { service, prisma, paymentProvider } = makeService(); + const tx = { + paymentAttempt: { + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + payment: { + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + }; + prisma.$transaction.mockImplementation( + (callback: (transaction: typeof tx) => unknown) => callback(tx), + ); + paymentProvider.verifyPayment.mockResolvedValue({ + status: "pending", + amountKobo: 250000n, + reference: "tx-original", + currency: "NGN", + gatewayResponse: "PENDING", + }); + paymentProvider.initializePayment + .mockRejectedValueOnce(new Error("reference already exists")) + .mockRejectedValueOnce(new Error("provider response timed out")); + + await expect( + service.initialize("buyer-1", { orderId: "order-1" }, "idem-timeout"), + ).rejects.toThrow("provider response timed out"); + + expect(tx.paymentAttempt.updateMany).toHaveBeenCalledWith({ + where: { id: "attempt-new", status: "INITIALIZED" }, + data: { + status: "RECONCILIATION_REQUIRED", + reconciliationRequiredAt: expect.any(Date), + }, + }); + expect(tx.payment.updateMany).toHaveBeenCalledWith({ + where: { id: "payment-existing", status: PaymentStatus.INITIALIZED }, + data: { status: PaymentStatus.RECONCILIATION_REQUIRED }, + }); + }); +}); + +describe("PaymentService remaining-balance collection", () => { + const payment = { + id: "payment-1", + orderId: "order-1", + provider: "PAYSTACK", + providerReference: "tx-initial", + paystackReference: "tx-initial", + amountKobo: 10_001n, + status: PaymentStatus.RECONCILIATION_REQUIRED, + order: { + id: "order-1", + buyerId: "buyer-1", + storeId: "store-1", + status: OrderStatus.PENDING_PAYMENT, + totalAmountKobo: 10_001n, + platformFeeKobo: 0n, + platformFeeBearer: "MERCHANT", + items: [], + }, + }; + const exception = { + id: "exception-1", + paymentId: payment.id, + paymentAttemptId: "attempt-initial", + remainingBalanceAttemptId: null, + provider: "PAYSTACK", + providerReference: "tx-initial", + providerTransactionReference: "provider-initial", + expectedAmountKobo: 10_001n, + receivedAmountKobo: 4_321n, + exceptionType: "UNDERPAID", + status: "RECONCILIATION_REQUIRED", + payment, + remainingBalanceAttempt: null, + }; + + function makeRemainingBalanceService(featureEnabled = true) { + const tx = { + paymentAttempt: { + create: jest.fn().mockImplementation(({ data }) => ({ + id: "attempt-remaining", + status: PaymentAttemptStatus.INITIALIZED, + ...data, + })), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + findUnique: jest.fn(), + }, + paymentAmountException: { + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + findUnique: jest.fn().mockResolvedValue({ + ...exception, + remainingBalanceAttemptId: "attempt-remaining", + status: "REMAINING_BALANCE_PENDING", + }), + }, + payment: { updateMany: jest.fn().mockResolvedValue({ count: 1 }) }, + paymentEvent: { create: jest.fn() }, + order: { findUnique: jest.fn().mockResolvedValue(null) }, + }; + const prisma = { + paymentAmountException: { + findFirst: jest.fn().mockResolvedValue(exception), + }, + user: { + findUnique: jest + .fn() + .mockResolvedValue({ id: "buyer-1", email: "buyer@example.com" }), + }, + paymentAttempt: { + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + order: { + findUnique: jest.fn().mockResolvedValue(null), + }, + cartItem: { deleteMany: jest.fn() }, + $transaction: jest.fn((callback) => callback(tx)), + }; + const provider = { + name: "PAYSTACK", + initializePayment: jest.fn().mockResolvedValue({ + authorizationUrl: "https://checkout.example/remaining", + accessCode: "access-code", + reference: "provider-echo", + }), + }; + const orderService = { + transitionBySystemInTransaction: jest.fn(), + transitionLinkedFulfillment: jest.fn(), + }; + const ledger = { + recordPaymentAmountExceptionAllocated: jest.fn(), + recordPaymentReceived: jest.fn(), + }; + const domainEvents = { append: jest.fn() }; + const service = new PaymentService( + prisma as never, + provider as never, + orderService as never, + {} as never, + { + get: jest.fn((key: string, fallback?: unknown) => { + if (key === "PAYMENT_REMAINING_BALANCE_COLLECTION_ENABLED") { + return featureEnabled; + } + if (key === "app.frontendUrl") return "http://localhost:3000"; + return fallback; + }), + } as never, + {} as never, + {} as never, + ledger as never, + domainEvents as never, + { append: jest.fn() } as never, + { getSummary: jest.fn() } as never, + ); + return { + service, + prisma, + tx, + provider, + orderService, + ledger, + }; + } + + it("initializes exactly the same-provider remaining amount", async () => { + const { service, provider, tx } = makeRemainingBalanceService(); + + const result = await service.initializeRemainingBalance( + "buyer-1", + { orderId: "order-1" }, + "remaining-idempotency", + ); + + expect(provider.initializePayment).toHaveBeenCalledWith( + expect.objectContaining({ + amountKobo: 5_680n, + reference: expect.stringMatching(/^tx-/), + }), + ); + expect(tx.paymentAttempt.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + paymentId: "payment-1", + provider: "PAYSTACK", + expectedAmountKobo: 5_680n, + }), + }); + expect(result.remainingAmountKobo).toBe("5680"); + }); + + it("does not create or submit a remaining checkout while the flag is disabled", async () => { + const { service, prisma, provider } = makeRemainingBalanceService(false); + + await expect( + service.initializeRemainingBalance( + "buyer-1", + { orderId: "order-1" }, + "remaining-idempotency", + ), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "REMAINING_BALANCE_COLLECTION_DISABLED", + }), + }); + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(provider.initializePayment).not.toHaveBeenCalled(); + }); + + it("does not call the provider again when a remaining checkout already exists", async () => { + const { service, prisma, provider } = makeRemainingBalanceService(); + prisma.paymentAmountException.findFirst.mockResolvedValue({ + ...exception, + status: "REMAINING_BALANCE_PENDING", + remainingBalanceAttemptId: "attempt-remaining", + remainingBalanceAttempt: { + id: "attempt-remaining", + status: PaymentAttemptStatus.INITIALIZED, + }, + }); + + await expect( + service.initializeRemainingBalance( + "buyer-1", + { orderId: "order-1" }, + "remaining-idempotency", + ), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "REMAINING_BALANCE_RECONCILIATION_REQUIRED", + }), + }); + expect(provider.initializePayment).not.toHaveBeenCalled(); + }); + + it("keeps an ambiguous provider initialization in reconciliation", async () => { + const { service, prisma, provider } = makeRemainingBalanceService(); + provider.initializePayment.mockRejectedValueOnce(new Error("timeout")); + + await expect( + service.initializeRemainingBalance( + "buyer-1", + { orderId: "order-1" }, + "remaining-idempotency", + ), + ).rejects.toThrow("timeout"); + + expect(prisma.paymentAttempt.updateMany).toHaveBeenCalledWith({ + where: { + id: "attempt-remaining", + status: PaymentAttemptStatus.INITIALIZED, + }, + data: { + status: PaymentAttemptStatus.RECONCILIATION_REQUIRED, + reconciliationRequiredAt: expect.any(Date), + }, + }); + }); + + it("atomically allocates the first collection only after the remainder is confirmed", async () => { + const { service, ledger, orderService } = makeRemainingBalanceService(); + + await ( + service as unknown as { + completeRemainingBalanceCollection: ( + paymentInput: typeof payment, + attemptId: string, + exceptionId: string, + reference: string, + verification: { + amountKobo: bigint; + gatewayResponse: string; + }, + ) => Promise; + } + ).completeRemainingBalanceCollection( + payment, + "attempt-remaining", + "exception-1", + "tx-remaining", + { amountKobo: 5_680n, gatewayResponse: "Approved" }, + ); + + expect(ledger.recordPaymentAmountExceptionAllocated).toHaveBeenCalledWith( + expect.objectContaining({ amountKobo: 4_321n }), + expect.any(Object), + ); + expect(ledger.recordPaymentReceived).toHaveBeenCalledWith( + expect.objectContaining({ amountKobo: 10_001n }), + expect.any(Object), + ); + expect(orderService.transitionBySystemInTransaction).toHaveBeenCalledWith( + expect.any(Object), + "order-1", + OrderStatus.PENDING_PAYMENT, + OrderStatus.PAID, + expect.objectContaining({ multiCollection: true }), + ); + }); + + it("accumulates the remaining-collection processor fee onto the prior snapshot and records a provider fee", async () => { + const { service, tx, ledger } = makeRemainingBalanceService(); + (tx.order as { update?: jest.Mock }).update = jest + .fn() + .mockResolvedValue({}); + (ledger as { recordProviderFee?: jest.Mock }).recordProviderFee = jest.fn(); + + // The order already carries a fee snapshot (e.g. from an earlier capture). + const paymentWithPriorFee = { + ...payment, + order: { ...payment.order, processorFeeKobo: 100n }, + }; + + await ( + service as unknown as { + completeRemainingBalanceCollection: ( + paymentInput: typeof payment, + attemptId: string, + exceptionId: string, + reference: string, + verification: { + amountKobo: bigint; + gatewayResponse: string; + feesKobo?: bigint | null; + }, + ) => Promise; + } + ).completeRemainingBalanceCollection( + paymentWithPriorFee, + "attempt-remaining", + "exception-1", + "tx-remaining", + { amountKobo: 5_680n, gatewayResponse: "Approved", feesKobo: 70n }, + ); + + // Prior 100n + this collection's 70n — added, not overwritten. + expect( + (tx.order as unknown as { update: jest.Mock }).update, + ).toHaveBeenCalledWith({ + where: { id: "order-1" }, + data: { processorFeeKobo: 170n }, + }); + expect( + (ledger as unknown as { recordProviderFee: jest.Mock }).recordProviderFee, + ).toHaveBeenCalledWith( + "order-1", + 70n, + "tx-remaining", + expect.objectContaining({ storeId: "store-1" }), + ); + }); +}); + +describe("RequestPayoutDto", () => { + async function validateAmount(amount: unknown) { + const dto = new RequestPayoutDto(); + (dto as { amount: unknown }).amount = amount; + return validate(dto); + } + + it("accepts positive integer kobo digit strings only", async () => { + await expect(validateAmount("150000")).resolves.toHaveLength(0); + await expect(validateAmount("900719925474099312345")).resolves.toHaveLength( + 0, + ); + + await expect(validateAmount(150000)).resolves.not.toHaveLength(0); + await expect(validateAmount("0")).resolves.not.toHaveLength(0); + await expect(validateAmount("-150000")).resolves.not.toHaveLength(0); + await expect(validateAmount("1500.50")).resolves.not.toHaveLength(0); + await expect(validateAmount("abc")).resolves.not.toHaveLength(0); + }); +}); + +describe("PaymentService payout requests", () => { + const makePayoutService = (availableBalanceBase = 1000n) => { + const storeProfile = { + id: "store-1", + userId: "owner-1", + businessName: "Ada Store", + bankAccountNumber: "0123456789", + bankCode: "044", + settlementAccountName: "Ada Store Ltd", + }; + const payoutRequests: Array<{ + id: string; + storeId: string; + idempotencyKey: string; + amountKobo: bigint; + status: string; + bankName: string | null; + accountNumber: string | null; + accountName: string | null; + }> = []; + const tx = { + $executeRaw: jest.fn().mockResolvedValue(undefined), + payoutRequest: { + findUnique: jest.fn(async ({ where }) => + payoutRequests.find( + (request) => + request.storeId === where.storeId_idempotencyKey.storeId && + request.idempotencyKey === + where.storeId_idempotencyKey.idempotencyKey, + ), + ), + aggregate: jest.fn(async ({ where }) => { + const statuses = where.status.in as string[]; + const sum = payoutRequests + .filter( + (request) => + request.status && + statuses.includes(request.status) && + where.storeId === "store-1", + ) + .reduce((total, request) => total + request.amountKobo, 0n); + return { _sum: { amountKobo: sum } }; + }), + create: jest.fn(async ({ data }) => { + const request = { + id: `payout-request-${payoutRequests.length + 1}`, + ...data, + }; + payoutRequests.push(request); + return request; + }), + }, + }; + const prisma = { + storeProfile: { + findUnique: jest.fn().mockResolvedValue(storeProfile), + }, + payoutRequest: { + aggregate: jest.fn(), + create: jest.fn(), + findUnique: jest.fn(), + }, + user: { + findMany: jest.fn().mockResolvedValue([{ id: "admin-1" }]), + }, + $transaction: jest.fn(), + }; + const notifications = { + triggerPayoutRequested: jest.fn().mockResolvedValue(undefined), + triggerMerchantPayoutRequestedConfirmation: jest + .fn() + .mockResolvedValue(undefined), + }; + const ledgerService = { + getMerchantAvailableBalance: jest.fn( + async (_storeId: string, reservedAmount: bigint) => + availableBalanceBase - reservedAmount, + ), + }; + const earningsService = { + getSummary: jest.fn(async () => { + const reserved = payoutRequests + .filter((request) => + ["PENDING", "PROCESSING"].includes(request.status), + ) + .reduce((total, request) => total + request.amountKobo, 0n); + return { availableKobo: (availableBalanceBase - reserved).toString() }; + }), + }; + const service = new PaymentService( + prisma as never, + {} as never, + {} as never, + notifications as never, + { get: jest.fn() } as never, + {} as never, + {} as never, + ledgerService as never, + { append: jest.fn() } as never, + { append: jest.fn() } as never, + earningsService as never, + ); + + let transactionChain = Promise.resolve(); + prisma.$transaction.mockImplementation((callback) => { + const run = transactionChain.then(() => callback(tx)); + transactionChain = run.then( + () => undefined, + () => undefined, + ); + return run; + }); + + return { + service, + prisma, + tx, + notifications, + ledgerService, + earningsService, + payoutRequests, + }; + }; + + it("converts payout amount digit strings exactly to bigint", async () => { + const { service, tx, notifications } = + makePayoutService(10_000_000_000_000_000n); + + const result = await service.requestPayout( + "store-1", + { amount: "9007199254740993" }, + "request-large", + ); + + expect(tx.payoutRequest.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + amountKobo: 9007199254740993n, + idempotencyKey: "request-large", + status: "PENDING", + }), + }); + expect(result).toEqual( + expect.objectContaining({ + amountRequested: "9007199254740993", + status: "PENDING", + }), + ); + expect(notifications.triggerPayoutRequested).toHaveBeenCalledWith( + ["admin-1"], + expect.objectContaining({ amountKobo: "9007199254740993" }), + ); + }); + + it("rejects invalid payout amounts before balance checks", async () => { + const { service, prisma } = makePayoutService(); + + await expect( + service.requestPayout("store-1", { amount: "0" }, "invalid-1"), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.requestPayout("store-1", { amount: "-1" }, "invalid-2"), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.requestPayout("store-1", { amount: "1500.50" }, "invalid-3"), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.requestPayout( + "store-1", + { + amount: 150000 as unknown as string, + }, + "invalid-4", + ), + ).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("requires a bounded idempotency key", async () => { + const { service, prisma } = makePayoutService(); + + await expect( + service.requestPayout("store-1", { amount: "500" }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "PAYOUT_IDEMPOTENCY_KEY_INVALID", + }), + }); + await expect( + service.requestPayout("store-1", { amount: "500" }, "x".repeat(129)), + ).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("returns the committed reservation without duplicate notifications", async () => { + const { service, tx, notifications, payoutRequests } = makePayoutService(); + + const first = await service.requestPayout( + "store-1", + { amount: "500" }, + "same-request", + ); + const retry = await service.requestPayout( + "store-1", + { amount: "500" }, + "same-request", + ); + + expect(retry).toEqual( + expect.objectContaining({ requestId: first.requestId, reused: true }), + ); + expect(payoutRequests).toHaveLength(1); + expect(tx.payoutRequest.create).toHaveBeenCalledTimes(1); + expect(notifications.triggerPayoutRequested).toHaveBeenCalledTimes(1); + expect( + notifications.triggerMerchantPayoutRequestedConfirmation, + ).toHaveBeenCalledTimes(1); + }); + + it("rejects an idempotency key reused for a different amount", async () => { + const { service } = makePayoutService(); + await service.requestPayout( + "store-1", + { amount: "400" }, + "conflicting-request", + ); + + await expect( + service.requestPayout( + "store-1", + { amount: "500" }, + "conflicting-request", + ), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "PAYOUT_IDEMPOTENCY_CONFLICT", + }), + }); + }); + + it("serializes same-store payout reservations so concurrent requests cannot over-reserve", async () => { + const { service, tx, payoutRequests } = makePayoutService(); + + const [first, second] = await Promise.allSettled([ + service.requestPayout("store-1", { amount: "1000" }, "request-1"), + service.requestPayout("store-1", { amount: "1000" }, "request-2"), + ]); + + const succeeded = [first, second].filter( + (result) => result.status === "fulfilled", + ); + const failed = [first, second].filter( + (result) => result.status === "rejected", + ); + + expect(succeeded).toHaveLength(1); + expect(failed).toHaveLength(1); + expect(failed[0]).toMatchObject({ + reason: expect.any(ConflictException), + }); + expect(payoutRequests).toHaveLength(1); + expect( + payoutRequests.reduce((total, request) => total + request.amountKobo, 0n), + ).toBe(1000n); + expect(tx.$executeRaw).toHaveBeenCalledTimes(2); + }); + + it("reserves only pending and processing payout requests", async () => { + const { service, payoutRequests, tx } = makePayoutService(); + const makeExisting = (id: string, amountKobo: bigint, status: string) => ({ + id, + storeId: "store-1", + idempotencyKey: `legacy:${id}`, + amountKobo, + status, + bankName: "044", + accountNumber: "****6789", + accountName: "Ada Store Ltd", + }); + payoutRequests.push( + makeExisting("1", 300n, "PENDING"), + makeExisting("2", 200n, "PROCESSING"), + makeExisting("3", 700n, "PROCESSED"), + makeExisting("4", 700n, "FAILED"), + makeExisting("5", 700n, "REJECTED"), + ); + + await service.requestPayout("store-1", { amount: "500" }, "request-6"); + + expect(tx.payoutRequest.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ amountKobo: 500n }), + }); + }); + + it("rejects payout requests for missing stores and missing bank details", async () => { + const { service, prisma } = makePayoutService(); + prisma.storeProfile.findUnique.mockResolvedValueOnce(null); + + await expect( + service.requestPayout("store-1", { amount: "500" }, "missing-store"), + ).rejects.toBeInstanceOf(NotFoundException); + + prisma.storeProfile.findUnique.mockResolvedValueOnce({ + userId: "owner-1", + bankAccountNumber: null, + bankCode: null, + }); + + await expect( + service.requestPayout("store-1", { amount: "500" }, "missing-bank"), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/apps/backend/src/domains/money/payment/payment.service.ts b/apps/backend/src/domains/money/payment/payment.service.ts new file mode 100644 index 00000000..cd660728 --- /dev/null +++ b/apps/backend/src/domains/money/payment/payment.service.ts @@ -0,0 +1,2812 @@ +import { + Injectable, + Logger, + BadGatewayException, + NotFoundException, + BadRequestException, + ForbiddenException, + ConflictException, + Inject, + forwardRef, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { InjectQueue } from "@nestjs/bullmq"; +import { Queue } from "bullmq"; +import { PrismaService } from "../../../prisma/prisma.service"; +import { + InitializePaymentResult, + PAYMENT_PROVIDER, + PaymentProvider, + PaymentProviderEvent, + PaymentProviderName, +} from "./providers/payment-provider.interface"; +import { + PAYMENT_PROVIDER_REGISTRY, + PaymentProviderRegistry, +} from "./providers/payment-provider.registry"; +import { OrderService } from "../../orders/order/order.service"; +import { DvaService } from "./dva/dva.service"; +import { NotificationTriggerService } from "../../social/notification/notification-trigger.service"; +import { InitializePaymentDto } from "./dto/initialize-payment.dto"; +import { RequestPayoutDto } from "./dto/request-payout.dto"; +import { LedgerService } from "../ledger/ledger.service"; +import { + PaymentStatus, + PaymentDirection, + OrderStatus, + UserRole, + NotificationType, + NotificationChannel, +} from "@twizrr/shared"; +import { + LedgerDirection, + LedgerEntryType, + PaymentAmountExceptionType, + PaymentAttemptStatus, + PaymentProviderName as PrismaPaymentProviderName, + PlatformFeeBearer, + PriceType, + Prisma, + WebhookStatus, +} from "@prisma/client"; +import * as crypto from "crypto"; +import { PAYOUT_QUEUE } from "../../../queue/queue.constants"; +import { DomainEventService } from "../../platform/domain-event/domain-event.service"; +import { OutboxService } from "../../platform/outbox/outbox.service"; +import { CommerceOutboxService } from "../../platform/outbox/commerce-outbox.service"; +import { OUTBOX_TOPIC } from "../../platform/outbox/outbox.types"; +import { StoreEarningsService } from "../earnings/store-earnings.service"; + +type PaymentDomainEventType = + | "PAYMENT_INITIALIZED" + | "PAYSTACK_WEBHOOK_RECEIVED" + | "MONNIFY_WEBHOOK_RECEIVED" + | "PAYMENT_CONFIRMED" + | "PAYMENT_RECONCILIATION_REQUIRED" + | "PAYMENT_FAILED"; + +type PaymentProcessingOutcome = + | "success" + | "failed" + | "pending" + | "reconciliation_required"; + +@Injectable() +export class PaymentService { + private readonly logger = new Logger(PaymentService.name); + constructor( + private prisma: PrismaService, + @Inject(PAYMENT_PROVIDER) + private paymentProvider: PaymentProvider, + @Inject(forwardRef(() => OrderService)) + private orderService: OrderService, + private notifications: NotificationTriggerService, + private config: ConfigService, + @InjectQueue(PAYOUT_QUEUE) private payoutQueue: Queue, + private dvaService: DvaService, + private ledgerService: LedgerService, + private domainEvents: DomainEventService, + private outbox: OutboxService, + private earningsService: StoreEarningsService, + private commerceOutbox?: CommerceOutboxService, + @Inject(PAYMENT_PROVIDER_REGISTRY) + private paymentProviders?: PaymentProviderRegistry, + ) {} + + private providerForName(provider: PaymentProviderName): PaymentProvider { + if (this.paymentProviders) { + return this.paymentProviders.getByName(provider); + } + if (this.paymentProvider.name === provider) { + return this.paymentProvider; + } + throw new Error(`Payment provider '${provider}' is not available.`); + } + + private providerForPayment(payment: { + provider?: PaymentProviderName | null; + }): PaymentProvider { + return payment.provider + ? this.providerForName(payment.provider) + : this.paymentProvider; + } + + private paymentReference(payment: { + providerReference?: string | null; + paystackReference?: string | null; + }): string { + const reference = payment.providerReference || payment.paystackReference; + if (!reference) { + throw new Error("Payment has no provider reference."); + } + return reference; + } + + private customerName(user: { + firstName?: string | null; + lastName?: string | null; + }): string | undefined { + const name = [user.firstName, user.lastName] + .filter((value): value is string => Boolean(value && value.trim())) + .join(" "); + return name || undefined; + } + + private toLegacyInitializeResponse( + response: InitializePaymentResult, + provider: PaymentProviderName, + canonicalReference: string, + ) { + return { + authorization_url: response.authorizationUrl, + ...(response.accessCode ? { access_code: response.accessCode } : {}), + // Provider echoes are not authoritative. Return the immutable Twizrr + // attempt reference that was persisted before provider submission so a + // checkout can always be reconciled to the exact attempt it started. + reference: canonicalReference, + provider: provider.toLowerCase(), + checkout_mode: provider === "MONNIFY" ? "redirect" : "inline", + }; + } + + // ────────────────────────────────────────────── + // INITIALIZE PAYMENT (idempotent by orderId) + // ────────────────────────────────────────────── + + private async appendPaymentDomainEvent( + tx: Prisma.TransactionClient, + payment: { + id: string; + orderId: string; + amountKobo?: bigint | number | null; + providerReference?: string | null; + paystackReference?: string | null; + }, + eventType: Exclude< + PaymentDomainEventType, + "PAYSTACK_WEBHOOK_RECEIVED" | "MONNIFY_WEBHOOK_RECEIVED" + >, + actorType: "USER" | "SYSTEM" | "PROVIDER", + actorId: string | null, + metadata: Record = {}, + idempotencyKey?: string, + ) { + await this.domainEvents.append(tx, { + aggregateType: "PAYMENT", + aggregateId: payment.id, + eventType, + actorType, + actorId, + source: "payment.service", + metadata: { + paymentId: payment.id, + orderId: payment.orderId, + reference: this.paymentReference(payment), + amountKobo: payment.amountKobo?.toString() ?? null, + ...metadata, + }, + idempotencyKey: + idempotencyKey ?? `payment:${payment.id}:${eventType.toLowerCase()}`, + }); + } + + async initialize( + buyerId: string, + dto: InitializePaymentDto, + idempotencyKey: string, + ) { + const order = await this.prisma.order.findUnique({ + where: { id: dto.orderId }, + }); + if (!order) throw new NotFoundException("Order not found"); + + // Ownership check: only the buyer can pay + if (order.buyerId !== buyerId) { + throw new ForbiddenException("Access denied"); + } + + if (order.status !== OrderStatus.PENDING_PAYMENT) { + throw new BadRequestException("Order is not in pending payment state"); + } + + // Generate reference and get buyer email + const buyer = await this.prisma.user.findUnique({ + where: { id: buyerId }, + }); + if (!buyer) throw new NotFoundException("Buyer not found"); + + // Callback URL from config (not hardcoded) + const frontendUrl = this.config.get( + "app.frontendUrl", + "http://localhost:3000", + ); + const callbackUrl = `${frontendUrl}/orders/payment/callback/${encodeURIComponent(order.id)}`; + + // Use the immutable order price snapshot. New orders contain product + // subtotal plus delivery only; legacy orders may also include the + // shopper-borne platform fee. Never recalculate either policy here. + const totalKobo = order.totalAmountKobo; + + // Idempotency: if payment already exists for this order, return existing + const existingPayment = await this.prisma.payment.findFirst({ + where: { + orderId: dto.orderId, + direction: PaymentDirection.INFLOW, + status: { + in: [ + PaymentStatus.INITIALIZED, + PaymentStatus.RECONCILIATION_REQUIRED, + PaymentStatus.MANUAL_REVIEW, + ], + }, + }, + include: { attempts: { orderBy: { initializedAt: "desc" } } }, + }); + + if (existingPayment) { + if (existingPayment.status !== PaymentStatus.INITIALIZED) { + throw new ConflictException({ + message: + "This payment requires reconciliation before another checkout can be started.", + code: "PAYMENT_PENDING_RECONCILIATION", + }); + } + const activeAttempt = (existingPayment.attempts ?? []).find( + (attempt) => attempt.status === PaymentAttemptStatus.INITIALIZED, + ); + const existingReference = + activeAttempt?.providerReference ?? + this.paymentReference(existingPayment); + const existingProvider = this.providerForPayment(existingPayment); + // Reconcile against Paystack first — if the existing reference already + // succeeded, finish the success path instead of issuing a fresh charge. + let alreadySucceeded = false; + try { + const verification = + await existingProvider.verifyPayment(existingReference); + if ( + verification?.status === "success" || + verification?.status === "reconciliation_required" + ) { + alreadySucceeded = true; + } + } catch (verifyErr) { + const verifyMsg = + verifyErr instanceof Error ? verifyErr.message : "unknown error"; + this.logger.warn( + `Could not verify existing ${existingProvider.name} reference ${existingReference}: ${verifyMsg}`, + ); + } + + if (alreadySucceeded) { + this.logger.log( + `Existing payment ${existingPayment.id} already succeeded on Paystack — reconciling`, + ); + const outcome = await this.processSuccessfulPayment( + existingPayment.id, + existingReference, + ); + if (outcome === "reconciliation_required") { + throw new ConflictException({ + message: + "This payment requires reconciliation before another checkout can be started.", + code: "PAYMENT_PENDING_RECONCILIATION", + }); + } + throw new ConflictException({ + message: "Payment already completed", + code: "PAYMENT_ALREADY_COMPLETED", + }); + } + + // Reuse the same Paystack reference — never blindly overwrite a + // still-payable reference. Only rotate when Paystack rejects re-init. + try { + const reusedProviderResponse = await existingProvider.initializePayment( + { + email: buyer.email, + ...(this.customerName(buyer) + ? { customerName: this.customerName(buyer) } + : {}), + amountKobo: totalKobo, + reference: existingReference, + callbackUrl, + }, + ); + const reusedResponse = this.toLegacyInitializeResponse( + reusedProviderResponse, + existingProvider.name, + existingReference, + ); + if ( + activeAttempt && + reusedProviderResponse.providerTransactionReference + ) { + await this.prisma.paymentAttempt.update({ + where: { id: activeAttempt.id }, + data: { + providerTransactionReference: + reusedProviderResponse.providerTransactionReference, + }, + }); + } + this.logger.log( + `Resuming pending payment ${existingPayment.id} with existing provider reference ${existingReference}`, + ); + return { + ...reusedResponse, + reference: existingReference, + paymentId: existingPayment.id, + message: "Resuming pending payment with existing reference", + }; + } catch (reuseErr) { + const reuseMsg = + reuseErr instanceof Error ? reuseErr.message : "unknown error"; + this.logger.warn( + `${existingProvider.name} rejected re-use of reference ${existingReference}: ${reuseMsg} — rotating to a new reference`, + ); + + const newReference = `tx-${crypto.randomUUID()}`; + const newAttempt = await this.prisma.paymentAttempt.create({ + data: { + paymentId: existingPayment.id, + provider: existingPayment.provider, + providerReference: newReference, + expectedAmountKobo: totalKobo, + }, + }); + + let freshInitializedProviderResponse: InitializePaymentResult; + try { + freshInitializedProviderResponse = + await existingProvider.initializePayment({ + email: buyer.email, + ...(this.customerName(buyer) + ? { customerName: this.customerName(buyer) } + : {}), + amountKobo: totalKobo, + reference: newReference, + callbackUrl, + }); + } catch (freshError) { + // Once a fresh reference has been sent to the provider, an exception + // is ambiguous: the provider may have accepted it before the response + // was lost. Freeze the parent payment as well as the attempt so a + // subsequent checkout cannot create another potentially payable + // provider transaction before reconciliation. + await this.prisma.$transaction(async (tx) => { + const reconciliationRequiredAt = new Date(); + await tx.paymentAttempt.updateMany({ + where: { + id: newAttempt.id, + status: PaymentAttemptStatus.INITIALIZED, + }, + data: { + status: PaymentAttemptStatus.RECONCILIATION_REQUIRED, + reconciliationRequiredAt, + }, + }); + await tx.payment.updateMany({ + where: { + id: existingPayment.id, + status: PaymentStatus.INITIALIZED, + }, + data: { status: PaymentStatus.RECONCILIATION_REQUIRED }, + }); + }); + throw freshError; + } + + await this.prisma.paymentAttempt.update({ + where: { id: newAttempt.id }, + data: { + providerTransactionReference: + freshInitializedProviderResponse.providerTransactionReference ?? + null, + }, + }); + const freshProviderResponse = this.toLegacyInitializeResponse( + freshInitializedProviderResponse, + existingProvider.name, + newReference, + ); + + return { + ...freshProviderResponse, + reference: newReference, + paymentId: existingPayment.id, + message: + "Previous reference was no longer usable - issued a fresh reference", + }; + } + } + + const paymentReference = `tx-${crypto.randomUUID()}`; + + // Persist the Twizrr operation before exposing its reference to a provider. + // If the provider accepts the request and the response is lost, a delayed + // webhook can still resolve this immutable attempt instead of becoming an + // unowned collection. + const payment = await this.prisma.$transaction(async (tx) => { + const newPayment = await tx.payment.create({ + data: { + orderId: order.id, + provider: this.paymentProvider.name as PrismaPaymentProviderName, + providerReference: paymentReference, + ...(this.paymentProvider.name === "PAYSTACK" + ? { paystackReference: paymentReference } + : {}), + amountKobo: totalKobo, + currency: order.currency, + status: PaymentStatus.INITIALIZED, + direction: PaymentDirection.INFLOW, + idempotencyKey: idempotencyKey || paymentReference, + }, + }); + + await tx.paymentAttempt.create({ + data: { + paymentId: newPayment.id, + provider: this.paymentProvider.name as PrismaPaymentProviderName, + providerReference: paymentReference, + expectedAmountKobo: totalKobo, + }, + }); + + await tx.paymentEvent.create({ + data: { + paymentId: newPayment.id, + eventType: "INITIALIZED", + payload: { + reference: paymentReference, + amountKobo: totalKobo.toString(), + orderId: order.id, + }, + }, + }); + + await this.appendPaymentDomainEvent( + tx, + newPayment, + "PAYMENT_INITIALIZED", + "USER", + buyerId, + { + orderId: order.id, + reference: paymentReference, + amountKobo: totalKobo.toString(), + }, + `payment:initialized:${newPayment.id}`, + ); + + await this.domainEvents.append(tx, { + aggregateType: "ORDER", + aggregateId: order.id, + eventType: "PAYMENT_PENDING", + actorType: "USER", + actorId: buyerId, + source: "payment.service", + metadata: { + orderId: order.id, + paymentId: newPayment.id, + reference: paymentReference, + amountKobo: totalKobo.toString(), + }, + idempotencyKey: `order:${order.id}:payment-pending:${newPayment.id}`, + }); + + await this.ledgerService.recordPaymentInitialized( + { + paymentId: newPayment.id, + orderId: order.id, + buyerId, + storeId: order.storeId, + amountKobo: BigInt(totalKobo), + reference: paymentReference, + }, + tx, + ); + + return newPayment; + }); + + let providerResponse: InitializePaymentResult; + try { + providerResponse = await this.paymentProvider.initializePayment({ + email: buyer.email, + ...(this.customerName(buyer) + ? { customerName: this.customerName(buyer) } + : {}), + amountKobo: totalKobo, + reference: paymentReference, + callbackUrl, + }); + } catch (error) { + // Once a stable reference has been sent, an exception is ambiguous: the + // provider may have accepted the operation before the response was lost. + // Freeze the durable attempt so checkout cannot create another payable + // operation until provider reconciliation establishes the outcome. + try { + await this.markPaymentReconciliationRequired( + payment, + paymentReference, + { + providerStatus: "INITIALIZATION_OUTCOME_UNKNOWN", + providerAmountKobo: totalKobo, + reason: "PROVIDER_STATUS", + }, + ); + } catch (reconciliationError) { + const message = + reconciliationError instanceof Error + ? reconciliationError.message + : "unknown error"; + this.logger.error( + `Could not freeze payment ${payment.id} after an ambiguous provider initialization: ${message}`, + ); + } + throw error; + } + + if (providerResponse.providerTransactionReference) { + try { + await this.prisma.$transaction(async (tx) => { + // The provider transaction identifier is learned after submission. + // Set it only while absent; never rotate an existing binding. + await tx.paymentAttempt.updateMany({ + where: { + paymentId: payment.id, + providerReference: paymentReference, + providerTransactionReference: null, + }, + data: { + providerTransactionReference: + providerResponse.providerTransactionReference, + }, + }); + await tx.payment.updateMany({ + where: { + id: payment.id, + providerTransactionReference: null, + }, + data: { + providerTransactionReference: + providerResponse.providerTransactionReference, + }, + }); + }); + } catch (error) { + // The provider accepted the request but Twizrr could not persist the + // returned operation ID. Reconciliation by the already-durable Twizrr + // reference is required; returning checkout data would be unsafe. + try { + await this.markPaymentReconciliationRequired( + payment, + paymentReference, + { + providerStatus: "INITIALIZATION_PERSISTENCE_FAILED", + providerAmountKobo: totalKobo, + reason: "PROVIDER_STATUS", + }, + ); + } catch (reconciliationError) { + const message = + reconciliationError instanceof Error + ? reconciliationError.message + : "unknown error"; + this.logger.error( + `Could not freeze payment ${payment.id} after provider reference persistence failed: ${message}`, + ); + } + throw error; + } + } + + const paymentResponse = this.toLegacyInitializeResponse( + providerResponse, + this.paymentProvider.name, + paymentReference, + ); + + this.logger.log(`Payment ${payment.id} initialized for order ${order.id}`); + + return { + ...paymentResponse, + paymentReference, + paymentId: payment.id, + }; + } + + async initializeRemainingBalance( + buyerId: string, + dto: InitializePaymentDto, + _idempotencyKey: string, + ) { + if ( + !this.config.get( + "PAYMENT_REMAINING_BALANCE_COLLECTION_ENABLED", + false, + ) + ) { + throw new ConflictException({ + message: "Remaining-balance checkout is not available yet.", + code: "REMAINING_BALANCE_COLLECTION_DISABLED", + }); + } + const exception = await this.prisma.paymentAmountException.findFirst({ + where: { + exceptionType: PaymentAmountExceptionType.UNDERPAID, + status: { + in: ["RECONCILIATION_REQUIRED", "REMAINING_BALANCE_PENDING"], + }, + payment: { orderId: dto.orderId }, + }, + include: { + payment: { include: { order: true } }, + remainingBalanceAttempt: true, + }, + }); + if (!exception) { + throw new ConflictException({ + message: "This order has no confirmed remaining balance to collect.", + code: "REMAINING_BALANCE_NOT_AVAILABLE", + }); + } + const { payment } = exception; + if (payment.order.buyerId !== buyerId) { + throw new ForbiddenException("Access denied"); + } + if (payment.order.status !== OrderStatus.PENDING_PAYMENT) { + throw new ConflictException({ + message: "This order is no longer awaiting payment.", + code: "ORDER_STATE_CONFLICT", + }); + } + const remainingAmountKobo = + exception.expectedAmountKobo - exception.receivedAmountKobo; + if ( + exception.receivedAmountKobo <= 0n || + remainingAmountKobo <= 0n || + exception.receivedAmountKobo + remainingAmountKobo !== + exception.expectedAmountKobo + ) { + throw new ConflictException({ + message: "This payment requires support review.", + code: "INVALID_REMAINING_BALANCE", + }); + } + + const buyer = await this.prisma.user.findUnique({ + where: { id: buyerId }, + }); + if (!buyer) throw new NotFoundException("Buyer not found"); + const provider = this.providerForPayment(payment); + let attempt = exception.remainingBalanceAttempt; + + if (attempt) { + throw new ConflictException({ + message: + "The remaining payment is being reconciled. Please do not start another payment.", + code: "REMAINING_BALANCE_RECONCILIATION_REQUIRED", + }); + } + + const reference = `tx-${crypto.randomUUID()}`; + attempt = await this.prisma.$transaction(async (tx) => { + const created = await tx.paymentAttempt.create({ + data: { + paymentId: payment.id, + provider: payment.provider, + providerReference: reference, + expectedAmountKobo: remainingAmountKobo, + }, + }); + const claimed = await tx.paymentAmountException.updateMany({ + where: { + id: exception.id, + status: "RECONCILIATION_REQUIRED", + remainingBalanceAttemptId: null, + }, + data: { + status: "REMAINING_BALANCE_PENDING", + remainingBalanceAttemptId: created.id, + }, + }); + if (claimed.count !== 1) { + throw new ConflictException({ + message: "A remaining payment checkout already exists.", + code: "REMAINING_BALANCE_ALREADY_INITIALIZED", + }); + } + return created; + }); + + const frontendUrl = this.config.get( + "app.frontendUrl", + "http://localhost:3000", + ); + const callbackUrl = `${frontendUrl}/orders/payment/callback/${encodeURIComponent(payment.orderId)}`; + let providerResponse: InitializePaymentResult; + try { + providerResponse = await provider.initializePayment({ + email: buyer.email, + ...(this.customerName(buyer) + ? { customerName: this.customerName(buyer) } + : {}), + amountKobo: remainingAmountKobo, + reference: attempt.providerReference, + callbackUrl, + metadata: { + orderId: payment.orderId, + paymentId: payment.id, + purpose: "remaining_balance", + }, + }); + } catch (error) { + await this.prisma.paymentAttempt.updateMany({ + where: { + id: attempt.id, + status: PaymentAttemptStatus.INITIALIZED, + }, + data: { + status: PaymentAttemptStatus.RECONCILIATION_REQUIRED, + reconciliationRequiredAt: new Date(), + }, + }); + throw error; + } + + await this.prisma.paymentAttempt.updateMany({ + where: { + id: attempt.id, + status: PaymentAttemptStatus.INITIALIZED, + providerTransactionReference: null, + }, + data: { + providerTransactionReference: + providerResponse.providerTransactionReference ?? null, + }, + }); + return { + ...this.toLegacyInitializeResponse( + providerResponse, + provider.name, + attempt.providerReference, + ), + paymentId: payment.id, + remainingAmountKobo: remainingAmountKobo.toString(), + }; + } + + async getRemainingBalance(buyerId: string, orderId: string) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + select: { id: true, buyerId: true, status: true }, + }); + if (!order) throw new NotFoundException("Order not found"); + if (order.buyerId !== buyerId) { + throw new ForbiddenException("Access denied"); + } + const exception = await this.prisma.paymentAmountException.findFirst({ + where: { + exceptionType: PaymentAmountExceptionType.UNDERPAID, + payment: { orderId }, + }, + include: { remainingBalanceAttempt: true }, + orderBy: { detectedAt: "desc" }, + }); + if (!exception || exception.status === "RESOLVED") { + return { available: false, status: "NONE" }; + } + const remainingAmountKobo = + exception.expectedAmountKobo - exception.receivedAmountKobo; + const featureEnabled = this.config.get( + "PAYMENT_REMAINING_BALANCE_COLLECTION_ENABLED", + false, + ); + const available = + featureEnabled && + order.status === OrderStatus.PENDING_PAYMENT && + remainingAmountKobo > 0n && + exception.status === "RECONCILIATION_REQUIRED" && + !exception.remainingBalanceAttemptId; + return { + available, + status: available ? "ACTION_REQUIRED" : "UNDER_REVIEW", + remainingAmountKobo: remainingAmountKobo.toString(), + provider: exception.provider.toLowerCase(), + }; + } + + // ────────────────────────────────────────────── + // WEBHOOK HANDLER (idempotent processing) + // ────────────────────────────────────────────── + + async handleWebhook( + providerNameOrPayload: unknown, + payloadOrRawBody?: unknown | Buffer | string, + rawBodyOrSignature?: Buffer | string | string[] | undefined, + explicitSignature?: string | string[] | undefined, + ) { + const explicitProvider = typeof providerNameOrPayload === "string"; + const providerName = ( + explicitProvider ? providerNameOrPayload : "PAYSTACK" + ) as PaymentProviderName; + const payload = explicitProvider ? payloadOrRawBody : providerNameOrPayload; + const rawBody = ( + explicitProvider ? rawBodyOrSignature : payloadOrRawBody + ) as Buffer | string | undefined; + const signature = ( + explicitProvider ? explicitSignature : rawBodyOrSignature + ) as string | string[] | undefined; + const provider = this.providerForName(providerName); + // Every payment callback is public and must fail closed. Keeping the + // compatibility overload must never make an unsigned invocation trusted. + if (!rawBody || !provider.verifyWebhookSignature({ rawBody, signature })) { + this.logger.warn("Invalid payment webhook signature - ignoring"); + return { status: "ignored" }; + } + + const providerEvent = provider.parseWebhookEvent(payload); + this.logger.log(`Webhook received: ${providerEvent.eventType}`); + const webhookEvent = await this.startWebhookEvent(providerEvent); + + if (!webhookEvent.shouldProcess) { + this.logger.log( + `Duplicate ${provider.name.toLowerCase()} webhook skipped: ${webhookEvent.eventId}`, + ); + return { status: "duplicate" }; + } + + try { + const result = await this.dispatchPaymentWebhook(providerEvent); + await this.markWebhookProcessed(webhookEvent.id); + return result; + } catch (error) { + await this.markWebhookFailed(webhookEvent.id, error); + throw error; + } + } + + private async dispatchPaymentWebhook(providerEvent: PaymentProviderEvent) { + const payload = providerEvent.rawPayload as any; + const event = providerEvent.eventType; + + if ( + (providerEvent.provider === "PAYSTACK" && event === "charge.success") || + (providerEvent.provider === "MONNIFY" && + event === "SUCCESSFUL_TRANSACTION") + ) { + return this.handleChargeSuccess( + providerEvent.reference, + providerEvent.provider, + ); + } + + if (event === "dedicatedaccount.assign.success") { + await this.dvaService.handleDvaAssignSuccess(payload.data); + return { status: "received" }; + } + if (event === "dedicatedaccount.assign.failed") { + await this.dvaService.handleDvaAssignFailed(payload.data); + return { status: "received" }; + } + + if ( + event === "transfer.success" || + event === "transfer.failed" || + event === "transfer.reversed" + ) { + return this.handleTransferEvent(payload); + } + + this.logger.log(`Ignoring webhook event: ${event}`); + return { status: "ignored" }; + } + + private async startWebhookEvent( + providerEvent: PaymentProviderEvent, + ): Promise<{ + id: string; + eventId: string; + shouldProcess: boolean; + }> { + const provider = providerEvent.provider.toLowerCase(); + const payload = providerEvent.rawPayload as any; + + try { + const created = await this.prisma.$transaction(async (tx) => { + const webhookEvent = await tx.webhookEvent.create({ + data: { + provider, + eventId: providerEvent.eventId, + eventType: providerEvent.eventType, + reference: providerEvent.reference, + payload: this.sanitizeWebhookPayload(payload), + status: WebhookStatus.PROCESSING, + }, + }); + + await this.domainEvents.append(tx, { + aggregateType: "WEBHOOK", + aggregateId: webhookEvent.id, + eventType: + providerEvent.provider === "MONNIFY" + ? "MONNIFY_WEBHOOK_RECEIVED" + : "PAYSTACK_WEBHOOK_RECEIVED", + actorType: "PROVIDER", + actorId: null, + source: `${provider}.webhook`, + metadata: { + provider, + eventId: providerEvent.eventId, + eventType: providerEvent.eventType, + reference: providerEvent.reference, + }, + idempotencyKey: `webhook:${provider}:${providerEvent.eventId}:received`, + }); + + return webhookEvent; + }); + + return { + id: created.id, + eventId: created.eventId, + shouldProcess: true, + }; + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + const existing = await this.prisma.webhookEvent.findUnique({ + where: { + provider_eventId: { + provider, + eventId: providerEvent.eventId, + }, + }, + }); + + if (existing?.status === WebhookStatus.FAILED) { + const retry = await this.prisma.webhookEvent.updateMany({ + where: { id: existing.id, status: WebhookStatus.FAILED }, + data: { + status: WebhookStatus.PROCESSING, + error: null, + retryCount: { increment: 1 }, + payload: this.sanitizeWebhookPayload(payload), + }, + }); + + if (retry.count !== 1) { + return { + id: existing.id, + eventId: providerEvent.eventId, + shouldProcess: false, + }; + } + + const retryEvent = await this.prisma.webhookEvent.findUniqueOrThrow({ + where: { id: existing.id }, + }); + + return { + id: retryEvent.id, + eventId: retryEvent.eventId, + shouldProcess: true, + }; + } + + return { + id: existing?.id || "", + eventId: providerEvent.eventId, + shouldProcess: false, + }; + } + + throw error; + } + } + + private async markWebhookProcessed(id: string): Promise { + await this.prisma.webhookEvent.update({ + where: { id }, + data: { + status: WebhookStatus.PROCESSED, + processedAt: new Date(), + error: null, + }, + }); + } + + private async markWebhookFailed(id: string, error: unknown): Promise { + await this.prisma.webhookEvent.update({ + where: { id }, + data: { + status: WebhookStatus.FAILED, + error: error instanceof Error ? error.message : String(error), + }, + }); + } + + private sanitizeWebhookPayload(payload: any): Prisma.InputJsonValue { + const data = payload?.data || payload?.eventData || {}; + return { + event: payload?.event || payload?.eventType || "unknown", + data: { + id: data.id, + reference: data.reference || data.paymentReference, + transactionReference: data.transactionReference, + transferCode: data.transfer_code, + customerCode: data.customer?.customer_code || data.customer_code, + eventId: data.event_id, + status: data.status, + amount: data.amount || data.amountPaid, + currency: data.currency || data.currencyCode, + gatewayResponse: data.gateway_response, + paidAt: data.paid_at || data.paidOn, + channel: data.channel || data.paymentMethod, + }, + } as Prisma.InputJsonValue; + } + + /** + * Handle charge.success — buyer payment confirmed by Paystack. + */ + private async handleChargeSuccess( + reference: string | undefined, + provider: PaymentProviderName, + ) { + if (!reference) { + this.logger.warn("Webhook missing reference"); + return { status: "missing_reference" }; + } + + const attempt = await this.prisma.paymentAttempt.findUnique({ + where: { providerReference: reference }, + include: { + payment: { include: { order: true } }, + amountException: { select: { status: true } }, + }, + }); + + const payment = attempt?.payment; + + if (!attempt || !payment) { + this.logger.warn(`No payment found for reference: ${reference}`); + return { status: "unknown_reference" }; + } + + if (attempt.provider !== provider) { + this.logger.warn(`Payment provider mismatch for reference: ${reference}`); + return { status: "provider_mismatch" }; + } + + // Manual review is an operator-owned state. A webhook may be acknowledged, + // but it must not bypass the review by mutating the order, payment or ledger. + if (payment.status === PaymentStatus.MANUAL_REVIEW) { + return { status: "requires_review" }; + } + + // A late collection can arrive after a previously confirmed failure. It is + // unsafe to run the normal success path because that path assumes a payable + // INITIALIZED parent. Verify this exact attempt and route any collected or + // unknown outcome to reconciliation without crediting the order. + if (payment.status === PaymentStatus.FAILED) { + const outcome = await this.inspectAdditionalPaymentAttempt( + payment, + attempt, + ); + return { + status: outcome === "already_processed" ? "failed" : outcome, + }; + } + + // Idempotency: skip if already processed + if (payment.status === PaymentStatus.SUCCESS) { + if (attempt.amountException?.status === "RESOLVED") { + return { status: "already_processed" }; + } + if (attempt.status !== PaymentAttemptStatus.SUCCESS) { + const outcome = await this.inspectAdditionalPaymentAttempt( + payment, + attempt, + ); + if (outcome !== "already_processed") { + return { status: outcome }; + } + } + await this.ensureSuccessfulPaymentOrderIsPaid(payment, reference); + this.logger.log(`Payment ${payment.id} already processed, skipping`); + return { status: "already_processed" }; + } + + try { + const outcome = await this.processSuccessfulPayment( + payment.id, + reference, + ); + if (outcome === "reconciliation_required") { + return { status: "reconciliation_required" }; + } + if (outcome === "failed") { + return { status: "failed" }; + } + if (outcome === "pending") { + return { status: "pending" }; + } + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + this.logger.error( + `Webhook processing failed for ${reference}: ${message}`, + ); + throw err; + } + + return { status: "received" }; + } + + /** + * Handle transfer.success / transfer.failed / transfer.reversed + * These fire after the platform initiates a payout to a merchant's bank. + */ + private async handleTransferEvent(payload: any) { + const event = payload.event as string; + const transferCode = payload.data?.transfer_code; + + if (!transferCode) { + this.logger.warn(`Transfer webhook missing transfer_code`); + return { status: "missing_identifiers" }; + } + + // Find the payout record by transfer code + const payout = await this.prisma.payout.findFirst({ + where: { + paystackTransferCode: transferCode, + }, + include: { + order: { + select: { id: true }, + }, + store: { + select: { id: true, bankCode: true, bankAccountNumber: true }, + }, + }, + }); + + if (!payout) { + this.logger.warn(`No payout found for transfer_code=${transferCode}`); + return { status: "unknown_transfer" }; + } + + if (event === "transfer.success") { + const wasCompleted = payout.status === "COMPLETED"; + await this.prisma.$transaction(async (tx) => { + await tx.payout.update({ + where: { id: payout.id }, + data: { + status: "COMPLETED", + completedAt: new Date(), + }, + }); + + await tx.order.update({ + where: { id: payout.orderId }, + data: { payoutStatus: "COMPLETED" }, + }); + + if (!wasCompleted) { + if (payout.settlementLegId) { + await this.ledgerService.recordEntry( + { + entryType: LedgerEntryType.PAYOUT_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo: BigInt(payout.amountKobo), + orderId: payout.orderId, + storeId: payout.storeId, + payoutId: payout.id, + settlementLegId: payout.settlementLegId, + reference: transferCode, + idempotencyKey: `settlement-leg:${payout.settlementLegId}:payout-completed`, + }, + tx, + ); + } else { + await this.ledgerService.recordPayoutCompleted( + { + payoutId: payout.id, + orderId: payout.orderId, + storeId: payout.storeId, + amountKobo: BigInt(payout.amountKobo), + reference: transferCode, + }, + tx, + ); + } + } + }); + + await this.notifications.triggerPayoutCompleted(payout.storeId, { + amountKobo: payout.amountKobo.toString(), + orderRef: payout.orderId.slice(0, 8).toUpperCase(), + bankName: "Bank Account", // Can be dynamically expanded + }); + } else { + // transfer.failed or transfer.reversed + const failureReason = payload.data?.reason || event; + const wasFailed = payout.status === "FAILED"; + await this.prisma.$transaction(async (tx) => { + await tx.payout.update({ + where: { id: payout.id }, + data: { + status: "FAILED", + failureReason, + }, + }); + + await tx.order.update({ + where: { id: payout.orderId }, + data: { payoutStatus: "FAILED" }, + }); + + if (!wasFailed) { + await this.ledgerService.recordPayoutFailed( + { + payoutId: payout.id, + orderId: payout.orderId, + storeId: payout.storeId, + amountKobo: BigInt(payout.amountKobo), + reason: failureReason, + }, + tx, + ); + } + }); + + await this.notifications.triggerPayoutFailed(payout.storeId, { + orderRef: payout.orderId.slice(0, 8).toUpperCase(), + }); + } + + this.logger.log( + `Transfer ${event} processed for payout ${payout.id} (order: ${payout.orderId})`, + ); + + return { status: "received" }; + } + + // ────────────────────────────────────────────── + // MANUAL VERIFICATION (Fallback for no webhook) + // ────────────────────────────────────────────── + + async verifyPayment(buyerId: string, reference: string) { + const attempt = await this.prisma.paymentAttempt.findUnique({ + where: { providerReference: reference }, + include: { + payment: { include: { order: true } }, + amountException: { select: { status: true } }, + }, + }); + const payment = attempt?.payment; + if (!attempt || !payment) throw new NotFoundException("Payment not found"); + if (payment.order.buyerId !== buyerId) { + throw new ForbiddenException("Access denied"); + } + + if (payment.status === PaymentStatus.SUCCESS) { + if (attempt.amountException?.status === "RESOLVED") { + return { status: "already_verified", paymentId: payment.id }; + } + if (attempt.status !== PaymentAttemptStatus.SUCCESS) { + const outcome = await this.inspectAdditionalPaymentAttempt( + payment, + attempt, + ); + if (outcome === "reconciliation_required") { + return { status: "requires_review", paymentId: payment.id }; + } + if (outcome === "pending") { + return { status: "pending", paymentId: payment.id }; + } + } + await this.ensureSuccessfulPaymentOrderIsPaid(payment, reference); + return { status: "already_verified", paymentId: payment.id }; + } + + if (payment.status === PaymentStatus.MANUAL_REVIEW) { + return { status: "requires_review", paymentId: payment.id }; + } + + if (payment.status === PaymentStatus.FAILED) { + const outcome = await this.inspectAdditionalPaymentAttempt( + payment, + attempt, + ); + if (outcome === "reconciliation_required") { + return { status: "requires_review", paymentId: payment.id }; + } + if (outcome === "pending") { + return { status: "pending", paymentId: payment.id }; + } + throw new BadRequestException("Verification failed"); + } + + try { + const outcome = await this.processSuccessfulPayment( + payment.id, + reference, + ); + if (outcome === "reconciliation_required") { + return { status: "requires_review", paymentId: payment.id }; + } + if (outcome === "failed") { + throw new BadRequestException("Verification failed"); + } + if (outcome === "pending") { + return { status: "pending", paymentId: payment.id }; + } + return { status: "verified", paymentId: payment.id }; + } catch (err) { + if (err instanceof BadRequestException) { + throw err; + } + this.logger.error(`Manual verification failed for ${reference}`, err); + throw new BadRequestException("Verification failed"); + } + } + + // ────────────────────────────────────────────── + // PROCESS SUCCESSFUL PAYMENT (verify + transition) + // ────────────────────────────────────────────── + + /** + * Re-checks the provider state for an already-recorded amount exception. + * This is intentionally observation-only: a mismatch can never be resolved + * into a paid order by an admin refresh, and no refund is initiated here. + */ + async inspectPaymentAmountException(exceptionId: string) { + const exception = await this.prisma.paymentAmountException.findUnique({ + where: { id: exceptionId }, + select: { + id: true, + provider: true, + providerReference: true, + receivedAmountKobo: true, + status: true, + }, + }); + if (!exception) { + throw new NotFoundException("Payment amount exception not found"); + } + + let verification; + try { + verification = await this.providerForPayment(exception).verifyPayment( + exception.providerReference, + ); + } catch (error) { + this.logger.error( + `Payment amount exception provider re-check failed (exceptionId=${exception.id}, provider=${exception.provider})`, + ); + throw new BadGatewayException( + "Could not reach the payment provider to re-check this exception.", + ); + } + + return { + exceptionId: exception.id, + provider: exception.provider, + status: exception.status, + providerStatus: verification.status, + observedAmountKobo: verification.amountKobo.toString(), + matchesRecordedAmount: + verification.amountKobo === exception.receivedAmountKobo, + }; + } + + private async appendPaymentNotificationOutboxes( + tx: Prisma.TransactionClient, + payment: { id: string; orderId: string; amountKobo: bigint }, + reference: string, + ): Promise { + const order = await tx.order.findUnique({ + where: { id: payment.orderId }, + include: { product: true, user: true }, + }); + + if (!order || !order.storeId) { + return; + } + + if (order.productId !== null && order.quantity !== null) { + const productName = order.product?.name || "Product"; + const buyerName = ( + (order.user?.firstName || "Buyer") + + " " + + (order.user?.lastName || "") + ).trim(); + const storeAmountKobo = this.calculateStoreNotificationAmount( + order, + order.platformFeeBearer, + ); + const buyerData = { + orderId: payment.orderId, + reference, + productName, + buyerName, + quantity: order.quantity, + amountKobo: payment.amountKobo.toString(), + storeAmountKobo: storeAmountKobo.toString(), + ...(order.deliveryAddress + ? { deliveryAddress: order.deliveryAddress } + : {}), + }; + const storeData = { + orderId: payment.orderId, + reference, + productName, + quantity: order.quantity, + amountKobo: storeAmountKobo.toString(), + isMerchantId: true, + }; + + await this.appendPaymentNotificationOutbox(tx, payment, { + recipientUserId: order.buyerId, + notificationType: NotificationType.ORDER_PURCHASE_CONFIRMED, + title: "Payment confirmed!", + body: "Your order is being prepared. You will receive a delivery code when twizrr starts delivery.", + data: buyerData, + channels: [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + dedupeKey: + "payment:" + + payment.id + + ":notification:" + + order.buyerId + + ":purchase-confirmed", + }); + await this.appendPaymentNotificationOutbox(tx, payment, { + recipientUserId: order.storeId, + notificationType: NotificationType.ORDER_PURCHASE_RECEIVED, + title: "New order received!", + body: + productName + + " x " + + order.quantity + + " - NGN " + + this.formatKoboForNotification(storeAmountKobo) + + ". Check your dashboard to prepare the order.", + data: storeData, + channels: [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + dedupeKey: + "payment:" + + payment.id + + ":notification:" + + order.storeId + + ":purchase-received", + }); + await this.outbox.append(tx, { + topic: OUTBOX_TOPIC.WHATSAPP_DIRECT_ORDER_NOTIFICATION_REQUESTED, + version: 1, + aggregateType: "PAYMENT", + aggregateId: payment.id, + idempotencyKey: + "payment:" + payment.id + ":whatsapp:direct-order:" + order.storeId, + payload: { + storeId: order.storeId, + orderData: { + orderId: payment.orderId, + productName, + quantity: order.quantity, + amountKobo: storeAmountKobo.toString(), + }, + }, + }); + return; + } + + const baseData = { + orderId: payment.orderId, + reference, + amountKobo: payment.amountKobo.toString(), + }; + await this.appendPaymentNotificationOutbox(tx, payment, { + recipientUserId: order.buyerId, + notificationType: NotificationType.PAYMENT_CONFIRMED, + title: "Payment Successful", + body: "Your payment was successful.", + data: baseData, + channels: [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + dedupeKey: + "payment:" + + payment.id + + ":notification:" + + order.buyerId + + ":payment-confirmed", + }); + await this.appendPaymentNotificationOutbox(tx, payment, { + recipientUserId: order.storeId, + notificationType: NotificationType.PAYMENT_CONFIRMED, + title: "Payment Received", + body: "Payment received for an order.", + data: { ...baseData, isMerchantId: true }, + channels: [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + dedupeKey: + "payment:" + + payment.id + + ":notification:" + + order.storeId + + ":payment-confirmed", + }); + } + + private async appendPaymentNotificationOutbox( + tx: Prisma.TransactionClient, + payment: { id: string }, + input: { + recipientUserId: string; + notificationType: string; + title: string; + body: string; + data: Prisma.InputJsonObject; + channels: NotificationChannel[]; + dedupeKey: string; + }, + ): Promise { + await this.outbox.append(tx, { + topic: OUTBOX_TOPIC.NOTIFICATION_DISPATCH_REQUESTED, + version: 1, + aggregateType: "PAYMENT", + aggregateId: payment.id, + idempotencyKey: input.dedupeKey, + payload: { + recipientUserId: input.recipientUserId, + notificationType: input.notificationType, + title: input.title, + body: input.body, + data: input.data, + channels: input.channels, + dedupeKey: input.dedupeKey, + }, + }); + } + private async markPaymentReconciliationRequired( + payment: { + id: string; + orderId: string; + amountKobo: bigint; + provider: PrismaPaymentProviderName; + order?: { buyerId: string; storeId: string | null }; + providerReference?: string | null; + paystackReference?: string | null; + }, + reference: string, + input: { + providerStatus: string; + providerAmountKobo: bigint; + reason: "PROVIDER_STATUS" | "AMOUNT_MISMATCH"; + providerTransactionReference?: string | null; + }, + ): Promise { + await this.prisma.$transaction(async (tx) => { + const markedAt = new Date(); + const hasCollectedAmountMismatch = + input.providerAmountKobo > 0n && + input.providerAmountKobo !== payment.amountKobo; + const update = await tx.payment.updateMany({ + where: { id: payment.id, status: PaymentStatus.INITIALIZED }, + data: { status: PaymentStatus.RECONCILIATION_REQUIRED }, + }); + + await tx.paymentAttempt.updateMany({ + where: { + paymentId: payment.id, + providerReference: reference, + status: PaymentAttemptStatus.INITIALIZED, + }, + data: { + status: PaymentAttemptStatus.RECONCILIATION_REQUIRED, + reconciliationRequiredAt: markedAt, + }, + }); + + if (hasCollectedAmountMismatch) { + if (!payment.order) { + throw new Error( + "Payment order context is required to record an amount exception", + ); + } + const attempt = await tx.paymentAttempt.findFirst({ + where: { + paymentId: payment.id, + providerReference: reference, + }, + select: { id: true }, + }); + if (!attempt) { + throw new Error( + `Payment attempt for mismatch reference ${reference} was not found`, + ); + } + + const exceptionType = + input.providerAmountKobo < payment.amountKobo + ? PaymentAmountExceptionType.UNDERPAID + : PaymentAmountExceptionType.OVERPAID; + const exception = await tx.paymentAmountException.upsert({ + where: { paymentAttemptId: attempt.id }, + create: { + paymentId: payment.id, + paymentAttemptId: attempt.id, + provider: payment.provider, + providerReference: reference, + providerTransactionReference: + input.providerTransactionReference ?? null, + expectedAmountKobo: payment.amountKobo, + receivedAmountKobo: input.providerAmountKobo, + exceptionType, + }, + // Financial evidence is immutable. Retries only return the already + // recorded exception; resolution is a future guarded operation. + // Keep this update non-empty so Prisma uses an atomic upsert path + // instead of a read-then-insert path under concurrent webhooks. + update: { updatedAt: markedAt }, + }); + + await this.ledgerService.recordPaymentAmountExceptionReceived( + { + paymentAmountExceptionId: exception.id, + paymentId: payment.id, + orderId: payment.orderId, + buyerId: payment.order.buyerId, + storeId: payment.order.storeId, + amountKobo: input.providerAmountKobo, + expectedAmountKobo: payment.amountKobo, + exceptionType, + provider: payment.provider, + reference, + }, + tx, + ); + + if ( + exceptionType === PaymentAmountExceptionType.UNDERPAID && + this.config.get( + "PAYMENT_REMAINING_BALANCE_COLLECTION_ENABLED", + false, + ) + ) { + await this.appendPaymentNotificationOutbox(tx, payment, { + recipientUserId: payment.order.buyerId, + notificationType: NotificationType.PAYMENT_ACTION_REQUIRED, + title: "Complete your payment", + body: "We received part of your payment. Complete the remaining balance to confirm your order.", + data: { + orderId: payment.orderId, + reference, + remainingAmountKobo: ( + payment.amountKobo - input.providerAmountKobo + ).toString(), + }, + channels: [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + dedupeKey: `payment:${payment.id}:exception:${exception.id}:remaining-balance-required`, + }); + } + } + + if (update.count === 0) { + return; + } + + const metadata = { + reference, + reason: input.reason, + providerStatus: input.providerStatus, + providerAmountKobo: input.providerAmountKobo.toString(), + expectedAmountKobo: payment.amountKobo.toString(), + ...(hasCollectedAmountMismatch + ? { + exceptionType: + input.providerAmountKobo < payment.amountKobo + ? "UNDERPAID" + : "OVERPAID", + } + : {}), + }; + await tx.paymentEvent.create({ + data: { + paymentId: payment.id, + eventType: "RECONCILIATION_REQUIRED", + payload: metadata, + }, + }); + await this.appendPaymentDomainEvent( + tx, + payment, + "PAYMENT_RECONCILIATION_REQUIRED", + "PROVIDER", + null, + metadata, + `payment:reconciliation-required:${payment.id}:${reference}`, + ); + }); + } + + private async inspectAdditionalPaymentAttempt( + payment: Prisma.PaymentGetPayload<{ include: { order: true } }>, + attempt: { + id: string; + provider: PrismaPaymentProviderName; + providerReference: string; + }, + ): Promise<"already_processed" | "pending" | "reconciliation_required"> { + const provider = this.providerForName(attempt.provider); + const verification = await provider.verifyPayment( + attempt.providerReference, + ); + if (["pending", "processing", "ongoing"].includes(verification.status)) { + return "pending"; + } + const confirmedFailureStatuses = new Set([ + "failed", + "abandoned", + "reversed", + "cancelled", + "canceled", + "expired", + ]); + if (confirmedFailureStatuses.has(verification.status)) { + return "already_processed"; + } + + await this.prisma.$transaction(async (tx) => { + const markedAt = new Date(); + const update = await tx.paymentAttempt.updateMany({ + where: { + id: attempt.id, + status: { + notIn: [ + PaymentAttemptStatus.SUCCESS, + PaymentAttemptStatus.RECONCILIATION_REQUIRED, + ], + }, + }, + data: { + status: PaymentAttemptStatus.RECONCILIATION_REQUIRED, + reconciliationRequiredAt: markedAt, + ...(verification.providerTransactionReference + ? { + providerTransactionReference: + verification.providerTransactionReference, + } + : {}), + }, + }); + if (update.count === 0) { + return; + } + + if (payment.status === PaymentStatus.FAILED) { + await tx.payment.updateMany({ + where: { id: payment.id, status: PaymentStatus.FAILED }, + data: { status: PaymentStatus.RECONCILIATION_REQUIRED }, + }); + } + + const metadata = { + reference: attempt.providerReference, + reason: "ADDITIONAL_PROVIDER_COLLECTION", + providerStatus: verification.gatewayResponse || verification.status, + providerAmountKobo: verification.amountKobo.toString(), + expectedAmountKobo: payment.amountKobo.toString(), + }; + await tx.paymentEvent.create({ + data: { + paymentId: payment.id, + eventType: "RECONCILIATION_REQUIRED", + payload: metadata, + }, + }); + await this.appendPaymentDomainEvent( + tx, + payment, + "PAYMENT_RECONCILIATION_REQUIRED", + "PROVIDER", + null, + metadata, + `payment:additional-collection:${payment.id}:${attempt.providerReference}`, + ); + }); + return "reconciliation_required"; + } + + private async processSuccessfulPayment( + paymentId: string, + reference: string, + ): Promise { + const payment = await this.prisma.payment.findUnique({ + where: { id: paymentId }, + include: { + order: true, + attempts: { + where: { providerReference: reference }, + include: { remainingBalanceException: true }, + }, + }, + }); + + if (!payment) throw new NotFoundException("Payment not found"); + + const provider = this.providerForPayment(payment); + const verification = await provider.verifyPayment(reference); + const currentAttempt = payment.attempts?.[0]; + + if (verification.status === "reconciliation_required") { + await this.markPaymentReconciliationRequired(payment, reference, { + providerStatus: verification.gatewayResponse || verification.status, + providerAmountKobo: verification.amountKobo, + reason: "PROVIDER_STATUS", + providerTransactionReference: verification.providerTransactionReference, + }); + return "reconciliation_required"; + } + + if (["pending", "processing", "ongoing"].includes(verification.status)) { + return "pending"; + } + + const confirmedFailureStatuses = new Set([ + "failed", + "abandoned", + "reversed", + "cancelled", + "canceled", + "expired", + ]); + if ( + verification.status !== "success" && + !confirmedFailureStatuses.has(verification.status) + ) { + await this.markPaymentReconciliationRequired(payment, reference, { + providerStatus: verification.gatewayResponse || verification.status, + providerAmountKobo: verification.amountKobo, + reason: "PROVIDER_STATUS", + providerTransactionReference: verification.providerTransactionReference, + }); + return "reconciliation_required"; + } + + if (verification.status === "success") { + const providerAmountKobo = verification.amountKobo; + const expectedAttemptAmountKobo = + currentAttempt?.expectedAmountKobo ?? payment.amountKobo; + if (providerAmountKobo !== expectedAttemptAmountKobo) { + this.logger.error( + `Amount mismatch from ${provider.name}: ${providerAmountKobo}, Expected: ${expectedAttemptAmountKobo}`, + ); + + if (currentAttempt?.remainingBalanceException) { + await this.markRemainingBalanceReconciliationRequired( + payment, + currentAttempt.id, + currentAttempt.remainingBalanceException.id, + reference, + verification, + ); + return "reconciliation_required"; + } + + await this.markPaymentReconciliationRequired(payment, reference, { + providerStatus: verification.gatewayResponse || verification.status, + providerAmountKobo, + reason: "AMOUNT_MISMATCH", + providerTransactionReference: + verification.providerTransactionReference, + }); + return "reconciliation_required"; + } + + if (currentAttempt?.remainingBalanceException) { + await this.completeRemainingBalanceCollection( + payment, + currentAttempt.id, + currentAttempt.remainingBalanceException.id, + reference, + verification, + ); + return "success"; + } + + // Update payment status and the order state in one transaction so a + // successful payment row cannot commit while its order remains unpaid. + await this.prisma.$transaction(async (tx) => { + const verifiedAt = new Date(); + if (payment.order.status !== OrderStatus.PENDING_PAYMENT) { + throw new ConflictException({ + message: `Order ${payment.orderId} is no longer pending payment`, + code: "ORDER_STATE_CONFLICT", + }); + } + + await this.orderService.transitionBySystemInTransaction( + tx, + payment.orderId, + OrderStatus.PENDING_PAYMENT, + OrderStatus.PAID, + { paymentId: payment.id, reference }, + ); + + // Snapshot the processor fee Paystack deducted so it is later removed + // from the merchant payout (merchant bears processing cost). Null/absent + // fees leave the field untouched (legacy payout fallback treats as 0). + if ( + verification.feesKobo !== null && + verification.feesKobo !== undefined + ) { + await tx.order.update({ + where: { id: payment.orderId }, + data: { processorFeeKobo: verification.feesKobo }, + }); + } + + const updateResult = await tx.payment.updateMany({ + where: { + id: payment.id, + status: { + in: [ + PaymentStatus.INITIALIZED, + PaymentStatus.RECONCILIATION_REQUIRED, + ], + }, + }, + data: { + status: PaymentStatus.SUCCESS, + verifiedAt, + }, + }); + + if (updateResult.count === 0) { + throw new Error("Payment already processed by another thread"); + } + + await tx.paymentAttempt.updateMany({ + where: { paymentId: payment.id, providerReference: reference }, + data: { + status: "SUCCESS", + verifiedAt, + ...(verification.providerTransactionReference + ? { + providerTransactionReference: + verification.providerTransactionReference, + } + : {}), + }, + }); + await tx.paymentAttempt.updateMany({ + where: { + paymentId: payment.id, + providerReference: { not: reference }, + status: "INITIALIZED", + }, + data: { status: "SUPERSEDED", supersededAt: verifiedAt }, + }); + + await tx.paymentEvent.create({ + data: { + paymentId: payment.id, + eventType: "SUCCESS", + payload: { + reference, + amountKobo: providerAmountKobo.toString(), + gatewayResponse: verification.gatewayResponse, + verifiedAt: new Date().toISOString(), + }, + }, + }); + + await this.appendPaymentDomainEvent( + tx, + payment, + "PAYMENT_CONFIRMED", + "PROVIDER", + null, + { + reference, + amountKobo: providerAmountKobo.toString(), + gatewayResponse: verification.gatewayResponse, + verifiedAt: new Date().toISOString(), + }, + `payment:confirmed:${payment.id}:${reference}`, + ); + + await this.ledgerService.recordPaymentReceived( + { + paymentId: payment.id, + orderId: payment.orderId, + buyerId: payment.order.buyerId, + storeId: payment.order.storeId, + amountKobo: BigInt(payment.amountKobo), + platformFeeKobo: + payment.order.platformFeeKobo === null || + payment.order.platformFeeKobo === undefined + ? null + : BigInt(payment.order.platformFeeKobo), + reference, + }, + tx, + ); + + // Balance-neutral audit trail of the processing fee the merchant bore. + // Uses the same captured value snapshotted on the order above. + if ( + verification.feesKobo !== null && + verification.feesKobo !== undefined && + verification.feesKobo > 0n + ) { + await this.ledgerService.recordProviderFee( + payment.orderId, + verification.feesKobo, + reference, + { + paymentId: payment.id, + storeId: payment.order.storeId, + tx, + }, + ); + } + + await this.appendPaymentNotificationOutboxes(tx, payment, reference); + + await this.commerceOutbox?.appendPaymentConfirmed( + tx, + { id: payment.id, orderId: payment.orderId, verifiedAt }, + payment.order, + ); + }); + + // Transition order via state machine (PENDING_PAYMENT → PAID) + if (payment.order.status === OrderStatus.PENDING_PAYMENT) { + // For dropship orders, mirror the PAID transition onto the linked + // fulfillment order so the physical store sees a paid fulfillment + // ready to dispatch. No-op for direct orders. + // + // Best-effort: payment is already SUCCESS at this point. A failure + // here must not abort cart cleanup, notifications, or logistics + // queueing further down — the customer order is correctly PAID + // and reconciliation can re-mirror the fulfillment later. + try { + await this.orderService.transitionLinkedFulfillment( + payment.orderId, + OrderStatus.PENDING_PAYMENT, + OrderStatus.PAID, + { paymentId: payment.id, reference }, + ); + } catch (linkedErr) { + const linkedMsg = + linkedErr instanceof Error ? linkedErr.message : String(linkedErr); + this.logger.warn( + `Linked fulfillment transition failed for order ${payment.orderId}: ${linkedMsg}`, + ); + } + + // Clear cart items for the products in this order + const orderItems = payment.order.items as any[]; + if (Array.isArray(orderItems) && orderItems.length > 0) { + const cartItemFilters = orderItems.reduce< + Prisma.CartItemWhereInput[] + >((filters, item: any) => { + if (item.productId && item.priceType) { + filters.push({ + productId: item.productId, + priceType: item.priceType, + }); + } + return filters; + }, []); + + if (cartItemFilters.length > 0) { + const deleted = await this.prisma.cartItem.deleteMany({ + where: { + buyerId: payment.order.buyerId, + OR: cartItemFilters, + }, + }); + this.logger.log( + `Cleared ${deleted.count} cart items for order ${payment.orderId}`, + ); + } + } + } + + this.logger.log( + `Payment ${payment.id} SUCCESS for order ${payment.orderId}`, + ); + return "success"; + } else { + // Payment failed + this.logger.error( + `${provider.name} verification failed for ${reference}: ${verification.status}`, + ); + await this.prisma.$transaction(async (tx) => { + const failedAttempt = await tx.paymentAttempt.updateMany({ + where: { + paymentId: payment.id, + providerReference: reference, + status: PaymentAttemptStatus.INITIALIZED, + }, + data: { status: PaymentAttemptStatus.FAILED }, + }); + + if (failedAttempt.count === 0) { + return; + } + + if (currentAttempt?.remainingBalanceException) { + await tx.paymentAmountException.updateMany({ + where: { + id: currentAttempt.remainingBalanceException.id, + status: "REMAINING_BALANCE_PENDING", + remainingBalanceAttemptId: currentAttempt.id, + }, + data: { + status: "RECONCILIATION_REQUIRED", + remainingBalanceAttemptId: null, + }, + }); + } + + const otherUnresolvedAttempts = await tx.paymentAttempt.count({ + where: { + paymentId: payment.id, + providerReference: { not: reference }, + status: { + in: [ + PaymentAttemptStatus.INITIALIZED, + PaymentAttemptStatus.RECONCILIATION_REQUIRED, + ], + }, + }, + }); + if (otherUnresolvedAttempts === 0) { + await tx.payment.updateMany({ + where: { id: payment.id, status: PaymentStatus.INITIALIZED }, + data: { status: PaymentStatus.FAILED }, + }); + } else { + const reconciliationAttempt = await tx.paymentAttempt.findFirst({ + where: { + paymentId: payment.id, + providerReference: { not: reference }, + status: PaymentAttemptStatus.RECONCILIATION_REQUIRED, + }, + select: { id: true }, + }); + if (reconciliationAttempt) { + await tx.payment.updateMany({ + where: { id: payment.id, status: PaymentStatus.INITIALIZED }, + data: { status: PaymentStatus.RECONCILIATION_REQUIRED }, + }); + } + } + + await tx.paymentEvent.create({ + data: { + paymentId: payment.id, + eventType: "FAILED", + payload: { + reference, + verificationStatus: verification.status, + gatewayResponse: verification.gatewayResponse, + }, + }, + }); + + await this.appendPaymentDomainEvent( + tx, + payment, + "PAYMENT_FAILED", + "PROVIDER", + null, + { + reference, + verificationStatus: verification.status, + gatewayResponse: verification.gatewayResponse, + }, + `payment:failed:${payment.id}:${reference}`, + ); + }); + + this.logger.warn( + `Payment ${payment.id} FAILED for order ${payment.orderId}`, + ); + return "failed"; + } + } + + private async markRemainingBalanceReconciliationRequired( + payment: Prisma.PaymentGetPayload<{ include: { order: true } }>, + attemptId: string, + exceptionId: string, + reference: string, + verification: { + amountKobo: bigint; + status: string; + gatewayResponse: string; + providerTransactionReference?: string | null; + }, + ): Promise { + await this.prisma.$transaction(async (tx) => { + const markedAt = new Date(); + await tx.paymentAttempt.updateMany({ + where: { + id: attemptId, + status: PaymentAttemptStatus.INITIALIZED, + }, + data: { + status: PaymentAttemptStatus.RECONCILIATION_REQUIRED, + reconciliationRequiredAt: markedAt, + ...(verification.providerTransactionReference + ? { + providerTransactionReference: + verification.providerTransactionReference, + } + : {}), + }, + }); + await tx.paymentEvent.create({ + data: { + paymentId: payment.id, + eventType: "REMAINING_BALANCE_RECONCILIATION_REQUIRED", + payload: { + reference, + exceptionId, + providerStatus: verification.gatewayResponse || verification.status, + observedAmountKobo: verification.amountKobo.toString(), + }, + }, + }); + }); + } + + private async completeRemainingBalanceCollection( + payment: Prisma.PaymentGetPayload<{ include: { order: true } }>, + attemptId: string, + exceptionId: string, + reference: string, + verification: { + amountKobo: bigint; + gatewayResponse: string; + providerTransactionReference?: string | null; + feesKobo?: bigint | null; + }, + ): Promise { + await this.prisma.$transaction(async (tx) => { + const exception = await tx.paymentAmountException.findUnique({ + where: { id: exceptionId }, + }); + if (!exception) { + throw new ConflictException("Payment exception no longer exists"); + } + if ( + exception.exceptionType !== PaymentAmountExceptionType.UNDERPAID || + exception.status !== "REMAINING_BALANCE_PENDING" || + exception.remainingBalanceAttemptId !== attemptId || + exception.receivedAmountKobo <= 0n || + verification.amountKobo <= 0n || + exception.receivedAmountKobo + verification.amountKobo !== + payment.amountKobo + ) { + throw new ConflictException({ + message: "Remaining payment requires reconciliation.", + code: "REMAINING_BALANCE_INVARIANT_FAILED", + }); + } + + const verifiedAt = new Date(); + const attemptUpdate = await tx.paymentAttempt.updateMany({ + where: { + id: attemptId, + status: PaymentAttemptStatus.INITIALIZED, + expectedAmountKobo: verification.amountKobo, + }, + data: { + status: PaymentAttemptStatus.SUCCESS, + verifiedAt, + ...(verification.providerTransactionReference + ? { + providerTransactionReference: + verification.providerTransactionReference, + } + : {}), + }, + }); + if (attemptUpdate.count !== 1) { + const completedAttempt = await tx.paymentAttempt.findUnique({ + where: { id: attemptId }, + select: { status: true }, + }); + if (completedAttempt?.status === PaymentAttemptStatus.SUCCESS) return; + throw new ConflictException("Remaining payment is not eligible"); + } + + const exceptionUpdate = await tx.paymentAmountException.updateMany({ + where: { + id: exceptionId, + status: "REMAINING_BALANCE_PENDING", + remainingBalanceAttemptId: attemptId, + }, + data: { status: "RESOLVED" }, + }); + const paymentUpdate = await tx.payment.updateMany({ + where: { + id: payment.id, + status: PaymentStatus.RECONCILIATION_REQUIRED, + }, + data: { status: PaymentStatus.SUCCESS, verifiedAt }, + }); + if (exceptionUpdate.count !== 1 || paymentUpdate.count !== 1) { + throw new ConflictException("Payment completion was already claimed"); + } + + await this.orderService.transitionBySystemInTransaction( + tx, + payment.orderId, + OrderStatus.PENDING_PAYMENT, + OrderStatus.PAID, + { paymentId: payment.id, reference, multiCollection: true }, + ); + await this.ledgerService.recordPaymentAmountExceptionAllocated( + { + paymentAmountExceptionId: exception.id, + paymentId: payment.id, + orderId: payment.orderId, + buyerId: payment.order.buyerId, + storeId: payment.order.storeId, + amountKobo: exception.receivedAmountKobo, + reference: exception.providerReference, + }, + tx, + ); + await this.ledgerService.recordPaymentReceived( + { + paymentId: payment.id, + orderId: payment.orderId, + buyerId: payment.order.buyerId, + storeId: payment.order.storeId, + amountKobo: payment.amountKobo, + platformFeeKobo: payment.order.platformFeeKobo, + reference, + }, + tx, + ); + + // The merchant bears the Paystack processing fee here too. Accumulate + // onto any fee already snapshotted on the order rather than overwriting, + // so this is additive by construction. NOTE: the fee charged on the + // initial underpaid collection is not yet threaded into this path (it + // isn't propagated to markPaymentReconciliationRequired), so for a + // two-charge order this still captures only the final collection's fee — + // a lower bound. Capturing the initial-charge fee is a scoped follow-up + // (this whole path is gated behind PAYMENT_REMAINING_BALANCE_COLLECTION_ENABLED). + if ( + verification.feesKobo !== null && + verification.feesKobo !== undefined + ) { + const priorFeeKobo = BigInt(payment.order.processorFeeKobo ?? 0n); + await tx.order.update({ + where: { id: payment.orderId }, + data: { processorFeeKobo: priorFeeKobo + verification.feesKobo }, + }); + } + if ( + verification.feesKobo !== null && + verification.feesKobo !== undefined && + verification.feesKobo > 0n + ) { + await this.ledgerService.recordProviderFee( + payment.orderId, + verification.feesKobo, + reference, + { + paymentId: payment.id, + storeId: payment.order.storeId, + tx, + }, + ); + } + + await tx.paymentEvent.create({ + data: { + paymentId: payment.id, + eventType: "REMAINING_BALANCE_COMPLETED", + payload: { + reference, + exceptionId, + initialCollectionKobo: exception.receivedAmountKobo.toString(), + remainingCollectionKobo: verification.amountKobo.toString(), + totalAmountKobo: payment.amountKobo.toString(), + }, + }, + }); + await this.appendPaymentDomainEvent( + tx, + payment, + "PAYMENT_CONFIRMED", + "PROVIDER", + null, + { + reference, + exceptionId, + collectionMode: "REMAINING_BALANCE", + amountKobo: payment.amountKobo.toString(), + }, + `payment:confirmed:${payment.id}:remaining-balance`, + ); + await this.appendPaymentNotificationOutboxes(tx, payment, reference); + await this.commerceOutbox?.appendPaymentConfirmed( + tx, + { id: payment.id, orderId: payment.orderId, verifiedAt }, + payment.order, + ); + }); + + try { + await this.orderService.transitionLinkedFulfillment( + payment.orderId, + OrderStatus.PENDING_PAYMENT, + OrderStatus.PAID, + { paymentId: payment.id, reference }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn( + `Linked fulfillment transition failed for order ${payment.orderId}: ${message}`, + ); + } + + const orderItems = payment.order.items as Array<{ + productId?: string; + priceType?: PriceType; + }>; + if (Array.isArray(orderItems) && orderItems.length > 0) { + const filters = orderItems.reduce( + (result, item) => { + if (item.productId && item.priceType) { + result.push({ + productId: item.productId, + priceType: item.priceType, + }); + } + return result; + }, + [], + ); + if (filters.length > 0) { + await this.prisma.cartItem.deleteMany({ + where: { buyerId: payment.order.buyerId, OR: filters }, + }); + } + } + } + + // ────────────────────────────────────────────── + // RESOLVE ACCOUNT (Proxy to Paystack) + // ────────────────────────────────────────────── + + async resolveAccount(accountNumber: string, bankCode: string) { + try { + // Bank-directory and account-resolution behaviour remains Paystack-owned + // until the payout provider abstraction adds an equivalent capability. + const account = await this.providerForName("PAYSTACK").resolveAccount({ + accountNumber, + bankCode, + }); + return { + account_number: account.accountNumber, + account_name: account.accountName, + bank_id: account.bankId, + }; + } catch (err) { + if (err instanceof Error) { + throw new BadRequestException(err.message); + } + throw new BadRequestException("Could not resolve account"); + } + } + + private formatKoboForNotification(amountKobo: bigint): string { + const naira = amountKobo / 100n; + const kobo = amountKobo % 100n; + return naira.toString() + "." + kobo.toString().padStart(2, "0"); + } + private calculateStoreNotificationAmount( + order: { + totalAmountKobo: bigint | number | string; + deliveryFeeKobo?: bigint | number | string | null; + platformFeeKobo?: bigint | number | string | null; + unitPriceKobo?: bigint | number | string | null; + quantity?: number | null; + }, + platformFeeBearer?: PlatformFeeBearer | null, + ): bigint { + if (order.unitPriceKobo !== null && order.unitPriceKobo !== undefined) { + const quantity = order.quantity ?? 1; + return BigInt(order.unitPriceKobo) * BigInt(quantity); + } + + const totalAmountKobo = BigInt(order.totalAmountKobo); + const deliveryFeeKobo = + order.deliveryFeeKobo === null || order.deliveryFeeKobo === undefined + ? 0n + : BigInt(order.deliveryFeeKobo); + // Missing bearer means a legacy order created before the immutable + // pricing-policy snapshot existed. Those shopper-borne totals include the + // platform fee, so retain the historical subtraction by default. + const shopperBornePlatformFeeKobo = + platformFeeBearer === PlatformFeeBearer.MERCHANT || + order.platformFeeKobo === null || + order.platformFeeKobo === undefined + ? 0n + : BigInt(order.platformFeeKobo); + const merchandiseAmountKobo = + totalAmountKobo - deliveryFeeKobo - shopperBornePlatformFeeKobo; + + return merchandiseAmountKobo > 0n ? merchandiseAmountKobo : 0n; + } + + private isPaymentSettledOrderStatus(status: string): boolean { + const settledStatuses: string[] = [ + OrderStatus.PAID, + OrderStatus.READY_FOR_PICKUP, + OrderStatus.DISPATCHED, + OrderStatus.IN_TRANSIT, + OrderStatus.DELIVERED, + OrderStatus.COMPLETED, + ]; + + return settledStatuses.includes(status); + } + + private async ensureSuccessfulPaymentOrderIsPaid( + payment: Prisma.PaymentGetPayload<{ include: { order: true } }>, + reference: string, + ) { + if (this.isPaymentSettledOrderStatus(payment.order.status)) { + return; + } + + if (payment.order.status !== OrderStatus.PENDING_PAYMENT) { + throw new ConflictException({ + message: `Order ${payment.orderId} is not payable`, + code: "ORDER_STATE_CONFLICT", + }); + } + + await this.orderService.transitionBySystem( + payment.orderId, + OrderStatus.PENDING_PAYMENT, + OrderStatus.PAID, + { paymentId: payment.id, reference, action: "payment_success_repair" }, + ); + + try { + await this.orderService.transitionLinkedFulfillment( + payment.orderId, + OrderStatus.PENDING_PAYMENT, + OrderStatus.PAID, + { + paymentId: payment.id, + reference, + action: "payment_success_repair", + }, + ); + } catch (linkedErr) { + const linkedMsg = + linkedErr instanceof Error ? linkedErr.message : String(linkedErr); + this.logger.warn( + `Linked fulfillment repair failed for order ${payment.orderId}: ${linkedMsg}`, + ); + } + } + + async getBanks() { + try { + return await this.providerForName("PAYSTACK").listBanks(); + } catch (err) { + this.logger.error("Failed to fetch banks", err); + throw new BadRequestException("Could not fetch banks"); + } + } + + private parsePayoutAmountKobo(amount: RequestPayoutDto["amount"]) { + if (typeof amount !== "string" || !/^[1-9]\d*$/.test(amount)) { + throw new BadRequestException({ + message: "Payout amount must be a positive integer kobo string.", + code: "PAYOUT_AMOUNT_INVALID", + }); + } + + return BigInt(amount); + } + + private maskAccountNumber(accountNumber: string): string { + return `****${accountNumber.slice(-4)}`; + } + + private normalizePayoutIdempotencyKey(key: string | undefined): string { + const normalized = key?.trim(); + if (!normalized || normalized.length > 128) { + throw new BadRequestException({ + message: "A valid X-Idempotency-Key header is required.", + code: "PAYOUT_IDEMPOTENCY_KEY_INVALID", + }); + } + return normalized; + } + + private assertMatchingPayoutReservation( + existing: { + amountKobo: bigint; + bankName: string | null; + accountNumber: string | null; + accountName: string | null; + }, + expected: { + amountKobo: bigint; + bankName: string; + accountNumber: string; + accountName: string | null; + }, + ): void { + if ( + BigInt(existing.amountKobo) !== expected.amountKobo || + existing.bankName !== expected.bankName || + existing.accountNumber !== expected.accountNumber || + existing.accountName !== expected.accountName + ) { + throw new ConflictException({ + message: + "The idempotency key was already used for another payout request.", + code: "PAYOUT_IDEMPOTENCY_CONFLICT", + }); + } + } + + // ────────────────────────────────────────────── + // REQUEST PAYOUT (Manual request by store owner) + // ────────────────────────────────────────────── + + async requestPayout( + storeId: string, + dto: RequestPayoutDto, + rawIdempotencyKey?: string, + ) { + const amountKobo = this.parsePayoutAmountKobo(dto.amount); + const idempotencyKey = + this.normalizePayoutIdempotencyKey(rawIdempotencyKey); + + this.logger.log( + `Payout requested by store ${storeId} for amount ${amountKobo.toString()}`, + ); + + const storeProfile = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + }); + + if (!storeProfile) { + throw new NotFoundException("Store profile not found"); + } + + // 4. Validate store owner has bank details + if (!storeProfile.bankAccountNumber || !storeProfile.bankCode) { + throw new BadRequestException( + "Please set your bank details in settings before requesting a payout.", + ); + } + + const bankAccountNumber = storeProfile.bankAccountNumber; + const bankCode = storeProfile.bankCode; + const reservationIntent = { + amountKobo, + bankName: bankCode, + accountNumber: this.maskAccountNumber(bankAccountNumber), + accountName: storeProfile.settlementAccountName, + }; + + let reservationResult: { + payoutRequest: { + id: string; + amountKobo: bigint; + status: string; + bankName: string | null; + accountNumber: string | null; + accountName: string | null; + }; + created: boolean; + }; + try { + reservationResult = await this.prisma.$transaction(async (tx) => { + await tx.$executeRaw` + SELECT pg_advisory_xact_lock(hashtext('payout_request'), hashtext(${storeId})) + `; + + const existing = await tx.payoutRequest.findUnique({ + where: { storeId_idempotencyKey: { storeId, idempotencyKey } }, + }); + if (existing) { + this.assertMatchingPayoutReservation(existing, reservationIntent); + return { payoutRequest: existing, created: false }; + } + + const earnings = await this.earningsService.getSummary(storeId, tx); + if (amountKobo > BigInt(earnings.availableKobo)) { + throw new ConflictException({ + message: "Requested amount exceeds available balance.", + code: "PAYOUT_INSUFFICIENT_BALANCE", + }); + } + + const payoutRequest = await tx.payoutRequest.create({ + data: { + storeId, + idempotencyKey, + amountKobo, + status: "PENDING", + bankName: reservationIntent.bankName, + accountNumber: reservationIntent.accountNumber, + accountName: reservationIntent.accountName, + }, + }); + return { payoutRequest, created: true }; + }); + } catch (error) { + if ( + !(error instanceof Prisma.PrismaClientKnownRequestError) || + error.code !== "P2002" + ) { + throw error; + } + const existing = await this.prisma.payoutRequest.findUnique({ + where: { storeId_idempotencyKey: { storeId, idempotencyKey } }, + }); + if (!existing) throw error; + this.assertMatchingPayoutReservation(existing, reservationIntent); + reservationResult = { payoutRequest: existing, created: false }; + } + + const { payoutRequest, created } = reservationResult; + + if (created) { + const admins = await this.prisma.user.findMany({ + where: { role: UserRole.SUPER_ADMIN }, + select: { id: true }, + }); + const adminIds = admins.map((a) => a.id); + if (adminIds.length > 0) { + await this.notifications.triggerPayoutRequested(adminIds, { + storeId, + merchantName: storeProfile.businessName || "Unknown store", + amountKobo: amountKobo.toString(), + requestId: payoutRequest.id, + }); + } + await this.notifications.triggerMerchantPayoutRequestedConfirmation( + storeProfile.userId, + { + amountKobo: amountKobo.toString(), + requestId: payoutRequest.id, + }, + ); + } + + return { + message: created + ? "Payout request received and queued for processing" + : "Existing payout request returned", + amountRequested: amountKobo.toString(), + status: payoutRequest.status, + requestId: payoutRequest.id, + reused: !created, + }; + } +} diff --git a/apps/backend/src/domains/money/payment/providers/payment-provider.interface.ts b/apps/backend/src/domains/money/payment/providers/payment-provider.interface.ts new file mode 100644 index 00000000..71b7efc4 --- /dev/null +++ b/apps/backend/src/domains/money/payment/providers/payment-provider.interface.ts @@ -0,0 +1,91 @@ +export const PAYMENT_PROVIDER = Symbol("PAYMENT_PROVIDER"); + +export type PaymentProviderKey = + | "paystack" + | "nomba" + | "flutterwave" + | "monnify"; + +export type PaymentProviderName = + | "PAYSTACK" + | "NOMBA" + | "FLUTTERWAVE" + | "MONNIFY"; + +export interface InitializePaymentInput { + email: string; + customerName?: string; + amountKobo: bigint; + reference: string; + callbackUrl?: string; + metadata?: Record; +} + +export interface InitializePaymentResult { + authorizationUrl: string; + accessCode?: string | null; + reference: string; + providerTransactionReference?: string | null; +} + +export interface VerifyPaymentResult { + // Providers can add statuses without notice. Adapters normalize known values, + // while the payment service treats every unknown value as reconciliation-only. + status: string; + amountKobo: bigint; + reference: string; + currency: string; + gatewayResponse: string; + paidAt?: string | null; + channel?: string | null; + metadata?: unknown; + providerTransactionReference?: string | null; + // Processing fee the provider deducted for this charge, in kobo. Null when the + // provider does not report it; treated as 0 downstream. + feesKobo?: bigint | null; +} + +export interface VerifyWebhookSignatureInput { + rawBody: Buffer | string; + signature: string | string[] | undefined; +} + +export interface PaymentProviderEvent { + provider: PaymentProviderName; + eventType: string; + eventId: string; + reference?: string; + rawPayload: unknown; +} + +export interface ResolvePaymentAccountInput { + accountNumber: string; + bankCode: string; +} + +export interface ResolvePaymentAccountResult { + accountNumber: string; + accountName: string; + bankId: number; +} + +export interface ListPaymentBankResult { + name: string; + code: string; + active: boolean; + type: string; +} + +export interface PaymentProvider { + readonly name: PaymentProviderName; + initializePayment( + input: InitializePaymentInput, + ): Promise; + verifyPayment(reference: string): Promise; + verifyWebhookSignature(input: VerifyWebhookSignatureInput): boolean; + parseWebhookEvent(payload: unknown): PaymentProviderEvent; + resolveAccount( + input: ResolvePaymentAccountInput, + ): Promise; + listBanks(): Promise; +} diff --git a/apps/backend/src/domains/money/payment/providers/payment-provider.registry.ts b/apps/backend/src/domains/money/payment/providers/payment-provider.registry.ts new file mode 100644 index 00000000..36997a0c --- /dev/null +++ b/apps/backend/src/domains/money/payment/providers/payment-provider.registry.ts @@ -0,0 +1,30 @@ +import { + PaymentProvider, + PaymentProviderName, +} from "./payment-provider.interface"; + +export const PAYMENT_PROVIDER_REGISTRY = Symbol("PAYMENT_PROVIDER_REGISTRY"); + +export interface PaymentProviderRegistry { + getByName(provider: PaymentProviderName): PaymentProvider; +} + +export function createPaymentProviderRegistry(input: { + paystack: PaymentProvider; + monnify: PaymentProvider; +}): PaymentProviderRegistry { + const providers = new Map([ + [input.paystack.name, input.paystack], + [input.monnify.name, input.monnify], + ]); + + return { + getByName(provider: PaymentProviderName): PaymentProvider { + const resolved = providers.get(provider); + if (!resolved) { + throw new Error(`Payment provider '${provider}' is not registered.`); + } + return resolved; + }, + }; +} diff --git a/apps/backend/src/domains/money/payment/providers/payment-provider.selector.spec.ts b/apps/backend/src/domains/money/payment/providers/payment-provider.selector.spec.ts new file mode 100644 index 00000000..d4ead852 --- /dev/null +++ b/apps/backend/src/domains/money/payment/providers/payment-provider.selector.spec.ts @@ -0,0 +1,37 @@ +import { selectPaymentProvider } from "./payment-provider.selector"; +import { PaymentProvider } from "./payment-provider.interface"; + +describe("payment provider selection", () => { + const paystackProvider = { name: "PAYSTACK" } as PaymentProvider; + const monnifyProvider = { name: "MONNIFY" } as PaymentProvider; + const providers = { paystack: paystackProvider, monnify: monnifyProvider }; + + it("selects Paystack when PAYMENT_PROVIDER is missing", () => { + expect(selectPaymentProvider(undefined, providers)).toBe(paystackProvider); + }); + + it("selects Paystack when PAYMENT_PROVIDER is paystack", () => { + expect(selectPaymentProvider("paystack", providers)).toBe(paystackProvider); + }); + + it("fails clearly when a future payment provider is selected before implementation", () => { + expect(() => selectPaymentProvider("nomba", providers)).toThrow( + "Payment provider 'nomba' is reserved but not implemented yet.", + ); + }); + + it("fails clearly when PAYMENT_PROVIDER is unsupported", () => { + expect(() => selectPaymentProvider("stripe", providers)).toThrow( + "Unsupported PAYMENT_PROVIDER 'stripe'. Supported values: paystack, nomba, flutterwave, monnify.", + ); + }); + + it("selects Monnify only when the sandbox enablement flag is set", () => { + expect(selectPaymentProvider("monnify", providers, true)).toBe( + monnifyProvider, + ); + expect(() => selectPaymentProvider("monnify", providers, false)).toThrow( + "Monnify payment provider is disabled", + ); + }); +}); diff --git a/apps/backend/src/domains/money/payment/providers/payment-provider.selector.ts b/apps/backend/src/domains/money/payment/providers/payment-provider.selector.ts new file mode 100644 index 00000000..1d5114b5 --- /dev/null +++ b/apps/backend/src/domains/money/payment/providers/payment-provider.selector.ts @@ -0,0 +1,67 @@ +import { + PaymentProvider, + PaymentProviderKey, +} from "./payment-provider.interface"; + +const SUPPORTED_PAYMENT_PROVIDER_KEYS: PaymentProviderKey[] = [ + "paystack", + "nomba", + "flutterwave", + "monnify", +]; + +const IMPLEMENTED_PAYMENT_PROVIDER_KEYS: PaymentProviderKey[] = [ + "paystack", + "monnify", +]; + +export function resolvePaymentProviderKey( + configuredProvider: string | undefined, +): PaymentProviderKey { + const providerKey = (configuredProvider?.trim().toLowerCase() || + "paystack") as PaymentProviderKey; + + if (!SUPPORTED_PAYMENT_PROVIDER_KEYS.includes(providerKey)) { + throw new Error( + `Unsupported PAYMENT_PROVIDER '${configuredProvider}'. Supported values: ${SUPPORTED_PAYMENT_PROVIDER_KEYS.join( + ", ", + )}.`, + ); + } + + if (!IMPLEMENTED_PAYMENT_PROVIDER_KEYS.includes(providerKey)) { + throw new Error( + `Payment provider '${providerKey}' is reserved but not implemented yet.`, + ); + } + + return providerKey; +} + +export function selectPaymentProvider( + configuredProvider: string | undefined, + providers: { + paystack: PaymentProvider; + monnify: PaymentProvider; + }, + monnifyPaymentEnabled = false, +): PaymentProvider { + const providerKey = resolvePaymentProviderKey(configuredProvider); + + if (providerKey === "paystack") { + return providers.paystack; + } + + if (providerKey === "monnify") { + if (!monnifyPaymentEnabled) { + throw new Error( + "Monnify payment provider is disabled. Set MONNIFY_PAYMENT_ENABLED=true only in an approved sandbox environment.", + ); + } + return providers.monnify; + } + + throw new Error( + `Payment provider '${providerKey}' is reserved but not implemented yet.`, + ); +} diff --git a/apps/backend/src/domains/money/payout/payout-account.service.spec.ts b/apps/backend/src/domains/money/payout/payout-account.service.spec.ts new file mode 100644 index 00000000..41cd8e87 --- /dev/null +++ b/apps/backend/src/domains/money/payout/payout-account.service.spec.ts @@ -0,0 +1,37 @@ +import { PayoutAccountService } from "./payout-account.service"; + +describe("PayoutAccountService", () => { + const provider = { + key: "monnify", + listBanks: jest.fn(), + resolveAccount: jest.fn(), + createRecipient: jest.fn(), + }; + const service = new PayoutAccountService(provider as never); + + beforeEach(() => jest.clearAllMocks()); + + it("returns the active provider binding without exposing provider payloads", async () => { + provider.resolveAccount.mockResolvedValue({ + accountName: "Test Merchant", + accountNumber: "0123456789", + }); + provider.createRecipient.mockResolvedValue({ recipientCode: null }); + + await expect( + service.verifyAccountAndCreateRecipient({ + bankCode: "058", + bankName: "Test Bank", + accountNumber: "0123456789", + }), + ).resolves.toEqual({ + provider: "monnify", + bankCode: "058", + bankName: "Test Bank", + accountNumber: "0123456789", + accountName: "Test Merchant", + accountNumberLast4: "6789", + recipientCode: undefined, + }); + }); +}); diff --git a/apps/backend/src/domains/money/payout/payout-account.service.ts b/apps/backend/src/domains/money/payout/payout-account.service.ts new file mode 100644 index 00000000..42704db4 --- /dev/null +++ b/apps/backend/src/domains/money/payout/payout-account.service.ts @@ -0,0 +1,74 @@ +import { Inject, Injectable } from "@nestjs/common"; + +import { + PAYOUT_PROVIDER, + PayoutProvider, +} from "./providers/payout-provider.interface"; + +export interface VerifyPayoutAccountInput { + bankCode: string; + bankName?: string; + accountNumber: string; +} + +export interface VerifiedPayoutAccount { + provider: "paystack" | "monnify"; + bankCode: string; + bankName?: string; + accountNumber: string; + accountName: string; + accountNumberLast4: string; + recipientCode?: string; +} + +@Injectable() +export class PayoutAccountService { + constructor( + @Inject(PAYOUT_PROVIDER) private readonly provider: PayoutProvider, + ) {} + + async listBanks() { + const banks = await this.provider.listBanks(); + return banks + .filter((bank) => bank.active && bank.type === "nuban") + .map((bank) => ({ code: bank.code, name: bank.name })); + } + + async verifyAccount( + input: VerifyPayoutAccountInput, + ): Promise { + const resolved = await this.provider.resolveAccount({ + accountNumber: input.accountNumber, + bankCode: input.bankCode, + }); + + return { + provider: this.provider.key, + bankCode: input.bankCode, + bankName: input.bankName ?? "", + accountNumber: input.accountNumber, + accountName: resolved.accountName, + accountNumberLast4: this.maskAccountNumber(input.accountNumber), + }; + } + + async verifyAccountAndCreateRecipient( + input: VerifyPayoutAccountInput, + ): Promise { + const verified = await this.verifyAccount(input); + const recipient = await this.provider.createRecipient({ + bankCode: verified.bankCode, + accountNumber: verified.accountNumber, + accountName: verified.accountName, + }); + + return { + ...verified, + recipientCode: recipient.recipientCode ?? undefined, + }; + } + + private maskAccountNumber(accountNumber: string) { + return accountNumber.slice(-4); + } +} diff --git a/apps/backend/src/domains/money/payout/payout-retry.processor.ts b/apps/backend/src/domains/money/payout/payout-retry.processor.ts new file mode 100644 index 00000000..a1cfee01 --- /dev/null +++ b/apps/backend/src/domains/money/payout/payout-retry.processor.ts @@ -0,0 +1,45 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Job } from "bullmq"; +import { Logger } from "@nestjs/common"; +import { PAYOUT_RETRY_QUEUE } from "../../../queue/queue.constants"; +import { PayoutService } from "./payout.service"; + +const MAX_PAYOUT_ATTEMPTS = 4; + +interface PayoutRetryJobData { + orderId: string; + attempt: number; +} + +@Processor(PAYOUT_RETRY_QUEUE, { + drainDelay: 60000, + stalledInterval: 300000, + lockDuration: 60000, +}) +export class PayoutRetryProcessor extends WorkerHost { + private readonly logger = new Logger(PayoutRetryProcessor.name); + + constructor(private readonly payoutService: PayoutService) { + super(); + } + + async process(job: Job): Promise { + if (job.name !== "retry-payout") return; + + const { orderId, attempt } = job.data; + this.logger.log( + `Processing payout retry for order ${orderId} (attempt ${attempt})`, + ); + + try { + await this.payoutService.preparePayoutForReview(orderId); + } catch (error) { + if (attempt < MAX_PAYOUT_ATTEMPTS) { + await this.payoutService.scheduleRetry(orderId, attempt + 1); + } else { + await this.payoutService.markFinalFailure(orderId, error); + } + throw error; + } + } +} diff --git a/apps/backend/src/domains/money/payout/payout-webhook.controller.spec.ts b/apps/backend/src/domains/money/payout/payout-webhook.controller.spec.ts new file mode 100644 index 00000000..825e3112 --- /dev/null +++ b/apps/backend/src/domains/money/payout/payout-webhook.controller.spec.ts @@ -0,0 +1,38 @@ +import { BadRequestException } from "@nestjs/common"; + +import { PayoutWebhookController } from "./payout-webhook.controller"; + +describe("PayoutWebhookController", () => { + const webhooks = { handleMonnifyWebhook: jest.fn() }; + const controller = new PayoutWebhookController(webhooks as never); + + beforeEach(() => jest.clearAllMocks()); + + it("rejects callbacks when raw-body capture is unavailable", () => { + expect(() => + controller.handleMonnify({ + rawBody: undefined, + headers: {}, + body: {}, + } as never), + ).toThrow(BadRequestException); + expect(webhooks.handleMonnifyWebhook).not.toHaveBeenCalled(); + }); + + it("passes only captured signature material to the webhook service", async () => { + webhooks.handleMonnifyWebhook.mockResolvedValue({ status: "accepted" }); + const rawBody = Buffer.from("{}"); + await expect( + controller.handleMonnify({ + rawBody, + headers: { "monnify-signature": "signature" }, + body: { eventType: "SUCCESSFUL_DISBURSEMENT" }, + } as never), + ).resolves.toEqual({ status: "accepted" }); + expect(webhooks.handleMonnifyWebhook).toHaveBeenCalledWith({ + rawBody, + signature: "signature", + payload: { eventType: "SUCCESSFUL_DISBURSEMENT" }, + }); + }); +}); diff --git a/apps/backend/src/domains/money/payout/payout-webhook.controller.ts b/apps/backend/src/domains/money/payout/payout-webhook.controller.ts new file mode 100644 index 00000000..28c62bab --- /dev/null +++ b/apps/backend/src/domains/money/payout/payout-webhook.controller.ts @@ -0,0 +1,33 @@ +import { + BadRequestException, + Controller, + HttpCode, + Post, + Req, +} from "@nestjs/common"; +import type { RawBodyRequest } from "@nestjs/common"; +import { SkipThrottle } from "@nestjs/throttler"; +import type { Request } from "express"; + +import { PayoutWebhookService } from "./payout-webhook.service"; + +@Controller("payouts/webhook") +export class PayoutWebhookController { + constructor(private readonly webhooks: PayoutWebhookService) {} + + @Post("monnify") + @HttpCode(200) + @SkipThrottle() + handleMonnify(@Req() req: RawBodyRequest) { + if (!req.rawBody) { + throw new BadRequestException( + "Missing raw body for webhook verification", + ); + } + return this.webhooks.handleMonnifyWebhook({ + rawBody: req.rawBody, + signature: req.headers["monnify-signature"], + payload: req.body, + }); + } +} diff --git a/apps/backend/src/domains/money/payout/payout-webhook.service.spec.ts b/apps/backend/src/domains/money/payout/payout-webhook.service.spec.ts new file mode 100644 index 00000000..3a80744a --- /dev/null +++ b/apps/backend/src/domains/money/payout/payout-webhook.service.spec.ts @@ -0,0 +1,94 @@ +import { PayoutWebhookService } from "./payout-webhook.service"; + +describe("PayoutWebhookService", () => { + const prisma = { + payoutAttempt: { findUnique: jest.fn() }, + payout: { updateMany: jest.fn() }, + }; + const monnify = { + verifyWebhookSignature: jest.fn(), + parseWebhookEvent: jest.fn(), + }; + const providers = { get: jest.fn(() => monnify) }; + const service = new PayoutWebhookService(prisma as never, providers as never); + + beforeEach(() => jest.clearAllMocks()); + + it("fails closed when raw signature material is missing", async () => { + await expect( + service.handleMonnifyWebhook({ + rawBody: undefined, + signature: undefined, + payload: {}, + }), + ).resolves.toEqual({ status: "ignored" }); + expect(monnify.verifyWebhookSignature).not.toHaveBeenCalled(); + expect(prisma.payout.updateMany).not.toHaveBeenCalled(); + }); + + it("only wakes reconciliation for a verified immutable Monnify attempt", async () => { + monnify.verifyWebhookSignature.mockReturnValue(true); + monnify.parseWebhookEvent.mockReturnValue({ reference: "PO-PAYOUT-1-A1" }); + prisma.payoutAttempt.findUnique.mockResolvedValue({ payoutId: "payout-1" }); + prisma.payout.updateMany.mockResolvedValue({ count: 1 }); + + await expect( + service.handleMonnifyWebhook({ + rawBody: Buffer.from("{}"), + signature: "signature", + payload: {}, + }), + ).resolves.toEqual({ status: "accepted" }); + expect(prisma.payoutAttempt.findUnique).toHaveBeenCalledWith({ + where: { + provider_providerReference: { + provider: "MONNIFY", + providerReference: "PO-PAYOUT-1-A1", + }, + }, + select: { payoutId: true }, + }); + expect(prisma.payout.updateMany).toHaveBeenCalledWith({ + where: { + id: "payout-1", + provider: "MONNIFY", + nextReconcileAt: null, + status: { in: ["PROCESSING", "SUBMITTED"] }, + }, + data: { + status: "SUBMITTED", + nextReconcileAt: expect.any(Date), + }, + }); + }); + + it("does not acknowledge a webhook when no eligible payout was scheduled", async () => { + monnify.verifyWebhookSignature.mockReturnValue(true); + monnify.parseWebhookEvent.mockReturnValue({ reference: "PO-PAYOUT-1-A1" }); + prisma.payoutAttempt.findUnique.mockResolvedValue({ payoutId: "payout-1" }); + prisma.payout.updateMany.mockResolvedValue({ count: 0 }); + + await expect( + service.handleMonnifyWebhook({ + rawBody: Buffer.from("{}"), + signature: "signature", + payload: {}, + }), + ).resolves.toEqual({ status: "ignored" }); + }); + + it("does not transition or write the ledger directly", async () => { + monnify.verifyWebhookSignature.mockReturnValue(true); + monnify.parseWebhookEvent.mockReturnValue({ reference: "unknown" }); + prisma.payoutAttempt.findUnique.mockResolvedValue(null); + + await expect( + service.handleMonnifyWebhook({ + rawBody: Buffer.from("{}"), + signature: "signature", + payload: {}, + }), + ).resolves.toEqual({ status: "ignored" }); + expect(prisma.payout.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/domains/money/payout/payout-webhook.service.ts b/apps/backend/src/domains/money/payout/payout-webhook.service.ts new file mode 100644 index 00000000..394451b8 --- /dev/null +++ b/apps/backend/src/domains/money/payout/payout-webhook.service.ts @@ -0,0 +1,67 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { PayoutProviderName, PayoutStatus } from "@prisma/client"; + +import { PrismaService } from "../../../prisma/prisma.service"; +import { + PAYOUT_PROVIDER_REGISTRY, + PayoutProviderRegistry, +} from "./providers/payout-provider.registry"; + +const WEBHOOK_RECONCILE_DELAY_MS = 2 * 60 * 1000; + +@Injectable() +export class PayoutWebhookService { + constructor( + private readonly prisma: PrismaService, + @Inject(PAYOUT_PROVIDER_REGISTRY) + private readonly providers: PayoutProviderRegistry, + ) {} + + async handleMonnifyWebhook(input: { + rawBody: Buffer | string | undefined; + signature: string | string[] | undefined; + payload: unknown; + }): Promise<{ status: "accepted" | "ignored" }> { + const monnify = this.providers.get("monnify"); + if ( + !input.rawBody || + !monnify.verifyWebhookSignature?.(input.rawBody, input.signature) + ) { + return { status: "ignored" }; + } + const event = monnify.parseWebhookEvent?.(input.payload); + if (!event) return { status: "ignored" }; + + const attempt = await this.prisma.payoutAttempt.findUnique({ + where: { + provider_providerReference: { + provider: PayoutProviderName.MONNIFY, + providerReference: event.reference, + }, + }, + select: { payoutId: true }, + }); + if (!attempt) return { status: "ignored" }; + + const scheduled = await this.prisma.payout.updateMany({ + where: { + id: attempt.payoutId, + provider: PayoutProviderName.MONNIFY, + nextReconcileAt: null, + status: { + in: [PayoutStatus.PROCESSING, PayoutStatus.SUBMITTED], + }, + }, + data: { + status: PayoutStatus.SUBMITTED, + // Give an in-flight request time to persist its normalized provider + // response. If that process crashed, reconciliation still recovers the + // deterministic attempt shortly afterward without a second submit. + nextReconcileAt: new Date(Date.now() + WEBHOOK_RECONCILE_DELAY_MS), + }, + }); + return scheduled.count === 1 + ? { status: "accepted" } + : { status: "ignored" }; + } +} diff --git a/apps/backend/src/domains/money/payout/payout.controller.ts b/apps/backend/src/domains/money/payout/payout.controller.ts new file mode 100644 index 00000000..2f2fc9e3 --- /dev/null +++ b/apps/backend/src/domains/money/payout/payout.controller.ts @@ -0,0 +1,24 @@ +import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common"; +import { UserRole } from "@twizrr/shared"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { RolesGuard } from "../../../common/guards/roles.guard"; +import { Roles } from "../../../common/decorators/roles.decorator"; +import { CurrentMerchant } from "../../../common/decorators/current-merchant.decorator"; +import { PayoutService } from "./payout.service"; + +// Read-only payout history for the authenticated store owner. Money movement +// (initiate/release/retry) stays admin-only — this endpoint only reads. +@Controller("payouts") +@UseGuards(JwtAuthGuard, RolesGuard) +export class PayoutController { + constructor(private readonly payoutService: PayoutService) {} + + @Get() + @Roles(UserRole.USER) + list(@CurrentMerchant() storeId: string | undefined) { + if (!storeId) { + throw new ForbiddenException("Store identity required"); + } + return this.payoutService.listByStore(storeId); + } +} diff --git a/apps/backend/src/domains/money/payout/payout.module.ts b/apps/backend/src/domains/money/payout/payout.module.ts new file mode 100644 index 00000000..fe56fe24 --- /dev/null +++ b/apps/backend/src/domains/money/payout/payout.module.ts @@ -0,0 +1,77 @@ +import { Module } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { BullModule } from "@nestjs/bullmq"; +import { PayoutController } from "./payout.controller"; +import { PayoutWebhookController } from "./payout-webhook.controller"; +import { PayoutWebhookService } from "./payout-webhook.service"; +import { PayoutService } from "./payout.service"; +import { PayoutAccountService } from "./payout-account.service"; +import { PayoutProcessor } from "./payout.processor"; +import { PayoutRetryProcessor } from "./payout-retry.processor"; +import { PaystackPayoutProvider } from "./providers/paystack-payout.provider"; +import { PAYOUT_PROVIDER } from "./providers/payout-provider.interface"; +import { PaystackModule } from "../../../integrations/paystack/paystack.module"; +import { MonnifyModule } from "../../../integrations/monnify/monnify.module"; +import { MonnifyPayoutProvider } from "../../../integrations/monnify/monnify-payout.provider"; +import { + createPayoutProviderRegistry, + PAYOUT_PROVIDER_REGISTRY, + PayoutProviderRegistry, +} from "./providers/payout-provider.registry"; +import { resolvePayoutProviderKey } from "./providers/payout-provider.selector"; +import { NotificationModule } from "../../social/notification/notification.module"; +import { LedgerModule } from "../ledger/ledger.module"; +import { + PAYOUT_QUEUE, + PAYOUT_RETRY_QUEUE, +} from "../../../queue/queue.constants"; +import { DomainEventModule } from "../../platform/domain-event/domain-event.module"; + +@Module({ + imports: [ + PaystackModule, + MonnifyModule, + NotificationModule, + LedgerModule, + DomainEventModule, + BullModule.registerQueue( + { name: PAYOUT_QUEUE }, + { name: PAYOUT_RETRY_QUEUE }, + ), + ], + controllers: [PayoutController, PayoutWebhookController], + providers: [ + PayoutService, + PayoutAccountService, + PayoutProcessor, + PayoutRetryProcessor, + PayoutWebhookService, + PaystackPayoutProvider, + { + provide: PAYOUT_PROVIDER_REGISTRY, + useFactory: ( + paystack: PaystackPayoutProvider, + monnify: MonnifyPayoutProvider, + ) => createPayoutProviderRegistry({ paystack, monnify }), + inject: [PaystackPayoutProvider, MonnifyPayoutProvider], + }, + { + provide: PAYOUT_PROVIDER, + useFactory: (registry: PayoutProviderRegistry, config: ConfigService) => { + // Payout account setup follows the active checkout provider for new + // stores. Actual payouts are derived from each order's immutable + // captured Payment.provider in PayoutService. Execution gates remain + // in the provider adapter, so a disabled Monnify payout product cannot + // be bypassed or fall back to Paystack. + const key = resolvePayoutProviderKey( + config.get("PAYMENT_PROVIDER"), + "PAYMENT_PROVIDER", + ); + return registry.get(key); + }, + inject: [PAYOUT_PROVIDER_REGISTRY, ConfigService], + }, + ], + exports: [PayoutService, PayoutAccountService], +}) +export class PayoutModule {} diff --git a/apps/backend/src/domains/money/payout/payout.processor.ts b/apps/backend/src/domains/money/payout/payout.processor.ts new file mode 100644 index 00000000..2918d630 --- /dev/null +++ b/apps/backend/src/domains/money/payout/payout.processor.ts @@ -0,0 +1,46 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Job } from "bullmq"; +import { Logger } from "@nestjs/common"; +import { PAYOUT_QUEUE } from "../../../queue/queue.constants"; +import { PayoutService } from "./payout.service"; + +const MAX_PAYOUT_ATTEMPTS = 4; + +interface PayoutJobData { + orderId: string; + attempt?: number; +} + +@Processor(PAYOUT_QUEUE, { + drainDelay: 60000, + stalledInterval: 300000, + lockDuration: 60000, +}) +export class PayoutProcessor extends WorkerHost { + private readonly logger = new Logger(PayoutProcessor.name); + + constructor(private readonly payoutService: PayoutService) { + super(); + } + + async process(job: Job): Promise { + if (job.name !== "process-payout") return; + + const { orderId } = job.data; + const attempt = job.data.attempt ?? 1; + this.logger.log( + `Processing payout for order ${orderId} (attempt ${attempt})`, + ); + + try { + await this.payoutService.preparePayoutForReview(orderId); + } catch (error) { + if (attempt < MAX_PAYOUT_ATTEMPTS) { + await this.payoutService.scheduleRetry(orderId, attempt + 1); + } else { + await this.payoutService.markFinalFailure(orderId, error); + } + throw error; + } + } +} diff --git a/apps/backend/src/domains/money/payout/payout.service.spec.ts b/apps/backend/src/domains/money/payout/payout.service.spec.ts new file mode 100644 index 00000000..c9a6e3a0 --- /dev/null +++ b/apps/backend/src/domains/money/payout/payout.service.spec.ts @@ -0,0 +1,941 @@ +import { PayoutService } from "./payout.service"; + +describe("PayoutService domain events", () => { + const makeService = () => { + const order = { + id: "order-1", + status: "COMPLETED", + totalAmountKobo: 250000n, + platformFeeKobo: 5000n, + storeProfile: { + id: "store-1", + userId: "user-1", + paystackRecipientCode: "RCP_test", + payoutRecipientProvider: "PAYSTACK", + payoutRecipientReference: "RCP_test", + bankCode: "058", + accountNumber: "0123456789", + accountName: "Test Merchant", + settlementAccountName: "Test Bank", + }, + product: { name: "Product" }, + quantity: 1, + payments: [{ provider: "PAYSTACK" }], + }; + const createdPayout = { + id: "payout-1", + orderId: "order-1", + storeId: "store-1", + amountKobo: 245000n, + platformFeeKobo: 5000n, + status: "PENDING", + provider: "PAYSTACK", + providerReference: null, + providerOperationId: null, + destinationRecipientReference: "RCP_test", + destinationBankCode: "058", + destinationAccountNumber: "0123456789", + destinationAccountName: "Test Bank", + paystackTransferCode: null, + completedAt: null, + }; + const completedPayout = { + ...createdPayout, + status: "COMPLETED", + paystackTransferCode: "TRF_test", + completedAt: new Date("2026-01-01T00:00:00.000Z"), + }; + const tx = { + payout: { + findUnique: jest.fn().mockResolvedValue(null), + findUniqueOrThrow: jest.fn().mockResolvedValue(createdPayout), + create: jest.fn().mockResolvedValue(createdPayout), + update: jest + .fn() + .mockImplementation(({ data }) => + Promise.resolve( + data.status === "COMPLETED" + ? completedPayout + : { ...createdPayout, ...data }, + ), + ), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + payoutAttempt: { + create: jest.fn().mockResolvedValue({ id: "attempt-1" }), + update: jest.fn().mockResolvedValue({ id: "attempt-1" }), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + }; + const prisma = { + order: { findUnique: jest.fn().mockResolvedValue(order) }, + payout: { + findUnique: jest.fn().mockResolvedValue({ + ...createdPayout, + order, + store: { + id: "store-1", + userId: "user-1", + settlementAccountName: "Test Bank", + }, + }), + update: jest.fn().mockResolvedValue({ + ...createdPayout, + status: "SUBMITTED", + paystackTransferCode: null, + completedAt: null, + }), + }, + payoutAttempt: { + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + payment: { + findFirst: jest.fn().mockResolvedValue({ provider: "PAYSTACK" }), + }, + // §6 payout-hold guard queries pending settlements; none by default. + disputeSettlement: { findFirst: jest.fn().mockResolvedValue(null) }, + $transaction: jest.fn((callback: (transaction: typeof tx) => unknown) => + callback(tx), + ), + }; + const payoutProvider = { + key: "paystack", + initiateTransfer: jest.fn().mockResolvedValue({ + transferCode: "TRF_test", + reference: "PO-TESTREF", + status: "SUCCESS", + }), + verifyTransfer: jest.fn().mockResolvedValue({ + reference: "PO-TESTREF", + transferCode: "TRF_test", + status: "SUCCESS", + }), + }; + const notifications = { + triggerPayoutInitiated: jest.fn().mockResolvedValue(undefined), + triggerPayoutFailed: jest.fn().mockResolvedValue(undefined), + }; + const ledgerService = { + calculateOrderPayout: jest.fn().mockReturnValue({ + payoutAmountKobo: 245000n, + platformFeeKobo: 5000n, + }), + recordPayoutInitiated: jest.fn().mockResolvedValue(undefined), + recordPayoutCompleted: jest.fn().mockResolvedValue(undefined), + recordPayoutFailed: jest.fn().mockResolvedValue(undefined), + recordEntry: jest.fn().mockResolvedValue(undefined), + }; + const payoutQueue = { add: jest.fn().mockResolvedValue(undefined) }; + const retryQueue = { add: jest.fn().mockResolvedValue(undefined) }; + const domainEvents = { + append: jest.fn().mockResolvedValue({ id: "domain-event-1" }), + }; + const payoutProviders = { get: jest.fn().mockReturnValue(payoutProvider) }; + const service = new PayoutService( + prisma as never, + payoutProviders as never, + notifications as never, + ledgerService as never, + payoutQueue as never, + retryQueue as never, + domainEvents as never, + ); + + return { + service, + tx, + payoutQueue, + retryQueue, + domainEvents, + ledgerService, + payoutProvider, + notifications, + prisma, + payoutProviders, + }; + }; + + it("records PAYOUT_QUEUED after queueing succeeds", async () => { + const { service, payoutQueue, domainEvents } = makeService(); + + await service.queuePayout("order-1"); + + expect(payoutQueue.add).toHaveBeenCalledWith( + "process-payout", + { orderId: "order-1", attempt: 1 }, + expect.objectContaining({ jobId: "payout-order-1" }), + ); + expect(domainEvents.append).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + aggregateType: "ORDER", + aggregateId: "order-1", + eventType: "PAYOUT_QUEUED", + metadata: expect.objectContaining({ attempt: 1 }), + }), + ); + }); + + it("prepares payout rows for admin review without initiating transfer", async () => { + const { service, tx, domainEvents, ledgerService, payoutProvider } = + makeService(); + + await service.preparePayoutForReview("order-1"); + + expect(tx.payout.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + orderId: "order-1", + amountKobo: 245000n, + platformFeeKobo: 5000n, + status: "PENDING", + initiatedAt: null, + }), + }), + ); + expect(ledgerService.recordPayoutInitiated).toHaveBeenCalledWith( + expect.objectContaining({ amountKobo: 245000n }), + tx, + ); + expect(payoutProvider.initiateTransfer).not.toHaveBeenCalled(); + expect(domainEvents.append).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + aggregateType: "PAYOUT", + aggregateId: "payout-1", + eventType: "PAYOUT_READY_FOR_REVIEW", + }), + ); + }); + + it("binds a new payout to the provider that captured the order payment", async () => { + const { service, prisma, tx } = makeService(); + prisma.order.findUnique.mockResolvedValueOnce({ + id: "order-1", + status: "COMPLETED", + totalAmountKobo: 250000n, + platformFeeKobo: 5000n, + payments: [{ provider: "MONNIFY" }], + storeProfile: { + id: "store-1", + userId: "user-1", + paystackRecipientCode: null, + payoutRecipientProvider: "MONNIFY", + payoutRecipientReference: null, + bankCode: "058", + accountNumber: "0123456789", + accountName: "Test Merchant", + settlementAccountName: "Test Merchant", + }, + product: { name: "Product" }, + quantity: 1, + }); + prisma.payout.findUnique.mockResolvedValueOnce(null); + + await service.preparePayoutForReview("order-1"); + + expect(tx.payout.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ provider: "MONNIFY" }), + }), + ); + }); + + it("fails closed when an existing payout provider differs from its captured payment", async () => { + const { service, prisma, tx } = makeService(); + prisma.order.findUnique.mockResolvedValueOnce({ + id: "order-1", + status: "COMPLETED", + totalAmountKobo: 250000n, + platformFeeKobo: 5000n, + payments: [{ provider: "MONNIFY" }], + storeProfile: { + id: "store-1", + userId: "user-1", + paystackRecipientCode: "RCP_test", + payoutRecipientProvider: "PAYSTACK", + payoutRecipientReference: "RCP_test", + bankCode: "058", + accountNumber: "0123456789", + accountName: "Test Merchant", + settlementAccountName: "Test Merchant", + }, + product: { name: "Product" }, + quantity: 1, + }); + prisma.payout.findUnique.mockResolvedValueOnce({ + provider: "PAYSTACK", + onHold: false, + settlementLegId: null, + destinationRecipientReference: "RCP_test", + destinationBankCode: "058", + destinationAccountNumber: "0123456789", + destinationAccountName: "Test Merchant", + }); + + await service.preparePayoutForReview("order-1"); + + expect(tx.payout.create).not.toHaveBeenCalled(); + expect(tx.payout.update).not.toHaveBeenCalled(); + }); + + it("fails closed if a mismatched payout is created while reserving a review row", async () => { + const { service, prisma, tx } = makeService(); + prisma.order.findUnique.mockResolvedValueOnce({ + id: "order-1", + status: "COMPLETED", + totalAmountKobo: 250000n, + platformFeeKobo: 5000n, + payments: [{ provider: "MONNIFY" }], + storeProfile: { + id: "store-1", + userId: "user-1", + paystackRecipientCode: null, + payoutRecipientProvider: "MONNIFY", + payoutRecipientReference: null, + bankCode: "058", + accountNumber: "0123456789", + accountName: "Test Merchant", + settlementAccountName: "Test Merchant", + }, + product: { name: "Product" }, + quantity: 1, + }); + // The initial read sees no row, but the transaction sees a payout created + // concurrently with a provider binding that must never be reused. + prisma.payout.findUnique.mockResolvedValueOnce(null); + tx.payout.findUnique.mockResolvedValueOnce({ + provider: "PAYSTACK", + status: "PENDING", + }); + + await expect( + service.preparePayoutForReview("order-1"), + ).resolves.toBeUndefined(); + + expect(tx.payout.create).not.toHaveBeenCalled(); + expect(tx.payout.update).not.toHaveBeenCalled(); + }); + + it("releases a reviewed payout through the configured provider", async () => { + const { service, tx, domainEvents, ledgerService, payoutProvider } = + makeService(); + + await service.releasePayout("payout-1", "admin-1", 1); + + expect(payoutProvider.initiateTransfer).toHaveBeenCalledWith( + expect.objectContaining({ + destination: expect.objectContaining({ + recipientCode: "RCP_test", + bankCode: "058", + }), + amountKobo: 245000n, + }), + ); + expect(ledgerService.recordPayoutCompleted).toHaveBeenCalledWith( + expect.objectContaining({ reference: "TRF_test" }), + tx, + ); + expect(domainEvents.append).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + aggregateType: "PAYOUT", + aggregateId: "payout-1", + eventType: "PAYOUT_PROCESSING", + metadata: expect.objectContaining({ releasedBy: "admin-1" }), + }), + ); + expect(domainEvents.append).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + eventType: "PAYOUT_SENT", + metadata: expect.objectContaining({ + amountKobo: "245000", + reference: "TRF_test", + releasedBy: "admin-1", + }), + }), + ); + }); + + it("uses the provider reference when a completed transfer has no operation id", async () => { + const { service, payoutProvider, ledgerService, domainEvents } = + makeService(); + payoutProvider.initiateTransfer.mockResolvedValueOnce({ + transferCode: null, + reference: "PO-PROVIDER-REFERENCE", + status: "SUCCESS", + }); + + await service.releasePayout("payout-1", "admin-1", 1); + + expect(ledgerService.recordPayoutCompleted).toHaveBeenCalledWith( + expect.objectContaining({ reference: "PO-PROVIDER-REFERENCE" }), + expect.any(Object), + ); + expect(domainEvents.append).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + eventType: "PAYOUT_SENT", + metadata: expect.objectContaining({ + reference: "PO-PROVIDER-REFERENCE", + }), + }), + ); + }); + + it("keeps a payout SUBMITTED when the provider response is lost", async () => { + const { service, payoutProvider, tx, ledgerService, domainEvents } = + makeService(); + payoutProvider.initiateTransfer.mockRejectedValueOnce( + new Error("network response lost"), + ); + + const result = await service.releasePayout("payout-1", "admin-1", 1); + + expect(result).toEqual(expect.objectContaining({ status: "SUBMITTED" })); + expect(tx.payout.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: "SUBMITTED", + providerStatus: "UNKNOWN", + paystackReference: expect.stringMatching(/^PO-PAYOUT-1-/), + }), + }), + ); + expect(ledgerService.recordPayoutFailed).not.toHaveBeenCalled(); + expect( + domainEvents.append.mock.calls.some( + ([, event]) => event.eventType === "PAYOUT_FAILED", + ), + ).toBe(false); + }); + + it("does not downgrade a confirmed payout when its notification fails", async () => { + const { service, notifications, prisma, ledgerService } = makeService(); + notifications.triggerPayoutInitiated.mockRejectedValueOnce( + new Error("notification unavailable"), + ); + + const result = await service.releasePayout("payout-1", "admin-1", 1); + + expect(result).toEqual(expect.objectContaining({ status: "COMPLETED" })); + expect(ledgerService.recordPayoutCompleted).toHaveBeenCalledTimes(1); + expect(prisma.payout.update).not.toHaveBeenCalled(); + }); + + it("records and notifies a provider-verified submitted payout completion", async () => { + const { service, prisma, ledgerService, notifications, domainEvents } = + makeService(); + prisma.payout.findUnique.mockResolvedValueOnce({ + id: "payout-1", + orderId: "order-1", + storeId: "store-1", + amountKobo: 245000n, + platformFeeKobo: 5000n, + paystackReference: "PO-TESTREF", + provider: "PAYSTACK", + providerReference: "PO-TESTREF", + settlementLegId: null, + status: "SUBMITTED", + order: { product: { name: "Product" }, quantity: 1 }, + store: { userId: "user-1", settlementAccountName: "Test Bank" }, + }); + + const result = await service.reconcileSubmittedPayout("payout-1"); + + expect(result).toEqual({ status: "COMPLETED", settlementLegId: null }); + expect(ledgerService.recordEntry).toHaveBeenCalledWith( + expect.objectContaining({ entryType: "PAYOUT_COMPLETED" }), + expect.any(Object), + ); + expect(notifications.triggerPayoutInitiated).toHaveBeenCalledWith( + "user-1", + expect.objectContaining({ orderId: "order-1" }), + ); + expect(domainEvents.append).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ eventType: "PAYOUT_SENT" }), + ); + }); + + it("uses the verified provider reference when reconciliation has no operation id", async () => { + const { service, prisma, payoutProvider, ledgerService, domainEvents } = + makeService(); + prisma.payout.findUnique.mockResolvedValueOnce({ + id: "payout-1", + orderId: "order-1", + storeId: "store-1", + amountKobo: 245000n, + platformFeeKobo: 5000n, + paystackReference: null, + provider: "MONNIFY", + providerReference: "PO-MONNIFY-1", + settlementLegId: null, + status: "SUBMITTED", + order: { product: { name: "Product" }, quantity: 1 }, + store: { userId: "user-1", settlementAccountName: "Test Bank" }, + }); + payoutProvider.verifyTransfer.mockResolvedValueOnce({ + reference: "PO-MONNIFY-1", + transferCode: null, + status: "SUCCESS", + }); + + await service.reconcileSubmittedPayout("payout-1"); + + expect(ledgerService.recordEntry).toHaveBeenCalledWith( + expect.objectContaining({ reference: "PO-MONNIFY-1" }), + expect.any(Object), + ); + expect(domainEvents.append).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + eventType: "PAYOUT_SENT", + metadata: expect.objectContaining({ reference: "PO-MONNIFY-1" }), + }), + ); + }); + + it("records and notifies a provider-verified submitted payout failure", async () => { + const { + service, + prisma, + payoutProvider, + ledgerService, + notifications, + domainEvents, + } = makeService(); + prisma.payout.findUnique.mockResolvedValueOnce({ + id: "payout-1", + orderId: "order-1", + storeId: "store-1", + amountKobo: 245000n, + platformFeeKobo: 5000n, + paystackReference: "PO-TESTREF", + provider: "PAYSTACK", + providerReference: "PO-TESTREF", + settlementLegId: null, + status: "SUBMITTED", + order: { product: { name: "Product" }, quantity: 1 }, + store: { userId: "user-1", settlementAccountName: "Test Bank" }, + }); + payoutProvider.verifyTransfer.mockResolvedValueOnce({ + reference: "PO-TESTREF", + transferCode: "TRF_test", + status: "FAILED", + }); + + const result = await service.reconcileSubmittedPayout("payout-1"); + + expect(result).toEqual({ status: "FAILED", settlementLegId: null }); + expect(ledgerService.recordPayoutFailed).toHaveBeenCalledWith( + expect.objectContaining({ payoutId: "payout-1" }), + expect.any(Object), + ); + expect(notifications.triggerPayoutFailed).toHaveBeenCalledWith("user-1", { + orderRef: "ORDER-1", + }); + expect(domainEvents.append).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ eventType: "PAYOUT_FAILED" }), + ); + }); + + // Regression: a payout that FAILED once must be releasable again. Keying the + // per-attempt domain events on `attempt` (always 1) reused the first + // attempt's idempotency keys, so the retry's INSERT hit the DomainEvent + // idempotency_key unique constraint, poisoned the Postgres transaction, and + // surfaced as "current transaction is aborted". Each release must generate a + // fresh key. + it("uses a distinct PAYOUT_PROCESSING idempotency key per release attempt", async () => { + const { service, tx, domainEvents } = makeService(); + + // Two full releases need the update mock to resolve on every call, not just + // the two pre-queued values the shared setup provides. + tx.payout.update.mockReset(); + tx.payout.update.mockResolvedValue({ + id: "payout-1", + orderId: "order-1", + storeId: "store-1", + amountKobo: 245000n, + platformFeeKobo: 5000n, + status: "PROCESSING", + paystackTransferCode: "TRF_test", + completedAt: null, + }); + + await service.releasePayout("payout-1", "admin-1"); + await service.releasePayout("payout-1", "admin-1"); + + const processingKeys = domainEvents.append.mock.calls + .map((call) => call[1] as { eventType: string; idempotencyKey?: string }) + .filter((event) => event.eventType === "PAYOUT_PROCESSING") + .map((event) => event.idempotencyKey); + + expect(processingKeys).toHaveLength(2); + expect(processingKeys[0]).toEqual(expect.stringContaining("payout-1")); + expect(processingKeys[0]).not.toEqual(processingKeys[1]); + }); + + it("does not downgrade an already submitted payout during duplicate preparation", async () => { + const { service, prisma, tx, payoutProvider } = makeService(); + prisma.payout.findUnique.mockResolvedValueOnce({ + provider: "PAYSTACK", + status: "SUBMITTED", + onHold: false, + settlementLegId: null, + destinationRecipientReference: "RCP_test", + destinationBankCode: "058", + destinationAccountNumber: "0123456789", + destinationAccountName: "Test Bank", + }); + tx.payout.findUnique.mockResolvedValueOnce({ + id: "payout-1", + status: "SUBMITTED", + provider: "PAYSTACK", + }); + tx.payout.findUniqueOrThrow.mockResolvedValueOnce({ + id: "payout-1", + orderId: "order-1", + storeId: "store-1", + amountKobo: 245000n, + platformFeeKobo: 5000n, + status: "SUBMITTED", + }); + + await service.preparePayoutForReview("order-1"); + + expect(tx.payout.update).not.toHaveBeenCalled(); + expect(payoutProvider.initiateTransfer).not.toHaveBeenCalled(); + }); + + it("hydrates missing legacy payout destination fields during safe reuse", async () => { + const { service, prisma, tx } = makeService(); + prisma.payout.findUnique.mockResolvedValueOnce({ + provider: "PAYSTACK", + status: "PENDING", + onHold: false, + settlementLegId: null, + destinationRecipientReference: null, + destinationBankCode: null, + destinationAccountNumber: null, + destinationAccountName: null, + }); + tx.payout.findUnique.mockResolvedValueOnce({ + id: "payout-1", + status: "PENDING", + provider: "PAYSTACK", + destinationRecipientReference: null, + destinationBankCode: null, + destinationAccountNumber: null, + destinationAccountName: null, + }); + + await service.preparePayoutForReview("order-1"); + + expect(tx.payout.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + destinationRecipientReference: "RCP_test", + destinationBankCode: "058", + destinationAccountNumber: "0123456789", + destinationAccountName: "Test Bank", + }), + }), + ); + }); + + it("uses the payout's immutable provider and destination after configuration changes", async () => { + const { service, prisma, tx, payoutProviders, payoutProvider } = + makeService(); + prisma.payout.findUnique.mockResolvedValueOnce({ + id: "payout-1", + orderId: "order-1", + storeId: "store-1", + amountKobo: 245000n, + platformFeeKobo: 5000n, + status: "PENDING", + provider: "MONNIFY", + providerReference: null, + providerOperationId: null, + destinationRecipientReference: null, + destinationBankCode: "058", + destinationAccountNumber: "0123456789", + destinationAccountName: "Original Account", + paystackTransferCode: null, + completedAt: null, + order: { + id: "order-1", + status: "COMPLETED", + totalAmountKobo: 250000n, + platformFeeKobo: 5000n, + storeProfile: { + id: "store-1", + userId: "user-1", + bankCode: "999", + accountNumber: "9999999999", + accountName: "Changed Account", + settlementAccountName: "Changed Account", + paystackRecipientCode: "RCP_changed", + payoutRecipientProvider: "PAYSTACK", + payoutRecipientReference: "RCP_changed", + }, + product: { name: "Product" }, + quantity: 1, + }, + store: { id: "store-1", userId: "user-1" }, + }); + + await service.releasePayout("payout-1", "admin-1"); + + expect(payoutProviders.get).toHaveBeenCalledWith("monnify"); + expect(payoutProvider.initiateTransfer).toHaveBeenCalledWith( + expect.objectContaining({ + destination: { + recipientCode: null, + bankCode: "058", + accountNumber: "0123456789", + accountName: "Original Account", + }, + }), + ); + expect(tx.payout.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + destinationBankCode: "058", + destinationAccountNumber: "0123456789", + destinationAccountName: "Original Account", + }), + }), + ); + }); + + it("does not submit a second settlement transfer for an in-flight payout", async () => { + const { service, prisma, tx, payoutProvider } = makeService(); + Object.assign(prisma, { + disputeSettlementLeg: { + findUnique: jest.fn().mockResolvedValue({ + type: "MERCHANT_PAYOUT", + status: "PROCESSING", + amountKobo: 245000n, + attempts: 1, + settlement: { + orderId: "order-1", + order: { storeId: "store-1" }, + }, + }), + }, + storeProfile: { + findUnique: jest.fn().mockResolvedValue({ + paystackRecipientCode: "RCP_test", + payoutRecipientProvider: "PAYSTACK", + payoutRecipientReference: "RCP_test", + bankCode: "058", + accountNumber: "0123456789", + accountName: "Test Merchant", + settlementAccountName: "Test Merchant", + }), + }, + }); + prisma.payout.findUnique.mockResolvedValueOnce({ + provider: "PAYSTACK", + destinationRecipientReference: "RCP_test", + destinationBankCode: "058", + destinationAccountNumber: "0123456789", + destinationAccountName: "Test Merchant", + }); + tx.payout.findUnique.mockResolvedValueOnce({ + id: "payout-1", + status: "SUBMITTED", + provider: "PAYSTACK", + providerReference: "PO-IN-FLIGHT", + providerOperationId: "TRF_IN_FLIGHT", + destinationRecipientReference: "RCP_test", + destinationBankCode: "058", + destinationAccountNumber: "0123456789", + destinationAccountName: "Test Merchant", + attempts: [], + }); + + await expect( + service.executeSettlementPayout({ + legId: "leg-1", + orderId: "order-1", + storeId: "store-1", + amountKobo: 245000n, + approvedBy: "admin-1", + }), + ).resolves.toEqual({ + status: "SUBMITTED", + provider: "paystack", + providerReference: "PO-IN-FLIGHT", + providerOperationId: "TRF_IN_FLIGHT", + }); + expect(payoutProvider.initiateTransfer).not.toHaveBeenCalled(); + expect(tx.payout.update).not.toHaveBeenCalled(); + expect(tx.payoutAttempt.create).not.toHaveBeenCalled(); + }); + + it("returns a failed settlement result if a mismatched payout appears during the claim", async () => { + const { service, prisma, tx, payoutProvider } = makeService(); + Object.assign(prisma, { + disputeSettlementLeg: { + findUnique: jest.fn().mockResolvedValue({ + type: "MERCHANT_PAYOUT", + status: "PROCESSING", + amountKobo: 245000n, + attempts: 1, + settlement: { + orderId: "order-1", + order: { storeId: "store-1" }, + }, + }), + }, + payment: { + findFirst: jest.fn().mockResolvedValue({ provider: "MONNIFY" }), + }, + storeProfile: { + findUnique: jest.fn().mockResolvedValue({ + paystackRecipientCode: null, + payoutRecipientProvider: "MONNIFY", + payoutRecipientReference: null, + bankCode: "058", + accountNumber: "0123456789", + accountName: "Test Merchant", + settlementAccountName: "Test Merchant", + }), + }, + }); + prisma.payout.findUnique.mockResolvedValueOnce(null); + tx.payout.findUnique.mockResolvedValueOnce({ + id: "payout-1", + status: "PENDING", + provider: "PAYSTACK", + providerReference: null, + providerOperationId: null, + destinationRecipientReference: "RCP_test", + destinationBankCode: "058", + destinationAccountNumber: "0123456789", + destinationAccountName: "Test Merchant", + attempts: [], + }); + + await expect( + service.executeSettlementPayout({ + legId: "leg-1", + orderId: "order-1", + storeId: "store-1", + amountKobo: 245000n, + approvedBy: "admin-1", + }), + ).resolves.toEqual({ + status: "FAILED", + provider: null, + providerReference: null, + providerOperationId: null, + error: + "Existing payout provider does not match the captured payment provider", + errorCode: "PAYOUT_PROVIDER_MISMATCH", + }); + expect(payoutProvider.initiateTransfer).not.toHaveBeenCalled(); + expect(tx.payout.create).not.toHaveBeenCalled(); + expect(tx.payout.update).not.toHaveBeenCalled(); + }); + + it("allows a Paystack settlement payout with a validated recipient only", async () => { + const { service, prisma, tx, payoutProvider } = makeService(); + Object.assign(prisma, { + disputeSettlementLeg: { + findUnique: jest.fn().mockResolvedValue({ + type: "MERCHANT_PAYOUT", + status: "PROCESSING", + amountKobo: 245000n, + attempts: 1, + settlement: { + orderId: "order-1", + order: { storeId: "store-1" }, + }, + }), + }, + storeProfile: { + findUnique: jest.fn().mockResolvedValue({ + paystackRecipientCode: "RCP_test", + payoutRecipientProvider: "PAYSTACK", + payoutRecipientReference: "RCP_test", + bankCode: null, + accountNumber: null, + accountName: null, + settlementAccountName: null, + }), + }, + }); + prisma.payout.findUnique.mockResolvedValueOnce(null); + tx.payout.findUnique.mockResolvedValueOnce(null); + + const result = await service.executeSettlementPayout({ + legId: "leg-1", + orderId: "order-1", + storeId: "store-1", + amountKobo: 245000n, + approvedBy: "admin-1", + }); + + expect(result.status).toBe("COMPLETED"); + expect(payoutProvider.initiateTransfer).toHaveBeenCalledWith( + expect.objectContaining({ + destination: expect.objectContaining({ + recipientCode: "RCP_test", + }), + }), + ); + }); + + it("binds a settlement payout to the provider that captured the order payment", async () => { + const { service, prisma, tx } = makeService(); + Object.assign(prisma, { + disputeSettlementLeg: { + findUnique: jest.fn().mockResolvedValue({ + type: "MERCHANT_PAYOUT", + status: "PROCESSING", + amountKobo: 245000n, + attempts: 1, + settlement: { + orderId: "order-1", + order: { storeId: "store-1" }, + }, + }), + }, + payment: { + findFirst: jest.fn().mockResolvedValue({ provider: "MONNIFY" }), + }, + storeProfile: { + findUnique: jest.fn().mockResolvedValue({ + paystackRecipientCode: null, + payoutRecipientProvider: "MONNIFY", + payoutRecipientReference: null, + bankCode: "058", + accountNumber: "0123456789", + accountName: "Test Merchant", + settlementAccountName: "Test Merchant", + }), + }, + }); + prisma.payout.findUnique.mockResolvedValueOnce(null); + tx.payout.findUnique.mockResolvedValueOnce(null); + + await service.executeSettlementPayout({ + legId: "leg-1", + orderId: "order-1", + storeId: "store-1", + amountKobo: 245000n, + approvedBy: "admin-1", + }); + + expect(tx.payout.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ provider: "MONNIFY" }), + }), + ); + }); +}); diff --git a/apps/backend/src/domains/money/payout/payout.service.ts b/apps/backend/src/domains/money/payout/payout.service.ts new file mode 100644 index 00000000..23bfd0ad --- /dev/null +++ b/apps/backend/src/domains/money/payout/payout.service.ts @@ -0,0 +1,2030 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { + DisputeSettlementStatus, + LedgerDirection, + LedgerEntryType, + OrderDisputeStatus, + OrderStatus, + PaymentStatus, + PayoutProviderName, + Prisma, +} from "@prisma/client"; +import { InjectQueue } from "@nestjs/bullmq"; +import { Queue } from "bullmq"; +import { randomUUID } from "crypto"; +import { PrismaService } from "../../../prisma/prisma.service"; +import { NotificationTriggerService } from "../../social/notification/notification-trigger.service"; +import { LedgerService } from "../ledger/ledger.service"; +import { + PAYOUT_QUEUE, + PAYOUT_RETRY_QUEUE, +} from "../../../queue/queue.constants"; +import { DomainEventService } from "../../platform/domain-event/domain-event.service"; +import { InitiatePayoutTransferResult } from "./providers/payout-provider.interface"; +import { + PAYOUT_PROVIDER_REGISTRY, + PayoutProviderKey, + PayoutProviderRegistry, +} from "./providers/payout-provider.registry"; +import { payoutProviderForPaymentProvider } from "./providers/payout-provider-continuity"; + +type PayoutDomainEventType = + | "PAYOUT_QUEUED" + | "PAYOUT_PROCESSING" + | "PAYOUT_READY_FOR_REVIEW" + | "PAYOUT_SUBMITTED" + | "PAYOUT_SENT" + | "PAYOUT_FAILED" + | "PAYOUT_RETRIED"; + +// How long to wait before the first transfer reconciliation poll for a +// submitted-but-unconfirmed payout. +const PAYOUT_RECONCILE_DELAY_MS = 2 * 60 * 1000; + +// Retry schedule: attempt 1 (immediate) -> 5min -> 30min -> 2h. After +// attempt 4 the payout is marked FAILED and the merchant is notified. +const RETRY_DELAYS_MS: readonly number[] = [ + 0, + 5 * 60 * 1000, + 30 * 60 * 1000, + 2 * 60 * 60 * 1000, +]; +const MAX_PAYOUT_ATTEMPTS = RETRY_DELAYS_MS.length; + +@Injectable() +export class PayoutService { + private readonly logger = new Logger(PayoutService.name); + + constructor( + private readonly prisma: PrismaService, + @Inject(PAYOUT_PROVIDER_REGISTRY) + private readonly payoutProviders: PayoutProviderRegistry, + private readonly notifications: NotificationTriggerService, + private readonly ledgerService: LedgerService, + @InjectQueue(PAYOUT_QUEUE) private readonly payoutQueue: Queue, + @InjectQueue(PAYOUT_RETRY_QUEUE) private readonly payoutRetryQueue: Queue, + private readonly domainEvents: DomainEventService, + ) {} + + private providerFor(provider: PayoutProviderName) { + return this.payoutProviders.get( + provider.toLowerCase() as PayoutProviderKey, + ); + } + + private providerReferenceData( + provider: PayoutProviderName, + reference: string, + operationId: string | null, + ) { + return { + providerReference: reference, + providerOperationId: operationId, + ...(provider === PayoutProviderName.PAYSTACK + ? { + paystackReference: reference, + paystackTransferCode: operationId, + } + : {}), + }; + } + + private async appendPayoutDomainEvent( + tx: Prisma.TransactionClient, + payout: { + id: string; + orderId: string; + storeId?: string | null; + amountKobo?: bigint | number | null; + platformFeeKobo?: bigint | number | null; + }, + eventType: PayoutDomainEventType, + metadata: Record = {}, + idempotencyKey?: string, + ) { + await this.domainEvents.append(tx, { + aggregateType: "PAYOUT", + aggregateId: payout.id, + eventType, + actorType: "SYSTEM", + actorId: null, + source: "payout.service", + metadata: { + payoutId: payout.id, + orderId: payout.orderId, + storeId: payout.storeId, + amountKobo: payout.amountKobo?.toString() ?? null, + platformFeeKobo: payout.platformFeeKobo?.toString() ?? null, + ...metadata, + }, + idempotencyKey: idempotencyKey ?? null, + }); + } + + private async appendOrderPayoutDomainEvent( + eventType: PayoutDomainEventType, + orderId: string, + metadata: Record, + idempotencyKey: string, + ) { + await this.prisma.$transaction(async (tx) => { + await this.domainEvents.append(tx, { + aggregateType: "ORDER", + aggregateId: orderId, + eventType, + actorType: "SYSTEM", + actorId: null, + source: "payout.service", + metadata: { orderId, ...metadata }, + idempotencyKey, + }); + }); + } + + // Phase 5 §6 — database-enforced payout protection. A held payout can never + // progress to a provider transfer regardless of queue state. Settlement-leg + // payouts (payout.settlementLegId set) are pre-approved dispute outcomes and + // bypass the normal dispute hold for that exact leg ONLY. Returns a domain + // error code; never silently proceeds. + private async assertPayoutNotBlocked( + order: { + id: string; + status: OrderStatus | string; + disputeStatus: OrderDisputeStatus | string | null; + disputeWindowEndsAt?: Date | null; + }, + payout: { onHold?: boolean | null; settlementLegId?: string | null }, + ): Promise { + const block = (message: string, code: string): never => { + throw new ForbiddenException({ message, code }); + }; + + if (payout.onHold) { + block( + "Payout is on hold pending dispute resolution", + "PAYOUT_BLOCKED_BY_DISPUTE", + ); + } + + // A merchant-payout settlement leg is itself the approved dispute outcome — + // the remaining checks (which exist to protect against disputes) do not + // apply to it. + if (payout.settlementLegId) { + return; + } + + if ( + order.status === OrderStatus.DISPUTE || + order.disputeStatus === OrderDisputeStatus.PENDING + ) { + block( + "Payout is blocked by an active dispute", + "PAYOUT_BLOCKED_BY_DISPUTE", + ); + } + + const pendingSettlement = await this.prisma.disputeSettlement.findFirst({ + where: { + orderId: order.id, + status: { + in: [ + DisputeSettlementStatus.PENDING, + DisputeSettlementStatus.PROCESSING, + DisputeSettlementStatus.RECONCILIATION_REQUIRED, + DisputeSettlementStatus.MANUAL_REVIEW, + ], + }, + }, + select: { id: true }, + }); + if (pendingSettlement) { + block( + "Payout is blocked by a pending dispute settlement", + "PAYOUT_BLOCKED_BY_SETTLEMENT", + ); + } + + // Escrow guarantee: never release while the shopper can still open a + // dispute. + if ( + order.status === OrderStatus.COMPLETED && + order.disputeWindowEndsAt && + order.disputeWindowEndsAt > new Date() + ) { + block( + "Payout is blocked until the dispute window closes", + "PAYOUT_BLOCKED_BY_DISPUTE_WINDOW", + ); + } + } + + // Single entry point for queueing a payout after order completion. + // jobId is stable per order so BullMQ rejects duplicates while the + // job is still active or waiting; repeated calls do not stack jobs + // and do not create duplicate Payout rows. + async queuePayout(orderId: string): Promise { + await this.payoutQueue.add( + "process-payout", + { orderId, attempt: 1 }, + { + jobId: `payout-${orderId}`, + removeOnComplete: true, + removeOnFail: true, + }, + ); + await this.appendOrderPayoutDomainEvent( + "PAYOUT_QUEUED", + orderId, + { attempt: 1 }, + `order:${orderId}:payout-queued:attempt-1`, + ); + this.logger.log(`Queued payout for order ${orderId} (attempt 1)`); + } + + // Read-only payout history for a store's own payouts page. Returns the + // approved-display fields only (never recipient codes or transfer internals). + async listByStore(storeId: string): Promise< + { + id: string; + orderCode: string | null; + productName: string | null; + amountKobo: string; + status: string; + createdAt: Date; + completedAt: Date | null; + }[] + > { + const payouts = await this.prisma.payout.findMany({ + where: { storeId }, + orderBy: { createdAt: "desc" }, + take: 50, + include: { + order: { + select: { + orderCode: true, + product: { select: { name: true } }, + }, + }, + }, + }); + + return payouts.map((payout) => ({ + id: payout.id, + orderCode: payout.order?.orderCode ?? null, + productName: payout.order?.product?.name ?? null, + amountKobo: payout.amountKobo.toString(), + status: payout.status, + createdAt: payout.createdAt, + completedAt: payout.completedAt, + })); + } + + // Re-enqueue this payout for another attempt on the dedicated retry + // queue. Different jobId per attempt so the same order can flow + // 1 -> 2 -> 3 -> 4 without colliding with itself in the queue. + async scheduleRetry(orderId: string, nextAttempt: number): Promise { + if (nextAttempt < 2 || nextAttempt > MAX_PAYOUT_ATTEMPTS) { + this.logger.warn( + `Refusing to schedule retry for order ${orderId}: attempt ${nextAttempt} out of range`, + ); + return; + } + const delay = RETRY_DELAYS_MS[nextAttempt - 1]; + await this.payoutRetryQueue.add( + "retry-payout", + { orderId, attempt: nextAttempt }, + { + delay, + jobId: `payout-retry-${orderId}-${nextAttempt}`, + removeOnComplete: true, + removeOnFail: true, + }, + ); + await this.appendOrderPayoutDomainEvent( + "PAYOUT_RETRIED", + orderId, + { attempt: nextAttempt, delayMs: delay }, + `order:${orderId}:payout-retried:attempt-${nextAttempt}`, + ); + this.logger.log( + `Scheduled payout retry for order ${orderId} (attempt ${nextAttempt}, delay ${delay}ms)`, + ); + } + + // Queue processors only prepare the payout row for admin review. Releasing + // funds remains an explicit admin action through releasePayout(). + async preparePayoutForReview(orderId: string): Promise { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + include: { + storeProfile: true, + product: true, + payments: { + where: { status: PaymentStatus.SUCCESS }, + orderBy: { verifiedAt: "desc" }, + take: 1, + select: { provider: true }, + }, + }, + }); + if (!order) { + this.logger.error(`Order not found for payout: ${orderId}`); + return; + } + + const capturedPayment = order.payments[0]; + if (!capturedPayment) { + this.logger.error( + `Refusing payout review for order ${orderId}: no successful payment provider is recorded`, + ); + return; + } + const paymentProvider = payoutProviderForPaymentProvider( + capturedPayment.provider, + ); + + if (order.status !== "COMPLETED") { + this.logger.warn( + `Refusing payout review for order ${orderId}: status ${order.status} (must be COMPLETED)`, + ); + return; + } + + // Never queue a disputed / settlement-held order for release. This is a + // best-effort queue path, so a block is a skip-and-log, not a throw. + let existingPayout: { + onHold: boolean; + settlementLegId: string | null; + provider: PayoutProviderName; + destinationRecipientReference: string | null; + destinationBankCode: string | null; + destinationAccountNumber: string | null; + destinationAccountName: string | null; + } | null = null; + try { + existingPayout = await this.prisma.payout.findUnique({ + where: { orderId }, + select: { + onHold: true, + settlementLegId: true, + provider: true, + destinationRecipientReference: true, + destinationBankCode: true, + destinationAccountNumber: true, + destinationAccountName: true, + }, + }); + await this.assertPayoutNotBlocked(order, existingPayout ?? {}); + } catch (error) { + if (error instanceof ForbiddenException) { + const code = (error.getResponse() as { code?: string }).code; + this.logger.warn( + `Skipping payout review for order ${orderId}: ${code ?? "blocked"}`, + ); + return; + } + throw error; + } + + const merchant = order.storeProfile; + if (!merchant) { + this.logger.error(`Store profile missing for order ${orderId}`); + return; + } + + const payoutBreakdown = this.ledgerService.calculateOrderPayout(order); + const platformFeeKobo = payoutBreakdown.platformFeeKobo; + const payoutAmountKobo = payoutBreakdown.payoutAmountKobo; + + if (payoutAmountKobo <= 0n) { + this.logger.error( + `Refusing payout review for order ${orderId}: non-positive amount ${payoutAmountKobo}`, + ); + return; + } + + if (existingPayout && existingPayout.provider !== paymentProvider) { + this.logger.error( + `Refusing payout review for order ${orderId}: payout provider ${existingPayout.provider} does not match captured payment provider ${capturedPayment.provider}`, + ); + return; + } + const selectedProvider = existingPayout?.provider ?? paymentProvider; + const selectedRecipientReference = + existingPayout?.destinationRecipientReference ?? + (merchant.payoutRecipientProvider === selectedProvider + ? merchant.payoutRecipientReference + : selectedProvider === PayoutProviderName.PAYSTACK + ? merchant.paystackRecipientCode + : null); + const selectedBankCode = + existingPayout?.destinationBankCode ?? merchant.bankCode; + const selectedAccountNumber = + existingPayout?.destinationAccountNumber ?? merchant.accountNumber; + const selectedAccountName = + existingPayout?.destinationAccountName ?? + merchant.settlementAccountName ?? + merchant.accountName; + const hasPayoutDestination = + selectedProvider === PayoutProviderName.PAYSTACK + ? Boolean(selectedRecipientReference) + : Boolean( + selectedBankCode && selectedAccountNumber && selectedAccountName, + ); + if (!hasPayoutDestination) { + await this.recordPermanentFailure( + orderId, + merchant.id, + merchant.userId, + payoutAmountKobo, + platformFeeKobo, + "Store has no registered bank account", + selectedProvider, + ); + return; + } + + const payout = await this.reservePayoutRow({ + orderId, + storeId: merchant.id, + amountKobo: payoutAmountKobo, + platformFeeKobo, + provider: selectedProvider, + status: "PENDING", + destination: { + recipientReference: selectedRecipientReference, + bankCode: selectedBankCode, + accountNumber: selectedAccountNumber, + accountName: selectedAccountName, + }, + }); + + if (payout && "kind" in payout) { + this.logger.error( + `Refusing payout review for order ${orderId}: payout provider changed during reservation and no longer matches the captured payment provider`, + ); + return; + } + + if (!payout) { + this.logger.log( + `Payout for order ${orderId} already COMPLETED, skipping review queue`, + ); + return; + } + + this.logger.log( + payout.status === "PENDING" + ? `Payout ${payout.id} for order ${orderId} is ready for admin review` + : `Payout ${payout.id} for order ${orderId} remains ${payout.status}; duplicate preparation did not change it`, + ); + } + + // Compatibility entry point for old callers. This now follows the same + // admin-reviewed contract as the queue processors and never initiates a + // provider transfer directly. + async initiatePayout(orderId: string, attempt: number = 1): Promise { + void attempt; + await this.preparePayoutForReview(orderId); + } + + async releasePayout( + payoutId: string, + adminUserId?: string, + attempt: number = 1, + ): Promise<{ + id: string; + orderId: string; + storeId: string; + amountKobo: string; + platformFeeKobo: string; + status: string; + paystackTransferCode: string | null; + completedAt: Date | null; + }> { + const payoutWithOrder = await this.prisma.payout.findUnique({ + where: { id: payoutId }, + include: { + order: { include: { storeProfile: true, product: true } }, + store: true, + }, + }); + + if (!payoutWithOrder) { + throw new NotFoundException("Payout not found"); + } + const payoutProvider = this.providerFor(payoutWithOrder.provider); + + if (payoutWithOrder.status === "COMPLETED") { + return this.toPayoutReleaseResponse(payoutWithOrder); + } + + if (!["PENDING", "FAILED"].includes(payoutWithOrder.status)) { + throw new BadRequestException( + `Payout cannot be released while ${payoutWithOrder.status.toLowerCase()}.`, + ); + } + + const order = payoutWithOrder.order; + if (order.status !== "COMPLETED") { + throw new BadRequestException( + `Payout cannot be released while order is ${order.status}.`, + ); + } + + const merchant = order.storeProfile; + const recipientReference = + payoutWithOrder.provider === PayoutProviderName.PAYSTACK + ? (payoutWithOrder.destinationRecipientReference ?? + (merchant?.payoutRecipientProvider === PayoutProviderName.PAYSTACK + ? merchant.payoutRecipientReference + : merchant?.paystackRecipientCode)) + : null; + const destinationBankCode = + payoutWithOrder.destinationBankCode ?? merchant?.bankCode; + const destinationAccountNumber = + payoutWithOrder.destinationAccountNumber ?? merchant?.accountNumber; + const destinationAccountName = + payoutWithOrder.destinationAccountName ?? + merchant?.settlementAccountName ?? + merchant?.accountName; + if ( + !merchant || + (payoutWithOrder.provider === PayoutProviderName.PAYSTACK && + !recipientReference) || + (payoutWithOrder.provider === PayoutProviderName.MONNIFY && + (!destinationBankCode || + !destinationAccountNumber || + !destinationAccountName)) + ) { + throw new BadRequestException("Store has no registered bank account."); + } + + // §6 — an admin release is refused for a disputed / held / settlement-pending + // order. Settlement-leg payouts bypass this hold for their own approved leg. + await this.assertPayoutNotBlocked(order, payoutWithOrder); + + const payoutBreakdown = this.ledgerService.calculateOrderPayout(order); + const platformFeeKobo = payoutBreakdown.platformFeeKobo; + const payoutAmountKobo = payoutBreakdown.payoutAmountKobo; + + if (payoutAmountKobo <= 0n) { + throw new BadRequestException("Payout amount must be greater than zero."); + } + + // Each manual release is a distinct real-world action. releasePayout is + // only ever invoked from the admin console (never a retrying queue job), + // and `attempt` always defaults to 1, so keying the per-attempt domain + // events and ledger writes on `attempt-${attempt}` made every retry of a + // previously-FAILED payout reuse the first attempt's idempotency keys. The + // colliding INSERT threw P2002 mid-transaction, which poisons the whole + // Postgres transaction ("current transaction is aborted...") and the + // in-transaction conflict recovery cannot run. A per-release token keeps + // each attempt's keys unique so a failed payout can be released again. + const releaseToken = randomUUID(); + const transferReference = `PO-${payoutId.slice(0, 8).toUpperCase()}-${releaseToken.slice(0, 8).toUpperCase()}`; + + await this.prisma.$transaction(async (tx) => { + const claim = await tx.payout.updateMany({ + where: { id: payoutId, status: { in: ["PENDING", "FAILED"] } }, + data: { + amountKobo: payoutAmountKobo, + platformFeeKobo, + destinationRecipientReference: + payoutWithOrder.destinationRecipientReference ?? recipientReference, + destinationBankCode: + payoutWithOrder.destinationBankCode ?? destinationBankCode, + destinationAccountNumber: + payoutWithOrder.destinationAccountNumber ?? + destinationAccountNumber, + destinationAccountName: + payoutWithOrder.destinationAccountName ?? destinationAccountName, + status: "PROCESSING", + initiatedAt: new Date(), + failureReason: null, + }, + }); + if (claim.count !== 1) { + throw new BadRequestException( + "Payout is no longer eligible for release", + ); + } + await tx.payoutAttempt.create({ + data: { + payoutId, + provider: payoutWithOrder.provider, + providerReference: transferReference, + status: "PROCESSING", + }, + }); + + await this.appendPayoutDomainEvent( + tx, + payoutWithOrder, + "PAYOUT_PROCESSING", + { attempt, releasedBy: adminUserId ?? null }, + `payout:${payoutId}:processing:${releaseToken}`, + ); + }); + + // A transport error cannot prove the provider did not accept the transfer: + // the request may have reached Paystack while the response was lost. Keep + // that outcome SUBMITTED and reconcile it by the deterministic reference; + // only an explicit provider FAILED result is safe to mark retryable. + let transferResponse!: InitiatePayoutTransferResult; + try { + this.logger.log( + `Releasing payout ${payoutId} for order ${order.id} (attempt ${attempt})`, + ); + transferResponse = await payoutProvider.initiateTransfer({ + destination: { + recipientCode: recipientReference, + bankCode: destinationBankCode ?? "", + accountNumber: destinationAccountNumber ?? "", + accountName: destinationAccountName ?? "", + }, + amountKobo: payoutAmountKobo, + reference: transferReference, + reason: `twizrr payout for Order #${order.id.slice(0, 8).toUpperCase()}`, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error( + `Payout ${payoutId} provider response was ambiguous; marking SUBMITTED for reconciliation: ${message}`, + ); + const submitted = await this.markReleaseSubmitted({ + payoutId, + provider: payoutWithOrder.provider, + providerStatus: "UNKNOWN", + transferCode: null, + reference: transferReference, + }); + return this.toPayoutReleaseResponse(submitted); + } + + // A provider-reported FAILED status means the transfer was created but + // failed; the consumed reference blocks a duplicate at the provider on a + // retry, so a clean FAILED is safe. + if (transferResponse.status === "FAILED") { + await this.recordReleaseFailure({ + payoutId, + orderId: order.id, + storeId: merchant.id, + amountKobo: payoutAmountKobo, + platformFeeKobo, + releaseToken, + attempt, + adminUserId, + reason: "Provider reported the transfer as failed", + provider: payoutWithOrder.provider, + providerReference: transferReference, + providerOperationId: transferResponse.transferCode, + }); + throw new Error("Provider reported the transfer as failed"); + } + + // From here the provider ACCEPTED the request. §11: acceptance is NOT + // completion — only a confirmed SUCCESS completes the payout and notifies; + // PENDING/OTP/PROCESSING stays SUBMITTED for reconciliation. Any failure in + // this block is ambiguous (money may already have moved) and is handled by + // marking SUBMITTED, never FAILED — an admin must not be able to retry and + // pay twice. + try { + if (transferResponse.status !== "SUCCESS") { + const submitted = await this.prisma.$transaction(async (tx) => { + const updated = await tx.payout.update({ + where: { id: payoutId }, + data: { + ...this.providerReferenceData( + payoutWithOrder.provider, + transferResponse.reference, + transferResponse.transferCode, + ), + status: "SUBMITTED", + providerStatus: transferResponse.status, + submittedAt: new Date(), + nextReconcileAt: new Date(Date.now() + PAYOUT_RECONCILE_DELAY_MS), + failureReason: null, + }, + }); + await tx.payoutAttempt.update({ + where: { + provider_providerReference: { + provider: payoutWithOrder.provider, + providerReference: transferReference, + }, + }, + data: { + status: "SUBMITTED", + providerOperationId: transferResponse.transferCode, + submittedAt: new Date(), + }, + }); + + await this.appendPayoutDomainEvent( + tx, + { + id: payoutId, + orderId: order.id, + storeId: merchant.id, + amountKobo: payoutAmountKobo, + platformFeeKobo, + }, + "PAYOUT_SUBMITTED", + { + reference: transferResponse.reference, + providerStatus: transferResponse.status, + attempt, + releasedBy: adminUserId ?? null, + }, + `payout:${payoutId}:submitted:${releaseToken}`, + ); + + return updated; + }); + + this.logger.log( + `Payout SUBMITTED (provider ${transferResponse.status}) for order ${order.id} (payout ${payoutId}); awaiting reconciliation`, + ); + return this.toPayoutReleaseResponse(submitted); + } + + const completionReference = + transferResponse.transferCode ?? transferResponse.reference; + const completed = await this.prisma.$transaction(async (tx) => { + const updated = await tx.payout.update({ + where: { id: payoutId }, + data: { + ...this.providerReferenceData( + payoutWithOrder.provider, + transferResponse.reference, + transferResponse.transferCode, + ), + status: "COMPLETED", + providerStatus: transferResponse.status, + completedAt: new Date(), + failureReason: null, + }, + }); + await tx.payoutAttempt.update({ + where: { + provider_providerReference: { + provider: payoutWithOrder.provider, + providerReference: transferReference, + }, + }, + data: { + status: "COMPLETED", + providerOperationId: transferResponse.transferCode, + submittedAt: new Date(), + completedAt: new Date(), + }, + }); + + await this.ledgerService.recordPayoutCompleted( + { + payoutId, + orderId: order.id, + storeId: merchant.id, + amountKobo: payoutAmountKobo, + reference: completionReference, + }, + tx, + ); + + await this.appendPayoutDomainEvent( + tx, + { + id: payoutId, + orderId: order.id, + storeId: merchant.id, + amountKobo: payoutAmountKobo, + platformFeeKobo, + }, + "PAYOUT_SENT", + { + reference: completionReference, + attempt, + releasedBy: adminUserId ?? null, + }, + `payout:${payoutId}:sent:${completionReference}`, + ); + + return updated; + }); + + const productName = order.product?.name || "Items"; + const quantity = order.quantity || 1; + + // Notification delivery is best effort. It must not downgrade a payout + // whose DB transaction already recorded a confirmed provider success. + try { + await this.notifications.triggerPayoutInitiated(merchant.userId, { + orderId: order.id, + orderRef: order.id.slice(0, 8).toUpperCase(), + productName, + quantity, + payoutAmountKobo: payoutAmountKobo.toString(), + bankName: merchant.settlementAccountName || "your bank", + }); + } catch (notificationError) { + this.logger.error( + `Payout ${payoutId} completed but merchant notification failed: ${ + notificationError instanceof Error + ? notificationError.message + : String(notificationError) + }`, + ); + } + this.logger.log( + `Payout COMPLETED for order ${order.id} (payout ${payoutId})`, + ); + + return this.toPayoutReleaseResponse(completed); + } catch (error) { + // The provider already ACCEPTED the transfer above; this is our own + // follow-up failure (DB/serialization). The money may have moved, so mark + // the payout SUBMITTED for reconciliation to confirm the true outcome — + // never FAILED, which would permit a duplicate payout on retry. + const message = error instanceof Error ? error.message : String(error); + this.logger.error( + `Payout ${payoutId} follow-up failed after provider acceptance; marking SUBMITTED for reconciliation: ${message}`, + ); + const submitted = await this.markReleaseSubmitted({ + payoutId, + provider: payoutWithOrder.provider, + providerStatus: transferResponse.status ?? "UNKNOWN", + transferCode: transferResponse.transferCode, + reference: transferResponse.reference ?? transferReference, + }); + return this.toPayoutReleaseResponse(submitted); + } + } + + private async markReleaseSubmitted(input: { + payoutId: string; + provider: PayoutProviderName; + providerStatus: string; + transferCode: string | null; + reference: string; + }) { + return this.prisma.$transaction(async (tx) => { + const payout = await tx.payout.update({ + where: { id: input.payoutId }, + data: { + status: "SUBMITTED", + providerStatus: input.providerStatus, + ...this.providerReferenceData( + input.provider, + input.reference, + input.transferCode, + ), + submittedAt: new Date(), + nextReconcileAt: new Date(Date.now() + PAYOUT_RECONCILE_DELAY_MS), + failureReason: null, + }, + }); + await tx.payoutAttempt.updateMany({ + where: { + payoutId: input.payoutId, + provider: input.provider, + providerReference: input.reference, + }, + data: { + status: "RECONCILIATION_REQUIRED", + providerOperationId: input.transferCode, + submittedAt: new Date(), + }, + }); + return payout; + }); + } + + // Clean-failure path for a payout release: an explicit provider FAILED result + // confirms that no transfer completed. Records the FAILED status, the + // PAYOUT_FAILED ledger entry, and the domain event. + private async recordReleaseFailure(input: { + payoutId: string; + orderId: string; + storeId: string; + amountKobo: bigint; + platformFeeKobo: bigint; + provider: PayoutProviderName; + releaseToken: string; + attempt: number; + adminUserId?: string; + reason: string; + providerReference: string; + providerOperationId: string | null; + }): Promise { + this.logger.error( + `Payout release failed for order ${input.orderId} (attempt ${input.attempt}): ${input.reason}`, + ); + await this.prisma.$transaction(async (tx) => { + await tx.payout.update({ + where: { id: input.payoutId }, + data: { status: "FAILED", failureReason: input.reason }, + }); + await tx.payoutAttempt.update({ + where: { + provider_providerReference: { + provider: input.provider, + providerReference: input.providerReference, + }, + }, + data: { + status: "FAILED", + providerOperationId: input.providerOperationId, + failureReason: input.reason, + }, + }); + await this.ledgerService.recordPayoutFailed( + { + payoutId: input.payoutId, + orderId: input.orderId, + storeId: input.storeId, + amountKobo: input.amountKobo, + reason: input.reason, + idempotencyKey: `payout-failed:${input.payoutId}:${input.releaseToken}`, + }, + tx, + ); + await this.appendPayoutDomainEvent( + tx, + { + id: input.payoutId, + orderId: input.orderId, + storeId: input.storeId, + amountKobo: input.amountKobo, + platformFeeKobo: input.platformFeeKobo, + }, + "PAYOUT_FAILED", + { + reason: input.reason, + attempt: input.attempt, + releasedBy: input.adminUserId ?? null, + }, + `payout:${input.payoutId}:failed:${input.releaseToken}`, + ); + }); + } + + // §10 — release a merchant-payout settlement leg. Uses the amount approved on + // the leg (never recalculated), links the payout row to the leg so it bypasses + // the dispute hold for that exact leg, and applies the §11 acceptance-is-not- + // completion rule. Returns the normalized leg outcome for the settlement + // service to persist. Deterministic transfer reference + ledger idempotency + // key prevent a duplicate transfer/ledger entry on retry. + async executeSettlementPayout(input: { + legId: string; + orderId: string; + storeId: string; + amountKobo: bigint; + approvedBy: string; + }): Promise<{ + status: "COMPLETED" | "SUBMITTED" | "FAILED"; + provider: PayoutProviderKey | null; + providerReference: string | null; + providerOperationId: string | null; + error?: string; + errorCode?: "PAYOUT_PROVIDER_MISMATCH"; + }> { + const settlementLeg = await this.prisma.disputeSettlementLeg.findUnique({ + where: { id: input.legId }, + select: { + type: true, + status: true, + amountKobo: true, + attempts: true, + settlement: { + select: { + orderId: true, + order: { select: { storeId: true } }, + }, + }, + }, + }); + if ( + !settlementLeg || + settlementLeg.type !== "MERCHANT_PAYOUT" || + settlementLeg.status !== "PROCESSING" || + BigInt(settlementLeg.amountKobo) !== input.amountKobo || + settlementLeg.settlement.orderId !== input.orderId || + settlementLeg.settlement.order.storeId !== input.storeId + ) { + return { + status: "FAILED", + provider: null, + providerReference: null, + providerOperationId: null, + error: + "Settlement payout leg is not an approved processing leg for this order", + }; + } + + const existingPayoutBinding = await this.prisma.payout.findUnique({ + where: { orderId: input.orderId }, + select: { + provider: true, + destinationRecipientReference: true, + destinationBankCode: true, + destinationAccountNumber: true, + destinationAccountName: true, + }, + }); + const capturedPayment = await this.prisma.payment.findFirst({ + where: { orderId: input.orderId, status: PaymentStatus.SUCCESS }, + orderBy: { verifiedAt: "desc" }, + select: { provider: true }, + }); + if (!capturedPayment) { + return { + status: "FAILED", + provider: null, + providerReference: null, + providerOperationId: null, + error: "No successful payment provider is recorded for this order", + }; + } + const paymentProvider = payoutProviderForPaymentProvider( + capturedPayment.provider, + ); + if ( + existingPayoutBinding && + existingPayoutBinding.provider !== paymentProvider + ) { + return { + status: "FAILED", + provider: null, + providerReference: null, + providerOperationId: null, + error: + "Existing payout provider does not match the captured payment provider", + errorCode: "PAYOUT_PROVIDER_MISMATCH", + }; + } + const merchant = await this.prisma.storeProfile.findUnique({ + where: { id: input.storeId }, + select: { + paystackRecipientCode: true, + payoutRecipientProvider: true, + payoutRecipientReference: true, + bankCode: true, + accountNumber: true, + accountName: true, + settlementAccountName: true, + }, + }); + const configuredProvider = + existingPayoutBinding?.provider ?? paymentProvider; + const recipientReference = + existingPayoutBinding?.destinationRecipientReference ?? + (merchant?.payoutRecipientProvider === configuredProvider + ? merchant.payoutRecipientReference + : configuredProvider === PayoutProviderName.PAYSTACK + ? merchant?.paystackRecipientCode + : null); + const destinationBankCode = + existingPayoutBinding?.destinationBankCode ?? merchant?.bankCode; + const destinationAccountNumber = + existingPayoutBinding?.destinationAccountNumber ?? + merchant?.accountNumber; + const destinationAccountName = + existingPayoutBinding?.destinationAccountName ?? + merchant?.settlementAccountName ?? + merchant?.accountName; + if ( + !merchant || + (configuredProvider === PayoutProviderName.PAYSTACK && + !recipientReference) || + (configuredProvider === PayoutProviderName.MONNIFY && + (!destinationBankCode || + !destinationAccountNumber || + !destinationAccountName)) + ) { + return { + status: "FAILED", + provider: configuredProvider.toLowerCase() as PayoutProviderKey, + providerReference: null, + providerOperationId: null, + error: "Store has no registered bank account", + }; + } + + const reference = `DS-${input.legId.slice(0, 8).toUpperCase()}-A${settlementLeg.attempts}`; + // Find-or-create the single payout row and atomically claim the stable + // settlement transfer reference. The unique attempt key prevents duplicate + // outbox deliveries from making a second provider call. + type ClaimedSettlementPayout = { + id: string; + provider: PayoutProviderName; + destinationRecipientReference: string | null; + destinationBankCode: string | null; + destinationAccountNumber: string | null; + destinationAccountName: string | null; + }; + type SettlementPayoutClaim = + | { kind: "claimed"; payout: ClaimedSettlementPayout } + | { kind: "provider_mismatch" } + | { + kind: "in_flight" | "completed"; + provider: PayoutProviderName; + providerReference: string | null; + providerOperationId: string | null; + }; + let claimResult: SettlementPayoutClaim; + try { + claimResult = await this.prisma.$transaction(async (tx) => { + const existing = await tx.payout.findUnique({ + where: { orderId: input.orderId }, + select: { + id: true, + status: true, + provider: true, + providerReference: true, + providerOperationId: true, + destinationRecipientReference: true, + destinationBankCode: true, + destinationAccountNumber: true, + destinationAccountName: true, + attempts: { + orderBy: { createdAt: "desc" }, + take: 1, + select: { + providerReference: true, + providerOperationId: true, + }, + }, + }, + }); + if (existing && existing.provider !== configuredProvider) { + return { kind: "provider_mismatch" as const }; + } + if (existing?.status === "COMPLETED") { + return { + kind: "completed" as const, + provider: existing.provider, + providerReference: + existing.providerReference ?? + existing.attempts[0]?.providerReference ?? + null, + providerOperationId: + existing.providerOperationId ?? + existing.attempts[0]?.providerOperationId ?? + null, + }; + } + if ( + existing && + existing.status !== "PENDING" && + existing.status !== "FAILED" + ) { + return { + kind: "in_flight" as const, + provider: existing.provider, + providerReference: + existing.providerReference ?? + existing.attempts[0]?.providerReference ?? + null, + providerOperationId: + existing.providerOperationId ?? + existing.attempts[0]?.providerOperationId ?? + null, + }; + } + const claimedPayout = existing + ? await tx.payout.update({ + where: { + id: existing.id, + status: { in: ["PENDING", "FAILED"] }, + }, + data: { + amountKobo: input.amountKobo, + destinationRecipientReference: + existing.destinationRecipientReference ?? recipientReference, + destinationBankCode: + existing.destinationBankCode ?? destinationBankCode, + destinationAccountNumber: + existing.destinationAccountNumber ?? destinationAccountNumber, + destinationAccountName: + existing.destinationAccountName ?? destinationAccountName, + settlementLegId: input.legId, + onHold: false, + holdReason: null, + status: "PROCESSING", + initiatedAt: new Date(), + failureReason: null, + }, + select: { + id: true, + provider: true, + destinationRecipientReference: true, + destinationBankCode: true, + destinationAccountNumber: true, + destinationAccountName: true, + }, + }) + : await tx.payout.create({ + data: { + orderId: input.orderId, + storeId: input.storeId, + amountKobo: input.amountKobo, + platformFeeKobo: 0n, + provider: configuredProvider, + destinationBankCode, + destinationAccountNumber, + destinationAccountName, + destinationRecipientReference: recipientReference, + settlementLegId: input.legId, + status: "PROCESSING", + initiatedAt: new Date(), + }, + select: { + id: true, + provider: true, + destinationRecipientReference: true, + destinationBankCode: true, + destinationAccountNumber: true, + destinationAccountName: true, + }, + }); + await tx.payoutAttempt.create({ + data: { + payoutId: claimedPayout.id, + provider: claimedPayout.provider, + providerReference: reference, + status: "PROCESSING", + }, + }); + return { kind: "claimed" as const, payout: claimedPayout }; + }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + (error.code === "P2002" || error.code === "P2025") + ) { + const existing = await this.prisma.payout.findUnique({ + where: { orderId: input.orderId }, + select: { + status: true, + provider: true, + providerReference: true, + providerOperationId: true, + attempts: { + orderBy: { createdAt: "desc" }, + take: 1, + select: { + providerReference: true, + providerOperationId: true, + }, + }, + }, + }); + return { + status: existing?.status === "COMPLETED" ? "COMPLETED" : "SUBMITTED", + provider: ( + existing?.provider ?? configuredProvider + ).toLowerCase() as PayoutProviderKey, + providerReference: + existing?.providerReference ?? + existing?.attempts[0]?.providerReference ?? + reference, + providerOperationId: + existing?.providerOperationId ?? + existing?.attempts[0]?.providerOperationId ?? + null, + }; + } + throw error; + } + + if (claimResult.kind === "provider_mismatch") { + return { + status: "FAILED", + provider: null, + providerReference: null, + providerOperationId: null, + error: + "Existing payout provider does not match the captured payment provider", + errorCode: "PAYOUT_PROVIDER_MISMATCH", + }; + } + + if (claimResult.kind !== "claimed") { + return { + status: claimResult.kind === "completed" ? "COMPLETED" : "SUBMITTED", + provider: claimResult.provider.toLowerCase() as PayoutProviderKey, + providerReference: claimResult.providerReference, + providerOperationId: claimResult.providerOperationId, + }; + } + const payout = claimResult.payout; + + try { + const transfer = await this.providerFor(payout.provider).initiateTransfer( + { + destination: { + recipientCode: payout.destinationRecipientReference, + bankCode: payout.destinationBankCode ?? "", + accountNumber: payout.destinationAccountNumber ?? "", + accountName: payout.destinationAccountName ?? "", + }, + amountKobo: input.amountKobo, + reference, + reason: `twizrr dispute settlement payout for Order #${input.orderId.slice(0, 8).toUpperCase()}`, + }, + ); + + if (transfer.status === "FAILED") { + await this.failSettlementPayout( + payout.id, + input, + "Provider reported the transfer as failed", + ); + await this.prisma.payoutAttempt.updateMany({ + where: { payoutId: payout.id, providerReference: reference }, + data: { + status: "FAILED", + providerOperationId: transfer.transferCode, + failureReason: "Provider reported the transfer as failed", + }, + }); + return { + status: "FAILED", + provider: payout.provider.toLowerCase() as PayoutProviderKey, + providerReference: transfer.reference, + providerOperationId: transfer.transferCode, + error: "Provider reported the transfer as failed", + }; + } + + if (transfer.status !== "SUCCESS") { + await this.prisma.payout.update({ + where: { id: payout.id }, + data: { + status: "SUBMITTED", + providerStatus: transfer.status, + ...this.providerReferenceData( + payout.provider, + transfer.reference, + transfer.transferCode, + ), + submittedAt: new Date(), + nextReconcileAt: new Date(Date.now() + PAYOUT_RECONCILE_DELAY_MS), + }, + }); + await this.prisma.payoutAttempt.updateMany({ + where: { payoutId: payout.id, providerReference: reference }, + data: { + status: "SUBMITTED", + providerOperationId: transfer.transferCode, + submittedAt: new Date(), + }, + }); + return { + status: "SUBMITTED", + provider: payout.provider.toLowerCase() as PayoutProviderKey, + providerReference: transfer.reference, + providerOperationId: transfer.transferCode, + }; + } + + await this.prisma.$transaction(async (tx) => { + await tx.payout.update({ + where: { id: payout.id }, + data: { + status: "COMPLETED", + providerStatus: "SUCCESS", + ...this.providerReferenceData( + payout.provider, + transfer.reference, + transfer.transferCode, + ), + completedAt: new Date(), + failureReason: null, + }, + }); + await tx.payoutAttempt.updateMany({ + where: { payoutId: payout.id, providerReference: reference }, + data: { + status: "COMPLETED", + providerOperationId: transfer.transferCode, + completedAt: new Date(), + }, + }); + await this.ledgerService.recordEntry( + { + entryType: LedgerEntryType.PAYOUT_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo: input.amountKobo, + orderId: input.orderId, + storeId: input.storeId, + payoutId: payout.id, + settlementLegId: input.legId, + reference: transfer.transferCode ?? transfer.reference, + idempotencyKey: `settlement-leg:${input.legId}:payout-completed`, + }, + tx, + ); + }); + return { + status: "COMPLETED", + provider: payout.provider.toLowerCase() as PayoutProviderKey, + providerReference: transfer.reference, + providerOperationId: transfer.transferCode, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // A provider exception is ambiguous: the transfer may have been accepted + // after the request left this process. Preserve the deterministic + // reference and reconcile it instead of returning the leg to a retryable + // FAILED state, which could submit another transfer. + await this.prisma.payout.update({ + where: { id: payout.id }, + data: { + status: "SUBMITTED", + providerStatus: "UNKNOWN", + ...this.providerReferenceData(payout.provider, reference, null), + submittedAt: new Date(), + nextReconcileAt: new Date(Date.now() + PAYOUT_RECONCILE_DELAY_MS), + failureReason: message, + }, + }); + await this.prisma.payoutAttempt.updateMany({ + where: { payoutId: payout.id, providerReference: reference }, + data: { + status: "RECONCILIATION_REQUIRED", + failureReason: message, + submittedAt: new Date(), + }, + }); + return { + status: "SUBMITTED", + provider: payout.provider.toLowerCase() as PayoutProviderKey, + providerReference: reference, + providerOperationId: null, + }; + } + } + + private async failSettlementPayout( + payoutId: string, + input: { + legId: string; + orderId: string; + storeId: string; + amountKobo: bigint; + }, + reason: string, + ): Promise { + await this.prisma.payout.update({ + where: { id: payoutId }, + data: { status: "FAILED", failureReason: reason }, + }); + } + + // §12 — verify a SUBMITTED payout against the provider and finalize it only on + // a confirmed outcome. Idempotent: the PAYOUT_COMPLETED ledger entry is keyed + // per payout/leg, and a duplicate success event writes nothing new. Returns + // the resolved status and the linked settlement leg (if any). + async reconcileSubmittedPayout(payoutId: string): Promise<{ + status: "COMPLETED" | "FAILED" | "PENDING"; + settlementLegId: string | null; + }> { + const payout = await this.prisma.payout.findUnique({ + where: { id: payoutId }, + select: { + id: true, + orderId: true, + storeId: true, + amountKobo: true, + platformFeeKobo: true, + provider: true, + providerReference: true, + paystackReference: true, + settlementLegId: true, + status: true, + order: { + select: { product: { select: { name: true } }, quantity: true }, + }, + store: { + select: { userId: true, settlementAccountName: true }, + }, + }, + }); + const providerReference = + payout?.providerReference ?? payout?.paystackReference; + if (!payout || payout.status !== "SUBMITTED" || !providerReference) { + return { + status: "PENDING", + settlementLegId: payout?.settlementLegId ?? null, + }; + } + + let verified; + try { + verified = await this.providerFor(payout.provider).verifyTransfer( + providerReference, + ); + } catch (error) { + this.logger.warn( + `Transfer verify failed for payout ${payoutId}: ${ + error instanceof Error ? error.message : "unknown" + }`, + ); + await this.prisma.payout.update({ + where: { id: payoutId }, + data: { + nextReconcileAt: new Date(Date.now() + PAYOUT_RECONCILE_DELAY_MS), + }, + }); + return { status: "PENDING", settlementLegId: payout.settlementLegId }; + } + + if (verified.status === "SUCCESS") { + const completionReference = verified.transferCode ?? verified.reference; + let finalized = false; + await this.prisma.$transaction(async (tx) => { + const claim = await tx.payout.updateMany({ + where: { id: payoutId, status: "SUBMITTED" }, + data: { + status: "COMPLETED", + providerStatus: "SUCCESS", + completedAt: new Date(), + ...this.providerReferenceData( + payout.provider, + verified.reference, + verified.transferCode, + ), + nextReconcileAt: null, + }, + }); + if (claim.count !== 1) return; + finalized = true; + await tx.payoutAttempt.updateMany({ + where: { + payoutId: payout.id, + provider: payout.provider, + providerReference, + status: { + in: ["SUBMITTED", "RECONCILIATION_REQUIRED", "PROCESSING"], + }, + }, + data: { + status: "COMPLETED", + providerOperationId: verified.transferCode, + completedAt: new Date(), + }, + }); + await this.ledgerService.recordEntry( + { + entryType: LedgerEntryType.PAYOUT_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo: BigInt(payout.amountKobo), + orderId: payout.orderId, + storeId: payout.storeId, + payoutId: payout.id, + settlementLegId: payout.settlementLegId, + reference: completionReference, + idempotencyKey: payout.settlementLegId + ? `settlement-leg:${payout.settlementLegId}:payout-completed` + : `payout-completed:${payout.id}`, + }, + tx, + ); + await this.appendPayoutDomainEvent( + tx, + payout, + "PAYOUT_SENT", + { reference: completionReference }, + `payout:${payout.id}:sent:${completionReference}`, + ); + }); + if (finalized) { + try { + await this.notifications.triggerPayoutInitiated(payout.store.userId, { + orderId: payout.orderId, + orderRef: payout.orderId.slice(0, 8).toUpperCase(), + productName: payout.order.product?.name || "Items", + quantity: payout.order.quantity || 1, + payoutAmountKobo: payout.amountKobo.toString(), + bankName: payout.store.settlementAccountName || "your bank", + }); + } catch (notificationError) { + this.logger.error( + `Payout ${payoutId} reconciled but merchant notification failed: ${ + notificationError instanceof Error + ? notificationError.message + : String(notificationError) + }`, + ); + } + } + return { status: "COMPLETED", settlementLegId: payout.settlementLegId }; + } + + if (verified.status === "FAILED") { + const reason = "Provider verification reported the transfer failed"; + let finalized = false; + await this.prisma.$transaction(async (tx) => { + const claim = await tx.payout.updateMany({ + where: { id: payoutId, status: "SUBMITTED" }, + data: { + status: "FAILED", + providerStatus: "FAILED", + failureReason: reason, + nextReconcileAt: null, + }, + }); + if (claim.count !== 1) return; + finalized = true; + await tx.payoutAttempt.updateMany({ + where: { + payoutId: payout.id, + provider: payout.provider, + providerReference, + }, + data: { status: "FAILED", failureReason: reason }, + }); + await this.ledgerService.recordPayoutFailed( + { + payoutId: payout.id, + orderId: payout.orderId, + storeId: payout.storeId, + amountKobo: BigInt(payout.amountKobo), + reason, + idempotencyKey: `payout-failed:${payout.id}:provider-verification`, + }, + tx, + ); + await this.appendPayoutDomainEvent( + tx, + payout, + "PAYOUT_FAILED", + { reason, providerStatus: "FAILED" }, + `payout:${payout.id}:failed:provider-verification`, + ); + }); + if (finalized) { + try { + await this.notifications.triggerPayoutFailed(payout.store.userId, { + orderRef: payout.orderId.slice(0, 8).toUpperCase(), + }); + } catch (notificationError) { + this.logger.error( + `Payout ${payoutId} failure notification failed: ${ + notificationError instanceof Error + ? notificationError.message + : String(notificationError) + }`, + ); + } + } + return { status: "FAILED", settlementLegId: payout.settlementLegId }; + } + + await this.prisma.payout.update({ + where: { id: payoutId }, + data: { + nextReconcileAt: new Date(Date.now() + PAYOUT_RECONCILE_DELAY_MS), + }, + }); + return { status: "PENDING", settlementLegId: payout.settlementLegId }; + } + + // Called by the retry processor after repeated review-preparation failures. + // Sends the "update your bank details" notification without attempting + // a provider transfer. + async markFinalFailure(orderId: string, error: unknown): Promise { + const payout = await this.prisma.payout.findUnique({ + where: { orderId }, + include: { store: { select: { userId: true } } }, + }); + if (!payout) { + this.logger.error( + `markFinalFailure: payout not found for order ${orderId}`, + ); + return; + } + + const message = error instanceof Error ? error.message : String(error); + this.logger.error( + `Payout FINAL FAILURE for order ${orderId} after ${MAX_PAYOUT_ATTEMPTS} attempts: ${message}`, + ); + + try { + await this.notifications.triggerPayoutFailed(payout.store.userId, { + orderRef: orderId.slice(0, 8).toUpperCase(), + }); + } catch (notificationError) { + this.logger.error( + `Failed to notify merchant of final payout failure (order ${orderId}): ${ + notificationError instanceof Error + ? notificationError.message + : "unknown error" + }`, + ); + } + } + + // Reserve or reuse the single Payout row for this order. + // - Returns null if the payout already COMPLETED (idempotent no-op). + // - PENDING means calculated and waiting for admin release. + // - PROCESSING means an explicit release attempt is underway. + private async reservePayoutRow(input: { + orderId: string; + storeId: string; + amountKobo: bigint; + platformFeeKobo: bigint; + provider: PayoutProviderName; + status: "PENDING" | "PROCESSING"; + destination?: { + recipientReference: string | null; + bankCode: string | null; + accountNumber: string | null; + accountName: string | null; + }; + }): Promise< + | { + id: string; + orderId: string; + storeId: string; + amountKobo: bigint; + platformFeeKobo: bigint; + status: string; + } + | { kind: "provider_mismatch" } + | null + > { + return this.prisma.$transaction(async (tx) => { + const existing = await tx.payout.findUnique({ + where: { orderId: input.orderId }, + select: { + id: true, + status: true, + destinationRecipientReference: true, + destinationBankCode: true, + destinationAccountNumber: true, + destinationAccountName: true, + provider: true, + }, + }); + + if (existing?.status === "COMPLETED") { + return null; + } + + if (existing) { + if (existing.provider !== input.provider) { + return { kind: "provider_mismatch" as const }; + } + if (existing.status !== "PENDING" && existing.status !== "FAILED") { + return tx.payout.findUniqueOrThrow({ + where: { id: existing.id }, + select: { + id: true, + orderId: true, + storeId: true, + amountKobo: true, + platformFeeKobo: true, + status: true, + }, + }); + } + const updated = await tx.payout.update({ + where: { id: existing.id }, + data: { + amountKobo: input.amountKobo, + platformFeeKobo: input.platformFeeKobo, + destinationRecipientReference: + existing.destinationRecipientReference ?? + input.destination?.recipientReference, + destinationBankCode: + existing.destinationBankCode ?? input.destination?.bankCode, + destinationAccountNumber: + existing.destinationAccountNumber ?? + input.destination?.accountNumber, + destinationAccountName: + existing.destinationAccountName ?? input.destination?.accountName, + status: input.status, + failureReason: null, + initiatedAt: input.status === "PROCESSING" ? new Date() : null, + }, + select: { + id: true, + orderId: true, + storeId: true, + amountKobo: true, + platformFeeKobo: true, + status: true, + }, + }); + await this.appendPayoutDomainEvent( + tx, + updated, + input.status === "PENDING" + ? "PAYOUT_READY_FOR_REVIEW" + : "PAYOUT_PROCESSING", + { retry: existing.status === "FAILED" }, + `payout:${updated.id}:${input.status.toLowerCase()}:${updated.orderId}`, + ); + return updated; + } + + const created = await tx.payout.create({ + data: { + orderId: input.orderId, + storeId: input.storeId, + amountKobo: input.amountKobo, + platformFeeKobo: input.platformFeeKobo, + provider: input.provider, + destinationBankCode: input.destination?.bankCode ?? null, + destinationAccountNumber: input.destination?.accountNumber ?? null, + destinationAccountName: input.destination?.accountName ?? null, + destinationRecipientReference: + input.destination?.recipientReference ?? null, + status: input.status, + initiatedAt: input.status === "PROCESSING" ? new Date() : null, + }, + select: { + id: true, + orderId: true, + storeId: true, + amountKobo: true, + platformFeeKobo: true, + status: true, + }, + }); + + await this.ledgerService.recordPayoutInitiated( + { + payoutId: created.id, + orderId: input.orderId, + storeId: input.storeId, + amountKobo: input.amountKobo, + platformFeeKobo: input.platformFeeKobo, + }, + tx, + ); + + await this.appendPayoutDomainEvent( + tx, + created, + input.status === "PENDING" + ? "PAYOUT_READY_FOR_REVIEW" + : "PAYOUT_PROCESSING", + { retry: false }, + `payout:${created.id}:${input.status.toLowerCase()}:${created.orderId}`, + ); + + return created; + }); + } + + private toPayoutReleaseResponse(payout: { + id: string; + orderId: string; + storeId: string; + amountKobo: bigint | number; + platformFeeKobo: bigint | number; + status: string; + paystackTransferCode: string | null; + completedAt: Date | null; + }) { + return { + id: payout.id, + orderId: payout.orderId, + storeId: payout.storeId, + amountKobo: payout.amountKobo.toString(), + platformFeeKobo: payout.platformFeeKobo.toString(), + status: payout.status, + paystackTransferCode: payout.paystackTransferCode, + completedAt: payout.completedAt, + }; + } + + // Permanent (non-retryable) failure path used when the merchant has + // no recipient code on file. Single ledger entry + single notification, + // no retry scheduling. + private async recordPermanentFailure( + orderId: string, + storeId: string, + storeUserId: string, + amountKobo: bigint, + platformFeeKobo: bigint, + reason: string, + provider: PayoutProviderName, + ): Promise { + this.logger.error( + `Permanent payout failure for order ${orderId}: ${reason}`, + ); + try { + await this.prisma.$transaction(async (tx) => { + const existing = await tx.payout.findUnique({ + where: { orderId }, + select: { id: true, status: true }, + }); + + let payoutId: string; + let payoutForEvent: { + id: string; + orderId: string; + storeId: string; + amountKobo: bigint; + platformFeeKobo: bigint; + }; + if (existing) { + if (existing.status === "COMPLETED") { + return; + } + payoutId = existing.id; + await tx.payout.update({ + where: { id: existing.id }, + data: { status: "FAILED", failureReason: reason }, + }); + payoutForEvent = { + id: existing.id, + orderId, + storeId, + amountKobo, + platformFeeKobo, + }; + } else { + const created = await tx.payout.create({ + data: { + orderId, + storeId, + amountKobo, + platformFeeKobo, + provider, + status: "FAILED", + failureReason: reason, + }, + select: { + id: true, + orderId: true, + storeId: true, + amountKobo: true, + platformFeeKobo: true, + }, + }); + payoutId = created.id; + payoutForEvent = created; + } + + await this.ledgerService.recordPayoutFailed( + { + payoutId, + orderId, + storeId, + amountKobo, + reason, + idempotencyKey: `payout-failed:${payoutId}:permanent`, + }, + tx, + ); + + await this.appendPayoutDomainEvent( + tx, + payoutForEvent, + "PAYOUT_FAILED", + { reason, permanent: true }, + `payout:${payoutId}:failed:permanent`, + ); + }); + } catch (error) { + // Idempotent retries may hit the unique idempotencyKey; log and continue. + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + this.logger.warn( + `Permanent failure already recorded for order ${orderId}`, + ); + } else { + throw error; + } + } + + try { + await this.notifications.triggerPayoutFailed(storeUserId, { + orderRef: orderId.slice(0, 8).toUpperCase(), + }); + } catch (notificationError) { + this.logger.error( + `Failed to notify merchant of permanent payout failure (order ${orderId}): ${ + notificationError instanceof Error + ? notificationError.message + : "unknown error" + }`, + ); + } + } +} diff --git a/apps/backend/src/domains/money/payout/providers/payout-provider-continuity.spec.ts b/apps/backend/src/domains/money/payout/providers/payout-provider-continuity.spec.ts new file mode 100644 index 00000000..55212a55 --- /dev/null +++ b/apps/backend/src/domains/money/payout/providers/payout-provider-continuity.spec.ts @@ -0,0 +1,14 @@ +import { PaymentProviderName, PayoutProviderName } from "@prisma/client"; + +import { payoutProviderForPaymentProvider } from "./payout-provider-continuity"; + +describe("payout provider continuity", () => { + it.each([ + [PaymentProviderName.PAYSTACK, PayoutProviderName.PAYSTACK], + [PaymentProviderName.MONNIFY, PayoutProviderName.MONNIFY], + ])("maps %s payments to %s payouts", (paymentProvider, payoutProvider) => { + expect(payoutProviderForPaymentProvider(paymentProvider)).toBe( + payoutProvider, + ); + }); +}); diff --git a/apps/backend/src/domains/money/payout/providers/payout-provider-continuity.ts b/apps/backend/src/domains/money/payout/providers/payout-provider-continuity.ts new file mode 100644 index 00000000..f1667577 --- /dev/null +++ b/apps/backend/src/domains/money/payout/providers/payout-provider-continuity.ts @@ -0,0 +1,17 @@ +import { PaymentProviderName, PayoutProviderName } from "@prisma/client"; + +/** + * A merchant payout releases funds captured by one payment provider. It must + * use that same provider; a global environment change is never authority to + * move an existing order's funds through another provider. + */ +export function payoutProviderForPaymentProvider( + provider: PaymentProviderName, +): PayoutProviderName { + switch (provider) { + case PaymentProviderName.PAYSTACK: + return PayoutProviderName.PAYSTACK; + case PaymentProviderName.MONNIFY: + return PayoutProviderName.MONNIFY; + } +} diff --git a/apps/backend/src/domains/money/payout/providers/payout-provider.interface.ts b/apps/backend/src/domains/money/payout/providers/payout-provider.interface.ts new file mode 100644 index 00000000..a0647098 --- /dev/null +++ b/apps/backend/src/domains/money/payout/providers/payout-provider.interface.ts @@ -0,0 +1,89 @@ +export interface ListPayoutBankResult { + name: string; + code: string; + active: boolean; + type: string; +} + +export interface ResolvePayoutAccountInput { + accountNumber: string; + bankCode: string; +} + +export interface ResolvePayoutAccountResult { + accountName: string; + accountNumber: string; + bankId?: number; +} + +export interface CreatePayoutRecipientInput { + bankCode: string; + accountNumber: string; + accountName: string; +} + +export interface CreatePayoutRecipientResult { + // Paystack uses a durable recipient code. Monnify validates the destination + // account directly at transfer time and deliberately returns no equivalent. + recipientCode: string | null; +} + +export interface InitiatePayoutTransferInput { + destination: { + recipientCode?: string | null; + bankCode: string; + accountNumber: string; + accountName: string; + }; + amountKobo: bigint; + reference: string; + reason: string; +} + +// Normalized transfer lifecycle. Provider acceptance (PENDING/OTP/PROCESSING) +// is never final settlement — only SUCCESS completes a payout, only FAILED +// fails it. Anything else is reconciled, never blindly retried. +export type PayoutTransferStatus = + | "PENDING" + | "OTP" + | "PROCESSING" + | "SUCCESS" + | "FAILED"; + +export interface InitiatePayoutTransferResult { + transferCode: string | null; + reference: string; + status: PayoutTransferStatus; +} + +export interface VerifyPayoutTransferResult { + reference: string; + transferCode: string | null; + status: PayoutTransferStatus; +} + +export interface NormalizedPayoutWebhookEvent { + reference: string; +} + +export interface PayoutProvider { + readonly key: "paystack" | "monnify"; + listBanks(): Promise; + resolveAccount( + input: ResolvePayoutAccountInput, + ): Promise; + createRecipient( + input: CreatePayoutRecipientInput, + ): Promise; + initiateTransfer( + input: InitiatePayoutTransferInput, + ): Promise; + verifyTransfer(reference: string): Promise; + verifyWebhookSignature?( + rawBody: Buffer | string | undefined, + signature: string | string[] | undefined, + ): boolean; + parseWebhookEvent?(payload: unknown): NormalizedPayoutWebhookEvent | null; +} + +export const PAYOUT_PROVIDER = Symbol("PAYOUT_PROVIDER"); diff --git a/apps/backend/src/domains/money/payout/providers/payout-provider.registry.spec.ts b/apps/backend/src/domains/money/payout/providers/payout-provider.registry.spec.ts new file mode 100644 index 00000000..a21b80b7 --- /dev/null +++ b/apps/backend/src/domains/money/payout/providers/payout-provider.registry.spec.ts @@ -0,0 +1,30 @@ +import { createPayoutProviderRegistry } from "./payout-provider.registry"; + +describe("PayoutProviderRegistry", () => { + const paystack = { key: "paystack" }; + const monnify = { key: "monnify" }; + const registry = createPayoutProviderRegistry({ + paystack: paystack as never, + monnify: monnify as never, + }); + + it("selects providers explicitly without fallback", () => { + expect(registry.get("paystack")).toBe(paystack); + expect(registry.get("monnify")).toBe(monnify); + }); + + it("fails closed for an unsupported provider", () => { + expect(() => registry.get("unsupported" as never)).toThrow( + "Unsupported payout provider", + ); + }); + + it("fails startup when adapters are registered under the wrong keys", () => { + expect(() => + createPayoutProviderRegistry({ + paystack: monnify as never, + monnify: paystack as never, + }), + ).toThrow("Payout provider registry mismatch"); + }); +}); diff --git a/apps/backend/src/domains/money/payout/providers/payout-provider.registry.ts b/apps/backend/src/domains/money/payout/providers/payout-provider.registry.ts new file mode 100644 index 00000000..1f15e7a4 --- /dev/null +++ b/apps/backend/src/domains/money/payout/providers/payout-provider.registry.ts @@ -0,0 +1,39 @@ +import { BadRequestException } from "@nestjs/common"; + +import { PayoutProvider } from "./payout-provider.interface"; + +export type PayoutProviderKey = "paystack" | "monnify"; + +export interface PayoutProviderRegistry { + get(key: PayoutProviderKey): PayoutProvider; +} + +export function createPayoutProviderRegistry(input: { + paystack: PayoutProvider; + monnify: PayoutProvider; +}): PayoutProviderRegistry { + const providers = new Map([ + ["paystack", input.paystack], + ["monnify", input.monnify], + ]); + + for (const [key, provider] of providers) { + if (provider.key !== key) { + throw new Error( + `Payout provider registry mismatch: expected ${key}, received ${provider.key}`, + ); + } + } + + return { + get(key) { + const provider = providers.get(key); + if (!provider) { + throw new BadRequestException("Unsupported payout provider"); + } + return provider; + }, + }; +} + +export const PAYOUT_PROVIDER_REGISTRY = Symbol("PAYOUT_PROVIDER_REGISTRY"); diff --git a/apps/backend/src/domains/money/payout/providers/payout-provider.selector.spec.ts b/apps/backend/src/domains/money/payout/providers/payout-provider.selector.spec.ts new file mode 100644 index 00000000..c8449c4d --- /dev/null +++ b/apps/backend/src/domains/money/payout/providers/payout-provider.selector.spec.ts @@ -0,0 +1,28 @@ +import { + resolvePayoutProviderKey, + selectPayoutProvider, +} from "./payout-provider.selector"; + +describe("payout provider selection", () => { + const paystack = { key: "paystack" }; + const monnify = { key: "monnify" }; + + it("defaults to Paystack and never falls back silently", () => { + expect(resolvePayoutProviderKey(undefined)).toBe("paystack"); + expect( + selectPayoutProvider(undefined, { paystack, monnify } as never), + ).toBe(paystack); + expect(() => resolvePayoutProviderKey("unsupported")).toThrow( + "Unsupported PAYOUT_PROVIDER", + ); + }); + + it("requires the explicit Monnify execution gate", () => { + expect(() => + selectPayoutProvider("monnify", { paystack, monnify } as never, false), + ).toThrow("disabled"); + expect( + selectPayoutProvider("monnify", { paystack, monnify } as never, true), + ).toBe(monnify); + }); +}); diff --git a/apps/backend/src/domains/money/payout/providers/payout-provider.selector.ts b/apps/backend/src/domains/money/payout/providers/payout-provider.selector.ts new file mode 100644 index 00000000..478336ca --- /dev/null +++ b/apps/backend/src/domains/money/payout/providers/payout-provider.selector.ts @@ -0,0 +1,29 @@ +import { PayoutProvider } from "./payout-provider.interface"; +import { PayoutProviderKey } from "./payout-provider.registry"; + +export function resolvePayoutProviderKey( + configuredProvider: string | undefined, + settingName = "PAYOUT_PROVIDER", +): PayoutProviderKey { + const key = configuredProvider?.trim().toLowerCase() || "paystack"; + if (key !== "paystack" && key !== "monnify") { + throw new Error( + `Unsupported ${settingName} '${configuredProvider}'. Supported payout-compatible values: paystack, monnify.`, + ); + } + return key; +} + +export function selectPayoutProvider( + configuredProvider: string | undefined, + providers: { paystack: PayoutProvider; monnify: PayoutProvider }, + monnifyPayoutEnabled = false, +): PayoutProvider { + const key = resolvePayoutProviderKey(configuredProvider); + if (key === "monnify" && !monnifyPayoutEnabled) { + throw new Error( + "Monnify payout provider is disabled. Enable it only after sandbox, disbursement-MFA, static-IP, and operations rollout gates pass.", + ); + } + return providers[key]; +} diff --git a/apps/backend/src/domains/money/payout/providers/paystack-payout.provider.ts b/apps/backend/src/domains/money/payout/providers/paystack-payout.provider.ts new file mode 100644 index 00000000..323e5df4 --- /dev/null +++ b/apps/backend/src/domains/money/payout/providers/paystack-payout.provider.ts @@ -0,0 +1,99 @@ +import { Injectable } from "@nestjs/common"; + +import { PaystackClient } from "../../../../integrations/paystack/paystack.client"; +import { + CreatePayoutRecipientInput, + CreatePayoutRecipientResult, + InitiatePayoutTransferInput, + InitiatePayoutTransferResult, + PayoutProvider, + PayoutTransferStatus, + ResolvePayoutAccountInput, + ResolvePayoutAccountResult, + VerifyPayoutTransferResult, +} from "./payout-provider.interface"; + +// Paystack transfer status -> normalized Twizrr status. Unknown states are +// treated as PROCESSING so they are reconciled rather than assumed complete. +function normalizeTransferStatus(status: string | null): PayoutTransferStatus { + switch ((status ?? "").toLowerCase()) { + case "success": + return "SUCCESS"; + case "otp": + return "OTP"; + case "pending": + return "PENDING"; + case "failed": + case "reversed": + case "abandoned": + return "FAILED"; + default: + return "PROCESSING"; + } +} + +@Injectable() +export class PaystackPayoutProvider implements PayoutProvider { + readonly key = "paystack" as const; + constructor(private readonly paystack: PaystackClient) {} + + async listBanks() { + return this.paystack.getBanks(); + } + + async resolveAccount( + input: ResolvePayoutAccountInput, + ): Promise { + const account = await this.paystack.resolveAccount( + input.accountNumber, + input.bankCode, + ); + + return { + accountName: account.account_name, + accountNumber: account.account_number, + bankId: account.bank_id, + }; + } + + async createRecipient( + input: CreatePayoutRecipientInput, + ): Promise { + const recipient = await this.paystack.createTransferRecipient( + input.bankCode, + input.accountNumber, + input.accountName, + ); + + return { recipientCode: recipient.recipient_code }; + } + + async initiateTransfer( + input: InitiatePayoutTransferInput, + ): Promise { + if (!input.destination.recipientCode) { + throw new Error("Paystack payout destination is not configured"); + } + const transfer = await this.paystack.createTransfer( + input.destination.recipientCode, + input.amountKobo, + input.reference, + input.reason, + ); + + return { + transferCode: transfer.transfer_code, + reference: transfer.reference ?? input.reference, + status: normalizeTransferStatus(transfer.status), + }; + } + + async verifyTransfer(reference: string): Promise { + const transfer = await this.paystack.verifyTransfer(reference); + return { + reference: transfer.reference ?? reference, + transferCode: transfer.transfer_code ?? null, + status: normalizeTransferStatus(transfer.status), + }; + } +} diff --git a/apps/backend/src/domains/money/refund/README.md b/apps/backend/src/domains/money/refund/README.md new file mode 100644 index 00000000..8318aa28 --- /dev/null +++ b/apps/backend/src/domains/money/refund/README.md @@ -0,0 +1,5 @@ +# Refund Domain + +Refund trigger and provider coordination boundary. +Ledger writes must go through the ledger service. + diff --git a/apps/backend/src/domains/money/refund/payment-amount-exception-refund-reconciliation.service.ts b/apps/backend/src/domains/money/refund/payment-amount-exception-refund-reconciliation.service.ts new file mode 100644 index 00000000..aa9efb2e --- /dev/null +++ b/apps/backend/src/domains/money/refund/payment-amount-exception-refund-reconciliation.service.ts @@ -0,0 +1,65 @@ +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from "@nestjs/common"; +import { SchedulerRegistry } from "@nestjs/schedule"; + +import { PaymentAmountExceptionRefundService } from "./payment-amount-exception-refund.service"; + +@Injectable() +export class PaymentAmountExceptionRefundReconciliationService + implements OnModuleInit, OnModuleDestroy +{ + private static readonly timerName = + "payment-amount-exception-refund-reconcile"; + private readonly logger = new Logger( + PaymentAmountExceptionRefundReconciliationService.name, + ); + private running = false; + + constructor( + private readonly refunds: PaymentAmountExceptionRefundService, + private readonly scheduler: SchedulerRegistry, + ) {} + + onModuleInit(): void { + const timer = setInterval(() => { + void this.runOnce().catch((error: unknown) => + this.logger.error( + `Payment amount exception refund reconciliation tick failed: ${ + error instanceof Error ? error.message : "unknown" + }`, + ), + ); + }, 60_000); + this.scheduler.addInterval( + PaymentAmountExceptionRefundReconciliationService.timerName, + timer, + ); + } + + onModuleDestroy(): void { + if ( + this.scheduler.doesExist( + "interval", + PaymentAmountExceptionRefundReconciliationService.timerName, + ) + ) { + this.scheduler.deleteInterval( + PaymentAmountExceptionRefundReconciliationService.timerName, + ); + } + } + + async runOnce(): Promise { + if (this.running) return; + this.running = true; + try { + await this.refunds.reconcileDue(); + } finally { + this.running = false; + } + } +} diff --git a/apps/backend/src/domains/money/refund/payment-amount-exception-refund.outbox-handler.ts b/apps/backend/src/domains/money/refund/payment-amount-exception-refund.outbox-handler.ts new file mode 100644 index 00000000..580050d5 --- /dev/null +++ b/apps/backend/src/domains/money/refund/payment-amount-exception-refund.outbox-handler.ts @@ -0,0 +1,43 @@ +import { Injectable, OnModuleInit } from "@nestjs/common"; + +import { NonRetryableOutboxError } from "../../platform/outbox/outbox.errors"; +import { OutboxHandlerRegistry } from "../../platform/outbox/outbox-handler.registry"; +import { + OUTBOX_TOPIC, + type ClaimedOutboxEvent, + type OutboxHandler, + type PaymentAmountExceptionRefundExecuteRequestedV1, +} from "../../platform/outbox/outbox.types"; +import { PaymentAmountExceptionRefundService } from "./payment-amount-exception-refund.service"; + +@Injectable() +export class PaymentAmountExceptionRefundOutboxHandler + implements + OutboxHandler, + OnModuleInit +{ + readonly topic = + OUTBOX_TOPIC.PAYMENT_AMOUNT_EXCEPTION_REFUND_EXECUTE_REQUESTED; + readonly version = 1; + + constructor( + private readonly refunds: PaymentAmountExceptionRefundService, + private readonly registry: OutboxHandlerRegistry, + ) {} + + onModuleInit(): void { + this.registry.register(this); + } + + async handle( + event: ClaimedOutboxEvent, + ): Promise { + const refundId = event.payload?.refundId; + if (typeof refundId !== "string" || refundId.trim().length === 0) { + throw new NonRetryableOutboxError( + "Invalid payment amount exception refund execute payload", + ); + } + await this.refunds.execute(refundId); + } +} diff --git a/apps/backend/src/domains/money/refund/payment-amount-exception-refund.service.spec.ts b/apps/backend/src/domains/money/refund/payment-amount-exception-refund.service.spec.ts new file mode 100644 index 00000000..8ea936fc --- /dev/null +++ b/apps/backend/src/domains/money/refund/payment-amount-exception-refund.service.spec.ts @@ -0,0 +1,344 @@ +import { + PaymentAmountExceptionRefundStatus, + PaymentAmountExceptionStatus, + PaymentAmountExceptionType, + PaymentStatus, +} from "@prisma/client"; +import { BadRequestException } from "@nestjs/common"; + +import { PaymentAmountExceptionRefundConfig } from "../../../config/payment-amount-exception-refund.config"; +import { PaymentAmountExceptionRefundService } from "./payment-amount-exception-refund.service"; + +describe("PaymentAmountExceptionRefundService", () => { + const client = { + paymentAmountException: { + findUnique: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + }, + paymentAmountExceptionRefund: { + create: jest.fn(), + findUnique: jest.fn(), + updateMany: jest.fn(), + findMany: jest.fn(), + }, + payment: { updateMany: jest.fn() }, + }; + const prisma = { + ...client, + $transaction: jest.fn((callback: (tx: typeof client) => unknown) => + callback(client), + ), + }; + const ledger = { recordRefund: jest.fn() }; + const outbox = { append: jest.fn() }; + const provider = { + key: "paystack" as const, + supportsNewRefunds: true, + createRefund: jest.fn(), + fetchRefund: jest.fn(), + }; + const providers = { getForPaymentProvider: jest.fn(() => provider) }; + const service = new PaymentAmountExceptionRefundService( + prisma as never, + ledger as never, + outbox as never, + providers as never, + ); + const executionFlag = "PAYMENT_AMOUNT_EXCEPTION_REFUND_EXECUTION_ENABLED"; + const previousFlag = process.env[executionFlag]; + + const exception = { + id: "exception-1", + exceptionType: PaymentAmountExceptionType.OVERPAID, + status: PaymentAmountExceptionStatus.RECONCILIATION_REQUIRED, + provider: "PAYSTACK" as const, + providerReference: "charge-1", + providerTransactionReference: "provider-tx-1", + receivedAmountKobo: 10_001n, + refund: null, + payment: { + id: "payment-1", + orderId: "order-1", + status: PaymentStatus.RECONCILIATION_REQUIRED, + }, + }; + const refund = { + id: "refund-1", + paymentAmountExceptionId: "exception-1", + provider: "PAYSTACK" as const, + transactionReference: "charge-1", + amountKobo: 10_001n, + status: PaymentAmountExceptionRefundStatus.PENDING, + providerRefundId: null, + attempts: 1, + }; + + beforeEach(() => { + jest.clearAllMocks(); + process.env[executionFlag] = "true"; + }); + + afterAll(() => { + if (previousFlag === undefined) delete process.env[executionFlag]; + else process.env[executionFlag] = previousFlag; + }); + + it("keeps the full-refund action dark until its dedicated execution gate is enabled", async () => { + process.env[executionFlag] = "false"; + + await expect( + service.createPlan("exception-1", "admin-1"), + ).rejects.toBeInstanceOf(BadRequestException); + expect(client.paymentAmountException.findUnique).not.toHaveBeenCalled(); + expect(outbox.append).not.toHaveBeenCalled(); + expect(PaymentAmountExceptionRefundConfig.executionEnabled).toBe(false); + }); + + it("creates one immutable full-refund plan through the original provider", async () => { + client.paymentAmountException.findUnique.mockResolvedValue(exception); + client.paymentAmountExceptionRefund.create.mockResolvedValue(refund); + client.paymentAmountException.update.mockResolvedValue({}); + outbox.append.mockResolvedValue({ id: "outbox-1" }); + + await expect( + service.createPlan("exception-1", "admin-1", client as never), + ).resolves.toEqual({ refund, created: true }); + + expect(client.paymentAmountExceptionRefund.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + paymentAmountExceptionId: "exception-1", + provider: "PAYSTACK", + transactionReference: "charge-1", + amountKobo: 10_001n, + createdBy: "admin-1", + }), + }); + expect(outbox.append).toHaveBeenCalledWith( + client, + expect.objectContaining({ + idempotencyKey: "payment-amount-exception-refund:refund-1:execute", + }), + ); + }); + + it("returns an existing plan without appending another execution request", async () => { + client.paymentAmountException.findUnique.mockResolvedValue({ + ...exception, + refund, + }); + + await expect( + service.createPlan("exception-1", "admin-1", client as never), + ).resolves.toEqual({ refund, created: false }); + + expect(client.paymentAmountExceptionRefund.create).not.toHaveBeenCalled(); + expect(outbox.append).not.toHaveBeenCalled(); + }); + + it("claims a pending refund once before submitting it to the provider", async () => { + client.paymentAmountExceptionRefund.updateMany + .mockResolvedValueOnce({ count: 1 }) + .mockResolvedValueOnce({ count: 0 }) + .mockResolvedValueOnce({ count: 1 }); + client.paymentAmountExceptionRefund.findUnique.mockResolvedValue(refund); + client.paymentAmountException.updateMany.mockResolvedValue({ count: 1 }); + provider.createRefund.mockResolvedValue({ + provider: "paystack", + providerRefundId: "provider-refund-1", + status: "PENDING", + amountKobo: 10_001n, + }); + + await Promise.all([ + service.execute("refund-1"), + service.execute("refund-1"), + ]); + + expect(provider.createRefund).toHaveBeenCalledTimes(1); + expect(provider.createRefund).toHaveBeenCalledWith({ + transactionReference: "charge-1", + refundReference: "twz-payment-exception-refund-refund-1", + amountKobo: 10_001n, + currency: "NGN", + }); + expect(ledger.recordRefund).not.toHaveBeenCalled(); + }); + + it("writes the refund ledger entry only after a provider-confirmed completion", async () => { + client.paymentAmountExceptionRefund.updateMany + .mockResolvedValueOnce({ count: 1 }) + .mockResolvedValueOnce({ count: 1 }); + client.paymentAmountExceptionRefund.findUnique.mockResolvedValue(refund); + client.paymentAmountException.findUnique.mockResolvedValue(exception); + client.payment.updateMany.mockResolvedValue({ count: 1 }); + client.paymentAmountException.updateMany.mockResolvedValue({ count: 1 }); + provider.createRefund.mockResolvedValue({ + provider: "paystack", + providerRefundId: "provider-refund-1", + status: "COMPLETED", + amountKobo: 10_001n, + }); + ledger.recordRefund.mockResolvedValue({ id: "ledger-1" }); + + await service.execute("refund-1"); + + expect(ledger.recordRefund).toHaveBeenCalledWith( + "order-1", + 10_001n, + "provider-refund-1", + expect.objectContaining({ + paymentAmountExceptionId: "exception-1", + idempotencyKey: "payment-amount-exception-refund:refund-1:completed", + }), + ); + expect(client.payment.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ data: { status: PaymentStatus.REFUNDED } }), + ); + }); + + it("moves an ambiguous provider timeout to manual review without a completion ledger entry", async () => { + client.paymentAmountExceptionRefund.updateMany + .mockResolvedValueOnce({ count: 1 }) + .mockResolvedValueOnce({ count: 1 }); + client.paymentAmountExceptionRefund.findUnique + .mockResolvedValueOnce(refund) + .mockResolvedValueOnce({ paymentAmountExceptionId: "exception-1" }); + client.paymentAmountException.updateMany.mockResolvedValue({ count: 1 }); + provider.createRefund.mockRejectedValue(new Error("timeout")); + + await service.execute("refund-1"); + + expect(ledger.recordRefund).not.toHaveBeenCalled(); + expect( + client.paymentAmountExceptionRefund.updateMany, + ).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: PaymentAmountExceptionRefundStatus.MANUAL_REVIEW, + }), + }), + ); + }); + + it("writes the completion ledger entry when reconciliation confirms success", async () => { + const submittedRefund = { + ...refund, + status: PaymentAmountExceptionRefundStatus.SUBMITTED, + providerRefundId: "provider-refund-1", + }; + client.paymentAmountExceptionRefund.findUnique.mockResolvedValue( + submittedRefund, + ); + client.paymentAmountExceptionRefund.updateMany.mockResolvedValue({ + count: 1, + }); + client.paymentAmountException.findUnique.mockResolvedValue(exception); + client.payment.updateMany.mockResolvedValue({ count: 1 }); + client.paymentAmountException.updateMany.mockResolvedValue({ count: 1 }); + provider.fetchRefund.mockResolvedValue({ + providerRefundId: "provider-refund-1", + status: "COMPLETED", + amountKobo: 10_001n, + }); + ledger.recordRefund.mockResolvedValue({ id: "ledger-1" }); + + await service.reconcile("refund-1"); + + expect(ledger.recordRefund).toHaveBeenCalledWith( + "order-1", + 10_001n, + "provider-refund-1", + expect.objectContaining({ + idempotencyKey: "payment-amount-exception-refund:refund-1:completed", + }), + ); + }); + + it("moves provider-confirmed rejection to manual review without a ledger completion", async () => { + const submittedRefund = { + ...refund, + status: PaymentAmountExceptionRefundStatus.SUBMITTED, + providerRefundId: "provider-refund-1", + }; + client.paymentAmountExceptionRefund.findUnique + .mockResolvedValueOnce(submittedRefund) + .mockResolvedValueOnce({ paymentAmountExceptionId: "exception-1" }); + client.paymentAmountExceptionRefund.updateMany.mockResolvedValue({ + count: 1, + }); + client.paymentAmountException.updateMany.mockResolvedValue({ count: 1 }); + provider.fetchRefund.mockResolvedValue({ + providerRefundId: "provider-refund-1", + status: "FAILED", + amountKobo: 10_001n, + }); + + await service.reconcile("refund-1"); + + expect(ledger.recordRefund).not.toHaveBeenCalled(); + expect(client.paymentAmountExceptionRefund.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: PaymentAmountExceptionRefundStatus.MANUAL_REVIEW, + }), + }), + ); + }); + + it("escalates unresolved provider reconciliation once observation attempts are exhausted", async () => { + const submittedRefund = { + ...refund, + attempts: 6, + status: PaymentAmountExceptionRefundStatus.SUBMITTED, + providerRefundId: "provider-refund-1", + }; + client.paymentAmountExceptionRefund.findUnique + .mockResolvedValueOnce(submittedRefund) + .mockResolvedValueOnce({ paymentAmountExceptionId: "exception-1" }); + client.paymentAmountExceptionRefund.updateMany.mockResolvedValue({ + count: 1, + }); + client.paymentAmountException.updateMany.mockResolvedValue({ count: 1 }); + provider.fetchRefund.mockResolvedValue({ + providerRefundId: "provider-refund-1", + status: "PENDING", + amountKobo: 10_001n, + }); + + await service.reconcile("refund-1"); + + expect(ledger.recordRefund).not.toHaveBeenCalled(); + expect(client.paymentAmountExceptionRefund.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: PaymentAmountExceptionRefundStatus.MANUAL_REVIEW, + }), + }), + ); + }); + + it("reschedules a transient reconciliation lookup error without a ledger completion", async () => { + const submittedRefund = { + ...refund, + status: PaymentAmountExceptionRefundStatus.SUBMITTED, + providerRefundId: "provider-refund-1", + }; + client.paymentAmountExceptionRefund.findUnique.mockResolvedValue( + submittedRefund, + ); + client.paymentAmountExceptionRefund.updateMany.mockResolvedValue({ + count: 1, + }); + provider.fetchRefund.mockRejectedValue(new Error("provider unavailable")); + + await service.reconcile("refund-1"); + + expect(ledger.recordRefund).not.toHaveBeenCalled(); + expect(client.paymentAmountExceptionRefund.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ attempts: { increment: 1 } }), + }), + ); + }); +}); diff --git a/apps/backend/src/domains/money/refund/payment-amount-exception-refund.service.ts b/apps/backend/src/domains/money/refund/payment-amount-exception-refund.service.ts new file mode 100644 index 00000000..d20efba0 --- /dev/null +++ b/apps/backend/src/domains/money/refund/payment-amount-exception-refund.service.ts @@ -0,0 +1,540 @@ +import { + BadRequestException, + Inject, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { + PaymentAmountExceptionRefundStatus, + PaymentAmountExceptionStatus, + PaymentAmountExceptionType, + PaymentStatus, + Prisma, +} from "@prisma/client"; + +import { PaymentAmountExceptionRefundConfig } from "../../../config/payment-amount-exception-refund.config"; +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { LedgerService } from "../ledger/ledger.service"; +import { OutboxService } from "../../platform/outbox/outbox.service"; +import { OUTBOX_TOPIC } from "../../platform/outbox/outbox.types"; +import { + REFUND_PROVIDER_REGISTRY, + type RefundProviderRegistry, +} from "./refund-provider.registry"; + +const RECONCILE_DELAY_MS = 60_000; +const MAX_PROVIDER_OBSERVATION_ATTEMPTS = 6; +const STALE_PROCESSING_REFUND_MS = 5 * 60_000; + +type ExceptionRefund = { + id: string; + paymentAmountExceptionId: string; + provider: "PAYSTACK" | "MONNIFY"; + transactionReference: string; + amountKobo: bigint; + status: PaymentAmountExceptionRefundStatus; + providerRefundId: string | null; + attempts: number; +}; + +@Injectable() +export class PaymentAmountExceptionRefundService { + constructor( + private readonly prisma: PrismaService, + private readonly ledger: LedgerService, + private readonly outbox: OutboxService, + @Inject(REFUND_PROVIDER_REGISTRY) + private readonly providers: RefundProviderRegistry, + ) {} + + /** + * Creates exactly one full-refund plan for an overpaid, unallocated payment. + * This is intentionally not an escrow allocation: the order stays unpaid. + */ + async createPlan( + exceptionId: string, + adminUserId: string, + tx?: Prisma.TransactionClient, + ) { + // A PENDING plan has no safe execution path while the feature is dark. + // Refusing creation prevents a financial operation being stranded until a + // later environment change. + if (!PaymentAmountExceptionRefundConfig.executionEnabled) { + throw new BadRequestException( + "Payment amount exception refund execution is disabled", + ); + } + + const create = async (client: Prisma.TransactionClient) => { + const exception = await client.paymentAmountException.findUnique({ + where: { id: exceptionId }, + include: { + refund: true, + payment: { + select: { + id: true, + provider: true, + providerReference: true, + providerTransactionReference: true, + status: true, + orderId: true, + }, + }, + }, + }); + if (!exception) { + throw new NotFoundException("Payment amount exception not found"); + } + if (exception.refund) { + return { refund: exception.refund, created: false }; + } + + if ( + exception.exceptionType !== PaymentAmountExceptionType.OVERPAID || + exception.status !== + PaymentAmountExceptionStatus.RECONCILIATION_REQUIRED || + exception.payment.status !== PaymentStatus.RECONCILIATION_REQUIRED + ) { + throw new BadRequestException( + "This payment exception is not eligible for a refund plan", + ); + } + + const transactionReference = + exception.provider === "MONNIFY" + ? exception.providerTransactionReference + : exception.providerReference; + if (!transactionReference) { + throw new BadRequestException( + "The original payment has no safe provider transaction reference for a refund", + ); + } + + const refund = await client.paymentAmountExceptionRefund.create({ + data: { + paymentAmountExceptionId: exception.id, + provider: exception.provider, + transactionReference, + // Until a multi-collection allocation design exists, reverse the + // entire collected amount. The expected order amount remains unpaid. + amountKobo: exception.receivedAmountKobo, + createdBy: adminUserId, + }, + }); + await client.paymentAmountException.update({ + where: { id: exception.id }, + data: { status: PaymentAmountExceptionStatus.REFUND_PENDING }, + }); + + await this.outbox.append(client, { + topic: OUTBOX_TOPIC.PAYMENT_AMOUNT_EXCEPTION_REFUND_EXECUTE_REQUESTED, + version: 1, + aggregateType: "PAYMENT_AMOUNT_EXCEPTION_REFUND", + aggregateId: refund.id, + idempotencyKey: `payment-amount-exception-refund:${refund.id}:execute`, + payload: { refundId: refund.id }, + }); + return { refund, created: true }; + }; + + return tx ? create(tx) : this.prisma.$transaction(create); + } + + /** + * Claims and submits one pending plan. The provider call is outside a + * transaction; guarded status updates prevent duplicate submissions. + */ + async execute(refundId: string): Promise { + if (!PaymentAmountExceptionRefundConfig.executionEnabled) return; + + const claim = await this.prisma.paymentAmountExceptionRefund.updateMany({ + where: { + id: refundId, + status: PaymentAmountExceptionRefundStatus.PENDING, + }, + data: { + status: PaymentAmountExceptionRefundStatus.PROCESSING, + attempts: { increment: 1 }, + }, + }); + if (claim.count !== 1) return; + + const refund = await this.prisma.paymentAmountExceptionRefund.findUnique({ + where: { id: refundId }, + }); + if (!refund) return; + + let provider; + try { + provider = this.providers.getForPaymentProvider(refund.provider); + } catch { + await this.requiresManualReview( + refund.id, + "No refund provider is registered", + ); + return; + } + if (!provider.supportsNewRefunds) { + await this.requiresManualReview( + refund.id, + "The original payment provider is not enabled for new refunds", + ); + return; + } + + let result; + try { + result = await provider.createRefund({ + transactionReference: refund.transactionReference, + refundReference: `twz-payment-exception-refund-${refund.id}`, + amountKobo: refund.amountKobo, + currency: "NGN", + }); + } catch { + // A timeout cannot prove rejection, but without a provider operation ID + // this contract cannot poll safely. Escalate rather than resubmit. + await this.requiresManualReview( + refund.id, + "Provider refund submission outcome is unknown", + ); + return; + } + + try { + if ( + result.provider !== provider.key || + result.amountKobo !== refund.amountKobo + ) { + await this.markReconciliationRequired( + refund.id, + result.providerRefundId, + ); + return; + } + if (result.status === "COMPLETED") { + await this.confirmCompleted(refund, result.providerRefundId); + return; + } + if (result.status === "FAILED") { + await this.requiresManualReview( + refund.id, + "The provider rejected the refund", + ); + return; + } + if (result.status === "NEEDS_ATTENTION") { + await this.markReconciliationRequired( + refund.id, + result.providerRefundId, + ); + return; + } + + await this.prisma.$transaction(async (tx) => { + const updated = await tx.paymentAmountExceptionRefund.updateMany({ + where: { + id: refund.id, + status: PaymentAmountExceptionRefundStatus.PROCESSING, + }, + data: { + status: PaymentAmountExceptionRefundStatus.SUBMITTED, + providerRefundId: result.providerRefundId, + submittedAt: new Date(), + nextReconcileAt: new Date(Date.now() + RECONCILE_DELAY_MS), + failureSummary: null, + }, + }); + if (updated.count === 1) { + await tx.paymentAmountException.updateMany({ + where: { + id: refund.paymentAmountExceptionId, + status: PaymentAmountExceptionStatus.REFUND_PENDING, + }, + data: { status: PaymentAmountExceptionStatus.REFUND_SUBMITTED }, + }); + } + }); + } catch { + // Provider acceptance may already have happened. Never retry a claimed + // request blindly just because its local follow-up write failed. + await this.markReconciliationRequired(refund.id, result.providerRefundId); + } + } + + /** + * Observes an already-submitted provider operation. It never creates a new + * refund, and writes completion only after a confirmed matching amount. + */ + async reconcile(refundId: string): Promise { + const refund = await this.prisma.paymentAmountExceptionRefund.findUnique({ + where: { id: refundId }, + }); + if ( + !refund || + !refund.providerRefundId || + (refund.status !== PaymentAmountExceptionRefundStatus.SUBMITTED && + refund.status !== + PaymentAmountExceptionRefundStatus.RECONCILIATION_REQUIRED) + ) + return; + + try { + const provider = this.providers.getForPaymentProvider(refund.provider); + const result = await provider.fetchRefund(refund.providerRefundId); + if (result.amountKobo !== refund.amountKobo) { + await this.markReconciliationRequired( + refund.id, + refund.providerRefundId, + ); + } else if (result.status === "COMPLETED") { + await this.confirmCompleted(refund, refund.providerRefundId); + } else if (result.status === "FAILED") { + await this.requiresManualReview( + refund.id, + "The provider rejected the refund", + ); + } else { + await this.scheduleReconciliationOrManualReview(refund); + } + } catch { + await this.scheduleReconciliationOrManualReview(refund); + } + } + + /** + * Recovers pending plans, escalates stale ambiguous claims, then polls due + * provider operations. Pending plans submit only while execution is enabled. + */ + async reconcileDue(): Promise { + if (PaymentAmountExceptionRefundConfig.executionEnabled) { + const pending = await this.prisma.paymentAmountExceptionRefund.findMany({ + where: { status: PaymentAmountExceptionRefundStatus.PENDING }, + take: 25, + orderBy: { createdAt: "asc" }, + select: { id: true }, + }); + for (const refund of pending) await this.execute(refund.id); + } + + const staleProcessing = + await this.prisma.paymentAmountExceptionRefund.findMany({ + where: { + status: PaymentAmountExceptionRefundStatus.PROCESSING, + updatedAt: { lte: new Date(Date.now() - STALE_PROCESSING_REFUND_MS) }, + }, + take: 25, + orderBy: { updatedAt: "asc" }, + select: { id: true }, + }); + for (const refund of staleProcessing) { + // A stale claim may have reached the provider. Escalate instead of + // resubmitting, because there may be no provider operation ID to poll. + await this.requiresManualReview( + refund.id, + "Provider submission did not reach a recoverable local state", + ); + } + + const refunds = await this.prisma.paymentAmountExceptionRefund.findMany({ + where: { + status: { + in: [ + PaymentAmountExceptionRefundStatus.SUBMITTED, + PaymentAmountExceptionRefundStatus.RECONCILIATION_REQUIRED, + ], + }, + providerRefundId: { not: null }, + OR: [ + { nextReconcileAt: null }, + { nextReconcileAt: { lte: new Date() } }, + ], + }, + take: 25, + orderBy: { createdAt: "asc" }, + select: { id: true }, + }); + for (const refund of refunds) await this.reconcile(refund.id); + } + + private async scheduleReconciliationOrManualReview( + refund: ExceptionRefund, + ): Promise { + if (refund.attempts >= MAX_PROVIDER_OBSERVATION_ATTEMPTS) { + await this.requiresManualReview( + refund.id, + "Provider reconciliation attempts exhausted", + ); + return; + } + + await this.prisma.paymentAmountExceptionRefund.updateMany({ + where: { + id: refund.id, + status: { + in: [ + PaymentAmountExceptionRefundStatus.SUBMITTED, + PaymentAmountExceptionRefundStatus.RECONCILIATION_REQUIRED, + ], + }, + }, + data: { + attempts: { increment: 1 }, + nextReconcileAt: new Date(Date.now() + RECONCILE_DELAY_MS), + }, + }); + } + + private async confirmCompleted( + refund: ExceptionRefund, + providerRefundId: string, + ): Promise { + await this.prisma.$transaction(async (tx) => { + const claimed = await tx.paymentAmountExceptionRefund.updateMany({ + where: { + id: refund.id, + status: { + in: [ + PaymentAmountExceptionRefundStatus.PROCESSING, + PaymentAmountExceptionRefundStatus.SUBMITTED, + PaymentAmountExceptionRefundStatus.RECONCILIATION_REQUIRED, + ], + }, + }, + data: { + status: PaymentAmountExceptionRefundStatus.COMPLETED, + providerRefundId, + completedAt: new Date(), + nextReconcileAt: null, + failureSummary: null, + }, + }); + if (claimed.count !== 1) return; + + const exception = await tx.paymentAmountException.findUnique({ + where: { id: refund.paymentAmountExceptionId }, + include: { + payment: { select: { id: true, orderId: true, status: true } }, + }, + }); + if (!exception) throw new Error("Payment amount exception was not found"); + await this.ledger.recordRefund( + exception.payment.orderId, + refund.amountKobo, + providerRefundId, + { + tx, + paymentId: exception.paymentId, + paymentAmountExceptionId: exception.id, + idempotencyKey: `payment-amount-exception-refund:${refund.id}:completed`, + metadata: { provider: refund.provider, refundId: refund.id }, + }, + ); + await tx.payment.updateMany({ + where: { + id: exception.paymentId, + status: PaymentStatus.RECONCILIATION_REQUIRED, + }, + data: { status: PaymentStatus.REFUNDED }, + }); + await tx.paymentAmountException.updateMany({ + where: { + id: exception.id, + status: { + in: [ + PaymentAmountExceptionStatus.REFUND_PENDING, + PaymentAmountExceptionStatus.REFUND_SUBMITTED, + PaymentAmountExceptionStatus.RECONCILIATION_REQUIRED, + ], + }, + }, + data: { status: PaymentAmountExceptionStatus.RESOLVED }, + }); + }); + } + + private async markReconciliationRequired( + refundId: string, + providerRefundId: string | null, + ): Promise { + await this.prisma.$transaction(async (tx) => { + const refund = await tx.paymentAmountExceptionRefund.updateMany({ + where: { + id: refundId, + status: { + in: [ + PaymentAmountExceptionRefundStatus.PROCESSING, + PaymentAmountExceptionRefundStatus.SUBMITTED, + PaymentAmountExceptionRefundStatus.RECONCILIATION_REQUIRED, + ], + }, + }, + data: { + status: PaymentAmountExceptionRefundStatus.RECONCILIATION_REQUIRED, + ...(providerRefundId ? { providerRefundId } : {}), + nextReconcileAt: new Date(Date.now() + RECONCILE_DELAY_MS), + failureSummary: "Provider refund outcome requires reconciliation", + }, + }); + if (refund.count === 1) { + const current = await tx.paymentAmountExceptionRefund.findUnique({ + where: { id: refundId }, + select: { paymentAmountExceptionId: true }, + }); + if (current) + await tx.paymentAmountException.updateMany({ + where: { + id: current.paymentAmountExceptionId, + status: { + in: [ + PaymentAmountExceptionStatus.REFUND_PENDING, + PaymentAmountExceptionStatus.REFUND_SUBMITTED, + ], + }, + }, + data: { + status: PaymentAmountExceptionStatus.RECONCILIATION_REQUIRED, + }, + }); + } + }); + } + + private async requiresManualReview( + refundId: string, + summary: string, + ): Promise { + await this.prisma.$transaction(async (tx) => { + const refund = await tx.paymentAmountExceptionRefund.updateMany({ + where: { + id: refundId, + status: { + in: [ + PaymentAmountExceptionRefundStatus.PROCESSING, + PaymentAmountExceptionRefundStatus.SUBMITTED, + PaymentAmountExceptionRefundStatus.RECONCILIATION_REQUIRED, + ], + }, + }, + data: { + status: PaymentAmountExceptionRefundStatus.MANUAL_REVIEW, + nextReconcileAt: null, + failureSummary: summary, + }, + }); + if (refund.count === 1) { + const current = await tx.paymentAmountExceptionRefund.findUnique({ + where: { id: refundId }, + select: { paymentAmountExceptionId: true }, + }); + if (current) + await tx.paymentAmountException.updateMany({ + where: { + id: current.paymentAmountExceptionId, + status: { not: PaymentAmountExceptionStatus.RESOLVED }, + }, + data: { status: PaymentAmountExceptionStatus.MANUAL_REVIEW }, + }); + } + }); + } +} diff --git a/apps/backend/src/domains/money/refund/paystack-refund.provider.ts b/apps/backend/src/domains/money/refund/paystack-refund.provider.ts new file mode 100644 index 00000000..ca29523a --- /dev/null +++ b/apps/backend/src/domains/money/refund/paystack-refund.provider.ts @@ -0,0 +1,73 @@ +import { Injectable } from "@nestjs/common"; + +import { PaystackClient } from "../../../integrations/paystack/paystack.client"; +import type { PaystackRefundResponse } from "../../../integrations/paystack/paystack.types"; +import { + CreateRefundInput, + CreateRefundResult, + FetchRefundResult, + RefundProvider, + RefundProviderKey, + RefundStatus, +} from "./refund-provider.interface"; + +const PROVIDER: RefundProviderKey = "paystack"; + +// Paystack refund status -> normalized Twizrr status. Only "processed" is a +// confirmed completion; everything unrecognized is NEEDS_ATTENTION so it is +// reconciled/reviewed rather than assumed complete. +function normalizeRefundStatus(status: string | null): RefundStatus { + switch ((status ?? "").toLowerCase()) { + case "processed": + return "COMPLETED"; + case "pending": + return "PENDING"; + case "processing": + return "PROCESSING"; + case "failed": + return "FAILED"; + default: + return "NEEDS_ATTENTION"; + } +} + +@Injectable() +export class PaystackRefundProvider implements RefundProvider { + readonly key: RefundProviderKey = "paystack"; + readonly supportsNewRefunds = true; + + constructor(private readonly paystack: PaystackClient) {} + + async createRefund(input: CreateRefundInput): Promise { + const refund = await this.paystack.createRefund( + input.transactionReference, + input.amountKobo, + ); + return { + provider: PROVIDER, + providerRefundId: String(refund.id), + status: normalizeRefundStatus(refund.status), + amountKobo: this.readAmountKobo(refund, input.amountKobo), + }; + } + + async fetchRefund(providerRefundId: string): Promise { + const refund = await this.paystack.getRefund(providerRefundId); + const status = normalizeRefundStatus(refund.status); + return { + providerRefundId: String(refund.id), + status, + amountKobo: this.readAmountKobo(refund, 0n), + completedAt: status === "COMPLETED" ? new Date() : null, + }; + } + + private readAmountKobo( + refund: PaystackRefundResponse, + fallback: bigint, + ): bigint { + return typeof refund.amount === "number" && Number.isFinite(refund.amount) + ? BigInt(Math.trunc(refund.amount)) + : fallback; + } +} diff --git a/apps/backend/src/domains/money/refund/refund-provider.interface.ts b/apps/backend/src/domains/money/refund/refund-provider.interface.ts new file mode 100644 index 00000000..43acae8f --- /dev/null +++ b/apps/backend/src/domains/money/refund/refund-provider.interface.ts @@ -0,0 +1,50 @@ +// Twizrr-owned refund contract. Deliberately narrow: refunds are NOT added to +// the general PaymentProvider just because Paystack exposes them. Adapters +// return normalized Twizrr results, never raw provider payloads. + +export type RefundStatus = + | "PENDING" + | "PROCESSING" + | "COMPLETED" + | "NEEDS_ATTENTION" + | "FAILED"; + +// A refund is permanently bound to the provider that collected the original +// payment. This is intentionally separate from the active checkout selector: +// changing a future PAYMENT_PROVIDER setting must never reroute an existing +// payment's refund through a different provider. +export type RefundProviderKey = "paystack" | "monnify"; + +export interface CreateRefundInput { + transactionReference: string; + // Stable Twizrr-owned reference. Providers that support idempotent refund + // requests must receive this rather than a random retry-time value. + refundReference: string; + amountKobo: bigint; + currency: "NGN"; + customerNote?: string; + merchantNote?: string; +} + +export interface CreateRefundResult { + provider: RefundProviderKey; + providerRefundId: string; + status: RefundStatus; + amountKobo: bigint; +} + +export interface FetchRefundResult { + providerRefundId: string; + status: RefundStatus; + amountKobo: bigint; + completedAt?: Date | null; +} + +export interface RefundProvider { + readonly key: RefundProviderKey; + // Enables new provider submissions only. Reconciliation remains available + // for already-submitted operations even after the flag is turned off. + readonly supportsNewRefunds: boolean; + createRefund(input: CreateRefundInput): Promise; + fetchRefund(providerRefundId: string): Promise; +} diff --git a/apps/backend/src/domains/money/refund/refund-provider.registry.spec.ts b/apps/backend/src/domains/money/refund/refund-provider.registry.spec.ts new file mode 100644 index 00000000..8dc3678c --- /dev/null +++ b/apps/backend/src/domains/money/refund/refund-provider.registry.spec.ts @@ -0,0 +1,40 @@ +import { createRefundProviderRegistry } from "./refund-provider.registry"; +import type { RefundProvider } from "./refund-provider.interface"; + +function provider(key: "paystack" | "monnify"): RefundProvider { + return { + key, + supportsNewRefunds: true, + createRefund: jest.fn(), + fetchRefund: jest.fn(), + }; +} + +describe("RefundProviderRegistry", () => { + it("binds refunds to the provider that collected the payment", () => { + const paystack = provider("paystack"); + const monnify = provider("monnify"); + const registry = createRefundProviderRegistry({ paystack, monnify }); + + expect(registry.getForPaymentProvider("PAYSTACK")).toBe(paystack); + expect(registry.getForPaymentProvider("MONNIFY")).toBe(monnify); + }); + + it("does not fall back to another provider for an unknown mapping", () => { + const registry = createRefundProviderRegistry({ + paystack: provider("paystack"), + monnify: provider("monnify"), + }); + + expect(() => registry.getByKey("unknown")).toThrow("not registered"); + }); + + it("rejects adapters registered under the wrong provider key", () => { + expect(() => + createRefundProviderRegistry({ + paystack: provider("monnify"), + monnify: provider("paystack"), + }), + ).toThrow("Paystack refund adapter"); + }); +}); diff --git a/apps/backend/src/domains/money/refund/refund-provider.registry.ts b/apps/backend/src/domains/money/refund/refund-provider.registry.ts new file mode 100644 index 00000000..3b2cf1c3 --- /dev/null +++ b/apps/backend/src/domains/money/refund/refund-provider.registry.ts @@ -0,0 +1,50 @@ +import type { PaymentProviderName } from "@prisma/client"; + +import { RefundProvider, RefundProviderKey } from "./refund-provider.interface"; + +export const REFUND_PROVIDER_REGISTRY = Symbol("REFUND_PROVIDER_REGISTRY"); + +export interface RefundProviderRegistry { + getByKey(provider: string): RefundProvider; + getForPaymentProvider(provider: PaymentProviderName): RefundProvider; +} + +export function createRefundProviderRegistry(input: { + paystack: RefundProvider; + monnify: RefundProvider; +}): RefundProviderRegistry { + if (input.paystack.key !== "paystack") { + throw new Error("Paystack refund adapter must use the 'paystack' key."); + } + if (input.monnify.key !== "monnify") { + throw new Error("Monnify refund adapter must use the 'monnify' key."); + } + + const providers = new Map([ + [input.paystack.key, input.paystack], + [input.monnify.key, input.monnify], + ]); + + const getByKey = (provider: string): RefundProvider => { + const key = provider.trim().toLowerCase() as RefundProviderKey; + const resolved = providers.get(key); + if (!resolved) { + throw new Error(`Refund provider '${provider}' is not registered.`); + } + return resolved; + }; + + return { + getByKey, + getForPaymentProvider(provider: PaymentProviderName): RefundProvider { + switch (provider) { + case "PAYSTACK": + return getByKey("paystack"); + case "MONNIFY": + return getByKey("monnify"); + default: + throw new Error(`No refund provider mapping exists for ${provider}.`); + } + }, + }; +} diff --git a/apps/backend/src/domains/money/refund/refund-webhook.service.spec.ts b/apps/backend/src/domains/money/refund/refund-webhook.service.spec.ts new file mode 100644 index 00000000..d7468eeb --- /dev/null +++ b/apps/backend/src/domains/money/refund/refund-webhook.service.spec.ts @@ -0,0 +1,90 @@ +import { RefundWebhookService } from "./refund-webhook.service"; + +describe("RefundWebhookService", () => { + const prisma = { + disputeSettlementLeg: { updateMany: jest.fn() }, + disputeSettlementRefundOperation: { updateMany: jest.fn() }, + paymentAmountExceptionRefund: { updateMany: jest.fn() }, + }; + const monnify = { + verifyWebhookSignature: jest.fn(), + parseWebhookEvent: jest.fn(), + }; + const service = new RefundWebhookService(prisma as never, monnify as never); + + beforeEach(() => jest.clearAllMocks()); + + it("fails closed when the raw body or signature is missing", async () => { + await expect( + service.handleMonnifyWebhook({ + rawBody: undefined, + signature: undefined, + payload: {}, + }), + ).resolves.toEqual({ status: "ignored" }); + + expect(monnify.verifyWebhookSignature).not.toHaveBeenCalled(); + expect(prisma.disputeSettlementLeg.updateMany).not.toHaveBeenCalled(); + expect( + prisma.disputeSettlementRefundOperation.updateMany, + ).not.toHaveBeenCalled(); + expect( + prisma.paymentAmountExceptionRefund.updateMany, + ).not.toHaveBeenCalled(); + }); + + it("only makes a verified existing refund leg eligible for reconciliation", async () => { + (monnify.verifyWebhookSignature as jest.Mock).mockReturnValue(true); + (monnify.parseWebhookEvent as jest.Mock).mockReturnValue({ + providerRefundId: "twz-refund-leg-1", + eventType: "SUCCESSFUL_REFUND", + }); + prisma.disputeSettlementLeg.updateMany.mockResolvedValue({ count: 1 }); + prisma.disputeSettlementRefundOperation.updateMany.mockResolvedValue({ + count: 1, + }); + prisma.paymentAmountExceptionRefund.updateMany.mockResolvedValue({ + count: 0, + }); + + await expect( + service.handleMonnifyWebhook({ + rawBody: Buffer.from("{}"), + signature: "signature", + payload: {}, + }), + ).resolves.toEqual({ status: "accepted" }); + + expect(prisma.disputeSettlementLeg.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + provider: "monnify", + providerOperationId: "twz-refund-leg-1", + }), + data: { + nextReconcileAt: expect.any(Date), + }, + }), + ); + expect(prisma.paymentAmountExceptionRefund.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + provider: "MONNIFY", + providerRefundId: "twz-refund-leg-1", + }), + data: { nextReconcileAt: expect.any(Date) }, + }), + ); + expect( + prisma.disputeSettlementRefundOperation.updateMany, + ).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + provider: "MONNIFY", + providerRefundId: "twz-refund-leg-1", + }), + data: { nextReconcileAt: expect.any(Date) }, + }), + ); + }); +}); diff --git a/apps/backend/src/domains/money/refund/refund-webhook.service.ts b/apps/backend/src/domains/money/refund/refund-webhook.service.ts new file mode 100644 index 00000000..7b541350 --- /dev/null +++ b/apps/backend/src/domains/money/refund/refund-webhook.service.ts @@ -0,0 +1,80 @@ +import { Injectable } from "@nestjs/common"; +import { + DisputeSettlementLegStatus, + DisputeSettlementLegType, + DisputeSettlementRefundOperationStatus, +} from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { MonnifyRefundProvider } from "../../../integrations/monnify/monnify-refund.provider"; + +@Injectable() +export class RefundWebhookService { + constructor( + private readonly prisma: PrismaService, + private readonly monnify: MonnifyRefundProvider, + ) {} + + /** + * Verified refund webhooks only make an existing submitted leg eligible for + * the next server-side reconciliation poll. The webhook itself never marks a + * refund completed, writes a ledger entry, or persists raw provider data. + */ + async handleMonnifyWebhook(input: { + rawBody: Buffer | string | undefined; + signature: string | string[] | undefined; + payload: unknown; + }): Promise<{ status: "accepted" | "ignored" }> { + if ( + !input.rawBody || + !this.monnify.verifyWebhookSignature(input.rawBody, input.signature) + ) { + return { status: "ignored" }; + } + + const event = this.monnify.parseWebhookEvent(input.payload); + if (!event) return { status: "ignored" }; + + await this.prisma.disputeSettlementLeg.updateMany({ + where: { + type: DisputeSettlementLegType.BUYER_REFUND, + provider: "monnify", + providerOperationId: event.providerRefundId, + status: { + in: [ + DisputeSettlementLegStatus.SUBMITTED, + DisputeSettlementLegStatus.RECONCILIATION_REQUIRED, + ], + }, + }, + data: { nextReconcileAt: new Date() }, + }); + + await this.prisma.disputeSettlementRefundOperation.updateMany({ + where: { + provider: "MONNIFY", + providerRefundId: event.providerRefundId, + status: { + in: [ + DisputeSettlementRefundOperationStatus.SUBMITTED, + DisputeSettlementRefundOperationStatus.RECONCILIATION_REQUIRED, + ], + }, + }, + data: { nextReconcileAt: new Date() }, + }); + + // A verified webhook only makes the operation eligible for the guarded + // server-side reconciliation path. It never completes a refund directly. + await this.prisma.paymentAmountExceptionRefund.updateMany({ + where: { + provider: "MONNIFY", + providerRefundId: event.providerRefundId, + status: { in: ["SUBMITTED", "RECONCILIATION_REQUIRED"] }, + }, + data: { nextReconcileAt: new Date() }, + }); + + return { status: "accepted" }; + } +} diff --git a/apps/backend/src/domains/money/refund/refund.controller.spec.ts b/apps/backend/src/domains/money/refund/refund.controller.spec.ts new file mode 100644 index 00000000..1fee2d36 --- /dev/null +++ b/apps/backend/src/domains/money/refund/refund.controller.spec.ts @@ -0,0 +1,42 @@ +import { BadRequestException } from "@nestjs/common"; + +import { RefundController } from "./refund.controller"; + +describe("RefundController", () => { + const webhookService = { handleMonnifyWebhook: jest.fn() }; + const controller = new RefundController(webhookService as never); + + beforeEach(() => jest.clearAllMocks()); + + it("rejects a webhook when raw-body capture is unavailable", async () => { + await expect( + controller.monnifyWebhook({ + rawBody: undefined, + headers: {}, + body: {}, + } as never), + ).rejects.toBeInstanceOf(BadRequestException); + expect(webhookService.handleMonnifyWebhook).not.toHaveBeenCalled(); + }); + + it("passes captured raw data to the verified webhook service", async () => { + webhookService.handleMonnifyWebhook.mockResolvedValue({ + status: "accepted", + }); + const rawBody = Buffer.from("{}"); + + await expect( + controller.monnifyWebhook({ + rawBody, + headers: { "monnify-signature": "signature" }, + body: { eventType: "SUCCESSFUL_REFUND" }, + } as never), + ).resolves.toEqual({ status: "accepted" }); + + expect(webhookService.handleMonnifyWebhook).toHaveBeenCalledWith({ + rawBody, + signature: "signature", + payload: { eventType: "SUCCESSFUL_REFUND" }, + }); + }); +}); diff --git a/apps/backend/src/domains/money/refund/refund.controller.ts b/apps/backend/src/domains/money/refund/refund.controller.ts new file mode 100644 index 00000000..c6d9b371 --- /dev/null +++ b/apps/backend/src/domains/money/refund/refund.controller.ts @@ -0,0 +1,34 @@ +import { + BadRequestException, + Controller, + HttpCode, + Post, + Req, +} from "@nestjs/common"; +import type { RawBodyRequest } from "@nestjs/common"; +import { SkipThrottle } from "@nestjs/throttler"; +import type { Request } from "express"; + +import { RefundWebhookService } from "./refund-webhook.service"; + +@Controller("refunds") +export class RefundController { + constructor(private readonly webhookService: RefundWebhookService) {} + + @Post("webhook/monnify") + @HttpCode(200) + @SkipThrottle() + async monnifyWebhook(@Req() req: RawBodyRequest) { + if (!req.rawBody) { + throw new BadRequestException( + "Missing raw body for webhook verification", + ); + } + + return this.webhookService.handleMonnifyWebhook({ + rawBody: req.rawBody, + signature: req.headers["monnify-signature"], + payload: req.body, + }); + } +} diff --git a/apps/backend/src/domains/money/refund/refund.module.ts b/apps/backend/src/domains/money/refund/refund.module.ts new file mode 100644 index 00000000..ca514b87 --- /dev/null +++ b/apps/backend/src/domains/money/refund/refund.module.ts @@ -0,0 +1,46 @@ +import { Module } from "@nestjs/common"; + +import { MonnifyModule } from "../../../integrations/monnify/monnify.module"; +import { MonnifyRefundProvider } from "../../../integrations/monnify/monnify-refund.provider"; +import { PaystackModule } from "../../../integrations/paystack/paystack.module"; +import { PrismaModule } from "../../../prisma/prisma.module"; +import { LedgerModule } from "../ledger/ledger.module"; +import { OutboxModule } from "../../platform/outbox/outbox.module"; +import { PaymentAmountExceptionRefundOutboxHandler } from "./payment-amount-exception-refund.outbox-handler"; +import { PaymentAmountExceptionRefundReconciliationService } from "./payment-amount-exception-refund-reconciliation.service"; +import { PaymentAmountExceptionRefundService } from "./payment-amount-exception-refund.service"; +import { PaystackRefundProvider } from "./paystack-refund.provider"; +import { RefundController } from "./refund.controller"; +import { + createRefundProviderRegistry, + REFUND_PROVIDER_REGISTRY, +} from "./refund-provider.registry"; +import { RefundWebhookService } from "./refund-webhook.service"; + +@Module({ + imports: [ + PrismaModule, + PaystackModule, + MonnifyModule, + LedgerModule, + OutboxModule, + ], + controllers: [RefundController], + providers: [ + PaystackRefundProvider, + RefundWebhookService, + PaymentAmountExceptionRefundService, + PaymentAmountExceptionRefundOutboxHandler, + PaymentAmountExceptionRefundReconciliationService, + { + provide: REFUND_PROVIDER_REGISTRY, + useFactory: ( + paystack: PaystackRefundProvider, + monnify: MonnifyRefundProvider, + ) => createRefundProviderRegistry({ paystack, monnify }), + inject: [PaystackRefundProvider, MonnifyRefundProvider], + }, + ], + exports: [REFUND_PROVIDER_REGISTRY, PaymentAmountExceptionRefundService], +}) +export class RefundModule {} diff --git a/apps/backend/src/domains/money/settlement/settlement-amounts.spec.ts b/apps/backend/src/domains/money/settlement/settlement-amounts.spec.ts new file mode 100644 index 00000000..0662548d --- /dev/null +++ b/apps/backend/src/domains/money/settlement/settlement-amounts.spec.ts @@ -0,0 +1,123 @@ +import { BadRequestException } from "@nestjs/common"; +import { DisputeResolutionOutcome } from "@prisma/client"; + +import { + computeSettlementAmounts, + parseKoboString, +} from "./settlement-amounts"; + +describe("computeSettlementAmounts", () => { + const base = { + availableCapturedKobo: 100_000n, + normalMerchantPayoutKobo: 90_000n, + }; + + it("full shopper win refunds all available captured funds", () => { + const result = computeSettlementAmounts({ + ...base, + outcome: DisputeResolutionOutcome.BUYER_WINS, + }); + expect(result).toEqual({ + buyerRefundKobo: 100_000n, + merchantPayoutKobo: 0n, + platformRetainedKobo: 0n, + }); + }); + + it("store win pays the normal merchant payout and retains the residual", () => { + const result = computeSettlementAmounts({ + ...base, + outcome: DisputeResolutionOutcome.SELLER_WINS, + }); + expect(result).toEqual({ + buyerRefundKobo: 0n, + merchantPayoutKobo: 90_000n, + platformRetainedKobo: 10_000n, + }); + }); + + it("store win clamps the payout to available captured funds", () => { + const result = computeSettlementAmounts({ + outcome: DisputeResolutionOutcome.SELLER_WINS, + availableCapturedKobo: 50_000n, + normalMerchantPayoutKobo: 90_000n, + }); + expect(result.merchantPayoutKobo).toBe(50_000n); + expect(result.platformRetainedKobo).toBe(0n); + }); + + it("partial split requires both amounts and computes retained residual", () => { + const result = computeSettlementAmounts({ + ...base, + outcome: DisputeResolutionOutcome.PARTIAL, + buyerRefundAmountKobo: 40_000n, + merchantPayoutAmountKobo: 50_000n, + }); + expect(result).toEqual({ + buyerRefundKobo: 40_000n, + merchantPayoutKobo: 50_000n, + platformRetainedKobo: 10_000n, + }); + }); + + it("rejects a zero-value partial split", () => { + expect(() => + computeSettlementAmounts({ + ...base, + outcome: DisputeResolutionOutcome.PARTIAL, + buyerRefundAmountKobo: 0n, + merchantPayoutAmountKobo: 90_000n, + }), + ).toThrow(BadRequestException); + }); + + it("rejects a partial split that exceeds available captured funds", () => { + expect(() => + computeSettlementAmounts({ + ...base, + outcome: DisputeResolutionOutcome.PARTIAL, + buyerRefundAmountKobo: 60_000n, + merchantPayoutAmountKobo: 60_000n, + }), + ).toThrow(BadRequestException); + }); + + it("rejects a partial split with missing amounts", () => { + expect(() => + computeSettlementAmounts({ + ...base, + outcome: DisputeResolutionOutcome.PARTIAL, + buyerRefundAmountKobo: 40_000n, + }), + ).toThrow(BadRequestException); + }); + + it("rejects settlement when no captured funds are available", () => { + expect(() => + computeSettlementAmounts({ + outcome: DisputeResolutionOutcome.BUYER_WINS, + availableCapturedKobo: 0n, + normalMerchantPayoutKobo: 0n, + }), + ).toThrow(BadRequestException); + }); +}); + +describe("parseKoboString", () => { + it("parses an integer kobo string to BigInt", () => { + expect(parseKoboString("2450000")).toBe(2_450_000n); + }); + + it("returns null for empty/undefined", () => { + expect(parseKoboString(undefined)).toBeNull(); + expect(parseKoboString("")).toBeNull(); + }); + + it("rejects decimal naira values passed as floats", () => { + expect(() => parseKoboString("245.50")).toThrow(BadRequestException); + }); + + it("rejects negative amounts", () => { + expect(() => parseKoboString("-100")).toThrow(BadRequestException); + }); +}); diff --git a/apps/backend/src/domains/money/settlement/settlement-amounts.ts b/apps/backend/src/domains/money/settlement/settlement-amounts.ts new file mode 100644 index 00000000..2bbab2da --- /dev/null +++ b/apps/backend/src/domains/money/settlement/settlement-amounts.ts @@ -0,0 +1,124 @@ +import { BadRequestException } from "@nestjs/common"; +import { DisputeResolutionOutcome } from "@prisma/client"; + +export interface SettlementAmountsInput { + outcome: DisputeResolutionOutcome; + // Captured funds still available to settle: the successful payment amount + // minus any already-confirmed refunds and confirmed merchant payouts. + availableCapturedKobo: bigint; + // The merchant's normal payout for this order (delivery + platform fee + + // wholesale already removed), clamped to >= 0. Used for a full seller win. + normalMerchantPayoutKobo: bigint; + // Explicit amounts, required for a PARTIAL outcome. + buyerRefundAmountKobo?: bigint | null; + merchantPayoutAmountKobo?: bigint | null; +} + +export interface SettlementAmounts { + buyerRefundKobo: bigint; + merchantPayoutKobo: bigint; + platformRetainedKobo: bigint; +} + +const bad = (message: string, code: string): never => { + throw new BadRequestException({ message, code }); +}; + +/** + * Pure settlement amount rules (Phase 5 §3). Never uses floats; every value is + * BigInt kobo. The platform-retained amount is always the residual so the three + * legs plus retention reconcile exactly to the available captured funds. + */ +export function computeSettlementAmounts( + input: SettlementAmountsInput, +): SettlementAmounts { + const available = input.availableCapturedKobo; + + if (available <= 0n) { + return bad( + "No captured funds are available to settle for this order", + "SETTLEMENT_NO_AVAILABLE_FUNDS", + ); + } + + if (input.outcome === DisputeResolutionOutcome.BUYER_WINS) { + // Full shopper win: return the whole available captured amount. + return { + buyerRefundKobo: available, + merchantPayoutKobo: 0n, + platformRetainedKobo: 0n, + }; + } + + if (input.outcome === DisputeResolutionOutcome.SELLER_WINS) { + const payout = + input.normalMerchantPayoutKobo > available + ? available + : input.normalMerchantPayoutKobo; + if (payout <= 0n) { + return bad( + "Merchant payout amount must be greater than zero", + "SETTLEMENT_INVALID_AMOUNT", + ); + } + return { + buyerRefundKobo: 0n, + merchantPayoutKobo: payout, + platformRetainedKobo: available - payout, + }; + } + + // PARTIAL — both amounts are explicit and both must be positive. + const refund = input.buyerRefundAmountKobo ?? null; + const payout = input.merchantPayoutAmountKobo ?? null; + + if (refund === null || payout === null) { + return bad( + "A partial resolution requires explicit buyer refund and merchant payout amounts", + "SETTLEMENT_PARTIAL_AMOUNTS_REQUIRED", + ); + } + if (refund < 0n || payout < 0n) { + return bad( + "Settlement amounts must not be negative", + "SETTLEMENT_INVALID_AMOUNT", + ); + } + if (refund === 0n || payout === 0n) { + // A zero-value split is not a partial split — the operator should use the + // corresponding full outcome instead. + return bad( + "A partial split requires both a positive refund and a positive payout", + "SETTLEMENT_INVALID_PARTIAL_SPLIT", + ); + } + if (refund + payout > available) { + return bad( + "Refund plus merchant payout exceeds the available captured funds", + "SETTLEMENT_AMOUNT_EXCEEDS_AVAILABLE", + ); + } + + return { + buyerRefundKobo: refund, + merchantPayoutKobo: payout, + platformRetainedKobo: available - refund - payout, + }; +} + +/** + * Parses a kobo integer-string (already regex-validated at the DTO) into a + * BigInt, rejecting anything that is not a non-negative integer. + */ +export function parseKoboString( + value: string | null | undefined, +): bigint | null { + if (value === null || value === undefined || value === "") return null; + if (!/^\d{1,19}$/.test(value)) { + throw new BadRequestException({ + message: "Amount must be a non-negative kobo integer string", + code: "SETTLEMENT_INVALID_AMOUNT", + }); + } + return BigInt(value); +} diff --git a/apps/backend/src/domains/money/settlement/settlement-execute.outbox-handler.ts b/apps/backend/src/domains/money/settlement/settlement-execute.outbox-handler.ts new file mode 100644 index 00000000..bab1047f --- /dev/null +++ b/apps/backend/src/domains/money/settlement/settlement-execute.outbox-handler.ts @@ -0,0 +1,50 @@ +import { Injectable, OnModuleInit } from "@nestjs/common"; + +import { NonRetryableOutboxError } from "../../platform/outbox/outbox.errors"; +import { OutboxHandlerRegistry } from "../../platform/outbox/outbox-handler.registry"; +import { + OUTBOX_TOPIC, + type ClaimedOutboxEvent, + type DisputeSettlementExecuteRequestedV1, + type OutboxHandler, +} from "../../platform/outbox/outbox.types"; +import { SettlementExecutionService } from "./settlement-execution.service"; + +/** + * Outbox handler for dispute.settlement.execute.requested. It only delegates to + * the settlement application service (no business rules here). Self-registers + * with the registry on init so OutboxModule does not need to import the money + * domain (avoids a module dependency cycle). + */ +@Injectable() +export class SettlementExecuteOutboxHandler + implements OutboxHandler, OnModuleInit +{ + readonly topic = OUTBOX_TOPIC.DISPUTE_SETTLEMENT_EXECUTE_REQUESTED; + readonly version = 1; + + constructor( + private readonly execution: SettlementExecutionService, + private readonly registry: OutboxHandlerRegistry, + ) {} + + onModuleInit(): void { + this.registry.register(this); + } + + async handle( + event: ClaimedOutboxEvent, + ): Promise { + const payload = event.payload as { settlementId?: unknown } | null; + if ( + !payload || + typeof payload.settlementId !== "string" || + payload.settlementId.trim().length === 0 + ) { + throw new NonRetryableOutboxError( + "Invalid dispute settlement execute payload", + ); + } + await this.execution.executeSettlement(payload.settlementId); + } +} diff --git a/apps/backend/src/domains/money/settlement/settlement-execution.service.spec.ts b/apps/backend/src/domains/money/settlement/settlement-execution.service.spec.ts new file mode 100644 index 00000000..cd366629 --- /dev/null +++ b/apps/backend/src/domains/money/settlement/settlement-execution.service.spec.ts @@ -0,0 +1,95 @@ +import { SettlementExecutionService } from "./settlement-execution.service"; + +// Builds a service with a minimal prisma double whose settlement-leg findMany +// returns the given leg statuses, capturing the rolled-up settlement status. +function makeService(legStatuses: string[]) { + const updates: Array<{ status: string; completedAt?: Date }> = []; + const prisma = { + $queryRaw: jest.fn().mockResolvedValue([{ id: "s1" }]), + disputeSettlementLeg: { + findMany: jest + .fn() + .mockResolvedValue(legStatuses.map((status) => ({ status }))), + }, + disputeSettlement: { + findUnique: jest.fn().mockResolvedValue({ + status: "PROCESSING", + completedAt: null, + }), + update: jest.fn( + (args: { data: { status: string; completedAt?: Date } }) => { + updates.push(args.data); + return Promise.resolve({ orderId: "order-1" }); + }, + ), + }, + order: { updateMany: jest.fn().mockResolvedValue({ count: 1 }) }, + }; + const transactionalPrisma = prisma as typeof prisma & { + $transaction: ( + callback: (tx: typeof prisma) => Promise, + ) => Promise; + }; + transactionalPrisma.$transaction = jest.fn((callback) => callback(prisma)); + const service = new SettlementExecutionService( + transactionalPrisma as never, + { + getForPaymentProvider: jest.fn(), + getByKey: jest.fn(), + } as never, + {} as never, + {} as never, + { + appendSettlementRealtimeEvents: jest.fn().mockResolvedValue(undefined), + } as never, + ); + return { service, updates, prisma }; +} + +describe("SettlementExecutionService.rollUpSettlement", () => { + it("marks COMPLETED only when every leg is COMPLETED", async () => { + const { service, updates, prisma } = makeService([ + "COMPLETED", + "COMPLETED", + ]); + await service.rollUpSettlement("s1"); + expect(updates[0].status).toBe("COMPLETED"); + expect(updates[0].completedAt).toBeInstanceOf(Date); + expect(prisma.order.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ data: { status: "COMPLETED" } }), + ); + }); + + it("marks PARTIALLY_COMPLETED when one leg completes and another is in flight", async () => { + const { service, updates } = makeService(["COMPLETED", "SUBMITTED"]); + await service.rollUpSettlement("s1"); + expect(updates[0].status).toBe("PARTIALLY_COMPLETED"); + }); + + it("propagates RECONCILIATION_REQUIRED up from a leg", async () => { + const { service, updates } = makeService([ + "COMPLETED", + "RECONCILIATION_REQUIRED", + ]); + await service.rollUpSettlement("s1"); + expect(updates[0].status).toBe("RECONCILIATION_REQUIRED"); + }); + + it("propagates MANUAL_REVIEW up from a leg", async () => { + const { service, updates } = makeService(["MANUAL_REVIEW", "PENDING"]); + await service.rollUpSettlement("s1"); + expect(updates[0].status).toBe("MANUAL_REVIEW"); + }); + + it("stays PROCESSING while legs are still pending with none complete", async () => { + const { service, updates } = makeService(["PROCESSING", "PENDING"]); + await service.rollUpSettlement("s1"); + expect(updates).toHaveLength(0); + }); + + it("marks FAILED when the only leg failed and nothing is in flight", async () => { + const { service, updates } = makeService(["FAILED"]); + await service.rollUpSettlement("s1"); + expect(updates[0].status).toBe("FAILED"); + }); +}); diff --git a/apps/backend/src/domains/money/settlement/settlement-execution.service.ts b/apps/backend/src/domains/money/settlement/settlement-execution.service.ts new file mode 100644 index 00000000..944ca868 --- /dev/null +++ b/apps/backend/src/domains/money/settlement/settlement-execution.service.ts @@ -0,0 +1,1109 @@ +import { + BadRequestException, + Inject, + Injectable, + Logger, +} from "@nestjs/common"; +import { + DisputeSettlementLegStatus, + DisputeSettlementLegType, + DisputeSettlementRefundOperationStatus, + DisputeSettlementStatus, + LedgerDirection, + LedgerEntryType, + OrderStatus, + PaymentStatus, + PaymentProviderName, + Prisma, +} from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { SettlementConfig } from "../../../config/settlement.config"; +import { LedgerService } from "../ledger/ledger.service"; +import { PayoutService } from "../payout/payout.service"; +import { CommerceOutboxService } from "../../platform/outbox/commerce-outbox.service"; +import { + REFUND_PROVIDER_REGISTRY, + type RefundProviderRegistry, +} from "../refund/refund-provider.registry"; + +// First reconciliation poll delay for a submitted-but-unconfirmed refund leg. +const RECONCILE_DELAY_MS = 2 * 60 * 1000; + +type LegRow = Prisma.DisputeSettlementLegGetPayload; +type RefundOperationRow = + Prisma.DisputeSettlementRefundOperationGetPayload; + +@Injectable() +export class SettlementExecutionService { + private readonly logger = new Logger(SettlementExecutionService.name); + + constructor( + private readonly prisma: PrismaService, + @Inject(REFUND_PROVIDER_REGISTRY) + private readonly refundProviders: RefundProviderRegistry, + private readonly ledger: LedgerService, + private readonly payouts: PayoutService, + private readonly commerceOutbox: CommerceOutboxService, + ) {} + + /** + * Executes a settlement plan (§8, §10). Called by the outbox handler; contains + * the money-movement orchestration but never business/amount rules (those ran + * at planning). Idempotent: legs are claimed atomically and each provider + * operation is keyed, so a duplicate delivery moves money at most once. + */ + async executeSettlement(settlementId: string): Promise { + const settlement = await this.prisma.disputeSettlement.findUnique({ + where: { id: settlementId }, + include: { legs: true }, + }); + if (!settlement) { + this.logger.warn(`Settlement ${settlementId} not found; skipping`); + return; + } + if ( + settlement.status !== DisputeSettlementStatus.PENDING && + settlement.status !== DisputeSettlementStatus.PROCESSING + ) { + // COMPLETED / MANUAL_REVIEW / FAILED etc. — nothing to execute. + return; + } + + if (settlement.status === DisputeSettlementStatus.PENDING) { + await this.prisma.disputeSettlement.updateMany({ + where: { id: settlementId, status: DisputeSettlementStatus.PENDING }, + data: { + status: DisputeSettlementStatus.PROCESSING, + startedAt: new Date(), + }, + }); + } + + for (const leg of settlement.legs) { + if ( + leg.status === DisputeSettlementLegStatus.PENDING || + leg.status === DisputeSettlementLegStatus.FAILED + ) { + await this.executeLeg(settlement.orderId, leg); + } + } + + await this.rollUpSettlement(settlementId); + } + + private async executeLeg(orderId: string, leg: LegRow): Promise { + // Atomically claim the leg: PENDING/FAILED -> PROCESSING, attempts + 1. + const claim = await this.prisma.disputeSettlementLeg.updateMany({ + where: { + id: leg.id, + status: { + in: [ + DisputeSettlementLegStatus.PENDING, + DisputeSettlementLegStatus.FAILED, + ], + }, + }, + data: { + status: DisputeSettlementLegStatus.PROCESSING, + attempts: { increment: 1 }, + }, + }); + if (claim.count !== 1) { + // Another worker already claimed it. + return; + } + + if (leg.type === DisputeSettlementLegType.BUYER_REFUND) { + await this.executeRefundLeg(orderId, leg); + } else { + await this.executePayoutLeg(orderId, leg); + } + } + + private async executeRefundLeg(orderId: string, leg: LegRow): Promise { + const refundOperations = + await this.prisma.disputeSettlementRefundOperation.findMany({ + where: { settlementLegId: leg.id }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }); + if (refundOperations.length > 0) { + await this.executeRefundOperations(orderId, leg, refundOperations); + return; + } + + // Rolling-deploy compatibility for settlements planned before per- + // collection refund operations existed. + const payment = await this.prisma.payment.findFirst({ + where: { orderId, status: PaymentStatus.SUCCESS }, + orderBy: { verifiedAt: "desc" }, + select: { + provider: true, + providerReference: true, + providerTransactionReference: true, + }, + }); + if (!payment) { + await this.recordLegState( + leg.id, + DisputeSettlementLegStatus.RECONCILIATION_REQUIRED, + { + lastError: "No successful payment reference to refund against", + }, + ); + return; + } + + let refundProvider; + try { + refundProvider = this.refundProviders.getForPaymentProvider( + payment.provider, + ); + } catch { + await this.recordLegState( + leg.id, + DisputeSettlementLegStatus.MANUAL_REVIEW, + { + lastError: + "Refund provider for this payment is not registered for automated settlement", + }, + ); + return; + } + + if (!refundProvider.supportsNewRefunds) { + await this.recordLegState( + leg.id, + DisputeSettlementLegStatus.MANUAL_REVIEW, + { + lastError: + "Refund provider is not enabled for new automated settlement refunds", + }, + ); + return; + } + + const transactionReference = + payment.provider === "MONNIFY" + ? payment.providerTransactionReference + : payment.providerReference; + if (!transactionReference) { + await this.recordLegState( + leg.id, + DisputeSettlementLegStatus.MANUAL_REVIEW, + { + lastError: + "Original payment has no provider transaction reference for a safe automated refund", + }, + ); + return; + } + + let result; + try { + result = await refundProvider.createRefund({ + transactionReference, + refundReference: `twz-refund-${leg.id}`, + amountKobo: BigInt(leg.amountKobo), + currency: "NGN", + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // A provider exception, including a connection failure before we receive + // a response, cannot prove the provider did not accept the refund. Never + // reset this leg to PENDING and issue another refund request. + await this.recordLegState( + leg.id, + DisputeSettlementLegStatus.RECONCILIATION_REQUIRED, + { lastError: message }, + ); + return; + } + + if (result.provider !== refundProvider.key) { + await this.recordLegState( + leg.id, + DisputeSettlementLegStatus.RECONCILIATION_REQUIRED, + { + provider: refundProvider.key, + providerOperationId: result.providerRefundId, + lastError: "Refund provider returned a mismatched provider identity", + }, + ); + return; + } + + if (result.status === "COMPLETED") { + if (result.amountKobo !== BigInt(leg.amountKobo)) { + await this.recordLegState( + leg.id, + DisputeSettlementLegStatus.RECONCILIATION_REQUIRED, + { + provider: refundProvider.key, + providerOperationId: result.providerRefundId, + lastError: + "Provider confirmed a refund amount that differs from the settlement leg", + }, + ); + await this.rollUpSettlementByLeg(leg.id); + return; + } + await this.confirmRefundLeg( + orderId, + leg, + result.providerRefundId, + refundProvider.key, + ); + return; + } + if (result.status === "FAILED") { + await this.recordLegState(leg.id, DisputeSettlementLegStatus.FAILED, { + lastError: "Provider reported the refund as failed", + provider: refundProvider.key, + providerOperationId: result.providerRefundId, + }); + return; + } + if (result.status === "NEEDS_ATTENTION") { + await this.recordLegState( + leg.id, + DisputeSettlementLegStatus.RECONCILIATION_REQUIRED, + { + provider: refundProvider.key, + providerOperationId: result.providerRefundId, + }, + ); + return; + } + + // PENDING / PROCESSING → SUBMITTED. Provider acceptance is NOT completion: + // no REFUND_COMPLETED ledger entry is written yet. Hold the order in + // REFUND_PENDING and schedule reconciliation. + await this.recordLegState(leg.id, DisputeSettlementLegStatus.SUBMITTED, { + provider: refundProvider.key, + providerOperationId: result.providerRefundId, + submittedAt: new Date(), + nextReconcileAt: new Date(Date.now() + RECONCILE_DELAY_MS), + }); + await this.prisma.order.updateMany({ + where: { id: orderId, status: OrderStatus.DISPUTE }, + data: { status: OrderStatus.REFUND_PENDING }, + }); + } + + private async executeRefundOperations( + orderId: string, + leg: LegRow, + operations: RefundOperationRow[], + ): Promise { + for (const operation of operations) { + if ( + operation.status === DisputeSettlementRefundOperationStatus.PENDING || + operation.status === DisputeSettlementRefundOperationStatus.FAILED + ) { + await this.executeRefundOperation(operation); + } + } + await this.rollUpRefundOperations(orderId, leg.id); + } + + private async executeRefundOperation( + operation: RefundOperationRow, + ): Promise { + const claim = await this.prisma.disputeSettlementRefundOperation.updateMany( + { + where: { + id: operation.id, + status: { + in: [ + DisputeSettlementRefundOperationStatus.PENDING, + DisputeSettlementRefundOperationStatus.FAILED, + ], + }, + }, + data: { + status: DisputeSettlementRefundOperationStatus.PROCESSING, + attempts: { increment: 1 }, + failureSummary: null, + }, + }, + ); + if (claim.count !== 1) return; + + let provider; + try { + provider = this.refundProviders.getForPaymentProvider(operation.provider); + } catch { + await this.recordRefundOperationState( + operation.id, + DisputeSettlementRefundOperationStatus.MANUAL_REVIEW, + { + failureSummary: + "Refund provider for this collection is not registered", + }, + ); + return; + } + if (!provider.supportsNewRefunds) { + await this.recordRefundOperationState( + operation.id, + DisputeSettlementRefundOperationStatus.MANUAL_REVIEW, + { + failureSummary: + "Refund provider is not enabled for new automated refunds", + }, + ); + return; + } + + let result; + try { + result = await provider.createRefund({ + transactionReference: operation.transactionReference, + refundReference: `twz-refund-operation-${operation.id}`, + amountKobo: BigInt(operation.amountKobo), + currency: "NGN", + }); + } catch { + await this.recordRefundOperationState( + operation.id, + DisputeSettlementRefundOperationStatus.RECONCILIATION_REQUIRED, + { + failureSummary: + "Refund provider outcome is unknown; reconciliation is required", + }, + ); + return; + } + + if ( + result.provider !== provider.key || + result.amountKobo !== BigInt(operation.amountKobo) + ) { + await this.recordRefundOperationState( + operation.id, + DisputeSettlementRefundOperationStatus.RECONCILIATION_REQUIRED, + { + providerRefundId: result.providerRefundId, + failureSummary: + "Provider returned refund evidence that does not match the approved operation", + }, + ); + return; + } + + if (result.status === "COMPLETED") { + await this.confirmRefundOperation( + operation.id, + result.providerRefundId, + result.amountKobo, + ); + return; + } + if (result.status === "FAILED") { + await this.recordRefundOperationState( + operation.id, + DisputeSettlementRefundOperationStatus.FAILED, + { + providerRefundId: result.providerRefundId, + failureSummary: "Provider rejected the refund operation", + }, + ); + return; + } + if (result.status === "NEEDS_ATTENTION") { + await this.recordRefundOperationState( + operation.id, + DisputeSettlementRefundOperationStatus.RECONCILIATION_REQUIRED, + { providerRefundId: result.providerRefundId }, + ); + return; + } + + await this.recordRefundOperationState( + operation.id, + DisputeSettlementRefundOperationStatus.SUBMITTED, + { + providerRefundId: result.providerRefundId, + submittedAt: new Date(), + nextReconcileAt: new Date(Date.now() + RECONCILE_DELAY_MS), + }, + ); + } + + async confirmRefundOperation( + operationId: string, + providerRefundId: string, + confirmedAmountKobo: bigint, + ): Promise { + await this.prisma.$transaction(async (tx) => { + const operation = await tx.disputeSettlementRefundOperation.findUnique({ + where: { id: operationId }, + select: { amountKobo: true, status: true }, + }); + if (!operation) return; + if (BigInt(operation.amountKobo) !== confirmedAmountKobo) { + await tx.disputeSettlementRefundOperation.updateMany({ + where: { + id: operationId, + status: { + in: [ + DisputeSettlementRefundOperationStatus.PROCESSING, + DisputeSettlementRefundOperationStatus.SUBMITTED, + DisputeSettlementRefundOperationStatus.RECONCILIATION_REQUIRED, + ], + }, + }, + data: { + status: + DisputeSettlementRefundOperationStatus.RECONCILIATION_REQUIRED, + providerRefundId, + failureSummary: + "Provider confirmed an amount different from the approved refund operation", + }, + }); + return; + } + await tx.disputeSettlementRefundOperation.updateMany({ + where: { + id: operationId, + status: { + in: [ + DisputeSettlementRefundOperationStatus.PROCESSING, + DisputeSettlementRefundOperationStatus.SUBMITTED, + DisputeSettlementRefundOperationStatus.RECONCILIATION_REQUIRED, + ], + }, + }, + data: { + status: DisputeSettlementRefundOperationStatus.COMPLETED, + providerRefundId, + completedAt: new Date(), + nextReconcileAt: null, + failureSummary: null, + }, + }); + }); + } + + async recordRefundOperationState( + operationId: string, + status: DisputeSettlementRefundOperationStatus, + extra: Partial< + Pick< + RefundOperationRow, + | "providerRefundId" + | "failureSummary" + | "submittedAt" + | "completedAt" + | "nextReconcileAt" + > + > = {}, + expectedStatuses?: DisputeSettlementRefundOperationStatus[], + ): Promise { + await this.prisma.disputeSettlementRefundOperation.updateMany({ + where: { + id: operationId, + status: expectedStatuses ? { in: expectedStatuses } : { not: status }, + }, + data: { status, ...extra }, + }); + } + + async rollUpRefundOperations(orderId: string, legId: string): Promise { + const leg = await this.prisma.disputeSettlementLeg.findUnique({ + where: { id: legId }, + include: { + refundOperations: { + select: { status: true, provider: true }, + }, + }, + }); + if (!leg || leg.refundOperations.length === 0) return; + + const statuses = leg.refundOperations.map((operation) => operation.status); + if ( + statuses.every( + (status) => status === DisputeSettlementRefundOperationStatus.COMPLETED, + ) + ) { + const provider = leg.refundOperations[0]?.provider; + await this.confirmRefundLeg( + orderId, + leg, + null, + provider === PaymentProviderName.MONNIFY ? "monnify" : "paystack", + ); + return; + } + + let next: DisputeSettlementLegStatus; + if ( + statuses.some( + (status) => + status === DisputeSettlementRefundOperationStatus.MANUAL_REVIEW, + ) + ) { + next = DisputeSettlementLegStatus.MANUAL_REVIEW; + } else if ( + statuses.some( + (status) => + status === + DisputeSettlementRefundOperationStatus.RECONCILIATION_REQUIRED, + ) + ) { + next = DisputeSettlementLegStatus.RECONCILIATION_REQUIRED; + } else if ( + statuses.some( + (status) => status === DisputeSettlementRefundOperationStatus.SUBMITTED, + ) + ) { + next = DisputeSettlementLegStatus.SUBMITTED; + } else if ( + statuses.every( + (status) => status === DisputeSettlementRefundOperationStatus.FAILED, + ) || + (statuses.some( + (status) => status === DisputeSettlementRefundOperationStatus.FAILED, + ) && + !statuses.some( + (status) => + status === DisputeSettlementRefundOperationStatus.PENDING || + status === DisputeSettlementRefundOperationStatus.PROCESSING, + )) + ) { + next = DisputeSettlementLegStatus.FAILED; + } else { + next = DisputeSettlementLegStatus.PROCESSING; + } + + await this.recordLegState(legId, next, {}, [ + DisputeSettlementLegStatus.PROCESSING, + DisputeSettlementLegStatus.SUBMITTED, + DisputeSettlementLegStatus.FAILED, + DisputeSettlementLegStatus.RECONCILIATION_REQUIRED, + ]); + if (next === DisputeSettlementLegStatus.SUBMITTED) { + await this.prisma.order.updateMany({ + where: { id: orderId, status: OrderStatus.DISPUTE }, + data: { status: OrderStatus.REFUND_PENDING }, + }); + } + } + + private async executePayoutLeg(orderId: string, leg: LegRow): Promise { + const settlement = await this.prisma.disputeSettlement.findFirst({ + where: { legs: { some: { id: leg.id } } }, + select: { + orderId: true, + createdBy: true, + order: { select: { storeId: true } }, + }, + }); + const storeId = settlement?.order?.storeId; + if (!storeId) { + await this.recordLegState( + leg.id, + DisputeSettlementLegStatus.RECONCILIATION_REQUIRED, + { + lastError: "Order has no store for payout", + }, + ); + return; + } + + const outcome = await this.payouts.executeSettlementPayout({ + legId: leg.id, + orderId, + storeId, + amountKobo: BigInt(leg.amountKobo), + approvedBy: settlement?.createdBy ?? "system", + }); + + if (outcome.status === "COMPLETED") { + await this.recordLegState(leg.id, DisputeSettlementLegStatus.COMPLETED, { + provider: outcome.provider ?? undefined, + providerReference: outcome.providerReference, + providerOperationId: outcome.providerOperationId, + completedAt: new Date(), + }); + } else if (outcome.status === "SUBMITTED") { + await this.recordLegState(leg.id, DisputeSettlementLegStatus.SUBMITTED, { + provider: outcome.provider ?? undefined, + providerReference: outcome.providerReference, + providerOperationId: outcome.providerOperationId, + submittedAt: new Date(), + nextReconcileAt: new Date(Date.now() + RECONCILE_DELAY_MS), + }); + } else { + await this.recordLegState(leg.id, DisputeSettlementLegStatus.FAILED, { + lastError: outcome.error ?? "Settlement payout failed", + }); + } + } + + /** + * Confirms a refund leg on verified provider completion. One transaction: + * mark the leg COMPLETED, write the REFUND_COMPLETED ledger entry (keyed to + * the leg so a duplicate event writes nothing), and update payment/order + * status. Shared by immediate completion and reconciliation. + */ + async confirmRefundLeg( + orderId: string, + leg: LegRow, + providerRefundId: string | null, + provider: string, + confirmedAmountKobo?: bigint, + ): Promise { + if ( + confirmedAmountKobo !== undefined && + confirmedAmountKobo !== BigInt(leg.amountKobo) + ) { + await this.recordLegState( + leg.id, + DisputeSettlementLegStatus.RECONCILIATION_REQUIRED, + { + provider, + providerOperationId: providerRefundId, + lastError: + "Provider confirmed a refund amount that differs from the settlement leg", + }, + ); + await this.rollUpSettlementByLeg(leg.id); + return; + } + const capturedPayment = await this.prisma.payment.findFirst({ + where: { + orderId, + status: { + in: [PaymentStatus.SUCCESS, PaymentStatus.PARTIALLY_REFUNDED], + }, + }, + orderBy: { verifiedAt: "desc" }, + select: { id: true, amountKobo: true }, + }); + + await this.prisma.$transaction(async (tx) => { + const claim = await tx.disputeSettlementLeg.updateMany({ + where: { + id: leg.id, + status: { + in: [ + DisputeSettlementLegStatus.PROCESSING, + DisputeSettlementLegStatus.SUBMITTED, + DisputeSettlementLegStatus.RECONCILIATION_REQUIRED, + ], + }, + }, + data: { + status: DisputeSettlementLegStatus.COMPLETED, + provider, + providerOperationId: providerRefundId, + completedAt: new Date(), + lastError: null, + }, + }); + if (claim.count !== 1) { + return; // already completed by a concurrent reconciliation + } + + await this.ledger.recordEntry( + { + entryType: LedgerEntryType.REFUND_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo: BigInt(leg.amountKobo), + orderId, + settlementLegId: leg.id, + reference: providerRefundId ?? `settlement-refund:${leg.id}`, + idempotencyKey: `settlement-leg:${leg.id}:refund-completed`, + }, + tx, + ); + + const isFullRefund = + capturedPayment !== null && + BigInt(leg.amountKobo) >= BigInt(capturedPayment.amountKobo); + + if (capturedPayment) { + await tx.payment.update({ + where: { id: capturedPayment.id }, + data: { + status: isFullRefund + ? PaymentStatus.REFUNDED + : PaymentStatus.PARTIALLY_REFUNDED, + }, + }); + } + + if (isFullRefund) { + // Terminal REFUNDED only when the whole captured amount is confirmed. + await tx.order.updateMany({ + where: { + id: orderId, + status: { in: [OrderStatus.DISPUTE, OrderStatus.REFUND_PENDING] }, + }, + data: { status: OrderStatus.REFUNDED }, + }); + } + + const completedLeg = await tx.disputeSettlementLeg.findUnique({ + where: { id: leg.id }, + select: { + id: true, + type: true, + status: true, + settlement: { + select: { + id: true, + disputeId: true, + orderId: true, + status: true, + updatedAt: true, + dispute: { select: { buyerId: true } }, + order: { + select: { storeProfile: { select: { userId: true } } }, + }, + }, + }, + }, + }); + if (!completedLeg) return; + await this.commerceOutbox.appendSettlementRealtimeEvents(tx, { + settlementId: completedLeg.settlement.id, + disputeId: completedLeg.settlement.disputeId, + orderId: completedLeg.settlement.orderId, + status: completedLeg.settlement.status, + legId: completedLeg.id, + legStatus: completedLeg.status, + refundStatus: completedLeg.status, + updatedAt: completedLeg.settlement.updatedAt, + }); + await this.appendLegMilestoneNotification(tx, completedLeg); + }); + + await this.rollUpSettlementByLeg(leg.id); + } + + async recordLegState( + legId: string, + status: DisputeSettlementLegStatus, + extra: Partial< + Pick< + LegRow, + | "provider" + | "providerReference" + | "providerOperationId" + | "lastError" + | "submittedAt" + | "completedAt" + | "nextReconcileAt" + > + > = {}, + expectedStatuses?: DisputeSettlementLegStatus[], + ): Promise { + await this.prisma.$transaction(async (tx) => { + // Status transitions are compare-and-set. A duplicate worker must not + // append another milestone merely because it observed an old leg. + const updated = await tx.disputeSettlementLeg.updateMany({ + where: { + id: legId, + status: expectedStatuses ? { in: expectedStatuses } : { not: status }, + }, + data: { status, ...extra }, + }); + if (updated.count !== 1) return; + + const leg = await tx.disputeSettlementLeg.findUnique({ + where: { id: legId }, + select: { + id: true, + type: true, + status: true, + settlement: { + select: { + id: true, + disputeId: true, + orderId: true, + status: true, + updatedAt: true, + dispute: { select: { buyerId: true } }, + order: { + select: { storeProfile: { select: { userId: true } } }, + }, + }, + }, + }, + }); + if (!leg) return; + + await this.commerceOutbox.appendSettlementRealtimeEvents(tx, { + settlementId: leg.settlement.id, + disputeId: leg.settlement.disputeId, + orderId: leg.settlement.orderId, + status: leg.settlement.status, + legId: leg.id, + legStatus: leg.status, + refundStatus: + leg.type === DisputeSettlementLegType.BUYER_REFUND + ? leg.status + : undefined, + payoutStatus: + leg.type === DisputeSettlementLegType.MERCHANT_PAYOUT + ? leg.status + : undefined, + updatedAt: leg.settlement.updatedAt, + }); + + await this.appendLegMilestoneNotification(tx, leg); + if ( + leg.status === DisputeSettlementLegStatus.RECONCILIATION_REQUIRED || + leg.status === DisputeSettlementLegStatus.MANUAL_REVIEW + ) { + await this.commerceOutbox.appendAdminSettlementAlert(tx, { + settlementId: leg.settlement.id, + orderId: leg.settlement.orderId, + stateKey: `leg:${leg.id}:state:${leg.status}`, + title: "Settlement Review Required", + body: "A dispute settlement requires review before any further financial action.", + }); + } + }); + } + + /** + * Explicit admin retry for a leg whose previous attempt is provably safe to + * retry. Ambiguous provider states are intentionally rejected and must be + * reconciled instead. + */ + async retryLeg(legId: string): Promise { + if (!SettlementConfig.executionEnabled) { + throw new BadRequestException( + "Settlement execution is disabled; retry cannot initiate money movement", + ); + } + const leg = await this.prisma.disputeSettlementLeg.findUnique({ + where: { id: legId }, + include: { settlement: { select: { orderId: true, status: true } } }, + }); + if (!leg) throw new BadRequestException("Settlement leg not found"); + if ( + leg.status !== DisputeSettlementLegStatus.PENDING && + leg.status !== DisputeSettlementLegStatus.FAILED + ) { + throw new BadRequestException( + "Only pending or provider-rejected failed settlement legs can be retried; reconcile uncertain states instead", + ); + } + if ( + leg.settlement.status === DisputeSettlementStatus.COMPLETED || + leg.settlement.status === DisputeSettlementStatus.MANUAL_REVIEW || + leg.settlement.status === DisputeSettlementStatus.RECONCILIATION_REQUIRED + ) { + throw new BadRequestException( + "This settlement is not eligible for retry; reconcile or complete manual review first", + ); + } + await this.executeLeg(leg.settlement.orderId, leg); + await this.rollUpSettlement(leg.settlementId); + } + + private async appendLegMilestoneNotification( + tx: Prisma.TransactionClient, + leg: { + id: string; + type: DisputeSettlementLegType; + status: DisputeSettlementLegStatus; + settlement: { + id: string; + orderId: string; + dispute: { buyerId: string }; + order: { storeProfile: { userId: string } | null }; + }; + }, + ): Promise { + const isRefund = leg.type === DisputeSettlementLegType.BUYER_REFUND; + const recipientUserId = isRefund + ? leg.settlement.dispute.buyerId + : leg.settlement.order.storeProfile?.userId; + if (!recipientUserId) return; + + const messages: Partial< + Record + > = isRefund + ? { + SUBMITTED: { + title: "Refund Processing", + body: "Your approved refund for this order is being processed.", + }, + COMPLETED: { + title: "Refund Completed", + body: "Your approved refund has been completed.", + }, + RECONCILIATION_REQUIRED: { + title: "Refund Review Required", + body: "Your refund requires an additional review. The TWIZRR support team is checking it.", + }, + MANUAL_REVIEW: { + title: "Refund Review Required", + body: "Your refund requires an additional review. The TWIZRR support team is checking it.", + }, + } + : { + SUBMITTED: { + title: "Payout Processing", + body: "Your approved payout is being processed.", + }, + COMPLETED: { + title: "Payout Completed", + body: "Your approved payout has been completed.", + }, + RECONCILIATION_REQUIRED: { + title: "Payout Review Required", + body: "Your payout requires an additional review. The TWIZRR support team is checking it.", + }, + MANUAL_REVIEW: { + title: "Payout Review Required", + body: "Your payout requires an additional review. The TWIZRR support team is checking it.", + }, + }; + const message = messages[leg.status]; + if (!message) return; + + // These enum values already exist in the durable Notification table. The + // title/body carry the precise settlement milestone; no new enum migration + // is required solely for a notification classification. + await this.commerceOutbox.appendSettlementNotification(tx, { + recipientUserId, + notificationType: isRefund ? "ORDER_DISPUTED" : "PAYOUT_SENT", + title: message.title, + body: message.body, + settlementId: leg.settlement.id, + orderId: leg.settlement.orderId, + stateKey: `leg:${leg.id}:state:${leg.status}`, + audience: isRefund ? "SHOPPER" : "STORE", + }); + } + + async rollUpSettlementByLeg(legId: string): Promise { + const leg = await this.prisma.disputeSettlementLeg.findUnique({ + where: { id: legId }, + select: { settlementId: true }, + }); + if (leg) { + await this.rollUpSettlement(leg.settlementId); + } + } + + /** + * Recomputes settlement status from its legs. A settlement is COMPLETED only + * when every leg is COMPLETED; mixed completion is PARTIALLY_COMPLETED; + * any leg needing reconciliation/review propagates that state up. + */ + async rollUpSettlement(settlementId: string): Promise { + await this.prisma.$transaction(async (tx) => { + // Serialize roll-ups for one settlement. The leg status read must occur + // *after* this row lock: otherwise an older worker can derive + // PARTIALLY_COMPLETED, another worker can complete both legs, and the + // older worker can subsequently downgrade the completed settlement. + const locked = await tx.$queryRaw<{ id: string }[]>` + SELECT id FROM dispute_settlements WHERE id = ${settlementId} FOR UPDATE + `; + if (locked.length === 0) return; + + const legs = await tx.disputeSettlementLeg.findMany({ + where: { settlementId }, + select: { status: true }, + }); + if (legs.length === 0) return; + + const statuses = legs.map((leg) => leg.status); + const allComplete = statuses.every( + (status) => status === DisputeSettlementLegStatus.COMPLETED, + ); + const anyComplete = statuses.some( + (status) => status === DisputeSettlementLegStatus.COMPLETED, + ); + const anyManual = statuses.some( + (status) => status === DisputeSettlementLegStatus.MANUAL_REVIEW, + ); + const anyReconcile = statuses.some( + (status) => + status === DisputeSettlementLegStatus.RECONCILIATION_REQUIRED, + ); + const anyFailed = statuses.some( + (status) => status === DisputeSettlementLegStatus.FAILED, + ); + const anyInFlight = statuses.some( + (status) => + status === DisputeSettlementLegStatus.PENDING || + status === DisputeSettlementLegStatus.PROCESSING || + status === DisputeSettlementLegStatus.SUBMITTED, + ); + + let next: DisputeSettlementStatus; + let completedAt: Date | null = null; + if (allComplete) { + next = DisputeSettlementStatus.COMPLETED; + completedAt = new Date(); + } else if (anyManual) { + next = DisputeSettlementStatus.MANUAL_REVIEW; + } else if (anyReconcile) { + next = DisputeSettlementStatus.RECONCILIATION_REQUIRED; + } else if (anyComplete && (anyInFlight || anyFailed)) { + next = DisputeSettlementStatus.PARTIALLY_COMPLETED; + } else if (anyFailed && !anyInFlight) { + next = DisputeSettlementStatus.FAILED; + } else { + next = DisputeSettlementStatus.PROCESSING; + } + + const current = await tx.disputeSettlement.findUnique({ + where: { id: settlementId }, + select: { status: true, completedAt: true }, + }); + if (!current) return; + + // A duplicate poll or webhook that does not change state must not churn + // updatedAt or append a second realtime business milestone. + if ( + current.status === next && + (next !== DisputeSettlementStatus.COMPLETED || current.completedAt) + ) { + return; + } + + const settlement = await tx.disputeSettlement.update({ + where: { id: settlementId }, + data: { status: next, ...(completedAt ? { completedAt } : {}) }, + select: { + id: true, + disputeId: true, + orderId: true, + status: true, + updatedAt: true, + }, + }); + + if (allComplete) { + // A full refund is already terminally REFUNDED before roll-up. Seller-win + // and partial settlements must leave the dispute lifecycle once every + // financial leg is confirmed. + await tx.order.updateMany({ + where: { + id: settlement.orderId, + status: { in: [OrderStatus.DISPUTE, OrderStatus.REFUND_PENDING] }, + }, + data: { status: OrderStatus.COMPLETED }, + }); + } + + await this.commerceOutbox.appendSettlementRealtimeEvents(tx, { + settlementId: settlement.id, + disputeId: settlement.disputeId, + orderId: settlement.orderId, + status: settlement.status, + updatedAt: settlement.updatedAt, + }); + }); + } +} diff --git a/apps/backend/src/domains/money/settlement/settlement-reconciliation.service.spec.ts b/apps/backend/src/domains/money/settlement/settlement-reconciliation.service.spec.ts new file mode 100644 index 00000000..92536440 --- /dev/null +++ b/apps/backend/src/domains/money/settlement/settlement-reconciliation.service.spec.ts @@ -0,0 +1,225 @@ +import { PayoutStatus } from "@prisma/client"; +import { SettlementReconciliationService } from "./settlement-reconciliation.service"; + +describe("SettlementReconciliationService", () => { + it("confirms a submitted per-collection refund through its immutable provider", async () => { + const provider = { + fetchRefund: jest.fn().mockResolvedValue({ + providerRefundId: "refund-operation-1", + status: "COMPLETED", + amountKobo: 4_001n, + }), + }; + const prisma = { + disputeSettlementRefundOperation: { + findMany: jest + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + id: "operation-1", + settlementLegId: "leg-1", + provider: "MONNIFY", + providerRefundId: "refund-operation-1", + settlementLeg: { + id: "leg-1", + settlement: { orderId: "order-1" }, + }, + }, + ]), + updateMany: jest.fn(), + }, + disputeSettlementLeg: { findMany: jest.fn().mockResolvedValue([]) }, + payout: { findMany: jest.fn().mockResolvedValue([]) }, + }; + const execution = { + confirmRefundOperation: jest.fn(), + rollUpRefundOperations: jest.fn(), + rollUpSettlementByLeg: jest.fn(), + }; + const refunds = { + getForPaymentProvider: jest.fn().mockReturnValue(provider), + getByKey: jest.fn(), + }; + const service = new SettlementReconciliationService( + prisma as never, + refunds as never, + { reconcileSubmittedPayout: jest.fn() } as never, + execution as never, + { doesExist: jest.fn(), deleteInterval: jest.fn() } as never, + ); + + await service.runOnce(); + + expect(refunds.getForPaymentProvider).toHaveBeenCalledWith("MONNIFY"); + expect(execution.confirmRefundOperation).toHaveBeenCalledWith( + "operation-1", + "refund-operation-1", + 4_001n, + ); + expect(execution.rollUpRefundOperations).toHaveBeenCalledWith( + "order-1", + "leg-1", + ); + }); + + it("moves an unregistered refund provider to manual review without backoff", async () => { + const refunds = { + getByKey: jest.fn(() => { + throw new Error("Refund provider 'legacy' is not registered."); + }), + getForPaymentProvider: jest.fn(), + }; + const prisma = { + disputeSettlementRefundOperation: { + findMany: jest.fn().mockResolvedValue([]), + updateMany: jest.fn(), + }, + disputeSettlementLeg: { + findMany: jest + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + id: "leg-legacy", + provider: "legacy", + providerOperationId: "refund-legacy", + settlement: { orderId: "order-1" }, + }, + ]), + update: jest.fn(), + }, + payout: { findMany: jest.fn().mockResolvedValue([]) }, + }; + const execution = { + rollUpSettlementByLeg: jest.fn(), + recordLegState: jest.fn(), + }; + const service = new SettlementReconciliationService( + prisma as never, + refunds as never, + { reconcileSubmittedPayout: jest.fn() } as never, + execution as never, + { doesExist: jest.fn(), deleteInterval: jest.fn() } as never, + ); + + await service.runOnce(); + + expect(execution.recordLegState).toHaveBeenCalledWith( + "leg-legacy", + "MANUAL_REVIEW", + expect.objectContaining({ lastError: expect.stringContaining("legacy") }), + ["SUBMITTED", "RECONCILIATION_REQUIRED"], + ); + expect(prisma.disputeSettlementLeg.update).not.toHaveBeenCalled(); + }); + + it("reconciles a submitted refund through the provider persisted on its leg", async () => { + const monnifyRefund = { + fetchRefund: jest.fn().mockResolvedValue({ + providerRefundId: "twz-refund-leg-1", + status: "PENDING", + amountKobo: 1000n, + }), + }; + const refunds = { + getByKey: jest.fn().mockReturnValue(monnifyRefund), + getForPaymentProvider: jest.fn(), + }; + const prisma = { + disputeSettlementRefundOperation: { + findMany: jest.fn().mockResolvedValue([]), + updateMany: jest.fn(), + }, + disputeSettlementLeg: { + findMany: jest + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + id: "leg-1", + provider: "monnify", + providerOperationId: "twz-refund-leg-1", + settlement: { orderId: "order-1" }, + }, + ]), + update: jest.fn().mockResolvedValue({}), + }, + payout: { findMany: jest.fn().mockResolvedValue([]) }, + }; + const scheduler = { + doesExist: jest.fn(), + deleteInterval: jest.fn(), + }; + const service = new SettlementReconciliationService( + prisma as never, + refunds as never, + { reconcileSubmittedPayout: jest.fn() } as never, + { rollUpSettlementByLeg: jest.fn(), recordLegState: jest.fn() } as never, + scheduler as never, + ); + + await service.runOnce(); + + expect(refunds.getByKey).toHaveBeenCalledWith("monnify"); + expect(monnifyRefund.fetchRefund).toHaveBeenCalledWith("twz-refund-leg-1"); + }); + + it("reconciles normal submitted payouts as well as settlement transfers", async () => { + const prisma = { + disputeSettlementRefundOperation: { + findMany: jest.fn().mockResolvedValue([]), + updateMany: jest.fn(), + }, + disputeSettlementLeg: { findMany: jest.fn().mockResolvedValue([]) }, + payout: { + findMany: jest.fn().mockResolvedValue([ + { + id: "payout-normal-1", + status: PayoutStatus.SUBMITTED, + settlementLegId: null, + }, + ]), + }, + }; + const payouts = { + reconcileSubmittedPayout: jest.fn().mockResolvedValue({ + status: "COMPLETED", + settlementLegId: null, + }), + }; + const scheduler = { + doesExist: jest.fn(), + deleteInterval: jest.fn(), + }; + const service = new SettlementReconciliationService( + prisma as never, + { + getByKey: jest.fn(), + getForPaymentProvider: jest.fn(), + } as never, + payouts as never, + { rollUpSettlementByLeg: jest.fn() } as never, + scheduler as never, + ); + + await service.runOnce(); + + expect(payouts.reconcileSubmittedPayout).toHaveBeenCalledWith( + "payout-normal-1", + ); + expect(prisma.payout.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ + OR: expect.arrayContaining([ + expect.objectContaining({ status: PayoutStatus.SUBMITTED }), + ]), + }), + ]), + }), + }), + ); + }); +}); diff --git a/apps/backend/src/domains/money/settlement/settlement-reconciliation.service.ts b/apps/backend/src/domains/money/settlement/settlement-reconciliation.service.ts new file mode 100644 index 00000000..6bd73815 --- /dev/null +++ b/apps/backend/src/domains/money/settlement/settlement-reconciliation.service.ts @@ -0,0 +1,474 @@ +import { + Inject, + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from "@nestjs/common"; +import { SchedulerRegistry } from "@nestjs/schedule"; +import { + DisputeSettlementLegStatus, + DisputeSettlementLegType, + DisputeSettlementRefundOperationStatus, + PayoutStatus, +} from "@prisma/client"; + +import { SettlementConfig } from "../../../config/settlement.config"; +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { PayoutService } from "../payout/payout.service"; +import { + REFUND_PROVIDER_REGISTRY, + type RefundProviderRegistry, +} from "../refund/refund-provider.registry"; +import type { RefundProvider } from "../refund/refund-provider.interface"; +import { SettlementExecutionService } from "./settlement-execution.service"; + +const RECONCILE_BACKOFF_MS = 5 * 60 * 1000; +// A worker can die after atomically claiming a leg but before it records the +// provider outcome. Do not retry that leg automatically: a provider request may +// already have been accepted. Move only stale claims to manual reconciliation. +const STALE_PROCESSING_LEG_MS = 5 * 60 * 1000; +const TIMER_NAME = "settlement-reconciliation"; + +/** + * Polls provider status for money that was submitted but not yet confirmed + * (§9 refunds, §12 transfers). It never initiates new money movement — it only + * confirms or fails already-submitted operations, so it is safe to run with + * execution disabled. Overlap-guarded, bounded batches. + */ +@Injectable() +export class SettlementReconciliationService + implements OnModuleInit, OnModuleDestroy +{ + private readonly logger = new Logger(SettlementReconciliationService.name); + private running = false; + + constructor( + private readonly prisma: PrismaService, + @Inject(REFUND_PROVIDER_REGISTRY) + private readonly refundProviders: RefundProviderRegistry, + private readonly payouts: PayoutService, + private readonly execution: SettlementExecutionService, + private readonly scheduler: SchedulerRegistry, + ) {} + + onModuleInit(): void { + if (!SettlementConfig.reconciliationEnabled) { + this.logger.log("Settlement reconciliation disabled"); + return; + } + const interval = setInterval(() => { + void this.runOnce().catch((error: unknown) => + this.logger.error( + `Settlement reconciliation tick failed: ${ + error instanceof Error ? error.message : "unknown" + }`, + ), + ); + }, SettlementConfig.reconciliationIntervalMs); + this.scheduler.addInterval(TIMER_NAME, interval); + } + + onModuleDestroy(): void { + if (this.scheduler.doesExist("interval", TIMER_NAME)) { + this.scheduler.deleteInterval(TIMER_NAME); + } + } + + async runOnce(): Promise { + if (this.running) return; + this.running = true; + try { + await this.recoverStaleRefundOperations(); + await this.recoverStaleProcessingLegs(); + await this.reconcileRefundOperations(); + await this.reconcileRefundLegs(); + await this.reconcileTransfers(); + } finally { + this.running = false; + } + } + + /** Reconcile an already-submitted settlement only; it never starts money movement. */ + async reconcileSettlement(settlementId: string): Promise { + const settlement = await this.prisma.disputeSettlement.findUnique({ + where: { id: settlementId }, + select: { id: true }, + }); + if (!settlement) return; + await this.reconcileRefundOperations(settlementId); + await this.reconcileRefundLegs(settlementId); + await this.reconcileTransfers(settlementId); + } + + private async recoverStaleProcessingLegs(): Promise { + const cutoff = new Date(Date.now() - STALE_PROCESSING_LEG_MS); + const legs = await this.prisma.disputeSettlementLeg.findMany({ + where: { + status: DisputeSettlementLegStatus.PROCESSING, + updatedAt: { lte: cutoff }, + }, + take: SettlementConfig.reconciliationBatchSize, + select: { id: true }, + }); + + for (const leg of legs) { + const current = await this.prisma.disputeSettlementLeg.findUnique({ + where: { id: leg.id }, + select: { status: true, updatedAt: true }, + }); + if ( + current?.status === DisputeSettlementLegStatus.PROCESSING && + current.updatedAt <= cutoff + ) { + await this.execution.recordLegState( + leg.id, + DisputeSettlementLegStatus.RECONCILIATION_REQUIRED, + { + lastError: + "Settlement leg claim became stale before its provider outcome was recorded", + }, + [DisputeSettlementLegStatus.PROCESSING], + ); + await this.execution.rollUpSettlementByLeg(leg.id); + } + } + } + + private async recoverStaleRefundOperations(): Promise { + const cutoff = new Date(Date.now() - STALE_PROCESSING_LEG_MS); + const operations = + await this.prisma.disputeSettlementRefundOperation.findMany({ + where: { + status: DisputeSettlementRefundOperationStatus.PROCESSING, + updatedAt: { lte: cutoff }, + }, + take: SettlementConfig.reconciliationBatchSize, + select: { + id: true, + settlementLegId: true, + settlementLeg: { + select: { settlement: { select: { orderId: true } } }, + }, + }, + }); + + for (const operation of operations) { + await this.execution.recordRefundOperationState( + operation.id, + DisputeSettlementRefundOperationStatus.RECONCILIATION_REQUIRED, + { + failureSummary: + "Refund operation claim became stale before its provider outcome was recorded", + }, + [DisputeSettlementRefundOperationStatus.PROCESSING], + ); + await this.execution.rollUpRefundOperations( + operation.settlementLeg.settlement.orderId, + operation.settlementLegId, + ); + await this.execution.rollUpSettlementByLeg(operation.settlementLegId); + } + } + + private async reconcileRefundOperations( + settlementId?: string, + ): Promise { + const now = new Date(); + const operations = + await this.prisma.disputeSettlementRefundOperation.findMany({ + where: { + ...(settlementId ? { settlementLeg: { settlementId } } : {}), + status: { + in: [ + DisputeSettlementRefundOperationStatus.SUBMITTED, + DisputeSettlementRefundOperationStatus.RECONCILIATION_REQUIRED, + ], + }, + providerRefundId: { not: null }, + OR: [{ nextReconcileAt: null }, { nextReconcileAt: { lte: now } }], + }, + take: SettlementConfig.reconciliationBatchSize, + include: { + settlementLeg: { + select: { id: true, settlement: { select: { orderId: true } } }, + }, + }, + }); + + for (const operation of operations) { + let refundProvider: RefundProvider; + try { + refundProvider = this.refundProviders.getForPaymentProvider( + operation.provider, + ); + } catch (error) { + await this.execution.recordRefundOperationState( + operation.id, + DisputeSettlementRefundOperationStatus.MANUAL_REVIEW, + { + failureSummary: + error instanceof Error + ? error.message + : "Refund provider is not registered", + }, + [ + DisputeSettlementRefundOperationStatus.SUBMITTED, + DisputeSettlementRefundOperationStatus.RECONCILIATION_REQUIRED, + ], + ); + await this.execution.rollUpRefundOperations( + operation.settlementLeg.settlement.orderId, + operation.settlementLegId, + ); + await this.execution.rollUpSettlementByLeg(operation.settlementLegId); + continue; + } + + try { + const fetched = await refundProvider.fetchRefund( + operation.providerRefundId as string, + ); + if (fetched.status === "COMPLETED") { + await this.execution.confirmRefundOperation( + operation.id, + operation.providerRefundId as string, + fetched.amountKobo, + ); + } else if (fetched.status === "FAILED") { + await this.execution.recordRefundOperationState( + operation.id, + DisputeSettlementRefundOperationStatus.FAILED, + { failureSummary: "Provider reported the refund as failed" }, + [ + DisputeSettlementRefundOperationStatus.SUBMITTED, + DisputeSettlementRefundOperationStatus.RECONCILIATION_REQUIRED, + ], + ); + } else if (fetched.status === "NEEDS_ATTENTION") { + await this.execution.recordRefundOperationState( + operation.id, + DisputeSettlementRefundOperationStatus.RECONCILIATION_REQUIRED, + { nextReconcileAt: new Date(Date.now() + RECONCILE_BACKOFF_MS) }, + [ + DisputeSettlementRefundOperationStatus.SUBMITTED, + DisputeSettlementRefundOperationStatus.RECONCILIATION_REQUIRED, + ], + ); + } else { + await this.bumpRefundOperation(operation.id); + } + } catch (error) { + this.logger.warn( + `Refund operation reconcile failed for ${operation.id}: ${ + error instanceof Error ? error.message : "unknown" + }`, + ); + await this.bumpRefundOperation(operation.id); + } + + await this.execution.rollUpRefundOperations( + operation.settlementLeg.settlement.orderId, + operation.settlementLegId, + ); + await this.execution.rollUpSettlementByLeg(operation.settlementLegId); + } + } + + private async reconcileRefundLegs(settlementId?: string): Promise { + const now = new Date(); + const legs = await this.prisma.disputeSettlementLeg.findMany({ + where: { + ...(settlementId ? { settlementId } : {}), + type: DisputeSettlementLegType.BUYER_REFUND, + refundOperations: { none: {} }, + status: { + in: [ + DisputeSettlementLegStatus.SUBMITTED, + DisputeSettlementLegStatus.RECONCILIATION_REQUIRED, + ], + }, + providerOperationId: { not: null }, + OR: [{ nextReconcileAt: null }, { nextReconcileAt: { lte: now } }], + }, + take: SettlementConfig.reconciliationBatchSize, + include: { settlement: { select: { orderId: true } } }, + }); + + for (const leg of legs) { + let refundProvider: RefundProvider; + try { + refundProvider = this.refundProviders.getByKey( + leg.provider ?? "paystack", + ); + } catch (error) { + await this.execution.recordLegState( + leg.id, + DisputeSettlementLegStatus.MANUAL_REVIEW, + { + lastError: + error instanceof Error + ? error.message + : "Refund provider is not registered", + }, + [ + DisputeSettlementLegStatus.SUBMITTED, + DisputeSettlementLegStatus.RECONCILIATION_REQUIRED, + ], + ); + await this.execution.rollUpSettlementByLeg(leg.id); + continue; + } + + try { + const fetched = await refundProvider.fetchRefund( + leg.providerOperationId as string, + ); + if (fetched.status === "COMPLETED") { + await this.execution.confirmRefundLeg( + leg.settlement.orderId, + leg, + leg.providerOperationId as string, + leg.provider ?? "paystack", + fetched.amountKobo, + ); + } else if (fetched.status === "FAILED") { + await this.execution.recordLegState( + leg.id, + DisputeSettlementLegStatus.FAILED, + { lastError: "Provider reported the refund as failed" }, + [ + DisputeSettlementLegStatus.SUBMITTED, + DisputeSettlementLegStatus.RECONCILIATION_REQUIRED, + ], + ); + await this.execution.rollUpSettlementByLeg(leg.id); + } else { + await this.bumpLeg(leg.id); + } + } catch (error) { + this.logger.warn( + `Refund reconcile failed for leg ${leg.id}: ${ + error instanceof Error ? error.message : "unknown" + }`, + ); + await this.bumpLeg(leg.id); + } + } + } + + private async reconcileTransfers(settlementId?: string): Promise { + const now = new Date(); + // Reconcile every SUBMITTED transfer — settlement legs AND normal payouts. + // A normal payout reaches SUBMITTED when the provider accepted but did not + // confirm, or when an ambiguous follow-up failure occurred during release; + // without this it would never be confirmed. Settlement-linked COMPLETED/ + // FAILED payouts are additionally repaired into their legs (e.g. finalized + // by a Paystack webhook rather than this poller). + const payouts = await this.prisma.payout.findMany({ + where: { + AND: [ + ...(settlementId ? [{ settlementLeg: { settlementId } }] : []), + { + OR: [ + { status: PayoutStatus.SUBMITTED }, + { + status: PayoutStatus.COMPLETED, + settlementLegId: { not: null }, + }, + { + status: PayoutStatus.FAILED, + settlementLegId: { not: null }, + }, + ], + }, + { + OR: [{ nextReconcileAt: null }, { nextReconcileAt: { lte: now } }], + }, + ], + }, + take: SettlementConfig.reconciliationBatchSize, + select: { id: true, status: true, settlementLegId: true }, + }); + + for (const payout of payouts) { + if (payout.status === PayoutStatus.COMPLETED) { + await this.execution.recordLegState( + payout.settlementLegId as string, + DisputeSettlementLegStatus.COMPLETED, + { completedAt: new Date(), lastError: null }, + [ + DisputeSettlementLegStatus.SUBMITTED, + DisputeSettlementLegStatus.RECONCILIATION_REQUIRED, + ], + ); + await this.execution.rollUpSettlementByLeg( + payout.settlementLegId as string, + ); + continue; + } + if (payout.status === PayoutStatus.FAILED) { + await this.execution.recordLegState( + payout.settlementLegId as string, + DisputeSettlementLegStatus.FAILED, + { lastError: "Transfer failed on provider confirmation" }, + [ + DisputeSettlementLegStatus.SUBMITTED, + DisputeSettlementLegStatus.RECONCILIATION_REQUIRED, + ], + ); + await this.execution.rollUpSettlementByLeg( + payout.settlementLegId as string, + ); + continue; + } + const result = await this.payouts.reconcileSubmittedPayout(payout.id); + if (!result.settlementLegId) continue; + if (result.status === "COMPLETED") { + await this.execution.recordLegState( + result.settlementLegId, + DisputeSettlementLegStatus.COMPLETED, + { completedAt: new Date(), lastError: null }, + [ + DisputeSettlementLegStatus.SUBMITTED, + DisputeSettlementLegStatus.RECONCILIATION_REQUIRED, + ], + ); + await this.execution.rollUpSettlementByLeg(result.settlementLegId); + } else if (result.status === "FAILED") { + await this.execution.recordLegState( + result.settlementLegId, + DisputeSettlementLegStatus.FAILED, + { lastError: "Transfer failed on verification" }, + [ + DisputeSettlementLegStatus.SUBMITTED, + DisputeSettlementLegStatus.RECONCILIATION_REQUIRED, + ], + ); + await this.execution.rollUpSettlementByLeg(result.settlementLegId); + } + } + } + + private async bumpLeg(legId: string): Promise { + await this.prisma.disputeSettlementLeg.update({ + where: { id: legId }, + data: { nextReconcileAt: new Date(Date.now() + RECONCILE_BACKOFF_MS) }, + }); + } + + private async bumpRefundOperation(operationId: string): Promise { + await this.prisma.disputeSettlementRefundOperation.updateMany({ + where: { + id: operationId, + status: { + in: [ + DisputeSettlementRefundOperationStatus.SUBMITTED, + DisputeSettlementRefundOperationStatus.RECONCILIATION_REQUIRED, + ], + }, + }, + data: { nextReconcileAt: new Date(Date.now() + RECONCILE_BACKOFF_MS) }, + }); + } +} diff --git a/apps/backend/src/domains/money/settlement/settlement-refund-allocation.spec.ts b/apps/backend/src/domains/money/settlement/settlement-refund-allocation.spec.ts new file mode 100644 index 00000000..573c4659 --- /dev/null +++ b/apps/backend/src/domains/money/settlement/settlement-refund-allocation.spec.ts @@ -0,0 +1,53 @@ +import { BadRequestException } from "@nestjs/common"; + +import { allocateSettlementRefund } from "./settlement-refund-allocation"; + +describe("allocateSettlementRefund", () => { + const original = { + paymentAttemptId: "attempt-original", + provider: "MONNIFY" as const, + transactionReference: "provider-original", + amountKobo: 3_333n, + collectedAt: new Date("2026-07-19T10:00:00.000Z"), + }; + const remainder = { + paymentAttemptId: "attempt-remainder", + provider: "MONNIFY" as const, + transactionReference: "provider-remainder", + amountKobo: 6_668n, + collectedAt: new Date("2026-07-19T11:00:00.000Z"), + }; + + it("allocates a partial refund from the newest confirmed collection", () => { + expect(allocateSettlementRefund(4_001n, [original, remainder])).toEqual([ + expect.objectContaining({ + paymentAttemptId: "attempt-remainder", + refundAmountKobo: 4_001n, + }), + ]); + }); + + it("splits a full refund exactly across provider collections", () => { + const result = allocateSettlementRefund(10_001n, [original, remainder]); + + expect(result.map((item) => item.refundAmountKobo)).toEqual([ + 6_668n, + 3_333n, + ]); + expect( + result.reduce((total, item) => total + item.refundAmountKobo, 0n), + ).toBe(10_001n); + }); + + it("rejects a refund larger than confirmed provider collections", () => { + expect(() => + allocateSettlementRefund(10_002n, [original, remainder]), + ).toThrow(BadRequestException); + }); + + it("rejects duplicate source attempts", () => { + expect(() => + allocateSettlementRefund(5_000n, [remainder, remainder]), + ).toThrow(BadRequestException); + }); +}); diff --git a/apps/backend/src/domains/money/settlement/settlement-refund-allocation.ts b/apps/backend/src/domains/money/settlement/settlement-refund-allocation.ts new file mode 100644 index 00000000..c6e8a303 --- /dev/null +++ b/apps/backend/src/domains/money/settlement/settlement-refund-allocation.ts @@ -0,0 +1,64 @@ +import { BadRequestException } from "@nestjs/common"; +import type { PaymentProviderName } from "@prisma/client"; + +export interface RefundableCollection { + paymentAttemptId: string; + provider: PaymentProviderName; + transactionReference: string; + amountKobo: bigint; + collectedAt: Date; +} + +export interface SettlementRefundAllocation extends RefundableCollection { + refundAmountKobo: bigint; +} + +/** + * Allocates an approved refund over immutable provider collections. Newest + * collections are consumed first so a partial refund uses the fewest provider + * operations. The caller supplies only provider-confirmed, unrefunded capacity. + */ +export function allocateSettlementRefund( + refundAmountKobo: bigint, + collections: RefundableCollection[], +): SettlementRefundAllocation[] { + if (refundAmountKobo <= 0n) return []; + + const ordered = [...collections] + .filter((collection) => collection.amountKobo > 0n) + .sort((left, right) => { + const byDate = right.collectedAt.getTime() - left.collectedAt.getTime(); + return byDate !== 0 + ? byDate + : right.paymentAttemptId.localeCompare(left.paymentAttemptId); + }); + + const seen = new Set(); + let remaining = refundAmountKobo; + const allocations: SettlementRefundAllocation[] = []; + + for (const collection of ordered) { + if (seen.has(collection.paymentAttemptId)) { + throw new BadRequestException({ + message: "A provider collection was included more than once", + code: "SETTLEMENT_DUPLICATE_REFUND_COLLECTION", + }); + } + seen.add(collection.paymentAttemptId); + + if (remaining === 0n) break; + const allocated = + collection.amountKobo < remaining ? collection.amountKobo : remaining; + allocations.push({ ...collection, refundAmountKobo: allocated }); + remaining -= allocated; + } + + if (remaining !== 0n) { + throw new BadRequestException({ + message: "Confirmed provider collections cannot cover this refund", + code: "SETTLEMENT_REFUND_COLLECTIONS_INSUFFICIENT", + }); + } + + return allocations; +} diff --git a/apps/backend/src/domains/money/settlement/settlement.module.ts b/apps/backend/src/domains/money/settlement/settlement.module.ts new file mode 100644 index 00000000..8fffdcf3 --- /dev/null +++ b/apps/backend/src/domains/money/settlement/settlement.module.ts @@ -0,0 +1,26 @@ +import { Module } from "@nestjs/common"; + +import { LedgerModule } from "../ledger/ledger.module"; +import { PayoutModule } from "../payout/payout.module"; +import { RefundModule } from "../refund/refund.module"; +import { OutboxModule } from "../../platform/outbox/outbox.module"; +import { SettlementExecuteOutboxHandler } from "./settlement-execute.outbox-handler"; +import { SettlementExecutionService } from "./settlement-execution.service"; +import { SettlementReconciliationService } from "./settlement-reconciliation.service"; +import { SettlementService } from "./settlement.service"; + +@Module({ + imports: [LedgerModule, PayoutModule, RefundModule, OutboxModule], + providers: [ + SettlementService, + SettlementExecutionService, + SettlementReconciliationService, + SettlementExecuteOutboxHandler, + ], + exports: [ + SettlementService, + SettlementExecutionService, + SettlementReconciliationService, + ], +}) +export class SettlementModule {} diff --git a/apps/backend/src/domains/money/settlement/settlement.service.spec.ts b/apps/backend/src/domains/money/settlement/settlement.service.spec.ts new file mode 100644 index 00000000..bae90a7b --- /dev/null +++ b/apps/backend/src/domains/money/settlement/settlement.service.spec.ts @@ -0,0 +1,87 @@ +import { + DisputeResolutionOutcome, + DisputeSettlementStatus, +} from "@prisma/client"; + +import { SettlementService } from "./settlement.service"; + +describe("SettlementService multi-collection refund safety", () => { + it("plans one provider-bound refund operation per confirmed collection", async () => { + const ledger = { + calculateOrderPayout: jest.fn().mockReturnValue({ + payoutAmountKobo: 90_000n, + }), + }; + const service = new SettlementService( + ledger as never, + {} as never, + {} as never, + ); + const tx = { + payment: { + findFirst: jest.fn().mockResolvedValue({ + id: "payment-1", + amountKobo: 100_000n, + currency: "NGN", + status: "SUCCESS", + provider: "MONNIFY", + attempts: [], + amountExceptions: [ + { + receivedAmountKobo: 40_000n, + paymentAttempt: { + id: "attempt-original", + provider: "MONNIFY", + providerReference: "twz-original", + providerTransactionReference: "monnify-original", + initializedAt: new Date("2026-07-19T10:00:00.000Z"), + verifiedAt: null, + }, + remainingBalanceAttempt: { + id: "attempt-remainder", + provider: "MONNIFY", + providerReference: "twz-remainder", + providerTransactionReference: "monnify-remainder", + expectedAmountKobo: 60_000n, + status: "SUCCESS", + initializedAt: new Date("2026-07-19T11:00:00.000Z"), + verifiedAt: new Date("2026-07-19T11:01:00.000Z"), + }, + }, + ], + }), + }, + ledgerEntry: { findMany: jest.fn().mockResolvedValue([]) }, + payout: { findFirst: jest.fn().mockResolvedValue(null) }, + disputeSettlementRefundOperation: { + findFirst: jest.fn().mockResolvedValue(null), + }, + }; + + const plan = await service.buildPlan( + { + id: "order-1", + orderType: "DIRECT", + totalAmountKobo: 100_000n, + } as never, + DisputeResolutionOutcome.BUYER_WINS, + {}, + tx as never, + ); + + expect(plan.initialStatus).toBe(DisputeSettlementStatus.PENDING); + expect(plan.manualReviewReason).toBeNull(); + expect(plan.refundOperations).toEqual([ + expect.objectContaining({ + paymentAttemptId: "attempt-remainder", + transactionReference: "monnify-remainder", + amountKobo: 60_000n, + }), + expect.objectContaining({ + paymentAttemptId: "attempt-original", + transactionReference: "monnify-original", + amountKobo: 40_000n, + }), + ]); + }); +}); diff --git a/apps/backend/src/domains/money/settlement/settlement.service.ts b/apps/backend/src/domains/money/settlement/settlement.service.ts new file mode 100644 index 00000000..9d1ffbf8 --- /dev/null +++ b/apps/backend/src/domains/money/settlement/settlement.service.ts @@ -0,0 +1,549 @@ +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import { + DisputeResolutionOutcome, + DisputeSettlementLegType, + DisputeSettlementStatus, + LedgerEntryType, + Order, + PaymentAttemptStatus, + PaymentProviderName, + PayoutStatus, + PaymentStatus, + Prisma, +} from "@prisma/client"; + +import { SettlementConfig } from "../../../config/settlement.config"; +import { LedgerService } from "../ledger/ledger.service"; +import { OUTBOX_TOPIC } from "../../platform/outbox/outbox.types"; +import { OutboxService } from "../../platform/outbox/outbox.service"; +import { CommerceOutboxService } from "../../platform/outbox/commerce-outbox.service"; +import { + computeSettlementAmounts, + parseKoboString, + type SettlementAmounts, +} from "./settlement-amounts"; +import { + allocateSettlementRefund, + type RefundableCollection, +} from "./settlement-refund-allocation"; + +export interface ResolveAmountsInput { + buyerRefundAmountKobo?: string | null; + merchantPayoutAmountKobo?: string | null; +} + +export interface SettlementPlan { + amounts: SettlementAmounts; + capturedAmountKobo: bigint; + // MANUAL_REVIEW when the decision cannot be executed safely (e.g. the + // merchant payout already completed but the shopper must be refunded). In + // that case no execute event is emitted — an operator resolves it by hand. + initialStatus: DisputeSettlementStatus; + manualReviewReason: string | null; + refundOperations: Array<{ + paymentAttemptId: string; + provider: PaymentProviderName; + transactionReference: string; + amountKobo: bigint; + }>; +} + +@Injectable() +export class SettlementService { + private readonly logger = new Logger(SettlementService.name); + + constructor( + private readonly ledger: LedgerService, + private readonly outbox: OutboxService, + private readonly commerceOutbox: CommerceOutboxService, + ) {} + + /** + * Financial preflight (§5) + amount rules (§3). Runs read-only queries and + * pure math; performs NO writes. Throws BadRequestException for an + * unexecutable/invalid decision, or returns a plan (possibly MANUAL_REVIEW). + */ + async buildPlan( + order: Order, + outcome: DisputeResolutionOutcome, + amounts: ResolveAmountsInput, + tx: Prisma.TransactionClient, + ): Promise { + if (order.orderType !== "DIRECT") { + // Dropship dispute settlement is out of scope for Phase 5. + throw new BadRequestException({ + message: "Only direct orders support financial settlement", + code: "SETTLEMENT_UNSUPPORTED_ORDER_TYPE", + }); + } + + const payment = await tx.payment.findFirst({ + where: { orderId: order.id, status: PaymentStatus.SUCCESS }, + orderBy: { verifiedAt: "desc" }, + include: { + attempts: { + orderBy: { initializedAt: "desc" }, + select: { + id: true, + provider: true, + providerReference: true, + providerTransactionReference: true, + expectedAmountKobo: true, + status: true, + initializedAt: true, + verifiedAt: true, + }, + }, + amountExceptions: { + where: { + exceptionType: "UNDERPAID", + status: "RESOLVED", + remainingBalanceAttemptId: { not: null }, + }, + orderBy: { detectedAt: "desc" }, + take: 1, + select: { + receivedAmountKobo: true, + paymentAttempt: { + select: { + id: true, + provider: true, + providerReference: true, + providerTransactionReference: true, + initializedAt: true, + verifiedAt: true, + }, + }, + remainingBalanceAttempt: { + select: { + id: true, + provider: true, + providerReference: true, + providerTransactionReference: true, + expectedAmountKobo: true, + status: true, + initializedAt: true, + verifiedAt: true, + }, + }, + }, + }, + }, + }); + + if (!payment) { + throw new BadRequestException({ + message: "No successful captured payment exists for this order", + code: "SETTLEMENT_NO_CAPTURED_PAYMENT", + }); + } + if (payment.currency !== "NGN") { + throw new BadRequestException({ + message: "Only NGN settlements are supported", + code: "SETTLEMENT_UNSUPPORTED_CURRENCY", + }); + } + if (payment.status === PaymentStatus.REFUNDED) { + throw new BadRequestException({ + message: "This payment has already been fully refunded", + code: "SETTLEMENT_ALREADY_REFUNDED", + }); + } + + const capturedAmountKobo = BigInt(payment.amountKobo); + + // Confirmed movements so far (append-only ledger is the source of truth). + const ledgerRows = await tx.ledgerEntry.findMany({ + where: { + orderId: order.id, + entryType: { + in: [ + LedgerEntryType.REFUND_COMPLETED, + LedgerEntryType.PAYOUT_COMPLETED, + ], + }, + }, + select: { entryType: true, amountKobo: true }, + }); + + let confirmedRefundsKobo = 0n; + let confirmedPayoutsKobo = 0n; + for (const row of ledgerRows) { + if (row.entryType === LedgerEntryType.REFUND_COMPLETED) { + confirmedRefundsKobo += BigInt(row.amountKobo); + } else { + confirmedPayoutsKobo += BigInt(row.amountKobo); + } + } + + const availableCapturedKobo = + capturedAmountKobo - confirmedRefundsKobo - confirmedPayoutsKobo; + + const inFlightPayout = await tx.payout.findFirst({ + where: { + orderId: order.id, + status: { + in: [ + PayoutStatus.PROCESSING, + PayoutStatus.SUBMITTED, + PayoutStatus.RECONCILIATION_REQUIRED, + ], + }, + }, + select: { id: true }, + }); + + const payoutBreakdown = this.ledger.calculateOrderPayout(order); + const normalMerchantPayoutKobo = + payoutBreakdown.payoutAmountKobo > 0n + ? payoutBreakdown.payoutAmountKobo + : 0n; + + const parsed = { + buyerRefundAmountKobo: parseKoboString(amounts.buyerRefundAmountKobo), + merchantPayoutAmountKobo: parseKoboString( + amounts.merchantPayoutAmountKobo, + ), + }; + + const computed = computeSettlementAmounts({ + outcome, + availableCapturedKobo, + normalMerchantPayoutKobo, + buyerRefundAmountKobo: parsed.buyerRefundAmountKobo, + merchantPayoutAmountKobo: parsed.merchantPayoutAmountKobo, + }); + + let refundOperations: SettlementPlan["refundOperations"] = []; + if (computed.buyerRefundKobo > 0n) { + const refundPlan = await this.buildRefundOperations( + tx, + payment, + computed.buyerRefundKobo, + ); + if (refundPlan.manualReviewReason) { + return { + amounts: computed, + capturedAmountKobo, + initialStatus: DisputeSettlementStatus.MANUAL_REVIEW, + manualReviewReason: refundPlan.manualReviewReason, + refundOperations: [], + }; + } + refundOperations = refundPlan.operations; + } + + // A merchant payout already completed and the decision now requires + // refunding the shopper → we cannot safely reverse the transfer or + // platform-fund a refund automatically. Route to manual review. + if (inFlightPayout || confirmedPayoutsKobo > 0n) { + return { + amounts: computed, + capturedAmountKobo, + initialStatus: DisputeSettlementStatus.MANUAL_REVIEW, + manualReviewReason: inFlightPayout + ? "Merchant payout is still awaiting provider confirmation; settlement needs manual review" + : "Merchant payout already completed; settlement needs manual review", + refundOperations: [], + }; + } + + return { + amounts: computed, + capturedAmountKobo, + initialStatus: DisputeSettlementStatus.PENDING, + manualReviewReason: null, + refundOperations, + }; + } + + /** + * Creates the settlement header + legs inside the caller's resolveDispute + * transaction (§4). Deterministic settlement idempotency key + * (dispute:{disputeId}:settlement) means a repeated resolution can never + * create a second settlement. Emits the execute outbox event only when the + * plan is executable and execution is enabled — otherwise money never moves. + */ + async createSettlementInTx( + tx: Prisma.TransactionClient, + input: { + disputeId: string; + orderId: string; + outcome: DisputeResolutionOutcome; + plan: SettlementPlan; + createdBy: string; + }, + ): Promise<{ id: string; status: DisputeSettlementStatus }> { + const { plan } = input; + const legs: Prisma.DisputeSettlementLegCreateWithoutSettlementInput[] = []; + + // Legs are only created for positive movements. The unique (settlementId, + // type) constraint guarantees at most one of each. + if (plan.amounts.buyerRefundKobo > 0n) { + legs.push({ + type: DisputeSettlementLegType.BUYER_REFUND, + amountKobo: plan.amounts.buyerRefundKobo, + idempotencyKey: `dispute:${input.disputeId}:settlement:refund`, + ...(plan.refundOperations.length > 0 + ? { + refundOperations: { + create: plan.refundOperations.map((operation) => ({ + paymentAttempt: { + connect: { id: operation.paymentAttemptId }, + }, + provider: operation.provider, + transactionReference: operation.transactionReference, + amountKobo: operation.amountKobo, + idempotencyKey: `dispute:${input.disputeId}:settlement:refund:attempt:${operation.paymentAttemptId}`, + })), + }, + } + : {}), + }); + } + if (plan.amounts.merchantPayoutKobo > 0n) { + legs.push({ + type: DisputeSettlementLegType.MERCHANT_PAYOUT, + amountKobo: plan.amounts.merchantPayoutKobo, + idempotencyKey: `dispute:${input.disputeId}:settlement:payout`, + }); + } + + const settlement = await tx.disputeSettlement.create({ + data: { + disputeId: input.disputeId, + orderId: input.orderId, + outcome: input.outcome, + status: plan.initialStatus, + capturedAmountKobo: plan.capturedAmountKobo, + buyerRefundAmountKobo: plan.amounts.buyerRefundKobo, + merchantPayoutAmountKobo: plan.amounts.merchantPayoutKobo, + platformRetainedAmountKobo: plan.amounts.platformRetainedKobo, + createdBy: input.createdBy, + idempotencyKey: `dispute:${input.disputeId}:settlement`, + failureReason: plan.manualReviewReason, + legs: { create: legs }, + }, + }); + + const executable = + plan.initialStatus === DisputeSettlementStatus.PENDING && legs.length > 0; + + if (executable && SettlementConfig.executionEnabled) { + await this.outbox.append(tx, { + topic: OUTBOX_TOPIC.DISPUTE_SETTLEMENT_EXECUTE_REQUESTED, + version: 1, + aggregateType: "DISPUTE_SETTLEMENT", + aggregateId: settlement.id, + idempotencyKey: `settlement:${settlement.id}:execute`, + payload: { settlementId: settlement.id }, + }); + } else if (executable) { + this.logger.warn( + `Settlement ${settlement.id} created but execution is disabled; no money will move until DISPUTE_SETTLEMENT_EXECUTION_ENABLED=true`, + ); + } + + await this.commerceOutbox.appendSettlementRealtimeEvents(tx, { + settlementId: settlement.id, + disputeId: settlement.disputeId, + orderId: settlement.orderId, + status: settlement.status, + updatedAt: settlement.updatedAt, + }); + + if (plan.amounts.merchantPayoutKobo > 0n) { + const storeOwner = await tx.order.findUnique({ + where: { id: settlement.orderId }, + select: { storeProfile: { select: { userId: true } } }, + }); + if (storeOwner?.storeProfile?.userId) { + await this.commerceOutbox.appendSettlementNotification(tx, { + recipientUserId: storeOwner.storeProfile.userId, + notificationType: "PAYOUT_SENT", + title: "Settlement Payout Approved", + body: "A payout for this order has been approved and will be processed after the dispute settlement checks.", + settlementId: settlement.id, + orderId: settlement.orderId, + stateKey: "payout-leg-created", + audience: "STORE", + }); + } + } + + return { id: settlement.id, status: settlement.status }; + } + + private async buildRefundOperations( + tx: Prisma.TransactionClient, + payment: Prisma.PaymentGetPayload<{ + include: { + attempts: { + select: { + id: true; + provider: true; + providerReference: true; + providerTransactionReference: true; + expectedAmountKobo: true; + status: true; + initializedAt: true; + verifiedAt: true; + }; + }; + amountExceptions: { + select: { + receivedAmountKobo: true; + paymentAttempt: { + select: { + id: true; + provider: true; + providerReference: true; + providerTransactionReference: true; + initializedAt: true; + verifiedAt: true; + }; + }; + remainingBalanceAttempt: { + select: { + id: true; + provider: true; + providerReference: true; + providerTransactionReference: true; + expectedAmountKobo: true; + status: true; + initializedAt: true; + verifiedAt: true; + }; + }; + }; + }; + }; + }>, + refundAmountKobo: bigint, + ): Promise<{ + operations: SettlementPlan["refundOperations"]; + manualReviewReason: string | null; + }> { + const collections: RefundableCollection[] = []; + const multiCollection = payment.amountExceptions[0] ?? null; + + if (multiCollection) { + const remainingAttempt = multiCollection.remainingBalanceAttempt; + if ( + !remainingAttempt || + remainingAttempt.status !== PaymentAttemptStatus.SUCCESS || + remainingAttempt.expectedAmountKobo === null + ) { + return { + operations: [], + manualReviewReason: + "Multi-collection payment is missing a confirmed remaining-balance attempt", + }; + } + const source = multiCollection.paymentAttempt; + const sourceReference = this.refundTransactionReference( + source.provider, + source.providerReference, + source.providerTransactionReference, + ); + const remainingReference = this.refundTransactionReference( + remainingAttempt.provider, + remainingAttempt.providerReference, + remainingAttempt.providerTransactionReference, + ); + if ( + source.provider !== payment.provider || + remainingAttempt.provider !== payment.provider || + !sourceReference || + !remainingReference || + multiCollection.receivedAmountKobo + + remainingAttempt.expectedAmountKobo !== + payment.amountKobo + ) { + return { + operations: [], + manualReviewReason: + "Multi-collection payment references cannot be proven safe for automated refund", + }; + } + collections.push( + { + paymentAttemptId: source.id, + provider: source.provider, + transactionReference: sourceReference, + amountKobo: multiCollection.receivedAmountKobo, + collectedAt: source.verifiedAt ?? source.initializedAt, + }, + { + paymentAttemptId: remainingAttempt.id, + provider: remainingAttempt.provider, + transactionReference: remainingReference, + amountKobo: remainingAttempt.expectedAmountKobo, + collectedAt: + remainingAttempt.verifiedAt ?? remainingAttempt.initializedAt, + }, + ); + } else { + for (const attempt of payment.attempts) { + if (attempt.status !== PaymentAttemptStatus.SUCCESS) continue; + const transactionReference = this.refundTransactionReference( + attempt.provider, + attempt.providerReference, + attempt.providerTransactionReference, + ); + if (!transactionReference) continue; + collections.push({ + paymentAttemptId: attempt.id, + provider: attempt.provider, + transactionReference, + amountKobo: attempt.expectedAmountKobo ?? payment.amountKobo, + collectedAt: attempt.verifiedAt ?? attempt.initializedAt, + }); + } + + // Historical payments created before PaymentAttempt persistence retain + // the existing single-leg execution path. + if (collections.length === 0) { + return { operations: [], manualReviewReason: null }; + } + } + + const existingReservation = + await tx.disputeSettlementRefundOperation.findFirst({ + where: { + paymentAttemptId: { + in: collections.map((collection) => collection.paymentAttemptId), + }, + }, + select: { id: true }, + }); + if (existingReservation) { + return { + operations: [], + manualReviewReason: + "A confirmed provider collection is already reserved by another refund operation", + }; + } + + const allocations = allocateSettlementRefund(refundAmountKobo, collections); + return { + operations: allocations.map((allocation) => ({ + paymentAttemptId: allocation.paymentAttemptId, + provider: allocation.provider, + transactionReference: allocation.transactionReference, + amountKobo: allocation.refundAmountKobo, + })), + manualReviewReason: null, + }; + } + + private refundTransactionReference( + provider: PaymentProviderName, + providerReference: string, + providerTransactionReference: string | null, + ): string | null { + return provider === PaymentProviderName.MONNIFY + ? providerTransactionReference + : providerReference; + } +} diff --git a/apps/backend/src/domains/money/storepass/dto/checkout-storepass.dto.ts b/apps/backend/src/domains/money/storepass/dto/checkout-storepass.dto.ts new file mode 100644 index 00000000..6442bbc6 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/dto/checkout-storepass.dto.ts @@ -0,0 +1,25 @@ +import { IsIn, IsOptional } from "class-validator"; + +import { StorePassPaidPlanCode } from "../types/storepass.types"; + +const PAID_PLAN_CODES: StorePassPaidPlanCode[] = ["GROWTH", "PRO"]; + +/** Purpose values accepted by the Store Mode checkout endpoint (not re-card). */ +export type StorePassCheckoutRequestPurpose = + | "STOREPASS_SUBSCRIPTION" + | "STOREPASS_UPGRADE"; + +const CHECKOUT_PURPOSES: StorePassCheckoutRequestPurpose[] = [ + "STOREPASS_SUBSCRIPTION", + "STOREPASS_UPGRADE", +]; + +export class CheckoutStorePassDto { + @IsIn(PAID_PLAN_CODES) + planCode!: StorePassPaidPlanCode; + + /** Defaults to STOREPASS_SUBSCRIPTION when omitted. */ + @IsOptional() + @IsIn(CHECKOUT_PURPOSES) + purpose?: StorePassCheckoutRequestPurpose; +} diff --git a/apps/backend/src/domains/money/storepass/dto/use-entitlement.dto.ts b/apps/backend/src/domains/money/storepass/dto/use-entitlement.dto.ts new file mode 100644 index 00000000..734f9598 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/dto/use-entitlement.dto.ts @@ -0,0 +1,38 @@ +import { StorePassEntitlementType } from "@prisma/client"; +import { + IsEnum, + IsInt, + IsOptional, + IsString, + MaxLength, + Min, +} from "class-validator"; + +/** + * Consume credit from one of the current store's StorePass entitlements. Only + * credit-type entitlements can be consumed; the service rejects access flags. + * Supplying a (referenceType, referenceId) pair makes the consume idempotent. + */ +export class UseEntitlementDto { + @IsEnum(StorePassEntitlementType) + entitlementType!: StorePassEntitlementType; + + @IsInt() + @Min(1) + amount!: number; + + @IsOptional() + @IsString() + @MaxLength(64) + referenceType?: string; + + @IsOptional() + @IsString() + @MaxLength(128) + referenceId?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + reason?: string; +} diff --git a/apps/backend/src/domains/money/storepass/storepass-activation.service.spec.ts b/apps/backend/src/domains/money/storepass/storepass-activation.service.spec.ts new file mode 100644 index 00000000..f9ecef81 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-activation.service.spec.ts @@ -0,0 +1,132 @@ +import { + StorePassBillingAttemptStatus, + StorePassInvoiceStatus, + StorePassPlanCode, + StorePassSubscriptionStatus, +} from "@prisma/client"; + +import { StorePassActivationService } from "./storepass-activation.service"; + +function makeHarness() { + const tx = { + storePassBillingAttempt: { update: jest.fn() }, + storePassInvoice: { update: jest.fn() }, + storePassSubscription: { update: jest.fn() }, + storePassPaymentMethod: { + updateMany: jest.fn(), + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn(), + update: jest.fn(), + }, + storePassBillingEvent: { update: jest.fn() }, + }; + const prisma = { + $transaction: jest.fn().mockImplementation((cb) => cb(tx)), + }; + const entitlements = { + grantEntitlementsForSubscription: jest.fn().mockResolvedValue([]), + }; + const service = new StorePassActivationService( + prisma as never, + entitlements as never, + ); + return { service, tx, entitlements }; +} + +const attempt = { + id: "att-1", + storeId: "store-1", + invoiceId: "inv-1", +} as never; +const invoice = { + id: "inv-1", + planId: "plan-growth", + planCodeSnapshot: StorePassPlanCode.GROWTH, + amountKobo: 450000n, + paidByUserId: "user-1", +} as never; +const subscription = { id: "sub-1", ownerUserId: "user-1" } as never; + +describe("StorePassActivationService.applyPaymentSuccess", () => { + it("marks attempt/invoice/subscription paid and grants entitlements once", async () => { + const { service, tx, entitlements } = makeHarness(); + + await service.applyPaymentSuccess({ + attempt, + invoice, + subscription, + providerTransactionId: "txn-1", + now: new Date("2026-07-07T00:00:00.000Z"), + }); + + expect(tx.storePassBillingAttempt.update.mock.calls[0][0].data.status).toBe( + StorePassBillingAttemptStatus.SUCCEEDED, + ); + expect(tx.storePassInvoice.update.mock.calls[0][0].data.status).toBe( + StorePassInvoiceStatus.PAID, + ); + const subData = tx.storePassSubscription.update.mock.calls[0][0].data; + expect(subData.status).toBe(StorePassSubscriptionStatus.ACTIVE); + expect(subData.cancelAtPeriodEnd).toBe(false); + expect(subData.currentPeriodStart).toBeInstanceOf(Date); + expect(subData.nextBillingAt).toBeInstanceOf(Date); + + expect(entitlements.grantEntitlementsForSubscription).toHaveBeenCalledTimes( + 1, + ); + expect(entitlements.grantEntitlementsForSubscription).toHaveBeenCalledWith( + "sub-1", + tx, + ); + }); + + it("persists a tokenized card only when the event carried one", async () => { + const { service, tx } = makeHarness(); + + await service.applyPaymentSuccess({ + attempt, + invoice, + subscription, + providerTransactionId: "txn-1", + tokenizedCard: { + tokenKey: "tok_1", + maskedCardPan: "4***11*", + tokenExpiryMonth: "05", + tokenExpiryYear: "2030", + }, + }); + + expect(tx.storePassPaymentMethod.create).toHaveBeenCalled(); + const card = tx.storePassPaymentMethod.create.mock.calls[0][0].data; + expect(card.nombaTokenKey).toBe("tok_1"); + expect(card.tokenExpiryMonth).toBe(5); + }); + + it("does not touch payment methods when no card is present (renewal/recon)", async () => { + const { service, tx } = makeHarness(); + + await service.applyPaymentSuccess({ + attempt, + invoice, + subscription, + providerTransactionId: "txn-1", + }); + + expect(tx.storePassPaymentMethod.create).not.toHaveBeenCalled(); + expect(tx.storePassPaymentMethod.update).not.toHaveBeenCalled(); + }); + + it("finalizes the webhook event row when an eventRowId is supplied", async () => { + const { service, tx } = makeHarness(); + + await service.applyPaymentSuccess({ + attempt, + invoice, + subscription, + providerTransactionId: "txn-1", + eventRowId: "evt-1", + }); + + expect(tx.storePassBillingEvent.update).toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/domains/money/storepass/storepass-activation.service.ts b/apps/backend/src/domains/money/storepass/storepass-activation.service.ts new file mode 100644 index 00000000..193b8447 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-activation.service.ts @@ -0,0 +1,178 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { + Prisma, + StorePassBillingAttempt, + StorePassBillingAttemptStatus, + StorePassBillingEventStatus, + StorePassInvoice, + StorePassInvoiceStatus, + StorePassPaymentMethodType, + StorePassPaymentProvider, + StorePassSubscription, + StorePassSubscriptionStatus, +} from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { SubscriptionBillingTokenizedCard } from "../subscription-billing/providers/subscription-billing-provider.interface"; +import { StorePassEntitlementsService } from "./storepass-entitlements.service"; +import { computeMonthlyBillingPeriod } from "./storepass.constants"; + +export interface ApplyPaymentSuccessInput { + attempt: StorePassBillingAttempt; + invoice: StorePassInvoice; + subscription: StorePassSubscription; + providerTransactionId: string | null; + /** Present only when the payment event carried a fresh tokenized card. */ + tokenizedCard?: SubscriptionBillingTokenizedCard; + /** Optional webhook billing-event row to finalize as PROCESSED atomically. */ + eventRowId?: string; + now?: Date; +} + +/** + * The single place that turns a confirmed successful billing attempt into an + * active/renewed StorePass subscription. Used by both the verified-webhook flow + * and the reconciliation sweep so activation and entitlement granting happen in + * exactly one code path — entitlements can never be double-granted, and a + * renewal and a first payment activate identically. + * + * Callers are responsible for confirming success (amount + provider lookup) + * before calling this. This method only performs the atomic state change. + */ +@Injectable() +export class StorePassActivationService { + private readonly logger = new Logger(StorePassActivationService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly entitlements: StorePassEntitlementsService, + ) {} + + async applyPaymentSuccess(input: ApplyPaymentSuccessInput): Promise { + const { attempt, invoice, subscription, providerTransactionId } = input; + const now = input.now ?? new Date(); + const { billingPeriodStart, billingPeriodEnd } = + computeMonthlyBillingPeriod(now); + const payerUserId = invoice.paidByUserId ?? subscription.ownerUserId; + + await this.prisma.$transaction(async (tx) => { + await tx.storePassBillingAttempt.update({ + where: { id: attempt.id }, + data: { + status: StorePassBillingAttemptStatus.SUCCEEDED, + succeededAt: now, + providerTransactionId, + needsReview: false, + }, + }); + + await tx.storePassInvoice.update({ + where: { id: invoice.id }, + data: { status: StorePassInvoiceStatus.PAID, paidAt: now }, + }); + + await tx.storePassSubscription.update({ + where: { id: subscription.id }, + data: { + planId: invoice.planId, + planCodeSnapshot: invoice.planCodeSnapshot, + status: StorePassSubscriptionStatus.ACTIVE, + currentPeriodStart: billingPeriodStart, + currentPeriodEnd: billingPeriodEnd, + lastPaidAt: now, + nextBillingAt: billingPeriodEnd, + pastDueAt: null, + cancelAtPeriodEnd: false, + }, + }); + + if (input.tokenizedCard) { + await this.savePaymentMethod(tx, { + storeId: attempt.storeId, + userId: payerUserId, + card: input.tokenizedCard, + }); + } + + // Grant entitlements exactly once, in the same transaction, reading the + // subscription row updated above (ACTIVE, new period). + await this.entitlements.grantEntitlementsForSubscription( + subscription.id, + tx, + ); + + if (input.eventRowId) { + await tx.storePassBillingEvent.update({ + where: { id: input.eventRowId }, + data: { + processingStatus: StorePassBillingEventStatus.PROCESSED, + processedAt: now, + billingAttemptId: attempt.id, + invoiceId: invoice.id, + subscriptionId: subscription.id, + storeId: attempt.storeId, + providerTransactionId, + }, + }); + } + }); + + this.logger.log( + `Activated StorePass subscription ${subscription.id} for store ${attempt.storeId} (invoice ${invoice.id} paid).`, + ); + } + + /** Upserts the store's single default tokenized-card payment method. */ + async savePaymentMethod( + tx: Prisma.TransactionClient, + input: { + storeId: string; + userId: string; + card: SubscriptionBillingTokenizedCard; + }, + ): Promise { + const { storeId, userId, card } = input; + + // Only one default card per store. + await tx.storePassPaymentMethod.updateMany({ + where: { storeId, isDefault: true }, + data: { isDefault: false }, + }); + + const existing = await tx.storePassPaymentMethod.findFirst({ + where: { storeId, nombaTokenKey: card.tokenKey }, + }); + + const data = { + provider: StorePassPaymentProvider.NOMBA, + type: StorePassPaymentMethodType.CARD_TOKEN, + nombaTokenKey: card.tokenKey, + cardType: card.cardType ?? null, + maskedCardPan: card.maskedCardPan ?? null, + tokenExpiryMonth: this.parseIntOrNull(card.tokenExpiryMonth), + tokenExpiryYear: this.parseIntOrNull(card.tokenExpiryYear), + isDefault: true, + revokedAt: null, + }; + + if (existing) { + await tx.storePassPaymentMethod.update({ + where: { id: existing.id }, + data, + }); + return; + } + + await tx.storePassPaymentMethod.create({ + data: { storeId, userId, ...data }, + }); + } + + private parseIntOrNull(value: string | undefined): number | null { + if (!value) { + return null; + } + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : null; + } +} diff --git a/apps/backend/src/domains/money/storepass/storepass-badge.spec.ts b/apps/backend/src/domains/money/storepass/storepass-badge.spec.ts new file mode 100644 index 00000000..c52aec32 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-badge.spec.ts @@ -0,0 +1,164 @@ +import { + StorePassPlanCode, + StorePassSubscriptionStatus, + StoreTier, +} from "@prisma/client"; + +import { computeStorePassPublicBadge } from "./storepass-badge"; + +const NOW = new Date("2026-07-06T00:00:00.000Z"); +const FUTURE = new Date("2026-08-06T00:00:00.000Z"); +const PAST = new Date("2026-06-06T00:00:00.000Z"); + +function sub(overrides: { + status?: StorePassSubscriptionStatus; + planCodeSnapshot?: StorePassPlanCode; + currentPeriodEnd?: Date | null; +}) { + return { + status: overrides.status ?? StorePassSubscriptionStatus.ACTIVE, + planCodeSnapshot: overrides.planCodeSnapshot ?? StorePassPlanCode.GROWTH, + currentPeriodEnd: + overrides.currentPeriodEnd === undefined + ? FUTURE + : overrides.currentPeriodEnd, + }; +} + +function store(overrides: { tier?: StoreTier; isOpen?: boolean } = {}) { + return { + tier: overrides.tier ?? StoreTier.TIER_2, + isOpen: overrides.isOpen ?? true, + }; +} + +describe("computeStorePassPublicBadge", () => { + it("returns null when there is no subscription", () => { + expect(computeStorePassPublicBadge(null, store(), NOW)).toBeNull(); + }); + + it("returns null when there is no store", () => { + expect(computeStorePassPublicBadge(sub({}), null, NOW)).toBeNull(); + }); + + it("returns null for the Free plan", () => { + expect( + computeStorePassPublicBadge( + sub({ planCodeSnapshot: StorePassPlanCode.FREE }), + store(), + NOW, + ), + ).toBeNull(); + }); + + it("returns StorePass Growth for an ACTIVE Growth store", () => { + expect( + computeStorePassPublicBadge( + sub({ planCodeSnapshot: StorePassPlanCode.GROWTH }), + store(), + NOW, + ), + ).toEqual({ + planCode: StorePassPlanCode.GROWTH, + label: "StorePass Growth", + variant: "growth", + activeUntil: FUTURE.toISOString(), + }); + }); + + it("returns StorePass Pro for an ACTIVE Pro store", () => { + const badge = computeStorePassPublicBadge( + sub({ planCodeSnapshot: StorePassPlanCode.PRO }), + store(), + NOW, + ); + expect(badge).toEqual({ + planCode: StorePassPlanCode.PRO, + label: "StorePass Pro", + variant: "pro", + activeUntil: FUTURE.toISOString(), + }); + }); + + it("still shows the badge for CANCELLED_AT_PERIOD_END inside the period", () => { + const badge = computeStorePassPublicBadge( + sub({ status: StorePassSubscriptionStatus.CANCELLED_AT_PERIOD_END }), + store(), + NOW, + ); + expect(badge?.variant).toBe("growth"); + }); + + it("hides the badge for CANCELLED_AT_PERIOD_END once the period ends", () => { + expect( + computeStorePassPublicBadge( + sub({ + status: StorePassSubscriptionStatus.CANCELLED_AT_PERIOD_END, + currentPeriodEnd: PAST, + }), + store(), + NOW, + ), + ).toBeNull(); + }); + + it("hides the badge for an ACTIVE subscription whose period has expired", () => { + expect( + computeStorePassPublicBadge( + sub({ currentPeriodEnd: PAST }), + store(), + NOW, + ), + ).toBeNull(); + }); + + it.each([ + StorePassSubscriptionStatus.PAST_DUE, + StorePassSubscriptionStatus.INACTIVE, + StorePassSubscriptionStatus.CANCELLED, + StorePassSubscriptionStatus.FREE, + ])("returns null for status %s", (status) => { + expect( + computeStorePassPublicBadge(sub({ status }), store(), NOW), + ).toBeNull(); + }); + + it.each([StoreTier.TIER_0, StoreTier.TIER_1])( + "returns null for a %s store even with an active paid plan", + (tier) => { + expect( + computeStorePassPublicBadge(sub({}), store({ tier }), NOW), + ).toBeNull(); + }, + ); + + it("returns null for a closed store", () => { + expect( + computeStorePassPublicBadge(sub({}), store({ isOpen: false }), NOW), + ).toBeNull(); + }); + + it("exposes only safe fields — no subscription/billing/provider internals", () => { + const badge = computeStorePassPublicBadge(sub({}), store(), NOW); + expect(badge).not.toBeNull(); + expect(Object.keys(badge as object).sort()).toEqual([ + "activeUntil", + "label", + "planCode", + "variant", + ]); + const serialized = JSON.stringify(badge); + for (const forbidden of [ + "subscriptionId", + "invoiceId", + "billingAttemptId", + "providerTransactionId", + "nombaTokenKey", + "storeType", + "amountKobo", + "paidByUserId", + ]) { + expect(serialized).not.toContain(forbidden); + } + }); +}); diff --git a/apps/backend/src/domains/money/storepass/storepass-badge.ts b/apps/backend/src/domains/money/storepass/storepass-badge.ts new file mode 100644 index 00000000..4dba5b07 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-badge.ts @@ -0,0 +1,78 @@ +import { + StorePassPlanCode, + StorePassSubscriptionStatus, + StoreTier, +} from "@prisma/client"; + +import { StorePassPublicBadge } from "./types/storepass.types"; + +/** Minimal subscription fields the public-badge decision needs. */ +export interface BadgeSubscriptionInput { + status: StorePassSubscriptionStatus; + planCodeSnapshot: StorePassPlanCode; + currentPeriodEnd: Date | null; +} + +/** Minimal store fields the public-badge decision needs. */ +export interface BadgeStoreInput { + tier: StoreTier; + isOpen: boolean; +} + +/** + * Pure eligibility check for a store's public StorePass badge. Returns the safe + * badge when the store may display one, otherwise null. + * + * Rules (a paid growth-plan marker, never verification/trust): + * - Paid plan only (GROWTH or PRO; FREE never qualifies). + * - Store is Tier 2+ (Tier 0 and Tier 1 excluded). + * - Store is open (a closed store shows no badge). StoreProfile has no + * suspension flag, so closure is the available gate. + * - The current paid period has not expired, and the status is either ACTIVE or + * CANCELLED_AT_PERIOD_END. INACTIVE, PAST_DUE, and CANCELLED never qualify. + */ +export function computeStorePassPublicBadge( + subscription: BadgeSubscriptionInput | null, + store: BadgeStoreInput | null, + now: Date = new Date(), +): StorePassPublicBadge | null { + if (!subscription || !store) { + return null; + } + + const isPaidPlan = + subscription.planCodeSnapshot === StorePassPlanCode.GROWTH || + subscription.planCodeSnapshot === StorePassPlanCode.PRO; + if (!isPaidPlan) { + return null; + } + + // Tier 2+ only. Excluding TIER_0/TIER_1 keeps any future higher tier eligible. + if (store.tier === StoreTier.TIER_0 || store.tier === StoreTier.TIER_1) { + return null; + } + + if (!store.isOpen) { + return null; + } + + const periodActive = + subscription.currentPeriodEnd !== null && + subscription.currentPeriodEnd.getTime() > now.getTime(); + + const statusEligible = + subscription.status === StorePassSubscriptionStatus.ACTIVE || + subscription.status === StorePassSubscriptionStatus.CANCELLED_AT_PERIOD_END; + + if (!statusEligible || !periodActive) { + return null; + } + + const isPro = subscription.planCodeSnapshot === StorePassPlanCode.PRO; + return { + planCode: isPro ? StorePassPlanCode.PRO : StorePassPlanCode.GROWTH, + label: isPro ? "StorePass Pro" : "StorePass Growth", + variant: isPro ? "pro" : "growth", + activeUntil: subscription.currentPeriodEnd?.toISOString() ?? null, + }; +} diff --git a/apps/backend/src/domains/money/storepass/storepass-cancellation.service.spec.ts b/apps/backend/src/domains/money/storepass/storepass-cancellation.service.spec.ts new file mode 100644 index 00000000..238b555c --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-cancellation.service.spec.ts @@ -0,0 +1,69 @@ +import { StorePassPlanCode, StorePassSubscriptionStatus } from "@prisma/client"; + +import { StorePassCancellationService } from "./storepass-cancellation.service"; + +function makeHarness(due: Record[]) { + const tx = { + storePassSubscription: { update: jest.fn().mockResolvedValue({}) }, + }; + const prisma = { + storePassSubscription: { + findMany: jest.fn().mockResolvedValue(due), + }, + $transaction: jest.fn().mockImplementation((cb) => cb(tx)), + }; + const plansService = { + getPlanByCode: jest.fn().mockResolvedValue({ id: "plan-free" }), + }; + const entitlements = { + expireEntitlementsForSubscription: jest.fn().mockResolvedValue(undefined), + }; + const service = new StorePassCancellationService( + prisma as never, + plansService as never, + entitlements as never, + ); + return { service, prisma, tx, entitlements, plansService }; +} + +describe("StorePassCancellationService.finalizeExpiredCancellations", () => { + it("finalizes an expired cancel-at-period-end subscription to FREE and expires entitlements", async () => { + const { service, tx, entitlements } = makeHarness([ + { + id: "sub-1", + storeId: "store-1", + status: StorePassSubscriptionStatus.ACTIVE, + cancelAtPeriodEnd: true, + currentPeriodEnd: new Date("2026-07-01T00:00:00.000Z"), + cancelledAt: new Date("2026-06-20T00:00:00.000Z"), + }, + ]); + + const summary = await service.finalizeExpiredCancellations( + new Date("2026-07-07T00:00:00.000Z"), + ); + + expect(summary).toMatchObject({ due: 1, finalized: 1, failed: 0 }); + const data = tx.storePassSubscription.update.mock.calls[0][0].data; + expect(data.status).toBe(StorePassSubscriptionStatus.CANCELLED); + expect(data.planCodeSnapshot).toBe(StorePassPlanCode.FREE); + expect(data.planId).toBe("plan-free"); + expect(data.nextBillingAt).toBeNull(); + expect(entitlements.expireEntitlementsForSubscription).toHaveBeenCalledWith( + "sub-1", + tx, + ); + }); + + it("is a no-op when nothing is due (idempotent)", async () => { + const { service, entitlements, plansService } = makeHarness([]); + + const summary = await service.finalizeExpiredCancellations(); + + expect(summary).toMatchObject({ due: 0, finalized: 0 }); + expect( + entitlements.expireEntitlementsForSubscription, + ).not.toHaveBeenCalled(); + expect(plansService.getPlanByCode).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/domains/money/storepass/storepass-cancellation.service.ts b/apps/backend/src/domains/money/storepass/storepass-cancellation.service.ts new file mode 100644 index 00000000..86792c85 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-cancellation.service.ts @@ -0,0 +1,114 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { + StorePassPlanCode, + StorePassSubscription, + StorePassSubscriptionStatus, +} from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { StorePassEntitlementsService } from "./storepass-entitlements.service"; +import { StorePassPlansService } from "./storepass-plans.service"; + +export interface StorePassCancellationRunSummary { + due: number; + finalized: number; + failed: number; +} + +/** Statuses a subscription can hold while pending a period-end cancellation. */ +const FINALIZABLE_STATUSES: readonly StorePassSubscriptionStatus[] = [ + StorePassSubscriptionStatus.ACTIVE, + StorePassSubscriptionStatus.CANCELLED_AT_PERIOD_END, +]; + +/** + * Finalizes StorePass subscriptions that were set to cancel at period end once + * their paid period has actually elapsed: flips them to CANCELLED, downgrades to + * the FREE plan, stops future billing, and expires their entitlements (with an + * append-only EXPIRE ledger row each). Idempotent — a subsequent run finds + * nothing because the status is no longer finalizable. + */ +@Injectable() +export class StorePassCancellationService { + private readonly logger = new Logger(StorePassCancellationService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly plansService: StorePassPlansService, + private readonly entitlements: StorePassEntitlementsService, + ) {} + + async finalizeExpiredCancellations( + now: Date = new Date(), + ): Promise { + const due = await this.prisma.storePassSubscription.findMany({ + where: { + cancelAtPeriodEnd: true, + status: { in: [...FINALIZABLE_STATUSES] }, + currentPeriodEnd: { lte: now }, + }, + }); + + const summary: StorePassCancellationRunSummary = { + due: due.length, + finalized: 0, + failed: 0, + }; + + const freePlan = due.length + ? await this.plansService.getPlanByCode(StorePassPlanCode.FREE) + : null; + + for (const subscription of due) { + try { + await this.finalizeOne(subscription, freePlan!.id, now); + summary.finalized += 1; + } catch (error) { + summary.failed += 1; + this.logger.error( + `StorePass cancellation finalize failed for subscription ${subscription.id}: ${this.message(error)}`, + ); + } + } + + if (summary.due > 0) { + this.logger.log( + `StorePass cancellations: ${summary.due} due, ${summary.finalized} finalized, ${summary.failed} failed.`, + ); + } + return summary; + } + + private async finalizeOne( + subscription: StorePassSubscription, + freePlanId: string, + now: Date, + ): Promise { + await this.prisma.$transaction(async (tx) => { + await tx.storePassSubscription.update({ + where: { id: subscription.id }, + data: { + status: StorePassSubscriptionStatus.CANCELLED, + planId: freePlanId, + planCodeSnapshot: StorePassPlanCode.FREE, + cancelledAt: subscription.cancelledAt ?? now, + cancelAtPeriodEnd: false, + nextBillingAt: null, + }, + }); + + await this.entitlements.expireEntitlementsForSubscription( + subscription.id, + tx, + ); + }); + + this.logger.log( + `Finalized StorePass cancellation for subscription ${subscription.id} (store ${subscription.storeId}); downgraded to FREE.`, + ); + } + + private message(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +} diff --git a/apps/backend/src/domains/money/storepass/storepass-charge.service.spec.ts b/apps/backend/src/domains/money/storepass/storepass-charge.service.spec.ts new file mode 100644 index 00000000..5576ae82 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-charge.service.spec.ts @@ -0,0 +1,157 @@ +import { + StorePassBillingAttemptStatus, + StorePassInvoiceStatus, + StorePassPlanCode, + StorePassSubscriptionStatus, +} from "@prisma/client"; + +import { StorePassChargeService } from "./storepass-charge.service"; + +const invoice = { + id: "inv-1", + storeId: "store-1", + amountKobo: 450000n, + planCodeSnapshot: StorePassPlanCode.GROWTH, +} as never; +const subscription = { + id: "sub-1", + ownerUserId: "user-1", + pastDueAt: null, +} as never; +const paymentMethod = { nombaTokenKey: "tok_1" } as never; + +function makeHarness(chargeStatus: "SUCCESS" | "PENDING" | "FAILED") { + let created: Record = {}; + const prisma = { + storePassBillingAttempt: { + create: jest.fn().mockImplementation(({ data }) => { + created = { id: "att-1", storeId: "store-1", ...data }; + return created; + }), + update: jest.fn().mockImplementation(({ data }) => ({ + id: "att-1", + ...created, + ...data, + })), + }, + storePassInvoice: { update: jest.fn().mockResolvedValue({}) }, + storePassSubscription: { update: jest.fn().mockResolvedValue({}) }, + $transaction: jest.fn().mockImplementation((arg) => Promise.all(arg)), + }; + const config = { get: jest.fn().mockReturnValue("https://app.example") }; + const billingProvider = { + chargeTokenizedCard: jest.fn().mockResolvedValue({ + provider: "NOMBA", + reference: "storepass_inv_inv-1_1", + status: chargeStatus, + rawProviderReference: "txn-1", + }), + }; + const service = new StorePassChargeService( + prisma as never, + config as never, + billingProvider as never, + ); + return { service, prisma, billingProvider, getCreated: () => created }; +} + +describe("StorePassChargeService.chargeSavedCard", () => { + it("creates a PROCESSING attempt with the canonical order reference and charges with an idempotency key", async () => { + const { service, prisma, billingProvider } = makeHarness("PENDING"); + + await service.chargeSavedCard({ + invoice, + subscription, + paymentMethod, + payerUser: { id: "user-1", email: "owner@example.com" }, + purpose: "STOREPASS_RENEWAL", + attemptNumber: 1, + retryCount: 0, + }); + + const createData = + prisma.storePassBillingAttempt.create.mock.calls[0][0].data; + expect(createData.orderReference).toBe("storepass_inv_inv-1_1"); + expect(createData.status).toBe(StorePassBillingAttemptStatus.PROCESSING); + expect(createData.idempotencyKey).toBeDefined(); + + // Outbound charge uses the attempt's idempotency key. + const chargeArg = billingProvider.chargeTokenizedCard.mock.calls[0][0]; + expect(chargeArg.reference).toBe("storepass_inv_inv-1_1"); + expect(chargeArg.tokenKey).toBe("tok_1"); + expect(chargeArg.idempotencyKey).toBe(createData.idempotencyKey); + expect(chargeArg.metadata.purpose).toBe("STOREPASS_RENEWAL"); + + // PENDING leaves the attempt for webhook/reconciliation — no settle. + expect(prisma.storePassSubscription.update).not.toHaveBeenCalled(); + }); + + it("leaves the attempt PROCESSING on a synchronous SUCCESS (webhook confirms)", async () => { + const { service, prisma } = makeHarness("SUCCESS"); + + await service.chargeSavedCard({ + invoice, + subscription, + paymentMethod, + payerUser: { id: "user-1", email: "owner@example.com" }, + purpose: "STOREPASS_RENEWAL", + attemptNumber: 1, + retryCount: 0, + }); + + expect(prisma.storePassInvoice.update).not.toHaveBeenCalled(); + expect(prisma.storePassSubscription.update).not.toHaveBeenCalled(); + }); + + it("settles a synchronous FAILED and schedules the first retry (PAST_DUE)", async () => { + const { service, prisma } = makeHarness("FAILED"); + + await service.chargeSavedCard({ + invoice, + subscription, + paymentMethod, + payerUser: { id: "user-1", email: "owner@example.com" }, + purpose: "STOREPASS_RENEWAL", + attemptNumber: 1, + retryCount: 0, + }); + + const attemptData = + prisma.storePassBillingAttempt.update.mock.calls.at(-1)![0].data; + expect(attemptData.status).toBe(StorePassBillingAttemptStatus.FAILED); + expect(attemptData.nextRetryAt).toBeInstanceOf(Date); + // 5-minute first backoff, measured from the recorded failure time. + expect( + attemptData.nextRetryAt.getTime() - attemptData.failedAt.getTime(), + ).toBe(5 * 60_000); + expect(attemptData.needsReview).toBe(false); + expect(prisma.storePassInvoice.update.mock.calls[0][0].data.status).toBe( + StorePassInvoiceStatus.FAILED, + ); + expect( + prisma.storePassSubscription.update.mock.calls[0][0].data.status, + ).toBe(StorePassSubscriptionStatus.PAST_DUE); + }); + + it("gives up and marks the subscription INACTIVE when the retry budget is exhausted", async () => { + const { service, prisma } = makeHarness("FAILED"); + + await service.chargeSavedCard({ + invoice, + subscription, + paymentMethod, + payerUser: { id: "user-1", email: "owner@example.com" }, + purpose: "STOREPASS_RENEWAL", + attemptNumber: 4, + retryCount: 3, + }); + + const attemptData = + prisma.storePassBillingAttempt.update.mock.calls.at(-1)![0].data; + expect(attemptData.nextRetryAt).toBeNull(); + expect(attemptData.needsReview).toBe(true); + expect( + prisma.storePassSubscription.update.mock.calls[0][0].data.status, + ).toBe(StorePassSubscriptionStatus.INACTIVE); + }); +}); diff --git a/apps/backend/src/domains/money/storepass/storepass-charge.service.ts b/apps/backend/src/domains/money/storepass/storepass-charge.service.ts new file mode 100644 index 00000000..7e561329 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-charge.service.ts @@ -0,0 +1,215 @@ +import { randomUUID } from "crypto"; + +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + StorePassBillingAttempt, + StorePassBillingAttemptStatus, + StorePassInvoice, + StorePassInvoiceStatus, + StorePassPaymentMethod, + StorePassPaymentProvider, + StorePassSubscription, + StorePassSubscriptionStatus, +} from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { + SUBSCRIPTION_BILLING_PROVIDER, + SubscriptionBillingProvider, + SubscriptionBillingPurpose, +} from "../subscription-billing/providers/subscription-billing-provider.interface"; +import { + buildStorePassOrderReference, + buildStorePassCheckoutReturnUrl, + computeNextRetryAt, + STOREPASS_CURRENCY, + STOREPASS_MAX_RETRIES, +} from "./storepass.constants"; + +export interface ChargeSavedCardInput { + invoice: StorePassInvoice; + subscription: StorePassSubscription; + /** Default, active card with a non-null nombaTokenKey (asserted by caller). */ + paymentMethod: StorePassPaymentMethod; + payerUser: { id: string; email: string }; + purpose: SubscriptionBillingPurpose; + attemptNumber: number; + /** Retries already performed for this invoice (0 for the initial renewal). */ + retryCount: number; +} + +/** + * Shared "create a billing attempt then charge the saved tokenized card" + * primitive used by both renewals and retries. StorePass never touches Nomba + * directly — this service drives the provider-agnostic + * SUBSCRIPTION_BILLING_PROVIDER (Nomba adapter). Confirmation of success is NOT + * done here: a synchronous SUCCESS/PENDING leaves the attempt PROCESSING for the + * verified webhook or the reconciliation sweep to finalize (one activation path). + * Only a definitive synchronous FAILED is settled here (fail + schedule retry). + */ +@Injectable() +export class StorePassChargeService { + private readonly logger = new Logger(StorePassChargeService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly config: ConfigService, + @Inject(SUBSCRIPTION_BILLING_PROVIDER) + private readonly billingProvider: SubscriptionBillingProvider, + ) {} + + async chargeSavedCard( + input: ChargeSavedCardInput, + ): Promise { + const { invoice, subscription, paymentMethod, payerUser, purpose } = input; + const tokenKey = paymentMethod.nombaTokenKey; + if (!tokenKey) { + throw new Error("payment method has no saved card token"); + } + + const now = new Date(); + const orderReference = buildStorePassOrderReference( + invoice.id, + input.attemptNumber, + ); + + // Create the PROCESSING attempt before any provider call, with a fresh + // idempotency key used for the outbound tokenized-card POST. + const attempt = await this.prisma.storePassBillingAttempt.create({ + data: { + invoiceId: invoice.id, + storeId: invoice.storeId, + subscriptionId: subscription.id, + provider: StorePassPaymentProvider.NOMBA, + status: StorePassBillingAttemptStatus.PROCESSING, + amountKobo: invoice.amountKobo, + currency: STOREPASS_CURRENCY, + orderReference, + idempotencyKey: randomUUID(), + attemptNumber: input.attemptNumber, + retryCount: input.retryCount, + maxRetries: STOREPASS_MAX_RETRIES, + startedAt: now, + lastRetryAt: input.retryCount > 0 ? now : null, + }, + }); + + let result; + try { + result = await this.billingProvider.chargeTokenizedCard({ + storeId: invoice.storeId, + storeOwnerUserId: payerUser.id, + planCode: invoice.planCodeSnapshot, + reference: orderReference, + amountKobo: invoice.amountKobo, + email: payerUser.email, + tokenKey, + callbackUrl: this.buildCallbackUrl(), + idempotencyKey: attempt.idempotencyKey, + metadata: { + storeId: invoice.storeId, + planCode: invoice.planCodeSnapshot, + invoiceId: invoice.id, + subscriptionId: subscription.id, + billingAttemptId: attempt.id, + purpose, + }, + }); + } catch (error) { + // Unknown outcome: leave the attempt PROCESSING so the reconciliation + // sweep resolves the true state via provider lookup. Never fail/retry here + // — a blind retry with a new order reference could double-charge. + const message = this.errorMessage(error).slice(0, 500); + this.logger.warn( + `StorePass ${purpose} charge threw for attempt ${attempt.id} (order ${orderReference}); left PROCESSING for reconciliation: ${message}`, + ); + return this.prisma.storePassBillingAttempt.update({ + where: { id: attempt.id }, + data: { providerResponseMessage: message }, + }); + } + + if (result.status === "FAILED") { + return this.settleFailedAttempt( + attempt, + invoice, + subscription, + now, + "provider declined tokenized-card charge", + ); + } + + // SUCCESS or PENDING: confirmation is owned by the webhook / reconciliation. + return this.prisma.storePassBillingAttempt.update({ + where: { id: attempt.id }, + data: { providerTransactionId: result.rawProviderReference ?? null }, + }); + } + + /** + * Settles a definitively failed attempt: marks the attempt + invoice FAILED and + * either schedules the next retry (subscription PAST_DUE) or, when the retry + * budget is exhausted, flags the attempt for review and marks the subscription + * INACTIVE. Retry policy lives in storepass.constants. Shared by the charge + * flow (synchronous decline) and reconciliation (provider lookup returned + * FAILED for a stuck attempt). + */ + async settleFailedAttempt( + attempt: StorePassBillingAttempt, + invoice: StorePassInvoice, + subscription: StorePassSubscription, + now: Date, + reason: string, + ): Promise { + const nextRetryAt = computeNextRetryAt(attempt.retryCount, now); + const exhausted = nextRetryAt === null; + + const [updatedAttempt] = await this.prisma.$transaction([ + this.prisma.storePassBillingAttempt.update({ + where: { id: attempt.id }, + data: { + status: StorePassBillingAttemptStatus.FAILED, + failedAt: now, + failureReason: reason.slice(0, 500), + nextRetryAt, + needsReview: exhausted, + }, + }), + this.prisma.storePassInvoice.update({ + where: { id: invoice.id }, + data: { status: StorePassInvoiceStatus.FAILED, failedAt: now }, + }), + this.prisma.storePassSubscription.update({ + where: { id: subscription.id }, + data: exhausted + ? { + status: StorePassSubscriptionStatus.INACTIVE, + pastDueAt: subscription.pastDueAt ?? now, + } + : { + status: StorePassSubscriptionStatus.PAST_DUE, + pastDueAt: subscription.pastDueAt ?? now, + }, + }), + ]); + + this.logger.warn( + `StorePass charge FAILED for attempt ${attempt.id} (invoice ${invoice.id}); ` + + (exhausted + ? "retry budget exhausted, subscription INACTIVE, flagged for review." + : `next retry at ${nextRetryAt?.toISOString()}, subscription PAST_DUE.`), + ); + return updatedAttempt; + } + + private buildCallbackUrl(): string { + const frontendUrl = + this.config.get("app.frontendUrl") ?? "http://localhost:3000"; + return buildStorePassCheckoutReturnUrl(frontendUrl); + } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +} diff --git a/apps/backend/src/domains/money/storepass/storepass-checkout.service.spec.ts b/apps/backend/src/domains/money/storepass/storepass-checkout.service.spec.ts new file mode 100644 index 00000000..0755fe6b --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-checkout.service.spec.ts @@ -0,0 +1,197 @@ +import { + StorePassInvoiceStatus, + StorePassPlanCode, + StorePassSubscriptionStatus, + StoreTier, +} from "@prisma/client"; + +import { StorePassService } from "./storepass.service"; + +function makeCheckoutHarness(overrides?: { + storeUserId?: string; + tier?: StoreTier; +}) { + const store = { + id: "store-1", + userId: overrides?.storeUserId ?? "user-1", + tier: overrides?.tier ?? StoreTier.TIER_2, + isOpen: true, + storeHandle: "ada_store", + storeName: "Ada Store", + }; + const subscription = { + id: "sub-1", + storeId: "store-1", + ownerUserId: "user-1", + planCodeSnapshot: StorePassPlanCode.FREE, + status: StorePassSubscriptionStatus.FREE, + }; + const user = { + id: "user-1", + email: "ada@example.com", + username: "ada", + displayName: null, + firstName: "Ada", + lastName: "Obi", + }; + const plan = { + id: "plan-growth", + code: StorePassPlanCode.GROWTH, + priceKobo: 450000n, + }; + + const tx = { + storePassInvoice: { + create: jest.fn().mockImplementation(({ data }) => ({ + id: "inv-1", + ...data, + })), + }, + storePassBillingAttempt: { + create: jest.fn().mockImplementation(({ data }) => ({ + id: "att-1", + ...data, + })), + }, + }; + + const prisma = { + storeProfile: { findUnique: jest.fn().mockResolvedValue(store) }, + storePassSubscription: { + findUnique: jest.fn().mockResolvedValue(subscription), + }, + user: { findUnique: jest.fn().mockResolvedValue(user) }, + storePassBillingAttempt: { update: jest.fn().mockResolvedValue({}) }, + $transaction: jest + .fn() + .mockImplementation(async (cb: (client: unknown) => unknown) => cb(tx)), + }; + + const plansService = { + getPlanByCode: jest.fn().mockResolvedValue(plan), + }; + const entitlementsService = { + grantEntitlementsForSubscription: jest.fn(), + }; + const config = { get: jest.fn().mockReturnValue("https://app.twizrr.com") }; + const billingProvider = { + name: "NOMBA", + initializeSubscriptionPayment: jest.fn().mockResolvedValue({ + provider: "NOMBA", + reference: "storepass_inv_inv-1_1", + authorizationUrl: "https://checkout.example/abc", + rawProviderReference: "ord_1", + }), + }; + + const service = new StorePassService( + prisma as never, + plansService as never, + entitlementsService as never, + config as never, + billingProvider as never, + ); + + return { service, prisma, plansService, billingProvider, tx }; +} + +describe("StorePassService.startStorePassCheckout", () => { + it("creates PENDING invoice + PROCESSING attempt and returns the checkout URL", async () => { + const { service, billingProvider, tx } = makeCheckoutHarness(); + + const result = await service.startStorePassCheckout({ + userId: "user-1", + storeId: "store-1", + planCode: "GROWTH", + purpose: "STOREPASS_SUBSCRIPTION", + }); + + expect(result).toEqual({ + checkoutUrl: "https://checkout.example/abc", + orderReference: "storepass_inv_inv-1_1", + invoiceId: "inv-1", + billingAttemptId: "att-1", + planCode: StorePassPlanCode.GROWTH, + amountKobo: 450000n, + }); + + const invoiceData = tx.storePassInvoice.create.mock.calls[0][0].data; + expect(invoiceData.status).toBe(StorePassInvoiceStatus.PENDING); + expect(invoiceData.amountKobo).toBe(450000n); + expect(invoiceData.planCodeSnapshot).toBe(StorePassPlanCode.GROWTH); + expect(invoiceData.paidByUserId).toBe("user-1"); + + const attemptData = tx.storePassBillingAttempt.create.mock.calls[0][0].data; + expect(attemptData.orderReference).toBe("storepass_inv_inv-1_1"); + expect(typeof attemptData.idempotencyKey).toBe("string"); + expect(attemptData.idempotencyKey.length).toBeGreaterThan(0); + + const initArg = + billingProvider.initializeSubscriptionPayment.mock.calls[0][0]; + expect(initArg.amountKobo).toBe(450000n); + expect(typeof initArg.amountKobo).toBe("bigint"); + expect(initArg.reference).toBe("storepass_inv_inv-1_1"); + expect(initArg.metadata).toMatchObject({ + storeId: "store-1", + planCode: StorePassPlanCode.GROWTH, + invoiceId: "inv-1", + subscriptionId: "sub-1", + billingAttemptId: "att-1", + purpose: "STOREPASS_SUBSCRIPTION", + }); + }); + + it("rejects FREE with no provider call", async () => { + const { service, billingProvider } = makeCheckoutHarness(); + + await expect( + service.startStorePassCheckout({ + userId: "user-1", + storeId: "store-1", + planCode: "FREE" as never, + purpose: "STOREPASS_SUBSCRIPTION", + }), + ).rejects.toMatchObject({ + response: { code: "STOREPASS_PLAN_NOT_BILLABLE" }, + }); + expect( + billingProvider.initializeSubscriptionPayment, + ).not.toHaveBeenCalled(); + }); + + it("blocks a user who does not own the store", async () => { + const { service, billingProvider } = makeCheckoutHarness({ + storeUserId: "other-owner", + }); + + await expect( + service.startStorePassCheckout({ + userId: "user-1", + storeId: "store-1", + planCode: "GROWTH", + purpose: "STOREPASS_SUBSCRIPTION", + }), + ).rejects.toMatchObject({ response: { code: "NOT_STORE_OWNER" } }); + expect( + billingProvider.initializeSubscriptionPayment, + ).not.toHaveBeenCalled(); + }); + + it("blocks a store below TIER_2", async () => { + const { service, billingProvider } = makeCheckoutHarness({ + tier: StoreTier.TIER_1, + }); + + await expect( + service.startStorePassCheckout({ + userId: "user-1", + storeId: "store-1", + planCode: "PRO", + purpose: "STOREPASS_SUBSCRIPTION", + }), + ).rejects.toMatchObject({ response: { code: "TIER_REQUIREMENT_FAILED" } }); + expect( + billingProvider.initializeSubscriptionPayment, + ).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/domains/money/storepass/storepass-entitlements.service.spec.ts b/apps/backend/src/domains/money/storepass/storepass-entitlements.service.spec.ts new file mode 100644 index 00000000..808856b0 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-entitlements.service.spec.ts @@ -0,0 +1,268 @@ +import { + StorePassEntitlementLedgerType, + StorePassEntitlementType, + StorePassPlanCode, +} from "@prisma/client"; + +import { StorePassEntitlementsService } from "./storepass-entitlements.service"; + +function balanceFor( + preview: { + entitlementType: StorePassEntitlementType; + balance: number | null; + }[], + type: StorePassEntitlementType, +): number | null | undefined { + return preview.find((entry) => entry.entitlementType === type)?.balance; +} + +function isEnabledFor( + preview: { entitlementType: StorePassEntitlementType; isEnabled: boolean }[], + type: StorePassEntitlementType, +): boolean | undefined { + return preview.find((entry) => entry.entitlementType === type)?.isEnabled; +} + +describe("StorePassEntitlementsService.previewEntitlementsForPlan", () => { + const service = new StorePassEntitlementsService({} as never); + + it("previews the FREE plan with no credits and analytics disabled", () => { + const { planCode, entitlements } = service.previewEntitlementsForPlan( + StorePassPlanCode.FREE, + ); + + expect(planCode).toBe(StorePassPlanCode.FREE); + expect( + isEnabledFor(entitlements, StorePassEntitlementType.ANALYTICS_ACCESS), + ).toBe(false); + expect( + balanceFor( + entitlements, + StorePassEntitlementType.FEATURED_PLACEMENT_CREDIT, + ), + ).toBe(0); + expect( + balanceFor(entitlements, StorePassEntitlementType.CAMPAIGN_BOOST_CREDIT), + ).toBe(0); + expect( + balanceFor(entitlements, StorePassEntitlementType.WIZZA_DISCOVERY_CREDIT), + ).toBe(0); + }); + + it("previews the GROWTH plan credits and access flags", () => { + const { entitlements } = service.previewEntitlementsForPlan( + StorePassPlanCode.GROWTH, + ); + + expect( + isEnabledFor(entitlements, StorePassEntitlementType.ANALYTICS_ACCESS), + ).toBe(true); + expect( + isEnabledFor( + entitlements, + StorePassEntitlementType.PRODUCT_VISIBILITY_INSIGHTS, + ), + ).toBe(true); + expect( + isEnabledFor(entitlements, StorePassEntitlementType.PRIORITY_SUPPORT), + ).toBe(false); + expect( + balanceFor( + entitlements, + StorePassEntitlementType.FEATURED_PLACEMENT_CREDIT, + ), + ).toBe(3); + expect( + balanceFor(entitlements, StorePassEntitlementType.CAMPAIGN_BOOST_CREDIT), + ).toBe(5); + expect( + balanceFor(entitlements, StorePassEntitlementType.WIZZA_DISCOVERY_CREDIT), + ).toBe(20); + }); + + it("previews the PRO plan with higher credits and priority support", () => { + const { entitlements } = service.previewEntitlementsForPlan( + StorePassPlanCode.PRO, + ); + + expect( + isEnabledFor(entitlements, StorePassEntitlementType.PRIORITY_SUPPORT), + ).toBe(true); + expect( + balanceFor( + entitlements, + StorePassEntitlementType.FEATURED_PLACEMENT_CREDIT, + ), + ).toBe(10); + expect( + balanceFor(entitlements, StorePassEntitlementType.CAMPAIGN_BOOST_CREDIT), + ).toBe(20); + expect( + balanceFor(entitlements, StorePassEntitlementType.WIZZA_DISCOVERY_CREDIT), + ).toBe(75); + }); +}); + +describe("StorePassEntitlementsService.grantEntitlementsForSubscription", () => { + it("upserts entitlements and writes append-only GRANT ledger entries", async () => { + const entitlementUpsert = jest + .fn() + .mockImplementation(({ create }) => ({ id: "ent-x", ...create })); + const ledgerCreate = jest.fn().mockResolvedValue({ id: "ledger-x" }); + const tx = { + storePassEntitlement: { + findUnique: jest.fn().mockResolvedValue(null), + upsert: entitlementUpsert, + }, + storePassEntitlementLedger: { create: ledgerCreate }, + }; + const prisma = { + storePassSubscription: { + findUnique: jest.fn().mockResolvedValue({ + id: "sub-1", + storeId: "store-1", + planCodeSnapshot: StorePassPlanCode.GROWTH, + currentPeriodStart: null, + currentPeriodEnd: null, + }), + }, + $transaction: jest.fn().mockImplementation((cb) => cb(tx)), + }; + const service = new StorePassEntitlementsService(prisma as never); + + const granted = await service.grantEntitlementsForSubscription("sub-1"); + + // GROWTH grants 6 entitlement types. + expect(granted).toHaveLength(6); + expect(entitlementUpsert).toHaveBeenCalledTimes(6); + expect(ledgerCreate).toHaveBeenCalledTimes(6); + for (const call of ledgerCreate.mock.calls) { + expect(call[0].data.type).toBe(StorePassEntitlementLedgerType.GRANT); + } + }); +}); + +function makeConsumeHarness( + entitlement: Record | null, + existingUseLedger: Record | null = null, +) { + const entitlementUpdate = jest.fn().mockResolvedValue({}); + const ledgerCreate = jest.fn().mockResolvedValue({ id: "ledger-use" }); + const tx = { + storePassEntitlement: { + findUnique: jest.fn().mockResolvedValue(entitlement), + update: entitlementUpdate, + }, + storePassEntitlementLedger: { + findFirst: jest.fn().mockResolvedValue(existingUseLedger), + create: ledgerCreate, + }, + }; + const prisma = { + $transaction: jest.fn().mockImplementation((cb) => cb(tx)), + }; + const service = new StorePassEntitlementsService(prisma as never); + return { service, tx, entitlementUpdate, ledgerCreate }; +} + +const CREDIT = StorePassEntitlementType.WIZZA_DISCOVERY_CREDIT; + +describe("StorePassEntitlementsService.consumeEntitlement", () => { + const baseEntitlement = { + id: "ent-1", + storeId: "store-1", + subscriptionId: "sub-1", + entitlementType: CREDIT, + balance: 20, + isEnabled: true, + periodEnd: new Date(Date.now() + 86_400_000), + }; + + it("decrements balance and writes a USE ledger entry", async () => { + const { service, entitlementUpdate, ledgerCreate } = makeConsumeHarness({ + ...baseEntitlement, + }); + + const result = await service.consumeEntitlement({ + storeId: "store-1", + entitlementType: CREDIT, + amount: 3, + createdByUserId: "user-1", + }); + + expect(result.balance).toBe(17); + expect(result.consumed).toBe(3); + expect(result.alreadyApplied).toBe(false); + expect(entitlementUpdate).toHaveBeenCalledWith({ + where: { id: "ent-1" }, + data: { balance: 17 }, + }); + const ledger = ledgerCreate.mock.calls[0][0].data; + expect(ledger.type).toBe(StorePassEntitlementLedgerType.USE); + expect(ledger.balanceBefore).toBe(20); + expect(ledger.balanceAfter).toBe(17); + }); + + it("is a no-op when the same reference was already applied", async () => { + const { service, entitlementUpdate, ledgerCreate } = makeConsumeHarness( + { ...baseEntitlement }, + { id: "prior-use" }, + ); + + const result = await service.consumeEntitlement({ + storeId: "store-1", + entitlementType: CREDIT, + amount: 3, + referenceType: "CAMPAIGN", + referenceId: "camp-1", + }); + + expect(result.alreadyApplied).toBe(true); + expect(result.consumed).toBe(0); + expect(result.balance).toBe(20); + expect(entitlementUpdate).not.toHaveBeenCalled(); + expect(ledgerCreate).not.toHaveBeenCalled(); + }); + + it("rejects consuming an access flag (not a credit)", async () => { + const { service } = makeConsumeHarness(null); + await expect( + service.consumeEntitlement({ + storeId: "store-1", + entitlementType: StorePassEntitlementType.ANALYTICS_ACCESS, + amount: 1, + }), + ).rejects.toMatchObject({ + response: { code: "STOREPASS_ENTITLEMENT_NOT_A_CREDIT" }, + }); + }); + + it("rejects an insufficient balance", async () => { + const { service } = makeConsumeHarness({ ...baseEntitlement, balance: 1 }); + await expect( + service.consumeEntitlement({ + storeId: "store-1", + entitlementType: CREDIT, + amount: 5, + }), + ).rejects.toMatchObject({ + response: { code: "STOREPASS_ENTITLEMENT_INSUFFICIENT" }, + }); + }); + + it("rejects a disabled entitlement", async () => { + const { service } = makeConsumeHarness({ + ...baseEntitlement, + isEnabled: false, + }); + await expect( + service.consumeEntitlement({ + storeId: "store-1", + entitlementType: CREDIT, + amount: 1, + }), + ).rejects.toMatchObject({ + response: { code: "STOREPASS_ENTITLEMENT_DISABLED" }, + }); + }); +}); diff --git a/apps/backend/src/domains/money/storepass/storepass-entitlements.service.ts b/apps/backend/src/domains/money/storepass/storepass-entitlements.service.ts new file mode 100644 index 00000000..cb30c71c --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-entitlements.service.ts @@ -0,0 +1,420 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { + Prisma, + StorePassEntitlement, + StorePassEntitlementLedgerType, + StorePassEntitlementType, + StorePassPlanCode, + StorePassSubscription, +} from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { + getStorePassPlanDefinition, + isCreditEntitlementType, +} from "./storepass.constants"; +import { StorePassEntitlementUsageView } from "./types/storepass-store-mode.types"; +import { + StorePassEntitlementPreview, + StorePassPlanEntitlementsPreview, +} from "./types/storepass.types"; + +export interface ConsumeEntitlementInput { + storeId: string; + entitlementType: StorePassEntitlementType; + amount: number; + referenceType?: string; + referenceId?: string; + reason?: string; + createdByUserId?: string; +} + +export interface ConsumeEntitlementResult { + entitlementType: StorePassEntitlementType; + balance: number; + consumed: number; + /** True when a prior USE with the same reference already applied this consume. */ + alreadyApplied: boolean; +} + +@Injectable() +export class StorePassEntitlementsService { + private readonly logger = new Logger(StorePassEntitlementsService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** + * Pure, DB-free preview of the entitlements a plan grants. Used by future + * API/frontend work to show what a plan includes before subscribing. + */ + previewEntitlementsForPlan( + planCode: StorePassPlanCode, + ): StorePassPlanEntitlementsPreview { + const definition = getStorePassPlanDefinition(planCode); + const entitlements: StorePassEntitlementPreview[] = + definition.entitlements.map((entitlement) => ({ + entitlementType: entitlement.entitlementType, + balance: entitlement.balance, + isEnabled: entitlement.isEnabled, + })); + return { planCode, entitlements }; + } + + /** + * Applies a subscription's plan entitlements to the store's current + * entitlement balances and records an append-only GRANT ledger entry for + * each. Idempotent per period boundary. No external provider calls. + * + * When an existing transaction client is passed (for example from the webhook + * activation flow), the grant runs inside that transaction so entitlements are + * granted atomically with the invoice/subscription state change. Otherwise it + * opens its own transaction. + */ + async grantEntitlementsForSubscription( + subscriptionId: string, + tx?: Prisma.TransactionClient, + ): Promise { + const client = tx ?? this.prisma; + const subscription = await client.storePassSubscription.findUnique({ + where: { id: subscriptionId }, + }); + if (!subscription) { + throw new NotFoundException({ + message: "StorePass subscription not found", + code: "STOREPASS_SUBSCRIPTION_NOT_FOUND", + }); + } + + if (tx) { + return this.grantWithClient(tx, subscription); + } + return this.prisma.$transaction((innerTx) => + this.grantWithClient(innerTx, subscription), + ); + } + + private async grantWithClient( + tx: Prisma.TransactionClient, + subscription: StorePassSubscription, + ): Promise { + const definition = getStorePassPlanDefinition( + subscription.planCodeSnapshot, + ); + + const granted: StorePassEntitlement[] = []; + + for (const entitlementDefault of definition.entitlements) { + const existing = await tx.storePassEntitlement.findUnique({ + where: { + storeId_entitlementType: { + storeId: subscription.storeId, + entitlementType: entitlementDefault.entitlementType, + }, + }, + }); + + const balanceBefore = existing?.balance ?? null; + + const entitlement = await tx.storePassEntitlement.upsert({ + where: { + storeId_entitlementType: { + storeId: subscription.storeId, + entitlementType: entitlementDefault.entitlementType, + }, + }, + update: { + subscriptionId: subscription.id, + planCodeSnapshot: subscription.planCodeSnapshot, + balance: entitlementDefault.balance, + isEnabled: entitlementDefault.isEnabled, + periodStart: subscription.currentPeriodStart, + periodEnd: subscription.currentPeriodEnd, + }, + create: { + storeId: subscription.storeId, + subscriptionId: subscription.id, + planCodeSnapshot: subscription.planCodeSnapshot, + entitlementType: entitlementDefault.entitlementType, + balance: entitlementDefault.balance, + isEnabled: entitlementDefault.isEnabled, + periodStart: subscription.currentPeriodStart, + periodEnd: subscription.currentPeriodEnd, + }, + }); + + // Append-only ledger: record the grant. Never updated or deleted. + await tx.storePassEntitlementLedger.create({ + data: { + storeId: subscription.storeId, + subscriptionId: subscription.id, + entitlementId: entitlement.id, + type: StorePassEntitlementLedgerType.GRANT, + entitlementType: entitlementDefault.entitlementType, + amount: entitlementDefault.balance, + balanceBefore, + balanceAfter: entitlementDefault.balance, + reason: `Granted for plan ${subscription.planCodeSnapshot}`, + referenceType: "STOREPASS_SUBSCRIPTION", + referenceId: subscription.id, + }, + }); + + granted.push(entitlement); + } + + this.logger.log( + `Granted ${granted.length} StorePass entitlements for subscription ${subscription.id}.`, + ); + return granted; + } + + /** + * Expires all of a store's StorePass entitlements when a paid subscription is + * finalized (cancel-at-period-end). Zeroes credit balances, disables access + * flags, and appends an append-only EXPIRE ledger row per entitlement. + * Idempotent: entitlements already zeroed/disabled still record the transition + * truthfully (balanceBefore == balanceAfter == 0). No external provider calls. + */ + async expireEntitlementsForSubscription( + subscriptionId: string, + tx?: Prisma.TransactionClient, + ): Promise { + const client = tx ?? this.prisma; + const subscription = await client.storePassSubscription.findUnique({ + where: { id: subscriptionId }, + }); + if (!subscription) { + throw new NotFoundException({ + message: "StorePass subscription not found", + code: "STOREPASS_SUBSCRIPTION_NOT_FOUND", + }); + } + + if (tx) { + await this.expireWithClient(tx, subscription); + return; + } + await this.prisma.$transaction((innerTx) => + this.expireWithClient(innerTx, subscription), + ); + } + + private async expireWithClient( + tx: Prisma.TransactionClient, + subscription: StorePassSubscription, + ): Promise { + const entitlements = await tx.storePassEntitlement.findMany({ + where: { storeId: subscription.storeId }, + }); + + for (const entitlement of entitlements) { + const isCredit = isCreditEntitlementType(entitlement.entitlementType); + const balanceBefore = entitlement.balance; + const balanceAfter = isCredit ? 0 : entitlement.balance; + + await tx.storePassEntitlement.update({ + where: { id: entitlement.id }, + data: { + balance: balanceAfter, + isEnabled: false, + subscriptionId: subscription.id, + }, + }); + + await tx.storePassEntitlementLedger.create({ + data: { + storeId: subscription.storeId, + subscriptionId: subscription.id, + entitlementId: entitlement.id, + type: StorePassEntitlementLedgerType.EXPIRE, + entitlementType: entitlement.entitlementType, + amount: isCredit ? (balanceBefore ?? 0) : null, + balanceBefore, + balanceAfter, + reason: `Expired on cancellation of plan ${subscription.planCodeSnapshot}`, + referenceType: "STOREPASS_SUBSCRIPTION", + referenceId: subscription.id, + }, + }); + } + + this.logger.log( + `Expired ${entitlements.length} StorePass entitlements for subscription ${subscription.id}.`, + ); + } + + /** + * Consumes credit from a store's entitlement balance and records an + * append-only USE ledger entry (the auditable proof that a StorePass benefit + * was actually used). Only credit-type entitlements can be consumed. Rejects + * disabled, expired, or insufficient-balance consumes with safe coded errors. + * + * Idempotent when a (referenceType, referenceId) pair is supplied: a repeat + * call with the same reference is a no-op that returns the current balance, so + * a retried caller never double-spends a credit. + */ + async consumeEntitlement( + input: ConsumeEntitlementInput, + ): Promise { + const { storeId, entitlementType, amount } = input; + + if (!Number.isInteger(amount) || amount < 1) { + throw new BadRequestException({ + message: "Consume amount must be a positive integer", + code: "STOREPASS_ENTITLEMENT_INVALID_AMOUNT", + }); + } + if (!isCreditEntitlementType(entitlementType)) { + throw new BadRequestException({ + message: "This entitlement is an access flag, not a consumable credit", + code: "STOREPASS_ENTITLEMENT_NOT_A_CREDIT", + }); + } + + return this.prisma.$transaction(async (tx) => { + const entitlement = await tx.storePassEntitlement.findUnique({ + where: { storeId_entitlementType: { storeId, entitlementType } }, + }); + if (!entitlement) { + throw new NotFoundException({ + message: "Entitlement not found for this store", + code: "STOREPASS_ENTITLEMENT_NOT_FOUND", + }); + } + + // Idempotency: a prior USE with the same reference already applied. + if (input.referenceType && input.referenceId) { + const existing = await tx.storePassEntitlementLedger.findFirst({ + where: { + storeId, + entitlementType, + type: StorePassEntitlementLedgerType.USE, + referenceType: input.referenceType, + referenceId: input.referenceId, + }, + }); + if (existing) { + return { + entitlementType, + balance: entitlement.balance ?? 0, + consumed: 0, + alreadyApplied: true, + }; + } + } + + if (!entitlement.isEnabled) { + throw new BadRequestException({ + message: "This entitlement is not enabled on the current plan", + code: "STOREPASS_ENTITLEMENT_DISABLED", + }); + } + if ( + entitlement.periodEnd && + entitlement.periodEnd.getTime() <= Date.now() + ) { + throw new BadRequestException({ + message: "This entitlement's period has ended", + code: "STOREPASS_ENTITLEMENT_EXPIRED", + }); + } + + const balanceBefore = entitlement.balance ?? 0; + if (balanceBefore < amount) { + throw new BadRequestException({ + message: "Insufficient entitlement balance", + code: "STOREPASS_ENTITLEMENT_INSUFFICIENT", + }); + } + + const balanceAfter = balanceBefore - amount; + await tx.storePassEntitlement.update({ + where: { id: entitlement.id }, + data: { balance: balanceAfter }, + }); + + await tx.storePassEntitlementLedger.create({ + data: { + storeId, + subscriptionId: entitlement.subscriptionId, + entitlementId: entitlement.id, + type: StorePassEntitlementLedgerType.USE, + entitlementType, + amount, + balanceBefore, + balanceAfter, + reason: input.reason ?? `Used ${amount} ${entitlementType}`, + referenceType: input.referenceType ?? null, + referenceId: input.referenceId ?? null, + createdByUserId: input.createdByUserId ?? null, + }, + }); + + return { + entitlementType, + balance: balanceAfter, + consumed: amount, + alreadyApplied: false, + }; + }); + } + + /** + * Per-entitlement usage summary for the current period: balance/enabled state + * plus granted and used totals aggregated from the append-only ledger. Safe + * numbers only — the auditable proof of how StorePass benefits are performing. + */ + async getEntitlementUsage( + storeId: string, + ): Promise { + const entitlements = await this.prisma.storePassEntitlement.findMany({ + where: { storeId }, + orderBy: { entitlementType: "asc" }, + }); + + const views: StorePassEntitlementUsageView[] = []; + for (const entitlement of entitlements) { + const periodFilter = entitlement.periodStart + ? { gte: entitlement.periodStart } + : undefined; + + const [granted, used] = await Promise.all([ + this.prisma.storePassEntitlementLedger.aggregate({ + _sum: { amount: true }, + where: { + storeId, + entitlementType: entitlement.entitlementType, + type: StorePassEntitlementLedgerType.GRANT, + ...(periodFilter ? { createdAt: periodFilter } : {}), + }, + }), + this.prisma.storePassEntitlementLedger.aggregate({ + _sum: { amount: true }, + where: { + storeId, + entitlementType: entitlement.entitlementType, + type: StorePassEntitlementLedgerType.USE, + ...(periodFilter ? { createdAt: periodFilter } : {}), + }, + }), + ]); + + views.push({ + entitlementType: entitlement.entitlementType, + isCredit: isCreditEntitlementType(entitlement.entitlementType), + balance: entitlement.balance, + isEnabled: entitlement.isEnabled, + periodStart: entitlement.periodStart, + periodEnd: entitlement.periodEnd, + grantedThisPeriod: granted._sum.amount ?? 0, + usedThisPeriod: used._sum.amount ?? 0, + }); + } + return views; + } +} diff --git a/apps/backend/src/domains/money/storepass/storepass-job-lock.service.ts b/apps/backend/src/domains/money/storepass/storepass-job-lock.service.ts new file mode 100644 index 00000000..801e6656 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-job-lock.service.ts @@ -0,0 +1,61 @@ +import { Injectable, Logger } from "@nestjs/common"; + +import { RedisService } from "../../../core/redis/redis.service"; + +/** + * Best-effort distributed lock for StorePass scheduled jobs. The primary + * correctness guard is always strict DB state (idempotent invoice/attempt + * creation and status checks); this lock only reduces wasted duplicate work when + * more than one backend replica runs the same cron tick. MVP is expected to run + * a single backend replica, so a Redis failure here fails open (runs the work). + */ +@Injectable() +export class StorePassJobLockService { + private readonly logger = new Logger(StorePassJobLockService.name); + + constructor(private readonly redis: RedisService) {} + + /** + * Runs `fn` while holding a best-effort lock on `key`. When the lock cannot be + * acquired (another replica holds it), returns null without running `fn`. If + * Redis is unavailable, fails open and runs `fn` (single-replica MVP safety). + */ + async withLock( + key: string, + ttlSeconds: number, + fn: () => Promise, + ): Promise { + const lockKey = `storepass:lock:${key}`; + let acquired = false; + try { + acquired = await this.redis.set(lockKey, "1", ttlSeconds, true); + } catch (error) { + // Fail open: proceed without a lock rather than skipping billing work. + this.logger.warn( + `StorePass lock acquire failed for ${lockKey}; proceeding without lock: ${this.message(error)}`, + ); + return fn(); + } + + if (!acquired) { + return null; + } + + try { + return await fn(); + } finally { + try { + await this.redis.del(lockKey); + } catch (error) { + // TTL will expire the lock; nothing else to do. + this.logger.warn( + `StorePass lock release failed for ${lockKey}: ${this.message(error)}`, + ); + } + } + } + + private message(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +} diff --git a/apps/backend/src/domains/money/storepass/storepass-plans.service.ts b/apps/backend/src/domains/money/storepass/storepass-plans.service.ts new file mode 100644 index 00000000..48ad7b61 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-plans.service.ts @@ -0,0 +1,74 @@ +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { Prisma, StorePassPlan, StorePassPlanCode } from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { + STOREPASS_CURRENCY, + STOREPASS_PLAN_DEFINITIONS, +} from "./storepass.constants"; + +@Injectable() +export class StorePassPlansService { + private readonly logger = new Logger(StorePassPlansService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** All active StorePass plans, ordered for display. */ + async getPlans(): Promise { + return this.prisma.storePassPlan.findMany({ + where: { isActive: true }, + orderBy: { sortOrder: "asc" }, + }); + } + + async getPlanByCode(code: StorePassPlanCode): Promise { + const plan = await this.prisma.storePassPlan.findUnique({ + where: { code }, + }); + if (!plan) { + throw new NotFoundException({ + message: "StorePass plan not found", + code: "STOREPASS_PLAN_NOT_FOUND", + }); + } + return plan; + } + + /** + * Idempotently upserts the Twizrr-owned StorePass plans from the central + * definitions. Safe to run repeatedly (seed + startup). No external calls. + */ + async upsertPlansFromDefinitions(): Promise { + const plans: StorePassPlan[] = []; + for (const definition of STOREPASS_PLAN_DEFINITIONS) { + const benefits = definition.benefits as unknown as Prisma.InputJsonValue; + const plan = await this.prisma.storePassPlan.upsert({ + where: { code: definition.code }, + update: { + name: definition.name, + description: definition.description, + priceKobo: definition.priceKobo, + currency: STOREPASS_CURRENCY, + interval: definition.interval, + isActive: true, + sortOrder: definition.sortOrder, + benefits, + }, + create: { + code: definition.code, + name: definition.name, + description: definition.description, + priceKobo: definition.priceKobo, + currency: STOREPASS_CURRENCY, + interval: definition.interval, + isActive: true, + sortOrder: definition.sortOrder, + benefits, + }, + }); + plans.push(plan); + } + this.logger.log(`Upserted ${plans.length} StorePass plans.`); + return plans; + } +} diff --git a/apps/backend/src/domains/money/storepass/storepass-reconciliation.service.spec.ts b/apps/backend/src/domains/money/storepass/storepass-reconciliation.service.spec.ts new file mode 100644 index 00000000..f7fa0fd4 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-reconciliation.service.spec.ts @@ -0,0 +1,111 @@ +import { StorePassInvoiceStatus, StorePassPlanCode } from "@prisma/client"; + +import { StorePassReconciliationService } from "./storepass-reconciliation.service"; + +const NOW = new Date("2026-07-07T00:00:00.000Z"); + +function makeAttempt(overrides: Record = {}) { + return { + id: "att-1", + orderReference: "storepass_inv_inv-1_1", + storeId: "store-1", + retryCount: 0, + createdAt: new Date("2026-07-06T23:00:00.000Z"), + invoice: { + id: "inv-1", + amountKobo: 450000n, + status: StorePassInvoiceStatus.PENDING, + planCodeSnapshot: StorePassPlanCode.GROWTH, + }, + subscription: { id: "sub-1", ownerUserId: "user-1" }, + ...overrides, + }; +} + +function makeHarness(options: { + attempts?: Record[]; + lookup: { status: string; amountKobo: bigint; rawProviderReference?: string }; +}) { + const prisma = { + storePassBillingAttempt: { + findMany: jest + .fn() + .mockResolvedValue(options.attempts ?? [makeAttempt()]), + update: jest.fn().mockResolvedValue({}), + }, + }; + const activation = { + applyPaymentSuccess: jest.fn().mockResolvedValue(undefined), + }; + const chargeService = { + settleFailedAttempt: jest.fn().mockResolvedValue({}), + }; + const billingProvider = { + verifySubscriptionPayment: jest.fn().mockResolvedValue(options.lookup), + }; + const service = new StorePassReconciliationService( + prisma as never, + activation as never, + chargeService as never, + billingProvider as never, + ); + return { service, prisma, activation, chargeService, billingProvider }; +} + +describe("StorePassReconciliationService.sweepPendingAttempts", () => { + it("activates a stuck attempt the provider confirms as paid (single path)", async () => { + const { service, activation, chargeService, prisma } = makeHarness({ + lookup: { + status: "SUCCESS", + amountKobo: 450000n, + rawProviderReference: "txn-9", + }, + }); + + const summary = await service.sweepPendingAttempts(NOW); + + expect(summary.confirmedPaid).toBe(1); + expect(activation.applyPaymentSuccess).toHaveBeenCalledTimes(1); + expect(chargeService.settleFailedAttempt).not.toHaveBeenCalled(); + // Reconciliation timestamp/status recorded. + expect(prisma.storePassBillingAttempt.update).toHaveBeenCalled(); + }); + + it("does not activate on a provider amount mismatch — flags for review", async () => { + const { service, activation, prisma } = makeHarness({ + lookup: { status: "SUCCESS", amountKobo: 1n }, + }); + + const summary = await service.sweepPendingAttempts(NOW); + + expect(summary.flaggedForReview).toBe(1); + expect(activation.applyPaymentSuccess).not.toHaveBeenCalled(); + const stamp = prisma.storePassBillingAttempt.update.mock.calls[0][0].data; + expect(stamp.needsReview).toBe(true); + expect(stamp.reconciliationStatus).toBe("AMOUNT_MISMATCH"); + }); + + it("settles a provider-confirmed failed attempt (schedules retry)", async () => { + const { service, chargeService } = makeHarness({ + lookup: { status: "FAILED", amountKobo: 0n }, + }); + + const summary = await service.sweepPendingAttempts(NOW); + + expect(summary.confirmedFailed).toBe(1); + expect(chargeService.settleFailedAttempt).toHaveBeenCalledTimes(1); + }); + + it("leaves a still-pending recent attempt untouched but records the check", async () => { + const { service, activation, chargeService, prisma } = makeHarness({ + lookup: { status: "PENDING", amountKobo: 450000n }, + }); + + const summary = await service.sweepPendingAttempts(NOW); + + expect(summary.stillPending).toBe(1); + expect(activation.applyPaymentSuccess).not.toHaveBeenCalled(); + expect(chargeService.settleFailedAttempt).not.toHaveBeenCalled(); + expect(prisma.storePassBillingAttempt.update).toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/domains/money/storepass/storepass-reconciliation.service.ts b/apps/backend/src/domains/money/storepass/storepass-reconciliation.service.ts new file mode 100644 index 00000000..66227276 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-reconciliation.service.ts @@ -0,0 +1,246 @@ +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { + StorePassBillingAttempt, + StorePassBillingAttemptStatus, + StorePassInvoice, + StorePassSubscription, +} from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { + SUBSCRIPTION_BILLING_PROVIDER, + SubscriptionBillingProvider, +} from "../subscription-billing/providers/subscription-billing-provider.interface"; +import { StorePassActivationService } from "./storepass-activation.service"; +import { StorePassChargeService } from "./storepass-charge.service"; +import { + STOREPASS_RECONCILE_LOOKBACK_HOURS, + STOREPASS_STUCK_ATTEMPT_MINUTES, + STOREPASS_STUCK_ATTEMPT_REVIEW_MINUTES, +} from "./storepass.constants"; + +type AttemptWithRelations = StorePassBillingAttempt & { + invoice: StorePassInvoice; + subscription: StorePassSubscription | null; +}; + +export interface StorePassReconcileRunSummary { + scanned: number; + confirmedPaid: number; + confirmedFailed: number; + stillPending: number; + flaggedForReview: number; +} + +/** + * Reconciles StorePass billing attempts that were left PROCESSING (a webhook was + * missed or delayed) against the provider's own transaction record via the + * provider-agnostic lookup (`verifySubscriptionPayment`). This is the safety net + * that guarantees a store is never charged-but-not-activated and a stuck attempt + * is never left forever. It only reads provider state and settles local records; + * it never initiates a charge, so it cannot double-charge. Activation goes + * through the single shared StorePassActivationService. + */ +@Injectable() +export class StorePassReconciliationService { + private readonly logger = new Logger(StorePassReconciliationService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly activation: StorePassActivationService, + private readonly chargeService: StorePassChargeService, + @Inject(SUBSCRIPTION_BILLING_PROVIDER) + private readonly billingProvider: SubscriptionBillingProvider, + ) {} + + /** + * Fast sweep of attempts stuck in PROCESSING longer than the stuck threshold. + * Runs frequently so a missed webhook is resolved within ~30 minutes. + */ + async sweepPendingAttempts( + now: Date = new Date(), + ): Promise { + const cutoff = new Date( + now.getTime() - STOREPASS_STUCK_ATTEMPT_MINUTES * 60_000, + ); + const attempts = await this.prisma.storePassBillingAttempt.findMany({ + where: { + status: StorePassBillingAttemptStatus.PROCESSING, + createdAt: { lte: cutoff }, + }, + include: { invoice: true, subscription: true }, + }); + return this.reconcileMany(attempts, now, "sweep"); + } + + /** + * Wider periodic pass over recent PROCESSING attempts (regardless of age) to + * catch missed webhooks earlier than the stuck threshold. + */ + async runReconciliation( + now: Date = new Date(), + ): Promise { + const lookback = new Date( + now.getTime() - STOREPASS_RECONCILE_LOOKBACK_HOURS * 3_600_000, + ); + const attempts = await this.prisma.storePassBillingAttempt.findMany({ + where: { + status: StorePassBillingAttemptStatus.PROCESSING, + createdAt: { gte: lookback }, + }, + include: { invoice: true, subscription: true }, + }); + return this.reconcileMany(attempts, now, "periodic"); + } + + /** + * Reconciles a single attempt on demand by its order reference. Powers the + * post-checkout "confirm on return" flow so activation never depends on webhook + * timing: the store owner returns from the hosted checkout, the app calls this, + * and it verifies the payment against the provider and activates through the + * exact same shared path the periodic sweep uses. Idempotent and safe — only a + * PROCESSING attempt is acted on (a terminal or missing attempt is a no-op), + * and it never initiates a charge, so it can never double-charge or + * double-grant entitlements. + */ + async reconcileByOrderReference( + orderReference: string, + now: Date = new Date(), + ): Promise { + const empty: StorePassReconcileRunSummary = { + scanned: 0, + confirmedPaid: 0, + confirmedFailed: 0, + stillPending: 0, + flaggedForReview: 0, + }; + + const attempt = await this.prisma.storePassBillingAttempt.findUnique({ + where: { orderReference }, + include: { invoice: true, subscription: true }, + }); + if ( + !attempt || + attempt.status !== StorePassBillingAttemptStatus.PROCESSING + ) { + return empty; + } + return this.reconcileMany([attempt], now, "return-confirm"); + } + + private async reconcileMany( + attempts: AttemptWithRelations[], + now: Date, + source: string, + ): Promise { + const summary: StorePassReconcileRunSummary = { + scanned: attempts.length, + confirmedPaid: 0, + confirmedFailed: 0, + stillPending: 0, + flaggedForReview: 0, + }; + + for (const attempt of attempts) { + try { + await this.reconcileAttempt(attempt, now, summary); + } catch (error) { + this.logger.error( + `StorePass reconciliation error for attempt ${attempt.id}: ${this.message(error)}`, + ); + } + } + + if (summary.scanned > 0) { + this.logger.log( + `StorePass reconciliation (${source}): scanned ${summary.scanned}, ` + + `paid ${summary.confirmedPaid}, failed ${summary.confirmedFailed}, ` + + `pending ${summary.stillPending}, review ${summary.flaggedForReview}.`, + ); + } + return summary; + } + + private async reconcileAttempt( + attempt: AttemptWithRelations, + now: Date, + summary: StorePassReconcileRunSummary, + ): Promise { + const { invoice, subscription } = attempt; + if (!subscription) { + await this.stamp(attempt.id, "NO_SUBSCRIPTION", now, true); + summary.flaggedForReview += 1; + return; + } + + const lookup = await this.billingProvider.verifySubscriptionPayment( + attempt.orderReference, + ); + + if (lookup.status === "SUCCESS") { + if (lookup.amountKobo !== invoice.amountKobo) { + // Provider reports a paid amount that does not match the invoice — never + // activate; flag for a human. + await this.stamp(attempt.id, "AMOUNT_MISMATCH", now, true); + summary.flaggedForReview += 1; + return; + } + await this.stamp(attempt.id, "CONFIRMED_SUCCESS", now, false); + await this.activation.applyPaymentSuccess({ + attempt, + invoice, + subscription, + providerTransactionId: + lookup.rawProviderReference ?? attempt.providerTransactionId ?? null, + now, + }); + summary.confirmedPaid += 1; + return; + } + + if (lookup.status === "FAILED") { + await this.stamp(attempt.id, "CONFIRMED_FAILED", now, false); + await this.chargeService.settleFailedAttempt( + attempt, + invoice, + subscription, + now, + "reconciliation: provider reports charge failed", + ); + summary.confirmedFailed += 1; + return; + } + + // Still PENDING at the provider. Flag for review once it has been stuck too + // long; otherwise just record that we checked. + const ageMinutes = (now.getTime() - attempt.createdAt.getTime()) / 60_000; + if (ageMinutes >= STOREPASS_STUCK_ATTEMPT_REVIEW_MINUTES) { + await this.stamp(attempt.id, "PENDING_REVIEW", now, true); + summary.flaggedForReview += 1; + return; + } + await this.stamp(attempt.id, "PENDING", now, false); + summary.stillPending += 1; + } + + /** Records that reconciliation checked this attempt (and an optional review flag). */ + private async stamp( + attemptId: string, + status: string, + now: Date, + needsReview: boolean, + ): Promise { + await this.prisma.storePassBillingAttempt.update({ + where: { id: attemptId }, + data: { + lastReconciledAt: now, + reconciliationStatus: status, + ...(needsReview ? { needsReview: true } : {}), + }, + }); + } + + private message(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +} diff --git a/apps/backend/src/domains/money/storepass/storepass-renewal.service.spec.ts b/apps/backend/src/domains/money/storepass/storepass-renewal.service.spec.ts new file mode 100644 index 00000000..2ca62bd9 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-renewal.service.spec.ts @@ -0,0 +1,175 @@ +import { + StorePassPlanCode, + StorePassSubscriptionStatus, + StoreTier, +} from "@prisma/client"; + +import { StorePassRenewalService } from "./storepass-renewal.service"; + +const NOW = new Date("2026-07-07T00:00:00.000Z"); + +function makeSubscription(overrides: Record = {}) { + return { + id: "sub-1", + storeId: "store-1", + ownerUserId: "user-1", + planCodeSnapshot: StorePassPlanCode.GROWTH, + status: StorePassSubscriptionStatus.ACTIVE, + cancelAtPeriodEnd: false, + nextBillingAt: new Date("2026-07-06T00:00:00.000Z"), + ...overrides, + }; +} + +function makeHarness(options?: { + due?: Record[]; + store?: Record | null; + paymentMethod?: Record | null; + openInvoice?: Record | null; + processingAttempt?: Record | null; +}) { + const prisma = { + storePassSubscription: { + findMany: jest + .fn() + .mockResolvedValue(options?.due ?? [makeSubscription()]), + }, + storeProfile: { + findUnique: jest + .fn() + .mockResolvedValue( + options?.store === undefined + ? { id: "store-1", tier: StoreTier.TIER_2, isOpen: true } + : options.store, + ), + }, + storePassPaymentMethod: { + findFirst: jest.fn().mockResolvedValue( + options?.paymentMethod === undefined + ? { + id: "pm-1", + nombaTokenKey: "tok_1", + tokenExpiryMonth: 12, + tokenExpiryYear: 2099, + revokedAt: null, + isDefault: true, + } + : options.paymentMethod, + ), + }, + storePassInvoice: { + findFirst: jest.fn().mockResolvedValue(options?.openInvoice ?? null), + }, + storePassBillingAttempt: { + findFirst: jest + .fn() + .mockResolvedValue(options?.processingAttempt ?? null), + }, + user: { + findUnique: jest + .fn() + .mockResolvedValue({ id: "user-1", email: "owner@example.com" }), + }, + }; + + const storePassService = { + createInvoiceForSubscription: jest.fn().mockResolvedValue({ + id: "inv-1", + storeId: "store-1", + amountKobo: 450000n, + planCodeSnapshot: StorePassPlanCode.GROWTH, + }), + }; + const chargeService = { chargeSavedCard: jest.fn().mockResolvedValue({}) }; + const lock = { + withLock: jest.fn().mockImplementation((_key, _ttl, fn) => fn()), + }; + + const service = new StorePassRenewalService( + prisma as never, + storePassService as never, + chargeService as never, + lock as never, + ); + return { service, prisma, storePassService, chargeService }; +} + +describe("StorePassRenewalService.processDueRenewals", () => { + it("charges the saved card for an eligible due subscription", async () => { + const { service, chargeService, storePassService } = makeHarness(); + + const summary = await service.processDueRenewals(NOW); + + expect(summary).toMatchObject({ due: 1, charged: 1, skipped: 0 }); + expect(storePassService.createInvoiceForSubscription).toHaveBeenCalledTimes( + 1, + ); + const chargeArg = chargeService.chargeSavedCard.mock.calls[0][0]; + expect(chargeArg.purpose).toBe("STOREPASS_RENEWAL"); + expect(chargeArg.attemptNumber).toBe(1); + expect(chargeArg.retryCount).toBe(0); + }); + + it("skips a closed store", async () => { + const { service, chargeService } = makeHarness({ + store: { id: "store-1", tier: StoreTier.TIER_2, isOpen: false }, + }); + + const summary = await service.processDueRenewals(NOW); + + expect(summary).toMatchObject({ charged: 0, skipped: 1 }); + expect(chargeService.chargeSavedCard).not.toHaveBeenCalled(); + }); + + it("skips a store below Tier 2", async () => { + const { service, chargeService } = makeHarness({ + store: { id: "store-1", tier: StoreTier.TIER_1, isOpen: true }, + }); + + const summary = await service.processDueRenewals(NOW); + + expect(summary.skipped).toBe(1); + expect(chargeService.chargeSavedCard).not.toHaveBeenCalled(); + }); + + it("skips when there is no usable saved card", async () => { + const { service, chargeService } = makeHarness({ paymentMethod: null }); + + const summary = await service.processDueRenewals(NOW); + + expect(summary.skipped).toBe(1); + expect(chargeService.chargeSavedCard).not.toHaveBeenCalled(); + }); + + it("skips an expired card", async () => { + const { service, chargeService } = makeHarness({ + paymentMethod: { + id: "pm-1", + nombaTokenKey: "tok_1", + tokenExpiryMonth: 1, + tokenExpiryYear: 2020, + revokedAt: null, + isDefault: true, + }, + }); + + const summary = await service.processDueRenewals(NOW); + + expect(summary.skipped).toBe(1); + expect(chargeService.chargeSavedCard).not.toHaveBeenCalled(); + }); + + it("does not create a duplicate renewal when one is already in flight", async () => { + const { service, chargeService, storePassService } = makeHarness({ + openInvoice: { id: "inv-open" }, + }); + + const summary = await service.processDueRenewals(NOW); + + expect(summary.skipped).toBe(1); + expect( + storePassService.createInvoiceForSubscription, + ).not.toHaveBeenCalled(); + expect(chargeService.chargeSavedCard).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/domains/money/storepass/storepass-renewal.service.ts b/apps/backend/src/domains/money/storepass/storepass-renewal.service.ts new file mode 100644 index 00000000..83f59862 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-renewal.service.ts @@ -0,0 +1,202 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { + StorePassBillingAttemptStatus, + StorePassInvoiceStatus, + StorePassPaymentMethod, + StorePassPlanCode, + StorePassSubscription, + StorePassSubscriptionStatus, + StoreTier, +} from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { StorePassChargeService } from "./storepass-charge.service"; +import { StorePassJobLockService } from "./storepass-job-lock.service"; +import { StorePassService } from "./storepass.service"; +import { + computeMonthlyBillingPeriod, + isStorePassCardExpired, +} from "./storepass.constants"; + +export interface StorePassRenewalRunSummary { + due: number; + charged: number; + skipped: number; + failed: number; +} + +/** Paid plans that are billed monthly. FREE is never renewed. */ +const RENEWABLE_PLAN_CODES: readonly StorePassPlanCode[] = [ + StorePassPlanCode.GROWTH, + StorePassPlanCode.PRO, +]; + +/** + * Monthly StorePass renewal engine. Finds ACTIVE paid subscriptions due for + * renewal and charges the saved tokenized card via the provider-agnostic charge + * service (never Nomba directly). Idempotent and safe to run repeatedly: a + * subscription already mid-renewal (open invoice / in-flight attempt) is skipped, + * so duplicate cron ticks never create duplicate invoices, attempts, or charges. + */ +@Injectable() +export class StorePassRenewalService { + private readonly logger = new Logger(StorePassRenewalService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly storePassService: StorePassService, + private readonly chargeService: StorePassChargeService, + private readonly lock: StorePassJobLockService, + ) {} + + async processDueRenewals( + now: Date = new Date(), + ): Promise { + const dueSubscriptions = await this.prisma.storePassSubscription.findMany({ + where: { + status: StorePassSubscriptionStatus.ACTIVE, + planCodeSnapshot: { in: [...RENEWABLE_PLAN_CODES] }, + cancelAtPeriodEnd: false, + nextBillingAt: { lte: now }, + }, + }); + + const summary: StorePassRenewalRunSummary = { + due: dueSubscriptions.length, + charged: 0, + skipped: 0, + failed: 0, + }; + + for (const subscription of dueSubscriptions) { + try { + const result = await this.lock.withLock( + `renewal:${subscription.id}`, + 120, + () => this.renewOne(subscription, now), + ); + // Lock not acquired (another replica handling it) counts as skipped. + if (result === null || result === "skipped") { + summary.skipped += 1; + } else { + summary.charged += 1; + } + } catch (error) { + summary.failed += 1; + this.logger.error( + `StorePass renewal failed for subscription ${subscription.id}: ${this.message(error)}`, + ); + } + } + + if (summary.due > 0) { + this.logger.log( + `StorePass renewals: ${summary.due} due, ${summary.charged} charged, ${summary.skipped} skipped, ${summary.failed} failed.`, + ); + } + return summary; + } + + /** Attempts a single renewal. Returns "charged" or "skipped". */ + private async renewOne( + subscription: StorePassSubscription, + now: Date, + ): Promise<"charged" | "skipped"> { + // Re-check store eligibility: paid StorePass requires Tier 2 and an open + // (not closed) store. There is no separate suspension flag in the schema, so + // a closed store (isOpen=false) is the enforced "not billable" state. + const store = await this.prisma.storeProfile.findUnique({ + where: { id: subscription.storeId }, + select: { id: true, tier: true, isOpen: true }, + }); + if (!store || store.tier !== StoreTier.TIER_2 || !store.isOpen) { + return "skipped"; + } + + const paymentMethod = await this.getUsableDefaultCard(subscription.storeId); + if ( + !paymentMethod || + isStorePassCardExpired( + paymentMethod.tokenExpiryMonth, + paymentMethod.tokenExpiryYear, + now, + ) + ) { + return "skipped"; + } + + // Dedup guard: skip if this subscription already has an open invoice or a + // non-terminal attempt in flight (renewal already started, awaiting + // webhook/reconciliation). + if (await this.hasRenewalInFlight(subscription.id)) { + return "skipped"; + } + + const owner = await this.prisma.user.findUnique({ + where: { id: subscription.ownerUserId }, + select: { id: true, email: true }, + }); + if (!owner) { + return "skipped"; + } + + const { billingPeriodStart, billingPeriodEnd } = + computeMonthlyBillingPeriod(now); + const invoice = await this.storePassService.createInvoiceForSubscription({ + subscriptionId: subscription.id, + paidByUserId: owner.id, + billingPeriodStart, + billingPeriodEnd, + dueAt: billingPeriodStart, + }); + + await this.chargeService.chargeSavedCard({ + invoice, + subscription, + paymentMethod, + payerUser: { id: owner.id, email: owner.email }, + purpose: "STOREPASS_RENEWAL", + attemptNumber: 1, + retryCount: 0, + }); + + return "charged"; + } + + private async getUsableDefaultCard( + storeId: string, + ): Promise { + const method = await this.prisma.storePassPaymentMethod.findFirst({ + where: { storeId, revokedAt: null, isDefault: true }, + orderBy: { updatedAt: "desc" }, + }); + if (!method || !method.nombaTokenKey) { + return null; + } + return method; + } + + private async hasRenewalInFlight(subscriptionId: string): Promise { + const [openInvoice, processingAttempt] = await Promise.all([ + this.prisma.storePassInvoice.findFirst({ + where: { + subscriptionId, + status: StorePassInvoiceStatus.PENDING, + }, + select: { id: true }, + }), + this.prisma.storePassBillingAttempt.findFirst({ + where: { + subscriptionId, + status: StorePassBillingAttemptStatus.PROCESSING, + }, + select: { id: true }, + }), + ]); + return Boolean(openInvoice || processingAttempt); + } + + private message(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +} diff --git a/apps/backend/src/domains/money/storepass/storepass-retry.service.spec.ts b/apps/backend/src/domains/money/storepass/storepass-retry.service.spec.ts new file mode 100644 index 00000000..fd30869d --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-retry.service.spec.ts @@ -0,0 +1,165 @@ +import { + StorePassBillingAttemptStatus, + StorePassInvoiceStatus, + StorePassPlanCode, + StorePassSubscriptionStatus, + StoreTier, +} from "@prisma/client"; + +import { StorePassRetryService } from "./storepass-retry.service"; + +const NOW = new Date("2026-07-07T00:00:00.000Z"); + +function makeCandidate(overrides: Record = {}) { + return { + id: "att-1", + invoiceId: "inv-1", + attemptNumber: 1, + retryCount: 0, + maxRetries: 3, + status: StorePassBillingAttemptStatus.FAILED, + nextRetryAt: new Date("2026-07-06T23:00:00.000Z"), + needsReview: false, + invoice: { + id: "inv-1", + storeId: "store-1", + amountKobo: 450000n, + status: StorePassInvoiceStatus.FAILED, + planCodeSnapshot: StorePassPlanCode.GROWTH, + }, + subscription: { + id: "sub-1", + storeId: "store-1", + ownerUserId: "user-1", + planCodeSnapshot: StorePassPlanCode.GROWTH, + status: StorePassSubscriptionStatus.PAST_DUE, + }, + ...overrides, + }; +} + +function makeHarness(options?: { + candidates?: Record[]; + invoiceAttempts?: Record[]; +}) { + const candidates = options?.candidates ?? [makeCandidate()]; + const invoiceAttempts = + options?.invoiceAttempts ?? + candidates.map((c) => ({ + id: c.id, + attemptNumber: c.attemptNumber, + status: StorePassBillingAttemptStatus.FAILED, + })); + + const findMany = jest + .fn() + .mockImplementation((args: { where?: { invoiceId?: string } }) => { + if (args?.where?.invoiceId) { + return Promise.resolve(invoiceAttempts); + } + return Promise.resolve(candidates); + }); + + const prisma = { + storePassBillingAttempt: { + findMany, + update: jest.fn().mockResolvedValue({}), + fields: { maxRetries: "maxRetries" }, + }, + storeProfile: { + findUnique: jest + .fn() + .mockResolvedValue({ tier: StoreTier.TIER_2, isOpen: true }), + }, + storePassPaymentMethod: { + findFirst: jest.fn().mockResolvedValue({ + id: "pm-1", + nombaTokenKey: "tok_1", + tokenExpiryMonth: 12, + tokenExpiryYear: 2099, + revokedAt: null, + isDefault: true, + }), + }, + user: { + findUnique: jest + .fn() + .mockResolvedValue({ id: "user-1", email: "owner@example.com" }), + }, + }; + + const chargeService = { chargeSavedCard: jest.fn().mockResolvedValue({}) }; + const lock = { + withLock: jest.fn().mockImplementation((_key, _ttl, fn) => fn()), + }; + + const service = new StorePassRetryService( + prisma as never, + chargeService as never, + lock as never, + ); + return { service, prisma, chargeService }; +} + +describe("StorePassRetryService.processDueRetries", () => { + it("re-charges the next attempt for a due failed attempt", async () => { + const { service, chargeService, prisma } = makeHarness(); + + const summary = await service.processDueRetries(NOW); + + expect(summary).toMatchObject({ due: 1, retried: 1 }); + const arg = chargeService.chargeSavedCard.mock.calls[0][0]; + expect(arg.attemptNumber).toBe(2); + expect(arg.retryCount).toBe(1); + expect(arg.purpose).toBe("STOREPASS_RENEWAL"); + // Old attempt is retired so it does not re-qualify next tick. + expect(prisma.storePassBillingAttempt.update).toHaveBeenCalledWith({ + where: { id: "att-1" }, + data: { nextRetryAt: null }, + }); + }); + + it("skips a stale attempt that is no longer the latest for its invoice", async () => { + const { service, chargeService } = makeHarness({ + invoiceAttempts: [ + { + id: "att-2", + attemptNumber: 2, + status: StorePassBillingAttemptStatus.FAILED, + }, + { + id: "att-1", + attemptNumber: 1, + status: StorePassBillingAttemptStatus.FAILED, + }, + ], + }); + + const summary = await service.processDueRetries(NOW); + + expect(summary.retried).toBe(0); + expect(summary.skipped).toBe(1); + expect(chargeService.chargeSavedCard).not.toHaveBeenCalled(); + }); + + it("skips when the subscription is cancelled", async () => { + const { service, chargeService } = makeHarness({ + candidates: [ + makeCandidate({ + subscription: { + id: "sub-1", + storeId: "store-1", + ownerUserId: "user-1", + planCodeSnapshot: StorePassPlanCode.GROWTH, + status: StorePassSubscriptionStatus.CANCELLED, + }, + }), + ], + }); + + const summary = await service.processDueRetries(NOW); + + expect(summary.skipped).toBe(1); + expect(chargeService.chargeSavedCard).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/domains/money/storepass/storepass-retry.service.ts b/apps/backend/src/domains/money/storepass/storepass-retry.service.ts new file mode 100644 index 00000000..aea40ba6 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-retry.service.ts @@ -0,0 +1,214 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { + StorePassBillingAttempt, + StorePassBillingAttemptStatus, + StorePassInvoice, + StorePassInvoiceStatus, + StorePassPaymentMethod, + StorePassPlanCode, + StorePassSubscription, + StorePassSubscriptionStatus, + StoreTier, +} from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { StorePassChargeService } from "./storepass-charge.service"; +import { StorePassJobLockService } from "./storepass-job-lock.service"; +import { isStorePassCardExpired } from "./storepass.constants"; + +export interface StorePassRetryRunSummary { + due: number; + retried: number; + skipped: number; + failed: number; +} + +const RETRYABLE_PLAN_CODES: readonly StorePassPlanCode[] = [ + StorePassPlanCode.GROWTH, + StorePassPlanCode.PRO, +]; + +const ACTIVE_RETRY_STATUSES: readonly StorePassSubscriptionStatus[] = [ + StorePassSubscriptionStatus.PAST_DUE, + StorePassSubscriptionStatus.ACTIVE, +]; + +/** + * Failed-payment retry engine for StorePass renewals. Picks up FAILED billing + * attempts whose scheduled `nextRetryAt` has arrived and re-charges the saved + * card on the SAME invoice with the next attempt number, until the retry budget + * (`maxRetries`) is exhausted. Retry cadence and exhaustion are owned by the + * charge service + constants; this service only selects due work and re-invokes + * the charge. Idempotent: only the latest attempt per invoice drives a retry, + * and an in-flight PROCESSING attempt blocks a new one. + */ +@Injectable() +export class StorePassRetryService { + private readonly logger = new Logger(StorePassRetryService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly chargeService: StorePassChargeService, + private readonly lock: StorePassJobLockService, + ) {} + + async processDueRetries( + now: Date = new Date(), + ): Promise { + const candidates = await this.prisma.storePassBillingAttempt.findMany({ + where: { + status: StorePassBillingAttemptStatus.FAILED, + needsReview: false, + nextRetryAt: { lte: now }, + retryCount: { + lt: this.prisma.storePassBillingAttempt.fields.maxRetries, + }, + }, + include: { invoice: true, subscription: true }, + orderBy: { attemptNumber: "desc" }, + }); + + const summary: StorePassRetryRunSummary = { + due: candidates.length, + retried: 0, + skipped: 0, + failed: 0, + }; + + // Only one retry per invoice per run; the latest FAILED attempt wins. + const seenInvoices = new Set(); + + for (const candidate of candidates) { + if (seenInvoices.has(candidate.invoiceId)) { + summary.skipped += 1; + continue; + } + seenInvoices.add(candidate.invoiceId); + + const { invoice, subscription } = candidate; + if ( + !subscription || + !ACTIVE_RETRY_STATUSES.includes(subscription.status) || + !RETRYABLE_PLAN_CODES.includes(subscription.planCodeSnapshot) || + invoice.status === StorePassInvoiceStatus.PAID + ) { + summary.skipped += 1; + continue; + } + + try { + const result = await this.lock.withLock( + `retry:${subscription.id}`, + 120, + () => this.retryOne(candidate, invoice, subscription, now), + ); + if (result === null || result === "skipped") { + summary.skipped += 1; + } else { + summary.retried += 1; + } + } catch (error) { + summary.failed += 1; + this.logger.error( + `StorePass retry failed for attempt ${candidate.id}: ${this.message(error)}`, + ); + } + } + + if (summary.due > 0) { + this.logger.log( + `StorePass retries: ${summary.due} due, ${summary.retried} retried, ${summary.skipped} skipped, ${summary.failed} failed.`, + ); + } + return summary; + } + + private async retryOne( + candidate: StorePassBillingAttempt, + invoice: StorePassInvoice, + subscription: StorePassSubscription, + now: Date, + ): Promise<"retried" | "skipped"> { + // Guard against acting on a stale attempt: only the highest-numbered attempt + // for this invoice may drive a retry, and no attempt may be in flight. + const attempts = await this.prisma.storePassBillingAttempt.findMany({ + where: { invoiceId: invoice.id }, + orderBy: { attemptNumber: "desc" }, + }); + const latest = attempts[0]; + if (!latest || latest.id !== candidate.id) { + return "skipped"; + } + const hasInFlight = attempts.some( + (attempt) => + attempt.status === StorePassBillingAttemptStatus.PROCESSING || + attempt.status === StorePassBillingAttemptStatus.SUCCEEDED, + ); + if (hasInFlight) { + return "skipped"; + } + + const store = await this.prisma.storeProfile.findUnique({ + where: { id: subscription.storeId }, + select: { tier: true, isOpen: true }, + }); + if (!store || store.tier !== StoreTier.TIER_2 || !store.isOpen) { + return "skipped"; + } + + const paymentMethod = await this.getUsableDefaultCard(subscription.storeId); + if ( + !paymentMethod || + isStorePassCardExpired( + paymentMethod.tokenExpiryMonth, + paymentMethod.tokenExpiryYear, + now, + ) + ) { + return "skipped"; + } + + const owner = await this.prisma.user.findUnique({ + where: { id: subscription.ownerUserId }, + select: { id: true, email: true }, + }); + if (!owner) { + return "skipped"; + } + + await this.chargeService.chargeSavedCard({ + invoice, + subscription, + paymentMethod, + payerUser: { id: owner.id, email: owner.email }, + purpose: "STOREPASS_RENEWAL", + attemptNumber: candidate.attemptNumber + 1, + retryCount: candidate.retryCount + 1, + }); + + // Superseded: stop the old attempt re-qualifying on the next tick. + await this.prisma.storePassBillingAttempt.update({ + where: { id: candidate.id }, + data: { nextRetryAt: null }, + }); + + return "retried"; + } + + private async getUsableDefaultCard( + storeId: string, + ): Promise { + const method = await this.prisma.storePassPaymentMethod.findFirst({ + where: { storeId, revokedAt: null, isDefault: true }, + orderBy: { updatedAt: "desc" }, + }); + if (!method || !method.nombaTokenKey) { + return null; + } + return method; + } + + private message(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +} diff --git a/apps/backend/src/domains/money/storepass/storepass-scheduler.service.ts b/apps/backend/src/domains/money/storepass/storepass-scheduler.service.ts new file mode 100644 index 00000000..31f55085 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-scheduler.service.ts @@ -0,0 +1,67 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { Cron, CronExpression } from "@nestjs/schedule"; + +import { StorePassCancellationService } from "./storepass-cancellation.service"; +import { StorePassReconciliationService } from "./storepass-reconciliation.service"; +import { StorePassRenewalService } from "./storepass-renewal.service"; +import { StorePassRetryService } from "./storepass-retry.service"; + +/** + * Thin scheduler for the StorePass subscription engine. It contains no business + * logic — each `@Cron` handler only delegates to a domain service and swallows + * errors so one failing tick never crashes the scheduler. All engine services + * are independently injectable and could later be driven from a BullMQ processor + * without touching their logic. Follows the existing AdminCronService pattern; + * ScheduleModule.forRoot() is registered globally in CoreModule. + */ +@Injectable() +export class StorePassSchedulerService { + private readonly logger = new Logger(StorePassSchedulerService.name); + + constructor( + private readonly renewalService: StorePassRenewalService, + private readonly retryService: StorePassRetryService, + private readonly reconciliationService: StorePassReconciliationService, + private readonly cancellationService: StorePassCancellationService, + ) {} + + @Cron(CronExpression.EVERY_HOUR) + async processDueRenewals(): Promise { + await this.run("renewals", () => this.renewalService.processDueRenewals()); + } + + @Cron(CronExpression.EVERY_HOUR) + async processPaymentRetries(): Promise { + await this.run("retries", () => this.retryService.processDueRetries()); + } + + @Cron(CronExpression.EVERY_30_MINUTES) + async sweepPendingBillingAttempts(): Promise { + await this.run("pending-sweep", () => + this.reconciliationService.sweepPendingAttempts(), + ); + } + + @Cron(CronExpression.EVERY_6_HOURS) + async runStorePassReconciliation(): Promise { + await this.run("reconciliation", () => + this.reconciliationService.runReconciliation(), + ); + } + + @Cron(CronExpression.EVERY_HOUR) + async finalizeCancellations(): Promise { + await this.run("cancellations", () => + this.cancellationService.finalizeExpiredCancellations(), + ); + } + + private async run(job: string, fn: () => Promise): Promise { + try { + await fn(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`StorePass ${job} job failed: ${message}`); + } + } +} diff --git a/apps/backend/src/domains/money/storepass/storepass-store-mode.service.spec.ts b/apps/backend/src/domains/money/storepass/storepass-store-mode.service.spec.ts new file mode 100644 index 00000000..ffc0a3bc --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-store-mode.service.spec.ts @@ -0,0 +1,443 @@ +import { + StorePassPaymentMethodType, + StorePassPaymentProvider, + StorePassPlanCode, + StorePassSubscriptionStatus, +} from "@prisma/client"; + +import { StorePassStoreModeService } from "./storepass-store-mode.service"; + +const OWNER = "user-1"; +const STORE = "store-1"; + +function futureDate(days = 20): Date { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000); +} + +function makeHarness(options?: { + storeOwnerId?: string; + subscription?: Record | null; + paymentMethod?: Record | null; +}) { + const store = { + id: STORE, + userId: options?.storeOwnerId ?? OWNER, + storeHandle: "ada_store", + storeName: "Ada Store", + }; + + const subscription = + options?.subscription === undefined + ? { + id: "sub-1", + storeId: STORE, + ownerUserId: OWNER, + planCodeSnapshot: StorePassPlanCode.GROWTH, + status: StorePassSubscriptionStatus.ACTIVE, + currentPeriodStart: new Date(), + currentPeriodEnd: futureDate(), + cancelAtPeriodEnd: false, + cancelledAt: null, + lastPaidAt: new Date(), + nextBillingAt: futureDate(), + } + : options.subscription; + + const paymentMethod = + options?.paymentMethod === undefined + ? { + id: "pm-1", + storeId: STORE, + userId: OWNER, + provider: StorePassPaymentProvider.NOMBA, + type: StorePassPaymentMethodType.CARD_TOKEN, + nombaTokenKey: "tok_secret_should_never_leak", + cardType: "Visa", + maskedCardPan: "4***45**** ****111*", + tokenExpiryMonth: 12, + tokenExpiryYear: 2099, + isDefault: true, + revokedAt: null, + } + : options.paymentMethod; + + const prisma = { + storeProfile: { findUnique: jest.fn().mockResolvedValue(store) }, + storePassEntitlement: { + findMany: jest.fn().mockResolvedValue([ + { + entitlementType: "ANALYTICS_ACCESS", + balance: null, + isEnabled: true, + periodStart: new Date(), + periodEnd: futureDate(), + }, + ]), + }, + storePassPaymentMethod: { + findFirst: jest.fn().mockResolvedValue(paymentMethod), + }, + storePassInvoice: { + findMany: jest.fn().mockResolvedValue([]), + findFirst: jest.fn().mockResolvedValue(null), + }, + storePassBillingAttempt: { + findMany: jest.fn().mockResolvedValue([]), + findFirst: jest.fn().mockResolvedValue(null), + count: jest.fn().mockResolvedValue(0), + }, + storePassSubscription: { + update: jest.fn().mockImplementation(({ where, data }) => ({ + ...subscription, + id: where.id, + ...data, + })), + }, + }; + + const storePassService = { + getStorePassEligibility: jest.fn().mockResolvedValue({ + canSubscribeToPaidPlan: true, + requiredTier: "TIER_2", + currentTier: "TIER_2", + }), + getCurrentSubscriptionForStore: jest.fn().mockResolvedValue(subscription), + resolveStorePassPublicBadge: jest.fn().mockResolvedValue({ + planCode: StorePassPlanCode.GROWTH, + label: "StorePass Growth", + }), + startStorePassCheckout: jest.fn().mockResolvedValue({ + checkoutUrl: "https://checkout.example/abc", + orderReference: "storepass_inv_inv-1_1", + invoiceId: "inv-1", + billingAttemptId: "att-1", + planCode: StorePassPlanCode.GROWTH, + amountKobo: 450000n, + }), + }; + + const plansService = { + getPlans: jest.fn().mockResolvedValue([ + { + code: StorePassPlanCode.FREE, + name: "StorePass Free", + description: null, + priceKobo: 0n, + currency: "NGN", + interval: "MONTHLY", + sortOrder: 0, + benefits: [], + isActive: true, + }, + { + code: StorePassPlanCode.GROWTH, + name: "StorePass Growth", + description: null, + priceKobo: 450000n, + currency: "NGN", + interval: "MONTHLY", + sortOrder: 1, + benefits: [], + isActive: true, + }, + ]), + }; + + const entitlementsService = { + getEntitlementUsage: jest.fn().mockResolvedValue([]), + consumeEntitlement: jest.fn(), + }; + + const reconciliationService = { + reconcileByOrderReference: jest.fn().mockResolvedValue({ + scanned: 0, + confirmedPaid: 0, + confirmedFailed: 0, + stillPending: 0, + flaggedForReview: 0, + }), + }; + + const service = new StorePassStoreModeService( + prisma as never, + storePassService as never, + plansService as never, + entitlementsService as never, + reconciliationService as never, + ); + + return { + service, + prisma, + storePassService, + plansService, + entitlementsService, + reconciliationService, + }; +} + +describe("StorePassStoreModeService.getOverview", () => { + it("returns a complete overview with a safe payment-method summary", async () => { + const { service } = makeHarness(); + + const overview = await service.getOverview(OWNER, STORE); + + expect(overview.store).toEqual({ + id: STORE, + storeHandle: "ada_store", + storeName: "Ada Store", + }); + expect(overview.currentPlan?.code).toBe(StorePassPlanCode.GROWTH); + expect(overview.subscription?.status).toBe( + StorePassSubscriptionStatus.ACTIVE, + ); + expect(overview.flags).toEqual({ + isPaid: true, + cancelAtPeriodEnd: false, + canCancel: true, + canResume: false, + needsRecard: false, + }); + + // Safe payment-method summary — token key must never leak. + expect(overview.paymentMethod).not.toBeNull(); + expect(overview.paymentMethod?.maskedCardPan).toBe("4***45**** ****111*"); + expect(Object.keys(overview.paymentMethod as object)).not.toContain( + "nombaTokenKey", + ); + expect(JSON.stringify(overview.paymentMethod)).not.toContain( + "tok_secret_should_never_leak", + ); + }); + + it("flags needsRecard when the paid store has no payment method", async () => { + const { service } = makeHarness({ paymentMethod: null }); + + const overview = await service.getOverview(OWNER, STORE); + + expect(overview.flags.isPaid).toBe(true); + expect(overview.flags.needsRecard).toBe(true); + expect(overview.paymentMethod).toBeNull(); + }); + + it("rejects a user who does not own the store", async () => { + const { service } = makeHarness({ storeOwnerId: "someone-else" }); + + await expect(service.getOverview(OWNER, STORE)).rejects.toMatchObject({ + response: { code: "NOT_STORE_OWNER" }, + }); + }); +}); + +describe("StorePassStoreModeService.getBillingHealth", () => { + it("returns a safe billing-health summary scoped to the current store", async () => { + const { service, entitlementsService } = makeHarness(); + entitlementsService.getEntitlementUsage.mockResolvedValue([ + { + entitlementType: "WIZZA_DISCOVERY_CREDIT", + isCredit: true, + balance: 17, + isEnabled: true, + periodStart: new Date(), + periodEnd: futureDate(), + grantedThisPeriod: 20, + usedThisPeriod: 3, + }, + ]); + + const health = await service.getBillingHealth(OWNER, STORE); + + expect(health.subscription?.status).toBe( + StorePassSubscriptionStatus.ACTIVE, + ); + expect(health.needsRecard).toBe(false); + expect(health.counts).toEqual({ + pendingAttempts: 0, + failedAttempts: 0, + needsReview: 0, + }); + expect(health.entitlementUsage[0].usedThisPeriod).toBe(3); + // Never leaks the token key across the Store Mode boundary. + expect(JSON.stringify(health)).not.toContain( + "tok_secret_should_never_leak", + ); + }); + + it("rejects a non-owner", async () => { + const { service } = makeHarness({ storeOwnerId: "someone-else" }); + await expect(service.getBillingHealth(OWNER, STORE)).rejects.toMatchObject({ + response: { code: "NOT_STORE_OWNER" }, + }); + }); +}); + +describe("StorePassStoreModeService.useEntitlement", () => { + it("delegates to the entitlements service with the acting user", async () => { + const { service, entitlementsService } = makeHarness(); + entitlementsService.consumeEntitlement.mockResolvedValue({ + entitlementType: "WIZZA_DISCOVERY_CREDIT", + balance: 17, + consumed: 3, + alreadyApplied: false, + }); + + const result = await service.useEntitlement(OWNER, STORE, { + entitlementType: "WIZZA_DISCOVERY_CREDIT" as never, + amount: 3, + }); + + expect(result.consumed).toBe(3); + expect(entitlementsService.consumeEntitlement).toHaveBeenCalledWith( + expect.objectContaining({ + storeId: STORE, + amount: 3, + createdByUserId: OWNER, + }), + ); + }); +}); + +describe("StorePassStoreModeService.cancelAtPeriodEnd / resume", () => { + it("sets cancelAtPeriodEnd on an active paid subscription", async () => { + const { service, prisma } = makeHarness(); + + const result = await service.cancelAtPeriodEnd(OWNER, STORE); + + const updateArg = prisma.storePassSubscription.update.mock.calls[0][0]; + expect(updateArg.data.cancelAtPeriodEnd).toBe(true); + expect(updateArg.data.cancelledAt).toBeInstanceOf(Date); + expect(result.cancelAtPeriodEnd).toBe(true); + }); + + it("rejects cancel when there is no active paid subscription", async () => { + const { service } = makeHarness({ + subscription: { + id: "sub-1", + planCodeSnapshot: StorePassPlanCode.FREE, + status: StorePassSubscriptionStatus.FREE, + cancelAtPeriodEnd: false, + currentPeriodEnd: null, + }, + }); + + await expect(service.cancelAtPeriodEnd(OWNER, STORE)).rejects.toMatchObject( + { + response: { code: "STOREPASS_NO_ACTIVE_SUBSCRIPTION" }, + }, + ); + }); + + it("clears cancelAtPeriodEnd on resume within the current period", async () => { + const { service, prisma } = makeHarness({ + subscription: { + id: "sub-1", + storeId: STORE, + ownerUserId: OWNER, + planCodeSnapshot: StorePassPlanCode.GROWTH, + status: StorePassSubscriptionStatus.ACTIVE, + currentPeriodStart: new Date(), + currentPeriodEnd: futureDate(), + cancelAtPeriodEnd: true, + cancelledAt: new Date(), + lastPaidAt: new Date(), + nextBillingAt: futureDate(), + }, + }); + + const result = await service.resume(OWNER, STORE); + + const updateArg = prisma.storePassSubscription.update.mock.calls[0][0]; + expect(updateArg.data.cancelAtPeriodEnd).toBe(false); + expect(updateArg.data.cancelledAt).toBeNull(); + expect(result.cancelAtPeriodEnd).toBe(false); + }); + + it("rejects resume when the period has already ended", async () => { + const { service } = makeHarness({ + subscription: { + id: "sub-1", + planCodeSnapshot: StorePassPlanCode.GROWTH, + status: StorePassSubscriptionStatus.ACTIVE, + currentPeriodStart: new Date(), + currentPeriodEnd: new Date(Date.now() - 1000), + cancelAtPeriodEnd: true, + cancelledAt: new Date(), + }, + }); + + await expect(service.resume(OWNER, STORE)).rejects.toMatchObject({ + response: { code: "STOREPASS_PERIOD_ENDED" }, + }); + }); +}); + +describe("StorePassStoreModeService.startRecard", () => { + it("starts a re-card checkout for the current paid plan", async () => { + const { service, storePassService } = makeHarness(); + + const result = await service.startRecard(OWNER, STORE); + + expect(storePassService.startStorePassCheckout).toHaveBeenCalledWith({ + userId: OWNER, + storeId: STORE, + planCode: StorePassPlanCode.GROWTH, + purpose: "STOREPASS_RECARD", + }); + expect(result.checkoutUrl).toBe("https://checkout.example/abc"); + }); + + it("rejects re-card when there is no paid subscription", async () => { + const { service, storePassService } = makeHarness({ + subscription: { + id: "sub-1", + planCodeSnapshot: StorePassPlanCode.FREE, + status: StorePassSubscriptionStatus.FREE, + }, + }); + + await expect(service.startRecard(OWNER, STORE)).rejects.toMatchObject({ + response: { code: "STOREPASS_NO_PAID_SUBSCRIPTION" }, + }); + expect(storePassService.startStorePassCheckout).not.toHaveBeenCalled(); + }); +}); + +describe("StorePassStoreModeService.confirmReturn", () => { + it("reconciles the store's in-flight attempt then returns the overview", async () => { + const { service, prisma, reconciliationService } = makeHarness(); + prisma.storePassBillingAttempt.findFirst.mockResolvedValue({ + orderReference: "storepass_inv_inv-1_1", + }); + + const overview = await service.confirmReturn(OWNER, STORE); + + expect(prisma.storePassBillingAttempt.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { storeId: STORE, status: "PROCESSING" }, + }), + ); + expect( + reconciliationService.reconcileByOrderReference, + ).toHaveBeenCalledWith("storepass_inv_inv-1_1"); + expect(overview.flags.isPaid).toBe(true); + }); + + it("returns the overview without a provider call when nothing is in flight", async () => { + const { service, reconciliationService } = makeHarness(); + + const overview = await service.confirmReturn(OWNER, STORE); + + expect( + reconciliationService.reconcileByOrderReference, + ).not.toHaveBeenCalled(); + expect(overview.store.id).toBe(STORE); + }); + + it("rejects a user who does not own the store", async () => { + const { service } = makeHarness({ storeOwnerId: "someone-else" }); + + await expect(service.confirmReturn(OWNER, STORE)).rejects.toMatchObject({ + response: { code: "NOT_STORE_OWNER" }, + }); + }); +}); diff --git a/apps/backend/src/domains/money/storepass/storepass-store-mode.service.ts b/apps/backend/src/domains/money/storepass/storepass-store-mode.service.ts new file mode 100644 index 00000000..bb5a3ad7 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-store-mode.service.ts @@ -0,0 +1,606 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, +} from "@nestjs/common"; +import { + StorePassBillingAttempt, + StorePassBillingAttemptStatus, + StorePassPaymentMethod, + StorePassPlan, + StorePassPlanCode, + StorePassSubscription, + StorePassSubscriptionStatus, +} from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { UseEntitlementDto } from "./dto/use-entitlement.dto"; +import { + ConsumeEntitlementResult, + StorePassEntitlementsService, +} from "./storepass-entitlements.service"; +import { StorePassPlansService } from "./storepass-plans.service"; +import { StorePassReconciliationService } from "./storepass-reconciliation.service"; +import { StorePassService } from "./storepass.service"; +import { + StorePassBillingAttemptHealthView, + StorePassBillingHealthView, + StorePassEntitlementView, + StorePassInvoiceView, + StorePassOverview, + StorePassOverviewFlags, + StorePassPaymentMethodSummary, + StorePassPlanView, + StorePassSubscriptionView, +} from "./types/storepass-store-mode.types"; +import { + StartStorePassCheckoutResult, + StorePassCheckoutPurpose, + StorePassPaidPlanCode, +} from "./types/storepass.types"; + +interface StoreIdentity { + id: string; + userId: string; + storeHandle: string | null; + storeName: string | null; +} + +/** Statuses that represent a store currently on a paid StorePass plan. */ +const PAID_SUBSCRIPTION_STATUSES: readonly StorePassSubscriptionStatus[] = [ + StorePassSubscriptionStatus.ACTIVE, + StorePassSubscriptionStatus.PAST_DUE, + StorePassSubscriptionStatus.CANCELLED_AT_PERIOD_END, +]; + +/** + * Store Mode (store-owner) StorePass API surface. Every method is scoped to a + * single store the authenticated user owns; ownership is asserted here so a user + * can never read or mutate another store's StorePass state. StorePass belongs to + * the StoreProfile. No provider secrets, tokens, card PAN, or storeType are ever + * returned. + */ +@Injectable() +export class StorePassStoreModeService { + private readonly logger = new Logger(StorePassStoreModeService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly storePassService: StorePassService, + private readonly plansService: StorePassPlansService, + private readonly entitlementsService: StorePassEntitlementsService, + private readonly reconciliationService: StorePassReconciliationService, + ) {} + + /** Full StorePass overview for the current store. */ + async getOverview( + userId: string, + storeId: string, + ): Promise { + const store = await this.assertStoreOwnership(userId, storeId); + + const [ + eligibility, + subscription, + entitlements, + paymentMethod, + plans, + badge, + ] = await Promise.all([ + this.storePassService.getStorePassEligibility(userId, storeId), + this.storePassService.getCurrentSubscriptionForStore(storeId), + this.prisma.storePassEntitlement.findMany({ + where: { storeId }, + orderBy: { entitlementType: "asc" }, + }), + this.getDefaultPaymentMethod(storeId), + this.plansService.getPlans(), + this.storePassService.resolveStorePassPublicBadge(storeId), + ]); + + const planViews = plans.map((plan) => this.toPlanView(plan)); + const activePlanCode = + subscription?.planCodeSnapshot ?? StorePassPlanCode.FREE; + const currentPlan = + planViews.find((plan) => plan.code === activePlanCode) ?? null; + + const flags = this.buildFlags(subscription, paymentMethod); + + return { + store: { + id: store.id, + storeHandle: store.storeHandle, + storeName: store.storeName, + }, + eligibility, + currentPlan, + subscription: subscription ? this.toSubscriptionView(subscription) : null, + entitlements: entitlements.map((entitlement) => + this.toEntitlementView(entitlement), + ), + paymentMethod: paymentMethod + ? this.toPaymentMethodSummary(paymentMethod) + : null, + badge, + plans: planViews, + flags, + }; + } + + /** Available StorePass plans (public, safe). */ + async getPlans(): Promise { + const plans = await this.plansService.getPlans(); + return plans.map((plan) => this.toPlanView(plan)); + } + + /** Paid-plan eligibility for the current store. */ + async getEligibility(userId: string, storeId: string) { + await this.assertStoreOwnership(userId, storeId); + return this.storePassService.getStorePassEligibility(userId, storeId); + } + + /** Current entitlement balances and access flags. */ + async getEntitlements( + userId: string, + storeId: string, + ): Promise { + await this.assertStoreOwnership(userId, storeId); + const entitlements = await this.prisma.storePassEntitlement.findMany({ + where: { storeId }, + orderBy: { entitlementType: "asc" }, + }); + return entitlements.map((entitlement) => + this.toEntitlementView(entitlement), + ); + } + + /** Billing/invoice history for the current store (most recent first). */ + async getInvoices( + userId: string, + storeId: string, + limit = 20, + ): Promise { + await this.assertStoreOwnership(userId, storeId); + const take = Math.min(Math.max(limit, 1), 100); + const invoices = await this.prisma.storePassInvoice.findMany({ + where: { storeId }, + orderBy: { createdAt: "desc" }, + take, + }); + return invoices.map((invoice) => this.toInvoiceView(invoice)); + } + + /** + * Safe billing-health / proof summary for the current store's own StorePass + * subscription: subscription state, card health, latest invoice, redacted + * recent billing attempts (renewal/retry/reconciliation state), health counts, + * and entitlement usage. Never exposes provider tokens, secrets, raw payloads, + * full card data, storeType, or any other store's data. + */ + async getBillingHealth( + userId: string, + storeId: string, + ): Promise { + await this.assertStoreOwnership(userId, storeId); + + const [ + subscription, + paymentMethod, + latestInvoice, + recentAttempts, + pendingAttempts, + failedAttempts, + needsReview, + entitlementUsage, + ] = await Promise.all([ + this.storePassService.getCurrentSubscriptionForStore(storeId), + this.getDefaultPaymentMethod(storeId), + this.prisma.storePassInvoice.findFirst({ + where: { storeId }, + orderBy: { createdAt: "desc" }, + }), + this.prisma.storePassBillingAttempt.findMany({ + where: { storeId }, + orderBy: { createdAt: "desc" }, + take: 10, + }), + this.prisma.storePassBillingAttempt.count({ + where: { storeId, status: StorePassBillingAttemptStatus.PROCESSING }, + }), + this.prisma.storePassBillingAttempt.count({ + where: { storeId, status: StorePassBillingAttemptStatus.FAILED }, + }), + this.prisma.storePassBillingAttempt.count({ + where: { storeId, needsReview: true }, + }), + this.entitlementsService.getEntitlementUsage(storeId), + ]); + + return { + subscription: subscription ? this.toSubscriptionView(subscription) : null, + paymentMethod: paymentMethod + ? this.toPaymentMethodSummary(paymentMethod) + : null, + needsRecard: this.paymentMethodNeedsRecard(paymentMethod), + latestInvoice: latestInvoice ? this.toInvoiceView(latestInvoice) : null, + recentAttempts: recentAttempts.map((attempt) => + this.toAttemptHealthView(attempt), + ), + counts: { pendingAttempts, failedAttempts, needsReview }, + entitlementUsage, + }; + } + + /** + * Consumes credit from one of the current store's StorePass entitlements and + * records an auditable USE ledger entry. The authenticated user is captured as + * the actor; the consume is scoped to the current store only. + */ + async useEntitlement( + userId: string, + storeId: string, + dto: UseEntitlementDto, + ): Promise { + await this.assertStoreOwnership(userId, storeId); + return this.entitlementsService.consumeEntitlement({ + storeId, + entitlementType: dto.entitlementType, + amount: dto.amount, + referenceType: dto.referenceType, + referenceId: dto.referenceId, + reason: dto.reason, + createdByUserId: userId, + }); + } + + /** + * Starts a Growth/Pro subscription or upgrade checkout. Ownership, + * paid-plan eligibility, and record creation happen in the checkout + * orchestration (StorePassService); the authenticated user is the payer. + */ + async startCheckout(input: { + userId: string; + storeId: string; + planCode: StorePassPaidPlanCode; + purpose: Extract< + StorePassCheckoutPurpose, + "STOREPASS_SUBSCRIPTION" | "STOREPASS_UPGRADE" + >; + }): Promise { + await this.assertStoreOwnership(input.userId, input.storeId); + return this.storePassService.startStorePassCheckout({ + userId: input.userId, + storeId: input.storeId, + planCode: input.planCode, + purpose: input.purpose, + }); + } + + /** + * Post-checkout "confirm on return" for the current store. The hosted-checkout + * return page calls this so activation never waits on webhook delivery: it + * finds the store's most recent in-flight (PROCESSING) billing attempt and + * verifies it against the provider, activating through the shared reconcile + + * activation path if the provider confirms the payment. Idempotent — once the + * subscription is active there is no PROCESSING attempt left, so it degrades to + * a plain overview read with no provider call. Always returns the fresh + * overview so the caller can render the result without a second round trip. + */ + async confirmReturn( + userId: string, + storeId: string, + ): Promise { + await this.assertStoreOwnership(userId, storeId); + + const pending = await this.prisma.storePassBillingAttempt.findFirst({ + where: { storeId, status: StorePassBillingAttemptStatus.PROCESSING }, + orderBy: { createdAt: "desc" }, + select: { orderReference: true }, + }); + + if (pending) { + await this.reconciliationService.reconcileByOrderReference( + pending.orderReference, + ); + } + + return this.getOverview(userId, storeId); + } + + /** + * Starts a re-card checkout for a store already on a paid plan (for a + * missing, expired, revoked, or failed payment method). Re-uses the current + * plan; the authenticated user is the payer. + */ + async startRecard( + userId: string, + storeId: string, + ): Promise { + await this.assertStoreOwnership(userId, storeId); + + const subscription = + await this.storePassService.getCurrentSubscriptionForStore(storeId); + if ( + !subscription || + subscription.planCodeSnapshot === StorePassPlanCode.FREE || + !PAID_SUBSCRIPTION_STATUSES.includes(subscription.status) + ) { + throw new BadRequestException({ + message: "No paid StorePass subscription to re-card", + code: "STOREPASS_NO_PAID_SUBSCRIPTION", + }); + } + + return this.storePassService.startStorePassCheckout({ + userId, + storeId, + planCode: subscription.planCodeSnapshot as StorePassPaidPlanCode, + purpose: "STOREPASS_RECARD", + }); + } + + /** Sets cancelAtPeriodEnd = true on the active paid subscription. */ + async cancelAtPeriodEnd( + userId: string, + storeId: string, + ): Promise { + await this.assertStoreOwnership(userId, storeId); + const subscription = await this.requirePaidActiveSubscription(storeId); + + if (subscription.cancelAtPeriodEnd) { + return this.toSubscriptionView(subscription); + } + + const updated = await this.prisma.storePassSubscription.update({ + where: { id: subscription.id }, + data: { cancelAtPeriodEnd: true, cancelledAt: new Date() }, + }); + this.logger.log( + `StorePass subscription ${subscription.id} set to cancel at period end.`, + ); + return this.toSubscriptionView(updated); + } + + /** Clears cancelAtPeriodEnd if still active and inside the current period. */ + async resume( + userId: string, + storeId: string, + ): Promise { + await this.assertStoreOwnership(userId, storeId); + const subscription = await this.requirePaidActiveSubscription(storeId); + + if (!subscription.cancelAtPeriodEnd) { + return this.toSubscriptionView(subscription); + } + + const now = new Date(); + if ( + !subscription.currentPeriodEnd || + subscription.currentPeriodEnd.getTime() <= now.getTime() + ) { + throw new BadRequestException({ + message: "Subscription period has ended; start a new checkout instead", + code: "STOREPASS_PERIOD_ENDED", + }); + } + + const updated = await this.prisma.storePassSubscription.update({ + where: { id: subscription.id }, + data: { cancelAtPeriodEnd: false, cancelledAt: null }, + }); + this.logger.log(`StorePass subscription ${subscription.id} resumed.`); + return this.toSubscriptionView(updated); + } + + // ── helpers ───────────────────────────────────────────────────────────── + + /** Loads store identity and asserts the user owns it. */ + private async assertStoreOwnership( + userId: string, + storeId: string, + ): Promise { + const store = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + select: { + id: true, + userId: true, + storeHandle: true, + storeName: true, + }, + }); + if (!store || store.userId !== userId) { + throw new ForbiddenException({ + message: "You do not manage this store", + code: "NOT_STORE_OWNER", + }); + } + return store; + } + + private async requirePaidActiveSubscription( + storeId: string, + ): Promise { + const subscription = + await this.storePassService.getCurrentSubscriptionForStore(storeId); + if ( + !subscription || + subscription.planCodeSnapshot === StorePassPlanCode.FREE || + subscription.status !== StorePassSubscriptionStatus.ACTIVE + ) { + throw new BadRequestException({ + message: "No active paid StorePass subscription", + code: "STOREPASS_NO_ACTIVE_SUBSCRIPTION", + }); + } + return subscription; + } + + /** Default active (non-revoked) payment method for the store, if any. */ + private async getDefaultPaymentMethod( + storeId: string, + ): Promise { + const method = await this.prisma.storePassPaymentMethod.findFirst({ + where: { storeId, revokedAt: null }, + orderBy: [{ isDefault: "desc" }, { updatedAt: "desc" }], + }); + return method; + } + + private buildFlags( + subscription: StorePassSubscription | null, + paymentMethod: StorePassPaymentMethod | null, + ): StorePassOverviewFlags { + const isPaid = Boolean( + subscription && + subscription.planCodeSnapshot !== StorePassPlanCode.FREE && + PAID_SUBSCRIPTION_STATUSES.includes(subscription.status), + ); + const isActive = + subscription?.status === StorePassSubscriptionStatus.ACTIVE && + subscription.planCodeSnapshot !== StorePassPlanCode.FREE; + const cancelAtPeriodEnd = Boolean(subscription?.cancelAtPeriodEnd); + const now = Date.now(); + const insidePeriod = Boolean( + subscription?.currentPeriodEnd && + subscription.currentPeriodEnd.getTime() > now, + ); + + return { + isPaid, + cancelAtPeriodEnd, + canCancel: Boolean(isActive && !cancelAtPeriodEnd), + canResume: Boolean(isActive && cancelAtPeriodEnd && insidePeriod), + needsRecard: isPaid && this.paymentMethodNeedsRecard(paymentMethod), + }; + } + + /** A paid store needs a re-card when it has no usable, unexpired card. */ + private paymentMethodNeedsRecard( + paymentMethod: StorePassPaymentMethod | null, + ): boolean { + if (!paymentMethod || paymentMethod.revokedAt) { + return true; + } + return this.isCardExpired( + paymentMethod.tokenExpiryMonth, + paymentMethod.tokenExpiryYear, + ); + } + + private isCardExpired(month: number | null, year: number | null): boolean { + if (!month || !year) { + return false; + } + const now = new Date(); + // A card is valid through the end of its expiry month. + const expiry = new Date(year, month, 1); + return expiry.getTime() <= now.getTime(); + } + + private toPlanView(plan: StorePassPlan): StorePassPlanView { + return { + code: plan.code, + name: plan.name, + description: plan.description, + priceKobo: plan.priceKobo, + currency: plan.currency, + interval: plan.interval, + sortOrder: plan.sortOrder, + benefits: plan.benefits, + isActive: plan.isActive, + }; + } + + private toSubscriptionView( + subscription: StorePassSubscription, + ): StorePassSubscriptionView { + return { + status: subscription.status, + planCode: subscription.planCodeSnapshot, + currentPeriodStart: subscription.currentPeriodStart, + currentPeriodEnd: subscription.currentPeriodEnd, + cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, + cancelledAt: subscription.cancelledAt, + lastPaidAt: subscription.lastPaidAt, + nextBillingAt: subscription.nextBillingAt, + }; + } + + private toEntitlementView(entitlement: { + entitlementType: StorePassEntitlementView["entitlementType"]; + balance: number | null; + isEnabled: boolean; + periodStart: Date | null; + periodEnd: Date | null; + }): StorePassEntitlementView { + return { + entitlementType: entitlement.entitlementType, + balance: entitlement.balance, + isEnabled: entitlement.isEnabled, + periodStart: entitlement.periodStart, + periodEnd: entitlement.periodEnd, + }; + } + + private toPaymentMethodSummary( + method: StorePassPaymentMethod, + ): StorePassPaymentMethodSummary { + // Deliberately omits nombaTokenKey and any sensitive fields. + return { + provider: method.provider, + type: method.type, + cardType: method.cardType, + maskedCardPan: method.maskedCardPan, + tokenExpiryMonth: method.tokenExpiryMonth, + tokenExpiryYear: method.tokenExpiryYear, + isDefault: method.isDefault, + }; + } + + private toInvoiceView(invoice: { + id: string; + planCodeSnapshot: StorePassPlanCode; + amountKobo: bigint; + currency: string; + status: StorePassInvoiceView["status"]; + billingPeriodStart: Date; + billingPeriodEnd: Date; + dueAt: Date | null; + paidAt: Date | null; + createdAt: Date; + }): StorePassInvoiceView { + return { + id: invoice.id, + planCode: invoice.planCodeSnapshot, + amountKobo: invoice.amountKobo, + currency: invoice.currency, + status: invoice.status, + billingPeriodStart: invoice.billingPeriodStart, + billingPeriodEnd: invoice.billingPeriodEnd, + dueAt: invoice.dueAt, + paidAt: invoice.paidAt, + createdAt: invoice.createdAt, + }; + } + + /** Redacted billing-attempt view — never exposes tokens, payloads, or secrets. */ + private toAttemptHealthView( + attempt: StorePassBillingAttempt, + ): StorePassBillingAttemptHealthView { + return { + attemptNumber: attempt.attemptNumber, + status: attempt.status, + amountKobo: attempt.amountKobo, + currency: attempt.currency, + retryCount: attempt.retryCount, + maxRetries: attempt.maxRetries, + nextRetryAt: attempt.nextRetryAt, + lastReconciledAt: attempt.lastReconciledAt, + reconciliationStatus: attempt.reconciliationStatus, + needsReview: attempt.needsReview, + failureReason: attempt.failureReason, + createdAt: attempt.createdAt, + }; + } +} diff --git a/apps/backend/src/domains/money/storepass/storepass-store.controller.ts b/apps/backend/src/domains/money/storepass/storepass-store.controller.ts new file mode 100644 index 00000000..df1817f3 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-store.controller.ts @@ -0,0 +1,128 @@ +import { + Body, + Controller, + ForbiddenException, + Get, + DefaultValuePipe, + ParseIntPipe, + Post, + Query, + UseGuards, +} from "@nestjs/common"; +import { JwtPayload, UserRole } from "@twizrr/shared"; + +import { CurrentUser } from "../../../common/decorators/current-user.decorator"; +import { Roles } from "../../../common/decorators/roles.decorator"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { RolesGuard } from "../../../common/guards/roles.guard"; +import { CheckoutStorePassDto } from "./dto/checkout-storepass.dto"; +import { UseEntitlementDto } from "./dto/use-entitlement.dto"; +import { StorePassStoreModeService } from "./storepass-store-mode.service"; + +/** + * Store Mode StorePass API. StorePass is store-owner only: every route is + * scoped to the authenticated user's own store (storeId from the JWT), and the + * service asserts ownership so a user can never touch another store's StorePass. + */ +@Controller("store/storepass") +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.USER) +export class StorePassStoreController { + constructor(private readonly storeMode: StorePassStoreModeService) {} + + @Get() + getOverview(@CurrentUser() user: JwtPayload) { + return this.storeMode.getOverview(user.sub, this.requireStoreId(user)); + } + + @Get("plans") + getPlans() { + return this.storeMode.getPlans(); + } + + @Get("eligibility") + getEligibility(@CurrentUser() user: JwtPayload) { + return this.storeMode.getEligibility(user.sub, this.requireStoreId(user)); + } + + @Get("entitlements") + getEntitlements(@CurrentUser() user: JwtPayload) { + return this.storeMode.getEntitlements(user.sub, this.requireStoreId(user)); + } + + @Get("invoices") + getInvoices( + @CurrentUser() user: JwtPayload, + @Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number, + ) { + return this.storeMode.getInvoices( + user.sub, + this.requireStoreId(user), + limit, + ); + } + + @Get("billing-health") + getBillingHealth(@CurrentUser() user: JwtPayload) { + return this.storeMode.getBillingHealth(user.sub, this.requireStoreId(user)); + } + + @Post("entitlements/use") + useEntitlement( + @CurrentUser() user: JwtPayload, + @Body() dto: UseEntitlementDto, + ) { + return this.storeMode.useEntitlement( + user.sub, + this.requireStoreId(user), + dto, + ); + } + + @Post("checkout") + startCheckout( + @CurrentUser() user: JwtPayload, + @Body() dto: CheckoutStorePassDto, + ) { + return this.storeMode.startCheckout({ + userId: user.sub, + storeId: this.requireStoreId(user), + planCode: dto.planCode, + purpose: dto.purpose ?? "STOREPASS_SUBSCRIPTION", + }); + } + + @Post("confirm") + confirmReturn(@CurrentUser() user: JwtPayload) { + return this.storeMode.confirmReturn(user.sub, this.requireStoreId(user)); + } + + @Post("recard") + startRecard(@CurrentUser() user: JwtPayload) { + return this.storeMode.startRecard(user.sub, this.requireStoreId(user)); + } + + @Post("cancel") + cancel(@CurrentUser() user: JwtPayload) { + return this.storeMode.cancelAtPeriodEnd( + user.sub, + this.requireStoreId(user), + ); + } + + @Post("resume") + resume(@CurrentUser() user: JwtPayload) { + return this.storeMode.resume(user.sub, this.requireStoreId(user)); + } + + /** StorePass is Store Mode only — a store context is mandatory. */ + private requireStoreId(user: JwtPayload): string { + if (!user.storeId) { + throw new ForbiddenException({ + message: "Store identity required", + code: "STORE_IDENTITY_REQUIRED", + }); + } + return user.storeId; + } +} diff --git a/apps/backend/src/domains/money/storepass/storepass-webhook.controller.ts b/apps/backend/src/domains/money/storepass/storepass-webhook.controller.ts new file mode 100644 index 00000000..c7e3d7bf --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-webhook.controller.ts @@ -0,0 +1,29 @@ +import { Controller, HttpCode, Post, Req } from "@nestjs/common"; +import type { RawBodyRequest } from "@nestjs/common"; +import { SkipThrottle } from "@nestjs/throttler"; +import type { Request } from "express"; + +import { StorePassWebhookService } from "./storepass-webhook.service"; + +/** + * Nomba subscription-billing webhook endpoint. + * + * Always returns 200 so Nomba does not retry storm on handled outcomes + * (invalid signature, duplicate, business rejections). The raw body is required + * for signature verification, so this route relies on the app-level rawBody + * capture (see main.ts) and is exempt from rate limiting. + */ +@Controller("webhooks") +export class StorePassWebhookController { + constructor(private readonly webhookService: StorePassWebhookService) {} + + @Post("nomba") + @HttpCode(200) + @SkipThrottle() + handleNomba(@Req() req: RawBodyRequest) { + return this.webhookService.handleNombaWebhook({ + rawBody: req.rawBody, + headers: req.headers, + }); + } +} diff --git a/apps/backend/src/domains/money/storepass/storepass-webhook.service.spec.ts b/apps/backend/src/domains/money/storepass/storepass-webhook.service.spec.ts new file mode 100644 index 00000000..995f35f2 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-webhook.service.spec.ts @@ -0,0 +1,234 @@ +import { + Prisma, + StorePassBillingAttemptStatus, + StorePassInvoiceStatus, + StorePassPlanCode, +} from "@prisma/client"; + +import { StorePassWebhookService } from "./storepass-webhook.service"; + +function successEvent(overrides: Record = {}) { + return { + type: "SUBSCRIPTION_PAYMENT_SUCCEEDED", + provider: "NOMBA", + reference: "storepass_inv_inv-1_1", + orderReference: "storepass_inv_inv-1_1", + amountKobo: 450000n, + occurredAt: new Date("2026-07-06T00:00:00.000Z"), + rawEventId: "req-1", + requestId: "req-1", + storeId: "store-1", + invoiceId: "inv-1", + subscriptionId: "sub-1", + billingAttemptId: "att-1", + providerTransactionId: "txn-1", + planCode: StorePassPlanCode.GROWTH, + purpose: "STOREPASS_SUBSCRIPTION", + tokenizedCard: { + tokenKey: "tok_1", + cardType: "Visa", + maskedCardPan: "4***45**** ****111*", + tokenExpiryMonth: "05", + tokenExpiryYear: "2030", + }, + ...overrides, + }; +} + +function makeWebhookHarness(options?: { + event?: Record; + attempt?: Record | null; + verification?: { status: string; amountKobo: bigint }; + createThrowsDuplicate?: boolean; + signatureValid?: boolean; +}) { + const attempt = + options?.attempt === null + ? null + : { + id: "att-1", + storeId: "store-1", + invoiceId: "inv-1", + subscriptionId: "sub-1", + status: StorePassBillingAttemptStatus.PROCESSING, + providerTransactionId: null, + invoice: { + id: "inv-1", + amountKobo: 450000n, + status: StorePassInvoiceStatus.PENDING, + planId: "plan-growth", + planCodeSnapshot: StorePassPlanCode.GROWTH, + paidByUserId: "user-1", + }, + subscription: { id: "sub-1", ownerUserId: "user-1" }, + ...options?.attempt, + }; + + const createFn = options?.createThrowsDuplicate + ? jest.fn().mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("dup", { + code: "P2002", + clientVersion: "test", + }), + ) + : jest.fn().mockResolvedValue({ id: "evt-1" }); + + const prisma = { + storePassBillingEvent: { + create: createFn, + findUnique: jest.fn().mockResolvedValue({ id: "evt-existing" }), + update: jest.fn(), + }, + storePassBillingAttempt: { + findUnique: jest.fn().mockResolvedValue(attempt), + update: jest.fn(), + }, + storePassInvoice: { update: jest.fn() }, + $transaction: jest + .fn() + .mockImplementation(async (arg: unknown) => + Array.isArray(arg) ? Promise.all(arg) : undefined, + ), + }; + + // Activation is a separate collaborator (shared with reconciliation). The + // webhook's job is to verify and delegate; the atomic state change is asserted + // in the activation service's own spec. + const activation = { applyPaymentSuccess: jest.fn() }; + const billingProvider = { + name: "NOMBA", + verifyWebhookSignature: jest + .fn() + .mockReturnValue(options?.signatureValid ?? true), + parseWebhookEvent: jest + .fn() + .mockReturnValue(options?.event ?? successEvent()), + verifySubscriptionPayment: jest + .fn() + .mockResolvedValue( + options?.verification ?? { status: "SUCCESS", amountKobo: 450000n }, + ), + }; + + const service = new StorePassWebhookService( + prisma as never, + activation as never, + billingProvider as never, + ); + + return { service, prisma, activation, billingProvider }; +} + +const RAW = Buffer.from('{"event_type":"payment_success"}'); + +describe("StorePassWebhookService.handleNombaWebhook", () => { + it("verifies with the provider then delegates activation once on payment success", async () => { + const { service, activation, billingProvider } = makeWebhookHarness(); + + const result = await service.handleNombaWebhook({ + rawBody: RAW, + headers: {}, + }); + + expect(result.status).toBe("processed"); + + // Provider lookup confirmed success before any activation. + expect(billingProvider.verifySubscriptionPayment).toHaveBeenCalledWith( + "storepass_inv_inv-1_1", + ); + + // Activation delegated exactly once with the loaded records + card + event. + expect(activation.applyPaymentSuccess).toHaveBeenCalledTimes(1); + const arg = activation.applyPaymentSuccess.mock.calls[0][0]; + expect(arg.attempt.id).toBe("att-1"); + expect(arg.invoice.id).toBe("inv-1"); + expect(arg.subscription.id).toBe("sub-1"); + expect(arg.providerTransactionId).toBe("txn-1"); + expect(arg.tokenizedCard.tokenKey).toBe("tok_1"); + expect(arg.eventRowId).toBe("evt-1"); + }); + + it("never processes an unverified webhook", async () => { + const { service, billingProvider, prisma } = makeWebhookHarness({ + signatureValid: false, + }); + + const result = await service.handleNombaWebhook({ + rawBody: RAW, + headers: {}, + }); + + expect(result.status).toBe("invalid_signature"); + expect(billingProvider.parseWebhookEvent).not.toHaveBeenCalled(); + expect(prisma.storePassBillingEvent.create).not.toHaveBeenCalled(); + }); + + it("returns duplicate for an already-received event id", async () => { + const { service, prisma, activation } = makeWebhookHarness({ + createThrowsDuplicate: true, + }); + + const result = await service.handleNombaWebhook({ + rawBody: RAW, + headers: {}, + }); + + expect(result.status).toBe("duplicate"); + expect(prisma.storePassBillingAttempt.findUnique).not.toHaveBeenCalled(); + expect(activation.applyPaymentSuccess).not.toHaveBeenCalled(); + }); + + it("rejects an amount mismatch without activating", async () => { + const { service, activation } = makeWebhookHarness({ + event: successEvent({ amountKobo: 1n }), + }); + + const result = await service.handleNombaWebhook({ + rawBody: RAW, + headers: {}, + }); + + expect(result.status).toBe("amount_mismatch"); + expect(activation.applyPaymentSuccess).not.toHaveBeenCalled(); + }); + + it("does not activate when the provider transaction lookup is not successful", async () => { + const { service, activation, billingProvider } = makeWebhookHarness({ + verification: { status: "PENDING", amountKobo: 450000n }, + }); + + const result = await service.handleNombaWebhook({ + rawBody: RAW, + headers: {}, + }); + + expect(result.status).toBe("verification_failed"); + expect(billingProvider.verifySubscriptionPayment).toHaveBeenCalled(); + expect(activation.applyPaymentSuccess).not.toHaveBeenCalled(); + }); + + it("is idempotent when the attempt already succeeded", async () => { + const { service, activation } = makeWebhookHarness({ + attempt: { + status: StorePassBillingAttemptStatus.SUCCEEDED, + invoice: { + id: "inv-1", + amountKobo: 450000n, + status: StorePassInvoiceStatus.PAID, + planId: "plan-growth", + planCodeSnapshot: StorePassPlanCode.GROWTH, + paidByUserId: "user-1", + }, + subscription: { id: "sub-1", ownerUserId: "user-1" }, + }, + }); + + const result = await service.handleNombaWebhook({ + rawBody: RAW, + headers: {}, + }); + + expect(result.status).toBe("already_processed"); + expect(activation.applyPaymentSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/domains/money/storepass/storepass-webhook.service.ts b/apps/backend/src/domains/money/storepass/storepass-webhook.service.ts new file mode 100644 index 00000000..f33963f0 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass-webhook.service.ts @@ -0,0 +1,482 @@ +import { createHash } from "crypto"; + +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { + Prisma, + StorePassBillingAttemptStatus, + StorePassBillingEventStatus, + StorePassInvoiceStatus, + StorePassPaymentProvider, +} from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { + SUBSCRIPTION_BILLING_PROVIDER, + SubscriptionBillingProvider, + SubscriptionBillingProviderEvent, +} from "../subscription-billing/providers/subscription-billing-provider.interface"; +import { StorePassActivationService } from "./storepass-activation.service"; + +export interface WebhookOutcome { + status: string; +} + +/** + * Processes verified Nomba subscription-billing webhooks and reconciles them + * into StorePass state (invoice, billing attempt, subscription, entitlements). + * + * Non-negotiable rules enforced here: + * - Never activate a subscription from an unverified webhook. + * - Never grant entitlements before the provider transaction lookup confirms + * the payment; redirect/callback alone is never trusted. + * - Idempotent: deduped by provider request id, and all state changes happen in + * a single DB transaction so entitlements are never double-granted. + * - No secrets, signatures, tokens, card PAN/CVV/OTP/PIN, or full raw payloads + * are logged or stored — only a payload hash and a sanitized field subset. + */ +@Injectable() +export class StorePassWebhookService { + private readonly logger = new Logger(StorePassWebhookService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly activation: StorePassActivationService, + @Inject(SUBSCRIPTION_BILLING_PROVIDER) + private readonly billingProvider: SubscriptionBillingProvider, + ) {} + + async handleNombaWebhook(input: { + rawBody: Buffer | string | undefined; + headers: Record; + }): Promise { + const { rawBody, headers } = input; + + if (!rawBody) { + this.logger.warn("StorePass webhook missing raw body — ignoring"); + return { status: "ignored" }; + } + + // 1. Verify the Nomba signature. An unverified webhook is never processed. + const verified = this.billingProvider.verifyWebhookSignature({ + rawBody, + headers, + }); + if (!verified) { + this.logger.warn( + "StorePass webhook signature verification failed — ignoring", + ); + return { status: "invalid_signature" }; + } + + // 2. Parse/normalize into a provider-agnostic event. + let event: SubscriptionBillingProviderEvent; + try { + event = this.billingProvider.parseWebhookEvent({ rawBody, headers }); + } catch (error) { + this.logger.warn( + `StorePass webhook could not be parsed: ${this.errorMessage(error)}`, + ); + return { status: "ignored" }; + } + + // 3. Dedupe by provider request/event id via the DB unique constraint. + const dedupe = await this.startBillingEvent(event, rawBody); + if (!dedupe.shouldProcess) { + this.logger.log( + `Duplicate StorePass webhook skipped: ${dedupe.providerRequestId}`, + ); + return { status: "duplicate" }; + } + + try { + return await this.dispatch(event, dedupe.id); + } catch (error) { + // Unexpected/transient failure: keep the event retryable (no processedAt). + await this.markEventTransientFailure(dedupe.id, error); + throw error; + } + } + + private async dispatch( + event: SubscriptionBillingProviderEvent, + eventRowId: string, + ): Promise { + switch (event.type) { + case "SUBSCRIPTION_PAYMENT_SUCCEEDED": + return this.handlePaymentSucceeded(event, eventRowId); + case "SUBSCRIPTION_PAYMENT_FAILED": + return this.handlePaymentFailed(event, eventRowId); + case "SUBSCRIPTION_PAYMENT_REVERSED": + case "SUBSCRIPTION_CANCELLED": + // Reversal/cancellation reconciliation is handled by a later PR; record + // the event as processed so it is not reprocessed. + await this.finalizeEvent( + eventRowId, + StorePassBillingEventStatus.IGNORED, + ); + return { status: "ignored" }; + default: + await this.finalizeEvent( + eventRowId, + StorePassBillingEventStatus.IGNORED, + ); + return { status: "ignored" }; + } + } + + private async handlePaymentSucceeded( + event: Extract< + SubscriptionBillingProviderEvent, + { type: "SUBSCRIPTION_PAYMENT_SUCCEEDED" } + >, + eventRowId: string, + ): Promise { + const orderReference = event.orderReference || event.reference; + if (!orderReference) { + await this.finalizeEvent( + eventRowId, + StorePassBillingEventStatus.FAILED, + "missing order reference", + ); + return { status: "ignored" }; + } + + const attempt = await this.prisma.storePassBillingAttempt.findUnique({ + where: { orderReference }, + include: { invoice: true, subscription: true }, + }); + + if (!attempt || !attempt.subscription || !attempt.subscriptionId) { + await this.finalizeEvent( + eventRowId, + StorePassBillingEventStatus.FAILED, + "no billing attempt for order reference", + ); + return { status: "unknown_reference" }; + } + + const { invoice, subscription } = attempt; + + // Idempotency: a duplicate that slipped past dedupe still never re-activates. + if ( + attempt.status === StorePassBillingAttemptStatus.SUCCEEDED && + invoice.status === StorePassInvoiceStatus.PAID + ) { + await this.finalizeEvent( + eventRowId, + StorePassBillingEventStatus.PROCESSED, + undefined, + attempt, + ); + return { status: "already_processed" }; + } + + // Verify the webhook amount matches the invoice (BigInt kobo — exact match). + if (event.amountKobo !== invoice.amountKobo) { + await this.failAttemptAndEvent( + attempt.id, + invoice.id, + eventRowId, + `amount mismatch (event=${event.amountKobo} invoice=${invoice.amountKobo})`, + ); + return { status: "amount_mismatch" }; + } + + // Verify metadata against local records where the webhook supplied it. + const metadataMismatch = this.findMetadataMismatch(event, attempt); + if (metadataMismatch) { + await this.failAttemptAndEvent( + attempt.id, + invoice.id, + eventRowId, + `metadata mismatch: ${metadataMismatch}`, + ); + return { status: "metadata_mismatch" }; + } + + // Confirm with the provider transaction lookup before doing anything. A + // webhook alone never activates a subscription. + const verification = + await this.billingProvider.verifySubscriptionPayment(orderReference); + if (verification.status !== "SUCCESS") { + this.logger.warn( + `StorePass transaction lookup did not confirm success for ${orderReference} (status=${verification.status})`, + ); + await this.finalizeEvent( + eventRowId, + StorePassBillingEventStatus.FAILED, + `transaction lookup status ${verification.status}`, + ); + return { status: "verification_failed" }; + } + if (verification.amountKobo !== invoice.amountKobo) { + await this.failAttemptAndEvent( + attempt.id, + invoice.id, + eventRowId, + `lookup amount mismatch (lookup=${verification.amountKobo} invoice=${invoice.amountKobo})`, + ); + return { status: "amount_mismatch" }; + } + + const now = new Date(); + const providerTransactionId = + event.providerTransactionId ?? + verification.rawProviderReference ?? + attempt.providerTransactionId ?? + null; + + // Single activation code path (shared with reconciliation) so activation + + // entitlement grant are atomic and entitlements are never double-granted. + await this.activation.applyPaymentSuccess({ + attempt, + invoice, + subscription, + providerTransactionId, + tokenizedCard: event.tokenizedCard, + eventRowId, + now, + }); + + return { status: "processed" }; + } + + private async handlePaymentFailed( + event: Extract< + SubscriptionBillingProviderEvent, + { type: "SUBSCRIPTION_PAYMENT_FAILED" } + >, + eventRowId: string, + ): Promise { + const orderReference = event.orderReference || event.reference; + const attempt = orderReference + ? await this.prisma.storePassBillingAttempt.findUnique({ + where: { orderReference }, + }) + : null; + + if (!attempt) { + await this.finalizeEvent( + eventRowId, + StorePassBillingEventStatus.IGNORED, + "no billing attempt for failed payment", + ); + return { status: "unknown_reference" }; + } + + const now = new Date(); + const failureReason = (event.reason ?? "payment failed").slice(0, 500); + + // Never mark an already-succeeded attempt as failed on a late/duplicate event. + if (attempt.status !== StorePassBillingAttemptStatus.SUCCEEDED) { + await this.prisma.$transaction([ + this.prisma.storePassBillingAttempt.update({ + where: { id: attempt.id }, + data: { + status: StorePassBillingAttemptStatus.FAILED, + failedAt: now, + failureReason, + }, + }), + this.prisma.storePassInvoice.update({ + where: { id: attempt.invoiceId }, + data: { status: StorePassInvoiceStatus.FAILED, failedAt: now }, + }), + ]); + } + + await this.finalizeEvent( + eventRowId, + StorePassBillingEventStatus.PROCESSED, + failureReason, + attempt, + ); + return { status: "processed" }; + } + + private findMetadataMismatch( + event: SubscriptionBillingProviderEvent, + attempt: { + id: string; + storeId: string; + invoiceId: string; + subscriptionId: string | null; + }, + ): string | null { + if (event.storeId && event.storeId !== attempt.storeId) { + return "storeId"; + } + if (event.invoiceId && event.invoiceId !== attempt.invoiceId) { + return "invoiceId"; + } + if (event.billingAttemptId && event.billingAttemptId !== attempt.id) { + return "billingAttemptId"; + } + if ( + event.subscriptionId && + attempt.subscriptionId && + event.subscriptionId !== attempt.subscriptionId + ) { + return "subscriptionId"; + } + return null; + } + + private async startBillingEvent( + event: SubscriptionBillingProviderEvent, + rawBody: Buffer | string, + ): Promise<{ + id: string; + providerRequestId: string; + shouldProcess: boolean; + }> { + const providerRequestId = event.requestId || event.rawEventId; + + try { + const created = await this.prisma.storePassBillingEvent.create({ + data: { + provider: StorePassPaymentProvider.NOMBA, + providerRequestId, + eventType: event.type, + orderReference: event.orderReference ?? event.reference ?? null, + providerTransactionId: event.providerTransactionId ?? null, + billingAttemptId: event.billingAttemptId ?? null, + invoiceId: event.invoiceId ?? null, + subscriptionId: event.subscriptionId ?? null, + storeId: event.storeId ?? null, + processingStatus: StorePassBillingEventStatus.RECEIVED, + payloadHash: createHash("sha256") + .update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody)) + .digest("hex"), + sanitizedPayload: this.sanitizeEvent(event), + }, + }); + return { id: created.id, providerRequestId, shouldProcess: true }; + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + const existing = await this.prisma.storePassBillingEvent.findUnique({ + where: { providerRequestId }, + }); + return { + id: existing?.id ?? "", + providerRequestId, + shouldProcess: false, + }; + } + throw error; + } + } + + /** Sanitized, non-sensitive subset persisted for audit. Never card/secret data. */ + private sanitizeEvent( + event: SubscriptionBillingProviderEvent, + ): Prisma.InputJsonValue { + const amountKobo = + "amountKobo" in event && event.amountKobo !== undefined + ? event.amountKobo.toString() + : null; + return { + type: event.type, + provider: event.provider, + reference: event.reference ?? null, + orderReference: event.orderReference ?? null, + providerTransactionId: event.providerTransactionId ?? null, + storeId: event.storeId ?? null, + invoiceId: event.invoiceId ?? null, + subscriptionId: event.subscriptionId ?? null, + billingAttemptId: event.billingAttemptId ?? null, + planCode: event.planCode ?? null, + purpose: event.purpose ?? null, + amountKobo, + occurredAt: event.occurredAt.toISOString(), + // Presence flag only — the token itself is never stored here. + hasTokenizedCard: Boolean(event.tokenizedCard), + } satisfies Prisma.InputJsonObject; + } + + private async failAttemptAndEvent( + attemptId: string, + invoiceId: string, + eventRowId: string, + reason: string, + ): Promise { + const now = new Date(); + const failureReason = reason.slice(0, 500); + await this.prisma.$transaction([ + this.prisma.storePassBillingAttempt.update({ + where: { id: attemptId }, + data: { + status: StorePassBillingAttemptStatus.FAILED, + failedAt: now, + failureReason, + }, + }), + this.prisma.storePassInvoice.update({ + where: { id: invoiceId }, + data: { status: StorePassInvoiceStatus.FAILED, failedAt: now }, + }), + this.prisma.storePassBillingEvent.update({ + where: { id: eventRowId }, + data: { + processingStatus: StorePassBillingEventStatus.FAILED, + processedAt: now, + failureReason, + }, + }), + ]); + this.logger.warn( + `StorePass webhook rejected for attempt ${attemptId}: ${failureReason}`, + ); + } + + private async finalizeEvent( + eventRowId: string, + status: StorePassBillingEventStatus, + failureReason?: string, + attempt?: { + id: string; + storeId: string; + invoiceId: string; + subscriptionId: string | null; + }, + ): Promise { + if (!eventRowId) { + return; + } + await this.prisma.storePassBillingEvent.update({ + where: { id: eventRowId }, + data: { + processingStatus: status, + processedAt: new Date(), + failureReason: failureReason?.slice(0, 500) ?? null, + billingAttemptId: attempt?.id ?? undefined, + invoiceId: attempt?.invoiceId ?? undefined, + subscriptionId: attempt?.subscriptionId ?? undefined, + storeId: attempt?.storeId ?? undefined, + }, + }); + } + + private async markEventTransientFailure( + eventRowId: string, + error: unknown, + ): Promise { + if (!eventRowId) { + return; + } + await this.prisma.storePassBillingEvent.update({ + where: { id: eventRowId }, + data: { + processingStatus: StorePassBillingEventStatus.FAILED, + failureReason: this.errorMessage(error).slice(0, 500), + }, + }); + } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +} diff --git a/apps/backend/src/domains/money/storepass/storepass.constants.spec.ts b/apps/backend/src/domains/money/storepass/storepass.constants.spec.ts new file mode 100644 index 00000000..7dee2311 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass.constants.spec.ts @@ -0,0 +1,64 @@ +import { StorePassBillingInterval, StorePassPlanCode } from "@prisma/client"; + +import { + buildStorePassCheckoutReturnUrl, + getStorePassPlanDefinition, + STOREPASS_CHECKOUT_RETURN_PATH, + STOREPASS_PLAN_DEFINITIONS, +} from "./storepass.constants"; + +describe("StorePass plan definitions", () => { + it("defines FREE, GROWTH, and PRO monthly plans", () => { + const codes = STOREPASS_PLAN_DEFINITIONS.map((plan) => plan.code); + expect(codes).toEqual([ + StorePassPlanCode.FREE, + StorePassPlanCode.GROWTH, + StorePassPlanCode.PRO, + ]); + for (const plan of STOREPASS_PLAN_DEFINITIONS) { + expect(plan.interval).toBe(StorePassBillingInterval.MONTHLY); + } + }); + + it("stores prices as BigInt kobo with the correct amounts", () => { + expect(getStorePassPlanDefinition(StorePassPlanCode.FREE).priceKobo).toBe( + 0n, + ); + expect(getStorePassPlanDefinition(StorePassPlanCode.GROWTH).priceKobo).toBe( + 450000n, + ); + expect(getStorePassPlanDefinition(StorePassPlanCode.PRO).priceKobo).toBe( + 1200000n, + ); + + for (const plan of STOREPASS_PLAN_DEFINITIONS) { + expect(typeof plan.priceKobo).toBe("bigint"); + } + }); +}); + +describe("buildStorePassCheckoutReturnUrl", () => { + it("appends the checkout return path to the frontend base", () => { + expect(buildStorePassCheckoutReturnUrl("https://app.twizrr.com")).toBe( + `https://app.twizrr.com${STOREPASS_CHECKOUT_RETURN_PATH}`, + ); + }); + + it("tolerates a trailing slash on the frontend base (no double slash)", () => { + expect(buildStorePassCheckoutReturnUrl("https://app.twizrr.com/")).toBe( + `https://app.twizrr.com${STOREPASS_CHECKOUT_RETURN_PATH}`, + ); + }); + + it("builds an identical URL for the checkout and recurring-charge flows", () => { + const base = "http://localhost:3000"; + // Both StorePassService (checkout) and StorePassChargeService (renewal/retry) + // build their callbackUrl through this helper, so they can never drift. + expect(buildStorePassCheckoutReturnUrl(base)).toBe( + buildStorePassCheckoutReturnUrl(base), + ); + expect(buildStorePassCheckoutReturnUrl(base)).toBe( + "http://localhost:3000/store/storepass/return", + ); + }); +}); diff --git a/apps/backend/src/domains/money/storepass/storepass.constants.ts b/apps/backend/src/domains/money/storepass/storepass.constants.ts new file mode 100644 index 00000000..0fbf2e37 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass.constants.ts @@ -0,0 +1,285 @@ +import { + StorePassBillingInterval, + StorePassEntitlementType, + StorePassPlanCode, +} from "@prisma/client"; + +/** + * StorePass is Twizrr's own monthly subscription engine for store owners. + * Twizrr sets the plan prices. Nomba is only the future billing rail; nothing + * in this domain calls any external provider. + * + * All money is stored as BigInt kobo. Never use Float or Decimal for money. + * All plan and entitlement configuration is centralised here so there are no + * scattered magic numbers across services or seeds. + */ + +export const STOREPASS_CURRENCY = "NGN"; + +/** + * Store Mode path the store owner lands on after a Nomba hosted checkout. The + * page polls the StorePass overview while activation is confirmed asynchronously + * by the verified webhook. Kept here so the checkout and recurring-charge flows + * build the exact same callback URL from a single source. + */ +export const STOREPASS_CHECKOUT_RETURN_PATH = "/store/storepass/return"; + +/** + * Builds the absolute checkout return URL from the configured frontend base, + * tolerating a trailing slash. Used by every StorePass provider call that needs + * a callbackUrl so the value can never drift between flows. + */ +export function buildStorePassCheckoutReturnUrl(frontendUrl: string): string { + return `${frontendUrl.replace(/\/$/, "")}${STOREPASS_CHECKOUT_RETURN_PATH}`; +} + +/** + * Canonical Twizrr-local order reference for a StorePass billing attempt. + * Format: `storepass_inv__`. This is Twizrr's source + * of truth; Nomba's merchantTxRef is only ever treated as a provider alias. + */ +export function buildStorePassOrderReference( + invoiceId: string, + attemptNumber: number, +): string { + return `storepass_inv_${invoiceId}_${attemptNumber}`; +} + +/** Returns the monthly billing period [start, end) for a StorePass invoice. */ +export function computeMonthlyBillingPeriod(start: Date): { + billingPeriodStart: Date; + billingPeriodEnd: Date; +} { + const billingPeriodEnd = new Date(start); + billingPeriodEnd.setMonth(billingPeriodEnd.getMonth() + 1); + return { billingPeriodStart: start, billingPeriodEnd }; +} + +/** + * Failed-payment retry policy for recurring StorePass charges. Mirrors the + * payout-retry cadence: the first retry waits 5 minutes, the second 30 minutes, + * the third 2 hours. `retryCount` is the number of retries already performed + * (0 = the initial attempt just failed, next retry uses index 0). Centralised + * here so the schedule is never scattered across services. + */ +export const STOREPASS_RETRY_BACKOFF_MINUTES: readonly number[] = [5, 30, 120]; + +/** Maximum number of retries after the initial failed charge before giving up. */ +export const STOREPASS_MAX_RETRIES = STOREPASS_RETRY_BACKOFF_MINUTES.length; + +/** + * Returns when the next retry should run given how many retries have already + * happened, or null when the retry budget is exhausted. `retryCount` is the + * retry index about to be scheduled (0 = first retry). + */ +export function computeNextRetryAt( + retryCount: number, + from: Date = new Date(), +): Date | null { + if (retryCount < 0 || retryCount >= STOREPASS_RETRY_BACKOFF_MINUTES.length) { + return null; + } + const minutes = STOREPASS_RETRY_BACKOFF_MINUTES[retryCount]; + return new Date(from.getTime() + minutes * 60_000); +} + +/** + * A billing attempt left in PROCESSING longer than this is considered "stuck" + * (webhook likely never arrived) and is reconciled against the provider. + */ +export const STOREPASS_STUCK_ATTEMPT_MINUTES = 30; + +/** + * After this long a still-unconfirmed PROCESSING attempt is flagged for manual + * review rather than reconciled indefinitely. + */ +export const STOREPASS_STUCK_ATTEMPT_REVIEW_MINUTES = 24 * 60; + +/** + * How far back the periodic reconciliation sweep looks for non-terminal attempts + * to re-check against the provider (catches missed webhooks). + */ +export const STOREPASS_RECONCILE_LOOKBACK_HOURS = 72; + +/** + * A tokenized card is usable through the end of its expiry month. Returns false + * when expiry is unknown (nothing to enforce against). Shared by renewal, retry, + * and Store Mode so the rule is defined once. + */ +export function isStorePassCardExpired( + month: number | null, + year: number | null, + now: Date = new Date(), +): boolean { + if (!month || !year) { + return false; + } + const expiry = new Date(year, month, 1); + return expiry.getTime() <= now.getTime(); +} + +/** + * Paid StorePass plans require store verification. The StoreProfile.tier enum + * (StoreTier) currently tops out at TIER_2, so TIER_2 is the paid-eligibility + * threshold. There is no TIER_3 in the schema, so it is intentionally not + * referenced here. + */ +export const STOREPASS_REQUIRED_PAID_TIER = "TIER_2" as const; + +/** Entitlement types whose value is a monthly credit balance. */ +export const STOREPASS_CREDIT_ENTITLEMENT_TYPES: readonly StorePassEntitlementType[] = + [ + StorePassEntitlementType.FEATURED_PLACEMENT_CREDIT, + StorePassEntitlementType.CAMPAIGN_BOOST_CREDIT, + StorePassEntitlementType.WIZZA_DISCOVERY_CREDIT, + ]; + +/** Entitlement types that are a simple on/off capability, not a balance. */ +export const STOREPASS_ACCESS_ENTITLEMENT_TYPES: readonly StorePassEntitlementType[] = + [ + StorePassEntitlementType.ANALYTICS_ACCESS, + StorePassEntitlementType.PRODUCT_VISIBILITY_INSIGHTS, + StorePassEntitlementType.PRIORITY_SUPPORT, + ]; + +export function isCreditEntitlementType( + type: StorePassEntitlementType, +): boolean { + return STOREPASS_CREDIT_ENTITLEMENT_TYPES.includes(type); +} + +export interface StorePassEntitlementDefault { + entitlementType: StorePassEntitlementType; + /** Monthly credit balance for credit entitlements, null for access ones. */ + balance: number | null; + /** Whether the capability is enabled for this plan. */ + isEnabled: boolean; +} + +export interface StorePassPlanDefinition { + code: StorePassPlanCode; + name: string; + description: string; + priceKobo: bigint; + interval: StorePassBillingInterval; + sortOrder: number; + benefits: string[]; + entitlements: StorePassEntitlementDefault[]; +} + +function creditEntitlement( + entitlementType: StorePassEntitlementType, + balance: number, +): StorePassEntitlementDefault { + return { entitlementType, balance, isEnabled: balance > 0 }; +} + +function accessEntitlement( + entitlementType: StorePassEntitlementType, + isEnabled: boolean, +): StorePassEntitlementDefault { + return { entitlementType, balance: null, isEnabled }; +} + +/** + * Monthly-only StorePass plans (MVP). Prices are BigInt kobo. + * FREE 0 kobo, GROWTH 450000 kobo (NGN 4,500), PRO 1200000 kobo (NGN 12,000). + */ +export const STOREPASS_PLAN_DEFINITIONS: readonly StorePassPlanDefinition[] = [ + { + code: StorePassPlanCode.FREE, + name: "StorePass Free", + description: + "The default StorePass state for every store. Basic store profile, basic product listing, and normal organic discovery.", + priceKobo: 0n, + interval: StorePassBillingInterval.MONTHLY, + sortOrder: 0, + benefits: [ + "Basic store profile", + "Basic product listing", + "Normal organic discovery", + ], + entitlements: [ + accessEntitlement(StorePassEntitlementType.ANALYTICS_ACCESS, false), + accessEntitlement( + StorePassEntitlementType.PRODUCT_VISIBILITY_INSIGHTS, + false, + ), + accessEntitlement(StorePassEntitlementType.PRIORITY_SUPPORT, false), + creditEntitlement(StorePassEntitlementType.FEATURED_PLACEMENT_CREDIT, 0), + creditEntitlement(StorePassEntitlementType.CAMPAIGN_BOOST_CREDIT, 0), + creditEntitlement(StorePassEntitlementType.WIZZA_DISCOVERY_CREDIT, 0), + ], + }, + { + code: StorePassPlanCode.GROWTH, + name: "StorePass Growth", + description: + "More reach for posts and products, featured placement and campaign boost credits, store analytics, product visibility insights, and WIZZA discovery credits.", + priceKobo: 450000n, + interval: StorePassBillingInterval.MONTHLY, + sortOrder: 1, + benefits: [ + "More reach for posts and products", + "Featured placement credits", + "Campaign boost credits", + "Store analytics", + "Product visibility insights", + "WIZZA discovery credits", + "WIZZA-driven product interest insights", + ], + entitlements: [ + accessEntitlement(StorePassEntitlementType.ANALYTICS_ACCESS, true), + accessEntitlement( + StorePassEntitlementType.PRODUCT_VISIBILITY_INSIGHTS, + true, + ), + accessEntitlement(StorePassEntitlementType.PRIORITY_SUPPORT, false), + creditEntitlement(StorePassEntitlementType.FEATURED_PLACEMENT_CREDIT, 3), + creditEntitlement(StorePassEntitlementType.CAMPAIGN_BOOST_CREDIT, 5), + creditEntitlement(StorePassEntitlementType.WIZZA_DISCOVERY_CREDIT, 20), + ], + }, + { + code: StorePassPlanCode.PRO, + name: "StorePass Pro", + description: + "More growth credits, higher featured-placement limits, advanced analytics, more campaign tools, more WIZZA discovery credits, better WIZZA-driven visibility reporting, and priority support.", + priceKobo: 1200000n, + interval: StorePassBillingInterval.MONTHLY, + sortOrder: 2, + benefits: [ + "More growth credits", + "Higher featured-placement limits", + "Advanced analytics", + "More campaign tools", + "More WIZZA discovery credits", + "Better WIZZA-driven visibility reporting", + "Priority support", + "More automation for store growth", + ], + entitlements: [ + accessEntitlement(StorePassEntitlementType.ANALYTICS_ACCESS, true), + accessEntitlement( + StorePassEntitlementType.PRODUCT_VISIBILITY_INSIGHTS, + true, + ), + accessEntitlement(StorePassEntitlementType.PRIORITY_SUPPORT, true), + creditEntitlement(StorePassEntitlementType.FEATURED_PLACEMENT_CREDIT, 10), + creditEntitlement(StorePassEntitlementType.CAMPAIGN_BOOST_CREDIT, 20), + creditEntitlement(StorePassEntitlementType.WIZZA_DISCOVERY_CREDIT, 75), + ], + }, +]; + +export function getStorePassPlanDefinition( + code: StorePassPlanCode, +): StorePassPlanDefinition { + const definition = STOREPASS_PLAN_DEFINITIONS.find( + (plan) => plan.code === code, + ); + if (!definition) { + throw new Error(`Unknown StorePass plan code: ${code}`); + } + return definition; +} diff --git a/apps/backend/src/domains/money/storepass/storepass.module.ts b/apps/backend/src/domains/money/storepass/storepass.module.ts new file mode 100644 index 00000000..1ce73106 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass.module.ts @@ -0,0 +1,74 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; + +import { SubscriptionBillingModule } from "../subscription-billing/subscription-billing.module"; +import { StorePassActivationService } from "./storepass-activation.service"; +import { StorePassCancellationService } from "./storepass-cancellation.service"; +import { StorePassChargeService } from "./storepass-charge.service"; +import { StorePassEntitlementsService } from "./storepass-entitlements.service"; +import { StorePassJobLockService } from "./storepass-job-lock.service"; +import { StorePassPlansService } from "./storepass-plans.service"; +import { StorePassReconciliationService } from "./storepass-reconciliation.service"; +import { StorePassRenewalService } from "./storepass-renewal.service"; +import { StorePassRetryService } from "./storepass-retry.service"; +import { StorePassSchedulerService } from "./storepass-scheduler.service"; +import { StorePassStoreController } from "./storepass-store.controller"; +import { StorePassStoreModeService } from "./storepass-store-mode.service"; +import { StorePassWebhookController } from "./storepass-webhook.controller"; +import { StorePassWebhookService } from "./storepass-webhook.service"; +import { StorePassService } from "./storepass.service"; + +/** + * StorePass domain module. Twizrr owns all StorePass business state + * (subscriptions, invoices, billing attempts, entitlements, activation). It + * drives payments through the provider-agnostic SubscriptionBillingProvider, + * whose active adapter is NombaSubscriptionBillingProvider. + * + * - StorePassService: checkout orchestration. + * - StorePassWebhookService/Controller: verified Nomba webhook reconciliation. + * - StorePassStoreModeService/Controller: the store-owner Store Mode API + * (/store/storepass) the web frontend consumes, including the billing-health + * proof surface and entitlement usage. + * - Subscription engine (all idempotent, driven by StorePassSchedulerService's + * @Cron ticks; the engine services are independently injectable so they can + * later run from a BullMQ processor without changing their logic): + * - StorePassChargeService: shared "create attempt + charge saved card". + * - StorePassActivationService: single activation path (shared with webhook). + * - StorePassRenewalService: monthly renewals. + * - StorePassRetryService: failed-payment retries. + * - StorePassReconciliationService: stuck-pending sweeper + provider recon. + * - StorePassCancellationService: cancel-at-period-end finalization. + */ +@Module({ + imports: [ConfigModule, SubscriptionBillingModule], + controllers: [StorePassStoreController, StorePassWebhookController], + providers: [ + StorePassService, + StorePassPlansService, + StorePassEntitlementsService, + StorePassWebhookService, + StorePassStoreModeService, + StorePassChargeService, + StorePassActivationService, + StorePassJobLockService, + StorePassRenewalService, + StorePassRetryService, + StorePassReconciliationService, + StorePassCancellationService, + StorePassSchedulerService, + ], + exports: [ + StorePassService, + StorePassPlansService, + StorePassEntitlementsService, + StorePassWebhookService, + StorePassStoreModeService, + StorePassChargeService, + StorePassActivationService, + StorePassRenewalService, + StorePassRetryService, + StorePassReconciliationService, + StorePassCancellationService, + ], +}) +export class StorePassModule {} diff --git a/apps/backend/src/domains/money/storepass/storepass.service.spec.ts b/apps/backend/src/domains/money/storepass/storepass.service.spec.ts new file mode 100644 index 00000000..cf8ba284 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass.service.spec.ts @@ -0,0 +1,384 @@ +import { + StorePassInvoiceStatus, + StorePassPlanCode, + StorePassSubscriptionStatus, + StoreTier, +} from "@prisma/client"; + +import { StorePassService } from "./storepass.service"; + +function makeService(prisma: unknown, plansService: unknown = {}) { + const entitlementsService = { + grantEntitlementsForSubscription: jest.fn(), + }; + const config = { get: jest.fn() }; + const billingProvider = { + name: "NOMBA", + initializeSubscriptionPayment: jest.fn(), + verifySubscriptionPayment: jest.fn(), + }; + return new StorePassService( + prisma as never, + plansService as never, + entitlementsService as never, + config as never, + billingProvider as never, + ); +} + +describe("StorePassService.getStorePassEligibility", () => { + it("locks a user who does not own the store", async () => { + const prisma = { + storeProfile: { + findUnique: jest.fn().mockResolvedValue({ + id: "store-1", + userId: "other-owner", + tier: StoreTier.TIER_2, + isOpen: true, + }), + }, + }; + const service = makeService(prisma); + + const result = await service.getStorePassEligibility("user-1", "store-1"); + + expect(result).toEqual({ + canSubscribeToPaidPlan: false, + requiredTier: "TIER_2", + currentTier: "TIER_0", + reason: "NOT_STORE_OWNER", + }); + }); + + it("locks when the store does not exist", async () => { + const prisma = { + storeProfile: { findUnique: jest.fn().mockResolvedValue(null) }, + }; + const service = makeService(prisma); + + const result = await service.getStorePassEligibility("user-1", "store-x"); + + expect(result.canSubscribeToPaidPlan).toBe(false); + expect(result.reason).toBe("NOT_STORE_OWNER"); + }); + + it.each([StoreTier.TIER_0, StoreTier.TIER_1])( + "does not allow a %s store owner onto paid plans", + async (tier) => { + const prisma = { + storeProfile: { + findUnique: jest.fn().mockResolvedValue({ + id: "store-1", + userId: "user-1", + tier, + isOpen: true, + }), + }, + }; + const service = makeService(prisma); + + const result = await service.getStorePassEligibility("user-1", "store-1"); + + expect(result.canSubscribeToPaidPlan).toBe(false); + expect(result.reason).toBe("STORE_NOT_VERIFIED_ENOUGH"); + expect(result.requiredTier).toBe("TIER_2"); + expect(result.currentTier).toBe(tier); + }, + ); + + it("allows a TIER_2 store owner onto paid plans", async () => { + const prisma = { + storeProfile: { + findUnique: jest.fn().mockResolvedValue({ + id: "store-1", + userId: "user-1", + tier: StoreTier.TIER_2, + isOpen: true, + }), + }, + }; + const service = makeService(prisma); + + const result = await service.getStorePassEligibility("user-1", "store-1"); + + expect(result).toEqual({ + canSubscribeToPaidPlan: true, + requiredTier: "TIER_2", + currentTier: "TIER_2", + }); + }); +}); + +describe("StorePassService.getOrCreateFreeSubscriptionForStore", () => { + it("returns the existing subscription without creating or calling providers", async () => { + const existing = { + id: "sub-1", + storeId: "store-1", + status: StorePassSubscriptionStatus.FREE, + }; + const create = jest.fn(); + const prisma = { + storePassSubscription: { + findUnique: jest.fn().mockResolvedValue(existing), + create, + }, + storeProfile: { findUnique: jest.fn() }, + }; + const plansService = { getPlanByCode: jest.fn() }; + const service = makeService(prisma, plansService); + + const result = await service.getOrCreateFreeSubscriptionForStore("store-1"); + + expect(result).toBe(existing); + expect(create).not.toHaveBeenCalled(); + expect(plansService.getPlanByCode).not.toHaveBeenCalled(); + }); + + it("creates a Free subscription owned by the store's user with no external calls", async () => { + const created = { + id: "sub-1", + storeId: "store-1", + status: StorePassSubscriptionStatus.FREE, + }; + const prisma = { + storePassSubscription: { + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue(created), + }, + storeProfile: { + findUnique: jest + .fn() + .mockResolvedValue({ id: "store-1", userId: "owner-1" }), + }, + }; + const plansService = { + getPlanByCode: jest.fn().mockResolvedValue({ + id: "plan-free", + code: StorePassPlanCode.FREE, + priceKobo: 0n, + }), + }; + const service = makeService(prisma, plansService); + + const result = await service.getOrCreateFreeSubscriptionForStore("store-1"); + + expect(result).toBe(created); + expect(plansService.getPlanByCode).toHaveBeenCalledWith( + StorePassPlanCode.FREE, + ); + const createArg = prisma.storePassSubscription.create.mock.calls[0][0]; + expect(createArg.data).toMatchObject({ + storeId: "store-1", + ownerUserId: "owner-1", + planId: "plan-free", + planCodeSnapshot: StorePassPlanCode.FREE, + status: StorePassSubscriptionStatus.FREE, + }); + }); +}); + +describe("StorePassService.createInvoiceForSubscription", () => { + it("captures payer and store identity snapshots and BigInt kobo amount", async () => { + const prisma = { + storePassSubscription: { + findUnique: jest.fn().mockResolvedValue({ + id: "sub-1", + storeId: "store-1", + planCodeSnapshot: StorePassPlanCode.GROWTH, + }), + }, + storeProfile: { + findUnique: jest.fn().mockResolvedValue({ + storeHandle: "ada_store", + storeName: "Ada Store", + }), + }, + user: { + findUnique: jest.fn().mockResolvedValue({ + id: "owner-1", + email: "ada@example.com", + username: "ada", + displayName: null, + firstName: "Ada", + lastName: "Obi", + }), + }, + storePassInvoice: { + create: jest.fn().mockImplementation(({ data }) => ({ + id: "inv-1", + ...data, + })), + }, + }; + const plansService = { + getPlanByCode: jest.fn().mockResolvedValue({ + id: "plan-growth", + code: StorePassPlanCode.GROWTH, + priceKobo: 450000n, + }), + }; + const service = makeService(prisma, plansService); + + const invoice = await service.createInvoiceForSubscription({ + subscriptionId: "sub-1", + paidByUserId: "owner-1", + billingPeriodStart: new Date("2026-07-01T00:00:00.000Z"), + billingPeriodEnd: new Date("2026-08-01T00:00:00.000Z"), + }); + + const createArg = prisma.storePassInvoice.create.mock.calls[0][0]; + expect(createArg.data).toMatchObject({ + storeId: "store-1", + subscriptionId: "sub-1", + planId: "plan-growth", + planCodeSnapshot: StorePassPlanCode.GROWTH, + status: StorePassInvoiceStatus.PENDING, + paidByUserId: "owner-1", + paidByUserEmailSnapshot: "ada@example.com", + paidByUsernameSnapshot: "ada", + paidByDisplayNameSnapshot: "Ada Obi", + storeHandleSnapshot: "ada_store", + storeNameSnapshot: "Ada Store", + currency: "NGN", + }); + expect(createArg.data.amountKobo).toBe(450000n); + expect(typeof invoice.amountKobo).toBe("bigint"); + }); +}); + +describe("StorePassService.resolveStorePassPublicBadge", () => { + const FUTURE = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + + it("returns null for a store with no active paid plan", async () => { + const prisma = { + storePassSubscription: { + findUnique: jest.fn().mockResolvedValue({ + id: "sub-1", + storeId: "store-1", + status: StorePassSubscriptionStatus.FREE, + planCodeSnapshot: StorePassPlanCode.FREE, + currentPeriodEnd: null, + }), + }, + storeProfile: { + findUnique: jest + .fn() + .mockResolvedValue({ tier: StoreTier.TIER_2, isOpen: true }), + }, + }; + const service = makeService(prisma); + + await expect( + service.resolveStorePassPublicBadge("store-1"), + ).resolves.toBeNull(); + }); + + it("returns a variant badge for an active paid Tier 2 store", async () => { + const prisma = { + storePassSubscription: { + findUnique: jest.fn().mockResolvedValue({ + id: "sub-1", + storeId: "store-1", + status: StorePassSubscriptionStatus.ACTIVE, + planCodeSnapshot: StorePassPlanCode.PRO, + currentPeriodEnd: FUTURE, + }), + }, + storeProfile: { + findUnique: jest + .fn() + .mockResolvedValue({ tier: StoreTier.TIER_2, isOpen: true }), + }, + }; + const service = makeService(prisma); + + await expect( + service.resolveStorePassPublicBadge("store-1"), + ).resolves.toEqual({ + planCode: StorePassPlanCode.PRO, + label: "StorePass Pro", + variant: "pro", + activeUntil: FUTURE.toISOString(), + }); + }); + + it("returns null for a Tier 1 store even with an active paid plan", async () => { + const prisma = { + storePassSubscription: { + findUnique: jest.fn().mockResolvedValue({ + id: "sub-1", + storeId: "store-1", + status: StorePassSubscriptionStatus.ACTIVE, + planCodeSnapshot: StorePassPlanCode.GROWTH, + currentPeriodEnd: FUTURE, + }), + }, + storeProfile: { + findUnique: jest + .fn() + .mockResolvedValue({ tier: StoreTier.TIER_1, isOpen: true }), + }, + }; + const service = makeService(prisma); + + await expect( + service.resolveStorePassPublicBadge("store-1"), + ).resolves.toBeNull(); + }); +}); + +describe("StorePassService.resolveStorePassPublicBadgesForStores", () => { + it("batches lookups (2 queries) and maps eligible stores to badges", async () => { + const FUTURE = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + const subFindMany = jest.fn().mockResolvedValue([ + { + storeId: "store-1", + status: StorePassSubscriptionStatus.ACTIVE, + planCodeSnapshot: StorePassPlanCode.GROWTH, + currentPeriodEnd: FUTURE, + }, + { + storeId: "store-2", + status: StorePassSubscriptionStatus.FREE, + planCodeSnapshot: StorePassPlanCode.FREE, + currentPeriodEnd: null, + }, + ]); + const storeFindMany = jest.fn().mockResolvedValue([ + { id: "store-1", tier: StoreTier.TIER_2, isOpen: true }, + { id: "store-2", tier: StoreTier.TIER_2, isOpen: true }, + ]); + const prisma = { + storePassSubscription: { findMany: subFindMany }, + storeProfile: { findMany: storeFindMany }, + }; + const service = makeService(prisma); + + const result = await service.resolveStorePassPublicBadgesForStores([ + "store-1", + "store-2", + "store-1", + ]); + + // Exactly one query per table regardless of store count — no N+1. + expect(subFindMany).toHaveBeenCalledTimes(1); + expect(storeFindMany).toHaveBeenCalledTimes(1); + expect(result.get("store-1")?.variant).toBe("growth"); + expect(result.get("store-2")).toBeNull(); + }); + + it("returns an empty map without querying for no store ids", async () => { + const subFindMany = jest.fn(); + const storeFindMany = jest.fn(); + const service = makeService({ + storePassSubscription: { findMany: subFindMany }, + storeProfile: { findMany: storeFindMany }, + }); + + const result = await service.resolveStorePassPublicBadgesForStores([]); + + expect(result.size).toBe(0); + expect(subFindMany).not.toHaveBeenCalled(); + expect(storeFindMany).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/domains/money/storepass/storepass.service.ts b/apps/backend/src/domains/money/storepass/storepass.service.ts new file mode 100644 index 00000000..39a3c7aa --- /dev/null +++ b/apps/backend/src/domains/money/storepass/storepass.service.ts @@ -0,0 +1,574 @@ +import { randomUUID } from "crypto"; + +import { + BadGatewayException, + BadRequestException, + ForbiddenException, + Inject, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + StorePassBillingAttemptStatus, + StorePassInvoice, + StorePassInvoiceStatus, + StorePassPaymentProvider, + StorePassPlanCode, + StorePassSubscription, + StorePassSubscriptionStatus, + StoreTier, +} from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { + SUBSCRIPTION_BILLING_PROVIDER, + SubscriptionBillingProvider, +} from "../subscription-billing/providers/subscription-billing-provider.interface"; +import { computeStorePassPublicBadge } from "./storepass-badge"; +import { StorePassEntitlementsService } from "./storepass-entitlements.service"; +import { StorePassPlansService } from "./storepass-plans.service"; +import { + buildStorePassCheckoutReturnUrl, + buildStorePassOrderReference, + computeMonthlyBillingPeriod, + STOREPASS_CURRENCY, +} from "./storepass.constants"; +import { + StartStorePassCheckoutInput, + StartStorePassCheckoutResult, + StorePassBillingIdentitySnapshot, + StorePassEligibility, + StorePassPublicBadge, + StorePassStoreTier, +} from "./types/storepass.types"; + +interface StoreOwnershipContext { + id: string; + userId: string; + tier: StoreTier; + isOpen: boolean; + storeHandle: string | null; + storeName: string | null; +} + +interface BillingUserContext { + id: string; + email: string; + username: string | null; + displayName: string | null; + firstName: string; + lastName: string; +} + +@Injectable() +export class StorePassService { + private readonly logger = new Logger(StorePassService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly plansService: StorePassPlansService, + private readonly entitlementsService: StorePassEntitlementsService, + private readonly config: ConfigService, + @Inject(SUBSCRIPTION_BILLING_PROVIDER) + private readonly billingProvider: SubscriptionBillingProvider, + ) {} + + /** + * Starts a paid StorePass checkout (initial subscription, upgrade, or re-card) + * for a store the authenticated user manages. + * + * Twizrr's database is the source of truth: ownership and paid-plan + * eligibility are enforced here, records are created before any provider call, + * and the canonical orderReference is generated locally. Nomba only returns a + * hosted checkout URL — it never decides who paid. FREE never starts checkout. + */ + async startStorePassCheckout( + input: StartStorePassCheckoutInput, + ): Promise { + const { userId, storeId, planCode, purpose } = input; + + // FREE is the default state and never starts a Nomba checkout. + if ( + planCode !== StorePassPlanCode.GROWTH && + planCode !== StorePassPlanCode.PRO + ) { + throw new BadRequestException({ + message: "Only the Growth or Pro plan can start StorePass checkout", + code: "STOREPASS_PLAN_NOT_BILLABLE", + }); + } + + // Ownership + eligibility are enforced in backend logic, never trusted from + // the client. One user cannot start billing for another user's store. + const eligibility = await this.getStorePassEligibility(userId, storeId); + if (!eligibility.canSubscribeToPaidPlan) { + if (eligibility.reason === "NOT_STORE_OWNER") { + throw new ForbiddenException({ + message: "You do not manage this store", + code: "NOT_STORE_OWNER", + }); + } + throw new ForbiddenException({ + message: "Store is not eligible for a paid StorePass plan", + code: "TIER_REQUIREMENT_FAILED", + }); + } + + const [store, user, plan] = await Promise.all([ + this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + select: { + id: true, + userId: true, + storeHandle: true, + storeName: true, + }, + }), + this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + username: true, + displayName: true, + firstName: true, + lastName: true, + }, + }), + this.plansService.getPlanByCode(planCode), + ]); + + if (!store) { + throw new NotFoundException({ + message: "Store not found", + code: "STORE_NOT_FOUND", + }); + } + if (!user) { + throw new NotFoundException({ + message: "User not found", + code: "USER_NOT_FOUND", + }); + } + + // The subscription belongs to the store. Ensure one exists (created in the + // Free/default state if absent); payment activation upgrades it. + const subscription = + await this.getOrCreateFreeSubscriptionForStore(storeId); + + const now = new Date(); + const { billingPeriodStart, billingPeriodEnd } = + computeMonthlyBillingPeriod(now); + const snapshot = this.buildBillingIdentitySnapshot( + { storeHandle: store.storeHandle, storeName: store.storeName }, + user, + ); + + // Create the PENDING invoice and PROCESSING billing attempt atomically. The + // canonical orderReference embeds the invoice id and attempt number. + const { invoice, attempt } = await this.prisma.$transaction(async (tx) => { + const invoice = await tx.storePassInvoice.create({ + data: { + storeId, + subscriptionId: subscription.id, + planId: plan.id, + planCodeSnapshot: plan.code, + amountKobo: plan.priceKobo, + currency: STOREPASS_CURRENCY, + status: StorePassInvoiceStatus.PENDING, + billingPeriodStart, + billingPeriodEnd, + dueAt: billingPeriodStart, + ...snapshot, + }, + }); + + const attemptNumber = 1; + const orderReference = buildStorePassOrderReference( + invoice.id, + attemptNumber, + ); + + const attempt = await tx.storePassBillingAttempt.create({ + data: { + invoiceId: invoice.id, + storeId, + subscriptionId: subscription.id, + provider: StorePassPaymentProvider.NOMBA, + status: StorePassBillingAttemptStatus.PROCESSING, + amountKobo: plan.priceKobo, + currency: STOREPASS_CURRENCY, + orderReference, + idempotencyKey: randomUUID(), + attemptNumber, + startedAt: now, + }, + }); + + return { invoice, attempt }; + }); + + // Provider call happens outside the DB transaction so a slow network call + // never holds a write lock. amountKobo is passed as BigInt; the provider + // converts it to the Nomba amount string. + let authorizationUrl: string | undefined; + let rawProviderReference: string | undefined; + try { + const checkout = await this.billingProvider.initializeSubscriptionPayment( + { + storeId, + storeOwnerUserId: user.id, + planCode: plan.code, + reference: attempt.orderReference, + amountKobo: plan.priceKobo, + email: user.email, + callbackUrl: this.buildCheckoutCallbackUrl(), + metadata: { + storeId, + planCode: plan.code, + invoiceId: invoice.id, + subscriptionId: subscription.id, + billingAttemptId: attempt.id, + purpose, + }, + }, + ); + authorizationUrl = checkout.authorizationUrl; + rawProviderReference = checkout.rawProviderReference; + } catch (error) { + await this.markCheckoutInitFailed(attempt.id, invoice.id, error); + throw error; + } + + if (!authorizationUrl) { + await this.markCheckoutInitFailed( + attempt.id, + invoice.id, + new Error("provider returned no checkout URL"), + ); + throw new BadGatewayException({ + message: "Billing provider did not return a checkout URL", + code: "STOREPASS_CHECKOUT_UNAVAILABLE", + }); + } + + await this.prisma.storePassBillingAttempt.update({ + where: { id: attempt.id }, + data: { + checkoutUrl: authorizationUrl, + providerTransactionId: rawProviderReference ?? null, + }, + }); + + this.logger.log( + `Started StorePass ${purpose} checkout for store ${storeId} (invoice ${invoice.id}, attempt ${attempt.id}).`, + ); + + return { + checkoutUrl: authorizationUrl, + orderReference: attempt.orderReference, + invoiceId: invoice.id, + billingAttemptId: attempt.id, + planCode: plan.code, + amountKobo: plan.priceKobo, + }; + } + + /** Marks the attempt and invoice FAILED when checkout initialization fails. */ + private async markCheckoutInitFailed( + attemptId: string, + invoiceId: string, + error: unknown, + ): Promise { + const failureReason = ( + error instanceof Error ? error.message : String(error) + ).slice(0, 500); + const failedAt = new Date(); + await this.prisma.$transaction([ + this.prisma.storePassBillingAttempt.update({ + where: { id: attemptId }, + data: { + status: StorePassBillingAttemptStatus.FAILED, + failedAt, + failureReason, + }, + }), + this.prisma.storePassInvoice.update({ + where: { id: invoiceId }, + data: { status: StorePassInvoiceStatus.FAILED, failedAt }, + }), + ]); + } + + /** Twizrr checkout-return URL the store owner lands on after Nomba checkout. */ + private buildCheckoutCallbackUrl(): string { + const frontendUrl = + this.config.get("app.frontendUrl") ?? "http://localhost:3000"; + return buildStorePassCheckoutReturnUrl(frontendUrl); + } + + /** + * Determines whether the authenticated user may put this store on a paid + * StorePass plan. Enforced in service logic, never trusted from the client. + * Every store may hold the Free/default state; paid plans require TIER_2. + */ + async getStorePassEligibility( + userId: string, + storeId: string, + ): Promise { + const store = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + select: { id: true, userId: true, tier: true, isOpen: true }, + }); + + if (!store || store.userId !== userId) { + return { + canSubscribeToPaidPlan: false, + requiredTier: "TIER_2", + currentTier: "TIER_0", + reason: "NOT_STORE_OWNER", + }; + } + + const currentTier = store.tier as StorePassStoreTier; + const meetsTierRequirement = store.tier === StoreTier.TIER_2; + + if (!meetsTierRequirement) { + return { + canSubscribeToPaidPlan: false, + requiredTier: "TIER_2", + currentTier, + reason: "STORE_NOT_VERIFIED_ENOUGH", + }; + } + + return { + canSubscribeToPaidPlan: true, + requiredTier: "TIER_2", + currentTier, + }; + } + + /** Current StorePass subscription for a store, or null when none exists. */ + async getCurrentSubscriptionForStore( + storeId: string, + ): Promise { + return this.prisma.storePassSubscription.findUnique({ + where: { storeId }, + }); + } + + /** + * Returns the store's subscription, creating a Free/default one if absent. + * The subscription belongs to the StoreProfile and references the owning + * User as the actor. Performs no external provider calls. + */ + async getOrCreateFreeSubscriptionForStore( + storeId: string, + ): Promise { + const existing = await this.getCurrentSubscriptionForStore(storeId); + if (existing) { + return existing; + } + + const store = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + select: { id: true, userId: true }, + }); + if (!store) { + throw new NotFoundException({ + message: "Store not found", + code: "STORE_NOT_FOUND", + }); + } + + const freePlan = await this.plansService.getPlanByCode( + StorePassPlanCode.FREE, + ); + + return this.prisma.storePassSubscription.create({ + data: { + storeId: store.id, + ownerUserId: store.userId, + planId: freePlan.id, + planCodeSnapshot: StorePassPlanCode.FREE, + status: StorePassSubscriptionStatus.FREE, + }, + }); + } + + /** + * Grants the plan's entitlements to the store for its current subscription. + * Thin wrapper over the entitlements service so callers use StorePassService + * as the single StorePass entry point. + */ + async grantEntitlementsForSubscription(subscriptionId: string) { + return this.entitlementsService.grantEntitlementsForSubscription( + subscriptionId, + ); + } + + /** + * Resolves a store's safe public StorePass badge, or null when the store is + * not eligible to show one. The badge is a paid growth-plan marker, never a + * verification/trust badge, and exposes no subscription/billing/provider + * internals or storeType. + * + * Shown only when the store is on an active paid plan (Growth/Pro), is Tier 2+ + * (Tier 0/1 excluded), is open, and its current paid period has not expired — + * for ACTIVE, or CANCELLED_AT_PERIOD_END while still inside the period. FREE, + * INACTIVE, PAST_DUE, CANCELLED, and expired periods all resolve to null. + */ + async resolveStorePassPublicBadge( + storeId: string, + ): Promise { + const [subscription, store] = await Promise.all([ + this.getCurrentSubscriptionForStore(storeId), + this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + select: { tier: true, isOpen: true }, + }), + ]); + return computeStorePassPublicBadge(subscription, store); + } + + /** + * Batch variant of {@link resolveStorePassPublicBadge} for list/feed surfaces. + * Runs exactly two queries regardless of store count, avoiding N+1. Returns a + * map keyed by storeId; stores with no eligible badge map to null. + */ + async resolveStorePassPublicBadgesForStores( + storeIds: string[], + ): Promise> { + const result = new Map(); + const uniqueIds = [...new Set(storeIds)]; + if (uniqueIds.length === 0) { + return result; + } + + const [subscriptions, stores] = await Promise.all([ + this.prisma.storePassSubscription.findMany({ + where: { storeId: { in: uniqueIds } }, + select: { + storeId: true, + status: true, + planCodeSnapshot: true, + currentPeriodEnd: true, + }, + }), + this.prisma.storeProfile.findMany({ + where: { id: { in: uniqueIds } }, + select: { id: true, tier: true, isOpen: true }, + }), + ]); + + const subscriptionByStore = new Map( + subscriptions.map((subscription) => [subscription.storeId, subscription]), + ); + const storeById = new Map(stores.map((store) => [store.id, store])); + const now = new Date(); + + for (const storeId of uniqueIds) { + result.set( + storeId, + computeStorePassPublicBadge( + subscriptionByStore.get(storeId) ?? null, + storeById.get(storeId) ?? null, + now, + ), + ); + } + return result; + } + + /** + * Builds the historical identity snapshot stored on invoices so that billing + * records stay accurate even if the user or store is renamed later. + */ + buildBillingIdentitySnapshot( + store: Pick, + user: BillingUserContext | null, + ): StorePassBillingIdentitySnapshot { + const displayName = user + ? (user.displayName ?? `${user.firstName} ${user.lastName}`.trim()) + : null; + return { + paidByUserId: user?.id ?? null, + paidByUserEmailSnapshot: user?.email ?? null, + paidByUsernameSnapshot: user?.username ?? null, + paidByDisplayNameSnapshot: displayName, + storeHandleSnapshot: store.storeHandle ?? null, + storeNameSnapshot: store.storeName ?? null, + }; + } + + /** + * Creates a StorePass invoice for a subscription's billing period, capturing + * store + payer identity snapshots. Persists a local record only; it does not + * call any billing provider. + */ + async createInvoiceForSubscription(input: { + subscriptionId: string; + paidByUserId: string | null; + billingPeriodStart: Date; + billingPeriodEnd: Date; + dueAt?: Date; + }): Promise { + const subscription = await this.prisma.storePassSubscription.findUnique({ + where: { id: input.subscriptionId }, + }); + if (!subscription) { + throw new NotFoundException({ + message: "StorePass subscription not found", + code: "STOREPASS_SUBSCRIPTION_NOT_FOUND", + }); + } + + const [store, plan, payer] = await Promise.all([ + this.prisma.storeProfile.findUnique({ + where: { id: subscription.storeId }, + select: { storeHandle: true, storeName: true }, + }), + this.plansService.getPlanByCode(subscription.planCodeSnapshot), + input.paidByUserId + ? this.prisma.user.findUnique({ + where: { id: input.paidByUserId }, + select: { + id: true, + email: true, + username: true, + displayName: true, + firstName: true, + lastName: true, + }, + }) + : Promise.resolve(null), + ]); + + const snapshot = this.buildBillingIdentitySnapshot( + { + storeHandle: store?.storeHandle ?? null, + storeName: store?.storeName ?? null, + }, + payer, + ); + + return this.prisma.storePassInvoice.create({ + data: { + storeId: subscription.storeId, + subscriptionId: subscription.id, + planId: plan.id, + planCodeSnapshot: subscription.planCodeSnapshot, + amountKobo: plan.priceKobo, + currency: STOREPASS_CURRENCY, + status: StorePassInvoiceStatus.PENDING, + billingPeriodStart: input.billingPeriodStart, + billingPeriodEnd: input.billingPeriodEnd, + dueAt: input.dueAt ?? null, + ...snapshot, + }, + }); + } +} diff --git a/apps/backend/src/domains/money/storepass/types/storepass-store-mode.types.ts b/apps/backend/src/domains/money/storepass/types/storepass-store-mode.types.ts new file mode 100644 index 00000000..892a6b65 --- /dev/null +++ b/apps/backend/src/domains/money/storepass/types/storepass-store-mode.types.ts @@ -0,0 +1,156 @@ +import { + StorePassBillingAttemptStatus, + StorePassBillingInterval, + StorePassEntitlementType, + StorePassInvoiceStatus, + StorePassPaymentMethodType, + StorePassPaymentProvider, + StorePassPlanCode, + StorePassSubscriptionStatus, +} from "@prisma/client"; + +import { StorePassEligibility, StorePassPublicBadge } from "./storepass.types"; + +/** Safe public-facing view of a StorePass plan (no secrets — plans are public). */ +export interface StorePassPlanView { + code: StorePassPlanCode; + name: string; + description: string | null; + priceKobo: bigint; + currency: string; + interval: StorePassBillingInterval; + sortOrder: number; + benefits: unknown; + isActive: boolean; +} + +/** Store-owner view of the current subscription state. */ +export interface StorePassSubscriptionView { + status: StorePassSubscriptionStatus; + planCode: StorePassPlanCode; + currentPeriodStart: Date | null; + currentPeriodEnd: Date | null; + cancelAtPeriodEnd: boolean; + cancelledAt: Date | null; + lastPaidAt: Date | null; + nextBillingAt: Date | null; +} + +export interface StorePassEntitlementView { + entitlementType: StorePassEntitlementType; + balance: number | null; + isEnabled: boolean; + periodStart: Date | null; + periodEnd: Date | null; +} + +/** + * Safe payment-method summary. Never exposes the Nomba token key, card PAN, + * CVV/OTP/PIN, or any provider secret — only the already-masked PAN and the + * non-sensitive token metadata needed to render a "card on file" row. + */ +export interface StorePassPaymentMethodSummary { + provider: StorePassPaymentProvider; + type: StorePassPaymentMethodType; + cardType: string | null; + maskedCardPan: string | null; + tokenExpiryMonth: number | null; + tokenExpiryYear: number | null; + isDefault: boolean; +} + +export interface StorePassInvoiceView { + id: string; + planCode: StorePassPlanCode; + amountKobo: bigint; + currency: string; + status: StorePassInvoiceStatus; + billingPeriodStart: Date; + billingPeriodEnd: Date; + dueAt: Date | null; + paidAt: Date | null; + createdAt: Date; +} + +/** Derived flags the frontend uses to decide which actions to surface. */ +export interface StorePassOverviewFlags { + isPaid: boolean; + cancelAtPeriodEnd: boolean; + canCancel: boolean; + canResume: boolean; + needsRecard: boolean; +} + +/** + * Per-entitlement usage summary for the current period. Credit entitlements + * report a numeric balance and grant/use totals; access entitlements report an + * on/off `isEnabled` with null balance. Safe numbers only. + */ +export interface StorePassEntitlementUsageView { + entitlementType: StorePassEntitlementType; + isCredit: boolean; + balance: number | null; + isEnabled: boolean; + periodStart: Date | null; + periodEnd: Date | null; + grantedThisPeriod: number; + usedThisPeriod: number; +} + +/** + * Safe, redacted view of a recent billing attempt for the billing-health proof + * surface. Never exposes the provider token key, raw provider payload, secrets, + * card PAN/CVV/OTP/PIN, or webhook payloads — only the operational status a store + * owner needs to understand their billing health. + */ +export interface StorePassBillingAttemptHealthView { + attemptNumber: number; + status: StorePassBillingAttemptStatus; + amountKobo: bigint; + currency: string; + retryCount: number; + maxRetries: number; + nextRetryAt: Date | null; + lastReconciledAt: Date | null; + reconciliationStatus: string | null; + needsReview: boolean; + failureReason: string | null; + createdAt: Date; +} + +/** + * Safe billing-health / proof summary for the current store's own StorePass + * subscription. Demonstrates that the subscription engine is auditable + * (renewals, retries, reconciliation, entitlement usage) without leaking any + * provider or payment secrets across the Store Mode boundary. + */ +export interface StorePassBillingHealthView { + subscription: StorePassSubscriptionView | null; + paymentMethod: StorePassPaymentMethodSummary | null; + needsRecard: boolean; + latestInvoice: StorePassInvoiceView | null; + recentAttempts: StorePassBillingAttemptHealthView[]; + counts: { + pendingAttempts: number; + failedAttempts: number; + needsReview: number; + }; + entitlementUsage: StorePassEntitlementUsageView[]; +} + +/** Complete Store Mode StorePass overview for the current store. */ +export interface StorePassOverview { + store: { + id: string; + storeHandle: string | null; + storeName: string | null; + }; + eligibility: StorePassEligibility; + currentPlan: StorePassPlanView | null; + subscription: StorePassSubscriptionView | null; + entitlements: StorePassEntitlementView[]; + paymentMethod: StorePassPaymentMethodSummary | null; + badge: StorePassPublicBadge | null; + plans: StorePassPlanView[]; + flags: StorePassOverviewFlags; +} diff --git a/apps/backend/src/domains/money/storepass/types/storepass.types.ts b/apps/backend/src/domains/money/storepass/types/storepass.types.ts new file mode 100644 index 00000000..75a4a93f --- /dev/null +++ b/apps/backend/src/domains/money/storepass/types/storepass.types.ts @@ -0,0 +1,93 @@ +import { StorePassEntitlementType, StorePassPlanCode } from "@prisma/client"; + +/** + * StorePass checkout purposes supported in this PR. Renewals (STOREPASS_RENEWAL) + * are driven by the recurring billing job (a later PR), not by this entry point, + * so they are intentionally excluded from the checkout-start surface. + */ +export type StorePassCheckoutPurpose = + | "STOREPASS_SUBSCRIPTION" + | "STOREPASS_UPGRADE" + | "STOREPASS_RECARD"; + +/** Paid StorePass plan codes that can start a Nomba checkout (never FREE). */ +export type StorePassPaidPlanCode = "GROWTH" | "PRO"; + +export interface StartStorePassCheckoutInput { + /** Authenticated user initiating checkout — the payer of record. */ + userId: string; + /** Store the subscription belongs to. */ + storeId: string; + planCode: StorePassPaidPlanCode; + purpose: StorePassCheckoutPurpose; +} + +/** + * Normalized checkout result returned to the caller. The subscription is for the + * store; `paidByUserId` (captured on the invoice) is the authenticated user. + * Amounts are BigInt kobo — never Float/Decimal. + */ +export interface StartStorePassCheckoutResult { + checkoutUrl: string; + orderReference: string; + invoiceId: string; + billingAttemptId: string; + planCode: StorePassPlanCode; + amountKobo: bigint; +} + +/** + * StoreProfile.tier (StoreTier) currently has TIER_0, TIER_1, and TIER_2 only. + * There is no TIER_3 in the schema, so it is deliberately not part of this union. + */ +export type StorePassStoreTier = "TIER_0" | "TIER_1" | "TIER_2"; + +export type StorePassEligibilityReason = + | "NOT_STORE_OWNER" + | "STORE_NOT_VERIFIED_ENOUGH" + | "STORE_SUSPENDED" + | "STORE_CLOSED"; + +export interface StorePassEligibility { + canSubscribeToPaidPlan: boolean; + requiredTier: "TIER_2"; + currentTier: StorePassStoreTier; + reason?: StorePassEligibilityReason; +} + +export interface StorePassEntitlementPreview { + entitlementType: StorePassEntitlementType; + balance: number | null; + isEnabled: boolean; +} + +export interface StorePassPlanEntitlementsPreview { + planCode: StorePassPlanCode; + entitlements: StorePassEntitlementPreview[]; +} + +export type StorePassBadgeVariant = "growth" | "pro"; + +/** + * Safe public StorePass badge for a store on an active paid plan. This is a + * paid growth-plan marker, NOT a verification/trust badge. It exposes no + * subscription, billing, payment-method, or provider internals and no + * storeType — only what a public surface needs to render the chip. + */ +export interface StorePassPublicBadge { + planCode: Exclude; + label: string; + variant: StorePassBadgeVariant; + /** ISO end of the current paid period, if known. Safe, non-billing. */ + activeUntil?: string | null; +} + +/** Identity snapshot captured on invoices so old records stay accurate. */ +export interface StorePassBillingIdentitySnapshot { + paidByUserId: string | null; + paidByUserEmailSnapshot: string | null; + paidByUsernameSnapshot: string | null; + paidByDisplayNameSnapshot: string | null; + storeHandleSnapshot: string | null; + storeNameSnapshot: string | null; +} diff --git a/apps/backend/src/domains/money/subscription-billing/providers/not-configured-subscription-billing.provider.ts b/apps/backend/src/domains/money/subscription-billing/providers/not-configured-subscription-billing.provider.ts new file mode 100644 index 00000000..eea69cc9 --- /dev/null +++ b/apps/backend/src/domains/money/subscription-billing/providers/not-configured-subscription-billing.provider.ts @@ -0,0 +1,82 @@ +import { + CancelSubscriptionInput, + CancelSubscriptionResult, + ChargeTokenizedSubscriptionPaymentInput, + ChargeTokenizedSubscriptionPaymentResult, + CreateSubscriptionCustomerInput, + CreateSubscriptionCustomerResult, + InitializeSubscriptionPaymentInput, + InitializeSubscriptionPaymentResult, + ParseSubscriptionBillingWebhookEventInput, + SubscriptionBillingProvider, + SubscriptionBillingProviderEvent, + SubscriptionBillingProviderKey, + SubscriptionBillingProviderName, + VerifySubscriptionBillingWebhookSignatureInput, + VerifySubscriptionPaymentResult, +} from "./subscription-billing-provider.interface"; + +const PROVIDER_NAME_BY_KEY: Record< + SubscriptionBillingProviderKey, + SubscriptionBillingProviderName +> = { + nomba: "NOMBA", + paystack: "PAYSTACK", + flutterwave: "FLUTTERWAVE", +}; + +export class NotConfiguredSubscriptionBillingProvider implements SubscriptionBillingProvider { + readonly name: SubscriptionBillingProviderName; + + constructor(private readonly providerKey: SubscriptionBillingProviderKey) { + this.name = PROVIDER_NAME_BY_KEY[providerKey]; + } + + createCustomer( + _input: CreateSubscriptionCustomerInput, + ): Promise { + return Promise.reject(this.notImplementedError()); + } + + initializeSubscriptionPayment( + _input: InitializeSubscriptionPaymentInput, + ): Promise { + return Promise.reject(this.notImplementedError()); + } + + verifySubscriptionPayment( + _reference: string, + ): Promise { + return Promise.reject(this.notImplementedError()); + } + + chargeTokenizedCard( + _input: ChargeTokenizedSubscriptionPaymentInput, + ): Promise { + return Promise.reject(this.notImplementedError()); + } + + cancelSubscription( + _input: CancelSubscriptionInput, + ): Promise { + return Promise.reject(this.notImplementedError()); + } + + verifyWebhookSignature( + _input: VerifySubscriptionBillingWebhookSignatureInput, + ): boolean { + throw this.notImplementedError(); + } + + parseWebhookEvent( + _input: ParseSubscriptionBillingWebhookEventInput, + ): SubscriptionBillingProviderEvent { + throw this.notImplementedError(); + } + + private notImplementedError(): Error { + return new Error( + `Subscription billing provider '${this.providerKey}' is not implemented yet.`, + ); + } +} diff --git a/apps/backend/src/domains/money/subscription-billing/providers/subscription-billing-provider.interface.ts b/apps/backend/src/domains/money/subscription-billing/providers/subscription-billing-provider.interface.ts new file mode 100644 index 00000000..3de2d82f --- /dev/null +++ b/apps/backend/src/domains/money/subscription-billing/providers/subscription-billing-provider.interface.ts @@ -0,0 +1,216 @@ +export const SUBSCRIPTION_BILLING_PROVIDER = Symbol( + "SUBSCRIPTION_BILLING_PROVIDER", +); + +export type SubscriptionBillingProviderKey = + | "nomba" + | "paystack" + | "flutterwave"; + +export type SubscriptionBillingProviderName = + | "NOMBA" + | "PAYSTACK" + | "FLUTTERWAVE"; + +export type SubscriptionBillingPlanCode = "FREE" | "GROWTH" | "PRO"; + +export type SubscriptionBillingPurpose = + | "STOREPASS_SUBSCRIPTION" + | "STOREPASS_RENEWAL" + | "STOREPASS_UPGRADE" + | "STOREPASS_RECARD"; + +export interface CreateSubscriptionCustomerInput { + storeId: string; + storeOwnerUserId: string; + email: string; + name?: string; + phone?: string; + metadata?: Record; +} + +export interface CreateSubscriptionCustomerResult { + provider: SubscriptionBillingProviderName; + storeId: string; + rawProviderCustomerId?: string; +} + +export interface InitializeSubscriptionPaymentInput { + storeId: string; + storeOwnerUserId: string; + planCode: SubscriptionBillingPlanCode; + reference: string; + amountKobo: bigint; + email: string; + callbackUrl?: string; + metadata?: Record; +} + +export interface InitializeSubscriptionPaymentResult { + provider: SubscriptionBillingProviderName; + reference: string; + authorizationUrl?: string; + rawProviderReference?: string; +} + +export interface VerifySubscriptionPaymentResult { + provider: SubscriptionBillingProviderName; + reference: string; + status: "SUCCESS" | "FAILED" | "PENDING"; + amountKobo: bigint; + paidAt?: Date; + rawProviderReference?: string; +} + +/** + * Input for charging a previously tokenized card for a recurring StorePass + * attempt (renewal or retry). The domain never holds card data — only the + * provider's saved token key. `reference` is the Twizrr canonical order + * reference (`storepass_inv__`); `idempotencyKey` + * guards the outbound POST and defaults to `reference` when omitted. Amounts are + * BigInt kobo — the adapter converts to the provider amount. + */ +export interface ChargeTokenizedSubscriptionPaymentInput { + storeId: string; + storeOwnerUserId: string; + planCode: SubscriptionBillingPlanCode; + reference: string; + amountKobo: bigint; + email: string; + tokenKey: string; + callbackUrl?: string; + idempotencyKey?: string; + metadata?: Record; +} + +export interface ChargeTokenizedSubscriptionPaymentResult { + provider: SubscriptionBillingProviderName; + reference: string; + status: "SUCCESS" | "FAILED" | "PENDING"; + rawProviderReference?: string; +} + +export interface CancelSubscriptionInput { + storeId: string; + reference: string; + reason?: string; +} + +export interface CancelSubscriptionResult { + provider: SubscriptionBillingProviderName; + reference: string; + cancelled: boolean; + rawProviderReference?: string; +} + +export interface VerifySubscriptionBillingWebhookSignatureInput { + rawBody: Buffer | string; + /** + * Signature value when the provider passes it directly. Some providers (for + * example Nomba) carry the signature and its metadata in headers instead, so + * this is optional and adapters may read `headers`. + */ + signature?: string | string[] | undefined; + headers?: Record; +} + +export interface ParseSubscriptionBillingWebhookEventInput { + rawBody: Buffer | string; + headers?: Record; +} + +/** + * Safe tokenized-card data a provider may surface on a successful payment event + * so the domain can persist a reusable payment method. Only non-sensitive token + * metadata is carried — never full PAN, CVV, OTP, or PIN. `maskedCardPan` is the + * provider's already-masked PAN. + */ +export interface SubscriptionBillingTokenizedCard { + tokenKey: string; + cardType?: string; + maskedCardPan?: string; + tokenExpiryMonth?: string; + tokenExpiryYear?: string; +} + +/** + * StorePass reference fields carried on normalized subscription billing events. + * These are optional at the contract level so providers that cannot supply them + * still satisfy the interface. Nomba populates them from order metadata. + */ +export interface SubscriptionBillingEventStorePassFields { + /** + * Twizrr canonical order reference. For Nomba this equals `reference`; the + * provider may also surface it as an alias (for example merchantTxRef). + */ + orderReference?: string; + providerTransactionId?: string; + /** Provider request id (Nomba `requestId`). Also used as `rawEventId`. */ + requestId?: string; + storeId?: string; + planCode?: SubscriptionBillingPlanCode; + invoiceId?: string; + subscriptionId?: string; + billingAttemptId?: string; + purpose?: SubscriptionBillingPurpose; + /** Present on a successful payment when the card was tokenized. */ + tokenizedCard?: SubscriptionBillingTokenizedCard; +} + +export type SubscriptionBillingProviderEvent = + | ({ + type: "SUBSCRIPTION_PAYMENT_SUCCEEDED"; + provider: SubscriptionBillingProviderName; + reference: string; + amountKobo: bigint; + occurredAt: Date; + rawEventId: string; + } & SubscriptionBillingEventStorePassFields) + | ({ + type: "SUBSCRIPTION_PAYMENT_FAILED"; + provider: SubscriptionBillingProviderName; + reference: string; + reason?: string; + occurredAt: Date; + rawEventId: string; + } & SubscriptionBillingEventStorePassFields) + | ({ + type: "SUBSCRIPTION_PAYMENT_REVERSED"; + provider: SubscriptionBillingProviderName; + reference: string; + amountKobo?: bigint; + occurredAt: Date; + rawEventId: string; + } & SubscriptionBillingEventStorePassFields) + | ({ + type: "SUBSCRIPTION_CANCELLED"; + provider: SubscriptionBillingProviderName; + reference: string; + occurredAt: Date; + rawEventId: string; + } & SubscriptionBillingEventStorePassFields); + +export interface SubscriptionBillingProvider { + readonly name: SubscriptionBillingProviderName; + createCustomer( + input: CreateSubscriptionCustomerInput, + ): Promise; + initializeSubscriptionPayment( + input: InitializeSubscriptionPaymentInput, + ): Promise; + verifySubscriptionPayment( + reference: string, + ): Promise; + chargeTokenizedCard( + input: ChargeTokenizedSubscriptionPaymentInput, + ): Promise; + cancelSubscription( + input: CancelSubscriptionInput, + ): Promise; + verifyWebhookSignature( + input: VerifySubscriptionBillingWebhookSignatureInput, + ): boolean; + parseWebhookEvent( + input: ParseSubscriptionBillingWebhookEventInput, + ): SubscriptionBillingProviderEvent; +} diff --git a/apps/backend/src/domains/money/subscription-billing/providers/subscription-billing-provider.selector.spec.ts b/apps/backend/src/domains/money/subscription-billing/providers/subscription-billing-provider.selector.spec.ts new file mode 100644 index 00000000..31f2d462 --- /dev/null +++ b/apps/backend/src/domains/money/subscription-billing/providers/subscription-billing-provider.selector.spec.ts @@ -0,0 +1,81 @@ +import { SubscriptionBillingProvider } from "./subscription-billing-provider.interface"; +import { + createSubscriptionBillingProvider, + resolveSubscriptionBillingProviderKey, + selectSubscriptionBillingProvider, +} from "./subscription-billing-provider.selector"; + +describe("subscription billing provider selection", () => { + it("defaults subscription billing provider selection to nomba", () => { + expect(resolveSubscriptionBillingProviderKey(undefined)).toBe("nomba"); + }); + + it("returns a safe not-configured provider for reserved providers", async () => { + const provider = createSubscriptionBillingProvider("nomba"); + + expect(provider.name).toBe("NOMBA"); + await expect( + provider.initializeSubscriptionPayment({ + storeId: "store-1", + storeOwnerUserId: "user-1", + planCode: "GROWTH", + reference: "sub-reference", + amountKobo: 500000n, + email: "owner@example.com", + }), + ).rejects.toThrow( + "Subscription billing provider 'nomba' is not implemented yet.", + ); + }); + + it("fails clearly when SUBSCRIPTION_BILLING_PROVIDER is unsupported", () => { + expect(() => createSubscriptionBillingProvider("stripe")).toThrow( + "Unsupported SUBSCRIPTION_BILLING_PROVIDER 'stripe'. Supported values: nomba, paystack, flutterwave.", + ); + }); +}); + +describe("selectSubscriptionBillingProvider", () => { + const nombaProvider = { + name: "NOMBA", + } as unknown as SubscriptionBillingProvider; + + it("resolves the Nomba adapter when nomba is configured and registered", () => { + const provider = selectSubscriptionBillingProvider("nomba", { + nomba: nombaProvider, + }); + + expect(provider).toBe(nombaProvider); + }); + + it("defaults to the registered Nomba adapter when unset", () => { + const provider = selectSubscriptionBillingProvider(undefined, { + nomba: nombaProvider, + }); + + expect(provider).toBe(nombaProvider); + }); + + it("falls back to a safe not-configured provider when nomba has no adapter", async () => { + const provider = selectSubscriptionBillingProvider("nomba"); + + expect(provider.name).toBe("NOMBA"); + await expect(provider.verifySubscriptionPayment("ref")).rejects.toThrow( + "not implemented yet", + ); + }); + + it("returns a safe not-configured provider for reserved providers", () => { + const provider = selectSubscriptionBillingProvider("paystack", { + nomba: nombaProvider, + }); + + expect(provider.name).toBe("PAYSTACK"); + }); + + it("fails clearly when the provider value is unsupported", () => { + expect(() => + selectSubscriptionBillingProvider("stripe", { nomba: nombaProvider }), + ).toThrow("Unsupported SUBSCRIPTION_BILLING_PROVIDER 'stripe'"); + }); +}); diff --git a/apps/backend/src/domains/money/subscription-billing/providers/subscription-billing-provider.selector.ts b/apps/backend/src/domains/money/subscription-billing/providers/subscription-billing-provider.selector.ts new file mode 100644 index 00000000..c6d34f19 --- /dev/null +++ b/apps/backend/src/domains/money/subscription-billing/providers/subscription-billing-provider.selector.ts @@ -0,0 +1,61 @@ +import { NotConfiguredSubscriptionBillingProvider } from "./not-configured-subscription-billing.provider"; +import { + SubscriptionBillingProvider, + SubscriptionBillingProviderKey, +} from "./subscription-billing-provider.interface"; + +const SUPPORTED_SUBSCRIPTION_BILLING_PROVIDER_KEYS: SubscriptionBillingProviderKey[] = + ["nomba", "paystack", "flutterwave"]; + +export function resolveSubscriptionBillingProviderKey( + configuredProvider: string | undefined, +): SubscriptionBillingProviderKey { + const providerKey = (configuredProvider?.trim().toLowerCase() || + "nomba") as SubscriptionBillingProviderKey; + + if (!SUPPORTED_SUBSCRIPTION_BILLING_PROVIDER_KEYS.includes(providerKey)) { + throw new Error( + `Unsupported SUBSCRIPTION_BILLING_PROVIDER '${configuredProvider}'. Supported values: ${SUPPORTED_SUBSCRIPTION_BILLING_PROVIDER_KEYS.join( + ", ", + )}.`, + ); + } + + return providerKey; +} + +export function createSubscriptionBillingProvider( + configuredProvider: string | undefined, +): SubscriptionBillingProvider { + return new NotConfiguredSubscriptionBillingProvider( + resolveSubscriptionBillingProviderKey(configuredProvider), + ); +} + +/** + * Registry of implemented subscription billing provider adapters, keyed by + * provider key. Reserved-but-unimplemented providers are omitted and fall back + * to the safe not-configured provider. + */ +export interface SubscriptionBillingProviderRegistry { + nomba?: SubscriptionBillingProvider; +} + +/** + * Resolves the active subscription billing provider. When the configured + * provider has a real adapter registered it is returned; otherwise a safe + * not-configured provider is returned so that provider selection never throws + * for a supported-but-unimplemented value. Unsupported values still fail fast. + */ +export function selectSubscriptionBillingProvider( + configuredProvider: string | undefined, + registry: SubscriptionBillingProviderRegistry = {}, +): SubscriptionBillingProvider { + const providerKey = resolveSubscriptionBillingProviderKey(configuredProvider); + + if (providerKey === "nomba" && registry.nomba) { + return registry.nomba; + } + + return new NotConfiguredSubscriptionBillingProvider(providerKey); +} diff --git a/apps/backend/src/domains/money/subscription-billing/subscription-billing.module.ts b/apps/backend/src/domains/money/subscription-billing/subscription-billing.module.ts new file mode 100644 index 00000000..4036f156 --- /dev/null +++ b/apps/backend/src/domains/money/subscription-billing/subscription-billing.module.ts @@ -0,0 +1,27 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; + +import { NombaModule } from "../../../integrations/nomba/nomba.module"; +import { NombaSubscriptionBillingProvider } from "../../../integrations/nomba/nomba-subscription-billing.provider"; +import { SUBSCRIPTION_BILLING_PROVIDER } from "./providers/subscription-billing-provider.interface"; +import { selectSubscriptionBillingProvider } from "./providers/subscription-billing-provider.selector"; + +@Module({ + imports: [ConfigModule, NombaModule], + providers: [ + { + provide: SUBSCRIPTION_BILLING_PROVIDER, + useFactory: ( + config: ConfigService, + nomba: NombaSubscriptionBillingProvider, + ) => + selectSubscriptionBillingProvider( + config.get("SUBSCRIPTION_BILLING_PROVIDER"), + { nomba }, + ), + inject: [ConfigService, NombaSubscriptionBillingProvider], + }, + ], + exports: [SUBSCRIPTION_BILLING_PROVIDER], +}) +export class SubscriptionBillingModule {} diff --git a/apps/backend/src/domains/orders.module.ts b/apps/backend/src/domains/orders.module.ts new file mode 100644 index 00000000..ca8fec1e --- /dev/null +++ b/apps/backend/src/domains/orders.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; + +import { CartModule } from "./orders/cart/cart.module"; +import { DisputeModule } from "./orders/dispute/dispute.module"; +import { OrderModule } from "./orders/order/order.module"; +import { DeliveryModule } from "./orders/delivery/delivery.module"; + +@Module({ + imports: [OrderModule, CartModule, DisputeModule, DeliveryModule], +}) +export class OrdersDomainModule {} diff --git a/apps/backend/src/domains/orders/cart/cart.controller.ts b/apps/backend/src/domains/orders/cart/cart.controller.ts new file mode 100644 index 00000000..f3ea5430 --- /dev/null +++ b/apps/backend/src/domains/orders/cart/cart.controller.ts @@ -0,0 +1,57 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, +} from "@nestjs/common"; +import { CartService } from "./cart.service"; +import { AddToCartDto } from "./dto/add-to-cart.dto"; +import { UpdateCartItemDto } from "./dto/update-cart-item.dto"; +import { CartCheckoutDto } from "./dto/checkout-cart.dto"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { CurrentUser } from "../../../common/decorators/current-user.decorator"; +import { JwtPayload } from "@twizrr/shared"; + +@Controller("cart") +@UseGuards(JwtAuthGuard) +export class CartController { + constructor(private readonly cartService: CartService) {} + + @Get() + getCart(@CurrentUser() user: JwtPayload) { + return this.cartService.getCart(user.sub); + } + + @Post("items") + addItem(@CurrentUser() user: JwtPayload, @Body() dto: AddToCartDto) { + return this.cartService.addItemToCart(user.sub, dto); + } + + @Patch("items/:id") + updateItem( + @CurrentUser() user: JwtPayload, + @Param("id") id: string, + @Body() dto: UpdateCartItemDto, + ) { + return this.cartService.updateItemQuantity(user.sub, id, dto); + } + + @Delete("items/:id") + removeItem(@CurrentUser() user: JwtPayload, @Param("id") id: string) { + return this.cartService.removeItemFromCart(user.sub, id); + } + + @Delete() + clearCart(@CurrentUser() user: JwtPayload) { + return this.cartService.clearCart(user.sub); + } + + @Post("checkout") + checkout(@CurrentUser() user: JwtPayload, @Body() dto: CartCheckoutDto) { + return this.cartService.checkout(user.sub, dto); + } +} diff --git a/apps/backend/src/domains/orders/cart/cart.module.ts b/apps/backend/src/domains/orders/cart/cart.module.ts new file mode 100644 index 00000000..500b149c --- /dev/null +++ b/apps/backend/src/domains/orders/cart/cart.module.ts @@ -0,0 +1,13 @@ +import { Module, forwardRef } from "@nestjs/common"; +import { CartService } from "./cart.service"; +import { CartController } from "./cart.controller"; +import { PrismaModule } from "../../../prisma/prisma.module"; +import { OrderModule } from "../order/order.module"; + +@Module({ + imports: [PrismaModule, forwardRef(() => OrderModule)], + controllers: [CartController], + providers: [CartService], + exports: [CartService], +}) +export class CartModule {} diff --git a/apps/backend/src/domains/orders/cart/cart.service.ts b/apps/backend/src/domains/orders/cart/cart.service.ts new file mode 100644 index 00000000..bf018eda --- /dev/null +++ b/apps/backend/src/domains/orders/cart/cart.service.ts @@ -0,0 +1,345 @@ +import { + BadRequestException, + Inject, + Injectable, + NotFoundException, + forwardRef, +} from "@nestjs/common"; +import { Prisma, ProductStatus, StoreTier } from "@prisma/client"; + +import { PrismaService } from "../../../prisma/prisma.service"; +import { OrderService } from "../order/order.service"; +import { CheckoutCartDto } from "../order/dto/checkout-cart.dto"; +import { AddToCartDto } from "./dto/add-to-cart.dto"; +import { CartCheckoutDto } from "./dto/checkout-cart.dto"; +import { UpdateCartItemDto } from "./dto/update-cart-item.dto"; +import { PriceType } from "@twizrr/shared"; + +const cartProductInclude = { + storeProfile: { + select: { + id: true, + businessName: true, + storeName: true, + storeHandle: true, + slug: true, + logoUrl: true, + verificationTier: true, + tier: true, + isOpen: true, + }, + }, + productStockCaches: { + where: { variantId: null }, + take: 1, + select: { stock: true, updatedAt: true }, + }, +} as const; + +type ProductForCart = Prisma.ProductGetPayload<{ + include: typeof cartProductInclude; +}>; + +type CartItemForResponse = Prisma.CartItemGetPayload<{ + include: { + product: { + include: typeof cartProductInclude; + }; + }; +}>; + +@Injectable() +export class CartService { + constructor( + private readonly prisma: PrismaService, + @Inject(forwardRef(() => OrderService)) + private readonly orderService: OrderService, + ) {} + + async getCart(buyerId: string) { + const items = await this.getCartItemsForUser(buyerId); + return this.mapCart(items); + } + + async addItemToCart(buyerId: string, dto: AddToCartDto) { + const product = await this.getProductForCart(dto.productId); + this.assertProductCanBeAdded(product); + + const priceType = dto.priceType || PriceType.RETAIL; + this.resolvePrice(product, priceType); + this.assertMinimumQuantity(product, priceType, dto.quantity); + + const existingItem = await this.prisma.cartItem.findFirst({ + where: { + buyerId, + productId: dto.productId, + priceType, + }, + }); + + const nextQuantity = (existingItem?.quantity ?? 0) + dto.quantity; + await this.assertStockAvailable(product.id, nextQuantity); + + if (existingItem) { + await this.prisma.cartItem.update({ + where: { id: existingItem.id }, + data: { quantity: nextQuantity }, + }); + } else { + await this.prisma.cartItem.create({ + data: { + buyerId, + productId: dto.productId, + quantity: dto.quantity, + priceType, + }, + }); + } + + return this.getCart(buyerId); + } + + async updateItemQuantity( + buyerId: string, + itemId: string, + dto: UpdateCartItemDto, + ) { + const item = await this.prisma.cartItem.findFirst({ + where: { id: itemId, buyerId }, + include: { + product: { + include: cartProductInclude, + }, + }, + }); + + if (!item) { + throw new NotFoundException("Cart item not found"); + } + + this.assertProductCanBeAdded(item.product); + this.resolvePrice(item.product, item.priceType as PriceType); + this.assertMinimumQuantity( + item.product, + item.priceType as PriceType, + dto.quantity, + ); + await this.assertStockAvailable(item.productId, dto.quantity); + + await this.prisma.cartItem.update({ + where: { id: itemId }, + data: { quantity: dto.quantity }, + }); + + return this.getCart(buyerId); + } + + async removeItemFromCart(buyerId: string, itemId: string) { + await this.prisma.cartItem.deleteMany({ + where: { id: itemId, buyerId }, + }); + + return this.getCart(buyerId); + } + + async clearCart(buyerId: string) { + await this.prisma.cartItem.deleteMany({ + where: { buyerId }, + }); + + return this.getCart(buyerId); + } + + async checkout(buyerId: string, dto: CartCheckoutDto) { + // A provided subset (single item / one store's items) wins; otherwise the + // entire cart is checked out. Ownership of every id is enforced again in + // OrderService.checkoutCart. + let cartItemIds = dto.cartItemIds ?? []; + if (cartItemIds.length === 0) { + const cartItems = await this.prisma.cartItem.findMany({ + where: { buyerId }, + select: { id: true }, + orderBy: { createdAt: "desc" }, + }); + cartItemIds = cartItems.map((item) => item.id); + } + + if (cartItemIds.length === 0) { + throw new BadRequestException("Cart is empty"); + } + + const checkoutDto: CheckoutCartDto = { + cartItemIds, + deliveryAddress: dto.deliveryAddress, + deliveryDetails: dto.deliveryDetails, + secondaryDeliveryPhone: dto.secondaryDeliveryPhone, + idempotencyKey: dto.idempotencyKey, + }; + + return this.orderService.checkoutCart(buyerId, checkoutDto); + } + + private async getProductForCart(productId: string) { + const product = await this.prisma.product.findUnique({ + where: { id: productId }, + include: cartProductInclude, + }); + + if (!product) { + throw new NotFoundException("Product not found or unavailable"); + } + + return product; + } + + private async getCartItemsForUser(buyerId: string) { + return this.prisma.cartItem.findMany({ + where: { buyerId }, + include: { + product: { + include: cartProductInclude, + }, + }, + orderBy: { createdAt: "desc" }, + }); + } + + private assertProductCanBeAdded(product: ProductForCart): void { + if ( + !product.isActive || + product.deletedAt || + product.status !== ProductStatus.ACTIVE + ) { + throw new BadRequestException("Product is not available"); + } + + if ( + !product.storeProfile || + !product.storeProfile.isOpen || + product.storeProfile.tier === StoreTier.TIER_0 + ) { + throw new BadRequestException("Store is not available"); + } + } + + private assertMinimumQuantity( + product: ProductForCart, + priceType: PriceType, + quantity: number, + ): void { + const minQty = + priceType === PriceType.WHOLESALE + ? product.minOrderQuantity + : product.minOrderQuantityConsumer; + + if (quantity < minQty) { + throw new BadRequestException( + `Minimum order quantity for ${priceType} is ${minQty}`, + ); + } + } + + private async assertStockAvailable( + productId: string, + quantity: number, + ): Promise { + const stockCache = await this.prisma.productStockCache.findFirst({ + where: { productId, variantId: null }, + select: { stock: true }, + }); + + if (!stockCache || stockCache.stock < quantity) { + throw new BadRequestException("Insufficient stock"); + } + } + + private resolvePrice(product: ProductForCart, priceType: PriceType): bigint { + const priceKobo = this.getPriceKobo(product, priceType); + + if (priceKobo === null) { + throw new BadRequestException( + `Product does not have a valid price for the ${priceType} tier.`, + ); + } + + return BigInt(priceKobo); + } + + private getPriceKobo( + product: ProductForCart, + priceType: PriceType, + ): bigint | null { + const priceKobo = + priceType === PriceType.WHOLESALE + ? (product.wholesalePriceKobo ?? product.pricePerUnitKobo) + : (product.retailPriceKobo ?? product.pricePerUnitKobo); + + if (!priceKobo || priceKobo <= 0n) { + return null; + } + + return BigInt(priceKobo); + } + + private mapCart(items: CartItemForResponse[]) { + let subtotalKobo = 0n; + + const mappedItems = items.map((item) => { + const unitPriceKobo = + this.getPriceKobo(item.product, item.priceType as PriceType) ?? 0n; + const hasValidPrice = unitPriceKobo > 0n; + const itemTotalKobo = unitPriceKobo * BigInt(item.quantity); + const stock = item.product.productStockCaches[0]?.stock ?? 0; + subtotalKobo += itemTotalKobo; + + return { + id: item.id, + productId: item.productId, + quantity: item.quantity, + priceType: item.priceType, + unitPriceKobo: unitPriceKobo.toString(), + itemTotalKobo: itemTotalKobo.toString(), + isAvailable: + item.product.status === ProductStatus.ACTIVE && + item.product.isActive && + item.product.deletedAt === null && + Boolean(item.product.storeProfile?.isOpen) && + item.product.storeProfile?.tier !== StoreTier.TIER_0 && + stock >= item.quantity && + hasValidPrice, + stockAvailable: stock, + variantId: null, + variantName: null, + product: { + id: item.product.id, + productCode: item.product.productCode, + name: item.product.name, + title: item.product.title, + imageUrl: item.product.imageUrl, + unit: item.product.unit, + categoryTag: item.product.categoryTag, + minOrderQuantity: item.product.minOrderQuantity, + minOrderQuantityConsumer: item.product.minOrderQuantityConsumer, + }, + store: item.product.storeProfile + ? { + id: item.product.storeProfile.id, + name: + item.product.storeProfile.storeName || + item.product.storeProfile.businessName, + handle: + item.product.storeProfile.storeHandle || + item.product.storeProfile.slug, + logoUrl: item.product.storeProfile.logoUrl, + verificationTier: item.product.storeProfile.verificationTier, + } + : null, + }; + }); + + return { + items: mappedItems, + subtotalKobo: subtotalKobo.toString(), + }; + } +} diff --git a/apps/backend/src/domains/orders/cart/dto/add-to-cart.dto.ts b/apps/backend/src/domains/orders/cart/dto/add-to-cart.dto.ts new file mode 100644 index 00000000..81643c5d --- /dev/null +++ b/apps/backend/src/domains/orders/cart/dto/add-to-cart.dto.ts @@ -0,0 +1,25 @@ +import { + IsEnum, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Min, +} from "class-validator"; +import { PriceType } from "@twizrr/shared"; + +export class AddToCartDto { + // IDs are Prisma cuids, not UUIDs — validate as a non-empty string. + @IsString() + @IsNotEmpty() + productId!: string; + + @IsInt() + @IsNotEmpty() + @Min(1) + quantity!: number; + + @IsEnum(PriceType) + @IsOptional() + priceType?: PriceType; +} diff --git a/apps/backend/src/domains/orders/cart/dto/checkout-cart.dto.ts b/apps/backend/src/domains/orders/cart/dto/checkout-cart.dto.ts new file mode 100644 index 00000000..6ee31409 --- /dev/null +++ b/apps/backend/src/domains/orders/cart/dto/checkout-cart.dto.ts @@ -0,0 +1,61 @@ +import { Transform } from "class-transformer"; +import { + ArrayNotEmpty, + IsArray, + IsIn, + IsNotEmpty, + Matches, + IsObject, + IsOptional, + IsString, +} from "class-validator"; + +function normalizeOptionalNigerianPhone(value: unknown): unknown { + if (value == null) return undefined; + if (typeof value !== "string") return value; + + const compact = value.trim().replace(/[\s\-()]/g, ""); + if (!compact) return undefined; + if (/^0[789]\d{9}$/.test(compact)) return `+234${compact.slice(1)}`; + if (/^[789]\d{9}$/.test(compact)) return `+234${compact}`; + if (/^234[789]\d{9}$/.test(compact)) return `+${compact}`; + return compact; +} + +export class CartCheckoutDto { + // Optional subset of the buyer's cart to check out (single item or one + // store's items). Omitted → the entire cart. Ids are cuids, validated as + // non-empty strings; ownership is enforced in OrderService.checkoutCart. + @IsOptional() + @IsArray() + @IsString({ each: true }) + @ArrayNotEmpty() + cartItemIds?: string[]; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsString() + @IsNotEmpty() + deliveryAddress!: string; + + @IsOptional() + @IsObject() + deliveryDetails?: Record; + + @Transform(({ value }) => normalizeOptionalNigerianPhone(value)) + @IsOptional() + @IsString() + @Matches(/^\+234[789]\d{9}$/) + secondaryDeliveryPhone?: string; + + // Deprecated compatibility field. Shopper input is ignored by checkout; + // delivery is server-selected and handled by twizrr. + @IsIn(["STORE_DELIVERY", "PLATFORM_LOGISTICS"]) + @IsOptional() + deliveryMethod?: "STORE_DELIVERY" | "PLATFORM_LOGISTICS"; + + @IsOptional() + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsString() + @IsNotEmpty() + idempotencyKey?: string; +} diff --git a/apps/backend/src/domains/orders/cart/dto/update-cart-item.dto.ts b/apps/backend/src/domains/orders/cart/dto/update-cart-item.dto.ts new file mode 100644 index 00000000..01e6cb4f --- /dev/null +++ b/apps/backend/src/domains/orders/cart/dto/update-cart-item.dto.ts @@ -0,0 +1,8 @@ +import { IsNotEmpty, IsInt, Min } from "class-validator"; + +export class UpdateCartItemDto { + @IsInt() + @IsNotEmpty() + @Min(1) + quantity!: number; +} diff --git a/apps/backend/src/domains/orders/delivery/delivery.controller.ts b/apps/backend/src/domains/orders/delivery/delivery.controller.ts new file mode 100644 index 00000000..272b4456 --- /dev/null +++ b/apps/backend/src/domains/orders/delivery/delivery.controller.ts @@ -0,0 +1,91 @@ +import { + BadRequestException, + Body, + Controller, + ForbiddenException, + Get, + HttpCode, + Inject, + Post, + Query, + Req, + UseGuards, +} from "@nestjs/common"; +import type { RawBodyRequest } from "@nestjs/common"; +import { SkipThrottle } from "@nestjs/throttler"; +import type { Request } from "express"; + +import { CurrentUser } from "../../../common/decorators/current-user.decorator"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { AuthenticatedRequestUser } from "../../users/auth/auth.types"; +import { BookDeliveryDto } from "./dto/book-delivery.dto"; +import { DeliveryEstimateQueryDto } from "./dto/delivery-estimate-query.dto"; +import { DeliveryService } from "./delivery.service"; +import { + SHIPPING_PROVIDER, + ShippingProvider, +} from "./providers/shipping-provider.interface"; + +@Controller("delivery") +export class DeliveryController { + constructor( + private readonly deliveryService: DeliveryService, + @Inject(SHIPPING_PROVIDER) + private readonly shippingProvider: ShippingProvider, + ) {} + + @Get("estimates") + getEstimates(@Query() query: DeliveryEstimateQueryDto) { + return this.deliveryService.getEstimates(query); + } + + @Post("book") + @UseGuards(JwtAuthGuard) + book( + @CurrentUser() user: AuthenticatedRequestUser, + @Body() dto: BookDeliveryDto, + ) { + if (!user.storeId) { + throw new ForbiddenException({ + message: "Store owner access is required", + code: "STORE_REQUIRED", + }); + } + + return this.deliveryService.bookDelivery(user.storeId, { + ...dto, + parcel: { + ...dto.parcel, + itemValueKobo: dto.parcel.itemValueKobo + ? BigInt(dto.parcel.itemValueKobo) + : undefined, + }, + }); + } + + @Post("webhook") + @HttpCode(200) + @SkipThrottle() + async webhook(@Req() req: RawBodyRequest) { + const rawBody = req.rawBody; + if (!rawBody) { + throw new BadRequestException({ + message: "Invalid delivery provider webhook payload", + code: "SHIPPING_WEBHOOK_INVALID", + }); + } + + const payload = await this.shippingProvider.verifyAndNormalizeWebhook( + req.headers, + rawBody, + req.body, + ); + if (!payload) { + throw new BadRequestException({ + message: "Invalid delivery provider webhook payload", + code: "SHIPPING_WEBHOOK_INVALID", + }); + } + return this.deliveryService.handleNormalizedWebhook(payload); + } +} diff --git a/apps/backend/src/domains/orders/delivery/delivery.module.ts b/apps/backend/src/domains/orders/delivery/delivery.module.ts new file mode 100644 index 00000000..1149786b --- /dev/null +++ b/apps/backend/src/domains/orders/delivery/delivery.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; + +import { PrismaModule } from "../../../core/prisma/prisma.module"; +import { RedisModule } from "../../../core/redis/redis.module"; +import { ShipbubbleModule } from "../../../integrations/shipbubble/shipbubble.module"; +import { OutboxModule } from "../../platform/outbox/outbox.module"; +import { DeliveryController } from "./delivery.controller"; +import { DeliveryService } from "./delivery.service"; + +@Module({ + imports: [PrismaModule, RedisModule, ShipbubbleModule, OutboxModule], + controllers: [DeliveryController], + providers: [DeliveryService], +}) +export class DeliveryModule {} diff --git a/apps/backend/src/domains/orders/delivery/delivery.service.spec.ts b/apps/backend/src/domains/orders/delivery/delivery.service.spec.ts new file mode 100644 index 00000000..e679a494 --- /dev/null +++ b/apps/backend/src/domains/orders/delivery/delivery.service.spec.ts @@ -0,0 +1,611 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, +} from "@nestjs/common"; +import { + DeliveryStatus, + OrderStatus, + OrderType, + StoreType, +} from "@prisma/client"; +import { plainToInstance } from "class-transformer"; +import { validate } from "class-validator"; + +import { DeliveryParcelDto } from "./dto/book-delivery.dto"; +import { DeliveryService } from "./delivery.service"; + +describe("DeliveryService", () => { + const pickupDetails = { + formattedAddress: "12 Allen Avenue, Ikeja", + city: "Ikeja", + state: "Lagos", + }; + + const owner = { + email: "store@example.com", + phone: "+2348012345678", + displayName: "Store Owner", + }; + + function createService() { + const prisma: any = { + product: { + findFirst: jest.fn().mockResolvedValue({ + id: "product_1", + isActive: true, + name: "Ankara Dress", + weightKg: 2, + retailPriceKobo: 2500000n, + storeProfile: { + businessAddressDetails: pickupDetails, + storeType: StoreType.PHYSICAL, + user: owner, + }, + }), + }, + sourcedProduct: { + findFirst: jest.fn(), + }, + order: { + findUnique: jest.fn(), + findUniqueOrThrow: jest.fn().mockResolvedValue({ + id: "order_1", + buyerId: "buyer_1", + storeId: "store_1", + status: OrderStatus.PAID, + createdAt: new Date("2026-07-12T10:00:00.000Z"), + updatedAt: new Date("2026-07-12T10:00:00.000Z"), + }), + }, + deliveryBooking: { + create: jest.fn(), + delete: jest.fn(), + findFirst: jest.fn(), + findUniqueOrThrow: jest.fn().mockResolvedValue({ + id: "booking_1", + orderId: "order_1", + status: DeliveryStatus.IN_TRANSIT, + pickedUpAt: null, + deliveredAt: null, + }), + update: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + }; + prisma.$transaction = jest.fn((callback) => callback(prisma)); + const redis = { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(true), + }; + const shippingProvider = { + getQuotes: jest.fn(), + createBooking: jest.fn(), + }; + + return { + prisma, + redis, + shipbubble: shippingProvider, + service: new DeliveryService( + prisma as never, + redis as never, + shippingProvider as never, + ), + }; + } + + it("returns preview estimates without calling Shipbubble for city and state only", async () => { + const { service, shipbubble } = createService(); + + const result = await service.getEstimates({ + productId: "product_1", + shopperCity: "Ikeja", + shopperState: "Lagos", + }); + + expect(shipbubble.getQuotes).not.toHaveBeenCalled(); + expect(result.mode).toBe("PREVIEW"); + expect(result.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "SAME_DAY", + mode: "PREVIEW", + }), + expect.objectContaining({ + type: "NATIONWIDE", + mode: "PREVIEW", + }), + ]), + ); + }); + + it("rejects missing and conflicting delivery product identities", async () => { + const { service } = createService(); + + await expect( + service.getEstimates({ + shopperCity: "Ikeja", + shopperState: "Lagos", + }), + ).rejects.toBeInstanceOf(BadRequestException); + + await expect( + service.getEstimates({ + productId: "product_1", + sourcedProductId: "source_1", + shopperCity: "Ikeja", + shopperState: "Lagos", + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("returns exact Shipbubble rates for full shopper destination details", async () => { + const { service, redis, shipbubble } = createService(); + shipbubble.getQuotes.mockResolvedValue([ + { + provider: "shipbubble", + quoteId: "rate_same_day", + bookingToken: "request_token", + serviceName: "Kwik", + serviceCode: "same_day", + amountKobo: 250000n, + estimatedDeliveryWindow: "Today by 6:00 PM", + }, + { + provider: "shipbubble", + quoteId: "rate_nationwide", + bookingToken: "request_token", + serviceName: "GIG Logistics", + serviceCode: "nationwide", + amountKobo: 420000n, + estimatedDeliveryWindow: "1-2 days", + }, + ]); + + const result = await service.getEstimates({ + productId: "product_1", + shopperAddress: "9 Admiralty Way, Lekki", + shopperCity: "Lekki", + shopperState: "Lagos", + shopperName: "Ada Buyer", + shopperEmail: "ada@example.com", + shopperPhone: "+2348098765432", + }); + + expect(shipbubble.getQuotes).toHaveBeenCalledWith( + expect.objectContaining({ + address: pickupDetails.formattedAddress, + phone: owner.phone, + }), + expect.objectContaining({ + address: "9 Admiralty Way, Lekki", + city: "Lekki", + }), + expect.objectContaining({ + weightKg: 2, + quantity: 1, + }), + ); + expect(result.mode).toBe("EXACT"); + expect(result.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "SAME_DAY", + priceKobo: 250000n, + rateId: "rate_same_day", + requestToken: "request_token", + }), + expect.objectContaining({ + type: "NATIONWIDE", + priceKobo: 420000n, + }), + ]), + ); + expect(redis.set).toHaveBeenCalledWith( + expect.stringContaining("delivery:estimate:"), + expect.any(String), + 1800, + ); + }); + + it("falls back to pickup-incomplete preview when a physical source lacks structured pickup details", async () => { + const { prisma, service, shipbubble } = createService(); + prisma.product.findFirst.mockResolvedValue({ + id: "product_1", + isActive: true, + name: "Ankara Dress", + weightKg: 2, + retailPriceKobo: 2500000n, + storeProfile: { + businessAddressDetails: null, + storeType: StoreType.PHYSICAL, + user: owner, + }, + }); + + const result = await service.getEstimates({ + productId: "product_1", + shopperAddress: "9 Admiralty Way, Lekki", + shopperCity: "Lekki", + shopperState: "Lagos", + shopperName: "Ada Buyer", + shopperEmail: "ada@example.com", + shopperPhone: "+2348098765432", + }); + + expect(shipbubble.getQuotes).not.toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ + mode: "PREVIEW", + pickupAddressComplete: false, + }), + ); + }); + + it("resolves sourced product pickup through the physical product store", async () => { + const { prisma, service } = createService(); + prisma.sourcedProduct.findFirst.mockResolvedValue({ + id: "source_1", + isActive: true, + physicalProduct: { + id: "product_1", + isActive: true, + name: "Ankara Dress", + weightKg: 2, + retailPriceKobo: 2500000n, + storeProfile: { + businessAddressDetails: pickupDetails, + storeType: StoreType.PHYSICAL, + user: owner, + }, + }, + }); + + const result = await service.getEstimates({ + sourcedProductId: "source_1", + shopperCity: "Lekki", + shopperState: "Lagos", + }); + + expect(prisma.sourcedProduct.findFirst).toHaveBeenCalled(); + expect(result.options[0]).not.toHaveProperty("physicalStoreId"); + }); + + it("books direct physical Shipbubble delivery for the owning store", async () => { + const { prisma, service, shipbubble } = createService(); + prisma.order.findUnique.mockResolvedValue({ + id: "order_1", + orderType: OrderType.DIRECT, + status: OrderStatus.PAID, + storeId: "store_1", + deliveryMethod: "PLATFORM_LOGISTICS", + deliveryFeeKobo: 420000n, + deliveryAddress: "9 Admiralty Way, Lekki", + deliveryDetails: { + formattedAddress: "9 Admiralty Way, Lekki", + city: "Lekki", + state: "Lagos", + name: "Ada Buyer", + email: "ada@example.com", + phone: "+2348098765432", + }, + storeProfile: { + businessAddressDetails: pickupDetails, + storeType: StoreType.PHYSICAL, + user: owner, + }, + user: { + email: "ada@example.com", + phone: "+2348098765432", + displayName: "Ada Buyer", + }, + linkedOrder: null, + linkedFromOrder: null, + deliveryBooking: null, + product: { name: "Ankara Dress", retailPriceKobo: 2500000n }, + }); + shipbubble.createBooking.mockResolvedValue({ + provider: "shipbubble", + providerBookingId: "booking_ref", + providerTrackingId: "tracking_ref", + trackingUrl: "https://tracking.test/booking_ref", + feeKobo: 420000n, + status: "BOOKED", + }); + prisma.deliveryBooking.create.mockResolvedValue({ + id: "booking_1", + orderId: "order_1", + partnerRef: null, + }); + prisma.deliveryBooking.update.mockResolvedValue({ + id: "booking_1", + orderId: "order_1", + partnerRef: "booking_ref", + }); + + const result = await service.bookDelivery("store_1", { + orderId: "order_1", + rateId: "rate_nationwide", + requestToken: "request_token", + parcel: { weightKg: 2, categoryId: 1 }, + }); + + expect(shipbubble.createBooking).toHaveBeenCalledWith( + expect.objectContaining({ + quoteId: "rate_nationwide", + bookingToken: "request_token", + idempotencyKey: "delivery-booking:booking_1", + pickup: expect.objectContaining({ + address: pickupDetails.formattedAddress, + }), + dropoff: expect.objectContaining({ + address: "9 Admiralty Way, Lekki", + }), + parcel: expect.objectContaining({ categoryId: 1 }), + }), + ); + expect(result).toEqual( + expect.objectContaining({ partnerRef: "booking_ref" }), + ); + }); + + it("returns the existing delivery booking when a concurrent booking already claimed the order", async () => { + const { prisma, service, shipbubble } = createService(); + prisma.order.findUnique.mockResolvedValue({ + id: "order_1", + orderType: OrderType.DIRECT, + status: OrderStatus.PAID, + storeId: "store_1", + deliveryMethod: "PLATFORM_LOGISTICS", + deliveryFeeKobo: 420000n, + deliveryDetails: { + formattedAddress: "9 Admiralty Way, Lekki", + city: "Lekki", + state: "Lagos", + name: "Ada Buyer", + email: "ada@example.com", + phone: "+2348098765432", + }, + storeProfile: { + businessAddressDetails: pickupDetails, + storeType: StoreType.PHYSICAL, + user: owner, + }, + user: { + email: "ada@example.com", + phone: "+2348098765432", + displayName: "Ada Buyer", + }, + linkedOrder: null, + linkedFromOrder: null, + deliveryBooking: null, + product: { name: "Ankara Dress", retailPriceKobo: 2500000n }, + }); + prisma.deliveryBooking.create.mockRejectedValue({ code: "P2002" }); + prisma.deliveryBooking.findFirst.mockResolvedValue({ + id: "booking_1", + orderId: "order_1", + partnerRef: "booking_ref", + status: DeliveryStatus.PICKUP_SCHEDULED, + }); + + const result = await service.bookDelivery("store_1", { + orderId: "order_1", + rateId: "rate_nationwide", + requestToken: "request_token", + parcel: { weightKg: 2, categoryId: 1 }, + }); + + expect(shipbubble.createBooking).not.toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ partnerRef: "booking_ref" }), + ); + }); + + it("releases the pending booking claim and rethrows when Shipbubble booking fails", async () => { + const { prisma, service, shipbubble } = createService(); + prisma.order.findUnique.mockResolvedValue({ + id: "order_1", + orderType: OrderType.DIRECT, + status: OrderStatus.PAID, + storeId: "store_1", + deliveryMethod: "PLATFORM_LOGISTICS", + deliveryFeeKobo: 420000n, + deliveryDetails: { + formattedAddress: "9 Admiralty Way, Lekki", + city: "Lekki", + state: "Lagos", + name: "Ada Buyer", + email: "ada@example.com", + phone: "+2348098765432", + }, + storeProfile: { + businessAddressDetails: pickupDetails, + storeType: StoreType.PHYSICAL, + user: owner, + }, + user: { + email: "ada@example.com", + phone: "+2348098765432", + displayName: "Ada Buyer", + }, + linkedOrder: null, + linkedFromOrder: null, + deliveryBooking: null, + product: { name: "Ankara Dress", retailPriceKobo: 2500000n }, + }); + prisma.deliveryBooking.create.mockResolvedValue({ + id: "booking_1", + orderId: "order_1", + partnerRef: null, + }); + const providerError = new Error("Shipbubble request failed"); + shipbubble.createBooking.mockRejectedValue(providerError); + prisma.deliveryBooking.delete.mockResolvedValue(undefined); + + await expect( + service.bookDelivery("store_1", { + orderId: "order_1", + rateId: "rate_nationwide", + requestToken: "request_token", + parcel: { weightKg: 2, categoryId: 1 }, + }), + ).rejects.toBe(providerError); + + // The claim is released so a retry is not permanently blocked, and the + // booking is never promoted to a scheduled state. + expect(prisma.deliveryBooking.delete).toHaveBeenCalledWith({ + where: { id: "booking_1" }, + }); + expect(prisma.deliveryBooking.update).not.toHaveBeenCalled(); + }); + + it("rejects invalid direct booking package weights before calling Shipbubble", async () => { + const { prisma, service, shipbubble } = createService(); + prisma.order.findUnique.mockResolvedValue({ + id: "order_1", + orderType: OrderType.DIRECT, + status: OrderStatus.PAID, + storeId: "store_1", + deliveryMethod: "PLATFORM_LOGISTICS", + deliveryFeeKobo: 420000n, + deliveryDetails: { + formattedAddress: "9 Admiralty Way, Lekki", + city: "Lekki", + state: "Lagos", + name: "Ada Buyer", + email: "ada@example.com", + phone: "+2348098765432", + }, + storeProfile: { + businessAddressDetails: pickupDetails, + storeType: StoreType.PHYSICAL, + user: owner, + }, + user: { + email: "ada@example.com", + phone: "+2348098765432", + displayName: "Ada Buyer", + }, + linkedOrder: null, + linkedFromOrder: null, + deliveryBooking: null, + product: { name: "Ankara Dress", retailPriceKobo: 2500000n }, + }); + + await expect( + service.bookDelivery("store_1", { + orderId: "order_1", + rateId: "rate_nationwide", + requestToken: "request_token", + parcel: { weightKg: 0, categoryId: 1 }, + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(shipbubble.createBooking).not.toHaveBeenCalled(); + }); + + it("rejects dropship customer booking and booking another store order", async () => { + const { prisma, service } = createService(); + prisma.order.findUnique.mockResolvedValue({ + id: "order_customer", + orderType: OrderType.DROPSHIP_CUSTOMER, + status: OrderStatus.PAID, + storeId: "digital_store", + deliveryBooking: null, + }); + + await expect( + service.bookDelivery("physical_store", { + orderId: "order_customer", + rateId: "rate_1", + requestToken: "request_token", + parcel: { weightKg: 1, categoryId: 1 }, + }), + ).rejects.toBeInstanceOf(ConflictException); + + prisma.order.findUnique.mockResolvedValue({ + id: "order_direct", + orderType: OrderType.DIRECT, + status: OrderStatus.PAID, + storeId: "different_store", + deliveryBooking: null, + }); + + await expect( + service.bookDelivery("physical_store", { + orderId: "order_direct", + rateId: "rate_1", + requestToken: "request_token", + parcel: { weightKg: 1, categoryId: 1 }, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it("updates booking status once for verified Shipbubble webhook events", async () => { + const { prisma, service } = createService(); + prisma.deliveryBooking.findFirst.mockResolvedValue({ + id: "booking_1", + partnerRef: "shipment_1", + status: DeliveryStatus.PENDING, + }); + prisma.deliveryBooking.update.mockResolvedValue(undefined); + + const result = await service.handleNormalizedWebhook({ + providerBookingId: "shipment_1", + status: "IN_TRANSIT", + }); + + expect(result).toEqual({ status: "processed" }); + expect(prisma.deliveryBooking.updateMany).toHaveBeenCalledWith({ + where: { id: "booking_1", status: DeliveryStatus.PENDING }, + data: expect.objectContaining({ status: DeliveryStatus.IN_TRANSIT }), + }); + }); + + it("ignores stale Shipbubble webhook status updates that would downgrade delivery state", async () => { + const { prisma, service } = createService(); + prisma.deliveryBooking.findFirst.mockResolvedValue({ + id: "booking_1", + partnerRef: "shipment_1", + status: DeliveryStatus.DELIVERED, + }); + + const result = await service.handleNormalizedWebhook({ + providerBookingId: "shipment_1", + status: "IN_TRANSIT", + }); + + expect(result).toEqual({ status: "ignored" }); + expect(prisma.deliveryBooking.updateMany).not.toHaveBeenCalled(); + }); +}); + +describe("DeliveryParcelDto weight validation", () => { + async function validateWeight(weightKg: unknown) { + const dto = plainToInstance(DeliveryParcelDto, { + weightKg, + categoryId: 1, + }); + const errors = await validate(dto); + return errors.some((error) => error.property === "weightKg"); + } + + it("accepts positive finite weights within the 1000kg ceiling", async () => { + await expect(validateWeight(2)).resolves.toBe(false); + await expect(validateWeight(0.5)).resolves.toBe(false); + await expect(validateWeight(1000)).resolves.toBe(false); + // Up to three decimal places of precision is allowed. + await expect(validateWeight(1.234)).resolves.toBe(false); + }); + + it("rejects zero, negative, over-max, over-precision, and non-finite weights", async () => { + await expect(validateWeight(0)).resolves.toBe(true); + await expect(validateWeight(-1)).resolves.toBe(true); + await expect(validateWeight(1000.001)).resolves.toBe(true); + await expect(validateWeight(1.2345)).resolves.toBe(true); + await expect(validateWeight("not-a-number")).resolves.toBe(true); + await expect(validateWeight(Number.NaN)).resolves.toBe(true); + await expect(validateWeight(Number.POSITIVE_INFINITY)).resolves.toBe(true); + }); +}); diff --git a/apps/backend/src/domains/orders/delivery/delivery.service.ts b/apps/backend/src/domains/orders/delivery/delivery.service.ts new file mode 100644 index 00000000..8af1c3b3 --- /dev/null +++ b/apps/backend/src/domains/orders/delivery/delivery.service.ts @@ -0,0 +1,799 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Inject, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { + DeliveryBooking, + DeliveryMethod, + DeliveryStatus, + OrderStatus, + OrderType, + Prisma, + StoreType, +} from "@prisma/client"; +import { createHash } from "crypto"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { RedisService } from "../../../core/redis/redis.service"; +import { CommerceOutboxService } from "../../platform/outbox/commerce-outbox.service"; +import { + NormalizedShippingStatus, + SHIPPING_PROVIDER, + ShippingAddress, + ShippingParcel, + ShippingProvider, + ShippingQuoteOption, +} from "./providers/shipping-provider.interface"; + +const DELIVERY_ESTIMATE_TTL_SECONDS = 30 * 60; + +export type DeliveryEstimateMode = "EXACT" | "PREVIEW"; + +export interface DeliveryEstimateQuery { + productId?: string; + sourcedProductId?: string; + shopperCity: string; + shopperState: string; + shopperAddress?: string; + shopperName?: string; + shopperEmail?: string; + shopperPhone?: string; + quantity?: number; + variantId?: string; +} + +export interface DeliveryEstimateOption { + type: "SAME_DAY" | "NATIONWIDE"; + label: string; + eta: string; + available: boolean; + mode: DeliveryEstimateMode; + priceKobo: bigint | null; + serviceCode: string | null; + rateId: string | null; + requestToken: string | null; +} + +export interface DeliveryEstimateResponse { + mode: DeliveryEstimateMode; + pickupAddressComplete: boolean; + options: DeliveryEstimateOption[]; +} + +export interface DeliveryParcelInput { + weightKg: number; + categoryId: number; + itemName?: string; + itemValueKobo?: bigint; + lengthCm?: number; + widthCm?: number; + heightCm?: number; + description?: string; + quantity?: number; +} + +export interface BookDeliveryInput { + orderId: string; + rateId: string; + requestToken: string; + serviceCode?: string; + parcel: DeliveryParcelInput; +} + +interface StructuredAddress { + formattedAddress: string; + city: string; + state: string; + postalCode?: string; +} + +interface StorePickupRecord { + storeType: StoreType; + businessAddressDetails: unknown; + user: { + email: string; + phone: string | null; + displayName: string | null; + }; +} + +interface ProductPickupRecord { + id: string; + isActive: boolean; + name: string; + weightKg: number | null; + retailPriceKobo: bigint | null; + storeProfile: StorePickupRecord | null; +} + +@Injectable() +export class DeliveryService { + constructor( + private readonly prisma: PrismaService, + private readonly redis: RedisService, + @Inject(SHIPPING_PROVIDER) + private readonly shippingProvider: ShippingProvider, + private readonly commerceOutbox?: CommerceOutboxService, + ) {} + + async getEstimates( + query: DeliveryEstimateQuery, + ): Promise { + this.assertProductIdentity(query); + + const cacheKey = this.toEstimateCacheKey(query); + const cachedEstimate = await this.redis.get(cacheKey); + if (cachedEstimate) { + return this.parseEstimate(cachedEstimate); + } + + const product = await this.findPhysicalProduct(query); + if (!product || !product.isActive || !product.storeProfile) { + throw new NotFoundException({ + message: "Product not found", + code: "PRODUCT_NOT_FOUND", + }); + } + + const exactAddresses = this.toExactRateAddresses(product, query); + const result = exactAddresses + ? await this.toExactEstimate( + product, + exactAddresses.pickup, + exactAddresses.delivery, + query, + ) + : this.toPreviewEstimate(product); + + await this.redis.set( + cacheKey, + this.stringifyEstimate(result), + DELIVERY_ESTIMATE_TTL_SECONDS, + ); + + return result; + } + + async bookDelivery(storeId: string, input: BookDeliveryInput) { + const order = await this.prisma.order.findUnique({ + where: { id: input.orderId }, + include: { + deliveryBooking: true, + linkedOrder: true, + linkedFromOrder: true, + product: true, + storeProfile: { include: { user: true } }, + user: true, + }, + }); + + if (!order) { + throw new NotFoundException({ + message: "Order not found", + code: "ORDER_NOT_FOUND", + }); + } + if (order.orderType === OrderType.DROPSHIP_CUSTOMER) { + throw new ConflictException({ + message: "Book the dropship fulfillment order", + code: "DROPSHIP_FULFILLMENT_REQUIRED", + }); + } + if (order.storeId !== storeId) { + throw new ForbiddenException({ + message: "Order does not belong to this store", + code: "DELIVERY_ORDER_FORBIDDEN", + }); + } + if (order.status !== OrderStatus.PAID) { + throw new ConflictException({ + message: "Only paid orders can be booked for delivery", + code: "DELIVERY_ORDER_NOT_PAID", + }); + } + if (order.dispatchedAt || order.deliveryBooking) { + throw new ConflictException({ + message: "Delivery is already dispatched or booked", + code: "DELIVERY_ALREADY_BOOKED", + }); + } + if ( + order.deliveryMethod && + order.deliveryMethod !== DeliveryMethod.PLATFORM_LOGISTICS + ) { + throw new ConflictException({ + message: "Order does not use platform logistics", + code: "DELIVERY_METHOD_UNSUPPORTED", + }); + } + + const pickup = this.toOrderPickupAddress(order.storeProfile); + const dropoff = this.toOrderDropoffAddress(order); + const parcel = this.toParcel(input.parcel); + if (!pickup || !dropoff) { + throw new ConflictException({ + message: "Structured pickup and delivery addresses are required", + code: "DELIVERY_ADDRESS_INCOMPLETE", + }); + } + + const pendingBookingClaim = await this.createPendingBooking( + order, + pickup, + dropoff, + ); + if (!pendingBookingClaim.created) { + return pendingBookingClaim.booking; + } + + let booking; + try { + booking = await this.shippingProvider.createBooking({ + idempotencyKey: `delivery-booking:${pendingBookingClaim.booking.id}`, + quoteId: input.rateId, + bookingToken: input.requestToken, + serviceCode: input.serviceCode, + pickup, + dropoff, + parcel, + }); + } catch (error) { + await this.prisma.deliveryBooking + .delete({ where: { id: pendingBookingClaim.booking.id } }) + .catch(() => undefined); + throw error; + } + + return this.prisma.deliveryBooking.update({ + where: { id: pendingBookingClaim.booking.id }, + data: { + partnerName: booking.provider, + partnerRef: booking.providerBookingId, + trackingUrl: booking.trackingUrl, + actualCostKobo: booking.feeKobo, + status: DeliveryStatus.PICKUP_SCHEDULED, + estimatedArrival: this.toOptionalDate(booking.estimatedDeliveryDate), + }, + }); + } + + async handleNormalizedWebhook(payload: { + providerBookingId: string; + status: NormalizedShippingStatus; + }): Promise<{ status: "ignored" | "processed" }> { + const status = this.toDeliveryStatus(payload.status); + const booking = await this.prisma.deliveryBooking.findFirst({ + where: { partnerRef: payload.providerBookingId }, + }); + if ( + !booking || + booking.status === status || + !this.shouldApplyWebhookStatus(booking.status, status) + ) { + return { status: "ignored" }; + } + + const processed = await this.prisma.$transaction(async (tx) => { + const updated = await tx.deliveryBooking.updateMany({ + where: { id: booking.id, status: booking.status }, + data: { + status, + ...(status === DeliveryStatus.PICKED_UP + ? { pickedUpAt: new Date() } + : {}), + ...(status === DeliveryStatus.DELIVERED + ? { deliveredAt: new Date() } + : {}), + }, + }); + + if (updated.count === 0) { + return false; + } + + const updatedBooking = await tx.deliveryBooking.findUniqueOrThrow({ + where: { id: booking.id }, + select: { + id: true, + orderId: true, + status: true, + deliveredAt: true, + pickedUpAt: true, + }, + }); + const order = await tx.order.findUniqueOrThrow({ + where: { id: updatedBooking.orderId }, + select: { + id: true, + buyerId: true, + storeId: true, + status: true, + createdAt: true, + updatedAt: true, + }, + }); + + await this.commerceOutbox?.appendDeliveryUpdated(tx, { + deliveryBookingId: updatedBooking.id, + order, + status: updatedBooking.status, + updatedAt: + updatedBooking.deliveredAt ?? updatedBooking.pickedUpAt ?? new Date(), + }); + + return true; + }); + + return { status: processed ? "processed" : "ignored" }; + } + + private async createPendingBooking( + order: { + id: string; + deliveryFeeKobo: bigint | null; + }, + pickup: ShippingAddress, + dropoff: ShippingAddress, + ): Promise<{ + booking: DeliveryBooking; + created: boolean; + }> { + try { + const booking = await this.prisma.deliveryBooking.create({ + data: { + orderId: order.id, + method: DeliveryMethod.PLATFORM_LOGISTICS, + partnerName: "Shipbubble", + pickupAddress: pickup.address, + deliveryAddress: dropoff.address, + estimatedCostKobo: order.deliveryFeeKobo, + status: DeliveryStatus.PENDING, + }, + }); + return { booking, created: true }; + } catch (error) { + if (!this.isUniqueConstraintError(error)) { + throw error; + } + + const existing = await this.prisma.deliveryBooking.findFirst({ + where: { orderId: order.id }, + }); + if (existing) { + return { booking: existing, created: false }; + } + throw error; + } + } + + private assertProductIdentity(query: DeliveryEstimateQuery): void { + if (Boolean(query.productId) === Boolean(query.sourcedProductId)) { + throw new BadRequestException({ + message: "Provide either productId or sourcedProductId", + code: "DELIVERY_PRODUCT_REQUIRED", + }); + } + } + + private async findPhysicalProduct( + query: DeliveryEstimateQuery, + ): Promise { + if (query.productId) { + return this.prisma.product.findFirst({ + where: { id: query.productId }, + select: this.productPickupSelect(), + }); + } + + const sourcedProduct = await this.prisma.sourcedProduct.findFirst({ + where: { id: query.sourcedProductId, isActive: true }, + select: { + physicalProduct: { + select: this.productPickupSelect(), + }, + }, + }); + + return sourcedProduct?.physicalProduct ?? null; + } + + private productPickupSelect() { + return { + id: true, + isActive: true, + name: true, + weightKg: true, + retailPriceKobo: true, + storeProfile: { + select: { + storeType: true, + businessAddressDetails: true, + user: { + select: { + email: true, + phone: true, + displayName: true, + }, + }, + }, + }, + } satisfies Prisma.ProductSelect; + } + + private toPreviewEstimate( + product: ProductPickupRecord, + ): DeliveryEstimateResponse { + const pickupAddressComplete = Boolean(this.toProductPickupAddress(product)); + + return { + mode: "PREVIEW", + pickupAddressComplete, + options: [ + { + type: "SAME_DAY", + label: "Same-day delivery", + eta: "Address required for exact ETA", + available: pickupAddressComplete, + mode: "PREVIEW", + priceKobo: null, + serviceCode: null, + rateId: null, + requestToken: null, + }, + { + type: "NATIONWIDE", + label: "Nationwide delivery", + eta: "Address required for exact ETA", + available: true, + mode: "PREVIEW", + priceKobo: null, + serviceCode: null, + rateId: null, + requestToken: null, + }, + ], + }; + } + + private async toExactEstimate( + product: ProductPickupRecord, + pickup: ShippingAddress, + delivery: ShippingAddress, + query: DeliveryEstimateQuery, + ): Promise { + const rates = await this.shippingProvider.getQuotes( + pickup, + delivery, + this.toEstimateParcel(product, query.quantity), + ); + + return { + mode: "EXACT", + pickupAddressComplete: true, + options: rates.map((rate) => this.toRateOption(rate)), + }; + } + + private toRateOption(rate: ShippingQuoteOption): DeliveryEstimateOption { + const sameDay = `${rate.serviceCode ?? ""} ${rate.serviceName}` + .toLowerCase() + .includes("same"); + + return { + type: sameDay ? "SAME_DAY" : "NATIONWIDE", + label: sameDay + ? `Same-day delivery - ${rate.serviceName}` + : `Nationwide - ${rate.serviceName}`, + eta: + rate.estimatedDeliveryWindow || + rate.estimatedDeliveryDate || + "Courier ETA pending", + available: true, + mode: "EXACT", + priceKobo: rate.amountKobo, + serviceCode: rate.serviceCode ?? null, + rateId: rate.quoteId, + requestToken: rate.bookingToken ?? null, + }; + } + + private toExactRateAddresses( + product: ProductPickupRecord, + query: DeliveryEstimateQuery, + ): { pickup: ShippingAddress; delivery: ShippingAddress } | null { + const pickup = this.toProductPickupAddress(product); + if ( + !pickup || + !query.shopperAddress || + !query.shopperName || + !query.shopperEmail || + !query.shopperPhone + ) { + return null; + } + + return { + pickup, + delivery: { + name: query.shopperName, + email: query.shopperEmail, + phone: query.shopperPhone, + address: query.shopperAddress, + city: query.shopperCity, + state: query.shopperState, + countryCode: "NG", + }, + }; + } + + private toProductPickupAddress( + product: ProductPickupRecord, + ): ShippingAddress | null { + const store = product.storeProfile; + const address = this.toStructuredAddress(store?.businessAddressDetails); + if ( + !store || + store.storeType !== StoreType.PHYSICAL || + !address || + !store.user.phone + ) { + return null; + } + + return { + name: store.user.displayName ?? product.name, + email: store.user.email, + phone: store.user.phone, + address: address.formattedAddress, + city: address.city, + state: address.state, + countryCode: "NG", + postalCode: address.postalCode, + }; + } + + private toEstimateParcel( + product: ProductPickupRecord, + quantity = 1, + ): ShippingParcel { + const baseWeightKg = + typeof product.weightKg === "number" && + Number.isFinite(product.weightKg) && + product.weightKg > 0 + ? product.weightKg + : 1; + + return { + weightKg: this.toValidWeightKg(baseWeightKg * quantity), + itemName: product.name, + itemValueKobo: product.retailPriceKobo ?? undefined, + quantity, + }; + } + + private toParcel(parcel: DeliveryParcelInput): ShippingParcel { + return { + ...parcel, + weightKg: this.toValidWeightKg(parcel.weightKg), + }; + } + + private toValidWeightKg(weightKg: number): number { + if (!Number.isFinite(weightKg) || weightKg <= 0 || weightKg > 1000) { + throw new BadRequestException({ + message: "Package weight must be a positive number up to 1000kg", + code: "DELIVERY_WEIGHT_INVALID", + }); + } + + return Math.ceil(weightKg * 1000) / 1000; + } + + private shouldApplyWebhookStatus( + current: DeliveryStatus, + incoming: DeliveryStatus, + ): boolean { + if ( + current === DeliveryStatus.DELIVERED || + current === DeliveryStatus.FAILED + ) { + return false; + } + + if (incoming === DeliveryStatus.FAILED) { + return true; + } + + return this.deliveryStatusRank(incoming) > this.deliveryStatusRank(current); + } + + private deliveryStatusRank(status: DeliveryStatus): number { + switch (status) { + case DeliveryStatus.PENDING: + return 0; + case DeliveryStatus.PICKUP_SCHEDULED: + return 1; + case DeliveryStatus.PICKED_UP: + return 2; + case DeliveryStatus.IN_TRANSIT: + return 3; + case DeliveryStatus.ARRIVING: + return 4; + case DeliveryStatus.DELIVERED: + return 5; + case DeliveryStatus.FAILED: + return 6; + } + } + + private isUniqueConstraintError(error: unknown): boolean { + return ( + (error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002") || + (typeof error === "object" && + error !== null && + "code" in error && + error.code === "P2002") + ); + } + + private toOrderPickupAddress( + store: + | (StorePickupRecord & { + businessAddressDetails: Prisma.JsonValue | null; + }) + | null, + ): ShippingAddress | null { + const address = this.toStructuredAddress(store?.businessAddressDetails); + if ( + !store || + store.storeType !== StoreType.PHYSICAL || + !address || + !store.user.phone + ) { + return null; + } + + return { + name: store.user.displayName ?? "Store pickup", + email: store.user.email, + phone: store.user.phone, + address: address.formattedAddress, + city: address.city, + state: address.state, + countryCode: "NG", + postalCode: address.postalCode, + }; + } + + private toOrderDropoffAddress(order: { + deliveryDetails: Prisma.JsonValue | null; + user: { + email: string; + phone: string | null; + displayName: string | null; + }; + }): ShippingAddress | null { + const details = this.toAddressRecord(order.deliveryDetails); + const address = this.toStructuredAddress(order.deliveryDetails); + const phone = this.readString(details, "phone") ?? order.user.phone; + const email = this.readString(details, "email") ?? order.user.email; + const name = + this.readString(details, "name") ?? order.user.displayName ?? undefined; + + if (!address || !name || !email || !phone) { + return null; + } + + return { + name, + email, + phone, + address: address.formattedAddress, + city: address.city, + state: address.state, + countryCode: "NG", + postalCode: address.postalCode, + }; + } + + private toStructuredAddress(value: unknown): StructuredAddress | null { + const record = this.toAddressRecord(value); + const formattedAddress = this.readString(record, "formattedAddress"); + const city = this.readString(record, "city"); + const state = this.readString(record, "state"); + if (!formattedAddress || !city || !state) { + return null; + } + + return { + formattedAddress, + city, + state, + postalCode: this.readString(record, "postalCode"), + }; + } + + private toAddressRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + } + + private readString( + record: Record, + key: string, + ): string | undefined { + const value = record[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; + } + + private toDeliveryStatus(value: NormalizedShippingStatus): DeliveryStatus { + switch (value) { + case "BOOKED": + return DeliveryStatus.PICKUP_SCHEDULED; + case "PICKED_UP": + return DeliveryStatus.PICKED_UP; + case "IN_TRANSIT": + return DeliveryStatus.IN_TRANSIT; + case "OUT_FOR_DELIVERY": + return DeliveryStatus.ARRIVING; + case "DELIVERED": + return DeliveryStatus.DELIVERED; + case "FAILED": + case "CANCELLED": + return DeliveryStatus.FAILED; + default: + return DeliveryStatus.PENDING; + } + } + + private toOptionalDate(value: string | undefined): Date | undefined { + if (!value) { + return undefined; + } + + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date; + } + + private stringifyEstimate(result: DeliveryEstimateResponse): string { + return JSON.stringify(result, (_key, value: unknown) => + typeof value === "bigint" ? `${value}n` : value, + ); + } + + private parseEstimate(value: string): DeliveryEstimateResponse { + return JSON.parse(value, (_key, parsed: unknown) => + typeof parsed === "string" && /^\d+n$/.test(parsed) + ? BigInt(parsed.slice(0, -1)) + : parsed, + ) as DeliveryEstimateResponse; + } + + private toEstimateCacheKey(query: DeliveryEstimateQuery): string { + const productKey = query.productId + ? `product:${query.productId}` + : `sourced:${query.sourcedProductId}`; + const city = query.shopperCity.trim().toLowerCase(); + const state = query.shopperState.trim().toLowerCase(); + const variant = query.variantId || "default"; + const quantity = query.quantity || 1; + const destination = query.shopperAddress + ? createHash("sha256") + .update( + [query.shopperAddress, query.shopperCity, query.shopperState].join( + ":", + ), + ) + .digest("hex") + .slice(0, 16) + : "preview"; + + return `delivery:estimate:${productKey}:${state}:${city}:${variant}:${quantity}:${destination}`; + } +} diff --git a/apps/backend/src/domains/orders/delivery/dto/book-delivery.dto.ts b/apps/backend/src/domains/orders/delivery/dto/book-delivery.dto.ts new file mode 100644 index 00000000..5dc9b692 --- /dev/null +++ b/apps/backend/src/domains/orders/delivery/dto/book-delivery.dto.ts @@ -0,0 +1,86 @@ +import { Type } from "class-transformer"; +import { + IsInt, + IsNumber, + IsOptional, + IsString, + Matches, + Max, + MaxLength, + Min, + ValidateNested, +} from "class-validator"; + +export class DeliveryParcelDto { + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 3 }) + @Min(0.001) + @Max(1000) + weightKg!: number; + + @Type(() => Number) + @IsInt() + @Min(1) + categoryId!: number; + + @IsOptional() + @IsString() + @MaxLength(160) + itemName?: string; + + @IsOptional() + @Matches(/^\d+$/) + itemValueKobo?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0.01) + lengthCm?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0.01) + widthCm?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0.01) + heightCm?: number; + + @IsOptional() + @IsString() + @MaxLength(300) + description?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + quantity?: number; +} + +export class BookDeliveryDto { + @IsString() + @MaxLength(120) + orderId!: string; + + @IsString() + @MaxLength(160) + rateId!: string; + + @IsString() + @MaxLength(255) + requestToken!: string; + + @IsOptional() + @IsString() + @MaxLength(160) + serviceCode?: string; + + @ValidateNested() + @Type(() => DeliveryParcelDto) + parcel!: DeliveryParcelDto; +} diff --git a/apps/backend/src/domains/orders/delivery/dto/delivery-estimate-query.dto.ts b/apps/backend/src/domains/orders/delivery/dto/delivery-estimate-query.dto.ts new file mode 100644 index 00000000..023fcd35 --- /dev/null +++ b/apps/backend/src/domains/orders/delivery/dto/delivery-estimate-query.dto.ts @@ -0,0 +1,60 @@ +import { Type } from "class-transformer"; +import { + IsInt, + IsOptional, + IsString, + MaxLength, + Min, + ValidateIf, +} from "class-validator"; + +export class DeliveryEstimateQueryDto { + @ValidateIf((dto: DeliveryEstimateQueryDto) => !dto.sourcedProductId) + @IsString() + @MaxLength(120) + productId?: string; + + @ValidateIf((dto: DeliveryEstimateQueryDto) => !dto.productId) + @IsString() + @MaxLength(120) + sourcedProductId?: string; + + @IsString() + @MaxLength(120) + shopperCity!: string; + + @IsString() + @MaxLength(120) + shopperState!: string; + + @IsOptional() + @IsString() + @MaxLength(300) + shopperAddress?: string; + + @IsOptional() + @IsString() + @MaxLength(160) + shopperName?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + shopperEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(40) + shopperPhone?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + quantity?: number; + + @IsOptional() + @IsString() + @MaxLength(120) + variantId?: string; +} diff --git a/apps/backend/src/domains/orders/delivery/dto/shipbubble-webhook.dto.ts b/apps/backend/src/domains/orders/delivery/dto/shipbubble-webhook.dto.ts new file mode 100644 index 00000000..eea73cfc --- /dev/null +++ b/apps/backend/src/domains/orders/delivery/dto/shipbubble-webhook.dto.ts @@ -0,0 +1,11 @@ +import { IsString, MaxLength } from "class-validator"; + +export class ShipbubbleWebhookDto { + @IsString() + @MaxLength(160) + shipmentId!: string; + + @IsString() + @MaxLength(120) + status!: string; +} diff --git a/apps/backend/src/domains/orders/delivery/providers/shipping-provider.interface.ts b/apps/backend/src/domains/orders/delivery/providers/shipping-provider.interface.ts new file mode 100644 index 00000000..76d8c1d7 --- /dev/null +++ b/apps/backend/src/domains/orders/delivery/providers/shipping-provider.interface.ts @@ -0,0 +1,92 @@ +export type ShippingProviderName = "shipbubble"; + +export type NormalizedShippingStatus = + | "PENDING" + | "BOOKED" + | "PICKED_UP" + | "IN_TRANSIT" + | "OUT_FOR_DELIVERY" + | "DELIVERED" + | "FAILED" + | "CANCELLED" + | "UNKNOWN"; + +export interface ShippingAddress { + name?: string; + email?: string; + phone: string; + address: string; + city: string; + state: string; + countryCode?: string; + postalCode?: string; +} + +export interface ShippingParcel { + weightKg: number; + categoryId?: number; + itemName?: string; + itemValueKobo?: bigint; + lengthCm?: number; + widthCm?: number; + heightCm?: number; + description?: string; + quantity?: number; +} + +export interface ShippingQuoteOption { + provider: ShippingProviderName; + quoteId: string; + bookingToken?: string; + serviceCode?: string; + serviceName: string; + amountKobo: bigint; + estimatedDeliveryDate?: string; + estimatedDeliveryWindow?: string; +} + +export interface ShippingBookingRequest { + idempotencyKey: string; + quoteId: string; + bookingToken: string; + serviceCode?: string; + pickup: ShippingAddress; + dropoff: ShippingAddress; + parcel: ShippingParcel; +} + +export interface ShippingBookingResult { + provider: ShippingProviderName; + providerBookingId: string; + providerTrackingId?: string; + trackingUrl?: string; + feeKobo?: bigint; + estimatedDeliveryDate?: string; + status: NormalizedShippingStatus; +} + +export interface NormalizedShippingWebhookEvent { + provider: ShippingProviderName; + providerEventId: string; + providerBookingId: string; + status: NormalizedShippingStatus; + occurredAt: Date; +} + +export interface ShippingProvider { + getQuotes( + pickup: ShippingAddress, + dropoff: ShippingAddress, + parcel: ShippingParcel, + ): Promise; + createBooking( + request: ShippingBookingRequest, + ): Promise; + verifyAndNormalizeWebhook( + headers: Record, + rawBody: Buffer, + payload: unknown, + ): Promise; +} + +export const SHIPPING_PROVIDER = Symbol("SHIPPING_PROVIDER"); diff --git a/apps/backend/src/domains/orders/dispute/README.md b/apps/backend/src/domains/orders/dispute/README.md new file mode 100644 index 00000000..d93fdc15 --- /dev/null +++ b/apps/backend/src/domains/orders/dispute/README.md @@ -0,0 +1,5 @@ +# Dispute Domain + +Order dispute filing and operator resolution boundary. +To be built as part of the orders domain. + diff --git a/apps/backend/src/domains/orders/dispute/dispute.controller.ts b/apps/backend/src/domains/orders/dispute/dispute.controller.ts new file mode 100644 index 00000000..5e3b135d --- /dev/null +++ b/apps/backend/src/domains/orders/dispute/dispute.controller.ts @@ -0,0 +1,90 @@ +import { Body, Controller, Get, Param, Post, UseGuards } from "@nestjs/common"; +import { JwtPayload, UserRole } from "@twizrr/shared"; +import { CurrentUser } from "../../../common/decorators/current-user.decorator"; +import { Roles } from "../../../common/decorators/roles.decorator"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { RolesGuard } from "../../../common/guards/roles.guard"; +import { CloseDisputeDto } from "./dto/close-dispute.dto"; +import { CreateDisputeDto } from "./dto/create-dispute.dto"; +import { DisputeEvidenceDto } from "./dto/dispute-evidence.dto"; +import { ResolveDisputeDto } from "./dto/resolve-dispute.dto"; +import { RespondDisputeDto } from "./dto/respond-dispute.dto"; +import { DisputeService } from "./dispute.service"; + +@Controller() +@UseGuards(JwtAuthGuard, RolesGuard) +export class DisputeController { + constructor(private readonly disputeService: DisputeService) {} + + @Post("orders/:id/disputes") + openDispute( + @CurrentUser() user: JwtPayload, + @Param("id") orderId: string, + @Body() dto: CreateDisputeDto, + ) { + return this.disputeService.openDispute(orderId, user, dto); + } + + @Get("orders/:id/disputes") + getOrderDisputes( + @CurrentUser() user: JwtPayload, + @Param("id") orderId: string, + ) { + return this.disputeService.getOrderDisputes(orderId, user); + } + + @Get("disputes") + listDisputes(@CurrentUser() user: JwtPayload) { + return this.disputeService.listDisputes(user); + } + + @Get("disputes/:id") + getDispute(@CurrentUser() user: JwtPayload, @Param("id") id: string) { + return this.disputeService.getDispute(id, user); + } + + @Get("disputes/:id/settlement") + getSettlementVisibility( + @CurrentUser() user: JwtPayload, + @Param("id") id: string, + ) { + return this.disputeService.getSettlementVisibility(id, user); + } + + @Post("disputes/:id/respond") + respondToDispute( + @CurrentUser() user: JwtPayload, + @Param("id") id: string, + @Body() dto: RespondDisputeDto, + ) { + return this.disputeService.respondToDispute(id, user, dto); + } + + @Post("disputes/:id/evidence") + addEvidence( + @CurrentUser() user: JwtPayload, + @Param("id") id: string, + @Body() dto: DisputeEvidenceDto, + ) { + return this.disputeService.addEvidence(id, user, dto); + } + + @Post("disputes/:id/resolve") + @Roles(UserRole.SUPER_ADMIN) + resolveDispute( + @CurrentUser() user: JwtPayload, + @Param("id") id: string, + @Body() dto: ResolveDisputeDto, + ) { + return this.disputeService.resolveDispute(id, user, dto); + } + + @Post("disputes/:id/close") + closeDispute( + @CurrentUser() user: JwtPayload, + @Param("id") id: string, + @Body() dto: CloseDisputeDto, + ) { + return this.disputeService.closeDispute(id, user, dto); + } +} diff --git a/apps/backend/src/domains/orders/dispute/dispute.module.ts b/apps/backend/src/domains/orders/dispute/dispute.module.ts new file mode 100644 index 00000000..f5d5b907 --- /dev/null +++ b/apps/backend/src/domains/orders/dispute/dispute.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { PrismaModule } from "../../../prisma/prisma.module"; +import { DisputeController } from "./dispute.controller"; +import { DisputeService } from "./dispute.service"; +import { DomainEventModule } from "../../platform/domain-event/domain-event.module"; +import { OutboxModule } from "../../platform/outbox/outbox.module"; +import { SettlementModule } from "../../money/settlement/settlement.module"; + +@Module({ + imports: [PrismaModule, DomainEventModule, OutboxModule, SettlementModule], + controllers: [DisputeController], + providers: [DisputeService], + exports: [DisputeService], +}) +export class DisputeModule {} diff --git a/apps/backend/src/domains/orders/dispute/dispute.service.spec.ts b/apps/backend/src/domains/orders/dispute/dispute.service.spec.ts new file mode 100644 index 00000000..837b6139 --- /dev/null +++ b/apps/backend/src/domains/orders/dispute/dispute.service.spec.ts @@ -0,0 +1,420 @@ +import { + DisputeResolutionOutcome, + DisputeStatus, + OrderDisputeStatus, + OrderStatus, + Prisma, +} from "@prisma/client"; +import { UserRole } from "@twizrr/shared"; + +import { DisputeService } from "./dispute.service"; + +const BUYER = { + sub: "buyer-1", + email: "buyer@example.com", + role: UserRole.USER, +}; +const STORE_USER = { + sub: "merchant-user-1", + email: "store@example.com", + role: UserRole.USER, + storeId: "store-1", +}; +const OPERATOR = { + sub: "admin-1", + email: "admin@example.com", + role: UserRole.SUPER_ADMIN, +}; + +function makeService(overrides?: { + disputeStatus?: DisputeStatus; + createRejects?: unknown; + orderUpdateCount?: number; +}) { + const order = { + id: "order-1", + buyerId: "buyer-1", + storeId: "store-1", + status: OrderStatus.PAID, + disputeStatus: OrderDisputeStatus.NONE, + disputeWindowEndsAt: new Date(Date.now() + 60_000), + orderType: "DIRECT", + storeProfile: { id: "store-1" }, + payments: [], + }; + + const dispute = { + id: "dispute-1", + orderId: "order-1", + buyerId: "buyer-1", + storeId: "store-1", + status: overrides?.disputeStatus ?? DisputeStatus.OPEN, + reason: "damaged", + description: "The dress arrived torn at the seam", + buyerRequestedOutcome: "refund", + storeResponse: null, + resolutionOutcome: null, + resolutionNotes: null, + resolvedById: null, + resolvedAt: new Date("2026-07-12T10:00:00.000Z"), + closedAt: null, + createdAt: new Date("2026-07-12T09:00:00.000Z"), + updatedAt: new Date("2026-07-12T10:00:00.000Z"), + order, + buyer: { id: "buyer-1", username: "buyer", displayName: "Buyer" }, + resolver: null, + store: { id: "store-1", storeHandle: "store", storeName: "Store" }, + evidence: [], + }; + + const createMock = overrides?.createRejects + ? jest.fn().mockRejectedValue(overrides.createRejects) + : jest.fn().mockResolvedValue(dispute); + + const tx = { + dispute: { + create: createMock, + findUnique: jest.fn().mockResolvedValue(dispute), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + order: { + updateMany: jest + .fn() + .mockResolvedValue({ count: overrides?.orderUpdateCount ?? 1 }), + findUnique: jest.fn().mockResolvedValue(order), + }, + payout: { updateMany: jest.fn().mockResolvedValue({ count: 0 }) }, + disputeEvidence: { createMany: jest.fn().mockResolvedValue({ count: 0 }) }, + orderEvent: { + create: jest.fn().mockResolvedValue({ id: "order-event-1" }), + }, + auditLog: { create: jest.fn().mockResolvedValue({ id: "audit-1" }) }, + storeProfile: { + findUnique: jest.fn().mockResolvedValue({ userId: "merchant-user-1" }), + }, + user: { + findMany: jest + .fn() + .mockResolvedValue([{ id: "admin-1" }, { id: "admin-2" }]), + }, + }; + + const prisma = { + order: { findUnique: jest.fn().mockResolvedValue(order) }, + dispute: { + findFirst: jest.fn().mockResolvedValue(null), + findUnique: jest.fn().mockResolvedValue(dispute), + }, + $transaction: jest.fn((cb: (transaction: typeof tx) => unknown) => cb(tx)), + }; + + const domainEvents = { + append: jest.fn().mockResolvedValue({ id: "domain-event-1" }), + }; + + const commerceOutbox = { + appendDisputeNotification: jest.fn().mockResolvedValue(undefined), + appendDisputeCreatedRealtime: jest.fn().mockResolvedValue(undefined), + appendDisputeUpdatedRealtime: jest.fn().mockResolvedValue(undefined), + appendDisputeResolvedRealtime: jest.fn().mockResolvedValue(undefined), + }; + + const settlement = { + buildPlan: jest.fn().mockResolvedValue({ + amounts: { + buyerRefundKobo: 0n, + merchantPayoutKobo: 0n, + platformRetainedKobo: 0n, + }, + capturedAmountKobo: 0n, + initialStatus: "PENDING", + manualReviewReason: null, + }), + createSettlementInTx: jest + .fn() + .mockResolvedValue({ id: "settlement-1", status: "PENDING" }), + }; + + const service = new DisputeService( + prisma as never, + domainEvents as never, + commerceOutbox as never, + settlement as never, + ); + + return { + service, + tx, + prisma, + domainEvents, + commerceOutbox, + settlement, + dispute, + }; +} + +describe("DisputeService — open", () => { + it("appends merchant + admin notifications and a buyer/merchant/admin realtime hint", async () => { + const { service, commerceOutbox } = makeService(); + + await service.openDispute("order-1", BUYER, { + reason: "damaged", + description: "The dress arrived torn at the seam", + buyerRequestedOutcome: "refund", + evidence: [], + }); + + // Merchant + 2 admins each get a durable notification. + expect(commerceOutbox.appendDisputeNotification).toHaveBeenCalledTimes(3); + const recipients = commerceOutbox.appendDisputeNotification.mock.calls.map( + (call) => call[1].recipientUserId, + ); + expect(recipients).toEqual( + expect.arrayContaining(["merchant-user-1", "admin-1", "admin-2"]), + ); + + // Realtime fan-out targets buyer, merchant and admins. + expect(commerceOutbox.appendDisputeCreatedRealtime).toHaveBeenCalledTimes( + 1, + ); + const realtimeArg = + commerceOutbox.appendDisputeCreatedRealtime.mock.calls[0][1]; + expect(realtimeArg.recipientUserIds).toEqual( + expect.arrayContaining([ + "buyer-1", + "merchant-user-1", + "admin-1", + "admin-2", + ]), + ); + expect(realtimeArg.domainEventId).toBe("domain-event-1"); + }); + + it("addresses the merchant by StoreProfile.userId, never the store id", async () => { + const { service, commerceOutbox } = makeService(); + + await service.openDispute("order-1", BUYER, { + reason: "damaged", + description: "The dress arrived torn at the seam", + evidence: [], + }); + + const merchantNotification = + commerceOutbox.appendDisputeNotification.mock.calls.find( + (call) => call[1].recipientUserId === "merchant-user-1", + ); + expect(merchantNotification).toBeDefined(); + expect( + commerceOutbox.appendDisputeNotification.mock.calls.some( + (call) => call[1].recipientUserId === "store-1", + ), + ).toBe(false); + }); + + it("never leaks the dispute description into realtime payloads", async () => { + const { service, commerceOutbox } = makeService(); + + await service.openDispute("order-1", BUYER, { + reason: "damaged", + description: "The dress arrived torn at the seam", + evidence: [], + }); + + const realtimeArg = + commerceOutbox.appendDisputeCreatedRealtime.mock.calls[0][1]; + expect(Object.keys(realtimeArg.dispute).sort()).toEqual([ + "id", + "orderId", + "status", + ]); + expect(JSON.stringify(realtimeArg.dispute)).not.toContain("torn"); + }); + + it("rolls back and appends nothing when the order guard fails", async () => { + const { service, commerceOutbox } = makeService({ orderUpdateCount: 0 }); + + await expect( + service.openDispute("order-1", BUYER, { + reason: "damaged", + description: "The dress arrived torn at the seam", + evidence: [], + }), + ).rejects.toMatchObject({ response: { code: "DISPUTE_STATE_CONFLICT" } }); + + expect(commerceOutbox.appendDisputeCreatedRealtime).not.toHaveBeenCalled(); + expect(commerceOutbox.appendDisputeNotification).not.toHaveBeenCalled(); + }); + + it("maps a unique-constraint violation to DISPUTE_ALREADY_OPEN", async () => { + const p2002 = new Prisma.PrismaClientKnownRequestError( + "Unique constraint failed", + { code: "P2002", clientVersion: "test" }, + ); + const { service } = makeService({ createRejects: p2002 }); + + await expect( + service.openDispute("order-1", BUYER, { + reason: "damaged", + description: "The dress arrived torn at the seam", + evidence: [], + }), + ).rejects.toMatchObject({ response: { code: "DISPUTE_ALREADY_OPEN" } }); + }); +}); + +describe("DisputeService — store response", () => { + it("emits STORE_RESPONDED to buyer/merchant/admins and does not notify the store of its own action", async () => { + const { service, commerceOutbox } = makeService(); + + await service.respondToDispute("dispute-1", STORE_USER, { + response: "We shipped the correct item", + }); + + expect(commerceOutbox.appendDisputeUpdatedRealtime).toHaveBeenCalledTimes( + 1, + ); + const arg = commerceOutbox.appendDisputeUpdatedRealtime.mock.calls[0][1]; + expect(arg.action).toBe("STORE_RESPONDED"); + expect(arg.recipientUserIds).toEqual( + expect.arrayContaining(["buyer-1", "merchant-user-1", "admin-1"]), + ); + expect(commerceOutbox.appendDisputeNotification).not.toHaveBeenCalled(); + }); + + it("appends nothing when the guarded status update does not apply", async () => { + const { service, tx, commerceOutbox } = makeService(); + tx.dispute.updateMany.mockResolvedValueOnce({ count: 0 }); + + await expect( + service.respondToDispute("dispute-1", STORE_USER, { response: "hi" }), + ).rejects.toMatchObject({ response: { code: "DISPUTE_STATE_CONFLICT" } }); + + expect(commerceOutbox.appendDisputeUpdatedRealtime).not.toHaveBeenCalled(); + }); +}); + +describe("DisputeService — evidence", () => { + it("emits EVIDENCE_ADDED without evidence contents and no email notification", async () => { + const { service, commerceOutbox } = makeService(); + + await service.addEvidence("dispute-1", BUYER, { + url: "https://cdn.example/evidence.jpg", + publicId: "evidence-1", + note: "Photo of the tear", + }); + + expect(commerceOutbox.appendDisputeUpdatedRealtime).toHaveBeenCalledTimes( + 1, + ); + const arg = commerceOutbox.appendDisputeUpdatedRealtime.mock.calls[0][1]; + expect(arg.action).toBe("EVIDENCE_ADDED"); + expect(JSON.stringify(arg)).not.toContain("cdn.example"); + expect(JSON.stringify(arg)).not.toContain("Photo of the tear"); + expect(commerceOutbox.appendDisputeNotification).not.toHaveBeenCalled(); + }); +}); + +describe("DisputeService — resolve", () => { + it("appends transactional buyer + merchant notifications and a resolved realtime hint", async () => { + const { service, commerceOutbox, domainEvents } = makeService({ + disputeStatus: DisputeStatus.STORE_RESPONDED, + }); + + await service.resolveDispute("dispute-1", OPERATOR, { + outcome: DisputeResolutionOutcome.BUYER_WINS, + resolutionNotes: "Refund the shopper", + }); + + const notifRecipients = + commerceOutbox.appendDisputeNotification.mock.calls.map( + (call) => call[1].recipientUserId, + ); + expect(notifRecipients).toEqual( + expect.arrayContaining(["buyer-1", "merchant-user-1"]), + ); + expect(commerceOutbox.appendDisputeResolvedRealtime).toHaveBeenCalledTimes( + 1, + ); + + // Phase 5: resolution now creates a durable settlement plan and records it + // on the domain event metadata (no longer a bare "DEFERRED" marker). + const resolveEvent = domainEvents.append.mock.calls.find( + (call) => call[1].metadata?.settlementId, + ); + expect(resolveEvent?.[1].metadata.settlementId).toBe("settlement-1"); + expect(resolveEvent?.[1].metadata.settlementStatus).toBe("PENDING"); + }); + + it("uses wording that does not claim money has already moved", async () => { + const { service, commerceOutbox } = makeService({ + disputeStatus: DisputeStatus.STORE_RESPONDED, + }); + + await service.resolveDispute("dispute-1", OPERATOR, { + outcome: DisputeResolutionOutcome.BUYER_WINS, + resolutionNotes: "Refund the shopper", + }); + + const bodies = commerceOutbox.appendDisputeNotification.mock.calls.map( + (call) => call[1].body, + ); + for (const body of bodies) { + expect(body).not.toMatch(/refund (completed|sent)|funds released|paid/i); + } + expect(bodies.some((body) => /pending processing/i.test(body))).toBe(true); + }); + + it("does not touch ledger, escrow, refund, or payout state", async () => { + const { service, tx } = makeService({ + disputeStatus: DisputeStatus.STORE_RESPONDED, + }); + + await service.resolveDispute("dispute-1", OPERATOR, { + outcome: DisputeResolutionOutcome.SELLER_WINS, + resolutionNotes: "Store proved dispatch", + }); + + expect(tx).not.toHaveProperty("ledgerEntry"); + // Resolution plans the settlement but never moves money or mutates payout + // rows directly (payout holds happen when a dispute opens, not on resolve). + expect(tx.payout.updateMany).not.toHaveBeenCalled(); + // Only dispute/order/orderEvent/auditLog/settlement writes happen in the transaction. + expect(tx.order.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + disputeStatus: OrderDisputeStatus.RESOLVED_STORE, + }), + }), + ); + }); +}); + +describe("DisputeService — close", () => { + it("emits CLOSED to buyer/merchant/admins", async () => { + const { service, commerceOutbox } = makeService({ + disputeStatus: DisputeStatus.STORE_RESPONDED, + }); + + await service.closeDispute("dispute-1", OPERATOR, { reason: "withdrawn" }); + + expect(commerceOutbox.appendDisputeUpdatedRealtime).toHaveBeenCalledTimes( + 1, + ); + expect( + commerceOutbox.appendDisputeUpdatedRealtime.mock.calls[0][1].action, + ).toBe("CLOSED"); + }); + + it("appends nothing when the guarded close update does not apply", async () => { + const { service, tx, commerceOutbox } = makeService({ + disputeStatus: DisputeStatus.STORE_RESPONDED, + }); + tx.dispute.updateMany.mockResolvedValueOnce({ count: 0 }); + + await expect( + service.closeDispute("dispute-1", OPERATOR, { reason: "withdrawn" }), + ).rejects.toMatchObject({ response: { code: "DISPUTE_STATE_CONFLICT" } }); + + expect(commerceOutbox.appendDisputeUpdatedRealtime).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/domains/orders/dispute/dispute.service.ts b/apps/backend/src/domains/orders/dispute/dispute.service.ts new file mode 100644 index 00000000..6d50f2e7 --- /dev/null +++ b/apps/backend/src/domains/orders/dispute/dispute.service.ts @@ -0,0 +1,1187 @@ +import { + ConflictException, + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { + DisputeActorType, + DisputeResolutionOutcome, + DisputeStatus, + DomainEvent, + OrderDisputeStatus, + OrderStatus, + PayoutStatus, + Prisma, + UserRole as PrismaUserRole, +} from "@prisma/client"; +import { + DISPUTE_UPDATED_ACTION, + JwtPayload, + NotificationChannel, + NotificationType, + UserRole, + type DisputeUpdatedAction, +} from "@twizrr/shared"; +import { PrismaService } from "../../../prisma/prisma.service"; +import { CloseDisputeDto } from "./dto/close-dispute.dto"; +import { CreateDisputeDto } from "./dto/create-dispute.dto"; +import { DisputeEvidenceDto } from "./dto/dispute-evidence.dto"; +import { ResolveDisputeDto } from "./dto/resolve-dispute.dto"; +import { RespondDisputeDto } from "./dto/respond-dispute.dto"; +import { DomainEventService } from "../../platform/domain-event/domain-event.service"; +import { CommerceOutboxService } from "../../platform/outbox/commerce-outbox.service"; +import { SettlementService } from "../../money/settlement/settlement.service"; + +const ACTIVE_DISPUTE_STATUSES: DisputeStatus[] = [ + DisputeStatus.OPEN, + DisputeStatus.STORE_RESPONDED, + DisputeStatus.UNDER_REVIEW, +] as const; + +const OPEN_ELIGIBLE_ORDER_STATUSES: OrderStatus[] = [ + OrderStatus.PAID, + OrderStatus.DISPATCHED, + OrderStatus.DELIVERED, + OrderStatus.COMPLETED, +] as const; + +type DisputeWithRelations = Prisma.DisputeGetPayload<{ + include: { + order: true; + buyer: true; + resolver: true; + store: true; + evidence: { include: { actor: true }; orderBy: { createdAt: "asc" } }; + }; +}>; + +type OrderForDispute = Prisma.OrderGetPayload<{ + include: { storeProfile: true; payments: true }; +}>; + +type DisputeDomainEventType = + | "DISPUTE_OPENED" + | "DISPUTE_EVIDENCE_ADDED" + | "DISPUTE_RESOLVED_REFUND_BUYER" + | "DISPUTE_RESOLVED_RELEASE_TO_STORE" + | "DISPUTE_CLOSED"; + +@Injectable() +export class DisputeService { + constructor( + private readonly prisma: PrismaService, + private readonly domainEvents: DomainEventService, + private readonly commerceOutbox: CommerceOutboxService, + private readonly settlement: SettlementService, + ) {} + + private async appendDisputeDomainEvent( + tx: Prisma.TransactionClient, + dispute: { + id: string; + orderId: string; + buyerId: string; + storeId: string; + }, + eventType: DisputeDomainEventType, + actorType: "USER" | "STORE" | "OPERATOR", + actorId: string, + metadata: Record = {}, + idempotencyKey?: string, + ): Promise { + return this.domainEvents.append(tx, { + aggregateType: "DISPUTE", + aggregateId: dispute.id, + eventType, + actorType, + actorId, + source: "dispute.service", + metadata: { + disputeId: dispute.id, + orderId: dispute.orderId, + buyerId: dispute.buyerId, + storeId: dispute.storeId, + ...metadata, + }, + idempotencyKey: idempotencyKey ?? null, + }); + } + + /** + * Resolves the merchant's real user id from the store profile. Realtime + * rooms and notifications are always addressed to StoreProfile.userId — + * never StoreProfile.id. + */ + private async resolveMerchantUserId( + tx: Prisma.TransactionClient, + storeId: string, + ): Promise { + const store = await tx.storeProfile.findUnique({ + where: { id: storeId }, + select: { userId: true }, + }); + return store?.userId ?? null; + } + + /** Resolves active operator (SUPER_ADMIN) user ids for admin notifications. */ + private async resolveAdminUserIds( + tx: Prisma.TransactionClient, + ): Promise { + const admins = await tx.user.findMany({ + where: { + role: PrismaUserRole.SUPER_ADMIN, + isActive: true, + deletedAt: null, + }, + select: { id: true }, + }); + return admins.map((admin) => admin.id); + } + + /** + * Resolves the buyer, merchant, and admin recipients for a dispute event. + * The combined `all` list is what realtime hints fan out to. Admins resolved + * after the event are covered by REST — historical socket delivery is not + * required. + */ + private async resolveDisputeRecipients( + tx: Prisma.TransactionClient, + dispute: { buyerId: string; storeId: string }, + ): Promise<{ + buyerId: string; + merchantUserId: string | null; + adminUserIds: string[]; + all: string[]; + }> { + const merchantUserId = await this.resolveMerchantUserId( + tx, + dispute.storeId, + ); + const adminUserIds = await this.resolveAdminUserIds(tx); + + const all = [dispute.buyerId]; + if (merchantUserId) { + all.push(merchantUserId); + } + all.push(...adminUserIds); + + return { buyerId: dispute.buyerId, merchantUserId, adminUserIds, all }; + } + + private disputeRealtimeCore(dispute: { + id: string; + orderId: string; + status: DisputeStatus; + }) { + return { + id: dispute.id, + orderId: dispute.orderId, + status: dispute.status, + }; + } + + async openDispute(orderId: string, user: JwtPayload, dto: CreateDisputeDto) { + return this.openBuyerDispute(orderId, user.sub, dto); + } + + async openBuyerDispute( + orderId: string, + buyerId: string, + dto: CreateDisputeDto, + ) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + include: { storeProfile: true, payments: true }, + }); + + if (!order) { + throw new NotFoundException("Order not found"); + } + + this.assertBuyerOwnsOrder(order, buyerId); + this.assertOrderCanBeDisputed(order); + + const existingActive = await this.prisma.dispute.findFirst({ + where: { + orderId, + status: { in: [...ACTIVE_DISPUTE_STATUSES] }, + }, + }); + + if (existingActive) { + throw new ConflictException({ + message: "An active dispute already exists for this order", + code: "DISPUTE_ALREADY_OPEN", + }); + } + + const dispute = await this.runOpenDisputeTransaction( + order, + orderId, + buyerId, + dto, + ); + + return this.mapDispute(dispute); + } + + async assertBuyerCanOpenDispute( + orderId: string, + buyerId: string, + ): Promise { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + include: { storeProfile: true, payments: true }, + }); + + if (!order) { + throw new NotFoundException("Order not found"); + } + + this.assertBuyerOwnsOrder(order, buyerId); + this.assertOrderCanBeDisputed(order); + + const existingActive = await this.prisma.dispute.findFirst({ + where: { + orderId, + status: { in: [...ACTIVE_DISPUTE_STATUSES] }, + }, + select: { id: true }, + }); + if (existingActive) { + throw new ConflictException({ + message: "An active dispute already exists for this order", + code: "DISPUTE_ALREADY_OPEN", + }); + } + } + + private async runOpenDisputeTransaction( + order: OrderForDispute, + orderId: string, + buyerId: string, + dto: CreateDisputeDto, + ): Promise { + try { + return await this.prisma.$transaction(async (tx) => { + const created = await tx.dispute.create({ + data: { + orderId, + buyerId: order.buyerId, + storeId: order.storeId as string, + reason: dto.reason, + description: dto.description, + buyerRequestedOutcome: dto.buyerRequestedOutcome, + }, + }); + + const updateResult = await tx.order.updateMany({ + where: { + id: orderId, + status: order.status, + disputeStatus: { not: OrderDisputeStatus.PENDING }, + }, + data: { + status: OrderStatus.DISPUTE, + disputeStatus: OrderDisputeStatus.PENDING, + disputeReason: dto.reason, + }, + }); + + if (updateResult.count !== 1) { + throw new ConflictException({ + message: "Order dispute state changed while opening dispute", + code: "DISPUTE_STATE_CONFLICT", + }); + } + + // Phase 5 §6 — DB-enforced payout hold. Any not-yet-sent payout for this + // order is held so it can never be released while the dispute is open, + // independent of any queued BullMQ job. Completed/processing transfers + // are left for reconciliation / manual settlement review. + await tx.payout.updateMany({ + where: { + orderId, + status: { in: [PayoutStatus.PENDING, PayoutStatus.FAILED] }, + }, + data: { onHold: true, holdReason: "Dispute opened" }, + }); + + await this.createEvidenceRows( + tx, + created.id, + buyerId, + DisputeActorType.BUYER, + dto.evidence, + ); + + await tx.orderEvent.create({ + data: { + orderId, + fromStatus: order.status, + toStatus: OrderStatus.DISPUTE, + triggeredBy: buyerId, + metadata: { + action: "dispute_opened", + disputeId: created.id, + reason: dto.reason, + }, + }, + }); + + const domainEvent = await this.appendDisputeDomainEvent( + tx, + created, + "DISPUTE_OPENED", + "USER", + buyerId, + { + reason: dto.reason, + buyerRequestedOutcome: dto.buyerRequestedOutcome, + evidenceCount: dto.evidence?.length ?? 0, + }, + ); + + const recipients = await this.resolveDisputeRecipients(tx, created); + + // Merchant milestone notification — preserves "Order Dispute Raised". + if (recipients.merchantUserId) { + await this.commerceOutbox.appendDisputeNotification(tx, { + recipientUserId: recipients.merchantUserId, + notificationType: NotificationType.ORDER_DISPUTED, + title: "Order Dispute Raised", + body: `A buyer has raised a dispute for Order #${orderId.slice( + 0, + 8, + )}. Reason: ${dto.reason}`, + data: { disputeId: created.id, orderId, reason: dto.reason }, + channels: [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + disputeId: created.id, + domainEventId: domainEvent.id, + url: `/store/orders/${orderId}`, + audience: "STORE", + }); + } + + // Durable new-dispute notification for each operator (in-app only). + for (const adminUserId of recipients.adminUserIds) { + await this.commerceOutbox.appendDisputeNotification(tx, { + recipientUserId: adminUserId, + notificationType: NotificationType.ORDER_DISPUTED, + title: "New dispute filed", + body: `A dispute was filed for Order #${orderId.slice( + 0, + 8, + )}. Reason: ${dto.reason}`, + data: { disputeId: created.id, orderId, reason: dto.reason }, + channels: [NotificationChannel.IN_APP], + disputeId: created.id, + domainEventId: domainEvent.id, + url: `/admin/disputes/${created.id}`, + audience: "SHOPPER", + }); + } + + // Realtime cache hint for buyer, merchant, and admins. + await this.commerceOutbox.appendDisputeCreatedRealtime(tx, { + dispute: this.disputeRealtimeCore(created), + domainEventId: domainEvent.id, + recipientUserIds: recipients.all, + createdAt: created.createdAt, + }); + + return this.findDisputeByIdOrThrow(created.id, tx); + }); + } catch (error) { + if (this.isActiveDisputeUniqueConflict(error)) { + throw new ConflictException({ + message: "An active dispute already exists for this order", + code: "DISPUTE_ALREADY_OPEN", + }); + } + throw error; + } + } + + private isActiveDisputeUniqueConflict(error: unknown): boolean { + // The only unique constraint openDispute can violate is the partial + // "one active dispute per order" index, so any P2002 here means a + // concurrent request already opened an active dispute. + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ); + } + + async getOrderDisputes(orderId: string, user: JwtPayload) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + }); + + if (!order) { + throw new NotFoundException("Order not found"); + } + + this.assertCanAccessOrder(order.buyerId, order.storeId, user); + + return this.findOrderDisputes(orderId); + } + + async getBuyerOrderDisputes(orderId: string, buyerId: string) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + }); + + if (!order) { + throw new NotFoundException("Order not found"); + } + + if (order.buyerId !== buyerId) { + throw new ForbiddenException("You cannot access this order"); + } + + return this.findOrderDisputes(orderId); + } + + private async findOrderDisputes(orderId: string) { + const disputes = await this.prisma.dispute.findMany({ + where: { orderId }, + include: this.disputeInclude(), + orderBy: { createdAt: "desc" }, + }); + + return disputes.map((dispute) => this.mapDispute(dispute)); + } + + async listDisputes(user: JwtPayload) { + const where = this.isOperator(user) + ? {} + : user.storeId + ? { storeId: user.storeId } + : { buyerId: user.sub }; + + const disputes = await this.prisma.dispute.findMany({ + where, + include: this.disputeInclude(), + orderBy: { createdAt: "desc" }, + take: 50, + }); + + return disputes.map((dispute) => this.mapDispute(dispute)); + } + + async getDispute(id: string, user: JwtPayload) { + const dispute = await this.findDisputeByIdOrThrow(id); + this.assertCanAccessDispute(dispute, user); + return this.mapDispute(dispute); + } + + async getSettlementVisibility(id: string, user: JwtPayload) { + const dispute = await this.findDisputeByIdOrThrow(id); + this.assertCanAccessDispute(dispute, user); + return this.findSettlementVisibility(id, dispute.buyerId === user.sub); + } + + async getBuyerSettlementVisibility(id: string, buyerId: string) { + const dispute = await this.findDisputeByIdOrThrow(id); + if (dispute.buyerId !== buyerId) { + throw new ForbiddenException("You cannot access this dispute"); + } + return this.findSettlementVisibility(id, true); + } + + private async findSettlementVisibility(id: string, isBuyer: boolean) { + const settlement = await this.prisma.disputeSettlement.findUnique({ + where: { disputeId: id }, + select: { + status: true, + outcome: true, + buyerRefundAmountKobo: true, + merchantPayoutAmountKobo: true, + legs: { + select: { + type: true, + status: true, + submittedAt: true, + completedAt: true, + }, + }, + }, + }); + if (!settlement) return null; + const leg = settlement.legs.find((item) => + isBuyer ? item.type === "BUYER_REFUND" : item.type === "MERCHANT_PAYOUT", + ); + // Never expose the counterparty amount, provider references, or internal + // reconciliation detail to buyer/store views. + return { + outcome: settlement.outcome, + settlementStatus: settlement.status, + amountKobo: isBuyer + ? settlement.buyerRefundAmountKobo.toString() + : settlement.merchantPayoutAmountKobo.toString(), + status: leg?.status ?? "PENDING", + submittedAt: leg?.submittedAt ?? null, + completedAt: leg?.completedAt ?? null, + }; + } + + async respondToDispute(id: string, user: JwtPayload, dto: RespondDisputeDto) { + const dispute = await this.findDisputeByIdOrThrow(id); + this.assertStoreOwnsDispute(dispute, user); + + const updated = await this.prisma.$transaction(async (tx) => { + const updateResult = await tx.dispute.updateMany({ + where: { id, status: DisputeStatus.OPEN }, + data: { + status: DisputeStatus.STORE_RESPONDED, + storeResponse: dto.response, + }, + }); + + if (updateResult.count !== 1) { + throw new ConflictException({ + message: "Dispute can only be responded to while open", + code: "DISPUTE_STATE_CONFLICT", + }); + } + + await this.createEvidenceRows( + tx, + id, + user.sub, + DisputeActorType.STORE, + dto.evidence, + ); + + await tx.orderEvent.create({ + data: { + orderId: dispute.orderId, + fromStatus: OrderStatus.DISPUTE, + toStatus: OrderStatus.DISPUTE, + triggeredBy: user.sub, + metadata: { action: "dispute_store_responded", disputeId: id }, + }, + }); + + const domainEvent = await this.appendDisputeDomainEvent( + tx, + dispute, + "DISPUTE_EVIDENCE_ADDED", + "STORE", + user.sub, + { + action: "dispute_store_responded", + evidenceCount: dto.evidence?.length ?? 0, + }, + ); + + const fresh = await this.findDisputeByIdOrThrow(id, tx); + await this.appendDisputeUpdated( + tx, + fresh, + DISPUTE_UPDATED_ACTION.STORE_RESPONDED, + domainEvent, + fresh.updatedAt, + ); + + return fresh; + }); + + return this.mapDispute(updated); + } + + /** + * Shared dispute:updated fan-out: resolves recipients and appends one + * realtime outbox row per party. Used by store-response, evidence, and close. + */ + private async appendDisputeUpdated( + tx: Prisma.TransactionClient, + dispute: DisputeWithRelations, + action: DisputeUpdatedAction, + domainEvent: DomainEvent, + updatedAt: Date, + ): Promise { + const recipients = await this.resolveDisputeRecipients(tx, dispute); + await this.commerceOutbox.appendDisputeUpdatedRealtime(tx, { + dispute: this.disputeRealtimeCore(dispute), + action, + domainEventId: domainEvent.id, + recipientUserIds: recipients.all, + updatedAt, + }); + } + + async addEvidence(id: string, user: JwtPayload, dto: DisputeEvidenceDto) { + const dispute = await this.findDisputeByIdOrThrow(id); + this.assertCanAccessDispute(dispute, user); + + if (!ACTIVE_DISPUTE_STATUSES.includes(dispute.status)) { + throw new ConflictException({ + message: "Evidence can only be added to an active dispute", + code: "DISPUTE_NOT_ACTIVE", + }); + } + + const actorType = this.resolveActorType(dispute, user); + + const updated = await this.prisma.$transaction(async (tx) => { + await this.createEvidenceRows(tx, id, user.sub, actorType, [dto]); + + await tx.orderEvent.create({ + data: { + orderId: dispute.orderId, + fromStatus: OrderStatus.DISPUTE, + toStatus: OrderStatus.DISPUTE, + triggeredBy: user.sub, + metadata: { action: "dispute_evidence_added", disputeId: id }, + }, + }); + + const domainEvent = await this.appendDisputeDomainEvent( + tx, + dispute, + "DISPUTE_EVIDENCE_ADDED", + actorType === DisputeActorType.STORE ? "STORE" : "USER", + user.sub, + { + actorType, + evidenceCount: 1, + }, + ); + + const fresh = await this.findDisputeByIdOrThrow(id, tx); + // Realtime-only: evidence contents/URLs never enter the payload. + await this.appendDisputeUpdated( + tx, + fresh, + DISPUTE_UPDATED_ACTION.EVIDENCE_ADDED, + domainEvent, + fresh.updatedAt, + ); + + return fresh; + }); + + return this.mapDispute(updated); + } + + async resolveDispute(id: string, user: JwtPayload, dto: ResolveDisputeDto) { + if (!this.isOperator(user)) { + throw new ForbiddenException("Operator access required"); + } + + const dispute = await this.findDisputeByIdOrThrow(id); + const resolutionStatus = this.getResolutionStatus(dto.outcome); + const orderDisputeStatus = + dto.outcome === DisputeResolutionOutcome.SELLER_WINS + ? OrderDisputeStatus.RESOLVED_STORE + : OrderDisputeStatus.RESOLVED_SHOPPER; + + const updated = await this.prisma.$transaction(async (tx) => { + const disputeUpdate = await tx.dispute.updateMany({ + where: { + id, + status: { in: [...ACTIVE_DISPUTE_STATUSES] }, + }, + data: { + status: resolutionStatus, + resolutionOutcome: dto.outcome, + resolutionNotes: dto.resolutionNotes, + resolvedById: user.sub, + resolvedAt: new Date(), + }, + }); + + if (disputeUpdate.count !== 1) { + throw new ConflictException({ + message: "Dispute state changed before resolution", + code: "DISPUTE_STATE_CONFLICT", + }); + } + + const orderUpdate = await tx.order.updateMany({ + where: { + id: dispute.orderId, + status: OrderStatus.DISPUTE, + disputeStatus: OrderDisputeStatus.PENDING, + }, + data: { disputeStatus: orderDisputeStatus }, + }); + + if (orderUpdate.count !== 1) { + throw new ConflictException({ + message: "Order dispute state changed before resolution", + code: "DISPUTE_STATE_CONFLICT", + }); + } + + // Phase 5 — convert the decision into a durable financial settlement plan + // in the SAME transaction. buildPlan runs read-only preflight + amount + // rules and throws on an invalid/unexecutable decision, rolling back the + // whole resolution (no partial resolve, no orphan settlement). The + // deterministic settlement key means a repeated resolution never creates + // a second settlement. + const orderRecord = await tx.order.findUnique({ + where: { id: dispute.orderId }, + }); + if (!orderRecord) { + throw new NotFoundException("Order not found"); + } + const settlementPlan = await this.settlement.buildPlan( + orderRecord, + dto.outcome, + { + buyerRefundAmountKobo: dto.buyerRefundAmountKobo, + merchantPayoutAmountKobo: dto.merchantPayoutAmountKobo, + }, + tx, + ); + const settlementRecord = await this.settlement.createSettlementInTx(tx, { + disputeId: dispute.id, + orderId: dispute.orderId, + outcome: dto.outcome, + plan: settlementPlan, + createdBy: user.sub, + }); + + await tx.orderEvent.create({ + data: { + orderId: dispute.orderId, + fromStatus: OrderStatus.DISPUTE, + toStatus: OrderStatus.DISPUTE, + triggeredBy: user.sub, + metadata: { + action: "dispute_resolved", + disputeId: id, + outcome: dto.outcome, + settlementId: settlementRecord.id, + }, + }, + }); + + await tx.auditLog.create({ + data: { + userId: user.sub, + action: "DISPUTE_RESOLVED", + targetType: "Dispute", + targetId: id, + metadata: { + outcome: dto.outcome, + orderId: dispute.orderId, + settlementId: settlementRecord.id, + settlementStatus: settlementRecord.status, + }, + }, + }); + + const domainEvent = await this.appendDisputeDomainEvent( + tx, + dispute, + dto.outcome === DisputeResolutionOutcome.SELLER_WINS + ? "DISPUTE_RESOLVED_RELEASE_TO_STORE" + : "DISPUTE_RESOLVED_REFUND_BUYER", + "OPERATOR", + user.sub, + { + outcome: dto.outcome, + resolutionStatus, + orderDisputeStatus, + settlementId: settlementRecord.id, + settlementStatus: settlementRecord.status, + }, + ); + + const fresh = await this.findDisputeByIdOrThrow(id, tx); + const recipients = await this.resolveDisputeRecipients(tx, fresh); + const resolvedAt = fresh.resolvedAt ?? new Date(); + const copy = this.resolutionNotificationCopy(dto.outcome, fresh.orderId); + + // Buyer resolution notification — decision recorded; money not yet moved. + await this.commerceOutbox.appendDisputeNotification(tx, { + recipientUserId: fresh.buyerId, + notificationType: NotificationType.DISPUTE_RESOLVED, + title: "Dispute Resolved", + body: copy.buyer, + data: { + disputeId: fresh.id, + orderId: fresh.orderId, + outcome: dto.outcome, + }, + channels: [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + disputeId: fresh.id, + domainEventId: domainEvent.id, + url: `/orders/${fresh.orderId}`, + audience: "SHOPPER", + }); + + // Merchant resolution notification. + if (recipients.merchantUserId) { + await this.commerceOutbox.appendDisputeNotification(tx, { + recipientUserId: recipients.merchantUserId, + notificationType: NotificationType.DISPUTE_RESOLVED, + title: "Dispute Resolved", + body: copy.merchant, + data: { + disputeId: fresh.id, + orderId: fresh.orderId, + outcome: dto.outcome, + }, + channels: [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + disputeId: fresh.id, + domainEventId: domainEvent.id, + url: `/store/orders/${fresh.orderId}`, + audience: "STORE", + }); + } + + // Realtime cache hint for buyer, merchant, and admins. + await this.commerceOutbox.appendDisputeResolvedRealtime(tx, { + dispute: this.disputeRealtimeCore(fresh), + outcome: dto.outcome, + domainEventId: domainEvent.id, + recipientUserIds: recipients.all, + resolvedAt, + }); + + return fresh; + }); + + return this.mapDispute(updated); + } + + /** + * Resolution notification copy. Wording must record the decision only — + * it must never claim money has already moved (financialExecution stays + * DEFERRED in Phase 4). + */ + private resolutionNotificationCopy( + outcome: DisputeResolutionOutcome, + orderId: string, + ): { buyer: string; merchant: string } { + const ref = `#${orderId.slice(0, 8)}`; + if (outcome === DisputeResolutionOutcome.BUYER_WINS) { + return { + buyer: `Your dispute for Order ${ref} has been resolved in your favour. Your refund has been approved and is pending processing.`, + merchant: `The dispute for Order ${ref} has been resolved in favour of the shopper.`, + }; + } + if (outcome === DisputeResolutionOutcome.SELLER_WINS) { + return { + buyer: `Your dispute for Order ${ref} has been resolved in favour of the store.`, + merchant: `The dispute for Order ${ref} has been resolved in your favour. Your payout has been approved and is pending processing.`, + }; + } + return { + buyer: `Your dispute for Order ${ref} has been resolved with a partial outcome. Any approved refund is pending processing.`, + merchant: `The dispute for Order ${ref} has been resolved with a partial outcome. Any approved payout is pending processing.`, + }; + } + + async closeDispute(id: string, user: JwtPayload, dto: CloseDisputeDto) { + const dispute = await this.findDisputeByIdOrThrow(id); + + if (!this.isOperator(user) && dispute.buyerId !== user.sub) { + throw new ForbiddenException("Access denied"); + } + + if (!ACTIVE_DISPUTE_STATUSES.includes(dispute.status)) { + throw new ConflictException({ + message: "Only active disputes can be closed", + code: "DISPUTE_NOT_ACTIVE", + }); + } + + const updated = await this.prisma.$transaction(async (tx) => { + const disputeUpdate = await tx.dispute.updateMany({ + where: { + id, + status: { in: [...ACTIVE_DISPUTE_STATUSES] }, + }, + data: { + status: DisputeStatus.CLOSED, + closedAt: new Date(), + resolutionNotes: dto.reason, + }, + }); + + if (disputeUpdate.count !== 1) { + throw new ConflictException({ + message: "Dispute state changed before close", + code: "DISPUTE_STATE_CONFLICT", + }); + } + + const orderUpdate = await tx.order.updateMany({ + where: { + id: dispute.orderId, + status: OrderStatus.DISPUTE, + disputeStatus: OrderDisputeStatus.PENDING, + }, + data: { disputeStatus: OrderDisputeStatus.RESOLVED }, + }); + + if (orderUpdate.count !== 1) { + throw new ConflictException({ + message: "Order dispute state changed before close", + code: "DISPUTE_STATE_CONFLICT", + }); + } + + await tx.orderEvent.create({ + data: { + orderId: dispute.orderId, + fromStatus: OrderStatus.DISPUTE, + toStatus: OrderStatus.DISPUTE, + triggeredBy: user.sub, + metadata: { + action: "dispute_closed", + disputeId: id, + reason: dto.reason, + }, + }, + }); + + const domainEvent = await this.appendDisputeDomainEvent( + tx, + dispute, + "DISPUTE_CLOSED", + this.isOperator(user) ? "OPERATOR" : "USER", + user.sub, + { reason: dto.reason }, + ); + + const fresh = await this.findDisputeByIdOrThrow(id, tx); + await this.appendDisputeUpdated( + tx, + fresh, + DISPUTE_UPDATED_ACTION.CLOSED, + domainEvent, + fresh.updatedAt, + ); + + return fresh; + }); + + return this.mapDispute(updated); + } + + private disputeInclude() { + return { + order: true, + buyer: true, + resolver: true, + store: true, + evidence: { + include: { actor: true }, + orderBy: { createdAt: "asc" as const }, + }, + }; + } + + private async findDisputeByIdOrThrow( + id: string, + client: Prisma.TransactionClient | PrismaService = this.prisma, + ): Promise { + const dispute = await client.dispute.findUnique({ + where: { id }, + include: this.disputeInclude(), + }); + + if (!dispute) { + throw new NotFoundException("Dispute not found"); + } + + return dispute; + } + + private assertBuyerOwnsOrder(order: OrderForDispute, userId: string) { + if (order.buyerId !== userId) { + throw new ForbiddenException("Only the buyer can dispute this order"); + } + } + + private assertOrderCanBeDisputed(order: OrderForDispute) { + if (!order.storeId) { + throw new BadRequestException({ + message: "Order cannot be disputed without a store", + code: "ORDER_NOT_DISPUTABLE", + }); + } + + this.assertDirectOrderOnly(order); + + if (!OPEN_ELIGIBLE_ORDER_STATUSES.includes(order.status)) { + throw new BadRequestException({ + message: "Order is not eligible for dispute", + code: "ORDER_NOT_DISPUTABLE", + }); + } + + if ( + order.status === OrderStatus.COMPLETED && + (!order.disputeWindowEndsAt || order.disputeWindowEndsAt < new Date()) + ) { + throw new ConflictException({ + message: "Dispute window is closed", + code: "DISPUTE_WINDOW_CLOSED", + }); + } + } + + private assertCanAccessOrder( + buyerId: string, + storeId: string | null, + user: JwtPayload, + ) { + if ( + this.isOperator(user) || + buyerId === user.sub || + storeId === user.storeId + ) { + return; + } + + throw new ForbiddenException("Access denied"); + } + + private assertDirectOrderOnly(order: OrderForDispute) { + const record = order as unknown as Record; + const discriminator = record.orderType ?? record.type; + + if (typeof discriminator !== "string") { + return; + } + + if (discriminator !== "DIRECT") { + throw new BadRequestException({ + message: "Only direct orders can be disputed in this workflow", + code: "DIRECT_ORDER_DISPUTES_ONLY", + }); + } + } + + private assertCanAccessDispute( + dispute: DisputeWithRelations, + user: JwtPayload, + ) { + this.assertCanAccessOrder(dispute.buyerId, dispute.storeId, user); + } + + private assertStoreOwnsDispute( + dispute: DisputeWithRelations, + user: JwtPayload, + ) { + if (!user.storeId || user.storeId !== dispute.storeId) { + throw new ForbiddenException("Only the store owner can respond"); + } + } + + private resolveActorType( + dispute: DisputeWithRelations, + user: JwtPayload, + ): DisputeActorType { + if (this.isOperator(user)) { + return DisputeActorType.OPERATOR; + } + if (user.storeId === dispute.storeId) { + return DisputeActorType.STORE; + } + return DisputeActorType.BUYER; + } + + private isOperator(user: JwtPayload) { + return user.role === UserRole.SUPER_ADMIN; + } + + private getResolutionStatus(outcome: DisputeResolutionOutcome) { + if (outcome === DisputeResolutionOutcome.SELLER_WINS) { + return DisputeStatus.RESOLVED_SELLER; + } + + if (outcome === DisputeResolutionOutcome.PARTIAL) { + return DisputeStatus.RESOLVED_PARTIAL; + } + + return DisputeStatus.RESOLVED_BUYER; + } + + private async createEvidenceRows( + client: Prisma.TransactionClient, + disputeId: string, + actorId: string, + actorType: DisputeActorType, + evidence?: DisputeEvidenceDto[], + ) { + if (!evidence?.length) { + return; + } + + await client.disputeEvidence.createMany({ + data: evidence.map((item) => ({ + disputeId, + actorId, + actorType, + url: item.url, + publicId: item.publicId, + note: item.note, + })), + }); + } + + private mapDispute(dispute: DisputeWithRelations) { + return { + id: dispute.id, + status: dispute.status, + reason: dispute.reason, + description: dispute.description, + buyerRequestedOutcome: dispute.buyerRequestedOutcome, + storeResponse: dispute.storeResponse, + resolutionOutcome: dispute.resolutionOutcome, + resolutionNotes: dispute.resolutionNotes, + resolvedAt: dispute.resolvedAt, + closedAt: dispute.closedAt, + createdAt: dispute.createdAt, + updatedAt: dispute.updatedAt, + order: { + id: dispute.order.id, + orderCode: dispute.order.orderCode, + status: dispute.order.status, + disputeStatus: dispute.order.disputeStatus, + }, + buyer: { + id: dispute.buyer.id, + username: dispute.buyer.username, + displayName: dispute.buyer.displayName, + }, + store: { + id: dispute.store.id, + handle: dispute.store.storeHandle, + name: dispute.store.storeName ?? dispute.store.businessName, + logoUrl: dispute.store.logoUrl ?? dispute.store.profileImage, + }, + resolver: dispute.resolver + ? { + id: dispute.resolver.id, + username: dispute.resolver.username, + displayName: dispute.resolver.displayName, + } + : null, + evidence: dispute.evidence.map((item) => ({ + id: item.id, + actorType: item.actorType, + url: item.url, + publicId: item.publicId, + note: item.note, + createdAt: item.createdAt, + actor: { + id: item.actor.id, + username: item.actor.username, + displayName: item.actor.displayName, + }, + })), + }; + } +} diff --git a/apps/backend/src/domains/orders/dispute/dto/close-dispute.dto.ts b/apps/backend/src/domains/orders/dispute/dto/close-dispute.dto.ts new file mode 100644 index 00000000..7ffc0dcc --- /dev/null +++ b/apps/backend/src/domains/orders/dispute/dto/close-dispute.dto.ts @@ -0,0 +1,12 @@ +import { Transform } from "class-transformer"; +import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator"; +import { trimString } from "../../../../common/utils/string.util"; + +export class CloseDisputeDto { + @IsOptional() + @Transform(({ value }) => trimString(value)) + @IsString() + @IsNotEmpty() + @MaxLength(1000) + reason?: string; +} diff --git a/apps/backend/src/domains/orders/dispute/dto/create-dispute.dto.ts b/apps/backend/src/domains/orders/dispute/dto/create-dispute.dto.ts new file mode 100644 index 00000000..5e132a49 --- /dev/null +++ b/apps/backend/src/domains/orders/dispute/dto/create-dispute.dto.ts @@ -0,0 +1,39 @@ +import { Type, Transform } from "class-transformer"; +import { + ArrayMaxSize, + IsArray, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from "class-validator"; +import { trimString } from "../../../../common/utils/string.util"; +import { DisputeEvidenceDto } from "./dispute-evidence.dto"; + +export class CreateDisputeDto { + @Transform(({ value }) => trimString(value)) + @IsString() + @IsNotEmpty() + @MaxLength(120) + reason!: string; + + @Transform(({ value }) => trimString(value)) + @IsString() + @IsNotEmpty() + @MaxLength(2000) + description!: string; + + @IsOptional() + @Transform(({ value }) => trimString(value)) + @IsString() + @MaxLength(500) + buyerRequestedOutcome?: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(10) + @ValidateNested({ each: true }) + @Type(() => DisputeEvidenceDto) + evidence?: DisputeEvidenceDto[]; +} diff --git a/apps/backend/src/domains/orders/dispute/dto/dispute-evidence.dto.ts b/apps/backend/src/domains/orders/dispute/dto/dispute-evidence.dto.ts new file mode 100644 index 00000000..d44496a1 --- /dev/null +++ b/apps/backend/src/domains/orders/dispute/dto/dispute-evidence.dto.ts @@ -0,0 +1,23 @@ +import { Transform } from "class-transformer"; +import { IsOptional, IsString, IsUrl, MaxLength } from "class-validator"; +import { trimString } from "../../../../common/utils/string.util"; + +export class DisputeEvidenceDto { + @Transform(({ value }) => trimString(value)) + @IsString() + @IsUrl({ require_protocol: true }) + @MaxLength(2048) + url!: string; + + @IsOptional() + @Transform(({ value }) => trimString(value)) + @IsString() + @MaxLength(255) + publicId?: string; + + @IsOptional() + @Transform(({ value }) => trimString(value)) + @IsString() + @MaxLength(500) + note?: string; +} diff --git a/apps/backend/src/domains/orders/dispute/dto/resolve-dispute.dto.ts b/apps/backend/src/domains/orders/dispute/dto/resolve-dispute.dto.ts new file mode 100644 index 00000000..1163fc82 --- /dev/null +++ b/apps/backend/src/domains/orders/dispute/dto/resolve-dispute.dto.ts @@ -0,0 +1,48 @@ +import { DisputeResolutionOutcome } from "@prisma/client"; +import { Transform } from "class-transformer"; +import { + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, +} from "class-validator"; +import { trimString } from "../../../../common/utils/string.util"; + +// Money is always kobo as an integer string. A decimal naira value ("245.50") +// or a float fails this pattern, so the API can never accept a rounded/unsafe +// amount. Parsed to BigInt in the service, never in the client. +const KOBO_STRING = /^\d{1,19}$/; + +export class ResolveDisputeDto { + @IsEnum(DisputeResolutionOutcome) + outcome!: DisputeResolutionOutcome; + + @IsOptional() + @Transform(({ value }) => trimString(value)) + @IsString() + @IsNotEmpty() + @MaxLength(2000) + resolutionNotes?: string; + + // Explicit settlement amounts. Required (and both must be positive) for a + // PARTIAL outcome; ignored for full BUYER_WINS / SELLER_WINS where the + // amount is derived. Kept as strings; the settlement service parses and + // validates them against the captured, unsettled funds. + @IsOptional() + @Transform(({ value }) => trimString(value)) + @IsString() + @Matches(KOBO_STRING, { + message: "buyerRefundAmountKobo must be kobo (integer string)", + }) + buyerRefundAmountKobo?: string; + + @IsOptional() + @Transform(({ value }) => trimString(value)) + @IsString() + @Matches(KOBO_STRING, { + message: "merchantPayoutAmountKobo must be kobo (integer string)", + }) + merchantPayoutAmountKobo?: string; +} diff --git a/apps/backend/src/domains/orders/dispute/dto/respond-dispute.dto.ts b/apps/backend/src/domains/orders/dispute/dto/respond-dispute.dto.ts new file mode 100644 index 00000000..79cd7975 --- /dev/null +++ b/apps/backend/src/domains/orders/dispute/dto/respond-dispute.dto.ts @@ -0,0 +1,27 @@ +import { Type, Transform } from "class-transformer"; +import { + ArrayMaxSize, + IsArray, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from "class-validator"; +import { trimString } from "../../../../common/utils/string.util"; +import { DisputeEvidenceDto } from "./dispute-evidence.dto"; + +export class RespondDisputeDto { + @Transform(({ value }) => trimString(value)) + @IsString() + @IsNotEmpty() + @MaxLength(2000) + response!: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(10) + @ValidateNested({ each: true }) + @Type(() => DisputeEvidenceDto) + evidence?: DisputeEvidenceDto[]; +} diff --git a/apps/backend/src/domains/orders/escrow/README.md b/apps/backend/src/domains/orders/escrow/README.md new file mode 100644 index 00000000..83dd6938 --- /dev/null +++ b/apps/backend/src/domains/orders/escrow/README.md @@ -0,0 +1,5 @@ +# Orders Escrow Boundary + +Order lifecycle hooks for escrow hold, delivery confirmation, and release coordination. +Money movement stays in the money domain. + diff --git a/apps/backend/src/domains/orders/order/delivery-fee.ts b/apps/backend/src/domains/orders/order/delivery-fee.ts new file mode 100644 index 00000000..fa817c2b --- /dev/null +++ b/apps/backend/src/domains/orders/order/delivery-fee.ts @@ -0,0 +1,76 @@ +export const DELIVERY_FEE_SOURCE = "MVP_SELECTED_ZONE"; + +export interface DeliveryZoneAddress { + formattedAddress?: string | null; + street?: string | null; + city?: string | null; + state?: string | null; +} + +export interface DeliveryFeeQuote { + feeKobo: bigint; + source: typeof DELIVERY_FEE_SOURCE; + rule: string; + pickupZone: string; + deliveryZone: string; +} + +const ZONE_FEES_KOBO: Record> = { + BALOGUN: { + BALOGUN: 150_000n, + YABA: 250_000n, + LAGOS_SELECTED: 300_000n, + }, + YABA: { + BALOGUN: 250_000n, + YABA: 150_000n, + LAGOS_SELECTED: 300_000n, + }, + LAGOS_SELECTED: { + BALOGUN: 300_000n, + YABA: 300_000n, + LAGOS_SELECTED: 300_000n, + }, +}; + +export function resolveMvpDeliveryZone( + address: DeliveryZoneAddress, +): string | null { + const haystack = [ + address.formattedAddress, + address.street, + address.city, + address.state, + ] + .filter((value): value is string => typeof value === "string") + .join(" ") + .toLowerCase(); + + if (!haystack.trim()) return null; + if (/\b(balogun|idumota|marina|lagos island)\b/.test(haystack)) { + return "BALOGUN"; + } + if (/\b(yaba|akoka|sabo)\b/.test(haystack)) { + return "YABA"; + } + if (/\blagos\b/.test(haystack)) { + return "LAGOS_SELECTED"; + } + return null; +} + +export function calculateMvpDeliveryFee(input: { + pickupZone: string; + deliveryZone: string; +}): DeliveryFeeQuote | null { + const feeKobo = ZONE_FEES_KOBO[input.pickupZone]?.[input.deliveryZone]; + if (feeKobo === undefined) return null; + + return { + feeKobo, + source: DELIVERY_FEE_SOURCE, + rule: `${input.pickupZone}_TO_${input.deliveryZone}`, + pickupZone: input.pickupZone, + deliveryZone: input.deliveryZone, + }; +} diff --git a/apps/backend/src/domains/orders/order/dto/checkout-cart.dto.ts b/apps/backend/src/domains/orders/order/dto/checkout-cart.dto.ts new file mode 100644 index 00000000..5fabadeb --- /dev/null +++ b/apps/backend/src/domains/orders/order/dto/checkout-cart.dto.ts @@ -0,0 +1,39 @@ +import { + ArrayNotEmpty, + IsArray, + IsIn, + IsNotEmpty, + IsOptional, + IsString, + IsObject, +} from "class-validator"; + +export class CheckoutCartDto { + // Cart item ids are cuids, not UUIDs — validate as non-empty strings. + @IsArray() + @IsString({ each: true }) + @ArrayNotEmpty() + cartItemIds!: string[]; + + @IsString() + @IsNotEmpty() + deliveryAddress!: string; + + @IsOptional() + @IsObject() + deliveryDetails?: Record; + + @IsOptional() + @IsString() + secondaryDeliveryPhone?: string; + + // Deprecated compatibility field. Shopper input is ignored; delivery is + // server-selected and handled by twizrr. + @IsIn(["STORE_DELIVERY", "PLATFORM_LOGISTICS"]) + @IsOptional() + deliveryMethod?: "STORE_DELIVERY" | "PLATFORM_LOGISTICS"; + + @IsOptional() + @IsString() + idempotencyKey?: string; +} diff --git a/apps/backend/src/modules/order/dto/confirm-delivery.dto.ts b/apps/backend/src/domains/orders/order/dto/confirm-delivery.dto.ts similarity index 88% rename from apps/backend/src/modules/order/dto/confirm-delivery.dto.ts rename to apps/backend/src/domains/orders/order/dto/confirm-delivery.dto.ts index 25e29e7d..c94995e6 100644 --- a/apps/backend/src/modules/order/dto/confirm-delivery.dto.ts +++ b/apps/backend/src/domains/orders/order/dto/confirm-delivery.dto.ts @@ -3,5 +3,5 @@ import { IsString, Length } from "class-validator"; export class ConfirmDeliveryDto { @IsString() @Length(6, 6) - otp: string; + otp!: string; } diff --git a/apps/backend/src/domains/orders/order/dto/create-direct-order.dto.ts b/apps/backend/src/domains/orders/order/dto/create-direct-order.dto.ts new file mode 100644 index 00000000..46158eb7 --- /dev/null +++ b/apps/backend/src/domains/orders/order/dto/create-direct-order.dto.ts @@ -0,0 +1,51 @@ +import { + IsNotEmpty, + IsInt, + IsString, + Min, + IsOptional, + IsEnum, + IsObject, +} from "class-validator"; +import { DeliveryMethod } from "@prisma/client"; + +export class CreateDirectOrderDto { + // Product ids are cuids, not UUIDs — validate as a plain string. + @IsString() + @IsNotEmpty() + productId!: string; + + // Variant ids are cuids, not UUIDs — validate as a plain string. + @IsOptional() + @IsString() + variantId?: string; + + @IsInt() + @Min(1) + @IsNotEmpty() + quantity!: number; + + @IsString() + @IsNotEmpty() + deliveryAddress!: string; + + @IsOptional() + @IsObject() + deliveryDetails?: Record; + + @IsOptional() + @IsString() + secondaryDeliveryPhone?: string; + + // Deprecated compatibility field. Shopper input is ignored; delivery is + // server-selected and handled by twizrr. + @IsOptional() + @IsEnum(DeliveryMethod, { + message: "deliveryMethod must be STORE_DELIVERY or PLATFORM_LOGISTICS", + }) + deliveryMethod?: DeliveryMethod; + + @IsOptional() + @IsString() + idempotencyKey?: string; +} diff --git a/apps/backend/src/domains/orders/order/dto/create-sourced-order.dto.ts b/apps/backend/src/domains/orders/order/dto/create-sourced-order.dto.ts new file mode 100644 index 00000000..06b8b76f --- /dev/null +++ b/apps/backend/src/domains/orders/order/dto/create-sourced-order.dto.ts @@ -0,0 +1,41 @@ +import { + IsNotEmpty, + IsInt, + IsString, + Min, + IsOptional, + IsEnum, + IsObject, +} from "class-validator"; +import { DeliveryMethod } from "@prisma/client"; + +export class CreateSourcedOrderDto { + @IsInt() + @Min(1) + @IsNotEmpty() + quantity!: number; + + @IsString() + @IsNotEmpty() + deliveryAddress!: string; + + @IsOptional() + @IsObject() + deliveryDetails?: Record; + + @IsOptional() + @IsString() + secondaryDeliveryPhone?: string; + + // Deprecated compatibility field. Shopper input is ignored; delivery is + // server-selected and handled by twizrr. + @IsOptional() + @IsEnum(DeliveryMethod, { + message: "deliveryMethod must be STORE_DELIVERY or PLATFORM_LOGISTICS", + }) + deliveryMethod?: DeliveryMethod; + + @IsOptional() + @IsString() + idempotencyKey?: string; +} diff --git a/apps/backend/src/modules/order/dto/create-tracking.dto.ts b/apps/backend/src/domains/orders/order/dto/create-tracking.dto.ts similarity index 83% rename from apps/backend/src/modules/order/dto/create-tracking.dto.ts rename to apps/backend/src/domains/orders/order/dto/create-tracking.dto.ts index cb06997c..d4ac09c4 100644 --- a/apps/backend/src/modules/order/dto/create-tracking.dto.ts +++ b/apps/backend/src/domains/orders/order/dto/create-tracking.dto.ts @@ -1,5 +1,5 @@ import { IsIn, IsString, IsOptional } from "class-validator"; -import { OrderStatus } from "@swifta/shared"; +import { OrderStatus } from "@twizrr/shared"; export class CreateTrackingDto { @IsIn( @@ -9,7 +9,7 @@ export class CreateTrackingDto { "status must be a valid tracking status: PREPARING, DISPATCHED, or IN_TRANSIT", }, ) - status: OrderStatus; + status!: OrderStatus; @IsOptional() @IsString() diff --git a/apps/backend/src/domains/orders/order/invoice.service.spec.ts b/apps/backend/src/domains/orders/order/invoice.service.spec.ts new file mode 100644 index 00000000..98e043ae --- /dev/null +++ b/apps/backend/src/domains/orders/order/invoice.service.spec.ts @@ -0,0 +1,50 @@ +import { InvoiceService } from "./invoice.service"; + +type InvoiceItemResolver = { + getInvoiceItems(order: Record): Array<{ + name: string; + priceType?: string | null; + unitPriceKobo: string | number | bigint; + quantity: number; + }>; +}; + +describe("InvoiceService item rendering", () => { + const makeService = () => + new InvoiceService({} as never) as unknown as InvoiceItemResolver; + + it("uses item snapshots when an order has them", () => { + const service = makeService(); + const items = [ + { + name: "Cart item", + priceType: "RETAIL", + unitPriceKobo: "120000", + quantity: 2, + }, + ]; + + expect(service.getInvoiceItems({ items })).toEqual(items); + }); + + it("falls back to the purchased product for direct-order invoices", () => { + const service = makeService(); + + expect( + service.getInvoiceItems({ + items: null, + productId: "product-1", + product: { name: "Direct product" }, + unitPriceKobo: 150000n, + quantity: 1, + }), + ).toEqual([ + { + name: "Direct product", + priceType: "Standard", + unitPriceKobo: 150000n, + quantity: 1, + }, + ]); + }); +}); diff --git a/apps/backend/src/domains/orders/order/invoice.service.ts b/apps/backend/src/domains/orders/order/invoice.service.ts new file mode 100644 index 00000000..fb57924b --- /dev/null +++ b/apps/backend/src/domains/orders/order/invoice.service.ts @@ -0,0 +1,295 @@ +import { + Injectable, + NotFoundException, + ForbiddenException, +} from "@nestjs/common"; +import { PrismaService } from "../../../prisma/prisma.service"; +import PDFDocument from "pdfkit"; +import { Response } from "express"; +import { JwtPayload } from "@twizrr/shared"; + +@Injectable() +export class InvoiceService { + constructor(private prisma: PrismaService) {} + + async generateInvoice(orderId: string, res: Response, user: JwtPayload) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + include: { + user: { + select: { + firstName: true, + lastName: true, + email: true, + phone: true, + }, + }, + storeProfile: { + select: { + businessName: true, + businessAddress: true, + }, + }, + product: { + select: { + name: true, + }, + }, + }, + }); + + if (!order) throw new NotFoundException("Order not found"); + + if (order.buyerId !== user.sub && order.storeId !== user?.storeId) { + throw new ForbiddenException( + "You do not have permission to view this invoice", + ); + } + + const doc = new PDFDocument({ margin: 50, size: "A4" }); + + // Set headers for download + res.setHeader("Content-Type", "application/pdf"); + res.setHeader( + "Content-Disposition", + `attachment; filename=Invoice-${order.id.slice(0, 8)}.pdf`, + ); + + doc.pipe(res); + + // --- Header --- + this.generateHeader(doc); + this.generateCustomerInformation(doc, order, { + includeBuyerContact: order.buyerId === user.sub, + }); + this.generateInvoiceTable(doc, order); + this.generateFooter(doc); + + doc.end(); + } + + private generateHeader(doc: PDFKit.PDFDocument) { + doc + .fillColor("#444444") + .fontSize(20) + .text("twizrr", 50, 45, { align: "left" }) + .fontSize(10) + .text("Social commerce with protected payment", 50, 70, { + align: "left", + }) + .fillColor("#000000") + .fontSize(10) + .text("Invoice", 200, 50, { align: "right" }) + .text(`Date: ${new Date().toLocaleDateString()}`, 200, 65, { + align: "right", + }) + .moveDown(); + } + + private generateCustomerInformation( + doc: PDFKit.PDFDocument, + order: any, + options: { includeBuyerContact: boolean }, + ) { + doc + .fillColor("#444444") + .fontSize(12) + .text("Bill To:", 50, 140) + .fontSize(10) + .fillColor("#000000") + .text( + options.includeBuyerContact + ? `${order.user.firstName} ${order.user.lastName}` + : "Customer", + 50, + 155, + ); + + if (options.includeBuyerContact) { + doc.text(order.user.email, 50, 170).text(order.user.phone, 50, 185); + } else { + doc.text("Contact details managed by twizrr", 50, 170); + } + + doc.moveDown(); + + doc + .fontSize(12) + .fillColor("#444444") + .text("Store:", 300, 140) + .fontSize(10) + .fillColor("#000000") + .text(order.storeProfile?.businessName || "N/A", 300, 155) + .text(order.storeProfile?.businessAddress || "N/A", 300, 170) + .moveDown(); + + this.generateHr(doc, 215); + } + + private generateInvoiceTable(doc: PDFKit.PDFDocument, order: any) { + const invoiceTableTop = 230; + + doc.font("Helvetica-Bold"); + this.generateTableRow( + doc, + invoiceTableTop, + "Item", + "Description", + "Unit Cost", + "Quantity", + "Line Total", + ); + this.generateHr(doc, invoiceTableTop + 20); + doc.font("Helvetica"); + + const items = this.getInvoiceItems(order); + let position = invoiceTableTop + 30; + + items.forEach((item) => { + const unitPriceKobo = BigInt(item.unitPriceKobo); + const lineTotalKobo = unitPriceKobo * BigInt(item.quantity); + this.generateTableRow( + doc, + position, + item.name, + item.priceType || "Standard", + this.formatKobo(unitPriceKobo), + item.quantity.toString(), + this.formatKobo(lineTotalKobo), + ); + + position += 20; + }); + + const legacyShopperFee = + order.platformFeeBearer === "SHOPPER" + ? BigInt(order.platformFeeKobo ?? 0) + : 0n; + const subtotal = + BigInt(order.totalAmountKobo) - + BigInt(order.deliveryFeeKobo) - + legacyShopperFee; + const subtotalPosition = position + 30; + this.generateTableRow( + doc, + subtotalPosition, + "", + "", + "Subtotal", + "", + this.formatKobo(subtotal), + ); + + const deliveryPosition = subtotalPosition + 20; + this.generateTableRow( + doc, + deliveryPosition, + "", + "", + "Delivery Fee", + "", + this.formatKobo(BigInt(order.deliveryFeeKobo)), + ); + + let duePosition = deliveryPosition + 25; + if (legacyShopperFee > 0n) { + this.generateTableRow( + doc, + duePosition, + "", + "", + "Service Fee", + "", + this.formatKobo(legacyShopperFee), + ); + duePosition += 20; + } + doc.font("Helvetica-Bold"); + this.generateTableRow( + doc, + duePosition, + "", + "", + "Total Paid", + "", + this.formatKobo(BigInt(order.totalAmountKobo)), + ); + doc.font("Helvetica"); + } + + private formatKobo(value: bigint): string { + const sign = value < 0n ? "-" : ""; + const absolute = value < 0n ? -value : value; + const naira = absolute / 100n; + const kobo = (absolute % 100n).toString().padStart(2, "0"); + return `${sign}₦${naira.toLocaleString("en-NG")}.${kobo}`; + } + + private getInvoiceItems(order: any): Array<{ + name: string; + priceType?: string | null; + unitPriceKobo: string | number | bigint; + quantity: number; + }> { + const items = order.items as unknown; + if (Array.isArray(items) && items.length > 0) { + return items; + } + + if ( + order.productId && + order.unitPriceKobo !== null && + order.unitPriceKobo !== undefined && + order.quantity + ) { + return [ + { + name: order.product?.name || "Product", + priceType: "Standard", + unitPriceKobo: order.unitPriceKobo, + quantity: order.quantity, + }, + ]; + } + + return []; + } + + private generateFooter(doc: PDFKit.PDFDocument) { + doc + .fontSize(10) + .text( + "Thank you for your business. Payment was processed with twizrr Buyer Protection.", + 50, + 700, + { align: "center", width: 500 }, + ); + } + + private generateTableRow( + doc: PDFKit.PDFDocument, + y: number, + item: string, + description: string, + unitCost: string, + quantity: string, + lineTotal: string, + ) { + doc + .fontSize(10) + .text(item, 50, y) + .text(description, 150, y) + .text(unitCost, 280, y, { width: 90, align: "right" }) + .text(quantity, 370, y, { width: 90, align: "right" }) + .text(lineTotal, 0, y, { align: "right" }); + } + + private generateHr(doc: PDFKit.PDFDocument, y: number) { + doc + .strokeColor("#aaaaaa") + .lineWidth(1) + .moveTo(50, y) + .lineTo(550, y) + .stroke(); + } +} diff --git a/apps/backend/src/modules/order/order-state-machine.ts b/apps/backend/src/domains/orders/order/order-state-machine.ts similarity index 82% rename from apps/backend/src/modules/order/order-state-machine.ts rename to apps/backend/src/domains/orders/order/order-state-machine.ts index 4cde4065..d8d714ca 100644 --- a/apps/backend/src/modules/order/order-state-machine.ts +++ b/apps/backend/src/domains/orders/order/order-state-machine.ts @@ -1,4 +1,4 @@ -import { OrderStatus, ORDER_TRANSITIONS } from "@swifta/shared"; +import { OrderStatus, ORDER_TRANSITIONS } from "@twizrr/shared"; export function validateTransition( from: OrderStatus, diff --git a/apps/backend/src/domains/orders/order/order.controller.ts b/apps/backend/src/domains/orders/order/order.controller.ts new file mode 100644 index 00000000..ccd9e55d --- /dev/null +++ b/apps/backend/src/domains/orders/order/order.controller.ts @@ -0,0 +1,236 @@ +import { + Controller, + Get, + Post, + Put, + Body, + Param, + UseGuards, + Query, + ForbiddenException, + DefaultValuePipe, + ParseIntPipe, + Res, +} from "@nestjs/common"; +import { Throttle } from "@nestjs/throttler"; +import { OrderService } from "./order.service"; +import { InvoiceService } from "./invoice.service"; +import { ConfirmDeliveryDto } from "./dto/confirm-delivery.dto"; +import type { Response } from "express"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { RolesGuard } from "../../../common/guards/roles.guard"; +import { Roles } from "../../../common/decorators/roles.decorator"; +import { CurrentUser } from "../../../common/decorators/current-user.decorator"; +import { CurrentMerchant } from "../../../common/decorators/current-merchant.decorator"; +import { UserRole, JwtPayload } from "@twizrr/shared"; +import { CreateDirectOrderDto } from "./dto/create-direct-order.dto"; +import { CreateSourcedOrderDto } from "./dto/create-sourced-order.dto"; +import { CreateTrackingDto } from "./dto/create-tracking.dto"; +import { CheckoutCartDto } from "./dto/checkout-cart.dto"; + +@Controller("orders") +@UseGuards(JwtAuthGuard, RolesGuard) +export class OrderController { + constructor( + private readonly orderService: OrderService, + private readonly invoiceService: InvoiceService, + ) {} + + @Get() + findAll( + @CurrentUser() user: JwtPayload, + @Query("page", new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number, + ) { + if (user.role === UserRole.USER && user.storeId) { + return this.orderService.listByMerchant(user.storeId, page, limit); + } + return this.orderService.listByBuyer(user.sub, page, limit); + } + + @Get("summary") + @Roles(UserRole.USER) + getSummary(@CurrentMerchant() storeId: string | undefined) { + if (!storeId) { + throw new ForbiddenException("Store identity required"); + } + return this.orderService.getMerchantSummary(storeId); + } + + @Post("direct") + @Roles(UserRole.USER, UserRole.USER) + @Throttle({ default: { limit: 5, ttl: 60000 } }) + createDirectOrder( + @CurrentUser() user: JwtPayload, + @Body() dto: CreateDirectOrderDto, + ) { + return this.orderService.createDirectOrder(user.sub, dto); + } + + // Dropship checkout: shopper buys a SourcedProduct listed by a digital + // store. Creates the linked DROPSHIP_CUSTOMER + DROPSHIP_FULFILLMENT pair + // in a single transaction. Shopper pays the customer-facing total only. + @Post("sourced/:sourcedProductId") + @Roles(UserRole.USER, UserRole.USER) + @Throttle({ default: { limit: 5, ttl: 60000 } }) + createSourcedOrder( + @CurrentUser() user: JwtPayload, + @Param("sourcedProductId") sourcedProductId: string, + @Body() dto: CreateSourcedOrderDto, + ) { + return this.orderService.createSourcedOrder( + user.sub, + sourcedProductId, + dto, + ); + } + + // B-17 spec alias for POST /orders. Maps to the existing direct-checkout + // path so the spec-named route works without duplicating logic. + @Post() + @Roles(UserRole.USER, UserRole.USER) + @Throttle({ default: { limit: 5, ttl: 60000 } }) + createOrder( + @CurrentUser() user: JwtPayload, + @Body() dto: CreateDirectOrderDto, + ) { + return this.orderService.createDirectOrder(user.sub, dto); + } + + // B-17 spec: explicit /orders/me listing the shopper's own orders. + // Static path declared before any ":id" route to avoid Nest treating + // "me" as an order id. + @Get("me") + listMyOrders( + @CurrentUser() user: JwtPayload, + @Query("page", new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number, + ) { + return this.orderService.listByBuyer(user.sub, page, limit); + } + + @Post("checkout/cart") + @Roles(UserRole.USER, UserRole.USER) + @Throttle({ default: { limit: 5, ttl: 60000 } }) + checkoutCart(@CurrentUser() user: JwtPayload, @Body() dto: CheckoutCartDto) { + return this.orderService.checkoutCart(user.sub, dto); + } + + @Get(":id") + findOne(@CurrentUser() user: JwtPayload, @Param("id") id: string) { + return this.orderService.getById(id, user.sub, user.storeId); + } + + @Post(":id/tracking") + @Roles(UserRole.USER) + addTracking( + @CurrentMerchant() storeId: string | undefined, + @Param("id") id: string, + @Body() dto: CreateTrackingDto, + ) { + if (!storeId) { + throw new ForbiddenException("Store identity required"); + } + return this.orderService.addTracking(storeId, id, dto); + } + + @Get(":id/tracking") + getTracking(@CurrentUser() user: JwtPayload, @Param("id") id: string) { + return this.orderService.getTracking(id, user.sub, user.storeId); + } + + @Post(":id/ready-for-pickup") + @Roles(UserRole.USER) + @Throttle({ default: { limit: 3, ttl: 60000 } }) + readyForPickup( + @CurrentMerchant() storeId: string | undefined, + @Param("id") id: string, + ) { + if (!storeId) { + throw new ForbiddenException("Store identity required"); + } + return this.orderService.readyForPickup(storeId, id); + } + + @Post(":id/dispatch") + @Roles(UserRole.USER) + @Throttle({ default: { limit: 3, ttl: 60000 } }) + dispatch( + @CurrentMerchant() storeId: string | undefined, + @Param("id") id: string, + ) { + if (!storeId) { + throw new ForbiddenException("Store identity required"); + } + return this.orderService.dispatch(storeId, id); + } + + // B-17 spec alias: PUT /orders/:id/dispatch (idempotent semantics). + @Put(":id/dispatch") + @Roles(UserRole.USER) + @Throttle({ default: { limit: 3, ttl: 60000 } }) + dispatchPut( + @CurrentMerchant() storeId: string | undefined, + @Param("id") id: string, + ) { + if (!storeId) { + throw new ForbiddenException("Store identity required"); + } + return this.orderService.dispatch(storeId, id); + } + + @Post(":id/confirm-delivery") + @Roles(UserRole.USER, UserRole.USER) + @Throttle({ default: { limit: 5, ttl: 60000 } }) + confirmDelivery( + @CurrentUser() user: JwtPayload, + @Param("id") id: string, + @Body() dto: ConfirmDeliveryDto, + ) { + return this.orderService.confirmDelivery(user.sub, id, dto.otp); + } + + // B-17 spec alias: POST /orders/:id/confirm. + @Post(":id/confirm") + @Roles(UserRole.USER, UserRole.USER) + @Throttle({ default: { limit: 5, ttl: 60000 } }) + confirmDeliveryShort( + @CurrentUser() user: JwtPayload, + @Param("id") id: string, + @Body() dto: ConfirmDeliveryDto, + ) { + return this.orderService.confirmDelivery(user.sub, id, dto.otp); + } + + @Post(":id/cancel") + cancel(@CurrentUser() user: JwtPayload, @Param("id") id: string) { + return this.orderService.cancel(user.sub, id, user.storeId); + } + + @Post(":id/report-issue") + @Roles(UserRole.USER, UserRole.USER) + reportIssue( + @CurrentUser() user: JwtPayload, + @Param("id") id: string, + @Body("reason") reason: string, + ) { + return this.orderService.reportIssue(user.sub, id, reason); + } + + @Get(":id/receipt") + @Roles(UserRole.USER, UserRole.USER) + getReceipt(@CurrentUser() user: JwtPayload, @Param("id") id: string) { + return this.orderService.getReceipt(id, user.sub); + } + + @Get(":id/invoice") + @Roles(UserRole.USER, UserRole.USER) + async getInvoice( + @CurrentUser() user: JwtPayload, + @Param("id") id: string, + @Res() res: Response, + ) { + // Pass caller identity to the service to enforce ownership validation + return this.invoiceService.generateInvoice(id, res, user); + } +} diff --git a/apps/backend/src/domains/orders/order/order.module.ts b/apps/backend/src/domains/orders/order/order.module.ts new file mode 100644 index 00000000..1c464f52 --- /dev/null +++ b/apps/backend/src/domains/orders/order/order.module.ts @@ -0,0 +1,37 @@ +import { Module, forwardRef } from "@nestjs/common"; +import { OrderService } from "./order.service"; +import { InvoiceService } from "./invoice.service"; +import { OrderController } from "./order.controller"; +import { StoreOrderController } from "./store-order.controller"; +import { PrismaModule } from "../../../prisma/prisma.module"; +import { NotificationModule } from "../../social/notification/notification.module"; +import { InventoryModule } from "../../commerce/inventory/inventory.module"; +import { PaymentModule } from "../../money/payment/payment.module"; +import { VerificationModule } from "../../users/verification/verification.module"; +import { WhatsAppModule } from "../../../channels/whatsapp/whatsapp.module"; +import { PayoutModule } from "../../money/payout/payout.module"; +import { LedgerModule } from "../../money/ledger/ledger.module"; +import { QueueModule } from "../../../queue/queue.module"; +import { DomainEventModule } from "../../platform/domain-event/domain-event.module"; +import { OutboxModule } from "../../platform/outbox/outbox.module"; +import { AutoConfirmProcessor } from "../../../queue/processors/auto-confirm.processor"; + +@Module({ + imports: [ + PrismaModule, + forwardRef(() => NotificationModule), + InventoryModule, + forwardRef(() => PaymentModule), + VerificationModule, + QueueModule, + LedgerModule, + DomainEventModule, + OutboxModule, + forwardRef(() => WhatsAppModule), + PayoutModule, + ], + controllers: [OrderController, StoreOrderController], + providers: [OrderService, InvoiceService, AutoConfirmProcessor], + exports: [OrderService], +}) +export class OrderModule {} diff --git a/apps/backend/src/domains/orders/order/order.service.spec.ts b/apps/backend/src/domains/orders/order/order.service.spec.ts new file mode 100644 index 00000000..cb64b2fa --- /dev/null +++ b/apps/backend/src/domains/orders/order/order.service.spec.ts @@ -0,0 +1,2445 @@ +import { BadRequestException, ConflictException } from "@nestjs/common"; +import { + DeliveryMethod, + DeliveryStatus, + OrderType, + PayoutStatus, + ProductStatus, + StoreType, +} from "@prisma/client"; +import { OrderStatus } from "@twizrr/shared"; +import * as bcrypt from "bcrypt"; + +import { CartService } from "../cart/cart.service"; +import { OrderService } from "./order.service"; + +type TransitionForTest = ( + orderId: string, + fromStatus: OrderStatus, + toStatus: OrderStatus, + triggeredBy: string, + metadata?: Record, + updateData?: Record, +) => Promise<{ id: string; status: OrderStatus }>; + +describe("OrderService domain events", () => { + const makeService = () => { + const tx = { + order: { + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + findUniqueOrThrow: jest.fn().mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-123456", + buyerId: "buyer-1", + storeId: "store-1", + status: OrderStatus.CANCELLED, + totalAmountKobo: 250000n, + platformFeeKobo: 5000n, + }), + }, + orderEvent: { + create: jest.fn().mockResolvedValue({ id: "legacy-event-1" }), + }, + }; + const prisma = { + $transaction: jest.fn((callback: (transaction: typeof tx) => unknown) => + callback(tx), + ), + }; + const domainEvents = { + append: jest.fn().mockResolvedValue({ id: "domain-event-1" }), + }; + + const service = new OrderService( + prisma as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + domainEvents as never, + ); + + return { service, prisma, tx, domainEvents }; + }; + + it("writes legacy and domain events inside the order transition transaction", async () => { + const { service, prisma, tx, domainEvents } = makeService(); + const transition = ( + service as unknown as { transition: TransitionForTest } + ).transition.bind(service); + + await transition( + "order-1", + OrderStatus.PENDING_PAYMENT, + OrderStatus.CANCELLED, + "buyer-1", + { cancelledBy: "buyer" }, + ); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(tx.order.updateMany).toHaveBeenCalledWith({ + where: { id: "order-1", status: OrderStatus.PENDING_PAYMENT }, + data: { status: OrderStatus.CANCELLED }, + }); + expect(tx.orderEvent.create).toHaveBeenCalledWith({ + data: { + orderId: "order-1", + fromStatus: OrderStatus.PENDING_PAYMENT, + toStatus: OrderStatus.CANCELLED, + triggeredBy: "buyer-1", + metadata: { cancelledBy: "buyer" }, + }, + }); + expect(domainEvents.append).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + aggregateType: "ORDER", + aggregateId: "order-1", + eventType: "ORDER_CANCELLED", + actorType: "USER", + actorId: "buyer-1", + metadata: expect.objectContaining({ + totalAmountKobo: "250000", + platformFeeKobo: "5000", + fromStatus: OrderStatus.PENDING_PAYMENT, + toStatus: OrderStatus.CANCELLED, + cancelledBy: "buyer", + }), + }), + ); + }); + + it("does not append a domain event when the status update fails", async () => { + const { service, tx, domainEvents } = makeService(); + tx.order.updateMany.mockResolvedValueOnce({ count: 0 }); + const transition = ( + service as unknown as { transition: TransitionForTest } + ).transition.bind(service); + + await expect( + transition( + "order-1", + OrderStatus.PENDING_PAYMENT, + OrderStatus.CANCELLED, + "buyer-1", + ), + ).rejects.toBeInstanceOf(ConflictException); + + expect(domainEvents.append).not.toHaveBeenCalled(); + }); +}); + +describe("OrderService transitionBySystem atomicity", () => { + const makeService = () => { + const tx = { + order: { + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + findUnique: jest.fn().mockResolvedValue({ id: "order-1" }), + findUniqueOrThrow: jest.fn().mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-123456", + buyerId: "buyer-1", + storeId: "store-1", + status: OrderStatus.PAID, + totalAmountKobo: 250000n, + platformFeeKobo: 5000n, + }), + }, + orderEvent: { + create: jest.fn().mockResolvedValue({ id: "legacy-event-1" }), + }, + }; + const prisma = { + $transaction: jest.fn((callback: (transaction: typeof tx) => unknown) => + callback(tx), + ), + }; + const domainEvents = { + append: jest.fn().mockResolvedValue({ id: "domain-event-1" }), + }; + + const service = new OrderService( + prisma as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + domainEvents as never, + ); + + return { service, prisma, tx, domainEvents }; + }; + + it("conditions the system update on the current DB status and writes the event after it succeeds", async () => { + const { service, tx, domainEvents } = makeService(); + + await service.transitionBySystem( + "order-1", + OrderStatus.PENDING_PAYMENT, + OrderStatus.PAID, + { paymentId: "pay-1" }, + ); + + expect(tx.order.updateMany).toHaveBeenCalledWith({ + where: { id: "order-1", status: OrderStatus.PENDING_PAYMENT }, + data: { status: OrderStatus.PAID }, + }); + expect(tx.orderEvent.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + orderId: "order-1", + fromStatus: OrderStatus.PENDING_PAYMENT, + toStatus: OrderStatus.PAID, + triggeredBy: "buyer-1", + metadata: expect.objectContaining({ triggeredBySystem: true }), + }), + }); + expect(domainEvents.append).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + eventType: "PAYMENT_CONFIRMED", + actorType: "SYSTEM", + actorId: null, + }), + ); + }); + + it("rejects a stale system transition when the row already moved on, writing no events", async () => { + const { service, tx, domainEvents } = makeService(); + // The row was concurrently cancelled, so the conditional update on + // { status: PENDING_PAYMENT } matches zero rows even though the static + // transition PENDING_PAYMENT -> PAID is valid. + tx.order.updateMany.mockResolvedValueOnce({ count: 0 }); + + await expect( + service.transitionBySystem( + "order-1", + OrderStatus.PENDING_PAYMENT, + OrderStatus.PAID, + ), + ).rejects.toMatchObject({ + response: expect.objectContaining({ code: "ORDER_STATE_CONFLICT" }), + }); + + expect(tx.orderEvent.create).not.toHaveBeenCalled(); + expect(domainEvents.append).not.toHaveBeenCalled(); + }); +}); + +describe("OrderService pending-payment cancellation safety", () => { + it("blocks buyer cancellation when provider-confirmed funds remain under review", async () => { + const prisma = { + order: { + findUnique: jest.fn().mockResolvedValue({ + id: "order-1", + buyerId: "buyer-1", + storeId: "store-1", + status: OrderStatus.PENDING_PAYMENT, + }), + }, + paymentAmountException: { + findFirst: jest.fn().mockResolvedValue({ id: "exception-1" }), + }, + }; + const service = new OrderService( + prisma as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + + await expect(service.cancel("buyer-1", "order-1")).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "PAYMENT_COLLECTION_REVIEW_REQUIRED", + }), + }); + }); +}); + +describe("OrderService store-owner privacy", () => { + const makeService = (prisma: Record) => + new OrderService( + prisma as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + + it("strips shopper contact and delivery details from store order lists", async () => { + const prisma = { + order: { + findMany: jest.fn().mockResolvedValue([ + { + id: "order-1", + orderCode: "TWZ-123456", + buyerId: "buyer-1", + storeId: "store-1", + status: OrderStatus.PAID, + deliveryAddress: "12 Shopper Street", + deliveryDetails: { phone: "+2348012345678" }, + deliveryPrimaryPhone: "+2348012345678", + deliverySecondaryPhone: "+2349012345678", + deliveryAddressSnapshot: { primaryPhone: "+2348012345678" }, + pickupAddressSnapshot: { formattedAddress: "Balogun, Lagos" }, + pickupStoreId: "store-1", + sourceStoreId: null, + deliveryOtp: "123456", + user: { + email: "shopper@example.com", + phone: "+2348012345678", + }, + }, + ]), + count: jest.fn().mockResolvedValue(1), + }, + }; + const service = makeService(prisma); + + const result = await service.listByMerchant("store-1", 1, 20); + const row = result.data[0] as unknown as Record; + + expect(row.user).toBeUndefined(); + expect(row.buyerId).toBeUndefined(); + expect(row.deliveryDetails).toBeUndefined(); + expect(row.deliveryPrimaryPhone).toBeUndefined(); + expect(row.deliverySecondaryPhone).toBeUndefined(); + expect(row.deliveryAddressSnapshot).toBeUndefined(); + expect(row.pickupAddressSnapshot).toBeUndefined(); + expect(row.pickupStoreId).toBeUndefined(); + expect(row.sourceStoreId).toBeUndefined(); + expect(row.deliveryAddress).toBeNull(); + expect(row.deliveryOtp).toBeNull(); + }); + + it("keeps dropship fulfillment rows visible for the involved store without leaking internals", async () => { + const prisma = { + order: { + findMany: jest.fn().mockResolvedValue([ + { + id: "fulfillment-order-1", + orderCode: "TWZ-654321", + buyerId: "digital-store-owner", + storeId: "physical-store-1", + status: OrderStatus.PAID, + orderType: OrderType.DROPSHIP_FULFILLMENT, + linkedOrderId: "customer-order-1", + sourcedProductId: "sourced-product-1", + dropshipperCostKobo: 100_000n, + digitalMarginKobo: 50_000n, + deliveryAddress: "12 Shopper Street", + deliveryDetails: { phone: "+2348012345678" }, + deliveryPrimaryPhone: "+2348012345678", + deliverySecondaryPhone: "+2349012345678", + deliveryAddressSnapshot: { primaryPhone: "+2348012345678" }, + pickupAddressSnapshot: { formattedAddress: "Balogun, Lagos" }, + pickupStoreId: "physical-store-1", + sourceStoreId: "physical-store-1", + deliveryOtp: "123456", + product: { + name: "Safe product name", + imageUrl: "https://cdn.test/product.jpg", + productCode: "TWZ-PROD1", + storeId: "physical-store-1", + dropshipperPriceKobo: 100_000n, + }, + }, + ]), + count: jest.fn().mockResolvedValue(1), + }, + }; + const service = makeService(prisma); + + const result = await service.listByMerchant("physical-store-1", 1, 20); + const row = result.data[0] as unknown as Record; + const product = row.product as Record; + + expect(prisma.order.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { storeId: "physical-store-1" }, + }), + ); + expect(row.id).toBe("fulfillment-order-1"); + expect(row.orderCode).toBe("TWZ-654321"); + expect(row.status).toBe(OrderStatus.PAID); + expect(product).toEqual({ + name: "Safe product name", + imageUrl: "https://cdn.test/product.jpg", + productCode: "TWZ-PROD1", + }); + expect(row.orderType).toBeUndefined(); + expect(row.linkedOrderId).toBeUndefined(); + expect(row.sourcedProductId).toBeUndefined(); + expect(row.dropshipperCostKobo).toBeUndefined(); + expect(row.digitalMarginKobo).toBeUndefined(); + expect(row.buyerId).toBeUndefined(); + expect(row.deliveryDetails).toBeUndefined(); + expect(row.deliveryPrimaryPhone).toBeUndefined(); + expect(row.deliverySecondaryPhone).toBeUndefined(); + expect(row.deliveryAddressSnapshot).toBeUndefined(); + expect(row.deliveryAddress).toBeNull(); + expect(row.deliveryOtp).toBeNull(); + expect(product.storeId).toBeUndefined(); + expect(product.dropshipperPriceKobo).toBeUndefined(); + }); + + it("keeps dropship customer rows visible for the digital store without leaking fulfillment internals", async () => { + const prisma = { + order: { + findMany: jest.fn().mockResolvedValue([ + { + id: "customer-order-1", + orderCode: "TWZ-111222", + buyerId: "buyer-1", + storeId: "digital-store-1", + status: OrderStatus.PAID, + orderType: OrderType.DROPSHIP_CUSTOMER, + linkedOrderId: "fulfillment-order-1", + sourcedProductId: "sourced-product-1", + dropshipperCostKobo: 100_000n, + digitalMarginKobo: 50_000n, + deliveryAddress: "12 Shopper Street", + deliveryDetails: { phone: "+2348012345678" }, + deliveryPrimaryPhone: "+2348012345678", + deliverySecondaryPhone: "+2349012345678", + deliveryAddressSnapshot: { primaryPhone: "+2348012345678" }, + pickupAddressSnapshot: { formattedAddress: "Balogun, Lagos" }, + pickupStoreId: "physical-store-1", + sourceStoreId: "physical-store-1", + deliveryOtp: "123456", + product: { + name: "Safe reseller listing", + imageUrl: "https://cdn.test/listing.jpg", + productCode: "TWZ-LIST1", + storeId: "digital-store-1", + dropshipperPriceKobo: 100_000n, + }, + }, + ]), + count: jest.fn().mockResolvedValue(1), + }, + }; + const service = makeService(prisma); + + const result = await service.listByMerchant("digital-store-1", 1, 20); + const row = result.data[0] as unknown as Record; + const product = row.product as Record; + + expect(prisma.order.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { storeId: "digital-store-1" }, + }), + ); + expect(row.id).toBe("customer-order-1"); + expect(row.orderCode).toBe("TWZ-111222"); + expect(product).toEqual({ + name: "Safe reseller listing", + imageUrl: "https://cdn.test/listing.jpg", + productCode: "TWZ-LIST1", + }); + expect(row.orderType).toBeUndefined(); + expect(row.linkedOrderId).toBeUndefined(); + expect(row.sourcedProductId).toBeUndefined(); + expect(row.dropshipperCostKobo).toBeUndefined(); + expect(row.digitalMarginKobo).toBeUndefined(); + expect(row.buyerId).toBeUndefined(); + expect(row.deliveryDetails).toBeUndefined(); + expect(row.deliveryPrimaryPhone).toBeUndefined(); + expect(row.deliverySecondaryPhone).toBeUndefined(); + expect(row.deliveryAddressSnapshot).toBeUndefined(); + expect(row.pickupAddressSnapshot).toBeUndefined(); + expect(row.pickupStoreId).toBeUndefined(); + expect(row.sourceStoreId).toBeUndefined(); + expect(row.deliveryAddress).toBeNull(); + expect(row.deliveryOtp).toBeNull(); + expect(product.storeId).toBeUndefined(); + expect(product.dropshipperPriceKobo).toBeUndefined(); + }); + + it("queries only the requesting store's order rows so unrelated stores see none", async () => { + const prisma = { + order: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }, + }; + const service = makeService(prisma); + + const result = await service.listByMerchant("unrelated-store-1", 1, 20); + + expect(prisma.order.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { storeId: "unrelated-store-1" }, + }), + ); + expect(result.data).toEqual([]); + }); + + it("strips shopper contact and delivery details from store order detail", async () => { + const prisma = { + order: { + findUnique: jest.fn().mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-123456", + buyerId: "buyer-1", + storeId: "store-1", + status: OrderStatus.PAID, + orderType: OrderType.DROPSHIP_CUSTOMER, + linkedOrderId: "fulfillment-order-1", + sourcedProductId: "sourced-product-1", + dropshipperCostKobo: 100_000n, + digitalMarginKobo: 50_000n, + deliveryAddress: "12 Shopper Street", + deliveryDetails: { phone: "+2348012345678" }, + deliveryPrimaryPhone: "+2348012345678", + deliverySecondaryPhone: "+2349012345678", + deliveryAddressSnapshot: { primaryPhone: "+2348012345678" }, + pickupAddressSnapshot: { formattedAddress: "Balogun, Lagos" }, + pickupStoreId: "store-1", + sourceStoreId: null, + deliveryOtp: "123456", + user: { + email: "shopper@example.com", + phone: "+2348012345678", + }, + product: { + name: "Safe product name", + imageUrl: "https://cdn.test/product.jpg", + productCode: "TWZ-PROD1", + storeId: "store-1", + dropshipperPriceKobo: 100_000n, + }, + orderEvents: [ + { + id: "event-1", + triggeredBy: "buyer-1", + actorId: "buyer-1", + userId: "buyer-1", + }, + ], + }), + }, + }; + const service = makeService(prisma); + + const order = (await service.getById( + "order-1", + "store-owner-user", + "store-1", + )) as Record; + + expect(order.user).toBeUndefined(); + expect(order.buyerId).toBeUndefined(); + expect(order.orderType).toBeUndefined(); + expect(order.linkedOrderId).toBeUndefined(); + expect(order.sourcedProductId).toBeUndefined(); + expect(order.dropshipperCostKobo).toBeUndefined(); + expect(order.digitalMarginKobo).toBeUndefined(); + expect(order.deliveryDetails).toBeUndefined(); + expect(order.deliveryPrimaryPhone).toBeUndefined(); + expect(order.deliverySecondaryPhone).toBeUndefined(); + expect(order.deliveryAddressSnapshot).toBeUndefined(); + expect(order.pickupAddressSnapshot).toBeUndefined(); + expect(order.pickupStoreId).toBeUndefined(); + expect(order.sourceStoreId).toBeUndefined(); + expect(order.deliveryAddress).toBeNull(); + expect(order.deliveryOtp).toBeNull(); + expect(order.product).toEqual({ + name: "Safe product name", + imageUrl: "https://cdn.test/product.jpg", + productCode: "TWZ-PROD1", + }); + const orderEvents = order.orderEvents as Array>; + expect(orderEvents[0].triggeredBy).toBeNull(); + expect(orderEvents[0].actorId).toBeNull(); + expect(orderEvents[0].userId).toBeNull(); + }); +}); + +describe("OrderService store summary totals", () => { + const makeService = (prisma: Record) => + new OrderService( + prisma as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + + it("uses store-facing held amounts and payout records for protected balance", async () => { + const prisma = { + order: { + findMany: jest.fn().mockResolvedValue([ + { + id: "order-paid", + status: OrderStatus.PAID, + payoutStatus: PayoutStatus.PENDING, + totalAmountKobo: 13_200n, + deliveryFeeKobo: 3_000n, + platformFeeKobo: 1_200n, + dropshipperCostKobo: null, + }, + { + id: "order-in-transit", + status: OrderStatus.IN_TRANSIT, + payoutStatus: PayoutStatus.PENDING, + totalAmountKobo: 7_500n, + deliveryFeeKobo: 1_500n, + platformFeeKobo: 500n, + dropshipperCostKobo: null, + }, + { + id: "order-completed-awaiting-payout", + status: OrderStatus.COMPLETED, + payoutStatus: PayoutStatus.PENDING, + totalAmountKobo: 11_000n, + deliveryFeeKobo: 2_000n, + platformFeeKobo: 1_000n, + dropshipperCostKobo: null, + }, + { + id: "order-paid-out", + status: OrderStatus.COMPLETED, + payoutStatus: PayoutStatus.COMPLETED, + totalAmountKobo: 18_300n, + deliveryFeeKobo: 3_000n, + platformFeeKobo: 1_300n, + dropshipperCostKobo: 4_000n, + }, + ]), + }, + payout: { + findMany: jest.fn().mockResolvedValue([ + { + orderId: "order-paid-out", + amountKobo: 10_000n, + status: PayoutStatus.COMPLETED, + }, + ]), + }, + ledgerEntry: { + findMany: jest.fn().mockResolvedValue([ + { + orderId: "order-paid-out", + amountKobo: 10_000n, + }, + ]), + }, + }; + const service = makeService(prisma); + + await expect(service.getMerchantSummary("store-1")).resolves.toEqual({ + escrow: 22_500, + protectedOrderCount: 3, + paidOut: 10_000, + pending: 0, + failed: 0, + orderCount: 4, + }); + }); +}); + +describe("OrderService ready-for-pickup fulfillment", () => { + const makeService = ({ + initialStatus = OrderStatus.PAID, + freshStatus = OrderStatus.READY_FOR_PICKUP, + updateCount = 1, + storeType = StoreType.PHYSICAL, + }: { + initialStatus?: OrderStatus; + freshStatus?: OrderStatus; + updateCount?: number; + storeType?: StoreType; + } = {}) => { + const initialOrder = { + id: "order-1", + orderCode: "TWZ-123456", + buyerId: "buyer-1", + storeId: "store-1", + status: initialStatus, + totalAmountKobo: 250000n, + platformFeeKobo: 5000n, + deliveryOtp: null, + deliveryAddress: "12 Shopper Street", + deliveryDetails: { phone: "+2348012345678" }, + deliveryPrimaryPhone: "+2348012345678", + deliverySecondaryPhone: "+2349012345678", + deliveryAddressSnapshot: { primaryPhone: "+2348012345678" }, + pickupAddressSnapshot: { formattedAddress: "Balogun, Lagos" }, + pickupStoreId: "store-1", + sourceStoreId: null, + user: { email: "shopper@example.com", phone: "+2348012345678" }, + }; + const freshOrder = { ...initialOrder, status: freshStatus }; + const tx = { + order: { + updateMany: jest.fn().mockResolvedValue({ count: updateCount }), + findUnique: jest.fn().mockResolvedValue({ + ...initialOrder, + deliveryBooking: { + id: "booking-1", + status: DeliveryStatus.PENDING, + }, + }), + findUniqueOrThrow: jest.fn().mockResolvedValue(freshOrder), + }, + deliveryBooking: { + update: jest.fn().mockResolvedValue({ + id: "booking-1", + orderId: "order-1", + status: DeliveryStatus.PICKED_UP, + pickedUpAt: new Date("2026-06-07T10:00:00.000Z"), + }), + }, + orderTracking: { + create: jest.fn().mockResolvedValue({ id: "tracking-1" }), + }, + orderEvent: { + create: jest.fn().mockResolvedValue({ id: "event-1" }), + }, + }; + const prisma = { + order: { + findUnique: jest.fn().mockResolvedValue(initialOrder), + }, + storeProfile: { + findUnique: jest + .fn() + .mockResolvedValue({ userId: "owner-1", storeType }), + }, + $transaction: jest.fn((callback: (transaction: typeof tx) => unknown) => + callback(tx), + ), + }; + const notifications = { + triggerOrderDispatched: jest.fn(), + }; + const autoConfirmQueue = { add: jest.fn() }; + const autoConfirmWarningQueue = { add: jest.fn() }; + const domainEvents = { append: jest.fn() }; + const service = new OrderService( + prisma as never, + notifications as never, + {} as never, + {} as never, + {} as never, + autoConfirmQueue as never, + autoConfirmWarningQueue as never, + {} as never, + {} as never, + {} as never, + domainEvents as never, + ); + + return { + service, + prisma, + tx, + notifications, + domainEvents, + }; + }; + + it("moves paid orders to ready for pickup without generating a delivery code", async () => { + const { service, tx, notifications, domainEvents } = makeService(); + + const result = (await service.readyForPickup( + "store-1", + "order-1", + )) as Record; + + expect(tx.order.updateMany).toHaveBeenCalledWith({ + where: { id: "order-1", status: OrderStatus.PAID }, + data: { status: OrderStatus.READY_FOR_PICKUP }, + }); + expect(tx.orderTracking.create).toHaveBeenCalledWith({ + data: { orderId: "order-1", status: OrderStatus.READY_FOR_PICKUP }, + }); + expect(tx.orderEvent.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + fromStatus: OrderStatus.PAID, + toStatus: OrderStatus.READY_FOR_PICKUP, + metadata: { action: "ready_for_pickup" }, + }), + }); + expect(domainEvents.append).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + eventType: "ORDER_READY_FOR_PICKUP", + }), + ); + expect(notifications.triggerOrderDispatched).not.toHaveBeenCalled(); + expect(result.deliveryOtp).toBeNull(); + expect(result.deliveryAddress).toBeNull(); + expect(result.deliveryDetails).toBeUndefined(); + expect(result.buyerId).toBeUndefined(); + expect(result.user).toBeUndefined(); + }); + + it("moves preparing orders to ready for pickup", async () => { + const { service, tx } = makeService({ + initialStatus: OrderStatus.PREPARING, + }); + + await service.readyForPickup("store-1", "order-1"); + + expect(tx.order.updateMany).toHaveBeenCalledWith({ + where: { id: "order-1", status: OrderStatus.PREPARING }, + data: { status: OrderStatus.READY_FOR_PICKUP }, + }); + }); + + it("rejects invalid source statuses for ready for pickup", async () => { + const { service, prisma } = makeService({ + initialStatus: OrderStatus.DISPATCHED, + }); + + await expect( + service.readyForPickup("store-1", "order-1"), + ).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("rejects ready for pickup for digital stores", async () => { + const { service, prisma } = makeService({ + storeType: StoreType.DIGITAL, + }); + + await expect( + service.readyForPickup("store-1", "order-1"), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "PHYSICAL_FULFILLMENT_REQUIRED", + }), + }); + + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("rejects store-owner dispatch for digital stores", async () => { + const { service, prisma } = makeService({ + initialStatus: OrderStatus.READY_FOR_PICKUP, + storeType: StoreType.DIGITAL, + }); + + await expect(service.dispatch("store-1", "order-1")).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "PHYSICAL_FULFILLMENT_REQUIRED", + }), + }); + + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("rejects store-owner tracking transitions for digital stores", async () => { + const { service, prisma } = makeService({ + storeType: StoreType.DIGITAL, + }); + + await expect( + service.addTracking("store-1", "order-1", { + status: OrderStatus.READY_FOR_PICKUP, + }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "PHYSICAL_FULFILLMENT_REQUIRED", + }), + }); + + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("dispatches from ready for pickup and keeps delivery-code timing in dispatch", async () => { + const { service, tx, notifications } = makeService({ + initialStatus: OrderStatus.READY_FOR_PICKUP, + freshStatus: OrderStatus.DISPATCHED, + }); + + await service.dispatch("store-1", "order-1"); + + expect(tx.order.updateMany).toHaveBeenCalledWith({ + where: { id: "order-1", status: OrderStatus.READY_FOR_PICKUP }, + data: expect.objectContaining({ + status: OrderStatus.DISPATCHED, + deliveryOtp: expect.any(String), + dispatchedAt: expect.any(Date), + }), + }); + expect(notifications.triggerOrderDispatched).toHaveBeenCalledWith( + "buyer-1", + expect.objectContaining({ + orderId: "order-1", + otp: expect.stringMatching(/^\d{6}$/), + }), + ); + }); + + it("admin start delivery dispatches booked ready orders and marks booking picked up", async () => { + const { service, tx, notifications, domainEvents } = makeService({ + initialStatus: OrderStatus.READY_FOR_PICKUP, + freshStatus: OrderStatus.DISPATCHED, + }); + + await service.startDeliveryByAdmin("order-1", "admin-1"); + + expect(tx.order.findUnique).toHaveBeenCalledWith({ + where: { id: "order-1" }, + select: expect.objectContaining({ + deliveryBooking: { + select: { + id: true, + status: true, + }, + }, + }), + }); + expect(tx.deliveryBooking.update).toHaveBeenCalledWith({ + where: { id: "booking-1" }, + data: { + status: DeliveryStatus.PICKED_UP, + pickedUpAt: expect.any(Date), + }, + }); + expect(tx.order.updateMany).toHaveBeenCalledWith({ + where: { id: "order-1", status: OrderStatus.READY_FOR_PICKUP }, + data: expect.objectContaining({ + status: OrderStatus.DISPATCHED, + deliveryOtp: expect.any(String), + dispatchedAt: expect.any(Date), + }), + }); + expect(tx.orderTracking.create).toHaveBeenCalledWith({ + data: { orderId: "order-1", status: OrderStatus.DISPATCHED }, + }); + expect(tx.orderEvent.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + triggeredBy: "admin-1", + fromStatus: OrderStatus.READY_FOR_PICKUP, + toStatus: OrderStatus.DISPATCHED, + metadata: { action: "admin_start_delivery" }, + }), + }); + expect(domainEvents.append).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + actorType: "ADMIN", + actorId: "admin-1", + eventType: "ORDER_DISPATCHED", + metadata: expect.objectContaining({ + action: "admin_start_delivery", + }), + }), + ); + expect(notifications.triggerOrderDispatched).toHaveBeenCalledWith( + "buyer-1", + expect.objectContaining({ + orderId: "order-1", + otp: expect.stringMatching(/^\d{6}$/), + }), + ); + }); + + it("admin start delivery rejects orders that are not ready for pickup", async () => { + const { service, tx } = makeService({ + initialStatus: OrderStatus.PAID, + }); + + await expect( + service.startDeliveryByAdmin("order-1", "admin-1"), + ).rejects.toBeInstanceOf(BadRequestException); + expect(tx.deliveryBooking.update).not.toHaveBeenCalled(); + expect(tx.order.updateMany).not.toHaveBeenCalled(); + }); + + it("admin start delivery requires an existing delivery booking", async () => { + const { service, tx } = makeService({ + initialStatus: OrderStatus.READY_FOR_PICKUP, + }); + tx.order.findUnique.mockResolvedValueOnce({ + id: "order-1", + status: OrderStatus.READY_FOR_PICKUP, + deliveryBooking: null, + }); + + await expect( + service.startDeliveryByAdmin("order-1", "admin-1"), + ).rejects.toBeInstanceOf(BadRequestException); + expect(tx.deliveryBooking.update).not.toHaveBeenCalled(); + expect(tx.order.updateMany).not.toHaveBeenCalled(); + }); + + it("admin start delivery rejects already-started bookings", async () => { + const { service, tx } = makeService({ + initialStatus: OrderStatus.READY_FOR_PICKUP, + }); + tx.order.findUnique.mockResolvedValueOnce({ + id: "order-1", + status: OrderStatus.READY_FOR_PICKUP, + deliveryBooking: { + id: "booking-1", + status: DeliveryStatus.PICKED_UP, + }, + }); + + await expect( + service.startDeliveryByAdmin("order-1", "admin-1"), + ).rejects.toBeInstanceOf(BadRequestException); + expect(tx.deliveryBooking.update).not.toHaveBeenCalled(); + expect(tx.order.updateMany).not.toHaveBeenCalled(); + }); + + it("does not fail dispatch when buyer dispatched notification fails", async () => { + const { service, tx, notifications } = makeService({ + initialStatus: OrderStatus.READY_FOR_PICKUP, + freshStatus: OrderStatus.DISPATCHED, + }); + notifications.triggerOrderDispatched.mockRejectedValueOnce( + new Error("notification unavailable"), + ); + + await expect( + service.startDeliveryByAdmin("order-1", "admin-1"), + ).resolves.toMatchObject({ + status: OrderStatus.DISPATCHED, + }); + + expect(tx.order.updateMany).toHaveBeenCalledWith({ + where: { id: "order-1", status: OrderStatus.READY_FOR_PICKUP }, + data: expect.objectContaining({ + status: OrderStatus.DISPATCHED, + deliveryOtp: expect.any(String), + dispatchedAt: expect.any(Date), + }), + }); + }); +}); + +describe("OrderService delivery confirmation", () => { + const makeDispatchedOrder = async (deliveryCode = "123456") => ({ + id: "order-1", + orderCode: "TWZ-123456", + buyerId: "buyer-1", + storeId: "store-1", + status: OrderStatus.DISPATCHED, + totalAmountKobo: 250000n, + platformFeeKobo: 5000n, + deliveryOtp: await bcrypt.hash(deliveryCode, 1), + dispatchedAt: new Date(), + orderType: "DIRECT", + linkedOrderId: null, + }); + + const makeService = async ({ + order, + }: { + order?: Record; + } = {}) => { + const dispatchedOrder = order ?? (await makeDispatchedOrder()); + const deliveredOrder = { + ...dispatchedOrder, + status: OrderStatus.DELIVERED, + }; + const completedOrder = { + ...dispatchedOrder, + status: OrderStatus.COMPLETED, + }; + const tx = { + order: { + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + findUniqueOrThrow: jest + .fn() + .mockResolvedValueOnce(deliveredOrder) + .mockResolvedValueOnce(completedOrder), + }, + orderEvent: { + create: jest.fn().mockResolvedValue({ id: "event-1" }), + }, + }; + const prisma = { + order: { + findUnique: jest.fn().mockResolvedValue(dispatchedOrder), + update: jest.fn().mockResolvedValue(deliveredOrder), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + orderTracking: { + create: jest.fn().mockResolvedValue({ id: "tracking-1" }), + }, + $transaction: jest.fn((callback: (transaction: typeof tx) => unknown) => + callback(tx), + ), + }; + const notifications = { + triggerDeliveryConfirmed: jest.fn(), + triggerOrderAutoConfirmed: jest.fn(), + }; + const verification = { + checkAndUpgradeTier: jest.fn(), + }; + const payout = { + queuePayout: jest.fn(), + }; + const ledger = { + recordEscrowRelease: jest.fn(), + }; + const domainEvents = { + append: jest.fn(), + }; + const service = new OrderService( + prisma as never, + notifications as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + verification as never, + payout as never, + ledger as never, + domainEvents as never, + ); + + return { + service, + prisma, + tx, + notifications, + verification, + payout, + ledger, + domainEvents, + }; + }; + + it("confirms a dispatched order with a valid delivery code and completes payout flow", async () => { + const { + service, + tx, + notifications, + verification, + payout, + ledger, + domainEvents, + } = await makeService(); + + await expect( + service.confirmDelivery("buyer-1", "order-1", "123456"), + ).resolves.toEqual({ message: "Delivery confirmed" }); + + expect(tx.order.updateMany).toHaveBeenNthCalledWith(1, { + where: { id: "order-1", status: OrderStatus.DISPATCHED }, + data: { status: OrderStatus.DELIVERED }, + }); + expect(tx.order.updateMany).toHaveBeenNthCalledWith(2, { + where: { id: "order-1", status: OrderStatus.DELIVERED }, + data: { status: OrderStatus.COMPLETED }, + }); + expect(tx.orderEvent.create).toHaveBeenNthCalledWith(1, { + data: expect.objectContaining({ + fromStatus: OrderStatus.DISPATCHED, + toStatus: OrderStatus.DELIVERED, + metadata: { action: "delivery_confirmed" }, + }), + }); + expect(tx.orderEvent.create).toHaveBeenNthCalledWith(2, { + data: expect.objectContaining({ + fromStatus: OrderStatus.DELIVERED, + toStatus: OrderStatus.COMPLETED, + metadata: { action: "delivery_confirmed_completed" }, + }), + }); + expect(domainEvents.append).toHaveBeenCalledWith( + tx, + expect.objectContaining({ eventType: "DELIVERY_CONFIRMED" }), + ); + expect(domainEvents.append).toHaveBeenCalledWith( + tx, + expect.objectContaining({ eventType: "ORDER_COMPLETED" }), + ); + expect(ledger.recordEscrowRelease).toHaveBeenCalledWith( + "order-1", + 250000n, + { storeId: "store-1" }, + ); + expect(payout.queuePayout).toHaveBeenCalledWith("order-1"); + expect(notifications.triggerDeliveryConfirmed).toHaveBeenCalledWith( + "store-1", + "buyer-1", + expect.objectContaining({ orderId: "order-1" }), + ); + expect(verification.checkAndUpgradeTier).toHaveBeenCalledWith("store-1"); + }); + + it("resumes a buyer confirmation from DELIVERED after an escrow ledger failure", async () => { + const dispatchedOrder = await makeDispatchedOrder(); + const deliveredOrder = { + ...dispatchedOrder, + status: OrderStatus.DELIVERED, + }; + const { service, prisma, tx, ledger, payout } = await makeService({ + order: dispatchedOrder, + }); + prisma.order.findUnique + .mockResolvedValueOnce(dispatchedOrder) + .mockResolvedValueOnce(deliveredOrder) + .mockResolvedValue(dispatchedOrder); + ledger.recordEscrowRelease + .mockRejectedValueOnce(new Error("ledger unavailable")) + .mockResolvedValueOnce(undefined); + + await expect( + service.confirmDelivery("buyer-1", "order-1", "123456"), + ).rejects.toThrow("ledger unavailable"); + + await expect( + service.confirmDelivery("buyer-1", "order-1", "123456"), + ).resolves.toEqual({ message: "Delivery confirmed" }); + + expect(tx.order.updateMany).toHaveBeenCalledTimes(2); + expect(tx.order.updateMany).toHaveBeenNthCalledWith(1, { + where: { id: "order-1", status: OrderStatus.DISPATCHED }, + data: { status: OrderStatus.DELIVERED }, + }); + expect(tx.order.updateMany).toHaveBeenNthCalledWith(2, { + where: { id: "order-1", status: OrderStatus.DELIVERED }, + data: { status: OrderStatus.COMPLETED }, + }); + expect(ledger.recordEscrowRelease).toHaveBeenCalledTimes(2); + expect(payout.queuePayout).toHaveBeenCalledTimes(1); + }); + + it("records escrow release and safely resumes an automatic confirmation", async () => { + const dispatchedOrder = await makeDispatchedOrder(); + const deliveredOrder = { + ...dispatchedOrder, + status: OrderStatus.DELIVERED, + disputeWindowEndsAt: new Date(), + }; + const { service, prisma, tx, notifications, verification, ledger, payout } = + await makeService({ order: dispatchedOrder }); + prisma.order.findUnique + .mockResolvedValueOnce(dispatchedOrder) + .mockResolvedValueOnce(deliveredOrder) + .mockResolvedValue(dispatchedOrder); + ledger.recordEscrowRelease + .mockRejectedValueOnce(new Error("ledger unavailable")) + .mockResolvedValueOnce(undefined); + + await expect(service.autoConfirmDelivery("order-1")).rejects.toThrow( + "ledger unavailable", + ); + await expect(service.autoConfirmDelivery("order-1")).resolves.toEqual({ + success: true, + }); + + expect(tx.order.updateMany).toHaveBeenCalledTimes(2); + expect(tx.order.updateMany).toHaveBeenNthCalledWith(1, { + where: { id: "order-1", status: OrderStatus.DISPATCHED }, + data: { + disputeWindowEndsAt: expect.any(Date), + status: OrderStatus.DELIVERED, + }, + }); + expect(tx.order.updateMany).toHaveBeenNthCalledWith(2, { + where: { id: "order-1", status: OrderStatus.DELIVERED }, + data: { status: OrderStatus.COMPLETED }, + }); + expect(prisma.order.update).not.toHaveBeenCalled(); + expect(prisma.orderTracking.create).toHaveBeenCalledTimes(1); + expect(prisma.orderTracking.create).toHaveBeenCalledWith({ + data: { + orderId: "order-1", + status: OrderStatus.DELIVERED, + note: "Delivery auto-confirmed after 48 hours of no response.", + }, + }); + expect(notifications.triggerOrderAutoConfirmed).toHaveBeenCalledTimes(1); + expect(ledger.recordEscrowRelease).toHaveBeenCalledTimes(2); + expect(payout.queuePayout).toHaveBeenCalledWith("order-1"); + expect(verification.checkAndUpgradeTier).toHaveBeenCalledWith("store-1"); + }); + + it("backfills a missing dispute timestamp when resuming an already delivered order", async () => { + const deliveredOrder = { + ...(await makeDispatchedOrder()), + status: OrderStatus.DELIVERED, + disputeWindowEndsAt: null, + }; + const { service, prisma, tx } = await makeService({ + order: deliveredOrder, + }); + + await expect(service.autoConfirmDelivery("order-1")).resolves.toEqual({ + success: true, + }); + + expect(prisma.order.updateMany).toHaveBeenCalledWith({ + where: { + id: "order-1", + status: OrderStatus.DELIVERED, + disputeWindowEndsAt: null, + }, + data: { disputeWindowEndsAt: expect.any(Date) }, + }); + expect(tx.order.updateMany).toHaveBeenCalledTimes(1); + expect(tx.order.updateMany).toHaveBeenCalledWith({ + where: { id: "order-1", status: OrderStatus.DELIVERED }, + data: { status: OrderStatus.COMPLETED }, + }); + expect(prisma.orderTracking.create).not.toHaveBeenCalled(); + }); + + it("rejects an invalid delivery code without changing order status", async () => { + const { service, tx, ledger, payout } = await makeService(); + + await expect( + service.confirmDelivery("buyer-1", "order-1", "654321"), + ).rejects.toMatchObject({ + response: { + message: "Invalid delivery code", + code: "OTP_INVALID", + }, + }); + + expect(tx.order.updateMany).not.toHaveBeenCalled(); + expect(ledger.recordEscrowRelease).not.toHaveBeenCalled(); + expect(payout.queuePayout).not.toHaveBeenCalled(); + }); + + it("rejects confirmation before delivery has started", async () => { + const { service, tx } = await makeService({ + order: { + ...(await makeDispatchedOrder()), + status: OrderStatus.READY_FOR_PICKUP, + }, + }); + + await expect( + service.confirmDelivery("buyer-1", "order-1", "123456"), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(tx.order.updateMany).not.toHaveBeenCalled(); + }); + + it("keeps shopper contact and delivery details hidden from store owners after confirmation", async () => { + const prisma = { + order: { + findUnique: jest.fn().mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-123456", + buyerId: "buyer-1", + storeId: "store-1", + status: OrderStatus.COMPLETED, + deliveryAddress: "12 Shopper Street", + deliveryDetails: { phone: "+2348012345678" }, + deliveryPrimaryPhone: "+2348012345678", + deliverySecondaryPhone: "+2349012345678", + deliveryAddressSnapshot: { primaryPhone: "+2348012345678" }, + pickupAddressSnapshot: { formattedAddress: "Balogun, Lagos" }, + pickupStoreId: "store-1", + sourceStoreId: null, + deliveryOtp: "hashed-code", + user: { + email: "shopper@example.com", + phone: "+2348012345678", + }, + orderEvents: [], + }), + }, + }; + const service = new OrderService( + prisma as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + + const order = (await service.getById( + "order-1", + "store-owner-user", + "store-1", + )) as Record; + + expect(order.user).toBeUndefined(); + expect(order.buyerId).toBeUndefined(); + expect(order.deliveryDetails).toBeUndefined(); + expect(order.deliveryPrimaryPhone).toBeUndefined(); + expect(order.deliverySecondaryPhone).toBeUndefined(); + expect(order.deliveryAddressSnapshot).toBeUndefined(); + expect(order.pickupAddressSnapshot).toBeUndefined(); + expect(order.pickupStoreId).toBeUndefined(); + expect(order.sourceStoreId).toBeUndefined(); + expect(order.deliveryAddress).toBeNull(); + expect(order.deliveryOtp).toBeNull(); + }); +}); + +describe("OrderService server-selected delivery method", () => { + const makeService = (overrides: Record = {}) => { + const orderCreate = jest.fn( + async ({ data }: { data: Record }) => ({ + id: + data.orderType === "DROPSHIP_FULFILLMENT" + ? "fulfillment-order-1" + : "order-1", + orderCode: "TWZ-123456", + quantity: 1, + linkedOrderId: null, + ...data, + }), + ); + const tx = { + order: { + findUnique: jest.fn().mockResolvedValue(null), + create: orderCreate, + update: jest.fn( + async ({ data }: { data: Record }) => ({ + id: "order-1", + orderCode: "TWZ-123456", + buyerId: "buyer-1", + storeId: "digital-store-1", + totalAmountKobo: 251_500n, + deliveryFeeKobo: 250_000n, + platformFeeKobo: 0n, + platformFeeBearer: "MERCHANT", + ...data, + }), + ), + }, + productStockCache: { + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + inventoryEvent: { + create: jest.fn().mockResolvedValue({ id: "inventory-event-1" }), + }, + orderEvent: { + create: jest.fn().mockResolvedValue({ id: "order-event-1" }), + }, + }; + const buyer = { + id: "buyer-1", + phone: "+2348012345678", + phoneVerified: true, + displayName: "Buyer One", + firstName: "Buyer", + lastName: "One", + }; + const pickupDetails = { + formattedAddress: "Balogun Market, Lagos", + city: "Lagos Island", + state: "Lagos", + }; + const product = { + id: "product-1", + storeId: "store-1", + isActive: true, + retailPriceKobo: 1000n, + wholesalePriceKobo: null, + pricePerUnitKobo: null, + unit: "piece", + name: "Test product", + hasVariants: false, + variants: [], + productStockCaches: [{ stock: 10, variantId: null }], + storeProfile: { + id: "store-1", + userId: "store-owner-1", + verificationTier: "TIER_1", + businessName: "Balogun Store", + storeName: "Balogun Store", + storeType: StoreType.PHYSICAL, + isOpen: true, + allowDropship: true, + businessAddress: "Balogun Market, Lagos", + businessAddressDetails: pickupDetails, + }, + }; + const sourcedProduct = { + id: "sourced-product-1", + isActive: true, + sellingPriceKobo: 1500n, + floorPriceKobo: 1000n, + digitalStoreId: "digital-store-1", + physicalStoreId: "physical-store-1", + digitalStore: { + userId: "digital-store-owner", + verificationTier: "TIER_1", + storeType: StoreType.DIGITAL, + isOpen: true, + }, + physicalStore: { + id: "physical-store-1", + userId: "physical-store-owner", + businessName: "Physical Supplier", + storeName: "Physical Supplier", + storeType: StoreType.PHYSICAL, + isOpen: true, + allowDropship: true, + businessAddress: "Balogun Market, Lagos", + businessAddressDetails: pickupDetails, + }, + physicalProduct: { + ...product, + id: "physical-product-1", + storeId: "physical-store-1", + status: ProductStatus.ACTIVE, + deletedAt: null, + allowDropship: true, + dropshipperPriceKobo: 1000n, + }, + }; + const cartItem = { + id: "cart-item-1", + buyerId: "buyer-1", + productId: "product-1", + quantity: 1, + priceType: "RETAIL", + product, + }; + const prisma = { + user: { + findUnique: jest.fn().mockResolvedValue(buyer), + }, + product: { + findUnique: jest.fn().mockResolvedValue(product), + }, + sourcedProduct: { + findUnique: jest.fn().mockResolvedValue(sourcedProduct), + }, + cartItem: { + findMany: jest.fn().mockResolvedValue([cartItem]), + }, + order: { + findUnique: jest.fn().mockResolvedValue({ + id: "order-1", + user: { firstName: "Buyer", lastName: "One" }, + product: { name: "Test product" }, + }), + }, + $transaction: jest.fn((callback: (transaction: typeof tx) => unknown) => + callback(tx), + ), + ...overrides, + }; + const ledgerService = { + calculateCheckoutTotals: jest.fn( + ({ + subtotalKobo, + deliveryFeeKobo, + }: { + subtotalKobo: bigint; + deliveryFeeKobo: bigint; + }) => ({ + subtotalKobo, + deliveryFeeKobo, + totalAmountKobo: subtotalKobo + deliveryFeeKobo, + platformFeeKobo: 0n, + platformFeePercent: 0, + platformFeeBearer: "MERCHANT", + }), + ), + recordCheckoutCreated: jest.fn().mockResolvedValue(undefined), + }; + const paymentService = { + initialize: jest + .fn() + .mockResolvedValue({ authorization_url: "https://pay.test/checkout" }), + }; + const checkoutReminderQueue = { + add: jest.fn().mockResolvedValue(undefined), + }; + const domainEvents = { + append: jest.fn().mockResolvedValue({ id: "domain-event-1" }), + }; + const service = new OrderService( + prisma as never, + {} as never, + {} as never, + paymentService as never, + checkoutReminderQueue as never, + {} as never, + {} as never, + {} as never, + {} as never, + ledgerService as never, + domainEvents as never, + ); + + return { + service, + tx, + prisma, + product, + cartItem, + sourcedProduct, + ledgerService, + paymentService, + checkoutReminderQueue, + }; + }; + + it("ignores shopper-selected platform logistics for direct orders", async () => { + const { service, tx } = makeService(); + + await service.createDirectOrder("buyer-1", { + productId: "product-1", + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + deliveryMethod: DeliveryMethod.PLATFORM_LOGISTICS, + }); + + expect(tx.order.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + deliveryMethod: DeliveryMethod.STORE_DELIVERY, + deliveryFeeKobo: 250_000n, + deliveryPrimaryPhone: "+2348012345678", + deliveryAddressSnapshot: expect.objectContaining({ + formattedAddress: "20 Yaba Road, Lagos", + primaryPhone: "+2348012345678", + }), + pickupAddressSnapshot: expect.objectContaining({ + formattedAddress: "Balogun Market, Lagos", + }), + pickupStoreId: "store-1", + deliveryFeeSource: "MVP_SELECTED_ZONE", + deliveryFeeRule: "BALOGUN_TO_YABA", + pickupZone: "BALOGUN", + deliveryZone: "YABA", + }), + }); + }); + + const variantProductOverride = () => { + const variantProduct = { + id: "product-1", + storeId: "store-1", + isActive: true, + hasVariants: true, + retailPriceKobo: 1000n, + wholesalePriceKobo: null, + pricePerUnitKobo: null, + unit: "piece", + name: "Test product", + variants: [ + { + id: "variant-red-m", + isActive: true, + colorName: "Red", + sizeName: "M", + variantLabel: "Red - M", + priceOverrideKobo: 2000n, + }, + ], + productStockCaches: [{ stock: 5, variantId: "variant-red-m" }], + storeProfile: { + id: "store-1", + userId: "store-owner-1", + verificationTier: "TIER_1", + businessName: "Balogun Store", + storeName: "Balogun Store", + storeType: StoreType.PHYSICAL, + isOpen: true, + allowDropship: true, + businessAddress: "Balogun Market, Lagos", + businessAddressDetails: { + formattedAddress: "Balogun Market, Lagos", + city: "Lagos Island", + state: "Lagos", + }, + }, + }; + return { + product: { findUnique: jest.fn().mockResolvedValue(variantProduct) }, + }; + }; + + it("requires a variant when the product has variants", async () => { + const { service } = makeService(variantProductOverride()); + + await expect( + service.createDirectOrder("buyer-1", { + productId: "product-1", + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ code: "VARIANT_REQUIRED" }), + }); + }); + + it("rejects an unknown or inactive variant", async () => { + const { service } = makeService(variantProductOverride()); + + await expect( + service.createDirectOrder("buyer-1", { + productId: "product-1", + variantId: "does-not-exist", + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ code: "VARIANT_INVALID" }), + }); + }); + + it("reserves the selected variant's stock and uses its price", async () => { + const { service, tx } = makeService(variantProductOverride()); + + await service.createDirectOrder("buyer-1", { + productId: "product-1", + variantId: "variant-red-m", + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + }); + + // Stock reserved against the variant row, not the product-level row. + expect(tx.productStockCache.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + productId: "product-1", + variantId: "variant-red-m", + }), + }), + ); + // Order uses the variant's override price and snapshots the variant. + expect(tx.order.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + unitPriceKobo: 2000n, + items: expect.arrayContaining([ + expect.objectContaining({ + variantId: "variant-red-m", + variantName: "Red - M", + }), + ]), + }), + }); + }); + + it("returns the server-calculated delivery fee for direct checkout without leaking internal fields", async () => { + const { service, paymentService } = makeService(); + + const result = (await service.createDirectOrder("buyer-1", { + productId: "product-1", + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + })) as Record; + + expect(result).toMatchObject({ + orderId: "order-1", + authorizationUrl: "https://pay.test/checkout", + totalAmountKobo: 251_000n, + deliveryFeeKobo: 250_000n, + platformFeeKobo: 0n, + platformFeeBearer: "MERCHANT", + }); + expect(paymentService.initialize).toHaveBeenCalledWith( + "buyer-1", + { orderId: "order-1" }, + "init-direct-order-1", + ); + expect(result).not.toHaveProperty("deliveryAddressSnapshot"); + expect(result).not.toHaveProperty("deliveryPrimaryPhone"); + expect(result).not.toHaveProperty("deliverySecondaryPhone"); + expect(result).not.toHaveProperty("deliveryDetails"); + expect(result).not.toHaveProperty("deliveryOtp"); + }); + + it("rejects direct checkout when the stock cache is missing", async () => { + const { service, prisma, product, tx, paymentService } = makeService(); + prisma.product.findUnique.mockResolvedValueOnce({ + ...product, + productStockCaches: [], + }); + + await expect( + service.createDirectOrder("buyer-1", { + productId: "product-1", + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ message: "Insufficient stock" }), + }); + + expect(tx.productStockCache.updateMany).not.toHaveBeenCalled(); + expect(tx.order.create).not.toHaveBeenCalled(); + expect(paymentService.initialize).not.toHaveBeenCalled(); + }); + + it("does not reserve stock again when direct checkout idempotency returns an existing order", async () => { + const { service, tx } = makeService(); + tx.order.findUnique.mockResolvedValueOnce({ + id: "existing-order-1", + quantity: 1, + storeId: "store-1", + totalAmountKobo: 252_000n, + deliveryFeeKobo: 250_000n, + platformFeeKobo: 1_000n, + platformFeeBearer: "SHOPPER", + }); + + const result = await service.createDirectOrder("buyer-1", { + productId: "product-1", + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + idempotencyKey: "direct-retry-key", + }); + + expect(tx.productStockCache.updateMany).not.toHaveBeenCalled(); + expect(tx.inventoryEvent.create).not.toHaveBeenCalled(); + expect(tx.order.create).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + totalAmountKobo: 252_000n, + platformFeeBearer: "SHOPPER", + }); + }); + + it("ignores shopper-selected platform logistics for sourced orders", async () => { + const { service, tx } = makeService(); + + await service.createSourcedOrder("buyer-1", "sourced-product-1", { + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + deliveryMethod: DeliveryMethod.PLATFORM_LOGISTICS, + }); + + expect(tx.order.create).toHaveBeenNthCalledWith(1, { + data: expect.objectContaining({ + orderType: "DROPSHIP_CUSTOMER", + deliveryMethod: DeliveryMethod.STORE_DELIVERY, + deliveryFeeKobo: 250_000n, + pickupStoreId: "physical-store-1", + sourceStoreId: "physical-store-1", + pickupAddressSnapshot: expect.objectContaining({ + formattedAddress: "Balogun Market, Lagos", + }), + }), + }); + expect(tx.order.create).toHaveBeenNthCalledWith(2, { + data: expect.objectContaining({ + orderType: "DROPSHIP_FULFILLMENT", + deliveryMethod: DeliveryMethod.STORE_DELIVERY, + deliveryFeeKobo: 0n, + pickupStoreId: "physical-store-1", + sourceStoreId: "physical-store-1", + }), + }); + }); + + it("returns the server-calculated delivery fee for sourced checkout without leaking dropship internals", async () => { + const { service, paymentService } = makeService(); + + const result = (await service.createSourcedOrder( + "buyer-1", + "sourced-product-1", + { + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + }, + )) as Record; + + expect(result).toMatchObject({ + orderId: "order-1", + authorizationUrl: "https://pay.test/checkout", + totalAmountKobo: 251_500n, + deliveryFeeKobo: 250_000n, + platformFeeKobo: 0n, + platformFeeBearer: "MERCHANT", + }); + expect(paymentService.initialize).toHaveBeenCalledWith( + "buyer-1", + { orderId: "order-1" }, + "init-sourced-order-1", + ); + expect(result).not.toHaveProperty("linkedOrderId"); + expect(result).not.toHaveProperty("sourcedProductId"); + expect(result).not.toHaveProperty("dropshipperCostKobo"); + expect(result).not.toHaveProperty("digitalMarginKobo"); + expect(result).not.toHaveProperty("physicalStoreId"); + expect(result).not.toHaveProperty("physicalProductId"); + }); + + it("does not reserve stock again when sourced checkout idempotency returns an existing order", async () => { + const { service, tx } = makeService(); + tx.order.findUnique.mockResolvedValueOnce({ + id: "existing-sourced-order-1", + linkedOrderId: "fulfillment-order-1", + quantity: 1, + storeId: "digital-store-1", + totalAmountKobo: 251_500n, + }); + + await service.createSourcedOrder("buyer-1", "sourced-product-1", { + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + idempotencyKey: "sourced-retry-key", + }); + + expect(tx.productStockCache.updateMany).not.toHaveBeenCalled(); + expect(tx.inventoryEvent.create).not.toHaveBeenCalled(); + expect(tx.order.create).not.toHaveBeenCalled(); + }); + + it("rejects sourced checkout when the source store disables sourcing", async () => { + const { service, prisma, sourcedProduct } = makeService(); + prisma.sourcedProduct.findUnique.mockResolvedValueOnce({ + ...sourcedProduct, + physicalStore: { + ...sourcedProduct.physicalStore, + allowDropship: false, + }, + }); + + await expect( + service.createSourcedOrder("buyer-1", "sourced-product-1", { + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "SOURCE_PRODUCT_INACTIVE", + }), + }); + }); + + it("rejects sourced checkout when the source product disables sourcing", async () => { + const { service, prisma, sourcedProduct } = makeService(); + prisma.sourcedProduct.findUnique.mockResolvedValueOnce({ + ...sourcedProduct, + physicalProduct: { + ...sourcedProduct.physicalProduct, + allowDropship: false, + }, + }); + + await expect( + service.createSourcedOrder("buyer-1", "sourced-product-1", { + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "SOURCE_PRODUCT_INACTIVE", + }), + }); + }); + + it.each([ + [ + "source product is deleted", + (sourcedProduct: Record) => ({ + ...sourcedProduct, + physicalProduct: { + ...(sourcedProduct.physicalProduct as Record), + deletedAt: new Date("2026-06-01T00:00:00.000Z"), + }, + }), + ], + [ + "source product is no longer active", + (sourcedProduct: Record) => ({ + ...sourcedProduct, + physicalProduct: { + ...(sourcedProduct.physicalProduct as Record), + status: ProductStatus.DRAFT, + }, + }), + ], + [ + "source store is closed", + (sourcedProduct: Record) => ({ + ...sourcedProduct, + physicalStore: { + ...(sourcedProduct.physicalStore as Record), + isOpen: false, + }, + }), + ], + [ + "source store is no longer physical", + (sourcedProduct: Record) => ({ + ...sourcedProduct, + physicalStore: { + ...(sourcedProduct.physicalStore as Record), + storeType: StoreType.DIGITAL, + }, + }), + ], + [ + "digital reseller store is closed", + (sourcedProduct: Record) => ({ + ...sourcedProduct, + digitalStore: { + ...(sourcedProduct.digitalStore as Record), + isOpen: false, + }, + }), + ], + [ + "digital reseller store is no longer digital", + (sourcedProduct: Record) => ({ + ...sourcedProduct, + digitalStore: { + ...(sourcedProduct.digitalStore as Record), + storeType: StoreType.PHYSICAL, + }, + }), + ], + ])("rejects sourced checkout when %s", async (_label, mutate) => { + const { service, prisma, sourcedProduct, paymentService, tx } = + makeService(); + prisma.sourcedProduct.findUnique.mockResolvedValueOnce( + mutate(sourcedProduct), + ); + + await expect( + service.createSourcedOrder("buyer-1", "sourced-product-1", { + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "SOURCE_PRODUCT_INACTIVE", + }), + }); + expect(paymentService.initialize).not.toHaveBeenCalled(); + expect(tx.order.create).not.toHaveBeenCalled(); + }); + + it("ignores shopper-selected platform logistics for cart checkout", async () => { + const { service, tx } = makeService(); + + await service.checkoutCart("buyer-1", { + cartItemIds: ["cart-item-1"], + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + deliveryMethod: "PLATFORM_LOGISTICS", + }); + + expect(tx.order.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + deliveryMethod: DeliveryMethod.STORE_DELIVERY, + deliveryFeeKobo: 250_000n, + pickupStoreId: "store-1", + }), + }); + }); + + it("returns the server-calculated delivery fee for cart checkout without leaking private fields", async () => { + const { service, paymentService } = makeService(); + + const result = (await service.checkoutCart("buyer-1", { + cartItemIds: ["cart-item-1"], + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + })) as Record; + + expect(result).toMatchObject({ + orderId: "order-1", + authorizationUrl: "https://pay.test/checkout", + totalAmountKobo: 251_000n, + deliveryFeeKobo: 250_000n, + platformFeeKobo: 0n, + platformFeeBearer: "MERCHANT", + }); + expect(paymentService.initialize).toHaveBeenCalledWith( + "buyer-1", + { orderId: "order-1" }, + "init-cart-order-1", + ); + expect(result).not.toHaveProperty("deliveryAddressSnapshot"); + expect(result).not.toHaveProperty("deliveryPrimaryPhone"); + expect(result).not.toHaveProperty("deliverySecondaryPhone"); + expect(result).not.toHaveProperty("deliveryDetails"); + expect(result).not.toHaveProperty("deliveryOtp"); + }); + + it("rejects cart checkout when a product stock cache is missing", async () => { + const { service, prisma, cartItem, tx, paymentService } = makeService(); + prisma.cartItem.findMany.mockResolvedValueOnce([ + { + ...cartItem, + product: { + ...cartItem.product, + productStockCaches: [], + }, + }, + ]); + + await expect( + service.checkoutCart("buyer-1", { + cartItemIds: ["cart-item-1"], + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(tx.productStockCache.updateMany).not.toHaveBeenCalled(); + expect(tx.order.create).not.toHaveBeenCalled(); + expect(paymentService.initialize).not.toHaveBeenCalled(); + }); + + it("does not reserve stock again when cart checkout idempotency returns an existing order", async () => { + const { service, tx } = makeService(); + tx.order.findUnique.mockResolvedValueOnce({ + id: "existing-cart-order-1", + quantity: null, + storeId: "store-1", + totalAmountKobo: 251_000n, + }); + + await service.checkoutCart("buyer-1", { + cartItemIds: ["cart-item-1"], + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + idempotencyKey: "cart-retry-key", + }); + + expect(tx.productStockCache.updateMany).not.toHaveBeenCalled(); + expect(tx.inventoryEvent.create).not.toHaveBeenCalled(); + expect(tx.order.create).not.toHaveBeenCalled(); + }); + + it("changes cart checkout idempotency when cart quantity changes before payment", async () => { + const { service, tx, prisma, cartItem } = makeService(); + prisma.cartItem.findMany + .mockResolvedValueOnce([{ ...cartItem, quantity: 1 }]) + .mockResolvedValueOnce([{ ...cartItem, quantity: 2 }]); + + await service.checkoutCart("buyer-1", { + cartItemIds: ["cart-item-1"], + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + }); + await service.checkoutCart("buyer-1", { + cartItemIds: ["cart-item-1"], + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + }); + + const firstIdempotencyKey = + tx.order.findUnique.mock.calls[0][0].where.idempotencyKey; + const secondIdempotencyKey = + tx.order.findUnique.mock.calls[1][0].where.idempotencyKey; + + expect(secondIdempotencyKey).not.toBe(firstIdempotencyKey); + }); + + it("still creates orders when legacy store-delivery input is sent", async () => { + const { service, tx, paymentService } = makeService(); + + const result = await service.createDirectOrder("buyer-1", { + productId: "product-1", + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + deliveryMethod: DeliveryMethod.STORE_DELIVERY, + }); + + expect(tx.order.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + deliveryMethod: DeliveryMethod.STORE_DELIVERY, + }), + }); + expect(paymentService.initialize).toHaveBeenCalled(); + expect(result.authorizationUrl).toBe("https://pay.test/checkout"); + }); + + it("rejects checkout when the buyer primary phone is unverified", async () => { + const { service } = makeService({ + user: { + findUnique: jest.fn().mockResolvedValue({ + id: "buyer-1", + phone: "+2348012345678", + phoneVerified: false, + displayName: "Buyer One", + firstName: "Buyer", + lastName: "One", + }), + }, + }); + + await expect( + service.createDirectOrder("buyer-1", { + productId: "product-1", + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "DELIVERY_PRIMARY_PHONE_UNVERIFIED", + }), + }); + }); + + it("persists optional secondary delivery phone in the snapshot", async () => { + const { service, tx } = makeService(); + + await service.createDirectOrder("buyer-1", { + productId: "product-1", + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + secondaryDeliveryPhone: "09012345678", + }); + + expect(tx.order.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + deliverySecondaryPhone: "+2349012345678", + deliveryAddressSnapshot: expect.objectContaining({ + secondaryPhone: "+2349012345678", + }), + }), + }); + }); + + it("includes server-calculated delivery fee before payment initialization", async () => { + const { service, ledgerService, paymentService } = makeService(); + + await service.createDirectOrder("buyer-1", { + productId: "product-1", + quantity: 1, + deliveryAddress: "20 Yaba Road, Lagos", + deliveryDetails: { city: "Yaba", state: "Lagos" }, + }); + + expect(ledgerService.calculateCheckoutTotals).toHaveBeenCalledWith( + expect.objectContaining({ + deliveryFeeKobo: 250_000n, + }), + ); + expect(paymentService.initialize).toHaveBeenCalled(); + }); + + it("fails clearly when the dropoff zone is unsupported", async () => { + const { service } = makeService(); + + await expect( + service.createDirectOrder("buyer-1", { + productId: "product-1", + quantity: 1, + deliveryAddress: "1 Unknown Road, Ibadan", + deliveryDetails: { city: "Ibadan", state: "Oyo" }, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); + +describe("CartService checkout delivery compatibility", () => { + it("does not forward shopper-selected deliveryMethod to order checkout", async () => { + const prisma = { + cartItem: { + findMany: jest.fn().mockResolvedValue([{ id: "cart-item-1" }]), + }, + }; + const orderService = { + checkoutCart: jest.fn().mockResolvedValue({ orderId: "order-1" }), + }; + const service = new CartService(prisma as never, orderService as never); + + await service.checkout("buyer-1", { + deliveryAddress: "20 Shopper Street", + secondaryDeliveryPhone: "09012345678", + deliveryMethod: "PLATFORM_LOGISTICS", + }); + + const forwardedDto = orderService.checkoutCart.mock.calls[0][1]; + expect(forwardedDto).toEqual({ + cartItemIds: ["cart-item-1"], + deliveryAddress: "20 Shopper Street", + deliveryDetails: undefined, + secondaryDeliveryPhone: "09012345678", + idempotencyKey: undefined, + }); + expect("deliveryMethod" in forwardedDto).toBe(false); + }); +}); + +describe("OrderService WhatsApp shopper ownership lookup", () => { + const makeService = (prisma: Record) => + new OrderService( + prisma as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + + it("scopes a public order reference to the linked buyer", async () => { + const prisma = { + order: { + findFirst: jest.fn().mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-123456", + status: "PAID", + }), + }, + }; + const service = makeService(prisma); + + await service.getByCodeForBuyer("TWZ-123456", "buyer-1"); + + expect(prisma.order.findFirst).toHaveBeenCalledWith({ + where: { + orderCode: "TWZ-123456", + buyerId: "buyer-1", + orderType: { + in: [OrderType.DIRECT, OrderType.DROPSHIP_CUSTOMER], + }, + }, + select: { + id: true, + orderCode: true, + status: true, + }, + }); + }); + + it("selects the latest shopper-facing order without exposing fulfillment orders", async () => { + const prisma = { + order: { + findFirst: jest.fn().mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-123456", + status: "PAID", + }), + }, + }; + const service = makeService(prisma); + + await service.getLatestByBuyer("buyer-1"); + + expect(prisma.order.findFirst).toHaveBeenCalledWith({ + where: { + buyerId: "buyer-1", + orderType: { + in: [OrderType.DIRECT, OrderType.DROPSHIP_CUSTOMER], + }, + status: { + in: [ + OrderStatus.PAID, + OrderStatus.DISPATCHED, + OrderStatus.DELIVERED, + OrderStatus.COMPLETED, + OrderStatus.DISPUTE, + OrderStatus.REFUND_PENDING, + OrderStatus.REFUNDED, + ], + }, + }, + orderBy: { + createdAt: "desc", + }, + select: { + id: true, + orderCode: true, + status: true, + }, + }); + }); + + it("lists only buyer-owned dispatched orders with a delivery code", async () => { + const prisma = { + order: { + findMany: jest.fn().mockResolvedValue([ + { + id: "order-1", + orderCode: "TWZ-123456", + status: OrderStatus.DISPATCHED, + }, + ]), + }, + }; + const service = makeService(prisma); + + const result = await service.listDeliveryConfirmationCandidates( + "buyer-1", + 3, + ); + + expect(prisma.order.findMany).toHaveBeenCalledWith({ + where: { + buyerId: "buyer-1", + orderType: { + in: [OrderType.DIRECT, OrderType.DROPSHIP_CUSTOMER], + }, + status: { + in: [OrderStatus.DISPATCHED, OrderStatus.IN_TRANSIT], + }, + deliveryOtp: { not: null }, + }, + orderBy: { dispatchedAt: "desc" }, + take: 3, + select: { + id: true, + orderCode: true, + status: true, + }, + }); + expect(result).toEqual([ + { + id: "order-1", + orderCode: "TWZ-123456", + status: OrderStatus.DISPATCHED, + }, + ]); + }); +}); diff --git a/apps/backend/src/domains/orders/order/order.service.ts b/apps/backend/src/domains/orders/order/order.service.ts new file mode 100644 index 00000000..0e1fe767 --- /dev/null +++ b/apps/backend/src/domains/orders/order/order.service.ts @@ -0,0 +1,3202 @@ +import { + Injectable, + Logger, + NotFoundException, + BadRequestException, + ForbiddenException, + ConflictException, + Inject, + forwardRef, +} from "@nestjs/common"; +import { InjectQueue } from "@nestjs/bullmq"; +import { Queue } from "bullmq"; +import { + DeliveryMethod, + DeliveryStatus, + PlatformFeeBearer, + Prisma, + ProductStatus, + OrderType, + StoreType, +} from "@prisma/client"; +import { PrismaService } from "../../../prisma/prisma.service"; +import { NotificationTriggerService } from "../../social/notification/notification-trigger.service"; +import { InventoryService } from "../../commerce/inventory/inventory.service"; +import { PaymentService } from "../../money/payment/payment.service"; +import { + CHECKOUT_REMINDER_QUEUE, + AUTO_CONFIRM_QUEUE, + AUTO_CONFIRM_WARNING_QUEUE, +} from "../../../queue/queue.constants"; + +// Delivery is Twizrr-managed. Keep the legacy enum value that does not +// auto-queue provider booking until the internal logistics flow is wired. +const SERVER_SELECTED_DELIVERY_METHOD = DeliveryMethod.STORE_DELIVERY; +import { + InventoryEventType, + NotificationChannel, + NotificationType, + Order, + OrderStatus, + PaginatedResponse, + PriceType, +} from "@twizrr/shared"; + +import { paginate } from "../../../common/utils/pagination"; +import { validateTransition, getNextStates } from "./order-state-machine"; +import * as crypto from "crypto"; +import * as bcrypt from "bcrypt"; + +const OTP_HASH_ROUNDS = 10; +const OTP_VALIDITY_MS = 48 * 60 * 60 * 1000; +import { VerificationService } from "../../users/verification/verification.service"; +import { CreateDirectOrderDto } from "./dto/create-direct-order.dto"; +import { CreateSourcedOrderDto } from "./dto/create-sourced-order.dto"; +import { CreateTrackingDto } from "./dto/create-tracking.dto"; +import { CheckoutCartDto } from "./dto/checkout-cart.dto"; +import { + calculateMvpDeliveryFee, + DeliveryFeeQuote, + resolveMvpDeliveryZone, +} from "./delivery-fee"; + +import { PayoutService } from "../../money/payout/payout.service"; +import { LedgerService } from "../../money/ledger/ledger.service"; +import { DomainEventService } from "../../platform/domain-event/domain-event.service"; +import { CommerceOutboxService } from "../../platform/outbox/commerce-outbox.service"; + +type OrderDomainEventType = + | "ORDER_CREATED" + | "PAYMENT_CONFIRMED" + | "ORDER_READY_FOR_PICKUP" + | "ORDER_DISPATCHED" + | "DELIVERY_CONFIRMED" + | "AUTO_CONFIRMED" + | "ORDER_CANCELLED" + | "ORDER_COMPLETED" + | "DISPUTE_OPENED"; + +interface DeliveryBuyerSnapshot { + id: string; + phone: string; + phoneVerified: boolean; + displayName: string | null; + firstName: string | null; + lastName: string | null; +} + +interface StorePickupSnapshotSource { + id?: string; + storeName?: string | null; + businessName?: string | null; + businessAddress?: string | null; + businessAddressDetails?: Prisma.JsonValue | null; +} + +interface DeliverySnapshotContext { + primaryPhone: string; + secondaryPhone: string | null; + deliveryAddressSnapshot: Prisma.JsonObject; + pickupAddressSnapshot: Prisma.JsonObject; + pickupStoreId: string | null; + sourceStoreId: string | null; + feeQuote: DeliveryFeeQuote; +} + +@Injectable() +export class OrderService { + private readonly logger = new Logger(OrderService.name); + + constructor( + private prisma: PrismaService, + private notifications: NotificationTriggerService, + private inventoryService: InventoryService, + @Inject(forwardRef(() => PaymentService)) + private paymentService: PaymentService, + @InjectQueue(CHECKOUT_REMINDER_QUEUE) private checkoutReminderQueue: Queue, + @InjectQueue(AUTO_CONFIRM_QUEUE) private autoConfirmQueue: Queue, + @InjectQueue(AUTO_CONFIRM_WARNING_QUEUE) + private autoConfirmWarningQueue: Queue, + private verificationService: VerificationService, + private payoutService: PayoutService, + private ledgerService: LedgerService, + private domainEvents: DomainEventService, + private commerceOutbox?: CommerceOutboxService, + ) { + void this.autoConfirmQueue; + void this.autoConfirmWarningQueue; + } + + // CREATE BUY-NOW ORDER + + async createDirectOrder(buyerId: string, dto: CreateDirectOrderDto) { + const { productId, quantity, deliveryAddress, deliveryDetails } = dto; + const deliveryMethod = this.getServerSelectedDeliveryMethod(); + const buyer = await this.getVerifiedDeliveryBuyer(buyerId); + const product = await this.prisma.product.findUnique({ + where: { id: productId }, + include: { + productStockCaches: true, + variants: true, + storeProfile: true, + }, + }); + + if (!product) throw new NotFoundException("Product not found"); + if (!product.isActive) throw new BadRequestException("Product is inactive"); + + if (product.storeProfile?.userId === buyerId) { + throw new ForbiddenException( + "Store owners cannot purchase their own products", + ); + } + + // Resolve the selected variant. Products with variants require one; stock + // and price both come from the chosen variant, not the product aggregate. + let selectedVariant: (typeof product.variants)[number] | null = null; + if (product.hasVariants) { + if (!dto.variantId) { + throw new BadRequestException({ + message: "Please select a variant before ordering", + code: "VARIANT_REQUIRED", + }); + } + selectedVariant = + product.variants.find((v) => v.id === dto.variantId && v.isActive) ?? + null; + if (!selectedVariant) { + throw new BadRequestException({ + message: "The selected variant is unavailable", + code: "VARIANT_INVALID", + }); + } + } + const targetVariantId = selectedVariant?.id ?? null; + + const basePriceKobo = product.retailPriceKobo + ? product.retailPriceKobo + : (product.wholesalePriceKobo ?? product.pricePerUnitKobo); + const resolvedPriceKobo = + selectedVariant?.priceOverrideKobo ?? basePriceKobo; + + if (resolvedPriceKobo === null) { + throw new BadRequestException("Product price is not available"); + } + + const currentStock = + product.productStockCaches.find( + (cache) => cache.variantId === targetVariantId, + )?.stock ?? 0; + + if (currentStock < quantity) { + throw new BadRequestException("Insufficient stock"); + } + + // Determine payment method based on merchant verification tier + const merchantTier = product.storeProfile?.verificationTier; + const subtotalKobo = BigInt(resolvedPriceKobo) * BigInt(quantity); + + const deliveryContext = this.buildDeliverySnapshotContext({ + buyer, + deliveryAddress, + deliveryDetails, + secondaryDeliveryPhone: dto.secondaryDeliveryPhone, + pickupStore: product.storeProfile, + pickupStoreId: product.storeId, + sourceStoreId: null, + }); + + const totals = this.ledgerService.calculateCheckoutTotals({ + subtotalKobo, + deliveryFeeKobo: deliveryContext.feeQuote.feeKobo, + merchantTier, + }); + + const idempotencyKey = + dto.idempotencyKey || + this.generateDeterministicKey("direct", buyerId, { + productId, + variantId: targetVariantId, + quantity, + unitPriceKobo: resolvedPriceKobo.toString(), + deliveryAddress, + deliveryDetails, + secondaryDeliveryPhone: deliveryContext.secondaryPhone, + deliveryMethod, + deliveryFeeKobo: totals.deliveryFeeKobo.toString(), + }); + + // Create the order with reservation + const order = await this.prisma.$transaction(async (tx) => { + const existingOrder = await tx.order.findUnique({ + where: { idempotencyKey }, + }); + + if (existingOrder) { + return existingOrder; + } + + // 1. Atomic reservation on the selected variant's cache row (or the + // product-level row when the product has no variants). + const updateResult = await tx.productStockCache.updateMany({ + where: { + productId: product.id, + variantId: targetVariantId, + stock: { gte: quantity }, + }, + data: { + stock: { decrement: quantity }, + quantity: { decrement: quantity }, + }, + }); + + if (updateResult.count === 0) { + throw new BadRequestException("Insufficient stock"); + } + + // 2. Inventory Event + await tx.inventoryEvent.create({ + data: { + productId: product.id, + variantId: targetVariantId, + storeId: product.storeId, + eventType: InventoryEventType.ORDER_RESERVED, + quantity: -quantity, + notes: `Direct purchase reservation (buyer: ${buyerId})`, + }, + }); + + // 3. Create order after reservation succeeds. The orderCode retry + // wraps the actual insert so the DB's unique index acts as the + // collision check — no TOCTOU window between findUnique and create. + const newOrder = await this.createOrderWithUniqueCode(tx, (orderCode) => + tx.order.create({ + data: { + orderCode, + buyerId, + storeId: product.storeId, + productId: product.id, + quantity, + unitPriceKobo: resolvedPriceKobo, + items: [ + { + productId: product.id, + variantId: targetVariantId, + variantName: selectedVariant?.variantLabel ?? null, + variantAttributes: selectedVariant + ? { + color: selectedVariant.colorName, + size: selectedVariant.sizeName, + } + : null, + quantity, + unitPriceKobo: resolvedPriceKobo.toString(), + }, + ] as unknown as Prisma.InputJsonValue, + deliveryAddress, + deliveryDetails: deliveryDetails ? (deliveryDetails as any) : null, + deliveryPrimaryPhone: deliveryContext.primaryPhone, + deliverySecondaryPhone: deliveryContext.secondaryPhone, + deliveryAddressSnapshot: + deliveryContext.deliveryAddressSnapshot as Prisma.InputJsonValue, + pickupAddressSnapshot: + deliveryContext.pickupAddressSnapshot as Prisma.InputJsonValue, + pickupStoreId: deliveryContext.pickupStoreId, + sourceStoreId: deliveryContext.sourceStoreId, + deliveryFeeSource: deliveryContext.feeQuote.source, + deliveryFeeRule: deliveryContext.feeQuote.rule, + pickupZone: deliveryContext.feeQuote.pickupZone, + deliveryZone: deliveryContext.feeQuote.deliveryZone, + totalAmountKobo: totals.totalAmountKobo, + deliveryFeeKobo: totals.deliveryFeeKobo, + currency: "NGN", + status: OrderStatus.PENDING_PAYMENT, + deliveryMethod, + platformFeeKobo: totals.platformFeeKobo, + platformFeePercent: totals.platformFeePercent, + platformFeeBearer: totals.platformFeeBearer, + quoteId: null, + idempotencyKey, + payoutStatus: "PENDING", + }, + }), + ); + + // Log initial OrderEvent + const orderEvent = await tx.orderEvent.create({ + data: { + orderId: newOrder.id, + fromStatus: null, + toStatus: OrderStatus.PENDING_PAYMENT, + triggeredBy: buyerId, + metadata: { + action: "direct_purchase_created", + productId, + quantity, + }, + }, + }); + + await this.commerceOutbox?.appendOrderCreated( + tx, + newOrder, + orderEvent.id, + ); + + await this.appendOrderDomainEvent( + tx, + newOrder, + "ORDER_CREATED", + "USER", + buyerId, + { + status: OrderStatus.PENDING_PAYMENT, + action: "direct_purchase_created", + productId, + quantity, + }, + ); + + await this.ledgerService.recordCheckoutCreated( + { + orderId: newOrder.id, + buyerId, + storeId: product.storeId, + totals, + idempotencyKey, + }, + tx, + ); + + return newOrder; + }); + + this.logger.log( + `Escrow order ${order.id} created for product ${productId}`, + ); + + await this.checkoutReminderQueue.add( + "send-checkout-reminder", + { orderId: order.id }, + { + delay: 60 * 60 * 1000, + jobId: `checkout-reminder-${order.id}`, + removeOnComplete: true, + }, + ); + + // Call payment service to get the authorization URL dynamically + const paymentData = await this.paymentService.initialize( + buyerId, + { orderId: order.id }, + `init-direct-${order.id}`, + ); + + return { + orderId: order.id, + authorizationUrl: paymentData.authorization_url, + // Return the persisted snapshot, not freshly calculated totals. An + // idempotent retry may have returned an order created under the legacy + // shopper-fee policy and its payment amount must remain unchanged. + totalAmountKobo: order.totalAmountKobo, + deliveryFeeKobo: order.deliveryFeeKobo, + platformFeeKobo: order.platformFeeKobo, + platformFeeBearer: order.platformFeeBearer, + }; + } + + // ─────────────────────────────────────────────────────────────────────────── + // CREATE DROPSHIP SOURCED ORDER (creates two linked orders in one tx) + // ─────────────────────────────────────────────────────────────────────────── + // + // Shopper buys a SourcedProduct from a digital store. The system creates + // two orders, linked via Order.linkedOrderId: + // 1. DROPSHIP_CUSTOMER: shopper <-> digital store (carries escrow) + // 2. DROPSHIP_FULFILLMENT: digital store <-> physical store (wholesale) + // + // Shopper pays ONLY the customer order. Payment webhook transitions the + // fulfillment order to PAID via OrderService.transitionLinkedFulfillment. + // Stock is deducted on the PHYSICAL product/variant only — the digital + // sourced product holds no inventory of its own. + async createSourcedOrder( + buyerId: string, + sourcedProductId: string, + dto: CreateSourcedOrderDto, + ) { + const { quantity, deliveryAddress, deliveryDetails } = dto; + const deliveryMethod = this.getServerSelectedDeliveryMethod(); + const buyer = await this.getVerifiedDeliveryBuyer(buyerId); + + const sourcedProduct = await this.prisma.sourcedProduct.findUnique({ + where: { id: sourcedProductId }, + include: { + physicalProduct: { + include: { + productStockCaches: { where: { variantId: null }, take: 1 }, + storeProfile: true, + }, + }, + digitalStore: true, + physicalStore: true, + }, + }); + + if (!sourcedProduct) { + throw new NotFoundException("Sourced product not found"); + } + if (!sourcedProduct.isActive) { + throw new BadRequestException({ + message: "Sourced product is no longer available", + code: "SOURCE_PRODUCT_INACTIVE", + }); + } + if (!sourcedProduct.physicalProduct?.isActive) { + throw new BadRequestException({ + message: "Source product is unavailable", + code: "SOURCE_PRODUCT_INACTIVE", + }); + } + if ( + sourcedProduct.digitalStore.storeType !== StoreType.DIGITAL || + !sourcedProduct.digitalStore.isOpen || + sourcedProduct.physicalStore.storeType !== StoreType.PHYSICAL || + !sourcedProduct.physicalStore.isOpen || + !sourcedProduct.physicalStore.allowDropship || + sourcedProduct.physicalProduct.status !== ProductStatus.ACTIVE || + sourcedProduct.physicalProduct.deletedAt !== null || + !sourcedProduct.physicalProduct.allowDropship + ) { + throw new BadRequestException({ + message: "Source product is unavailable", + code: "SOURCE_PRODUCT_INACTIVE", + }); + } + if (sourcedProduct.digitalStore.userId === buyerId) { + throw new ForbiddenException( + "Store owners cannot purchase their own listings", + ); + } + if (sourcedProduct.physicalStore.userId === buyerId) { + throw new ForbiddenException( + "You cannot purchase a product sourced from your own store", + ); + } + + // Re-validate floor at checkout time using the CURRENT supplier floor + // on the physical product (mirrors SourcedProductService.getFloorPrice). + // sourcedProduct.floorPriceKobo is a snapshot taken at sourcing time — + // if the physical store has raised its dropshipper price since then, + // the snapshot is stale and would underpay the supplier. + const physicalProduct = sourcedProduct.physicalProduct; + const currentFloorPriceKobo = + physicalProduct.dropshipperPriceKobo ?? + physicalProduct.retailPriceKobo ?? + sourcedProduct.floorPriceKobo; + if (currentFloorPriceKobo === null || currentFloorPriceKobo === undefined) { + throw new BadRequestException({ + message: "Source product floor price is unavailable", + code: "SOURCE_PRICE_UNAVAILABLE", + }); + } + if (sourcedProduct.sellingPriceKobo < currentFloorPriceKobo) { + throw new BadRequestException({ + message: "Sourced price has fallen below the supplier floor", + code: "PRICE_BELOW_FLOOR", + }); + } + + const currentStock = physicalProduct.productStockCaches[0]?.stock ?? 0; + if (currentStock < quantity) { + throw new BadRequestException("Insufficient stock"); + } + + const sellingPriceKobo = BigInt(sourcedProduct.sellingPriceKobo); + const wholesalePriceKobo = BigInt(currentFloorPriceKobo); + const subtotalKobo = sellingPriceKobo * BigInt(quantity); + const dropshipperCostKobo = wholesalePriceKobo * BigInt(quantity); + const deliveryContext = this.buildDeliverySnapshotContext({ + buyer, + deliveryAddress, + deliveryDetails, + secondaryDeliveryPhone: dto.secondaryDeliveryPhone, + pickupStore: sourcedProduct.physicalStore, + pickupStoreId: sourcedProduct.physicalStoreId, + sourceStoreId: sourcedProduct.physicalStoreId, + }); + + // Compute totals server-side using the digital store's tier. + const merchantTier = sourcedProduct.digitalStore.verificationTier; + const totals = this.ledgerService.calculateCheckoutTotals({ + subtotalKobo, + deliveryFeeKobo: deliveryContext.feeQuote.feeKobo, + merchantTier, + }); + const digitalMarginKobo = subtotalKobo - dropshipperCostKobo; + + const idempotencyKey = + dto.idempotencyKey || + this.generateDeterministicKey("sourced", buyerId, { + sourcedProductId, + quantity, + sellingPriceKobo: sellingPriceKobo.toString(), + currentFloorPriceKobo: wholesalePriceKobo.toString(), + deliveryAddress, + deliveryDetails, + secondaryDeliveryPhone: deliveryContext.secondaryPhone, + deliveryMethod, + deliveryFeeKobo: totals.deliveryFeeKobo.toString(), + }); + const fulfillmentIdempotencyKey = `${idempotencyKey}:fulfillment`; + + const itemsSnapshot = [ + { + productId: physicalProduct.id, + sourcedProductId: sourcedProduct.id, + variantId: null, + variantName: null, + variantAttributes: null, + quantity, + unitPriceKobo: sellingPriceKobo.toString(), + }, + ]; + + const fulfillmentItemsSnapshot = [ + { + productId: physicalProduct.id, + sourcedProductId: sourcedProduct.id, + variantId: null, + variantName: null, + variantAttributes: null, + quantity, + unitPriceKobo: wholesalePriceKobo.toString(), + }, + ]; + + const customerOrder = await this.prisma.$transaction(async (tx) => { + // Idempotency: if customer order already exists for this key, return it. + // The fulfillment order created on the original attempt is reachable + // via existing.linkedOrderId, so no second pair is created. + const existing = await tx.order.findUnique({ + where: { idempotencyKey }, + }); + if (existing) { + return existing; + } + + // 1. Atomic stock reservation on the physical product cache. + // Missing cache (count === 0) is treated as unavailable — never + // synthesised. This mirrors the createDirectOrder discipline. + const stockUpdate = await tx.productStockCache.updateMany({ + where: { + productId: physicalProduct.id, + variantId: null, + stock: { gte: quantity }, + }, + data: { + stock: { decrement: quantity }, + quantity: { decrement: quantity }, + }, + }); + if (stockUpdate.count === 0) { + throw new BadRequestException("Insufficient stock"); + } + + // 2. InventoryEvent on the physical product. + await tx.inventoryEvent.create({ + data: { + productId: physicalProduct.id, + variantId: null, + storeId: physicalProduct.storeId, + eventType: InventoryEventType.ORDER_RESERVED, + quantity: -quantity, + notes: `Dropship reservation (buyer: ${buyerId}, sourced: ${sourcedProduct.id})`, + }, + }); + + // 3. Create the customer-facing order first (so the fulfillment + // order can reference it via linkedOrderId). + const newCustomerOrder = await this.createOrderWithUniqueCode( + tx, + (orderCode) => + tx.order.create({ + data: { + orderCode, + orderType: "DROPSHIP_CUSTOMER", + buyerId, + storeId: sourcedProduct.digitalStoreId, + productId: physicalProduct.id, + sourcedProductId: sourcedProduct.id, + quantity, + unitPriceKobo: sellingPriceKobo, + deliveryAddress, + deliveryDetails: deliveryDetails + ? (deliveryDetails as Prisma.InputJsonValue) + : Prisma.DbNull, + deliveryPrimaryPhone: deliveryContext.primaryPhone, + deliverySecondaryPhone: deliveryContext.secondaryPhone, + deliveryAddressSnapshot: + deliveryContext.deliveryAddressSnapshot as Prisma.InputJsonValue, + pickupAddressSnapshot: + deliveryContext.pickupAddressSnapshot as Prisma.InputJsonValue, + pickupStoreId: deliveryContext.pickupStoreId, + sourceStoreId: deliveryContext.sourceStoreId, + deliveryFeeSource: deliveryContext.feeQuote.source, + deliveryFeeRule: deliveryContext.feeQuote.rule, + pickupZone: deliveryContext.feeQuote.pickupZone, + deliveryZone: deliveryContext.feeQuote.deliveryZone, + totalAmountKobo: totals.totalAmountKobo, + deliveryFeeKobo: totals.deliveryFeeKobo, + currency: "NGN", + status: OrderStatus.PENDING_PAYMENT, + deliveryMethod, + platformFeeKobo: totals.platformFeeKobo, + platformFeePercent: totals.platformFeePercent, + platformFeeBearer: totals.platformFeeBearer, + dropshipperCostKobo, + digitalMarginKobo, + quoteId: null, + idempotencyKey, + payoutStatus: "PENDING", + items: itemsSnapshot as unknown as Prisma.InputJsonValue, + }, + }), + ); + + // 4. Create the fulfillment order. buyerId is the digital store + // owner's userId (per the dropship semantics: the digital store is + // the wholesale buyer from the physical store). storeId is the + // physical store. The fulfillment order carries the wholesale + // amount only — zero platform fee, zero delivery fee. + const newFulfillmentOrder = await this.createOrderWithUniqueCode( + tx, + (orderCode) => + tx.order.create({ + data: { + orderCode, + orderType: "DROPSHIP_FULFILLMENT", + buyerId: sourcedProduct.digitalStore.userId, + storeId: sourcedProduct.physicalStoreId, + productId: physicalProduct.id, + sourcedProductId: sourcedProduct.id, + quantity, + unitPriceKobo: wholesalePriceKobo, + deliveryAddress, + deliveryDetails: deliveryDetails + ? (deliveryDetails as Prisma.InputJsonValue) + : Prisma.DbNull, + deliveryPrimaryPhone: deliveryContext.primaryPhone, + deliverySecondaryPhone: deliveryContext.secondaryPhone, + deliveryAddressSnapshot: + deliveryContext.deliveryAddressSnapshot as Prisma.InputJsonValue, + pickupAddressSnapshot: + deliveryContext.pickupAddressSnapshot as Prisma.InputJsonValue, + pickupStoreId: deliveryContext.pickupStoreId, + sourceStoreId: deliveryContext.sourceStoreId, + deliveryFeeSource: deliveryContext.feeQuote.source, + deliveryFeeRule: deliveryContext.feeQuote.rule, + pickupZone: deliveryContext.feeQuote.pickupZone, + deliveryZone: deliveryContext.feeQuote.deliveryZone, + totalAmountKobo: dropshipperCostKobo, + deliveryFeeKobo: 0n, + currency: "NGN", + status: OrderStatus.PENDING_PAYMENT, + deliveryMethod, + platformFeeKobo: 0n, + platformFeePercent: 0, + platformFeeBearer: PlatformFeeBearer.MERCHANT, + linkedOrderId: newCustomerOrder.id, + quoteId: null, + idempotencyKey: fulfillmentIdempotencyKey, + payoutStatus: "PENDING", + items: + fulfillmentItemsSnapshot as unknown as Prisma.InputJsonValue, + }, + }), + ); + + // 5. Back-link the customer order to the fulfillment order. + const linkedCustomerOrder = await tx.order.update({ + where: { id: newCustomerOrder.id }, + data: { linkedOrderId: newFulfillmentOrder.id }, + }); + + // 6. Audit trail for both orders. + const customerOrderEvent = await tx.orderEvent.create({ + data: { + orderId: linkedCustomerOrder.id, + fromStatus: null, + toStatus: OrderStatus.PENDING_PAYMENT, + triggeredBy: buyerId, + metadata: { + action: "dropship_customer_order_created", + sourcedProductId, + quantity, + linkedFulfillmentOrderId: newFulfillmentOrder.id, + }, + }, + }); + const fulfillmentOrderEvent = await tx.orderEvent.create({ + data: { + orderId: newFulfillmentOrder.id, + fromStatus: null, + toStatus: OrderStatus.PENDING_PAYMENT, + triggeredBy: buyerId, + metadata: { + action: "dropship_fulfillment_order_created", + sourcedProductId, + quantity, + linkedCustomerOrderId: linkedCustomerOrder.id, + }, + }, + }); + + await this.commerceOutbox?.appendOrderCreated( + tx, + linkedCustomerOrder, + customerOrderEvent.id, + ); + await this.commerceOutbox?.appendOrderCreated( + tx, + newFulfillmentOrder, + fulfillmentOrderEvent.id, + ); + + await this.appendOrderDomainEvent( + tx, + linkedCustomerOrder, + "ORDER_CREATED", + "USER", + buyerId, + { + status: OrderStatus.PENDING_PAYMENT, + action: "dropship_customer_order_created", + sourcedProductId, + quantity, + }, + ); + await this.appendOrderDomainEvent( + tx, + newFulfillmentOrder, + "ORDER_CREATED", + "USER", + buyerId, + { + status: OrderStatus.PENDING_PAYMENT, + action: "dropship_fulfillment_order_created", + sourcedProductId, + quantity, + customerOrderId: linkedCustomerOrder.id, + }, + ); + + // 7. Ledger entry for the customer order (the escrow inflow). + // The fulfillment order does not take additional money from escrow; + // its payout is funded from the customer order's escrow on completion. + await this.ledgerService.recordCheckoutCreated( + { + orderId: linkedCustomerOrder.id, + buyerId, + storeId: sourcedProduct.digitalStoreId, + totals, + idempotencyKey, + }, + tx, + ); + + return linkedCustomerOrder; + }); + + await this.checkoutReminderQueue.add( + "send-checkout-reminder", + { orderId: customerOrder.id }, + { + delay: 60 * 60 * 1000, + jobId: `checkout-reminder-${customerOrder.id}`, + removeOnComplete: true, + }, + ); + + const paymentData = await this.paymentService.initialize( + buyerId, + { orderId: customerOrder.id }, + `init-sourced-${customerOrder.id}`, + ); + + this.logger.log( + `Dropship order ${customerOrder.id} (linked fulfillment ${customerOrder.linkedOrderId}) created for sourced ${sourcedProductId}`, + ); + + return { + orderId: customerOrder.id, + authorizationUrl: paymentData.authorization_url, + totalAmountKobo: customerOrder.totalAmountKobo, + deliveryFeeKobo: customerOrder.deliveryFeeKobo, + platformFeeKobo: customerOrder.platformFeeKobo, + platformFeeBearer: customerOrder.platformFeeBearer, + }; + } + + // ─────────────────────────────────────────────────────────────────────────── + // CHECKOUT CART (B2C MULTI-ITEM CHECKOUT) + // ─────────────────────────────────────────────────────────────────────────── + + async checkoutCart(buyerId: string, dto: CheckoutCartDto) { + const { cartItemIds, deliveryAddress, deliveryDetails } = dto; + const deliveryMethod = this.getServerSelectedDeliveryMethod(); + const buyer = await this.getVerifiedDeliveryBuyer(buyerId); + + if (!cartItemIds || cartItemIds.length === 0) { + throw new BadRequestException("No cart items provided for checkout."); + } + + // Fetch the cart items with products and ensuring they belong to the buyer + const items = await this.prisma.cartItem.findMany({ + where: { + id: { in: cartItemIds }, + buyerId: buyerId, + }, + include: { + product: { + include: { + storeProfile: true, + productStockCaches: { where: { variantId: null }, take: 1 }, + }, + }, + }, + }); + + if (items.length !== cartItemIds.length) { + throw new BadRequestException( + "One or more cart items are invalid or do not belong to you.", + ); + } + + // Ensure all items are from the same store because escrow payouts are 1-to-1 order-to-store + const storeIds = new Set(items.map((item) => item.product.storeId)); + if (storeIds.size > 1) { + throw new BadRequestException( + "Cart checkout is restricted to one store per order. Please split your checkout.", + ); + } + + const storeId = items[0].product.storeId; + const storeProfile = items[0].product.storeProfile; + if (!storeProfile) { + throw new BadRequestException("Store profile not found for these items."); + } + + if (storeProfile.userId === buyerId) { + throw new ForbiddenException( + "Store owners cannot purchase their own products", + ); + } + + let subtotalKobo = 0n; + const jsonItems: any[] = []; + + // Validations and calculations + for (const item of items) { + const product = item.product; + if (!product.isActive || product.deletedAt) { + throw new BadRequestException( + `Product ${product.name} is no longer available.`, + ); + } + + // Logic: Retail vs Wholesale price selection based on item.priceType + const isWholesale = item.priceType === PriceType.WHOLESALE; + const minQty = isWholesale + ? product.minOrderQuantity + : product.minOrderQuantityConsumer; + + const currentStock = product.productStockCaches[0]?.stock ?? 0; + + if (currentStock < item.quantity || item.quantity < minQty) { + throw new BadRequestException( + `Insufficient stock or quantity for ${product.name} is below the minimum for ${item.priceType} (${minQty})`, + ); + } + + const resolvedPriceKobo = isWholesale + ? (product.wholesalePriceKobo ?? product.pricePerUnitKobo ?? 0n) + : (product.retailPriceKobo ?? product.pricePerUnitKobo ?? 0n); + + if (resolvedPriceKobo === 0n) { + throw new BadRequestException( + `Product ${product.name} does not have a valid ${item.priceType} price.`, + ); + } + + subtotalKobo += BigInt(resolvedPriceKobo) * BigInt(item.quantity); + + jsonItems.push({ + productId: product.id, + quantity: item.quantity, + unitPriceKobo: resolvedPriceKobo.toString(), + name: product.name, + priceType: item.priceType, + }); + } + + // Determine payment method and platform fee + const merchantTier = storeProfile.verificationTier; + const deliveryContext = this.buildDeliverySnapshotContext({ + buyer, + deliveryAddress, + deliveryDetails, + secondaryDeliveryPhone: dto.secondaryDeliveryPhone, + pickupStore: storeProfile, + pickupStoreId: storeId, + sourceStoreId: null, + }); + + const totals = this.ledgerService.calculateCheckoutTotals({ + subtotalKobo, + deliveryFeeKobo: deliveryContext.feeQuote.feeKobo, + merchantTier, + }); + + const idempotencyKey = + dto.idempotencyKey || + this.generateDeterministicKey("cart", buyerId, { + cartItems: jsonItems + .map((item) => ({ + productId: item.productId, + quantity: item.quantity, + priceType: item.priceType, + unitPriceKobo: item.unitPriceKobo, + })) + .sort((a, b) => a.productId.localeCompare(b.productId)), + deliveryAddress, + deliveryDetails, + secondaryDeliveryPhone: deliveryContext.secondaryPhone, + deliveryMethod, + deliveryFeeKobo: totals.deliveryFeeKobo.toString(), + }); + + // Create the order atomically with inventory reservation + const order = await this.prisma.$transaction(async (tx) => { + const existingOrder = await tx.order.findUnique({ + where: { idempotencyKey }, + }); + + if (existingOrder) { + return existingOrder; + } + + // 1. Atomic: check and decrement stock + for (const item of items) { + const updateResult = await tx.productStockCache.updateMany({ + where: { + productId: item.productId, + variantId: null, + stock: { gte: item.quantity }, + }, + data: { + stock: { decrement: item.quantity }, + quantity: { decrement: item.quantity }, + }, + }); + + if (updateResult.count === 0) { + throw new BadRequestException( + `Insufficient stock for ${item.product.name}.`, + ); + } + + // Log inventory event + await tx.inventoryEvent.create({ + data: { + productId: item.productId, + variantId: null, + storeId: storeId as string, + eventType: InventoryEventType.ORDER_RESERVED, + quantity: -item.quantity, + notes: `Reserved for cart checkout (buyer: ${buyerId})`, + }, + }); + } + + // 2. Create the order after all reservations succeed. The orderCode + // retry wraps the actual insert so the DB's unique index acts as + // the collision check (race-safe). + const newOrder = await this.createOrderWithUniqueCode(tx, (orderCode) => + tx.order.create({ + data: { + orderCode, + buyerId, + storeId, + deliveryAddress, + deliveryDetails: deliveryDetails ? (deliveryDetails as any) : null, + deliveryPrimaryPhone: deliveryContext.primaryPhone, + deliverySecondaryPhone: deliveryContext.secondaryPhone, + deliveryAddressSnapshot: + deliveryContext.deliveryAddressSnapshot as Prisma.InputJsonValue, + pickupAddressSnapshot: + deliveryContext.pickupAddressSnapshot as Prisma.InputJsonValue, + pickupStoreId: deliveryContext.pickupStoreId, + sourceStoreId: deliveryContext.sourceStoreId, + deliveryFeeSource: deliveryContext.feeQuote.source, + deliveryFeeRule: deliveryContext.feeQuote.rule, + pickupZone: deliveryContext.feeQuote.pickupZone, + deliveryZone: deliveryContext.feeQuote.deliveryZone, + totalAmountKobo: totals.totalAmountKobo, + deliveryFeeKobo: totals.deliveryFeeKobo, + currency: "NGN", + status: OrderStatus.PENDING_PAYMENT, + deliveryMethod: deliveryMethod as any, + platformFeeKobo: totals.platformFeeKobo, + platformFeePercent: totals.platformFeePercent, + platformFeeBearer: totals.platformFeeBearer, + items: jsonItems, + quoteId: null, + idempotencyKey, + payoutStatus: "PENDING", + }, + }), + ); + + // 3. Create initial order event (audit trail) + const orderEvent = await tx.orderEvent.create({ + data: { + orderId: newOrder.id, + toStatus: OrderStatus.PENDING_PAYMENT, + triggeredBy: buyerId, + metadata: { action: "cart_checkout", items: jsonItems }, + }, + }); + + await this.commerceOutbox?.appendOrderCreated( + tx, + newOrder, + orderEvent.id, + ); + + await this.appendOrderDomainEvent( + tx, + newOrder, + "ORDER_CREATED", + "USER", + buyerId, + { + status: OrderStatus.PENDING_PAYMENT, + action: "cart_checkout", + itemCount: items.length, + }, + ); + + await this.ledgerService.recordCheckoutCreated( + { + orderId: newOrder.id, + buyerId, + storeId, + totals, + idempotencyKey, + }, + tx, + ); + + // 4. NOTE: Cart items are NOT cleared here anymore. + // They will be cleared in the webhook handler after payment succeeds. + // This prevents data loss when buyers abandon payment. + + return newOrder; + }); + + await this.checkoutReminderQueue.add( + "send-checkout-reminder", + { orderId: order.id }, + { + delay: 60 * 60 * 1000, + jobId: `checkout-reminder-${order.id}`, + removeOnComplete: true, + }, + ); + + // Generate Payment Link + const paymentData = await this.paymentService.initialize( + buyerId, + { orderId: order.id }, + `init-cart-${order.id}`, + ); + + return { + orderId: order.id, + authorizationUrl: paymentData.authorization_url, + totalAmountKobo: order.totalAmountKobo, + deliveryFeeKobo: order.deliveryFeeKobo, + platformFeeKobo: order.platformFeeKobo, + platformFeeBearer: order.platformFeeBearer, + }; + } + + // ────────────────────────────────────────────── + // GENERIC STATE TRANSITION (audit + validate) + // ────────────────────────────────────────────── + + private async appendOrderDomainEvent( + tx: Prisma.TransactionClient, + order: { + id: string; + orderCode?: string | null; + buyerId: string; + storeId?: string | null; + totalAmountKobo?: bigint | number | null; + platformFeeKobo?: bigint | number | null; + platformFeeBearer?: PlatformFeeBearer | null; + }, + eventType: OrderDomainEventType, + actorType: "USER" | "SYSTEM" | "ADMIN", + actorId: string | null, + metadata: Record = {}, + idempotencyKey?: string, + ) { + await this.domainEvents.append(tx, { + aggregateType: "ORDER", + aggregateId: order.id, + eventType, + actorType, + actorId, + source: "order.service", + metadata: { + orderId: order.id, + orderCode: order.orderCode, + buyerId: order.buyerId, + storeId: order.storeId, + totalAmountKobo: order.totalAmountKobo?.toString() ?? null, + platformFeeKobo: order.platformFeeKobo?.toString() ?? null, + platformFeeBearer: order.platformFeeBearer ?? null, + ...metadata, + }, + idempotencyKey: + idempotencyKey ?? `order:${order.id}:${eventType.toLowerCase()}`, + }); + } + + private getServerSelectedDeliveryMethod(): DeliveryMethod { + return SERVER_SELECTED_DELIVERY_METHOD; + } + + private async getVerifiedDeliveryBuyer( + buyerId: string, + ): Promise { + const buyer = await this.prisma.user.findUnique({ + where: { id: buyerId }, + select: { + id: true, + phone: true, + phoneVerified: true, + displayName: true, + firstName: true, + lastName: true, + }, + }); + + if (!buyer) { + throw new NotFoundException({ + message: "Buyer not found", + code: "BUYER_NOT_FOUND", + }); + } + + if (!buyer.phone || !buyer.phoneVerified) { + throw new BadRequestException({ + message: "Verify your account phone before checkout.", + code: "DELIVERY_PRIMARY_PHONE_UNVERIFIED", + }); + } + + return buyer; + } + + private buildDeliverySnapshotContext(input: { + buyer: DeliveryBuyerSnapshot; + deliveryAddress: string; + deliveryDetails?: Record; + secondaryDeliveryPhone?: string; + pickupStore: StorePickupSnapshotSource | null; + pickupStoreId: string | null; + sourceStoreId: string | null; + }): DeliverySnapshotContext { + const deliveryAddressSnapshot = this.toDeliveryAddressSnapshot( + input.deliveryAddress, + input.deliveryDetails, + input.buyer, + input.secondaryDeliveryPhone, + ); + const pickupAddressSnapshot = this.toPickupAddressSnapshot( + input.pickupStore, + input.pickupStoreId, + ); + const pickupZone = resolveMvpDeliveryZone(pickupAddressSnapshot); + const deliveryZone = resolveMvpDeliveryZone(deliveryAddressSnapshot); + + if (!pickupZone || !deliveryZone) { + throw new BadRequestException({ + message: + "Delivery is not available for this pickup or dropoff zone yet.", + code: "DELIVERY_ZONE_UNSUPPORTED", + }); + } + + const feeQuote = calculateMvpDeliveryFee({ pickupZone, deliveryZone }); + if (!feeQuote) { + throw new BadRequestException({ + message: "Delivery is not available for this route yet.", + code: "DELIVERY_ROUTE_UNSUPPORTED", + }); + } + + return { + primaryPhone: input.buyer.phone, + secondaryPhone: + this.readString(deliveryAddressSnapshot, "secondaryPhone") ?? null, + deliveryAddressSnapshot, + pickupAddressSnapshot, + pickupStoreId: input.pickupStoreId, + sourceStoreId: input.sourceStoreId, + feeQuote, + }; + } + + private toDeliveryAddressSnapshot( + deliveryAddress: string, + deliveryDetails: Record | undefined, + buyer: DeliveryBuyerSnapshot, + secondaryDeliveryPhone?: string, + ): Prisma.JsonObject { + const details = this.toRecord(deliveryDetails); + const secondaryPhone = this.normalizeOptionalDeliveryPhone( + secondaryDeliveryPhone || + this.readString(details, "secondaryPhone") || + this.readString(details, "altPhone"), + ); + const recipientName = + this.readString(details, "recipientName") || + this.readString(details, "name") || + buyer.displayName || + `${buyer.firstName ?? ""} ${buyer.lastName ?? ""}`.trim() || + "Shopper"; + + return this.compactJson({ + label: this.readString(details, "label"), + recipientName, + formattedAddress: + this.readString(details, "formattedAddress") || deliveryAddress, + street: this.readString(details, "street") || deliveryAddress, + line2: this.readString(details, "line2"), + city: this.readString(details, "city"), + state: this.readString(details, "state"), + postalCode: this.readString(details, "postalCode"), + landmark: + this.readString(details, "landmark") || + this.readString(details, "note"), + primaryPhone: buyer.phone, + secondaryPhone, + }); + } + + private toPickupAddressSnapshot( + store: StorePickupSnapshotSource | null, + storeId: string | null, + ): Prisma.JsonObject { + const details = this.toRecord(store?.businessAddressDetails); + const formattedAddress = + this.readString(details, "formattedAddress") || store?.businessAddress; + + if (!formattedAddress) { + throw new BadRequestException({ + message: "Store pickup address is required before checkout.", + code: "DELIVERY_PICKUP_ADDRESS_REQUIRED", + }); + } + + return this.compactJson({ + storeId, + storeName: store?.storeName || store?.businessName, + formattedAddress, + addressLine2: this.readString(details, "addressLine2"), + city: this.readString(details, "city"), + state: this.readString(details, "state"), + postalCode: this.readString(details, "postalCode"), + latitude: this.readNumber(details, "latitude"), + longitude: this.readNumber(details, "longitude"), + }); + } + + private normalizeOptionalDeliveryPhone(value?: string): string | null { + if (!value) return null; + const compact = value.replace(/[\s\-()]/g, ""); + let normalized = compact; + + if (/^0[789]\d{9}$/.test(compact)) { + normalized = `+234${compact.slice(1)}`; + } else if (/^[789]\d{9}$/.test(compact)) { + normalized = `+234${compact}`; + } else if (/^234[789]\d{9}$/.test(compact)) { + normalized = `+${compact}`; + } + + if (!/^\+234[789]\d{9}$/.test(normalized)) { + throw new BadRequestException({ + message: + "Secondary delivery phone must be a valid Nigerian mobile number.", + code: "INVALID_SECONDARY_DELIVERY_PHONE", + }); + } + + return normalized; + } + + private toRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + } + + private readString( + record: Record, + key: string, + ): string | undefined { + const value = record[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; + } + + private readNumber( + record: Record, + key: string, + ): number | undefined { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; + } + + private compactJson(record: Record): Prisma.JsonObject { + return Object.fromEntries( + Object.entries(record).filter(([, value]) => value !== undefined), + ) as Prisma.JsonObject; + } + + private getTransitionDomainEventType( + toStatus: OrderStatus, + metadata?: Record, + ): OrderDomainEventType | null { + const action = + typeof metadata?.action === "string" ? metadata.action : undefined; + + if (toStatus === OrderStatus.PAID) return "PAYMENT_CONFIRMED"; + if (toStatus === OrderStatus.READY_FOR_PICKUP) { + return "ORDER_READY_FOR_PICKUP"; + } + if (toStatus === OrderStatus.DISPATCHED) return "ORDER_DISPATCHED"; + if (toStatus === OrderStatus.DELIVERED) { + return action === "system_auto_confirm" + ? "AUTO_CONFIRMED" + : "DELIVERY_CONFIRMED"; + } + if (toStatus === OrderStatus.CANCELLED) return "ORDER_CANCELLED"; + if (toStatus === OrderStatus.COMPLETED) return "ORDER_COMPLETED"; + if (toStatus === OrderStatus.DISPUTE) return "DISPUTE_OPENED"; + + return null; + } + + private async transition( + orderId: string, + fromStatus: OrderStatus, + toStatus: OrderStatus, + triggeredBy: string, + metadata?: Record, + updateData?: Prisma.OrderUpdateManyMutationInput, + ) { + if (!validateTransition(fromStatus, toStatus)) { + throw new BadRequestException( + `Invalid state transition from ${fromStatus} to ${toStatus}`, + ); + } + + const updatedOrder = await this.prisma.$transaction(async (tx) => { + const { count } = await tx.order.updateMany({ + where: { id: orderId, status: fromStatus }, + data: { ...(updateData ?? {}), status: toStatus }, + }); + + if (count !== 1) { + throw new ConflictException({ + message: `Order ${orderId} is no longer in ${fromStatus} state`, + code: "ORDER_STATE_CONFLICT", + }); + } + + const fresh = await tx.order.findUniqueOrThrow({ + where: { id: orderId }, + }); + + const orderEvent = await tx.orderEvent.create({ + data: { + orderId, + fromStatus, + toStatus, + triggeredBy, + metadata: metadata || {}, + }, + }); + + await this.commerceOutbox?.appendOrderUpdated( + tx, + fresh, + orderEvent.id, + fromStatus, + ); + + const domainEventType = this.getTransitionDomainEventType( + toStatus, + metadata, + ); + if (domainEventType) { + await this.appendOrderDomainEvent( + tx, + fresh, + domainEventType, + "USER", + triggeredBy, + { + fromStatus, + toStatus, + ...(metadata || {}), + }, + ); + } + + return fresh; + }); + + this.logger.log(`Order ${orderId}: ${fromStatus} -> ${toStatus}`); + return updatedOrder; + } + + // ────────────────────────────────────────────── + // SYSTEM-DRIVEN TRANSITION (e.g., payment webhook) + // ────────────────────────────────────────────── + + async transitionBySystem( + orderId: string, + fromStatus: OrderStatus, + toStatus: OrderStatus, + metadata?: Record, + ) { + if (!validateTransition(fromStatus, toStatus)) { + throw new BadRequestException( + `Invalid state transition from ${fromStatus} to ${toStatus}`, + ); + } + + const updatedOrder = await this.prisma.$transaction((tx) => + this.transitionBySystemInTransaction( + tx, + orderId, + fromStatus, + toStatus, + metadata, + ), + ); + + this.logger.log(`Order ${orderId}: ${fromStatus} -> ${toStatus} (system)`); + return updatedOrder; + } + + async transitionBySystemInTransaction( + tx: Prisma.TransactionClient, + orderId: string, + fromStatus: OrderStatus, + toStatus: OrderStatus, + metadata?: Record, + ) { + if (!validateTransition(fromStatus, toStatus)) { + throw new BadRequestException( + `Invalid state transition from ${fromStatus} to ${toStatus}`, + ); + } + + const { count } = await tx.order.updateMany({ + where: { id: orderId, status: fromStatus }, + data: { status: toStatus }, + }); + + if (count !== 1) { + const existing = await tx.order.findUnique({ + where: { id: orderId }, + select: { id: true }, + }); + if (!existing) throw new NotFoundException("Order not found"); + + throw new ConflictException({ + message: `Order ${orderId} is no longer in ${fromStatus} state`, + code: "ORDER_STATE_CONFLICT", + }); + } + + const fresh = await tx.order.findUniqueOrThrow({ + where: { id: orderId }, + }); + + const eventMetadata = { ...(metadata || {}), triggeredBySystem: true }; + const orderEvent = await tx.orderEvent.create({ + data: { + orderId, + fromStatus, + toStatus, + triggeredBy: fresh.buyerId, + metadata: eventMetadata, + }, + }); + + await this.commerceOutbox?.appendOrderUpdated( + tx, + fresh, + orderEvent.id, + fromStatus, + ); + + const domainEventType = this.getTransitionDomainEventType( + toStatus, + eventMetadata, + ); + if (domainEventType) { + await this.appendOrderDomainEvent( + tx, + fresh, + domainEventType, + "SYSTEM", + null, + { + fromStatus, + toStatus, + ...eventMetadata, + }, + metadata?.paymentId + ? `order:${orderId}:${domainEventType.toLowerCase()}:${metadata.paymentId}` + : undefined, + ); + } + + return fresh; + } + + // After a DROPSHIP_CUSTOMER order transitions PENDING_PAYMENT -> PAID, + // mirror that transition onto its linked DROPSHIP_FULFILLMENT order so + // the physical store sees a paid fulfillment ready to dispatch. No-op + // for direct orders. Safe to call repeatedly: the atomic updateMany + // condition on { status: PENDING_PAYMENT } guarantees idempotency. + async transitionLinkedFulfillment( + customerOrderId: string, + fromStatus: OrderStatus, + toStatus: OrderStatus, + metadata?: Record, + ): Promise { + const customer = await this.prisma.order.findUnique({ + where: { id: customerOrderId }, + select: { + orderType: true, + linkedOrderId: true, + }, + }); + if (!customer) return; + if (customer.orderType !== "DROPSHIP_CUSTOMER") return; + if (!customer.linkedOrderId) { + this.logger.warn( + `DROPSHIP_CUSTOMER order ${customerOrderId} has no linked fulfillment order to transition`, + ); + return; + } + + try { + await this.transitionBySystem( + customer.linkedOrderId, + fromStatus, + toStatus, + { + ...(metadata || {}), + action: "linked_fulfillment_transition", + customerOrderId, + }, + ); + } catch (error) { + // Don't break the customer-order flow if the fulfillment transition + // races with another caller. The conflict is logged and the + // fulfillment order can be reconciled by an OPERATOR. + if (error instanceof ConflictException) { + this.logger.warn( + `Linked fulfillment ${customer.linkedOrderId} already past ${fromStatus} — skipping`, + ); + return; + } + throw error; + } + } + + // After a DROPSHIP_CUSTOMER order completes (shopper confirmed delivery + // or auto-confirm fired), finalise the linked DROPSHIP_FULFILLMENT order + // and queue its payout to the physical store. The fulfillment lifecycle + // is driven by the customer order, not by the physical store + // independently — once the shopper has the package, the wholesale leg is + // also done. Uses a status-gated updateMany for atomicity; no-op if the + // fulfillment row is already COMPLETED or CANCELLED. + async completeLinkedFulfillment(customerOrderId: string): Promise { + const customer = await this.prisma.order.findUnique({ + where: { id: customerOrderId }, + select: { orderType: true, linkedOrderId: true, buyerId: true }, + }); + if (!customer) return; + if (customer.orderType !== "DROPSHIP_CUSTOMER") return; + if (!customer.linkedOrderId) return; + + const fulfillmentId = customer.linkedOrderId; + const completed = await this.prisma.$transaction(async (tx) => { + const { count } = await tx.order.updateMany({ + where: { + id: fulfillmentId, + status: { notIn: [OrderStatus.COMPLETED, OrderStatus.CANCELLED] }, + }, + data: { status: OrderStatus.COMPLETED }, + }); + + if (count !== 1) { + return false; + } + + const fresh = await tx.order.findUniqueOrThrow({ + where: { id: fulfillmentId }, + }); + + await tx.orderEvent.create({ + data: { + orderId: fulfillmentId, + fromStatus: null, + toStatus: OrderStatus.COMPLETED, + triggeredBy: customer.buyerId, + metadata: { + action: "linked_fulfillment_completed", + customerOrderId, + triggeredBySystem: true, + }, + }, + }); + + await this.appendOrderDomainEvent( + tx, + fresh, + "ORDER_COMPLETED", + "SYSTEM", + null, + { + action: "linked_fulfillment_completed", + customerOrderId, + triggeredBySystem: true, + }, + ); + + return true; + }); + + if (completed) { + this.logger.log( + `Linked fulfillment ${fulfillmentId} marked COMPLETED via customer order ${customerOrderId}`, + ); + } else { + this.logger.warn( + `Linked fulfillment ${fulfillmentId} already terminal - skipping completion`, + ); + } + + // Queue the physical store's payout regardless of whether the row was + // just updated (queuePayout is idempotent via stable jobId). + try { + await this.payoutService.queuePayout(fulfillmentId); + } catch (error) { + const msg = error instanceof Error ? error.message : "Unknown error"; + this.logger.error( + `Fulfillment payout enqueue failed for ${fulfillmentId}: ${msg}`, + ); + } + } + + // ────────────────────────────────────────────── + // GET ORDER BY ID (with ownership check) + // ────────────────────────────────────────────── + + async getById(id: string, userId: string, storeId?: string) { + const order = await this.prisma.order.findUnique({ + where: { id }, + include: { + orderEvents: { orderBy: { createdAt: "asc" } }, + product: { select: { name: true, imageUrl: true, productCode: true } }, + storeProfile: { + select: { + storeName: true, + storeHandle: true, + businessName: true, + verificationTier: true, + }, + }, + }, + }); + if (!order) throw new NotFoundException("Order not found"); + + // Ownership check: must be the buyer OR the merchant + const isBuyer = order.buyerId === userId; + const isMerchant = storeId && order.storeId === storeId; + if (!isBuyer && !isMerchant) { + throw new ForbiddenException("Access denied"); + } + + // Shoppers must not see dropship internals: physical-store wholesale + // cost, digital margin, or the linked fulfillment order id. The + // shopper interacts only with the digital store identity. + if (isBuyer) { + this.stripDropshipPrivateFields(order); + } + + if (!isBuyer && isMerchant) { + this.sanitizeStoreOwnerOrder(order); + } + + return order; + } + + // Removes physical-store and margin internals from any order object + // before returning it to a shopper. Mutates the object in place — used + // by getById/listByBuyer to enforce dropship privacy. + private stripDropshipPrivateFields(order: unknown): void { + if (!order || typeof order !== "object") return; + const target = order as Record; + target.dropshipperCostKobo = null; + target.digitalMarginKobo = null; + target.linkedOrderId = null; + target.sourcedProductId = null; + } + + // Store owners do not receive shopper contact details or shopper delivery + // coordinates by default. twizrr keeps those fields for approved logistics + // handoff and admin/operator paths, not store-owner API responses. + private sanitizeStoreOwnerOrder(order: unknown): void { + if (!order || typeof order !== "object") return; + const target = order as Record; + if (Array.isArray(target.orderEvents)) { + target.orderEvents = target.orderEvents.map((event) => { + if (!event || typeof event !== "object") return event; + return { + ...(event as Record), + triggeredBy: null, + actorId: null, + userId: null, + }; + }); + } + if (target.product && typeof target.product === "object") { + const product = target.product as Record; + target.product = { + name: product.name, + imageUrl: product.imageUrl ?? null, + productCode: product.productCode ?? null, + }; + } + delete target.user; + delete target.buyerId; + delete target.orderType; + delete target.linkedOrderId; + delete target.sourcedProductId; + delete target.dropshipperCostKobo; + delete target.digitalMarginKobo; + delete target.deliveryDetails; + delete target.deliveryPrimaryPhone; + delete target.deliverySecondaryPhone; + delete target.deliveryAddressSnapshot; + delete target.pickupAddressSnapshot; + delete target.pickupStoreId; + delete target.sourceStoreId; + target.deliveryAddress = null; + target.deliveryOtp = null; + } + + // ────────────────────────────────────────────── + // LIST ORDERS + // ────────────────────────────────────────────── + + async listByBuyer( + buyerId: string, + page: number, + limit: number, + ): Promise> { + const paginated = await paginate( + this.prisma.order, + { page, limit }, + { + where: { buyerId }, + orderBy: { createdAt: "desc" }, + include: { + product: { + select: { name: true, imageUrl: true, productCode: true }, + }, + storeProfile: { + select: { + storeName: true, + storeHandle: true, + businessName: true, + verificationTier: true, + }, + }, + }, + }, + ); + + // Shopper-facing list — strip dropship internals from every row. + if (paginated.data) { + (paginated.data as unknown[]).forEach((order) => + this.stripDropshipPrivateFields(order), + ); + } + return paginated as PaginatedResponse; + } + + async getByCodeForBuyer(orderCode: string, buyerId: string) { + return this.prisma.order.findFirst({ + where: { + orderCode, + buyerId, + orderType: { + in: [OrderType.DIRECT, OrderType.DROPSHIP_CUSTOMER], + }, + }, + select: { + id: true, + orderCode: true, + status: true, + }, + }); + } + + async getLatestByBuyer(buyerId: string) { + return this.prisma.order.findFirst({ + where: { + buyerId, + orderType: { + in: [OrderType.DIRECT, OrderType.DROPSHIP_CUSTOMER], + }, + status: { + in: [ + OrderStatus.PAID, + OrderStatus.DISPATCHED, + OrderStatus.DELIVERED, + OrderStatus.COMPLETED, + OrderStatus.DISPUTE, + OrderStatus.REFUND_PENDING, + OrderStatus.REFUNDED, + ], + }, + }, + orderBy: { + createdAt: "desc", + }, + select: { + id: true, + orderCode: true, + status: true, + }, + }); + } + + async listDeliveryConfirmationCandidates(buyerId: string, limit = 5) { + return this.prisma.order.findMany({ + where: { + buyerId, + orderType: { + in: [OrderType.DIRECT, OrderType.DROPSHIP_CUSTOMER], + }, + status: { + in: [OrderStatus.DISPATCHED, OrderStatus.IN_TRANSIT], + }, + deliveryOtp: { + not: null, + }, + }, + orderBy: { + dispatchedAt: "desc", + }, + take: limit, + select: { + id: true, + orderCode: true, + status: true, + }, + }); + } + + async listByMerchant( + storeId: string, + page: number, + limit: number, + ): Promise> { + const paginated = await paginate( + this.prisma.order, + { page, limit }, + { + where: { storeId }, + orderBy: { createdAt: "desc" }, + include: { + product: { + select: { name: true, imageUrl: true, productCode: true }, + }, + }, + }, + ); + + // Store-owner responses are fulfillment/workflow views, not shopper + // contact exports. + if (paginated.data) { + (paginated.data as unknown[]).forEach((order) => { + this.sanitizeStoreOwnerOrder(order); + }); + } + + return paginated as PaginatedResponse; + } + + // ────────────────────────────────────────────── + // DISPATCH (merchant only, generates OTP) + // ────────────────────────────────────────────── + + async readyForPickup(storeId: string, orderId: string) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + }); + if (!order) throw new NotFoundException("Order not found"); + if (order.storeId !== storeId) + throw new ForbiddenException("Access denied"); + const triggeredBy = await this.getPhysicalFulfillmentStoreUserId(storeId); + + if ( + order.status !== OrderStatus.PAID && + order.status !== OrderStatus.PREPARING + ) { + throw new BadRequestException( + "Order must be in PAID or PREPARING status to mark ready for pickup", + ); + } + + if ( + !validateTransition( + order.status as OrderStatus, + OrderStatus.READY_FOR_PICKUP, + ) + ) { + throw new BadRequestException( + `Cannot transition from ${order.status} to ${OrderStatus.READY_FOR_PICKUP}`, + ); + } + + const expectedFromStatus = order.status as OrderStatus; + + const updatedOrder = await this.prisma.$transaction(async (tx) => { + const { count } = await tx.order.updateMany({ + where: { id: orderId, status: expectedFromStatus }, + data: { status: OrderStatus.READY_FOR_PICKUP }, + }); + + if (count !== 1) { + throw new ConflictException({ + message: `Order ${orderId} is no longer in ${expectedFromStatus} state`, + code: "ORDER_STATE_CONFLICT", + }); + } + + const fresh = await tx.order.findUniqueOrThrow({ + where: { id: orderId }, + }); + + await tx.orderTracking.create({ + data: { orderId, status: OrderStatus.READY_FOR_PICKUP }, + }); + + await tx.orderEvent.create({ + data: { + orderId, + fromStatus: expectedFromStatus, + toStatus: OrderStatus.READY_FOR_PICKUP, + triggeredBy, + metadata: { action: "ready_for_pickup" }, + }, + }); + + await this.appendOrderDomainEvent( + tx, + fresh, + "ORDER_READY_FOR_PICKUP", + "USER", + triggeredBy, + { + fromStatus: expectedFromStatus, + toStatus: OrderStatus.READY_FOR_PICKUP, + action: "ready_for_pickup", + }, + ); + + return fresh; + }); + + this.logger.log( + `Order ${orderId}: ${expectedFromStatus} -> ${OrderStatus.READY_FOR_PICKUP}`, + ); + + this.sanitizeStoreOwnerOrder(updatedOrder); + return updatedOrder; + } + + async dispatch(storeId: string, orderId: string) { + const triggeredBy = await this.getPhysicalFulfillmentStoreUserId(storeId); + return this.dispatchOrderCore({ + orderId, + triggeredBy, + actorType: "USER", + action: "dispatched", + allowedFromStatuses: [ + OrderStatus.PAID, + OrderStatus.PREPARING, + OrderStatus.READY_FOR_PICKUP, + ], + storeId, + sanitizeForStoreOwner: true, + }); + } + + async startDeliveryByAdmin(orderId: string, adminId: string) { + return this.dispatchOrderCore({ + orderId, + triggeredBy: adminId, + actorType: "ADMIN", + action: "admin_start_delivery", + allowedFromStatuses: [OrderStatus.READY_FOR_PICKUP], + requireStartableDeliveryBooking: true, + sanitizeForStoreOwner: false, + }); + } + + private async dispatchOrderCore(input: { + orderId: string; + triggeredBy: string; + actorType: "USER" | "ADMIN"; + action: "dispatched" | "admin_start_delivery"; + allowedFromStatuses: OrderStatus[]; + storeId?: string; + requireStartableDeliveryBooking?: boolean; + sanitizeForStoreOwner: boolean; + }) { + // Crypto-secure 6-digit delivery code. Stored as a bcrypt hash; the + // plaintext is held only in memory for the buyer notification. + const plainOtp = crypto.randomInt(100000, 1000000).toString(); + const deliveryOtpHash = await bcrypt.hash(plainOtp, OTP_HASH_ROUNDS); + + const dispatchedOrder = await this.prisma.$transaction(async (tx) => { + const order = await tx.order.findUnique({ + where: { id: input.orderId }, + select: { + id: true, + orderCode: true, + buyerId: true, + storeId: true, + status: true, + totalAmountKobo: true, + platformFeeKobo: true, + deliveryBooking: { + select: { + id: true, + status: true, + }, + }, + }, + }); + + if (!order) { + throw new NotFoundException("Order not found"); + } + + if (input.storeId && order.storeId !== input.storeId) { + throw new ForbiddenException("Access denied"); + } + + const expectedFromStatus = order.status as OrderStatus; + if (!input.allowedFromStatuses.includes(expectedFromStatus)) { + throw new BadRequestException( + input.requireStartableDeliveryBooking + ? "Order must be ready for pickup before delivery can start" + : "Order must be in PAID, PREPARING, or READY_FOR_PICKUP status to dispatch", + ); + } + + if (!validateTransition(expectedFromStatus, OrderStatus.DISPATCHED)) { + throw new BadRequestException( + `Cannot transition from ${order.status} to ${OrderStatus.DISPATCHED}`, + ); + } + + if (input.requireStartableDeliveryBooking) { + if (!order.deliveryBooking) { + throw new BadRequestException({ + message: "Create a delivery booking before starting delivery.", + code: "DELIVERY_BOOKING_REQUIRED", + }); + } + + if ( + order.deliveryBooking.status !== DeliveryStatus.PENDING && + order.deliveryBooking.status !== DeliveryStatus.PICKUP_SCHEDULED + ) { + throw new BadRequestException({ + message: "Delivery has already started for this booking.", + code: "DELIVERY_BOOKING_ALREADY_STARTED", + }); + } + + await tx.deliveryBooking.update({ + where: { id: order.deliveryBooking.id }, + data: { + status: DeliveryStatus.PICKED_UP, + pickedUpAt: new Date(), + }, + }); + } + + const { count } = await tx.order.updateMany({ + where: { id: input.orderId, status: expectedFromStatus }, + data: { + status: OrderStatus.DISPATCHED, + deliveryOtp: deliveryOtpHash, + dispatchedAt: new Date(), + }, + }); + + if (count !== 1) { + throw new ConflictException({ + message: `Order ${input.orderId} is no longer in ${expectedFromStatus} state`, + code: "ORDER_STATE_CONFLICT", + }); + } + + const fresh = await tx.order.findUniqueOrThrow({ + where: { id: input.orderId }, + }); + + await tx.orderTracking.create({ + data: { orderId: input.orderId, status: OrderStatus.DISPATCHED }, + }); + + const orderEvent = await tx.orderEvent.create({ + data: { + orderId: input.orderId, + fromStatus: expectedFromStatus, + toStatus: OrderStatus.DISPATCHED, + triggeredBy: input.triggeredBy, + metadata: { action: input.action }, + }, + }); + + await this.commerceOutbox?.appendOrderUpdated( + tx, + fresh, + orderEvent.id, + expectedFromStatus, + ); + await this.commerceOutbox?.appendAutoConfirmSchedule( + tx, + input.orderId, + orderEvent.id, + ); + + await this.appendOrderDomainEvent( + tx, + fresh, + "ORDER_DISPATCHED", + input.actorType, + input.triggeredBy, + { + fromStatus: expectedFromStatus, + toStatus: OrderStatus.DISPATCHED, + action: input.action, + }, + ); + + return { initialOrder: order, updatedOrder: fresh, expectedFromStatus }; + }); + + this.logger.log( + `Order ${input.orderId}: ${dispatchedOrder.expectedFromStatus} -> ${OrderStatus.DISPATCHED}`, + ); + + try { + await this.notifications.triggerOrderDispatched( + dispatchedOrder.initialOrder.buyerId, + { + orderId: input.orderId, + reference: input.orderId.slice(0, 8).toUpperCase(), + otp: plainOtp, + }, + ); + } catch (error) { + this.logger.error( + `Failed to send dispatched notification for order ${input.orderId} to buyer ${dispatchedOrder.initialOrder.buyerId}: ${ + error instanceof Error ? error.message : String(error) + }`, + error instanceof Error ? error.stack : undefined, + ); + } + + this.logger.log( + `Order ${input.orderId} dispatched, delivery code generated`, + ); + + if (input.sanitizeForStoreOwner) { + this.sanitizeStoreOwnerOrder(dispatchedOrder.updatedOrder); + } + + return dispatchedOrder.updatedOrder; + } + + // crypto.randomInt picks a fresh 6-digit code; the actual collision + // check is the DB's unique index, caught by createOrderWithUniqueCode. + // The schema retains a DB-level RANDOM() default as a defensive fallback, + // but every order created through this service path overrides it with a + // securely-random code. + private nextOrderCodeCandidate(): string { + return `TWZ-${crypto.randomInt(100000, 1000000).toString()}`; + } + + // Race-safe order code generation. The pre-check approach (findUnique + // then create) is TOCTOU: two concurrent transactions can both clear + // the check and one will fail on the @unique index, surfacing as a + // 500. Instead we let the DB's unique index BE the check — attempt + // the insert, catch the P2002, regenerate, retry. Collisions are + // vanishingly rare (1 in 900k) so we almost always exit on attempt 1. + private async createOrderWithUniqueCode( + tx: Prisma.TransactionClient, + build: (orderCode: string) => Promise, + maxAttempts = 5, + ): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const code = this.nextOrderCodeCandidate(); + try { + return await build(code); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" && + Array.isArray(error.meta?.target) && + (error.meta?.target as string[]).includes("order_code") + ) { + this.logger.warn( + `Order code collision on ${code}, retrying (attempt ${attempt + 1}/${maxAttempts})`, + ); + continue; + } + throw error; + } + } + throw new Error( + `Failed to generate unique order code after ${maxAttempts} attempts`, + ); + } + + // TRACKING (merchant) + // ────────────────────────────────────────────── + + async addTracking(storeId: string, orderId: string, dto: CreateTrackingDto) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + }); + if (!order) throw new NotFoundException("Order not found"); + if (order.storeId !== storeId) + throw new ForbiddenException("Access denied"); + const triggeredBy = await this.getPhysicalFulfillmentStoreUserId(storeId); + + // Reject DELIVERED status for merchant tracking updates + if (dto.status === OrderStatus.DELIVERED) { + throw new BadRequestException( + "Use confirmDelivery() to mark an order as DELIVERED after delivery code validation", + ); + } + + // Check valid next states via state machine + const allowedNext = getNextStates(order.status as OrderStatus); + if (!allowedNext.includes(dto.status)) { + throw new BadRequestException( + `Cannot transition from ${order.status} to ${dto.status}`, + ); + } + + // Handle OTP generation if transitioning to DISPATCHED. + // plainOtp is held only in memory for the notification send below; + // the DB stores only the bcrypt hash. Both are computed up-front so + // they can be folded into the atomic status update below. + let plainOtp: string | null = null; + let deliveryOtpHash: string | null = null; + if (dto.status === OrderStatus.DISPATCHED && !order.deliveryOtp) { + plainOtp = crypto.randomInt(100000, 1000000).toString(); + deliveryOtpHash = await bcrypt.hash(plainOtp, OTP_HASH_ROUNDS); + } + + const expectedFromStatus = order.status as OrderStatus; + + // Atomic transition: status change, optional OTP fields, the tracking + // row, and the orderEvent audit row all commit together. This closes + // the race where the OTP hash could be persisted on a row whose + // status transition was about to be rejected by a concurrent caller — + // which would have left a hash no shopper had the plaintext for. + const updatedOrder = await this.prisma.$transaction(async (tx) => { + const updateData: Prisma.OrderUpdateManyMutationInput = { + status: dto.status, + }; + if (deliveryOtpHash) { + updateData.deliveryOtp = deliveryOtpHash; + updateData.dispatchedAt = new Date(); + } + + const { count } = await tx.order.updateMany({ + where: { id: orderId, status: expectedFromStatus }, + data: updateData, + }); + + if (count !== 1) { + throw new ConflictException({ + message: `Order ${orderId} is no longer in ${expectedFromStatus} state`, + code: "ORDER_STATE_CONFLICT", + }); + } + + const fresh = await tx.order.findUniqueOrThrow({ + where: { id: orderId }, + }); + + await tx.orderTracking.create({ + data: { orderId, status: dto.status, note: dto.note }, + }); + + const orderEvent = await tx.orderEvent.create({ + data: { + orderId, + fromStatus: expectedFromStatus, + toStatus: dto.status, + triggeredBy, + metadata: { action: "tracking_update", note: dto.note }, + }, + }); + + await this.commerceOutbox?.appendOrderUpdated( + tx, + fresh, + orderEvent.id, + expectedFromStatus, + ); + if (dto.status === OrderStatus.DISPATCHED) { + await this.commerceOutbox?.appendAutoConfirmSchedule( + tx, + orderId, + orderEvent.id, + ); + } + + if (dto.status === OrderStatus.PREPARING) { + await this.commerceOutbox?.appendNotificationDispatch(tx, { + recipientUserId: fresh.buyerId, + notificationType: NotificationType.ORDER_PREPARING, + title: "Order Preparing", + body: "Your order is being prepared. The store is packing your goods.", + data: { + orderId, + reference: orderId.slice(0, 8).toUpperCase(), + }, + channels: [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + dedupeKey: + "order-event:" + + orderEvent.id + + ":notification:" + + fresh.buyerId + + ":preparing", + aggregateType: "Order", + aggregateId: orderId, + }); + } else if (dto.status === OrderStatus.IN_TRANSIT) { + const noteText = dto.note ? " Delivery update: " + dto.note : ""; + await this.commerceOutbox?.appendNotificationDispatch(tx, { + recipientUserId: fresh.buyerId, + notificationType: NotificationType.ORDER_IN_TRANSIT, + title: "Order In Transit", + body: "Your order is in transit." + noteText, + data: { + orderId, + reference: orderId.slice(0, 8).toUpperCase(), + ...(dto.note ? { note: dto.note } : {}), + }, + channels: [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + dedupeKey: + "order-event:" + + orderEvent.id + + ":notification:" + + fresh.buyerId + + ":in-transit", + aggregateType: "Order", + aggregateId: orderId, + }); + } + const domainEventType = this.getTransitionDomainEventType(dto.status, { + action: "tracking_update", + note: dto.note, + }); + if (domainEventType) { + await this.appendOrderDomainEvent( + tx, + fresh, + domainEventType, + "USER", + triggeredBy, + { + fromStatus: expectedFromStatus, + toStatus: dto.status, + action: "tracking_update", + note: dto.note, + }, + ); + } + + return fresh; + }); + + this.logger.log( + `Order ${orderId}: ${expectedFromStatus} -> ${dto.status} (tracking)`, + ); + + // Send relevant notification + try { + if (dto.status === OrderStatus.DISPATCHED) { + // The delivery OTP is intentionally never persisted in an outbox, + // notification record, domain event, or Socket.IO payload. + if (plainOtp) { + await this.notifications.triggerOrderDispatched(order.buyerId, { + orderId, + reference: orderId.slice(0, 8).toUpperCase(), + otp: plainOtp, + }); + } + } + } catch (error) { + this.logger.error( + `Failed to send tracking notification for order ${orderId} (buyer ${order.buyerId}, type: ${dto.status})`, + error instanceof Error ? error.stack : "Unknown error", + ); + } + + return updatedOrder; + } + + async getTracking(orderId: string, userId: string, storeId?: string) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + }); + if (!order) throw new NotFoundException("Order not found"); + + // Ensure permission + if (order.buyerId !== userId && order.storeId !== storeId) { + throw new ForbiddenException("Access denied"); + } + + return this.prisma.orderTracking.findMany({ + where: { orderId }, + orderBy: { createdAt: "asc" }, + }); + } + + // ────────────────────────────────────────────── + // CONFIRM DELIVERY (buyer only, verifies OTP) + // ────────────────────────────────────────────── + + private async releaseEscrowAndComplete( + order: { + id: string; + buyerId: string; + storeId: string | null; + totalAmountKobo: bigint; + }, + completionAction: string, + ): Promise { + try { + await this.ledgerService.recordEscrowRelease( + order.id, + order.totalAmountKobo, + { + ...(order.storeId ? { storeId: order.storeId } : {}), + }, + ); + } catch (ledgerError) { + this.logger.error( + `Escrow release ledger write failed for order ${order.id}: ${ + ledgerError instanceof Error ? ledgerError.message : "unknown error" + } - payout NOT queued.`, + ledgerError instanceof Error ? ledgerError.stack : undefined, + ); + throw ledgerError; + } + + await this.transition( + order.id, + OrderStatus.DELIVERED, + OrderStatus.COMPLETED, + order.buyerId, + { action: completionAction }, + ); + } + + async confirmDelivery(buyerId: string, orderId: string, otp: string) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + }); + if (!order) throw new NotFoundException("Order not found"); + if (order.buyerId !== buyerId) + throw new ForbiddenException("Access denied"); + + if (order.status === OrderStatus.COMPLETED) { + return { message: "Delivery confirmed" }; + } + + const isRecoveringDelivery = order.status === OrderStatus.DELIVERED; + + if ( + order.status !== OrderStatus.DISPATCHED && + order.status !== OrderStatus.IN_TRANSIT && + !isRecoveringDelivery + ) { + throw new BadRequestException( + "Order must be in DISPATCHED, IN_TRANSIT, or DELIVERED status to confirm delivery", + ); + } + + if (!isRecoveringDelivery) { + // OTP expiry: 48 hours from dispatch. Auto-confirm queue normally + // moves the order out of DISPATCHED first, but a shopper trying to + // submit late while the queue is delayed deserves an explicit signal. + if ( + order.dispatchedAt && + Date.now() > order.dispatchedAt.getTime() + OTP_VALIDITY_MS + ) { + throw new BadRequestException({ + message: "Delivery code expired", + code: "OTP_EXPIRED", + }); + } + + // Compare against the stored bcrypt hash. Empty string fallback keeps + // bcrypt.compare from throwing when the hash column is somehow null. + const otpMatches = await bcrypt.compare(otp, order.deliveryOtp ?? ""); + if (!otpMatches) { + throw new BadRequestException({ + message: "Invalid delivery code", + code: "OTP_INVALID", + }); + } + + await this.transition( + orderId, + order.status as OrderStatus, + OrderStatus.DELIVERED, + buyerId, + { action: "delivery_confirmed" }, + ); + } + + // A ledger failure leaves the order in DELIVERED. A subsequent buyer + // request can resume here without replaying OTP or delivery side effects. + await this.releaseEscrowAndComplete(order, "delivery_confirmed_completed"); + + // Queue payout ONLY after the ledger release has been recorded AND + // the order has been finalized. PayoutService.queuePayout owns the + // BullMQ enqueue surface so retry/idempotency rules live in one place. + try { + this.logger.log(`Queueing auto-payout for escrow order ${orderId}`); + await this.payoutService.queuePayout(orderId); + } catch (error) { + const msg = error instanceof Error ? error.message : "Unknown error"; + this.logger.error( + `Auto-payout enqueue failed for order ${orderId} (will need manual retry): ${msg}`, + error instanceof Error ? error.stack : undefined, + ); + } + + // Dropship pairs: also complete the linked fulfillment order and queue + // the physical store's payout. No-op for direct orders. + await this.completeLinkedFulfillment(orderId); + + // Best-effort notifications and tier evaluation — must not block the + // success response now that ledger + payout queue are settled. + try { + await this.notifications.triggerDeliveryConfirmed( + order.storeId as string, + order.buyerId, + { + orderId, + reference: orderId.slice(0, 8).toUpperCase(), + amountKobo: order.totalAmountKobo, + }, + ); + } catch (error) { + this.logger.error( + `Failed to send delivery confirmed notification (orderId=${orderId}, storeId=${order.storeId}): ${error instanceof Error ? error.message : error}`, + ); + } + + try { + this.logger.log(`Evaluating tier upgrade for merchant ${order.storeId}`); + await this.verificationService.checkAndUpgradeTier( + order.storeId as string, + ); + } catch (error) { + this.logger.error( + `Failed to evaluate tier upgrade for merchant ${order.storeId}: ${error instanceof Error ? error.message : error}`, + ); + } + + return { message: "Delivery confirmed" }; + } + + /** + * System-triggered delivery confirmation after 48 hours of inactivity. + */ + async autoConfirmDelivery(orderId: string) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + include: { + user: true, // buyer + storeProfile: true, + }, + }); + + if (!order) { + throw new NotFoundException(`Order ${orderId} not found`); + } + + // Explicitly guard against DISPUTE - funds must NOT be auto-released. + if (order.status === OrderStatus.DISPUTE) { + this.logger.warn( + `Order ${orderId} is in DISPUTE state. Auto-confirmation blocked - awaiting admin resolution.`, + ); + return; + } + + if (order.status === OrderStatus.COMPLETED) { + return { success: true }; + } + + const isRecoveringDelivery = order.status === OrderStatus.DELIVERED; + + if ( + order.status !== OrderStatus.DISPATCHED && + order.status !== OrderStatus.IN_TRANSIT && + !isRecoveringDelivery + ) { + this.logger.warn( + `Order ${orderId} is in ${order.status} state, cannot auto-confirm.`, + ); + return; + } + + if (!isRecoveringDelivery) { + this.logger.log(`Auto-confirming delivery for order ${orderId}`); + + await this.transition( + orderId, + order.status as OrderStatus, + OrderStatus.DELIVERED, + order.buyerId, + { action: "system_auto_confirm" }, + { disputeWindowEndsAt: new Date() }, + ); + + await this.prisma.orderTracking.create({ + data: { + orderId, + status: OrderStatus.DELIVERED, + note: "Delivery auto-confirmed after 48 hours of no response.", + }, + }); + + try { + if (order.storeId) { + await this.notifications.triggerOrderAutoConfirmed( + order.buyerId, + order.storeId, + { + orderId: order.id, + reference: order.id.slice(0, 8).toUpperCase(), + amountKobo: order.totalAmountKobo, + }, + ); + } + } catch (error) { + this.logger.error( + `Failed to notify for auto-confirmed order ${orderId}: ${error instanceof Error ? error.message : error}`, + ); + } + } else if (!order.disputeWindowEndsAt) { + // Older or partially completed deliveries may predate the atomic + // transition write. Backfill once before resuming escrow release. + await this.prisma.order.updateMany({ + where: { + id: orderId, + status: OrderStatus.DELIVERED, + disputeWindowEndsAt: null, + }, + data: { disputeWindowEndsAt: new Date() }, + }); + } + + // BullMQ retries resume from DELIVERED after an escrow ledger failure. + // The ledger idempotency key prevents duplicate release entries. + await this.releaseEscrowAndComplete(order, "auto_completion_payout"); + + // Post-completion processing (payouts and tier checks) + try { + if (order.storeId) { + // Upgrade check + await this.verificationService.checkAndUpgradeTier(order.storeId); + + await this.payoutService.queuePayout(orderId); + + // Dropship pairs: also complete the linked fulfillment + queue its + // payout. No-op for direct orders. + await this.completeLinkedFulfillment(orderId); + } + } catch (err: unknown) { + this.logger.error( + `Post-auto-confirm processing failed for ${orderId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + return { success: true }; + } + + // ────────────────────────────────────────────── + // CANCEL (role-based status rules) + // ────────────────────────────────────────────── + + async cancel(userId: string, orderId: string, storeId?: string) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + }); + if (!order) throw new NotFoundException("Order not found"); + + const isBuyer = order.buyerId === userId; + const isMerchant = storeId && order.storeId === storeId; + + if (!isBuyer && !isMerchant) { + throw new ForbiddenException("Access denied"); + } + + // Role-based cancellation rules per guide: + // - Buyer can cancel if PENDING_PAYMENT (no refund needed) + // - Merchant can cancel if PAID (auto-refund triggered) + if (isBuyer && order.status !== OrderStatus.PENDING_PAYMENT) { + throw new BadRequestException( + "Buyer can only cancel orders in PENDING_PAYMENT status", + ); + } + if (isBuyer) { + const unresolvedCollection = + await this.prisma.paymentAmountException.findFirst({ + where: { + payment: { orderId }, + status: { not: "RESOLVED" }, + }, + select: { id: true }, + }); + if (unresolvedCollection) { + throw new ConflictException({ + message: + "This order has a collected payment under review and cannot be cancelled yet.", + code: "PAYMENT_COLLECTION_REVIEW_REQUIRED", + }); + } + } + if (isMerchant && order.status !== OrderStatus.PAID) { + throw new BadRequestException( + "Store owner can only cancel orders in PAID status", + ); + } + + // Transition to CANCELLED + await this.transition( + orderId, + order.status as OrderStatus, + OrderStatus.CANCELLED, + userId, + { cancelledBy: isBuyer ? "buyer" : "merchant" }, + ); + + // Release reserved stock for different order types + if (order.productId && order.quantity) { + // Direct Purchase — release back to the variant that was reserved. + const directVariantId = this.extractDirectOrderVariantId(order.items); + await this.inventoryService.releaseStock( + order.productId, + order.storeId as string, + order.quantity, + orderId, + directVariantId, + ); + } else if (order.items) { + // Cart Checkout (Multi-item) + const items = order.items as any[]; + const releasePayload = items + .filter((item) => item.productId && item.quantity) + .map((item) => ({ + productId: item.productId, + quantity: Number(item.quantity), + })); + + if (releasePayload.length > 0) { + await this.inventoryService.releaseStockBatch( + releasePayload, + order.storeId as string, + orderId, + ); + } + } + + // Notify both parties + await this.notifications.triggerOrderCancelled( + order.buyerId, + order.storeId as string, + orderId, + ); + + return { message: "Order cancelled" }; + } + + // ────────────────────────────────────────────── + // DISPUTE (buyer only, DISPATCHED only) + // ────────────────────────────────────────────── + + /** Reads the reserved variant id from a direct order's items snapshot. */ + private extractDirectOrderVariantId( + items: Prisma.JsonValue | null | undefined, + ): string | null { + if (!Array.isArray(items) || items.length === 0) return null; + const first = items[0]; + if (first && typeof first === "object" && !Array.isArray(first)) { + const variantId = (first as { variantId?: unknown }).variantId; + return typeof variantId === "string" ? variantId : null; + } + return null; + } + + async reportIssue(buyerId: string, orderId: string, reason: string) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + }); + if (!order) throw new NotFoundException("Order not found"); + if (order.buyerId !== buyerId) + throw new ForbiddenException("Access denied"); + + // Issues can be raised for PAID or DISPATCHED orders + if ( + order.status !== OrderStatus.DISPATCHED && + order.status !== OrderStatus.PAID + ) { + throw new BadRequestException( + "Issues can only be raised for PAID or DISPATCHED orders", + ); + } + + const updatedOrder = await this.prisma.$transaction(async (tx) => { + const fresh = await tx.order.update({ + where: { id: orderId }, + data: { + status: OrderStatus.DISPUTE, + disputeStatus: "PENDING", + disputeReason: reason, + }, + }); + + await tx.orderEvent.create({ + data: { + orderId, + fromStatus: order.status as OrderStatus, + toStatus: OrderStatus.DISPUTE, + triggeredBy: buyerId, + metadata: { action: "issue_reported", reason }, + }, + }); + + await this.appendOrderDomainEvent( + tx, + fresh, + "DISPUTE_OPENED", + "USER", + buyerId, + { + fromStatus: order.status as OrderStatus, + toStatus: OrderStatus.DISPUTE, + action: "issue_reported", + reason, + }, + ); + + return fresh; + }); + + // Notify merchant and admin + try { + await this.notifications.triggerOrderDisputed( + order.storeId as string, + orderId, + reason, + ); + } catch (error) { + this.logger.error( + `Failed to send dispute notification: ${error instanceof Error ? error.message : error}`, + ); + } + + return updatedOrder; + } + + // ────────────────────────────────────────────── + // ORDER RECEIPT AGGREGATION + // ────────────────────────────────────────────── + + async getReceipt(orderId: string, userId: string) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + include: { + storeProfile: { + select: { + businessName: true, + businessAddress: true, + user: { select: { phone: true, email: true } }, + }, + }, + user: { select: { email: true, phone: true } }, + payments: { + where: { status: "SUCCESS" }, + orderBy: { createdAt: "desc" }, + take: 1, + }, + }, + }); + + if (!order) { + throw new NotFoundException("Order not found"); + } + + if (order.buyerId !== userId) { + throw new ForbiddenException("Only the buyer can access their receipt"); + } + + // Buyer-facing receipt — strip dropship internals just like + // getById/listByBuyer so wholesale cost, digital margin, and the + // linked fulfillment order id never leak to the shopper. + this.stripDropshipPrivateFields(order); + return order; + } + + // HELPERS + async getMerchantSummary(storeId: string) { + const [orders, payouts, completedPayoutLedgerEntries] = await Promise.all([ + this.prisma.order.findMany({ + where: { storeId }, + select: { + id: true, + totalAmountKobo: true, + deliveryFeeKobo: true, + platformFeeKobo: true, + dropshipperCostKobo: true, + status: true, + payoutStatus: true, + }, + }), + this.prisma.payout.findMany({ + where: { storeId }, + select: { + orderId: true, + amountKobo: true, + status: true, + }, + }), + this.prisma.ledgerEntry.findMany({ + where: { + storeId, + entryType: "PAYOUT_COMPLETED", + }, + select: { + orderId: true, + amountKobo: true, + }, + }), + ]); + + const releasedOrderIds = new Set(); + let paidOutKobo = 0n; + + completedPayoutLedgerEntries.forEach((entry) => { + if (!entry.orderId || releasedOrderIds.has(entry.orderId)) return; + releasedOrderIds.add(entry.orderId); + paidOutKobo += BigInt(entry.amountKobo); + }); + + payouts + .filter((payout) => payout.status === "COMPLETED") + .forEach((payout) => { + if (releasedOrderIds.has(payout.orderId)) return; + releasedOrderIds.add(payout.orderId); + paidOutKobo += BigInt(payout.amountKobo); + }); + + orders + .filter( + (order) => + order.payoutStatus === "COMPLETED" && !releasedOrderIds.has(order.id), + ) + .forEach((order) => { + releasedOrderIds.add(order.id); + paidOutKobo += this.calculateStoreSummaryAmountKobo(order); + }); + + const summary = { + escrow: 0, + protectedOrderCount: 0, + paidOut: Number(paidOutKobo), + pending: 0, + failed: 0, + orderCount: orders.length, + }; + + orders.forEach((order) => { + const amount = Number(this.calculateStoreSummaryAmountKobo(order)); + switch (order.status) { + case OrderStatus.PAID: + case OrderStatus.PREPARING: + case OrderStatus.READY_FOR_PICKUP: + case OrderStatus.DISPATCHED: + case OrderStatus.IN_TRANSIT: + case OrderStatus.DELIVERED: + case OrderStatus.COMPLETED: + if (amount > 0 && !releasedOrderIds.has(order.id)) { + summary.escrow += amount; + summary.protectedOrderCount += 1; + } + break; + case OrderStatus.PENDING_PAYMENT: + summary.pending += amount; + break; + case OrderStatus.CANCELLED: + case OrderStatus.DISPUTE: + case OrderStatus.REFUND_PENDING: + summary.failed += amount; + break; + } + }); + + return summary; + } + + private calculateStoreSummaryAmountKobo(order: { + totalAmountKobo?: bigint | number | string | null; + deliveryFeeKobo?: bigint | number | string | null; + platformFeeKobo?: bigint | number | string | null; + dropshipperCostKobo?: bigint | number | string | null; + }): bigint { + const totalAmountKobo = + order.totalAmountKobo === null || order.totalAmountKobo === undefined + ? 0n + : BigInt(order.totalAmountKobo); + const deliveryFeeKobo = + order.deliveryFeeKobo === null || order.deliveryFeeKobo === undefined + ? 0n + : BigInt(order.deliveryFeeKobo); + const platformFeeKobo = + order.platformFeeKobo === null || order.platformFeeKobo === undefined + ? 0n + : BigInt(order.platformFeeKobo); + const dropshipperCostKobo = + order.dropshipperCostKobo === null || + order.dropshipperCostKobo === undefined + ? 0n + : BigInt(order.dropshipperCostKobo); + const storeAmountKobo = + totalAmountKobo - deliveryFeeKobo - platformFeeKobo - dropshipperCostKobo; + + return storeAmountKobo > 0n ? storeAmountKobo : 0n; + } + + private async getUserIdFromMerchant(storeId: string): Promise { + const merchant = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + }); + if (!merchant) throw new NotFoundException("Store owner not found"); + return merchant.userId; + } + + private async getPhysicalFulfillmentStoreUserId( + storeId: string, + ): Promise { + const store = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + }); + if (!store) throw new NotFoundException("Store owner not found"); + if (store.storeType !== StoreType.PHYSICAL) { + throw new ForbiddenException({ + message: + "Physical fulfillment actions are available only to physical stores", + code: "PHYSICAL_FULFILLMENT_REQUIRED", + }); + } + return store.userId; + } + + private generateDeterministicKey( + prefix: string, + buyerId: string, + payload: Record, + ): string { + // Canonicalize: sort arrays and use stable key ordering + const canonical = this.canonicalize({ buyerId, ...payload }); + const hash = crypto.createHash("sha256").update(canonical).digest("hex"); + return `${prefix}-${hash.substring(0, 16)}`; + } + + /** + * Stable JSON serialization: sort object keys recursively and sort arrays + * so that the same logical payload always produces the same hash. + */ + private canonicalize(obj: any): string { + return JSON.stringify(obj, (_, value) => { + if (Array.isArray(value)) { + // Sort primitive arrays for stable ordering + return [...value].sort(); + } + if ( + value !== null && + typeof value === "object" && + !Array.isArray(value) + ) { + // Sort object keys + return Object.keys(value) + .sort() + .reduce( + (sorted, key) => { + sorted[key] = value[key]; + return sorted; + }, + {} as Record, + ); + } + return value; + }); + } +} diff --git a/apps/backend/src/domains/orders/order/store-order.controller.ts b/apps/backend/src/domains/orders/order/store-order.controller.ts new file mode 100644 index 00000000..9a352a56 --- /dev/null +++ b/apps/backend/src/domains/orders/order/store-order.controller.ts @@ -0,0 +1,40 @@ +import { + Controller, + ForbiddenException, + Get, + DefaultValuePipe, + ParseIntPipe, + Query, + UseGuards, +} from "@nestjs/common"; +import { OrderService } from "./order.service"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { RolesGuard } from "../../../common/guards/roles.guard"; +import { Roles } from "../../../common/decorators/roles.decorator"; +import { CurrentMerchant } from "../../../common/decorators/current-merchant.decorator"; +import { UserRole } from "@twizrr/shared"; + +// B-17 spec: GET /store/orders returns the orders for the authenticated +// store owner's store only. Lives on its own controller because +// OrderController is rooted at /orders. +@Controller("store/orders") +@UseGuards(JwtAuthGuard, RolesGuard) +export class StoreOrderController { + constructor(private readonly orderService: OrderService) {} + + @Get() + @Roles(UserRole.USER) + listStoreOrders( + @CurrentMerchant() storeId: string | undefined, + @Query("page", new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number, + ) { + if (!storeId) { + throw new ForbiddenException({ + message: "Store identity required", + code: "MERCHANT_IDENTITY_REQUIRED", + }); + } + return this.orderService.listByMerchant(storeId, page, limit); + } +} diff --git a/apps/backend/src/domains/platform.module.ts b/apps/backend/src/domains/platform.module.ts new file mode 100644 index 00000000..c380b0cb --- /dev/null +++ b/apps/backend/src/domains/platform.module.ts @@ -0,0 +1,20 @@ +import { Module } from "@nestjs/common"; + +import { AdminModule } from "./platform/admin/admin.module"; +import { DomainEventModule } from "./platform/domain-event/domain-event.module"; +import { OutboxModule } from "./platform/outbox/outbox.module"; +import { EmbeddingModule } from "./platform/embedding/embedding.module"; +import { UploadModule } from "./platform/upload/upload.module"; +import { WaitlistModule } from "./platform/waitlist/waitlist.module"; + +@Module({ + imports: [ + AdminModule, + DomainEventModule, + EmbeddingModule, + OutboxModule, + UploadModule, + WaitlistModule, + ], +}) +export class PlatformDomainModule {} diff --git a/apps/backend/src/domains/platform/admin/admin-cron.service.ts b/apps/backend/src/domains/platform/admin/admin-cron.service.ts new file mode 100644 index 00000000..38877549 --- /dev/null +++ b/apps/backend/src/domains/platform/admin/admin-cron.service.ts @@ -0,0 +1,62 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { Cron, CronExpression } from "@nestjs/schedule"; +import { PrismaService } from "../../../prisma/prisma.service"; +import { OrderStatus } from "@twizrr/shared"; + +/** + * Admin cron service for system health monitoring. + * + * NOTE: Auto-confirm delivery logic has been intentionally removed from this service. + * It is handled EXCLUSIVELY by AutoConfirmProcessor (BullMQ) in src/queue/auto-confirm.processor.ts. + * Do NOT re-add auto-confirm logic here — it will cause double payouts. + */ +@Injectable() +export class AdminCronService { + private readonly logger = new Logger(AdminCronService.name); + + constructor(private readonly prisma: PrismaService) {} + + @Cron(CronExpression.EVERY_HOUR) + async monitorSystemHealth() { + this.logger.log("Running automated system health checks..."); + + // Rule 1: Stuck Escrow (Order PAID but not DISPATCHED for > 48h) + const fortyEightHoursAgo = new Date(Date.now() - 48 * 60 * 60 * 1000); + const stuckOrders = await this.prisma.order.findMany({ + where: { + status: OrderStatus.PAID, + updatedAt: { + lt: fortyEightHoursAgo, + }, + }, + select: { id: true, storeId: true }, + }); + + if (stuckOrders.length > 0) { + this.logger.warn( + `CRITICAL: Found ${stuckOrders.length} orders stuck in PAID state for > 48h. Action required.`, + ); + // Mock triggering a Slack webhook / Email to operations team + } + + // Rule 2: Verification Choke (stores waiting > 24h) + const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const chokeSize = await this.prisma.storeProfile.count({ + where: { + verificationRequests: { + some: { + status: "PENDING", + createdAt: { lt: twentyFourHoursAgo }, + }, + }, + }, + }); + + if (chokeSize > 10) { + this.logger.warn( + `SLA BREACH: ${chokeSize} merchants have been sitting in the UNVERIFIED queue for > 24h. Platform adoption blocked.`, + ); + // Mock alerting operations + } + } +} diff --git a/apps/backend/src/domains/platform/admin/admin-guards.spec.ts b/apps/backend/src/domains/platform/admin/admin-guards.spec.ts new file mode 100644 index 00000000..e1787add --- /dev/null +++ b/apps/backend/src/domains/platform/admin/admin-guards.spec.ts @@ -0,0 +1,62 @@ +import { GUARDS_METADATA } from "@nestjs/common/constants"; +import { UserRole } from "@twizrr/shared"; +import { ROLES_KEY } from "../../../common/decorators/roles.decorator"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { RolesGuard } from "../../../common/guards/roles.guard"; +import { AdminController } from "./admin.controller"; +import { AuditLogController } from "./audit-log.controller"; +import { CategoryDemandInsightsController } from "../../commerce/search/category-demand-insights.controller"; + +// Route-inventory guard test. Every admin API group is exposed through one of +// these three controllers, and each is protected at the CLASS level with +// JwtAuthGuard + RolesGuard + @Roles(SUPER_ADMIN). Proving the class guards is +// therefore equivalent to proving a normal USER cannot reach any admin route in +// that group — no need to enumerate every handler. +// +// Groups covered by AdminController (@Controller("admin")): dashboard, +// users, stores, verification, orders, payouts, deliveries, disputes, +// moderation, technical ops (ops/overview), reconciliation, audit-logs. +// AuditLogController (@Controller("admin/audit-logs")): legacy audit reads. +// CategoryDemandInsightsController (@Controller("admin/search")): WIZZA / +// category-demand analytics. + +type Ctor = new (...args: never[]) => unknown; + +const ADMIN_CONTROLLERS: Array<{ name: string; controller: Ctor }> = [ + { name: "AdminController", controller: AdminController as unknown as Ctor }, + { + name: "AuditLogController", + controller: AuditLogController as unknown as Ctor, + }, + { + name: "CategoryDemandInsightsController (WIZZA analytics)", + controller: CategoryDemandInsightsController as unknown as Ctor, + }, +]; + +describe("admin API access control (SUPER_ADMIN only)", () => { + it.each(ADMIN_CONTROLLERS)( + "$name is guarded by JwtAuthGuard + RolesGuard at the class level", + ({ controller }) => { + const guards = Reflect.getMetadata(GUARDS_METADATA, controller) as + | Array unknown> + | undefined; + + expect(guards).toEqual([JwtAuthGuard, RolesGuard]); + }, + ); + + it.each(ADMIN_CONTROLLERS)( + "$name restricts every route to SUPER_ADMIN (no OPERATOR/SUPPORT)", + ({ controller }) => { + const roles = Reflect.getMetadata(ROLES_KEY, controller) as + | UserRole[] + | undefined; + + expect(roles).toEqual([UserRole.SUPER_ADMIN]); + // Defensive: the removed roles must never reappear here. + expect(roles).not.toContain("OPERATOR" as UserRole); + expect(roles).not.toContain("SUPPORT" as UserRole); + }, + ); +}); diff --git a/apps/backend/src/domains/platform/admin/admin-ops.service.spec.ts b/apps/backend/src/domains/platform/admin/admin-ops.service.spec.ts new file mode 100644 index 00000000..1336510f --- /dev/null +++ b/apps/backend/src/domains/platform/admin/admin-ops.service.spec.ts @@ -0,0 +1,223 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { ConfigService } from "@nestjs/config"; +import { ModuleRef } from "@nestjs/core"; +import { getQueueToken } from "@nestjs/bullmq"; + +import { AdminOpsService } from "./admin-ops.service"; +import { AdminService } from "./admin.service"; +import { PrismaService } from "../../../prisma/prisma.service"; +import { QUEUE } from "../../../queue/queue.constants"; + +describe("AdminOpsService", () => { + let service: AdminOpsService; + + const mockPrisma: any = { + webhookEvent: { + groupBy: jest.fn(), + findMany: jest.fn(), + }, + reconciliationLog: { + findFirst: jest.fn(), + count: jest.fn(), + }, + payout: { + findMany: jest.fn(), + }, + }; + + const mockConfig = { + get: jest.fn(), + }; + + // A fake queue that returns safe counts + one failed job carrying a payload + // we must never surface. + const fakeQueue = { + getJobCounts: jest.fn().mockResolvedValue({ + waiting: 2, + active: 1, + completed: 10, + failed: 1, + delayed: 0, + }), + getFailed: jest.fn().mockResolvedValue([ + { + id: "job-1", + name: "send-payout", + finishedOn: 1_700_000_000_000, + attemptsMade: 3, + failedReason: "Paystack transfer timed out", + // This must never appear in the output. + data: { secretToken: "sk_live_should_never_leak", amountKobo: 100000 }, + }, + ]), + }; + + const mockModuleRef = { + get: jest.fn((token: unknown) => { + // Only the payout queue resolves; others are "unavailable" and skipped. + if (token === getQueueToken(QUEUE.PAYOUT)) { + return fakeQueue; + } + throw new Error("not found"); + }), + }; + + const mockAdminService = { + getSystemAlerts: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminOpsService, + { provide: PrismaService, useValue: mockPrisma }, + { provide: ConfigService, useValue: mockConfig }, + { provide: ModuleRef, useValue: mockModuleRef }, + { provide: AdminService, useValue: mockAdminService }, + ], + }).compile(); + + service = module.get(AdminOpsService); + + mockPrisma.webhookEvent.groupBy.mockReset(); + mockPrisma.webhookEvent.findMany.mockReset().mockResolvedValue([]); + mockPrisma.reconciliationLog.findFirst.mockReset().mockResolvedValue(null); + mockPrisma.reconciliationLog.count.mockReset().mockResolvedValue(0); + mockPrisma.payout.findMany.mockReset().mockResolvedValue([]); + mockConfig.get.mockReset().mockReturnValue(""); + mockAdminService.getSystemAlerts.mockReset().mockResolvedValue([]); + + // Two groupBy calls: [0] last-received per provider, [1] recent by status. + mockPrisma.webhookEvent.groupBy + .mockResolvedValueOnce([ + { provider: "paystack", _max: { receivedAt: new Date() } }, + ]) + .mockResolvedValueOnce([ + { provider: "paystack", status: "FAILED", _count: { _all: 2 } }, + { provider: "paystack", status: "PROCESSED", _count: { _all: 8 } }, + ]); + }); + + it("returns safe queue counts and a failed job without its payload", async () => { + const overview = await service.getOverview(); + + expect(overview.queues.available).toBe(true); + expect(overview.queues.totalFailed).toBe(1); + expect(overview.queues.byQueue[0]).toMatchObject({ + name: QUEUE.PAYOUT, + waiting: 2, + active: 1, + failed: 1, + }); + + const job = overview.queues.recentFailedJobs[0]; + expect(job.queue).toBe(QUEUE.PAYOUT); + expect(job.message).toBe("Paystack transfer timed out"); + // The raw job payload / any secret is never returned. + const serialized = JSON.stringify(overview); + expect(serialized).not.toContain("sk_live_should_never_leak"); + expect(serialized).not.toContain("secretToken"); + }); + + it("summarizes webhook health without payloads/signatures", async () => { + const overview = await service.getOverview(); + + expect(overview.webhooks.available).toBe(true); + expect(overview.webhooks.recentFailures).toBe(2); + expect(overview.webhooks.recentSuccesses).toBe(8); + expect(overview.webhooks.byProvider[0].provider).toBe("paystack"); + + // groupBy never selects the payload column. + for (const call of mockPrisma.webhookEvent.groupBy.mock.calls) { + expect(JSON.stringify(call[0])).not.toContain("payload"); + } + }); + + it("reports provider status from config presence without leaking values", async () => { + mockConfig.get.mockImplementation((key: string) => + key === "paystack.secretKey" ? "sk_live_secret_value" : "", + ); + + const overview = await service.getOverview(); + + const paystack = overview.providers.find((p) => + p.provider.startsWith("Paystack"), + ); + const cloudinary = overview.providers.find((p) => + p.provider.startsWith("Cloudinary"), + ); + expect(paystack?.status).toBe("configured"); + expect(cloudinary?.status).toBe("not_configured"); + // The secret value is never echoed back. + expect(JSON.stringify(overview)).not.toContain("sk_live_secret_value"); + }); + + it("summarizes reconciliation as attention_needed when there are open items", async () => { + mockPrisma.reconciliationLog.findFirst.mockResolvedValue({ + status: "DISCREPANCY", + createdAt: new Date(), + }); + mockPrisma.reconciliationLog.count.mockResolvedValue(3); + + const overview = await service.getOverview(); + + expect(overview.reconciliation.status).toBe("attention_needed"); + expect(overview.reconciliation.openItems).toBe(3); + expect(overview.reconciliation.lastRunAt).not.toBeNull(); + }); + + it("maps system alerts to severities and counts", async () => { + mockAdminService.getSystemAlerts.mockResolvedValue([ + { id: "a1", type: "CRITICAL", title: "Failed payouts", message: "x" }, + { id: "a2", type: "WARNING", title: "Queue bloat", message: "y" }, + ]); + + const overview = await service.getOverview(); + + expect(overview.alerts.bySeverity.critical).toBe(1); + expect(overview.alerts.bySeverity.warning).toBe(1); + expect(overview.alerts.items[0].severity).toBe("critical"); + }); + + it("collects recent failed webhook errors without payloads", async () => { + mockPrisma.webhookEvent.findMany.mockResolvedValue([ + { + provider: "paystack", + eventType: "charge.success", + reference: "ref_1", + error: "signature mismatch", + receivedAt: new Date(), + }, + ]); + + const overview = await service.getOverview(); + + const webhookError = overview.recentErrors.find((e) => + e.source.startsWith("webhook"), + ); + expect(webhookError?.message).toBe("signature mismatch"); + + // The failed-webhook read never selects the payload column. + const call = mockPrisma.webhookEvent.findMany.mock.calls[0][0]; + expect(JSON.stringify(call.select)).not.toContain("payload"); + }); + + it("degrades queue metrics to unavailable when no queue resolves", async () => { + mockModuleRef.get.mockImplementationOnce(() => { + throw new Error("not found"); + }); + // Make every resolve fail. + mockModuleRef.get.mockImplementation(() => { + throw new Error("not found"); + }); + + const overview = await service.getOverview(); + expect(overview.queues.available).toBe(false); + + // Restore the payout-queue resolver for later tests. + mockModuleRef.get.mockImplementation((token: unknown) => { + if (token === getQueueToken(QUEUE.PAYOUT)) return fakeQueue; + throw new Error("not found"); + }); + }); +}); diff --git a/apps/backend/src/domains/platform/admin/admin-ops.service.ts b/apps/backend/src/domains/platform/admin/admin-ops.service.ts new file mode 100644 index 00000000..ac7f8666 --- /dev/null +++ b/apps/backend/src/domains/platform/admin/admin-ops.service.ts @@ -0,0 +1,481 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { getQueueToken } from "@nestjs/bullmq"; +import { ConfigService } from "@nestjs/config"; +import { ModuleRef } from "@nestjs/core"; +import type { Queue } from "bullmq"; + +import { PrismaService } from "../../../prisma/prisma.service"; +import { QUEUE } from "../../../queue/queue.constants"; +import { AdminService } from "./admin.service"; + +// Read-only technical-operations diagnostics for SUPER_ADMIN. +// +// Everything returned here is aggregate/safe: counts, statuses, timestamps, +// and short capped error strings. It never returns raw job payloads, webhook +// bodies, provider payloads, tokens, secrets, env values, or private prompts. + +export type ProviderStatus = + | "configured" + | "not_configured" + | "degraded" + | "unknown"; + +export interface QueueCounts { + name: string; + waiting: number; + active: number; + failed: number; + delayed: number; + completed: number; +} + +export interface SafeFailedJob { + queue: string; + id: string | null; + name: string; + failedAt: string | null; + attemptsMade: number; + message: string | null; +} + +export interface SafeOperationalError { + source: string; + reference: string | null; + message: string | null; + occurredAt: string | null; +} + +// Config-based only — presence of the key(s), never the value. +const PROVIDER_CONFIG: Array<{ provider: string; keys: string[] }> = [ + { provider: "Paystack (payments & payouts)", keys: ["paystack.secretKey"] }, + { provider: "Shipbubble (logistics)", keys: ["shipbubble.apiKey"] }, + { + provider: "Cloudinary (media)", + keys: ["cloudinary.apiKey", "cloudinary.apiSecret", "cloudinary.cloudName"], + }, + { provider: "Prembly (KYB)", keys: ["prembly.apiKey"] }, + { + provider: "WhatsApp (Meta Cloud)", + keys: ["whatsapp.accessToken", "whatsapp.phoneNumberId"], + }, + { provider: "Gemini (AI assistant)", keys: ["ai.geminiApiKey"] }, + { + provider: "Vertex / Google Cloud (embeddings & vision)", + keys: ["ai.googleCloudProject"], + }, + { provider: "Resend (email)", keys: ["resend.apiKey"] }, + { provider: "Africa's Talking (SMS/USSD)", keys: ["africastalking.apiKey"] }, + { provider: "Google Places (address)", keys: ["googlePlaces.apiKey"] }, +]; + +const RECENT_WINDOW_MS = 24 * 60 * 60 * 1000; +const MAX_MESSAGE_LENGTH = 200; +const MAX_FAILED_JOBS = 10; +const MAX_RECENT_ERRORS = 15; + +@Injectable() +export class AdminOpsService { + private readonly logger = new Logger(AdminOpsService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly config: ConfigService, + private readonly moduleRef: ModuleRef, + private readonly adminService: AdminService, + ) {} + + async getOverview() { + const now = new Date(); + const since = new Date(now.getTime() - RECENT_WINDOW_MS); + + const [queues, webhooks, reconciliation, recentErrors, alerts] = + await Promise.all([ + this.getQueueHealth(), + this.getWebhookHealth(since), + this.getReconciliationSummary(), + this.getRecentErrors(since), + this.getAlerts(), + ]); + + return { + generatedAt: now.toISOString(), + queues, + webhooks, + providers: this.getProviderStatus(now), + reconciliation, + recentErrors, + alerts, + }; + } + + // ── Queues (BullMQ) ────────────────────────────────────────────────────── + + private resolveQueue(name: string): Queue | null { + try { + return this.moduleRef.get(getQueueToken(name), { strict: false }); + } catch { + return null; + } + } + + private async getQueueHealth() { + const names = Object.values(QUEUE); + const byQueue: QueueCounts[] = []; + const recentFailedJobs: SafeFailedJob[] = []; + let available = true; + let anyResolved = false; + + for (const name of names) { + const queue = this.resolveQueue(name); + if (!queue) { + continue; + } + anyResolved = true; + try { + const counts = await queue.getJobCounts( + "waiting", + "active", + "completed", + "failed", + "delayed", + ); + byQueue.push({ + name, + waiting: counts.waiting ?? 0, + active: counts.active ?? 0, + failed: counts.failed ?? 0, + delayed: counts.delayed ?? 0, + completed: counts.completed ?? 0, + }); + + if ( + (counts.failed ?? 0) > 0 && + recentFailedJobs.length < MAX_FAILED_JOBS + ) { + await this.collectFailedJobs(queue, name, recentFailedJobs); + } + } catch (error) { + // A single queue read failing (e.g. Redis blip) marks the section + // degraded but never leaks the underlying error to the client. + available = false; + this.logger.warn( + `Queue counts unavailable for "${name}": ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + if (!anyResolved) { + available = false; + } + + return { + available, + totalWaiting: sum(byQueue, "waiting"), + totalActive: sum(byQueue, "active"), + totalFailed: sum(byQueue, "failed"), + totalDelayed: sum(byQueue, "delayed"), + byQueue, + recentFailedJobs, + }; + } + + private async collectFailedJobs( + queue: Queue, + name: string, + sink: SafeFailedJob[], + ) { + try { + const remaining = MAX_FAILED_JOBS - sink.length; + if (remaining <= 0) return; + const jobs = await queue.getFailed(0, remaining - 1); + for (const job of jobs) { + if (sink.length >= MAX_FAILED_JOBS) break; + sink.push({ + queue: name, + id: job.id ?? null, + name: job.name, + // NOTE: job.data is deliberately never read or returned. + failedAt: + typeof job.finishedOn === "number" + ? new Date(job.finishedOn).toISOString() + : null, + attemptsMade: job.attemptsMade ?? 0, + message: this.capMessage(job.failedReason), + }); + } + } catch (error) { + this.logger.warn( + `Failed-job read unavailable for "${name}": ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + // ── Webhooks ───────────────────────────────────────────────────────────── + + private async getWebhookHealth(since: Date) { + // All-time last-received per provider (indexed), plus recent counts by + // status. Never touches WebhookEvent.payload. + let lastReceived: Array<{ + provider: string; + _max: { receivedAt: Date | null }; + }> = []; + let recentByStatus: Array<{ + provider: string; + status: string; + _count: { _all: number }; + }> = []; + + try { + [lastReceived, recentByStatus] = await Promise.all([ + this.prisma.webhookEvent.groupBy({ + by: ["provider"], + _max: { receivedAt: true }, + }), + this.prisma.webhookEvent.groupBy({ + by: ["provider", "status"], + where: { receivedAt: { gte: since } }, + _count: { _all: true }, + }), + ]); + } catch (error) { + this.logger.warn( + `Webhook health unavailable: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return { + available: false, + recentFailures: 0, + recentSuccesses: 0, + byProvider: [], + }; + } + + const byProvider = lastReceived.map((row) => { + const failures = recentByStatus + .filter((r) => r.provider === row.provider && r.status === "FAILED") + .reduce((acc, r) => acc + r._count._all, 0); + const successes = recentByStatus + .filter((r) => r.provider === row.provider && r.status === "PROCESSED") + .reduce((acc, r) => acc + r._count._all, 0); + return { + provider: row.provider, + lastReceivedAt: row._max.receivedAt + ? row._max.receivedAt.toISOString() + : null, + recentFailureCount: failures, + recentSuccessCount: successes, + }; + }); + + return { + available: true, + recentFailures: byProvider.reduce( + (acc, p) => acc + p.recentFailureCount, + 0, + ), + recentSuccesses: byProvider.reduce( + (acc, p) => acc + p.recentSuccessCount, + 0, + ), + byProvider, + }; + } + + // ── Providers (config-based) ───────────────────────────────────────────── + + private getProviderStatus(now: Date): Array<{ + provider: string; + status: ProviderStatus; + lastCheckedAt: string; + }> { + return PROVIDER_CONFIG.map(({ provider, keys }) => { + const configured = keys.every((key) => { + const value = this.config.get(key); + return typeof value === "string" && value.trim().length > 0; + }); + return { + provider, + status: (configured + ? "configured" + : "not_configured") as ProviderStatus, + lastCheckedAt: now.toISOString(), + }; + }); + } + + // ── Reconciliation ─────────────────────────────────────────────────────── + + private async getReconciliationSummary() { + try { + const [latest, openItems] = await Promise.all([ + this.prisma.reconciliationLog.findFirst({ + orderBy: { createdAt: "desc" }, + select: { status: true, createdAt: true }, + }), + this.prisma.reconciliationLog.count({ + where: { status: { in: ["DISCREPANCY", "INVESTIGATING"] } }, + }), + ]); + + const status: "ok" | "attention_needed" | "unknown" = !latest + ? "unknown" + : openItems > 0 || latest.status === "DISCREPANCY" + ? "attention_needed" + : "ok"; + + return { + status, + openItems, + lastRunAt: latest?.createdAt ? latest.createdAt.toISOString() : null, + latestStatus: latest?.status ?? null, + }; + } catch (error) { + this.logger.warn( + `Reconciliation summary unavailable: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return { + status: "unknown" as const, + openItems: 0, + lastRunAt: null, + latestStatus: null, + }; + } + } + + // ── Recent operational errors (DB-sourced, safe) ───────────────────────── + + private async getRecentErrors(since: Date): Promise { + const errors: SafeOperationalError[] = []; + + try { + const failedWebhooks = await this.prisma.webhookEvent.findMany({ + where: { status: "FAILED", receivedAt: { gte: since } }, + orderBy: { receivedAt: "desc" }, + take: 10, + // payload is deliberately excluded from the select. + select: { + provider: true, + eventType: true, + reference: true, + error: true, + receivedAt: true, + }, + }); + for (const w of failedWebhooks) { + errors.push({ + source: `webhook:${w.provider}`, + reference: w.reference ?? w.eventType, + message: this.capMessage(w.error), + occurredAt: w.receivedAt.toISOString(), + }); + } + } catch (error) { + this.logger.warn( + `Failed-webhook read unavailable: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + try { + const failedPayouts = await this.prisma.payout.findMany({ + where: { status: "FAILED" }, + orderBy: { createdAt: "desc" }, + take: 10, + select: { + id: true, + orderId: true, + failureReason: true, + createdAt: true, + }, + }); + for (const p of failedPayouts) { + errors.push({ + source: "payout", + reference: p.orderId ?? p.id, + message: this.capMessage(p.failureReason), + occurredAt: p.createdAt.toISOString(), + }); + } + } catch (error) { + this.logger.warn( + `Failed-payout read unavailable: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + return errors + .sort((a, b) => (b.occurredAt ?? "").localeCompare(a.occurredAt ?? "")) + .slice(0, MAX_RECENT_ERRORS); + } + + // ── Alerts (reuse existing safe alert logic) ───────────────────────────── + + private async getAlerts() { + let raw: Array<{ + id: string; + type: string; + title: string; + message: string; + actionLink?: string; + }> = []; + try { + raw = (await this.adminService.getSystemAlerts()) as typeof raw; + } catch (error) { + this.logger.warn( + `System alerts unavailable: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + const items = raw.map((a) => ({ + id: a.id, + severity: this.mapSeverity(a.type), + title: a.title, + description: a.message, + targetHref: a.actionLink ?? null, + })); + + const bySeverity = { info: 0, warning: 0, critical: 0 }; + for (const item of items) { + bySeverity[item.severity] += 1; + } + + return { bySeverity, items }; + } + + private mapSeverity(type: string): "info" | "warning" | "critical" { + switch (type.toUpperCase()) { + case "CRITICAL": + return "critical"; + case "WARNING": + return "warning"; + default: + return "info"; + } + } + + // Collapses whitespace and caps length. Used on our own error strings so a + // long/verbose message can never dominate the response. + private capMessage(value: string | null | undefined): string | null { + if (!value) return null; + const collapsed = value.replace(/\s+/g, " ").trim(); + if (!collapsed) return null; + return collapsed.length > MAX_MESSAGE_LENGTH + ? `${collapsed.slice(0, MAX_MESSAGE_LENGTH)}…` + : collapsed; + } +} + +function sum(rows: QueueCounts[], key: keyof QueueCounts): number { + return rows.reduce((acc, row) => acc + (row[key] as number), 0); +} diff --git a/apps/backend/src/domains/platform/admin/admin.controller.spec.ts b/apps/backend/src/domains/platform/admin/admin.controller.spec.ts new file mode 100644 index 00000000..0c2e6627 --- /dev/null +++ b/apps/backend/src/domains/platform/admin/admin.controller.spec.ts @@ -0,0 +1,410 @@ +import { GUARDS_METADATA } from "@nestjs/common/constants"; +import { UserRole } from "@twizrr/shared"; +import { ROLES_KEY } from "../../../common/decorators/roles.decorator"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { RolesGuard } from "../../../common/guards/roles.guard"; +import { AdminController } from "./admin.controller"; + +describe("AdminController timeline routes", () => { + const adminService = { + getDashboard: jest.fn(), + getOrders: jest.fn(), + getOrderById: jest.fn(), + getDomainEventTimeline: jest.fn(), + getOrderTimeline: jest.fn(), + getPaymentTimeline: jest.fn(), + getPayouts: jest.fn(), + getPayoutById: jest.fn(), + processPayout: jest.fn(), + getPayoutTimeline: jest.fn(), + getDisputeTimeline: jest.fn(), + getStoreTimeline: jest.fn(), + getReadyForPickupDeliveryQueue: jest.fn(), + getDeliveryById: jest.fn(), + getDeliveryTimeline: jest.fn(), + createManualDeliveryBooking: jest.fn(), + getShipbubbleDeliveryRates: jest.fn(), + bookDeliveryWithShipbubble: jest.fn(), + startDelivery: jest.fn(), + verifyMerchant: jest.fn(), + rejectMerchant: jest.fn(), + getVerificationQueue: jest.fn(), + getVerificationRequestDetail: jest.fn(), + reviewVerificationRequest: jest.fn(), + getAuditLogs: jest.fn(), + getAuditLogById: jest.fn(), + getAuditLogFacets: jest.fn(), + }; + + const adminOpsService = { + getOverview: jest.fn(), + }; + + // Twizrr admin is SUPER_ADMIN-only — no OPERATOR/SUPPORT roles. + const routeRoles = ( + handler: (...args: never[]) => unknown, + ): UserRole[] | undefined => + Reflect.getMetadata(ROLES_KEY, handler) as UserRole[] | undefined; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("keeps admin routes behind JWT and role guards (SUPER_ADMIN only)", () => { + const guards = Reflect.getMetadata(GUARDS_METADATA, AdminController) as + | Array unknown> + | undefined; + const roles = Reflect.getMetadata(ROLES_KEY, AdminController) as + | UserRole[] + | undefined; + + expect(guards).toEqual([JwtAuthGuard, RolesGuard]); + expect(roles).toEqual([UserRole.SUPER_ADMIN]); + }); + + it("delegates the dashboard overview to AdminService with the range query", () => { + const controller = new AdminController( + adminService as never, + adminOpsService as never, + ); + const query = { range: "30d" as const }; + + controller.getDashboard(query); + + expect(adminService.getDashboard).toHaveBeenCalledWith(query); + }); + + it("delegates payout list/detail and the money-moving process action (SUPER_ADMIN)", () => { + const controller = new AdminController( + adminService as never, + adminOpsService as never, + ); + const query = { status: "FAILED", page: 1 }; + const req = { user: { sub: "admin-1" } }; + + controller.getPayouts(query); + controller.getPayoutById("payout-1"); + controller.processPayout("payout-1", req as never); + + expect(adminService.getPayouts).toHaveBeenCalledWith(query); + expect(adminService.getPayoutById).toHaveBeenCalledWith("payout-1"); + expect(adminService.processPayout).toHaveBeenCalledWith( + "payout-1", + "admin-1", + undefined, + ); + expect(routeRoles(AdminController.prototype.getPayoutById)).toEqual([ + UserRole.SUPER_ADMIN, + ]); + expect(routeRoles(AdminController.prototype.processPayout)).toEqual([ + UserRole.SUPER_ADMIN, + ]); + }); + + it("delegates the global orders list and detail to AdminService (SUPER_ADMIN)", () => { + const controller = new AdminController( + adminService as never, + adminOpsService as never, + ); + const query = { status: "PAID", payoutStatus: "PENDING", page: 1 }; + + controller.getOrders(query); + controller.getOrderById("order-1"); + controller.getOrderTimeline("order-1", {}); + + expect(adminService.getOrders).toHaveBeenCalledWith(query); + expect(adminService.getOrderById).toHaveBeenCalledWith("order-1"); + expect(adminService.getOrderTimeline).toHaveBeenCalledWith("order-1", {}); + // Whole controller is SUPER_ADMIN-only (asserted in the guard test above). + }); + + it("delegates the generic timeline route to AdminService", () => { + const controller = new AdminController( + adminService as never, + adminOpsService as never, + ); + const query = { limit: 10 }; + + controller.getDomainEventTimeline("order", "order-1", query); + + expect(adminService.getDomainEventTimeline).toHaveBeenCalledWith( + "order", + "order-1", + query, + ); + }); + + it("delegates explicit timeline routes to AdminService", () => { + const controller = new AdminController( + adminService as never, + adminOpsService as never, + ); + const query = { page: 1, limit: 5 }; + + controller.getOrderTimeline("order-1", query); + controller.getPaymentTimeline("payment-1", query); + controller.getPayoutTimeline("payout-1", query); + controller.getDisputeTimeline("dispute-1", query); + controller.getStoreTimeline("store-1", query); + + expect(adminService.getOrderTimeline).toHaveBeenCalledWith( + "order-1", + query, + ); + expect(adminService.getPaymentTimeline).toHaveBeenCalledWith( + "payment-1", + query, + ); + expect(adminService.getPayoutTimeline).toHaveBeenCalledWith( + "payout-1", + query, + ); + expect(adminService.getDisputeTimeline).toHaveBeenCalledWith( + "dispute-1", + query, + ); + expect(adminService.getStoreTimeline).toHaveBeenCalledWith( + "store-1", + query, + ); + }); + + it("delegates delivery detail + timeline to AdminService (SUPER_ADMIN)", () => { + const controller = new AdminController( + adminService as never, + adminOpsService as never, + ); + const query = { limit: 20 }; + + controller.getDeliveryById("booking-1"); + controller.getDeliveryTimeline("booking-1", query); + + expect(adminService.getDeliveryById).toHaveBeenCalledWith("booking-1"); + expect(adminService.getDeliveryTimeline).toHaveBeenCalledWith( + "booking-1", + query, + ); + expect(routeRoles(AdminController.prototype.getDeliveryById)).toEqual([ + UserRole.SUPER_ADMIN, + ]); + expect(routeRoles(AdminController.prototype.getDeliveryTimeline)).toEqual([ + UserRole.SUPER_ADMIN, + ]); + }); + + it("restricts the delivery queue read route to SUPER_ADMIN", () => { + const controller = new AdminController( + adminService as never, + adminOpsService as never, + ); + const query = { limit: 10 }; + + controller.getReadyForPickupDeliveryQueue(query); + + expect( + routeRoles(AdminController.prototype.getReadyForPickupDeliveryQueue), + ).toEqual([UserRole.SUPER_ADMIN]); + expect(adminService.getReadyForPickupDeliveryQueue).toHaveBeenCalledWith( + query, + ); + }); + + it("restricts manual delivery booking to SUPER_ADMIN", () => { + const controller = new AdminController( + adminService as never, + adminOpsService as never, + ); + const req = { user: { sub: "admin-1" } }; + + controller.createManualDeliveryBooking("order-1", req as never); + + expect( + routeRoles(AdminController.prototype.createManualDeliveryBooking), + ).toEqual([UserRole.SUPER_ADMIN]); + expect(adminService.createManualDeliveryBooking).toHaveBeenCalledWith( + "order-1", + "admin-1", + undefined, + ); + }); + + it("restricts start-delivery to SUPER_ADMIN", () => { + const controller = new AdminController( + adminService as never, + adminOpsService as never, + ); + const req = { user: { sub: "admin-1" } }; + + controller.startDelivery("order-1", req as never); + + expect(routeRoles(AdminController.prototype.startDelivery)).toEqual([ + UserRole.SUPER_ADMIN, + ]); + expect(adminService.startDelivery).toHaveBeenCalledWith( + "order-1", + "admin-1", + undefined, + ); + }); + + it("restricts Shipbubble booking to SUPER_ADMIN", () => { + const controller = new AdminController( + adminService as never, + adminOpsService as never, + ); + const req = { user: { sub: "admin-1" } }; + const dto = { rateId: "rate-1", requestToken: "request-1" }; + + controller.bookDeliveryWithShipbubble("booking-1", dto, req as never); + + expect( + routeRoles(AdminController.prototype.bookDeliveryWithShipbubble), + ).toEqual([UserRole.SUPER_ADMIN]); + expect(adminService.bookDeliveryWithShipbubble).toHaveBeenCalledWith( + "booking-1", + "admin-1", + dto, + undefined, + ); + }); + + it("restricts Shipbubble rate lookup to SUPER_ADMIN", () => { + const controller = new AdminController( + adminService as never, + adminOpsService as never, + ); + const req = { user: { sub: "admin-1" } }; + + controller.getShipbubbleDeliveryRates("booking-1", req as never); + + expect( + routeRoles(AdminController.prototype.getShipbubbleDeliveryRates), + ).toEqual([UserRole.SUPER_ADMIN]); + expect(adminService.getShipbubbleDeliveryRates).toHaveBeenCalledWith( + "booking-1", + "admin-1", + ); + }); + + it("restricts verification queue reads to SUPER_ADMIN", () => { + const controller = new AdminController( + adminService as never, + adminOpsService as never, + ); + const query = { limit: 10 }; + + controller.getVerificationQueue(query); + + expect(routeRoles(AdminController.prototype.getVerificationQueue)).toEqual([ + UserRole.SUPER_ADMIN, + ]); + expect(adminService.getVerificationQueue).toHaveBeenCalledWith(query); + }); + + it("restricts verification request detail reads to SUPER_ADMIN", () => { + const controller = new AdminController( + adminService as never, + adminOpsService as never, + ); + + controller.getVerificationRequestDetail("req-1"); + + expect( + routeRoles(AdminController.prototype.getVerificationRequestDetail), + ).toEqual([UserRole.SUPER_ADMIN]); + expect(adminService.getVerificationRequestDetail).toHaveBeenCalledWith( + "req-1", + ); + }); + + it("routes the canonical verification review to SUPER_ADMIN and passes the reason", () => { + const controller = new AdminController( + adminService as never, + adminOpsService as never, + ); + const req = { user: { sub: "admin-1" } }; + const dto = { + decision: "REJECTED" as const, + rejectionReason: "Selfie did not match the submitted identity", + }; + + controller.reviewVerificationRequest("req-1", dto, req as never); + + expect( + routeRoles(AdminController.prototype.reviewVerificationRequest), + ).toEqual([UserRole.SUPER_ADMIN]); + expect(adminService.reviewVerificationRequest).toHaveBeenCalledWith( + "req-1", + "REJECTED", + "Selfie did not match the submitted identity", + "admin-1", + undefined, + ); + }); + + it("restricts store verification mutations to SUPER_ADMIN", () => { + expect(routeRoles(AdminController.prototype.verifyStoreProfile)).toEqual([ + UserRole.SUPER_ADMIN, + ]); + expect(routeRoles(AdminController.prototype.rejectStoreProfile)).toEqual([ + UserRole.SUPER_ADMIN, + ]); + }); + + it("passes the rejection reason to AdminService", () => { + const controller = new AdminController( + adminService as never, + adminOpsService as never, + ); + const req = { user: { sub: "admin-1" } }; + const dto = { reason: "Identity document could not be verified" }; + + controller.rejectStoreProfile("store-1", dto, req as never); + + expect(adminService.rejectMerchant).toHaveBeenCalledWith( + "store-1", + "Identity document could not be verified", + "admin-1", + undefined, + ); + }); + + it("delegates the read-only technical-ops overview to AdminOpsService (SUPER_ADMIN)", () => { + const controller = new AdminController( + adminService as never, + adminOpsService as never, + ); + + controller.getOpsOverview(); + + expect(adminOpsService.getOverview).toHaveBeenCalled(); + expect(routeRoles(AdminController.prototype.getOpsOverview)).toEqual([ + UserRole.SUPER_ADMIN, + ]); + }); + + it("delegates the read-only audit log routes to AdminService (SUPER_ADMIN)", () => { + const controller = new AdminController( + adminService as never, + adminOpsService as never, + ); + const query = { action: "DELETE_USER", page: 1 }; + + controller.getAuditLogs(query); + controller.getAuditLogById("audit-1"); + controller.getAuditLogFacets(); + + expect(adminService.getAuditLogs).toHaveBeenCalledWith(query); + expect(adminService.getAuditLogById).toHaveBeenCalledWith("audit-1"); + expect(adminService.getAuditLogFacets).toHaveBeenCalled(); + expect(routeRoles(AdminController.prototype.getAuditLogs)).toEqual([ + UserRole.SUPER_ADMIN, + ]); + expect(routeRoles(AdminController.prototype.getAuditLogById)).toEqual([ + UserRole.SUPER_ADMIN, + ]); + expect(routeRoles(AdminController.prototype.getAuditLogFacets)).toEqual([ + UserRole.SUPER_ADMIN, + ]); + }); +}); diff --git a/apps/backend/src/domains/platform/admin/admin.controller.ts b/apps/backend/src/domains/platform/admin/admin.controller.ts new file mode 100644 index 00000000..fb3935b2 --- /dev/null +++ b/apps/backend/src/domains/platform/admin/admin.controller.ts @@ -0,0 +1,1134 @@ +import { + Controller, + Get, + Patch, + Delete, + Param, + UseGuards, + Body, + Req, + Query, + Post, + Put, +} from "@nestjs/common"; +import { Request } from "express"; +import { AdminService } from "./admin.service"; +import { AdminOpsService } from "./admin-ops.service"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { RolesGuard } from "../../../common/guards/roles.guard"; +import { Roles } from "../../../common/decorators/roles.decorator"; +import { UserRole, OrderStatus } from "@twizrr/shared"; +import { + PaymentAmountExceptionType, + PaymentProviderName, + StoreTier, +} from "@prisma/client"; +import { Type } from "class-transformer"; +import { + IsEnum, + IsString, + IsNotEmpty, + IsBoolean, + IsIn, + IsInt, + IsNumber, + IsOptional, + Max, + MaxLength, + Min, + MinLength, + ValidateIf, +} from "class-validator"; + +interface AuthenticatedRequest extends Request { + user: { + sub: string; + email: string; + role: string; + }; +} + +export class AdminListQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + tier?: string; + + // Dashboard time-range preset. Custom windows use startDate/endDate. + @IsOptional() + @IsIn(["today", "7d", "30d"]) + range?: "today" | "7d" | "30d"; + + // Order console filters (ignored by routes that don't use them). + @IsOptional() + @IsString() + payoutStatus?: string; + + @IsOptional() + @IsString() + disputeStatus?: string; + + @IsOptional() + @IsString() + deliveryStatus?: string; + + @IsOptional() + @IsString() + q?: string; + + @IsOptional() + @IsString() + startDate?: string; + + @IsOptional() + @IsString() + endDate?: string; +} + +export class AdminAuditLogQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @IsString() + action?: string; + + @IsOptional() + @IsString() + targetType?: string; + + @IsOptional() + @IsString() + targetId?: string; + + @IsOptional() + @IsString() + userId?: string; + + @IsOptional() + @IsString() + q?: string; + + @IsOptional() + @IsString() + startDate?: string; + + @IsOptional() + @IsString() + endDate?: string; +} + +export class AdminSecurityEventQueryDto extends AdminListQueryDto { + @IsOptional() @IsString() eventType?: string; + @IsOptional() @IsString() signalCode?: string; + @IsOptional() @IsIn(["low", "medium", "high"]) severity?: + | "low" + | "medium" + | "high"; + @IsOptional() @IsString() userId?: string; + @IsOptional() @IsString() ipAddress?: string; + @IsOptional() @IsString() deviceType?: string; + @IsOptional() @IsString() requestId?: string; + @IsOptional() @IsString() source?: string; +} + +export class AdminSafetyInvestigationQueryDto extends AdminListQueryDto { + @IsOptional() @IsString() signalCode?: string; + @IsOptional() @IsIn(["low", "medium", "high"]) severity?: + | "low" + | "medium" + | "high"; + @IsOptional() @IsString() userId?: string; + @IsOptional() @IsIn(["active", "suspended", "deactivated"]) status?: + | "active" + | "suspended" + | "deactivated"; + @IsOptional() + @IsIn(["same_device", "same_ip", "suspended_user_device", "recent_creation"]) + evidenceType?: + | "same_device" + | "same_ip" + | "suspended_user_device" + | "recent_creation"; +} + +export class AdminSettlementQueryDto extends AdminListQueryDto { + @IsOptional() @IsString() outcome?: string; + @IsOptional() @IsString() legType?: string; + @IsOptional() @IsString() legStatus?: string; + @IsOptional() @IsString() disputeId?: string; + @IsOptional() @IsString() orderId?: string; + @IsOptional() @IsString() createdFrom?: string; + @IsOptional() @IsString() createdTo?: string; +} + +export class AdminSettlementManualReviewDto { + @IsString() + @IsNotEmpty() + @MaxLength(500) + reason!: string; +} + +export class AdminPaymentAmountExceptionQueryDto extends AdminListQueryDto { + @IsOptional() @IsEnum(PaymentAmountExceptionType) exceptionType?: string; + @IsOptional() @IsEnum(PaymentProviderName) provider?: string; + @IsOptional() @IsString() paymentId?: string; + @IsOptional() @IsString() orderId?: string; +} + +export class AdminPaymentAmountExceptionManualReviewDto { + @IsString() + @IsNotEmpty() + @MaxLength(500) + reason!: string; +} + +export class UpdateUserStatusDto { + @IsBoolean() + isActive!: boolean; + + @IsString() + @IsNotEmpty() + reason!: string; +} + +export class UpdateUserRoleDto { + @IsEnum(UserRole) + role!: UserRole; + + @IsString() + @IsNotEmpty() + reason!: string; +} + +export class UpdateStoreStatusDto { + @IsOptional() + @IsBoolean() + isOpen?: boolean; + + @IsOptional() + @IsBoolean() + showOwnerPublicly?: boolean; + + @IsString() + @IsNotEmpty() + reason!: string; +} + +export class SetStoreTierDto { + @IsEnum(StoreTier) + tier!: StoreTier; + + @IsOptional() + @IsString() + @MaxLength(300) + reason?: string; +} + +export class ReviewVerificationRequestDto { + @IsIn(["APPROVED", "REJECTED"]) + decision!: "APPROVED" | "REJECTED"; + + // Reject requires a real, human-readable reason (stored on the request and + // shown to the store owner). Approve ignores it. Backend is the source of + // truth for this rule — the console mirrors it client-side. + @ValidateIf((o: ReviewVerificationRequestDto) => o.decision === "REJECTED") + @IsString() + @IsNotEmpty({ message: "rejectionReason is required when rejecting" }) + @MinLength(5, { + message: "rejectionReason must be at least 5 characters", + }) + rejectionReason?: string; +} + +export class RejectStoreProfileDto { + @IsString() + @IsNotEmpty() + reason!: string; +} + +export class ModerationReviewDto { + @IsIn(["SAFE", "SENSITIVE", "BLOCKED"]) + decision!: "SAFE" | "SENSITIVE" | "BLOCKED"; + + @IsString() + @IsNotEmpty() + reason!: string; +} + +export class ToggleMerchantFlagDto { + @IsIn(["addressVerified", "bankVerified"]) + flag!: "addressVerified" | "bankVerified"; + + @IsBoolean() + value!: boolean; +} + +export class ResolveDisputeDto { + @IsIn(["SHOPPER", "STORE"]) + decision!: "SHOPPER" | "STORE"; + + @IsString() + notes?: string; +} + +// Dispute-console resolution requires a real reason. The service re-validates +// (AdminService.resolveDisputeById) as the source of truth. +export class ResolveDisputeReasonDto { + @IsIn(["SHOPPER", "STORE"]) + decision!: "SHOPPER" | "STORE"; + + @IsString() + @IsNotEmpty({ message: "notes is required to resolve a dispute" }) + @MinLength(5, { message: "notes must be at least 5 characters" }) + notes!: string; +} + +export class BookShipbubbleDeliveryDto { + @IsString() + @IsNotEmpty() + rateId!: string; + + @IsString() + @IsNotEmpty() + requestToken!: string; + + @IsOptional() + @IsString() + @MaxLength(80) + serviceCode?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0.1) + weightKg?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + categoryId?: number; + + @IsOptional() + @IsString() + @MaxLength(120) + itemName?: string; + + @IsOptional() + @IsString() + itemValueKobo?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + lengthCm?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + widthCm?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + heightCm?: number; + + @IsOptional() + @IsString() + @MaxLength(240) + description?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + quantity?: number; +} + +@Controller("admin") +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.SUPER_ADMIN) +export class AdminController { + constructor( + private readonly adminService: AdminService, + private readonly adminOpsService: AdminOpsService, + ) {} + + @Get("dashboard") + getDashboard(@Query() query: AdminListQueryDto) { + return this.adminService.getDashboard(query); + } + + // Read-only technical-operations diagnostics (queues, webhooks, providers, + // reconciliation, recent errors, alerts). Aggregate/safe data only. + @Get("ops/overview") + @Roles(UserRole.SUPER_ADMIN) + getOpsOverview() { + return this.adminOpsService.getOverview(); + } + + @Get("stats") + @Roles(UserRole.SUPER_ADMIN) + getSystemStats() { + return this.adminService.getPlatformStats(); + } + + @Patch("merchants/:id/verify") + @Roles(UserRole.SUPER_ADMIN) + verifyStoreProfile( + @Param("id") storeId: string, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.verifyMerchant( + storeId, + req.user.sub, + req.securityContext, + ); + } + + @Patch("merchants/:id/reject") + @Roles(UserRole.SUPER_ADMIN) + rejectStoreProfile( + @Param("id") storeId: string, + @Body() dto: RejectStoreProfileDto, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.rejectMerchant( + storeId, + dto.reason, + req.user.sub, + req.securityContext, + ); + } + + @Patch("merchants/:id/flags") + @Roles(UserRole.SUPER_ADMIN) + toggleMerchantFlag( + @Param("id") storeId: string, + @Body() dto: ToggleMerchantFlagDto, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.toggleMerchantFlag( + storeId, + dto.flag, + dto.value, + req.user.sub, + req.securityContext, + ); + } + + @Get("merchants/pending") + getPendingMerchants() { + return this.adminService.getPendingMerchants(); + } + + @Get("stores") + getStores(@Query() query: AdminListQueryDto) { + return this.adminService.getStores(query); + } + + @Get("stores/:id/verification") + getStoreVerification(@Param("id") storeId: string) { + return this.adminService.getStoreVerification(storeId); + } + + @Get("stores/:id/timeline") + getStoreTimeline( + @Param("id") storeId: string, + @Query() query: AdminListQueryDto, + ) { + return this.adminService.getStoreTimeline(storeId, query); + } + + @Get("stores/:id") + getStoreById(@Param("id") storeId: string) { + return this.adminService.getStoreById(storeId); + } + + @Patch("stores/:id/status") + @Roles(UserRole.SUPER_ADMIN) + updateStoreStatus( + @Param("id") storeId: string, + @Body() dto: UpdateStoreStatusDto, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.updateStoreStatus( + storeId, + dto, + req.user.sub, + req.securityContext, + ); + } + + @Patch("stores/:id/tier") + @Roles(UserRole.SUPER_ADMIN) + setStoreTier( + @Param("id") storeId: string, + @Body() dto: SetStoreTierDto, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.setStoreTier( + storeId, + dto.tier, + req.user.sub, + dto.reason, + req.securityContext, + ); + } + + @Patch("orders/:id/force-resolve") + @Roles(UserRole.SUPER_ADMIN) + forceResolveOrder( + @Param("id") orderId: string, + @Body("status") status: OrderStatus, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.forceResolveOrder(orderId, status, req.user.sub); + } + + @Patch("orders/:id/verify") + @Roles(UserRole.SUPER_ADMIN) + markOrderVerified( + @Param("id") orderId: string, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.markOrderVerified(orderId, req.user.sub); + } + + // ─── Dispute Resolution ─── + + @Post("orders/:id/resolve-dispute") + @Roles(UserRole.SUPER_ADMIN) + resolveDispute( + @Param("id") orderId: string, + @Body() dto: ResolveDisputeDto, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.resolveDispute( + orderId, + dto.decision, + req.user.sub, + dto.notes, + req.securityContext, + ); + } + + @Get("products") + getAllProducts() { + return this.adminService.getAllProducts(); + } + + @Get("analytics") + @Roles(UserRole.SUPER_ADMIN) + getGlobalAnalytics() { + return this.adminService.getGlobalAnalytics(); + } + + @Get("alerts") + getSystemAlerts() { + return this.adminService.getSystemAlerts(); + } + + @Get("orders") + getOrders(@Query() query: AdminListQueryDto) { + return this.adminService.getOrders(query); + } + + @Get("orders/export") + exportOrders( + @Query("startDate") startDate?: string, + @Query("endDate") endDate?: string, + ) { + return this.adminService.exportOrders(startDate, endDate); + } + + @Get("orders/:id/timeline") + getOrderTimeline( + @Param("id") orderId: string, + @Query() query: AdminListQueryDto, + ) { + return this.adminService.getOrderTimeline(orderId, query); + } + + @Get("orders/:id") + getOrderById(@Param("id") orderId: string) { + return this.adminService.getOrderById(orderId); + } + + @Get("disputes") + getDisputes(@Query() query: AdminListQueryDto) { + return this.adminService.getDisputes(query); + } + + @Get("dispute-settlements") + @Roles(UserRole.SUPER_ADMIN) + getDisputeSettlements(@Query() query: AdminSettlementQueryDto) { + return this.adminService.getDisputeSettlements(query); + } + + @Get("dispute-settlements/summary") + @Roles(UserRole.SUPER_ADMIN) + getDisputeSettlementSummary() { + return this.adminService.getDisputeSettlementSummary(); + } + + @Get("dispute-settlements/:settlementId") + @Roles(UserRole.SUPER_ADMIN) + getDisputeSettlement(@Param("settlementId") settlementId: string) { + return this.adminService.getDisputeSettlement(settlementId); + } + + @Post("dispute-settlements/:settlementId/reconcile") + @Roles(UserRole.SUPER_ADMIN) + reconcileDisputeSettlement(@Param("settlementId") settlementId: string) { + return this.adminService.reconcileDisputeSettlement(settlementId); + } + + @Post("dispute-settlements/:settlementId/manual-review") + @Roles(UserRole.SUPER_ADMIN) + moveSettlementToManualReview( + @Param("settlementId") settlementId: string, + @Body() dto: AdminSettlementManualReviewDto, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.moveSettlementToManualReview( + settlementId, + req.user.sub, + dto.reason, + req.securityContext, + ); + } + + @Post("dispute-settlement-legs/:legId/retry") + @Roles(UserRole.SUPER_ADMIN) + retryDisputeSettlementLeg(@Param("legId") legId: string) { + return this.adminService.retryDisputeSettlementLeg(legId); + } + + @Post("dispute-settlement-legs/:legId/reconcile") + @Roles(UserRole.SUPER_ADMIN) + reconcileDisputeSettlementLeg(@Param("legId") legId: string) { + return this.adminService.reconcileDisputeSettlementLeg(legId); + } + + @Get("disputes/:id/timeline") + getDisputeTimeline( + @Param("id") disputeId: string, + @Query() query: AdminListQueryDto, + ) { + return this.adminService.getDisputeTimeline(disputeId, query); + } + + @Get("disputes/:id") + getDisputeById(@Param("id") disputeId: string) { + return this.adminService.getDisputeById(disputeId); + } + + @Put("disputes/:id/resolve") + @Roles(UserRole.SUPER_ADMIN) + resolveDisputeById( + @Param("id") disputeId: string, + @Body() dto: ResolveDisputeReasonDto, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.resolveDisputeById( + disputeId, + dto.decision, + req.user.sub, + dto.notes, + req.securityContext, + ); + } + + @Post("broadcast") + @Roles(UserRole.SUPER_ADMIN) + broadcastMessage(@Body("message") message: string) { + return this.adminService.broadcastMessage(message); + } + + @Delete("products/:id") + @Roles(UserRole.SUPER_ADMIN) + deleteProduct(@Param("id") productId: string) { + return this.adminService.deleteProduct(productId); + } + + @Get("users") + getUsers(@Query() query: AdminListQueryDto) { + return this.adminService.getUsers(query); + } + + @Get("users/:id") + getUserById(@Param("id") userId: string) { + return this.adminService.getUserById(userId); + } + + @Patch("users/:id/status") + @Roles(UserRole.SUPER_ADMIN) + updateUserStatus( + @Param("id") userId: string, + @Body() dto: UpdateUserStatusDto, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.updateUserStatus( + userId, + dto.isActive, + dto.reason, + req.user.sub, + req.securityContext, + ); + } + + @Patch("users/:id/role") + @Roles(UserRole.SUPER_ADMIN) + updateUserRole( + @Param("id") userId: string, + @Body() dto: UpdateUserRoleDto, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.updateUserRole( + userId, + dto.role, + dto.reason, + req.user.sub, + req.securityContext, + ); + } + + @Delete("users/:id") + @Roles(UserRole.SUPER_ADMIN) + deleteUser(@Param("id") userId: string, @Req() req: AuthenticatedRequest) { + return this.adminService.deleteUser( + userId, + req.user.sub, + req.securityContext, + ); + } + + @Patch("change-password") + changePassword( + @Body("currentPassword") currentPassword: string, + @Body("newPassword") newPassword: string, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.changePassword( + req.user.sub, + currentPassword, + newPassword, + ); + } + + // ─── Payout Management ─── + + @Get("payouts") + @Roles(UserRole.SUPER_ADMIN) + getPayouts(@Query() query: AdminListQueryDto) { + return this.adminService.getPayouts(query); + } + + @Get("payouts/:id/timeline") + @Roles(UserRole.SUPER_ADMIN) + getPayoutTimeline( + @Param("id") payoutId: string, + @Query() query: AdminListQueryDto, + ) { + return this.adminService.getPayoutTimeline(payoutId, query); + } + + @Get("payouts/:id") + @Roles(UserRole.SUPER_ADMIN) + getPayoutById(@Param("id") payoutId: string) { + return this.adminService.getPayoutById(payoutId); + } + + @Get("payments") + @Roles(UserRole.SUPER_ADMIN) + getPayments(@Query() query: AdminListQueryDto) { + return this.adminService.getPayments(query); + } + + @Get("payment-amount-exceptions") + @Roles(UserRole.SUPER_ADMIN) + getPaymentAmountExceptions( + @Query() query: AdminPaymentAmountExceptionQueryDto, + ) { + return this.adminService.getPaymentAmountExceptions(query); + } + + @Get("payment-amount-exceptions/:id") + @Roles(UserRole.SUPER_ADMIN) + getPaymentAmountException(@Param("id") exceptionId: string) { + return this.adminService.getPaymentAmountException(exceptionId); + } + + @Post("payment-amount-exceptions/:id/reconcile") + @Roles(UserRole.SUPER_ADMIN) + reconcilePaymentAmountException( + @Param("id") exceptionId: string, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.reconcilePaymentAmountException( + exceptionId, + req.user.sub, + req.securityContext, + ); + } + + @Post("payment-amount-exceptions/:id/manual-review") + @Roles(UserRole.SUPER_ADMIN) + movePaymentAmountExceptionToManualReview( + @Param("id") exceptionId: string, + @Body() dto: AdminPaymentAmountExceptionManualReviewDto, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.movePaymentAmountExceptionToManualReview( + exceptionId, + req.user.sub, + dto.reason, + req.securityContext, + ); + } + + @Post("payment-amount-exceptions/:id/refund") + @Roles(UserRole.SUPER_ADMIN) + createPaymentAmountExceptionRefund( + @Param("id") exceptionId: string, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.createPaymentAmountExceptionRefund( + exceptionId, + req.user.sub, + req.securityContext, + ); + } + + @Get("payments/:id/timeline") + @Roles(UserRole.SUPER_ADMIN) + getPaymentTimeline( + @Param("id") paymentId: string, + @Query() query: AdminListQueryDto, + ) { + return this.adminService.getPaymentTimeline(paymentId, query); + } + + @Get("timeline/:aggregateType/:aggregateId") + @Roles(UserRole.SUPER_ADMIN) + getDomainEventTimeline( + @Param("aggregateType") aggregateType: string, + @Param("aggregateId") aggregateId: string, + @Query() query: AdminListQueryDto, + ) { + return this.adminService.getDomainEventTimeline( + aggregateType, + aggregateId, + query, + ); + } + + @Get("ledger") + @Roles(UserRole.SUPER_ADMIN) + getLedger(@Query() query: AdminListQueryDto) { + return this.adminService.getLedger(query); + } + + @Get("reconciliation") + @Roles(UserRole.SUPER_ADMIN) + getReconciliation(@Query() query: AdminListQueryDto) { + return this.adminService.getReconciliation(query); + } + + // ─── Store verification review console (canonical, request-keyed) ─── + // One canonical admin verification surface. Approve/reject flows through + // AdminService.reviewVerificationRequest, which delegates the decision to the + // single VerificationService.reviewRequest implementation and writes the + // AuditLog + DomainEvent. SUPER_ADMIN only (enforced on the controller). + + @Get("verification/requests") + @Roles(UserRole.SUPER_ADMIN) + getVerificationQueue(@Query() query: AdminListQueryDto) { + return this.adminService.getVerificationQueue(query); + } + + @Get("verification/requests/:id") + @Roles(UserRole.SUPER_ADMIN) + getVerificationRequestDetail(@Param("id") requestId: string) { + return this.adminService.getVerificationRequestDetail(requestId); + } + + @Get("verification/attempts/reconciliation") + @Roles(UserRole.SUPER_ADMIN) + getVerificationReconciliationAttempts(@Query() query: AdminListQueryDto) { + return this.adminService.getVerificationReconciliationAttempts(query); + } + + @Post("verification/attempts/:id/reconcile") + @Roles(UserRole.SUPER_ADMIN) + reconcileVerificationAttempt( + @Param("id") attemptId: string, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.reconcileVerificationAttempt( + attemptId, + req.user.sub, + req.securityContext, + ); + } + + @Post("verification/requests/:id/review") + @Roles(UserRole.SUPER_ADMIN) + reviewVerificationRequest( + @Param("id") requestId: string, + @Body() dto: ReviewVerificationRequestDto, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.reviewVerificationRequest( + requestId, + dto.decision, + dto.rejectionReason, + req.user.sub, + req.securityContext, + ); + } + + @Get("moderation/posts") + @Roles(UserRole.SUPER_ADMIN) + getPostModeration(@Query() query: AdminListQueryDto) { + return this.adminService.getModerationQueue("post", query); + } + + @Get("moderation/products") + @Roles(UserRole.SUPER_ADMIN) + getProductModeration(@Query() query: AdminListQueryDto) { + return this.adminService.getModerationQueue("product", query); + } + + @Get("moderation/posts/:id/timeline") + @Roles(UserRole.SUPER_ADMIN) + getPostModerationTimeline( + @Param("id") moderationLogId: string, + @Query() query: AdminListQueryDto, + ) { + return this.adminService.getModerationTimeline( + "post", + moderationLogId, + query, + ); + } + + @Get("moderation/products/:id/timeline") + @Roles(UserRole.SUPER_ADMIN) + getProductModerationTimeline( + @Param("id") moderationLogId: string, + @Query() query: AdminListQueryDto, + ) { + return this.adminService.getModerationTimeline( + "product", + moderationLogId, + query, + ); + } + + @Get("moderation/posts/:id") + @Roles(UserRole.SUPER_ADMIN) + getPostModerationItem(@Param("id") moderationLogId: string) { + return this.adminService.getModerationItem("post", moderationLogId); + } + + @Get("moderation/products/:id") + @Roles(UserRole.SUPER_ADMIN) + getProductModerationItem(@Param("id") moderationLogId: string) { + return this.adminService.getModerationItem("product", moderationLogId); + } + + @Get("audit-logs/facets") + @Roles(UserRole.SUPER_ADMIN) + getAuditLogFacets() { + return this.adminService.getAuditLogFacets(); + } + + @Get("audit-logs/:id") + @Roles(UserRole.SUPER_ADMIN) + getAuditLogById(@Param("id") auditLogId: string) { + return this.adminService.getAuditLogById(auditLogId); + } + + @Get("audit-logs") + @Roles(UserRole.SUPER_ADMIN) + getAuditLogs(@Query() query: AdminAuditLogQueryDto) { + return this.adminService.getAuditLogs(query); + } + + @Get("security/events") + @Roles(UserRole.SUPER_ADMIN) + getSecurityEvents(@Query() query: AdminSecurityEventQueryDto) { + return this.adminService.getSecurityEvents(query); + } + + @Get("safety/investigations/:userId") + @Roles(UserRole.SUPER_ADMIN) + getSafetyInvestigation(@Param("userId") userId: string) { + return this.adminService.getSafetyInvestigation(userId); + } + + @Get("safety/investigations") + @Roles(UserRole.SUPER_ADMIN) + getSafetyInvestigations(@Query() query: AdminSafetyInvestigationQueryDto) { + return this.adminService.getSafetyInvestigations(query); + } + + @Patch("moderation/posts/:id") + @Roles(UserRole.SUPER_ADMIN) + reviewPostModeration( + @Param("id") moderationLogId: string, + @Body() dto: ModerationReviewDto, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.reviewModerationLog( + moderationLogId, + "post", + dto.decision, + dto.reason, + req.user.sub, + req.securityContext, + ); + } + + @Patch("moderation/products/:id") + @Roles(UserRole.SUPER_ADMIN) + reviewProductModeration( + @Param("id") moderationLogId: string, + @Body() dto: ModerationReviewDto, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.reviewModerationLog( + moderationLogId, + "product", + dto.decision, + dto.reason, + req.user.sub, + req.securityContext, + ); + } + + @Get("deliveries") + @Roles(UserRole.SUPER_ADMIN) + getDeliveries(@Query() query: AdminListQueryDto) { + return this.adminService.getDeliveries(query); + } + + @Get("deliveries/ready-for-pickup") + @Roles(UserRole.SUPER_ADMIN) + getReadyForPickupDeliveryQueue(@Query() query: AdminListQueryDto) { + return this.adminService.getReadyForPickupDeliveryQueue(query); + } + + @Post("deliveries/orders/:orderId/book-manual") + @Roles(UserRole.SUPER_ADMIN) + createManualDeliveryBooking( + @Param("orderId") orderId: string, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.createManualDeliveryBooking( + orderId, + req.user.sub, + req.securityContext, + ); + } + + @Post("deliveries/orders/:orderId/start") + @Roles(UserRole.SUPER_ADMIN) + startDelivery( + @Param("orderId") orderId: string, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.startDelivery( + orderId, + req.user.sub, + req.securityContext, + ); + } + + @Post("deliveries/:bookingId/shipbubble-rates") + @Roles(UserRole.SUPER_ADMIN) + getShipbubbleDeliveryRates( + @Param("bookingId") bookingId: string, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.getShipbubbleDeliveryRates( + bookingId, + req.user.sub, + ); + } + + @Post("deliveries/:bookingId/book-shipbubble") + @Roles(UserRole.SUPER_ADMIN) + bookDeliveryWithShipbubble( + @Param("bookingId") bookingId: string, + @Body() dto: BookShipbubbleDeliveryDto, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.bookDeliveryWithShipbubble( + bookingId, + req.user.sub, + dto, + req.securityContext, + ); + } + + @Get("deliveries/:id/timeline") + @Roles(UserRole.SUPER_ADMIN) + getDeliveryTimeline( + @Param("id") deliveryId: string, + @Query() query: AdminListQueryDto, + ) { + return this.adminService.getDeliveryTimeline(deliveryId, query); + } + + @Get("deliveries/:id") + @Roles(UserRole.SUPER_ADMIN) + getDeliveryById(@Param("id") deliveryId: string) { + return this.adminService.getDeliveryById(deliveryId); + } + + @Patch("payouts/:id/process") + @Roles(UserRole.SUPER_ADMIN) + processPayout( + @Param("id") payoutId: string, + @Req() req: AuthenticatedRequest, + ) { + return this.adminService.processPayout( + payoutId, + req.user.sub, + req.securityContext, + ); + } +} diff --git a/apps/backend/src/domains/platform/admin/admin.module.ts b/apps/backend/src/domains/platform/admin/admin.module.ts new file mode 100644 index 00000000..c04067f0 --- /dev/null +++ b/apps/backend/src/domains/platform/admin/admin.module.ts @@ -0,0 +1,58 @@ +import { Module } from "@nestjs/common"; +import { PrismaModule } from "../../../prisma/prisma.module"; +import { BullModule } from "@nestjs/bullmq"; +import { QUEUE } from "../../../queue/queue.constants"; +import { AdminController } from "./admin.controller"; +import { AdminService } from "./admin.service"; +import { AdminOpsService } from "./admin-ops.service"; +import { AdminCronService } from "./admin-cron.service"; +import { AuditLogService } from "./audit-log.service"; +import { AuditLogController } from "./audit-log.controller"; +import { VerificationModule } from "../../users/verification/verification.module"; +import { DomainEventModule } from "../domain-event/domain-event.module"; +import { ShipbubbleModule } from "../../../integrations/shipbubble/shipbubble.module"; +import { NotificationModule } from "../../social/notification/notification.module"; +import { PayoutModule } from "../../money/payout/payout.module"; +import { CategoryDemandInsightsModule } from "../../commerce/search/category-demand-insights.module"; +import { LocationDemandSupplyInsightsController } from "./location-demand-supply-insights.controller"; +import { LocationDemandSupplyInsightsService } from "./location-demand-supply-insights.service"; + +import { OrderModule } from "../../orders/order/order.module"; +import { OutboxModule } from "../outbox/outbox.module"; +import { SettlementModule } from "../../money/settlement/settlement.module"; +import { PaymentModule } from "../../money/payment/payment.module"; +import { RefundModule } from "../../money/refund/refund.module"; + +@Module({ + imports: [ + PrismaModule, + VerificationModule, + OrderModule, + DomainEventModule, + OutboxModule, + SettlementModule, + PaymentModule, + RefundModule, + ShipbubbleModule, + NotificationModule, + PayoutModule, + CategoryDemandInsightsModule, + // Register all queues here so AdminService can enqueue payouts and the + // read-only technical-ops console can read job counts for every queue. + BullModule.registerQueue(...Object.values(QUEUE).map((name) => ({ name }))), + ], + controllers: [ + AdminController, + AuditLogController, + LocationDemandSupplyInsightsController, + ], + providers: [ + AdminService, + AdminOpsService, + AdminCronService, + AuditLogService, + LocationDemandSupplyInsightsService, + ], + exports: [AdminService, AuditLogService], +}) +export class AdminModule {} diff --git a/apps/backend/src/domains/platform/admin/admin.service.spec.ts b/apps/backend/src/domains/platform/admin/admin.service.spec.ts new file mode 100644 index 00000000..fb4369ea --- /dev/null +++ b/apps/backend/src/domains/platform/admin/admin.service.spec.ts @@ -0,0 +1,3070 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { AdminService } from "./admin.service"; +import { PrismaService } from "../../../prisma/prisma.service"; +import { RedisService } from "../../../redis/redis.service"; +import { NotificationTriggerService } from "../../social/notification/notification-trigger.service"; +import { AuditLogService } from "./audit-log.service"; +import { VerificationService } from "../../users/verification/verification.service"; +import { OrderService } from "../../orders/order/order.service"; +import { PayoutService } from "../../money/payout/payout.service"; +import { CategoryDemandInsightsService } from "../../commerce/search/category-demand-insights.service"; +import { DomainEventService } from "../domain-event/domain-event.service"; +import { SHIPPING_PROVIDER } from "../../orders/delivery/providers/shipping-provider.interface"; +import { CommerceOutboxService } from "../outbox/commerce-outbox.service"; +import { SettlementExecutionService } from "../../money/settlement/settlement-execution.service"; +import { SettlementReconciliationService } from "../../money/settlement/settlement-reconciliation.service"; +import { PaymentService } from "../../money/payment/payment.service"; +import { PaymentAmountExceptionRefundService } from "../../money/refund/payment-amount-exception-refund.service"; +import { ConfigService } from "@nestjs/config"; +import { getQueueToken } from "@nestjs/bullmq"; +import { PAYOUT_QUEUE } from "../../../queue/queue.constants"; +import { + OrderStatus, + OrderDisputeStatus, + InventoryEventType, +} from "@twizrr/shared"; +import { + NotFoundException, + BadRequestException, + ConflictException, + BadGatewayException, +} from "@nestjs/common"; +import { DeliveryMethod, DeliveryStatus } from "@prisma/client"; + +describe("AdminService", () => { + let service: AdminService; + + const mockPrisma: any = { + order: { + findUnique: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + count: jest.fn(), + findMany: jest.fn(), + aggregate: jest.fn(), + }, + moderationLog: { + count: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + post: { + count: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + whatsAppAnalytics: { + count: jest.fn(), + }, + deliveryBooking: { + create: jest.fn(), + count: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + orderEvent: { + create: jest.fn(), + }, + inventoryEvent: { + create: jest.fn(), + }, + productStockCache: { + updateMany: jest.fn(), + upsert: jest.fn(), + }, + user: { + count: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + userDevice: { + findMany: jest.fn(), + }, + storeProfile: { + count: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + product: { + count: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + dispute: { + count: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + }, + payout: { + count: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + aggregate: jest.fn(), + }, + payoutRequest: { + findUnique: jest.fn(), + updateMany: jest.fn(), + }, + payment: { + count: jest.fn(), + findMany: jest.fn(), + }, + paymentAmountException: { + count: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + updateMany: jest.fn(), + }, + verificationRequest: { + count: jest.fn(), + findMany: jest.fn(), + findFirst: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + storeVerificationAttempt: { + count: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + }, + auditLog: { + create: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + count: jest.fn(), + }, + domainEvent: { + create: jest.fn(), + count: jest.fn(), + findMany: jest.fn(), + findUniqueOrThrow: jest.fn(), + }, + $queryRaw: jest.fn(), + $transaction: jest.fn((callback) => callback(mockPrisma)), + }; + + const mockRedis = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + }; + + const mockNotifications = { + triggerDisputeResolved: jest.fn(), + triggerMerchantRejected: jest.fn(), + }; + + const mockAuditLog = { + log: jest.fn(), + }; + + const mockVerification = { + verifyMerchant: jest.fn(), + reviewRequest: jest.fn(), + reconcileVerificationAttempt: jest.fn(), + }; + + const mockCategoryDemand = { + getTopZeroResultSubcategories: jest.fn().mockResolvedValue([]), + }; + + const mockPayoutQueue = { + add: jest.fn(), + }; + + const mockOrderService = { + startDeliveryByAdmin: jest.fn(), + }; + + const mockShippingProvider = { + createBooking: jest.fn(), + getQuotes: jest.fn(), + }; + const mockShipbubble = { + bookDelivery: mockShippingProvider.createBooking, + getDeliveryRates: mockShippingProvider.getQuotes, + }; + + const mockPayoutService = { + releasePayout: jest.fn(), + }; + + const mockConfig = { + get: jest.fn(), + }; + + const mockDomainEvents = { + append: jest.fn().mockResolvedValue({ id: "domain-event-1" }), + sanitizeMetadata: jest.fn((metadata: unknown) => + new DomainEventService().sanitizeMetadata(metadata), + ), + }; + + const mockCommerceOutbox = { + appendSettlementRealtimeEvents: jest.fn(), + appendAdminSettlementAlert: jest.fn(), + }; + const mockSettlementExecution = { retryLeg: jest.fn() }; + const mockSettlementReconciliation = { reconcileSettlement: jest.fn() }; + const mockPaymentService = { inspectPaymentAmountException: jest.fn() }; + const mockPaymentAmountExceptionRefundService = { createPlan: jest.fn() }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminService, + { provide: PrismaService, useValue: mockPrisma }, + { provide: RedisService, useValue: mockRedis }, + { provide: NotificationTriggerService, useValue: mockNotifications }, + { provide: AuditLogService, useValue: mockAuditLog }, + { provide: VerificationService, useValue: mockVerification }, + { provide: OrderService, useValue: mockOrderService }, + { provide: PayoutService, useValue: mockPayoutService }, + { + provide: CategoryDemandInsightsService, + useValue: mockCategoryDemand, + }, + { provide: SHIPPING_PROVIDER, useValue: mockShippingProvider }, + { provide: ConfigService, useValue: mockConfig }, + { provide: getQueueToken(PAYOUT_QUEUE), useValue: mockPayoutQueue }, + { provide: DomainEventService, useValue: mockDomainEvents }, + { provide: CommerceOutboxService, useValue: mockCommerceOutbox }, + { + provide: SettlementExecutionService, + useValue: mockSettlementExecution, + }, + { + provide: SettlementReconciliationService, + useValue: mockSettlementReconciliation, + }, + { provide: PaymentService, useValue: mockPaymentService }, + { + provide: PaymentAmountExceptionRefundService, + useValue: mockPaymentAmountExceptionRefundService, + }, + ], + }).compile(); + + service = module.get(AdminService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("getDashboard (platform overview)", () => { + beforeEach(() => { + // Aggregate counts default to 0; individual tests override what they assert. + mockPrisma.user.count.mockResolvedValue(0); + mockPrisma.storeProfile.count.mockResolvedValue(0); + mockPrisma.product.count.mockResolvedValue(0); + mockPrisma.order.count.mockResolvedValue(0); + mockPrisma.verificationRequest.count.mockResolvedValue(0); + mockPrisma.moderationLog.count.mockResolvedValue(0); + mockPrisma.dispute.count.mockResolvedValue(0); + mockPrisma.payout.count.mockResolvedValue(0); + mockPrisma.deliveryBooking.count.mockResolvedValue(0); + mockPrisma.whatsAppAnalytics.count.mockResolvedValue(0); + mockPrisma.order.aggregate.mockResolvedValue({ + _sum: { totalAmountKobo: null, platformFeeKobo: null }, + }); + mockPrisma.payout.aggregate.mockResolvedValue({ + _sum: { amountKobo: null }, + }); + mockPrisma.auditLog.findMany.mockResolvedValue([]); + mockCategoryDemand.getTopZeroResultSubcategories.mockResolvedValue([]); + }); + + it("returns a safe aggregate overview and defaults the range to 7d", async () => { + const result = await service.getDashboard({}); + + expect(result.range.preset).toBe("7d"); + expect(result.users).toHaveProperty("total"); + expect(result.stores).toHaveProperty("verified"); + expect(result.operations).toHaveProperty("verificationPending"); + expect(result.wizza).toHaveProperty("zeroResultCount"); + // Money is kobo strings (or null) — never floats. + expect( + result.money.gmvKobo === null || + typeof result.money.gmvKobo === "string", + ).toBe(true); + expect( + result.money.protectedPaymentsHeldKobo === null || + typeof result.money.protectedPaymentsHeldKobo === "string", + ).toBe(true); + }); + + it("honours the 30d preset window", async () => { + const result = await service.getDashboard({ range: "30d" }); + + expect(result.range.preset).toBe("30d"); + const spanDays = + (new Date(result.range.to).getTime() - + new Date(result.range.from).getTime()) / + (24 * 60 * 60 * 1000); + expect(Math.round(spanDays)).toBe(30); + }); + + it("returns money as kobo strings and counts from the underlying models", async () => { + mockPrisma.verificationRequest.count.mockResolvedValue(4); + mockPrisma.order.aggregate + .mockResolvedValueOnce({ _sum: { totalAmountKobo: BigInt("2450000") } }) + .mockResolvedValueOnce({ _sum: { platformFeeKobo: BigInt("49000") } }) + .mockResolvedValueOnce({ + _sum: { totalAmountKobo: BigInt("1000000") }, + }); + mockPrisma.payout.aggregate + .mockResolvedValueOnce({ _sum: { amountKobo: BigInt("500000") } }) + .mockResolvedValueOnce({ _sum: { amountKobo: BigInt("0") } }); + + const result = await service.getDashboard({ range: "7d" }); + + expect(result.stores.pendingVerification).toBe(4); + expect(result.operations.verificationPending).toBe(4); + expect(result.money.gmvKobo).toBe("2450000"); + expect(result.money.platformFeesKobo).toBe("49000"); + expect(result.money.protectedPaymentsHeldKobo).toBe("1000000"); + expect(result.money.payoutsPendingKobo).toBe("500000"); + expect(typeof result.money.gmvKobo).toBe("string"); + }); + + it("maps WIZZA demand gaps to a safe shape and raises a demand alert", async () => { + mockCategoryDemand.getTopZeroResultSubcategories.mockResolvedValue([ + { + parentCategoryLabel: "Fashion", + subcategoryLabel: "Sneakers", + zeroResultCount: 12, + searchModeBreakdown: { text: 12, image: 0 }, + uniqueUserCount: 5, + uniqueSessionCount: 6, + topMatchedTerms: ["sneaker"], + topReasonCodes: ["NO_MATCH"], + firstSeenAt: new Date(), + lastSeenAt: new Date(), + }, + ]); + + const result = await service.getDashboard({ range: "7d" }); + + expect(result.wizza.topDemandGaps).toEqual([ + { + parentCategoryLabel: "Fashion", + subcategoryLabel: "Sneakers", + zeroResultCount: 12, + }, + ]); + expect(result.alerts.some((alert) => alert.id === "demand-gap")).toBe( + true, + ); + // The mapped gap must not leak the analytics service's internal fields. + const serialized = JSON.stringify(result.wizza.topDemandGaps); + expect(serialized).not.toContain("topMatchedTerms"); + expect(serialized).not.toContain("uniqueUserCount"); + expect(serialized).not.toContain("parsedFilters"); + }); + + it("counts moderation appeals only — not auto-flagged uploads", async () => { + // A pending appeal is the only moderation item that needs a human; + // auto-blocked/sensitive uploads are already handled at upload time. + mockPrisma.moderationLog.count.mockResolvedValue(2); + + const result = await service.getDashboard({ range: "7d" }); + + // The count targets pending appeals — never the raw auto-moderation log. + expect(mockPrisma.moderationLog.count).toHaveBeenCalledWith({ + where: { appealStatus: "PENDING" }, + }); + expect(result.operations.moderationPending).toBe(2); + + // The alert is framed as appeals, and the misleading "awaiting moderation + // review" alert no longer exists. + const alert = result.alerts.find((a) => a.id === "moderation-appeals"); + expect(alert?.title).toBe("Moderation appeals"); + expect(result.alerts.some((a) => a.id === "moderation-pending")).toBe( + false, + ); + }); + + it("degrades gracefully when the demand-insights service throws", async () => { + mockCategoryDemand.getTopZeroResultSubcategories.mockRejectedValueOnce( + new Error("analytics unavailable"), + ); + + const result = await service.getDashboard({ range: "7d" }); + + expect(result.wizza.topDemandGaps).toEqual([]); + }); + }); + + describe("global orders console", () => { + it("lists orders with kobo strings + delivery status and applies filters", async () => { + mockPrisma.order.findMany.mockResolvedValue([ + { + id: "order-1", + orderCode: "TWZ-100001", + orderType: "DIRECT", + linkedOrderId: null, + buyerId: "buyer-1", + storeId: "store-1", + totalAmountKobo: BigInt("2450000"), + deliveryFeeKobo: BigInt("250000"), + platformFeeKobo: BigInt("49000"), + status: "PAID", + disputeStatus: "NONE", + payoutStatus: "PENDING", + deliveryMethod: "TWIZRR", + createdAt: new Date(), + updatedAt: new Date(), + user: { + id: "buyer-1", + email: "b@example.com", + firstName: "B", + lastName: "Uyer", + }, + storeProfile: { + id: "store-1", + businessName: "Store", + storeHandle: "store", + tier: "TIER_1", + verificationTier: "TIER_1", + }, + deliveryBooking: { status: "IN_TRANSIT" }, + }, + ]); + mockPrisma.order.count.mockResolvedValue(1); + + const result = await service.getOrders({ + payoutStatus: "PENDING", + disputeStatus: "NONE", + deliveryStatus: "IN_TRANSIT", + q: "store", + limit: 10, + }); + + const first = result.items[0] as Record; + expect(first.totalAmountKobo).toBe("2450000"); + expect(first.deliveryStatus).toBe("IN_TRANSIT"); + expect(result.pagination).toMatchObject({ page: 1, limit: 10, total: 1 }); + + const whereArg = mockPrisma.order.findMany.mock.calls[0][0].where; + expect(whereArg.payoutStatus).toBe("PENDING"); + expect(whereArg.disputeStatus).toBe("NONE"); + expect(whereArg.deliveryBooking).toEqual({ + is: { status: "IN_TRANSIT" }, + }); + expect(Array.isArray(whereArg.OR)).toBe(true); + }); + + it("returns a safe order detail with money as strings and no OTP / delivery phone / pickup address / storeType", async () => { + mockPrisma.order.findUnique.mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-100001", + orderType: "DIRECT", + linkedOrderId: null, + buyerId: "buyer-1", + storeId: "store-1", + items: [ + { + productId: "p1", + quantity: 1, + unitPriceKobo: "2450000", + variantName: "Red - M", + }, + ], + totalAmountKobo: BigInt("2450000"), + deliveryFeeKobo: BigInt("250000"), + platformFeeKobo: BigInt("49000"), + currency: "NGN", + status: "PAID", + disputeStatus: "NONE", + payoutStatus: "PENDING", + deliveryMethod: "TWIZRR", + disputeWindowEndsAt: null, + createdAt: new Date(), + updatedAt: new Date(), + user: { + id: "buyer-1", + email: "b@example.com", + phone: "+2348012345678", + firstName: "B", + lastName: "Uyer", + }, + storeProfile: { + id: "store-1", + businessName: "Store", + storeHandle: "store", + tier: "TIER_1", + verificationTier: "TIER_1", + }, + linkedOrder: null, + linkedFromOrder: null, + payments: [ + { + id: "pay-1", + provider: "PAYSTACK", + providerReference: "ref_1", + providerTransactionReference: null, + amountKobo: BigInt("2450000"), + currency: "NGN", + status: "SUCCESS", + direction: "INFLOW", + verifiedAt: new Date(), + createdAt: new Date(), + }, + ], + payout: { + id: "payout-1", + amountKobo: BigInt("2400000"), + platformFeeKobo: BigInt("49000"), + status: "PENDING", + failureReason: null, + createdAt: new Date(), + completedAt: null, + }, + deliveryBooking: { + id: "db-1", + method: "TWIZRR", + partnerName: "GIG", + partnerRef: "REF", + trackingUrl: "https://track", + estimatedCostKobo: BigInt("250000"), + actualCostKobo: null, + status: "IN_TRANSIT", + estimatedArrival: null, + pickedUpAt: null, + deliveredAt: null, + createdAt: new Date(), + }, + disputes: [], + }); + + const result = await service.getOrderById("order-1"); + + expect(result.totalAmountKobo).toBe("2450000"); + expect(result.payments[0].amountKobo).toBe("2450000"); + expect(result.payout?.amountKobo).toBe("2400000"); + expect(result.deliveryBooking?.estimatedCostKobo).toBe("250000"); + + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("deliveryOtp"); + expect(serialized).not.toContain("deliveryDetails"); + expect(serialized).not.toContain("pickupAddress"); + expect(serialized).not.toContain("storeType"); + expect(serialized).not.toContain("physicalStoreId"); + expect(serialized).not.toContain("dropshipperCostKobo"); + + // The DB select must never request the phone-leaking / OTP / raw-address fields. + const selectArg = mockPrisma.order.findUnique.mock.calls[0][0].select; + expect(selectArg.deliveryOtp).toBeUndefined(); + expect(selectArg.deliveryDetails).toBeUndefined(); + expect(selectArg.deliveryAddress).toBeUndefined(); + expect(selectArg.storeProfile.select.storeType).toBeUndefined(); + expect(selectArg.deliveryBooking.select.pickupAddress).toBeUndefined(); + }); + + it("throws when the order detail is missing", async () => { + mockPrisma.order.findUnique.mockResolvedValue(null); + + await expect(service.getOrderById("missing")).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + }); + + describe("markOrderVerified", () => { + it("sets verifiedAt and writes an audit event for a paid order", async () => { + mockPrisma.order.findUnique.mockResolvedValue({ + id: "order-1", + status: "READY_FOR_PICKUP", + verifiedAt: null, + }); + mockPrisma.order.update.mockResolvedValue({ + id: "order-1", + verifiedAt: new Date(), + }); + + await service.markOrderVerified("order-1", "admin-1"); + + expect(mockPrisma.order.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "order-1" }, + data: { verifiedAt: expect.any(Date) }, + }), + ); + expect(mockPrisma.orderEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + orderId: "order-1", + triggeredBy: "admin-1", + }), + }), + ); + }); + + it("is idempotent — an already-verified order is not re-updated", async () => { + mockPrisma.order.findUnique.mockResolvedValue({ + id: "order-1", + status: "DISPATCHED", + verifiedAt: new Date("2026-07-24T00:00:00.000Z"), + }); + + await service.markOrderVerified("order-1", "admin-1"); + + expect(mockPrisma.order.update).not.toHaveBeenCalled(); + expect(mockPrisma.orderEvent.create).not.toHaveBeenCalled(); + }); + + it("rejects verifying a cancelled order", async () => { + mockPrisma.order.findUnique.mockResolvedValue({ + id: "order-1", + status: "CANCELLED", + verifiedAt: null, + }); + + await expect( + service.markOrderVerified("order-1", "admin-1"), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mockPrisma.order.update).not.toHaveBeenCalled(); + }); + + it("throws when the order does not exist", async () => { + mockPrisma.order.findUnique.mockResolvedValue(null); + + await expect( + service.markOrderVerified("missing", "admin-1"), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); + + describe("payment review console", () => { + it("searches and returns provider-neutral canonical and attempt references", async () => { + mockPrisma.payment.findMany.mockResolvedValue([ + { + id: "payment-1", + orderId: "order-1", + provider: "MONNIFY", + providerReference: "twz-monnify-reference", + providerTransactionReference: "MNFY-transaction", + paystackTransferRef: null, + amountKobo: 2450000n, + currency: "NGN", + status: "INITIALIZED", + direction: "INFLOW", + verifiedAt: null, + createdAt: new Date("2026-07-18T00:00:00.000Z"), + updatedAt: new Date("2026-07-18T00:00:00.000Z"), + order: { + id: "order-1", + orderCode: "TWZ-100001", + status: "PENDING_PAYMENT", + orderType: "DIRECT", + }, + }, + ]); + mockPrisma.payment.count.mockResolvedValue(1); + + const result = await service.getPayments({ q: "old-attempt" }); + + expect(mockPrisma.payment.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: expect.arrayContaining([ + expect.objectContaining({ + providerReference: expect.objectContaining({ + contains: "old-attempt", + }), + }), + expect.objectContaining({ + attempts: { + some: { + providerReference: expect.objectContaining({ + contains: "old-attempt", + }), + }, + }, + }), + ]), + }), + }), + ); + expect(result.items).toEqual([ + expect.objectContaining({ + provider: "MONNIFY", + providerReference: "twz-monnify-reference", + amountKobo: "2450000", + }), + ]); + }); + }); + + describe("payout review console", () => { + it("returns one provider-neutral reference contract in payout lists", async () => { + mockPrisma.payout.findMany.mockResolvedValue([ + { + id: "payout-1", + orderId: "order-1", + storeId: "store-1", + amountKobo: 2400000n, + platformFeeKobo: 49000n, + paystackTransferCode: null, + provider: "MONNIFY", + providerReference: "PO-MONNIFY-1", + providerOperationId: "MNF-TRANSFER-1", + status: "SUBMITTED", + initiatedAt: null, + completedAt: null, + failureReason: null, + createdAt: new Date("2026-07-18T00:00:00.000Z"), + order: null, + store: null, + }, + ]); + mockPrisma.payout.count.mockResolvedValue(1); + + const result = await service.getPayouts(); + + expect(result.items).toEqual([ + expect.objectContaining({ + provider: "Monnify", + transferRef: "MNF-TRANSFER-1", + amountKobo: "2400000", + }), + ]); + expect(JSON.stringify(result.items)).not.toContain("providerReference"); + expect(JSON.stringify(result.items)).not.toContain("providerOperationId"); + expect(JSON.stringify(result.items)).not.toContain( + "paystackTransferCode", + ); + }); + + it("returns a safe payout detail with masked bank and money as strings", async () => { + mockPrisma.payout.findUnique.mockResolvedValue({ + id: "payout-1", + orderId: "order-1", + storeId: "store-1", + amountKobo: BigInt("2400000"), + platformFeeKobo: BigInt("49000"), + paystackTransferCode: "TRF_abc", + status: "PENDING", + initiatedAt: null, + completedAt: null, + failureReason: null, + createdAt: new Date(), + store: { + id: "store-1", + businessName: "Yaba Fashion", + storeHandle: "yabafashion", + tier: "TIER_1", + verificationTier: "TIER_1", + bankVerified: true, + bankName: "GTBank", + accountName: "Ada Obi", + accountNumber: "0123456789", + bankAccountNumber: "9988776655", + }, + order: { + id: "order-1", + orderCode: "TWZ-100001", + orderType: "DIRECT", + status: "COMPLETED", + disputeStatus: "NONE", + payoutStatus: "PENDING", + totalAmountKobo: BigInt("2450000"), + payments: [ + { + id: "pay-1", + provider: "PAYSTACK", + providerReference: "ref_1", + providerTransactionReference: null, + amountKobo: BigInt("2450000"), + status: "SUCCESS", + direction: "INFLOW", + verifiedAt: new Date(), + createdAt: new Date(), + }, + ], + }, + }); + + const result = await service.getPayoutById("payout-1"); + + expect(result.amountKobo).toBe("2400000"); + expect(typeof result.amountKobo).toBe("string"); + expect(result.store?.bank.accountNumberLast4).toBe("6789"); + + const serialized = JSON.stringify(result); + // Full account numbers and the recipient code must never leak. + expect(serialized).not.toContain("0123456789"); + expect(serialized).not.toContain("9988776655"); + expect(serialized).not.toContain("paystackRecipientCode"); + }); + + it("throws when the payout detail is missing", async () => { + mockPrisma.payout.findUnique.mockResolvedValue(null); + + await expect(service.getPayoutById("missing")).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it("releases an eligible payout via the safe service and audits it", async () => { + mockPrisma.payout.findUnique.mockResolvedValue({ + id: "payout-1", + status: "PENDING", + storeId: "store-1", + amountKobo: BigInt("2400000"), + }); + mockPayoutService.releasePayout.mockResolvedValue({ + id: "payout-1", + status: "PROCESSING", + }); + + const result = await service.processPayout("payout-1", "admin-1"); + + expect(result.success).toBe(true); + expect(mockPayoutService.releasePayout).toHaveBeenCalledWith( + "payout-1", + "admin-1", + ); + expect(mockPrisma.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: "admin-1", + action: "RELEASE_PAYOUT", + targetType: "Payout", + targetId: "payout-1", + metadata: expect.objectContaining({ + payoutId: "payout-1", + storeId: "store-1", + previousStatus: "PENDING", + amountKobo: "2400000", + source: "admin", + }), + }), + }); + expect(mockDomainEvents.append).toHaveBeenCalledWith( + mockPrisma, + expect.objectContaining({ + eventType: "PAYOUT_RELEASED_BY_ADMIN", + aggregateId: "payout-1", + }), + ); + }); + + it("propagates an ineligible payout error and audits nothing", async () => { + mockPrisma.payout.findUnique.mockResolvedValue({ + id: "payout-1", + status: "COMPLETED", + storeId: "store-1", + amountKobo: BigInt("2400000"), + }); + mockPayoutService.releasePayout.mockRejectedValue( + new BadRequestException("Payout cannot be released while completed."), + ); + + await expect( + service.processPayout("payout-1", "admin-1"), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(mockPrisma.auditLog.create).not.toHaveBeenCalled(); + expect(mockDomainEvents.append).not.toHaveBeenCalled(); + }); + + it("does not mask a successful release when audit logging fails", async () => { + mockPrisma.payout.findUnique.mockResolvedValue({ + id: "payout-1", + status: "PENDING", + storeId: "store-1", + amountKobo: BigInt("2400000"), + }); + mockPayoutService.releasePayout.mockResolvedValue({ + id: "payout-1", + status: "PROCESSING", + }); + mockPrisma.$transaction.mockRejectedValueOnce(new Error("audit down")); + + await expect( + service.processPayout("payout-1", "admin-1"), + ).resolves.toMatchObject({ success: true }); + }); + + it("does not mark an unlinked payout request as financially processed", async () => { + mockPrisma.payout.findUnique.mockResolvedValue(null); + mockPrisma.payoutRequest.findUnique.mockResolvedValue({ + id: "request-1", + payoutId: null, + status: "PENDING", + }); + + await expect( + service.processPayout("request-1", "admin-1"), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "PAYOUT_REQUEST_OPERATION_REQUIRED", + }), + }); + expect(mockPayoutService.releasePayout).not.toHaveBeenCalled(); + expect(mockPrisma.payoutRequest.updateMany).not.toHaveBeenCalled(); + }); + }); + + describe("delivery coordination console", () => { + it("returns a safe delivery detail with no delivery code / opaque details blob", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue({ + id: "booking-1", + orderId: "order-1", + method: "TWIZRR", + partnerName: "GIG", + partnerRef: "REF", + trackingUrl: "https://track", + pickupAddress: "12 Supplier Rd", + deliveryAddress: "3 Shopper St", + estimatedCostKobo: BigInt("250000"), + actualCostKobo: null, + status: "IN_TRANSIT", + estimatedArrival: null, + pickedUpAt: null, + deliveredAt: null, + createdAt: new Date(), + order: { + id: "order-1", + orderCode: "TWZ-100001", + orderType: "DIRECT", + linkedOrderId: null, + status: "READY_FOR_PICKUP", + payoutStatus: "PENDING", + disputeStatus: "NONE", + storeId: "store-1", + deliveryAddress: "3 Shopper St", + deliveryPrimaryPhone: "+2348012345678", + deliverySecondaryPhone: null, + storeProfile: { + id: "store-1", + businessName: "Yaba Fashion", + storeHandle: "yabafashion", + tier: "TIER_1", + verificationTier: "TIER_1", + }, + }, + }); + + const result = await service.getDeliveryById("booking-1"); + + expect(result.estimatedCostKobo).toBe("250000"); + expect(result.order?.storeProfile?.businessName).toBe("Yaba Fashion"); + + // The opaque deliveryDetails blob and any delivery code must never appear. + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("deliveryDetails"); + expect(serialized).not.toContain("deliveryOtp"); + + // The DB select must never request deliveryDetails / deliveryOtp. + const selectArg = mockPrisma.deliveryBooking.findUnique.mock.calls[0][0] + .select as Record & { + order: { select: Record }; + }; + expect(selectArg.order.select.deliveryDetails).toBeUndefined(); + expect(selectArg.order.select.deliveryOtp).toBeUndefined(); + }); + + it("throws when the delivery detail is missing", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue(null); + + await expect(service.getDeliveryById("missing")).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it("scopes the delivery timeline to DeliveryBooking aggregate events", async () => { + mockPrisma.domainEvent.findMany.mockResolvedValue([]); + mockPrisma.domainEvent.count.mockResolvedValue(0); + + await service.getDeliveryTimeline("booking-1", { limit: 20 }); + + expect(mockPrisma.domainEvent.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + aggregateId: "booking-1", + aggregateType: { in: ["DELIVERY", "Delivery", "DeliveryBooking"] }, + }, + }), + ); + }); + }); + + describe("dispute evidence console", () => { + it("returns a safe dispute detail (mapped payment, delivery summary, no delivery code)", async () => { + mockPrisma.dispute.findUnique.mockResolvedValue({ + id: "dispute-1", + orderId: "order-1", + buyerId: "buyer-1", + storeId: "store-1", + status: "UNDER_REVIEW", + reason: "Item not received", + description: "Package never arrived", + buyerRequestedOutcome: "REFUND", + storeResponse: null, + resolutionOutcome: null, + resolutionNotes: null, + resolvedById: null, + resolvedAt: null, + closedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + order: { + id: "order-1", + orderCode: "TWZ-100001", + orderType: "DIRECT", + status: "DISPUTE", + payoutStatus: "CANCELLED", + disputeStatus: "PENDING", + deliveryMethod: "TWIZRR", + totalAmountKobo: BigInt("2450000"), + deliveryFeeKobo: BigInt("250000"), + disputeWindowEndsAt: null, + payments: [ + { + id: "pay-1", + provider: "PAYSTACK", + providerReference: "ref_1", + providerTransactionReference: null, + amountKobo: BigInt("2450000"), + status: "SUCCESS", + direction: "INFLOW", + verifiedAt: new Date(), + createdAt: new Date(), + }, + ], + deliveryBooking: { + id: "db-1", + method: "TWIZRR", + partnerName: "GIG", + partnerRef: "REF", + trackingUrl: "https://track", + status: "IN_TRANSIT", + estimatedArrival: null, + pickedUpAt: null, + deliveredAt: null, + }, + }, + buyer: { + id: "buyer-1", + email: "b@example.com", + phone: "+2348012345678", + firstName: "Ada", + lastName: "Obi", + }, + store: { + id: "store-1", + businessName: "Yaba Fashion", + storeHandle: "yabafashion", + tier: "TIER_1", + verificationTier: "TIER_1", + }, + evidence: [ + { + id: "ev-1", + actorId: "buyer-1", + actorType: "SHOPPER", + url: "https://cdn/evidence.jpg", + note: "photo", + createdAt: new Date(), + }, + ], + }); + + const result = await service.getDisputeById("dispute-1"); + + expect(result.order?.totalAmountKobo).toBe("2450000"); + expect(result.order?.payment?.amountKobo).toBe("2450000"); + expect(result.order?.payment?.providerReference).toBe("ref_1"); + expect(result.order?.deliveryBooking?.status).toBe("IN_TRANSIT"); + expect(result.evidence).toHaveLength(1); + // Raw payments array is dropped in favour of the single mapped payment. + expect(result.order?.payments).toBeUndefined(); + + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("deliveryOtp"); + expect(serialized).not.toContain("deliveryDetails"); + }); + + it("throws when the dispute detail is missing", async () => { + mockPrisma.dispute.findUnique.mockResolvedValue(null); + + await expect(service.getDisputeById("missing")).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it("refuses to resolve a dispute without a real reason and never looks it up", async () => { + await expect( + service.resolveDisputeById("dispute-1", "SHOPPER", "admin-1", " "), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(mockPrisma.dispute.findUnique).not.toHaveBeenCalled(); + }); + + it("looks up the dispute's order once a valid reason is supplied", async () => { + mockPrisma.dispute.findUnique.mockResolvedValue({ + id: "dispute-1", + orderId: "order-1", + }); + // resolveDispute then loads the order; a missing order surfaces NotFound. + mockPrisma.order.findUnique.mockResolvedValue(null); + + await expect( + service.resolveDisputeById( + "dispute-1", + "SHOPPER", + "admin-1", + "Tracking shows the package was never delivered", + ), + ).rejects.toBeInstanceOf(NotFoundException); + + expect(mockPrisma.dispute.findUnique).toHaveBeenCalledWith({ + where: { id: "dispute-1" }, + select: { id: true, orderId: true }, + }); + }); + }); + + describe("moderation evidence console", () => { + it("normalizes SafeSearch, drops the raw payload, and adds content context in the list", async () => { + mockPrisma.moderationLog.findMany.mockResolvedValue([ + { + id: "mod-1", + contentType: "product_image", + contentId: "product-1", + imageUrl: "https://cdn/flagged.jpg", + decision: "BLOCKED", + confidence: { + adult: "VERY_LIKELY", + violence: "UNLIKELY", + racy: "POSSIBLE", + // A stray raw field must never survive normalization. + rawVisionField: { boundingPoly: [1, 2, 3] }, + }, + reviewedBy: null, + reviewNote: null, + appealStatus: null, + createdAt: new Date(), + reviewer: null, + }, + ]); + mockPrisma.moderationLog.count.mockResolvedValue(1); + mockPrisma.product.findMany.mockResolvedValue([ + { + id: "product-1", + title: "Red Dress", + name: "Red Dress", + moderationStatus: "BLOCKED", + isActive: false, + storeProfile: { + businessName: "Yaba Fashion", + storeHandle: "yabafashion", + }, + }, + ]); + + const result = (await service.getModerationQueue("product", {})) as { + items: Array>; + }; + const row = result.items[0]; + + expect(row.safeSearch).toEqual({ + adult: "VERY_LIKELY", + violence: "UNLIKELY", + racy: "POSSIBLE", + }); + expect(row.evidenceImageUrl).toBe("https://cdn/flagged.jpg"); + expect((row.content as Record).title).toBe("Red Dress"); + + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("confidence"); + expect(serialized).not.toContain("rawVisionField"); + expect(serialized).not.toContain("boundingPoly"); + }); + + it("returns a safe product moderation detail without the raw provider payload", async () => { + mockPrisma.moderationLog.findUnique.mockResolvedValue({ + id: "mod-1", + contentType: "product_image", + contentId: "product-1", + imageUrl: "https://cdn/flagged.jpg", + decision: "SENSITIVE", + confidence: { adult: "POSSIBLE", violence: "UNLIKELY", racy: "LIKELY" }, + reviewedBy: null, + reviewNote: null, + appealStatus: null, + createdAt: new Date(), + reviewer: null, + }); + mockPrisma.product.findUnique.mockResolvedValue({ + id: "product-1", + title: "Red Dress", + name: "Red Dress", + description: "A dress", + shortDescription: null, + productCode: "TWZ-123456", + status: "ACTIVE", + moderationStatus: "SENSITIVE", + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + storeProfile: { + id: "store-1", + businessName: "Yaba Fashion", + storeHandle: "yabafashion", + tier: "TIER_1", + verificationTier: "TIER_1", + }, + images: [ + { + id: "img-1", + url: "https://cdn/1.jpg", + order: 0, + isDefault: true, + moderationStatus: "SAFE", + }, + ], + }); + + const result = (await service.getModerationItem( + "product", + "mod-1", + )) as Record; + + expect(result.safeSearch).toEqual({ + adult: "POSSIBLE", + violence: "UNLIKELY", + racy: "LIKELY", + }); + expect((result.content as Record).store).toMatchObject({ + storeHandle: "yabafashion", + }); + + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("confidence"); + // No internal dropship / source-store fields leak in. + expect(serialized).not.toContain("physicalStoreId"); + expect(serialized).not.toContain("dropshipperPriceKobo"); + }); + + it("throws when the moderation detail is missing", async () => { + mockPrisma.moderationLog.findUnique.mockResolvedValue(null); + + await expect( + service.getModerationItem("product", "missing"), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it("rejects a content-type mismatch", async () => { + mockPrisma.moderationLog.findUnique.mockResolvedValue({ + id: "mod-1", + contentType: "post_image", + contentId: "post-1", + }); + + await expect( + service.getModerationItem("product", "mod-1"), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("writes an AuditLog and DomainEvent when a moderation decision is recorded", async () => { + mockPrisma.moderationLog.findUnique.mockResolvedValue({ + id: "mod-1", + contentType: "product_image", + contentId: "product-1", + decision: "PENDING", + }); + mockPrisma.moderationLog.update.mockResolvedValue({ + id: "mod-1", + contentType: "product_image", + contentId: "product-1", + decision: "BLOCKED", + reviewedBy: "admin-1", + reviewNote: "Nudity", + createdAt: new Date(), + }); + + await service.reviewModerationLog( + "mod-1", + "product", + "BLOCKED", + "Nudity", + "admin-1", + ); + + expect(mockPrisma.product.updateMany).toHaveBeenCalled(); + expect(mockPrisma.auditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action: "MODERATION_REVIEWED", + targetType: "ModerationLog", + targetId: "mod-1", + }), + }), + ); + expect(mockDomainEvents.append).toHaveBeenCalled(); + }); + + it("throws when reviewing a missing moderation log", async () => { + mockPrisma.moderationLog.findUnique.mockResolvedValue(null); + + await expect( + service.reviewModerationLog( + "missing", + "product", + "BLOCKED", + "reason", + "admin-1", + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); + + describe("audit log console", () => { + it("lists audit logs newest-first, mapped and metadata-sanitized", async () => { + mockPrisma.auditLog.findMany.mockResolvedValue([ + { + id: "audit-1", + action: "DELETE_USER", + targetType: "User", + targetId: "user-1", + metadata: { + reason: "Spam account", + token: "secret-token-value", + nested: { accessToken: "should-drop", note: "ok" }, + }, + createdAt: new Date(), + user: { + id: "admin-1", + email: "admin@twizrr.com", + firstName: "Ada", + lastName: "Admin", + role: "SUPER_ADMIN", + }, + }, + ]); + mockPrisma.auditLog.count.mockResolvedValue(1); + + const result = (await service.getAuditLogs({ page: 1, limit: 20 })) as { + items: Array>; + pagination: { total: number }; + }; + const row = result.items[0]; + const metadata = row.metadata as Record; + + expect(row.action).toBe("DELETE_USER"); + expect((row.actor as Record).email).toBe( + "admin@twizrr.com", + ); + expect(metadata.reason).toBe("Spam account"); + // Sensitive keys are redacted at any depth. + expect(metadata.token).toBe("[redacted]"); + expect((metadata.nested as Record).accessToken).toBe( + "[redacted]", + ); + expect((metadata.nested as Record).note).toBe("ok"); + expect(result.pagination.total).toBe(1); + + // Newest-first ordering is requested from the DB. + expect(mockPrisma.auditLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { createdAt: "desc" } }), + ); + + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("secret-token-value"); + expect(serialized).not.toContain("should-drop"); + }); + + it("applies action/targetType/date filters to the query", async () => { + mockPrisma.auditLog.findMany.mockResolvedValue([]); + mockPrisma.auditLog.count.mockResolvedValue(0); + + await service.getAuditLogs({ + action: "STORE_SUSPENDED", + targetType: "Store", + startDate: "2026-07-01", + endDate: "2026-07-31", + q: "handle", + }); + + const call = mockPrisma.auditLog.findMany.mock.calls[0][0] as { + where: Record; + }; + expect(call.where).toEqual( + expect.objectContaining({ + action: "STORE_SUSPENDED", + targetType: "Store", + }), + ); + expect(call.where.createdAt).toBeDefined(); + expect(call.where.OR).toBeDefined(); + }); + + it("caps long metadata strings in a detail view", async () => { + mockPrisma.auditLog.findUnique.mockResolvedValue({ + id: "audit-1", + action: "NOTE", + targetType: "Order", + targetId: "order-1", + metadata: { note: "x".repeat(2000), password: "hunter2" }, + createdAt: new Date(), + user: null, + }); + + const result = (await service.getAuditLogById("audit-1")) as { + actor: unknown; + metadata: Record; + }; + + expect(result.actor).toBeNull(); + expect((result.metadata.note as string).length).toBeLessThanOrEqual(501); + expect(result.metadata.password).toBe("[redacted]"); + expect(JSON.stringify(result)).not.toContain("hunter2"); + }); + + it("throws when the audit log detail is missing", async () => { + mockPrisma.auditLog.findUnique.mockResolvedValue(null); + + await expect(service.getAuditLogById("missing")).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it("returns distinct action/target-type facets", async () => { + mockPrisma.auditLog.findMany + .mockResolvedValueOnce([ + { action: "DELETE_USER" }, + { action: "STORE_SUSPENDED" }, + ]) + .mockResolvedValueOnce([ + { targetType: "User" }, + { targetType: "Store" }, + ]); + + const facets = await service.getAuditLogFacets(); + + expect(facets.actions).toEqual(["DELETE_USER", "STORE_SUSPENDED"]); + expect(facets.targetTypes).toEqual(["User", "Store"]); + }); + }); + + describe("getDomainEventTimeline", () => { + it("returns an ascending admin timeline with sanitized metadata", async () => { + const createdAt = new Date("2026-06-01T01:00:00.000Z"); + mockPrisma.domainEvent.findMany.mockResolvedValue([ + { + id: "event-1", + aggregateType: "ORDER", + aggregateId: "order-1", + eventType: "PAYMENT_CONFIRMED", + actorType: "PROVIDER", + actorId: null, + source: "payment.service", + metadata: { + amountKobo: 250000, + password: "secret", + rawProviderPayload: { unsafe: true }, + nested: { cardNumber: "4242424242424242", safe: "ok" }, + }, + correlationId: "corr-1", + createdAt, + }, + ]); + mockPrisma.domainEvent.count.mockResolvedValue(1); + + const result = await service.getOrderTimeline("order-1", { limit: 200 }); + + expect(mockPrisma.domainEvent.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + aggregateId: "order-1", + aggregateType: { in: ["ORDER", "Order"] }, + }, + orderBy: { createdAt: "asc" }, + take: 100, + }), + ); + expect(result.pagination).toEqual({ + page: 1, + limit: 100, + total: 1, + hasMore: false, + }); + expect(result.items[0]).toEqual( + expect.objectContaining({ + id: "event-1", + aggregateType: "ORDER", + aggregateId: "order-1", + eventType: "PAYMENT_CONFIRMED", + actorType: "PROVIDER", + actorId: null, + source: "payment.service", + correlationId: "corr-1", + createdAt, + }), + ); + expect(result.items[0].metadata).toEqual({ + amountKobo: "250000", + nested: { safe: "ok" }, + }); + }); + + it("returns an empty timeline for unknown aggregates", async () => { + mockPrisma.domainEvent.findMany.mockResolvedValue([]); + mockPrisma.domainEvent.count.mockResolvedValue(0); + + const result = await service.getDomainEventTimeline( + "payment", + "missing-payment", + ); + + expect(result.items).toEqual([]); + expect(result.pagination.total).toBe(0); + }); + }); + + describe("resolveDispute", () => { + const orderId = "order-123"; + const adminId = "admin-456"; + + it("should throw NotFoundException if order does not exist", async () => { + mockPrisma.order.findUnique.mockResolvedValue(null); + + await expect( + service.resolveDispute(orderId, "SHOPPER", adminId, "Test"), + ).rejects.toThrow(NotFoundException); + }); + + it("should throw BadRequestException if order is not in DISPUTE status", async () => { + mockPrisma.order.findUnique.mockResolvedValue({ + id: orderId, + status: OrderStatus.PAID, + disputeStatus: OrderDisputeStatus.PENDING, + }); + + await expect( + service.resolveDispute(orderId, "SHOPPER", adminId, "Test"), + ).rejects.toThrow(BadRequestException); + }); + + it("should resolve in favor of SHOPPER, mark REFUND_PENDING, and release inventory", async () => { + const mockOrder = { + id: orderId, + status: OrderStatus.DISPUTE, + disputeStatus: OrderDisputeStatus.PENDING, + items: [ + { productId: "prod-1", quantity: 2 }, + { productId: "prod-2", quantity: 1 }, + ], + buyerId: "buyer-1", + storeId: "merchant-1", + }; + + mockPrisma.order.findUnique + .mockResolvedValueOnce(mockOrder) + .mockResolvedValueOnce({ + ...mockOrder, + status: OrderStatus.REFUND_PENDING, + }); + mockPrisma.order.updateMany.mockResolvedValue({ count: 1 }); + mockPrisma.productStockCache.updateMany.mockResolvedValue({ count: 1 }); + mockPrisma.order.update.mockResolvedValue({ + ...mockOrder, + status: OrderStatus.REFUND_PENDING, + }); + + const result = await service.resolveDispute( + orderId, + "SHOPPER", + adminId, + "Item not as described", + ); + + expect(result.status).toBe(OrderStatus.REFUND_PENDING); + + // Verify Order Update (within transaction) + expect(mockPrisma.order.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: orderId }), + data: expect.objectContaining({ + status: OrderStatus.REFUND_PENDING, + disputeStatus: OrderDisputeStatus.RESOLVED_SHOPPER, + payoutStatus: "CANCELLED", + }), + }), + ); + + // Verify Inventory Events + expect(mockPrisma.inventoryEvent.create).toHaveBeenCalledTimes(2); + expect(mockPrisma.inventoryEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + productId: "prod-1", + eventType: InventoryEventType.ORDER_RELEASED, + }), + }), + ); + + // Verify Stock Cache Update + expect(mockPrisma.productStockCache.updateMany).toHaveBeenCalledTimes(2); + + // Verify Notifications + expect(mockNotifications.triggerDisputeResolved).toHaveBeenCalledWith( + "buyer-1", + "merchant-1", + orderId, + "SHOPPER", + ); + }); + + it("should resolve in favor of STORE, mark COMPLETED, and queue payout", async () => { + const mockOrder = { + id: orderId, + status: OrderStatus.DISPUTE, + disputeStatus: OrderDisputeStatus.PENDING, + buyerId: "buyer-1", + storeId: "merchant-1", + }; + + mockPrisma.order.findUnique + .mockResolvedValueOnce(mockOrder) + .mockResolvedValueOnce({ + ...mockOrder, + status: OrderStatus.COMPLETED, + }); + mockPrisma.order.updateMany.mockResolvedValue({ count: 1 }); + mockPrisma.order.update.mockResolvedValue({ + ...mockOrder, + status: OrderStatus.COMPLETED, + }); + + const result = await service.resolveDispute( + orderId, + "STORE", + adminId, + "Delivery confirmed by courier", + ); + + expect(result.status).toBe(OrderStatus.COMPLETED); + + // Verify Order Update + expect(mockPrisma.order.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: orderId }), + data: expect.objectContaining({ + status: OrderStatus.COMPLETED, + disputeStatus: OrderDisputeStatus.RESOLVED_STORE, + payoutStatus: "PENDING", + }), + }), + ); + + // Verify Payout Queued + expect(mockPayoutQueue.add).toHaveBeenCalled(); + + // Verify Notifications + expect(mockNotifications.triggerDisputeResolved).toHaveBeenCalledWith( + "buyer-1", + "merchant-1", + orderId, + "STORE", + ); + }); + }); + + describe("admin operations", () => { + it("lists only ready-for-pickup orders without delivery bookings", async () => { + const updatedAt = new Date("2026-06-07T10:00:00.000Z"); + mockPrisma.order.findMany.mockResolvedValue([ + { + id: "order-1", + orderCode: "TWZ-123456", + orderType: "DIRECT", + storeId: "store-1", + productId: "product-1", + sourcedProductId: null, + deliveryFeeKobo: 250000n, + status: OrderStatus.READY_FOR_PICKUP, + createdAt: updatedAt, + updatedAt, + deliveryPrimaryPhone: "+2348012345678", + deliverySecondaryPhone: null, + deliveryAddressSnapshot: { + formattedAddress: "12 Shopper Street, Yaba", + }, + pickupAddressSnapshot: { + formattedAddress: "Balogun Market, Lagos", + }, + pickupStoreId: "store-1", + sourceStoreId: null, + pickupZone: "balogun", + deliveryZone: "yaba", + storeProfile: { + id: "store-1", + storeName: "Twizrr Store", + businessName: "Twizrr Store Ltd", + storeHandle: "twizrr-store", + logoUrl: null, + }, + product: { + id: "product-1", + productCode: "P123", + name: "Test product", + title: null, + imageUrl: null, + }, + sourcedProduct: null, + }, + ]); + mockPrisma.order.count.mockResolvedValue(1); + + const result = await service.getReadyForPickupDeliveryQueue({ + limit: 10, + }); + + expect(mockPrisma.order.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: OrderStatus.READY_FOR_PICKUP, + deliveryBooking: { is: null }, + }), + orderBy: { updatedAt: "asc" }, + }), + ); + expect(result.items[0]).toMatchObject({ + id: "order-1", + deliveryFeeKobo: "250000", + deliveryPrimaryPhone: "+2348012345678", + deliveryAddressSnapshot: { + formattedAddress: "12 Shopper Street, Yaba", + }, + pickupAddressSnapshot: { + formattedAddress: "Balogun Market, Lagos", + }, + }); + }); + + it("creates a manual delivery booking without dispatching the order", async () => { + mockPrisma.order.findUnique.mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-123456", + status: OrderStatus.READY_FOR_PICKUP, + deliveryFeeKobo: 250000n, + deliveryOtp: null, + dispatchedAt: null, + deliveryAddress: "12 Shopper Street, Yaba", + deliveryPrimaryPhone: "+2348012345678", + deliverySecondaryPhone: null, + deliveryAddressSnapshot: { + formattedAddress: "12 Shopper Street, Yaba", + }, + pickupAddressSnapshot: { + formattedAddress: "Balogun Market, Lagos", + }, + deliveryBooking: null, + }); + mockPrisma.deliveryBooking.create.mockResolvedValue({ + id: "booking-1", + orderId: "order-1", + method: DeliveryMethod.PLATFORM_LOGISTICS, + partnerName: "twizrr internal", + partnerRef: null, + trackingUrl: null, + pickupAddress: "Balogun Market, Lagos", + deliveryAddress: "12 Shopper Street, Yaba", + estimatedCostKobo: 250000n, + actualCostKobo: null, + status: DeliveryStatus.PENDING, + estimatedArrival: null, + pickedUpAt: null, + deliveredAt: null, + createdAt: new Date("2026-06-07T10:00:00.000Z"), + }); + + const result = await service.createManualDeliveryBooking( + "order-1", + "admin-1", + ); + + expect(mockPrisma.deliveryBooking.create).toHaveBeenCalledWith({ + data: { + orderId: "order-1", + method: DeliveryMethod.PLATFORM_LOGISTICS, + partnerName: "twizrr internal", + pickupAddress: "Balogun Market, Lagos", + deliveryAddress: "12 Shopper Street, Yaba", + estimatedCostKobo: 250000n, + status: DeliveryStatus.PENDING, + }, + }); + expect(mockPrisma.order.update).not.toHaveBeenCalled(); + expect(mockPrisma.order.updateMany).not.toHaveBeenCalled(); + expect(mockPrisma.orderEvent.create).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + id: "booking-1", + estimatedCostKobo: "250000", + actualCostKobo: null, + }); + }); + + it("rejects duplicate manual delivery bookings cleanly", async () => { + mockPrisma.order.findUnique.mockResolvedValue({ + id: "order-1", + status: OrderStatus.READY_FOR_PICKUP, + deliveryBooking: { id: "booking-1" }, + }); + + await expect( + service.createManualDeliveryBooking("order-1", "admin-1"), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it("rejects manual booking when required snapshots are missing", async () => { + mockPrisma.order.findUnique.mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-123456", + status: OrderStatus.READY_FOR_PICKUP, + deliveryFeeKobo: 250000n, + deliveryAddress: null, + deliveryPrimaryPhone: "+2348012345678", + deliveryAddressSnapshot: { + formattedAddress: "12 Shopper Street, Yaba", + }, + pickupAddressSnapshot: null, + deliveryBooking: null, + }); + + await expect( + service.createManualDeliveryBooking("order-1", "admin-1"), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mockPrisma.deliveryBooking.create).not.toHaveBeenCalled(); + }); + + it("rejects manual booking for non-ready orders", async () => { + mockPrisma.order.findUnique.mockResolvedValue({ + id: "order-1", + status: OrderStatus.PAID, + deliveryBooking: null, + }); + + await expect( + service.createManualDeliveryBooking("order-1", "admin-1"), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mockPrisma.deliveryBooking.create).not.toHaveBeenCalled(); + }); + + const shipbubbleOrder = { + id: "order-1", + orderCode: "TWZ-123456", + status: OrderStatus.READY_FOR_PICKUP, + totalAmountKobo: 2500000n, + deliveryFeeKobo: 250000n, + quantity: 1, + deliveryAddress: "12 Shopper Street, Yaba", + deliveryPrimaryPhone: "+2348012345678", + deliverySecondaryPhone: null, + deliveryAddressSnapshot: { + recipientName: "Ada Buyer", + formattedAddress: "12 Shopper Street, Yaba", + city: "Yaba", + state: "Lagos", + postalCode: "100001", + }, + pickupAddressSnapshot: { + formattedAddress: "Balogun Market, Lagos", + city: "Lagos Island", + state: "Lagos", + storeName: "Balogun Source", + }, + product: { + name: "Test product", + title: null, + retailPriceKobo: 2500000n, + weightKg: 2, + }, + sourcedProduct: null, + storeProfile: { + storeName: "Twizrr Store", + businessName: "Twizrr Store Ltd", + user: { + email: "store@example.com", + phone: "+2348000000000", + displayName: "Store Owner", + firstName: "Store", + lastName: "Owner", + }, + }, + }; + + const pendingShipbubbleBooking = { + id: "booking-1", + orderId: "order-1", + status: DeliveryStatus.PENDING, + order: shipbubbleOrder, + }; + + it("books a pending delivery with Shipbubble without dispatching the order", async () => { + const estimatedArrival = new Date("2026-06-08T10:00:00.000Z"); + mockPrisma.deliveryBooking.findUnique + .mockResolvedValueOnce(pendingShipbubbleBooking) + .mockResolvedValueOnce({ + id: "booking-1", + orderId: "order-1", + status: DeliveryStatus.PICKUP_SCHEDULED, + partnerRef: null, + order: { + id: "order-1", + orderCode: "TWZ-123456", + status: OrderStatus.READY_FOR_PICKUP, + }, + }); + mockShipbubble.bookDelivery.mockResolvedValue({ + provider: "shipbubble", + providerBookingId: "shipbubble-booking-1", + providerTrackingId: "SB-TRACK-1", + trackingUrl: "https://shipbubble.example/waybill.pdf", + feeKobo: 260000n, + estimatedDeliveryDate: estimatedArrival.toISOString(), + status: "BOOKED", + }); + mockPrisma.deliveryBooking.update.mockResolvedValue({ + id: "booking-1", + orderId: "order-1", + method: DeliveryMethod.PLATFORM_LOGISTICS, + partnerName: "Shipbubble", + partnerRef: "shipbubble-booking-1", + trackingUrl: "https://shipbubble.example/waybill.pdf", + pickupAddress: "Balogun Market, Lagos", + deliveryAddress: "12 Shopper Street, Yaba", + estimatedCostKobo: 250000n, + actualCostKobo: 260000n, + status: DeliveryStatus.PICKUP_SCHEDULED, + estimatedArrival, + pickedUpAt: null, + deliveredAt: null, + createdAt: new Date("2026-06-07T10:00:00.000Z"), + }); + + const result = await service.bookDeliveryWithShipbubble( + "booking-1", + "admin-1", + { + rateId: "rate-1", + requestToken: "request-1", + serviceCode: "express", + }, + ); + + expect(mockShipbubble.bookDelivery).toHaveBeenCalledWith( + expect.objectContaining({ + quoteId: "rate-1", + bookingToken: "request-1", + serviceCode: "express", + idempotencyKey: "admin-delivery-booking:booking-1", + pickup: expect.objectContaining({ + name: "Balogun Source", + email: "store@example.com", + phone: "+2348000000000", + address: "Balogun Market, Lagos", + city: "Lagos Island", + state: "Lagos", + countryCode: "NG", + }), + dropoff: expect.objectContaining({ + name: "Ada Buyer", + phone: "+2348012345678", + address: "12 Shopper Street, Yaba", + city: "Yaba", + state: "Lagos", + countryCode: "NG", + }), + parcel: expect.objectContaining({ + weightKg: 2, + categoryId: 1, + itemName: "Test product", + itemValueKobo: 2500000n, + quantity: 1, + }), + }), + ); + expect(mockPrisma.deliveryBooking.update).toHaveBeenCalledWith({ + where: { id: "booking-1" }, + data: { + partnerName: "Shipbubble", + partnerRef: "shipbubble-booking-1", + trackingUrl: "https://shipbubble.example/waybill.pdf", + actualCostKobo: 260000n, + estimatedArrival, + status: DeliveryStatus.PICKUP_SCHEDULED, + }, + }); + expect(mockPrisma.deliveryBooking.updateMany).toHaveBeenCalledWith({ + where: { + id: "booking-1", + status: DeliveryStatus.PENDING, + }, + data: { + partnerName: "Shipbubble", + status: DeliveryStatus.PICKUP_SCHEDULED, + }, + }); + expect(mockPrisma.order.update).not.toHaveBeenCalled(); + expect(mockPrisma.order.updateMany).not.toHaveBeenCalled(); + expect(mockPrisma.orderEvent.create).not.toHaveBeenCalled(); + expect(mockOrderService.startDeliveryByAdmin).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + id: "booking-1", + partnerName: "Shipbubble", + partnerRef: "shipbubble-booking-1", + actualCostKobo: "260000", + status: DeliveryStatus.PICKUP_SCHEDULED, + }); + }); + + it("fetches Shipbubble rates for a pending delivery from order snapshots", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue( + pendingShipbubbleBooking, + ); + mockShipbubble.getDeliveryRates.mockResolvedValue([ + { + provider: "shipbubble", + quoteId: "rate-1", + bookingToken: "request-1", + serviceName: "Shipbubble Express", + serviceCode: "express", + amountKobo: 260000n, + estimatedDeliveryWindow: "1-2 days", + }, + ]); + + const result = await service.getShipbubbleDeliveryRates( + "booking-1", + "admin-1", + ); + + expect(mockShipbubble.getDeliveryRates).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Balogun Source", + email: "store@example.com", + phone: "+2348000000000", + address: "Balogun Market, Lagos", + city: "Lagos Island", + state: "Lagos", + countryCode: "NG", + }), + expect.objectContaining({ + name: "Ada Buyer", + phone: "+2348012345678", + address: "12 Shopper Street, Yaba", + city: "Yaba", + state: "Lagos", + countryCode: "NG", + }), + expect.objectContaining({ + weightKg: 2, + categoryId: 1, + itemName: "Test product", + itemValueKobo: 2500000n, + quantity: 1, + }), + ); + expect(mockPrisma.deliveryBooking.update).not.toHaveBeenCalled(); + expect(mockPrisma.order.update).not.toHaveBeenCalled(); + expect(mockOrderService.startDeliveryByAdmin).not.toHaveBeenCalled(); + expect(result).toEqual({ + provider: "Shipbubble", + bookingId: "booking-1", + orderId: "order-1", + orderCode: "TWZ-123456", + requestToken: "request-1", + rates: [ + { + rateId: "rate-1", + courierName: "Shipbubble Express", + serviceCode: "express", + amountKobo: "260000", + currency: "NGN", + estimatedDeliveryDate: null, + estimatedDeliveryWindow: "1-2 days", + }, + ], + }); + }); + + it("does not call Shipbubble when another admin has already claimed the booking", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue( + pendingShipbubbleBooking, + ); + mockPrisma.deliveryBooking.updateMany.mockResolvedValueOnce({ count: 0 }); + + await expect( + service.bookDeliveryWithShipbubble("booking-1", "admin-1", { + rateId: "rate-1", + requestToken: "request-1", + serviceCode: "express", + }), + ).rejects.toBeInstanceOf(ConflictException); + + expect(mockShipbubble.bookDelivery).not.toHaveBeenCalled(); + }); + + it("uses configured package defaults when product and snapshot values are missing", async () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "shipbubble.defaultPackageWeightKg") return 1.75; + if (key === "shipbubble.defaultCategoryId") return 42; + return undefined; + }); + mockPrisma.deliveryBooking.findUnique.mockResolvedValue({ + ...pendingShipbubbleBooking, + order: { + ...shipbubbleOrder, + pickupAddressSnapshot: { + formattedAddress: "Balogun Market, Lagos", + city: "Lagos Island", + state: "Lagos", + storeName: "Balogun Source", + }, + product: { + ...shipbubbleOrder.product, + weightKg: null, + }, + }, + }); + mockShipbubble.getDeliveryRates.mockResolvedValue([]); + + await service.getShipbubbleDeliveryRates("booking-1", "admin-1"); + + expect(mockShipbubble.getDeliveryRates).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + expect.objectContaining({ + weightKg: 1.75, + categoryId: 42, + }), + ); + }); + + it("keeps product weight ahead of configured package weight", async () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "shipbubble.defaultPackageWeightKg") return 1.75; + if (key === "shipbubble.defaultCategoryId") return 42; + return undefined; + }); + mockPrisma.deliveryBooking.findUnique.mockResolvedValue( + pendingShipbubbleBooking, + ); + mockShipbubble.getDeliveryRates.mockResolvedValue([]); + + await service.getShipbubbleDeliveryRates("booking-1", "admin-1"); + + expect(mockShipbubble.getDeliveryRates).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + expect.objectContaining({ + weightKg: 2, + categoryId: 42, + }), + ); + }); + + it("rejects Shipbubble rate lookup for missing delivery bookings", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue(null); + + await expect( + service.getShipbubbleDeliveryRates("missing", "admin-1"), + ).rejects.toBeInstanceOf(NotFoundException); + expect(mockShipbubble.getDeliveryRates).not.toHaveBeenCalled(); + }); + + it("rejects Shipbubble rate lookup unless the booking is pending", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue({ + ...pendingShipbubbleBooking, + status: DeliveryStatus.PICKUP_SCHEDULED, + }); + + await expect( + service.getShipbubbleDeliveryRates("booking-1", "admin-1"), + ).rejects.toBeInstanceOf(ConflictException); + expect(mockShipbubble.getDeliveryRates).not.toHaveBeenCalled(); + }); + + it("rejects Shipbubble rate lookup unless the order is ready for pickup", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue({ + ...pendingShipbubbleBooking, + order: { ...shipbubbleOrder, status: OrderStatus.PAID }, + }); + + await expect( + service.getShipbubbleDeliveryRates("booking-1", "admin-1"), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mockShipbubble.getDeliveryRates).not.toHaveBeenCalled(); + }); + + it("rejects Shipbubble rate lookup when snapshots or contacts are incomplete", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue({ + ...pendingShipbubbleBooking, + order: { + ...shipbubbleOrder, + deliveryPrimaryPhone: null, + }, + }); + + await expect( + service.getShipbubbleDeliveryRates("booking-1", "admin-1"), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mockShipbubble.getDeliveryRates).not.toHaveBeenCalled(); + }); + + it("leaves delivery booking unchanged when Shipbubble rate lookup fails", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue( + pendingShipbubbleBooking, + ); + mockShipbubble.getDeliveryRates.mockRejectedValue( + new Error("provider unavailable"), + ); + + await expect( + service.getShipbubbleDeliveryRates("booking-1", "admin-1"), + ).rejects.toBeInstanceOf(BadGatewayException); + expect(mockPrisma.deliveryBooking.update).not.toHaveBeenCalled(); + expect(mockPrisma.order.update).not.toHaveBeenCalled(); + }); + + it("rejects Shipbubble booking for missing delivery bookings", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue(null); + + await expect( + service.bookDeliveryWithShipbubble("missing", "admin-1", { + rateId: "rate-1", + requestToken: "request-1", + }), + ).rejects.toBeInstanceOf(NotFoundException); + expect(mockShipbubble.bookDelivery).not.toHaveBeenCalled(); + }); + + it("rejects Shipbubble booking unless the booking is pending", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue({ + ...pendingShipbubbleBooking, + status: DeliveryStatus.PICKUP_SCHEDULED, + }); + + await expect( + service.bookDeliveryWithShipbubble("booking-1", "admin-1", { + rateId: "rate-1", + requestToken: "request-1", + }), + ).rejects.toBeInstanceOf(ConflictException); + expect(mockShipbubble.bookDelivery).not.toHaveBeenCalled(); + }); + + it("rejects Shipbubble booking unless the order is ready for pickup", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue({ + ...pendingShipbubbleBooking, + order: { ...shipbubbleOrder, status: OrderStatus.PAID }, + }); + + await expect( + service.bookDeliveryWithShipbubble("booking-1", "admin-1", { + rateId: "rate-1", + requestToken: "request-1", + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mockShipbubble.bookDelivery).not.toHaveBeenCalled(); + }); + + it("rejects Shipbubble booking when snapshots or contacts are incomplete", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue({ + ...pendingShipbubbleBooking, + order: { + ...shipbubbleOrder, + deliveryPrimaryPhone: null, + }, + }); + + await expect( + service.bookDeliveryWithShipbubble("booking-1", "admin-1", { + rateId: "rate-1", + requestToken: "request-1", + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mockShipbubble.bookDelivery).not.toHaveBeenCalled(); + }); + + it("leaves manual booking retryable when Shipbubble fails", async () => { + mockPrisma.deliveryBooking.findUnique.mockResolvedValue( + pendingShipbubbleBooking, + ); + mockShipbubble.bookDelivery.mockRejectedValue( + new Error("provider unavailable"), + ); + + await expect( + service.bookDeliveryWithShipbubble("booking-1", "admin-1", { + rateId: "rate-1", + requestToken: "request-1", + }), + ).rejects.toBeInstanceOf(BadGatewayException); + expect(mockPrisma.deliveryBooking.update).not.toHaveBeenCalled(); + expect(mockPrisma.order.update).not.toHaveBeenCalled(); + }); + + it("delegates admin start delivery to OrderService", async () => { + mockOrderService.startDeliveryByAdmin.mockResolvedValue({ + id: "order-1", + status: OrderStatus.DISPATCHED, + }); + + await expect( + service.startDelivery("order-1", "admin-1"), + ).resolves.toMatchObject({ + id: "order-1", + status: OrderStatus.DISPATCHED, + }); + + expect(mockOrderService.startDeliveryByAdmin).toHaveBeenCalledWith( + "order-1", + "admin-1", + ); + expect(mockAuditLog.log).toHaveBeenCalledWith( + "admin-1", + "ADMIN_START_DELIVERY", + "Order", + "order-1", + { + action: "admin_start_delivery", + reason: "", + requestContext: null, + }, + ); + }); + + it("does not mask start delivery success when audit logging fails", async () => { + mockOrderService.startDeliveryByAdmin.mockResolvedValue({ + id: "order-1", + status: OrderStatus.DISPATCHED, + }); + mockAuditLog.log.mockRejectedValueOnce(new Error("audit unavailable")); + + await expect( + service.startDelivery("order-1", "admin-1"), + ).resolves.toMatchObject({ + id: "order-1", + status: OrderStatus.DISPATCHED, + }); + }); + + it("should build a platform overview with counts, money, and recent activity", async () => { + mockPrisma.user.count.mockResolvedValue(10); + mockPrisma.storeProfile.count.mockResolvedValue(4); + mockPrisma.order.count.mockResolvedValue(7); + mockPrisma.product.count.mockResolvedValue(12); + mockPrisma.dispute.count.mockResolvedValue(1); + mockPrisma.payout.count.mockResolvedValue(3); + mockPrisma.verificationRequest.count.mockResolvedValue(5); + mockPrisma.moderationLog.count.mockResolvedValue(2); + mockPrisma.deliveryBooking.count.mockResolvedValue(0); + mockPrisma.whatsAppAnalytics.count.mockResolvedValue(9); + mockPrisma.order.aggregate.mockResolvedValue({ + _sum: { + totalAmountKobo: BigInt("1000"), + platformFeeKobo: BigInt("20"), + }, + }); + mockPrisma.payout.aggregate.mockResolvedValue({ + _sum: { amountKobo: BigInt("50") }, + }); + mockCategoryDemand.getTopZeroResultSubcategories.mockResolvedValue([]); + mockPrisma.auditLog.findMany.mockResolvedValue([ + { id: "audit-1", action: "STORE_APPROVED" }, + ]); + + const result = await service.getDashboard(); + + expect(result).toMatchObject({ + users: { total: 10 }, + stores: { total: 4, pendingVerification: 5 }, + operations: { + verificationPending: 5, + moderationPending: 2, + disputesOpen: 1, + payoutsFailed: 3, + }, + recentActivity: [{ id: "audit-1", action: "STORE_APPROVED" }], + }); + expect(typeof result.money.gmvKobo).toBe("string"); + }); + + it("should return paginated users without secret fields", async () => { + mockPrisma.user.findMany.mockResolvedValue([ + { + id: "user-1", + email: "user@example.com", + phone: "+2348012345678", + role: "USER", + isActive: true, + passwordHash: "secret", + createdAt: new Date("2026-05-01T00:00:00.000Z"), + }, + ]); + mockPrisma.user.count.mockResolvedValue(1); + + const result = await service.getUsers({ limit: 10 }); + + expect(result.items[0]).not.toHaveProperty("passwordHash"); + expect(result).toMatchObject({ + pagination: { limit: 10, page: 1, total: 1, hasMore: false }, + }); + }); + + it("should mask store bank fields in admin store details", async () => { + mockPrisma.storeProfile.findUnique.mockResolvedValue({ + id: "store-1", + businessName: "Twizrr Test Store", + accountNumber: "0123456789", + bankAccountNumber: "9988776655", + paystackRecipientCode: "RCP_secret", + ninNumber: "12345678901", + user: { id: "user-1", email: "owner@example.com" }, + }); + + const result = await service.getStoreById("store-1"); + + expect(result).toMatchObject({ + id: "store-1", + bankAccount: { + accountNumberLast4: "6789", + bankAccountNumberLast4: "6655", + }, + }); + expect(result).not.toHaveProperty("paystackRecipientCode"); + expect(result).not.toHaveProperty("ninNumber"); + }); + + it("rejects store verification approval when no pending request exists", async () => { + mockPrisma.storeProfile.findUnique.mockResolvedValue({ + id: "store-1", + verificationRequests: [], + }); + + await expect( + service.verifyMerchant("store-1", "admin-1"), + ).rejects.toBeInstanceOf(NotFoundException); + + expect(mockVerification.reviewRequest).not.toHaveBeenCalled(); + expect(mockPrisma.storeProfile.update).not.toHaveBeenCalled(); + }); + + it("approves store verification through the verification service and records audit trace", async () => { + mockPrisma.storeProfile.findUnique.mockResolvedValue({ + id: "store-1", + verificationRequests: [{ id: "request-1", targetTier: "TIER_2" }], + }); + mockVerification.reviewRequest.mockResolvedValue({ + message: "Verification request approved", + decision: "APPROVED", + newTier: "TIER_2", + }); + + await expect( + service.verifyMerchant("store-1", "admin-1"), + ).resolves.toMatchObject({ + decision: "APPROVED", + newTier: "TIER_2", + }); + + expect(mockVerification.reviewRequest).toHaveBeenCalledWith( + "request-1", + "admin-1", + { decision: "APPROVED" }, + ); + expect(mockAuditLog.log).toHaveBeenCalledWith( + "admin-1", + "VERIFY_STORE", + "VerificationRequest", + "request-1", + { + action: "admin_verify_store", + requestContext: null, + storeId: "store-1", + targetTier: "TIER_2", + }, + ); + expect(mockPrisma.storeProfile.update).not.toHaveBeenCalled(); + }); + + it("returns a safe verification request detail (no raw NIN / document / provider payload)", async () => { + mockPrisma.verificationRequest.findUnique.mockResolvedValue({ + id: "request-1", + storeId: "store-1", + idType: "NIN", + status: "PENDING", + targetTier: "TIER_2", + governmentIdUrl: "https://cloudinary/secret-doc.jpg", + reviewedAt: null, + rejectionReason: null, + createdAt: new Date("2026-07-01T00:00:00Z"), + reviewer: null, + store: { + id: "store-1", + businessName: "Yaba Fashion", + storeHandle: "yabafashion", + businessCategory: "Fashion", + isOpen: true, + tier: "TIER_1", + verificationTier: "TIER_1", + verifiedAt: null, + createdAt: new Date("2026-06-01T00:00:00Z"), + updatedAt: new Date("2026-06-15T00:00:00Z"), + bankVerified: true, + businessPhoneVerified: true, + addressVerified: false, + addressVerifiedVia: null, + guarantorVerified: false, + profileImage: "https://cloudinary/profile.jpg", + ninVerified: true, + ninVerifiedAt: new Date("2026-06-20T00:00:00Z"), + ninVerifiedVia: "PREMBLY", + ninVerificationStatus: "VERIFIED", + ninVerificationAttempts: 1, + ninVerificationLockedAt: null, + user: { + id: "owner-1", + email: "owner@example.com", + phone: "+2348012345678", + firstName: "Ada", + lastName: "Obi", + }, + }, + }); + mockPrisma.order.count.mockResolvedValue(6); + mockPrisma.verificationRequest.count.mockResolvedValue(2); + mockPrisma.verificationRequest.findMany.mockResolvedValue([]); + + const result = await service.getVerificationRequestDetail("request-1"); + + // Normalized Prembly outcome is present… + expect(result.prembly.nin).toMatchObject({ + verified: true, + method: "PREMBLY", + status: "VERIFIED", + }); + expect(result.store.owner).toMatchObject({ + email: "owner@example.com", + name: "Ada Obi", + }); + expect(result.evidence).toEqual({ + hasGovernmentId: true, + hasProfilePhoto: true, + }); + + // …but no raw identity/provider data escapes the DTO. + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("secret-doc.jpg"); + expect(serialized).not.toContain("governmentIdUrl"); + expect(serialized).not.toContain("ninNumber"); + expect(serialized).not.toContain("confidence"); + }); + + it("throws when the verification request detail is missing", async () => { + mockPrisma.verificationRequest.findUnique.mockResolvedValue(null); + + await expect( + service.getVerificationRequestDetail("missing"), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it("lists reconciliation attempts with only the safe internal review fields", async () => { + const submittedAt = new Date("2026-07-19T10:00:00.000Z"); + mockPrisma.storeVerificationAttempt.findMany.mockResolvedValue([ + { + id: "attempt-1", + storeId: "store-1", + provider: "prembly", + checkType: "NIN", + status: "RECONCILIATION_REQUIRED", + failureCode: "PROVIDER_TIMEOUT", + providerReference: "provider-reference-1", + submittedAt, + updatedAt: submittedAt, + store: { businessName: "Store One", user: { id: "owner-1" } }, + }, + ]); + mockPrisma.storeVerificationAttempt.count.mockResolvedValue(1); + + const result = await service.getVerificationReconciliationAttempts({ + page: 1, + limit: 20, + }); + + expect(result.pagination).toMatchObject({ total: 1 }); + expect(mockPrisma.storeVerificationAttempt.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { status: "RECONCILIATION_REQUIRED" }, + select: expect.not.objectContaining({ nin: true, selfie: true }), + }), + ); + expect(JSON.stringify(result)).not.toContain("raw-provider-payload"); + }); + + it("uses the canonical reconciliation path and appends a safe admin audit", async () => { + mockPrisma.storeVerificationAttempt.findUnique.mockResolvedValue({ + id: "attempt-1", + storeId: "store-1", + status: "RECONCILIATION_REQUIRED", + checkType: "NIN", + provider: "prembly", + }); + mockVerification.reconcileVerificationAttempt.mockResolvedValue({ + status: "VERIFIED", + }); + + await service.reconcileVerificationAttempt("attempt-1", "admin-1"); + + expect( + mockVerification.reconcileVerificationAttempt, + ).toHaveBeenCalledWith("attempt-1"); + expect(mockPrisma.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: "admin-1", + action: "RECONCILE_VERIFICATION_ATTEMPT", + targetType: "StoreVerificationAttempt", + targetId: "attempt-1", + metadata: expect.objectContaining({ + storeId: "store-1", + previousStatus: "RECONCILIATION_REQUIRED", + nextStatus: "VERIFIED", + }), + }), + }); + expect(mockDomainEvents.append).toHaveBeenCalledWith( + mockPrisma, + expect.objectContaining({ + eventType: "STORE_VERIFICATION_RECONCILED", + aggregateId: "attempt-1", + }), + ); + }); + + it("reviews (approves) a request through the canonical service path and audits it", async () => { + mockPrisma.verificationRequest.findUnique.mockResolvedValue({ + id: "request-1", + storeId: "store-1", + status: "PENDING", + targetTier: "TIER_2", + store: { tier: "TIER_1", verificationTier: "TIER_1" }, + }); + mockVerification.reviewRequest.mockResolvedValue({ + message: "Verification request approved", + decision: "APPROVED", + newTier: "TIER_2", + }); + + const result = await service.reviewVerificationRequest( + "request-1", + "APPROVED", + undefined, + "admin-1", + ); + + expect(result).toMatchObject({ decision: "APPROVED", newTier: "TIER_2" }); + // Single canonical decision implementation. + expect(mockVerification.reviewRequest).toHaveBeenCalledWith( + "request-1", + "admin-1", + { decision: "APPROVED", rejectionReason: undefined }, + ); + // AuditLog written for the decision. + expect(mockPrisma.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: "admin-1", + action: "VERIFICATION_APPROVED", + targetType: "VerificationRequest", + targetId: "request-1", + }), + }); + expect(mockDomainEvents.append).toHaveBeenCalledWith( + mockPrisma, + expect.objectContaining({ + eventType: "STORE_VERIFICATION_UPDATED", + actorId: "admin-1", + aggregateId: "request-1", + metadata: expect.objectContaining({ + storeId: "store-1", + nextStatus: "APPROVED", + requestedTier: "TIER_2", + }), + }), + ); + }); + + it("rejects a request with a reason and audits it", async () => { + mockPrisma.verificationRequest.findUnique.mockResolvedValue({ + id: "request-1", + storeId: "store-1", + status: "PENDING", + targetTier: "TIER_2", + store: { tier: "TIER_1", verificationTier: "TIER_1" }, + }); + mockVerification.reviewRequest.mockResolvedValue({ + message: "Verification request rejected", + decision: "REJECTED", + }); + + await service.reviewVerificationRequest( + "request-1", + "REJECTED", + "Selfie did not match the submitted identity", + "admin-1", + ); + + expect(mockVerification.reviewRequest).toHaveBeenCalledWith( + "request-1", + "admin-1", + { + decision: "REJECTED", + rejectionReason: "Selfie did not match the submitted identity", + }, + ); + expect(mockPrisma.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ action: "VERIFICATION_REJECTED" }), + }); + }); + + it("refuses to reject without a real reason and never calls the decision service", async () => { + mockPrisma.verificationRequest.findUnique.mockResolvedValue({ + id: "request-1", + storeId: "store-1", + status: "PENDING", + targetTier: "TIER_2", + store: { tier: "TIER_1", verificationTier: "TIER_1" }, + }); + + await expect( + service.reviewVerificationRequest( + "request-1", + "REJECTED", + " ", + "admin-1", + ), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(mockVerification.reviewRequest).not.toHaveBeenCalled(); + expect(mockPrisma.auditLog.create).not.toHaveBeenCalled(); + }); + + it("refuses to review an already-processed request", async () => { + mockPrisma.verificationRequest.findUnique.mockResolvedValue({ + id: "request-1", + storeId: "store-1", + status: "APPROVED", + targetTier: "TIER_2", + store: { tier: "TIER_2", verificationTier: "TIER_2" }, + }); + + await expect( + service.reviewVerificationRequest( + "request-1", + "APPROVED", + undefined, + "admin-1", + ), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(mockVerification.reviewRequest).not.toHaveBeenCalled(); + }); + + it("does not mask verification review success when audit logging fails", async () => { + mockPrisma.verificationRequest.findUnique.mockResolvedValue({ + id: "request-1", + storeId: "store-1", + status: "PENDING", + targetTier: "TIER_2", + store: { tier: "TIER_1", verificationTier: "TIER_1" }, + }); + mockVerification.reviewRequest.mockResolvedValue({ + message: "Verification request approved", + decision: "APPROVED", + newTier: "TIER_2", + }); + mockPrisma.$transaction.mockRejectedValueOnce( + new Error("audit unavailable"), + ); + + await expect( + service.reviewVerificationRequest( + "request-1", + "APPROVED", + undefined, + "admin-1", + ), + ).resolves.toMatchObject({ + decision: "APPROVED", + newTier: "TIER_2", + }); + }); + + it("rejects pending store verification through the verification service", async () => { + mockPrisma.storeProfile.findUnique.mockResolvedValue({ + id: "store-1", + userId: "owner-1", + verificationRequests: [{ id: "request-1", targetTier: "TIER_2" }], + }); + mockVerification.reviewRequest.mockResolvedValue({ + message: "Verification request rejected", + decision: "REJECTED", + }); + + await expect( + service.rejectMerchant( + "store-1", + "Incomplete identity check", + "admin-1", + ), + ).resolves.toMatchObject({ + decision: "REJECTED", + }); + + expect(mockVerification.reviewRequest).toHaveBeenCalledWith( + "request-1", + "admin-1", + { + decision: "REJECTED", + rejectionReason: "Incomplete identity check", + }, + ); + expect(mockNotifications.triggerMerchantRejected).not.toHaveBeenCalled(); + expect(mockPrisma.storeProfile.update).not.toHaveBeenCalled(); + }); + + it("requires a real reason when rejecting a pending store verification request", async () => { + mockPrisma.storeProfile.findUnique.mockResolvedValue({ + id: "store-1", + userId: "owner-1", + verificationRequests: [{ id: "request-1", targetTier: "TIER_2" }], + }); + + await expect( + service.rejectMerchant("store-1", " ", "admin-1"), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(mockVerification.reviewRequest).not.toHaveBeenCalled(); + expect(mockPrisma.storeProfile.update).not.toHaveBeenCalled(); + }); + }); + + describe("trust and safety investigations", () => { + it("returns paginated, safe linkage investigation rows", async () => { + mockPrisma.$queryRaw.mockResolvedValue([ + { + subjectUserId: "user-1", + highestSeverity: "high", + signalCount: BigInt(2), + latestSignalAt: new Date("2026-07-16T10:00:00.000Z"), + signalCodes: ["LOGIN_FROM_SUSPENDED_USER_DEVICE"], + evidenceTypes: ["suspended_user_device"], + total: BigInt(1), + }, + ]); + mockPrisma.user.findMany.mockResolvedValue([ + { + id: "user-1", + email: "user@example.com", + role: "USER", + isActive: true, + createdAt: new Date("2026-07-01T00:00:00.000Z"), + deletedAt: null, + }, + ]); + + const result = await service.getSafetyInvestigations({ + severity: "high", + evidenceType: "suspended_user_device", + }); + + expect(result).toEqual( + expect.objectContaining({ + items: [ + expect.objectContaining({ + subjectUserId: "user-1", + highestSeverity: "high", + signalCount: 2, + evidenceTypes: ["suspended_user_device"], + }), + ], + pagination: expect.objectContaining({ total: 1 }), + }), + ); + expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1); + }); + + it("returns detail evidence without raw device hashes or unsafe metadata", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "user-1", + email: "subject@example.com", + firstName: "Ada", + lastName: "Lovelace", + role: "USER", + isActive: true, + createdAt: new Date("2026-07-01T00:00:00.000Z"), + deletedAt: null, + }); + mockPrisma.domainEvent.findMany.mockResolvedValue([ + { + id: "signal-1", + eventType: "AUTH_RISK_SIGNAL_RECORDED", + source: "auth.account-abuse-linkage", + correlationId: "req-1", + createdAt: new Date("2026-07-16T10:00:00.000Z"), + metadata: { + signalCode: "MULTIPLE_ACCOUNTS_SAME_DEVICE", + deviceIdHash: "must-not-leak", + evidence: [{ type: "same_device", userId: "user-2" }], + }, + }, + ]); + mockPrisma.userDevice.findMany + .mockResolvedValueOnce([ + { + id: "device-record-1", + deviceIdHash: "server-only-hash", + firstSeenAt: new Date("2026-07-01T00:00:00.000Z"), + lastSeenAt: new Date("2026-07-16T00:00:00.000Z"), + deviceType: "desktop", + }, + ]) + .mockResolvedValueOnce([]); + mockPrisma.user.findMany.mockResolvedValueOnce([ + { + id: "user-2", + email: "related@example.com", + role: "USER", + isActive: false, + createdAt: new Date("2026-07-02T00:00:00.000Z"), + deletedAt: null, + }, + ]); + + const result = await service.getSafetyInvestigation("user-1"); + + expect(result.relatedAccounts).toEqual([ + expect.objectContaining({ + userId: "user-2", + relationshipType: "same_device", + }), + ]); + expect(JSON.stringify(result)).not.toContain("must-not-leak"); + expect(JSON.stringify(result)).not.toContain("server-only-hash"); + expect(JSON.stringify(result)).not.toContain("deliveryAddress"); + }); + }); + + describe("payment amount exception review", () => { + const detectedAt = new Date("2026-07-19T10:00:00.000Z"); + const exception = { + id: "exception-1", + paymentId: "payment-1", + paymentAttemptId: "attempt-1", + provider: "PAYSTACK", + providerReference: "safe-reference-1", + providerTransactionReference: "provider-tx-1", + expectedAmountKobo: 260000n, + receivedAmountKobo: 259999n, + exceptionType: "UNDERPAID", + status: "RECONCILIATION_REQUIRED", + detectedAt, + createdAt: detectedAt, + updatedAt: detectedAt, + payment: { + orderId: "order-1", + status: "RECONCILIATION_REQUIRED", + order: { + id: "order-1", + orderCode: "TWZ-123456", + status: "PENDING_PAYMENT", + buyerId: "buyer-1", + storeId: "store-1", + }, + }, + }; + + it("lists safe exception evidence with BigInt amounts serialized as strings", async () => { + mockPrisma.paymentAmountException.findMany.mockResolvedValue([exception]); + mockPrisma.paymentAmountException.count.mockResolvedValue(1); + + const result = await service.getPaymentAmountExceptions({ + status: "RECONCILIATION_REQUIRED", + exceptionType: "UNDERPAID", + }); + + expect(result).toMatchObject({ + items: [ + expect.objectContaining({ + expectedAmountKobo: "260000", + receivedAmountKobo: "259999", + exceptionType: "UNDERPAID", + }), + ], + }); + expect(mockPrisma.paymentAmountException.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: "RECONCILIATION_REQUIRED", + exceptionType: "UNDERPAID", + }), + select: expect.not.objectContaining({ rawPayload: true }), + }), + ); + }); + + it("re-checks the original provider without allocating money or changing the order", async () => { + mockPrisma.paymentAmountException.findUnique.mockResolvedValue(exception); + mockPrisma.auditLog.findMany.mockResolvedValue([]); + mockPaymentService.inspectPaymentAmountException.mockResolvedValue({ + exceptionId: "exception-1", + provider: "PAYSTACK", + status: "RECONCILIATION_REQUIRED", + providerStatus: "success", + observedAmountKobo: "259999", + matchesRecordedAmount: true, + }); + + await service.reconcilePaymentAmountException("exception-1", "admin-1"); + + expect( + mockPaymentService.inspectPaymentAmountException, + ).toHaveBeenCalledWith("exception-1"); + expect(mockPrisma.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: "RECONCILE_PAYMENT_AMOUNT_EXCEPTION", + targetType: "PaymentAmountException", + targetId: "exception-1", + metadata: expect.objectContaining({ outcome: "REQUIRES_REVIEW" }), + }), + }); + expect(mockPrisma.order.update).not.toHaveBeenCalled(); + }); + + it("requires a reason before moving an exception to manual review", async () => { + await expect( + service.movePaymentAmountExceptionToManualReview( + "exception-1", + "admin-1", + " ", + ), + ).rejects.toBeInstanceOf(BadRequestException); + expect( + mockPrisma.paymentAmountException.updateMany, + ).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/backend/src/domains/platform/admin/admin.service.ts b/apps/backend/src/domains/platform/admin/admin.service.ts new file mode 100644 index 00000000..fc2ed82c --- /dev/null +++ b/apps/backend/src/domains/platform/admin/admin.service.ts @@ -0,0 +1,7113 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + BadGatewayException, + Logger, + ConflictException, + Inject, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { InjectQueue } from "@nestjs/bullmq"; +import { Queue } from "bullmq"; +import { PrismaService } from "../../../prisma/prisma.service"; +import { + OrderStatus, + OrderDisputeStatus, + UserRole, + VerificationTier, + InventoryEventType, +} from "@twizrr/shared"; +import { + DeliveryMethod, + DeliveryStatus, + Prisma, + StoreTier, +} from "@prisma/client"; +import * as bcrypt from "bcrypt"; +import { RedisService } from "../../../redis/redis.service"; +import { AuditLogService } from "./audit-log.service"; +import { VerificationService } from "../../users/verification/verification.service"; +import { PAYOUT_QUEUE } from "../../../queue/queue.constants"; +import { NotificationTriggerService } from "../../social/notification/notification-trigger.service"; +import { DomainEventService } from "../domain-event/domain-event.service"; +import { CommerceOutboxService } from "../outbox/commerce-outbox.service"; +import { OrderService } from "../../orders/order/order.service"; +import { PayoutService } from "../../money/payout/payout.service"; +import { CategoryDemandInsightsService } from "../../commerce/search/category-demand-insights.service"; +import { SettlementExecutionService } from "../../money/settlement/settlement-execution.service"; +import { SettlementReconciliationService } from "../../money/settlement/settlement-reconciliation.service"; +import { PaymentService } from "../../money/payment/payment.service"; +import { PaymentAmountExceptionRefundService } from "../../money/refund/payment-amount-exception-refund.service"; +import { + serializeRequestSecurityContextForAudit, + type RequestSecurityContext, +} from "../../../common/security/request-security-context"; +import { + SHIPPING_PROVIDER, + ShippingAddress, + ShippingParcel, + ShippingProvider, + ShippingQuoteOption, +} from "../../orders/delivery/providers/shipping-provider.interface"; + +const DEFAULT_ADMIN_LIMIT = 20; +const DEFAULT_TIMELINE_LIMIT = 50; +const MAX_ADMIN_LIMIT = 100; + +const DASHBOARD_DAY_MS = 24 * 60 * 60 * 1000; +// Hard cap on a custom dashboard window so an HTTP call can never trigger an +// unbounded aggregate scan. +const DASHBOARD_MAX_RANGE_DAYS = 366; +const DASHBOARD_DEMAND_GAP_LIMIT = 5; +// A demand gap at/above this zero-result count is surfaced as an admin alert. +const DASHBOARD_HIGH_DEMAND_THRESHOLD = 10; + +// Order statuses that represent a captured, paid sale (used for GMV / fees). +const PAID_OR_BEYOND_STATUSES: OrderStatus[] = [ + OrderStatus.PAID, + OrderStatus.READY_FOR_PICKUP, + OrderStatus.PREPARING, + OrderStatus.DISPATCHED, + OrderStatus.IN_TRANSIT, + OrderStatus.DELIVERED, + OrderStatus.COMPLETED, + OrderStatus.DISPUTE, +]; +// Paid orders whose funds are still held in protected payment (not yet +// released to the store or refunded). +const ESCROW_HELD_STATUSES: OrderStatus[] = [ + OrderStatus.PAID, + OrderStatus.READY_FOR_PICKUP, + OrderStatus.PREPARING, + OrderStatus.DISPATCHED, + OrderStatus.IN_TRANSIT, + OrderStatus.DELIVERED, +]; +// Paid orders still awaiting delivery completion. +const PENDING_DELIVERY_STATUSES: OrderStatus[] = [ + OrderStatus.PAID, + OrderStatus.READY_FOR_PICKUP, + OrderStatus.PREPARING, + OrderStatus.DISPATCHED, + OrderStatus.IN_TRANSIT, +]; + +export interface AdminListQuery { + page?: number; + limit?: number; + status?: string; + tier?: string; + range?: string; + payoutStatus?: string; + disputeStatus?: string; + deliveryStatus?: string; + q?: string; + startDate?: string; + endDate?: string; +} + +export interface AdminAuditLogQuery extends AdminListQuery { + action?: string; + targetType?: string; + targetId?: string; + userId?: string; +} + +export interface AdminSecurityEventQuery extends AdminListQuery { + eventType?: string; + signalCode?: string; + severity?: "low" | "medium" | "high"; + userId?: string; + ipAddress?: string; + deviceType?: string; + requestId?: string; + source?: string; +} + +export interface AdminSafetyInvestigationQuery extends AdminListQuery { + signalCode?: string; + severity?: "low" | "medium" | "high"; + userId?: string; + evidenceType?: + | "same_device" + | "same_ip" + | "suspended_user_device" + | "recent_creation"; +} + +export interface AdminSettlementQuery extends AdminListQuery { + outcome?: string; + legType?: string; + legStatus?: string; + disputeId?: string; + orderId?: string; + createdFrom?: string; + createdTo?: string; +} + +export interface AdminPaymentAmountExceptionQuery extends AdminListQuery { + exceptionType?: string; + provider?: string; + paymentId?: string; + orderId?: string; +} + +type SafetyInvestigationGroupRow = { + subjectUserId: string; + highestSeverity: "low" | "medium" | "high"; + signalCount: bigint; + latestSignalAt: Date; + signalCodes: string[]; + evidenceTypes: string[]; + total: bigint; +}; + +export interface AdminPagination { + page: number; + limit: number; + total: number; + hasMore: boolean; +} + +export interface AdminPaginatedResult { + items: T[]; + pagination: AdminPagination; +} + +export interface AdminTimelineEvent { + id: string; + aggregateType: string; + aggregateId: string; + eventType: string; + actorType: string; + actorId: string | null; + source: string; + metadata: Prisma.InputJsonValue; + correlationId: string | null; + createdAt: Date; +} + +interface AdminStoreRecord { + id: string; + userId?: string | null; + storeHandle?: string | null; + storeName?: string | null; + storeType?: string | null; + businessName?: string | null; + businessCategory?: string | null; + businessAddress?: string | null; + businessAddressDetails?: Prisma.JsonValue | null; + tier?: string | null; + verificationTier?: string | null; + isOpen?: boolean | null; + showOwnerPublicly?: boolean | null; + allowDropship?: boolean | null; + completedOrders?: number | null; + disputeCountLast6Months?: number | null; + addressVerified?: boolean | null; + bankVerified?: boolean | null; + ninVerified?: boolean | null; + ninVerifiedAt?: Date | null; + ninVerificationStatus?: string | null; + ninVerificationAttempts?: number | null; + ninVerificationLockedAt?: Date | null; + bankName?: string | null; + accountName?: string | null; + accountNumber?: string | null; + bankAccountNumber?: string | null; + createdAt?: Date | null; + updatedAt?: Date | null; + user?: { + id?: string; + email?: string; + phone?: string | null; + firstName?: string | null; + lastName?: string | null; + role?: string; + } | null; +} + +type AdminDomainEventType = + | "USER_STATUS_CHANGED" + | "USER_ROLE_CHANGED" + | "STORE_STATUS_CHANGED" + | "STORE_VERIFICATION_UPDATED" + | "DISPUTE_RESOLVED_BY_ADMIN" + | "PRODUCT_MODERATION_DECISION" + | "POST_MODERATION_DECISION" + | "DELIVERY_BOOKING_CREATED" + | "DELIVERY_BOOKING_PROVIDER_BOOKED" + | "PAYOUT_RELEASED_BY_ADMIN" + | "STORE_VERIFICATION_RECONCILED" + | "PAYMENT_AMOUNT_EXCEPTION_RECONCILED" + | "PAYMENT_AMOUNT_EXCEPTION_MANUAL_REVIEW" + | "PAYMENT_AMOUNT_EXCEPTION_REFUND_PLANNED"; + +export interface AdminShipbubbleBookingInput { + rateId: string; + requestToken: string; + serviceCode?: string; + weightKg?: number; + categoryId?: number; + itemName?: string; + itemValueKobo?: string; + lengthCm?: number; + widthCm?: number; + heightCm?: number; + description?: string; + quantity?: number; +} + +export interface AdminShipbubbleRateQuote { + provider: "Shipbubble"; + bookingId: string; + orderId: string; + orderCode: string; + requestToken: string | null; + rates: Array<{ + rateId: string; + courierName: string; + serviceCode: string | null; + amountKobo: string; + currency: string; + estimatedDeliveryDate: string | null; + estimatedDeliveryWindow: string | null; + }>; +} + +type AdminDeliveryBookingOrder = { + id: string; + orderCode: string; + status: string; + totalAmountKobo: bigint; + deliveryFeeKobo: bigint; + quantity: number | null; + deliveryPrimaryPhone: string | null; + deliverySecondaryPhone: string | null; + deliveryAddress: string | null; + deliveryAddressSnapshot: Prisma.JsonValue | null; + pickupAddressSnapshot: Prisma.JsonValue | null; + product: { + name: string; + title: string | null; + retailPriceKobo: bigint | null; + weightKg: number | null; + } | null; + sourcedProduct: { + customTitle: string | null; + sellingPriceKobo: bigint; + physicalProduct: { + name: string; + title: string | null; + retailPriceKobo: bigint | null; + weightKg: number | null; + }; + physicalStore: { + storeName: string | null; + businessName: string | null; + user: AdminDeliveryContactUser; + }; + } | null; + storeProfile: { + storeName: string | null; + businessName: string | null; + user: AdminDeliveryContactUser; + } | null; +}; + +type AdminDeliveryContactUser = { + email: string | null; + phone: string | null; + displayName: string | null; + firstName: string | null; + lastName: string | null; +}; + +// Order states in which twizrr's inspection gate can mark an item verified: a +// paid order somewhere on the fulfilment path. Mirrors the admin UI's +// VERIFIABLE_STATUSES; keep the two in sync. +// Typed as string so a Prisma `order.status` (a distinct nominal enum from the +// shared OrderStatus used to build the set) can be tested with .has(). +const VERIFIABLE_ORDER_STATUSES: ReadonlySet = new Set([ + OrderStatus.PAID, + OrderStatus.PREPARING, + OrderStatus.READY_FOR_PICKUP, + OrderStatus.DISPATCHED, + OrderStatus.IN_TRANSIT, + OrderStatus.DELIVERED, +]); + +@Injectable() +export class AdminService { + private readonly logger = new Logger(AdminService.name); + + constructor( + private prisma: PrismaService, + private redis: RedisService, + private notifications: NotificationTriggerService, + private auditLog: AuditLogService, + private verificationService: VerificationService, + @InjectQueue(PAYOUT_QUEUE) private payoutQueue: Queue, + private domainEvents: DomainEventService, + private commerceOutbox: CommerceOutboxService, + private orderService: OrderService, + private payoutService: PayoutService, + private categoryDemandInsights: CategoryDemandInsightsService, + @Inject(SHIPPING_PROVIDER) + private shippingProvider: ShippingProvider, + private configService: ConfigService, + private settlementExecution: SettlementExecutionService, + private settlementReconciliation: SettlementReconciliationService, + private paymentService: PaymentService, + private paymentAmountExceptionRefunds: PaymentAmountExceptionRefundService, + ) {} + + private async appendAdminAuditDomainEvent( + tx: Prisma.TransactionClient, + eventType: AdminDomainEventType, + adminId: string, + targetType: string, + targetId: string, + metadata: Record = {}, + idempotencyKey?: string, + ) { + await this.domainEvents.append(tx, { + aggregateType: targetType, + aggregateId: targetId, + eventType, + actorType: "ADMIN", + actorId: adminId, + source: "admin.service", + metadata: { + adminId, + targetType, + targetId, + ...metadata, + }, + idempotencyKey: idempotencyKey ?? null, + }); + } + + private requestContextForAudit(securityContext?: RequestSecurityContext) { + return securityContext + ? serializeRequestSecurityContextForAudit(securityContext) + : null; + } + + private normalizeListQuery(query: AdminListQuery): { + page: number; + limit: number; + skip: number; + } { + const page = Math.max(1, Math.trunc(query.page ?? 1)); + const limit = Math.min( + MAX_ADMIN_LIMIT, + Math.max(1, Math.trunc(query.limit ?? DEFAULT_ADMIN_LIMIT)), + ); + + return { + page, + limit, + skip: (page - 1) * limit, + }; + } + + private normalizeTimelineQuery(query: AdminListQuery): { + page: number; + limit: number; + skip: number; + } { + const page = Math.max(1, Math.trunc(query.page ?? 1)); + const limit = Math.min( + MAX_ADMIN_LIMIT, + Math.max(1, Math.trunc(query.limit ?? DEFAULT_TIMELINE_LIMIT)), + ); + + return { + page, + limit, + skip: (page - 1) * limit, + }; + } + + private buildPagination( + page: number, + limit: number, + total: number, + ): AdminPagination { + return { + page, + limit, + total, + hasMore: page * limit < total, + }; + } + + private dateRangeFilter( + query: AdminListQuery, + ): Prisma.DateTimeFilter | undefined { + if (!query.startDate && !query.endDate) { + return undefined; + } + + return { + ...(query.startDate ? { gte: new Date(query.startDate) } : {}), + ...(query.endDate ? { lte: new Date(query.endDate) } : {}), + }; + } + + private stringifyKobo( + value: bigint | number | null | undefined, + ): string | null { + if (value === null || value === undefined) { + return null; + } + return value.toString(); + } + + // ── Audit log console (read-only) ──────────────────────────────────────── + // AuditLog rows are historical and can carry arbitrary metadata written by + // many code paths. Everything returned here is sanitized at the DTO layer: + // sensitive keys are dropped, long strings capped, and depth/breadth bounded + // so a huge or risky historical blob can never reach the client. + + private static readonly AUDIT_SENSITIVE_KEY_FRAGMENTS = [ + "apikey", + "apisecret", + "accountnumber", + "authorization", + "authorizationcode", + "banksecret", + "biometric", + "bvn", + "card", + "cookie", + "cvv", + "cvc", + "deliveryotp", + "deviceid", + "deviceidhash", + "dropshipperprice", + "dropshipstoreid", + "embedding", + "nin", + "otp", + "pan", + "password", + "physicalproductid", + "physicalstoreid", + "pin", + "prompt", + "providerpayload", + "rawbody", + "rawpayload", + "rawproviderpayload", + "recipientcode", + "refreshtoken", + "secret", + "signature", + "token", + "webhooksecret", + ]; + + private static readonly AUDIT_MAX_STRING_LENGTH = 500; + private static readonly AUDIT_MAX_DEPTH = 4; + private static readonly AUDIT_MAX_ARRAY_ITEMS = 50; + private static readonly AUDIT_MAX_OBJECT_KEYS = 50; + + private isAuditSensitiveKey(key: string): boolean { + const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ""); + return AdminService.AUDIT_SENSITIVE_KEY_FRAGMENTS.some((fragment) => + normalized.includes(fragment), + ); + } + + // Read-side sanitizer. Never throws on malformed input (unlike the write-side + // DomainEvent sanitizer) — the worst case degrades to a safe placeholder. + private sanitizeAuditMetadata(value: unknown, depth = 0): unknown { + if (value === null || value === undefined) { + return null; + } + if (typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "boolean") { + return value; + } + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + if (typeof value === "string") { + return value.length > AdminService.AUDIT_MAX_STRING_LENGTH + ? `${value.slice(0, AdminService.AUDIT_MAX_STRING_LENGTH)}…` + : value; + } + if (value instanceof Date) { + return value.toISOString(); + } + if (depth >= AdminService.AUDIT_MAX_DEPTH) { + return "[truncated]"; + } + if (Array.isArray(value)) { + const capped = value + .slice(0, AdminService.AUDIT_MAX_ARRAY_ITEMS) + .map((item) => this.sanitizeAuditMetadata(item, depth + 1)); + if (value.length > AdminService.AUDIT_MAX_ARRAY_ITEMS) { + capped.push( + `[+${value.length - AdminService.AUDIT_MAX_ARRAY_ITEMS} more]`, + ); + } + return capped; + } + if (typeof value === "object") { + const record = value as Record; + const result: Record = {}; + let count = 0; + for (const [key, childValue] of Object.entries(record)) { + if (count >= AdminService.AUDIT_MAX_OBJECT_KEYS) { + result["…"] = "[truncated]"; + break; + } + if (this.isAuditSensitiveKey(key)) { + result[key] = "[redacted]"; + count += 1; + continue; + } + result[key] = this.sanitizeAuditMetadata(childValue, depth + 1); + count += 1; + } + return result; + } + return String(value); + } + + private mapAuditActor( + user: { + id: string; + email: string | null; + firstName: string | null; + lastName: string | null; + role: string; + } | null, + ) { + if (!user) { + return null; + } + return { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + }; + } + + private auditWhere(query: AdminAuditLogQuery): Prisma.AuditLogWhereInput { + const createdAt = this.dateRangeFilter(query); + const q = query.q?.trim(); + return { + ...(query.action ? { action: query.action } : {}), + ...(query.targetType ? { targetType: query.targetType } : {}), + ...(query.targetId ? { targetId: query.targetId } : {}), + ...(query.userId ? { userId: query.userId } : {}), + ...(createdAt ? { createdAt } : {}), + ...(q + ? { + OR: [ + { action: { contains: q, mode: "insensitive" } }, + { targetType: { contains: q, mode: "insensitive" } }, + { targetId: { contains: q, mode: "insensitive" } }, + { + user: { + email: { contains: q, mode: "insensitive" }, + }, + }, + ], + } + : {}), + }; + } + + async getAuditLogs( + query: AdminAuditLogQuery = {}, + ): Promise> { + const { page, limit, skip } = this.normalizeListQuery(query); + const where = this.auditWhere(query); + + const [logs, total] = await Promise.all([ + this.prisma.auditLog.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: "desc" }, + select: { + id: true, + action: true, + targetType: true, + targetId: true, + metadata: true, + createdAt: true, + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + }, + }, + }, + }), + this.prisma.auditLog.count({ where }), + ]); + + const items = logs.map((log) => ({ + id: log.id, + action: log.action, + targetType: log.targetType, + targetId: log.targetId, + createdAt: log.createdAt, + actor: this.mapAuditActor(log.user), + metadata: this.sanitizeAuditMetadata(log.metadata), + })); + + return { items, pagination: this.buildPagination(page, limit, total) }; + } + + async getAuditLogById(auditLogId: string) { + const log = await this.prisma.auditLog.findUnique({ + where: { id: auditLogId }, + select: { + id: true, + action: true, + targetType: true, + targetId: true, + metadata: true, + createdAt: true, + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + }, + }, + }, + }); + + if (!log) { + throw new NotFoundException("Audit log not found"); + } + + return { + id: log.id, + action: log.action, + targetType: log.targetType, + targetId: log.targetId, + createdAt: log.createdAt, + actor: this.mapAuditActor(log.user), + metadata: this.sanitizeAuditMetadata(log.metadata), + }; + } + + // Distinct action / targetType values for the console filter dropdowns. + // Bounded so a pathological data set can't return an unbounded facet list. + async getAuditLogFacets(): Promise<{ + actions: string[]; + targetTypes: string[]; + }> { + const [actions, targetTypes] = await Promise.all([ + this.prisma.auditLog.findMany({ + distinct: ["action"], + select: { action: true }, + orderBy: { action: "asc" }, + take: 200, + }), + this.prisma.auditLog.findMany({ + distinct: ["targetType"], + select: { targetType: true }, + orderBy: { targetType: "asc" }, + take: 100, + }), + ]); + + return { + actions: actions.map((a) => a.action), + targetTypes: targetTypes.map((t) => t.targetType), + }; + } + + async getSecurityEvents(query: AdminSecurityEventQuery = {}) { + const { page, limit, skip } = this.normalizeListQuery(query); + const now = new Date(); + const createdAt = this.dateRangeFilter({ + ...query, + startDate: + query.startDate ?? + new Date(now.getTime() - 7 * DASHBOARD_DAY_MS).toISOString(), + }); + const where: Prisma.DomainEventWhereInput = { + ...(createdAt ? { createdAt } : {}), + ...(query.eventType ? { eventType: query.eventType } : {}), + ...(query.userId ? { actorId: query.userId } : {}), + ...(query.source ? { source: { startsWith: query.source } } : {}), + // Security review is auth/risk first. Operational domain events are + // included only when an explicit source filter requests them. + ...(query.source ? {} : { aggregateType: "AUTH" }), + AND: [ + ...(query.signalCode + ? [{ metadata: { path: ["signalCode"], equals: query.signalCode } }] + : []), + ...(query.severity + ? [{ metadata: { path: ["severity"], equals: query.severity } }] + : []), + ...(query.ipAddress + ? [{ metadata: { path: ["ipAddress"], equals: query.ipAddress } }] + : []), + ...(query.deviceType + ? [{ metadata: { path: ["deviceType"], equals: query.deviceType } }] + : []), + ...(query.requestId ? [{ correlationId: query.requestId }] : []), + ], + }; + const [events, total] = await Promise.all([ + this.prisma.domainEvent.findMany({ + where, + select: { + id: true, + eventType: true, + actorId: true, + source: true, + metadata: true, + correlationId: true, + createdAt: true, + }, + orderBy: { createdAt: "desc" }, + skip, + take: limit, + }), + this.prisma.domainEvent.count({ where }), + ]); + const items = events.map((event) => { + const metadata = this.sanitizeAuditMetadata(event.metadata) as Record< + string, + unknown + >; + const request = (metadata.requestContext ?? metadata) as Record< + string, + unknown + >; + const geo = + request.geo && + typeof request.geo === "object" && + !Array.isArray(request.geo) + ? (request.geo as Record) + : null; + return { + id: event.id, + eventType: event.eventType, + signalCode: + typeof metadata.signalCode === "string" ? metadata.signalCode : null, + severity: + typeof metadata.severity === "string" ? metadata.severity : null, + userId: event.actorId, + userSummary: event.actorId + ? { id: event.actorId, email: null, role: null } + : null, + requestContext: { + requestId: + typeof request.requestId === "string" + ? request.requestId + : event.correlationId, + ipAddress: + typeof request.ipAddress === "string" ? request.ipAddress : null, + ipSource: + typeof request.ipSource === "string" ? request.ipSource : null, + userAgentSummary: + typeof request.userAgent === "string" + ? request.userAgent.slice(0, 120) + : null, + deviceType: + typeof request.deviceType === "string" ? request.deviceType : null, + origin: typeof request.origin === "string" ? request.origin : null, + referer: typeof request.referer === "string" ? request.referer : null, + method: typeof request.method === "string" ? request.method : null, + path: typeof request.path === "string" ? request.path : null, + geo: geo + ? { + countryCode: + typeof geo.countryCode === "string" ? geo.countryCode : null, + countryName: + typeof geo.countryName === "string" ? geo.countryName : null, + region: typeof geo.region === "string" ? geo.region : null, + city: typeof geo.city === "string" ? geo.city : null, + source: typeof geo.source === "string" ? geo.source : null, + confidence: + typeof geo.confidence === "string" ? geo.confidence : null, + } + : null, + }, + metadataSummary: metadata, + createdAt: event.createdAt, + }; + }); + return { + items, + pagination: this.buildPagination(page, limit, total), + }; + } + + async getSafetyInvestigations(query: AdminSafetyInvestigationQuery = {}) { + const { page, limit, skip } = this.normalizeListQuery(query); + const now = new Date(); + const createdAt = this.dateRangeFilter({ + ...query, + startDate: + query.startDate ?? + new Date(now.getTime() - 7 * DASHBOARD_DAY_MS).toISOString(), + }); + const clauses: Prisma.Sql[] = [ + Prisma.sql`e.aggregate_type = 'AUTH'`, + Prisma.sql`e.event_type = 'AUTH_RISK_SIGNAL_RECORDED'`, + Prisma.sql`e.source = 'auth.account-abuse-linkage'`, + ]; + if (createdAt?.gte) + clauses.push(Prisma.sql`e.created_at >= ${createdAt.gte}`); + if (createdAt?.lte) + clauses.push(Prisma.sql`e.created_at <= ${createdAt.lte}`); + if (query.userId) clauses.push(Prisma.sql`e.actor_id = ${query.userId}`); + if (query.signalCode) { + clauses.push(Prisma.sql`e.metadata->>'signalCode' = ${query.signalCode}`); + } + if (query.severity) { + clauses.push(Prisma.sql`e.metadata->>'severity' = ${query.severity}`); + } + if (query.status === "active") { + clauses.push(Prisma.sql`u.is_active = true AND u.deleted_at IS NULL`); + } + if (query.status === "suspended") { + clauses.push(Prisma.sql`u.is_active = false`); + } + if (query.status === "deactivated") { + clauses.push(Prisma.sql`u.deleted_at IS NOT NULL`); + } + if (query.evidenceType) { + clauses.push(Prisma.sql`EXISTS ( + SELECT 1 FROM jsonb_array_elements(COALESCE(e.metadata->'evidence', '[]'::jsonb)) evidence + WHERE evidence->>'type' = ${query.evidenceType} + )`); + } + const where = Prisma.join(clauses, " AND "); + const groups = await this.prisma.$queryRaw( + Prisma.sql` + WITH grouped AS ( + SELECT + e.actor_id AS "subjectUserId", + CASE MAX(CASE e.metadata->>'severity' + WHEN 'high' THEN 3 WHEN 'medium' THEN 2 ELSE 1 END) + WHEN 3 THEN 'high' WHEN 2 THEN 'medium' ELSE 'low' END AS "highestSeverity", + COUNT(DISTINCT e.id) AS "signalCount", + MAX(e.created_at) AS "latestSignalAt", + ARRAY_REMOVE(ARRAY_AGG(DISTINCT e.metadata->>'signalCode'), NULL) AS "signalCodes", + ARRAY_REMOVE(ARRAY_AGG(DISTINCT evidence->>'type'), NULL) AS "evidenceTypes" + FROM domain_events e + JOIN users u ON u.id = e.actor_id + LEFT JOIN LATERAL jsonb_array_elements(COALESCE(e.metadata->'evidence', '[]'::jsonb)) evidence ON true + WHERE ${where} + GROUP BY e.actor_id + ) + SELECT grouped.*, COUNT(*) OVER() AS total + FROM grouped + ORDER BY "latestSignalAt" DESC + LIMIT ${limit} OFFSET ${skip} + `, + ); + const userIds = groups.map((group) => group.subjectUserId); + const users = userIds.length + ? await this.prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { + id: true, + email: true, + role: true, + isActive: true, + createdAt: true, + deletedAt: true, + }, + }) + : []; + const usersById = new Map(users.map((user) => [user.id, user])); + const total = groups.length ? Number(groups[0].total) : 0; + return { + items: groups.flatMap((group) => { + const user = usersById.get(group.subjectUserId); + if (!user) return []; + return [ + { + subjectUserId: group.subjectUserId, + userSummary: { + id: user.id, + email: user.email, + role: user.role, + isActive: user.isActive, + status: user.deletedAt + ? "deactivated" + : user.isActive + ? "active" + : "suspended", + createdAt: user.createdAt, + }, + highestSeverity: group.highestSeverity, + signalCount: Number(group.signalCount), + latestSignalAt: group.latestSignalAt, + signalCodes: group.signalCodes, + evidenceTypes: group.evidenceTypes, + }, + ]; + }), + pagination: this.buildPagination(page, limit, total), + }; + } + + async getSafetyInvestigation(userId: string) { + const subject = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + isActive: true, + createdAt: true, + deletedAt: true, + }, + }); + if (!subject) throw new NotFoundException("User not found"); + + const events = await this.prisma.domainEvent.findMany({ + where: { + aggregateType: "AUTH", + actorId: userId, + eventType: { + in: [ + "AUTH_ACCOUNT_CREATED", + "AUTH_LOGIN_SUCCEEDED", + "AUTH_RISK_SIGNAL_RECORDED", + ], + }, + source: { + in: [ + "auth.registration", + "auth.login", + "auth.risk-signal", + "auth.account-abuse-linkage", + ], + }, + }, + select: { + id: true, + eventType: true, + source: true, + metadata: true, + correlationId: true, + createdAt: true, + }, + orderBy: { createdAt: "desc" }, + take: DEFAULT_TIMELINE_LIMIT, + }); + const safeEvents = events.map((event) => ({ + id: event.id, + eventType: event.eventType, + source: event.source, + requestId: event.correlationId, + createdAt: event.createdAt, + metadata: this.sanitizeAuditMetadata(event.metadata), + })); + const linkageEvents = safeEvents.filter( + (event) => event.source === "auth.account-abuse-linkage", + ); + const relatedEvidence = linkageEvents.flatMap((event) => + this.relatedEvidence(event.metadata), + ); + const relatedUserIds = [ + ...new Set(relatedEvidence.map((evidence) => evidence.userId)), + ].filter((relatedUserId) => relatedUserId !== userId); + const relatedUsers = relatedUserIds.length + ? await this.prisma.user.findMany({ + where: { id: { in: relatedUserIds } }, + select: { + id: true, + email: true, + role: true, + isActive: true, + createdAt: true, + deletedAt: true, + }, + }) + : []; + const relatedById = new Map(relatedUsers.map((user) => [user.id, user])); + + const devices = await this.prisma.userDevice.findMany({ + where: { userId }, + select: { + id: true, + deviceIdHash: true, + firstSeenAt: true, + lastSeenAt: true, + deviceType: true, + }, + take: 25, + }); + const deviceHashes = devices.map((device) => device.deviceIdHash); + const linkedDevices = deviceHashes.length + ? await this.prisma.userDevice.findMany({ + where: { deviceIdHash: { in: deviceHashes } }, + select: { + deviceIdHash: true, + user: { select: { id: true, isActive: true, deletedAt: true } }, + }, + take: 100, + }) + : []; + const deviceEvidence = devices.map((device) => { + const linked = linkedDevices.filter( + (linkedDevice) => + linkedDevice.deviceIdHash === device.deviceIdHash && + linkedDevice.user, + ); + return { + deviceRecordId: device.id, + firstSeenAt: device.firstSeenAt, + lastSeenAt: device.lastSeenAt, + deviceType: device.deviceType, + relatedAccountCount: new Set( + linked.map((linkedDevice) => linkedDevice.user?.id), + ).size, + suspendedLinkedUserCount: linked.filter( + (linkedDevice) => + linkedDevice.user && + (!linkedDevice.user.isActive || + linkedDevice.user.deletedAt !== null), + ).length, + }; + }); + const accountCreated = safeEvents.find( + (event) => event.eventType === "AUTH_ACCOUNT_CREATED", + ); + return { + subject: { + id: subject.id, + email: subject.email, + name: + [subject.firstName, subject.lastName].filter(Boolean).join(" ") || + null, + role: subject.role, + isActive: subject.isActive, + status: subject.deletedAt + ? "deactivated" + : subject.isActive + ? "active" + : "suspended", + createdAt: subject.createdAt, + }, + accountCreationContext: accountCreated + ? this.safeRequestContext(accountCreated.metadata) + : null, + signals: linkageEvents, + deviceEvidence, + relatedAccounts: relatedEvidence.flatMap((evidence) => { + const user = relatedById.get(evidence.userId); + if (!user) return []; + return [ + { + userId: user.id, + email: user.email, + role: user.role, + status: user.deletedAt + ? "deactivated" + : user.isActive + ? "active" + : "suspended", + createdAt: user.createdAt, + relationshipType: evidence.type, + firstLinkedAt: evidence.createdAt, + }, + ]; + }), + timeline: safeEvents, + }; + } + + private relatedEvidence( + metadata: unknown, + ): Array<{ userId: string; type: string; createdAt: string | null }> { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) + return []; + const evidence = (metadata as Record).evidence; + if (!Array.isArray(evidence)) return []; + return evidence.flatMap((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return []; + const record = item as Record; + return typeof record.userId === "string" && + typeof record.type === "string" + ? [ + { + userId: record.userId, + type: record.type, + createdAt: + typeof record.createdAt === "string" ? record.createdAt : null, + }, + ] + : []; + }); + } + + private safeRequestContext(metadata: unknown) { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) + return null; + const request = (metadata as Record).requestContext; + if (!request || typeof request !== "object" || Array.isArray(request)) + return null; + const record = request as Record; + return { + requestId: typeof record.requestId === "string" ? record.requestId : null, + ipAddress: typeof record.ipAddress === "string" ? record.ipAddress : null, + ipSource: typeof record.ipSource === "string" ? record.ipSource : null, + deviceType: + typeof record.deviceType === "string" ? record.deviceType : null, + path: typeof record.path === "string" ? record.path : null, + geo: + record.geo && + typeof record.geo === "object" && + !Array.isArray(record.geo) + ? this.sanitizeAuditMetadata(record.geo) + : null, + }; + } + + // Resolves the dashboard time window. A custom start/end window takes + // precedence (clamped to DASHBOARD_MAX_RANGE_DAYS); otherwise a preset is + // used, defaulting to 7d. Malformed input degrades safely to 7d. + private resolveDashboardRange(query: AdminListQuery): { + from: Date; + to: Date; + preset: "today" | "7d" | "30d" | "custom"; + } { + const now = new Date(); + const default7d = () => ({ + from: new Date(now.getTime() - 7 * DASHBOARD_DAY_MS), + to: now, + preset: "7d" as const, + }); + + if (query.startDate || query.endDate) { + const to = query.endDate ? new Date(query.endDate) : now; + let from = query.startDate + ? new Date(query.startDate) + : new Date(to.getTime() - 7 * DASHBOARD_DAY_MS); + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) { + return default7d(); + } + const maxFrom = new Date( + to.getTime() - DASHBOARD_MAX_RANGE_DAYS * DASHBOARD_DAY_MS, + ); + if (from < maxFrom) { + from = maxFrom; + } + return { from, to, preset: "custom" }; + } + + if (query.range === "today") { + const from = new Date(now); + from.setUTCHours(0, 0, 0, 0); + return { from, to: now, preset: "today" }; + } + if (query.range === "30d") { + return { + from: new Date(now.getTime() - 30 * DASHBOARD_DAY_MS), + to: now, + preset: "30d", + }; + } + return default7d(); + } + + // Derives read-only admin-attention alerts from the operations snapshot. + // targetHref is only set for admin consoles that actually exist. + private buildDashboardAlerts( + operations: { + verificationPending: number; + moderationPending: number; + disputesOpen: number; + payoutsFailed: number; + deliveriesReadyForPickup: number; + deliveriesFailedBooking: number; + }, + topDemandGaps: Array<{ + parentCategoryLabel: string | null; + subcategoryLabel: string | null; + zeroResultCount: number; + }>, + ): Array<{ + id: string; + severity: "info" | "warning" | "critical"; + title: string; + description: string; + targetHref?: string; + }> { + const alerts: Array<{ + id: string; + severity: "info" | "warning" | "critical"; + title: string; + description: string; + targetHref?: string; + }> = []; + + if (operations.payoutsFailed > 0) { + alerts.push({ + id: "payouts-failed", + severity: "critical", + title: "Failed payouts", + description: `${operations.payoutsFailed} payout(s) have failed and need attention.`, + }); + } + if (operations.deliveriesFailedBooking > 0) { + alerts.push({ + id: "deliveries-failed", + severity: "critical", + title: "Failed delivery bookings", + description: `${operations.deliveriesFailedBooking} delivery booking(s) failed.`, + targetHref: "/admin/deliveries", + }); + } + if (operations.disputesOpen > 0) { + alerts.push({ + id: "disputes-open", + severity: "warning", + title: "Open disputes", + description: `${operations.disputesOpen} dispute(s) awaiting resolution.`, + targetHref: "/admin/disputes", + }); + } + if (operations.verificationPending > 0) { + alerts.push({ + id: "verification-pending", + severity: "warning", + title: "Pending verifications", + description: `${operations.verificationPending} store verification request(s) awaiting review.`, + targetHref: "/admin/verification", + }); + } + if (operations.moderationPending > 0) { + // Only fires for genuine appeals against an automated decision — not for + // routine auto-blocked/sensitive uploads, which need no human action. + alerts.push({ + id: "moderation-appeals", + severity: "warning", + title: "Moderation appeals", + description: `${operations.moderationPending} moderation appeal(s) awaiting review.`, + targetHref: "/admin/moderation", + }); + } + const topGap = topDemandGaps[0]; + if (topGap && topGap.zeroResultCount >= DASHBOARD_HIGH_DEMAND_THRESHOLD) { + const label = + topGap.subcategoryLabel ?? topGap.parentCategoryLabel ?? "a category"; + alerts.push({ + id: "demand-gap", + severity: "info", + title: "High unmet search demand", + description: `${topGap.zeroResultCount} zero-result searches for ${label} in this range.`, + }); + } + + return alerts; + } + + private readJsonString( + value: Prisma.JsonValue | null | undefined, + key: string, + ): string | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + const raw = value[key]; + return typeof raw === "string" && raw.trim() ? raw.trim() : null; + } + + private readJsonNumber( + value: Prisma.JsonValue | null | undefined, + key: string, + ): number | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + const raw = value[key]; + if (typeof raw === "number" && Number.isFinite(raw)) { + return raw; + } + if (typeof raw === "string" && raw.trim()) { + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : null; + } + return null; + } + + private formatAddressSnapshot( + value: Prisma.JsonValue | null | undefined, + fallback?: string | null, + ): string | null { + return ( + this.readJsonString(value, "formattedAddress") ?? + this.readJsonString(value, "street") ?? + this.readJsonString(value, "address") ?? + fallback ?? + null + ); + } + + private formatContactName( + user: AdminDeliveryContactUser | null | undefined, + fallback?: string | null, + ): string { + const fullName = [user?.firstName, user?.lastName] + .filter(Boolean) + .join(" ") + .trim(); + return user?.displayName || fullName || fallback || "twizrr delivery"; + } + + private parseOptionalKobo(value: string | undefined): bigint | undefined { + if (!value?.trim()) { + return undefined; + } + if (!/^\d+$/.test(value.trim())) { + throw new BadRequestException({ + message: "Item value must be provided in kobo.", + code: "INVALID_ITEM_VALUE_KOBO", + }); + } + return BigInt(value.trim()); + } + + private toOptionalDate(value: string | undefined): Date | null { + if (!value) { + return null; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + + private buildShipbubblePickupAddress( + order: AdminDeliveryBookingOrder, + ): ShippingAddress { + const snapshot = order.pickupAddressSnapshot; + const fallbackStore = + order.sourcedProduct?.physicalStore ?? order.storeProfile ?? null; + const fallbackUser = fallbackStore?.user ?? null; + const address = this.formatAddressSnapshot(snapshot); + const phone = this.readJsonString(snapshot, "phone") ?? fallbackUser?.phone; + + if (!address || !phone) { + throw new BadRequestException({ + message: + "Order is missing pickup address or pickup contact required for Shipbubble booking.", + code: "SHIPBUBBLE_PICKUP_SNAPSHOT_INCOMPLETE", + }); + } + + return { + name: + this.readJsonString(snapshot, "name") ?? + this.readJsonString(snapshot, "storeName") ?? + this.formatContactName( + fallbackUser, + fallbackStore?.storeName ?? fallbackStore?.businessName, + ), + email: fallbackUser?.email ?? undefined, + phone, + address, + city: this.readJsonString(snapshot, "city") ?? "Lagos", + state: this.readJsonString(snapshot, "state") ?? "Lagos", + countryCode: this.readJsonString(snapshot, "country") ?? "NG", + postalCode: this.readJsonString(snapshot, "postalCode") ?? undefined, + }; + } + + private buildShipbubbleDeliveryAddress( + order: AdminDeliveryBookingOrder, + ): ShippingAddress { + const snapshot = order.deliveryAddressSnapshot; + const address = this.formatAddressSnapshot(snapshot, order.deliveryAddress); + const phone = + order.deliveryPrimaryPhone ?? + this.readJsonString(snapshot, "primaryPhone") ?? + this.readJsonString(snapshot, "phone"); + + if (!address || !phone) { + throw new BadRequestException({ + message: + "Order is missing delivery address or primary delivery contact required for Shipbubble booking.", + code: "SHIPBUBBLE_DELIVERY_SNAPSHOT_INCOMPLETE", + }); + } + + return { + name: + this.readJsonString(snapshot, "recipientName") ?? + this.readJsonString(snapshot, "name") ?? + "Shopper", + phone, + address, + city: this.readJsonString(snapshot, "city") ?? "Lagos", + state: this.readJsonString(snapshot, "state") ?? "Lagos", + countryCode: this.readJsonString(snapshot, "country") ?? "NG", + postalCode: this.readJsonString(snapshot, "postalCode") ?? undefined, + }; + } + + private buildShipbubbleParcel( + order: AdminDeliveryBookingOrder, + input: Partial, + ): ShippingParcel { + const product = order.sourcedProduct?.physicalProduct ?? order.product; + const itemValueKobo = + this.parseOptionalKobo(input.itemValueKobo) ?? + order.sourcedProduct?.sellingPriceKobo ?? + product?.retailPriceKobo ?? + order.totalAmountKobo; + + return { + weightKg: + input.weightKg ?? + product?.weightKg ?? + this.readJsonNumber(order.pickupAddressSnapshot, "weightKg") ?? + this.configService.get("shipbubble.defaultPackageWeightKg") ?? + 1, + categoryId: + input.categoryId ?? + this.configService.get("shipbubble.defaultCategoryId") ?? + 1, + itemName: + input.itemName ?? + order.sourcedProduct?.customTitle ?? + product?.title ?? + product?.name ?? + `Order ${order.orderCode}`, + itemValueKobo, + lengthCm: input.lengthCm, + widthCm: input.widthCm, + heightCm: input.heightCm, + description: input.description ?? `Order ${order.orderCode}`, + quantity: input.quantity ?? order.quantity ?? 1, + }; + } + + private timelineAggregateTypes(aggregateType: string): string[] { + const normalized = aggregateType.trim().toLowerCase(); + if (normalized === "order" || normalized === "orders") { + return ["ORDER", "Order"]; + } + if (normalized === "payment" || normalized === "payments") { + return ["PAYMENT", "Payment"]; + } + if (normalized === "payout" || normalized === "payouts") { + return ["PAYOUT", "Payout"]; + } + if (normalized === "dispute" || normalized === "disputes") { + return ["DISPUTE", "Dispute"]; + } + if (normalized === "store" || normalized === "stores") { + return ["STORE", "Store", "StoreProfile"]; + } + if (normalized === "delivery" || normalized === "deliveries") { + return ["DELIVERY", "Delivery", "DeliveryBooking"]; + } + if (normalized === "post" || normalized === "posts") { + return ["POST", "Post"]; + } + if (normalized === "product" || normalized === "products") { + return ["PRODUCT", "Product"]; + } + + return [aggregateType]; + } + + private mapDomainEventTimelineItem(event: { + id: string; + aggregateType: string; + aggregateId: string; + eventType: string; + actorType: string; + actorId: string | null; + source: string; + metadata: Prisma.JsonValue; + correlationId: string | null; + createdAt: Date; + }): AdminTimelineEvent { + return { + id: event.id, + aggregateType: event.aggregateType, + aggregateId: event.aggregateId, + eventType: event.eventType, + actorType: event.actorType, + actorId: event.actorId, + source: event.source, + metadata: this.domainEvents.sanitizeMetadata(event.metadata), + correlationId: event.correlationId, + createdAt: event.createdAt, + }; + } + + async getDomainEventTimeline( + aggregateType: string, + aggregateId: string, + query: AdminListQuery = {}, + ): Promise> { + const { page, limit, skip } = this.normalizeTimelineQuery(query); + const aggregateTypes = this.timelineAggregateTypes(aggregateType); + const where: Prisma.DomainEventWhereInput = { + aggregateId, + aggregateType: + aggregateTypes.length === 1 + ? aggregateTypes[0] + : { in: aggregateTypes }, + }; + + const [items, total] = await Promise.all([ + this.prisma.domainEvent.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: "asc" }, + select: { + id: true, + aggregateType: true, + aggregateId: true, + eventType: true, + actorType: true, + actorId: true, + source: true, + metadata: true, + correlationId: true, + createdAt: true, + }, + }), + this.prisma.domainEvent.count({ where }), + ]); + + return { + items: items.map((event) => this.mapDomainEventTimelineItem(event)), + pagination: this.buildPagination(page, limit, total), + }; + } + + getOrderTimeline(orderId: string, query: AdminListQuery = {}) { + return this.getDomainEventTimeline("order", orderId, query); + } + + getPaymentTimeline(paymentId: string, query: AdminListQuery = {}) { + return this.getDomainEventTimeline("payment", paymentId, query); + } + + getPayoutTimeline(payoutId: string, query: AdminListQuery = {}) { + return this.getDomainEventTimeline("payout", payoutId, query); + } + + getDisputeTimeline(disputeId: string, query: AdminListQuery = {}) { + return this.getDomainEventTimeline("dispute", disputeId, query); + } + + getDeliveryTimeline(deliveryId: string, query: AdminListQuery = {}) { + return this.getDomainEventTimeline("delivery", deliveryId, query); + } + + getStoreTimeline(storeId: string, query: AdminListQuery = {}) { + return this.getDomainEventTimeline("store", storeId, query); + } + + private lastFour(value: string | null | undefined): string | null { + if (!value) { + return null; + } + return value.slice(-4); + } + + private mapStoreForAdmin(store: AdminStoreRecord) { + return { + id: store.id, + userId: store.userId, + storeHandle: store.storeHandle, + storeName: store.storeName, + storeType: store.storeType, + businessName: store.businessName, + businessCategory: store.businessCategory, + businessAddress: store.businessAddress, + businessAddressDetails: store.businessAddressDetails, + tier: store.tier, + verificationTier: store.verificationTier, + isOpen: store.isOpen, + showOwnerPublicly: store.showOwnerPublicly, + allowDropship: store.allowDropship, + completedOrders: store.completedOrders, + disputeCountLast6Months: store.disputeCountLast6Months, + addressVerified: store.addressVerified, + bankVerified: store.bankVerified, + ninVerified: store.ninVerified, + ninVerifiedAt: store.ninVerifiedAt, + ninVerificationStatus: store.ninVerificationStatus, + ninVerificationAttempts: store.ninVerificationAttempts, + ninVerificationLockedAt: store.ninVerificationLockedAt, + bankAccount: { + bankName: store.bankName, + accountName: store.accountName, + accountNumberLast4: this.lastFour(store.accountNumber), + bankAccountNumberLast4: this.lastFour(store.bankAccountNumber), + }, + owner: store.user, + createdAt: store.createdAt, + updatedAt: store.updatedAt, + }; + } + + private buildStoreSelect() { + return { + id: true, + userId: true, + storeHandle: true, + storeName: true, + storeType: true, + businessName: true, + businessCategory: true, + businessAddress: true, + businessAddressDetails: true, + tier: true, + verificationTier: true, + isOpen: true, + showOwnerPublicly: true, + allowDropship: true, + completedOrders: true, + disputeCountLast6Months: true, + addressVerified: true, + bankVerified: true, + ninVerified: true, + ninVerifiedAt: true, + ninVerificationStatus: true, + ninVerificationAttempts: true, + ninVerificationLockedAt: true, + bankName: true, + accountName: true, + accountNumber: true, + bankAccountNumber: true, + createdAt: true, + updatedAt: true, + user: { + select: { + id: true, + email: true, + phone: true, + firstName: true, + lastName: true, + role: true, + }, + }, + } satisfies Prisma.StoreProfileSelect; + } + + async getPlatformStats() { + // Parallelize these independent global queries for speed + const [ + totalMerchants, + verifiedMerchants, + pendingVerificationCount, + totalBuyers, + totalOrders, + totalRevenueKobo, + ] = await Promise.all([ + this.prisma.storeProfile.count(), + this.prisma.storeProfile.count({ + where: { + verificationTier: { + in: [VerificationTier.TIER_2], + }, + }, + }), + this.prisma.storeProfile.count({ + where: { + verificationRequests: { some: { status: "PENDING" } }, + }, + }), + this.prisma.user.count({ where: { role: UserRole.USER } }), + this.prisma.order.count(), + this.prisma.payment.aggregate({ + _sum: { amountKobo: true }, + where: { status: "SUCCESS", direction: "INFLOW" }, + }), + ]); + + return { + totalMerchants, + verifiedMerchants, + pendingMerchants: pendingVerificationCount, + totalBuyers, + totalUsers: totalMerchants + totalBuyers, + totalOrders, + totalRevenueKobo: totalRevenueKobo._sum.amountKobo || BigInt(0), + }; + } + + // Consolidated SUPER_ADMIN platform overview. Aggregate-only: every field is + // a count or a summed kobo total — no raw PII, provider payloads, identity + // data, secrets, or internal dropship/source-store IDs. Money is returned as + // kobo strings (formatted to Naira in the frontend). + async getDashboard(query: AdminListQuery = {}) { + const { from, to, preset } = this.resolveDashboardRange(query); + const inRange: Prisma.DateTimeFilter = { gte: from, lte: to }; + + const [ + usersTotal, + usersNew, + storesTotal, + storesOpen, + storesVerified, + productsTotal, + productsActive, + productsPublic, + ordersTotal, + ordersPaid, + ordersCompleted, + ordersDisputed, + ordersPendingDelivery, + gmvAgg, + feesAgg, + escrowAgg, + payoutsPendingAgg, + payoutsFailedAgg, + verificationPending, + moderationPending, + disputesOpen, + payoutsFailedCount, + deliveriesReadyForPickup, + deliveriesFailedBooking, + searchesInRange, + zeroResultCount, + recentActivity, + ] = await Promise.all([ + this.prisma.user.count({ where: { deletedAt: null } }), + this.prisma.user.count({ + where: { deletedAt: null, createdAt: inRange }, + }), + this.prisma.storeProfile.count(), + this.prisma.storeProfile.count({ where: { isOpen: true } }), + this.prisma.storeProfile.count({ + where: { verificationTier: VerificationTier.TIER_2 }, + }), + this.prisma.product.count({ where: { deletedAt: null } }), + this.prisma.product.count({ + where: { deletedAt: null, isActive: true, status: "ACTIVE" }, + }), + this.prisma.product.count({ + where: { + deletedAt: null, + status: "ACTIVE", + moderationStatus: { not: "BLOCKED" }, + }, + }), + this.prisma.order.count({ where: { createdAt: inRange } }), + this.prisma.order.count({ + where: { createdAt: inRange, status: { in: PAID_OR_BEYOND_STATUSES } }, + }), + this.prisma.order.count({ + where: { createdAt: inRange, status: OrderStatus.COMPLETED }, + }), + this.prisma.order.count({ + where: { + createdAt: inRange, + disputeStatus: { not: OrderDisputeStatus.NONE }, + }, + }), + this.prisma.order.count({ + where: { status: { in: PENDING_DELIVERY_STATUSES } }, + }), + this.prisma.order.aggregate({ + _sum: { totalAmountKobo: true }, + where: { createdAt: inRange, status: { in: PAID_OR_BEYOND_STATUSES } }, + }), + this.prisma.order.aggregate({ + _sum: { platformFeeKobo: true }, + where: { createdAt: inRange, status: { in: PAID_OR_BEYOND_STATUSES } }, + }), + this.prisma.order.aggregate({ + _sum: { totalAmountKobo: true }, + where: { status: { in: ESCROW_HELD_STATUSES } }, + }), + this.prisma.payout.aggregate({ + _sum: { amountKobo: true }, + where: { status: { in: ["PENDING", "PROCESSING"] } }, + }), + this.prisma.payout.aggregate({ + _sum: { amountKobo: true }, + where: { status: "FAILED" }, + }), + this.prisma.verificationRequest.count({ where: { status: "PENDING" } }), + // Content moderation is fully automated at upload (Cloud Vision): BLOCKED + // uploads are rejected and never go live, SENSITIVE is auto-blurred, SAFE + // passes. None of those need a human. The only thing that genuinely needs + // admin review is an appeal against an automated decision, so this counts + // pending appeals only (0 until an appeals flow exists) — never the raw + // auto-moderation log, which would grow forever and imply work that isn't + // there. + this.prisma.moderationLog.count({ + where: { appealStatus: "PENDING" }, + }), + this.prisma.dispute.count({ + where: { status: { in: ["OPEN", "STORE_RESPONDED", "UNDER_REVIEW"] } }, + }), + this.prisma.payout.count({ where: { status: "FAILED" } }), + this.prisma.order.count({ + where: { status: OrderStatus.READY_FOR_PICKUP }, + }), + this.prisma.deliveryBooking.count({ + where: { status: DeliveryStatus.FAILED }, + }), + this.prisma.whatsAppAnalytics.count({ where: { createdAt: inRange } }), + this.prisma.whatsAppAnalytics.count({ + where: { createdAt: inRange, zeroResults: true }, + }), + this.prisma.auditLog.findMany({ + orderBy: { createdAt: "desc" }, + take: 10, + // Safe labels only — deliberately excludes AuditLog.metadata, which can + // carry reasons / references not meant for a broad overview. + select: { + id: true, + action: true, + targetType: true, + targetId: true, + createdAt: true, + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + }, + }, + }, + }), + ]); + + // WIZZA category demand gaps — reuse the aggregate-only analytics service. + // Degrades to an empty list on failure; never blocks the overview. + let topDemandGaps: Array<{ + parentCategoryLabel: string | null; + subcategoryLabel: string | null; + zeroResultCount: number; + }> = []; + try { + const gaps = + await this.categoryDemandInsights.getTopZeroResultSubcategories({ + from, + to, + limit: DASHBOARD_DEMAND_GAP_LIMIT, + }); + topDemandGaps = gaps.map((gap) => ({ + parentCategoryLabel: gap.parentCategoryLabel, + subcategoryLabel: gap.subcategoryLabel, + zeroResultCount: gap.zeroResultCount, + })); + } catch (error) { + this.logger.warn( + `Dashboard demand-gap lookup failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + const operations = { + verificationPending, + moderationPending, + disputesOpen, + payoutsFailed: payoutsFailedCount, + deliveriesReadyForPickup, + deliveriesFailedBooking, + }; + + return { + generatedAt: new Date().toISOString(), + range: { from: from.toISOString(), to: to.toISOString(), preset }, + users: { total: usersTotal, newInRange: usersNew }, + stores: { + total: storesTotal, + open: storesOpen, + verified: storesVerified, + pendingVerification: verificationPending, + }, + products: { + total: productsTotal, + active: productsActive, + public: productsPublic, + }, + orders: { + totalInRange: ordersTotal, + paidInRange: ordersPaid, + completedInRange: ordersCompleted, + disputedInRange: ordersDisputed, + pendingDelivery: ordersPendingDelivery, + }, + money: { + gmvKobo: this.stringifyKobo(gmvAgg._sum.totalAmountKobo ?? BigInt(0)), + platformFeesKobo: this.stringifyKobo( + feesAgg._sum.platformFeeKobo ?? BigInt(0), + ), + protectedPaymentsHeldKobo: this.stringifyKobo( + escrowAgg._sum.totalAmountKobo ?? BigInt(0), + ), + payoutsPendingKobo: this.stringifyKobo( + payoutsPendingAgg._sum.amountKobo ?? BigInt(0), + ), + payoutsFailedKobo: this.stringifyKobo( + payoutsFailedAgg._sum.amountKobo ?? BigInt(0), + ), + }, + operations, + wizza: { searchesInRange, zeroResultCount, topDemandGaps }, + recentActivity, + alerts: this.buildDashboardAlerts(operations, topDemandGaps), + }; + } + + async getUsers( + query: AdminListQuery = {}, + ): Promise> { + const { page, limit, skip } = this.normalizeListQuery(query); + const createdAt = this.dateRangeFilter(query); + const where: Prisma.UserWhereInput = { + deletedAt: null, + ...(query.status === "active" ? { isActive: true } : {}), + ...(query.status === "inactive" ? { isActive: false } : {}), + ...(createdAt ? { createdAt } : {}), + ...(query.q + ? { + OR: [ + { email: { contains: query.q, mode: "insensitive" } }, + { phone: { contains: query.q, mode: "insensitive" } }, + { username: { contains: query.q, mode: "insensitive" } }, + { firstName: { contains: query.q, mode: "insensitive" } }, + { lastName: { contains: query.q, mode: "insensitive" } }, + ], + } + : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.user.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: "desc" }, + select: { + id: true, + email: true, + phone: true, + username: true, + displayName: true, + firstName: true, + lastName: true, + role: true, + isActive: true, + emailVerified: true, + phoneVerified: true, + createdAt: true, + updatedAt: true, + deletedAt: true, + adminProfile: { select: { approvalStatus: true } }, + storeProfile: { + select: { + id: true, + businessName: true, + storeHandle: true, + tier: true, + verificationTier: true, + isOpen: true, + }, + }, + }, + }), + this.prisma.user.count({ where }), + ]); + + const sanitizedItems = items.map((item) => { + const { + passwordHash, + resetToken, + resetTokenExpiry, + paystackCustomerId, + paystackCustomerCode, + dvaAccountNumber, + ...safeItem + } = item as Record; + + void passwordHash; + void resetToken; + void resetTokenExpiry; + void paystackCustomerId; + void paystackCustomerCode; + void dvaAccountNumber; + + return safeItem; + }); + + return { + items: sanitizedItems, + pagination: this.buildPagination(page, limit, total), + }; + } + + async getUserById(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + phone: true, + username: true, + displayName: true, + firstName: true, + lastName: true, + role: true, + isActive: true, + emailVerified: true, + phoneVerified: true, + createdAt: true, + updatedAt: true, + deletedAt: true, + adminProfile: { + select: { + approvalStatus: true, + accessLevel: true, + department: true, + }, + }, + storeProfile: { + select: this.buildStoreSelect(), + }, + _count: { + select: { + orders: true, + buyerDisputes: true, + auditLogs: true, + }, + }, + }, + }); + + if (!user) { + throw new NotFoundException("User not found"); + } + + return { + ...user, + storeProfile: user.storeProfile + ? this.mapStoreForAdmin(user.storeProfile) + : null, + }; + } + + async updateUserStatus( + userId: string, + isActive: boolean, + reason: string, + adminId: string, + securityContext?: RequestSecurityContext, + ) { + if (userId === adminId) { + throw new BadRequestException("You cannot change your own status."); + } + + const before = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, email: true, role: true, isActive: true }, + }); + + if (!before) { + throw new NotFoundException("User not found"); + } + + if (before.role === UserRole.SUPER_ADMIN && !isActive) { + throw new BadRequestException("Cannot deactivate a Super Admin."); + } + + const updated = await this.prisma.$transaction(async (tx) => { + const changed = await tx.user.update({ + where: { id: userId }, + data: { isActive }, + select: { + id: true, + email: true, + role: true, + isActive: true, + updatedAt: true, + }, + }); + + const metadata = { + requestContext: this.requestContextForAudit(securityContext), + reason, + before: { isActive: before.isActive }, + after: { isActive: changed.isActive }, + }; + await tx.auditLog.create({ + data: { + userId: adminId, + action: "USER_STATUS_UPDATED", + targetType: "User", + targetId: userId, + metadata, + }, + }); + await this.appendAdminAuditDomainEvent( + tx, + "USER_STATUS_CHANGED", + adminId, + "User", + userId, + metadata, + ); + + return changed; + }); + + return updated; + } + + async updateUserRole( + userId: string, + role: UserRole, + reason: string, + adminId: string, + securityContext?: RequestSecurityContext, + ) { + if (userId === adminId) { + throw new BadRequestException("You cannot change your own role."); + } + + const allowedRoles = [UserRole.USER, UserRole.SUPER_ADMIN]; + + if (!allowedRoles.includes(role)) { + throw new BadRequestException("Invalid target role."); + } + + const before = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, email: true, role: true }, + }); + + if (!before) { + throw new NotFoundException("User not found"); + } + + const updated = await this.prisma.$transaction(async (tx) => { + const changed = await tx.user.update({ + where: { id: userId }, + data: { role }, + select: { + id: true, + email: true, + role: true, + updatedAt: true, + }, + }); + + const metadata = { + requestContext: this.requestContextForAudit(securityContext), + reason, + before: { role: before.role }, + after: { role: changed.role }, + }; + await tx.auditLog.create({ + data: { + userId: adminId, + action: "USER_ROLE_UPDATED", + targetType: "User", + targetId: userId, + metadata, + }, + }); + await this.appendAdminAuditDomainEvent( + tx, + "USER_ROLE_CHANGED", + adminId, + "User", + userId, + metadata, + ); + + return changed; + }); + + return updated; + } + + async getStores( + query: AdminListQuery = {}, + ): Promise> { + const { page, limit, skip } = this.normalizeListQuery(query); + const createdAt = this.dateRangeFilter(query); + const where: Prisma.StoreProfileWhereInput = { + ...(query.status === "open" ? { isOpen: true } : {}), + ...(query.status === "closed" ? { isOpen: false } : {}), + ...(createdAt ? { createdAt } : {}), + ...(query.q + ? { + OR: [ + { businessName: { contains: query.q, mode: "insensitive" } }, + { storeName: { contains: query.q, mode: "insensitive" } }, + { storeHandle: { contains: query.q, mode: "insensitive" } }, + { + businessCategory: { + contains: query.q, + mode: "insensitive", + }, + }, + ], + } + : {}), + }; + + const [stores, total] = await Promise.all([ + this.prisma.storeProfile.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: "desc" }, + select: this.buildStoreSelect(), + }), + this.prisma.storeProfile.count({ where }), + ]); + + return { + items: stores.map((store) => this.mapStoreForAdmin(store)), + pagination: this.buildPagination(page, limit, total), + }; + } + + async getStoreById(storeId: string) { + const store = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + select: this.buildStoreSelect(), + }); + + if (!store) { + throw new NotFoundException("Store profile not found"); + } + + return this.mapStoreForAdmin(store); + } + + async updateStoreStatus( + storeId: string, + dto: { isOpen?: boolean; showOwnerPublicly?: boolean; reason: string }, + adminId: string, + securityContext?: RequestSecurityContext, + ) { + if (dto.isOpen === undefined && dto.showOwnerPublicly === undefined) { + throw new BadRequestException( + "Provide isOpen or showOwnerPublicly to update store status.", + ); + } + + const before = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + select: { + id: true, + isOpen: true, + showOwnerPublicly: true, + }, + }); + + if (!before) { + throw new NotFoundException("Store profile not found"); + } + + const updated = await this.prisma.$transaction(async (tx) => { + const changed = await tx.storeProfile.update({ + where: { id: storeId }, + data: { + ...(dto.isOpen !== undefined ? { isOpen: dto.isOpen } : {}), + ...(dto.showOwnerPublicly !== undefined + ? { showOwnerPublicly: dto.showOwnerPublicly } + : {}), + }, + select: this.buildStoreSelect(), + }); + + const metadata = { + requestContext: this.requestContextForAudit(securityContext), + reason: dto.reason, + before, + after: { + isOpen: changed.isOpen, + showOwnerPublicly: changed.showOwnerPublicly, + }, + }; + await tx.auditLog.create({ + data: { + userId: adminId, + action: "STORE_STATUS_UPDATED", + targetType: "StoreProfile", + targetId: storeId, + metadata, + }, + }); + await this.appendAdminAuditDomainEvent( + tx, + "STORE_STATUS_CHANGED", + adminId, + "StoreProfile", + storeId, + metadata, + ); + + return changed; + }); + + return this.mapStoreForAdmin(updated); + } + + async getStoreVerification(storeId: string) { + const store = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + select: { + id: true, + businessName: true, + tier: true, + verificationTier: true, + addressVerified: true, + bankVerified: true, + ninVerified: true, + ninVerifiedAt: true, + ninVerifiedVia: true, + ninVerificationStatus: true, + ninVerificationAttempts: true, + ninVerificationLockedAt: true, + verificationRequests: { + orderBy: { createdAt: "desc" }, + select: { + id: true, + status: true, + targetTier: true, + idType: true, + reviewedBy: true, + reviewedAt: true, + rejectionReason: true, + createdAt: true, + reviewer: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + }, + }, + }, + }, + }, + }); + + if (!store) { + throw new NotFoundException("Store profile not found"); + } + + return store; + } + + async getOrders( + query: AdminListQuery = {}, + ): Promise> { + const { page, limit, skip } = this.normalizeListQuery(query); + const createdAt = this.dateRangeFilter(query); + const where: Prisma.OrderWhereInput = { + ...(query.status + ? { status: query.status as Prisma.EnumOrderStatusFilter } + : {}), + ...(query.payoutStatus + ? { payoutStatus: query.payoutStatus as Prisma.EnumPayoutStatusFilter } + : {}), + ...(query.disputeStatus + ? { + disputeStatus: + query.disputeStatus as Prisma.EnumOrderDisputeStatusFilter, + } + : {}), + ...(query.deliveryStatus + ? { + deliveryBooking: { + is: { + status: query.deliveryStatus as Prisma.EnumDeliveryStatusFilter, + }, + }, + } + : {}), + ...(createdAt ? { createdAt } : {}), + ...(query.q + ? { + OR: [ + { orderCode: { contains: query.q, mode: "insensitive" } }, + { id: { contains: query.q, mode: "insensitive" } }, + { + storeProfile: { + businessName: { contains: query.q, mode: "insensitive" }, + }, + }, + { + storeProfile: { + storeHandle: { contains: query.q, mode: "insensitive" }, + }, + }, + { user: { email: { contains: query.q, mode: "insensitive" } } }, + ], + } + : {}), + }; + + const [orders, total] = await Promise.all([ + this.prisma.order.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: "desc" }, + select: { + id: true, + orderCode: true, + orderType: true, + linkedOrderId: true, + buyerId: true, + storeId: true, + totalAmountKobo: true, + deliveryFeeKobo: true, + platformFeeKobo: true, + platformFeeBearer: true, + status: true, + disputeStatus: true, + payoutStatus: true, + deliveryMethod: true, + createdAt: true, + updatedAt: true, + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + }, + }, + storeProfile: { + select: { + id: true, + businessName: true, + storeHandle: true, + tier: true, + verificationTier: true, + }, + }, + deliveryBooking: { select: { status: true } }, + }, + }), + this.prisma.order.count({ where }), + ]); + + return { + items: orders.map((order) => ({ + ...order, + totalAmountKobo: this.stringifyKobo(order.totalAmountKobo), + deliveryFeeKobo: this.stringifyKobo(order.deliveryFeeKobo), + platformFeeKobo: this.stringifyKobo(order.platformFeeKobo), + deliveryStatus: order.deliveryBooking?.status ?? null, + })), + pagination: this.buildPagination(page, limit, total), + }; + } + + async getOrderById(orderId: string) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + select: { + id: true, + orderCode: true, + orderType: true, + linkedOrderId: true, + buyerId: true, + storeId: true, + items: true, + totalAmountKobo: true, + deliveryFeeKobo: true, + platformFeeKobo: true, + platformFeeBearer: true, + currency: true, + status: true, + disputeStatus: true, + payoutStatus: true, + deliveryMethod: true, + disputeWindowEndsAt: true, + verifiedAt: true, + createdAt: true, + updatedAt: true, + // Deliberately NOT selected: deliveryOtp (delivery code), deliveryDetails + // (holds the shopper's private delivery phone), deliveryAddress / + // deliveryAddressSnapshot / deliveryPrimaryPhone / deliverySecondaryPhone + // (shopper PII), storeType (internal), and physicalStore/physicalProduct + // IDs (dropship source is never surfaced). + user: { + select: { + id: true, + email: true, + phone: true, + firstName: true, + lastName: true, + }, + }, + storeProfile: { + select: { + id: true, + businessName: true, + storeHandle: true, + tier: true, + verificationTier: true, + }, + }, + linkedOrder: { + select: { + id: true, + orderCode: true, + orderType: true, + status: true, + totalAmountKobo: true, + }, + }, + linkedFromOrder: { + select: { + id: true, + orderCode: true, + orderType: true, + status: true, + totalAmountKobo: true, + }, + }, + payments: { + select: { + id: true, + provider: true, + providerReference: true, + providerTransactionReference: true, + amountKobo: true, + currency: true, + status: true, + direction: true, + verifiedAt: true, + createdAt: true, + }, + }, + payout: { + select: { + id: true, + amountKobo: true, + platformFeeKobo: true, + status: true, + failureReason: true, + createdAt: true, + completedAt: true, + }, + }, + // Safe delivery summary only — no raw pickup/delivery address strings + // (pickup can reveal a dropship physical supplier), no OTP, no payloads. + deliveryBooking: { + select: { + id: true, + method: true, + partnerName: true, + partnerRef: true, + trackingUrl: true, + estimatedCostKobo: true, + actualCostKobo: true, + status: true, + estimatedArrival: true, + pickedUpAt: true, + deliveredAt: true, + createdAt: true, + }, + }, + disputes: { + select: { + id: true, + status: true, + reason: true, + description: true, + resolutionOutcome: true, + resolutionNotes: true, + createdAt: true, + resolvedAt: true, + }, + }, + }, + }); + + if (!order) { + throw new NotFoundException("Order not found"); + } + + return { + ...order, + totalAmountKobo: this.stringifyKobo(order.totalAmountKobo), + deliveryFeeKobo: this.stringifyKobo(order.deliveryFeeKobo), + platformFeeKobo: this.stringifyKobo(order.platformFeeKobo), + linkedOrder: order.linkedOrder + ? { + ...order.linkedOrder, + totalAmountKobo: this.stringifyKobo( + order.linkedOrder.totalAmountKobo, + ), + } + : null, + linkedFromOrder: order.linkedFromOrder + ? { + ...order.linkedFromOrder, + totalAmountKobo: this.stringifyKobo( + order.linkedFromOrder.totalAmountKobo, + ), + } + : null, + payments: order.payments.map((payment) => ({ + ...payment, + amountKobo: this.stringifyKobo(payment.amountKobo), + })), + payout: order.payout + ? { + ...order.payout, + amountKobo: this.stringifyKobo(order.payout.amountKobo), + platformFeeKobo: this.stringifyKobo(order.payout.platformFeeKobo), + } + : null, + deliveryBooking: order.deliveryBooking + ? { + ...order.deliveryBooking, + estimatedCostKobo: this.stringifyKobo( + order.deliveryBooking.estimatedCostKobo, + ), + actualCostKobo: this.stringifyKobo( + order.deliveryBooking.actualCostKobo, + ), + } + : null, + }; + } + + async getDisputes( + query: AdminListQuery = {}, + ): Promise> { + const { page, limit, skip } = this.normalizeListQuery(query); + const createdAt = this.dateRangeFilter(query); + const where: Prisma.DisputeWhereInput = { + ...(query.status + ? { status: query.status as Prisma.EnumDisputeStatusFilter } + : {}), + ...(createdAt ? { createdAt } : {}), + ...(query.q + ? { + OR: [ + { reason: { contains: query.q, mode: "insensitive" } }, + { description: { contains: query.q, mode: "insensitive" } }, + { + order: { + orderCode: { contains: query.q, mode: "insensitive" }, + }, + }, + ], + } + : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.dispute.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: "desc" }, + select: { + id: true, + orderId: true, + buyerId: true, + storeId: true, + status: true, + reason: true, + description: true, + buyerRequestedOutcome: true, + storeResponse: true, + resolutionOutcome: true, + resolutionNotes: true, + resolvedById: true, + resolvedAt: true, + closedAt: true, + createdAt: true, + updatedAt: true, + order: { + select: { + id: true, + orderCode: true, + orderType: true, + status: true, + totalAmountKobo: true, + }, + }, + buyer: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + }, + }, + store: { + select: { + id: true, + businessName: true, + storeHandle: true, + }, + }, + }, + }), + this.prisma.dispute.count({ where }), + ]); + + return { + items: items.map((dispute) => ({ + ...dispute, + order: dispute.order + ? { + ...dispute.order, + totalAmountKobo: this.stringifyKobo( + dispute.order.totalAmountKobo, + ), + } + : null, + })), + pagination: this.buildPagination(page, limit, total), + }; + } + + async getDisputeSettlements( + query: AdminSettlementQuery = {}, + ): Promise> { + const { page, limit, skip } = this.normalizeListQuery(query); + const createdAt = this.dateRangeFilter({ + ...query, + startDate: query.createdFrom ?? query.startDate, + endDate: query.createdTo ?? query.endDate, + }); + const where: Prisma.DisputeSettlementWhereInput = { + ...(query.status ? { status: query.status as never } : {}), + ...(query.outcome ? { outcome: query.outcome as never } : {}), + ...(query.disputeId ? { disputeId: query.disputeId } : {}), + ...(query.orderId ? { orderId: query.orderId } : {}), + ...(createdAt ? { createdAt } : {}), + ...(query.legType || query.legStatus + ? { + legs: { + some: { + ...(query.legType ? { type: query.legType as never } : {}), + ...(query.legStatus + ? { status: query.legStatus as never } + : {}), + }, + }, + } + : {}), + }; + const [items, total] = await Promise.all([ + this.prisma.disputeSettlement.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: "desc" }, + select: { + id: true, + disputeId: true, + orderId: true, + outcome: true, + status: true, + buyerRefundAmountKobo: true, + merchantPayoutAmountKobo: true, + platformRetainedAmountKobo: true, + createdAt: true, + updatedAt: true, + legs: { + select: { + id: true, + type: true, + status: true, + attempts: true, + submittedAt: true, + completedAt: true, + }, + }, + dispute: { + select: { + buyer: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + }, + }, + }, + }, + order: { + select: { + orderCode: true, + storeProfile: { select: { businessName: true, userId: true } }, + }, + }, + }, + }), + this.prisma.disputeSettlement.count({ where }), + ]); + return { + items: items.map((item) => ({ + ...item, + buyerRefundAmountKobo: this.stringifyKobo(item.buyerRefundAmountKobo), + merchantPayoutAmountKobo: this.stringifyKobo( + item.merchantPayoutAmountKobo, + ), + platformRetainedAmountKobo: this.stringifyKobo( + item.platformRetainedAmountKobo, + ), + })), + pagination: this.buildPagination(page, limit, total), + }; + } + + async getDisputeSettlementSummary() { + const [byStatus, oldestPending, submittedLegs] = await Promise.all([ + this.prisma.disputeSettlement.groupBy({ + by: ["status"], + _count: { _all: true }, + }), + this.prisma.disputeSettlement.findFirst({ + where: { status: "PENDING" }, + orderBy: { createdAt: "asc" }, + select: { createdAt: true }, + }), + this.prisma.disputeSettlementLeg.groupBy({ + by: ["type"], + where: { status: "SUBMITTED" }, + _count: { _all: true }, + }), + ]); + const count = (status: string) => + byStatus.find((item) => item.status === status)?._count._all ?? 0; + const submitted = (type: string) => + submittedLegs.find((item) => item.type === type)?._count._all ?? 0; + return { + pending: count("PENDING"), + processing: count("PROCESSING"), + reconciliationRequired: count("RECONCILIATION_REQUIRED"), + manualReview: count("MANUAL_REVIEW"), + failed: count("FAILED"), + oldestPendingAt: oldestPending?.createdAt ?? null, + submittedRefundLegs: submitted("BUYER_REFUND"), + submittedPayoutLegs: submitted("MERCHANT_PAYOUT"), + }; + } + + async getDisputeSettlement(settlementId: string) { + const settlement = await this.prisma.disputeSettlement.findUnique({ + where: { id: settlementId }, + select: { + id: true, + disputeId: true, + orderId: true, + outcome: true, + status: true, + capturedAmountKobo: true, + buyerRefundAmountKobo: true, + merchantPayoutAmountKobo: true, + platformRetainedAmountKobo: true, + createdAt: true, + updatedAt: true, + startedAt: true, + completedAt: true, + failureReason: true, + legs: { + select: { + id: true, + type: true, + status: true, + amountKobo: true, + provider: true, + providerReference: true, + attempts: true, + lastError: true, + submittedAt: true, + completedAt: true, + refundOperations: { + select: { + id: true, + paymentAttemptId: true, + provider: true, + amountKobo: true, + status: true, + providerRefundId: true, + attempts: true, + failureSummary: true, + submittedAt: true, + completedAt: true, + nextReconcileAt: true, + }, + orderBy: { createdAt: "asc" }, + }, + }, + }, + dispute: { + select: { + buyer: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + }, + }, + }, + }, + order: { + select: { + orderCode: true, + storeProfile: { select: { businessName: true, storeHandle: true } }, + }, + }, + }, + }); + if (!settlement) throw new NotFoundException("Settlement not found"); + return { + ...settlement, + capturedAmountKobo: this.stringifyKobo(settlement.capturedAmountKobo), + buyerRefundAmountKobo: this.stringifyKobo( + settlement.buyerRefundAmountKobo, + ), + merchantPayoutAmountKobo: this.stringifyKobo( + settlement.merchantPayoutAmountKobo, + ), + platformRetainedAmountKobo: this.stringifyKobo( + settlement.platformRetainedAmountKobo, + ), + legs: settlement.legs.map((leg) => ({ + ...leg, + amountKobo: this.stringifyKobo(leg.amountKobo), + refundOperations: leg.refundOperations.map((operation) => ({ + ...operation, + amountKobo: this.stringifyKobo(operation.amountKobo), + })), + })), + }; + } + + async getDisputeById(disputeId: string) { + const dispute = await this.prisma.dispute.findUnique({ + where: { id: disputeId }, + select: { + id: true, + orderId: true, + buyerId: true, + storeId: true, + status: true, + reason: true, + description: true, + buyerRequestedOutcome: true, + storeResponse: true, + resolutionOutcome: true, + resolutionNotes: true, + resolvedById: true, + resolvedAt: true, + closedAt: true, + createdAt: true, + updatedAt: true, + order: { + select: { + id: true, + orderCode: true, + orderType: true, + status: true, + payoutStatus: true, + disputeStatus: true, + deliveryMethod: true, + totalAmountKobo: true, + deliveryFeeKobo: true, + disputeWindowEndsAt: true, + payments: { + select: { + id: true, + provider: true, + providerReference: true, + providerTransactionReference: true, + amountKobo: true, + status: true, + direction: true, + verifiedAt: true, + createdAt: true, + }, + orderBy: { createdAt: "desc" }, + take: 1, + }, + // Safe delivery summary only — no raw addresses, no delivery code. + deliveryBooking: { + select: { + id: true, + method: true, + partnerName: true, + partnerRef: true, + trackingUrl: true, + status: true, + estimatedArrival: true, + pickedUpAt: true, + deliveredAt: true, + }, + }, + }, + }, + buyer: { + select: { + id: true, + email: true, + phone: true, + firstName: true, + lastName: true, + }, + }, + store: { + select: { + id: true, + businessName: true, + storeHandle: true, + tier: true, + verificationTier: true, + }, + }, + evidence: { + select: { + id: true, + actorId: true, + actorType: true, + url: true, + note: true, + createdAt: true, + }, + orderBy: { createdAt: "asc" }, + }, + }, + }); + + if (!dispute) { + throw new NotFoundException("Dispute not found"); + } + + const order = dispute.order; + const payment = order?.payments?.[0] ?? null; + + return { + ...dispute, + order: order + ? { + ...order, + totalAmountKobo: this.stringifyKobo(order.totalAmountKobo), + deliveryFeeKobo: this.stringifyKobo(order.deliveryFeeKobo), + payments: undefined, + payment: payment + ? { + id: payment.id, + provider: payment.provider, + providerReference: payment.providerReference, + providerTransactionReference: + payment.providerTransactionReference, + amountKobo: this.stringifyKobo(payment.amountKobo), + status: payment.status, + direction: payment.direction, + verifiedAt: payment.verifiedAt, + createdAt: payment.createdAt, + } + : null, + } + : null, + }; + } + + async resolveDisputeById( + disputeId: string, + decision: "SHOPPER" | "STORE", + adminUserId: string, + notes?: string, + securityContext?: RequestSecurityContext, + ) { + // The dispute console requires a real, human-readable resolution reason + // (stored on the order event + AuditLog). Backend is the source of truth; + // the console mirrors this client-side. + const reason = notes?.trim(); + if (!reason || reason.length < 5) { + throw new BadRequestException({ + message: "A resolution reason of at least 5 characters is required", + code: "RESOLUTION_REASON_REQUIRED", + }); + } + + const dispute = await this.prisma.dispute.findUnique({ + where: { id: disputeId }, + select: { id: true, orderId: true }, + }); + + if (!dispute) { + throw new NotFoundException("Dispute not found"); + } + + return this.resolveDispute( + dispute.orderId, + decision, + adminUserId, + reason, + securityContext, + ); + } + + async moveSettlementToManualReview( + settlementId: string, + adminUserId: string, + reason: string, + securityContext?: RequestSecurityContext, + ) { + const safeReason = reason.trim(); + if (!safeReason) { + throw new BadRequestException("A review reason is required"); + } + return this.prisma.$transaction(async (tx) => { + const current = await tx.disputeSettlement.findUnique({ + where: { id: settlementId }, + select: { id: true, disputeId: true, orderId: true, status: true }, + }); + if (!current) throw new NotFoundException("Settlement not found"); + if (current.status === "COMPLETED") { + throw new BadRequestException( + "A completed settlement cannot be moved to manual review", + ); + } + const settlement = await tx.disputeSettlement.update({ + where: { id: settlementId }, + data: { status: "MANUAL_REVIEW", failureReason: safeReason }, + select: { + id: true, + disputeId: true, + orderId: true, + status: true, + updatedAt: true, + }, + }); + const metadata = { + previousStatus: current.status, + nextStatus: settlement.status, + reason: safeReason, + requestContext: this.requestContextForAudit(securityContext), + }; + await tx.auditLog.create({ + data: { + userId: adminUserId, + action: "DISPUTE_SETTLEMENT_MANUAL_REVIEW", + targetType: "DisputeSettlement", + targetId: settlement.id, + metadata, + }, + }); + await this.appendAdminAuditDomainEvent( + tx, + "DISPUTE_SETTLEMENT_MANUAL_REVIEW" as AdminDomainEventType, + adminUserId, + "DisputeSettlement", + settlement.id, + metadata, + `admin:${adminUserId}:settlement:${settlement.id}:manual-review:${settlement.updatedAt.toISOString()}`, + ); + await this.commerceOutbox.appendSettlementRealtimeEvents(tx, { + settlementId: settlement.id, + disputeId: settlement.disputeId, + orderId: settlement.orderId, + status: settlement.status, + updatedAt: settlement.updatedAt, + }); + await this.commerceOutbox.appendAdminSettlementAlert(tx, { + settlementId: settlement.id, + orderId: settlement.orderId, + stateKey: `settlement:state:${settlement.status}`, + title: "Settlement Manual Review", + body: "A dispute settlement was moved to manual review before any further financial action.", + }); + return settlement; + }); + } + + async reconcileDisputeSettlement(settlementId: string) { + const settlement = await this.prisma.disputeSettlement.findUnique({ + where: { id: settlementId }, + select: { id: true }, + }); + if (!settlement) throw new NotFoundException("Settlement not found"); + // This only polls known submitted provider operations. It cannot create a + // refund or transfer, including when the execution feature flag is off. + await this.settlementReconciliation.reconcileSettlement(settlementId); + return this.getDisputeSettlement(settlementId); + } + + async retryDisputeSettlementLeg(legId: string) { + const leg = await this.prisma.disputeSettlementLeg.findUnique({ + where: { id: legId }, + select: { id: true }, + }); + if (!leg) throw new NotFoundException("Settlement leg not found"); + await this.settlementExecution.retryLeg(legId); + return this.getDisputeSettlementLegSummary(legId); + } + + async reconcileDisputeSettlementLeg(legId: string) { + const leg = await this.prisma.disputeSettlementLeg.findUnique({ + where: { id: legId }, + select: { settlementId: true }, + }); + if (!leg) throw new NotFoundException("Settlement leg not found"); + await this.settlementReconciliation.reconcileSettlement(leg.settlementId); + return this.getDisputeSettlementLegSummary(legId); + } + + private async getDisputeSettlementLegSummary(legId: string) { + const leg = await this.prisma.disputeSettlementLeg.findUnique({ + where: { id: legId }, + select: { + id: true, + settlementId: true, + type: true, + status: true, + attempts: true, + submittedAt: true, + completedAt: true, + }, + }); + if (!leg) throw new NotFoundException("Settlement leg not found"); + return leg; + } + + async getPayments( + query: AdminListQuery = {}, + ): Promise> { + const { page, limit, skip } = this.normalizeListQuery(query); + const createdAt = this.dateRangeFilter(query); + const where: Prisma.PaymentWhereInput = { + ...(query.status + ? { status: query.status as Prisma.EnumPaymentStatusFilter } + : {}), + ...(createdAt ? { createdAt } : {}), + ...(query.q + ? { + OR: [ + { + providerReference: { + contains: query.q, + mode: "insensitive", + }, + }, + { + attempts: { + some: { + providerReference: { + contains: query.q, + mode: "insensitive", + }, + }, + }, + }, + { + order: { + orderCode: { contains: query.q, mode: "insensitive" }, + }, + }, + ], + } + : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.payment.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: "desc" }, + select: { + id: true, + orderId: true, + provider: true, + providerReference: true, + providerTransactionReference: true, + paystackTransferRef: true, + amountKobo: true, + currency: true, + status: true, + direction: true, + verifiedAt: true, + createdAt: true, + updatedAt: true, + attempts: { + orderBy: { initializedAt: "desc" }, + select: { + id: true, + provider: true, + providerReference: true, + providerTransactionReference: true, + status: true, + initializedAt: true, + verifiedAt: true, + reconciliationRequiredAt: true, + }, + }, + order: { + select: { + id: true, + orderCode: true, + status: true, + orderType: true, + }, + }, + }, + }), + this.prisma.payment.count({ where }), + ]); + + return { + items: items.map((payment) => ({ + ...payment, + amountKobo: this.stringifyKobo(payment.amountKobo), + })), + pagination: this.buildPagination(page, limit, total), + }; + } + + async getPaymentAmountExceptions( + query: AdminPaymentAmountExceptionQuery = {}, + ): Promise> { + const { page, limit, skip } = this.normalizeListQuery(query); + const createdAt = this.dateRangeFilter(query); + const where: Prisma.PaymentAmountExceptionWhereInput = { + ...(query.status + ? { + status: + query.status as Prisma.EnumPaymentAmountExceptionStatusFilter, + } + : {}), + ...(query.exceptionType + ? { + exceptionType: + query.exceptionType as Prisma.EnumPaymentAmountExceptionTypeFilter, + } + : {}), + ...(query.provider + ? { + provider: query.provider as Prisma.EnumPaymentProviderNameFilter, + } + : {}), + ...(query.paymentId ? { paymentId: query.paymentId } : {}), + ...(query.orderId ? { payment: { orderId: query.orderId } } : {}), + ...(createdAt ? { createdAt } : {}), + ...(query.q + ? { + OR: [ + { + providerReference: { + contains: query.q, + mode: "insensitive", + }, + }, + { + payment: { + order: { + orderCode: { contains: query.q, mode: "insensitive" }, + }, + }, + }, + ], + } + : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.paymentAmountException.findMany({ + where, + skip, + take: limit, + orderBy: { detectedAt: "desc" }, + select: { + id: true, + paymentId: true, + paymentAttemptId: true, + provider: true, + providerReference: true, + providerTransactionReference: true, + expectedAmountKobo: true, + receivedAmountKobo: true, + exceptionType: true, + status: true, + detectedAt: true, + updatedAt: true, + refund: { + select: { + id: true, + provider: true, + amountKobo: true, + status: true, + providerRefundId: true, + submittedAt: true, + completedAt: true, + failureSummary: true, + }, + }, + payment: { + select: { + orderId: true, + status: true, + order: { + select: { + id: true, + orderCode: true, + status: true, + buyerId: true, + storeId: true, + }, + }, + }, + }, + }, + }), + this.prisma.paymentAmountException.count({ where }), + ]); + + return { + items: items.map((exception) => ({ + ...exception, + expectedAmountKobo: this.stringifyKobo(exception.expectedAmountKobo), + receivedAmountKobo: this.stringifyKobo(exception.receivedAmountKobo), + refund: exception.refund + ? { + ...exception.refund, + amountKobo: this.stringifyKobo(exception.refund.amountKobo), + } + : null, + })), + pagination: this.buildPagination(page, limit, total), + }; + } + + async getPaymentAmountException(exceptionId: string) { + const [exception, auditLogs] = await Promise.all([ + this.prisma.paymentAmountException.findUnique({ + where: { id: exceptionId }, + select: { + id: true, + paymentId: true, + paymentAttemptId: true, + provider: true, + providerReference: true, + providerTransactionReference: true, + expectedAmountKobo: true, + receivedAmountKobo: true, + exceptionType: true, + status: true, + detectedAt: true, + createdAt: true, + updatedAt: true, + refund: { + select: { + id: true, + provider: true, + amountKobo: true, + status: true, + providerRefundId: true, + attempts: true, + submittedAt: true, + completedAt: true, + failureSummary: true, + }, + }, + payment: { + select: { + orderId: true, + status: true, + order: { + select: { + id: true, + orderCode: true, + status: true, + buyerId: true, + storeId: true, + }, + }, + }, + }, + }, + }), + this.prisma.auditLog.findMany({ + where: { targetType: "PaymentAmountException", targetId: exceptionId }, + orderBy: { createdAt: "desc" }, + take: DEFAULT_TIMELINE_LIMIT, + select: { + id: true, + action: true, + metadata: true, + createdAt: true, + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + }, + }, + }, + }), + ]); + if (!exception) { + throw new NotFoundException("Payment amount exception not found"); + } + + return { + ...exception, + expectedAmountKobo: this.stringifyKobo(exception.expectedAmountKobo), + receivedAmountKobo: this.stringifyKobo(exception.receivedAmountKobo), + refund: exception.refund + ? { + ...exception.refund, + amountKobo: this.stringifyKobo(exception.refund.amountKobo), + } + : null, + auditTimeline: auditLogs.map((log) => ({ + id: log.id, + action: log.action, + createdAt: log.createdAt, + actor: this.mapAuditActor(log.user), + metadata: this.sanitizeAuditMetadata(log.metadata), + })), + }; + } + + async reconcilePaymentAmountException( + exceptionId: string, + adminUserId: string, + securityContext?: RequestSecurityContext, + ) { + const exception = await this.prisma.paymentAmountException.findUnique({ + where: { id: exceptionId }, + select: { id: true, status: true, provider: true, paymentId: true }, + }); + if (!exception) { + throw new NotFoundException("Payment amount exception not found"); + } + + const result = await this.paymentService.inspectPaymentAmountException( + exception.id, + ); + const metadata = { + requestContext: this.requestContextForAudit(securityContext), + paymentId: exception.paymentId, + provider: exception.provider, + previousStatus: exception.status, + providerStatus: result.providerStatus.slice(0, 100), + observedAmountKobo: result.observedAmountKobo, + matchesRecordedAmount: result.matchesRecordedAmount, + // This action is intentionally observation-only. It does not allocate + // money, mark an order paid, or initiate a provider refund. + outcome: "REQUIRES_REVIEW", + }; + await this.prisma.$transaction(async (tx) => { + await tx.auditLog.create({ + data: { + userId: adminUserId, + action: "RECONCILE_PAYMENT_AMOUNT_EXCEPTION", + targetType: "PaymentAmountException", + targetId: exception.id, + metadata, + }, + }); + await this.appendAdminAuditDomainEvent( + tx, + "PAYMENT_AMOUNT_EXCEPTION_RECONCILED", + adminUserId, + "PaymentAmountException", + exception.id, + metadata, + `admin:${adminUserId}:payment-amount-exception:${exception.id}:reconcile:${result.providerStatus}:${result.observedAmountKobo}`, + ); + }); + + return { + exception: await this.getPaymentAmountException(exception.id), + result, + }; + } + + async movePaymentAmountExceptionToManualReview( + exceptionId: string, + adminUserId: string, + reason: string, + securityContext?: RequestSecurityContext, + ) { + const safeReason = reason.trim(); + if (!safeReason) { + throw new BadRequestException("A review reason is required"); + } + + await this.prisma.$transaction(async (tx) => { + const current = await tx.paymentAmountException.findUnique({ + where: { id: exceptionId }, + select: { id: true, status: true, paymentId: true }, + }); + if (!current) { + throw new NotFoundException("Payment amount exception not found"); + } + if (current.status === "RESOLVED") { + throw new BadRequestException( + "A resolved payment amount exception cannot be moved to manual review", + ); + } + if (current.status === "MANUAL_REVIEW") { + return; + } + + const transition = await tx.paymentAmountException.updateMany({ + where: { id: current.id, status: "RECONCILIATION_REQUIRED" }, + data: { status: "MANUAL_REVIEW" }, + }); + if (transition.count === 0) { + return; + } + + const metadata = { + requestContext: this.requestContextForAudit(securityContext), + paymentId: current.paymentId, + previousStatus: current.status, + nextStatus: "MANUAL_REVIEW", + reason: safeReason, + }; + await tx.auditLog.create({ + data: { + userId: adminUserId, + action: "PAYMENT_AMOUNT_EXCEPTION_MANUAL_REVIEW", + targetType: "PaymentAmountException", + targetId: current.id, + metadata, + }, + }); + await this.appendAdminAuditDomainEvent( + tx, + "PAYMENT_AMOUNT_EXCEPTION_MANUAL_REVIEW", + adminUserId, + "PaymentAmountException", + current.id, + metadata, + `admin:${adminUserId}:payment-amount-exception:${current.id}:manual-review`, + ); + }); + + return this.getPaymentAmountException(exceptionId); + } + + async createPaymentAmountExceptionRefund( + exceptionId: string, + adminUserId: string, + securityContext?: RequestSecurityContext, + ) { + await this.prisma.$transaction(async (tx) => { + const plan = await this.paymentAmountExceptionRefunds.createPlan( + exceptionId, + adminUserId, + tx, + ); + if (!plan.created) return; + + const { refund } = plan; + const metadata = { + requestContext: this.requestContextForAudit(securityContext), + refundId: refund.id, + provider: refund.provider, + amountKobo: refund.amountKobo.toString(), + // This is a full reversal of the unallocated collection; it does not + // allocate any amount to escrow or mark the order paid. + outcome: "FULL_UNALLOCATED_COLLECTION_REFUND", + }; + await tx.auditLog.create({ + data: { + userId: adminUserId, + action: "CREATE_PAYMENT_AMOUNT_EXCEPTION_REFUND", + targetType: "PaymentAmountException", + targetId: exceptionId, + metadata, + }, + }); + await this.appendAdminAuditDomainEvent( + tx, + "PAYMENT_AMOUNT_EXCEPTION_REFUND_PLANNED", + adminUserId, + "PaymentAmountException", + exceptionId, + metadata, + `admin:${adminUserId}:payment-amount-exception:${exceptionId}:refund:${refund.id}`, + ); + }); + return this.getPaymentAmountException(exceptionId); + } + + async getPayouts( + query: AdminListQuery = {}, + ): Promise> { + const { page, limit, skip } = this.normalizeListQuery(query); + const createdAt = this.dateRangeFilter(query); + const where: Prisma.PayoutWhereInput = { + ...(query.status + ? { status: query.status as Prisma.EnumPayoutStatusFilter } + : {}), + ...(createdAt ? { createdAt } : {}), + ...(query.q + ? { + OR: [ + { id: { contains: query.q, mode: "insensitive" } }, + { + providerReference: { + contains: query.q, + mode: "insensitive", + }, + }, + { + providerOperationId: { + contains: query.q, + mode: "insensitive", + }, + }, + { + order: { + orderCode: { contains: query.q, mode: "insensitive" }, + }, + }, + ], + } + : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.payout.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: "desc" }, + select: { + id: true, + orderId: true, + storeId: true, + amountKobo: true, + platformFeeKobo: true, + paystackTransferCode: true, + provider: true, + providerReference: true, + providerOperationId: true, + status: true, + initiatedAt: true, + completedAt: true, + failureReason: true, + createdAt: true, + order: { + select: { + id: true, + orderCode: true, + orderType: true, + status: true, + }, + }, + store: { + select: { + id: true, + businessName: true, + storeHandle: true, + }, + }, + }, + }), + this.prisma.payout.count({ where }), + ]); + + return { + items: items.map((payout) => ({ + id: payout.id, + orderId: payout.orderId, + storeId: payout.storeId, + amountKobo: this.stringifyKobo(payout.amountKobo), + platformFeeKobo: this.stringifyKobo(payout.platformFeeKobo), + provider: payout.provider === "MONNIFY" ? "Monnify" : "Paystack", + transferRef: + payout.providerOperationId ?? + payout.providerReference ?? + payout.paystackTransferCode, + status: payout.status, + initiatedAt: payout.initiatedAt, + completedAt: payout.completedAt, + failureReason: payout.failureReason, + createdAt: payout.createdAt, + order: payout.order, + store: payout.store, + })), + pagination: this.buildPagination(page, limit, total), + }; + } + + // Safe payout detail for the admin review console. Bank account numbers are + // masked to last-4; never returns full account numbers, the Paystack + // recipient code, provider payloads, or identity data. + async getPayoutById(payoutId: string) { + const payout = await this.prisma.payout.findUnique({ + where: { id: payoutId }, + select: { + id: true, + orderId: true, + storeId: true, + amountKobo: true, + platformFeeKobo: true, + paystackTransferCode: true, + provider: true, + providerReference: true, + providerOperationId: true, + status: true, + initiatedAt: true, + completedAt: true, + failureReason: true, + createdAt: true, + store: { + select: { + id: true, + businessName: true, + storeHandle: true, + tier: true, + verificationTier: true, + bankVerified: true, + bankName: true, + accountName: true, + accountNumber: true, + bankAccountNumber: true, + }, + }, + order: { + select: { + id: true, + orderCode: true, + orderType: true, + status: true, + disputeStatus: true, + payoutStatus: true, + totalAmountKobo: true, + payments: { + select: { + id: true, + provider: true, + providerReference: true, + providerTransactionReference: true, + amountKobo: true, + status: true, + direction: true, + verifiedAt: true, + createdAt: true, + }, + orderBy: { createdAt: "desc" }, + take: 1, + }, + }, + }, + }, + }); + + if (!payout) { + throw new NotFoundException("Payout not found"); + } + + const store = payout.store; + const order = payout.order; + const payment = order?.payments?.[0] ?? null; + + return { + id: payout.id, + orderId: payout.orderId, + storeId: payout.storeId, + amountKobo: this.stringifyKobo(payout.amountKobo), + platformFeeKobo: this.stringifyKobo(payout.platformFeeKobo), + provider: payout.provider === "MONNIFY" ? "Monnify" : "Paystack", + transferRef: + payout.providerOperationId ?? + payout.providerReference ?? + payout.paystackTransferCode, + status: payout.status, + initiatedAt: payout.initiatedAt, + completedAt: payout.completedAt, + failureReason: payout.failureReason, + createdAt: payout.createdAt, + store: store + ? { + id: store.id, + businessName: store.businessName, + storeHandle: store.storeHandle, + tier: store.tier, + verificationTier: store.verificationTier, + bankVerified: store.bankVerified, + // Masked bank summary — last 4 only, never the full number or + // the Paystack recipient code. + bank: { + bankName: store.bankName, + accountName: store.accountName, + accountNumberLast4: this.lastFour( + store.accountNumber ?? store.bankAccountNumber, + ), + }, + } + : null, + order: order + ? { + id: order.id, + orderCode: order.orderCode, + orderType: order.orderType, + status: order.status, + disputeStatus: order.disputeStatus, + payoutStatus: order.payoutStatus, + totalAmountKobo: this.stringifyKobo(order.totalAmountKobo), + } + : null, + payment: payment + ? { + id: payment.id, + provider: payment.provider, + providerReference: payment.providerReference, + providerTransactionReference: payment.providerTransactionReference, + amountKobo: this.stringifyKobo(payment.amountKobo), + status: payment.status, + direction: payment.direction, + verifiedAt: payment.verifiedAt, + createdAt: payment.createdAt, + } + : null, + }; + } + + async getLedger( + query: AdminListQuery = {}, + ): Promise> { + const { page, limit, skip } = this.normalizeListQuery(query); + const createdAt = this.dateRangeFilter(query); + const where: Prisma.LedgerEntryWhereInput = { + ...(query.status + ? { entryType: query.status as Prisma.EnumLedgerEntryTypeFilter } + : {}), + ...(createdAt ? { createdAt } : {}), + ...(query.q + ? { + OR: [ + { reference: { contains: query.q, mode: "insensitive" } }, + { orderId: { contains: query.q, mode: "insensitive" } }, + { storeId: { contains: query.q, mode: "insensitive" } }, + { userId: { contains: query.q, mode: "insensitive" } }, + ], + } + : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.ledgerEntry.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: "desc" }, + select: { + id: true, + entryType: true, + direction: true, + amountKobo: true, + currency: true, + orderId: true, + paymentId: true, + payoutId: true, + storeId: true, + userId: true, + reference: true, + createdAt: true, + }, + }), + this.prisma.ledgerEntry.count({ where }), + ]); + + return { + items: items.map((entry) => ({ + ...entry, + amountKobo: this.stringifyKobo(entry.amountKobo), + })), + pagination: this.buildPagination(page, limit, total), + }; + } + + async getReconciliation( + query: AdminListQuery = {}, + ): Promise> { + const { page, limit, skip } = this.normalizeListQuery(query); + const createdAt = this.dateRangeFilter(query); + const where: Prisma.ReconciliationLogWhereInput = { + ...(query.status + ? { status: query.status as Prisma.EnumReconciliationStatusFilter } + : {}), + ...(createdAt ? { createdAt } : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.reconciliationLog.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: "desc" }, + select: { + id: true, + paystackBalanceKobo: true, + ledgerEscrowKobo: true, + differenceKobo: true, + status: true, + createdAt: true, + }, + }), + this.prisma.reconciliationLog.count({ where }), + ]); + + return { + items: items.map((entry) => ({ + ...entry, + paystackBalanceKobo: this.stringifyKobo(entry.paystackBalanceKobo), + ledgerEscrowKobo: this.stringifyKobo(entry.ledgerEscrowKobo), + differenceKobo: this.stringifyKobo(entry.differenceKobo), + })), + pagination: this.buildPagination(page, limit, total), + }; + } + + async getVerificationQueue( + query: AdminListQuery = {}, + ): Promise> { + const { page, limit, skip } = this.normalizeListQuery(query); + const createdAt = this.dateRangeFilter(query); + const where: Prisma.VerificationRequestWhereInput = { + ...(query.status + ? { status: query.status as Prisma.EnumVerificationRequestStatusFilter } + : {}), + ...(query.tier + ? { targetTier: query.tier as Prisma.EnumVerificationTierFilter } + : {}), + ...(createdAt ? { createdAt } : {}), + ...(query.q + ? { + store: { + OR: [ + { businessName: { contains: query.q, mode: "insensitive" } }, + { storeHandle: { contains: query.q, mode: "insensitive" } }, + ], + }, + } + : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.verificationRequest.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: "desc" }, + select: { + id: true, + storeId: true, + idType: true, + status: true, + targetTier: true, + reviewedBy: true, + reviewedAt: true, + rejectionReason: true, + createdAt: true, + store: { + select: { + id: true, + businessName: true, + storeHandle: true, + tier: true, + verificationTier: true, + ninVerified: true, + ninVerifiedVia: true, + ninVerificationStatus: true, + addressVerified: true, + bankVerified: true, + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + }, + }, + }, + }, + reviewer: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + }, + }, + }, + }), + this.prisma.verificationRequest.count({ where }), + ]); + + return { + items, + pagination: this.buildPagination(page, limit, total), + }; + } + + // Detailed, safe verification-request view for the admin review console. + // Excludes raw NIN, the government-ID document URL, and any Prembly payload / + // biometric data — Prembly output is surfaced only as normalized status flags. + async getVerificationRequestDetail(requestId: string) { + const request = await this.prisma.verificationRequest.findUnique({ + where: { id: requestId }, + select: { + id: true, + storeId: true, + idType: true, + status: true, + targetTier: true, + // Selected only to derive a presence flag — never returned to the client. + governmentIdUrl: true, + reviewedAt: true, + rejectionReason: true, + createdAt: true, + reviewer: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + }, + }, + store: { + select: { + id: true, + businessName: true, + storeHandle: true, + businessCategory: true, + isOpen: true, + tier: true, + verificationTier: true, + verifiedAt: true, + createdAt: true, + updatedAt: true, + bankVerified: true, + businessPhoneVerified: true, + addressVerified: true, + addressVerifiedVia: true, + guarantorVerified: true, + profileImage: true, + ninVerified: true, + ninVerifiedAt: true, + ninVerifiedVia: true, + ninVerificationStatus: true, + ninVerificationAttempts: true, + ninVerificationLockedAt: true, + user: { + select: { + id: true, + email: true, + phone: true, + firstName: true, + lastName: true, + }, + }, + }, + }, + }, + }); + + if (!request) { + throw new NotFoundException("Verification request not found"); + } + + const storeId = request.storeId; + const [completedOrders, openDisputes, resubmissionCount, history] = + await Promise.all([ + this.prisma.order.count({ + where: { storeId, status: OrderStatus.COMPLETED }, + }), + this.prisma.order.count({ + where: { storeId, disputeStatus: { not: OrderDisputeStatus.NONE } }, + }), + this.prisma.verificationRequest.count({ where: { storeId } }), + this.prisma.verificationRequest.findMany({ + where: { storeId }, + orderBy: { createdAt: "desc" }, + take: 20, + select: { + id: true, + status: true, + targetTier: true, + idType: true, + reviewedAt: true, + rejectionReason: true, + createdAt: true, + reviewer: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + }, + }, + }, + }), + ]); + + const store = request.store; + const owner = store.user; + + return { + request: { + id: request.id, + status: request.status, + targetTier: request.targetTier, + idType: request.idType, + rejectionReason: request.rejectionReason, + reviewedAt: request.reviewedAt, + reviewer: request.reviewer, + submittedAt: request.createdAt, + resubmissionCount, + }, + store: { + id: store.id, + businessName: store.businessName, + storeHandle: store.storeHandle, + businessCategory: store.businessCategory, + isOpen: store.isOpen, + tier: store.tier, + verificationTier: store.verificationTier, + verifiedAt: store.verifiedAt, + createdAt: store.createdAt, + updatedAt: store.updatedAt, + owner: { + id: owner.id, + name: + [owner.firstName, owner.lastName].filter(Boolean).join(" ") || null, + email: owner.email, + phone: owner.phone, + }, + }, + requirements: { + bankVerified: store.bankVerified, + businessPhoneVerified: store.businessPhoneVerified, + addressVerified: store.addressVerified, + addressVerifiedVia: store.addressVerifiedVia, + guarantorVerified: store.guarantorVerified, + hasProfilePhoto: !!store.profileImage, + ninVerified: store.ninVerified, + completedOrders, + openDisputes, + }, + // Normalized Prembly outcome only — no confidence payload, no raw response. + // `method` = PREMBLY (automated NIN + face match) | MANUAL (admin) | null. + prembly: { + nin: { + verified: store.ninVerified, + method: store.ninVerifiedVia, + status: store.ninVerificationStatus ?? "NONE", + attempts: store.ninVerificationAttempts, + locked: store.ninVerificationStatus === "LOCKED", + lockedAt: store.ninVerificationLockedAt, + verifiedAt: store.ninVerifiedAt, + }, + }, + // Presence flags only — never the raw document link. + evidence: { + hasGovernmentId: !!request.governmentIdUrl, + hasProfilePhoto: !!store.profileImage, + }, + timeline: history.map((entry) => ({ + id: entry.id, + status: entry.status, + targetTier: entry.targetTier, + idType: entry.idType, + submittedAt: entry.createdAt, + reviewedAt: entry.reviewedAt, + rejectionReason: entry.rejectionReason, + reviewer: entry.reviewer, + })), + }; + } + + /** + * Safe operator queue for provider attempts whose financial/provider outcome + * is not yet known. Provider references remain internal; raw NIN, biometric + * data and provider payloads are never selected here. + */ + async getVerificationReconciliationAttempts( + query: AdminListQuery = {}, + ): Promise> { + const { page, limit, skip } = this.normalizeListQuery(query); + const where: Prisma.StoreVerificationAttemptWhereInput = { + status: "RECONCILIATION_REQUIRED", + ...(query.startDate || query.endDate + ? { + createdAt: { + ...(query.startDate ? { gte: new Date(query.startDate) } : {}), + ...(query.endDate ? { lte: new Date(query.endDate) } : {}), + }, + } + : {}), + }; + const [items, total] = await Promise.all([ + this.prisma.storeVerificationAttempt.findMany({ + where, + orderBy: { updatedAt: "asc" }, + skip, + take: limit, + select: { + id: true, + storeId: true, + provider: true, + checkType: true, + status: true, + failureCode: true, + providerReference: true, + submittedAt: true, + updatedAt: true, + store: { + select: { + businessName: true, + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + }, + }, + }, + }, + }, + }), + this.prisma.storeVerificationAttempt.count({ where }), + ]); + return { items, pagination: this.buildPagination(page, limit, total) }; + } + + async reconcileVerificationAttempt( + attemptId: string, + adminId: string, + securityContext?: RequestSecurityContext, + ) { + const attempt = await this.prisma.storeVerificationAttempt.findUnique({ + where: { id: attemptId }, + select: { + id: true, + storeId: true, + status: true, + checkType: true, + provider: true, + }, + }); + if (!attempt) throw new NotFoundException("Verification attempt not found"); + + const result = await this.verificationService.reconcileVerificationAttempt( + attempt.id, + ); + const metadata = { + requestContext: this.requestContextForAudit(securityContext), + storeId: attempt.storeId, + verificationAttemptId: attempt.id, + checkType: attempt.checkType, + provider: attempt.provider, + previousStatus: attempt.status, + nextStatus: result.status, + }; + await this.prisma.$transaction(async (tx) => { + await tx.auditLog.create({ + data: { + userId: adminId, + action: "RECONCILE_VERIFICATION_ATTEMPT", + targetType: "StoreVerificationAttempt", + targetId: attempt.id, + metadata, + }, + }); + await this.appendAdminAuditDomainEvent( + tx, + "STORE_VERIFICATION_RECONCILED", + adminId, + "StoreVerificationAttempt", + attempt.id, + metadata, + `admin:${adminId}:verification-attempt:${attempt.id}:reconcile:${result.status}`, + ); + }); + return result; + } + + // Canonical admin approve/reject entry point (request-keyed). Delegates the + // decision to the single VerificationService.reviewRequest implementation, + // then writes the AuditLog + DomainEvent. Rejection requires a real reason — + // enforced here (backend source of truth) as well as in the controller DTO. + async reviewVerificationRequest( + requestId: string, + decision: "APPROVED" | "REJECTED", + rejectionReason: string | undefined, + adminId: string, + securityContext?: RequestSecurityContext, + ) { + const request = await this.prisma.verificationRequest.findUnique({ + where: { id: requestId }, + select: { + id: true, + storeId: true, + status: true, + targetTier: true, + store: { select: { tier: true, verificationTier: true } }, + }, + }); + + if (!request) { + throw new NotFoundException("Verification request not found"); + } + if (request.status !== "PENDING") { + throw new BadRequestException({ + message: "Verification request is already processed", + code: "REQUEST_ALREADY_PROCESSED", + }); + } + + const reason = rejectionReason?.trim(); + if (decision === "REJECTED" && (!reason || reason.length < 5)) { + throw new BadRequestException({ + message: "A rejection reason of at least 5 characters is required", + code: "REJECTION_REASON_REQUIRED", + }); + } + + const result = await this.verificationService.reviewRequest( + request.id, + adminId, + { + decision, + rejectionReason: decision === "REJECTED" ? reason : undefined, + }, + ); + + const metadata = { + requestContext: this.requestContextForAudit(securityContext), + storeId: request.storeId, + verificationRequestId: request.id, + previousStatus: request.status, + nextStatus: decision, + requestedTier: request.targetTier, + previousTier: request.store.tier, + previousVerificationTier: request.store.verificationTier, + ...(decision === "REJECTED" ? { rejectionReason: reason } : {}), + }; + + try { + await this.prisma.$transaction(async (tx) => { + await tx.auditLog.create({ + data: { + userId: adminId, + action: + decision === "REJECTED" + ? "VERIFICATION_REJECTED" + : "VERIFICATION_APPROVED", + targetType: "VerificationRequest", + targetId: request.id, + metadata, + }, + }); + await this.appendAdminAuditDomainEvent( + tx, + "STORE_VERIFICATION_UPDATED", + adminId, + "VerificationRequest", + request.id, + metadata, + ); + }); + } catch (error) { + this.logger.error( + `Failed to audit verification review for request ${request.id} by admin ${adminId}`, + error instanceof Error ? error.stack : String(error), + ); + } + + return result; + } + + async getModerationQueue( + contentType: "post" | "product", + query: AdminListQuery = {}, + ): Promise> { + const { page, limit, skip } = this.normalizeListQuery(query); + const createdAt = this.dateRangeFilter(query); + const where: Prisma.ModerationLogWhereInput = { + contentType: { contains: contentType, mode: "insensitive" }, + ...(query.status + ? { decision: query.status as Prisma.EnumModerationStatusFilter } + : {}), + ...(createdAt ? { createdAt } : {}), + ...(query.q + ? { + OR: [ + { contentId: { contains: query.q, mode: "insensitive" } }, + { imageUrl: { contains: query.q, mode: "insensitive" } }, + ], + } + : {}), + }; + + const [logs, total] = await Promise.all([ + this.prisma.moderationLog.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: "desc" }, + select: { + id: true, + contentType: true, + contentId: true, + imageUrl: true, + decision: true, + // confidence is the normalized SafeSearch summary; never returned raw. + confidence: true, + reviewedBy: true, + reviewNote: true, + appealStatus: true, + createdAt: true, + reviewer: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + }, + }, + }, + }), + this.prisma.moderationLog.count({ where }), + ]); + + // Batch-load a safe content/owner summary for the page (one query per type — + // no N+1). The list is already scoped to a single content type. + const contentIds = logs.map((log) => log.contentId); + const summaries = await this.moderationContentSummaries( + contentType, + contentIds, + ); + + const items = logs.map((log) => { + const { confidence, imageUrl, ...rest } = log; + return { + ...rest, + evidenceImageUrl: imageUrl, + safeSearch: this.normalizeSafeSearchSummary(confidence), + content: summaries.get(log.contentId) ?? null, + }; + }); + + return { + items, + pagination: this.buildPagination(page, limit, total), + }; + } + + // Cloud Vision's stored result is `{ adult, violence, racy }` likelihood + // labels — already a normalized summary. This coerces to exactly those three + // string fields so no unexpected/raw provider payload can leak through the API. + private normalizeSafeSearchSummary(value: unknown): { + adult: string | null; + violence: string | null; + racy: string | null; + } | null { + if (!value || typeof value !== "object") { + return null; + } + const record = value as Record; + const pick = (key: string): string | null => + typeof record[key] === "string" ? (record[key] as string) : null; + const adult = pick("adult"); + const violence = pick("violence"); + const racy = pick("racy"); + if (adult === null && violence === null && racy === null) { + return null; + } + return { adult, violence, racy }; + } + + private async moderationContentSummaries( + contentType: "post" | "product", + contentIds: string[], + ): Promise< + Map< + string, + { + title: string | null; + moderationStatus: string; + isActive: boolean; + storeName: string | null; + storeHandle: string | null; + } + > + > { + const map = new Map< + string, + { + title: string | null; + moderationStatus: string; + isActive: boolean; + storeName: string | null; + storeHandle: string | null; + } + >(); + if (contentIds.length === 0) { + return map; + } + + if (contentType === "product") { + const products = await this.prisma.product.findMany({ + where: { id: { in: contentIds } }, + select: { + id: true, + title: true, + name: true, + moderationStatus: true, + isActive: true, + storeProfile: { + select: { businessName: true, storeHandle: true }, + }, + }, + }); + for (const product of products) { + map.set(product.id, { + title: product.title ?? product.name ?? null, + moderationStatus: product.moderationStatus, + isActive: product.isActive, + storeName: product.storeProfile?.businessName ?? null, + storeHandle: product.storeProfile?.storeHandle ?? null, + }); + } + return map; + } + + const posts = await this.prisma.post.findMany({ + where: { id: { in: contentIds } }, + select: { + id: true, + text: true, + moderationStatus: true, + isActive: true, + author: { select: { firstName: true, lastName: true } }, + }, + }); + for (const post of posts) { + const ownerName = + [post.author?.firstName, post.author?.lastName] + .filter(Boolean) + .join(" ") || null; + map.set(post.id, { + title: post.text ?? null, + moderationStatus: post.moderationStatus, + isActive: post.isActive, + storeName: ownerName, + storeHandle: null, + }); + } + return map; + } + + async getModerationItem( + contentType: "post" | "product", + moderationLogId: string, + ) { + const log = await this.prisma.moderationLog.findUnique({ + where: { id: moderationLogId }, + select: { + id: true, + contentType: true, + contentId: true, + imageUrl: true, + decision: true, + confidence: true, + reviewedBy: true, + reviewNote: true, + appealStatus: true, + createdAt: true, + reviewer: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + }, + }, + }, + }); + + if (!log) { + throw new NotFoundException("Moderation log not found"); + } + if (!log.contentType.toLowerCase().includes(contentType)) { + throw new BadRequestException("Moderation log content type mismatch."); + } + + const { confidence, imageUrl, ...rest } = log; + const content = await this.moderationContentDetail( + contentType, + log.contentId, + ); + + return { + ...rest, + evidenceImageUrl: imageUrl, + safeSearch: this.normalizeSafeSearchSummary(confidence), + content, + }; + } + + private async moderationContentDetail( + contentType: "post" | "product", + contentId: string, + ) { + if (contentType === "product") { + const product = await this.prisma.product.findUnique({ + where: { id: contentId }, + select: { + id: true, + title: true, + name: true, + description: true, + shortDescription: true, + productCode: true, + status: true, + moderationStatus: true, + isActive: true, + createdAt: true, + updatedAt: true, + storeProfile: { + select: { + id: true, + businessName: true, + storeHandle: true, + tier: true, + verificationTier: true, + }, + }, + images: { + select: { + id: true, + url: true, + order: true, + isDefault: true, + moderationStatus: true, + }, + orderBy: { order: "asc" }, + }, + }, + }); + if (!product) { + return null; + } + return { + kind: "product" as const, + id: product.id, + title: product.title ?? product.name ?? null, + description: product.description ?? product.shortDescription ?? null, + productCode: product.productCode, + status: product.status, + moderationStatus: product.moderationStatus, + isActive: product.isActive, + createdAt: product.createdAt, + updatedAt: product.updatedAt, + store: product.storeProfile, + owner: null, + images: product.images, + }; + } + + const post = await this.prisma.post.findUnique({ + where: { id: contentId }, + select: { + id: true, + type: true, + text: true, + moderationStatus: true, + isActive: true, + storeId: true, + taggedProductId: true, + createdAt: true, + updatedAt: true, + author: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + images: { + select: { + id: true, + url: true, + order: true, + moderationStatus: true, + }, + orderBy: { order: "asc" }, + }, + }, + }); + if (!post) { + return null; + } + + // Post has no direct store relation — resolve a safe store summary by id. + const store = post.storeId + ? await this.prisma.storeProfile.findUnique({ + where: { id: post.storeId }, + select: { + id: true, + businessName: true, + storeHandle: true, + tier: true, + verificationTier: true, + }, + }) + : null; + + return { + kind: "post" as const, + id: post.id, + title: post.text ?? null, + description: post.text ?? null, + postType: post.type, + moderationStatus: post.moderationStatus, + isActive: post.isActive, + taggedProductId: post.taggedProductId, + createdAt: post.createdAt, + updatedAt: post.updatedAt, + store, + owner: post.author, + images: post.images, + }; + } + + getModerationTimeline( + contentType: "post" | "product", + moderationLogId: string, + query: AdminListQuery = {}, + ) { + return this.resolveModerationTimeline(contentType, moderationLogId, query); + } + + private async resolveModerationTimeline( + contentType: "post" | "product", + moderationLogId: string, + query: AdminListQuery, + ): Promise> { + const log = await this.prisma.moderationLog.findUnique({ + where: { id: moderationLogId }, + select: { id: true, contentId: true, contentType: true }, + }); + if (!log) { + throw new NotFoundException("Moderation log not found"); + } + if (!log.contentType.toLowerCase().includes(contentType)) { + throw new BadRequestException("Moderation log content type mismatch."); + } + return this.getDomainEventTimeline(contentType, log.contentId, query); + } + + async reviewModerationLog( + moderationLogId: string, + contentType: "post" | "product", + decision: "SAFE" | "SENSITIVE" | "BLOCKED", + reason: string, + adminId: string, + securityContext?: RequestSecurityContext, + ) { + const log = await this.prisma.moderationLog.findUnique({ + where: { id: moderationLogId }, + select: { id: true, contentType: true, contentId: true, decision: true }, + }); + + if (!log) { + throw new NotFoundException("Moderation log not found"); + } + + if (!log.contentType.toLowerCase().includes(contentType)) { + throw new BadRequestException("Moderation log content type mismatch."); + } + + const updated = await this.prisma.$transaction(async (tx) => { + const updatedLog = await tx.moderationLog.update({ + where: { id: moderationLogId }, + data: { + decision, + reviewedBy: adminId, + reviewNote: reason, + }, + select: { + id: true, + contentType: true, + contentId: true, + decision: true, + reviewedBy: true, + reviewNote: true, + createdAt: true, + }, + }); + + if (contentType === "post") { + await tx.post.updateMany({ + where: { id: log.contentId }, + data: { + moderationStatus: decision, + isActive: decision !== "BLOCKED", + }, + }); + } else { + await tx.product.updateMany({ + where: { id: log.contentId }, + data: { + moderationStatus: decision, + isActive: decision !== "BLOCKED", + ...(decision === "BLOCKED" ? { status: "HIDDEN" } : {}), + }, + }); + } + + const metadata = { + requestContext: this.requestContextForAudit(securityContext), + reason, + contentType, + contentId: log.contentId, + before: { decision: log.decision }, + after: { decision }, + }; + await tx.auditLog.create({ + data: { + userId: adminId, + action: "MODERATION_REVIEWED", + targetType: "ModerationLog", + targetId: moderationLogId, + metadata, + }, + }); + await this.appendAdminAuditDomainEvent( + tx, + contentType === "post" + ? "POST_MODERATION_DECISION" + : "PRODUCT_MODERATION_DECISION", + adminId, + contentType === "post" ? "Post" : "Product", + log.contentId, + metadata, + `admin:${adminId}:moderation:${moderationLogId}:${decision}`, + ); + + return updatedLog; + }); + + return updated; + } + + async getDeliveries( + query: AdminListQuery = {}, + ): Promise> { + const { page, limit, skip } = this.normalizeListQuery(query); + const createdAt = this.dateRangeFilter(query); + const where: Prisma.DeliveryBookingWhereInput = { + ...(query.status + ? { status: query.status as Prisma.EnumDeliveryStatusFilter } + : {}), + ...(createdAt ? { createdAt } : {}), + ...(query.q + ? { + OR: [ + { partnerRef: { contains: query.q, mode: "insensitive" } }, + { + order: { + orderCode: { contains: query.q, mode: "insensitive" }, + }, + }, + ], + } + : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.deliveryBooking.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: "desc" }, + select: { + id: true, + orderId: true, + method: true, + partnerName: true, + partnerRef: true, + trackingUrl: true, + estimatedCostKobo: true, + actualCostKobo: true, + status: true, + estimatedArrival: true, + pickedUpAt: true, + deliveredAt: true, + createdAt: true, + order: { + select: { + id: true, + orderCode: true, + orderType: true, + status: true, + storeId: true, + }, + }, + }, + }), + this.prisma.deliveryBooking.count({ where }), + ]); + + return { + items: items.map((booking) => ({ + ...booking, + estimatedCostKobo: this.stringifyKobo(booking.estimatedCostKobo), + actualCostKobo: this.stringifyKobo(booking.actualCostKobo), + })), + pagination: this.buildPagination(page, limit, total), + }; + } + + async getReadyForPickupDeliveryQueue( + query: AdminListQuery = {}, + ): Promise> { + const { page, limit, skip } = this.normalizeListQuery(query); + const createdAt = this.dateRangeFilter(query); + const where: Prisma.OrderWhereInput = { + status: OrderStatus.READY_FOR_PICKUP, + deliveryBooking: { is: null }, + ...(createdAt ? { createdAt } : {}), + ...(query.q + ? { + OR: [ + { orderCode: { contains: query.q, mode: "insensitive" } }, + { + storeProfile: { + is: { + OR: [ + { + storeName: { + contains: query.q, + mode: "insensitive", + }, + }, + { + businessName: { + contains: query.q, + mode: "insensitive", + }, + }, + { + storeHandle: { + contains: query.q, + mode: "insensitive", + }, + }, + ], + }, + }, + }, + { + product: { + is: { + OR: [ + { name: { contains: query.q, mode: "insensitive" } }, + { title: { contains: query.q, mode: "insensitive" } }, + { + productCode: { + contains: query.q, + mode: "insensitive", + }, + }, + ], + }, + }, + }, + ], + } + : {}), + }; + + const [orders, total] = await Promise.all([ + this.prisma.order.findMany({ + where, + skip, + take: limit, + orderBy: { updatedAt: "asc" }, + select: { + id: true, + orderCode: true, + orderType: true, + storeId: true, + productId: true, + sourcedProductId: true, + deliveryFeeKobo: true, + status: true, + createdAt: true, + updatedAt: true, + deliveryPrimaryPhone: true, + deliverySecondaryPhone: true, + deliveryAddressSnapshot: true, + pickupAddressSnapshot: true, + pickupStoreId: true, + sourceStoreId: true, + pickupZone: true, + deliveryZone: true, + storeProfile: { + select: { + id: true, + storeName: true, + businessName: true, + storeHandle: true, + logoUrl: true, + }, + }, + product: { + select: { + id: true, + productCode: true, + name: true, + title: true, + imageUrl: true, + }, + }, + sourcedProduct: { + select: { + id: true, + customTitle: true, + sellingPriceKobo: true, + physicalStore: { + select: { + id: true, + storeName: true, + businessName: true, + storeHandle: true, + }, + }, + physicalProduct: { + select: { + id: true, + productCode: true, + name: true, + title: true, + imageUrl: true, + }, + }, + }, + }, + }, + }), + this.prisma.order.count({ where }), + ]); + + return { + items: orders.map((order) => ({ + ...order, + deliveryFeeKobo: this.stringifyKobo(order.deliveryFeeKobo), + sourcedProduct: order.sourcedProduct + ? { + ...order.sourcedProduct, + sellingPriceKobo: this.stringifyKobo( + order.sourcedProduct.sellingPriceKobo, + ), + } + : null, + })), + pagination: this.buildPagination(page, limit, total), + }; + } + + async createManualDeliveryBooking( + orderId: string, + adminId: string, + securityContext?: RequestSecurityContext, + ) { + try { + const booking = await this.prisma.$transaction(async (tx) => { + const order = await tx.order.findUnique({ + where: { id: orderId }, + select: { + id: true, + orderCode: true, + status: true, + deliveryFeeKobo: true, + deliveryAddress: true, + deliveryPrimaryPhone: true, + deliverySecondaryPhone: true, + deliveryAddressSnapshot: true, + pickupAddressSnapshot: true, + deliveryBooking: { + select: { + id: true, + }, + }, + }, + }); + + if (!order) { + throw new NotFoundException("Order not found"); + } + + if (order.status !== OrderStatus.READY_FOR_PICKUP) { + throw new BadRequestException({ + message: + "Order must be ready for pickup before an internal delivery booking can be created.", + code: "ORDER_NOT_READY_FOR_PICKUP", + }); + } + + if (order.deliveryBooking) { + throw new ConflictException({ + message: "Delivery booking already exists for this order.", + code: "DELIVERY_BOOKING_ALREADY_EXISTS", + }); + } + + const pickupAddress = this.formatAddressSnapshot( + order.pickupAddressSnapshot, + ); + const deliveryAddress = this.formatAddressSnapshot( + order.deliveryAddressSnapshot, + order.deliveryAddress, + ); + + if (!pickupAddress || !deliveryAddress || !order.deliveryPrimaryPhone) { + throw new BadRequestException({ + message: + "Order is missing pickup, delivery, or primary delivery contact snapshots.", + code: "DELIVERY_BOOKING_SNAPSHOT_INCOMPLETE", + }); + } + + const created = await tx.deliveryBooking.create({ + data: { + orderId: order.id, + method: DeliveryMethod.PLATFORM_LOGISTICS, + partnerName: "twizrr internal", + pickupAddress, + deliveryAddress, + estimatedCostKobo: order.deliveryFeeKobo, + status: DeliveryStatus.PENDING, + }, + }); + + const metadata = { + requestContext: this.requestContextForAudit(securityContext), + orderCode: order.orderCode, + status: DeliveryStatus.PENDING, + method: DeliveryMethod.PLATFORM_LOGISTICS, + action: "manual_delivery_booking_created", + }; + + await tx.auditLog.create({ + data: { + userId: adminId, + action: "DELIVERY_BOOKING_CREATED", + targetType: "DeliveryBooking", + targetId: created.id, + metadata, + }, + }); + await this.appendAdminAuditDomainEvent( + tx, + "DELIVERY_BOOKING_CREATED", + adminId, + "DeliveryBooking", + created.id, + metadata, + `admin:${adminId}:delivery-booking:${order.id}`, + ); + + return created; + }); + + return { + ...booking, + estimatedCostKobo: this.stringifyKobo(booking.estimatedCostKobo), + actualCostKobo: this.stringifyKobo(booking.actualCostKobo), + }; + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + throw new ConflictException({ + message: "Delivery booking already exists for this order.", + code: "DELIVERY_BOOKING_ALREADY_EXISTS", + }); + } + throw error; + } + } + + async bookDeliveryWithShipbubble( + bookingId: string, + adminId: string, + input: AdminShipbubbleBookingInput, + securityContext?: RequestSecurityContext, + ) { + const booking = await this.prisma.deliveryBooking.findUnique({ + where: { id: bookingId }, + select: { + id: true, + orderId: true, + status: true, + order: { + select: { + id: true, + orderCode: true, + status: true, + totalAmountKobo: true, + deliveryFeeKobo: true, + quantity: true, + deliveryAddress: true, + deliveryPrimaryPhone: true, + deliverySecondaryPhone: true, + deliveryAddressSnapshot: true, + pickupAddressSnapshot: true, + product: { + select: { + name: true, + title: true, + retailPriceKobo: true, + weightKg: true, + }, + }, + sourcedProduct: { + select: { + customTitle: true, + sellingPriceKobo: true, + physicalProduct: { + select: { + name: true, + title: true, + retailPriceKobo: true, + weightKg: true, + }, + }, + physicalStore: { + select: { + storeName: true, + businessName: true, + user: { + select: { + email: true, + phone: true, + displayName: true, + firstName: true, + lastName: true, + }, + }, + }, + }, + }, + }, + storeProfile: { + select: { + storeName: true, + businessName: true, + user: { + select: { + email: true, + phone: true, + displayName: true, + firstName: true, + lastName: true, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!booking) { + throw new NotFoundException("Delivery booking not found"); + } + + if (booking.status !== DeliveryStatus.PENDING) { + throw new ConflictException({ + message: + "Delivery booking must be pending before booking with Shipbubble.", + code: "DELIVERY_BOOKING_NOT_PENDING", + }); + } + + if (!booking.order) { + throw new NotFoundException("Order not found for delivery booking"); + } + + if (booking.order.status !== OrderStatus.READY_FOR_PICKUP) { + throw new BadRequestException({ + message: + "Order must be ready for pickup before booking with Shipbubble.", + code: "ORDER_NOT_READY_FOR_PICKUP", + }); + } + + const pickupAddress = this.buildShipbubblePickupAddress(booking.order); + const deliveryAddress = this.buildShipbubbleDeliveryAddress(booking.order); + const parcel = this.buildShipbubbleParcel(booking.order, input); + + const claim = await this.prisma.deliveryBooking.updateMany({ + where: { + id: bookingId, + status: DeliveryStatus.PENDING, + }, + data: { + partnerName: "Shipbubble", + status: DeliveryStatus.PICKUP_SCHEDULED, + }, + }); + if (claim.count === 0) { + throw new ConflictException({ + message: + "Delivery booking has already been updated. Refresh and try again.", + code: "DELIVERY_BOOKING_NOT_PENDING", + }); + } + + let providerBooking; + try { + providerBooking = await this.shippingProvider.createBooking({ + idempotencyKey: `admin-delivery-booking:${bookingId}`, + quoteId: input.rateId, + bookingToken: input.requestToken, + serviceCode: input.serviceCode, + pickup: pickupAddress, + dropoff: deliveryAddress, + parcel, + }); + } catch (error) { + await this.releaseShipbubbleBookingClaim(bookingId); + this.logger.error( + `Shipbubble booking failed for delivery booking ${bookingId}: ${ + error instanceof Error ? error.message : String(error) + }`, + error instanceof Error ? error.stack : undefined, + ); + throw new BadGatewayException({ + message: "Shipbubble booking failed. Manual booking remains available.", + code: "SHIPBUBBLE_BOOKING_FAILED", + }); + } + + if (!providerBooking.providerBookingId) { + await this.releaseShipbubbleBookingClaim(bookingId); + throw new BadGatewayException({ + message: "Shipbubble booking failed. Manual booking remains available.", + code: "SHIPBUBBLE_BOOKING_FAILED", + }); + } + + const updated = await this.prisma.$transaction(async (tx) => { + const current = await tx.deliveryBooking.findUnique({ + where: { id: bookingId }, + select: { + id: true, + orderId: true, + status: true, + partnerRef: true, + order: { + select: { + id: true, + orderCode: true, + status: true, + }, + }, + }, + }); + + if (!current) { + throw new NotFoundException("Delivery booking not found"); + } + + if ( + current.status !== DeliveryStatus.PICKUP_SCHEDULED || + current.partnerRef + ) { + throw new ConflictException({ + message: + "Delivery booking has already been updated. Refresh and try again.", + code: "DELIVERY_BOOKING_NOT_PENDING", + }); + } + + if (current.order.status !== OrderStatus.READY_FOR_PICKUP) { + throw new BadRequestException({ + message: + "Order must be ready for pickup before booking with Shipbubble.", + code: "ORDER_NOT_READY_FOR_PICKUP", + }); + } + + const changed = await tx.deliveryBooking.update({ + where: { id: bookingId }, + data: { + partnerName: "Shipbubble", + partnerRef: providerBooking.providerBookingId, + trackingUrl: providerBooking.trackingUrl ?? null, + actualCostKobo: providerBooking.feeKobo ?? null, + estimatedArrival: this.toOptionalDate( + providerBooking.estimatedDeliveryDate, + ), + status: DeliveryStatus.PICKUP_SCHEDULED, + }, + }); + + const metadata = { + requestContext: this.requestContextForAudit(securityContext), + action: "shipbubble_delivery_booking_created", + orderId: current.orderId, + orderCode: current.order.orderCode, + bookingId, + partnerName: "Shipbubble", + partnerRef: providerBooking.providerBookingId, + trackingId: providerBooking.providerTrackingId || null, + status: DeliveryStatus.PICKUP_SCHEDULED, + }; + + await tx.auditLog.create({ + data: { + userId: adminId, + action: "DELIVERY_BOOKING_PROVIDER_BOOKED", + targetType: "DeliveryBooking", + targetId: bookingId, + metadata, + }, + }); + await this.appendAdminAuditDomainEvent( + tx, + "DELIVERY_BOOKING_PROVIDER_BOOKED", + adminId, + "DeliveryBooking", + bookingId, + metadata, + `admin:${adminId}:shipbubble-booking:${bookingId}`, + ); + + return changed; + }); + + return { + ...updated, + estimatedCostKobo: this.stringifyKobo(updated.estimatedCostKobo), + actualCostKobo: this.stringifyKobo(updated.actualCostKobo), + }; + } + + private async releaseShipbubbleBookingClaim(bookingId: string) { + try { + await this.prisma.deliveryBooking.updateMany({ + where: { + id: bookingId, + status: DeliveryStatus.PICKUP_SCHEDULED, + partnerRef: null, + }, + data: { + partnerName: "twizrr internal", + status: DeliveryStatus.PENDING, + }, + }); + } catch (error) { + this.logger.error( + `Failed to release Shipbubble booking claim for ${bookingId}: ${ + error instanceof Error ? error.message : String(error) + }`, + error instanceof Error ? error.stack : undefined, + ); + } + } + + async getShipbubbleDeliveryRates( + bookingId: string, + adminId: string, + ): Promise { + const booking = await this.prisma.deliveryBooking.findUnique({ + where: { id: bookingId }, + select: { + id: true, + orderId: true, + status: true, + order: { + select: { + id: true, + orderCode: true, + status: true, + totalAmountKobo: true, + deliveryFeeKobo: true, + quantity: true, + deliveryAddress: true, + deliveryPrimaryPhone: true, + deliverySecondaryPhone: true, + deliveryAddressSnapshot: true, + pickupAddressSnapshot: true, + product: { + select: { + name: true, + title: true, + retailPriceKobo: true, + weightKg: true, + }, + }, + sourcedProduct: { + select: { + customTitle: true, + sellingPriceKobo: true, + physicalProduct: { + select: { + name: true, + title: true, + retailPriceKobo: true, + weightKg: true, + }, + }, + physicalStore: { + select: { + storeName: true, + businessName: true, + user: { + select: { + email: true, + phone: true, + displayName: true, + firstName: true, + lastName: true, + }, + }, + }, + }, + }, + }, + storeProfile: { + select: { + storeName: true, + businessName: true, + user: { + select: { + email: true, + phone: true, + displayName: true, + firstName: true, + lastName: true, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!booking) { + throw new NotFoundException("Delivery booking not found"); + } + + if (booking.status !== DeliveryStatus.PENDING) { + throw new ConflictException({ + message: + "Delivery booking must be pending before fetching Shipbubble rates.", + code: "DELIVERY_BOOKING_NOT_PENDING", + }); + } + + if (!booking.order) { + throw new NotFoundException("Order not found for delivery booking"); + } + + if (booking.order.status !== OrderStatus.READY_FOR_PICKUP) { + throw new BadRequestException({ + message: + "Order must be ready for pickup before fetching Shipbubble rates.", + code: "ORDER_NOT_READY_FOR_PICKUP", + }); + } + + const pickupAddress = this.buildShipbubblePickupAddress(booking.order); + const deliveryAddress = this.buildShipbubbleDeliveryAddress(booking.order); + const parcel = this.buildShipbubbleParcel(booking.order, {}); + + let rates: ShippingQuoteOption[]; + try { + rates = await this.shippingProvider.getQuotes( + pickupAddress, + deliveryAddress, + parcel, + ); + } catch (error) { + this.logger.error( + `Shipbubble rate lookup failed for delivery booking ${bookingId} by admin ${adminId}: ${ + error instanceof Error ? error.message : String(error) + }`, + error instanceof Error ? error.stack : undefined, + ); + throw new BadGatewayException({ + message: + "Shipbubble rates could not be fetched. Manual booking remains available.", + code: "SHIPBUBBLE_RATES_FAILED", + }); + } + + return { + provider: "Shipbubble", + bookingId: booking.id, + orderId: booking.orderId, + orderCode: booking.order.orderCode, + requestToken: + rates.find((rate) => rate.bookingToken)?.bookingToken ?? null, + rates: rates.map((rate) => ({ + rateId: rate.quoteId, + courierName: rate.serviceName, + serviceCode: rate.serviceCode ?? null, + amountKobo: rate.amountKobo.toString(), + currency: "NGN", + estimatedDeliveryDate: rate.estimatedDeliveryDate ?? null, + estimatedDeliveryWindow: rate.estimatedDeliveryWindow ?? null, + })), + }; + } + + async startDelivery( + orderId: string, + adminId: string, + securityContext?: RequestSecurityContext, + ) { + const result = await this.orderService.startDeliveryByAdmin( + orderId, + adminId, + ); + + try { + await this.auditLog.log( + adminId, + "ADMIN_START_DELIVERY", + "Order", + orderId, + { + requestContext: this.requestContextForAudit(securityContext), + action: "admin_start_delivery", + reason: "", + }, + ); + } catch (error) { + this.logger.error( + `Failed to audit admin start-delivery action for order ${orderId}: ${ + error instanceof Error ? error.message : String(error) + }`, + error instanceof Error ? error.stack : undefined, + ); + } + + return result; + } + + async getDeliveryById(deliveryId: string) { + const booking = await this.prisma.deliveryBooking.findUnique({ + where: { id: deliveryId }, + select: { + id: true, + orderId: true, + method: true, + partnerName: true, + partnerRef: true, + trackingUrl: true, + pickupAddress: true, + deliveryAddress: true, + estimatedCostKobo: true, + actualCostKobo: true, + status: true, + estimatedArrival: true, + pickedUpAt: true, + deliveredAt: true, + createdAt: true, + order: { + select: { + id: true, + orderCode: true, + orderType: true, + linkedOrderId: true, + status: true, + payoutStatus: true, + disputeStatus: true, + storeId: true, + // Coordination address is kept (existing admin delivery policy), but + // the opaque deliveryDetails JSON blob is NOT selected — it can carry + // arbitrary fields; delivery contact is exposed via explicit, + // reviewed phone columns instead. deliveryOtp is never selected. + deliveryAddress: true, + deliveryPrimaryPhone: true, + deliverySecondaryPhone: true, + storeProfile: { + select: { + id: true, + businessName: true, + storeHandle: true, + tier: true, + verificationTier: true, + }, + }, + }, + }, + }, + }); + + if (!booking) { + throw new NotFoundException("Delivery booking not found"); + } + + return { + ...booking, + estimatedCostKobo: this.stringifyKobo(booking.estimatedCostKobo), + actualCostKobo: this.stringifyKobo(booking.actualCostKobo), + }; + } + + async verifyMerchant( + storeId: string, + adminId: string, + securityContext?: RequestSecurityContext, + ) { + const merchant = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + include: { + verificationRequests: { + where: { status: "PENDING" }, + orderBy: { createdAt: "desc" }, + take: 1, + }, + }, + }); + + if (!merchant) { + throw new NotFoundException("Store profile not found"); + } + + const pendingRequest = merchant.verificationRequests[0]; + if (!pendingRequest) { + throw new NotFoundException("Pending verification request not found"); + } + + const result = await this.verificationService.reviewRequest( + pendingRequest.id, + adminId, + { + decision: "APPROVED", + }, + ); + + await this.auditLog.log( + adminId, + "VERIFY_STORE", + "VerificationRequest", + pendingRequest.id, + { + requestContext: this.requestContextForAudit(securityContext), + action: "admin_verify_store", + storeId, + targetTier: pendingRequest.targetTier, + }, + ); + + return result; + } + + /** + * Admin bypass: directly set a store's tier without a pending verification + * request. Keeps the public tier and KYB verification tier in sync, and for + * TIER_2 marks NIN as manually verified so the store reads as a fully + * verified business. SUPER_ADMIN only; every change is written to AuditLog. + */ + async setStoreTier( + storeId: string, + tier: StoreTier, + adminId: string, + reason?: string, + securityContext?: RequestSecurityContext, + ) { + const store = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + select: { id: true, tier: true, verificationTier: true }, + }); + + if (!store) { + throw new NotFoundException("Store profile not found"); + } + + const verificationTier = + tier === StoreTier.TIER_2 + ? VerificationTier.TIER_2 + : tier === StoreTier.TIER_1 + ? VerificationTier.TIER_1 + : VerificationTier.UNVERIFIED; + + await this.prisma.storeProfile.update({ + where: { id: storeId }, + data: { + tier, + verificationTier, + tierUpgradedAt: tier === StoreTier.TIER_0 ? null : new Date(), + ...(tier === StoreTier.TIER_2 + ? { + ninVerified: true, + ninVerifiedAt: new Date(), + ninVerifiedVia: "MANUAL", + } + : {}), + }, + }); + + await this.auditLog.log( + adminId, + "SET_STORE_TIER", + "StoreProfile", + storeId, + { + requestContext: this.requestContextForAudit(securityContext), + action: "admin_set_store_tier", + before: store.tier, + after: tier, + reason: reason ?? null, + }, + ); + + return this.getStoreById(storeId); + } + + async rejectMerchant( + storeId: string, + reason?: string, + adminId?: string, + securityContext?: RequestSecurityContext, + ) { + const merchant = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + include: { + verificationRequests: { + where: { status: "PENDING" }, + orderBy: { createdAt: "desc" }, + take: 1, + }, + }, + }); + + if (!merchant) { + throw new NotFoundException("Store profile not found"); + } + + const pendingRequest = merchant.verificationRequests[0]; + if (pendingRequest && adminId) { + const rejectionReason = reason?.trim(); + if (!rejectionReason) { + throw new BadRequestException( + "Rejection reason is required when rejecting a verification request", + ); + } + + const result = await this.verificationService.reviewRequest( + pendingRequest.id, + adminId, + { + decision: "REJECTED", + rejectionReason, + }, + ); + + await this.auditLog.log( + adminId, + "REJECT_STORE_VERIFICATION", + "VerificationRequest", + pendingRequest.id, + { + requestContext: this.requestContextForAudit(securityContext), + action: "admin_reject_store_verification", + storeId, + reason: rejectionReason, + targetTier: pendingRequest.targetTier, + }, + ); + + return result; + } + + const updated = await this.prisma.storeProfile.update({ + where: { id: storeId }, + data: { updatedAt: new Date() }, + }); + + await this.notifications.triggerMerchantRejected(merchant.userId, reason); + + if (adminId) { + await this.auditLog.log( + adminId, + "REJECT_STORE", + "StoreProfile", + storeId, + { + reason, + requestContext: this.requestContextForAudit(securityContext), + }, + ); + } + + return updated; + } + + async getPendingMerchants() { + // Fetch all merchants who have at least one verification request + const merchants = await this.prisma.storeProfile.findMany({ + where: { + verificationRequests: { + some: {}, + }, + }, + include: { + user: { + select: { + email: true, + firstName: true, + lastName: true, + phone: true, + }, + }, + verificationRequests: { + orderBy: { createdAt: "desc" }, + take: 1, + }, + }, + orderBy: { + createdAt: "asc", + }, + }); + + // Filter to only merchants whose LATEST request is PENDING or REJECTED + return merchants.filter((m) => { + const latest = m.verificationRequests[0]; + return ( + latest && (latest.status === "PENDING" || latest.status === "REJECTED") + ); + }); + } + + async getAllOrders() { + const orders = await this.prisma.order.findMany({ + include: { + storeProfile: { + select: { businessName: true }, + }, + user: { + select: { firstName: true, lastName: true, email: true }, + }, + product: { select: { name: true, unit: true } }, + }, + orderBy: { createdAt: "desc" }, + }); + + orders.forEach((order) => { + order.deliveryOtp = null; + }); + + return orders; + } + + async forceResolveOrder( + orderId: string, + status: OrderStatus, + adminUserId: string, + ) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + }); + + if (!order) { + throw new NotFoundException("Order not found"); + } + + return this.prisma.$transaction(async (tx) => { + const updatedOrder = await tx.order.update({ + where: { id: orderId }, + data: { status }, + }); + + await tx.orderEvent.create({ + data: { + orderId, + fromStatus: order.status, + toStatus: status, + triggeredBy: adminUserId, + metadata: { + description: "Order manually overridden by System Administrator", + }, + }, + }); + + return updatedOrder; + }); + } + + /** + * Mark a paid order's item as inspected & approved by twizrr. Sets + * verifiedAt, which surfaces to the store as the "Verified by twizrr" step — + * the point the store is cleared for the order (transit risk is twizrr's from + * there). Idempotent: re-verifying a verified order is a no-op. Rejected for + * unpaid, cancelled or refunding orders where verification is meaningless. + */ + async markOrderVerified(orderId: string, adminUserId: string) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + }); + if (!order) { + throw new NotFoundException("Order not found"); + } + if (order.verifiedAt) { + return order; + } + // Authoritative allow-list of verifiable states — a paid order somewhere on + // the fulfilment path. Kept in sync with the admin UI's VERIFIABLE_STATUSES + // so a direct API call can't verify ineligible states (DISPUTE, COMPLETED, + // unpaid, cancelled, refunding). + if (!VERIFIABLE_ORDER_STATUSES.has(order.status)) { + throw new BadRequestException({ + message: "This order can't be verified in its current state", + code: "ORDER_NOT_VERIFIABLE", + }); + } + + return this.prisma.$transaction(async (tx) => { + const updatedOrder = await tx.order.update({ + where: { id: orderId }, + data: { verifiedAt: new Date() }, + }); + + await tx.orderEvent.create({ + data: { + orderId, + fromStatus: order.status, + toStatus: order.status, + triggeredBy: adminUserId, + metadata: { + description: "Item verified by twizrr — passed inspection", + }, + }, + }); + + return updatedOrder; + }); + } + + // ─── Dispute Resolution Center ─── + + async resolveDispute( + orderId: string, + decision: "SHOPPER" | "STORE", + adminUserId: string, + notes?: string, + securityContext?: RequestSecurityContext, + ) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + include: { + product: true, + storeProfile: true, + }, + }); + + if (!order) { + throw new NotFoundException("Order not found"); + } + + if (order.status !== OrderStatus.DISPUTE) { + throw new BadRequestException( + `Order is in ${order.status} status. Only orders in DISPUTE status can be resolved.`, + ); + } + + if (order.disputeStatus !== OrderDisputeStatus.PENDING) { + throw new BadRequestException( + `Dispute has already been resolved (status: ${order.disputeStatus}).`, + ); + } + + if (decision === "SHOPPER") { + // ── Buyer wins: mark order REFUND_PENDING, release inventory ── + const updatedOrder = await this.prisma.$transaction(async (tx) => { + // 1. Atomic status check & update + const updateResult = await tx.order.updateMany({ + where: { + id: orderId, + status: OrderStatus.DISPUTE, + disputeStatus: OrderDisputeStatus.PENDING, + }, + data: { + status: OrderStatus.REFUND_PENDING, + disputeStatus: OrderDisputeStatus.RESOLVED_SHOPPER, + payoutStatus: "CANCELLED", + }, + }); + + if (updateResult.count === 0) { + throw new BadRequestException( + "Order is not in DISPUTE status or has already been resolved.", + ); + } + + // Fetch the order for subsequent steps (needed for inventory release data) + const freshOrder = await tx.order.findUnique({ + where: { id: orderId }, + }); + if (!freshOrder) throw new NotFoundException("Order no longer exists"); + + await tx.orderEvent.create({ + data: { + orderId, + fromStatus: OrderStatus.DISPUTE, + toStatus: OrderStatus.REFUND_PENDING, + triggeredBy: adminUserId, + metadata: { + action: "dispute_resolved_shopper", + notes: notes || "Dispute resolved in favour of shopper", + }, + }, + }); + + const metadata = { + requestContext: this.requestContextForAudit(securityContext), + decision, + notes, + result: "REFUND_BUYER", + previousStatus: OrderStatus.DISPUTE, + nextStatus: OrderStatus.REFUND_PENDING, + }; + await tx.auditLog.create({ + data: { + userId: adminUserId, + action: "RESOLVE_DISPUTE_SHOPPER", + targetType: "Order", + targetId: orderId, + metadata, + }, + }); + await this.appendAdminAuditDomainEvent( + tx, + "DISPUTE_RESOLVED_BY_ADMIN", + adminUserId, + "Order", + orderId, + metadata, + `admin:${adminUserId}:dispute:${orderId}:shopper`, + ); + + // Release reserved inventory back to the merchant + const storeId = order.storeId; + if (!storeId) + throw new BadRequestException("Store ID not found on order"); + + if (freshOrder.productId && freshOrder.quantity) { + // 1. Record event + await tx.inventoryEvent.create({ + data: { + productId: freshOrder.productId, + storeId, + eventType: InventoryEventType.ORDER_RELEASED, + quantity: freshOrder.quantity, + referenceId: orderId, + notes: `Dispute resolved in favor of shopper (Order: ${orderId})`, + }, + }); + + // 2. Update stock cache atomically + const updateResult = await tx.productStockCache.updateMany({ + where: { productId: freshOrder.productId, variantId: null }, + data: { + stock: { increment: freshOrder.quantity }, + quantity: { increment: freshOrder.quantity }, + }, + }); + + if (updateResult.count === 0) { + await tx.productStockCache.create({ + data: { + productId: freshOrder.productId, + variantId: null, + stock: freshOrder.quantity, + quantity: freshOrder.quantity, + }, + }); + } + } else if (freshOrder.items) { + const items = Array.isArray(freshOrder.items) ? freshOrder.items : []; + for (const item of items) { + if ( + item && + typeof item === "object" && + "productId" in item && + "quantity" in item && + typeof item.productId === "string" && + item.productId + ) { + const productId = item.productId; + const quantity = Number(item.quantity); + + if (isNaN(quantity)) continue; + + // 1. Record event + await tx.inventoryEvent.create({ + data: { + productId: productId, + storeId, + eventType: InventoryEventType.ORDER_RELEASED, + quantity: quantity, + referenceId: orderId, + notes: `Dispute resolved in favor of shopper (Order: ${orderId})`, + }, + }); + + // 2. Update stock cache atomically + const updateResult = await tx.productStockCache.updateMany({ + where: { productId, variantId: null }, + data: { + stock: { increment: quantity }, + quantity: { increment: quantity }, + }, + }); + + if (updateResult.count === 0) { + await tx.productStockCache.create({ + data: { + productId, + variantId: null, + stock: quantity, + quantity, + }, + }); + } + } + } + } + + return freshOrder; + }); + // Notify both parties (best-effort) + try { + await this.notifications.triggerDisputeResolved( + order.buyerId, + order.storeId as string, + orderId, + "SHOPPER", + ); + } catch (err) { + this.logger.error( + `Failed to notify dispute resolution (shopper wins): ${err instanceof Error ? err.message : err}`, + ); + } + + this.logger.log( + `Dispute on order ${orderId} resolved in favour of SHOPPER by admin ${adminUserId}`, + ); + return updatedOrder; + } else { + // ── Merchant wins: complete the order and trigger payout ── + const updatedOrder = await this.prisma.$transaction(async (tx) => { + // 1. Atomic status check & update + const updateResult = await tx.order.updateMany({ + where: { + id: orderId, + status: OrderStatus.DISPUTE, + disputeStatus: OrderDisputeStatus.PENDING, + }, + data: { + status: OrderStatus.COMPLETED, + disputeStatus: OrderDisputeStatus.RESOLVED_STORE, + payoutStatus: "PENDING", + }, + }); + + if (updateResult.count === 0) { + throw new BadRequestException( + "Order is not in DISPUTE status or has already been resolved.", + ); + } + + await tx.orderEvent.create({ + data: { + orderId, + fromStatus: OrderStatus.DISPUTE, + toStatus: OrderStatus.COMPLETED, + triggeredBy: adminUserId, + metadata: { + action: "dispute_resolved_store", + notes: notes || "Dispute resolved in favour of store", + }, + }, + }); + + const metadata = { + requestContext: this.requestContextForAudit(securityContext), + decision, + notes, + result: "RELEASE_TO_STORE", + previousStatus: OrderStatus.DISPUTE, + nextStatus: OrderStatus.COMPLETED, + }; + await tx.auditLog.create({ + data: { + userId: adminUserId, + action: "RESOLVE_DISPUTE_STORE", + targetType: "Order", + targetId: orderId, + metadata, + }, + }); + await this.appendAdminAuditDomainEvent( + tx, + "DISPUTE_RESOLVED_BY_ADMIN", + adminUserId, + "Order", + orderId, + metadata, + `admin:${adminUserId}:dispute:${orderId}:store`, + ); + + // Fetch the order for subsequent steps + const freshOrder = await tx.order.findUnique({ + where: { id: orderId }, + }); + if (!freshOrder) throw new NotFoundException("Order no longer exists"); + + return freshOrder; + }); + + try { + await this.payoutQueue.add( + "process-payout", + { orderId }, + { jobId: `payout-dispute-${orderId}` }, + ); + this.logger.log( + `Payout queued for dispute-resolved order ${orderId} (store wins)`, + ); + } catch (err) { + const msg = `CRITICAL: Failed to queue payout for COMPLETED dispute ${orderId}: ${err instanceof Error ? err.message : err}`; + this.logger.error(msg); + } + // Notify both parties (best-effort) + try { + await this.notifications.triggerDisputeResolved( + order.buyerId, + order.storeId as string, + orderId, + "STORE", + ); + } catch (err) { + this.logger.error( + `Failed to notify dispute resolution (store wins): ${err instanceof Error ? err.message : err}`, + ); + } + + this.logger.log( + `Dispute on order ${orderId} resolved in favour of STORE by admin ${adminUserId}`, + ); + return updatedOrder; + } + } + + async deleteProduct(productId: string) { + const product = await this.prisma.product.findUnique({ + where: { id: productId }, + }); + + if (!product) { + throw new NotFoundException("Product not found in the Catalogue"); + } + + return this.prisma.product.update({ + where: { id: productId }, + data: { + isActive: false, + deletedAt: new Date(), + }, + }); + } + + async getAllProducts() { + return this.prisma.product.findMany({ + include: { + storeProfile: { + select: { businessName: true }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + } + + async getGlobalAnalytics() { + const [totalOrders] = await Promise.all([this.prisma.order.count()]); + + // Financial calculations + const gmvAggregate = await this.prisma.order.aggregate({ + where: { + status: { in: ["PAID", "DISPATCHED", "DELIVERED"] }, + }, + _sum: { totalAmountKobo: true }, + }); + + const escrowAggregate = await this.prisma.order.aggregate({ + where: { + status: { in: ["PAID", "DISPATCHED"] }, + }, + _sum: { totalAmountKobo: true }, + }); + + // Market Intelligence: Top Requested Categories via Orders + const topCategoriesRaw = await this.prisma.order.findMany({ + select: { + product: { + select: { categoryTag: true }, + }, + }, + }); + + // Reduce into category counts + const categoryCounts: Record = {}; + topCategoriesRaw.forEach((order) => { + const tag = order.product?.categoryTag || "Unknown"; + categoryCounts[tag] = (categoryCounts[tag] || 0) + 1; + }); + + const topCategories = Object.entries(categoryCounts) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([name, value]) => ({ name, value })); + + return { + funnel: { + totalOrders, + }, + financials: { + gmvKobo: Number(gmvAggregate._sum.totalAmountKobo || 0), + escrowLiabilityKobo: Number(escrowAggregate._sum.totalAmountKobo || 0), + }, + market: { + topCategories, + }, + }; + } + + async getSystemAlerts() { + const alerts = []; + + // Check 1: Stuck Escrow + const fortyEightHoursAgo = new Date(Date.now() - 48 * 60 * 60 * 1000); + const stuckOrders = await this.prisma.order.count({ + where: { + status: OrderStatus.PAID, + updatedAt: { lt: fortyEightHoursAgo }, + }, + }); + + if (stuckOrders > 0) { + alerts.push({ + id: "escrow-timeout", + type: "CRITICAL", + title: "Escrow Timeout Warning", + message: `${stuckOrders} orders have been stuck in PAID for over 48 hours without dispatch.`, + actionLink: "/admin/orders?filter=stuck", + }); + } + + // Check 2: Verification Choke + const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const chokeSize = await this.prisma.storeProfile.count({ + where: { + verificationRequests: { + some: { + status: "PENDING", + createdAt: { lt: twentyFourHoursAgo }, + }, + }, + }, + }); + + if (chokeSize > 0) { + alerts.push({ + id: "verification-choke", + type: "WARNING", + title: "Verification Queue Bloat", + message: `${chokeSize} merchants have been waiting > 24 hours for approval.`, + actionLink: "/admin/merchants", + }); + } + + // Check 3: Pending Payout Requests + const pendingPayouts = await this.prisma.payoutRequest.count({ + where: { + status: "PENDING", + createdAt: { lt: twentyFourHoursAgo }, + }, + }); + + if (pendingPayouts > 0) { + alerts.push({ + id: "payout-delay", + type: "WARNING", + title: "Delayed Payout Processing", + message: `${pendingPayouts} payout requests have been waiting > 24 hours for processing.`, + actionLink: "/admin/payouts", + }); + } + + // Check 4: Unresolved Disputes + const unresolvedDisputes = await this.prisma.order.count({ + where: { + status: OrderStatus.DISPUTE, + updatedAt: { lt: twentyFourHoursAgo }, + }, + }); + + if (unresolvedDisputes > 0) { + alerts.push({ + id: "unresolved-disputes", + type: "CRITICAL", + title: "Unresolved Disputes", + message: `${unresolvedDisputes} disputed orders have been waiting > 24 hours for admin resolution.`, + actionLink: "/admin/orders?filter=dispute", + }); + } + + // Check 5: Failed Payouts + const failedPayouts = await this.prisma.payout.count({ + where: { + status: "FAILED", + }, + }); + + if (failedPayouts > 0) { + alerts.push({ + id: "failed-payouts", + type: "CRITICAL", + title: "Failed Paystack Payouts", + message: `${failedPayouts} payouts have failed and require manual review or retry.`, + actionLink: "/admin/payouts?filter=failed", + }); + } + + // Check 6: Stuck PENDING Payouts + const sixHoursAgo = new Date(Date.now() - 6 * 60 * 60 * 1000); + const stuckPayouts = await this.prisma.order.count({ + where: { + payoutStatus: "PENDING", + status: OrderStatus.COMPLETED, + updatedAt: { lt: sixHoursAgo }, + }, + }); + + if (stuckPayouts > 0) { + alerts.push({ + id: "stuck-payouts", + type: "WARNING", + title: "Stuck Pending Payouts", + message: `${stuckPayouts} completed orders have been in PENDING payout status for > 6 hours.`, + actionLink: "/admin/orders?filter=payout_pending", + }); + } + + return alerts; + } + + async exportOrders(startDate?: string, endDate?: string) { + const whereClause: any = { status: OrderStatus.DELIVERED }; + + if (startDate || endDate) { + whereClause.createdAt = {}; + if (startDate) whereClause.createdAt.gte = new Date(startDate); + if (endDate) whereClause.createdAt.lte = new Date(endDate); + } + + const orders = await this.prisma.order.findMany({ + where: whereClause, + include: { + storeProfile: { select: { businessName: true } }, + user: { select: { firstName: true, lastName: true } }, + }, + orderBy: { createdAt: "desc" }, + }); + + // Generate CSV String + const headers = [ + "Order ID", + "Date", + "Store", + "Buyer", + "Total Kobo", + "Delivery Kobo", + "Status", + ]; + const rows = orders.map((o) => [ + o.id, + o.createdAt.toISOString(), + `"${o.storeProfile?.businessName}"`, + `"${[o.user?.firstName, o.user?.lastName].filter(Boolean).join(" ")}"`, + o.totalAmountKobo.toString(), + o.deliveryFeeKobo.toString(), + o.status, + ]); + + const csvContent = [headers.join(",")] + .concat(rows.map((row) => row.join(","))) + .join("\n"); + + return csvContent; + } + + async broadcastMessage(message: string) { + const verifiedMerchants = await this.prisma.storeProfile.findMany({ + where: { + verificationTier: { + in: [VerificationTier.TIER_2], + }, + }, + include: { user: true }, + }); + + const phoneNumbers = verifiedMerchants + .map((m) => m.user?.phone) + .filter((phone): phone is string => !!phone); + + // MOCK: Integration with Email / In-App Notification API + this.logger.log( + `[BROADCAST QUEUE] Transmitting message to ${phoneNumbers.length} store owners.`, + ); + this.logger.log(`[PAYLOAD]: ${message}`); + + return { + success: true, + deliveredTo: phoneNumbers.length, + message: "Broadcast initiated successfully", + }; + } + + async getAllUsers() { + return this.prisma.user.findMany({ + where: { deletedAt: null }, + select: { + id: true, + email: true, + phone: true, + firstName: true, + middleName: true, + lastName: true, + role: true, + emailVerified: true, + createdAt: true, + storeProfile: { + select: { verificationTier: true }, + }, + adminProfile: { + select: { approvalStatus: true }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + } + + async deleteUser( + userId: string, + requestingAdminId: string, + securityContext?: RequestSecurityContext, + ) { + if (userId === requestingAdminId) { + throw new NotFoundException("You cannot delete your own account."); + } + + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) throw new NotFoundException("User not found."); + if (user.role === UserRole.SUPER_ADMIN) { + throw new NotFoundException( + "Cannot delete another admin. Demote them first.", + ); + } + + // Soft delete the user + await this.prisma.user.update({ + where: { id: userId }, + data: { deletedAt: new Date() }, + }); + + await this.auditLog.log(requestingAdminId, "DELETE_USER", "User", userId, { + requestContext: this.requestContextForAudit(securityContext), + }); + + this.logger.warn( + `User ${user.email} (${userId}) soft-deleted by admin ${requestingAdminId}`, + ); + return { success: true, message: `User ${user.email} has been removed.` }; + } + + async changePassword( + adminUserId: string, + currentPassword: string, + newPassword: string, + ) { + const user = await this.prisma.user.findUnique({ + where: { id: adminUserId }, + }); + if (!user) throw new NotFoundException("Admin user not found."); + + // Verify current password + if (!user.passwordHash) { + throw new BadRequestException("Current password is incorrect."); + } + + const isValid = await bcrypt.compare(currentPassword, user.passwordHash); + if (!isValid) { + throw new BadRequestException("Current password is incorrect."); + } + + // Hash new password and update + const SALT_ROUNDS = 10; + const passwordHash = await bcrypt.hash(newPassword, SALT_ROUNDS); + + await this.prisma.user.update({ + where: { id: adminUserId }, + data: { passwordHash }, + }); + + // Invalidate refresh tokens for this user globally + await this.redis.del(`refresh_token:${adminUserId}`); + + this.logger.log(`Admin ${user.email} changed their password.`); + return { success: true, message: "Password updated successfully." }; + } + + // ─── Payout Management ─── + + async getPendingPayouts() { + // Also including PROCESSING for visibility, or we can just fetch all non-PROCESSED + return this.prisma.payoutRequest.findMany({ + where: { + status: { + in: ["PENDING", "PROCESSING"], + }, + }, + include: { + storeProfile: { + select: { + businessName: true, + user: { + select: { email: true, phone: true }, + }, + }, + }, + }, + orderBy: { createdAt: "asc" }, + }); + } + + async processPayout( + payoutId: string, + adminUserId: string, + securityContext?: RequestSecurityContext, + ) { + const payout = await this.prisma.payout.findUnique({ + where: { id: payoutId }, + select: { id: true, status: true, storeId: true, amountKobo: true }, + }); + + if (payout) { + // Eligibility + double-processing are enforced inside the safe service + // path (PayoutService.releasePayout throws for COMPLETED / non-releasable + // states). This console never bypasses it. + const releasedPayout = await this.payoutService.releasePayout( + payoutId, + adminUserId, + ); + + const nextStatus = + (releasedPayout as { status?: string } | null)?.status ?? "PROCESSING"; + const metadata = { + requestContext: this.requestContextForAudit(securityContext), + payoutId, + storeId: payout.storeId, + previousStatus: payout.status, + nextStatus, + amountKobo: this.stringifyKobo(payout.amountKobo), + source: "admin", + }; + + // Money has already moved via the safe service path; audit is best-effort + // and must never mask the release outcome. + try { + await this.prisma.$transaction(async (tx) => { + await tx.auditLog.create({ + data: { + userId: adminUserId, + action: "RELEASE_PAYOUT", + targetType: "Payout", + targetId: payoutId, + metadata, + }, + }); + await this.appendAdminAuditDomainEvent( + tx, + "PAYOUT_RELEASED_BY_ADMIN", + adminUserId, + "Payout", + payoutId, + metadata, + ); + }); + } catch (error) { + this.logger.error( + `Failed to audit payout release for ${payoutId} by admin ${adminUserId}`, + error instanceof Error ? error.stack : String(error), + ); + } + + this.logger.log(`Payout ${payoutId} released by admin ${adminUserId}`); + + return { + success: true, + message: "Payout released successfully.", + payout: releasedPayout, + }; + } + + const payoutRequest = await this.prisma.payoutRequest.findUnique({ + where: { id: payoutId }, + select: { id: true, payoutId: true, status: true }, + }); + if (!payoutRequest || payoutRequest.status === "PROCESSED") { + throw new BadRequestException( + "Payout request not found or already processed.", + ); + } + if (!payoutRequest.payoutId) { + throw new BadRequestException({ + message: + "This payout request is not linked to an approved payout operation.", + code: "PAYOUT_REQUEST_OPERATION_REQUIRED", + }); + } + + const releasedPayout = await this.payoutService.releasePayout( + payoutRequest.payoutId, + adminUserId, + ); + const releasedStatus = + (releasedPayout as { status?: string } | null)?.status ?? "PROCESSING"; + await this.prisma.payoutRequest.updateMany({ + where: { id: payoutId, payoutId: payoutRequest.payoutId }, + data: { + status: releasedStatus === "COMPLETED" ? "PROCESSED" : "PROCESSING", + processedAt: releasedStatus === "COMPLETED" ? new Date() : null, + }, + }); + + await this.auditLog.log( + adminUserId, + "PROCESS_PAYOUT", + "PayoutRequest", + payoutId, + { + requestContext: this.requestContextForAudit(securityContext), + }, + ); + + this.logger.log( + `Payout request ${payoutId} released by admin ${adminUserId}`, + ); + + return { + success: true, + message: "Payout request sent through the approved payout operation.", + payout: { + id: payoutId, + payoutId: payoutRequest.payoutId, + status: releasedStatus, + }, + }; + } + async toggleMerchantFlag( + storeId: string, + flag: "addressVerified" | "bankVerified", + value: boolean, + adminId?: string, + securityContext?: RequestSecurityContext, + ) { + const merchant = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + }); + + if (!merchant) { + throw new NotFoundException("Store profile not found"); + } + + const updated = await this.prisma.storeProfile.update({ + where: { id: storeId }, + data: { [flag]: value }, + }); + + if (adminId) { + await this.auditLog.log( + adminId, + "TOGGLE_STORE_FLAG", + "StoreProfile", + storeId, + { + flag, + value, + requestContext: this.requestContextForAudit(securityContext), + }, + ); + } + + return updated; + } +} diff --git a/apps/backend/src/modules/admin/audit-log.controller.ts b/apps/backend/src/domains/platform/admin/audit-log.controller.ts similarity index 82% rename from apps/backend/src/modules/admin/audit-log.controller.ts rename to apps/backend/src/domains/platform/admin/audit-log.controller.ts index d6818d48..ec144bb5 100644 --- a/apps/backend/src/modules/admin/audit-log.controller.ts +++ b/apps/backend/src/domains/platform/admin/audit-log.controller.ts @@ -7,10 +7,10 @@ import { BadRequestException, } from "@nestjs/common"; import { AuditLogService } from "./audit-log.service"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { RolesGuard } from "../../common/guards/roles.guard"; -import { Roles } from "../../common/decorators/roles.decorator"; -import { UserRole } from "@swifta/shared"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { RolesGuard } from "../../../common/guards/roles.guard"; +import { Roles } from "../../../common/decorators/roles.decorator"; +import { UserRole } from "@twizrr/shared"; const MAX_LIMIT = 100; @@ -25,7 +25,7 @@ function parseAndCapLimit(raw: string | undefined, defaultVal: number): number { @Controller("admin/audit-logs") @UseGuards(JwtAuthGuard, RolesGuard) -@Roles(UserRole.SUPER_ADMIN, UserRole.OPERATOR) +@Roles(UserRole.SUPER_ADMIN) export class AuditLogController { constructor(private readonly auditLogService: AuditLogService) {} diff --git a/apps/backend/src/modules/admin/audit-log.service.ts b/apps/backend/src/domains/platform/admin/audit-log.service.ts similarity index 97% rename from apps/backend/src/modules/admin/audit-log.service.ts rename to apps/backend/src/domains/platform/admin/audit-log.service.ts index ab5e16c6..8603f69d 100644 --- a/apps/backend/src/modules/admin/audit-log.service.ts +++ b/apps/backend/src/domains/platform/admin/audit-log.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from "@nestjs/common"; -import { PrismaService } from "../../prisma/prisma.service"; +import { PrismaService } from "../../../prisma/prisma.service"; @Injectable() export class AuditLogService { diff --git a/apps/backend/src/domains/platform/admin/location-demand-supply-insights.controller.ts b/apps/backend/src/domains/platform/admin/location-demand-supply-insights.controller.ts new file mode 100644 index 00000000..2a59ae8f --- /dev/null +++ b/apps/backend/src/domains/platform/admin/location-demand-supply-insights.controller.ts @@ -0,0 +1,63 @@ +import { + BadRequestException, + Controller, + Get, + Query, + UseGuards, +} from "@nestjs/common"; +import { UserRole } from "@twizrr/shared"; +import { + IsIn, + IsISO8601, + IsOptional, + IsString, + MaxLength, +} from "class-validator"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { Roles } from "../../../common/decorators/roles.decorator"; +import { RolesGuard } from "../../../common/guards/roles.guard"; +import { LocationDemandSupplyInsightsService } from "./location-demand-supply-insights.service"; + +class LocationInsightsQueryDto { + @IsOptional() @IsString() @MaxLength(80) state?: string; + @IsOptional() @IsString() @MaxLength(80) city?: string; + @IsOptional() @IsString() @MaxLength(80) area?: string; + @IsOptional() @IsString() @MaxLength(100) category?: string; + @IsOptional() @IsIn(["wizza", "all"]) source?: "wizza" | "all"; + @IsOptional() @IsISO8601() from?: string; + @IsOptional() @IsISO8601() to?: string; +} + +@Controller("admin/insights") +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.SUPER_ADMIN) +export class LocationDemandSupplyInsightsController { + constructor(private readonly insights: LocationDemandSupplyInsightsService) {} + + @Get("location-demand-supply") + getInsights(@Query() query: LocationInsightsQueryDto) { + const to = query.to ? new Date(query.to) : new Date(); + const from = query.from + ? new Date(query.from) + : new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000); + if ( + Number.isNaN(from.getTime()) || + Number.isNaN(to.getTime()) || + from > to + ) { + throw new BadRequestException({ + message: "Invalid date range", + code: "INVALID_DATE_RANGE", + }); + } + return this.insights.getInsights({ + from, + to, + state: query.state?.trim(), + city: query.city?.trim(), + area: query.area?.trim(), + category: query.category?.trim(), + source: query.source ?? "all", + }); + } +} diff --git a/apps/backend/src/domains/platform/admin/location-demand-supply-insights.service.spec.ts b/apps/backend/src/domains/platform/admin/location-demand-supply-insights.service.spec.ts new file mode 100644 index 00000000..8a09b805 --- /dev/null +++ b/apps/backend/src/domains/platform/admin/location-demand-supply-insights.service.spec.ts @@ -0,0 +1,53 @@ +import { LocationDemandSupplyInsightsService } from "./location-demand-supply-insights.service"; + +describe("LocationDemandSupplyInsightsService", () => { + const prisma = { + whatsAppAnalytics: { findMany: jest.fn() }, + product: { findMany: jest.fn() }, + }; + const service = new LocationDemandSupplyInsightsService(prisma as never); + + beforeEach(() => jest.clearAllMocks()); + + it("returns aggregate broad-location demand and safe visible supply only", async () => { + prisma.whatsAppAnalytics.findMany.mockResolvedValue([ + { + createdAt: new Date("2026-07-01"), + categoryInferred: "Sneakers", + parsedFilters: { + locationContext: { + source: "user_preference", + countryCode: "NG", + state: "Lagos", + city: "Lagos", + area: "Yaba", + }, + privateSearchText: "must not return", + }, + }, + ]); + prisma.product.findMany.mockResolvedValue([ + { storeId: "store-1", categoryTag: "Sneakers", platformCategory: null }, + ]); + + const result = await service.getInsights({ + from: new Date("2026-06-01"), + to: new Date("2026-08-01"), + }); + + expect(result.demandByLocation[0]).toMatchObject({ + state: "Lagos", + city: "Lagos", + area: "Yaba", + demandCount: 1, + }); + expect(result.supplyByLocation[0]).toMatchObject({ + storeCount: 1, + productCount: 1, + }); + expect(result.gaps[0].gapScore).toBe(0.5); + expect(JSON.stringify(result)).not.toMatch( + /userId|privateSearchText|must not return|deliveryAddress|ipAddress|deviceIdentity/i, + ); + }); +}); diff --git a/apps/backend/src/domains/platform/admin/location-demand-supply-insights.service.ts b/apps/backend/src/domains/platform/admin/location-demand-supply-insights.service.ts new file mode 100644 index 00000000..ef987a8f --- /dev/null +++ b/apps/backend/src/domains/platform/admin/location-demand-supply-insights.service.ts @@ -0,0 +1,299 @@ +import { Injectable } from "@nestjs/common"; +import { + ModerationStatus, + Prisma, + ProductStatus, + StoreTier, +} from "@prisma/client"; +import { PrismaService } from "../../../prisma/prisma.service"; + +export type LocationInsightsSource = "wizza" | "all"; + +export interface LocationDemandSupplyQuery { + from: Date; + to: Date; + state?: string; + city?: string; + area?: string; + category?: string; + source?: LocationInsightsSource; +} + +type Location = { + countryCode: string | null; + state: string | null; + city: string | null; + area: string | null; +}; + +interface DemandBucket extends Location { + demandCount: number; + categories: Map; +} + +const MAX_EVENTS = 10_000; +const MAX_LOCATIONS = 50; + +/** + * Aggregate planning data only. The transparent gap score is demand divided by + * visible product and store supply (minimum denominator one). It is a review + * priority, never a prediction or an enforcement input. + */ +@Injectable() +export class LocationDemandSupplyInsightsService { + constructor(private readonly prisma: PrismaService) {} + + async getInsights(query: LocationDemandSupplyQuery) { + const rows = await this.prisma.whatsAppAnalytics.findMany({ + where: { + createdAt: { gte: query.from, lte: query.to }, + categoryInferred: { not: null }, + intentClassified: { in: ["search_products", "image_search"] }, + }, + select: { createdAt: true, categoryInferred: true, parsedFilters: true }, + orderBy: { createdAt: "desc" }, + take: MAX_EVENTS, + }); + + const demand = new Map(); + for (const row of rows) { + const location = this.readLocation(row.parsedFilters); + if (!location || !this.matchesLocation(location, query)) continue; + const category = this.clean(row.categoryInferred); + if ( + !category || + (query.category && !this.matches(category, query.category)) + ) + continue; + const key = this.locationKey(location); + const bucket = demand.get(key) ?? { + ...location, + demandCount: 0, + categories: new Map(), + }; + bucket.demandCount += 1; + bucket.categories.set( + category, + (bucket.categories.get(category) ?? 0) + 1, + ); + demand.set(key, bucket); + } + + const demandBuckets = [...demand.values()] + .sort((a, b) => b.demandCount - a.demandCount) + .slice(0, MAX_LOCATIONS); + const supply = await Promise.all( + demandBuckets.map((bucket) => this.supplyFor(bucket)), + ); + const supplyByKey = new Map( + supply.map((item) => [this.locationKey(item.location), item]), + ); + + const demandByLocation = demandBuckets.map((bucket) => ({ + ...this.publicLocation(bucket), + demandCount: bucket.demandCount, + topCategories: this.topCategories(bucket.categories, "demandCount"), + })); + const supplyByLocation = supply.map((item) => ({ + ...this.publicLocation(item.location), + storeCount: item.storeCount, + productCount: item.productCount, + topCategories: item.topCategories, + })); + const gaps = demandBuckets + .flatMap((bucket) => { + const current = supplyByKey.get(this.locationKey(bucket)); + return this.topCategories(bucket.categories, "demandCount").map( + (category) => { + const categorySupply = current?.categorySupply.get( + category.categoryName, + ) ?? { productCount: 0, storeCount: 0 }; + const gapScore = + category.demandCount / + Math.max( + categorySupply.productCount + categorySupply.storeCount, + 1, + ); + return { + ...this.publicLocation(bucket), + categoryId: null, + categorySlug: null, + categoryName: category.categoryName, + demandCount: category.demandCount, + productCount: categorySupply.productCount, + storeCount: categorySupply.storeCount, + gapScore: Number(gapScore.toFixed(2)), + recommendation: + categorySupply.productCount === 0 + ? "Potential supply gap — high demand with no visible product supply. Worth reviewing." + : "Potential supply gap — demand is higher than visible supply. Worth reviewing.", + }; + }, + ); + }) + .sort((a, b) => b.gapScore - a.gapScore || b.demandCount - a.demandCount); + + return { + summary: { + totalDemandSignals: demandBuckets.reduce( + (total, item) => total + item.demandCount, + 0, + ), + totalSupplyStores: supply.reduce( + (total, item) => total + item.storeCount, + 0, + ), + totalSupplyProducts: supply.reduce( + (total, item) => total + item.productCount, + 0, + ), + locationsCovered: demandBuckets.length, + categoriesCovered: new Set( + demandBuckets.flatMap((item) => [...item.categories.keys()]), + ).size, + }, + demandByLocation, + supplyByLocation, + gaps: gaps.slice(0, 100), + dataSources: ["wizza"] as const, + }; + } + + private async supplyFor(location: Location) { + const where: Prisma.ProductWhereInput = { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + productCode: { not: null }, + moderationStatus: { not: ModerationStatus.BLOCKED }, + storeProfile: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + AND: this.locationTerms(location).map((term) => ({ + businessAddress: { + contains: term, + mode: Prisma.QueryMode.insensitive, + }, + })), + }, + }; + const products = await this.prisma.product.findMany({ + where, + select: { storeId: true, categoryTag: true, platformCategory: true }, + take: MAX_EVENTS, + }); + const categories = new Map< + string, + { productCount: number; stores: Set } + >(); + for (const product of products) { + const name = + this.clean(product.platformCategory) ?? + this.clean(product.categoryTag) ?? + "Uncategorized"; + const item = categories.get(name) ?? { + productCount: 0, + stores: new Set(), + }; + item.productCount += 1; + item.stores.add(product.storeId); + categories.set(name, item); + } + const categorySupply = new Map( + [...categories.entries()].map(([name, value]) => [ + name, + { productCount: value.productCount, storeCount: value.stores.size }, + ]), + ); + return { + location, + storeCount: new Set(products.map((product) => product.storeId)).size, + productCount: products.length, + categorySupply, + topCategories: [...categorySupply.entries()] + .sort((a, b) => b[1].productCount - a[1].productCount) + .slice(0, 5) + .map(([categoryName, value]) => ({ + categoryId: null, + categorySlug: null, + categoryName, + ...value, + })), + }; + } + + private readLocation(value: Prisma.JsonValue | null): Location | null { + if (!value || typeof value !== "object" || Array.isArray(value)) + return null; + const context = (value as Record).locationContext; + if (!context || typeof context !== "object" || Array.isArray(context)) + return null; + const record = context as Record; + if ( + record.source !== "explicit_query" && + record.source !== "user_preference" + ) + return null; + const location = { + countryCode: this.clean(record.countryCode), + state: this.clean(record.state), + city: this.clean(record.city), + area: this.clean(record.area), + }; + return location.state || location.city || location.area ? location : null; + } + + private matchesLocation( + location: Location, + query: LocationDemandSupplyQuery, + ): boolean { + return ( + (!query.state || this.matches(location.state, query.state)) && + (!query.city || this.matches(location.city, query.city)) && + (!query.area || this.matches(location.area, query.area)) + ); + } + private matches(value: string | null, filter: string): boolean { + return value?.toLowerCase() === filter.trim().toLowerCase(); + } + private clean(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 + ? value.trim().slice(0, 80) + : null; + } + private locationKey(location: Location): string { + return [ + location.countryCode, + location.state, + location.city, + location.area, + ].join("|"); + } + private locationTerms(location: Location): string[] { + return [location.state, location.city, location.area].filter( + (value): value is string => value !== null, + ); + } + private publicLocation(location: Location): Location { + return { + countryCode: location.countryCode, + state: location.state, + city: location.city, + area: location.area, + }; + } + private topCategories( + categories: Map, + countKey: "demandCount", + ) { + return [...categories.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([categoryName, demandCount]) => ({ + categoryId: null, + categorySlug: null, + categoryName, + [countKey]: demandCount, + })); + } +} diff --git a/apps/backend/src/domains/platform/domain-event/domain-event.module.ts b/apps/backend/src/domains/platform/domain-event/domain-event.module.ts new file mode 100644 index 00000000..1825ea10 --- /dev/null +++ b/apps/backend/src/domains/platform/domain-event/domain-event.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; + +import { DomainEventService } from "./domain-event.service"; + +@Module({ + providers: [DomainEventService], + exports: [DomainEventService], +}) +export class DomainEventModule {} diff --git a/apps/backend/src/domains/platform/domain-event/domain-event.service.spec.ts b/apps/backend/src/domains/platform/domain-event/domain-event.service.spec.ts new file mode 100644 index 00000000..2469f140 --- /dev/null +++ b/apps/backend/src/domains/platform/domain-event/domain-event.service.spec.ts @@ -0,0 +1,140 @@ +import { BadRequestException } from "@nestjs/common"; +import { DomainEvent, Prisma } from "@prisma/client"; + +import { DomainEventService } from "./domain-event.service"; + +describe("DomainEventService", () => { + let service: DomainEventService; + + const makeEvent = (overrides: Partial = {}): DomainEvent => ({ + id: "event-1", + aggregateType: "ORDER", + aggregateId: "order-1", + eventType: "ORDER_CREATED", + actorType: "USER", + actorId: "user-1", + source: "order.service", + metadata: {}, + correlationId: null, + idempotencyKey: null, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + ...overrides, + }); + + const makeUniqueConflict = () => + new Prisma.PrismaClientKnownRequestError( + "Unique constraint failed on the fields: (`idempotency_key`)", + { + code: "P2002", + clientVersion: "7.5.0", + meta: { target: ["idempotency_key"] }, + }, + ); + + beforeEach(() => { + service = new DomainEventService(); + }); + + it("inserts events through the provided transaction client", async () => { + const created = makeEvent({ idempotencyKey: "order-created:order-1" }); + const create = jest.fn().mockResolvedValue(created); + const findUniqueOrThrow = jest.fn(); + const tx = { + domainEvent: { create, findUniqueOrThrow }, + } as unknown as Prisma.TransactionClient; + + await expect( + service.append(tx, { + aggregateType: "ORDER", + aggregateId: "order-1", + eventType: "ORDER_CREATED", + actorType: "USER", + actorId: "user-1", + source: "order.service", + metadata: { amountKobo: 1200 }, + correlationId: "request-1", + idempotencyKey: "order-created:order-1", + }), + ).resolves.toEqual(created); + + expect(create).toHaveBeenCalledWith({ + data: { + aggregateType: "ORDER", + aggregateId: "order-1", + eventType: "ORDER_CREATED", + actorType: "USER", + actorId: "user-1", + source: "order.service", + metadata: { amountKobo: "1200" }, + correlationId: "request-1", + idempotencyKey: "order-created:order-1", + }, + }); + expect(findUniqueOrThrow).not.toHaveBeenCalled(); + }); + + it("strips sensitive metadata keys recursively", () => { + expect( + service.sanitizeMetadata({ + fullNin: "12345678901", + cardDetails: { last4: "1234" }, + webhookSecret: "secret", + rawProviderPayload: { unsafe: true }, + nested: { + apiKey: "key", + safeValue: "kept", + }, + safeStatus: "SUCCESS", + }), + ).toEqual({ + nested: { safeValue: "kept" }, + safeStatus: "SUCCESS", + }); + }); + + it("preserves kobo money values as integer strings", () => { + expect( + service.sanitizeMetadata({ + amountKobo: 250000, + platformFeeKobo: 5000n, + productPrice: 2450000, + quantity: 2, + }), + ).toEqual({ + amountKobo: "250000", + platformFeeKobo: "5000", + productPrice: "2450000", + quantity: 2, + }); + }); + + it("rejects non-integer money metadata", () => { + expect(() => service.sanitizeMetadata({ amountKobo: 12.5 })).toThrow( + BadRequestException, + ); + }); + + it("returns the existing event when idempotency key already exists", async () => { + const existing = makeEvent({ idempotencyKey: "same-key" }); + const create = jest.fn().mockRejectedValue(makeUniqueConflict()); + const findUniqueOrThrow = jest.fn().mockResolvedValue(existing); + const tx = { + domainEvent: { create, findUniqueOrThrow }, + } as unknown as Prisma.TransactionClient; + + await expect( + service.append(tx, { + aggregateType: "PAYMENT", + aggregateId: "payment-1", + eventType: "PAYMENT_INITIALIZED", + actorType: "SYSTEM", + source: "payment.service", + idempotencyKey: "same-key", + }), + ).resolves.toEqual(existing); + + expect(findUniqueOrThrow).toHaveBeenCalledWith({ + where: { idempotencyKey: "same-key" }, + }); + }); +}); diff --git a/apps/backend/src/domains/platform/domain-event/domain-event.service.ts b/apps/backend/src/domains/platform/domain-event/domain-event.service.ts new file mode 100644 index 00000000..f7bd2b38 --- /dev/null +++ b/apps/backend/src/domains/platform/domain-event/domain-event.service.ts @@ -0,0 +1,203 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import { DomainEvent, Prisma } from "@prisma/client"; + +type DomainEventTransaction = Prisma.TransactionClient; + +type SanitizedJsonValue = + | string + | number + | boolean + | null + | SanitizedJsonObject + | SanitizedJsonValue[]; + +interface SanitizedJsonObject { + [key: string]: SanitizedJsonValue; +} + +export interface AppendDomainEventInput { + aggregateType: string; + aggregateId: string; + eventType: string; + actorType: string; + actorId?: string | null; + source: string; + metadata?: unknown; + correlationId?: string | null; + idempotencyKey?: string | null; +} + +const SENSITIVE_KEY_FRAGMENTS = [ + "apikey", + "apisecret", + "authorization", + "authorizationcode", + "banksecret", + "bvn", + "card", + "cardnumber", + "cvv", + "cvc", + "nin", + "pan", + "password", + "pin", + "providerpayload", + "rawbody", + "rawpayload", + "rawproviderpayload", + "recipientcode", + "secret", + "signature", + "token", + "webhooksecret", +]; + +const MONEY_KEY_FRAGMENTS = [ + "amount", + "cost", + "fee", + "kobo", + "margin", + "price", +]; + +@Injectable() +export class DomainEventService { + async append( + tx: DomainEventTransaction, + input: AppendDomainEventInput, + ): Promise { + try { + return await tx.domainEvent.create({ + data: { + aggregateType: input.aggregateType, + aggregateId: input.aggregateId, + eventType: input.eventType, + actorType: input.actorType, + actorId: input.actorId ?? null, + source: input.source, + metadata: this.sanitizeMetadata(input.metadata), + correlationId: input.correlationId ?? null, + idempotencyKey: input.idempotencyKey ?? null, + }, + }); + } catch (error) { + if (this.isUniqueConflict(error) && input.idempotencyKey) { + return tx.domainEvent.findUniqueOrThrow({ + where: { idempotencyKey: input.idempotencyKey }, + }); + } + + throw error; + } + } + + sanitizeMetadata(metadata: unknown): Prisma.InputJsonValue { + if (metadata === null || metadata === undefined) { + return {}; + } + + const sanitized = this.sanitizeValue("metadata", metadata); + if (this.isPlainRecord(sanitized)) { + return sanitized as Prisma.InputJsonValue; + } + + return { value: sanitized } as Prisma.InputJsonValue; + } + + private sanitizeValue(key: string, value: unknown): SanitizedJsonValue { + if (value === null || value === undefined) { + return null; + } + + if (typeof value === "bigint") { + return value.toString(); + } + + if (typeof value === "string" || typeof value === "boolean") { + return value; + } + + if (typeof value === "number") { + return this.normalizeNumber(key, value); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return value.map((item) => this.sanitizeValue(key, item)); + } + + if (this.isPlainRecord(value)) { + const result: SanitizedJsonObject = {}; + for (const [childKey, childValue] of Object.entries(value)) { + if (this.isSensitiveKey(childKey)) { + continue; + } + result[childKey] = this.sanitizeValue(childKey, childValue); + } + return result; + } + + return String(value); + } + + private normalizeNumber(key: string, value: number): number | string { + if (!Number.isFinite(value)) { + throw new BadRequestException( + "Domain event metadata contains NaN/Infinity", + ); + } + + if (!this.isMoneyKey(key)) { + return value; + } + + if (!Number.isSafeInteger(value)) { + throw new BadRequestException( + `Domain event money metadata "${key}" must be an integer kobo value`, + ); + } + + return value.toString(); + } + + private isSensitiveKey(key: string): boolean { + const normalized = this.normalizeKey(key); + return SENSITIVE_KEY_FRAGMENTS.some((fragment) => + normalized.includes(fragment), + ); + } + + private isMoneyKey(key: string): boolean { + const normalized = this.normalizeKey(key); + return MONEY_KEY_FRAGMENTS.some((fragment) => + normalized.includes(fragment), + ); + } + + private normalizeKey(key: string): string { + return key.toLowerCase().replace(/[^a-z0-9]/g, ""); + } + + private isPlainRecord(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); + } + + private isUniqueConflict( + error: unknown, + ): error is Prisma.PrismaClientKnownRequestError { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ); + } +} diff --git a/apps/backend/src/domains/platform/embedding/README.md b/apps/backend/src/domains/platform/embedding/README.md new file mode 100644 index 00000000..2d5ef998 --- /dev/null +++ b/apps/backend/src/domains/platform/embedding/README.md @@ -0,0 +1,5 @@ +# Embedding Domain + +Product embedding generation boundary for text and image vectors stored in pgvector. +To be backed by Vertex AI embeddings. + diff --git a/apps/backend/src/domains/platform/embedding/embedding.module.ts b/apps/backend/src/domains/platform/embedding/embedding.module.ts new file mode 100644 index 00000000..f4ab8a3e --- /dev/null +++ b/apps/backend/src/domains/platform/embedding/embedding.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; + +import { AIModule } from "../../../integrations/ai/ai.module"; +import { PrismaModule } from "../../../core/prisma/prisma.module"; +import { QueueModule } from "../../../queue/queue.module"; +import { EmbeddingProcessor } from "./embedding.processor"; +import { EmbeddingService } from "./embedding.service"; + +@Module({ + imports: [PrismaModule, QueueModule, AIModule], + providers: [EmbeddingService, EmbeddingProcessor], + exports: [EmbeddingService], +}) +export class EmbeddingModule {} diff --git a/apps/backend/src/domains/platform/embedding/embedding.processor.spec.ts b/apps/backend/src/domains/platform/embedding/embedding.processor.spec.ts new file mode 100644 index 00000000..b73e7dd7 --- /dev/null +++ b/apps/backend/src/domains/platform/embedding/embedding.processor.spec.ts @@ -0,0 +1,76 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { Job } from "bullmq"; + +import { EmbeddingJobData, EmbeddingProcessor } from "./embedding.processor"; +import { EmbeddingService } from "./embedding.service"; + +describe("EmbeddingProcessor", () => { + let processor: EmbeddingProcessor; + let service: { generateProductEmbedding: jest.Mock }; + + beforeEach(async () => { + service = { + generateProductEmbedding: jest.fn().mockResolvedValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmbeddingProcessor, + { provide: EmbeddingService, useValue: service }, + ], + }).compile(); + + processor = module.get(EmbeddingProcessor); + }); + + function makeJob(data: unknown): Job { + return { id: "job_1", data } as unknown as Job< + EmbeddingJobData, + void, + string + >; + } + + it("skips when productId is missing", async () => { + await processor.process(makeJob({})); + expect(service.generateProductEmbedding).not.toHaveBeenCalled(); + }); + + it("skips when productId is empty string", async () => { + await processor.process(makeJob({ productId: "" })); + expect(service.generateProductEmbedding).not.toHaveBeenCalled(); + }); + + it("skips when productId is not a string", async () => { + await processor.process(makeJob({ productId: 42 })); + expect(service.generateProductEmbedding).not.toHaveBeenCalled(); + }); + + it("delegates to embedding service when productId is valid", async () => { + await processor.process(makeJob({ productId: "prod_1" })); + expect(service.generateProductEmbedding).toHaveBeenCalledWith( + "prod_1", + null, + ); + }); + + it("forwards productUpdatedAt revision to the service", async () => { + const revision = "2026-05-01T00:00:00.000Z"; + await processor.process( + makeJob({ productId: "prod_1", productUpdatedAt: revision }), + ); + expect(service.generateProductEmbedding).toHaveBeenCalledWith( + "prod_1", + revision, + ); + }); + + it("rethrows service errors so BullMQ can retry", async () => { + const error = new Error("vertex unavailable"); + service.generateProductEmbedding.mockRejectedValue(error); + + await expect( + processor.process(makeJob({ productId: "prod_1" })), + ).rejects.toThrow("vertex unavailable"); + }); +}); diff --git a/apps/backend/src/domains/platform/embedding/embedding.processor.ts b/apps/backend/src/domains/platform/embedding/embedding.processor.ts new file mode 100644 index 00000000..78a7452a --- /dev/null +++ b/apps/backend/src/domains/platform/embedding/embedding.processor.ts @@ -0,0 +1,46 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Logger } from "@nestjs/common"; +import { Job } from "bullmq"; + +import { QUEUE } from "../../../queue/queue.constants"; +import { EmbeddingService } from "./embedding.service"; + +export interface EmbeddingJobData { + productId?: string; + productUpdatedAt?: string; +} + +@Processor(QUEUE.EMBEDDING) +export class EmbeddingProcessor extends WorkerHost { + private readonly logger = new Logger(EmbeddingProcessor.name); + + constructor(private readonly embeddingService: EmbeddingService) { + super(); + } + + async process(job: Job): Promise { + const productId = job.data?.productId; + + if (!productId || typeof productId !== "string") { + this.logger.warn( + `Embedding job ${job.id ?? "unknown"} skipped: missing or invalid productId`, + ); + return; + } + + const productUpdatedAt = job.data?.productUpdatedAt ?? null; + + try { + await this.embeddingService.generateProductEmbedding( + productId, + productUpdatedAt, + ); + } catch (error) { + const errorType = error instanceof Error ? error.name : typeof error; + this.logger.error( + `Embedding job ${job.id ?? "unknown"} failed (productId=${productId}, type=${errorType})`, + ); + throw error; + } + } +} diff --git a/apps/backend/src/domains/platform/embedding/embedding.service.spec.ts b/apps/backend/src/domains/platform/embedding/embedding.service.spec.ts new file mode 100644 index 00000000..1fcf5a23 --- /dev/null +++ b/apps/backend/src/domains/platform/embedding/embedding.service.spec.ts @@ -0,0 +1,305 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { ProductStatus } from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { VertexClient } from "../../../integrations/ai/vertex.client"; +import { EmbeddingService } from "./embedding.service"; + +jest.mock("dns/promises", () => ({ + lookup: jest.fn(), +})); + +import { lookup as dnsLookup } from "dns/promises"; + +const lookupMock = dnsLookup as unknown as jest.Mock; + +type MockPrisma = { + product: { + findFirst: jest.Mock; + update: jest.Mock; + }; + $transaction: jest.Mock; +}; + +describe("EmbeddingService", () => { + const originalFetch = globalThis.fetch; + + let service: EmbeddingService; + let prisma: MockPrisma; + let vertex: { isConfigured: jest.Mock; generateEmbedding: jest.Mock }; + + const productUpdatedAt = new Date("2026-05-01T00:00:00.000Z"); + const baseProduct = { + id: "prod_1", + updatedAt: productUpdatedAt, + name: "Test Product", + title: "Test Product", + description: "A description", + shortDescription: null, + platformCategory: "Fashion", + categoryTag: "fashion", + productSubCategory: null, + storeTags: ["red", "summer"], + imageUrl: "https://cdn.example.com/img.jpg", + images: [ + { + url: "https://cdn.example.com/img.jpg", + isDefault: true, + order: 0, + moderationStatus: "SAFE", + }, + ], + }; + + const validVector = Array.from({ length: 1408 }, () => 0.5); + + beforeEach(async () => { + prisma = { + product: { + findFirst: jest.fn().mockResolvedValue(baseProduct), + update: jest.fn().mockResolvedValue({}), + }, + $transaction: jest.fn(async (callback: (tx: unknown) => unknown) => + callback({ + $executeRaw: jest.fn().mockResolvedValue(1), + product: { + update: jest.fn().mockResolvedValue({}), + }, + }), + ), + }; + + vertex = { + isConfigured: jest.fn().mockReturnValue(true), + generateEmbedding: jest.fn().mockResolvedValue(validVector), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmbeddingService, + { provide: PrismaService, useValue: prisma }, + { provide: VertexClient, useValue: vertex }, + ], + }).compile(); + + service = module.get(EmbeddingService); + + lookupMock.mockReset(); + lookupMock.mockResolvedValue([{ address: "203.0.113.10", family: 4 }]); + + const mockResponse = { + ok: true, + headers: { + get: (name: string) => { + if (name.toLowerCase() === "content-type") { + return "image/jpeg"; + } + if (name.toLowerCase() === "content-length") { + return "1024"; + } + return null; + }, + }, + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(8)), + }; + (globalThis as unknown as { fetch: jest.Mock }).fetch = jest + .fn() + .mockResolvedValue(mockResponse); + }); + + afterEach(() => { + jest.restoreAllMocks(); + globalThis.fetch = originalFetch; + }); + + it("skips when Vertex is not configured", async () => { + vertex.isConfigured.mockReturnValue(false); + + await service.generateProductEmbedding("prod_1"); + + expect(prisma.product.findFirst).not.toHaveBeenCalled(); + expect(vertex.generateEmbedding).not.toHaveBeenCalled(); + }); + + it("skips when product is missing/inactive/deleted", async () => { + prisma.product.findFirst.mockResolvedValue(null); + + await service.generateProductEmbedding("prod_1"); + + expect(prisma.product.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: "prod_1", + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + }), + }), + ); + expect(vertex.generateEmbedding).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("skips when product has no usable image", async () => { + prisma.product.findFirst.mockResolvedValue({ + ...baseProduct, + imageUrl: null, + images: [], + }); + + await service.generateProductEmbedding("prod_1"); + + expect(vertex.generateEmbedding).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("rejects vectors with non-1408 length and does not persist", async () => { + vertex.generateEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + + await service.generateProductEmbedding("prod_1"); + + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("rejects vectors containing NaN or Infinity", async () => { + const invalidVector = [...validVector]; + invalidVector[5] = Number.NaN; + vertex.generateEmbedding.mockResolvedValue(invalidVector); + + await service.generateProductEmbedding("prod_1"); + + expect(prisma.$transaction).not.toHaveBeenCalled(); + + const infinityVector = [...validVector]; + infinityVector[10] = Number.POSITIVE_INFINITY; + vertex.generateEmbedding.mockResolvedValue(infinityVector); + + await service.generateProductEmbedding("prod_1"); + + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("persists a valid embedding via raw SQL transaction", async () => { + await service.generateProductEmbedding("prod_1"); + + expect(vertex.generateEmbedding).toHaveBeenCalledTimes(1); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + it("skips when productId is empty", async () => { + await service.generateProductEmbedding(""); + + expect(prisma.product.findFirst).not.toHaveBeenCalled(); + expect(vertex.generateEmbedding).not.toHaveBeenCalled(); + }); + + it("skips when job revision does not match current product.updatedAt", async () => { + await service.generateProductEmbedding( + "prod_1", + new Date("2026-01-01T00:00:00.000Z").toISOString(), + ); + + expect(vertex.generateEmbedding).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("proceeds when job revision matches current product.updatedAt", async () => { + await service.generateProductEmbedding( + "prod_1", + productUpdatedAt.toISOString(), + ); + + expect(vertex.generateEmbedding).toHaveBeenCalledTimes(1); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + describe("image url SSRF guard", () => { + function withImageUrl(url: string) { + prisma.product.findFirst.mockResolvedValue({ + ...baseProduct, + imageUrl: url, + images: [ + { + url, + isDefault: true, + order: 0, + moderationStatus: "SAFE", + }, + ], + }); + } + + it("rejects non-https URLs", async () => { + withImageUrl("http://cdn.example.com/img.jpg"); + + await expect(service.generateProductEmbedding("prod_1")).rejects.toThrow( + /https/, + ); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("rejects URLs with credentials", async () => { + withImageUrl("https://user:pass@cdn.example.com/img.jpg"); + + await expect(service.generateProductEmbedding("prod_1")).rejects.toThrow( + /credentials/, + ); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("rejects non-default https ports", async () => { + withImageUrl("https://cdn.example.com:8443/img.jpg"); + + await expect(service.generateProductEmbedding("prod_1")).rejects.toThrow( + /port/, + ); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("rejects literal loopback IPs", async () => { + withImageUrl("https://127.0.0.1/img.jpg"); + + await expect(service.generateProductEmbedding("prod_1")).rejects.toThrow( + /non-public/, + ); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("rejects literal link-local cloud metadata addresses", async () => { + withImageUrl("https://169.254.169.254/img.jpg"); + + await expect(service.generateProductEmbedding("prod_1")).rejects.toThrow( + /non-public/, + ); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("rejects hostnames that resolve to private IPv4 ranges", async () => { + lookupMock.mockResolvedValueOnce([{ address: "10.1.2.3", family: 4 }]); + withImageUrl("https://internal.example.com/img.jpg"); + + await expect(service.generateProductEmbedding("prod_1")).rejects.toThrow( + /non-public/, + ); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("rejects literal IPv6 loopback", async () => { + withImageUrl("https://[::1]/img.jpg"); + + await expect(service.generateProductEmbedding("prod_1")).rejects.toThrow( + /non-public/, + ); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("rejects the bare 'localhost' hostname", async () => { + withImageUrl("https://localhost/img.jpg"); + + await expect(service.generateProductEmbedding("prod_1")).rejects.toThrow( + /not allowed/, + ); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/backend/src/domains/platform/embedding/embedding.service.ts b/apps/backend/src/domains/platform/embedding/embedding.service.ts new file mode 100644 index 00000000..42d3e077 --- /dev/null +++ b/apps/backend/src/domains/platform/embedding/embedding.service.ts @@ -0,0 +1,409 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { Prisma, ProductStatus } from "@prisma/client"; +import { randomUUID } from "crypto"; +import { lookup } from "dns/promises"; +import { isIP } from "net"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { VertexClient } from "../../../integrations/ai/vertex.client"; + +const EMBEDDING_DIMENSION = 1408; +const EMBEDDING_MODEL = "vertex-ai-multimodal-v1"; +const IMAGE_FETCH_TIMEOUT_MS = 15_000; +const MAX_IMAGE_BYTES = 10 * 1024 * 1024; +const MAX_EMBEDDING_TEXT_LENGTH = 4_000; +const BLOCKED_HOSTNAMES = new Set([ + "localhost", + "ip6-localhost", + "ip6-loopback", +]); + +type ProductForEmbedding = Prisma.ProductGetPayload<{ + select: typeof PRODUCT_EMBEDDING_SELECT; +}>; + +const PRODUCT_EMBEDDING_SELECT = { + id: true, + updatedAt: true, + name: true, + title: true, + description: true, + shortDescription: true, + platformCategory: true, + categoryTag: true, + productSubCategory: true, + storeTags: true, + imageUrl: true, + images: { + select: { + url: true, + isDefault: true, + order: true, + moderationStatus: true, + }, + orderBy: { order: "asc" }, + }, +} satisfies Prisma.ProductSelect; + +@Injectable() +export class EmbeddingService { + private readonly logger = new Logger(EmbeddingService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly vertexClient: VertexClient, + ) {} + + async generateProductEmbedding( + productId: string, + jobProductUpdatedAt?: string | null, + ): Promise { + if (!productId || typeof productId !== "string") { + this.logger.warn("Embedding job skipped: invalid productId"); + return; + } + + if (!this.vertexClient.isConfigured()) { + this.logger.warn( + `Embedding job skipped: Vertex AI is not configured (productId=${productId})`, + ); + return; + } + + const product = await this.prisma.product.findFirst({ + where: { + id: productId, + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + }, + select: PRODUCT_EMBEDDING_SELECT, + }); + + if (!product) { + this.logger.log( + `Embedding job skipped: product missing, inactive, or soft-deleted (productId=${productId})`, + ); + return; + } + + if ( + jobProductUpdatedAt && + product.updatedAt.toISOString() !== jobProductUpdatedAt + ) { + this.logger.log( + `Embedding job skipped: stale revision (productId=${productId})`, + ); + return; + } + + const imageUrl = this.resolveImageUrl(product); + + if (!imageUrl) { + this.logger.warn( + `Embedding job skipped: product has no usable image (productId=${productId})`, + ); + return; + } + + const text = this.buildEmbeddingText(product); + + let imageBuffer: Buffer; + try { + imageBuffer = await this.downloadImage(imageUrl); + } catch (error) { + this.logger.warn( + `Embedding image download failed (productId=${productId}, host=${this.safeHost(imageUrl)}, type=${this.errorName(error)})`, + ); + throw error; + } + + const embedding = await this.vertexClient.generateEmbedding( + imageBuffer, + text, + ); + + if (!this.isValidVector(embedding)) { + this.logger.error( + `Embedding rejected: provider returned an invalid vector (productId=${productId})`, + ); + return; + } + + await this.storeEmbedding(product.id, embedding); + } + + private resolveImageUrl(product: ProductForEmbedding): string | null { + const eligibleImages = product.images.filter( + (image) => image.moderationStatus !== "BLOCKED", + ); + const defaultImage = eligibleImages.find((image) => image.isDefault); + if (defaultImage) { + return defaultImage.url; + } + if (eligibleImages.length > 0) { + return eligibleImages[0].url; + } + if (product.imageUrl) { + return product.imageUrl; + } + return null; + } + + private buildEmbeddingText(product: ProductForEmbedding): string { + const parts: string[] = []; + const push = (value: string | null | undefined) => { + const trimmed = value?.trim(); + if (trimmed) { + parts.push(trimmed); + } + }; + + push(product.title ?? product.name); + if ( + product.title && + product.name && + product.title.trim().toLowerCase() !== product.name.trim().toLowerCase() + ) { + push(product.name); + } + push(product.shortDescription); + push(product.description); + push(product.platformCategory); + push(product.categoryTag); + push(product.productSubCategory); + + const cleanTags = (product.storeTags ?? []) + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); + if (cleanTags.length > 0) { + parts.push(cleanTags.join(" ")); + } + + return parts.join("\n").slice(0, MAX_EMBEDDING_TEXT_LENGTH); + } + + private async downloadImage(url: string): Promise { + await this.assertSafeImageUrl(url); + + const response = await fetch(url, { + signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error(`image fetch returned status ${response.status}`); + } + + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.toLowerCase().startsWith("image/")) { + throw new Error("image fetch returned a non-image content type"); + } + + const contentLengthHeader = response.headers.get("content-length"); + if (contentLengthHeader) { + const declaredLength = Number.parseInt(contentLengthHeader, 10); + if (Number.isFinite(declaredLength) && declaredLength > MAX_IMAGE_BYTES) { + throw new Error("image exceeds maximum allowed size"); + } + } + + const arrayBuffer = await response.arrayBuffer(); + if (arrayBuffer.byteLength > MAX_IMAGE_BYTES) { + throw new Error("image exceeds maximum allowed size"); + } + + return Buffer.from(arrayBuffer); + } + + // SSRF hardening: a product image URL is sourced from a DB row that the + // seller can influence through the product DTO. Without these checks an + // attacker could redirect the worker to internal services or cloud + // metadata endpoints. We require https, reject credentials and non-default + // ports, resolve the hostname, and refuse any address inside reserved or + // link-local ranges. + private async assertSafeImageUrl(rawUrl: string): Promise { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + throw new Error("image url is not a valid URL"); + } + + if (parsed.protocol !== "https:") { + throw new Error("image url must use https"); + } + + if (parsed.username || parsed.password) { + throw new Error("image url must not contain credentials"); + } + + if (parsed.port !== "" && parsed.port !== "443") { + throw new Error("image url must use the default https port"); + } + + const hostname = parsed.hostname.toLowerCase(); + if (BLOCKED_HOSTNAMES.has(hostname)) { + throw new Error("image url host is not allowed"); + } + + // URL.hostname keeps the surrounding brackets for IPv6 literals, + // but net.isIP does not accept them — strip them before the check. + const unbracketed = + hostname.startsWith("[") && hostname.endsWith("]") + ? hostname.slice(1, -1) + : hostname; + const literalKind = isIP(unbracketed); + if (literalKind !== 0) { + this.assertPublicAddress(unbracketed, literalKind); + return; + } + + const records = await lookup(hostname, { all: true }); + if (records.length === 0) { + throw new Error("image url hostname did not resolve"); + } + for (const record of records) { + this.assertPublicAddress(record.address, record.family); + } + } + + private assertPublicAddress(address: string, family: number): void { + if (family === 4) { + if (!this.isPublicIPv4(address)) { + throw new Error("image url resolves to a non-public IPv4 address"); + } + return; + } + if (family === 6) { + if (!this.isPublicIPv6(address)) { + throw new Error("image url resolves to a non-public IPv6 address"); + } + return; + } + throw new Error("image url resolves to an unsupported address family"); + } + + private isPublicIPv4(address: string): boolean { + const parts = address.split(".").map((value) => Number.parseInt(value, 10)); + if (parts.length !== 4 || parts.some((value) => !Number.isFinite(value))) { + return false; + } + const [a, b] = parts; + if (a === 0) return false; // 0.0.0.0/8 unspecified + if (a === 10) return false; // 10.0.0.0/8 private + if (a === 127) return false; // 127.0.0.0/8 loopback + if (a === 169 && b === 254) return false; // 169.254.0.0/16 link-local + if (a === 172 && b >= 16 && b <= 31) return false; // 172.16.0.0/12 private + if (a === 192 && b === 168) return false; // 192.168.0.0/16 private + if (a >= 224) return false; // multicast + reserved + return true; + } + + private isPublicIPv6(address: string): boolean { + const normalized = address.toLowerCase().split("%")[0]; + if (normalized === "::" || normalized === "::1") { + return false; + } + if ( + normalized.startsWith("fc") || + normalized.startsWith("fd") // fc00::/7 unique local + ) { + return false; + } + if (normalized.startsWith("fe8") || normalized.startsWith("fe9")) { + return false; // fe80::/10 link-local + } + if (normalized.startsWith("fea") || normalized.startsWith("feb")) { + return false; // fe80::/10 link-local + } + if (normalized.startsWith("ff")) { + return false; // ff00::/8 multicast + } + if (normalized.startsWith("::ffff:")) { + const mapped = normalized.slice("::ffff:".length); + if (isIP(mapped) === 4) { + return this.isPublicIPv4(mapped); + } + } + return true; + } + + private isValidVector(embedding: unknown): embedding is number[] { + if (!Array.isArray(embedding)) { + return false; + } + if (embedding.length !== EMBEDDING_DIMENSION) { + return false; + } + return embedding.every( + (value) => typeof value === "number" && Number.isFinite(value), + ); + } + + private async storeEmbedding( + productId: string, + embedding: number[], + ): Promise { + const id = randomUUID(); + const vectorLiteral = this.toVectorLiteral(embedding); + + await this.prisma.$transaction(async (tx) => { + // Raw SQL is required here because Prisma does not support the + // pgvector `vector(1408)` column type natively. The `embedding` + // column is declared as `Unsupported("vector(1408)")` in the schema, + // so we INSERT/UPDATE the value via a parameterized raw query with + // an explicit `::vector` cast. + await tx.$executeRaw` + INSERT INTO "product_embeddings" ( + "id", + "product_id", + "embedding", + "embedding_model", + "embedding_ready", + "embedding_updated_at", + "created_at", + "updated_at" + ) VALUES ( + ${id}, + ${productId}, + ${vectorLiteral}::vector, + ${EMBEDDING_MODEL}, + TRUE, + NOW(), + NOW(), + NOW() + ) + ON CONFLICT ("product_id") DO UPDATE SET + "embedding" = EXCLUDED."embedding", + "embedding_model" = EXCLUDED."embedding_model", + "embedding_ready" = TRUE, + "embedding_updated_at" = NOW(), + "updated_at" = NOW() + `; + + await tx.product.update({ + where: { id: productId }, + data: { + embeddingReady: true, + embeddingUpdatedAt: new Date(), + }, + }); + }); + } + + private toVectorLiteral(embedding: number[]): string { + return `[${embedding.join(",")}]`; + } + + private errorName(error: unknown): string { + return error instanceof Error ? error.name : typeof error; + } + + private safeHost(url: string): string { + try { + return new URL(url).host; + } catch { + return "unknown"; + } + } +} diff --git a/apps/backend/src/domains/platform/moderation/README.md b/apps/backend/src/domains/platform/moderation/README.md new file mode 100644 index 00000000..20ca41b9 --- /dev/null +++ b/apps/backend/src/domains/platform/moderation/README.md @@ -0,0 +1,5 @@ +# Moderation Domain + +Content moderation boundary for image and post review decisions. +Upload flows must run moderation before publishing assets. + diff --git a/apps/backend/src/domains/platform/outbox/commerce-outbox.service.spec.ts b/apps/backend/src/domains/platform/outbox/commerce-outbox.service.spec.ts new file mode 100644 index 00000000..586b7f7e --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/commerce-outbox.service.spec.ts @@ -0,0 +1,97 @@ +import { NotificationChannel, REALTIME_EVENT } from "@twizrr/shared"; + +import { CommerceOutboxService } from "./commerce-outbox.service"; +import { OUTBOX_TOPIC } from "./outbox.types"; + +describe("CommerceOutboxService — dispute helpers", () => { + const tx = {} as never; + + function make() { + const outbox = { append: jest.fn().mockResolvedValue(undefined) }; + const service = new CommerceOutboxService(outbox as never); + return { service, outbox }; + } + + const dispute = { id: "dispute-1", orderId: "order-1", status: "OPEN" }; + + it("appends one DISPUTE realtime row per unique recipient with a domain-event idempotency key", async () => { + const { service, outbox } = make(); + + await service.appendDisputeCreatedRealtime(tx, { + dispute, + domainEventId: "domain-event-1", + // buyer-1 duplicated to prove de-duplication. + recipientUserIds: ["buyer-1", "merchant-1", "buyer-1", "", "admin-1"], + createdAt: new Date("2026-07-12T10:00:00.000Z"), + }); + + expect(outbox.append).toHaveBeenCalledTimes(3); + const calls = outbox.append.mock.calls.map((call) => call[1]); + for (const call of calls) { + expect(call.topic).toBe(OUTBOX_TOPIC.REALTIME_USER_EVENT_REQUESTED); + expect(call.aggregateType).toBe("DISPUTE"); + expect(call.aggregateId).toBe("dispute-1"); + expect(call.payload.eventType).toBe(REALTIME_EVENT.DISPUTE_CREATED); + } + const keys = calls.map((call) => call.idempotencyKey).sort(); + expect(keys).toEqual([ + "domain-event:domain-event-1:realtime:admin-1", + "domain-event:domain-event-1:realtime:buyer-1", + "domain-event:domain-event-1:realtime:merchant-1", + ]); + }); + + it("carries the action on dispute:updated and never leaks extra dispute fields", async () => { + const { service, outbox } = make(); + + await service.appendDisputeUpdatedRealtime(tx, { + dispute, + action: "EVIDENCE_ADDED", + domainEventId: "domain-event-2", + recipientUserIds: ["buyer-1"], + updatedAt: new Date("2026-07-12T10:05:00.000Z"), + }); + + const payload = outbox.append.mock.calls[0][1].payload; + expect(payload.eventType).toBe(REALTIME_EVENT.DISPUTE_UPDATED); + expect(payload.data).toEqual({ + disputeId: "dispute-1", + orderId: "order-1", + status: "OPEN", + action: "EVIDENCE_ADDED", + updatedAt: "2026-07-12T10:05:00.000Z", + }); + }); + + it("builds a deterministic dedupe key for dispute notifications", async () => { + const { service, outbox } = make(); + + await service.appendDisputeNotification(tx, { + recipientUserId: "merchant-1", + notificationType: "ORDER_DISPUTED", + title: "Order Dispute Raised", + body: "A buyer has raised a dispute.", + data: { disputeId: "dispute-1", orderId: "order-1" }, + channels: [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + disputeId: "dispute-1", + domainEventId: "domain-event-1", + url: "/store/orders/order-1", + }); + + const call = outbox.append.mock.calls[0][1]; + expect(call.topic).toBe(OUTBOX_TOPIC.NOTIFICATION_DISPATCH_REQUESTED); + expect(call.aggregateType).toBe("DISPUTE"); + expect(call.aggregateId).toBe("dispute-1"); + // dedupe key doubles as the outbox idempotency key so retries never + // create duplicate notifications, emails, or unread-count increments. + const expectedKey = [ + "domain-event", + "domain-event-1", + "notification", + "merchant-1", + "ORDER_DISPUTED", + ].join(":"); + expect(call.idempotencyKey).toBe(expectedKey); + expect(call.payload.dedupeKey).toBe(expectedKey); + }); +}); diff --git a/apps/backend/src/domains/platform/outbox/commerce-outbox.service.ts b/apps/backend/src/domains/platform/outbox/commerce-outbox.service.ts new file mode 100644 index 00000000..cf0dcbdb --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/commerce-outbox.service.ts @@ -0,0 +1,551 @@ +import { Injectable } from "@nestjs/common"; +import { Order, Payment, Prisma } from "@prisma/client"; +import { + NotificationChannel, + REALTIME_EVENT, + REALTIME_EVENT_VERSION, + UserRole, + type DeliveryUpdatedData, + type DisputeCreatedData, + type DisputeResolvedData, + type DisputeSettlementUpdatedData, + type DisputeUpdatedAction, + type DisputeUpdatedData, + type OrderCreatedData, + type OrderUpdatedData, + type PaymentConfirmedData, +} from "@twizrr/shared"; + +import { OutboxService } from "./outbox.service"; +import { + OUTBOX_TOPIC, + type NotificationAudienceValue, + type RealtimeUserEventRequestedV1, + type SupportedDisputeRealtimeEvent, +} from "./outbox.types"; + +/** + * Minimal dispute shape used to build realtime hints. Only lifecycle-safe + * fields are read — never descriptions, notes, evidence, or contact details. + */ +type DisputeRealtimeCore = { + id: string; + orderId: string; + status: string; +}; + +type CommerceOrder = Pick< + Order, + "id" | "buyerId" | "storeId" | "status" | "createdAt" | "updatedAt" +>; + +@Injectable() +export class CommerceOutboxService { + constructor(private readonly outbox: OutboxService) {} + + async appendOrderCreated( + tx: Prisma.TransactionClient, + order: CommerceOrder, + orderEventId: string, + ): Promise { + const data: OrderCreatedData = { + orderId: order.id, + status: order.status, + createdAt: order.createdAt.toISOString(), + }; + + await this.appendForOrderParticipants( + tx, + order, + REALTIME_EVENT.ORDER_CREATED, + REALTIME_EVENT_VERSION.ORDER_CREATED, + data as unknown as Record, + "order-event:" + orderEventId + ":realtime", + "Order", + ); + } + + async appendOrderUpdated( + tx: Prisma.TransactionClient, + order: CommerceOrder, + orderEventId: string, + previousStatus: string, + ): Promise { + const data: OrderUpdatedData = { + orderId: order.id, + status: order.status, + previousStatus, + updatedAt: order.updatedAt.toISOString(), + }; + + await this.appendForOrderParticipants( + tx, + order, + REALTIME_EVENT.ORDER_UPDATED, + REALTIME_EVENT_VERSION.ORDER_UPDATED, + data as unknown as Record, + "order-event:" + orderEventId + ":realtime", + "Order", + ); + } + + async appendPaymentConfirmed( + tx: Prisma.TransactionClient, + payment: Pick, + order: CommerceOrder, + ): Promise { + const data: PaymentConfirmedData = { + paymentId: payment.id, + orderId: payment.orderId, + status: "SUCCESS", + confirmedAt: (payment.verifiedAt ?? order.updatedAt).toISOString(), + }; + + await this.appendForOrderParticipants( + tx, + order, + REALTIME_EVENT.PAYMENT_CONFIRMED, + REALTIME_EVENT_VERSION.PAYMENT_CONFIRMED, + data as unknown as Record, + "payment:" + payment.id + ":realtime", + "Payment", + payment.id, + ); + } + + async appendDeliveryUpdated( + tx: Prisma.TransactionClient, + input: { + deliveryBookingId: string; + order: CommerceOrder; + status: string; + updatedAt: Date; + }, + ): Promise { + const data: DeliveryUpdatedData = { + deliveryBookingId: input.deliveryBookingId, + orderId: input.order.id, + status: input.status, + updatedAt: input.updatedAt.toISOString(), + }; + + await this.appendForOrderParticipants( + tx, + input.order, + REALTIME_EVENT.DELIVERY_UPDATED, + REALTIME_EVENT_VERSION.DELIVERY_UPDATED, + data as unknown as Record, + "delivery:" + input.deliveryBookingId + ":" + input.status + ":realtime", + "DeliveryBooking", + input.deliveryBookingId, + ); + } + + async appendNotificationDispatch( + tx: Prisma.TransactionClient, + input: { + recipientUserId: string; + notificationType: string; + title: string; + body: string; + data: Record; + channels: import("@twizrr/shared").NotificationChannel[]; + dedupeKey: string; + aggregateType: string; + aggregateId: string; + url?: string | null; + audience?: NotificationAudienceValue; + }, + ): Promise { + await this.outbox.append(tx, { + topic: OUTBOX_TOPIC.NOTIFICATION_DISPATCH_REQUESTED, + version: 1, + aggregateType: input.aggregateType, + aggregateId: input.aggregateId, + idempotencyKey: input.dedupeKey, + payload: { + recipientUserId: input.recipientUserId, + notificationType: input.notificationType, + title: input.title, + body: input.body, + data: input.data, + channels: input.channels, + dedupeKey: input.dedupeKey, + url: input.url, + ...(input.audience ? { audience: input.audience } : {}), + }, + }); + } + async appendAutoConfirmSchedule( + tx: Prisma.TransactionClient, + orderId: string, + orderEventId: string, + ): Promise { + await this.outbox.append(tx, { + topic: OUTBOX_TOPIC.ORDER_AUTO_CONFIRM_SCHEDULE_REQUESTED, + version: 1, + aggregateType: "Order", + aggregateId: orderId, + idempotencyKey: "order-event:" + orderEventId + ":auto-confirm-schedule", + payload: { + orderId, + expectedStatus: "DISPATCHED", + }, + }); + } + + // ── Dispute realtime hints (Phase 4) ───────────────────────────────────── + // + // Every dispute lifecycle change appends one realtime outbox row per + // recipient. Idempotency is derived from the persisted DomainEvent id so a + // relay retry never double-emits. aggregateType is always "DISPUTE". + + async appendDisputeCreatedRealtime( + tx: Prisma.TransactionClient, + input: { + dispute: DisputeRealtimeCore; + domainEventId: string; + recipientUserIds: string[]; + createdAt: Date; + }, + ): Promise { + const data: DisputeCreatedData = { + disputeId: input.dispute.id, + orderId: input.dispute.orderId, + status: input.dispute.status, + createdAt: input.createdAt.toISOString(), + }; + + await this.appendDisputeRealtimeForRecipients(tx, { + recipientUserIds: input.recipientUserIds, + eventType: REALTIME_EVENT.DISPUTE_CREATED, + eventVersion: REALTIME_EVENT_VERSION.DISPUTE_CREATED, + data: data as unknown as Record, + aggregateId: input.dispute.id, + domainEventId: input.domainEventId, + }); + } + + async appendDisputeUpdatedRealtime( + tx: Prisma.TransactionClient, + input: { + dispute: DisputeRealtimeCore; + action: DisputeUpdatedAction; + domainEventId: string; + recipientUserIds: string[]; + updatedAt: Date; + }, + ): Promise { + const data: DisputeUpdatedData = { + disputeId: input.dispute.id, + orderId: input.dispute.orderId, + status: input.dispute.status, + action: input.action, + updatedAt: input.updatedAt.toISOString(), + }; + + await this.appendDisputeRealtimeForRecipients(tx, { + recipientUserIds: input.recipientUserIds, + eventType: REALTIME_EVENT.DISPUTE_UPDATED, + eventVersion: REALTIME_EVENT_VERSION.DISPUTE_UPDATED, + data: data as unknown as Record, + aggregateId: input.dispute.id, + domainEventId: input.domainEventId, + }); + } + + async appendDisputeResolvedRealtime( + tx: Prisma.TransactionClient, + input: { + dispute: DisputeRealtimeCore; + outcome: string; + domainEventId: string; + recipientUserIds: string[]; + resolvedAt: Date; + }, + ): Promise { + const data: DisputeResolvedData = { + disputeId: input.dispute.id, + orderId: input.dispute.orderId, + status: input.dispute.status, + outcome: input.outcome, + resolvedAt: input.resolvedAt.toISOString(), + }; + + await this.appendDisputeRealtimeForRecipients(tx, { + recipientUserIds: input.recipientUserIds, + eventType: REALTIME_EVENT.DISPUTE_RESOLVED, + eventVersion: REALTIME_EVENT_VERSION.DISPUTE_RESOLVED, + data: data as unknown as Record, + aggregateId: input.dispute.id, + domainEventId: input.domainEventId, + }); + } + + /** + * Transactional dispute milestone notification. Centralises the DISPUTE + * aggregate wiring and the deterministic dedupe key so retries never create + * duplicate durable notifications, emails, or unread-count increments. + */ + async appendDisputeNotification( + tx: Prisma.TransactionClient, + input: { + recipientUserId: string; + notificationType: string; + title: string; + body: string; + data: Record; + channels: NotificationChannel[]; + disputeId: string; + domainEventId: string; + url?: string | null; + audience?: NotificationAudienceValue; + }, + ): Promise { + const dedupeKey = + "domain-event:" + + input.domainEventId + + ":notification:" + + input.recipientUserId + + ":" + + input.notificationType; + + await this.appendNotificationDispatch(tx, { + recipientUserId: input.recipientUserId, + notificationType: input.notificationType, + title: input.title, + body: input.body, + data: input.data, + channels: input.channels, + dedupeKey, + aggregateType: "DISPUTE", + aggregateId: input.disputeId, + url: input.url, + audience: input.audience, + }); + } + + /** + * Appends a settlement cache-invalidation hint for the buyer, store owner, + * and active operations staff in the same transaction as the state change. + * The payload intentionally contains no financial or provider data. + */ + async appendSettlementRealtimeEvents( + tx: Prisma.TransactionClient, + input: { + settlementId: string; + disputeId: string; + orderId: string; + status: string; + updatedAt: Date; + refundStatus?: string; + payoutStatus?: string; + legId?: string; + legStatus?: string; + }, + ): Promise { + const settlement = await tx.disputeSettlement.findUnique({ + where: { id: input.settlementId }, + select: { + dispute: { select: { buyerId: true } }, + order: { select: { storeProfile: { select: { userId: true } } } }, + }, + }); + if (!settlement) return; + + const admins = await tx.user.findMany({ + where: { isActive: true, role: UserRole.SUPER_ADMIN }, + select: { id: true }, + }); + const recipients = Array.from( + new Set( + [ + settlement.dispute.buyerId, + settlement.order.storeProfile?.userId, + ...admins.map((admin) => admin.id), + ].filter((id): id is string => typeof id === "string" && id.length > 0), + ), + ); + const data: DisputeSettlementUpdatedData = { + settlementId: input.settlementId, + disputeId: input.disputeId, + orderId: input.orderId, + status: input.status, + ...(input.refundStatus ? { refundStatus: input.refundStatus } : {}), + ...(input.payoutStatus ? { payoutStatus: input.payoutStatus } : {}), + updatedAt: input.updatedAt.toISOString(), + }; + const keyPrefix = input.legId + ? `settlement-leg:${input.legId}:state:${input.legStatus ?? input.status}` + : `settlement:${input.settlementId}:state:${input.status}`; + + await Promise.all( + recipients.map((recipientUserId) => + this.outbox.append(tx, { + topic: OUTBOX_TOPIC.REALTIME_USER_EVENT_REQUESTED, + version: 1, + aggregateType: "DISPUTE_SETTLEMENT", + aggregateId: input.settlementId, + idempotencyKey: `${keyPrefix}:recipient:${recipientUserId}`, + payload: { + recipientUserId, + eventType: REALTIME_EVENT.DISPUTE_SETTLEMENT_UPDATED, + eventVersion: REALTIME_EVENT_VERSION.DISPUTE_SETTLEMENT_UPDATED, + data: data as unknown as Record, + } satisfies RealtimeUserEventRequestedV1 as unknown as Prisma.InputJsonValue, + }), + ), + ); + } + + /** + * Durable settlement milestone notification. The caller supplies only + * product-safe copy and identifiers; dedupe is tied to the authoritative + * settlement or leg state so a reconciliation retry cannot create a second + * notification. + */ + async appendSettlementNotification( + tx: Prisma.TransactionClient, + input: { + recipientUserId: string; + notificationType: string; + title: string; + body: string; + settlementId: string; + orderId: string; + stateKey: string; + data?: Record; + audience?: NotificationAudienceValue; + }, + ): Promise { + await this.appendNotificationDispatch(tx, { + recipientUserId: input.recipientUserId, + notificationType: input.notificationType, + title: input.title, + body: input.body, + data: { + settlementId: input.settlementId, + orderId: input.orderId, + ...(input.data ?? {}), + }, + channels: [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + dedupeKey: `settlement:${input.settlementId}:${input.stateKey}:notification:${input.recipientUserId}`, + aggregateType: "DISPUTE_SETTLEMENT", + aggregateId: input.settlementId, + url: `/orders/${input.orderId}`, + audience: input.audience, + }); + } + + async appendAdminSettlementAlert( + tx: Prisma.TransactionClient, + input: { + settlementId: string; + orderId: string; + stateKey: string; + title: string; + body: string; + }, + ): Promise { + const admins = await tx.user.findMany({ + where: { isActive: true, role: UserRole.SUPER_ADMIN }, + select: { id: true }, + }); + await Promise.all( + admins.map((admin) => + this.appendSettlementNotification(tx, { + recipientUserId: admin.id, + notificationType: "PAYOUT_FAILED", + title: input.title, + body: input.body, + settlementId: input.settlementId, + orderId: input.orderId, + stateKey: `admin:${input.stateKey}`, + }), + ), + ); + } + + private async appendDisputeRealtimeForRecipients( + tx: Prisma.TransactionClient, + input: { + recipientUserIds: string[]; + eventType: SupportedDisputeRealtimeEvent; + eventVersion: number; + data: Record; + aggregateId: string; + domainEventId: string; + }, + ): Promise { + const recipients = Array.from( + new Set( + input.recipientUserIds.filter( + (id): id is string => typeof id === "string" && id.trim().length > 0, + ), + ), + ); + + await Promise.all( + recipients.map((recipientUserId) => + this.outbox.append(tx, { + topic: OUTBOX_TOPIC.REALTIME_USER_EVENT_REQUESTED, + version: 1, + aggregateType: "DISPUTE", + aggregateId: input.aggregateId, + idempotencyKey: + "domain-event:" + + input.domainEventId + + ":realtime:" + + recipientUserId, + payload: { + recipientUserId, + eventType: input.eventType, + eventVersion: input.eventVersion, + data: input.data, + } satisfies RealtimeUserEventRequestedV1 as unknown as Prisma.InputJsonValue, + }), + ), + ); + } + + private async appendForOrderParticipants( + tx: Prisma.TransactionClient, + order: CommerceOrder, + eventType: RealtimeUserEventRequestedV1["eventType"], + eventVersion: number, + data: Record, + idempotencyPrefix: string, + aggregateType: string, + aggregateId = order.id, + ): Promise { + const recipientUserIds = [order.buyerId]; + if (order.storeId) { + const store = await tx.storeProfile.findUnique({ + where: { id: order.storeId }, + select: { userId: true }, + }); + if (store && store.userId !== order.buyerId) { + recipientUserIds.push(store.userId); + } + } + + await Promise.all( + recipientUserIds.map((recipientUserId) => + this.outbox.append(tx, { + topic: OUTBOX_TOPIC.REALTIME_USER_EVENT_REQUESTED, + version: 1, + aggregateType, + aggregateId, + idempotencyKey: + idempotencyPrefix + ":" + eventType + ":" + recipientUserId, + payload: { + recipientUserId, + eventType, + eventVersion, + data, + }, + }), + ), + ); + } +} diff --git a/apps/backend/src/domains/platform/outbox/handlers/notification-dispatch.outbox-handler.spec.ts b/apps/backend/src/domains/platform/outbox/handlers/notification-dispatch.outbox-handler.spec.ts new file mode 100644 index 00000000..69744d97 --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/handlers/notification-dispatch.outbox-handler.spec.ts @@ -0,0 +1,63 @@ +import { NotificationChannel } from "@twizrr/shared"; + +import { NotificationDispatchOutboxHandler } from "./notification-dispatch.outbox-handler"; + +describe("NotificationDispatchOutboxHandler", () => { + it("uses the existing notification queue with a deterministic outbox job id", async () => { + const queue = { add: jest.fn().mockResolvedValue({ id: "job-1" }) }; + const handler = new NotificationDispatchOutboxHandler(queue as never); + + await handler.handle({ + id: "outbox-1", + topic: "notification.dispatch.requested", + version: 1, + aggregateType: "Payment", + aggregateId: "payment-1", + payload: { + recipientUserId: "user-1", + notificationType: "PAYMENT_CONFIRMED", + title: "Payment Successful", + body: "Your payment was successful.", + data: { orderId: "order-1" }, + channels: [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + dedupeKey: "payment:payment-1:notification:user-1", + }, + attempts: 1, + lockedAt: new Date(), + lockedBy: "worker-1", + createdAt: new Date(), + }); + + expect(queue.add).toHaveBeenCalledWith( + "send-notification", + expect.objectContaining({ + userId: "user-1", + dedupeKey: "payment:payment-1:notification:user-1", + }), + expect.objectContaining({ + jobId: "outbox:outbox-1:notification", + }), + ); + }); + + it("rejects malformed persisted payloads as non-retryable", async () => { + const handler = new NotificationDispatchOutboxHandler({ + add: jest.fn(), + } as never); + + await expect( + handler.handle({ + id: "outbox-1", + topic: "notification.dispatch.requested", + version: 1, + aggregateType: null, + aggregateId: null, + payload: { recipientUserId: "user-1" } as never, + attempts: 1, + lockedAt: new Date(), + lockedBy: "worker-1", + createdAt: new Date(), + }), + ).rejects.toThrow("Invalid notification dispatch outbox payload"); + }); +}); diff --git a/apps/backend/src/domains/platform/outbox/handlers/notification-dispatch.outbox-handler.ts b/apps/backend/src/domains/platform/outbox/handlers/notification-dispatch.outbox-handler.ts new file mode 100644 index 00000000..31be42c3 --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/handlers/notification-dispatch.outbox-handler.ts @@ -0,0 +1,122 @@ +import { InjectQueue } from "@nestjs/bullmq"; +import { Injectable } from "@nestjs/common"; +import { NotificationChannel } from "@twizrr/shared"; +import { Queue } from "bullmq"; + +import { QUEUE } from "../../../../queue/queue.constants"; +import { NonRetryableOutboxError } from "../outbox.errors"; +import { + ClaimedOutboxEvent, + NotificationDispatchRequestedV1, + OUTBOX_TOPIC, + OutboxHandler, +} from "../outbox.types"; + +@Injectable() +export class NotificationDispatchOutboxHandler implements OutboxHandler { + readonly topic = OUTBOX_TOPIC.NOTIFICATION_DISPATCH_REQUESTED; + readonly version = 1; + + constructor( + @InjectQueue(QUEUE.NOTIFICATION) + private readonly notificationQueue: Queue, + ) {} + + async handle( + event: ClaimedOutboxEvent, + ): Promise { + const payload = this.validate(event.payload); + + await this.notificationQueue.add( + "send-notification", + { + userId: payload.recipientUserId, + type: payload.notificationType, + title: payload.title, + body: payload.body, + channels: payload.channels, + metadata: payload.data, + url: payload.url ?? null, + dedupeKey: payload.dedupeKey, + ...(payload.audience ? { audience: payload.audience } : {}), + }, + { + jobId: "outbox:" + event.id + ":notification", + attempts: 3, + backoff: { type: "exponential", delay: 2000 }, + removeOnComplete: true, + removeOnFail: false, + }, + ); + } + + private validate(value: unknown): NotificationDispatchRequestedV1 { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new NonRetryableOutboxError( + "Notification outbox payload must be an object", + ); + } + + const record = value as Record; + if ( + typeof record.recipientUserId !== "string" || + typeof record.notificationType !== "string" || + typeof record.title !== "string" || + typeof record.body !== "string" || + typeof record.dedupeKey !== "string" || + !Array.isArray(record.channels) || + !record.channels.every((channel) => + Object.values(NotificationChannel).includes( + channel as NotificationChannel, + ), + ) || + !this.isJsonObject(record.data) + ) { + throw new NonRetryableOutboxError( + "Invalid notification dispatch outbox payload", + ); + } + + if ( + record.url !== undefined && + record.url !== null && + typeof record.url !== "string" + ) { + throw new NonRetryableOutboxError("Invalid notification dispatch URL"); + } + + if ( + record.audience !== undefined && + record.audience !== "SHOPPER" && + record.audience !== "STORE" + ) { + throw new NonRetryableOutboxError( + "Invalid notification dispatch audience", + ); + } + + return { + recipientUserId: record.recipientUserId, + notificationType: record.notificationType, + title: record.title, + body: record.body, + data: record.data as Record< + string, + import("@prisma/client").Prisma.JsonValue + >, + channels: record.channels as NotificationChannel[], + dedupeKey: record.dedupeKey, + url: record.url as string | null | undefined, + audience: record.audience as "SHOPPER" | "STORE" | undefined, + }; + } + + private isJsonObject(value: unknown): boolean { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); + } +} diff --git a/apps/backend/src/domains/platform/outbox/handlers/order-auto-confirm-schedule.outbox-handler.spec.ts b/apps/backend/src/domains/platform/outbox/handlers/order-auto-confirm-schedule.outbox-handler.spec.ts new file mode 100644 index 00000000..8747e51c --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/handlers/order-auto-confirm-schedule.outbox-handler.spec.ts @@ -0,0 +1,41 @@ +import { OrderAutoConfirmScheduleOutboxHandler } from "./order-auto-confirm-schedule.outbox-handler"; + +describe("OrderAutoConfirmScheduleOutboxHandler", () => { + it("creates deterministic delayed jobs", async () => { + const warningQueue = { add: jest.fn().mockResolvedValue(undefined) }; + const queue = { add: jest.fn().mockResolvedValue(undefined) }; + const handler = new OrderAutoConfirmScheduleOutboxHandler( + queue as never, + warningQueue as never, + ); + + await handler.handle({ + id: "outbox-1", + topic: "order.auto-confirm.schedule.requested", + version: 1, + aggregateType: "Order", + aggregateId: "order-1", + payload: { orderId: "order-1", expectedStatus: "DISPATCHED" }, + attempts: 1, + lockedAt: new Date(), + lockedBy: "worker-1", + createdAt: new Date(), + }); + + expect(warningQueue.add).toHaveBeenCalledWith( + "auto-confirm-warning", + expect.objectContaining({ orderId: "order-1" }), + expect.objectContaining({ jobId: "auto-confirm-warning-order-1" }), + ); + expect(queue.add).toHaveBeenCalledWith( + "auto-confirm", + expect.objectContaining({ orderId: "order-1" }), + expect.objectContaining({ + jobId: "auto-confirm-order-1", + attempts: 3, + backoff: { type: "exponential", delay: 5000 }, + removeOnFail: false, + }), + ); + }); +}); diff --git a/apps/backend/src/domains/platform/outbox/handlers/order-auto-confirm-schedule.outbox-handler.ts b/apps/backend/src/domains/platform/outbox/handlers/order-auto-confirm-schedule.outbox-handler.ts new file mode 100644 index 00000000..5e888e60 --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/handlers/order-auto-confirm-schedule.outbox-handler.ts @@ -0,0 +1,73 @@ +import { InjectQueue } from "@nestjs/bullmq"; +import { Injectable } from "@nestjs/common"; +import { OrderStatus } from "@prisma/client"; +import { Queue } from "bullmq"; + +import { + AUTO_CONFIRM_QUEUE, + AUTO_CONFIRM_WARNING_QUEUE, +} from "../../../../queue/queue.constants"; +import { NonRetryableOutboxError } from "../outbox.errors"; +import { + ClaimedOutboxEvent, + OrderAutoConfirmScheduleRequestedV1, + OUTBOX_TOPIC, + OutboxHandler, +} from "../outbox.types"; + +const AUTO_CONFIRM_DELAY_MS = 48 * 60 * 60 * 1000; +const AUTO_CONFIRM_WARNING_DELAY_MS = 36 * 60 * 60 * 1000; + +@Injectable() +export class OrderAutoConfirmScheduleOutboxHandler implements OutboxHandler { + readonly topic = OUTBOX_TOPIC.ORDER_AUTO_CONFIRM_SCHEDULE_REQUESTED; + readonly version = 1; + + constructor( + @InjectQueue(AUTO_CONFIRM_QUEUE) private readonly autoConfirmQueue: Queue, + @InjectQueue(AUTO_CONFIRM_WARNING_QUEUE) + private readonly autoConfirmWarningQueue: Queue, + ) {} + + async handle( + event: ClaimedOutboxEvent, + ): Promise { + const payload = this.validate(event.payload); + const jobPayload = { + orderId: payload.orderId, + expectedStatus: OrderStatus.DISPATCHED, + }; + + await this.autoConfirmWarningQueue.add("auto-confirm-warning", jobPayload, { + delay: AUTO_CONFIRM_WARNING_DELAY_MS, + jobId: "auto-confirm-warning-" + payload.orderId, + removeOnComplete: true, + removeOnFail: true, + }); + await this.autoConfirmQueue.add("auto-confirm", jobPayload, { + delay: AUTO_CONFIRM_DELAY_MS, + jobId: "auto-confirm-" + payload.orderId, + removeOnComplete: true, + attempts: 3, + backoff: { type: "exponential", delay: 5000 }, + removeOnFail: false, + }); + } + + private validate(value: unknown): OrderAutoConfirmScheduleRequestedV1 { + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) || + typeof (value as Record).orderId !== "string" || + !(value as Record).orderId || + (value as Record).expectedStatus !== "DISPATCHED" + ) { + throw new NonRetryableOutboxError( + "Invalid order auto-confirm schedule outbox payload", + ); + } + + return value as OrderAutoConfirmScheduleRequestedV1; + } +} diff --git a/apps/backend/src/domains/platform/outbox/handlers/realtime-user-event.outbox-handler.spec.ts b/apps/backend/src/domains/platform/outbox/handlers/realtime-user-event.outbox-handler.spec.ts new file mode 100644 index 00000000..87c1bd28 --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/handlers/realtime-user-event.outbox-handler.spec.ts @@ -0,0 +1,217 @@ +import { REALTIME_EVENT, REALTIME_EVENT_VERSION } from "@twizrr/shared"; + +import { NonRetryableOutboxError } from "../outbox.errors"; +import { RealtimeUserEventOutboxHandler } from "./realtime-user-event.outbox-handler"; + +describe("RealtimeUserEventOutboxHandler", () => { + const makeEvent = (payload: unknown) => ({ + id: "outbox-1", + topic: "realtime.user-event.requested", + version: 1, + aggregateType: "Order", + aggregateId: "order-1", + payload, + attempts: 1, + lockedAt: new Date(), + lockedBy: "worker-1", + createdAt: new Date("2026-07-12T10:00:00.000Z"), + }); + + it("emits the shared envelope using the outbox event identity", async () => { + const realtime = { emitToUser: jest.fn() }; + const handler = new RealtimeUserEventOutboxHandler(realtime as never); + + await handler.handle( + makeEvent({ + recipientUserId: "buyer-1", + eventType: REALTIME_EVENT.ORDER_UPDATED, + eventVersion: REALTIME_EVENT_VERSION.ORDER_UPDATED, + data: { + orderId: "order-1", + status: "PREPARING", + previousStatus: "PAID", + updatedAt: "2026-07-12T10:00:00.000Z", + }, + }) as never, + ); + + expect(realtime.emitToUser).toHaveBeenCalledWith( + "buyer-1", + expect.objectContaining({ + id: "outbox-1", + type: REALTIME_EVENT.ORDER_UPDATED, + occurredAt: "2026-07-12T10:00:00.000Z", + }), + ); + }); + + it.each([ + [ + "dispute:created", + { + recipientUserId: "buyer-1", + eventType: REALTIME_EVENT.DISPUTE_CREATED, + eventVersion: REALTIME_EVENT_VERSION.DISPUTE_CREATED, + data: { + disputeId: "dispute-1", + orderId: "order-1", + status: "OPEN", + createdAt: "2026-07-12T10:00:00.000Z", + }, + }, + ], + [ + "dispute:updated", + { + recipientUserId: "admin-1", + eventType: REALTIME_EVENT.DISPUTE_UPDATED, + eventVersion: REALTIME_EVENT_VERSION.DISPUTE_UPDATED, + data: { + disputeId: "dispute-1", + orderId: "order-1", + status: "STORE_RESPONDED", + action: "STORE_RESPONDED", + updatedAt: "2026-07-12T10:00:00.000Z", + }, + }, + ], + [ + "dispute:resolved", + { + recipientUserId: "merchant-user-1", + eventType: REALTIME_EVENT.DISPUTE_RESOLVED, + eventVersion: REALTIME_EVENT_VERSION.DISPUTE_RESOLVED, + data: { + disputeId: "dispute-1", + orderId: "order-1", + status: "RESOLVED_BUYER", + outcome: "BUYER_WINS", + resolvedAt: "2026-07-12T10:00:00.000Z", + }, + }, + ], + [ + "dispute:settlement-updated", + { + recipientUserId: "buyer-1", + eventType: REALTIME_EVENT.DISPUTE_SETTLEMENT_UPDATED, + eventVersion: REALTIME_EVENT_VERSION.DISPUTE_SETTLEMENT_UPDATED, + data: { + settlementId: "settlement-1", + disputeId: "dispute-1", + orderId: "order-1", + status: "PROCESSING", + refundStatus: "SUBMITTED", + updatedAt: "2026-07-12T10:00:00.000Z", + }, + }, + ], + ])( + "emits valid %s dispute events to the recipient", + async (type, payload) => { + const realtime = { emitToUser: jest.fn() }; + const handler = new RealtimeUserEventOutboxHandler(realtime as never); + + await handler.handle(makeEvent(payload) as never); + + expect(realtime.emitToUser).toHaveBeenCalledWith( + payload.recipientUserId, + expect.objectContaining({ id: "outbox-1", type }), + ); + }, + ); + + it("treats a valid dispute event with no connected socket as delivered", async () => { + // emitToUser is a best-effort no-op when the recipient has no socket; + // the handler must still resolve so the outbox row is marked PROCESSED. + const realtime = { emitToUser: jest.fn().mockReturnValue(undefined) }; + const handler = new RealtimeUserEventOutboxHandler(realtime as never); + + await expect( + handler.handle( + makeEvent({ + recipientUserId: "offline-user", + eventType: REALTIME_EVENT.DISPUTE_UPDATED, + eventVersion: REALTIME_EVENT_VERSION.DISPUTE_UPDATED, + data: { + disputeId: "dispute-1", + orderId: "order-1", + status: "CLOSED", + action: "CLOSED", + updatedAt: "2026-07-12T10:00:00.000Z", + }, + }) as never, + ), + ).resolves.toBeUndefined(); + expect(realtime.emitToUser).toHaveBeenCalledTimes(1); + }); + + it.each([ + { + recipientUserId: "buyer-1", + eventType: "unknown", + eventVersion: 1, + data: {}, + }, + { + recipientUserId: "buyer-1", + eventType: REALTIME_EVENT.ORDER_UPDATED, + eventVersion: 2, + data: {}, + }, + { + recipientUserId: "buyer-1", + eventType: REALTIME_EVENT.ORDER_UPDATED, + eventVersion: 1, + data: { orderId: "", status: "PAID", updatedAt: "not-a-date" }, + }, + { + // Unsupported version for a dispute event. + recipientUserId: "buyer-1", + eventType: REALTIME_EVENT.DISPUTE_CREATED, + eventVersion: REALTIME_EVENT_VERSION.DISPUTE_CREATED + 1, + data: { + disputeId: "dispute-1", + orderId: "order-1", + status: "OPEN", + createdAt: "2026-07-12T10:00:00.000Z", + }, + }, + { + // Unknown dispute:updated action. + recipientUserId: "buyer-1", + eventType: REALTIME_EVENT.DISPUTE_UPDATED, + eventVersion: REALTIME_EVENT_VERSION.DISPUTE_UPDATED, + data: { + disputeId: "dispute-1", + orderId: "order-1", + status: "OPEN", + action: "NOT_A_REAL_ACTION", + updatedAt: "2026-07-12T10:00:00.000Z", + }, + }, + { + // Missing dispute id. + recipientUserId: "buyer-1", + eventType: REALTIME_EVENT.DISPUTE_RESOLVED, + eventVersion: REALTIME_EVENT_VERSION.DISPUTE_RESOLVED, + data: { + orderId: "order-1", + status: "RESOLVED_BUYER", + outcome: "BUYER_WINS", + resolvedAt: "2026-07-12T10:00:00.000Z", + }, + }, + ])( + "dead-letters unsupported or malformed persisted payloads", + async (payload) => { + const handler = new RealtimeUserEventOutboxHandler({ + emitToUser: jest.fn(), + } as never); + + await expect( + handler.handle(makeEvent(payload) as never), + ).rejects.toBeInstanceOf(NonRetryableOutboxError); + }, + ); +}); diff --git a/apps/backend/src/domains/platform/outbox/handlers/realtime-user-event.outbox-handler.ts b/apps/backend/src/domains/platform/outbox/handlers/realtime-user-event.outbox-handler.ts new file mode 100644 index 00000000..c7f09a1d --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/handlers/realtime-user-event.outbox-handler.ts @@ -0,0 +1,191 @@ +import { Injectable } from "@nestjs/common"; +import { + REALTIME_EVENT, + REALTIME_EVENT_VERSION, + type RealtimeEventEnvelope, +} from "@twizrr/shared"; +import { Prisma } from "@prisma/client"; + +import { RealtimeService } from "../../../social/realtime/realtime.service"; +import { NonRetryableOutboxError } from "../outbox.errors"; +import { + ClaimedOutboxEvent, + OUTBOX_TOPIC, + OutboxHandler, + RealtimeUserEventRequestedV1, + SupportedRealtimeUserEvent, +} from "../outbox.types"; + +const DISPUTE_UPDATED_ACTIONS = new Set([ + "STORE_RESPONDED", + "EVIDENCE_ADDED", + "UNDER_REVIEW", + "CLOSED", +]); + +const EVENT_VERSIONS: Record = { + [REALTIME_EVENT.ORDER_CREATED]: REALTIME_EVENT_VERSION.ORDER_CREATED, + [REALTIME_EVENT.ORDER_UPDATED]: REALTIME_EVENT_VERSION.ORDER_UPDATED, + [REALTIME_EVENT.PAYMENT_CONFIRMED]: REALTIME_EVENT_VERSION.PAYMENT_CONFIRMED, + [REALTIME_EVENT.DELIVERY_UPDATED]: REALTIME_EVENT_VERSION.DELIVERY_UPDATED, + [REALTIME_EVENT.DISPUTE_CREATED]: REALTIME_EVENT_VERSION.DISPUTE_CREATED, + [REALTIME_EVENT.DISPUTE_UPDATED]: REALTIME_EVENT_VERSION.DISPUTE_UPDATED, + [REALTIME_EVENT.DISPUTE_RESOLVED]: REALTIME_EVENT_VERSION.DISPUTE_RESOLVED, + [REALTIME_EVENT.DISPUTE_SETTLEMENT_UPDATED]: + REALTIME_EVENT_VERSION.DISPUTE_SETTLEMENT_UPDATED, +}; + +@Injectable() +export class RealtimeUserEventOutboxHandler implements OutboxHandler { + readonly topic = OUTBOX_TOPIC.REALTIME_USER_EVENT_REQUESTED; + readonly version = 1; + + constructor(private readonly realtime: RealtimeService) {} + + async handle( + event: ClaimedOutboxEvent, + ): Promise { + const payload = this.validate(event.payload); + const envelope: RealtimeEventEnvelope = { + id: event.id, + type: payload.eventType, + version: payload.eventVersion, + occurredAt: event.createdAt.toISOString(), + data: payload.data, + }; + + this.realtime.emitToUser(payload.recipientUserId, envelope); + } + + private validate(value: unknown): RealtimeUserEventRequestedV1 { + if (!this.isRecord(value)) { + throw new NonRetryableOutboxError( + "Realtime user event outbox payload must be an object", + ); + } + + const recipientUserId = value.recipientUserId; + const eventType = value.eventType; + const eventVersion = value.eventVersion; + const data = value.data; + + if ( + typeof recipientUserId !== "string" || + !recipientUserId.trim() || + typeof eventType !== "string" || + !this.isSupportedEvent(eventType) || + typeof eventVersion !== "number" || + eventVersion !== EVENT_VERSIONS[eventType] || + !this.isRecord(data) || + !this.isValidData(eventType, data) + ) { + throw new NonRetryableOutboxError( + "Invalid realtime user event outbox payload", + ); + } + + return { + recipientUserId, + eventType, + eventVersion, + data: data as Record, + }; + } + + private isSupportedEvent(value: string): value is SupportedRealtimeUserEvent { + return Object.prototype.hasOwnProperty.call(EVENT_VERSIONS, value); + } + + private isValidData( + eventType: SupportedRealtimeUserEvent, + data: Record, + ): boolean { + if (eventType === REALTIME_EVENT.ORDER_CREATED) { + return ( + this.nonEmptyString(data.orderId) && + this.nonEmptyString(data.status) && + this.validTimestamp(data.createdAt) + ); + } + if (eventType === REALTIME_EVENT.ORDER_UPDATED) { + return ( + this.nonEmptyString(data.orderId) && + this.nonEmptyString(data.status) && + (data.previousStatus === undefined || + this.nonEmptyString(data.previousStatus)) && + this.validTimestamp(data.updatedAt) + ); + } + if (eventType === REALTIME_EVENT.PAYMENT_CONFIRMED) { + return ( + this.nonEmptyString(data.paymentId) && + this.nonEmptyString(data.orderId) && + data.status === "SUCCESS" && + this.validTimestamp(data.confirmedAt) + ); + } + if (eventType === REALTIME_EVENT.DISPUTE_CREATED) { + return ( + this.nonEmptyString(data.disputeId) && + this.nonEmptyString(data.orderId) && + this.nonEmptyString(data.status) && + this.validTimestamp(data.createdAt) + ); + } + if (eventType === REALTIME_EVENT.DISPUTE_UPDATED) { + return ( + this.nonEmptyString(data.disputeId) && + this.nonEmptyString(data.orderId) && + this.nonEmptyString(data.status) && + typeof data.action === "string" && + DISPUTE_UPDATED_ACTIONS.has(data.action) && + this.validTimestamp(data.updatedAt) + ); + } + if (eventType === REALTIME_EVENT.DISPUTE_RESOLVED) { + return ( + this.nonEmptyString(data.disputeId) && + this.nonEmptyString(data.orderId) && + this.nonEmptyString(data.status) && + this.nonEmptyString(data.outcome) && + this.validTimestamp(data.resolvedAt) + ); + } + if (eventType === REALTIME_EVENT.DISPUTE_SETTLEMENT_UPDATED) { + return ( + this.nonEmptyString(data.settlementId) && + this.nonEmptyString(data.disputeId) && + this.nonEmptyString(data.orderId) && + this.nonEmptyString(data.status) && + (data.refundStatus === undefined || + this.nonEmptyString(data.refundStatus)) && + (data.payoutStatus === undefined || + this.nonEmptyString(data.payoutStatus)) && + this.validTimestamp(data.updatedAt) + ); + } + return ( + this.nonEmptyString(data.deliveryBookingId) && + this.nonEmptyString(data.orderId) && + this.nonEmptyString(data.status) && + this.validTimestamp(data.updatedAt) + ); + } + + private isRecord(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); + } + + private nonEmptyString(value: unknown): boolean { + return typeof value === "string" && value.trim().length > 0; + } + + private validTimestamp(value: unknown): boolean { + return typeof value === "string" && !Number.isNaN(Date.parse(value)); + } +} diff --git a/apps/backend/src/domains/platform/outbox/handlers/whatsapp-direct-order-notification.outbox-handler.spec.ts b/apps/backend/src/domains/platform/outbox/handlers/whatsapp-direct-order-notification.outbox-handler.spec.ts new file mode 100644 index 00000000..eec2196e --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/handlers/whatsapp-direct-order-notification.outbox-handler.spec.ts @@ -0,0 +1,105 @@ +import { WhatsAppDirectOrderNotificationOutboxHandler } from "./whatsapp-direct-order-notification.outbox-handler"; + +describe("WhatsAppDirectOrderNotificationOutboxHandler", () => { + it("enqueues the allowlisted WhatsApp job with a deterministic id", async () => { + const queue = { add: jest.fn().mockResolvedValue({ id: "job-1" }) }; + const handler = new WhatsAppDirectOrderNotificationOutboxHandler( + queue as never, + ); + + await handler.handle({ + id: "outbox-1", + topic: "whatsapp.direct-order-notification.requested", + version: 1, + aggregateType: "PAYMENT", + aggregateId: "payment-1", + payload: { + storeId: "store-1", + orderData: { + orderId: "order-1", + productName: "Canvas Tote", + quantity: 2, + amountKobo: "200000", + }, + }, + attempts: 1, + lockedAt: new Date(), + lockedBy: "worker-1", + createdAt: new Date(), + }); + + expect(queue.add).toHaveBeenCalledWith( + "send-direct-order-notification", + { + storeId: "store-1", + orderData: { + orderId: "order-1", + productName: "Canvas Tote", + quantity: 2, + amountKobo: "200000", + }, + }, + { + jobId: "outbox:outbox-1:whatsapp-direct-order", + attempts: 3, + backoff: { type: "exponential", delay: 5000 }, + removeOnComplete: { age: 86400 }, + removeOnFail: false, + }, + ); + }); + + it("rejects malformed persisted payloads without retrying forever", async () => { + const handler = new WhatsAppDirectOrderNotificationOutboxHandler({ + add: jest.fn(), + } as never); + + await expect( + handler.handle({ + id: "outbox-1", + topic: "whatsapp.direct-order-notification.requested", + version: 1, + aggregateType: "PAYMENT", + aggregateId: "payment-1", + payload: { + storeId: "store-1", + orderData: { quantity: 0 }, + } as never, + attempts: 1, + lockedAt: new Date(), + lockedBy: "worker-1", + createdAt: new Date(), + }), + ).rejects.toThrow("Invalid WhatsApp direct-order notification data"); + }); + + it("propagates queue failures so the outbox relay can retry", async () => { + const enqueueError = new Error("Redis unavailable"); + const handler = new WhatsAppDirectOrderNotificationOutboxHandler({ + add: jest.fn().mockRejectedValue(enqueueError), + } as never); + + await expect( + handler.handle({ + id: "outbox-1", + topic: "whatsapp.direct-order-notification.requested", + version: 1, + aggregateType: "PAYMENT", + aggregateId: "payment-1", + payload: { + storeId: "store-1", + orderData: { + orderId: "order-1", + productName: "Canvas Tote", + quantity: 2, + amountKobo: "200000", + }, + }, + attempts: 1, + lockedAt: new Date(), + lockedBy: "worker-1", + createdAt: new Date(), + }), + ).rejects.toBe(enqueueError); + }); +}); diff --git a/apps/backend/src/domains/platform/outbox/handlers/whatsapp-direct-order-notification.outbox-handler.ts b/apps/backend/src/domains/platform/outbox/handlers/whatsapp-direct-order-notification.outbox-handler.ts new file mode 100644 index 00000000..8cab1e92 --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/handlers/whatsapp-direct-order-notification.outbox-handler.ts @@ -0,0 +1,86 @@ +import { InjectQueue } from "@nestjs/bullmq"; +import { Injectable } from "@nestjs/common"; +import { Queue } from "bullmq"; + +import { QUEUE } from "../../../../queue/queue.constants"; +import { NonRetryableOutboxError } from "../outbox.errors"; +import { + ClaimedOutboxEvent, + OUTBOX_TOPIC, + OutboxHandler, + WhatsAppDirectOrderNotificationRequestedV1, +} from "../outbox.types"; + +@Injectable() +export class WhatsAppDirectOrderNotificationOutboxHandler implements OutboxHandler { + readonly topic = OUTBOX_TOPIC.WHATSAPP_DIRECT_ORDER_NOTIFICATION_REQUESTED; + readonly version = 1; + + constructor( + @InjectQueue(QUEUE.WHATSAPP) + private readonly whatsAppQueue: Queue, + ) {} + + async handle( + event: ClaimedOutboxEvent, + ): Promise { + const payload = this.validate(event.payload); + + await this.whatsAppQueue.add("send-direct-order-notification", payload, { + jobId: `outbox:${event.id}:whatsapp-direct-order`, + attempts: 3, + backoff: { type: "exponential", delay: 5000 }, + removeOnComplete: { age: 24 * 60 * 60 }, + removeOnFail: false, + }); + } + + private validate(value: unknown): WhatsAppDirectOrderNotificationRequestedV1 { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new NonRetryableOutboxError( + "WhatsApp direct-order outbox payload must be an object", + ); + } + + const record = value as Record; + const orderData = record.orderData; + if ( + typeof record.storeId !== "string" || + record.storeId.trim().length === 0 || + typeof orderData !== "object" || + orderData === null || + Array.isArray(orderData) + ) { + throw new NonRetryableOutboxError( + "Invalid WhatsApp direct-order outbox payload", + ); + } + + const order = orderData as Record; + if ( + typeof order.orderId !== "string" || + order.orderId.trim().length === 0 || + typeof order.productName !== "string" || + order.productName.trim().length === 0 || + typeof order.quantity !== "number" || + !Number.isSafeInteger(order.quantity) || + order.quantity <= 0 || + typeof order.amountKobo !== "string" || + !/^\d+$/.test(order.amountKobo) + ) { + throw new NonRetryableOutboxError( + "Invalid WhatsApp direct-order notification data", + ); + } + + return { + storeId: record.storeId, + orderData: { + orderId: order.orderId, + productName: order.productName, + quantity: order.quantity, + amountKobo: order.amountKobo, + }, + }; + } +} diff --git a/apps/backend/src/domains/platform/outbox/outbox-handler.registry.spec.ts b/apps/backend/src/domains/platform/outbox/outbox-handler.registry.spec.ts new file mode 100644 index 00000000..4f741713 --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/outbox-handler.registry.spec.ts @@ -0,0 +1,34 @@ +import { OutboxHandlerRegistry } from "./outbox-handler.registry"; + +describe("OutboxHandlerRegistry", () => { + const handler = (topic: string, version: number) => ({ + topic, + version, + handle: jest.fn(), + }); + + it("resolves handlers by topic and version", () => { + const registered = handler("topic-a", 1); + const registry = new OutboxHandlerRegistry([registered]); + + expect(registry.resolve("topic-a", 1)).toBe(registered); + }); + + it("rejects duplicate topic/version registrations", () => { + expect( + () => + new OutboxHandlerRegistry([ + handler("topic-a", 1), + handler("topic-a", 1), + ]), + ).toThrow("Duplicate outbox handler registration"); + }); + + it("throws a non-retryable error for a missing handler", () => { + const registry = new OutboxHandlerRegistry([]); + + expect(() => registry.resolve("missing", 1)).toThrow( + "No outbox handler registered", + ); + }); +}); diff --git a/apps/backend/src/domains/platform/outbox/outbox-handler.registry.ts b/apps/backend/src/domains/platform/outbox/outbox-handler.registry.ts new file mode 100644 index 00000000..a65fa587 --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/outbox-handler.registry.ts @@ -0,0 +1,60 @@ +import { Inject, Injectable } from "@nestjs/common"; + +import { MissingOutboxHandlerError } from "./outbox.errors"; +import { OutboxHandler } from "./outbox.types"; + +export const OUTBOX_HANDLER_TOKEN = Symbol("OUTBOX_HANDLER_TOKEN"); + +@Injectable() +export class OutboxHandlerRegistry { + private readonly handlers = new Map(); + + constructor( + @Inject(OUTBOX_HANDLER_TOKEN) registeredHandlers: OutboxHandler[], + ) { + for (const handler of registeredHandlers) { + const key = this.key(handler.topic, handler.version); + if (this.handlers.has(key)) { + throw new Error( + "Duplicate outbox handler registration for topic=" + + handler.topic + + " version=" + + handler.version, + ); + } + this.handlers.set(key, handler); + } + } + + /** + * Registers a handler at runtime. Lets a handler that lives in another module + * (e.g. settlement execution) self-register on init without OutboxModule + * importing that module — avoiding a module dependency cycle. + */ + register(handler: OutboxHandler): void { + const key = this.key(handler.topic, handler.version); + if (this.handlers.has(key)) { + return; + } + this.handlers.set(key, handler); + } + + resolve(topic: string, version: number): OutboxHandler { + const handler = this.handlers.get(this.key(topic, version)); + if (!handler) { + throw new MissingOutboxHandlerError(topic, version); + } + return handler; + } + + list(): Array<{ topic: string; version: number }> { + return [...this.handlers.values()].map(({ topic, version }) => ({ + topic, + version, + })); + } + + private key(topic: string, version: number): string { + return topic + ":v" + version; + } +} diff --git a/apps/backend/src/domains/platform/outbox/outbox-relay.service.spec.ts b/apps/backend/src/domains/platform/outbox/outbox-relay.service.spec.ts new file mode 100644 index 00000000..1f5a8955 --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/outbox-relay.service.spec.ts @@ -0,0 +1,202 @@ +import { Logger } from "@nestjs/common"; + +import { OutboxRelayService } from "./outbox-relay.service"; +import { NonRetryableOutboxError } from "./outbox.errors"; + +describe("OutboxRelayService", () => { + const makeEvent = (overrides: Record = {}) => ({ + id: "outbox-1", + topic: "topic-a", + version: 1, + aggregateType: "Payment", + aggregateId: "payment-1", + payload: { ok: true }, + attempts: 1, + lockedAt: new Date(), + lockedBy: "worker", + createdAt: new Date(), + ...overrides, + }); + + const makeRelay = ( + overrides: { + handler?: { handle: jest.Mock }; + event?: Record; + config?: Record; + } = {}, + ) => { + const event = makeEvent(overrides.event); + const queryRaw = jest.fn().mockResolvedValue([event]); + const updateMany = jest.fn().mockResolvedValue({ count: 1 }); + const prisma = { + $transaction: jest.fn(async (callback: (tx: unknown) => unknown) => + callback({ $queryRawUnsafe: queryRaw }), + ), + outboxEvent: { updateMany }, + }; + const registry = { + resolve: jest.fn().mockReturnValue( + overrides.handler ?? { + handle: jest.fn().mockResolvedValue(undefined), + }, + ), + }; + const scheduler = { + addInterval: jest.fn(), + doesExist: jest.fn().mockReturnValue(false), + deleteInterval: jest.fn(), + }; + const values = { + "outbox.relayEnabled": true, + "outbox.pollIntervalMs": 1000, + "outbox.batchSize": 50, + "outbox.processingConcurrency": 1, + "outbox.lockTtlSeconds": 60, + "outbox.maxAttempts": 3, + "outbox.baseRetryDelayMs": 1, + ...(overrides.config ?? {}), + }; + const config = { + get: jest.fn((key: string) => values[key as keyof typeof values]), + }; + + return { + relay: new OutboxRelayService( + prisma as never, + config as never, + scheduler as never, + registry as never, + ), + prisma, + registry, + updateMany, + queryRaw, + }; + }; + + it("processes a claimed event and conditionally marks it processed", async () => { + const { relay, updateMany, registry } = makeRelay(); + + await relay.pollOnce(); + + expect(registry.resolve).toHaveBeenCalledWith("topic-a", 1); + expect(updateMany).toHaveBeenCalledWith({ + where: { + id: "outbox-1", + status: "PROCESSING", + lockedBy: expect.any(String), + }, + data: expect.objectContaining({ status: "PROCESSED" }), + }); + }); + + it("reschedules retryable failures with a persisted delay", async () => { + const handler = { + handle: jest.fn().mockRejectedValue(new Error("redis down")), + }; + const { relay, updateMany } = makeRelay({ handler }); + + await relay.pollOnce(); + + expect(updateMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ + id: "outbox-1", + status: "PROCESSING", + lockedBy: expect.any(String), + }), + data: expect.objectContaining({ + status: "PENDING", + availableAt: expect.any(Date), + lastError: "redis down", + }), + }); + }); + + it("dead-letters permanent failures without retrying", async () => { + const handler = { + handle: jest + .fn() + .mockRejectedValue(new NonRetryableOutboxError("invalid payload")), + }; + const { relay, updateMany } = makeRelay({ handler }); + + await relay.pollOnce(); + + expect(updateMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ status: "PROCESSING" }), + data: expect.objectContaining({ + status: "DEAD", + availableAt: undefined, + lastError: "invalid payload", + }), + }); + }); + + it("does not overlap polling within one process", async () => { + let release: (() => void) | undefined; + const queryRaw = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + release = () => resolve([]); + }), + ); + const prisma = { + $transaction: jest.fn(async (callback: (tx: unknown) => unknown) => + callback({ $queryRawUnsafe: queryRaw }), + ), + outboxEvent: { updateMany: jest.fn() }, + }; + const registry = { resolve: jest.fn() }; + const scheduler = { + addInterval: jest.fn(), + doesExist: jest.fn().mockReturnValue(false), + deleteInterval: jest.fn(), + }; + const config = { + get: jest.fn((key: string) => + key === "outbox.relayEnabled" + ? true + : key === "outbox.processingConcurrency" + ? 1 + : 50, + ), + }; + const relay = new OutboxRelayService( + prisma as never, + config as never, + scheduler as never, + registry as never, + ); + + const first = relay.pollOnce(); + await Promise.resolve(); + const second = relay.pollOnce(); + expect(queryRaw).toHaveBeenCalledTimes(1); + release?.(); + await Promise.all([first, second]); + }); + it("logs unexpected startup and interval poll failures", async () => { + jest.useFakeTimers(); + const { relay } = makeRelay(); + const logError = jest + .spyOn(Logger.prototype, "error") + .mockImplementation(() => undefined); + const pollOnce = jest + .spyOn(relay, "pollOnce") + .mockRejectedValue(new Error("database unavailable")); + + relay.onModuleInit(); + await Promise.resolve(); + jest.advanceTimersByTime(1000); + await Promise.resolve(); + + expect(pollOnce).toHaveBeenCalledTimes(2); + expect(logError).toHaveBeenCalledWith( + expect.stringContaining("Outbox relay poll failed"), + ); + + await relay.onModuleDestroy(); + logError.mockRestore(); + jest.useRealTimers(); + }); +}); diff --git a/apps/backend/src/domains/platform/outbox/outbox-relay.service.ts b/apps/backend/src/domains/platform/outbox/outbox-relay.service.ts new file mode 100644 index 00000000..fa8bd0ef --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/outbox-relay.service.ts @@ -0,0 +1,291 @@ +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { SchedulerRegistry } from "@nestjs/schedule"; +import { OutboxStatus, Prisma } from "@prisma/client"; +import { randomUUID } from "crypto"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { NonRetryableOutboxError } from "./outbox.errors"; +import { OutboxHandlerRegistry } from "./outbox-handler.registry"; +import { ClaimedOutboxEvent } from "./outbox.types"; + +interface RawClaimedOutboxEvent { + id: string; + topic: string; + version: number; + aggregateType: string | null; + aggregateId: string | null; + payload: Prisma.JsonValue; + attempts: number; + lockedAt: Date; + lockedBy: string; + createdAt: Date; +} + +@Injectable() +export class OutboxRelayService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(OutboxRelayService.name); + private readonly workerId = "outbox-" + process.pid + "-" + randomUUID(); + private readonly enabled: boolean; + private readonly pollIntervalMs: number; + private readonly batchSize: number; + private readonly concurrency: number; + private readonly lockTtlSeconds: number; + private readonly maxAttempts: number; + private readonly baseRetryDelayMs: number; + private polling = false; + private stopping = false; + private activePoll: Promise | null = null; + + constructor( + private readonly prisma: PrismaService, + private readonly config: ConfigService, + private readonly scheduler: SchedulerRegistry, + private readonly registry: OutboxHandlerRegistry, + ) { + this.enabled = config.get("outbox.relayEnabled") ?? true; + this.pollIntervalMs = config.get("outbox.pollIntervalMs") ?? 1000; + this.batchSize = config.get("outbox.batchSize") ?? 50; + this.concurrency = config.get("outbox.processingConcurrency") ?? 5; + this.lockTtlSeconds = config.get("outbox.lockTtlSeconds") ?? 60; + this.maxAttempts = config.get("outbox.maxAttempts") ?? 10; + this.baseRetryDelayMs = + config.get("outbox.baseRetryDelayMs") ?? 2000; + } + + onModuleInit(): void { + if (!this.enabled) { + this.logger.log("Outbox relay disabled"); + return; + } + + const interval = setInterval(() => { + this.triggerPoll(); + }, this.pollIntervalMs); + this.scheduler.addInterval(this.workerId, interval); + this.logger.log( + "Outbox relay enabled workerId=" + + this.workerId + + " pollIntervalMs=" + + this.pollIntervalMs, + ); + this.triggerPoll(); + } + + async pollOnce(): Promise { + if (!this.enabled || this.stopping || this.polling) { + return; + } + + this.polling = true; + const work = this.runPoll(); + this.activePoll = work; + try { + await work; + } finally { + this.activePoll = null; + this.polling = false; + } + } + + private triggerPoll(): void { + void this.pollOnce().catch((error: unknown) => { + this.logger.error( + "Outbox relay poll failed workerId=" + + this.workerId + + " reason=" + + this.sanitizeError(error), + ); + }); + } + async onModuleDestroy(): Promise { + this.stopping = true; + if (this.scheduler.doesExist("interval", this.workerId)) { + this.scheduler.deleteInterval(this.workerId); + } + + const activePoll = this.activePoll; + if (activePoll) { + await Promise.race([ + activePoll, + new Promise((resolve) => setTimeout(resolve, 10_000)), + ]); + } + this.logger.log("Outbox relay shutdown workerId=" + this.workerId); + } + + private async runPoll(): Promise { + const events = await this.claimBatch(); + if (events.length === 0) { + return; + } + + this.logger.log( + "Outbox batch claimed count=" + + events.length + + " workerId=" + + this.workerId, + ); + + let cursor = 0; + const processNext = async (): Promise => { + const event = events[cursor++]; + if (!event) { + return; + } + await this.processEvent(event); + await processNext(); + }; + + await Promise.all( + Array.from({ length: Math.min(this.concurrency, events.length) }, () => + processNext(), + ), + ); + } + + private async claimBatch(): Promise { + const staleCutoff = new Date(Date.now() - this.lockTtlSeconds * 1000); + const sql = [ + "WITH candidates AS (", + ' SELECT id FROM "outbox_events"', + " WHERE (status = 'PENDING' AND available_at <= NOW())", + " OR (status = 'PROCESSING' AND locked_at IS NOT NULL AND locked_at < $1)", + " ORDER BY created_at ASC", + " FOR UPDATE SKIP LOCKED", + " LIMIT $2", + ")", + 'UPDATE "outbox_events" AS event', + "SET status = 'PROCESSING'::\"OutboxStatus\", locked_at = NOW(), locked_by = $3, attempts = event.attempts + 1, updated_at = NOW()", + "FROM candidates", + "WHERE event.id = candidates.id", + 'RETURNING event.id, event.topic, event.version, event.aggregate_type AS "aggregateType", event.aggregate_id AS "aggregateId", event.payload, event.attempts, event.locked_at AS "lockedAt", event.locked_by AS "lockedBy", event.created_at AS "createdAt"', + ].join(" "); + return this.prisma.$transaction(async (tx) => { + const rows = await tx.$queryRawUnsafe( + sql, + staleCutoff, + this.batchSize, + this.workerId, + ); + const staleCount = rows.filter( + (row) => row.attempts > 1 && row.lockedBy === this.workerId, + ).length; + if (staleCount > 0) { + this.logger.warn( + "Outbox stale locks reclaimed count=" + + staleCount + + " workerId=" + + this.workerId, + ); + } + return rows.map((row) => ({ ...row })); + }); + } + + private async processEvent(event: ClaimedOutboxEvent): Promise { + try { + const handler = this.registry.resolve(event.topic, event.version); + await handler.handle(event); + const result = await this.prisma.outboxEvent.updateMany({ + where: { + id: event.id, + status: OutboxStatus.PROCESSING, + lockedBy: this.workerId, + }, + data: { + status: OutboxStatus.PROCESSED, + processedAt: new Date(), + lockedAt: null, + lockedBy: null, + lastError: null, + }, + }); + if (result.count === 1) { + this.logger.log( + "Outbox event processed id=" + + event.id + + " topic=" + + event.topic + + " version=" + + event.version + + " aggregateType=" + + (event.aggregateType ?? "none") + + " aggregateId=" + + (event.aggregateId ?? "none") + + " attempt=" + + event.attempts + + " workerId=" + + this.workerId, + ); + } + } catch (error) { + const permanent = error instanceof NonRetryableOutboxError; + const lastError = this.sanitizeError(error); + const retry = !permanent && event.attempts < this.maxAttempts; + const availableAt = new Date( + Date.now() + this.retryDelayMs(event.attempts), + ); + + const result = await this.prisma.outboxEvent.updateMany({ + where: { + id: event.id, + status: OutboxStatus.PROCESSING, + lockedBy: this.workerId, + }, + data: { + status: retry ? OutboxStatus.PENDING : OutboxStatus.DEAD, + availableAt: retry ? availableAt : undefined, + lockedAt: null, + lockedBy: null, + lastError, + }, + }); + + if (result.count !== 1) { + return; + } + + const message = + "Outbox event " + + (retry ? "retry scheduled" : "dead-lettered") + + " id=" + + event.id + + " topic=" + + event.topic + + " version=" + + event.version + + " attempt=" + + event.attempts + + " workerId=" + + this.workerId; + if (retry) { + this.logger.warn(message); + } else { + this.logger.error(message); + } + } + } + + private retryDelayMs(attempt: number): number { + const exponential = this.baseRetryDelayMs * 2 ** Math.max(0, attempt - 1); + const capped = Math.min(exponential, 300_000); + return Math.round(capped * (0.8 + Math.random() * 0.4)); + } + + private sanitizeError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message + .replace( + /(token|secret|password|authorization|cookie|signature)=?[^ ]*/gi, + "$1=[redacted]", + ) + .replace(/\s+/g, " ") + .slice(0, 500); + } +} diff --git a/apps/backend/src/domains/platform/outbox/outbox.errors.ts b/apps/backend/src/domains/platform/outbox/outbox.errors.ts new file mode 100644 index 00000000..026691b9 --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/outbox.errors.ts @@ -0,0 +1,15 @@ +export class NonRetryableOutboxError extends Error { + constructor(message: string) { + super(message); + this.name = "NonRetryableOutboxError"; + } +} + +export class MissingOutboxHandlerError extends NonRetryableOutboxError { + constructor(topic: string, version: number) { + super( + "No outbox handler registered for topic=" + topic + " version=" + version, + ); + this.name = "MissingOutboxHandlerError"; + } +} diff --git a/apps/backend/src/domains/platform/outbox/outbox.module.ts b/apps/backend/src/domains/platform/outbox/outbox.module.ts new file mode 100644 index 00000000..9312a278 --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/outbox.module.ts @@ -0,0 +1,53 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; + +import { PrismaModule } from "../../../core/prisma/prisma.module"; +import { QueueModule } from "../../../queue/queue.module"; +import { RealtimeModule } from "../../social/realtime/realtime.module"; +import { CommerceOutboxService } from "./commerce-outbox.service"; +import { OrderAutoConfirmScheduleOutboxHandler } from "./handlers/order-auto-confirm-schedule.outbox-handler"; +import { NotificationDispatchOutboxHandler } from "./handlers/notification-dispatch.outbox-handler"; +import { RealtimeUserEventOutboxHandler } from "./handlers/realtime-user-event.outbox-handler"; +import { WhatsAppDirectOrderNotificationOutboxHandler } from "./handlers/whatsapp-direct-order-notification.outbox-handler"; +import { + OUTBOX_HANDLER_TOKEN, + OutboxHandlerRegistry, +} from "./outbox-handler.registry"; +import { OutboxRelayService } from "./outbox-relay.service"; +import { OutboxService } from "./outbox.service"; + +@Module({ + imports: [PrismaModule, QueueModule, ConfigModule, RealtimeModule], + providers: [ + OutboxService, + CommerceOutboxService, + OutboxRelayService, + NotificationDispatchOutboxHandler, + RealtimeUserEventOutboxHandler, + OrderAutoConfirmScheduleOutboxHandler, + WhatsAppDirectOrderNotificationOutboxHandler, + OutboxHandlerRegistry, + { + provide: OUTBOX_HANDLER_TOKEN, + useFactory: ( + notificationHandler: NotificationDispatchOutboxHandler, + realtimeHandler: RealtimeUserEventOutboxHandler, + autoConfirmHandler: OrderAutoConfirmScheduleOutboxHandler, + whatsAppDirectOrderHandler: WhatsAppDirectOrderNotificationOutboxHandler, + ) => [ + notificationHandler, + realtimeHandler, + autoConfirmHandler, + whatsAppDirectOrderHandler, + ], + inject: [ + NotificationDispatchOutboxHandler, + RealtimeUserEventOutboxHandler, + OrderAutoConfirmScheduleOutboxHandler, + WhatsAppDirectOrderNotificationOutboxHandler, + ], + }, + ], + exports: [OutboxService, CommerceOutboxService, OutboxHandlerRegistry], +}) +export class OutboxModule {} diff --git a/apps/backend/src/domains/platform/outbox/outbox.service.spec.ts b/apps/backend/src/domains/platform/outbox/outbox.service.spec.ts new file mode 100644 index 00000000..1e786ed9 --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/outbox.service.spec.ts @@ -0,0 +1,103 @@ +import { Prisma } from "@prisma/client"; + +import { OutboxService } from "./outbox.service"; + +describe("OutboxService", () => { + const makeService = () => + new OutboxService({ + outboxEvent: { + count: jest.fn(), + findFirst: jest.fn(), + }, + } as never); + + const input = { + topic: "notification.dispatch.requested", + version: 1, + aggregateType: "Payment", + aggregateId: "payment-1", + idempotencyKey: "payment:payment-1:notification:user-1", + payload: { recipientUserId: "user-1", amountKobo: "1000" }, + }; + + it("appends through the supplied transaction client", async () => { + const tx = { + outboxEvent: { + create: jest.fn().mockResolvedValue({ id: "outbox-1", ...input }), + findUnique: jest.fn(), + }, + }; + + const result = await makeService().append(tx as never, input); + + expect(result.id).toBe("outbox-1"); + expect(tx.outboxEvent.create).toHaveBeenCalledWith({ + data: expect.objectContaining(input), + }); + }); + + it("does not open an independent transaction when a transaction client is supplied", async () => { + const prisma = { + outboxEvent: { + create: jest.fn(), + count: jest.fn(), + findFirst: jest.fn(), + }, + }; + const tx = { + outboxEvent: { + create: jest.fn().mockResolvedValue({ id: "outbox-1" }), + findUnique: jest.fn(), + }, + }; + + await new OutboxService(prisma as never).append(tx as never, input); + + expect(prisma.outboxEvent.create).not.toHaveBeenCalled(); + }); + + it("returns the existing event for a duplicate idempotency key", async () => { + const duplicate = new Prisma.PrismaClientKnownRequestError("duplicate", { + code: "P2002", + clientVersion: "test", + }); + const existing = { + id: "existing-outbox", + idempotencyKey: input.idempotencyKey, + }; + const tx = { + outboxEvent: { + create: jest.fn().mockRejectedValue(duplicate), + findUnique: jest.fn().mockResolvedValue(existing), + }, + }; + + await expect(makeService().append(tx as never, input)).resolves.toEqual( + existing, + ); + }); + + it("rejects non-JSON-safe payloads", async () => { + const tx = { outboxEvent: { create: jest.fn(), findUnique: jest.fn() } }; + + await expect( + makeService().append(tx as never, { + ...input, + payload: { amount: BigInt(1) } as never, + }), + ).rejects.toThrow("unsupported value type"); + expect(tx.outboxEvent.create).not.toHaveBeenCalled(); + }); + + it("propagates unexpected database errors", async () => { + const error = new Error("database unavailable"); + const tx = { + outboxEvent: { + create: jest.fn().mockRejectedValue(error), + findUnique: jest.fn(), + }, + }; + + await expect(makeService().append(tx as never, input)).rejects.toBe(error); + }); +}); diff --git a/apps/backend/src/domains/platform/outbox/outbox.service.ts b/apps/backend/src/domains/platform/outbox/outbox.service.ts new file mode 100644 index 00000000..1c2aa0c9 --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/outbox.service.ts @@ -0,0 +1,128 @@ +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { AppendOutboxEventInput, PersistedOutboxEvent } from "./outbox.types"; + +type OutboxTransaction = Prisma.TransactionClient; + +@Injectable() +export class OutboxService { + constructor(private readonly prisma: PrismaService) {} + + async append( + tx: OutboxTransaction, + input: AppendOutboxEventInput, + ): Promise { + this.validateInput(input); + + try { + return await tx.outboxEvent.create({ + data: { + topic: input.topic, + version: input.version ?? 1, + aggregateType: input.aggregateType, + aggregateId: input.aggregateId, + payload: input.payload, + idempotencyKey: input.idempotencyKey, + availableAt: input.availableAt, + }, + }); + } catch (error) { + if (!this.isUniqueConflict(error)) { + throw error; + } + + const existing = await tx.outboxEvent.findUnique({ + where: { idempotencyKey: input.idempotencyKey }, + }); + if (existing) { + return existing; + } + + throw error; + } + } + + async getPendingCount(): Promise { + return this.countByStatus("PENDING"); + } + + async getProcessingCount(): Promise { + return this.countByStatus("PROCESSING"); + } + + async getDeadCount(): Promise { + return this.countByStatus("DEAD"); + } + + async getOldestPendingAge(): Promise { + const oldest = await this.prisma.outboxEvent.findFirst({ + where: { status: "PENDING" }, + orderBy: [{ availableAt: "asc" }, { createdAt: "asc" }], + select: { createdAt: true }, + }); + return oldest ? Math.max(0, Date.now() - oldest.createdAt.getTime()) : null; + } + + private async countByStatus(status: "PENDING" | "PROCESSING" | "DEAD") { + return this.prisma.outboxEvent.count({ where: { status } }); + } + + private validateInput(input: AppendOutboxEventInput): void { + if (!input.topic.trim()) { + throw new Error("Outbox topic is required"); + } + if (!Number.isSafeInteger(input.version ?? 1) || (input.version ?? 1) < 1) { + throw new Error("Outbox version must be a positive integer"); + } + if (!input.idempotencyKey.trim()) { + throw new Error("Outbox idempotencyKey is required"); + } + this.assertJsonSafe(input.payload, "payload"); + } + + private assertJsonSafe(value: unknown, path: string): void { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" + ) { + return; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new Error("Outbox " + path + " contains a non-finite number"); + } + return; + } + if (Array.isArray(value)) { + value.forEach((item, index) => + this.assertJsonSafe(item, path + "[" + index + "]"), + ); + return; + } + if (typeof value === "object") { + if ( + value instanceof Date || + Object.getPrototypeOf(value) !== Object.prototype + ) { + throw new Error("Outbox " + path + " contains a non-JSON object"); + } + for (const [key, child] of Object.entries(value)) { + this.assertJsonSafe(child, path + "." + key); + } + return; + } + throw new Error("Outbox " + path + " contains unsupported value type"); + } + + private isUniqueConflict( + error: unknown, + ): error is Prisma.PrismaClientKnownRequestError { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ); + } +} diff --git a/apps/backend/src/domains/platform/outbox/outbox.types.ts b/apps/backend/src/domains/platform/outbox/outbox.types.ts new file mode 100644 index 00000000..fb856b0c --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/outbox.types.ts @@ -0,0 +1,121 @@ +import type { OutboxEvent, Prisma } from "@prisma/client"; +import { + NotificationChannel, + REALTIME_EVENT, + type RealtimeEventType, +} from "@twizrr/shared"; + +export const OUTBOX_TOPIC = { + NOTIFICATION_DISPATCH_REQUESTED: "notification.dispatch.requested", + WHATSAPP_DIRECT_ORDER_NOTIFICATION_REQUESTED: + "whatsapp.direct-order-notification.requested", + REALTIME_USER_EVENT_REQUESTED: "realtime.user-event.requested", + ORDER_AUTO_CONFIRM_SCHEDULE_REQUESTED: + "order.auto-confirm.schedule.requested", + // Phase 5: requests asynchronous execution of a settlement plan (buyer + // refund and/or merchant payout). The handler delegates to the settlement + // application service — never contains business rules itself. + DISPUTE_SETTLEMENT_EXECUTE_REQUESTED: "dispute.settlement.execute.requested", + PAYMENT_AMOUNT_EXCEPTION_REFUND_EXECUTE_REQUESTED: + "payment.amount-exception-refund.execute.requested", +} as const; + +export type SupportedCommerceRealtimeEvent = + | typeof REALTIME_EVENT.ORDER_CREATED + | typeof REALTIME_EVENT.ORDER_UPDATED + | typeof REALTIME_EVENT.PAYMENT_CONFIRMED + | typeof REALTIME_EVENT.DELIVERY_UPDATED; + +export type SupportedDisputeRealtimeEvent = + | typeof REALTIME_EVENT.DISPUTE_CREATED + | typeof REALTIME_EVENT.DISPUTE_UPDATED + | typeof REALTIME_EVENT.DISPUTE_RESOLVED + | typeof REALTIME_EVENT.DISPUTE_SETTLEMENT_UPDATED; + +/** + * Every realtime event type the generic realtime-user-event outbox handler can + * deliver. Commerce (Phase 3) and dispute (Phase 4) events share the same + * user-addressed delivery path. + */ +export type SupportedRealtimeUserEvent = + | SupportedCommerceRealtimeEvent + | SupportedDisputeRealtimeEvent; + +export type NotificationAudienceValue = "SHOPPER" | "STORE"; + +export interface NotificationDispatchRequestedV1 { + recipientUserId: string; + notificationType: string; + title: string; + body: string; + data: Record; + channels: NotificationChannel[]; + dedupeKey: string; + url?: string | null; + audience?: NotificationAudienceValue; +} + +export interface WhatsAppDirectOrderNotificationRequestedV1 { + storeId: string; + orderData: { + orderId: string; + productName: string; + quantity: number; + amountKobo: string; + }; +} + +export interface RealtimeUserEventRequestedV1 { + recipientUserId: string; + eventType: SupportedRealtimeUserEvent; + eventVersion: number; + data: Record; +} + +export interface OrderAutoConfirmScheduleRequestedV1 { + orderId: string; + expectedStatus: "DISPATCHED"; +} + +export interface DisputeSettlementExecuteRequestedV1 { + settlementId: string; +} + +export interface PaymentAmountExceptionRefundExecuteRequestedV1 { + refundId: string; +} + +export interface AppendOutboxEventInput { + topic: string; + version?: number; + aggregateType?: string; + aggregateId?: string; + idempotencyKey: string; + payload: Prisma.InputJsonValue; + availableAt?: Date; +} + +export interface ClaimedOutboxEvent { + id: string; + topic: string; + version: number; + aggregateType: string | null; + aggregateId: string | null; + payload: TPayload; + attempts: number; + lockedAt: Date; + lockedBy: string; + createdAt: Date; +} + +export interface OutboxHandler { + readonly topic: string; + readonly version: number; + handle(event: ClaimedOutboxEvent): Promise; +} + +export type PersistedOutboxEvent = OutboxEvent; +export type OutboxRealtimeEventType = Exclude< + RealtimeEventType, + typeof REALTIME_EVENT.NOTIFICATION_NEW +>; diff --git a/apps/backend/src/domains/platform/upload/moderation/moderation.service.ts b/apps/backend/src/domains/platform/upload/moderation/moderation.service.ts new file mode 100644 index 00000000..97382fe0 --- /dev/null +++ b/apps/backend/src/domains/platform/upload/moderation/moderation.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from "@nestjs/common"; +import { ModerationStatus, Prisma } from "@prisma/client"; + +import { + SafeSearchLikelihood, + SafeSearchResult, +} from "../../../../integrations/ai/vision.client"; +import { PrismaService } from "../../../../core/prisma/prisma.service"; + +export type UploadContentType = + | "PRODUCT_IMAGE" + | "STORE_LOGO" + | "STORE_BANNER" + | "PROFILE_PHOTO" + | "POST_IMAGE"; + +export interface ModerationLogInput { + contentType: UploadContentType; + contentId: string; + imageUrl: string; + cloudinaryPublicId?: string | null; + decision: ModerationStatus; + safety: SafeSearchResult; +} + +@Injectable() +export class ModerationService { + constructor(private readonly prisma: PrismaService) {} + + classifySafety(safety: SafeSearchResult): ModerationStatus { + if ( + this.isBlockedLikelihood(safety.adult) || + this.isBlockedLikelihood(safety.violence) || + this.isBlockedLikelihood(safety.racy) + ) { + return ModerationStatus.BLOCKED; + } + + if ( + safety.adult === "POSSIBLE" || + safety.violence === "POSSIBLE" || + safety.racy === "POSSIBLE" + ) { + return ModerationStatus.SENSITIVE; + } + + return ModerationStatus.SAFE; + } + + async createModerationLog(input: ModerationLogInput) { + return this.prisma.moderationLog.create({ + data: { + contentType: input.contentType, + contentId: input.contentId, + imageUrl: input.imageUrl, + cloudinaryId: input.cloudinaryPublicId || null, + decision: input.decision, + confidence: this.toConfidenceJson(input.safety), + }, + }); + } + + filterSensitiveForMinor( + items: T[], + isMinor: boolean, + ): T[] { + if (!isMinor) { + return items; + } + + return items.filter( + (item) => item.moderationStatus !== ModerationStatus.SENSITIVE, + ); + } + + private isBlockedLikelihood(value: SafeSearchLikelihood): boolean { + return value === "LIKELY" || value === "VERY_LIKELY"; + } + + private toConfidenceJson(safety: SafeSearchResult): Prisma.InputJsonValue { + return { + adult: safety.adult, + violence: safety.violence, + racy: safety.racy, + }; + } +} diff --git a/apps/backend/src/domains/platform/upload/upload.controller.ts b/apps/backend/src/domains/platform/upload/upload.controller.ts new file mode 100644 index 00000000..ed3bfaee --- /dev/null +++ b/apps/backend/src/domains/platform/upload/upload.controller.ts @@ -0,0 +1,59 @@ +import { + BadRequestException, + Body, + Controller, + Post, + UploadedFile, + UseGuards, + UseInterceptors, +} from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; + +import { CurrentUser } from "../../../common/decorators/current-user.decorator"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { AuthenticatedRequestUser } from "../../users/auth/auth.types"; +import { UploadService } from "./upload.service"; + +@Controller("upload") +export class UploadController { + constructor(private readonly uploadService: UploadService) {} + + @UseGuards(JwtAuthGuard) + @Post("image") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: 5 * 1024 * 1024 }, + fileFilter: (_request, file, callback) => { + if ( + !["image/jpeg", "image/png", "image/webp"].includes(file.mimetype) + ) { + callback( + new BadRequestException({ + message: "Only JPG, PNG, and WebP images are allowed", + code: "INVALID_IMAGE_TYPE", + }), + false, + ); + return; + } + + callback(null, true); + }, + }), + ) + uploadImage( + @CurrentUser() user: AuthenticatedRequestUser, + @UploadedFile() file: Express.Multer.File, + @Body("uploadType") uploadType?: string, + @Body("contentType") contentType?: string, + @Body("contentId") contentId?: string, + ) { + return this.uploadService.uploadImage({ + file, + userId: user.id, + uploadType, + contentType, + contentId, + }); + } +} diff --git a/apps/backend/src/domains/platform/upload/upload.module.ts b/apps/backend/src/domains/platform/upload/upload.module.ts new file mode 100644 index 00000000..1b1d5883 --- /dev/null +++ b/apps/backend/src/domains/platform/upload/upload.module.ts @@ -0,0 +1,24 @@ +import { Module } from "@nestjs/common"; +import { MulterModule } from "@nestjs/platform-express"; +import { memoryStorage } from "multer"; + +import { AIModule } from "../../../integrations/ai/ai.module"; +import { CloudinaryModule } from "../../../integrations/cloudinary/cloudinary.module"; +import { ModerationService } from "./moderation/moderation.service"; +import { UploadController } from "./upload.controller"; +import { UploadService } from "./upload.service"; + +@Module({ + imports: [ + AIModule, + CloudinaryModule, + MulterModule.register({ + storage: memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024 }, + }), + ], + controllers: [UploadController], + providers: [UploadService, ModerationService], + exports: [UploadService, ModerationService], +}) +export class UploadModule {} diff --git a/apps/backend/src/domains/platform/upload/upload.service.spec.ts b/apps/backend/src/domains/platform/upload/upload.service.spec.ts new file mode 100644 index 00000000..68dbfb64 --- /dev/null +++ b/apps/backend/src/domains/platform/upload/upload.service.spec.ts @@ -0,0 +1,203 @@ +import { + BadRequestException, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ModerationStatus } from "@prisma/client"; +import { Readable } from "stream"; + +import { CloudinaryClient } from "../../../integrations/cloudinary/cloudinary.client"; +import { VisionClient } from "../../../integrations/ai/vision.client"; +import { ModerationService } from "./moderation/moderation.service"; +import { UploadService } from "./upload.service"; + +describe("UploadService", () => { + let service: UploadService; + let visionClient: jest.Mocked< + Pick + >; + let cloudinaryClient: jest.Mocked< + Pick + >; + let moderationService: jest.Mocked< + Pick + >; + + beforeEach(() => { + visionClient = { + checkSafety: jest.fn().mockResolvedValue({ adult: "VERY_UNLIKELY" }), + isConfigured: jest.fn().mockReturnValue(true), + }; + cloudinaryClient = { + uploadImage: jest.fn().mockResolvedValue({ + url: "https://res.cloudinary.com/twizrr/image/upload/v1/upload.jpg", + publicId: "twizrr/app/users/avatars/user-123/upload", + }), + generateSignedUrl: jest + .fn() + .mockReturnValue( + "https://res.cloudinary.com/twizrr/image/upload/s--signed--/v1/upload.jpg", + ), + }; + moderationService = { + classifySafety: jest.fn().mockReturnValue(ModerationStatus.SAFE), + createModerationLog: jest.fn().mockResolvedValue(undefined), + }; + + service = new UploadService( + visionClient as unknown as VisionClient, + cloudinaryClient as unknown as CloudinaryClient, + moderationService as unknown as ModerationService, + ); + }); + + it("rejects unsupported MIME types before moderation or upload", async () => { + await expect( + service.uploadImage({ + file: buildFile({ mimetype: "application/pdf" }), + userId: "user-123", + uploadType: "PROFILE_PHOTO", + contentType: "PROFILE_PHOTO", + }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ code: "INVALID_IMAGE_TYPE" }), + }); + + expect(visionClient.checkSafety).not.toHaveBeenCalled(); + expect(cloudinaryClient.uploadImage).not.toHaveBeenCalled(); + }); + + it("rejects oversized images before moderation or upload", async () => { + await expect( + service.uploadImage({ + file: buildFile({ size: 5 * 1024 * 1024 + 1 }), + userId: "user-123", + uploadType: "PRODUCT_DETAIL", + contentType: "PRODUCT_IMAGE", + }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ code: "IMAGE_TOO_LARGE" }), + }); + + expect(visionClient.checkSafety).not.toHaveBeenCalled(); + expect(cloudinaryClient.uploadImage).not.toHaveBeenCalled(); + }); + + it("fails closed when moderation is not configured", async () => { + visionClient.isConfigured.mockReturnValue(false); + + await expect( + service.uploadImage({ + file: buildFile(), + userId: "user-123", + uploadType: "PRODUCT_DETAIL", + contentType: "PRODUCT_IMAGE", + }), + ).rejects.toBeInstanceOf(ServiceUnavailableException); + + expect(visionClient.checkSafety).not.toHaveBeenCalled(); + expect(cloudinaryClient.uploadImage).not.toHaveBeenCalled(); + }); + + it("uploads profile images into the authenticated user's avatar folder", async () => { + await service.uploadImage({ + file: buildFile(), + userId: "User_123", + uploadType: "PROFILE_PHOTO", + contentType: "PROFILE_PHOTO", + }); + + expect(cloudinaryClient.uploadImage).toHaveBeenCalledWith( + expect.any(Buffer), + "twizrr/app/users/avatars/user_123", + ); + }); + + it("does not allow client contentId to inject Cloudinary folders", async () => { + await service.uploadImage({ + file: buildFile(), + userId: "store-owner-123", + uploadType: "STORE_LOGO", + contentType: "STORE_LOGO", + contentId: "../verification/private/nin-number", + }); + + expect(cloudinaryClient.uploadImage).toHaveBeenCalledWith( + expect.any(Buffer), + "twizrr/app/stores/avatars/store-owner-123", + ); + }); + + it("uploads product and post images into app media folders", async () => { + await service.uploadImage({ + file: buildFile(), + userId: "store-owner-123", + uploadType: "PRODUCT_DETAIL", + contentType: "PRODUCT_IMAGE", + }); + await service.uploadImage({ + file: buildFile(), + userId: "user-123", + uploadType: "POST_IMAGE", + contentType: "POST_IMAGE", + }); + + expect(cloudinaryClient.uploadImage).toHaveBeenNthCalledWith( + 1, + expect.any(Buffer), + "twizrr/app/products/physical/store-owner-123", + ); + expect(cloudinaryClient.uploadImage).toHaveBeenNthCalledWith( + 2, + expect.any(Buffer), + "twizrr/app/posts/user-123", + ); + expect(cloudinaryClient.generateSignedUrl).toHaveBeenNthCalledWith( + 1, + expect.any(String), + expect.objectContaining({ width: 1080, height: 1080, crop: "fill" }), + ); + }); + + it("does not upload blocked images to Cloudinary", async () => { + moderationService.classifySafety.mockReturnValue(ModerationStatus.BLOCKED); + + await expect( + service.uploadImage({ + file: buildFile(), + userId: "user-123", + uploadType: "PROFILE_PHOTO", + contentType: "PROFILE_PHOTO", + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(cloudinaryClient.uploadImage).not.toHaveBeenCalled(); + expect(moderationService.createModerationLog).toHaveBeenCalledWith( + expect.objectContaining({ + imageUrl: "", + cloudinaryPublicId: null, + decision: ModerationStatus.BLOCKED, + }), + ); + }); +}); + +function buildFile( + overrides: Partial< + Pick + > = {}, +): Express.Multer.File { + const buffer = overrides.buffer ?? Buffer.from("fake-image"); + + return { + fieldname: "file", + originalname: "image.webp", + encoding: "7bit", + mimetype: overrides.mimetype ?? "image/webp", + size: overrides.size ?? buffer.length, + buffer, + destination: "", + filename: "", + path: "", + stream: Readable.from([]), + }; +} diff --git a/apps/backend/src/domains/platform/upload/upload.service.ts b/apps/backend/src/domains/platform/upload/upload.service.ts new file mode 100644 index 00000000..317c0b80 --- /dev/null +++ b/apps/backend/src/domains/platform/upload/upload.service.ts @@ -0,0 +1,276 @@ +import { + BadRequestException, + Injectable, + Logger, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ModerationStatus } from "@prisma/client"; +import { randomUUID } from "crypto"; + +import { + CloudinaryClient, + CloudinaryTransforms, +} from "../../../integrations/cloudinary/cloudinary.client"; +import { VisionClient } from "../../../integrations/ai/vision.client"; +import { + ModerationService, + UploadContentType, +} from "./moderation/moderation.service"; + +export type UploadImageType = + | "PRODUCT_FEED" + | "PRODUCT_DETAIL" + | "POST_IMAGE" + | "STORE_LOGO" + | "STORE_BANNER" + | "PROFILE_PHOTO"; + +export interface UploadImageInput { + file: Express.Multer.File; + userId: string; + uploadType?: string; + contentType?: string; + contentId?: string; +} + +export interface UploadImageResult { + url: string; + moderationStatus: ModerationStatus; + cloudinaryPublicId: string; +} + +const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; +const CLOUDINARY_APP_ROOT = "twizrr/app"; +const FALLBACK_FOLDER_SEGMENT = "unknown"; +const ALLOWED_IMAGE_MIME_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/webp", +]); + +const UPLOAD_TRANSFORMS: Record = { + PRODUCT_FEED: { + quality: "auto", + fetch_format: "auto", + width: 1080, + height: 1080, + crop: "fill", + }, + PRODUCT_DETAIL: { + quality: "auto", + fetch_format: "auto", + width: 1080, + height: 1080, + crop: "fill", + }, + POST_IMAGE: { + quality: "auto", + fetch_format: "auto", + width: 800, + }, + STORE_LOGO: { + quality: "auto", + fetch_format: "auto", + width: 200, + height: 200, + crop: "fill", + }, + STORE_BANNER: { + quality: "auto", + fetch_format: "auto", + width: 1200, + height: 400, + crop: "fill", + }, + PROFILE_PHOTO: { + quality: "auto", + fetch_format: "auto", + width: 200, + height: 200, + crop: "fill", + gravity: "face", + }, +}; + +@Injectable() +export class UploadService { + private readonly logger = new Logger(UploadService.name); + + constructor( + private readonly visionClient: VisionClient, + private readonly cloudinaryClient: CloudinaryClient, + private readonly moderationService: ModerationService, + ) {} + + async uploadImage(input: UploadImageInput): Promise { + this.assertValidImage(input.file); + + const uploadType = this.normalizeUploadType(input.uploadType); + const contentType = this.normalizeContentType(input.contentType); + const contentId = this.normalizeContentId(input.contentId); + + const safety = await this.visionClient.checkSafety(input.file.buffer); + const moderationStatus = this.moderationService.classifySafety(safety); + + if (moderationStatus === ModerationStatus.BLOCKED) { + await this.moderationService.createModerationLog({ + contentType, + contentId, + // Blocked images never reach Cloudinary; the current schema requires a string. + imageUrl: "", + cloudinaryPublicId: null, + decision: ModerationStatus.BLOCKED, + safety, + }); + + throw new BadRequestException({ + message: "Image blocked by content policy", + code: "IMAGE_BLOCKED", + }); + } + + const folder = this.getFolder(uploadType, input.userId); + const upload = await this.cloudinaryClient.uploadImage( + input.file.buffer, + folder, + ); + const url = this.cloudinaryClient.generateSignedUrl( + upload.publicId, + UPLOAD_TRANSFORMS[uploadType], + ); + + await this.moderationService.createModerationLog({ + contentType, + contentId, + imageUrl: url, + cloudinaryPublicId: upload.publicId, + decision: moderationStatus, + safety, + }); + + if (moderationStatus === ModerationStatus.SENSITIVE) { + this.logger.warn( + `Sensitive image uploaded userId=${input.userId} contentType=${contentType}`, + ); + } + + return { + url, + moderationStatus, + cloudinaryPublicId: upload.publicId, + }; + } + + private assertValidImage(file: Express.Multer.File | undefined): void { + if (!file) { + throw new BadRequestException({ + message: "No image uploaded", + code: "IMAGE_REQUIRED", + }); + } + + if (!ALLOWED_IMAGE_MIME_TYPES.has(file.mimetype)) { + throw new BadRequestException({ + message: "Only JPG, PNG, and WebP images are allowed", + code: "INVALID_IMAGE_TYPE", + }); + } + + if (!file.buffer || file.buffer.length === 0) { + throw new BadRequestException({ + message: "Uploaded image is empty", + code: "IMAGE_REQUIRED", + }); + } + + if (file.size > MAX_IMAGE_SIZE_BYTES) { + throw new BadRequestException({ + message: "Image must be 5MB or smaller", + code: "IMAGE_TOO_LARGE", + }); + } + + if (!this.visionClient.isConfigured()) { + throw new ServiceUnavailableException({ + message: "Image moderation is not configured", + code: "MODERATION_NOT_CONFIGURED", + }); + } + } + + private normalizeUploadType(value: string | undefined): UploadImageType { + if (!value) { + return "PRODUCT_DETAIL"; + } + + if (this.isUploadImageType(value)) { + return value; + } + + throw new BadRequestException({ + message: "Invalid upload type", + code: "INVALID_UPLOAD_TYPE", + }); + } + + private normalizeContentType(value: string | undefined): UploadContentType { + if (!value) { + return "PRODUCT_IMAGE"; + } + + if (this.isUploadContentType(value)) { + return value; + } + + throw new BadRequestException({ + message: "Invalid content type", + code: "INVALID_CONTENT_TYPE", + }); + } + + private normalizeContentId(value: string | undefined): string { + return value?.trim() || `upload-${randomUUID()}`; + } + + private isUploadImageType(value: string): value is UploadImageType { + return Object.prototype.hasOwnProperty.call(UPLOAD_TRANSFORMS, value); + } + + private isUploadContentType(value: string): value is UploadContentType { + return [ + "PRODUCT_IMAGE", + "STORE_LOGO", + "STORE_BANNER", + "PROFILE_PHOTO", + "POST_IMAGE", + ].includes(value); + } + + private getFolder(uploadType: UploadImageType, userId: string): string { + const ownerSegment = this.safeFolderSegment(userId); + + switch (uploadType) { + case "STORE_LOGO": + return `${CLOUDINARY_APP_ROOT}/stores/avatars/${ownerSegment}`; + case "STORE_BANNER": + return `${CLOUDINARY_APP_ROOT}/stores/banners/${ownerSegment}`; + case "PROFILE_PHOTO": + return `${CLOUDINARY_APP_ROOT}/users/avatars/${ownerSegment}`; + case "POST_IMAGE": + return `${CLOUDINARY_APP_ROOT}/posts/${ownerSegment}`; + case "PRODUCT_FEED": + case "PRODUCT_DETAIL": + return `${CLOUDINARY_APP_ROOT}/products/physical/${ownerSegment}`; + } + } + + private safeFolderSegment(value: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + + return normalized || FALLBACK_FOLDER_SEGMENT; + } +} diff --git a/apps/backend/src/domains/platform/waitlist/dto/join-waitlist.dto.ts b/apps/backend/src/domains/platform/waitlist/dto/join-waitlist.dto.ts new file mode 100644 index 00000000..def86b70 --- /dev/null +++ b/apps/backend/src/domains/platform/waitlist/dto/join-waitlist.dto.ts @@ -0,0 +1,32 @@ +import { Transform } from "class-transformer"; +import { Equals, IsEmail, IsIn, IsString } from "class-validator"; + +import { + WAITLIST_CONSENT_TEXT, + WAITLIST_CONSENT_VERSION, + WAITLIST_SOURCES, +} from "../waitlist.constants"; + +export class JoinWaitlistDto { + @Transform(({ value }) => + typeof value === "string" ? value.trim().toLowerCase() : value, + ) + @IsEmail() + email!: string; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsString() + @IsIn(WAITLIST_SOURCES) + source!: string; + + @Equals(true, { message: "marketingConsent must be accepted" }) + marketingConsent!: boolean; + + @IsString() + @Equals(WAITLIST_CONSENT_TEXT) + consentText!: string; + + @IsString() + @Equals(WAITLIST_CONSENT_VERSION) + consentVersion!: string; +} diff --git a/apps/backend/src/domains/platform/waitlist/waitlist.constants.ts b/apps/backend/src/domains/platform/waitlist/waitlist.constants.ts new file mode 100644 index 00000000..1afb9da8 --- /dev/null +++ b/apps/backend/src/domains/platform/waitlist/waitlist.constants.ts @@ -0,0 +1,19 @@ +export const WAITLIST_CONSENT_VERSION = "launch-v1"; + +export const WAITLIST_CONSENT_TEXT = + "I agree to receive Twizrr launch updates, product news, shopping guides, and community emails. I can unsubscribe anytime."; + +export const WAITLIST_SOURCES = [ + "header_cta", + "shopper_hero", + "store_owner_cta", + "store_owner_section", + "dropshipper_cta", + "marketplace_preview", + "final_cta", + "final_shopper_cta", + "final_store_cta", + "other", +] as const; + +export type WaitlistSource = (typeof WAITLIST_SOURCES)[number]; diff --git a/apps/backend/src/domains/platform/waitlist/waitlist.controller.spec.ts b/apps/backend/src/domains/platform/waitlist/waitlist.controller.spec.ts new file mode 100644 index 00000000..a9370fb2 --- /dev/null +++ b/apps/backend/src/domains/platform/waitlist/waitlist.controller.spec.ts @@ -0,0 +1,47 @@ +import { WaitlistController } from "./waitlist.controller"; +import { + WAITLIST_CONSENT_TEXT, + WAITLIST_CONSENT_VERSION, +} from "./waitlist.service"; + +describe("WaitlistController", () => { + const waitlistService = { + join: jest.fn(), + }; + + let controller: WaitlistController; + + beforeEach(() => { + jest.clearAllMocks(); + controller = new WaitlistController(waitlistService as never); + waitlistService.join.mockResolvedValue({ + joined: true, + message: "You are on the early access list.", + }); + }); + + it("submits public landing waitlist signups without returning internal ids", async () => { + const result = await controller.join({ + email: "shopper@example.com", + source: "final_cta", + marketingConsent: true, + consentText: WAITLIST_CONSENT_TEXT, + consentVersion: WAITLIST_CONSENT_VERSION, + }); + + expect(waitlistService.join).toHaveBeenCalledWith({ + email: "shopper@example.com", + source: "final_cta", + marketingConsent: true, + consentText: WAITLIST_CONSENT_TEXT, + consentVersion: WAITLIST_CONSENT_VERSION, + }); + expect(result).toEqual({ + joined: true, + message: "You are on the early access list.", + }); + expect(JSON.stringify(result)).not.toMatch( + /waitlist-1|id|userId|internal/i, + ); + }); +}); diff --git a/apps/backend/src/domains/platform/waitlist/waitlist.controller.ts b/apps/backend/src/domains/platform/waitlist/waitlist.controller.ts new file mode 100644 index 00000000..80579acf --- /dev/null +++ b/apps/backend/src/domains/platform/waitlist/waitlist.controller.ts @@ -0,0 +1,14 @@ +import { Body, Controller, Post } from "@nestjs/common"; + +import { JoinWaitlistDto } from "./dto/join-waitlist.dto"; +import { WaitlistService } from "./waitlist.service"; + +@Controller("waitlist") +export class WaitlistController { + constructor(private readonly waitlistService: WaitlistService) {} + + @Post() + async join(@Body() dto: JoinWaitlistDto) { + return await this.waitlistService.join(dto); + } +} diff --git a/apps/backend/src/domains/platform/waitlist/waitlist.module.ts b/apps/backend/src/domains/platform/waitlist/waitlist.module.ts new file mode 100644 index 00000000..d211f7d4 --- /dev/null +++ b/apps/backend/src/domains/platform/waitlist/waitlist.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; + +import { PrismaModule } from "../../../core/prisma/prisma.module"; +import { WaitlistController } from "./waitlist.controller"; +import { WaitlistService } from "./waitlist.service"; + +@Module({ + imports: [PrismaModule], + controllers: [WaitlistController], + providers: [WaitlistService], +}) +export class WaitlistModule {} diff --git a/apps/backend/src/domains/platform/waitlist/waitlist.service.spec.ts b/apps/backend/src/domains/platform/waitlist/waitlist.service.spec.ts new file mode 100644 index 00000000..0feb5606 --- /dev/null +++ b/apps/backend/src/domains/platform/waitlist/waitlist.service.spec.ts @@ -0,0 +1,139 @@ +import { BadRequestException } from "@nestjs/common"; + +import { + WAITLIST_CONSENT_TEXT, + WAITLIST_CONSENT_VERSION, + WaitlistService, +} from "./waitlist.service"; + +describe("WaitlistService", () => { + const prisma = { + user: { + create: jest.fn(), + }, + waitlistSubscriber: { + upsert: jest.fn(), + }, + }; + + let service: WaitlistService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new WaitlistService(prisma as never); + prisma.waitlistSubscriber.upsert.mockResolvedValue({ + id: "waitlist-1", + email: "shopper@example.com", + source: "shopper_hero", + marketingConsent: true, + consentText: WAITLIST_CONSENT_TEXT, + consentVersion: WAITLIST_CONSENT_VERSION, + whatsappInviteClickedAt: null, + unsubscribedAt: null, + createdAt: new Date("2026-06-16T00:00:00.000Z"), + updatedAt: new Date("2026-06-16T00:00:00.000Z"), + }); + }); + + it("stores normalized email, consent text, consent version, and source", async () => { + const result = await service.join({ + email: " Shopper@Example.COM ", + source: "shopper_hero", + marketingConsent: true, + consentText: WAITLIST_CONSENT_TEXT, + consentVersion: WAITLIST_CONSENT_VERSION, + }); + + expect(prisma.waitlistSubscriber.upsert).toHaveBeenCalledWith({ + where: { email: "shopper@example.com" }, + create: { + email: "shopper@example.com", + source: "shopper_hero", + marketingConsent: true, + consentText: WAITLIST_CONSENT_TEXT, + consentVersion: WAITLIST_CONSENT_VERSION, + }, + update: { + source: "shopper_hero", + marketingConsent: true, + consentText: WAITLIST_CONSENT_TEXT, + consentVersion: WAITLIST_CONSENT_VERSION, + unsubscribedAt: null, + }, + }); + expect(prisma.user.create).not.toHaveBeenCalled(); + expect(result).toEqual({ + joined: true, + message: "You are on the early access list.", + }); + }); + + it("rejects missing marketing consent", async () => { + await expect( + service.join({ + email: "shopper@example.com", + source: "shopper_hero", + marketingConsent: false, + consentText: WAITLIST_CONSENT_TEXT, + consentVersion: WAITLIST_CONSENT_VERSION, + }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "WAITLIST_MARKETING_CONSENT_REQUIRED", + }), + }); + + expect(prisma.waitlistSubscriber.upsert).not.toHaveBeenCalled(); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + it("rejects invalid email", async () => { + await expect( + service.join({ + email: "not-an-email", + source: "shopper_hero", + marketingConsent: true, + consentText: WAITLIST_CONSENT_TEXT, + consentVersion: WAITLIST_CONSENT_VERSION, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(prisma.waitlistSubscriber.upsert).not.toHaveBeenCalled(); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + it("upserts duplicate emails instead of creating multiple records", async () => { + await service.join({ + email: "shopper@example.com", + source: "store_owner_cta", + marketingConsent: true, + consentText: WAITLIST_CONSENT_TEXT, + consentVersion: WAITLIST_CONSENT_VERSION, + }); + await service.join({ + email: " SHOPPER@example.com ", + source: "final_cta", + marketingConsent: true, + consentText: WAITLIST_CONSENT_TEXT, + consentVersion: WAITLIST_CONSENT_VERSION, + }); + + expect(prisma.waitlistSubscriber.upsert).toHaveBeenCalledTimes(2); + expect(prisma.waitlistSubscriber.upsert).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: { email: "shopper@example.com" }, + create: expect.objectContaining({ source: "store_owner_cta" }), + update: expect.objectContaining({ source: "store_owner_cta" }), + }), + ); + expect(prisma.waitlistSubscriber.upsert).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + where: { email: "shopper@example.com" }, + create: expect.objectContaining({ source: "final_cta" }), + update: expect.objectContaining({ source: "final_cta" }), + }), + ); + }); +}); diff --git a/apps/backend/src/domains/platform/waitlist/waitlist.service.ts b/apps/backend/src/domains/platform/waitlist/waitlist.service.ts new file mode 100644 index 00000000..e8f7c5f7 --- /dev/null +++ b/apps/backend/src/domains/platform/waitlist/waitlist.service.ts @@ -0,0 +1,72 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { + WAITLIST_CONSENT_TEXT, + WAITLIST_CONSENT_VERSION, +} from "./waitlist.constants"; +import { JoinWaitlistDto } from "./dto/join-waitlist.dto"; + +export { WAITLIST_CONSENT_TEXT, WAITLIST_CONSENT_VERSION }; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +@Injectable() +export class WaitlistService { + constructor(private readonly prisma: PrismaService) {} + + async join(dto: JoinWaitlistDto) { + const email = this.normalizeEmail(dto.email); + + if (!EMAIL_PATTERN.test(email)) { + throw new BadRequestException({ + message: "Enter a valid email address.", + code: "WAITLIST_INVALID_EMAIL", + }); + } + + if (dto.marketingConsent !== true) { + throw new BadRequestException({ + message: "Marketing consent is required to join early access.", + code: "WAITLIST_MARKETING_CONSENT_REQUIRED", + }); + } + + if ( + dto.consentText !== WAITLIST_CONSENT_TEXT || + dto.consentVersion !== WAITLIST_CONSENT_VERSION + ) { + throw new BadRequestException({ + message: "Consent terms are out of date. Refresh and try again.", + code: "WAITLIST_CONSENT_VERSION_MISMATCH", + }); + } + + await this.prisma.waitlistSubscriber.upsert({ + where: { email }, + create: { + email, + source: dto.source, + marketingConsent: true, + consentText: dto.consentText, + consentVersion: dto.consentVersion, + }, + update: { + source: dto.source, + marketingConsent: true, + consentText: dto.consentText, + consentVersion: dto.consentVersion, + unsubscribedAt: null, + }, + }); + + return { + joined: true, + message: "You are on the early access list.", + }; + } + + private normalizeEmail(email: string): string { + return email.trim().toLowerCase(); + } +} diff --git a/apps/backend/src/domains/social.module.ts b/apps/backend/src/domains/social.module.ts new file mode 100644 index 00000000..e824e399 --- /dev/null +++ b/apps/backend/src/domains/social.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; + +import { EmailModule } from "./social/email/email.module"; +import { NotificationModule } from "./social/notification/notification.module"; +import { PostSubscriptionModule } from "./social/post-subscription/post-subscription.module"; + +@Module({ + imports: [NotificationModule, EmailModule, PostSubscriptionModule], +}) +export class SocialDomainModule {} diff --git a/apps/backend/src/domains/social/chat/README.md b/apps/backend/src/domains/social/chat/README.md new file mode 100644 index 00000000..6890a772 --- /dev/null +++ b/apps/backend/src/domains/social/chat/README.md @@ -0,0 +1,5 @@ +# Chat Domain + +Real-time shopper and store messaging boundary. +To be built as part of the social domain. + diff --git a/apps/backend/src/domains/social/email/email.module.ts b/apps/backend/src/domains/social/email/email.module.ts new file mode 100644 index 00000000..d40d834f --- /dev/null +++ b/apps/backend/src/domains/social/email/email.module.ts @@ -0,0 +1,11 @@ +import { Module, Global } from "@nestjs/common"; +import { EmailService } from "./email.service"; +import { ResendModule } from "../../../integrations/resend/resend.module"; + +@Global() +@Module({ + imports: [ResendModule], + providers: [EmailService], + exports: [EmailService], +}) +export class EmailModule {} diff --git a/apps/backend/src/domains/social/email/email.service.spec.ts b/apps/backend/src/domains/social/email/email.service.spec.ts new file mode 100644 index 00000000..35a2e275 --- /dev/null +++ b/apps/backend/src/domains/social/email/email.service.spec.ts @@ -0,0 +1,63 @@ +import { ConfigService } from "@nestjs/config"; + +import { EmailService } from "./email.service"; + +describe("EmailService notification copy", () => { + const makeService = () => { + const configService = { + get: jest.fn().mockReturnValue("https://app.twizrr.com"), + }; + const emailProvider = { + sendTransactionalEmail: jest.fn().mockResolvedValue({ + provider: "resend", + providerMessageId: "message-1", + accepted: true, + }), + }; + const service = new EmailService( + configService as unknown as ConfigService, + emailProvider as never, + ); + + return { service, emailProvider }; + }; + + it("uses protected-payment language in welcome email copy", async () => { + const { service, emailProvider } = makeService(); + + await service.sendWelcomeEmail("shopper@example.com", "Ada", "USER"); + + expect(emailProvider.sendTransactionalEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: { email: "shopper@example.com" }, + template: "TRANSACTIONAL_CONTENT", + variables: expect.objectContaining({ + subject: "Welcome to twizrr", + html: expect.stringContaining("protected payment"), + }), + }), + ); + }); + + it("uses Twizrr-managed delivery copy for store payment emails", async () => { + const { service, emailProvider } = makeService(); + + await service.sendPaymentConfirmedNotification( + "owner@example.com", + "TWZ-123456", + 250000n, + true, + ); + + expect(emailProvider.sendTransactionalEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: { email: "owner@example.com" }, + template: "TRANSACTIONAL_CONTENT", + variables: expect.objectContaining({ + subject: "Payment confirmed for Order #TWZ-1234", + html: expect.stringContaining("Delivery is handled by twizrr"), + }), + }), + ); + }); +}); diff --git a/apps/backend/src/domains/social/email/email.service.ts b/apps/backend/src/domains/social/email/email.service.ts new file mode 100644 index 00000000..783590f1 --- /dev/null +++ b/apps/backend/src/domains/social/email/email.service.ts @@ -0,0 +1,194 @@ +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PlatformConfig } from "../../../config/platform.config"; +import { + EMAIL_PROVIDER, + type EmailProvider, +} from "./providers/email-provider.interface"; + +@Injectable() +export class EmailService { + private readonly logger = new Logger(EmailService.name); + + constructor( + private configService: ConfigService, + @Inject(EMAIL_PROVIDER) + private readonly emailProvider: EmailProvider, + ) {} + + /** + * Core send method wrapped in try/catch to ensure email failures never break the flow + */ + async sendEmail(to: string, subject: string, html: string): Promise { + try { + this.logger.log("Sending transactional email"); + + const result = await this.emailProvider.sendTransactionalEmail({ + to: { email: to }, + template: "TRANSACTIONAL_CONTENT", + variables: { subject, html }, + }); + + this.logger.log(`Transactional email accepted=${result.accepted}`); + } catch (err: unknown) { + const errorType = err instanceof Error ? err.name : typeof err; + this.logger.error( + `Transactional email delivery failed type=${errorType}`, + ); + throw err; + } + } + + private escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + private formatNaira(kobo: number | bigint): string { + const naira = Number(kobo) / 100; + return new Intl.NumberFormat("en-NG", { + style: "currency", + currency: "NGN", + minimumFractionDigits: 2, + }) + .format(naira) + .replace("NGN", "₦"); + } + + private getLayout(content: string): string { + return ` +
+
+

twizrr

+
+
+ ${content} +
+

© ${new Date().getFullYear()} twizrr. Built for Lagos trade.

+
+
+
+ `; + } + + async sendWelcomeEmail( + to: string, + name: string, + role: string, + ): Promise { + const safeName = this.escapeHtml(name); + const safeRole = this.escapeHtml(role.toLowerCase()); + const content = ` +

Welcome to twizrr, ${safeName}!

+

We're excited to have you on board as a ${safeRole}.

+

twizrr is digitizing Africa's digital trade network, and you're now part of the movement.

+

Next step: Browse trusted stores and start shopping with protected payment. You can open a store profile later from your account.

+ + `; + await this.sendEmail(to, "Welcome to twizrr", this.getLayout(content)); + } + + async sendVerificationOTP(to: string, otp: string): Promise { + const safeOtp = this.escapeHtml(otp); + const content = ` +

Your Verification Code

+

Please use the following code to verify your account. It expires in ${PlatformConfig.timers.otpExpiryEmailMinutes} minutes.

+
+

${safeOtp}

+
+ `; + await this.sendEmail(to, "Your Verification Code", this.getLayout(content)); + } + + async sendPaymentConfirmedNotification( + to: string, + reference: string, + amountKobo: bigint, + isMerchant: boolean, + ): Promise { + const safeReference = this.escapeHtml(reference.slice(0, 8)); + const content = ` +

Payment Confirmed

+

Payment of ${this.formatNaira(amountKobo)} has been confirmed for Order #${safeReference}.

+ ${ + isMerchant + ? `

Please prepare the order from your dashboard. Delivery is handled by twizrr. Your payout will be released once delivery is confirmed.

` + : `

Your payment is protected. The store has been notified to prepare your order.

` + } + `; + await this.sendEmail( + to, + `Payment confirmed for Order #${safeReference}`, + this.getLayout(content), + ); + } + + async sendOrderDispatchedNotification( + to: string, + reference: string, + otp: string, + ): Promise { + const safeReference = this.escapeHtml(reference.slice(0, 8)); + const safeOtp = this.escapeHtml(otp); + const content = ` +

Your order is on the way

+

Order #${safeReference} is on its way to your delivery address.

+
+

Delivery code:

+

${safeOtp}

+

Important: Only share this delivery code after you have inspected and received your goods.

+
+ `; + await this.sendEmail( + to, + "Your order is on the way", + this.getLayout(content), + ); + } + + async sendDeliveryConfirmedNotification( + to: string, + reference: string, + amountKobo: bigint, + ): Promise { + const safeReference = this.escapeHtml(reference.slice(0, 8)); + const content = ` +

Delivery Confirmed Success!

+

Delivery of Order #${safeReference} has been confirmed.

+

The transaction of ${this.formatNaira(amountKobo)} is now complete.

+

Thank you for trading with twizrr!

+ `; + await this.sendEmail( + to, + `Order #${safeReference} delivered successfully`, + this.getLayout(content), + ); + } + + async sendPasswordResetEmail( + to: string, + resetToken: string, + frontendUrl: string, + ): Promise { + const resetUrl = `${frontendUrl}/reset-password?token=${encodeURIComponent(resetToken)}`; + const content = ` +

Password Reset Request

+

We received a request to reset your password. If you didn't make this request, you can safely ignore this email.

+ +

This link will expire in ${PlatformConfig.timers.otpExpiryAuthMinutes} minutes.

+ `; + await this.sendEmail( + to, + "Reset your twizrr Password", + this.getLayout(content), + ); + } +} diff --git a/apps/backend/src/domains/social/email/providers/email-provider.interface.ts b/apps/backend/src/domains/social/email/providers/email-provider.interface.ts new file mode 100644 index 00000000..c26f34ab --- /dev/null +++ b/apps/backend/src/domains/social/email/providers/email-provider.interface.ts @@ -0,0 +1,74 @@ +export const EMAIL_PROVIDER = Symbol("EMAIL_PROVIDER"); + +export type EmailProviderName = "resend"; +export type TransactionalEmailVariable = string | number | boolean | null; +export type TransactionalEmailVariables = Record< + string, + TransactionalEmailVariable +>; + +export type TransactionalEmailTemplate = + | "OTP_EMAIL_VERIFY" + | "OTP_PASSWORD_RESET" + | "ORDER_CONFIRMED" + | "ORDER_DISPATCHED" + | "ORDER_COMPLETED" + | "PAYOUT_SENT" + | "PAYOUT_FAILED" + | "DISPUTE_FILED" + | "DISPUTE_RESOLVED" + | "STORE_TIER_UPGRADED" + | "LEGACY_NOTIFICATION" + | "TRANSACTIONAL_CONTENT"; + +export type EmailRecipient = { email: string; name?: string | null }; +export type EmailAttachment = { + filename: string; + contentType: string; + content: Buffer | string; +}; + +export type SendTransactionalEmailRequest = { + to: EmailRecipient; + template: TransactionalEmailTemplate; + variables: TransactionalEmailVariables; + idempotencyKey?: string; + replyTo?: string | null; + attachments?: EmailAttachment[]; +}; + +export type SendTransactionalEmailResult = { + provider: EmailProviderName; + providerMessageId: string | null; + accepted: boolean; +}; + +export interface EmailProvider { + sendTransactionalEmail( + request: SendTransactionalEmailRequest, + ): Promise; +} + +const EMAIL_TEMPLATES: readonly TransactionalEmailTemplate[] = [ + "OTP_EMAIL_VERIFY", + "OTP_PASSWORD_RESET", + "ORDER_CONFIRMED", + "ORDER_DISPATCHED", + "ORDER_COMPLETED", + "PAYOUT_SENT", + "PAYOUT_FAILED", + "DISPUTE_FILED", + "DISPUTE_RESOLVED", + "STORE_TIER_UPGRADED", + "LEGACY_NOTIFICATION", + "TRANSACTIONAL_CONTENT", +]; + +export function isTransactionalEmailTemplate( + value: unknown, +): value is TransactionalEmailTemplate { + return ( + typeof value === "string" && + EMAIL_TEMPLATES.includes(value as TransactionalEmailTemplate) + ); +} diff --git a/apps/backend/src/domains/social/notification/dto/list-notifications.dto.ts b/apps/backend/src/domains/social/notification/dto/list-notifications.dto.ts new file mode 100644 index 00000000..9dc1cab8 --- /dev/null +++ b/apps/backend/src/domains/social/notification/dto/list-notifications.dto.ts @@ -0,0 +1,29 @@ +import { Transform, Type } from "class-transformer"; +import { IsIn, IsInt, IsOptional, IsString, Max, Min } from "class-validator"; + +/** + * Notification mode context. A twizrr account is both a shopper and (optionally) + * a store owner; each mode's notifications page + unread badge only reflects + * that identity. Omitting context returns every audience (back-compatible). + */ +export type NotificationContext = "shopper" | "store"; + +export class NotificationContextDto { + @IsOptional() + @IsIn(["shopper", "store"]) + context?: NotificationContext; +} + +export class ListNotificationsDto extends NotificationContextDto { + @Transform(({ value }) => (value == null ? value : String(value))) + @IsOptional() + @IsString() + cursor?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + limit?: number; +} diff --git a/apps/backend/src/domains/social/notification/notification-trigger.service.spec.ts b/apps/backend/src/domains/social/notification/notification-trigger.service.spec.ts new file mode 100644 index 00000000..2a1d8cc5 --- /dev/null +++ b/apps/backend/src/domains/social/notification/notification-trigger.service.spec.ts @@ -0,0 +1,116 @@ +import { NotificationChannel, NotificationType } from "@twizrr/shared"; + +import { NotificationTriggerService } from "./notification-trigger.service"; + +describe("NotificationTriggerService order purchase notifications", () => { + const makeService = () => { + const queue = { + add: jest.fn().mockResolvedValue(undefined), + }; + const whatsappQueue = { + add: jest.fn().mockResolvedValue(undefined), + }; + const service = new NotificationTriggerService( + queue as never, + whatsappQueue as never, + ); + + return { service, queue, whatsappQueue }; + }; + + it("keeps buyer totals and store-owner merchandise amounts separate", async () => { + const { service, queue, whatsappQueue } = makeService(); + + await service.triggerOrderPurchaseConfirmed("buyer-1", "store-1", { + orderId: "order-1", + reference: "tx-reference", + productName: "Canvas Tote", + buyerName: "Ada Okafor", + quantity: 2, + amountKobo: 260000n, + storeAmountKobo: 200000n, + deliveryAddress: "12 Allen Avenue", + }); + + expect(queue.add).toHaveBeenNthCalledWith( + 1, + "send-notification", + expect.objectContaining({ + userId: "buyer-1", + type: NotificationType.ORDER_PURCHASE_CONFIRMED, + body: expect.stringContaining("delivery code"), + channels: [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + metadata: expect.objectContaining({ + amountKobo: "260000", + deliveryAddress: "12 Allen Avenue", + }), + }), + expect.any(Object), + ); + expect(queue.add).toHaveBeenNthCalledWith( + 2, + "send-notification", + expect.objectContaining({ + userId: "store-1", + type: NotificationType.ORDER_PURCHASE_RECEIVED, + body: "Canvas Tote x 2 - NGN 2000.00. Check your dashboard to prepare the order.", + channels: [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + metadata: { + orderId: "order-1", + reference: "tx-reference", + productName: "Canvas Tote", + quantity: 2, + amountKobo: "200000", + isMerchantId: true, + }, + }), + expect.any(Object), + ); + expect(whatsappQueue.add).not.toHaveBeenCalled(); + }); + + it("queues dispatched WhatsApp updates with the buyer identity, not a raw phone", async () => { + const { service, queue, whatsappQueue } = makeService(); + + await service.triggerOrderDispatched("buyer-1", { + orderId: "order-1", + reference: "TWZ-123456", + otp: "123456", + }); + + expect(queue.add).toHaveBeenCalledWith( + "send-notification", + expect.objectContaining({ + channels: [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + }), + expect.any(Object), + ); + expect(whatsappQueue.add).toHaveBeenCalledWith( + "send-order-dispatched-notification", + { + buyerId: "buyer-1", + orderData: { orderReference: "TWZ-123456", otp: "123456" }, + }, + expect.objectContaining({ + jobId: "whatsapp-order-dispatched-order-1", + attempts: 3, + removeOnFail: false, + }), + ); + }); + + it("keeps store payout alerts out of the shopper WhatsApp channel", async () => { + const { service, whatsappQueue } = makeService(); + + await service.triggerPayoutInitiated("store-1", { + orderId: "order-1", + orderRef: "TWZ-123456", + productName: "Canvas Tote", + quantity: 1, + payoutAmountKobo: "200000", + bankName: "Example Bank", + }); + + expect(whatsappQueue.add).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/domains/social/notification/notification-trigger.service.ts b/apps/backend/src/domains/social/notification/notification-trigger.service.ts new file mode 100644 index 00000000..87fb5651 --- /dev/null +++ b/apps/backend/src/domains/social/notification/notification-trigger.service.ts @@ -0,0 +1,552 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectQueue } from "@nestjs/bullmq"; +import { Queue } from "bullmq"; +import { + NOTIFICATION_QUEUE, + WHATSAPP_QUEUE, +} from "../../../queue/queue.constants"; +import { NotificationType, NotificationChannel } from "@twizrr/shared"; + +@Injectable() +export class NotificationTriggerService { + private readonly logger = new Logger(NotificationTriggerService.name); + + constructor( + @InjectQueue(NOTIFICATION_QUEUE) private queue: Queue, + @InjectQueue(WHATSAPP_QUEUE) private whatsappQueue: Queue, + ) {} + + private async enqueueWhatsAppNotification( + name: string, + data: Record, + jobId: string, + ): Promise { + try { + await this.whatsappQueue.add(name, data, { + jobId, + attempts: 3, + backoff: { type: "exponential", delay: 5000 }, + removeOnComplete: { age: 24 * 60 * 60 }, + removeOnFail: false, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error( + `Unable to enqueue WhatsApp notification ${name}: ${message}`, + ); + } + } + + public async addJob( + userId: string, + type: NotificationType, + title: string, + body: string, + metadata?: any, + channels: NotificationChannel[] = [ + NotificationChannel.IN_APP, + NotificationChannel.EMAIL, + ], + options?: { removeOnFail?: boolean; url?: string }, + ) { + await this.queue.add( + "send-notification", + { + userId, + type, + title, + body, + channels, + metadata, + url: options?.url, + }, + { + attempts: 3, + backoff: { type: "exponential", delay: 2000 }, + removeOnComplete: true, + removeOnFail: options?.removeOnFail ?? false, + }, + ); + } + + async triggerWelcome(userId: string) { + await this.addJob( + userId, + NotificationType.WELCOME, + "Welcome to twizrr", + "Welcome to Africa's digital trade network.", + ); + } + + async triggerSecurityLogin( + userId: string, + input: { + signalCode: string; + deviceType: string | null; + requestId: string | null; + isSuperAdmin: boolean; + }, + ): Promise { + const title = input.isSuperAdmin + ? "New SUPER_ADMIN login detected" + : "New login to your Twizrr account"; + await this.addJob( + userId, + NotificationType.SECURITY_LOGIN, + title, + "We noticed a login from a device or network we have not seen before. If this was not you, change your password from account settings.", + { + signalCode: input.signalCode, + deviceType: input.deviceType, + requestId: input.requestId, + }, + [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + ); + } + + async triggerEmailVerification(userId: string, otp: string) { + await this.addJob( + userId, + NotificationType.EMAIL_VERIFICATION, + "Verify your account", + `Your code is ${otp}`, + { otp }, + [NotificationChannel.EMAIL], + { removeOnFail: true }, + ); + } + + async triggerOrderPreparing( + buyerId: string, + metadata: { orderId: string; reference: string }, + ) { + await this.addJob( + buyerId, + NotificationType.ORDER_PREPARING, + "Order Preparing", + "Your order is being prepared. The store is packing your goods.", + { ...metadata }, + [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + ); + } + + async triggerOrderDispatched( + buyerId: string, + metadata: { orderId: string; reference: string; otp: string }, + ) { + await this.addJob( + buyerId, + NotificationType.ORDER_DISPATCHED, + "Order on the way", + `Your order is on the way. Your delivery code is ${metadata.otp}.`, + { ...metadata }, + [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + ); + + await this.enqueueWhatsAppNotification( + "send-order-dispatched-notification", + { + buyerId, + orderData: { + orderReference: metadata.reference, + otp: metadata.otp, + }, + }, + `whatsapp-order-dispatched-${metadata.orderId}`, + ); + } + + async triggerOrderInTransit( + buyerId: string, + metadata: { orderId: string; reference: string; note?: string }, + ) { + const noteText = metadata.note ? ` Delivery update: ${metadata.note}` : ""; + await this.addJob( + buyerId, + NotificationType.ORDER_IN_TRANSIT, + "Order In Transit", + `Your order is in transit.${noteText}`, + { ...metadata }, + [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + ); + } + + async triggerDeliveryConfirmed( + storeId: string, + buyerId: string, + metadata: { orderId: string; reference: string; amountKobo: bigint }, + ) { + // Notify merchant + await this.addJob( + storeId, + NotificationType.DELIVERY_CONFIRMED, + "Delivery Confirmed", + `Order #${metadata.reference.slice(0, 8)} delivery has been confirmed.`, + { + ...metadata, + amountKobo: metadata.amountKobo.toString(), + isMerchantId: true, + }, + [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + ); + // Notify buyer + await this.addJob( + buyerId, + NotificationType.DELIVERY_CONFIRMED, + "Delivery Confirmed", + `Order #${metadata.reference.slice(0, 8)} delivery has been confirmed.`, + { ...metadata, amountKobo: metadata.amountKobo.toString() }, + [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + ); + } + + async triggerPayoutInitiated( + storeId: string, + metadata: { + orderId: string; + orderRef: string; + productName: string; + quantity: number; + payoutAmountKobo: string; + bankName: string; + }, + ) { + await this.addJob( + storeId, + NotificationType.PAYOUT_INITIATED, + "Payout Initiated", + "Payout for your order has been initiated.", + { ...metadata, isMerchantId: true }, + ); + } + + async triggerPayoutCompleted( + storeId: string, + metadata: { + amountKobo: string; + orderRef: string; + bankName: string; + }, + ) { + await this.addJob( + storeId, + NotificationType.PAYOUT_COMPLETED, + "Payout Received", + `Payout of ₦${Number(metadata.amountKobo) / 100} has been sent to your ${metadata.bankName} account for Order #${metadata.orderRef}.`, + { ...metadata, isMerchantId: true }, + ); + } + + async triggerPayoutFailed( + storeId: string, + metadata: { + orderRef: string; + amountKobo?: string; + }, + ) { + await this.addJob( + storeId, + NotificationType.PAYOUT_FAILED, + "Payout Delayed", + `Your payout for Order #${metadata.orderRef} is being reviewed.`, + { ...metadata, isMerchantId: true }, + ); + } + + async triggerOrderCancelled( + buyerId: string, + storeId: string, + orderId: string, + ) { + await this.addJob( + buyerId, + NotificationType.ORDER_CANCELLED, + "Order Cancelled", + "Order has been cancelled.", + { orderId }, + ); + await this.addJob( + storeId, + NotificationType.ORDER_CANCELLED, + "Order Cancelled", + "Order has been cancelled.", + { orderId, isMerchantId: true }, + ); + } + + async triggerPasswordReset( + userId: string, + email: string, + resetToken: string, + frontendUrl: string, + ) { + await this.addJob( + userId, + NotificationType.PASSWORD_RESET, + "Reset your password", + "Forgot your password?", + { email, resetToken, frontendUrl }, + [NotificationChannel.EMAIL], + { removeOnFail: true }, + ); + } + + async triggerPaymentConfirmed( + buyerId: string, + storeId: string, + metadata: { orderId: string; reference: string; amountKobo: bigint }, + ) { + // Notify buyer + await this.addJob( + buyerId, + NotificationType.PAYMENT_CONFIRMED, + "Payment Successful", + "Your payment was successful.", + { ...metadata, amountKobo: metadata.amountKobo.toString() }, + [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + ); + + // Notify merchant + await this.addJob( + storeId, + NotificationType.PAYMENT_CONFIRMED, + "Payment Received", + "Payment received for an order.", + { + ...metadata, + amountKobo: metadata.amountKobo.toString(), + isMerchantId: true, + }, + ); + } + + async triggerOrderPurchaseConfirmed( + buyerId: string, + storeId: string, + metadata: { + orderId: string; + reference: string; + productName: string; + buyerName: string; + quantity: number; + amountKobo: bigint; + storeAmountKobo?: bigint; + deliveryAddress?: string; + }, + ) { + const amountKobo = metadata.amountKobo; + const storeAmountKobo = metadata.storeAmountKobo ?? amountKobo; + const formattedAmount = this.formatKoboForNotification(storeAmountKobo); + const storeSafeMetadata = { + orderId: metadata.orderId, + reference: metadata.reference, + productName: metadata.productName, + quantity: metadata.quantity, + }; + + // Notify buyer via Email / In-App + await this.addJob( + buyerId, + NotificationType.ORDER_PURCHASE_CONFIRMED, + "Payment confirmed!", + "Your order is being prepared. You will receive a delivery code when twizrr starts delivery.", + { ...metadata, amountKobo: amountKobo.toString() }, + [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + ); + + // Notify store owner via Email / In-App + await this.addJob( + storeId, + NotificationType.ORDER_PURCHASE_RECEIVED, + "New order received!", + `${metadata.productName} x ${metadata.quantity} - NGN ${formattedAmount}. Check your dashboard to prepare the order.`, + { + ...storeSafeMetadata, + amountKobo: storeAmountKobo.toString(), + isMerchantId: true, + }, + [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + ); + } + async triggerMerchantVerified(userId: string) { + await this.addJob( + userId, + NotificationType.STORE_VERIFIED, + "Store Verified", + "Your store has been successfully verified. You can now list products and receive orders.", + {}, + ); + } + + async triggerMerchantRejected(userId: string, reason?: string) { + await this.addJob( + userId, + NotificationType.STORE_REJECTED, + "Store Verification Rejected", + `Your store verification was unsuccessful. ${reason ? "Reason: " + reason : "Please review your details and re-submit."}`, + { reason }, + ); + } + + async triggerNewMerchantSubmission( + adminUserIds: string[], + metadata: { storeId: string; merchantName: string; targetTier?: string }, + ) { + await Promise.allSettled( + adminUserIds.map((adminId) => + this.addJob( + adminId, + NotificationType.NEW_STORE_SUBMISSION, + "New Store Verification Pending", + `${metadata.merchantName} has submitted their account for verification${metadata.targetTier ? ` (${metadata.targetTier})` : ""}.`, + { ...metadata }, + ), + ), + ); + } + + async triggerPayoutRequested( + adminUserIds: string[], + metadata: { + storeId: string; + merchantName: string; + amountKobo: string; + requestId: string; + }, + ) { + await Promise.allSettled( + adminUserIds.map((adminId) => + this.addJob( + adminId, + NotificationType.PAYOUT_REQUESTED, + "New Payout Request", + `${metadata.merchantName} has requested a payout.`, + { ...metadata }, + ), + ), + ); + } + + async triggerMerchantPayoutRequestedConfirmation( + userId: string, + metadata: { amountKobo: string; requestId: string }, + ) { + await this.addJob( + userId, + NotificationType.PAYOUT_REQUEST_RECEIVED, + "Payout Request Received", + `Your payout request has been received and is being processed.`, + { ...metadata }, + ); + } + + async triggerOrderDisputed(storeId: string, orderId: string, reason: string) { + // Notify store owner + await this.addJob( + storeId, + NotificationType.ORDER_DISPUTED, + "Order Dispute Raised", + `A buyer has raised a dispute for Order #${orderId.slice(0, 8)}. Reason: ${reason}`, + { orderId, reason, isMerchantId: true }, + [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + { url: `/store/orders/${orderId}` }, + ); + + // Notify Admins (Static broadcast for now, in a real app we'd fetch admin IDs) + // For now, the OrderService will handle the call if it has admin IDs, + // but here we just provide the capability. + } + + async triggerDisputeResolved( + buyerId: string, + storeId: string, + orderId: string, + winner: "SHOPPER" | "STORE", + ) { + const buyerMsg = + winner === "SHOPPER" + ? `The dispute for Order #${orderId.slice(0, 8)} has been resolved in your favour. A refund will be processed.` + : `The dispute for Order #${orderId.slice(0, 8)} has been resolved in favour of the store.`; + + const merchantMsg = + winner === "STORE" + ? `The dispute for Order #${orderId.slice(0, 8)} has been resolved in your favour. Payout will be processed.` + : `The dispute for Order #${orderId.slice(0, 8)} has been resolved in favour of the shopper.`; + + // Notify buyer + await this.addJob( + buyerId, + NotificationType.DISPUTE_RESOLVED, + "Dispute Resolved", + buyerMsg, + { orderId, winner }, + [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + { url: `/orders/${orderId}` }, + ); + + // Notify store owner + await this.addJob( + storeId, + NotificationType.DISPUTE_RESOLVED, + "Dispute Resolved", + merchantMsg, + { orderId, winner, isMerchantId: true }, + [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + { url: `/store/orders/${orderId}` }, + ); + } + + async triggerAutoConfirmationWarning( + buyerId: string, + orderRef: string, + hoursRemaining: number, + ) { + await this.addJob( + buyerId, + NotificationType.ORDER_AUTO_CONFIRM_WARNING, + "Action Required: Order Confirmation", + `Your order #${orderRef} will be automatically confirmed in ${hoursRemaining} hours. Please confirm delivery or raise a dispute if there are issues.`, + { orderRef, hoursRemaining }, + [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + ); + } + + async triggerOrderAutoConfirmed( + buyerId: string, + storeId: string, + metadata: { orderId: string; reference: string; amountKobo: bigint }, + ) { + const serializedMetadata = { + ...metadata, + amountKobo: metadata.amountKobo.toString(), + }; + + // Notify buyer + await this.addJob( + buyerId, + NotificationType.ORDER_AUTO_CONFIRMED, + "Order Auto-Confirmed", + `Order #${metadata.reference.slice(0, 8)} has been automatically confirmed after 72 hours.`, + serializedMetadata, + [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + { url: `/orders/${metadata.orderId}` }, + ); + + // Notify merchant + await this.addJob( + storeId, + NotificationType.ORDER_AUTO_CONFIRMED, + "Order Auto-Confirmed", + `Order #${metadata.reference.slice(0, 8)} has been automatically confirmed by the system. Payout will be processed shortly.`, + { ...serializedMetadata, isMerchantId: true }, + [NotificationChannel.IN_APP, NotificationChannel.EMAIL], + { url: `/store/orders/${metadata.orderId}` }, + ); + } + + private formatKoboForNotification(amountKobo: bigint): string { + const naira = amountKobo / 100n; + const kobo = amountKobo % 100n; + return `${naira.toString()}.${kobo.toString().padStart(2, "0")}`; + } +} diff --git a/apps/backend/src/domains/social/notification/notification.controller.ts b/apps/backend/src/domains/social/notification/notification.controller.ts new file mode 100644 index 00000000..cc5b63f3 --- /dev/null +++ b/apps/backend/src/domains/social/notification/notification.controller.ts @@ -0,0 +1,48 @@ +import { Controller, Get, Param, Put, Query, UseGuards } from "@nestjs/common"; + +import { CurrentUser } from "../../../common/decorators/current-user.decorator"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { AuthenticatedRequestUser } from "../../users/auth/auth.types"; +import { + ListNotificationsDto, + NotificationContextDto, +} from "./dto/list-notifications.dto"; +import { NotificationService } from "./notification.service"; + +@UseGuards(JwtAuthGuard) +@Controller("notifications") +export class NotificationController { + constructor(private readonly notificationService: NotificationService) {} + + @Get() + list( + @CurrentUser() user: AuthenticatedRequestUser, + @Query() query: ListNotificationsDto, + ) { + return this.notificationService.listForUser(user.id, query); + } + + @Get("unread-count") + getUnreadCount( + @CurrentUser() user: AuthenticatedRequestUser, + @Query() query: NotificationContextDto, + ) { + return this.notificationService.getUnreadCount(user.id, query.context); + } + + @Put("read-all") + markAllRead( + @CurrentUser() user: AuthenticatedRequestUser, + @Query() query: NotificationContextDto, + ) { + return this.notificationService.markAllRead(user.id, query.context); + } + + @Put(":id/read") + markRead( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("id") id: string, + ) { + return this.notificationService.markRead(user.id, id); + } +} diff --git a/apps/backend/src/domains/social/notification/notification.module.ts b/apps/backend/src/domains/social/notification/notification.module.ts new file mode 100644 index 00000000..8b53d110 --- /dev/null +++ b/apps/backend/src/domains/social/notification/notification.module.ts @@ -0,0 +1,32 @@ +import { Module } from "@nestjs/common"; + +import { PrismaModule } from "../../../core/prisma/prisma.module"; +import { AfricasTalkingModule } from "../../../integrations/africastalking/africastalking.module"; +import { ResendModule } from "../../../integrations/resend/resend.module"; +import { QueueModule } from "../../../queue/queue.module"; +import { RealtimeModule } from "../realtime/realtime.module"; +import { NotificationController } from "./notification.controller"; +import { NotificationService } from "./notification.service"; +import { NotificationTriggerService } from "./notification-trigger.service"; +import { NotificationProcessor } from "./processors/notification.processor"; +import { SmsController } from "./sms.controller"; +import { SmsService } from "./sms.service"; + +@Module({ + imports: [ + PrismaModule, + QueueModule, + ResendModule, + AfricasTalkingModule, + RealtimeModule, + ], + controllers: [NotificationController, SmsController], + providers: [ + NotificationService, + NotificationTriggerService, + NotificationProcessor, + SmsService, + ], + exports: [NotificationService, NotificationTriggerService, SmsService], +}) +export class NotificationModule {} diff --git a/apps/backend/src/domains/social/notification/notification.service.spec.ts b/apps/backend/src/domains/social/notification/notification.service.spec.ts new file mode 100644 index 00000000..d819a42c --- /dev/null +++ b/apps/backend/src/domains/social/notification/notification.service.spec.ts @@ -0,0 +1,294 @@ +import { + NotificationAudience, + NotificationChannel, + NotificationType, +} from "@prisma/client"; +import { REALTIME_EVENT, REALTIME_EVENT_VERSION } from "@twizrr/shared"; + +import { NotificationService } from "./notification.service"; + +describe("NotificationService social activity grouping", () => { + const makeService = () => { + const prisma = { + notification: { + create: jest.fn().mockImplementation(({ data }) => + Promise.resolve({ + id: data.id ?? "notification-new", + ...data, + isRead: false, + createdAt: new Date("2026-06-24T10:00:00.000Z"), + }), + ), + findMany: jest.fn().mockResolvedValue([]), + update: jest.fn().mockImplementation(({ data }) => + Promise.resolve({ + id: "notification-existing", + userId: "target-user", + type: data.type ?? NotificationType.NEW_FOLLOWER, + title: data.title, + body: data.body, + channel: NotificationChannel.IN_APP, + metadata: data.metadata, + url: data.url, + isRead: false, + createdAt: data.createdAt ?? new Date("2026-06-24T10:00:00.000Z"), + }), + ), + findFirst: jest.fn(), + updateMany: jest.fn(), + count: jest.fn(), + }, + }; + const queue = { + add: jest.fn(), + }; + const realtime = { + emitToUser: jest.fn(), + }; + + return { + prisma, + queue, + realtime, + service: new NotificationService( + prisma as never, + queue as never, + realtime as never, + ), + }; + }; + + it("groups new followers into an existing follower notification", async () => { + const { prisma, service } = makeService(); + prisma.notification.findMany.mockResolvedValueOnce([ + { + id: "notification-existing", + metadata: { + groupKey: "NEW_FOLLOWER:target-user", + actorCount: 1, + actorIds: ["follower-1"], + actorPreview: [ + { + id: "follower-1", + name: "Daniel Oreofe", + username: "daniel", + profilePhotoUrl: "https://cdn.example.com/daniel.jpg", + }, + ], + }, + createdAt: new Date("2026-06-24T10:00:00.000Z"), + }, + ]); + + await service.createInApp("target-user", NotificationType.NEW_FOLLOWER, { + followerId: "follower-2", + username: "ameen", + displayName: "AMEEN", + profilePhotoUrl: "https://cdn.example.com/ameen.jpg", + }); + + expect(prisma.notification.create).not.toHaveBeenCalled(); + expect(prisma.notification.update).toHaveBeenCalledWith({ + where: { id: "notification-existing" }, + data: expect.objectContaining({ + title: "New followers", + body: "AMEEN and 1 other followed you.", + metadata: expect.objectContaining({ + groupKey: "NEW_FOLLOWER:target-user", + actorCount: 2, + actorIds: ["follower-2", "follower-1"], + actorPreview: [ + { + id: "follower-2", + name: "AMEEN", + username: "ameen", + profilePhotoUrl: "https://cdn.example.com/ameen.jpg", + }, + { + id: "follower-1", + name: "Daniel Oreofe", + username: "daniel", + profilePhotoUrl: "https://cdn.example.com/daniel.jpg", + }, + ], + }), + createdAt: expect.any(Date), + }), + }); + }); + + it("groups post likes by post target without creating duplicate rows", async () => { + const { prisma, service } = makeService(); + prisma.notification.findMany.mockResolvedValueOnce([ + { + id: "notification-existing", + metadata: { + groupKey: "POST_LIKED:post-1", + actorCount: 2, + actorIds: ["liker-1", "liker-2"], + actorPreview: [ + { id: "liker-2", name: "Reena", username: "reena" }, + { id: "liker-1", name: "Teknium", username: "teknium" }, + ], + postId: "post-1", + }, + createdAt: new Date("2026-06-24T10:00:00.000Z"), + }, + ]); + + await service.createInApp("target-user", NotificationType.POST_LIKED, { + userId: "liker-3", + username: "prince", + displayName: "Prince M. Okunola", + postId: "post-1", + }); + + expect(prisma.notification.create).not.toHaveBeenCalled(); + expect(prisma.notification.update).toHaveBeenCalledWith({ + where: { id: "notification-existing" }, + data: expect.objectContaining({ + title: "Post likes", + body: "Prince M. Okunola and 2 others liked your post.", + metadata: expect.objectContaining({ + groupKey: "POST_LIKED:post-1", + actorCount: 3, + actorIds: ["liker-3", "liker-1", "liker-2"], + postId: "post-1", + }), + url: "/posts/post-1", + }), + }); + }); + + it("emits a minimal typed notification hint after persistence", async () => { + const { prisma, realtime, service } = makeService(); + + await service.createInApp("target-user", NotificationType.ORDER_PAID, { + orderId: "order-1", + orderCode: "TWZ-123456", + paymentReference: "paystack-secret-reference", + }); + + expect(prisma.notification.create).toHaveBeenCalledTimes(1); + expect(realtime.emitToUser).toHaveBeenCalledWith( + "target-user", + expect.objectContaining({ + id: "notification-new", + type: REALTIME_EVENT.NOTIFICATION_NEW, + version: REALTIME_EVENT_VERSION.NOTIFICATION_NEW, + data: { + notificationId: "notification-new", + type: NotificationType.ORDER_PAID, + createdAt: "2026-06-24T10:00:00.000Z", + }, + }), + ); + expect(realtime.emitToUser.mock.calls[0][1]).not.toMatchObject({ + title: expect.anything(), + body: expect.anything(), + url: expect.anything(), + paymentReference: expect.anything(), + }); + }); +}); + +describe("NotificationService audience partitioning", () => { + const makeService = () => { + const prisma = { + notification: { + create: jest.fn().mockImplementation(({ data }) => + Promise.resolve({ + id: "notification-new", + ...data, + isRead: false, + createdAt: new Date("2026-07-12T10:00:00.000Z"), + }), + ), + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 3 }), + count: jest.fn().mockResolvedValue(2), + }, + }; + const realtime = { emitToUser: jest.fn() }; + return { + prisma, + service: new NotificationService( + prisma as never, + { add: jest.fn() } as never, + realtime as never, + ), + }; + }; + + it("defaults createInApp to the shopper audience", async () => { + const { prisma, service } = makeService(); + await service.createInApp("user-1", NotificationType.ORDER_PAID, { + orderId: "order-1", + }); + expect(prisma.notification.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + audience: NotificationAudience.SHOPPER, + }), + }); + }); + + it("persists an explicit store audience for store-directed social", async () => { + const { prisma, service } = makeService(); + await service.createInApp( + "store-owner-1", + NotificationType.POST_LIKED, + { postId: "post-1", userId: "liker-1" }, + undefined, + NotificationAudience.STORE, + ); + expect(prisma.notification.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + audience: NotificationAudience.STORE, + }), + }); + }); + + it("filters unread count by the store audience", async () => { + const { prisma, service } = makeService(); + await service.getUnreadCount("user-1", "store"); + expect(prisma.notification.count).toHaveBeenCalledWith({ + where: { + userId: "user-1", + isRead: false, + audience: NotificationAudience.STORE, + }, + }); + }); + + it("counts every audience when no context is supplied", async () => { + const { prisma, service } = makeService(); + await service.getUnreadCount("user-1"); + expect(prisma.notification.count).toHaveBeenCalledWith({ + where: { userId: "user-1", isRead: false }, + }); + }); + + it("marks only the shopper audience read for the shopper context", async () => { + const { prisma, service } = makeService(); + await service.markAllRead("user-1", "shopper"); + expect(prisma.notification.updateMany).toHaveBeenCalledWith({ + where: { + userId: "user-1", + isRead: false, + audience: NotificationAudience.SHOPPER, + }, + data: { isRead: true }, + }); + }); + + it("scopes the list query to the requested audience", async () => { + const { prisma, service } = makeService(); + await service.listForUser("user-1", { context: "store" }); + expect(prisma.notification.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", audience: NotificationAudience.STORE }, + }), + ); + }); +}); diff --git a/apps/backend/src/domains/social/notification/notification.service.ts b/apps/backend/src/domains/social/notification/notification.service.ts new file mode 100644 index 00000000..bf1a0af8 --- /dev/null +++ b/apps/backend/src/domains/social/notification/notification.service.ts @@ -0,0 +1,719 @@ +import { InjectQueue } from "@nestjs/bullmq"; +import { + BadRequestException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { + Notification, + NotificationAudience, + NotificationChannel, + NotificationType, + Prisma, +} from "@prisma/client"; +import { Queue } from "bullmq"; +import { + REALTIME_EVENT, + REALTIME_EVENT_VERSION, + type NotificationCreatedEvent, +} from "@twizrr/shared"; + +import { + isTransactionalEmailTemplate, + type TransactionalEmailTemplate, + type TransactionalEmailVariables, +} from "../email/providers/email-provider.interface"; +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { QUEUE } from "../../../queue/queue.constants"; +import { RealtimeService } from "../realtime/realtime.service"; +import { + ListNotificationsDto, + type NotificationContext, +} from "./dto/list-notifications.dto"; + +function contextToAudience( + context: NotificationContext | undefined, +): NotificationAudience | undefined { + if (context === "store") return NotificationAudience.STORE; + if (context === "shopper") return NotificationAudience.SHOPPER; + return undefined; +} + +const DEFAULT_PAGE_SIZE = 20; +const SOCIAL_GROUP_WINDOW_MS = 30 * 60 * 1000; +const ACTOR_PREVIEW_LIMIT = 3; + +const NOTIFICATION_SELECT = { + id: true, + type: true, + title: true, + body: true, + channel: true, + isRead: true, + metadata: true, + url: true, + createdAt: true, +} satisfies Prisma.NotificationSelect; + +type NotificationRecord = Prisma.NotificationGetPayload<{ + select: typeof NOTIFICATION_SELECT; +}>; + +export interface NotificationEmailJob { + kind: "email"; + to: string; + template: TransactionalEmailTemplate; + variables: TransactionalEmailVariables; +} + +export interface NotificationResponse { + id: string; + type: string; + title: string; + body: string; + channel: NotificationChannel; + isRead: boolean; + data: Prisma.JsonValue | null; + url: string | null; + createdAt: string; +} + +export interface NotificationPageResponse { + items: NotificationResponse[]; + nextCursor: string | null; +} + +interface ActivityActor { + id: string | null; + name: string; + username: string | null; + profilePhotoUrl: string | null; +} + +interface ActivityGroupInput { + groupKey: string; + actor: ActivityActor; +} + +@Injectable() +export class NotificationService { + constructor( + private readonly prisma: PrismaService, + @InjectQueue(QUEUE.NOTIFICATION) + private readonly notificationQueue: Queue, + private readonly realtime: RealtimeService, + ) {} + + async createInApp( + userId: string, + type: NotificationType, + data: Prisma.InputJsonValue | null, + dedupeKey?: string, + audience: NotificationAudience = NotificationAudience.SHOPPER, + ): Promise { + if (dedupeKey) { + const existing = await this.prisma.notification.findUnique({ + where: { dedupeKey }, + }); + if (existing) { + return existing; + } + } + const grouped = await this.createOrUpdateGroupedNotification( + userId, + type, + data, + audience, + ); + + const notification = + grouped ?? + (await (async () => { + const content = this.resolveNotificationContent(type, data); + return this.prisma.notification.create({ + data: { + userId, + type, + title: content.title, + body: content.body, + channel: NotificationChannel.IN_APP, + audience, + metadata: data ?? Prisma.JsonNull, + url: content.url, + ...(dedupeKey ? { dedupeKey } : {}), + }, + }); + })()); + + this.emitRealtime(userId, notification); + return notification; + } + + /** + * Push a minimal notification hint to the recipient's live sockets so the + * client can refetch authoritative notification state over REST. + */ + emitRealtime( + userId: string, + notification: Pick, + ): void { + const occurredAt = notification.createdAt.toISOString(); + const event: NotificationCreatedEvent = { + id: notification.id, + type: REALTIME_EVENT.NOTIFICATION_NEW, + version: REALTIME_EVENT_VERSION.NOTIFICATION_NEW, + occurredAt, + data: { + notificationId: notification.id, + type: notification.type, + createdAt: occurredAt, + }, + }; + + this.realtime.emitToUser(userId, event); + } + + async queueEmail( + to: string, + template: TransactionalEmailTemplate, + variables: TransactionalEmailVariables, + ): Promise { + if (!this.isEmailAddress(to)) { + throw new BadRequestException({ + message: "A valid email recipient is required", + code: "EMAIL_RECIPIENT_INVALID", + }); + } + + if (!this.isTemplateName(template)) { + throw new BadRequestException({ + message: "Unsupported email template", + code: "EMAIL_TEMPLATE_UNSUPPORTED", + }); + } + + await this.notificationQueue.add( + "send-email", + { + kind: "email", + to, + template, + variables, + }, + { + attempts: 3, + backoff: { type: "exponential", delay: 30_000 }, + removeOnComplete: true, + removeOnFail: 100, + }, + ); + } + + async listForUser( + userId: string, + query: ListNotificationsDto, + ): Promise { + const limit = query.limit ?? DEFAULT_PAGE_SIZE; + const audience = contextToAudience(query.context); + const cursor = query.cursor + ? await this.getOwnedCursor(userId, query.cursor) + : null; + const rows = await this.prisma.notification.findMany({ + where: { + userId, + ...(audience ? { audience } : {}), + }, + select: NOTIFICATION_SELECT, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: limit + 1, + ...(cursor + ? { + cursor: { id: cursor.id }, + skip: 1, + } + : {}), + }); + const pageItems = rows.slice(0, limit); + const hasMore = rows.length > limit; + + return { + items: pageItems.map((notification) => + this.toNotificationResponse(notification), + ), + nextCursor: hasMore + ? (pageItems[pageItems.length - 1]?.id ?? null) + : null, + }; + } + + async markRead( + userId: string, + notificationId: string, + ): Promise { + const notification = await this.prisma.notification.findFirst({ + where: { + id: notificationId, + userId, + }, + select: { id: true }, + }); + + if (!notification) { + throw new NotFoundException({ + message: "Notification not found", + code: "NOTIFICATION_NOT_FOUND", + }); + } + + const updated = await this.prisma.notification.update({ + where: { id: notification.id }, + data: { isRead: true }, + select: NOTIFICATION_SELECT, + }); + + return this.toNotificationResponse(updated); + } + + async markAllRead( + userId: string, + context?: NotificationContext, + ): Promise<{ count: number }> { + const audience = contextToAudience(context); + const result = await this.prisma.notification.updateMany({ + where: { + userId, + isRead: false, + ...(audience ? { audience } : {}), + }, + data: { isRead: true }, + }); + + return { count: result.count }; + } + + async getUnreadCount( + userId: string, + context?: NotificationContext, + ): Promise<{ count: number }> { + const audience = contextToAudience(context); + const count = await this.prisma.notification.count({ + where: { + userId, + isRead: false, + ...(audience ? { audience } : {}), + }, + }); + + return { count }; + } + + private toNotificationResponse( + notification: NotificationRecord, + ): NotificationResponse { + return { + id: notification.id, + type: notification.type, + title: notification.title, + body: notification.body, + channel: notification.channel, + isRead: notification.isRead, + data: notification.metadata, + url: notification.url, + createdAt: notification.createdAt.toISOString(), + }; + } + + private async getOwnedCursor( + userId: string, + cursor: string, + ): Promise<{ id: string }> { + const notification = await this.prisma.notification.findFirst({ + where: { + id: cursor, + userId, + }, + select: { id: true }, + }); + + if (!notification) { + throw new NotFoundException({ + message: "Notification cursor not found", + code: "NOTIFICATION_CURSOR_NOT_FOUND", + }); + } + + return notification; + } + + private resolveNotificationContent( + type: NotificationType, + data: Prisma.InputJsonValue | null, + ): { title: string; body: string; url: string | null } { + const metadata = this.asRecord(data); + const actorCount = this.readPositiveNumber(metadata, "actorCount"); + const actorPreview = this.readActorPreview(metadata); + const primaryActorName = actorPreview[0]?.name; + const orderCode = this.readString(metadata, "orderCode") ?? "your order"; + const username = this.readString(metadata, "username") ?? "Someone"; + const displayName = + this.readString(metadata, "displayName") ?? + this.readString(metadata, "name") ?? + primaryActorName ?? + username; + const storeName = this.readString(metadata, "storeName") ?? "A store"; + const postId = this.readString(metadata, "postId"); + const orderId = this.readString(metadata, "orderId"); + + switch (type) { + case NotificationType.ORDER_PAID: + return { + title: "Order confirmed", + body: `Payment received for ${orderCode}.`, + url: orderId ? `/orders/${orderId}` : null, + }; + case NotificationType.ORDER_DISPATCHED: + return { + title: "Your order is on the way", + body: `${orderCode} is on the way.`, + url: orderId ? `/orders/${orderId}` : null, + }; + case NotificationType.ORDER_COMPLETED: + return { + title: "Order completed", + body: `${orderCode} is complete.`, + url: orderId ? `/orders/${orderId}` : null, + }; + case NotificationType.PAYOUT_SENT: + return { + title: "Payout sent", + body: `Payout for ${orderCode} has been sent.`, + url: orderId ? `/orders/${orderId}` : null, + }; + case NotificationType.PAYOUT_FAILED: + return { + title: "Payout failed", + body: `Payout for ${orderCode} needs review.`, + url: orderId ? `/orders/${orderId}` : null, + }; + case NotificationType.POST_LIKED: + if (actorCount && actorCount > 1) { + return { + title: "Post likes", + body: `${primaryActorName ?? displayName} and ${actorCount - 1} ${actorCount === 2 ? "other" : "others"} liked your post.`, + url: postId ? `/posts/${postId}` : null, + }; + } + return { + title: "New like", + body: `${displayName} liked your post.`, + url: postId ? `/posts/${postId}` : null, + }; + case NotificationType.POST_COMMENTED: + return { + title: "New comment", + body: `${username} commented on your post.`, + url: postId ? `/posts/${postId}` : null, + }; + case NotificationType.COMMENT_REPLIED: + return { + title: "New reply", + body: `${username} replied to your comment.`, + url: postId ? `/posts/${postId}` : null, + }; + case NotificationType.USER_MENTIONED_IN_POST: + case NotificationType.USER_MENTIONED_IN_COMMENT: + return { + title: "You were mentioned", + body: `${username} mentioned you.`, + url: postId ? `/posts/${postId}` : null, + }; + case NotificationType.NEW_FOLLOWER: + if (actorCount && actorCount > 1) { + return { + title: "New followers", + body: `${primaryActorName ?? displayName} and ${actorCount - 1} ${actorCount === 2 ? "other" : "others"} followed you.`, + url: username === "Someone" ? null : `/u/${username}`, + }; + } + return { + title: "New follower", + body: `${displayName} started following you.`, + url: username === "Someone" ? null : `/u/${username}`, + }; + case NotificationType.TAGGED_PRODUCT_SOLD_VIA_POST: + return { + title: "Sale from your post", + body: "A tagged product sold through your post.", + url: postId ? `/posts/${postId}` : null, + }; + case NotificationType.STORE_NOTIFICATION_NEW_POST: { + // Shared by store and personal-profile new-post fan-out. posterName is + // the store or user display name; postKind distinguishes a product + // listing from a social post. + const posterName = this.readString(metadata, "posterName") ?? storeName; + const isProductPost = + this.readString(metadata, "postKind") === "PRODUCT"; + return { + title: "New post", + // The in-app social row shows the body — keep the poster + action in + // it so the message reads " listed a new product". + body: isProductPost + ? `${posterName} listed a new product` + : `${posterName} shared a new post`, + url: postId ? `/posts/${postId}` : null, + }; + } + case NotificationType.MODERATION_BLOCKED: + return { + title: "Content blocked", + body: "Your content did not pass moderation.", + url: null, + }; + case NotificationType.STORE_TIER_UPGRADED: + return { + title: "Store tier upgraded", + body: "Your store verification tier has been updated.", + url: "/store/settings/verification", + }; + default: + return { + title: "Notification", + body: "You have a new notification.", + url: null, + }; + } + } + + private isTemplateName(value: string): value is TransactionalEmailTemplate { + return isTransactionalEmailTemplate(value); + } + + private isEmailAddress(value: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); + } + + private asRecord( + value: Prisma.InputJsonValue | null, + ): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return {}; + } + + return value as Record; + } + + private readString( + metadata: Record, + key: string, + ): string | null { + const value = metadata[key]; + return typeof value === "string" && value.trim() ? value : null; + } + + private async createOrUpdateGroupedNotification( + userId: string, + type: NotificationType, + data: Prisma.InputJsonValue | null, + audience: NotificationAudience, + ): Promise { + const group = this.buildActivityGroup(userId, type, data); + if (!group) return null; + + const now = new Date(); + const rows = await this.prisma.notification.findMany({ + where: { + userId, + type, + channel: NotificationChannel.IN_APP, + createdAt: { + gte: new Date(now.getTime() - SOCIAL_GROUP_WINDOW_MS), + }, + }, + select: { + id: true, + metadata: true, + }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: 20, + }); + const existing = rows.find( + (row) => this.asRecord(row.metadata).groupKey === group.groupKey, + ); + const metadata = existing + ? this.mergeActivityMetadata(this.asRecord(existing.metadata), group) + : this.createActivityMetadata(data, group); + const content = this.resolveNotificationContent(type, metadata); + + if (existing) { + return this.prisma.notification.update({ + where: { id: existing.id }, + data: { + title: content.title, + body: content.body, + metadata, + url: content.url, + createdAt: now, + }, + }); + } + + return this.prisma.notification.create({ + data: { + userId, + type, + title: content.title, + body: content.body, + channel: NotificationChannel.IN_APP, + audience, + metadata, + url: content.url, + }, + }); + } + + private buildActivityGroup( + userId: string, + type: NotificationType, + data: Prisma.InputJsonValue | null, + ): ActivityGroupInput | null { + const metadata = this.asRecord(data); + + if (type === NotificationType.NEW_FOLLOWER) { + return { + groupKey: `${type}:${userId}`, + actor: this.readActivityActor(metadata), + }; + } + + if (type === NotificationType.POST_LIKED) { + const postId = this.readString(metadata, "postId"); + if (!postId) return null; + + return { + groupKey: `${type}:${postId}`, + actor: this.readActivityActor(metadata), + }; + } + + return null; + } + + private createActivityMetadata( + data: Prisma.InputJsonValue | null, + group: ActivityGroupInput, + ): Prisma.JsonObject { + return { + ...this.asRecord(data), + groupKey: group.groupKey, + actorCount: 1, + actorIds: group.actor.id ? [group.actor.id] : [], + actorPreview: [this.toActivityActorJson(group.actor)], + }; + } + + private mergeActivityMetadata( + existing: Record, + group: ActivityGroupInput, + ): Prisma.JsonObject { + const existingActorIds = this.readStringArray(existing.actorIds); + const actorAlreadyCounted = group.actor.id + ? existingActorIds.includes(group.actor.id) + : false; + const actorIds = + group.actor.id && !actorAlreadyCounted + ? [group.actor.id, ...existingActorIds] + : existingActorIds; + const existingActorCount = + this.readPositiveNumber(existing, "actorCount") ?? + Math.max(existingActorIds.length, this.readActorPreview(existing).length); + const actorCount = actorAlreadyCounted + ? existingActorCount + : existingActorCount + 1; + const actorPreview = actorAlreadyCounted + ? this.readActorPreview(existing) + : [group.actor, ...this.readActorPreview(existing)] + .filter((actor, index, actors) => { + if (!actor.id) return index === actors.findIndex((a) => !a.id); + return actors.findIndex((a) => a.id === actor.id) === index; + }) + .slice(0, ACTOR_PREVIEW_LIMIT); + + return { + ...existing, + groupKey: group.groupKey, + actorCount, + actorIds, + actorPreview: actorPreview.map((actor) => + this.toActivityActorJson(actor), + ), + }; + } + + private readActivityActor(metadata: Record): ActivityActor { + const username = this.readString(metadata, "username"); + const name = + this.readString(metadata, "displayName") ?? + this.readString(metadata, "name") ?? + username ?? + "Someone"; + + return { + id: + this.readString(metadata, "actorId") ?? + this.readString(metadata, "followerId") ?? + this.readString(metadata, "userId"), + name, + username, + profilePhotoUrl: + this.readString(metadata, "profilePhotoUrl") ?? + this.readString(metadata, "avatarUrl") ?? + this.readString(metadata, "imageUrl"), + }; + } + + private readActorPreview(value: Record): ActivityActor[] { + const preview = value.actorPreview; + if (!Array.isArray(preview)) return []; + + return preview + .map((entry): ActivityActor | null => { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) + return null; + const actor = entry as Record; + const name = this.readString(actor, "name"); + if (!name) return null; + + return { + id: this.readString(actor, "id"), + name, + username: this.readString(actor, "username"), + profilePhotoUrl: this.readString(actor, "profilePhotoUrl"), + }; + }) + .filter((actor): actor is ActivityActor => actor !== null); + } + + private readStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); + } + + private toActivityActorJson(actor: ActivityActor): Prisma.JsonObject { + return { + id: actor.id, + name: actor.name, + username: actor.username, + profilePhotoUrl: actor.profilePhotoUrl, + }; + } + + private readPositiveNumber( + metadata: Record, + key: string, + ): number | null { + const value = metadata[key]; + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? value + : null; + } +} diff --git a/apps/backend/src/domains/social/notification/processors/notification.processor.spec.ts b/apps/backend/src/domains/social/notification/processors/notification.processor.spec.ts new file mode 100644 index 00000000..9f7ce9c1 --- /dev/null +++ b/apps/backend/src/domains/social/notification/processors/notification.processor.spec.ts @@ -0,0 +1,231 @@ +import { + NotificationAudience, + NotificationChannel, + Prisma, +} from "@prisma/client"; +import { + NotificationChannel as SharedNotificationChannel, + NotificationType, +} from "@twizrr/shared"; + +import { NotificationProcessor } from "./notification.processor"; + +describe("NotificationProcessor legacy trigger jobs", () => { + const makeProcessor = () => { + const prisma = { + user: { + findUnique: jest.fn().mockResolvedValue({ + id: "buyer-1", + email: "buyer@example.com", + }), + }, + storeProfile: { + findUnique: jest.fn().mockResolvedValue({ + id: "store-1", + userId: "store-owner-1", + user: { email: "owner@example.com" }, + }), + }, + notification: { + create: jest.fn().mockResolvedValue({ + id: "notification-1", + type: "ORDER_PAID", + title: "Title", + body: "Body", + url: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }), + }, + }; + const emailProvider = { + sendTransactionalEmail: jest.fn().mockResolvedValue({ + provider: "resend", + providerMessageId: null, + accepted: true, + }), + }; + const realtime = { + emitToUser: jest.fn(), + }; + const processor = new NotificationProcessor( + emailProvider as never, + prisma as never, + realtime as never, + ); + + return { processor, prisma, emailProvider, realtime }; + }; + + it("persists and emails buyer legacy notification trigger jobs", async () => { + const { processor, prisma, emailProvider } = makeProcessor(); + + await processor.process({ + id: "job-1", + name: "send-notification", + data: { + userId: "buyer-1", + type: NotificationType.ORDER_PURCHASE_CONFIRMED, + title: "Payment confirmed", + body: "Your payment is protected.", + channels: [ + SharedNotificationChannel.IN_APP, + SharedNotificationChannel.EMAIL, + ], + metadata: { orderId: "order-1" }, + url: "/orders/order-1", + }, + } as never); + + expect(prisma.user.findUnique).toHaveBeenCalledWith({ + where: { id: "buyer-1" }, + select: { id: true, email: true }, + }); + expect(prisma.notification.create).toHaveBeenCalledWith({ + data: { + id: expect.stringMatching(/^legacy_[a-f0-9]{64}$/), + userId: "buyer-1", + type: NotificationType.ORDER_PURCHASE_CONFIRMED, + title: "Payment confirmed", + body: "Your payment is protected.", + channel: NotificationChannel.IN_APP, + audience: NotificationAudience.SHOPPER, + metadata: { orderId: "order-1" }, + url: "/orders/order-1", + }, + }); + expect(emailProvider.sendTransactionalEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: { email: "buyer@example.com" }, + template: "LEGACY_NOTIFICATION", + variables: { + title: "Payment confirmed", + body: "Your payment is protected.", + }, + }), + ); + }); + + it("resolves store-owner legacy notification jobs from store id metadata", async () => { + const { processor, prisma, emailProvider } = makeProcessor(); + + await processor.process({ + id: "job-2", + name: "send-notification", + data: { + userId: "store-1", + type: NotificationType.ORDER_PURCHASE_RECEIVED, + title: "New order received", + body: "Check your dashboard to prepare the order.", + channels: [ + SharedNotificationChannel.IN_APP, + SharedNotificationChannel.EMAIL, + ], + metadata: { orderId: "order-1", isMerchantId: true }, + }, + } as never); + + expect(prisma.storeProfile.findUnique).toHaveBeenCalledWith({ + where: { id: "store-1" }, + select: { userId: true, user: { select: { email: true } } }, + }); + expect(prisma.notification.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: "store-owner-1", + channel: NotificationChannel.IN_APP, + // isMerchantId metadata routes the notification to the store audience. + audience: NotificationAudience.STORE, + }), + }); + expect(emailProvider.sendTransactionalEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: { email: "owner@example.com" }, + template: "LEGACY_NOTIFICATION", + variables: { + title: "New order received", + body: "Check your dashboard to prepare the order.", + }, + }), + ); + }); + + it("does not send legacy email when the in-app notification write fails", async () => { + const { processor, prisma, emailProvider } = makeProcessor(); + prisma.notification.create.mockRejectedValueOnce( + new Error("database unavailable"), + ); + + await expect( + processor.process({ + id: "job-4", + name: "send-notification", + data: { + userId: "buyer-1", + type: NotificationType.ORDER_PURCHASE_CONFIRMED, + title: "Payment confirmed", + body: "Your payment is protected.", + channels: [ + SharedNotificationChannel.IN_APP, + SharedNotificationChannel.EMAIL, + ], + metadata: { orderId: "order-1" }, + }, + } as never), + ).rejects.toThrow("database unavailable"); + + expect(emailProvider.sendTransactionalEmail).not.toHaveBeenCalled(); + }); + + it("skips duplicate legacy in-app rows on retry while allowing email delivery to retry", async () => { + const { processor, prisma, emailProvider } = makeProcessor(); + prisma.notification.create.mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + clientVersion: "test", + }), + ); + + await processor.process({ + id: "job-5", + name: "send-notification", + data: { + userId: "buyer-1", + type: NotificationType.ORDER_PURCHASE_CONFIRMED, + title: "Payment confirmed", + body: "Your payment is protected.", + channels: [ + SharedNotificationChannel.IN_APP, + SharedNotificationChannel.EMAIL, + ], + metadata: { orderId: "order-1" }, + }, + } as never); + + expect(prisma.notification.create).toHaveBeenCalledTimes(1); + expect(emailProvider.sendTransactionalEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: { email: "buyer@example.com" }, + template: "LEGACY_NOTIFICATION", + }), + ); + }); + + it("does not fail legacy jobs that include WhatsApp as an unsupported notification-table channel", async () => { + const { processor, prisma, emailProvider } = makeProcessor(); + + await processor.process({ + id: "job-3", + name: "send-notification", + data: { + userId: "buyer-1", + type: NotificationType.ORDER_DISPATCHED, + title: "Order on the way", + body: "Your delivery code is 123456.", + channels: [SharedNotificationChannel.WHATSAPP], + metadata: { orderId: "order-1" }, + }, + } as never); + + expect(prisma.notification.create).not.toHaveBeenCalled(); + expect(emailProvider.sendTransactionalEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/domains/social/notification/processors/notification.processor.ts b/apps/backend/src/domains/social/notification/processors/notification.processor.ts new file mode 100644 index 00000000..6116282b --- /dev/null +++ b/apps/backend/src/domains/social/notification/processors/notification.processor.ts @@ -0,0 +1,314 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Inject, Logger } from "@nestjs/common"; +import { + NotificationAudience, + NotificationChannel, + Prisma, +} from "@prisma/client"; +import { Job } from "bullmq"; +import { createHash } from "crypto"; +import { + REALTIME_EVENT, + REALTIME_EVENT_VERSION, + type NotificationCreatedEvent, +} from "@twizrr/shared"; + +import { + EMAIL_PROVIDER, + isTransactionalEmailTemplate, + type EmailProvider, + type TransactionalEmailTemplate, + type TransactionalEmailVariables, +} from "../../email/providers/email-provider.interface"; +import { PrismaService } from "../../../../core/prisma/prisma.service"; +import { QUEUE } from "../../../../queue/queue.constants"; +import { RealtimeService } from "../../realtime/realtime.service"; +import { NotificationEmailJob } from "../notification.service"; + +interface LegacyNotificationJob { + userId: string; + type: string; + title: string; + body: string; + channels: string[]; + metadata?: Prisma.InputJsonValue | null; + url?: string | null; + dedupeKey?: string; + audience?: string; +} + +@Processor(QUEUE.NOTIFICATION) +export class NotificationProcessor extends WorkerHost { + private readonly logger = new Logger(NotificationProcessor.name); + + constructor( + @Inject(EMAIL_PROVIDER) + private readonly emailProvider: EmailProvider, + private readonly prisma: PrismaService, + private readonly realtime: RealtimeService, + ) { + super(); + } + + async process(job: Job): Promise { + if (this.isLegacyNotificationJob(job.data)) { + await this.processLegacyNotificationJob(job.data, job.id); + return; + } + + if (!this.isEmailJob(job.data)) { + this.logger.warn( + `Notification job ${job.id ?? "unknown"} failed: unsupported payload`, + ); + throw new Error("Unsupported notification job payload"); + } + + try { + await this.emailProvider.sendTransactionalEmail({ + to: { email: job.data.to }, + template: job.data.template, + variables: job.data.variables, + idempotencyKey: job.id ?? undefined, + }); + } catch (error) { + const errorType = error instanceof Error ? error.name : typeof error; + this.logger.error( + `Notification email job ${job.id ?? "unknown"} failed recipientDomain=${this.maskEmailDomain( + job.data.to, + )} template=${job.data.template} type=${errorType}`, + ); + throw error; + } + } + + private async processLegacyNotificationJob( + data: LegacyNotificationJob, + jobId: string | undefined, + ): Promise { + const recipient = await this.resolveLegacyRecipient(data); + + if (!recipient) { + this.logger.warn( + `Legacy notification job ${jobId ?? "unknown"} skipped: recipient not found`, + ); + return; + } + + if (data.channels.includes("IN_APP")) { + await this.createLegacyInAppNotification(data, jobId, recipient.userId); + } + + if (data.channels.includes("EMAIL")) { + if (recipient.email) { + await this.emailProvider.sendTransactionalEmail({ + to: { email: recipient.email }, + template: "LEGACY_NOTIFICATION", + variables: { title: data.title, body: data.body }, + idempotencyKey: jobId + ? `legacy-notification:${jobId}:email` + : undefined, + }); + } else { + this.logger.warn( + `Legacy notification job ${jobId ?? "unknown"} skipped email: recipient has no email`, + ); + } + } + } + + private async createLegacyInAppNotification( + data: LegacyNotificationJob, + jobId: string | undefined, + recipientUserId: string, + ): Promise { + try { + const created = await this.prisma.notification.create({ + data: { + id: this.buildLegacyNotificationId( + jobId, + NotificationChannel.IN_APP, + recipientUserId, + data, + ), + userId: recipientUserId, + type: data.type, + title: data.title, + body: data.body, + channel: NotificationChannel.IN_APP, + audience: this.resolveAudience(data), + metadata: data.metadata ?? Prisma.JsonNull, + url: data.url ?? null, + ...(data.dedupeKey ? { dedupeKey: data.dedupeKey } : {}), + }, + }); + const occurredAt = created.createdAt.toISOString(); + const event: NotificationCreatedEvent = { + id: created.id, + type: REALTIME_EVENT.NOTIFICATION_NEW, + version: REALTIME_EVENT_VERSION.NOTIFICATION_NEW, + occurredAt, + data: { + notificationId: created.id, + type: created.type, + createdAt: occurredAt, + }, + }; + + // Live-push to the recipient's sockets; delivery hint only. + this.realtime.emitToUser(recipientUserId, event); + } catch (error) { + if (this.isUniqueConstraintError(error)) { + this.logger.warn( + `Legacy notification job ${jobId ?? "unknown"} skipped duplicate in-app notification recipient=${recipientUserId}`, + ); + return; + } + + throw error; + } + } + + private async resolveLegacyRecipient( + data: LegacyNotificationJob, + ): Promise<{ userId: string; email: string | null } | null> { + if (this.isStoreScopedLegacyJob(data)) { + const store = await this.prisma.storeProfile.findUnique({ + where: { id: data.userId }, + select: { userId: true, user: { select: { email: true } } }, + }); + + return store + ? { userId: store.userId, email: store.user.email ?? null } + : null; + } + + const user = await this.prisma.user.findUnique({ + where: { id: data.userId }, + select: { id: true, email: true }, + }); + + return user ? { userId: user.id, email: user.email ?? null } : null; + } + + private isStoreScopedLegacyJob(data: LegacyNotificationJob): boolean { + return this.asRecord(data.metadata)?.isMerchantId === true; + } + + private isEmailJob(value: unknown): value is NotificationEmailJob { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + + const record = value as Record; + return ( + record.kind === "email" && + typeof record.to === "string" && + this.isTemplateName(record.template) && + this.isVariables(record.variables) + ); + } + + private isTemplateName(value: unknown): value is TransactionalEmailTemplate { + return isTransactionalEmailTemplate(value); + } + + private isLegacyNotificationJob( + value: unknown, + ): value is LegacyNotificationJob { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + + const record = value as Record; + return ( + typeof record.userId === "string" && + typeof record.type === "string" && + typeof record.title === "string" && + typeof record.body === "string" && + Array.isArray(record.channels) && + record.channels.every((channel) => typeof channel === "string") && + (record.metadata === undefined || + record.metadata === null || + this.isJsonObject(record.metadata)) && + (record.url === undefined || + record.url === null || + typeof record.url === "string") && + (record.dedupeKey === undefined || typeof record.dedupeKey === "string") + ); + } + + private isVariables(value: unknown): value is TransactionalEmailVariables { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + + return Object.values(value).every( + (item) => + item === null || + typeof item === "string" || + typeof item === "number" || + typeof item === "boolean", + ); + } + + private maskEmailDomain(email: string): string { + return email.includes("@") ? `****@${email.split("@").pop()}` : "unknown"; + } + + private asRecord(value: Prisma.InputJsonValue | null | undefined) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + + return value as Record; + } + + // Audience partitions notifications between the shopper and store modes. + // An explicit job field wins (outbox dispatches set it); otherwise the + // historical store signal metadata.isMerchantId decides. Everything else is + // a shopper-identity notification. + private resolveAudience(data: LegacyNotificationJob): NotificationAudience { + if (data.audience === NotificationAudience.STORE) { + return NotificationAudience.STORE; + } + if (data.audience === NotificationAudience.SHOPPER) { + return NotificationAudience.SHOPPER; + } + return this.asRecord(data.metadata)?.isMerchantId === true + ? NotificationAudience.STORE + : NotificationAudience.SHOPPER; + } + + private isJsonObject(value: unknown): value is Prisma.InputJsonValue { + return typeof value === "object" && value !== null && !Array.isArray(value); + } + + private buildLegacyNotificationId( + jobId: string | undefined, + channel: NotificationChannel, + recipientUserId: string, + data: LegacyNotificationJob, + ): string { + const source = JSON.stringify({ + jobId: jobId ?? "unknown", + channel, + recipientUserId, + type: data.type, + title: data.title, + body: data.body, + url: data.url ?? null, + metadata: data.metadata ?? null, + }); + + return `legacy_${createHash("sha256").update(source).digest("hex")}`; + } + + private isUniqueConstraintError( + error: unknown, + ): error is Prisma.PrismaClientKnownRequestError { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ); + } +} diff --git a/apps/backend/src/domains/social/notification/sms.controller.ts b/apps/backend/src/domains/social/notification/sms.controller.ts new file mode 100644 index 00000000..a8c073a8 --- /dev/null +++ b/apps/backend/src/domains/social/notification/sms.controller.ts @@ -0,0 +1,57 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Logger, + Post, + Query, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { SkipThrottle } from "@nestjs/throttler"; + +import { SmsService } from "./sms.service"; + +/** + * Africa's Talking SMS delivery-report (DLR) webhook. + * + * Configured in the AT dashboard under SMS > Delivery Reports. AT sends + * application/x-www-form-urlencoded and expects a 200 response — any non-200 + * makes it retry — so this endpoint always returns 200 and never validates + * against a strict DTO (AT's field set is not fixed, and the global pipe's + * forbidNonWhitelisted would 400 on unknown fields). AT does not sign delivery + * reports, so an optional shared secret (AT_DELIVERY_REPORT_SECRET) can be + * embedded in the callback URL as ?token=... to reject spoofed calls. + */ +@Controller("sms") +@SkipThrottle() +export class SmsController { + private readonly logger = new Logger(SmsController.name); + + constructor( + private readonly smsService: SmsService, + private readonly configService: ConfigService, + ) {} + + @Post("delivery-report") + @HttpCode(HttpStatus.OK) + handleDeliveryReport( + @Body() body: Record, + @Query("token") token?: string, + ): { received: true } { + const expected = this.configService.get( + "africastalking.deliveryReportSecret", + ); + + if (expected && token !== expected) { + // Acknowledge with 200 so AT stops retrying, but do not process it. + this.logger.warn( + "Rejected SMS delivery report: invalid or missing token", + ); + return { received: true }; + } + + this.smsService.recordDeliveryReport(body); + return { received: true }; + } +} diff --git a/apps/backend/src/domains/social/notification/sms.service.ts b/apps/backend/src/domains/social/notification/sms.service.ts new file mode 100644 index 00000000..39ddd28f --- /dev/null +++ b/apps/backend/src/domains/social/notification/sms.service.ts @@ -0,0 +1,71 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { AfricasTalkingClient } from "../../../integrations/africastalking/africastalking.client"; + +@Injectable() +export class SmsService { + private readonly logger = new Logger(SmsService.name); + + constructor(private africasTalkingClient: AfricasTalkingClient) {} + + /** + * Dispatches an SMS to a specified phone number using Africa's Talking. + * @param phoneNumber Phone number must be in E.164 format (e.g., +2348147846093). + * + * @param to The recipient's phone number + * @param message The message body + */ + async sendSms(to: string, message: string): Promise { + // A fast regex to ensure the number loosely matches E.164 structure before sending + const e164Regex = /^\+[1-9]\d{1,14}$/; + if (!e164Regex.test(to)) { + this.logger.error( + `Failed to send SMS: Invalid phone number format for ${to}. Must be E.164 (e.g. +234...)`, + ); + throw new Error("Invalid phone number format"); + } + + try { + return await this.africasTalkingClient.sendSms(to, message); + } catch (error: any) { + this.logger.error( + `SMS dispatch failed for ${to}`, + error?.response?.data || error.message, + ); + throw error; + } + } + + /** + * Records an Africa's Talking SMS delivery report (DLR). We do not correlate + * reports back to a specific message yet (send responses don't persist the AT + * messageId), so this is observability only: terminal failures are logged at + * warn so deliverability issues (DND, unregistered sender, bad numbers) are + * visible, everything else at log level. Phone numbers are masked. + */ + recordDeliveryReport(report: Record): void { + const id = this.asString(report.id) ?? "unknown"; + const status = this.asString(report.status) ?? "unknown"; + const failureReason = this.asString(report.failureReason); + const recipient = this.maskPhone(this.asString(report.phoneNumber) ?? ""); + + const detail = `id=${id} status=${status} recipient=${recipient}${ + failureReason ? ` reason=${failureReason}` : "" + }`; + + // "Failed" and "Rejected" are Africa's Talking terminal failure states. + if (status === "Failed" || status === "Rejected") { + this.logger.warn(`SMS delivery failed ${detail}`); + return; + } + + this.logger.log(`SMS delivery report ${detail}`); + } + + private asString(value: unknown): string | null { + return typeof value === "string" && value.trim() !== "" ? value : null; + } + + private maskPhone(phone: string): string { + return phone.length <= 4 ? "****" : `****${phone.slice(-4)}`; + } +} diff --git a/apps/backend/src/domains/social/post-subscription/dto/update-profile-subscription.dto.ts b/apps/backend/src/domains/social/post-subscription/dto/update-profile-subscription.dto.ts new file mode 100644 index 00000000..967338ba --- /dev/null +++ b/apps/backend/src/domains/social/post-subscription/dto/update-profile-subscription.dto.ts @@ -0,0 +1,6 @@ +import { IsBoolean } from "class-validator"; + +export class UpdateProfileSubscriptionDto { + @IsBoolean() + enabled!: boolean; +} diff --git a/apps/backend/src/domains/social/post-subscription/dto/update-subscription.dto.ts b/apps/backend/src/domains/social/post-subscription/dto/update-subscription.dto.ts new file mode 100644 index 00000000..62fe1e58 --- /dev/null +++ b/apps/backend/src/domains/social/post-subscription/dto/update-subscription.dto.ts @@ -0,0 +1,12 @@ +import { IsIn } from "class-validator"; + +/** + * Store bell setting. "OFF" removes the subscription; the scopes map to + * PostNotificationScope. Personal profiles use the boolean DTO below. + */ +export type StoreBellSetting = "SOCIAL" | "PRODUCT" | "ALL" | "OFF"; + +export class UpdateStoreSubscriptionDto { + @IsIn(["SOCIAL", "PRODUCT", "ALL", "OFF"]) + setting!: StoreBellSetting; +} diff --git a/apps/backend/src/domains/social/post-subscription/post-fanout.processor.spec.ts b/apps/backend/src/domains/social/post-subscription/post-fanout.processor.spec.ts new file mode 100644 index 00000000..ecd20b02 --- /dev/null +++ b/apps/backend/src/domains/social/post-subscription/post-fanout.processor.spec.ts @@ -0,0 +1,139 @@ +import { + NotificationAudience, + NotificationType, + PostNotificationScope, + PostType, +} from "@prisma/client"; +import type { Job } from "bullmq"; + +import { PostFanoutProcessor } from "./post-fanout.processor"; +import type { PostFanoutJob } from "./post-fanout.types"; + +function makeProcessor(subscribers: { id: string; userId: string }[]) { + const prisma = { + post: { + findFirst: jest.fn().mockResolvedValue({ id: "post-1" }), + }, + storeProfile: { + findUnique: jest + .fn() + .mockResolvedValue({ userId: "owner-1", storeName: "Yaba" }), + }, + user: { + findUnique: jest + .fn() + .mockResolvedValue({ displayName: "AMEEN", username: "ameen" }), + }, + storeNotificationSubscription: { + findMany: jest.fn().mockResolvedValue(subscribers), + }, + profilePostSubscription: { + findMany: jest + .fn() + .mockResolvedValue( + subscribers.map((s) => ({ id: s.id, subscriberId: s.userId })), + ), + }, + }; + const notifications = { + createInApp: jest.fn().mockResolvedValue(undefined), + }; + return { + prisma, + notifications, + processor: new PostFanoutProcessor(prisma as never, notifications as never), + }; +} + +const job = (data: PostFanoutJob) => ({ data }) as Job; + +describe("PostFanoutProcessor", () => { + it("notifies scope-matching store subscribers in the SHOPPER audience", async () => { + const { processor, prisma, notifications } = makeProcessor([ + { id: "s1", userId: "follower-1" }, + { id: "s2", userId: "follower-2" }, + ]); + + await processor.process( + job({ + postId: "post-1", + postType: PostType.PRODUCT_POST, + authorId: "owner-1", + storeId: "store-1", + }), + ); + + // A product post targets PRODUCT + ALL subscribers, excluding the owner. + const where = + prisma.storeNotificationSubscription.findMany.mock.calls[0][0].where; + expect(where.scope.in).toEqual([ + PostNotificationScope.PRODUCT, + PostNotificationScope.ALL, + ]); + expect(where.userId).toEqual({ not: "owner-1" }); + + expect(notifications.createInApp).toHaveBeenCalledTimes(2); + const [recipient, type, data, dedupeKey, audience] = + notifications.createInApp.mock.calls[0]; + expect(recipient).toBe("follower-1"); + expect(type).toBe(NotificationType.STORE_NOTIFICATION_NEW_POST); + expect(data).toMatchObject({ postId: "post-1", postKind: "PRODUCT" }); + expect(dedupeKey).toBe("new-post:post-1:follower-1"); + expect(audience).toBe(NotificationAudience.SHOPPER); + }); + + it("uses SOCIAL+ALL scope for a store social post", async () => { + const { processor, prisma } = makeProcessor([]); + await processor.process( + job({ + postId: "post-1", + postType: PostType.IMAGE_POST, + authorId: "owner-1", + storeId: "store-1", + }), + ); + const where = + prisma.storeNotificationSubscription.findMany.mock.calls[0][0].where; + expect(where.scope.in).toEqual([ + PostNotificationScope.SOCIAL, + PostNotificationScope.ALL, + ]); + }); + + it("fans a personal post out to profile subscribers", async () => { + const { processor, notifications } = makeProcessor([ + { id: "p1", userId: "follower-9" }, + ]); + await processor.process( + job({ + postId: "post-2", + postType: PostType.GIST, + authorId: "author-1", + storeId: null, + }), + ); + expect(notifications.createInApp).toHaveBeenCalledWith( + "follower-9", + NotificationType.STORE_NOTIFICATION_NEW_POST, + expect.objectContaining({ postId: "post-2", postKind: "SOCIAL" }), + "new-post:post-2:follower-9", + NotificationAudience.SHOPPER, + ); + }); + + it("fans out nothing when the post is no longer active", async () => { + const { processor, prisma, notifications } = makeProcessor([ + { id: "s1", userId: "follower-1" }, + ]); + prisma.post.findFirst.mockResolvedValueOnce(null); + await processor.process( + job({ + postId: "post-1", + postType: PostType.PRODUCT_POST, + authorId: "owner-1", + storeId: "store-1", + }), + ); + expect(notifications.createInApp).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/domains/social/post-subscription/post-fanout.processor.ts b/apps/backend/src/domains/social/post-subscription/post-fanout.processor.ts new file mode 100644 index 00000000..08b62d18 --- /dev/null +++ b/apps/backend/src/domains/social/post-subscription/post-fanout.processor.ts @@ -0,0 +1,200 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Logger } from "@nestjs/common"; +import { + NotificationAudience, + NotificationType, + PostNotificationScope, + PostType, + Prisma, +} from "@prisma/client"; +import { Job } from "bullmq"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { QUEUE } from "../../../queue/queue.constants"; +import { NotificationService } from "../notification/notification.service"; +import { type PostFanoutJob } from "./post-fanout.types"; + +const SUBSCRIBER_BATCH_SIZE = 200; + +@Processor(QUEUE.POST_FANOUT) +export class PostFanoutProcessor extends WorkerHost { + private readonly logger = new Logger(PostFanoutProcessor.name); + + constructor( + private readonly prisma: PrismaService, + private readonly notifications: NotificationService, + ) { + super(); + } + + async process(job: Job): Promise { + const { postId, postType, authorId, storeId } = job.data; + const isProduct = postType === PostType.PRODUCT_POST; + + // Guard: the post must still exist and be live. This makes enqueue-inside-a + // transaction safe — if the publish rolled back, or the post was removed + // from the feed before this job ran, we fan out nothing. + const post = await this.prisma.post.findFirst({ + where: { id: postId, isActive: true }, + select: { id: true }, + }); + if (!post) { + return; + } + + if (storeId) { + await this.fanOutStore(postId, storeId, isProduct); + } else if (authorId && !isProduct) { + // Personal profiles only publish social posts. + await this.fanOutProfile(postId, authorId); + } + } + + private async fanOutStore( + postId: string, + storeId: string, + isProduct: boolean, + ): Promise { + const store = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + select: { + userId: true, + storeName: true, + businessName: true, + storeHandle: true, + logoUrl: true, + }, + }); + if (!store) { + return; + } + const posterName = store.storeName ?? store.businessName ?? "A store"; + // A product post reaches PRODUCT + ALL subscribers; a social post reaches + // SOCIAL + ALL subscribers. Owner is excluded so a store owner is never + // notified of their own post. + const scopes: PostNotificationScope[] = isProduct + ? [PostNotificationScope.PRODUCT, PostNotificationScope.ALL] + : [PostNotificationScope.SOCIAL, PostNotificationScope.ALL]; + + await this.paginate( + (cursor) => + this.prisma.storeNotificationSubscription.findMany({ + where: { + storeId, + scope: { in: scopes }, + userId: { not: store.userId }, + }, + select: { id: true, userId: true }, + orderBy: { id: "asc" }, + take: SUBSCRIBER_BATCH_SIZE, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }), + (rows) => rows[rows.length - 1]?.id ?? null, + (row) => + this.notifyOnce(row.userId, postId, { + posterName, + postKind: isProduct ? "PRODUCT" : "SOCIAL", + actorType: "STORE", + actorUsername: store.storeHandle ?? null, + actorImageUrl: store.logoUrl ?? null, + }), + ); + } + + private async fanOutProfile(postId: string, authorId: string): Promise { + const author = await this.prisma.user.findUnique({ + where: { id: authorId }, + select: { displayName: true, username: true, profilePhotoUrl: true }, + }); + const posterName = + author?.displayName ?? author?.username ?? "Someone you follow"; + + await this.paginate( + (cursor) => + this.prisma.profilePostSubscription.findMany({ + where: { targetUserId: authorId, subscriberId: { not: authorId } }, + select: { id: true, subscriberId: true }, + orderBy: { id: "asc" }, + take: SUBSCRIBER_BATCH_SIZE, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }), + (rows) => rows[rows.length - 1]?.id ?? null, + (row) => + this.notifyOnce(row.subscriberId, postId, { + posterName, + postKind: "SOCIAL", + actorType: "USER", + actorUsername: author?.username ?? null, + actorImageUrl: author?.profilePhotoUrl ?? null, + }), + ); + } + + /** + * Creates one durable new-post notification (SHOPPER audience — it lands in + * the follower's shopping notifications). Idempotent via a per-recipient + * dedupeKey so a job retry never duplicates a row. + */ + private async notifyOnce( + recipientUserId: string, + postId: string, + meta: { + posterName: string; + postKind: "PRODUCT" | "SOCIAL"; + actorType: "STORE" | "USER"; + actorUsername: string | null; + actorImageUrl: string | null; + }, + ): Promise { + try { + await this.notifications.createInApp( + recipientUserId, + NotificationType.STORE_NOTIFICATION_NEW_POST, + { + postId, + posterName: meta.posterName, + postKind: meta.postKind, + // Actor fields drive the notification avatar: the frontend renders + // `name` + `imageUrl` and switches to a squircle for `actorType` + // STORE. `name` doubles as the poster label for the social row. + name: meta.posterName, + username: meta.actorUsername, + imageUrl: meta.actorImageUrl, + actorType: meta.actorType, + } as Prisma.InputJsonObject, + "new-post:" + postId + ":" + recipientUserId, + NotificationAudience.SHOPPER, + ); + } catch (error) { + this.logger.warn( + `Post fan-out notification failed post=${postId} recipient=${recipientUserId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + private async paginate( + fetch: (cursor: string | null) => Promise, + nextCursor: (rows: T[]) => string | null, + handle: (row: T) => Promise, + ): Promise { + let cursor: string | null = null; + for (;;) { + const rows = await fetch(cursor); + if (rows.length === 0) { + break; + } + for (const row of rows) { + await handle(row); + } + if (rows.length < SUBSCRIBER_BATCH_SIZE) { + break; + } + cursor = nextCursor(rows); + if (!cursor) { + break; + } + } + } +} diff --git a/apps/backend/src/domains/social/post-subscription/post-fanout.service.ts b/apps/backend/src/domains/social/post-subscription/post-fanout.service.ts new file mode 100644 index 00000000..0d6370f9 --- /dev/null +++ b/apps/backend/src/domains/social/post-subscription/post-fanout.service.ts @@ -0,0 +1,47 @@ +import { InjectQueue } from "@nestjs/bullmq"; +import { Injectable, Logger } from "@nestjs/common"; +import { PostType } from "@prisma/client"; +import { Queue } from "bullmq"; + +import { QUEUE } from "../../../queue/queue.constants"; +import { POST_FANOUT_JOB_NAME, type PostFanoutJob } from "./post-fanout.types"; + +/** + * Thin enqueue facade so the post write path never fans out inline. A store + * with thousands of subscribers must not block publish; the processor does the + * subscriber selection + notification creation. + */ +@Injectable() +export class PostFanoutService { + private readonly logger = new Logger(PostFanoutService.name); + + constructor( + @InjectQueue(QUEUE.POST_FANOUT) + private readonly queue: Queue, + ) {} + + async enqueue(job: PostFanoutJob): Promise { + try { + await this.queue.add(POST_FANOUT_JOB_NAME, job, { + // Deterministic id → publishing the same post twice (e.g. a + // reactivated product post) never double-fans-out while the job lives. + jobId: "post-fanout:" + job.postId + ":" + job.postType, + attempts: 3, + backoff: { type: "exponential", delay: 5000 }, + removeOnComplete: true, + removeOnFail: 100, + }); + } catch (error) { + // Best-effort: a fan-out enqueue failure must never fail the post write. + this.logger.warn( + `Failed to enqueue post fan-out for ${job.postId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + isProductPost(postType: PostType): boolean { + return postType === PostType.PRODUCT_POST; + } +} diff --git a/apps/backend/src/domains/social/post-subscription/post-fanout.types.ts b/apps/backend/src/domains/social/post-subscription/post-fanout.types.ts new file mode 100644 index 00000000..5fa5c271 --- /dev/null +++ b/apps/backend/src/domains/social/post-subscription/post-fanout.types.ts @@ -0,0 +1,15 @@ +import { PostType } from "@prisma/client"; + +/** + * Enqueued when a post is published, so scope-matching subscribers can be + * notified off the request path. postType tells the fan-out whether this is a + * PRODUCT_POST (product bell) or a social post (social bell / profile bell). + */ +export interface PostFanoutJob { + postId: string; + postType: PostType; + authorId: string | null; + storeId: string | null; +} + +export const POST_FANOUT_JOB_NAME = "fanout-new-post"; diff --git a/apps/backend/src/domains/social/post-subscription/post-subscription.controller.ts b/apps/backend/src/domains/social/post-subscription/post-subscription.controller.ts new file mode 100644 index 00000000..45967188 --- /dev/null +++ b/apps/backend/src/domains/social/post-subscription/post-subscription.controller.ts @@ -0,0 +1,48 @@ +import { Body, Controller, Get, Param, Put, UseGuards } from "@nestjs/common"; + +import { CurrentUser } from "../../../common/decorators/current-user.decorator"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { AuthenticatedRequestUser } from "../../users/auth/auth.types"; +import { UpdateStoreSubscriptionDto } from "./dto/update-subscription.dto"; +import { UpdateProfileSubscriptionDto } from "./dto/update-profile-subscription.dto"; +import { PostSubscriptionService } from "./post-subscription.service"; + +@UseGuards(JwtAuthGuard) +@Controller("post-notifications") +export class PostSubscriptionController { + constructor(private readonly service: PostSubscriptionService) {} + + @Get("store/:storeId") + getStoreBell( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("storeId") storeId: string, + ) { + return this.service.getStoreBell(user.id, storeId); + } + + @Put("store/:storeId") + setStoreBell( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("storeId") storeId: string, + @Body() dto: UpdateStoreSubscriptionDto, + ) { + return this.service.setStoreBell(user.id, storeId, dto.setting); + } + + @Get("user/:userId") + getProfileBell( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("userId") userId: string, + ) { + return this.service.getProfileBell(user.id, userId); + } + + @Put("user/:userId") + setProfileBell( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("userId") userId: string, + @Body() dto: UpdateProfileSubscriptionDto, + ) { + return this.service.setProfileBell(user.id, userId, dto.enabled); + } +} diff --git a/apps/backend/src/domains/social/post-subscription/post-subscription.module.ts b/apps/backend/src/domains/social/post-subscription/post-subscription.module.ts new file mode 100644 index 00000000..f2cdd14e --- /dev/null +++ b/apps/backend/src/domains/social/post-subscription/post-subscription.module.ts @@ -0,0 +1,24 @@ +import { BullModule } from "@nestjs/bullmq"; +import { Module } from "@nestjs/common"; + +import { PrismaModule } from "../../../core/prisma/prisma.module"; +import { QueueModule } from "../../../queue/queue.module"; +import { QUEUE } from "../../../queue/queue.constants"; +import { NotificationModule } from "../notification/notification.module"; +import { PostFanoutProcessor } from "./post-fanout.processor"; +import { PostFanoutService } from "./post-fanout.service"; +import { PostSubscriptionController } from "./post-subscription.controller"; +import { PostSubscriptionService } from "./post-subscription.service"; + +@Module({ + imports: [ + PrismaModule, + QueueModule, + NotificationModule, + BullModule.registerQueue({ name: QUEUE.POST_FANOUT }), + ], + controllers: [PostSubscriptionController], + providers: [PostSubscriptionService, PostFanoutService, PostFanoutProcessor], + exports: [PostFanoutService], +}) +export class PostSubscriptionModule {} diff --git a/apps/backend/src/domains/social/post-subscription/post-subscription.service.spec.ts b/apps/backend/src/domains/social/post-subscription/post-subscription.service.spec.ts new file mode 100644 index 00000000..a7a74fbb --- /dev/null +++ b/apps/backend/src/domains/social/post-subscription/post-subscription.service.spec.ts @@ -0,0 +1,133 @@ +import { PostNotificationScope } from "@prisma/client"; + +import { PostSubscriptionService } from "./post-subscription.service"; + +function makeService(overrides?: { + storeExists?: boolean; + userExists?: boolean; + followingStore?: boolean; + followingUser?: boolean; + storeScope?: PostNotificationScope | null; + profileEnabled?: boolean; +}) { + const prisma = { + storeProfile: { + findUnique: jest + .fn() + .mockResolvedValue( + overrides?.storeExists === false ? null : { id: "store-1" }, + ), + }, + user: { + findFirst: jest + .fn() + .mockResolvedValue( + overrides?.userExists === false ? null : { id: "user-2" }, + ), + }, + follow: { + findUnique: jest + .fn() + .mockResolvedValue(overrides?.followingStore ? { id: "f1" } : null), + }, + followRelation: { + findFirst: jest + .fn() + .mockResolvedValue(overrides?.followingUser ? { id: "fr1" } : null), + }, + storeNotificationSubscription: { + findUnique: jest + .fn() + .mockResolvedValue( + overrides?.storeScope ? { scope: overrides.storeScope } : null, + ), + upsert: jest.fn().mockResolvedValue({}), + deleteMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + profilePostSubscription: { + findUnique: jest + .fn() + .mockResolvedValue(overrides?.profileEnabled ? { id: "p1" } : null), + upsert: jest.fn().mockResolvedValue({}), + deleteMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + }; + return { + prisma, + service: new PostSubscriptionService(prisma as never), + }; +} + +describe("PostSubscriptionService — store bell", () => { + it("reports OFF with follow state when there is no subscription", async () => { + const { service } = makeService({ followingStore: true, storeScope: null }); + expect(await service.getStoreBell("user-1", "store-1")).toEqual({ + following: true, + setting: "OFF", + }); + }); + + it("maps the stored scope to a setting", async () => { + const { service } = makeService({ + followingStore: true, + storeScope: PostNotificationScope.PRODUCT, + }); + expect(await service.getStoreBell("user-1", "store-1")).toEqual({ + following: true, + setting: "PRODUCT", + }); + }); + + it("requires a follow before turning the bell on", async () => { + const { service, prisma } = makeService({ followingStore: false }); + await expect( + service.setStoreBell("user-1", "store-1", "ALL"), + ).rejects.toMatchObject({ response: { code: "FOLLOW_REQUIRED" } }); + expect(prisma.storeNotificationSubscription.upsert).not.toHaveBeenCalled(); + }); + + it("upserts the chosen scope when following", async () => { + const { service, prisma } = makeService({ followingStore: true }); + const result = await service.setStoreBell("user-1", "store-1", "SOCIAL"); + expect(prisma.storeNotificationSubscription.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + scope: PostNotificationScope.SOCIAL, + }), + update: { scope: PostNotificationScope.SOCIAL }, + }), + ); + expect(result).toEqual({ following: true, setting: "SOCIAL" }); + }); + + it("OFF deletes the subscription without requiring a follow", async () => { + const { service, prisma } = makeService({ followingStore: false }); + const result = await service.setStoreBell("user-1", "store-1", "OFF"); + expect(prisma.storeNotificationSubscription.deleteMany).toHaveBeenCalled(); + expect(result.setting).toBe("OFF"); + }); +}); + +describe("PostSubscriptionService — profile bell", () => { + it("requires a follow before enabling", async () => { + const { service, prisma } = makeService({ followingUser: false }); + await expect( + service.setProfileBell("user-1", "user-2", true), + ).rejects.toMatchObject({ response: { code: "FOLLOW_REQUIRED" } }); + expect(prisma.profilePostSubscription.upsert).not.toHaveBeenCalled(); + }); + + it("enables when following", async () => { + const { service, prisma } = makeService({ followingUser: true }); + const result = await service.setProfileBell("user-1", "user-2", true); + expect(prisma.profilePostSubscription.upsert).toHaveBeenCalled(); + expect(result).toEqual({ following: true, enabled: true }); + }); + + it("disabling deletes without requiring a follow", async () => { + const { service, prisma } = makeService({ followingUser: false }); + const result = await service.setProfileBell("user-1", "user-2", false); + expect(prisma.profilePostSubscription.deleteMany).toHaveBeenCalled(); + expect(result.enabled).toBe(false); + }); +}); diff --git a/apps/backend/src/domains/social/post-subscription/post-subscription.service.ts b/apps/backend/src/domains/social/post-subscription/post-subscription.service.ts new file mode 100644 index 00000000..e948315c --- /dev/null +++ b/apps/backend/src/domains/social/post-subscription/post-subscription.service.ts @@ -0,0 +1,186 @@ +import { + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { PostNotificationScope } from "@prisma/client"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { type StoreBellSetting } from "./dto/update-subscription.dto"; + +export interface StoreBellState { + following: boolean; + setting: StoreBellSetting; +} + +export interface ProfileBellState { + following: boolean; + enabled: boolean; +} + +@Injectable() +export class PostSubscriptionService { + constructor(private readonly prisma: PrismaService) {} + + // ── Store bell ──────────────────────────────────────────────────────────── + + async getStoreBell(userId: string, storeId: string): Promise { + await this.assertStoreExists(storeId); + const [following, subscription] = await Promise.all([ + this.isFollowingStore(userId, storeId), + this.prisma.storeNotificationSubscription.findUnique({ + where: { userId_storeId: { userId, storeId } }, + select: { scope: true }, + }), + ]); + return { + following, + setting: subscription ? scopeToSetting(subscription.scope) : "OFF", + }; + } + + async setStoreBell( + userId: string, + storeId: string, + setting: StoreBellSetting, + ): Promise { + await this.assertStoreExists(storeId); + + if (setting === "OFF") { + await this.prisma.storeNotificationSubscription.deleteMany({ + where: { userId, storeId }, + }); + const following = await this.isFollowingStore(userId, storeId); + return { following, setting: "OFF" }; + } + + // Turning the bell on requires an existing follow. + const following = await this.isFollowingStore(userId, storeId); + if (!following) { + throw new ConflictException({ + message: "Follow the store before turning on post notifications", + code: "FOLLOW_REQUIRED", + }); + } + + const scope = settingToScope(setting); + await this.prisma.storeNotificationSubscription.upsert({ + where: { userId_storeId: { userId, storeId } }, + create: { userId, storeId, scope }, + update: { scope }, + }); + return { following: true, setting }; + } + + // ── Profile bell ────────────────────────────────────────────────────────── + + async getProfileBell( + subscriberId: string, + targetUserId: string, + ): Promise { + await this.assertUserExists(targetUserId); + const [following, subscription] = await Promise.all([ + this.isFollowingUser(subscriberId, targetUserId), + this.prisma.profilePostSubscription.findUnique({ + where: { subscriberId_targetUserId: { subscriberId, targetUserId } }, + select: { id: true }, + }), + ]); + return { following, enabled: Boolean(subscription) }; + } + + async setProfileBell( + subscriberId: string, + targetUserId: string, + enabled: boolean, + ): Promise { + await this.assertUserExists(targetUserId); + + if (!enabled) { + await this.prisma.profilePostSubscription.deleteMany({ + where: { subscriberId, targetUserId }, + }); + const following = await this.isFollowingUser(subscriberId, targetUserId); + return { following, enabled: false }; + } + + const following = await this.isFollowingUser(subscriberId, targetUserId); + if (!following) { + throw new ConflictException({ + message: "Follow the profile before turning on post notifications", + code: "FOLLOW_REQUIRED", + }); + } + + await this.prisma.profilePostSubscription.upsert({ + where: { subscriberId_targetUserId: { subscriberId, targetUserId } }, + create: { subscriberId, targetUserId }, + update: {}, + }); + return { following: true, enabled: true }; + } + + // ── Follow checks (stores use legacy Follow, users use FollowRelation) ────── + + private async isFollowingStore( + userId: string, + storeId: string, + ): Promise { + const follow = await this.prisma.follow.findUnique({ + where: { followerId_storeId: { followerId: userId, storeId } }, + select: { id: true }, + }); + return Boolean(follow); + } + + private async isFollowingUser( + followerId: string, + targetUserId: string, + ): Promise { + const relation = await this.prisma.followRelation.findFirst({ + where: { followerId, targetUserId }, + select: { id: true }, + }); + return Boolean(relation); + } + + private async assertStoreExists(storeId: string): Promise { + const store = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + select: { id: true }, + }); + if (!store) { + throw new NotFoundException({ + message: "Store not found", + code: "STORE_NOT_FOUND", + }); + } + } + + private async assertUserExists(userId: string): Promise { + const user = await this.prisma.user.findFirst({ + where: { id: userId, isActive: true, deletedAt: null }, + select: { id: true }, + }); + if (!user) { + throw new NotFoundException({ + message: "User not found", + code: "USER_NOT_FOUND", + }); + } + } +} + +function settingToScope( + setting: Exclude, +): PostNotificationScope { + if (setting === "SOCIAL") return PostNotificationScope.SOCIAL; + if (setting === "PRODUCT") return PostNotificationScope.PRODUCT; + return PostNotificationScope.ALL; +} + +function scopeToSetting(scope: PostNotificationScope): StoreBellSetting { + if (scope === PostNotificationScope.SOCIAL) return "SOCIAL"; + if (scope === PostNotificationScope.PRODUCT) return "PRODUCT"; + return "ALL"; +} diff --git a/apps/backend/src/domains/social/realtime/realtime.gateway.spec.ts b/apps/backend/src/domains/social/realtime/realtime.gateway.spec.ts new file mode 100644 index 00000000..aa665a70 --- /dev/null +++ b/apps/backend/src/domains/social/realtime/realtime.gateway.spec.ts @@ -0,0 +1,66 @@ +import { RealtimeGateway } from "./realtime.gateway"; +import { RealtimeService } from "./realtime.service"; +import { SocketAuthService } from "./socket-auth.service"; + +describe("RealtimeGateway", () => { + const makeGateway = () => { + const realtime = { + setServer: jest.fn(), + userRoom: jest.fn((userId: string) => `user:${userId}`), + }; + const socketAuth = { + authenticate: jest.fn(), + }; + const gateway = new RealtimeGateway( + realtime as unknown as RealtimeService, + socketAuth as unknown as SocketAuthService, + ); + + return { gateway, realtime, socketAuth }; + }; + + it("authenticates during socket.io middleware and attaches identity to socket.data", async () => { + const { gateway, socketAuth } = makeGateway(); + const use = jest.fn(); + const next = jest.fn(); + const socket = { id: "socket-1", data: {} }; + socketAuth.authenticate.mockResolvedValueOnce({ + userId: "user-1", + sessionId: "session-1", + }); + + gateway.afterInit({ use } as never); + await use.mock.calls[0][0](socket, next); + + expect(socket.data).toEqual({ + userId: "user-1", + sessionId: "session-1", + }); + expect(next).toHaveBeenCalledWith(); + }); + + it("joins only the authenticated user's room after connection", async () => { + const { gateway } = makeGateway(); + const join = jest.fn().mockResolvedValue(undefined); + + await gateway.handleConnection({ + id: "socket-1", + data: { userId: "user-1" }, + join, + handshake: { auth: { room: "user:user-2" } }, + } as never); + + expect(join).toHaveBeenCalledTimes(1); + expect(join).toHaveBeenCalledWith("user:user-1"); + }); + + it("does not expose client-controlled room join handlers", () => { + const prototypeMethods = Object.getOwnPropertyNames( + RealtimeGateway.prototype, + ); + + expect(prototypeMethods).not.toEqual( + expect.arrayContaining(["joinRoom", "subscribe", "joinStore"]), + ); + }); +}); diff --git a/apps/backend/src/domains/social/realtime/realtime.gateway.ts b/apps/backend/src/domains/social/realtime/realtime.gateway.ts new file mode 100644 index 00000000..e0c64c61 --- /dev/null +++ b/apps/backend/src/domains/social/realtime/realtime.gateway.ts @@ -0,0 +1,87 @@ +import { Logger } from "@nestjs/common"; +import { + OnGatewayConnection, + OnGatewayDisconnect, + OnGatewayInit, + WebSocketGateway, +} from "@nestjs/websockets"; +import type { Server, Socket } from "socket.io"; + +import { RealtimeService } from "./realtime.service"; +import { SocketAuthError, SocketAuthService } from "./socket-auth.service"; + +interface AuthenticatedSocketData { + userId?: string; + sessionId?: string; +} + +type RealtimeSocket = Socket & { data: AuthenticatedSocketData }; + +function getCorsOrigins(): string[] { + return (process.env.CORS_ORIGINS || "http://localhost:3000") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); +} + +function getSocketIoPath(): string { + return process.env.SOCKET_IO_PATH || "/socket.io"; +} + +@WebSocketGateway({ + path: getSocketIoPath(), + cors: { origin: getCorsOrigins(), credentials: true }, +}) +export class RealtimeGateway + implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect +{ + private readonly logger = new Logger(RealtimeGateway.name); + + constructor( + private readonly realtimeService: RealtimeService, + private readonly socketAuth: SocketAuthService, + ) {} + + afterInit(server: Server): void { + server.use(async (socket: RealtimeSocket, next) => { + try { + const identity = await this.socketAuth.authenticate(socket); + socket.data.userId = identity.userId; + socket.data.sessionId = identity.sessionId; + next(); + } catch (error) { + const code = + error instanceof SocketAuthError ? error.code : "SOCKET_AUTH_INVALID"; + this.logger.warn( + `Socket authentication rejected socketId=${socket.id} code=${code}`, + ); + next(new Error(code)); + } + }); + + this.realtimeService.setServer(server); + this.logger.log( + `Realtime gateway initialized path=${getSocketIoPath()} corsOrigins=${getCorsOrigins().join(",")}`, + ); + } + + async handleConnection(client: RealtimeSocket): Promise { + const userId = client.data.userId; + if (!userId) { + client.disconnect(true); + return; + } + + const room = this.realtimeService.userRoom(userId); + await client.join(room); + this.logger.debug?.( + `Realtime connected socketId=${client.id} userId=${userId}`, + ); + } + + handleDisconnect(client: RealtimeSocket): void { + this.logger.debug?.( + `Realtime disconnected socketId=${client.id} userId=${client.data.userId ?? "unknown"}`, + ); + } +} diff --git a/apps/backend/src/domains/social/realtime/realtime.module.ts b/apps/backend/src/domains/social/realtime/realtime.module.ts new file mode 100644 index 00000000..3ebf85d8 --- /dev/null +++ b/apps/backend/src/domains/social/realtime/realtime.module.ts @@ -0,0 +1,28 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { JwtModule } from "@nestjs/jwt"; + +import { AccessTokenUserService } from "../../../common/security/access-token-user.service"; +import { RealtimeGateway } from "./realtime.gateway"; +import { RealtimeService } from "./realtime.service"; +import { SocketAuthService } from "./socket-auth.service"; + +@Module({ + imports: [ + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + secret: configService.getOrThrow("jwt.accessSecret"), + }), + inject: [ConfigService], + }), + ], + providers: [ + AccessTokenUserService, + SocketAuthService, + RealtimeGateway, + RealtimeService, + ], + exports: [RealtimeService], +}) +export class RealtimeModule {} diff --git a/apps/backend/src/domains/social/realtime/realtime.service.spec.ts b/apps/backend/src/domains/social/realtime/realtime.service.spec.ts new file mode 100644 index 00000000..31f2dd94 --- /dev/null +++ b/apps/backend/src/domains/social/realtime/realtime.service.spec.ts @@ -0,0 +1,45 @@ +import { REALTIME_EVENT, type NotificationCreatedEvent } from "@twizrr/shared"; + +import { RealtimeService } from "./realtime.service"; + +describe("RealtimeService", () => { + const notificationEvent: NotificationCreatedEvent = { + id: "event-1", + type: REALTIME_EVENT.NOTIFICATION_NEW, + version: 1, + occurredAt: "2026-07-12T10:00:00.000Z", + data: { + notificationId: "notification-1", + type: "ORDER_PAID", + createdAt: "2026-07-12T10:00:00.000Z", + }, + }; + + it("emits typed events only to the authenticated user's room", () => { + const emit = jest.fn(); + const to = jest.fn().mockReturnValue({ emit }); + const service = new RealtimeService(); + + service.setServer({ to } as never); + service.emitToUser("user-1", notificationEvent); + + expect(to).toHaveBeenCalledWith("user:user-1"); + expect(emit).toHaveBeenCalledWith( + REALTIME_EVENT.NOTIFICATION_NEW, + notificationEvent, + ); + }); + + it("does not throw when socket emission fails", () => { + const service = new RealtimeService(); + service.setServer({ + to: jest.fn().mockReturnValue({ + emit: jest.fn(() => { + throw new Error("adapter unavailable"); + }), + }), + } as never); + + expect(() => service.emitToUser("user-1", notificationEvent)).not.toThrow(); + }); +}); diff --git a/apps/backend/src/domains/social/realtime/realtime.service.ts b/apps/backend/src/domains/social/realtime/realtime.service.ts new file mode 100644 index 00000000..5ae512e0 --- /dev/null +++ b/apps/backend/src/domains/social/realtime/realtime.service.ts @@ -0,0 +1,45 @@ +import { Injectable, Logger } from "@nestjs/common"; +import type { RealtimeEventEnvelope } from "@twizrr/shared"; +import type { Server } from "socket.io"; + +@Injectable() +export class RealtimeService { + private readonly logger = new Logger(RealtimeService.name); + private server: Server | null = null; + + setServer(server: Server): void { + this.server = server; + } + + userRoom(userId: string): string { + return `user:${userId}`; + } + + emitToUser( + userId: string, + event: RealtimeEventEnvelope, + ): void; + emitToUser(userId: string, event: string, payload: unknown): void; + emitToUser( + userId: string, + eventOrName: RealtimeEventEnvelope | string, + payload?: unknown, + ): void { + if (!this.server) return; + + const eventName = + typeof eventOrName === "string" ? eventOrName : eventOrName.type; + const eventPayload = + typeof eventOrName === "string" ? payload : eventOrName; + const eventId = typeof eventOrName === "string" ? "legacy" : eventOrName.id; + + try { + this.server.to(this.userRoom(userId)).emit(eventName, eventPayload); + } catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + this.logger.warn( + `Realtime emit failed userId=${userId} eventType=${eventName} eventId=${eventId} reason=${message}`, + ); + } + } +} diff --git a/apps/backend/src/domains/social/realtime/redis-io.adapter.spec.ts b/apps/backend/src/domains/social/realtime/redis-io.adapter.spec.ts new file mode 100644 index 00000000..8da9935f --- /dev/null +++ b/apps/backend/src/domains/social/realtime/redis-io.adapter.spec.ts @@ -0,0 +1,91 @@ +import { ConfigService } from "@nestjs/config"; + +import { RedisIoAdapter } from "./redis-io.adapter"; + +const mockCreateAdapter = jest.fn((..._args: unknown[]) => "redis-adapter"); +const mockRedisInstances: Array<{ + url: string; + duplicate: jest.Mock; + connect: jest.Mock; + quit: jest.Mock; +}> = []; + +jest.mock("@socket.io/redis-adapter", () => ({ + createAdapter: (...args: unknown[]) => mockCreateAdapter(...args), +})); + +jest.mock("ioredis", () => { + class RedisMock { + readonly duplicate = jest.fn(() => new RedisMock(this.url)); + readonly connect = jest.fn().mockResolvedValue("OK"); + readonly quit = jest.fn().mockResolvedValue("OK"); + + constructor(readonly url: string) { + mockRedisInstances.push(this); + } + } + + return { + __esModule: true, + default: RedisMock, + }; +}); + +describe("RedisIoAdapter", () => { + beforeEach(() => { + mockCreateAdapter.mockClear(); + mockRedisInstances.length = 0; + }); + + const config = (enabled: boolean, redisUrl = "redis://localhost:6379") => + ({ + get: jest.fn((key: string) => { + if (key === "realtime.redisAdapterEnabled") return enabled; + if (key === "redis.url") return redisUrl; + return undefined; + }), + }) as unknown as ConfigService; + + it("uses in-memory mode when the Redis adapter is disabled", async () => { + const adapter = new RedisIoAdapter({} as never, config(false)); + + await adapter.connectToRedis(); + + expect(mockRedisInstances).toHaveLength(0); + expect(mockCreateAdapter).not.toHaveBeenCalled(); + }); + + it("initialises separate Redis publisher and subscriber connections when enabled", async () => { + const adapter = new RedisIoAdapter({} as never, config(true)); + + await adapter.connectToRedis(); + + expect(mockRedisInstances).toHaveLength(2); + expect(mockRedisInstances[0]).not.toBe(mockRedisInstances[1]); + expect(mockRedisInstances[0].connect).toHaveBeenCalledTimes(1); + expect(mockRedisInstances[1].connect).toHaveBeenCalledTimes(1); + expect(mockCreateAdapter).toHaveBeenCalledWith( + mockRedisInstances[0], + mockRedisInstances[1], + expect.objectContaining({ key: expect.any(String) }), + ); + }); + + it("does not silently fall back when enabled without REDIS_URL", async () => { + const adapter = new RedisIoAdapter({} as never, config(true, "")); + + await expect(adapter.connectToRedis()).rejects.toThrow( + "REDIS_URL is required", + ); + }); + + it("closes publisher and subscriber connections on shutdown", async () => { + const adapter = new RedisIoAdapter({} as never, config(true)); + await adapter.connectToRedis(); + + await adapter.close(); + + expect(mockRedisInstances[0].quit).toHaveBeenCalledTimes(1); + expect(mockRedisInstances[1].quit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/backend/src/domains/social/realtime/redis-io.adapter.ts b/apps/backend/src/domains/social/realtime/redis-io.adapter.ts new file mode 100644 index 00000000..70f19877 --- /dev/null +++ b/apps/backend/src/domains/social/realtime/redis-io.adapter.ts @@ -0,0 +1,79 @@ +import { Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { IoAdapter } from "@nestjs/platform-socket.io"; +import { createAdapter } from "@socket.io/redis-adapter"; +import Redis from "ioredis"; +import type { ServerOptions } from "socket.io"; +import type { INestApplicationContext } from "@nestjs/common"; + +const SOCKET_IO_REDIS_ADAPTER_KEY = "twizrr:socket.io"; + +export class RedisIoAdapter extends IoAdapter { + private readonly logger = new Logger(RedisIoAdapter.name); + private readonly enabled: boolean; + private adapterConstructor: ReturnType | null = null; + private pubClient: Redis | null = null; + private subClient: Redis | null = null; + + constructor( + app: INestApplicationContext, + private readonly configService: ConfigService, + ) { + super(app); + this.enabled = + configService.get("realtime.redisAdapterEnabled") ?? false; + } + + async connectToRedis(): Promise { + if (!this.enabled) { + this.logger.log("Socket.IO adapter mode=in-memory scope=single-instance"); + return; + } + + const redisUrl = this.configService.get("redis.url"); + if (!redisUrl) { + this.logger.error( + "Socket.IO Redis adapter startup failed reason=missing-redis-url", + ); + throw new Error( + "REDIS_URL is required when SOCKET_IO_REDIS_ADAPTER_ENABLED=true", + ); + } + + try { + this.pubClient = new Redis(redisUrl, { + lazyConnect: true, + maxRetriesPerRequest: null, + }); + this.subClient = this.pubClient.duplicate(); + await Promise.all([this.pubClient.connect(), this.subClient.connect()]); + this.adapterConstructor = createAdapter(this.pubClient, this.subClient, { + key: SOCKET_IO_REDIS_ADAPTER_KEY, + }); + this.logger.log("Socket.IO adapter mode=redis status=ready"); + } catch (error) { + const message = error instanceof Error ? error.message : "unknown"; + this.logger.error( + `Socket.IO Redis adapter startup failed reason=${message}`, + ); + await this.close(); + throw error; + } + } + + createIOServer(port: number, options?: ServerOptions): unknown { + const server = super.createIOServer(port, options); + if (this.adapterConstructor) { + server.adapter(this.adapterConstructor); + } + return server; + } + + async close(): Promise { + await Promise.allSettled([this.pubClient?.quit(), this.subClient?.quit()]); + this.pubClient = null; + this.subClient = null; + this.adapterConstructor = null; + this.logger.log("Socket.IO Redis adapter connections closed"); + } +} diff --git a/apps/backend/src/domains/social/realtime/socket-auth.service.spec.ts b/apps/backend/src/domains/social/realtime/socket-auth.service.spec.ts new file mode 100644 index 00000000..35da89b8 --- /dev/null +++ b/apps/backend/src/domains/social/realtime/socket-auth.service.spec.ts @@ -0,0 +1,111 @@ +import { JwtService } from "@nestjs/jwt"; + +import { AccessTokenUserService } from "../../../common/security/access-token-user.service"; +import { SocketAuthError, SocketAuthService } from "./socket-auth.service"; + +describe("SocketAuthService", () => { + const makeService = () => { + const jwtService = { + verifyAsync: jest.fn(), + }; + const accessTokenUsers = { + findActiveUser: jest.fn(), + }; + const service = new SocketAuthService( + jwtService as unknown as JwtService, + accessTokenUsers as unknown as AccessTokenUserService, + ); + + return { service, jwtService, accessTokenUsers }; + }; + + const socketWithCookie = (cookie?: string) => + ({ + handshake: { + headers: { + cookie, + }, + }, + }) as never; + + it("rejects a missing access-token cookie", async () => { + const { service } = makeService(); + + await expect( + service.authenticate(socketWithCookie()), + ).rejects.toMatchObject({ + code: "SOCKET_AUTH_REQUIRED", + }); + }); + + it("rejects an invalid JWT without exposing the token", async () => { + const { service, jwtService } = makeService(); + jwtService.verifyAsync.mockRejectedValueOnce(new Error("bad token")); + + await expect( + service.authenticate(socketWithCookie("twizrr_access_token=raw.jwt")), + ).rejects.toMatchObject({ + code: "SOCKET_AUTH_INVALID", + }); + }); + + it("rejects an expired JWT with an explicit safe code", async () => { + const { service, jwtService } = makeService(); + const expiredError = new Error("expired"); + expiredError.name = "TokenExpiredError"; + jwtService.verifyAsync.mockRejectedValueOnce(expiredError); + + await expect( + service.authenticate(socketWithCookie("twizrr_access_token=raw.jwt")), + ).rejects.toMatchObject({ + code: "SOCKET_AUTH_EXPIRED", + }); + }); + + it("rejects a token whose user no longer exists", async () => { + const { service, jwtService, accessTokenUsers } = makeService(); + jwtService.verifyAsync.mockResolvedValueOnce({ + sub: "missing-user", + email: "user@example.com", + role: "USER", + jti: "jti-1", + }); + accessTokenUsers.findActiveUser.mockResolvedValueOnce(null); + + await expect( + service.authenticate(socketWithCookie("twizrr_access_token=raw.jwt")), + ).rejects.toMatchObject({ + code: "SOCKET_USER_INACTIVE", + }); + }); + + it("returns a minimal socket identity for an active user", async () => { + const { service, jwtService, accessTokenUsers } = makeService(); + jwtService.verifyAsync.mockResolvedValueOnce({ + sub: "user-1", + email: "user@example.com", + role: "USER", + jti: "session-1", + }); + accessTokenUsers.findActiveUser.mockResolvedValueOnce({ + id: "user-1", + sub: "user-1", + email: "user@example.com", + role: "USER", + }); + + await expect( + service.authenticate(socketWithCookie("twizrr_access_token=raw.jwt")), + ).resolves.toEqual({ + userId: "user-1", + sessionId: "session-1", + }); + }); + + it("uses typed socket authentication errors", () => { + const error = new SocketAuthError("SOCKET_AUTH_INVALID"); + + expect(error.message).toBe("SOCKET_AUTH_INVALID"); + expect(error.code).toBe("SOCKET_AUTH_INVALID"); + }); +}); diff --git a/apps/backend/src/domains/social/realtime/socket-auth.service.ts b/apps/backend/src/domains/social/realtime/socket-auth.service.ts new file mode 100644 index 00000000..5a8870e5 --- /dev/null +++ b/apps/backend/src/domains/social/realtime/socket-auth.service.ts @@ -0,0 +1,99 @@ +import { Injectable } from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import type { Socket } from "socket.io"; + +import { AccessTokenUserService } from "../../../common/security/access-token-user.service"; +import { AUTH_COOKIE } from "../../users/auth/auth.constants"; +import type { AccessTokenPayload } from "../../users/auth/auth.types"; + +export type SocketAuthErrorCode = + | "SOCKET_AUTH_REQUIRED" + | "SOCKET_AUTH_INVALID" + | "SOCKET_AUTH_EXPIRED" + | "SOCKET_USER_INACTIVE"; + +export interface SocketIdentity { + userId: string; + sessionId?: string; +} + +export class SocketAuthError extends Error { + constructor(readonly code: SocketAuthErrorCode) { + super(code); + } +} + +@Injectable() +export class SocketAuthService { + constructor( + private readonly jwtService: JwtService, + private readonly accessTokenUsers: AccessTokenUserService, + ) {} + + async authenticate(socket: Socket): Promise { + const token = this.readAccessTokenCookie(socket.handshake.headers.cookie); + if (!token) { + throw new SocketAuthError("SOCKET_AUTH_REQUIRED"); + } + + let payload: AccessTokenPayload; + try { + payload = await this.jwtService.verifyAsync(token); + } catch (error) { + if (this.isTokenExpiredError(error)) { + throw new SocketAuthError("SOCKET_AUTH_EXPIRED"); + } + throw new SocketAuthError("SOCKET_AUTH_INVALID"); + } + + if (!this.isAccessTokenPayload(payload)) { + throw new SocketAuthError("SOCKET_AUTH_INVALID"); + } + + const user = await this.accessTokenUsers.findActiveUser(payload); + if (!user) { + throw new SocketAuthError("SOCKET_USER_INACTIVE"); + } + + return { + userId: user.id, + sessionId: payload.jti, + }; + } + + private isTokenExpiredError(error: unknown): boolean { + return error instanceof Error && error.name === "TokenExpiredError"; + } + + private readAccessTokenCookie( + cookieHeader: string | string[] | undefined, + ): string | null { + const header = Array.isArray(cookieHeader) + ? cookieHeader.join(";") + : (cookieHeader ?? ""); + + for (const part of header.split(";")) { + const [name, ...rest] = part.trim().split("="); + if (name === AUTH_COOKIE.ACCESS) { + const value = rest.join("="); + return value ? decodeURIComponent(value) : null; + } + } + + return null; + } + + private isAccessTokenPayload(value: unknown): value is AccessTokenPayload { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + + const payload = value as Record; + return ( + typeof payload.sub === "string" && + payload.sub.trim().length > 0 && + typeof payload.jti === "string" && + payload.jti.trim().length > 0 + ); + } +} diff --git a/apps/backend/src/domains/users-trust.module.ts b/apps/backend/src/domains/users-trust.module.ts new file mode 100644 index 00000000..cdc75d6e --- /dev/null +++ b/apps/backend/src/domains/users-trust.module.ts @@ -0,0 +1,18 @@ +import { Module } from "@nestjs/common"; + +import { MerchantModule } from "./users/store/merchant/merchant.module"; +import { VerificationModule } from "./users/verification/verification.module"; +import { UsersAuthModule } from "./users/auth/auth.module"; +import { StoreModule } from "./users/store/store.module"; +import { UserModule } from "./users/user/user.module"; + +@Module({ + imports: [ + UsersAuthModule, + UserModule, + StoreModule, + MerchantModule, + VerificationModule, + ], +}) +export class UsersTrustDomainModule {} diff --git a/apps/backend/src/domains/users/auth/account-abuse-linkage.service.spec.ts b/apps/backend/src/domains/users/auth/account-abuse-linkage.service.spec.ts new file mode 100644 index 00000000..6dbd9b33 --- /dev/null +++ b/apps/backend/src/domains/users/auth/account-abuse-linkage.service.spec.ts @@ -0,0 +1,182 @@ +import { AccountAbuseLinkageService } from "./account-abuse-linkage.service"; +import type { RequestSecurityContext } from "../../../common/security/request-security-context"; + +describe("AccountAbuseLinkageService", () => { + const prisma = { + userDevice: { findMany: jest.fn() }, + domainEvent: { + findMany: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + }, + }; + const context: RequestSecurityContext = { + requestId: "req-linkage-1", + ipAddress: "203.0.113.10", + ipSource: "remote-address", + userAgent: "Mozilla/5.0", + origin: "https://twizrr.test", + referer: "https://twizrr.test/register", + method: "POST", + path: "/auth/register/email", + deviceType: "desktop", + geo: { + countryCode: "NG", + countryName: null, + region: null, + city: null, + source: "trusted_header", + confidence: "low", + }, + deviceIdentity: { + deviceIdHash: "server-side-device-hash", + source: "header", + }, + isTrustedProxyHeaderUsed: false, + createdAt: new Date(), + }; + const deviceIdentity = { + deviceHashPresent: true, + deviceRecordId: "device-record-current", + deviceType: "desktop", + lastSeenAt: new Date().toISOString(), + }; + + let service: AccountAbuseLinkageService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new AccountAbuseLinkageService(prisma as never); + prisma.userDevice.findMany.mockResolvedValue([]); + prisma.domainEvent.findMany.mockResolvedValue([]); + prisma.domainEvent.findFirst.mockResolvedValue(null); + prisma.domainEvent.create.mockResolvedValue({}); + }); + + it("does not signal the first account associated with a device", async () => { + prisma.userDevice.findMany.mockResolvedValue([ + device("device-record-current", "user-1"), + ]); + + await service.recordAfterAccountCreation({ + userId: "user-1", + securityContext: context, + deviceIdentity, + }); + + expect(prisma.domainEvent.create).not.toHaveBeenCalled(); + }); + + it("records multi-account and repeated-creation signals for a shared device", async () => { + prisma.userDevice.findMany.mockResolvedValue([ + device("device-record-current", "user-1"), + device("device-record-2", "user-2"), + device("device-record-3", "user-3"), + ]); + + await service.recordAfterAccountCreation({ + userId: "user-1", + securityContext: context, + deviceIdentity, + }); + + const codes = prisma.domainEvent.create.mock.calls.map( + ([input]) => input.data.metadata.signalCode, + ); + expect(codes).toEqual( + expect.arrayContaining([ + "MULTIPLE_ACCOUNTS_SAME_DEVICE", + "REPEATED_ACCOUNT_CREATION_SAME_DEVICE", + ]), + ); + const metadata = prisma.domainEvent.create.mock.calls[0][0].data.metadata; + expect(metadata).not.toHaveProperty("deviceIdHash"); + expect(JSON.stringify(metadata)).not.toContain("server-side-device-hash"); + }); + + it("records a high-severity suspended-device login signal without affecting login", async () => { + prisma.userDevice.findMany.mockResolvedValue([ + device("device-record-current", "user-1"), + device("device-record-suspended", "suspended-user", false), + ]); + + await expect( + service.recordAfterSuccessfulLogin({ + userId: "user-1", + securityContext: context, + deviceIdentity, + }), + ).resolves.toBeUndefined(); + + expect(prisma.domainEvent.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + source: "auth.account-abuse-linkage", + metadata: expect.objectContaining({ + signalCode: "LOGIN_FROM_SUSPENDED_USER_DEVICE", + severity: "high", + }), + }), + }); + }); + + it("records repeated account creation from one IP using recent account-created events", async () => { + prisma.userDevice.findMany.mockResolvedValue([]); + prisma.domainEvent.findMany.mockResolvedValue( + Array.from({ length: 5 }, (_, index) => ({ + id: `event-${index}`, + actorId: `user-${index}`, + createdAt: new Date(), + })), + ); + + await service.recordAfterAccountCreation({ + userId: "user-5", + securityContext: { + ...context, + deviceIdentity: { deviceIdHash: null, source: "missing" }, + }, + }); + + expect(prisma.domainEvent.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + metadata: expect.objectContaining({ + signalCode: "REPEATED_ACCOUNT_CREATION_SAME_IP", + recentAccountCreationCount: 5, + }), + }), + }); + }); + + it("dedupes an equivalent signal inside the 24-hour review window", async () => { + prisma.userDevice.findMany.mockResolvedValue([ + device("device-record-current", "user-1"), + device("device-record-2", "user-2"), + device("device-record-3", "user-3"), + ]); + prisma.domainEvent.findFirst.mockResolvedValue({ id: "existing-signal" }); + + await service.recordAfterAccountCreation({ + userId: "user-1", + securityContext: context, + deviceIdentity, + }); + + expect(prisma.domainEvent.create).not.toHaveBeenCalled(); + }); + + it("ignores missing device identity safely", async () => { + await expect( + service.recordAfterSuccessfulLogin({ userId: "user-1" }), + ).resolves.toBeUndefined(); + expect(prisma.userDevice.findMany).not.toHaveBeenCalled(); + }); + + function device(id: string, userId: string, isActive = true) { + return { + id, + userId, + createdAt: new Date(), + user: { id: userId, isActive, deletedAt: null }, + }; + } +}); diff --git a/apps/backend/src/domains/users/auth/account-abuse-linkage.service.ts b/apps/backend/src/domains/users/auth/account-abuse-linkage.service.ts new file mode 100644 index 00000000..0cd22482 --- /dev/null +++ b/apps/backend/src/domains/users/auth/account-abuse-linkage.service.ts @@ -0,0 +1,315 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; + +import type { RequestSecurityContext } from "../../../common/security/request-security-context"; +import type { DeviceIdentityAuditMetadata } from "../../../common/security/device-identity.service"; +import { PrismaService } from "../../../core/prisma/prisma.service"; + +export type AccountAbuseLinkageSignalCode = + | "MULTIPLE_ACCOUNTS_SAME_DEVICE" + | "ACCOUNT_CREATED_FROM_SUSPENDED_USER_DEVICE" + | "REPEATED_ACCOUNT_CREATION_SAME_DEVICE" + | "REPEATED_ACCOUNT_CREATION_SAME_IP" + | "LOGIN_FROM_SUSPENDED_USER_DEVICE" + | "POSSIBLE_BAN_EVASION_REVIEW"; + +type SignalSeverity = "low" | "medium" | "high"; + +type LinkageEvidence = { + type: "same_device" | "same_ip" | "suspended_user_device" | "recent_creation"; + userId?: string; + eventId?: string; + createdAt?: string; +}; + +const WINDOW_HOURS = 24; +const WINDOW_MS = WINDOW_HOURS * 60 * 60 * 1000; +const MULTIPLE_ACCOUNTS_THRESHOLD = 3; +const REPEATED_DEVICE_CREATION_THRESHOLD = 3; +const REPEATED_IP_CREATION_THRESHOLD = 5; +const EVIDENCE_LIMIT = 10; + +/** + * Records bounded, review-only correlations between first-party device records + * and recent auth events. It deliberately performs no enforcement. + */ +@Injectable() +export class AccountAbuseLinkageService { + private readonly logger = new Logger(AccountAbuseLinkageService.name); + + constructor(private readonly prisma: PrismaService) {} + + async recordAfterAccountCreation(input: { + userId: string; + securityContext?: RequestSecurityContext; + deviceIdentity?: DeviceIdentityAuditMetadata; + }): Promise { + try { + await this.recordDeviceSignals({ + ...input, + suspendedSignalCode: "ACCOUNT_CREATED_FROM_SUSPENDED_USER_DEVICE", + includeRecentCreationSignals: true, + }); + await this.recordRecentIpCreationSignal(input); + } catch (error) { + this.logFailure( + "account-creation", + input.securityContext?.requestId, + error, + ); + } + } + + async recordAfterSuccessfulLogin(input: { + userId: string; + securityContext?: RequestSecurityContext; + deviceIdentity?: DeviceIdentityAuditMetadata; + }): Promise { + try { + await this.recordDeviceSignals({ + ...input, + suspendedSignalCode: "LOGIN_FROM_SUSPENDED_USER_DEVICE", + includeRecentCreationSignals: false, + }); + } catch (error) { + this.logFailure( + "successful-login", + input.securityContext?.requestId, + error, + ); + } + } + + private async recordDeviceSignals(input: { + userId: string; + securityContext?: RequestSecurityContext; + deviceIdentity?: DeviceIdentityAuditMetadata; + suspendedSignalCode: + | "ACCOUNT_CREATED_FROM_SUSPENDED_USER_DEVICE" + | "LOGIN_FROM_SUSPENDED_USER_DEVICE"; + includeRecentCreationSignals: boolean; + }): Promise { + const deviceHash = input.securityContext?.deviceIdentity?.deviceIdHash; + if (!deviceHash) return; + + const since = new Date(Date.now() - WINDOW_MS); + const linkedDevices = await this.prisma.userDevice.findMany({ + where: { deviceIdHash: deviceHash }, + select: { + id: true, + userId: true, + createdAt: true, + user: { + select: { id: true, isActive: true, deletedAt: true }, + }, + }, + orderBy: { createdAt: "desc" }, + take: EVIDENCE_LIMIT, + }); + const otherLinkedDevices = linkedDevices.filter( + (device) => device.userId && device.userId !== input.userId, + ); + const linkedUserIds = new Set( + linkedDevices + .map((device) => device.userId) + .filter((userId): userId is string => Boolean(userId)), + ); + const deviceRecordId = input.deviceIdentity?.deviceRecordId ?? null; + const evidence = this.deviceEvidence(otherLinkedDevices); + + if (linkedUserIds.size >= MULTIPLE_ACCOUNTS_THRESHOLD) { + await this.recordSignal({ + userId: input.userId, + signalCode: "MULTIPLE_ACCOUNTS_SAME_DEVICE", + severity: "low", + securityContext: input.securityContext, + deviceRecordId, + linkedUserCount: linkedUserIds.size, + evidence, + }); + } + + if (input.includeRecentCreationSignals) { + const recentCreationCount = linkedDevices.filter( + (device) => device.createdAt >= since, + ).length; + if (recentCreationCount >= REPEATED_DEVICE_CREATION_THRESHOLD) { + await this.recordSignal({ + userId: input.userId, + signalCode: "REPEATED_ACCOUNT_CREATION_SAME_DEVICE", + severity: "medium", + securityContext: input.securityContext, + deviceRecordId, + recentAccountCreationCount: recentCreationCount, + evidence, + }); + } + } + + const suspendedDevices = otherLinkedDevices.filter( + (device) => + device.user && + (!device.user.isActive || device.user.deletedAt !== null), + ); + if (suspendedDevices.length > 0) { + const suspendedEvidence = this.deviceEvidence( + suspendedDevices, + "suspended_user_device", + ); + const common = { + userId: input.userId, + severity: "high" as const, + securityContext: input.securityContext, + deviceRecordId, + linkedUserCount: linkedUserIds.size, + suspendedLinkedUserCount: suspendedDevices.length, + evidence: suspendedEvidence, + }; + await this.recordSignal({ + ...common, + signalCode: input.suspendedSignalCode, + }); + await this.recordSignal({ + ...common, + signalCode: "POSSIBLE_BAN_EVASION_REVIEW", + }); + } + } + + private async recordRecentIpCreationSignal(input: { + userId: string; + securityContext?: RequestSecurityContext; + }): Promise { + const ipAddress = input.securityContext?.ipAddress; + if (!ipAddress) return; + + const since = new Date(Date.now() - WINDOW_MS); + const events = await this.prisma.domainEvent.findMany({ + where: { + aggregateType: "AUTH", + eventType: "AUTH_ACCOUNT_CREATED", + createdAt: { gte: since }, + metadata: { + path: ["requestContext", "ipAddress"], + equals: ipAddress, + }, + }, + select: { id: true, actorId: true, createdAt: true }, + orderBy: { createdAt: "desc" }, + take: EVIDENCE_LIMIT, + }); + if (events.length < REPEATED_IP_CREATION_THRESHOLD) return; + + await this.recordSignal({ + userId: input.userId, + signalCode: "REPEATED_ACCOUNT_CREATION_SAME_IP", + severity: "medium", + securityContext: input.securityContext, + recentAccountCreationCount: events.length, + evidence: events.map((event) => ({ + type: "same_ip", + userId: event.actorId ?? undefined, + eventId: event.id, + createdAt: event.createdAt.toISOString(), + })), + }); + } + + private async recordSignal(input: { + userId: string; + signalCode: AccountAbuseLinkageSignalCode; + severity: SignalSeverity; + securityContext?: RequestSecurityContext; + deviceRecordId?: string | null; + linkedUserCount?: number; + recentAccountCreationCount?: number; + suspendedLinkedUserCount?: number; + evidence: LinkageEvidence[]; + }): Promise { + if (await this.hasRecentDuplicate(input)) return; + + const metadata: Prisma.InputJsonValue = { + signalCode: input.signalCode, + severity: input.severity, + subjectUserId: input.userId, + requestId: input.securityContext?.requestId ?? null, + deviceRecordId: input.deviceRecordId ?? null, + linkedUserCount: input.linkedUserCount ?? null, + recentAccountCreationCount: input.recentAccountCreationCount ?? null, + suspendedLinkedUserCount: input.suspendedLinkedUserCount ?? null, + ipAddress: input.securityContext?.ipAddress ?? null, + ipSource: input.securityContext?.ipSource ?? null, + geoCountryCode: input.securityContext?.geo?.countryCode ?? null, + deviceType: input.securityContext?.deviceType ?? null, + windowHours: WINDOW_HOURS, + evidence: input.evidence, + }; + await this.prisma.domainEvent.create({ + data: { + aggregateType: "AUTH", + aggregateId: input.userId, + eventType: "AUTH_RISK_SIGNAL_RECORDED", + actorType: "USER", + actorId: input.userId, + source: "auth.account-abuse-linkage", + correlationId: input.securityContext?.requestId ?? null, + metadata, + }, + }); + } + + private async hasRecentDuplicate(input: { + userId: string; + signalCode: AccountAbuseLinkageSignalCode; + deviceRecordId?: string | null; + }): Promise { + const filters: Prisma.DomainEventWhereInput[] = [ + { + aggregateType: "AUTH", + aggregateId: input.userId, + eventType: "AUTH_RISK_SIGNAL_RECORDED", + source: "auth.account-abuse-linkage", + createdAt: { gte: new Date(Date.now() - WINDOW_MS) }, + }, + { metadata: { path: ["signalCode"], equals: input.signalCode } }, + ]; + if (input.deviceRecordId) { + filters.push({ + metadata: { path: ["deviceRecordId"], equals: input.deviceRecordId }, + }); + } + return Boolean( + await this.prisma.domainEvent.findFirst({ + where: { AND: filters }, + select: { id: true }, + }), + ); + } + + private deviceEvidence( + devices: Array<{ userId: string | null; createdAt: Date }>, + type: LinkageEvidence["type"] = "same_device", + ): LinkageEvidence[] { + return devices.slice(0, EVIDENCE_LIMIT).flatMap((device) => + device.userId + ? [ + { + type, + userId: device.userId, + createdAt: device.createdAt.toISOString(), + }, + ] + : [], + ); + } + + private logFailure( + operation: string, + requestId: string | null | undefined, + error: unknown, + ): void { + this.logger.warn( + `Account abuse linkage evaluation failed: operation=${operation} requestId=${requestId ?? "none"} error=${error instanceof Error ? error.name : "UnknownError"}`, + ); + } +} diff --git a/apps/backend/src/domains/users/auth/auth-risk-signal.service.spec.ts b/apps/backend/src/domains/users/auth/auth-risk-signal.service.spec.ts new file mode 100644 index 00000000..299efece --- /dev/null +++ b/apps/backend/src/domains/users/auth/auth-risk-signal.service.spec.ts @@ -0,0 +1,330 @@ +import { AuthRiskSignalService } from "./auth-risk-signal.service"; +import type { RequestSecurityContext } from "../../../common/security/request-security-context"; + +const context = ( + overrides: Partial = {}, +): RequestSecurityContext => ({ + requestId: "req-1", + ipAddress: "203.0.113.10", + ipSource: "remote-address", + userAgent: "Mozilla/5.0", + origin: "https://twizrr.com", + referer: "https://twizrr.com/login", + method: "POST", + path: "/auth/login", + deviceType: "desktop", + isTrustedProxyHeaderUsed: false, + createdAt: new Date(), + ...overrides, +}); + +describe("AuthRiskSignalService", () => { + const prisma = { + domainEvent: { + findMany: jest.fn(), + create: jest.fn(), + }, + }; + const redis = { + incr: jest.fn(), + expire: jest.fn(), + }; + const notifications = { triggerSecurityLogin: jest.fn() }; + + let service: AuthRiskSignalService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new AuthRiskSignalService( + prisma as never, + redis as never, + notifications as never, + ); + prisma.domainEvent.create.mockResolvedValue({}); + prisma.domainEvent.findMany.mockResolvedValue([]); + redis.incr.mockResolvedValue(1); + redis.expire.mockResolvedValue(true); + }); + + it("does not flag a first successful login context", async () => { + prisma.domainEvent.findMany.mockResolvedValueOnce([]); + + await service.recordSuccessfulLogin({ + userId: "user-1", + sessionId: "session-1", + userRole: "USER", + attemptedIdentifierHash: "identifier-hash", + securityContext: context(), + }); + + expect(prisma.domainEvent.create).not.toHaveBeenCalled(); + }); + + it("records a higher-severity new-device signal for a SUPER_ADMIN", async () => { + prisma.domainEvent.findMany + .mockResolvedValueOnce([ + { + metadata: { + userAgent: "Mozilla/4.0", + deviceType: "desktop", + ipAddress: "203.0.113.10", + }, + }, + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + await service.recordSuccessfulLogin({ + userId: "admin-1", + sessionId: "session-1", + userRole: "SUPER_ADMIN", + attemptedIdentifierHash: "identifier-hash", + securityContext: context(), + }); + + expect(prisma.domainEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + eventType: "AUTH_RISK_SIGNAL_RECORDED", + metadata: expect.objectContaining({ + signalCode: "SUPER_ADMIN_NEW_DEVICE", + severity: "high", + }), + }), + }), + ); + }); + + it("records repeated failures once the threshold is reached without sensitive metadata", async () => { + prisma.domainEvent.findMany + .mockResolvedValueOnce( + Array.from({ length: 5 }, () => ({ + metadata: { attemptedIdentifierHash: "identifier-hash" }, + })), + ) + .mockResolvedValueOnce([]); + + await service.recordLoginFailure({ + userId: null, + attemptedIdentifierHash: "identifier-hash", + securityContext: context(), + }); + + const metadata = prisma.domainEvent.create.mock.calls[0][0].data.metadata; + expect(metadata).toEqual( + expect.objectContaining({ + signalCode: "REPEATED_FAILED_LOGINS", + recentFailureCount: 5, + }), + ); + expect(JSON.stringify(metadata)).not.toMatch( + /password|cookie|authorization|token|rawbody|rawheaders|providerpayload/i, + ); + }); + + it("does not treat the first known country as suspicious", async () => { + prisma.domainEvent.findMany.mockResolvedValueOnce([]); + + await service.recordSuccessfulLogin({ + userId: "user-1", + sessionId: "session-1", + userRole: "USER", + attemptedIdentifierHash: "identifier-hash", + securityContext: context({ + geo: { + countryCode: "NG", + countryName: null, + region: null, + city: null, + source: "trusted_header", + confidence: "medium", + }, + }), + }); + + expect(prisma.domainEvent.create).not.toHaveBeenCalled(); + }); + + it("records a new-country signal without changing login behavior", async () => { + prisma.domainEvent.findMany + .mockResolvedValueOnce([ + { + metadata: { + userAgent: "Mozilla/5.0", + deviceType: "desktop", + ipAddress: "203.0.113.10", + geo: { countryCode: "NG" }, + }, + }, + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + await service.recordSuccessfulLogin({ + userId: "user-1", + sessionId: "session-1", + userRole: "USER", + attemptedIdentifierHash: "identifier-hash", + securityContext: context({ + geo: { + countryCode: "GH", + countryName: null, + region: null, + city: null, + source: "trusted_header", + confidence: "medium", + }, + }), + }); + + expect(prisma.domainEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + metadata: expect.objectContaining({ + signalCode: "NEW_COUNTRY", + severity: "low", + geo: expect.objectContaining({ countryCode: "GH" }), + }), + }), + }), + ); + }); + + it("uses higher severity for a SUPER_ADMIN country change", async () => { + prisma.domainEvent.findMany + .mockResolvedValueOnce([ + { + metadata: { + userAgent: "Mozilla/5.0", + deviceType: "desktop", + ipAddress: "203.0.113.10", + geo: { countryCode: "NG" }, + }, + }, + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + await service.recordSuccessfulLogin({ + userId: "admin-1", + sessionId: "session-1", + userRole: "SUPER_ADMIN", + attemptedIdentifierHash: "identifier-hash", + securityContext: context({ + geo: { + countryCode: "GH", + countryName: null, + region: null, + city: null, + source: "trusted_header", + confidence: "medium", + }, + }), + }); + + expect(prisma.domainEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + metadata: expect.objectContaining({ + signalCode: "SUPER_ADMIN_NEW_COUNTRY", + severity: "high", + }), + }), + }), + ); + }); + + it("records repeated OTP resend abuse only after the threshold", async () => { + redis.incr.mockResolvedValueOnce(4).mockResolvedValueOnce(5); + prisma.domainEvent.findMany.mockResolvedValueOnce([]); + + await service.recordSensitiveAuthAttempt({ + signalCode: "REPEATED_OTP_RESENDS", + flow: "email_otp", + attemptedIdentifier: "buyer@example.com", + securityContext: context(), + }); + expect(prisma.domainEvent.create).not.toHaveBeenCalled(); + + await service.recordSensitiveAuthAttempt({ + signalCode: "REPEATED_OTP_RESENDS", + flow: "email_otp", + attemptedIdentifier: "buyer@example.com", + securityContext: context(), + }); + + const metadata = prisma.domainEvent.create.mock.calls[0][0].data.metadata; + expect(metadata).toEqual( + expect.objectContaining({ + signalCode: "REPEATED_OTP_RESENDS", + flow: "email_otp", + recentAttemptCount: 5, + windowMinutes: 15, + }), + ); + expect(JSON.stringify(metadata)).not.toMatch( + /buyer@example\.com|password|cookie|authorization|token|rawbody|rawheaders/i, + ); + }); + + it("records OTP verification failures and dedupes matching signals", async () => { + redis.incr.mockResolvedValue(5); + prisma.domainEvent.findMany.mockResolvedValueOnce([]); + + await service.recordSensitiveAuthAttempt({ + signalCode: "REPEATED_OTP_VERIFY_FAILURES", + flow: "phone_otp", + userId: "user-1", + securityContext: context(), + }); + prisma.domainEvent.findMany.mockResolvedValueOnce([ + { metadata: prisma.domainEvent.create.mock.calls[0][0].data.metadata }, + ]); + await service.recordSensitiveAuthAttempt({ + signalCode: "REPEATED_OTP_VERIFY_FAILURES", + flow: "phone_otp", + userId: "user-1", + securityContext: context(), + }); + + expect(prisma.domainEvent.create).toHaveBeenCalledTimes(1); + }); + + it("records password-reset and rate-limit signals without request context", async () => { + redis.incr.mockResolvedValue(5); + prisma.domainEvent.findMany.mockResolvedValue([]); + + await service.recordSensitiveAuthAttempt({ + signalCode: "REPEATED_PASSWORD_RESET_REQUESTS", + flow: "password_reset", + attemptedIdentifier: "buyer@example.com", + }); + await service.recordSensitiveAuthAttempt({ + signalCode: "RATE_LIMIT_TRIGGERED", + flow: "login", + securityContext: context(), + alwaysRecord: true, + }); + + expect(prisma.domainEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + metadata: expect.objectContaining({ + signalCode: "REPEATED_PASSWORD_RESET_REQUESTS", + requestId: null, + }), + }), + }), + ); + expect(prisma.domainEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + metadata: expect.objectContaining({ + signalCode: "RATE_LIMIT_TRIGGERED", + flow: "login", + }), + }), + }), + ); + }); +}); diff --git a/apps/backend/src/domains/users/auth/auth-risk-signal.service.ts b/apps/backend/src/domains/users/auth/auth-risk-signal.service.ts new file mode 100644 index 00000000..5cbbee20 --- /dev/null +++ b/apps/backend/src/domains/users/auth/auth-risk-signal.service.ts @@ -0,0 +1,458 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { createHash } from "crypto"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { RedisService } from "../../../core/redis/redis.service"; +import { NotificationTriggerService } from "../../social/notification/notification-trigger.service"; +import type { RequestSecurityContext } from "../../../common/security/request-security-context"; + +export type AuthRiskSignalCode = + | "NEW_DEVICE" + | "NEW_IP" + | "REPEATED_FAILED_LOGINS" + | "SUCCESS_AFTER_FAILURES" + | "SUPER_ADMIN_NEW_DEVICE" + | "SUPER_ADMIN_NEW_IP" + | "NEW_COUNTRY" + | "SUPER_ADMIN_NEW_COUNTRY" + | "RATE_LIMIT_TRIGGERED" + | "REPEATED_OTP_RESENDS" + | "REPEATED_OTP_VERIFY_FAILURES" + | "REPEATED_PASSWORD_RESET_REQUESTS" + | "AUTH_ABUSE_PATTERN_DETECTED"; + +export type AuthRiskSignalFlow = + | "email_otp" + | "phone_otp" + | "password_reset" + | "login" + | "registration"; + +type AuthRiskSignalSeverity = "low" | "medium" | "high"; + +const FAILURE_WINDOW_MS = 15 * 60 * 1000; +const FAILURE_THRESHOLD = 5; +const DEDUPE_WINDOW_MS = 15 * 60 * 1000; +const LOGIN_HISTORY_LIMIT = 100; +const ABUSE_WINDOW_SECONDS = 15 * 60; +const ABUSE_THRESHOLD = 5; + +@Injectable() +export class AuthRiskSignalService { + private readonly logger = new Logger(AuthRiskSignalService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly redis: RedisService, + private readonly notifications: NotificationTriggerService, + ) {} + + async recordSensitiveAuthAttempt(input: { + signalCode: + | "RATE_LIMIT_TRIGGERED" + | "REPEATED_OTP_RESENDS" + | "REPEATED_OTP_VERIFY_FAILURES" + | "REPEATED_PASSWORD_RESET_REQUESTS" + | "AUTH_ABUSE_PATTERN_DETECTED"; + flow: AuthRiskSignalFlow; + userId?: string | null; + attemptedIdentifier?: string | null; + securityContext?: RequestSecurityContext; + alwaysRecord?: boolean; + }): Promise { + try { + const attemptedIdentifierHash = input.attemptedIdentifier + ? this.hashIdentifier(input.attemptedIdentifier) + : null; + const ipFingerprint = input.securityContext?.ipAddress + ? this.hashIdentifier(input.securityContext.ipAddress) + : null; + const subject = attemptedIdentifierHash ?? input.userId ?? ipFingerprint; + + if (!subject) { + return; + } + + const counterKey = this.abuseCounterKey( + input.signalCode, + input.flow, + subject, + ); + const recentAttemptCount = input.alwaysRecord + ? 1 + : await this.incrementAbuseCounter(counterKey); + + if (!input.alwaysRecord && recentAttemptCount < ABUSE_THRESHOLD) { + return; + } + + await this.recordSignal({ + userId: input.userId ?? null, + aggregateId: input.userId ?? subject, + signalCode: input.signalCode, + severity: + input.signalCode === "RATE_LIMIT_TRIGGERED" ? "medium" : "low", + flow: input.flow, + securityContext: input.securityContext, + attemptedIdentifierHash, + recentAttemptCount, + }); + } catch (error) { + this.logFailure(`sensitive-auth-attempt:${input.flow}`, error); + } + } + + async recordSuccessfulLogin(input: { + userId: string; + sessionId: string; + userRole: string; + attemptedIdentifierHash: string; + securityContext?: RequestSecurityContext; + }): Promise { + if (!input.securityContext) { + return; + } + + try { + const successfulLogins = await this.prisma.domainEvent.findMany({ + where: { + aggregateType: "AUTH", + aggregateId: input.userId, + eventType: "AUTH_LOGIN_SUCCEEDED", + }, + select: { metadata: true }, + orderBy: { createdAt: "desc" }, + take: LOGIN_HISTORY_LIMIT, + }); + + if (successfulLogins.length > 0) { + const currentUserAgentHash = this.userAgentHash(input.securityContext); + const knownUserAgentHashes = new Set( + successfulLogins + .map((event) => this.userAgentHashFromMetadata(event.metadata)) + .filter((hash): hash is string => Boolean(hash)), + ); + const knownIps = new Set( + successfulLogins + .map((event) => this.stringMetadata(event.metadata, "ipAddress")) + .filter((ip): ip is string => Boolean(ip)), + ); + const countryCode = input.securityContext.geo?.countryCode; + const knownCountries = new Set( + successfulLogins + .map((event) => this.geoCountryFromMetadata(event.metadata)) + .filter((country): country is string => Boolean(country)), + ); + + if ( + currentUserAgentHash && + !knownUserAgentHashes.has(currentUserAgentHash) + ) { + await this.recordSignal({ + userId: input.userId, + sessionId: input.sessionId, + signalCode: + input.userRole === "SUPER_ADMIN" + ? "SUPER_ADMIN_NEW_DEVICE" + : "NEW_DEVICE", + severity: input.userRole === "SUPER_ADMIN" ? "high" : "medium", + securityContext: input.securityContext, + userAgentHash: currentUserAgentHash, + }); + } + + if ( + countryCode && + knownCountries.size > 0 && + !knownCountries.has(countryCode) + ) { + await this.recordSignal({ + userId: input.userId, + sessionId: input.sessionId, + signalCode: + input.userRole === "SUPER_ADMIN" + ? "SUPER_ADMIN_NEW_COUNTRY" + : "NEW_COUNTRY", + severity: input.userRole === "SUPER_ADMIN" ? "high" : "low", + securityContext: input.securityContext, + userAgentHash: currentUserAgentHash, + }); + } + + if ( + input.securityContext.ipAddress && + !knownIps.has(input.securityContext.ipAddress) + ) { + await this.recordSignal({ + userId: input.userId, + sessionId: input.sessionId, + signalCode: + input.userRole === "SUPER_ADMIN" + ? "SUPER_ADMIN_NEW_IP" + : "NEW_IP", + severity: input.userRole === "SUPER_ADMIN" ? "high" : "low", + securityContext: input.securityContext, + userAgentHash: currentUserAgentHash, + }); + } + } + + const recentFailureCount = await this.countRecentFailures( + input.attemptedIdentifierHash, + ); + if (recentFailureCount >= FAILURE_THRESHOLD) { + await this.recordSignal({ + userId: input.userId, + sessionId: input.sessionId, + signalCode: "SUCCESS_AFTER_FAILURES", + severity: "medium", + securityContext: input.securityContext, + recentFailureCount, + }); + } + } catch (error) { + this.logFailure("successful-login", error); + } + } + + async recordLoginFailure(input: { + userId: string | null; + attemptedIdentifierHash: string; + securityContext?: RequestSecurityContext; + }): Promise { + try { + const recentFailureCount = await this.countRecentFailures( + input.attemptedIdentifierHash, + ); + if (recentFailureCount < FAILURE_THRESHOLD) { + return; + } + + await this.recordSignal({ + userId: input.userId, + aggregateId: input.userId ?? input.attemptedIdentifierHash, + signalCode: "REPEATED_FAILED_LOGINS", + severity: "medium", + securityContext: input.securityContext, + recentFailureCount, + }); + } catch (error) { + this.logFailure("failed-login", error); + } + } + + private async countRecentFailures( + attemptedIdentifierHash: string, + ): Promise { + const events = await this.prisma.domainEvent.findMany({ + where: { + aggregateType: "AUTH", + eventType: "AUTH_LOGIN_FAILED", + createdAt: { gte: new Date(Date.now() - FAILURE_WINDOW_MS) }, + }, + select: { metadata: true }, + take: LOGIN_HISTORY_LIMIT, + }); + + return events.filter( + (event) => + this.stringMetadata(event.metadata, "attemptedIdentifierHash") === + attemptedIdentifierHash, + ).length; + } + + private async recordSignal(input: { + userId: string | null; + aggregateId?: string; + sessionId?: string; + signalCode: AuthRiskSignalCode; + severity: AuthRiskSignalSeverity; + securityContext?: RequestSecurityContext; + userAgentHash?: string | null; + attemptedIdentifierHash?: string | null; + flow?: AuthRiskSignalFlow; + recentAttemptCount?: number; + recentFailureCount?: number; + }): Promise { + if (await this.hasRecentDuplicate(input)) { + return; + } + + await this.prisma.domainEvent.create({ + data: { + aggregateType: "AUTH", + aggregateId: input.aggregateId ?? input.userId ?? "anonymous", + eventType: "AUTH_RISK_SIGNAL_RECORDED", + actorType: input.userId ? "USER" : "ANONYMOUS", + actorId: input.userId, + source: "auth.risk-signal", + correlationId: input.securityContext?.requestId ?? null, + metadata: { + userId: input.userId, + sessionId: input.sessionId ?? null, + attemptedIdentifierHash: input.attemptedIdentifierHash ?? null, + requestId: input.securityContext?.requestId ?? null, + signalCode: input.signalCode, + severity: input.severity, + flow: input.flow ?? null, + ipAddress: input.securityContext?.ipAddress ?? null, + ipSource: input.securityContext?.ipSource ?? null, + deviceType: input.securityContext?.deviceType ?? null, + geo: input.securityContext?.geo ?? null, + userAgentHash: + input.userAgentHash ?? + (input.securityContext + ? this.userAgentHash(input.securityContext) + : null), + origin: input.securityContext?.origin ?? null, + referer: input.securityContext?.referer ?? null, + path: input.securityContext?.path ?? null, + recentAttemptCount: input.recentAttemptCount ?? null, + recentFailureCount: input.recentFailureCount ?? null, + windowMinutes: + input.recentFailureCount === undefined && + input.recentAttemptCount === undefined + ? null + : input.recentAttemptCount === undefined + ? FAILURE_WINDOW_MS / 60_000 + : ABUSE_WINDOW_SECONDS / 60, + }, + }, + }); + if ( + input.userId && + [ + "NEW_DEVICE", + "NEW_IP", + "SUCCESS_AFTER_FAILURES", + "SUPER_ADMIN_NEW_DEVICE", + "SUPER_ADMIN_NEW_IP", + ].includes(input.signalCode) + ) { + try { + await this.notifications.triggerSecurityLogin(input.userId, { + signalCode: input.signalCode, + deviceType: input.securityContext?.deviceType ?? null, + requestId: input.securityContext?.requestId ?? null, + isSuperAdmin: input.signalCode.startsWith("SUPER_ADMIN_"), + }); + } catch (error) { + this.logger.warn( + `Security login notification failed: userId=${input.userId} requestId=${input.securityContext?.requestId ?? "none"} error=${error instanceof Error ? error.name : "UnknownError"}`, + ); + } + } + } + + private async hasRecentDuplicate(input: { + userId: string | null; + aggregateId?: string; + signalCode: AuthRiskSignalCode; + flow?: AuthRiskSignalFlow; + securityContext?: RequestSecurityContext; + userAgentHash?: string | null; + }): Promise { + const events = await this.prisma.domainEvent.findMany({ + where: { + aggregateType: "AUTH", + aggregateId: input.aggregateId ?? input.userId ?? "anonymous", + eventType: "AUTH_RISK_SIGNAL_RECORDED", + createdAt: { gte: new Date(Date.now() - DEDUPE_WINDOW_MS) }, + }, + select: { metadata: true }, + take: LOGIN_HISTORY_LIMIT, + }); + + const userAgentHash = + input.userAgentHash ?? + (input.securityContext + ? this.userAgentHash(input.securityContext) + : null); + return events.some( + (event) => + this.stringMetadata(event.metadata, "signalCode") === + input.signalCode && + this.stringMetadata(event.metadata, "flow") === (input.flow ?? null) && + this.stringMetadata(event.metadata, "userAgentHash") === + userAgentHash && + this.stringMetadata(event.metadata, "ipAddress") === + (input.securityContext?.ipAddress ?? null), + ); + } + + private userAgentHash(context: RequestSecurityContext): string | null { + if (!context.userAgent) { + return null; + } + + return createHash("sha256") + .update( + `${context.userAgent.trim().toLowerCase()}:${context.deviceType ?? "unknown"}`, + ) + .digest("hex"); + } + + private async incrementAbuseCounter(key: string): Promise { + const count = await this.redis.incr(key); + if (count === 1) { + await this.redis.expire(key, ABUSE_WINDOW_SECONDS); + } + return count; + } + + private abuseCounterKey( + signalCode: AuthRiskSignalCode, + flow: AuthRiskSignalFlow, + subject: string, + ): string { + return `auth:risk:${signalCode}:${flow}:${this.hashIdentifier(subject)}`; + } + + private hashIdentifier(identifier: string): string { + return createHash("sha256") + .update(identifier.trim().toLowerCase()) + .digest("hex"); + } + + private geoCountryFromMetadata(metadata: unknown): string | null { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) + return null; + const geo = (metadata as Record).geo; + if (!geo || typeof geo !== "object" || Array.isArray(geo)) return null; + const countryCode = (geo as Record).countryCode; + return typeof countryCode === "string" ? countryCode : null; + } + + private userAgentHashFromMetadata(metadata: unknown): string | null { + const existingHash = this.stringMetadata(metadata, "userAgentHash"); + if (existingHash) { + return existingHash; + } + + const userAgent = this.stringMetadata(metadata, "userAgent"); + if (!userAgent) { + return null; + } + + return createHash("sha256") + .update( + `${userAgent.trim().toLowerCase()}:${this.stringMetadata(metadata, "deviceType") ?? "unknown"}`, + ) + .digest("hex"); + } + + private stringMetadata(metadata: unknown, key: string): string | null { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + return null; + } + + const value = (metadata as Record)[key]; + return typeof value === "string" ? value : null; + } + + private logFailure(operation: string, error: unknown): void { + this.logger.warn( + `Auth risk signal evaluation failed: operation=${operation} error=${error instanceof Error ? error.name : "UnknownError"}`, + ); + } +} diff --git a/apps/backend/src/domains/users/auth/auth-security-audit.service.spec.ts b/apps/backend/src/domains/users/auth/auth-security-audit.service.spec.ts new file mode 100644 index 00000000..ae070b51 --- /dev/null +++ b/apps/backend/src/domains/users/auth/auth-security-audit.service.spec.ts @@ -0,0 +1,200 @@ +import { createHash } from "crypto"; + +import { AuthSecurityAuditService } from "./auth-security-audit.service"; +import type { RequestSecurityContext } from "../../../common/security/request-security-context"; + +describe("AuthSecurityAuditService", () => { + const prisma = { + domainEvent: { + create: jest.fn(), + }, + }; + const logger = { + warn: jest.fn(), + }; + const authRiskSignals = { + recordLoginFailure: jest.fn(), + recordSuccessfulLogin: jest.fn(), + }; + + const context: RequestSecurityContext = { + requestId: "req-123", + ipAddress: "203.0.113.10", + ipSource: "cf-connecting-ip", + userAgent: "Mozilla/5.0", + origin: "https://twizrr.com", + referer: "https://twizrr.com/login", + method: "POST", + path: "/auth/login", + deviceType: "desktop", + isTrustedProxyHeaderUsed: true, + createdAt: new Date("2026-07-04T10:00:00.000Z"), + }; + + let service: AuthSecurityAuditService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new AuthSecurityAuditService( + prisma as never, + authRiskSignals as never, + ); + Object.assign(service as unknown as { logger: typeof logger }, { logger }); + prisma.domainEvent.create.mockResolvedValue({}); + }); + + it("records successful login context as safe domain-event metadata", async () => { + await service.recordLoginSucceeded({ + userId: "user-1", + sessionId: "session-1", + securityContext: context, + }); + + expect(prisma.domainEvent.create).toHaveBeenCalledWith({ + data: { + aggregateType: "AUTH", + aggregateId: "user-1", + eventType: "AUTH_LOGIN_SUCCEEDED", + actorType: "USER", + actorId: "user-1", + source: "auth.login", + correlationId: "req-123", + metadata: { + sessionId: "session-1", + deviceIdentity: { + deviceHashPresent: false, + deviceRecordId: null, + deviceType: "desktop", + lastSeenAt: null, + }, + requestId: "req-123", + ipAddress: "203.0.113.10", + ipSource: "cf-connecting-ip", + userAgent: "Mozilla/5.0", + origin: "https://twizrr.com", + referer: "https://twizrr.com/login", + method: "POST", + path: "/auth/login", + deviceType: "desktop", + geo: null, + }, + }, + }); + }); + + it("records failed login context without storing secrets or raw identifiers", async () => { + await service.recordLoginFailed({ + userId: null, + attemptedIdentifier: " Shopper@Example.com ", + failureReasonCode: "USER_NOT_FOUND_OR_PASSWORD_INVALID", + securityContext: { + ...context, + userAgent: null, + ipAddress: null, + }, + }); + + const expectedHash = createHash("sha256") + .update("shopper@example.com") + .digest("hex"); + + expect(prisma.domainEvent.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + aggregateType: "AUTH", + aggregateId: "anonymous", + eventType: "AUTH_LOGIN_FAILED", + actorType: "ANONYMOUS", + actorId: null, + source: "auth.login", + correlationId: "req-123", + metadata: expect.objectContaining({ + attemptedIdentifierHash: expectedHash, + failureReasonCode: "USER_NOT_FOUND_OR_PASSWORD_INVALID", + requestId: "req-123", + ipAddress: null, + userAgent: null, + }), + }), + }); + + const metadata = prisma.domainEvent.create.mock.calls[0][0].data.metadata; + expect(JSON.stringify(metadata)).not.toMatch( + /wrong-password|cookie=|authorization:|accessToken|refreshToken|signature=|rawBody|Shopper@Example/i, + ); + }); + + it("records account creation with safe request context and no raw device hash", async () => { + await service.recordAccountCreated({ + userId: "user-1", + authProvider: "email", + securityContext: context, + deviceIdentity: { + deviceHashPresent: true, + deviceRecordId: "device-1", + deviceType: "desktop", + lastSeenAt: "2026-07-16T12:00:00.000Z", + }, + }); + + const event = prisma.domainEvent.create.mock.calls[0][0].data; + expect(event).toEqual( + expect.objectContaining({ + eventType: "AUTH_ACCOUNT_CREATED", + source: "auth.registration", + correlationId: "req-123", + }), + ); + expect(JSON.stringify(event.metadata)).not.toMatch( + /password|cookie|authorization|deviceIdHash/i, + ); + }); + + it("strips query and fragment components from referer before persistence", async () => { + await service.recordLoginSucceeded({ + userId: "user-1", + sessionId: "session-1", + securityContext: { + ...context, + referer: + "https://twizrr.com/reset-password?token=opaque-reset-value#section", + }, + }); + + const metadata = prisma.domainEvent.create.mock.calls[0][0].data.metadata; + expect(metadata).toEqual( + expect.objectContaining({ + referer: "https://twizrr.com/reset-password", + }), + ); + expect(JSON.stringify(metadata)).not.toMatch(/opaque-reset-value|token=/i); + }); + + it("uses safe null defaults when request security context is missing", async () => { + await service.recordLoginFailed({ + userId: "user-1", + attemptedIdentifier: "buyer@example.com", + failureReasonCode: "INVALID_CREDENTIALS", + }); + + expect(prisma.domainEvent.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + aggregateId: "user-1", + actorType: "USER", + actorId: "user-1", + correlationId: null, + metadata: expect.objectContaining({ + requestId: null, + ipAddress: null, + ipSource: "unknown", + userAgent: null, + origin: null, + referer: null, + method: null, + path: null, + deviceType: "unknown", + geo: null, + }), + }), + }); + }); +}); diff --git a/apps/backend/src/domains/users/auth/auth-security-audit.service.ts b/apps/backend/src/domains/users/auth/auth-security-audit.service.ts new file mode 100644 index 00000000..3e1c5247 --- /dev/null +++ b/apps/backend/src/domains/users/auth/auth-security-audit.service.ts @@ -0,0 +1,186 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { createHash } from "crypto"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { AuthRiskSignalService } from "./auth-risk-signal.service"; +import type { DeviceIdentityAuditMetadata } from "../../../common/security/device-identity.service"; +import type { + RequestDeviceType, + RequestSecurityContext, +} from "../../../common/security/request-security-context"; +import { serializeRequestSecurityContextForAudit } from "../../../common/security/request-security-context"; + +type AuthLoginFailureReasonCode = + | "INVALID_CREDENTIALS" + | "USER_NOT_FOUND_OR_PASSWORD_INVALID" + | "ACCOUNT_DISABLED" + | "EMAIL_NOT_VERIFIED" + | "RATE_LIMITED" + | "UNKNOWN_AUTH_FAILURE"; + +type AuthSecurityContextMetadata = { + requestId: string | null; + ipAddress: string | null; + ipSource: string; + userAgent: string | null; + origin: string | null; + referer: string | null; + method: string | null; + path: string | null; + deviceType: RequestDeviceType; + geo: RequestSecurityContext["geo"]; +}; + +@Injectable() +export class AuthSecurityAuditService { + private readonly logger = new Logger(AuthSecurityAuditService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly authRiskSignals: AuthRiskSignalService, + ) {} + + async recordLoginSucceeded(input: { + userId: string; + sessionId: string; + userRole?: string; + attemptedIdentifier?: string; + securityContext?: RequestSecurityContext; + deviceIdentity?: DeviceIdentityAuditMetadata; + }): Promise { + await this.createAuthEvent({ + userId: input.userId, + eventType: "AUTH_LOGIN_SUCCEEDED", + metadata: { + sessionId: input.sessionId, + deviceIdentity: input.deviceIdentity ?? { + deviceHashPresent: false, + deviceRecordId: null, + deviceType: input.securityContext?.deviceType ?? null, + lastSeenAt: null, + }, + ...this.serializeSecurityContext(input.securityContext), + }, + correlationId: input.securityContext?.requestId ?? null, + }); + if (input.userRole && input.attemptedIdentifier) { + await this.authRiskSignals.recordSuccessfulLogin({ + userId: input.userId, + sessionId: input.sessionId, + userRole: input.userRole, + attemptedIdentifierHash: this.hashIdentifier(input.attemptedIdentifier), + securityContext: input.securityContext, + }); + } + } + + async recordAccountCreated(input: { + userId: string; + authProvider: "email" | "google" | "unknown"; + securityContext?: RequestSecurityContext; + deviceIdentity?: DeviceIdentityAuditMetadata; + }): Promise { + await this.createAuthEvent({ + userId: input.userId, + eventType: "AUTH_ACCOUNT_CREATED", + metadata: { + userId: input.userId, + authProvider: input.authProvider, + requestContext: this.serializeSecurityContext(input.securityContext), + deviceIdentity: { + present: input.deviceIdentity?.deviceHashPresent ?? false, + deviceRecordId: input.deviceIdentity?.deviceRecordId ?? null, + }, + createdAt: new Date().toISOString(), + }, + correlationId: input.securityContext?.requestId ?? null, + }); + } + + async recordLoginFailed(input: { + userId: string | null; + attemptedIdentifier: string; + failureReasonCode: AuthLoginFailureReasonCode; + securityContext?: RequestSecurityContext; + }): Promise { + await this.createAuthEvent({ + userId: input.userId, + eventType: "AUTH_LOGIN_FAILED", + metadata: { + attemptedIdentifierHash: this.hashIdentifier(input.attemptedIdentifier), + failureReasonCode: input.failureReasonCode, + ...this.serializeSecurityContext(input.securityContext), + }, + correlationId: input.securityContext?.requestId ?? null, + }); + await this.authRiskSignals.recordLoginFailure({ + userId: input.userId, + attemptedIdentifierHash: this.hashIdentifier(input.attemptedIdentifier), + securityContext: input.securityContext, + }); + } + + private async createAuthEvent(input: { + userId: string | null; + eventType: + | "AUTH_LOGIN_SUCCEEDED" + | "AUTH_LOGIN_FAILED" + | "AUTH_ACCOUNT_CREATED"; + metadata: Prisma.InputJsonValue; + correlationId: string | null; + }): Promise { + try { + await this.prisma.domainEvent.create({ + data: { + aggregateType: "AUTH", + aggregateId: input.userId ?? "anonymous", + eventType: input.eventType, + actorType: input.userId ? "USER" : "ANONYMOUS", + actorId: input.userId, + source: + input.eventType === "AUTH_ACCOUNT_CREATED" + ? "auth.registration" + : "auth.login", + metadata: input.metadata, + correlationId: input.correlationId, + }, + }); + } catch (error) { + this.logger.warn( + `Auth security audit write failed: event=${input.eventType} requestId=${input.correlationId ?? "none"} error=${this.getErrorName(error)}`, + ); + } + } + + private serializeSecurityContext( + context?: RequestSecurityContext, + ): AuthSecurityContextMetadata { + const serialized = context + ? serializeRequestSecurityContextForAudit(context) + : undefined; + + return { + requestId: serialized?.requestId ?? null, + ipAddress: serialized?.ipAddress ?? null, + ipSource: serialized?.ipSource ?? "unknown", + userAgent: serialized?.userAgent ?? null, + origin: serialized?.origin ?? null, + referer: serialized?.referer ?? null, + method: serialized?.method ?? null, + path: serialized?.path ?? null, + deviceType: serialized?.deviceType ?? "unknown", + geo: serialized?.geo ?? null, + }; + } + + private hashIdentifier(identifier: string): string { + return createHash("sha256") + .update(identifier.trim().toLowerCase()) + .digest("hex"); + } + + private getErrorName(error: unknown): string { + return error instanceof Error ? error.name : "UnknownError"; + } +} diff --git a/apps/backend/src/domains/users/auth/auth.constants.ts b/apps/backend/src/domains/users/auth/auth.constants.ts new file mode 100644 index 00000000..101e638d --- /dev/null +++ b/apps/backend/src/domains/users/auth/auth.constants.ts @@ -0,0 +1,23 @@ +export const AUTH_COOKIE = { + ACCESS: "twizrr_access_token", + REFRESH: "twizrr_refresh_token", +} as const; + +export const AUTH_TTL = { + ACCESS_SECONDS: 15 * 60, + REFRESH_SECONDS: 7 * 24 * 60 * 60, + ACCESS_COOKIE_MS: 15 * 60 * 1000, + REFRESH_COOKIE_MS: 7 * 24 * 60 * 60 * 1000, +} as const; + +export const AUTH_STRATEGY = { + ACCESS: "jwt-access", + REFRESH: "jwt-refresh", +} as const; + +export const GOOGLE_STATE_COOKIE = "twizrr_google_oauth_state"; + +export const REDIRECT_TARGET = { + STORE_DASHBOARD: "/store/dashboard", + EXPLORE: "/explore", +} as const; diff --git a/apps/backend/src/domains/users/auth/auth.controller.spec.ts b/apps/backend/src/domains/users/auth/auth.controller.spec.ts new file mode 100644 index 00000000..28d61058 --- /dev/null +++ b/apps/backend/src/domains/users/auth/auth.controller.spec.ts @@ -0,0 +1,126 @@ +import { AuthController } from "./auth.controller"; +import { AUTH_COOKIE } from "./auth.constants"; + +function makeResponse() { + return { + cookie: jest.fn(), + clearCookie: jest.fn(), + }; +} + +describe("AuthController session cookies", () => { + const originalNodeEnv = process.env.NODE_ENV; + const originalCookieDomain = process.env.AUTH_COOKIE_DOMAIN; + + afterEach(() => { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + + if (originalCookieDomain === undefined) { + delete process.env.AUTH_COOKIE_DOMAIN; + } else { + process.env.AUTH_COOKIE_DOMAIN = originalCookieDomain; + } + }); + + it("sets both auth cookies for the configured shared domain", async () => { + process.env.NODE_ENV = "production"; + process.env.AUTH_COOKIE_DOMAIN = "twizrr.com"; + const authService = { + login: jest.fn().mockResolvedValue({ + accessToken: "access-token", + refreshToken: "refresh-token", + redirectTo: "/home", + }), + }; + const response = makeResponse(); + const controller = new AuthController(authService as never); + + await controller.login( + { email: "shopper@example.com", password: "password" }, + { headers: {}, ip: "127.0.0.1" } as never, + response as never, + ); + + expect(response.cookie).toHaveBeenCalledWith( + AUTH_COOKIE.ACCESS, + "access-token", + expect.objectContaining({ domain: "twizrr.com", path: "/" }), + ); + expect(response.cookie).toHaveBeenCalledWith( + AUTH_COOKIE.REFRESH, + "refresh-token", + expect.objectContaining({ domain: "twizrr.com", path: "/" }), + ); + expect(response.clearCookie).toHaveBeenCalledWith( + AUTH_COOKIE.ACCESS, + expect.not.objectContaining({ domain: expect.anything() }), + ); + expect(response.clearCookie).toHaveBeenCalledWith( + AUTH_COOKIE.REFRESH, + expect.not.objectContaining({ domain: expect.anything() }), + ); + expect(response.clearCookie.mock.invocationCallOrder[0]).toBeLessThan( + response.cookie.mock.invocationCallOrder[0], + ); + }); + + it("clears both auth cookies from the same configured shared domain", async () => { + process.env.NODE_ENV = "production"; + process.env.AUTH_COOKIE_DOMAIN = "twizrr.com"; + const authService = { + logout: jest.fn().mockResolvedValue({ loggedOut: true }), + }; + const response = makeResponse(); + const controller = new AuthController(authService as never); + + await controller.logout( + { headers: {}, ip: "127.0.0.1", cookies: {} } as never, + response as never, + ); + + expect(response.clearCookie).toHaveBeenCalledWith( + AUTH_COOKIE.ACCESS, + expect.objectContaining({ domain: "twizrr.com", path: "/" }), + ); + expect(response.clearCookie).toHaveBeenCalledWith( + AUTH_COOKIE.REFRESH, + expect.objectContaining({ domain: "twizrr.com", path: "/" }), + ); + expect(response.clearCookie).toHaveBeenCalledWith( + AUTH_COOKIE.ACCESS, + expect.not.objectContaining({ domain: expect.anything() }), + ); + expect(response.clearCookie).toHaveBeenCalledWith( + AUTH_COOKIE.REFRESH, + expect.not.objectContaining({ domain: expect.anything() }), + ); + }); + + it("keeps local auth cookies host-only when no domain is configured", async () => { + process.env.NODE_ENV = "development"; + delete process.env.AUTH_COOKIE_DOMAIN; + const authService = { + login: jest.fn().mockResolvedValue({ + accessToken: "access-token", + refreshToken: "refresh-token", + redirectTo: "/home", + }), + }; + const response = makeResponse(); + const controller = new AuthController(authService as never); + + await controller.login( + { email: "shopper@example.com", password: "password" }, + { headers: {}, ip: "127.0.0.1" } as never, + response as never, + ); + + for (const [, , options] of response.cookie.mock.calls) { + expect(options).not.toHaveProperty("domain"); + } + }); +}); diff --git a/apps/backend/src/domains/users/auth/auth.controller.ts b/apps/backend/src/domains/users/auth/auth.controller.ts new file mode 100644 index 00000000..09297355 --- /dev/null +++ b/apps/backend/src/domains/users/auth/auth.controller.ts @@ -0,0 +1,382 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Query, + Req, + Res, + UseGuards, +} from "@nestjs/common"; +import { Throttle } from "@nestjs/throttler"; +import type { CookieOptions, Request, Response } from "express"; + +import { CurrentSecurityContext } from "../../../common/decorators/current-security-context.decorator"; +import { CurrentUser } from "../../../common/decorators/current-user.decorator"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { JwtRefreshGuard } from "../../../common/guards/jwt-refresh.guard"; +import { AUTH_COOKIE, AUTH_TTL, GOOGLE_STATE_COOKIE } from "./auth.constants"; +import { AuthService } from "./auth.service"; +import type { RequestSecurityContext } from "../../../common/security/request-security-context"; +import { + AuthenticatedRequestUser, + RefreshRequestUser, + SessionMetadata, +} from "./auth.types"; +import { LoginDto } from "./dto/login.dto"; +import { CompleteGoogleOnboardingDto } from "./dto/google-onboarding.dto"; +import { ForgotPasswordDto, ResetPasswordDto } from "./dto/password-reset.dto"; +import { RegisterEmailDto } from "./dto/register.dto"; +import { + ResendEmailVerificationDto, + VerifyEmailDto, + VerifyPhoneOtpDto, +} from "./dto/verify-email.dto"; + +@Controller("auth") +export class AuthController { + constructor(private readonly authService: AuthService) {} + + private getAuthCookieDomain(): string | undefined { + return process.env.AUTH_COOKIE_DOMAIN?.trim() || undefined; + } + + private successEnvelope(data: T): { success: true; data: T } { + return { success: true, data }; + } + + private setAuthCookies( + response: Response, + tokens: { accessToken: string; refreshToken: string }, + ): void { + const isLocal = process.env.NODE_ENV === "development"; + const secure = !isLocal; + const sameSite = isLocal ? "lax" : "none"; + const domain = this.getAuthCookieDomain(); + const baseOptions: CookieOptions = { + httpOnly: true, + secure, + sameSite, + path: "/", + }; + + if (domain) { + response.clearCookie(AUTH_COOKIE.ACCESS, baseOptions); + response.clearCookie(AUTH_COOKIE.REFRESH, baseOptions); + } + + response.cookie(AUTH_COOKIE.ACCESS, tokens.accessToken, { + ...baseOptions, + ...(domain ? { domain } : {}), + maxAge: AUTH_TTL.ACCESS_COOKIE_MS, + }); + response.cookie(AUTH_COOKIE.REFRESH, tokens.refreshToken, { + ...baseOptions, + ...(domain ? { domain } : {}), + maxAge: AUTH_TTL.REFRESH_COOKIE_MS, + }); + } + + private clearAuthCookies(response: Response): void { + const isLocal = process.env.NODE_ENV === "development"; + const secure = !isLocal; + const sameSite = isLocal ? "lax" : "none"; + const domain = this.getAuthCookieDomain(); + const baseOptions: CookieOptions = { + httpOnly: true, + secure, + sameSite, + path: "/", + }; + + response.clearCookie(AUTH_COOKIE.ACCESS, baseOptions); + response.clearCookie(AUTH_COOKIE.REFRESH, baseOptions); + + if (domain) { + response.clearCookie(AUTH_COOKIE.ACCESS, { ...baseOptions, domain }); + response.clearCookie(AUTH_COOKIE.REFRESH, { ...baseOptions, domain }); + } + } + + private setGoogleStateCookie( + response: Response, + cookieName: string, + state: string, + maxAgeMs: number, + ): void { + const isLocal = process.env.NODE_ENV === "development"; + response.cookie(cookieName, state, { + httpOnly: true, + secure: !isLocal, + sameSite: isLocal ? "lax" : "none", + maxAge: maxAgeMs, + path: "/auth/google", + }); + } + + private clearGoogleStateCookie(response: Response, cookieName: string): void { + const isLocal = process.env.NODE_ENV === "development"; + response.clearCookie(cookieName, { + httpOnly: true, + secure: !isLocal, + sameSite: isLocal ? "lax" : "none", + path: "/auth/google", + }); + } + + private getSessionMetadata(request: Request): SessionMetadata { + return { + userAgent: request.headers["user-agent"], + ipAddress: request.ip, + }; + } + + private getRefreshCookie(request: Request): string | undefined { + const cookies = ( + request as Request & { + cookies?: Record; + } + ).cookies; + + return cookies?.[AUTH_COOKIE.REFRESH]; + } + + private getCookie(request: Request, cookieName: string): string | undefined { + const cookies = ( + request as Request & { + cookies?: Record; + } + ).cookies; + + return cookies?.[cookieName]; + } + + private getWebRedirectUrl(path: string): string { + const webUrl = process.env.WEB_URL || "http://localhost:3000"; + return new URL(path, webUrl).toString(); + } + + @Get("google") + async startGoogle( + @Query("redirect") redirectTo: string | undefined, + @Res() response: Response, + ) { + const result = await this.authService.startGoogleOAuth(redirectTo); + this.setGoogleStateCookie( + response, + result.cookieName, + result.state, + result.cookieMaxAgeMs, + ); + + return response.redirect(result.authorizationUrl); + } + + @Get("google/callback") + async googleCallback( + @Query("code") code: string | undefined, + @Query("state") state: string | undefined, + @Req() request: Request, + @Res() response: Response, + ) { + const cookieName = GOOGLE_STATE_COOKIE; + + try { + const result = await this.authService.completeGoogleOAuth({ + code, + state, + cookieState: this.getCookie(request, cookieName), + metadata: this.getSessionMetadata(request), + }); + + this.clearGoogleStateCookie(response, cookieName); + + if (result.kind === "login") { + this.setAuthCookies(response, result); + } + + return response.redirect(this.getWebRedirectUrl(result.redirectTo)); + } catch { + this.clearGoogleStateCookie(response, cookieName); + return response.redirect( + this.getWebRedirectUrl("/login?error=google_sign_in_failed"), + ); + } + } + + @Throttle({ default: { limit: 5, ttl: 3_600_000 } }) + @Post("register/email") + async registerEmail( + @Body() dto: RegisterEmailDto, + @CurrentSecurityContext() securityContext?: RequestSecurityContext, + ) { + return this.authService.registerEmail(dto, securityContext); + } + + @Get("google/onboarding/:sessionId") + async getGoogleOnboarding(@Param("sessionId") sessionId: string) { + return this.authService.getGoogleOnboardingSession(sessionId); + } + + @Throttle({ default: { limit: 5, ttl: 3_600_000 } }) + @Post("google/onboarding/:sessionId/complete") + @HttpCode(HttpStatus.OK) + async completeGoogleOnboarding( + @Param("sessionId") sessionId: string, + @Body() dto: CompleteGoogleOnboardingDto, + @Req() request: Request, + @Res({ passthrough: true }) response: Response, + @CurrentSecurityContext() securityContext?: RequestSecurityContext, + ) { + const result = await this.authService.completeGoogleOnboardingSession( + sessionId, + dto, + this.getSessionMetadata(request), + securityContext, + ); + this.setAuthCookies(response, result); + + return this.successEnvelope({ redirectTo: result.redirectTo }); + } + + @Throttle({ default: { limit: 10, ttl: 900_000 } }) + @Post("login") + @HttpCode(HttpStatus.OK) + async login( + @Body() dto: LoginDto, + @Req() request: Request, + @Res({ passthrough: true }) response: Response, + @CurrentSecurityContext() securityContext?: RequestSecurityContext, + ) { + const result = await this.authService.login( + dto, + this.getSessionMetadata(request), + securityContext, + ); + + this.setAuthCookies(response, result); + + return this.successEnvelope({ redirectTo: result.redirectTo }); + } + + @Post("logout") + @HttpCode(HttpStatus.OK) + async logout( + @Req() request: Request, + @Res({ passthrough: true }) response: Response, + ) { + const refreshToken = this.getRefreshCookie(request); + const result = await this.authService.logout(refreshToken); + this.clearAuthCookies(response); + + return this.successEnvelope(result); + } + + @UseGuards(JwtRefreshGuard) + @Post("refresh") + @HttpCode(HttpStatus.OK) + async refresh( + @Req() request: Request, + @Res({ passthrough: true }) response: Response, + ) { + const refreshUser = (request as Request & { user: RefreshRequestUser }) + .user; + const tokens = await this.authService.refresh( + refreshUser, + this.getSessionMetadata(request), + ); + + this.setAuthCookies(response, tokens); + + return this.successEnvelope({ refreshed: true }); + } + + @Post("email/verify") + @HttpCode(HttpStatus.OK) + async verifyEmail( + @Body() dto: VerifyEmailDto, + @Req() request: Request, + @Res({ passthrough: true }) response: Response, + @CurrentSecurityContext() securityContext?: RequestSecurityContext, + ) { + const result = await this.authService.verifyEmail( + dto, + this.getSessionMetadata(request), + securityContext, + ); + this.setAuthCookies(response, result); + + return this.successEnvelope({ + verified: result.verified, + redirectTo: result.redirectTo, + }); + } + + @Throttle({ default: { limit: 3, ttl: 3_600_000 } }) + @Post("email/resend") + @HttpCode(HttpStatus.OK) + async resendEmailVerification( + @Body() dto: ResendEmailVerificationDto, + @CurrentSecurityContext() securityContext?: RequestSecurityContext, + ) { + return this.authService.resendEmailVerification(dto, securityContext); + } + + @Throttle({ default: { limit: 3, ttl: 3_600_000 } }) + @Post("forgot-password") + @HttpCode(HttpStatus.OK) + async forgotPassword( + @Body() dto: ForgotPasswordDto, + @CurrentSecurityContext() securityContext?: RequestSecurityContext, + ) { + const result = await this.authService.forgotPassword(dto, securityContext); + return this.successEnvelope(result); + } + + @Throttle({ default: { limit: 3, ttl: 3_600_000 } }) + @Post("reset-password") + @HttpCode(HttpStatus.OK) + async resetPassword( + @Body() dto: ResetPasswordDto, + @CurrentSecurityContext() securityContext?: RequestSecurityContext, + ) { + const result = await this.authService.resetPassword(dto, securityContext); + return this.successEnvelope(result); + } + + // Account phone verification is guarded and session-bound: the phone comes + // from the authenticated user, so a client cannot target another account's + // phone by supplying one in the body. + @UseGuards(JwtAuthGuard) + @Throttle({ default: { limit: 3, ttl: 3_600_000 } }) + @Post("otp/send") + @HttpCode(HttpStatus.OK) + async sendOtp(@CurrentUser() user: AuthenticatedRequestUser) { + return this.authService.sendPhoneOtp(user.id); + } + + @UseGuards(JwtAuthGuard) + @Post("otp/verify") + @HttpCode(HttpStatus.OK) + async verifyOtp( + @CurrentUser() user: AuthenticatedRequestUser, + @Body() dto: VerifyPhoneOtpDto, + @CurrentSecurityContext() securityContext?: RequestSecurityContext, + ) { + return this.authService.verifyPhoneOtp(user.id, dto.code, securityContext); + } + + @UseGuards(JwtAuthGuard) + @Throttle({ default: { limit: 3, ttl: 3_600_000 } }) + @Post("otp/resend") + @HttpCode(HttpStatus.OK) + async resendOtp( + @CurrentUser() user: AuthenticatedRequestUser, + @CurrentSecurityContext() securityContext?: RequestSecurityContext, + ) { + return this.authService.resendPhoneOtp(user.id, securityContext); + } +} diff --git a/apps/backend/src/domains/users/auth/auth.module.ts b/apps/backend/src/domains/users/auth/auth.module.ts new file mode 100644 index 00000000..7f445bfc --- /dev/null +++ b/apps/backend/src/domains/users/auth/auth.module.ts @@ -0,0 +1,69 @@ +import { forwardRef, Module } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { + JwtModule, + type JwtModuleOptions, + type JwtSignOptions, +} from "@nestjs/jwt"; +import { PassportModule } from "@nestjs/passport"; + +import { AfricasTalkingModule } from "../../../integrations/africastalking/africastalking.module"; +import { GoogleOAuthModule } from "../../../integrations/google-oauth/google-oauth.module"; +import { ResendModule } from "../../../integrations/resend/resend.module"; +import { AccessTokenUserService } from "../../../common/security/access-token-user.service"; +import { DeviceIdentityModule } from "../../../common/security/device-identity.module"; +import { AdminModule } from "../../platform/admin/admin.module"; +import { VerificationModule } from "../verification/verification.module"; +import { NotificationModule } from "../../social/notification/notification.module"; +import { AuthController } from "./auth.controller"; +import { AuthSecurityAuditService } from "./auth-security-audit.service"; +import { AuthRiskSignalService } from "./auth-risk-signal.service"; +import { AccountAbuseLinkageService } from "./account-abuse-linkage.service"; +import { InternalAuthController } from "./internal-auth.controller"; +import { AuthService } from "./auth.service"; +import { JwtAccessStrategy } from "./strategies/jwt.strategy"; +import { JwtRefreshStrategy } from "./strategies/jwt-refresh.strategy"; +import { LocalStrategy } from "./strategies/local.strategy"; + +type JwtExpiresIn = NonNullable; + +const jwtTtl = (configService: ConfigService, key: string): JwtExpiresIn => + configService.getOrThrow(key) as JwtExpiresIn; + +@Module({ + imports: [ + PassportModule, + DeviceIdentityModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService): JwtModuleOptions => ({ + secret: configService.getOrThrow("jwt.accessSecret"), + signOptions: { expiresIn: jwtTtl(configService, "jwt.accessTtl") }, + }), + inject: [ConfigService], + }), + ResendModule, + AfricasTalkingModule, + GoogleOAuthModule, + NotificationModule, + forwardRef(() => VerificationModule), + forwardRef(() => AdminModule), + ], + controllers: [AuthController, InternalAuthController], + providers: [ + AuthService, + AuthSecurityAuditService, + AuthRiskSignalService, + AccountAbuseLinkageService, + { + provide: "AUTH_RISK_SIGNAL_EMITTER", + useExisting: AuthRiskSignalService, + }, + AccessTokenUserService, + LocalStrategy, + JwtAccessStrategy, + JwtRefreshStrategy, + ], + exports: [AuthService], +}) +export class UsersAuthModule {} diff --git a/apps/backend/src/domains/users/auth/auth.service.google.spec.ts b/apps/backend/src/domains/users/auth/auth.service.google.spec.ts new file mode 100644 index 00000000..391fad65 --- /dev/null +++ b/apps/backend/src/domains/users/auth/auth.service.google.spec.ts @@ -0,0 +1,597 @@ +import { BadRequestException, ConflictException } from "@nestjs/common"; +import { AuthProvider, UserRole } from "@prisma/client"; +import * as bcrypt from "bcrypt"; + +import { AuthService } from "./auth.service"; + +jest.mock("bcrypt", () => ({ + compare: jest.fn(), +})); + +describe("AuthService Google OAuth", () => { + const prisma = { + $transaction: jest.fn(), + authProviderAccount: { + findUnique: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + }, + user: { + findFirst: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + create: jest.fn(), + }, + storeProfile: { + findUnique: jest.fn(), + }, + session: { + create: jest.fn(), + }, + }; + const redis = { + get: jest.fn(), + getDel: jest.fn(), + set: jest.fn(), + del: jest.fn(), + }; + const googleOAuthClient = { + getAuthorizationUrl: jest.fn(), + getProfileFromCode: jest.fn(), + }; + const jwtService = { + signAsync: jest.fn(), + }; + const accountAbuseLinkage = { + recordAfterAccountCreation: jest.fn(), + }; + const authSecurityAudit = { + recordAccountCreated: jest.fn(), + recordLoginFailed: jest.fn(), + }; + const deviceIdentity = { + recordSuccessfulLogin: jest.fn(), + }; + + type AuthServiceTestHarness = { + prisma: typeof prisma; + redis: typeof redis; + googleOAuthClient: typeof googleOAuthClient; + jwtService: typeof jwtService; + accessTokenSecret: string; + refreshTokenSecret: string; + googleOAuthStateTtlSeconds: number; + googlePendingOnboardingTtlSeconds: number; + appWebUrl: string; + accountAbuseLinkage: typeof accountAbuseLinkage; + authSecurityAudit: typeof authSecurityAudit; + deviceIdentity: typeof deviceIdentity; + }; + + let service: AuthService; + + beforeEach(() => { + jest.clearAllMocks(); + service = Object.create(AuthService.prototype) as AuthService; + Object.assign(service as unknown as AuthServiceTestHarness, { + prisma, + redis, + googleOAuthClient, + jwtService, + accessTokenSecret: "access-secret", + refreshTokenSecret: "refresh-secret", + googleOAuthStateTtlSeconds: 600, + googlePendingOnboardingTtlSeconds: 900, + appWebUrl: "https://twizrr.test", + accountAbuseLinkage, + authSecurityAudit, + deviceIdentity, + }); + + redis.set.mockResolvedValue(true); + redis.getDel.mockResolvedValue( + JSON.stringify({ redirectTo: "/buyer/orders", createdAt: 1 }), + ); + redis.get.mockResolvedValue( + JSON.stringify({ redirectTo: "/buyer/orders", createdAt: 1 }), + ); + redis.del.mockResolvedValue(1); + authSecurityAudit.recordAccountCreated.mockResolvedValue(undefined); + authSecurityAudit.recordLoginFailed.mockResolvedValue(undefined); + deviceIdentity.recordSuccessfulLogin.mockResolvedValue({ + deviceHashPresent: false, + deviceRecordId: null, + deviceType: null, + lastSeenAt: null, + }); + accountAbuseLinkage.recordAfterAccountCreation.mockResolvedValue(undefined); + googleOAuthClient.getAuthorizationUrl.mockReturnValue( + "https://accounts.google.com/o/oauth2/v2/auth?state=state", + ); + googleOAuthClient.getProfileFromCode.mockResolvedValue({ + sub: "google-sub-1", + email: "buyer@example.com", + emailVerified: true, + name: "Buyer One", + givenName: "Buyer", + familyName: "One", + picture: "https://lh3.googleusercontent.com/avatar", + }); + jwtService.signAsync + .mockResolvedValueOnce("access-token") + .mockResolvedValueOnce("refresh-token"); + prisma.authProviderAccount.findUnique.mockResolvedValue(null); + prisma.authProviderAccount.findFirst.mockResolvedValue(null); + prisma.user.findFirst.mockResolvedValue(null); + prisma.user.findUnique.mockResolvedValue(null); + prisma.storeProfile.findUnique.mockResolvedValue(null); + prisma.session.create.mockResolvedValue({}); + prisma.$transaction.mockImplementation(async (callback) => + callback(prisma), + ); + }); + + it("starts Google OAuth with stored state and rejects unsafe redirects", async () => { + const started = await service.startGoogleOAuth("/buyer/orders"); + + expect(started.authorizationUrl).toContain("accounts.google.com"); + expect(started.state).toEqual(expect.any(String)); + expect(redis.set).toHaveBeenCalledWith( + expect.stringMatching(/^auth:google:state:/), + expect.stringContaining('"/buyer/orders"'), + 600, + true, + ); + await expect( + service.startGoogleOAuth("https://evil.example"), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("rejects callback when state is missing or mismatched", async () => { + await expect( + service.completeGoogleOAuth({ + code: "code", + state: "returned-state", + cookieState: "cookie-state", + metadata: {}, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(googleOAuthClient.getProfileFromCode).not.toHaveBeenCalled(); + }); + + it("logs in an existing linked Google provider without changing phone verification", async () => { + prisma.authProviderAccount.findUnique.mockResolvedValue({ + user: { + id: "user-1", + email: "buyer@example.com", + role: UserRole.USER, + phoneVerified: false, + isActive: true, + deletedAt: null, + storeProfile: null, + }, + }); + + const result = await service.completeGoogleOAuth({ + code: "code", + state: "state", + cookieState: "state", + metadata: { userAgent: "test-agent", ipAddress: "127.0.0.1" }, + }); + + expect(result.kind).toBe("login"); + expect(result).toEqual( + expect.objectContaining({ + accessToken: "access-token", + refreshToken: "refresh-token", + }), + ); + expect(prisma.user.update).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ phoneVerified: true }), + }), + ); + }); + + it("links a verified Google email to an existing user without bypassing onboarding", async () => { + prisma.user.findFirst.mockResolvedValue({ + id: "user-1", + email: "buyer@example.com", + role: UserRole.USER, + emailVerified: false, + phoneVerified: false, + dateOfBirth: null, + storeProfile: null, + }); + + const result = await service.completeGoogleOAuth({ + code: "code", + state: "state", + cookieState: "state", + metadata: {}, + }); + + expect(prisma.authProviderAccount.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: "user-1", + provider: AuthProvider.GOOGLE, + providerUserId: "google-sub-1", + providerEmail: "buyer@example.com", + isProviderEmailVerified: true, + }), + }); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { emailVerified: true }, + }); + expect(prisma.user.update).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ phoneVerified: true }), + }), + ); + expect(result.kind).toBe("login"); + }); + + it("creates a pending onboarding session for new Google users instead of a User", async () => { + const result = await service.completeGoogleOAuth({ + code: "code", + state: "state", + cookieState: "state", + metadata: {}, + }); + + expect(result.kind).toBe("pending_onboarding"); + expect(result.redirectTo).toMatch( + /^\/register\/google\/complete\?session=/, + ); + expect(prisma.user.create).not.toHaveBeenCalled(); + expect(redis.set).toHaveBeenCalledWith( + expect.stringMatching(/^auth:google:onboarding:/), + expect.stringContaining('"givenName":"Buyer"'), + 900, + true, + ); + }); + + it("reads pending Google onboarding and returns safe prefill data only", async () => { + redis.get.mockResolvedValueOnce( + JSON.stringify({ + provider: AuthProvider.GOOGLE, + providerUserId: "google-sub-1", + providerEmail: "buyer@example.com", + isProviderEmailVerified: true, + providerAvatarUrl: "https://lh3.googleusercontent.com/avatar", + prefill: { + email: "buyer@example.com", + name: "Buyer One", + givenName: "Buyer", + familyName: "One", + picture: "https://lh3.googleusercontent.com/avatar", + }, + redirectTo: "/buyer/orders", + createdAt: new Date().toISOString(), + }), + ); + + const result = await service.getGoogleOnboardingSession("pending-session"); + + expect(result).toEqual({ + providerEmail: "buyer@example.com", + providerEmailVerified: true, + firstName: "Buyer", + lastName: "One", + displayName: "Buyer One", + providerAvatarUrl: "https://lh3.googleusercontent.com/avatar", + }); + expect(JSON.stringify(result)).not.toContain("access_token"); + expect(JSON.stringify(result)).not.toContain("refresh_token"); + }); + + it("rejects missing pending Google onboarding sessions", async () => { + redis.get.mockResolvedValueOnce(null); + + await expect( + service.getGoogleOnboardingSession("missing-session"), + ).rejects.toMatchObject({ + response: { code: "GOOGLE_ONBOARDING_SESSION_EXPIRED" }, + }); + }); + + it("completes pending Google onboarding with a User, provider account, and auth cookies", async () => { + redis.getDel.mockResolvedValueOnce( + JSON.stringify({ + provider: AuthProvider.GOOGLE, + providerUserId: "google-sub-1", + providerEmail: "buyer@example.com", + isProviderEmailVerified: true, + providerAvatarUrl: "https://lh3.googleusercontent.com/avatar", + prefill: { + email: "buyer@example.com", + name: "Buyer One", + givenName: "Buyer", + familyName: "One", + picture: "https://lh3.googleusercontent.com/avatar", + }, + redirectTo: "/buyer/orders", + createdAt: new Date().toISOString(), + }), + ); + prisma.user.create.mockResolvedValueOnce({ + id: "user-1", + email: "buyer@example.com", + phone: "+2348012345678", + username: "user483729", + displayName: "Buyer", + dateOfBirth: new Date("1995-05-20T00:00:00.000Z"), + firstName: "Buyer", + lastName: "One", + role: UserRole.USER, + emailVerified: true, + phoneVerified: false, + profilePhotoUrl: "https://lh3.googleusercontent.com/avatar", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }); + + const result = await service.completeGoogleOnboardingSession( + "pending-session", + { + firstName: "Buyer", + lastName: "One", + dateOfBirth: new Date("1995-05-20T00:00:00.000Z"), + phone: "+2348012345678", + }, + { userAgent: "test-agent", ipAddress: "127.0.0.1" }, + ); + + expect(prisma.user.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + email: "buyer@example.com", + emailVerified: true, + phone: "+2348012345678", + phoneVerified: false, + passwordHash: null, + firstName: "Buyer", + lastName: "One", + displayName: "Buyer", + username: expect.stringMatching(/^user\d{6}$/), + profilePhotoUrl: "https://lh3.googleusercontent.com/avatar", + role: UserRole.USER, + }), + }); + expect(prisma.authProviderAccount.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: "user-1", + provider: AuthProvider.GOOGLE, + providerUserId: "google-sub-1", + providerEmail: "buyer@example.com", + isProviderEmailVerified: true, + providerAvatarUrl: "https://lh3.googleusercontent.com/avatar", + }), + }); + expect(prisma.session.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: "user-1", + refreshTokenHash: expect.any(String), + }), + }); + expect(result).toEqual( + expect.objectContaining({ + accessToken: "access-token", + refreshToken: "refresh-token", + redirectTo: "/buyer/orders", + }), + ); + }); + + it("rejects under-13 Google onboarding completion and does not create a User", async () => { + redis.getDel.mockResolvedValueOnce( + JSON.stringify({ + provider: AuthProvider.GOOGLE, + providerUserId: "google-sub-1", + providerEmail: "buyer@example.com", + isProviderEmailVerified: true, + prefill: {}, + redirectTo: "/explore", + createdAt: new Date().toISOString(), + }), + ); + + await expect( + service.completeGoogleOnboardingSession( + "pending-session", + { + firstName: "Buyer", + lastName: "One", + dateOfBirth: new Date("2020-01-01T00:00:00.000Z"), + phone: "+2348012345678", + }, + {}, + ), + ).rejects.toMatchObject({ response: { code: "AGE_RESTRICTED" } }); + + expect(prisma.user.create).not.toHaveBeenCalled(); + expect(prisma.authProviderAccount.create).not.toHaveBeenCalled(); + }); + + it("rejects duplicate phone or email during Google onboarding completion", async () => { + redis.getDel.mockResolvedValue( + JSON.stringify({ + provider: AuthProvider.GOOGLE, + providerUserId: "google-sub-1", + providerEmail: "buyer@example.com", + isProviderEmailVerified: true, + prefill: {}, + redirectTo: "/explore", + createdAt: new Date().toISOString(), + }), + ); + prisma.user.findFirst.mockResolvedValueOnce({ + email: null, + phone: "+2348012345678", + username: null, + }); + + await expect( + service.completeGoogleOnboardingSession( + "pending-session", + { + firstName: "Buyer", + lastName: "One", + dateOfBirth: new Date("1995-05-20T00:00:00.000Z"), + phone: "+2348012345678", + }, + {}, + ), + ).rejects.toMatchObject({ response: { code: "PHONE_TAKEN" } }); + + prisma.user.findFirst.mockResolvedValueOnce({ + email: "buyer@example.com", + phone: null, + username: null, + }); + + await expect( + service.completeGoogleOnboardingSession( + "pending-session", + { + firstName: "Buyer", + lastName: "One", + dateOfBirth: new Date("1995-05-20T00:00:00.000Z"), + phone: "+2348099999999", + }, + {}, + ), + ).rejects.toMatchObject({ response: { code: "EMAIL_TAKEN" } }); + }); + + it("consumes pending Google onboarding sessions so reuse fails", async () => { + redis.getDel + .mockResolvedValueOnce( + JSON.stringify({ + provider: AuthProvider.GOOGLE, + providerUserId: "google-sub-1", + providerEmail: "buyer@example.com", + isProviderEmailVerified: true, + providerAvatarUrl: "https://lh3.googleusercontent.com/avatar", + prefill: {}, + redirectTo: "/explore", + createdAt: new Date().toISOString(), + }), + ) + .mockResolvedValueOnce(null); + prisma.user.create.mockResolvedValueOnce({ + id: "user-1", + email: "buyer@example.com", + phone: "+2348012345678", + username: "user483729", + displayName: "Buyer", + dateOfBirth: new Date("1995-05-20T00:00:00.000Z"), + firstName: "Buyer", + lastName: "One", + role: UserRole.USER, + emailVerified: true, + phoneVerified: false, + profilePhotoUrl: "https://lh3.googleusercontent.com/avatar", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }); + + const firstResult = await service.completeGoogleOnboardingSession( + "pending-session", + { + firstName: "Buyer", + lastName: "One", + dateOfBirth: new Date("1995-05-20T00:00:00.000Z"), + phone: "+2348012345678", + }, + {}, + ); + + expect(firstResult).toEqual( + expect.objectContaining({ + accessToken: "access-token", + refreshToken: "refresh-token", + }), + ); + expect(redis.getDel).toHaveBeenCalledWith( + expect.stringMatching(/^auth:google:onboarding:/), + ); + + await expect( + service.completeGoogleOnboardingSession( + "pending-session", + { + firstName: "Buyer", + lastName: "One", + dateOfBirth: new Date("1995-05-20T00:00:00.000Z"), + phone: "+2348012345678", + }, + {}, + ), + ).rejects.toMatchObject({ + response: { code: "GOOGLE_ONBOARDING_SESSION_EXPIRED" }, + }); + }); + + it("keeps password login safe for provider-only users", async () => { + prisma.user.findFirst.mockResolvedValueOnce({ + id: "user-1", + email: "buyer@example.com", + passwordHash: null, + isActive: true, + deletedAt: null, + storeProfile: null, + }); + + await expect( + service.login({ email: "buyer@example.com", password: "Password1!" }, {}), + ).rejects.toMatchObject({ + response: { code: "AUTH_INVALID_CREDENTIALS" }, + }); + expect(bcrypt.compare).not.toHaveBeenCalled(); + }); + + it("rejects unverified Google email and provider conflicts", async () => { + googleOAuthClient.getProfileFromCode.mockResolvedValueOnce({ + sub: "google-sub-1", + email: "buyer@example.com", + emailVerified: false, + }); + + await expect( + service.completeGoogleOAuth({ + code: "code", + state: "state", + cookieState: "state", + metadata: {}, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + googleOAuthClient.getProfileFromCode.mockResolvedValueOnce({ + sub: "google-sub-2", + email: "buyer@example.com", + emailVerified: true, + }); + prisma.user.findFirst.mockResolvedValueOnce({ + id: "user-1", + email: "buyer@example.com", + role: UserRole.USER, + emailVerified: true, + phoneVerified: true, + storeProfile: null, + }); + prisma.authProviderAccount.findFirst.mockResolvedValueOnce({ + userId: "user-1", + providerUserId: "other-google-sub", + }); + + await expect( + service.completeGoogleOAuth({ + code: "code", + state: "state", + cookieState: "state", + metadata: {}, + }), + ).rejects.toBeInstanceOf(ConflictException); + }); +}); diff --git a/apps/backend/src/domains/users/auth/auth.service.internal.spec.ts b/apps/backend/src/domains/users/auth/auth.service.internal.spec.ts new file mode 100644 index 00000000..d6a1d223 --- /dev/null +++ b/apps/backend/src/domains/users/auth/auth.service.internal.spec.ts @@ -0,0 +1,128 @@ +import { UnauthorizedException } from "@nestjs/common"; +import { UserRole } from "@prisma/client"; +import * as bcrypt from "bcrypt"; + +import { AuthService } from "./auth.service"; + +jest.mock("bcrypt", () => ({ + compare: jest.fn(), + hash: jest.fn(), +})); + +describe("AuthService internal auth", () => { + const prisma = { + user: { + findUnique: jest.fn(), + create: jest.fn(), + }, + session: { + create: jest.fn(), + }, + }; + const jwtService = { + signAsync: jest.fn(), + }; + + type AuthServiceHarness = { + prisma: typeof prisma; + jwtService: typeof jwtService; + accessTokenSecret: string; + refreshTokenSecret: string; + }; + + let service: AuthService; + + beforeEach(() => { + jest.clearAllMocks(); + service = Object.create(AuthService.prototype) as AuthService; + Object.assign(service as unknown as AuthServiceHarness, { + prisma, + jwtService, + accessTokenSecret: "access-secret", + refreshTokenSecret: "refresh-secret", + }); + jwtService.signAsync + .mockResolvedValueOnce("access-token") + .mockResolvedValueOnce("refresh-token"); + prisma.session.create.mockResolvedValue({}); + prisma.user.create.mockResolvedValue({}); + (bcrypt.compare as jest.Mock).mockResolvedValue(true); + (bcrypt.hash as jest.Mock).mockResolvedValue("hashed-password"); + }); + + // Twizrr admin is SUPER_ADMIN-only. Internal login is the only admin auth + // path; there is no self-service admin registration or staff access tokens. + it("logs in an approved SUPER_ADMIN using the domain session token path", async () => { + prisma.user.findUnique.mockResolvedValueOnce({ + id: "admin-1", + email: "admin@twizrr.com", + phone: "+234000123456", + firstName: "Ada", + middleName: null, + lastName: "Admin", + passwordHash: "stored-hash", + role: UserRole.SUPER_ADMIN, + emailVerified: true, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + adminProfile: { id: "admin-profile-1", approvalStatus: "APPROVED" }, + storeProfile: null, + whatsappLink: { id: "wa-1" }, + }); + + const result = await service.internalLogin( + { identifier: "ADMIN@TWIZRR.COM", password: "Password1!" }, + { userAgent: "test-agent", ipAddress: "127.0.0.1" }, + ); + + expect(prisma.user.findUnique).toHaveBeenCalledWith({ + where: { email: "admin@twizrr.com" }, + include: { + adminProfile: true, + storeProfile: { select: { id: true } }, + whatsappLink: true, + }, + }); + expect(jwtService.signAsync).toHaveBeenCalledTimes(2); + expect(prisma.session.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: "admin-1", + userAgent: "test-agent", + ipAddress: "127.0.0.1", + }), + }); + expect(result).toEqual( + expect.objectContaining({ + accessToken: "access-token", + refreshToken: "refresh-token", + user: expect.objectContaining({ + id: "admin-1", + role: UserRole.SUPER_ADMIN, + adminId: "admin-profile-1", + isWhatsAppLinked: true, + }), + }), + ); + }); + + it("rejects regular users from internal login", async () => { + prisma.user.findUnique.mockResolvedValueOnce({ + id: "user-1", + email: "shopper@example.com", + passwordHash: "stored-hash", + role: UserRole.USER, + adminProfile: null, + storeProfile: null, + whatsappLink: null, + }); + + await expect( + service.internalLogin( + { identifier: "shopper@example.com", password: "Password1!" }, + {}, + ), + ).rejects.toBeInstanceOf(UnauthorizedException); + + expect(prisma.session.create).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/domains/users/auth/auth.service.login-security-context.spec.ts b/apps/backend/src/domains/users/auth/auth.service.login-security-context.spec.ts new file mode 100644 index 00000000..6938c0a5 --- /dev/null +++ b/apps/backend/src/domains/users/auth/auth.service.login-security-context.spec.ts @@ -0,0 +1,200 @@ +import { UnauthorizedException } from "@nestjs/common"; +import { UserRole } from "@prisma/client"; +import * as bcrypt from "bcrypt"; + +import { AuthService } from "./auth.service"; +import type { RequestSecurityContext } from "../../../common/security/request-security-context"; + +jest.mock("bcrypt", () => ({ + compare: jest.fn(), +})); + +describe("AuthService login security context", () => { + const prisma = { + user: { + findFirst: jest.fn(), + }, + session: { + create: jest.fn(), + }, + }; + const jwtService = { + signAsync: jest.fn(), + }; + const authSecurityAudit = { + recordLoginSucceeded: jest.fn(), + recordLoginFailed: jest.fn(), + }; + const deviceIdentity = { + recordSuccessfulLogin: jest.fn(), + }; + const accountAbuseLinkage = { + recordAfterSuccessfulLogin: jest.fn(), + }; + + type AuthServiceHarness = { + prisma: typeof prisma; + jwtService: typeof jwtService; + authSecurityAudit: typeof authSecurityAudit; + deviceIdentity: typeof deviceIdentity; + accountAbuseLinkage: typeof accountAbuseLinkage; + accessTokenSecret: string; + refreshTokenSecret: string; + }; + + type LoginWithSecurityContext = { + login: ( + dto: { email: string; password: string }, + metadata: { userAgent?: string; ipAddress?: string }, + securityContext?: RequestSecurityContext, + ) => Promise<{ + accessToken: string; + refreshToken: string; + redirectTo: string; + }>; + }; + + const securityContext: RequestSecurityContext = { + requestId: "req-login-1", + ipAddress: "203.0.113.10", + ipSource: "x-forwarded-for", + userAgent: "Mozilla/5.0", + origin: "https://twizrr.com", + referer: "https://twizrr.com/login", + method: "POST", + path: "/auth/login", + deviceType: "desktop", + isTrustedProxyHeaderUsed: true, + createdAt: new Date("2026-07-04T10:00:00.000Z"), + }; + + let service: AuthService; + + beforeEach(() => { + jest.clearAllMocks(); + service = Object.create(AuthService.prototype) as AuthService; + Object.assign(service as unknown as AuthServiceHarness, { + prisma, + jwtService, + authSecurityAudit, + deviceIdentity, + accountAbuseLinkage, + accessTokenSecret: "access-secret", + refreshTokenSecret: "refresh-secret", + }); + + prisma.session.create.mockResolvedValue({ id: "session-1" }); + jwtService.signAsync + .mockResolvedValueOnce("access-token") + .mockResolvedValueOnce("refresh-token"); + (bcrypt.compare as jest.Mock).mockResolvedValue(true); + authSecurityAudit.recordLoginSucceeded.mockResolvedValue(undefined); + deviceIdentity.recordSuccessfulLogin.mockResolvedValue({ + deviceHashPresent: false, + deviceRecordId: null, + deviceType: "desktop", + lastSeenAt: null, + }); + authSecurityAudit.recordLoginFailed.mockResolvedValue(undefined); + accountAbuseLinkage.recordAfterSuccessfulLogin.mockResolvedValue(undefined); + }); + + it("records successful login security context without changing the API result", async () => { + prisma.user.findFirst.mockResolvedValueOnce({ + id: "user-1", + email: "buyer@example.com", + passwordHash: "stored-password-hash", + role: UserRole.USER, + storeProfile: null, + }); + + const result = await (service as unknown as LoginWithSecurityContext).login( + { email: "buyer@example.com", password: "Password1!" }, + { userAgent: "legacy-agent", ipAddress: "127.0.0.1" }, + securityContext, + ); + + expect(result).toEqual({ + accessToken: "access-token", + refreshToken: "refresh-token", + redirectTo: "/explore", + }); + expect(prisma.session.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: "user-1", + userAgent: "legacy-agent", + ipAddress: "127.0.0.1", + }), + }); + expect(authSecurityAudit.recordLoginSucceeded).toHaveBeenCalledWith({ + userId: "user-1", + sessionId: "session-1", + userRole: "USER", + attemptedIdentifier: "buyer@example.com", + securityContext, + deviceIdentity: { + deviceHashPresent: false, + deviceRecordId: null, + deviceType: "desktop", + lastSeenAt: null, + }, + }); + expect(authSecurityAudit.recordLoginFailed).not.toHaveBeenCalled(); + }); + + it("records failed login security context and preserves the public error", async () => { + prisma.user.findFirst.mockResolvedValueOnce({ + id: "user-1", + email: "buyer@example.com", + passwordHash: "stored-password-hash", + role: UserRole.USER, + storeProfile: null, + }); + (bcrypt.compare as jest.Mock).mockResolvedValueOnce(false); + + await expect( + (service as unknown as LoginWithSecurityContext).login( + { email: "buyer@example.com", password: "wrong-password" }, + { userAgent: "legacy-agent", ipAddress: "127.0.0.1" }, + securityContext, + ), + ).rejects.toMatchObject({ + response: { + message: "Invalid email or password", + code: "AUTH_INVALID_CREDENTIALS", + }, + }); + + expect(authSecurityAudit.recordLoginFailed).toHaveBeenCalledWith({ + userId: "user-1", + attemptedIdentifier: "buyer@example.com", + failureReasonCode: "USER_NOT_FOUND_OR_PASSWORD_INVALID", + securityContext, + }); + expect(authSecurityAudit.recordLoginSucceeded).not.toHaveBeenCalled(); + expect(prisma.session.create).not.toHaveBeenCalled(); + }); + + it("records anonymous failed login context when the user is not found", async () => { + prisma.user.findFirst.mockResolvedValueOnce(null); + + const rejection = await (service as unknown as LoginWithSecurityContext) + .login( + { email: "missing@example.com", password: "Password1!" }, + { userAgent: "legacy-agent", ipAddress: "127.0.0.1" }, + undefined, + ) + .then( + () => null, + (error: unknown) => error, + ); + + expect(rejection).toBeInstanceOf(UnauthorizedException); + expect(authSecurityAudit.recordLoginFailed).toHaveBeenCalledWith({ + userId: null, + attemptedIdentifier: "missing@example.com", + failureReasonCode: "USER_NOT_FOUND_OR_PASSWORD_INVALID", + securityContext: undefined, + }); + }); +}); diff --git a/apps/backend/src/domains/users/auth/auth.service.registration.spec.ts b/apps/backend/src/domains/users/auth/auth.service.registration.spec.ts new file mode 100644 index 00000000..784b5b26 --- /dev/null +++ b/apps/backend/src/domains/users/auth/auth.service.registration.spec.ts @@ -0,0 +1,597 @@ +import { + BadRequestException, + ConflictException, + ServiceUnavailableException, + UnauthorizedException, +} from "@nestjs/common"; +import { OTPPurpose, Prisma, UserRole } from "@prisma/client"; +import * as bcrypt from "bcrypt"; + +import { AuthService } from "./auth.service"; + +jest.mock("bcrypt", () => ({ + hash: jest.fn(), +})); + +describe("AuthService email registration", () => { + const prisma = { + $transaction: jest.fn(), + user: { + findFirst: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + create: jest.fn(), + }, + session: { + create: jest.fn(), + updateMany: jest.fn(), + }, + storeProfile: { + findUnique: jest.fn(), + }, + oTPCode: { + updateMany: jest.fn(), + update: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + }, + }; + const emailProvider = { + sendTransactionalEmail: jest.fn(), + }; + const jwtService = { + signAsync: jest.fn(), + }; + const verificationService = { + tryUpgradeTierForUser: jest.fn(), + }; + const authRiskSignals = { + recordSensitiveAuthAttempt: jest.fn(), + }; + const authSecurityAudit = { recordAccountCreated: jest.fn() }; + const deviceIdentity = { recordSuccessfulLogin: jest.fn() }; + const accountAbuseLinkage = { recordAfterAccountCreation: jest.fn() }; + const logger = { + error: jest.fn(), + warn: jest.fn(), + }; + + type AuthServiceTestHarness = { + prisma: typeof prisma; + emailProvider: typeof emailProvider; + jwtService: typeof jwtService; + verificationService: typeof verificationService; + authRiskSignals: typeof authRiskSignals; + authSecurityAudit: typeof authSecurityAudit; + deviceIdentity: typeof deviceIdentity; + accountAbuseLinkage: typeof accountAbuseLinkage; + otpSecret: string; + accessTokenSecret: string; + refreshTokenSecret: string; + logger: typeof logger; + }; + + const baseDto = { + email: "buyer@example.com", + password: "Password1!", + phone: "+2348012345678", + dateOfBirth: new Date("1995-05-20T00:00:00.000Z"), + firstName: "Ada", + lastName: "Lovelace", + }; + + let service: AuthService; + + beforeEach(() => { + jest.clearAllMocks(); + service = Object.create(AuthService.prototype) as AuthService; + Object.assign(service as unknown as AuthServiceTestHarness, { + prisma, + emailProvider, + jwtService, + verificationService, + authRiskSignals, + authSecurityAudit, + deviceIdentity, + accountAbuseLinkage, + otpSecret: "otp-secret", + accessTokenSecret: "access-secret", + refreshTokenSecret: "refresh-secret", + logger, + }); + + (bcrypt.hash as jest.Mock).mockResolvedValue("hashed-password"); + prisma.user.findFirst.mockResolvedValue(null); + prisma.user.findUnique.mockResolvedValue(null); + prisma.user.create.mockImplementation(({ data }) => + Promise.resolve({ + id: "user-1", + ...data, + emailVerified: false, + phoneVerified: false, + role: UserRole.USER, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }), + ); + prisma.storeProfile.findUnique.mockResolvedValue(null); + prisma.oTPCode.updateMany.mockResolvedValue({ count: 0 }); + prisma.oTPCode.update.mockResolvedValue({}); + prisma.oTPCode.findFirst.mockResolvedValue(null); + prisma.oTPCode.create.mockResolvedValue({}); + prisma.user.update.mockResolvedValue({}); + prisma.session.create.mockResolvedValue({}); + prisma.session.updateMany.mockResolvedValue({ count: 1 }); + prisma.$transaction.mockResolvedValue([]); + jwtService.signAsync + .mockResolvedValueOnce("access-token") + .mockResolvedValueOnce("refresh-token"); + verificationService.tryUpgradeTierForUser.mockResolvedValue(undefined); + authRiskSignals.recordSensitiveAuthAttempt.mockResolvedValue(undefined); + authSecurityAudit.recordAccountCreated.mockResolvedValue(undefined); + deviceIdentity.recordSuccessfulLogin.mockResolvedValue({ + deviceHashPresent: false, + deviceRecordId: null, + deviceType: null, + lastSeenAt: null, + }); + accountAbuseLinkage.recordAfterAccountCreation.mockResolvedValue(undefined); + emailProvider.sendTransactionalEmail.mockResolvedValue({ + provider: "resend", + providerMessageId: null, + accepted: true, + }); + }); + + it("stores submitted first and last names directly without splitting displayName", async () => { + const result = await service.registerEmail({ + ...baseDto, + displayName: "Ada Marie Lovelace", + username: "ada_l", + }); + + expect(prisma.user.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + firstName: "Ada", + lastName: "Lovelace", + displayName: "Ada Marie Lovelace", + username: "ada_l", + emailVerified: false, + phoneVerified: false, + role: UserRole.USER, + }), + }); + expect(result.user).toEqual( + expect.objectContaining({ + emailVerified: false, + phoneVerified: false, + username: "ada_l", + displayName: "Ada Marie Lovelace", + }), + ); + }); + + it("records a safe account-created event without changing the registration response", async () => { + const securityContext = { + requestId: "req-register-1", + ipAddress: "203.0.113.10", + ipSource: "remote-address" as const, + userAgent: "Mozilla/5.0", + origin: "https://twizrr.com", + referer: "https://twizrr.com/register", + method: "POST", + path: "/auth/register/email", + deviceType: "desktop" as const, + geo: null, + deviceIdentity: { deviceIdHash: "hash", source: "header" as const }, + isTrustedProxyHeaderUsed: false, + createdAt: new Date(), + }; + + const result = await service.registerEmail(baseDto, securityContext); + + expect(result).toEqual( + expect.objectContaining({ emailOtpSent: true, user: expect.any(Object) }), + ); + expect(deviceIdentity.recordSuccessfulLogin).toHaveBeenCalledWith( + "user-1", + securityContext, + ); + expect(authSecurityAudit.recordAccountCreated).toHaveBeenCalledWith({ + userId: "user-1", + authProvider: "email", + securityContext, + deviceIdentity: expect.objectContaining({ deviceHashPresent: false }), + }); + }); + + it("defaults displayName to firstName when omitted", async () => { + await service.registerEmail({ + ...baseDto, + username: "ada_l", + }); + + expect(prisma.user.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + displayName: "Ada", + firstName: "Ada", + lastName: "Lovelace", + }), + }); + }); + + it("auto-generates a unique public username when omitted", async () => { + await service.registerEmail(baseDto); + + const createdUser = prisma.user.create.mock.calls[0][0].data; + expect(createdUser.username).toMatch(/^user\d{6}$/); + expect(prisma.user.findUnique).toHaveBeenCalledWith({ + where: { username: createdUser.username }, + select: { id: true }, + }); + expect(prisma.storeProfile.findUnique).toHaveBeenCalledWith({ + where: { storeHandle: createdUser.username }, + select: { id: true }, + }); + }); + + it("preserves provided username uniqueness checks", async () => { + prisma.user.findFirst.mockResolvedValueOnce({ + email: null, + phone: null, + username: "taken_user", + }); + + await expect( + service.registerEmail({ + ...baseDto, + username: "taken_user", + }), + ).rejects.toBeInstanceOf(ConflictException); + + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + it("preserves duplicate email and phone rejection", async () => { + prisma.user.findFirst.mockResolvedValueOnce({ + email: baseDto.email, + phone: null, + username: null, + }); + + await expect( + service.registerEmail({ + ...baseDto, + username: "ada_l", + }), + ).rejects.toMatchObject({ response: { code: "EMAIL_TAKEN" } }); + + prisma.user.findFirst.mockResolvedValueOnce({ + email: null, + phone: baseDto.phone, + username: null, + }); + + await expect( + service.registerEmail({ + ...baseDto, + email: "other@example.com", + username: "ada_l", + }), + ).rejects.toMatchObject({ response: { code: "PHONE_TAKEN" } }); + }); + + it("rejects under-13 registrations", async () => { + await expect( + service.registerEmail({ + ...baseDto, + dateOfBirth: new Date("2020-01-01T00:00:00.000Z"), + username: "ada_l", + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + it("sends email verification and does not require phone verification", async () => { + await service.registerEmail({ + ...baseDto, + username: "ada_l", + }); + + expect(prisma.oTPCode.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + email: baseDto.email, + phone: undefined, + purpose: OTPPurpose.EMAIL_VERIFY, + }), + }); + expect(emailProvider.sendTransactionalEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: { email: baseDto.email }, + template: "OTP_EMAIL_VERIFY", + }), + ); + expect(prisma.user.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + emailVerified: false, + phoneVerified: false, + }), + }); + }); + + it("keeps an unverified registration recoverable when email OTP delivery fails", async () => { + emailProvider.sendTransactionalEmail + .mockRejectedValueOnce(new Error("provider unavailable")) + .mockResolvedValueOnce({}); + + await expect( + service.registerEmail({ + ...baseDto, + username: "ada_l", + }), + ).rejects.toBeInstanceOf(ServiceUnavailableException); + + expect(prisma.user.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + email: baseDto.email, + emailVerified: false, + phoneVerified: false, + }), + }); + expect(prisma.oTPCode.create).toHaveBeenCalledTimes(1); + + prisma.user.findFirst.mockResolvedValueOnce({ + id: "user-1", + email: baseDto.email, + emailVerified: false, + }); + + await expect( + service.resendEmailVerification({ email: baseDto.email }), + ).resolves.toEqual({ + message: + "If this account exists and needs verification, a code has been sent.", + }); + + expect(prisma.oTPCode.updateMany).toHaveBeenLastCalledWith({ + where: { + userId: "user-1", + purpose: OTPPurpose.EMAIL_VERIFY, + consumedAt: null, + }, + data: { consumedAt: expect.any(Date) }, + }); + expect(prisma.oTPCode.create).toHaveBeenCalledTimes(2); + expect(emailProvider.sendTransactionalEmail).toHaveBeenCalledTimes(2); + }); + + it("maps duplicate registration races on email to the existing conflict code", async () => { + prisma.user.create.mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + clientVersion: "test", + meta: { target: ["email"] }, + }), + ); + + await expect( + service.registerEmail({ + ...baseDto, + username: "ada_l", + }), + ).rejects.toMatchObject({ response: { code: "EMAIL_TAKEN" } }); + expect(emailProvider.sendTransactionalEmail).not.toHaveBeenCalled(); + }); + + it("maps duplicate registration races on phone and username to conflict codes", async () => { + prisma.user.create.mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + clientVersion: "test", + meta: { target: ["phone"] }, + }), + ); + + await expect( + service.registerEmail({ + ...baseDto, + username: "ada_l", + }), + ).rejects.toMatchObject({ response: { code: "PHONE_TAKEN" } }); + + prisma.user.create.mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + clientVersion: "test", + meta: { target: ["username"] }, + }), + ); + + await expect( + service.registerEmail({ + ...baseDto, + username: "ada_l", + }), + ).rejects.toMatchObject({ response: { code: "USERNAME_TAKEN" } }); + }); + + it("allows only one concurrent refresh rotation for a single old refresh token", async () => { + jwtService.signAsync + .mockReset() + .mockResolvedValueOnce("access-token-a") + .mockResolvedValueOnce("refresh-token-a") + .mockResolvedValueOnce("access-token-b") + .mockResolvedValueOnce("refresh-token-b"); + prisma.session.updateMany + .mockResolvedValueOnce({ count: 1 }) + .mockResolvedValueOnce({ count: 0 }); + + const refreshUser = { + sessionId: "session-1", + refreshToken: "old-refresh-token", + user: { + id: "user-1", + sub: "user-1", + email: baseDto.email, + role: UserRole.USER, + username: "ada_l", + displayName: "Ada", + isEmailVerified: false, + isPhoneVerified: false, + }, + }; + + const results = await Promise.allSettled([ + service.refresh(refreshUser, { + userAgent: "jest", + ipAddress: "127.0.0.1", + }), + service.refresh(refreshUser, { + userAgent: "jest", + ipAddress: "127.0.0.1", + }), + ]); + + const fulfilled = results.find((result) => result.status === "fulfilled"); + const rejected = results.find((result) => result.status === "rejected"); + + expect( + results.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + expect( + results.filter((result) => result.status === "rejected"), + ).toHaveLength(1); + expect(fulfilled?.status).toBe("fulfilled"); + expect(rejected?.status).toBe("rejected"); + if (fulfilled?.status !== "fulfilled" || rejected?.status !== "rejected") { + throw new Error("Expected one successful and one rejected refresh"); + } + expect(fulfilled.value).toEqual({ + accessToken: "access-token-a", + refreshToken: "refresh-token-a", + }); + expect(rejected.reason).toBeInstanceOf(UnauthorizedException); + expect(rejected.reason).toMatchObject({ + response: { code: "AUTH_REFRESH_INVALID" }, + }); + expect(prisma.session.updateMany).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: expect.objectContaining({ + id: "session-1", + userId: "user-1", + refreshTokenHash: expect.any(String), + revokedAt: null, + expiresAt: { gt: expect.any(Date) }, + }), + data: expect.objectContaining({ + refreshTokenHash: expect.any(String), + userAgent: "jest", + ipAddress: "127.0.0.1", + }), + }), + ); + }); + + it("rejects a refresh when the session is revoked or expired", async () => { + // A revoked/expired session no longer matches the conditional rotation + // predicate ({ revokedAt: null, expiresAt: { gt: now } }), so the atomic + // updateMany affects zero rows and no new tokens are minted. + prisma.session.updateMany.mockReset().mockResolvedValue({ count: 0 }); + jwtService.signAsync.mockReset().mockResolvedValue("signed-token"); + + const refreshUser = { + sessionId: "session-1", + refreshToken: "old-refresh-token", + user: { + id: "user-1", + sub: "user-1", + email: baseDto.email, + role: UserRole.USER, + username: "ada_l", + displayName: "Ada", + isEmailVerified: false, + isPhoneVerified: false, + }, + }; + + const rejection = await service + .refresh(refreshUser, { userAgent: "jest", ipAddress: "127.0.0.1" }) + .then( + () => null, + (error: unknown) => error, + ); + + expect(rejection).toBeInstanceOf(UnauthorizedException); + expect(rejection).toMatchObject({ + response: { code: "AUTH_REFRESH_INVALID" }, + }); + // No replacement session row is created when rotation does not persist. + expect(prisma.session.create).not.toHaveBeenCalled(); + }); + + it("issues auth tokens after successful email verification", async () => { + const code = "123456"; + const user = { + id: "user-1", + email: baseDto.email, + phone: baseDto.phone, + username: "user123456", + displayName: "Ada", + dateOfBirth: baseDto.dateOfBirth, + firstName: "Ada", + lastName: "Lovelace", + passwordHash: "hashed-password", + role: UserRole.USER, + emailVerified: false, + phoneVerified: false, + isActive: true, + deletedAt: null, + storeProfile: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }; + const otpSecret = (service as unknown as AuthServiceTestHarness).otpSecret; + const codeHash = await ( + service as unknown as { + hashOtp: ( + code: string, + purpose: OTPPurpose, + identifier: string, + ) => string; + } + ).hashOtp(code, OTPPurpose.EMAIL_VERIFY, baseDto.email); + + prisma.user.findUnique.mockResolvedValueOnce(user); + prisma.oTPCode.findFirst.mockResolvedValueOnce({ + id: "otp-1", + codeHash, + attempts: 0, + }); + + const result = await service.verifyEmail( + { email: baseDto.email, code }, + { userAgent: "jest", ipAddress: "127.0.0.1" }, + ); + + expect(otpSecret).toBe("otp-secret"); + expect(result).toEqual({ + accessToken: "access-token", + refreshToken: "refresh-token", + verified: true, + redirectTo: "/explore", + }); + expect(jwtService.signAsync).toHaveBeenCalledTimes(2); + expect(prisma.session.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: user.id, + refreshTokenHash: expect.any(String), + userAgent: "jest", + ipAddress: "127.0.0.1", + expiresAt: expect.any(Date), + }), + }); + expect(verificationService.tryUpgradeTierForUser).toHaveBeenCalledWith( + user.id, + ); + }); +}); diff --git a/apps/backend/src/domains/users/auth/auth.service.ts b/apps/backend/src/domains/users/auth/auth.service.ts new file mode 100644 index 00000000..fbb5ab6e --- /dev/null +++ b/apps/backend/src/domains/users/auth/auth.service.ts @@ -0,0 +1,1785 @@ +import { + BadRequestException, + ConflictException, + forwardRef, + Inject, + Injectable, + Logger, + NotFoundException, + ServiceUnavailableException, + UnauthorizedException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { JwtService } from "@nestjs/jwt"; +import { + AuthProvider, + OTPPurpose, + Prisma, + User, + UserRole, +} from "@prisma/client"; +import * as bcrypt from "bcrypt"; +import { + createHash, + createHmac, + randomBytes, + randomInt, + randomUUID, + timingSafeEqual, +} from "crypto"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { RedisService } from "../../../core/redis/redis.service"; +import { AfricasTalkingClient } from "../../../integrations/africastalking/africastalking.client"; +import { GoogleOAuthClient } from "../../../integrations/google-oauth/google-oauth.client"; +import { GoogleOAuthProfile } from "../../../integrations/google-oauth/google-oauth.types"; +import type { RequestSecurityContext } from "../../../common/security/request-security-context"; +import { DeviceIdentityService } from "../../../common/security/device-identity.service"; +import { + EMAIL_PROVIDER, + type EmailProvider, +} from "../../social/email/providers/email-provider.interface"; +import { VerificationService } from "../verification/verification.service"; +import { AuthSecurityAuditService } from "./auth-security-audit.service"; +import { AuthRiskSignalService } from "./auth-risk-signal.service"; +import { AccountAbuseLinkageService } from "./account-abuse-linkage.service"; +import { + AUTH_TTL, + GOOGLE_STATE_COOKIE, + REDIRECT_TARGET, +} from "./auth.constants"; +import { + AccessTokenPayload, + AuthCookieTokens, + RefreshRequestUser, + RefreshTokenPayload, + SessionMetadata, +} from "./auth.types"; +import { InternalLoginDto } from "./dto/internal-login.dto"; +import { LoginDto } from "./dto/login.dto"; +import { ForgotPasswordDto, ResetPasswordDto } from "./dto/password-reset.dto"; +import { RegisterEmailDto } from "./dto/register.dto"; +import { + ResendEmailVerificationDto, + VerifyEmailDto, +} from "./dto/verify-email.dto"; +import { CompleteGoogleOnboardingDto } from "./dto/google-onboarding.dto"; + +const PASSWORD_SALT_ROUNDS = 12; +const OTP_TTL_MINUTES = 15; +const OTP_TTL_MS = OTP_TTL_MINUTES * 60 * 1000; +const MINIMUM_REGISTRATION_AGE = 13; +const MAX_OTP_ATTEMPTS = 5; +const PASSWORD_RESET_TTL_MS = 15 * 60 * 1000; +const GOOGLE_OAUTH_STATE_TTL_SECONDS = 10 * 60; +const GOOGLE_PENDING_ONBOARDING_TTL_SECONDS = 15 * 60; +const GOOGLE_PENDING_ONBOARDING_PATH = "/register/google/complete"; +const AUTO_USERNAME_PREFIX = "user"; +const AUTO_USERNAME_DIGITS = 6; +const AUTO_USERNAME_MAX_ATTEMPTS = 10; +const GENERIC_PASSWORD_RESET_MESSAGE = + "If an account exists for that email, a password reset link has been sent."; +const GENERIC_EMAIL_VERIFICATION_RESEND_MESSAGE = + "If this account exists and needs verification, a code has been sent."; +const REGISTRATION_OTP_DELIVERY_FAILURE_MESSAGE = + "Account created, but verification code could not be sent. Please request a new verification code."; +const OTP_DELIVERY_FAILURE_MESSAGE = + "Verification code could not be sent. Please try again."; + +export interface AuthUserResponse { + id: string; + email: string; + phone: string; + username: string | null; + displayName: string | null; + dateOfBirth: string | null; + role: UserRole; + emailVerified: boolean; + phoneVerified: boolean; + isMinor: boolean; + createdAt: string; +} + +export interface InternalAuthUserResponse { + id: string; + email: string; + phone: string; + firstName: string; + middleName: string | null; + lastName: string; + role: UserRole; + emailVerified: boolean; + isWhatsAppLinked: boolean; + storeId: string | null; + adminId: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface InternalLoginResult extends AuthCookieTokens { + user: InternalAuthUserResponse; +} + +export interface GoogleOAuthStartResult { + authorizationUrl: string; + state: string; + cookieName: string; + cookieMaxAgeMs: number; +} + +export type GoogleOAuthCompleteResult = + | (AuthCookieTokens & { + kind: "login"; + redirectTo: string; + }) + | { + kind: "pending_onboarding"; + redirectTo: string; + pendingSessionId: string; + }; + +export interface GoogleOnboardingSessionResponse { + providerEmail: string; + providerEmailVerified: boolean; + firstName: string | null; + lastName: string | null; + displayName: string | null; + providerAvatarUrl: string | null; +} + +type PendingGoogleOnboardingPayload = { + provider: AuthProvider; + providerUserId: string; + providerEmail: string; + isProviderEmailVerified: boolean; + providerAvatarUrl: string | null; + prefill: { + email?: string | null; + name?: string | null; + givenName?: string | null; + familyName?: string | null; + picture?: string | null; + }; + redirectTo: string; + createdAt: string; +}; + +@Injectable() +export class AuthService { + private readonly logger = new Logger(AuthService.name); + private readonly otpSecret: string; + private readonly accessTokenSecret: string; + private readonly refreshTokenSecret: string; + private readonly appWebUrl: string; + private readonly googleOAuthStateTtlSeconds: number; + private readonly googlePendingOnboardingTtlSeconds: number; + + constructor( + private readonly prisma: PrismaService, + private readonly jwtService: JwtService, + @Inject(EMAIL_PROVIDER) + private readonly emailProvider: EmailProvider, + private readonly africasTalkingClient: AfricasTalkingClient, + private readonly googleOAuthClient: GoogleOAuthClient, + private readonly redis: RedisService, + private readonly authSecurityAudit: AuthSecurityAuditService, + private readonly authRiskSignals: AuthRiskSignalService, + private readonly deviceIdentity: DeviceIdentityService, + private readonly accountAbuseLinkage: AccountAbuseLinkageService, + configService: ConfigService, + @Inject(forwardRef(() => VerificationService)) + private readonly verificationService: VerificationService, + ) { + const configuredSecret = + configService.get("ONBOARDING_OTP_SECRET") || + configService.get("jwt.accessSecret") || + configService.get("JWT_SECRET"); + + if (!configuredSecret?.trim()) { + throw new Error("ONBOARDING_OTP_SECRET or JWT_SECRET is required"); + } + + this.otpSecret = configuredSecret; + this.accessTokenSecret = + configService.getOrThrow("jwt.accessSecret"); + this.refreshTokenSecret = + configService.getOrThrow("jwt.refreshSecret"); + this.appWebUrl = + configService.get("app.webUrl") || + configService.get("WEB_URL") || + "http://localhost:3000"; + this.googleOAuthStateTtlSeconds = this.readPositiveIntegerConfig( + configService, + "GOOGLE_OAUTH_STATE_TTL_SECONDS", + GOOGLE_OAUTH_STATE_TTL_SECONDS, + ); + this.googlePendingOnboardingTtlSeconds = this.readPositiveIntegerConfig( + configService, + "GOOGLE_PENDING_ONBOARDING_TTL_SECONDS", + GOOGLE_PENDING_ONBOARDING_TTL_SECONDS, + ); + } + + async startGoogleOAuth(redirectTo?: string): Promise { + const safeRedirect = this.normalizeRedirectPath(redirectTo); + const state = randomBytes(32).toString("base64url"); + const stateStored = await this.redis.set( + this.googleStateKey(state), + JSON.stringify({ + redirectTo: safeRedirect, + createdAt: Date.now(), + }), + this.googleOAuthStateTtlSeconds, + true, + ); + + if (!stateStored) { + throw new ServiceUnavailableException({ + message: "Google sign-in could not be started", + code: "GOOGLE_OAUTH_STATE_NOT_STORED", + }); + } + + return { + authorizationUrl: this.googleOAuthClient.getAuthorizationUrl(state), + state, + cookieName: GOOGLE_STATE_COOKIE, + cookieMaxAgeMs: this.googleOAuthStateTtlSeconds * 1000, + }; + } + + async completeGoogleOAuth(params: { + code?: string; + state?: string; + cookieState?: string; + metadata: SessionMetadata; + }): Promise { + const statePayload = await this.consumeGoogleOAuthState( + params.state, + params.cookieState, + ); + + if (!params.code) { + throw new BadRequestException({ + message: "Google sign-in code is required", + code: "GOOGLE_OAUTH_CODE_REQUIRED", + }); + } + + const profile = await this.googleOAuthClient.getProfileFromCode( + params.code, + ); + + if (!profile.emailVerified) { + throw new BadRequestException({ + message: "Google email must be verified to continue", + code: "GOOGLE_EMAIL_UNVERIFIED", + }); + } + + const linkedAccount = await this.prisma.authProviderAccount.findUnique({ + where: { + provider_providerUserId: { + provider: AuthProvider.GOOGLE, + providerUserId: profile.sub, + }, + }, + include: { + user: { + include: { + storeProfile: { + select: { id: true }, + }, + }, + }, + }, + }); + + if (linkedAccount?.user) { + if (!linkedAccount.user.isActive || linkedAccount.user.deletedAt) { + throw new UnauthorizedException({ + message: "Google sign-in is not available for this account", + code: "GOOGLE_ACCOUNT_INACTIVE", + }); + } + + return this.loginGoogleUser(linkedAccount.user, params.metadata); + } + + const existingUser = await this.prisma.user.findFirst({ + where: { + email: profile.email, + isActive: true, + deletedAt: null, + }, + include: { + storeProfile: { + select: { id: true }, + }, + }, + }); + + if (!existingUser) { + return this.createPendingGoogleOnboardingSession( + profile, + statePayload.redirectTo, + ); + } + + const existingGoogleLink = await this.prisma.authProviderAccount.findFirst({ + where: { + userId: existingUser.id, + provider: AuthProvider.GOOGLE, + }, + select: { + providerUserId: true, + userId: true, + }, + }); + + if ( + existingGoogleLink && + existingGoogleLink.providerUserId !== profile.sub + ) { + throw new ConflictException({ + message: "This account is already linked to a different Google profile", + code: "GOOGLE_PROVIDER_CONFLICT", + }); + } + + if (!existingGoogleLink) { + await this.prisma.authProviderAccount.create({ + data: this.toGoogleProviderAccountCreateInput(existingUser.id, profile), + }); + } + + if (!existingUser.emailVerified) { + await this.prisma.user.update({ + where: { id: existingUser.id }, + data: { emailVerified: true }, + }); + } + + return this.loginGoogleUser(existingUser, params.metadata); + } + + async getGoogleOnboardingSession( + sessionId: string, + ): Promise { + const pending = await this.readPendingGoogleOnboardingSession(sessionId); + return this.toGoogleOnboardingSessionResponse(pending); + } + + async completeGoogleOnboardingSession( + sessionId: string, + dto: CompleteGoogleOnboardingDto, + metadata: SessionMetadata, + securityContext?: RequestSecurityContext, + ): Promise { + const pending = await this.consumePendingGoogleOnboardingSession(sessionId); + + if (!pending.isProviderEmailVerified) { + throw new BadRequestException({ + message: "Google email must be verified to continue", + code: "GOOGLE_EMAIL_UNVERIFIED", + }); + } + + this.assertAgeAllowed(dto.dateOfBirth); + + await this.assertUniqueRegistrationFields( + pending.providerEmail, + dto.phone, + dto.username, + ); + + const username = dto.username ?? (await this.generateUniqueUsername()); + const displayName = dto.displayName || dto.firstName; + + let user: User; + + try { + user = await this.prisma.$transaction(async (tx) => { + const createdUser = await tx.user.create({ + data: { + email: pending.providerEmail, + phone: dto.phone, + username, + displayName, + dateOfBirth: dto.dateOfBirth, + firstName: dto.firstName, + lastName: dto.lastName, + passwordHash: null, + profilePhotoUrl: pending.providerAvatarUrl || undefined, + role: UserRole.USER, + emailVerified: true, + phoneVerified: false, + }, + }); + + await tx.authProviderAccount.create({ + data: { + userId: createdUser.id, + provider: AuthProvider.GOOGLE, + providerUserId: pending.providerUserId, + providerEmail: pending.providerEmail, + isProviderEmailVerified: true, + providerAvatarUrl: pending.providerAvatarUrl, + }, + }); + + return createdUser; + }); + } catch (error) { + this.handleRegistrationCreateError(error); + } + + const tokens = await this.createSessionTokens({ + user: { + id: user.id, + email: user.email, + role: user.role, + }, + metadata, + afterSessionCreated: async () => { + const deviceIdentity = await this.deviceIdentity.recordSuccessfulLogin( + user.id, + securityContext, + ); + await this.authSecurityAudit.recordAccountCreated({ + userId: user.id, + authProvider: "google", + securityContext, + deviceIdentity, + }); + await this.accountAbuseLinkage.recordAfterAccountCreation({ + userId: user.id, + securityContext, + deviceIdentity, + }); + }, + }); + + return { + ...tokens, + redirectTo: this.normalizeRedirectPath(pending.redirectTo), + }; + } + + async registerEmail( + dto: RegisterEmailDto, + securityContext?: RequestSecurityContext, + ): Promise<{ user: AuthUserResponse; emailOtpSent: boolean }> { + this.assertAgeAllowed(dto.dateOfBirth); + + await this.assertUniqueRegistrationFields( + dto.email, + dto.phone, + dto.username, + ); + + const passwordHash = await bcrypt.hash(dto.password, PASSWORD_SALT_ROUNDS); + const username = dto.username ?? (await this.generateUniqueUsername()); + const displayName = dto.displayName || dto.firstName; + + let user: User; + + try { + user = await this.prisma.user.create({ + data: { + email: dto.email, + phone: dto.phone, + username, + displayName, + dateOfBirth: dto.dateOfBirth, + firstName: dto.firstName, + lastName: dto.lastName, + passwordHash, + role: UserRole.USER, + emailVerified: false, + phoneVerified: false, + }, + }); + } catch (error) { + this.handleRegistrationCreateError(error); + } + + await this.issueOtp({ + userId: user.id, + email: user.email, + purpose: OTPPurpose.EMAIL_VERIFY, + send: (code) => this.sendEmailOtp(user.email, code), + failureMessage: REGISTRATION_OTP_DELIVERY_FAILURE_MESSAGE, + }); + + const deviceIdentity = await this.deviceIdentity.recordSuccessfulLogin( + user.id, + securityContext, + ); + await this.authSecurityAudit.recordAccountCreated({ + userId: user.id, + authProvider: "email", + securityContext, + deviceIdentity, + }); + await this.accountAbuseLinkage.recordAfterAccountCreation({ + userId: user.id, + securityContext, + deviceIdentity, + }); + + return { + user: this.toAuthUserResponse(user), + emailOtpSent: true, + }; + } + + async verifyEmail( + dto: VerifyEmailDto, + metadata: SessionMetadata, + securityContext?: RequestSecurityContext, + ): Promise { + const user = await this.prisma.user.findUnique({ + where: { email: dto.email }, + include: { + storeProfile: { + select: { id: true }, + }, + }, + }); + + if (!user) { + throw new NotFoundException({ + message: "User not found", + code: "USER_NOT_FOUND", + }); + } + + const otp = await this.findActiveOtp({ + userId: user.id, + email: dto.email, + purpose: OTPPurpose.EMAIL_VERIFY, + }); + + if ( + !this.isOtpMatch( + otp.codeHash, + dto.code, + OTPPurpose.EMAIL_VERIFY, + dto.email, + ) + ) { + await this.recordFailedOtpAttempt(otp.id, otp.attempts); + await this.authRiskSignals.recordSensitiveAuthAttempt({ + signalCode: "REPEATED_OTP_VERIFY_FAILURES", + flow: "email_otp", + userId: user.id, + attemptedIdentifier: dto.email, + securityContext, + }); + throw new BadRequestException({ + message: "Invalid verification code", + code: "OTP_INVALID", + }); + } + + const now = new Date(); + await this.prisma.$transaction([ + this.prisma.oTPCode.update({ + where: { id: otp.id }, + data: { consumedAt: now }, + }), + this.prisma.user.update({ + where: { id: user.id }, + data: { emailVerified: true }, + }), + ]); + + // Best-effort Tier 1 re-evaluation. The store may now satisfy all + // Tier 1 conditions (email + phone + bank) — checkAndUpgradeTier + // is idempotent so triggering it here is safe. + void this.verificationService + .tryUpgradeTierForUser(user.id) + .catch((error) => { + const message = + error instanceof Error ? error.message : "unknown error"; + this.logger.warn( + `Tier upgrade check after email verification failed: ${message}`, + ); + }); + + const tokens = await this.createSessionTokens({ + user: { + id: user.id, + email: user.email, + role: user.role, + storeId: user.storeProfile?.id, + }, + metadata, + }); + + return { + ...tokens, + verified: true, + redirectTo: user.storeProfile + ? REDIRECT_TARGET.STORE_DASHBOARD + : REDIRECT_TARGET.EXPLORE, + }; + } + + async resendEmailVerification( + dto: ResendEmailVerificationDto, + securityContext?: RequestSecurityContext, + ): Promise<{ message: string }> { + await this.authRiskSignals.recordSensitiveAuthAttempt({ + signalCode: "REPEATED_OTP_RESENDS", + flow: "email_otp", + attemptedIdentifier: dto.email, + securityContext, + }); + const user = await this.prisma.user.findFirst({ + where: { + email: dto.email, + isActive: true, + deletedAt: null, + }, + select: { + id: true, + email: true, + emailVerified: true, + }, + }); + + if (!user || user.emailVerified) { + return { message: GENERIC_EMAIL_VERIFICATION_RESEND_MESSAGE }; + } + + await this.issueOtp({ + userId: user.id, + email: user.email, + purpose: OTPPurpose.EMAIL_VERIFY, + send: (code) => this.sendEmailOtp(user.email, code), + failureMessage: OTP_DELIVERY_FAILURE_MESSAGE, + }); + + return { message: GENERIC_EMAIL_VERIFICATION_RESEND_MESSAGE }; + } + + // Phone verification is session-bound: the phone is always the one on the + // authenticated user's record, never a client-supplied value. This prevents a + // tampered request from issuing or verifying an OTP against another account's + // phone. The controller supplies userId from CurrentUser. + async sendPhoneOtp(userId: string): Promise<{ sent: true }> { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundException({ + message: "User not found", + code: "USER_NOT_FOUND", + }); + } + + if (!user.phone) { + throw new BadRequestException({ + message: "Add a phone number to your account before verifying it", + code: "PHONE_NOT_SET", + }); + } + + // Already verified — idempotent, don't send another SMS. + if (user.phoneVerified) { + return { sent: true }; + } + + const phone = user.phone; + await this.issueOtp({ + userId: user.id, + phone, + purpose: OTPPurpose.PHONE_VERIFY, + send: (code) => this.sendSmsOtp(phone, code), + failureMessage: OTP_DELIVERY_FAILURE_MESSAGE, + }); + + return { sent: true }; + } + + async verifyPhoneOtp( + userId: string, + code: string, + securityContext?: RequestSecurityContext, + ): Promise<{ verified: true }> { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundException({ + message: "User not found", + code: "USER_NOT_FOUND", + }); + } + + if (!user.phone) { + throw new BadRequestException({ + message: "Add a phone number to your account before verifying it", + code: "PHONE_NOT_SET", + }); + } + + const phone = user.phone; + const otp = await this.findActiveOtp({ + userId: user.id, + phone, + purpose: OTPPurpose.PHONE_VERIFY, + }); + + if (!this.isOtpMatch(otp.codeHash, code, OTPPurpose.PHONE_VERIFY, phone)) { + await this.recordFailedOtpAttempt(otp.id, otp.attempts); + await this.authRiskSignals.recordSensitiveAuthAttempt({ + signalCode: "REPEATED_OTP_VERIFY_FAILURES", + flow: "phone_otp", + userId: user.id, + securityContext, + }); + throw new BadRequestException({ + message: "Invalid verification code", + code: "OTP_INVALID", + }); + } + + const now = new Date(); + await this.prisma.$transaction([ + this.prisma.oTPCode.update({ + where: { id: otp.id }, + data: { consumedAt: now }, + }), + this.prisma.user.update({ + where: { id: user.id }, + data: { phoneVerified: true }, + }), + // If this user's store business phone is the same number and still + // unverified, one OTP verifies both. The WHERE guards the match, so a + // differing business phone is never touched. + this.prisma.storeProfile.updateMany({ + where: { + userId: user.id, + businessPhone: phone, + businessPhoneVerified: false, + }, + data: { businessPhoneVerified: true, businessPhoneVerifiedAt: now }, + }), + ]); + + // Best-effort Tier 1 re-evaluation after phone verification. + void this.verificationService + .tryUpgradeTierForUser(user.id) + .catch((error) => { + const message = + error instanceof Error ? error.message : "unknown error"; + this.logger.warn( + `Tier upgrade check after phone verification failed: ${message}`, + ); + }); + + return { verified: true }; + } + + async resendPhoneOtp( + userId: string, + securityContext?: RequestSecurityContext, + ): Promise<{ sent: true }> { + await this.authRiskSignals.recordSensitiveAuthAttempt({ + signalCode: "REPEATED_OTP_RESENDS", + flow: "phone_otp", + userId, + securityContext, + }); + return this.sendPhoneOtp(userId); + } + + async login( + dto: LoginDto, + metadata: SessionMetadata, + securityContext?: RequestSecurityContext, + ): Promise { + const user = await this.prisma.user.findFirst({ + where: { + email: dto.email, + isActive: true, + deletedAt: null, + }, + include: { + storeProfile: { + select: { id: true }, + }, + }, + }); + + if ( + !user || + !user.passwordHash || + !(await bcrypt.compare(dto.password, user.passwordHash)) + ) { + await this.authSecurityAudit.recordLoginFailed({ + userId: user?.id ?? null, + attemptedIdentifier: dto.email, + failureReasonCode: "USER_NOT_FOUND_OR_PASSWORD_INVALID", + securityContext, + }); + throw new UnauthorizedException({ + message: "Invalid email or password", + code: "AUTH_INVALID_CREDENTIALS", + }); + } + + const tokens = await this.createSessionTokens({ + user: { + id: user.id, + email: user.email, + role: user.role, + storeId: user.storeProfile?.id, + }, + metadata, + afterSessionCreated: async (sessionId) => { + const deviceIdentity = await this.deviceIdentity.recordSuccessfulLogin( + user.id, + securityContext, + ); + await this.authSecurityAudit.recordLoginSucceeded({ + userId: user.id, + sessionId, + userRole: user.role, + attemptedIdentifier: dto.email, + securityContext, + deviceIdentity, + }); + await this.accountAbuseLinkage.recordAfterSuccessfulLogin({ + userId: user.id, + securityContext, + deviceIdentity, + }); + }, + }); + + return { + ...tokens, + redirectTo: user.storeProfile + ? REDIRECT_TARGET.STORE_DASHBOARD + : REDIRECT_TARGET.EXPLORE, + }; + } + + async internalLogin( + dto: InternalLoginDto, + metadata: SessionMetadata, + ): Promise { + const user = await this.prisma.user.findUnique({ + where: { email: dto.identifier.toLowerCase() }, + include: { + adminProfile: true, + storeProfile: { select: { id: true } }, + whatsappLink: true, + }, + }); + + if ( + !user || + !user.passwordHash || + !(await bcrypt.compare(dto.password, user.passwordHash)) + ) { + throw new UnauthorizedException({ + message: "Invalid email or password", + code: "AUTH_INVALID_CREDENTIALS", + }); + } + + if (user.role === UserRole.USER) { + throw new UnauthorizedException({ + message: "Invalid email or password", + code: "AUTH_INVALID_CREDENTIALS", + }); + } + + if (user.adminProfile?.approvalStatus === "PENDING") { + throw new UnauthorizedException({ + message: "Your account is awaiting Super Admin approval.", + code: "STAFF_APPROVAL_PENDING", + }); + } + + if (user.adminProfile?.approvalStatus === "SUSPENDED") { + throw new UnauthorizedException({ + message: "Your account has been suspended.", + code: "STAFF_SUSPENDED", + }); + } + + const tokens = await this.createSessionTokens({ + user: { + id: user.id, + email: user.email, + role: user.role, + storeId: user.storeProfile?.id, + }, + metadata, + }); + + return { + ...tokens, + user: this.toInternalAuthUserResponse(user), + }; + } + + async refresh( + refreshUser: RefreshRequestUser, + metadata: SessionMetadata, + ): Promise { + const oldRefreshTokenHash = this.hashToken(refreshUser.refreshToken); + const tokens = await this.signTokenPair({ + sub: refreshUser.user.id, + email: refreshUser.user.email, + role: refreshUser.user.role, + storeId: refreshUser.user.storeId, + }); + const now = new Date(); + + const rotation = await this.prisma.session.updateMany({ + where: { + id: refreshUser.sessionId, + userId: refreshUser.user.id, + refreshTokenHash: oldRefreshTokenHash, + revokedAt: null, + expiresAt: { gt: now }, + }, + data: { + refreshTokenHash: this.hashToken(tokens.refreshToken), + expiresAt: new Date(Date.now() + AUTH_TTL.REFRESH_COOKIE_MS), + userAgent: metadata.userAgent, + ipAddress: metadata.ipAddress, + }, + }); + + if (rotation.count !== 1) { + throw new UnauthorizedException({ + message: "Invalid refresh token", + code: "AUTH_REFRESH_INVALID", + }); + } + + return tokens; + } + + async logout(refreshToken: string | undefined): Promise<{ loggedOut: true }> { + if (!refreshToken) { + return { loggedOut: true }; + } + + await this.prisma.session.updateMany({ + where: { + refreshTokenHash: this.hashToken(refreshToken), + revokedAt: null, + }, + data: { revokedAt: new Date() }, + }); + + return { loggedOut: true }; + } + + async forgotPassword( + dto: ForgotPasswordDto, + securityContext?: RequestSecurityContext, + ): Promise<{ message: string }> { + await this.authRiskSignals.recordSensitiveAuthAttempt({ + signalCode: "REPEATED_PASSWORD_RESET_REQUESTS", + flow: "password_reset", + attemptedIdentifier: dto.email, + securityContext, + }); + const user = await this.prisma.user.findFirst({ + where: { + email: dto.email, + isActive: true, + deletedAt: null, + }, + select: { id: true, email: true }, + }); + + if (!user) { + return { message: GENERIC_PASSWORD_RESET_MESSAGE }; + } + + const resetToken = randomBytes(32).toString("hex"); + const resetUrl = `${this.appWebUrl}/reset-password?token=${resetToken}`; + + await this.prisma.user.update({ + where: { id: user.id }, + data: { + resetToken: this.hashToken(resetToken), + resetTokenExpiry: new Date(Date.now() + PASSWORD_RESET_TTL_MS), + }, + }); + + await this.emailProvider.sendTransactionalEmail({ + to: { email: user.email }, + template: "OTP_PASSWORD_RESET", + variables: { resetUrl, ttlMinutes: 15 }, + }); + + return { message: GENERIC_PASSWORD_RESET_MESSAGE }; + } + + async resetPassword( + dto: ResetPasswordDto, + securityContext?: RequestSecurityContext, + ): Promise<{ reset: true }> { + const user = await this.prisma.user.findFirst({ + where: { + resetToken: this.hashToken(dto.token), + resetTokenExpiry: { gt: new Date() }, + isActive: true, + deletedAt: null, + }, + select: { id: true }, + }); + + if (!user) { + await this.authRiskSignals.recordSensitiveAuthAttempt({ + signalCode: "AUTH_ABUSE_PATTERN_DETECTED", + flow: "password_reset", + securityContext, + }); + throw new BadRequestException({ + message: "Invalid or expired password reset token", + code: "PASSWORD_RESET_INVALID", + }); + } + + const passwordHash = await bcrypt.hash( + dto.newPassword, + PASSWORD_SALT_ROUNDS, + ); + + await this.prisma.$transaction([ + this.prisma.user.update({ + where: { id: user.id }, + data: { + passwordHash, + resetToken: null, + resetTokenExpiry: null, + }, + }), + this.prisma.session.updateMany({ + where: { + userId: user.id, + revokedAt: null, + }, + data: { revokedAt: new Date() }, + }), + ]); + + return { reset: true }; + } + + private async consumeGoogleOAuthState( + state: string | undefined, + cookieState: string | undefined, + ): Promise<{ redirectTo: string }> { + if (!state || !cookieState || state !== cookieState) { + throw new BadRequestException({ + message: "Google sign-in state is invalid", + code: "GOOGLE_OAUTH_STATE_INVALID", + }); + } + + const key = this.googleStateKey(state); + const rawState = await this.redis.getDel(key); + + if (!rawState) { + throw new BadRequestException({ + message: "Google sign-in state has expired", + code: "GOOGLE_OAUTH_STATE_EXPIRED", + }); + } + + try { + const parsed = JSON.parse(rawState) as { redirectTo?: unknown }; + return { + redirectTo: this.normalizeRedirectPath( + typeof parsed.redirectTo === "string" ? parsed.redirectTo : undefined, + ), + }; + } catch { + throw new BadRequestException({ + message: "Google sign-in state is invalid", + code: "GOOGLE_OAUTH_STATE_INVALID", + }); + } + } + + private async createPendingGoogleOnboardingSession( + profile: GoogleOAuthProfile, + redirectTo: string, + ): Promise { + const pendingSessionId = randomBytes(32).toString("base64url"); + const pendingPayload = { + provider: AuthProvider.GOOGLE, + providerUserId: profile.sub, + providerEmail: profile.email, + isProviderEmailVerified: profile.emailVerified, + providerAvatarUrl: profile.picture || null, + prefill: { + email: profile.email, + name: profile.name || null, + givenName: profile.givenName || null, + familyName: profile.familyName || null, + picture: profile.picture || null, + }, + redirectTo, + createdAt: new Date().toISOString(), + }; + + const stored = await this.redis.set( + this.googlePendingOnboardingKey(pendingSessionId), + JSON.stringify(pendingPayload), + this.googlePendingOnboardingTtlSeconds, + true, + ); + + if (!stored) { + throw new ServiceUnavailableException({ + message: "Google onboarding session could not be created", + code: "GOOGLE_ONBOARDING_SESSION_NOT_STORED", + }); + } + + return { + kind: "pending_onboarding", + pendingSessionId, + redirectTo: `${GOOGLE_PENDING_ONBOARDING_PATH}?session=${encodeURIComponent( + pendingSessionId, + )}`, + }; + } + + private async readPendingGoogleOnboardingSession( + sessionId: string, + ): Promise { + const rawSession = await this.redis.get( + this.googlePendingOnboardingKey(sessionId), + ); + return this.parsePendingGoogleOnboardingSession(rawSession); + } + + private async consumePendingGoogleOnboardingSession( + sessionId: string, + ): Promise { + const rawSession = await this.redis.getDel( + this.googlePendingOnboardingKey(sessionId), + ); + return this.parsePendingGoogleOnboardingSession(rawSession); + } + + private parsePendingGoogleOnboardingSession( + rawSession: string | null, + ): PendingGoogleOnboardingPayload { + if (!rawSession) { + throw new BadRequestException({ + message: "Google onboarding session has expired", + code: "GOOGLE_ONBOARDING_SESSION_EXPIRED", + }); + } + + try { + const parsed = JSON.parse( + rawSession, + ) as Partial; + + if ( + parsed.provider !== AuthProvider.GOOGLE || + typeof parsed.providerUserId !== "string" || + typeof parsed.providerEmail !== "string" || + typeof parsed.isProviderEmailVerified !== "boolean" + ) { + throw new Error("invalid pending Google onboarding payload"); + } + + return { + provider: AuthProvider.GOOGLE, + providerUserId: parsed.providerUserId, + providerEmail: parsed.providerEmail, + isProviderEmailVerified: parsed.isProviderEmailVerified, + providerAvatarUrl: + typeof parsed.providerAvatarUrl === "string" + ? parsed.providerAvatarUrl + : null, + prefill: + parsed.prefill && typeof parsed.prefill === "object" + ? parsed.prefill + : {}, + redirectTo: this.normalizeRedirectPath( + typeof parsed.redirectTo === "string" ? parsed.redirectTo : undefined, + ), + createdAt: + typeof parsed.createdAt === "string" + ? parsed.createdAt + : new Date().toISOString(), + }; + } catch { + throw new BadRequestException({ + message: "Google onboarding session is invalid", + code: "GOOGLE_ONBOARDING_SESSION_INVALID", + }); + } + } + + private toGoogleOnboardingSessionResponse( + pending: PendingGoogleOnboardingPayload, + ): GoogleOnboardingSessionResponse { + return { + providerEmail: pending.providerEmail, + providerEmailVerified: pending.isProviderEmailVerified, + firstName: pending.prefill.givenName || null, + lastName: pending.prefill.familyName || null, + displayName: pending.prefill.name || null, + providerAvatarUrl: pending.providerAvatarUrl, + }; + } + + private toGoogleProviderAccountCreateInput( + userId: string, + profile: GoogleOAuthProfile, + ): Prisma.AuthProviderAccountUncheckedCreateInput { + return { + userId, + provider: AuthProvider.GOOGLE, + providerUserId: profile.sub, + providerEmail: profile.email, + isProviderEmailVerified: profile.emailVerified, + providerAvatarUrl: profile.picture, + }; + } + + private async loginGoogleUser( + user: User & { storeProfile?: { id: string } | null }, + metadata: SessionMetadata, + ): Promise { + const tokens = await this.createSessionTokens({ + user: { + id: user.id, + email: user.email, + role: user.role, + storeId: user.storeProfile?.id, + }, + metadata, + }); + + return { + kind: "login", + ...tokens, + redirectTo: user.storeProfile + ? REDIRECT_TARGET.STORE_DASHBOARD + : REDIRECT_TARGET.EXPLORE, + }; + } + + private normalizeRedirectPath(redirectTo: string | undefined): string { + if (!redirectTo) { + return REDIRECT_TARGET.EXPLORE; + } + + if ( + !redirectTo.startsWith("/") || + redirectTo.startsWith("//") || + redirectTo.includes("\\") + ) { + throw new BadRequestException({ + message: "Redirect path is not allowed", + code: "AUTH_REDIRECT_UNSAFE", + }); + } + + return redirectTo; + } + + private googleStateKey(state: string): string { + return `auth:google:state:${this.hashToken(state)}`; + } + + private googlePendingOnboardingKey(sessionId: string): string { + return `auth:google:onboarding:${this.hashToken(sessionId)}`; + } + + private readPositiveIntegerConfig( + configService: ConfigService, + key: string, + fallback: number, + ): number { + const value = configService.get(key); + const parsed = + typeof value === "number" ? value : Number.parseInt(value || "", 10); + + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; + } + + private async assertUniqueRegistrationFields( + email: string, + phone: string, + username?: string, + ): Promise { + const uniqueFieldChecks: Prisma.UserWhereInput[] = [{ email }, { phone }]; + if (username) { + uniqueFieldChecks.push({ username }); + } + + const existingUser = await this.prisma.user.findFirst({ + where: { + OR: uniqueFieldChecks, + }, + select: { + email: true, + phone: true, + username: true, + }, + }); + + if (existingUser?.email === email) { + throw new ConflictException({ + message: "Email is already registered", + code: "EMAIL_TAKEN", + }); + } + + if (existingUser?.phone === phone) { + throw new ConflictException({ + message: "Phone number is already registered", + code: "PHONE_TAKEN", + }); + } + + if (username && existingUser?.username === username) { + throw new ConflictException({ + message: "Username is already taken", + code: "USERNAME_TAKEN", + }); + } + + if (username) { + const existingStoreHandle = await this.prisma.storeProfile.findUnique({ + where: { storeHandle: username }, + select: { id: true }, + }); + + if (existingStoreHandle) { + throw new ConflictException({ + message: "Username is already taken", + code: "USERNAME_TAKEN", + }); + } + } + } + + private async generateUniqueUsername(): Promise { + for (let attempt = 0; attempt < AUTO_USERNAME_MAX_ATTEMPTS; attempt += 1) { + const suffix = randomInt(0, 10 ** AUTO_USERNAME_DIGITS) + .toString() + .padStart(AUTO_USERNAME_DIGITS, "0"); + const username = `${AUTO_USERNAME_PREFIX}${suffix}`; + + if (await this.isUsernameAvailable(username)) { + return username; + } + } + + throw new ServiceUnavailableException({ + message: "Username could not be generated. Please choose one.", + code: "USERNAME_GENERATION_FAILED", + }); + } + + private async isUsernameAvailable(username: string): Promise { + const [existingUser, existingStoreHandle] = await Promise.all([ + this.prisma.user.findUnique({ + where: { username }, + select: { id: true }, + }), + this.prisma.storeProfile.findUnique({ + where: { storeHandle: username }, + select: { id: true }, + }), + ]); + + return !existingUser && !existingStoreHandle; + } + + private toInternalAuthUserResponse(user: { + id: string; + email: string; + phone: string; + firstName: string; + middleName: string | null; + lastName: string; + role: UserRole; + emailVerified: boolean; + createdAt: Date; + updatedAt: Date; + storeProfile?: { id: string } | null; + adminProfile?: { id: string } | null; + whatsappLink?: unknown | null; + }): InternalAuthUserResponse { + return { + id: user.id, + email: user.email, + phone: user.phone, + firstName: user.firstName, + middleName: user.middleName, + lastName: user.lastName, + role: user.role, + emailVerified: user.emailVerified, + isWhatsAppLinked: !!user.whatsappLink, + storeId: user.storeProfile?.id ?? null, + adminId: user.adminProfile?.id ?? null, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; + } + + private async generateUniqueInternalPhonePlaceholder(): Promise { + for (let attempt = 0; attempt < 10; attempt += 1) { + const suffix = randomInt(100_000, 999_999).toString(); + const phone = `+234000${suffix}`; + const existing = await this.prisma.user.findUnique({ where: { phone } }); + + if (!existing) { + return phone; + } + } + + throw new ServiceUnavailableException({ + message: "Internal account phone placeholder could not be generated", + code: "INTERNAL_PHONE_PLACEHOLDER_FAILED", + }); + } + + private async createSessionTokens(params: { + user: { + id: string; + email: string; + role: UserRole; + storeId?: string; + }; + metadata: SessionMetadata; + afterSessionCreated?: (sessionId: string) => Promise; + }): Promise { + const tokens = await this.signTokenPair({ + sub: params.user.id, + email: params.user.email, + role: params.user.role, + storeId: params.user.storeId, + }); + + const session = await this.prisma.session.create({ + data: { + userId: params.user.id, + refreshTokenHash: this.hashToken(tokens.refreshToken), + userAgent: params.metadata.userAgent, + ipAddress: params.metadata.ipAddress, + expiresAt: new Date(Date.now() + AUTH_TTL.REFRESH_COOKIE_MS), + }, + }); + await params.afterSessionCreated?.(session.id); + + return tokens; + } + + private async signTokenPair(params: { + sub: string; + email: string; + role: UserRole; + storeId?: string; + }): Promise { + const accessPayload: AccessTokenPayload = { + ...params, + jti: randomUUID(), + }; + const refreshPayload: RefreshTokenPayload = { + ...params, + jti: randomUUID(), + }; + + const [accessToken, refreshToken] = await Promise.all([ + this.jwtService.signAsync(accessPayload, { + secret: this.accessTokenSecret, + expiresIn: AUTH_TTL.ACCESS_SECONDS, + }), + this.jwtService.signAsync(refreshPayload, { + secret: this.refreshTokenSecret, + expiresIn: AUTH_TTL.REFRESH_SECONDS, + }), + ]); + + return { accessToken, refreshToken }; + } + + private hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); + } + + private assertAgeAllowed(dateOfBirth: Date): void { + if (Number.isNaN(dateOfBirth.getTime()) || dateOfBirth > new Date()) { + throw new BadRequestException({ + message: "dateOfBirth must be a valid past date", + code: "INVALID_DATE_OF_BIRTH", + }); + } + + if (this.calculateAge(dateOfBirth) < MINIMUM_REGISTRATION_AGE) { + throw new BadRequestException({ + message: "You must be at least 13 years old to register", + code: "AGE_RESTRICTED", + }); + } + } + + private async issueOtp(params: { + userId: string; + email?: string; + phone?: string; + purpose: OTPPurpose; + send: (code: string) => Promise; + failureMessage: string; + }): Promise { + const identifier = params.email || params.phone; + + if (!identifier) { + throw new Error("OTP identifier is required"); + } + + const now = new Date(); + await this.prisma.oTPCode.updateMany({ + where: { + userId: params.userId, + purpose: params.purpose, + consumedAt: null, + }, + data: { consumedAt: now }, + }); + + const code = this.generateOtpCode(); + const codeHash = this.hashOtp(code, params.purpose, identifier); + + await this.prisma.oTPCode.create({ + data: { + userId: params.userId, + email: params.email, + phone: params.phone, + purpose: params.purpose, + codeHash, + expiresAt: new Date(now.getTime() + OTP_TTL_MS), + }, + }); + + try { + await params.send(code); + } catch (error) { + this.logger.error( + `OTP delivery failed: purpose=${params.purpose} channel=${params.email ? "email" : "sms"} userId=${params.userId} error=${this.getErrorName(error)}`, + ); + + throw new ServiceUnavailableException({ + message: params.failureMessage, + code: "OTP_DELIVERY_FAILED", + }); + } + } + + private handleRegistrationCreateError(error: unknown): never { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + const targetFields = this.getPrismaTargetFields(error.meta?.target); + + if (targetFields.includes("email")) { + throw new ConflictException({ + message: "Email is already registered", + code: "EMAIL_TAKEN", + }); + } + + if (targetFields.includes("phone")) { + throw new ConflictException({ + message: "Phone number is already registered", + code: "PHONE_TAKEN", + }); + } + + if (targetFields.includes("username")) { + throw new ConflictException({ + message: "Username is already taken", + code: "USERNAME_TAKEN", + }); + } + + throw new ConflictException({ + message: "Registration details conflict with an existing account", + code: "REGISTRATION_CONFLICT", + }); + } + + throw error; + } + + private getPrismaTargetFields(target: unknown): string[] { + if (Array.isArray(target)) { + return target.filter( + (field): field is string => typeof field === "string", + ); + } + + if (typeof target === "string") { + return [target]; + } + + return []; + } + + private getErrorName(error: unknown): string { + return error instanceof Error ? error.name : "UnknownError"; + } + + private async findActiveOtp(params: { + userId: string; + email?: string; + phone?: string; + purpose: OTPPurpose; + }): Promise<{ id: string; codeHash: string; attempts: number }> { + const otp = await this.prisma.oTPCode.findFirst({ + where: { + userId: params.userId, + email: params.email, + phone: params.phone, + purpose: params.purpose, + consumedAt: null, + expiresAt: { gt: new Date() }, + }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + codeHash: true, + attempts: true, + }, + }); + + if (!otp) { + throw new BadRequestException({ + message: "Verification code expired or not found", + code: "OTP_INVALID_OR_EXPIRED", + }); + } + + if (otp.attempts >= MAX_OTP_ATTEMPTS) { + await this.prisma.oTPCode.update({ + where: { id: otp.id }, + data: { consumedAt: new Date() }, + }); + throw new BadRequestException({ + message: "Verification code expired or not found", + code: "OTP_INVALID_OR_EXPIRED", + }); + } + + return otp; + } + + private async recordFailedOtpAttempt( + id: string, + attempts: number, + ): Promise { + await this.prisma.oTPCode.update({ + where: { id }, + data: { + attempts: { increment: 1 }, + consumedAt: attempts + 1 >= MAX_OTP_ATTEMPTS ? new Date() : undefined, + }, + }); + } + + private generateOtpCode(): string { + return randomInt(0, 1_000_000).toString().padStart(6, "0"); + } + + private hashOtp( + code: string, + purpose: OTPPurpose, + identifier: string, + ): string { + return createHmac("sha256", this.otpSecret) + .update(`${purpose}:${identifier}:${code}`) + .digest("hex"); + } + + private isOtpMatch( + storedHash: string, + code: string, + purpose: OTPPurpose, + identifier: string, + ): boolean { + const candidateHash = this.hashOtp(code, purpose, identifier); + const stored = Buffer.from(storedHash, "hex"); + const candidate = Buffer.from(candidateHash, "hex"); + + return ( + stored.length === candidate.length && timingSafeEqual(stored, candidate) + ); + } + + private async sendEmailOtp(email: string, code: string): Promise { + await this.emailProvider.sendTransactionalEmail({ + to: { email }, + template: "OTP_EMAIL_VERIFY", + variables: { code, ttlMinutes: OTP_TTL_MINUTES }, + }); + } + + private async sendSmsOtp(phone: string, code: string): Promise { + await this.africasTalkingClient.sendSms( + phone, + `Your twizrr verification code is ${code}. It expires in ${OTP_TTL_MINUTES} minutes.`, + ); + } + + private toAuthUserResponse(user: User): AuthUserResponse { + return { + id: user.id, + email: user.email, + phone: user.phone, + username: user.username, + displayName: user.displayName, + dateOfBirth: user.dateOfBirth?.toISOString() || null, + role: user.role, + emailVerified: user.emailVerified, + phoneVerified: user.phoneVerified, + isMinor: user.dateOfBirth + ? this.calculateAge(user.dateOfBirth) < 18 + : false, + createdAt: user.createdAt.toISOString(), + }; + } + + private calculateAge(dateOfBirth: Date): number { + const now = new Date(); + let age = now.getFullYear() - dateOfBirth.getFullYear(); + const hasHadBirthdayThisYear = + now.getMonth() > dateOfBirth.getMonth() || + (now.getMonth() === dateOfBirth.getMonth() && + now.getDate() >= dateOfBirth.getDate()); + + if (!hasHadBirthdayThisYear) { + age -= 1; + } + + return age; + } +} diff --git a/apps/backend/src/domains/users/auth/auth.types.ts b/apps/backend/src/domains/users/auth/auth.types.ts new file mode 100644 index 00000000..0acf5499 --- /dev/null +++ b/apps/backend/src/domains/users/auth/auth.types.ts @@ -0,0 +1,44 @@ +import { UserRole } from "@prisma/client"; +import type { Request } from "express"; + +export interface AccessTokenPayload { + sub: string; + email: string; + role: UserRole; + storeId?: string; + jti: string; +} + +export interface RefreshTokenPayload extends AccessTokenPayload {} + +export interface AuthenticatedRequestUser { + id: string; + sub: string; + email: string; + role: UserRole; + storeId?: string; + username: string | null; + displayName: string | null; + isEmailVerified: boolean; + isPhoneVerified: boolean; +} + +export interface RefreshRequestUser { + sessionId: string; + refreshToken: string; + user: AuthenticatedRequestUser; +} + +export interface SessionMetadata { + userAgent?: string; + ipAddress?: string; +} + +export interface AuthCookieTokens { + accessToken: string; + refreshToken: string; +} + +export type RequestWithCookies = Request & { + cookies?: Record; +}; diff --git a/apps/backend/src/domains/users/auth/dto/google-onboarding.dto.ts b/apps/backend/src/domains/users/auth/dto/google-onboarding.dto.ts new file mode 100644 index 00000000..7fdbe16e --- /dev/null +++ b/apps/backend/src/domains/users/auth/dto/google-onboarding.dto.ts @@ -0,0 +1,60 @@ +import { Transform, Type } from "class-transformer"; +import { + IsDate, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, + MinLength, +} from "class-validator"; + +export class CompleteGoogleOnboardingDto { + @Transform(({ value }) => + typeof value === "string" ? value.trim().replace(/\s+/g, " ") : value, + ) + @IsString() + @IsNotEmpty() + @MaxLength(60) + firstName!: string; + + @Transform(({ value }) => + typeof value === "string" ? value.trim().replace(/\s+/g, " ") : value, + ) + @IsString() + @IsNotEmpty() + @MaxLength(60) + lastName!: string; + + @Type(() => Date) + @IsDate() + dateOfBirth!: Date; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsString() + @Matches(/^\+234[789]\d{9}$/, { + message: "phone must be a valid Nigerian E.164 number", + }) + phone!: string; + + @Transform(({ value }) => + typeof value === "string" ? value.trim().toLowerCase() : value, + ) + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(30) + @Matches(/^[a-z0-9_]+$/, { + message: + "username can only contain lowercase letters, numbers, and underscores", + }) + username?: string; + + @Transform(({ value }) => + typeof value === "string" ? value.trim().replace(/\s+/g, " ") : value, + ) + @IsOptional() + @IsString() + @MaxLength(80) + displayName?: string; +} diff --git a/apps/backend/src/domains/users/auth/dto/internal-login.dto.ts b/apps/backend/src/domains/users/auth/dto/internal-login.dto.ts new file mode 100644 index 00000000..68b75550 --- /dev/null +++ b/apps/backend/src/domains/users/auth/dto/internal-login.dto.ts @@ -0,0 +1,11 @@ +import { IsString, MinLength } from "class-validator"; + +export class InternalLoginDto { + @IsString() + @MinLength(1) + identifier!: string; + + @IsString() + @MinLength(1) + password!: string; +} diff --git a/apps/backend/src/domains/users/auth/dto/login.dto.ts b/apps/backend/src/domains/users/auth/dto/login.dto.ts new file mode 100644 index 00000000..27c4bb0e --- /dev/null +++ b/apps/backend/src/domains/users/auth/dto/login.dto.ts @@ -0,0 +1,14 @@ +import { Transform } from "class-transformer"; +import { IsEmail, IsString, MinLength } from "class-validator"; + +export class LoginDto { + @Transform(({ value }) => + typeof value === "string" ? value.trim().toLowerCase() : value, + ) + @IsEmail() + email!: string; + + @IsString() + @MinLength(1) + password!: string; +} diff --git a/apps/backend/src/domains/users/auth/dto/password-reset.dto.ts b/apps/backend/src/domains/users/auth/dto/password-reset.dto.ts new file mode 100644 index 00000000..13d6ca25 --- /dev/null +++ b/apps/backend/src/domains/users/auth/dto/password-reset.dto.ts @@ -0,0 +1,32 @@ +import { Transform } from "class-transformer"; +import { + IsEmail, + IsString, + Matches, + MaxLength, + MinLength, +} from "class-validator"; + +export class ForgotPasswordDto { + @Transform(({ value }) => + typeof value === "string" ? value.trim().toLowerCase() : value, + ) + @IsEmail() + email!: string; +} + +export class ResetPasswordDto { + @IsString() + @MinLength(32) + @MaxLength(256) + token!: string; + + @IsString() + @MinLength(8) + @MaxLength(128) + @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z\d]).+$/, { + message: + "newPassword must contain uppercase, lowercase, number, and special character", + }) + newPassword!: string; +} diff --git a/apps/backend/src/domains/users/auth/dto/register.dto.ts b/apps/backend/src/domains/users/auth/dto/register.dto.ts new file mode 100644 index 00000000..8c808970 --- /dev/null +++ b/apps/backend/src/domains/users/auth/dto/register.dto.ts @@ -0,0 +1,76 @@ +import { Transform, Type } from "class-transformer"; +import { + IsDate, + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, + MinLength, +} from "class-validator"; + +export class RegisterEmailDto { + @Transform(({ value }) => + typeof value === "string" ? value.trim().toLowerCase() : value, + ) + @IsEmail() + email!: string; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsString() + @Matches(/^\+234[789]\d{9}$/, { + message: "phone must be a valid Nigerian E.164 number", + }) + phone!: string; + + @Transform(({ value }) => + typeof value === "string" ? value.trim().replace(/\s+/g, " ") : value, + ) + @IsString() + @IsNotEmpty() + @MaxLength(60) + firstName!: string; + + @Transform(({ value }) => + typeof value === "string" ? value.trim().replace(/\s+/g, " ") : value, + ) + @IsString() + @IsNotEmpty() + @MaxLength(60) + lastName!: string; + + @Transform(({ value }) => + typeof value === "string" ? value.trim().toLowerCase() : value, + ) + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(30) + @Matches(/^[a-z0-9_]+$/, { + message: + "username can only contain lowercase letters, numbers, and underscores", + }) + username?: string; + + @Transform(({ value }) => + typeof value === "string" ? value.trim().replace(/\s+/g, " ") : value, + ) + @IsOptional() + @IsString() + @MaxLength(80) + displayName?: string; + + @Type(() => Date) + @IsDate() + dateOfBirth!: Date; + + @IsString() + @MinLength(8) + @MaxLength(128) + @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z\d]).+$/, { + message: + "password must contain uppercase, lowercase, number, and special character", + }) + password!: string; +} diff --git a/apps/backend/src/domains/users/auth/dto/verify-email.dto.ts b/apps/backend/src/domains/users/auth/dto/verify-email.dto.ts new file mode 100644 index 00000000..5f4026cd --- /dev/null +++ b/apps/backend/src/domains/users/auth/dto/verify-email.dto.ts @@ -0,0 +1,33 @@ +import { Transform } from "class-transformer"; +import { IsEmail, IsString, Length, Matches } from "class-validator"; + +export class ResendEmailVerificationDto { + @Transform(({ value }) => + typeof value === "string" ? value.trim().toLowerCase() : value, + ) + @IsEmail() + email!: string; +} + +export class VerifyEmailDto { + @Transform(({ value }) => + typeof value === "string" ? value.trim().toLowerCase() : value, + ) + @IsEmail() + email!: string; + + @IsString() + @Length(6, 6) + @Matches(/^\d{6}$/, { message: "code must be a 6 digit number" }) + code!: string; +} + +// Account phone verification is session-bound — the phone is taken from the +// authenticated user server-side, never from the request body — so this DTO +// carries only the code. There is intentionally no client-supplied phone field. +export class VerifyPhoneOtpDto { + @IsString() + @Length(6, 6) + @Matches(/^\d{6}$/, { message: "code must be a 6 digit number" }) + code!: string; +} diff --git a/apps/backend/src/domains/users/auth/internal-auth.controller.ts b/apps/backend/src/domains/users/auth/internal-auth.controller.ts new file mode 100644 index 00000000..94669272 --- /dev/null +++ b/apps/backend/src/domains/users/auth/internal-auth.controller.ts @@ -0,0 +1,87 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + Req, + Res, +} from "@nestjs/common"; +import { Throttle } from "@nestjs/throttler"; +import type { CookieOptions, Request, Response } from "express"; + +import { AUTH_COOKIE, AUTH_TTL } from "./auth.constants"; +import { AuthService } from "./auth.service"; +import { SessionMetadata } from "./auth.types"; +import { InternalLoginDto } from "./dto/internal-login.dto"; + +/** + * Internal (admin) authentication surface. + * + * Twizrr admin is SUPER_ADMIN-only. The first SUPER_ADMIN is seeded via the + * bootstrap path (ADMIN_BOOTSTRAP_EMAIL / ADMIN_BOOTSTRAP_PASSWORD); there is no + * self-service admin registration or staff access-token onboarding. This + * controller only issues sessions for existing internal accounts (login). + */ +@Controller("auth/internal") +export class InternalAuthController { + constructor(private readonly authService: AuthService) {} + + private getAuthCookieDomain(): string | undefined { + return process.env.AUTH_COOKIE_DOMAIN?.trim() || undefined; + } + + private setCookies( + res: Response, + tokens: { accessToken: string; refreshToken: string }, + ): void { + const isLocal = process.env.NODE_ENV === "development"; + const domain = this.getAuthCookieDomain(); + const baseOptions: CookieOptions = { + httpOnly: true, + secure: !isLocal, + sameSite: isLocal ? "lax" : "none", + path: "/", + }; + + if (domain) { + res.clearCookie(AUTH_COOKIE.ACCESS, baseOptions); + res.clearCookie(AUTH_COOKIE.REFRESH, baseOptions); + } + + res.cookie(AUTH_COOKIE.ACCESS, tokens.accessToken, { + ...baseOptions, + ...(domain ? { domain } : {}), + maxAge: AUTH_TTL.ACCESS_COOKIE_MS, + }); + res.cookie(AUTH_COOKIE.REFRESH, tokens.refreshToken, { + ...baseOptions, + ...(domain ? { domain } : {}), + maxAge: AUTH_TTL.REFRESH_COOKIE_MS, + }); + } + + private getSessionMetadata(request: Request): SessionMetadata { + return { + userAgent: request.headers["user-agent"], + ipAddress: request.ip, + }; + } + + @Throttle({ default: { limit: 5, ttl: 60_000 } }) + @Post("login") + @HttpCode(HttpStatus.OK) + async internalLogin( + @Body() dto: InternalLoginDto, + @Req() request: Request, + @Res({ passthrough: true }) res: Response, + ) { + const result = await this.authService.internalLogin( + dto, + this.getSessionMetadata(request), + ); + this.setCookies(res, result); + + return { user: result.user }; + } +} diff --git a/apps/backend/src/domains/users/auth/strategies/jwt-refresh.strategy.ts b/apps/backend/src/domains/users/auth/strategies/jwt-refresh.strategy.ts new file mode 100644 index 00000000..c90d671c --- /dev/null +++ b/apps/backend/src/domains/users/auth/strategies/jwt-refresh.strategy.ts @@ -0,0 +1,109 @@ +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PassportStrategy } from "@nestjs/passport"; +import { ExtractJwt, Strategy } from "passport-jwt"; +import { createHash } from "crypto"; + +import { PrismaService } from "../../../../core/prisma/prisma.service"; +import { AUTH_COOKIE, AUTH_STRATEGY } from "../auth.constants"; +import { + AuthenticatedRequestUser, + RefreshRequestUser, + RefreshTokenPayload, + RequestWithCookies, +} from "../auth.types"; + +const refreshTokenExtractor = (request: RequestWithCookies): string | null => { + return request.cookies?.[AUTH_COOKIE.REFRESH] || null; +}; + +@Injectable() +export class JwtRefreshStrategy extends PassportStrategy( + Strategy, + AUTH_STRATEGY.REFRESH, +) { + constructor( + configService: ConfigService, + private readonly prisma: PrismaService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromExtractors([refreshTokenExtractor]), + ignoreExpiration: false, + passReqToCallback: true, + secretOrKey: configService.getOrThrow("jwt.refreshSecret"), + }); + } + + async validate( + request: RequestWithCookies, + payload: RefreshTokenPayload, + ): Promise { + const refreshToken = refreshTokenExtractor(request); + + if (!refreshToken) { + throw new UnauthorizedException({ + message: "Refresh token required", + code: "AUTH_REFRESH_REQUIRED", + }); + } + + const session = await this.prisma.session.findFirst({ + where: { + userId: payload.sub, + refreshTokenHash: this.hashToken(refreshToken), + revokedAt: null, + expiresAt: { gt: new Date() }, + user: { + isActive: true, + deletedAt: null, + }, + }, + include: { + user: { + select: { + id: true, + email: true, + role: true, + username: true, + displayName: true, + emailVerified: true, + phoneVerified: true, + storeProfile: { + select: { id: true }, + }, + }, + }, + }, + }); + + if (!session) { + throw new UnauthorizedException({ + message: "Invalid refresh token", + code: "AUTH_REFRESH_INVALID", + }); + } + + const storeId = session.user.storeProfile?.id; + const user: AuthenticatedRequestUser = { + id: session.user.id, + sub: session.user.id, + email: session.user.email, + role: session.user.role, + storeId, + username: session.user.username, + displayName: session.user.displayName, + isEmailVerified: session.user.emailVerified, + isPhoneVerified: session.user.phoneVerified, + }; + + return { + sessionId: session.id, + refreshToken, + user, + }; + } + + private hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); + } +} diff --git a/apps/backend/src/domains/users/auth/strategies/jwt.strategy.ts b/apps/backend/src/domains/users/auth/strategies/jwt.strategy.ts new file mode 100644 index 00000000..2a8bbc62 --- /dev/null +++ b/apps/backend/src/domains/users/auth/strategies/jwt.strategy.ts @@ -0,0 +1,48 @@ +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PassportStrategy } from "@nestjs/passport"; +import { ExtractJwt, Strategy } from "passport-jwt"; + +import { AccessTokenUserService } from "../../../../common/security/access-token-user.service"; +import { AUTH_COOKIE, AUTH_STRATEGY } from "../auth.constants"; +import { + AccessTokenPayload, + AuthenticatedRequestUser, + RequestWithCookies, +} from "../auth.types"; + +const accessTokenExtractor = (request: RequestWithCookies): string | null => { + return request.cookies?.[AUTH_COOKIE.ACCESS] || null; +}; + +@Injectable() +export class JwtAccessStrategy extends PassportStrategy( + Strategy, + AUTH_STRATEGY.ACCESS, +) { + constructor( + configService: ConfigService, + private readonly accessTokenUsers: AccessTokenUserService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromExtractors([accessTokenExtractor]), + ignoreExpiration: false, + secretOrKey: configService.getOrThrow("jwt.accessSecret"), + }); + } + + async validate( + payload: AccessTokenPayload, + ): Promise { + const user = await this.accessTokenUsers.findActiveUser(payload); + + if (!user) { + throw new UnauthorizedException({ + message: "Invalid token", + code: "AUTH_INVALID_TOKEN", + }); + } + + return user; + } +} diff --git a/apps/backend/src/domains/users/auth/strategies/local.strategy.ts b/apps/backend/src/domains/users/auth/strategies/local.strategy.ts new file mode 100644 index 00000000..9068ad2c --- /dev/null +++ b/apps/backend/src/domains/users/auth/strategies/local.strategy.ts @@ -0,0 +1,11 @@ +import { Injectable, UnauthorizedException } from "@nestjs/common"; + +@Injectable() +export class LocalStrategy { + validate(): never { + throw new UnauthorizedException({ + message: "Use POST /auth/login for password authentication", + code: "AUTH_ROUTE_REQUIRED", + }); + } +} diff --git a/apps/backend/src/domains/users/follow/README.md b/apps/backend/src/domains/users/follow/README.md new file mode 100644 index 00000000..7a133239 --- /dev/null +++ b/apps/backend/src/domains/users/follow/README.md @@ -0,0 +1,5 @@ +# Follow Domain + +Follow relationship boundary for users and stores. +To be built as part of the users domain. + diff --git a/apps/backend/src/domains/users/store/dto/business-phone-otp.dto.ts b/apps/backend/src/domains/users/store/dto/business-phone-otp.dto.ts new file mode 100644 index 00000000..454953d2 --- /dev/null +++ b/apps/backend/src/domains/users/store/dto/business-phone-otp.dto.ts @@ -0,0 +1,6 @@ +import { Matches } from "class-validator"; + +export class VerifyBusinessPhoneOtpDto { + @Matches(/^\d{6}$/, { message: "code must be a 6 digit number" }) + code!: string; +} diff --git a/apps/backend/src/domains/users/store/dto/create-store.dto.ts b/apps/backend/src/domains/users/store/dto/create-store.dto.ts new file mode 100644 index 00000000..d16550f3 --- /dev/null +++ b/apps/backend/src/domains/users/store/dto/create-store.dto.ts @@ -0,0 +1,93 @@ +import { StoreType } from "@prisma/client"; +import { Transform, Type } from "class-transformer"; +import { + IsBoolean, + IsEnum, + IsOptional, + IsString, + IsUrl, + Matches, + MaxLength, + MinLength, + ValidateIf, + ValidateNested, +} from "class-validator"; + +import { StoreAddressDto } from "./store-address.dto"; + +export class CreateStoreDto { + @Transform(({ value }) => + typeof value === "string" + ? value.trim().replace(/^@/, "").toLowerCase() + : value, + ) + @IsString() + @MinLength(3) + @MaxLength(30) + @Matches(/^[a-z0-9_]+$/, { + message: + "storeHandle can only contain lowercase letters, numbers, and underscores", + }) + storeHandle!: string; + + @Transform(({ value }) => + typeof value === "string" ? value.trim().replace(/\s+/g, " ") : value, + ) + @IsString() + @MinLength(2) + @MaxLength(80) + storeName!: string; + + @IsEnum(StoreType) + storeType!: StoreType; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsOptional() + @IsString() + @MaxLength(180) + bio?: string; + + @Transform(({ value }) => + typeof value === "string" ? value.trim().replace(/\s+/g, " ") : value, + ) + @IsString() + @MinLength(2) + @MaxLength(80) + businessCategory!: string; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @ValidateIf((dto: CreateStoreDto) => dto.useAccountPhoneForBusiness !== true) + @IsString() + @Matches(/^\+234[789]\d{9}$/, { + message: "businessPhone must be a valid Nigerian E.164 number", + }) + businessPhone?: string; + + @IsOptional() + @IsBoolean() + useAccountPhoneForBusiness?: boolean; + + @IsOptional() + @ValidateNested() + @Type(() => StoreAddressDto) + businessAddress?: StoreAddressDto; + + @IsOptional() + @ValidateNested() + @Type(() => StoreAddressDto) + homeAddress?: StoreAddressDto; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsOptional() + @IsUrl({ require_protocol: true }) + logoUrl?: string; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsOptional() + @IsUrl({ require_protocol: true }) + bannerUrl?: string; + + @IsOptional() + @IsBoolean() + showOwnerPublicly?: boolean; +} diff --git a/apps/backend/src/domains/users/store/dto/store-address.dto.ts b/apps/backend/src/domains/users/store/dto/store-address.dto.ts new file mode 100644 index 00000000..52029d0d --- /dev/null +++ b/apps/backend/src/domains/users/store/dto/store-address.dto.ts @@ -0,0 +1,53 @@ +import { Transform } from "class-transformer"; +import { IsNumber, IsOptional, IsString, MaxLength } from "class-validator"; + +export class StoreAddressDto { + @Transform(({ value }) => + typeof value === "string" ? value.trim().replace(/\s+/g, " ") : value, + ) + @IsString() + @MaxLength(300) + formattedAddress!: string; + + @Transform(({ value }) => + typeof value === "string" ? value.trim().replace(/\s+/g, " ") : value, + ) + @IsOptional() + @IsString() + @MaxLength(120) + addressLine2?: string; + + @Transform(({ value }) => + typeof value === "string" ? value.trim().replace(/\s+/g, " ") : value, + ) + @IsString() + @MaxLength(80) + city!: string; + + @Transform(({ value }) => + typeof value === "string" ? value.trim().replace(/\s+/g, " ") : value, + ) + @IsString() + @MaxLength(80) + state!: string; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsOptional() + @IsString() + @MaxLength(20) + postalCode?: string; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsOptional() + @IsString() + @MaxLength(160) + placeId?: string; + + @IsOptional() + @IsNumber() + latitude?: number; + + @IsOptional() + @IsNumber() + longitude?: number; +} diff --git a/apps/backend/src/domains/users/store/dto/store-public.dto.ts b/apps/backend/src/domains/users/store/dto/store-public.dto.ts new file mode 100644 index 00000000..f7cea86b --- /dev/null +++ b/apps/backend/src/domains/users/store/dto/store-public.dto.ts @@ -0,0 +1,81 @@ +import { Prisma, StoreTier } from "@prisma/client"; + +import { StorePassPublicBadge } from "../../../money/storepass/types/storepass.types"; + +interface StorePublicSource { + id: string; + storeHandle: string | null; + storeName: string | null; + bio: string | null; + logoUrl: string | null; + bannerUrl: string | null; + businessCategory: string | null; + homeAddress: Prisma.JsonValue | null; + tier: StoreTier; + isOpen: boolean; + createdAt: Date; + _count?: { + followers?: number; + }; +} + +export interface StorePublicResponse { + id: string; + storeHandle: string; + storeName: string; + bio: string | null; + logoUrl: string | null; + bannerUrl: string | null; + businessCategory: string | null; + publicLocation: string | null; + tier: StoreTier; + isOpen: boolean; + memberSince: string; + followerCount: number; + /** Paid StorePass growth-plan badge; null when the store is not eligible. */ + storePassBadge: StorePassPublicBadge | null; +} + +export class StorePublicDto { + static fromStore( + store: StorePublicSource, + storePassBadge: StorePassPublicBadge | null = null, + ): StorePublicResponse { + return { + id: store.id, + storeHandle: store.storeHandle ?? "", + storeName: store.storeName ?? "", + bio: store.bio, + logoUrl: store.logoUrl, + bannerUrl: store.bannerUrl, + businessCategory: store.businessCategory, + publicLocation: StorePublicDto.toPublicLocation(store), + tier: store.tier, + isOpen: store.isOpen, + memberSince: store.createdAt.toISOString(), + followerCount: store._count?.followers ?? 0, + storePassBadge, + }; + } + + private static toPublicLocation(store: StorePublicSource): string | null { + return StorePublicDto.locationFromJson(store.homeAddress); + } + + private static locationFromJson( + value: Prisma.JsonValue | null, + ): string | null { + if (!value || Array.isArray(value) || typeof value !== "object") { + return null; + } + + const city = value.city; + const state = value.state; + + if (typeof city === "string" && typeof state === "string") { + return `${city}, ${state}`; + } + + return null; + } +} diff --git a/apps/backend/src/domains/users/store/dto/update-store-bank.dto.ts b/apps/backend/src/domains/users/store/dto/update-store-bank.dto.ts new file mode 100644 index 00000000..ab09310c --- /dev/null +++ b/apps/backend/src/domains/users/store/dto/update-store-bank.dto.ts @@ -0,0 +1,36 @@ +import { Transform } from "class-transformer"; +import { + IsBoolean, + IsOptional, + IsString, + Matches, + MaxLength, + MinLength, +} from "class-validator"; + +export class UpdateStoreBankDto { + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsString() + @MinLength(1) + @MaxLength(20) + bankCode!: string; + + @Transform(({ value }) => + typeof value === "string" ? value.trim().replace(/\s+/g, " ") : value, + ) + @IsString() + @MinLength(2) + @MaxLength(80) + bankName!: string; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsString() + @Matches(/^\d{10}$/, { + message: "accountNumber must be exactly 10 digits", + }) + accountNumber!: string; + + @IsOptional() + @IsBoolean() + confirm?: boolean; +} diff --git a/apps/backend/src/domains/users/store/dto/update-store-status.dto.ts b/apps/backend/src/domains/users/store/dto/update-store-status.dto.ts new file mode 100644 index 00000000..8a6fc0d9 --- /dev/null +++ b/apps/backend/src/domains/users/store/dto/update-store-status.dto.ts @@ -0,0 +1,6 @@ +import { IsBoolean } from "class-validator"; + +export class UpdateStoreStatusDto { + @IsBoolean() + isOpen!: boolean; +} diff --git a/apps/backend/src/domains/users/store/dto/update-store.dto.ts b/apps/backend/src/domains/users/store/dto/update-store.dto.ts new file mode 100644 index 00000000..1d865437 --- /dev/null +++ b/apps/backend/src/domains/users/store/dto/update-store.dto.ts @@ -0,0 +1,80 @@ +import { Transform, Type } from "class-transformer"; +import { + IsBoolean, + IsOptional, + IsString, + IsUrl, + Matches, + MaxLength, + MinLength, + ValidateNested, +} from "class-validator"; + +import { StoreAddressDto } from "./store-address.dto"; + +export class UpdateStoreDto { + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(30) + @Matches(/^[a-z0-9_]+$/, { + message: + "storeHandle can only contain lowercase letters, numbers, and underscores", + }) + storeHandle?: string; + @Transform(({ value }) => + typeof value === "string" ? value.trim().replace(/\s+/g, " ") : value, + ) + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(80) + storeName?: string; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsOptional() + @IsString() + @MaxLength(180) + bio?: string; + + @Transform(({ value }) => + typeof value === "string" ? value.trim().replace(/\s+/g, " ") : value, + ) + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(80) + businessCategory?: string; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsOptional() + @IsString() + @Matches(/^\+234[789]\d{9}$/, { + message: "businessPhone must be a valid Nigerian E.164 number", + }) + businessPhone?: string; + + @IsOptional() + @IsBoolean() + useAccountPhoneForBusiness?: boolean; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsOptional() + @IsUrl({ require_protocol: true }) + logoUrl?: string; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsOptional() + @IsUrl({ require_protocol: true }) + bannerUrl?: string; + + @IsOptional() + @ValidateNested() + @Type(() => StoreAddressDto) + businessAddress?: StoreAddressDto; + + @IsOptional() + @IsBoolean() + allowDropship?: boolean; +} diff --git a/apps/backend/src/modules/merchant/dto/update-bank-account.dto.ts b/apps/backend/src/domains/users/store/merchant/dto/update-bank-account.dto.ts similarity index 75% rename from apps/backend/src/modules/merchant/dto/update-bank-account.dto.ts rename to apps/backend/src/domains/users/store/merchant/dto/update-bank-account.dto.ts index 858fdf58..478bcc2f 100644 --- a/apps/backend/src/modules/merchant/dto/update-bank-account.dto.ts +++ b/apps/backend/src/domains/users/store/merchant/dto/update-bank-account.dto.ts @@ -3,9 +3,9 @@ import { IsString, IsNotEmpty } from "class-validator"; export class UpdateBankAccountDto { @IsString() @IsNotEmpty() - bankAccountNumber: string; + bankAccountNumber!: string; @IsString() @IsNotEmpty() - bankCode: string; + bankCode!: string; } diff --git a/apps/backend/src/modules/merchant/dto/update-merchant.dto.ts b/apps/backend/src/domains/users/store/merchant/dto/update-merchant.dto.ts similarity index 89% rename from apps/backend/src/modules/merchant/dto/update-merchant.dto.ts rename to apps/backend/src/domains/users/store/merchant/dto/update-merchant.dto.ts index 32b0d234..e693d118 100644 --- a/apps/backend/src/modules/merchant/dto/update-merchant.dto.ts +++ b/apps/backend/src/domains/users/store/merchant/dto/update-merchant.dto.ts @@ -21,14 +21,6 @@ export class UpdateMerchantDto { @IsString() businessAddress?: string; - @IsOptional() - @IsString() - cacNumber?: string; - - @IsOptional() - @IsString() - cacDocumentUrl?: string; - @IsOptional() @IsString() taxId?: string; diff --git a/apps/backend/src/modules/merchant/dto/update-preferences.dto.ts b/apps/backend/src/domains/users/store/merchant/dto/update-preferences.dto.ts similarity index 100% rename from apps/backend/src/modules/merchant/dto/update-preferences.dto.ts rename to apps/backend/src/domains/users/store/merchant/dto/update-preferences.dto.ts diff --git a/apps/backend/src/modules/merchant/merchant-analytics.service.ts b/apps/backend/src/domains/users/store/merchant/merchant-analytics.service.ts similarity index 82% rename from apps/backend/src/modules/merchant/merchant-analytics.service.ts rename to apps/backend/src/domains/users/store/merchant/merchant-analytics.service.ts index 77ee0a1b..290d52ee 100644 --- a/apps/backend/src/modules/merchant/merchant-analytics.service.ts +++ b/apps/backend/src/domains/users/store/merchant/merchant-analytics.service.ts @@ -1,20 +1,20 @@ import { Injectable, BadRequestException } from "@nestjs/common"; -import { PrismaService } from "../../prisma/prisma.service"; +import { PrismaService } from "../../../../prisma/prisma.service"; @Injectable() export class MerchantAnalyticsService { constructor(private prisma: PrismaService) {} async getMerchantStats( - merchantId: string, + storeId: string, startDate?: string, endDate?: string, ) { - if (!merchantId) { - throw new BadRequestException("Merchant claim is missing"); + if (!storeId) { + throw new BadRequestException("Store claim is missing"); } - const where: any = { merchantId }; + const where: any = { storeId }; if (startDate || endDate) { where.createdAt = {}; diff --git a/apps/backend/src/domains/users/store/merchant/merchant.controller.ts b/apps/backend/src/domains/users/store/merchant/merchant.controller.ts new file mode 100644 index 00000000..fc8433e4 --- /dev/null +++ b/apps/backend/src/domains/users/store/merchant/merchant.controller.ts @@ -0,0 +1,165 @@ +import { + Controller, + Get, + Patch, + Body, + Param, + Query, + UseGuards, + Post, + Delete, +} from "@nestjs/common"; +import { MerchantService } from "./merchant.service"; +import { UpdateMerchantDto } from "./dto/update-merchant.dto"; +import { UpdateBankAccountDto } from "./dto/update-bank-account.dto"; +import { UpdatePreferencesDto } from "./dto/update-preferences.dto"; +import { JwtAuthGuard } from "../../../../common/guards/jwt-auth.guard"; +import { RolesGuard } from "../../../../common/guards/roles.guard"; +import { Roles } from "../../../../common/decorators/roles.decorator"; +import { CurrentUser } from "../../../../common/decorators/current-user.decorator"; +import { CurrentStore } from "../../../../common/decorators/current-store.decorator"; +import { UserRole, JwtPayload } from "@twizrr/shared"; + +import { MerchantAnalyticsService } from "./merchant-analytics.service"; + +@Controller("merchants") +export class MerchantController { + constructor( + private readonly merchantService: MerchantService, + private readonly analyticsService: MerchantAnalyticsService, + ) {} + + @Get("me") + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.USER) + async getMyProfile(@CurrentStore() storeId: string) { + return this.merchantService.getProfile(storeId); + } + + @Patch("me") + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.USER) + async updateMyProfile( + @CurrentStore() storeId: string, + @Body() dto: UpdateMerchantDto, + ) { + return this.merchantService.updateProfile(storeId, dto); + } + + @Patch("me/username") + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.USER) + async updateUsername( + @CurrentStore() storeId: string, + @Body("username") username: string, + ) { + return this.merchantService.updateUsername(storeId, username); + } + + @Get("lookup/:slug") + async getBySlug(@Param("slug") slug: string) { + return this.merchantService.findBySlug(slug); + } + + @Get() + async getAllMerchants() { + return this.merchantService.getAllMerchants(); + } + + @Get("balance-summary") + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.USER) + async getBalanceSummary(@CurrentStore() storeId: string) { + return this.merchantService.getBalanceSummary(storeId); + } + + @Get(":id") + async getPublicProfile(@Param("id") id: string) { + return this.merchantService.getPublicProfile(id); + } + + @Get("bank/resolve") + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.USER) + async resolveBank( + @Query("accountNumber") accountNumber: string, + @Query("bankCode") bankCode: string, + ) { + return this.merchantService.resolveBankAccount(accountNumber, bankCode); + } + + @Patch("bank-account") + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.USER) + async updateBankAccount( + @CurrentStore() storeId: string, + @Body() dto: UpdateBankAccountDto, + ) { + return this.merchantService.updateBankAccount(storeId, dto); + } + + @Get("banks/list") + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.USER) + async getBanks() { + return this.merchantService.getBanks(); + } + + @Post("me/submit") + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.USER) + async submitVerification(@CurrentStore() storeId: string) { + return this.merchantService.submitForVerification(storeId); + } + @Get("me/analytics") + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.USER) + async getMyAnalytics( + @CurrentStore() storeId: string, + @Query("startDate") startDate?: string, + @Query("endDate") endDate?: string, + ) { + return this.analyticsService.getMerchantStats(storeId, startDate, endDate); + } + + @Post(":id/follow") + @UseGuards(JwtAuthGuard) + async followMerchant( + @CurrentUser() user: JwtPayload, + @Param("id") storeId: string, + ) { + return this.merchantService.followMerchant(user.sub, storeId); + } + + @Delete(":id/follow") + @UseGuards(JwtAuthGuard) + async unfollowMerchant( + @CurrentUser() user: JwtPayload, + @Param("id") storeId: string, + ) { + return this.merchantService.unfollowMerchant(user.sub, storeId); + } + + @Get(":id/is-following") + @UseGuards(JwtAuthGuard) + async isFollowing( + @CurrentUser() user: JwtPayload, + @Param("id") storeId: string, + ) { + const isFollowing = await this.merchantService.isFollowing( + user.sub, + storeId, + ); + return { isFollowing }; + } + + @Patch("me/preferences") + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.USER) + async updatePreferences( + @CurrentStore() storeId: string, + @Body() dto: UpdatePreferencesDto, + ) { + return this.merchantService.updatePreferences(storeId, dto); + } +} diff --git a/apps/backend/src/domains/users/store/merchant/merchant.module.ts b/apps/backend/src/domains/users/store/merchant/merchant.module.ts new file mode 100644 index 00000000..4745a789 --- /dev/null +++ b/apps/backend/src/domains/users/store/merchant/merchant.module.ts @@ -0,0 +1,22 @@ +import { forwardRef, Module } from "@nestjs/common"; +import { MerchantService } from "./merchant.service"; +import { MerchantController } from "./merchant.controller"; +import { PrismaModule } from "../../../../prisma/prisma.module"; +import { PayoutModule } from "../../../money/payout/payout.module"; +import { VerificationModule } from "../../verification/verification.module"; +import { NotificationModule } from "../../../social/notification/notification.module"; + +import { MerchantAnalyticsService } from "./merchant-analytics.service"; + +@Module({ + imports: [ + PrismaModule, + PayoutModule, + forwardRef(() => VerificationModule), + NotificationModule, + ], + controllers: [MerchantController], + providers: [MerchantService, MerchantAnalyticsService], + exports: [MerchantService, MerchantAnalyticsService], +}) +export class MerchantModule {} diff --git a/apps/backend/src/domains/users/store/merchant/merchant.service.ts b/apps/backend/src/domains/users/store/merchant/merchant.service.ts new file mode 100644 index 00000000..111b5d4c --- /dev/null +++ b/apps/backend/src/domains/users/store/merchant/merchant.service.ts @@ -0,0 +1,597 @@ +import { + Injectable, + Logger, + NotFoundException, + BadRequestException, + forwardRef, + Inject, +} from "@nestjs/common"; +import { PrismaService } from "../../../../prisma/prisma.service"; +import { UpdateMerchantDto } from "./dto/update-merchant.dto"; +import { UpdateBankAccountDto } from "./dto/update-bank-account.dto"; +import { UserRole, VerificationTier, maskNin } from "@twizrr/shared"; +import { PayoutAccountService } from "../../../money/payout/payout-account.service"; +import { NotificationTriggerService } from "../../../social/notification/notification-trigger.service"; +import { VerificationService } from "../../verification/verification.service"; +import { UpdatePreferencesDto } from "./dto/update-preferences.dto"; +import { PayoutProviderName } from "@prisma/client"; + +@Injectable() +export class MerchantService { + private readonly logger = new Logger(MerchantService.name); + + constructor( + private prisma: PrismaService, + private payoutAccounts: PayoutAccountService, + private notifications: NotificationTriggerService, + @Inject(forwardRef(() => VerificationService)) + private readonly verificationService: VerificationService, + ) {} + + async resolveBankAccount(accountNumber: string, bankCode: string) { + try { + const response = await this.payoutAccounts.verifyAccount({ + accountNumber, + bankCode, + bankName: "", + }); + return { + accountName: response.accountName, + accountNumber: response.accountNumber, + }; + } catch (error: any) { + throw new NotFoundException( + `Could not resolve account: ${error.message}`, + ); + } + } + + async getBanks() { + try { + return await this.payoutAccounts.listBanks(); + } catch (error: any) { + throw new NotFoundException(`Could not get banks: ${error.message}`); + } + } + + async getProfile(storeId: string) { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const [merchant, slugChangesCount] = await Promise.all([ + this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + include: { + user: { + select: { + email: true, + phone: true, + emailVerified: true, + }, + }, + }, + }), + (this.prisma as any).storeSlugHistory.count({ + where: { + storeProfileId: storeId, + changedAt: { gte: thirtyDaysAgo }, + }, + }), + ]); + + if (!merchant) throw new NotFoundException("Store profile not found"); + + const { ninNumber, ...merchantWithoutNin } = merchant; + + return { + ...merchantWithoutNin, + maskedNin: maskNin(ninNumber ?? undefined), + contact: merchant.user + ? { + email: merchant.user.email, + phone: merchant.user.phone, + emailVerified: merchant.user.emailVerified, + } + : null, + slugChangesCount, + }; + } + + async getPublicProfile(storeId: string) { + const merchant = await (this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + include: { + user: { + select: { + email: true, + phone: true, + }, + }, + _count: { + select: { + orders: { + where: { + status: "COMPLETED", + }, + }, + followers: true, + } as any, + }, + }, + }) as any); + + if (!merchant) throw new NotFoundException("Store not found"); + + // We only expose contact info if the merchant is TIER_2 or higher + const isVerified = merchant.verificationTier === VerificationTier.TIER_2; + + return { + id: merchant.id, + slug: merchant.slug, + businessName: merchant.businessName, + businessAddress: merchant.businessAddress, + businessType: merchant.businessType, + estYear: merchant.estYear, + category: merchant.category, + warehouseLocation: merchant.warehouseLocation, + distributionCenter: merchant.distributionCenter, + warehouseCapacity: merchant.warehouseCapacity, + verificationTier: merchant.verificationTier, + verifiedAt: merchant.verifiedAt, + createdAt: merchant.createdAt, + lastSlugChangeAt: merchant.lastSlugChangeAt, + profileImage: merchant.profileImage, + coverImage: merchant.coverImage, + addressVerified: merchant.addressVerified, + guarantorVerified: merchant.guarantorVerified, + bankVerified: merchant.bankVerified, + averageRating: merchant.averageRating || 0, + reviewCount: merchant.reviewCount || 0, + description: merchant.description, + socialLinks: merchant.socialLinks as any, + contact: isVerified + ? { + email: merchant.user.email, + phone: merchant.user.phone, + } + : null, + dealsClosed: Number(merchant._count.orders), + followersCount: merchant._count.followers, + }; + } + + async getAllMerchants() { + return this.prisma.storeProfile.findMany({ + where: { + verificationTier: { + in: [VerificationTier.TIER_2], + }, + }, + select: { + id: true, + businessName: true, + verificationTier: true, + }, + orderBy: { + businessName: "asc", + }, + }); + } + + async updateProfile(storeId: string, dto: UpdateMerchantDto) { + const existing = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + }); + if (!existing) throw new NotFoundException("Store profile not found"); + + // Build the data to update from only provided fields + const updateData: Record = { ...dto }; + + // Progressive onboarding auto-advance (5-step flow) + const merged = { ...existing, ...dto }; + + let newStep = existing.onboardingStep; + + // Step 1 to 2: Business profile complete (businessName, businessType, estYear, category) + if ( + newStep === 1 && + merged.businessName && + merged.businessType && + merged.estYear && + merged.category + ) { + newStep = 2; + } + + // Step 2 -> 3: Registration details complete + if (newStep === 2 && merged.taxId) { + newStep = 3; + } + + // Step 3 to 4: Warehouse/logistics complete (businessAddress, warehouseLocation, distributionCenter, warehouseCapacity) + if ( + newStep === 3 && + merged.businessAddress && + merged.warehouseLocation && + merged.distributionCenter && + merged.warehouseCapacity + ) { + newStep = 4; + } + + // Step 4 to 5: Bank details complete (bankCode, bankAccountNumber, settlementAccountName) + if ( + newStep === 4 && + merged.bankCode && + merged.bankAccountNumber && + merged.settlementAccountName + ) { + newStep = 5; + } + + // Step 5 reached: Set verification = PENDING (review & submit step) + + if (newStep !== existing.onboardingStep) { + updateData.onboardingStep = newStep; + } + + const updated = await this.prisma.storeProfile.update({ + where: { id: storeId }, + data: updateData, + include: { + user: { + select: { + email: true, + phone: true, + }, + }, + }, + }); + + return updated; + } + + async updateBankAccount(storeId: string, dto: UpdateBankAccountDto) { + const existing = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + }); + if (!existing) throw new NotFoundException("Store profile not found"); + + const banks = await this.getBanks(); + const selectedBank = banks.find((bank) => bank.code === dto.bankCode); + const bankName = selectedBank?.name ?? existing.bankName ?? ""; + + const payoutAccount = + await this.payoutAccounts.verifyAccountAndCreateRecipient({ + bankCode: dto.bankCode, + bankName, + accountNumber: dto.bankAccountNumber, + }); + + // 3. Prepare the data to update + const updateData = { + bankAccountNumber: dto.bankAccountNumber, + bankCode: dto.bankCode, + bankName: payoutAccount.bankName || bankName, + accountNumber: payoutAccount.accountNumber, + accountName: payoutAccount.accountName, + settlementAccountName: payoutAccount.accountName, + bankVerified: true, + bankVerifiedAt: new Date(), + payoutRecipientProvider: + payoutAccount.provider === "paystack" + ? PayoutProviderName.PAYSTACK + : PayoutProviderName.MONNIFY, + payoutRecipientReference: payoutAccount.recipientCode ?? null, + paystackRecipientCode: + payoutAccount.provider === "paystack" + ? (payoutAccount.recipientCode ?? null) + : null, + ...(existing.onboardingStep === 4 ? { onboardingStep: 5 } : {}), + }; + + const updated = await this.prisma.storeProfile.update({ + where: { id: storeId }, + data: updateData, + }); + + // Best-effort Tier 1 re-evaluation after bank/recipient set. + // checkAndUpgradeTier is idempotent. + void this.verificationService + .tryUpgradeTierForUser(existing.userId) + .catch((error) => { + const message = + error instanceof Error ? error.message : "unknown error"; + this.logger.warn( + `Tier upgrade check after bank update failed: ${message}`, + ); + }); + + return updated; + } + + async submitForVerification(storeId: string) { + const merchant = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + }); + + if (!merchant) throw new NotFoundException("Store not found"); + + // Only allow submission if they've at least provided bank details (Step 4+) + if (merchant.onboardingStep < 4) { + throw new BadRequestException( + "Please complete your profile and bank details before submitting for verification.", + ); + } + + const updated = await this.prisma.storeProfile.update({ + where: { id: storeId }, + data: { + onboardingStep: Math.max(merchant.onboardingStep, 5), + }, + }); + + // Notify Admins + const admins = await this.prisma.user.findMany({ + where: { + role: UserRole.SUPER_ADMIN, + }, + select: { id: true }, + }); + + const adminIds = admins.map((a) => a.id); + if (adminIds.length > 0) { + await this.notifications.triggerNewMerchantSubmission(adminIds, { + storeId, + merchantName: merchant.businessName || "Unknown store", + }); + } + + return updated; + } + + async findBySlug(slug: string) { + // Attempt direct match first + const directMatch = await (this.prisma.storeProfile as any).findUnique({ + where: { slug: slug.toLowerCase() }, + include: { + user: { + select: { + email: true, + phone: true, + }, + }, + }, + }); + + if (directMatch) return directMatch; + + // Check history for redirection + const historyMatch = await (this.prisma as any).storeSlugHistory.findFirst({ + where: { oldSlug: slug.toLowerCase() }, + include: { + storeProfile: { + include: { + user: { + select: { + email: true, + phone: true, + }, + }, + }, + }, + }, + }); + + if (historyMatch) return historyMatch.storeProfile; + + throw new NotFoundException(`Store with username "${slug}" not found`); + } + + async updateUsername(storeId: string, newSlugInput: string) { + const newSlug = newSlugInput.toLowerCase().trim(); + + // 1. Format validation + const slugRegex = /^[a-z0-9](-?[a-z0-9])*$/; + if (!slugRegex.test(newSlug) || newSlug.length < 3 || newSlug.length > 30) { + throw new BadRequestException( + "Username must be 3-30 characters, lowercase alphanumeric, and can contain hyphens (but not at the start/end).", + ); + } + + const merchant = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + include: { user: { select: { emailVerified: true } } }, + }); + if (!merchant) throw new NotFoundException("Store profile not found"); + + if (!merchant.user.emailVerified) { + throw new BadRequestException( + "You must verify your email address before you can change your business username.", + ); + } + + if ((merchant as any).slug === newSlug) { + throw new BadRequestException( + "New username must be different from current one.", + ); + } + + // 2. Uniqueness check (Current + History) + const isTaken = await (this.prisma.storeProfile as any).findUnique({ + where: { slug: newSlug }, + }); + if (isTaken) throw new BadRequestException("Username is already in use."); + + const isReserved = await (this.prisma as any).storeSlugHistory.findFirst({ + where: { oldSlug: newSlug }, + }); + if (isReserved) + throw new BadRequestException("Username is reserved by another store."); + + // 3. Cooldown check (7 days) + if ((merchant as any).lastSlugChangeAt) { + const msSinceLastChange = + Date.now() - (merchant as any).lastSlugChangeAt.getTime(); + const coolingDays = msSinceLastChange / (1000 * 60 * 60 * 24); + if (coolingDays < 7) { + const remaining = Math.ceil(7 - coolingDays); + throw new BadRequestException( + `Please wait ${remaining} more day(s) before changing your username again.`, + ); + } + } + + // 3. Rate Limit Check (max 3 changes in 30 days) + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const recentChanges = await (this.prisma as any).storeSlugHistory.count({ + where: { + storeProfileId: storeId, + changedAt: { gte: thirtyDaysAgo }, + }, + }); + + if (recentChanges >= 3) { + throw new BadRequestException( + "You have reached the limit of 3 username changes per month.", + ); + } + + // 4. Cooldown Check (e.g., 24 hours between changes) + if ( + (merchant as any).lastSlugChangeAt && + new Date().getTime() - + new Date((merchant as any).lastSlugChangeAt).getTime() < + 24 * 60 * 60 * 1000 + ) { + throw new BadRequestException( + "Please wait 24 hours between username changes.", + ); + } + + // 5. Execute Update Transaction + return this.prisma.$transaction(async (tx) => { + // Record historical slug + await (tx as any).storeSlugHistory.create({ + data: { + storeProfileId: storeId, + oldSlug: (merchant as any).slug, + newSlug: newSlug, + }, + }); + + // Update current profile + return tx.storeProfile.update({ + where: { id: storeId }, + data: { + slug: newSlug, + lastSlugChangeAt: new Date(), + }, + } as any); + }); + } + + async followMerchant(userId: string, storeId: string) { + const merchant = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + }); + if (!merchant) throw new NotFoundException("Store not found"); + + if (merchant.userId === userId) { + throw new BadRequestException("You cannot follow your own business"); + } + + try { + return await (this.prisma as any).follow.create({ + data: { + followerId: userId, + storeId: storeId, + }, + }); + } catch (error: any) { + if (error.code === "P2002") { + throw new BadRequestException("You are already following this store"); + } + throw error; + } + } + + async unfollowMerchant(userId: string, storeId: string) { + try { + await (this.prisma as any).follow.delete({ + where: { + followerId_storeId: { + followerId: userId, + storeId: storeId, + }, + }, + }); + return { success: true }; + } catch (error: any) { + if (error.code === "P2025") { + throw new BadRequestException("You are not following this store"); + } + throw error; + } + } + + async isFollowing(userId: string, storeId: string) { + const follow = await (this.prisma as any).follow.findUnique({ + where: { + followerId_storeId: { + followerId: userId, + storeId: storeId, + }, + }, + }); + return !!follow; + } + + async updatePreferences(storeId: string, dto: UpdatePreferencesDto) { + const merchant = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + }); + if (!merchant) throw new NotFoundException("Store profile not found"); + + return this.prisma.storeProfile.update({ + where: { id: storeId }, + data: { + notificationPreferences: dto.notificationPreferences as any, + }, + }); + } + + async getBalanceSummary(storeId: string) { + // Fire concurrent fast aggregates offloaded to DB (leveraging the composite indexes) + const [escrowResult, availableResult, revenueResult, pendingResult] = + await Promise.all([ + this.prisma.order.aggregate({ + where: { storeId, status: { in: ["PAID", "DISPATCHED"] } }, + _sum: { totalAmountKobo: true }, + }), + this.prisma.order.aggregate({ + where: { storeId, payoutStatus: "COMPLETED" }, + _sum: { totalAmountKobo: true }, + }), + this.prisma.order.aggregate({ + where: { storeId, status: "COMPLETED" }, + _sum: { totalAmountKobo: true }, + }), + this.prisma.order.aggregate({ + where: { storeId, payoutStatus: "PENDING", status: "COMPLETED" }, + _sum: { totalAmountKobo: true }, + }), + ]); + + return { + escrowBalanceKobo: (escrowResult._sum.totalAmountKobo || 0n).toString(), + availableBalanceKobo: ( + availableResult._sum.totalAmountKobo || 0n + ).toString(), + totalRevenueKobo: (revenueResult._sum.totalAmountKobo || 0n).toString(), + pendingPayoutsKobo: (pendingResult._sum.totalAmountKobo || 0n).toString(), + }; + } +} diff --git a/apps/backend/src/domains/users/store/store.controller.ts b/apps/backend/src/domains/users/store/store.controller.ts new file mode 100644 index 00000000..c1866e80 --- /dev/null +++ b/apps/backend/src/domains/users/store/store.controller.ts @@ -0,0 +1,98 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Put, + UseGuards, +} from "@nestjs/common"; + +import { CurrentUser } from "../../../common/decorators/current-user.decorator"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { AuthenticatedRequestUser } from "../auth/auth.types"; +import { VerifyBusinessPhoneOtpDto } from "./dto/business-phone-otp.dto"; +import { CreateStoreDto } from "./dto/create-store.dto"; +import { UpdateStoreBankDto } from "./dto/update-store-bank.dto"; +import { UpdateStoreStatusDto } from "./dto/update-store-status.dto"; +import { UpdateStoreDto } from "./dto/update-store.dto"; +import { StoreService } from "./store.service"; + +@Controller("stores") +export class StoreController { + constructor(private readonly storeService: StoreService) {} + + @UseGuards(JwtAuthGuard) + @Post() + createStore( + @CurrentUser() user: AuthenticatedRequestUser, + @Body() dto: CreateStoreDto, + ) { + return this.storeService.createStore(user.id, dto); + } + + @UseGuards(JwtAuthGuard) + @Get("me") + getOwnStore(@CurrentUser() user: AuthenticatedRequestUser) { + return this.storeService.getOwnStore(user.id); + } + + @UseGuards(JwtAuthGuard) + @Put("me") + updateStore( + @CurrentUser() user: AuthenticatedRequestUser, + @Body() dto: UpdateStoreDto, + ) { + return this.storeService.updateStore(user.id, dto); + } + + @UseGuards(JwtAuthGuard) + @Post("me/business-phone/otp/send") + sendBusinessPhoneOtp(@CurrentUser() user: AuthenticatedRequestUser) { + return this.storeService.sendBusinessPhoneOtp(user.id); + } + + @UseGuards(JwtAuthGuard) + @Post("me/business-phone/otp/verify") + verifyBusinessPhoneOtp( + @CurrentUser() user: AuthenticatedRequestUser, + @Body() dto: VerifyBusinessPhoneOtpDto, + ) { + return this.storeService.verifyBusinessPhoneOtp(user.id, dto.code); + } + + @UseGuards(JwtAuthGuard) + @Put("me/status") + updateStatus( + @CurrentUser() user: AuthenticatedRequestUser, + @Body() dto: UpdateStoreStatusDto, + ) { + return this.storeService.updateStatus(user.id, dto.isOpen); + } + + @UseGuards(JwtAuthGuard) + @Put("me/bank") + updateBank( + @CurrentUser() user: AuthenticatedRequestUser, + @Body() dto: UpdateStoreBankDto, + ) { + return this.storeService.updateBank(user.id, dto); + } + + @UseGuards(JwtAuthGuard) + @Get("me/stats") + getStats(@CurrentUser() user: AuthenticatedRequestUser) { + return this.storeService.getStats(user.id); + } + + @UseGuards(JwtAuthGuard) + @Get("me/about") + getAbout(@CurrentUser() user: AuthenticatedRequestUser) { + return this.storeService.getAbout(user.id); + } + + @Get(":handle") + getPublicStore(@Param("handle") handle: string) { + return this.storeService.getPublicStore(handle); + } +} diff --git a/apps/backend/src/domains/users/store/store.module.ts b/apps/backend/src/domains/users/store/store.module.ts new file mode 100644 index 00000000..aa18014b --- /dev/null +++ b/apps/backend/src/domains/users/store/store.module.ts @@ -0,0 +1,23 @@ +import { forwardRef, Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; + +import { AfricasTalkingModule } from "../../../integrations/africastalking/africastalking.module"; +import { PayoutModule } from "../../money/payout/payout.module"; +import { StorePassModule } from "../../money/storepass/storepass.module"; +import { VerificationModule } from "../verification/verification.module"; +import { StoreController } from "./store.controller"; +import { StoreService } from "./store.service"; + +@Module({ + imports: [ + ConfigModule, + AfricasTalkingModule, + PayoutModule, + StorePassModule, + forwardRef(() => VerificationModule), + ], + controllers: [StoreController], + providers: [StoreService], + exports: [StoreService], +}) +export class StoreModule {} diff --git a/apps/backend/src/domains/users/store/store.service.spec.ts b/apps/backend/src/domains/users/store/store.service.spec.ts new file mode 100644 index 00000000..3aa72106 --- /dev/null +++ b/apps/backend/src/domains/users/store/store.service.spec.ts @@ -0,0 +1,341 @@ +import { StoreTier, StoreType } from "@prisma/client"; + +import { StoreService } from "./store.service"; + +function makeOwnerStore(overrides: Record = {}) { + return { + id: "store-1", + userId: "owner-1", + storeHandle: "ada_store", + storeName: "Ada Store", + storeType: StoreType.PHYSICAL, + bio: null, + logoUrl: null, + bannerUrl: null, + businessCategory: "Fashion", + businessPhone: "+2348012345678", + businessPhoneVerified: true, + businessPhoneVerifiedAt: new Date("2026-01-01T00:00:00.000Z"), + businessPhoneChangeCount: 0, + businessPhoneChangeWindowStartedAt: null, + homeAddress: null, + businessAddress: "12 Allen Avenue, Ikeja", + businessAddressDetails: null, + tier: StoreTier.TIER_0, + isOpen: true, + showOwnerPublicly: true, + allowDropship: false, + completedOrders: 0, + disputeCountLast6Months: 0, + dispatchTimeAvgHours: null, + orderCompletionRateBps: null, + bankName: null, + bankCode: null, + accountName: null, + accountNumber: null, + bankVerified: false, + verifiedAt: null, + tierUpgradedAt: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + user: { + id: "owner-1", + username: "ada", + displayName: "Ada", + emailVerified: true, + phone: "+2348012345678", + phoneVerified: true, + dateOfBirth: new Date("1990-01-01T00:00:00.000Z"), + }, + _count: { + followers: 0, + products: 0, + digitalSourcedProducts: 0, + orders: 0, + }, + ...overrides, + }; +} + +describe("StoreService structured delivery pickup address", () => { + it("stores physical business address details without changing the display address string", async () => { + const createError = new Error("stop after inspecting create payload"); + const prisma = { + user: { + findFirst: jest.fn().mockResolvedValue({ + id: "user_1", + phone: "+2348012345678", + phoneVerified: true, + dateOfBirth: new Date("1990-01-01T00:00:00.000Z"), + storeProfile: null, + }), + findUnique: jest.fn().mockResolvedValue(null), + }, + storeProfile: { + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn().mockRejectedValue(createError), + }, + $transaction: jest.fn().mockResolvedValue([null, null]), + }; + const service = new StoreService( + prisma as never, + {} as never, + { sendSms: jest.fn().mockResolvedValue(undefined) } as never, + { get: jest.fn().mockReturnValue("test-otp-secret") } as never, + { + tryUpgradeTierForUser: jest.fn().mockResolvedValue(undefined), + } as never, + { + resolveStorePassPublicBadge: jest.fn().mockResolvedValue(null), + } as never, + ); + const businessAddress = { + formattedAddress: "12 Allen Avenue, Ikeja", + city: "Ikeja", + state: "Lagos", + postalCode: "100001", + }; + + await expect( + service.createStore("user_1", { + storeHandle: "ada_store", + storeName: "Ada Store", + businessCategory: "Fashion", + businessPhone: "+2348012345678", + storeType: StoreType.PHYSICAL, + businessAddress, + }), + ).rejects.toBe(createError); + + expect(prisma.storeProfile.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + businessAddress: businessAddress.formattedAddress, + businessAddressDetails: businessAddress, + }), + }), + ); + }); +}); + +describe("StoreService stats", () => { + it("excludes delivery fees from store revenue", async () => { + const store = { + id: "store-1", + storeType: StoreType.PHYSICAL, + _count: { + products: 2, + digitalSourcedProducts: 0, + orders: 3, + followers: 4, + }, + disputeCountLast6Months: 0, + dispatchTimeAvgHours: null, + orderCompletionRateBps: null, + tier: "TIER_1", + }; + const prisma = { + storeProfile: { + findUnique: jest.fn().mockResolvedValue(store), + }, + order: { + count: jest.fn(), + aggregate: jest.fn(), + }, + $transaction: jest.fn().mockResolvedValue([ + 2, + { + _sum: { + totalAmountKobo: 31_500n, + deliveryFeeKobo: 6_000n, + }, + }, + ]), + }; + const service = new StoreService( + prisma as never, + {} as never, + { sendSms: jest.fn().mockResolvedValue(undefined) } as never, + { get: jest.fn().mockReturnValue("test-otp-secret") } as never, + { + tryUpgradeTierForUser: jest.fn().mockResolvedValue(undefined), + } as never, + { + resolveStorePassPublicBadge: jest.fn().mockResolvedValue(null), + } as never, + ); + + await expect(service.getStats("user-1")).resolves.toMatchObject({ + grossRevenueKobo: 25_500n, + completedOrderCount: 2, + }); + }); + + it("uses sourced listing count for digital store dashboards", async () => { + const store = { + id: "digital-store-1", + storeType: StoreType.DIGITAL, + _count: { + products: 0, + digitalSourcedProducts: 5, + orders: 3, + followers: 4, + }, + disputeCountLast6Months: 0, + dispatchTimeAvgHours: null, + orderCompletionRateBps: null, + tier: StoreTier.TIER_1, + }; + const prisma = { + storeProfile: { + findUnique: jest.fn().mockResolvedValue(store), + }, + order: { + count: jest.fn(), + aggregate: jest.fn(), + }, + $transaction: jest.fn().mockResolvedValue([ + 0, + { + _sum: { + totalAmountKobo: 0n, + deliveryFeeKobo: 0n, + }, + }, + ]), + }; + const service = new StoreService( + prisma as never, + {} as never, + { sendSms: jest.fn().mockResolvedValue(undefined) } as never, + { get: jest.fn().mockReturnValue("test-otp-secret") } as never, + { + tryUpgradeTierForUser: jest.fn().mockResolvedValue(undefined), + } as never, + { + resolveStorePassPublicBadge: jest.fn().mockResolvedValue(null), + } as never, + ); + + await expect(service.getStats("digital-owner-1")).resolves.toMatchObject({ + productCount: 5, + }); + }); +}); + +describe("StoreService sourcing opt-in", () => { + const makeService = (store: ReturnType | null) => { + const prisma = { + storeProfile: { + findUnique: jest.fn().mockResolvedValue(store), + update: jest.fn().mockImplementation(({ data }) => + Promise.resolve( + makeOwnerStore({ + ...(store ?? {}), + ...data, + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + }), + ), + ), + }, + }; + const service = new StoreService( + prisma as never, + {} as never, + { sendSms: jest.fn().mockResolvedValue(undefined) } as never, + { get: jest.fn().mockReturnValue("test-otp-secret") } as never, + { + tryUpgradeTierForUser: jest.fn().mockResolvedValue(undefined), + } as never, + { + resolveStorePassPublicBadge: jest.fn().mockResolvedValue(null), + } as never, + ); + return { service, prisma }; + }; + + it("lets a physical store owner enable sourcing opt-in", async () => { + const { service, prisma } = makeService(makeOwnerStore()); + + const result = await service.updateStore("owner-1", { + allowDropship: true, + }); + + expect(prisma.storeProfile.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "store-1" }, + data: { allowDropship: true }, + }), + ); + expect(result.allowDropship).toBe(true); + }); + + it("lets a physical store owner disable sourcing opt-in", async () => { + const { service, prisma } = makeService( + makeOwnerStore({ allowDropship: true }), + ); + + const result = await service.updateStore("owner-1", { + allowDropship: false, + }); + + expect(prisma.storeProfile.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "store-1" }, + data: { allowDropship: false }, + }), + ); + expect(result.allowDropship).toBe(false); + }); + + it("rejects sourcing opt-in for digital stores", async () => { + const { service, prisma } = makeService( + makeOwnerStore({ storeType: StoreType.DIGITAL }), + ); + + await expect( + service.updateStore("owner-1", { allowDropship: true }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "SOURCING_PHYSICAL_STORE_ONLY", + }), + }); + + expect(prisma.storeProfile.update).not.toHaveBeenCalled(); + }); + + it("rejects physical pickup address updates for digital stores", async () => { + const { service, prisma } = makeService( + makeOwnerStore({ storeType: StoreType.DIGITAL }), + ); + + await expect( + service.updateStore("owner-1", { + businessAddress: { + formattedAddress: "12 Allen Avenue, Ikeja", + city: "Ikeja", + state: "Lagos", + postalCode: "100001", + }, + }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: "BUSINESS_ADDRESS_PHYSICAL_ONLY", + }), + }); + + expect(prisma.storeProfile.update).not.toHaveBeenCalled(); + }); + + it("does not let a user without a store update sourcing opt-in", async () => { + const { service, prisma } = makeService(null); + + await expect( + service.updateStore("other-user", { allowDropship: true }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ code: "STORE_NOT_FOUND" }), + }); + + expect(prisma.storeProfile.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/domains/users/store/store.service.ts b/apps/backend/src/domains/users/store/store.service.ts new file mode 100644 index 00000000..709aa828 --- /dev/null +++ b/apps/backend/src/domains/users/store/store.service.ts @@ -0,0 +1,1178 @@ +import { + BadRequestException, + ConflictException, + forwardRef, + Inject, + Injectable, + Logger, + NotFoundException, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + OTPPurpose, + PayoutProviderName, + Prisma, + StoreTier, + StoreType, +} from "@prisma/client"; +import { createHmac, randomInt, timingSafeEqual } from "crypto"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { AfricasTalkingClient } from "../../../integrations/africastalking/africastalking.client"; +import { PayoutAccountService } from "../../money/payout/payout-account.service"; +import { StorePassService } from "../../money/storepass/storepass.service"; +import { VerificationService } from "../verification/verification.service"; +import { CreateStoreDto } from "./dto/create-store.dto"; +import { StoreAddressDto } from "./dto/store-address.dto"; +import { StorePublicDto, StorePublicResponse } from "./dto/store-public.dto"; +import { UpdateStoreBankDto } from "./dto/update-store-bank.dto"; +import { UpdateStoreDto } from "./dto/update-store.dto"; + +const BUSINESS_PHONE_CHANGE_LIMIT = 2; +const BUSINESS_PHONE_CHANGE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; +const STORE_HANDLE_CHANGE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; +const OTP_TTL_MINUTES = 15; +const OTP_TTL_MS = OTP_TTL_MINUTES * 60 * 1000; +const MAX_OTP_ATTEMPTS = 5; +const OTP_DELIVERY_FAILURE_MESSAGE = + "Verification code could not be sent. Please try again."; + +const STORE_OWNER_SELECT = { + id: true, + userId: true, + storeHandle: true, + storeName: true, + storeType: true, + bio: true, + logoUrl: true, + bannerUrl: true, + businessCategory: true, + businessPhone: true, + businessPhoneVerified: true, + businessPhoneVerifiedAt: true, + businessPhoneChangeCount: true, + businessPhoneChangeWindowStartedAt: true, + lastSlugChangeAt: true, + homeAddress: true, + businessAddress: true, + businessAddressDetails: true, + tier: true, + isOpen: true, + showOwnerPublicly: true, + allowDropship: true, + completedOrders: true, + disputeCountLast6Months: true, + dispatchTimeAvgHours: true, + orderCompletionRateBps: true, + bankName: true, + bankCode: true, + accountName: true, + accountNumber: true, + bankVerified: true, + bankVerifiedAt: true, + verifiedAt: true, + tierUpgradedAt: true, + createdAt: true, + updatedAt: true, + user: { + select: { + id: true, + username: true, + displayName: true, + emailVerified: true, + phone: true, + phoneVerified: true, + dateOfBirth: true, + }, + }, + _count: { + select: { + followers: true, + products: true, + digitalSourcedProducts: true, + orders: true, + }, + }, +} satisfies Prisma.StoreProfileSelect; + +const STORE_PUBLIC_SELECT = { + id: true, + storeHandle: true, + storeName: true, + bio: true, + logoUrl: true, + bannerUrl: true, + businessCategory: true, + homeAddress: true, + tier: true, + isOpen: true, + createdAt: true, + _count: { + select: { + followers: true, + }, + }, +} satisfies Prisma.StoreProfileSelect; + +type StoreOwnerRecord = Prisma.StoreProfileGetPayload<{ + select: typeof STORE_OWNER_SELECT; +}>; + +export interface OwnerStoreResponse { + id: string; + userId: string; + storeHandle: string; + storeName: string; + storeType: StoreType; + bio: string | null; + logoUrl: string | null; + bannerUrl: string | null; + businessCategory: string | null; + businessPhone: string | null; + businessPhoneVerified: boolean; + businessPhoneVerifiedAt: string | null; + homeAddress: Prisma.JsonValue | null; + businessAddress: string | null; + businessAddressDetails: Prisma.JsonValue | null; + tier: StoreTier; + isOpen: boolean; + showOwnerPublicly: boolean; + allowDropship: boolean; + completedOrders: number; + disputeCountLast6Months: number; + dispatchTimeAvgHours: number | null; + orderCompletionRateBps: number | null; + bank: { + bankName: string | null; + bankCode: string | null; + accountName: string | null; + accountNumberLast4: string | null; + bankVerified: boolean; + bankVerifiedAt: string | null; + }; + owner: { + id: string; + username: string | null; + displayName: string | null; + emailVerified: boolean; + phoneVerified: boolean; + }; + counts: { + followers: number; + products: number; + orders: number; + }; + verifiedAt: string | null; + tierUpgradedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface StoreStatsResponse { + productCount: number; + orderCount: number; + completedOrderCount: number; + followerCount: number; + grossRevenueKobo: bigint; + disputeCountLast6Months: number; + dispatchTimeAvgHours: number | null; + orderCompletionRateBps: number | null; + tier: StoreTier; +} + +export interface StoreAboutResponse { + memberSince: string; + publicLocation: string | null; + verificationStatus: StoreTier; + owner: { + username: string | null; + displayName: string | null; + } | null; + escrowProtected: true; + completedOrders: number; + disputeCountLast6Months: number; + dispatchTimeAvgHours: number | null; + orderCompletionRateBps: number | null; +} + +@Injectable() +export class StoreService { + private readonly logger = new Logger(StoreService.name); + private readonly otpSecret: string; + + constructor( + private readonly prisma: PrismaService, + private readonly payoutAccounts: PayoutAccountService, + private readonly africasTalkingClient: AfricasTalkingClient, + configService: ConfigService, + @Inject(forwardRef(() => VerificationService)) + private readonly verificationService: VerificationService, + private readonly storePassService: StorePassService, + ) { + const configuredSecret = + configService.get("ONBOARDING_OTP_SECRET") || + configService.get("jwt.accessSecret") || + configService.get("JWT_SECRET"); + + if (!configuredSecret?.trim()) { + throw new Error("ONBOARDING_OTP_SECRET or JWT_SECRET is required"); + } + + this.otpSecret = configuredSecret; + } + + async createStore( + userId: string, + dto: CreateStoreDto, + ): Promise { + const user = await this.prisma.user.findFirst({ + where: { + id: userId, + isActive: true, + deletedAt: null, + }, + select: { + id: true, + phone: true, + phoneVerified: true, + dateOfBirth: true, + storeProfile: { select: { id: true } }, + }, + }); + + if (!user) { + throw new NotFoundException({ + message: "User not found", + code: "USER_NOT_FOUND", + }); + } + + this.assertCanCreateStore(user.dateOfBirth); + + if (user.storeProfile) { + throw new ConflictException({ + message: "User already has a store", + code: "STORE_ALREADY_EXISTS", + }); + } + + const storeHandle = this.normalizeHandle(dto.storeHandle); + await this.assertHandleAvailable(storeHandle); + this.assertAddressRules(dto); + const businessPhoneState = this.resolveCreateBusinessPhone(dto, user); + + try { + const createdStore = await this.prisma.storeProfile.create({ + data: { + userId, + storeHandle, + storeName: dto.storeName, + businessName: dto.storeName, + storeType: dto.storeType, + bio: dto.bio, + businessCategory: dto.businessCategory, + businessPhone: businessPhoneState.phone, + businessPhoneVerified: businessPhoneState.verified, + businessPhoneVerifiedAt: businessPhoneState.verified + ? new Date() + : null, + businessPhoneChangeCount: 0, + businessPhoneChangeWindowStartedAt: null, + businessAddress: + dto.storeType === StoreType.PHYSICAL && dto.businessAddress + ? dto.businessAddress.formattedAddress + : null, + businessAddressDetails: + dto.storeType === StoreType.PHYSICAL && dto.businessAddress + ? this.toAddressJson(dto.businessAddress) + : Prisma.JsonNull, + homeAddress: + dto.storeType === StoreType.DIGITAL && dto.homeAddress + ? this.toAddressJson(dto.homeAddress) + : Prisma.JsonNull, + logoUrl: dto.logoUrl, + bannerUrl: dto.bannerUrl, + tier: StoreTier.TIER_0, + isOpen: true, + showOwnerPublicly: dto.showOwnerPublicly ?? true, + bankVerified: false, + }, + select: STORE_OWNER_SELECT, + }); + + return this.toOwnerResponse(createdStore); + } catch (error) { + this.handleStoreCreateError(error); + } + } + + async getOwnStore(userId: string): Promise { + const store = await this.findOwnStore(userId); + return this.toOwnerResponse(store); + } + + async getPublicStore(handle: string): Promise { + const storeHandle = this.normalizeHandle(handle); + const store = await this.prisma.storeProfile.findFirst({ + where: { storeHandle }, + select: STORE_PUBLIC_SELECT, + }); + + if (!store) { + throw new NotFoundException({ + message: "Store not found", + code: "STORE_NOT_FOUND", + }); + } + + const storePassBadge = + await this.storePassService.resolveStorePassPublicBadge(store.id); + return StorePublicDto.fromStore(store, storePassBadge); + } + + async updateStore( + userId: string, + dto: UpdateStoreDto, + ): Promise { + const store = await this.findOwnStore(userId); + const data: Prisma.StoreProfileUpdateInput = {}; + + if (dto.storeName !== undefined) { + data.storeName = dto.storeName; + data.businessName = dto.storeName; + } + + if (dto.storeHandle !== undefined) { + const nextHandle = this.normalizeHandle(dto.storeHandle); + if (nextHandle !== store.storeHandle) { + this.assertStoreHandleChangeAllowed(store.lastSlugChangeAt); + await this.assertHandleAvailable(nextHandle); + data.storeHandle = nextHandle; + data.lastSlugChangeAt = new Date(); + } + } + + if (dto.bio !== undefined) { + data.bio = dto.bio; + } + + if (dto.businessCategory !== undefined) { + data.businessCategory = dto.businessCategory; + } + + const businessPhoneUpdate = this.resolveBusinessPhoneUpdate(dto, store); + if (businessPhoneUpdate) { + Object.assign(data, businessPhoneUpdate); + } + + if (dto.logoUrl !== undefined) { + data.logoUrl = dto.logoUrl; + } + + if (dto.bannerUrl !== undefined) { + data.bannerUrl = dto.bannerUrl; + } + + if (dto.allowDropship !== undefined) { + if (store.storeType !== StoreType.PHYSICAL) { + throw new BadRequestException({ + message: + "Only physical stores can allow digital stores to source products", + code: "SOURCING_PHYSICAL_STORE_ONLY", + }); + } + + data.allowDropship = dto.allowDropship; + } + + if (dto.businessAddress !== undefined) { + if (store.storeType !== StoreType.PHYSICAL) { + throw new BadRequestException({ + message: "Business address is only available for physical stores", + code: "BUSINESS_ADDRESS_PHYSICAL_ONLY", + }); + } + + data.businessAddress = dto.businessAddress.formattedAddress; + data.businessAddressDetails = this.toAddressJson(dto.businessAddress); + } + + if (Object.keys(data).length === 0) { + return this.toOwnerResponse(store); + } + + try { + const updatedStore = await this.prisma.storeProfile.update({ + where: { id: store.id }, + data, + select: STORE_OWNER_SELECT, + }); + + return this.toOwnerResponse(updatedStore); + } catch (error) { + this.handleStoreUpdateError(error); + } + } + + async updateStatus( + userId: string, + isOpen: boolean, + ): Promise { + const store = await this.findOwnStore(userId); + const updatedStore = await this.prisma.storeProfile.update({ + where: { id: store.id }, + data: { isOpen }, + select: STORE_OWNER_SELECT, + }); + + return this.toOwnerResponse(updatedStore); + } + + async updateBank( + userId: string, + dto: UpdateStoreBankDto, + ): Promise<{ + accountName: string; + bankCode: string; + bankName: string; + accountNumberLast4: string; + confirmed: boolean; + tier: StoreTier; + }> { + const store = await this.findOwnStore(userId); + const resolvedAccount = await this.payoutAccounts.verifyAccount({ + accountNumber: dto.accountNumber, + bankCode: dto.bankCode, + bankName: dto.bankName, + }); + + if (!dto.confirm) { + return { + accountName: resolvedAccount.accountName, + bankCode: dto.bankCode, + bankName: dto.bankName, + accountNumberLast4: resolvedAccount.accountNumberLast4, + confirmed: false, + tier: store.tier, + }; + } + + const recipientAccount = + await this.payoutAccounts.verifyAccountAndCreateRecipient({ + accountNumber: dto.accountNumber, + bankCode: dto.bankCode, + bankName: dto.bankName, + }); + + const updatedStore = await this.prisma.storeProfile.update({ + where: { id: store.id }, + data: { + bankCode: dto.bankCode, + bankName: dto.bankName, + accountNumber: dto.accountNumber, + bankAccountNumber: dto.accountNumber, + accountName: recipientAccount.accountName, + settlementAccountName: recipientAccount.accountName, + bankVerified: true, + bankVerifiedAt: new Date(), + payoutRecipientProvider: + recipientAccount.provider === "paystack" + ? PayoutProviderName.PAYSTACK + : PayoutProviderName.MONNIFY, + payoutRecipientReference: recipientAccount.recipientCode ?? null, + paystackRecipientCode: + recipientAccount.provider === "paystack" + ? (recipientAccount.recipientCode ?? null) + : null, + }, + select: STORE_OWNER_SELECT, + }); + + const tierCheckedStore = await this.maybeUpgradeToTierOne(updatedStore); + + // Best-effort verificationTier upgrade (uses the canonical + // VerificationTier enum and notification path). Runs in parallel + // with maybeUpgradeToTierOne above which writes the legacy + // StoreTier enum on a different column. + void this.verificationService + .tryUpgradeTierForUser(updatedStore.userId) + .catch((error) => { + const message = + error instanceof Error ? error.message : "unknown error"; + this.logger.warn( + `Tier upgrade check after bank update failed: ${message}`, + ); + }); + + return { + accountName: recipientAccount.accountName, + bankCode: dto.bankCode, + bankName: dto.bankName, + accountNumberLast4: recipientAccount.accountNumberLast4, + confirmed: true, + tier: tierCheckedStore.tier, + }; + } + + async getStats(userId: string): Promise { + const store = await this.findOwnStore(userId); + const [completedOrders, revenue] = await this.prisma.$transaction([ + this.prisma.order.count({ + where: { + storeId: store.id, + status: "COMPLETED", + }, + }), + this.prisma.order.aggregate({ + where: { + storeId: store.id, + status: "COMPLETED", + }, + _sum: { + totalAmountKobo: true, + deliveryFeeKobo: true, + }, + }), + ]); + const grossRevenueKobo = + (revenue._sum.totalAmountKobo ?? 0n) - + (revenue._sum.deliveryFeeKobo ?? 0n); + const productCount = + store.storeType === StoreType.DIGITAL + ? store._count.digitalSourcedProducts + : store._count.products; + + return { + productCount, + orderCount: store._count.orders, + completedOrderCount: completedOrders, + followerCount: store._count.followers, + grossRevenueKobo, + disputeCountLast6Months: store.disputeCountLast6Months, + dispatchTimeAvgHours: store.dispatchTimeAvgHours, + orderCompletionRateBps: store.orderCompletionRateBps, + tier: store.tier, + }; + } + + async getAbout(userId: string): Promise { + const store = await this.findOwnStore(userId); + + return { + memberSince: store.createdAt.toISOString(), + publicLocation: this.ownerSafeLocation(store), + verificationStatus: store.tier, + owner: store.showOwnerPublicly + ? { + username: store.user.username, + displayName: store.user.displayName, + } + : null, + escrowProtected: true, + completedOrders: store.completedOrders, + disputeCountLast6Months: store.disputeCountLast6Months, + dispatchTimeAvgHours: store.dispatchTimeAvgHours, + orderCompletionRateBps: store.orderCompletionRateBps, + }; + } + + private async findOwnStore(userId: string): Promise { + const store = await this.prisma.storeProfile.findUnique({ + where: { userId }, + select: STORE_OWNER_SELECT, + }); + + if (!store) { + throw new NotFoundException({ + message: "Store not found", + code: "STORE_NOT_FOUND", + }); + } + + return store; + } + + private assertStoreHandleChangeAllowed(lastChangedAt: Date | null): void { + if ( + lastChangedAt && + Date.now() - lastChangedAt.getTime() < STORE_HANDLE_CHANGE_WINDOW_MS + ) { + throw new BadRequestException({ + message: "Store handle can only be changed once every 7 days", + code: "STORE_HANDLE_CHANGE_LIMIT_REACHED", + }); + } + } + + private async assertHandleAvailable(storeHandle: string): Promise { + const [existingStore, existingUser] = await this.prisma.$transaction([ + this.prisma.storeProfile.findUnique({ + where: { storeHandle }, + select: { id: true }, + }), + this.prisma.user.findUnique({ + where: { username: storeHandle }, + select: { id: true }, + }), + ]); + + if (existingStore || existingUser) { + throw new ConflictException({ + message: "Store handle is already taken", + code: "STORE_HANDLE_TAKEN", + }); + } + } + + async sendBusinessPhoneOtp(userId: string): Promise<{ sent: true }> { + const store = await this.findOwnStore(userId); + + if (!store.businessPhone) { + throw new BadRequestException({ + message: "Add a business phone number before verification", + code: "BUSINESS_PHONE_REQUIRED", + }); + } + + if (store.businessPhoneVerified) { + return { sent: true }; + } + + await this.issueBusinessPhoneOtp(store.userId, store.businessPhone); + return { sent: true }; + } + + async verifyBusinessPhoneOtp( + userId: string, + code: string, + ): Promise { + const store = await this.findOwnStore(userId); + + if (!store.businessPhone) { + throw new BadRequestException({ + message: "Add a business phone number before verification", + code: "BUSINESS_PHONE_REQUIRED", + }); + } + + const businessPhone = store.businessPhone; + const otp = await this.findActiveBusinessPhoneOtp( + store.userId, + businessPhone, + ); + + if ( + !this.isOtpMatch( + otp.codeHash, + code, + OTPPurpose.STORE_BUSINESS_PHONE_VERIFY, + store.businessPhone, + ) + ) { + await this.recordFailedOtpAttempt(otp.id, otp.attempts); + throw new BadRequestException({ + message: "Invalid verification code", + code: "OTP_INVALID", + }); + } + + const now = new Date(); + const updatedStore = await this.prisma.$transaction(async (tx) => { + await tx.oTPCode.update({ + where: { id: otp.id }, + data: { consumedAt: now }, + }); + + // If the account phone is this same number and still unverified, one OTP + // verifies both. The WHERE guards the match, so a differing account phone + // is never touched. + await tx.user.updateMany({ + where: { + id: store.userId, + phone: businessPhone, + phoneVerified: false, + }, + data: { phoneVerified: true }, + }); + + return tx.storeProfile.update({ + where: { id: store.id }, + data: { + businessPhoneVerified: true, + businessPhoneVerifiedAt: now, + }, + select: STORE_OWNER_SELECT, + }); + }); + + void this.verificationService + .tryUpgradeTierForUser(updatedStore.userId) + .catch((error) => { + const message = + error instanceof Error ? error.message : "unknown error"; + this.logger.warn( + `Tier upgrade check after business phone verification failed: ${message}`, + ); + }); + + return this.toOwnerResponse(updatedStore); + } + + private resolveCreateBusinessPhone( + dto: CreateStoreDto, + user: { phone: string; phoneVerified: boolean }, + ): { phone: string; verified: boolean } { + if (dto.useAccountPhoneForBusiness === true) { + if (!user.phoneVerified) { + throw new BadRequestException({ + message: + "Verify your account phone before using it as your business phone", + code: "ACCOUNT_PHONE_NOT_VERIFIED", + }); + } + + return { phone: user.phone, verified: true }; + } + + if (!dto.businessPhone) { + throw new BadRequestException({ + message: "Business phone number is required", + code: "BUSINESS_PHONE_REQUIRED", + }); + } + + return { + phone: dto.businessPhone, + verified: user.phoneVerified && dto.businessPhone === user.phone, + }; + } + + private resolveBusinessPhoneUpdate( + dto: UpdateStoreDto, + store: StoreOwnerRecord, + ): Prisma.StoreProfileUpdateInput | null { + const nextPhone = + dto.useAccountPhoneForBusiness === true + ? store.user.phone + : dto.businessPhone; + + if (nextPhone === undefined || nextPhone === store.businessPhone) { + return null; + } + + if (dto.useAccountPhoneForBusiness === true && !store.user.phoneVerified) { + throw new BadRequestException({ + message: + "Verify your account phone before using it as your business phone", + code: "ACCOUNT_PHONE_NOT_VERIFIED", + }); + } + + const changeState = this.resolveBusinessPhoneChangeState(store); + const verified = store.user.phoneVerified && nextPhone === store.user.phone; + + return { + businessPhone: nextPhone, + businessPhoneVerified: verified, + businessPhoneVerifiedAt: verified ? new Date() : null, + businessPhoneChangeCount: changeState.count, + businessPhoneChangeWindowStartedAt: changeState.windowStartedAt, + }; + } + + private resolveBusinessPhoneChangeState(store: StoreOwnerRecord): { + count: number; + windowStartedAt: Date; + } { + const now = new Date(); + const windowStartedAt = store.businessPhoneChangeWindowStartedAt; + const windowExpired = + !windowStartedAt || + now.getTime() - windowStartedAt.getTime() >= + BUSINESS_PHONE_CHANGE_WINDOW_MS; + + if (windowExpired) { + return { count: 1, windowStartedAt: now }; + } + + if (store.businessPhoneChangeCount >= BUSINESS_PHONE_CHANGE_LIMIT) { + throw new BadRequestException({ + message: + "Business phone can only be changed twice per week. Please contact support if you need another change.", + code: "BUSINESS_PHONE_CHANGE_LIMIT_REACHED", + }); + } + + return { + count: store.businessPhoneChangeCount + 1, + windowStartedAt, + }; + } + + private async issueBusinessPhoneOtp( + userId: string, + phone: string, + ): Promise { + const now = new Date(); + await this.prisma.oTPCode.updateMany({ + where: { + userId, + purpose: OTPPurpose.STORE_BUSINESS_PHONE_VERIFY, + consumedAt: null, + }, + data: { consumedAt: now }, + }); + + const code = this.generateOtpCode(); + const codeHash = this.hashOtp( + code, + OTPPurpose.STORE_BUSINESS_PHONE_VERIFY, + phone, + ); + + await this.prisma.oTPCode.create({ + data: { + userId, + phone, + purpose: OTPPurpose.STORE_BUSINESS_PHONE_VERIFY, + codeHash, + expiresAt: new Date(now.getTime() + OTP_TTL_MS), + }, + }); + + try { + await this.africasTalkingClient.sendSms( + phone, + `Your twizrr business phone verification code is ${code}. It expires in ${OTP_TTL_MINUTES} minutes.`, + ); + } catch (error) { + this.logger.error( + `Business phone verification code delivery failed userId=${userId} error=${this.getErrorName(error)}`, + ); + + throw new ServiceUnavailableException({ + message: OTP_DELIVERY_FAILURE_MESSAGE, + code: "OTP_DELIVERY_FAILED", + }); + } + } + + private async findActiveBusinessPhoneOtp( + userId: string, + phone: string, + ): Promise<{ id: string; codeHash: string; attempts: number }> { + const otp = await this.prisma.oTPCode.findFirst({ + where: { + userId, + phone, + purpose: OTPPurpose.STORE_BUSINESS_PHONE_VERIFY, + consumedAt: null, + expiresAt: { gt: new Date() }, + }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + codeHash: true, + attempts: true, + }, + }); + + if (!otp) { + throw new BadRequestException({ + message: "Verification code expired or not found", + code: "OTP_INVALID_OR_EXPIRED", + }); + } + + if (otp.attempts >= MAX_OTP_ATTEMPTS) { + await this.prisma.oTPCode.update({ + where: { id: otp.id }, + data: { consumedAt: new Date() }, + }); + throw new BadRequestException({ + message: "Verification code expired or not found", + code: "OTP_INVALID_OR_EXPIRED", + }); + } + + return otp; + } + + private async recordFailedOtpAttempt( + id: string, + attempts: number, + ): Promise { + await this.prisma.oTPCode.update({ + where: { id }, + data: { + attempts: { increment: 1 }, + consumedAt: attempts + 1 >= MAX_OTP_ATTEMPTS ? new Date() : undefined, + }, + }); + } + + private generateOtpCode(): string { + return randomInt(0, 1_000_000).toString().padStart(6, "0"); + } + + private hashOtp( + code: string, + purpose: OTPPurpose, + identifier: string, + ): string { + return createHmac("sha256", this.otpSecret) + .update(`${purpose}:${identifier}:${code}`) + .digest("hex"); + } + + private isOtpMatch( + storedHash: string, + code: string, + purpose: OTPPurpose, + identifier: string, + ): boolean { + const candidateHash = this.hashOtp(code, purpose, identifier); + const stored = Buffer.from(storedHash, "hex"); + const candidate = Buffer.from(candidateHash, "hex"); + + return ( + stored.length === candidate.length && timingSafeEqual(stored, candidate) + ); + } + + private getErrorName(error: unknown): string { + return error instanceof Error ? error.name : "UnknownError"; + } + + private handleStoreUpdateError(error: unknown): never { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + const targetFields = this.getPrismaTargetFields(error.meta?.target); + + if ( + targetFields.includes("storeHandle") || + targetFields.includes("store_handle") + ) { + throw new ConflictException({ + message: "Store handle is already taken", + code: "STORE_HANDLE_TAKEN", + }); + } + } + + throw error; + } + + private handleStoreCreateError(error: unknown): never { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + const targetFields = this.getPrismaTargetFields(error.meta?.target); + + if ( + targetFields.includes("storeHandle") || + targetFields.includes("store_handle") + ) { + throw new ConflictException({ + message: "Store handle is already taken", + code: "STORE_HANDLE_TAKEN", + }); + } + + if (targetFields.includes("userId") || targetFields.includes("user_id")) { + throw new ConflictException({ + message: "User already has a store", + code: "STORE_ALREADY_EXISTS", + }); + } + } + + throw error; + } + + private getPrismaTargetFields(target: unknown): string[] { + if (Array.isArray(target)) { + return target.filter( + (field): field is string => typeof field === "string", + ); + } + + if (typeof target === "string") { + return [target]; + } + + return []; + } + + private assertCanCreateStore(dateOfBirth: Date | null): void { + if (!dateOfBirth || this.calculateAge(dateOfBirth) < 18) { + throw new BadRequestException({ + message: "You must be at least 18 years old to create a store", + code: "AGE_RESTRICTED_STORE", + }); + } + } + + private assertAddressRules(dto: CreateStoreDto): void { + if (dto.storeType === StoreType.PHYSICAL && !dto.businessAddress) { + throw new BadRequestException({ + message: "Business address is required for physical stores", + code: "BUSINESS_ADDRESS_REQUIRED", + }); + } + + if (dto.storeType === StoreType.DIGITAL && !dto.homeAddress) { + throw new BadRequestException({ + message: "Pickup address is required for digital stores", + code: "HOME_ADDRESS_REQUIRED", + }); + } + } + + private async maybeUpgradeToTierOne( + store: StoreOwnerRecord, + ): Promise { + if ( + store.tier !== StoreTier.TIER_0 || + !store.user.emailVerified || + !store.user.phoneVerified || + !store.businessPhoneVerified || + !store.bankVerified + ) { + return store; + } + + // STORE_TIER_UPGRADED notification wiring belongs with the notification queue contract. + return this.prisma.storeProfile.update({ + where: { id: store.id }, + data: { + tier: StoreTier.TIER_1, + tierUpgradedAt: new Date(), + verifiedAt: new Date(), + }, + select: STORE_OWNER_SELECT, + }); + } + + private normalizeHandle(handle: string): string { + return handle.trim().replace(/^@/, "").toLowerCase(); + } + + private toAddressJson(address: StoreAddressDto): Prisma.JsonObject { + return { + formattedAddress: address.formattedAddress, + city: address.city, + state: address.state, + ...(address.addressLine2 ? { addressLine2: address.addressLine2 } : {}), + ...(address.postalCode ? { postalCode: address.postalCode } : {}), + ...(address.placeId ? { placeId: address.placeId } : {}), + ...(address.latitude !== undefined ? { latitude: address.latitude } : {}), + ...(address.longitude !== undefined + ? { longitude: address.longitude } + : {}), + }; + } + + private toOwnerResponse(store: StoreOwnerRecord): OwnerStoreResponse { + return { + id: store.id, + userId: store.userId, + storeHandle: store.storeHandle ?? "", + storeName: store.storeName ?? "", + storeType: store.storeType, + bio: store.bio, + logoUrl: store.logoUrl, + bannerUrl: store.bannerUrl, + businessCategory: store.businessCategory, + businessPhone: store.businessPhone, + businessPhoneVerified: store.businessPhoneVerified, + businessPhoneVerifiedAt: + store.businessPhoneVerifiedAt?.toISOString() ?? null, + homeAddress: store.homeAddress, + businessAddress: store.businessAddress, + businessAddressDetails: store.businessAddressDetails, + tier: store.tier, + isOpen: store.isOpen, + showOwnerPublicly: store.showOwnerPublicly, + allowDropship: store.allowDropship, + completedOrders: store.completedOrders, + disputeCountLast6Months: store.disputeCountLast6Months, + dispatchTimeAvgHours: store.dispatchTimeAvgHours, + orderCompletionRateBps: store.orderCompletionRateBps, + bank: { + bankName: store.bankName, + bankCode: store.bankCode, + accountName: store.accountName, + accountNumberLast4: store.accountNumber + ? this.maskAccountNumber(store.accountNumber) + : null, + bankVerified: store.bankVerified, + bankVerifiedAt: store.bankVerifiedAt?.toISOString() ?? null, + }, + owner: { + id: store.user.id, + username: store.user.username, + displayName: store.user.displayName, + emailVerified: store.user.emailVerified, + phoneVerified: store.user.phoneVerified, + }, + counts: { + followers: store._count.followers, + products: + store.storeType === StoreType.DIGITAL + ? store._count.digitalSourcedProducts + : store._count.products, + orders: store._count.orders, + }, + verifiedAt: store.verifiedAt?.toISOString() ?? null, + tierUpgradedAt: store.tierUpgradedAt?.toISOString() ?? null, + createdAt: store.createdAt.toISOString(), + updatedAt: store.updatedAt.toISOString(), + }; + } + + private ownerSafeLocation(store: StoreOwnerRecord): string | null { + if (store.storeType === StoreType.PHYSICAL) { + return store.businessAddress; + } + + if ( + store.homeAddress && + !Array.isArray(store.homeAddress) && + typeof store.homeAddress === "object" + ) { + const address = store.homeAddress as Prisma.JsonObject; + const city = address.city; + const state = address.state; + + if (typeof city === "string" && typeof state === "string") { + return `${city}, ${state}`; + } + } + + return null; + } + + private maskAccountNumber(accountNumber: string): string { + return accountNumber.slice(-4); + } + + private calculateAge(dateOfBirth: Date): number { + const now = new Date(); + let age = now.getFullYear() - dateOfBirth.getFullYear(); + const hasHadBirthdayThisYear = + now.getMonth() > dateOfBirth.getMonth() || + (now.getMonth() === dateOfBirth.getMonth() && + now.getDate() >= dateOfBirth.getDate()); + + if (!hasHadBirthdayThisYear) { + age -= 1; + } + + return age; + } +} diff --git a/apps/backend/src/domains/users/user/dto/update-addresses.dto.ts b/apps/backend/src/domains/users/user/dto/update-addresses.dto.ts new file mode 100644 index 00000000..b905015e --- /dev/null +++ b/apps/backend/src/domains/users/user/dto/update-addresses.dto.ts @@ -0,0 +1,65 @@ +import { Transform, Type } from "class-transformer"; +import { + IsArray, + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from "class-validator"; + +export class DeliveryAddressDto { + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsOptional() + @IsString() + @MaxLength(80) + id?: string; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsString() + @IsNotEmpty() + @MaxLength(40) + label!: string; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsString() + @IsNotEmpty() + @MaxLength(160) + street!: string; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsOptional() + @IsString() + @MaxLength(160) + line2?: string; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsString() + @IsNotEmpty() + @MaxLength(80) + city!: string; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsString() + @IsNotEmpty() + @MaxLength(80) + state!: string; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsOptional() + @IsString() + @MaxLength(20) + postalCode?: string; + + @IsOptional() + @IsBoolean() + isDefault?: boolean; +} + +export class UpdateAddressesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => DeliveryAddressDto) + addresses!: DeliveryAddressDto[]; +} diff --git a/apps/backend/src/domains/users/user/dto/update-date-of-birth.dto.ts b/apps/backend/src/domains/users/user/dto/update-date-of-birth.dto.ts new file mode 100644 index 00000000..e7a50b49 --- /dev/null +++ b/apps/backend/src/domains/users/user/dto/update-date-of-birth.dto.ts @@ -0,0 +1,18 @@ +import { Type } from "class-transformer"; +import { IsInt, Max, Min } from "class-validator"; + +// Only the day and month of birth may be corrected. The year is never accepted +// here so a user cannot shift their age bracket (age-gate stays intact). +export class UpdateDateOfBirthDto { + @Type(() => Number) + @IsInt() + @Min(1) + @Max(31) + day!: number; + + @Type(() => Number) + @IsInt() + @Min(1) + @Max(12) + month!: number; +} diff --git a/apps/backend/src/domains/users/user/dto/update-delivery-preferences.dto.ts b/apps/backend/src/domains/users/user/dto/update-delivery-preferences.dto.ts new file mode 100644 index 00000000..5c96e313 --- /dev/null +++ b/apps/backend/src/domains/users/user/dto/update-delivery-preferences.dto.ts @@ -0,0 +1,11 @@ +import { Transform } from "class-transformer"; +import { IsOptional, Matches } from "class-validator"; + +export class UpdateDeliveryPreferencesDto { + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsOptional() + @Matches(/^\+[1-9]\d{7,14}$/, { + message: "Alternative delivery phone must be a valid E.164 phone number.", + }) + alternativeDeliveryPhone?: string | null; +} diff --git a/apps/backend/src/domains/users/user/dto/update-location-preference.dto.ts b/apps/backend/src/domains/users/user/dto/update-location-preference.dto.ts new file mode 100644 index 00000000..9992e08d --- /dev/null +++ b/apps/backend/src/domains/users/user/dto/update-location-preference.dto.ts @@ -0,0 +1,44 @@ +import { Transform } from "class-transformer"; +import { IsOptional, IsString, Matches, MaxLength } from "class-validator"; + +const trimOptional = ({ value }: { value: unknown }) => + typeof value === "string" ? value.trim() || undefined : value; + +export class UpdateLocationPreferenceDto { + @Transform(({ value }) => + typeof value === "string" ? value.trim().toUpperCase() : value, + ) + @IsString() + @Matches(/^[A-Z]{2}$/) + countryCode!: string; + + @Transform(trimOptional) + @IsOptional() + @IsString() + @MaxLength(80) + countryName?: string; + + @Transform(trimOptional) + @IsOptional() + @IsString() + @MaxLength(80) + state?: string; + + @Transform(trimOptional) + @IsOptional() + @IsString() + @MaxLength(80) + city?: string; + + @Transform(trimOptional) + @IsOptional() + @IsString() + @MaxLength(80) + area?: string; + + @Transform(trimOptional) + @IsOptional() + @IsString() + @MaxLength(120) + label?: string; +} diff --git a/apps/backend/src/domains/users/user/dto/update-measurements.dto.ts b/apps/backend/src/domains/users/user/dto/update-measurements.dto.ts new file mode 100644 index 00000000..4d8125c5 --- /dev/null +++ b/apps/backend/src/domains/users/user/dto/update-measurements.dto.ts @@ -0,0 +1,39 @@ +import { IsNumber, IsOptional, Max, Min } from "class-validator"; + +export class UpdateMeasurementsDto { + @IsOptional() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + @Max(300) + bustCm?: number; + + @IsOptional() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + @Max(300) + waistCm?: number; + + @IsOptional() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + @Max(300) + hipsCm?: number; + + @IsOptional() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + @Max(250) + heightCm?: number; + + @IsOptional() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + @Max(60) + shoeSizeEU?: number; + + @IsOptional() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + @Max(40) + footLengthCm?: number; +} diff --git a/apps/backend/src/domains/users/user/dto/update-phone.dto.ts b/apps/backend/src/domains/users/user/dto/update-phone.dto.ts new file mode 100644 index 00000000..fad142d6 --- /dev/null +++ b/apps/backend/src/domains/users/user/dto/update-phone.dto.ts @@ -0,0 +1,25 @@ +import { Transform } from "class-transformer"; +import { + IsBoolean, + IsOptional, + IsString, + Matches, + ValidateIf, +} from "class-validator"; + +export class UpdatePhoneDto { + // Required unless reusing the business phone, in which case the number comes + // from the store record server-side. + @ValidateIf((dto: UpdatePhoneDto) => dto.useBusinessPhoneForAccount !== true) + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsString() + @Matches(/^\+234[789]\d{9}$/, { + message: "phone must be a valid Nigerian E.164 number", + }) + phone?: string; + + // Reuse the store's (verified) business phone as the account phone. + @IsOptional() + @IsBoolean() + useBusinessPhoneForAccount?: boolean; +} diff --git a/apps/backend/src/domains/users/user/dto/update-user.dto.ts b/apps/backend/src/domains/users/user/dto/update-user.dto.ts new file mode 100644 index 00000000..2a217b6d --- /dev/null +++ b/apps/backend/src/domains/users/user/dto/update-user.dto.ts @@ -0,0 +1,44 @@ +import { Transform } from "class-transformer"; +import { + IsOptional, + IsString, + IsUrl, + Matches, + MaxLength, + MinLength, +} from "class-validator"; + +export class UpdateUserDto { + @Transform(({ value }) => + typeof value === "string" ? value.trim().replace(/\s+/g, " ") : value, + ) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(80) + displayName?: string; + + @Transform(({ value }) => + typeof value === "string" ? value.trim().toLowerCase() : value, + ) + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(20) + @Matches(/^[a-z0-9_]+$/, { + message: + "username can only contain lowercase letters, numbers, and underscores", + }) + username?: string; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsOptional() + @IsString() + @MaxLength(180) + bio?: string; + + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsOptional() + @IsUrl({ require_protocol: true }) + profilePhotoUrl?: string; +} diff --git a/apps/backend/src/domains/users/user/user.controller.ts b/apps/backend/src/domains/users/user/user.controller.ts new file mode 100644 index 00000000..3bebee9a --- /dev/null +++ b/apps/backend/src/domains/users/user/user.controller.ts @@ -0,0 +1,179 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + UseGuards, +} from "@nestjs/common"; + +import { CurrentUser } from "../../../common/decorators/current-user.decorator"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { AuthenticatedRequestUser } from "../auth/auth.types"; +import { UpdateAddressesDto } from "./dto/update-addresses.dto"; +import { UpdateDateOfBirthDto } from "./dto/update-date-of-birth.dto"; +import { UpdateDeliveryPreferencesDto } from "./dto/update-delivery-preferences.dto"; +import { UpdateMeasurementsDto } from "./dto/update-measurements.dto"; +import { UpdateLocationPreferenceDto } from "./dto/update-location-preference.dto"; +import { UpdatePhoneDto } from "./dto/update-phone.dto"; +import { UpdateUserDto } from "./dto/update-user.dto"; +import { UserService } from "./user.service"; + +@Controller("users") +export class UserController { + constructor(private readonly userService: UserService) {} + + @UseGuards(JwtAuthGuard) + @Get("me") + getMe(@CurrentUser() user: AuthenticatedRequestUser) { + return this.userService.getMe(user.id); + } + + @UseGuards(JwtAuthGuard) + @Put("me") + updateMe( + @CurrentUser() user: AuthenticatedRequestUser, + @Body() dto: UpdateUserDto, + ) { + return this.userService.updateMe(user.id, dto); + } + + @UseGuards(JwtAuthGuard) + @Put("me/phone") + updatePhone( + @CurrentUser() user: AuthenticatedRequestUser, + @Body() dto: UpdatePhoneDto, + ) { + return this.userService.updatePhone(user.id, dto); + } + + @UseGuards(JwtAuthGuard) + @Put("me/date-of-birth") + updateDateOfBirth( + @CurrentUser() user: AuthenticatedRequestUser, + @Body() dto: UpdateDateOfBirthDto, + ) { + return this.userService.updateDateOfBirth(user.id, dto); + } + + @UseGuards(JwtAuthGuard) + @Put("me/measurements") + updateMeasurements( + @CurrentUser() user: AuthenticatedRequestUser, + @Body() dto: UpdateMeasurementsDto, + ) { + return this.userService.updateMeasurements(user.id, dto); + } + + @UseGuards(JwtAuthGuard) + @Put("me/addresses") + updateAddresses( + @CurrentUser() user: AuthenticatedRequestUser, + @Body() dto: UpdateAddressesDto, + ) { + return this.userService.updateAddresses(user.id, dto); + } + + @UseGuards(JwtAuthGuard) + @Get("me/addresses") + getAddresses(@CurrentUser() user: AuthenticatedRequestUser) { + return this.userService.getAddresses(user.id); + } + + @UseGuards(JwtAuthGuard) + @Get("me/location-preference") + getLocationPreference(@CurrentUser() user: AuthenticatedRequestUser) { + return this.userService.getLocationPreference(user.id); + } + + @UseGuards(JwtAuthGuard) + @Put("me/location-preference") + updateLocationPreference( + @CurrentUser() user: AuthenticatedRequestUser, + @Body() dto: UpdateLocationPreferenceDto, + ) { + return this.userService.upsertLocationPreference(user.id, dto); + } + + @UseGuards(JwtAuthGuard) + @Delete("me/location-preference") + clearLocationPreference(@CurrentUser() user: AuthenticatedRequestUser) { + return this.userService.clearLocationPreference(user.id); + } + + @UseGuards(JwtAuthGuard) + @Get("me/delivery-preferences") + getDeliveryPreferences(@CurrentUser() user: AuthenticatedRequestUser) { + return this.userService.getDeliveryPreferences(user.id); + } + + @UseGuards(JwtAuthGuard) + @Put("me/delivery-preferences") + updateDeliveryPreferences( + @CurrentUser() user: AuthenticatedRequestUser, + @Body() dto: UpdateDeliveryPreferencesDto, + ) { + return this.userService.updateDeliveryPreferences(user.id, dto); + } + + @UseGuards(JwtAuthGuard) + @Get("me/wizza") + getWizzaConnection(@CurrentUser() user: AuthenticatedRequestUser) { + return this.userService.getWizzaConnection(user.id); + } + + @UseGuards(JwtAuthGuard) + @Delete("me/wizza") + unlinkWizza(@CurrentUser() user: AuthenticatedRequestUser) { + return this.userService.unlinkWizza(user.id); + } + + @UseGuards(JwtAuthGuard) + @Post(":userId/follow") + followUser( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("userId") userId: string, + ) { + return this.userService.followUser(user.id, userId); + } + + @UseGuards(JwtAuthGuard) + @Delete(":userId/follow") + unfollowUser( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("userId") userId: string, + ) { + return this.userService.unfollowUser(user.id, userId); + } + + @UseGuards(JwtAuthGuard) + @Get(":userId/follow-status") + getFollowStatus( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("userId") userId: string, + ) { + return this.userService.getFollowStatus(user.id, userId); + } + + @Get(":username/followers") + listFollowers(@Param("username") username: string) { + return this.userService.listFollowers(username); + } + + @Get(":username/following") + listFollowing(@Param("username") username: string) { + return this.userService.listFollowing(username); + } + + @Get(":username/verified-followers") + listVerifiedFollowers(@Param("username") username: string) { + return this.userService.listVerifiedFollowers(username); + } + + @Get(":username") + getPublicProfile(@Param("username") username: string) { + return this.userService.getPublicProfile(username); + } +} diff --git a/apps/backend/src/domains/users/user/user.location-preference.spec.ts b/apps/backend/src/domains/users/user/user.location-preference.spec.ts new file mode 100644 index 00000000..b7ee68e2 --- /dev/null +++ b/apps/backend/src/domains/users/user/user.location-preference.spec.ts @@ -0,0 +1,129 @@ +import { plainToInstance } from "class-transformer"; +import { validateSync } from "class-validator"; + +import { UserController } from "./user.controller"; +import { UpdateLocationPreferenceDto } from "./dto/update-location-preference.dto"; +import { UserService } from "./user.service"; + +describe("User location preference", () => { + const now = new Date("2026-07-16T12:00:00.000Z"); + const preference = { + countryCode: "NG", + countryName: "Nigeria", + state: "Lagos", + city: "Lagos", + area: "Yaba", + label: "Lagos discovery", + source: "manual", + isActive: true, + createdAt: now, + updatedAt: now, + }; + const prisma = { + userLocationPreference: { + findFirst: jest.fn(), + upsert: jest.fn(), + updateMany: jest.fn(), + }, + }; + const notificationService = {}; + const verificationService = {}; + let service: UserService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new UserService( + prisma as never, + notificationService as never, + verificationService as never, + ); + }); + + it("returns null when a user has not set a preference", async () => { + prisma.userLocationPreference.findFirst.mockResolvedValue(null); + + await expect(service.getLocationPreference("user-1")).resolves.toBeNull(); + expect(prisma.userLocationPreference.findFirst).toHaveBeenCalledWith({ + where: { userId: "user-1", isActive: true }, + }); + }); + + it("creates a manual discovery preference without changing delivery addresses", async () => { + prisma.userLocationPreference.upsert.mockResolvedValue(preference); + + const result = await service.upsertLocationPreference("user-1", { + countryCode: "NG", + countryName: "Nigeria", + state: "Lagos", + city: "Lagos", + area: "Yaba", + label: "Lagos discovery", + }); + + expect(result).toEqual(expect.objectContaining({ source: "manual" })); + expect(prisma.userLocationPreference.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1" }, + create: expect.objectContaining({ + source: "manual", + isActive: true, + countryCode: "NG", + }), + update: expect.objectContaining({ source: "manual", isActive: true }), + }), + ); + expect( + JSON.stringify(prisma.userLocationPreference.upsert.mock.calls), + ).not.toContain("deliveryAddresses"); + }); + + it("updates the single existing preference and can clear it", async () => { + prisma.userLocationPreference.upsert.mockResolvedValue({ + ...preference, + city: "Ikeja", + }); + prisma.userLocationPreference.updateMany.mockResolvedValue({ count: 1 }); + + await service.upsertLocationPreference("user-1", { + countryCode: "NG", + city: "Ikeja", + }); + await expect(service.clearLocationPreference("user-1")).resolves.toEqual({ + cleared: true, + }); + expect(prisma.userLocationPreference.updateMany).toHaveBeenCalledWith({ + where: { userId: "user-1", isActive: true }, + data: { isActive: false }, + }); + }); + + it("validates a trimmed, uppercase ISO-like country code and rejects invalid or long values", () => { + const valid = plainToInstance(UpdateLocationPreferenceDto, { + countryCode: " ng ", + city: " Lagos ", + }); + expect(validateSync(valid)).toHaveLength(0); + expect(valid.countryCode).toBe("NG"); + expect(valid.city).toBe("Lagos"); + + const invalid = plainToInstance(UpdateLocationPreferenceDto, { + countryCode: "NGA", + area: "x".repeat(81), + }); + expect(validateSync(invalid).length).toBeGreaterThan(0); + }); + + it("routes updates only through the authenticated subject user", () => { + const userService = { + upsertLocationPreference: jest.fn(), + }; + const controller = new UserController(userService as never); + const dto = { countryCode: "NG" } as UpdateLocationPreferenceDto; + + controller.updateLocationPreference({ id: "current-user" } as never, dto); + expect(userService.upsertLocationPreference).toHaveBeenCalledWith( + "current-user", + dto, + ); + }); +}); diff --git a/apps/backend/src/domains/users/user/user.module.ts b/apps/backend/src/domains/users/user/user.module.ts new file mode 100644 index 00000000..14fb1ab3 --- /dev/null +++ b/apps/backend/src/domains/users/user/user.module.ts @@ -0,0 +1,14 @@ +import { forwardRef, Module } from "@nestjs/common"; + +import { NotificationModule } from "../../social/notification/notification.module"; +import { VerificationModule } from "../verification/verification.module"; +import { UserController } from "./user.controller"; +import { UserService } from "./user.service"; + +@Module({ + imports: [NotificationModule, forwardRef(() => VerificationModule)], + controllers: [UserController], + providers: [UserService], + exports: [UserService], +}) +export class UserModule {} diff --git a/apps/backend/src/domains/users/user/user.service.ts b/apps/backend/src/domains/users/user/user.service.ts new file mode 100644 index 00000000..53badb68 --- /dev/null +++ b/apps/backend/src/domains/users/user/user.service.ts @@ -0,0 +1,1142 @@ +import { + BadRequestException, + ConflictException, + forwardRef, + Inject, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { + FollowTargetType, + ModerationStatus, + NotificationType, + PostType, + Prisma, + ProductStatus, + StoreTier, +} from "@prisma/client"; +import { randomUUID } from "crypto"; + +import { PrismaService } from "../../../core/prisma/prisma.service"; +import { NotificationService } from "../../social/notification/notification.service"; +import { VerificationService } from "../verification/verification.service"; +import { UpdateAddressesDto } from "./dto/update-addresses.dto"; +import { UpdateDateOfBirthDto } from "./dto/update-date-of-birth.dto"; +import { UpdateDeliveryPreferencesDto } from "./dto/update-delivery-preferences.dto"; +import { UpdateMeasurementsDto } from "./dto/update-measurements.dto"; +import { UpdateLocationPreferenceDto } from "./dto/update-location-preference.dto"; +import { UpdatePhoneDto } from "./dto/update-phone.dto"; +import { UpdateUserDto } from "./dto/update-user.dto"; + +const USERNAME_LOCK_DAYS = 7; +const USERNAME_LOCK_MS = USERNAME_LOCK_DAYS * 24 * 60 * 60 * 1000; +const PUBLIC_POST_COUNT_WHERE = { + type: { + in: [PostType.PRODUCT_POST, PostType.IMAGE_POST, PostType.GIST], + }, + isActive: true, + moderationStatus: { not: ModerationStatus.BLOCKED }, + OR: [ + { taggedProductId: null }, + { + taggedProduct: { + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + storeProfile: { tier: { not: StoreTier.TIER_0 } }, + }, + }, + ], +} satisfies Prisma.PostWhereInput; + +interface UserCountShape { + followerRelations: number; + followingRelations: number; + posts: number; +} + +interface SelectedUserProfile { + id: string; + email: string; + phone: string; + username: string | null; + displayName: string | null; + bio: string | null; + profilePhotoUrl: string | null; + dateOfBirth: Date | null; + bodyMeasurements: Prisma.JsonValue | null; + deliveryAddresses: Prisma.JsonValue | null; + alternativeDeliveryPhone: string | null; + emailVerified: boolean; + phoneVerified: boolean; + createdAt: Date; + updatedAt: Date; + lastUsernameChangedAt: Date | null; + _count: UserCountShape; +} + +export interface PublicUserProfile { + id: string; + username: string | null; + displayName: string | null; + bio: string | null; + profilePhotoUrl: string | null; + memberSince: string; + followerCount: number; + followingCount: number; + postCount: number; +} + +export interface PublicFollowerProfile { + id: string; + type: "USER" | "STORE"; + username: string | null; + displayName: string | null; + bio: string | null; + profilePhotoUrl: string | null; + followerCount: number; + verified: boolean; + href: string | null; +} + +export interface PublicFollowList { + profile: PublicUserProfile; + items: PublicFollowerProfile[]; +} + +export interface OwnUserProfile extends PublicUserProfile { + email: string; + phone: string; + dateOfBirth: string | null; + bodyMeasurements: Prisma.JsonValue | null; + deliveryAddresses: Prisma.JsonValue | null; + alternativeDeliveryPhone: string | null; + isEmailVerified: boolean; + isPhoneVerified: boolean; + isMinor: boolean; + lastUsernameChangedAt: string | null; + updatedAt: string; +} + +export interface UserLocationPreferenceResponse { + countryCode: string; + countryName: string | null; + state: string | null; + city: string | null; + area: string | null; + label: string | null; + source: "manual"; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +type DeliveryAddress = Prisma.JsonObject & { + id: string; + label: string; + street: string; + line2?: string; + city: string; + state: string; + postalCode?: string; + isDefault: boolean; +}; + +@Injectable() +export class UserService { + private readonly logger = new Logger(UserService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly notificationService: NotificationService, + @Inject(forwardRef(() => VerificationService)) + private readonly verificationService: VerificationService, + ) {} + + async getMe(userId: string): Promise { + const user = await this.findUserProfileById(userId); + return this.toOwnProfile(user); + } + + async updateMe(userId: string, dto: UpdateUserDto): Promise { + const currentUser = await this.findUserProfileById(userId); + const data: Prisma.UserUpdateInput = {}; + + if (dto.displayName !== undefined) { + data.displayName = dto.displayName; + const { firstName, lastName } = this.splitDisplayName(dto.displayName); + data.firstName = firstName; + data.lastName = lastName; + } + + if (dto.bio !== undefined) { + data.bio = dto.bio; + } + + if (dto.profilePhotoUrl !== undefined) { + data.profilePhotoUrl = dto.profilePhotoUrl; + } + + if (dto.username !== undefined && dto.username !== currentUser.username) { + await this.assertUsernameCanChange(currentUser, dto.username); + data.username = dto.username; + data.lastUsernameChangedAt = new Date(); + } + + if (Object.keys(data).length === 0) { + return this.toOwnProfile(currentUser); + } + + const updatedUser = await this.prisma.user.update({ + where: { id: userId }, + data, + select: this.userProfileSelect(), + }); + + return this.toOwnProfile(updatedUser); + } + + // Change the account phone number. A new number is always unverified until the + // OTP flow confirms it, so this resets phoneVerified — the session-bound + // /auth/otp send + verify flow then re-verifies against the new number. When + // useBusinessPhoneForAccount is set, the number is copied from the store's + // verified business phone and lands already verified. + async updatePhone( + userId: string, + dto: UpdatePhoneDto, + ): Promise { + const currentUser = await this.findUserProfileById(userId); + + if (dto.useBusinessPhoneForAccount === true) { + const store = await this.prisma.storeProfile.findUnique({ + where: { userId }, + select: { businessPhone: true, businessPhoneVerified: true }, + }); + + if (!store?.businessPhone) { + throw new BadRequestException({ + message: + "Add a business phone number before using it as your account phone", + code: "BUSINESS_PHONE_REQUIRED", + }); + } + + if (!store.businessPhoneVerified) { + throw new BadRequestException({ + message: + "Verify your business phone before using it as your account phone", + code: "BUSINESS_PHONE_NOT_VERIFIED", + }); + } + + // The business phone is verified, so the account phone lands verified. + return this.writeAccountPhone( + userId, + store.businessPhone, + true, + currentUser, + ); + } + + const normalized = dto.phone?.trim(); + if (!normalized) { + throw new BadRequestException({ + message: "Phone number is required", + code: "PHONE_REQUIRED", + }); + } + + // No change — leave verification status untouched. + if (normalized === currentUser.phone) { + return this.toOwnProfile(currentUser); + } + + return this.writeAccountPhone(userId, normalized, false, currentUser); + } + + private async writeAccountPhone( + userId: string, + phone: string, + verified: boolean, + currentUser: SelectedUserProfile, + ): Promise { + // Nothing to change (same number, same verification state). + if (phone === currentUser.phone && currentUser.phoneVerified === verified) { + return this.toOwnProfile(currentUser); + } + + try { + const updatedUser = await this.prisma.user.update({ + where: { id: userId }, + data: { phone, phoneVerified: verified }, + select: this.userProfileSelect(), + }); + + // When this write verifies the account phone (e.g. reusing a verified + // business phone), re-evaluate Tier 1 — matching the other verification + // paths in AuthService and StoreService, which never fires on a normal + // number change (verified = false). + if (verified) { + void this.verificationService + .tryUpgradeTierForUser(userId) + .catch((error) => { + const message = + error instanceof Error ? error.message : "unknown error"; + this.logger.warn( + `Tier upgrade check after account phone reuse failed: ${message}`, + ); + }); + } + + return this.toOwnProfile(updatedUser); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + throw new ConflictException({ + message: "That phone number is already in use", + code: "PHONE_TAKEN", + }); + } + throw error; + } + } + + // Correct the day/month of birth only. The year is never changed here, so the + // computed age (and the age-gate) stays intact — a user cannot become older or + // younger by editing this. + async updateDateOfBirth( + userId: string, + dto: UpdateDateOfBirthDto, + ): Promise { + const currentUser = await this.findUserProfileById(userId); + + if (!currentUser.dateOfBirth) { + throw new BadRequestException({ + message: "No date of birth on file to update.", + code: "DOB_MISSING", + }); + } + + const year = currentUser.dateOfBirth.getUTCFullYear(); + const nextDateOfBirth = new Date(Date.UTC(year, dto.month - 1, dto.day)); + + // Reject impossible dates (e.g. 31 February rolls over to March). + if ( + nextDateOfBirth.getUTCFullYear() !== year || + nextDateOfBirth.getUTCMonth() !== dto.month - 1 || + nextDateOfBirth.getUTCDate() !== dto.day + ) { + throw new BadRequestException({ + message: "That date does not exist. Check the day and month.", + code: "DOB_INVALID", + }); + } + + const updatedUser = await this.prisma.user.update({ + where: { id: userId }, + data: { dateOfBirth: nextDateOfBirth }, + select: this.userProfileSelect(), + }); + + return this.toOwnProfile(updatedUser); + } + + async getPublicProfile(username: string): Promise { + const normalizedUsername = username.trim().toLowerCase(); + const user = await this.prisma.user.findFirst({ + where: { + username: normalizedUsername, + isActive: true, + deletedAt: null, + }, + select: this.publicUserProfileSelect(), + }); + + if (!user) { + throw new NotFoundException({ + message: "User not found", + code: "USER_NOT_FOUND", + }); + } + + return this.toPublicProfile(user); + } + + async updateMeasurements( + userId: string, + dto: UpdateMeasurementsDto, + ): Promise<{ bodyMeasurements: Prisma.JsonObject }> { + const bodyMeasurements = this.toMeasurementJson(dto); + + await this.prisma.user.update({ + where: { id: userId }, + data: { bodyMeasurements }, + select: { id: true }, + }); + + return { bodyMeasurements }; + } + + async updateAddresses( + userId: string, + dto: UpdateAddressesDto, + ): Promise<{ deliveryAddresses: DeliveryAddress[] }> { + const deliveryAddresses = this.normalizeAddresses(dto.addresses); + + await this.prisma.user.update({ + where: { id: userId }, + data: { deliveryAddresses }, + select: { id: true }, + }); + + return { deliveryAddresses }; + } + + async getAddresses(userId: string): Promise<{ + deliveryAddresses: Prisma.JsonValue | null; + }> { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { deliveryAddresses: true }, + }); + + if (!user) { + throw new NotFoundException({ + message: "User not found", + code: "USER_NOT_FOUND", + }); + } + + return { deliveryAddresses: user.deliveryAddresses }; + } + + /** + * Discovery-only preference. It is deliberately separate from delivery + * addresses and never reads request IP or security geolocation metadata. + */ + async getLocationPreference( + userId: string, + ): Promise { + const preference = await this.prisma.userLocationPreference.findFirst({ + where: { userId, isActive: true }, + }); + return preference ? this.toLocationPreferenceResponse(preference) : null; + } + + async upsertLocationPreference( + userId: string, + dto: UpdateLocationPreferenceDto, + ): Promise { + const preference = await this.prisma.userLocationPreference.upsert({ + where: { userId }, + create: { + userId, + countryCode: dto.countryCode, + countryName: dto.countryName ?? null, + state: dto.state ?? null, + city: dto.city ?? null, + area: dto.area ?? null, + label: dto.label ?? null, + source: "manual", + isActive: true, + }, + update: { + countryCode: dto.countryCode, + countryName: dto.countryName ?? null, + state: dto.state ?? null, + city: dto.city ?? null, + area: dto.area ?? null, + label: dto.label ?? null, + source: "manual", + isActive: true, + }, + }); + return this.toLocationPreferenceResponse(preference); + } + + async clearLocationPreference(userId: string): Promise<{ cleared: boolean }> { + const result = await this.prisma.userLocationPreference.updateMany({ + where: { userId, isActive: true }, + data: { isActive: false }, + }); + return { cleared: result.count > 0 }; + } + + async getDeliveryPreferences(userId: string): Promise<{ + alternativeDeliveryPhone: string | null; + }> { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { alternativeDeliveryPhone: true }, + }); + + if (!user) { + throw new NotFoundException({ + message: "User not found", + code: "USER_NOT_FOUND", + }); + } + + return { alternativeDeliveryPhone: user.alternativeDeliveryPhone }; + } + + async updateDeliveryPreferences( + userId: string, + dto: UpdateDeliveryPreferencesDto, + ): Promise<{ alternativeDeliveryPhone: string | null }> { + const alternativeDeliveryPhone = + typeof dto.alternativeDeliveryPhone === "string" && + dto.alternativeDeliveryPhone.trim().length > 0 + ? dto.alternativeDeliveryPhone.trim() + : null; + + const user = await this.prisma.user.update({ + where: { id: userId }, + data: { alternativeDeliveryPhone }, + select: { alternativeDeliveryPhone: true }, + }); + + return { alternativeDeliveryPhone: user.alternativeDeliveryPhone }; + } + + /** + * Wizza (WhatsApp AI assistant) connection status for the current user. A user + * is linked when a WhatsAppSession points at them. Read-only; the phone is + * masked so the full number is never returned to the client. + */ + async getWizzaConnection(userId: string): Promise<{ + isLinked: boolean; + linkedPhone: string | null; + linkedAt: string | null; + consentGiven: boolean; + lastMessageAt: string | null; + }> { + const session = await this.prisma.whatsAppSession.findFirst({ + where: { userId }, + orderBy: { updatedAt: "desc" }, + select: { + phone: true, + consentGiven: true, + createdAt: true, + lastMessageAt: true, + }, + }); + + if (!session) { + return { + isLinked: false, + linkedPhone: null, + linkedAt: null, + consentGiven: false, + lastMessageAt: null, + }; + } + + return { + isLinked: true, + linkedPhone: this.maskPhone(session.phone), + linkedAt: session.createdAt.toISOString(), + consentGiven: session.consentGiven, + lastMessageAt: session.lastMessageAt?.toISOString() ?? null, + }; + } + + /** + * Unlinks Wizza from the current user by detaching the WhatsApp session(s) + * (sets userId to null). Safe and reversible: it revokes the association only + * — it never deletes the session, touches credentials, or grants access. + */ + async unlinkWizza(userId: string): Promise<{ isLinked: false }> { + await this.prisma.whatsAppSession.updateMany({ + where: { userId }, + data: { userId: null }, + }); + return { isLinked: false }; + } + + /** Masks a phone to its last 4 digits for safe display (e.g. "**** 6789"). */ + private maskPhone(phone: string): string { + const compact = phone.replace(/\D/g, ""); + if (compact.length <= 4) { + return phone; + } + return `**** ${compact.slice(-4)}`; + } + + async followUser( + followerId: string, + targetUserId: string, + ): Promise<{ following: true }> { + if (followerId === targetUserId) { + throw new BadRequestException({ + message: "You cannot follow yourself", + code: "SELF_FOLLOW_NOT_ALLOWED", + }); + } + + await this.assertUserExists(targetUserId); + + let follower: { + id: string; + username: string | null; + displayName: string | null; + profilePhotoUrl: string | null; + } | null = null; + + try { + [, follower] = await this.prisma.$transaction([ + this.prisma.followRelation.create({ + data: { + followerId, + targetUserId, + targetType: FollowTargetType.USER, + }, + select: { id: true }, + }), + this.prisma.user.findUnique({ + where: { id: followerId }, + select: { + id: true, + username: true, + displayName: true, + profilePhotoUrl: true, + }, + }), + ]); + } catch (error) { + if (this.isUniqueConstraintError(error)) { + throw new ConflictException({ + message: "Already following this user", + code: "FOLLOW_ALREADY_EXISTS", + }); + } + + throw error; + } + + if (follower) { + void this.notificationService + .createInApp(targetUserId, NotificationType.NEW_FOLLOWER, { + followerId: follower.id, + username: follower.username, + displayName: follower.displayName, + profilePhotoUrl: follower.profilePhotoUrl, + }) + .catch(() => undefined); + } + + return { following: true }; + } + + async getFollowStatus( + followerId: string, + targetUserId: string, + ): Promise<{ following: boolean }> { + if (followerId === targetUserId) { + return { following: false }; + } + + const existingFollow = await this.prisma.followRelation.findUnique({ + where: { + followerId_targetUserId: { + followerId, + targetUserId, + }, + }, + select: { id: true }, + }); + + return { following: Boolean(existingFollow) }; + } + + async listFollowers(username: string): Promise { + return this.listUserFollowers(username, false); + } + + async listVerifiedFollowers(username: string): Promise { + return this.listUserFollowers(username, true); + } + + async listFollowing(username: string): Promise { + const user = await this.findPublicUserByUsername(username); + + const rows = await this.prisma.followRelation.findMany({ + where: { + followerId: user.id, + OR: [ + { + targetUser: { + is: { + isActive: true, + deletedAt: null, + }, + }, + }, + { + targetStore: { + is: { + isOpen: true, + }, + }, + }, + ], + }, + orderBy: { createdAt: "desc" }, + take: 50, + select: { + targetType: true, + targetUser: { + select: this.socialUserSelect(), + }, + targetStore: { + select: { + id: true, + storeHandle: true, + storeName: true, + businessName: true, + bio: true, + description: true, + logoUrl: true, + profileImage: true, + tier: true, + _count: { + select: { + followers: true, + targetedFollowRelations: true, + }, + }, + }, + }, + }, + }); + + return { + profile: this.toPublicProfile(user), + items: rows + .map((row) => { + if (row.targetType === FollowTargetType.USER && row.targetUser) { + return this.toPublicFollowerUser(row.targetUser); + } + + if (row.targetType === FollowTargetType.STORE && row.targetStore) { + const store = row.targetStore; + const handle = store.storeHandle; + return { + id: store.id, + type: "STORE" as const, + username: handle, + displayName: + store.storeName ?? store.businessName ?? "twizrr store", + bio: store.bio ?? store.description, + profilePhotoUrl: store.logoUrl ?? store.profileImage, + followerCount: + store._count.followers + store._count.targetedFollowRelations, + verified: store.tier !== StoreTier.TIER_0, + href: handle ? `/stores/${handle}` : null, + }; + } + + return null; + }) + .filter((item): item is PublicFollowerProfile => item !== null), + }; + } + + private async listUserFollowers( + username: string, + verifiedOnly: boolean, + ): Promise { + const user = await this.findPublicUserByUsername(username); + + const rows = await this.prisma.followRelation.findMany({ + where: { + targetUserId: user.id, + targetType: FollowTargetType.USER, + follower: { + isActive: true, + deletedAt: null, + ...(verifiedOnly + ? { + storeProfile: { + is: { + tier: { + not: StoreTier.TIER_0, + }, + }, + }, + } + : {}), + }, + }, + orderBy: { createdAt: "desc" }, + take: 50, + select: { + follower: { + select: this.socialUserSelect(), + }, + }, + }); + + return { + profile: this.toPublicProfile(user), + items: rows.map(({ follower }) => this.toPublicFollowerUser(follower)), + }; + } + + async unfollowUser( + followerId: string, + targetUserId: string, + ): Promise<{ following: false }> { + await this.prisma.followRelation.deleteMany({ + where: { + followerId, + targetUserId, + targetType: FollowTargetType.USER, + }, + }); + + return { following: false }; + } + + private async findUserProfileById( + userId: string, + ): Promise { + const user = await this.prisma.user.findFirst({ + where: { + id: userId, + isActive: true, + deletedAt: null, + }, + select: this.userProfileSelect(), + }); + + if (!user) { + throw new NotFoundException({ + message: "User not found", + code: "USER_NOT_FOUND", + }); + } + + return user; + } + + private async assertUserExists(userId: string): Promise { + const user = await this.prisma.user.findFirst({ + where: { + id: userId, + isActive: true, + deletedAt: null, + }, + select: { id: true }, + }); + + if (!user) { + throw new NotFoundException({ + message: "User not found", + code: "USER_NOT_FOUND", + }); + } + } + + private async findPublicUserByUsername(username: string): Promise< + Prisma.UserGetPayload<{ + select: ReturnType; + }> + > { + const normalizedUsername = username.trim().replace(/^@+/, "").toLowerCase(); + const user = await this.prisma.user.findFirst({ + where: { + username: normalizedUsername, + isActive: true, + deletedAt: null, + }, + select: this.publicUserProfileSelect(), + }); + + if (!user) { + throw new NotFoundException({ + message: "User not found", + code: "USER_NOT_FOUND", + }); + } + + return user; + } + + private async assertUsernameCanChange( + user: SelectedUserProfile, + username: string, + ): Promise { + const now = new Date(); + const lockExpiresAt = user.lastUsernameChangedAt + ? new Date(user.lastUsernameChangedAt.getTime() + USERNAME_LOCK_MS) + : null; + + if (lockExpiresAt && lockExpiresAt > now) { + throw new BadRequestException({ + message: "Username can only be changed once every 7 days", + code: "USERNAME_LOCK_ACTIVE", + unlocksAt: lockExpiresAt.toISOString(), + }); + } + + const existingUser = await this.prisma.user.findFirst({ + where: { + username, + NOT: { id: user.id }, + }, + select: { id: true }, + }); + + if (existingUser) { + throw new ConflictException({ + message: "Username is already taken", + code: "USERNAME_TAKEN", + }); + } + + const existingStore = await this.prisma.storeProfile.findUnique({ + where: { storeHandle: username }, + select: { id: true }, + }); + + if (existingStore) { + throw new ConflictException({ + message: "Username is already taken", + code: "USERNAME_TAKEN", + }); + } + } + + private normalizeAddresses( + addresses: UpdateAddressesDto["addresses"], + ): DeliveryAddress[] { + const normalizedAddresses = addresses.map((address) => ({ + id: address.id || randomUUID(), + label: address.label, + street: address.street, + city: address.city, + state: address.state, + isDefault: address.isDefault === true, + ...(address.line2 ? { line2: address.line2 } : {}), + ...(address.postalCode ? { postalCode: address.postalCode } : {}), + })); + + if (normalizedAddresses.length === 0) { + return normalizedAddresses; + } + + const firstDefaultIndex = normalizedAddresses.findIndex( + (address) => address.isDefault, + ); + const defaultIndex = firstDefaultIndex === -1 ? 0 : firstDefaultIndex; + + return normalizedAddresses.map((address, index) => ({ + ...address, + isDefault: index === defaultIndex, + })); + } + + private toMeasurementJson(dto: UpdateMeasurementsDto): Prisma.JsonObject { + const measurements: Prisma.JsonObject = {}; + + for (const [key, value] of Object.entries(dto)) { + if (typeof value === "number") { + measurements[key] = value; + } + } + + return measurements; + } + + private splitDisplayName(displayName: string): { + firstName: string; + lastName: string; + } { + const parts = displayName.split(" ").filter(Boolean); + const firstName = parts[0] || displayName; + const lastName = parts.length > 1 ? parts.slice(1).join(" ") : firstName; + + return { firstName, lastName }; + } + + private toOwnProfile(user: SelectedUserProfile): OwnUserProfile { + return { + ...this.toPublicProfile(user), + email: user.email, + phone: user.phone, + dateOfBirth: user.dateOfBirth?.toISOString() || null, + bodyMeasurements: user.bodyMeasurements, + deliveryAddresses: user.deliveryAddresses, + alternativeDeliveryPhone: user.alternativeDeliveryPhone, + isEmailVerified: user.emailVerified, + isPhoneVerified: user.phoneVerified, + isMinor: user.dateOfBirth + ? this.calculateAge(user.dateOfBirth) < 18 + : false, + lastUsernameChangedAt: user.lastUsernameChangedAt?.toISOString() || null, + updatedAt: user.updatedAt.toISOString(), + }; + } + + private toPublicProfile(user: { + id: string; + username: string | null; + displayName: string | null; + bio: string | null; + profilePhotoUrl: string | null; + createdAt: Date; + _count: UserCountShape; + }): PublicUserProfile { + return { + id: user.id, + username: user.username, + displayName: user.displayName, + bio: user.bio, + profilePhotoUrl: user.profilePhotoUrl, + memberSince: user.createdAt.toISOString(), + followerCount: user._count.followerRelations, + followingCount: user._count.followingRelations, + postCount: user._count.posts, + }; + } + + private toLocationPreferenceResponse(preference: { + countryCode: string; + countryName: string | null; + state: string | null; + city: string | null; + area: string | null; + label: string | null; + source: string; + isActive: boolean; + createdAt: Date; + updatedAt: Date; + }): UserLocationPreferenceResponse { + return { + countryCode: preference.countryCode, + countryName: preference.countryName, + state: preference.state, + city: preference.city, + area: preference.area, + label: preference.label, + source: "manual", + isActive: preference.isActive, + createdAt: preference.createdAt.toISOString(), + updatedAt: preference.updatedAt.toISOString(), + }; + } + + private calculateAge(dateOfBirth: Date): number { + const now = new Date(); + let age = now.getFullYear() - dateOfBirth.getFullYear(); + const hasHadBirthdayThisYear = + now.getMonth() > dateOfBirth.getMonth() || + (now.getMonth() === dateOfBirth.getMonth() && + now.getDate() >= dateOfBirth.getDate()); + + if (!hasHadBirthdayThisYear) { + age -= 1; + } + + return age; + } + + private isUniqueConstraintError( + error: unknown, + ): error is Prisma.PrismaClientKnownRequestError { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ); + } + + private userProfileSelect() { + return { + id: true, + email: true, + phone: true, + username: true, + displayName: true, + bio: true, + profilePhotoUrl: true, + dateOfBirth: true, + bodyMeasurements: true, + deliveryAddresses: true, + alternativeDeliveryPhone: true, + emailVerified: true, + phoneVerified: true, + createdAt: true, + updatedAt: true, + lastUsernameChangedAt: true, + _count: { + select: { + followerRelations: true, + followingRelations: true, + posts: { where: PUBLIC_POST_COUNT_WHERE }, + }, + }, + } satisfies Prisma.UserSelect; + } + + private publicUserProfileSelect() { + return { + id: true, + username: true, + displayName: true, + bio: true, + profilePhotoUrl: true, + createdAt: true, + _count: { + select: { + followerRelations: true, + followingRelations: true, + posts: { where: PUBLIC_POST_COUNT_WHERE }, + }, + }, + } satisfies Prisma.UserSelect; + } + + private socialUserSelect() { + return { + id: true, + username: true, + displayName: true, + bio: true, + profilePhotoUrl: true, + storeProfile: { + select: { + tier: true, + }, + }, + _count: { + select: { + followerRelations: true, + }, + }, + } satisfies Prisma.UserSelect; + } + + private toPublicFollowerUser( + user: Prisma.UserGetPayload<{ + select: ReturnType; + }>, + ): PublicFollowerProfile { + return { + id: user.id, + type: "USER", + username: user.username, + displayName: user.displayName, + bio: user.bio, + profilePhotoUrl: user.profilePhotoUrl, + followerCount: user._count.followerRelations, + verified: Boolean( + user.storeProfile && user.storeProfile.tier !== StoreTier.TIER_0, + ), + href: user.username ? `/u/${user.username}` : null, + }; + } +} diff --git a/apps/backend/src/domains/users/verification/dto/start-nin-verification.dto.ts b/apps/backend/src/domains/users/verification/dto/start-nin-verification.dto.ts new file mode 100644 index 00000000..8b5e2ca4 --- /dev/null +++ b/apps/backend/src/domains/users/verification/dto/start-nin-verification.dto.ts @@ -0,0 +1,10 @@ +import { IsNotEmpty, IsString, Matches } from "class-validator"; + +export class StartNinVerificationDto { + // Nigerian NIN is exactly 11 digits. Regex-validated here so a bad + // input fails closed before reaching the identity-verification provider. + @IsString() + @IsNotEmpty() + @Matches(/^\d{11}$/, { message: "NIN must be 11 digits" }) + nin!: string; +} diff --git a/apps/backend/src/domains/users/verification/providers/identity-verification-provider.interface.ts b/apps/backend/src/domains/users/verification/providers/identity-verification-provider.interface.ts new file mode 100644 index 00000000..73fbac39 --- /dev/null +++ b/apps/backend/src/domains/users/verification/providers/identity-verification-provider.interface.ts @@ -0,0 +1,77 @@ +export type IdentityVerificationProviderName = "prembly"; + +export type NormalizedVerificationStatus = + | "VERIFIED" + | "FAILED" + | "PENDING" + | "REQUIRES_RETRY" + | "MANUAL_REVIEW" + | "UNKNOWN"; + +export type VerificationCheckType = "NIN" | "NIN_FACE_MATCH" | "CAC"; + +export interface VerifyNinRequest { + storeId: string; + userId: string; + nin: string; + idempotencyKey?: string; +} + +export interface VerifyNinFaceMatchRequest { + storeId: string; + userId: string; + ninReference?: string | null; + selfieImageBuffer: Buffer; + idempotencyKey?: string; +} + +export interface VerifyBusinessRegistrationRequest { + storeId: string; + userId: string; + cacNumber: string; + idempotencyKey?: string; +} + +/** + * Rechecks a previously submitted provider operation. This must never create a + * new paid verification request. + */ +export interface GetVerificationStatusRequest { + providerReference: string; + checkType: VerificationCheckType; +} + +export interface IdentityVerificationResult { + provider: IdentityVerificationProviderName; + checkType: VerificationCheckType; + status: NormalizedVerificationStatus; + providerReference?: string | null; + matched?: boolean | null; + failureCode?: string | null; + failureReason?: string | null; + checkedAt: Date; +} + +export interface IdentityVerificationProvider { + /** + * Immutable owner for every verification attempt created through this + * contract. A future provider selection can affect only new attempts. + */ + readonly name: IdentityVerificationProviderName; + + verifyNin(request: VerifyNinRequest): Promise; + verifyNinFaceMatch( + request: VerifyNinFaceMatchRequest, + ): Promise; + verifyBusinessRegistration( + request: VerifyBusinessRegistrationRequest, + ): Promise; + + getVerificationStatus( + request: GetVerificationStatusRequest, + ): Promise; +} + +export const IDENTITY_VERIFICATION_PROVIDER = Symbol( + "IDENTITY_VERIFICATION_PROVIDER", +); diff --git a/apps/backend/src/domains/users/verification/verification.controller.ts b/apps/backend/src/domains/users/verification/verification.controller.ts new file mode 100644 index 00000000..9ccb398b --- /dev/null +++ b/apps/backend/src/domains/users/verification/verification.controller.ts @@ -0,0 +1,85 @@ +import { + Controller, + Post, + Get, + Body, + UseGuards, + UseInterceptors, + UploadedFile, + BadRequestException, +} from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { Throttle } from "@nestjs/throttler"; +import { memoryStorage } from "multer"; +import { VerificationService } from "./verification.service"; +import { SubmitVerificationDto } from "./verification.dto"; +import { StartNinVerificationDto } from "./dto/start-nin-verification.dto"; +import { JwtAuthGuard } from "../../../common/guards/jwt-auth.guard"; +import { RolesGuard } from "../../../common/guards/roles.guard"; +import { Roles } from "../../../common/decorators/roles.decorator"; +import { CurrentMerchant } from "../../../common/decorators/current-merchant.decorator"; +import { UserRole } from "@twizrr/shared"; + +@Controller() +@UseGuards(JwtAuthGuard, RolesGuard) +export class VerificationController { + constructor(private readonly verificationService: VerificationService) {} + + // ─── B-22: Prembly NIN verification endpoints ────────────────────────────── + + @Post("verification/nin/start") + @Roles(UserRole.USER) + @Throttle({ default: { limit: 3, ttl: 60 * 60 * 1000 } }) + startNin( + @CurrentMerchant() storeId: string, + @Body() dto: StartNinVerificationDto, + ) { + return this.verificationService.startNinVerification(storeId, dto.nin); + } + + @Post("verification/nin/selfie") + @Roles(UserRole.USER) + @Throttle({ default: { limit: 5, ttl: 60 * 60 * 1000 } }) + @UseInterceptors( + FileInterceptor("selfie", { + storage: memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + cb(null, /^image\/(jpeg|png|jpg)$/i.test(file.mimetype)); + }, + }), + ) + submitSelfie( + @CurrentMerchant() storeId: string, + @UploadedFile() selfie: Express.Multer.File, + ) { + if (!selfie?.buffer) { + throw new BadRequestException({ + message: "Selfie file required", + code: "SELFIE_REQUIRED", + }); + } + return this.verificationService.submitNinSelfie(storeId, selfie.buffer); + } + + // ─── Store verification endpoints ────────────────────────────────────────────────── + + @Post("verification/request") + @Roles(UserRole.USER) + submitRequest( + @Body() dto: SubmitVerificationDto, + @CurrentMerchant() storeId: string, + ) { + return this.verificationService.submitRequest(storeId, dto); + } + + @Get("verification/status") + @Roles(UserRole.USER) + getStatus(@CurrentMerchant() storeId: string) { + return this.verificationService.getStatus(storeId); + } + + // Admin verification review (list / detail / approve / reject) is served by + // the canonical, audited surface in platform/admin (AdminController: + // /admin/verification/requests[...]). It is intentionally NOT duplicated here. +} diff --git a/apps/backend/src/domains/users/verification/verification.dto.ts b/apps/backend/src/domains/users/verification/verification.dto.ts new file mode 100644 index 00000000..48274431 --- /dev/null +++ b/apps/backend/src/domains/users/verification/verification.dto.ts @@ -0,0 +1,37 @@ +import { + IsString, + IsNotEmpty, + IsOptional, + IsEnum, + IsUrl, + IsIn, +} from "class-validator"; +import { VerificationIdType } from "@twizrr/shared"; + +export class SubmitVerificationDto { + @IsUrl({ protocols: ["https"], require_protocol: true }) + governmentIdUrl!: string; + + @IsEnum(VerificationIdType) + @IsNotEmpty() + idType!: VerificationIdType; + + @IsNotEmpty() + @IsIn(["TIER_2"]) + targetTier!: "TIER_2"; + + @IsOptional() + @IsString() + ninNumber?: string; +} + +export class ReviewVerificationDto { + @IsIn(["APPROVED", "REJECTED"], { + message: "decision must be APPROVED or REJECTED", + }) + decision!: "APPROVED" | "REJECTED"; + + @IsOptional() + @IsString() + rejectionReason?: string; +} diff --git a/apps/backend/src/domains/users/verification/verification.module.ts b/apps/backend/src/domains/users/verification/verification.module.ts new file mode 100644 index 00000000..44befa13 --- /dev/null +++ b/apps/backend/src/domains/users/verification/verification.module.ts @@ -0,0 +1,18 @@ +import { Module } from "@nestjs/common"; +import { VerificationService } from "./verification.service"; +import { VerificationController } from "./verification.controller"; +import { PrismaModule } from "../../../prisma/prisma.module"; +import { NotificationModule } from "../../social/notification/notification.module"; +import { PremblyModule } from "../../../integrations/prembly/prembly.module"; + +@Module({ + // RedisModule is @Global — RedisService is injected directly. + // Consumers (AuthModule, StoreModule, MerchantModule) import this + // module via forwardRef and consume VerificationService for their + // event-driven Tier 1 hooks. + imports: [PrismaModule, NotificationModule, PremblyModule], + controllers: [VerificationController], + providers: [VerificationService], + exports: [VerificationService], +}) +export class VerificationModule {} diff --git a/apps/backend/src/domains/users/verification/verification.service.spec.ts b/apps/backend/src/domains/users/verification/verification.service.spec.ts new file mode 100644 index 00000000..a812ef0d --- /dev/null +++ b/apps/backend/src/domains/users/verification/verification.service.spec.ts @@ -0,0 +1,614 @@ +import { + BadRequestException, + ConflictException, + ServiceUnavailableException, +} from "@nestjs/common"; +import { StoreTier } from "@prisma/client"; +import { + OrderDisputeStatus, + OrderStatus, + VerificationTier, +} from "@twizrr/shared"; + +import { VerificationService } from "./verification.service"; + +function makeStore(overrides: Record = {}) { + return { + id: "store-1", + userId: "owner-1", + verificationTier: VerificationTier.UNVERIFIED, + tier: StoreTier.TIER_0, + bankVerified: true, + bankAccountNumber: "0123456789", + businessPhoneVerified: true, + businessName: "Ada Store", + businessAddress: "12 Allen Avenue, Ikeja", + ninVerified: false, + profileImage: null, + user: { + emailVerified: true, + phoneVerified: true, + firstName: "Ada", + lastName: "Okafor", + }, + ...overrides, + }; +} + +function makeService(prisma: Record) { + const notifications = { + addJob: jest.fn().mockResolvedValue(undefined), + }; + const identityVerificationProvider = { + name: "prembly" as const, + verifyNin: jest.fn(), + verifyNinFaceMatch: jest.fn(), + verifyBusinessRegistration: jest.fn(), + getVerificationStatus: jest.fn(), + }; + const redis = { get: jest.fn(), set: jest.fn(), del: jest.fn() }; + const storeVerificationAttempt = { + create: jest.fn().mockImplementation(({ data }) => ({ + id: "attempt-1", + status: "SUBMITTED", + ...data, + })), + findUnique: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }; + const database = { + storeVerificationAttempt, + ...prisma, + }; + const databaseWithTransaction = { + ...database, + $transaction: async ( + callback: (tx: typeof database) => Promise | T, + ): Promise => callback(database), + }; + const service = new VerificationService( + databaseWithTransaction as never, + notifications as never, + identityVerificationProvider as never, + redis as never, + ); + + return { + service, + notifications, + identityVerificationProvider, + redis, + prisma: databaseWithTransaction, + storeVerificationAttempt, + }; +} + +describe("VerificationService tier upgrades", () => { + it("uses protected-payment copy and avoids verified-badge language for Tier 1 notifications", async () => { + const store = makeStore(); + const prisma = { + storeProfile: { + findUnique: jest + .fn() + .mockResolvedValueOnce(store) + .mockResolvedValueOnce({ + id: store.id, + userId: store.userId, + }), + update: jest.fn().mockResolvedValue({ + ...store, + verificationTier: VerificationTier.TIER_1, + tier: StoreTier.TIER_1, + }), + }, + order: { + count: jest.fn(), + }, + }; + const { service, notifications } = makeService(prisma); + + await service.checkAndUpgradeTier("store-1"); + + expect(notifications.addJob).toHaveBeenCalledWith( + "owner-1", + expect.any(String), + "Store basics completed", + "You can now list products and receive orders with protected payment.", + { tier: VerificationTier.TIER_1 }, + ); + }); + + it("keeps public StoreTier in sync when Tier 1 auto-upgrade succeeds", async () => { + const store = makeStore(); + const prisma = { + storeProfile: { + findUnique: jest.fn().mockResolvedValue(store), + update: jest.fn().mockResolvedValue({ + ...store, + verificationTier: VerificationTier.TIER_1, + tier: StoreTier.TIER_1, + }), + }, + order: { + count: jest.fn(), + }, + }; + const { service } = makeService(prisma); + + await expect(service.checkAndUpgradeTier("store-1")).resolves.toBe( + VerificationTier.TIER_1, + ); + + expect(prisma.storeProfile.update).toHaveBeenCalledWith({ + where: { id: "store-1" }, + data: expect.objectContaining({ + verificationTier: VerificationTier.TIER_1, + tier: StoreTier.TIER_1, + }), + }); + }); + + it("keeps public StoreTier in sync when Tier 2 automated upgrade succeeds", async () => { + const store = makeStore({ + verificationTier: VerificationTier.TIER_1, + tier: StoreTier.TIER_1, + ninVerified: true, + profileImage: "https://cdn.twizrr.test/profile.jpg", + }); + const prisma = { + storeProfile: { + findUnique: jest.fn().mockResolvedValue(store), + update: jest.fn().mockResolvedValue({ + ...store, + verificationTier: VerificationTier.TIER_2, + tier: StoreTier.TIER_2, + }), + }, + order: { + count: jest.fn().mockResolvedValueOnce(5).mockResolvedValueOnce(0), + }, + }; + const { service } = makeService(prisma); + + await expect(service.checkAndUpgradeTier("store-1")).resolves.toBe( + VerificationTier.TIER_2, + ); + + expect(prisma.order.count).toHaveBeenNthCalledWith(1, { + where: { storeId: "store-1", status: OrderStatus.COMPLETED }, + }); + expect(prisma.order.count).toHaveBeenNthCalledWith(2, { + where: { + storeId: "store-1", + disputeStatus: { not: OrderDisputeStatus.NONE }, + }, + }); + expect(prisma.storeProfile.update).toHaveBeenCalledWith({ + where: { id: "store-1" }, + data: expect.objectContaining({ + verificationTier: VerificationTier.TIER_2, + tier: StoreTier.TIER_2, + verifiedAt: expect.any(Date), + }), + }); + }); + + it("does not upgrade Tier 2 when any dispute is recorded", async () => { + const store = makeStore({ + verificationTier: VerificationTier.TIER_1, + tier: StoreTier.TIER_1, + ninVerified: true, + profileImage: "https://cdn.twizrr.test/profile.jpg", + }); + const prisma = { + storeProfile: { + findUnique: jest.fn().mockResolvedValue(store), + update: jest.fn(), + }, + order: { + count: jest.fn().mockResolvedValueOnce(5).mockResolvedValueOnce(1), + }, + }; + const { service } = makeService(prisma); + + await expect(service.checkAndUpgradeTier("store-1")).resolves.toBe( + VerificationTier.TIER_1, + ); + + expect(prisma.storeProfile.update).not.toHaveBeenCalled(); + }); +}); + +describe("VerificationService NIN provider boundary", () => { + it("uses the Twizrr-owned provider contract and preserves pending-selfie status", async () => { + const store = makeStore({ ninVerificationAttempts: 0 }); + const prisma = { + storeProfile: { + findUnique: jest.fn().mockResolvedValue(store), + update: jest.fn().mockResolvedValue(store), + }, + }; + const { service, identityVerificationProvider } = makeService(prisma); + (identityVerificationProvider.verifyNin as jest.Mock).mockResolvedValue({ + status: "VERIFIED", + }); + + await expect( + service.startNinVerification("store-1", "12345678901"), + ).resolves.toEqual({ status: "PENDING_SELFIE", nextStep: "submit_selfie" }); + expect(identityVerificationProvider.verifyNin).toHaveBeenCalledWith( + expect.objectContaining({ + storeId: "store-1", + userId: "owner-1", + nin: "12345678901", + }), + ); + expect(prisma.storeProfile.update).toHaveBeenCalledWith({ + where: { id: "store-1" }, + data: { ninVerificationStatus: "PENDING_SELFIE" }, + }); + }); + + it("does not count a provider timeout as an identity failure", async () => { + const store = makeStore({ ninVerificationAttempts: 0 }); + const prisma = { + storeProfile: { + findUnique: jest.fn().mockResolvedValue(store), + update: jest.fn(), + }, + }; + const { service, identityVerificationProvider } = makeService(prisma); + const timeout = new Error("provider timeout"); + (identityVerificationProvider.verifyNin as jest.Mock).mockRejectedValue( + timeout, + ); + + await expect( + service.startNinVerification("store-1", "12345678901"), + ).rejects.toBe(timeout); + expect(prisma.storeProfile.update).not.toHaveBeenCalled(); + }); + + it("claims one durable attempt before a NIN provider call", async () => { + const store = makeStore({ ninVerificationAttempts: 0 }); + const prisma = { + storeProfile: { + findUnique: jest.fn().mockResolvedValue(store), + update: jest.fn().mockResolvedValue(store), + }, + }; + const { service, identityVerificationProvider, storeVerificationAttempt } = + makeService(prisma); + (identityVerificationProvider.verifyNin as jest.Mock).mockResolvedValue({ + status: "VERIFIED", + checkedAt: new Date("2026-07-19T00:00:00.000Z"), + providerReference: "prembly-ref-1", + }); + + await service.startNinVerification("store-1", "12345678901"); + + expect(storeVerificationAttempt.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + storeId: "store-1", + provider: "prembly", + checkType: "NIN", + activeKey: "verification:store-1:NIN", + idempotencyKey: expect.stringMatching(/^verification:/), + }), + }); + expect(identityVerificationProvider.verifyNin).toHaveBeenCalledWith( + expect.objectContaining({ + idempotencyKey: expect.stringMatching(/^verification:/), + }), + ); + expect(storeVerificationAttempt.updateMany).toHaveBeenCalledWith({ + where: { id: "attempt-1", status: "SUBMITTED" }, + data: expect.objectContaining({ + status: "VERIFIED", + activeKey: null, + providerReference: "prembly-ref-1", + }), + }); + }); + + it("does not submit a second paid NIN request while an attempt is active", async () => { + const store = makeStore({ ninVerificationAttempts: 0 }); + const activeAttempt = { + id: "attempt-active", + status: "SUBMITTED", + activeKey: "verification:store-1:NIN", + }; + const prisma = { + storeProfile: { + findUnique: jest.fn().mockResolvedValue(store), + update: jest.fn(), + }, + storeVerificationAttempt: { + create: jest.fn().mockRejectedValue({ code: "P2002" }), + findUnique: jest.fn().mockResolvedValue(activeAttempt), + updateMany: jest.fn(), + }, + }; + const { service, identityVerificationProvider } = makeService(prisma); + + await expect( + service.startNinVerification("store-1", "12345678901"), + ).rejects.toBeInstanceOf(ConflictException); + + expect(identityVerificationProvider.verifyNin).not.toHaveBeenCalled(); + }); + + it("reuses a verified NIN session instead of calling Prembly twice", async () => { + const store = makeStore({ + ninVerificationAttempts: 0, + ninVerificationStatus: "PENDING_SELFIE", + }); + const prisma = { + storeProfile: { + findUnique: jest.fn().mockResolvedValue(store), + update: jest.fn(), + }, + }; + const { service, identityVerificationProvider, redis } = + makeService(prisma); + redis.get.mockResolvedValue("12345678901"); + + await expect( + service.startNinVerification("store-1", "12345678901"), + ).resolves.toEqual({ status: "PENDING_SELFIE", nextStep: "submit_selfie" }); + + expect(identityVerificationProvider.verifyNin).not.toHaveBeenCalled(); + }); + + it("resets an expired pending-selfie session before a fresh NIN lookup", async () => { + const store = makeStore({ + ninVerificationAttempts: 0, + ninVerificationStatus: "PENDING_SELFIE", + }); + const prisma = { + storeProfile: { + findUnique: jest.fn().mockResolvedValue(store), + update: jest.fn().mockResolvedValue(store), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + }; + const { service, identityVerificationProvider, redis } = + makeService(prisma); + redis.get.mockResolvedValue(null); + (identityVerificationProvider.verifyNin as jest.Mock).mockResolvedValue({ + status: "VERIFIED", + checkedAt: new Date("2026-07-19T00:00:00.000Z"), + }); + + await expect( + service.startNinVerification("store-1", "12345678901"), + ).resolves.toEqual({ status: "PENDING_SELFIE", nextStep: "submit_selfie" }); + + expect(prisma.storeProfile.updateMany).toHaveBeenCalledWith({ + where: { id: "store-1", ninVerificationStatus: "PENDING_SELFIE" }, + data: { ninVerificationStatus: "NONE" }, + }); + expect(identityVerificationProvider.verifyNin).toHaveBeenCalledTimes(1); + }); + + it("resets a stale session when selfie submission finds no cached NIN", async () => { + const store = makeStore({ + ninVerificationAttempts: 0, + ninVerificationStatus: "PENDING_SELFIE", + }); + const prisma = { + storeProfile: { + findUnique: jest.fn().mockResolvedValue(store), + update: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + }; + const { service, identityVerificationProvider, redis } = + makeService(prisma); + redis.get.mockResolvedValue(null); + + await expect( + service.submitNinSelfie("store-1", Buffer.from("selfie")), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(prisma.storeProfile.updateMany).toHaveBeenCalledWith({ + where: { id: "store-1", ninVerificationStatus: "PENDING_SELFIE" }, + data: { ninVerificationStatus: "NONE" }, + }); + expect( + identityVerificationProvider.verifyNinFaceMatch, + ).not.toHaveBeenCalled(); + }); + + it("preserves an unknown provider outcome for reconciliation without counting a failed NIN", async () => { + const store = makeStore({ ninVerificationAttempts: 0 }); + const prisma = { + storeProfile: { + findUnique: jest.fn().mockResolvedValue(store), + update: jest.fn(), + }, + }; + const { service, identityVerificationProvider, storeVerificationAttempt } = + makeService(prisma); + (identityVerificationProvider.verifyNin as jest.Mock).mockRejectedValue( + new Error("provider timeout"), + ); + + await expect( + service.startNinVerification("store-1", "12345678901"), + ).rejects.toThrow("provider timeout"); + + expect(storeVerificationAttempt.updateMany).toHaveBeenCalledWith({ + where: { id: "attempt-1", status: "SUBMITTED" }, + data: { + status: "RECONCILIATION_REQUIRED", + failureCode: "PROVIDER_OUTCOME_UNKNOWN", + }, + }); + expect(prisma.storeProfile.update).not.toHaveBeenCalled(); + }); + + it("preserves a safe provider reference for a non-final NIN outcome", async () => { + const store = makeStore({ ninVerificationAttempts: 0 }); + const prisma = { + storeProfile: { + findUnique: jest.fn().mockResolvedValue(store), + update: jest.fn(), + }, + }; + const { service, identityVerificationProvider, storeVerificationAttempt } = + makeService(prisma); + (identityVerificationProvider.verifyNin as jest.Mock).mockResolvedValue({ + status: "PENDING", + providerReference: "prembly-reference-1", + failureCode: "VERIFICATION_PENDING", + }); + + await expect( + service.startNinVerification("store-1", "12345678901"), + ).rejects.toBeInstanceOf(ServiceUnavailableException); + + expect(storeVerificationAttempt.updateMany).toHaveBeenCalledWith({ + where: { id: "attempt-1", status: "SUBMITTED" }, + data: expect.objectContaining({ + status: "RECONCILIATION_REQUIRED", + providerReference: "prembly-reference-1", + }), + }); + }); + + it("reconciles a verified NIN reference without submitting a new paid lookup", async () => { + const store = makeStore({ ninVerificationAttempts: 0 }); + const attempt = { + id: "attempt-1", + storeId: "store-1", + provider: "prembly", + checkType: "NIN", + status: "RECONCILIATION_REQUIRED", + providerReference: "prembly-reference-1", + store: { id: "store-1", userId: "owner-1" }, + }; + const prisma = { + storeProfile: { update: jest.fn().mockResolvedValue(store) }, + storeVerificationAttempt: { + findUnique: jest.fn().mockResolvedValue(attempt), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + }; + const { service, identityVerificationProvider, redis } = + makeService(prisma); + redis.get.mockResolvedValue("12345678901"); + ( + identityVerificationProvider.getVerificationStatus as jest.Mock + ).mockResolvedValue({ + status: "VERIFIED", + providerReference: "prembly-reference-1", + checkedAt: new Date("2026-07-19T00:00:00.000Z"), + }); + + await expect( + service.reconcileVerificationAttempt("attempt-1"), + ).resolves.toEqual({ + status: "PENDING_SELFIE", + attemptId: "attempt-1", + }); + expect(identityVerificationProvider.verifyNin).not.toHaveBeenCalled(); + expect( + identityVerificationProvider.getVerificationStatus, + ).toHaveBeenCalledWith({ + providerReference: "prembly-reference-1", + checkType: "NIN", + }); + }); + + it("moves a reference-less reconciliation attempt to manual review without calling Prembly", async () => { + const attempt = { + id: "attempt-1", + storeId: "store-1", + provider: "prembly", + checkType: "NIN", + status: "RECONCILIATION_REQUIRED", + providerReference: null, + store: { id: "store-1", userId: "owner-1" }, + }; + const prisma = { + storeVerificationAttempt: { + findUnique: jest.fn().mockResolvedValue(attempt), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + }; + const { service, identityVerificationProvider } = makeService(prisma); + + await expect( + service.reconcileVerificationAttempt("attempt-1"), + ).resolves.toEqual({ + status: "MANUAL_REVIEW", + attemptId: "attempt-1", + }); + expect( + identityVerificationProvider.getVerificationStatus, + ).not.toHaveBeenCalled(); + }); +}); + +describe("VerificationService.reviewRequest (canonical decision)", () => { + function makePendingRequest(overrides: Record = {}) { + return { + id: "request-1", + storeId: "store-1", + status: "PENDING", + targetTier: "TIER_2", + ninNumber: null, + store: { + id: "store-1", + userId: "owner-1", + ninNumber: null, + user: { id: "owner-1", email: "owner@example.com" }, + }, + ...overrides, + }; + } + + it("refuses to reject without a rejection reason and writes nothing", async () => { + const prisma = { + verificationRequest: { + findUnique: jest.fn().mockResolvedValue(makePendingRequest()), + update: jest.fn(), + }, + }; + const { service, notifications } = makeService(prisma); + + await expect( + service.reviewRequest("request-1", "admin-1", { decision: "REJECTED" }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(prisma.verificationRequest.update).not.toHaveBeenCalled(); + expect(notifications.addJob).not.toHaveBeenCalled(); + }); + + it("rejects with a reason, records it on the request, and notifies the owner", async () => { + const prisma = { + verificationRequest: { + findUnique: jest.fn().mockResolvedValue(makePendingRequest()), + update: jest.fn().mockResolvedValue({ id: "request-1" }), + }, + }; + const { service, notifications } = makeService(prisma); + + const result = await service.reviewRequest("request-1", "admin-1", { + decision: "REJECTED", + rejectionReason: "Selfie did not match the submitted identity", + }); + + expect(result).toMatchObject({ decision: "REJECTED" }); + expect(prisma.verificationRequest.update).toHaveBeenCalledWith({ + where: { id: "request-1" }, + data: expect.objectContaining({ + status: "REJECTED", + reviewedBy: "admin-1", + rejectionReason: "Selfie did not match the submitted identity", + }), + }); + expect(notifications.addJob).toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/domains/users/verification/verification.service.ts b/apps/backend/src/domains/users/verification/verification.service.ts new file mode 100644 index 00000000..fa494827 --- /dev/null +++ b/apps/backend/src/domains/users/verification/verification.service.ts @@ -0,0 +1,1145 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + ForbiddenException, + Inject, + Logger, + ConflictException, + ServiceUnavailableException, +} from "@nestjs/common"; +import { randomUUID } from "crypto"; +import { PrismaService } from "../../../prisma/prisma.service"; +import { + SubmitVerificationDto, + ReviewVerificationDto, +} from "./verification.dto"; +import { NotificationTriggerService } from "../../social/notification/notification-trigger.service"; +import { RedisService } from "../../../core/redis/redis.service"; +import { + IDENTITY_VERIFICATION_PROVIDER, + IdentityVerificationProvider, +} from "./providers/identity-verification-provider.interface"; +import { + IdentityVerificationAttemptStatus, + IdentityVerificationCheckType, + StoreTier, +} from "@prisma/client"; +import { + VerificationTier, + OrderStatus, + OrderDisputeStatus, + NotificationType, +} from "@twizrr/shared"; + +// NIN verification lockout discipline. +// After MAX_NIN_ATTEMPTS combined failures (NIN-not-found OR face-mismatch), +// the store cannot retry until an OPERATOR intervenes. +const MAX_NIN_ATTEMPTS = 3; + +// Completed-order track record required before a TIER_1 store can reach TIER_2 +// (alongside NIN verification, zero open disputes, and a profile photo). +const TIER_2_MIN_COMPLETED_ORDERS = 3; + +// Redis session TTL bridging POST /nin/start and POST /nin/selfie. +// 30 minutes is generous enough for a shopper to take and upload a selfie +// without re-submitting their NIN, but short enough to limit the window +// in which a validated NIN sits in the cache. +const NIN_SESSION_TTL_SECONDS = 30 * 60; + +const ninSessionKey = (storeId: string): string => `nin-session:${storeId}`; + +type NinVerificationStatus = "NONE" | "PENDING_SELFIE" | "VERIFIED" | "LOCKED"; + +@Injectable() +export class VerificationService { + private readonly logger = new Logger(VerificationService.name); + + constructor( + private prisma: PrismaService, + private notifications: NotificationTriggerService, + @Inject(IDENTITY_VERIFICATION_PROVIDER) + private identityVerificationProvider: IdentityVerificationProvider, + private redis: RedisService, + ) {} + + async submitRequest(storeId: string, dto: SubmitVerificationDto) { + const merchant = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + }); + + if (!merchant) throw new NotFoundException("Store profile not found"); + + if (merchant.verificationTier === VerificationTier.TIER_2) { + throw new BadRequestException( + "You are already at the highest verification tier", + ); + } + + // Validate prerequisite tier + if ( + dto.targetTier === "TIER_2" && + merchant.verificationTier !== VerificationTier.TIER_1 + ) { + throw new BadRequestException("You must be TIER_1 to apply for TIER_2"); + } + + // Atomically check for existing PENDING request and create + const request = await this.prisma.$transaction(async (tx) => { + const existing = await tx.verificationRequest.findFirst({ + where: { storeId, status: "PENDING", targetTier: dto.targetTier }, + }); + + if (existing) { + throw new BadRequestException( + `You already have a ${dto.targetTier} verification request pending review`, + ); + } + + return tx.verificationRequest.create({ + data: { + storeId, + governmentIdUrl: dto.governmentIdUrl, + idType: dto.idType, + targetTier: dto.targetTier, + ninNumber: dto.ninNumber, + status: "PENDING", + }, + include: { + store: { + select: { id: true, businessName: true, userId: true }, + }, + }, + }); + }); + + // Notify admins + try { + const admins = await this.prisma.adminProfile.findMany({ + include: { user: true }, + where: { user: { role: "SUPER_ADMIN" } }, + }); + const adminIds = admins.map((a) => a.userId); + + if (adminIds.length > 0) { + await this.notifications.triggerNewMerchantSubmission(adminIds, { + storeId: request.storeId, + merchantName: request.store.businessName, + targetTier: dto.targetTier as string, + }); + } + } catch (e) { + this.logger.error("Failed to notify admins of new verification request"); + } + + return request; + } + + async getStatus(storeId: string) { + // Lazy auto-upgrade: if the merchant has met all Tier 1 (or Tier 2) + // requirements without an order ever triggering checkAndUpgradeTier, + // the GET status call catches them up. Best-effort — never blocks the + // status response on an upgrade-eval failure. + try { + await this.checkAndUpgradeTier(storeId); + } catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + this.logger.warn( + `Lazy tier upgrade check failed for ${storeId}: ${message}`, + ); + } + + const merchant = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + include: { user: true }, + }); + + if (!merchant) throw new NotFoundException("Store profile not found"); + + const [completedOrdersCount, openDisputesCount, pendingRequest] = + await Promise.all([ + this.prisma.order.count({ + where: { storeId, status: OrderStatus.COMPLETED }, + }), + this.prisma.order.count({ + where: { + storeId, + disputeStatus: { not: OrderDisputeStatus.NONE }, + }, + }), + this.prisma.verificationRequest.findFirst({ + where: { storeId, status: "PENDING" }, + orderBy: { createdAt: "desc" }, + }), + ]); + + const user = merchant.user; + return { + currentTier: merchant.verificationTier, + tier1: { + status: + merchant.verificationTier !== VerificationTier.UNVERIFIED + ? "COMPLETE" + : "IN_PROGRESS", + requirements: { + emailVerified: user.emailVerified, + phoneVerified: user.phoneVerified, + businessPhoneVerified: merchant.businessPhoneVerified, + bankVerified: merchant.bankVerified, + basicInfo: !!( + (user.firstName?.trim() && user.lastName?.trim()) || + merchant.businessName?.trim() + ), + businessAddress: !!merchant.businessAddress?.trim(), + }, + }, + tier2: { + status: + merchant.verificationTier === VerificationTier.TIER_2 + ? "COMPLETE" + : merchant.verificationTier === VerificationTier.TIER_1 + ? "IN_PROGRESS" + : "LOCKED", + requirements: { + ninVerified: merchant.ninVerified, + completedOrders: { + current: completedOrdersCount, + required: TIER_2_MIN_COMPLETED_ORDERS, + }, + zeroDisputes: openDisputesCount === 0, + profilePhoto: !!merchant.profileImage, + }, + // NIN flow status and attempt counters. Safe to expose — no raw + // NIN, no Prembly payload, no confidence score. + nin: { + status: merchant.ninVerificationStatus ?? "NONE", + attempts: merchant.ninVerificationAttempts, + maxAttempts: MAX_NIN_ATTEMPTS, + locked: merchant.ninVerificationStatus === "LOCKED", + lockedAt: merchant.ninVerificationLockedAt, + }, + pendingRequest: + pendingRequest?.targetTier === "TIER_2" ? pendingRequest : null, + }, + }; + } + + async reviewRequest( + requestId: string, + adminId: string, + dto: ReviewVerificationDto, + ) { + const request = await this.prisma.verificationRequest.findUnique({ + where: { id: requestId }, + include: { + store: { + include: { user: true }, + }, + }, + }); + + if (!request) throw new NotFoundException("Verification request not found"); + if (request.status !== "PENDING") + throw new BadRequestException("Request is already processed"); + + const storeId = request.storeId; + + if (dto.decision === "REJECTED") { + if (!dto.rejectionReason) { + throw new BadRequestException( + "Rejection reason is required when rejecting a request", + ); + } + + await this.prisma.verificationRequest.update({ + where: { id: requestId }, + data: { + status: "REJECTED", + reviewedBy: adminId, + reviewedAt: new Date(), + rejectionReason: dto.rejectionReason, + }, + }); + + this.notifications + .addJob( + request.store.userId, + NotificationType.VERIFICATION_REJECTED, + "Verification Request Rejected", + `Your ${request.targetTier} verification request was rejected. Reason: ${dto.rejectionReason}`, + { requestId }, + ) + .catch((err) => + this.logger.error( + `Failed to enqueue rejection notification for request ${requestId}`, + err, + ), + ); + + return { + message: "Verification request rejected", + decision: "REJECTED", + }; + } + + // Handle APPROVAL + await this.prisma.$transaction(async (tx) => { + // Approve the request + await tx.verificationRequest.update({ + where: { id: requestId }, + data: { + status: "APPROVED", + reviewedBy: adminId, + reviewedAt: new Date(), + }, + }); + + // Update verification metadata based on target tier + if (request.targetTier === "TIER_2") { + await tx.storeProfile.update({ + where: { id: storeId }, + data: { + ninVerified: true, + ninVerifiedAt: new Date(), + ninVerifiedVia: "MANUAL", + ninNumber: request.ninNumber || request.store.ninNumber, + }, + }); + } + }); + + // Check for tier upgrade + const newTier = await this.checkAndUpgradeTier(storeId); + + return { + message: "Verification request approved", + decision: "APPROVED", + newTier, + }; + } + + async checkAndUpgradeTier(storeId: string): Promise { + const merchant = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + include: { user: true }, + }); + + if (!merchant) throw new NotFoundException("Store not found"); + + const currentTier = merchant.verificationTier; + + // Check Tier 1 requirements + if (currentTier === VerificationTier.UNVERIFIED) { + const user = merchant.user; + const meetsT1 = + user.emailVerified && + user.phoneVerified && + merchant.businessPhoneVerified && + merchant.bankVerified && + merchant.bankAccountNumber && + (((user.firstName?.trim()?.length ?? 0) > 0 && + (user.lastName?.trim()?.length ?? 0) > 0) || + (merchant.businessName?.trim()?.length ?? 0) > 0) && + (merchant.businessAddress?.trim()?.length ?? 0) > 0; + + if (meetsT1) { + await this.prisma.storeProfile.update({ + where: { id: storeId }, + data: { + verificationTier: VerificationTier.TIER_1, + tier: StoreTier.TIER_1, + tierUpgradedAt: new Date(), + }, + }); + await this.notifyTierUpgrade(storeId, VerificationTier.TIER_1); + return VerificationTier.TIER_1; + } + } + + // Check Tier 2 requirements + if (currentTier === VerificationTier.TIER_1) { + const completedOrders = await this.prisma.order.count({ + where: { storeId, status: OrderStatus.COMPLETED }, + }); + const openDisputes = await this.prisma.order.count({ + where: { storeId, disputeStatus: { not: "NONE" } }, + }); + + const meetsT2 = + merchant.ninVerified && + completedOrders >= TIER_2_MIN_COMPLETED_ORDERS && + openDisputes === 0 && + merchant.profileImage; + + if (meetsT2) { + await this.prisma.storeProfile.update({ + where: { id: storeId }, + data: { + verificationTier: VerificationTier.TIER_2, + tier: StoreTier.TIER_2, + tierUpgradedAt: new Date(), + verifiedAt: new Date(), + }, + }); + await this.notifyTierUpgrade(storeId, VerificationTier.TIER_2); + return VerificationTier.TIER_2; + } + } + + return currentTier as any; + } + + // ─── B-22: Prembly NIN verification ──────────────────────────────────────── + + // Step 1 of NIN flow. Validates the NIN against the NIMC database via + // identity-verification provider and, on success, caches the validated NIN in Redis so the + // selfie step does not require re-submission. Persisting the NIN to the + // DB column is deliberately deferred to the selfie-success path. + async startNinVerification(storeId: string, nin: string) { + const merchant = await this.assertStoreNotLocked(storeId); + if (merchant.ninVerified) { + throw new BadRequestException({ + message: "NIN already verified", + code: "NIN_ALREADY_VERIFIED", + }); + } + + // A successful NIN check remains available only for the short-lived selfie + // session. Returning the existing state here prevents a lost HTTP response + // from creating a second paid NIN lookup. + const cachedNin = await this.redis.get(ninSessionKey(storeId)); + if (merchant.ninVerificationStatus === "PENDING_SELFIE") { + if (cachedNin === nin) { + return { + status: "PENDING_SELFIE" as const, + nextStep: "submit_selfie", + }; + } + + if (cachedNin) { + // A different NIN must not replace a still-active verified session. + throw new ConflictException({ + message: "NIN verification is already awaiting a selfie.", + code: "NIN_SELFIE_PENDING", + }); + } + + await this.resetExpiredNinSelfieSession(storeId); + } + + const attempt = await this.claimVerificationAttempt( + storeId, + IdentityVerificationCheckType.NIN, + ); + + // Keep the NIN only in the existing short-lived Redis session while the + // provider outcome is uncertain. If Prembly later confirms this exact + // attempt, we can continue the face step without persisting raw NIN data. + // A cache failure occurs before the external request and is therefore a + // safe local failure, not a reconciliation case. + try { + await this.redis.set( + ninSessionKey(storeId), + nin, + NIN_SESSION_TTL_SECONDS, + ); + } catch (error) { + await this.prisma.storeVerificationAttempt.updateMany({ + where: { + id: attempt.id, + status: IdentityVerificationAttemptStatus.SUBMITTED, + }, + data: { + status: IdentityVerificationAttemptStatus.FAILED, + activeKey: null, + failureCode: "NIN_SESSION_UNAVAILABLE", + completedAt: new Date(), + }, + }); + throw new ServiceUnavailableException({ + message: "Verification is temporarily unavailable. Please try again.", + code: "NIN_SESSION_UNAVAILABLE", + cause: error, + }); + } + + try { + const result = await this.identityVerificationProvider.verifyNin({ + storeId, + userId: merchant.userId, + nin, + idempotencyKey: attempt.idempotencyKey, + }); + if (result.status !== "VERIFIED") { + await this.handleUnverifiedAttempt( + attempt.id, + result.status, + result.failureCode ?? "NIN_NOT_FOUND", + result.providerReference, + ); + if (result.status !== "FAILED") { + throw this.reconciliationRequiredException("NIN"); + } + + await this.redis.del(ninSessionKey(storeId)); + await this.recordNinFailure(storeId, "NIN_NOT_FOUND"); + throw new BadRequestException({ + message: "NIN could not be verified", + code: "NIN_NOT_VERIFIED", + }); + } + + const completed = await this.prisma.$transaction(async (tx) => { + const transition = await tx.storeVerificationAttempt.updateMany({ + where: { + id: attempt.id, + status: IdentityVerificationAttemptStatus.SUBMITTED, + }, + data: { + status: IdentityVerificationAttemptStatus.VERIFIED, + activeKey: null, + providerReference: result.providerReference ?? null, + completedAt: result.checkedAt, + }, + }); + if (transition.count === 0) return false; + + await tx.storeProfile.update({ + where: { id: storeId }, + data: { ninVerificationStatus: "PENDING_SELFIE" }, + }); + return true; + }); + if (!completed) { + throw this.reconciliationRequiredException("NIN"); + } + + this.logger.log(`NIN validated for store ${storeId}; awaiting selfie`); + + return { + status: "PENDING_SELFIE" as const, + nextStep: "submit_selfie", + }; + } catch (error) { + await this.markAttemptReconciliationRequired(attempt.id); + throw error; + } + } + + // Step 2 of NIN flow. Reads the cached NIN from Redis and runs a + // face-match against the supplied selfie. On success, persists the NIN + // to the store profile, marks ninVerified=true, resets the counter, + // and re-evaluates the tier ladder. + async submitNinSelfie(storeId: string, selfieBuffer: Buffer) { + const merchant = await this.assertStoreNotLocked(storeId); + if (merchant.ninVerified) { + throw new BadRequestException({ + message: "NIN already verified", + code: "NIN_ALREADY_VERIFIED", + }); + } + + const nin = await this.redis.get(ninSessionKey(storeId)); + if (!nin) { + await this.resetExpiredNinSelfieSession(storeId); + throw new BadRequestException({ + message: "NIN session expired; please re-submit your NIN", + code: "NIN_SESSION_EXPIRED", + }); + } + + const attempt = await this.claimVerificationAttempt( + storeId, + IdentityVerificationCheckType.NIN_FACE_MATCH, + ); + + try { + const result = await this.identityVerificationProvider.verifyNinFaceMatch( + { + storeId, + userId: merchant.userId, + ninReference: nin, + selfieImageBuffer: selfieBuffer, + idempotencyKey: attempt.idempotencyKey, + }, + ); + if (result.status !== "VERIFIED") { + await this.handleUnverifiedAttempt( + attempt.id, + result.status, + result.failureCode ?? "FACE_MISMATCH", + result.providerReference, + ); + if (result.status !== "FAILED") { + throw this.reconciliationRequiredException("selfie"); + } + + await this.recordNinFailure(storeId, "FACE_MISMATCH"); + throw new BadRequestException({ + message: "Selfie did not match the registered identity", + code: "FACE_MISMATCH", + }); + } + + const completed = await this.prisma.$transaction(async (tx) => { + const transition = await tx.storeVerificationAttempt.updateMany({ + where: { + id: attempt.id, + status: IdentityVerificationAttemptStatus.SUBMITTED, + }, + data: { + status: IdentityVerificationAttemptStatus.VERIFIED, + activeKey: null, + providerReference: result.providerReference ?? null, + completedAt: result.checkedAt, + }, + }); + if (transition.count === 0) return false; + + await tx.storeProfile.update({ + where: { id: storeId }, + data: { + ninNumber: nin, + ninVerified: true, + ninVerifiedAt: new Date(), + ninVerifiedVia: attempt.provider.toUpperCase(), + ninVerificationStatus: "VERIFIED", + ninVerificationAttempts: 0, + }, + }); + return true; + }); + if (!completed) { + throw this.reconciliationRequiredException("selfie"); + } + + await this.redis.del(ninSessionKey(storeId)); + this.logger.log(`NIN verification COMPLETED for store ${storeId}`); + + // Re-evaluate the tier ladder now that ninVerified is true. + const newTier = await this.checkAndUpgradeTier(storeId); + return { + status: "VERIFIED" as const, + currentTier: newTier, + }; + } catch (error) { + await this.markAttemptReconciliationRequired(attempt.id); + throw error; + } + } + + /** + * Rechecks an ambiguous provider-owned attempt. It deliberately never + * re-submits NIN, face, or CAC data: only the immutable provider reference + * can be queried here. + */ + async reconcileVerificationAttempt(attemptId: string) { + const attempt = await this.prisma.storeVerificationAttempt.findUnique({ + where: { id: attemptId }, + include: { + store: { select: { id: true, userId: true } }, + }, + }); + if (!attempt) { + throw new NotFoundException("Verification attempt not found"); + } + if ( + attempt.status !== + IdentityVerificationAttemptStatus.RECONCILIATION_REQUIRED + ) { + throw new ConflictException({ + message: + "Only verification attempts requiring reconciliation can be rechecked.", + code: "VERIFICATION_RECONCILIATION_NOT_REQUIRED", + }); + } + if (attempt.provider !== this.identityVerificationProvider.name) { + return this.moveAttemptToManualReview( + attempt.id, + "PROVIDER_RECONCILIATION_UNAVAILABLE", + ); + } + if (!attempt.providerReference) { + return this.moveAttemptToManualReview( + attempt.id, + "PROVIDER_REFERENCE_UNAVAILABLE", + ); + } + + const result = + await this.identityVerificationProvider.getVerificationStatus({ + providerReference: attempt.providerReference, + checkType: attempt.checkType, + }); + + // The provider status response must identify the same immutable operation. + // A browser callback or a cross-account reference must never complete an + // unrelated store's verification attempt. + if (result.providerReference !== attempt.providerReference) { + return this.moveAttemptToManualReview( + attempt.id, + "PROVIDER_REFERENCE_MISMATCH", + ); + } + + if (result.status === "VERIFIED") { + return this.completeReconciledAttempt(attempt); + } + if (result.status === "FAILED") { + const failed = await this.prisma.storeVerificationAttempt.updateMany({ + where: { + id: attempt.id, + status: IdentityVerificationAttemptStatus.RECONCILIATION_REQUIRED, + }, + data: { + status: IdentityVerificationAttemptStatus.FAILED, + activeKey: null, + failureCode: result.failureCode ?? "VERIFICATION_FAILED", + completedAt: result.checkedAt, + }, + }); + if (failed.count === 0) { + throw new ConflictException({ + message: + "Verification attempt state changed. Please refresh and try again.", + code: "VERIFICATION_STATE_CHANGED", + }); + } + if ( + attempt.checkType === IdentityVerificationCheckType.NIN || + attempt.checkType === IdentityVerificationCheckType.NIN_FACE_MATCH + ) { + await this.recordNinFailure( + attempt.storeId, + result.failureCode ?? "VERIFICATION_FAILED", + ); + } + return { status: "FAILED" as const, attemptId: attempt.id }; + } + + // PENDING, retryable and unknown results remain held for another safe + // recheck. The active key stays in place so this cannot cause a new paid + // provider submission. + await this.prisma.storeVerificationAttempt.updateMany({ + where: { + id: attempt.id, + status: IdentityVerificationAttemptStatus.RECONCILIATION_REQUIRED, + }, + data: { failureCode: result.failureCode ?? "PROVIDER_STILL_PENDING" }, + }); + return { + status: "RECONCILIATION_REQUIRED" as const, + attemptId: attempt.id, + }; + } + + private async completeReconciledAttempt(attempt: { + id: string; + storeId: string; + checkType: IdentityVerificationCheckType; + provider: string; + }) { + const needsNinSession = + attempt.checkType === IdentityVerificationCheckType.NIN || + attempt.checkType === IdentityVerificationCheckType.NIN_FACE_MATCH; + const cachedNin = needsNinSession + ? await this.redis.get(ninSessionKey(attempt.storeId)) + : null; + if (needsNinSession && !cachedNin) { + // Twizrr intentionally does not persist raw NIN values. Without the + // bounded session value, a reconciled result cannot safely be connected + // to a later face step, so retain the lock and require human review. + return this.moveAttemptToManualReview( + attempt.id, + "NIN_SESSION_UNAVAILABLE", + ); + } + + if (attempt.checkType === IdentityVerificationCheckType.NIN) { + const completed = await this.prisma.$transaction(async (tx) => { + const transition = await tx.storeVerificationAttempt.updateMany({ + where: { + id: attempt.id, + status: IdentityVerificationAttemptStatus.RECONCILIATION_REQUIRED, + }, + data: { + status: IdentityVerificationAttemptStatus.VERIFIED, + activeKey: null, + completedAt: new Date(), + }, + }); + if (transition.count === 0) return false; + await tx.storeProfile.update({ + where: { id: attempt.storeId }, + data: { ninVerificationStatus: "PENDING_SELFIE" }, + }); + return true; + }); + if (!completed) { + throw new ConflictException({ + message: + "Verification attempt state changed. Please refresh and try again.", + code: "VERIFICATION_STATE_CHANGED", + }); + } + return { status: "PENDING_SELFIE" as const, attemptId: attempt.id }; + } + + if (attempt.checkType === IdentityVerificationCheckType.NIN_FACE_MATCH) { + const completed = await this.prisma.$transaction(async (tx) => { + const transition = await tx.storeVerificationAttempt.updateMany({ + where: { + id: attempt.id, + status: IdentityVerificationAttemptStatus.RECONCILIATION_REQUIRED, + }, + data: { + status: IdentityVerificationAttemptStatus.VERIFIED, + activeKey: null, + completedAt: new Date(), + }, + }); + if (transition.count === 0) return false; + await tx.storeProfile.update({ + where: { id: attempt.storeId }, + data: { + ninNumber: cachedNin, + ninVerified: true, + ninVerifiedAt: new Date(), + ninVerifiedVia: attempt.provider.toUpperCase(), + ninVerificationStatus: "VERIFIED", + ninVerificationAttempts: 0, + }, + }); + return true; + }); + if (!completed) { + throw new ConflictException({ + message: + "Verification attempt state changed. Please refresh and try again.", + code: "VERIFICATION_STATE_CHANGED", + }); + } + await this.redis.del(ninSessionKey(attempt.storeId)); + const currentTier = await this.checkAndUpgradeTier(attempt.storeId); + return { + status: "VERIFIED" as const, + attemptId: attempt.id, + currentTier, + }; + } + + return this.moveAttemptToManualReview( + attempt.id, + "CHECK_TYPE_REQUIRES_MANUAL_REVIEW", + ); + } + + private async moveAttemptToManualReview( + attemptId: string, + failureCode: string, + ) { + const updated = await this.prisma.storeVerificationAttempt.updateMany({ + where: { + id: attemptId, + status: IdentityVerificationAttemptStatus.RECONCILIATION_REQUIRED, + }, + data: { + status: IdentityVerificationAttemptStatus.MANUAL_REVIEW, + failureCode, + }, + }); + if (updated.count === 0) { + throw new ConflictException({ + message: + "Verification attempt state changed. Please refresh and try again.", + code: "VERIFICATION_STATE_CHANGED", + }); + } + return { status: "MANUAL_REVIEW" as const, attemptId }; + } + + private async claimVerificationAttempt( + storeId: string, + checkType: IdentityVerificationCheckType, + ) { + const activeKey = this.verificationAttemptActiveKey(storeId, checkType); + try { + return await this.prisma.storeVerificationAttempt.create({ + data: { + storeId, + provider: this.identityVerificationProvider.name, + checkType, + idempotencyKey: `verification:${randomUUID()}`, + activeKey, + }, + }); + } catch (error) { + if (!this.isUniqueConstraintError(error)) { + throw error; + } + + const existing = await this.prisma.storeVerificationAttempt.findUnique({ + where: { activeKey }, + }); + if (!existing) { + throw error; + } + if ( + existing.status === + IdentityVerificationAttemptStatus.RECONCILIATION_REQUIRED + ) { + throw this.reconciliationRequiredException(checkType); + } + + throw new ConflictException({ + message: + "Verification is already in progress. Please wait before trying again.", + code: "VERIFICATION_IN_PROGRESS", + }); + } + } + + private async handleUnverifiedAttempt( + attemptId: string, + providerStatus: string, + failureCode: string, + providerReference?: string | null, + ): Promise { + const isConfirmedFailure = providerStatus === "FAILED"; + const transition = await this.prisma.storeVerificationAttempt.updateMany({ + where: { + id: attemptId, + status: IdentityVerificationAttemptStatus.SUBMITTED, + }, + data: isConfirmedFailure + ? { + status: IdentityVerificationAttemptStatus.FAILED, + activeKey: null, + failureCode, + providerReference: providerReference ?? null, + completedAt: new Date(), + } + : { + status: IdentityVerificationAttemptStatus.RECONCILIATION_REQUIRED, + failureCode, + providerReference: providerReference ?? null, + }, + }); + if (transition.count === 0) { + throw this.reconciliationRequiredException("verification"); + } + } + + private async resetExpiredNinSelfieSession(storeId: string): Promise { + const activeFaceAttempt = + await this.prisma.storeVerificationAttempt.findUnique({ + where: { + activeKey: this.verificationAttemptActiveKey( + storeId, + IdentityVerificationCheckType.NIN_FACE_MATCH, + ), + }, + }); + if (activeFaceAttempt) { + if ( + activeFaceAttempt.status === + IdentityVerificationAttemptStatus.RECONCILIATION_REQUIRED + ) { + throw this.reconciliationRequiredException("selfie"); + } + + throw new ConflictException({ + message: "NIN selfie verification is already in progress.", + code: "VERIFICATION_IN_PROGRESS", + }); + } + + const reset = await this.prisma.storeProfile.updateMany({ + where: { id: storeId, ninVerificationStatus: "PENDING_SELFIE" }, + data: { ninVerificationStatus: "NONE" }, + }); + if (reset.count === 0) { + throw new ConflictException({ + message: "Verification state changed. Please refresh and try again.", + code: "VERIFICATION_STATE_CHANGED", + }); + } + } + + private async markAttemptReconciliationRequired( + attemptId: string, + ): Promise { + try { + await this.prisma.storeVerificationAttempt.updateMany({ + where: { + id: attemptId, + status: IdentityVerificationAttemptStatus.SUBMITTED, + }, + data: { + status: IdentityVerificationAttemptStatus.RECONCILIATION_REQUIRED, + failureCode: "PROVIDER_OUTCOME_UNKNOWN", + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + this.logger.error( + `Could not mark verification attempt ${attemptId} for reconciliation: ${message}`, + ); + } + } + + private reconciliationRequiredException( + checkType: + | IdentityVerificationCheckType + | "NIN" + | "selfie" + | "verification", + ): ServiceUnavailableException { + return new ServiceUnavailableException({ + message: + "Your verification requires an additional review. The twizrr support team is checking it.", + code: "VERIFICATION_RECONCILIATION_REQUIRED", + checkType, + }); + } + + private isUniqueConstraintError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === "P2002" + ); + } + + private verificationAttemptActiveKey( + storeId: string, + checkType: IdentityVerificationCheckType, + ): string { + return `verification:${storeId}:${checkType}`; + } + + // Resolve a userId -> storeId, then run the tier ladder evaluation. + // Used by the event hooks in auth.service / merchant.service to make + // Tier 1 auto-upgrade event-driven rather than relying on later + // OrderService calls to trigger it. Returns undefined silently if + // the user has no store yet; never throws on the happy path. + async tryUpgradeTierForUser( + userId: string, + ): Promise { + try { + const merchant = await this.prisma.storeProfile.findUnique({ + where: { userId }, + select: { id: true }, + }); + if (!merchant) return undefined; + return await this.checkAndUpgradeTier(merchant.id); + } catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + this.logger.warn( + `tryUpgradeTierForUser failed for user ${userId}: ${message}`, + ); + return undefined; + } + } + + // Increment the combined attempt counter. On the 3rd failure, lock the + // verification path and clear any in-flight Redis session. + private async recordNinFailure( + storeId: string, + reason: string, + ): Promise { + const updated = await this.prisma.storeProfile.update({ + where: { id: storeId }, + data: { ninVerificationAttempts: { increment: 1 } }, + select: { ninVerificationAttempts: true }, + }); + + // Never log the NIN itself. Reason codes are safe. + this.logger.warn( + `NIN verification failure for store ${storeId} (reason: ${reason}, attempts: ${updated.ninVerificationAttempts})`, + ); + + if (updated.ninVerificationAttempts >= MAX_NIN_ATTEMPTS) { + await this.prisma.storeProfile.update({ + where: { id: storeId }, + data: { + ninVerificationStatus: "LOCKED", + ninVerificationLockedAt: new Date(), + }, + }); + await this.redis.del(ninSessionKey(storeId)); + this.logger.warn( + `NIN verification LOCKED for store ${storeId} after ${MAX_NIN_ATTEMPTS} failures`, + ); + } + } + + private async assertStoreNotLocked(storeId: string) { + const merchant = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + }); + if (!merchant) throw new NotFoundException("Store profile not found"); + if (merchant.ninVerificationStatus === "LOCKED") { + throw new ForbiddenException({ + message: + "NIN verification is locked after 3 failed attempts. Please contact support.", + code: "NIN_LOCKED", + }); + } + return merchant; + } + + // Status code helper kept private; the live `status` field on + // StoreProfile is the source of truth. Exposed type via union. + static get MAX_NIN_ATTEMPTS(): number { + return MAX_NIN_ATTEMPTS; + } + + static isNinStatus( + value: string | null | undefined, + ): value is NinVerificationStatus { + return ( + value === "NONE" || + value === "PENDING_SELFIE" || + value === "VERIFIED" || + value === "LOCKED" + ); + } + + private async notifyTierUpgrade(storeId: string, tier: VerificationTier) { + const tierNames: Record = { + [VerificationTier.TIER_1]: "Store basics completed", + [VerificationTier.TIER_2]: "Identity Verified", + }; + const tierBenefits: Record = { + [VerificationTier.TIER_1]: + "You can now list products and receive orders with protected payment.", + [VerificationTier.TIER_2]: + "You now have a verified badge and higher visibility.", + }; + + const merchant = await this.prisma.storeProfile.findUnique({ + where: { id: storeId }, + }); + + if (!merchant) return; + + const title = + tier === VerificationTier.TIER_1 + ? tierNames[tier] + : `Upgraded to ${tierNames[tier]}`; + + await this.notifications + .addJob( + merchant.userId, + NotificationType.TIER_UPGRADED, + title, + tierBenefits[tier], + { tier }, + ) + .catch((err) => + this.logger.error(`Failed to notify tier upgrade: ${err.message}`), + ); + } +} diff --git a/apps/backend/src/health/health.controller.ts b/apps/backend/src/health/health.controller.ts index 21b23978..f8fa6dbb 100644 --- a/apps/backend/src/health/health.controller.ts +++ b/apps/backend/src/health/health.controller.ts @@ -1,12 +1,44 @@ import { Controller, Get } from "@nestjs/common"; +import { + HealthCheckService, + HealthCheck, + MicroserviceHealthIndicator, + MemoryHealthIndicator, + DiskHealthIndicator, +} from "@nestjs/terminus"; +import { PrismaHealthIndicator } from "./indicators/prisma.health"; +import { Transport } from "@nestjs/microservices"; +import { ConfigService } from "@nestjs/config"; @Controller("health") export class HealthController { + constructor( + private health: HealthCheckService, + private prismaHealth: PrismaHealthIndicator, + private microservice: MicroserviceHealthIndicator, + private memory: MemoryHealthIndicator, + private disk: DiskHealthIndicator, + private configService: ConfigService, + ) {} + @Get() + @HealthCheck() check() { - return { - status: "ok", - timestamp: new Date().toISOString(), - }; + return this.health.check([ + () => this.prismaHealth.isHealthy("database"), + () => + this.microservice.pingCheck("redis", { + transport: Transport.REDIS, + options: { + url: this.configService.get("redis.url"), + }, + }), + () => this.memory.checkHeap("memory_heap", 150 * 1024 * 1024), // 150MB + () => + this.disk.checkStorage("storage", { + path: "/", + thresholdPercent: 0.9, + }), // 90% + ]); } } diff --git a/apps/backend/src/health/health.module.ts b/apps/backend/src/health/health.module.ts index f9f7c6b6..feca9525 100644 --- a/apps/backend/src/health/health.module.ts +++ b/apps/backend/src/health/health.module.ts @@ -1,10 +1,13 @@ import { Module } from "@nestjs/common"; +import { TerminusModule } from "@nestjs/terminus"; import { HealthController } from "./health.controller"; import { PrismaModule } from "../prisma/prisma.module"; import { RedisModule } from "../redis/redis.module"; +import { PrismaHealthIndicator } from "./indicators/prisma.health"; @Module({ - imports: [PrismaModule, RedisModule], + imports: [TerminusModule, PrismaModule, RedisModule], controllers: [HealthController], + providers: [PrismaHealthIndicator], }) export class HealthModule {} diff --git a/apps/backend/src/health/indicators/prisma.health.ts b/apps/backend/src/health/indicators/prisma.health.ts new file mode 100644 index 00000000..8ec79c4a --- /dev/null +++ b/apps/backend/src/health/indicators/prisma.health.ts @@ -0,0 +1,26 @@ +import { Injectable } from "@nestjs/common"; +import { + HealthIndicator, + HealthIndicatorResult, + HealthCheckError, +} from "@nestjs/terminus"; +import { PrismaService } from "../../prisma/prisma.service"; + +@Injectable() +export class PrismaHealthIndicator extends HealthIndicator { + constructor(private readonly prisma: PrismaService) { + super(); + } + + async isHealthy(key: string): Promise { + try { + await this.prisma.$queryRaw`SELECT 1`; + return this.getStatus(key, true); + } catch (e: any) { + throw new HealthCheckError( + "Prisma connection failed", + this.getStatus(key, false, { message: e.message }), + ); + } + } +} diff --git a/apps/backend/src/integrations/README.md b/apps/backend/src/integrations/README.md new file mode 100644 index 00000000..6664af05 --- /dev/null +++ b/apps/backend/src/integrations/README.md @@ -0,0 +1,22 @@ +# Integrations + +Integration modules own third-party API details. Feature modules should import +these clients instead of hiding provider-specific code inside business logic. + +Current integrations: + +- `africastalking`: SMS provider setup and dispatch. +- `ai`: Gemini function calling, Gemini Vision, and Google Cloud Vision clients. +- `cloudinary`: media upload client and provider-specific transformations. +- `meta-whatsapp`: Meta WhatsApp Cloud API message send and media download. +- `paystack`: transactions, transfers, bank/account resolution, recipients, and + dedicated virtual accounts. +- `resend`: transactional email delivery. + +Use this area when provider code is shared by multiple domains or needs its own +debugging, mocking, retries, or provider-specific error handling. + +Integration clients should read provider settings through named config +namespaces (`paystack.*`, `whatsapp.*`, `ai.*`, `cloudinary.*`, etc.). Feature, +domain, and channel services should not read raw provider env vars or call +provider HTTP endpoints directly. diff --git a/apps/backend/src/integrations/africastalking/africastalking.client.ts b/apps/backend/src/integrations/africastalking/africastalking.client.ts new file mode 100644 index 00000000..9ab66d5f --- /dev/null +++ b/apps/backend/src/integrations/africastalking/africastalking.client.ts @@ -0,0 +1,166 @@ +import { + BadGatewayException, + Injectable, + Logger, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +interface AfricasTalkingRecipient { + statusCode: number; + number: string; + messageId?: string; + cost?: string; +} + +interface AfricasTalkingSmsResponse { + SMSMessageData: { + Message: string; + Recipients: AfricasTalkingRecipient[]; + }; +} + +// Africa's Talking marks each recipient with a delivery statusCode. Only the +// 100-102 range means the message was accepted (Processed / Sent / Queued); +// anything else (403 invalid number, 405 insufficient balance, 406 blacklisted +// / DND, 5xx gateway errors) is a rejection that must not read as success. +const AT_SUCCESS_STATUS_MIN = 100; +const AT_SUCCESS_STATUS_MAX = 102; + +@Injectable() +export class AfricasTalkingClient { + private readonly logger = new Logger(AfricasTalkingClient.name); + private readonly apiUrl: string; + private readonly username: string; + private readonly apiKey: string; + private readonly senderId: string | undefined; + + constructor(private readonly configService: ConfigService) { + this.username = + this.configService.get("africastalking.username") || "sandbox"; + this.apiKey = this.configService.get("africastalking.apiKey") || ""; + this.senderId = this.configService.get("africastalking.senderId"); + // The sandbox username routes to a separate host; using the live host with + // sandbox credentials silently fails, so pick the base URL from the username. + const host = + this.username === "sandbox" + ? "api.sandbox.africastalking.com" + : "api.africastalking.com"; + this.apiUrl = `https://${host}/version1/messaging`; + } + + async sendSMS(to: string, message: string): Promise { + if (!this.apiKey) { + throw new ServiceUnavailableException({ + message: "Africa's Talking integration is not configured", + code: "AFRICASTALKING_NOT_CONFIGURED", + }); + } + + const body = new URLSearchParams({ + username: this.username, + to, + message, + ...(this.senderId ? { from: this.senderId } : {}), + }); + + let response: Response; + try { + response = await fetch(this.apiUrl, { + method: "POST", + headers: { + apiKey: this.apiKey, + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }); + } catch (error) { + this.logger.error( + `Africa's Talking SMS request failed recipient=${this.maskPhone(to)}`, + ); + throw new ServiceUnavailableException({ + message: "Africa's Talking is temporarily unavailable", + code: "AFRICASTALKING_UNAVAILABLE", + cause: error, + }); + } + + const payload = await this.readJson(response); + if (!this.isSmsResponse(payload)) { + this.logger.error( + `Africa's Talking returned an invalid SMS response status=${response.status}`, + ); + throw new BadGatewayException({ + message: "Africa's Talking returned an invalid response", + code: "AFRICASTALKING_INVALID_RESPONSE", + }); + } + + const recipients = payload.SMSMessageData.Recipients; + if (!response.ok || recipients.length === 0) { + this.logger.error( + `Africa's Talking SMS failed status=${response.status} recipient=${this.maskPhone( + to, + )}`, + ); + throw new BadGatewayException({ + message: "Africa's Talking SMS request failed", + code: "AFRICASTALKING_SMS_FAILED", + }); + } + + // HTTP 200 with recipients only means the request was accepted — each + // recipient still carries its own delivery statusCode that must be checked. + const rejected = recipients.find( + (recipient) => + recipient.statusCode < AT_SUCCESS_STATUS_MIN || + recipient.statusCode > AT_SUCCESS_STATUS_MAX, + ); + if (rejected) { + this.logger.error( + `Africa's Talking rejected SMS statusCode=${rejected.statusCode} recipient=${this.maskPhone( + to, + )}`, + ); + throw new BadGatewayException({ + message: "Africa's Talking could not deliver the SMS", + code: "AFRICASTALKING_SMS_REJECTED", + }); + } + } + + async sendSms(to: string, message: string): Promise { + return this.sendSMS(to, message); + } + + private async readJson(response: Response): Promise { + try { + return (await response.json()) as unknown; + } catch { + return null; + } + } + + private isSmsResponse(value: unknown): value is AfricasTalkingSmsResponse { + if (!value || typeof value !== "object") { + return false; + } + + const record = value as Record; + const smsData = record.SMSMessageData; + if (!smsData || typeof smsData !== "object") { + return false; + } + + const smsRecord = smsData as Record; + return ( + typeof smsRecord.Message === "string" && + Array.isArray(smsRecord.Recipients) + ); + } + + private maskPhone(phone: string): string { + return phone.length <= 4 ? "****" : `****${phone.slice(-4)}`; + } +} diff --git a/apps/backend/src/integrations/africastalking/africastalking.module.ts b/apps/backend/src/integrations/africastalking/africastalking.module.ts new file mode 100644 index 00000000..98dd16a7 --- /dev/null +++ b/apps/backend/src/integrations/africastalking/africastalking.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { AfricasTalkingClient } from "./africastalking.client"; + +@Module({ + imports: [ConfigModule], + providers: [AfricasTalkingClient], + exports: [AfricasTalkingClient], +}) +export class AfricasTalkingModule {} diff --git a/apps/backend/src/integrations/ai/ai.module.ts b/apps/backend/src/integrations/ai/ai.module.ts new file mode 100644 index 00000000..3ac6743a --- /dev/null +++ b/apps/backend/src/integrations/ai/ai.module.ts @@ -0,0 +1,18 @@ +import { Module } from "@nestjs/common"; +import { GeminiClient } from "./gemini.client"; +import { VertexClient } from "./vertex.client"; +import { VisionClient } from "./vision.client"; + +const aiProviders = [GeminiClient, VisionClient, VertexClient]; + +@Module({ + providers: aiProviders, + exports: aiProviders, +}) +export class AIModule {} + +@Module({ + providers: aiProviders, + exports: aiProviders, +}) +export class AiModule {} diff --git a/apps/backend/src/integrations/ai/gemini.client.ts b/apps/backend/src/integrations/ai/gemini.client.ts new file mode 100644 index 00000000..09e249e8 --- /dev/null +++ b/apps/backend/src/integrations/ai/gemini.client.ts @@ -0,0 +1,419 @@ +import { + BadGatewayException, + Injectable, + Logger, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + FunctionCallingMode, + FunctionDeclaration, + GoogleGenerativeAI, + SchemaType, +} from "@google/generative-ai"; + +export interface FunctionCall { + name: string; + args: Record; +} + +export interface GeminiFunctionDeclaration { + name: string; + description?: string; + parameters?: GeminiSchema; +} + +export interface GeminiSchema { + type?: string | SchemaType; + description?: string; + enum?: string[]; + required?: string[]; + properties?: Record; + items?: GeminiSchema; + nullable?: boolean; +} + +interface GeminiGenerateResponse { + candidates?: Array<{ + content?: { + parts?: Array<{ + text?: string; + }>; + }; + }>; +} + +interface GeminiFunctionCallPart { + functionCall?: { + name: string; + args?: unknown; + }; +} + +@Injectable() +export class GeminiClient { + private readonly logger = new Logger(GeminiClient.name); + private readonly apiKey: string; + private readonly genAI: GoogleGenerativeAI | null; + + constructor(private readonly configService: ConfigService) { + this.apiKey = this.configService.get("ai.geminiApiKey") || ""; + this.genAI = this.isConfigured() + ? new GoogleGenerativeAI(this.apiKey) + : null; + + if (!this.genAI) { + this.logger.warn( + "Gemini integration is disabled because it is not configured.", + ); + } + } + + isConfigured(): boolean { + return Boolean( + this.apiKey && this.apiKey !== "your_google_ai_studio_key_here", + ); + } + + async generateContent( + prompt: string, + systemPrompt?: string, + ): Promise { + const genAI = this.getConfiguredClient(); + + try { + const model = genAI.getGenerativeModel({ + model: this.getModelName(), + systemInstruction: systemPrompt || "", + }); + + const result = await model.generateContent(prompt); + const text = result.response.text(); + + if (!text) { + throw new BadGatewayException({ + message: "Gemini returned an empty response", + code: "AI_PROVIDER_INVALID_RESPONSE", + }); + } + + return text; + } catch (error) { + this.handleProviderError("Gemini content generation failed", error); + } + } + + async callWithFunctions( + prompt: string, + functions: GeminiFunctionDeclaration[], + systemPrompt?: string, + ): Promise { + const genAI = this.getConfiguredClient(); + + try { + const model = genAI.getGenerativeModel({ + model: this.getModelName(), + systemInstruction: systemPrompt || "", + tools: [ + { + functionDeclarations: this.normalizeFunctionDeclarations(functions), + }, + ], + toolConfig: { + functionCallingConfig: { + mode: FunctionCallingMode.ANY, + }, + }, + }); + + const result = await model.generateContent(prompt); + const functionCalls = result.response.functionCalls?.() ?? []; + const directFunctionCall = functionCalls[0]; + + if (directFunctionCall) { + return { + name: directFunctionCall.name, + args: this.toRecord(directFunctionCall.args), + }; + } + + const candidate = result.response.candidates?.[0]; + const parts = candidate?.content?.parts as + | GeminiFunctionCallPart[] + | undefined; + const functionCallPart = parts?.find((part) => + Boolean(part.functionCall), + ); + + if (functionCallPart?.functionCall) { + return { + name: functionCallPart.functionCall.name, + args: this.toRecord(functionCallPart.functionCall.args), + }; + } + + throw new BadGatewayException({ + message: "Gemini returned no function call", + code: "AI_PROVIDER_INVALID_RESPONSE", + }); + } catch (error) { + this.handleProviderError("Gemini function call failed", error); + } + } + + async parseFunctionCall(params: { + message: string; + systemPrompt?: string; + functionDeclarations: GeminiFunctionDeclaration[]; + modelName?: string; + }): Promise { + if (!this.isConfigured()) { + return null; + } + + try { + return await this.callWithFunctions( + params.message, + params.functionDeclarations, + params.systemPrompt, + ); + } catch (error) { + this.logger.warn( + this.safeErrorMessage("Gemini function parsing failed", error), + ); + return null; + } + } + + async generateJson(params: { + prompt: string; + systemPrompt?: string; + responseSchema: Record; + modelName?: string; + temperature?: number; + }): Promise { + if (!this.isConfigured()) { + return null; + } + + try { + const response = await this.fetchJson( + `${this.getGenerateContentUrl(params.modelName)}?key=${this.apiKey}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + system_instruction: { + parts: { text: params.systemPrompt || "" }, + }, + contents: [{ parts: [{ text: params.prompt }] }], + generationConfig: { + response_mime_type: "application/json", + response_schema: params.responseSchema, + temperature: params.temperature ?? 0.1, + }, + }), + }, + ); + + const content = response.candidates?.[0]?.content?.parts?.[0]?.text; + + if (!content) { + return null; + } + + return JSON.parse(content) as T; + } catch (error) { + this.logger.warn( + this.safeErrorMessage("Gemini JSON generation failed", error), + ); + return null; + } + } + + async analyzeImageKeywords( + base64Data: string, + mimeType: string, + ): Promise { + if (!this.isConfigured()) { + return null; + } + + try { + const response = await this.fetchJson( + `${this.getGenerateContentUrl("gemini-1.5-flash")}?key=${this.apiKey}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [ + { + parts: [ + { + text: "Identify the main product naturally in this image. Output a comma separated list of up to 5 concise search keywords. Just the keywords, no other text.", + }, + { + inlineData: { + mimeType, + data: base64Data, + }, + }, + ], + }, + ], + }), + }, + ); + + const text = response.candidates?.[0]?.content?.parts?.[0]?.text; + + if (!text) { + return null; + } + + const keywords = text + .split(",") + .map((term) => term.trim()) + .filter(Boolean) + .slice(0, 5); + + return keywords.length > 0 ? keywords : null; + } catch (error) { + this.logger.warn( + this.safeErrorMessage("Gemini image keyword analysis failed", error), + ); + return null; + } + } + + private getModelName(): string { + return ( + this.configService.get("ai.geminiModel") || "gemini-2.5-flash" + ); + } + + private getGenerateContentUrl(modelName?: string): string { + const model = modelName || this.getModelName(); + return `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`; + } + + private getConfiguredClient(): GoogleGenerativeAI { + if (!this.genAI) { + throw new ServiceUnavailableException({ + message: "Gemini integration is not configured", + code: "AI_PROVIDER_NOT_CONFIGURED", + }); + } + + return this.genAI; + } + + private normalizeFunctionDeclarations( + declarations: GeminiFunctionDeclaration[], + ): FunctionDeclaration[] { + return declarations.map((declaration) => ({ + ...declaration, + parameters: declaration.parameters + ? (this.normalizeSchema( + declaration.parameters, + ) as unknown as FunctionDeclaration["parameters"]) + : undefined, + })); + } + + private normalizeSchema(schema: GeminiSchema): Record { + const normalized: Record = { + ...schema, + type: this.normalizeSchemaType(schema.type), + }; + + if (schema.properties) { + normalized.properties = Object.fromEntries( + Object.entries(schema.properties) + .filter((entry): entry is [string, GeminiSchema] => Boolean(entry[1])) + .map(([key, value]) => [key, this.normalizeSchema(value)]), + ); + } + + if (schema.items) { + normalized.items = this.normalizeSchema(schema.items); + } + + return normalized; + } + + private normalizeSchemaType( + type: string | SchemaType | undefined, + ): SchemaType { + if (typeof type !== "string") { + return type || SchemaType.STRING; + } + + switch (type.toLowerCase()) { + case "object": + return SchemaType.OBJECT; + case "number": + return SchemaType.NUMBER; + case "integer": + return SchemaType.INTEGER; + case "boolean": + return SchemaType.BOOLEAN; + case "array": + return SchemaType.ARRAY; + case "string": + default: + return SchemaType.STRING; + } + } + + private async fetchJson(url: string, init: RequestInit): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(url, { + ...init, + signal: controller.signal, + }); + + if (!response.ok) { + throw new BadGatewayException({ + message: "Gemini provider request failed", + code: "AI_PROVIDER_REQUEST_FAILED", + }); + } + + return (await response.json()) as T; + } finally { + clearTimeout(timeout); + } + } + + private toRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + + return {}; + } + + private handleProviderError(message: string, error: unknown): never { + if ( + error instanceof BadGatewayException || + error instanceof ServiceUnavailableException + ) { + throw error; + } + + this.logger.warn(this.safeErrorMessage(message, error)); + throw new BadGatewayException({ + message: "Gemini provider is unavailable", + code: "AI_PROVIDER_UNAVAILABLE", + }); + } + + private safeErrorMessage(message: string, error: unknown): string { + const detail = error instanceof Error ? error.name : typeof error; + return `${message}: ${detail}`; + } +} diff --git a/apps/backend/src/integrations/ai/vertex.client.ts b/apps/backend/src/integrations/ai/vertex.client.ts new file mode 100644 index 00000000..0e4048d6 --- /dev/null +++ b/apps/backend/src/integrations/ai/vertex.client.ts @@ -0,0 +1,350 @@ +import { + BadGatewayException, + Injectable, + Logger, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { createSign } from "crypto"; +import { readFile } from "fs/promises"; + +const VERTEX_EMBEDDING_DIMENSION = 1408; +const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"; +const DEFAULT_TOKEN_URI = "https://oauth2.googleapis.com/token"; + +interface ServiceAccountCredentials { + client_email?: string; + private_key?: string; + token_uri?: string; +} + +interface TokenResponse { + access_token?: string; + expires_in?: number; +} + +interface VertexPrediction { + imageEmbedding?: unknown; + textEmbedding?: unknown; +} + +interface VertexPredictResponse { + predictions?: VertexPrediction[]; +} + +@Injectable() +export class VertexClient { + private readonly logger = new Logger(VertexClient.name); + private readonly projectId: string; + private readonly credentialsPath: string; + private readonly location: string; + private readonly model: string; + private cachedToken: { token: string; expiresAt: number } | null = null; + + constructor(private readonly configService: ConfigService) { + this.projectId = + this.configService.get("ai.googleCloudProject") || ""; + this.credentialsPath = + this.configService.get("ai.googleApplicationCredentials") || ""; + this.location = + this.configService.get("ai.vertexLocation") || "us-central1"; + this.model = + this.configService.get("ai.vertexModel") || + "multimodalembedding@001"; + + if (!this.projectId || !this.credentialsPath) { + this.logger.warn( + "Vertex AI integration is disabled because it is not configured.", + ); + } + } + + isConfigured(): boolean { + return Boolean(this.projectId && this.credentialsPath); + } + + getModelName(): string { + return this.model; + } + + async generateTextEmbedding(text: string): Promise { + this.assertConfigured(); + + const response = await this.predict({ + instances: [{ text }], + parameters: { + dimension: VERTEX_EMBEDDING_DIMENSION, + }, + }); + + const embedding = this.extractEmbedding(response); + + if (embedding.length !== VERTEX_EMBEDDING_DIMENSION) { + throw new BadGatewayException({ + message: "Vertex AI returned an invalid embedding shape", + code: "AI_PROVIDER_INVALID_RESPONSE", + }); + } + + return embedding; + } + + async generateImageEmbedding( + imageBase64: string, + contextText?: string, + ): Promise { + this.assertConfigured(); + + const instance: Record = { + image: { bytesBase64Encoded: imageBase64 }, + }; + const text = contextText?.trim(); + if (text) { + instance.text = text; + } + + const response = await this.predict({ + instances: [instance], + parameters: { + dimension: VERTEX_EMBEDDING_DIMENSION, + }, + }); + + const embedding = this.extractEmbedding(response); + + if (embedding.length !== VERTEX_EMBEDDING_DIMENSION) { + throw new BadGatewayException({ + message: "Vertex AI returned an invalid embedding shape", + code: "AI_PROVIDER_INVALID_RESPONSE", + }); + } + + return embedding; + } + + async generateEmbedding( + imageBuffer: Buffer, + text: string, + ): Promise { + this.assertConfigured(); + + const response = await this.predict({ + instances: [ + { + text, + image: { + bytesBase64Encoded: imageBuffer.toString("base64"), + }, + }, + ], + parameters: { + dimension: VERTEX_EMBEDDING_DIMENSION, + }, + }); + + const embedding = this.extractEmbedding(response); + + if (embedding.length !== VERTEX_EMBEDDING_DIMENSION) { + throw new BadGatewayException({ + message: "Vertex AI returned an invalid embedding shape", + code: "AI_PROVIDER_INVALID_RESPONSE", + }); + } + + return embedding; + } + + private async predict( + body: Record, + ): Promise { + const token = await this.getAccessToken(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); + + try { + const response = await fetch(this.getPredictUrl(), { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + + if (!response.ok) { + throw new BadGatewayException({ + message: "Vertex AI provider request failed", + code: "AI_PROVIDER_REQUEST_FAILED", + }); + } + + return (await response.json()) as VertexPredictResponse; + } catch (error) { + if ( + error instanceof BadGatewayException || + error instanceof ServiceUnavailableException + ) { + throw error; + } + + this.logger.warn( + this.safeErrorMessage("Vertex AI request failed", error), + ); + throw new BadGatewayException({ + message: "Vertex AI provider is unavailable", + code: "AI_PROVIDER_UNAVAILABLE", + }); + } finally { + clearTimeout(timeout); + } + } + + private extractEmbedding(response: VertexPredictResponse): number[] { + const prediction = response.predictions?.[0]; + const candidate = prediction?.imageEmbedding || prediction?.textEmbedding; + + if (!Array.isArray(candidate)) { + throw new BadGatewayException({ + message: "Vertex AI returned no embedding", + code: "AI_PROVIDER_INVALID_RESPONSE", + }); + } + + if (!candidate.every((value) => typeof value === "number")) { + throw new BadGatewayException({ + message: "Vertex AI returned a non-numeric embedding", + code: "AI_PROVIDER_INVALID_RESPONSE", + }); + } + + return candidate; + } + + private async getAccessToken(): Promise { + const now = Math.floor(Date.now() / 1000); + + if (this.cachedToken && this.cachedToken.expiresAt > now + 60) { + return this.cachedToken.token; + } + + const credentials = await this.loadCredentials(); + const tokenUri = credentials.token_uri || DEFAULT_TOKEN_URI; + const assertion = this.createJwtAssertion(credentials, tokenUri, now); + const response = await fetch(tokenUri, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion, + }), + }); + + if (!response.ok) { + throw new ServiceUnavailableException({ + message: "Vertex AI authentication failed", + code: "AI_PROVIDER_AUTH_FAILED", + }); + } + + const data = (await response.json()) as TokenResponse; + + if (!data.access_token || !data.expires_in) { + throw new ServiceUnavailableException({ + message: "Vertex AI authentication returned an invalid token", + code: "AI_PROVIDER_AUTH_FAILED", + }); + } + + this.cachedToken = { + token: data.access_token, + expiresAt: now + data.expires_in, + }; + + return data.access_token; + } + + private async loadCredentials(): Promise< + Required + > { + try { + const rawCredentials = await readFile(this.credentialsPath, "utf8"); + const credentials = JSON.parse( + rawCredentials, + ) as ServiceAccountCredentials; + + if (!credentials.client_email || !credentials.private_key) { + throw new Error("Missing required service account fields"); + } + + return { + client_email: credentials.client_email, + private_key: credentials.private_key, + token_uri: credentials.token_uri || DEFAULT_TOKEN_URI, + }; + } catch (error) { + this.logger.warn( + this.safeErrorMessage( + "Vertex AI credentials could not be loaded", + error, + ), + ); + throw new ServiceUnavailableException({ + message: "Vertex AI credentials are not available", + code: "AI_PROVIDER_NOT_CONFIGURED", + }); + } + } + + private createJwtAssertion( + credentials: Required, + tokenUri: string, + now: number, + ): string { + const encodedHeader = this.base64UrlEncode( + JSON.stringify({ alg: "RS256", typ: "JWT" }), + ); + const encodedClaimSet = this.base64UrlEncode( + JSON.stringify({ + iss: credentials.client_email, + scope: CLOUD_PLATFORM_SCOPE, + aud: tokenUri, + exp: now + 3600, + iat: now, + }), + ); + const unsignedToken = `${encodedHeader}.${encodedClaimSet}`; + const signature = createSign("RSA-SHA256") + .update(unsignedToken) + .sign(credentials.private_key, "base64"); + + return `${unsignedToken}.${this.toBase64Url(signature)}`; + } + + private getPredictUrl(): string { + const encodedModel = encodeURIComponent(this.model); + return `https://${this.location}-aiplatform.googleapis.com/v1/projects/${this.projectId}/locations/${this.location}/publishers/google/models/${encodedModel}:predict`; + } + + private assertConfigured(): void { + if (!this.projectId || !this.credentialsPath) { + throw new ServiceUnavailableException({ + message: "Vertex AI integration is not configured", + code: "AI_PROVIDER_NOT_CONFIGURED", + }); + } + } + + private base64UrlEncode(value: string): string { + return this.toBase64Url(Buffer.from(value, "utf8").toString("base64")); + } + + private toBase64Url(value: string): string { + return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + } + + private safeErrorMessage(message: string, error: unknown): string { + const detail = error instanceof Error ? error.name : typeof error; + return `${message}: ${detail}`; + } +} diff --git a/apps/backend/src/integrations/ai/vision.client.ts b/apps/backend/src/integrations/ai/vision.client.ts new file mode 100644 index 00000000..a3159971 --- /dev/null +++ b/apps/backend/src/integrations/ai/vision.client.ts @@ -0,0 +1,273 @@ +import { + BadGatewayException, + Injectable, + Logger, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +export type SafeSearchLikelihood = + | "VERY_LIKELY" + | "LIKELY" + | "POSSIBLE" + | "UNLIKELY" + | "VERY_UNLIKELY"; + +export interface SafeSearchResult { + adult: SafeSearchLikelihood; + violence: SafeSearchLikelihood; + racy: SafeSearchLikelihood; +} + +type VisionFeatureType = + | "LABEL_DETECTION" + | "SAFE_SEARCH_DETECTION" + | "OBJECT_LOCALIZATION" + | "TEXT_DETECTION"; + +interface VisionEntityAnnotation { + description?: string; + score?: number; +} + +interface VisionLocalizedObjectAnnotation { + name?: string; + score?: number; +} + +interface VisionAnnotateResponse { + responses?: Array<{ + labelAnnotations?: VisionEntityAnnotation[]; + localizedObjectAnnotations?: VisionLocalizedObjectAnnotation[]; + textAnnotations?: VisionEntityAnnotation[]; + safeSearchAnnotation?: Partial>; + error?: { + code?: number; + message?: string; + }; + }>; +} + +@Injectable() +export class VisionClient { + private readonly logger = new Logger(VisionClient.name); + private readonly apiKey: string; + private readonly endpoint = + "https://vision.googleapis.com/v1/images:annotate"; + + constructor(private readonly configService: ConfigService) { + this.apiKey = this.configService.get("ai.googleCloudApiKey") || ""; + + if (!this.apiKey) { + this.logger.warn( + "Cloud Vision integration is disabled because it is not configured.", + ); + } + } + + isConfigured(): boolean { + return Boolean(this.apiKey); + } + + async detectLabels(imageBuffer: Buffer): Promise { + const response = await this.annotateImage(imageBuffer, [ + { type: "LABEL_DETECTION", maxResults: 10 }, + ]); + + return this.extractLabels(response); + } + + async checkSafety(imageBuffer: Buffer): Promise { + const response = await this.annotateImage(imageBuffer, [ + { type: "SAFE_SEARCH_DETECTION", maxResults: 1 }, + ]); + + return this.extractSafeSearchResult(response); + } + + async checkSafetyFromBase64(base64Data: string): Promise { + const response = await this.annotateBase64(base64Data, [ + { type: "SAFE_SEARCH_DETECTION", maxResults: 1 }, + ]); + + return this.extractSafeSearchResult(response); + } + + async detectProductTerms(base64Data: string): Promise { + if (!this.isConfigured()) { + return null; + } + + try { + const response = await this.annotateBase64(base64Data, [ + { type: "TEXT_DETECTION", maxResults: 1 }, + { type: "OBJECT_LOCALIZATION", maxResults: 3 }, + { type: "LABEL_DETECTION", maxResults: 5 }, + ]); + + const responseData = response.responses?.[0]; + const objects = + responseData?.localizedObjectAnnotations + ?.filter((object) => (object.score || 0) > 0.5) + .map((object) => object.name) + .filter((name): name is string => Boolean(name)) || []; + + const labels = this.extractLabels(response); + const textTerms = + objects.length === 0 && labels.length === 0 + ? this.extractTextTerms( + responseData?.textAnnotations?.[0]?.description, + ) + : []; + const terms = Array.from(new Set([...objects, ...labels, ...textTerms])); + + return terms.length > 0 ? terms : null; + } catch (error) { + this.logger.warn( + this.safeErrorMessage( + "Cloud Vision product term extraction failed", + error, + ), + ); + return null; + } + } + + private async annotateImage( + imageBuffer: Buffer, + features: Array<{ type: VisionFeatureType; maxResults?: number }>, + ): Promise { + return this.annotateBase64(imageBuffer.toString("base64"), features); + } + + private async annotateBase64( + base64Data: string, + features: Array<{ type: VisionFeatureType; maxResults?: number }>, + ): Promise { + this.assertConfigured(); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(`${this.endpoint}?key=${this.apiKey}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + requests: [ + { + image: { content: base64Data }, + features, + }, + ], + }), + signal: controller.signal, + }); + + if (!response.ok) { + throw new BadGatewayException({ + message: "Cloud Vision provider request failed", + code: "AI_PROVIDER_REQUEST_FAILED", + }); + } + + const data = (await response.json()) as VisionAnnotateResponse; + const providerError = data.responses?.[0]?.error; + + if (providerError) { + throw new BadGatewayException({ + message: "Cloud Vision provider returned an error", + code: "AI_PROVIDER_REQUEST_FAILED", + }); + } + + return data; + } catch (error) { + if ( + error instanceof BadGatewayException || + error instanceof ServiceUnavailableException + ) { + throw error; + } + + this.logger.warn( + this.safeErrorMessage("Cloud Vision request failed", error), + ); + throw new BadGatewayException({ + message: "Cloud Vision provider is unavailable", + code: "AI_PROVIDER_UNAVAILABLE", + }); + } finally { + clearTimeout(timeout); + } + } + + private extractSafeSearchResult( + response: VisionAnnotateResponse, + ): SafeSearchResult { + const safeSearch = response.responses?.[0]?.safeSearchAnnotation; + + if (!safeSearch) { + throw new BadGatewayException({ + message: "Cloud Vision returned no SafeSearch result", + code: "AI_PROVIDER_INVALID_RESPONSE", + }); + } + + return { + adult: this.normalizeLikelihood(safeSearch.adult), + violence: this.normalizeLikelihood(safeSearch.violence), + racy: this.normalizeLikelihood(safeSearch.racy), + }; + } + + private extractLabels(response: VisionAnnotateResponse): string[] { + return ( + response.responses?.[0]?.labelAnnotations + ?.filter((label) => (label.score || 0) > 0.7) + .map((label) => label.description) + .filter((description): description is string => Boolean(description)) + .slice(0, 10) || [] + ); + } + + private extractTextTerms(textBlock: string | undefined): string[] { + if (!textBlock) { + return []; + } + + return textBlock + .split(/\s+/) + .filter((word) => word.length > 3 && !/^[0-9]+$/.test(word)) + .slice(0, 3); + } + + private normalizeLikelihood(value: string | undefined): SafeSearchLikelihood { + switch (value) { + case "VERY_LIKELY": + case "LIKELY": + case "POSSIBLE": + case "UNLIKELY": + case "VERY_UNLIKELY": + return value; + default: + return "VERY_UNLIKELY"; + } + } + + private assertConfigured(): void { + if (!this.apiKey) { + throw new ServiceUnavailableException({ + message: "Cloud Vision integration is not configured", + code: "AI_PROVIDER_NOT_CONFIGURED", + }); + } + } + + private safeErrorMessage(message: string, error: unknown): string { + const detail = error instanceof Error ? error.name : typeof error; + return `${message}: ${detail}`; + } +} + +export { VisionClient as GoogleVisionClient }; diff --git a/apps/backend/src/modules/upload/cloudinary-transforms.ts b/apps/backend/src/integrations/cloudinary/cloudinary-transforms.ts similarity index 84% rename from apps/backend/src/modules/upload/cloudinary-transforms.ts rename to apps/backend/src/integrations/cloudinary/cloudinary-transforms.ts index 09f68e2c..027f33be 100644 --- a/apps/backend/src/modules/upload/cloudinary-transforms.ts +++ b/apps/backend/src/integrations/cloudinary/cloudinary-transforms.ts @@ -7,7 +7,7 @@ export const CLOUDINARY_TRANSFORMS = { quality: "auto", fetch_format: "auto", }, - MERCHANT_BANNER: { + STORE_BANNER: { width: 1200, height: 400, crop: "fill", @@ -15,14 +15,16 @@ export const CLOUDINARY_TRANSFORMS = { fetch_format: "auto", }, PRODUCT_FEED: { - width: 400, - height: 400, + width: 1080, + height: 1080, crop: "fill", quality: "auto", fetch_format: "auto", }, PRODUCT_DETAIL: { - width: 800, + width: 1080, + height: 1080, + crop: "fill", quality: "auto", fetch_format: "auto", }, diff --git a/apps/backend/src/integrations/cloudinary/cloudinary.client.ts b/apps/backend/src/integrations/cloudinary/cloudinary.client.ts new file mode 100644 index 00000000..46177526 --- /dev/null +++ b/apps/backend/src/integrations/cloudinary/cloudinary.client.ts @@ -0,0 +1,146 @@ +import { + BadGatewayException, + Injectable, + Logger, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + UploadApiErrorResponse, + UploadApiOptions, + UploadApiResponse, + v2 as cloudinary, +} from "cloudinary"; +import { Readable } from "stream"; + +import { CLOUDINARY_TRANSFORMS } from "./cloudinary-transforms"; + +export type CloudinaryTransformValue = + | string + | number + | boolean + | string[] + | undefined; + +export type CloudinaryTransforms = Record; + +export interface CloudinaryUploadResult { + url: string; + publicId: string; +} + +@Injectable() +export class CloudinaryClient { + private readonly logger = new Logger(CloudinaryClient.name); + + constructor(private readonly configService: ConfigService) { + cloudinary.config({ + cloud_name: this.configService.get("cloudinary.cloudName"), + api_key: this.configService.get("cloudinary.apiKey"), + api_secret: this.configService.get("cloudinary.apiSecret"), + }); + } + + async uploadImage( + buffer: Buffer, + folder: string, + publicId?: string, + ): Promise { + this.assertConfigured(); + + const options: UploadApiOptions = { + folder, + resource_type: "image", + ...(publicId ? { public_id: publicId } : {}), + }; + + return new Promise((resolve, reject) => { + const upload = cloudinary.uploader.upload_stream( + options, + ( + error: UploadApiErrorResponse | undefined, + result: UploadApiResponse | undefined, + ) => { + if (error || !result) { + this.logger.error("Cloudinary upload failed"); + reject( + new BadGatewayException({ + message: "Cloudinary upload failed", + code: "CLOUDINARY_UPLOAD_FAILED", + }), + ); + return; + } + + resolve({ + url: result.secure_url, + publicId: result.public_id, + }); + }, + ); + + Readable.from(buffer).pipe(upload); + }); + } + + async deleteImage(publicId: string): Promise { + this.assertConfigured(); + + try { + await cloudinary.uploader.destroy(publicId, { resource_type: "image" }); + } catch { + this.logger.error(`Cloudinary delete failed publicId=${publicId}`); + throw new BadGatewayException({ + message: "Cloudinary delete failed", + code: "CLOUDINARY_DELETE_FAILED", + }); + } + } + + generateSignedUrl( + publicId: string, + transforms: CloudinaryTransforms = {}, + ): string { + this.assertConfigured(); + + return cloudinary.url(publicId, { + secure: true, + sign_url: true, + transformation: transforms, + }); + } + + async uploadBuffer( + buffer: Buffer, + folder: string, + transformType?: string, + ): Promise { + const result = await this.uploadImage(buffer, folder); + if (!transformType || !this.isTransformType(transformType)) { + return result.url; + } + + return this.generateSignedUrl(result.publicId, { + ...CLOUDINARY_TRANSFORMS[transformType], + }); + } + + private assertConfigured(): void { + const cloudName = this.configService.get("cloudinary.cloudName"); + const apiKey = this.configService.get("cloudinary.apiKey"); + const apiSecret = this.configService.get("cloudinary.apiSecret"); + + if (!cloudName || !apiKey || !apiSecret) { + throw new ServiceUnavailableException({ + message: "Cloudinary integration is not configured", + code: "CLOUDINARY_NOT_CONFIGURED", + }); + } + } + + private isTransformType( + value: string, + ): value is keyof typeof CLOUDINARY_TRANSFORMS { + return Object.prototype.hasOwnProperty.call(CLOUDINARY_TRANSFORMS, value); + } +} diff --git a/apps/backend/src/integrations/cloudinary/cloudinary.module.ts b/apps/backend/src/integrations/cloudinary/cloudinary.module.ts new file mode 100644 index 00000000..3329d9b7 --- /dev/null +++ b/apps/backend/src/integrations/cloudinary/cloudinary.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { CloudinaryClient } from "./cloudinary.client"; + +@Module({ + providers: [CloudinaryClient], + exports: [CloudinaryClient], +}) +export class CloudinaryModule {} diff --git a/apps/backend/src/integrations/google-oauth/google-oauth.client.ts b/apps/backend/src/integrations/google-oauth/google-oauth.client.ts new file mode 100644 index 00000000..b0b5616e --- /dev/null +++ b/apps/backend/src/integrations/google-oauth/google-oauth.client.ts @@ -0,0 +1,152 @@ +import { + Injectable, + ServiceUnavailableException, + UnauthorizedException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import { + GoogleOAuthProfile, + GoogleTokenResponse, + GoogleUserInfoResponse, +} from "./google-oauth.types"; + +const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"; +const GOOGLE_OAUTH_SCOPE = "openid email profile"; +const GOOGLE_HTTP_TIMEOUT_MS = 10_000; + +@Injectable() +export class GoogleOAuthClient { + private readonly clientId: string; + private readonly clientSecret: string; + private readonly redirectUrl: string; + + constructor(private readonly configService: ConfigService) { + this.clientId = this.configService.get("GOOGLE_CLIENT_ID") || ""; + this.clientSecret = + this.configService.get("GOOGLE_CLIENT_SECRET") || ""; + this.redirectUrl = + this.configService.get("GOOGLE_OAUTH_REDIRECT_URL") || ""; + } + + getAuthorizationUrl(state: string): string { + this.assertConfigured(); + + const query = new URLSearchParams({ + client_id: this.clientId, + redirect_uri: this.redirectUrl, + response_type: "code", + scope: GOOGLE_OAUTH_SCOPE, + state, + prompt: "select_account", + }); + + return `${GOOGLE_AUTH_URL}?${query.toString()}`; + } + + async getProfileFromCode(code: string): Promise { + this.assertConfigured(); + + const { response: tokenResponse, data: tokenData } = + await this.fetchGoogleJson( + GOOGLE_TOKEN_URL, + { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code, + client_id: this.clientId, + client_secret: this.clientSecret, + redirect_uri: this.redirectUrl, + grant_type: "authorization_code", + }), + }, + "Google sign-in could not be verified", + "GOOGLE_OAUTH_TOKEN_INVALID", + ); + + if (!tokenResponse.ok || !tokenData.access_token) { + throw new UnauthorizedException({ + message: "Google sign-in could not be verified", + code: "GOOGLE_OAUTH_TOKEN_INVALID", + }); + } + + const { response: profileResponse, data: profile } = + await this.fetchGoogleJson( + GOOGLE_USERINFO_URL, + { + headers: { + authorization: `Bearer ${tokenData.access_token}`, + }, + }, + "Google profile could not be verified", + "GOOGLE_OAUTH_PROFILE_INVALID", + ); + + if (!profileResponse.ok || !profile.sub || !profile.email) { + throw new UnauthorizedException({ + message: "Google profile could not be verified", + code: "GOOGLE_OAUTH_PROFILE_INVALID", + }); + } + + return { + sub: profile.sub, + email: profile.email.trim().toLowerCase(), + emailVerified: + profile.email_verified === true || profile.email_verified === "true", + name: profile.name, + givenName: profile.given_name, + familyName: profile.family_name, + picture: profile.picture, + }; + } + + private async fetchGoogleJson( + url: string, + init: RequestInit, + message: string, + code: string, + ): Promise<{ response: Response; data: T }> { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + GOOGLE_HTTP_TIMEOUT_MS, + ); + let response: Response; + + try { + response = await fetch(url, { + ...init, + signal: controller.signal, + }); + } catch { + throw new UnauthorizedException({ message, code }); + } finally { + clearTimeout(timeout); + } + + try { + return { + response, + data: (await response.json()) as T, + }; + } catch { + throw new UnauthorizedException({ message, code }); + } + } + + private assertConfigured(): void { + if (!this.clientId || !this.clientSecret || !this.redirectUrl) { + throw new ServiceUnavailableException({ + message: "Google sign-in is not configured", + code: "GOOGLE_OAUTH_NOT_CONFIGURED", + }); + } + } +} diff --git a/apps/backend/src/integrations/google-oauth/google-oauth.module.ts b/apps/backend/src/integrations/google-oauth/google-oauth.module.ts new file mode 100644 index 00000000..7054ed0e --- /dev/null +++ b/apps/backend/src/integrations/google-oauth/google-oauth.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; + +import { GoogleOAuthClient } from "./google-oauth.client"; + +@Module({ + providers: [GoogleOAuthClient], + exports: [GoogleOAuthClient], +}) +export class GoogleOAuthModule {} diff --git a/apps/backend/src/integrations/google-oauth/google-oauth.types.ts b/apps/backend/src/integrations/google-oauth/google-oauth.types.ts new file mode 100644 index 00000000..7fccce08 --- /dev/null +++ b/apps/backend/src/integrations/google-oauth/google-oauth.types.ts @@ -0,0 +1,29 @@ +export interface GoogleOAuthProfile { + sub: string; + email: string; + emailVerified: boolean; + name?: string; + givenName?: string; + familyName?: string; + picture?: string; +} + +interface GoogleTokenResponseBase { + access_token?: string; + error?: string; + error_description?: string; +} + +export type GoogleTokenResponse = GoogleTokenResponseBase & { + [key: string]: unknown; +}; + +export type GoogleUserInfoResponse = { + sub?: string; + email?: string; + email_verified?: boolean | string; + name?: string; + given_name?: string; + family_name?: string; + picture?: string; +}; diff --git a/apps/backend/src/integrations/google-places/README.md b/apps/backend/src/integrations/google-places/README.md new file mode 100644 index 00000000..50e1f824 --- /dev/null +++ b/apps/backend/src/integrations/google-places/README.md @@ -0,0 +1,6 @@ +# Google Places Integration + +Address autocomplete client for store setup. +To be built as part of the users/store domain. +Required methods: autocomplete(), getPlaceDetails(). + diff --git a/apps/backend/src/integrations/google-places/google-places.client.ts b/apps/backend/src/integrations/google-places/google-places.client.ts new file mode 100644 index 00000000..b738a0e3 --- /dev/null +++ b/apps/backend/src/integrations/google-places/google-places.client.ts @@ -0,0 +1,250 @@ +import { + BadGatewayException, + Injectable, + Logger, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import { + GooglePlaceDetails, + GooglePlacePrediction, +} from "./google-places.types"; + +type HttpMethod = "GET" | "POST"; + +interface GooglePlacesRequestOptions { + method?: HttpMethod; + body?: Record; + fieldMask: string; + safeReference?: string; +} + +@Injectable() +export class GooglePlacesClient { + private readonly logger = new Logger(GooglePlacesClient.name); + private readonly baseUrl: string; + private readonly apiKey: string; + + constructor(private readonly configService: ConfigService) { + this.baseUrl = + this.configService.get("googlePlaces.baseUrl") || + "https://places.googleapis.com/v1"; + this.apiKey = this.configService.get("googlePlaces.apiKey") || ""; + } + + async autocomplete( + input: string, + sessionToken: string, + ): Promise { + const payload = await this.request("autocomplete", "/places:autocomplete", { + method: "POST", + safeReference: sessionToken, + fieldMask: + "suggestions.placePrediction.placeId,suggestions.placePrediction.text,suggestions.placePrediction.structuredFormat", + body: { + input, + sessionToken, + includedRegionCodes: ["ng"], + }, + }); + + const suggestions = this.pickArray(payload, ["suggestions"]); + return suggestions + .map((suggestion) => this.toPrediction(suggestion)) + .filter((prediction): prediction is GooglePlacePrediction => + Boolean(prediction), + ); + } + + async getPlaceDetails( + placeId: string, + sessionToken: string, + ): Promise { + const query = new URLSearchParams({ sessionToken }); + const payload = await this.request( + "place details", + `/places/${encodeURIComponent(placeId)}?${query.toString()}`, + { + safeReference: placeId, + fieldMask: "id,displayName,formattedAddress,location,addressComponents", + }, + ); + const record = this.asRecord(payload); + const location = this.asRecord(record.location); + const addressComponents = Array.isArray(record.addressComponents) + ? record.addressComponents + : []; + + return { + placeId: this.readString(record, ["id"]) || placeId, + displayName: + this.readNestedString(record, ["displayName"], ["text"]) || "", + formattedAddress: this.readString(record, ["formattedAddress"]), + latitude: this.readOptionalNumber(location, ["latitude"]), + longitude: this.readOptionalNumber(location, ["longitude"]), + city: this.findAddressComponent(addressComponents, "locality"), + state: this.findAddressComponent( + addressComponents, + "administrative_area_level_1", + ), + country: this.findAddressComponent(addressComponents, "country"), + }; + } + + private async request( + operation: string, + path: string, + options: GooglePlacesRequestOptions, + ): Promise { + if (!this.apiKey) { + throw new ServiceUnavailableException({ + message: "Google Places integration is not configured", + code: "GOOGLE_PLACES_NOT_CONFIGURED", + }); + } + + let response: Response; + try { + response = await fetch(`${this.baseUrl}${path}`, { + method: options.method || "GET", + headers: { + "X-Goog-Api-Key": this.apiKey, + "X-Goog-FieldMask": options.fieldMask, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + } catch (error) { + this.logger.error( + `Google Places ${operation} request failed${this.safeReference( + options.safeReference, + )}`, + ); + throw new ServiceUnavailableException({ + message: "Google Places is temporarily unavailable", + code: "GOOGLE_PLACES_UNAVAILABLE", + cause: error, + }); + } + + const payload = await this.readJson(response); + if (!response.ok) { + this.logger.error( + `Google Places ${operation} failed status=${response.status}${this.safeReference( + options.safeReference, + )}`, + ); + throw new BadGatewayException({ + message: "Google Places request failed", + code: "GOOGLE_PLACES_REQUEST_FAILED", + }); + } + + return payload; + } + + private async readJson(response: Response): Promise { + try { + return (await response.json()) as unknown; + } catch { + return null; + } + } + + private toPrediction(value: unknown): GooglePlacePrediction | null { + const record = this.asRecord(value); + const placePrediction = this.asRecord(record.placePrediction); + if (!Object.keys(placePrediction).length) { + return null; + } + + const structuredFormat = this.asRecord(placePrediction.structuredFormat); + return { + placeId: this.readString(placePrediction, ["placeId"]), + text: this.readNestedString(placePrediction, ["text"], ["text"]) || "", + primaryText: + this.readNestedString(structuredFormat, ["mainText"], ["text"]) || + undefined, + secondaryText: + this.readNestedString(structuredFormat, ["secondaryText"], ["text"]) || + undefined, + }; + } + + private pickArray(value: unknown, keys: string[]): unknown[] { + const record = this.asRecord(value); + for (const key of keys) { + const candidate = record[key]; + if (Array.isArray(candidate)) { + return candidate; + } + } + return []; + } + + private asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + } + + private readString(record: Record, keys: string[]): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") { + return value; + } + } + return ""; + } + + private readNestedString( + record: Record, + objectKeys: string[], + valueKeys: string[], + ): string | undefined { + for (const key of objectKeys) { + const nested = this.asRecord(record[key]); + for (const valueKey of valueKeys) { + const value = nested[valueKey]; + if (typeof value === "string") { + return value; + } + } + } + return undefined; + } + + private readOptionalNumber( + record: Record, + keys: string[], + ): number | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number") { + return value; + } + } + return undefined; + } + + private findAddressComponent( + components: unknown[], + type: string, + ): string | undefined { + for (const component of components) { + const record = this.asRecord(component); + const types = record.types; + if (Array.isArray(types) && types.includes(type)) { + return this.readString(record, ["longText", "shortText"]); + } + } + return undefined; + } + + private safeReference(reference: string | undefined): string { + return reference ? ` reference=${reference}` : ""; + } +} diff --git a/apps/backend/src/integrations/google-places/google-places.module.ts b/apps/backend/src/integrations/google-places/google-places.module.ts new file mode 100644 index 00000000..94ce6c14 --- /dev/null +++ b/apps/backend/src/integrations/google-places/google-places.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; + +import { GooglePlacesClient } from "./google-places.client"; + +@Module({ + providers: [GooglePlacesClient], + exports: [GooglePlacesClient], +}) +export class GooglePlacesModule {} diff --git a/apps/backend/src/integrations/google-places/google-places.types.ts b/apps/backend/src/integrations/google-places/google-places.types.ts new file mode 100644 index 00000000..917514ff --- /dev/null +++ b/apps/backend/src/integrations/google-places/google-places.types.ts @@ -0,0 +1,17 @@ +export interface GooglePlacePrediction { + placeId: string; + text: string; + primaryText?: string; + secondaryText?: string; +} + +export interface GooglePlaceDetails { + placeId: string; + displayName: string; + formattedAddress: string; + latitude?: number; + longitude?: number; + city?: string; + state?: string; + country?: string; +} diff --git a/apps/backend/src/integrations/meta-whatsapp/meta-whatsapp.client.spec.ts b/apps/backend/src/integrations/meta-whatsapp/meta-whatsapp.client.spec.ts new file mode 100644 index 00000000..8484d7de --- /dev/null +++ b/apps/backend/src/integrations/meta-whatsapp/meta-whatsapp.client.spec.ts @@ -0,0 +1,91 @@ +import { Logger } from "@nestjs/common"; +import type { ConfigService } from "@nestjs/config"; +import { MetaWhatsAppClient } from "./meta-whatsapp.client"; + +describe("MetaWhatsAppClient logging", () => { + const configService = { + get: jest.fn(), + }; + + let client: MetaWhatsAppClient; + let fetchSpy: jest.SpyInstance; + let loggerErrorSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + configService.get.mockImplementation((key: string) => { + const values: Record = { + "whatsapp.apiVersion": "v21.0", + "whatsapp.phoneNumberId": "phone-number-id", + "whatsapp.accessToken": "test-token", + }; + return values[key]; + }); + loggerErrorSpy = jest.spyOn(Logger.prototype, "error").mockImplementation(); + client = new MetaWhatsAppClient(configService as unknown as ConfigService); + }); + + afterEach(() => { + fetchSpy?.mockRestore(); + loggerErrorSpy.mockRestore(); + }); + + it("does not log raw recipient or full Meta error bodies", async () => { + fetchSpy = jest.spyOn(global, "fetch").mockResolvedValue({ + ok: false, + status: 400, + text: async () => + JSON.stringify({ + error: { + message: "Cannot send message to +2348012345678: Hello from WIZZA", + type: "OAuthException", + code: 131_000, + error_subcode: 249_4012, + fbtrace_id: "trace-1", + }, + }), + } as unknown as Response); + + await expect( + client.sendTextMessage("+2348012345678", "Hello from WIZZA"), + ).rejects.toThrow( + "Meta API error status=400 type=OAuthException code=131000 subcode=2494012 traceId=trace-1", + ); + + const logOutput = loggerErrorSpy.mock.calls.flat().join(" "); + expect(logOutput).toContain("recipient=234******5678"); + expect(logOutput).toContain("messageType=text"); + expect(logOutput).toContain("code=131000"); + expect(logOutput).not.toContain("+2348012345678"); + expect(logOutput).not.toContain("Hello from WIZZA"); + expect(logOutput).not.toContain("Cannot send message"); + }); + + it("masks recipient phone when logging non-throwing send failures", async () => { + fetchSpy = jest.spyOn(global, "fetch").mockResolvedValue({ + ok: false, + status: 429, + text: async () => + JSON.stringify({ + error: { + message: "Rate limited recipient 2348012345678", + type: "OAuthException", + code: 4, + fbtrace_id: "trace-2", + }, + }), + } as unknown as Response); + + await expect( + client.sendTextMessage("2348012345678", "Hello from WIZZA", { + throwOnError: false, + }), + ).resolves.toBe(false); + + const logOutput = loggerErrorSpy.mock.calls.flat().join(" "); + expect(logOutput).toContain("recipient=234******5678"); + expect(logOutput).toContain("status=429"); + expect(logOutput).not.toContain("2348012345678"); + expect(logOutput).not.toContain("Rate limited recipient"); + }); +}); diff --git a/apps/backend/src/integrations/meta-whatsapp/meta-whatsapp.client.ts b/apps/backend/src/integrations/meta-whatsapp/meta-whatsapp.client.ts new file mode 100644 index 00000000..57ab18a6 --- /dev/null +++ b/apps/backend/src/integrations/meta-whatsapp/meta-whatsapp.client.ts @@ -0,0 +1,249 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { maskWhatsAppPhone } from "../../channels/whatsapp/whatsapp.utils"; + +type SendMessageOptions = { + normalizeRecipient?: boolean; + throwOnError?: boolean; + timeoutMs?: number; +}; + +type SafeMetaError = { + type?: string; + code?: string | number; + subcode?: string | number; + traceId?: string; +}; + +@Injectable() +export class MetaWhatsAppClient { + private readonly logger = new Logger(MetaWhatsAppClient.name); + private readonly baseUrl = "https://graph.facebook.com"; + private readonly apiVersion: string; + private readonly phoneNumberId: string; + private readonly accessToken: string; + + constructor(private configService: ConfigService) { + this.apiVersion = + this.configService.get("whatsapp.apiVersion") || "v21.0"; + this.phoneNumberId = + this.configService.get("whatsapp.phoneNumberId") || ""; + this.accessToken = + this.configService.get("whatsapp.accessToken") || ""; + + if (!this.phoneNumberId || !this.accessToken) { + this.logger.warn( + "WhatsApp configuration missing! WHATSAPP_PHONE_NUMBER_ID and WHATSAPP_TOKEN are required for WhatsApp features to work.", + ); + } + } + + getMediaGraphUrl(imageId: string): string { + return `${this.baseUrl}/${this.apiVersion}/${imageId}`; + } + + async sendTextMessage( + to: string, + text: string, + options: SendMessageOptions = {}, + ): Promise { + return this.sendMessage( + { + messaging_product: "whatsapp", + to, + type: "text", + text: { body: text }, + }, + options, + ); + } + + async markMessageAsReadWithTyping(messageId: string): Promise { + if (!messageId.trim()) { + return false; + } + + return this.sendMessage( + { + messaging_product: "whatsapp", + status: "read", + message_id: messageId, + typing_indicator: { + type: "text", + }, + }, + { + normalizeRecipient: false, + throwOnError: false, + timeoutMs: 5000, + }, + ); + } + + async sendMessage( + payload: Record, + options: SendMessageOptions = {}, + ): Promise { + const { normalizeRecipient = true, throwOnError = true } = options; + + if (!this.phoneNumberId || !this.accessToken) { + const message = "WhatsApp credentials missing. Skipping message send."; + this.logger.warn(message); + if (throwOnError) { + throw new Error(message); + } + return false; + } + + const outboundPayload = { ...payload }; + if (normalizeRecipient && typeof outboundPayload.to === "string") { + outboundPayload.to = outboundPayload.to.startsWith("+") + ? outboundPayload.to.slice(1) + : outboundPayload.to; + } + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + options.timeoutMs ?? 10000, + ); + + try { + const response = await fetch( + `${this.baseUrl}/${this.apiVersion}/${this.phoneNumberId}/messages`, + { + method: "POST", + headers: { + Authorization: `Bearer ${this.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(outboundPayload), + signal: controller.signal as RequestInit["signal"], + }, + ); + + clearTimeout(timeout); + + if (!response.ok) { + const errorBody = await response.text(); + const safeError = this.extractSafeMetaError(errorBody); + const errorMessage = this.formatMetaError(response.status, safeError); + if (throwOnError) { + throw new Error(errorMessage); + } + this.logger.error( + `Meta API error recipient=${maskWhatsAppPhone(outboundPayload.to)} messageType=${this.getPayloadType(outboundPayload)} ${errorMessage}`, + ); + return false; + } + + return true; + } catch (error) { + clearTimeout(timeout); + this.logger.error( + `Failed to send WhatsApp message recipient=${maskWhatsAppPhone(outboundPayload.to)} messageType=${this.getPayloadType(outboundPayload)}: ${this.safeErrorMessage(error)}`, + ); + if (throwOnError) { + throw error; + } + return false; + } + } + + private extractSafeMetaError(errorBody: string): SafeMetaError { + try { + const parsed = JSON.parse(errorBody) as { + error?: { + type?: string; + code?: string | number; + error_subcode?: string | number; + fbtrace_id?: string; + }; + }; + return { + type: parsed.error?.type, + code: parsed.error?.code, + subcode: parsed.error?.error_subcode, + traceId: parsed.error?.fbtrace_id, + }; + } catch { + return {}; + } + } + + private formatMetaError(status: number, error: SafeMetaError): string { + const parts = [`status=${status}`]; + if (error.type) parts.push(`type=${error.type}`); + if (error.code !== undefined) parts.push(`code=${error.code}`); + if (error.subcode !== undefined) parts.push(`subcode=${error.subcode}`); + if (error.traceId) parts.push(`traceId=${error.traceId}`); + return `Meta API error ${parts.join(" ")}`; + } + + private getPayloadType(payload: Record): string { + return typeof payload.type === "string" ? payload.type : "unknown"; + } + + private safeErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message + .replace(/\+?\d{8,15}/g, (match) => maskWhatsAppPhone(match)) + .slice(0, 300); + } + + async downloadImage( + imageId: string, + ): Promise<{ base64Data: string; mimeType: string } | null> { + try { + const metadata = await this.fetchJsonWithTimeout( + this.getMediaGraphUrl(imageId), + ); + const imageUrl = metadata?.url; + + if (!imageUrl) { + throw new Error("Meta image metadata did not include a URL."); + } + + const imageResponse = await this.fetchWithTimeout(imageUrl); + + if (!imageResponse.ok) { + throw new Error("Failed to download image data"); + } + + const mimeType = + metadata.mime_type || + (imageResponse.headers.get("content-type") || "image/jpeg") + .split(";")[0] + .trim(); + const arrayBuffer = await imageResponse.arrayBuffer(); + const base64Data = Buffer.from(arrayBuffer).toString("base64"); + + return { base64Data, mimeType }; + } catch (error) { + this.logger.error("Meta image download error:", error); + return null; + } + } + + private async fetchJsonWithTimeout(url: string): Promise { + const response = await this.fetchWithTimeout(url); + if (!response.ok) { + throw new Error("Failed to get image URL"); + } + return response.json(); + } + + private async fetchWithTimeout(url: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + + try { + return await fetch(url, { + headers: { Authorization: `Bearer ${this.accessToken}` }, + signal: controller.signal as RequestInit["signal"], + }); + } finally { + clearTimeout(timeout); + } + } +} diff --git a/apps/backend/src/integrations/meta-whatsapp/meta-whatsapp.module.ts b/apps/backend/src/integrations/meta-whatsapp/meta-whatsapp.module.ts new file mode 100644 index 00000000..74b4beb1 --- /dev/null +++ b/apps/backend/src/integrations/meta-whatsapp/meta-whatsapp.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { MetaWhatsAppClient } from "./meta-whatsapp.client"; + +@Module({ + providers: [MetaWhatsAppClient], + exports: [MetaWhatsAppClient], +}) +export class MetaWhatsAppModule {} diff --git a/apps/backend/src/integrations/monnify/monnify-payment.provider.spec.ts b/apps/backend/src/integrations/monnify/monnify-payment.provider.spec.ts new file mode 100644 index 00000000..ac4c50d6 --- /dev/null +++ b/apps/backend/src/integrations/monnify/monnify-payment.provider.spec.ts @@ -0,0 +1,199 @@ +import { createHmac } from "crypto"; + +import { MonnifyClient } from "./monnify.client"; +import { MonnifyPaymentProvider } from "./monnify-payment.provider"; + +describe("MonnifyPaymentProvider", () => { + const client = { + initializeTransaction: jest.fn(), + verifyTransaction: jest.fn(), + verifyWebhookSignature: jest.fn(), + } as unknown as MonnifyClient; + const provider = new MonnifyPaymentProvider(client); + + beforeEach(() => jest.clearAllMocks()); + + it("maps hosted checkout initialization into the Twizrr payment contract", async () => { + (client.initializeTransaction as jest.Mock).mockResolvedValue({ + checkoutUrl: "https://sandbox.monnify.com/checkout/abc", + transactionReference: "MNFY|transaction", + paymentReference: "twz-reference", + }); + + await expect( + provider.initializePayment({ + email: "buyer@twizrr.test", + amountKobo: 250050n, + reference: "twz-reference", + }), + ).resolves.toEqual( + expect.objectContaining({ + authorizationUrl: "https://sandbox.monnify.com/checkout/abc", + accessCode: null, + reference: "twz-reference", + providerTransactionReference: "MNFY|transaction", + }), + ); + }); + + it("normalizes a confirmed transaction and converts naira to kobo", async () => { + (client.verifyTransaction as jest.Mock).mockResolvedValue({ + transactionReference: "MNFY|transaction", + paymentReference: "twz-reference", + amountPaid: "2500.50", + paymentStatus: "PAID", + currencyCode: "NGN", + paymentMethod: "ACCOUNT_TRANSFER", + }); + + await expect(provider.verifyPayment("twz-reference")).resolves.toEqual( + expect.objectContaining({ + status: "success", + amountKobo: 250050n, + reference: "twz-reference", + providerTransactionReference: "MNFY|transaction", + }), + ); + }); + + it.each(["OVERPAID", "PARTIALLY_PAID", "PARTIAL_PAID"])( + "maps collected discrepancy status %s to reconciliation", + async (paymentStatus) => { + (client.verifyTransaction as jest.Mock).mockResolvedValue({ + transactionReference: "MNFY|transaction", + paymentReference: "twz-reference", + amountPaid: "2600.00", + paymentStatus, + currencyCode: "NGN", + }); + + await expect(provider.verifyPayment("twz-reference")).resolves.toEqual( + expect.objectContaining({ + status: "reconciliation_required", + amountKobo: 260000n, + }), + ); + }, + ); + + it("normalizes a successful collection webhook with a stable dedupe identity", () => { + const event = provider.parseWebhookEvent({ + eventType: "SUCCESSFUL_TRANSACTION", + eventData: { + transactionReference: "MNFY|transaction", + paymentReference: "twz-reference", + destinationAccountInformation: { accountNumber: "0123456789" }, + }, + }); + + expect(event).toMatchObject({ + provider: "MONNIFY", + eventType: "SUCCESSFUL_TRANSACTION", + reference: "twz-reference", + eventId: "SUCCESSFUL_TRANSACTION:MNFY|transaction", + }); + }); + + it("delegates signature verification to the Monnify client", () => { + (client.verifyWebhookSignature as jest.Mock).mockReturnValue(true); + expect( + provider.verifyWebhookSignature({ + rawBody: Buffer.from("{}"), + signature: "signature", + }), + ).toBe(true); + }); +}); + +describe("MonnifyClient webhook verification", () => { + const config = { + get: jest.fn((key: string) => { + const values: Record = { + "monnify.baseUrl": "https://sandbox.monnify.com", + "monnify.apiKey": "api-key", + "monnify.secretKey": "secret-key", + "monnify.contractCode": "contract-code", + }; + return values[key]; + }), + }; + + it("accepts the exact HMAC-SHA512 client-secret signature and rejects a mismatch", () => { + const client = new MonnifyClient(config as never); + const rawBody = Buffer.from('{"eventType":"SUCCESSFUL_TRANSACTION"}'); + const signature = createHmac("sha512", "secret-key") + .update(rawBody) + .digest("hex"); + + expect(client.verifyWebhookSignature(rawBody, signature)).toBe(true); + expect(client.verifyWebhookSignature(rawBody, "invalid")).toBe(false); + expect(client.verifyWebhookSignature(rawBody, undefined)).toBe(false); + }); + + it("authenticates server-side and initializes hosted checkout with a kobo-safe amount", async () => { + const client = new MonnifyClient(config as never); + const fetchMock = jest + .spyOn(global, "fetch") + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + requestSuccessful: true, + responseBody: { accessToken: "sandbox-token", expiresIn: 3600 }, + }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + requestSuccessful: true, + responseBody: { + checkoutUrl: "https://sandbox.monnify.com/checkout/abc", + transactionReference: "MNFY|transaction", + paymentReference: "twz-reference", + }, + }), + } as Response); + + await expect( + client.initializeTransaction({ + email: "buyer@twizrr.test", + customerName: "Buyer Test", + amountKobo: 250050n, + reference: "twz-reference", + callbackUrl: "https://twizrr.test/payment/callback", + }), + ).resolves.toMatchObject({ paymentReference: "twz-reference" }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://sandbox.monnify.com/api/v1/auth/login", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: `Basic ${Buffer.from("api-key:secret-key").toString("base64")}`, + }), + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://sandbox.monnify.com/api/v1/merchant/transactions/init-transaction", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer sandbox-token", + }), + body: expect.stringContaining('"amount":2500.5'), + }), + ); + fetchMock.mockRestore(); + }); + + it("rejects amounts that cannot cross the JSON-number boundary without kobo loss", async () => { + const client = new MonnifyClient(config as never); + await expect( + client.initializeTransaction({ + email: "buyer@twizrr.test", + amountKobo: BigInt(Number.MAX_SAFE_INTEGER), + reference: "twz-too-large", + }), + ).rejects.toThrow("Payment amount is outside the supported provider range"); + }); +}); diff --git a/apps/backend/src/integrations/monnify/monnify-payment.provider.ts b/apps/backend/src/integrations/monnify/monnify-payment.provider.ts new file mode 100644 index 00000000..6073dec8 --- /dev/null +++ b/apps/backend/src/integrations/monnify/monnify-payment.provider.ts @@ -0,0 +1,137 @@ +import { Injectable } from "@nestjs/common"; +import { createHash } from "crypto"; + +import { + InitializePaymentInput, + InitializePaymentResult, + ListPaymentBankResult, + PaymentProvider, + PaymentProviderEvent, + ResolvePaymentAccountInput, + ResolvePaymentAccountResult, + VerifyPaymentResult, + VerifyWebhookSignatureInput, +} from "../../domains/money/payment/providers/payment-provider.interface"; +import { MonnifyClient } from "./monnify.client"; + +@Injectable() +export class MonnifyPaymentProvider implements PaymentProvider { + readonly name = "MONNIFY" as const; + + constructor(private readonly monnify: MonnifyClient) {} + + async initializePayment( + input: InitializePaymentInput, + ): Promise { + const response = await this.monnify.initializeTransaction(input); + return { + authorizationUrl: response.checkoutUrl, + // Monnify uses hosted checkout. Do not populate Paystack's inline + // access-code field or web clients will attempt to open it in Paystack. + accessCode: null, + reference: response.paymentReference, + providerTransactionReference: response.transactionReference, + }; + } + + async verifyPayment(reference: string): Promise { + const response = await this.monnify.verifyTransaction(reference); + const providerStatus = response.paymentStatus.toUpperCase(); + const collectedDiscrepancy = new Set([ + "OVERPAID", + "PARTIALLY_PAID", + "PARTIAL_PAID", + ]); + const pending = new Set([ + "PENDING", + "PENDING_PAYMENT", + "WAITING_FOR_PAYMENT", + "PROCESSING", + ]); + const confirmedFailure = new Set([ + "FAILED", + "CANCELLED", + "CANCELED", + "EXPIRED", + "ABANDONED", + "REVERSED", + ]); + const status = + providerStatus === "PAID" + ? "success" + : collectedDiscrepancy.has(providerStatus) + ? "reconciliation_required" + : pending.has(providerStatus) + ? "pending" + : confirmedFailure.has(providerStatus) + ? "failed" + : "reconciliation_required"; + return { + status, + amountKobo: this.toKobo(response.amountPaid), + reference: response.paymentReference, + currency: response.currencyCode || response.currency || "NGN", + gatewayResponse: response.paymentStatus, + paidAt: response.paidOn || response.completedOn || null, + channel: response.paymentMethod || null, + providerTransactionReference: response.transactionReference || null, + }; + } + + verifyWebhookSignature(input: VerifyWebhookSignatureInput): boolean { + return this.monnify.verifyWebhookSignature(input.rawBody, input.signature); + } + + parseWebhookEvent(payload: unknown): PaymentProviderEvent { + const eventPayload = this.object(payload); + const eventData = this.object(eventPayload.eventData); + const eventType = this.string(eventPayload.eventType) || "unknown"; + const reference = this.string(eventData.paymentReference); + const providerEventId = + this.string(eventData.transactionReference) || reference; + return { + provider: this.name, + eventType, + eventId: providerEventId + ? `${eventType}:${providerEventId}` + : `${eventType}:${createHash("sha256") + .update(JSON.stringify(payload ?? {})) + .digest("hex")}`, + reference, + rawPayload: payload, + }; + } + + async resolveAccount( + _input: ResolvePaymentAccountInput, + ): Promise { + throw new Error( + "Monnify account resolution is not available through the payment adapter", + ); + } + + async listBanks(): Promise { + throw new Error( + "Monnify bank listing is not available through the payment adapter", + ); + } + + private toKobo(value: number | string): bigint { + const amount = String(value); + const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(amount); + if (!match) throw new Error("Monnify returned an invalid payment amount"); + return ( + BigInt(match[1]) * 100n + BigInt((match[2] || "").padEnd(2, "0") || "0") + ); + } + + private object(value: unknown): Record { + return value && typeof value === "object" + ? (value as Record) + : {}; + } + + private string(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; + } +} diff --git a/apps/backend/src/integrations/monnify/monnify-payout.provider.spec.ts b/apps/backend/src/integrations/monnify/monnify-payout.provider.spec.ts new file mode 100644 index 00000000..a150e6b2 --- /dev/null +++ b/apps/backend/src/integrations/monnify/monnify-payout.provider.spec.ts @@ -0,0 +1,138 @@ +import { ConfigService } from "@nestjs/config"; + +import { MonnifyClient } from "./monnify.client"; +import { MonnifyPayoutProvider } from "./monnify-payout.provider"; + +describe("MonnifyPayoutProvider", () => { + const client = { + listDisbursementBanks: jest.fn(), + validateDisbursementAccount: jest.fn(), + initiateDisbursement: jest.fn(), + getDisbursement: jest.fn(), + verifyWebhookSignature: jest.fn(), + } as unknown as MonnifyClient; + const config = { + get: jest.fn((key: string) => { + if (key === "monnify.payoutEnabled") return true; + if (key === "monnify.disbursementAccountNumber") return "1234567890"; + return undefined; + }), + } as unknown as ConfigService; + const provider = new MonnifyPayoutProvider(client, config); + + beforeEach(() => jest.clearAllMocks()); + + it("maps banks and account validation into Twizrr-owned types", async () => { + (client.listDisbursementBanks as jest.Mock).mockResolvedValue([ + { name: "Test Bank", code: "058" }, + ]); + (client.validateDisbursementAccount as jest.Mock).mockResolvedValue({ + accountName: "Test Merchant", + accountNumber: "0123456789", + }); + + await expect(provider.listBanks()).resolves.toEqual([ + { name: "Test Bank", code: "058", active: true, type: "nuban" }, + ]); + await expect( + provider.resolveAccount({ bankCode: "058", accountNumber: "0123456789" }), + ).resolves.toEqual({ + accountName: "Test Merchant", + accountNumber: "0123456789", + }); + await expect( + provider.createRecipient({ + bankCode: "058", + accountNumber: "0123456789", + accountName: "Test Merchant", + }), + ).resolves.toEqual({ recipientCode: null }); + }); + + it("submits the exact BigInt-kobo amount with a stable reference", async () => { + (client.initiateDisbursement as jest.Mock).mockResolvedValue({ + reference: "PO-PAYOUT-1-A1", + transactionReference: "MNFY|transfer-1", + status: "PENDING", + }); + + await expect( + provider.initiateTransfer({ + destination: { + bankCode: "058", + accountNumber: "0123456789", + accountName: "Test Merchant", + }, + amountKobo: 1234567n, + reference: "PO-PAYOUT-1-A1", + reason: "Twizrr merchant payout", + }), + ).resolves.toEqual({ + reference: "PO-PAYOUT-1-A1", + transferCode: "MNFY|transfer-1", + status: "PENDING", + }); + + expect(client.initiateDisbursement).toHaveBeenCalledWith({ + amountKobo: 1234567n, + reference: "PO-PAYOUT-1-A1", + narration: "Twizrr merchant payout", + destinationBankCode: "058", + destinationAccountNumber: "0123456789", + destinationAccountName: "Test Merchant", + sourceAccountNumber: "1234567890", + }); + }); + + it.each([ + ["SUCCESS", "SUCCESS"], + ["COMPLETED", "SUCCESS"], + ["PENDING_AUTHORIZATION", "OTP"], + ["IN_PROGRESS", "PROCESSING"], + ["FAILED", "FAILED"], + ["REVERSED", "FAILED"], + ])( + "normalizes %s without treating acceptance as completion", + async (status, expected) => { + (client.getDisbursement as jest.Mock).mockResolvedValue({ + reference: "PO-PAYOUT-1-A1", + transactionReference: null, + status, + }); + + await expect(provider.verifyTransfer("PO-PAYOUT-1-A1")).resolves.toEqual({ + reference: "PO-PAYOUT-1-A1", + transferCode: null, + status: expected, + }); + }, + ); + + it("fails closed while payout execution is disabled", async () => { + (config.get as jest.Mock).mockImplementation((key: string) => + key === "monnify.payoutEnabled" ? false : "1234567890", + ); + + await expect(provider.listBanks()).rejects.toThrow("not enabled"); + expect(client.listDisbursementBanks).not.toHaveBeenCalled(); + }); + + it("accepts only normalized disbursement webhook events", () => { + (client.verifyWebhookSignature as jest.Mock).mockReturnValue(true); + expect( + provider.verifyWebhookSignature(Buffer.from("{}"), "signature"), + ).toBe(true); + expect( + provider.parseWebhookEvent({ + eventType: "SUCCESSFUL_DISBURSEMENT", + eventData: { reference: "PO-PAYOUT-1-A1", bankAccount: "secret" }, + }), + ).toEqual({ reference: "PO-PAYOUT-1-A1" }); + expect( + provider.parseWebhookEvent({ + eventType: "SUCCESSFUL_TRANSACTION", + eventData: { reference: "PO-PAYOUT-1-A1" }, + }), + ).toBeNull(); + }); +}); diff --git a/apps/backend/src/integrations/monnify/monnify-payout.provider.ts b/apps/backend/src/integrations/monnify/monnify-payout.provider.ts new file mode 100644 index 00000000..0242a44e --- /dev/null +++ b/apps/backend/src/integrations/monnify/monnify-payout.provider.ts @@ -0,0 +1,146 @@ +import { Injectable, ServiceUnavailableException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import { + CreatePayoutRecipientInput, + CreatePayoutRecipientResult, + InitiatePayoutTransferInput, + InitiatePayoutTransferResult, + ListPayoutBankResult, + PayoutProvider, + PayoutTransferStatus, + ResolvePayoutAccountInput, + ResolvePayoutAccountResult, + VerifyPayoutTransferResult, +} from "../../domains/money/payout/providers/payout-provider.interface"; +import { MonnifyClient } from "./monnify.client"; + +function normalizeStatus( + status: string | null | undefined, +): PayoutTransferStatus { + switch ((status ?? "").toUpperCase()) { + case "SUCCESS": + case "COMPLETED": + return "SUCCESS"; + case "FAILED": + case "REVERSED": + case "EXPIRED": + return "FAILED"; + case "PENDING_AUTHORIZATION": + return "OTP"; + case "PENDING": + return "PENDING"; + default: + return "PROCESSING"; + } +} + +@Injectable() +export class MonnifyPayoutProvider implements PayoutProvider { + readonly key = "monnify" as const; + + constructor( + private readonly monnify: MonnifyClient, + private readonly config: ConfigService, + ) {} + + private assertEnabled(): void { + if (this.config.get("monnify.payoutEnabled") !== true) { + throw new ServiceUnavailableException("Monnify payouts are not enabled"); + } + } + + private sourceAccountNumber(): string { + const accountNumber = this.config.get( + "monnify.disbursementAccountNumber", + ); + if (!accountNumber) { + throw new ServiceUnavailableException( + "Monnify payout source account is not configured", + ); + } + return accountNumber; + } + + async listBanks(): Promise { + this.assertEnabled(); + const banks = await this.monnify.listDisbursementBanks(); + return banks.map((bank) => ({ + name: bank.name, + code: bank.code, + active: true, + type: "nuban", + })); + } + + async resolveAccount( + input: ResolvePayoutAccountInput, + ): Promise { + this.assertEnabled(); + const account = await this.monnify.validateDisbursementAccount(input); + return { + accountName: account.accountName, + accountNumber: account.accountNumber, + }; + } + + async createRecipient( + _input: CreatePayoutRecipientInput, + ): Promise { + this.assertEnabled(); + // Monnify validates the destination account for every transfer rather than + // issuing a durable recipient code. The payout row snapshots the validated + // destination details before execution. + return { recipientCode: null }; + } + + async initiateTransfer( + input: InitiatePayoutTransferInput, + ): Promise { + this.assertEnabled(); + const transfer = await this.monnify.initiateDisbursement({ + amountKobo: input.amountKobo, + reference: input.reference, + narration: input.reason, + destinationBankCode: input.destination.bankCode, + destinationAccountNumber: input.destination.accountNumber, + destinationAccountName: input.destination.accountName, + sourceAccountNumber: this.sourceAccountNumber(), + }); + return { + reference: transfer.reference || input.reference, + transferCode: transfer.transactionReference ?? null, + status: normalizeStatus(transfer.status), + }; + } + + async verifyTransfer(reference: string): Promise { + this.assertEnabled(); + const transfer = await this.monnify.getDisbursement(reference); + return { + reference: transfer.reference || reference, + transferCode: transfer.transactionReference ?? null, + status: normalizeStatus(transfer.status), + }; + } + + verifyWebhookSignature( + rawBody: Buffer | string | undefined, + signature: string | string[] | undefined, + ): boolean { + return this.monnify.verifyWebhookSignature(rawBody, signature); + } + + parseWebhookEvent(payload: unknown): { reference: string } | null { + if (!payload || typeof payload !== "object") return null; + const body = payload as Record; + const eventType = typeof body.eventType === "string" ? body.eventType : ""; + if (!eventType.toUpperCase().includes("DISBURSEMENT")) return null; + const eventData = body.eventData; + if (!eventData || typeof eventData !== "object") return null; + const reference = (eventData as Record).reference; + return typeof reference === "string" && reference.length > 0 + ? { reference } + : null; + } +} diff --git a/apps/backend/src/integrations/monnify/monnify-refund.provider.spec.ts b/apps/backend/src/integrations/monnify/monnify-refund.provider.spec.ts new file mode 100644 index 00000000..3787e649 --- /dev/null +++ b/apps/backend/src/integrations/monnify/monnify-refund.provider.spec.ts @@ -0,0 +1,101 @@ +import { MonnifyClient } from "./monnify.client"; +import { MonnifyRefundProvider } from "./monnify-refund.provider"; + +describe("MonnifyRefundProvider", () => { + const client = { + initiateRefund: jest.fn(), + getRefund: jest.fn(), + verifyWebhookSignature: jest.fn(), + } as unknown as MonnifyClient; + const config = { + get: jest.fn(), + }; + const provider = new MonnifyRefundProvider(client, config as never); + + beforeEach(() => { + jest.clearAllMocks(); + (config.get as jest.Mock).mockReturnValue(true); + }); + + it("submits a stable Twizrr refund reference and normalizes PENDING", async () => { + (client.initiateRefund as jest.Mock).mockResolvedValue({ + refundReference: "twz-refund-leg-1", + refundAmount: "2500.50", + refundStatus: "PENDING", + }); + + await expect( + provider.createRefund({ + transactionReference: "MNFY|transaction-1", + refundReference: "twz-refund-leg-1", + amountKobo: 250050n, + currency: "NGN", + }), + ).resolves.toEqual({ + provider: "monnify", + providerRefundId: "twz-refund-leg-1", + status: "PENDING", + amountKobo: 250050n, + }); + + expect(client.initiateRefund).toHaveBeenCalledWith( + expect.objectContaining({ + transactionReference: "MNFY|transaction-1", + refundReference: "twz-refund-leg-1", + amountKobo: 250050n, + }), + ); + }); + + it("does not submit new refunds while the Monnify refund flag is disabled", async () => { + (config.get as jest.Mock).mockReturnValue(false); + + await expect( + provider.createRefund({ + transactionReference: "MNFY|transaction-1", + refundReference: "twz-refund-leg-1", + amountKobo: 250050n, + currency: "NGN", + }), + ).rejects.toThrow("disabled"); + expect(client.initiateRefund).not.toHaveBeenCalled(); + }); + + it("maps unknown provider statuses to review instead of completion", async () => { + (client.getRefund as jest.Mock).mockResolvedValue({ + refundReference: "twz-refund-leg-1", + refundAmount: "10000.03", + refundStatus: "AWAITING_BANK_ACTION", + }); + + await expect(provider.fetchRefund("twz-refund-leg-1")).resolves.toEqual( + expect.objectContaining({ + providerRefundId: "twz-refund-leg-1", + status: "NEEDS_ATTENTION", + amountKobo: 1000003n, + }), + ); + }); + + it("accepts only normalized refund webhook events", () => { + (client.verifyWebhookSignature as jest.Mock).mockReturnValue(true); + expect( + provider.verifyWebhookSignature(Buffer.from("{}"), "signature"), + ).toBe(true); + expect( + provider.parseWebhookEvent({ + eventType: "SUCCESSFUL_REFUND", + eventData: { refundReference: "twz-refund-leg-1" }, + }), + ).toEqual({ + eventType: "SUCCESSFUL_REFUND", + providerRefundId: "twz-refund-leg-1", + }); + expect( + provider.parseWebhookEvent({ + eventType: "SUCCESSFUL_TRANSACTION", + eventData: { refundReference: "twz-refund-leg-1" }, + }), + ).toBeNull(); + }); +}); diff --git a/apps/backend/src/integrations/monnify/monnify-refund.provider.ts b/apps/backend/src/integrations/monnify/monnify-refund.provider.ts new file mode 100644 index 00000000..c1b01495 --- /dev/null +++ b/apps/backend/src/integrations/monnify/monnify-refund.provider.ts @@ -0,0 +1,126 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import { + CreateRefundInput, + CreateRefundResult, + FetchRefundResult, + RefundProvider, + RefundProviderKey, + RefundStatus, +} from "../../domains/money/refund/refund-provider.interface"; +import { MonnifyClient } from "./monnify.client"; +import type { MonnifyRefundResponse } from "./monnify.types"; + +const PROVIDER: RefundProviderKey = "monnify"; + +export type MonnifyRefundWebhookEvent = { + providerRefundId: string; + eventType: "SUCCESSFUL_REFUND" | "FAILED_REFUND"; +}; + +@Injectable() +export class MonnifyRefundProvider implements RefundProvider { + readonly key = PROVIDER; + + constructor( + private readonly monnify: MonnifyClient, + private readonly config: ConfigService, + ) {} + + get supportsNewRefunds(): boolean { + return this.config.get("monnify.refundEnabled", false); + } + + async createRefund(input: CreateRefundInput): Promise { + if (!this.supportsNewRefunds) { + throw new Error("Monnify refund execution is disabled"); + } + + const refund = await this.monnify.initiateRefund({ + transactionReference: input.transactionReference, + refundReference: input.refundReference, + amountKobo: input.amountKobo, + refundReason: input.merchantNote || "Twizrr dispute settlement refund", + customerNote: input.customerNote, + }); + + return { + provider: PROVIDER, + providerRefundId: refund.refundReference, + status: this.normalizeStatus(refund.refundStatus), + amountKobo: this.readAmountKobo(refund, input.amountKobo), + }; + } + + async fetchRefund(providerRefundId: string): Promise { + const refund = await this.monnify.getRefund(providerRefundId); + const status = this.normalizeStatus(refund.refundStatus); + return { + providerRefundId: refund.refundReference, + status, + amountKobo: this.readAmountKobo(refund, 0n), + completedAt: + status === "COMPLETED" ? this.parseDate(refund.completedOn) : null, + }; + } + + verifyWebhookSignature(rawBody: unknown, signature: unknown): boolean { + return this.monnify.verifyWebhookSignature(rawBody, signature); + } + + parseWebhookEvent(payload: unknown): MonnifyRefundWebhookEvent | null { + const root = this.object(payload); + const eventType = this.string(root.eventType); + if (eventType !== "SUCCESSFUL_REFUND" && eventType !== "FAILED_REFUND") { + return null; + } + const eventData = this.object(root.eventData); + const providerRefundId = this.string(eventData.refundReference); + return providerRefundId ? { providerRefundId, eventType } : null; + } + + private normalizeStatus(status: string | null | undefined): RefundStatus { + switch ((status ?? "").trim().toUpperCase()) { + case "COMPLETED": + return "COMPLETED"; + case "PENDING": + return "PENDING"; + case "PROCESSING": + case "IN_PROGRESS": + return "PROCESSING"; + case "FAILED": + return "FAILED"; + default: + return "NEEDS_ATTENTION"; + } + } + + private readAmountKobo( + refund: MonnifyRefundResponse, + fallback: bigint, + ): bigint { + const amount = String(refund.refundAmount); + const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(amount); + if (!match) return fallback; + return ( + BigInt(match[1]) * 100n + BigInt((match[2] || "").padEnd(2, "0") || "0") + ); + } + + private parseDate(value: string | null | undefined): Date | null { + if (!value) return null; + const parsed = new Date(value); + return Number.isNaN(parsed.valueOf()) ? null : parsed; + } + + private object(value: unknown): Record { + return value && typeof value === "object" + ? (value as Record) + : {}; + } + + private string(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; + } +} diff --git a/apps/backend/src/integrations/monnify/monnify.client.ts b/apps/backend/src/integrations/monnify/monnify.client.ts new file mode 100644 index 00000000..90352644 --- /dev/null +++ b/apps/backend/src/integrations/monnify/monnify.client.ts @@ -0,0 +1,292 @@ +import { + BadGatewayException, + Injectable, + Logger, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { createHmac, timingSafeEqual } from "crypto"; + +import { + MonnifyAuthResponse, + MonnifyEnvelope, + MonnifyInitializeTransactionResponse, + MonnifyAccountValidationResponse, + MonnifyBank, + MonnifyDisbursementResponse, + MonnifyRefundResponse, + MonnifyTransactionResponse, +} from "./monnify.types"; + +@Injectable() +export class MonnifyClient { + private static readonly REQUEST_TIMEOUT_MS = 15_000; + private static readonly MAX_SAFE_PROVIDER_KOBO = + BigInt(Number.MAX_SAFE_INTEGER) / 100n; + private readonly logger = new Logger(MonnifyClient.name); + private readonly baseUrl: string; + private readonly apiKey: string; + private readonly secretKey: string; + private readonly contractCode: string; + private accessToken: string | null = null; + private accessTokenExpiresAt = 0; + + constructor(private readonly config: ConfigService) { + this.baseUrl = this.config.get("monnify.baseUrl") || ""; + this.apiKey = this.config.get("monnify.apiKey") || ""; + this.secretKey = this.config.get("monnify.secretKey") || ""; + this.contractCode = this.config.get("monnify.contractCode") || ""; + } + + async initializeTransaction(input: { + email: string; + customerName?: string; + amountKobo: bigint; + reference: string; + callbackUrl?: string; + metadata?: Record; + }): Promise { + this.assertPaymentConfigured(); + return this.request( + "/api/v1/merchant/transactions/init-transaction", + { + method: "POST", + body: { + amount: this.toNairaAmount(input.amountKobo), + customerEmail: input.email, + customerName: input.customerName || input.email, + paymentReference: input.reference, + paymentDescription: "Twizrr order payment", + currencyCode: "NGN", + contractCode: this.contractCode, + ...(input.callbackUrl ? { redirectUrl: input.callbackUrl } : {}), + ...(input.metadata ? { metadata: input.metadata } : {}), + }, + }, + ); + } + + async verifyTransaction( + paymentReference: string, + ): Promise { + this.assertApiConfigured(); + const query = new URLSearchParams({ paymentReference }); + return this.request( + `/api/v2/merchant/transactions/query?${query.toString()}`, + { method: "GET" }, + ); + } + + async initiateRefund(input: { + transactionReference: string; + refundReference: string; + amountKobo: bigint; + refundReason: string; + customerNote?: string; + }): Promise { + this.assertApiConfigured(); + return this.request( + "/api/v1/refunds/initiate-refund", + { + method: "POST", + body: { + transactionReference: input.transactionReference, + refundReference: input.refundReference, + refundAmount: this.toNairaAmount(input.amountKobo), + refundReason: input.refundReason, + ...(input.customerNote ? { customerNote: input.customerNote } : {}), + }, + }, + ); + } + + async getRefund(refundReference: string): Promise { + this.assertApiConfigured(); + return this.request( + `/api/v1/refunds/${encodeURIComponent(refundReference)}`, + { method: "GET" }, + ); + } + + async listDisbursementBanks(): Promise { + this.assertApiConfigured(); + return this.request("/api/v1/banks", { method: "GET" }); + } + + async validateDisbursementAccount(input: { + accountNumber: string; + bankCode: string; + }): Promise { + this.assertApiConfigured(); + const query = new URLSearchParams({ + accountNumber: input.accountNumber, + bankCode: input.bankCode, + }); + return this.request( + `/api/v2/disbursements/account/validate?${query.toString()}`, + { method: "GET" }, + ); + } + + async initiateDisbursement(input: { + amountKobo: bigint; + reference: string; + narration: string; + destinationBankCode: string; + destinationAccountNumber: string; + destinationAccountName: string; + sourceAccountNumber: string; + }): Promise { + this.assertApiConfigured(); + return this.request( + "/api/v2/disbursements/single", + { + method: "POST", + body: { + amount: this.toNairaAmount(input.amountKobo), + reference: input.reference, + narration: input.narration, + destinationBankCode: input.destinationBankCode, + destinationAccountNumber: input.destinationAccountNumber, + destinationAccountName: input.destinationAccountName, + sourceAccountNumber: input.sourceAccountNumber, + currency: "NGN", + async: true, + }, + }, + ); + } + + async getDisbursement( + reference: string, + ): Promise { + this.assertApiConfigured(); + const query = new URLSearchParams({ reference }); + return this.request( + `/api/v2/disbursements/single/summary?${query.toString()}`, + { method: "GET" }, + ); + } + + verifyWebhookSignature(rawBody: unknown, signature: unknown): boolean { + if ( + !(Buffer.isBuffer(rawBody) || typeof rawBody === "string") || + typeof signature !== "string" || + signature.length === 0 || + !this.secretKey + ) { + return false; + } + + const digest = createHmac("sha512", this.secretKey) + .update(rawBody) + .digest("hex"); + const expected = Buffer.from(digest, "utf8"); + const received = Buffer.from(signature, "utf8"); + return ( + expected.length === received.length && timingSafeEqual(expected, received) + ); + } + + private async request( + path: string, + input: { method: "GET" | "POST"; body?: Record }, + ): Promise { + const token = await this.getAccessToken(); + let response: Response; + try { + response = await fetch(`${this.baseUrl}${path}`, { + method: input.method, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + ...(input.body ? { body: JSON.stringify(input.body) } : {}), + signal: AbortSignal.timeout(MonnifyClient.REQUEST_TIMEOUT_MS), + }); + } catch (error) { + this.logger.warn(`Monnify request failed: ${this.message(error)}`); + throw new ServiceUnavailableException("Payment provider is unavailable"); + } + + const payload = (await response + .json() + .catch(() => null)) as MonnifyEnvelope | null; + if (!response.ok || !payload?.requestSuccessful) { + this.logger.warn( + `Monnify request rejected: HTTP ${response.status} ${payload?.responseCode ?? "unknown"}`, + ); + throw new BadGatewayException("Payment provider rejected the request"); + } + return payload.responseBody; + } + + private async getAccessToken(): Promise { + if (this.accessToken && Date.now() < this.accessTokenExpiresAt) { + return this.accessToken; + } + this.assertApiConfigured(); + const credentials = Buffer.from( + `${this.apiKey}:${this.secretKey}`, + ).toString("base64"); + let response: Response; + try { + response = await fetch(`${this.baseUrl}/api/v1/auth/login`, { + method: "POST", + headers: { Authorization: `Basic ${credentials}` }, + signal: AbortSignal.timeout(MonnifyClient.REQUEST_TIMEOUT_MS), + }); + } catch (error) { + this.logger.warn(`Monnify authentication failed: ${this.message(error)}`); + throw new ServiceUnavailableException("Payment provider is unavailable"); + } + const payload = (await response + .json() + .catch(() => null)) as MonnifyEnvelope | null; + if ( + !response.ok || + !payload?.requestSuccessful || + !payload.responseBody?.accessToken + ) { + throw new BadGatewayException("Payment provider authentication failed"); + } + this.accessToken = payload.responseBody.accessToken; + const expiresInSeconds = Number(payload.responseBody.expiresIn ?? 2700); + this.accessTokenExpiresAt = + Date.now() + Math.max(expiresInSeconds - 60, 60) * 1000; + return this.accessToken; + } + + private assertApiConfigured(): void { + if (!this.baseUrl || !this.apiKey || !this.secretKey) { + throw new ServiceUnavailableException( + "Monnify provider is not configured", + ); + } + } + + private assertPaymentConfigured(): void { + this.assertApiConfigured(); + if (!this.contractCode) { + throw new ServiceUnavailableException( + "Monnify payment provider is not configured", + ); + } + } + + private toNairaAmount(amountKobo: bigint): number { + if (amountKobo < 0n || amountKobo > MonnifyClient.MAX_SAFE_PROVIDER_KOBO) { + throw new BadGatewayException( + "Payment amount is outside the supported provider range", + ); + } + // Monnify's hosted-checkout contract accepts an NGN JSON number. Money + // remains BigInt kobo everywhere in Twizrr; this is the constrained, + // exact two-decimal serialization boundary for the provider request. + return Number(amountKobo) / 100; + } + + private message(error: unknown): string { + return error instanceof Error ? error.message : "unknown error"; + } +} diff --git a/apps/backend/src/integrations/monnify/monnify.module.ts b/apps/backend/src/integrations/monnify/monnify.module.ts new file mode 100644 index 00000000..3aa903b6 --- /dev/null +++ b/apps/backend/src/integrations/monnify/monnify.module.ts @@ -0,0 +1,24 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; + +import { MonnifyClient } from "./monnify.client"; +import { MonnifyPaymentProvider } from "./monnify-payment.provider"; +import { MonnifyRefundProvider } from "./monnify-refund.provider"; +import { MonnifyPayoutProvider } from "./monnify-payout.provider"; + +@Module({ + imports: [ConfigModule], + providers: [ + MonnifyClient, + MonnifyPaymentProvider, + MonnifyRefundProvider, + MonnifyPayoutProvider, + ], + exports: [ + MonnifyClient, + MonnifyPaymentProvider, + MonnifyRefundProvider, + MonnifyPayoutProvider, + ], +}) +export class MonnifyModule {} diff --git a/apps/backend/src/integrations/monnify/monnify.types.ts b/apps/backend/src/integrations/monnify/monnify.types.ts new file mode 100644 index 00000000..fbc84d64 --- /dev/null +++ b/apps/backend/src/integrations/monnify/monnify.types.ts @@ -0,0 +1,55 @@ +export interface MonnifyEnvelope { + requestSuccessful: boolean; + responseMessage?: string; + responseCode?: string; + responseBody: T; +} + +export interface MonnifyAuthResponse { + accessToken: string; + expiresIn?: number; +} + +export interface MonnifyInitializeTransactionResponse { + transactionReference: string; + paymentReference: string; + checkoutUrl: string; +} + +export interface MonnifyTransactionResponse { + transactionReference?: string; + paymentReference: string; + amountPaid: number | string; + totalPayable?: number | string; + paymentStatus: string; + currencyCode?: string; + currency?: string; + paidOn?: string | null; + completedOn?: string | null; + paymentMethod?: string | null; +} + +export interface MonnifyRefundResponse { + refundReference: string; + transactionReference?: string; + refundAmount: number | string; + refundStatus: string; + completedOn?: string | null; +} + +export interface MonnifyBank { + name: string; + code: string; +} + +export interface MonnifyAccountValidationResponse { + accountNumber: string; + accountName: string; + bankCode?: string; +} + +export interface MonnifyDisbursementResponse { + reference: string; + transactionReference?: string | null; + status: string; +} diff --git a/apps/backend/src/integrations/nomba/nomba-amount.util.spec.ts b/apps/backend/src/integrations/nomba/nomba-amount.util.spec.ts new file mode 100644 index 00000000..104649a3 --- /dev/null +++ b/apps/backend/src/integrations/nomba/nomba-amount.util.spec.ts @@ -0,0 +1,47 @@ +import { koboToNombaAmount, nombaAmountToKobo } from "./nomba-amount.util"; +import { NombaError } from "./nomba.errors"; + +describe("koboToNombaAmount", () => { + it.each([ + [0n, "0.00"], + [1n, "0.01"], + [100n, "1.00"], + [450000n, "4500.00"], + [1200000n, "12000.00"], + ])("formats %s kobo as %s naira", (kobo, expected) => { + expect(koboToNombaAmount(kobo)).toBe(expected); + }); + + it("formats sub-naira amounts with a leading zero naira", () => { + expect(koboToNombaAmount(5n)).toBe("0.05"); + expect(koboToNombaAmount(99n)).toBe("0.99"); + }); + + it("rejects negative kobo amounts", () => { + expect(() => koboToNombaAmount(-1n)).toThrow(NombaError); + }); +}); + +describe("nombaAmountToKobo", () => { + it.each([ + ["0.00", 0n], + ["0.01", 1n], + ["1.00", 100n], + ["4500.00", 450000n], + ["12000.00", 1200000n], + ["4500", 450000n], + ["4500.5", 450050n], + ])("parses %s naira as %s kobo", (amount, expected) => { + expect(nombaAmountToKobo(amount)).toBe(expected); + }); + + it("round-trips with koboToNombaAmount", () => { + for (const kobo of [0n, 1n, 100n, 450000n, 1200000n]) { + expect(nombaAmountToKobo(koboToNombaAmount(kobo))).toBe(kobo); + } + }); + + it("rejects non-numeric amounts", () => { + expect(() => nombaAmountToKobo("not-a-number")).toThrow(NombaError); + }); +}); diff --git a/apps/backend/src/integrations/nomba/nomba-amount.util.ts b/apps/backend/src/integrations/nomba/nomba-amount.util.ts new file mode 100644 index 00000000..f2de2fc9 --- /dev/null +++ b/apps/backend/src/integrations/nomba/nomba-amount.util.ts @@ -0,0 +1,72 @@ +import { NombaError } from "./nomba.errors"; + +/** + * Money is stored internally as BigInt kobo. Nomba checkout and tokenized-card + * APIs expect a Naira string with exactly two decimals. All conversion here is + * BigInt-safe — never Float, Decimal, or Number — to avoid rounding errors. + * + * MVP assumption: non-negative kobo only. Refunds are out of scope. + */ + +/** + * Formats a non-negative BigInt kobo amount as a Naira string with two + * decimals. + * + * Examples: + * 0n -> "0.00" + * 1n -> "0.01" + * 100n -> "1.00" + * 450000n -> "4500.00" + * 1200000n -> "12000.00" + */ +export function koboToNombaAmount(kobo: bigint): string { + if (kobo < 0n) { + throw new NombaError( + "PROVIDER_INVALID_REQUEST", + "Negative kobo amounts are not supported for Nomba billing", + ); + } + + const naira = kobo / 100n; + const remainder = kobo % 100n; + return `${naira.toString()}.${remainder.toString().padStart(2, "0")}`; +} + +/** + * Parses a Nomba Naira amount (string or number) back into BigInt kobo without + * using floating point. Used for reconciliation and webhook normalization where + * Nomba reports the amount as a Naira value. + */ +export function nombaAmountToKobo(amount: string | number): bigint { + const raw = typeof amount === "number" ? amount.toString() : amount.trim(); + + if (raw.length === 0) { + throw new NombaError( + "PROVIDER_INVALID_RESPONSE", + "Nomba amount is empty and cannot be converted to kobo", + ); + } + + if (raw.includes("e") || raw.includes("E")) { + throw new NombaError( + "PROVIDER_INVALID_RESPONSE", + "Nomba amount is in exponent form and cannot be safely converted", + ); + } + + const negative = raw.startsWith("-"); + const unsigned = negative ? raw.slice(1) : raw; + + if (!/^\d+(\.\d+)?$/.test(unsigned)) { + throw new NombaError( + "PROVIDER_INVALID_RESPONSE", + "Nomba amount is not a valid numeric value", + ); + } + + const [nairaPart, fractionPart = ""] = unsigned.split("."); + const kobFraction = `${fractionPart}00`.slice(0, 2); + const kobo = BigInt(nairaPart) * 100n + BigInt(kobFraction); + + return negative ? -kobo : kobo; +} diff --git a/apps/backend/src/integrations/nomba/nomba-auth.service.spec.ts b/apps/backend/src/integrations/nomba/nomba-auth.service.spec.ts new file mode 100644 index 00000000..8c82c2b0 --- /dev/null +++ b/apps/backend/src/integrations/nomba/nomba-auth.service.spec.ts @@ -0,0 +1,108 @@ +import { Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import { NombaAuthService } from "./nomba-auth.service"; + +describe("NombaAuthService", () => { + const clientSecret = "twizrr-test-client-credential-value"; + const accessToken = "twizrr-test-access-credential-value"; + let fetchMock: jest.Mock; + let nowMs: number; + + function configService(): ConfigService { + const values: Record = { + "nomba.baseUrl": "https://sandbox.nomba.com/v1", + "nomba.accountId": "acct_parent", + "nomba.clientId": "client_id_value", + "nomba.clientSecret": clientSecret, + }; + return { + get: (key: string): string | undefined => values[key], + } as unknown as ConfigService; + } + + function tokenResponse(): Partial { + return { + ok: true, + status: 200, + json: async () => ({ + code: "00", + description: "success", + status: true, + data: { access_token: accessToken }, + }), + }; + } + + beforeEach(() => { + nowMs = 1_000_000; + jest.spyOn(Date, "now").mockImplementation(() => nowMs); + fetchMock = jest.fn().mockResolvedValue(tokenResponse()); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("fetches the token once and reuses it before expiry", async () => { + const service = new NombaAuthService(configService()); + + const first = await service.getAccessToken(); + const second = await service.getAccessToken(); + + expect(first).toBe(accessToken); + expect(second).toBe(accessToken); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0][0])).toContain("/auth/token/issue"); + }); + + it("reissues the token around the 55 minute refresh window", async () => { + const service = new NombaAuthService(configService()); + + await service.getAccessToken(); + nowMs += 56 * 60 * 1000; + await service.getAccessToken(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("shares a single in-flight request across concurrent callers", async () => { + const service = new NombaAuthService(configService()); + + const [a, b] = await Promise.all([ + service.getAccessToken(), + service.getAccessToken(), + ]); + + expect(a).toBe(accessToken); + expect(b).toBe(accessToken); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("sends the client secret to Nomba but never logs the secret or token", async () => { + const logSpy = jest.spyOn(Logger.prototype, "log"); + const errorSpy = jest.spyOn(Logger.prototype, "error"); + const warnSpy = jest.spyOn(Logger.prototype, "warn"); + + const service = new NombaAuthService(configService()); + await service.getAccessToken(); + + const requestBody = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ); + expect(requestBody.client_secret).toBe(clientSecret); + + const logged = [ + ...logSpy.mock.calls, + ...errorSpy.mock.calls, + ...warnSpy.mock.calls, + ] + .flat() + .map((entry) => JSON.stringify(entry)) + .join(" "); + + expect(logged).not.toContain(clientSecret); + expect(logged).not.toContain(accessToken); + }); +}); diff --git a/apps/backend/src/integrations/nomba/nomba-auth.service.ts b/apps/backend/src/integrations/nomba/nomba-auth.service.ts new file mode 100644 index 00000000..b3a12f89 --- /dev/null +++ b/apps/backend/src/integrations/nomba/nomba-auth.service.ts @@ -0,0 +1,160 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import { + NOMBA_TOKEN_ISSUE_PATH, + NOMBA_TOKEN_REFRESH_WINDOW_MS, +} from "./nomba.constants"; +import { mapNombaHttpStatusToErrorCode, NombaError } from "./nomba.errors"; +import { NombaResponseEnvelope, NombaTokenData } from "./nomba.types"; + +/** + * Server-to-server Nomba auth. Issues an access token via + * POST /auth/token/issue and caches it, reissuing around 55 minutes. + * + * Security: never logs the client secret, access token, refresh token, + * Authorization header, or the full credential payload. + */ +@Injectable() +export class NombaAuthService { + private readonly logger = new Logger(NombaAuthService.name); + + private cachedToken: string | null = null; + private tokenExpiresAtMs = 0; + private inFlight: Promise | null = null; + + constructor(private readonly configService: ConfigService) {} + + /** + * Returns a valid cached access token, issuing a new one only when the cache + * is empty or within the refresh window. Concurrent callers share a single + * in-flight issue request. + */ + async getAccessToken(): Promise { + if (this.cachedToken && Date.now() < this.tokenExpiresAtMs) { + return this.cachedToken; + } + + if (this.inFlight) { + return this.inFlight; + } + + this.inFlight = this.issueToken().finally(() => { + this.inFlight = null; + }); + + return this.inFlight; + } + + /** Clears the cached token, forcing a reissue on the next call. */ + invalidate(): void { + this.cachedToken = null; + this.tokenExpiresAtMs = 0; + } + + private async issueToken(): Promise { + const { baseUrl, accountId, clientId, clientSecret } = this.credentials(); + + let response: Response; + try { + response = await fetch(`${baseUrl}${NOMBA_TOKEN_ISSUE_PATH}`, { + method: "POST", + headers: { + accountId, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "client_credentials", + client_id: clientId, + client_secret: clientSecret, + }), + }); + } catch (error) { + this.logger.error("Nomba token issue request failed before response"); + throw new NombaError( + "PROVIDER_UNAVAILABLE", + "Nomba is temporarily unavailable while issuing an access token", + { operation: "issue token" }, + ); + } + + const envelope = await this.readEnvelope(response); + + if (!response.ok || envelope.code !== "00") { + this.logger.error( + `Nomba token issue failed status=${response.status} code=${envelope.code}`, + ); + throw new NombaError( + response.ok + ? "PROVIDER_AUTH_FAILED" + : mapNombaHttpStatusToErrorCode(response.status), + "Nomba rejected the access token request", + { + operation: "issue token", + httpStatus: response.status, + providerCode: envelope.code, + }, + ); + } + + const token = envelope.data?.access_token ?? envelope.data?.accessToken; + if (!token) { + this.logger.error("Nomba token issue returned no access token"); + throw new NombaError( + "PROVIDER_INVALID_RESPONSE", + "Nomba token response did not include an access token", + { operation: "issue token" }, + ); + } + + this.cachedToken = token; + this.tokenExpiresAtMs = Date.now() + NOMBA_TOKEN_REFRESH_WINDOW_MS; + this.logger.log("Nomba access token issued and cached"); + + return token; + } + + private async readEnvelope( + response: Response, + ): Promise> { + try { + const payload = (await response.json()) as unknown; + if (payload && typeof payload === "object" && "code" in payload) { + return payload as NombaResponseEnvelope; + } + } catch { + // fall through to invalid response + } + + throw new NombaError( + "PROVIDER_INVALID_RESPONSE", + "Nomba token response was not a valid envelope", + { operation: "issue token", httpStatus: response.status }, + ); + } + + private credentials(): { + baseUrl: string; + accountId: string; + clientId: string; + clientSecret: string; + } { + const baseUrl = + this.configService.get("nomba.baseUrl") || + "https://sandbox.nomba.com/v1"; + const accountId = this.configService.get("nomba.accountId") || ""; + const clientId = this.configService.get("nomba.clientId") || ""; + const clientSecret = + this.configService.get("nomba.clientSecret") || ""; + + if (!accountId || !clientId || !clientSecret) { + throw new NombaError( + "PROVIDER_NOT_CONFIGURED", + "Nomba credentials are not configured", + { operation: "issue token" }, + ); + } + + return { baseUrl, accountId, clientId, clientSecret }; + } +} diff --git a/apps/backend/src/integrations/nomba/nomba-subscription-billing.provider.spec.ts b/apps/backend/src/integrations/nomba/nomba-subscription-billing.provider.spec.ts new file mode 100644 index 00000000..425d5849 --- /dev/null +++ b/apps/backend/src/integrations/nomba/nomba-subscription-billing.provider.spec.ts @@ -0,0 +1,270 @@ +import { ConfigService } from "@nestjs/config"; + +import { NombaAuthService } from "./nomba-auth.service"; +import { NombaClient } from "./nomba.client"; +import { NombaSubscriptionBillingProvider } from "./nomba-subscription-billing.provider"; +import { NombaWebhookVerifier } from "./nomba-webhook-verifier"; +import { NombaStorePassOrderMetadata } from "./nomba.types"; + +interface CapturedRequest { + url: string; + init: RequestInit; +} + +describe("NombaSubscriptionBillingProvider", () => { + const parentAccountId = "acct_parent"; + const subAccountId = "subacct_child"; + const metadata: NombaStorePassOrderMetadata = { + storeId: "store_1", + planCode: "GROWTH", + invoiceId: "inv_1", + subscriptionId: "sub_1", + billingAttemptId: "att_1", + purpose: "STOREPASS_SUBSCRIPTION", + }; + + let fetchMock: jest.Mock; + const captured: Record = {}; + + function configService(): ConfigService { + const values: Record = { + "nomba.baseUrl": "https://sandbox.nomba.com/v1", + "nomba.accountId": parentAccountId, + "nomba.subAccountId": subAccountId, + "nomba.clientId": "client_id_value", + "nomba.clientSecret": "client_secret_value", + "nomba.webhookSecret": "webhook_secret_value", + }; + return { + get: (key: string): string | undefined => values[key], + } as unknown as ConfigService; + } + + function jsonResponse(body: unknown): Partial { + return { ok: true, status: 200, json: async () => body }; + } + + function createProvider(): NombaSubscriptionBillingProvider { + const config = configService(); + const auth = new NombaAuthService(config); + const client = new NombaClient(config, auth); + const verifier = new NombaWebhookVerifier(config); + return new NombaSubscriptionBillingProvider(client, verifier, config); + } + + beforeEach(() => { + for (const key of Object.keys(captured)) { + delete captured[key]; + } + + fetchMock = jest.fn(async (url: string, init: RequestInit) => { + const target = String(url); + + if (target.includes("/auth/token/issue")) { + return jsonResponse({ code: "00", data: { access_token: "tok" } }); + } + if (target.includes("/checkout/order")) { + captured.checkout = { url: target, init }; + return jsonResponse({ + code: "00", + data: { + checkoutLink: "https://checkout.nomba.com/order/xyz", + orderReference: "storepass_inv_inv_1_1", + }, + }); + } + if (target.includes("/checkout/tokenized-card-payment")) { + captured.tokenized = { url: target, init }; + return jsonResponse({ + code: "00", + data: { status: "SUCCESS", transactionId: "txn_recur_1" }, + }); + } + if (target.includes("/transactions/accounts/single")) { + captured.lookup = { url: target, init }; + if (target.includes("notfound")) { + return jsonResponse({ + code: "99", + description: "Transaction not found", + data: null, + }); + } + return jsonResponse({ + code: "00", + data: { + status: "SUCCESS", + amount: "4500.00", + onlineCheckoutOrderReference: "storepass_inv_inv_1_1", + transactionId: "txn_1", + }, + }); + } + + throw new Error(`Unexpected fetch to ${target}`); + }); + + global.fetch = fetchMock as unknown as typeof fetch; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function checkoutBody(): Record { + return JSON.parse(captured.checkout.init.body as string); + } + + function tokenizedBody(): Record { + return JSON.parse(captured.tokenized.init.body as string); + } + + describe("createCheckoutSession", () => { + it("maps a StorePass checkout order to the Nomba payload", async () => { + const provider = createProvider(); + + const result = await provider.createCheckoutSession({ + amountKobo: 450000n, + orderReference: "storepass_inv_inv_1_1", + customerEmail: "owner@example.com", + customerId: "user_1", + callbackUrl: "https://app.twizrr.com/api/payment/return", + metadata, + }); + + const body = checkoutBody(); + const headers = captured.checkout.init.headers as Record; + + expect(body.order.amount).toBe("4500.00"); + expect(headers.accountId).toBe(parentAccountId); + expect(body.order.accountId).toBe(subAccountId); + expect(body.tokenizeCard).toBe(true); + expect(body.order.orderReference).toBe("storepass_inv_inv_1_1"); + expect(body.order.orderMetaData).toMatchObject({ + storeId: "store_1", + planCode: "GROWTH", + invoiceId: "inv_1", + subscriptionId: "sub_1", + billingAttemptId: "att_1", + purpose: "STOREPASS_SUBSCRIPTION", + }); + expect(headers["X-Idempotent-key"]).toBe("storepass_inv_inv_1_1"); + expect(result.checkoutUrl).toBe("https://checkout.nomba.com/order/xyz"); + }); + }); + + describe("chargeTokenizedPayment", () => { + it("maps a recurring tokenized-card charge to the Nomba payload", async () => { + const provider = createProvider(); + + const result = await provider.chargeTokenizedPayment({ + tokenKey: "tok_card_123", + amountKobo: 450000n, + orderReference: "storepass_inv_inv_1_2", + customerEmail: "owner@example.com", + customerId: "user_1", + metadata: { ...metadata, purpose: "STOREPASS_RENEWAL" }, + idempotencyKey: "idemp_key_1", + }); + + const body = tokenizedBody(); + const headers = captured.tokenized.init.headers as Record; + + expect(captured.tokenized.url).toContain( + "/checkout/tokenized-card-payment", + ); + expect(body.tokenKey).toBe("tok_card_123"); + expect(body.order.amount).toBe("4500.00"); + expect(body.order.orderReference).toBe("storepass_inv_inv_1_2"); + expect(body.order.orderMetaData.purpose).toBe("STOREPASS_RENEWAL"); + expect(headers["X-Idempotent-key"]).toBe("idemp_key_1"); + expect(result.status).toBe("SUCCESS"); + expect(result.providerTransactionId).toBe("txn_recur_1"); + }); + + it("defaults the idempotency key to the order reference", async () => { + const provider = createProvider(); + + await provider.chargeTokenizedPayment({ + tokenKey: "tok_card_123", + amountKobo: 450000n, + orderReference: "storepass_inv_inv_1_3", + customerEmail: "owner@example.com", + customerId: "user_1", + metadata: { ...metadata, purpose: "STOREPASS_RENEWAL" }, + }); + + const headers = captured.tokenized.init.headers as Record; + expect(headers["X-Idempotent-key"]).toBe("storepass_inv_inv_1_3"); + }); + }); + + describe("chargeTokenizedCard (provider-agnostic foundation)", () => { + it("maps the normalized charge input to the Nomba tokenized payload", async () => { + const provider = createProvider(); + + const result = await provider.chargeTokenizedCard({ + storeId: "store_1", + storeOwnerUserId: "user_1", + planCode: "GROWTH", + reference: "storepass_inv_inv_1_2", + amountKobo: 450000n, + email: "owner@example.com", + tokenKey: "tok_card_123", + idempotencyKey: "idemp_key_9", + metadata: { + invoiceId: "inv_1", + subscriptionId: "sub_1", + billingAttemptId: "att_1", + purpose: "STOREPASS_RENEWAL", + }, + }); + + const body = tokenizedBody(); + const headers = captured.tokenized.init.headers as Record; + + expect(body.tokenKey).toBe("tok_card_123"); + expect(body.order.orderReference).toBe("storepass_inv_inv_1_2"); + expect(body.order.orderMetaData).toMatchObject({ + storeId: "store_1", + planCode: "GROWTH", + invoiceId: "inv_1", + subscriptionId: "sub_1", + billingAttemptId: "att_1", + purpose: "STOREPASS_RENEWAL", + }); + expect(headers["X-Idempotent-key"]).toBe("idemp_key_9"); + expect(result.status).toBe("SUCCESS"); + expect(result.reference).toBe("storepass_inv_inv_1_2"); + expect(result.rawProviderReference).toBe("txn_recur_1"); + }); + }); + + describe("lookupTransactionByOrderReference", () => { + it("normalizes a found transaction", async () => { + const provider = createProvider(); + + const result = await provider.lookupTransactionByOrderReference( + "storepass_inv_inv_1_1", + ); + + expect(captured.lookup.url).toContain( + "/transactions/accounts/single?orderReference=storepass_inv_inv_1_1", + ); + expect(result.found).toBe(true); + expect(result.status).toBe("SUCCESS"); + expect(result.amountKobo).toBe(450000n); + expect(result.orderReference).toBe("storepass_inv_inv_1_1"); + expect(result.providerTransactionId).toBe("txn_1"); + }); + + it("normalizes a not-found transaction safely", async () => { + const provider = createProvider(); + + const result = + await provider.lookupTransactionByOrderReference("notfound_ref"); + + expect(result.found).toBe(false); + expect(result.amountKobo).toBeUndefined(); + }); + }); +}); diff --git a/apps/backend/src/integrations/nomba/nomba-subscription-billing.provider.ts b/apps/backend/src/integrations/nomba/nomba-subscription-billing.provider.ts new file mode 100644 index 00000000..0b94ab48 --- /dev/null +++ b/apps/backend/src/integrations/nomba/nomba-subscription-billing.provider.ts @@ -0,0 +1,428 @@ +import { Injectable } from "@nestjs/common"; + +import { + CancelSubscriptionInput, + CancelSubscriptionResult, + ChargeTokenizedSubscriptionPaymentInput, + ChargeTokenizedSubscriptionPaymentResult, + CreateSubscriptionCustomerInput, + CreateSubscriptionCustomerResult, + InitializeSubscriptionPaymentInput, + InitializeSubscriptionPaymentResult, + ParseSubscriptionBillingWebhookEventInput, + SubscriptionBillingProvider, + SubscriptionBillingProviderEvent, + SubscriptionBillingPurpose, + VerifySubscriptionBillingWebhookSignatureInput, + VerifySubscriptionPaymentResult, +} from "../../domains/money/subscription-billing/providers/subscription-billing-provider.interface"; +import { koboToNombaAmount, nombaAmountToKobo } from "./nomba-amount.util"; +import { NombaClient } from "./nomba.client"; +import { + NOMBA_CHECKOUT_ORDER_PATH, + NOMBA_CURRENCY, + NOMBA_DEFAULT_CALLBACK_URL, + NOMBA_TOKENIZED_CARD_PAYMENT_PATH, + NOMBA_TRANSACTION_LOOKUP_PATH, +} from "./nomba.constants"; +import { ConfigService } from "@nestjs/config"; +import { parseNombaWebhookEvent } from "./nomba-webhook.parser"; +import { NombaWebhookVerifier } from "./nomba-webhook-verifier"; +import { + NombaChargeTokenizedInput, + NombaChargeTokenizedResult, + NombaCreateCheckoutInput, + NombaStorePassOrderMetadata, + NombaTransactionLookupResult, +} from "./nomba.types"; + +/** + * Nomba adapter behind the Twizrr SubscriptionBillingProvider foundation. + * + * StorePass business state (subscriptions, invoices, entitlements, activation) + * stays in the Twizrr domain. This adapter only maps normalized Twizrr input to + * Nomba payloads, normalizes Nomba responses/errors, and verifies/parses Nomba + * webhooks. It does not create any StorePass database records. + */ +@Injectable() +export class NombaSubscriptionBillingProvider implements SubscriptionBillingProvider { + readonly name = "NOMBA" as const; + + constructor( + private readonly client: NombaClient, + private readonly webhookVerifier: NombaWebhookVerifier, + private readonly configService: ConfigService, + ) {} + + // ── SubscriptionBillingProvider foundation ────────────────────────────── + + createCustomer( + input: CreateSubscriptionCustomerInput, + ): Promise { + // Nomba checkout carries the customer inline (customerEmail/customerId), so + // there is no separate customer-create call. The store owner id is the + // stable Twizrr-side customer reference. + return Promise.resolve({ + provider: this.name, + storeId: input.storeId, + rawProviderCustomerId: input.storeOwnerUserId, + }); + } + + async initializeSubscriptionPayment( + input: InitializeSubscriptionPaymentInput, + ): Promise { + const checkout = await this.createCheckoutSession({ + amountKobo: input.amountKobo, + orderReference: input.reference, + customerEmail: input.email, + customerId: input.storeOwnerUserId, + callbackUrl: input.callbackUrl, + metadata: this.buildMetadataFromInput(input), + tokenizeCard: true, + }); + + return { + provider: this.name, + reference: input.reference, + authorizationUrl: checkout.checkoutUrl, + rawProviderReference: checkout.providerOrderReference, + }; + } + + async verifySubscriptionPayment( + reference: string, + ): Promise { + const lookup = await this.lookupTransactionByOrderReference(reference); + + return { + provider: this.name, + reference, + status: this.mapLookupStatus(lookup), + amountKobo: lookup.amountKobo ?? 0n, + rawProviderReference: lookup.providerTransactionId, + }; + } + + /** + * Charges a previously tokenized card for a recurring StorePass attempt + * (renewal or retry) via the provider-agnostic foundation. Delegates to the + * Nomba tokenized-card primitive. The idempotency key defaults to the + * canonical order reference, which is unique per attempt. + */ + async chargeTokenizedCard( + input: ChargeTokenizedSubscriptionPaymentInput, + ): Promise { + const result = await this.chargeTokenizedPayment({ + tokenKey: input.tokenKey, + amountKobo: input.amountKobo, + orderReference: input.reference, + customerEmail: input.email, + customerId: input.storeOwnerUserId, + callbackUrl: input.callbackUrl, + idempotencyKey: input.idempotencyKey ?? input.reference, + metadata: this.buildChargeMetadata(input), + }); + + return { + provider: this.name, + reference: input.reference, + status: result.status, + rawProviderReference: result.providerTransactionId, + }; + } + + cancelSubscription( + input: CancelSubscriptionInput, + ): Promise { + // MVP: StorePass recurring billing uses tokenized-card charges, not Nomba + // mandates/direct debit, so cancellation is a Twizrr-side state change and + // there is no provider mandate to cancel. + return Promise.resolve({ + provider: this.name, + reference: input.reference, + cancelled: true, + }); + } + + verifyWebhookSignature( + input: VerifySubscriptionBillingWebhookSignatureInput, + ): boolean { + const signature = + typeof input.signature === "string" ? input.signature : undefined; + return this.webhookVerifier.verify({ + rawBody: input.rawBody, + headers: input.headers, + signature, + }).valid; + } + + parseWebhookEvent( + input: ParseSubscriptionBillingWebhookEventInput, + ): SubscriptionBillingProviderEvent { + return parseNombaWebhookEvent(input.rawBody); + } + + // ── Nomba-specific StorePass billing methods ──────────────────────────── + + /** + * Creates a StorePass checkout order (first subscription payment or re-card + * flow). The parent account id is sent as the `accountId` header by the + * client; the Nomba sub-account id is sent as `order.accountId`. + */ + async createCheckoutSession(input: NombaCreateCheckoutInput): Promise<{ + provider: "NOMBA"; + orderReference: string; + checkoutUrl?: string; + providerOrderReference?: string; + }> { + const body = { + order: { + amount: koboToNombaAmount(input.amountKobo), + currency: NOMBA_CURRENCY, + accountId: this.subAccountId(), + orderReference: input.orderReference, + orderMetaData: this.toOrderMetaData(input.metadata), + callbackUrl: input.callbackUrl ?? NOMBA_DEFAULT_CALLBACK_URL, + customerEmail: input.customerEmail, + customerId: input.customerId, + }, + tokenizeCard: input.tokenizeCard ?? true, + }; + + const data = await this.client.request>( + "create checkout order", + NOMBA_CHECKOUT_ORDER_PATH, + { + method: "POST", + body, + idempotencyKey: input.orderReference, + orderReference: input.orderReference, + }, + ); + + return { + provider: this.name, + orderReference: input.orderReference, + checkoutUrl: this.readString(data, [ + "checkoutLink", + "checkout_url", + "checkoutUrl", + ]), + providerOrderReference: this.readString(data, [ + "orderReference", + "onlineCheckoutOrderReference", + "id", + ]), + }; + } + + /** + * Charges a previously tokenized card for a recurring StorePass attempt via + * POST /checkout/tokenized-card-payment. This is the MVP recurring billing + * primitive — not mandates or direct debit. + */ + async chargeTokenizedPayment( + input: NombaChargeTokenizedInput, + ): Promise { + const body = { + tokenKey: input.tokenKey, + order: { + orderReference: input.orderReference, + customerId: input.customerId, + customerEmail: input.customerEmail, + amount: koboToNombaAmount(input.amountKobo), + currency: NOMBA_CURRENCY, + callbackUrl: input.callbackUrl ?? NOMBA_DEFAULT_CALLBACK_URL, + accountId: this.subAccountId(), + orderMetaData: this.toOrderMetaData(input.metadata), + }, + }; + + const data = await this.client.request>( + "charge tokenized card", + NOMBA_TOKENIZED_CARD_PAYMENT_PATH, + { + method: "POST", + body, + idempotencyKey: input.idempotencyKey ?? input.orderReference, + orderReference: input.orderReference, + }, + ); + + return { + provider: this.name, + orderReference: input.orderReference, + status: this.mapTransactionStatus( + this.readString(data, ["status", "transactionStatus"]), + ), + providerTransactionId: this.readString(data, [ + "transactionId", + "transaction_id", + "id", + ]), + }; + } + + /** + * Looks up a transaction by its canonical order reference for reconciliation. + * A "not found" result is normalized to `found: false` rather than throwing a + * raw provider payload into domain services. + */ + async lookupTransactionByOrderReference( + orderReference: string, + ): Promise { + const query = new URLSearchParams({ orderReference }); + const result = await this.client.requestTolerant>( + "lookup transaction", + `${NOMBA_TRANSACTION_LOOKUP_PATH}?${query.toString()}`, + { method: "GET", orderReference }, + ); + + const { envelope } = result; + const data = + envelope.data && typeof envelope.data === "object" + ? (envelope.data as Record) + : null; + + if (envelope.code !== "00" || !data) { + return { provider: this.name, found: false }; + } + + return { + provider: this.name, + found: true, + orderReference: + this.readString(data, [ + "onlineCheckoutOrderReference", + "orderReference", + "merchantTxRef", + ]) ?? orderReference, + status: this.readString(data, ["status", "transactionStatus"]), + amountKobo: this.readAmountKobo(data), + providerTransactionId: this.readString(data, [ + "transactionId", + "transaction_id", + "id", + ]), + }; + } + + // ── helpers ───────────────────────────────────────────────────────────── + + private toOrderMetaData( + metadata: NombaStorePassOrderMetadata, + ): Record { + return { + storeId: metadata.storeId, + planCode: metadata.planCode, + invoiceId: metadata.invoiceId, + subscriptionId: metadata.subscriptionId, + billingAttemptId: metadata.billingAttemptId, + purpose: metadata.purpose, + }; + } + + private buildMetadataFromInput( + input: InitializeSubscriptionPaymentInput, + ): NombaStorePassOrderMetadata { + const metadata = (input.metadata ?? {}) as Record; + const purpose = metadata.purpose; + + return { + storeId: input.storeId, + planCode: input.planCode, + invoiceId: this.metadataString(metadata.invoiceId), + subscriptionId: this.metadataString(metadata.subscriptionId), + billingAttemptId: this.metadataString(metadata.billingAttemptId), + purpose: this.isPurpose(purpose) ? purpose : "STOREPASS_SUBSCRIPTION", + }; + } + + private buildChargeMetadata( + input: ChargeTokenizedSubscriptionPaymentInput, + ): NombaStorePassOrderMetadata { + const metadata = (input.metadata ?? {}) as Record; + const purpose = metadata.purpose; + + return { + storeId: input.storeId, + planCode: input.planCode, + invoiceId: this.metadataString(metadata.invoiceId), + subscriptionId: this.metadataString(metadata.subscriptionId), + billingAttemptId: this.metadataString(metadata.billingAttemptId), + purpose: this.isPurpose(purpose) ? purpose : "STOREPASS_RENEWAL", + }; + } + + private isPurpose(value: unknown): value is SubscriptionBillingPurpose { + return ( + value === "STOREPASS_SUBSCRIPTION" || + value === "STOREPASS_RENEWAL" || + value === "STOREPASS_UPGRADE" || + value === "STOREPASS_RECARD" + ); + } + + private metadataString(value: unknown): string { + return typeof value === "string" ? value : ""; + } + + private subAccountId(): string { + return this.configService.get("nomba.subAccountId") || ""; + } + + private mapLookupStatus( + lookup: NombaTransactionLookupResult, + ): "SUCCESS" | "FAILED" | "PENDING" { + if (!lookup.found) { + return "PENDING"; + } + return this.mapTransactionStatus(lookup.status); + } + + private mapTransactionStatus( + status: string | undefined, + ): "SUCCESS" | "FAILED" | "PENDING" { + const normalized = (status ?? "").toUpperCase(); + if (["SUCCESS", "SUCCESSFUL", "PAID", "COMPLETED"].includes(normalized)) { + return "SUCCESS"; + } + if ( + ["FAILED", "DECLINED", "REVERSED", "CANCELLED", "CANCELED"].includes( + normalized, + ) + ) { + return "FAILED"; + } + return "PENDING"; + } + + private readAmountKobo(data: Record): bigint | undefined { + const amount = data.amount; + if (typeof amount !== "string" && typeof amount !== "number") { + return undefined; + } + try { + return nombaAmountToKobo(amount); + } catch { + return undefined; + } + } + + private readString( + data: Record, + keys: string[], + ): string | undefined { + for (const key of keys) { + const value = data[key]; + if (typeof value === "string" && value.length > 0) { + return value; + } + if (typeof value === "number") { + return String(value); + } + } + return undefined; + } +} diff --git a/apps/backend/src/integrations/nomba/nomba-webhook-verifier.spec.ts b/apps/backend/src/integrations/nomba/nomba-webhook-verifier.spec.ts new file mode 100644 index 00000000..ca2a36af --- /dev/null +++ b/apps/backend/src/integrations/nomba/nomba-webhook-verifier.spec.ts @@ -0,0 +1,124 @@ +import { + buildNombaHashingPayload, + computeNombaSignature, + extractNombaSignatureFields, + verifyNombaWebhookSignature, +} from "./nomba-webhook-verifier"; + +describe("Nomba webhook verifier", () => { + const secret = "twizrr-test-webhook-credential-value"; + const nombaTimestamp = "2026-07-05T10:00:05Z"; + + function samplePayload() { + return { + event_type: "payment_success", + requestId: "req_1", + data: { + merchant: { userId: "user_1", walletId: "wallet_1" }, + transaction: { + transactionId: "txn_1", + type: "checkout", + time: "2026-07-05T10:00:00Z", + responseCode: "00", + merchantTxRef: "storepass_inv_inv_1_1", + amount: "4500.00", + }, + }, + }; + } + + function signedHeaders(rawBody: string): Record { + const fields = extractNombaSignatureFields(JSON.parse(rawBody)); + const payload = buildNombaHashingPayload(fields, nombaTimestamp); + const signature = computeNombaSignature(secret, payload); + return { + "nomba-signature": signature, + "nomba-signature-algorithm": "HmacSHA256", + "nomba-timestamp": nombaTimestamp, + }; + } + + it("builds the documented field-based hashing payload", () => { + const fields = extractNombaSignatureFields(samplePayload()); + const payload = buildNombaHashingPayload(fields, nombaTimestamp); + + expect(payload).toBe( + [ + "payment_success", + "req_1", + "user_1", + "wallet_1", + "txn_1", + "checkout", + "2026-07-05T10:00:00Z", + "00", + nombaTimestamp, + ].join(":"), + ); + }); + + it("validates a correct signature", () => { + const rawBody = JSON.stringify(samplePayload()); + + const result = verifyNombaWebhookSignature({ + secret, + rawBody, + headers: signedHeaders(rawBody), + }); + + expect(result.valid).toBe(true); + }); + + it("rejects an invalid signature", () => { + const rawBody = JSON.stringify(samplePayload()); + const headers = signedHeaders(rawBody); + headers["nomba-signature"] = computeNombaSignature( + secret, + "tampered-payload", + ); + + const result = verifyNombaWebhookSignature({ secret, rawBody, headers }); + + expect(result.valid).toBe(false); + expect(result.reason).toBe("signature mismatch"); + }); + + it("rejects a signature computed with the wrong secret", () => { + const rawBody = JSON.stringify(samplePayload()); + const fields = extractNombaSignatureFields(JSON.parse(rawBody)); + const payload = buildNombaHashingPayload(fields, nombaTimestamp); + + const result = verifyNombaWebhookSignature({ + secret, + rawBody, + headers: { + "nomba-signature": computeNombaSignature("wrong_secret", payload), + "nomba-signature-algorithm": "HmacSHA256", + "nomba-timestamp": nombaTimestamp, + }, + }); + + expect(result.valid).toBe(false); + }); + + it("handles missing signature headers safely", () => { + const rawBody = JSON.stringify(samplePayload()); + + expect(verifyNombaWebhookSignature({ secret, rawBody }).valid).toBe(false); + expect( + verifyNombaWebhookSignature({ secret, rawBody, headers: {} }).reason, + ).toBe("missing signature header"); + }); + + it("rejects verification when no secret is configured", () => { + const rawBody = JSON.stringify(samplePayload()); + + const result = verifyNombaWebhookSignature({ + secret: "", + rawBody, + headers: signedHeaders(rawBody), + }); + + expect(result.valid).toBe(false); + }); +}); diff --git a/apps/backend/src/integrations/nomba/nomba-webhook-verifier.ts b/apps/backend/src/integrations/nomba/nomba-webhook-verifier.ts new file mode 100644 index 00000000..9750553a --- /dev/null +++ b/apps/backend/src/integrations/nomba/nomba-webhook-verifier.ts @@ -0,0 +1,257 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { createHmac, timingSafeEqual } from "crypto"; + +import { + NOMBA_WEBHOOK_ALGORITHM_HEADER, + NOMBA_WEBHOOK_SIGNATURE_HEADER, + NOMBA_WEBHOOK_TIMESTAMP_HEADER, +} from "./nomba.constants"; + +/** + * Nomba webhook verification is field-based, not a simple raw-body HMAC. + * The signature is computed over selected parsed fields joined with the + * webhook timestamp, then base64-encoded HMAC-SHA256 using the webhook secret. + * + * Security: never logs the webhook secret or the full raw payload. + */ + +export interface NombaWebhookSignatureFields { + eventType: string; + requestId: string; + userId: string; + walletId: string; + transactionId: string; + type: string; + transactionTime: string; + responseCode: string; +} + +export interface NombaWebhookHeaders { + signature?: string; + algorithm?: string; + timestamp?: string; +} + +export interface NombaWebhookVerificationResult { + valid: boolean; + reason?: string; +} + +export interface VerifyNombaWebhookInput { + secret: string; + rawBody: Buffer | string; + headers?: Record; + /** Explicit signature override when not carried in headers. */ + signature?: string; +} + +/** + * Builds the documented Nomba hashing payload from the signature fields and the + * webhook timestamp header: + * `${eventType}:${requestId}:${userId}:${walletId}:${transactionId}:${type}:${transactionTime}:${responseCode}:${nombaTimestamp}` + */ +export function buildNombaHashingPayload( + fields: NombaWebhookSignatureFields, + nombaTimestamp: string, +): string { + return [ + fields.eventType, + fields.requestId, + fields.userId, + fields.walletId, + fields.transactionId, + fields.type, + fields.transactionTime, + fields.responseCode, + nombaTimestamp, + ].join(":"); +} + +/** Computes base64(HMAC-SHA256(secret, hashingPayload)). */ +export function computeNombaSignature( + secret: string, + hashingPayload: string, +): string { + return createHmac("sha256", secret).update(hashingPayload).digest("base64"); +} + +/** Reads Nomba signature headers case-insensitively. */ +export function extractNombaWebhookHeaders( + headers: Record | undefined, +): NombaWebhookHeaders { + if (!headers) { + return {}; + } + + const lookup = (name: string): string | undefined => { + const target = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === target) { + return Array.isArray(value) ? value[0] : value; + } + } + return undefined; + }; + + return { + signature: lookup(NOMBA_WEBHOOK_SIGNATURE_HEADER), + algorithm: lookup(NOMBA_WEBHOOK_ALGORITHM_HEADER), + timestamp: lookup(NOMBA_WEBHOOK_TIMESTAMP_HEADER), + }; +} + +/** Extracts the signature fields from a parsed Nomba webhook payload. */ +export function extractNombaSignatureFields( + body: unknown, +): NombaWebhookSignatureFields { + const root = asRecord(body); + const data = asRecord(root.data); + const merchant = asRecord(data.merchant); + const transaction = Object.keys(asRecord(data.transaction)).length + ? asRecord(data.transaction) + : data; + + return { + eventType: readString(root, ["event_type", "eventType"]), + requestId: readString(root, ["requestId", "request_id"]), + userId: readString(merchant, ["userId", "user_id"], data, [ + "userId", + "user_id", + ]), + walletId: readString(merchant, ["walletId", "wallet_id"], data, [ + "walletId", + "wallet_id", + ]), + transactionId: readString(transaction, [ + "transactionId", + "transaction_id", + "id", + ]), + type: readString(transaction, ["type"]), + transactionTime: readString(transaction, [ + "time", + "transactionTime", + "transaction_time", + ]), + responseCode: readString( + transaction, + ["responseCode", "response_code"], + data, + ["responseCode", "response_code"], + ), + }; +} + +/** + * Verifies a Nomba webhook signature. Both the expected and received signatures + * are trimmed with the same normalization before a timing-safe comparison. + */ +export function verifyNombaWebhookSignature( + input: VerifyNombaWebhookInput, +): NombaWebhookVerificationResult { + if (!input.secret) { + return { valid: false, reason: "webhook secret not configured" }; + } + + const headerValues = extractNombaWebhookHeaders(input.headers); + const receivedSignature = input.signature ?? headerValues.signature; + const nombaTimestamp = headerValues.timestamp; + + if (!receivedSignature) { + return { valid: false, reason: "missing signature header" }; + } + if (!nombaTimestamp) { + return { valid: false, reason: "missing timestamp header" }; + } + + let parsedBody: unknown; + try { + const bodyText = Buffer.isBuffer(input.rawBody) + ? input.rawBody.toString("utf8") + : input.rawBody; + parsedBody = JSON.parse(bodyText); + } catch { + return { valid: false, reason: "invalid webhook body" }; + } + + const fields = extractNombaSignatureFields(parsedBody); + const hashingPayload = buildNombaHashingPayload(fields, nombaTimestamp); + const expectedSignature = computeNombaSignature(input.secret, hashingPayload); + + if (!timingSafeEquals(expectedSignature, receivedSignature)) { + return { valid: false, reason: "signature mismatch" }; + } + + return { valid: true }; +} + +@Injectable() +export class NombaWebhookVerifier { + private readonly logger = new Logger(NombaWebhookVerifier.name); + + constructor(private readonly configService: ConfigService) {} + + verify(input: { + rawBody: Buffer | string; + headers?: Record; + signature?: string; + }): NombaWebhookVerificationResult { + const secret = this.configService.get("nomba.webhookSecret") || ""; + const result = verifyNombaWebhookSignature({ ...input, secret }); + + if (!result.valid) { + this.logger.warn(`Nomba webhook verification failed: ${result.reason}`); + } + + return result; + } +} + +function timingSafeEquals(expected: string, received: string): boolean { + const expectedBuffer = Buffer.from(expected.trim(), "utf8"); + const receivedBuffer = Buffer.from(received.trim(), "utf8"); + + if (expectedBuffer.length !== receivedBuffer.length) { + return false; + } + + return timingSafeEqual(expectedBuffer, receivedBuffer); +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" + ? (value as Record) + : {}; +} + +function readString( + primary: Record, + primaryKeys: string[], + fallback?: Record, + fallbackKeys?: string[], +): string { + for (const key of primaryKeys) { + const value = primary[key]; + if (typeof value === "string" && value.length > 0) { + return value; + } + if (typeof value === "number") { + return String(value); + } + } + + if (fallback && fallbackKeys) { + for (const key of fallbackKeys) { + const value = fallback[key]; + if (typeof value === "string" && value.length > 0) { + return value; + } + if (typeof value === "number") { + return String(value); + } + } + } + + return ""; +} diff --git a/apps/backend/src/integrations/nomba/nomba-webhook.parser.spec.ts b/apps/backend/src/integrations/nomba/nomba-webhook.parser.spec.ts new file mode 100644 index 00000000..40e061ff --- /dev/null +++ b/apps/backend/src/integrations/nomba/nomba-webhook.parser.spec.ts @@ -0,0 +1,105 @@ +import { NombaError } from "./nomba.errors"; +import { parseNombaWebhookEvent } from "./nomba-webhook.parser"; + +describe("parseNombaWebhookEvent", () => { + const orderMetaData = { + storeId: "store_1", + planCode: "GROWTH", + invoiceId: "inv_1", + subscriptionId: "sub_1", + billingAttemptId: "att_1", + purpose: "STOREPASS_RENEWAL", + }; + + function payload(eventType: string, overrides: Record = {}) { + return JSON.stringify({ + event_type: eventType, + requestId: "req_1", + data: { + merchant: { userId: "user_1", walletId: "wallet_1" }, + order: { + orderReference: "storepass_inv_inv_1_1", + orderMetaData, + }, + transaction: { + transactionId: "txn_1", + type: "checkout", + time: "2026-07-05T10:00:00Z", + responseCode: "00", + amount: "4500.00", + ...overrides, + }, + }, + }); + } + + it("normalizes payment_success into SUBSCRIPTION_PAYMENT_SUCCEEDED", () => { + const event = parseNombaWebhookEvent(payload("payment_success")); + + expect(event.type).toBe("SUBSCRIPTION_PAYMENT_SUCCEEDED"); + expect(event.provider).toBe("NOMBA"); + expect(event.reference).toBe("storepass_inv_inv_1_1"); + expect(event.orderReference).toBe("storepass_inv_inv_1_1"); + expect(event.requestId).toBe("req_1"); + expect(event.providerTransactionId).toBe("txn_1"); + if (event.type === "SUBSCRIPTION_PAYMENT_SUCCEEDED") { + expect(event.amountKobo).toBe(450000n); + } + expect(event.storeId).toBe("store_1"); + expect(event.planCode).toBe("GROWTH"); + expect(event.invoiceId).toBe("inv_1"); + expect(event.subscriptionId).toBe("sub_1"); + expect(event.billingAttemptId).toBe("att_1"); + expect(event.purpose).toBe("STOREPASS_RENEWAL"); + }); + + it("normalizes payment_failed into SUBSCRIPTION_PAYMENT_FAILED", () => { + const event = parseNombaWebhookEvent( + payload("payment_failed", { responseMessage: "card declined" }), + ); + + expect(event.type).toBe("SUBSCRIPTION_PAYMENT_FAILED"); + if (event.type === "SUBSCRIPTION_PAYMENT_FAILED") { + expect(event.reason).toBe("card declined"); + } + expect(event.storeId).toBe("store_1"); + }); + + it("normalizes payment_reversal into SUBSCRIPTION_PAYMENT_REVERSED", () => { + const event = parseNombaWebhookEvent(payload("payment_reversal")); + + expect(event.type).toBe("SUBSCRIPTION_PAYMENT_REVERSED"); + expect(event.reference).toBe("storepass_inv_inv_1_1"); + }); + + it("falls back to merchantTxRef as the order reference alias", () => { + const raw = JSON.stringify({ + event_type: "payment_success", + requestId: "req_2", + data: { + merchant: { userId: "user_1", walletId: "wallet_1" }, + transaction: { + transactionId: "txn_2", + type: "checkout", + time: "2026-07-05T10:00:00Z", + responseCode: "00", + amount: "12000.00", + merchantTxRef: "storepass_inv_inv_2_1", + orderMetaData, + }, + }, + }); + + const event = parseNombaWebhookEvent(raw); + + expect(event.reference).toBe("storepass_inv_inv_2_1"); + expect(event.orderReference).toBe("storepass_inv_inv_2_1"); + expect(event.planCode).toBe("GROWTH"); + }); + + it("throws a normalized error for unsupported events", () => { + expect(() => parseNombaWebhookEvent(payload("payout_success"))).toThrow( + NombaError, + ); + }); +}); diff --git a/apps/backend/src/integrations/nomba/nomba-webhook.parser.ts b/apps/backend/src/integrations/nomba/nomba-webhook.parser.ts new file mode 100644 index 00000000..4aa6bcc7 --- /dev/null +++ b/apps/backend/src/integrations/nomba/nomba-webhook.parser.ts @@ -0,0 +1,320 @@ +import { createHash } from "crypto"; + +import { + SubscriptionBillingEventStorePassFields, + SubscriptionBillingPlanCode, + SubscriptionBillingProviderEvent, + SubscriptionBillingPurpose, +} from "../../domains/money/subscription-billing/providers/subscription-billing-provider.interface"; +import { nombaAmountToKobo } from "./nomba-amount.util"; +import { NOMBA_WEBHOOK_EVENTS } from "./nomba.constants"; +import { NombaError } from "./nomba.errors"; + +const PLAN_CODES: readonly SubscriptionBillingPlanCode[] = [ + "FREE", + "GROWTH", + "PRO", +]; + +const PURPOSES: readonly SubscriptionBillingPurpose[] = [ + "STOREPASS_SUBSCRIPTION", + "STOREPASS_RENEWAL", + "STOREPASS_UPGRADE", + "STOREPASS_RECARD", +]; + +/** + * Normalizes a Nomba webhook payload into a Twizrr subscription billing event. + * + * Only StorePass-relevant events are handled: payment_success, payment_failed, + * and payment_reversal. This parser does not verify signatures, dedupe, grant + * entitlements, or write any database rows — that is PR3. + */ +export function parseNombaWebhookEvent( + rawBody: Buffer | string | unknown, +): SubscriptionBillingProviderEvent { + const root = parseBody(rawBody); + const data = asRecord(root.data); + const order = asRecord(data.order); + const transaction = Object.keys(asRecord(data.transaction)).length + ? asRecord(data.transaction) + : data; + + const eventType = readString(root, ["event_type", "eventType"]); + + // Prefer the canonical order reference from order metadata / online checkout + // fields; fall back to merchantTxRef, which is a provider alias. + const orderReference = + readString(order, ["orderReference"], data, [ + "onlineCheckoutOrderReference", + "orderReference", + ]) || readString(transaction, ["merchantTxRef"], data, ["merchantTxRef"]); + + const metadata = resolveMetadata(order, transaction, data, root); + const storePassFields = mapStorePassFields( + orderReference, + transaction, + root, + metadata, + ); + + const requestId = readString(root, ["requestId", "request_id"]); + const rawEventId = + requestId || storePassFields.providerTransactionId || fallbackEventId(root); + const occurredAt = resolveOccurredAt(transaction, root); + + switch (eventType) { + case NOMBA_WEBHOOK_EVENTS.PAYMENT_SUCCESS: + return { + type: "SUBSCRIPTION_PAYMENT_SUCCEEDED", + provider: "NOMBA", + reference: orderReference, + amountKobo: resolveAmountKobo(transaction, order, data) ?? 0n, + occurredAt, + rawEventId, + ...storePassFields, + tokenizedCard: extractNombaTokenizedCardData(root) ?? undefined, + }; + case NOMBA_WEBHOOK_EVENTS.PAYMENT_FAILED: + return { + type: "SUBSCRIPTION_PAYMENT_FAILED", + provider: "NOMBA", + reference: orderReference, + reason: + readString(transaction, ["responseMessage", "response_message"]) || + readString(root, ["description"]) || + undefined, + occurredAt, + rawEventId, + ...storePassFields, + }; + case NOMBA_WEBHOOK_EVENTS.PAYMENT_REVERSAL: + return { + type: "SUBSCRIPTION_PAYMENT_REVERSED", + provider: "NOMBA", + reference: orderReference, + amountKobo: resolveAmountKobo(transaction, order, data), + occurredAt, + rawEventId, + ...storePassFields, + }; + default: + throw new NombaError( + "PROVIDER_INVALID_REQUEST", + `Unsupported Nomba webhook event: ${eventType || "unknown"}`, + ); + } +} + +/** + * Safe tokenized-card fields lifted from a Nomba webhook payload. Only + * non-sensitive token metadata is surfaced: the reusable token key, the card + * brand, the already-masked PAN, and the token expiry. Full PAN/CVV/OTP/PIN are + * never present in this payload shape and are never read here. + */ +export interface NombaTokenizedCardData { + tokenKey: string; + cardType?: string; + maskedCardPan?: string; + tokenExpiryMonth?: string; + tokenExpiryYear?: string; +} + +/** + * Extracts safe tokenized-card data from a Nomba webhook payload when the first + * successful checkout tokenized the card. Returns null when no reusable token is + * present. Never returns full card numbers — `cardPan` from Nomba is already + * masked (for example `4***45**** ****111*`). + */ +export function extractNombaTokenizedCardData( + rawBody: Buffer | string | unknown, +): NombaTokenizedCardData | null { + const root = parseBody(rawBody); + const data = asRecord(root.data); + const transaction = asRecord(data.transaction); + const order = asRecord(data.order); + + for (const source of [data, transaction, order]) { + const card = asRecord(source.tokenizedCardData); + const tokenKey = readString(card, ["tokenKey", "token_key"]); + if (tokenKey) { + return { + tokenKey, + cardType: readString(card, ["cardType", "card_type"]) || undefined, + maskedCardPan: + readString(card, ["cardPan", "maskedCardPan", "masked_card_pan"]) || + undefined, + tokenExpiryMonth: + readString(card, ["tokenExpiryMonth", "token_expiry_month"]) || + undefined, + tokenExpiryYear: + readString(card, ["tokenExpiryYear", "token_expiry_year"]) || + undefined, + }; + } + } + + return null; +} + +function mapStorePassFields( + orderReference: string, + transaction: Record, + root: Record, + metadata: Record, +): SubscriptionBillingEventStorePassFields { + const providerTransactionId = + readString(transaction, ["transactionId", "transaction_id", "id"]) || + undefined; + const requestId = readString(root, ["requestId", "request_id"]) || undefined; + + return { + orderReference: orderReference || undefined, + providerTransactionId, + requestId, + storeId: readString(metadata, ["storeId"]) || undefined, + planCode: readPlanCode(metadata), + invoiceId: readString(metadata, ["invoiceId"]) || undefined, + subscriptionId: readString(metadata, ["subscriptionId"]) || undefined, + billingAttemptId: readString(metadata, ["billingAttemptId"]) || undefined, + purpose: readPurpose(metadata), + }; +} + +function resolveMetadata( + order: Record, + transaction: Record, + data: Record, + root: Record, +): Record { + for (const source of [order, transaction, data, root]) { + const metadata = asRecord(source.orderMetaData); + if (Object.keys(metadata).length > 0) { + return metadata; + } + } + return {}; +} + +function resolveAmountKobo( + transaction: Record, + order: Record, + data: Record, +): bigint | undefined { + const amount = + firstDefined(transaction.amount, order.amount, data.amount) ?? undefined; + if (amount === undefined) { + return undefined; + } + if (typeof amount !== "string" && typeof amount !== "number") { + return undefined; + } + try { + return nombaAmountToKobo(amount); + } catch { + return undefined; + } +} + +function resolveOccurredAt( + transaction: Record, + root: Record, +): Date { + const raw = + readString(transaction, ["time", "transactionTime", "transaction_time"]) || + readString(root, ["timestamp"]); + if (raw) { + const parsed = new Date(raw); + if (!Number.isNaN(parsed.getTime())) { + return parsed; + } + } + return new Date(); +} + +function readPlanCode( + metadata: Record, +): SubscriptionBillingPlanCode | undefined { + const value = readString(metadata, ["planCode"]); + return PLAN_CODES.includes(value as SubscriptionBillingPlanCode) + ? (value as SubscriptionBillingPlanCode) + : undefined; +} + +function readPurpose( + metadata: Record, +): SubscriptionBillingPurpose | undefined { + const value = readString(metadata, ["purpose"]); + return PURPOSES.includes(value as SubscriptionBillingPurpose) + ? (value as SubscriptionBillingPurpose) + : undefined; +} + +function fallbackEventId(root: Record): string { + return createHash("sha256").update(JSON.stringify(root)).digest("hex"); +} + +function parseBody( + rawBody: Buffer | string | unknown, +): Record { + if (Buffer.isBuffer(rawBody) || typeof rawBody === "string") { + try { + const text = Buffer.isBuffer(rawBody) + ? rawBody.toString("utf8") + : rawBody; + return asRecord(JSON.parse(text)); + } catch { + throw new NombaError( + "PROVIDER_INVALID_REQUEST", + "Nomba webhook body is not valid JSON", + ); + } + } + return asRecord(rawBody); +} + +function firstDefined(...values: unknown[]): unknown { + for (const value of values) { + if (value !== undefined && value !== null) { + return value; + } + } + return undefined; +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" + ? (value as Record) + : {}; +} + +function readString( + primary: Record, + primaryKeys: string[], + fallback?: Record, + fallbackKeys?: string[], +): string { + for (const key of primaryKeys) { + const value = primary[key]; + if (typeof value === "string" && value.length > 0) { + return value; + } + if (typeof value === "number") { + return String(value); + } + } + + if (fallback && fallbackKeys) { + for (const key of fallbackKeys) { + const value = fallback[key]; + if (typeof value === "string" && value.length > 0) { + return value; + } + if (typeof value === "number") { + return String(value); + } + } + } + + return ""; +} diff --git a/apps/backend/src/integrations/nomba/nomba.client.ts b/apps/backend/src/integrations/nomba/nomba.client.ts new file mode 100644 index 00000000..4f02ca45 --- /dev/null +++ b/apps/backend/src/integrations/nomba/nomba.client.ts @@ -0,0 +1,180 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import { NombaAuthService } from "./nomba-auth.service"; +import { mapNombaHttpStatusToErrorCode, NombaError } from "./nomba.errors"; +import { NombaRawResult, NombaResponseEnvelope } from "./nomba.types"; + +type HttpMethod = "GET" | "POST"; + +interface NombaRequestOptions { + method?: HttpMethod; + body?: Record; + /** Sent as the X-Idempotent-key header on outbound POST calls. */ + idempotencyKey?: string; + orderReference?: string; +} + +/** + * Authenticated Nomba HTTP client. Adds the bearer access token, the parent + * `accountId` header, and JSON content type to every call, and supports + * `X-Idempotent-key` on outbound POST calls. + * + * Nomba success is `code === "00"`, not `status` alone. `request` throws a + * normalized {@link NombaError} on failure; `requestTolerant` returns the raw + * envelope so callers (for example transaction lookup) can inspect a not-found + * result without throwing. + * + * Security: never logs the access token, Authorization header, or credentials. + */ +@Injectable() +export class NombaClient { + private readonly logger = new Logger(NombaClient.name); + + constructor( + private readonly configService: ConfigService, + private readonly auth: NombaAuthService, + ) {} + + async request( + operation: string, + path: string, + options: NombaRequestOptions = {}, + ): Promise { + const result = await this.send(operation, path, options); + + if (!result.httpOk || result.envelope.code !== "00") { + this.logger.error( + `Nomba ${operation} failed status=${result.httpStatus} code=${result.envelope.code}${this.safeReference(options.orderReference)}`, + ); + throw new NombaError( + result.httpOk + ? "PROVIDER_REJECTED" + : mapNombaHttpStatusToErrorCode(result.httpStatus), + `Nomba ${operation} was rejected`, + { + operation, + httpStatus: result.httpStatus, + providerCode: result.envelope.code, + orderReference: options.orderReference, + }, + ); + } + + if (result.envelope.data === null) { + throw new NombaError( + "PROVIDER_INVALID_RESPONSE", + `Nomba ${operation} returned no data`, + { operation, orderReference: options.orderReference }, + ); + } + + return result.envelope.data; + } + + /** + * Performs a Nomba call without throwing on a non-success provider code. Used + * for reconciliation lookups where "not found" is an expected outcome. + * Still throws on transport errors, auth failures, and invalid responses. + */ + async requestTolerant( + operation: string, + path: string, + options: NombaRequestOptions = {}, + ): Promise> { + return this.send(operation, path, options); + } + + private async send( + operation: string, + path: string, + options: NombaRequestOptions, + isRetry = false, + ): Promise> { + const baseUrl = + this.configService.get("nomba.baseUrl") || + "https://sandbox.nomba.com/v1"; + const accountId = this.configService.get("nomba.accountId") || ""; + + if (!accountId) { + throw new NombaError( + "PROVIDER_NOT_CONFIGURED", + "Nomba account id is not configured", + { operation }, + ); + } + + const method = options.method ?? "GET"; + const accessToken = await this.auth.getAccessToken(); + + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + accountId, + "Content-Type": "application/json", + }; + if (method === "POST" && options.idempotencyKey) { + headers["X-Idempotent-key"] = options.idempotencyKey; + } + + let response: Response; + try { + response = await fetch(`${baseUrl}${path}`, { + method, + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + } catch { + this.logger.error( + `Nomba ${operation} request failed before response${this.safeReference(options.orderReference)}`, + ); + throw new NombaError( + "PROVIDER_UNAVAILABLE", + `Nomba ${operation} is temporarily unavailable`, + { operation, orderReference: options.orderReference }, + ); + } + + // A single retry after refreshing the token on an auth failure. + if (response.status === 401 && !isRetry) { + this.auth.invalidate(); + return this.send(operation, path, options, true); + } + + const envelope = await this.readEnvelope(response, operation); + + return { + httpOk: response.ok, + httpStatus: response.status, + envelope, + }; + } + + private async readEnvelope( + response: Response, + operation: string, + ): Promise> { + let payload: unknown = null; + try { + payload = (await response.json()) as unknown; + } catch { + payload = null; + } + + if (payload && typeof payload === "object" && "code" in payload) { + return payload as NombaResponseEnvelope; + } + + this.logger.error( + `Nomba ${operation} returned an invalid response shape status=${response.status}`, + ); + throw new NombaError( + "PROVIDER_INVALID_RESPONSE", + `Nomba ${operation} returned an invalid response`, + { operation, httpStatus: response.status }, + ); + } + + private safeReference(orderReference: string | undefined): string { + return orderReference ? ` orderReference=${orderReference}` : ""; + } +} diff --git a/apps/backend/src/integrations/nomba/nomba.constants.ts b/apps/backend/src/integrations/nomba/nomba.constants.ts new file mode 100644 index 00000000..eb564a6b --- /dev/null +++ b/apps/backend/src/integrations/nomba/nomba.constants.ts @@ -0,0 +1,59 @@ +import { SubscriptionBillingPurpose } from "../../domains/money/subscription-billing/providers/subscription-billing-provider.interface"; + +/** + * Shared Nomba integration constants for the StorePass subscription billing + * adapter. StorePass business state (subscriptions, invoices, entitlements) is + * owned by the Twizrr domain, not this integration. + */ + +/** StorePass billing is always in Naira. */ +export const NOMBA_CURRENCY = "NGN" as const; + +/** + * Default checkout return URL. StorePass checkout and re-card flows point the + * store owner back to the Twizrr payment return route after Nomba checkout. + */ +export const NOMBA_DEFAULT_CALLBACK_URL = + "https://app.twizrr.com/api/payment/return"; + +/** Nomba token issue endpoint (base URL already includes `/v1`). */ +export const NOMBA_TOKEN_ISSUE_PATH = "/auth/token/issue"; + +/** Nomba checkout order creation endpoint. */ +export const NOMBA_CHECKOUT_ORDER_PATH = "/checkout/order"; + +/** Nomba tokenized-card (recurring) charge endpoint. */ +export const NOMBA_TOKENIZED_CARD_PAYMENT_PATH = + "/checkout/tokenized-card-payment"; + +/** Nomba single-transaction lookup endpoint. */ +export const NOMBA_TRANSACTION_LOOKUP_PATH = "/transactions/accounts/single"; + +/** + * Reissue the cached Nomba access token around 55 minutes even though the token + * is valid for about 60 minutes, leaving headroom before expiry. + */ +export const NOMBA_TOKEN_REFRESH_WINDOW_MS = 55 * 60 * 1000; + +/** Supported StorePass order purposes carried in Nomba order metadata. */ +export const NOMBA_STOREPASS_PURPOSES: readonly SubscriptionBillingPurpose[] = [ + "STOREPASS_SUBSCRIPTION", + "STOREPASS_RENEWAL", + "STOREPASS_UPGRADE", + "STOREPASS_RECARD", +]; + +/** Nomba webhook event names relevant to StorePass subscription billing. */ +export const NOMBA_WEBHOOK_EVENTS = { + PAYMENT_SUCCESS: "payment_success", + PAYMENT_FAILED: "payment_failed", + PAYMENT_REVERSAL: "payment_reversal", +} as const; + +/** Nomba webhook signature headers. */ +export const NOMBA_WEBHOOK_SIGNATURE_HEADER = "nomba-signature"; +export const NOMBA_WEBHOOK_ALGORITHM_HEADER = "nomba-signature-algorithm"; +export const NOMBA_WEBHOOK_TIMESTAMP_HEADER = "nomba-timestamp"; + +/** Documented Nomba webhook signature algorithm. */ +export const NOMBA_WEBHOOK_ALGORITHM = "HmacSHA256"; diff --git a/apps/backend/src/integrations/nomba/nomba.errors.ts b/apps/backend/src/integrations/nomba/nomba.errors.ts new file mode 100644 index 00000000..cf0799e8 --- /dev/null +++ b/apps/backend/src/integrations/nomba/nomba.errors.ts @@ -0,0 +1,67 @@ +/** + * Normalized Nomba provider errors. + * + * Nomba-specific status codes, HTTP failures, and response shapes are mapped + * into these Twizrr-safe provider error codes so that domain services never + * branch on raw Nomba error payloads. This mirrors the provider error model in + * TWIZRR_PROVIDER_ARCHITECTURE.md. + */ + +export type NombaErrorCode = + | "PROVIDER_TIMEOUT" + | "PROVIDER_UNAVAILABLE" + | "PROVIDER_AUTH_FAILED" + | "PROVIDER_RATE_LIMITED" + | "PROVIDER_INVALID_REQUEST" + | "PROVIDER_REJECTED" + | "PROVIDER_NOT_CONFIGURED" + | "PROVIDER_INVALID_RESPONSE" + | "PROVIDER_UNKNOWN"; + +export interface NombaErrorContext { + operation?: string; + orderReference?: string; + httpStatus?: number; + /** Nomba envelope `code` (never a secret). Safe to log for debugging. */ + providerCode?: string; +} + +export class NombaError extends Error { + readonly code: NombaErrorCode; + readonly context?: NombaErrorContext; + + constructor( + code: NombaErrorCode, + message: string, + context?: NombaErrorContext, + ) { + super(message); + this.name = "NombaError"; + this.code = code; + this.context = context; + } +} + +/** + * Maps an HTTP status returned by Nomba into a normalized provider error code. + * The response body is never used here to keep provider-specific shapes out of + * this mapping. + */ +export function mapNombaHttpStatusToErrorCode(status: number): NombaErrorCode { + if (status === 401 || status === 403) { + return "PROVIDER_AUTH_FAILED"; + } + if (status === 408 || status === 504) { + return "PROVIDER_TIMEOUT"; + } + if (status === 429) { + return "PROVIDER_RATE_LIMITED"; + } + if (status >= 400 && status < 500) { + return "PROVIDER_INVALID_REQUEST"; + } + if (status >= 500) { + return "PROVIDER_UNAVAILABLE"; + } + return "PROVIDER_UNKNOWN"; +} diff --git a/apps/backend/src/integrations/nomba/nomba.module.ts b/apps/backend/src/integrations/nomba/nomba.module.ts new file mode 100644 index 00000000..b6c31657 --- /dev/null +++ b/apps/backend/src/integrations/nomba/nomba.module.ts @@ -0,0 +1,29 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; + +import { NombaAuthService } from "./nomba-auth.service"; +import { NombaClient } from "./nomba.client"; +import { NombaSubscriptionBillingProvider } from "./nomba-subscription-billing.provider"; +import { NombaWebhookVerifier } from "./nomba-webhook-verifier"; + +/** + * Nomba integration module. Provides the authenticated client, auth/token + * caching service, webhook verifier, and the StorePass subscription billing + * adapter. Construction is side-effect free — Nomba credentials are only + * required when an adapter method is actually invoked. + */ +@Module({ + imports: [ConfigModule], + providers: [ + NombaAuthService, + NombaClient, + NombaWebhookVerifier, + NombaSubscriptionBillingProvider, + ], + exports: [ + NombaClient, + NombaWebhookVerifier, + NombaSubscriptionBillingProvider, + ], +}) +export class NombaModule {} diff --git a/apps/backend/src/integrations/nomba/nomba.types.ts b/apps/backend/src/integrations/nomba/nomba.types.ts new file mode 100644 index 00000000..8cddea3e --- /dev/null +++ b/apps/backend/src/integrations/nomba/nomba.types.ts @@ -0,0 +1,96 @@ +import { + SubscriptionBillingPlanCode, + SubscriptionBillingPurpose, +} from "../../domains/money/subscription-billing/providers/subscription-billing-provider.interface"; + +/** + * Nomba API response envelope. Every Nomba call returns this shape. + * Success is determined by `code === "00"`, not by `status` alone. + */ +export interface NombaResponseEnvelope { + code: string; + description?: string; + status?: boolean | string; + data: TData | null; +} + +/** Result of a raw (tolerant) Nomba call, including HTTP context. */ +export interface NombaRawResult { + httpOk: boolean; + httpStatus: number; + envelope: NombaResponseEnvelope; +} + +/** Nomba access-token issue response payload. */ +export interface NombaTokenData { + access_token?: string; + accessToken?: string; + expiresAt?: string; + expires_in?: number | string; + token_type?: string; +} + +/** + * StorePass order metadata attached to every Nomba order. Future webhook + * handling (PR3) reads these fields, so they are always sent. + */ +export interface NombaStorePassOrderMetadata { + storeId: string; + planCode: SubscriptionBillingPlanCode; + invoiceId: string; + subscriptionId: string; + billingAttemptId: string; + purpose: SubscriptionBillingPurpose; +} + +/** Normalized input for creating a StorePass checkout order. */ +export interface NombaCreateCheckoutInput { + amountKobo: bigint; + /** Twizrr canonical local reference: storepass_inv__. */ + orderReference: string; + customerEmail: string; + /** Store owner user id or email. */ + customerId: string; + callbackUrl?: string; + metadata: NombaStorePassOrderMetadata; + /** Defaults to true so the first payment tokenizes the card. */ + tokenizeCard?: boolean; +} + +export interface NombaCreateCheckoutResult { + provider: "NOMBA"; + orderReference: string; + checkoutUrl?: string; + providerOrderReference?: string; +} + +/** Normalized input for a recurring tokenized-card charge. */ +export interface NombaChargeTokenizedInput { + /** Token from a previous successful tokenized checkout webhook. */ + tokenKey: string; + amountKobo: bigint; + orderReference: string; + customerEmail: string; + customerId: string; + callbackUrl?: string; + metadata: NombaStorePassOrderMetadata; + /** Idempotency key for the outbound POST; defaults to the orderReference. */ + idempotencyKey?: string; +} + +export interface NombaChargeTokenizedResult { + provider: "NOMBA"; + orderReference: string; + status: "SUCCESS" | "PENDING" | "FAILED"; + providerTransactionId?: string; +} + +/** Normalized transaction lookup result for reconciliation. */ +export interface NombaTransactionLookupResult { + provider: "NOMBA"; + found: boolean; + orderReference?: string; + status?: string; + amountKobo?: bigint; + providerTransactionId?: string; +} diff --git a/apps/backend/src/integrations/paystack/paystack-payment.provider.ts b/apps/backend/src/integrations/paystack/paystack-payment.provider.ts new file mode 100644 index 00000000..1162934a --- /dev/null +++ b/apps/backend/src/integrations/paystack/paystack-payment.provider.ts @@ -0,0 +1,139 @@ +import { Injectable } from "@nestjs/common"; +import { createHash } from "crypto"; + +import { PaystackClient } from "./paystack.client"; +import { + InitializePaymentInput, + InitializePaymentResult, + ListPaymentBankResult, + PaymentProvider, + PaymentProviderEvent, + ResolvePaymentAccountInput, + ResolvePaymentAccountResult, + VerifyPaymentResult, + VerifyWebhookSignatureInput, +} from "../../domains/money/payment/providers/payment-provider.interface"; + +@Injectable() +export class PaystackPaymentProvider implements PaymentProvider { + readonly name = "PAYSTACK" as const; + + constructor(private readonly paystack: PaystackClient) {} + + async initializePayment( + input: InitializePaymentInput, + ): Promise { + const response = await this.paystack.initializePayment( + input.email, + input.amountKobo, + input.reference, + input.callbackUrl, + input.metadata, + ); + + return { + authorizationUrl: response.authorization_url, + accessCode: response.access_code, + reference: response.reference, + }; + } + + async verifyPayment(reference: string): Promise { + const response = await this.paystack.verifyPayment(reference); + + return { + status: response.status, + amountKobo: BigInt(response.amount), + reference: response.reference, + currency: response.currency, + gatewayResponse: response.gateway_response, + paidAt: response.paid_at, + channel: response.channel, + metadata: response.metadata, + feesKobo: + response.fees === null || response.fees === undefined + ? null + : BigInt(Math.max(0, Math.round(response.fees))), + }; + } + + verifyWebhookSignature(input: VerifyWebhookSignatureInput): boolean { + return this.paystack.verifyWebhookSignature(input.rawBody, input.signature); + } + + parseWebhookEvent(payload: unknown): PaymentProviderEvent { + const eventPayload = + payload && typeof payload === "object" + ? (payload as Record) + : {}; + const eventType = + typeof eventPayload.event === "string" ? eventPayload.event : "unknown"; + const data = + eventPayload.data && typeof eventPayload.data === "object" + ? (eventPayload.data as Record) + : {}; + const customer = + data.customer && typeof data.customer === "object" + ? (data.customer as Record) + : {}; + const reference = this.firstString( + data.reference, + data.transfer_code, + customer.customer_code, + data.customer_code, + ); + const providerEventId = this.firstString( + data.id, + data.event_id, + data.reference, + data.transfer_code, + customer.customer_code, + data.customer_code, + ); + const eventId = providerEventId + ? `${eventType}:${providerEventId}` + : `${eventType}:${createHash("sha256") + .update(JSON.stringify(payload ?? {})) + .digest("hex")}`; + + return { + provider: this.name, + eventType, + eventId, + reference, + rawPayload: payload, + }; + } + + async resolveAccount( + input: ResolvePaymentAccountInput, + ): Promise { + const account = await this.paystack.resolveAccount( + input.accountNumber, + input.bankCode, + ); + + return { + accountNumber: account.account_number, + accountName: account.account_name, + bankId: account.bank_id, + }; + } + + async listBanks(): Promise { + return this.paystack.getBanks(); + } + + private firstString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.length > 0) { + return value; + } + if (typeof value === "number") { + return String(value); + } + } + + return undefined; + } +} diff --git a/apps/backend/src/integrations/paystack/paystack.client.spec.ts b/apps/backend/src/integrations/paystack/paystack.client.spec.ts new file mode 100644 index 00000000..907f1ae4 --- /dev/null +++ b/apps/backend/src/integrations/paystack/paystack.client.spec.ts @@ -0,0 +1,115 @@ +import { ConfigService } from "@nestjs/config"; +import { createHmac } from "crypto"; + +import { PaystackClient } from "./paystack.client"; + +describe("PaystackClient", () => { + const secret = "test_webhook_secret"; + + function createClient(): PaystackClient { + const configService = { + get(key: string): string { + const values: Record = { + "paystack.baseUrl": "https://api.paystack.co", + "paystack.secretKey": "sk_test_local", + "paystack.webhookSecret": secret, + }; + + return values[key] || ""; + }, + }; + + return new PaystackClient(configService as ConfigService); + } + + it("validates a correct webhook signature", () => { + const client = createClient(); + const rawBody = Buffer.from('{"event":"charge.success"}'); + const signature = createHmac("sha512", secret) + .update(rawBody) + .digest("hex"); + + expect(client.verifyWebhookSignature(rawBody, signature)).toBe(true); + }); + + it("rejects an incorrect webhook signature", () => { + const client = createClient(); + const rawBody = Buffer.from('{"event":"charge.success"}'); + const signature = createHmac("sha512", secret) + .update(Buffer.from('{"event":"transfer.success"}')) + .digest("hex"); + + expect(client.verifyWebhookSignature(rawBody, signature)).toBe(false); + }); + + it("rejects a missing webhook signature", () => { + const client = createClient(); + + expect(client.verifyWebhookSignature(Buffer.from("{}"), undefined)).toBe( + false, + ); + }); + + it("rejects a length-mismatched webhook signature", () => { + const client = createClient(); + + expect(client.verifyWebhookSignature(Buffer.from("{}"), "short")).toBe( + false, + ); + }); + + describe("initiateTransfer", () => { + const fetchMock = jest.fn(); + const originalFetch = global.fetch; + + beforeEach(() => { + fetchMock.mockReset(); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + it("surfaces Paystack's error message on a non-ok transfer response", async () => { + const client = createClient(); + // Paystack error bodies carry { status: false, message } and omit `data`. + fetchMock.mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ + status: false, + message: + "You cannot initiate third party payouts as a starter business", + }), + }); + + await expect( + client.initiateTransfer(240000n, "RCP_test", "PO-TEST01", "payout"), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + message: + "You cannot initiate third party payouts as a starter business", + code: "PAYSTACK_REQUEST_FAILED", + }), + }); + }); + + it("returns transfer data on a successful response", async () => { + const client = createClient(); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + status: true, + message: "Transfer has been queued", + data: { transfer_code: "TRF_test", status: "pending" }, + }), + }); + + await expect( + client.initiateTransfer(240000n, "RCP_test", "PO-TEST02", "payout"), + ).resolves.toMatchObject({ transfer_code: "TRF_test" }); + }); + }); +}); diff --git a/apps/backend/src/integrations/paystack/paystack.client.ts b/apps/backend/src/integrations/paystack/paystack.client.ts new file mode 100644 index 00000000..47f3ab29 --- /dev/null +++ b/apps/backend/src/integrations/paystack/paystack.client.ts @@ -0,0 +1,507 @@ +import { + BadGatewayException, + Injectable, + Logger, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { createHmac, timingSafeEqual } from "crypto"; + +import { + PaystackBank, + PaystackCreateDvaResponse, + PaystackCustomerResponse, + PaystackEnvelope, + PaystackInitializePaymentResponse, + PaystackRefundResponse, + PaystackResolveAccountResponse, + PaystackTransferRecipientResponse, + PaystackTransferResponse, + PaystackVerifyPaymentResponse, +} from "./paystack.types"; + +type HttpMethod = "GET" | "POST"; + +interface PaystackRequestOptions { + method?: HttpMethod; + body?: Record; + reference?: string; +} + +@Injectable() +export class PaystackClient { + private readonly logger = new Logger(PaystackClient.name); + private readonly baseUrl: string; + private readonly secretKey: string; + private readonly webhookSecret: string; + + constructor(private readonly configService: ConfigService) { + this.baseUrl = + this.configService.get("paystack.baseUrl") || + "https://api.paystack.co"; + this.secretKey = this.configService.get("paystack.secretKey") || ""; + this.webhookSecret = + this.configService.get("paystack.webhookSecret") || ""; + } + + async initializePayment( + email: string, + amountKobo: bigint, + reference: string, + callbackUrl?: string, + metadata?: Record, + ): Promise { + return this.request( + "initialize payment", + "/transaction/initialize", + { + method: "POST", + reference, + body: { + email, + amount: this.toPaystackAmount(amountKobo), + reference, + ...(callbackUrl ? { callback_url: callbackUrl } : {}), + ...(metadata ? { metadata } : {}), + }, + }, + ); + } + + async verifyPayment( + reference: string, + ): Promise { + return this.request( + "verify payment", + `/transaction/verify/${encodeURIComponent(reference)}`, + { reference }, + ); + } + + async resolveAccount( + bankCodeOrAccountNumber: string, + accountNumberOrBankCode: string, + ): Promise { + const { bankCode, accountNumber } = this.normalizeAccountResolveArgs( + bankCodeOrAccountNumber, + accountNumberOrBankCode, + ); + const query = new URLSearchParams({ + account_number: accountNumber, + bank_code: bankCode, + }); + + return this.request( + "resolve account", + `/bank/resolve?${query.toString()}`, + ); + } + + async createTransferRecipient( + bankCode: string, + accountNumber: string, + accountName: string, + name = accountName, + ): Promise { + return this.request( + "create transfer recipient", + "/transferrecipient", + { + method: "POST", + body: { + type: "nuban", + name, + account_number: accountNumber, + bank_code: bankCode, + currency: "NGN", + metadata: { accountName }, + }, + }, + ); + } + + async initiateTransfer( + amountKobo: bigint, + recipientCode: string, + reference: string, + reason: string, + ): Promise { + return this.request( + "initiate transfer", + "/transfer", + { + method: "POST", + reference, + body: { + source: "balance", + amount: this.toPaystackAmount(amountKobo), + recipient: recipientCode, + reference, + reason, + }, + }, + ); + } + + async createDVA( + customerId: string, + preferredBank?: string, + ): Promise { + return this.request( + "create dedicated virtual account", + "/dedicated_account", + { + method: "POST", + body: { + customer: customerId, + ...(preferredBank ? { preferred_bank: preferredBank } : {}), + }, + }, + ); + } + + async createCustomer(params: { + email: string; + firstName: string; + lastName: string; + phone: string; + }): Promise>; + async createCustomer( + email: string, + firstName: string, + lastName: string, + phone: string, + ): Promise; + async createCustomer( + emailOrParams: + | string + | { email: string; firstName: string; lastName: string; phone: string }, + firstName?: string, + lastName?: string, + phone?: string, + ): Promise< + PaystackCustomerResponse | PaystackEnvelope + > { + const params = + typeof emailOrParams === "string" + ? { email: emailOrParams, firstName, lastName, phone } + : emailOrParams; + + const customer = await this.request( + "create customer", + "/customer", + { + method: "POST", + body: { + email: params.email, + first_name: params.firstName, + last_name: params.lastName, + phone: params.phone, + }, + }, + ); + + if (typeof emailOrParams === "string") { + return customer; + } + + return { + status: true, + message: "Customer created", + data: customer, + }; + } + + async createRefund( + transactionReference: string, + amountKobo?: bigint, + ): Promise { + return this.request("create refund", "/refund", { + method: "POST", + reference: transactionReference, + body: { + transaction: transactionReference, + ...(amountKobo !== undefined + ? { amount: this.toPaystackAmount(amountKobo) } + : {}), + }, + }); + } + + // Fetch a single refund by its Paystack refund id — used by settlement + // reconciliation to confirm a submitted refund actually processed (provider + // acceptance is never treated as completion). + async getRefund(refundId: string): Promise { + return this.request( + "fetch refund", + `/refund/${encodeURIComponent(refundId)}`, + ); + } + + // Verify a transfer by its stable reference — used to confirm a payout + // transfer reached final SUCCESS/FAILED before completing the payout. + async verifyTransfer(reference: string): Promise { + return this.request( + "verify transfer", + `/transfer/verify/${encodeURIComponent(reference)}`, + { reference }, + ); + } + + verifyWebhookSignature(rawBody: unknown, signature: unknown): boolean { + if ( + !(Buffer.isBuffer(rawBody) || typeof rawBody === "string") || + typeof signature !== "string" || + signature.length === 0 + ) { + return false; + } + + const secret = this.webhookSecret || this.secretKey; + if (!secret) { + this.logger.error("Paystack webhook secret is not configured"); + return false; + } + + const digest = createHmac("sha512", secret).update(rawBody).digest("hex"); + const digestBuffer = Buffer.from(digest, "utf8"); + const signatureBuffer = Buffer.from(signature, "utf8"); + + if (digestBuffer.length !== signatureBuffer.length) { + return false; + } + + return timingSafeEqual(digestBuffer, signatureBuffer); + } + + async initializeTransaction( + email: string, + amountKobo: bigint, + reference: string, + callbackUrl?: string, + metadata?: Record, + ): Promise { + return this.initializePayment( + email, + amountKobo, + reference, + callbackUrl, + metadata, + ); + } + + async verifyTransaction( + reference: string, + ): Promise { + return this.verifyPayment(reference); + } + + async createTransfer( + recipientCode: string, + amountKobo: bigint, + reference: string, + reason: string, + ): Promise { + return this.initiateTransfer(amountKobo, recipientCode, reference, reason); + } + + async createDedicatedVirtualAccount( + customerCode: string, + ): Promise> { + const data = await this.createDVA( + customerCode, + this.secretKey.startsWith("sk_test_") ? "test-bank" : "wema-bank", + ); + + return { + status: true, + message: "Dedicated virtual account created", + data, + }; + } + + async getBanks(): Promise { + return this.request("get banks", "/bank?currency=NGN"); + } + + async findBankByName( + bankName: string, + ): Promise<{ code: string; name: string } | null> { + const banks = await this.getBanks(); + const lower = bankName.toLowerCase(); + const exactMatch = banks.find((bank) => bank.name.toLowerCase() === lower); + if (exactMatch) { + return { code: exactMatch.code, name: exactMatch.name }; + } + + const partialMatches = banks.filter((bank) => + bank.name.toLowerCase().includes(lower), + ); + + if (partialMatches.length > 1) { + this.logger.warn( + `Ambiguous Paystack bank name matched: ${partialMatches + .map((bank) => bank.name) + .join(", ")}`, + ); + return null; + } + + const match = partialMatches[0]; + return match ? { code: match.code, name: match.name } : null; + } + + private async request( + operation: string, + path: string, + options: PaystackRequestOptions = {}, + ): Promise { + if (!this.secretKey) { + throw new ServiceUnavailableException({ + message: "Paystack integration is not configured", + code: "PAYSTACK_NOT_CONFIGURED", + }); + } + + let response: Response; + try { + response = await fetch(`${this.baseUrl}${path}`, { + method: options.method || "GET", + headers: { + Authorization: `Bearer ${this.secretKey}`, + "Content-Type": "application/json", + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + } catch (error) { + this.logger.error( + `Paystack ${operation} request failed before response${this.safeReference(options.reference)}`, + ); + throw new ServiceUnavailableException({ + message: "Paystack is temporarily unavailable", + code: "PAYSTACK_UNAVAILABLE", + cause: error, + }); + } + + const payload = await this.readJson(response); + + // Paystack error responses carry { status: false, message } but omit the + // `data` field, so they fail the envelope guard below. Surface the provider + // message BEFORE the shape check so the real reason (e.g. "You cannot + // initiate third party payouts as a starter business", "Insufficient + // balance") reaches the logs and the caller's stored failureReason instead + // of a generic "invalid response". + const providerMessage = this.extractMessage(payload); + + if (!response.ok) { + this.logger.error( + `Paystack ${operation} failed status=${response.status}${this.safeReference( + options.reference, + )}: ${providerMessage ?? "no message"}`, + ); + throw new BadGatewayException({ + message: providerMessage || "Paystack request failed", + code: "PAYSTACK_REQUEST_FAILED", + }); + } + + if (!this.isPaystackEnvelope(payload)) { + this.logger.error( + `Paystack ${operation} returned an invalid response shape status=${response.status}${this.safeReference( + options.reference, + )}`, + ); + throw new BadGatewayException({ + message: "Paystack returned an invalid response", + code: "PAYSTACK_INVALID_RESPONSE", + }); + } + + // A 200 response can still report a failed operation via status: false. + if (!payload.status) { + this.logger.error( + `Paystack ${operation} failed status=${response.status}${this.safeReference( + options.reference, + )}: ${payload.message}`, + ); + throw new BadGatewayException({ + message: payload.message || "Paystack request failed", + code: "PAYSTACK_REQUEST_FAILED", + }); + } + + return payload.data; + } + + private async readJson(response: Response): Promise { + try { + return (await response.json()) as unknown; + } catch { + return null; + } + } + + // Pull the human-readable `message` off any Paystack body, including error + // responses that omit `data` and therefore do not satisfy isPaystackEnvelope. + private extractMessage(value: unknown): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + + const message = (value as Record).message; + return typeof message === "string" && message.length > 0 + ? message + : undefined; + } + + private isPaystackEnvelope( + value: unknown, + ): value is PaystackEnvelope { + if (!value || typeof value !== "object") { + return false; + } + + const envelope = value as Record; + return ( + typeof envelope.status === "boolean" && + typeof envelope.message === "string" && + "data" in envelope + ); + } + + private toPaystackAmount(amountKobo: bigint): number { + if (amountKobo < 0n || amountKobo > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new BadGatewayException({ + message: "Invalid Paystack amount", + code: "PAYSTACK_INVALID_AMOUNT", + }); + } + + return Number(amountKobo); + } + + private safeReference(reference: string | undefined): string { + return reference ? ` reference=${reference}` : ""; + } + + private normalizeAccountResolveArgs( + bankCodeOrAccountNumber: string, + accountNumberOrBankCode: string, + ): { bankCode: string; accountNumber: string } { + if ( + /^\d{10}$/.test(bankCodeOrAccountNumber) && + !/^\d{10}$/.test(accountNumberOrBankCode) + ) { + return { + bankCode: accountNumberOrBankCode, + accountNumber: bankCodeOrAccountNumber, + }; + } + + return { + bankCode: bankCodeOrAccountNumber, + accountNumber: accountNumberOrBankCode, + }; + } +} diff --git a/apps/backend/src/integrations/paystack/paystack.module.ts b/apps/backend/src/integrations/paystack/paystack.module.ts new file mode 100644 index 00000000..23a35b0d --- /dev/null +++ b/apps/backend/src/integrations/paystack/paystack.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; + +import { PaystackClient } from "./paystack.client"; +import { PaystackPaymentProvider } from "./paystack-payment.provider"; + +@Module({ + imports: [ConfigModule], + providers: [PaystackClient, PaystackPaymentProvider], + exports: [PaystackClient, PaystackPaymentProvider], +}) +export class PaystackModule {} diff --git a/apps/backend/src/integrations/paystack/paystack.types.ts b/apps/backend/src/integrations/paystack/paystack.types.ts new file mode 100644 index 00000000..fd24078f --- /dev/null +++ b/apps/backend/src/integrations/paystack/paystack.types.ts @@ -0,0 +1,87 @@ +export interface PaystackEnvelope { + status: boolean; + message: string; + data: TData; +} + +export interface PaystackInitializePaymentParams { + email: string; + amountKobo: bigint; + reference: string; + callbackUrl?: string; + metadata?: Record; +} + +export interface PaystackInitializePaymentResponse { + authorization_url: string; + access_code: string; + reference: string; +} + +export interface PaystackVerifyPaymentResponse { + id: number; + status: string; + amount: number; + reference: string; + currency: string; + gateway_response: string; + paid_at?: string | null; + channel?: string | null; + metadata?: unknown; + // Processing fee Paystack deducted for this transaction, in kobo. + fees?: number | null; +} + +export interface PaystackResolveAccountResponse { + account_number: string; + account_name: string; + bank_id: number; +} + +export interface PaystackTransferRecipientResponse { + recipient_code: string; + type: string; + name: string; + details?: unknown; +} + +export interface PaystackTransferResponse { + status: string; + transfer_code: string; + reference: string; +} + +export interface PaystackCreateDvaResponse { + account_number?: string; + account_name?: string; + bank?: { + name?: string; + slug?: string; + id?: number; + }; + assignment?: unknown; +} + +export interface PaystackCustomerResponse { + id: number; + customer_code: string; + email: string; + first_name?: string | null; + last_name?: string | null; + phone?: string | null; +} + +export interface PaystackRefundResponse { + id: number; + transaction: unknown; + amount: number; + status: string; + currency: string; +} + +export interface PaystackBank { + name: string; + code: string; + active: boolean; + type: string; +} diff --git a/apps/backend/src/integrations/prembly/README.md b/apps/backend/src/integrations/prembly/README.md new file mode 100644 index 00000000..12219155 --- /dev/null +++ b/apps/backend/src/integrations/prembly/README.md @@ -0,0 +1,7 @@ +# Prembly Integration + +Prembly adapter for Twizrr-owned identity verification checks. +To be built as part of the users/verification domain. +The verification domain depends on `IdentityVerificationProvider`; this client +contains Prembly request and response details only. Twizrr owns KYB tier rules, +attempt limits, status transitions, audit policy, and user-facing copy. diff --git a/apps/backend/src/integrations/prembly/prembly-identity-verification.provider.spec.ts b/apps/backend/src/integrations/prembly/prembly-identity-verification.provider.spec.ts new file mode 100644 index 00000000..aa87f23d --- /dev/null +++ b/apps/backend/src/integrations/prembly/prembly-identity-verification.provider.spec.ts @@ -0,0 +1,109 @@ +import { PremblyIdentityVerificationProvider } from "./prembly-identity-verification.provider"; + +describe("PremblyIdentityVerificationProvider", () => { + const client = { + verifyNIN: jest.fn(), + verifyFace: jest.fn(), + verifyCACNumber: jest.fn(), + getVerificationStatus: jest.fn(), + }; + const provider = new PremblyIdentityVerificationProvider(client as never); + + beforeEach(() => jest.clearAllMocks()); + + it("maps a Prembly NIN success to a safe normalized result", async () => { + client.verifyNIN.mockResolvedValue({ + verified: true, + rawStatus: "success", + }); + await expect( + provider.verifyNin({ + storeId: "store-1", + userId: "user-1", + nin: "12345678901", + }), + ).resolves.toEqual( + expect.objectContaining({ + provider: "prembly", + checkType: "NIN", + status: "VERIFIED", + failureReason: null, + }), + ); + }); + + it("maps failed and pending responses without exposing provider payloads", async () => { + client.verifyNIN.mockResolvedValue({ + verified: false, + rawStatus: "pending", + raw: { nin: "12345678901" }, + }); + await expect( + provider.verifyNin({ + storeId: "store-1", + userId: "user-1", + nin: "12345678901", + }), + ).resolves.toEqual( + expect.objectContaining({ + status: "PENDING", + failureCode: "VERIFICATION_FAILED", + }), + ); + }); + + it("maps face-match and CAC failures to normalized check results", async () => { + client.verifyFace.mockResolvedValue({ + verified: false, + rawStatus: "failed", + }); + client.verifyCACNumber.mockResolvedValue({ + verified: true, + rawStatus: "verified", + }); + await expect( + provider.verifyNinFaceMatch({ + storeId: "store-1", + userId: "user-1", + ninReference: "12345678901", + selfieImageBuffer: Buffer.from("image"), + }), + ).resolves.toEqual( + expect.objectContaining({ + checkType: "NIN_FACE_MATCH", + status: "FAILED", + matched: false, + }), + ); + await expect( + provider.verifyBusinessRegistration({ + storeId: "store-1", + userId: "user-1", + cacNumber: "RC123", + }), + ).resolves.toEqual( + expect.objectContaining({ checkType: "CAC", status: "VERIFIED" }), + ); + }); + + it("maps a provider status recheck without exposing its raw response", async () => { + client.getVerificationStatus.mockResolvedValue({ + verified: true, + rawStatus: "VERIFIED", + reference: "prembly-reference-1", + raw: { nin: "12345678901" }, + }); + + await expect( + provider.getVerificationStatus({ + providerReference: "prembly-reference-1", + checkType: "NIN", + }), + ).resolves.toEqual( + expect.objectContaining({ + status: "VERIFIED", + providerReference: "prembly-reference-1", + }), + ); + }); +}); diff --git a/apps/backend/src/integrations/prembly/prembly-identity-verification.provider.ts b/apps/backend/src/integrations/prembly/prembly-identity-verification.provider.ts new file mode 100644 index 00000000..7eaeb7a4 --- /dev/null +++ b/apps/backend/src/integrations/prembly/prembly-identity-verification.provider.ts @@ -0,0 +1,123 @@ +import { Injectable } from "@nestjs/common"; + +import { + IdentityVerificationProvider, + IdentityVerificationResult, + GetVerificationStatusRequest, + NormalizedVerificationStatus, + VerifyBusinessRegistrationRequest, + VerifyNinFaceMatchRequest, + VerifyNinRequest, +} from "../../domains/users/verification/providers/identity-verification-provider.interface"; +import { PremblyClient } from "./prembly.client"; + +@Injectable() +export class PremblyIdentityVerificationProvider implements IdentityVerificationProvider { + readonly name = "prembly" as const; + + constructor(private readonly client: PremblyClient) {} + + async verifyNin( + request: VerifyNinRequest, + ): Promise { + const result = await this.client.verifyNIN(request.nin); + return this.toResult( + "NIN", + result.verified, + result.rawStatus, + undefined, + result.reference, + ); + } + + async verifyNinFaceMatch( + request: VerifyNinFaceMatchRequest, + ): Promise { + if (!request.ninReference) { + return this.toResult("NIN_FACE_MATCH", false, undefined, "NIN_MISSING"); + } + const result = await this.client.verifyFace( + request.selfieImageBuffer, + request.ninReference, + ); + return this.toResult( + "NIN_FACE_MATCH", + result.verified, + result.rawStatus, + undefined, + result.reference, + ); + } + + async verifyBusinessRegistration( + request: VerifyBusinessRegistrationRequest, + ): Promise { + const result = await this.client.verifyCACNumber(request.cacNumber); + return this.toResult( + "CAC", + result.verified, + result.rawStatus, + undefined, + result.reference, + ); + } + + async getVerificationStatus( + request: GetVerificationStatusRequest, + ): Promise { + const result = await this.client.getVerificationStatus( + request.providerReference, + ); + return this.toResult( + request.checkType, + result.verified, + result.rawStatus, + undefined, + result.reference, + ); + } + + private toResult( + checkType: IdentityVerificationResult["checkType"], + verified: boolean, + rawStatus: string | undefined, + failureCode?: string, + providerReference?: string, + ): IdentityVerificationResult { + const status = this.toStatus(verified, rawStatus); + return { + provider: "prembly", + checkType, + status, + providerReference: providerReference ?? null, + matched: checkType === "NIN_FACE_MATCH" ? verified : null, + failureCode: verified ? null : (failureCode ?? "VERIFICATION_FAILED"), + failureReason: null, + checkedAt: new Date(), + }; + } + + private toStatus( + verified: boolean, + rawStatus: string | undefined, + ): NormalizedVerificationStatus { + if (verified) return "VERIFIED"; + switch ((rawStatus ?? "").trim().toLowerCase()) { + case "pending": + case "processing": + return "PENDING"; + case "retry": + case "requires_retry": + return "REQUIRES_RETRY"; + case "manual_review": + case "review": + return "MANUAL_REVIEW"; + case "failed": + case "not_verified": + case "not found": + return "FAILED"; + default: + return "UNKNOWN"; + } + } +} diff --git a/apps/backend/src/integrations/prembly/prembly.client.ts b/apps/backend/src/integrations/prembly/prembly.client.ts new file mode 100644 index 00000000..0b34473b --- /dev/null +++ b/apps/backend/src/integrations/prembly/prembly.client.ts @@ -0,0 +1,311 @@ +import { + BadGatewayException, + Injectable, + Logger, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import { + PremblyCacResult, + PremblyFaceMatchResult, + PremblyNinResult, + PremblyVerificationStatusResult, +} from "./prembly.types"; + +interface PremblyRequestOptions { + body: Record; + safeReference?: string; +} + +@Injectable() +export class PremblyClient { + private readonly logger = new Logger(PremblyClient.name); + private readonly baseUrl: string; + private readonly apiKey: string; + private readonly appId: string; + + constructor(private readonly configService: ConfigService) { + this.baseUrl = + this.configService.get("prembly.baseUrl") || + "https://api.prembly.com"; + this.apiKey = this.configService.get("prembly.apiKey") || ""; + this.appId = this.configService.get("prembly.appId") || ""; + } + + async verifyNIN(nin: string): Promise { + const payload = await this.request( + "verify NIN", + "/identitypass/verification/nin", + { + safeReference: this.maskIdentifier(nin), + body: { number: nin }, + }, + ); + const data = this.pickObject(payload, ["data", "response"]); + + return { + verified: this.readVerified(payload, data), + firstName: this.readOptionalString(data, ["first_name", "firstname"]), + lastName: this.readOptionalString(data, ["last_name", "lastname"]), + middleName: this.readOptionalString(data, ["middle_name", "middlename"]), + dateOfBirth: this.readOptionalString(data, ["date_of_birth", "dob"]), + gender: this.readOptionalString(data, ["gender"]), + phone: this.readOptionalString(data, ["phone", "phone_number"]), + photoUrl: this.readOptionalString(data, ["photo", "photo_url"]), + rawStatus: this.readOptionalString(this.asRecord(payload), [ + "status", + "verification_status", + ]), + reference: this.readVerificationReference(payload, data), + }; + } + + async verifyFace( + selfieBuffer: Buffer, + nin: string, + ): Promise { + const payload = await this.request( + "verify face", + "/identitypass/verification/face", + { + safeReference: this.maskIdentifier(nin), + body: { + number: nin, + image: selfieBuffer.toString("base64"), + }, + }, + ); + const data = this.pickObject(payload, ["data", "response"]); + + return { + verified: this.readVerified(payload, data), + confidence: this.readOptionalNumber(data, [ + "confidence", + "match_score", + "score", + ]), + rawStatus: this.readOptionalString(this.asRecord(payload), [ + "status", + "verification_status", + ]), + reference: this.readVerificationReference(payload, data), + }; + } + + async verifyCACNumber(rcNumber: string): Promise { + const payload = await this.request( + "verify CAC number", + "/identitypass/verification/cac", + { + safeReference: this.maskIdentifier(rcNumber), + body: { rc_number: rcNumber }, + }, + ); + const data = this.pickObject(payload, ["data", "response"]); + + return { + verified: this.readVerified(payload, data), + rcNumber, + companyName: this.readOptionalString(data, [ + "company_name", + "business_name", + "name", + ]), + companyType: this.readOptionalString(data, [ + "company_type", + "business_type", + ]), + registrationDate: this.readOptionalString(data, [ + "registration_date", + "date_registered", + ]), + rawStatus: this.readOptionalString(this.asRecord(payload), [ + "status", + "verification_status", + ]), + reference: this.readVerificationReference(payload, data), + }; + } + + /** + * Queries Prembly's status endpoint for an existing verification reference. + * This is deliberately separate from the submission methods so reconciliation + * can never create another paid provider operation. + */ + async getVerificationStatus( + providerReference: string, + ): Promise { + const payload = await this.request( + "get verification status", + `/verification/${encodeURIComponent(providerReference)}/status`, + { + safeReference: this.maskIdentifier(providerReference), + body: {}, + }, + ); + const data = this.pickObject(payload, ["data", "response"]); + return { + verified: this.readVerified(payload, data), + rawStatus: + this.readOptionalString(data, ["verification_status", "status"]) ?? + this.readOptionalString(this.asRecord(payload), [ + "verification_status", + "status", + ]), + reference: this.readVerificationReference(payload, data), + }; + } + + private async request( + operation: string, + path: string, + options: PremblyRequestOptions, + ): Promise { + if (!this.apiKey) { + throw new ServiceUnavailableException({ + message: "Prembly integration is not configured", + code: "PREMBLY_NOT_CONFIGURED", + }); + } + + let response: Response; + try { + response = await fetch(`${this.baseUrl}${path}`, { + method: "POST", + headers: { + "x-api-key": this.apiKey, + "Content-Type": "application/json", + Accept: "application/json", + ...(this.appId ? { "x-app-id": this.appId } : {}), + }, + body: JSON.stringify(options.body), + }); + } catch (error) { + this.logger.error( + `Prembly ${operation} request failed${this.safeReference( + options.safeReference, + )}`, + ); + throw new ServiceUnavailableException({ + message: "Prembly is temporarily unavailable", + code: "PREMBLY_UNAVAILABLE", + cause: error, + }); + } + + const payload = await this.readJson(response); + if (!response.ok) { + this.logger.error( + `Prembly ${operation} failed status=${response.status}${this.safeReference( + options.safeReference, + )}`, + ); + throw new BadGatewayException({ + message: "Prembly request failed", + code: "PREMBLY_REQUEST_FAILED", + }); + } + + return payload; + } + + private async readJson(response: Response): Promise { + try { + return (await response.json()) as unknown; + } catch { + return null; + } + } + + private readVerified( + payload: unknown, + data: Record, + ): boolean { + const root = this.asRecord(payload); + const status = + this.readOptionalString(data, ["status", "verification_status"]) || + this.readOptionalString(root, ["status", "verification_status"]); + const verified = data.verified ?? root.verified; + + return ( + verified === true || + status?.toLowerCase() === "verified" || + status?.toLowerCase() === "success" + ); + } + + private pickObject(value: unknown, keys: string[]): Record { + const record = this.asRecord(value); + for (const key of keys) { + const candidate = record[key]; + if ( + candidate && + typeof candidate === "object" && + !Array.isArray(candidate) + ) { + return candidate as Record; + } + } + return record; + } + + private asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + } + + private readOptionalString( + record: Record, + keys: string[], + ): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") { + return value; + } + if (typeof value === "number") { + return value.toString(); + } + } + return undefined; + } + + private readOptionalNumber( + record: Record, + keys: string[], + ): number | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number") { + return value; + } + if (typeof value === "string" && /^\d+(\.\d+)?$/.test(value)) { + return Number(value); + } + } + return undefined; + } + + private readVerificationReference( + payload: unknown, + data: Record, + ): string | undefined { + return ( + this.readOptionalString(data, ["reference", "verification_reference"]) ?? + this.readOptionalString(this.pickObject(payload, ["verification"]), [ + "reference", + "id", + ]) + ); + } + + private maskIdentifier(value: string): string { + return value.length <= 4 ? "****" : `****${value.slice(-4)}`; + } + + private safeReference(reference: string | undefined): string { + return reference ? ` reference=${reference}` : ""; + } +} diff --git a/apps/backend/src/integrations/prembly/prembly.module.ts b/apps/backend/src/integrations/prembly/prembly.module.ts new file mode 100644 index 00000000..46da4e66 --- /dev/null +++ b/apps/backend/src/integrations/prembly/prembly.module.ts @@ -0,0 +1,18 @@ +import { Module } from "@nestjs/common"; + +import { PremblyClient } from "./prembly.client"; +import { PremblyIdentityVerificationProvider } from "./prembly-identity-verification.provider"; +import { IDENTITY_VERIFICATION_PROVIDER } from "../../domains/users/verification/providers/identity-verification-provider.interface"; + +@Module({ + providers: [ + PremblyClient, + PremblyIdentityVerificationProvider, + { + provide: IDENTITY_VERIFICATION_PROVIDER, + useExisting: PremblyIdentityVerificationProvider, + }, + ], + exports: [IDENTITY_VERIFICATION_PROVIDER], +}) +export class PremblyModule {} diff --git a/apps/backend/src/integrations/prembly/prembly.types.ts b/apps/backend/src/integrations/prembly/prembly.types.ts new file mode 100644 index 00000000..2b064e49 --- /dev/null +++ b/apps/backend/src/integrations/prembly/prembly.types.ts @@ -0,0 +1,35 @@ +export interface PremblyNinResult { + verified: boolean; + firstName?: string; + lastName?: string; + middleName?: string; + dateOfBirth?: string; + gender?: string; + phone?: string; + photoUrl?: string; + rawStatus?: string; + reference?: string; +} + +export interface PremblyFaceMatchResult { + verified: boolean; + confidence?: number; + rawStatus?: string; + reference?: string; +} + +export interface PremblyCacResult { + verified: boolean; + rcNumber: string; + companyName?: string; + companyType?: string; + registrationDate?: string; + rawStatus?: string; + reference?: string; +} + +export interface PremblyVerificationStatusResult { + verified: boolean; + rawStatus?: string; + reference?: string; +} diff --git a/apps/backend/src/integrations/resend/resend-email.provider.spec.ts b/apps/backend/src/integrations/resend/resend-email.provider.spec.ts new file mode 100644 index 00000000..5647de37 --- /dev/null +++ b/apps/backend/src/integrations/resend/resend-email.provider.spec.ts @@ -0,0 +1,63 @@ +import { BadGatewayException } from "@nestjs/common"; + +import { ResendEmailProvider } from "./resend-email.provider"; + +describe("ResendEmailProvider", () => { + const makeProvider = () => { + const resendClient = { sendEmail: jest.fn().mockResolvedValue(undefined) }; + return { + provider: new ResendEmailProvider(resendClient as never), + resendClient, + }; + }; + + it("maps a Twizrr template request to the Resend client", async () => { + const { provider, resendClient } = makeProvider(); + + await expect( + provider.sendTransactionalEmail({ + to: { email: "shopper@example.com" }, + template: "OTP_EMAIL_VERIFY", + variables: { code: "123456", ttlMinutes: 15 }, + }), + ).resolves.toEqual({ + provider: "resend", + providerMessageId: null, + accepted: true, + }); + + expect(resendClient.sendEmail).toHaveBeenCalledWith( + "shopper@example.com", + "OTP_EMAIL_VERIFY", + { code: "123456", ttlMinutes: 15 }, + ); + }); + + it("renders legacy notifications without leaking provider shapes", async () => { + const { provider, resendClient } = makeProvider(); + + await provider.sendTransactionalEmail({ + to: { email: "shopper@example.com" }, + template: "LEGACY_NOTIFICATION", + variables: { title: "Order update", body: "Your order is on the way." }, + }); + + expect(resendClient.sendEmail).toHaveBeenCalledWith( + "shopper@example.com", + "Order update", + expect.stringContaining("Your order is on the way."), + ); + }); + + it("rejects invalid adapter-only template variables safely", async () => { + const { provider } = makeProvider(); + + await expect( + provider.sendTransactionalEmail({ + to: { email: "shopper@example.com" }, + template: "LEGACY_NOTIFICATION", + variables: { title: "Order update", body: null }, + }), + ).rejects.toBeInstanceOf(BadGatewayException); + }); +}); diff --git a/apps/backend/src/integrations/resend/resend-email.provider.ts b/apps/backend/src/integrations/resend/resend-email.provider.ts new file mode 100644 index 00000000..6877d3b9 --- /dev/null +++ b/apps/backend/src/integrations/resend/resend-email.provider.ts @@ -0,0 +1,62 @@ +import { BadGatewayException, Injectable } from "@nestjs/common"; + +import type { + EmailProvider, + SendTransactionalEmailRequest, + SendTransactionalEmailResult, + TransactionalEmailVariables, +} from "../../domains/social/email/providers/email-provider.interface"; +import { ResendClient } from "./resend.client"; + +@Injectable() +export class ResendEmailProvider implements EmailProvider { + constructor(private readonly resendClient: ResendClient) {} + + async sendTransactionalEmail( + request: SendTransactionalEmailRequest, + ): Promise { + const { to, template, variables } = request; + + if (template === "LEGACY_NOTIFICATION") { + const title = this.readRequiredString(variables, "title"); + await this.resendClient.sendEmail( + to.email, + title, + `

${this.escapeHtml(title)}

${this.escapeHtml(this.readRequiredString(variables, "body"))}

`, + ); + } else if (template === "TRANSACTIONAL_CONTENT") { + await this.resendClient.sendEmail( + to.email, + this.readRequiredString(variables, "subject"), + this.readRequiredString(variables, "html"), + ); + } else { + await this.resendClient.sendEmail(to.email, template, variables); + } + + return { provider: "resend", providerMessageId: null, accepted: true }; + } + + private readRequiredString( + variables: TransactionalEmailVariables, + key: string, + ): string { + const value = variables[key]; + if (typeof value !== "string") { + throw new BadGatewayException({ + message: "Email template variables are invalid", + code: "EMAIL_TEMPLATE_VARIABLES_INVALID", + }); + } + return value; + } + + private escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'"); + } +} diff --git a/apps/backend/src/integrations/resend/resend.client.ts b/apps/backend/src/integrations/resend/resend.client.ts new file mode 100644 index 00000000..ec7a13f9 --- /dev/null +++ b/apps/backend/src/integrations/resend/resend.client.ts @@ -0,0 +1,184 @@ +import { + BadGatewayException, + Injectable, + Logger, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Resend } from "resend"; +import type { + TransactionalEmailTemplate, + TransactionalEmailVariable, + TransactionalEmailVariables, +} from "../../domains/social/email/providers/email-provider.interface"; + +export type ResendTemplateVariable = TransactionalEmailVariable; +export type ResendTemplateVariables = TransactionalEmailVariables; + +interface ResendTemplateDefinition { + subject: string; + html: string; + replyTo?: string; +} + +export const RESEND_TEMPLATES = { + OTP_EMAIL_VERIFY: { + subject: "Verify your email", + html: "

Your twizrr verification code is {{code}}.

This code expires in {{ttlMinutes}} minutes.

", + }, + OTP_PASSWORD_RESET: { + subject: "Reset your password", + html: '

Use this secure link to reset your twizrr password:

Reset password

This link expires in {{ttlMinutes}} minutes.

', + }, + ORDER_CONFIRMED: { + subject: "Order confirmed - {{orderCode}}", + html: "

Your order {{orderCode}} is confirmed. Your payment is protected.

", + }, + ORDER_DISPATCHED: { + subject: "Order on the way - {{orderCode}}", + html: "

Your order {{orderCode}} is on the way. Tracking: {{trackingCode}}

", + }, + ORDER_COMPLETED: { + subject: "Order completed - {{orderCode}}", + html: "

Your order {{orderCode}} is complete. Thank you for shopping on twizrr.

", + }, + PAYOUT_SENT: { + subject: "Payout sent - {{orderCode}}", + html: "

Your payout for order {{orderCode}} has been sent.

", + }, + PAYOUT_FAILED: { + subject: "Payout failed - {{orderCode}}", + html: "

Your payout for order {{orderCode}} could not be sent. Please review your bank details.

", + }, + DISPUTE_FILED: { + subject: "Dispute filed - {{orderCode}}", + html: "

A dispute has been filed for order {{orderCode}}. Please respond from your dashboard.

", + }, + DISPUTE_RESOLVED: { + subject: "Dispute resolved - {{orderCode}}", + html: "

The dispute for order {{orderCode}} has been resolved.

", + }, + STORE_TIER_UPGRADED: { + subject: "Store verification updated", + html: "

Your store has been upgraded to {{tier}}.

", + }, +} as const satisfies Record; + +export type ResendTemplateName = Exclude< + TransactionalEmailTemplate, + "LEGACY_NOTIFICATION" | "TRANSACTIONAL_CONTENT" +>; + +@Injectable() +export class ResendClient { + private readonly logger = new Logger(ResendClient.name); + private readonly resend: Resend | null; + private readonly fromEmail: string; + + constructor(private readonly configService: ConfigService) { + const apiKey = this.configService.get("resend.apiKey")?.trim(); + this.resend = apiKey ? new Resend(apiKey) : null; + this.fromEmail = + this.configService.get("resend.fromEmail") || + "no-reply@twizrr.com"; + } + + async sendEmail( + to: string, + templateName: ResendTemplateName, + variables: ResendTemplateVariables, + ): Promise; + async sendEmail(to: string, subject: string, html: string): Promise; + async sendEmail( + to: string, + templateNameOrSubject: ResendTemplateName | string, + variablesOrHtml: ResendTemplateVariables | string, + ): Promise { + const resend = this.getConfiguredClient(); + + const email = + typeof variablesOrHtml === "string" + ? { + subject: templateNameOrSubject, + html: variablesOrHtml, + } + : this.renderTemplate(templateNameOrSubject, variablesOrHtml); + + const { error } = await resend.emails.send({ + from: `twizrr <${this.fromEmail}>`, + to: [to], + subject: `twizrr | ${email.subject}`, + html: email.html, + ...(email.replyTo ? { replyTo: email.replyTo } : {}), + }); + + if (error) { + this.logger.error( + `Resend send failed recipientDomain=${this.maskEmailDomain( + to, + )} template=${templateNameOrSubject}`, + ); + throw new BadGatewayException({ + message: "Resend email send failed", + code: "RESEND_SEND_FAILED", + }); + } + } + + private renderTemplate( + templateName: string, + variables: ResendTemplateVariables, + ): ResendTemplateDefinition { + if (!this.isTemplateName(templateName)) { + throw new BadGatewayException({ + message: "Unknown email template", + code: "RESEND_UNKNOWN_TEMPLATE", + }); + } + + const template = RESEND_TEMPLATES[templateName]; + const definition = template as ResendTemplateDefinition; + return { + subject: this.interpolate(template.subject, variables), + html: this.interpolate(template.html, variables), + replyTo: definition.replyTo, + }; + } + + private interpolate( + source: string, + variables: ResendTemplateVariables, + ): string { + return source.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, (_, key: string) => + this.escapeHtml(String(variables[key] ?? "")), + ); + } + + private escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + private isTemplateName(value: string): value is ResendTemplateName { + return Object.prototype.hasOwnProperty.call(RESEND_TEMPLATES, value); + } + + private maskEmailDomain(email: string): string { + return email.includes("@") ? `****@${email.split("@").pop()}` : "unknown"; + } + + private getConfiguredClient(): Resend { + if (!this.resend) { + throw new ServiceUnavailableException({ + message: "Resend integration is not configured", + code: "RESEND_NOT_CONFIGURED", + }); + } + + return this.resend; + } +} diff --git a/apps/backend/src/integrations/resend/resend.module.ts b/apps/backend/src/integrations/resend/resend.module.ts new file mode 100644 index 00000000..5447272d --- /dev/null +++ b/apps/backend/src/integrations/resend/resend.module.ts @@ -0,0 +1,32 @@ +import { Module } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + EMAIL_PROVIDER, + type EmailProvider, +} from "../../domains/social/email/providers/email-provider.interface"; +import { ResendClient } from "./resend.client"; +import { ResendEmailProvider } from "./resend-email.provider"; + +@Module({ + providers: [ + ResendClient, + ResendEmailProvider, + { + provide: EMAIL_PROVIDER, + inject: [ConfigService, ResendEmailProvider], + useFactory: ( + configService: ConfigService, + resendEmailProvider: ResendEmailProvider, + ): EmailProvider => { + const provider = + configService.get("resend.provider") ?? "resend"; + if (provider !== "resend") { + throw new Error(`Unsupported email provider: ${provider}`); + } + return resendEmailProvider; + }, + }, + ], + exports: [EMAIL_PROVIDER], +}) +export class ResendModule {} diff --git a/apps/backend/src/integrations/shipbubble/README.md b/apps/backend/src/integrations/shipbubble/README.md new file mode 100644 index 00000000..aa3f6e02 --- /dev/null +++ b/apps/backend/src/integrations/shipbubble/README.md @@ -0,0 +1,13 @@ +# Shipbubble Integration + +Logistics provider client for delivery booking and tracking. +To be built as part of the orders/delivery domain. +Required methods: getDeliveryRates(), bookDelivery(), trackShipment(). + +Before webhook or tracking automation, run the sandbox/admin QA checklist in +`SANDBOX_QA_RUNBOOK.md`. + +Provider-sensitive package fallbacks are configured with +`SHIPBUBBLE_DEFAULT_CATEGORY_ID` and `DEFAULT_PACKAGE_WEIGHT_KG`. Confirm both +values in the Shipbubble sandbox before using live provider booking. + diff --git a/apps/backend/src/integrations/shipbubble/SANDBOX_QA_RUNBOOK.md b/apps/backend/src/integrations/shipbubble/SANDBOX_QA_RUNBOOK.md new file mode 100644 index 00000000..3458489a --- /dev/null +++ b/apps/backend/src/integrations/shipbubble/SANDBOX_QA_RUNBOOK.md @@ -0,0 +1,142 @@ +# Shipbubble Sandbox QA Runbook + +Use this runbook before adding Shipbubble webhook or tracking automation. It is +for dev or sandbox data only. Do not use production orders, production provider +credentials, or real shopper contact details during this test. + +## Current Flow + +1. Shopper checkout creates delivery snapshots and a twizrr-calculated delivery + fee. +2. Store owner marks the order `READY_FOR_PICKUP`. +3. Admin or operator creates an internal/manual `DeliveryBooking`. +4. Admin or operator fetches Shipbubble rates for the booking. +5. Admin or operator selects and books a Shipbubble rate. +6. The booking becomes `PICKUP_SCHEDULED`. +7. The order remains `READY_FOR_PICKUP`. +8. Admin or operator starts delivery separately. +9. The order becomes `DISPATCHED`; the delivery code is generated, the buyer is + notified, and auto-confirm timers start. + +## Required Environment + +Set these only in the relevant dev or sandbox environment: + +- `SHIPBUBBLE_API_KEY`: required for live/sandbox Shipbubble rate and booking + calls. +- `SHIPBUBBLE_BASE_URL`: optional; defaults to the configured Shipbubble base + URL when omitted. +- `SHIPBUBBLE_DEFAULT_CATEGORY_ID`: optional local/dev fallback used when an + order has no mapped provider category. Confirm the value in Shipbubble before + sandbox provider testing. +- `DEFAULT_PACKAGE_WEIGHT_KG`: optional local/dev fallback used when product or + package weight is missing. Confirm the value in Shipbubble before sandbox + provider testing because it affects quotes and booking eligibility. +- `SHIPBUBBLE_WEBHOOK_SECRET`: later webhook/tracking automation only. + +Do not commit real values. Shipbubble envs are optional until provider testing +is being performed. + +## Required Test Data + +Use a test order that has: + +- A shopper with a verified primary account phone. +- A supported pickup zone and supported dropoff zone. +- A product with a clear name and sane item value. +- A product weight if available. If missing, the provider request falls back to + `DEFAULT_PACKAGE_WEIGHT_KG`. +- A provider category mapping if available. If missing, the provider request + falls back to `SHIPBUBBLE_DEFAULT_CATEGORY_ID`. +- `pickupAddressSnapshot`. +- `deliveryAddressSnapshot`. +- `deliveryPrimaryPhone`. +- `pickupZone`. +- `deliveryZone`. +- Order status `READY_FOR_PICKUP`. +- A pending internal `DeliveryBooking`. + +For direct orders, pickup should come from the selling store pickup point. For +sourced or dropshipped orders, pickup should come from the physical supplier +pickup point. + +## Manual Test Steps + +1. Create or identify a dev/sandbox shopper order. +2. Confirm checkout created delivery fee and delivery snapshots. +3. Mark the order `READY_FOR_PICKUP` as the store owner. +4. Open `/admin/deliveries` as `SUPER_ADMIN` or `OPERATOR`. +5. Confirm the order appears in Ready for booking. +6. Create the internal/manual booking. +7. Confirm the booking appears in Booked deliveries with status `PENDING`. +8. Click `Fetch Shipbubble rates`. +9. Confirm the response shows: + - request token + - rate id + - courier or service name + - fee + - ETA or delivery window when provided +10. Select a rate. +11. Click `Book selected rate`. +12. Confirm the booking becomes `PICKUP_SCHEDULED`. +13. Confirm the order remains `READY_FOR_PICKUP`. +14. Confirm no delivery code has been generated yet. +15. Confirm no buyer dispatched notification has been sent yet. +16. Confirm no auto-confirm timer has started yet. +17. Click `Start delivery`. +18. Confirm the order becomes `DISPATCHED`. +19. Confirm the booking becomes `PICKED_UP`. +20. Confirm the delivery code is generated and sent to the buyer at start + delivery time. +21. Confirm the buyer can confirm delivery with the delivery code. + +## Failure Cases To Test + +| Case | Expected result | +| --- | --- | +| Missing `SHIPBUBBLE_API_KEY` | Rate/booking action fails safely; manual booking remains available. | +| Unsupported or bad pickup/dropoff address | Provider error is shown to admin only; order and booking status stay unchanged. | +| Missing pickup snapshot | Rate/booking action is rejected before provider booking. | +| Missing delivery snapshot | Rate/booking action is rejected before provider booking. | +| Missing primary delivery phone | Rate/booking action is rejected before provider booking. | +| Provider quote failure | Booking/order remain unchanged and admin sees safe retry/fallback copy. | +| Provider booking failure | Booking/order remain unchanged and admin sees safe retry/fallback copy. | +| Provider cost differs from collected delivery fee | Record the mismatch and confirm product decision before automation. | +| `SUPPORT` attempts rate, booking, or start-delivery mutation | Backend denies the mutation. | +| Manual fallback is used without Shipbubble | Internal manual booking path still works. | + +## Privacy Checklist + +- Shopper UI must not show Shipbubble or provider selection. +- Store owner UI must not show Shipbubble or provider selection. +- Store owner APIs must not expose shopper phone, shopper email, delivery phone, + delivery address snapshots, raw delivery details, or delivery code hashes. +- Admin delivery operations may show contact and address snapshots for internal + fulfillment only. +- Delivery phone and address are internal fulfillment data and may be shared only + with approved delivery or logistics providers when required. + +## Pass/Fail Template + +| Test case | Expected result | Actual result | Pass/fail | Notes or log reference | +| --- | --- | --- | --- | --- | +| Ready order has required snapshots | Pickup, dropoff, zones, and primary phone exist | | | | +| Manual booking created | DeliveryBooking is `PENDING` | | | | +| Shipbubble rates fetched | Rates include request token, rate id, courier, fee, and ETA/window when available | | | | +| Selected rate booked | Booking becomes `PICKUP_SCHEDULED`; order remains `READY_FOR_PICKUP` | | | | +| No dispatch side effects before start | No delivery code, buyer dispatched notification, or auto-confirm timer | | | | +| Start delivery | Order becomes `DISPATCHED`; booking becomes `PICKED_UP` | | | | +| Buyer receives delivery code | Notification is sent only when delivery starts | | | | +| Buyer confirms delivery | Delivery confirmation succeeds with valid delivery code | | | | +| Store-owner privacy | Store owner cannot see shopper contact or delivery snapshots | | | | +| Manual fallback | Manual booking remains usable when provider calls fail | | | | + +## Next-Step Gate + +Do not start Shipbubble webhook or tracking automation until: + +- At least one real/sandbox rate fetch succeeds. +- At least one real/sandbox booking succeeds. +- Any provider payload mismatches are documented and fixed. +- The cost mismatch handling decision is confirmed. +- Admin/operator runbook results are recorded for the dev or sandbox test order. diff --git a/apps/backend/src/integrations/shipbubble/shipbubble-shipping.provider.spec.ts b/apps/backend/src/integrations/shipbubble/shipbubble-shipping.provider.spec.ts new file mode 100644 index 00000000..cc2c596e --- /dev/null +++ b/apps/backend/src/integrations/shipbubble/shipbubble-shipping.provider.spec.ts @@ -0,0 +1,102 @@ +import { UnauthorizedException } from "@nestjs/common"; + +import { ShipbubbleShippingProvider } from "./shipbubble-shipping.provider"; + +describe("ShipbubbleShippingProvider", () => { + const client = { + getDeliveryRates: jest.fn(), + bookDelivery: jest.fn(), + verifyWebhookSignature: jest.fn(), + normalizeWebhookUpdate: jest.fn(), + }; + const provider = new ShipbubbleShippingProvider(client as never); + const address = { + phone: "+2348012345678", + address: "1 Test Street", + city: "Ikeja", + state: "Lagos", + countryCode: "NG", + }; + + beforeEach(() => jest.clearAllMocks()); + + it("maps provider rates to Twizrr quote options", async () => { + client.getDeliveryRates.mockResolvedValue([ + { + rateId: "rate_1", + requestToken: "token_1", + courierName: "Courier", + amountKobo: 250000n, + serviceCode: "same_day", + }, + ]); + + await expect( + provider.getQuotes(address, address, { weightKg: 1 }), + ).resolves.toEqual([ + expect.objectContaining({ + provider: "shipbubble", + quoteId: "rate_1", + bookingToken: "token_1", + amountKobo: 250000n, + }), + ]); + }); + + it("maps provider bookings without returning raw provider payloads", async () => { + client.bookDelivery.mockResolvedValue({ + bookingId: "booking_1", + trackingId: "tracking_1", + amountKobo: 250000n, + rawStatus: "picked_up", + }); + + await expect( + provider.createBooking({ + idempotencyKey: "delivery-booking:1", + quoteId: "rate_1", + bookingToken: "token_1", + pickup: address, + dropoff: address, + parcel: { weightKg: 1 }, + }), + ).resolves.toEqual({ + provider: "shipbubble", + providerBookingId: "booking_1", + providerTrackingId: "tracking_1", + feeKobo: 250000n, + estimatedDeliveryDate: undefined, + status: "PICKED_UP", + trackingUrl: undefined, + }); + }); + + it("rejects invalid webhook signatures before normalization", async () => { + client.verifyWebhookSignature.mockReturnValue(false); + await expect( + provider.verifyAndNormalizeWebhook({}, Buffer.from("{}"), {}), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(client.normalizeWebhookUpdate).not.toHaveBeenCalled(); + }); + + it("normalizes a verified webhook without passing raw payload to the domain", async () => { + client.verifyWebhookSignature.mockReturnValue(true); + client.normalizeWebhookUpdate.mockReturnValue({ + shipmentId: "booking_1", + status: "delivered", + }); + await expect( + provider.verifyAndNormalizeWebhook( + { "x-ship-signature": "signature", "x-ship-timestamp": "1" }, + Buffer.from("{}"), + { private: "raw payload" }, + ), + ).resolves.toEqual( + expect.objectContaining({ + providerBookingId: "booking_1", + providerEventId: "booking_1:delivered", + status: "DELIVERED", + }), + ); + }); +}); diff --git a/apps/backend/src/integrations/shipbubble/shipbubble-shipping.provider.ts b/apps/backend/src/integrations/shipbubble/shipbubble-shipping.provider.ts new file mode 100644 index 00000000..38e439ac --- /dev/null +++ b/apps/backend/src/integrations/shipbubble/shipbubble-shipping.provider.ts @@ -0,0 +1,136 @@ +import { Injectable, UnauthorizedException } from "@nestjs/common"; + +import { + NormalizedShippingStatus, + NormalizedShippingWebhookEvent, + ShippingAddress, + ShippingBookingRequest, + ShippingBookingResult, + ShippingProvider, + ShippingQuoteOption, +} from "../../domains/orders/delivery/providers/shipping-provider.interface"; +import { ShipbubbleClient } from "./shipbubble.client"; + +@Injectable() +export class ShipbubbleShippingProvider implements ShippingProvider { + constructor(private readonly client: ShipbubbleClient) {} + + async getQuotes( + pickup: ShippingAddress, + dropoff: ShippingAddress, + parcel: ShippingBookingRequest["parcel"], + ): Promise { + const rates = await this.client.getDeliveryRates( + this.toAddress(pickup), + this.toAddress(dropoff), + this.toParcel(parcel), + ); + return rates.map((rate) => ({ + provider: "shipbubble", + quoteId: rate.rateId, + bookingToken: rate.requestToken, + serviceCode: rate.serviceCode, + serviceName: rate.courierName, + amountKobo: rate.amountKobo, + estimatedDeliveryDate: rate.estimatedDeliveryDate, + estimatedDeliveryWindow: rate.estimatedDeliveryWindow, + })); + } + + async createBooking( + request: ShippingBookingRequest, + ): Promise { + const booking = await this.client.bookDelivery( + { + rateId: request.quoteId, + requestToken: request.bookingToken, + serviceCode: request.serviceCode, + }, + this.toAddress(request.pickup), + this.toAddress(request.dropoff), + this.toParcel(request.parcel), + ); + return { + provider: "shipbubble", + providerBookingId: booking.bookingId, + providerTrackingId: booking.trackingId, + trackingUrl: booking.waybillUrl, + feeKobo: booking.amountKobo, + estimatedDeliveryDate: booking.estimatedDeliveryDate, + status: this.toStatus(booking.rawStatus), + }; + } + + async verifyAndNormalizeWebhook( + headers: Record, + rawBody: Buffer, + payload: unknown, + ): Promise { + const signature = headers["x-ship-signature"]; + const timestamp = + headers["x-ship-timestamp"] ?? headers["x-shipbubble-timestamp"]; + if ( + !this.client.verifyWebhookSignature( + rawBody, + this.toHeader(signature), + this.toHeader(timestamp), + ) + ) { + throw new UnauthorizedException({ + message: "Invalid delivery provider webhook signature", + code: "SHIPPING_WEBHOOK_UNVERIFIED", + }); + } + const update = this.client.normalizeWebhookUpdate(payload); + if (!update) return null; + return { + provider: "shipbubble", + providerEventId: `${update.shipmentId}:${update.status}`, + providerBookingId: update.shipmentId, + status: this.toStatus(update.status), + occurredAt: new Date(), + }; + } + + private toStatus(value: string | undefined): NormalizedShippingStatus { + switch ((value ?? "").trim().toLowerCase()) { + case "pending": + return "PENDING"; + case "booked": + case "pickup_scheduled": + case "scheduled": + return "BOOKED"; + case "picked_up": + case "picked up": + return "PICKED_UP"; + case "in_transit": + case "in transit": + return "IN_TRANSIT"; + case "out_for_delivery": + case "out for delivery": + case "arriving": + return "OUT_FOR_DELIVERY"; + case "delivered": + return "DELIVERED"; + case "failed": + return "FAILED"; + case "cancelled": + case "canceled": + return "CANCELLED"; + default: + return "UNKNOWN"; + } + } + + private toHeader(value: unknown): string | string[] | undefined { + return typeof value === "string" || Array.isArray(value) + ? value + : undefined; + } + private toAddress(address: ShippingAddress) { + return { ...address, country: address.countryCode }; + } + private toParcel(parcel: ShippingBookingRequest["parcel"]) { + return parcel; + } +} diff --git a/apps/backend/src/integrations/shipbubble/shipbubble.client.spec.ts b/apps/backend/src/integrations/shipbubble/shipbubble.client.spec.ts new file mode 100644 index 00000000..83efbad6 --- /dev/null +++ b/apps/backend/src/integrations/shipbubble/shipbubble.client.spec.ts @@ -0,0 +1,85 @@ +import { createHmac } from "crypto"; + +import { ConfigService } from "@nestjs/config"; + +import { ShipbubbleClient } from "./shipbubble.client"; + +describe("ShipbubbleClient webhook verification", () => { + const now = new Date("2026-06-25T12:00:00.000Z").getTime(); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function createClient(config: Record) { + const configService = { + get: jest.fn((key: string) => config[key]), + }; + + return new ShipbubbleClient(configService as unknown as ConfigService); + } + + it("rejects webhook verification when the secret is missing", () => { + const client = createClient({}); + + expect( + client.verifyWebhookSignature( + Buffer.from("{}"), + "signature", + String(now), + ), + ).toBe(false); + }); + + it("verifies Shipbubble SHA512 webhook signatures", () => { + jest.spyOn(Date, "now").mockReturnValue(now); + const rawBody = Buffer.from('{"event":"shipment.updated"}'); + const secret = "shipbubble-secret"; + const timestamp = String(now); + const signature = createHmac("sha512", secret) + .update(`${timestamp}.`) + .update(rawBody) + .digest("hex"); + const client = createClient({ "shipbubble.webhookSecret": secret }); + + expect(client.verifyWebhookSignature(rawBody, signature, timestamp)).toBe( + true, + ); + expect(client.verifyWebhookSignature(rawBody, "invalid", timestamp)).toBe( + false, + ); + }); + + it("rejects missing, stale, and far-future webhook timestamps", () => { + jest.spyOn(Date, "now").mockReturnValue(now); + const rawBody = Buffer.from('{"event":"shipment.updated"}'); + const secret = "shipbubble-secret"; + const client = createClient({ "shipbubble.webhookSecret": secret }); + const sign = (timestamp: string) => + createHmac("sha512", secret) + .update(`${timestamp}.`) + .update(rawBody) + .digest("hex"); + + const staleTimestamp = String(now - 6 * 60 * 1000); + const futureTimestamp = String(now + 6 * 60 * 1000); + + expect( + client.verifyWebhookSignature(rawBody, sign(String(now)), undefined), + ).toBe(false); + expect( + client.verifyWebhookSignature( + rawBody, + sign(staleTimestamp), + staleTimestamp, + ), + ).toBe(false); + expect( + client.verifyWebhookSignature( + rawBody, + sign(futureTimestamp), + futureTimestamp, + ), + ).toBe(false); + }); +}); diff --git a/apps/backend/src/integrations/shipbubble/shipbubble.client.ts b/apps/backend/src/integrations/shipbubble/shipbubble.client.ts new file mode 100644 index 00000000..137ba819 --- /dev/null +++ b/apps/backend/src/integrations/shipbubble/shipbubble.client.ts @@ -0,0 +1,426 @@ +import { + BadGatewayException, + Injectable, + Logger, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { createHmac, timingSafeEqual } from "crypto"; + +import { + ShipbubbleAddress, + ShipbubbleBooking, + ShipbubbleBookingSelection, + ShipbubbleParcel, + ShipbubbleRate, + ShipbubbleTrackingEvent, + ShipbubbleTrackingStatus, +} from "./shipbubble.types"; + +type HttpMethod = "GET" | "POST"; + +interface ShipbubbleRequestOptions { + method?: HttpMethod; + body?: Record; + safeReference?: string; +} + +@Injectable() +export class ShipbubbleClient { + private readonly logger = new Logger(ShipbubbleClient.name); + private readonly baseUrl: string; + private readonly apiKey: string; + private readonly webhookSecret: string; + private readonly webhookReplayWindowMs = 5 * 60 * 1000; + + constructor(private readonly configService: ConfigService) { + this.baseUrl = + this.configService.get("shipbubble.baseUrl") || + "https://api.shipbubble.com/v1"; + this.apiKey = this.configService.get("shipbubble.apiKey") || ""; + this.webhookSecret = + this.configService.get("shipbubble.webhookSecret") || ""; + } + + async getDeliveryRates( + pickup: ShipbubbleAddress, + delivery: ShipbubbleAddress, + parcel: ShipbubbleParcel, + ): Promise { + const [pickupCode, deliveryCode] = await Promise.all([ + this.validateAddress(pickup), + this.validateAddress(delivery), + ]); + const payload = await this.request( + "get delivery rates", + "/shipping/fetch_rates", + { + method: "POST", + body: { + sender_address_code: pickupCode, + reciever_address_code: deliveryCode, + receiver_address_code: deliveryCode, + ...this.toProviderParcel(parcel), + }, + }, + ); + + const requestToken = this.readOptionalString(this.asRecord(payload), [ + "request_token", + "requestToken", + ]); + const rates = this.pickArray(payload, ["rates", "data", "couriers"]); + return rates.map((rate) => this.toRate(rate, requestToken)); + } + + async bookDelivery( + selection: ShipbubbleBookingSelection, + pickup: ShipbubbleAddress, + delivery: ShipbubbleAddress, + parcel: ShipbubbleParcel, + ): Promise { + const payload = await this.request("book delivery", "/shipping/labels", { + method: "POST", + safeReference: selection.rateId, + body: { + courier_id: selection.rateId, + request_token: selection.requestToken, + service_code: selection.serviceCode, + pickup: this.toProviderAddress(pickup), + delivery: this.toProviderAddress(delivery), + ...this.toProviderParcel(parcel), + }, + }); + + const data = this.pickObject(payload, ["data", "booking", "shipment"]); + return { + bookingId: this.readString(data, ["id", "booking_id", "shipment_id"]), + trackingId: this.readString(data, ["tracking_id", "tracking_number"]), + courierName: this.readOptionalString(data, ["courier_name", "courier"]), + waybillUrl: this.readOptionalString(data, ["waybill_url", "label_url"]), + amountKobo: this.readOptionalKobo(data, ["amount", "total", "price"]), + estimatedDeliveryDate: this.readOptionalString(data, [ + "estimated_delivery_date", + "delivery_date", + ]), + rawStatus: this.readOptionalString(data, ["status"]), + }; + } + + async trackShipment(trackingId: string): Promise { + const payload = await this.request( + "track shipment", + `/shipping/track/${encodeURIComponent(trackingId)}`, + { safeReference: trackingId }, + ); + const data = this.pickObject(payload, ["data", "tracking", "shipment"]); + const events = this.pickArray(data, ["events", "history", "timeline"]).map( + (event) => this.toTrackingEvent(event), + ); + + return { + trackingId, + status: this.readString(data, ["status", "current_status"]), + statusDescription: this.readOptionalString(data, [ + "status_description", + "description", + ]), + lastUpdatedAt: this.readOptionalString(data, [ + "updated_at", + "last_updated_at", + ]), + events, + }; + } + + verifyWebhookSignature( + rawBody: Buffer, + signature: string | string[] | undefined, + timestamp: string | string[] | undefined, + ): boolean { + if ( + !this.webhookSecret || + typeof signature !== "string" || + typeof timestamp !== "string" + ) { + return false; + } + + const timestampMs = this.parseWebhookTimestamp(timestamp); + if (timestampMs === null) { + return false; + } + + const now = Date.now(); + if (Math.abs(now - timestampMs) > this.webhookReplayWindowMs) { + return false; + } + + const expected = createHmac("sha512", this.webhookSecret) + .update(`${timestamp}.`) + .update(rawBody) + .digest("hex"); + const signatureBuffer = Buffer.from(signature); + const expectedBuffer = Buffer.from(expected); + + return ( + signatureBuffer.length === expectedBuffer.length && + timingSafeEqual(signatureBuffer, expectedBuffer) + ); + } + + private parseWebhookTimestamp(value: string): number | null { + if (!/^\d+$/.test(value)) { + return null; + } + + const numericValue = Number(value); + if (!Number.isSafeInteger(numericValue) || numericValue <= 0) { + return null; + } + + return value.length <= 10 ? numericValue * 1000 : numericValue; + } + + normalizeWebhookUpdate( + payload: unknown, + ): { shipmentId: string; status: string } | null { + const data = this.pickObject(payload, ["data", "shipment", "delivery"]); + const shipmentId = this.readOptionalString(data, [ + "shipment_id", + "shipmentId", + "id", + "booking_id", + ]); + const status = this.readOptionalString(data, [ + "status", + "shipment_status", + "delivery_status", + ]); + + return shipmentId && status ? { shipmentId, status } : null; + } + + private async request( + operation: string, + path: string, + options: ShipbubbleRequestOptions = {}, + ): Promise { + if (!this.apiKey) { + throw new ServiceUnavailableException({ + message: "Shipbubble integration is not configured", + code: "SHIPBUBBLE_NOT_CONFIGURED", + }); + } + + let response: Response; + try { + response = await fetch(`${this.baseUrl}${path}`, { + method: options.method || "GET", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + } catch (error) { + this.logger.error( + `Shipbubble ${operation} request failed${this.safeReference( + options.safeReference, + )}`, + ); + throw new ServiceUnavailableException({ + message: "Shipbubble is temporarily unavailable", + code: "SHIPBUBBLE_UNAVAILABLE", + cause: error, + }); + } + + const payload = await this.readJson(response); + if (!response.ok) { + this.logger.error( + `Shipbubble ${operation} failed status=${response.status}${this.safeReference( + options.safeReference, + )}`, + ); + throw new BadGatewayException({ + message: "Shipbubble request failed", + code: "SHIPBUBBLE_REQUEST_FAILED", + }); + } + + return payload; + } + + private async readJson(response: Response): Promise { + try { + return (await response.json()) as unknown; + } catch { + return null; + } + } + + private toProviderAddress( + address: ShipbubbleAddress, + ): Record { + return { + name: address.name, + email: address.email, + phone: address.phone, + address: address.address, + city: address.city, + state: address.state, + country: address.country || "NG", + postal_code: address.postalCode, + }; + } + + private async validateAddress(address: ShipbubbleAddress): Promise { + const payload = await this.request( + "validate address", + "/shipping/address/validate", + { + method: "POST", + body: this.toProviderAddress(address), + }, + ); + const data = this.pickObject(payload, ["data", "address"]); + + return this.readString(data, ["address_code", "addressCode", "code"]); + } + + private toProviderParcel(parcel: ShipbubbleParcel): Record { + return { + category_id: parcel.categoryId, + package_items: [ + { + name: parcel.itemName || parcel.description || "Twizrr order", + description: parcel.description, + unit_weight: parcel.weightKg, + unit_amount: parcel.itemValueKobo?.toString(), + quantity: parcel.quantity || 1, + }, + ], + package_dimension: + parcel.lengthCm || parcel.widthCm || parcel.heightCm + ? { + length: parcel.lengthCm, + width: parcel.widthCm, + height: parcel.heightCm, + } + : undefined, + }; + } + + private toRate( + value: unknown, + requestToken: string | undefined, + ): ShipbubbleRate { + const data = this.asRecord(value); + return { + rateId: this.readString(data, ["rate_id", "id", "courier_id"]), + requestToken: + this.readOptionalString(data, ["request_token", "requestToken"]) || + requestToken, + courierName: this.readString(data, ["courier_name", "courier"]), + serviceCode: this.readOptionalString(data, ["service_code", "service"]), + amountKobo: this.readKobo(data, ["amount", "total", "price"]), + currency: this.readOptionalString(data, ["currency"]) || "NGN", + estimatedDeliveryDate: this.readOptionalString(data, [ + "estimated_delivery_date", + "delivery_date", + ]), + estimatedDeliveryWindow: this.readOptionalString(data, [ + "estimated_delivery_window", + "eta", + ]), + }; + } + + private toTrackingEvent(value: unknown): ShipbubbleTrackingEvent { + const data = this.asRecord(value); + return { + status: this.readString(data, ["status"]), + description: this.readOptionalString(data, ["description", "message"]), + location: this.readOptionalString(data, ["location"]), + occurredAt: this.readOptionalString(data, ["created_at", "occurred_at"]), + }; + } + + private pickObject(value: unknown, keys: string[]): Record { + const record = this.asRecord(value); + for (const key of keys) { + const candidate = record[key]; + if ( + candidate && + typeof candidate === "object" && + !Array.isArray(candidate) + ) { + return candidate as Record; + } + } + return record; + } + + private pickArray(value: unknown, keys: string[]): unknown[] { + const record = this.asRecord(value); + for (const key of keys) { + const candidate = record[key]; + if (Array.isArray(candidate)) { + return candidate; + } + } + return []; + } + + private asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + } + + private readString(record: Record, keys: string[]): string { + return this.readOptionalString(record, keys) || ""; + } + + private readOptionalString( + record: Record, + keys: string[], + ): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") { + return value; + } + if (typeof value === "number") { + return value.toString(); + } + } + return undefined; + } + + private readKobo(record: Record, keys: string[]): bigint { + return this.readOptionalKobo(record, keys) || 0n; + } + + private readOptionalKobo( + record: Record, + keys: string[], + ): bigint | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return BigInt(Math.round(value)); + } + if (typeof value === "string" && /^\d+$/.test(value)) { + return BigInt(value); + } + } + return undefined; + } + + private safeReference(reference: string | undefined): string { + return reference ? ` reference=${reference}` : ""; + } +} diff --git a/apps/backend/src/integrations/shipbubble/shipbubble.module.ts b/apps/backend/src/integrations/shipbubble/shipbubble.module.ts new file mode 100644 index 00000000..a5b36394 --- /dev/null +++ b/apps/backend/src/integrations/shipbubble/shipbubble.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; + +import { ShipbubbleClient } from "./shipbubble.client"; +import { ShipbubbleShippingProvider } from "./shipbubble-shipping.provider"; +import { SHIPPING_PROVIDER } from "../../domains/orders/delivery/providers/shipping-provider.interface"; + +@Module({ + providers: [ + ShipbubbleClient, + ShipbubbleShippingProvider, + { provide: SHIPPING_PROVIDER, useExisting: ShipbubbleShippingProvider }, + ], + exports: [SHIPPING_PROVIDER], +}) +export class ShipbubbleModule {} diff --git a/apps/backend/src/integrations/shipbubble/shipbubble.types.ts b/apps/backend/src/integrations/shipbubble/shipbubble.types.ts new file mode 100644 index 00000000..d03b6330 --- /dev/null +++ b/apps/backend/src/integrations/shipbubble/shipbubble.types.ts @@ -0,0 +1,64 @@ +export interface ShipbubbleAddress { + name?: string; + email?: string; + phone: string; + address: string; + city: string; + state: string; + country?: "NG" | string; + postalCode?: string; +} + +export interface ShipbubbleParcel { + weightKg: number; + categoryId?: number; + itemName?: string; + itemValueKobo?: bigint; + lengthCm?: number; + widthCm?: number; + heightCm?: number; + description?: string; + quantity?: number; +} + +export interface ShipbubbleRate { + rateId: string; + requestToken?: string; + courierName: string; + serviceCode?: string; + amountKobo: bigint; + currency: "NGN" | string; + estimatedDeliveryDate?: string; + estimatedDeliveryWindow?: string; +} + +export interface ShipbubbleBookingSelection { + rateId: string; + requestToken: string; + serviceCode?: string; +} + +export interface ShipbubbleBooking { + bookingId: string; + trackingId: string; + courierName?: string; + waybillUrl?: string; + amountKobo?: bigint; + estimatedDeliveryDate?: string; + rawStatus?: string; +} + +export interface ShipbubbleTrackingStatus { + trackingId: string; + status: string; + statusDescription?: string; + lastUpdatedAt?: string; + events: ShipbubbleTrackingEvent[]; +} + +export interface ShipbubbleTrackingEvent { + status: string; + description?: string; + location?: string; + occurredAt?: string; +} diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 3993056a..4cf93bfe 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -1,66 +1,105 @@ -// BigInt JSON serialization polyfill — required for Prisma BigInt columns -(BigInt.prototype as any).toJSON = function () { +type BigIntWithJson = bigint & { + toJSON?: (this: bigint) => string; +}; + +(BigInt.prototype as BigIntWithJson).toJSON = function (this: bigint): string { return this.toString(); }; +import { Logger as NestLogger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { NestFactory } from "@nestjs/core"; -import { AppModule } from "./app.module"; -import helmet from "helmet"; +import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; import cookieParser from "cookie-parser"; +import helmet from "helmet"; import { Logger } from "nestjs-pino"; -import { Logger as NestLogger } from "@nestjs/common"; -import { AppValidationPipe } from "./common/pipes/validation.pipe"; + +import { AppModule } from "./app.module"; +import { RedisIoAdapter } from "./domains/social/realtime/redis-io.adapter"; import { GlobalExceptionFilter } from "./common/filters/global-exception.filter"; import { ResponseTransformInterceptor } from "./common/interceptors/response-transform.interceptor"; +import { AppValidationPipe } from "./common/pipes/validation.pipe"; +import { RequestSecurityContextMiddleware } from "./common/security/request-security-context.middleware"; + +function getCorsOrigins(): string[] { + return (process.env.CORS_ORIGINS || "http://localhost:3000") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); +} -async function bootstrap() { +async function bootstrap(): Promise { const app = await NestFactory.create(AppModule, { bufferLogs: true, - rawBody: true, // Required for Paystack webhook HMAC verification + rawBody: true, }); app.useLogger(app.get(Logger)); - // Global validation pipe (whitelist + transform + formatted errors) - app.useGlobalPipes(new AppValidationPipe()); - - // Global exception filter (formats all errors including Prisma) - app.useGlobalFilters(new GlobalExceptionFilter()); - - app.useGlobalInterceptors(new ResponseTransformInterceptor()); - const isDev = process.env.NODE_ENV === "development"; - if (!isDev) { - app.use(helmet()); - } else { - // Disable some helmet features in dev that might block local browser requests - app.use( - helmet({ - contentSecurityPolicy: false, - crossOriginResourcePolicy: false, - }), - ); - } + app.use( + helmet( + isDev + ? { + contentSecurityPolicy: false, + crossOriginResourcePolicy: false, + } + : undefined, + ), + ); + + const requestSecurityContextMiddleware = app.get( + RequestSecurityContextMiddleware, + ); + app.use( + requestSecurityContextMiddleware.use.bind(requestSecurityContextMiddleware), + ); app.use(cookieParser()); - const origins = process.env.CORS_ORIGINS - ? process.env.CORS_ORIGINS.split(",").map((o) => o.trim()) - : ["http://localhost:3000", "http://127.0.0.1:3000"]; + const origins = getCorsOrigins(); app.enableCors({ - origin: isDev ? true : origins, + origin: origins, credentials: true, methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS", - allowedHeaders: "Content-Type, Accept, Authorization, X-Requested-With", + allowedHeaders: + "Content-Type, Accept, Authorization, X-Requested-With, X-Request-Id, X-Twizrr-Device-Id", }); - const port = process.env.PORT || 4000; + app.useGlobalPipes(new AppValidationPipe()); + app.useGlobalFilters(new GlobalExceptionFilter()); + app.useGlobalInterceptors(new ResponseTransformInterceptor()); + + const realtimeAdapter = new RedisIoAdapter(app, app.get(ConfigService)); + await realtimeAdapter.connectToRedis(); + app.useWebSocketAdapter(realtimeAdapter); + + if (isDev && process.env.ENABLE_SWAGGER === "true") { + const config = new DocumentBuilder() + .setTitle("Twizrr Backend API") + .setDescription( + "The official Twizrr Social Commerce API. Built for trust-protected marketplace transactions in Nigeria.", + ) + .setVersion("1.0") + .addBearerAuth() + .addTag("Twizrr") + .build(); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup("api/docs", app, document, { + swaggerOptions: { + persistAuthorization: true, + }, + customSiteTitle: "Twizrr API Documentation", + }); + } + + const port = Number.parseInt(process.env.PORT || "4000", 10); await app.listen(port, "0.0.0.0"); NestLogger.log(`Application is running on: ${await app.getUrl()}`); - if (isDev) { - NestLogger.log(`CORS enabled for: ${origins.join(", ")} (or TRUE in dev)`); - } + NestLogger.log(`CORS enabled for: ${origins.join(", ")}`); } -bootstrap(); + +void bootstrap(); diff --git a/apps/backend/src/modules/admin/admin-cron.service.ts b/apps/backend/src/modules/admin/admin-cron.service.ts deleted file mode 100644 index 69ff42e6..00000000 --- a/apps/backend/src/modules/admin/admin-cron.service.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { Cron, CronExpression } from "@nestjs/schedule"; -import { PrismaService } from "../../prisma/prisma.service"; -import { OrderStatus } from "@swifta/shared"; -import { OrderService } from "../order/order.service"; -import { NotificationTriggerService } from "../notification/notification-trigger.service"; -import { RedisService } from "../../redis/redis.service"; -import { PlatformConfig } from "../../config/platform.config"; - -@Injectable() -export class AdminCronService { - private readonly logger = new Logger(AdminCronService.name); - - constructor( - private readonly prisma: PrismaService, - private readonly orderService: OrderService, - private readonly notifications: NotificationTriggerService, - private readonly redis: RedisService, - ) {} - - @Cron(CronExpression.EVERY_HOUR) - async handleUnconfirmedDeliveries() { - const lockKey = "lock:admin-cron:unconfirmed-deliveries"; - const lockAcquired = await this.redis.set(lockKey, "locked", 3600, true); - - if (!lockAcquired) { - this.logger.log( - "Cron job handleUnconfirmedDeliveries is already running on another instance. Skipping.", - ); - return; - } - - try { - this.logger.log( - "Scanning for unconfirmed deliveries requiring action...", - ); - - const seventyTwoHoursAgo = new Date( - Date.now() - - PlatformConfig.timers.autoConfirmationHours * 60 * 60 * 1000, - ); - const twentyFourHoursAgo = new Date( - Date.now() - PlatformConfig.timers.escrowWindowHours * 60 * 60 * 1000, - ); - - const overdueOrders = await this.prisma.order.findMany({ - where: { - status: { in: [OrderStatus.DISPATCHED, OrderStatus.IN_TRANSIT] }, - dispatchedAt: { - lt: seventyTwoHoursAgo, - not: null, - }, - }, - }); - - for (const order of overdueOrders) { - try { - await this.orderService.autoConfirmDelivery(order.id); - this.logger.log( - `Auto-confirmed order ${order.id} (over ${PlatformConfig.timers.autoConfirmationHours}h since update)`, - ); - } catch (err: unknown) { - this.logger.error( - `Failed to auto-confirm order ${order.id}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - // 2. 48h & 24h Warnings - const warningCandidates = await this.prisma.order.findMany({ - where: { - status: { in: [OrderStatus.DISPATCHED, OrderStatus.IN_TRANSIT] }, - dispatchedAt: { - lt: twentyFourHoursAgo, - gt: seventyTwoHoursAgo, - not: null, - }, - }, - select: { id: true, buyerId: true, dispatchedAt: true, metadata: true }, - }); - - for (const order of warningCandidates) { - try { - if (!order.dispatchedAt) continue; - const hoursSinceUpdate = - (Date.now() - order.dispatchedAt.getTime()) / (1000 * 60 * 60); - - const metadata = (order.metadata as any) || {}; - - // Decision logic based on hours since last update - const cutoff = PlatformConfig.timers.autoConfirmationHours; - const warningThreshold1 = cutoff / 3; // e.g. 24h if 72h total - const warningThreshold2 = (cutoff * 2) / 3; // e.g. 48h if 72h total - - // Decision logic based on hours since last update - if (hoursSinceUpdate >= warningThreshold2) { - // Already past 2/3 of cutoff mark, check if this specific warning was sent - if (!metadata.autoConfirmationWarningSent48) { - const remaining = Math.max( - 0, - cutoff - Math.floor(hoursSinceUpdate), - ); - await this.notifications.triggerAutoConfirmationWarning( - order.buyerId, - order.id.slice(0, 8).toUpperCase(), - remaining, - ); - - await this.prisma.order.update({ - where: { id: order.id }, - data: { - metadata: { - ...metadata, - autoConfirmationWarningSent48: true, - }, - }, - }); - this.logger.log( - `Sent warning (${remaining}h remaining) for order ${order.id}`, - ); - } - } else if (hoursSinceUpdate >= warningThreshold1) { - // Past 1/3 but less than 2/3 mark - if (!metadata.autoConfirmationWarningSent24) { - const remaining = Math.max( - 0, - cutoff - Math.floor(hoursSinceUpdate), - ); - await this.notifications.triggerAutoConfirmationWarning( - order.buyerId, - order.id.slice(0, 8).toUpperCase(), - remaining, - ); - - await this.prisma.order.update({ - where: { id: order.id }, - data: { - metadata: { - ...metadata, - autoConfirmationWarningSent24: true, - }, - }, - }); - this.logger.log( - `Sent warning (${remaining}h remaining) for order ${order.id}`, - ); - } - } - } catch (error) { - this.logger.error( - `Failed to process warning for order ${order.id}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - } finally { - // Release lock after work or failure - await this.redis.del(lockKey); - } - } - - @Cron(CronExpression.EVERY_HOUR) - async monitorSystemHealth() { - this.logger.log("Running automated system health checks..."); - - // Rule 1: Stuck Escrow (Order PAID but not DISPATCHED for > 48h) - const fortyEightHoursAgo = new Date(Date.now() - 48 * 60 * 60 * 1000); - const stuckOrders = await this.prisma.order.findMany({ - where: { - status: OrderStatus.PAID, - updatedAt: { - lt: fortyEightHoursAgo, - }, - }, - select: { id: true, merchantId: true }, - }); - - if (stuckOrders.length > 0) { - this.logger.warn( - `CRITICAL: Found ${stuckOrders.length} orders stuck in PAID state for > 48h. Action required.`, - ); - // Mock triggering a Slack webhook / Email to operations team - } - - // Rule 2: Verification Choke (Merchants waiting > 24h) - const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); - const chokeSize = await this.prisma.merchantProfile.count({ - where: { - verificationRequests: { - some: { - status: "PENDING", - createdAt: { lt: twentyFourHoursAgo }, - }, - }, - }, - }); - - if (chokeSize > 10) { - this.logger.warn( - `SLA BREACH: ${chokeSize} merchants have been sitting in the UNVERIFIED queue for > 24h. Platform adoption blocked.`, - ); - // Mock alerting operations - } - } -} diff --git a/apps/backend/src/modules/admin/admin.controller.ts b/apps/backend/src/modules/admin/admin.controller.ts deleted file mode 100644 index 0e5cb9d0..00000000 --- a/apps/backend/src/modules/admin/admin.controller.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { - Controller, - Get, - Patch, - Delete, - Param, - UseGuards, - Body, - Req, - Query, - Post, -} from "@nestjs/common"; -import { Request } from "express"; -import { AdminService } from "./admin.service"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { RolesGuard } from "../../common/guards/roles.guard"; -import { Roles } from "../../common/decorators/roles.decorator"; -import { UserRole, OrderStatus } from "@swifta/shared"; -import { IsEnum, IsString, IsNotEmpty, IsBoolean, IsIn } from "class-validator"; - -interface AuthenticatedRequest extends Request { - user: { - sub: string; - email: string; - role: string; - }; -} - -export class CreateAccessTokenDto { - @IsEnum(UserRole) - role: UserRole; - - @IsString() - @IsNotEmpty() - token: string; -} - -export class ToggleMerchantFlagDto { - @IsIn(["cacVerified", "addressVerified", "bankVerified"]) - flag: "cacVerified" | "addressVerified" | "bankVerified"; - - @IsBoolean() - value: boolean; -} - -@Controller("admin") -@UseGuards(JwtAuthGuard, RolesGuard) -@Roles(UserRole.SUPER_ADMIN, UserRole.OPERATOR, UserRole.SUPPORT) -export class AdminController { - constructor(private readonly adminService: AdminService) {} - - @Get("stats") - @Roles(UserRole.SUPER_ADMIN) - getSystemStats() { - return this.adminService.getPlatformStats(); - } - - @Patch("merchants/:id/verify") - @Roles(UserRole.SUPER_ADMIN, UserRole.OPERATOR) - verifyMerchantProfile( - @Param("id") merchantId: string, - @Req() req: AuthenticatedRequest, - ) { - return this.adminService.verifyMerchant(merchantId, req.user.sub); - } - - @Patch("merchants/:id/reject") - @Roles(UserRole.SUPER_ADMIN, UserRole.OPERATOR) - rejectMerchantProfile( - @Param("id") merchantId: string, - @Req() req: AuthenticatedRequest, - ) { - return this.adminService.rejectMerchant( - merchantId, - undefined, - req.user.sub, - ); - } - - @Patch("merchants/:id/flags") - @Roles(UserRole.SUPER_ADMIN, UserRole.OPERATOR) - toggleMerchantFlag( - @Param("id") merchantId: string, - @Body() dto: ToggleMerchantFlagDto, - @Req() req: AuthenticatedRequest, - ) { - return this.adminService.toggleMerchantFlag( - merchantId, - dto.flag, - dto.value, - req.user.sub, - ); - } - - @Get("merchants/pending") - getPendingMerchants() { - return this.adminService.getPendingMerchants(); - } - - @Get("orders") - getAllOrders() { - return this.adminService.getAllOrders(); - } - - @Patch("orders/:id/force-resolve") - @Roles(UserRole.SUPER_ADMIN, UserRole.OPERATOR) - forceResolveOrder( - @Param("id") orderId: string, - @Body("status") status: OrderStatus, - @Req() req: AuthenticatedRequest, - ) { - return this.adminService.forceResolveOrder(orderId, status, req.user.sub); - } - - @Get("products") - getAllProducts() { - return this.adminService.getAllProducts(); - } - - @Get("analytics") - @Roles(UserRole.SUPER_ADMIN) - getGlobalAnalytics() { - return this.adminService.getGlobalAnalytics(); - } - - @Get("alerts") - getSystemAlerts() { - return this.adminService.getSystemAlerts(); - } - - @Get("orders/export") - exportOrders( - @Query("startDate") startDate?: string, - @Query("endDate") endDate?: string, - ) { - return this.adminService.exportOrders(startDate, endDate); - } - - @Post("broadcast") - @Roles(UserRole.SUPER_ADMIN) - broadcastMessage(@Body("message") message: string) { - return this.adminService.broadcastMessage(message); - } - - @Delete("products/:id") - @Roles(UserRole.SUPER_ADMIN, UserRole.OPERATOR) - deleteProduct(@Param("id") productId: string) { - return this.adminService.deleteProduct(productId); - } - - @Get("users") - getAllUsers() { - return this.adminService.getAllUsers(); - } - - @Patch("users/:id/promote") - @Roles(UserRole.SUPER_ADMIN) - promoteToAdmin( - @Param("id") userId: string, - @Body("role") role: UserRole, - @Req() req: AuthenticatedRequest, - ) { - return this.adminService.promoteToAdmin(userId, role, req.user.sub); - } - @Get("users/pending") - @Roles(UserRole.SUPER_ADMIN) - getPendingStaff() { - return this.adminService.getPendingStaff(); - } - - @Patch("staff/:id/approve") - @Roles(UserRole.SUPER_ADMIN) - approveStaff(@Param("id") staffId: string, @Req() req: AuthenticatedRequest) { - return this.adminService.approveStaff(staffId, req.user.sub); - } - - @Delete("users/:id") - @Roles(UserRole.SUPER_ADMIN) - deleteUser(@Param("id") userId: string, @Req() req: AuthenticatedRequest) { - return this.adminService.deleteUser(userId, req.user.sub); - } - - @Patch("staff/:id/suspend") - @Roles(UserRole.SUPER_ADMIN) - suspendStaff(@Param("id") staffId: string, @Req() req: AuthenticatedRequest) { - return this.adminService.suspendStaff(staffId, req.user.sub); - } - - @Patch("staff/:id/reactivate") - @Roles(UserRole.SUPER_ADMIN) - reactivateStaff( - @Param("id") staffId: string, - @Req() req: AuthenticatedRequest, - ) { - return this.adminService.reactivateStaff(staffId, req.user.sub); - } - - @Patch("change-password") - changePassword( - @Body("currentPassword") currentPassword: string, - @Body("newPassword") newPassword: string, - @Req() req: AuthenticatedRequest, - ) { - return this.adminService.changePassword( - req.user.sub, - currentPassword, - newPassword, - ); - } - - // ─── Payout Management ─── - - @Get("payouts") - @Roles(UserRole.SUPER_ADMIN, UserRole.OPERATOR) - getPendingPayouts() { - return this.adminService.getPendingPayouts(); - } - - @Patch("payouts/:id/process") - @Roles(UserRole.SUPER_ADMIN, UserRole.OPERATOR) - processPayout( - @Param("id") payoutId: string, - @Req() req: AuthenticatedRequest, - ) { - return this.adminService.processPayout(payoutId, req.user.sub); - } - - // ─── Staff Access Token Management ─── - - @Get("access-tokens") - @Roles(UserRole.SUPER_ADMIN) - getAccessTokens() { - return this.adminService.getAccessTokens(); - } - - @Post("access-tokens") - @Roles(UserRole.SUPER_ADMIN) - createAccessToken( - @Body() dto: CreateAccessTokenDto, - @Req() req: AuthenticatedRequest, - ) { - return this.adminService.createAccessToken( - dto.role, - dto.token, - req.user.sub, - ); - } - - @Delete("access-tokens/:id") - @Roles(UserRole.SUPER_ADMIN) - revokeAccessToken( - @Param("id") tokenId: string, - @Req() req: AuthenticatedRequest, - ) { - return this.adminService.revokeAccessToken(tokenId, req.user.sub); - } - - // ─── Supplier Management (E: Admin creates supplier accounts) ─── - - @Post("suppliers/create") - @Roles(UserRole.SUPER_ADMIN, UserRole.OPERATOR) - async createSupplierAccount( - @Body() - dto: { - firstName: string; - lastName: string; - email: string; - phone: string; - password: string; - companyName: string; - companyAddress: string; - cacNumber?: string; - }, - @Req() req: AuthenticatedRequest, - ) { - return this.adminService.createSupplierAccount(dto, req.user.sub); - } - - @Get("suppliers") - @Roles(UserRole.SUPER_ADMIN, UserRole.OPERATOR) - listSuppliers(@Query("verified") verified?: string) { - const isVerified = - verified === "true" ? true : verified === "false" ? false : undefined; - return this.adminService.listSuppliers(isVerified); - } - - @Patch("suppliers/:id/verify") - @Roles(UserRole.SUPER_ADMIN, UserRole.OPERATOR) - verifySupplier( - @Param("id") supplierId: string, - @Req() req: AuthenticatedRequest, - ) { - return this.adminService.verifySupplier(supplierId, req.user.sub); - } -} diff --git a/apps/backend/src/modules/admin/admin.module.ts b/apps/backend/src/modules/admin/admin.module.ts deleted file mode 100644 index 682ccc79..00000000 --- a/apps/backend/src/modules/admin/admin.module.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Module, forwardRef } from "@nestjs/common"; -import { PrismaModule } from "../../prisma/prisma.module"; -import { AdminController } from "./admin.controller"; -import { AdminService } from "./admin.service"; -import { AdminCronService } from "./admin-cron.service"; -import { AuditLogService } from "./audit-log.service"; -import { AuditLogController } from "./audit-log.controller"; -import { AuthModule } from "../auth/auth.module"; -import { VerificationModule } from "../verification/verification.module"; - -import { OrderModule } from "../order/order.module"; - -@Module({ - imports: [ - PrismaModule, - forwardRef(() => AuthModule), - VerificationModule, - OrderModule, - ], - controllers: [AdminController, AuditLogController], - providers: [AdminService, AdminCronService, AuditLogService], - exports: [AdminService, AuditLogService], -}) -export class AdminModule {} diff --git a/apps/backend/src/modules/admin/admin.service.ts b/apps/backend/src/modules/admin/admin.service.ts deleted file mode 100644 index b543444b..00000000 --- a/apps/backend/src/modules/admin/admin.service.ts +++ /dev/null @@ -1,1071 +0,0 @@ -import { - Injectable, - NotFoundException, - BadRequestException, - Logger, -} from "@nestjs/common"; -import { PrismaService } from "../../prisma/prisma.service"; -import { OrderStatus, UserRole, VerificationTier } from "@swifta/shared"; -import * as bcrypt from "bcrypt"; -import { RedisService } from "../../redis/redis.service"; -import { AuditLogService } from "./audit-log.service"; -import { VerificationService } from "../verification/verification.service"; - -import { NotificationTriggerService } from "../notification/notification-trigger.service"; - -@Injectable() -export class AdminService { - private readonly logger = new Logger(AdminService.name); - - constructor( - private prisma: PrismaService, - private redis: RedisService, - private notifications: NotificationTriggerService, - private auditLog: AuditLogService, - private verificationService: VerificationService, - ) {} - - async getPlatformStats() { - // Parallelize these independent global queries for speed - const [ - totalMerchants, - verifiedMerchants, - pendingVerificationCount, - totalBuyers, - totalOrders, - totalRevenueKobo, - ] = await Promise.all([ - this.prisma.merchantProfile.count(), - this.prisma.merchantProfile.count({ - where: { - verificationTier: { - in: [VerificationTier.TIER_2, VerificationTier.TIER_3], - }, - }, - }), - this.prisma.merchantProfile.count({ - where: { - verificationRequests: { some: { status: "PENDING" } }, - }, - }), - this.prisma.user.count({ where: { role: "BUYER" } }), - this.prisma.order.count(), - this.prisma.payment.aggregate({ - _sum: { amountKobo: true }, - where: { status: "SUCCESS", direction: "INFLOW" }, - }), - ]); - - return { - totalMerchants, - verifiedMerchants, - pendingMerchants: pendingVerificationCount, - totalBuyers, - totalUsers: totalMerchants + totalBuyers, - totalOrders, - totalRevenueKobo: totalRevenueKobo._sum.amountKobo || BigInt(0), - }; - } - - async verifyMerchant(merchantId: string, adminId: string) { - // 1. Find the merchant - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - include: { - verificationRequests: { - where: { status: "PENDING" }, - orderBy: { createdAt: "desc" }, - take: 1, - }, - }, - }); - - if (!merchant) { - throw new NotFoundException("Merchant profile not found"); - } - - // 2. Locate the pending request - const pendingRequest = merchant.verificationRequests[0]; - if (!pendingRequest) { - // Fallback: If no pending request, just update the tier directly (legacy/manual flow) - return this.prisma.merchantProfile.update({ - where: { id: merchantId }, - data: { - verificationTier: VerificationTier.TIER_2, - verifiedAt: new Date(), - }, - }); - } - - // 3. Use VerificationService to perform atomic transition - return this.verificationService.reviewRequest(pendingRequest.id, adminId, { - decision: "APPROVED", - }); - } - - async rejectMerchant(merchantId: string, reason?: string, adminId?: string) { - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - }); - - if (!merchant) { - throw new NotFoundException("Merchant profile not found"); - } - - const updated = await this.prisma.merchantProfile.update({ - where: { id: merchantId }, - data: { updatedAt: new Date() }, - }); - - await this.notifications.triggerMerchantRejected(merchant.userId, reason); - - if (adminId) { - await this.auditLog.log( - adminId, - "REJECT_MERCHANT", - "MerchantProfile", - merchantId, - { reason }, - ); - } - - return updated; - } - - async getPendingMerchants() { - // Fetch all merchants who have at least one verification request - const merchants = await this.prisma.merchantProfile.findMany({ - where: { - verificationRequests: { - some: {}, - }, - }, - include: { - user: { - select: { - email: true, - firstName: true, - lastName: true, - phone: true, - }, - }, - verificationRequests: { - orderBy: { createdAt: "desc" }, - take: 1, - }, - }, - orderBy: { - createdAt: "asc", - }, - }); - - // Filter to only merchants whose LATEST request is PENDING or REJECTED - return merchants.filter((m) => { - const latest = m.verificationRequests[0]; - return ( - latest && (latest.status === "PENDING" || latest.status === "REJECTED") - ); - }); - } - - async getAllOrders() { - const orders = await this.prisma.order.findMany({ - include: { - merchantProfile: { - select: { businessName: true }, - }, - user: { - select: { firstName: true, lastName: true, email: true }, - }, - product: { select: { name: true, unit: true } }, - }, - orderBy: { createdAt: "desc" }, - }); - - orders.forEach((order) => { - order.deliveryOtp = null; - }); - - return orders; - } - - async forceResolveOrder( - orderId: string, - status: OrderStatus, - adminUserId: string, - ) { - const order = await this.prisma.order.findUnique({ - where: { id: orderId }, - }); - - if (!order) { - throw new NotFoundException("Order not found"); - } - - return this.prisma.$transaction(async (tx) => { - const updatedOrder = await tx.order.update({ - where: { id: orderId }, - data: { status }, - }); - - await tx.orderEvent.create({ - data: { - orderId, - fromStatus: order.status, - toStatus: status, - triggeredBy: adminUserId, - metadata: { - description: "Order manually overridden by System Administrator", - }, - }, - }); - - return updatedOrder; - }); - } - - async deleteProduct(productId: string) { - const product = await this.prisma.product.findUnique({ - where: { id: productId }, - }); - - if (!product) { - throw new NotFoundException("Product not found in the Catalogue"); - } - - return this.prisma.product.update({ - where: { id: productId }, - data: { - isActive: false, - deletedAt: new Date(), - }, - }); - } - - async getAllProducts() { - return this.prisma.product.findMany({ - include: { - merchantProfile: { - select: { businessName: true }, - }, - }, - orderBy: { createdAt: "desc" }, - }); - } - - async getGlobalAnalytics() { - const [totalOrders] = await Promise.all([this.prisma.order.count()]); - - // Financial calculations - const gmvAggregate = await this.prisma.order.aggregate({ - where: { - status: { in: ["PAID", "DISPATCHED", "DELIVERED"] }, - }, - _sum: { totalAmountKobo: true }, - }); - - const escrowAggregate = await this.prisma.order.aggregate({ - where: { - status: { in: ["PAID", "DISPATCHED"] }, - }, - _sum: { totalAmountKobo: true }, - }); - - // Market Intelligence: Top Requested Categories via Orders - const topCategoriesRaw = await this.prisma.order.findMany({ - select: { - product: { - select: { categoryTag: true }, - }, - }, - }); - - // Reduce into category counts - const categoryCounts: Record = {}; - topCategoriesRaw.forEach((order) => { - const tag = order.product?.categoryTag || "Unknown"; - categoryCounts[tag] = (categoryCounts[tag] || 0) + 1; - }); - - const topCategories = Object.entries(categoryCounts) - .sort(([, a], [, b]) => b - a) - .slice(0, 5) - .map(([name, value]) => ({ name, value })); - - return { - funnel: { - totalOrders, - }, - financials: { - gmvKobo: Number(gmvAggregate._sum.totalAmountKobo || 0), - escrowLiabilityKobo: Number(escrowAggregate._sum.totalAmountKobo || 0), - }, - market: { - topCategories, - }, - }; - } - - async getSystemAlerts() { - const alerts = []; - - // Check 1: Stuck Escrow - const fortyEightHoursAgo = new Date(Date.now() - 48 * 60 * 60 * 1000); - const stuckOrders = await this.prisma.order.count({ - where: { - status: OrderStatus.PAID, - updatedAt: { lt: fortyEightHoursAgo }, - }, - }); - - if (stuckOrders > 0) { - alerts.push({ - id: "escrow-timeout", - type: "CRITICAL", - title: "Escrow Timeout Warning", - message: `${stuckOrders} orders have been stuck in PAID for over 48 hours without dispatch.`, - actionLink: "/admin/orders?filter=stuck", - }); - } - - // Check 2: Verification Choke - const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); - const chokeSize = await this.prisma.merchantProfile.count({ - where: { - verificationRequests: { - some: { - status: "PENDING", - createdAt: { lt: twentyFourHoursAgo }, - }, - }, - }, - }); - - if (chokeSize > 0) { - alerts.push({ - id: "verification-choke", - type: "WARNING", - title: "Verification Queue Bloat", - message: `${chokeSize} merchants have been waiting > 24 hours for approval.`, - actionLink: "/admin/merchants", - }); - } - - // Check 3: Pending Payout Requests - const pendingPayouts = await this.prisma.payoutRequest.count({ - where: { - status: "PENDING", - createdAt: { lt: twentyFourHoursAgo }, - }, - }); - - if (pendingPayouts > 0) { - alerts.push({ - id: "payout-delay", - type: "WARNING", - title: "Delayed Payout Processing", - message: `${pendingPayouts} payout requests have been waiting > 24 hours for processing.`, - actionLink: "/admin/payouts", - }); - } - - return alerts; - } - - async exportOrders(startDate?: string, endDate?: string) { - const whereClause: any = { status: OrderStatus.DELIVERED }; - - if (startDate || endDate) { - whereClause.createdAt = {}; - if (startDate) whereClause.createdAt.gte = new Date(startDate); - if (endDate) whereClause.createdAt.lte = new Date(endDate); - } - - const orders = await this.prisma.order.findMany({ - where: whereClause, - include: { - merchantProfile: { select: { businessName: true } }, - user: { select: { firstName: true, lastName: true } }, - }, - orderBy: { createdAt: "desc" }, - }); - - // Generate CSV String - const headers = [ - "Order ID", - "Date", - "Merchant", - "Buyer", - "Total Kobo", - "Delivery Kobo", - "Status", - ]; - const rows = orders.map((o) => [ - o.id, - o.createdAt.toISOString(), - `"${o.merchantProfile?.businessName}"`, - `"${[o.user?.firstName, o.user?.lastName].filter(Boolean).join(" ")}"`, - o.totalAmountKobo.toString(), - o.deliveryFeeKobo.toString(), - o.status, - ]); - - const csvContent = [headers.join(",")] - .concat(rows.map((row) => row.join(","))) - .join("\n"); - - return csvContent; - } - - async broadcastMessage(message: string) { - const verifiedMerchants = await this.prisma.merchantProfile.findMany({ - where: { - verificationTier: { - in: [VerificationTier.TIER_2, VerificationTier.TIER_3], - }, - }, - include: { user: true }, - }); - - const phoneNumbers = verifiedMerchants - .map((m) => m.user?.phone) - .filter((phone): phone is string => !!phone); - - // MOCK: Integration with Email / In-App Notification API - this.logger.log( - `[BROADCAST QUEUE] Transmitting message to ${phoneNumbers.length} merchants.`, - ); - this.logger.log(`[PAYLOAD]: ${message}`); - - return { - success: true, - deliveredTo: phoneNumbers.length, - message: "Broadcast initiated successfully", - }; - } - - async getAllUsers() { - return this.prisma.user.findMany({ - where: { deletedAt: null }, - select: { - id: true, - email: true, - phone: true, - firstName: true, - middleName: true, - lastName: true, - role: true, - emailVerified: true, - createdAt: true, - merchantProfile: { - select: { verificationTier: true }, - }, - adminProfile: { - select: { approvalStatus: true }, - }, - }, - orderBy: { createdAt: "desc" }, - }); - } - - async promoteToAdmin( - userId: string, - targetRole: UserRole, - requestingAdminId: string, - ) { - if (userId === requestingAdminId) { - throw new BadRequestException("You cannot modify your own role."); - } - - if ( - ![UserRole.SUPER_ADMIN, UserRole.OPERATOR, UserRole.SUPPORT].includes( - targetRole, - ) - ) { - throw new BadRequestException( - "Invalid target role. Must be SUPER_ADMIN, OPERATOR, or SUPPORT.", - ); - } - - const user = await this.prisma.user.findUnique({ - where: { id: userId }, - include: { adminProfile: true }, - }); - if (!user) throw new NotFoundException("User not found."); - - return this.prisma.$transaction(async (tx) => { - // 1. Update the User role - const updatedUser = await tx.user.update({ - where: { id: userId }, - data: { role: targetRole as any }, - select: { - id: true, - email: true, - firstName: true, - lastName: true, - role: true, - }, - }); - - // 2. Map logical role to integer access level - const accessLevelText = - targetRole === UserRole.SUPER_ADMIN - ? "HIGH" - : targetRole === UserRole.OPERATOR - ? "MEDIUM" - : "LOW"; - - // 3. Upsert the AdminProfile to attach the new staff schema - await tx.adminProfile.upsert({ - where: { userId: userId }, - update: { - accessLevel: accessLevelText, - department: "Internal Operations", - }, - create: { - userId: userId, - accessLevel: accessLevelText, - department: "Internal Operations", - }, - }); - - await this.auditLog.log( - requestingAdminId, - "PROMOTE_ADMIN", - "User", - userId, - { targetRole }, - ); - - this.logger.log( - `User ${updatedUser.email} granted role ${targetRole} by admin ${requestingAdminId}`, - ); - return updatedUser; - }); - } - - async deleteUser(userId: string, requestingAdminId: string) { - if (userId === requestingAdminId) { - throw new NotFoundException("You cannot delete your own account."); - } - - const user = await this.prisma.user.findUnique({ where: { id: userId } }); - if (!user) throw new NotFoundException("User not found."); - if (user.role === UserRole.SUPER_ADMIN) { - throw new NotFoundException( - "Cannot delete another admin. Demote them first.", - ); - } - - // Soft delete the user - await this.prisma.user.update({ - where: { id: userId }, - data: { deletedAt: new Date() }, - }); - - await this.auditLog.log(requestingAdminId, "DELETE_USER", "User", userId); - - this.logger.warn( - `User ${user.email} (${userId}) soft-deleted by admin ${requestingAdminId}`, - ); - return { success: true, message: `User ${user.email} has been removed.` }; - } - - async changePassword( - adminUserId: string, - currentPassword: string, - newPassword: string, - ) { - const user = await this.prisma.user.findUnique({ - where: { id: adminUserId }, - }); - if (!user) throw new NotFoundException("Admin user not found."); - - // Verify current password - const isValid = await bcrypt.compare(currentPassword, user.passwordHash); - if (!isValid) { - throw new BadRequestException("Current password is incorrect."); - } - - // Hash new password and update - const SALT_ROUNDS = 10; - const passwordHash = await bcrypt.hash(newPassword, SALT_ROUNDS); - - await this.prisma.user.update({ - where: { id: adminUserId }, - data: { passwordHash }, - }); - - // Invalidate refresh tokens for this user globally - await this.redis.del(`refresh_token:${adminUserId}`); - - this.logger.log(`Admin ${user.email} changed their password.`); - return { success: true, message: "Password updated successfully." }; - } - - // ─── Staff Access Token Management ─── - - async getAccessTokens() { - const tokens = await this.prisma.staffAccessToken.findMany({ - select: { - id: true, - role: true, - label: true, - isActive: true, - createdAt: true, - updatedAt: true, - user: { select: { firstName: true, lastName: true, email: true } }, - }, - orderBy: [{ isActive: "desc" }, { createdAt: "desc" }], - }); - return tokens; - } - - async createAccessToken( - role: UserRole, - plainToken: string, - adminUserId: string, - ) { - if (![UserRole.OPERATOR, UserRole.SUPPORT].includes(role)) { - throw new BadRequestException( - "Access tokens can only be created for OPERATOR or SUPPORT roles.", - ); - } - - const admin = await this.prisma.user.findUnique({ - where: { id: adminUserId }, - }); - if (!admin) throw new NotFoundException("Admin user not found."); - - const SALT_ROUNDS = 10; - const tokenHash = await bcrypt.hash(plainToken, SALT_ROUNDS); - - // Deactivate any existing active tokens for this role - await this.prisma.staffAccessToken.updateMany({ - where: { role: role as any, isActive: true }, - data: { isActive: false }, - }); - - // Create the new active token - const token = await this.prisma.staffAccessToken.create({ - data: { - role: role as any, - tokenHash, - label: `${role} Token`, - createdBy: adminUserId, - }, - select: { - id: true, - role: true, - label: true, - createdAt: true, - }, - }); - - this.logger.log( - `New access token created for role ${role} by admin ${adminUserId}`, - ); - return token; - } - - async revokeAccessToken(tokenId: string, adminUserId: string) { - const token = await this.prisma.staffAccessToken.findUnique({ - where: { id: tokenId }, - }); - if (!token) throw new NotFoundException("Access token not found."); - - await this.prisma.staffAccessToken.update({ - where: { id: tokenId }, - data: { isActive: false }, - }); - - await this.auditLog.log( - adminUserId, - "REVOKE_STAFF_TOKEN", - "StaffAccessToken", - tokenId, - { role: token.role }, - ); - - this.logger.warn( - `Access token ${tokenId} (${token.role}) revoked by admin ${adminUserId}`, - ); - return { - success: true, - message: `${token.role} access token has been revoked.`, - }; - } - - async verifyStaffToken( - userId: string, - userRole: UserRole, - plainToken: string, - ) { - const activeToken = await this.prisma.staffAccessToken.findFirst({ - where: { role: userRole as any, isActive: true }, - }); - - if (!activeToken) { - throw new BadRequestException( - "No active access token exists for your role. Contact your Super Admin.", - ); - } - - const isValid = await bcrypt.compare(plainToken, activeToken.tokenHash); - if (!isValid) { - throw new BadRequestException( - "Invalid access token. Please try again or contact your Super Admin.", - ); - } - - return { verified: true }; - } - - async getPendingStaff() { - return this.prisma.user.findMany({ - where: { - adminProfile: { approvalStatus: "PENDING" }, - }, - select: { - id: true, - firstName: true, - lastName: true, - email: true, - role: true, - createdAt: true, - adminProfile: { - select: { approvalStatus: true }, - }, - }, - orderBy: { createdAt: "desc" }, - }); - } - - async approveStaff(staffId: string, adminId: string) { - const user = await this.prisma.user.findUnique({ - where: { id: staffId }, - include: { adminProfile: true }, - }); - - if (!user || !user.adminProfile) { - throw new NotFoundException("Staff profile not found."); - } - - if (user.adminProfile.approvalStatus !== "PENDING") { - throw new BadRequestException( - `Staff status is ${user.adminProfile.approvalStatus}, only PENDING can be approved.`, - ); - } - - await this.prisma.adminProfile.update({ - where: { userId: staffId }, - data: { approvalStatus: "APPROVED" }, - }); - - await this.auditLog.log(adminId, "APPROVE_STAFF", "AdminProfile", staffId); - - this.logger.log(`Staff ${staffId} approved by admin ${adminId}`); - return { success: true, message: "Staff member approved successfully." }; - } - - // ─── Payout Management ─── - - async getPendingPayouts() { - // Also including PROCESSING for visibility, or we can just fetch all non-PROCESSED - return this.prisma.payoutRequest.findMany({ - where: { - status: { - in: ["PENDING", "PROCESSING"], - }, - }, - include: { - merchantProfile: { - select: { - businessName: true, - user: { - select: { email: true, phone: true }, - }, - }, - }, - }, - orderBy: { createdAt: "asc" }, - }); - } - - async processPayout(payoutId: string, adminUserId: string) { - const result = await this.prisma.payoutRequest.updateMany({ - where: { - id: payoutId, - status: { not: "PROCESSED" }, - }, - data: { - status: "PROCESSED", - processedAt: new Date(), - }, - }); - - if (result.count === 0) { - throw new BadRequestException( - "Payout request not found or already processed.", - ); - } - - await this.auditLog.log( - adminUserId, - "PROCESS_PAYOUT", - "PayoutRequest", - payoutId, - ); - - this.logger.log( - `Payout request ${payoutId} manually marked as PROCESSED by admin ${adminUserId}`, - ); - - return { - success: true, - message: "Payout marked as processed successfully.", - payout: { - id: payoutId, - status: "PROCESSED", - }, - }; - } - - async toggleMerchantFlag( - merchantId: string, - flag: "cacVerified" | "addressVerified" | "bankVerified", - value: boolean, - adminId?: string, - ) { - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - }); - - if (!merchant) { - throw new NotFoundException("Merchant profile not found"); - } - - const updated = await this.prisma.merchantProfile.update({ - where: { id: merchantId }, - data: { [flag]: value }, - }); - - if (adminId) { - await this.auditLog.log( - adminId, - "TOGGLE_MERCHANT_FLAG", - "MerchantProfile", - merchantId, - { flag, value }, - ); - } - - return updated; - } - - // ─── Staff Suspend / Reactivate ─── - - async suspendStaff(staffId: string, adminId: string) { - // Prevent self-suspend - if (staffId === adminId) { - throw new BadRequestException("You cannot suspend your own account."); - } - - const user = await this.prisma.user.findUnique({ - where: { id: staffId }, - include: { adminProfile: true }, - }); - - if (!user || !user.adminProfile) { - throw new NotFoundException("Staff profile not found."); - } - - // Prevent suspending SUPER_ADMIN - if (user.role === "SUPER_ADMIN") { - throw new BadRequestException("Cannot suspend a Super Admin."); - } - - // Only allow suspending APPROVED staff (prevents PENDING -> SUSPENDED -> APPROVED bypass) - if (user.adminProfile.approvalStatus !== "APPROVED") { - throw new BadRequestException( - `Staff status is ${user.adminProfile.approvalStatus}, only APPROVED staff can be suspended.`, - ); - } - - await this.prisma.adminProfile.update({ - where: { userId: staffId }, - data: { approvalStatus: "SUSPENDED" }, - }); - - // Revoke refresh token so existing sessions are invalidated - try { - await this.redis.del(`refresh_token:${staffId}`); - this.logger.log(`Refresh token revoked for suspended staff ${staffId}`); - } catch (err) { - this.logger.error( - `Failed to revoke refresh token for ${staffId}: ${err}`, - ); - } - - await this.auditLog.log(adminId, "SUSPEND_STAFF", "AdminProfile", staffId); - - this.logger.log(`Staff ${staffId} suspended by admin ${adminId}`); - return { success: true, message: "Staff member has been suspended." }; - } - - async reactivateStaff(staffId: string, adminId: string) { - const user = await this.prisma.user.findUnique({ - where: { id: staffId }, - include: { adminProfile: true }, - }); - - if (!user || !user.adminProfile) { - throw new NotFoundException("Staff profile not found."); - } - - if (user.adminProfile.approvalStatus !== "SUSPENDED") { - throw new BadRequestException( - `Staff status is ${user.adminProfile.approvalStatus}, only SUSPENDED can be reactivated.`, - ); - } - - await this.prisma.adminProfile.update({ - where: { userId: staffId }, - data: { approvalStatus: "APPROVED" }, - }); - - await this.auditLog.log( - adminId, - "REACTIVATE_STAFF", - "AdminProfile", - staffId, - ); - - this.logger.log(`Staff ${staffId} reactivated by admin ${adminId}`); - return { success: true, message: "Staff member has been reactivated." }; - } - - // ─── Supplier Management (E: Admin-Invite-Only Onboarding) ─── - - async createSupplierAccount( - dto: { - firstName: string; - lastName: string; - email: string; - phone: string; - password: string; - companyName: string; - companyAddress: string; - cacNumber?: string; - }, - adminId: string, - ) { - // Validate unique email and phone - const existing = await this.prisma.user.findFirst({ - where: { OR: [{ email: dto.email }, { phone: dto.phone }] }, - }); - if (existing) { - throw new BadRequestException( - "An account with this email or phone already exists.", - ); - } - - const SALT_ROUNDS = 10; - const passwordHash = await bcrypt.hash(dto.password, SALT_ROUNDS); - - const user = await this.prisma.$transaction(async (tx) => { - const newUser = await tx.user.create({ - data: { - email: dto.email, - phone: dto.phone, - passwordHash, - role: "SUPPLIER", - firstName: dto.firstName, - lastName: dto.lastName, - emailVerified: true, // Admin-created accounts are pre-verified - }, - }); - - await tx.supplierProfile.create({ - data: { - userId: newUser.id, - companyName: dto.companyName, - companyAddress: dto.companyAddress, - cacNumber: dto.cacNumber, - isVerified: false, // Admin must explicitly verify after reviewing docs - }, - }); - - return newUser; - }); - - await this.auditLog.log(adminId, "CREATE_SUPPLIER", "User", user.id, { - email: dto.email, - companyName: dto.companyName, - }); - - this.logger.log( - `Supplier account created by admin ${adminId}: ${dto.email} (${dto.companyName})`, - ); - - return { - success: true, - userId: user.id, - email: user.email, - message: - "Supplier account created. They can now log in and list products.", - }; - } - - async listSuppliers(isVerified?: boolean) { - return this.prisma.supplierProfile.findMany({ - where: isVerified !== undefined ? { isVerified } : {}, - include: { - user: { - select: { - id: true, - email: true, - phone: true, - firstName: true, - lastName: true, - createdAt: true, - }, - }, - _count: { - select: { products: true, orders: true }, - }, - }, - orderBy: { createdAt: "desc" }, - }); - } - - async verifySupplier(supplierId: string, adminId: string) { - const profile = await this.prisma.supplierProfile.findUnique({ - where: { id: supplierId }, - }); - if (!profile) { - throw new NotFoundException("Supplier profile not found."); - } - - await this.prisma.supplierProfile.update({ - where: { id: supplierId }, - data: { isVerified: true }, - }); - - await this.auditLog.log( - adminId, - "VERIFY_SUPPLIER", - "SupplierProfile", - supplierId, - ); - - this.logger.log( - `Supplier ${supplierId} (${profile.companyName}) verified by admin ${adminId}`, - ); - - return { - success: true, - message: `${profile.companyName} has been verified and can now be seen in the merchant catalogue.`, - }; - } -} diff --git a/apps/backend/src/modules/auth/auth.controller.ts b/apps/backend/src/modules/auth/auth.controller.ts deleted file mode 100644 index ce3fe670..00000000 --- a/apps/backend/src/modules/auth/auth.controller.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { - Controller, - Post, - Patch, - Body, - UseGuards, - HttpCode, - HttpStatus, - Res, - Get, - Req, -} from "@nestjs/common"; -import type { Response, Request } from "express"; -import { Throttle } from "@nestjs/throttler"; -import { AuthService } from "./auth.service"; -import { RegisterDto } from "./dto/register.dto"; -import { LoginDto } from "./dto/login.dto"; -import { RefreshTokenDto } from "./dto/refresh-token.dto"; -import { VerifyEmailDto } from "./dto/verify-email.dto"; -import { ResendVerificationDto } from "./dto/resend-verification.dto"; -import { SendPhoneOtpDto } from "./dto/send-phone-otp.dto"; -import { VerifyPhoneOtpDto } from "./dto/verify-phone-otp.dto"; -import { ForgotPasswordDto } from "./dto/forgot-password.dto"; -import { ResetPasswordDto } from "./dto/reset-password.dto"; -import { UpdateProfileDto } from "./dto/update-profile.dto"; -import { JwtRefreshGuard } from "../../common/guards/jwt-refresh.guard"; -import { CurrentUser } from "../../common/decorators/current-user.decorator"; -import { JwtPayload } from "@swifta/shared"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; - -@Controller("auth") -export class AuthController { - constructor(private readonly authService: AuthService) {} - - private setCookies( - res: Response, - tokens: { accessToken: string; refreshToken: string }, - ) { - const isProd = process.env.NODE_ENV === "production"; - res.cookie("swifta_access_token", tokens.accessToken, { - httpOnly: true, - secure: isProd, - sameSite: isProd ? "none" : false, - maxAge: 15 * 60 * 1000, - path: "/", - }); - res.cookie("swifta_refresh_token", tokens.refreshToken, { - httpOnly: true, - secure: isProd, - sameSite: isProd ? "none" : false, - maxAge: 7 * 24 * 60 * 60 * 1000, - path: "/", - }); - } - - private clearCookies(res: Response) { - const isProd = process.env.NODE_ENV === "production"; - const cookieOptions = { - httpOnly: true, - secure: isProd, - sameSite: isProd ? ("none" as const) : false, - path: "/", - }; - res.clearCookie("swifta_access_token", cookieOptions); - res.clearCookie("swifta_refresh_token", cookieOptions); - } - - @Throttle({ default: { limit: 5, ttl: 60000 } }) - @Post("register") - async register( - @Body() dto: RegisterDto, - @Res({ passthrough: true }) res: Response, - ) { - const result = await this.authService.register(dto); - this.setCookies(res, result); - return { user: result.user }; - } - - @Throttle({ default: { limit: 5, ttl: 60000 } }) - @Post("login") - @HttpCode(HttpStatus.OK) - async login( - @Body() dto: LoginDto, - @Res({ passthrough: true }) res: Response, - ) { - const result = await this.authService.login(dto); - this.setCookies(res, result); - return { user: result.user }; - } - - @Throttle({ default: { limit: 3, ttl: 60000 } }) - @Post("verify-email") - @HttpCode(HttpStatus.OK) - async verifyEmail(@Body() dto: VerifyEmailDto) { - return this.authService.verifyEmail(dto); - } - - @Throttle({ default: { limit: 3, ttl: 60000 } }) - @Post("resend-verification") - @HttpCode(HttpStatus.OK) - async resendVerification(@Body() dto: ResendVerificationDto) { - return this.authService.resendVerification(dto); - } - - @UseGuards(JwtAuthGuard) - @Throttle({ default: { limit: 3, ttl: 60000 } }) - @Post("send-phone-otp") - @HttpCode(HttpStatus.OK) - async sendPhoneOtp( - @CurrentUser() user: JwtPayload, - @Body() dto: SendPhoneOtpDto, - ) { - return this.authService.sendPhoneOtp(dto, user.sub); - } - - @Throttle({ default: { limit: 3, ttl: 60000 } }) - @Post("whatsapp/initiate") - @HttpCode(HttpStatus.OK) - async initiateWhatsAppLogin(@Body() dto: SendPhoneOtpDto) { - return this.authService.initiateWhatsAppLogin(dto.phone); - } - - @Throttle({ default: { limit: 5, ttl: 60000 } }) - @Post("whatsapp/verify") - @HttpCode(HttpStatus.OK) - async verifyWhatsAppLogin( - @Body() dto: VerifyPhoneOtpDto, - @Res({ passthrough: true }) res: Response, - ) { - const result = await this.authService.verifyWhatsAppLogin( - dto.phone, - dto.code, - ); - this.setCookies(res, result); - return { user: result.user }; - } - - @UseGuards(JwtAuthGuard) - @Throttle({ default: { limit: 3, ttl: 60000 } }) - @Post("verify-phone-otp") - @HttpCode(HttpStatus.OK) - async verifyPhoneOtp( - @CurrentUser() user: JwtPayload, - @Body() dto: VerifyPhoneOtpDto, - ) { - return this.authService.verifyPhoneOtp(dto, user.sub); - } - - @UseGuards(JwtAuthGuard) - @Throttle({ default: { limit: 3, ttl: 60000 } }) - @Post("whatsapp/link") - @HttpCode(HttpStatus.OK) - async initiateWhatsAppLink( - @CurrentUser() user: JwtPayload, - @Body() dto: SendPhoneOtpDto, - ) { - return this.authService.initiateWhatsAppLink(user.sub, dto.phone); - } - - @UseGuards(JwtAuthGuard) - @Throttle({ default: { limit: 5, ttl: 60000 } }) - @Post("whatsapp/link/verify") - @HttpCode(HttpStatus.OK) - async verifyWhatsAppLink( - @CurrentUser() user: JwtPayload, - @Body() dto: VerifyPhoneOtpDto, - ) { - return this.authService.verifyWhatsAppLink(user.sub, dto.phone, dto.code); - } - - @Post("refresh") - @UseGuards(JwtRefreshGuard) - @HttpCode(HttpStatus.OK) - async refresh( - @CurrentUser() user: any, - @Body() dto: RefreshTokenDto, - @Req() req: Request, - @Res({ passthrough: true }) res: Response, - ) { - // Prefer HttpOnly cookie first, fallback to DTO body - const refreshToken = - (req.cookies as Record)?.["swifta_refresh_token"] || - dto.refreshToken; - const result = await this.authService.refreshTokens(user.sub, refreshToken); - this.setCookies(res, result); - return { success: true }; - } - - @Post("logout") - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - async logout( - @CurrentUser() user: JwtPayload, - @Res({ passthrough: true }) res: Response, - ) { - const result = await this.authService.logout(user.sub); - this.clearCookies(res); - return result; - } - - @Get("me") - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - async getMe(@CurrentUser() user: JwtPayload) { - const freshUser = await this.authService.getInternalMe(user.sub); - return { user: freshUser }; - } - - @Throttle({ default: { limit: 3, ttl: 3600000 } }) // 3 per hour limit - @Post("forgot-password") - @HttpCode(HttpStatus.OK) - async forgotPassword(@Body() dto: ForgotPasswordDto) { - return this.authService.forgotPassword(dto); - } - - @Throttle({ default: { limit: 3, ttl: 3600000 } }) // 3 per hour limit - @Post("reset-password") - @HttpCode(HttpStatus.OK) - async resetPassword(@Body() dto: ResetPasswordDto) { - return this.authService.resetPassword(dto); - } - - @UseGuards(JwtAuthGuard) - @Patch("profile") - @HttpCode(HttpStatus.OK) - async updateProfile( - @CurrentUser() user: JwtPayload, - @Body() dto: UpdateProfileDto, - ) { - return this.authService.updateProfile(user.sub, dto); - } - - @UseGuards(JwtAuthGuard) - @Post("change-password") - @HttpCode(HttpStatus.OK) - async changePassword(@CurrentUser() user: JwtPayload, @Body() dto: any) { - return this.authService.changePassword(user.sub, dto); - } -} diff --git a/apps/backend/src/modules/auth/auth.module.ts b/apps/backend/src/modules/auth/auth.module.ts deleted file mode 100644 index 5a4cc04b..00000000 --- a/apps/backend/src/modules/auth/auth.module.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Module, forwardRef } from "@nestjs/common"; -import { PassportModule } from "@nestjs/passport"; -import { JwtModule } from "@nestjs/jwt"; -import { ConfigModule, ConfigService } from "@nestjs/config"; -import { AuthController } from "./auth.controller"; -import { InternalAuthController } from "./internal-auth.controller"; -import { AuthService } from "./auth.service"; -import { JwtAccessStrategy } from "./strategies/jwt-access.strategy"; -import { JwtRefreshStrategy } from "./strategies/jwt-refresh.strategy"; -import { PrismaModule } from "../../prisma/prisma.module"; -import { RedisModule } from "../../redis/redis.module"; -import { AdminModule } from "../admin/admin.module"; -import { NotificationModule } from "../notification/notification.module"; -import { EmailModule } from "../email/email.module"; -import { WhatsAppModule } from "../whatsapp/whatsapp.module"; - -@Module({ - imports: [ - PassportModule, - JwtModule.registerAsync({ - imports: [ConfigModule], - useFactory: async (configService: ConfigService) => ({ - secret: configService.get("jwt.accessSecret"), - signOptions: { expiresIn: configService.get("jwt.accessTtl") }, - }), - inject: [ConfigService], - }), - ConfigModule, - PrismaModule, - RedisModule, - NotificationModule, - EmailModule, - forwardRef(() => AdminModule), - forwardRef(() => WhatsAppModule), - ], - controllers: [AuthController, InternalAuthController], - providers: [AuthService, JwtAccessStrategy, JwtRefreshStrategy], - exports: [AuthService], -}) -export class AuthModule {} diff --git a/apps/backend/src/modules/auth/auth.service.ts b/apps/backend/src/modules/auth/auth.service.ts deleted file mode 100644 index 4bee6271..00000000 --- a/apps/backend/src/modules/auth/auth.service.ts +++ /dev/null @@ -1,1077 +0,0 @@ -import { - Injectable, - UnauthorizedException, - ConflictException, - BadRequestException, - NotFoundException, - Logger, - Inject, - forwardRef, -} from "@nestjs/common"; -import { Prisma } from "@prisma/client"; -import { JwtService } from "@nestjs/jwt"; -import { ConfigService } from "@nestjs/config"; -import { randomInt, randomBytes, createHash } from "crypto"; -import * as bcrypt from "bcrypt"; -import { PrismaService } from "../../prisma/prisma.service"; -import { RedisService } from "../../redis/redis.service"; -import { EmailService } from "../email/email.service"; -import { NotificationTriggerService } from "../notification/notification-trigger.service"; -import { RegisterDto } from "./dto/register.dto"; -import { PlatformConfig } from "../../config/platform.config"; -import { LoginDto } from "./dto/login.dto"; -import { VerifyEmailDto } from "./dto/verify-email.dto"; -import { ResendVerificationDto } from "./dto/resend-verification.dto"; -import { ForgotPasswordDto } from "./dto/forgot-password.dto"; -import { ResetPasswordDto } from "./dto/reset-password.dto"; -import { AdminRegisterDto } from "./dto/admin-register.dto"; -import { SendPhoneOtpDto } from "./dto/send-phone-otp.dto"; -import { VerifyPhoneOtpDto } from "./dto/verify-phone-otp.dto"; -import { UpdateProfileDto } from "./dto/update-profile.dto"; -import { TokenPair, JwtPayload } from "@swifta/shared"; -import { WhatsAppService } from "../whatsapp/whatsapp.service"; -import { WHATSAPP_OTP_TEMPLATE } from "../whatsapp/whatsapp.constants"; -import AfricasTalking from "africastalking"; - -const SALT_ROUNDS = 10; -const REFRESH_TOKEN_PREFIX = "refresh_token:"; -const REFRESH_TOKEN_TTL = 7 * 24 * 60 * 60; // 7 days in seconds - -const EMAIL_OTP_PREFIX = "email_otp:"; -const EMAIL_OTP_TTL = PlatformConfig.timers.otpExpiryEmailMinutes * 60; // Convert minutes to seconds -const EMAIL_OTP_RATE_PREFIX = "email_otp_count:"; -const EMAIL_OTP_RATE_TTL = 600; // 10 minutes window -const EMAIL_OTP_MAX_RESENDS = 3; - -const PHONE_OTP_PREFIX = "phone_otp:"; -const PHONE_OTP_TTL = PlatformConfig.timers.otpExpiryWhatsappMinutes * 60; // Convert minutes to seconds -const PHONE_OTP_RATE_PREFIX = "phone_otp_count:"; -const PHONE_OTP_RATE_TTL = 600; // 10 minutes window -const PHONE_OTP_MAX_RESENDS = 3; - -/** - * Normalize a phone number to E.164-ish format. - * Strips spaces/dashes, ensures leading +. - */ -function normalizePhone(phone: string): string { - let cleaned = phone.replace(/[\s\-()]/g, ""); - if (!cleaned.startsWith("+")) { - // Assume Nigerian number if no country code - if (cleaned.startsWith("0")) { - cleaned = "+234" + cleaned.slice(1); - } else { - cleaned = "+" + cleaned; - } - } - return cleaned; -} - -@Injectable() -export class AuthService { - private readonly logger = new Logger(AuthService.name); - private smsClient: any; - - constructor( - private prisma: PrismaService, - private jwtService: JwtService, - private configService: ConfigService, - private redis: RedisService, - private emailService: EmailService, - private notificationTriggerService: NotificationTriggerService, - @Inject(forwardRef(() => WhatsAppService)) - private whatsappService: WhatsAppService, - ) { - const atConfig = { - apiKey: this.configService.get("africastalking.apiKey"), - username: this.configService.get("africastalking.username"), - }; - if (atConfig.apiKey && atConfig.username) { - const africastalkingClient = AfricasTalking(atConfig); - this.smsClient = africastalkingClient.SMS; - } - } - - async register(dto: RegisterDto): Promise { - const normalizedPhone = normalizePhone(dto.phone); - const existingUser = await this.prisma.user.findFirst({ - where: { - OR: [{ email: dto.email }, { phone: normalizedPhone }], - }, - }); - if (existingUser) { - throw new ConflictException( - "This email or phone number is already registered. Please log in instead.", - ); - } - - const passwordHash = await bcrypt.hash(dto.password, SALT_ROUNDS); - - const user = await this.prisma.user.create({ - data: { - email: dto.email, - phone: normalizedPhone, - firstName: dto.firstName, - middleName: dto.middleName, - lastName: dto.lastName, - passwordHash, - role: dto.role as any, - ...(dto.role === "MERCHANT" && dto.businessName - ? { - merchantProfile: { - create: { - businessName: dto.businessName, - slug: - dto.slug || - (await this.generateUniqueSlug(dto.businessName)), - } as any, - }, - } - : {}), - ...(dto.role === "SUPPLIER" && dto.companyName && dto.companyAddress - ? { - supplierProfile: { - create: { - companyName: dto.companyName, - companyAddress: dto.companyAddress, - cacNumber: dto.cacNumber, - }, - }, - } - : {}), - ...(dto.role === "BUYER" - ? { - buyerProfile: { - create: { - buyerType: dto.buyerType || "CONSUMER", - businessName: - dto.buyerType === "CONSUMER" ? "" : dto.businessName, - }, - }, - } - : {}), - }, - include: { - merchantProfile: true, - supplierProfile: true, - buyerProfile: true, - }, - }); - - // Generate and store OTP for email verification - await this.generateAndStoreOtp(user.id, dto.email); - - // Trigger Welcome Email - await this.notificationTriggerService.triggerWelcome(user.id); - - return this.generateAndStoreTokens(user); - } - - async verifyEmail(dto: VerifyEmailDto): Promise<{ message: string }> { - const storedOtp = await this.redis.get(`${EMAIL_OTP_PREFIX}${dto.email}`); - - if (!storedOtp) { - throw new BadRequestException( - "Verification code expired or not found. Please request a new one.", - ); - } - - if (storedOtp !== dto.code) { - throw new BadRequestException("Invalid verification code."); - } - - // Mark user as verified - const user = await this.prisma.user.findUnique({ - where: { email: dto.email }, - }); - if (!user) { - throw new BadRequestException("User not found."); - } - - await this.prisma.user.update({ - where: { id: user.id }, - data: { emailVerified: true }, - }); - - // Clean up OTP and rate limit keys - await this.redis.del(`${EMAIL_OTP_PREFIX}${dto.email}`); - await this.redis.del(`${EMAIL_OTP_RATE_PREFIX}${dto.email}`); - - this.logger.log(`Email verified for user ${dto.email}`); - - return { message: "Email verified successfully." }; - } - - async resendVerification( - dto: ResendVerificationDto, - ): Promise<{ message: string }> { - // Apply rate limiting before sending a new code - - // Rate limiting: max 3 resends per 10 minutes - const rateKey = `${EMAIL_OTP_RATE_PREFIX}${dto.email}`; - const currentCount = await this.redis.get(rateKey); - const count = currentCount ? parseInt(currentCount, 10) : 0; - - if (count >= EMAIL_OTP_MAX_RESENDS) { - throw new BadRequestException( - "Too many resend attempts. Please wait 10 minutes before trying again.", - ); - } - - // Generate and store new OTP (overwrites the old one) - // We only actually send/store if the user exists and isn't verified, - // but we do that inside the helper to avoid leaking existence via timing - await this.generateAndStoreOtpIfEligible(dto.email); - - // Increment rate limit counter - await this.redis.set(rateKey, (count + 1).toString(), EMAIL_OTP_RATE_TTL); - - return { - message: - "If an account with that email exists, a new code has been sent.", - }; - } - - async login(dto: LoginDto): Promise { - // Check if the input is an email, or if it's potentially a merchant slug - const isEmail = dto.identifier.includes("@"); - - let user; - if (isEmail) { - user = await this.prisma.user.findUnique({ - where: { email: dto.identifier.toLowerCase() }, - include: { merchantProfile: true, buyerProfile: true }, - }); - } else { - user = await this.prisma.user.findFirst({ - where: { - merchantProfile: { - OR: [ - { slug: dto.identifier.toLowerCase() } as any, - { - slugHistory: { - some: { oldSlug: dto.identifier.toLowerCase() }, - }, - } as any, - ], - }, - }, - include: { merchantProfile: true, buyerProfile: true }, - }); - } - - if (!user) { - throw new UnauthorizedException("Invalid credentials"); - } - - if (!(await bcrypt.compare(dto.password, user.passwordHash))) { - throw new UnauthorizedException("Invalid credentials"); - } - - return this.generateAndStoreTokens(user); - } - - async internalLogin(dto: LoginDto): Promise { - const user = await this.prisma.user.findUnique({ - where: { email: dto.identifier.toLowerCase() }, - include: { adminProfile: true }, // Internal users have admin profiles instead of merchant profiles - }); - - if (!user) { - throw new UnauthorizedException("Invalid email or password"); - } - - if (!(await bcrypt.compare(dto.password, user.passwordHash))) { - throw new UnauthorizedException("Invalid email or password"); - } - - // Reject standard users - if (user.role === "BUYER" || user.role === "MERCHANT") { - throw new UnauthorizedException("Invalid email or password"); - } - - if (user.adminProfile?.approvalStatus === "PENDING") { - throw new UnauthorizedException( - "Your account is awaiting Super Admin approval.", - ); - } - - if (user.adminProfile?.approvalStatus === "SUSPENDED") { - throw new UnauthorizedException("Your account has been suspended."); - } - - return this.generateAndStoreTokens(user); - } - - async adminRegister(dto: AdminRegisterDto): Promise<{ message: string }> { - // 1. Check if email already exists - const existingUser = await this.prisma.user.findUnique({ - where: { email: dto.email.toLowerCase() }, - }); - if (existingUser) { - throw new ConflictException("User with this email already exists."); - } - - // 2. Find and validate the access token - // We have to iterate over active tokens to check bcrypt hashes (like passwords) - const activeTokens = await this.prisma.staffAccessToken.findMany({ - where: { isActive: true }, - }); - - let matchedToken = null; - for (const token of activeTokens) { - if (await bcrypt.compare(dto.accessToken, token.tokenHash)) { - matchedToken = token; - break; - } - } - - if (!matchedToken) { - throw new UnauthorizedException( - "Invalid, expired, or revoked access token.", - ); - } - - // 3. Create the new staff account - const passwordHash = await bcrypt.hash(dto.password, SALT_ROUNDS); - - await this.prisma.$transaction(async (prisma) => { - // Dummy phone number, the internal users don't need phone verification for this phase - const phonePlaceholder = `+2340000000${randomInt(100, 999)}`; - - await prisma.user.create({ - data: { - firstName: dto.firstName, - middleName: dto.middleName, - lastName: dto.lastName, - email: dto.email.toLowerCase(), - phone: phonePlaceholder, - passwordHash, - role: matchedToken.role, // Derive role directly from the token - emailVerified: true, // Internal accounts are implicitly verified - adminProfile: { - create: { - accessLevel: "STANDARD", - approvalStatus: "PENDING", // Security measure - }, - }, - }, - }); - }); - - return { - message: - "Registration successful. Your account is pending Super Admin approval.", - }; - } - - async refreshTokens( - userId: string, - refreshToken: string, - ): Promise { - // Validate the refresh token against Redis - await this.validateRefreshToken(userId, refreshToken); - - const user = await this.prisma.user.findUnique({ - where: { id: userId }, - include: { - merchantProfile: true, - buyerProfile: true, - supplierProfile: { - include: { whatsappSupplierLink: true }, - }, - whatsappLink: true, - whatsappBuyerLink: true, - }, - }); - - if (!user) { - throw new UnauthorizedException("User not found"); - } - - // Rotate: generate new pair and store new refresh token (old one is overwritten) - return this.generateAndStoreTokens(user); - } - - async logout(userId: string): Promise<{ message: string }> { - // Delete refresh token from Redis — invalidates all sessions for this user - await this.redis.del(`${REFRESH_TOKEN_PREFIX}${userId}`); - return { message: "Logged out successfully" }; - } - - async getInternalMe(userId: string) { - const user = await this.prisma.user.findUnique({ - where: { id: userId }, - include: { - merchantProfile: true, - adminProfile: true, - supplierProfile: { - include: { whatsappSupplierLink: true }, - }, - whatsappLink: true, - whatsappBuyerLink: true, - }, - }); - - if (!user) { - throw new NotFoundException("User not found"); - } - - return { - id: user.id, - email: user.email, - phone: user.phone, - firstName: user.firstName, - middleName: user.middleName, - lastName: user.lastName, - role: user.role, - emailVerified: user.emailVerified, - phoneVerified: user.phoneVerified, - isWhatsAppLinked: !!( - (user as any).whatsappLink || - (user as any).whatsappBuyerLink || - (user as any).supplierProfile?.whatsappSupplierLink - ), - merchantId: user.merchantProfile?.id, - adminId: user.adminProfile?.id, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - }; - } - - async forgotPassword(dto: ForgotPasswordDto): Promise<{ message: string }> { - const user = await this.prisma.user.findUnique({ - where: { email: dto.email }, - }); - - // Always return same message to prevent email enumeration - const successMessage = - "If an account exists for that email, a password reset link has been sent."; - - if (!user) { - return { message: successMessage }; - } - - // Generate secure token - const resetToken = randomBytes(32).toString("hex"); - const hashedToken = createHash("sha256").update(resetToken).digest("hex"); - - // Set expiry to 15 mins - const expiresAt = new Date(); - expiresAt.setMinutes(expiresAt.getMinutes() + 15); - - await this.prisma.user.update({ - where: { id: user.id }, - data: { - resetToken: hashedToken, - resetTokenExpiry: expiresAt, - }, - }); - - const frontendUrl = - this.configService.get("FRONTEND_URL") || "http://localhost:3000"; - await this.notificationTriggerService.triggerPasswordReset( - user.id, - dto.email, - resetToken, - frontendUrl, - ); - - return { message: successMessage }; - } - - async resetPassword(dto: ResetPasswordDto): Promise<{ message: string }> { - // Hash the plain token to match what is stored in the database - const hashedToken = createHash("sha256").update(dto.token).digest("hex"); - - const user = await this.prisma.user.findFirst({ - where: { - resetToken: hashedToken, - resetTokenExpiry: { - gt: new Date(), // must not be expired - }, - }, - }); - - if (!user) { - throw new BadRequestException("Invalid or expired password reset token"); - } - - const passwordHash = await bcrypt.hash(dto.newPassword, SALT_ROUNDS); - - await this.prisma.user.update({ - where: { id: user.id }, - data: { - passwordHash, - resetToken: null, - resetTokenExpiry: null, - }, - }); - - // Log out all active sessions for this user globally for security - await this.redis.del(`${REFRESH_TOKEN_PREFIX}${user.id}`); - - this.logger.log(`Password reset successfully for user: ${user.email}`); - - return { message: "Password has been successfully reset" }; - } - - /** - * Securely generates and stores an OTP only if the user exists and is not verified. - * Returns generic success regardless of internal state to prevent enumeration. - */ - private async generateAndStoreOtpIfEligible(email: string): Promise { - const user = await this.prisma.user.findUnique({ - where: { email }, - }); - - // If no user or already verified, return early (generic success handled by caller) - if (!user || user.emailVerified) { - return; - } - - const otp = randomInt(100000, 999999).toString(); - this.logger.log( - `[DEVELOPMENT / SANDBOX] Generated OTP for ${email}: ${otp}`, - ); - await this.redis.set(`${EMAIL_OTP_PREFIX}${email}`, otp, EMAIL_OTP_TTL); - await this.notificationTriggerService.triggerEmailVerification( - user.id, - otp, - ); - } - - /** - * Generates a cryptographically random 6-digit OTP and stores it in Redis. - */ - private async generateAndStoreOtp( - userId: string, - email: string, - ): Promise { - const otp = randomInt(100000, 999999).toString(); - this.logger.log( - `[DEVELOPMENT / SANDBOX] Generated OTP for ${email}: ${otp}`, - ); - - await this.redis.set(`${EMAIL_OTP_PREFIX}${email}`, otp, EMAIL_OTP_TTL); - - await this.notificationTriggerService.triggerEmailVerification(userId, otp); - } - - /** - * Validates the incoming refresh token against the hashed version stored in Redis. - * Throws UnauthorizedException if token is invalid or expired. - */ - private async validateRefreshToken( - userId: string, - refreshToken: string, - ): Promise { - const storedHash = await this.redis.get(`${REFRESH_TOKEN_PREFIX}${userId}`); - - if (!storedHash) { - throw new UnauthorizedException("Refresh token expired or revoked"); - } - - const isValid = await bcrypt.compare(refreshToken, storedHash); - if (!isValid) { - // Possible token theft — delete stored token to force re-login - await this.redis.del(`${REFRESH_TOKEN_PREFIX}${userId}`); - this.logger.warn(`Invalid refresh token attempt for user ${userId}`); - throw new UnauthorizedException("Invalid refresh token"); - } - } - - /** - * Generates access + refresh tokens, hashes the refresh token, - * and stores it in Redis with a 7-day TTL. - */ - private async generateAndStoreTokens( - user: any, - ): Promise { - const payload: JwtPayload = { - sub: user.id, - email: user.email, - role: user.role, - merchantId: user.merchantProfile?.id, - }; - - const [accessToken, refreshToken] = await Promise.all([ - this.jwtService.signAsync(payload, { - secret: this.configService.get("jwt.accessSecret"), - expiresIn: this.configService.get("jwt.accessTtl"), - }), - this.jwtService.signAsync(payload, { - secret: this.configService.get("jwt.refreshSecret"), - expiresIn: this.configService.get("jwt.refreshTtl"), - }), - ]); - - // Hash and store refresh token in Redis - const refreshHash = await bcrypt.hash(refreshToken, SALT_ROUNDS); - await this.redis.set( - `${REFRESH_TOKEN_PREFIX}${user.id}`, - refreshHash, - REFRESH_TOKEN_TTL, - ); - - return { - accessToken, - refreshToken, - user: { - id: user.id, - email: user.email, - phone: user.phone, - firstName: user.firstName, - middleName: user.middleName, - lastName: user.lastName, - role: user.role, - emailVerified: user.emailVerified, - isWhatsAppLinked: !!( - user.whatsappLink || - user.whatsappBuyerLink || - user.supplierProfile?.whatsappSupplierLink - ), - merchantId: user.merchantProfile?.id, - buyerType: - user.buyerProfile?.buyerType || - (user.role === "BUYER" ? "CONSUMER" : undefined), - createdAt: user.createdAt, - updatedAt: user.updatedAt, - }, - }; - } - - /** - * Generates a unique, URL-safe slug from a business name. - */ - private async generateUniqueSlug(businessName: string): Promise { - const baseSlug = businessName - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") // Replace non-alphanumeric with hyphens - .replace(/^-+|-+$/g, "") // Trim leading/trailing hyphens - .substring(0, 30); // Keep it reasonably short - - let slug = baseSlug || "merchant"; - let isUnique = false; - let counter = 0; - - while (!isUnique) { - const existing = await this.prisma.merchantProfile.findFirst({ - where: { slug }, - }); - - if (!existing) { - isUnique = true; - } else { - counter++; - slug = `${baseSlug}-${counter}`; - } - } - - return slug; - } - - async sendPhoneOtp( - dto: SendPhoneOtpDto, - userId: string, - ): Promise<{ message: string }> { - const phone = normalizePhone(dto.phone); - const rateKey = `${PHONE_OTP_RATE_PREFIX}${phone}:${userId}`; - const currentCount = await this.redis.get(rateKey); - const count = currentCount ? parseInt(currentCount, 10) : 0; - - if (count >= PHONE_OTP_MAX_RESENDS) { - throw new BadRequestException( - "Too many resend attempts. Please wait 10 minutes before trying again.", - ); - } - - const otp = randomInt(100000, 999999).toString(); - this.logger.log( - `[DEVELOPMENT / LIVE] Generated SMS OTP and queued delivery for ${phone}`, - ); - await this.redis.set( - `${PHONE_OTP_PREFIX}${phone}:${userId}`, - otp, - PHONE_OTP_TTL, - ); - await this.redis.set(rateKey, (count + 1).toString(), PHONE_OTP_RATE_TTL); - - try { - this.logger.log(`Attempting to send WhatsApp OTP to ${phone}`); - await this.whatsappService.sendWhatsAppTemplateMessage( - phone, - WHATSAPP_OTP_TEMPLATE, - [{ type: "text", text: otp }], - ); - this.logger.log( - `[DEVELOPMENT / LIVE] WhatsApp OTP sent successfully to ${phone}`, - ); - } catch (waError) { - this.logger.warn( - `Failed to send WhatsApp OTP to ${phone}, falling back to SMS. Error: ${waError instanceof Error ? waError.message : waError}`, - ); - - try { - if (this.smsClient) { - await this.smsClient.send({ - to: [phone], - message: `Your Swifta verification code is ${otp}. It expires in 5 minutes.`, - from: this.configService.get("africastalking.senderId"), - }); - } else { - this.logger.warn( - "AfricasTalking SMS client not configured. OTP generated but not sent.", - ); - } - } catch (smsError) { - this.logger.error("Failed to send SMS via AfricasTalking", smsError); - } - } - - return { message: "Verification code sent successfully." }; - } - - async verifyPhoneOtp( - dto: VerifyPhoneOtpDto, - userId: string, - ): Promise<{ message: string }> { - const phone = normalizePhone(dto.phone); - const storedOtp = await this.redis.get( - `${PHONE_OTP_PREFIX}${phone}:${userId}`, - ); - - if (!storedOtp) { - throw new BadRequestException( - "Verification code expired or not found. Please request a new one.", - ); - } - - if (storedOtp !== dto.code) { - throw new BadRequestException("Invalid verification code."); - } - - await this.prisma.user.update({ - where: { id: userId }, - data: { phoneVerified: true, phone: phone }, - }); - - await this.redis.del(`${PHONE_OTP_PREFIX}${phone}:${userId}`); - await this.redis.del(`${PHONE_OTP_RATE_PREFIX}${phone}:${userId}`); - - this.logger.log(`Phone verified for user ${userId}`); - - return { message: "Phone verified successfully." }; - } - - async initiateWhatsAppLogin(phone: string): Promise<{ message: string }> { - const normalizedPhone = normalizePhone(phone); - - // 1. Resolve linked identity (Merchant, Buyer, or Supplier) - const [merchantLink, buyerLink, supplierLink] = await Promise.all([ - this.prisma.whatsAppLink.findUnique({ - where: { phone: normalizedPhone }, - include: { user: true }, - }), - this.prisma.whatsAppBuyerLink.findUnique({ - where: { phone: normalizedPhone }, - include: { buyer: true }, - }), - this.prisma.whatsAppSupplierLink.findUnique({ - where: { phone: normalizedPhone }, - include: { supplier: { include: { user: true } } }, - }), - ]); - - const user = - merchantLink?.user || buyerLink?.buyer || supplierLink?.supplier?.user; - - if (!user) { - this.logger.warn( - `WhatsApp login initiated for unlinked/non-existent phone: ${normalizedPhone}`, - ); - // Return generic success to mask user existence - return { message: "A login code has been sent to your WhatsApp." }; - } - - const otp = randomInt(100000, 999999).toString(); - const otpKey = `wa_login_otp:${normalizedPhone}`; - - // Store in Redis (dynamically using PlatformConfig) - await this.redis.set( - otpKey, - otp, - PlatformConfig.timers.otpExpiryWhatsappMinutes * 60, - ); - - // Send via WhatsApp Template (bypass 24h window) - await this.whatsappService.sendWhatsAppTemplateMessage( - normalizedPhone, - WHATSAPP_OTP_TEMPLATE, - [{ type: "text", text: otp }], - ); - - this.logger.log( - `WhatsApp Login Template sent to verified identity: ${normalizedPhone}`, - ); - - return { message: "A login code has been sent to your WhatsApp." }; - } - - async initiateWhatsAppLink( - userId: string, - phone: string, - ): Promise<{ message: string }> { - const normalizedPhone = normalizePhone(phone); - - // 1. Check if phone is already linked to SOMEONE ELSE - const [merchantLink, buyerLink, supplierLink] = await Promise.all([ - this.prisma.whatsAppLink.findUnique({ - where: { phone: normalizedPhone }, - }), - this.prisma.whatsAppBuyerLink.findUnique({ - where: { phone: normalizedPhone }, - }), - this.prisma.whatsAppSupplierLink.findUnique({ - where: { phone: normalizedPhone }, - }), - ]); - - if ( - (merchantLink && merchantLink.userId !== userId) || - (buyerLink && buyerLink.buyerId !== userId) || - (supplierLink && - supplierLink.isActive && - supplierLink.supplierId !== - (await this.prisma.supplierProfile.findUnique({ where: { userId } })) - ?.id) - ) { - throw new ConflictException( - "This WhatsApp number is already linked to another account.", - ); - } - - const otp = randomInt(100000, 999999).toString(); - const otpKey = `wa_link_otp:${userId}:${normalizedPhone}`; - - await this.redis.set( - otpKey, - otp, - PlatformConfig.timers.otpExpiryWhatsappMinutes * 60, - ); // Use WhatsApp expiry for linking code too - - // Send via WhatsApp Template - await this.whatsappService.sendWhatsAppTemplateMessage( - normalizedPhone, - WHATSAPP_OTP_TEMPLATE, - [{ type: "text", text: otp }], - ); - - this.logger.log(`WhatsApp Linking Template sent to: ${normalizedPhone}`); - - return { message: "Verification code sent to WhatsApp." }; - } - - async verifyWhatsAppLink( - userId: string, - phone: string, - code: string, - ): Promise<{ message: string }> { - const normalizedPhone = normalizePhone(phone); - const otpKey = `wa_link_otp:${userId}:${normalizedPhone}`; - const storedOtp = await this.redis.get(otpKey); - - if (!storedOtp || storedOtp !== code) { - throw new BadRequestException("Invalid or expired verification code."); - } - - const user = await this.prisma.user.findUnique({ - where: { id: userId }, - }); - if (!user) throw new NotFoundException("User not found"); - - // Link based on role - await this.prisma.$transaction(async (tx) => { - if (user.role === "MERCHANT") { - await tx.whatsAppLink.upsert({ - where: { userId }, - update: { phone: normalizedPhone, isActive: true }, - create: { userId, phone: normalizedPhone, isActive: true }, - }); - } else if (user.role === "BUYER") { - await tx.whatsAppBuyerLink.upsert({ - where: { buyerId: userId }, - update: { phone: normalizedPhone, isActive: true }, - create: { buyerId: userId, phone: normalizedPhone, isActive: true }, - }); - } else if (user.role === "SUPPLIER") { - const supplier = await tx.supplierProfile.findUnique({ - where: { userId }, - }); - if (supplier) { - await tx.whatsAppSupplierLink.upsert({ - where: { supplierId: supplier.id }, - update: { phone: normalizedPhone, isActive: true }, - create: { - phone: normalizedPhone, - supplierId: supplier.id, - isActive: true, - }, - }); - } - } - // Also mark phone as verified on the main user record - await tx.user.update({ - where: { id: userId }, - data: { phoneVerified: true }, - }); - }); - - await this.redis.del(otpKey); - return { message: "WhatsApp linked successfully." }; - } - - async verifyWhatsAppLogin( - phone: string, - code: string, - ): Promise { - const normalizedPhone = normalizePhone(phone); - const otpKey = `wa_login_otp:${normalizedPhone}`; - const storedOtp = await this.redis.get(otpKey); - - if (!storedOtp || storedOtp !== code) { - throw new UnauthorizedException("Invalid or expired verification code."); - } - - // Resolve linked identity again during verification - const [merchantLink, buyerLink, supplierLink] = await Promise.all([ - this.prisma.whatsAppLink.findUnique({ - where: { phone: normalizedPhone }, - include: { - user: { - include: { - merchantProfile: true, - buyerProfile: true, - supplierProfile: true, - }, - }, - }, - }), - this.prisma.whatsAppBuyerLink.findUnique({ - where: { phone: normalizedPhone }, - include: { - buyer: { - include: { - merchantProfile: true, - buyerProfile: true, - supplierProfile: true, - }, - }, - }, - }), - this.prisma.whatsAppSupplierLink.findUnique({ - where: { phone: normalizedPhone }, - include: { - supplier: { - include: { - user: { - include: { - merchantProfile: true, - buyerProfile: true, - supplierProfile: true, - }, - }, - }, - }, - }, - }), - ]); - - const user = - merchantLink?.user || buyerLink?.buyer || supplierLink?.supplier?.user; - - if (!user) { - throw new UnauthorizedException("User no longer exists."); - } - - // Clean up - await this.redis.del(otpKey); - - return this.generateAndStoreTokens(user); - } - - async updateProfile(userId: string, dto: UpdateProfileDto) { - const user = await this.prisma.user.findUnique({ where: { id: userId } }); - if (!user) throw new NotFoundException("User not found"); - - const data: Prisma.UserUpdateInput = { - firstName: dto.firstName, - middleName: dto.middleName, - lastName: dto.lastName, - }; - - if (dto.phone && dto.phone !== user.phone) { - const normalizedPhone = normalizePhone(dto.phone); - if (normalizedPhone !== user.phone) { - const existingUser = await this.prisma.user.findFirst({ - where: { phone: normalizedPhone }, - }); - if (existingUser) { - throw new ConflictException("Phone number already in use"); - } - data.phone = normalizedPhone; - data.phoneVerified = false; - } - } - - if (dto.email && dto.email !== user.email) { - const existingUser = await this.prisma.user.findUnique({ - where: { email: dto.email }, - }); - if (existingUser) { - throw new BadRequestException("Email already in use"); - } - data.email = dto.email; - data.emailVerified = false; - } - - return this.prisma.user.update({ - where: { id: userId }, - data, - select: { - id: true, - email: true, - firstName: true, - middleName: true, - lastName: true, - phone: true, - role: true, - emailVerified: true, - createdAt: true, - updatedAt: true, - }, - }); - } - - async changePassword(userId: string, dto: any) { - const user = await this.prisma.user.findUnique({ where: { id: userId } }); - if (!user) throw new UnauthorizedException(); - - const isValid = await bcrypt.compare( - dto.currentPassword, - user.passwordHash, - ); - if (!isValid) - throw new BadRequestException("Current password is incorrect"); - - const passwordHash = await bcrypt.hash(dto.newPassword, SALT_ROUNDS); - await this.prisma.user.update({ - where: { id: userId }, - data: { passwordHash }, - }); - - // Revoke all active sessions for this user globally for security - await this.redis.del(`${REFRESH_TOKEN_PREFIX}${userId}`); - - return { message: "Password updated successfully" }; - } -} diff --git a/apps/backend/src/modules/auth/dto/admin-register.dto.ts b/apps/backend/src/modules/auth/dto/admin-register.dto.ts deleted file mode 100644 index e3765d0e..00000000 --- a/apps/backend/src/modules/auth/dto/admin-register.dto.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - IsEmail, - IsNotEmpty, - IsString, - IsOptional, - MinLength, -} from "class-validator"; - -export class AdminRegisterDto { - @IsNotEmpty() - @IsString() - firstName: string; - - @IsOptional() - @IsString() - middleName?: string; - - @IsNotEmpty() - @IsString() - lastName: string; - - @IsNotEmpty() - @IsEmail() - email: string; - - @IsNotEmpty() - @IsString() - @MinLength(8, { message: "Password must be at least 8 characters long" }) - password: string; - - @IsNotEmpty() - @IsString() - accessToken: string; -} diff --git a/apps/backend/src/modules/auth/dto/forgot-password.dto.ts b/apps/backend/src/modules/auth/dto/forgot-password.dto.ts deleted file mode 100644 index 3207e40e..00000000 --- a/apps/backend/src/modules/auth/dto/forgot-password.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsEmail, IsNotEmpty } from "class-validator"; - -export class ForgotPasswordDto { - @IsEmail({}, { message: "Please provide a valid email address" }) - @IsNotEmpty({ message: "Email is required" }) - email: string; -} diff --git a/apps/backend/src/modules/auth/dto/login.dto.ts b/apps/backend/src/modules/auth/dto/login.dto.ts deleted file mode 100644 index 69bcec0d..00000000 --- a/apps/backend/src/modules/auth/dto/login.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { IsString } from "class-validator"; - -export class LoginDto { - @IsString() - identifier: string; - - @IsString() - password: string; -} diff --git a/apps/backend/src/modules/auth/dto/refresh-token.dto.ts b/apps/backend/src/modules/auth/dto/refresh-token.dto.ts deleted file mode 100644 index 3cc573c5..00000000 --- a/apps/backend/src/modules/auth/dto/refresh-token.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsString, IsOptional } from "class-validator"; - -export class RefreshTokenDto { - @IsOptional() - @IsString() - refreshToken?: string; -} diff --git a/apps/backend/src/modules/auth/dto/register.dto.ts b/apps/backend/src/modules/auth/dto/register.dto.ts deleted file mode 100644 index aa6136ff..00000000 --- a/apps/backend/src/modules/auth/dto/register.dto.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - IsEmail, - IsString, - IsOptional, - IsEnum, - MinLength, - Matches, -} from "class-validator"; -import { UserRole } from "@swifta/shared"; - -export class RegisterDto { - @IsEmail() - email: string; - - @IsString() - phone: string; - - @IsString() - @Matches(/^[\p{L}\s'-]+$/u, { - message: "First name contains invalid characters", - }) - firstName: string; - - @IsOptional() - @IsString() - middleName?: string; - - @IsString() - @Matches(/^[\p{L}\s'-]+$/u, { - message: "Last name contains invalid characters", - }) - lastName: string; - - @IsString() - @MinLength(8, { message: "Password must be at least 8 characters long" }) - @Matches( - /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#])[A-Za-z\d@$!%*?&#]{8,}$/, - { - message: - "Password must contain at least 1 uppercase letter, 1 lowercase letter, 1 number, and 1 special character", - }, - ) - password: string; - - @IsOptional() - @IsString() - businessName?: string; - - @IsOptional() - @IsString() - companyName?: string; - - @IsOptional() - @IsString() - companyAddress?: string; - - @IsOptional() - @IsString() - cacNumber?: string; - - @IsEnum(UserRole) - role: UserRole; - - @IsOptional() - @IsString() - buyerType?: string; - - @IsOptional() - @IsString() - slug?: string; -} diff --git a/apps/backend/src/modules/auth/dto/resend-verification.dto.ts b/apps/backend/src/modules/auth/dto/resend-verification.dto.ts deleted file mode 100644 index f1bf1e41..00000000 --- a/apps/backend/src/modules/auth/dto/resend-verification.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { IsEmail } from "class-validator"; - -export class ResendVerificationDto { - @IsEmail() - email: string; -} diff --git a/apps/backend/src/modules/auth/dto/reset-password.dto.ts b/apps/backend/src/modules/auth/dto/reset-password.dto.ts deleted file mode 100644 index 4d21a4c0..00000000 --- a/apps/backend/src/modules/auth/dto/reset-password.dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { IsNotEmpty, IsString, MinLength, Matches } from "class-validator"; - -export class ResetPasswordDto { - @IsNotEmpty({ message: "Reset token is required" }) - @IsString() - token: string; - - @IsNotEmpty({ message: "New password is required" }) - @IsString() - @MinLength(8, { message: "Password must be at least 8 characters long" }) - @Matches( - /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#])[A-Za-z\d@$!%*?&#]{8,}$/, - { - message: - "Password must contain at least 1 uppercase letter, 1 lowercase letter, 1 number, and 1 special character", - }, - ) - newPassword: string; -} diff --git a/apps/backend/src/modules/auth/dto/send-phone-otp.dto.ts b/apps/backend/src/modules/auth/dto/send-phone-otp.dto.ts deleted file mode 100644 index 8081984a..00000000 --- a/apps/backend/src/modules/auth/dto/send-phone-otp.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsString, IsNotEmpty } from "class-validator"; - -export class SendPhoneOtpDto { - @IsNotEmpty() - @IsString() - phone: string; -} diff --git a/apps/backend/src/modules/auth/dto/update-profile.dto.ts b/apps/backend/src/modules/auth/dto/update-profile.dto.ts deleted file mode 100644 index 0c8f9c60..00000000 --- a/apps/backend/src/modules/auth/dto/update-profile.dto.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - IsString, - IsOptional, - MinLength, - Matches, - IsEmail, -} from "class-validator"; - -export class UpdateProfileDto { - @IsOptional() - @IsString() - @MinLength(2) - firstName?: string; - - @IsOptional() - @IsString() - middleName?: string; - - @IsOptional() - @IsString() - @MinLength(2) - lastName?: string; - - @IsOptional() - @IsEmail() - email?: string; - - @IsOptional() - @IsString() - @Matches(/^\+?[0-9]{10,15}$/) - phone?: string; -} diff --git a/apps/backend/src/modules/auth/dto/verify-email.dto.ts b/apps/backend/src/modules/auth/dto/verify-email.dto.ts deleted file mode 100644 index f12e66db..00000000 --- a/apps/backend/src/modules/auth/dto/verify-email.dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { IsEmail, IsString, Length } from "class-validator"; - -export class VerifyEmailDto { - @IsEmail() - email: string; - - @IsString() - @Length(6, 6) - code: string; -} diff --git a/apps/backend/src/modules/auth/dto/verify-phone-otp.dto.ts b/apps/backend/src/modules/auth/dto/verify-phone-otp.dto.ts deleted file mode 100644 index e70e3d7b..00000000 --- a/apps/backend/src/modules/auth/dto/verify-phone-otp.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IsNotEmpty, IsString, Length } from "class-validator"; - -export class VerifyPhoneOtpDto { - @IsNotEmpty() - @IsString() - phone: string; - - @IsNotEmpty() - @IsString() - @Length(6, 6) - code: string; -} diff --git a/apps/backend/src/modules/auth/internal-auth.controller.ts b/apps/backend/src/modules/auth/internal-auth.controller.ts deleted file mode 100644 index 3ee9b01b..00000000 --- a/apps/backend/src/modules/auth/internal-auth.controller.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { - Controller, - Post, - Body, - HttpCode, - HttpStatus, - UseGuards, - Req, - Res, -} from "@nestjs/common"; -import { Throttle } from "@nestjs/throttler"; -import type { Response } from "express"; -import { AuthService } from "./auth.service"; -import { AdminService } from "../admin/admin.service"; -import { LoginDto } from "./dto/login.dto"; -import { AdminRegisterDto } from "./dto/admin-register.dto"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; - -@Controller("auth/internal") -export class InternalAuthController { - constructor( - private readonly authService: AuthService, - private readonly adminService: AdminService, - ) {} - - private setCookies( - res: Response, - tokens: { accessToken: string; refreshToken: string }, - ) { - const isProd = process.env.NODE_ENV === "production"; - res.cookie("swifta_access_token", tokens.accessToken, { - httpOnly: true, - secure: isProd, - sameSite: isProd ? "none" : "lax", - maxAge: 15 * 60 * 1000, - path: "/", - }); - res.cookie("swifta_refresh_token", tokens.refreshToken, { - httpOnly: true, - secure: isProd, - sameSite: isProd ? "none" : "lax", - maxAge: 7 * 24 * 60 * 60 * 1000, - path: "/", - }); - } - - @Throttle({ default: { limit: 5, ttl: 60000 } }) - @Post("register") - async adminRegister(@Body() dto: AdminRegisterDto) { - return this.authService.adminRegister(dto); - } - - @Throttle({ default: { limit: 5, ttl: 60000 } }) - @Post("login") - @HttpCode(HttpStatus.OK) - async internalLogin( - @Body() dto: LoginDto, - @Res({ passthrough: true }) res: Response, - ) { - const result = await this.authService.internalLogin(dto); - this.setCookies(res, result); - return { user: result.user }; - } - - @Throttle({ default: { limit: 5, ttl: 60000 } }) - @Post("verify-token") - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - async verifyStaffToken(@Body("token") token: string, @Req() req: any) { - return this.adminService.verifyStaffToken( - req.user.sub, - req.user.role, - token, - ); - } -} diff --git a/apps/backend/src/modules/auth/strategies/jwt-access.strategy.ts b/apps/backend/src/modules/auth/strategies/jwt-access.strategy.ts deleted file mode 100644 index 280efafe..00000000 --- a/apps/backend/src/modules/auth/strategies/jwt-access.strategy.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { ExtractJwt, Strategy } from "passport-jwt"; -import { PassportStrategy } from "@nestjs/passport"; -import { Injectable, UnauthorizedException } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { JwtPayload, UserRole } from "@swifta/shared"; -import { PrismaService } from "../../../prisma/prisma.service"; - -import { Request } from "express"; - -const cookieExtractor = (req: Request): string | null => { - let token = null; - if (req && req.cookies && req.cookies["swifta_access_token"]) { - token = req.cookies["swifta_access_token"]; - } - // Fallback to Bearer token - if (!token && req.headers.authorization) { - const authHeader = req.headers.authorization; - if (authHeader.startsWith("Bearer ")) { - token = authHeader.substring(7); - } - } - return token; -}; - -const ADMIN_ROLES: string[] = [ - UserRole.SUPER_ADMIN, - UserRole.OPERATOR, - UserRole.SUPPORT, -]; - -@Injectable() -export class JwtAccessStrategy extends PassportStrategy( - Strategy, - "jwt-access", -) { - constructor( - configService: ConfigService, - private prisma: PrismaService, - ) { - super({ - jwtFromRequest: ExtractJwt.fromExtractors([cookieExtractor]), - ignoreExpiration: false, - secretOrKey: configService.get("jwt.accessSecret"), - }); - } - - async validate(payload: JwtPayload) { - // For admin/staff roles, verify they're still approved - if (ADMIN_ROLES.includes(payload.role)) { - try { - const adminProfile = await this.prisma.adminProfile.findUnique({ - where: { userId: payload.sub }, - select: { approvalStatus: true }, - }); - - if (adminProfile && adminProfile.approvalStatus !== "APPROVED") { - throw new UnauthorizedException( - adminProfile.approvalStatus === "SUSPENDED" - ? "Your account has been suspended." - : "Your account is awaiting approval.", - ); - } - } catch (err) { - if (err instanceof UnauthorizedException) throw err; - // Don't crash on DB errors — let the request through (fail open for non-critical check) - } - } - return payload; - } -} diff --git a/apps/backend/src/modules/auth/strategies/jwt-refresh.strategy.ts b/apps/backend/src/modules/auth/strategies/jwt-refresh.strategy.ts deleted file mode 100644 index 482cc174..00000000 --- a/apps/backend/src/modules/auth/strategies/jwt-refresh.strategy.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ExtractJwt, Strategy } from "passport-jwt"; -import { PassportStrategy } from "@nestjs/passport"; -import { Injectable } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; - -import { Request } from "express"; - -const cookieExtractor = (req: Request): string | null => { - let token = null; - if (req && req.cookies && req.cookies["swifta_refresh_token"]) { - token = req.cookies["swifta_refresh_token"]; - } - - // Non-production fallback to support easy Postman/Swagger testing without - // cookie manipulation. Production strictly enforces purely HttpOnly cookies. - if (process.env.NODE_ENV !== "production") { - if (!token && req.body && req.body.refreshToken) { - token = req.body.refreshToken; - } - } - - return token; -}; - -@Injectable() -export class JwtRefreshStrategy extends PassportStrategy( - Strategy, - "jwt-refresh", -) { - constructor(configService: ConfigService) { - super({ - jwtFromRequest: ExtractJwt.fromExtractors([cookieExtractor]), - ignoreExpiration: false, - secretOrKey: configService.get("jwt.refreshSecret"), - }); - } - - async validate(payload: any) { - return payload; - } -} diff --git a/apps/backend/src/modules/buyer/buyer-dashboard.service.ts b/apps/backend/src/modules/buyer/buyer-dashboard.service.ts deleted file mode 100644 index eab7b1a8..00000000 --- a/apps/backend/src/modules/buyer/buyer-dashboard.service.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { PrismaService } from "../../prisma/prisma.service"; - -@Injectable() -export class BuyerDashboardService { - constructor(private prisma: PrismaService) {} - - async getDashboardStats(userId: string) { - const [orders] = await Promise.all([ - this.prisma.order.findMany({ - where: { buyerId: userId }, - select: { - status: true, - totalAmountKobo: true, - }, - }), - ]); - - // Pending Quotes (Action Required): Deprecated - const pendingQuotesCount = 0; - - // Active Orders: Orders that are not COMPLETED or CANCELLED, but have been initialized/paid. - const activeOrdersCount = orders.filter( - (o) => o.status !== "COMPLETED" && o.status !== "CANCELLED", - ).length; - - // Total Spending (Escrow Locked): PAID or DISPATCHED - const escrowLocked = orders - .filter((o) => o.status === "PAID" || o.status === "DISPATCHED") - .reduce((sum, o) => sum + BigInt(o.totalAmountKobo || 0), 0n); - - // Total Orders (All Time) - const totalOrdersCount = orders.length; - - return { - activeOrdersCount, - pendingQuotesCount, - totalOrdersCount, - totalSpendingKobo: escrowLocked.toString(), - recentOrders: orders.slice(0, 5), // Optional: could add more details if needed - }; - } -} diff --git a/apps/backend/src/modules/buyer/buyer.controller.ts b/apps/backend/src/modules/buyer/buyer.controller.ts deleted file mode 100644 index c623994a..00000000 --- a/apps/backend/src/modules/buyer/buyer.controller.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Controller, Get, UseGuards } from "@nestjs/common"; -import { BuyerDashboardService } from "./buyer-dashboard.service"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { RolesGuard } from "../../common/guards/roles.guard"; -import { Roles } from "../../common/decorators/roles.decorator"; -import { CurrentUser } from "../../common/decorators/current-user.decorator"; -import { UserRole, JwtPayload } from "@swifta/shared"; - -@Controller("buyer") -export class BuyerController { - constructor(private readonly dashboardService: BuyerDashboardService) {} - - @Get("dashboard/stats") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.BUYER) - async getDashboardStats(@CurrentUser() user: JwtPayload) { - return this.dashboardService.getDashboardStats(user.sub); - } -} diff --git a/apps/backend/src/modules/buyer/buyer.module.ts b/apps/backend/src/modules/buyer/buyer.module.ts deleted file mode 100644 index 463442e3..00000000 --- a/apps/backend/src/modules/buyer/buyer.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from "@nestjs/common"; -import { BuyerDashboardService } from "./buyer-dashboard.service"; -import { BuyerController } from "./buyer.controller"; -import { PrismaModule } from "../../prisma/prisma.module"; - -@Module({ - imports: [PrismaModule], - controllers: [BuyerController], - providers: [BuyerDashboardService], - exports: [BuyerDashboardService], -}) -export class BuyerModule {} diff --git a/apps/backend/src/modules/cart/cart.controller.ts b/apps/backend/src/modules/cart/cart.controller.ts deleted file mode 100644 index 928cd082..00000000 --- a/apps/backend/src/modules/cart/cart.controller.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - Controller, - Get, - Post, - Body, - Patch, - Param, - Delete, - UseGuards, -} from "@nestjs/common"; -import { CartService } from "./cart.service"; -import { AddToCartDto } from "./dto/add-to-cart.dto"; -import { UpdateCartItemDto } from "./dto/update-cart-item.dto"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { CurrentUser } from "../../common/decorators/current-user.decorator"; -import { JwtPayload } from "@swifta/shared"; - -@Controller("cart") -@UseGuards(JwtAuthGuard) -export class CartController { - constructor(private readonly cartService: CartService) {} - - @Get() - getCart(@CurrentUser() user: JwtPayload) { - return this.cartService.getCart(user.sub); - } - - @Post("items") - addItem(@CurrentUser() user: JwtPayload, @Body() dto: AddToCartDto) { - return this.cartService.addItemToCart(user.sub, dto); - } - - @Patch("items/:id") - updateItem( - @CurrentUser() user: JwtPayload, - @Param("id") id: string, - @Body() dto: UpdateCartItemDto, - ) { - return this.cartService.updateItemQuantity(user.sub, id, dto); - } - - @Delete("items/:id") - removeItem(@CurrentUser() user: JwtPayload, @Param("id") id: string) { - return this.cartService.removeItemFromCart(user.sub, id); - } - - @Delete() - clearCart(@CurrentUser() user: JwtPayload) { - return this.cartService.clearCart(user.sub); - } -} diff --git a/apps/backend/src/modules/cart/cart.module.ts b/apps/backend/src/modules/cart/cart.module.ts deleted file mode 100644 index 5a3eb3cb..00000000 --- a/apps/backend/src/modules/cart/cart.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from "@nestjs/common"; -import { CartService } from "./cart.service"; -import { CartController } from "./cart.controller"; -import { PrismaModule } from "../../prisma/prisma.module"; - -@Module({ - imports: [PrismaModule], - controllers: [CartController], - providers: [CartService], - exports: [CartService], -}) -export class CartModule {} diff --git a/apps/backend/src/modules/cart/cart.service.ts b/apps/backend/src/modules/cart/cart.service.ts deleted file mode 100644 index e3d44116..00000000 --- a/apps/backend/src/modules/cart/cart.service.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { - Injectable, - NotFoundException, - BadRequestException, -} from "@nestjs/common"; -import { PrismaService } from "../../prisma/prisma.service"; -import { AddToCartDto } from "./dto/add-to-cart.dto"; -import { UpdateCartItemDto } from "./dto/update-cart-item.dto"; -import { PriceType } from "@swifta/shared"; - -@Injectable() -export class CartService { - constructor(private readonly prisma: PrismaService) {} - - async getCart(buyerId: string) { - const items = await this.prisma.cartItem.findMany({ - where: { buyerId }, - include: { - product: { - include: { - merchantProfile: { - select: { - businessName: true, - verificationTier: true, - businessAddress: true, - }, - }, - }, - }, - }, - orderBy: { createdAt: "desc" }, - }); - - let subtotalKobo = BigInt(0); - const cartObj = items.map((item) => { - // Calculate based on the item's saved priceType - const isWholesale = item.priceType === PriceType.WHOLESALE; - const priceKobo = isWholesale - ? (item.product.wholesalePriceKobo ?? - item.product.pricePerUnitKobo ?? - BigInt(0)) - : (item.product.retailPriceKobo ?? - item.product.pricePerUnitKobo ?? - BigInt(0)); - - const itemTotalKobo = priceKobo * BigInt(item.quantity); - subtotalKobo += itemTotalKobo; - - return { - id: item.id, - productId: item.productId, - quantity: item.quantity, - priceType: item.priceType, - product: { - name: item.product.name, - imageUrl: item.product.imageUrl, - priceKobo: priceKobo.toString(), - merchantName: - item.product.merchantProfile?.businessName || "Unknown Merchant", - merchantId: item.product.merchantId, - merchantTier: item.product.merchantProfile?.verificationTier, - merchantAddress: item.product.merchantProfile?.businessAddress, - unit: item.product.unit, - - minOrderQuantity: item.product.minOrderQuantity, - minOrderQuantityConsumer: item.product.minOrderQuantityConsumer, - }, - - itemTotalKobo: itemTotalKobo.toString(), - }; - }); - - return { - items: cartObj, - subtotalKobo: subtotalKobo.toString(), - }; - } - - async addItemToCart(buyerId: string, dto: AddToCartDto) { - const product = await this.prisma.product.findUnique({ - where: { id: dto.productId, isActive: true, deletedAt: null }, - }); - - if (!product) { - throw new NotFoundException("Product not found or unavailable"); - } - - const priceType = dto.priceType || PriceType.RETAIL; - const minQty = - priceType === PriceType.WHOLESALE - ? product.minOrderQuantity - : product.minOrderQuantityConsumer; - - if (dto.quantity < minQty) { - throw new BadRequestException( - `Minimum order quantity for ${priceType} is ${minQty}`, - ); - } - - const priceKobo = - priceType === PriceType.WHOLESALE - ? (product.wholesalePriceKobo ?? product.pricePerUnitKobo) - : (product.retailPriceKobo ?? product.pricePerUnitKobo); - - if (!priceKobo) { - throw new BadRequestException( - `Product does not have a valid price for the ${priceType} tier.`, - ); - } - - // Upsert the cart item: if it exists for this SPECIFIC priceType, add quantity - const existingItem = await this.prisma.cartItem.findFirst({ - where: { - buyerId, - productId: dto.productId, - priceType, - }, - }); - - if (existingItem) { - const newQty = existingItem.quantity + dto.quantity; - return this.prisma.cartItem.update({ - where: { id: existingItem.id }, - data: { - quantity: newQty, - }, - }); - } - - return this.prisma.cartItem.create({ - data: { - buyerId, - productId: dto.productId, - quantity: dto.quantity, - priceType, - }, - }); - } - - async updateItemQuantity( - buyerId: string, - itemId: string, - dto: UpdateCartItemDto, - ) { - const item = await this.prisma.cartItem.findFirst({ - where: { id: itemId, buyerId }, - include: { product: true }, - }); - - if (!item) { - throw new NotFoundException("Cart item not found"); - } - - const minQty = - item.priceType === PriceType.WHOLESALE - ? item.product.minOrderQuantity - : item.product.minOrderQuantityConsumer; - - if (dto.quantity < minQty) { - throw new BadRequestException( - `Minimum order quantity for ${item.priceType} is ${minQty}`, - ); - } - - return this.prisma.cartItem.update({ - where: { id: itemId }, - data: { quantity: dto.quantity }, - }); - } - - async removeItemFromCart(buyerId: string, itemId: string) { - const item = await this.prisma.cartItem.findFirst({ - where: { id: itemId, buyerId }, - }); - - if (!item) { - throw new NotFoundException("Cart item not found"); - } - - await this.prisma.cartItem.delete({ - where: { id: itemId }, - }); - - return { success: true }; - } - - async clearCart(buyerId: string) { - await this.prisma.cartItem.deleteMany({ - where: { buyerId }, - }); - - return { success: true }; - } -} diff --git a/apps/backend/src/modules/cart/dto/add-to-cart.dto.ts b/apps/backend/src/modules/cart/dto/add-to-cart.dto.ts deleted file mode 100644 index c28aa146..00000000 --- a/apps/backend/src/modules/cart/dto/add-to-cart.dto.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - IsEnum, - IsNotEmpty, - IsNumber, - IsOptional, - IsString, - IsUUID, - Min, -} from "class-validator"; -import { PriceType } from "@swifta/shared"; - -export class AddToCartDto { - @IsString() - @IsNotEmpty() - @IsUUID() - productId: string; - - @IsNumber() - @IsNotEmpty() - @Min(1) - quantity: number; - - @IsEnum(PriceType) - @IsOptional() - priceType?: PriceType; -} diff --git a/apps/backend/src/modules/cart/dto/update-cart-item.dto.ts b/apps/backend/src/modules/cart/dto/update-cart-item.dto.ts deleted file mode 100644 index 1c76e466..00000000 --- a/apps/backend/src/modules/cart/dto/update-cart-item.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { IsNotEmpty, IsNumber, Min } from "class-validator"; - -export class UpdateCartItemDto { - @IsNumber() - @IsNotEmpty() - @Min(1) - quantity: number; -} diff --git a/apps/backend/src/modules/category/category.controller.ts b/apps/backend/src/modules/category/category.controller.ts deleted file mode 100644 index d2ed405d..00000000 --- a/apps/backend/src/modules/category/category.controller.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - Controller, - Get, - Post, - Body, - Put, - Param, - Delete, - UseGuards, - ParseUUIDPipe, -} from "@nestjs/common"; -import { CategoryService } from "./category.service"; -import { CreateCategoryDto } from "./dto/create-category.dto"; -import { UpdateCategoryDto } from "./dto/update-category.dto"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { RolesGuard } from "../../common/guards/roles.guard"; -import { Roles } from "../../common/decorators/roles.decorator"; -import { UserRole } from "@swifta/shared"; - -@Controller("categories") -export class CategoryController { - constructor(private readonly categoryService: CategoryService) {} - - @Get() - findAll() { - return this.categoryService.findTree(); - } - - @Get(":slug") - findBySlug(@Param("slug") slug: string) { - return this.categoryService.findBySlug(slug); - } - - @Post() - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.ADMIN) - create(@Body() dto: CreateCategoryDto) { - return this.categoryService.create(dto); - } - - @Put(":id") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.ADMIN) - update( - @Param("id", ParseUUIDPipe) id: string, - @Body() dto: UpdateCategoryDto, - ) { - return this.categoryService.update(id, dto); - } - - @Delete(":id") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.ADMIN) - remove(@Param("id", ParseUUIDPipe) id: string) { - return this.categoryService.remove(id); - } -} diff --git a/apps/backend/src/modules/dva/dva.controller.ts b/apps/backend/src/modules/dva/dva.controller.ts deleted file mode 100644 index 2c1ede7c..00000000 --- a/apps/backend/src/modules/dva/dva.controller.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - Controller, - Post, - Get, - UseGuards, - HttpCode, - HttpStatus, -} from "@nestjs/common"; -import { DvaService } from "./dva.service"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { RolesGuard } from "../../common/guards/roles.guard"; -import { Roles } from "../../common/decorators/roles.decorator"; -import { CurrentUser } from "../../common/decorators/current-user.decorator"; -import { UserRole, JwtPayload } from "@swifta/shared"; - -@Controller("buyer/dva") -@UseGuards(JwtAuthGuard, RolesGuard) -@Roles(UserRole.BUYER) -export class DvaController { - constructor(private readonly dvaService: DvaService) {} - - @Post("create") - @HttpCode(HttpStatus.CREATED) - async createDva(@CurrentUser() user: JwtPayload) { - return this.dvaService.createDva(user.sub); - } - - @Get() - async getDva(@CurrentUser() user: JwtPayload) { - return this.dvaService.getDva(user.sub); - } -} diff --git a/apps/backend/src/modules/dva/dva.module.ts b/apps/backend/src/modules/dva/dva.module.ts deleted file mode 100644 index ca3cf247..00000000 --- a/apps/backend/src/modules/dva/dva.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module, forwardRef } from "@nestjs/common"; -import { DvaController } from "./dva.controller"; -import { DvaService } from "./dva.service"; -import { PrismaModule } from "../../prisma/prisma.module"; -import { PaymentModule } from "../payment/payment.module"; - -@Module({ - imports: [PrismaModule, forwardRef(() => PaymentModule)], - controllers: [DvaController], - providers: [DvaService], - exports: [DvaService], -}) -export class DvaModule {} diff --git a/apps/backend/src/modules/dva/dva.service.ts b/apps/backend/src/modules/dva/dva.service.ts deleted file mode 100644 index abb3881d..00000000 --- a/apps/backend/src/modules/dva/dva.service.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { - Injectable, - Logger, - BadRequestException, - NotFoundException, -} from "@nestjs/common"; -import { PrismaService } from "../../prisma/prisma.service"; -import { PaystackClient } from "../payment/paystack.client"; - -@Injectable() -export class DvaService { - private readonly logger = new Logger(DvaService.name); - - constructor( - private readonly prisma: PrismaService, - private readonly paystack: PaystackClient, - ) {} - - /** - * Assures a Paystack Customer exists for the buyer. - * Creates one on Paystack if missing, then saves the ID/Code to DB. - */ - async assureCustomer( - buyerProfileId: string, - userId: string, - ): Promise { - const profile = await this.prisma.buyerProfile.findUnique({ - where: { id: buyerProfileId }, - include: { user: true }, - }); - - if (!profile || !profile.user) { - throw new NotFoundException("Buyer profile or user not found"); - } - - if (profile.paystackCustomerCode) { - return profile.paystackCustomerCode; - } - - this.logger.log(`Creating Paystack customer for user ${userId}`); - - const newCustomer = await this.paystack.createCustomer({ - email: profile.user.email, - firstName: profile.user.firstName, - lastName: profile.user.lastName, - phone: profile.user.phone, - }); - - if (!newCustomer?.status || !newCustomer?.data?.customer_code) { - this.logger.error("Failed to create Paystack customer", newCustomer); - throw new BadRequestException("Could not create Paystack customer"); - } - - const { customer_code, id } = newCustomer.data; - - await this.prisma.buyerProfile.update({ - where: { id: buyerProfileId }, - data: { - paystackCustomerCode: customer_code, - paystackCustomerId: String(id), - }, - }); - - return customer_code; - } - - /** - * Creates a Dedicated Virtual Account for the buyer. - */ - async createDva(userId: string): Promise { - const profile = await this.prisma.buyerProfile.findUnique({ - where: { userId }, - }); - - if (!profile) { - throw new NotFoundException("Buyer profile not found"); - } - - if (profile.dvaActive && profile.dvaAccountNumber) { - throw new BadRequestException("Buyer already has an active DVA"); - } - - // Ensure customer exists on Paystack - const customerCode = await this.assureCustomer(profile.id, userId); - - this.logger.log(`Creating DVA for customer ${customerCode}`); - - // Create DVA - const dvaResponse = - await this.paystack.createDedicatedVirtualAccount(customerCode); - - if (!dvaResponse?.status) { - this.logger.error("Failed to create DVA", dvaResponse); - throw new BadRequestException( - "Could not create Dedicated Virtual Account", - ); - } - - // Paystack processes DVAs asynchronously sometimes, but often returns details immediately - // If it returns them, save them immediately. Otherwise, rely on webhooks. - if (dvaResponse.data?.account_number) { - const data = dvaResponse.data; - await this.prisma.buyerProfile.update({ - where: { id: profile.id }, - data: { - dvaAccountNumber: data.account_number, - dvaAccountName: data.account_name, - dvaBankName: data.bank?.name, - dvaBankSlug: data.bank?.slug, - dvaActive: true, - }, - }); - } - - return { - message: "DVA creation initiated successfully", - status: "PENDING_OR_CREATED", - dva: dvaResponse.data - ? { - accountNumber: dvaResponse.data.account_number, - accountName: dvaResponse.data.account_name, - bankName: dvaResponse.data.bank?.name, - } - : null, - }; - } - - /** - * Get the current user's DVA status/details - */ - async getDva(userId: string): Promise { - const profile = await this.prisma.buyerProfile.findUnique({ - where: { userId }, - select: { - dvaActive: true, - dvaAccountNumber: true, - dvaAccountName: true, - dvaBankName: true, - }, - }); - - if (!profile) { - throw new NotFoundException("Buyer profile not found"); - } - - return { - active: profile.dvaActive, - accountNumber: profile.dvaAccountNumber, - accountName: profile.dvaAccountName, - bankName: profile.dvaBankName, - }; - } - - /** - * Called by the PaymentService webhook handler when a DVA is successfully assigned - */ - async handleDvaAssignSuccess(payload: any): Promise { - const { customer, dedicated_account } = payload; - - if (!customer?.customer_code || !dedicated_account?.account_number) { - this.logger.warn("Invalid DVA success webhook payload", payload); - return; - } - - const profile = await this.prisma.buyerProfile.findFirst({ - where: { paystackCustomerCode: customer.customer_code }, - }); - - if (!profile) { - this.logger.warn( - `Received DVA webhook for unknown customer code ${customer.customer_code}`, - ); - return; - } - - await this.prisma.buyerProfile.update({ - where: { id: profile.id }, - data: { - dvaAccountNumber: dedicated_account.account_number, - dvaAccountName: dedicated_account.account_name, - dvaBankName: dedicated_account.bank?.name, - dvaBankSlug: dedicated_account.bank?.slug, - dvaActive: true, - }, - }); - - this.logger.log(`Successfully activated DVA for buyer ${profile.id}`); - } - - /** - * Called by the PaymentService webhook handler when DVA assignment fails - */ - async handleDvaAssignFailed(payload: any): Promise { - const { customer } = payload; - - if (!customer?.customer_code) { - this.logger.warn("Invalid DVA failed webhook payload", payload); - return; - } - - const profile = await this.prisma.buyerProfile.findFirst({ - where: { paystackCustomerCode: customer.customer_code }, - }); - - if (!profile) { - return; - } - - // Ideally, we might want a 'failed' state, but setting active=false is sufficient here - await this.prisma.buyerProfile.update({ - where: { id: profile.id }, - data: { - dvaActive: false, - }, - }); - - this.logger.error(`DVA assignment failed for buyer ${profile.id}`); - } -} diff --git a/apps/backend/src/modules/email/email.module.ts b/apps/backend/src/modules/email/email.module.ts deleted file mode 100644 index 5dd231d6..00000000 --- a/apps/backend/src/modules/email/email.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module, Global } from "@nestjs/common"; -import { EmailService } from "./email.service"; - -@Global() -@Module({ - providers: [EmailService], - exports: [EmailService], -}) -export class EmailModule {} diff --git a/apps/backend/src/modules/email/email.service.ts b/apps/backend/src/modules/email/email.service.ts deleted file mode 100644 index b811de6f..00000000 --- a/apps/backend/src/modules/email/email.service.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { Resend } from "resend"; -import { PlatformConfig } from "../../config/platform.config"; - -@Injectable() -export class EmailService { - private resend: Resend; - private readonly logger = new Logger(EmailService.name); - private fromEmail: string; - - constructor(private configService: ConfigService) { - const apiKey = this.configService.get("RESEND_API_KEY"); - this.resend = new Resend(apiKey); - this.fromEmail = - this.configService.get("EMAIL_FROM") || "onboarding@resend.dev"; - } - - /** - * Core send method wrapped in try/catch to ensure email failures never break the flow - */ - async sendEmail(to: string, subject: string, html: string): Promise { - try { - this.logger.log(`Sending email to ${to}: ${subject}`); - - const { data, error } = await this.resend.emails.send({ - from: `Swifta <${this.fromEmail}>`, - to: [to], - subject: `Swifta | ${subject}`, - html, - }); - - if (error) { - this.logger.error( - `Resend API Error: ${error.message} (to: ${to}, subject: ${subject})`, - ); - throw new Error(`Resend API Error: ${error.message}`); - } - - this.logger.log(`Email sent successfully: ${data?.id}`); - } catch (err: any) { - this.logger.error( - `Critical error in EmailService.sendEmail: ${err.message}`, - ); - throw err; - } - } - - private escapeHtml(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - } - - private formatNaira(kobo: number | bigint): string { - const naira = Number(kobo) / 100; - return new Intl.NumberFormat("en-NG", { - style: "currency", - currency: "NGN", - minimumFractionDigits: 2, - }) - .format(naira) - .replace("NGN", "₦"); - } - - private getLayout(content: string): string { - return ` -
-
-

Swifta

-
-
- ${content} -
-

© ${new Date().getFullYear()} Swifta. Built for Lagos trade.

-
-
-
- `; - } - - async sendWelcomeEmail( - to: string, - name: string, - role: string, - ): Promise { - const safeName = this.escapeHtml(name); - const safeRole = this.escapeHtml(role); - const content = ` -

Welcome to Swifta, ${safeName}!

-

We're excited to have you on board as a ${safeRole}.

-

Swifta is digitizing Africa's digital trade network, and you're now part of the movement.

- ${ - role === "MERCHANT" - ? `

Next step: Complete your business profile and start listing your products to receive orders from buyers.

` - : `

Next step: Browse our merchant catalogues and start shopping for your favorite products.

` - } - - `; - await this.sendEmail(to, "Welcome to Swifta", this.getLayout(content)); - } - - async sendVerificationOTP(to: string, otp: string): Promise { - const safeOtp = this.escapeHtml(otp); - const content = ` -

Your Verification Code

-

Please use the following code to verify your account. It expires in ${PlatformConfig.timers.otpExpiryEmailMinutes} minutes.

-
-

${safeOtp}

-
- `; - await this.sendEmail(to, "Your Verification Code", this.getLayout(content)); - } - - async sendPaymentConfirmedNotification( - to: string, - reference: string, - amountKobo: bigint, - isMerchant: boolean, - ): Promise { - const safeReference = this.escapeHtml(reference.slice(0, 8)); - const content = ` -

Payment Confirmed

-

Payment of ${this.formatNaira(amountKobo)} has been confirmed for Order #${safeReference}.

- ${ - isMerchant - ? `

Please prepare the goods for dispatch. Your payout will be released once delivery is confirmed.

` - : `

Your payment is held securely in escrow. The merchant has been notified to dispatch your goods.

` - } - `; - await this.sendEmail( - to, - `Payment confirmed for Order #${safeReference}`, - this.getLayout(content), - ); - } - - async sendOrderDispatchedNotification( - to: string, - reference: string, - otp: string, - ): Promise { - const safeReference = this.escapeHtml(reference.slice(0, 8)); - const safeOtp = this.escapeHtml(otp); - const content = ` -

Your Order Has Been Dispatched!

-

Order #${safeReference} is on its way to your delivery address.

-
-

Delivery Verification Code:

-

${safeOtp}

-

IMPORTANT: Only share this code with the driver AFTER you have inspected and received your goods.

-
- `; - await this.sendEmail( - to, - "Your order has been dispatched", - this.getLayout(content), - ); - } - - async sendDeliveryConfirmedNotification( - to: string, - reference: string, - amountKobo: bigint, - ): Promise { - const safeReference = this.escapeHtml(reference.slice(0, 8)); - const content = ` -

Delivery Confirmed Success!

-

Delivery of Order #${safeReference} has been confirmed.

-

The transaction of ${this.formatNaira(amountKobo)} is now complete.

-

Thank you for trading with Swifta!

- `; - await this.sendEmail( - to, - `Order #${safeReference} delivered successfully`, - this.getLayout(content), - ); - } - - async sendPasswordResetEmail( - to: string, - resetToken: string, - frontendUrl: string, - ): Promise { - const resetUrl = `${frontendUrl}/reset-password?token=${encodeURIComponent(resetToken)}`; - const content = ` -

Password Reset Request

-

We received a request to reset your password. If you didn't make this request, you can safely ignore this email.

- -

This link will expire in ${PlatformConfig.timers.otpExpiryAuthMinutes} minutes.

- `; - await this.sendEmail( - to, - "Reset your Swifta Password", - this.getLayout(content), - ); - } -} diff --git a/apps/backend/src/modules/inventory/inventory.controller.ts b/apps/backend/src/modules/inventory/inventory.controller.ts deleted file mode 100644 index 42094c15..00000000 --- a/apps/backend/src/modules/inventory/inventory.controller.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - Controller, - Get, - Body, - Param, - UseGuards, - Post, - Query, - ParseUUIDPipe, -} from "@nestjs/common"; -import { InventoryService } from "./inventory.service"; -import { UpdateStockDto } from "./dto/update-stock.dto"; -import { PaginationQueryDto } from "../../common/dto/pagination-query.dto"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { RolesGuard } from "../../common/guards/roles.guard"; -import { Roles } from "../../common/decorators/roles.decorator"; -import { CurrentMerchant } from "../../common/decorators/current-merchant.decorator"; -import { UserRole } from "@swifta/shared"; - -@Controller("inventory") -@UseGuards(JwtAuthGuard, RolesGuard) -export class InventoryController { - constructor(private readonly inventoryService: InventoryService) {} - - @Get("products/:productId") - @Roles(UserRole.MERCHANT) - getHistory( - @CurrentMerchant() merchantId: string, - @Param("productId", ParseUUIDPipe) productId: string, - @Query() query: PaginationQueryDto, - ) { - return this.inventoryService.getHistory( - merchantId, - productId, - query.page, - query.limit, - ); - } - - @Get("products/:productId/stock") - @Roles(UserRole.MERCHANT) - getStockLevel( - @CurrentMerchant() merchantId: string, - @Param("productId", ParseUUIDPipe) productId: string, - ) { - return this.inventoryService.getStockLevel(merchantId, productId); - } - - @Post("products/:productId/adjust") - @Roles(UserRole.MERCHANT) - adjustStock( - @CurrentMerchant() merchantId: string, - @Param("productId", ParseUUIDPipe) productId: string, - @Body() dto: UpdateStockDto, - ) { - return this.inventoryService.manualAdjustment( - merchantId, - productId, - dto.quantity, - dto.notes, - ); - } -} diff --git a/apps/backend/src/modules/inventory/inventory.module.ts b/apps/backend/src/modules/inventory/inventory.module.ts deleted file mode 100644 index e1aba048..00000000 --- a/apps/backend/src/modules/inventory/inventory.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module, Global } from "@nestjs/common"; -import { InventoryService } from "./inventory.service"; -import { InventoryController } from "./inventory.controller"; -import { PrismaModule } from "../../prisma/prisma.module"; - -@Global() -@Module({ - imports: [PrismaModule], - controllers: [InventoryController], - providers: [InventoryService], - exports: [InventoryService], -}) -export class InventoryModule {} diff --git a/apps/backend/src/modules/inventory/inventory.service.ts b/apps/backend/src/modules/inventory/inventory.service.ts deleted file mode 100644 index e06b1cd1..00000000 --- a/apps/backend/src/modules/inventory/inventory.service.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { - Injectable, - NotFoundException, - ForbiddenException, -} from "@nestjs/common"; -import { PrismaService } from "../../prisma/prisma.service"; -import { InventoryEventType } from "@swifta/shared"; - -@Injectable() -export class InventoryService { - constructor(private prisma: PrismaService) {} - - async reserveStock( - productId: string, - merchantId: string, - quantity: number, - orderId: string, - ) { - await this.createEvent( - productId, - merchantId, - InventoryEventType.ORDER_RESERVED, - -quantity, - orderId, - "Order reservation", - ); - } - - async releaseStock( - productId: string, - merchantId: string, - quantity: number, - orderId: string, - ) { - await this.createEvent( - productId, - merchantId, - InventoryEventType.ORDER_RELEASED, - quantity, - orderId, - "Order cancellation release", - ); - } - - async releaseStockBatch( - items: { productId: string; quantity: number }[], - merchantId: string, - orderId: string, - ) { - await this.prisma.$transaction(async (tx) => { - for (const item of items) { - // 1. Record event - await tx.inventoryEvent.create({ - data: { - productId: item.productId, - merchantId, - eventType: InventoryEventType.ORDER_RELEASED, - quantity: item.quantity, - referenceId: orderId, - notes: "Order cancellation release (batch)", - }, - }); - - // 2. Update stock cache atomically - await tx.productStockCache.upsert({ - where: { productId: item.productId }, - create: { productId: item.productId, stock: item.quantity }, - update: { stock: { increment: item.quantity } }, - }); - } - }); - } - - async manualAdjustment( - merchantId: string, - productId: string, - quantity: number, - notes?: string, - ) { - // Verify product ownership - const product = await this.prisma.product.findUnique({ - where: { id: productId }, - }); - if (!product) throw new NotFoundException("Product not found"); - if (product.merchantId !== merchantId) - throw new ForbiddenException("Access denied"); - - const type = - quantity > 0 ? InventoryEventType.STOCK_IN : InventoryEventType.STOCK_OUT; - - await this.createEvent( - productId, - merchantId, - type, - quantity, - null, - notes || "Manual adjustment", - ); - - return { message: "Stock adjusted" }; - } - - async getStockLevel(merchantId: string, productId: string) { - const product = await this.prisma.product.findUnique({ - where: { id: productId }, - }); - if (!product) throw new NotFoundException("Product not found"); - if (product.merchantId !== merchantId) - throw new ForbiddenException("Access denied"); - - const cache = await this.prisma.productStockCache.findUnique({ - where: { productId }, - }); - - return { - productId, - stock: cache?.stock ?? 0, - updatedAt: cache?.updatedAt ?? null, - }; - } - - async getHistory( - merchantId: string, - productId: string, - page: number, - limit: number, - ) { - const product = await this.prisma.product.findUnique({ - where: { id: productId }, - }); - if (!product) throw new NotFoundException("Product not found"); - if (product.merchantId !== merchantId) - throw new ForbiddenException("Access denied"); - - const skip = (page - 1) * limit; - const [data, total] = await Promise.all([ - this.prisma.inventoryEvent.findMany({ - where: { productId }, - skip, - take: limit, - orderBy: { createdAt: "desc" }, - }), - this.prisma.inventoryEvent.count({ where: { productId } }), - ]); - - return { data, meta: { page, limit, total } }; - } - - /** - * Atomic: creates inventory event + updates stock cache in one transaction. - * This is the single source of truth for all stock changes. - */ - private async createEvent( - productId: string, - merchantId: string, - eventType: InventoryEventType, - quantity: number, - referenceId: string | null, - notes: string, - ) { - await this.prisma.$transaction(async (tx) => { - // 1. Record event - await tx.inventoryEvent.create({ - data: { - productId, - merchantId, - eventType, - quantity, - referenceId, - notes, - }, - }); - - // 2. Update stock cache atomically - await tx.productStockCache.upsert({ - where: { productId }, - create: { productId, stock: quantity }, - update: { stock: { increment: quantity } }, - }); - }); - } -} diff --git a/apps/backend/src/modules/logistics/clients/logistics.client.ts b/apps/backend/src/modules/logistics/clients/logistics.client.ts deleted file mode 100644 index c0c82724..00000000 --- a/apps/backend/src/modules/logistics/clients/logistics.client.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { DeliveryStatus } from "@prisma/client"; - -export interface LogisticsClient { - getQuote( - pickup: string, - delivery: string, - weightKg?: number, - ): Promise<{ costKobo: bigint; estimatedMinutes: number }>; - - bookPickup( - orderId: string, - pickup: string, - delivery: string, - contactPhone: string, - ): Promise<{ bookingRef: string; trackingUrl: string }>; - - getStatus( - bookingRef: string, - ): Promise<{ status: DeliveryStatus; location?: string; eta?: Date }>; -} - -@Injectable() -export class MockLogisticsClient implements LogisticsClient { - async getQuote( - pickup: string, - delivery: string, - weightKg?: number, - ): Promise<{ costKobo: bigint; estimatedMinutes: number }> { - // Generate a realistic but fake distance-based cost - const baseCost = 200000n; // 2000 NGN - const weightFactor = weightKg ? BigInt(weightKg * 5000) : 0n; // 50 NGN per kg - const randomDistance = Math.floor(Math.random() * 20) + 5; // 5 to 25 km - - return { - costKobo: baseCost + weightFactor + BigInt(randomDistance * 10000), // NGN 100 per km - estimatedMinutes: randomDistance * 3 + 15, - }; - } - - async bookPickup( - _orderId: string, - _pickup: string, - _delivery: string, - _contactPhone: string, - ): Promise<{ bookingRef: string; trackingUrl: string }> { - const bookingRef = `MOCK-LOG-${Date.now().toString().slice(-6)}-${_orderId.slice(0, 4)}`; - return { - bookingRef, - trackingUrl: `https://track.mocklogistics.com/${bookingRef}`, - }; - } - - async getStatus( - _bookingRef: string, - ): Promise<{ status: DeliveryStatus; location?: string; eta?: Date }> { - // In a real app we'd fetch this from the partner API. - // For the mock, we simulate it advancing or default to PICKUP_SCHEDULED. - return { - status: "PICKUP_SCHEDULED", - location: "Logistics Hub A", - eta: new Date(Date.now() + 60 * 60 * 1000), // ETA in 1 hr - }; - } -} diff --git a/apps/backend/src/modules/logistics/logistics.controller.ts b/apps/backend/src/modules/logistics/logistics.controller.ts deleted file mode 100644 index 18e0b164..00000000 --- a/apps/backend/src/modules/logistics/logistics.controller.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { - Controller, - Post, - Get, - Body, - Param, - Req, - UseGuards, - UnauthorizedException, - ForbiddenException, - Logger, -} from "@nestjs/common"; -import * as crypto from "crypto"; -import { LogisticsService } from "./logistics.service"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { UserRole } from "@swifta/shared"; -import { RolesGuard } from "../../common/guards/roles.guard"; -import { Roles } from "../../common/decorators/roles.decorator"; - -const REPLAY_WINDOW_MS = 5 * 60 * 1000; // 5 minutes - -@Controller("logistics") -export class LogisticsController { - private readonly logger = new Logger(LogisticsController.name); - - constructor(private readonly logisticsService: LogisticsService) {} - - @Post("quote") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.BUYER, UserRole.MERCHANT) - async getQuote( - @Body() - dto: { - pickupAddress: string; - deliveryAddress: string; - weightKg?: number; - }, - ) { - return this.logisticsService.getQuote( - dto.pickupAddress, - dto.deliveryAddress, - dto.weightKg, - ); - } - - // Internal endpoint usually triggered by queues, but could be exposed for Admins to retry - @Post("book") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.SUPER_ADMIN, UserRole.OPERATOR) - async bookPickup(@Body() dto: { orderId: string }) { - return this.logisticsService.bookPickup(dto.orderId); - } - - @Post("webhook") - async handleWebhook(@Body() payload: any, @Req() req: any) { - // 0. Optional IP allowlisting - const allowedIps = process.env.LOGISTICS_ALLOWED_IPS; - if (allowedIps) { - const clientIp = req.ip; - const ipList = allowedIps.split(",").map((ip) => ip.trim()); - if (!clientIp || !ipList.includes(clientIp)) { - this.logger.warn(`Webhook rejected: IP ${clientIp} not in allowlist`); - throw new ForbiddenException("Unauthorized source IP"); - } - } - - const secret = process.env.LOGISTICS_WEBHOOK_SECRET; - const signature = req.headers["x-logistics-signature"] as string; - const timestamp = req.headers["x-logistics-timestamp"] as string; - - // 1. Validate timestamp to prevent replay attacks - if (timestamp) { - const ts = parseInt(timestamp, 10); - if (isNaN(ts) || Date.now() - ts > REPLAY_WINDOW_MS) { - this.logger.warn( - "Webhook rejected: timestamp out of window or invalid", - ); - throw new UnauthorizedException( - "Request timestamp is expired or invalid", - ); - } - } - - // 2. Verify HMAC signature if secret is configured - if (secret) { - if (!signature) { - this.logger.warn( - "Webhook rejected: missing x-logistics-signature header", - ); - throw new UnauthorizedException("Missing webhook signature"); - } - - const rawBody = req.rawBody - ? req.rawBody.toString("utf8") - : JSON.stringify(payload); - - const expected = crypto - .createHmac("sha256", secret) - .update(rawBody) - .digest("hex"); - - const sigBuffer = Buffer.from(signature); - const expectedBuffer = Buffer.from(expected); - - if ( - sigBuffer.length !== expectedBuffer.length || - !crypto.timingSafeEqual(sigBuffer, expectedBuffer) - ) { - this.logger.warn("Webhook rejected: HMAC signature mismatch"); - throw new UnauthorizedException("Invalid webhook signature"); - } - } else { - this.logger.warn( - "LOGISTICS_WEBHOOK_SECRET not set — skipping signature verification (NOT safe for production!)", - ); - } - - return this.logisticsService.handlePartnerWebhook(payload); - } - - @Get("tracking/:orderId") - @UseGuards(JwtAuthGuard) - async getTrackingStatus(@Param("orderId") orderId: string, @Req() req: any) { - // Basic authorization checking - const userId = req.user.sub; - return this.logisticsService.getTrackingStatus( - orderId, - userId, - req.user.role, - ); - } -} diff --git a/apps/backend/src/modules/logistics/logistics.module.ts b/apps/backend/src/modules/logistics/logistics.module.ts deleted file mode 100644 index 885c2648..00000000 --- a/apps/backend/src/modules/logistics/logistics.module.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Module, forwardRef } from "@nestjs/common"; -import { BullModule } from "@nestjs/bullmq"; -import { LogisticsController } from "./logistics.controller"; -import { LogisticsService } from "./logistics.service"; -import { MockLogisticsClient } from "./clients/logistics.client"; -import { PrismaModule } from "../../prisma/prisma.module"; -import { WhatsAppModule } from "../whatsapp/whatsapp.module"; -import { LogisticsProcessor } from "../../queue/logistics.processor"; -import { LOGISTICS_QUEUE } from "../../queue/queue.constants"; - -@Module({ - imports: [ - PrismaModule, - forwardRef(() => WhatsAppModule), - BullModule.registerQueue({ name: LOGISTICS_QUEUE }), - ], - controllers: [LogisticsController], - providers: [ - LogisticsService, - LogisticsProcessor, - { - provide: "LogisticsClient", - useClass: MockLogisticsClient, // Can swap out with real logic based on ConfigService later - }, - ], - exports: [LogisticsService], -}) -export class LogisticsModule {} diff --git a/apps/backend/src/modules/logistics/logistics.service.ts b/apps/backend/src/modules/logistics/logistics.service.ts deleted file mode 100644 index 1768af04..00000000 --- a/apps/backend/src/modules/logistics/logistics.service.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { - Injectable, - Inject, - forwardRef, - NotFoundException, - BadRequestException, - ForbiddenException, - Logger, -} from "@nestjs/common"; -import { PrismaService } from "../../prisma/prisma.service"; -import type { LogisticsClient } from "./clients/logistics.client"; -import { DeliveryStatus, OrderStatus, UserRole } from "@prisma/client"; -import { WhatsAppService } from "../whatsapp/whatsapp.service"; - -@Injectable() -export class LogisticsService { - private readonly logger = new Logger(LogisticsService.name); - - constructor( - private readonly prisma: PrismaService, - @Inject("LogisticsClient") private readonly client: LogisticsClient, - @Inject(forwardRef(() => WhatsAppService)) - private readonly whatsappService: WhatsAppService, - ) {} - - async getQuote( - pickupAddress: string, - deliveryAddress: string, - weightKg?: number, - ) { - // Basic validation - if (!pickupAddress || !deliveryAddress) { - throw new BadRequestException( - "Pickup and delivery addresses are required", - ); - } - - // Call the abstracted client - return this.client.getQuote(pickupAddress, deliveryAddress, weightKg); - } - - async bookPickup(orderId: string) { - const order = await this.prisma.order.findUnique({ - where: { id: orderId }, - include: { - user: true, // buyer - merchantProfile: true, // seller (merchant) - supplierProfile: true, // seller (supplier) - deliveryBooking: true, - }, - }); - - if (!order) throw new NotFoundException("Order not found"); - if (order.deliveryMethod !== "PLATFORM_LOGISTICS") { - throw new BadRequestException("Order does not use platform logistics"); - } - if (order.deliveryBooking) { - this.logger.warn(`Order ${orderId} already has a delivery booking.`); - return order.deliveryBooking; - } - - try { - // 1. Book with partner - // Note: We need a reliable pickup address. In a real app we'd fetch the exact warehouse. - // We fall back to merchant profile business address if warehouse isn't set. - // Support both merchants and suppliers as sellers - const pickupAddress = - order.merchantProfile?.businessAddress || - order.supplierProfile?.companyAddress; - const deliveryAddress = order.deliveryAddress; - const contactPhone = order.user?.phone; - - if (!pickupAddress || !deliveryAddress) { - throw new Error("Missing addresses for logistics booking"); - } - - if (!contactPhone) { - throw new Error("Missing contact phone for logistics booking"); - } - - this.logger.log( - `Booking pickup for order ${orderId} via logistics partner...`, - ); - const { bookingRef, trackingUrl } = await this.client.bookPickup( - orderId, - pickupAddress, - deliveryAddress, - contactPhone, - ); - - // 2. Save DeliveryBooking record - const booking = await this.prisma.deliveryBooking.create({ - data: { - orderId: order.id, - method: "PLATFORM_LOGISTICS", - partnerName: process.env.LOGISTICS_PARTNER || "mock_partner", // Can make dynamic based on client used - partnerRef: bookingRef, - trackingUrl: trackingUrl, - pickupAddress: pickupAddress, - deliveryAddress: deliveryAddress, - status: DeliveryStatus.PENDING, - // Since it's a booking, actual costs might be settled offline, but we store the fee charged - estimatedCostKobo: order.deliveryFeeKobo, - }, - }); - - this.logger.log( - `Created delivery booking for order ${orderId}: Ref ${bookingRef}`, - ); - return booking; - } catch (error) { - this.logger.error( - `Failed to book pickup for order ${orderId}`, - error instanceof Error ? error.stack : String(error), - ); - - // Save a failed booking so we can track the error and retry manually - const failedBooking = await this.prisma.deliveryBooking.create({ - data: { - orderId: order.id, - method: "PLATFORM_LOGISTICS", - pickupAddress: - order.merchantProfile?.businessAddress || - order.supplierProfile?.companyAddress || - "Unknown", - deliveryAddress: order.deliveryAddress || "Unknown", - status: DeliveryStatus.FAILED, - estimatedCostKobo: order.deliveryFeeKobo, - }, - }); - - // TODO: Send admin alert that booking failed and needs manual intervention - - return failedBooking; - } - } - - // Called by partner webhooks (e.g., GIG, Kwik) - async handlePartnerWebhook(payload: any) { - // 1. For a real provider, extract bookingRef, status, etc. - // Assuming simple mapping for our mock - const partnerRef = payload.bookingRef; - const rawStatus = payload.status; - - if (!partnerRef || typeof partnerRef !== "string" || !partnerRef.trim()) { - throw new BadRequestException( - "Invalid webhook payload: missing bookingRef", - ); - } - - if ( - !rawStatus || - !Object.values(DeliveryStatus).includes(rawStatus as DeliveryStatus) - ) { - throw new BadRequestException( - `Invalid webhook payload: unknown status "${rawStatus}". Valid values: ${Object.values(DeliveryStatus).join(", ")}`, - ); - } - - const newStatus = rawStatus as DeliveryStatus; - - this.logger.log( - `Received webhook for booking ${partnerRef}: Status ${newStatus}`, - ); - - const booking = await this.prisma.deliveryBooking.findFirst({ - where: { partnerRef }, - include: { - order: { - include: { - user: true, - merchantProfile: true, - supplierProfile: true, - }, - }, - }, - }); - - if (!booking) { - this.logger.warn( - `No delivery booking found for partnerRef: ${partnerRef}`, - ); - return { success: false, reason: "Booking not found" }; - } - - // Skip if status hasn't changed to avoid duplicate notifications - if (booking.status === newStatus) { - return { success: true, message: "Status unchanged" }; - } - - // 2. Update booking - const updateData: any = { status: newStatus }; - if (newStatus === DeliveryStatus.PICKED_UP) - updateData.pickedUpAt = new Date(); - if (newStatus === DeliveryStatus.DELIVERED) - updateData.deliveredAt = new Date(); - - await this.prisma.$transaction(async (tx) => { - await tx.deliveryBooking.update({ - where: { id: booking.id }, - data: updateData, - }); - - // 3. Create OrderTracking event - await tx.orderTracking.create({ - data: { - orderId: booking.orderId, - status: this.mapDeliveryStatusToOrderStatus(newStatus), - note: `Logistics partner update: ${newStatus}`, - }, - }); - - // Update the main order status if appropriate (e.g., delivered) - if (newStatus === DeliveryStatus.DELIVERED) { - await tx.order.update({ - where: { id: booking.orderId }, - data: { status: OrderStatus.DELIVERED }, - }); - } - }); - - // 4. Send WhatsApp notifications based on status - const buyerPhone = booking.order.user.phone; - const trackingUrl = booking.trackingUrl; - - try { - // Notify Buyer - await this.whatsappService.sendBuyerLogisticsUpdate( - buyerPhone, - booking.orderId, - newStatus, - trackingUrl, - ); - - // Notify Merchant - if (booking.order.merchantId) { - await this.whatsappService.sendMerchantLogisticsUpdate( - booking.order.merchantId, - booking.orderId, - newStatus, - ); - } - } catch (err) { - this.logger.error( - `Failed to send WhatsApp notification for booking ${partnerRef}`, - err, - ); - } - - return { success: true }; - } - - async getTrackingStatus(orderId: string, userId: string, role: string) { - const booking = await this.prisma.deliveryBooking.findUnique({ - where: { orderId }, - include: { - order: { - include: { trackingEvents: true }, - }, - }, - }); - - if (!booking) - throw new NotFoundException("Delivery tracking not found for this order"); - - const order = booking.order; - const isBuyer = role === UserRole.BUYER && order.buyerId === userId; - const isMerchant = - role === UserRole.MERCHANT && order.merchantId === userId; - const isAdmin = - role === UserRole.SUPER_ADMIN || - role === UserRole.OPERATOR || - role === UserRole.SUPPORT; - - if (!isBuyer && !isMerchant && !isAdmin) { - throw new ForbiddenException( - "You do not have permission to view this tracking information", - ); - } - - return { - bookingRef: booking.partnerRef, - status: booking.status, - trackingUrl: booking.trackingUrl, - estimatedArrival: booking.estimatedArrival, - history: order.trackingEvents.sort( - (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), - ), - }; - } - - private mapDeliveryStatusToOrderStatus(status: DeliveryStatus): OrderStatus { - switch (status) { - case DeliveryStatus.PICKED_UP: - case DeliveryStatus.IN_TRANSIT: - case DeliveryStatus.ARRIVING: - return OrderStatus.DISPATCHED; - case DeliveryStatus.DELIVERED: - return OrderStatus.DELIVERED; - case DeliveryStatus.FAILED: - return OrderStatus.DISPUTE; // Or essentially hold it. - default: - return OrderStatus.PREPARING; - } - } -} diff --git a/apps/backend/src/modules/merchant/merchant.controller.ts b/apps/backend/src/modules/merchant/merchant.controller.ts deleted file mode 100644 index acd4fdac..00000000 --- a/apps/backend/src/modules/merchant/merchant.controller.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { - Controller, - Get, - Patch, - Body, - Param, - Query, - UseGuards, - ParseUUIDPipe, - Post, - Delete, -} from "@nestjs/common"; -import { MerchantService } from "./merchant.service"; -import { UpdateMerchantDto } from "./dto/update-merchant.dto"; -import { UpdateBankAccountDto } from "./dto/update-bank-account.dto"; -import { UpdatePreferencesDto } from "./dto/update-preferences.dto"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { RolesGuard } from "../../common/guards/roles.guard"; -import { Roles } from "../../common/decorators/roles.decorator"; -import { CurrentUser } from "../../common/decorators/current-user.decorator"; -import { CurrentMerchant } from "../../common/decorators/current-merchant.decorator"; -import { UserRole, JwtPayload } from "@swifta/shared"; - -import { MerchantAnalyticsService } from "./merchant-analytics.service"; - -@Controller("merchants") -export class MerchantController { - constructor( - private readonly merchantService: MerchantService, - private readonly analyticsService: MerchantAnalyticsService, - ) {} - - @Get("me") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.MERCHANT) - async getMyProfile(@CurrentMerchant() merchantId: string) { - return this.merchantService.getProfile(merchantId); - } - - @Patch("me") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.MERCHANT) - async updateMyProfile( - @CurrentMerchant() merchantId: string, - @Body() dto: UpdateMerchantDto, - ) { - return this.merchantService.updateProfile(merchantId, dto); - } - - @Patch("me/username") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.MERCHANT) - async updateUsername( - @CurrentMerchant() merchantId: string, - @Body("username") username: string, - ) { - return this.merchantService.updateUsername(merchantId, username); - } - - @Get("lookup/:slug") - async getBySlug(@Param("slug") slug: string) { - return this.merchantService.findBySlug(slug); - } - - @Get() - async getAllMerchants() { - return this.merchantService.getAllMerchants(); - } - - @Get("balance-summary") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.MERCHANT) - async getBalanceSummary(@CurrentMerchant() merchantId: string) { - return this.merchantService.getBalanceSummary(merchantId); - } - - @Get(":id") - async getPublicProfile(@Param("id", ParseUUIDPipe) id: string) { - return this.merchantService.getPublicProfile(id); - } - - @Get("bank/resolve") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.MERCHANT) - async resolveBank( - @Query("accountNumber") accountNumber: string, - @Query("bankCode") bankCode: string, - ) { - return this.merchantService.resolveBankAccount(accountNumber, bankCode); - } - - @Patch("bank-account") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.MERCHANT) - async updateBankAccount( - @CurrentMerchant() merchantId: string, - @Body() dto: UpdateBankAccountDto, - ) { - return this.merchantService.updateBankAccount(merchantId, dto); - } - - @Get("banks/list") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.MERCHANT) - async getBanks() { - return this.merchantService.getBanks(); - } - - @Post("me/submit") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.MERCHANT) - async submitVerification(@CurrentMerchant() merchantId: string) { - return this.merchantService.submitForVerification(merchantId); - } - @Get("me/analytics") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.MERCHANT) - async getMyAnalytics( - @CurrentMerchant() merchantId: string, - @Query("startDate") startDate?: string, - @Query("endDate") endDate?: string, - ) { - return this.analyticsService.getMerchantStats( - merchantId, - startDate, - endDate, - ); - } - - @Post(":id/follow") - @UseGuards(JwtAuthGuard) - async followMerchant( - @CurrentUser() user: JwtPayload, - @Param("id", ParseUUIDPipe) merchantId: string, - ) { - return this.merchantService.followMerchant(user.sub, merchantId); - } - - @Delete(":id/follow") - @UseGuards(JwtAuthGuard) - async unfollowMerchant( - @CurrentUser() user: JwtPayload, - @Param("id", ParseUUIDPipe) merchantId: string, - ) { - return this.merchantService.unfollowMerchant(user.sub, merchantId); - } - - @Get(":id/is-following") - @UseGuards(JwtAuthGuard) - async isFollowing( - @CurrentUser() user: JwtPayload, - @Param("id", ParseUUIDPipe) merchantId: string, - ) { - const isFollowing = await this.merchantService.isFollowing( - user.sub, - merchantId, - ); - return { isFollowing }; - } - - @Patch("me/preferences") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.MERCHANT) - async updatePreferences( - @CurrentMerchant() merchantId: string, - @Body() dto: UpdatePreferencesDto, - ) { - return this.merchantService.updatePreferences(merchantId, dto); - } -} diff --git a/apps/backend/src/modules/merchant/merchant.module.ts b/apps/backend/src/modules/merchant/merchant.module.ts deleted file mode 100644 index a553f1d4..00000000 --- a/apps/backend/src/modules/merchant/merchant.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Module } from "@nestjs/common"; -import { MerchantService } from "./merchant.service"; -import { MerchantController } from "./merchant.controller"; -import { PrismaModule } from "../../prisma/prisma.module"; -import { PaymentModule } from "../payment/payment.module"; - -import { MerchantAnalyticsService } from "./merchant-analytics.service"; - -@Module({ - imports: [PrismaModule, PaymentModule], - controllers: [MerchantController], - providers: [MerchantService, MerchantAnalyticsService], - exports: [MerchantService, MerchantAnalyticsService], -}) -export class MerchantModule {} diff --git a/apps/backend/src/modules/merchant/merchant.service.ts b/apps/backend/src/modules/merchant/merchant.service.ts deleted file mode 100644 index 37569d8b..00000000 --- a/apps/backend/src/modules/merchant/merchant.service.ts +++ /dev/null @@ -1,582 +0,0 @@ -import { - Injectable, - NotFoundException, - BadRequestException, -} from "@nestjs/common"; -import { PrismaService } from "../../prisma/prisma.service"; -import { UpdateMerchantDto } from "./dto/update-merchant.dto"; -import { UpdateBankAccountDto } from "./dto/update-bank-account.dto"; -import { UserRole, VerificationTier, maskNin } from "@swifta/shared"; -import { PaystackClient } from "../payment/paystack.client"; -import { NotificationTriggerService } from "../notification/notification-trigger.service"; -import { UpdatePreferencesDto } from "./dto/update-preferences.dto"; - -@Injectable() -export class MerchantService { - constructor( - private prisma: PrismaService, - private paystack: PaystackClient, - private notifications: NotificationTriggerService, - ) {} - - async resolveBankAccount(accountNumber: string, bankCode: string) { - try { - const response = await this.paystack.resolveAccount( - accountNumber, - bankCode, - ); - return { - accountName: response.account_name, - accountNumber: response.account_number, - bankId: response.bank_id, - }; - } catch (error: any) { - throw new NotFoundException( - `Could not resolve account: ${error.message}`, - ); - } - } - - async getBanks() { - try { - const banks = await this.paystack.getBanks(); - // Only return active nuban banks to keep the list clean - return banks - .filter((b) => b.active && b.type === "nuban") - .map((b) => ({ code: b.code, name: b.name })); - } catch (error: any) { - throw new NotFoundException(`Could not get banks: ${error.message}`); - } - } - - async getProfile(merchantId: string) { - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - - const [merchant, slugChangesCount] = await Promise.all([ - this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - include: { - user: { - select: { - email: true, - phone: true, - emailVerified: true, - }, - }, - }, - }), - (this.prisma as any).merchantSlugHistory.count({ - where: { - merchantProfileId: merchantId, - changedAt: { gte: thirtyDaysAgo }, - }, - }), - ]); - - if (!merchant) throw new NotFoundException("Merchant profile not found"); - - const { ninNumber, ...merchantWithoutNin } = merchant; - - return { - ...merchantWithoutNin, - maskedNin: maskNin(ninNumber), - contact: merchant.user - ? { - email: merchant.user.email, - phone: merchant.user.phone, - emailVerified: merchant.user.emailVerified, - } - : null, - slugChangesCount, - }; - } - - async getPublicProfile(merchantId: string) { - const merchant = await (this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - include: { - user: { - select: { - email: true, - phone: true, - }, - }, - _count: { - select: { - orders: { - where: { - status: "COMPLETED", - }, - }, - followers: true, - } as any, - }, - }, - }) as any); - - if (!merchant) throw new NotFoundException("Merchant not found"); - - // We only expose contact info if the merchant is TIER_2 or higher - const isVerified = - merchant.verificationTier === VerificationTier.TIER_2 || - merchant.verificationTier === VerificationTier.TIER_3; - - return { - id: merchant.id, - slug: merchant.slug, - businessName: merchant.businessName, - businessAddress: merchant.businessAddress, - businessType: merchant.businessType, - estYear: merchant.estYear, - category: merchant.category, - warehouseLocation: merchant.warehouseLocation, - distributionCenter: merchant.distributionCenter, - warehouseCapacity: merchant.warehouseCapacity, - verificationTier: merchant.verificationTier, - verifiedAt: merchant.verifiedAt, - createdAt: merchant.createdAt, - lastSlugChangeAt: merchant.lastSlugChangeAt, - profileImage: merchant.profileImage, - coverImage: merchant.coverImage, - cacVerified: merchant.cacVerified, - addressVerified: merchant.addressVerified, - guarantorVerified: merchant.guarantorVerified, - bankVerified: merchant.bankVerified, - averageRating: merchant.averageRating || 0, - reviewCount: merchant.reviewCount || 0, - description: merchant.description, - socialLinks: merchant.socialLinks as any, - contact: isVerified - ? { - email: merchant.user.email, - phone: merchant.user.phone, - } - : null, - dealsClosed: Number(merchant._count.orders), - followersCount: merchant._count.followers, - }; - } - - async getAllMerchants() { - return this.prisma.merchantProfile.findMany({ - where: { - verificationTier: { - in: [VerificationTier.TIER_2, VerificationTier.TIER_3], - }, - }, - select: { - id: true, - businessName: true, - verificationTier: true, - }, - orderBy: { - businessName: "asc", - }, - }); - } - - async updateProfile(merchantId: string, dto: UpdateMerchantDto) { - const existing = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - }); - if (!existing) throw new NotFoundException("Merchant profile not found"); - - // Build the data to update — only provided fields - const updateData: Record = { ...dto }; - - // Progressive onboarding auto-advance (5-step flow) - const merged = { ...existing, ...dto }; - - let newStep = existing.onboardingStep; - - // Step 1 → 2: Business profile complete (businessName, businessType, estYear, category) - if ( - newStep === 1 && - merged.businessName && - merged.businessType && - merged.estYear && - merged.category - ) { - newStep = 2; - } - - // Step 2 → 3: Registration details complete (cacNumber, taxId) - if (newStep === 2 && merged.cacNumber && merged.taxId) { - newStep = 3; - } - - // Step 3 → 4: Warehouse/logistics complete (businessAddress, warehouseLocation, distributionCenter, warehouseCapacity) - if ( - newStep === 3 && - merged.businessAddress && - merged.warehouseLocation && - merged.distributionCenter && - merged.warehouseCapacity - ) { - newStep = 4; - } - - // Step 4 → 5: Bank details complete (bankCode, bankAccountNumber, settlementAccountName) - if ( - newStep === 4 && - merged.bankCode && - merged.bankAccountNumber && - merged.settlementAccountName - ) { - newStep = 5; - } - - // Step 5 reached: Set verification = PENDING (review & submit step) - - if (newStep !== existing.onboardingStep) { - updateData.onboardingStep = newStep; - } - - const updated = await this.prisma.merchantProfile.update({ - where: { id: merchantId }, - data: updateData, - include: { - user: { - select: { - email: true, - phone: true, - }, - }, - }, - }); - - return updated; - } - - async updateBankAccount(merchantId: string, dto: UpdateBankAccountDto) { - const existing = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - }); - if (!existing) throw new NotFoundException("Merchant profile not found"); - - // 1. Resolve account to verify and get the exact account name - const resolved = await this.resolveBankAccount( - dto.bankAccountNumber, - dto.bankCode, - ); - - // 2. Create Paystack Transfer Recipient for future payouts - const recipient = await this.paystack.createTransferRecipient( - dto.bankCode, - dto.bankAccountNumber, - resolved.accountName, - ); - - // 3. Prepare the data to update - const updateData: any = { - bankAccountNumber: dto.bankAccountNumber, - bankCode: dto.bankCode, - settlementAccountName: resolved.accountName, - paystackRecipientCode: recipient.recipient_code, - }; - - // Auto-advance step 4->5 if other fields are complete and we are at step 4 - if (existing.onboardingStep === 4) { - updateData.onboardingStep = 5; - } - - return this.prisma.merchantProfile.update({ - where: { id: merchantId }, - data: updateData, - }); - } - - async submitForVerification(merchantId: string) { - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - }); - - if (!merchant) throw new NotFoundException("Merchant not found"); - - // Only allow submission if they've at least provided bank details (Step 4+) - if (merchant.onboardingStep < 4) { - throw new BadRequestException( - "Please complete your profile and bank details before submitting for verification.", - ); - } - - const updated = await this.prisma.merchantProfile.update({ - where: { id: merchantId }, - data: { - onboardingStep: Math.max(merchant.onboardingStep, 5), - }, - }); - - // Notify Admins - const admins = await this.prisma.user.findMany({ - where: { - role: { in: [UserRole.SUPER_ADMIN, UserRole.OPERATOR] }, - }, - select: { id: true }, - }); - - const adminIds = admins.map((a) => a.id); - if (adminIds.length > 0) { - await this.notifications.triggerNewMerchantSubmission(adminIds, { - merchantId, - merchantName: merchant.businessName || "Unknown Merchant", - }); - } - - return updated; - } - - async findBySlug(slug: string) { - // Attempt direct match first - const directMatch = await (this.prisma.merchantProfile as any).findUnique({ - where: { slug: slug.toLowerCase() }, - include: { - user: { - select: { - email: true, - phone: true, - }, - }, - }, - }); - - if (directMatch) return directMatch; - - // Check history for redirection - const historyMatch = await ( - this.prisma as any - ).merchantSlugHistory.findFirst({ - where: { oldSlug: slug.toLowerCase() }, - include: { - merchantProfile: { - include: { - user: { - select: { - email: true, - phone: true, - }, - }, - }, - }, - }, - }); - - if (historyMatch) return historyMatch.merchantProfile; - - throw new NotFoundException(`Merchant with username "${slug}" not found`); - } - - async updateUsername(merchantId: string, newSlugInput: string) { - const newSlug = newSlugInput.toLowerCase().trim(); - - // 1. Format validation - const slugRegex = /^[a-z0-9](-?[a-z0-9])*$/; - if (!slugRegex.test(newSlug) || newSlug.length < 3 || newSlug.length > 30) { - throw new BadRequestException( - "Username must be 3-30 characters, lowercase alphanumeric, and can contain hyphens (but not at the start/end).", - ); - } - - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - include: { user: { select: { emailVerified: true } } }, - }); - if (!merchant) throw new NotFoundException("Merchant profile not found"); - - if (!merchant.user.emailVerified) { - throw new BadRequestException( - "You must verify your email address before you can change your business username.", - ); - } - - if ((merchant as any).slug === newSlug) { - throw new BadRequestException( - "New username must be different from current one.", - ); - } - - // 2. Uniqueness check (Current + History) - const isTaken = await (this.prisma.merchantProfile as any).findUnique({ - where: { slug: newSlug }, - }); - if (isTaken) throw new BadRequestException("Username is already in use."); - - const isReserved = await (this.prisma as any).merchantSlugHistory.findFirst( - { - where: { oldSlug: newSlug }, - }, - ); - if (isReserved) - throw new BadRequestException( - "Username is reserved by another merchant.", - ); - - // 3. Cooldown check (7 days) - if ((merchant as any).lastSlugChangeAt) { - const msSinceLastChange = - Date.now() - (merchant as any).lastSlugChangeAt.getTime(); - const coolingDays = msSinceLastChange / (1000 * 60 * 60 * 24); - if (coolingDays < 7) { - const remaining = Math.ceil(7 - coolingDays); - throw new BadRequestException( - `Please wait ${remaining} more day(s) before changing your username again.`, - ); - } - } - - // 3. Rate Limit Check (max 3 changes in 30 days) - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - - const recentChanges = await (this.prisma as any).merchantSlugHistory.count({ - where: { - merchantProfileId: merchantId, - changedAt: { gte: thirtyDaysAgo }, - }, - }); - - if (recentChanges >= 3) { - throw new BadRequestException( - "You have reached the limit of 3 username changes per month.", - ); - } - - // 4. Cooldown Check (e.g., 24 hours between changes) - if ( - (merchant as any).lastSlugChangeAt && - new Date().getTime() - - new Date((merchant as any).lastSlugChangeAt).getTime() < - 24 * 60 * 60 * 1000 - ) { - throw new BadRequestException( - "Please wait 24 hours between username changes.", - ); - } - - // 5. Execute Update Transaction - return this.prisma.$transaction(async (tx) => { - // Record historical slug - await (tx as any).merchantSlugHistory.create({ - data: { - merchantProfileId: merchantId, - oldSlug: (merchant as any).slug, - newSlug: newSlug, - }, - }); - - // Update current profile - return tx.merchantProfile.update({ - where: { id: merchantId }, - data: { - slug: newSlug, - lastSlugChangeAt: new Date(), - }, - } as any); - }); - } - - async followMerchant(userId: string, merchantId: string) { - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - }); - if (!merchant) throw new NotFoundException("Merchant not found"); - - if (merchant.userId === userId) { - throw new BadRequestException("You cannot follow your own business"); - } - - try { - return await (this.prisma as any).follow.create({ - data: { - followerId: userId, - merchantId: merchantId, - }, - }); - } catch (error: any) { - if (error.code === "P2002") { - throw new BadRequestException( - "You are already following this merchant", - ); - } - throw error; - } - } - - async unfollowMerchant(userId: string, merchantId: string) { - try { - await (this.prisma as any).follow.delete({ - where: { - followerId_merchantId: { - followerId: userId, - merchantId: merchantId, - }, - }, - }); - return { success: true }; - } catch (error: any) { - if (error.code === "P2025") { - throw new BadRequestException("You are not following this merchant"); - } - throw error; - } - } - - async isFollowing(userId: string, merchantId: string) { - const follow = await (this.prisma as any).follow.findUnique({ - where: { - followerId_merchantId: { - followerId: userId, - merchantId: merchantId, - }, - }, - }); - return !!follow; - } - - async updatePreferences(merchantId: string, dto: UpdatePreferencesDto) { - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - }); - if (!merchant) throw new NotFoundException("Merchant profile not found"); - - return this.prisma.merchantProfile.update({ - where: { id: merchantId }, - data: { - notificationPreferences: dto.notificationPreferences as any, - }, - }); - } - - async getBalanceSummary(merchantId: string) { - // Fire concurrent fast aggregates offloaded to DB (leveraging the composite indexes) - const [escrowResult, availableResult, revenueResult, pendingResult] = - await Promise.all([ - this.prisma.order.aggregate({ - where: { merchantId, status: { in: ["PAID", "DISPATCHED"] } }, - _sum: { totalAmountKobo: true }, - }), - this.prisma.order.aggregate({ - where: { merchantId, payoutStatus: "COMPLETED" }, - _sum: { totalAmountKobo: true }, - }), - this.prisma.order.aggregate({ - where: { merchantId, status: "COMPLETED" }, - _sum: { totalAmountKobo: true }, - }), - this.prisma.order.aggregate({ - where: { merchantId, payoutStatus: "PENDING", status: "COMPLETED" }, - _sum: { totalAmountKobo: true }, - }), - ]); - - return { - escrowBalanceKobo: (escrowResult._sum.totalAmountKobo || 0n).toString(), - availableBalanceKobo: ( - availableResult._sum.totalAmountKobo || 0n - ).toString(), - totalRevenueKobo: (revenueResult._sum.totalAmountKobo || 0n).toString(), - pendingPayoutsKobo: (pendingResult._sum.totalAmountKobo || 0n).toString(), - }; - } -} diff --git a/apps/backend/src/modules/notification/notification-trigger.service.ts b/apps/backend/src/modules/notification/notification-trigger.service.ts deleted file mode 100644 index a1b6573f..00000000 --- a/apps/backend/src/modules/notification/notification-trigger.service.ts +++ /dev/null @@ -1,596 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { InjectQueue } from "@nestjs/bullmq"; -import { Queue } from "bullmq"; -import { - NOTIFICATION_QUEUE, - WHATSAPP_QUEUE, -} from "../../queue/queue.constants"; -import { NotificationType, NotificationChannel } from "@swifta/shared"; - -@Injectable() -export class NotificationTriggerService { - private readonly logger = new Logger(NotificationTriggerService.name); - - constructor( - @InjectQueue(NOTIFICATION_QUEUE) private queue: Queue, - @InjectQueue(WHATSAPP_QUEUE) private whatsappQueue: Queue, - ) {} - - public async addJob( - userId: string, - type: NotificationType, - title: string, - body: string, - metadata?: any, - channels: NotificationChannel[] = [ - NotificationChannel.IN_APP, - NotificationChannel.EMAIL, - ], - options?: { removeOnFail?: boolean; url?: string }, - ) { - await this.queue.add( - "send-notification", - { - userId, - type, - title, - body, - channels, - metadata, - url: options?.url, - }, - { - attempts: 3, - backoff: { type: "exponential", delay: 2000 }, - removeOnComplete: true, - removeOnFail: options?.removeOnFail ?? false, - }, - ); - } - - async triggerWelcome(userId: string) { - await this.addJob( - userId, - NotificationType.WELCOME, - "Welcome to Swifta", - "Welcome to Africa's digital trade network.", - ); - } - - async triggerEmailVerification(userId: string, otp: string) { - await this.addJob( - userId, - NotificationType.EMAIL_VERIFICATION, - "Verify your account", - `Your code is ${otp}`, - { otp }, - [NotificationChannel.EMAIL], - { removeOnFail: true }, - ); - } - - async triggerOrderPreparing( - buyerId: string, - metadata: { orderId: string; reference: string }, - ) { - await this.addJob( - buyerId, - NotificationType.ORDER_PREPARING, - "Order Preparing", - "📦 Your order is being prepared! The merchant is packing your goods.", - { ...metadata }, - [ - NotificationChannel.IN_APP, - NotificationChannel.EMAIL, - NotificationChannel.WHATSAPP, - ], - ); - } - - async triggerOrderDispatched( - buyerId: string, - metadata: { orderId: string; reference: string; otp: string }, - ) { - await this.addJob( - buyerId, - NotificationType.ORDER_DISPATCHED, - "Order Dispatched", - `🚚 Your order has been dispatched! Your delivery code is: ${metadata.otp}`, - { ...metadata }, - [ - NotificationChannel.IN_APP, - NotificationChannel.EMAIL, - NotificationChannel.WHATSAPP, - ], - ); - } - - async triggerOrderInTransit( - buyerId: string, - metadata: { orderId: string; reference: string; note?: string }, - ) { - const noteText = metadata.note ? ` Merchant says: ${metadata.note}` : ""; - await this.addJob( - buyerId, - NotificationType.ORDER_IN_TRANSIT, - "Order In Transit", - `📍 Your order is on the way!${noteText}`, - { ...metadata }, - [ - NotificationChannel.IN_APP, - NotificationChannel.EMAIL, - NotificationChannel.WHATSAPP, - ], - ); - } - - async triggerDeliveryConfirmed( - merchantId: string, - buyerId: string, - metadata: { orderId: string; reference: string; amountKobo: bigint }, - ) { - // Notify merchant - await this.addJob( - merchantId, - NotificationType.DELIVERY_CONFIRMED, - "Delivery Confirmed", - `Order #${metadata.reference.slice(0, 8)} delivery has been confirmed.`, - { - ...metadata, - amountKobo: metadata.amountKobo.toString(), - isMerchantId: true, - }, - [NotificationChannel.IN_APP, NotificationChannel.EMAIL], - ); - // Notify buyer - await this.addJob( - buyerId, - NotificationType.DELIVERY_CONFIRMED, - "Delivery Confirmed", - `Order #${metadata.reference.slice(0, 8)} delivery has been confirmed.`, - { ...metadata, amountKobo: metadata.amountKobo.toString() }, - [NotificationChannel.IN_APP, NotificationChannel.EMAIL], - ); - } - - async triggerPayoutInitiated( - merchantId: string, - metadata: { - orderId: string; - orderRef: string; - productName: string; - quantity: number; - payoutAmountKobo: string; - bankName: string; - }, - ) { - await this.addJob( - merchantId, - NotificationType.PAYOUT_INITIATED, - "Payout Initiated", - "Payout for your order has been initiated.", - { ...metadata, isMerchantId: true }, - ); - - // WhatsApp push to merchant - try { - await this.whatsappQueue.add( - "send-delivery-confirmed-notification", - { - merchantId, - payoutData: metadata, - }, - { - attempts: 2, - backoff: { type: "exponential", delay: 3000 }, - removeOnComplete: true, - removeOnFail: true, - }, - ); - } catch (error: unknown) { - const errMsg = error instanceof Error ? error.message : String(error); - const errStack = error instanceof Error ? error.stack : undefined; - this.logger.error( - `WhatsApp notification failed for new-order: ${errMsg}`, - errStack, - ); - } - } - - async triggerPayoutCompleted( - merchantId: string, - metadata: { - amountKobo: string; - orderRef: string; - bankName: string; - }, - ) { - await this.addJob( - merchantId, - NotificationType.PAYOUT_COMPLETED, - "Payout Received", - `Payout of ₦${Number(metadata.amountKobo) / 100} has been sent to your ${metadata.bankName} account for Order #${metadata.orderRef}.`, - { ...metadata, isMerchantId: true }, - ); - - try { - await this.whatsappQueue.add( - "send-payout-completed-notification", - { merchantId, payoutData: metadata }, - { attempts: 2, removeOnComplete: true, removeOnFail: true }, - ); - } catch (error: unknown) { - const errMsg = error instanceof Error ? error.message : String(error); - const errStack = error instanceof Error ? error.stack : undefined; - this.logger.error( - `WhatsApp notification failed for payout-completed: ${errMsg}`, - errStack, - ); - } - } - - async triggerPayoutFailed( - merchantId: string, - metadata: { - orderRef: string; - amountKobo?: string; - }, - ) { - await this.addJob( - merchantId, - NotificationType.PAYOUT_FAILED, - "Payout Delayed", - `Your payout for Order #${metadata.orderRef} is being reviewed.`, - { ...metadata, isMerchantId: true }, - ); - - try { - await this.whatsappQueue.add( - "send-payout-failed-notification", - { merchantId, payoutData: metadata }, - { attempts: 2, removeOnComplete: true, removeOnFail: true }, - ); - } catch (error: unknown) { - const errMsg = error instanceof Error ? error.message : String(error); - const errStack = error instanceof Error ? error.stack : undefined; - this.logger.error( - `WhatsApp notification failed for payout-failed: ${errMsg}`, - errStack, - ); - } - } - - async triggerOrderCancelled( - buyerId: string, - merchantId: string, - orderId: string, - ) { - await this.addJob( - buyerId, - NotificationType.ORDER_CANCELLED, - "Order Cancelled", - "Order has been cancelled.", - { orderId }, - ); - await this.addJob( - merchantId, - NotificationType.ORDER_CANCELLED, - "Order Cancelled", - "Order has been cancelled.", - { orderId, isMerchantId: true }, - ); - } - - async triggerPasswordReset( - userId: string, - email: string, - resetToken: string, - frontendUrl: string, - ) { - await this.addJob( - userId, - NotificationType.PASSWORD_RESET, - "Reset your password", - "Forgot your password?", - { email, resetToken, frontendUrl }, - [NotificationChannel.EMAIL], - { removeOnFail: true }, - ); - } - - async triggerPaymentConfirmed( - buyerId: string, - merchantId: string, - metadata: { orderId: string; reference: string; amountKobo: bigint }, - ) { - // Notify buyer - await this.addJob( - buyerId, - NotificationType.PAYMENT_CONFIRMED, - "Payment Successful", - "Your payment was successful.", - { ...metadata, amountKobo: metadata.amountKobo.toString() }, - [ - NotificationChannel.IN_APP, - NotificationChannel.EMAIL, - NotificationChannel.WHATSAPP, - ], - ); - // Notify merchant - await this.addJob( - merchantId, - NotificationType.PAYMENT_CONFIRMED, - "Payment Received", - "Payment received for an order.", - { - ...metadata, - amountKobo: metadata.amountKobo.toString(), - isMerchantId: true, - }, - ); - } - - async triggerDirectPurchaseConfirmed( - buyerId: string, - merchantId: string, - metadata: { - orderId: string; - reference: string; - productName: string; - buyerName: string; - quantity: number; - amountKobo: bigint; - deliveryAddress?: string; - }, - ) { - // Notify buyer via Email / In-App - await this.addJob( - buyerId, - NotificationType.DIRECT_PURCHASE_CONFIRMED, - "Payment confirmed!", - "Your order is being prepared. You will receive a delivery code when the merchant dispatches.", - { ...metadata, amountKobo: metadata.amountKobo.toString() }, - [ - NotificationChannel.IN_APP, - NotificationChannel.EMAIL, - NotificationChannel.WHATSAPP, - ], - ); - - // Notify merchant via Email / In-App - await this.addJob( - merchantId, - NotificationType.DIRECT_PURCHASE_RECEIVED, - "New order received!", - `${metadata.productName} × ${metadata.quantity} — ₦${Number(metadata.amountKobo) / 100}. Check your dashboard to dispatch.`, - { - ...metadata, - amountKobo: metadata.amountKobo.toString(), - isMerchantId: true, - }, - [NotificationChannel.IN_APP, NotificationChannel.EMAIL], - ); - - // WhatsApp push to merchant - try { - await this.whatsappQueue.add( - "send-direct-order-notification", - { - merchantId, - orderData: { - orderId: metadata.orderId, - buyerName: metadata.buyerName, - productName: metadata.productName, - quantity: metadata.quantity, - amountKobo: metadata.amountKobo.toString(), - deliveryAddress: metadata.deliveryAddress || "Not specified", - }, - }, - { - attempts: 2, - backoff: { type: "exponential", delay: 3000 }, - removeOnComplete: true, - removeOnFail: true, - }, - ); - } catch { - // Ignore Whatsapp failures to omit transaction rollbacks - } - } - - async triggerReorderReminder( - buyerId: string, - metadata: { - reminderId: string; - productName: string; - merchantName: string; - originalQuantity: number; - daysSinceOrder: number; - }, - ) { - await this.addJob( - buyerId, - NotificationType.REORDER_REMINDER, - "Time to Reorder", - `It's been ${metadata.daysSinceOrder} days since you ordered ${metadata.productName}. Need a restock?`, - { ...metadata }, - [NotificationChannel.IN_APP, NotificationChannel.EMAIL], - ); - } - - async triggerMerchantReorderPrompt( - merchantId: string, - metadata: { - reminderId: string; - buyerName: string; - productName: string; - originalQuantity: number; - daysSinceOrder: number; - }, - ) { - await this.addJob( - merchantId, - NotificationType.MERCHANT_REORDER_PROMPT, - "Reorder Opportunity", - `${metadata.buyerName} might need a restock of ${metadata.productName}.`, - { ...metadata, isMerchantId: true }, - ); - } - - async triggerMerchantVerified(userId: string) { - await this.addJob( - userId, - NotificationType.MERCHANT_VERIFIED, - "Account Verified", - "Your merchant account has been successfully verified. You can now list products and receive quotes.", - {}, - ); - } - - async triggerMerchantRejected(userId: string, reason?: string) { - await this.addJob( - userId, - NotificationType.MERCHANT_REJECTED, - "Account Verification Rejected", - `Your merchant account verification was unsuccessful. ${reason ? "Reason: " + reason : "Please review your details and re-submit."}`, - { reason }, - ); - } - - async triggerNewMerchantSubmission( - adminUserIds: string[], - metadata: { merchantId: string; merchantName: string; targetTier?: string }, - ) { - await Promise.allSettled( - adminUserIds.map((adminId) => - this.addJob( - adminId, - NotificationType.NEW_MERCHANT_SUBMISSION, - "New Merchant Verification Pending", - `${metadata.merchantName} has submitted their account for verification${metadata.targetTier ? ` (${metadata.targetTier})` : ""}.`, - { ...metadata }, - ), - ), - ); - } - - async triggerPayoutRequested( - adminUserIds: string[], - metadata: { - merchantId: string; - merchantName: string; - amountKobo: string; - requestId: string; - }, - ) { - await Promise.allSettled( - adminUserIds.map((adminId) => - this.addJob( - adminId, - NotificationType.PAYOUT_REQUESTED, - "New Payout Request", - `${metadata.merchantName} has requested a payout.`, - { ...metadata }, - ), - ), - ); - } - - async triggerMerchantPayoutRequestedConfirmation( - userId: string, - metadata: { amountKobo: string; requestId: string }, - ) { - await this.addJob( - userId, - NotificationType.PAYOUT_REQUEST_RECEIVED, - "Payout Request Received", - `Your payout request has been received and is being processed.`, - { ...metadata }, - ); - } - - async triggerOrderDisputed( - merchantId: string, - orderId: string, - reason: string, - ) { - // Notify Merchant - await this.addJob( - merchantId, - NotificationType.ORDER_DISPUTED, - "Order Dispute Raised", - `A buyer has raised a dispute for Order #${orderId.slice(0, 8)}. Reason: ${reason}`, - { orderId, reason, isMerchantId: true }, - [NotificationChannel.IN_APP, NotificationChannel.EMAIL], - { url: `/merchant/orders/${orderId}` }, - ); - - // Notify Admins (Static broadcast for now, in a real app we'd fetch admin IDs) - // For now, the OrderService will handle the call if it has admin IDs, - // but here we just provide the capability. - } - - async triggerOrderDisputeResolved( - userId: string, - orderId: string, - resolution: string, - ) { - await this.addJob( - userId, - NotificationType.DISPUTE_RESOLVED, - "Dispute Resolved", - `The dispute for Order #${orderId.slice(0, 8)} has been resolved.`, - { orderId, resolution }, - [NotificationChannel.IN_APP, NotificationChannel.EMAIL], - { url: `/buyer/orders/${orderId}` }, - ); - } - - async triggerAutoConfirmationWarning( - buyerId: string, - orderRef: string, - hoursRemaining: number, - ) { - await this.addJob( - buyerId, - NotificationType.ORDER_AUTO_CONFIRM_WARNING, - "Action Required: Order Confirmation", - `Your order #${orderRef} will be automatically confirmed in ${hoursRemaining} hours. Please confirm delivery or raise a dispute if there are issues.`, - { orderRef, hoursRemaining }, - [NotificationChannel.IN_APP, NotificationChannel.EMAIL], - ); - } - - async triggerOrderAutoConfirmed( - buyerId: string, - merchantId: string, - metadata: { orderId: string; reference: string; amountKobo: bigint }, - ) { - const serializedMetadata = { - ...metadata, - amountKobo: metadata.amountKobo.toString(), - }; - - // Notify buyer - await this.addJob( - buyerId, - NotificationType.ORDER_AUTO_CONFIRMED, - "Order Auto-Confirmed", - `Order #${metadata.reference.slice(0, 8)} has been automatically confirmed after 72 hours.`, - serializedMetadata, - [NotificationChannel.IN_APP, NotificationChannel.EMAIL], - { url: `/buyer/orders/${metadata.orderId}` }, - ); - - // Notify merchant - await this.addJob( - merchantId, - NotificationType.ORDER_AUTO_CONFIRMED, - "Order Auto-Confirmed", - `Order #${metadata.reference.slice(0, 8)} has been automatically confirmed by the system. Payout will be processed shortly.`, - { ...serializedMetadata, isMerchantId: true }, - [NotificationChannel.IN_APP, NotificationChannel.EMAIL], - { url: `/merchant/orders/${metadata.orderId}` }, - ); - } -} diff --git a/apps/backend/src/modules/notification/notification.controller.ts b/apps/backend/src/modules/notification/notification.controller.ts deleted file mode 100644 index 0b35a3ce..00000000 --- a/apps/backend/src/modules/notification/notification.controller.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - Controller, - Get, - Patch, - Param, - UseGuards, - Query, - ParseUUIDPipe, -} from "@nestjs/common"; -import { NotificationService } from "./notification.service"; -import { PaginationQueryDto } from "../../common/dto/pagination-query.dto"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { CurrentUser } from "../../common/decorators/current-user.decorator"; -import { JwtPayload } from "@swifta/shared"; - -@Controller("notifications") -@UseGuards(JwtAuthGuard) -export class NotificationController { - constructor(private readonly notificationService: NotificationService) {} - - @Get() - findAll(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) { - return this.notificationService.getUserNotifications( - user.sub, - query.page, - query.limit, - ); - } - - @Get("unread-count") - getUnreadCount(@CurrentUser() user: JwtPayload) { - return this.notificationService.getUnreadCount(user.sub); - } - - @Patch(":id/read") - markAsRead( - @CurrentUser() user: JwtPayload, - @Param("id", ParseUUIDPipe) id: string, - ) { - return this.notificationService.markAsRead(user.sub, id); - } - - @Patch("read-all") - markAllAsRead(@CurrentUser() user: JwtPayload) { - return this.notificationService.markAllAsRead(user.sub); - } -} diff --git a/apps/backend/src/modules/notification/notification.module.ts b/apps/backend/src/modules/notification/notification.module.ts deleted file mode 100644 index ff160177..00000000 --- a/apps/backend/src/modules/notification/notification.module.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Module, Global, forwardRef } from "@nestjs/common"; -import { NotificationService } from "./notification.service"; -import { NotificationController } from "./notification.controller"; -import { NotificationTriggerService } from "./notification-trigger.service"; -import { NotificationProcessor } from "./notification.processor"; -import { SmsService } from "./sms.service"; -import { PrismaModule } from "../../prisma/prisma.module"; -import { QueueModule } from "../../queue/queue.module"; -import { ConfigModule } from "@nestjs/config"; - -@Global() -@Module({ - imports: [PrismaModule, forwardRef(() => QueueModule), ConfigModule], - controllers: [NotificationController], - providers: [ - NotificationService, - NotificationTriggerService, - NotificationProcessor, - SmsService, - ], - exports: [NotificationService, NotificationTriggerService, SmsService], -}) -export class NotificationModule {} diff --git a/apps/backend/src/modules/notification/notification.processor.ts b/apps/backend/src/modules/notification/notification.processor.ts deleted file mode 100644 index d6aca84a..00000000 --- a/apps/backend/src/modules/notification/notification.processor.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { Processor, WorkerHost } from "@nestjs/bullmq"; -import { Job } from "bullmq"; -import { Logger } from "@nestjs/common"; -import { - NOTIFICATION_QUEUE, - WHATSAPP_QUEUE, -} from "../../queue/queue.constants"; -import { PrismaService } from "../../prisma/prisma.service"; -import { EmailService } from "../email/email.service"; -import { SmsService } from "./sms.service"; -import { NotificationChannel } from "@swifta/shared"; -import { InjectQueue } from "@nestjs/bullmq"; -import { Queue } from "bullmq"; - -@Processor(NOTIFICATION_QUEUE, { - concurrency: 5, - limiter: { max: 10, duration: 1000 }, - drainDelay: 60000, - stalledInterval: 300000, - lockDuration: 60000, - metrics: null, -}) -export class NotificationProcessor extends WorkerHost { - private readonly logger = new Logger(NotificationProcessor.name); - - constructor( - private prisma: PrismaService, - private emailService: EmailService, - private smsService: SmsService, - @InjectQueue(WHATSAPP_QUEUE) private whatsappQueue: Queue, - ) { - super(); - } - - async process(job: Job): Promise { - const { userId, type, title, body, channels, metadata } = job.data; - let targetUserId = userId; - - this.logger.log(`Processing notification: ${type} for user ${userId}`); - - // Resolve merchant ID to user ID if needed - if (metadata && metadata.isMerchantId) { - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: userId }, - }); - if (!merchant) { - this.logger.warn(`Merchant ${userId} not found, skipping notification`); - return; - } - targetUserId = merchant.userId; - } - - const user = await this.prisma.user.findUnique({ - where: { id: targetUserId }, - }); - - if (!user) { - this.logger.warn(`User ${targetUserId} not found, skipping notification`); - return; - } - - for (const channel of channels) { - try { - if (channel === NotificationChannel.IN_APP) { - await this.prisma.notification.create({ - data: { - userId: targetUserId, - type, - title, - body, - channel, - metadata, - }, - }); - this.logger.log(`In-app notification sent to ${targetUserId}`); - } else if (channel === NotificationChannel.EMAIL) { - switch (type) { - case "WELCOME": - await this.emailService.sendWelcomeEmail( - user.email, - user.firstName, - user.role, - ); - break; - case "EMAIL_VERIFICATION": - if (!metadata?.otp) { - this.logger.error( - `Missing metadata.otp for EMAIL_VERIFICATION (user=${targetUserId})`, - ); - continue; - } - await this.emailService.sendVerificationOTP( - user.email, - metadata.otp, - ); - break; - case "PAYMENT_CONFIRMED": - if (!metadata?.reference || !metadata?.amountKobo) { - this.logger.error( - `Missing metadata for PAYMENT_CONFIRMED (user=${targetUserId})`, - ); - continue; - } - try { - await this.emailService.sendPaymentConfirmedNotification( - user.email, - metadata.reference, - BigInt(metadata.amountKobo), - !!metadata.isMerchantId, - ); - } catch (convErr) { - this.logger.error( - `Invalid amountKobo for PAYMENT_CONFIRMED (user=${targetUserId}, value=${metadata.amountKobo}): ${convErr}`, - ); - continue; - } - break; - case "ORDER_DISPATCHED": - if (!metadata?.reference || !metadata?.otp) { - this.logger.error( - `Missing metadata for ORDER_DISPATCHED (user=${targetUserId})`, - ); - continue; - } - await this.emailService.sendOrderDispatchedNotification( - user.email, - metadata.reference, - metadata.otp, - ); - break; - case "DELIVERY_CONFIRMED": - if (!metadata?.reference || !metadata?.amountKobo) { - this.logger.error( - `Missing metadata for DELIVERY_CONFIRMED (user=${targetUserId})`, - ); - continue; - } - try { - await this.emailService.sendDeliveryConfirmedNotification( - user.email, - metadata.reference, - BigInt(metadata.amountKobo), - ); - } catch (convErr) { - this.logger.error( - `Invalid amountKobo for DELIVERY_CONFIRMED (user=${targetUserId}, value=${metadata.amountKobo}): ${convErr}`, - ); - continue; - } - break; - case "PASSWORD_RESET": - if (!metadata?.resetToken || !metadata?.frontendUrl) { - this.logger.error( - `Missing metadata for PASSWORD_RESET (user=${targetUserId})`, - ); - continue; - } - await this.emailService.sendPasswordResetEmail( - user.email, - metadata.resetToken, - metadata.frontendUrl, - ); - break; - default: - await this.emailService.sendEmail(user.email, title, body); - } - this.logger.log(`Email notification (${type}) sent to ${user.email}`); - } else if (channel === NotificationChannel.SMS && user.phone) { - try { - await this.smsService.sendSms(user.phone, body); - } catch (smsError) { - this.logger.warn( - `Failed to dispatch SMS, moving to next channel. Warning: ${smsError}`, - ); - } - } else if (channel === NotificationChannel.WHATSAPP && user.phone) { - try { - switch (type) { - case "PAYMENT_CONFIRMED": - case "DIRECT_PURCHASE_CONFIRMED": - await this.whatsappQueue.add( - "send-payment-confirmed-notification", - { phone: user.phone, orderData: metadata }, - { attempts: 2, removeOnComplete: true, removeOnFail: true }, - ); - break; - case "ORDER_DISPATCHED": - await this.whatsappQueue.add( - "send-order-dispatched-notification", - { phone: user.phone, orderData: metadata }, - { attempts: 2, removeOnComplete: true, removeOnFail: true }, - ); - break; - default: - await this.whatsappQueue.add( - "send-text-message", - { phone: user.phone, text: body }, - { attempts: 2, removeOnComplete: true, removeOnFail: true }, - ); - break; - } - this.logger.log( - `WhatsApp notification (${type}) queued for ${user.phone}`, - ); - } catch (waError) { - this.logger.warn( - `Failed to dispatch WhatsApp job, moving to next channel. Warning: ${waError}`, - ); - } - } - } catch (error: unknown) { - const errMsg = error instanceof Error ? error.message : String(error); - this.logger.error( - `Failed to send ${channel} notification to ${targetUserId}: ${errMsg}`, - ); - throw error; // Re-throw so BullMQ can retry - } - } - } -} diff --git a/apps/backend/src/modules/notification/notification.service.ts b/apps/backend/src/modules/notification/notification.service.ts deleted file mode 100644 index 7aa71ed0..00000000 --- a/apps/backend/src/modules/notification/notification.service.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; -import { PrismaService } from "../../prisma/prisma.service"; - -@Injectable() -export class NotificationService { - constructor(private prisma: PrismaService) {} - - async getUserNotifications(userId: string, page: number, limit: number) { - const skip = (page - 1) * limit; - const [data, total, unreadCount] = await Promise.all([ - this.prisma.notification.findMany({ - where: { userId }, - skip, - take: limit, - orderBy: { createdAt: "desc" }, - }), - this.prisma.notification.count({ where: { userId } }), - this.prisma.notification.count({ where: { userId, isRead: false } }), - ]); - return { data, meta: { page, limit, total, unreadCount } }; - } - - async getUnreadCount(userId: string) { - const count = await this.prisma.notification.count({ - where: { userId, isRead: false }, - }); - return { unreadCount: count }; - } - - async markAsRead(userId: string, id: string) { - const notification = await this.prisma.notification.findUnique({ - where: { id }, - }); - - if (!notification || notification.userId !== userId) { - throw new NotFoundException("Notification not found"); - } - - return this.prisma.notification.update({ - where: { id }, - data: { isRead: true }, - }); - } - - async markAllAsRead(userId: string) { - const result = await this.prisma.notification.updateMany({ - where: { userId, isRead: false }, - data: { isRead: true }, - }); - return { message: "All marked as read", count: result.count }; - } -} diff --git a/apps/backend/src/modules/notification/sms.service.ts b/apps/backend/src/modules/notification/sms.service.ts deleted file mode 100644 index a5f7c2ea..00000000 --- a/apps/backend/src/modules/notification/sms.service.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import africastalking from "africastalking"; - -@Injectable() -export class SmsService { - private readonly logger = new Logger(SmsService.name); - private atSdk: any; - private sms: any; - private senderId: string; - - constructor(private configService: ConfigService) { - const apiKey = this.configService.get("africastalking.apiKey"); - const username = this.configService.get("africastalking.username"); - this.senderId = - this.configService.get("africastalking.senderId") || ""; - - if (!apiKey) { - this.logger.warn( - "Africa's Talking API key is missing. SMS functionality will be disabled.", - ); - } else { - try { - this.atSdk = africastalking({ apiKey, username }); - this.sms = this.atSdk.SMS; - this.logger.log("Africa's Talking SDK initialized successfully."); - } catch (error) { - this.logger.error("Failed to initialize Africa's Talking SDK", error); - } - } - } - - /** - * Dispatches an SMS to a specified phone number using Africa's Talking. - * @param phoneNumber Phone number must be in E.164 format (e.g., +2348147846093). - * - * @param to The recipient's phone number - * @param message The message body - */ - async sendSms(to: string, message: string): Promise { - if (!this.sms) { - this.logger.warn( - `Skipping SMS to ${to}: Service is poorly configured or disabled.`, - ); - return; - } - - // A fast regex to ensure the number loosely matches E.164 structure before sending - const e164Regex = /^\+[1-9]\d{1,14}$/; - if (!e164Regex.test(to)) { - this.logger.error( - `Failed to send SMS: Invalid phone number format for ${to}. Must be E.164 (e.g. +234...)`, - ); - throw new Error("Invalid phone number format"); - } - - try { - const options: any = { - to: [to], - message, - }; - - // Only attach senderId if it is explicitly configured in the environment - if (this.senderId) { - options.from = this.senderId; - } - - const response = await this.sms.send(options); - - this.logger.log( - `SMS dispatched to ${to}. MessageId: ${response?.SMSMessageData?.Recipients?.[0]?.messageId}`, - ); - return response; - } catch (error: any) { - this.logger.error( - `SMS dispatch failed for ${to}`, - error?.response?.data || error.message, - ); - throw error; - } - } -} diff --git a/apps/backend/src/modules/order/dto/checkout-cart.dto.ts b/apps/backend/src/modules/order/dto/checkout-cart.dto.ts deleted file mode 100644 index b388e7dc..00000000 --- a/apps/backend/src/modules/order/dto/checkout-cart.dto.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - ArrayNotEmpty, - IsArray, - IsEnum, - IsIn, - IsNotEmpty, - IsOptional, - IsString, - IsUUID, - IsObject, -} from "class-validator"; - -import { PaymentMethod } from "@swifta/shared"; - -export class CheckoutCartDto { - @IsArray() - @IsUUID("4", { each: true }) - @ArrayNotEmpty() - cartItemIds: string[]; - - @IsString() - @IsNotEmpty() - deliveryAddress: string; - - @IsOptional() - @IsObject() - deliveryDetails?: Record; - - @IsEnum(PaymentMethod) - @IsOptional() - paymentMethod?: "ESCROW" | "DIRECT"; - - @IsIn(["MERCHANT_DELIVERY", "PLATFORM_LOGISTICS"]) - @IsOptional() - deliveryMethod?: "MERCHANT_DELIVERY" | "PLATFORM_LOGISTICS"; - - @IsOptional() - @IsString() - idempotencyKey?: string; -} diff --git a/apps/backend/src/modules/order/dto/create-direct-order.dto.ts b/apps/backend/src/modules/order/dto/create-direct-order.dto.ts deleted file mode 100644 index 03d4c4ed..00000000 --- a/apps/backend/src/modules/order/dto/create-direct-order.dto.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - IsNotEmpty, - IsNumber, - IsString, - IsUUID, - Min, - IsOptional, - IsEnum, - IsObject, -} from "class-validator"; -import { DeliveryMethod } from "@prisma/client"; - -enum PaymentMethodDto { - ESCROW = "ESCROW", - DIRECT = "DIRECT", -} - -export class CreateDirectOrderDto { - @IsUUID() - @IsNotEmpty() - productId: string; - - @IsNumber() - @Min(1) - @IsNotEmpty() - quantity: number; - - @IsString() - @IsNotEmpty() - deliveryAddress: string; - - @IsOptional() - @IsObject() - deliveryDetails?: Record; - - @IsOptional() - @IsEnum(PaymentMethodDto, { - message: "paymentMethod must be ESCROW or DIRECT", - }) - paymentMethod?: "ESCROW" | "DIRECT"; - - @IsOptional() - @IsEnum(DeliveryMethod, { - message: "deliveryMethod must be MERCHANT_DELIVERY or PLATFORM_LOGISTICS", - }) - deliveryMethod?: DeliveryMethod; - - @IsOptional() - @IsString() - idempotencyKey?: string; -} diff --git a/apps/backend/src/modules/order/invoice.service.ts b/apps/backend/src/modules/order/invoice.service.ts deleted file mode 100644 index f1f7a01f..00000000 --- a/apps/backend/src/modules/order/invoice.service.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { - Injectable, - NotFoundException, - ForbiddenException, -} from "@nestjs/common"; -import { PrismaService } from "../../prisma/prisma.service"; -import PDFDocument from "pdfkit"; -import { Response } from "express"; -import { JwtPayload } from "@swifta/shared"; - -@Injectable() -export class InvoiceService { - constructor(private prisma: PrismaService) {} - - async generateInvoice(orderId: string, res: Response, user: JwtPayload) { - const order = await this.prisma.order.findUnique({ - where: { id: orderId }, - include: { - user: { - select: { - firstName: true, - lastName: true, - email: true, - phone: true, - buyerProfile: true, - }, - }, - merchantProfile: { - select: { - businessName: true, - businessAddress: true, - }, - }, - }, - }); - - if (!order) throw new NotFoundException("Order not found"); - - if (order.buyerId !== user.sub && order.merchantId !== user?.merchantId) { - throw new ForbiddenException( - "You do not have permission to view this invoice", - ); - } - - const doc = new PDFDocument({ margin: 50, size: "A4" }); - - // Set headers for download - res.setHeader("Content-Type", "application/pdf"); - res.setHeader( - "Content-Disposition", - `attachment; filename=Invoice-${order.id.slice(0, 8)}.pdf`, - ); - - doc.pipe(res); - - // --- Header --- - this.generateHeader(doc); - this.generateCustomerInformation(doc, order); - this.generateInvoiceTable(doc, order); - this.generateFooter(doc); - - doc.end(); - } - - private generateHeader(doc: PDFKit.PDFDocument) { - doc - .fillColor("#444444") - .fontSize(20) - .text("Swifta", 50, 45, { align: "left" }) - .fontSize(10) - .text("Digital Marketplace & Escrow Payments", 50, 70, { align: "left" }) - .fillColor("#000000") - .fontSize(10) - .text("Invoice", 200, 50, { align: "right" }) - .text(`Date: ${new Date().toLocaleDateString()}`, 200, 65, { - align: "right", - }) - .moveDown(); - } - - private generateCustomerInformation(doc: PDFKit.PDFDocument, order: any) { - doc - .fillColor("#444444") - .fontSize(12) - .text("Bill To:", 50, 140) - .fontSize(10) - .fillColor("#000000") - .text(`${order.user.firstName} ${order.user.lastName}`, 50, 155) - .text(order.user.email, 50, 170) - .text(order.user.phone, 50, 185) - .moveDown(); - - doc - .fontSize(12) - .fillColor("#444444") - .text("Merchant:", 300, 140) - .fontSize(10) - .fillColor("#000000") - .text(order.merchantProfile?.businessName || "N/A", 300, 155) - .text(order.merchantProfile?.businessAddress || "N/A", 300, 170) - .moveDown(); - - this.generateHr(doc, 215); - } - - private generateInvoiceTable(doc: PDFKit.PDFDocument, order: any) { - const invoiceTableTop = 230; - - doc.font("Helvetica-Bold"); - this.generateTableRow( - doc, - invoiceTableTop, - "Item", - "Description", - "Unit Cost", - "Quantity", - "Line Total", - ); - this.generateHr(doc, invoiceTableTop + 20); - doc.font("Helvetica"); - - const items = (order.items as any[]) || []; - let position = invoiceTableTop + 30; - - items.forEach((item) => { - const lineTotal = (Number(item.unitPriceKobo) * item.quantity) / 100; - this.generateTableRow( - doc, - position, - item.name, - item.priceType || "Standard", - (Number(item.unitPriceKobo) / 100).toLocaleString("en-NG", { - style: "currency", - currency: "NGN", - }), - item.quantity.toString(), - lineTotal.toLocaleString("en-NG", { - style: "currency", - currency: "NGN", - }), - ); - - position += 20; - }); - - const subtotal = - Number(order.totalAmountKobo) - Number(order.deliveryFeeKobo); - const subtotalPosition = position + 30; - this.generateTableRow( - doc, - subtotalPosition, - "", - "", - "Subtotal", - "", - (subtotal / 100).toLocaleString("en-NG", { - style: "currency", - currency: "NGN", - }), - ); - - const deliveryPosition = subtotalPosition + 20; - this.generateTableRow( - doc, - deliveryPosition, - "", - "", - "Delivery Fee", - "", - (Number(order.deliveryFeeKobo) / 100).toLocaleString("en-NG", { - style: "currency", - currency: "NGN", - }), - ); - - const duePosition = deliveryPosition + 25; - doc.font("Helvetica-Bold"); - this.generateTableRow( - doc, - duePosition, - "", - "", - "Total Paid", - "", - (Number(order.totalAmountKobo) / 100).toLocaleString("en-NG", { - style: "currency", - currency: "NGN", - }), - ); - doc.font("Helvetica"); - } - - private generateFooter(doc: PDFKit.PDFDocument) { - doc - .fontSize(10) - .text( - "Thank you for your business. Payment was successfully processed via Paystack Escrow.", - 50, - 700, - { align: "center", width: 500 }, - ); - } - - private generateTableRow( - doc: PDFKit.PDFDocument, - y: number, - item: string, - description: string, - unitCost: string, - quantity: string, - lineTotal: string, - ) { - doc - .fontSize(10) - .text(item, 50, y) - .text(description, 150, y) - .text(unitCost, 280, y, { width: 90, align: "right" }) - .text(quantity, 370, y, { width: 90, align: "right" }) - .text(lineTotal, 0, y, { align: "right" }); - } - - private generateHr(doc: PDFKit.PDFDocument, y: number) { - doc - .strokeColor("#aaaaaa") - .lineWidth(1) - .moveTo(50, y) - .lineTo(550, y) - .stroke(); - } -} diff --git a/apps/backend/src/modules/order/order.controller.ts b/apps/backend/src/modules/order/order.controller.ts deleted file mode 100644 index 3d2239c4..00000000 --- a/apps/backend/src/modules/order/order.controller.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { - Controller, - Get, - Post, - Body, - Param, - UseGuards, - Query, - ForbiddenException, - ParseIntPipe, - Res, -} from "@nestjs/common"; -import { OrderService } from "./order.service"; -import { InvoiceService } from "./invoice.service"; -import { ConfirmDeliveryDto } from "./dto/confirm-delivery.dto"; -import type { Response } from "express"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { RolesGuard } from "../../common/guards/roles.guard"; -import { Roles } from "../../common/decorators/roles.decorator"; -import { CurrentUser } from "../../common/decorators/current-user.decorator"; -import { CurrentMerchant } from "../../common/decorators/current-merchant.decorator"; -import { UserRole, JwtPayload } from "@swifta/shared"; -import { CreateDirectOrderDto } from "./dto/create-direct-order.dto"; -import { CreateTrackingDto } from "./dto/create-tracking.dto"; -import { CheckoutCartDto } from "./dto/checkout-cart.dto"; - -@Controller("orders") -@UseGuards(JwtAuthGuard, RolesGuard) -export class OrderController { - constructor( - private readonly orderService: OrderService, - private readonly invoiceService: InvoiceService, - ) {} - - @Get() - findAll( - @CurrentUser() user: JwtPayload, - @Query("page", new ParseIntPipe({ optional: true })) page: number = 1, - @Query("limit", new ParseIntPipe({ optional: true })) limit: number = 20, - ) { - if (user.role === UserRole.MERCHANT && user.merchantId) { - return this.orderService.listByMerchant(user.merchantId, page, limit); - } - return this.orderService.listByBuyer(user.sub, page, limit); - } - - @Get("summary") - @Roles(UserRole.MERCHANT) - getSummary(@CurrentMerchant() merchantId: string | undefined) { - if (!merchantId) { - throw new ForbiddenException("Merchant identity required"); - } - return this.orderService.getMerchantSummary(merchantId); - } - - @Post("direct") - @Roles(UserRole.BUYER, UserRole.MERCHANT) - createDirectOrder( - @CurrentUser() user: JwtPayload, - @Body() dto: CreateDirectOrderDto, - ) { - return this.orderService.createDirectOrder(user.sub, dto); - } - - @Post("checkout/cart") - @Roles(UserRole.BUYER, UserRole.MERCHANT) - checkoutCart(@CurrentUser() user: JwtPayload, @Body() dto: CheckoutCartDto) { - return this.orderService.checkoutCart(user.sub, dto); - } - - @Get(":id") - findOne(@CurrentUser() user: JwtPayload, @Param("id") id: string) { - return this.orderService.getById(id, user.sub, user.merchantId); - } - - @Post(":id/tracking") - @Roles(UserRole.MERCHANT) - addTracking( - @CurrentMerchant() merchantId: string | undefined, - @Param("id") id: string, - @Body() dto: CreateTrackingDto, - ) { - if (!merchantId) { - throw new ForbiddenException("Merchant identity required"); - } - return this.orderService.addTracking(merchantId, id, dto); - } - - @Get(":id/tracking") - getTracking(@CurrentUser() user: JwtPayload, @Param("id") id: string) { - return this.orderService.getTracking(id, user.sub, user.merchantId); - } - - @Post(":id/dispatch") - @Roles(UserRole.MERCHANT) - dispatch( - @CurrentMerchant() merchantId: string | undefined, - @Param("id") id: string, - ) { - if (!merchantId) { - throw new ForbiddenException("Merchant identity required"); - } - return this.orderService.dispatch(merchantId, id); - } - - @Post(":id/confirm-delivery") - @Roles(UserRole.BUYER, UserRole.MERCHANT) - confirmDelivery( - @CurrentUser() user: JwtPayload, - @Param("id") id: string, - @Body() dto: ConfirmDeliveryDto, - ) { - return this.orderService.confirmDelivery(user.sub, id, dto.otp); - } - - @Post(":id/cancel") - cancel(@CurrentUser() user: JwtPayload, @Param("id") id: string) { - return this.orderService.cancel(user.sub, id, user.merchantId); - } - - @Post(":id/report-issue") - @Roles(UserRole.BUYER, UserRole.MERCHANT) - reportIssue( - @CurrentUser() user: JwtPayload, - @Param("id") id: string, - @Body("reason") reason: string, - ) { - return this.orderService.reportIssue(user.sub, id, reason); - } - - @Get(":id/receipt") - @Roles(UserRole.BUYER, UserRole.MERCHANT) - getReceipt(@CurrentUser() user: JwtPayload, @Param("id") id: string) { - return this.orderService.getReceipt(id, user.sub); - } - - @Get(":id/invoice") - @Roles(UserRole.BUYER, UserRole.MERCHANT) - async getInvoice( - @CurrentUser() user: JwtPayload, - @Param("id") id: string, - @Res() res: Response, - ) { - // Pass caller identity to the service to enforce ownership validation - return this.invoiceService.generateInvoice(id, res, user); - } -} diff --git a/apps/backend/src/modules/order/order.module.ts b/apps/backend/src/modules/order/order.module.ts deleted file mode 100644 index 0f9cd01b..00000000 --- a/apps/backend/src/modules/order/order.module.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Module, forwardRef } from "@nestjs/common"; -import { OrderService } from "./order.service"; -import { InvoiceService } from "./invoice.service"; -import { OrderController } from "./order.controller"; -import { PrismaModule } from "../../prisma/prisma.module"; -import { NotificationModule } from "../notification/notification.module"; -import { InventoryModule } from "../inventory/inventory.module"; -import { PaymentModule } from "../payment/payment.module"; -import { ReorderModule } from "../reorder/reorder.module"; -import { VerificationModule } from "../verification/verification.module"; -import { LogisticsModule } from "../logistics/logistics.module"; -import { WhatsAppModule } from "../whatsapp/whatsapp.module"; -import { PayoutModule } from "../payout/payout.module"; - -@Module({ - imports: [ - PrismaModule, - forwardRef(() => NotificationModule), - InventoryModule, - forwardRef(() => PaymentModule), - forwardRef(() => ReorderModule), - VerificationModule, - forwardRef(() => LogisticsModule), - forwardRef(() => WhatsAppModule), - PayoutModule, - ], - controllers: [OrderController], - providers: [OrderService, InvoiceService], - exports: [OrderService], -}) -export class OrderModule {} diff --git a/apps/backend/src/modules/order/order.service.ts b/apps/backend/src/modules/order/order.service.ts deleted file mode 100644 index bd8962c9..00000000 --- a/apps/backend/src/modules/order/order.service.ts +++ /dev/null @@ -1,1405 +0,0 @@ -import { - Injectable, - Logger, - NotFoundException, - BadRequestException, - ForbiddenException, - Inject, - forwardRef, -} from "@nestjs/common"; -import { InjectQueue } from "@nestjs/bullmq"; -import { Queue } from "bullmq"; -import { PrismaService } from "../../prisma/prisma.service"; -import { NotificationTriggerService } from "../notification/notification-trigger.service"; -import { InventoryService } from "../inventory/inventory.service"; -import { PaymentService } from "../payment/payment.service"; -import { ReorderService } from "../reorder/reorder.service"; -import { LogisticsService } from "../logistics/logistics.service"; -import { - PAYOUT_QUEUE, - REVIEW_QUEUE, - CHECKOUT_REMINDER_QUEUE, -} from "../../queue/queue.constants"; -import { WhatsAppService } from "../whatsapp/whatsapp.service"; -import { - InventoryEventType, - Order, - OrderStatus, - PaginatedResponse, - PriceType, -} from "@swifta/shared"; - -import { paginate } from "../../common/utils/pagination"; -import { validateTransition, getNextStates } from "./order-state-machine"; -import * as crypto from "crypto"; -import { VerificationService } from "../verification/verification.service"; -import { CreateDirectOrderDto } from "./dto/create-direct-order.dto"; -import { CreateTrackingDto } from "./dto/create-tracking.dto"; -import { CheckoutCartDto } from "./dto/checkout-cart.dto"; - -import { PayoutService } from "../payout/payout.service"; -import { PlatformConfig } from "../../config/platform.config"; - -@Injectable() -export class OrderService { - private readonly logger = new Logger(OrderService.name); - - constructor( - private prisma: PrismaService, - private notifications: NotificationTriggerService, - private inventoryService: InventoryService, - @Inject(forwardRef(() => PaymentService)) - private paymentService: PaymentService, - private reorderService: ReorderService, - @InjectQueue(PAYOUT_QUEUE) private payoutQueue: Queue, - @InjectQueue(REVIEW_QUEUE) private reviewQueue: Queue, - @InjectQueue(CHECKOUT_REMINDER_QUEUE) private checkoutReminderQueue: Queue, - private verificationService: VerificationService, - @Inject(forwardRef(() => LogisticsService)) - private logisticsService: LogisticsService, - @Inject(forwardRef(() => WhatsAppService)) - private whatsappService: WhatsAppService, - private payoutService: PayoutService, - ) {} - - // ────────────────────────────────────────────── - // CREATE DIRECT ORDER - // ────────────────────────────────────────────── - - async createDirectOrder(buyerId: string, dto: CreateDirectOrderDto) { - const { - productId, - quantity, - deliveryAddress, - deliveryDetails, - paymentMethod: requestedMethod, - deliveryMethod = "MERCHANT_DELIVERY", - } = dto; - const product = await this.prisma.product.findUnique({ - where: { id: productId }, - include: { productStockCache: true, merchantProfile: true }, - }); - - if (!product) throw new NotFoundException("Product not found"); - if (!product.isActive) throw new BadRequestException("Product is inactive"); - - if (product.merchantProfile?.userId === buyerId) { - throw new ForbiddenException( - "Merchants cannot purchase their own products", - ); - } - - // Resolve buyer type to determine price - const buyerProfile = await this.prisma.buyerProfile.findUnique({ - where: { userId: buyerId }, - }); - const isConsumer = buyerProfile?.buyerType === "CONSUMER"; - - const resolvedPriceKobo = - isConsumer && product.retailPriceKobo - ? product.retailPriceKobo - : (product.wholesalePriceKobo ?? product.pricePerUnitKobo); - - if (resolvedPriceKobo === null) { - throw new BadRequestException("Product price is not available"); - } - - // Handle missing stock cache: auto-heal for all active products to prevent checkout blocks - let currentStock = product.productStockCache?.stock ?? 0; - if (!product.productStockCache && product.isActive) { - this.logger.warn( - `Auto-creating missing productStockCache for product ${product.id}`, - ); - currentStock = product.isSeeded ? 100 : 50; - await this.prisma.productStockCache.upsert({ - where: { productId: product.id }, - update: { stock: currentStock }, - create: { productId: product.id, stock: currentStock }, - }); - } - - if (currentStock < quantity) { - throw new BadRequestException("Insufficient stock"); - } - - // Determine payment method based on merchant verification tier - const merchantTier = product.merchantProfile?.verificationTier; - const isTier2Or3 = merchantTier === "TIER_2" || merchantTier === "TIER_3"; - const paymentMethod = - requestedMethod === "DIRECT" && isTier2Or3 ? "DIRECT" : "ESCROW"; - - const subtotalKobo = BigInt(resolvedPriceKobo) * BigInt(quantity); - const platformFeePercent = PlatformConfig.getPlatformFeePercent( - merchantTier, - paymentMethod, - ); - const platformFeeKobo = PlatformConfig.calculateFeeKobo( - subtotalKobo, - merchantTier, - paymentMethod, - ); - - let calculatedDeliveryFeeKobo = 0n; - if ( - deliveryMethod === "PLATFORM_LOGISTICS" && - product.merchantProfile?.businessAddress - ) { - // Estimate weight roughly, e.g., 50kg per unit if bulky, or fallback to 1kg - const estimatedWeightKg = - product.unit.toLowerCase() === "bag" ? 50 * quantity : 1 * quantity; - const quote = await this.logisticsService.getQuote( - product.merchantProfile.businessAddress, - deliveryAddress, - estimatedWeightKg, - ); - calculatedDeliveryFeeKobo = BigInt(quote.costKobo); - } - - // Add delivery fee logic - const totalKobo = - subtotalKobo + platformFeeKobo + calculatedDeliveryFeeKobo; - - const idempotencyKey = - dto.idempotencyKey || - this.generateDeterministicKey("direct", buyerId, { - productId, - quantity, - deliveryAddress, - paymentMethod, - deliveryMethod, - }); - - // Create the order with reservation - const order = await this.prisma.$transaction(async (tx) => { - // 1. Atomic reservation - const updateResult = await tx.productStockCache.updateMany({ - where: { - productId: product.id, - stock: { gte: quantity }, - }, - data: { stock: { decrement: quantity } }, - }); - - if (updateResult.count === 0) { - throw new BadRequestException("Insufficient stock"); - } - - // 2. Inventory Event - await tx.inventoryEvent.create({ - data: { - productId: product.id, - merchantId: product.merchantId, - eventType: InventoryEventType.ORDER_RESERVED, - quantity: quantity, - notes: `Direct purchase reservation (buyer: ${buyerId})`, - }, - }); - - // 3. Create order (with P2002 idempotency guard) - let newOrder; - try { - newOrder = await tx.order.create({ - data: { - buyerId, - merchantId: product.merchantId, - productId: product.id, - quantity, - unitPriceKobo: resolvedPriceKobo, - deliveryAddress, - deliveryDetails: deliveryDetails ? (deliveryDetails as any) : null, - totalAmountKobo: totalKobo, - deliveryFeeKobo: calculatedDeliveryFeeKobo, - currency: "NGN", - status: OrderStatus.PENDING_PAYMENT, - paymentMethod, - deliveryMethod, - platformFeeKobo, - platformFeePercent, - quoteId: null, - idempotencyKey, - payoutStatus: "PENDING", - }, - }); - } catch (err: any) { - // If idempotency key conflict (P2002), return existing order - if (err?.code === "P2002") { - const existing = await tx.order.findFirst({ - where: { idempotencyKey }, - }); - if (existing) return existing; - } - throw err; - } - - // Log initial OrderEvent - await tx.orderEvent.create({ - data: { - orderId: newOrder.id, - fromStatus: null, - toStatus: OrderStatus.PENDING_PAYMENT, - triggeredBy: buyerId, - metadata: { - action: "direct_purchase_created", - productId, - quantity, - paymentMethod, - }, - }, - }); - - return newOrder; - }); - - // Notify Merchant (outside transaction) - try { - const orderWithDetails = await this.prisma.order.findUnique({ - where: { id: order.id }, - include: { user: true, product: true }, - }); - - if (orderWithDetails && order.merchantId) { - this.whatsappService - .sendDirectOrderNotification(order.merchantId, { - orderId: order.id, - buyerName: - orderWithDetails.user.firstName + - " " + - orderWithDetails.user.lastName, - productName: orderWithDetails.product?.name || "Product", - quantity: order.quantity || 1, - amountKobo: order.totalAmountKobo, - deliveryAddress: order.deliveryAddress || "Not specified", - }) - .catch((err) => - this.logger.error(`Error in sendDirectOrderNotification: ${err}`), - ); - } - } catch (notifierErr) { - this.logger.error( - `Failed to send merchant WhatsApp notification for ${order.id}`, - notifierErr, - ); - } - - this.logger.log( - `Direct order ${order.id} created for product ${productId} (method: ${paymentMethod})`, - ); - - await this.checkoutReminderQueue.add( - "send-checkout-reminder", - { orderId: order.id }, - { delay: 60 * 60 * 1000 }, - ); - - // Call payment service to get the authorization URL dynamically - const paymentData = await this.paymentService.initialize( - buyerId, - { orderId: order.id }, - `init-direct-${order.id}`, - ); - - return { - orderId: order.id, - authorizationUrl: paymentData.authorization_url, - totalAmountKobo: Number(totalKobo), // For notifications/events, cast back to number - platformFeeKobo, - paymentMethod, - }; - } - - // ────────────────────────────────────────────── - // CHECKOUT CART (B2C MULTI-ITEM CHECKOUT) - // ────────────────────────────────────────────── - - async checkoutCart(buyerId: string, dto: CheckoutCartDto) { - const { - cartItemIds, - deliveryAddress, - deliveryDetails, - deliveryMethod = "MERCHANT_DELIVERY", - paymentMethod: requestedMethod, - } = dto; - - if (!cartItemIds || cartItemIds.length === 0) { - throw new BadRequestException("No cart items provided for checkout."); - } - - // Fetch the cart items with products and ensuring they belong to the buyer - const items = await this.prisma.cartItem.findMany({ - where: { - id: { in: cartItemIds }, - buyerId: buyerId, - }, - include: { - product: { - include: { merchantProfile: true, productStockCache: true }, - }, - }, - }); - - if (items.length !== cartItemIds.length) { - throw new BadRequestException( - "One or more cart items are invalid or do not belong to you.", - ); - } - - // Ensure all items are from the SAME MERCHANT because Escrow Payouts are 1-to-1 Order-to-Merchant - const merchantIds = new Set(items.map((item) => item.product.merchantId)); - if (merchantIds.size > 1) { - throw new BadRequestException( - "Cart checkout is restricted to one merchant per order. Please split your checkout.", - ); - } - - const merchantId = items[0].product.merchantId; - const merchantProfile = items[0].product.merchantProfile; - if (!merchantProfile) { - throw new BadRequestException( - "Merchant profile not found for these items.", - ); - } - - if (merchantProfile.userId === buyerId) { - throw new ForbiddenException( - "Merchants cannot purchase their own products", - ); - } - - await this.prisma.buyerProfile.findUnique({ - where: { userId: buyerId }, - }); - - let subtotalKobo = 0n; - const jsonItems = []; - - // Validations and calculations - for (const item of items) { - const product = item.product; - if (!product.isActive || product.deletedAt) { - throw new BadRequestException( - `Product ${product.name} is no longer available.`, - ); - } - - // Logic: Retail vs Wholesale price selection based on item.priceType - const isWholesale = item.priceType === PriceType.WHOLESALE; - const minQty = isWholesale - ? product.minOrderQuantity - : product.minOrderQuantityConsumer; - - // Handle missing stock cache: auto-heal for all active products to prevent checkout blocks - let currentStock = product.productStockCache?.stock ?? 0; - if (!product.productStockCache && product.isActive) { - this.logger.warn( - `Auto-creating missing productStockCache for product ${product.id}`, - ); - // Give it a default buffer to allow checkout to proceed or 100 for seeded - currentStock = product.isSeeded ? 100 : 50; - await this.prisma.productStockCache.upsert({ - where: { productId: product.id }, - update: { stock: currentStock }, - create: { productId: product.id, stock: currentStock }, - }); - } - - if (currentStock < item.quantity || item.quantity < minQty) { - throw new BadRequestException( - `Insufficient stock or quantity for ${product.name} is below the minimum for ${item.priceType} (${minQty})`, - ); - } - - const resolvedPriceKobo = isWholesale - ? (product.wholesalePriceKobo ?? product.pricePerUnitKobo ?? 0n) - : (product.retailPriceKobo ?? product.pricePerUnitKobo ?? 0n); - - if (resolvedPriceKobo === 0n) { - throw new BadRequestException( - `Product ${product.name} does not have a valid ${item.priceType} price.`, - ); - } - - subtotalKobo += BigInt(resolvedPriceKobo) * BigInt(item.quantity); - - jsonItems.push({ - productId: product.id, - quantity: item.quantity, - unitPriceKobo: resolvedPriceKobo.toString(), - name: product.name, - priceType: item.priceType, - }); - } - - // Determine payment method and platform fee - const merchantTier = merchantProfile.verificationTier; - const isTier2Or3 = merchantTier === "TIER_2" || merchantTier === "TIER_3"; - const paymentMethod = - requestedMethod === "DIRECT" && isTier2Or3 ? "DIRECT" : "ESCROW"; - - // Dynamic platform fee based on config/tier - const platformFeePercentage = PlatformConfig.getPlatformFeePercent( - merchantTier, - paymentMethod, - ); - const platformFeeKobo = PlatformConfig.calculateFeeKobo( - subtotalKobo, - merchantTier, - paymentMethod, - ); - - let calculatedDeliveryFeeKobo = 0n; - if ( - deliveryMethod === "PLATFORM_LOGISTICS" && - merchantProfile.businessAddress - ) { - // Very rough estimate of 10kg per cart item total for now - const estimatedWeightKg = 10 * items.length; - // No try-catch here: let logistics errors propagate to the user - const quote = await this.logisticsService.getQuote( - merchantProfile.businessAddress, - deliveryAddress, - estimatedWeightKg, - ); - calculatedDeliveryFeeKobo = BigInt(quote.costKobo); - } - - const totalKobo = - subtotalKobo + platformFeeKobo + calculatedDeliveryFeeKobo; - const idempotencyKey = - dto.idempotencyKey || - this.generateDeterministicKey("cart", buyerId, { - cartItemIds: [...cartItemIds].sort(), - deliveryAddress, - paymentMethod, - deliveryMethod, - }); - - // Create the order atomically with inventory reservation - const order = await this.prisma.$transaction(async (tx) => { - // 1. Atomic: check and decrement stock - for (const item of items) { - const updateResult = await tx.productStockCache.updateMany({ - where: { - productId: item.productId, - stock: { gte: item.quantity }, - }, - data: { stock: { decrement: item.quantity } }, - }); - - if (updateResult.count === 0) { - throw new BadRequestException( - `Insufficient stock for ${item.product.name}.`, - ); - } - - // Log inventory event - await tx.inventoryEvent.create({ - data: { - productId: item.productId, - merchantId: merchantId as string, - eventType: InventoryEventType.ORDER_RESERVED, - quantity: item.quantity, - notes: `Reserved for cart checkout (buyer: ${buyerId})`, - }, - }); - } - - // 2. Create the order (with P2002 idempotency guard) - let newOrder; - try { - newOrder = await tx.order.create({ - data: { - buyerId, - merchantId, - deliveryAddress, - deliveryDetails: deliveryDetails ? (deliveryDetails as any) : null, - totalAmountKobo: totalKobo, - deliveryFeeKobo: calculatedDeliveryFeeKobo, - currency: "NGN", - status: OrderStatus.PENDING_PAYMENT, - paymentMethod, - deliveryMethod: deliveryMethod as any, - platformFeeKobo, - platformFeePercent: platformFeePercentage, - items: jsonItems, - quoteId: null, - idempotencyKey, - payoutStatus: "PENDING", - }, - }); - } catch (err: any) { - // If idempotency key conflict (P2002), return existing order - if (err?.code === "P2002") { - const existing = await tx.order.findFirst({ - where: { idempotencyKey }, - }); - if (existing) return existing; - } - throw err; - } - - // 3. Create initial order event (audit trail) - await tx.orderEvent.create({ - data: { - orderId: newOrder.id, - toStatus: OrderStatus.PENDING_PAYMENT, - triggeredBy: buyerId, - metadata: { action: "cart_checkout", items: jsonItems }, - }, - }); - - // 4. NOTE: Cart items are NOT cleared here anymore. - // They will be cleared in the webhook handler after payment succeeds. - // This prevents data loss when buyers abandon payment. - - return newOrder; - }); - - await this.checkoutReminderQueue.add( - "send-checkout-reminder", - { orderId: order.id }, - { delay: 60 * 60 * 1000 }, - ); - - // Generate Payment Link - const paymentData = await this.paymentService.initialize( - buyerId, - { orderId: order.id }, - `init-cart-${order.id}`, - ); - - return { - orderId: order.id, - authorizationUrl: paymentData.authorization_url, - totalAmountKobo: Number(totalKobo), - platformFeeKobo: Number(platformFeeKobo), - paymentMethod, - }; - } - - // ────────────────────────────────────────────── - // GENERIC STATE TRANSITION (audit + validate) - // ────────────────────────────────────────────── - - private async transition( - orderId: string, - fromStatus: OrderStatus, - toStatus: OrderStatus, - triggeredBy: string, - metadata?: Record, - ) { - if (!validateTransition(fromStatus, toStatus)) { - throw new BadRequestException( - `Invalid state transition from ${fromStatus} to ${toStatus}`, - ); - } - - const updatedOrder = await this.prisma.order.update({ - where: { id: orderId }, - data: { status: toStatus }, - }); - - await this.prisma.orderEvent.create({ - data: { - orderId, - fromStatus, - toStatus, - triggeredBy, - metadata: metadata || {}, - }, - }); - - this.logger.log(`Order ${orderId}: ${fromStatus} → ${toStatus}`); - return updatedOrder; - } - - // ────────────────────────────────────────────── - // SYSTEM-DRIVEN TRANSITION (e.g., payment webhook) - // ────────────────────────────────────────────── - - async transitionBySystem( - orderId: string, - fromStatus: OrderStatus, - toStatus: OrderStatus, - metadata?: Record, - ) { - const order = await this.prisma.order.findUnique({ - where: { id: orderId }, - }); - if (!order) throw new NotFoundException("Order not found"); - - if (!validateTransition(fromStatus, toStatus)) { - throw new BadRequestException( - `Invalid state transition from ${fromStatus} to ${toStatus}`, - ); - } - - const updatedOrder = await this.prisma.order.update({ - where: { id: orderId }, - data: { status: toStatus }, - }); - - await this.prisma.orderEvent.create({ - data: { - orderId, - fromStatus, - toStatus, - triggeredBy: order.buyerId, - metadata: { ...(metadata || {}), triggeredBySystem: true }, - }, - }); - - this.logger.log(`Order ${orderId}: ${fromStatus} → ${toStatus} (system)`); - return updatedOrder; - } - - // ────────────────────────────────────────────── - // GET ORDER BY ID (with ownership check) - // ────────────────────────────────────────────── - - async getById(id: string, userId: string, merchantId?: string) { - const order = await this.prisma.order.findUnique({ - where: { id }, - include: { - orderEvents: { orderBy: { createdAt: "asc" } }, - review: true, - }, - }); - if (!order) throw new NotFoundException("Order not found"); - - // Ownership check: must be the buyer OR the merchant - const isBuyer = order.buyerId === userId; - const isMerchant = merchantId && order.merchantId === merchantId; - if (!isBuyer && !isMerchant) { - throw new ForbiddenException("Access denied"); - } - - if (!isBuyer && "deliveryOtp" in order) { - order.deliveryOtp = null; - } - - return order; - } - - // ────────────────────────────────────────────── - // LIST ORDERS - // ────────────────────────────────────────────── - - async listByBuyer( - buyerId: string, - page: number, - limit: number, - ): Promise> { - return paginate( - this.prisma.order, - { page, limit }, - { - where: { buyerId }, - orderBy: { createdAt: "desc" }, - include: { merchantProfile: { select: { businessName: true } } }, - }, - ); - } - - async listByMerchant( - merchantId: string, - page: number, - limit: number, - ): Promise> { - const paginated = await paginate( - this.prisma.order, - { page, limit }, - { - where: { merchantId }, - orderBy: { createdAt: "desc" }, - include: { - user: { select: { email: true, phone: true } }, - product: true, - }, - }, - ); - - // Strip OTP from merchant responses - if (paginated.data) { - (paginated.data as any[]).forEach((order) => { - order.deliveryOtp = null; - }); - } - - return paginated as any; - } - - // ────────────────────────────────────────────── - // DISPATCH (merchant only, generates OTP) - // ────────────────────────────────────────────── - - async dispatch(merchantId: string, orderId: string) { - const order = await this.prisma.order.findUnique({ - where: { id: orderId }, - }); - if (!order) throw new NotFoundException("Order not found"); - if (order.merchantId !== merchantId) - throw new ForbiddenException("Access denied"); - - if ( - order.status !== OrderStatus.PAID && - order.status !== OrderStatus.PREPARING - ) { - throw new BadRequestException( - "Order must be in PAID or PREPARING status to dispatch", - ); - } - - // Crypto-secure 6-digit OTP - const deliveryOtp = - order.deliveryOtp || crypto.randomInt(100000, 999999).toString(); - - // Resolve triggeredBy (userId from merchantId) - const triggeredBy = await this.getUserIdFromMerchant(merchantId); - - // Save OTP, dispatchedAt + tracking record atomically - await this.prisma.$transaction([ - this.prisma.order.update({ - where: { id: orderId }, - data: { deliveryOtp, dispatchedAt: new Date() }, - }), - this.prisma.orderTracking.create({ - data: { - orderId, - status: OrderStatus.DISPATCHED, - }, - }), - ]); - - const updatedOrder = await this.transition( - orderId, - order.status as OrderStatus, - OrderStatus.DISPATCHED, - triggeredBy, - { action: "dispatched" }, - ); - - // Notification (async, best-effort) - await this.notifications.triggerOrderDispatched(order.buyerId, { - orderId, - reference: orderId.slice(0, 8).toUpperCase(), - otp: deliveryOtp, - }); - - this.logger.log(`Order ${orderId} dispatched, OTP generated`); - return updatedOrder; - } - - // ────────────────────────────────────────────── - // TRACKING (merchant) - // ────────────────────────────────────────────── - - async addTracking( - merchantId: string, - orderId: string, - dto: CreateTrackingDto, - ) { - const order = await this.prisma.order.findUnique({ - where: { id: orderId }, - }); - if (!order) throw new NotFoundException("Order not found"); - if (order.merchantId !== merchantId) - throw new ForbiddenException("Access denied"); - - // Reject DELIVERED status for merchant tracking updates - if (dto.status === OrderStatus.DELIVERED) { - throw new BadRequestException( - "Use confirmDelivery() to mark an order as DELIVERED after OTP validation", - ); - } - - // Check valid next states via state machine - const allowedNext = getNextStates(order.status as OrderStatus); - if (!allowedNext.includes(dto.status)) { - throw new BadRequestException( - `Cannot transition from ${order.status} to ${dto.status}`, - ); - } - - // Handle OTP generation if transitioning to DISPATCHED - let deliveryOtp = order.deliveryOtp; - if (dto.status === OrderStatus.DISPATCHED && !deliveryOtp) { - deliveryOtp = crypto.randomInt(100000, 999999).toString(); - await this.prisma.order.update({ - where: { id: orderId }, - data: { deliveryOtp, dispatchedAt: new Date() }, - }); - } - - // Resolve triggeredBy - const triggeredBy = await this.getUserIdFromMerchant(merchantId); - - // Create tracking record - await this.prisma.orderTracking.create({ - data: { - orderId, - status: dto.status, - note: dto.note, - }, - }); - - const updatedOrder = await this.transition( - orderId, - order.status as OrderStatus, - dto.status, - triggeredBy, - { action: "tracking_update", note: dto.note }, - ); - - // Send relevant notification - try { - if (dto.status === OrderStatus.PREPARING) { - await this.notifications.triggerOrderPreparing(order.buyerId, { - orderId, - reference: orderId.slice(0, 8).toUpperCase(), - }); - } else if (dto.status === OrderStatus.DISPATCHED) { - await this.notifications.triggerOrderDispatched(order.buyerId, { - orderId, - reference: orderId.slice(0, 8).toUpperCase(), - otp: deliveryOtp as string, - }); - } else if (dto.status === OrderStatus.IN_TRANSIT) { - await this.notifications.triggerOrderInTransit(order.buyerId, { - orderId, - reference: orderId.slice(0, 8).toUpperCase(), - note: dto.note, - }); - } - } catch (error) { - this.logger.error( - `Failed to send tracking notification for order ${orderId} (buyer ${order.buyerId}, type: ${dto.status})`, - error instanceof Error ? error.stack : "Unknown error", - ); - } - - return updatedOrder; - } - - async getTracking(orderId: string, userId: string, merchantId?: string) { - const order = await this.prisma.order.findUnique({ - where: { id: orderId }, - }); - if (!order) throw new NotFoundException("Order not found"); - - // Ensure permission - if (order.buyerId !== userId && order.merchantId !== merchantId) { - throw new ForbiddenException("Access denied"); - } - - return this.prisma.orderTracking.findMany({ - where: { orderId }, - orderBy: { createdAt: "asc" }, - }); - } - - // ────────────────────────────────────────────── - // CONFIRM DELIVERY (buyer only, verifies OTP) - // ────────────────────────────────────────────── - - async confirmDelivery(buyerId: string, orderId: string, otp: string) { - const order = await this.prisma.order.findUnique({ - where: { id: orderId }, - }); - if (!order) throw new NotFoundException("Order not found"); - if (order.buyerId !== buyerId) - throw new ForbiddenException("Access denied"); - - // Explicit status check before OTP validation - if ( - order.status !== OrderStatus.DISPATCHED && - order.status !== OrderStatus.IN_TRANSIT - ) { - throw new BadRequestException( - "Order must be in DISPATCHED or IN_TRANSIT status to confirm delivery", - ); - } - - if (order.deliveryOtp !== otp) { - throw new BadRequestException("Invalid OTP"); - } - - // Transition: CURRENT_STATUS → DELIVERED - await this.transition( - orderId, - order.status as OrderStatus, - OrderStatus.DELIVERED, - buyerId, - { action: "delivery_confirmed" }, - ); - - // Notify both merchant and buyer (best-effort, must not block state transition) - try { - await this.notifications.triggerDeliveryConfirmed( - order.merchantId, - order.buyerId, - { - orderId, - reference: orderId.slice(0, 8).toUpperCase(), - amountKobo: order.totalAmountKobo, - }, - ); - } catch (error) { - this.logger.error( - `Failed to send delivery confirmed notification (orderId=${orderId}, merchantId=${order.merchantId}): ${error instanceof Error ? error.message : error}`, - ); - } - - // Auto-transition: DELIVERED → COMPLETED - await this.transition( - orderId, - OrderStatus.DELIVERED, - OrderStatus.COMPLETED, - buyerId, - { action: "auto_completed" }, - ); - - // Call VerificationService to trigger check on merchant's verification tier - try { - this.logger.log( - `Evaluating tier upgrade for merchant ${order.merchantId}`, - ); - await this.verificationService.checkAndUpgradeTier(order.merchantId); - } catch (error) { - this.logger.error( - `Failed to evaluate tier upgrade for merchant ${order.merchantId}: ${error instanceof Error ? error.message : error}`, - ); - } - - // AUTO-PAYOUT: Only queue payout for ESCROW orders. - // DIRECT orders are already paid out immediately on payment confirmation. - if (order.paymentMethod === "ESCROW") { - try { - this.logger.log(`Queueing auto-payout for ESCROW order ${orderId}`); - await this.payoutQueue.add( - "process-payout", - { orderId }, - { jobId: `payout-${orderId}` }, - ); - } catch (error) { - const msg = error instanceof Error ? error.message : "Unknown error"; - this.logger.error( - `Auto-payout failed for order ${orderId} (will need manual retry): ${msg}`, - error instanceof Error ? error.stack : undefined, - ); - } - } else { - this.logger.log( - `Skipping payout queue for DIRECT order ${orderId} (already paid out)`, - ); - } - - // Create reorder reminder (best-effort) - try { - await this.reorderService.createReminder(orderId); - } catch (error) { - this.logger.error( - `Failed to create reorder reminder for order ${orderId}: ${error instanceof Error ? error.message : error}`, - ); - } - - // REVIEW PROMPT: Queue a review prompt job after 1 hour (3600 seconds) - // Only if the order has a merchant (Reviews are for merchants) - if (order.merchantId) { - try { - this.logger.log( - `Queueing review prompt for order ${orderId} in 1 hour`, - ); - await this.reviewQueue.add( - "send-review-prompt", - { orderId, buyerId }, - { - delay: 3600 * 1000, // 1 hour delay - jobId: `review-prompt-${orderId}`, - removeOnComplete: true, - }, - ); - } catch (error) { - this.logger.error( - `Failed to queue review prompt for order ${orderId}: ${error instanceof Error ? error.message : error}`, - ); - } - } else { - this.logger.log( - `Skipping review prompt for order ${orderId} (No merchant associated)`, - ); - } - - return { message: "Delivery confirmed" }; - } - - /** - * System-triggered delivery confirmation after 72 hours of inactivity. - */ - async autoConfirmDelivery(orderId: string) { - const order = await this.prisma.order.findUnique({ - where: { id: orderId }, - include: { - user: true, // buyer - merchantProfile: true, - }, - }); - - if (!order) { - throw new NotFoundException(`Order ${orderId} not found`); - } - - if ( - order.status !== OrderStatus.DISPATCHED && - order.status !== OrderStatus.IN_TRANSIT - ) { - this.logger.warn( - `Order ${orderId} is in ${order.status} state, cannot auto-confirm.`, - ); - return; - } - - this.logger.log(`Auto-confirming delivery for order ${orderId}`); - - // 1. Transition to DELIVERED (uses standard audit trail) - await this.transition( - orderId, - order.status as OrderStatus, - OrderStatus.DELIVERED, - order.buyerId, // System acting on behalf of buyer's silence - { action: "system_auto_confirm" }, - ); - - // 2. Clear dispute window immediately for auto-confirmation - await this.prisma.order.update({ - where: { id: orderId }, - data: { disputeWindowEndsAt: new Date() }, - }); - - // 3. Create tracking entry AFTER transition - await this.prisma.orderTracking.create({ - data: { - orderId, - status: OrderStatus.DELIVERED, - note: "Delivery auto-confirmed after 72 hours of no response.", - }, - }); - - // 4. Notify participants with strict isolation - try { - if (order.merchantId) { - await this.notifications.triggerOrderAutoConfirmed( - order.buyerId, - order.merchantId, - { - orderId: order.id, - reference: order.id.slice(0, 8).toUpperCase(), - amountKobo: order.totalAmountKobo, - }, - ); - } - } catch (error) { - this.logger.error( - `Failed to notify for auto-confirmed order ${orderId}: ${error instanceof Error ? error.message : error}`, - ); - } - - // 5. Final transition to COMPLETED - await this.transition( - orderId, - OrderStatus.DELIVERED, - OrderStatus.COMPLETED, - order.buyerId, - { action: "auto_completion_payout" }, - ); - - // 6. Post-completion processing (payouts and tier checks) - try { - if (order.merchantId) { - // Upgrade check - await this.verificationService.checkAndUpgradeTier(order.merchantId); - - // Payout if ESCROW - if (order.paymentMethod === "ESCROW") { - await this.payoutQueue.add( - "process-payout", - { orderId }, - { jobId: `payout-${orderId}` }, - ); - } - } - } catch (err: unknown) { - this.logger.error( - `Post-auto-confirm processing failed for ${orderId}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - return { success: true }; - } - - // ────────────────────────────────────────────── - // CANCEL (role-based status rules) - // ────────────────────────────────────────────── - - async cancel(userId: string, orderId: string, merchantId?: string) { - const order = await this.prisma.order.findUnique({ - where: { id: orderId }, - }); - if (!order) throw new NotFoundException("Order not found"); - - const isBuyer = order.buyerId === userId; - const isMerchant = merchantId && order.merchantId === merchantId; - - if (!isBuyer && !isMerchant) { - throw new ForbiddenException("Access denied"); - } - - // Role-based cancellation rules per guide: - // - Buyer can cancel if PENDING_PAYMENT (no refund needed) - // - Merchant can cancel if PAID (auto-refund triggered) - if (isBuyer && order.status !== OrderStatus.PENDING_PAYMENT) { - throw new BadRequestException( - "Buyer can only cancel orders in PENDING_PAYMENT status", - ); - } - if (isMerchant && order.status !== OrderStatus.PAID) { - throw new BadRequestException( - "Merchant can only cancel orders in PAID status", - ); - } - - // Transition to CANCELLED - await this.transition( - orderId, - order.status as OrderStatus, - OrderStatus.CANCELLED, - userId, - { cancelledBy: isBuyer ? "buyer" : "merchant" }, - ); - - // Release reserved stock for different order types - if (order.productId && order.quantity) { - // Direct Purchase - await this.inventoryService.releaseStock( - order.productId, - order.merchantId as string, - order.quantity, - orderId, - ); - } else if (order.items) { - // Cart Checkout (Multi-item) - const items = order.items as any[]; - const releasePayload = items - .filter((item) => item.productId && item.quantity) - .map((item) => ({ - productId: item.productId, - quantity: Number(item.quantity), - })); - - if (releasePayload.length > 0) { - await this.inventoryService.releaseStockBatch( - releasePayload, - order.merchantId as string, - orderId, - ); - } - } - - // Notify both parties - await this.notifications.triggerOrderCancelled( - order.buyerId, - order.merchantId, - orderId, - ); - - return { message: "Order cancelled" }; - } - - // ────────────────────────────────────────────── - // DISPUTE (buyer only, DISPATCHED only) - // ────────────────────────────────────────────── - - async reportIssue(buyerId: string, orderId: string, reason: string) { - const order = await this.prisma.order.findUnique({ - where: { id: orderId }, - }); - if (!order) throw new NotFoundException("Order not found"); - if (order.buyerId !== buyerId) - throw new ForbiddenException("Access denied"); - - // Issues can be raised for PAID or DISPATCHED orders - if ( - order.status !== OrderStatus.DISPATCHED && - order.status !== OrderStatus.PAID - ) { - throw new BadRequestException( - "Issues can only be raised for PAID or DISPATCHED orders", - ); - } - - const updatedOrder = await this.prisma.order.update({ - where: { id: orderId }, - data: { - status: OrderStatus.DISPUTE, - disputeStatus: "PENDING", - disputeReason: reason, - }, - }); - - await this.prisma.orderEvent.create({ - data: { - orderId, - fromStatus: order.status as OrderStatus, - toStatus: OrderStatus.DISPUTE, - triggeredBy: buyerId, - metadata: { action: "issue_reported", reason }, - }, - }); - - // Notify merchant and admin - try { - await this.notifications.triggerOrderDisputed( - order.merchantId, - orderId, - reason, - ); - } catch (error) { - this.logger.error( - `Failed to send dispute notification: ${error instanceof Error ? error.message : error}`, - ); - } - - return updatedOrder; - } - - // ────────────────────────────────────────────── - // ORDER RECEIPT AGGREGATION - // ────────────────────────────────────────────── - - async getReceipt(orderId: string, userId: string) { - const order = await this.prisma.order.findUnique({ - where: { id: orderId }, - include: { - merchantProfile: { - select: { - businessName: true, - businessAddress: true, - user: { select: { phone: true, email: true } }, - }, - }, - user: { select: { email: true, phone: true } }, - payments: { - where: { status: "SUCCESS" }, - orderBy: { createdAt: "desc" }, - take: 1, - }, - }, - }); - - if (!order) { - throw new NotFoundException("Order not found"); - } - - if (order.buyerId !== userId) { - throw new ForbiddenException("Only the buyer can access their receipt"); - } - - return order; - } - - // ────────────────────────────────────────────── - // HELPERS - // ────────────────────────────────────────────── - - async getMerchantSummary(merchantId: string) { - const orders = await this.prisma.order.findMany({ - where: { merchantId }, - select: { - totalAmountKobo: true, - deliveryFeeKobo: true, - status: true, - }, - }); - - const summary = { - escrow: 0, - paidOut: 0, - pending: 0, - failed: 0, - orderCount: orders.length, - }; - - orders.forEach((o) => { - const amount = Number( - (o.totalAmountKobo || 0n) + (o.deliveryFeeKobo || 0n), - ); - switch (o.status) { - case OrderStatus.PAID: - case OrderStatus.DISPATCHED: - summary.escrow += amount; - break; - case OrderStatus.DELIVERED: - case OrderStatus.COMPLETED: - summary.paidOut += amount; - break; - case OrderStatus.PENDING_PAYMENT: - summary.pending += amount; - break; - case OrderStatus.CANCELLED: - case OrderStatus.DISPUTE: - summary.failed += amount; - break; - } - }); - - return summary; - } - - private async getUserIdFromMerchant(merchantId: string): Promise { - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - }); - if (!merchant) throw new NotFoundException("Merchant not found"); - return merchant.userId; - } - - private generateDeterministicKey( - prefix: string, - buyerId: string, - payload: Record, - ): string { - // Canonicalize: sort arrays and use stable key ordering - const canonical = this.canonicalize({ buyerId, ...payload }); - const hash = crypto.createHash("sha256").update(canonical).digest("hex"); - return `${prefix}-${hash.substring(0, 16)}`; - } - - /** - * Stable JSON serialization: sort object keys recursively and sort arrays - * so that the same logical payload always produces the same hash. - */ - private canonicalize(obj: any): string { - return JSON.stringify(obj, (_, value) => { - if (Array.isArray(value)) { - // Sort primitive arrays for stable ordering - return [...value].sort(); - } - if ( - value !== null && - typeof value === "object" && - !Array.isArray(value) - ) { - // Sort object keys - return Object.keys(value) - .sort() - .reduce( - (sorted, key) => { - sorted[key] = value[key]; - return sorted; - }, - {} as Record, - ); - } - return value; - }); - } -} diff --git a/apps/backend/src/modules/payment/dto/initialize-payment.dto.ts b/apps/backend/src/modules/payment/dto/initialize-payment.dto.ts deleted file mode 100644 index b3fa31b9..00000000 --- a/apps/backend/src/modules/payment/dto/initialize-payment.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { IsUUID } from "class-validator"; - -export class InitializePaymentDto { - @IsUUID() - orderId: string; -} diff --git a/apps/backend/src/modules/payment/dto/request-payout.dto.ts b/apps/backend/src/modules/payment/dto/request-payout.dto.ts deleted file mode 100644 index ed45c4e0..00000000 --- a/apps/backend/src/modules/payment/dto/request-payout.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsNumber, IsPositive } from "class-validator"; - -export class RequestPayoutDto { - @IsNumber() - @IsPositive() - amount: number; -} diff --git a/apps/backend/src/modules/payment/payment.controller.ts b/apps/backend/src/modules/payment/payment.controller.ts deleted file mode 100644 index c7978947..00000000 --- a/apps/backend/src/modules/payment/payment.controller.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - Controller, - Post, - Body, - UseGuards, - Get, - Query, - Param, -} from "@nestjs/common"; -import { PaymentService } from "./payment.service"; -import { InitializePaymentDto } from "./dto/initialize-payment.dto"; -import { RequestPayoutDto } from "./dto/request-payout.dto"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { RolesGuard } from "../../common/guards/roles.guard"; -import { WebhookSignatureGuard } from "./webhook-signature.guard"; -import { CurrentUser } from "../../common/decorators/current-user.decorator"; -import { IdempotencyKey } from "../../common/decorators/idempotency-key.decorator"; -import { Roles } from "../../common/decorators/roles.decorator"; -import { UserRole, JwtPayload } from "@swifta/shared"; - -@Controller("payments") -export class PaymentController { - constructor(private readonly paymentService: PaymentService) {} - - @Post("initialize") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.BUYER) - initialize( - @CurrentUser() user: JwtPayload, - @Body() dto: InitializePaymentDto, - @IdempotencyKey() idempotencyKey: string, - ) { - return this.paymentService.initialize(user.sub, dto, idempotencyKey); - } - - @Post("webhook") - @UseGuards(WebhookSignatureGuard) - webhook(@Body() payload: any) { - return this.paymentService.handleWebhook(payload); - } - - @Get("resolve-account") - @UseGuards(JwtAuthGuard) - resolveAccount( - @Query("accountNumber") accountNumber: string, - @Query("bankCode") bankCode: string, - ) { - return this.paymentService.resolveAccount(accountNumber, bankCode); - } - - @Get("banks") - @UseGuards(JwtAuthGuard) - getBanks() { - return this.paymentService.getBanks(); - } - - @Get("verify/:reference") - @UseGuards(JwtAuthGuard) - verifyPayment(@Param("reference") reference: string) { - return this.paymentService.verifyPayment(reference); - } - - @Post("request-payout") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.MERCHANT) - requestPayout( - @CurrentUser() user: JwtPayload, - @Body() dto: RequestPayoutDto, - ) { - return this.paymentService.requestPayout(user.merchantId, dto); - } -} diff --git a/apps/backend/src/modules/payment/payment.module.ts b/apps/backend/src/modules/payment/payment.module.ts deleted file mode 100644 index 13d963f0..00000000 --- a/apps/backend/src/modules/payment/payment.module.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Module, forwardRef } from "@nestjs/common"; -import { BullModule } from "@nestjs/bullmq"; -import { PaymentService } from "./payment.service"; -import { PaymentController } from "./payment.controller"; -import { PaystackClient } from "./paystack.client"; -import { PrismaModule } from "../../prisma/prisma.module"; -import { OrderModule } from "../order/order.module"; -import { NotificationModule } from "../notification/notification.module"; -import { DvaModule } from "../dva/dva.module"; -import { ConfigModule } from "@nestjs/config"; -import { PAYOUT_QUEUE, LOGISTICS_QUEUE } from "../../queue/queue.constants"; - -@Module({ - imports: [ - PrismaModule, - forwardRef(() => OrderModule), - forwardRef(() => DvaModule), - forwardRef(() => NotificationModule), - ConfigModule, - BullModule.registerQueue({ name: PAYOUT_QUEUE }), - BullModule.registerQueue({ name: LOGISTICS_QUEUE }), - ], - controllers: [PaymentController], - providers: [PaymentService, PaystackClient], - exports: [PaymentService, PaystackClient], -}) -export class PaymentModule {} diff --git a/apps/backend/src/modules/payment/payment.service.ts b/apps/backend/src/modules/payment/payment.service.ts deleted file mode 100644 index 16579f84..00000000 --- a/apps/backend/src/modules/payment/payment.service.ts +++ /dev/null @@ -1,681 +0,0 @@ -import { - Injectable, - Logger, - NotFoundException, - BadRequestException, - ForbiddenException, - Inject, - forwardRef, -} from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { InjectQueue } from "@nestjs/bullmq"; -import { Queue } from "bullmq"; -import { PrismaService } from "../../prisma/prisma.service"; -import { PaystackClient } from "./paystack.client"; -import { OrderService } from "../order/order.service"; -import { DvaService } from "../dva/dva.service"; -import { NotificationTriggerService } from "../notification/notification-trigger.service"; -import { InitializePaymentDto } from "./dto/initialize-payment.dto"; -import { RequestPayoutDto } from "./dto/request-payout.dto"; -import { - PaymentStatus, - PaymentDirection, - OrderStatus, - UserRole, -} from "@swifta/shared"; -import * as crypto from "crypto"; -import { PAYOUT_QUEUE, LOGISTICS_QUEUE } from "../../queue/queue.constants"; - -@Injectable() -export class PaymentService { - private readonly logger = new Logger(PaymentService.name); - - constructor( - private prisma: PrismaService, - private paystack: PaystackClient, - @Inject(forwardRef(() => OrderService)) - private orderService: OrderService, - private notifications: NotificationTriggerService, - private config: ConfigService, - @InjectQueue(PAYOUT_QUEUE) private payoutQueue: Queue, - @InjectQueue(LOGISTICS_QUEUE) private logisticsQueue: Queue, - @Inject(forwardRef(() => DvaService)) - private dvaService: DvaService, - ) {} - - // ────────────────────────────────────────────── - // INITIALIZE PAYMENT (idempotent by orderId) - // ────────────────────────────────────────────── - - async initialize( - buyerId: string, - dto: InitializePaymentDto, - idempotencyKey: string, - ) { - const order = await this.prisma.order.findUnique({ - where: { id: dto.orderId }, - }); - if (!order) throw new NotFoundException("Order not found"); - - // Ownership check: only the buyer can pay - if (order.buyerId !== buyerId) { - throw new ForbiddenException("Access denied"); - } - - if (order.status !== OrderStatus.PENDING_PAYMENT) { - throw new BadRequestException("Order is not in pending payment state"); - } - - // Generate reference and get buyer email - const buyer = await this.prisma.user.findUnique({ - where: { id: buyerId }, - }); - if (!buyer) throw new NotFoundException("Buyer not found"); - - // Callback URL from config (not hardcoded) - const frontendUrl = this.config.get( - "app.frontendUrl", - "http://localhost:3000", - ); - const callbackUrl = `${frontendUrl}/buyer/orders/payment/callback`; - - // Total amount in kobo (subtotal + platform fee + shipping) - // IMPORTANT: order.totalAmountKobo already includes the delivery fee in the current logic. - // Adding it again here would double-charge the shipping cost. - const totalKobo = order.totalAmountKobo; - - // Idempotency: if payment already exists for this order, return existing - const existingPayment = await this.prisma.payment.findFirst({ - where: { - orderId: dto.orderId, - direction: PaymentDirection.INFLOW, - status: PaymentStatus.INITIALIZED, - }, - }); - - if (existingPayment) { - this.logger.log( - `Updating existing payment ${existingPayment.id} for order ${dto.orderId} with new reference`, - ); - - const newReference = `tx-${crypto.randomUUID()}`; - - // We must fetch a fresh access_code from Paystack using a NEW reference - // because access_codes expire and Paystack rejects duplicate references. - const freshPaystackResponse = await this.paystack.initializeTransaction( - buyer.email, - totalKobo, - newReference, - callbackUrl, - ); - - // Update the database to reflect the new reference for the retry - await this.prisma.payment.update({ - where: { id: existingPayment.id }, - data: { paystackReference: newReference }, - }); - - return { - ...freshPaystackResponse, - reference: newReference, - paymentId: existingPayment.id, - message: - "Payment already initialized — returning fresh access code with new reference", - }; - } - - const paymentReference = `tx-${crypto.randomUUID()}`; - - // Initialize with Paystack - const paystackResponse = await this.paystack.initializeTransaction( - buyer.email, - totalKobo, - paymentReference, - callbackUrl, - ); - - // Create Payment record + PaymentEvent - const payment = await this.prisma.$transaction(async (tx) => { - const newPayment = await tx.payment.create({ - data: { - orderId: order.id, - paystackReference: paymentReference, - amountKobo: totalKobo, - currency: order.currency, - status: PaymentStatus.INITIALIZED, - direction: PaymentDirection.INFLOW, - idempotencyKey: idempotencyKey || paymentReference, - }, - }); - - await tx.paymentEvent.create({ - data: { - paymentId: newPayment.id, - eventType: "INITIALIZED", - payload: { - reference: paymentReference, - amountKobo: Number(totalKobo), - orderId: order.id, - }, - }, - }); - - return newPayment; - }); - - this.logger.log(`Payment ${payment.id} initialized for order ${order.id}`); - - return { - ...paystackResponse, - paymentReference, - paymentId: payment.id, - }; - } - - // ────────────────────────────────────────────── - // WEBHOOK HANDLER (idempotent processing) - // ────────────────────────────────────────────── - - async handleWebhook(payload: any) { - const event = payload.event; - this.logger.log(`Webhook received: ${event}`); - - // ── CHARGE SUCCESS (buyer payment) ── - if (event === "charge.success") { - return this.handleChargeSuccess(payload); - } - - // ── DVA EVENTS ── - if (event === "dedicatedaccount.assign.success") { - await this.dvaService.handleDvaAssignSuccess(payload.data); - return { status: "received" }; - } - if (event === "dedicatedaccount.assign.failed") { - await this.dvaService.handleDvaAssignFailed(payload.data); - return { status: "received" }; - } - - // ── TRANSFER EVENTS (merchant payout) ── - if ( - event === "transfer.success" || - event === "transfer.failed" || - event === "transfer.reversed" - ) { - return this.handleTransferEvent(payload); - } - - this.logger.log(`Ignoring webhook event: ${event}`); - return { status: "ignored" }; - } - - /** - * Handle charge.success — buyer payment confirmed by Paystack. - */ - private async handleChargeSuccess(payload: any) { - const reference = payload.data?.reference; - if (!reference) { - this.logger.warn("Webhook missing reference"); - return { status: "missing_reference" }; - } - - const payment = await this.prisma.payment.findUnique({ - where: { paystackReference: reference }, - }); - - if (!payment) { - this.logger.warn(`No payment found for reference: ${reference}`); - return { status: "unknown_reference" }; - } - - // Idempotency: skip if already processed - if (payment.status === PaymentStatus.SUCCESS) { - this.logger.log(`Payment ${payment.id} already processed, skipping`); - return { status: "already_processed" }; - } - - try { - await this.processSuccessfulPayment(payment.id, reference); - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - this.logger.error( - `Webhook processing failed for ${reference}: ${message}`, - ); - } - - return { status: "received" }; - } - - /** - * Handle transfer.success / transfer.failed / transfer.reversed - * These fire after the platform initiates a payout to a merchant's bank. - */ - private async handleTransferEvent(payload: any) { - const event = payload.event as string; - const transferCode = payload.data?.transfer_code; - - if (!transferCode) { - this.logger.warn(`Transfer webhook missing transfer_code`); - return { status: "missing_identifiers" }; - } - - // Find the payout record by transfer code - const payout = await this.prisma.payout.findFirst({ - where: { - paystackTransferCode: transferCode, - }, - include: { - order: { - select: { id: true }, - }, - merchant: { - select: { id: true, bankCode: true, bankAccountNumber: true }, - }, - }, - }); - - if (!payout) { - this.logger.warn(`No payout found for transfer_code=${transferCode}`); - return { status: "unknown_transfer" }; - } - - if (event === "transfer.success") { - await this.prisma.payout.update({ - where: { id: payout.id }, - data: { - status: "COMPLETED", - completedAt: new Date(), - }, - }); - - await this.prisma.order.update({ - where: { id: payout.orderId }, - data: { payoutStatus: "COMPLETED" }, - }); - - await this.notifications.triggerPayoutCompleted(payout.merchantId, { - amountKobo: payout.amountKobo.toString(), - orderRef: payout.orderId.slice(0, 8).toUpperCase(), - bankName: "Bank Account", // Can be dynamically expanded - }); - } else { - // transfer.failed or transfer.reversed - await this.prisma.payout.update({ - where: { id: payout.id }, - data: { - status: "FAILED", - failureReason: payload.data?.reason || event, - }, - }); - - await this.prisma.order.update({ - where: { id: payout.orderId }, - data: { payoutStatus: "FAILED" }, - }); - - await this.notifications.triggerPayoutFailed(payout.merchantId, { - orderRef: payout.orderId.slice(0, 8).toUpperCase(), - }); - } - - this.logger.log( - `Transfer ${event} processed for payout ${payout.id} (order: ${payout.orderId})`, - ); - - return { status: "received" }; - } - - // ────────────────────────────────────────────── - // MANUAL VERIFICATION (Fallback for no webhook) - // ────────────────────────────────────────────── - - async verifyPayment(reference: string) { - const payment = await this.prisma.payment.findUnique({ - where: { paystackReference: reference }, - }); - if (!payment) throw new NotFoundException("Payment not found"); - - if (payment.status === PaymentStatus.SUCCESS) { - return { status: "already_verified", paymentId: payment.id }; - } - - try { - await this.processSuccessfulPayment(payment.id, reference); - return { status: "verified", paymentId: payment.id }; - } catch (err) { - this.logger.error(`Manual verification failed for ${reference}`, err); - throw new BadRequestException("Verification failed"); - } - } - - // ────────────────────────────────────────────── - // PROCESS SUCCESSFUL PAYMENT (verify + transition) - // ────────────────────────────────────────────── - - private async processSuccessfulPayment(paymentId: string, reference: string) { - // Verify with Paystack API - const verification = await this.paystack.verifyTransaction(reference); - - const payment = await this.prisma.payment.findUnique({ - where: { id: paymentId }, - include: { order: true }, - }); - - if (!payment) throw new NotFoundException("Payment not found"); - - if (verification.status === "success") { - // Amount verification: Paystack returns amount in kobo - const paystackAmountKobo = BigInt(verification.amount); - if (paystackAmountKobo !== payment.amountKobo) { - this.logger.error( - `Amount mismatch! Paystack: ${paystackAmountKobo}, Expected: ${payment.amountKobo}`, - ); - - await this.prisma.paymentEvent.create({ - data: { - paymentId: payment.id, - eventType: "AMOUNT_MISMATCH", - payload: { - paystackAmount: Number(paystackAmountKobo), - expectedAmount: Number(payment.amountKobo), - reference, - }, - }, - }); - - throw new BadRequestException("Payment amount mismatch"); - } - - // Update payment status + log event - await this.prisma.$transaction(async (tx) => { - const updateResult = await tx.payment.updateMany({ - where: { id: payment.id, status: PaymentStatus.INITIALIZED }, - data: { - status: PaymentStatus.SUCCESS, - verifiedAt: new Date(), - }, - }); - - if (updateResult.count === 0) { - throw new Error("Payment already processed by another thread"); - } - - await tx.paymentEvent.create({ - data: { - paymentId: payment.id, - eventType: "SUCCESS", - payload: { - reference, - amountKobo: Number(paystackAmountKobo), - gatewayResponse: verification.gateway_response, - verifiedAt: new Date().toISOString(), - }, - }, - }); - }); - - // Transition order via state machine (PENDING_PAYMENT → PAID) - if (payment.order.status === OrderStatus.PENDING_PAYMENT) { - await this.orderService.transitionBySystem( - payment.orderId, - OrderStatus.PENDING_PAYMENT, - OrderStatus.PAID, - { paymentId: payment.id, reference }, - ); - - // Clear cart items for the products in this order - const orderItems = payment.order.items as any[]; - if (Array.isArray(orderItems) && orderItems.length > 0) { - const productIds = orderItems - .map((item: any) => item.productId) - .filter(Boolean); - if (productIds.length > 0) { - const deleted = await this.prisma.cartItem.deleteMany({ - where: { - buyerId: payment.order.buyerId, - productId: { in: productIds }, - }, - }); - this.logger.log( - `Cleared ${deleted.count} cart items for order ${payment.orderId}`, - ); - } - } - } - - const orderData = await this.prisma.order.findUnique({ - where: { id: payment.orderId }, - include: { product: true, user: true }, - }); - - if ( - orderData && - orderData.productId !== null && - orderData.quantity !== null - ) { - // DIRECT PURCHASE LOGIC - - // Notifications - await this.notifications.triggerDirectPurchaseConfirmed( - payment.order.buyerId, - payment.order.merchantId, - { - orderId: payment.orderId, - reference: reference, - productName: orderData.product?.name || "Product", - buyerName: - `${orderData.user?.firstName || "Buyer"} ${orderData.user?.lastName || ""}`.trim(), - quantity: orderData.quantity, - amountKobo: payment.order.totalAmountKobo, - deliveryAddress: orderData.deliveryAddress || undefined, - }, - ); - - // DIRECT PAYMENT: Queue immediate payout (don't wait for OTP delivery confirmation) - if (orderData.paymentMethod === "DIRECT") { - try { - this.logger.log( - `DIRECT payment — queueing immediate payout for order ${orderData.id}`, - ); - await this.payoutQueue.add( - "process-payout", - { orderId: orderData.id }, - { jobId: `payout-${orderData.id}` }, - ); - } catch (payoutErr) { - this.logger.error( - `Failed to queue immediate payout for DIRECT order ${orderData.id}: ${ - payoutErr instanceof Error ? payoutErr.message : "Unknown" - }`, - ); - } - } - } else { - // CART ORDER PAYMENT CONFIRMATION LOGIC - await this.notifications.triggerPaymentConfirmed( - payment.order.buyerId, - payment.order.merchantId, - { - orderId: payment.orderId, - reference: reference, // Paystack reference - amountKobo: payment.order.totalAmountKobo, - }, - ); - } - - // DISPATCH LOGISTICS IF PLATFORM LOGISTICS IS SELECTED - if (payment.order.deliveryMethod === "PLATFORM_LOGISTICS") { - this.logger.log( - `Order ${payment.orderId} uses platform logistics. Auto-queueing booking.`, - ); - try { - await this.logisticsQueue.add( - "book-pickup", - { orderId: payment.orderId }, - { jobId: `book-logistics-${payment.orderId}` }, - ); - } catch (queueErr) { - this.logger.error( - `Failed to queue logistics booking for order ${payment.orderId}`, - queueErr, - ); - } - } - - this.logger.log( - `Payment ${payment.id} SUCCESS for order ${payment.orderId}`, - ); - } else { - // Payment failed - this.logger.error( - `Paystack verification failed for ${reference}: ${verification.status}`, - ); - await this.prisma.$transaction(async (tx) => { - await tx.payment.update({ - where: { id: payment.id }, - data: { status: PaymentStatus.FAILED }, - }); - - await tx.paymentEvent.create({ - data: { - paymentId: payment.id, - eventType: "FAILED", - payload: { - reference, - verificationStatus: verification.status, - gatewayResponse: verification.gateway_response, - }, - }, - }); - }); - - this.logger.warn( - `Payment ${payment.id} FAILED for order ${payment.orderId}`, - ); - } - } - - // ────────────────────────────────────────────── - // RESOLVE ACCOUNT (Proxy to Paystack) - // ────────────────────────────────────────────── - - async resolveAccount(accountNumber: string, bankCode: string) { - try { - return await this.paystack.resolveAccount(accountNumber, bankCode); - } catch (err) { - if (err instanceof Error) { - throw new BadRequestException(err.message); - } - throw new BadRequestException("Could not resolve account"); - } - } - - async getBanks() { - try { - return await this.paystack.getBanks(); - } catch (err) { - this.logger.error("Failed to fetch banks", err); - throw new BadRequestException("Could not fetch banks"); - } - } - - // ────────────────────────────────────────────── - // REQUEST PAYOUT (Manual request by Merchant) - // ────────────────────────────────────────────── - - async requestPayout(merchantId: string, dto: RequestPayoutDto) { - this.logger.log( - `Payout requested by merchant ${merchantId} for amount ${dto.amount}`, - ); - - const merchantProfile = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - }); - - if (!merchantProfile) { - throw new NotFoundException("Merchant profile not found"); - } - - // 4. Validate merchant has bank details - if (!merchantProfile.bankAccountNumber || !merchantProfile.bankCode) { - throw new BadRequestException( - "Please set your bank details in settings before requesting a payout.", - ); - } - - // 5. Validate available balance - const orders = await this.prisma.order.aggregate({ - where: { merchantId, status: "COMPLETED" }, - _sum: { totalAmountKobo: true, platformFeeKobo: true }, - }); - - const payouts = await this.prisma.payout.aggregate({ - where: { merchantId, status: { in: ["COMPLETED", "PROCESSING"] } }, - _sum: { amountKobo: true }, - }); - - const pendingRequests = await this.prisma.payoutRequest.aggregate({ - where: { merchantId, status: { in: ["PENDING", "PROCESSING"] } }, - _sum: { amountKobo: true }, - }); - - const grossOrders = BigInt(orders._sum.totalAmountKobo || 0); - const platformFees = BigInt(orders._sum.platformFeeKobo || 0); - const existingPayouts = - BigInt(payouts._sum.amountKobo || 0) + - BigInt(pendingRequests._sum.amountKobo || 0); - - const availableBalance = grossOrders - platformFees - existingPayouts; - - if (BigInt(dto.amount) > availableBalance) { - throw new BadRequestException( - `Requested amount exceeds available balance.`, - ); - } - - // Create the payout request in the database - const payoutRequest = await this.prisma.payoutRequest.create({ - data: { - merchantId, - amountKobo: dto.amount, - status: "PENDING", - bankName: merchantProfile.bankCode, // Note: This might need resolving to real bank name - accountNumber: merchantProfile.bankAccountNumber, - accountName: merchantProfile.settlementAccountName, - }, - }); - - // Notify Admins - const admins = await this.prisma.user.findMany({ - where: { - role: { in: [UserRole.SUPER_ADMIN, UserRole.OPERATOR] }, - }, - select: { id: true }, - }); - - const adminIds = admins.map((a) => a.id); - if (adminIds.length > 0) { - await this.notifications.triggerPayoutRequested(adminIds, { - merchantId, - merchantName: merchantProfile.businessName || "Unknown Merchant", - amountKobo: dto.amount.toString(), - requestId: payoutRequest.id, - }); - } - - // Notify Merchant - await this.notifications.triggerMerchantPayoutRequestedConfirmation( - merchantProfile.userId, - { - amountKobo: dto.amount.toString(), - requestId: payoutRequest.id, - }, - ); - - return { - message: "Payout request received and queued for processing", - amountRequested: dto.amount, - status: "QUEUED", - requestId: payoutRequest.id, - }; - } -} diff --git a/apps/backend/src/modules/payment/paystack.client.ts b/apps/backend/src/modules/payment/paystack.client.ts deleted file mode 100644 index 6c200689..00000000 --- a/apps/backend/src/modules/payment/paystack.client.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; - -interface PaystackInitResponse { - authorization_url: string; - access_code: string; - reference: string; -} - -interface PaystackVerifyResponse { - status: string; - amount: number; - reference: string; - currency: string; - gateway_response: string; -} - -interface PaystackTransferResponse { - status: string; - transfer_code: string; - reference: string; -} - -interface PaystackRecipientResponse { - recipient_code: string; -} - -export interface PaystackResolveResponse { - account_number: string; - account_name: string; - bank_id: number; -} - -export interface PaystackBank { - name: string; - code: string; - active: boolean; - type: string; -} - -@Injectable() -export class PaystackClient { - private readonly logger = new Logger(PaystackClient.name); - private readonly baseUrl: string; - private readonly secretKey: string; - - constructor(private config: ConfigService) { - this.baseUrl = this.config.get( - "paystack.baseUrl", - "https://api.paystack.co", - ); - this.secretKey = this.config.get("paystack.secretKey", ""); - } - - private get headers() { - return { - Authorization: `Bearer ${this.secretKey}`, - "Content-Type": "application/json", - }; - } - - /** - * Create a Paystack customer (required before DVA) - */ - async createCustomer(params: { - email: string; - firstName: string; - lastName: string; - phone: string; - }): Promise { - const response = await fetch(`${this.baseUrl}/customer`, { - method: "POST", - headers: this.headers, - body: JSON.stringify({ - email: params.email, - first_name: params.firstName, - last_name: params.lastName, - phone: params.phone, - }), - }); - return response.json(); - } - - /** - * Create a Dedicated Virtual Account for a customer - * Auto-detects test mode and uses 'test-bank' accordingly - */ - async createDedicatedVirtualAccount(customerCode: string): Promise { - const isTestMode = this.secretKey.startsWith("sk_test_"); - const response = await fetch(`${this.baseUrl}/dedicated_account`, { - method: "POST", - headers: this.headers, - body: JSON.stringify({ - customer: customerCode, - preferred_bank: isTestMode ? "test-bank" : "wema-bank", - }), - }); - return response.json(); - } - - // ────────────────────────────────────────────── - // INITIALIZE TRANSACTION - // ────────────────────────────────────────────── - - async initializeTransaction( - email: string, - amountKobo: bigint, - reference: string, - callbackUrl?: string, - ): Promise { - const response = await fetch(`${this.baseUrl}/transaction/initialize`, { - method: "POST", - headers: this.headers, - body: JSON.stringify({ - email, - amount: Number(amountKobo), - reference, - callback_url: callbackUrl, - }), - }); - - const json = await response.json(); - - if (!json.status) { - this.logger.error(`Paystack init failed: ${json.message}`, json); - throw new Error(`Paystack initialization failed: ${json.message}`); - } - - this.logger.log(`Payment initialized: ${reference}`); - return json.data as PaystackInitResponse; - } - - // ────────────────────────────────────────────── - // VERIFY TRANSACTION - // ────────────────────────────────────────────── - - async verifyTransaction(reference: string): Promise { - const response = await fetch( - `${this.baseUrl}/transaction/verify/${encodeURIComponent(reference)}`, - { - method: "GET", - headers: this.headers, - }, - ); - - const json = await response.json(); - - if (!json.status) { - this.logger.error(`Paystack verify failed: ${json.message}`, json); - throw new Error(`Paystack verification failed: ${json.message}`); - } - - return json.data as PaystackVerifyResponse; - } - - // ────────────────────────────────────────────── - // CREATE TRANSFER RECIPIENT - // ────────────────────────────────────────────── - - async createTransferRecipient( - bankCode: string, - accountNumber: string, - accountName: string, - ): Promise { - const response = await fetch(`${this.baseUrl}/transferrecipient`, { - method: "POST", - headers: this.headers, - body: JSON.stringify({ - type: "nuban", - name: accountName, - account_number: accountNumber, - bank_code: bankCode, - currency: "NGN", - }), - }); - - const json = await response.json(); - - if (!json.status) { - this.logger.error(`Paystack recipient failed: ${json.message}`, json); - throw new Error(`Paystack create recipient failed: ${json.message}`); - } - - return json.data as PaystackRecipientResponse; - } - - // ────────────────────────────────────────────── - // CREATE TRANSFER (PAYOUT) - // ────────────────────────────────────────────── - - async createTransfer( - recipientCode: string, - amountKobo: bigint, - reference: string, - reason: string, - ): Promise { - const response = await fetch(`${this.baseUrl}/transfer`, { - method: "POST", - headers: this.headers, - body: JSON.stringify({ - source: "balance", - amount: Number(amountKobo), - recipient: recipientCode, - reference, - reason, - }), - }); - - const json = await response.json(); - - if (!json.status) { - this.logger.error(`Paystack transfer failed: ${json.message}`, json); - throw new Error(`Paystack transfer failed: ${json.message}`); - } - - this.logger.log(`Payout initiated: ${reference}`); - return json.data as PaystackTransferResponse; - } - - // ────────────────────────────────────────────── - // RESOLVE ACCOUNT NUMBER - // ────────────────────────────────────────────── - - async resolveAccount( - accountNumber: string, - bankCode: string, - ): Promise { - const response = await fetch( - `${this.baseUrl}/bank/resolve?account_number=${accountNumber}&bank_code=${bankCode}`, - { - method: "GET", - headers: this.headers, - }, - ); - - const json = await response.json(); - - if (!json.status) { - this.logger.error( - `Paystack account resolution failed: ${json.message}`, - json, - ); - throw new Error(`Account resolution failed: ${json.message}`); - } - - return json.data as PaystackResolveResponse; - } - - // ────────────────────────────────────────────── - // GET BANKS - // ────────────────────────────────────────────── - - async getBanks(): Promise { - const response = await fetch(`${this.baseUrl}/bank?currency=NGN`, { - method: "GET", - headers: this.headers, - }); - - const json = await response.json(); - - if (!json.status) { - this.logger.error(`Paystack get banks failed: ${json.message}`, json); - throw new Error(`Failed to fetch banks: ${json.message}`); - } - - return json.data as PaystackBank[]; - } -} diff --git a/apps/backend/src/modules/payment/webhook-signature.guard.ts b/apps/backend/src/modules/payment/webhook-signature.guard.ts deleted file mode 100644 index db6ba7d7..00000000 --- a/apps/backend/src/modules/payment/webhook-signature.guard.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - Injectable, - CanActivate, - ExecutionContext, - Logger, -} from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import * as crypto from "crypto"; - -@Injectable() -export class WebhookSignatureGuard implements CanActivate { - private readonly logger = new Logger(WebhookSignatureGuard.name); - - constructor(private config: ConfigService) {} - - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); - const signature = request.headers["x-paystack-signature"]; - const secret = this.config.get("paystack.webhookSecret"); - - if (!signature || !secret) { - this.logger.warn("Missing webhook signature or secret"); - return false; - } - - // Use raw body for HMAC to avoid JSON.stringify inconsistencies - const body = request.rawBody || Buffer.from(JSON.stringify(request.body)); - - const hash = crypto.createHmac("sha512", secret).update(body).digest("hex"); - - const hashBuffer = Buffer.from(hash); - const signatureBuffer = Buffer.from(signature); - - if (hashBuffer.length !== signatureBuffer.length) { - this.logger.warn("Invalid webhook signature (length mismatch)"); - return false; - } - - const isValid = crypto.timingSafeEqual(hashBuffer, signatureBuffer); - - if (!isValid) { - this.logger.warn("Invalid webhook signature"); - } - - return isValid; - } -} diff --git a/apps/backend/src/modules/payout/payout.module.ts b/apps/backend/src/modules/payout/payout.module.ts deleted file mode 100644 index d1d8fa0f..00000000 --- a/apps/backend/src/modules/payout/payout.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module, forwardRef } from "@nestjs/common"; -import { PayoutService } from "./payout.service"; -import { PayoutProcessor } from "./payout.processor"; -import { PaymentModule } from "../payment/payment.module"; -import { NotificationModule } from "../notification/notification.module"; - -@Module({ - imports: [forwardRef(() => PaymentModule), NotificationModule], - providers: [PayoutService, PayoutProcessor], - exports: [PayoutService], -}) -export class PayoutModule {} diff --git a/apps/backend/src/modules/payout/payout.processor.ts b/apps/backend/src/modules/payout/payout.processor.ts deleted file mode 100644 index 0d543393..00000000 --- a/apps/backend/src/modules/payout/payout.processor.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Processor, WorkerHost } from "@nestjs/bullmq"; -import { Job } from "bullmq"; -import { PAYOUT_QUEUE } from "../../queue/queue.constants"; -import { PayoutService } from "./payout.service"; -import { Logger } from "@nestjs/common"; - -@Processor(PAYOUT_QUEUE, { - drainDelay: 60000, - stalledInterval: 300000, - lockDuration: 60000, - metrics: null, -}) -export class PayoutProcessor extends WorkerHost { - private readonly logger = new Logger(PayoutProcessor.name); - - constructor(private readonly payoutService: PayoutService) { - super(); - } - - async process(job: Job): Promise { - if (job.name === "process-payout") { - const { orderId } = job.data; - this.logger.log(`Processing payout for order ${orderId}`); - try { - await this.payoutService.initiatePayout(orderId); - } catch (e) { - this.logger.error(`Payout job failed for order ${orderId}`, e); - throw e; // To trigger retry or dead letter - } - } - } -} diff --git a/apps/backend/src/modules/payout/payout.service.ts b/apps/backend/src/modules/payout/payout.service.ts deleted file mode 100644 index 52042247..00000000 --- a/apps/backend/src/modules/payout/payout.service.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { PrismaService } from "../../prisma/prisma.service"; -import { PaystackClient } from "../payment/paystack.client"; -import { ConfigService } from "@nestjs/config"; -import { NotificationTriggerService } from "../notification/notification-trigger.service"; - -@Injectable() -export class PayoutService { - private readonly logger = new Logger(PayoutService.name); - - constructor( - private readonly prisma: PrismaService, - private readonly paystack: PaystackClient, - private readonly config: ConfigService, - private readonly notifications: NotificationTriggerService, - ) {} - - async initiatePayout(orderId: string) { - const order = await this.prisma.order.findUnique({ - where: { id: orderId }, - include: { - merchantProfile: true, - product: true, - }, - }); - if (!order) { - this.logger.error(`Order not found for payout: ${orderId}`); - return; - } - - // Check if a payout is already completed or processing for this order - const existingPayout = await this.prisma.payout.findFirst({ - where: { - orderId, - status: { in: ["COMPLETED", "PROCESSING"] }, - }, - }); - - if (existingPayout) { - this.logger.warn( - `Payout already exists for order ${orderId} with status ${existingPayout.status}. Skipping.`, - ); - return; - } - - const merchant = order.merchantProfile; - - // Determine gross amount and platform fee - const grossAmountKobo = BigInt(order.totalAmountKobo); - - // Use saved platform fee directly from the order with legacy fallback - let platformFeeKoboCount = 0n; - if (order.platformFeeKobo === null || order.platformFeeKobo === undefined) { - this.logger.warn( - `Missing platformFeeKobo on order ${orderId}. Defaulting to 0 for legacy order payout.`, - ); - platformFeeKoboCount = 0n; - } else { - platformFeeKoboCount = BigInt(order.platformFeeKobo); - } - - const payoutAmountKobo = grossAmountKobo - platformFeeKoboCount; - - if (!merchant.paystackRecipientCode) { - this.logger.error( - `Merchant ${merchant.id} has no registered bank account / recipient code.`, - ); - await this.prisma.payout.create({ - data: { - orderId, - merchantId: merchant.id, - amountKobo: payoutAmountKobo, - platformFeeKobo: platformFeeKoboCount, - status: "FAILED", - failureReason: "Merchant has no registered bank account", - }, - }); - return; - } - - const payout = await this.prisma.payout.create({ - data: { - orderId, - merchantId: merchant.id, - amountKobo: payoutAmountKobo, - platformFeeKobo: platformFeeKoboCount, - status: "PROCESSING", - initiatedAt: new Date(), - }, - }); - - try { - this.logger.log( - `Initiating Paystack transfer for order ${orderId} (Payout ID: ${payout.id})`, - ); - const transferResponse = await this.paystack.createTransfer( - merchant.paystackRecipientCode, - payoutAmountKobo, - `PO-${order.id.slice(0, 8).toUpperCase()}`, - `Swifta payout for Order #${order.id.slice(0, 8).toUpperCase()}`, - ); - - await this.prisma.payout.update({ - where: { id: payout.id }, - data: { paystackTransferCode: transferResponse.transfer_code }, - }); - - const productName = order.product?.name || "Items"; - const quantity = order.quantity || 1; - - await this.notifications.triggerPayoutInitiated(merchant.userId, { - orderId: order.id, - orderRef: order.id.slice(0, 8).toUpperCase(), - productName, - quantity, - payoutAmountKobo: payoutAmountKobo.toString(), - bankName: merchant.settlementAccountName || "your bank", - }); - } catch (error: any) { - this.logger.error( - `Paystack Transfer Failed for Order ${orderId}: ${error.message}`, - ); - await this.prisma.payout.update({ - where: { id: payout.id }, - data: { - status: "FAILED", - failureReason: error.message, - }, - }); - } - } -} diff --git a/apps/backend/src/modules/product/dto/catalogue-query.dto.ts b/apps/backend/src/modules/product/dto/catalogue-query.dto.ts deleted file mode 100644 index 840034e0..00000000 --- a/apps/backend/src/modules/product/dto/catalogue-query.dto.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { IsOptional, IsString, IsInt, Min, Max } from "class-validator"; -import { Type } from "class-transformer"; - -export class CatalogueQueryDto { - @IsOptional() - @IsString() - category?: string; - - @IsOptional() - @IsString() - search?: string; - - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - page?: number = 1; - - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - @Max(100) - limit?: number = 20; -} diff --git a/apps/backend/src/modules/product/dto/create-product.dto.ts b/apps/backend/src/modules/product/dto/create-product.dto.ts deleted file mode 100644 index 83f454d4..00000000 --- a/apps/backend/src/modules/product/dto/create-product.dto.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - IsString, - IsNotEmpty, - IsOptional, - IsInt, - IsNumber, - Min, - Max, -} from "class-validator"; - -export class CreateProductDto { - @IsString() - @IsNotEmpty() - name: string; - - @IsOptional() - @IsString() - description?: string; - - @IsOptional() - @IsString() - shortDescription?: string; - - @IsString() - @IsNotEmpty() - unit: string; - - @IsString() - @IsNotEmpty() - categoryTag: string; - - @IsString() - @IsNotEmpty() - categoryId: string; - - @IsOptional() - @IsString() - imageUrl?: string; - - @IsOptional() - @IsInt() - @Min(1) - minOrderQuantity?: number; - - @IsOptional() - @IsInt() - @Min(1) - minOrderQuantityConsumer?: number; - - @IsOptional() - @IsString() - warehouseLocation?: string; - - @IsOptional() - @IsString() - pricePerUnitKobo?: string; - - @IsOptional() - @IsString() - retailPriceKobo?: string; - - @IsOptional() - @IsNumber() - @Min(1) - @Max(99) - wholesaleDiscountPercent?: number; - - @IsOptional() - @IsNumber() - weightKg?: number; - - @IsOptional() - @IsInt() - processingDays?: number; - - @IsOptional() - @IsString() - productCode?: string; - - @IsOptional() - @IsInt() - @Min(0) - initialStock?: number; -} diff --git a/apps/backend/src/modules/product/dto/update-product.dto.ts b/apps/backend/src/modules/product/dto/update-product.dto.ts deleted file mode 100644 index ff20b9b3..00000000 --- a/apps/backend/src/modules/product/dto/update-product.dto.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - IsOptional, - IsString, - IsInt, - IsNumber, - IsBoolean, - Min, - Max, -} from "class-validator"; - -export class UpdateProductDto { - @IsOptional() - @IsString() - name?: string; - - @IsOptional() - @IsString() - description?: string; - - @IsOptional() - @IsString() - unit?: string; - - @IsOptional() - @IsString() - categoryTag?: string; - - @IsOptional() - @IsString() - categoryId?: string; - - @IsOptional() - @IsInt() - @Min(1) - minOrderQuantity?: number; - - @IsOptional() - @IsInt() - @Min(1) - minOrderQuantityConsumer?: number; - - @IsOptional() - @IsBoolean() - isActive?: boolean; - - @IsOptional() - @IsString() - warehouseLocation?: string; - - @IsOptional() - @IsString() - imageUrl?: string; - - @IsOptional() - @IsString() - pricePerUnitKobo?: string; - - @IsOptional() - @IsString() - retailPriceKobo?: string; - - @IsOptional() - @IsNumber() - @Min(1) - @Max(99) - wholesaleDiscountPercent?: number; - - @IsOptional() - @IsBoolean() - wholesaleEnabled?: boolean; - - @IsOptional() - @IsNumber() - weightKg?: number; - - @IsOptional() - @IsString() - shortDescription?: string; - - @IsOptional() - @IsString() - productCode?: string; - - @IsOptional() - @IsString() - wholesalePriceKobo?: string; - - @IsOptional() - @IsInt() - processingDays?: number; -} diff --git a/apps/backend/src/modules/product/product.controller.ts b/apps/backend/src/modules/product/product.controller.ts deleted file mode 100644 index 30754fae..00000000 --- a/apps/backend/src/modules/product/product.controller.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { - Controller, - Get, - Post, - Body, - Patch, - Param, - Delete, - UseGuards, - Query, - ParseUUIDPipe, - UseInterceptors, -} from "@nestjs/common"; -import { CacheInterceptor } from "@nestjs/cache-manager"; -import { ProductService } from "./product.service"; -import { CreateProductDto } from "./dto/create-product.dto"; -import { UpdateProductDto } from "./dto/update-product.dto"; -import { CatalogueQueryDto } from "./dto/catalogue-query.dto"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { RolesGuard } from "../../common/guards/roles.guard"; -import { Roles } from "../../common/decorators/roles.decorator"; -import { CurrentUser } from "../../common/decorators/current-user.decorator"; -import { CurrentMerchant } from "../../common/decorators/current-merchant.decorator"; -import { UserRole, JwtPayload } from "@swifta/shared"; - -@Controller("products") -export class ProductController { - constructor(private readonly productService: ProductService) {} - - @Post() - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.MERCHANT) - create(@CurrentMerchant() merchantId: string, @Body() dto: CreateProductDto) { - return this.productService.create(merchantId, dto); - } - - @Get() - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.MERCHANT) - findAllMyProducts( - @CurrentMerchant() merchantId: string, - @Query("page") page: number = 1, - @Query("limit") limit: number = 20, - ) { - return this.productService.listByMerchant(merchantId, +page, +limit); - } - - @Get("merchant/:merchantId") - @UseInterceptors(CacheInterceptor) - findAllByMerchant( - @Param("merchantId", ParseUUIDPipe) merchantId: string, - @Query("page") page: number = 1, - @Query("limit") limit: number = 20, - ) { - return this.productService.listPublicByMerchant(merchantId, +page, +limit); - } - - @Get("associations") - @UseInterceptors(CacheInterceptor) - getAssociations(@Query("category") category: string) { - return this.productService.getAssociations(category); - } - - @Get("social-feed") - @UseGuards(JwtAuthGuard) - async getSocialFeed( - @CurrentUser() user: JwtPayload, - @Query("page") page: number = 1, - @Query("limit") limit: number = 20, - ) { - return this.productService.getSocialFeed( - user.sub, - Number(page), - Number(limit), - user.role, - ); - } - - @Get("catalogue") - @UseInterceptors(CacheInterceptor) - findAllCatalogue(@Query() query: CatalogueQueryDto) { - return this.productService.catalogue( - query.search, - query.category, - query.page, - query.limit, - ); - } - - @Get(":id") - findOne(@Param("id", ParseUUIDPipe) id: string) { - return this.productService.getById(id); - } - - @Patch(":id") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.MERCHANT) - update( - @CurrentMerchant() merchantId: string, - @Param("id", ParseUUIDPipe) id: string, - @Body() dto: UpdateProductDto, - ) { - return this.productService.update(merchantId, id, dto); - } - - @Delete(":id") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.MERCHANT) - remove( - @CurrentMerchant() merchantId: string, - @Param("id", ParseUUIDPipe) id: string, - ) { - return this.productService.softDelete(merchantId, id); - } - - @Post(":id/restore") - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.MERCHANT) - restore( - @CurrentMerchant() merchantId: string, - @Param("id", ParseUUIDPipe) id: string, - ) { - return this.productService.restore(merchantId, id); - } -} diff --git a/apps/backend/src/modules/product/product.module.ts b/apps/backend/src/modules/product/product.module.ts deleted file mode 100644 index c632003d..00000000 --- a/apps/backend/src/modules/product/product.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from "@nestjs/common"; -import { ProductService } from "./product.service"; -import { ProductController } from "./product.controller"; - -@Module({ - imports: [], - controllers: [ProductController], - providers: [ProductService], - exports: [ProductService], -}) -export class ProductModule {} diff --git a/apps/backend/src/modules/product/product.service.ts b/apps/backend/src/modules/product/product.service.ts deleted file mode 100644 index a5ca9c8b..00000000 --- a/apps/backend/src/modules/product/product.service.ts +++ /dev/null @@ -1,519 +0,0 @@ -import { - Injectable, - NotFoundException, - ForbiddenException, - BadRequestException, - Inject, -} from "@nestjs/common"; -import { randomBytes } from "crypto"; -import { CACHE_MANAGER } from "@nestjs/cache-manager"; -import type { Cache } from "cache-manager"; -import { PrismaService } from "../../prisma/prisma.service"; -import { CreateProductDto } from "./dto/create-product.dto"; -import { UpdateProductDto } from "./dto/update-product.dto"; -import { PaginatedResponse, Product } from "@swifta/shared"; -import { paginate } from "../../common/utils/pagination"; -import { InventoryService } from "../inventory/inventory.service"; - -@Injectable() -export class ProductService { - constructor( - private prisma: PrismaService, - private inventoryService: InventoryService, - @Inject(CACHE_MANAGER) private cacheManager: Cache, - ) {} - - async create(merchantId: string, dto: CreateProductDto) { - const { - pricePerUnitKobo, - retailPriceKobo, - wholesaleDiscountPercent, - initialStock, - ...rest - } = dto; - - // 1. Fetch the merchant's slug - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - select: { slug: true }, - }); - - if (!merchant) { - throw new NotFoundException("Merchant profile not found"); - } - - // 2. Generate Product Code (Format: merchantSlug-productNameSlug-shortId) - const baseNameSlug = dto.name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .substring(0, 15); - const shortId = randomBytes(3).toString("hex"); // 6 character hash - const productCode = `${merchant.slug}-${baseNameSlug}-${shortId}`; - - // Auto-compute wholesale price from retail price and discount - let computedWholesaleKobo: bigint | null = null; - if ( - retailPriceKobo && - wholesaleDiscountPercent && - wholesaleDiscountPercent > 0 - ) { - const retailBigInt = BigInt(retailPriceKobo); - computedWholesaleKobo = BigInt( - Math.round(Number(retailBigInt) * (1 - wholesaleDiscountPercent / 100)), - ); - } - - const product = await this.prisma.product.create({ - data: { - merchantId, - productCode, // Inject the generated semantic ID - pricePerUnitKobo: pricePerUnitKobo ? BigInt(pricePerUnitKobo) : null, - retailPriceKobo: retailPriceKobo ? BigInt(retailPriceKobo) : null, - wholesalePriceKobo: computedWholesaleKobo, - wholesaleDiscountPercent: wholesaleDiscountPercent ?? null, - ...rest, - }, - }); - - if (initialStock && initialStock > 0) { - await this.inventoryService.manualAdjustment( - merchantId, - product.id, - initialStock, - "Initial stock configuration", - ); - } - - await this.invalidateCatalogueCache(); - return product; - } - - async listByMerchant( - merchantId: string, - page: number, - limit: number, - ): Promise> { - const response = await paginate( - this.prisma.product, - { page, limit }, - { - where: { merchantId, deletedAt: null }, - orderBy: { createdAt: "desc" }, - include: { productStockCache: true }, - }, - ); - - // Mark soft-deleted products with a flag (for frontend convenience if needed) - response.data = response.data.map((product: any) => { - const { productStockCache, ...rest } = product; - return { - ...rest, - stockCache: productStockCache, - isDeleted: product.deletedAt !== null, - pricePerUnitKobo: product.pricePerUnitKobo - ? product.pricePerUnitKobo.toString() - : null, - wholesalePriceKobo: product.wholesalePriceKobo - ? product.wholesalePriceKobo.toString() - : null, - retailPriceKobo: product.retailPriceKobo - ? product.retailPriceKobo.toString() - : null, - wholesaleDiscountPercent: product.wholesaleDiscountPercent ?? null, - }; - }); - - return response as unknown as PaginatedResponse; - } - - async listPublicByMerchant( - merchantId: string, - page: number, - limit: number, - ): Promise> { - const response = await paginate( - this.prisma.product, - { page, limit }, - { - where: { - merchantId, - isActive: true, - deletedAt: null, - }, - orderBy: { createdAt: "desc" }, - include: { productStockCache: true }, - }, - ); - - response.data = response.data.map((product: any) => - this.mapProductForPublic(product), - ); - - return response as unknown as PaginatedResponse; - } - - async catalogue( - search: string = "", - category: string = "", - page: number, - limit: number, - buyerType?: string, - ): Promise> { - const where: any = { - isActive: true, - deletedAt: null, - }; - - if (category) { - // Check if it's a UUID - const isUuid = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( - category, - ); - - const foundCategory = await this.prisma.category.findFirst({ - where: isUuid - ? { id: category, isActive: true } - : { slug: category, isActive: true }, - include: { - children: { - where: { isActive: true }, - select: { id: true }, - }, - }, - }); - - if (foundCategory) { - const categoryIds = [ - foundCategory.id, - ...foundCategory.children.map((c) => c.id), - ]; - where.categoryId = { in: categoryIds }; - } else { - // Fallback to legacy categoryTag - where.categoryTag = category; - } - } - - if (search) { - where.OR = [ - { name: { contains: search, mode: "insensitive" } }, - { description: { contains: search, mode: "insensitive" } }, - { - merchantProfile: { - businessName: { contains: search, mode: "insensitive" }, - }, - }, - { category: { name: { contains: search, mode: "insensitive" } } }, - ]; - } - - const response = await paginate( - this.prisma.product, - { page, limit }, - { - where, - orderBy: { createdAt: "desc" }, - include: { - merchantProfile: { - select: { - id: true, - businessName: true, - averageRating: true, - reviewCount: true, - verificationTier: true, - profileImage: true, - cacVerified: true, - addressVerified: true, - guarantorVerified: true, - bankVerified: true, - slug: true, - }, - }, - productStockCache: true, // Needed to determine stockAvailability - }, - }, - ); - - // Map the response to include string-serialized values + stockAvailability - response.data = response.data.map((product: any) => - this.mapProductForPublic(product, buyerType), - ); - - return response as unknown as PaginatedResponse; - } - - async getSocialFeed( - userId: string, - page: number, - limit: number, - buyerType?: string, - ): Promise> { - // 1. Get merchants the user follows - const following = await this.prisma.follow.findMany({ - where: { followerId: userId }, - select: { merchantId: true }, - }); - - const followedMerchantIds = following.map((f) => f.merchantId); - - if (followedMerchantIds.length === 0) { - return { - success: true, - data: [], - meta: { - total: 0, - page, - limit, - totalPages: 0, - }, - }; - } - - // 2. Fetch products from these merchants - const response = await paginate( - this.prisma.product, - { page, limit }, - { - where: { - merchantId: { in: followedMerchantIds }, - isActive: true, - deletedAt: null, - }, - orderBy: { createdAt: "desc" }, - include: { - merchantProfile: { - select: { - id: true, - businessName: true, - averageRating: true, - reviewCount: true, - verificationTier: true, - profileImage: true, - cacVerified: true, - addressVerified: true, - guarantorVerified: true, - bankVerified: true, - slug: true, - }, - }, - productStockCache: true, - }, - }, - ); - - // 3. Map response - response.data = response.data.map((product: any) => - this.mapProductForPublic(product, buyerType), - ); - - return response as unknown as PaginatedResponse; - } - - async getById(id: string, buyerType?: string) { - const product = await this.prisma.product.findUnique({ - where: { id }, - include: { - merchantProfile: { - select: { - id: true, - businessName: true, - verificationTier: true, - averageRating: true, - reviewCount: true, - profileImage: true, - cacVerified: true, - addressVerified: true, - guarantorVerified: true, - bankVerified: true, - }, - }, - productStockCache: true, - }, - }); - if (!product || product.deletedAt) { - throw new NotFoundException("Product not found"); - } - - return this.mapProductForPublic(product, buyerType) as unknown as Product; - } - - async update(merchantId: string, productId: string, dto: UpdateProductDto) { - await this.verifyProductOwnership(merchantId, productId); - const { - pricePerUnitKobo, - retailPriceKobo, - wholesalePriceKobo, - wholesaleDiscountPercent, - wholesaleEnabled, - ...rest - } = dto; - const updateData: any = { ...rest }; - - if (pricePerUnitKobo !== undefined) { - updateData.pricePerUnitKobo = - pricePerUnitKobo === null ? null : BigInt(pricePerUnitKobo); - } - if (retailPriceKobo !== undefined) { - updateData.retailPriceKobo = - retailPriceKobo === null ? null : BigInt(retailPriceKobo); - } - - // Handle wholesale toggle and pricing - if (wholesaleEnabled === false) { - // Wholesale toggled OFF — clear all wholesale data - updateData.wholesalePriceKobo = null; - updateData.wholesaleDiscountPercent = null; - } else if (wholesalePriceKobo !== undefined) { - // Explicit wholesale price provided - updateData.wholesalePriceKobo = - wholesalePriceKobo === null ? null : BigInt(wholesalePriceKobo); - updateData.wholesaleDiscountPercent = wholesaleDiscountPercent ?? null; - } else if ( - wholesaleDiscountPercent !== undefined && - wholesaleDiscountPercent > 0 - ) { - // Wholesale ON with a discount — compute wholesale price - let retailValue: bigint; - if (retailPriceKobo) { - retailValue = BigInt(retailPriceKobo); - } else { - const existing = await this.prisma.product.findUnique({ - where: { id: productId }, - select: { retailPriceKobo: true }, - }); - retailValue = existing?.retailPriceKobo ?? BigInt(0); - } - - updateData.wholesaleDiscountPercent = wholesaleDiscountPercent; - updateData.wholesalePriceKobo = BigInt( - Math.round(Number(retailValue) * (1 - wholesaleDiscountPercent / 100)), - ); - } - - const updated = await this.prisma.product.update({ - where: { id: productId }, - data: updateData, - }); - await this.invalidateCatalogueCache(); - return updated; - } - - async softDelete(merchantId: string, productId: string) { - await this.verifyProductOwnership(merchantId, productId); - const deleted = await this.prisma.product.update({ - where: { id: productId }, - data: { deletedAt: new Date(), isActive: false }, - }); - await this.invalidateCatalogueCache(); - return deleted; - } - - async restore(merchantId: string, productId: string) { - const product = await this.prisma.product.findUnique({ - where: { id: productId }, - }); - if (!product) throw new NotFoundException("Product not found"); - if (product.merchantId !== merchantId) { - throw new ForbiddenException("Access denied"); - } - const restored = await this.prisma.product.update({ - where: { id: productId }, - data: { deletedAt: null }, - }); - await this.invalidateCatalogueCache(); - return restored; - } - - async validateProductAvailability(productId: string) { - const product = await this.prisma.product.findUnique({ - where: { id: productId }, - }); - - if (!product || product.deletedAt) { - throw new NotFoundException("Product not found"); - } - - if (!product.isActive) { - throw new BadRequestException("Product is not currently active"); - } - - return product; - } - - private async verifyProductOwnership(merchantId: string, productId: string) { - const product = await this.prisma.product.findFirst({ - where: { id: productId, merchantId }, - }); - if (!product) { - throw new NotFoundException("Product not found"); - } - if (product.deletedAt) { - throw new NotFoundException("Product has been deleted"); - } - } - - private async invalidateCatalogueCache() { - try { - // Clear out all keys starting with /products to purge all paginated endpoints - const keys: string[] = await (this.cacheManager as any).store.keys( - "/products*", - ); - if (keys && keys.length > 0) { - await Promise.all(keys.map((k) => this.cacheManager.del(k))); - } - } catch (e) { - // In case underlying store lacks `.keys()` or errors, safely fallback to full reset - await this.cacheManager.clear(); - } - } - - async getAssociations(category: string) { - return this.prisma.productAssociation.findMany({ - where: { productCategoryA: category }, - orderBy: { strength: "desc" }, - select: { - productCategoryB: true, - strength: true, - promptText: true, - }, - }); - } - - private mapProductForPublic(product: any, buyerType?: string) { - const { productStockCache, ...rest } = product; - - let resolvedPrice = product.retailPriceKobo; - if (buyerType === "BUSINESS") { - resolvedPrice = product.wholesalePriceKobo || product.pricePerUnitKobo; - } else { - resolvedPrice = - product.retailPriceKobo || - product.wholesalePriceKobo || - product.pricePerUnitKobo; - } - - let stockAvailability = "OUT_OF_STOCK"; - const stockQuantity = productStockCache?.stock ?? 0; - - if (stockQuantity > 10) { - stockAvailability = "IN_STOCK"; - } else if (stockQuantity > 0) { - stockAvailability = "LOW_STOCK"; - } - - return { - ...rest, - pricePerUnitKobo: resolvedPrice ? resolvedPrice.toString() : null, - wholesalePriceKobo: product.wholesalePriceKobo - ? product.wholesalePriceKobo.toString() - : null, - retailPriceKobo: product.retailPriceKobo - ? product.retailPriceKobo.toString() - : null, - stockCache: productStockCache ? { stock: productStockCache.stock } : null, - stockAvailability, - }; - } -} diff --git a/apps/backend/src/modules/reorder/reorder-reminder.processor.ts b/apps/backend/src/modules/reorder/reorder-reminder.processor.ts deleted file mode 100644 index e5311191..00000000 --- a/apps/backend/src/modules/reorder/reorder-reminder.processor.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Processor, WorkerHost } from "@nestjs/bullmq"; -import { Job } from "bullmq"; -import { InjectQueue } from "@nestjs/bullmq"; -import { Queue } from "bullmq"; -import { Logger } from "@nestjs/common"; -import { REORDER_REMINDER_QUEUE } from "../../queue/queue.constants"; -import { ReorderService } from "./reorder.service"; - -@Processor(REORDER_REMINDER_QUEUE, { - drainDelay: 60000, - stalledInterval: 300000, - lockDuration: 60000, - metrics: null, -}) -export class ReorderReminderProcessor extends WorkerHost { - private readonly logger = new Logger(ReorderReminderProcessor.name); - - constructor( - @InjectQueue(REORDER_REMINDER_QUEUE) private readonly reorderQueue: Queue, - private readonly reorderService: ReorderService, - ) { - super(); - } - - async onModuleInit() { - try { - await this.reorderQueue.add( - "process-reorder-reminders", - {}, - { - repeat: { pattern: "0 8 * * *" }, // Daily at 8 AM - removeOnComplete: true, - removeOnFail: false, - }, - ); - this.logger.log("Reorder reminder cron job registered (daily at 8 AM)"); - } catch (error) { - this.logger.error( - `Failed to register reorder reminder cron job: ${error instanceof Error ? error.message : error}`, - ); - } - } - - async process(_job: Job): Promise { - this.logger.log("Running reorder reminder check..."); - const count = await this.reorderService.processReminders(); - this.logger.log(`Processed ${count} reorder reminders`); - } -} diff --git a/apps/backend/src/modules/reorder/reorder.constants.ts b/apps/backend/src/modules/reorder/reorder.constants.ts deleted file mode 100644 index 6dbbc013..00000000 --- a/apps/backend/src/modules/reorder/reorder.constants.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const REORDER_WINDOW_DAYS: Record = { - electronics: 45, - fashion: 30, - "health & beauty": 21, - "home & kitchen": 14, - "auto parts": 60, - agriculture: 14, - "food & groceries": 7, - "office & stationery": 30, - default: 21, -}; - -export function getReorderWindowDays(categoryTag: string): number { - return ( - REORDER_WINDOW_DAYS[categoryTag.toLowerCase()] || - REORDER_WINDOW_DAYS.default - ); -} diff --git a/apps/backend/src/modules/reorder/reorder.controller.ts b/apps/backend/src/modules/reorder/reorder.controller.ts deleted file mode 100644 index 2d822ae7..00000000 --- a/apps/backend/src/modules/reorder/reorder.controller.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - Controller, - Patch, - Param, - UseGuards, - ParseUUIDPipe, -} from "@nestjs/common"; -import { ReorderService } from "./reorder.service"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { RolesGuard } from "../../common/guards/roles.guard"; -import { CurrentUser } from "../../common/decorators/current-user.decorator"; -import { JwtPayload } from "@swifta/shared"; - -@Controller() -@UseGuards(JwtAuthGuard, RolesGuard) -export class ReorderController { - constructor(private readonly reorderService: ReorderService) {} - - @Patch("reorder-reminders/:id/dismiss") - dismiss( - @CurrentUser() user: JwtPayload, - @Param("id", ParseUUIDPipe) id: string, - ) { - return this.reorderService.dismiss(id, user.sub); - } -} diff --git a/apps/backend/src/modules/reorder/reorder.module.ts b/apps/backend/src/modules/reorder/reorder.module.ts deleted file mode 100644 index acb270dc..00000000 --- a/apps/backend/src/modules/reorder/reorder.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Module, forwardRef } from "@nestjs/common"; -import { ReorderService } from "./reorder.service"; -import { ReorderController } from "./reorder.controller"; -import { ReorderReminderProcessor } from "./reorder-reminder.processor"; -import { PrismaModule } from "../../prisma/prisma.module"; -import { NotificationModule } from "../notification/notification.module"; -import { QueueModule } from "../../queue/queue.module"; - -@Module({ - imports: [ - PrismaModule, - forwardRef(() => NotificationModule), - forwardRef(() => QueueModule), - ], - controllers: [ReorderController], - providers: [ReorderService, ReorderReminderProcessor], - exports: [ReorderService], -}) -export class ReorderModule {} diff --git a/apps/backend/src/modules/reorder/reorder.service.ts b/apps/backend/src/modules/reorder/reorder.service.ts deleted file mode 100644 index acf7fceb..00000000 --- a/apps/backend/src/modules/reorder/reorder.service.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { - Injectable, - Logger, - NotFoundException, - BadRequestException, -} from "@nestjs/common"; -import { PrismaService } from "../../prisma/prisma.service"; -import { NotificationTriggerService } from "../notification/notification-trigger.service"; -import { getReorderWindowDays } from "./reorder.constants"; - -@Injectable() -export class ReorderService { - private readonly logger = new Logger(ReorderService.name); - - constructor( - private prisma: PrismaService, - private notifications: NotificationTriggerService, - ) {} - - async createReminder(orderId: string) { - const order = await this.prisma.order.findUnique({ - where: { id: orderId }, - include: { - product: true, - merchantProfile: { select: { businessName: true } }, - }, - }); - - if (!order?.product || !order.merchantId) { - this.logger.log( - `Order ${orderId} has no linked product or merchant, skipping reorder reminder`, - ); - return; - } - - const product = order.product; - const windowDays = getReorderWindowDays(product.categoryTag); - const remindAt = new Date(); - remindAt.setDate(remindAt.getDate() + windowDays); - - // Check if reminder already exists for this order - const existing = await this.prisma.reorderReminder.findUnique({ - where: { orderId }, - }); - if (existing) { - this.logger.log(`Reorder reminder already exists for order ${orderId}`); - return; - } - - await this.prisma.reorderReminder.create({ - data: { - orderId, - buyerId: order.buyerId, - merchantId: order.merchantId, - productCategory: product.categoryTag, - productName: product.name, - originalQuantity: order.quantity || 1, - remindAt, - }, - }); - - this.logger.log( - `Reorder reminder created for order ${orderId}, remind at ${remindAt.toISOString()}`, - ); - } - - async processReminders() { - const now = new Date(); - const reminders = await this.prisma.reorderReminder.findMany({ - where: { - status: "PENDING", - remindAt: { lte: now }, - }, - include: { - user: { select: { firstName: true, lastName: true } }, - merchantProfile: { select: { businessName: true, userId: true } }, - order: true, - }, - }); - - this.logger.log(`Found ${reminders.length} reorder reminders to process`); - - for (const reminder of reminders) { - try { - // Check if buyer already ordered for same category+merchant since order - const existingOrder = await this.prisma.order.findFirst({ - where: { - buyerId: reminder.buyerId, - merchantId: reminder.merchantId, - createdAt: { gt: reminder.order.createdAt }, - product: { categoryTag: reminder.productCategory }, - }, - }); - - if (existingOrder) { - this.logger.log( - `Buyer already reordered for reminder ${reminder.id}, marking as REORDERED`, - ); - await this.prisma.reorderReminder.update({ - where: { id: reminder.id }, - data: { status: "REORDERED" }, - }); - continue; - } - - const daysSinceOrder = Math.floor( - (now.getTime() - new Date(reminder.order.createdAt).getTime()) / - (1000 * 60 * 60 * 24), - ); - - // Notify buyer - await this.notifications.triggerReorderReminder(reminder.buyerId, { - reminderId: reminder.id, - productName: reminder.productName, - merchantName: reminder.merchantProfile.businessName, - originalQuantity: reminder.originalQuantity, - daysSinceOrder, - }); - - // Notify merchant - await this.notifications.triggerMerchantReorderPrompt( - reminder.merchantId, - { - reminderId: reminder.id, - buyerName: `${reminder.user.firstName} ${reminder.user.lastName}`, - productName: reminder.productName, - originalQuantity: reminder.originalQuantity, - daysSinceOrder, - }, - ); - - await this.prisma.reorderReminder.update({ - where: { id: reminder.id }, - data: { status: "SENT" }, - }); - } catch (error) { - this.logger.error( - `Failed to process reorder reminder ${reminder.id}: ${error instanceof Error ? error.message : error}`, - ); - } - } - - return reminders.length; - } - - async dismiss(reminderId: string, userId: string) { - const reminder = await this.prisma.reorderReminder.findUnique({ - where: { id: reminderId }, - include: { merchantProfile: { select: { userId: true } } }, - }); - - if (!reminder) { - throw new NotFoundException("Reorder reminder not found"); - } - - // Allow buyer or merchant to dismiss - if ( - reminder.buyerId !== userId && - reminder.merchantProfile.userId !== userId - ) { - throw new BadRequestException("Access denied"); - } - - return this.prisma.reorderReminder.update({ - where: { id: reminderId }, - data: { status: "DISMISSED" }, - }); - } -} diff --git a/apps/backend/src/modules/review/dto/create-review.dto.ts b/apps/backend/src/modules/review/dto/create-review.dto.ts deleted file mode 100644 index b7736cc0..00000000 --- a/apps/backend/src/modules/review/dto/create-review.dto.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - IsString, - IsNotEmpty, - Min, - Max, - IsOptional, - IsInt, -} from "class-validator"; - -export class CreateReviewDto { - @IsString() - @IsNotEmpty() - orderId: string; - - @IsInt() - @Min(1) - @Max(5) - rating: number; - - @IsString() - @IsOptional() - comment?: string; - - @IsString() - @IsOptional() - imageUrl?: string; -} diff --git a/apps/backend/src/modules/review/review.controller.ts b/apps/backend/src/modules/review/review.controller.ts deleted file mode 100644 index 343494bf..00000000 --- a/apps/backend/src/modules/review/review.controller.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { - Controller, - Post, - Get, - Body, - Param, - UseGuards, - Query, - ParseIntPipe, -} from "@nestjs/common"; -import { ReviewService } from "./review.service"; -import { CreateReviewDto } from "./dto/create-review.dto"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { CurrentUser } from "../../common/decorators/current-user.decorator"; -import { OrderService } from "../order/order.service"; -import { JwtPayload } from "@swifta/shared"; - -@Controller("reviews") -export class ReviewController { - constructor( - private readonly reviewService: ReviewService, - private readonly orderService: OrderService, - ) {} - - @Post() - @UseGuards(JwtAuthGuard) - async create( - @CurrentUser("id") userId: string, - @Body() dto: CreateReviewDto, - ) { - return this.reviewService.create(userId, dto); - } - - @Get("merchant/:merchantId") - async findByMerchant( - @Param("merchantId") merchantId: string, - @Query("page", new ParseIntPipe({ optional: true })) page = 1, - @Query("limit", new ParseIntPipe({ optional: true })) limit = 10, - ) { - return this.reviewService.findByMerchant(merchantId, page, limit); - } - - @Get("order/:orderId") - @UseGuards(JwtAuthGuard) - async findByOrder( - @CurrentUser() user: JwtPayload, - @Param("orderId") orderId: string, - ) { - // Verify participation via OrderService - await this.orderService.getById(orderId, user.sub, user.merchantId); - return this.reviewService.findByOrder(orderId); - } -} diff --git a/apps/backend/src/modules/review/review.module.ts b/apps/backend/src/modules/review/review.module.ts deleted file mode 100644 index 5c3b7bac..00000000 --- a/apps/backend/src/modules/review/review.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Module, forwardRef } from "@nestjs/common"; -import { OrderModule } from "../order/order.module"; -import { ReviewService } from "./review.service"; -import { ReviewController } from "./review.controller"; -import { PrismaModule } from "../../prisma/prisma.module"; -import { ReviewPromptProcessor } from "./review.processor"; -import { WhatsAppModule } from "../whatsapp/whatsapp.module"; - -@Module({ - imports: [ - PrismaModule, - forwardRef(() => WhatsAppModule), - forwardRef(() => OrderModule), - ], - controllers: [ReviewController], - providers: [ReviewService, ReviewPromptProcessor], - exports: [ReviewService], -}) -export class ReviewModule {} diff --git a/apps/backend/src/modules/review/review.processor.ts b/apps/backend/src/modules/review/review.processor.ts deleted file mode 100644 index 3fa8bbab..00000000 --- a/apps/backend/src/modules/review/review.processor.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Processor, WorkerHost } from "@nestjs/bullmq"; -import { Logger } from "@nestjs/common"; -import { Job } from "bullmq"; -import { ReviewService } from "./review.service"; -import { WhatsAppService } from "../whatsapp/whatsapp.service"; -import { REVIEW_QUEUE } from "../../queue/queue.constants"; -import { PrismaService } from "../../prisma/prisma.service"; - -@Processor(REVIEW_QUEUE, { - drainDelay: 60000, - stalledInterval: 300000, - lockDuration: 60000, - metrics: null, -}) -export class ReviewPromptProcessor extends WorkerHost { - private readonly logger = new Logger(ReviewPromptProcessor.name); - - constructor( - private reviewService: ReviewService, - private whatsappService: WhatsAppService, - private prisma: PrismaService, - ) { - super(); - } - - async process(job: Job): Promise { - try { - const { orderId, buyerId } = job.data; - - // 1. Check if order still exists and is completed/delivered - const order = await this.prisma.order.findUnique({ - where: { id: orderId }, - include: { review: true, merchantProfile: true, product: true }, - }); - - if (!order) { - this.logger.warn(`Order ${orderId} not found for review prompt`); - return; - } - - // 2. Check if already reviewed (via web dashboard) - if (order.review) { - this.logger.log(`Order ${orderId} already reviewed, skipping prompt`); - return; - } - - // 3. Send WhatsApp prompt - const productName = order.product?.name || "your order"; - const merchantName = - order.merchantProfile?.businessName || "the merchant"; - - await this.whatsappService.sendReviewPrompt( - buyerId, - orderId, - merchantName, - productName, - ); - - this.logger.log(`Review prompt sent for order ${orderId}`); - } catch (error) { - this.logger.error( - `Review prompt job failed: ${error instanceof Error ? error.message : error}`, - ); - throw error; // Rethrow to allow BullMQ to retry - } - } -} diff --git a/apps/backend/src/modules/review/review.service.ts b/apps/backend/src/modules/review/review.service.ts deleted file mode 100644 index f60b07a2..00000000 --- a/apps/backend/src/modules/review/review.service.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { - Injectable, - NotFoundException, - BadRequestException, - ConflictException, - Logger, - ForbiddenException, -} from "@nestjs/common"; -import { PrismaService } from "../../prisma/prisma.service"; -import { CreateReviewDto } from "./dto/create-review.dto"; -import { OrderStatus, VerificationTier } from "@swifta/shared"; - -@Injectable() -export class ReviewService { - private readonly logger = new Logger(ReviewService.name); - - constructor(private prisma: PrismaService) {} - - async create(userId: string, dto: CreateReviewDto) { - const { orderId, rating, comment, imageUrl } = dto; - - const order = await this.prisma.order.findUnique({ - where: { id: orderId }, - include: { review: true }, - }); - - if (!order) throw new NotFoundException("Order not found"); - if (order.buyerId !== userId) - throw new ForbiddenException("You did not place this order"); - if ( - order.status !== OrderStatus.COMPLETED && - order.status !== OrderStatus.DELIVERED - ) { - throw new BadRequestException( - "Orders can only be reviewed after delivery", - ); - } - if (order.review) - throw new ConflictException("This order has already been reviewed"); - - const merchantId = order.merchantId; - if (!merchantId) throw new BadRequestException("Order has no merchant"); - - // Get buyer profile - const buyerProfile = await this.prisma.buyerProfile.findUnique({ - where: { userId }, - }); - if (!buyerProfile) throw new BadRequestException("Buyer profile not found"); - - const review = await this.prisma.$transaction(async (tx) => { - try { - const newReview = await tx.review.create({ - data: { - orderId, - buyerId: buyerProfile.userId, // Review model links to buyer via buyerId (User relations) - merchantId, - rating, - comment, - imageUrl, - }, - }); - - // Recalculate merchant ratings - const reviews = await tx.review.findMany({ - where: { merchantId }, - select: { rating: true }, - }); - - const reviewCount = reviews.length; - const totalStars = reviews.reduce((sum, r) => sum + r.rating, 0); - const averageRating = totalStars / reviewCount; - - await tx.merchantProfile.update({ - where: { id: merchantId }, - data: { - reviewCount, - averageRating, - }, - }); - - // Check for TRUSTED tier upgrade (inside transaction) - try { - await this.evaluateTrustedTierInternal(merchantId, tx); - } catch (tierErr) { - this.logger.error( - `Failed to evaluate trusted tier for merchant ${merchantId}`, - tierErr, - ); - } - - return newReview; - } catch (error) { - if ((error as any).code === "P2002") { - throw new ConflictException("This order has already been reviewed"); - } - throw error; - } - }); - - return review; - } - - async updateComment(orderId: string, comment: string) { - const review = await this.prisma.review.findUnique({ - where: { orderId }, - }); - if (!review) throw new NotFoundException("Review not found for this order"); - - return this.prisma.review.update({ - where: { orderId }, - data: { comment }, - }); - } - - async findByMerchant(merchantId: string, page = 1, limit = 10) { - const skip = (page - 1) * limit; - const reviews = await this.prisma.review.findMany({ - where: { merchantId }, - select: { - id: true, - rating: true, - comment: true, - createdAt: true, - buyer: { - select: { - user: { - select: { - firstName: true, - }, - }, - }, - }, - }, - orderBy: { createdAt: "desc" }, - skip, - take: limit, - }); - - return reviews.map((r) => ({ - id: r.id, - rating: r.rating, - comment: r.comment, - createdAt: r.createdAt, - buyerName: r.buyer?.user?.firstName || "Buyer", - })); - } - - async findByOrder(orderId: string) { - return this.prisma.review.findUnique({ - where: { orderId }, - }); - } - - private async evaluateTrustedTier(merchantId: string) { - return this.evaluateTrustedTierInternal(merchantId, this.prisma as any); - } - - private async evaluateTrustedTierInternal(merchantId: string, tx: any) { - const merchant = await tx.merchantProfile.findUnique({ - where: { id: merchantId }, - include: { - _count: { - select: { orders: { where: { status: OrderStatus.COMPLETED } } }, - }, - }, - }); - - if (!merchant) return; - - // Criteria: 10+ completed orders, 30+ days since creation, 4.5+ average rating - const completedOrders = merchant._count.orders; - const daysSinceCreation = - (new Date().getTime() - merchant.createdAt.getTime()) / - (1000 * 60 * 60 * 24); - const avgRating = merchant.averageRating || 0; - - if (completedOrders >= 10 && daysSinceCreation >= 30 && avgRating >= 4.5) { - if (merchant.verificationTier !== VerificationTier.TIER_3) { - await tx.merchantProfile.update({ - where: { id: merchantId }, - data: { verificationTier: VerificationTier.TIER_3 }, - }); - this.logger.log(`Merchant ${merchantId} upgraded to TIER_3 tier`); - } - } - } -} diff --git a/apps/backend/src/modules/supplier/dto/create-supplier-product.dto.ts b/apps/backend/src/modules/supplier/dto/create-supplier-product.dto.ts deleted file mode 100644 index 6a6a984b..00000000 --- a/apps/backend/src/modules/supplier/dto/create-supplier-product.dto.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - IsString, - IsNumber, - Min, - IsNotEmpty, - IsOptional, -} from "class-validator"; - -export class CreateSupplierProductDto { - @IsString() - @IsNotEmpty() - name: string; - - @IsString() - @IsNotEmpty() - category: string; - - @IsOptional() - @IsString() - description?: string; - - @IsNumber() - @Min(0) - wholesalePriceKobo: number; - - @IsNumber() - @Min(1) - minOrderQty: number; - - @IsString() - @IsNotEmpty() - unit: string; -} diff --git a/apps/backend/src/modules/supplier/dto/create-wholesale-order.dto.ts b/apps/backend/src/modules/supplier/dto/create-wholesale-order.dto.ts deleted file mode 100644 index d1b33dc5..00000000 --- a/apps/backend/src/modules/supplier/dto/create-wholesale-order.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { IsUUID, IsInt, Min, IsString } from "class-validator"; - -export class CreateWholesaleOrderDto { - @IsUUID() - productId: string; - - @IsInt() - @Min(1) - quantity: number; - - @IsString() - deliveryAddress: string; -} diff --git a/apps/backend/src/modules/supplier/dto/update-supplier-product.dto.ts b/apps/backend/src/modules/supplier/dto/update-supplier-product.dto.ts deleted file mode 100644 index 0f3500ac..00000000 --- a/apps/backend/src/modules/supplier/dto/update-supplier-product.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { PartialType } from "@nestjs/mapped-types"; -import { CreateSupplierProductDto } from "./create-supplier-product.dto"; - -export class UpdateSupplierProductDto extends PartialType( - CreateSupplierProductDto, -) {} diff --git a/apps/backend/src/modules/supplier/supplier.controller.ts b/apps/backend/src/modules/supplier/supplier.controller.ts deleted file mode 100644 index 989bcdba..00000000 --- a/apps/backend/src/modules/supplier/supplier.controller.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { - Controller, - Get, - Post, - Put, - Body, - UseGuards, - Param, -} from "@nestjs/common"; -import { SupplierService } from "./supplier.service"; -import { CreateSupplierProductDto } from "./dto/create-supplier-product.dto"; -import { UpdateSupplierProductDto } from "./dto/update-supplier-product.dto"; -import { CreateWholesaleOrderDto } from "./dto/create-wholesale-order.dto"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { RolesGuard } from "../../common/guards/roles.guard"; -import { Roles } from "../../common/decorators/roles.decorator"; -import { UserRole } from "@swifta/shared"; -import { CurrentUser } from "../../common/decorators/current-user.decorator"; - -@Controller("supplier") -@UseGuards(JwtAuthGuard, RolesGuard) -export class SupplierController { - constructor(private readonly supplierService: SupplierService) {} - - @Get("profile") - @Roles(UserRole.SUPPLIER) - getProfile(@CurrentUser("sub") userId: string) { - return this.supplierService.getProfile(userId); - } - - @Post("products") - @Roles(UserRole.SUPPLIER) - createProduct( - @CurrentUser("sub") userId: string, - @Body() dto: CreateSupplierProductDto, - ) { - return this.supplierService.createProduct(userId, dto); - } - - @Get("products") - @Roles(UserRole.SUPPLIER) - getMyProducts(@CurrentUser("sub") userId: string) { - return this.supplierService.getMyProducts(userId); - } - - @Put("products/:id") - @Roles(UserRole.SUPPLIER) - updateProduct( - @CurrentUser("sub") userId: string, - @Param("id") id: string, - @Body() dto: UpdateSupplierProductDto, - ) { - return this.supplierService.updateProduct(userId, id, dto); - } - - @Get("catalogue") - @Roles(UserRole.MERCHANT, UserRole.SUPER_ADMIN) - getCatalogue() { - return this.supplierService.listCatalogue(); - } - - @Get("recommended") - @Roles(UserRole.MERCHANT) - async getRecommendedCatalogue(@CurrentUser("sub") userId: string) { - const merchant = await this.supplierService.getProfile(userId); - return this.supplierService.getRecommendedCatalogue(merchant.id); - } - - @Get("dashboard") - @Roles(UserRole.SUPPLIER) - getDashboard(@CurrentUser("sub") userId: string) { - return this.supplierService.getDashboardStats(userId); - } - - @Post("orders") - @Roles(UserRole.MERCHANT) - createWholesaleOrder( - @CurrentUser("sub") userId: string, - @Body() dto: CreateWholesaleOrderDto, - ) { - return this.supplierService.createOrder(userId, dto); - } -} diff --git a/apps/backend/src/modules/supplier/supplier.module.ts b/apps/backend/src/modules/supplier/supplier.module.ts deleted file mode 100644 index f032f8a2..00000000 --- a/apps/backend/src/modules/supplier/supplier.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Module, forwardRef } from "@nestjs/common"; -import { SupplierController } from "./supplier.controller"; -import { SupplierService } from "./supplier.service"; - -import { PaymentModule } from "../payment/payment.module"; -import { WhatsAppModule } from "../whatsapp/whatsapp.module"; -import { PrismaModule } from "../../prisma/prisma.module"; - -@Module({ - imports: [ - forwardRef(() => PaymentModule), - forwardRef(() => WhatsAppModule), - PrismaModule, - ], - controllers: [SupplierController], - providers: [SupplierService], - exports: [SupplierService], -}) -export class SupplierModule {} diff --git a/apps/backend/src/modules/supplier/supplier.service.ts b/apps/backend/src/modules/supplier/supplier.service.ts deleted file mode 100644 index 4f2d2367..00000000 --- a/apps/backend/src/modules/supplier/supplier.service.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { - Injectable, - ForbiddenException, - NotFoundException, - BadRequestException, - Inject, - forwardRef, - Logger, -} from "@nestjs/common"; -import { PrismaService } from "../../prisma/prisma.service"; -import { CreateSupplierProductDto } from "./dto/create-supplier-product.dto"; -import { UpdateSupplierProductDto } from "./dto/update-supplier-product.dto"; -import { CreateWholesaleOrderDto } from "./dto/create-wholesale-order.dto"; -import { OrderStatus } from "@swifta/shared"; -import { PaymentService } from "../payment/payment.service"; - -@Injectable() -export class SupplierService { - private readonly logger = new Logger(SupplierService.name); - - constructor( - private prisma: PrismaService, - @Inject(forwardRef(() => PaymentService)) - private paymentService: PaymentService, - ) {} - - private async findMerchantCategories(merchantId: string): Promise { - const products = await this.prisma.product.findMany({ - where: { merchantId, isActive: true, deletedAt: null }, - select: { categoryTag: true }, - distinct: ["categoryTag"], - }); - return products.map((p) => p.categoryTag); - } - - async getRecommendedCatalogue( - merchantId: string, - page: number = 1, - limit: number = 50, - ) { - const merchantCategories = ( - await this.findMerchantCategories(merchantId) - ).map((c) => c.trim().toLowerCase()); - - // Get all active supplier products (with pagination) - const allProducts = await this.prisma.supplierProduct.findMany({ - where: { - isActive: true, - supplier: { isVerified: true }, - }, - include: { - supplier: { - select: { companyName: true, isVerified: true }, - }, - }, - orderBy: { createdAt: "desc" }, - take: limit, - skip: (page - 1) * limit, - }); - - // Map and Sort: Recommended (matching categories) first - return allProducts - .map((p) => { - const isMatch = merchantCategories.includes( - p.category.trim().toLowerCase(), - ); - return { ...p, isRecommended: isMatch }; - }) - .sort((a, b) => { - if (a.isRecommended && !b.isRecommended) return -1; - if (!a.isRecommended && b.isRecommended) return 1; - return 0; // maintain default order (createdAt desc from query) - }); - } - - async getProfile(userId: string) { - const profile = await this.prisma.supplierProfile.findUnique({ - where: { userId }, - include: { products: true, user: true }, - }); - if (!profile) throw new NotFoundException("Supplier profile not found"); - return profile; - } - - async createProduct(userId: string, dto: CreateSupplierProductDto) { - const profile = await this.prisma.supplierProfile.findUnique({ - where: { userId }, - }); - if (!profile) - throw new ForbiddenException( - "Only registered suppliers can list products", - ); - - return this.prisma.supplierProduct.create({ - data: { - supplierId: profile.id, - name: dto.name, - category: dto.category, - description: dto.description, - wholesalePriceKobo: BigInt(dto.wholesalePriceKobo), - minOrderQty: dto.minOrderQty, - unit: dto.unit, - }, - }); - } - - async getMyProducts(userId: string) { - const profile = await this.prisma.supplierProfile.findUnique({ - where: { userId }, - }); - if (!profile) return []; - - return this.prisma.supplierProduct.findMany({ - where: { supplierId: profile.id }, - orderBy: { createdAt: "desc" }, - }); - } - - async updateProduct( - userId: string, - productId: string, - dto: UpdateSupplierProductDto, - ) { - const profile = await this.prisma.supplierProfile.findUnique({ - where: { userId }, - }); - if (!profile) throw new ForbiddenException(); - - const product = await this.prisma.supplierProduct.findFirst({ - where: { id: productId, supplierId: profile.id }, - }); - if (!product) throw new NotFoundException(); - - return this.prisma.supplierProduct.update({ - where: { id: productId }, - data: { - ...dto, - ...(dto.wholesalePriceKobo - ? { wholesalePriceKobo: BigInt(dto.wholesalePriceKobo) } - : {}), - }, - }); - } - - async listCatalogue() { - return this.prisma.supplierProduct.findMany({ - where: { - isActive: true, - supplier: { - isVerified: true, - }, - }, - include: { - supplier: { - select: { - companyName: true, - isVerified: true, - }, - }, - }, - orderBy: { createdAt: "desc" }, - }); - } - - async getDashboardStats(userId: string) { - const profile = await this.prisma.supplierProfile.findUnique({ - where: { userId }, - }); - if (!profile) throw new ForbiddenException(); - - const productCount = await this.prisma.supplierProduct.count({ - where: { supplierId: profile.id }, - }); - - const activeOrders = await this.prisma.order.count({ - where: { - supplierId: profile.id, - status: { - in: ["PAID", "PREPARING", "IN_TRANSIT", "DISPATCHED"], - }, - }, - }); - - const revenue = await this.prisma.order.aggregate({ - where: { - supplierId: profile.id, - status: "COMPLETED", - }, - _sum: { - totalAmountKobo: true, - }, - }); - - return { - productCount, - activeOrders, - totalRevenueKobo: revenue._sum.totalAmountKobo || 0n, - isVerified: profile.isVerified, - }; - } - - async createOrder(userId: string, dto: CreateWholesaleOrderDto) { - const product = await this.prisma.supplierProduct.findUnique({ - where: { id: dto.productId }, - include: { supplier: true }, - }); - - if (!product) throw new NotFoundException("Wholesale product not found"); - if (!product.isActive) throw new BadRequestException("Product is inactive"); - if (dto.quantity < product.minOrderQty) { - throw new BadRequestException( - `Minimum order quantity is ${product.minOrderQty}`, - ); - } - - const subtotalKobo = Number(product.wholesalePriceKobo) * dto.quantity; - const platformFeeKobo = Math.floor(subtotalKobo * 0.01); - const totalAmountKobo = BigInt(subtotalKobo + platformFeeKobo); - - const idempotencyKey = `wholesale-order-${product.id}-${userId}-${Date.now()}`; - - const order = await this.prisma.$transaction(async (tx) => { - const newOrder = await tx.order.create({ - data: { - buyerId: userId, - supplierId: product.supplierId, - supplierProductId: product.id, - quantity: dto.quantity, - unitPriceKobo: product.wholesalePriceKobo, - deliveryAddress: dto.deliveryAddress, - totalAmountKobo, - deliveryFeeKobo: 0n, - currency: "NGN", - status: OrderStatus.PENDING_PAYMENT, - paymentMethod: "ESCROW", - idempotencyKey, - }, - }); - - await tx.orderEvent.create({ - data: { - orderId: newOrder.id, - fromStatus: null, - toStatus: OrderStatus.PENDING_PAYMENT, - triggeredBy: userId, - metadata: { - action: "wholesale_order_created", - productId: product.id, - }, - }, - }); - - return newOrder; - }); - - // Initialize payment — if this fails, compensate by deleting the orphan order - let paymentData: { authorization_url?: string }; - try { - paymentData = await this.paymentService.initialize( - userId, - { orderId: order.id }, - `init-wholesale-${order.id}`, - ); - } catch (paymentError) { - // Compensate: remove the order so it doesn't sit in PENDING_PAYMENT forever - await this.prisma.order.delete({ where: { id: order.id } }); - throw new BadRequestException( - "Payment initialization failed. Please try again.", - ); - } - - return { - orderId: order.id, - authorizationUrl: paymentData.authorization_url, - totalAmountKobo: Number(totalAmountKobo), - }; - } -} diff --git a/apps/backend/src/modules/trade-financing/bnpl-partner.interface.ts b/apps/backend/src/modules/trade-financing/bnpl-partner.interface.ts deleted file mode 100644 index 9b153db7..00000000 --- a/apps/backend/src/modules/trade-financing/bnpl-partner.interface.ts +++ /dev/null @@ -1,41 +0,0 @@ -export interface BuyerCreditData { - userId: string; - firstName: string; - lastName: string; - email: string; - phone: string; - completedOrders: number; - totalSpendKobo: bigint; - disputeRate: number; -} - -export interface BnplPartnerClient { - /** - * Evaluates the buyer's creditworthiness based on Swifta history + partner logic - */ - checkEligibility(buyerData: BuyerCreditData): Promise<{ - eligible: boolean; - maxAmount: bigint; - interestRate: number; - reason?: string; - }>; - - /** - * Books the loan with the partner. If approved, the partner will disburse funds to Swifta. - */ - initiateLoan( - orderId: string, - amount: bigint, - tenure: number, - buyerData: BuyerCreditData, - ): Promise<{ approved: boolean; loanRef: string; disbursementRef: string }>; - - /** - * Fetches the latest repayment status for an active loan - */ - getLoanStatus(loanRef: string): Promise<{ - status: "ACTIVE" | "REPAID" | "DEFAULTED"; - repaid: bigint; - remaining: bigint; - }>; -} diff --git a/apps/backend/src/modules/trade-financing/dto/apply-loan.dto.ts b/apps/backend/src/modules/trade-financing/dto/apply-loan.dto.ts deleted file mode 100644 index 93fa0f45..00000000 --- a/apps/backend/src/modules/trade-financing/dto/apply-loan.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IsUUID, IsInt, Min, Max } from "class-validator"; - -export class ApplyLoanDto { - @IsUUID() - orderId: string; - - @IsInt() - @Min(7) - @Max(365) - tenureDays: number; -} diff --git a/apps/backend/src/modules/trade-financing/mock-bnpl.client.ts b/apps/backend/src/modules/trade-financing/mock-bnpl.client.ts deleted file mode 100644 index 24880620..00000000 --- a/apps/backend/src/modules/trade-financing/mock-bnpl.client.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { BnplPartnerClient, BuyerCreditData } from "./bnpl-partner.interface"; -import { Injectable, Logger } from "@nestjs/common"; -import { v4 as uuidv4 } from "uuid"; - -@Injectable() -export class MockBnplClient implements BnplPartnerClient { - private readonly logger = new Logger(MockBnplClient.name); - - // Hardcode rules for the mock partner - private readonly MIN_ORDERS = 5; - private readonly MIN_SPEND = BigInt(5000000); // 50k Naira - private readonly DEFAULT_INTEREST_RATE = 5.0; // 5% flat - - async checkEligibility(buyerData: BuyerCreditData) { - this.logger.debug(`Mock eligibility check for ${buyerData.userId}`); - - if (buyerData.completedOrders < this.MIN_ORDERS) { - return { - eligible: false, - maxAmount: BigInt(0), - interestRate: 0, - reason: `Need at least ${this.MIN_ORDERS} completed orders.`, - }; - } - - if (buyerData.totalSpendKobo < this.MIN_SPEND) { - return { - eligible: false, - maxAmount: BigInt(0), - interestRate: 0, - reason: "Need higher historical spend.", - }; - } - - if (buyerData.disputeRate > 0.1) { - return { - eligible: false, - maxAmount: BigInt(0), - interestRate: 0, - reason: "Dispute rate too high.", - }; - } - - // Give them half of their historic spend as a credit limit - const maxAmount = buyerData.totalSpendKobo / BigInt(2); - - return { - eligible: true, - maxAmount, - interestRate: this.DEFAULT_INTEREST_RATE, - reason: "Approved based on trading history.", - }; - } - - async initiateLoan( - orderId: string, - _amount: bigint, - _tenure: number, - _buyerData: BuyerCreditData, - ) { - this.logger.debug(`Mock loan initiated for order ${orderId}`); - - // The mock client always approves an initiated loan request assuming eligibility passed earlier - const mockLoanRef = `MOCK-LOAN-${uuidv4().substring(0, 8).toUpperCase()}`; - const mockDisbRef = `MOCK-DSB-${Date.now()}`; - - // Note: In a real integration, this is where the partner transfers funds to our Paystack account. - // For the mock, we just say it happened. - - return { - approved: true, - loanRef: mockLoanRef, - disbursementRef: mockDisbRef, - }; - } - - async getLoanStatus(loanRef: string) { - // 50% paid artificially for testing purposes - this.logger.debug(`Mock getLoanStatus called for ${loanRef}`); - - return { - status: "ACTIVE" as const, - repaid: BigInt(50000), - remaining: BigInt(50000), - }; - } -} diff --git a/apps/backend/src/modules/trade-financing/mock-trade-financing.client.ts b/apps/backend/src/modules/trade-financing/mock-trade-financing.client.ts deleted file mode 100644 index aa68691a..00000000 --- a/apps/backend/src/modules/trade-financing/mock-trade-financing.client.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - TradeFinancingPartnerClient, - MerchantCreditData, -} from "./trade-financing-partner.interface"; -import { Injectable, Logger } from "@nestjs/common"; -import { v4 as uuidv4 } from "uuid"; - -@Injectable() -export class MockTradeFinancingClient implements TradeFinancingPartnerClient { - private readonly logger = new Logger(MockTradeFinancingClient.name); - - // Hardcode rules for the mock partner for merchants - private readonly MIN_SALES = 10; - private readonly MIN_MONTHLY_REV = BigInt(50000000); // ₦500k - private readonly DEFAULT_INTEREST_RATE = 4.5; // 4.5% flat for B2B - - async checkEligibility(merchantData: MerchantCreditData) { - this.logger.debug( - `Mock eligibility check for merchant ${merchantData.merchantId}`, - ); - - if (merchantData.completedSales < this.MIN_SALES) { - return { - eligible: false, - maxAmount: 0n, - interestRate: 0, - reason: `Need at least ${this.MIN_SALES} completed sales orders.`, - }; - } - - if (merchantData.averageMonthlyRevenueKobo < this.MIN_MONTHLY_REV) { - return { - eligible: false, - maxAmount: 0n, - interestRate: 0, - reason: "Monthly revenue is below financing threshold.", - }; - } - - // Give them 3x their monthly revenue as a credit limit - const maxAmount = merchantData.averageMonthlyRevenueKobo * 3n; - const creditScore = - merchantData.averageMonthlyRevenueKobo > 100000000n ? "A+" : "B"; - - return { - eligible: true, - maxAmount, - interestRate: this.DEFAULT_INTEREST_RATE, - creditScore, - reason: "Approved based on merchant revenue history.", - }; - } - - async initiateLoan( - orderId: string, - _amount: bigint, - _tenure: number, - _merchantData: MerchantCreditData, - ) { - this.logger.debug(`Mock trade financing initiated for order ${orderId}`); - - const mockLoanRef = `TF-${uuidv4().substring(0, 8).toUpperCase()}`; - const mockDisbRef = `TF-DSB-${Date.now()}`; - - return { - approved: true, - loanRef: mockLoanRef, - disbursementRef: mockDisbRef, - }; - } - - async getLoanStatus(loanRef: string) { - this.logger.debug(`Mock getLoanStatus called for ${loanRef}`); - - return { - status: "ACTIVE" as const, - repaid: 0n, - remaining: 10000000n, // Dummy remaining - }; - } -} diff --git a/apps/backend/src/modules/trade-financing/trade-financing-partner.interface.ts b/apps/backend/src/modules/trade-financing/trade-financing-partner.interface.ts deleted file mode 100644 index 9f83e422..00000000 --- a/apps/backend/src/modules/trade-financing/trade-financing-partner.interface.ts +++ /dev/null @@ -1,44 +0,0 @@ -export interface MerchantCreditData { - userId: string; - merchantId: string; - businessName: string; - email: string; - phone: string; - completedSales: number; - totalRevenueKobo: bigint; - averageMonthlyRevenueKobo: bigint; - disputeRate: number; - verificationTier: string; -} - -export interface TradeFinancingPartnerClient { - /** - * Evaluates the merchant's creditworthiness based on Swifta sales history + partner logic - */ - checkEligibility(merchantData: MerchantCreditData): Promise<{ - eligible: boolean; - maxAmount: bigint; - interestRate: number; - creditScore?: string; - reason?: string; - }>; - - /** - * Books the trade financing loan with the partner. - */ - initiateLoan( - orderId: string, - amount: bigint, - tenure: number, - merchantData: MerchantCreditData, - ): Promise<{ approved: boolean; loanRef: string; disbursementRef: string }>; - - /** - * Fetches the latest repayment status for an active loan - */ - getLoanStatus(loanRef: string): Promise<{ - status: "ACTIVE" | "REPAID" | "DEFAULTED"; - repaid: bigint; - remaining: bigint; - }>; -} diff --git a/apps/backend/src/modules/trade-financing/trade-financing.controller.ts b/apps/backend/src/modules/trade-financing/trade-financing.controller.ts deleted file mode 100644 index d4420c8b..00000000 --- a/apps/backend/src/modules/trade-financing/trade-financing.controller.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Controller, Get, Post, Body, UseGuards } from "@nestjs/common"; -import { TradeFinancingService } from "./trade-financing.service"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { RolesGuard } from "../../common/guards/roles.guard"; -import { Roles } from "../../common/decorators/roles.decorator"; -import { UserRole } from "@swifta/shared"; -import { CurrentUser } from "../../common/decorators/current-user.decorator"; - -import { ApplyLoanDto } from "./dto/apply-loan.dto"; - -@Controller("trade-financing") -@UseGuards(JwtAuthGuard, RolesGuard) -export class TradeFinancingController { - constructor(private readonly financingService: TradeFinancingService) {} - - @Get("eligibility") - @Roles(UserRole.MERCHANT) - async getEligibility(@CurrentUser() user: any) { - return this.financingService.checkEligibility(user.sub); - } - - @Post("apply") - @Roles(UserRole.MERCHANT) - async apply(@CurrentUser() user: any, @Body() body: ApplyLoanDto) { - return this.financingService.applyForFinancing( - user.sub, - body.orderId, - body.tenureDays, - ); - } - - @Get("loans") - @Roles(UserRole.MERCHANT) - async getLoans(@CurrentUser() user: any) { - return this.financingService.getMerchantLoans(user.sub); - } - - @Post("waitlist") - async joinWaitlist(@CurrentUser() user: any) { - return this.financingService.joinWaitlist(user.sub); - } -} diff --git a/apps/backend/src/modules/trade-financing/trade-financing.module.ts b/apps/backend/src/modules/trade-financing/trade-financing.module.ts deleted file mode 100644 index 1cff6cc7..00000000 --- a/apps/backend/src/modules/trade-financing/trade-financing.module.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Module } from "@nestjs/common"; -import { TradeFinancingController } from "./trade-financing.controller"; -import { TradeFinancingService } from "./trade-financing.service"; -import { PrismaModule } from "../../prisma/prisma.module"; -import { MockTradeFinancingClient } from "./mock-trade-financing.client"; -import { ConfigModule, ConfigService } from "@nestjs/config"; - -@Module({ - imports: [PrismaModule, ConfigModule], - controllers: [TradeFinancingController], - providers: [ - TradeFinancingService, - { - provide: "TRADE_FINANCING_PARTNER_CLIENT", - useFactory: (config: ConfigService) => { - const partner = config.get("FINANCING_PARTNER") || "mock"; - if (partner === "mock") { - return new MockTradeFinancingClient(); - } - throw new Error( - `Trade Financing partner '${partner}' is not supported yet.`, - ); - }, - inject: [ConfigService], - }, - ], - exports: [TradeFinancingService], -}) -export class TradeFinancingModule {} diff --git a/apps/backend/src/modules/trade-financing/trade-financing.service.ts b/apps/backend/src/modules/trade-financing/trade-financing.service.ts deleted file mode 100644 index 8f3c50c0..00000000 --- a/apps/backend/src/modules/trade-financing/trade-financing.service.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { - Injectable, - Inject, - BadRequestException, - Logger, - NotFoundException, - ForbiddenException, - InternalServerErrorException, -} from "@nestjs/common"; -import { PrismaService } from "../../prisma/prisma.service"; -import type { - TradeFinancingPartnerClient, - MerchantCreditData, -} from "./trade-financing-partner.interface"; -import { VerificationTier } from "@prisma/client"; - -@Injectable() -export class TradeFinancingService { - private readonly logger = new Logger(TradeFinancingService.name); - - constructor( - private readonly prisma: PrismaService, - @Inject("TRADE_FINANCING_PARTNER_CLIENT") - private partnerClient: TradeFinancingPartnerClient, - ) {} - - private async getMerchantCreditData( - userId: string, - ): Promise { - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { userId }, - include: { - user: true, - orders: { - select: { status: true, totalAmountKobo: true, disputeStatus: true }, - }, - }, - }); - - if (!merchant) { - throw new NotFoundException("Merchant profile not found"); - } - - // Eligibility check for merchant tier - if ( - merchant.verificationTier !== VerificationTier.TIER_2 && - merchant.verificationTier !== VerificationTier.TIER_3 - ) { - throw new ForbiddenException( - "Trade Financing is only available to Level 2 or Level 3 merchants", - ); - } - - const completedSales = merchant.orders.filter( - (o) => o.status === "COMPLETED", - ); - const completedSalesCount = completedSales.length; - - let totalRevenueKobo = BigInt(0); - let disputeCount = 0; - - merchant.orders.forEach((o) => { - if (o.status === "COMPLETED") { - totalRevenueKobo += - typeof o.totalAmountKobo === "bigint" - ? o.totalAmountKobo - : BigInt(o.totalAmountKobo); - } - if (o.disputeStatus !== "NONE") { - disputeCount++; - } - }); - - // Calculate average monthly revenue (last 6 months or since join) - const now = new Date(); - const joinedAt = new Date(merchant.createdAt); - const monthsDiff = Math.max( - 1, - (now.getFullYear() - joinedAt.getFullYear()) * 12 + - now.getMonth() - - joinedAt.getMonth(), - ); - const activeMonths = Math.min(6, monthsDiff); - const averageMonthlyRevenueKobo = totalRevenueKobo / BigInt(activeMonths); - - const totalOrders = merchant.orders.length; - const disputeRate = totalOrders > 0 ? disputeCount / totalOrders : 0; - - return { - userId: merchant.userId, - merchantId: merchant.id, - businessName: merchant.businessName, - email: merchant.user.email, - phone: merchant.user.phone || "", - completedSales: completedSalesCount, - totalRevenueKobo, - averageMonthlyRevenueKobo, - disputeRate, - verificationTier: merchant.verificationTier, - }; - } - - async checkEligibility(userId: string) { - const merchantData = await this.getMerchantCreditData(userId); - - // Hard requirements from CLAUDE-V4.md - if (merchantData.completedSales < 10) { - return { - eligible: false, - maxAmount: 0n, - interestRate: 0, - creditScore: "N/A", - reason: "Need at least 10 completed sales orders.", - }; - } - - const minRevenue = 50000000n; // ₦500k in Kobo - if (merchantData.averageMonthlyRevenueKobo < minRevenue) { - return { - eligible: false, - maxAmount: 0n, - interestRate: 0, - creditScore: "N/A", - reason: "Average monthly revenue must be above ₦500,000.", - }; - } - - if (merchantData.disputeRate > 0.05) { - // Allow max 5% dispute rate for B2B - return { - eligible: false, - maxAmount: 0n, - interestRate: 0, - creditScore: "N/A", - reason: "Dispute rate is too high for trade financing.", - }; - } - - return this.partnerClient.checkEligibility(merchantData); - } - - async applyForFinancing(userId: string, orderId: string, tenureDays: number) { - // 1. Fetch Order (Must be a wholesale order from a supplier) - const order = await this.prisma.order.findUnique({ - where: { id: orderId }, - include: { creditApplication: true, supplierProfile: true }, - }); - - if (!order) { - throw new NotFoundException("Order not found"); - } - if (order.buyerId !== userId) { - throw new ForbiddenException("Unauthorized"); - } - if (!order.supplierId) { - throw new BadRequestException( - "Trade Financing is only available for wholesale supplier orders", - ); - } - if (order.status !== "PENDING_PAYMENT") { - throw new BadRequestException( - "Financing is only available for orders with status PENDING_PAYMENT", - ); - } - if (order.creditApplication) { - throw new BadRequestException( - "A financing application already exists for this order", - ); - } - - // 2. Fetch Merchant Data - const merchantData = await this.getMerchantCreditData(userId); - - if (order.supplierId === merchantData.merchantId) { - throw new BadRequestException( - "You cannot finance an order from your own supplier profile", - ); - } - - // 3. Check Eligibility - const eligibility = await this.checkEligibility(userId); - if (!eligibility.eligible) { - throw new BadRequestException( - `Not eligible for Trade Financing: ${eligibility.reason}`, - ); - } - - const requestedAmount = order.totalAmountKobo; - if (requestedAmount > eligibility.maxAmount) { - throw new BadRequestException( - `Order amount exceeds your Trade Financing limit of ${(Number(eligibility.maxAmount) / 100).toLocaleString()} NGN`, - ); - } - - // 4. Create pending credit application - let COMMISSION_PERCENTAGE = parseFloat( - process.env.TRADE_FINANCING_COMMISSION_PERCENTAGE || "3", - ); - if (isNaN(COMMISSION_PERCENTAGE) || COMMISSION_PERCENTAGE < 0) { - COMMISSION_PERCENTAGE = 3; - } - const commissionKobo = BigInt( - Math.floor((Number(requestedAmount) * COMMISSION_PERCENTAGE) / 100), - ); - - const pendingApplication = await this.prisma.creditApplication.create({ - data: { - buyerId: userId, - merchantId: merchantData.merchantId, - orderId, - requestedAmount, - tenure: tenureDays, - status: "PENDING", - partnerName: process.env.FINANCING_PARTNER || "mock", - commissionKobo, - approvedAmount: 0n, - interestRate: 0, - }, - }); - - // 5. Initiate Loan with Partner - this.logger.log( - `Initiating Trade Financing for merchant ${merchantData.businessName} (Order: ${orderId})`, - ); - const loanResult = await this.partnerClient.initiateLoan( - orderId, // correlation id - requestedAmount, - tenureDays, - merchantData, - ); - - if (!loanResult.approved) { - await this.prisma.creditApplication.update({ - where: { id: pendingApplication.id }, - data: { status: "REJECTED" }, - }); - throw new BadRequestException( - "Financing application was rejected by the partner.", - ); - } - - // 6. Update CreditApplication in DB & Update Order via Transaction - return this.prisma.$transaction(async (tx) => { - const application = await tx.creditApplication.update({ - where: { id: pendingApplication.id }, - data: { - status: "DISBURSED", - partnerRef: loanResult.loanRef, - approvedAmount: requestedAmount, - interestRate: eligibility.interestRate, - partnerDisbursementRef: loanResult.disbursementRef, - }, - }); - - await tx.order.update({ - where: { id: orderId }, - data: { status: "PAID" }, - }); - - await tx.orderEvent.create({ - data: { - orderId, - fromStatus: order.status, - toStatus: "PAID", - triggeredBy: userId, - metadata: { - method: "TRADE_FINANCING", - loanRef: loanResult.loanRef, - }, - }, - }); - - return application; - }); - } - - async getMerchantLoans(userId: string) { - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { userId }, - }); - if (!merchant) { - throw new NotFoundException( - "Merchant profile not found. Trade Financing is only for verified merchants.", - ); - } - - return this.prisma.creditApplication.findMany({ - where: { merchantId: merchant.id }, - include: { - order: { - include: { - supplierProfile: { select: { companyName: true } }, - supplierProduct: { select: { name: true } }, - }, - }, - }, - orderBy: { createdAt: "desc" }, - }); - } - - async joinWaitlist(userId: string): Promise<{ message: string }> { - try { - const user = await this.prisma.user.findUnique({ - where: { id: userId }, - include: { bnplWaitlist: true }, - }); - - if (!user) { - return { message: "User not found" }; - } - - if (user.bnplWaitlist) { - return { message: "You're already on the waitlist!" }; - } - - await this.prisma.bnplWaitlist.create({ - data: { - userId, - email: user.email, - phone: user.phone, - }, - }); - - return { message: "Successfully joined the waitlist!" }; - } catch (error) { - this.logger.error(`Error joining waitlist for user ${userId}:`, error); - throw new InternalServerErrorException("Failed to join waitlist"); - } - } -} diff --git a/apps/backend/src/modules/upload/upload.controller.spec.ts b/apps/backend/src/modules/upload/upload.controller.spec.ts deleted file mode 100644 index b05b9cd4..00000000 --- a/apps/backend/src/modules/upload/upload.controller.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from "@nestjs/testing"; -import { UploadController } from "./upload.controller"; - -describe("UploadController", () => { - let controller: UploadController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [UploadController], - }).compile(); - - controller = module.get(UploadController); - }); - - it("should be defined", () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/apps/backend/src/modules/upload/upload.controller.ts b/apps/backend/src/modules/upload/upload.controller.ts deleted file mode 100644 index 5f9bf7cb..00000000 --- a/apps/backend/src/modules/upload/upload.controller.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { - Controller, - Post, - UseInterceptors, - UploadedFile, - BadRequestException, - UseGuards, - Body, -} from "@nestjs/common"; -import { FileInterceptor } from "@nestjs/platform-express"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { UploadService } from "./upload.service"; - -@Controller("upload") -@UseGuards(JwtAuthGuard) -export class UploadController { - constructor(private readonly uploadService: UploadService) {} - - @Post("document") - @UseInterceptors( - FileInterceptor("file", { - limits: { fileSize: 5 * 1024 * 1024 }, // 5MB - fileFilter: (req, file, callback) => { - if (!file.mimetype.match(/\/(jpg|jpeg|png|pdf)$/)) { - return callback( - new BadRequestException("Only image and PDF files are allowed"), - false, - ); - } - callback(null, true); - }, - }), - ) - async uploadDocument( - @UploadedFile() file: Express.Multer.File, - @Body("transformType") transformType?: string, - ) { - if (!file) { - throw new BadRequestException("No file uploaded"); - } - - try { - const url = await this.uploadService.uploadImageToCloudinary( - file, - "swifta/documents", - transformType, - ); - return { - url, - message: "File uploaded successfully", - }; - } catch (error: any) { - throw new BadRequestException(error.message || "Error uploading file"); - } - } - - @Post("product-image") - @UseInterceptors( - FileInterceptor("file", { - limits: { fileSize: 2 * 1024 * 1024 }, // 2MB - fileFilter: (req, file, callback) => { - if (!file.mimetype.match(/\/(jpg|jpeg|png)$/)) { - return callback( - new BadRequestException("Only image files (JPG, PNG) are allowed"), - false, - ); - } - callback(null, true); - }, - }), - ) - async uploadProductImage( - @UploadedFile() file: Express.Multer.File, - @Body("transformType") transformType?: string, - ) { - if (!file) { - throw new BadRequestException("No image uploaded"); - } - - try { - const url = await this.uploadService.uploadImageToCloudinary( - file, - "swifta/products", - transformType, - ); - return { - url, - message: "Product image uploaded successfully", - }; - } catch (error: any) { - throw new BadRequestException(error.message || "Error uploading image"); - } - } -} diff --git a/apps/backend/src/modules/upload/upload.module.ts b/apps/backend/src/modules/upload/upload.module.ts deleted file mode 100644 index 422a2cf4..00000000 --- a/apps/backend/src/modules/upload/upload.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Module } from "@nestjs/common"; -import { UploadController } from "./upload.controller"; -import { UploadService } from "./upload.service"; -import { MulterModule } from "@nestjs/platform-express"; -import { memoryStorage } from "multer"; - -@Module({ - imports: [ - MulterModule.register({ - storage: memoryStorage(), - limits: { - fileSize: 5 * 1024 * 1024, // 5MB limit - }, - }), - ], - controllers: [UploadController], - providers: [UploadService], - exports: [UploadService], -}) -export class UploadModule {} diff --git a/apps/backend/src/modules/upload/upload.service.spec.ts b/apps/backend/src/modules/upload/upload.service.spec.ts deleted file mode 100644 index 877e59a4..00000000 --- a/apps/backend/src/modules/upload/upload.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from "@nestjs/testing"; -import { UploadService } from "./upload.service"; - -describe("UploadService", () => { - let service: UploadService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [UploadService], - }).compile(); - - service = module.get(UploadService); - }); - - it("should be defined", () => { - expect(service).toBeDefined(); - }); -}); diff --git a/apps/backend/src/modules/upload/upload.service.ts b/apps/backend/src/modules/upload/upload.service.ts deleted file mode 100644 index 2692a5a0..00000000 --- a/apps/backend/src/modules/upload/upload.service.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Injectable, BadRequestException } from "@nestjs/common"; -import { - v2 as cloudinary, - UploadApiResponse, - UploadApiErrorResponse, -} from "cloudinary"; -import { ConfigService } from "@nestjs/config"; -import { Readable } from "stream"; -import { CLOUDINARY_TRANSFORMS } from "./cloudinary-transforms"; - -@Injectable() -export class UploadService { - constructor(private configService: ConfigService) { - // Initialize Cloudinary with environment variables - cloudinary.config({ - cloud_name: this.configService.get("CLOUDINARY_CLOUD_NAME"), - api_key: this.configService.get("CLOUDINARY_API_KEY"), - api_secret: this.configService.get("CLOUDINARY_API_SECRET"), - // or optionally URL: this.configService.get('CLOUDINARY_URL') - }); - } - - async uploadImageToCloudinary( - file: Express.Multer.File, - folder: string = "swifta/merchants", - transformType?: string, - ): Promise { - return new Promise((resolve, reject) => { - const options: any = { folder }; - if ( - transformType && - CLOUDINARY_TRANSFORMS[ - transformType as keyof typeof CLOUDINARY_TRANSFORMS - ] - ) { - options.transformation = - CLOUDINARY_TRANSFORMS[ - transformType as keyof typeof CLOUDINARY_TRANSFORMS - ]; - } - - const upload = cloudinary.uploader.upload_stream( - options, - (error: UploadApiErrorResponse, result: UploadApiResponse) => { - if (error) { - return reject( - new BadRequestException("Failed to upload file to Cloudinary."), - ); - } - resolve(result.secure_url); - }, - ); - - // Convert buffer directly into a readable stream - Readable.from(file.buffer).pipe(upload); - }); - } -} diff --git a/apps/backend/src/modules/ussd/ussd.dto.ts b/apps/backend/src/modules/ussd/ussd.dto.ts deleted file mode 100644 index 789bf206..00000000 --- a/apps/backend/src/modules/ussd/ussd.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { IsString, IsOptional } from "class-validator"; - -export class UssdCallbackDto { - @IsString() - sessionId: string; - - @IsString() - phoneNumber: string; - - @IsString() - serviceCode: string; - - @IsString() - @IsOptional() - text: string; -} diff --git a/apps/backend/src/modules/ussd/ussd.module.ts b/apps/backend/src/modules/ussd/ussd.module.ts deleted file mode 100644 index 33ee1f07..00000000 --- a/apps/backend/src/modules/ussd/ussd.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from "@nestjs/common"; -import { UssdController } from "./ussd.controller"; -import { UssdService } from "./ussd.service"; -import { PrismaModule } from "../../prisma/prisma.module"; -import { PaymentModule } from "../payment/payment.module"; -import { NotificationModule } from "../notification/notification.module"; - -@Module({ - imports: [PrismaModule, PaymentModule, NotificationModule], - controllers: [UssdController], - providers: [UssdService], -}) -export class UssdModule {} diff --git a/apps/backend/src/modules/verification/verification.controller.ts b/apps/backend/src/modules/verification/verification.controller.ts deleted file mode 100644 index 171658ff..00000000 --- a/apps/backend/src/modules/verification/verification.controller.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - Controller, - Post, - Get, - Body, - Param, - UseGuards, - Req, - Query, -} from "@nestjs/common"; -import { VerificationService } from "./verification.service"; -import { - SubmitVerificationDto, - ReviewVerificationDto, -} from "./verification.dto"; -import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard"; -import { RolesGuard } from "../../common/guards/roles.guard"; -import { Roles } from "../../common/decorators/roles.decorator"; -import { CurrentMerchant } from "../../common/decorators/current-merchant.decorator"; -import { UserRole } from "@swifta/shared"; -import { PaginationQueryDto } from "../../common/dto/pagination-query.dto"; - -@Controller() -@UseGuards(JwtAuthGuard, RolesGuard) -export class VerificationController { - constructor(private readonly verificationService: VerificationService) {} - - // ─── MERCHANT ENDPOINTS ────────────────────────────────────────────────── - - @Post("verification/request") - @Roles(UserRole.MERCHANT) - submitRequest( - @Body() dto: SubmitVerificationDto, - @CurrentMerchant() merchantId: string, - ) { - return this.verificationService.submitRequest(merchantId, dto); - } - - @Get("verification/status") - @Roles(UserRole.MERCHANT) - getStatus(@CurrentMerchant() merchantId: string) { - return this.verificationService.getStatus(merchantId); - } - - // ─── ADMIN ENDPOINTS ───────────────────────────────────────────────────── - - @Get("admin/verification/requests") - @Roles(UserRole.SUPER_ADMIN, UserRole.OPERATOR) - getPendingRequests(@Query() query: PaginationQueryDto) { - const page = Number(query.page) || 1; - const limit = Number(query.limit) || 20; - return this.verificationService.getPendingRequests(page, limit); - } - - @Post("admin/verification/requests/:id/review") - @Roles(UserRole.SUPER_ADMIN, UserRole.OPERATOR) - reviewRequest( - @Param("id") requestId: string, - @Body() dto: ReviewVerificationDto, - @Req() req: any, - ) { - return this.verificationService.reviewRequest( - requestId, - req.user.sub, // admin user ID - dto, - ); - } -} diff --git a/apps/backend/src/modules/verification/verification.dto.ts b/apps/backend/src/modules/verification/verification.dto.ts deleted file mode 100644 index df024f14..00000000 --- a/apps/backend/src/modules/verification/verification.dto.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - IsString, - IsNotEmpty, - IsOptional, - IsEnum, - IsUrl, - IsIn, -} from "class-validator"; -import { VerificationIdType } from "@swifta/shared"; - -export class SubmitVerificationDto { - @IsUrl({ protocols: ["https"], require_protocol: true }) - governmentIdUrl: string; - - @IsEnum(VerificationIdType) - @IsNotEmpty() - idType: VerificationIdType; - - @IsOptional() - @IsUrl({ protocols: ["https"], require_protocol: true }) - cacCertUrl?: string; - - @IsNotEmpty() - @IsIn(["TIER_2", "TIER_3"]) - targetTier: "TIER_2" | "TIER_3"; - - @IsOptional() - @IsString() - ninNumber?: string; - - @IsOptional() - @IsUrl({ protocols: ["https"], require_protocol: true }) - proofOfAddressUrl?: string; -} - -export class ReviewVerificationDto { - @IsIn(["APPROVED", "REJECTED"], { - message: "decision must be APPROVED or REJECTED", - }) - decision: "APPROVED" | "REJECTED"; - - @IsOptional() - @IsString() - rejectionReason?: string; -} diff --git a/apps/backend/src/modules/verification/verification.module.ts b/apps/backend/src/modules/verification/verification.module.ts deleted file mode 100644 index 182e2d81..00000000 --- a/apps/backend/src/modules/verification/verification.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from "@nestjs/common"; -import { VerificationService } from "./verification.service"; -import { VerificationController } from "./verification.controller"; -import { PrismaModule } from "../../prisma/prisma.module"; -import { NotificationModule } from "../notification/notification.module"; - -@Module({ - imports: [PrismaModule, NotificationModule], - controllers: [VerificationController], - providers: [VerificationService], - exports: [VerificationService], -}) -export class VerificationModule {} diff --git a/apps/backend/src/modules/verification/verification.service.ts b/apps/backend/src/modules/verification/verification.service.ts deleted file mode 100644 index 3d0982f0..00000000 --- a/apps/backend/src/modules/verification/verification.service.ts +++ /dev/null @@ -1,472 +0,0 @@ -import { - Injectable, - NotFoundException, - BadRequestException, - Logger, -} from "@nestjs/common"; -import { PrismaService } from "../../prisma/prisma.service"; -import { - SubmitVerificationDto, - ReviewVerificationDto, -} from "./verification.dto"; -import { NotificationTriggerService } from "../notification/notification-trigger.service"; -import { - VerificationTier, - OrderStatus, - OrderDisputeStatus, - NotificationType, -} from "@swifta/shared"; - -@Injectable() -export class VerificationService { - private readonly logger = new Logger(VerificationService.name); - - constructor( - private prisma: PrismaService, - private notifications: NotificationTriggerService, - ) {} - - async submitRequest(merchantId: string, dto: SubmitVerificationDto) { - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - }); - - if (!merchant) throw new NotFoundException("Merchant profile not found"); - - if (merchant.verificationTier === VerificationTier.TIER_3) { - throw new BadRequestException( - "You are already at the highest verification tier", - ); - } - - // Validate prerequisite tier - if ( - dto.targetTier === "TIER_2" && - merchant.verificationTier !== VerificationTier.TIER_1 - ) { - throw new BadRequestException("You must be TIER_1 to apply for TIER_2"); - } - if ( - dto.targetTier === "TIER_3" && - merchant.verificationTier !== VerificationTier.TIER_2 - ) { - throw new BadRequestException("You must be TIER_2 to apply for TIER_3"); - } - - // Atomically check for existing PENDING request and create - const request = await this.prisma.$transaction(async (tx) => { - const existing = await tx.verificationRequest.findFirst({ - where: { merchantId, status: "PENDING", targetTier: dto.targetTier }, - }); - - if (existing) { - throw new BadRequestException( - `You already have a ${dto.targetTier} verification request pending review`, - ); - } - - return tx.verificationRequest.create({ - data: { - merchantId, - governmentIdUrl: dto.governmentIdUrl, - idType: dto.idType, - cacCertUrl: dto.cacCertUrl, - targetTier: dto.targetTier, - ninNumber: dto.ninNumber, - status: "PENDING", - }, - include: { - merchant: { - select: { id: true, businessName: true, userId: true }, - }, - }, - }); - }); - - // Notify admins - try { - const admins = await this.prisma.adminProfile.findMany({ - include: { user: true }, - where: { user: { role: { in: ["SUPER_ADMIN", "OPERATOR"] } } }, - }); - const adminIds = admins.map((a) => a.userId); - - if (adminIds.length > 0) { - await this.notifications.triggerNewMerchantSubmission(adminIds, { - merchantId: request.merchantId, - merchantName: request.merchant.businessName, - targetTier: dto.targetTier as string, - }); - } - } catch (e) { - this.logger.error("Failed to notify admins of new verification request"); - } - - return request; - } - - async getStatus(merchantId: string) { - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - include: { user: true }, - }); - - if (!merchant) throw new NotFoundException("Merchant profile not found"); - - const [completedOrdersCount, openDisputesCount, pendingRequest] = - await Promise.all([ - this.prisma.order.count({ - where: { merchantId, status: OrderStatus.COMPLETED }, - }), - this.prisma.order.count({ - where: { - merchantId, - disputeStatus: OrderDisputeStatus.PENDING, - }, - }), - this.prisma.verificationRequest.findFirst({ - where: { merchantId, status: "PENDING" }, - orderBy: { createdAt: "desc" }, - }), - ]); - - const user = merchant.user; - const accountAgeDays = Math.floor( - (Date.now() - new Date(merchant.createdAt).getTime()) / - (1000 * 60 * 60 * 24), - ); - const months = Math.floor(accountAgeDays / 30); - const accountAgeStr = - months >= 1 - ? `${months} month${months > 1 ? "s" : ""}` - : "Less than a month"; - - return { - currentTier: merchant.verificationTier, - tier1: { - status: - merchant.verificationTier !== VerificationTier.UNVERIFIED - ? "COMPLETE" - : "IN_PROGRESS", - requirements: { - emailVerified: user.emailVerified, - phoneVerified: user.phoneVerified, - bankVerified: merchant.bankVerified, - basicInfo: !!( - (user.firstName?.trim() && user.lastName?.trim()) || - merchant.businessName?.trim() - ), - businessAddress: !!merchant.businessAddress?.trim(), - }, - }, - tier2: { - status: - merchant.verificationTier === VerificationTier.TIER_2 || - merchant.verificationTier === VerificationTier.TIER_3 - ? "COMPLETE" - : merchant.verificationTier === VerificationTier.TIER_1 - ? "IN_PROGRESS" - : "LOCKED", - requirements: { - ninVerified: merchant.ninVerified, - completedOrders: { current: completedOrdersCount, required: 5 }, - zeroDisputes: openDisputesCount === 0, - profilePhoto: !!merchant.profileImage, - }, - pendingRequest: - pendingRequest?.targetTier === "TIER_2" ? pendingRequest : null, - }, - tier3: { - status: - merchant.verificationTier === VerificationTier.TIER_3 - ? "COMPLETE" - : merchant.verificationTier === VerificationTier.TIER_2 - ? "IN_PROGRESS" - : "LOCKED", - requirements: { - cacVerified: merchant.cacVerified, - addressVerified: merchant.addressVerified, - completedOrders: { current: completedOrdersCount, required: 20 }, - averageRating: { current: merchant.averageRating, required: 4.0 }, - accountAge: { current: accountAgeStr, required: "3 months" }, - }, - pendingRequest: - pendingRequest?.targetTier === "TIER_3" ? pendingRequest : null, - }, - }; - } - - async getPendingRequests(page: number = 1, limit: number = 20) { - const skip = (page - 1) * limit; - - const [data, total] = await Promise.all([ - this.prisma.verificationRequest.findMany({ - where: { status: "PENDING" }, - skip, - take: limit, - orderBy: { createdAt: "asc" }, - include: { - merchant: { - select: { - id: true, - businessName: true, - verificationTier: true, - user: { - select: { email: true, phone: true }, - }, - }, - }, - }, - }), - this.prisma.verificationRequest.count({ - where: { status: "PENDING" }, - }), - ]); - - return { - data, - meta: { - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }, - }; - } - - async reviewRequest( - requestId: string, - adminId: string, - dto: ReviewVerificationDto, - ) { - const request = await this.prisma.verificationRequest.findUnique({ - where: { id: requestId }, - include: { - merchant: { - include: { user: true }, - }, - }, - }); - - if (!request) throw new NotFoundException("Verification request not found"); - if (request.status !== "PENDING") - throw new BadRequestException("Request is already processed"); - - const merchantId = request.merchantId; - - if (dto.decision === "REJECTED") { - if (!dto.rejectionReason) { - throw new BadRequestException( - "Rejection reason is required when rejecting a request", - ); - } - - await this.prisma.verificationRequest.update({ - where: { id: requestId }, - data: { - status: "REJECTED", - reviewedBy: adminId, - reviewedAt: new Date(), - rejectionReason: dto.rejectionReason, - }, - }); - - this.notifications - .addJob( - request.merchant.userId, - NotificationType.VERIFICATION_REJECTED, - "Verification Request Rejected", - `Your ${request.targetTier} verification request was rejected. Reason: ${dto.rejectionReason}`, - { requestId }, - ) - .catch((err) => - this.logger.error( - `Failed to enqueue rejection notification for request ${requestId}`, - err, - ), - ); - - return { - message: "Verification request rejected", - decision: "REJECTED", - }; - } - - // Handle APPROVAL - await this.prisma.$transaction(async (tx) => { - // Approve the request - await tx.verificationRequest.update({ - where: { id: requestId }, - data: { - status: "APPROVED", - reviewedBy: adminId, - reviewedAt: new Date(), - }, - }); - - // Update verification metadata based on target tier - if (request.targetTier === "TIER_2") { - await tx.merchantProfile.update({ - where: { id: merchantId }, - data: { - ninVerified: true, - ninVerifiedAt: new Date(), - ninVerifiedVia: "MANUAL", - ninNumber: request.ninNumber || request.merchant.ninNumber, - }, - }); - } else if (request.targetTier === "TIER_3") { - await tx.merchantProfile.update({ - where: { id: merchantId }, - data: { - cacVerified: true, - addressVerified: true, // Assuming proof of address document approval - cacVerifiedVia: "MANUAL", - addressVerifiedVia: "MANUAL", - }, - }); - } - }); - - // Check for tier upgrade - const newTier = await this.checkAndUpgradeTier(merchantId); - - return { - message: "Verification request approved", - decision: "APPROVED", - newTier, - }; - } - - async checkAndUpgradeTier(merchantId: string): Promise { - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - include: { user: true }, - }); - - if (!merchant) throw new NotFoundException("Merchant not found"); - - const currentTier = merchant.verificationTier; - - // Check Tier 1 requirements - if (currentTier === VerificationTier.UNVERIFIED) { - const user = merchant.user; - const meetsT1 = - user.emailVerified && - user.phoneVerified && - merchant.bankVerified && - merchant.bankAccountNumber && - (((user.firstName?.trim()?.length ?? 0) > 0 && - (user.lastName?.trim()?.length ?? 0) > 0) || - (merchant.businessName?.trim()?.length ?? 0) > 0) && - (merchant.businessAddress?.trim()?.length ?? 0) > 0; - - if (meetsT1) { - await this.prisma.merchantProfile.update({ - where: { id: merchantId }, - data: { - verificationTier: VerificationTier.TIER_1, - tierUpgradedAt: new Date(), - }, - }); - await this.notifyTierUpgrade(merchantId, VerificationTier.TIER_1); - return VerificationTier.TIER_1; - } - } - - // Check Tier 2 requirements - if (currentTier === VerificationTier.TIER_1) { - const completedOrders = await this.prisma.order.count({ - where: { merchantId, status: OrderStatus.COMPLETED }, - }); - const openDisputes = await this.prisma.order.count({ - where: { merchantId, disputeStatus: { not: "NONE" } }, - }); - - const meetsT2 = - merchant.ninVerified && - completedOrders >= 5 && - openDisputes === 0 && - merchant.profileImage; - - if (meetsT2) { - await this.prisma.merchantProfile.update({ - where: { id: merchantId }, - data: { - verificationTier: VerificationTier.TIER_2, - tierUpgradedAt: new Date(), - verifiedAt: new Date(), - }, - }); - await this.notifyTierUpgrade(merchantId, VerificationTier.TIER_2); - return VerificationTier.TIER_2; - } - } - - // Check Tier 3 requirements - if (currentTier === VerificationTier.TIER_2) { - const completedOrders = await this.prisma.order.count({ - where: { merchantId, status: OrderStatus.COMPLETED }, - }); - - const accountCreatedAt = merchant.createdAt; - const threeMonthsAgo = new Date(); - threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); - - const meetsT3 = - merchant.cacVerified && - merchant.addressVerified && - completedOrders >= 20 && - (merchant.averageRating || 0) >= 4.0 && - accountCreatedAt < threeMonthsAgo; - - if (meetsT3) { - await this.prisma.merchantProfile.update({ - where: { id: merchantId }, - data: { - verificationTier: VerificationTier.TIER_3, - tierUpgradedAt: new Date(), - }, - }); - await this.notifyTierUpgrade(merchantId, VerificationTier.TIER_3); - return VerificationTier.TIER_3; - } - } - - return currentTier as any; - } - - private async notifyTierUpgrade(merchantId: string, tier: VerificationTier) { - const tierNames = { - [VerificationTier.TIER_1]: "Basic Verified", - [VerificationTier.TIER_2]: "Identity Verified", - [VerificationTier.TIER_3]: "Business Verified", - }; - const tierBenefits = { - [VerificationTier.TIER_1]: - "You can now list products and receive orders with escrow protection.", - [VerificationTier.TIER_2]: - "You now have a verified badge, direct payment option (1.5% fee), and higher visibility.", - [VerificationTier.TIER_3]: - "You now have a blue business badge, lowest fees (1%), priority search placement, and premium features.", - }; - - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - }); - - if (!merchant) return; - - await this.notifications - .addJob( - merchant.userId, - NotificationType.TIER_UPGRADED, - `Upgraded to ${tierNames[tier]}`, - tierBenefits[tier], - { tier }, - ) - .catch((err) => - this.logger.error(`Failed to notify tier upgrade: ${err.message}`), - ); - } -} diff --git a/apps/backend/src/modules/whatsapp/image-search.service.ts b/apps/backend/src/modules/whatsapp/image-search.service.ts deleted file mode 100644 index a41ea2e5..00000000 --- a/apps/backend/src/modules/whatsapp/image-search.service.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { PrismaService } from "../../prisma/prisma.service"; -import { WhatsAppInteractiveService } from "./whatsapp-interactive.service"; - -@Injectable() -export class ImageSearchService { - private readonly logger = new Logger(ImageSearchService.name); - private readonly googleCloudApiKey: string; - private readonly geminiApiKey: string; - private readonly primarySearchApi: string; - private readonly fallbackSearchApi: string; - private readonly metaAccessToken: string; - - constructor( - private configService: ConfigService, - private prisma: PrismaService, - private interactiveService: WhatsAppInteractiveService, - ) { - this.googleCloudApiKey = - this.configService.get("GOOGLE_CLOUD_API_KEY") || ""; - this.geminiApiKey = this.configService.get("GEMINI_API_KEY") || ""; - this.primarySearchApi = - this.configService.get("IMAGE_SEARCH_PRIMARY") || "cloud_vision"; - this.fallbackSearchApi = - this.configService.get("IMAGE_SEARCH_FALLBACK") || "gemini"; - this.metaAccessToken = - this.configService.get("whatsapp.accessToken") || ""; - } - - private maskIdentifier(identifier: string): string { - if (!identifier) return ""; - return identifier.length > 4 - ? `****${identifier.substring(identifier.length - 4)}` - : "****"; - } - - async handleImageSearch(phone: string, imageId: string): Promise { - try { - await this.interactiveService.sendTextMessage( - phone, - "Analyzing image. Please wait...", - ); - - // 1. Download image from Meta - const imageResult = await this.downloadMetaImage(imageId); - if (!imageResult) { - await this.interactiveService.sendTextMessage( - phone, - "Sorry, I couldn't download the image. Please try again.", - ); - return; - } - - const { base64Data, mimeType } = imageResult; - - // 2. Identify image content - let extractedTerms = await this.analyzeImage( - base64Data, - mimeType, - this.primarySearchApi, - ); - if (!extractedTerms && this.fallbackSearchApi) { - this.logger.log( - `Primary API failed, falling back to ${this.fallbackSearchApi}`, - ); - extractedTerms = await this.analyzeImage( - base64Data, - mimeType, - this.fallbackSearchApi, - ); - } - - if (!extractedTerms) { - await this.interactiveService.sendTextMessage( - phone, - "I couldn't identify the product in this photo. ⚠️ Please make sure the item is clearly visible and try sending it again.", - ); - return; - } - - this.logger.log(`Extracted terms: ${extractedTerms.join(", ")}`); - - // 3. Search database - const products = await this.searchProducts(extractedTerms); - - // 4. Send response - if (products.length === 0) { - // Feature 4: Smart "No Results" Fallback - const bodyText = `I identified the item as *${extractedTerms[0]}*, but I couldn't find a direct match right now. 📦\n\nHere are some other popular products you might like instead:`; - - const fallbackProducts = await this.prisma.product.findMany({ - where: { isActive: true }, - include: { merchantProfile: { select: { businessName: true } } }, - take: 5, - orderBy: { createdAt: "desc" }, - }); - - if (fallbackProducts.length > 0) { - const rows = fallbackProducts.map((p) => ({ - id: `buy_${p.id}_1`, - title: p.name.substring(0, 24), - description: - `₦${(Number(p.retailPriceKobo || p.pricePerUnitKobo || 0) / 100).toLocaleString("en-NG")} | ${p.merchantProfile?.businessName || "Verified Shop"}`.substring( - 0, - 72, - ), - })); - await this.interactiveService.sendListMessage( - phone, - bodyText, - "Alternative Products", - [ - { title: "Suggested Items", rows }, - { - title: "Other Options", - rows: [ - { - id: "browse_categories", - title: "Browse Categories", - description: "Explore by type", - }, - { - id: "search_products", - title: "Try Text Search", - description: "Search by keyword", - }, - ], - }, - ], - ); - return; - } - - const bodyTextFallback = `I identified the item as *${extractedTerms[0]}*, but I couldn't find a direct match. 📦\n\nWould you like to browse our top categories or search for a different product?`; - await this.interactiveService.sendReplyButtons( - phone, - bodyTextFallback, - [ - { id: "browse_categories", title: "Browse Categories" }, - { id: "search_products", title: "Try Text Search" }, - ], - ); - return; - } - - const rows = products.slice(0, 10).map((p) => ({ - id: `buy_${p.id}_1`, - title: p.name.substring(0, 24), - description: - `₦${(Number(p.retailPriceKobo || p.pricePerUnitKobo || 0) / 100).toLocaleString("en-NG")} | ${p.merchantProfile?.businessName || "Verified Shop"}`.substring( - 0, - 72, - ), - })); - - await this.interactiveService.sendListMessage( - phone, - `Matching products for "${extractedTerms[0]}":`, - "View Products", - [{ title: "Matches", rows }], - ); - } catch (error) { - this.logger.error( - `Image search failed for ${this.maskIdentifier(phone)}:`, - error, - ); - await this.interactiveService.sendTextMessage( - phone, - "An error occurred during image search. Please try again later.", - ); - } - } - - public async downloadMetaImage( - imageId: string, - ): Promise<{ base64Data: string; mimeType: string } | null> { - try { - const controller1 = new AbortController(); - const timeout1 = setTimeout(() => controller1.abort(), 10000); - - // Get URL - let urlResponse; - try { - urlResponse = await fetch( - `https://graph.facebook.com/v21.0/${imageId}`, - { - headers: { Authorization: `Bearer ${this.metaAccessToken}` }, - signal: controller1.signal as RequestInit["signal"], - }, - ); - } finally { - clearTimeout(timeout1); - } - if (!urlResponse.ok) throw new Error("Failed to get image URL"); - const urlData = await urlResponse.json(); - - const controller2 = new AbortController(); - const timeout2 = setTimeout(() => controller2.abort(), 10000); - - // Download binary - let imageResponse; - try { - imageResponse = await fetch(urlData.url, { - headers: { Authorization: `Bearer ${this.metaAccessToken}` }, - signal: controller2.signal as RequestInit["signal"], - }); - } finally { - clearTimeout(timeout2); - } - - if (!imageResponse.ok) throw new Error("Failed to download image data"); - - const mimeType = - urlData.mime_type || - (imageResponse.headers.get("content-type") || "image/jpeg") - .split(";")[0] - .trim(); - const arrayBuffer = await imageResponse.arrayBuffer(); - const base64Data = Buffer.from(arrayBuffer).toString("base64"); - - return { base64Data, mimeType }; - } catch (error) { - this.logger.error("Meta image download error:", error); - return null; - } - } - - private async analyzeImage( - base64Data: string, - mimeType: string, - apiType: string, - ): Promise { - if (apiType === "cloud_vision" && this.googleCloudApiKey) { - return this.callCloudVision(base64Data); - } - if (apiType === "gemini" && this.geminiApiKey) { - return this.callGeminiVision(base64Data, mimeType); - } - return null; - } - - private async callCloudVision(base64Data: string): Promise { - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10000); - - const response = await fetch( - `https://vision.googleapis.com/v1/images:annotate?key=${this.googleCloudApiKey}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - requests: [ - { - image: { content: base64Data }, - features: [ - { type: "TEXT_DETECTION", maxResults: 1 }, - { type: "OBJECT_LOCALIZATION", maxResults: 3 }, - { type: "LABEL_DETECTION", maxResults: 5 }, - ], - }, - ], - }), - signal: controller.signal as RequestInit["signal"], - }, - ); - - clearTimeout(timeout); - - if (!response.ok) return null; - const data = await response.json(); - - // 1. Extract object detection results (highest confidence for product identification) - const objects = - data.responses[0]?.localizedObjectAnnotations - ?.filter((o: { score: number }) => o.score > 0.5) - ?.map((o: { name: string }) => o.name) || []; - - // 2. Extract label annotations (general image classification) - const labels = - data.responses[0]?.labelAnnotations - ?.filter((l: { score: number }) => l.score > 0.7) - ?.map((l: { description: string }) => l.description) || []; - - // 3. OCR text — LAST RESORT ONLY. Only used if objects AND labels are both empty. - let textTerms: string[] = []; - if (objects.length === 0 && labels.length === 0) { - const textBlock = - data.responses[0]?.textAnnotations?.[0]?.description || ""; - if (textBlock) { - textTerms = textBlock - .split(/\s+/) - .filter((w: string) => w.length > 3 && !w.match(/^[0-9]+$/)) - .slice(0, 3); - } - } - - // Build final terms: Objects first, then Labels, then OCR fallback - const terms = Array.from(new Set([...objects, ...labels, ...textTerms])); - this.logger.debug( - `Vision results — objects: [${objects.join(", ")}], labels: [${labels.join(", ")}], ocrFallback: [${textTerms.join(", ")}]`, - ); - return terms.length > 0 ? terms : null; - } catch (error) { - this.logger.error("Cloud Vision error:", error); - return null; - } - } - - private async callGeminiVision( - base64Data: string, - mimeType: string, - ): Promise { - try { - const modelName = - this.configService.get("GEMINI_VISION_MODEL") || - "gemini-1.5-flash"; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10000); - - const response = await fetch( - `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent?key=${this.geminiApiKey}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - contents: [ - { - parts: [ - { - text: "Identify the main product naturally in this image. Output a comma separated list of up to 5 concise search keywords (e.g., iPhone, Sneakers, Laptop). Just the keywords, no other text.", - }, - { - inlineData: { - mimeType: mimeType, - data: base64Data, - }, - }, - ], - }, - ], - }), - signal: controller.signal as RequestInit["signal"], - }, - ); - - clearTimeout(timeout); - - if (!response.ok) return null; - const data = await response.json(); - const text = data.candidates?.[0]?.content?.parts?.[0]?.text; - - if (!text) return null; - return text - .split(",") - .map((t: string) => t.trim()) - .filter(Boolean); - } catch (error) { - this.logger.error("Gemini Vision error:", error); - return null; - } - } - - private async searchProducts(terms: string[]) { - if (!terms || terms.length === 0) return []; - - const searchConditions = terms.map((term) => ({ - name: { contains: term, mode: "insensitive" as any }, - })); - - return this.prisma.product.findMany({ - where: { - AND: [{ isActive: true }, { OR: searchConditions }], - }, - include: { merchantProfile: { select: { businessName: true } } }, - take: 20, - orderBy: { createdAt: "desc" }, - }); - } -} diff --git a/apps/backend/src/modules/whatsapp/whatsapp-auth.service.ts b/apps/backend/src/modules/whatsapp/whatsapp-auth.service.ts deleted file mode 100644 index 989b4af0..00000000 --- a/apps/backend/src/modules/whatsapp/whatsapp-auth.service.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { PrismaService } from "../../prisma/prisma.service"; -import { RedisService } from "../../redis/redis.service"; -import { EmailService } from "../email/email.service"; -import { - SessionState, - WA_SESSION_PREFIX, - WA_OTP_PREFIX, - SESSION_TTL, - OTP_TTL, - WELCOME_MESSAGE, - ROLE_SELECTED_MESSAGE, - LINK_OTP_SENT, - LINK_SUCCESS, - ALREADY_LINKED, - EMAIL_NOT_FOUND, - INVALID_OTP, - OTP_EXPIRED, -} from "./whatsapp.constants"; - -interface SessionData { - state: SessionState; - data: Record; -} - -// Helper to mask phone numbers — only show last 4 digits -function maskPhone(phone: string): string { - if (phone.length <= 4) return "****"; - return `****${phone.slice(-4)}`; -} - -// Helper to mask email -function maskEmail(email: string): string { - if (!email) return ""; - const [local, domain] = email.split("@"); - if (!domain) return "***"; - if (local.length <= 2) return `${local[0]}***@${domain}`; - return `${local[0]}${local[1]}***@${domain}`; -} - -/** - * Handles phone-number → merchant-account linking via WhatsApp. - * - * Flow: - * 1. Unknown phone → prompt for email - * 2. Email received → look up merchant, send OTP to email - * 3. OTP received → verify, create WhatsAppLink - */ -@Injectable() -export class WhatsAppAuthService { - private readonly logger = new Logger(WhatsAppAuthService.name); - - constructor( - private configService: ConfigService, - private prisma: PrismaService, - private redisService: RedisService, - private emailService: EmailService, - ) {} - - /** - * Check if a phone number is linked to a merchant. - * Returns the merchantId if linked & active, null otherwise. - */ - async resolvePhone(phone: string): Promise { - try { - const link = await this.prisma.whatsAppLink.findUnique({ - where: { phone }, - include: { - user: { - include: { merchantProfile: { select: { id: true } } }, - }, - }, - }); - - if (!link || !link.isActive) return null; - - // Return merchantId if available, otherwise userId - return link.user.merchantProfile?.id || link.userId; - } catch (error) { - this.logger.error( - `Error resolving phone ${maskPhone(phone)}: ${error instanceof Error ? error.message : error}`, - ); - return null; - } - } - - /** - * Check if a phone number is linked to a supplier. - * Returns the supplierId if linked & active, null otherwise. - */ - async resolveSupplierPhone(phone: string): Promise { - try { - const link = await this.prisma.whatsAppSupplierLink.findUnique({ - where: { phone }, - select: { supplierId: true, isActive: true }, - }); - return link?.isActive ? link.supplierId : null; - } catch (error) { - this.logger.error( - `Error resolving supplier phone ${maskPhone(phone)}: ${error instanceof Error ? error.message : error}`, - ); - return null; - } - } - - /** - * Handle a message from an unlinked phone number. - * Manages the multi-step linking flow via Redis session state. - * - * @returns The response message to send back to the user - */ - async handleLinkingFlow(phone: string, messageText: string): Promise { - const sessionKey = `${WA_SESSION_PREFIX}${phone}`; - - try { - // Check for existing session - const sessionRaw = await this.redisService.get(sessionKey); - - if (!sessionRaw) { - // No session — start the linking flow - const session: SessionData = { - state: SessionState.AWAITING_ROLE, - data: {}, - }; - await this.redisService.set( - sessionKey, - JSON.stringify(session), - SESSION_TTL, - ); - return ( - this.configService.get("whatsapp.welcomeMessage") || WELCOME_MESSAGE - ); - } - - const session: SessionData = JSON.parse(sessionRaw); - - switch (session.state) { - case SessionState.AWAITING_ROLE: - return this.handleRoleStep(phone, messageText, sessionKey, session); - - case SessionState.AWAITING_EMAIL: - return this.handleEmailStep(phone, messageText, sessionKey); - - case SessionState.AWAITING_OTP: - return this.handleOtpStep(phone, messageText, sessionKey, session); - - default: - // Corrupt session — restart - await this.redisService.del(sessionKey); - return ( - this.configService.get("whatsapp.welcomeMessage") || - WELCOME_MESSAGE - ); - } - } catch (error) { - this.logger.error( - `Error in linking flow for ${maskPhone(phone)}: ${error instanceof Error ? error.message : error}`, - ); - await this.redisService.del(sessionKey); - return ( - this.configService.get("whatsapp.welcomeMessage") || WELCOME_MESSAGE - ); - } - } - - // ----------------------------------------------------------------------- - // Step: Role selection - // ----------------------------------------------------------------------- - private async handleRoleStep( - phone: string, - messageText: string, - sessionKey: string, - session: SessionData, - ): Promise { - const text = messageText.trim(); - if (text === "1") { - session.data.selectedRole = "BUYER"; - } else if (text === "2") { - session.data.selectedRole = "MERCHANT"; - } else { - return "Please reply with 1 for Buyer or 2 for Merchant."; - } - - session.state = SessionState.AWAITING_EMAIL; - await this.redisService.set( - sessionKey, - JSON.stringify(session), - SESSION_TTL, - ); - - return ROLE_SELECTED_MESSAGE; - } - - // ----------------------------------------------------------------------- - // Step: Email validation - // ----------------------------------------------------------------------- - private async handleEmailStep( - phone: string, - email: string, - sessionKey: string, - ): Promise { - // Basic email format check - const emailLower = email.toLowerCase().trim(); - if (!emailLower.includes("@") || !emailLower.includes(".")) { - return "That doesn't look like a valid email address. Please reply with your registered email."; - } - - const user = await this.prisma.user.findUnique({ - where: { email: emailLower }, - select: { - id: true, - role: true, - firstName: true, - }, - }); - - if (!user) { - return EMAIL_NOT_FOUND; - } - - const actualRole = user.role; - - // Validate role context - suppliers can link even if not explicitly chosen - if ( - actualRole !== "MERCHANT" && - actualRole !== "BUYER" && - actualRole !== "SUPPLIER" - ) { - return EMAIL_NOT_FOUND; - } - - // Check if this user is already linked to a different phone based on their role - let isLinked = false; - let supplierId: string | undefined; - - if (actualRole === "MERCHANT") { - const link = await this.prisma.whatsAppLink.findUnique({ - where: { userId: user.id }, - }); - if (link && link.phone !== phone) isLinked = true; - } else if (actualRole === "BUYER") { - const link = await this.prisma.whatsAppBuyerLink.findUnique({ - where: { buyerId: user.id }, - }); - if (link && link.phone !== phone) isLinked = true; - } else if (actualRole === "SUPPLIER") { - const profile = await this.prisma.supplierProfile.findUnique({ - where: { userId: user.id }, - }); - if (!profile) return EMAIL_NOT_FOUND; - supplierId = profile.id; // Save for OTP step - const link = await this.prisma.whatsAppSupplierLink.findUnique({ - where: { supplierId: profile.id }, - }); - if (link && link.phone !== phone) isLinked = true; - } - - if (isLinked) { - return ALREADY_LINKED; - } - - // Generate 6-digit OTP - const otp = Math.floor(100000 + Math.random() * 900000).toString(); - const otpKey = `${WA_OTP_PREFIX}${phone}`; - await this.redisService.set(otpKey, otp, OTP_TTL); - - // Send OTP to merchant's email - try { - await this.emailService.sendVerificationOTP(emailLower, otp); - } catch (error) { - this.logger.error( - `Failed to send OTP email to ${maskEmail(emailLower)}: ${error instanceof Error ? error.message : error}`, - ); - return "I couldn't send the verification email right now. Please try again in a moment."; - } - - // Update session to AWAITING_OTP - const session: SessionData = { - state: SessionState.AWAITING_OTP, - data: { - email: emailLower, - userId: user.id, - userName: user.firstName, - role: user.role, - supplierId, - }, - }; - await this.redisService.set( - sessionKey, - JSON.stringify(session), - SESSION_TTL, - ); - - // Mask email for display - const maskedEmail = this.maskEmail(emailLower); - return LINK_OTP_SENT(maskedEmail); - } - - // ----------------------------------------------------------------------- - // Step: OTP verification - // ----------------------------------------------------------------------- - private async handleOtpStep( - phone: string, - otpInput: string, - sessionKey: string, - session: SessionData, - ): Promise { - const otpKey = `${WA_OTP_PREFIX}${phone}`; - const storedOtp = await this.redisService.get(otpKey); - - if (!storedOtp) { - // OTP expired — restart flow - await this.redisService.del(sessionKey); - return OTP_EXPIRED; - } - - const otpClean = otpInput.trim().replace(/\s/g, ""); - - if (otpClean !== storedOtp) { - return INVALID_OTP; - } - - // OTP matches — create appropriate WhatsAppLink - try { - if (session.data.role === "MERCHANT") { - await this.prisma.whatsAppLink.upsert({ - where: { phone }, - update: { userId: session.data.userId, isActive: true }, - create: { phone, userId: session.data.userId, isActive: true }, - }); - } else if (session.data.role === "BUYER") { - await this.prisma.whatsAppBuyerLink.upsert({ - where: { phone }, - update: { buyerId: session.data.userId, isActive: true }, - create: { phone, buyerId: session.data.userId, isActive: true }, - }); - } else if (session.data.role === "SUPPLIER" && session.data.supplierId) { - await this.prisma.whatsAppSupplierLink.upsert({ - where: { phone }, - update: { supplierId: session.data.supplierId, isActive: true }, - create: { - phone, - supplierId: session.data.supplierId, - isActive: true, - }, - }); - } - } catch (error) { - this.logger.error( - `Failed to create WhatsAppLink for ${maskPhone(phone)}: ${error instanceof Error ? error.message : error}`, - ); - await this.redisService.del(sessionKey); - return "Something went wrong linking your account. Please try again."; - } - - // Clean up session & OTP - await this.redisService.del(sessionKey); - await this.redisService.del(otpKey); - - this.logger.log( - `WhatsApp linked: phone=${maskPhone(phone)}, userId=${session.data.userId}, role=${session.data.role}`, - ); - return LINK_SUCCESS(session.data.userName || "there", session.data.role); - } - - // ----------------------------------------------------------------------- - // Helpers - // ----------------------------------------------------------------------- - private maskEmail(email: string): string { - const [local, domain] = email.split("@"); - if (local.length <= 2) return `${local[0]}***@${domain}`; - return `${local[0]}${local[1]}***@${domain}`; - } -} diff --git a/apps/backend/src/modules/whatsapp/whatsapp-buyer-auth.service.ts b/apps/backend/src/modules/whatsapp/whatsapp-buyer-auth.service.ts deleted file mode 100644 index 472b180d..00000000 --- a/apps/backend/src/modules/whatsapp/whatsapp-buyer-auth.service.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { PrismaService } from "../../prisma/prisma.service"; -import { RedisService } from "../../redis/redis.service"; -import { EmailService } from "../email/email.service"; -import { - SessionState, - WA_SESSION_PREFIX, - WA_OTP_PREFIX, - SESSION_TTL, - OTP_TTL, - EMAIL_NOT_FOUND, - INVALID_OTP, - OTP_EXPIRED, -} from "./whatsapp.constants"; - -const BUYER_WELCOME_MESSAGE = `Welcome to Swifta Buyer Bot! 🛒 - -To start placing orders natively right here on WhatsApp, please reply with your registered email address to link your buyer profile.`; - -const BUYER_LINK_SUCCESS = (buyerName: string) => - `You're all set, ${buyerName}! 🎉\n\nYou can now tell me what you want to buy, or say "track my order" if you have any active deliveries.`; - -const BUYER_ALREADY_LINKED = `This phone number is already linked to a Swifta buyer account.`; - -interface SessionData { - state: SessionState; - data: Record; -} - -// Helper to mask phone numbers — only show last 4 digits -function maskPhone(phone: string): string { - if (phone.length <= 4) return "****"; - return `****${phone.slice(-4)}`; -} - -// Helper to mask email -function maskEmail(email: string): string { - if (!email) return ""; - const [local, domain] = email.split("@"); - if (!domain) return "***"; - if (local.length <= 2) return `${local[0]}***@${domain}`; - return `${local[0]}${local[1]}***@${domain}`; -} - -@Injectable() -export class WhatsAppBuyerAuthService { - private readonly logger = new Logger(WhatsAppBuyerAuthService.name); - - constructor( - private configService: ConfigService, - private prisma: PrismaService, - private redisService: RedisService, - private emailService: EmailService, - ) {} - - /** - * Check if a phone number is linked to a buyer. - * Returns the buyerId if linked & active, null otherwise. - */ - async resolvePhone(phone: string): Promise { - try { - const link = await this.prisma.whatsAppBuyerLink.findUnique({ - where: { phone }, - select: { buyerId: true, isActive: true }, - }); - return link?.isActive ? link.buyerId : null; - } catch (error) { - this.logger.error( - `Error resolving phone ${maskPhone(phone)}: ${error instanceof Error ? error.message : error}`, - ); - return null; - } - } - - /** - * Handle unlinked buyer - */ - async handleLinkingFlow(phone: string, messageText: string): Promise { - const sessionKey = `${WA_SESSION_PREFIX}buyer_${phone}`; - - try { - const sessionRaw = await this.redisService.get(sessionKey); - - if (!sessionRaw) { - const session: SessionData = { - state: SessionState.AWAITING_EMAIL, - data: {}, - }; - await this.redisService.set( - sessionKey, - JSON.stringify(session), - SESSION_TTL, - ); - return ( - this.configService.get("whatsapp.welcomeMessage") || - BUYER_WELCOME_MESSAGE - ); - } - - const session: SessionData = JSON.parse(sessionRaw); - - switch (session.state) { - case SessionState.AWAITING_EMAIL: - return this.handleEmailStep(phone, messageText, sessionKey); - case SessionState.AWAITING_OTP: - return this.handleOtpStep(phone, messageText, sessionKey, session); - default: - await this.redisService.del(sessionKey); - return ( - this.configService.get("whatsapp.welcomeMessage") || - BUYER_WELCOME_MESSAGE - ); - } - } catch (error) { - this.logger.error( - `Error in linking flow for ${maskPhone(phone)}: ${error instanceof Error ? error.message : error}`, - ); - await this.redisService.del(sessionKey); - return ( - this.configService.get("whatsapp.welcomeMessage") || - BUYER_WELCOME_MESSAGE - ); - } - } - - private async handleEmailStep( - phone: string, - email: string, - sessionKey: string, - ): Promise { - const emailLower = email.toLowerCase().trim(); - if (!emailLower.includes("@") || !emailLower.includes(".")) { - return "That doesn't look like a valid email address. Please reply with your registered email."; - } - - // Look up the buyer - const user = await this.prisma.user.findUnique({ - where: { email: emailLower }, - select: { - id: true, - role: true, - firstName: true, - }, - }); - - if (!user || user.role !== "BUYER") { - return EMAIL_NOT_FOUND; - } - - // Check existing phone links - const existingLink = await this.prisma.whatsAppBuyerLink.findUnique({ - where: { buyerId: user.id }, - }); - if (existingLink && existingLink.phone !== phone) { - return BUYER_ALREADY_LINKED; - } - - const otp = Math.floor(100000 + Math.random() * 900000).toString(); - const otpKey = `${WA_OTP_PREFIX}buyer_${phone}`; - await this.redisService.set(otpKey, otp, OTP_TTL); - - try { - await this.emailService.sendVerificationOTP(emailLower, otp); - } catch (error) { - this.logger.error( - `Failed to send OTP to ${maskEmail(emailLower)}: ${error instanceof Error ? error.message : error}`, - ); - return "I couldn't send the email. Please try again in a moment."; - } - - const session: SessionData = { - state: SessionState.AWAITING_OTP, - data: { - email: emailLower, - buyerId: user.id, - buyerName: user.firstName, - }, - }; - await this.redisService.set( - sessionKey, - JSON.stringify(session), - SESSION_TTL, - ); - - const maskedEmail = this.maskEmail(emailLower); - return `I've sent a 6-digit verification code to ${maskedEmail}. Please reply with the code.`; - } - - private async handleOtpStep( - phone: string, - otpInput: string, - sessionKey: string, - session: SessionData, - ): Promise { - const otpKey = `${WA_OTP_PREFIX}buyer_${phone}`; - const storedOtp = await this.redisService.get(otpKey); - - if (!storedOtp) { - await this.redisService.del(sessionKey); - return OTP_EXPIRED; - } - - const otpClean = otpInput.trim().replace(/\s/g, ""); - - if (otpClean !== storedOtp) { - return INVALID_OTP; - } - - try { - // Check if this phone is already linked to a DIFFERENT buyer account - const existingLinkByPhone = - await this.prisma.whatsAppBuyerLink.findUnique({ - where: { phone }, - }); - if ( - existingLinkByPhone && - existingLinkByPhone.buyerId !== session.data.buyerId - ) { - await this.redisService.del(sessionKey); - return "This phone number is already linked to another account. Please contact support if this is an error."; - } - - await this.prisma.whatsAppBuyerLink.upsert({ - where: { phone }, - update: { - buyerId: session.data.buyerId, - isActive: true, - linkedAt: new Date(), - }, - create: { - phone, - buyerId: session.data.buyerId, - isActive: true, - }, - }); - } catch (error) { - this.logger.error( - `Failed to create WhatsAppBuyerLink for ${maskPhone(phone)}: ${error instanceof Error ? error.message : error}`, - ); - await this.redisService.del(sessionKey); - return "Something went wrong. Please try again."; - } - - await this.redisService.del(sessionKey); - await this.redisService.del(otpKey); - - this.logger.log( - `Buyer WhatsApp linked: phone=${maskPhone(phone)}, buyerId=${session.data.buyerId}`, - ); - return BUYER_LINK_SUCCESS(session.data.buyerName || "Buyer"); - } - - private maskEmail(email: string): string { - return maskEmail(email); - } -} diff --git a/apps/backend/src/modules/whatsapp/whatsapp-buyer-intent.service.ts b/apps/backend/src/modules/whatsapp/whatsapp-buyer-intent.service.ts deleted file mode 100644 index a2d6d811..00000000 --- a/apps/backend/src/modules/whatsapp/whatsapp-buyer-intent.service.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { GoogleGenerativeAI } from "@google/generative-ai"; -import { ParsedIntent } from "./whatsapp-intent.service"; -import { - BUYER_NUMBER_INTENT_MAP, - BUYER_GEMINI_FUNCTION_DECLARATIONS, -} from "./whatsapp-buyer.constants"; - -@Injectable() -export class WhatsAppBuyerIntentService { - private readonly logger = new Logger(WhatsAppBuyerIntentService.name); - private genAI: GoogleGenerativeAI | null = null; - - constructor(private configService: ConfigService) { - const apiKey = this.configService.get("GEMINI_API_KEY"); - if (apiKey && apiKey !== "your_google_ai_studio_key_here") { - this.genAI = new GoogleGenerativeAI(apiKey); - } - } - - async parseIntent(messageText: string): Promise { - const text = messageText.trim(); - - if (BUYER_NUMBER_INTENT_MAP[text]) { - return { functionName: BUYER_NUMBER_INTENT_MAP[text], params: {} }; - } - - const lower = text.toLowerCase(); - if (["menu", "help", "start", "hi", "hello"].includes(lower)) { - return { functionName: "show_menu", params: {} }; - } - - if (this.genAI) { - try { - const intent = await this.parseWithGemini(text); - return intent; - } catch (error) { - this.logger.error(`Gemini intent parsing failed for buyer: ${error}`); - return { functionName: "friendly_fallback", params: {} }; - } - } - - // Keyword fallback without AI - if ( - lower.includes("search") || - lower.includes("buy") || - lower.includes("need") - ) { - return { functionName: "search_products", params: { query: text } }; - } - if ( - lower.includes("category") || - lower.includes("browse") || - lower.includes("categories") - ) { - return { functionName: "browse_categories", params: {} }; - } - if ( - lower.includes("merchant") || - lower.includes("seller") || - lower.includes("shop") || - lower.includes("store") - ) { - return { functionName: "search_merchants", params: { query: text } }; - } - if (lower.includes("where") || lower.includes("track")) { - return { functionName: "get_active_orders", params: {} }; - } - - return { functionName: "show_menu", params: {} }; - } - - private async parseWithGemini(userMessage: string): Promise { - if (!this.genAI) throw new Error("Gemini AI is not initialized"); - - const systemPrompt = - this.configService.get("whatsapp.buyerSystemPrompt") || ""; - - const modelName = - this.configService.get("GEMINI_MODEL") || "gemini-2.5-flash"; - const model = this.genAI.getGenerativeModel({ - model: modelName, - systemInstruction: systemPrompt, - tools: [{ functionDeclarations: BUYER_GEMINI_FUNCTION_DECLARATIONS }], - }); - - const chat = model.startChat(); - const result = await chat.sendMessage(userMessage); - const functionCall = result.response.functionCalls()?.[0]; - - if (functionCall) { - return { - functionName: functionCall.name, - params: functionCall.args as Record, - }; - } - - return { functionName: "friendly_fallback", params: {} }; - } -} diff --git a/apps/backend/src/modules/whatsapp/whatsapp-buyer.constants.ts b/apps/backend/src/modules/whatsapp/whatsapp-buyer.constants.ts deleted file mode 100644 index 962359ce..00000000 --- a/apps/backend/src/modules/whatsapp/whatsapp-buyer.constants.ts +++ /dev/null @@ -1,146 +0,0 @@ -// --------------------------------------------------------------------------- -// WhatsApp Buyer Bot — Constants, templates & Gemini declarations -// --------------------------------------------------------------------------- -import { SchemaType, FunctionDeclaration } from "@google/generative-ai"; - -export const BUYER_MAIN_MENU = `Welcome to the Swifta Marketplace! ✅ - -I'm your personal shopping assistant. I can help you find products, discover local merchants, and track your deliveries in real-time. - -How can I assist you today?`; - -export const BUYER_FRIENDLY_FALLBACK = `I'm sorry, I didn't quite catch that. 📦 - -I can help you find products, discover merchants, track orders, or connect with support. - -What would you like to do?`; - -export const BUYER_NUMBER_INTENT_MAP: Record = { - "1": "search_products", - "2": "search_merchants", - "3": "get_active_orders", - "4": "confirm_delivery", - "5": "get_order_history", - "6": "contact_support", -}; - -// --------------------------------------------------------------------------- -// Gemini buyer system prompt is now managed via .env (WHATSAPP_BUYER_SYSTEM_PROMPT) -// --------------------------------------------------------------------------- - -export const BUYER_GEMINI_FUNCTION_DECLARATIONS: FunctionDeclaration[] = [ - { - name: "search_products", - description: - "Search the global Swifta catalogue for a product based on name and optionally location.", - parameters: { - type: SchemaType.OBJECT, - properties: { - query: { - type: SchemaType.STRING, - description: - "The name of the item they want to buy (e.g. 'phone', 'shoes', 'groceries')", - }, - location: { - type: SchemaType.STRING, - description: - "The specific location they want to buy it in (e.g. 'Lekki', 'Ikeja', 'Abuja')", - }, - quantity: { - type: SchemaType.NUMBER, - description: "How many items they want to buy", - }, - }, - required: ["query"], - }, - }, - { - name: "search_merchants", - description: - "Search for verified merchants and shops on Swifta by name or specific location.", - parameters: { - type: SchemaType.OBJECT, - properties: { - query: { - type: SchemaType.STRING, - description: - "The name of the shop or type of merchant (e.g. 'Aliko Store', 'Electronics shops')", - }, - location: { - type: SchemaType.STRING, - description: - "The location to search in (e.g. 'Lekki', 'Victoria Island')", - }, - }, - required: ["query"], - }, - }, - { - name: "buy_product", - description: - "Initialize an order checkout process to purchase a specific product ID.", - parameters: { - type: SchemaType.OBJECT, - properties: { - productId: { - type: SchemaType.STRING, - description: "The system ID of the product", - }, - quantity: { - type: SchemaType.NUMBER, - description: "How many units they want to buy", - }, - }, - required: ["productId"], - }, - }, - { - name: "get_active_orders", - description: "Get order status for active or pending deliveries.", - }, - { - name: "get_order_history", - description: "List completed or cancelled past orders.", - }, - { - name: "confirm_delivery", - description: - "Confirm receipt of an order. Only extracts the reference if explicitly provided.", - parameters: { - type: SchemaType.OBJECT, - properties: { - orderReference: { - type: SchemaType.STRING, - description: - "The order ID or reference string explicitly mentioned by the user.", - }, - }, - }, - }, - { - name: "browse_categories", - description: - "Browse the available product categories in the Swifta marketplace.", - }, - { - name: "list_merchant_products", - description: - "List all products available from a specific merchant by their @handle or name.", - parameters: { - type: SchemaType.OBJECT, - properties: { - merchantSlug: { - type: SchemaType.STRING, - description: - "The merchant's @handle or slug (e.g. '@kareem' or 'kareem')", - }, - query: { - type: SchemaType.STRING, - description: - "Optional product name to filter within that merchant's shop", - }, - }, - required: ["merchantSlug"], - }, - }, -]; diff --git a/apps/backend/src/modules/whatsapp/whatsapp-buyer.service.ts b/apps/backend/src/modules/whatsapp/whatsapp-buyer.service.ts deleted file mode 100644 index 51ead1ab..00000000 --- a/apps/backend/src/modules/whatsapp/whatsapp-buyer.service.ts +++ /dev/null @@ -1,1886 +0,0 @@ -import { - Injectable, - Logger, - Inject, - forwardRef, - NotFoundException, -} from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { PrismaService } from "../../prisma/prisma.service"; -import { OrderService } from "../order/order.service"; -import { ProductService } from "../product/product.service"; -import { WhatsAppBuyerAuthService } from "./whatsapp-buyer-auth.service"; -import { WhatsAppBuyerIntentService } from "./whatsapp-buyer-intent.service"; -import { ParsedIntent } from "./whatsapp-intent.service"; -import { RedisService } from "../../redis/redis.service"; -import { - BUYER_MAIN_MENU, - BUYER_FRIENDLY_FALLBACK, -} from "./whatsapp-buyer.constants"; -import { OrderStatus } from "@swifta/shared"; -import { ReviewService } from "../review/review.service"; -import { UploadService } from "../upload/upload.service"; -import { ImageSearchService } from "./image-search.service"; -import { WhatsAppInteractiveService } from "./whatsapp-interactive.service"; -import { DvaService } from "../dva/dva.service"; - -const PENDING_OTP_PREFIX = "wa_pending_otp_"; -const PENDING_REVIEW_PREFIX = "wa_pending_review_"; -const PENDING_OTP_TTL = 600; // 10 minutes - -// Helper to mask phone numbers — only show last 4 digits -function maskPhone(phone: string): string { - if (phone.length <= 4) return "****"; - return `****${phone.slice(-4)}`; -} - -// Helper to get order item name from product -function getOrderItemName(order: any): string { - return order.product?.name ?? "Unknown Item"; -} - -/** - * Helper to resolve price based on quantity and buyer type - */ -function resolvePrice( - product: any, - quantity: number, - isConsumer: boolean, -): number | null { - const retail = product.retailPriceKobo - ? Number(product.retailPriceKobo) - : null; - const wholesale = product.wholesalePriceKobo - ? Number(product.wholesalePriceKobo) - : null; - const legacy = product.pricePerUnitKobo - ? Number(product.pricePerUnitKobo) - : 0; - - let price: number | null = null; - if (!isConsumer) { - price = wholesale ?? legacy; - } else { - // Consumer (B2C) can buy retail OR wholesale - // Use wholesale if quantity meets threshold (default 10) OR if retail is missing - const threshold = product.minOrderQuantity || 10; - if (quantity >= threshold && wholesale) { - price = wholesale; - } else { - price = retail ?? wholesale ?? legacy; - } - } - - // Final safeguard: Avoid 0 or null prices - return price && price > 0 ? price : null; -} - -@Injectable() -export class WhatsAppBuyerService { - private readonly logger = new Logger(WhatsAppBuyerService.name); - private readonly accessToken: string; - private readonly phoneNumberId: string; - - constructor( - private configService: ConfigService, - private prisma: PrismaService, - @Inject(forwardRef(() => OrderService)) - private orderService: OrderService, - private productService: ProductService, - private authService: WhatsAppBuyerAuthService, - private intentService: WhatsAppBuyerIntentService, - private redisService: RedisService, - @Inject(forwardRef(() => ReviewService)) - private reviewService: ReviewService, - private interactiveService: WhatsAppInteractiveService, - private dvaService: DvaService, - private uploadService: UploadService, - private imageSearchService: ImageSearchService, - ) { - this.accessToken = - this.configService.get("whatsapp.accessToken") || ""; - this.phoneNumberId = - this.configService.get("whatsapp.phoneNumberId") || ""; - } - - // ======================================================================= - // Main Entry Processor - // ======================================================================= - async processMessage( - phone: string, - messageText: string, - messageId: string, - interactiveReply?: { type: string; id: string; title: string }, - ): Promise { - try { - const buyerId = await this.authService.resolvePhone(phone); - - if (!buyerId) { - this.logger.error( - `Buyer link not found for phone ${maskPhone(phone)} during processMessage`, - ); - return; - } - - // 1. Check for pending OTP confirmation (delivery completion) - const pendingOtpKey = `${PENDING_OTP_PREFIX}${buyerId}`; - const pendingSessionRaw = await this.redisService.get(pendingOtpKey); - let pendingOtpSession: any = null; - - if (pendingSessionRaw) { - try { - pendingOtpSession = JSON.parse(pendingSessionRaw); - } catch (parseErr) { - this.logger.error(`Malformed pending OTP session for ${buyerId}`); - await this.redisService.del(pendingOtpKey); - } - } - - const cleanText = messageText.trim().replace(/\s/g, ""); - if (pendingOtpSession && /^\d{6}$/.test(cleanText)) { - const response = await this.handleOtpConfirmation( - buyerId, - pendingOtpSession.orderId, - cleanText, - pendingOtpKey, - phone, - ); - await this.sendWhatsAppMessage(phone, response); - return; - } - - // 2. Check for pending checkout flow (interactive state) - const checkoutKey = `wa_pending_checkout_${buyerId}`; - const checkoutSessionRaw = await this.redisService.get(checkoutKey); - let checkoutSession: any = null; - - if (checkoutSessionRaw) { - try { - checkoutSession = JSON.parse(checkoutSessionRaw); - } catch (parseErr) { - this.logger.error(`Malformed checkout session for ${buyerId}`); - await this.redisService.del(checkoutKey); - } - } - - if (checkoutSession) { - // If this is an interactive reply and it's one of the delivery methods, LET IT FALL THROUGH - // to handleInteractiveReply, which properly processes and clears the checkout state. - const isAllowedInteraction = - interactiveReply && - ["delivery_merchant", "delivery_track", "support_handoff"].includes( - interactiveReply.id, - ); - - if (!isAllowedInteraction) { - await this.handleCheckoutStep( - buyerId, - checkoutSession, - messageText || "", - checkoutKey, - ); - return; - } - } - - // 3. Check for pending review session (text comments) - // Note: We need to find if there's an active review session for ANY order for this buyer - // Better: In a real system, we might query Redis for keys matching `${PENDING_REVIEW_PREFIX}${buyerId}:*` - // For simplicity, we'll try to get the most recent one if available or use a pointer - const reviewSessionPointer = await this.redisService.get( - `review_pointer:${buyerId}`, - ); - if (reviewSessionPointer && messageText && !interactiveReply) { - const reviewKey = `${PENDING_REVIEW_PREFIX}${buyerId}:${reviewSessionPointer}`; - const reviewSessionRaw = await this.redisService.get(reviewKey); - - if (reviewSessionRaw) { - const session = JSON.parse(reviewSessionRaw); - if (session.step === "COMMENT_PROMPT") { - await this.reviewService.updateComment( - session.orderId, - messageText, - ); - await this.redisService.del(reviewKey); - await this.redisService.del(`review_pointer:${buyerId}`); - await this.interactiveService.sendTextMessage( - phone, - "Comment added. ✅ Thank you for your feedback.", - ); - return; - } - } - } - - // 4. Handle general interactive replies - if (interactiveReply) { - await this.handleInteractiveReply(buyerId, phone, interactiveReply); - return; - } - - const intent = await this.intentService.parseIntent(messageText || ""); - - // B1: Scrubbed log — mask phone, only log intent function name + param keys (not values) - this.logger.debug( - `Buyer intent parsed | phone=${maskPhone(phone)} | fn=${intent.functionName} | paramKeys=${Object.keys(intent.params ?? {}).join(",")}`, - ); - - await this.executeCommand(buyerId, phone, intent); - } catch (error) { - // B1: Mask phone in error logs too - this.logger.error( - `Error processing buyer message from ${maskPhone(phone)}: ${error instanceof Error ? error.message : error}`, - ); - await this.interactiveService.sendTextMessage( - phone, - "An error occurred while processing your request. Please try again.", - ); - } - } - - /** - * Central handler for interactive replies - */ - private async handleInteractiveReply( - buyerId: string, - phone: string, - reply: { id: string; title: string }, - ): Promise { - const { id } = reply; - - if (id === "support_handoff") { - await this.handleSupportHandoff(phone); - return; - } - - // Delivery selection - if (id === "delivery_merchant" || id === "delivery_track") { - const checkoutKey = `wa_pending_checkout_${buyerId}`; - const checkoutSessionRaw = await this.redisService.get(checkoutKey); - if (!checkoutSessionRaw) { - await this.interactiveService.sendTextMessage( - phone, - "Checkout session expired. Please search for the product again.", - ); - return; - } - - const session = JSON.parse(checkoutSessionRaw); - session.deliveryMethod = - id === "delivery_merchant" ? "MERCHANT_DELIVERY" : "PLATFORM_LOGISTICS"; - - await this.redisService.del(checkoutKey); - - const appUrl = - this.configService.get("FRONTEND_URL") || "https://swifta.store"; - const checkoutLink = `${appUrl}/buyer/checkout/${session.productId}?qty=${session.quantity}&delivery=${session.deliveryMethod}`; - - const dva = await this.getSafeDva(buyerId); - if (dva && dva.active && dva.accountNumber) { - const dvaMsg = - `💳 *Pay via Bank Transfer (Fastest)*\n\n` + - `Bank: *${dva.bankName}*\n` + - `Account: *${dva.accountNumber}*\n` + - `Name: *${dva.accountName}*\n\n` + - `Transfer the exact amount to this account. your order will be processed automatically. ✅`; - await this.interactiveService.sendTextMessage(phone, dvaMsg); - } - - await this.interactiveService.sendCTAUrlButton( - phone, - `Logistics confirmed. ✅\n\n${dva?.active ? "Alternatively, you can " : ""}Tap below to pay securely via card or bank.`, - "Secure Payment", - checkoutLink, - ); - return; - } - - // Rating buttons - if (id.startsWith("rate_")) { - const parts = id.split("_"); - const rating = parseInt(parts[1]); - const orderId = parts[2]; - const reviewKey = `${PENDING_REVIEW_PREFIX}${buyerId}:${orderId}`; - const sessionRaw = await this.redisService.get(reviewKey); - if (sessionRaw) { - // Set a pointer so we know which order the next text message belongs to - await this.redisService.set(`review_pointer:${buyerId}`, orderId, 600); - await this.handleReviewRating( - buyerId, - JSON.parse(sessionRaw), - rating, - reviewKey, - phone, - ); - } - return; - } - - if (id.startsWith("review_add_comment_")) { - const orderId = id.replace("review_add_comment_", ""); - await this.redisService.set(`review_pointer:${buyerId}`, orderId, 600); - await this.interactiveService.sendTextMessage( - phone, - "Please type your comment below.", - ); - return; - } - - if (id.startsWith("review_skip_")) { - const orderId = id.replace("review_skip_", ""); - await this.redisService.del( - `${PENDING_REVIEW_PREFIX}${buyerId}:${orderId}`, - ); - await this.redisService.del(`review_pointer:${buyerId}`); - await this.interactiveService.sendTextMessage( - phone, - "Thank you. Your rating has been saved. ✅", - ); - return; - } - - // Menu actions - if (id === "search_merchants") { - await this.interactiveService.sendTextMessage( - phone, - "Who or what kind of shop are you looking for? (e.g., 'Groceries in Lekki' or 'Fashion stores')", - ); - return; - } - - if (id === "search_products") { - await this.interactiveService.sendTextMessage( - phone, - "What are you looking for? (e.g., 'iPhone 15' or 'Nike sneakers')", - ); - return; - } - - if (id === "get_active_orders") { - await this.handleGetActiveOrders(buyerId, phone); - return; - } - - if (id === "get_order_history") { - await this.handleGetOrderHistory(buyerId, phone); - return; - } - - if (id === "confirm_delivery_prompt") { - await this.interactiveService.sendTextMessage( - phone, - 'Please enter: *"Confirm delivery for [Order ID]"*', - ); - return; - } - - if (id === "browse_categories") { - await this.handleBrowseCategories(phone); - return; - } - - if (id === "show_full_menu") { - await this.sendBuyerMenu(phone, "Select an action below to continue:"); - return; - } - - if (id === "buy_now") { - await this.interactiveService.sendTextMessage( - phone, - 'Please type: *"Buy [Product ID] [Quantity]"*', - ); - return; - } - - if (id.startsWith("buy_")) { - const parts = id.split("_"); - const productIdRaw = parts[1]; - const quantity = parseInt(parts[2]) || 1; - - try { - let product; - if (productIdRaw.length === 36) { - product = await this.prisma.product.findUnique({ - where: { id: productIdRaw }, - }); - } else if (productIdRaw.length < 4) { - await this.interactiveService.sendTextMessage( - phone, - "Product code too short. Please provide at least 4 characters of the product code.", - ); - return; - } else { - const productsRaw = await this.prisma.$queryRaw` - SELECT id FROM products - WHERE id::text LIKE ${productIdRaw + "%"} - LIMIT 5 - `; - if (productsRaw.length > 1) { - await this.interactiveService.sendTextMessage( - phone, - `Multiple products match the code "${productIdRaw}". Please provide a longer code to narrow it down.`, - ); - return; - } - if (productsRaw.length === 1) { - product = { id: productsRaw[0].id } as any; - } - } - - if (product) { - await this.handleBuyProduct(buyerId, phone, product.id, quantity); - } else { - await this.interactiveService.sendTextMessage( - phone, - "This product is no longer available. Please search for another item.", - ); - } - } catch (err) { - this.logger.error( - `Buy interactive handler error: ${err instanceof Error ? err.message : err}`, - ); - await this.interactiveService.sendTextMessage( - phone, - "Something went wrong while loading the product. Please try again.", - ); - } - return; - } - - if (id.startsWith("track_")) { - const orderIdShort = id.replace("track_", ""); - await this.handleTrackOrder(buyerId, orderIdShort, phone); - return; - } - - if (id.startsWith("hist_")) { - const orderIdShort = id.replace("hist_", ""); - await this.handleShowOrderHistory(buyerId, orderIdShort, phone); - return; - } - - if (id.startsWith("cat_")) { - const categoryId = id.replace("cat_", ""); - await this.handleBrowseCategory(buyerId, phone, categoryId); - return; - } - - if (id === "show_buyer_menu") { - await this.sendBuyerMenu(phone); - return; - } - - if (id.startsWith("merchant_")) { - const merchantId = id.replace("merchant_", ""); - await this.handleShowMerchant(phone, merchantId); - return; - } - - if (id.startsWith("shop_products_")) { - const merchantId = id.replace("shop_products_", ""); - await this.handleSearchProducts(phone, "", undefined, 1, merchantId); - return; - } - } - - private async sendBuyerMenu( - phone: string, - customBody?: string, - ): Promise { - const isMerchant = await this.prisma.user.findFirst({ - where: { phone, role: "MERCHANT" }, - }); - - const sections = [ - { - title: "Shopping", - rows: [ - { - id: "search_products", - title: "Search Products", - description: "Find the best deals and items", - }, - { - id: "search_merchants", - title: "Find Merchants", - description: "Discover local shops and merchants", - }, - { - id: "browse_categories", - title: "Browse Categories", - description: "View by product type", - }, - ], - }, - { - title: "My Orders", - rows: [ - { - id: "get_active_orders", - title: "Active Orders", - description: "Track your current deliveries", - }, - { - id: "confirm_delivery_prompt", - title: "Confirm Delivery", - description: "Mark order as received", - }, - { - id: "get_order_history", - title: "Order History", - description: "Review past purchases", - }, - ], - }, - { - title: "Support", - rows: [ - { - id: "support_handoff", - title: "📞 Need Help?", - description: "Chat with a human agent", - }, - ], - }, - ]; - - if (isMerchant) { - sections.push({ - title: "Account", - rows: [ - { - id: "menu_merchant_mode", - title: "Return to Merchant Mode", - description: "Manage your own shop", - }, - ], - }); - } - - await this.interactiveService.sendListMessage( - phone, - customBody || BUYER_MAIN_MENU, - "Select Action", - sections, - ); - } - - // ======================================================================= - // Checkout Multi-step Flow - // ======================================================================= - private async handleCheckoutStep( - buyerId: string, - session: any, - text: string, - key: string, - ): Promise { - // This is now mostly handled by handleInteractiveReply for SELECT_DELIVERY - // but we keep it here for text-based fallbacks if needed. - const input = text.trim(); - - if (session.step === "SELECT_DELIVERY") { - if (input === "1" || input.toLowerCase().includes("merchant")) { - session.deliveryMethod = "MERCHANT_DELIVERY"; - } else if (input === "2" || input.toLowerCase().includes("swifta")) { - session.deliveryMethod = "PLATFORM_LOGISTICS"; - } else { - await this.interactiveService.sendReplyButtons( - session.phone, - "Please select your delivery method:", - [ - { id: "delivery_merchant", title: "Merchant Delivery" }, - { id: "delivery_track", title: "Tracked Delivery" }, - ], - ); - return; - } - - await this.redisService.del(key); - const appUrl = - this.configService.get("FRONTEND_URL") || "https://swifta.store"; - const checkoutLink = `${appUrl}/buyer/checkout/${session.productId}?qty=${session.quantity}&delivery=${session.deliveryMethod}`; - - const dva = await this.getSafeDva(buyerId); - if (dva && dva.active && dva.accountNumber) { - const dvaMsg = - `💳 *Pay via Bank Transfer (Fastest)*\n\n` + - `Bank: *${dva.bankName}*\n` + - `Account: *${dva.accountNumber}*\n` + - `Name: *${dva.accountName}*\n\n` + - `Transfer the exact amount to this account. Your order will be processed automatically. ✅`; - await this.interactiveService.sendTextMessage(session.phone, dvaMsg); - } - - await this.interactiveService.sendCTAUrlButton( - session.phone, - `Delivery confirmed. ✅\n\n${dva?.active ? "Alternatively, you can " : ""}Tap below to pay securely.`, - "Pay Now", - checkoutLink, - ); - return; - } - - await this.redisService.del(key); - await this.sendBuyerMenu(session.phone); - } - - // ======================================================================= - // Command Router - // ======================================================================= - private async executeCommand( - buyerId: string, - phone: string, - intent: ParsedIntent, - ): Promise { - try { - switch (intent.functionName) { - case "search_products": - await this.handleSearchProducts( - phone, - intent.params.query, - intent.params.location, - intent.params.quantity, - ); - break; - case "list_merchant_products": - await this.handleListMerchantProducts( - phone, - intent.params.merchantSlug, - intent.params.query, - ); - break; - case "search_merchants": - await this.handleSearchMerchants( - phone, - intent.params.query, - intent.params.location, - ); - break; - case "browse_categories": - await this.handleBrowseCategories(phone); - break; - case "buy_product": - await this.handleBuyProduct( - buyerId, - phone, - intent.params.productId, - intent.params.quantity, - ); - break; - case "get_active_orders": - await this.handleGetActiveOrders(buyerId, phone); - break; - case "get_order_history": - await this.handleGetOrderHistory(buyerId, phone); - break; - case "confirm_delivery": - await this.handleConfirmDelivery( - buyerId, - phone, - intent.params.orderReference, - ); - break; - case "contact_support": - case "show_menu": - await this.sendBuyerWelcomeButtons(phone); - break; - case "support_handoff": - await this.handleSupportHandoff(phone); - break; - case "friendly_fallback": - await this.sendBuyerMenu(phone, BUYER_FRIENDLY_FALLBACK); - break; - default: - await this.sendBuyerMenu(phone); - break; - } - } catch (error) { - this.logger.error( - `Buyer Command error (${intent.functionName}): ${error instanceof Error ? error.message : error}`, - ); - await this.interactiveService.sendTextMessage( - phone, - "An error occurred. Please try again later.", - ); - } - } - - // ======================================================================= - // Command Handlers - // ======================================================================= - - private async handleSupportHandoff(phone: string): Promise { - const pauseKey = `ai_paused_${phone}`; - await this.redisService.set(pauseKey, "1", 24 * 60 * 60); // 24 hours - await this.interactiveService.sendTextMessage( - phone, - "You are being transferred to a human agent. They will reply to you on this number shortly.\n\nType *'resume'* at any time to reactivate the AI assistant.", - ); - this.logger.log(`Support Handoff initiated for ${maskPhone(phone)}`); - - // Record in AuditLog for admin visibility - try { - const buyerId = await this.authService.resolvePhone(phone); - if (buyerId) { - await this.prisma.auditLog.create({ - data: { - userId: buyerId, - action: "SUPPORT_HANDOFF", - targetType: "WHATSAPP_BOT", - targetId: phone, - metadata: { status: "AI_PAUSED", phone }, - }, - }); - } - } catch (err) { - this.logger.error("Failed to record support handoff in AuditLog", err); - } - } - - /** - * 🔎 Search Products globally based on AI extracted intent - */ - private async handleSearchProducts( - phone: string, - query: string, - location?: string, - rawQuantity?: number, - merchantId?: string, - ): Promise { - const buyerId = await this.authService.resolvePhone(phone); - const profile = buyerId - ? await this.prisma.buyerProfile.findUnique({ - where: { userId: buyerId }, - }) - : null; - // For WhatsApp, treat ALL as consumers by default unless explicitly a BUSINESS buyer - const isConsumer = profile ? profile.buyerType !== "BUSINESS" : true; - - if (!query && !merchantId) { - await this.interactiveService.sendTextMessage( - phone, - "What kind of product are you looking for? e.g. 'I need a new laptop' or 'iPhone 15'", - ); - return; - } - - const quantity = rawQuantity || 1; - - const products = await this.prisma.product.findMany({ - where: { - isActive: true, - merchantId, - AND: [ - query - ? { - OR: [ - { name: { contains: query, mode: "insensitive" } }, - { description: { contains: query, mode: "insensitive" } }, - { - shortDescription: { contains: query, mode: "insensitive" }, - }, - ], - } - : {}, - location - ? { - merchantProfile: { - OR: [ - { - businessAddress: { - contains: location, - mode: "insensitive", - }, - }, - { - warehouseLocation: { - contains: location, - mode: "insensitive", - }, - }, - ], - }, - } - : {}, - ], - }, - include: { - merchantProfile: true, - category: true, - }, - take: 10, - }); - - if (products.length === 0) { - const msg = query - ? `No results found for "${query}"${location ? ` near ${location}` : ""}. 🛍️\n\nHere are some other popular items you might like:` - : "This merchant currently has no active products. 🏪\n\nHere are some popular items from other sellers:"; - - // Feature 4: Smart "No Results" Fallback - const fallbackProducts = await this.prisma.product.findMany({ - where: { isActive: true }, - include: { merchantProfile: true, category: true }, - take: 5, - orderBy: { createdAt: "desc" }, - }); - - if (fallbackProducts.length > 0) { - const validFallbacks = fallbackProducts.filter( - (p) => resolvePrice(p, quantity, isConsumer) !== null, - ); - - if (validFallbacks.length > 0) { - const rows = validFallbacks.map((p) => { - const resolvedPriceKobo = resolvePrice(p, quantity, isConsumer)!; - return { - id: `buy_${p.id}_${quantity}`, - title: p.name.substring(0, 24), - description: - `${this.formatNaira(resolvedPriceKobo)} | ${p.merchantProfile?.businessName || "Verified Shop"}`.substring( - 0, - 72, - ), - }; - }); - - await this.interactiveService.sendListMessage( - phone, - msg, - "Alternative Items", - [ - { title: "Suggested Products", rows }, - { - title: "Other Options", - rows: [ - { - id: "browse_categories", - title: "Browse Categories", - description: "Explore by type", - }, - { - id: "search_products", - title: "New Search", - description: "Search for a different item", - }, - { - id: "show_buyer_menu", - title: "Main Menu", - description: "Return to home", - }, - ], - }, - ], - ); - return; - } - } - - await this.interactiveService.sendListMessage( - phone, - query - ? `No results for "${query}". 🛍️` - : "This merchant has no active products. 🏪", - "Options", - [ - { - title: "Try something else", - rows: [ - { - id: "browse_categories", - title: "Browse Categories", - description: "Explore by product type", - }, - { - id: "search_products", - title: "New Search", - description: "Search for a different item", - }, - { - id: "show_buyer_menu", - title: "Main Menu", - description: "Return to home", - }, - ], - }, - ], - ); - return; - } - - const validResults = products.filter( - (p) => resolvePrice(p, quantity, isConsumer) !== null, - ); - - if (validResults.length === 0) { - await this.interactiveService.sendTextMessage( - phone, - `No products with valid pricing found matching "${query || "this search"}".`, - ); - return; - } - - await this.interactiveService.sendListMessage( - phone, - query - ? `Search results for "${query}":` - : `Products from ${products[0].merchantProfile?.businessName || "this merchant"}:`, - "View Results", - [ - { - title: "Top Matches", - rows: validResults.map((p) => { - const rating = p.merchantProfile?.averageRating || 0; - const starStr = - rating > 0 - ? ` ⭐${rating.toFixed(1)} (${p.merchantProfile?.reviewCount || 0})` - : ""; - const resolvedPriceKobo = resolvePrice(p, quantity, isConsumer)!; - const isWholesale = - p.wholesalePriceKobo && - resolvedPriceKobo === Number(p.wholesalePriceKobo); - const wholesaleBadge = isWholesale ? " [Wholesale! ✅]" : ""; - const weightStr = p.weightKg ? ` | ${p.weightKg}kg` : ""; - const locationStr = p.warehouseLocation - ? ` | 📍${p.warehouseLocation}` - : ""; - - const shortDesc = p.shortDescription - ? `${p.shortDescription} | ` - : ""; - - return { - id: `buy_${p.id}_${quantity}`, - title: `${p.name}${wholesaleBadge}`.substring(0, 24), - description: - `${shortDesc}${this.formatNaira(resolvedPriceKobo)}${weightStr}${locationStr}${starStr}`.substring( - 0, - 72, - ), - }; - }), - }, - ], - ); - } - - /** - * 📂 Browse Categories - */ - private async handleBrowseCategories(phone: string): Promise { - const categories = await this.prisma.category.findMany({ - where: { isActive: true }, - orderBy: { name: "asc" }, - take: 10, - }); - - if (categories.length === 0) { - await this.interactiveService.sendListMessage( - phone, - "No categories found.", - "Options", - [ - { - title: "Try something else", - rows: [ - { - id: "search_products", - title: "Search Products", - description: "Search for materials", - }, - { - id: "show_buyer_menu", - title: "Main Menu", - description: "Return to home", - }, - ], - }, - ], - ); - return; - } - - await this.interactiveService.sendListMessage( - phone, - "📂 *Browse by Category*\n\nExplore our marketplace by selecting a category below:", - "Select Category", - [ - { - title: "Popular Categories", - rows: categories.map((c) => ({ - id: `cat_${c.name.replace(/\s/g, "_")}`, - title: c.name, - })), - }, - ], - ); - } - - /** - * 💸 Buy Product (Generate Paystack checkout link) - */ - private async handleBuyProduct( - buyerId: string, - phone: string, - partialId: string, - rawQuantity?: number, - ): Promise { - if (!partialId) { - await this.interactiveService.sendTextMessage( - phone, - "Which product ID do you want to buy? E.g. 'Buy ABC12345 50 units'", - ); - return; - } - - const quantity = rawQuantity || 1; - - const products = await this.prisma.$queryRaw` - SELECT - id, - name, - unit, - price_per_unit_kobo AS "pricePerUnitKobo", - retail_price_kobo AS \"retailPriceKobo\", - wholesale_price_kobo AS \"wholesalePriceKobo\", - image_url AS \"imageUrl\" - FROM products - WHERE id::text LIKE ${partialId + "%"} - AND is_active = true - LIMIT 1 - `; - const product = products[0]; - - if (!product) { - await this.interactiveService.sendTextMessage( - phone, - `Product ID "${partialId}" not found.`, - ); - return; - } - - // Resolve buyer type to determine price - const profile = buyerId - ? await this.prisma.buyerProfile.findUnique({ - where: { userId: buyerId }, - }) - : null; - const isConsumer = profile ? profile.buyerType !== "BUSINESS" : true; - - const unitPriceKobo = resolvePrice(product, quantity, isConsumer); - - if (unitPriceKobo === null) { - await this.interactiveService.sendTextMessage( - phone, - "This product does not have a valid price listed. Please contact the merchant for pricing.", - ); - return; - } - - try { - const checkoutKey = `wa_pending_checkout_${buyerId}`; - const session = { - productId: product.id, - quantity, - phone, // Keep phone for fallback - totalAmountKobo: BigInt(Number(unitPriceKobo) * quantity).toString(), - step: "SELECT_DELIVERY", - }; - await this.redisService.set(checkoutKey, JSON.stringify(session), 3600); - - await this.interactiveService.sendReplyButtons( - phone, - `${product.name} (${quantity} units)\n\nHow would you like this delivered?`, - [ - { id: "delivery_merchant", title: "Direct Merchant" }, - { id: "delivery_track", title: "Swifta Tracked" }, - ], - product.imageUrl || undefined, - ); - } catch (e) { - this.logger.error("Checkout session issue", e); - await this.interactiveService.sendTextMessage( - phone, - "Unable to start the checkout at this time.", - ); - } - } - - /** - * 🚚 Get currently active orders — B3: include supplierProduct - */ - private async handleGetActiveOrders( - buyerId: string, - phone: string, - ): Promise { - const orders = await this.prisma.order.findMany({ - where: { - buyerId, - status: { - notIn: [ - OrderStatus.DELIVERED, - OrderStatus.COMPLETED, - OrderStatus.CANCELLED, - ], - }, - }, - include: { product: true }, - }); - - if (orders.length === 0) { - await this.interactiveService.sendTextMessage( - phone, - "You don't have any active deliveries right now.", - ); - return; - } - - await this.interactiveService.sendListMessage( - phone, - "🚚 *Active Orders*", - "View Details", - [ - { - title: "Track Deliveries", - rows: orders.map((o) => ({ - id: `track_${o.id.substring(0, 8)}`, - title: `#${o.id.substring(0, 8).toUpperCase()} | ${o.status.replace(/_/g, " ")}`, - description: `${getOrderItemName(o)} | OTP: ${o.deliveryOtp || "N/A"}`, - })), - }, - ], - ); - } - - private async handleGetOrderHistory( - buyerId: string, - phone: string, - ): Promise { - const orders = await this.prisma.order.findMany({ - where: { - buyerId, - status: { in: [OrderStatus.DELIVERED, OrderStatus.COMPLETED] }, - }, - include: { product: true }, - take: 5, - orderBy: { createdAt: "desc" }, - }); - - if (orders.length === 0) { - await this.interactiveService.sendTextMessage( - phone, - "No completed orders yet.", - ); - return; - } - - await this.interactiveService.sendListMessage( - phone, - "📜 *Order History (Last 5)*", - "Order Details", - [ - { - title: "Previous Purchases", - rows: orders.map((o) => ({ - id: `hist_${o.id.substring(0, 8)}`, - title: `#${o.id.substring(0, 8).toUpperCase()} | Delivered`, - description: `${getOrderItemName(o)} | ${this.formatNaira(Number(o.totalAmountKobo))}`, - })), - }, - ], - ); - } - - /** - * B4: Confirm Delivery — now persists pending OTP session, uses aliased SQL - */ - private async handleConfirmDelivery( - buyerId: string, - phone: string, - orderRef?: string, - ): Promise { - if (!orderRef || orderRef.length < 3) { - await this.interactiveService.sendTextMessage( - phone, - 'Please enter at least 3 characters of the Order ID. E.g. *"Confirm delivery for ABC"*.', - ); - return; - } - - const orders = await this.prisma.$queryRaw` - SELECT - id, - delivery_otp AS "deliveryOtp", - status - FROM orders - WHERE buyer_id = ${buyerId}::uuid - AND id::text LIKE ${orderRef + "%"} - AND status IN ('DISPATCHED', 'IN_TRANSIT') - `; - - if (orders.length === 0) { - await this.interactiveService.sendTextMessage( - phone, - `No active orders found matching "${orderRef}".`, - ); - return; - } - - if (orders.length > 1) { - await this.interactiveService.sendTextMessage( - phone, - `⚠️ Multiple orders matched *"${orderRef}"*. Please provide a few more characters to identify the exact order.`, - ); - return; - } - - const order = orders[0]; - - const pendingOtpKey = `${PENDING_OTP_PREFIX}${buyerId}`; - await this.redisService.set( - pendingOtpKey, - JSON.stringify({ orderId: order.id, createdAt: Date.now() }), - PENDING_OTP_TTL, - ); - - await this.interactiveService.sendTextMessage( - phone, - `To confirm delivery for Order #${order.id.substring(0, 8).toUpperCase()}, please reply with your 6-digit Delivery OTP.`, - ); - } - - /** - * B4: Handle incoming OTP from buyer — called when a pending confirmation session exists - */ - private async handleOtpConfirmation( - buyerId: string, - orderId: string, - otp: string, - pendingOtpKey: string, - phone: string, - ): Promise { - try { - // 1. Core action: Confirm delivery in DB - await this.orderService.confirmDelivery(buyerId, orderId, otp); - - // 2. Best-effort cleanup: Remove the pending OTP - try { - await this.redisService.del(pendingOtpKey); - } catch (err) { - this.logger.error( - `Failed to delete pendingOtpKey ${pendingOtpKey} after success`, - err, - ); - } - - // 3. Best-effort setup: Prepare for photo review - try { - const photoKey = `wa_pending_review_photo:${phone}:${orderId}`; - await this.redisService.set(photoKey, "1", 3600); // 1 hour window - } catch (err) { - this.logger.error( - `Failed to set photoKey for order ${orderId} after success`, - err, - ); - } - - return `Delivery confirmed. ✅ Your order has been marked as delivered. Payment will be released to the merchant.\n\n📸 *Optional:* Reply with a photo of the item you received to help other buyers!`; - } catch (error: any) { - this.logger.warn( - `OTP confirmation failed for order ${orderId}: ${error.message}`, - ); - - // Attempt cleanup on failure too, but ignore errors - try { - await this.redisService.del(pendingOtpKey); - } catch (ignore) {} - - return `Invalid or expired OTP. Please check your code and try again.`; - } - } - - // ======================================================================= - // Output utilities - // ======================================================================= - private formatNaira(kobo: number): string { - const naira = kobo / 100; - return `₦${naira.toLocaleString("en-NG", { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`; - } - - async sendWhatsAppMessage(phone: string, text: string): Promise { - const url = `https://graph.facebook.com/v22.0/${this.phoneNumberId}/messages`; - try { - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - messaging_product: "whatsapp", - to: phone, - type: "text", - text: { body: text }, - }), - }); - if (!response.ok) - this.logger.error(`Meta API error: ${await response.text()}`); - } catch (error) { - this.logger.error("Failed to send WhatsApp message", error); - } - } - - async sendWhatsAppReviewPrompt( - phone: string, - bodyText: string, - orderId: string, - ): Promise { - const buyerId = await this.authService.resolvePhone(phone); - if (!buyerId) return; - - // First message: Stars 1-3 - await this.interactiveService.sendReplyButtons(phone, bodyText, [ - { id: `rate_1_${orderId}`, title: "⭐" }, - { id: `rate_2_${orderId}`, title: "⭐⭐" }, - { id: `rate_3_${orderId}`, title: "⭐⭐⭐" }, - ]); - - // Second message: Stars 4-5 - await this.interactiveService.sendReplyButtons(phone, "Or more stars:", [ - { id: `rate_4_${orderId}`, title: "⭐⭐⭐⭐" }, - { id: `rate_5_${orderId}`, title: "⭐⭐⭐⭐⭐" }, - ]); - - // Store pending review session scoped to order - await this.redisService.set( - `${PENDING_REVIEW_PREFIX}${buyerId}:${orderId}`, - JSON.stringify({ - orderId, - step: "SELECT_RATING", - }), - 3600, - ); - } - - private async handleReviewRating( - buyerId: string, - session: any, - rating: number, - reviewKey: string, - phone: string, - ): Promise { - // Initial save (without comment) - try { - const bufferedImage = await this.redisService.get( - `wa_order_captured_image:${session.orderId}`, - ); - - await this.reviewService.create(buyerId, { - orderId: session.orderId, - rating, - imageUrl: bufferedImage || undefined, - }); - - if (bufferedImage) { - await this.redisService.del( - `wa_order_captured_image:${session.orderId}`, - ); - } - - // Update session to allow adding comment - session.rating = rating; - session.step = "COMMENT_PROMPT"; - await this.redisService.set(reviewKey, JSON.stringify(session), 3600); - - // Send interactive prompt for comment - await this.interactiveService.sendReplyButtons( - phone, - "Rating saved. ✅ Would you like to add a comment about your experience?", - [ - { - id: `review_add_comment_${session.orderId}`, - title: "Yes, add comment", - }, - { id: `review_skip_${session.orderId}`, title: "No, thanks" }, - ], - ); - - return null; // Handled via interactive service - } catch (error: any) { - if (error.message.includes("already been reviewed")) { - await this.redisService.del(reviewKey); - return "This order has already been reviewed. Thank you!"; - } - throw error; - } - } - - // Feature 3: Rich Media Delivery Reviews - async handleReviewPhoto( - phone: string, - orderId: string, - imageId: string, - ): Promise { - try { - await this.interactiveService.sendTextMessage( - phone, - "Processing your photo...", - ); - - const imageResult = - await this.imageSearchService.downloadMetaImage(imageId); - if (!imageResult) { - await this.interactiveService.sendTextMessage( - phone, - "Sorry, I couldn't download the photo. Please try again.", - ); - return; - } - - const buffer = Buffer.from(imageResult.base64Data, "base64"); - const file = { buffer, mimetype: imageResult.mimeType } as any; - - const imageUrl = await this.uploadService.uploadImageToCloudinary( - file, - "swifta/reviews", - ); - - const order = await this.prisma.order.findUnique({ - where: { id: orderId }, - }); - if (order && order.merchantId) { - // Store image in Redis instead of database placeholder - await this.redisService.set( - `wa_order_captured_image:${orderId}`, - imageUrl, - 3600, - ); - } - - await this.interactiveService.sendTextMessage( - phone, - "Amazing! 📸 Your photo has been attached to your review to help other buyers.", - ); - } catch (error) { - this.logger.error( - `Review photo upload failed: ${error instanceof Error ? error.message : error}`, - ); - await this.interactiveService.sendTextMessage( - phone, - "An error occurred while saving your photo.", - ); - } - } - - private async handleTrackOrder( - buyerId: string, - orderIdShort: string, - phone: string, - ): Promise { - const orders = await this.prisma.$queryRaw` - SELECT * FROM orders - WHERE buyer_id = ${buyerId}::uuid - AND id::text LIKE ${orderIdShort + "%"} - LIMIT 1 - `; - const order = orders[0]; - - if (!order) { - await this.interactiveService.sendTextMessage(phone, "Order not found."); - return; - } - - // Refresh with includes - const fullOrder = await this.prisma.order.findUnique({ - where: { id: order.id }, - include: { product: true, merchantProfile: true }, - }); - - if (!fullOrder) return; - - let msg = `📦 *Order #${fullOrder.id.substring(0, 8).toUpperCase()} Tracking*\n\n`; - msg += `Status: *${fullOrder.status.replace(/_/g, " ")}*\n`; - msg += `Item: ${getOrderItemName(fullOrder)}\n`; - msg += `Seller: ${fullOrder.merchantProfile?.businessName || "Verified Merchant"}\n`; - if (fullOrder.deliveryAddress) - msg += `Address: ${fullOrder.deliveryAddress}\n`; - if (fullOrder.deliveryOtp) msg += `OTP: *${fullOrder.deliveryOtp}*\n`; - - await this.interactiveService.sendTextMessage(phone, msg); - } - - private async handleShowOrderHistory( - buyerId: string, - orderIdShort: string, - phone: string, - ): Promise { - const orders = await this.prisma.$queryRaw` - SELECT * FROM orders - WHERE buyer_id = ${buyerId}::uuid - AND id::text LIKE ${orderIdShort + "%"} - LIMIT 1 - `; - const order = orders[0]; - - if (!order) { - await this.interactiveService.sendTextMessage( - phone, - "Order details not found.", - ); - return; - } - - // Refresh with includes - const fullOrder = await this.prisma.order.findUnique({ - where: { id: order.id }, - include: { product: true, review: true }, - }); - - if (!fullOrder) return; - - let msg = `📜 *Past Order Details*\n\n`; - msg += `ID: #${fullOrder.id.substring(0, 8).toUpperCase()}\n`; - msg += `Item: ${getOrderItemName(fullOrder)}\n`; - msg += `Amount: ${this.formatNaira(Number(fullOrder.totalAmountKobo))}\n`; - msg += `Date: ${fullOrder.createdAt.toLocaleDateString()}\n\n`; - - if (fullOrder.review) { - msg += `⭐ Your Rating: ${fullOrder.review.rating}/5\n`; - if (fullOrder.review.comment) - msg += `💬 Your Comment: "${fullOrder.review.comment}"`; - } - - await this.interactiveService.sendTextMessage(phone, msg); - } - - private async handleBrowseCategory( - buyerId: string, - phone: string, - categoryId: string, - ): Promise { - // We expect categoryId to be the name or actual ID - // Let's first try to find by ID then by slug/name - const category = await this.prisma.category.findFirst({ - where: { - OR: [ - { id: categoryId.length === 36 ? categoryId : undefined }, - { - name: { - equals: categoryId.replace(/_/g, " "), - mode: "insensitive", - }, - }, - { - slug: { - equals: categoryId.toLowerCase().replace(/_/g, "-"), - mode: "insensitive", - }, - }, - ], - }, - }); - - if (!category) { - await this.interactiveService.sendTextMessage( - phone, - "Category not found.", - ); - return; - } - - const profile = buyerId - ? await this.prisma.buyerProfile.findUnique({ - where: { userId: buyerId }, - }) - : null; - const isConsumer = profile ? profile.buyerType !== "BUSINESS" : true; - - const products = await this.prisma.product.findMany({ - where: { - categoryId: category.id, - isActive: true, - }, - include: { - merchantProfile: true, - category: true, - }, - take: 10, - }); - - if (products.length === 0) { - await this.interactiveService.sendListMessage( - phone, - `No products found in ${category.name}.`, - "Options", - [ - { - title: "Other Actions", - rows: [ - { - id: "browse_categories", - title: "Browse All Categories", - description: "See other categories", - }, - { - id: "search_products", - title: "Search Specific Item", - description: "Search across all categories", - }, - ], - }, - ], - ); - return; - } - - const validCategorized = products.filter( - (p) => resolvePrice(p, 1, isConsumer) !== null, - ); - - if (validCategorized.length === 0) { - await this.interactiveService.sendTextMessage( - phone, - `No products with valid pricing found in ${category.name}.`, - ); - return; - } - - await this.interactiveService.sendListMessage( - phone, - `Products in ${category.name}:`, - "View Products", - [ - { - title: "Available Items", - rows: validCategorized.map((p) => { - const rating = p.merchantProfile?.averageRating || 0; - const stars = rating > 0 ? ` ⭐${rating.toFixed(1)}` : ""; - - const resolvedPriceKobo = resolvePrice(p, 1, isConsumer)!; - const isWholesale = - p.wholesalePriceKobo && - resolvedPriceKobo === Number(p.wholesalePriceKobo); - const wholesaleBadge = isWholesale ? " [Wholesale! ✅]" : ""; - const weightStr = p.weightKg ? ` | ${p.weightKg}kg` : ""; - const locationStr = p.warehouseLocation - ? ` | 📍${p.warehouseLocation}` - : ""; - - return { - id: `buy_${p.id}_1`, - title: `${p.name}${wholesaleBadge}`.substring(0, 24), - description: - `${this.formatNaira(resolvedPriceKobo)}${weightStr}${locationStr}${stars} | ${p.merchantProfile?.businessName || "Seller"}`.substring( - 0, - 72, - ), - }; - }), - }, - ], - ); - } - - private async handleSearchMerchants( - phone: string, - query?: string, - location?: string, - ): Promise { - if (!query && !location) { - await this.interactiveService.sendTextMessage( - phone, - "Please specify a merchant name or category (e.g., 'Electronics') to search.", - ); - return; - } - - const merchants = await this.prisma.merchantProfile.findMany({ - where: { - AND: [ - { verificationTier: { not: "UNVERIFIED" } }, - query - ? { - OR: [ - { businessName: { contains: query, mode: "insensitive" } }, - { description: { contains: query, mode: "insensitive" } }, - { slug: { contains: query, mode: "insensitive" } }, - ], - } - : {}, - location - ? { - OR: [ - { - businessAddress: { - contains: location, - mode: "insensitive", - }, - }, - { - warehouseLocation: { - contains: location, - mode: "insensitive", - }, - }, - ], - } - : {}, - ], - }, - take: 10, - }); - - if (merchants.length === 0) { - await this.interactiveService.sendTextMessage( - phone, - `No merchants found matching "${query || location}". 🏪`, - ); - return; - } - - await this.interactiveService.sendListMessage( - phone, - `Verified Merchants:`, - "View Shops", - [ - { - title: "Top Matches", - rows: merchants.map((m) => ({ - id: `merchant_${m.id}`, - title: m.businessName || "Untitled Shop", - description: `${m.averageRating > 0 ? `⭐${m.averageRating.toFixed(1)} | ` : ""}${m.description?.substring(0, 50) || "Verified Swifta Merchant"}`, - })), - }, - ], - ); - } - - private async handleShowMerchant( - phone: string, - merchantId: string, - ): Promise { - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - }); - - if (!merchant) { - await this.interactiveService.sendTextMessage( - phone, - "Merchant not found.", - ); - return; - } - - const ratingStr = - merchant.averageRating > 0 - ? `⭐ ${merchant.averageRating.toFixed(1)} (${merchant.reviewCount} reviews)` - : "No reviews yet"; - - const msg = - `🏪 *${merchant.businessName}*\n\n` + - `${merchant.description || "A verified partner on Swifta."}\n\n` + - `📍 *Location:* ${merchant.businessAddress || "Abuja, Nigeria"}\n` + - `✨ *Rating:* ${ratingStr}\n\n` + - `What would you like to do?`; - - await this.interactiveService.sendReplyButtons(phone, msg, [ - { id: `shop_products_${merchantId}`, title: "Browse Their Shop" }, - { id: "show_buyer_menu", title: "Main Menu" }, - ]); - } - - /** - * 🏪 List Products from a specific merchant by slug/handle - */ - private async handleListMerchantProducts( - phone: string, - merchantSlug: string, - query?: string, - ): Promise { - if (!merchantSlug) { - await this.interactiveService.sendTextMessage( - phone, - "Please specify a merchant handle (e.g. @businessusername).", - ); - return; - } - - const cleanSlug = merchantSlug.trim().replace(/^@/, "").toLowerCase(); - - const merchant = await this.prisma.merchantProfile.findFirst({ - where: { - OR: [ - { slug: { equals: cleanSlug, mode: "insensitive" } }, - { businessName: { contains: cleanSlug, mode: "insensitive" } }, - ], - }, - }); - - if (!merchant) { - await this.interactiveService.sendTextMessage( - phone, - `I couldn't find a merchant or shop matching "${cleanSlug}". 🏪`, - ); - return; - } - - // Reuse existing search logic but pinned to this merchant - await this.handleSearchProducts( - phone, - query || "", - undefined, - 1, - merchant.id, - ); - } - - /** - * 🏠 Registered Buyer Welcome (Reply Buttons) — Scenario 2 fix - */ - private async sendBuyerWelcomeButtons(phone: string): Promise { - const welcomeMsg = - `Welcome back to Swifta! 👋\n\n` + - `Secure social commerce for Nigeria. Shop across any category or track your active deliveries.\n\n` + - `What would you like to do?`; - - await this.interactiveService.sendReplyButtons(phone, welcomeMsg, [ - { id: "browse_categories", title: "🛍️ Shop" }, - { id: "get_active_orders", title: "🚚 Tracks" }, - { id: "show_full_menu", title: "📋 More" }, - ]); - } - - private async getSafeDva(buyerId: string): Promise { - try { - const dva = await this.dvaService.getDva(buyerId); - if (dva && dva.active && dva.accountNumber) { - return dva; - } - - // If buyer exists but DVA is inactive/unassigned, attempt auto-provision - this.logger.log(`Auto-provisioning DVA for buyer ${buyerId}`); - try { - const newDva = await this.dvaService.createDva(buyerId); - if (newDva && newDva.dva) { - return { - active: true, - accountNumber: newDva.dva.accountNumber, - accountName: newDva.dva.accountName, - bankName: newDva.dva.bankName, - }; - } - } catch (createError) { - this.logger.error( - `Failed to auto-provision DVA for buyer ${buyerId}:`, - createError, - ); - } - return null; - } catch (error) { - if (error instanceof NotFoundException || (error as any).status === 404) { - return null; // Buyer profile doesn't exist - } - this.logger.error(`DVA lookup error for buyer ${buyerId}`, error); - return null; - } - } -} diff --git a/apps/backend/src/modules/whatsapp/whatsapp-intent.service.ts b/apps/backend/src/modules/whatsapp/whatsapp-intent.service.ts deleted file mode 100644 index d22246dd..00000000 --- a/apps/backend/src/modules/whatsapp/whatsapp-intent.service.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { GoogleGenerativeAI, SchemaType } from "@google/generative-ai"; -import { - NUMBER_INTENT_MAP, - GEMINI_FUNCTION_DECLARATIONS, -} from "./whatsapp.constants"; - -export interface ParsedIntent { - functionName: string; - params: Record; -} - -/** - * AI-powered intent parsing using Gemini function calling. - * - * Priority: - * 1. Numbers 1–6 bypass the AI entirely (fast, free, always works). - * 2. Greetings / "menu" / "help" → show_menu (no AI call). - * 3. Everything else → Gemini function calling. - * 4. Fallback on AI error → friendly_fallback (NOT show_menu). - */ -@Injectable() -export class WhatsAppIntentService { - private readonly logger = new Logger(WhatsAppIntentService.name); - private genAI: GoogleGenerativeAI | null = null; - - constructor(private configService: ConfigService) { - const apiKey = this.configService.get("GEMINI_API_KEY"); - if (apiKey && apiKey !== "your_google_ai_studio_key_here") { - this.genAI = new GoogleGenerativeAI(apiKey); - this.logger.log("Gemini AI initialized for intent parsing"); - } else { - this.logger.warn( - "GEMINI_API_KEY not configured — AI intent parsing disabled, number menu still works", - ); - } - } - - /** - * Parse the merchant's message text and determine which function to call. - */ - async parseIntent(messageText: string): Promise { - const text = messageText.trim(); - - // 1. Direct number mapping (fastest path — no AI needed) - if (NUMBER_INTENT_MAP[text]) { - this.logger.log( - `Number shortcut: "${text}" → ${NUMBER_INTENT_MAP[text]}`, - ); - return { functionName: NUMBER_INTENT_MAP[text], params: {} }; - } - - // 2. Explicit greetings / menu requests only (keep this list SHORT) - const lower = text.toLowerCase(); - if (["menu", "help", "start"].includes(lower)) { - this.logger.log(`Keyword shortcut: "${text}" → show_menu`); - return { functionName: "show_menu", params: {} }; - } - - // 4. EVERYTHING ELSE → send to Gemini AI for intent parsing - if (this.genAI) { - try { - this.logger.log(`Sending to Gemini AI: "${text}"`); - const intent = await this.parseWithGemini(text); - this.logger.log( - `Gemini returned: ${intent.functionName} | Params: ${JSON.stringify(intent.params)}`, - ); - return intent; - } catch (error) { - this.logger.error( - `Gemini intent parsing failed: ${error instanceof Error ? error.message : error}`, - ); - // AI failed — show friendly fallback, NOT the plain menu - return { functionName: "friendly_fallback", params: {} }; - } - } - - // 4. No AI configured — try basic keyword matching as last resort - this.logger.log(`No AI available, trying keyword match for: "${text}"`); - return this.basicKeywordMatch(text); - } - - // ----------------------------------------------------------------------- - // Basic keyword matching (when AI is unavailable) - // ----------------------------------------------------------------------- - private basicKeywordMatch(text: string): ParsedIntent { - const lower = text.toLowerCase(); - - // Dispatch - if (lower.includes("dispatch")) { - const match = lower.match(/dispatch\s+([a-zA-Z0-9]+)/); - const orderReference = match ? match[1] : undefined; - return { - functionName: "dispatch_order", - params: orderReference ? { orderReference } : {}, - }; - } - - // Orders - if ( - lower.includes("my orders") || - lower.includes("recent orders") || - lower.includes("show my orders") - ) { - return { functionName: "get_recent_orders", params: {} }; - } - - // Update Price - if (lower.includes("update price") || lower.includes("change price")) { - return { functionName: "update_product_price", params: {} }; - } - - // Sales-related - if ( - lower.includes("sales") || - lower.includes("market") || - lower.includes("revenue") || - lower.includes("sell") || - lower.includes("business") - ) { - let timeframe = "today"; - if (lower.includes("week")) timeframe = "this_week"; - if (lower.includes("month")) timeframe = "this_month"; - if (lower.includes("all")) timeframe = "all_time"; - return { functionName: "get_sales_summary", params: { timeframe } }; - } - - // Inventory / stock check - if ( - lower.includes("inventory") || - lower.includes("stock") || - lower.includes("store") || - lower.includes("warehouse") || - lower.includes("goods") - ) { - return { functionName: "get_inventory", params: {} }; - } - - // Product listing - if ( - lower.includes("product") || - lower.includes("listing") || - lower.includes("sell") - ) { - return { functionName: "get_products", params: {} }; - } - - // Stock update - if ( - lower.includes("add") || - lower.includes("remove") || - lower.includes("restock") || - lower.includes("receive") - ) { - return { functionName: "update_stock", params: {} }; - } - - // Verification status - if ( - lower.includes("verify") || - lower.includes("verification") || - lower.includes("verified") - ) { - return { functionName: "get_verification_status", params: {} }; - } - - // greetings - - // Greetings - if ( - ["hi", "hello", "hey", "good morning", "good evening"].includes(lower) - ) { - return { functionName: "show_menu", params: {} }; - } - - return { functionName: "friendly_fallback", params: {} }; - } - - // ----------------------------------------------------------------------- - // Gemini function calling - // ----------------------------------------------------------------------- - private async parseWithGemini(messageText: string): Promise { - const systemPrompt = - this.configService.get("whatsapp.merchantSystemPrompt") || ""; - - const modelName = - this.configService.get("GEMINI_MODEL") || "gemini-2.5-flash"; - const model = this.genAI!.getGenerativeModel({ - model: modelName, - systemInstruction: systemPrompt, - tools: [ - { - functionDeclarations: GEMINI_FUNCTION_DECLARATIONS.map((fd) => ({ - name: fd.name, - description: fd.description, - parameters: fd.parameters - ? { - type: SchemaType.OBJECT, - properties: Object.fromEntries( - Object.entries(fd.parameters.properties || {}).map( - ([key, val]: [string, any]) => [ - key, - { - type: - val.type === "number" - ? SchemaType.NUMBER - : SchemaType.STRING, - description: val.description || "", - ...(val.enum ? { enum: val.enum } : {}), - }, - ], - ), - ), - required: fd.parameters.required || [], - } - : undefined, - })) as any, - }, - ], - }); - - const result = await model.generateContent(messageText); - const response = result.response; - const candidate = response.candidates?.[0]; - - if (!candidate?.content?.parts) { - this.logger.warn("Gemini returned no candidate parts"); - return { functionName: "friendly_fallback", params: {} }; - } - - // Look for a function call in the response - for (const part of candidate.content.parts) { - if (part.functionCall) { - return { - functionName: part.functionCall.name, - params: (part.functionCall.args as Record) || {}, - }; - } - } - - // Gemini returned text instead of a function call — check if it's a text response - const textPart = candidate.content.parts.find((p) => p.text); - if (textPart?.text) { - this.logger.log( - `Gemini returned text instead of function call: "${textPart.text.substring(0, 100)}"`, - ); - } - - // No function call found — friendly fallback - return { functionName: "friendly_fallback", params: {} }; - } -} diff --git a/apps/backend/src/modules/whatsapp/whatsapp-onboarding.service.ts b/apps/backend/src/modules/whatsapp/whatsapp-onboarding.service.ts deleted file mode 100644 index 46f3516c..00000000 --- a/apps/backend/src/modules/whatsapp/whatsapp-onboarding.service.ts +++ /dev/null @@ -1,1387 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import * as bcrypt from "bcrypt"; -import * as crypto from "crypto"; -import { PrismaService } from "../../prisma/prisma.service"; -import { EmailService } from "../email/email.service"; -import { WhatsAppInteractiveService } from "./whatsapp-interactive.service"; -import { - ONBOARDING_SESSION_TTL, - NIGERIAN_BANKS, - OnboardingStep, -} from "./whatsapp.constants"; - -/** - * WhatsApp-Only Onboarding Service - * - * Handles full account creation for both buyers and merchants - * entirely via WhatsApp conversation. Uses Meta Interactive Messages - * (Reply Buttons and List Messages) for structured input. - * - * Flow: - * Unknown phone → Welcome buttons → Role selection → Data collection → Account creation - */ -@Injectable() -export class WhatsAppOnboardingService { - private readonly logger = new Logger(WhatsAppOnboardingService.name); - private readonly paystackSecretKey: string; - - constructor( - private prisma: PrismaService, - private emailService: EmailService, - private interactiveService: WhatsAppInteractiveService, - private configService: ConfigService, - ) { - this.paystackSecretKey = - this.configService.get("PAYSTACK_SECRET_KEY") || ""; - } - - private maskIdentifier(identifier: string): string { - if (!identifier) return ""; - if (identifier.includes("@")) { - const [local, domain] = identifier.split("@"); - return `${local.substring(0, 2)}***@${domain}`; - } - return identifier.length > 4 - ? `****${identifier.substring(identifier.length - 4)}` - : "****"; - } - - // ======================================================================= - // Main entry point - // ======================================================================= - async handleOnboarding( - phone: string, - messageText?: string, - interactiveReply?: { type: string; id: string; title: string }, - ): Promise { - try { - // 1. Check for existing onboarding session - const session = await this.prisma.onboardingSession.findUnique({ - where: { phone }, - }); - - if (!session) { - // Handle interactive replies from welcome/learn-more messages - if (interactiveReply) { - if ( - interactiveReply.id === "onboard_buyer" || - interactiveReply.id === "onboard_merchant" - ) { - await this.startOnboarding(phone, interactiveReply.id); - return; - } - if (interactiveReply.id === "learn_more") { - await this.handleLearnMore(phone); - return; - } - } - - // Handle text fallback for role selection - if (messageText) { - const lower = messageText.toLowerCase().trim(); - if ( - lower === "buy" || - lower === "buyer" || - lower.includes("buy product") - ) { - await this.startOnboarding(phone, "onboard_buyer"); - return; - } - if ( - lower === "sell" || - lower === "seller" || - lower === "merchant" || - lower.includes("sell product") - ) { - await this.startOnboarding(phone, "onboard_merchant"); - return; - } - } - - // No session, no recognized intent → show welcome - await this.sendWelcome(phone); - return; - } - - // 2. Session exists — check expiry - if (new Date() > session.expiresAt) { - await this.prisma.onboardingSession.delete({ - where: { id: session.id }, - }); - await this.sendWelcome(phone); - return; - } - - // 3. Route to the correct step handler - const step = session.step as OnboardingStep; - const data = (session.data as Record) || {}; - - if (session.userType === "BUYER") { - await this.handleBuyerStep( - phone, - session.id, - step, - data, - messageText, - interactiveReply, - ); - } else if (session.userType === "MERCHANT") { - await this.handleMerchantStep( - phone, - session.id, - step, - data, - messageText, - interactiveReply, - ); - } - } catch (error) { - this.logger.error( - `Onboarding error for ${this.maskIdentifier(phone)}: ${error instanceof Error ? error.message : error}`, - ); - await this.interactiveService.sendTextMessage( - phone, - "Something went wrong. Please try again by sending any message.", - ); - } - } - - // ======================================================================= - // Welcome & Learn More - // ======================================================================= - private async sendWelcome(phone: string): Promise { - await this.interactiveService.sendReplyButtons( - phone, - "Welcome to Swifta.\n\nBuy and sell anything on WhatsApp with secure escrow payments. Your money is protected until delivery is confirmed.\n\nHow would you like to get started?", - [ - { id: "onboard_buyer", title: "Buy Products" }, - { id: "onboard_merchant", title: "Sell Products" }, - { id: "learn_more", title: "Learn More" }, - ], - ); - } - - private async handleLearnMore(phone: string): Promise { - await this.interactiveService.sendReplyButtons( - phone, - "Swifta allows you to buy and sell products securely through WhatsApp.\n\nMoney is held safely until you receive your goods. You can track deliveries in real-time and buy from verified merchants. Merchants receive payments directly to their bank account.\n\nReady to get started?", - [ - { id: "onboard_buyer", title: "Sign Up to Buy" }, - { id: "onboard_merchant", title: "Sign Up to Sell" }, - ], - ); - } - - // ======================================================================= - // Start Onboarding (create session) - // ======================================================================= - private async startOnboarding(phone: string, roleId: string): Promise { - const userType = roleId === "onboard_buyer" ? "BUYER" : "MERCHANT"; - const firstStep = - userType === "BUYER" - ? OnboardingStep.BUYER_NAME - : OnboardingStep.MERCHANT_BUSINESS_NAME; - - await this.prisma.onboardingSession.upsert({ - where: { phone }, - update: { - userType, - step: firstStep, - data: {}, - expiresAt: new Date(Date.now() + ONBOARDING_SESSION_TTL * 1000), - }, - create: { - phone, - userType, - step: firstStep, - data: {}, - expiresAt: new Date(Date.now() + ONBOARDING_SESSION_TTL * 1000), - }, - }); - - if (userType === "BUYER") { - await this.interactiveService.sendTextMessage( - phone, - "Let's set up your buyer account. What is your full name?", - ); - } else { - await this.interactiveService.sendTextMessage( - phone, - "Let's set up your merchant account. What is your business name?", - ); - } - } - - // ======================================================================= - // Buyer Flow - // ======================================================================= - private async handleBuyerStep( - phone: string, - sessionId: string, - step: OnboardingStep, - data: Record, - messageText?: string, - interactiveReply?: { type: string; id: string; title: string }, - ): Promise { - switch (step) { - case OnboardingStep.BUYER_TYPE: - // Handle legacy sessions by defaulting to CONSUMER and asking for name - await this.updateSession(sessionId, OnboardingStep.BUYER_NAME, { - ...data, - buyerType: "CONSUMER", - }); - await this.interactiveService.sendTextMessage( - phone, - "Great! What's your full name?", - ); - break; - case OnboardingStep.BUYER_BUSINESS_NAME: - await this.handleBuyerBusinessName(phone, sessionId, data, messageText); - break; - case OnboardingStep.BUYER_NAME: - await this.handleBuyerName(phone, sessionId, data, messageText); - break; - case OnboardingStep.BUYER_EMAIL: - await this.handleBuyerEmail(phone, sessionId, data, messageText); - break; - case OnboardingStep.BUYER_OTP: - await this.handleBuyerOtp( - phone, - sessionId, - data, - messageText, - interactiveReply, - ); - break; - default: - await this.interactiveService.sendTextMessage( - phone, - "Something went wrong with your registration. Please try again.", - ); - await this.prisma.onboardingSession.delete({ - where: { id: sessionId }, - }); - await this.sendWelcome(phone); - } - } - - private async handleBuyerType( - phone: string, - sessionId: string, - data: Record, - interactiveReply?: { type: string; id: string; title: string }, - ): Promise { - if (!interactiveReply) { - await this.interactiveService.sendReplyButtons( - phone, - "Please select your buyer type:", - [ - { id: "buyer_type_business", title: "Business" }, - { id: "buyer_type_individual", title: "Individual" }, - ], - ); - return; - } - - const buyerType = - interactiveReply.id === "buyer_type_business" ? "BUSINESS" : "CONSUMER"; - - if (buyerType === "BUSINESS") { - await this.updateSession(sessionId, OnboardingStep.BUYER_BUSINESS_NAME, { - ...data, - buyerType, - }); - await this.interactiveService.sendTextMessage( - phone, - "What's your business name?", - ); - } else { - await this.updateSession(sessionId, OnboardingStep.BUYER_NAME, { - ...data, - buyerType, - }); - await this.interactiveService.sendTextMessage( - phone, - "Great! What's your full name?", - ); - } - } - - private async handleBuyerBusinessName( - phone: string, - sessionId: string, - data: Record, - messageText?: string, - ): Promise { - if (!messageText || messageText.trim().length < 2) { - await this.interactiveService.sendTextMessage( - phone, - "Please enter a valid business name (at least 2 characters).", - ); - return; - } - - await this.updateSession(sessionId, OnboardingStep.BUYER_NAME, { - ...data, - businessName: messageText.trim(), - }); - - await this.interactiveService.sendTextMessage( - phone, - "What is your full name?", - ); - } - - private async handleBuyerName( - phone: string, - sessionId: string, - data: Record, - messageText?: string, - ): Promise { - if (!messageText || messageText.trim().length < 2) { - await this.interactiveService.sendTextMessage( - phone, - "Please enter your full name (at least 2 characters).", - ); - return; - } - - const fullName = messageText.trim(); - const parts = fullName.split(" "); - const firstName = parts[0]; - const lastName = parts.slice(1).join(" ") || ""; - - await this.updateSession(sessionId, OnboardingStep.BUYER_EMAIL, { - ...data, - firstName, - lastName, - fullName, - }); - - await this.interactiveService.sendTextMessage( - phone, - "What is your email address? We will send a verification code.", - ); - } - - private async handleBuyerEmail( - phone: string, - sessionId: string, - data: Record, - messageText?: string, - ): Promise { - if (!messageText) { - await this.interactiveService.sendTextMessage( - phone, - "Please enter your email address.", - ); - return; - } - - const email = messageText.toLowerCase().trim(); - if (!email.includes("@") || !email.includes(".")) { - await this.interactiveService.sendTextMessage( - phone, - "That doesn't look like a valid email address. Please try again.", - ); - return; - } - - // Check if email is already registered - const existingUser = await this.prisma.user.findUnique({ - where: { email }, - }); - if (existingUser) { - await this.interactiveService.sendTextMessage( - phone, - "This email is already registered. If you already have a Swifta account, please visit swifta.store to link your WhatsApp number, or use a different email.", - ); - return; - } - - if (data.otpSentAt && Date.now() - data.otpSentAt < 60 * 1000) { - await this.interactiveService.sendTextMessage( - phone, - "Please wait a minute before requesting another code.", - ); - return; - } - - // Generate and send OTP - const otp = crypto.randomInt(100000, 1000000).toString(); - const otpHash = crypto - .createHmac("sha256", this.paystackSecretKey) - .update(otp) - .digest("hex"); - - try { - await this.emailService.sendVerificationOTP(email, otp); - } catch (error) { - this.logger.error( - `Failed to send OTP to ${this.maskIdentifier(email)}: ${error instanceof Error ? error.message : error}`, - ); - await this.interactiveService.sendTextMessage( - phone, - "I couldn't send the verification email right now. Please try again in a moment.", - ); - return; - } - - await this.updateSession(sessionId, OnboardingStep.BUYER_OTP, { - ...data, - email, - otpHash, - otpAttempts: 0, - otpSentAt: Date.now(), - }); - - await this.interactiveService.sendReplyButtons( - phone, - `A 6-digit code has been sent to ${email}. Please reply with the code.`, - [{ id: "resend_otp", title: "Resend Code" }], - ); - } - - private async handleBuyerOtp( - phone: string, - sessionId: string, - data: Record, - messageText?: string, - interactiveReply?: { type: string; id: string; title: string }, - ): Promise { - if (interactiveReply?.id === "resend_otp") { - await this.handleBuyerEmail(phone, sessionId, data, data.email); - return; - } - - if (!messageText) { - await this.interactiveService.sendTextMessage( - phone, - "Please enter the 6-digit verification code sent to your email.", - ); - return; - } - - const otpInput = messageText.trim().replace(/\s/g, ""); - - // Check OTP expiry (10 minutes) - if (data.otpSentAt && Date.now() - data.otpSentAt > 10 * 60 * 1000) { - await this.prisma.onboardingSession.delete({ - where: { id: sessionId }, - }); - await this.interactiveService.sendTextMessage( - phone, - "The verification code has expired. Please start again by sending any message.", - ); - return; - } - - const inputHash = crypto - .createHmac("sha256", this.paystackSecretKey) - .update(otpInput) - .digest("hex"); - - if (inputHash !== data.otpHash) { - const attempts = (data.otpAttempts || 0) + 1; - if (attempts >= 3) { - await this.prisma.onboardingSession.delete({ - where: { id: sessionId }, - }); - await this.interactiveService.sendTextMessage( - phone, - "Too many incorrect attempts. Please visit swifta.store to register instead, or try again later.", - ); - return; - } - - await this.updateSession(sessionId, OnboardingStep.BUYER_OTP, { - ...data, - otpAttempts: attempts, - }); - - await this.interactiveService.sendTextMessage( - phone, - `That code doesn't match. You have ${3 - attempts} attempt(s) remaining. Please check your email and try again.`, - ); - return; - } - - // OTP verified — create account - await this.createBuyerAccount(phone, sessionId, data); - } - - private async createBuyerAccount( - phone: string, - sessionId: string, - data: Record, - ): Promise { - try { - // Generate a random password (user can reset via web later) - const randomPassword = - Math.random().toString(36).slice(-12) + - Math.random().toString(36).slice(-4); - const passwordHash = await bcrypt.hash(randomPassword, 10); - - // Format phone to +234... format - const formattedPhone = phone.startsWith("234") ? `+${phone}` : phone; - - // Create User + BuyerProfile + WhatsAppBuyerLink in a transaction - await this.prisma.$transaction(async (tx) => { - const user = await tx.user.create({ - data: { - email: data.email, - phone: formattedPhone, - firstName: data.firstName, - lastName: data.lastName || "", - passwordHash, - role: "BUYER", - emailVerified: true, - phoneVerified: true, - buyerProfile: { - create: { - buyerType: data.buyerType || "CONSUMER", - businessName: data.businessName || null, - }, - }, - }, - }); - - await tx.whatsAppBuyerLink.create({ - data: { - phone, - buyerId: user.id, - isActive: true, - }, - }); - - // Delete the onboarding session - await tx.onboardingSession.delete({ - where: { id: sessionId }, - }); - }); - - // Send welcome with List Message - const welcomeMsg = - `Account active. ✅\n\n` + - `Your account is now linked to this phone number. You can access the marketplace instantly without passwords.\n\n` + - `Search for products, buy with escrow protection, and track your deliveries. What would you like to do?`; - - await this.interactiveService.sendListMessage( - phone, - welcomeMsg, - "Get Started", - [ - { - title: "Quick Actions", - rows: [ - { - id: "search_products", - title: "Search Products", - description: "Find what you need", - }, - { - id: "browse_categories", - title: "Browse Categories", - description: "Explore by category", - }, - { - id: "help", - title: "Help", - description: "Learn how Swifta works", - }, - ], - }, - ], - ); - - this.logger.log( - `Buyer account created via WhatsApp: ${this.maskIdentifier(data.email)}, phone: ${this.maskIdentifier(phone)}`, - ); - } catch (error: any) { - if (error.code === "P2002") { - await this.prisma.onboardingSession.delete({ - where: { id: sessionId }, - }); - await this.interactiveService.sendTextMessage( - phone, - "This email or phone number is already registered to another account. Please use different details or visit swifta.store.", - ); - return; - } - this.logger.error( - `Failed to create buyer account for ${this.maskIdentifier(phone)}: ${error instanceof Error ? error.message : error}`, - ); - await this.interactiveService.sendTextMessage( - phone, - "Something went wrong creating your account. Please try again or visit swifta.store to register.", - ); - } - } - - // ======================================================================= - // Merchant Flow - // ======================================================================= - private async handleMerchantStep( - phone: string, - sessionId: string, - step: OnboardingStep, - data: Record, - messageText?: string, - interactiveReply?: { type: string; id: string; title: string }, - ): Promise { - switch (step) { - case OnboardingStep.MERCHANT_BUSINESS_NAME: - await this.handleMerchantBusinessName( - phone, - sessionId, - data, - messageText, - ); - break; - case OnboardingStep.MERCHANT_NAME: - await this.handleMerchantName(phone, sessionId, data, messageText); - break; - case OnboardingStep.MERCHANT_EMAIL: - await this.handleMerchantEmail(phone, sessionId, data, messageText); - break; - case OnboardingStep.MERCHANT_OTP: - await this.handleMerchantOtp( - phone, - sessionId, - data, - messageText, - interactiveReply, - ); - break; - case OnboardingStep.MERCHANT_BANK_SELECT: - await this.handleMerchantBankSelect( - phone, - sessionId, - data, - messageText, - interactiveReply, - ); - break; - case OnboardingStep.MERCHANT_BANK_NAME: - await this.handleMerchantBankName(phone, sessionId, data, messageText); - break; - case OnboardingStep.MERCHANT_ACCOUNT_NUMBER: - await this.handleMerchantAccountNumber( - phone, - sessionId, - data, - messageText, - ); - break; - case OnboardingStep.MERCHANT_BANK_CONFIRM: - await this.handleMerchantBankConfirm( - phone, - sessionId, - data, - messageText, - interactiveReply, - ); - break; - default: - await this.interactiveService.sendTextMessage( - phone, - "Something went wrong with your registration. Please try again.", - ); - await this.prisma.onboardingSession.delete({ - where: { id: sessionId }, - }); - await this.sendWelcome(phone); - } - } - - private async handleMerchantBusinessName( - phone: string, - sessionId: string, - data: Record, - messageText?: string, - ): Promise { - if (!messageText || messageText.trim().length < 2) { - await this.interactiveService.sendTextMessage( - phone, - "Please enter your business name (at least 2 characters).", - ); - return; - } - - await this.updateSession(sessionId, OnboardingStep.MERCHANT_NAME, { - ...data, - businessName: messageText.trim(), - }); - - await this.interactiveService.sendTextMessage(phone, "And your full name?"); - } - - private async handleMerchantName( - phone: string, - sessionId: string, - data: Record, - messageText?: string, - ): Promise { - if (!messageText || messageText.trim().length < 2) { - await this.interactiveService.sendTextMessage( - phone, - "Please enter your full name (at least 2 characters).", - ); - return; - } - - const fullName = messageText.trim(); - const parts = fullName.split(" "); - - await this.updateSession(sessionId, OnboardingStep.MERCHANT_EMAIL, { - ...data, - firstName: parts[0], - lastName: parts.slice(1).join(" ") || "", - }); - - await this.interactiveService.sendTextMessage( - phone, - `Thanks, ${parts[0]}! What's your email address? We'll send you a verification code.`, - ); - } - - private async handleMerchantEmail( - phone: string, - sessionId: string, - data: Record, - messageText?: string, - ): Promise { - if (!messageText) { - await this.interactiveService.sendTextMessage( - phone, - "Please enter your email address.", - ); - return; - } - - const email = messageText.toLowerCase().trim(); - if (!email.includes("@") || !email.includes(".")) { - await this.interactiveService.sendTextMessage( - phone, - "That doesn't look like a valid email address. Please try again.", - ); - return; - } - - const existingUser = await this.prisma.user.findUnique({ - where: { email }, - }); - if (existingUser) { - await this.interactiveService.sendTextMessage( - phone, - "This email is already registered. If you already have a Swifta account, please visit swifta.store to link your WhatsApp number, or use a different email.", - ); - return; - } - - if (data.otpSentAt && Date.now() - data.otpSentAt < 60 * 1000) { - await this.interactiveService.sendTextMessage( - phone, - "Please wait a minute before requesting another code.", - ); - return; - } - - const otp = crypto.randomInt(100000, 1000000).toString(); - const otpHash = crypto - .createHmac("sha256", this.paystackSecretKey) - .update(otp) - .digest("hex"); - - try { - await this.emailService.sendVerificationOTP(email, otp); - } catch (error) { - this.logger.error( - `Failed to send OTP to ${this.maskIdentifier(email)}: ${error instanceof Error ? error.message : error}`, - ); - await this.interactiveService.sendTextMessage( - phone, - "I couldn't send the verification email right now. Please try again in a moment.", - ); - return; - } - - await this.updateSession(sessionId, OnboardingStep.MERCHANT_OTP, { - ...data, - email, - otpHash, - otpAttempts: 0, - otpSentAt: Date.now(), - }); - - await this.interactiveService.sendReplyButtons( - phone, - `A 6-digit code has been sent to ${email}. Please reply with the code.`, - [{ id: "resend_otp", title: "Resend Code" }], - ); - } - - private async handleMerchantOtp( - phone: string, - sessionId: string, - data: Record, - messageText?: string, - interactiveReply?: { type: string; id: string; title: string }, - ): Promise { - if (interactiveReply?.id === "resend_otp") { - await this.handleMerchantEmail(phone, sessionId, data, data.email); - return; - } - - if (!messageText) { - await this.interactiveService.sendTextMessage( - phone, - "Please enter the 6-digit verification code sent to your email.", - ); - return; - } - - const otpInput = messageText.trim().replace(/\s/g, ""); - - if (data.otpSentAt && Date.now() - data.otpSentAt > 10 * 60 * 1000) { - await this.prisma.onboardingSession.delete({ - where: { id: sessionId }, - }); - await this.interactiveService.sendTextMessage( - phone, - "The verification code has expired. Please start again by sending any message.", - ); - return; - } - - const inputHash = crypto - .createHmac("sha256", this.paystackSecretKey) - .update(otpInput) - .digest("hex"); - - if (inputHash !== data.otpHash) { - const attempts = (data.otpAttempts || 0) + 1; - if (attempts >= 3) { - await this.prisma.onboardingSession.delete({ - where: { id: sessionId }, - }); - await this.interactiveService.sendTextMessage( - phone, - "Too many incorrect attempts. Please visit swifta.store to register, or try again later.", - ); - return; - } - - await this.updateSession(sessionId, OnboardingStep.MERCHANT_OTP, { - ...data, - otpAttempts: attempts, - }); - - await this.interactiveService.sendTextMessage( - phone, - `Incorrect code. You have ${3 - attempts} attempt(s) remaining. Please check your email and try again.`, - ); - return; - } - - // OTP verified — proceed to bank selection - await this.updateSession(sessionId, OnboardingStep.MERCHANT_BANK_SELECT, { - ...data, - otpHash: undefined, - otpAttempts: undefined, - }); - - await this.interactiveService.sendListMessage( - phone, - "Email verified. Select your bank for payouts:", - "Select Bank", - [ - { - title: "Popular Banks", - rows: NIGERIAN_BANKS.map((bank) => ({ - id: `bank_${bank.code}`, - title: bank.name, - description: bank.description, - })), - }, - ], - ); - } - - private async handleMerchantBankSelect( - phone: string, - sessionId: string, - data: Record, - messageText?: string, - interactiveReply?: { type: string; id: string; title: string }, - ): Promise { - let bankCode: string | undefined; - let bankName: string | undefined; - - if (interactiveReply && interactiveReply.id.startsWith("bank_")) { - const code = interactiveReply.id.replace("bank_", ""); - - if (code === "other") { - // User wants to type their bank name - await this.updateSession( - sessionId, - OnboardingStep.MERCHANT_BANK_NAME, - data, - ); - await this.interactiveService.sendTextMessage( - phone, - "Please type the name of your bank.", - ); - return; - } - - bankCode = code; - bankName = interactiveReply.title; - } else if (messageText) { - // Text fallback — try to match to a bank - const match = NIGERIAN_BANKS.find( - (b) => - b.name.toLowerCase().includes(messageText.toLowerCase()) || - messageText.toLowerCase().includes(b.name.toLowerCase()), - ); - if (match) { - bankCode = match.code; - bankName = match.name; - } else { - // Unrecognized bank — ask to type name - await this.updateSession( - sessionId, - OnboardingStep.MERCHANT_BANK_NAME, - data, - ); - await this.interactiveService.sendTextMessage( - phone, - "I didn't recognize that bank. Please type the full name of your bank.", - ); - return; - } - } - - if (!bankCode || !bankName) { - await this.interactiveService.sendListMessage( - phone, - "Please select your bank from the list:", - "Select Bank", - [ - { - title: "Popular Banks", - rows: NIGERIAN_BANKS.map((bank) => ({ - id: `bank_${bank.code}`, - title: bank.name, - description: bank.description, - })), - }, - ], - ); - return; - } - - await this.updateSession( - sessionId, - OnboardingStep.MERCHANT_ACCOUNT_NUMBER, - { - ...data, - bankCode, - bankName, - }, - ); - - await this.interactiveService.sendTextMessage( - phone, - "What's your 10-digit account number?", - ); - } - - private async handleMerchantBankName( - phone: string, - sessionId: string, - data: Record, - messageText?: string, - ): Promise { - if (!messageText || messageText.trim().length < 2) { - await this.interactiveService.sendTextMessage( - phone, - "Please type the name of your bank.", - ); - return; - } - - // Try to find bank in Paystack's list - const bankInfo = await this.searchPaystackBank(messageText.trim()); - - if (!bankInfo) { - await this.interactiveService.sendTextMessage( - phone, - "I couldn't find that bank. Please check the spelling and try again, or select from the list.", - ); - // Re-show the list - await this.updateSession( - sessionId, - OnboardingStep.MERCHANT_BANK_SELECT, - data, - ); - await this.interactiveService.sendListMessage( - phone, - "Select your bank:", - "Select Bank", - [ - { - title: "Popular Banks", - rows: NIGERIAN_BANKS.map((bank) => ({ - id: `bank_${bank.code}`, - title: bank.name, - description: bank.description, - })), - }, - ], - ); - return; - } - - await this.updateSession( - sessionId, - OnboardingStep.MERCHANT_ACCOUNT_NUMBER, - { - ...data, - bankCode: bankInfo.code, - bankName: bankInfo.name, - }, - ); - - await this.interactiveService.sendTextMessage( - phone, - `Found: ${bankInfo.name}. What's your 10-digit account number?`, - ); - } - - private async handleMerchantAccountNumber( - phone: string, - sessionId: string, - data: Record, - messageText?: string, - ): Promise { - if (!messageText) { - await this.interactiveService.sendTextMessage( - phone, - "Please enter your 10-digit account number.", - ); - return; - } - - const accountNumber = messageText.trim().replace(/\s/g, ""); - - if (!/^\d{10}$/.test(accountNumber)) { - await this.interactiveService.sendTextMessage( - phone, - "That doesn't look like a valid 10-digit account number. Please try again.", - ); - return; - } - - // Resolve account via Paystack - await this.interactiveService.sendTextMessage( - phone, - "Verifying your account details...", - ); - - const resolved = await this.resolvePaystackAccount( - data.bankCode, - accountNumber, - ); - - if (!resolved) { - await this.interactiveService.sendTextMessage( - phone, - "I couldn't verify that account number. Please check the details and try again.", - ); - return; - } - - await this.updateSession(sessionId, OnboardingStep.MERCHANT_BANK_CONFIRM, { - ...data, - accountNumber, - accountName: resolved.account_name, - }); - - await this.interactiveService.sendReplyButtons( - phone, - `${data.bankName}\n${resolved.account_name}\n\nIs this correct?`, - [ - { id: "bank_confirm", title: "Yes, that's correct" }, - { id: "bank_retry", title: "No, try again" }, - ], - ); - } - - private async handleMerchantBankConfirm( - phone: string, - sessionId: string, - data: Record, - messageText?: string, - interactiveReply?: { type: string; id: string; title: string }, - ): Promise { - let confirmed = false; - - if (interactiveReply) { - confirmed = interactiveReply.id === "bank_confirm"; - } else if (messageText) { - const lower = messageText.toLowerCase().trim(); - confirmed = lower === "yes" || lower === "correct" || lower === "confirm"; - } - - if (!confirmed) { - // Go back to bank selection - await this.updateSession(sessionId, OnboardingStep.MERCHANT_BANK_SELECT, { - ...data, - bankCode: undefined, - bankName: undefined, - accountNumber: undefined, - accountName: undefined, - }); - - await this.interactiveService.sendListMessage( - phone, - "No problem. Let's try again. Select your bank:", - "Select Bank", - [ - { - title: "Popular Banks", - rows: NIGERIAN_BANKS.map((bank) => ({ - id: `bank_${bank.code}`, - title: bank.name, - description: bank.description, - })), - }, - ], - ); - return; - } - - // Confirmed — create merchant account - await this.createMerchantAccount(phone, sessionId, data); - } - - private async createMerchantAccount( - phone: string, - sessionId: string, - data: Record, - ): Promise { - try { - const randomPassword = - Math.random().toString(36).slice(-12) + - Math.random().toString(36).slice(-4); - const passwordHash = await bcrypt.hash(randomPassword, 10); - - const formattedPhone = phone.startsWith("234") ? `+${phone}` : phone; - - await this.prisma.$transaction(async (tx) => { - const user = await tx.user.create({ - data: { - email: data.email, - phone: formattedPhone, - firstName: data.firstName, - lastName: data.lastName || "", - passwordHash, - role: "MERCHANT", - emailVerified: true, - phoneVerified: true, - merchantProfile: { - create: { - businessName: data.businessName, - slug: - data.businessName - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .substring(0, 20) + - "-" + - Date.now().toString(36), - bankCode: data.bankCode, - bankAccountNumber: data.accountNumber, - settlementAccountName: data.accountName, - verificationTier: "BASIC", - } as any, - }, - }, - }); - - await tx.whatsAppLink.create({ - data: { - phone, - userId: user.id, - isActive: true, - }, - }); - - await tx.onboardingSession.delete({ - where: { id: sessionId }, - }); - }); - - // Send welcome with List Message - const welcomeMsg = - `Merchant account active. ✅\n\n` + - `Your account is linked to this phone number. No password is needed for WhatsApp operations.\n\n` + - `Business: ${data.businessName}\nBank: ${data.bankName} — ${data.accountName}\n\n` + - `What would you like to do first?`; - - await this.interactiveService.sendListMessage( - phone, - welcomeMsg, - "Get Started", - [ - { - title: "Quick Actions", - rows: [ - { - id: "add_product", - title: "Add a Product", - description: "List your first product", - }, - { - id: "sales_summary", - title: "Today's Sales", - description: "Check your revenue", - }, - { - id: "my_orders", - title: "My Orders", - description: "View and manage orders", - }, - { - id: "show_menu", - title: "Full Menu", - description: "See all available commands", - }, - ], - }, - ], - ); - - this.logger.log( - `Merchant account created via WhatsApp: ${this.maskIdentifier(data.email)}, business: ${this.maskIdentifier(data.businessName)}, phone: ${this.maskIdentifier(phone)}`, - ); - } catch (error: any) { - if (error.code === "P2002") { - await this.prisma.onboardingSession.delete({ - where: { id: sessionId }, - }); - await this.interactiveService.sendTextMessage( - phone, - "This email or phone number is already registered to another account. Please use different details or visit swifta.store.", - ); - return; - } - this.logger.error( - `Failed to create merchant account for ${this.maskIdentifier(phone)}: ${error instanceof Error ? error.message : error}`, - ); - await this.interactiveService.sendTextMessage( - phone, - "Something went wrong creating your account. Please try again or visit swifta.store to register.", - ); - } - } - - // ======================================================================= - // Paystack Helpers - // ======================================================================= - private async resolvePaystackAccount( - bankCode: string, - accountNumber: string, - ): Promise<{ account_name: string; account_number: string } | null> { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10000); - - try { - const response = await fetch( - `https://api.paystack.co/bank/resolve?account_number=${accountNumber}&bank_code=${bankCode}`, - { - headers: { - Authorization: `Bearer ${this.paystackSecretKey}`, - }, - signal: controller.signal as RequestInit["signal"], - }, - ); - - clearTimeout(timeout); - - if (!response.ok) { - return null; - } - - const result = await response.json(); - if (result.status && result.data) { - return result.data; - } - return null; - } catch (error) { - clearTimeout(timeout); - this.logger.error( - `Paystack resolve error: ${error instanceof Error ? error.message : error}`, - ); - return null; - } - } - - private async searchPaystackBank( - bankName: string, - ): Promise<{ code: string; name: string } | null> { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10000); - - try { - const response = await fetch("https://api.paystack.co/bank", { - headers: { - Authorization: `Bearer ${this.paystackSecretKey}`, - }, - signal: controller.signal as RequestInit["signal"], - }); - - clearTimeout(timeout); - - if (!response.ok) return null; - - const result = await response.json(); - if (!result.status || !result.data) return null; - - const lower = bankName.toLowerCase(); - const match = result.data.find( - (bank: any) => - bank.name.toLowerCase().includes(lower) || - lower.includes(bank.name.toLowerCase()), - ); - - return match ? { code: match.code, name: match.name } : null; - } catch (error) { - clearTimeout(timeout); - this.logger.error( - `Paystack bank list error: ${error instanceof Error ? error.message : error}`, - ); - return null; - } - } - - // ======================================================================= - // Session helpers - // ======================================================================= - private async updateSession( - sessionId: string, - step: OnboardingStep, - data: Record, - ): Promise { - await this.prisma.onboardingSession.update({ - where: { id: sessionId }, - data: { - step, - data: data as any, - expiresAt: new Date(Date.now() + ONBOARDING_SESSION_TTL * 1000), - }, - }); - } -} diff --git a/apps/backend/src/modules/whatsapp/whatsapp-supplier-intent.service.ts b/apps/backend/src/modules/whatsapp/whatsapp-supplier-intent.service.ts deleted file mode 100644 index d1153712..00000000 --- a/apps/backend/src/modules/whatsapp/whatsapp-supplier-intent.service.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { ParsedIntent } from "./whatsapp-intent.service"; - -// Define strict supplier intents matching those handled in the bot service. -const SUPPLIER_INTENT_SCHEMA = { - type: "object", - properties: { - functionName: { - type: "string", - enum: [ - "get_supplier_sales", - "get_supplier_orders", - "get_supplier_products", - "update_supplier_price", - "get_supplier_payouts", - "dispatch_supplier_order", - "show_menu", - "unknown", - ], - description: "The action the supplier wants to perform.", - }, - params: { - type: "object", - properties: { - productName: { - type: "string", - description: - "Name of the product indicating in the supplier message.", - }, - newPriceNaira: { - type: "number", - description: "The new wholesale price indicating in Naira.", - }, - orderId: { - type: "string", - description: "The order ID to dispatch.", - }, - }, - description: "Parameters required for the function.", - }, - }, - required: ["functionName"], -}; - -@Injectable() -export class WhatsAppSupplierIntentService { - private readonly logger = new Logger(WhatsAppSupplierIntentService.name); - private readonly apiKey: string; - private readonly systemPrompt: string; - - constructor(private configService: ConfigService) { - this.apiKey = this.configService.get("GEMINI_API_KEY") || ""; - this.systemPrompt = - this.configService.get("whatsapp.supplierSystemPrompt") || ""; - } - - private getGeminiUrl(): string { - const modelName = - this.configService.get("GEMINI_MODEL") || "gemini-2.5-flash"; - return `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent`; - } - - async parseIntent(text: string): Promise { - const cleanText = text.trim().toLowerCase(); - - // 1. Quick matches for menu numbers - if (cleanText === "1") - return { functionName: "get_supplier_sales", params: {} }; - if (cleanText === "2") - return { functionName: "get_supplier_orders", params: {} }; - if (cleanText === "3") - return { functionName: "get_supplier_products", params: {} }; - if (cleanText === "4") - return { functionName: "update_supplier_price", params: {} }; - if (cleanText === "5") - return { functionName: "get_supplier_payouts", params: {} }; - - // Common greetings - if (["menu", "hi", "hello", "help", "start"].includes(cleanText)) { - return { functionName: "show_menu", params: {} }; - } - - // 2. Fallback to Gemini - if (!this.apiKey) { - this.logger.warn("No GEMINI_API_KEY - falling back to unknown intent."); - return { functionName: "unknown", params: {} }; - } - - try { - const response = await fetch( - `${this.getGeminiUrl()}?key=${this.apiKey}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - system_instruction: { - parts: { text: this.systemPrompt }, - }, - contents: [{ parts: [{ text }] }], - generationConfig: { - response_mime_type: "application/json", - response_schema: SUPPLIER_INTENT_SCHEMA, - temperature: 0.1, - }, - }), - }, - ); - - if (!response.ok) { - throw new Error(`Gemini API Error: ${response.status}`); - } - - const data = await response.json(); - const content = data?.candidates?.[0]?.content?.parts?.[0]?.text; - - if (!content) { - return { functionName: "unknown", params: {} }; - } - - const parsed: ParsedIntent = JSON.parse(content); - - if (!parsed.params) parsed.params = {}; - - return parsed; - } catch (error) { - this.logger.error( - `Intent parsing failed: ${error instanceof Error ? error.message : error}`, - ); - return { functionName: "unknown", params: {} }; - } - } -} diff --git a/apps/backend/src/modules/whatsapp/whatsapp-supplier.service.ts b/apps/backend/src/modules/whatsapp/whatsapp-supplier.service.ts deleted file mode 100644 index 9cf7230e..00000000 --- a/apps/backend/src/modules/whatsapp/whatsapp-supplier.service.ts +++ /dev/null @@ -1,591 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { PrismaService } from "../../prisma/prisma.service"; -import { WhatsAppSupplierIntentService } from "./whatsapp-supplier-intent.service"; -import { ParsedIntent } from "./whatsapp-intent.service"; -import { RedisService } from "../../redis/redis.service"; -import { FRIENDLY_FALLBACK } from "./whatsapp.constants"; -import { OrderStatus } from "@swifta/shared"; -import { WhatsAppInteractiveService } from "./whatsapp-interactive.service"; - -// Helper to mask phone numbers — only show last 4 digits -function maskPhone(phone: string): string { - if (phone.length <= 4) return "****"; - return `****${phone.slice(-4)}`; -} - -@Injectable() -export class WhatsAppSupplierService { - private readonly logger = new Logger(WhatsAppSupplierService.name); - private readonly accessToken: string; - private readonly phoneNumberId: string; - - constructor( - private configService: ConfigService, - private prisma: PrismaService, - private intentService: WhatsAppSupplierIntentService, - private redisService: RedisService, - private interactiveService: WhatsAppInteractiveService, - ) { - this.accessToken = - this.configService.get("whatsapp.accessToken") || ""; - this.phoneNumberId = - this.configService.get("whatsapp.phoneNumberId") || ""; - } - - // ======================================================================= - // Main Entry Processor - // ======================================================================= - async processMessage( - phone: string, - messageText: string, - messageId: string, - interactiveType?: string, - interactiveId?: string, - ): Promise { - try { - // Find the supplier link - const link = await (this.prisma as any).whatsAppSupplierLink.findUnique({ - where: { phone }, - select: { supplierId: true, isActive: true }, - }); - - if (!link || !link.isActive) { - this.logger.error( - `Supplier link not found for phone ${maskPhone(phone)} during processMessage`, - ); - return; - } - - const supplierId = link.supplierId; - - // Handle interactive replies - if (interactiveId) { - await this.handleInteractiveReply(supplierId, phone, interactiveId); - return; - } - - const intent = await this.intentService.parseIntent(messageText); - - this.logger.debug( - `Supplier intent parsed | phone=${maskPhone(phone)} | fn=${intent.functionName} | paramKeys=${Object.keys(intent.params ?? {}).join(",")}`, - ); - - await this.executeCommand(supplierId, phone, intent); - } catch (error) { - this.logger.error( - `Error processing supplier message from ${maskPhone(phone)}: ${error instanceof Error ? error.message : error}`, - ); - await this.interactiveService.sendTextMessage( - phone, - "An error occurred while processing your request. Please try again.", - ); - } - } - - // ======================================================================= - // Interactive Reply Handler - // ======================================================================= - private async handleInteractiveReply( - supplierId: string, - phone: string, - interactiveId: string, - ): Promise { - if (interactiveId === "show_supplier_menu") { - await this.sendSupplierMenu(phone); - return; - } - - if (interactiveId === "get_supplier_sales") { - await this.handleSalesSummary(supplierId, phone); - return; - } - - if (interactiveId === "get_supplier_orders") { - await this.handleRecentOrders(supplierId, phone); - return; - } - - if (interactiveId === "get_supplier_products") { - await this.handleGetProducts(supplierId, phone); - return; - } - - if (interactiveId === "get_supplier_payouts") { - await this.handlePayouts(supplierId, phone); - return; - } - - if (interactiveId.startsWith("dispatch_order_")) { - const orderIdShort = interactiveId.replace("dispatch_order_", ""); - await this.handleDispatchOrder(supplierId, phone, orderIdShort); - return; - } - - if (interactiveId.startsWith("view_wholesale_order_")) { - const orderIdShort = interactiveId.replace("view_wholesale_order_", ""); - await this.handleViewWholesaleOrder(supplierId, phone, orderIdShort); - return; - } - - if (interactiveId.startsWith("view_supplier_product_")) { - const productIdShort = interactiveId.replace( - "view_supplier_product_", - "", - ); - await this.handleViewSupplierProduct(supplierId, phone, productIdShort); - return; - } - - await this.interactiveService.sendTextMessage(phone, FRIENDLY_FALLBACK); - } - - private async handleViewSupplierProduct( - supplierId: string, - phone: string, - productIdShort: string, - ): Promise { - const allProducts = await this.prisma.supplierProduct.findMany({ - where: { supplierId }, - }); - const product = allProducts.find((p) => p.id.startsWith(productIdShort)); - - if (!product) { - await this.interactiveService.sendTextMessage( - phone, - "Product details not found.", - ); - return; - } - - let msg = `${product.name}\n\n`; - msg += `Wholesale Price: ${this.formatNaira(Number(product.wholesalePriceKobo))}\n`; - msg += `Min Order: ${product.minOrderQty} ${product.unit}\n`; - msg += `Category: ${product.category}\n`; - msg += `Status: ${product.isActive ? "Active ✅" : "Inactive"}\n\n`; - msg += `To update price, say: "update price of ${product.name} to [price]"`; - - await this.interactiveService.sendTextMessage(phone, msg); - } - - private async sendSupplierMenu(phone: string): Promise { - await this.interactiveService.sendListMessage( - phone, - "Supplier Menu. What would you like to manage?", - "Open Menu", - [ - { - title: "Operations", - rows: [ - { - id: "get_supplier_sales", - title: "Sales Summary", - description: "View your revenue and performance", - }, - { - id: "get_supplier_orders", - title: "Active Orders", - description: "Manage pending fulfillments", - }, - { - id: "get_supplier_products", - title: "Product List", - description: "View and manage your catalogue", - }, - ], - }, - { - title: "Finance & Support", - rows: [ - { - id: "get_supplier_payouts", - title: "Payout History", - description: "Track your earnings and status", - }, - ], - }, - ], - ); - } - - // ======================================================================= - // Command router - // ======================================================================= - private async executeCommand( - supplierId: string, - phone: string, - intent: ParsedIntent, - ): Promise { - try { - switch (intent.functionName) { - case "show_menu": - await this.sendSupplierMenu(phone); - break; - case "get_supplier_sales": - await this.handleSalesSummary(supplierId, phone); - break; - case "get_supplier_orders": - await this.handleRecentOrders(supplierId, phone); - break; - case "get_supplier_products": - await this.handleGetProducts(supplierId, phone); - break; - case "update_supplier_price": - await this.handleUpdatePrice( - supplierId, - phone, - intent.params.productName, - intent.params.newPriceNaira, - ); - break; - case "get_supplier_payouts": - await this.handlePayouts(supplierId, phone); - break; - case "dispatch_supplier_order": - await this.handleDispatchOrder( - supplierId, - phone, - intent.params.orderId, - ); - break; - default: - await this.sendSupplierMenu(phone); - } - } catch (error) { - this.logger.error(`Error executing supplier command: ${error}`); - await this.interactiveService.sendTextMessage( - phone, - "An error occurred. Please try again later.", - ); - } - } - - // ======================================================================= - // Intent Handlers - // ======================================================================= - - private async handleSalesSummary( - supplierId: string, - phone: string, - ): Promise { - const orders = await this.prisma.order.findMany({ - where: { - supplierId, - status: { in: [OrderStatus.DELIVERED, OrderStatus.COMPLETED] }, - }, - select: { totalAmountKobo: true }, - }); - - const totalRevenue = orders.reduce( - (sum, o) => sum + Number(o.totalAmountKobo || 0), - 0, - ); - - const pendingOrders = await this.prisma.order.count({ - where: { supplierId, status: OrderStatus.PAID }, - }); - - let msg = `Performance Summary:\n\n`; - msg += `Completed Orders: ${orders.length}\n`; - msg += `Total Revenue: ${this.formatNaira(totalRevenue)}\n`; - msg += `Pending Fulfillment: ${pendingOrders}`; - - if (pendingOrders > 0) { - msg += `\nYou have active orders awaiting dispatch.`; - } - - await this.interactiveService.sendTextMessage(phone, msg); - } - - private async handleRecentOrders( - supplierId: string, - phone: string, - ): Promise { - const orders = await this.prisma.order.findMany({ - where: { supplierId }, - orderBy: { createdAt: "desc" }, - take: 10, - include: { product: true }, - }); - - if (orders.length === 0) { - await this.interactiveService.sendTextMessage( - phone, - "You have no wholesale orders yet.", - ); - return; - } - - await this.interactiveService.sendListMessage( - phone, - "Wholesale Orders received. 📦", - "View Orders", - [ - { - title: "Recent Orders", - rows: orders.map((o) => ({ - id: `view_wholesale_order_${o.id.substring(0, 8)}`, - title: `#${o.id.slice(0, 8).toUpperCase()} - ${o.status}`, - description: `${o.product?.name || "Product"} | Qty: ${o.quantity || "N/A"} | ${this.formatNaira(Number(o.totalAmountKobo))}`, - })), - }, - ], - ); - } - - private async handleGetProducts( - supplierId: string, - phone: string, - ): Promise { - const products = await this.prisma.supplierProduct.findMany({ - where: { supplierId, isActive: true }, - take: 10, - }); - - if (products.length === 0) { - await this.interactiveService.sendTextMessage( - phone, - "No active products listed in the manufacturer catalogue.", - ); - return; - } - - await this.interactiveService.sendListMessage( - phone, - "Manufacturer Catalogue.", - "View Products", - [ - { - title: "Active Products", - rows: products.map((p) => ({ - id: `view_supplier_product_${p.id.substring(0, 8)}`, - title: p.name, - description: `Price: ${this.formatNaira(Number(p.wholesalePriceKobo))} | Min: ${p.minOrderQty} ${p.unit}`, - })), - }, - ], - ); - } - - private async handleUpdatePrice( - supplierId: string, - phone: string, - productName?: string, - newPriceNaira?: number, - ): Promise { - if (!productName || !newPriceNaira) { - await this.interactiveService.sendTextMessage( - phone, - "To update a price, tell me the product and the new price, e.g. 'Update phone to 90000'.", - ); - return; - } - - const product = await this.prisma.supplierProduct.findFirst({ - where: { - supplierId, - name: { contains: productName, mode: "insensitive" }, - }, - }); - - if (!product) { - await this.interactiveService.sendTextMessage( - phone, - `Product "${productName}" not found.`, - ); - return; - } - - try { - await this.prisma.supplierProduct.update({ - where: { id: product.id }, - data: { wholesalePriceKobo: BigInt(newPriceNaira * 100) }, - }); - await this.interactiveService.sendTextMessage( - phone, - `Price updated. ✅\n\n${product.name} is now ${this.formatNaira(newPriceNaira * 100)}.`, - ); - } catch (error) { - await this.interactiveService.sendTextMessage( - phone, - `❌ Failed to update price. Please try again.`, - ); - } - } - - private async handlePayouts( - supplierId: string, - phone: string, - ): Promise { - const payouts = await this.prisma.payout.findMany({ - where: { - order: { supplierId }, - }, - orderBy: { createdAt: "desc" }, - take: 10, - }); - - if (payouts.length === 0) { - await this.interactiveService.sendTextMessage( - phone, - "No payout history found.", - ); - return; - } - - let msg = `Recent Payouts:\n\n`; - payouts.forEach((p) => { - msg += `• ${this.formatNaira(Number(p.amountKobo))} - ${p.status} (${p.createdAt.toLocaleDateString()})\n`; - }); - - await this.interactiveService.sendTextMessage(phone, msg); - } - - private async handleDispatchOrder( - supplierId: string, - phone: string, - orderId?: string, - ): Promise { - if (!orderId) { - await this.interactiveService.sendTextMessage( - phone, - "Which order do you want to mark as dispatched? Tell me the Order ID.", - ); - return; - } - - const activeOrders = await this.prisma.order.findMany({ - where: { - supplierId, - status: OrderStatus.PAID, - }, - select: { id: true }, - }); - - const matches = activeOrders.filter((o) => - o.id.toLowerCase().startsWith(orderId.toLowerCase()), - ); - - if (matches.length === 0) { - await this.interactiveService.sendTextMessage( - phone, - `Order #${orderId} not found or is not ready for dispatch.`, - ); - return; - } - - if (matches.length > 1) { - await this.interactiveService.sendTextMessage( - phone, - `⚠️ Multiple orders (${matches.length}) match "${orderId}". Please provide a more specific reference ID.`, - ); - return; - } - - const order = matches[0]; - - try { - await this.prisma.order.update({ - where: { id: order.id }, - data: { status: OrderStatus.DISPATCHED }, - }); - await this.interactiveService.sendTextMessage( - phone, - `Order #${orderId.toUpperCase()} marked as dispatched. ✅`, - ); - } catch (error) { - await this.interactiveService.sendTextMessage( - phone, - `❌ Failed to update order status.`, - ); - } - } - - private formatNaira(kobo: number): string { - const naira = kobo / 100; - return `₦${naira.toLocaleString("en-NG", { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`; - } - - private async handleViewWholesaleOrder( - supplierId: string, - phone: string, - orderIdShort: string, - ): Promise { - const allOrders = await this.prisma.order.findMany({ - where: { supplierId }, - include: { product: true, supplierProduct: true }, - }); - const order = allOrders.find((o) => o.id.startsWith(orderIdShort)); - - if (!order) { - await this.interactiveService.sendTextMessage( - phone, - "Order details not found.", - ); - return; - } - - const itemName = - (order as any).product?.name ?? - (order as any).supplierProduct?.name ?? - "Unknown Item"; - let msg = `Order #${order.id.substring(0, 8).toUpperCase()}\n\n`; - msg += `Status: ${order.status.replace(/_/g, " ")}\n`; - msg += `Item: ${itemName}\n`; - msg += `Quantity: ${order.quantity || "Not specified"}\n`; - msg += `Amount: ${this.formatNaira(Number(order.totalAmountKobo))}\n`; - msg += `Address: ${order.deliveryAddress || "Not specified"}\n\n`; - - const buttons = []; - if (order.status === OrderStatus.PAID) { - buttons.push({ - id: `dispatch_order_${orderIdShort}`, - title: "Dispatch Order", - }); - } - buttons.push({ id: "show_supplier_menu", title: "Main Menu" }); - - await this.interactiveService.sendReplyButtons(phone, msg, buttons); - } - - // ======================================================================= - // Meta Cloud API Wrapper - // ======================================================================= - private async sendWhatsAppMessage(to: string, text: string): Promise { - if (!this.accessToken || !this.phoneNumberId) { - this.logger.warn("WhatsApp credentials missing. Skipping message send."); - return; - } - - const url = `https://graph.facebook.com/v18.0/${this.phoneNumberId}/messages`; - - try { - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - messaging_product: "whatsapp", - recipient_type: "individual", - to, - type: "text", - text: { - preview_url: false, - body: text, - }, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - this.logger.error(`Failed to send WhatsApp message: ${errorText}`); - } - } catch (error) { - this.logger.error( - `Fetch error sending WhatsApp message: ${error instanceof Error ? error.message : error}`, - ); - } - } -} diff --git a/apps/backend/src/modules/whatsapp/whatsapp.constants.ts b/apps/backend/src/modules/whatsapp/whatsapp.constants.ts deleted file mode 100644 index 28607287..00000000 --- a/apps/backend/src/modules/whatsapp/whatsapp.constants.ts +++ /dev/null @@ -1,292 +0,0 @@ -// WhatsApp Bot — Constants, templates & Gemini function declarations -// --------------------------------------------------------------------------- -import { PlatformConfig } from "../../config/platform.config"; - -/** Professional English menu for linked merchants */ -export const MAIN_MENU = `Manage your business efficiently on WhatsApp. Select an option from the menu or state your request.`; - -/** Supplier main menu */ -export const SUPPLIER_MAIN_MENU = `Supplier Dashboard. Use the menu below to manage your wholesale operations.`; - -/** Friendly fallback when AI can't determine intent */ -export const FRIENDLY_FALLBACK = `I did not understand that. Select an option from the menu:`; - -/** Main menu for buyers */ -export const BUYER_MAIN_MENU = `Welcome to the Swifta Buyer Assistant. Select an option to proceed.`; - -/** Friendly fallback for buyers */ -export const BUYER_FRIENDLY_FALLBACK = `I did not understand that. You can ask: -• "I want to buy an iPhone 15" -• "Where is my order?" -• "Show my purchase history"`; - -/** Follow-up when stock update is incomplete */ -export const STOCK_UPDATE_FOLLOWUP = `Understood. Which product would you like to update and by how much? - -Example: "Add 10 pairs of sneakers" or "Remove 5 laptops"`; - -/** First message for an unlinked phone */ -export const WELCOME_MESSAGE = `Welcome to Swifta. Nigeria's digital marketplace for all your shopping needs. How would you like to use our platform?`; - -export const ROLE_SELECTED_MESSAGE = `To link your account, please reply with your registered email address.`; - -/** Sent after looking up the email */ -export const LINK_OTP_SENT = (email: string) => - `I've sent a 6-digit verification code to ${email}. Please reply with the code.`; - -/** Sent when linking succeeds */ -export const LINK_SUCCESS = (merchantName: string, role: string) => { - if (role === "BUYER") { - return `Account linked. ✅\n\nYou can search for products or track active deliveries.`; - } else if (role === "SUPPLIER") { - return `Account linked. ✅\n\n${SUPPLIER_MAIN_MENU}`; - } - return `Account linked. ✅\n\n${MAIN_MENU}`; -}; - -/** Sent when the phone is already linked */ -export const ALREADY_LINKED = `This phone number is already linked to a merchant account.`; - -/** Errors */ -export const EMAIL_NOT_FOUND = `We couldn't find an account associated with that email. Please check the spelling and try again.`; -export const INVALID_OTP = `The code you entered is incorrect. Please check your email and try again.`; -export const OTP_EXPIRED = `Your verification code has expired. Please provide your email again to receive a new one.`; -export const GENERIC_ERROR = `We encountered a problem while processing your request. Please try again or type "menu" to see available options.`; - -// --------------------------------------------------------------------------- -// Session states for multi-step flows -// --------------------------------------------------------------------------- -export enum SessionState { - AWAITING_ROLE = "AWAITING_ROLE", - AWAITING_EMAIL = "AWAITING_EMAIL", - AWAITING_OTP = "AWAITING_OTP", -} - -// --------------------------------------------------------------------------- -// Number-to-intent quick map (no AI call required) -// --------------------------------------------------------------------------- -export const NUMBER_INTENT_MAP: Record = { - "1": "get_sales_summary", - "3": "get_inventory", - "4": "get_recent_orders", - "5": "update_stock", - "6": "get_products", - "7": "update_product_price", - "8": "get_verification_status", -}; - -// --------------------------------------------------------------------------- -// Gemini system prompt is now managed via .env (WHATSAPP_MERCHANT_SYSTEM_PROMPT) -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Gemini function declarations (for function calling) -// --------------------------------------------------------------------------- -export const GEMINI_FUNCTION_DECLARATIONS = [ - { - name: "get_sales_summary", - description: - "Get merchant sales/revenue summary. Triggered by: 'how market', 'my sales', 'how much I sell', 'how business'", - parameters: { - type: "object" as const, - properties: { - timeframe: { - type: "string", - enum: ["today", "this_week", "this_month", "all_time"], - description: "Time period for summary. Default: today", - }, - }, - }, - }, - { - name: "get_inventory", - description: - "Get stock levels for all or specific product. Triggered by: 'check my stock', 'wetin dey my store', 'inventory', 'my goods'", - parameters: { - type: "object" as const, - properties: { - productName: { - type: "string", - description: "Optional specific product to check", - }, - }, - }, - }, - { - name: "update_stock", - description: - "Add or remove inventory stock. Triggered by: 'add 10 sneakers', 'remove 5 phones', 'I just received 100 shirts', 'restock'", - parameters: { - type: "object" as const, - properties: { - productName: { type: "string", description: "Product name" }, - quantity: { type: "number", description: "Quantity to add or remove" }, - action: { - type: "string", - enum: ["add", "remove"], - description: "Whether to add or remove stock", - }, - }, - required: ["productName", "quantity", "action"], - }, - }, - { - name: "get_products", - description: - "List all merchant products. Triggered by: 'my products', 'wetin I dey sell', 'show listings'", - parameters: { type: "object" as const, properties: {} }, - }, - { - name: "show_menu", - description: - "Show the main menu ONLY when intent is truly unclear or user explicitly asks for help/menu", - parameters: { type: "object" as const, properties: {} }, - }, - { - name: "get_recent_orders", - description: - "Get recent orders for the merchant. Triggered by: 'my orders', 'show my orders', 'recent orders'", - parameters: { type: "object" as const, properties: {} }, - }, - { - name: "dispatch_order", - description: - "Dispatch an order. Triggered by: 'dispatch ABC123', 'dispatch'", - parameters: { - type: "object" as const, - properties: { - orderReference: { - type: "string", - description: "Order ID or short reference to dispatch", - }, - }, - required: ["orderReference"], - }, - }, - { - name: "update_order_tracking", - description: - "Update the delivery tracking status of an order. Triggered by: 'update order ABC123 in transit', 'order PREPARING', 'update dispatched'", - parameters: { - type: "object" as const, - properties: { - orderReference: { - type: "string", - description: "Order ID or short reference", - }, - status: { - type: "string", - enum: ["PREPARING", "DISPATCHED", "IN_TRANSIT"], - description: "The new tracking status", - }, - note: { - type: "string", - description: - "Optional note or comment provided by merchant, e.g., 'truck left Alaba at 2pm'", - }, - }, - required: ["orderReference", "status"], - }, - }, - { - name: "update_product_price", - description: - "Update the price of a product. Triggered by: 'update phone price to 90000', 'change price to 8500'", - parameters: { - type: "object" as const, - properties: { - productName: { type: "string", description: "Product name to update" }, - priceNaira: { - type: "number", - description: "New price per unit in Naira", - }, - }, - required: ["productName", "priceNaira"], - }, - }, - { - name: "get_verification_status", - description: - "Get the merchant's current verification tier and status. Triggered by: 'verify', 'my verification', 'am I verified', 'verification status'", - parameters: { type: "object" as const, properties: {} }, - }, - { - name: "support_handoff", - description: - "Pause AI and request human assistance. Triggered by: 'talk to a human', 'support', 'customer care', 'agent', 'help me'", - parameters: { type: "object" as const, properties: {} }, - }, -]; - -/** Meta Graph API version */ -export const META_API_VERSION = "v21.0"; - -/** WhatsApp Auth/OTP Template Name (configured in Meta Business Suite) */ -export const WHATSAPP_OTP_TEMPLATE = "auth_otp"; - -/** Redis key prefix for WhatsApp sessions */ -export const WA_SESSION_PREFIX = "wa:session:"; -/** Redis key prefix for WhatsApp OTPs */ -export const WA_OTP_PREFIX = "wa:otp:"; - -/** Session TTL in seconds (30 minutes) */ -export const SESSION_TTL = 30 * 60; -/** OTP TTL in seconds (10 minutes) */ -export const OTP_TTL = PlatformConfig.timers.otpExpiryWhatsappMinutes * 60; // Use WhatsApp expiry from config - -/** Redis key prefix for message dedup */ -export const WA_MSG_DEDUP_PREFIX = "wa:msg:"; -/** Dedup TTL in seconds (5 minutes) */ -export const MSG_DEDUP_TTL = 5 * 60; - -// --------------------------------------------------------------------------- -// V5 Onboarding — Step enum and constants -// --------------------------------------------------------------------------- -export enum OnboardingStep { - BUYER_TYPE = "BUYER_TYPE", - BUYER_BUSINESS_NAME = "BUYER_BUSINESS_NAME", - BUYER_NAME = "BUYER_NAME", - BUYER_EMAIL = "BUYER_EMAIL", - BUYER_OTP = "BUYER_OTP", - MERCHANT_BUSINESS_NAME = "MERCHANT_BUSINESS_NAME", - MERCHANT_NAME = "MERCHANT_NAME", - MERCHANT_EMAIL = "MERCHANT_EMAIL", - MERCHANT_OTP = "MERCHANT_OTP", - MERCHANT_BANK_SELECT = "MERCHANT_BANK_SELECT", - MERCHANT_BANK_NAME = "MERCHANT_BANK_NAME", - MERCHANT_ACCOUNT_NUMBER = "MERCHANT_ACCOUNT_NUMBER", - MERCHANT_BANK_CONFIRM = "MERCHANT_BANK_CONFIRM", -} - -/** Onboarding session TTL in seconds (1 hour) */ -export const ONBOARDING_SESSION_TTL = - PlatformConfig.timers.onboardingSessionTtl; - -/** Nigerian banks for List Message (using Paystack bank codes) */ -export const NIGERIAN_BANKS = [ - { code: "058", name: "GTBank", description: "Guaranty Trust Bank" }, - { code: "044", name: "Access Bank", description: "Access Bank Plc" }, - { code: "011", name: "First Bank", description: "First Bank of Nigeria" }, - { code: "033", name: "UBA", description: "United Bank for Africa" }, - { code: "057", name: "Zenith Bank", description: "Zenith Bank Plc" }, - { code: "other", name: "Other Bank", description: "Type your bank name" }, -]; - -export enum ProductCreationStep { - NAME = "NAME", - SHORT_DESCRIPTION = "SHORT_DESCRIPTION", - CATEGORY = "CATEGORY", - ATTRIBUTES = "ATTRIBUTES", - UNIT = "UNIT", - WHOLESALE_PRICE = "WHOLESALE_PRICE", - RETAIL_PRICE = "RETAIL_PRICE", - IMAGE = "IMAGE", - CONFIRMATION = "CONFIRMATION", -} - -/** Toggle button for Merchant-as-Buyer mode */ -export const MENU_BUYER_MODE = "menu_buyer_mode"; -export const MENU_MERCHANT_MODE = "menu_merchant_mode"; - -/** Product creation session TTL (30 minutes) */ -export const PRODUCT_CREATION_SESSION_TTL = 30 * 60; diff --git a/apps/backend/src/modules/whatsapp/whatsapp.controller.ts b/apps/backend/src/modules/whatsapp/whatsapp.controller.ts deleted file mode 100644 index 529f9f0b..00000000 --- a/apps/backend/src/modules/whatsapp/whatsapp.controller.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { - Controller, - Get, - Post, - Query, - Req, - Res, - HttpCode, - Logger, -} from "@nestjs/common"; -import type { RawBodyRequest } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { InjectQueue } from "@nestjs/bullmq"; -import { Queue } from "bullmq"; -import type { Request, Response } from "express"; -import * as crypto from "crypto"; -import { SkipThrottle } from "@nestjs/throttler"; -import { RedisService } from "../../redis/redis.service"; -import { WHATSAPP_QUEUE } from "../../queue/queue.constants"; -import { WA_MSG_DEDUP_PREFIX, MSG_DEDUP_TTL } from "./whatsapp.constants"; - -/** - * WhatsApp Webhook Controller - * - * GET /whatsapp/webhook — Meta verification challenge - * POST /whatsapp/webhook — Incoming message handler (async via BullMQ) - * - * Both endpoints are public (no JWT auth) and skip throttle. - */ -@SkipThrottle() -@Controller("whatsapp") -export class WhatsAppController { - private readonly logger = new Logger(WhatsAppController.name); - - constructor( - private configService: ConfigService, - private redisService: RedisService, - @InjectQueue(WHATSAPP_QUEUE) private whatsappQueue: Queue, - ) {} - - // ----------------------------------------------------------------------- - // GET /whatsapp/webhook — Meta webhook verification - // ----------------------------------------------------------------------- - @Get("webhook") - verifyWebhook( - @Query("hub.mode") mode: string, - @Query("hub.verify_token") token: string, - @Query("hub.challenge") challenge: string, - @Res() res: Response, - ) { - const verifyToken = this.configService.get("whatsapp.verifyToken"); - - if (mode === "subscribe" && token === verifyToken) { - this.logger.log("WhatsApp webhook verified successfully"); - // Must return challenge as plain text, not wrapped in JSON - return res.status(200).send(challenge); - } - - this.logger.warn("WhatsApp webhook verification failed — token mismatch"); - return res.status(403).send("Forbidden"); - } - - // ----------------------------------------------------------------------- - // POST /whatsapp/webhook — Incoming messages - // ----------------------------------------------------------------------- - @Post("webhook") - @HttpCode(200) - async handleWebhook(@Req() req: RawBodyRequest) { - // 1. Verify signature - const signature = req.headers["x-hub-signature-256"] as string; - if (!this.verifySignature(req.rawBody, signature)) { - this.logger.warn("WhatsApp webhook signature verification failed"); - // Return 200 anyway to avoid Meta retries on forged requests - return { status: "ignored" }; - } - - const body = req.body; - - // 2. Extract message data from Meta's nested payload - try { - const entry = body?.entry?.[0]; - const changes = entry?.changes?.[0]; - const value = changes?.value; - - // Ignore status updates (delivered, read receipts, etc.) - if (!value?.messages || value.messages.length === 0) { - return { status: "ok" }; - } - - const message = value.messages[0]; - const phone = message.from; // Sender's phone number (e.g. "2348147846093") - const messageId = message.id; // Unique Meta message ID - const messageType = message.type; // "text", "interactive", "image", etc. - - // Extract message content based on type - let messageText: string | undefined; - let interactiveReply: - | { type: string; id: string; title: string } - | undefined; - let imageId: string | undefined; - - if (messageType === "text") { - messageText = message.text?.body?.trim(); - } else if (messageType === "interactive") { - // Handle Reply Button responses - if (message.interactive?.button_reply) { - interactiveReply = { - type: "button_reply", - id: message.interactive.button_reply.id, - title: message.interactive.button_reply.title, - }; - } - // Handle List Message responses - if (message.interactive?.list_reply) { - interactiveReply = { - type: "list_reply", - id: message.interactive.list_reply.id, - title: message.interactive.list_reply.title, - }; - } - } else if (messageType === "image") { - imageId = message.image?.id; - } - - // Skip if no usable content - if (!messageText && !interactiveReply && !imageId) { - return { status: "ok" }; - } - - // 3. Deduplicate — Meta sometimes sends duplicates - const dedupKey = `${WA_MSG_DEDUP_PREFIX}${messageId}`; - const alreadyProcessed = await this.redisService.get(dedupKey); - if (alreadyProcessed) { - this.logger.debug(`Duplicate message ignored: ${messageId}`); - return { status: "ok" }; - } - await this.redisService.set(dedupKey, "1", MSG_DEDUP_TTL); - - // 4. Queue for async processing — return 200 immediately - await this.whatsappQueue.add( - "process-message", - { phone, messageText, messageId, interactiveReply, imageId }, - { - attempts: 2, - backoff: { type: "exponential", delay: 3000 }, - removeOnComplete: 100, - removeOnFail: 50, - }, - ); - - this.logger.log( - `Queued WhatsApp message from ${phone}: type=${messageType}, text="${(messageText || "").substring(0, 50)}", interactive=${interactiveReply?.id || "none"}`, - ); - } catch (error) { - this.logger.error( - `Error processing webhook payload: ${error instanceof Error ? error.message : error}`, - ); - } - - // Always return 200 to Meta — never let processing failures cause retries - return { status: "ok" }; - } - - // ----------------------------------------------------------------------- - // Signature verification - // ----------------------------------------------------------------------- - private verifySignature( - rawBody: Buffer | undefined, - signature: string | undefined, - ): boolean { - if (!rawBody || !signature) return false; - - const appSecret = this.configService.get("whatsapp.appSecret"); - if (!appSecret) { - this.logger.error("WHATSAPP_APP_SECRET not configured"); - return false; - } - - const expectedSignature = - "sha256=" + - crypto.createHmac("sha256", appSecret).update(rawBody).digest("hex"); - - return crypto.timingSafeEqual( - Buffer.from(signature), - Buffer.from(expectedSignature), - ); - } -} diff --git a/apps/backend/src/modules/whatsapp/whatsapp.module.ts b/apps/backend/src/modules/whatsapp/whatsapp.module.ts deleted file mode 100644 index c42b7291..00000000 --- a/apps/backend/src/modules/whatsapp/whatsapp.module.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Module, forwardRef } from "@nestjs/common"; -import { ConfigModule } from "@nestjs/config"; -import { PrismaModule } from "../../prisma/prisma.module"; -import { RedisModule } from "../../redis/redis.module"; -import { QueueModule } from "../../queue/queue.module"; -import { OrderModule } from "../order/order.module"; -import { ProductModule } from "../product/product.module"; -import { TradeFinancingModule } from "../trade-financing/trade-financing.module"; -import { EmailModule } from "../email/email.module"; -import { ReviewModule } from "../review/review.module"; -import { SupplierModule } from "../supplier/supplier.module"; -import { DvaModule } from "../dva/dva.module"; -import { UploadModule } from "../upload/upload.module"; -import { WhatsAppController } from "./whatsapp.controller"; -import { WhatsAppService } from "./whatsapp.service"; -import { WhatsAppAuthService } from "./whatsapp-auth.service"; -import { WhatsAppIntentService } from "./whatsapp-intent.service"; -import { WhatsAppBuyerService } from "./whatsapp-buyer.service"; -import { WhatsAppBuyerAuthService } from "./whatsapp-buyer-auth.service"; -import { WhatsAppBuyerIntentService } from "./whatsapp-buyer-intent.service"; -import { WhatsAppSupplierService } from "./whatsapp-supplier.service"; -import { WhatsAppSupplierIntentService } from "./whatsapp-supplier-intent.service"; -import { WhatsAppOnboardingService } from "./whatsapp-onboarding.service"; -import { WhatsAppInteractiveService } from "./whatsapp-interactive.service"; -import { WhatsAppProcessor } from "./whatsapp.processor"; -import { ImageSearchService } from "./image-search.service"; -import { WhatsAppLoggerService } from "./whatsapp-logger.service"; - -/** - * WhatsApp Bot Module - * - * Integrates with Meta's WhatsApp Business Cloud API to provide - * merchants with a conversational interface to Swifta. - * - * Dependencies: - * - PrismaModule: database access (WhatsAppLink, products, orders, etc.) - * - RedisModule: session state for linking flow + message dedup - * - QueueModule: BullMQ for async message processing - * - OrderModule, ProductModule: existing services - * - EmailModule: OTP delivery for phone linking - * - ConfigModule: WhatsApp API credentials - * - * Note: InventoryModule is @Global() so no explicit import needed. - * Note: NotificationModule is @Global() so no explicit import needed. - */ -@Module({ - imports: [ - ConfigModule, - PrismaModule, - RedisModule, - forwardRef(() => QueueModule), - forwardRef(() => OrderModule), - ProductModule, - TradeFinancingModule, - EmailModule, - forwardRef(() => ReviewModule), - forwardRef(() => SupplierModule), - DvaModule, - UploadModule, - ], - controllers: [WhatsAppController], - providers: [ - WhatsAppService, - WhatsAppAuthService, - WhatsAppIntentService, - WhatsAppBuyerService, - WhatsAppBuyerAuthService, - WhatsAppBuyerIntentService, - WhatsAppSupplierService, - WhatsAppSupplierIntentService, - WhatsAppOnboardingService, - WhatsAppInteractiveService, - WhatsAppProcessor, - ImageSearchService, - WhatsAppLoggerService, - ], - exports: [WhatsAppService], -}) -export class WhatsAppModule {} diff --git a/apps/backend/src/modules/whatsapp/whatsapp.processor.ts b/apps/backend/src/modules/whatsapp/whatsapp.processor.ts deleted file mode 100644 index 469be073..00000000 --- a/apps/backend/src/modules/whatsapp/whatsapp.processor.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Processor, WorkerHost } from "@nestjs/bullmq"; -import { Logger } from "@nestjs/common"; -import { Job } from "bullmq"; -import { WhatsAppService } from "./whatsapp.service"; -import { WHATSAPP_QUEUE } from "../../queue/queue.constants"; - -/** - * BullMQ processor for the WhatsApp queue. - * - * Job types: - * - process-message: Incoming WhatsApp message to handle - */ -@Processor(WHATSAPP_QUEUE, { - drainDelay: 30000, // Slightly faster polling for WhatsApp messages, but still much slower than default 5s - stalledInterval: 300000, - lockDuration: 60000, - metrics: null, -}) -export class WhatsAppProcessor extends WorkerHost { - private readonly logger = new Logger(WhatsAppProcessor.name); - - constructor(private whatsAppService: WhatsAppService) { - super(); - } - - async process(job: Job): Promise { - try { - switch (job.name) { - case "process-message": { - const { phone, messageText, messageId, interactiveReply, imageId } = - job.data; - await this.whatsAppService.processMessage( - phone, - messageText, - messageId, - interactiveReply, - imageId, - ); - break; - } - - case "send-direct-order-notification": { - const { merchantId, orderData } = job.data; - await this.whatsAppService.sendDirectOrderNotification( - merchantId, - orderData, - ); - break; - } - - case "send-payment-confirmed-notification": { - const { phone, orderData } = job.data; - await this.whatsAppService.sendPaymentConfirmedNotification( - phone, - orderData, - ); - break; - } - - case "send-order-dispatched-notification": { - const { phone, orderData } = job.data; - await this.whatsAppService.sendOrderDispatchedNotification( - phone, - orderData, - ); - break; - } - - case "send-text-message": { - const { phone, text } = job.data; - await this.whatsAppService.sendWhatsAppMessage(phone, text); - break; - } - - case "send-delivery-confirmed-notification": { - const { merchantId, payoutData } = job.data; - await this.whatsAppService.sendDeliveryConfirmedNotification( - merchantId, - payoutData, - ); - break; - } - - case "send-payout-completed-notification": { - const { merchantId, payoutData } = job.data; - await this.whatsAppService.sendPayoutCompletedNotification( - merchantId, - payoutData, - ); - break; - } - - case "send-payout-failed-notification": { - const { merchantId, payoutData } = job.data; - await this.whatsAppService.sendPayoutFailedNotification( - merchantId, - payoutData, - ); - break; - } - - default: - this.logger.warn(`Unknown WhatsApp job type: ${job.name}`); - } - } catch (error) { - this.logger.error( - `WhatsApp job ${job.name} failed: ${error instanceof Error ? error.message : error}`, - ); - // Don't rethrow — failed message sends should not retry infinitely - } - } -} diff --git a/apps/backend/src/modules/whatsapp/whatsapp.service.ts b/apps/backend/src/modules/whatsapp/whatsapp.service.ts deleted file mode 100644 index c48ea888..00000000 --- a/apps/backend/src/modules/whatsapp/whatsapp.service.ts +++ /dev/null @@ -1,2217 +0,0 @@ -import { Injectable, Logger, Inject, forwardRef } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { PrismaService } from "../../prisma/prisma.service"; -import { OrderService } from "../order/order.service"; -import { WhatsAppOnboardingService } from "./whatsapp-onboarding.service"; -import { ProductService } from "../product/product.service"; -import { InventoryService } from "../inventory/inventory.service"; -import { WhatsAppAuthService } from "./whatsapp-auth.service"; -import { WhatsAppBuyerAuthService } from "./whatsapp-buyer-auth.service"; -import { WhatsAppBuyerService } from "./whatsapp-buyer.service"; -import { TradeFinancingService } from "../trade-financing/trade-financing.service"; -import { RedisService } from "../../redis/redis.service"; -import { WhatsAppSupplierService } from "./whatsapp-supplier.service"; -import { SupplierService } from "../supplier/supplier.service"; -import { WhatsAppIntentService, ParsedIntent } from "./whatsapp-intent.service"; -import { ImageSearchService } from "./image-search.service"; -import { WhatsAppInteractiveService } from "./whatsapp-interactive.service"; -import { - GENERIC_ERROR, - FRIENDLY_FALLBACK, - STOCK_UPDATE_FOLLOWUP, - META_API_VERSION, - MENU_BUYER_MODE, - MENU_MERCHANT_MODE, -} from "./whatsapp.constants"; -import { OrderStatus, VerificationTier } from "@swifta/shared"; - -function maskPhone(phone: string): string { - if (!phone) return "unknown"; - return phone.replace(/^(\+\d{1,3})(\d+)(\d{2})$/, (_, p1, p2, p3) => { - return p1 + "*".repeat(p2.length) + p3; - }); -} - -/** - * Core WhatsApp Bot service. - * - * Orchestrates: - * - Message processing (auth check → intent → execute → respond) - * - Command execution via existing services - * - Meta Cloud API message sending - */ -@Injectable() -export class WhatsAppService { - private readonly logger = new Logger(WhatsAppService.name); - private readonly accessToken: string; - private readonly phoneNumberId: string; - - constructor( - private configService: ConfigService, - private prisma: PrismaService, - @Inject(forwardRef(() => OrderService)) - private orderService: OrderService, - private onboardingService: WhatsAppOnboardingService, - private productService: ProductService, - private inventoryService: InventoryService, - private authService: WhatsAppAuthService, - private buyerAuthService: WhatsAppBuyerAuthService, - private intentService: WhatsAppIntentService, - private buyerService: WhatsAppBuyerService, - private supplierService: WhatsAppSupplierService, - @Inject(forwardRef(() => SupplierService)) - private wholesaleSupplierService: SupplierService, - private tradeFinancingService: TradeFinancingService, - private imageSearchService: ImageSearchService, - private redisService: RedisService, - private interactiveService: WhatsAppInteractiveService, - ) { - this.accessToken = - this.configService.get("whatsapp.accessToken") || ""; - this.phoneNumberId = - this.configService.get("whatsapp.phoneNumberId") || ""; - } - - // ======================================================================= - async processMessage( - phone: string, - messageText?: string, - messageId?: string, - interactiveReply?: { type: string; id: string; title: string }, - imageId?: string, - ): Promise { - // 0. Check if AI is paused for Support Handoff - const pauseKey = `ai_paused_${phone}`; - const isPaused = await this.redisService.get(pauseKey); - - if (isPaused) { - if ( - messageText?.toLowerCase().trim() === "resume" || - messageText?.toLowerCase().trim() === "resume ai" - ) { - await this.redisService.del(pauseKey); - await this.interactiveService.sendTextMessage( - phone, - "✅ AI assistance has been resumed. How can I help you today?", - ); - this.logger.log(`AI resumed by user (Phone: ${phone})`); - return; - } - this.logger.debug( - `Message from ${phone} ignored (AI Support Handoff active)`, - ); - return; - } - - try { - if (imageId) { - // Feature 3: Check if waiting for a review photo - const keys = await this.redisService.keys( - `wa_pending_review_photo:${phone}:*`, - ); - if (keys.length > 0) { - const orderId = keys[0].split(":").pop()!; - this.logger.log( - `Routing image as review photo (Phone: ${phone}, Order: ${orderId})`, - ); - await this.redisService.del(keys[0]); - await this.buyerService.handleReviewPhoto(phone, orderId, imageId); - return; - } - - this.logger.log(`Routing image search (Phone: ${phone})`); - await this.imageSearchService.handleImageSearch(phone, imageId); - return; - } - - // 2. Check for Mode Override (Merchant in Buyer Mode) - const mode = await this.redisService.get(`wa_mode_${phone}`); - if ( - mode === "buyer" && - (!interactiveReply || interactiveReply.id !== MENU_MERCHANT_MODE) - ) { - this.logger.log( - `Routing message to Buyer Bot via Mode Override (Phone: ${phone})`, - ); - await this.buyerService.processMessage( - phone, - messageText || "", - messageId || "", - interactiveReply, - ); - return; - } - - // 2. Check if phone is linked to a Merchant - const merchantId = await this.authService.resolvePhone(phone); - if (merchantId) { - // Merchant path - this.logger.log( - `Routing message to Merchant Bot (Merchant ID: ${merchantId})`, - ); - - if (interactiveReply) { - await this.handleMerchantInteractiveReply( - merchantId, - phone, - interactiveReply, - ); - return; - } - - // 1. Check for pending wholesale checkout flow - const checkoutKey = `wa_pending_wholesale_${merchantId}`; - const checkoutSessionRaw = await this.redisService.get(checkoutKey); - if (checkoutSessionRaw) { - try { - const session = JSON.parse(checkoutSessionRaw); - await (this as any).handleWholesaleCheckoutStep( - merchantId, - session, - messageText, - checkoutKey, - phone, - ); - return; - } catch (parseErr) { - this.logger.error( - `Malformed wholesale checkout session for ${merchantId}`, - ); - await this.redisService.del(checkoutKey); - } - } - - // 2. Check for pending product creation flow - const prodCreationKey = `wa_product_creation_${merchantId}`; - const prodCreationSessionRaw = - await this.redisService.get(prodCreationKey); - if (prodCreationSessionRaw) { - try { - const session = JSON.parse(prodCreationSessionRaw); - await this.handleProductCreationStep( - merchantId, - session, - messageText, - interactiveReply, - imageId, - prodCreationKey, - phone, - ); - return; - } catch (parseErr) { - this.logger.error( - `Malformed product creation session for ${merchantId}`, - ); - await this.redisService.del(prodCreationKey); - } - } - - const intent = await this.intentService.parseIntent(messageText); - await (this as any).executeCommand(merchantId, intent, phone); - return; - } - - // 3. Check if phone is linked to a Buyer - const buyerId = await this.buyerAuthService.resolvePhone(phone); - if (buyerId) { - this.logger.log(`Routing message to Buyer Bot (Phone: ${phone})`); - await this.buyerService.processMessage( - phone, - messageText || "", - messageId || "", - interactiveReply, - ); - return; - } - - // 4. Unknown number — Route to Onboarding - this.logger.log(`Routing to WhatsApp Onboarding (Phone: ${phone})`); - await this.onboardingService.handleOnboarding( - phone, - messageText, - interactiveReply, - ); - } catch (error) { - this.logger.error( - `Error processing message from ${phone}: ${error instanceof Error ? error.message : error}`, - ); - - // Send a friendly error before rethrowing for BullMQ retry — only once per 5 mins - try { - const fallbackKey = `whatsapp:fallback:${messageId ?? phone}`; - const canSend = await this.redisService.set( - fallbackKey, - "1", - 300, - true, // NX - ); - - if (canSend) { - await this.interactiveService.sendTextMessage( - phone, - "⚠️ We're having trouble processing your request right now. Please wait a moment while we retry, or try sending your message again.", - ); - } - } catch (sendError) { - this.logger.error( - `Failed to send fallback error message to ${phone}: ${sendError instanceof Error ? sendError.message : sendError}`, - ); - } - - throw error; // Rethrow to allow BullMQ to retry - } - } - - // ======================================================================= - // Command router - // ======================================================================= - private async handleMerchantInteractiveReply( - merchantId: string, - phone: string, - reply: { id: string; title: string }, - ): Promise { - const { id } = reply; - - if (id === "support_handoff") { - await this.handleSupportHandoff(phone); - return; - } - - if (id === "show_merchant_menu") { - await this.sendMerchantMenu(phone); - return; - } - - if (id === "menu_sales") { - await this.handleSalesSummary(merchantId, phone, "today"); - return; - } - - if (id === "menu_inventory") { - await this.handleInventory(merchantId, phone); - return; - } - - if (id === "menu_orders") { - await this.handleGetRecentOrders(merchantId, phone); - return; - } - - if (id === "menu_products") { - await this.handleGetProducts(merchantId, phone); - return; - } - - if (id === "menu_verify") { - await this.handleGetVerificationStatus(merchantId, phone); - return; - } - - if (id === "menu_help") { - await this.interactiveService.sendTextMessage( - phone, - `🤝 *Swifta Merchant Support*\n\nYou can manage your business by using the menu or via natural language commands:\n\n• *"sales summary"* - View performance\n• *"my orders"* - Manage latest orders\n• *"check inventory"* - Monitor stock\n• *"update price of [item] to [amount]"*\n• *"add [qty] to [item] stock"*\n\nNeed more help? Visit our web dashboard or contact support at support@swifta.store`, - ); - return; - } - - if (id === "add_product") { - await this.redisService.set( - `wa_product_creation_${merchantId}`, - JSON.stringify({ step: "NAME", data: {} }), - 30 * 60, // 30 minutes - ); - await this.interactiveService.sendTextMessage( - phone, - "Great! Let's add a new product. 🛒\n\nWhat is the *Name* of the product?", - ); - return; - } - - if (id === "menu_buyer_mode") { - await this.redisService.set(`wa_mode_${phone}`, "buyer", 24 * 3600); // 24 hours - await this.interactiveService.sendReplyButtons( - phone, - "Switching to *Buyer Mode*. ✅\n\nYou can now browse products and place orders. To switch back to Merchant Mode, use the menu button below.", - [{ id: "menu_merchant_mode", title: "Merchant Mode" }], - ); - return; - } - - if (id === "menu_merchant_mode") { - await this.redisService.del(`wa_mode_${phone}`); - await this.interactiveService.sendTextMessage( - phone, - "Switching back to *Merchant Mode*. ✅\n\nHow can I help you today?", - ); - await this.sendMerchantMenu(phone); - return; - } - - if (id.startsWith("view_product_")) { - const productIdShort = id.replace("view_product_", ""); - const products = await this.prisma.product.findMany({ - where: { merchantId }, - include: { category: true }, - }); - const product = products.find((p) => p.id.startsWith(productIdShort)); - - if (!product) { - await this.interactiveService.sendTextMessage( - phone, - "❌ Product details not found.", - ); - return; - } - - await this.interactiveService.sendTextMessage( - phone, - `🏪 *${product.name}*\n\nRetail Price: ${this.formatNaira(Number(product.retailPriceKobo || product.pricePerUnitKobo))}\nCategory: ${(product as any).category?.name || "General"}\nUnit: ${product.unit}\nStatus: ${product.isActive ? "Active ✅" : "Inactive ⭕"}\n\nTo update price, say: "update price of ${product.name} to [price]"`, - ); - return; - } - - if (id.startsWith("manage_stock_")) { - const productIdShort = id.replace("manage_stock_", ""); - const products = await this.prisma.product.findMany({ - where: { merchantId }, - include: { productStockCache: true }, - }); - const product = products.find((p) => p.id.startsWith(productIdShort)); - - if (!product) { - await this.interactiveService.sendTextMessage( - phone, - "Inventory details not found. ❌", - ); - return; - } - - const stock = (product as any).productStockCache?.stock || 0; - await this.interactiveService.sendReplyButtons( - phone, - `📦 *Manage Stock: ${product.name}*\n\nCurrent Level: *${stock} ${product.unit}s*\n\nWould you like to add or remove stock?`, - [ - { id: `stock_add_${productIdShort}`, title: "Add Stock" }, - { id: `stock_rem_${productIdShort}`, title: "Remove Stock" }, - { id: "show_merchant_menu", title: "Back to Menu" }, - ], - ); - return; - } - - if (id.startsWith("stock_add_") || id.startsWith("stock_rem_")) { - const isAdd = id.startsWith("stock_add_"); - const productIdShort = id.replace( - isAdd ? "stock_add_" : "stock_rem_", - "", - ); - const products = await this.prisma.product.findMany({ - where: { merchantId }, - }); - const product = products.find((p) => p.id.startsWith(productIdShort)); - if (!product) { - await this.interactiveService.sendTextMessage( - phone, - "Product not found.", - ); - return; - } - - await this.interactiveService.sendTextMessage( - phone, - `To ${isAdd ? "add to" : "remove from"} *${product.name}* stock, please reply with the quantity.\n\nExample: "${isAdd ? "add" : "remove"} 50"`, - ); - return; - } - - if (id.startsWith("manage_order_")) { - const orderIdShort = id.replace("manage_order_", ""); - const orders = await this.prisma.order.findMany({ - where: { merchantId }, - include: { product: true }, - }); - const order = orders.find((o) => o.id.startsWith(orderIdShort)); - if (!order) { - await this.interactiveService.sendTextMessage( - phone, - "Order details not found.", - ); - return; - } - - const status = order.status; - const canDispatch = status === "PAID" || status === "PREPARING"; - - let msg = `📦 *Order #${order.id.slice(0, 8).toUpperCase()}*\n`; - msg += `Status: *${status}*\n`; - msg += `Product: ${order.product?.name || "Multiple items"}\n`; - msg += `Total: ${this.formatNaira(Number(order.totalAmountKobo))}\n`; - msg += `Address: ${order.deliveryAddress || "Not specified"}\n`; - - const buttons: Array<{ id: string; title: string }> = []; - if (canDispatch) { - buttons.push({ - id: `dispatch_${orderIdShort}`, - title: "Mark Dispatched", - }); - } - buttons.push({ - id: `track_update_${orderIdShort}`, - title: "Update Status", - }); - buttons.push({ id: "show_merchant_menu", title: "Back to Menu" }); - - await this.interactiveService.sendReplyButtons(phone, msg, buttons); - return; - } - - if (id.startsWith("dispatch_")) { - const orderIdShort = id.replace("dispatch_", ""); - await this.handleDispatchOrder(merchantId, phone, orderIdShort); - return; - } - - if (id.startsWith("track_update_")) { - const orderIdShort = id.replace("track_update_", ""); - await this.interactiveService.sendListMessage( - phone, - `Update status for Order #${orderIdShort.toUpperCase()}:`, - "Select Status", - [ - { - title: "Internal States", - rows: [ - { - id: `status_PREPARING_${orderIdShort}`, - title: "Preparing", - description: "Order is being packed", - }, - { - id: `status_DISPATCHED_${orderIdShort}`, - title: "Dispatched", - description: "Picked up for delivery", - }, - { - id: `status_IN_TRANSIT_${orderIdShort}`, - title: "In Transit", - description: "On the way to buyer", - }, - ], - }, - ], - ); - return; - } - - if (id.startsWith("buy_wholesale_")) { - const productIdShort = id.replace("buy_wholesale_", ""); - await this.handleBuyWholesale(merchantId, phone, productIdShort); - return; - } - - if ( - id === "confirm_wholesale_pay_now" || - id === "confirm_wholesale_trade_finance" - ) { - const checkoutKey = `wa_pending_wholesale_${merchantId}`; - const checkoutSessionRaw = await this.redisService.get(checkoutKey); - if (!checkoutSessionRaw) { - await this.interactiveService.sendTextMessage( - phone, - "Checkout session expired. Please start over.", - ); - return; - } - const session = JSON.parse(checkoutSessionRaw); - const isPayNow = id === "confirm_wholesale_pay_now"; - await this.handleWholesaleCheckoutStep( - merchantId, - session, - isPayNow ? "1" : "2", - checkoutKey, - phone, - ); - return; - } - - if (id.startsWith("status_")) { - const parts = id.split("_"); - const status = parts[1]; - const orderIdShort = parts[2]; - await this.handleUpdateOrderTracking( - merchantId, - phone, - orderIdShort, - status, - ); - return; - } - } - - /** - * Send the professional merchant main menu - */ - private async sendMerchantMenu(phone: string): Promise { - await this.interactiveService.sendListMessage( - phone, - "Welcome back. What do you need?", - "Main Menu", - [ - { - title: "Business Insights", - rows: [ - { - id: "menu_sales", - title: "Sales Summary", - description: "View today's performance", - }, - { - id: "menu_inventory", - title: "Inventory Check", - description: "Monitor stock levels", - }, - ], - }, - { - title: "Operations", - rows: [ - { - id: "menu_orders", - title: "Recent Orders", - description: "Manage your active sales", - }, - { - id: "menu_products", - title: "Listings", - description: "Manage your product portfolio", - }, - { - id: MENU_BUYER_MODE, - title: "Switch to Buyer Mode", - description: "Search and buy products", - }, - ], - }, - { - title: "Account", - rows: [ - { - id: "menu_verify", - title: "Verification Status", - description: "Check tier level", - }, - { - id: "support_handoff", - title: "📞 Need Help?", - description: "Chat with a human agent", - }, - ], - }, - ], - ); - } - - private async executeCommand( - merchantId: string, - intent: ParsedIntent, - phone: string, - ): Promise { - try { - switch (intent.functionName) { - case "get_sales_summary": - await this.handleSalesSummary( - merchantId, - phone, - intent.params.timeframe, - ); - break; - case "update_product_price": - await (this as any).handleUpdateProductPrice( - merchantId, - phone, - intent.params.productName, - intent.params.newPrice || intent.params.newPriceNaira, - ); - break; - case "update_stock": - await this.handleUpdateStock( - merchantId, - phone, - intent.params.productName, - intent.params.quantity, - intent.params.action, - ); - break; - case "get_products": - await this.handleGetProducts(merchantId, phone); - break; - case "get_recent_orders": - await this.handleGetRecentOrders(merchantId, phone); - break; - case "update_order_tracking": - await this.handleUpdateOrderTracking( - merchantId, - phone, - intent.params.orderReference, - intent.params.status, - intent.params.note, - ); - break; - case "get_verification_status": - await this.handleGetVerificationStatus(merchantId, phone); - break; - case "friendly_fallback": - await this.interactiveService.sendTextMessage( - phone, - FRIENDLY_FALLBACK, - ); - break; - case "support_handoff": - await this.handleSupportHandoff(phone); - break; - case "show_menu": - default: - await this.sendMerchantMenu(phone); - break; - } - } catch (error) { - this.logger.error( - `Command execution error (${intent.functionName}): ${error instanceof Error ? error.message : error}`, - ); - await this.interactiveService.sendTextMessage(phone, GENERIC_ERROR); - } - } - - // ======================================================================= - // Command handlers — professional English responses (Pidgin/Lagos input understood, English output always) - // ======================================================================= - - private async handleSupportHandoff(phone: string): Promise { - const pauseKey = `ai_paused_${phone}`; - await this.redisService.set(pauseKey, "1", 24 * 60 * 60); // 24 hours - await this.interactiveService.sendTextMessage( - phone, - "You are being transferred to a human agent. They will reply to you on this number shortly.\n\nType *'resume'* at any time to reactivate the AI assistant.", - ); - this.logger.log( - `Support Handoff initiated for merchant/user ${maskPhone(phone)}`, - ); - } - - /** - * 📊 Sales Summary - */ - private async handleSalesSummary( - merchantId: string, - phone: string, - timeframe?: string, - ): Promise { - const dateFilter = this.getDateFilter(timeframe || "today"); - const timeframeLabel = this.getTimeframeLabel(timeframe || "today"); - - const orders = await this.prisma.order.findMany({ - where: { - merchantId, - status: { in: [OrderStatus.DELIVERED, OrderStatus.COMPLETED] }, - ...(dateFilter ? { createdAt: { gte: dateFilter } } : {}), - }, - select: { - totalAmountKobo: true, - deliveryFeeKobo: true, - product: { select: { name: true } }, - }, - }); - - const totalRevenue = orders.reduce( - (sum, o) => - sum + Number(o.totalAmountKobo || 0) + Number(o.deliveryFeeKobo || 0), - 0, - ); - - // Count products for "top seller" - const productCounts: Record = {}; - for (const o of orders) { - const name = o.product?.name || "Unnamed"; - productCounts[name] = (productCounts[name] || 0) + 1; - } - const topSeller = Object.entries(productCounts).sort( - (a, b) => b[1] - a[1], - )[0]; - let msg = `Performance Summary (${timeframeLabel}):\n\n`; - - if (orders.length === 0) { - msg += `No completed orders recorded ${timeframe === "today" ? "today" : "for this period"}.\n`; - } else { - msg += `Status: ${orders.length} orders completed\n`; - msg += `Revenue: ${this.formatNaira(totalRevenue)}\n`; - if (topSeller) { - msg += `Top Product: ${topSeller[0]} (${topSeller[1]} orders)\n`; - } - } - - await this.interactiveService.sendTextMessage(phone, msg); - } - - /** - * 📦 Inventory Check - */ - private async handleInventory( - merchantId: string, - phone: string, - productName?: string, - ): Promise { - if (productName) { - // Search for specific product - const products = await this.prisma.product.findMany({ - where: { - merchantId, - deletedAt: null, - name: { contains: productName, mode: "insensitive" }, - }, - include: { productStockCache: true }, - take: 10, - }); - - if (products.length === 0) { - await this.interactiveService.sendTextMessage( - phone, - `No products matching "${productName}" were found.`, - ); - return; - } - - await this.interactiveService.sendListMessage( - phone, - `📦 Inventory matches for "${productName}":`, - "View Products", - [ - { - title: "Inventory Search Results", - rows: products.map((p) => ({ - id: `manage_stock_${p.id.substring(0, 8)}`, - title: p.name, - description: `Stock: ${p.productStockCache?.stock || 0} ${p.unit}s | Tap to manage`, - })), - }, - ], - ); - return; - } - - // All products - const products = await this.prisma.product.findMany({ - where: { merchantId, deletedAt: null, isActive: true }, - include: { productStockCache: true }, - orderBy: { name: "asc" }, - take: 10, - }); - - if (products.length === 0) { - await this.interactiveService.sendTextMessage( - phone, - "No active product listings found.", - ); - return; - } - - await this.interactiveService.sendListMessage( - phone, - "📦 *Current Inventory Levels*", - "Manage Stock", - [ - { - title: "My Products", - rows: products.map((p) => ({ - id: `manage_stock_${p.id.substring(0, 8)}`, - title: p.name, - description: `Stock: ${p.productStockCache?.stock || 0} ${p.unit}s ${p.productStockCache?.stock <= 10 ? "⚠️ Low Stock" : ""}`, - })), - }, - ], - ); - } - - /** - * ✅ Update Stock - */ - private async handleUpdateStock( - merchantId: string, - phone: string, - productName: string, - quantity: number, - action?: string, - ): Promise { - if (!productName || !quantity) { - await this.interactiveService.sendTextMessage( - phone, - STOCK_UPDATE_FOLLOWUP, - ); - return; - } - - // Fuzzy match product by name - const product = await this.prisma.product.findFirst({ - where: { - merchantId, - deletedAt: null, - name: { startsWith: productName, mode: "insensitive" }, - }, - include: { productStockCache: true }, - }); - - if (!product) { - await this.interactiveService.sendTextMessage( - phone, - `No product matching "${productName}" found.`, - ); - return; - } - - const isRemove = action === "remove"; - const adjustedQty = isRemove ? -Math.abs(quantity) : Math.abs(quantity); - - try { - await this.inventoryService.manualAdjustment( - merchantId, - product.id, - adjustedQty, - `WhatsApp stock update`, - ); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : "Unknown error"; - this.logger.error(`Stock update failed: ${errorMsg}`); - await this.interactiveService.sendTextMessage( - phone, - `❌ Could not update stock for ${product.name}: ${errorMsg}`, - ); - return; - } - - const currentStock = (product.productStockCache?.stock ?? 0) + adjustedQty; - const symbol = isRemove ? "-" : "+"; - - let msg = `Stock updated. ✅\n\n`; - msg += `Product: ${product.name}\n`; - msg += `Adjustment: ${symbol}${Math.abs(quantity)}\n`; - msg += `Balance: ${currentStock} units`; - - if (currentStock <= 20 && currentStock > 0) { - msg += `\n\n⚠️ Low stock: ${product.name} — ${currentStock} units remaining.`; - } else if (currentStock <= 0) { - msg += `\n\n⚠️ Out of stock: ${product.name}. Listings hidden.`; - } - - await this.interactiveService.sendTextMessage(phone, msg); - } - - /** - * 📋 List Products - */ - private async handleGetProducts( - merchantId: string, - phone: string, - ): Promise { - const result = await this.productService.listByMerchant(merchantId, 1, 20); - const products = result.data; - - if (products.length === 0) { - await this.interactiveService.sendTextMessage( - phone, - "🏪 You have no products listed currently. Please add products via the Swifta web dashboard to begin selling. 🛒", - ); - return; - } - - // Meta interactive list supports max 10 rows - const displayedProducts = products.slice(0, 10); - - await this.interactiveService.sendListMessage( - phone, - `🏪 *Your Product Portfolio* (${products.length} items)`, - "View Details", - [ - { - title: "My Active Listings", - rows: displayedProducts.map((p) => ({ - id: `view_product_${p.id.substring(0, 8)}`, - title: p.name, - description: `Price: ${this.formatNaira(Number(p.pricePerUnitKobo || 0))} | Category: ${(p as any).category?.name || "General"}`, - })), - }, - ], - ); - } - - /** - * 🛒 Recent Orders - */ - private async handleGetRecentOrders( - merchantId: string, - phone: string, - ): Promise { - const orders = await this.prisma.order.findMany({ - where: { merchantId }, - orderBy: { createdAt: "desc" }, - take: 10, - include: { - product: true, - }, - }); - - if (orders.length === 0) { - await this.interactiveService.sendTextMessage( - phone, - "📦 No orders have been placed yet. Your recent orders will appear here once customers start purchasing your products.", - ); - return; - } - - await this.interactiveService.sendListMessage( - phone, - "📦 *Recent Customer Orders*", - "View Orders", - [ - { - title: "Orders (Last 10)", - rows: orders.map((o) => ({ - id: `manage_order_${o.id.substring(0, 8)}`, - title: `#${o.id.slice(0, 8).toUpperCase()} - ${o.status}`, - description: `${o.product?.name || "Product"} | ${this.formatNaira(Number(o.totalAmountKobo))}`, - })), - }, - ], - ); - } - - /** - * 🚚 Dispatch Order - */ - private async handleDispatchOrder( - merchantId: string, - phone: string, - orderReference?: string, - ): Promise { - if (!orderReference) { - await this.interactiveService.sendTextMessage( - phone, - `Which order would you like to dispatch? Please provide the order ID or short reference.`, - ); - return; - } - - // Fetch recent orders to find matching short ID - const activeOrders = await this.prisma.order.findMany({ - where: { - merchantId, - status: { in: ["PENDING_PAYMENT", "PAID"] }, - }, - select: { id: true }, - }); - - const targetOrder = activeOrders.find((o) => - o.id.toLowerCase().startsWith(orderReference.toLowerCase()), - ); - - if (!targetOrder) { - await this.interactiveService.sendTextMessage( - phone, - `❌ No active order matching ID "${orderReference}" was found. Please verify the ID and try again.`, - ); - return; - } - - try { - await this.orderService.dispatch(merchantId, targetOrder.id); - await this.interactiveService.sendTextMessage( - phone, - `✅ *Order #${orderReference.toUpperCase()} Dispatched*\n\nA verification code has been sent to the buyer. Please request this code upon delivery to confirm receipt.`, - ); - } catch (error: any) { - await this.interactiveService.sendTextMessage( - phone, - `❌ Dispatch failed: ${error.message}`, - ); - } - } - - /** - * 🚚 Update Order Tracking - */ - private async handleUpdateOrderTracking( - merchantId: string, - phone: string, - orderReference?: string, - status?: string, - note?: string, - ): Promise { - if (!orderReference || !status) { - await this.interactiveService.sendTextMessage( - phone, - `Please provide the order reference and the new status.\n\nE.g. *"update order ABC123 in transit"*`, - ); - return; - } - - // Fetch active orders to find matching short ID - const activeOrders = await this.prisma.order.findMany({ - where: { - merchantId, - status: { notIn: ["CANCELLED", "COMPLETED", "DISPUTE"] }, - }, - select: { id: true }, - }); - - const matches = activeOrders.filter((o) => - o.id.toLowerCase().startsWith(orderReference.toLowerCase()), - ); - - if (matches.length === 0) { - await this.interactiveService.sendTextMessage( - phone, - `❌ No active order matching "${orderReference}" was found. Please check your spelling.`, - ); - return; - } - - if (matches.length > 1) { - await this.interactiveService.sendTextMessage( - phone, - `⚠️ Multiple orders (${matches.length}) match "${orderReference}". Please provide a more specific reference ID.`, - ); - return; - } - - const targetOrder = matches[0]; - - // Normalize incoming status string - const normalizedStatus = status.trim().toUpperCase().replace(/[\s-]/g, "_"); - - // Map common phrases to valid enum values - const statusMap: Record = { - ON_THE_WAY: "IN_TRANSIT", - IN_TRANSIT: "IN_TRANSIT", - DISPATCHED: "DISPATCHED", - PREPARING: "PREPARING", - }; - const mappedStatus = statusMap[normalizedStatus] || normalizedStatus; - - // Must map string status back to OrderStatus enum based on what Gemini outputs - const validStatuses = ["PREPARING", "DISPATCHED", "IN_TRANSIT"]; - if (!validStatuses.includes(mappedStatus)) { - await this.interactiveService.sendTextMessage( - phone, - `Invalid status. Options: PREPARING, DISPATCHED, IN_TRANSIT.`, - ); - return; - } - - try { - await this.orderService.addTracking(merchantId, targetOrder.id, { - status: mappedStatus as OrderStatus, - note, - }); - - let msg = `Tracking updated. ✅\n`; - msg += `Order #${orderReference.toUpperCase()}\n`; - msg += `Status: ${mappedStatus.replace(/_/g, " ")}\n`; - if (note) msg += `Note: "${note}"\n`; - await this.interactiveService.sendTextMessage(phone, msg); - } catch (error: any) { - await this.interactiveService.sendTextMessage( - phone, - `❌ Tracking update failed: ${error.message}`, - ); - } - } - - /** - * ✅ Get Verification Status - */ - private async handleGetVerificationStatus( - merchantId: string, - phone: string, - ): Promise { - try { - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - select: { verificationTier: true, businessName: true }, - }); - - if (!merchant) { - await this.interactiveService.sendTextMessage( - phone, - `Merchant profile not found.`, - ); - return; - } - - const tier = (merchant as any).verificationTier || "UNVERIFIED"; - - let msg = ""; - switch (tier) { - case VerificationTier.UNVERIFIED: - msg = `Unverified Account. ⚠️\n\nVisit your dashboard settings to complete verification. Verified merchants receive lower fees and higher customer trust.\n\nDashboard: swifta.store/settings`; - break; - case VerificationTier.TIER_1: - msg = `Verification Tier: Basic.\n\nUpgrade your status to Verified to enjoy lower platform fees and direct customer payments.`; - break; - case VerificationTier.TIER_2: - msg = `Account Verified. ✅\n\nYou currently enjoy 1% platform fees and direct customer payments. Thank you for using Swifta.`; - break; - case VerificationTier.TIER_3: - msg = `Trusted Merchant Account. ✅\n\nYou have achieved the highest trust level. You benefit from minimal fees and featured listings.`; - break; - default: - msg = `Verification status: ${tier}. Please visit your dashboard for more details.`; - } - await this.interactiveService.sendTextMessage(phone, msg); - } catch (error) { - this.logger.error( - `Failed to fetch verification status for merchant ${merchantId}`, - error instanceof Error ? error.stack : "Unknown error", - ); - await this.interactiveService.sendTextMessage( - phone, - `❌ An error occurred while checking your verification status. Please try again later.`, - ); - } - } - - /** - * 🏷️ Update Product Price - */ - private async handleUpdateProductPrice( - merchantId: string, - phone: string, - productName?: string, - priceNaira?: number, - ): Promise { - if (!productName || !priceNaira) { - await this.interactiveService.sendTextMessage( - phone, - `To update a product's price, please provide the name and the new amount.\n\nExample: *"update price of iPhone to 900000"*`, - ); - return; - } - - // Find product fuzzy match - const target = await this.prisma.product.findFirst({ - where: { - merchantId, - deletedAt: null, - name: { contains: productName, mode: "insensitive" }, - }, - }); - - if (!target) { - await this.interactiveService.sendTextMessage( - phone, - `❌ Product "${productName}" was not found in your inventory. Please check your product list.`, - ); - return; - } - - try { - await this.productService.update(merchantId, target.id, { - pricePerUnitKobo: (priceNaira * 100).toString(), - }); - await this.interactiveService.sendTextMessage( - phone, - `Price updated. ✅\n\n${target.name} price is now ${this.formatNaira(priceNaira * 100)} per ${target.unit}.`, - ); - } catch (error: any) { - await this.interactiveService.sendTextMessage( - phone, - `❌ Price update failed: ${error.message}`, - ); - } - } - - async sendDirectOrderNotification( - merchantId: string, - orderData: any, - ): Promise { - try { - const link = await this.prisma.whatsAppLink.findFirst({ - where: { - OR: [ - { userId: merchantId }, - { user: { merchantProfile: { id: merchantId } } }, - ], - }, - select: { phone: true, isActive: true }, - }); - if (!link || !link.isActive) return; - - const shortId = orderData.orderId.substring(0, 8); - const bodyText = - `New order received. 📦\n\n` + - `Product: ${orderData.productName}\n` + - `Quantity: ${orderData.quantity}\n` + - `Total: ${this.formatNaira(Number(orderData.amountKobo))}\n\n` + - `Please prepare this order for dispatch.`; - - await this.interactiveService.sendReplyButtons(link.phone, bodyText, [ - { id: `manage_order_${shortId}`, title: "Manage Order" }, - { id: "show_merchant_menu", title: "Main Menu" }, - ]); - - this.logger.log( - `Direct order push notification sent to merchant ${merchantId} (phone: ${link.phone})`, - ); - } catch (error) { - this.logger.error( - `Failed to send direct order push notification to merchant ${merchantId}`, - ); - } - } - - async sendDeliveryConfirmedNotification( - merchantId: string, - payoutData: any, - ): Promise { - try { - const link = await this.prisma.whatsAppLink.findFirst({ - where: { - OR: [ - { userId: merchantId }, - { user: { merchantProfile: { id: merchantId } } }, - ], - }, - select: { phone: true, isActive: true }, - }); - if (!link || !link.isActive) return; - - let msg = `Delivery confirmed. ✅\n\n`; - msg += `Order #${payoutData.orderRef} — ${payoutData.quantity} ${payoutData.productName}\n`; - msg += `Buyer has confirmed receipt.\n\n`; - msg += `Payout of ${this.formatNaira(Number(payoutData.payoutAmountKobo))} sent to your ${payoutData.bankName} account.`; - - await this.sendWhatsAppMessage(link.phone, msg); - this.logger.log( - `Delivery confirmed push notification sent to merchant ${merchantId} (phone: ${link.phone})`, - ); - } catch (error) { - this.logger.error( - `Failed to send delivery confirmed push notification to merchant ${merchantId}`, - ); - } - } - - async sendPayoutCompletedNotification( - merchantId: string, - payoutData: any, - ): Promise { - try { - const link = await this.prisma.whatsAppLink.findFirst({ - where: { - OR: [ - { userId: merchantId }, - { user: { merchantProfile: { id: merchantId } } }, - ], - }, - select: { phone: true, isActive: true }, - }); - if (!link || !link.isActive) return; - - let msg = `Payout complete. ✅\n\n`; - msg += `₦${Number(payoutData.amountKobo) / 100} sent to your ${payoutData.bankName} account for Order #${payoutData.orderRef}.`; - - await this.sendWhatsAppMessage(link.phone, msg); - this.logger.log( - `Payout completed push notification sent to merchant ${merchantId} (phone: ${link.phone})`, - ); - } catch (error) { - this.logger.error( - `Failed to send payout completed push notification to merchant ${merchantId}`, - ); - } - } - - async sendPayoutFailedNotification( - merchantId: string, - payoutData: any, - ): Promise { - try { - const link = await this.prisma.whatsAppLink.findFirst({ - where: { - OR: [ - { userId: merchantId }, - { user: { merchantProfile: { id: merchantId } } }, - ], - }, - select: { phone: true, isActive: true }, - }); - if (!link || !link.isActive) return; - - let msg = `Payout delayed. ⚠️\n\n`; - msg += `Payout for Order #${payoutData.orderRef} is being reviewed. Our team will resolve this shortly.`; - - await this.sendWhatsAppMessage(link.phone, msg); - this.logger.log( - `Payout failed push notification sent to merchant ${merchantId} (phone: ${link.phone})`, - ); - } catch (error) { - this.logger.error( - `Failed to send payout failed push notification to merchant ${merchantId}`, - ); - } - } - - // ======================================================================= - // V4 Unified Notifications - // ======================================================================= - - async sendBuyerLogisticsUpdate( - phone: string, - orderId: string, - status: string, - trackingUrl?: string, - ): Promise { - const shortId = orderId.slice(0, 8); - let msg = ""; - - switch (status) { - case "PICKUP_SCHEDULED": - msg = `🚚 *Logistics Update*\n\nYour order #${shortId.toUpperCase()} has been scheduled for pickup. We will notify you once it is in transit.`; - break; - case "PICKED_UP": - case "IN_TRANSIT": - msg = `📦 *Order in Transit*\n\nYour order #${shortId.toUpperCase()} has been collected and is on its way to your delivery address.`; - break; - case "ARRIVING": - msg = `📍 *Arriving Shortly*\n\nYour order #${shortId.toUpperCase()} is nearby and should arrive within 15 minutes.`; - break; - case "DELIVERED": - msg = `✅ *Order Delivered Successfully*\n\nYour order #${shortId.toUpperCase()} has been delivered. Please share the 6-digit Delivery OTP with the rider to confirm receipt.`; - break; - case "FAILED": - msg = `⚠️ *Logistics Alert*\n\nThere was an issue delivering order #${shortId.toUpperCase()}. Our support team has been notified and will contact you shortly.`; - break; - default: - msg = `🚚 *Logistics Update*: Your order #${shortId.toUpperCase()} status is now ${status.replace(/_/g, " ").toLowerCase()}.`; - } - - if (trackingUrl) { - await this.interactiveService.sendCTAUrlButton( - phone, - msg, - "Track Live Location", - trackingUrl, - ); - } else { - await this.sendWhatsAppMessage(phone, msg); - } - } - - async sendMerchantLogisticsUpdate( - merchantId: string, - orderId: string, - status: string, - ): Promise { - try { - // Find the user associated with this merchant profile - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - select: { userId: true }, - }); - if (!merchant) return; - - const link = await this.prisma.whatsAppLink.findUnique({ - where: { userId: merchant.userId }, - select: { phone: true, isActive: true }, - }); - if (!link || !link.isActive) return; - - const shortId = orderId.slice(0, 6).toUpperCase(); - let msg = ""; - - switch (status) { - case "PICKUP_SCHEDULED": - msg = `🚚 *Rider is coming!*\n\nA rider is coming to pick up Order #${shortId}. Please have it ready for collection.`; - break; - case "PICKED_UP": - msg = `✅ *Order Picked Up!*\n\nOrder #${shortId} has been picked up successfully and is now on its way to the buyer.`; - break; - } - - if (msg) await this.sendWhatsAppMessage(link.phone, msg); - } catch (error) { - this.logger.error( - `Failed to send merchant logistics update for ${orderId}`, - ); - } - } - - async sendPaymentConfirmedNotification(phone: string, metadata: any) { - const amountNum = Number(metadata.amountKobo); - if ( - !metadata.reference || - !metadata.amountKobo || - !Number.isFinite(amountNum) - ) { - this.logger.error( - `Invalid metadata for payment confirmation (ref: ${metadata.reference}, amount: ${metadata.amountKobo}) to ${phone}`, - ); - return; - } - - const shortRef = metadata.reference.slice(0, 8); - const amountStr = this.formatNaira(amountNum); - const msg = `Payment Successful. ✅\n\nYour payment of ${amountStr} for Order #${shortRef.toUpperCase()} has been confirmed. The merchant is now preparing your order.`; - await this.sendWhatsAppMessage(phone, msg); - } - - async sendOrderDispatchedNotification(phone: string, metadata: any) { - if (!metadata.reference || !metadata.otp) { - this.logger.warn( - `Missing orderReference or OTP in sendOrderDispatchedNotification for ${phone}. Skipping.`, - ); - return; - } - - const shortRef = metadata.reference.slice(0, 8); - const otpSafe = String(metadata.otp); - const msg = `🚚 *Order Dispatched*\n\nYour order #${shortRef.toUpperCase()} is on the way! Your delivery code is *${otpSafe}*\n\nPlease provide this code to the merchant / dispatch rider upon delivery to confirm receipt.`; - - await this.sendWhatsAppMessage(phone, msg); - } - - // ======================================================================= - // Meta Cloud API — Send message - // ======================================================================= - async sendWhatsAppMessage(phone: string, text: string): Promise { - const url = `https://graph.facebook.com/${META_API_VERSION}/${this.phoneNumberId}/messages`; - - try { - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - messaging_product: "whatsapp", - to: phone, - type: "text", - text: { body: text }, - }), - }); - - if (!response.ok) { - const errorBody = await response.text(); - this.logger.error(`Meta API error (${response.status}): ${errorBody}`); - } - } catch (error) { - this.logger.error( - `Failed to send WhatsApp message to ${maskPhone(phone)}: ${error instanceof Error ? error.message : error}`, - ); - } - } - - /** - * Send a WhatsApp message using a pre-approved template. - * Required for initiating conversations outside the 24h window. - */ - async sendWhatsAppTemplateMessage( - phone: string, - templateName: string, - parameters: { type: "text"; text: string }[] = [], - ): Promise { - const otpCode = parameters.length > 0 ? parameters[0].text : ""; - this.logger.log( - `Dispatching template '${templateName}' to ${maskPhone(phone)}`, - ); - try { - await this.interactiveService.sendTemplateMessage( - phone, - templateName, - otpCode, - ); - } catch (error) { - this.logger.warn( - `Template failed, falling back to text for ${maskPhone(phone)}`, - ); - await this.interactiveService.sendTextMessage( - phone, - `Your Swifta OTP is: ${otpCode}. Please use this to confirm your action.`, - ); - // Rethrow to allow upstream SMS fallback if both WhatsApp methods fail - throw error; - } - } - - // ======================================================================= - // Helpers - // ======================================================================= - - /** Convert kobo (number) to formatted Naira string: ₦1,500,000 */ - private formatNaira(kobo: number): string { - const naira = kobo / 100; - return `₦${naira.toLocaleString("en-NG", { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`; - } - - /** Get date filter for timeframe queries */ - private getDateFilter(timeframe: string): Date | null { - const now = new Date(); - switch (timeframe) { - case "today": { - const start = new Date(now); - start.setHours(0, 0, 0, 0); - return start; - } - case "this_week": { - const start = new Date(now); - start.setDate(start.getDate() - start.getDay()); - start.setHours(0, 0, 0, 0); - return start; - } - case "this_month": { - const start = new Date(now.getFullYear(), now.getMonth(), 1); - return start; - } - case "all_time": - return null; - default: - return null; - } - } - - /** Friendly label for timeframe */ - private getTimeframeLabel(timeframe: string): string { - switch (timeframe) { - case "today": - return "Today"; - case "this_week": - return "This Week"; - case "this_month": - return "This Month"; - case "all_time": - return "All Time"; - default: - return "Today"; - } - } - - /** Format time remaining until a date */ - private formatTimeRemaining(date: Date): string { - const now = new Date(); - const diffMs = date.getTime() - now.getTime(); - - if (diffMs <= 0) return "Expired"; - - const hours = Math.floor(diffMs / (1000 * 60 * 60)); - const days = Math.floor(hours / 24); - - if (days > 1) return `Expires in ${days} days`; - if (days === 1) return "Expires tomorrow"; - if (hours > 1) return `Expires in ${hours}hrs`; - return "Expires soon ⚡"; - } - - // ======================================================================= - // Wholesale & Trade Financing Handlers (V4) - // ======================================================================= - - private async handleBuyWholesale( - merchantId: string, - phone: string, - productId: string, - quantity?: number, - ): Promise { - // Prisma doesn't support startsWith on UUID, so we fetch and filter - const allProducts = await this.prisma.supplierProduct.findMany({ - where: { isActive: true, supplier: { isVerified: true } }, - include: { supplier: true }, - }); - - const product = allProducts.find((p) => - p.id.toLowerCase().startsWith(productId.toLowerCase()), - ); - - if (!product) { - await this.interactiveService.sendTextMessage( - phone, - `❌ System ID starting with "${productId}" not found. Check the ID and try again.`, - ); - return; - } - - const qty = quantity || product.minOrderQty; - if (qty < product.minOrderQty) { - await this.interactiveService.sendTextMessage( - phone, - `⚠️ Min order for this item is ${product.minOrderQty} ${product.unit}. Please adjust your quantity.`, - ); - return; - } - - try { - const checkoutKey = `wa_pending_wholesale_${merchantId}`; - const session = { - productId: product.id, - quantity: qty, - unitPriceKobo: product.wholesalePriceKobo.toString(), - step: "SELECT_PAYMENT", - phone, - }; - await this.redisService.set(checkoutKey, JSON.stringify(session), 3600); - - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - select: { userId: true }, - }); - const eligibility = merchant - ? await this.tradeFinancingService.checkEligibility(merchant.userId) - : { eligible: false, reason: "Merchant not found", maxAmount: 0n }; - - const buttons: Array<{ id: string; title: string }> = [ - { id: "confirm_wholesale_pay_now", title: "Pay Now" }, - ]; - - let msg = `✅ Wholesale Order Initiated: *${product.name}*\nQuantity: *${qty} ${product.unit}s*\n\nHow would you like to proceed with payment?`; - - if (eligibility.eligible) { - buttons.push({ - id: "confirm_wholesale_trade_finance", - title: "Trade Finance", - }); - msg += `\n\nTrade Financing available up to ${this.formatNaira(Number(eligibility.maxAmount))}.`; - } else { - msg += `\n\n_Note: Trade Financing is currently unavailable: ${eligibility.reason}_`; - } - - await this.interactiveService.sendReplyButtons(phone, msg, buttons); - } catch (e) { - this.logger.error("Wholesale checkout initialization failed", e); - await this.interactiveService.sendTextMessage( - phone, - "An error occurred while initiating your wholesale order. Please try again or use the merchant dashboard.", - ); - } - } - - private async handleWholesaleCheckoutStep( - merchantId: string, - session: any, - text: string, - key: string, - phone: string, - ): Promise { - const input = text.trim(); - - if (session.step === "SELECT_PAYMENT") { - let paymentMethod = ""; - if (input === "confirm_wholesale_pay_now") { - paymentMethod = "PAY_NOW"; - } else if (input === "confirm_wholesale_trade_finance") { - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - select: { userId: true }, - }); - const eligibility = merchant - ? await this.tradeFinancingService.checkEligibility(merchant.userId) - : { eligible: false }; - if (!eligibility.eligible) { - await this.interactiveService.sendTextMessage( - phone, - "Sorry, Trade Financing is not available for your account yet. Please select Pay Now.", - ); - return; - } - paymentMethod = "TRADE_FINANCING"; - } else { - await this.interactiveService.sendTextMessage( - phone, - "Please select your payment method using the buttons provided.", - ); - return; - } - - // Complete - generate app link or pay stack link - await this.redisService.del(key); - const appUrl = - this.configService.get("FRONTEND_URL") || "https://Swifta.com"; - - if (paymentMethod === "PAY_NOW") { - const merchant = await this.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, - select: { userId: true, businessAddress: true }, - }); - - if (!merchant) { - await this.interactiveService.sendTextMessage( - phone, - "❌ Merchant profile not found.", - ); - return; - } - - if (!merchant.businessAddress) { - await this.interactiveService.sendTextMessage( - phone, - "❌ Your business address is not set. Please update your profile with a valid address before placing orders.", - ); - return; - } - - try { - const orderResponse = await this.wholesaleSupplierService.createOrder( - merchant.userId, - { - productId: session.productId, - quantity: session.quantity, - deliveryAddress: merchant.businessAddress, - }, - ); - - let msg = `✅ *Wholesale Details confirmed!*\n\n`; - msg += `*Item*: ${session.productId.substring(0, 8)} (${session.quantity} units)\n`; - msg += `*Payment*: ${paymentMethod.replace(/_/g, " ")}\n\n`; - - await this.interactiveService.sendCTAUrlButton( - phone, - msg + `🔗 *Tap below to securely pay via Paystack:*`, - "Pay Now", - orderResponse.authorizationUrl, - ); - } catch (error: any) { - this.logger.error("Wholesale order creation failed", error); - await this.interactiveService.sendTextMessage( - phone, - `❌ Failed to initiate order: ${error.message || "Unknown error"}`, - ); - } - } else { - // Trade financing usually needs a web checkout to sign terms - const checkoutLink = `${appUrl}/merchant/wholesale?productId=${session.productId}&qty=${session.quantity}&pay=${paymentMethod}`; - - let msg = `✅ *Wholesale Details confirmed!*\n\n`; - msg += `*Item*: ${session.productId.substring(0, 8)} (${session.quantity} units)\n`; - msg += `*Payment*: ${paymentMethod.replace(/_/g, " ")}\n\n`; - - await this.interactiveService.sendCTAUrlButton( - phone, - msg + `🔗 *Tap below to finalize this order:*`, - "Finalize Financing", - checkoutLink, - ); - } - return; - } - - await this.redisService.del(key); - await this.sendMerchantMenu(phone); - } - - // ======================================================================= - // Supplier Push Notifications - // ======================================================================= - - /** - * ⭐️ Review Prompt (Interactive) — called via BullMQ - */ - async sendReviewPrompt( - buyerId: string, - orderId: string, - merchantName: string, - productName: string, - ): Promise { - try { - // V5: Use WhatsAppBuyerLink for proper buyer resolution - const link = await this.prisma.whatsAppBuyerLink.findUnique({ - where: { buyerId }, - }); - - if (!link) return; - - const bodyText = `⭐️ *How was your order for ${productName}?*\n\nYour experience with *${merchantName}* matters! Please rate them below:`; - - await this.buyerService.sendWhatsAppReviewPrompt( - link.phone, - bodyText, - orderId, - ); - } catch (error) { - this.logger.error(`Failed to send review prompt to ${buyerId}: ${error}`); - } - } - /** - * 🏪 Handle Multi-step Product Creation - */ - private async handleProductCreationStep( - merchantId: string, - session: any, - messageText: string | undefined, - interactiveReply: any, - imageId: string | undefined, - sessionKey: string, - phone: string, - ): Promise { - const { step, data } = session; - - try { - if (step === "NAME") { - if (!messageText) { - await this.interactiveService.sendTextMessage( - phone, - "Please reply with the *Name* of the product.", - ); - return; - } - data.name = messageText; - session.step = "CATEGORY"; - - // Fetch categories for selection - const categories = await this.prisma.category.findMany({ - where: { isActive: true }, - take: 10, - }); - const rows = categories.map((c) => ({ - id: `prod_cat_${c.id}`, - title: (c.name || "Category").substring(0, 24), - })); - - await this.redisService.set( - sessionKey, - JSON.stringify(session), - 30 * 60, - ); - await this.interactiveService.sendListMessage( - phone, - `Product: *${data.name}*\n\nNext, select the *Category*:`, - "Select Category", - [{ title: "Categories", rows }], - ); - return; - } - - if (step === "CATEGORY") { - if (!interactiveReply || !interactiveReply.id.startsWith("prod_cat_")) { - await this.interactiveService.sendTextMessage( - phone, - "Please select a category from the list.", - ); - return; - } - data.categoryId = interactiveReply.id.replace("prod_cat_", ""); - data.categoryName = interactiveReply.title; - - // NEW: Check for category attributes - const category = (await this.prisma.category.findUnique({ - where: { id: data.categoryId }, - })) as any; - - const attrDefs = (category?.attributes as any[]) || []; - if (attrDefs.length > 0) { - session.step = "ATTRIBUTES"; - data.attributeIndex = 0; - data.attributes = {}; - - const firstAttr = attrDefs[0]; - await this.redisService.set( - sessionKey, - JSON.stringify(session), - 30 * 60, - ); - - let msg = `Category: *${data.categoryName}*\n\n`; - msg += `Please provide the *${firstAttr.name}*:`; - if (firstAttr.options && firstAttr.options.length > 0) { - const rows = firstAttr.options.slice(0, 10).map((opt: string) => ({ - id: `attr_opt_${opt.substring(0, 12)}`, - title: opt.substring(0, 24), - })); - await this.interactiveService.sendListMessage( - phone, - msg, - "Select Option", - [{ title: "Options", rows }], - ); - } else { - await this.interactiveService.sendTextMessage(phone, msg); - } - } else { - session.step = "UNIT"; - await this.redisService.set( - sessionKey, - JSON.stringify(session), - 30 * 60, - ); - await this.interactiveService.sendTextMessage( - phone, - "What is the *Unit* of measurement? (e.g., Bag, Piece, Set, Kg)", - ); - } - return; - } - - if (step === "ATTRIBUTES") { - const category = (await this.prisma.category.findUnique({ - where: { id: data.categoryId }, - })) as any; - const attrDefs = (category?.attributes as any[]) || []; - const currentIndex = data.attributeIndex || 0; - const currentAttr = attrDefs[currentIndex]; - - if (!currentAttr) { - // Fallback if index gets out of sync - session.step = "UNIT"; - await this.redisService.set( - sessionKey, - JSON.stringify(session), - 30 * 60, - ); - await this.interactiveService.sendTextMessage( - phone, - "What is the *Unit* of measurement?", - ); - return; - } - - // Store value - const val = interactiveReply ? interactiveReply.title : messageText; - if (!val) { - await this.interactiveService.sendTextMessage( - phone, - `Please provide the *${currentAttr.name}*.`, - ); - return; - } - - data.attributes[currentAttr.name] = val; - data.attributeIndex = currentIndex + 1; - - if (data.attributeIndex < attrDefs.length) { - const nextAttr = attrDefs[data.attributeIndex]; - await this.redisService.set( - sessionKey, - JSON.stringify(session), - 30 * 60, - ); - - const msg = `Next, *${nextAttr.name}*:`; - if (nextAttr.options && nextAttr.options.length > 0) { - const rows = nextAttr.options.slice(0, 10).map((opt: string) => ({ - id: `attr_opt_${opt.substring(0, 12)}`, - title: opt.substring(0, 24), - })); - await this.interactiveService.sendListMessage( - phone, - msg, - "Select Option", - [{ title: "Options", rows }], - ); - } else { - await this.interactiveService.sendTextMessage(phone, msg); - } - } else { - session.step = "UNIT"; - await this.redisService.set( - sessionKey, - JSON.stringify(session), - 30 * 60, - ); - await this.interactiveService.sendTextMessage( - phone, - "Got it! What is the *Unit* of measurement? (e.g., Bag, Piece, Set, Kg)", - ); - } - return; - } - - if (step === "UNIT") { - if (!messageText) { - await this.interactiveService.sendTextMessage( - phone, - "Please reply with the *Unit* (e.g., Bag).", - ); - return; - } - data.unit = messageText; - session.step = "WHOLESALE_PRICE"; - - await this.redisService.set( - sessionKey, - JSON.stringify(session), - 30 * 60, - ); - await this.interactiveService.sendTextMessage( - phone, - "What is the *Wholesale Price* per unit in Naira? (Numbers only)", - ); - return; - } - - if (step === "WHOLESALE_PRICE") { - const price = parseFloat(messageText || ""); - if (isNaN(price)) { - await this.interactiveService.sendTextMessage( - phone, - "Please enter a valid *Wholesale Price* (Numbers only, e.g. 8500).", - ); - return; - } - data.wholesalePriceNaira = price; - session.step = "RETAIL_PRICE"; - - await this.redisService.set( - sessionKey, - JSON.stringify(session), - 30 * 60, - ); - await this.interactiveService.sendTextMessage( - phone, - "What is the *Retail Price* (for individual consumers) in Naira? (Numbers only)", - ); - return; - } - - if (step === "RETAIL_PRICE") { - const price = parseFloat(messageText || ""); - if (isNaN(price)) { - await this.interactiveService.sendTextMessage( - phone, - "Please enter a valid *Retail Price* (Numbers only).", - ); - return; - } - data.retailPriceNaira = price; - session.step = "SHORT_DESCRIPTION"; - - await this.redisService.set( - sessionKey, - JSON.stringify(session), - 30 * 60, - ); - await this.interactiveService.sendTextMessage( - phone, - "Please provide a *Short Description* for consumers (approx. 60-100 characters).", - ); - return; - } - - if (step === "SHORT_DESCRIPTION") { - if (!messageText) { - await this.interactiveService.sendTextMessage( - phone, - "Please provide a *Short Description* description. (or reply 'skip')", - ); - return; - } - data.shortDescription = - messageText.toLowerCase() === "skip" ? null : messageText; - session.step = "IMAGE"; - - await this.redisService.set( - sessionKey, - JSON.stringify(session), - 30 * 60, - ); - await this.interactiveService.sendTextMessage( - phone, - "Great! Finally, please send a *Photo* of the product (or reply 'skip' to use a placeholder).", - ); - return; - } - - if (step === "IMAGE") { - if (messageText?.toLowerCase() === "skip") { - data.imageUrl = null; - } else if (imageId) { - // Simplified placeholder logic - data.imageUrl = `https://graph.facebook.com/v21.0/${imageId}`; - } else if (!messageText) { - await this.interactiveService.sendTextMessage( - phone, - "Please send a photo or reply 'skip'.", - ); - return; - } - - session.step = "CONFIRMATION"; - await this.redisService.set( - sessionKey, - JSON.stringify(session), - 30 * 60, - ); - - let summary = `📋 *Product Summary*\n\n`; - summary += `Name: *${data.name}*\n`; - summary += `Category: *${data.categoryName}*\n`; - summary += `Unit: *${data.unit}*\n`; - summary += `Wholesale Price: *₦${data.wholesalePriceNaira}*\n`; - summary += `Retail Price: *₦${data.retailPriceNaira}*\n`; - if (data.shortDescription) { - summary += `Description: _${data.shortDescription}_\n`; - } - - if (data.attributes && Object.keys(data.attributes).length > 0) { - summary += `\n*Specs:*\n`; - for (const [key, val] of Object.entries(data.attributes)) { - summary += `• ${key}: ${val}\n`; - } - } - - summary += `\nImage: *${data.imageUrl ? "✅ Provided" : "❌ Skipped"}*\n\n`; - summary += `Does everything look correct?`; - - await this.interactiveService.sendReplyButtons(phone, summary, [ - { id: "prod_confirm", title: "Confirm & Create" }, - { id: "prod_cancel", title: "Cancel" }, - ]); - return; - } - - if (step === "CONFIRMATION") { - if (interactiveReply?.id === "prod_cancel") { - await this.redisService.del(sessionKey); - await this.interactiveService.sendTextMessage( - phone, - "❌ Product creation cancelled.", - ); - return; - } - if (interactiveReply?.id === "prod_confirm") { - // Create product - await this.prisma.product.create({ - data: { - merchantId, - name: data.name, - unit: data.unit, - categoryId: data.categoryId, - categoryTag: data.categoryName, - pricePerUnitKobo: BigInt( - Math.round(data.wholesalePriceNaira * 100), - ), - retailPriceKobo: BigInt(Math.round(data.retailPriceNaira * 100)), - shortDescription: data.shortDescription, - imageUrl: data.imageUrl, - attributes: data.attributes || {}, - isActive: true, - } as any, - }); - - await this.redisService.del(sessionKey); - await this.interactiveService.sendTextMessage( - phone, - `✅ *Success!* "${data.name}" is now live on Swifta.`, - ); - return; - } - await this.interactiveService.sendTextMessage( - phone, - "Please tap 'Confirm' or 'Cancel'.", - ); - } - } catch (error) { - this.logger.error(`Error in product creation step ${step}:`, error); - await this.interactiveService.sendTextMessage( - phone, - "Something went wrong. Let's try again.", - ); - await this.redisService.del(sessionKey); - } - } -} diff --git a/apps/backend/src/prisma/bootstrap-admin-cli.ts b/apps/backend/src/prisma/bootstrap-admin-cli.ts new file mode 100644 index 00000000..9a0c6ad2 --- /dev/null +++ b/apps/backend/src/prisma/bootstrap-admin-cli.ts @@ -0,0 +1,55 @@ +import * as dotenv from "dotenv"; + +// Local env files are a fallback ONLY — loaded WITHOUT override so an injected +// environment (e.g. `railway run`) always wins. This is deliberately different +// from seed.ts (which overrides): running this against production via +// `railway run` must never be silently redirected to a local database by a +// stray .env.local. +dotenv.config({ path: ".env.local" }); +dotenv.config({ path: ".env" }); + +import { PrismaClient } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { Pool } from "pg"; +import { bootstrapSuperAdmin, type BootstrapPrisma } from "./bootstrap-admin"; + +// Standalone, production-safe entry point that runs ONLY the SUPER_ADMIN +// bootstrap — not the full seed (categories / size guides / plans). Safe to run +// once in production to create the single admin without seeding demo/reference +// data. Idempotent: a no-op once a SUPER_ADMIN exists. +async function run() { + const connectionString = process.env.DIRECT_URL ?? process.env.DATABASE_URL; + if (!connectionString) { + console.error( + "No DATABASE_URL / DIRECT_URL in the environment — aborting admin bootstrap.", + ); + process.exit(1); + } + + const pool = new Pool({ connectionString }); + const adapter = new PrismaPg(pool); + const prisma = new PrismaClient({ adapter }); + + try { + const result = await bootstrapSuperAdmin( + prisma as unknown as BootstrapPrisma, + { + email: process.env.ADMIN_BOOTSTRAP_EMAIL, + password: process.env.ADMIN_BOOTSTRAP_PASSWORD, + }, + ); + // Never logs the password — only the outcome status. + console.log(`Admin bootstrap result: ${result.status}`); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +} + +run().catch((error) => { + console.error( + "Admin bootstrap failed:", + error instanceof Error ? error.message : String(error), + ); + process.exit(1); +}); diff --git a/apps/backend/src/prisma/bootstrap-admin.spec.ts b/apps/backend/src/prisma/bootstrap-admin.spec.ts new file mode 100644 index 00000000..ea724f65 --- /dev/null +++ b/apps/backend/src/prisma/bootstrap-admin.spec.ts @@ -0,0 +1,144 @@ +import { UserRole } from "@twizrr/shared"; +import { bootstrapSuperAdmin, type BootstrapPrisma } from "./bootstrap-admin"; + +function makePrisma() { + return { + user: { + findFirst: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + create: jest.fn(), + }, + }; +} + +function makeLogger() { + return { log: jest.fn(), warn: jest.fn() }; +} + +describe("bootstrapSuperAdmin", () => { + it("creates a SUPER_ADMIN from env when none exists", async () => { + const prisma = makePrisma(); + const logger = makeLogger(); + prisma.user.findFirst.mockResolvedValue(null); + prisma.user.findUnique.mockResolvedValue(null); + prisma.user.create.mockResolvedValue({ id: "user-1" }); + + const result = await bootstrapSuperAdmin( + prisma as unknown as BootstrapPrisma, + { + email: "admin@twizrr.com", + password: "s3cret-pass", + logger, + }, + ); + + expect(result).toEqual({ status: "created", userId: "user-1" }); + const createArg = prisma.user.create.mock.calls[0][0] as { + data: Record; + }; + expect(createArg.data.email).toBe("admin@twizrr.com"); + expect(createArg.data.role).toBe(UserRole.SUPER_ADMIN); + // Password is hashed, never stored/echoed in plaintext. + expect(createArg.data.passwordHash).not.toBe("s3cret-pass"); + expect(typeof createArg.data.passwordHash).toBe("string"); + }); + + it("is idempotent — does nothing when a SUPER_ADMIN already exists", async () => { + const prisma = makePrisma(); + const logger = makeLogger(); + prisma.user.findFirst.mockResolvedValue({ id: "existing-admin" }); + + const result = await bootstrapSuperAdmin( + prisma as unknown as BootstrapPrisma, + { + email: "admin@twizrr.com", + password: "s3cret-pass", + logger, + }, + ); + + expect(result).toEqual({ status: "exists" }); + // No writes on repeat deploys — never resets the password. + expect(prisma.user.create).not.toHaveBeenCalled(); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("promotes an existing user with the bootstrap email to SUPER_ADMIN", async () => { + const prisma = makePrisma(); + prisma.user.findFirst.mockResolvedValue(null); + prisma.user.findUnique.mockResolvedValue({ id: "user-2" }); + prisma.user.update.mockResolvedValue({ id: "user-2" }); + + const result = await bootstrapSuperAdmin( + prisma as unknown as BootstrapPrisma, + { + email: "existing@twizrr.com", + password: "s3cret-pass", + }, + ); + + expect(result).toEqual({ status: "promoted", userId: "user-2" }); + const updateArg = prisma.user.update.mock.calls[0][0] as { + data: Record; + }; + expect(updateArg.data.role).toBe(UserRole.SUPER_ADMIN); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + it("never logs the password", async () => { + const prisma = makePrisma(); + const logger = makeLogger(); + prisma.user.findFirst.mockResolvedValue(null); + prisma.user.findUnique.mockResolvedValue(null); + prisma.user.create.mockResolvedValue({ id: "user-1" }); + + await bootstrapSuperAdmin(prisma as unknown as BootstrapPrisma, { + email: "admin@twizrr.com", + password: "super-secret-value", + logger, + }); + + const logged = [...logger.log.mock.calls, ...logger.warn.mock.calls] + .flat() + .join(" "); + expect(logged).not.toContain("super-secret-value"); + }); + + it("skips loudly when the password is missing", async () => { + const prisma = makePrisma(); + const logger = makeLogger(); + prisma.user.findFirst.mockResolvedValue(null); + + const result = await bootstrapSuperAdmin( + prisma as unknown as BootstrapPrisma, + { + email: "admin@twizrr.com", + password: undefined, + logger, + }, + ); + + expect(result).toEqual({ status: "skipped_no_password" }); + expect(logger.warn).toHaveBeenCalled(); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + it("skips loudly when the email is missing", async () => { + const prisma = makePrisma(); + const logger = makeLogger(); + prisma.user.findFirst.mockResolvedValue(null); + + const result = await bootstrapSuperAdmin( + prisma as unknown as BootstrapPrisma, + { + email: "", + password: "s3cret-pass", + logger, + }, + ); + + expect(result).toEqual({ status: "skipped_no_email" }); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/prisma/bootstrap-admin.ts b/apps/backend/src/prisma/bootstrap-admin.ts new file mode 100644 index 00000000..d7bc8778 --- /dev/null +++ b/apps/backend/src/prisma/bootstrap-admin.ts @@ -0,0 +1,104 @@ +import * as bcrypt from "bcrypt"; +import { UserRole } from "@twizrr/shared"; + +// Structural subset of PrismaClient the bootstrap needs — keeps this module +// free of side effects (no client instantiation) so it is unit-testable and +// safe to import from both seed.ts and tests. +export interface BootstrapPrisma { + user: { + findFirst(args: unknown): Promise<{ id: string } | null>; + findUnique(args: unknown): Promise<{ id: string } | null>; + update(args: unknown): Promise<{ id: string }>; + create(args: unknown): Promise<{ id: string }>; + }; +} + +export interface BootstrapAdminOptions { + email?: string; + password?: string; + logger?: Pick; +} + +export type BootstrapAdminResult = + | { status: "exists" } + | { status: "skipped_no_email" } + | { status: "skipped_no_password" } + | { status: "promoted"; userId: string } + | { status: "created"; userId: string }; + +const BCRYPT_ROUNDS = 10; +const BOOTSTRAP_PHONE = "+234000000000"; + +/** + * Idempotently ensure a single SUPER_ADMIN exists from environment config. + * + * Rules (see TWIZRR_ADMIN_AUDIT.md): + * - If any SUPER_ADMIN already exists, do nothing (never re-create or reset a + * password on repeat deploys). + * - Otherwise, with a configured email + password: promote an existing user + * with that email, or create a new SUPER_ADMIN. + * - The password is only ever hashed — it is never logged or returned. + * - Missing email or password is a loud, non-secret warning + skip. + * + * Never creates OPERATOR/SUPPORT/staff accounts — the admin model is a single + * SUPER_ADMIN. + */ +export async function bootstrapSuperAdmin( + prisma: BootstrapPrisma, + options: BootstrapAdminOptions = {}, +): Promise { + const logger = options.logger ?? console; + const email = options.email?.trim(); + const password = options.password; + + const existingAdmin = await prisma.user.findFirst({ + where: { role: UserRole.SUPER_ADMIN }, + }); + if (existingAdmin) { + logger.log("SUPER_ADMIN already present — bootstrap skipped."); + return { status: "exists" }; + } + + if (!email) { + logger.warn("ADMIN_BOOTSTRAP_EMAIL not set — skipping admin bootstrap."); + return { status: "skipped_no_email" }; + } + if (!password) { + logger.warn("ADMIN_BOOTSTRAP_PASSWORD not set — skipping admin bootstrap."); + return { status: "skipped_no_password" }; + } + + const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); + + const existingUser = await prisma.user.findUnique({ where: { email } }); + if (existingUser) { + await prisma.user.update({ + where: { id: existingUser.id }, + data: { + role: UserRole.SUPER_ADMIN, + adminProfile: { + upsert: { + create: { approvalStatus: "APPROVED" }, + update: { approvalStatus: "APPROVED" }, + }, + }, + }, + }); + logger.log("Promoted existing bootstrap user to SUPER_ADMIN."); + return { status: "promoted", userId: existingUser.id }; + } + + const created = await prisma.user.create({ + data: { + email, + phone: BOOTSTRAP_PHONE, + firstName: "twizrr", + lastName: "Admin", + passwordHash, + role: UserRole.SUPER_ADMIN, + adminProfile: { create: { approvalStatus: "APPROVED" } }, + }, + }); + logger.log("Created bootstrap SUPER_ADMIN user."); + return { status: "created", userId: created.id }; +} diff --git a/apps/backend/src/prisma/prisma.module.ts b/apps/backend/src/prisma/prisma.module.ts index 1edbf95e..4a46ddf0 100644 --- a/apps/backend/src/prisma/prisma.module.ts +++ b/apps/backend/src/prisma/prisma.module.ts @@ -1,9 +1 @@ -import { Global, Module } from "@nestjs/common"; -import { PrismaService } from "./prisma.service"; - -@Global() -@Module({ - providers: [PrismaService], - exports: [PrismaService], -}) -export class PrismaModule {} +export { PrismaModule } from "../core/prisma/prisma.module"; diff --git a/apps/backend/src/prisma/prisma.service.ts b/apps/backend/src/prisma/prisma.service.ts index 4080c863..e1de93fd 100644 --- a/apps/backend/src/prisma/prisma.service.ts +++ b/apps/backend/src/prisma/prisma.service.ts @@ -1,46 +1 @@ -import { - Injectable, - OnModuleInit, - OnModuleDestroy, - Logger, -} from "@nestjs/common"; -import { PrismaClient } from "@prisma/client"; -import { PrismaPg } from "@prisma/adapter-pg"; -import { Pool } from "pg"; - -@Injectable() -export class PrismaService - extends PrismaClient - implements OnModuleInit, OnModuleDestroy -{ - private readonly pool: Pool; - - constructor() { - const pool = new Pool({ - connectionString: process.env.DATABASE_URL, - max: 20, - idleTimeoutMillis: 30000, - }); - const adapter = new PrismaPg(pool); - super({ adapter }); - this.pool = pool; - } - - private readonly logger = new Logger(PrismaService.name); - - async onModuleInit() { - this.logger.log("Connecting to database..."); - try { - await this.$connect(); - this.logger.log("Successfully connected to database!"); - } catch (err) { - this.logger.error("Failed to connect to database", err); - throw err; - } - } - - async onModuleDestroy() { - this.logger.log("Closing database connection pool..."); - await this.pool.end(); - } -} +export { PrismaService } from "../core/prisma/prisma.service"; diff --git a/apps/backend/src/prisma/seed.ts b/apps/backend/src/prisma/seed.ts index bb093fd5..4fa02371 100644 --- a/apps/backend/src/prisma/seed.ts +++ b/apps/backend/src/prisma/seed.ts @@ -1,147 +1,416 @@ -import "dotenv/config"; -import { PrismaClient } from "@prisma/client"; -import * as bcrypt from "bcrypt"; -import { UserRole } from "@swifta/shared"; +import * as dotenv from "dotenv"; -const prisma = new PrismaClient(); +dotenv.config({ path: ".env" }); +dotenv.config({ path: ".env.local", override: true }); +import { GuideType, Prisma, PrismaClient } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { Pool } from "pg"; +import { PRODUCT_CATEGORY_TREE } from "@twizrr/shared"; +import { bootstrapSuperAdmin, type BootstrapPrisma } from "./bootstrap-admin"; +import { + STOREPASS_CURRENCY, + STOREPASS_PLAN_DEFINITIONS, +} from "../domains/money/storepass/storepass.constants"; -async function main() { - console.log("🌱 Starting Clean Database Seeding Process..."); +const pool = new Pool({ + connectionString: process.env.DIRECT_URL ?? process.env.DATABASE_URL, +}); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +const SIZE_GUIDES_TO_SEED: Array<{ + type: GuideType; + sizes: Prisma.InputJsonValue; +}> = [ + { + type: GuideType.CLOTHING_WOMEN, + sizes: [ + { + label: "XS", + bust: [76, 80], + waist: [56, 60], + hips: [82, 86], + height: [150, 160], + }, + { + label: "S", + bust: [80, 84], + waist: [60, 64], + hips: [86, 90], + height: [155, 165], + }, + { + label: "M", + bust: [84, 88], + waist: [64, 68], + hips: [90, 94], + height: [160, 170], + }, + { + label: "L", + bust: [88, 96], + waist: [68, 76], + hips: [94, 102], + height: [163, 173], + }, + { + label: "XL", + bust: [96, 104], + waist: [76, 84], + hips: [102, 110], + height: [165, 175], + }, + { + label: "XXL", + bust: [104, 112], + waist: [84, 92], + hips: [110, 118], + height: [165, 175], + }, + ], + }, + { + type: GuideType.CLOTHING_MEN, + sizes: [ + { + label: "S", + chest: [86, 90], + waist: [72, 76], + hips: [86, 90], + height: [165, 170], + }, + { + label: "M", + chest: [90, 96], + waist: [76, 82], + hips: [90, 96], + height: [170, 175], + }, + { + label: "L", + chest: [96, 102], + waist: [82, 88], + hips: [96, 102], + height: [175, 180], + }, + { + label: "XL", + chest: [102, 110], + waist: [88, 96], + hips: [102, 110], + height: [178, 183], + }, + { + label: "XXL", + chest: [110, 118], + waist: [96, 104], + hips: [110, 118], + height: [180, 185], + }, + { + label: "XXXL", + chest: [118, 126], + waist: [104, 112], + hips: [118, 126], + height: [182, 187], + }, + ], + }, + { + type: GuideType.CLOTHING_CHILDREN, + sizes: [ + { label: "2-3Y", chest: [52, 54], waist: [50, 52], height: [92, 98] }, + { label: "4-5Y", chest: [56, 58], waist: [53, 55], height: [104, 110] }, + { label: "6-7Y", chest: [60, 64], waist: [56, 58], height: [116, 122] }, + { label: "8-9Y", chest: [66, 70], waist: [59, 62], height: [128, 134] }, + { label: "10-11Y", chest: [72, 76], waist: [63, 66], height: [140, 146] }, + ], + }, + { + type: GuideType.CLOTHING_UNISEX, + sizes: [ + { label: "XS", chest: [80, 84], waist: [66, 70], height: [155, 165] }, + { label: "S", chest: [84, 90], waist: [70, 76], height: [160, 170] }, + { label: "M", chest: [90, 96], waist: [76, 82], height: [168, 176] }, + { label: "L", chest: [96, 104], waist: [82, 90], height: [172, 182] }, + { label: "XL", chest: [104, 112], waist: [90, 98], height: [176, 186] }, + ], + }, + { + type: GuideType.FOOTWEAR_WOMEN, + sizes: [ + { + eu: "35-36", + uk: "3-3.5", + us: "5-5.5", + footLengthCm: 22.5, + innerLengthCm: 23.0, + }, + { + eu: "36-37", + uk: "3.5-4", + us: "5.5-6", + footLengthCm: 23.0, + innerLengthCm: 23.5, + }, + { + eu: "38-39", + uk: "4.5-5", + us: "6.5-7", + footLengthCm: 24.0, + innerLengthCm: 24.5, + }, + { + eu: "40-41", + uk: "5.5-6", + us: "7.5-8", + footLengthCm: 25.0, + innerLengthCm: 25.5, + }, + { + eu: "42-43", + uk: "7-7.5", + us: "9-9.5", + footLengthCm: 26.0, + innerLengthCm: 26.5, + }, + ], + }, + { + type: GuideType.FOOTWEAR_MEN, + sizes: [ + { + eu: "38-39", + uk: "5-6", + us: "6-7", + footLengthCm: 24.5, + innerLengthCm: 25.0, + }, + { + eu: "40-41", + uk: "6.5-7", + us: "7.5-8", + footLengthCm: 25.5, + innerLengthCm: 26.0, + }, + { + eu: "42-43", + uk: "8-9", + us: "9-10", + footLengthCm: 26.5, + innerLengthCm: 27.0, + }, + { + eu: "44-45", + uk: "9.5-10", + us: "10.5-11", + footLengthCm: 27.5, + innerLengthCm: 28.0, + }, + { + eu: "46-47", + uk: "11-12", + us: "12-13", + footLengthCm: 28.5, + innerLengthCm: 29.0, + }, + ], + }, + { + type: GuideType.FOOTWEAR_CHILDREN, + sizes: [ + { + eu: "24-25", + uk: "7-8", + us: "8-9", + footLengthCm: 15.0, + innerLengthCm: 15.5, + }, + { + eu: "26-27", + uk: "8.5-9", + us: "9.5-10", + footLengthCm: 16.5, + innerLengthCm: 17.0, + }, + { + eu: "28-29", + uk: "10-11", + us: "11-12", + footLengthCm: 18.0, + innerLengthCm: 18.5, + }, + { + eu: "30-31", + uk: "12-12.5", + us: "13-13.5", + footLengthCm: 19.5, + innerLengthCm: 20.0, + }, + { + eu: "32-33", + uk: "13-1", + us: "1-2", + footLengthCm: 21.0, + innerLengthCm: 21.5, + }, + ], + }, +]; - const LEGACY_ADMIN_EMAIL = "admin@swifta.store"; - const BOOTSTRAP_ADMIN_EMAIL = - process.env.ADMIN_BOOTSTRAP_EMAIL || LEGACY_ADMIN_EMAIL; - const DEFAULT_ADMIN_PASSWORD = process.env.ADMIN_BOOTSTRAP_PASSWORD; +async function main() { + console.log("Starting database seed."); - // 1. Admin Bootstrap - const existingAdmin = await prisma.user.findFirst({ - where: { role: UserRole.SUPER_ADMIN }, + // 1. Admin Bootstrap — single SUPER_ADMIN from environment config. + // Idempotent: skips when a SUPER_ADMIN already exists (never resets the + // password on repeat deploys). The legacy default email is kept only as a + // fallback for existing deployments that set the password but not the email. + const LEGACY_ADMIN_EMAIL = "admin@twizrr.com"; + await bootstrapSuperAdmin(prisma as unknown as BootstrapPrisma, { + email: process.env.ADMIN_BOOTSTRAP_EMAIL || LEGACY_ADMIN_EMAIL, + password: process.env.ADMIN_BOOTSTRAP_PASSWORD, }); - if (!existingAdmin) { - if (!DEFAULT_ADMIN_PASSWORD) { - console.warn( - "⚠️ ADMIN_BOOTSTRAP_PASSWORD not set. Skipping admin creation.", - ); - } else { - const passwordHash = await bcrypt.hash(DEFAULT_ADMIN_PASSWORD, 10); + // 2. Product Categories (Structural) + console.log("Syncing product category taxonomy."); + const legacyBroadCategorySlugs = [ + "electronics", + "fashion", + "home-kitchen", + "health-beauty", + "phones-gadgets", + "other", + ]; - // Check if user with this email already exists - const existingUser = await prisma.user.findUnique({ - where: { email: BOOTSTRAP_ADMIN_EMAIL }, - }); + await prisma.category.updateMany({ + where: { slug: { in: legacyBroadCategorySlugs } }, + data: { isActive: false }, + }); + + for (let i = 0; i < PRODUCT_CATEGORY_TREE.length; i++) { + const category = PRODUCT_CATEGORY_TREE[i]; + const parent = await prisma.category.upsert({ + where: { slug: category.slug }, + update: { + name: category.label, + icon: category.icon, + sortOrder: i, + parentId: null, + isActive: true, + attributes: { + productTaxonomy: true, + requiresSizeGuide: + "requiresSizeGuide" in category && category.requiresSizeGuide, + }, + }, + create: { + name: category.label, + slug: category.slug, + icon: category.icon, + sortOrder: i, + isActive: true, + attributes: { + productTaxonomy: true, + requiresSizeGuide: + "requiresSizeGuide" in category && category.requiresSizeGuide, + }, + }, + }); - if (existingUser) { - console.log( - `🔄 Promoting existing user to Admin: ${BOOTSTRAP_ADMIN_EMAIL}`, - ); - await prisma.user.update({ - where: { id: existingUser.id }, - data: { - role: UserRole.SUPER_ADMIN, - adminProfile: { - upsert: { - create: { approvalStatus: "APPROVED" }, - update: { approvalStatus: "APPROVED" }, - }, - }, + for (let j = 0; j < category.subcategories.length; j++) { + const subcategory = category.subcategories[j]; + await prisma.category.upsert({ + where: { slug: subcategory.slug }, + update: { + name: subcategory.label, + icon: category.icon, + sortOrder: j, + parentId: parent.id, + isActive: true, + attributes: { + productTaxonomy: true, + requiresSizeGuide: + "requiresSizeGuide" in subcategory && + subcategory.requiresSizeGuide, }, - }); - } else { - await prisma.user.create({ - data: { - email: BOOTSTRAP_ADMIN_EMAIL, - phone: "+234000000000", - firstName: "Swifta", - lastName: "Admin", - passwordHash: passwordHash, - role: UserRole.SUPER_ADMIN, - adminProfile: { create: { approvalStatus: "APPROVED" } }, + }, + create: { + name: subcategory.label, + slug: subcategory.slug, + icon: category.icon, + sortOrder: j, + parentId: parent.id, + isActive: true, + attributes: { + productTaxonomy: true, + requiresSizeGuide: + "requiresSizeGuide" in subcategory && + subcategory.requiresSizeGuide, }, - }); - console.log(`🚀 Created Admin: ${BOOTSTRAP_ADMIN_EMAIL}`); - } + }, + }); } } - // 2. Marketplace Categories (Structural) - console.log(`\n📂 Syncing Product Categories...`); - const categoriesToSeed = [ - { name: "Electronics", slug: "electronics", icon: "devices" }, - { name: "Fashion", slug: "fashion", icon: "checkroom" }, - { name: "Home & Kitchen", slug: "home-kitchen", icon: "kitchen" }, - { name: "Health & Beauty", slug: "health-beauty", icon: "face" }, - { name: "Auto Parts", slug: "auto-parts", icon: "directions_car" }, - { name: "Agriculture", slug: "agriculture", icon: "agriculture" }, - { - name: "Office & Stationery", - slug: "office-stationery", - icon: "print", - }, - { - name: "Food & Groceries", - slug: "food-groceries", - icon: "local_grocery_store", - }, - { name: "Books & Media", slug: "books-media", icon: "menu_book" }, - { name: "Other", slug: "other", icon: "category" }, - ]; - - for (let i = 0; i < categoriesToSeed.length; i++) { - const cat = categoriesToSeed[i]; - await prisma.category.upsert({ - where: { slug: cat.slug }, - update: { name: cat.name, icon: cat.icon, sortOrder: i }, - create: { name: cat.name, slug: cat.slug, icon: cat.icon, sortOrder: i }, + for (const guide of SIZE_GUIDES_TO_SEED) { + await prisma.sizeGuide.upsert({ + where: { type: guide.type }, + update: { + sizes: guide.sizes, + version: "v1", + isActive: true, + }, + create: { + type: guide.type, + sizes: guide.sizes, + version: "v1", + isActive: true, + }, }); } - // 3. COMPLETE PURGE OF MOCKED/DEMO DATA - console.log(`\n🧹 Purging all demo and fake data...`); - - // Delete all seeded products and their caches - await prisma.productStockCache.deleteMany({ - where: { product: { isSeeded: true } }, - }); - await prisma.product.deleteMany({ where: { isSeeded: true } }); - await prisma.productAssociation.deleteMany({}); - - // Optional: Remove the demo merchant user and ALL their data if they exist - const DEMO_MERCHANT_EMAIL = "merchant@demo.swifta.store"; - const demoMerchant = await prisma.user.findUnique({ - where: { email: DEMO_MERCHANT_EMAIL }, - include: { merchantProfile: true }, - }); - - if (demoMerchant && demoMerchant.merchantProfile) { - const merchantId = demoMerchant.merchantProfile.id; - console.log( - `🗑️ Removing Demo Merchant (${DEMO_MERCHANT_EMAIL}) and their associated data...`, - ); - - // Reverse dependency deletion order - await prisma.inventoryEvent.deleteMany({ where: { merchantId } }); - await prisma.order.deleteMany({ where: { merchantId } }); - await prisma.creditApplication.deleteMany({ where: { merchantId } }); - await prisma.payoutRequest.deleteMany({ where: { merchantId } }); - await prisma.payout.deleteMany({ where: { merchantId } }); - // Add any other dependent models here - - await prisma.merchantProfile.deleteMany({ - where: { userId: demoMerchant.id }, + // StorePass plans (Twizrr-owned, monthly-only MVP). Idempotent upsert from + // the central plan definitions. Requires no Nomba credentials. + console.log("Syncing StorePass plans."); + for (const plan of STOREPASS_PLAN_DEFINITIONS) { + const benefits = plan.benefits as unknown as Prisma.InputJsonValue; + await prisma.storePassPlan.upsert({ + where: { code: plan.code }, + update: { + name: plan.name, + description: plan.description, + priceKobo: plan.priceKobo, + currency: STOREPASS_CURRENCY, + interval: plan.interval, + isActive: true, + sortOrder: plan.sortOrder, + benefits, + }, + create: { + code: plan.code, + name: plan.name, + description: plan.description, + priceKobo: plan.priceKobo, + currency: STOREPASS_CURRENCY, + interval: plan.interval, + isActive: true, + sortOrder: plan.sortOrder, + benefits, + }, }); - await prisma.user.delete({ where: { id: demoMerchant.id } }); } - console.log(`✅ Database is now clean and production-ready.`); + console.log("Database seed completed."); } main() .then(async () => { await prisma.$disconnect(); + await pool.end(); }) .catch(async (e) => { - console.error("❌ PURGE FAILED:", e); + console.error("Database seed failed:", e); await prisma.$disconnect(); + await pool.end(); process.exit(1); }); diff --git a/apps/backend/src/queue/auto-confirm.processor.ts b/apps/backend/src/queue/auto-confirm.processor.ts deleted file mode 100644 index e5cabd04..00000000 --- a/apps/backend/src/queue/auto-confirm.processor.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { Processor, WorkerHost } from "@nestjs/bullmq"; -import { Job } from "bullmq"; -import { Injectable, Logger, Inject, forwardRef } from "@nestjs/common"; -import { AUTO_CONFIRM_QUEUE } from "./queue.constants"; -import { PrismaService } from "../prisma/prisma.service"; -import { PayoutService } from "../modules/payout/payout.service"; -import { WhatsAppService } from "../modules/whatsapp/whatsapp.service"; -import { OrderStatus } from "@swifta/shared"; - -const AUTO_CONFIRM_HOURS = 168; // 7 days -const REMINDER_3DAYS = 72; -const REMINDER_5DAYS = 120; -const DISPUTE_WINDOW_HOURS = 48; // Additional window after auto-confirm - -@Injectable() -@Processor(AUTO_CONFIRM_QUEUE, { - drainDelay: 60000, - stalledInterval: 300000, - lockDuration: 60000, - metrics: null, -}) -export class AutoConfirmProcessor extends WorkerHost { - private readonly logger = new Logger(AutoConfirmProcessor.name); - - constructor( - private readonly prisma: PrismaService, - @Inject(forwardRef(() => PayoutService)) - private readonly payoutService: PayoutService, - private readonly whatsappService: WhatsAppService, - ) { - super(); - } - - async process(job: Job): Promise { - this.logger.log(`Auto-confirm job: ${job.name} (id=${job.id})`); - - switch (job.name) { - case "send-24h-reminder": - return this.send24hReminders(); - case "send-48h-warning": - return this.send48hWarnings(); - case "auto-confirm-deliveries": - return this.autoConfirmDeliveries(); - default: - this.logger.warn(`Unknown job name: ${job.name}`); - } - } - - /** - * D: Send 24h reminder to buyers who haven't confirmed delivery - */ - private async send24hReminders() { - const cutoffDate = new Date(Date.now() - REMINDER_3DAYS * 60 * 60 * 1000); - const twoDaysCutoff = new Date( - Date.now() - REMINDER_5DAYS * 60 * 60 * 1000, - ); - - const orders = await this.prisma.order.findMany({ - where: { - status: { in: [OrderStatus.DISPATCHED, OrderStatus.IN_TRANSIT] }, - updatedAt: { lte: cutoffDate, gt: twoDaysCutoff }, - disputeStatus: "NONE", - }, - include: { - user: true, - product: true, - supplierProduct: true, - }, - }); - - this.logger.log(`Sending 24h reminders to ${orders.length} buyers...`); - - for (const order of orders) { - const phone = order.user?.phone; - if (!phone) continue; - - const itemName = - order.product?.name ?? order.supplierProduct?.name ?? "your order"; - - try { - await this.whatsappService.sendWhatsAppMessage( - phone, - `⏰ *Delivery Reminder*\n\nYour order #${order.id.substring(0, 8)} for *${itemName}* was dispatched 72 hours ago. If it has arrived, please confirm delivery with your OTP code to complete the transaction.\n\nIf there's an issue with delivery, open a dispute via the Swifta app before the payment is released.`, - ); - } catch (err) { - this.logger.warn( - `Failed to send 24h reminder to order ${order.id}: ${err}`, - ); - } - } - } - - /** - * D: Send 48h final warning to buyers who haven't confirmed delivery - */ - private async send48hWarnings() { - const cutoffDate = new Date(Date.now() - REMINDER_5DAYS * 60 * 60 * 1000); - const threeDaysCutoff = new Date( - Date.now() - AUTO_CONFIRM_HOURS * 60 * 60 * 1000, - ); - - const orders = await this.prisma.order.findMany({ - where: { - status: { in: [OrderStatus.DISPATCHED, OrderStatus.IN_TRANSIT] }, - updatedAt: { lte: cutoffDate, gt: threeDaysCutoff }, - disputeStatus: "NONE", - }, - include: { - user: true, - product: true, - supplierProduct: true, - }, - }); - - this.logger.log(`Sending 48h warnings to ${orders.length} buyers...`); - - for (const order of orders) { - const phone = order.user?.phone; - if (!phone) continue; - - const itemName = - order.product?.name ?? order.supplierProduct?.name ?? "your order"; - - try { - await this.whatsappService.sendWhatsAppMessage( - phone, - `⚠️ *Final Reminder — Action Required*\n\nYour order #${order.id.substring(0, 8)} for *${itemName}* has been dispatched for 5 days.\n\n*If you do not confirm or dispute within 48 hours, the order will be auto-confirmed and the merchant will be paid automatically.*\n\nPlease confirm delivery with your OTP or open a dispute on the Swifta app.`, - ); - } catch (err) { - this.logger.warn( - `Failed to send 48h warning to order ${order.id}: ${err}`, - ); - } - } - } - - /** - * D: Auto-confirm orders that have been dispatched for 72+ hours with no confirmation or dispute - */ - private async autoConfirmDeliveries() { - const cutoffDate = new Date( - Date.now() - AUTO_CONFIRM_HOURS * 60 * 60 * 1000, - ); - - const orders = await this.prisma.order.findMany({ - where: { - status: { in: [OrderStatus.DISPATCHED, OrderStatus.IN_TRANSIT] }, - updatedAt: { lte: cutoffDate }, - disputeStatus: "NONE", - }, - include: { - user: true, - product: true, - supplierProduct: true, - merchantProfile: { - include: { user: true }, - }, - }, - }); - - this.logger.log(`Auto-confirming ${orders.length} overdue orders...`); - - for (const order of orders) { - try { - const disputeWindowEndsAt = new Date( - Date.now() + DISPUTE_WINDOW_HOURS * 60 * 60 * 1000, - ); - - // 1. Mark order as COMPLETED with dispute window - await (this.prisma.order as any).update({ - where: { id: order.id }, - data: { - status: OrderStatus.COMPLETED, - disputeWindowEndsAt, - }, - }); - - // 2. Create tracking event - await this.prisma.orderTracking.create({ - data: { - orderId: order.id, - status: OrderStatus.COMPLETED, - note: `Auto-confirmed after ${AUTO_CONFIRM_HOURS} hours without buyer confirmation. Dispute window ends at ${disputeWindowEndsAt.toISOString()}.`, - }, - }); - - // 3. Trigger merchant payout - try { - await this.payoutService.initiatePayout(order.id); - } catch (payoutErr) { - this.logger.error( - `Payout failed for auto-confirmed order ${order.id}: ${payoutErr}`, - ); - // Don't bail — order is still confirmed, payout can be retried manually - } - - const itemName = - order.product?.name ?? order.supplierProduct?.name ?? "your order"; - - // 4. Notify buyer - if (order.user?.phone) { - try { - await this.whatsappService.sendWhatsAppMessage( - order.user.phone, - `✅ *Order Auto-Confirmed*\n\nYour order #${order.id.substring(0, 8)} for *${itemName}* has been automatically confirmed after ${AUTO_CONFIRM_HOURS} hours.\n\nIf you did not receive your goods, you can open a dispute within the next ${DISPUTE_WINDOW_HOURS} hours via the Swifta app. After that, the payment will be released to the merchant.\n\nThank you for using Swifta! 🙏`, - ); - - // Trigger V5 Review Prompt - await this.whatsappService.sendReviewPrompt( - order.buyerId, - order.id, - order.merchantProfile?.businessName || "the merchant", - itemName, - ); - } catch (err) { - this.logger.warn(`Buyer notification failed for order ${order.id}`); - } - } - - // 5. Notify merchant - const merchantPhone = (order.merchantProfile as any)?.user?.phone; - if (merchantPhone) { - try { - await this.whatsappService.sendWhatsAppMessage( - merchantPhone, - `💰 *Order #${order.id.substring(0, 8)} Auto-Confirmed!*\n\nYour order for *${itemName}* has been auto-confirmed by the system. Payout is now processing.\n\nNote: The buyer has a ${DISPUTE_WINDOW_HOURS}-hour window to raise a dispute if needed.`, - ); - } catch (err) { - this.logger.warn( - `Merchant notification failed for order ${order.id}`, - ); - } - } - - this.logger.log(`Auto-confirmed order ${order.id} successfully`); - } catch (err) { - this.logger.error(`Failed to auto-confirm order ${order.id}: ${err}`); - } - } - - return { processed: orders.length }; - } -} diff --git a/apps/backend/src/queue/checkout-reminder.processor.ts b/apps/backend/src/queue/checkout-reminder.processor.ts deleted file mode 100644 index 21fe9906..00000000 --- a/apps/backend/src/queue/checkout-reminder.processor.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Processor, WorkerHost } from "@nestjs/bullmq"; -import { Job } from "bullmq"; -import { Injectable, Logger } from "@nestjs/common"; -import { CHECKOUT_REMINDER_QUEUE } from "./queue.constants"; -import { PrismaService } from "../prisma/prisma.service"; -import { WhatsAppService } from "../modules/whatsapp/whatsapp.service"; -import { OrderStatus } from "@swifta/shared"; - -@Injectable() -@Processor(CHECKOUT_REMINDER_QUEUE, { - drainDelay: 60000, - stalledInterval: 300000, - lockDuration: 60000, -}) -export class CheckoutReminderProcessor extends WorkerHost { - private readonly logger = new Logger(CheckoutReminderProcessor.name); - - constructor( - private readonly prisma: PrismaService, - private readonly whatsappService: WhatsAppService, - ) { - super(); - } - - async process(job: Job<{ orderId: string }, any, string>): Promise { - const { orderId } = job.data; - this.logger.log(`Processing checkout reminder for order: ${orderId}`); - - try { - const order = await this.prisma.order.findUnique({ - where: { id: orderId }, - include: { - user: { include: { buyerProfile: true } }, - product: true, - supplierProduct: true, - }, - }); - - if (!order) return; - if (order.status !== OrderStatus.PENDING_PAYMENT) { - this.logger.log( - `Order ${orderId} is no longer pending payment. Skipping reminder.`, - ); - return; - } - - const phone = order.user?.phone; - if (!phone) return; - - const itemName = - order.product?.name ?? order.supplierProduct?.name ?? "your items"; - - let msg = `🛒 *Checkout Reminder from Swifta*\n\nHi ${order.user.firstName || "there"}! Your order #${order.id.substring(0, 8).toUpperCase()} for *${itemName}* is waiting for payment.\n\n`; - - const buyerProfile = order.user?.buyerProfile; - if (buyerProfile?.dvaActive && buyerProfile?.dvaAccountNumber) { - const amountNaira = ( - Number(order.totalAmountKobo) / 100 - ).toLocaleString("en-NG", { - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }); - msg += `Please transfer ₦${amountNaira} to your Dedicated Virtual Account:\n\n*Bank*: ${buyerProfile.dvaBankName}\n*Account*: ${buyerProfile.dvaAccountNumber}\n*Name*: ${buyerProfile.dvaAccountName}\n\n`; - msg += `Your order will be processed automatically once payment is received. ⚡`; - } else { - const metadata = order.metadata as Record; - if (metadata?.checkoutUrl) { - msg += `Please complete your payment using this secure link: ${metadata.checkoutUrl}\n\n`; - msg += `This link will finalize your order immediately. ⚡`; - } else { - msg += `Please return to the Swifta shop to complete your payment or request a new checkout link from the menu. ⚡`; - } - } - - await this.whatsappService.sendWhatsAppMessage(phone, msg); - this.logger.log( - `Sent checkout reminder to ${phone} for order ${orderId}`, - ); - } catch (e) { - this.logger.error(`Failed to process checkout reminder: ${e}`); - } - } -} diff --git a/apps/backend/src/queue/logistics.processor.ts b/apps/backend/src/queue/logistics.processor.ts deleted file mode 100644 index c2a7a3b7..00000000 --- a/apps/backend/src/queue/logistics.processor.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Processor, WorkerHost } from "@nestjs/bullmq"; -import { Job } from "bullmq"; -import { Logger } from "@nestjs/common"; -import { LOGISTICS_QUEUE } from "./queue.constants"; -import { LogisticsService } from "../modules/logistics/logistics.service"; - -@Processor(LOGISTICS_QUEUE, { - drainDelay: 60000, - stalledInterval: 300000, - lockDuration: 60000, - metrics: null, -}) -export class LogisticsProcessor extends WorkerHost { - private readonly logger = new Logger(LogisticsProcessor.name); - - constructor(private readonly logisticsService: LogisticsService) { - super(); - } - - async process(job: Job): Promise { - this.logger.log(`Processing job ${job.id} of type ${job.name}`); - - try { - if (job.name === "book-pickup") { - if ( - !job.data || - typeof job.data.orderId !== "string" || - !job.data.orderId.trim() - ) { - this.logger.error( - `Job ${job.id} (book-pickup) has invalid or missing orderId. job.data=${JSON.stringify(job.data)}. Skipping.`, - ); - return; - } - await this.logisticsService.bookPickup(job.data.orderId); - } - } catch (error) { - this.logger.error( - `Failed to process logistics job ${job.id}: ${error instanceof Error ? error.message : error}`, - ); - throw error; - } - } -} diff --git a/apps/backend/src/queue/processors/audit-flush.processor.ts b/apps/backend/src/queue/processors/audit-flush.processor.ts new file mode 100644 index 00000000..e8d22342 --- /dev/null +++ b/apps/backend/src/queue/processors/audit-flush.processor.ts @@ -0,0 +1,11 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Job } from "bullmq"; + +import { QUEUE } from "../queue.constants"; + +@Processor(QUEUE.AUDIT_FLUSH) +export class AuditFlushProcessor extends WorkerHost { + async process(job: Job): Promise { + void job; + } +} diff --git a/apps/backend/src/queue/processors/auto-confirm-warning.processor.ts b/apps/backend/src/queue/processors/auto-confirm-warning.processor.ts new file mode 100644 index 00000000..f20bacea --- /dev/null +++ b/apps/backend/src/queue/processors/auto-confirm-warning.processor.ts @@ -0,0 +1,11 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Job } from "bullmq"; + +import { QUEUE } from "../queue.constants"; + +@Processor(QUEUE.AUTO_CONFIRM_WARNING) +export class AutoConfirmWarningProcessor extends WorkerHost { + async process(job: Job): Promise { + void job; + } +} diff --git a/apps/backend/src/queue/processors/auto-confirm.processor.spec.ts b/apps/backend/src/queue/processors/auto-confirm.processor.spec.ts new file mode 100644 index 00000000..cf15c907 --- /dev/null +++ b/apps/backend/src/queue/processors/auto-confirm.processor.spec.ts @@ -0,0 +1,52 @@ +import { Job } from "bullmq"; + +import { OrderService } from "../../domains/orders/order/order.service"; +import { AutoConfirmProcessor } from "./auto-confirm.processor"; + +describe("AutoConfirmProcessor", () => { + it("delegates a scheduled order to the canonical order service", async () => { + const orderService = { + autoConfirmDelivery: jest.fn().mockResolvedValue({ success: true }), + }; + const processor = new AutoConfirmProcessor(orderService as never); + + await processor.process({ + id: "auto-confirm-order-1", + name: "auto-confirm", + data: { + orderId: "order-1", + expectedStatus: "DISPATCHED", + }, + } as Job< + { orderId: string; expectedStatus: "DISPATCHED" }, + void, + "auto-confirm" + >); + + expect(orderService.autoConfirmDelivery).toHaveBeenCalledTimes(1); + expect(orderService.autoConfirmDelivery).toHaveBeenCalledWith("order-1"); + }); + + it("rejects malformed jobs instead of silently acknowledging them", async () => { + const orderService = { + autoConfirmDelivery: jest.fn(), + }; + const processor = new AutoConfirmProcessor( + orderService as unknown as OrderService, + ); + + await expect( + processor.process({ + id: "auto-confirm-invalid", + name: "auto-confirm", + data: {}, + } as unknown as Job< + { orderId: string; expectedStatus: "DISPATCHED" }, + void, + "auto-confirm" + >), + ).rejects.toThrow("Auto-confirm job is missing orderId"); + + expect(orderService.autoConfirmDelivery).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/queue/processors/auto-confirm.processor.ts b/apps/backend/src/queue/processors/auto-confirm.processor.ts new file mode 100644 index 00000000..446ed29e --- /dev/null +++ b/apps/backend/src/queue/processors/auto-confirm.processor.ts @@ -0,0 +1,35 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Logger } from "@nestjs/common"; +import { Job } from "bullmq"; + +import { OrderService } from "../../domains/orders/order/order.service"; +import { QUEUE } from "../queue.constants"; + +type AutoConfirmJobData = { + orderId: string; + expectedStatus: "DISPATCHED"; +}; + +@Processor(QUEUE.AUTO_CONFIRM) +export class AutoConfirmProcessor extends WorkerHost { + private readonly logger = new Logger(AutoConfirmProcessor.name); + + constructor(private readonly orderService: OrderService) { + super(); + } + + async process( + job: Job, + ): Promise { + const orderId = job.data?.orderId; + + if (!orderId) { + throw new Error("Auto-confirm job is missing orderId"); + } + + this.logger.log( + `Processing auto-confirm job ${job.id ?? "unknown"} for order ${orderId}`, + ); + await this.orderService.autoConfirmDelivery(orderId); + } +} diff --git a/apps/backend/src/queue/processors/delivery-estimate.processor.ts b/apps/backend/src/queue/processors/delivery-estimate.processor.ts new file mode 100644 index 00000000..b546b5ef --- /dev/null +++ b/apps/backend/src/queue/processors/delivery-estimate.processor.ts @@ -0,0 +1,11 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Job } from "bullmq"; + +import { QUEUE } from "../queue.constants"; + +@Processor(QUEUE.DELIVERY_ESTIMATE) +export class DeliveryEstimateProcessor extends WorkerHost { + async process(job: Job): Promise { + void job; + } +} diff --git a/apps/backend/src/queue/processors/floor-check.processor.ts b/apps/backend/src/queue/processors/floor-check.processor.ts new file mode 100644 index 00000000..6b20a196 --- /dev/null +++ b/apps/backend/src/queue/processors/floor-check.processor.ts @@ -0,0 +1,11 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Job } from "bullmq"; + +import { QUEUE } from "../queue.constants"; + +@Processor(QUEUE.FLOOR_CHECK) +export class FloorCheckProcessor extends WorkerHost { + async process(job: Job): Promise { + void job; + } +} diff --git a/apps/backend/src/queue/processors/moderation.processor.ts b/apps/backend/src/queue/processors/moderation.processor.ts new file mode 100644 index 00000000..d4bde042 --- /dev/null +++ b/apps/backend/src/queue/processors/moderation.processor.ts @@ -0,0 +1,11 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Job } from "bullmq"; + +import { QUEUE } from "../queue.constants"; + +@Processor(QUEUE.MODERATION) +export class ModerationProcessor extends WorkerHost { + async process(job: Job): Promise { + void job; + } +} diff --git a/apps/backend/src/queue/processors/notification.processor.ts b/apps/backend/src/queue/processors/notification.processor.ts new file mode 100644 index 00000000..6cd9e6b7 --- /dev/null +++ b/apps/backend/src/queue/processors/notification.processor.ts @@ -0,0 +1 @@ +export { NotificationProcessor } from "../../domains/social/notification/processors/notification.processor"; diff --git a/apps/backend/src/queue/processors/order-expiry.processor.ts b/apps/backend/src/queue/processors/order-expiry.processor.ts new file mode 100644 index 00000000..7ce51b3e --- /dev/null +++ b/apps/backend/src/queue/processors/order-expiry.processor.ts @@ -0,0 +1,11 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Job } from "bullmq"; + +import { QUEUE } from "../queue.constants"; + +@Processor(QUEUE.ORDER_EXPIRY) +export class OrderExpiryProcessor extends WorkerHost { + async process(job: Job): Promise { + void job; + } +} diff --git a/apps/backend/src/queue/processors/reconciliation.processor.ts b/apps/backend/src/queue/processors/reconciliation.processor.ts new file mode 100644 index 00000000..782f165b --- /dev/null +++ b/apps/backend/src/queue/processors/reconciliation.processor.ts @@ -0,0 +1,11 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Job } from "bullmq"; + +import { QUEUE } from "../queue.constants"; + +@Processor(QUEUE.RECONCILIATION) +export class ReconciliationProcessor extends WorkerHost { + async process(job: Job): Promise { + void job; + } +} diff --git a/apps/backend/src/queue/processors/restock-alert.processor.ts b/apps/backend/src/queue/processors/restock-alert.processor.ts new file mode 100644 index 00000000..82b9b6de --- /dev/null +++ b/apps/backend/src/queue/processors/restock-alert.processor.ts @@ -0,0 +1,11 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Job } from "bullmq"; + +import { QUEUE } from "../queue.constants"; + +@Processor(QUEUE.RESTOCK_ALERT) +export class RestockAlertProcessor extends WorkerHost { + async process(job: Job): Promise { + void job; + } +} diff --git a/apps/backend/src/queue/processors/review-prompt.processor.ts b/apps/backend/src/queue/processors/review-prompt.processor.ts new file mode 100644 index 00000000..b1212845 --- /dev/null +++ b/apps/backend/src/queue/processors/review-prompt.processor.ts @@ -0,0 +1,11 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Job } from "bullmq"; + +import { QUEUE } from "../queue.constants"; + +@Processor(QUEUE.REVIEW_PROMPT) +export class ReviewPromptProcessor extends WorkerHost { + async process(job: Job): Promise { + void job; + } +} diff --git a/apps/backend/src/queue/processors/session-cleanup.processor.ts b/apps/backend/src/queue/processors/session-cleanup.processor.ts new file mode 100644 index 00000000..e1477700 --- /dev/null +++ b/apps/backend/src/queue/processors/session-cleanup.processor.ts @@ -0,0 +1,11 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Job } from "bullmq"; + +import { QUEUE } from "../queue.constants"; + +@Processor(QUEUE.SESSION_CLEANUP) +export class SessionCleanupProcessor extends WorkerHost { + async process(job: Job): Promise { + void job; + } +} diff --git a/apps/backend/src/queue/queue.constants.ts b/apps/backend/src/queue/queue.constants.ts index 1dcc0458..b9b56105 100644 --- a/apps/backend/src/queue/queue.constants.ts +++ b/apps/backend/src/queue/queue.constants.ts @@ -1,8 +1,41 @@ -export const NOTIFICATION_QUEUE = "notification"; -export const REORDER_REMINDER_QUEUE = "reorder-reminder"; -export const WHATSAPP_QUEUE = "whatsapp"; -export const PAYOUT_QUEUE = "payout"; -export const LOGISTICS_QUEUE = "logistics"; -export const AUTO_CONFIRM_QUEUE = "auto-confirm"; -export const REVIEW_QUEUE = "review"; -export const CHECKOUT_REMINDER_QUEUE = "checkout-reminder"; +export const QUEUE = { + EMBEDDING: "embedding-generation", + PAYOUT: "payout", + PAYOUT_RETRY: "payout-retry", + NOTIFICATION: "notification", + WHATSAPP: "whatsapp", + AUTO_CONFIRM: "auto-confirm", + AUTO_CONFIRM_WARNING: "auto-confirm-warning", + FLOOR_CHECK: "floor-check", + MODERATION: "moderation", + DELIVERY_ESTIMATE: "delivery-estimate", + RECONCILIATION: "reconciliation", + RESTOCK_ALERT: "restock-alert", + ORDER_EXPIRY: "order-expiry", + REVIEW_PROMPT: "review-prompt", + SESSION_CLEANUP: "session-cleanup", + AUDIT_FLUSH: "audit-flush", + POST_FANOUT: "post-fanout", +} as const; + +export type QueueName = (typeof QUEUE)[keyof typeof QUEUE]; + +export const EMBEDDING_QUEUE = QUEUE.EMBEDDING; +export const PAYOUT_QUEUE = QUEUE.PAYOUT; +export const PAYOUT_RETRY_QUEUE = QUEUE.PAYOUT_RETRY; +export const NOTIFICATION_QUEUE = QUEUE.NOTIFICATION; +export const WHATSAPP_QUEUE = QUEUE.WHATSAPP; +export const AUTO_CONFIRM_QUEUE = QUEUE.AUTO_CONFIRM; +export const AUTO_CONFIRM_WARNING_QUEUE = QUEUE.AUTO_CONFIRM_WARNING; +export const FLOOR_CHECK_QUEUE = QUEUE.FLOOR_CHECK; +export const MODERATION_QUEUE = QUEUE.MODERATION; +export const DELIVERY_ESTIMATE_QUEUE = QUEUE.DELIVERY_ESTIMATE; +export const RECONCILIATION_QUEUE = QUEUE.RECONCILIATION; +export const RESTOCK_ALERT_QUEUE = QUEUE.RESTOCK_ALERT; +export const ORDER_EXPIRY_QUEUE = QUEUE.ORDER_EXPIRY; +export const REVIEW_PROMPT_QUEUE = QUEUE.REVIEW_PROMPT; +export const SESSION_CLEANUP_QUEUE = QUEUE.SESSION_CLEANUP; +export const AUDIT_FLUSH_QUEUE = QUEUE.AUDIT_FLUSH; +export const POST_FANOUT_QUEUE = QUEUE.POST_FANOUT; + +export const CHECKOUT_REMINDER_QUEUE = QUEUE.ORDER_EXPIRY; diff --git a/apps/backend/src/queue/queue.module.spec.ts b/apps/backend/src/queue/queue.module.spec.ts new file mode 100644 index 00000000..79acea18 --- /dev/null +++ b/apps/backend/src/queue/queue.module.spec.ts @@ -0,0 +1,24 @@ +import { MODULE_METADATA } from "@nestjs/common/constants"; +import { WhatsAppProcessor as ChannelWhatsAppProcessor } from "../channels/whatsapp/whatsapp.processor"; +import { WhatsAppModule } from "../channels/whatsapp/whatsapp.module"; +import { QueueModule } from "./queue.module"; + +function getModuleProviders(module: object): unknown[] { + return Reflect.getMetadata(MODULE_METADATA.PROVIDERS, module) ?? []; +} + +describe("QueueModule WhatsApp consumer registration", () => { + it("does not register a central WhatsApp queue consumer", () => { + const providerNames = getModuleProviders(QueueModule).map((provider) => + typeof provider === "function" ? provider.name : undefined, + ); + + expect(providerNames).not.toContain("WhatsAppProcessor"); + }); + + it("keeps the real WhatsApp channel processor registered", () => { + expect(getModuleProviders(WhatsAppModule)).toContain( + ChannelWhatsAppProcessor, + ); + }); +}); diff --git a/apps/backend/src/queue/queue.module.ts b/apps/backend/src/queue/queue.module.ts index d951c09a..39d50b55 100644 --- a/apps/backend/src/queue/queue.module.ts +++ b/apps/backend/src/queue/queue.module.ts @@ -1,48 +1,57 @@ -import { Module, Global, Logger, forwardRef } from "@nestjs/common"; import { BullModule } from "@nestjs/bullmq"; -import { ConfigService } from "@nestjs/config"; -import { WhatsAppModule } from "../modules/whatsapp/whatsapp.module"; -import { PayoutModule } from "../modules/payout/payout.module"; -import { - NOTIFICATION_QUEUE, - REORDER_REMINDER_QUEUE, - WHATSAPP_QUEUE, - PAYOUT_QUEUE, - LOGISTICS_QUEUE, - AUTO_CONFIRM_QUEUE, - REVIEW_QUEUE, - CHECKOUT_REMINDER_QUEUE, -} from "./queue.constants"; -import { AutoConfirmProcessor } from "./auto-confirm.processor"; -import { CheckoutReminderProcessor } from "./checkout-reminder.processor"; +import { Logger, Module } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; + +import { AutoConfirmWarningProcessor } from "./processors/auto-confirm-warning.processor"; +import { AuditFlushProcessor } from "./processors/audit-flush.processor"; +import { DeliveryEstimateProcessor } from "./processors/delivery-estimate.processor"; +import { FloorCheckProcessor } from "./processors/floor-check.processor"; +import { ModerationProcessor } from "./processors/moderation.processor"; +import { OrderExpiryProcessor } from "./processors/order-expiry.processor"; +import { ReconciliationProcessor } from "./processors/reconciliation.processor"; +import { RestockAlertProcessor } from "./processors/restock-alert.processor"; +import { ReviewPromptProcessor } from "./processors/review-prompt.processor"; +import { SessionCleanupProcessor } from "./processors/session-cleanup.processor"; +import { QUEUE } from "./queue.constants"; function sanitizeRedisUrl(url: string | undefined): string | undefined { - if (!url) return url; + if (!url) { + return undefined; + } + let cleanUrl = url.trim(); + if (cleanUrl.startsWith("REDIS_URL=")) { cleanUrl = cleanUrl.substring("REDIS_URL=".length); } - if (cleanUrl.startsWith('"') && cleanUrl.endsWith('"')) { - cleanUrl = cleanUrl.substring(1, cleanUrl.length - 1); - } - if (cleanUrl.startsWith("'") && cleanUrl.endsWith("'")) { + + if ( + (cleanUrl.startsWith('"') && cleanUrl.endsWith('"')) || + (cleanUrl.startsWith("'") && cleanUrl.endsWith("'")) + ) { cleanUrl = cleanUrl.substring(1, cleanUrl.length - 1); } + return cleanUrl.trim(); } -@Global() +const queueRegistrations = Object.values(QUEUE).map((name) => ({ name })); + @Module({ imports: [ BullModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], useFactory: (configService: ConfigService) => { - const rawUrl = configService.get("redis.url"); - const redisUrlString = sanitizeRedisUrl(rawUrl); + const redisUrlString = sanitizeRedisUrl( + configService.get("redis.url"), + ); if (!redisUrlString) { Logger.warn( "REDIS_URL not found for BullMQ, falling back to localhost", ); + return { connection: { host: "127.0.0.1", @@ -55,49 +64,78 @@ function sanitizeRedisUrl(url: string | undefined): string | undefined { }; } + // BullMQ talks Redis over TCP only. The Upstash REST URL + // (https://*.upstash.io) cannot be used as a queue connection + // — workers would hang trying to open a TCP socket on port 443. + // Fail fast with a clear message so the operator knows to set + // REDIS_URL to the Upstash TCP endpoint instead. + // + // RFC 3986 schemes are case-insensitive, so match HTTP/HTTPS in + // any casing — otherwise a stray `HTTPS://...` would slip past. + if (/^https?:\/\//i.test(redisUrlString)) { + Logger.error( + "BullMQ requires a TCP Redis URL (redis:// or rediss://), " + + "not a REST URL (https://). For Upstash, set REDIS_URL to " + + "rediss://default:@.upstash.io:6379.", + ); + throw new Error( + "Invalid REDIS_URL for QueueModule: must use redis:// or rediss:// (TCP), not https:// (REST).", + ); + } + try { const redisUrl = new URL(redisUrlString); + return { connection: { host: redisUrl.hostname, - port: parseInt(redisUrl.port, 10) || 6379, + port: Number.parseInt(redisUrl.port, 10) || 6379, password: redisUrl.password || undefined, username: redisUrl.username || undefined, - tls: - redisUrl.protocol === "rediss:" - ? { rejectUnauthorized: false } - : undefined, + tls: redisUrl.protocol === "rediss:" ? {} : undefined, family: 0, enableReadyCheck: false, + // BullMQ requires this to be null so blocking commands + // (BRPOPLPUSH, XREAD) are not auto-retried by ioredis. maxRetriesPerRequest: null, + // Upstash drops idle TCP sockets after ~5 minutes; keepAlive + // pings under that interval prevent silent ECONNRESETs while + // the retryStrategy reconnects cleanly when one slips through. + keepAlive: 30000, + connectTimeout: 10000, + retryStrategy: (times: number) => { + if (times > 3) { + Logger.warn( + `BullMQ Redis reconnect attempt ${times} — connection unstable`, + ); + } + return Math.min(times * 200, 2000); + }, }, prefix: "{bull}", }; - } catch (error: any) { - Logger.error( - `BullMQ failed to parse REDIS_URL prefix: ${redisUrlString.substring(0, 15)}...`, - ); - throw new Error( - `Invalid REDIS_URL for QueueModule: ${error.message}`, - ); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "Unknown Redis error"; + Logger.error("BullMQ failed to parse REDIS_URL"); + throw new Error(`Invalid REDIS_URL for QueueModule: ${message}`); } }, - inject: [ConfigService], }), - forwardRef(() => WhatsAppModule), - forwardRef(() => PayoutModule), - BullModule.registerQueue( - { name: NOTIFICATION_QUEUE }, - { name: REORDER_REMINDER_QUEUE }, - { name: WHATSAPP_QUEUE }, - { name: PAYOUT_QUEUE }, - { name: LOGISTICS_QUEUE }, - { name: AUTO_CONFIRM_QUEUE }, - { name: REVIEW_QUEUE }, - { name: CHECKOUT_REMINDER_QUEUE }, - ), + BullModule.registerQueue(...queueRegistrations), + ], + providers: [ + AutoConfirmWarningProcessor, + FloorCheckProcessor, + ModerationProcessor, + DeliveryEstimateProcessor, + ReconciliationProcessor, + RestockAlertProcessor, + OrderExpiryProcessor, + ReviewPromptProcessor, + SessionCleanupProcessor, + AuditFlushProcessor, ], - providers: [AutoConfirmProcessor, CheckoutReminderProcessor], exports: [BullModule], }) export class QueueModule {} diff --git a/apps/backend/src/redis/redis.module.ts b/apps/backend/src/redis/redis.module.ts index 99bdefc9..84d149e9 100644 --- a/apps/backend/src/redis/redis.module.ts +++ b/apps/backend/src/redis/redis.module.ts @@ -1,65 +1 @@ -import { Global, Module, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { RedisService } from "./redis.service"; -import Redis from "ioredis"; - -function sanitizeRedisUrl(url: string | undefined): string | undefined { - if (!url) return url; - let cleanUrl = url.trim(); - if (cleanUrl.startsWith("REDIS_URL=")) { - cleanUrl = cleanUrl.substring("REDIS_URL=".length); - } - if (cleanUrl.startsWith('"') && cleanUrl.endsWith('"')) { - cleanUrl = cleanUrl.substring(1, cleanUrl.length - 1); - } - if (cleanUrl.startsWith("'") && cleanUrl.endsWith("'")) { - cleanUrl = cleanUrl.substring(1, cleanUrl.length - 1); - } - return cleanUrl.trim(); -} - -@Global() -@Module({ - providers: [ - { - provide: "REDIS_CLIENT", - useFactory: (configService: ConfigService) => { - const rawUrl = configService.get("redis.url"); - const urlString = sanitizeRedisUrl(rawUrl); - - if (!urlString) { - Logger.warn( - "REDIS_URL not found in config, falling back to localhost", - ); - return new Redis("redis://127.0.0.1:6379", { - enableReadyCheck: false, - maxRetriesPerRequest: null, - }); - } - - try { - // If it's a full URL, ioredis can handle it directly. - const isTls = urlString.startsWith("rediss://"); - - return new Redis(urlString, { - tls: isTls ? { rejectUnauthorized: false } : undefined, - family: 0, - maxRetriesPerRequest: null, - enableReadyCheck: false, // Required for Upstash compatibility - }); - } catch (error: any) { - Logger.error( - `Failed to parse REDIS_URL prefix: ${urlString.substring(0, 15)}...`, - ); - throw new Error( - `Invalid REDIS_URL provided in environment variables: ${error.message}`, - ); - } - }, - inject: [ConfigService], - }, - RedisService, - ], - exports: ["REDIS_CLIENT", RedisService], -}) -export class RedisModule {} +export { REDIS_CLIENT, RedisModule } from "../core/redis/redis.module"; diff --git a/apps/backend/src/redis/redis.service.ts b/apps/backend/src/redis/redis.service.ts index da173c72..de765c4b 100644 --- a/apps/backend/src/redis/redis.service.ts +++ b/apps/backend/src/redis/redis.service.ts @@ -1,53 +1 @@ -import { Injectable, Inject } from "@nestjs/common"; -import Redis from "ioredis"; - -@Injectable() -export class RedisService { - constructor(@Inject("REDIS_CLIENT") private readonly redis: Redis) {} - - async get(key: string): Promise { - return this.redis.get(key); - } - - async set( - key: string, - value: string, - ttl?: number, - nx?: boolean, - ): Promise { - const args: any[] = [key, value]; - if (ttl) { - args.push("EX", ttl); - } - if (nx) { - args.push("NX"); - } - - const res = await (this.redis.set as any)(...args); - return res === "OK"; - } - - async del(key: string): Promise { - await this.redis.del(key); - } - - async exists(key: string): Promise { - return this.redis.exists(key); - } - - async hIncrBy( - key: string, - field: string, - increment: number, - ): Promise { - return this.redis.hincrby(key, field, increment); - } - - async hGetAll(key: string): Promise> { - return this.redis.hgetall(key); - } - - async keys(pattern: string): Promise { - return this.redis.keys(pattern); - } -} +export { RedisService } from "../core/redis/redis.service"; diff --git a/apps/backend/src/types/africastalking.d.ts b/apps/backend/src/types/africastalking.d.ts new file mode 100644 index 00000000..9a42e65c --- /dev/null +++ b/apps/backend/src/types/africastalking.d.ts @@ -0,0 +1,4 @@ +declare module "africastalking" { + const africastalking: any; + export = africastalking; +} diff --git a/apps/backend/sync-categories.ts b/apps/backend/sync-categories.ts deleted file mode 100644 index b21b44b5..00000000 --- a/apps/backend/sync-categories.ts +++ /dev/null @@ -1,66 +0,0 @@ -import "dotenv/config"; -import { PrismaClient } from "@prisma/client"; -import { PrismaPg } from "@prisma/adapter-pg"; -// @ts-expect-error - standalone script doesn't need full pg types -import { Pool } from "pg"; - -async function main() { - console.log(`\n📂 Syncing Product Categories Safely...`); - - if (!process.env.DATABASE_URL) { - console.error("DATABASE_URL environment variable is not set"); - process.exit(1); - } - - const pool = new Pool({ - connectionString: process.env.DATABASE_URL, - max: 2, - idleTimeoutMillis: 10000, - }); - const adapter = new PrismaPg(pool); - const prisma = new PrismaClient({ adapter }); - - try { - const categoriesToSeed = [ - { name: "Electronics", slug: "electronics", icon: "devices" }, - { name: "Fashion", slug: "fashion", icon: "checkroom" }, - { name: "Home & Kitchen", slug: "home-kitchen", icon: "kitchen" }, - { name: "Health & Beauty", slug: "health-beauty", icon: "face" }, - { name: "Auto Parts", slug: "auto-parts", icon: "directions_car" }, - { name: "Agriculture", slug: "agriculture", icon: "agriculture" }, - { name: "Office & Stationery", slug: "office-stationery", icon: "print" }, - { name: "Food & Groceries", slug: "food-groceries", icon: "local_grocery_store" }, - { name: "Books & Media", slug: "books-media", icon: "menu_book" }, - { name: "Other", slug: "other", icon: "category" }, - ]; - - for (let i = 0; i < categoriesToSeed.length; i++) { - const cat = categoriesToSeed[i]; - await prisma.category.upsert({ - where: { slug: cat.slug }, - update: { name: cat.name, icon: cat.icon, sortOrder: i, isActive: true }, - create: { name: cat.name, slug: cat.slug, icon: cat.icon, sortOrder: i, isActive: true }, - }); - } - - // Now, let's DEACTIVATE any legacy/test categories (like Phones & Gadgets or Baby & Kids) that are not in our official top 10 list - // This ensures only our clean 10 categories show up in the dropdown - const validSlugs = categoriesToSeed.map(c => c.slug); - - await prisma.category.updateMany({ - where: { slug: { notIn: validSlugs } }, - data: { isActive: false } - }); - - console.log(`✅ Categories synced successfully! Only the official 10 are now active.`); - } finally { - await prisma.$disconnect(); - await pool.end(); - } -} - -main() - .catch(async (e) => { - console.error("❌ SYNC FAILED:", e); - process.exit(1); - }); diff --git a/apps/backend/test/dispute-concurrency.e2e-spec.ts b/apps/backend/test/dispute-concurrency.e2e-spec.ts new file mode 100644 index 00000000..0b4761c7 --- /dev/null +++ b/apps/backend/test/dispute-concurrency.e2e-spec.ts @@ -0,0 +1,121 @@ +import { DisputeStatus, OrderStatus } from "@prisma/client"; +import { UserRole } from "@twizrr/shared"; + +import { DisputeService } from "../src/domains/orders/dispute/dispute.service"; +import { TestSetup } from "./helpers/test-setup"; + +/** + * PostgreSQL-backed proof that the partial unique index + * (disputes_one_active_per_order_key) prevents two simultaneous open requests + * from producing two active disputes for the same order. + * + * Requires a live database with migrations applied (CI Neon branch). Runs via + * `pnpm --filter @twizrr/backend test:e2e`. + */ +describe("Dispute concurrency (e2e)", () => { + jest.setTimeout(30000); + + (BigInt.prototype as unknown as { toJSON: () => string }).toJSON = + function () { + return this.toString(); + }; + + let ctx: TestSetup; + let service: DisputeService; + let buyerId: string; + let buyerEmail: string; + let storeId: string; + let orderId: string; + + beforeAll(async () => { + ctx = new TestSetup(); + await ctx.init(); + service = ctx.app.get(DisputeService); + + const buyer = await ctx.createMockBuyer(); + buyerId = buyer.user.id; + buyerEmail = buyer.user.email; + + const merchant = await ctx.createMockMerchant(); + storeId = merchant.storeProfile!.id; + + const order = await ctx.prisma.order.create({ + data: { + buyerId, + storeId, + status: OrderStatus.PAID, + totalAmountKobo: 2450000n, + deliveryFeeKobo: 150000n, + unitPriceKobo: 2450000n, + quantity: 1, + items: [], + idempotencyKey: `e2e-dispute-concurrency-${Date.now()}`, + }, + }); + orderId = order.id; + }); + + afterAll(async () => { + if (!ctx) return; + await ctx.prisma.outboxEvent.deleteMany({ + where: { aggregateType: "DISPUTE" }, + }); + await ctx.prisma.domainEvent.deleteMany({ + where: { aggregateType: "DISPUTE" }, + }); + await ctx.prisma.disputeEvidence.deleteMany({ + where: { dispute: { orderId } }, + }); + await ctx.prisma.dispute.deleteMany({ where: { orderId } }); + await ctx.prisma.orderEvent.deleteMany({ where: { orderId } }); + await ctx.prisma.order.deleteMany({ where: { id: orderId } }); + await ctx.prisma.storeProfile.deleteMany({ where: { id: storeId } }); + await ctx.prisma.user.deleteMany({ where: { id: buyerId } }); + await ctx.close(); + }); + + it("allows only one active dispute when two open requests race", async () => { + const buyerPayload = { + sub: buyerId, + email: buyerEmail, + role: UserRole.USER, + }; + const dto = { + reason: "damaged", + description: "The item arrived damaged", + evidence: [], + }; + + const results = await Promise.allSettled([ + service.openDispute(orderId, buyerPayload, dto), + service.openDispute(orderId, buyerPayload, dto), + ]); + + const fulfilled = results.filter((r) => r.status === "fulfilled"); + const rejected = results.filter( + (r): r is PromiseRejectedResult => r.status === "rejected", + ); + + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + + // The loser fails with a guarded conflict, never a duplicate insert. + const code = (rejected[0].reason as { response?: { code?: string } }) + ?.response?.code; + expect(["DISPUTE_ALREADY_OPEN", "DISPUTE_STATE_CONFLICT"]).toContain(code); + + const activeCount = await ctx.prisma.dispute.count({ + where: { + orderId, + status: { + in: [ + DisputeStatus.OPEN, + DisputeStatus.STORE_RESPONDED, + DisputeStatus.UNDER_REVIEW, + ], + }, + }, + }); + expect(activeCount).toBe(1); + }); +}); diff --git a/apps/backend/test/golden-path.e2e-spec.ts b/apps/backend/test/golden-path.e2e-spec.ts index a9287bbe..53204fb5 100644 --- a/apps/backend/test/golden-path.e2e-spec.ts +++ b/apps/backend/test/golden-path.e2e-spec.ts @@ -16,7 +16,7 @@ describe("The Golden Path (e2e)", () => { let buyerId: string; let merchantToken: string; - let merchantId: string; + let storeId: string; let productId: string; @@ -35,7 +35,7 @@ describe("The Golden Path (e2e)", () => { const merchantData = await ctx.createMockMerchant(); merchantToken = merchantData.token; - merchantId = merchantData.merchantProfile!.id; + storeId = merchantData.storeProfile!.id; // Seed a product const category = await ctx.prisma.category.upsert({ @@ -46,7 +46,7 @@ describe("The Golden Path (e2e)", () => { const product = await ctx.prisma.product.create({ data: { - merchantId: merchantId, + storeId: storeId, categoryId: category.id, name: "iPhone 15 Pro Max", categoryTag: category.name, @@ -68,8 +68,8 @@ describe("The Golden Path (e2e)", () => { await ctx.prisma.product.deleteMany(); if (buyerId) await ctx.prisma.user.delete({ where: { id: buyerId } }); - const m = await ctx.prisma.merchantProfile.findUnique({ - where: { id: merchantId }, + const m = await ctx.prisma.storeProfile.findUnique({ + where: { id: storeId }, }); if (m?.userId) await ctx.prisma.user.delete({ where: { id: m.userId } }); @@ -84,8 +84,7 @@ describe("The Golden Path (e2e)", () => { productId, quantity: 100, deliveryAddress: "Lekki Phase 1, Lagos", - paymentMethod: "ESCROW", - deliveryMethod: "MERCHANT_DELIVERY", + deliveryMethod: "STORE_DELIVERY", }) .expect(201); // Created diff --git a/apps/backend/test/helpers/test-setup.ts b/apps/backend/test/helpers/test-setup.ts index 0c827ed3..468476c1 100644 --- a/apps/backend/test/helpers/test-setup.ts +++ b/apps/backend/test/helpers/test-setup.ts @@ -3,12 +3,12 @@ import { Test, TestingModule } from "@nestjs/testing"; import { AppModule } from "../../src/app.module"; import { PrismaService } from "../../src/prisma/prisma.service"; import { JwtService } from "@nestjs/jwt"; -import { UserRole } from "@swifta/shared"; +import { UserRole } from "@twizrr/shared"; export class TestSetup { - public app: INestApplication; - public prisma: PrismaService; - public jwtService: JwtService; + public app!: INestApplication; + public prisma!: PrismaService; + public jwtService!: JwtService; async init(): Promise { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -29,7 +29,7 @@ export class TestSetup { } async createMockBuyer() { - const email = `test.buyer.${Date.now()}@swifta.store`; + const email = `test.buyer.${Date.now()}@twizrr.com`; const user = await this.prisma.user.create({ data: { email, @@ -37,7 +37,7 @@ export class TestSetup { passwordHash: "hashed_password", // Bypassing bcrypt for raw DB seed firstName: "Test", lastName: "Buyer", - role: UserRole.BUYER, + role: UserRole.USER, emailVerified: true, }, }); @@ -54,7 +54,7 @@ export class TestSetup { } async createMockMerchant() { - const email = `test.merchant.${Date.now()}@swifta.store`; + const email = `test.merchant.${Date.now()}@twizrr.com`; const user = await this.prisma.user.create({ data: { email, @@ -62,20 +62,19 @@ export class TestSetup { passwordHash: "hashed_password", // Bypassing bcrypt for raw DB seed firstName: "Test", lastName: "Merchant", - role: UserRole.MERCHANT, + role: UserRole.USER, emailVerified: true, - merchantProfile: { + storeProfile: { create: { businessName: "Zaza Fashion Hub", slug: "zaza-fashion-hub", businessAddress: "123 E2E Street, Lagos", - cacNumber: "RC123456", - verificationTier: "VERIFIED", + verificationTier: "TIER_1", }, }, }, include: { - merchantProfile: true, + storeProfile: true, }, }); @@ -84,7 +83,7 @@ export class TestSetup { sub: user.id, email: user.email, role: user.role, - merchantId: (user as any).merchantProfile?.id, + storeId: (user as any).storeProfile?.id, }, { secret: process.env.JWT_ACCESS_SECRET || "fallback-secret", @@ -92,6 +91,6 @@ export class TestSetup { }, ); - return { user, merchantProfile: (user as any).merchantProfile, token }; + return { user, storeProfile: (user as any).storeProfile, token }; } } diff --git a/apps/backend/test/jest-e2e.json b/apps/backend/test/jest-e2e.json index e96b1b0e..4d862f8e 100644 --- a/apps/backend/test/jest-e2e.json +++ b/apps/backend/test/jest-e2e.json @@ -7,6 +7,6 @@ "^.+\\.(t|j)s$": "ts-jest" }, "moduleNameMapper": { - "^@swifta/shared$": "/../../../packages/shared/src" + "^@twizrr/shared$": "/../../../packages/shared/src" } } diff --git a/apps/backend/test/jest-settlement-postgres.json b/apps/backend/test/jest-settlement-postgres.json new file mode 100644 index 00000000..b4038590 --- /dev/null +++ b/apps/backend/test/jest-settlement-postgres.json @@ -0,0 +1,13 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": "..", + "testEnvironment": "node", + "testRegex": "settlement-postgres\\.integration-spec\\.ts$", + "setupFiles": ["/test/settlement/setup.ts"], + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "moduleNameMapper": { + "^@twizrr/shared$": "/../../packages/shared/src" + } +} diff --git a/apps/backend/test/settlement/settlement-postgres.fixture.ts b/apps/backend/test/settlement/settlement-postgres.fixture.ts new file mode 100644 index 00000000..830034b3 --- /dev/null +++ b/apps/backend/test/settlement/settlement-postgres.fixture.ts @@ -0,0 +1,318 @@ +import { randomUUID } from "crypto"; +import { + DisputeResolutionOutcome, + DisputeSettlementLegStatus, + DisputeSettlementLegType, + DisputeSettlementStatus, + DisputeStatus, + LedgerDirection, + LedgerEntryType, + OrderStatus, + PaymentDirection, + PaymentProviderName, + PaymentStatus, + type Prisma, +} from "@prisma/client"; +import { UserRole } from "@twizrr/shared"; + +import { PrismaService } from "../../src/core/prisma/prisma.service"; +import { LedgerService } from "../../src/domains/money/ledger/ledger.service"; +import { CommerceOutboxService } from "../../src/domains/platform/outbox/commerce-outbox.service"; +import { OutboxService } from "../../src/domains/platform/outbox/outbox.service"; + +export type SettlementFixture = { + prefix: string; + buyerId: string; + merchantId: string; + storeId: string; + adminId: string; + orderId: string; + paymentId: string; + disputeId: string; + settlementId: string; + refundLegId: string | null; + payoutLegId: string | null; + capturedAmountKobo: bigint; + buyerRefundAmountKobo: bigint; + merchantPayoutAmountKobo: bigint; + platformRetainedAmountKobo: bigint; +}; + +export type SettlementFixtureInput = { + capturedAmountKobo?: bigint; + buyerRefundAmountKobo?: bigint; + merchantPayoutAmountKobo?: bigint; + platformRetainedAmountKobo?: bigint; + settlementStatus?: DisputeSettlementStatus; + refundLegStatus?: DisputeSettlementLegStatus; + payoutLegStatus?: DisputeSettlementLegStatus; + paymentProvider?: PaymentProviderName; +}; + +export async function createSettlementFixture( + prisma: PrismaService, + ledger: LedgerService, + input: SettlementFixtureInput = {}, +): Promise { + const prefix = `settlement-test-${randomUUID()}`; + const paymentProvider = input.paymentProvider ?? "PAYSTACK"; + const capturedAmountKobo = input.capturedAmountKobo ?? 1_000_003n; + const buyerRefundAmountKobo = + input.buyerRefundAmountKobo ?? capturedAmountKobo; + const merchantPayoutAmountKobo = input.merchantPayoutAmountKobo ?? 0n; + const platformRetainedAmountKobo = + input.platformRetainedAmountKobo ?? + capturedAmountKobo - buyerRefundAmountKobo - merchantPayoutAmountKobo; + + if ( + buyerRefundAmountKobo + + merchantPayoutAmountKobo + + platformRetainedAmountKobo !== + capturedAmountKobo + ) { + throw new Error("Settlement fixture amounts must balance exactly in kobo"); + } + + const buyer = await prisma.user.create({ + data: { + email: `${prefix}-buyer@example.test`, + phone: `+234${randomDigits()}`, + passwordHash: "test-only-hash", + firstName: "Buyer", + lastName: "Fixture", + role: UserRole.USER, + emailVerified: true, + }, + }); + const merchant = await prisma.user.create({ + data: { + email: `${prefix}-merchant@example.test`, + phone: `+234${randomDigits()}`, + passwordHash: "test-only-hash", + firstName: "Merchant", + lastName: "Fixture", + role: UserRole.USER, + emailVerified: true, + storeProfile: { + create: { + businessName: `${prefix} Store`, + slug: `${prefix}-store`, + businessAddress: "Test locality, Lagos", + paystackRecipientCode: "RCP_test_settlement", + bankCode: "999", + accountNumber: "0123456789", + accountName: "Settlement Fixture Store", + settlementAccountName: "Settlement Fixture Store", + }, + }, + }, + include: { storeProfile: true }, + }); + const admin = await prisma.user.create({ + data: { + email: `${prefix}-admin@example.test`, + phone: `+234${randomDigits()}`, + passwordHash: "test-only-hash", + firstName: "Admin", + lastName: "Fixture", + role: UserRole.SUPER_ADMIN, + emailVerified: true, + }, + }); + const storeId = merchant.storeProfile?.id; + if (!storeId) throw new Error("Settlement fixture store was not created"); + + const order = await prisma.order.create({ + data: { + buyerId: buyer.id, + storeId, + status: OrderStatus.DISPUTE, + totalAmountKobo: capturedAmountKobo, + deliveryFeeKobo: 0n, + platformFeeKobo: platformRetainedAmountKobo, + platformFeePercent: 0, + unitPriceKobo: capturedAmountKobo, + quantity: 1, + items: [], + idempotencyKey: `${prefix}:order`, + }, + }); + const payment = await prisma.payment.create({ + data: { + orderId: order.id, + provider: paymentProvider, + providerReference: `${prefix}:payment`, + ...(paymentProvider === "PAYSTACK" + ? { paystackReference: `${prefix}:payment` } + : { providerTransactionReference: `${prefix}:transaction` }), + amountKobo: capturedAmountKobo, + currency: "NGN", + status: PaymentStatus.SUCCESS, + direction: PaymentDirection.INFLOW, + idempotencyKey: `${prefix}:payment`, + verifiedAt: new Date(), + }, + }); + await ledger.recordEntry({ + entryType: LedgerEntryType.PAYMENT_RECEIVED, + direction: LedgerDirection.CREDIT, + amountKobo: capturedAmountKobo, + orderId: order.id, + paymentId: payment.id, + reference: payment.providerReference, + idempotencyKey: `${prefix}:captured`, + }); + const dispute = await prisma.dispute.create({ + data: { + orderId: order.id, + buyerId: buyer.id, + storeId, + status: DisputeStatus.RESOLVED_PARTIAL, + reason: "fixture", + description: "Settlement production-readiness fixture", + resolutionOutcome: + buyerRefundAmountKobo === capturedAmountKobo + ? DisputeResolutionOutcome.BUYER_WINS + : merchantPayoutAmountKobo === capturedAmountKobo + ? DisputeResolutionOutcome.SELLER_WINS + : DisputeResolutionOutcome.PARTIAL, + resolvedById: admin.id, + resolvedAt: new Date(), + }, + }); + const settlement = await prisma.disputeSettlement.create({ + data: { + disputeId: dispute.id, + orderId: order.id, + outcome: dispute.resolutionOutcome as DisputeResolutionOutcome, + status: input.settlementStatus ?? DisputeSettlementStatus.PENDING, + capturedAmountKobo, + buyerRefundAmountKobo, + merchantPayoutAmountKobo, + platformRetainedAmountKobo, + createdBy: admin.id, + idempotencyKey: `${prefix}:settlement`, + legs: { + create: [ + ...(buyerRefundAmountKobo > 0n + ? [ + { + type: DisputeSettlementLegType.BUYER_REFUND, + status: + input.refundLegStatus ?? DisputeSettlementLegStatus.PENDING, + amountKobo: buyerRefundAmountKobo, + idempotencyKey: `${prefix}:refund-leg`, + }, + ] + : []), + ...(merchantPayoutAmountKobo > 0n + ? [ + { + type: DisputeSettlementLegType.MERCHANT_PAYOUT, + status: + input.payoutLegStatus ?? DisputeSettlementLegStatus.PENDING, + amountKobo: merchantPayoutAmountKobo, + idempotencyKey: `${prefix}:payout-leg`, + }, + ] + : []), + ], + }, + }, + include: { legs: true }, + }); + + return { + prefix, + buyerId: buyer.id, + merchantId: merchant.id, + storeId, + adminId: admin.id, + orderId: order.id, + paymentId: payment.id, + disputeId: dispute.id, + settlementId: settlement.id, + refundLegId: + settlement.legs.find( + (leg) => leg.type === DisputeSettlementLegType.BUYER_REFUND, + )?.id ?? null, + payoutLegId: + settlement.legs.find( + (leg) => leg.type === DisputeSettlementLegType.MERCHANT_PAYOUT, + )?.id ?? null, + capturedAmountKobo, + buyerRefundAmountKobo, + merchantPayoutAmountKobo, + platformRetainedAmountKobo, + }; +} + +export async function cleanupSettlementFixture( + prisma: PrismaService, + fixture: SettlementFixture, +): Promise { + // Production ledger rows remain append-only. This helper runs only against + // the disposable settlement test database and deletes the complete isolated + // fixture in one transaction. Disabling the trigger and deleting the fixture + // ledger rows together avoids FK ON DELETE actions attempting forbidden + // ledger updates; rollback restores both data and trigger state on failure. + await prisma.$transaction(async (tx) => { + await tx.$executeRawUnsafe( + 'ALTER TABLE "ledger_entries" DISABLE TRIGGER "ledger_entries_append_only"', + ); + try { + await tx.notification.deleteMany({ + where: { + userId: { + in: [fixture.buyerId, fixture.merchantId, fixture.adminId], + }, + }, + }); + await tx.outboxEvent.deleteMany({ + where: { idempotencyKey: { startsWith: fixture.prefix } }, + }); + await tx.outboxEvent.deleteMany({ + where: { aggregateId: fixture.settlementId }, + }); + await tx.ledgerEntry.deleteMany({ + where: { orderId: fixture.orderId }, + }); + await tx.payout.deleteMany({ where: { orderId: fixture.orderId } }); + await tx.disputeSettlement.deleteMany({ + where: { id: fixture.settlementId }, + }); + await tx.dispute.deleteMany({ where: { id: fixture.disputeId } }); + await tx.paymentAmountException.deleteMany({ + where: { paymentId: fixture.paymentId }, + }); + await tx.payment.deleteMany({ where: { id: fixture.paymentId } }); + await tx.order.deleteMany({ where: { id: fixture.orderId } }); + await tx.storeProfile.deleteMany({ where: { id: fixture.storeId } }); + await tx.user.deleteMany({ + where: { + id: { in: [fixture.buyerId, fixture.merchantId, fixture.adminId] }, + }, + }); + } finally { + await tx.$executeRawUnsafe( + 'ALTER TABLE "ledger_entries" ENABLE TRIGGER "ledger_entries_append_only"', + ); + } + }); +} + +export function createSettlementServices(prisma: PrismaService): { + ledger: LedgerService; + outbox: OutboxService; + commerceOutbox: CommerceOutboxService; +} { + const ledger = new LedgerService(prisma); + const outbox = new OutboxService(prisma); + return { ledger, outbox, commerceOutbox: new CommerceOutboxService(outbox) }; +} + +function randomDigits(): string { + return Math.floor(1_000_000_000 + Math.random() * 8_999_999_999).toString(); +} + +export type TestTransaction = Prisma.TransactionClient; diff --git a/apps/backend/test/settlement/settlement-postgres.integration-spec.ts b/apps/backend/test/settlement/settlement-postgres.integration-spec.ts new file mode 100644 index 00000000..16e81afc --- /dev/null +++ b/apps/backend/test/settlement/settlement-postgres.integration-spec.ts @@ -0,0 +1,1175 @@ +import { + DisputeResolutionOutcome, + DisputeSettlementLegStatus, + DisputeSettlementRefundOperationStatus, + DisputeSettlementStatus, + LedgerEntryType, + OrderStatus, + PaymentAmountExceptionStatus, + PaymentAmountExceptionType, + PaymentAttemptStatus, + PaymentStatus, + PayoutProviderName, + PayoutStatus, +} from "@prisma/client"; + +import { SettlementConfig } from "../../src/config/settlement.config"; +import { PrismaService } from "../../src/core/prisma/prisma.service"; +import { SettlementExecutionService } from "../../src/domains/money/settlement/settlement-execution.service"; +import { SettlementService } from "../../src/domains/money/settlement/settlement.service"; +import { PayoutService } from "../../src/domains/money/payout/payout.service"; +import { PayoutWebhookService } from "../../src/domains/money/payout/payout-webhook.service"; +import { createPayoutProviderRegistry } from "../../src/domains/money/payout/providers/payout-provider.registry"; +import { DomainEventService } from "../../src/domains/platform/domain-event/domain-event.service"; +import { + cleanupSettlementFixture, + createSettlementFixture, + createSettlementServices, + type SettlementFixture, +} from "./settlement-postgres.fixture"; +import { + RefundProviderDouble, + PayoutProviderDouble, + SettlementPayoutDouble, +} from "./settlement-provider.doubles"; + +describe("Settlement PostgreSQL production-readiness", () => { + jest.setTimeout(60_000); + + let prisma: PrismaService; + let fixture: SettlementFixture | null = null; + + beforeAll(async () => { + (BigInt.prototype as unknown as { toJSON: () => string }).toJSON = + function () { + return this.toString(); + }; + prisma = new PrismaService(); + await prisma.onModuleInit(); + }); + + afterEach(async () => { + if (fixture) await cleanupSettlementFixture(prisma, fixture); + fixture = null; + process.env.DISPUTE_SETTLEMENT_EXECUTION_ENABLED = "false"; + }); + + afterAll(async () => { + await prisma.onModuleDestroy(); + }); + + function makeExecution( + refund: RefundProviderDouble, + payout: SettlementPayoutDouble, + ): SettlementExecutionService { + const { ledger, commerceOutbox } = createSettlementServices(prisma); + return new SettlementExecutionService( + prisma, + { + getForPaymentProvider: jest.fn().mockReturnValue(refund), + getByKey: jest.fn().mockReturnValue(refund), + } as never, + ledger, + payout as never, + commerceOutbox, + ); + } + + async function planSplitCollectionRefund(input: { + fixture: SettlementFixture; + initialCollectionKobo: bigint; + }): Promise<{ settlementId: string; refundLegId: string }> { + const current = input.fixture; + const remainingCollectionKobo = + current.capturedAmountKobo - input.initialCollectionKobo; + const payment = await prisma.payment.findUniqueOrThrow({ + where: { id: current.paymentId }, + }); + const source = await prisma.paymentAttempt.create({ + data: { + paymentId: payment.id, + provider: payment.provider, + providerReference: `${current.prefix}:collection:original`, + providerTransactionReference: + payment.provider === "MONNIFY" + ? `${current.prefix}:transaction:original` + : null, + expectedAmountKobo: current.capturedAmountKobo, + status: PaymentAttemptStatus.RECONCILIATION_REQUIRED, + reconciliationRequiredAt: new Date(), + }, + }); + const remainder = await prisma.paymentAttempt.create({ + data: { + paymentId: payment.id, + provider: payment.provider, + providerReference: `${current.prefix}:collection:remainder`, + providerTransactionReference: + payment.provider === "MONNIFY" + ? `${current.prefix}:transaction:remainder` + : null, + expectedAmountKobo: remainingCollectionKobo, + status: PaymentAttemptStatus.SUCCESS, + verifiedAt: new Date(), + }, + }); + await prisma.paymentAmountException.create({ + data: { + paymentId: payment.id, + paymentAttemptId: source.id, + remainingBalanceAttemptId: remainder.id, + provider: payment.provider, + providerReference: source.providerReference, + providerTransactionReference: source.providerTransactionReference, + expectedAmountKobo: current.capturedAmountKobo, + receivedAmountKobo: input.initialCollectionKobo, + exceptionType: PaymentAmountExceptionType.UNDERPAID, + status: PaymentAmountExceptionStatus.RESOLVED, + }, + }); + + await prisma.disputeSettlement.delete({ + where: { id: current.settlementId }, + }); + const order = await prisma.order.findUniqueOrThrow({ + where: { id: current.orderId }, + }); + const services = createSettlementServices(prisma); + const planner = new SettlementService( + services.ledger, + services.outbox, + services.commerceOutbox, + ); + const planned = await prisma.$transaction(async (tx) => { + const plan = await planner.buildPlan( + order, + DisputeResolutionOutcome.BUYER_WINS, + {}, + tx, + ); + return planner.createSettlementInTx(tx, { + disputeId: current.disputeId, + orderId: current.orderId, + outcome: DisputeResolutionOutcome.BUYER_WINS, + plan, + createdBy: current.adminId, + }); + }); + const leg = await prisma.disputeSettlementLeg.findFirstOrThrow({ + where: { + settlementId: planned.id, + type: "BUYER_REFUND", + }, + }); + current.settlementId = planned.id; + current.refundLegId = leg.id; + return { settlementId: planned.id, refundLegId: leg.id }; + } + + function makePayoutServices(input: { + monnify: PayoutProviderDouble; + paystack?: PayoutProviderDouble; + notifications?: { + triggerPayoutInitiated: jest.Mock; + triggerPayoutFailed: jest.Mock; + }; + }): { + service: PayoutService; + webhook: PayoutWebhookService; + notifications: { + triggerPayoutInitiated: jest.Mock; + triggerPayoutFailed: jest.Mock; + }; + } { + const paystack = + input.paystack ?? + new PayoutProviderDouble("paystack", "SUCCESS_IMMEDIATE"); + const registry = createPayoutProviderRegistry({ + paystack, + monnify: input.monnify, + }); + const notifications = input.notifications ?? { + triggerPayoutInitiated: jest.fn().mockResolvedValue(undefined), + triggerPayoutFailed: jest.fn().mockResolvedValue(undefined), + }; + const { ledger } = createSettlementServices(prisma); + const service = new PayoutService( + prisma, + registry, + notifications as never, + ledger, + { add: jest.fn().mockResolvedValue(undefined) } as never, + { add: jest.fn().mockResolvedValue(undefined) } as never, + new DomainEventService(), + ); + return { + service, + webhook: new PayoutWebhookService(prisma, registry), + notifications, + }; + } + + it("keeps execution disabled by default", () => { + delete process.env.DISPUTE_SETTLEMENT_EXECUTION_ENABLED; + expect(SettlementConfig.executionEnabled).toBe(false); + }); + + it("persists a disabled settlement plan without an execute outbox event or provider call", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger); + await prisma.disputeSettlement.delete({ + where: { id: fixture.settlementId }, + }); + const order = await prisma.order.findUniqueOrThrow({ + where: { id: fixture.orderId }, + }); + const planner = new SettlementService( + services.ledger, + services.outbox, + services.commerceOutbox, + ); + + const planned = await prisma.$transaction(async (tx) => { + const plan = await planner.buildPlan( + order, + DisputeResolutionOutcome.BUYER_WINS, + {}, + tx, + ); + return planner.createSettlementInTx(tx, { + disputeId: fixture?.disputeId as string, + orderId: fixture?.orderId as string, + outcome: DisputeResolutionOutcome.BUYER_WINS, + plan, + createdBy: fixture?.adminId as string, + }); + }); + fixture.settlementId = planned.id; + + const plan = await prisma.disputeSettlement.findUniqueOrThrow({ + where: { id: planned.id }, + include: { legs: true }, + }); + expect(plan.status).toBe(DisputeSettlementStatus.PENDING); + expect(plan.legs).toHaveLength(1); + expect( + await prisma.outboxEvent.count({ + where: { + topic: "dispute.settlement.execute.requested", + aggregateId: planned.id, + }, + }), + ).toBe(0); + }); + + it("atomically claims a refund leg when duplicate outbox deliveries race", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger); + const refund = new RefundProviderDouble("SUCCESS_IMMEDIATE"); + const payout = new SettlementPayoutDouble( + prisma, + services.ledger, + "SUCCESS_IMMEDIATE", + ); + const first = makeExecution(refund, payout); + const second = makeExecution(refund, payout); + + await Promise.all([ + first.executeSettlement(fixture.settlementId), + second.executeSettlement(fixture.settlementId), + ]); + + expect(refund.invocations).toHaveLength(1); + const leg = await prisma.disputeSettlementLeg.findUniqueOrThrow({ + where: { id: fixture.refundLegId as string }, + }); + expect(leg.status).toBe(DisputeSettlementLegStatus.COMPLETED); + expect(leg.attempts).toBe(1); + await expectCompletionEffects( + fixture.refundLegId as string, + "REFUND_COMPLETED", + ); + }); + + it("submits each split collection once and completes one aggregate refund ledger entry", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger, { + capturedAmountKobo: 1_000_003n, + buyerRefundAmountKobo: 1_000_003n, + }); + const split = await planSplitCollectionRefund({ + fixture, + initialCollectionKobo: 333_334n, + }); + const operations = await prisma.disputeSettlementRefundOperation.findMany({ + where: { settlementLegId: split.refundLegId }, + orderBy: { amountKobo: "asc" }, + }); + expect(operations.map((operation) => operation.amountKobo)).toEqual([ + 333_334n, + 666_669n, + ]); + + const refund = new RefundProviderDouble("SUCCESS_IMMEDIATE"); + const payout = new SettlementPayoutDouble( + prisma, + services.ledger, + "SUCCESS_IMMEDIATE", + ); + await Promise.all([ + makeExecution(refund, payout).executeSettlement(split.settlementId), + makeExecution(refund, payout).executeSettlement(split.settlementId), + ]); + + expect(refund.invocations).toHaveLength(2); + expect( + new Set(refund.invocations.map((call) => call.refundReference)).size, + ).toBe(2); + expect( + refund.invocations.reduce((sum, call) => sum + call.amountKobo, 0n), + ).toBe(fixture.capturedAmountKobo); + expect( + await prisma.disputeSettlementRefundOperation.count({ + where: { + settlementLegId: split.refundLegId, + status: DisputeSettlementRefundOperationStatus.COMPLETED, + }, + }), + ).toBe(2); + await expectCompletionEffects(split.refundLegId, "REFUND_COMPLETED"); + }); + + it("does not resubmit ambiguous split-collection refunds", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger); + const split = await planSplitCollectionRefund({ + fixture, + initialCollectionKobo: 400_001n, + }); + const refund = new RefundProviderDouble("TIMEOUT_AMBIGUOUS"); + const payout = new SettlementPayoutDouble( + prisma, + services.ledger, + "SUCCESS_IMMEDIATE", + ); + const execution = makeExecution(refund, payout); + + await execution.executeSettlement(split.settlementId); + await execution.executeSettlement(split.settlementId); + + expect(refund.invocations).toHaveLength(2); + expect( + await prisma.disputeSettlementRefundOperation.count({ + where: { + settlementLegId: split.refundLegId, + status: + DisputeSettlementRefundOperationStatus.RECONCILIATION_REQUIRED, + }, + }), + ).toBe(2); + expect( + await prisma.ledgerEntry.count({ + where: { + settlementLegId: split.refundLegId, + entryType: LedgerEntryType.REFUND_COMPLETED, + }, + }), + ).toBe(0); + }); + + it("makes concurrent split-operation confirmations produce one completion milestone", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger); + const split = await planSplitCollectionRefund({ + fixture, + initialCollectionKobo: 400_001n, + }); + const refund = new RefundProviderDouble("SUBMITTED_THEN_SUCCESS"); + const payout = new SettlementPayoutDouble( + prisma, + services.ledger, + "SUCCESS_IMMEDIATE", + ); + const execution = makeExecution(refund, payout); + await execution.executeSettlement(split.settlementId); + + const operations = await prisma.disputeSettlementRefundOperation.findMany({ + where: { settlementLegId: split.refundLegId }, + }); + expect(operations).toHaveLength(2); + expect( + operations.every( + (operation) => + operation.status === DisputeSettlementRefundOperationStatus.SUBMITTED, + ), + ).toBe(true); + + await Promise.all( + operations.flatMap((operation) => [ + execution.confirmRefundOperation( + operation.id, + operation.providerRefundId as string, + operation.amountKobo, + ), + makeExecution(refund, payout).confirmRefundOperation( + operation.id, + operation.providerRefundId as string, + operation.amountKobo, + ), + ]), + ); + await Promise.all([ + execution.rollUpRefundOperations(fixture.orderId, split.refundLegId), + makeExecution(refund, payout).rollUpRefundOperations( + fixture.orderId, + split.refundLegId, + ), + ]); + await Promise.all([ + execution.rollUpSettlement(split.settlementId), + makeExecution(refund, payout).rollUpSettlement(split.settlementId), + ]); + + await expectCompletionEffects(split.refundLegId, "REFUND_COMPLETED"); + expect( + await prisma.outboxEvent.count({ + where: { + idempotencyKey: `settlement:${split.settlementId}:leg:${split.refundLegId}:state:COMPLETED:notification:${fixture.buyerId}`, + }, + }), + ).toBe(1); + }); + + it("atomically claims a payout leg when duplicate outbox deliveries race", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger, { + buyerRefundAmountKobo: 0n, + merchantPayoutAmountKobo: 1_000_003n, + platformRetainedAmountKobo: 0n, + }); + const refund = new RefundProviderDouble("SUCCESS_IMMEDIATE"); + const payout = new SettlementPayoutDouble( + prisma, + services.ledger, + "SUCCESS_IMMEDIATE", + ); + await Promise.all([ + makeExecution(refund, payout).executeSettlement(fixture.settlementId), + makeExecution(refund, payout).executeSettlement(fixture.settlementId), + ]); + + expect(payout.invocations).toHaveLength(1); + const leg = await prisma.disputeSettlementLeg.findUniqueOrThrow({ + where: { id: fixture.payoutLegId as string }, + }); + expect(leg.status).toBe(DisputeSettlementLegStatus.COMPLETED); + expect(leg.attempts).toBe(1); + await expectCompletionEffects( + fixture.payoutLegId as string, + "PAYOUT_COMPLETED", + ); + }); + + it("submits one Monnify transfer when two real payout services claim the same leg", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger, { + paymentProvider: "MONNIFY", + buyerRefundAmountKobo: 0n, + merchantPayoutAmountKobo: 1_000_003n, + platformRetainedAmountKobo: 0n, + payoutLegStatus: DisputeSettlementLegStatus.PROCESSING, + settlementStatus: DisputeSettlementStatus.PROCESSING, + }); + const monnify = new PayoutProviderDouble( + "monnify", + "PROCESSING_THEN_SUCCESS", + ); + const input = { + legId: fixture.payoutLegId as string, + orderId: fixture.orderId, + storeId: fixture.storeId, + amountKobo: fixture.merchantPayoutAmountKobo, + approvedBy: fixture.adminId, + }; + + const results = await Promise.all([ + makePayoutServices({ monnify }).service.executeSettlementPayout(input), + makePayoutServices({ monnify }).service.executeSettlementPayout(input), + ]); + + expect(results.map((result) => result.status)).toEqual([ + "SUBMITTED", + "SUBMITTED", + ]); + expect(monnify.submissions).toHaveLength(1); + expect(monnify.submissions[0]).toMatchObject({ + amountKobo: fixture.merchantPayoutAmountKobo, + destination: { + bankCode: "999", + accountNumber: "0123456789", + }, + }); + const payout = await prisma.payout.findUniqueOrThrow({ + where: { orderId: fixture.orderId }, + include: { attempts: true }, + }); + expect(payout).toMatchObject({ + provider: PayoutProviderName.MONNIFY, + status: PayoutStatus.SUBMITTED, + settlementLegId: fixture.payoutLegId, + }); + expect(payout.attempts).toHaveLength(1); + expect(payout.attempts[0]).toMatchObject({ + provider: PayoutProviderName.MONNIFY, + status: "SUBMITTED", + }); + expect( + await prisma.ledgerEntry.count({ + where: { + payoutId: payout.id, + entryType: LedgerEntryType.PAYOUT_COMPLETED, + }, + }), + ).toBe(0); + }); + + it("completes one Monnify payout ledger movement under concurrent reconciliation", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger, { + paymentProvider: "MONNIFY", + buyerRefundAmountKobo: 0n, + merchantPayoutAmountKobo: 1_000_003n, + platformRetainedAmountKobo: 0n, + payoutLegStatus: DisputeSettlementLegStatus.PROCESSING, + settlementStatus: DisputeSettlementStatus.PROCESSING, + }); + const monnify = new PayoutProviderDouble( + "monnify", + "PROCESSING_THEN_SUCCESS", + ); + const sharedNotifications = { + triggerPayoutInitiated: jest.fn().mockResolvedValue(undefined), + triggerPayoutFailed: jest.fn().mockResolvedValue(undefined), + }; + const first = makePayoutServices({ + monnify, + notifications: sharedNotifications, + }); + await first.service.executeSettlementPayout({ + legId: fixture.payoutLegId as string, + orderId: fixture.orderId, + storeId: fixture.storeId, + amountKobo: fixture.merchantPayoutAmountKobo, + approvedBy: fixture.adminId, + }); + const payout = await prisma.payout.findUniqueOrThrow({ + where: { orderId: fixture.orderId }, + }); + monnify.setTransferStatus("SUCCESS"); + + await Promise.all([ + first.service.reconcileSubmittedPayout(payout.id), + makePayoutServices({ + monnify, + notifications: sharedNotifications, + }).service.reconcileSubmittedPayout(payout.id), + ]); + + expect(monnify.verifications).toHaveLength(2); + expect( + await prisma.ledgerEntry.count({ + where: { + payoutId: payout.id, + settlementLegId: fixture.payoutLegId, + entryType: LedgerEntryType.PAYOUT_COMPLETED, + }, + }), + ).toBe(1); + expect( + await prisma.domainEvent.count({ + where: { + aggregateType: "PAYOUT", + aggregateId: payout.id, + eventType: "PAYOUT_SENT", + }, + }), + ).toBe(1); + expect(sharedNotifications.triggerPayoutInitiated).toHaveBeenCalledTimes(1); + expect( + await prisma.payoutAttempt.count({ + where: { payoutId: payout.id, status: "COMPLETED" }, + }), + ).toBe(1); + }); + + it("dedupes repeated Monnify webhook wake-ups without completing money movement", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger, { + paymentProvider: "MONNIFY", + buyerRefundAmountKobo: 0n, + merchantPayoutAmountKobo: 1_000_003n, + platformRetainedAmountKobo: 0n, + payoutLegStatus: DisputeSettlementLegStatus.PROCESSING, + settlementStatus: DisputeSettlementStatus.PROCESSING, + }); + const monnify = new PayoutProviderDouble( + "monnify", + "PROCESSING_THEN_SUCCESS", + ); + const payoutServices = makePayoutServices({ monnify }); + await payoutServices.service.executeSettlementPayout({ + legId: fixture.payoutLegId as string, + orderId: fixture.orderId, + storeId: fixture.storeId, + amountKobo: fixture.merchantPayoutAmountKobo, + approvedBy: fixture.adminId, + }); + const payout = await prisma.payout.findUniqueOrThrow({ + where: { orderId: fixture.orderId }, + include: { attempts: true }, + }); + await prisma.payout.update({ + where: { id: payout.id }, + data: { status: PayoutStatus.PROCESSING, nextReconcileAt: null }, + }); + const payload = { reference: payout.attempts[0].providerReference }; + + const results = await Promise.all([ + payoutServices.webhook.handleMonnifyWebhook({ + rawBody: Buffer.from(JSON.stringify(payload)), + signature: "valid-test-signature", + payload, + }), + makePayoutServices({ monnify }).webhook.handleMonnifyWebhook({ + rawBody: Buffer.from(JSON.stringify(payload)), + signature: "valid-test-signature", + payload, + }), + ]); + + expect(results).toEqual( + expect.arrayContaining([{ status: "accepted" }, { status: "ignored" }]), + ); + expect( + await prisma.ledgerEntry.count({ + where: { + payoutId: payout.id, + entryType: LedgerEntryType.PAYOUT_COMPLETED, + }, + }), + ).toBe(0); + expect( + await prisma.payoutAttempt.count({ where: { payoutId: payout.id } }), + ).toBe(1); + }); + + it("reconciles an ambiguous Monnify timeout without a second provider submission", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger, { + paymentProvider: "MONNIFY", + buyerRefundAmountKobo: 0n, + merchantPayoutAmountKobo: 1_000_003n, + platformRetainedAmountKobo: 0n, + payoutLegStatus: DisputeSettlementLegStatus.PROCESSING, + settlementStatus: DisputeSettlementStatus.PROCESSING, + }); + const monnify = new PayoutProviderDouble("monnify", "TIMEOUT_AMBIGUOUS"); + const first = makePayoutServices({ monnify }); + const input = { + legId: fixture.payoutLegId as string, + orderId: fixture.orderId, + storeId: fixture.storeId, + amountKobo: fixture.merchantPayoutAmountKobo, + approvedBy: fixture.adminId, + }; + + await first.service.executeSettlementPayout(input); + await makePayoutServices({ monnify }).service.executeSettlementPayout( + input, + ); + + expect(monnify.submissions).toHaveLength(1); + const payout = await prisma.payout.findUniqueOrThrow({ + where: { orderId: fixture.orderId }, + include: { attempts: true }, + }); + expect(payout.status).toBe(PayoutStatus.SUBMITTED); + expect(payout.providerStatus).toBe("UNKNOWN"); + expect(payout.attempts).toHaveLength(1); + expect(payout.attempts[0].status).toBe("RECONCILIATION_REQUIRED"); + expect( + await prisma.ledgerEntry.count({ + where: { + payoutId: payout.id, + entryType: LedgerEntryType.PAYOUT_COMPLETED, + }, + }), + ).toBe(0); + + monnify.setTransferStatus("SUCCESS"); + await first.service.reconcileSubmittedPayout(payout.id); + expect(monnify.submissions).toHaveLength(1); + expect( + await prisma.ledgerEntry.count({ + where: { + payoutId: payout.id, + entryType: LedgerEntryType.PAYOUT_COMPLETED, + }, + }), + ).toBe(1); + }); + + it("reconciles an existing Monnify payout through Monnify after the active provider changes", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger, { + paymentProvider: "MONNIFY", + buyerRefundAmountKobo: 0n, + merchantPayoutAmountKobo: 1_000_003n, + platformRetainedAmountKobo: 0n, + payoutLegStatus: DisputeSettlementLegStatus.PROCESSING, + settlementStatus: DisputeSettlementStatus.PROCESSING, + }); + const monnify = new PayoutProviderDouble( + "monnify", + "PROCESSING_THEN_SUCCESS", + ); + const paystack = new PayoutProviderDouble("paystack", "SUCCESS_IMMEDIATE"); + const creator = makePayoutServices({ monnify, paystack }); + await creator.service.executeSettlementPayout({ + legId: fixture.payoutLegId as string, + orderId: fixture.orderId, + storeId: fixture.storeId, + amountKobo: fixture.merchantPayoutAmountKobo, + approvedBy: fixture.adminId, + }); + const payout = await prisma.payout.findUniqueOrThrow({ + where: { orderId: fixture.orderId }, + }); + monnify.setTransferStatus("SUCCESS"); + + await makePayoutServices({ + monnify, + paystack, + }).service.reconcileSubmittedPayout(payout.id); + + expect(monnify.verifications).toHaveLength(1); + expect(paystack.verifications).toHaveLength(0); + expect( + await prisma.payout.findUniqueOrThrow({ where: { id: payout.id } }), + ).toMatchObject({ + provider: PayoutProviderName.MONNIFY, + status: PayoutStatus.COMPLETED, + }); + }); + + it("makes duplicate refund provider successes and concurrent reconciliation idempotent", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger, { + refundLegStatus: DisputeSettlementLegStatus.SUBMITTED, + settlementStatus: DisputeSettlementStatus.PROCESSING, + }); + const leg = await prisma.disputeSettlementLeg.update({ + where: { id: fixture.refundLegId as string }, + data: { + providerOperationId: "refund-duplicate-success", + provider: "test-refund", + }, + }); + const refund = new RefundProviderDouble("SUCCESS_IMMEDIATE"); + const payout = new SettlementPayoutDouble( + prisma, + services.ledger, + "SUCCESS_IMMEDIATE", + ); + const execution = makeExecution(refund, payout); + + await Promise.all([ + execution.confirmRefundLeg( + fixture.orderId, + leg, + "refund-duplicate-success", + "test-refund", + fixture.buyerRefundAmountKobo, + ), + makeExecution(refund, payout).confirmRefundLeg( + fixture.orderId, + leg, + "refund-duplicate-success", + "test-refund", + fixture.buyerRefundAmountKobo, + ), + ]); + + await expectCompletionEffects( + fixture.refundLegId as string, + "REFUND_COMPLETED", + ); + const notifications = await prisma.outboxEvent.count({ + where: { + idempotencyKey: `settlement:${fixture.settlementId}:leg:${fixture.refundLegId}:state:COMPLETED:notification:${fixture.buyerId}`, + }, + }); + expect(notifications).toBe(1); + }); + + it("makes duplicate payout provider successes and reconciliation idempotent", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger, { + buyerRefundAmountKobo: 0n, + merchantPayoutAmountKobo: 1_000_003n, + platformRetainedAmountKobo: 0n, + }); + const refund = new RefundProviderDouble("SUCCESS_IMMEDIATE"); + const payout = new SettlementPayoutDouble( + prisma, + services.ledger, + "PROCESSING_THEN_SUCCESS", + ); + const execution = makeExecution(refund, payout); + await execution.executeSettlement(fixture.settlementId); + + const input = { + legId: fixture.payoutLegId as string, + orderId: fixture.orderId, + storeId: fixture.storeId, + amountKobo: fixture.merchantPayoutAmountKobo, + }; + await Promise.all( + [execution, makeExecution(refund, payout)].map(async (worker) => { + const claimed = await payout.confirmSubmitted(input); + if (claimed) { + await worker.recordLegState( + input.legId, + DisputeSettlementLegStatus.COMPLETED, + { completedAt: new Date() }, + [DisputeSettlementLegStatus.SUBMITTED], + ); + await worker.rollUpSettlement(fixture?.settlementId as string); + } + }), + ); + + await expectCompletionEffects(input.legId, "PAYOUT_COMPLETED"); + const leg = await prisma.disputeSettlementLeg.findUniqueOrThrow({ + where: { id: input.legId }, + }); + expect(leg.status).toBe(DisputeSettlementLegStatus.COMPLETED); + }); + + it("leaves an ambiguous provider timeout in reconciliation-required without a blind second submission", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger); + const refund = new RefundProviderDouble("TIMEOUT_AMBIGUOUS"); + const payout = new SettlementPayoutDouble( + prisma, + services.ledger, + "SUCCESS_IMMEDIATE", + ); + const execution = makeExecution(refund, payout); + + await execution.executeSettlement(fixture.settlementId); + await execution.executeSettlement(fixture.settlementId); + + expect(refund.invocations).toHaveLength(1); + const leg = await prisma.disputeSettlementLeg.findUniqueOrThrow({ + where: { id: fixture.refundLegId as string }, + }); + expect(leg.status).toBe(DisputeSettlementLegStatus.RECONCILIATION_REQUIRED); + expect( + await prisma.ledgerEntry.count({ + where: { + settlementLegId: leg.id, + entryType: LedgerEntryType.REFUND_COMPLETED, + }, + }), + ).toBe(0); + }); + + it("writes no refund completion ledger entry until a submitted refund is confirmed", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger); + const refund = new RefundProviderDouble("SUBMITTED_THEN_SUCCESS"); + const payout = new SettlementPayoutDouble( + prisma, + services.ledger, + "SUCCESS_IMMEDIATE", + ); + const execution = makeExecution(refund, payout); + + await execution.executeSettlement(fixture.settlementId); + let leg = await prisma.disputeSettlementLeg.findUniqueOrThrow({ + where: { id: fixture.refundLegId as string }, + }); + expect(leg.status).toBe(DisputeSettlementLegStatus.SUBMITTED); + expect( + await prisma.ledgerEntry.count({ + where: { + settlementLegId: leg.id, + entryType: LedgerEntryType.REFUND_COMPLETED, + }, + }), + ).toBe(0); + + const providerResult = await refund.fetchRefund( + leg.providerOperationId as string, + ); + await execution.confirmRefundLeg( + fixture.orderId, + leg, + providerResult.providerRefundId, + "test-refund", + providerResult.amountKobo, + ); + leg = await prisma.disputeSettlementLeg.findUniqueOrThrow({ + where: { id: leg.id }, + }); + expect(leg.status).toBe(DisputeSettlementLegStatus.COMPLETED); + await expectCompletionEffects(leg.id, "REFUND_COMPLETED"); + }); + + it("keeps a Monnify refund bound to Monnify through submitted reconciliation", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger, { + paymentProvider: "MONNIFY", + }); + const refund = new RefundProviderDouble( + "SUBMITTED_THEN_SUCCESS", + "monnify", + ); + const payout = new SettlementPayoutDouble( + prisma, + services.ledger, + "SUCCESS_IMMEDIATE", + ); + const first = makeExecution(refund, payout); + + await first.executeSettlement(fixture.settlementId); + + const submitted = await prisma.disputeSettlementLeg.findUniqueOrThrow({ + where: { id: fixture.refundLegId as string }, + }); + expect(submitted.status).toBe(DisputeSettlementLegStatus.SUBMITTED); + expect(submitted.provider).toBe("monnify"); + expect(submitted.providerOperationId).toBe( + `twz-refund-${fixture.refundLegId}`, + ); + expect(refund.invocations[0]).toMatchObject({ + transactionReference: `${fixture.prefix}:transaction`, + refundReference: `twz-refund-${fixture.refundLegId}`, + }); + expect( + await prisma.ledgerEntry.count({ + where: { + settlementLegId: submitted.id, + entryType: LedgerEntryType.REFUND_COMPLETED, + }, + }), + ).toBe(0); + + const confirmed = await refund.fetchRefund( + submitted.providerOperationId as string, + ); + await Promise.all( + [first, makeExecution(refund, payout)].map((worker) => + worker.confirmRefundLeg( + fixture?.orderId as string, + submitted, + confirmed.providerRefundId, + "monnify", + confirmed.amountKobo, + ), + ), + ); + + await expectCompletionEffects(submitted.id, "REFUND_COMPLETED"); + expect( + await prisma.disputeSettlementLeg.findUniqueOrThrow({ + where: { id: submitted.id }, + }), + ).toMatchObject({ + status: DisputeSettlementLegStatus.COMPLETED, + provider: "monnify", + }); + }); + + it("rejects retry of submitted operations and writes no completion ledger entry", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger, { + refundLegStatus: DisputeSettlementLegStatus.SUBMITTED, + settlementStatus: DisputeSettlementStatus.PROCESSING, + }); + const execution = makeExecution( + new RefundProviderDouble("SUCCESS_IMMEDIATE"), + new SettlementPayoutDouble(prisma, services.ledger, "SUCCESS_IMMEDIATE"), + ); + process.env.DISPUTE_SETTLEMENT_EXECUTION_ENABLED = "true"; + + await expect( + execution.retryLeg(fixture.refundLegId as string), + ).rejects.toThrow( + "Only pending or provider-rejected failed settlement legs can be retried", + ); + expect( + await prisma.ledgerEntry.count({ + where: { + settlementLegId: fixture.refundLegId as string, + entryType: LedgerEntryType.REFUND_COMPLETED, + }, + }), + ).toBe(0); + }); + + it("proves full buyer-win accounting after confirmed refund only", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger); + const execution = makeExecution( + new RefundProviderDouble("SUCCESS_IMMEDIATE"), + new SettlementPayoutDouble(prisma, services.ledger, "SUCCESS_IMMEDIATE"), + ); + await execution.executeSettlement(fixture.settlementId); + + const payment = await prisma.payment.findUniqueOrThrow({ + where: { id: fixture.paymentId }, + }); + const order = await prisma.order.findUniqueOrThrow({ + where: { id: fixture.orderId }, + }); + const settlement = await prisma.disputeSettlement.findUniqueOrThrow({ + where: { id: fixture.settlementId }, + }); + expect(payment.status).toBe(PaymentStatus.REFUNDED); + expect(order.status).toBe(OrderStatus.REFUNDED); + expect(settlement.status).toBe(DisputeSettlementStatus.COMPLETED); + expect(await services.ledger.getOrderBalance(fixture.orderId)).toBe(0n); + }); + + it("proves merchant-win and partial invariants without floating point arithmetic", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger, { + buyerRefundAmountKobo: 333_334n, + merchantPayoutAmountKobo: 555_556n, + platformRetainedAmountKobo: 111_113n, + }); + const refund = new RefundProviderDouble("SUCCESS_IMMEDIATE"); + const payout = new SettlementPayoutDouble( + prisma, + services.ledger, + "SUCCESS_IMMEDIATE", + ); + await Promise.all([ + makeExecution(refund, payout).executeSettlement(fixture.settlementId), + makeExecution(refund, payout).executeSettlement(fixture.settlementId), + ]); + + const settlement = await prisma.disputeSettlement.findUniqueOrThrow({ + where: { id: fixture.settlementId }, + }); + expect(settlement.status).toBe(DisputeSettlementStatus.COMPLETED); + expect( + fixture.buyerRefundAmountKobo + + fixture.merchantPayoutAmountKobo + + fixture.platformRetainedAmountKobo, + ).toBe(fixture.capturedAmountKobo); + const completed = await prisma.ledgerEntry.findMany({ + where: { + orderId: fixture.orderId, + entryType: { in: ["REFUND_COMPLETED", "PAYOUT_COMPLETED"] }, + }, + }); + expect(completed).toHaveLength(2); + expect(await services.ledger.getOrderBalance(fixture.orderId)).toBe( + fixture.platformRetainedAmountKobo, + ); + }); + + it("proves a full merchant win records one approved payout and no refund", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger, { + buyerRefundAmountKobo: 0n, + merchantPayoutAmountKobo: 1_000_003n, + platformRetainedAmountKobo: 0n, + }); + await makeExecution( + new RefundProviderDouble("SUCCESS_IMMEDIATE"), + new SettlementPayoutDouble(prisma, services.ledger, "SUCCESS_IMMEDIATE"), + ).executeSettlement(fixture.settlementId); + + const entries = await prisma.ledgerEntry.findMany({ + where: { orderId: fixture.orderId }, + }); + expect( + entries.filter( + (entry) => entry.entryType === LedgerEntryType.PAYOUT_COMPLETED, + ), + ).toHaveLength(1); + expect( + entries.filter( + (entry) => entry.entryType === LedgerEntryType.REFUND_COMPLETED, + ), + ).toHaveLength(0); + expect( + fixture.merchantPayoutAmountKobo + fixture.platformRetainedAmountKobo, + ).toBe(fixture.capturedAmountKobo); + expect(await services.ledger.getOrderBalance(fixture.orderId)).toBe(0n); + }); + + it("serializes concurrent roll-ups and never downgrades a completed settlement", async () => { + const services = createSettlementServices(prisma); + fixture = await createSettlementFixture(prisma, services.ledger, { + buyerRefundAmountKobo: 400_001n, + merchantPayoutAmountKobo: 600_002n, + platformRetainedAmountKobo: 0n, + settlementStatus: DisputeSettlementStatus.PROCESSING, + refundLegStatus: DisputeSettlementLegStatus.COMPLETED, + payoutLegStatus: DisputeSettlementLegStatus.SUBMITTED, + }); + const execution = makeExecution( + new RefundProviderDouble("SUCCESS_IMMEDIATE"), + new SettlementPayoutDouble(prisma, services.ledger, "SUCCESS_IMMEDIATE"), + ); + + await Promise.all([ + execution.rollUpSettlement(fixture.settlementId), + (async () => { + await prisma.disputeSettlementLeg.update({ + where: { id: fixture.payoutLegId as string }, + data: { status: DisputeSettlementLegStatus.COMPLETED }, + }); + await makeExecution( + new RefundProviderDouble("SUCCESS_IMMEDIATE"), + new SettlementPayoutDouble( + prisma, + services.ledger, + "SUCCESS_IMMEDIATE", + ), + ).rollUpSettlement(fixture.settlementId); + })(), + ]); + await execution.rollUpSettlement(fixture.settlementId); + + const settlement = await prisma.disputeSettlement.findUniqueOrThrow({ + where: { id: fixture.settlementId }, + }); + expect(settlement.status).toBe(DisputeSettlementStatus.COMPLETED); + expect(settlement.completedAt).not.toBeNull(); + }); + + async function expectCompletionEffects( + legId: string, + entryType: LedgerEntryType, + ): Promise { + expect( + await prisma.ledgerEntry.count({ + where: { settlementLegId: legId, entryType }, + }), + ).toBe(1); + const realtime = await prisma.outboxEvent.findMany({ + where: { + aggregateType: "DISPUTE_SETTLEMENT", + aggregateId: fixture?.settlementId, + }, + }); + const uniqueKeys = new Set(realtime.map((event) => event.idempotencyKey)); + expect(uniqueKeys.size).toBe(realtime.length); + } +}); diff --git a/apps/backend/test/settlement/settlement-provider.doubles.ts b/apps/backend/test/settlement/settlement-provider.doubles.ts new file mode 100644 index 00000000..a1204cbf --- /dev/null +++ b/apps/backend/test/settlement/settlement-provider.doubles.ts @@ -0,0 +1,393 @@ +import { + LedgerDirection, + LedgerEntryType, + PayoutStatus, + type DisputeSettlementLegStatus, +} from "@prisma/client"; + +import { PrismaService } from "../../src/core/prisma/prisma.service"; +import { LedgerService } from "../../src/domains/money/ledger/ledger.service"; +import { + type CreatePayoutRecipientInput, + type CreatePayoutRecipientResult, + type InitiatePayoutTransferInput, + type InitiatePayoutTransferResult, + type ListPayoutBankResult, + type PayoutProvider, + type ResolvePayoutAccountInput, + type ResolvePayoutAccountResult, + type VerifyPayoutTransferResult, +} from "../../src/domains/money/payout/providers/payout-provider.interface"; +import { + type CreateRefundInput, + type CreateRefundResult, + type FetchRefundResult, + type RefundProvider, + type RefundProviderKey, +} from "../../src/domains/money/refund/refund-provider.interface"; + +export type RefundProviderMode = + | "SUCCESS_IMMEDIATE" + | "SUBMITTED_THEN_SUCCESS" + | "SUBMITTED_THEN_FAILED" + | "TIMEOUT_BEFORE_SUBMISSION" + | "TIMEOUT_AMBIGUOUS" + | "REJECTED"; + +export class RefundProviderDouble implements RefundProvider { + readonly key: RefundProviderKey; + readonly supportsNewRefunds = true; + readonly invocations: CreateRefundInput[] = []; + private readonly results = new Map(); + + constructor( + private readonly mode: RefundProviderMode, + providerKey: RefundProviderKey = "paystack", + ) { + this.key = providerKey; + } + + async createRefund(input: CreateRefundInput): Promise { + this.invocations.push(input); + const providerRefundId = input.refundReference; + if ( + this.mode === "TIMEOUT_BEFORE_SUBMISSION" || + this.mode === "TIMEOUT_AMBIGUOUS" + ) { + throw new Error("test provider timeout"); + } + if (this.mode === "REJECTED") { + return { + provider: this.key, + providerRefundId, + status: "FAILED", + amountKobo: input.amountKobo, + }; + } + const status = this.mode === "SUCCESS_IMMEDIATE" ? "COMPLETED" : "PENDING"; + this.results.set(providerRefundId, { + providerRefundId, + status: this.mode === "SUBMITTED_THEN_FAILED" ? "FAILED" : "COMPLETED", + amountKobo: input.amountKobo, + completedAt: new Date(), + }); + return { + provider: this.key, + providerRefundId, + status, + amountKobo: input.amountKobo, + }; + } + + async fetchRefund(providerRefundId: string): Promise { + const result = this.results.get(providerRefundId); + if (!result) throw new Error("unknown test refund operation"); + return result; + } +} + +export type PayoutProviderMode = + | "SUCCESS_IMMEDIATE" + | "PROCESSING_THEN_SUCCESS" + | "PROCESSING_THEN_FAILED" + | "OTP" + | "TIMEOUT_AMBIGUOUS" + | "REJECTED"; + +/** + * Provider double used with the real PayoutService and PostgreSQL. It records + * every external submission while allowing reconciliation results to change + * independently. No in-process locking is used: duplicate-submit protection + * must come from the payout and payout-attempt database claims. + */ +export class PayoutProviderDouble implements PayoutProvider { + readonly key: "paystack" | "monnify"; + readonly submissions: InitiatePayoutTransferInput[] = []; + readonly verifications: string[] = []; + private transferStatus: InitiatePayoutTransferResult["status"]; + private readonly throwOnSubmission: boolean; + private readonly operationIdByReference = new Map(); + + constructor(key: "paystack" | "monnify", mode: PayoutProviderMode) { + this.key = key; + this.transferStatus = this.statusForMode(mode); + this.throwOnSubmission = mode === "TIMEOUT_AMBIGUOUS"; + } + + setTransferStatus(status: InitiatePayoutTransferResult["status"]): void { + this.transferStatus = status; + } + + async listBanks(): Promise { + return [{ name: "Test Bank", code: "999", active: true, type: "nuban" }]; + } + + async resolveAccount( + input: ResolvePayoutAccountInput, + ): Promise { + return { + accountName: "Settlement Fixture Store", + accountNumber: input.accountNumber, + }; + } + + async createRecipient( + _input: CreatePayoutRecipientInput, + ): Promise { + return { + recipientCode: this.key === "paystack" ? "RCP_test_settlement" : null, + }; + } + + async initiateTransfer( + input: InitiatePayoutTransferInput, + ): Promise { + this.submissions.push(input); + const operationId = `${this.key}-operation-${input.reference}`; + this.operationIdByReference.set(input.reference, operationId); + if (this.throwOnSubmission) { + throw new Error("test provider response timeout after submission"); + } + if (this.transferStatus === "FAILED") { + return { + reference: input.reference, + transferCode: operationId, + status: "FAILED", + }; + } + return { + reference: input.reference, + transferCode: operationId, + status: this.transferStatus, + }; + } + + async verifyTransfer(reference: string): Promise { + this.verifications.push(reference); + return { + reference, + transferCode: + this.operationIdByReference.get(reference) ?? + `${this.key}-operation-${reference}`, + status: this.transferStatus, + }; + } + + verifyWebhookSignature( + rawBody: Buffer | string | undefined, + signature: string | string[] | undefined, + ): boolean { + return Boolean(rawBody) && signature === "valid-test-signature"; + } + + parseWebhookEvent(payload: unknown): { reference: string } | null { + if ( + typeof payload !== "object" || + payload === null || + !("reference" in payload) || + typeof payload.reference !== "string" + ) { + return null; + } + return { reference: payload.reference }; + } + + private statusForMode( + mode: PayoutProviderMode, + ): InitiatePayoutTransferResult["status"] { + switch (mode) { + case "SUCCESS_IMMEDIATE": + return "SUCCESS"; + case "PROCESSING_THEN_SUCCESS": + case "PROCESSING_THEN_FAILED": + case "TIMEOUT_AMBIGUOUS": + return "PROCESSING"; + case "OTP": + return "OTP"; + case "REJECTED": + return "FAILED"; + } + } +} + +/** + * Test-owned settlement payout adapter. It uses the real database, payout row, + * and ledger idempotency key, while never making an HTTP request. The execution + * service only relies on this narrow PayoutService method at this boundary. + */ +export class SettlementPayoutDouble { + readonly invocations: Array<{ + legId: string; + orderId: string; + storeId: string; + amountKobo: bigint; + }> = []; + + constructor( + private readonly prisma: PrismaService, + private readonly ledger: LedgerService, + private readonly mode: PayoutProviderMode, + ) {} + + async executeSettlementPayout(input: { + legId: string; + orderId: string; + storeId: string; + amountKobo: bigint; + approvedBy: string; + }): Promise<{ + status: "COMPLETED" | "SUBMITTED" | "FAILED"; + providerReference: string | null; + providerOperationId: string | null; + error?: string; + }> { + this.invocations.push(input); + const reference = `test-payout-${input.legId}`; + if (this.mode === "TIMEOUT_AMBIGUOUS") { + return { + status: "SUBMITTED", + providerReference: reference, + providerOperationId: null, + }; + } + if (this.mode === "REJECTED" || this.mode === "PROCESSING_THEN_FAILED") { + return { + status: "FAILED", + providerReference: reference, + providerOperationId: `transfer-${input.legId}`, + error: "Provider confirmed rejection", + }; + } + if (this.mode === "PROCESSING_THEN_SUCCESS" || this.mode === "OTP") { + await this.ensureSubmittedPayout(input, reference); + return { + status: "SUBMITTED", + providerReference: reference, + providerOperationId: `transfer-${input.legId}`, + }; + } + + await this.complete(input, reference); + return { + status: "COMPLETED", + providerReference: reference, + providerOperationId: `transfer-${input.legId}`, + }; + } + + async confirmSubmitted(input: { + legId: string; + orderId: string; + storeId: string; + amountKobo: bigint; + }): Promise { + const reference = `test-payout-${input.legId}`; + let claimed = false; + await this.prisma.$transaction(async (tx) => { + const payout = await tx.payout.findUnique({ + where: { orderId: input.orderId }, + }); + if (!payout) return; + const claim = await tx.payout.updateMany({ + where: { id: payout.id, status: PayoutStatus.SUBMITTED }, + data: { status: PayoutStatus.COMPLETED, completedAt: new Date() }, + }); + if (claim.count !== 1) return; + claimed = true; + await this.ledger.recordEntry( + { + entryType: LedgerEntryType.PAYOUT_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo: input.amountKobo, + orderId: input.orderId, + storeId: input.storeId, + payoutId: payout.id, + settlementLegId: input.legId, + reference, + idempotencyKey: `settlement-leg:${input.legId}:payout-completed`, + }, + tx, + ); + }); + return claimed; + } + + private async ensureSubmittedPayout( + input: { + legId: string; + orderId: string; + storeId: string; + amountKobo: bigint; + }, + reference: string, + ): Promise { + await this.prisma.payout.upsert({ + where: { orderId: input.orderId }, + create: { + orderId: input.orderId, + storeId: input.storeId, + amountKobo: input.amountKobo, + platformFeeKobo: 0n, + settlementLegId: input.legId, + status: PayoutStatus.SUBMITTED, + paystackReference: reference, + submittedAt: new Date(), + }, + update: { + status: PayoutStatus.SUBMITTED, + settlementLegId: input.legId, + paystackReference: reference, + }, + }); + } + + private async complete( + input: { + legId: string; + orderId: string; + storeId: string; + amountKobo: bigint; + }, + reference: string, + ): Promise { + await this.prisma.$transaction(async (tx) => { + const payout = await tx.payout.upsert({ + where: { orderId: input.orderId }, + create: { + orderId: input.orderId, + storeId: input.storeId, + amountKobo: input.amountKobo, + platformFeeKobo: 0n, + settlementLegId: input.legId, + status: PayoutStatus.COMPLETED, + paystackReference: reference, + completedAt: new Date(), + }, + update: { + status: PayoutStatus.COMPLETED, + settlementLegId: input.legId, + paystackReference: reference, + completedAt: new Date(), + }, + }); + await this.ledger.recordEntry( + { + entryType: LedgerEntryType.PAYOUT_COMPLETED, + direction: LedgerDirection.DEBIT, + amountKobo: input.amountKobo, + orderId: input.orderId, + storeId: input.storeId, + payoutId: payout.id, + settlementLegId: input.legId, + reference, + idempotencyKey: `settlement-leg:${input.legId}:payout-completed`, + }, + tx, + ); + }); + } +} + +export function isFinalLegStatus(status: DisputeSettlementLegStatus): boolean { + return status === "COMPLETED" || status === "FAILED"; +} diff --git a/apps/backend/test/settlement/setup.ts b/apps/backend/test/settlement/setup.ts new file mode 100644 index 00000000..6b1aaeec --- /dev/null +++ b/apps/backend/test/settlement/setup.ts @@ -0,0 +1,41 @@ +/** + * The safety suites are intentionally opt-in and cannot fall back to the app + * DATABASE_URL. This protects shared Neon environments from test writes. + */ +const testDatabaseUrl = process.env.SETTLEMENT_TEST_DATABASE_URL?.trim(); + +if (!testDatabaseUrl) { + throw new Error( + "SETTLEMENT_TEST_DATABASE_URL is required; settlement safety tests refuse to use DATABASE_URL", + ); +} + +let parsed: URL; +try { + parsed = new URL(testDatabaseUrl); +} catch { + throw new Error( + "SETTLEMENT_TEST_DATABASE_URL must be a valid PostgreSQL URL", + ); +} + +const hostname = parsed.hostname.toLowerCase(); +const database = parsed.pathname.toLowerCase(); +const safeHosts = new Set(["localhost", "127.0.0.1", "::1", "postgres"]); + +if ( + !safeHosts.has(hostname) || + hostname.includes("neon") || + hostname.includes("prod") || + hostname.includes("staging") || + !database.includes("settlement") || + !database.includes("test") +) { + throw new Error( + "SETTLEMENT_TEST_DATABASE_URL does not look like a disposable local settlement test database", + ); +} + +process.env.DATABASE_URL = testDatabaseUrl; +process.env.DIRECT_URL = testDatabaseUrl; +process.env.DISPUTE_SETTLEMENT_EXECUTION_ENABLED = "false"; diff --git a/apps/backend/test/stock-integrity.e2e-spec.ts b/apps/backend/test/stock-integrity.e2e-spec.ts index eff92a46..27768cc9 100644 --- a/apps/backend/test/stock-integrity.e2e-spec.ts +++ b/apps/backend/test/stock-integrity.e2e-spec.ts @@ -1,8 +1,8 @@ import request from "supertest"; import { TestSetup } from "./helpers/test-setup"; import { OrderStatus } from "@prisma/client"; -import { PaystackClient } from "../src/modules/payment/paystack.client"; -import { PaymentService } from "../src/modules/payment/payment.service"; +import { PaystackClient } from "../src/integrations/paystack/paystack.client"; +import { PaymentService } from "../src/domains/money/payment/payment.service"; describe("Stock Integrity (e2e)", () => { jest.setTimeout(30000); @@ -25,7 +25,7 @@ describe("Stock Integrity (e2e)", () => { buyerToken = buyerData.token; const merchantData = await ctx.createMockMerchant(); - const merchantId = merchantData.merchantProfile!.id; + const storeId = merchantData.storeProfile!.id; const category = await ctx.prisma.category.upsert({ where: { slug: "electronics-test" }, @@ -35,7 +35,7 @@ describe("Stock Integrity (e2e)", () => { const product = await ctx.prisma.product.create({ data: { - merchantId: merchantId, + storeId: storeId, categoryId: category.id, name: "Test Gadget for Stock Test", categoryTag: category.name, @@ -44,8 +44,8 @@ describe("Stock Integrity (e2e)", () => { wholesalePriceKobo: 8000, unit: "piece", isActive: true, - productStockCache: { - create: { stock: 100 }, + productStockCaches: { + create: { stock: 100, quantity: 100 }, }, }, }); @@ -75,8 +75,7 @@ describe("Stock Integrity (e2e)", () => { productId, quantity: 5, deliveryAddress: "Test Address", - paymentMethod: "ESCROW", - deliveryMethod: "MERCHANT_DELIVERY", + deliveryMethod: "STORE_DELIVERY", }) .expect(201); @@ -84,8 +83,8 @@ describe("Stock Integrity (e2e)", () => { expect(orderId).toBeDefined(); // Verify stock is 95 - const stockAfterOrder = await ctx.prisma.productStockCache.findUnique({ - where: { productId }, + const stockAfterOrder = await ctx.prisma.productStockCache.findFirst({ + where: { productId, variantId: null }, }); expect(Number(stockAfterOrder!.stock)).toBe(95); @@ -95,7 +94,7 @@ describe("Stock Integrity (e2e)", () => { }); // One from creation expect(eventsAfterOrder.length).toBe(1); - expect(Number(eventsAfterOrder[0].quantity)).toBe(5); + expect(Number(eventsAfterOrder[0].quantity)).toBe(-5); // 2. Mock Paystack verification const paystackClient = ctx.app.get(PaystackClient); @@ -124,8 +123,8 @@ describe("Stock Integrity (e2e)", () => { }); // 4. Verify stock is STILL 95 (Bug fix verification: it was 90 before the fix) - const finalStock = await ctx.prisma.productStockCache.findUnique({ - where: { productId }, + const finalStock = await ctx.prisma.productStockCache.findFirst({ + where: { productId, variantId: null }, }); expect(Number(finalStock!.stock)).toBe(95); diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 9ce1ad08..6e08165d 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -11,14 +11,23 @@ "sourceMap": true, "outDir": "./dist", "baseUrl": "./", + "types": ["node", "jest"], "incremental": true, "skipLibCheck": true, - "strictNullChecks": false, - "noImplicitAny": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false, - "paths": {} + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@twizrr/shared": ["../../packages/shared/src"] + } }, - "include": ["src/**/*", "test/**/*", "prisma.config.ts"] + "include": [ + "src/**/*", + "test/**/*", + "prisma.config.ts", + "prisma.settlement-test.config.ts" + ] } diff --git a/apps/web/.env.example b/apps/web/.env.example index 66998897..a56275d3 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,10 +1,163 @@ -NEXT_PUBLIC_APP_URL=http://localhost:3000 +# ----------------------------------------------------------------------------- +# twizrr - Web Frontend Environment Variables (apps/web) +# Copy this file to .env.local and fill in values. Never commit .env.local. +# ----------------------------------------------------------------------------- +# +# ONE build, THREE Vercel projects. apps/web powers all three production +# deployments; they share the same code and differ ONLY by env vars. Set each +# project's vars in its Vercel dashboard (Settings -> Environment Variables). +# +# Vercel project Domain Surface +# -------------- ---------------- ------------------------------------- +# twizrr-web twizrr.com Marketing / landing / legal +# twizrr-app app.twizrr.com Product app + guest marketplace +# twizrr-admin admin.twizrr.com Operations console (SUPER_ADMIN only) +# +# PER-PROJECT MATRIX (value = exact value to set, "-" = leave unset) +# +# Variable twizrr-web twizrr-app twizrr-admin +# ---------------------------------------- ----------- ----------- ------------ +# NEXT_PUBLIC_API_URL (shared) (shared) (shared) +# NEXT_PUBLIC_APP_URL (shared,req) (shared) (shared) +# NEXT_PUBLIC_ADMIN_URL (shared) (shared) (shared, self) +# NEXT_PUBLIC_ADMIN_ENABLED false false true +# NEXT_PUBLIC_MARKETPLACE_ENABLED false true true (never false) +# NEXT_PUBLIC_MARKETING_ONLY true - / false - / false +# NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME twizrr twizrr twizrr +# NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY - set - +# NEXT_PUBLIC_WHATSAPP_NUMBER set (CTA) set - +# NEXT_PUBLIC_GOOGLE_PLACES_API_KEY - set (store) - +# NEXT_PUBLIC_GOOGLE_MAPS_API_KEY - set (store) - +# NEXT_PUBLIC_PLATFORM_FEE - 0.02 - +# NEXT_PUBLIC_TWIZRR_WHATSAPP_COMMUNITY_URL set - - +# WEB_URL twizrr.com app.twizrr.com admin.twizrr.com +# +# Shared values (identical on all three projects): +# NEXT_PUBLIC_API_URL = https://api.twizrr.com +# NEXT_PUBLIC_APP_URL = https://app.twizrr.com +# NEXT_PUBLIC_ADMIN_URL = https://admin.twizrr.com +# +# WHY NEXT_PUBLIC_MARKETPLACE_ENABLED must be true (or unset) on twizrr-admin: +# The admin surface redirects every non-admin navigation to /admin. If the +# marketplace gate is disabled (false), /admin itself is bounced to "/", +# which the admin surface bounces back to /admin -> an infinite redirect +# loop. Leave it unset (defaults true) or set it true on the admin project. +# +# !! DO NOT set NODE_ENV on Vercel !! +# Vercel already sets NODE_ENV=production for the build. Adding your own +# NODE_ENV var makes `pnpm install` skip devDependencies, so the build fails +# with "Cannot find module 'tailwindcss'". Leave NODE_ENV unset on Vercel. +# ----------------------------------------------------------------------------- + + +# ============================================================================= +# ALL PROJECTS - set on twizrr-web, twizrr-app AND twizrr-admin +# ============================================================================= + +# Backend API base URL. Every surface talks to the same NestJS API. +# Local: http://localhost:4000 +# Production: https://api.twizrr.com NEXT_PUBLIC_API_URL=http://localhost:4000 -NEXT_PUBLIC_WHATSAPP_BOT_NUMBER=2348147846093 -NEXT_PUBLIC_WHATSAPP_WELCOME_MESSAGE="Hi, I'd like to shop on Swifta" - -# Platform Fees (Percentages) -NEXT_PUBLIC_PLATFORM_FEE_ESCROW=2 -NEXT_PUBLIC_PLATFORM_FEE_DIRECT_TIER2=1.5 -NEXT_PUBLIC_PLATFORM_FEE_DIRECT_TIER3=1 -WHATSAPP_VERIFY_TOKEN=your_token_here + +# Socket.IO endpoint path. Must match backend SOCKET_IO_PATH. +NEXT_PUBLIC_SOCKET_IO_PATH=/socket.io +# Product app base URL. Used by the marketing/admin surfaces to link into app +# flows, and by the app surface's own host detection. +# REQUIRED on twizrr-web (marketing): if missing there, getAppUrl() throws and +# CTAs fall back to a relative URL, so "Start Shopping" etc. stay on twizrr.com +# instead of opening app.twizrr.com. +# Local single-app dev: http://localhost:3000 +# Production: https://app.twizrr.com +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# Where the admin console lives. Used by the marketing/app surfaces to redirect +# any /admin request to the admin host, and by admin links. On twizrr-admin it +# is that project's own origin. +# Production: https://admin.twizrr.com +NEXT_PUBLIC_ADMIN_URL=https://admin.twizrr.com + +# Cloudinary public cloud name. Used by next.config.mjs to allow-list the image +# CDN for next/image. Same value on all projects (defaults to "twizrr-dev"). +NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=twizrr + + +# ============================================================================= +# SURFACE FLAGS - one build behaves as marketing, app, or admin based on these. +# Each project sets its own combination (see the matrix above). +# ============================================================================= + +# TRUE only on twizrr-admin. Marks THIS deployment as the admin surface: it +# serves the /admin route group and redirects all other navigation to /admin. +# Works on every URL of the project (including Vercel previews), not just +# admin.twizrr.com. This is route/surface ownership, NOT security - the backend +# RolesGuard still enforces SUPER_ADMIN on every request. +# Local admin dev: set this true in .env.local and run the dev server; +# localhost:3000 then serves the admin surface. Leave false to run the app. +# twizrr-web=false twizrr-app=false twizrr-admin=true +NEXT_PUBLIC_ADMIN_ENABLED=false + +# Launch gate for the marketplace/app routes. +# twizrr-web=false (marketing/legal pages only during pre-launch) +# twizrr-app=true (product app + guest marketplace) +# twizrr-admin=true (MUST be true/unset - see redirect-loop note above) +# When false, any navigation that is not a launch-allowed marketing/legal route +# (/, /how-it-works, /buyer-protection, /privacy, /terms) is redirected to the +# landing page with ?earlyAccess=1. +NEXT_PUBLIC_MARKETPLACE_ENABLED=true + +# TRUE only on twizrr-web (marketing). When true, any navigation that is not a +# launch-allowed marketing/legal route is redirected to NEXT_PUBLIC_APP_URL so +# app and guest-marketplace routes never resolve on the marketing domain. +# Leave unset/false on twizrr-app, twizrr-admin, and local dev. +NEXT_PUBLIC_MARKETING_ONLY=false + + +# ============================================================================= +# twizrr-app ONLY - product app / guest marketplace features +# (harmless to omit on twizrr-web and twizrr-admin: those surfaces never render +# the checkout or store-setup screens that read them) +# ============================================================================= + +# Paystack public (publishable) key - safe to expose in the browser. Read by the +# checkout screens only. +# Use a TEST key for local/dev/staging; LIVE key for production only. +NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY= + +# Google Maps/Places browser key for store pickup address search and +# current-location confirmation (store setup / settings). Restrict this key by +# allowed web origins in Google Cloud. Do NOT use the backend server key here. +NEXT_PUBLIC_GOOGLE_PLACES_API_KEY= +NEXT_PUBLIC_GOOGLE_MAPS_API_KEY= + +# Platform fee shown in the checkout UI - must match the backend value. +NEXT_PUBLIC_PLATFORM_FEE=0.02 + + +# ============================================================================= +# SHARED marketing/app - twizrr-web + twizrr-app (not needed on twizrr-admin) +# ============================================================================= + +# Public WhatsApp number used by the landing CTA and the shopping-assistant page. +# If missing on twizrr-web, the "Chat on WhatsApp" button silently falls back to +# the waitlist. +NEXT_PUBLIC_WHATSAPP_NUMBER= + + +# ============================================================================= +# twizrr-web ONLY - marketing / waitlist +# ============================================================================= + +# Optional WhatsApp launch-community invite URL shown after early-access signup. +# Do not commit the real private invite link - configure it in host env vars. +NEXT_PUBLIC_TWIZRR_WHATSAPP_COMMUNITY_URL= + + +# ============================================================================= +# Per-surface base URL (self origin of each project) +# ============================================================================= + +# Public base URL of THIS deployment. Set to each project's own origin: +# twizrr-web=https://twizrr.com twizrr-app=https://app.twizrr.com +# twizrr-admin=https://admin.twizrr.com +# Local: http://localhost:3000 +WEB_URL=http://localhost:3000 diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md new file mode 100644 index 00000000..8b1a4231 --- /dev/null +++ b/apps/web/AGENTS.md @@ -0,0 +1,714 @@ +# twizrr - Web Frontend AI Assistant Instructions +# apps/web/AGENTS.md + +> Read the root AGENTS.md first. This file adds web-specific detail. +> If root AGENTS.md and this file conflict, root AGENTS.md wins. +> Design system: see TWIZRR_DESIGN_SYSTEM.md. +> Page specifications: see TWIZRR_USER_SYSTEM.md, TWIZRR_STORE_SYSTEM.md, TWIZRR_NAVIGATION_SYSTEM.md, and current task docs. + +--- + +## 0. Web App Overview + +```text +Framework: Next.js 14 App Router + TypeScript strict mode +Styling: Tailwind CSS with design tokens via CSS variables +Server state: TanStack Query v5 where appropriate +Forms: React Hook Form + Zod where appropriate +UI base: Radix UI primitives, styled by twizrr +Icons: Lucide React only, except official twizrr logo assets +Fonts: next/font/google +Realtime: socket.io-client via RealtimeNotifications (mounted in AppShell; + connects only when signed in, withCredentials so the HttpOnly cookie auths + the handshake). Socket events are delivery hints only — on notification:new + the client invalidates the notifications React Query keys and refetches over + REST. Never treat the socket as a data source. Chat will reuse this socket. +``` + +The web app serves: + +- Public discovery pages: landing, explore, search, product detail, public store pages, public user profiles. +- Shopper pages: home, cart, checkout, orders, wishlist, saved, messages, notifications, settings. +- Store owner pages: store home/feed, my store, products, orders, payouts, source products, settings, verification. +- Admin pages: dashboard, users, stores, disputes, moderation, delivery operations. + +Domain ownership: + +- `twizrr.com` is the public marketing and information site. It owns `/`, + `/how-it-works`, `/buyer-protection`, `/privacy`, and `/terms`. +- `app.twizrr.com` is the product app. It owns login, registration, guest + marketplace discovery, product/store pages, shopper app routes, store-owner + routes, and admin routes. +- Guest marketplace usage is still product-app usage, so public store/product + discovery links should point to `app.twizrr.com` when they are generated from + the marketing site. +- Keep one repo and one `apps/web` Next.js app. Do not create `apps/marketing`, + a `(marketing)` route group, or a `(guest)` route group. +- Use `NEXT_PUBLIC_APP_URL` plus `getAppUrl()` from `src/lib/routes.ts` when a + marketing/public page needs to build a product-app URL. Do not hardcode + `app.twizrr.com` inside components. +- The recommended deployment model is provider-neutral: deploy the same`r`n `apps/web` app for the marketing and product-app domains with different env`r`n values. `twizrr.com` uses `NEXT_PUBLIC_MARKETPLACE_ENABLED=false`;`r`n `app.twizrr.com` uses `NEXT_PUBLIC_MARKETPLACE_ENABLED=true`. + +The web app is not: + +- A standalone backend. All business logic comes from the NestJS API. +- The WhatsApp AI assistant. WhatsApp lives in `apps/backend/src/channels/whatsapp`. +- A place to reimplement payment, delivery, payout, or order state rules that belong in the backend. + +--- + +## Web Design Direction + +Architecture and design are both part of the frontend contract. Do not reduce +`apps/web` guidance to folder structure only. + +twizrr is a social commerce product, so the web app should feel like a social +feed with commerce naturally built into it, not like a heavy dashboard. + +Design inspiration: + +- X/Twitter Web for the main shell: fixed left navigation, center feed, right context panel on desktop. +- Instagram Web for feed-driven discovery: visual product cards, store/profile surfaces, familiar scroll behavior. +- Creator/business tools for store owners: useful and operational, but still light and social-profile-oriented. + +Primary layout model: + +```txt +Desktop: left nav 240px + center feed/detail surface + right panel 300px +Tablet: icon nav 72px + center surface, right panel hidden +Mobile: top header + full-width content + bottom nav +``` + +Design rule of thumb: + +- If a shopper is discovering or buying, the page should feel like a social shopping feed. +- If a store owner is working, the page should feel like creator studio tools, not an ERP. +- If a user is transacting, trust/protection/delivery copy must be quiet, clear, and visible. +- If a surface starts to feel like generic SaaS admin UI, rethink the composition. + +### Typography and Logo + +Product UI uses Cabinet Grotesque. This includes: + +- Page titles, section headings, card titles, product names, store names. +- Body copy, product descriptions, store bio, helper text, error text. +- Buttons, inputs, labels, navigation, tabs, badges, toasts. +- Prices, order codes, stats, and counters. + +Syne is only for brand-heavy display moments: + +- Landing hero headlines. +- 404/500 large display headlines. +- Launch/coming-soon hero text. + +Do not use Syne for dashboards, order tracking, product listing forms, +checkout, navigation, buttons, labels, or transactional copy. + +Logo rules: + +- Use official assets from `apps/web/public/logo/*`. +- Use `logo.svg` for brand-heavy surfaces like auth, landing, empty states, and error states. +- Use `wordmark.svg` for headers/nav where horizontal space is available. +- Use `mark.svg` for compact loaders/buttons/small spaces. +- Do not manually recreate the logo with text or custom SVG. +- The App Router favicon lives at `apps/web/src/app/icon.svg`; do not replace it with a public logo asset without explicit design approval. + +### Visual System + +Use CSS variables and Tailwind tokens only. Do not hardcode hex values in JSX. + +Core visual rules: + +- Background: Bianca/light surfaces for shopper mode. +- Text: Espresso for primary text. +- Primary actions: Saffron background with Espresso text. +- Verification and trust states: use existing badge/token patterns. +- Cards: use restrained radii and borders; do not nest cards inside cards. +- Images: product/place/person/store media should be real or generated bitmap imagery, not decorative SVG fillers. +- Empty/error/loading states: use friendly state components and shaped skeletons, not raw error dumps or generic spinners. +- No emojis in UI. Use Lucide React icons. + +### Shell and Page Surface Rules + +Shopper/public shell: + +- Preserve the social layout: left nav, center content, optional right panel. +- Center content should remain scan-friendly and feed/detail oriented. +- Right panel should contain context such as search, suggestions, trending content, or helpful discovery modules. +- Mobile should collapse naturally into top header + content + bottom nav. + +Store shell: + +- Store mode should feel like creator/business tools inside the same social product. +- Keep one shared `/store` app shell. Do not create `/physical-store/*`, + `/digital-store/*`, or a dropshipper route group. +- Split store operations through `getStoreCapabilities()` from + `src/lib/store-capabilities.ts`, not scattered store-type comparisons. +- Physical stores own native products, sourcing opt-in, pickup details, and + ready-for-pickup controls. Digital stores own source-product and sourced + listing workflows. Store home/feed, orders, payouts, verification, and settings + remain shared, with capability-aware actions and copy. +- Prefer clear operational surfaces over decorative dashboards. +- Store owners prepare orders and use twizrr-managed delivery flows; do not show old provider/responsibility choices. +- Store-owner UI must not expose shopper phone/address/contact data by default. + +Admin shell: + +- Admin UI can be denser and more operational. +- Admin/operator delivery pages may show fulfillment snapshots and internal provider actions. +- Admin provider language such as Shipbubble is allowed only on admin/internal surfaces. + +Auth/onboarding: + +- Auth pages may use a split visual layout when appropriate: account flow on one side, branded image/visual panel on the other. +- Keep the form side clear and focused. +- Use the logo centrally or prominently enough that the surface still feels like twizrr. +- Google auth copy must not imply Google verifies phone or age. + +Product/detail pages: + +- Public product links should prefer canonical store-scoped URLs. +- Product surfaces should present the selling store, product imagery, price, variants, delivery handled by twizrr, and protected payment clearly. +- Shopper copy must not reveal supplier/source/dropship/private fulfillment relationships. + +Order/checkout pages: + +- Checkout must show delivery handled by twizrr and server-calculated fees where available. +- Primary delivery phone policy is backend-owned; web should display and collect only according to current backend contract. +- Use "twizrr Buyer Protection" or "protected payment" for user-facing copy. +- Use "delivery code", not "OTP". + +### Design and Architecture Together + +Feature extraction must preserve the intended experience. Moving a file into +`features/*` is not a reason to flatten the UI into generic panels or remove +brand/layout guidance. + +When refactoring: + +- Keep route behavior stable. +- Keep visual composition stable unless the PR is explicitly a design change. +- Move design-specific feature sections with their feature folder when needed. +- Keep truly generic primitives in shared UI. +- Do not replace tailored product/store/order surfaces with generic admin cards. + +--- + +## 1. Current App Structure + +This section describes the current repo shape. Keep it current when routes move. + +```text +apps/web/src/ + app/ + layout.tsx + globals.css + error.tsx + not-found.tsx + + (auth)/ + layout.tsx + login/page.tsx + register/page.tsx + register/google/complete/page.tsx + forgot-password/page.tsx + verify-email/page.tsx + + (public)/ + page.tsx + explore/page.tsx + search/page.tsx + p/[code]/page.tsx + stores/page.tsx + stores/[handle]/page.tsx + stores/[handle]/p/[code]/page.tsx + u/[username]/page.tsx + u/[username]/followers/page.tsx + u/[username]/following/page.tsx + u/[username]/verified_followers/page.tsx + c/[category]/page.tsx + + (app)/ + layout.tsx + home/page.tsx + cart/page.tsx + checkout/[productId]/page.tsx + orders/page.tsx + orders/[id]/page.tsx + orders/confirmed/[orderId]/page.tsx + saved/page.tsx + wishlist/page.tsx + messages/page.tsx + notifications/page.tsx + whatsapp/page.tsx + + (settings)/settings/ + account/page.tsx + profile/page.tsx + store/page.tsx + store/account/page.tsx + store/profile/page.tsx + + (store)/store/ + layout.tsx + dashboard/ + products/ + orders/ + source/ + setup/ + settings/ + verification/ + payouts/page.tsx + analytics/page.tsx + messages/page.tsx + + (admin)/admin/ + layout.tsx + _components/ + page.tsx + dashboard/page.tsx + deliveries/page.tsx + disputes/page.tsx + moderation/page.tsx + stores/page.tsx + users/page.tsx + + components/ + ui/ + layout/ + feed/ + image/ + order/ + product/ + settings/ + store-orders/ + store-settings/ + providers/ + + features/ + admin-deliveries/ + cart/ + checkout/ + measurements/ + orders/ + profile/ + public-product/ + public-store/ + saved/ + settings/ + source-products/ + store-payouts/ + store-orders/ + store-products/ + wishlist/ + + shared/ + api/ + query-keys.ts + layout/ + ShopperShell.tsx + + hooks/ + useMode.ts + useOwnerStore.ts + useOwnProfile.ts + + lib/ + api.ts + auth.ts + image-crop.ts + paystack.ts + route-policy.ts + routes.ts + store-settings.ts + user.ts + utils.ts + + middleware.ts +``` + +Current scaling pressure: + +- Authenticated shopper URLs are now owned by `(app)`, while shopper feature UI lives under `features/*`. +- Checkout, cart, orders, wishlist, saved, settings, measurements, public store/product, store home/profile/products/orders/payouts/source-products, and admin deliveries now own their API/type boundaries under `features/*`. +- Some not-yet-extracted areas still use broad `lib/*` helpers, especially auth/onboarding, public profile/social graph, store settings/verification, and non-delivery admin pages. +- React Query is installed and shared query keys exist in `shared/api/query-keys.ts`; not every manual-fetch screen has migrated yet. + +--- + +## 2. Route Ownership Policy + +`src/app` should stay route-focused and thin. + +Preferred route file shape: + +```tsx +import { CheckoutPage } from "@/features/checkout"; + +export default function Page() { + return ; +} +``` + +Route files may own: + +- `params` and `searchParams` adaptation. +- Metadata and layout boundaries. +- Server/client boundary handoff. +- Redirect wrappers where the route itself owns the redirect. +- Small loading, not-found, and error boundaries that are specific to the route segment. + +Route files should not own: + +- Large business logic. +- Complex API orchestration. +- Long form state machines. +- Mutation flows. +- Feature DTO normalization. +- Large presentational sections that are reusable within a domain. + +If a route/client file crosses roughly 300-400 lines, check whether feature components, hooks, schemas, or API helpers should be extracted before adding more logic. A 500+ line route/client file should be treated as a refactor candidate. + +Do not change URL behavior during file-structure refactors. Moving code into feature folders must preserve the public route contract. + +--- + +## 3. Target Frontend Structure + +The long-term target is a route-thin, feature-owned structure: + +```txt +apps/web/src/ + app/ + (auth)/ + (public)/ + (app)/ + (store)/ + (admin)/ + + features/ + auth/ + api.ts + hooks.ts + schemas.ts + types.ts + components/ + checkout/ + cart/ + orders/ + saved/ + wishlist/ + settings/ + profile/ + measurements/ + public-product/ + public-store/ + public-profile/ + social-graph/ + store-payouts/ + store-products/ + store-orders/ + source-products/ + admin-deliveries/ + admin-users/ + + shared/ + api/ + client.ts + errors.ts + query-keys.ts + ui/ + layout/ + config/ + routes/ + utils/ +``` + +Do not create this full folder structure just to satisfy documentation. Create folders only when migrating real code in a focused PR. + +--- + +## 4. Feature Folder Rules + +Each feature folder should own the feature's application surface: + +- `api.ts`: endpoint wrappers and response normalization for that feature. +- `hooks.ts`: React Query hooks and feature-specific client orchestration. +- `schemas.ts`: Zod schemas and form validation. +- `types.ts`: feature DTO/domain types that are not shared across the whole app. +- `components/`: feature-specific UI sections and controls. +- Local helpers for feature-only formatting, mapping, and state machines. + +Feature folders should not: + +- Duplicate shared UI primitives. +- Create a second base API client. +- Own global route constants. +- Reimplement backend business rules that should remain server-authoritative. +- Import from another feature's private components or hooks unless that surface is intentionally exported. + +When migrating existing code, prefer one feature at a time. Avoid moving route groups, changing URLs, and rewriting feature logic in the same PR. + +--- + +## 5. Shared Folder Rules + +Future shared code should be generic, stable, and genuinely reused. + +`shared/api` should own: + +- Base API client. +- API error parsing. +- Shared request utilities. +- Centralized query key helpers for new and migrated features. + +`shared/ui` should own: + +- Generic primitives only: buttons, inputs, modals, sheets, badges, skeletons, state components, toasts. +- UI components that do not know about checkout, orders, stores, users, admin, or payments. + +`shared/layout` should own: + +- App shells. +- Navigation primitives. +- Mobile header/bottom navigation. +- Right panel/shell layout primitives when they are not feature-specific. + +`shared/routes` should own: + +- Canonical route builders where useful. +- Safe redirect helpers. +- Public URL helpers that prevent accidental legacy route generation. + +`shared/utils` should own: + +- Formatting helpers such as `formatKobo`. +- `cn` and other small generic utilities. + +Do not put domain DTOs into shared folders unless multiple independent features use them and the ownership is clear. + +--- + +## 6. API and Type Ownership + +Base API client files must not become domain DTO dumping grounds. + +Rules: + +- New feature API wrappers should live in the feature folder. +- Not-yet-extracted domains may temporarily keep focused helpers in `src/lib/*`, but once a matching `features/` folder exists, that feature owns its API and types. +- Feed, product, order, admin, store, profile, cart, checkout, and source-product DTOs should live near their feature API layer. +- Direct `fetch` or raw `api` calls inside route components should be avoided for new work. +- Prefer feature-level functions such as `getCheckoutReview`, `getStoreOrders`, or `useAdminDeliveries`. +- Keep response normalization at the API boundary, not scattered through JSX. +- Keep money values as backend-provided kobo strings/numbers until display, then use `formatKobo`. + +Temporary compatibility: + +- Existing `src/lib/*` domain files are acceptable only until the matching feature folder is introduced. +- Do not churn all `lib/*` files in one PR. Move them feature-by-feature and keep behavior stable. + +--- + +## 7. React Query Policy + +New async client data flows should prefer TanStack Query when the data is server state. + +Use React Query for: + +- Lists that refresh or paginate. +- Detail reads with loading/error states. +- Mutations that should invalidate or update cached data. +- Admin/store operational screens. +- Cart, checkout review, orders, profile, social graph, and notifications where appropriate. + +React Query rules: + +- Use centralized query keys for new or migrated features. +- Keep query key shape stable and specific. +- Encapsulate mutations in feature hooks where practical. +- Prefer targeted invalidation over broad cache invalidation. +- Use consistent loading, empty, and error states. +- Keep optimistic updates scoped to simple social interactions or well-understood local changes. + +Example key style: + +```ts +export const queryKeys = { + checkout: { + review: (productId: string) => ["checkout", "review", productId] as const, + }, + storeOrders: { + list: (status: string) => ["store-orders", "list", status] as const, + detail: (orderId: string) => ["store-orders", "detail", orderId] as const, + }, +}; +``` + +Do not duplicate string query keys across large page files. + +--- + +## 8. Shopper Route Policy + +Canonical model: + +- Authenticated shopper URLs are owned by `(app)`. +- Shopper settings URLs are owned by `(settings)/settings`. +- Shopper shell/layout code lives in `shared/layout`. +- Shopper feature UI lives under `features/*`. + +Route ownership rules: + +- Do not reintroduce `(shopper)/buyer` as a parallel route group. +- Keep existing shopper URLs stable when moving feature code. +- Route files should remain thin wrappers around feature components. +- If a future URL migration is needed, add explicit redirects or wrappers. + +The legacy `(shopper)/buyer` route group has been retired to prevent drift +between duplicated shopper route systems. + +--- + +## 9. Recommended Migration Sequence + +Use small PRs in this order: + +1. Docs-only frontend architecture alignment. +2. Introduce shared query keys and clearer API boundaries. +3. Split the checkout feature into API, hooks, schemas, and components. +4. Split admin delivery operations into a feature folder. +5. Split cart checkout and order detail/list flows. +6. Split public product, public store, public profile, and social graph flows. +7. Shopper route ownership consolidation — completed: `(app)` owns authenticated shopper URLs, with shopper UI in `features/*` and shell code in `shared/layout`. + +Each migration PR should preserve route behavior and should not redesign UI unless the PR explicitly says it is a UI change. + +--- + +## 10. Anti-Patterns + +Avoid: + +- 500+ line route/client files that combine UI, API calls, validation, formatting, mutation handling, and layout. +- Domain DTOs inside the base API client. +- Duplicated query keys in page files. +- Direct `fetch` calls in route components for feature data when a feature API wrapper should own the call. +- Moving route groups and feature logic in one giant PR. +- Changing public URL behavior during file-structure refactors. +- Reimplementing checkout, order, payout, or delivery business rules in the web app. +- Hardcoded production URLs when config or route builders exist. +- Store-owner/sh shopper copy that reveals private fulfillment, supplier, or internal source details. + +--- + +## 11. Server and Client Component Rules + +Default to Server Components. Add `"use client"` only when the component needs: + +- Browser APIs. +- React state/effects. +- Event handlers. +- React Query hooks. +- Form libraries. +- Client-only context. + +Keep client boundaries as low as practical. A route can be a Server Component that renders a feature client component. + +--- + +## 12. Design Rules + +- Use CSS variables and Tailwind tokens. Do not hardcode hex values. +- Use Lucide React for icons. Do not use emojis as icons. +- Use official twizrr logo assets from `public/logo/*`. +- Use mobile-first layouts starting at 375px. +- Minimum touch target is 44px by 44px. +- Input font size should be at least 16px to avoid iOS zoom. +- Do not implement dark mode in MVP. +- Use skeletons or shaped loading states instead of raw spinners. +- Use friendly empty/error states. Do not expose raw backend errors directly to users. +- Long scrolling lists (the feed) use CSS rendering containment, not JS windowing: + `[content-visibility:auto]` + `[contain-intrinsic-size:auto_px]` on each + list item so off-screen items skip layout/paint while SSR, variable card heights, + and scroll restoration keep working. See `FeedClient.tsx` for the reference usage. + +Brand language: + +- Use "twizrr Buyer Protection" or "protected payment", not "escrow" in public/user-facing copy. +- Use "store" or "store owner", not "merchant" in public/user-facing copy. +- Use "delivery code", not "OTP" in public/user-facing copy. +- Use "service fee", not "platform fee" or "commission" in public/user-facing copy. +- Use "delivery handled by twizrr" for shopper/store-owner delivery copy. + +--- + +## 13. Money Display + +Never display raw kobo values to users. + +Use `formatKobo` from `src/lib/utils` or its future shared equivalent. + +```tsx +import { formatKobo } from "@/lib/utils"; + +{formatKobo(order.totalAmountKobo)} +``` + +Do not divide money inline in JSX unless the helper explicitly owns that conversion. + +--- + +## 14. Auth and Route Protection + +Current route protection lives in: + +- `apps/web/src/middleware.ts` +- `apps/web/src/lib/route-policy.ts` + +Middleware only checks for backend-managed HttpOnly auth cookies as a first-pass gate. The backend remains the source of truth for token validity and role permissions. + +Rules: + +- Keep auth redirect helpers centralized. +- Do not scatter protected-route prefix checks across page files. +- Admin role access must remain backend-enforced. +- Store ownership and store setup checks may require client/server API probes because middleware cannot call authenticated backend APIs safely. + +--- + +## 15. Before You Write Frontend Code + +Checklist: + +- Read root `AGENTS.md`. +- Read this file. +- Confirm the route group and feature owner. +- Confirm whether the route should be server or client. +- Use CSS variables, not hardcoded colors. +- Use Lucide icons, not emojis. +- Use `formatKobo` for money display. +- Keep route files thin for new work. +- Prefer feature API wrappers and React Query hooks for async client data. +- Do not change route behavior in architecture-only refactors. + +Validation for code changes: + +```bash +cd apps/web +pnpm run lint +npx tsc --noEmit +pnpm run build +``` + +Docs-only PRs usually need: + +```bash +git diff --check +``` + +--- + +Last updated: June 2026 +Project: twizrr - apps/web +Maintained by: CODEDDEVS TECHNOLOGY LTD diff --git a/apps/web/README.md b/apps/web/README.md deleted file mode 100644 index 577e97c3..00000000 --- a/apps/web/README.md +++ /dev/null @@ -1,235 +0,0 @@ -# Swifta — Frontend - -Next.js 14 App Router web application for Swifta. - ---- - -## Tech Stack - -- **Next.js 14** (App Router) — framework -- **TypeScript** — type safety -- **Tailwind CSS** — styling -- **@swifta/shared** — shared types, enums, money utilities - ---- - -## Getting Started - -```bash -# From monorepo root (backend must be running) -cd apps/web -cp .env.example .env.local # Or create manually -pnpm dev # Start on http://localhost:3000 -``` - -### Environment Variables (`apps/web/.env.local`) - -```env -NEXT_PUBLIC_API_URL=http://localhost:4000 -NEXT_PUBLIC_PAYSTACK_KEY=pk_test_xxxxx -``` - ---- - -## Project Structure - -``` -src/ -├── app/ # Pages (file-based routing) -│ ├── layout.tsx # Root layout (providers, fonts, global CSS) -│ ├── page.tsx # Landing → redirect to dashboard or login -│ ├── loading.tsx # Global loading skeleton -│ ├── not-found.tsx # 404 page -│ ├── error.tsx # Error boundary -│ │ -│ ├── (auth)/ # Auth pages (no sidebar) -│ │ ├── layout.tsx # Centered card layout -│ │ ├── login/page.tsx -│ │ ├── register/page.tsx -│ │ └── verify-email/page.tsx -│ │ -│ └── (dashboard)/ # Dashboard pages (sidebar + header) -│ ├── layout.tsx # Auth check + sidebar + header -│ ├── merchant/ # 10 merchant pages -│ │ ├── dashboard/ -│ │ ├── onboarding/ -│ │ ├── products/ # list, new, [id]/edit -│ │ ├── rfqs/ # list, [id] (with quote form) -│ │ ├── orders/ # list, [id] (dispatch + OTP) -│ │ └── inventory/ -│ └── buyer/ # 7 buyer pages -│ ├── dashboard/ -│ ├── catalogue/ -│ ├── rfqs/ # list, new, [id] (with quote review) -│ └── orders/ # list, [id] (pay + OTP confirm) -│ -├── components/ -│ ├── ui/ # Generic primitives (13 components) -│ │ ├── button.tsx # Variants: primary, secondary, danger, ghost -│ │ ├── input.tsx # Label + error state -│ │ ├── textarea.tsx -│ │ ├── select.tsx -│ │ ├── badge.tsx -│ │ ├── card.tsx -│ │ ├── modal.tsx # isOpen, onClose, title -│ │ ├── toast.tsx # Auto-dismiss 5s -│ │ ├── skeleton.tsx # Animated loading placeholder -│ │ ├── empty-state.tsx # "Nothing here yet" + action -│ │ ├── data-table.tsx -│ │ ├── pagination.tsx -│ │ └── status-badge.tsx # Color-coded by status enum -│ │ -│ ├── layout/ # Layout components -│ │ ├── sidebar.tsx # Role-aware navigation -│ │ ├── header.tsx # User info + notification bell -│ │ ├── mobile-nav.tsx # Hamburger menu -│ │ └── page-header.tsx # Title + action button -│ │ -│ ├── auth/ # login-form, register-form, role-select -│ ├── merchant/ # onboarding-stepper, product-form, quote-form, etc. -│ ├── buyer/ # catalogue-grid, rfq-form, payment-button, otp-input, etc. -│ ├── order/ # order-status-timeline, order-summary-card, order-list-item -│ └── notification/ # notification-bell, dropdown, item -│ -├── lib/ -│ ├── api-client.ts # Centralized HTTP client (auto JWT, error handling) -│ ├── api/ # 9 API modules (one per backend module) -│ │ ├── auth.api.ts -│ │ ├── merchant.api.ts -│ │ ├── product.api.ts -│ │ ├── rfq.api.ts -│ │ ├── quote.api.ts -│ │ ├── order.api.ts -│ │ ├── payment.api.ts -│ │ ├── inventory.api.ts -│ │ └── notification.api.ts -│ ├── paystack.ts # Paystack inline popup helper -│ └── utils.ts # cn(), formatDate(), formatCurrency() -│ -├── hooks/ -│ ├── use-auth.ts # Auth context consumer -│ ├── use-current-user.ts # User + role -│ ├── use-notifications.ts # Polls unread count every 60s -│ └── use-debounce.ts # Search input debounce -│ -├── providers/ -│ ├── auth-provider.tsx # Stores tokens in memory, auto-refresh -│ ├── toast-provider.tsx # Toast notifications -│ └── query-provider.tsx # Data fetching (optional) -│ -└── styles/ - └── globals.css # Tailwind directives -``` - ---- - -## Pages (20 total) - -### Auth (3 pages) - -| Route | Purpose | -| --------------- | ------------------------------ | -| `/login` | Email + password login | -| `/register` | Role selection + registration | -| `/verify-email` | Email verification placeholder | - -### Merchant (10 pages) - -| Route | Purpose | -| ------------------------------ | ------------------------------------------------------ | -| `/merchant/dashboard` | Quick stats, recent RFQs and orders | -| `/merchant/onboarding` | Multi-step form: business info → bank details → review | -| `/merchant/products` | Own product list with add/edit/delete | -| `/merchant/products/new` | Create product form | -| `/merchant/products/[id]/edit` | Edit product form | -| `/merchant/rfqs` | Incoming RFQ inbox | -| `/merchant/rfqs/[id]` | RFQ detail + quote submission form | -| `/merchant/orders` | Order list | -| `/merchant/orders/[id]` | Order detail + dispatch button + OTP display | -| `/merchant/inventory` | Stock levels + manual adjustment | - -### Buyer (7 pages) - -| Route | Purpose | -| -------------------- | --------------------------------------------------- | -| `/buyer/dashboard` | Active RFQs + pending orders | -| `/buyer/catalogue` | Browse all products, search, "Request Quote" button | -| `/buyer/rfqs` | Own RFQ list | -| `/buyer/rfqs/new` | Create RFQ form | -| `/buyer/rfqs/[id]` | RFQ detail + quote accept/decline | -| `/buyer/orders` | Order list | -| `/buyer/orders/[id]` | Order detail + pay button + OTP delivery confirm | - ---- - -## API Communication - -All backend calls go through the centralized API layer: - -``` -Component → lib/api/*.api.ts → lib/api-client.ts → Backend -``` - -**Never use `fetch()` directly in components.** Always use the API functions. - -The API client automatically: - -- Adds `Authorization: Bearer ` header -- Parses JSON responses -- Throws typed errors with `{ error, code, statusCode }` - ---- - -## Authentication - -- Tokens stored in **React state (memory)** via AuthProvider — NOT localStorage -- Access token: 15 min TTL, auto-refreshed before expiry -- Route protection: dashboard layout redirects to `/login` if not authenticated -- Role guards: merchant layout redirects buyers, buyer layout redirects merchants - ---- - -## Money Display - -All money from the API arrives in **kobo** (integer). Always convert for display: - -```typescript -import { formatKobo } from "@swifta/shared"; - -formatKobo(650000n); // "₦6,500.00" -formatKobo(32500000n); // "₦325,000.00" -``` - -When user types Naira in forms, convert to kobo before sending to API: - -```typescript -import { nairaToKobo } from "@swifta/shared"; - -nairaToKobo(6500); // 650000n -``` - ---- - -## Key Rules - -1. **No prices on product cards** — products don't have prices (business rule) -2. **Don't update order status after payment** — backend does it via webhook; poll until status changes -3. **Don't show delivery OTP to buyer** — only merchant sees OTP -4. **Don't store tokens in localStorage** — memory only (AuthProvider) -5. **Don't use fetch() in components** — use the API layer -6. **Every page must handle**: loading state, empty state, error state -7. **Responsive design** — must work on mobile (sidebar → hamburger menu) - ---- - -## Design System - -| Token | Value | -| ----------------- | ---------------------- | -| Primary | `#1B2A4A` (navy) | -| Accent | `#2E75B6` (blue) | -| Action | `#E87722` (orange) | -| Sidebar | `w-64` (256px) | -| Header | `h-16` (64px) | -| Card | `rounded-lg shadow-sm` | -| Mobile breakpoint | `md:` (768px) | diff --git a/apps/web/WEB_DESIGN.md b/apps/web/WEB_DESIGN.md new file mode 100644 index 00000000..2479789d --- /dev/null +++ b/apps/web/WEB_DESIGN.md @@ -0,0 +1,1303 @@ +# twizrr — Web Design System +# apps/web/WEB_DESIGN.md + +> Version: 1.0 — May 2026 +> Platform: Web only (Next.js 14 App Router) +> Based on: twizrr Overall Brand Identity (TWIZRR_DESIGN_SYSTEM.md) +> Designer: Abdul Rasheed Brimah — Lead Designer (Brand Identity & UI/UX) +> +> This document governs all design decisions for the web app. +> When the mobile app is built, a separate MOBILE_DESIGN.md will be created. +> Both documents are inspired by the overall brand system but have +> platform-specific implementation rules. +> +> If this document conflicts with TWIZRR_DESIGN_SYSTEM.md on a web-specific +> rule: this document wins. For anything not covered here: refer to +> TWIZRR_DESIGN_SYSTEM.md. + +--- + +## 0. Web Design Philosophy + +``` +SOCIAL FEED FIRST — COMMERCE BUILT NATURALLY INTO THE EXPERIENCE. + +twizrr is a social commerce platform. +The web experience should feel closer to a social feed +than a heavy management system or admin dashboard. + +INSPIRATION: +├── X (Twitter) Web — overall desktop layout structure +│ Left fixed navigation + center scrollable feed + right context panel +│ Navigation collapses to bottom bar on mobile +│ +└── Instagram Web — how content appears in the center feed + Post cards, story-like elements, feed-driven discovery + Clean, familiar, scroll-driven + +WHY THIS MATTERS: +└── Nigerian users already live on Instagram and X. + A familiar layout removes the learning curve. + They know: left nav → feed → scroll. + Twizrr should feel like a natural extension of + the social platforms they already use — not a + new type of software they have to learn. + +THE RULE: +└── If it feels like an admin dashboard → rethink it. + If it feels like a social app → you're on track. + +TWO PRIMARY USERS: + +1. SHOPPERS — discovering and buying + Experience: scrollable feed, product discovery, + familiar social interactions (like, follow, save) + Commerce overlaid naturally: prices on cards, + Quick Buy on feed, escrow trust built in + Goal: feel like Instagram with products you can buy + +2. STORE OWNERS — managing their business + Experience: social profile management + lightweight tools + Dashboard feels like a "creator studio" — not an ERP + Orders, products, payouts — accessible but not overwhelming + Goal: feel like managing your Instagram business account + +THE WEB LAYOUT ADAPTS ACROSS DEVICES: +├── Desktop: 3-column (left nav + center feed + right context) +├── Tablet: 2-column (icon nav + center feed, right hidden) +└── Mobile: 1-column (full width feed + bottom nav) + +This mirrors how X and Instagram already handle responsiveness. +The structure collapses cleanly — no separate mobile redesign needed. +``` + +--- + +## 1. Typography + +### The Core Rule + +``` +PRODUCT UI — Cabinet Grotesque only: + Every heading, body text, button, label, input, + navigation item, dashboard stat, empty state, + error message, notification, and tooltip. + +LOGO AND BRAND PLACEMENT — SVG assets only: + Never type "twizrr" as text inside the UI. + Always use the official SVG files: + ├── /public/logo/logo.svg — primary full logo lockup (ZZ mark + TWIZRR wordmark) + ├── /public/logo/wordmark.svg — text-only horizontal wordmark + └── /public/logo/mark.svg — icon-only ZZ mark + +MARKETING PAGES — Cabinet Grotesque primary + Syne selectively: + The landing page, 404 page, 500 page, and any + coming-soon or launch page may use Syne for + large display headlines where brand expression matters. + Cabinet Grotesque still handles all body copy + and supporting text on these pages. + +RULE OF THUMB: + If a user is trying to DO something → Cabinet Grotesque only. + If a user is being introduced to the brand → Syne is allowed. +``` + +### Cabinet Grotesque — Product UI Font + +``` +Used for: ALL interface text in the product UI + + HEADINGS AND TITLES: + ├── Page titles: Cabinet Grotesque Bold, 24–28px + ├── Section headers: Cabinet Grotesque SemiBold, 18–20px + ├── Card titles: Cabinet Grotesque SemiBold, 15–16px + └── Dashboard stats: Cabinet Grotesque Bold, 28–36px + + BODY AND CONTENT: + ├── Product descriptions: Cabinet Grotesque Regular, 14–15px + ├── Feed post captions: Cabinet Grotesque Regular, 14px + ├── Store bio: Cabinet Grotesque Regular, 14px + └── Help text / tooltips: Cabinet Grotesque Light, 12–13px + + UI CONTROLS: + ├── Button text: Cabinet Grotesque Medium, 15–16px + ├── Input labels: Cabinet Grotesque Medium, 14px + ├── Input placeholder: Cabinet Grotesque Regular, 16px (min 16px) + ├── Navigation labels: Cabinet Grotesque Medium, 12px + ├── Tab labels: Cabinet Grotesque Medium, 13–14px + └── Badge text: Cabinet Grotesque Medium, 11–12px + + DATA AND NUMBERS: + ├── Prices: Cabinet Grotesque Bold, monospace-style + ├── Order codes: Cabinet Grotesque Bold, monospace-style + └── Stats / counts: Cabinet Grotesque Bold + + FEEDBACK: + ├── Toast messages: Cabinet Grotesque Medium, 14px + ├── Error messages: Cabinet Grotesque Regular, 13px + ├── Empty state text: Cabinet Grotesque Regular, 14–16px + └── Success messages: Cabinet Grotesque Regular, 14px + +LOAD VIA next/font/google: + import { Cabinet_Grotesk } from 'next/font/google' + + const cabinet = Cabinet_Grotesk({ + subsets: ['latin'], + weight: ['300', '400', '500', '600', '700', '800'], + variable: '--font-cabinet', + display: 'swap', + }) + +Apply to root layout: + +``` + +### Syne — Brand and Marketing Font + +``` +Used selectively for display headlines on brand-heavy pages only. + +ALLOWED USES: + ├── Landing page (/) — hero headline and value proposition only + ├── 404 page — large "404" or "Page not found" headline only + ├── 500 page — large error headline only + └── Coming soon / launch pages — hero text only + +NOT ALLOWED: + ├── Store dashboard + ├── Product listing form + ├── Order tracking + ├── Any auth page body text (Syne on hero illustration is fine) + ├── Navigation items + ├── Buttons + ├── Form labels or inputs + ├── Descriptions or body copy anywhere + └── Any page where the user is actively managing or transacting + +LOAD VIA next/font/google: + import { Syne } from 'next/font/google' + + const syne = Syne({ + subsets: ['latin'], + weight: ['700', '800'], + variable: '--font-syne', + display: 'swap', + }) + +Apply only where needed: +

+ Shop from verified Nigerian stores. +

+``` + +### Logo and Brand Assets + +``` +NEVER spell "twizrr" as plain text in any UI component. +ALWAYS use the official SVG assets via next/image: + + import Image from 'next/image' + + // Primary full logo lockup — use in brand-heavy surfaces + twizrr + + // Text-only wordmark — use in nav/header surfaces where space is tight + twizrr + + // Icon-only ZZ mark — use in small spaces and loading screens + twizrr + +WHERE TO USE LOGO ASSETS: + ├── Public header: wordmark.svg (dark on Bianca background) + ├── Store mode header: wordmark.svg or mark.svg + ├── Auth pages: logo.svg or wordmark.svg depending on available space + ├── Landing page: logo.svg for hero/brand placement and nav + ├── Loading screen: mark.svg centered on Espresso background + ├── Footer: wordmark.svg (small) + └── 404 / 500 pages: logo.svg for full brand moments, mark.svg for compact states + + Note: the logo assets above are for in-app brand placement only. + The browser/app icon (favicon) is NOT a logo asset — see "Browser Favicon + and App Icon" below. + +BROWSER FAVICON AND APP ICON: + The official browser favicon / app icon is the designer-provided file at: + └── apps/web/src/app/icon.svg + + This is the App Router-native icon path. Next.js 14 auto-detects + src/app/icon.svg and emits the correct tags — no manual + favicon metadata in layout.tsx and no PWA manifest are required. + Do not point the favicon at /logo/mark.svg or any /public icon file. + +LOGO COLOUR RULE: + Light backgrounds (Bianca): Use dark (Espresso fill) SVG + Dark backgrounds (Espresso): Use light (Bianca/white fill) SVG + The SVG files use fill="black" — override via CSS filter or + provide separate light variants when needed. +``` + +--- + +## 2. Colour System + +### Theme — Web Light Mode + +``` +The web app uses a WARM LIGHT THEME. +Bianca (#FBF7F2) is the default page canvas. +Dark mode is deferred to Phase 3 — do not implement now. + +PAGE BACKGROUND: #FBF7F2 (Bianca) +CARD BACKGROUND: #FFFFFF (white) +SECTION BACKGROUND: #F5EFE6 (warm off-white) +INPUT BACKGROUND: #F0E8DC +BORDER COLOR: #E0D5C8 +PRIMARY TEXT: #1C1208 (Espresso) +SECONDARY TEXT: #6B5B4E (Mocha) +TERTIARY TEXT: #8C7B6E (Mocha light) +PRIMARY BRAND: #E8A020 (Saffron) +ACTION / CTA: #C2185B (Berry) +``` + +### Store Dashboard — Dark Surfaces + +``` +The store dashboard uses dark surfaces inspired by the app dark theme. +This gives the dashboard a distinct "management mode" feel. + +DASHBOARD BACKGROUND: #1C1208 (Espresso) +CARD BACKGROUND: #2A1E10 (Espresso light) +ELEVATED SURFACE: #3D2E1E (Espresso mid) +INPUT BACKGROUND: #2A1E10 +BORDER COLOR: #3D2E1E +PRIMARY TEXT: #FBF7F2 (Bianca) +SECONDARY TEXT: #C4B8AC (Bianca muted) +TERTIARY TEXT: #6B5B4E (Mocha) +PRIMARY BRAND: #E8A020 (Saffron) +``` + +### CSS Design Tokens (globals.css) + +```css +:root { + /* ── Brand Colors ────────────────────────── */ + --color-saffron: #E8A020; + --color-saffron-light: #F5C35A; + --color-saffron-dark: #C68A10; + + --color-espresso: #1C1208; + --color-espresso-light: #2A1E10; + --color-espresso-mid: #3D2E1E; + + --color-berry: #C2185B; + --color-berry-light: #E91E8C; + --color-berry-dark: #9C1448; + + --color-bianca: #FBF7F2; + --color-bianca-dark: #F0E8DC; + --color-bianca-section: #F5EFE6; + + --color-mocha: #6B5B4E; + --color-mocha-light: #8C7B6E; + + /* ── Web Light Theme (default) ───────────── */ + --bg-page: var(--color-bianca); + --bg-card: #FFFFFF; + --bg-section: var(--color-bianca-section); + --bg-input: var(--color-bianca-dark); + --border: #E0D5C8; + --text-primary: var(--color-espresso); + --text-secondary: var(--color-mocha); + --text-tertiary: var(--color-mocha-light); + --text-brand: var(--color-saffron); + --text-action: var(--color-berry); + + /* ── Status Colors ───────────────────────── */ + --color-success: #22C55E; + --color-warning: #F59E0B; + --color-error: #EF4444; + --color-info: #3B82F6; + + /* ── Verified Badge Colors ───────────────── */ + --color-badge-mocha: #6B5B4E; + --color-badge-berry: #C2185B; + + /* ── Third-party Brand Colors ────────────── */ + --color-whatsapp: #25D366; /* WhatsApp assistant link — intentional */ + --color-whatsapp-dark: #128C7E; + + /* ── Spacing Base Unit: 4px ─────────────── */ + /* Use Tailwind spacing scale (based on 4px) */ + + /* ── Border Radius ───────────────────────── */ + --radius-sm: 6px; /* tags, badges, chips */ + --radius-md: 10px; /* inputs, small cards */ + --radius-lg: 14px; /* product cards, modals */ + --radius-xl: 20px; /* image containers, sheets */ + --radius-full: 9999px; /* pills, avatars, buttons */ + + /* ── Typography ─────────────────────────── */ + --font-cabinet: 'Cabinet Grotesk', sans-serif; + --font-syne: 'Syne', sans-serif; + + /* ── Shadows (light surfaces only) ─────── */ + --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08); + --shadow-modal: 0 4px 12px rgba(0, 0, 0, 0.15); + /* Dark surfaces: no shadows — use 1px solid border */ +} +``` + +### Colour Rules + +``` +ALWAYS: + ├── Use CSS variables — never hardcode hex values in components + ├── Use Saffron for primary CTAs and active states + ├── Use Berry for action buttons and urgency CTAs + ├── Use Espresso for heavy typography on light surfaces + └── Use Bianca for text on dark surfaces + +NEVER: + ├── Berry and Saffron competing as equal elements on same surface + ├── Mocha as a primary or hero color + ├── Pure black (#000000) or pure white (#FFFFFF) as brand colors + ├── Any hex value not in the token system + └── Old Swifta green — removed, banned, never again +``` + +--- + +## 3. Spacing and Layout + +### Base Unit + +``` +Base: 4px +Scale: 4, 8, 12, 16, 20, 24, 32, 40, 48, 64, 80, 96 +Use Tailwind spacing scale — it maps to this scale exactly. +``` + +### Responsive Breakpoints + +``` +Mobile: 375px (default — design starts here) +Tablet: 768px (sm: in Tailwind) +Desktop: 1024px (lg: in Tailwind) +Wide: 1280px (xl: in Tailwind) + +Mobile-first always. Add breakpoint classes to expand layout. +Never design desktop-first and try to shrink down. +``` + +### Three-Column Social Layout (The Core Structure) + +``` +INSPIRED BY: X (Twitter) Web + Instagram Web +This is the master layout that all pages adapt from. + +───────────────────────────────────────────────────────── +DESKTOP (1024px+) — THREE COLUMNS: +───────────────────────────────────────────────────────── + +┌──────────────┬────────────────────────┬──────────────┐ +│ LEFT NAV │ CENTER FEED │ RIGHT PANEL │ +│ 240px │ flexible │ 300px │ +│ fixed │ max-width: 600px │ sticky top │ +│ │ scrollable │ │ +│ [logo] │ │ Search bar │ +│ │ [Feed tabs] │ ────────── │ +│ Home │ [Post card] │ Suggested │ +│ Stores │ [Product card] │ stores │ +│ Activity │ [Post card] │ ────────── │ +│ Chat │ [Product card] │ Trending │ +│ Profile │ ... │ hashtags │ +│ │ │ ────────── │ +│ ───────── │ │ New │ +│ Manage │ │ verified │ +│ Store │ │ stores │ +│ (if owner) │ │ │ +└──────────────┴────────────────────────┴──────────────┘ + +LEFT NAV: width: 240px, fixed, full viewport height +CENTER: flex: 1, max-width: 600px, centered between columns +RIGHT: width: 300px, sticky top: 16px, scrolls independently +OUTER: max-width: 1200px, centered, side padding: 24px + +───────────────────────────────────────────────────────── +TABLET (768px–1023px) — TWO COLUMNS: +───────────────────────────────────────────────────────── + +┌──────────┬────────────────────────────────────────────┐ +│ LEFT NAV │ CENTER FEED │ +│ 72px │ full remaining width │ +│ icons │ │ +│ only │ [Feed tabs] │ +│ │ [Post card] │ +│ [icon] │ [Product card] │ +│ [icon] │ [Post card] │ +│ [icon] │ ... │ +│ [icon] │ │ +│ [icon] │ │ +└──────────┴────────────────────────────────────────────┘ + +LEFT NAV: width: 72px, icons only (no labels), fixed +CENTER: flex: 1, max-width: 700px +RIGHT: completely hidden at this breakpoint + +───────────────────────────────────────────────────────── +MOBILE (375px–767px) — SINGLE COLUMN: +───────────────────────────────────────────────────────── + +┌──────────────────────────────────────────────────────┐ +│ HEADER (fixed top) │ +│ [logo mark] [search icon] [notification] [avatar] │ +├──────────────────────────────────────────────────────┤ +│ │ +│ CONTENT (scrollable, full width) │ +│ [Feed tabs] │ +│ [Post card] │ +│ [Product card] │ +│ [Post card] │ +│ ... │ +│ │ +├──────────────────────────────────────────────────────┤ +│ BOTTOM NAV (fixed bottom, 56px + safe area) │ +│ Home | Stores | Activity | Chat | Profile │ +└──────────────────────────────────────────────────────┘ + +LEFT NAV: completely hidden on mobile +BOTTOM NAV: replaces left nav on mobile +HEADER: fixed top bar with logo mark, search, notifications +CENTER: full viewport width, side padding: 16px +RIGHT: completely hidden on mobile +``` + +### Left Navigation (X/Twitter Style) + +``` +DESKTOP (full nav — 240px): + Position: fixed, left: 0, top: 0, height: 100vh + Width: 240px + Background: var(--color-bianca) (shopping mode) + var(--color-espresso) (store mode) + Border right: 1px solid #E0D5C8 (light) / var(--color-espresso-mid) (dark) + Padding: 16px 12px + Overflow: hidden (no scrollbar on nav itself) + + TOP: + └── twizrr wordmark.svg (height: 28px, margin-bottom: 32px) + + NAVIGATION ITEMS (stacked vertically): + └── Each item: icon (20px) + label + Height: 48px (touch target) + Border radius: var(--radius-full) — pill shape + Padding: 12px 16px + Font: Cabinet Grotesque Medium, 16px + Gap between icon and label: 16px + + Default: transparent bg, Espresso text (light) / Bianca text (dark) + Hover: var(--color-bianca-dark) bg (light) / Espresso-mid bg (dark) + Active: Cabinet Grotesque Bold (weight changes — like X) + + SHOPPING MODE ITEMS: + ├── [HomeIcon] Home + ├── [StoreIcon] Stores + ├── [BellIcon] Activity (shows unread count badge) + ├── [MessageIcon] Chat (shows unread count badge) + └── [UserIcon] Profile + + SEPARATOR (if user has StoreProfile): + └── 1px divider line + [LayoutDashboardIcon] Manage Store → switches to store mode + + BOTTOM (pinned to bottom of nav): + └── User avatar (32px) + @username + [...] more options + Same pattern as X/Twitter bottom profile section + +TABLET (icon nav — 72px): + Same structure but: + Width: 72px + Labels: hidden + Items: icon only, centered in 72px width + Tooltip on hover showing label (like X collapsed nav) + +MOBILE: + Left nav: display: none + Replaced by: fixed bottom navigation bar +``` + +### Right Context Panel + +``` +DESKTOP ONLY (hidden below 1024px): + Width: 300px + Position: sticky top: 16px + Background: transparent (content cards have their own bg) + +CONTENT SECTIONS: + +1. SEARCH BAR (top of right panel): + Input: var(--color-bianca-dark) bg, 44px height, rounded-full + Placeholder: "Search twizrr" + [SearchIcon] left inside input + +2. SUGGESTED STORES: + Section card: white bg, 14px radius, 16px padding + Title: Cabinet Grotesque SemiBold, 15px, Espresso + Each store row: + ├── Store avatar (40px circle, Saffron border if verified) + ├── Store name (Cabinet Grotesque SemiBold, 14px) + verified badge + ├── Store handle (Cabinet Grotesque Regular, 13px, Mocha) + └── [Follow] pill button (Berry outlined, 32px height, 13px text) + Max 3 stores shown, [Show more] link + +3. TRENDING HASHTAGS: + Section card: same style + Title: Cabinet Grotesque SemiBold, 15px, Espresso + Each hashtag row: + ├── #hashtag (Cabinet Grotesque SemiBold, 14px, Espresso) + └── X posts this week (Cabinet Grotesque Regular, 12px, Mocha) + Max 5 hashtags shown + +4. NEW VERIFIED STORES: + Same card style + Stores verified in the last 30 days + Same row format as Suggested Stores + +5. FOOTER (bottom of right panel): + Very small text, Mocha, Cabinet Grotesque Light, 12px + About · Privacy · Terms · Help · @twizrrapp +``` + +### Center Feed Column + +``` +Max width: 600px +Centered between left nav and right panel +Padding: 0 (cards handle their own padding) + +FEED TABS BAR (sticky at top of center column): + Background: var(--color-bianca) with bottom border + Tabs: For You | Following | Stores | Explore + Active tab: Saffron bottom border (3px), Cabinet Grotesque Bold + Inactive tab: No border, Cabinet Grotesque Regular, Mocha + Height: 48px + Sticky top: 0 (sticks below page header on mobile) + +CONTENT CARDS: + Width: 100% of center column + No side margins (cards span full center column width) + Bottom border between cards: 1px solid #E0D5C8 + No card border-radius on feed (full-bleed like X/Instagram) + Border radius only on images within cards: var(--radius-xl) + +INFINITE SCROLL: + Load 20 items per page + Cursor-based pagination + Skeleton screens while loading (never spinners) + Load trigger: when user scrolls within 300px of last item +``` + +### Page-Level Layout Wrappers + +``` +ROOT LAYOUT (applies to all pages using the 3-column layout): + +// apps/web/src/app/(public)/layout.tsx +// apps/web/src/app/(app)/layout.tsx + +
+ {/* Left Nav — hidden on mobile */} + + + {/* Mobile top header — visible on mobile only */} +
+ +
+ + {/* Main content wrapper */} +
+ {/* Center column */} +
+ {children} +
+ + {/* Right panel — desktop only */} + +
+ + {/* Bottom nav — mobile only */} + +
+ + +STORE MODE LAYOUT: +Same structure but: + Left nav background: var(--color-espresso) + Left nav text/icons: Bianca + Center column: no max-width cap (store tools can use more space) + Right panel: hidden in store mode (no suggestions needed) + Main background: var(--color-espresso) + + +STANDALONE PAGES (no 3-column layout): + These pages break out of the 3-column structure: + ├── Landing page (/) — full-width custom layout + ├── Auth pages (/login, /register) — centered split layout + ├── Checkout flow — centered, no nav (focus mode) + ├── Order confirmed screen — centered, minimal chrome + ├── 404 / 500 pages — centered, minimal chrome + └── Admin pages — use standard 3-column but no right panel +``` + +### Modal and Bottom Sheet Behaviour + +``` +BOTTOM SHEETS (mobile): + Trigger: swipe up or button tap + Animation: slide up from bottom (200ms ease-out) + Height: auto (content-driven), max 90vh + Background: white + Top corners: var(--radius-xl) — 20px + Handle bar: 32px wide, 4px tall, Mocha/40%, centered at top + Overlay: var(--color-espresso) at 60% opacity + Close: tap overlay, swipe down, or × button + +MODALS (desktop): + Trigger: button click or navigation + Animation: fade in + scale from 95% (150ms) + Max width: 560px + Border radius: var(--radius-xl) — 20px + Background: white + Overlay: var(--color-espresso) at 70% opacity + Close: click overlay or × button (top right) + +QUICK BUY SHEET (special case): + Appears on feed — never navigates away from feed + Mobile: bottom sheet (same spec above) + Desktop: inline panel slides in from right within center column + Content: variant selector + address + payment + [Confirm] button +``` + +### Touch Targets + +``` +Minimum size: 44×44px for all interactive elements + ├── Buttons: min-height 48px web / 52px mobile + ├── Icon buttons: min 44×44px including padding + ├── Tab items: min 44px height + └── List items: min 48px height + +TAILWIND SHORTHAND: + className="min-h-[44px] min-w-[44px]" +``` + +--- + +## 4. Component Specifications + +### Buttons + +``` +PRIMARY BUTTON (main CTA): + Background: var(--color-saffron) #E8A020 + Text: var(--color-espresso) #1C1208 + Border radius: var(--radius-full) 9999px + Height: 48px web / 52px mobile + Font: Cabinet Grotesque Medium, 15–16px + Hover: var(--color-saffron-dark) #C68A10 + Pressed: #B07A0E + Disabled: 40% opacity + Tailwind: bg-[var(--color-saffron)] text-[var(--color-espresso)] + rounded-full min-h-[48px] font-medium + +ACTION BUTTON (urgency / Pro CTA): + Background: var(--color-berry) #C2185B + Text: var(--color-bianca) #FBF7F2 + Border radius: var(--radius-full) + Height: 48px web / 52px mobile + Hover: var(--color-berry-dark) #9C1448 + +SECONDARY BUTTON (outlined): + Background: transparent + Border: 1px solid var(--color-saffron) + Text: var(--color-saffron) + Border radius: var(--radius-full) + Height: 48px web / 52px mobile + +GHOST BUTTON: + Background: transparent + Border: none + Text: context-dependent (Espresso on light / Bianca on dark) + +DESTRUCTIVE BUTTON: + Background: var(--color-error) #EF4444 + Text: white + Use for: delete, cancel order, remove item +``` + +### Input Fields + +``` +LIGHT SURFACE (default web): + Background: var(--color-bianca-dark) #F0E8DC + Border: 1px solid #E0D5C8 + Border radius: var(--radius-md) 10px + Height: 52px + Font: Cabinet Grotesque Regular, 16px (MINIMUM 16px — iOS zoom prevention) + Focus border: var(--color-saffron) #E8A020 + Error border: var(--color-error) #EF4444 + Placeholder: var(--color-mocha) #6B5B4E + Label: Cabinet Grotesque Medium, 14px, Espresso + +DARK SURFACE (store dashboard): + Background: var(--color-espresso-light) #2A1E10 + Border: 1px solid var(--color-espresso-mid) + Same focus / error / placeholder rules apply + +CHARACTER COUNTER (for capped inputs): + Position: right side of label row + Style: Cabinet Grotesque Light, 12px, Mocha + Near limit: Saffron color + At limit: Berry color +``` + +### Cards + +``` +LIGHT SURFACE CARD: + Background: #FFFFFF + Border: none + Shadow: var(--shadow-card) 0 1px 3px rgba(0,0,0,0.08) + Border radius: var(--radius-lg) 14px + Padding: 16px + +DARK SURFACE CARD (dashboard): + Background: var(--color-espresso-light) #2A1E10 + Border: 1px solid var(--color-espresso-mid) + Shadow: none (use border on dark surfaces) + Border radius: var(--radius-lg) 14px + Padding: 16px +``` + +### Product Card (Feed) + +``` +Background: #FFFFFF (light surface) +Border radius: var(--radius-lg) 14px +Image: 1:1 ratio, top of card, var(--radius-xl) top corners +Shadow: var(--shadow-card) + +CONTENT (bottom of card): + Store avatar: 28px circle, Saffron ring if verified + Store name: Cabinet Grotesque SemiBold, 13px, Mocha + Product name: Cabinet Grotesque SemiBold, 15px, Espresso (1 line + ellipsis) + Price: Cabinet Grotesque Bold, 18px, Saffron (monospace-style) + Compare-at price: Cabinet Grotesque Regular, 14px, Mocha (strikethrough) + Discount badge: Saffron bg, Espresso text, 6px radius + +ACTIONS (card bottom row): + [cart icon] [bookmark icon] [Quick Buy] pill + Quick Buy: Saffron small pill, Cabinet Grotesque Medium, 13px + Icons: Lucide React — never emoji +``` + +### Navigation (Bottom Nav — Mobile Web) + +``` +Height: 56px + safe area +Background: var(--color-bianca) (light) / var(--color-espresso) (store mode) +Top border: 1px solid #E0D5C8 (light) / 1px solid var(--color-espresso-mid) (dark) + +SHOPPING MODE (5 tabs): + Home | Stores | Activity | Chat | Profile + Active: Saffron icon + Saffron label + Inactive: Mocha icon + Mocha label + Label: Cabinet Grotesque Medium, 11px + +STORE MODE (5 tabs): + Dashboard | Products | Orders | Messages | My Store + Active: Saffron icon + Saffron label + Inactive: Mocha icon + Mocha label (on dark Espresso bg) +``` + +### Verified Badges + +``` +DISPLAY FORMAT: ✓ pill (fully rounded, 6px inner radius on checkmark) + +No badge: Tier 0 stores — no badge shown +No badge: Tier 1 stores — no badge shown (auto-verified only) +Mocha badge: Tier 2 Physical stores — #6B5B4E bg, Bianca text +Berry badge: Tier 3 Pro stores — #C2185B bg, Bianca text +Berry badge: Tier 2 Dropship stores — #C2185B bg, Bianca text + +SIZE: + Inline with store name: 20px height, Cabinet Grotesque Medium, 11px + Store page header: 24px height, Cabinet Grotesque Medium, 12px +``` + +### Escrow Trust Elements + +``` +ESCROW PILL (checkout + order confirmed screen): + Background: var(--color-saffron) + Text: var(--color-espresso) + Icon: Lucide Shield icon (left of text) + Text: Cabinet Grotesque Medium, 14px + Border radius: var(--radius-full) + Content: "ESCROW PROTECTED — ₦[amount] HELD" + +ESCROW BANNER (product detail, checkout): + Background: var(--color-espresso-light) on dark + #F5EFE6 on light + Icon: Lucide Shield, Saffron + Text: Cabinet Grotesque Regular, 14px + Border: 1px solid var(--color-saffron) at 30% opacity + Border radius: var(--radius-md) +``` + +### Toast Notifications + +``` +Position: top-right (desktop) / top-center (mobile) +Border radius: var(--radius-md) 10px +Auto dismiss: 4 seconds +Font: Cabinet Grotesque Medium, 14px +Background: white (light) / Espresso light (dark) +Left border: 4px solid [status color] + +Success: var(--color-success) #22C55E +Error: var(--color-error) #EF4444 +Warning: var(--color-warning) #F59E0B +Info: var(--color-info) #3B82F6 +``` + +### Skeleton Loading + +``` +RULE: Always use skeleton screens — never spinners +Shape must match expected content exactly + +Light surface: + Background: #F0E8DC + Shimmer: animate-pulse (Tailwind) + +Dark surface: + Background: var(--color-espresso-light) #2A1E10 + Shimmer: animate-pulse + +Examples: + Product card skeleton: image square + 3 text lines + Feed skeleton: 3 product card skeletons in column + Stats skeleton: 4 equal-sized square skeletons +``` + +### Empty States + +``` +STRUCTURE: + 1. Centered icon (Lucide — 48px, Mocha color) + 2. Heading: Cabinet Grotesque SemiBold, 18px, Espresso + 3. Body: Cabinet Grotesque Regular, 14px, Mocha + 4. CTA button (Saffron primary) — always provide a next action + +COPY RULE: Never "Error: No data found" — always friendly + ├── Empty orders: "No orders yet — start exploring stores and find something you love." + ├── Empty cart: "Nothing in your cart yet — browse products and add something." + ├── Empty search: "We couldn't find that — try a different search." + └── Empty products: "No products listed yet — add your first product." +``` + +### OTP / Delivery Code Input + +``` +6 individual digit boxes (not one field) +Each box: + Width: 48px + Height: 56px + Background: var(--color-bianca-dark) + Border: 1px solid #E0D5C8 + Border radius: var(--radius-md) 10px + Font: Cabinet Grotesque Bold, 24px, Espresso + Focus: Saffron border + Filled: Espresso border + Espresso text + +Behaviour: + Auto-advance to next box after each digit entry + Backspace clears current box and focuses previous + Paste of 6 digits fills all boxes at once +``` + +--- + +## 5. Screen-Specific Guidelines + +### Landing Page (/) + +``` +This is a brand moment — Syne is allowed here. + +Hero section: + Background: var(--color-espresso) + Headline: Syne ExtraBold, 48–64px, Bianca + Sub-headline: Cabinet Grotesque Regular, 18–20px, Bianca muted + CTA buttons: Primary (Saffron) + Secondary (outlined Saffron) + Logo: logo.svg in Bianca/light variant (top of hero) + +Trust section (below hero): + Background: var(--color-bianca) + Body text: Cabinet Grotesque only — Syne ends at the hero + +Featured products section: + Public landing preview — use static launch-safe product placeholders + Product cards at standard spec + Cabinet Grotesque only for all text +``` + +### Auth Pages (/login, /register) + +``` +Split layout (desktop): + Left panel: Hero slideshow with brand imagery (existing hero slides) + Saffron ZZ mark + wordmark.svg centered + Espresso background overlay on image + Right panel: Form content — Cabinet Grotesque only + +Mobile: + Full screen form — wordmark.svg at top + Bianca background + Cabinet Grotesque only throughout + +Registration wizard (8 screens): + Progress indicator: Saffron dots / line + Cabinet Grotesque throughout + No Syne on any registration screen +``` + +### Product Detail Page (/p/[code]) + +``` +Gallery: Full width, 1:1 images, white background +Product name: Cabinet Grotesque Bold, 24px, Espresso +Price: Cabinet Grotesque Bold, 28px, Saffron (monospace-style) +Compare-at: Cabinet Grotesque Regular, 18px, Mocha (strikethrough) +Description: Cabinet Grotesque Regular, 14px, Mocha +Store card: Cabinet Grotesque — all text +Sticky bar: Bianca background, top shadow + ├── [Add to Cart] outlined Saffron pill + └── [Buy Now — ₦185,000] Saffron filled pill +``` + +### Store Dashboard + +``` +Dark canvas — Espresso background system throughout + +Escrow Balance Card: + Background: gradient from Espresso to Espresso-mid + OR solid: Espresso-light (#2A1E10) + Amount: Cabinet Grotesque Bold, 36px, Bianca (monospace-style) + Label: Cabinet Grotesque Medium, 13px, Mocha-light + Available: Cabinet Grotesque Regular, 14px, Bianca + Pending: Cabinet Grotesque Regular, 14px, Mocha-light + +Stats Grid (2×2): + Card bg: Espresso-light, Saffron left border accent (3px) + Stat number: Cabinet Grotesque Bold, 28px, Bianca + Stat label: Cabinet Grotesque Medium, 12px, Mocha-light + Change: Cabinet Grotesque Regular, 12px, Success/Error green/red + +Quick Actions: + Item bg: Espresso-mid + Label: Cabinet Grotesque Medium, 12px, Bianca + Icon: Lucide React, Saffron, 20px +``` + +### Order Confirmed Screen + +``` +Background: Bianca (white canvas moment) +Checkmark icon: Lucide CheckCircle, 64px, Saffron circle background +"Order Placed!": Cabinet Grotesque Bold, 28px, Espresso +Trust message: Cabinet Grotesque Regular, 15px, Mocha +Escrow pill: Saffron background, Espresso text (see Escrow spec above) + +Order summary: + Code: Cabinet Grotesque Bold, 14px, Mocha + Product name: Cabinet Grotesque SemiBold, 16px, Espresso + Amount: Cabinet Grotesque Bold, 18px, Espresso + +"What Happens Next" (3 steps): + Step number: Saffron circle, Cabinet Grotesque Bold, 14px, Espresso + Step text: Cabinet Grotesque Regular, 14px, Mocha + +CTAs: + [Track Order →]: Saffron full-width pill button + [Continue Shopping]: Cabinet Grotesque Medium, 14px, Mocha text link +``` + +### 404 Page (/not-found) + +``` +Background: Bianca +Logo: mark.svg centered (Espresso fill), 60px +"404": Syne ExtraBold, 96px, Saffron ← Syne allowed here +Headline: Syne Bold, 32px, Espresso ← Syne allowed here +Body: Cabinet Grotesque Regular, 16px, Mocha +CTA: [Back to Feed] Saffron pill button +``` + +### 500 Page (/error) + +``` +Background: Bianca +Logo: mark.svg centered (Espresso fill), 60px +Headline: Syne Bold, 32px, Espresso ← Syne allowed here +Body: Cabinet Grotesque Regular, 16px, Mocha +CTA: [Try Again] Saffron pill button +``` + +--- + +## 6. Icons + +``` +LIBRARY: Lucide React — the ONLY icon library for twizrr web + import { ShoppingCart, Store, Search, Shield } from 'lucide-react' + +NEVER use: + ├── Emoji as icons (absolute rule — see brand system) + ├── Heroicons, Font Awesome, or any other icon library + ├── Inline custom SVG (except twizrr logo files) + └── PNG or bitmap icons + +SIZING: + Navigation icons: 20–22px + Button icons: 18–20px + Card action icons: 18px + Section header icons: 20–24px + Feature/hero icons: 40–48px + +COLOUR: + Active state: Saffron (#E8A020) + Inactive state: Mocha (#6B5B4E) + On dark background: Bianca (#FBF7F2) + Success: #22C55E + Error: #EF4444 + Info: #3B82F6 + +EXCEPTION — WhatsApp assistant link: + Uses WhatsApp brand green (#25D366) — intentional third-party branding + Not a twizrr colour — do not use #25D366 for anything else +``` + +--- + +## 7. Imagery + +``` +HERO / AUTH SLIDESHOW IMAGES: + Style: Real Nigerians in real commerce contexts + Lighting: Warm, golden-hour (reflects Saffron brand naturally) + Content: Mobile phones, transactions, everyday life + NEVER: Hardware, construction, industrial, generic stock photos + +PRODUCT IMAGES: + Ratio: 1:1 (square) for feed cards + Aspect: 4:3 or 16:9 for product detail gallery + Delivery: Cloudinary CDN with transforms: + Feed card: q_auto,f_auto,w_400 + Product detail: q_auto,f_auto,w_800 + +STORE ASSETS: + Logo: q_auto,f_auto,w_200,h_200,c_fill + Banner: q_auto,f_auto,w_1200,h_400,c_fill + +PROFILE PHOTOS: + Transform: q_auto,f_auto,w_200,h_200,c_fill,g_face + +ALWAYS use next/image for all images: + import Image from 'next/image' + {description} + Never: tags for product or user images +``` + +--- + +## 8. Copy and Voice + +``` +TONE: Warm, direct, knowledgeable friend — not corporate. + +ESCROW LANGUAGE: + ✓ "Your money is held safely until you confirm delivery." + ✗ "Funds are escrowed pending fulfillment confirmation." + +VERIFICATION PROMPTS: + ✓ "Verify your identity to unlock more features and build trust." + ✗ "Complete KYC to proceed with tier upgrade." + +EMPTY STATES: + ✓ "No orders yet — start exploring stores and find something you love." + ✗ "Error: No orders found." + +DELIVERY CODE: + ✓ "Your order is here! Enter the code to confirm and release payment." + ✗ "Input OTP to complete transaction verification." + +STORE OWNER LANGUAGE: + Always: "Store" / "Store Owner" / "Your store" + Never: "Merchant" / "Seller account" / "Vendor" + +SHOPPER LANGUAGE: + Always: "Shopper" / "You" / "Your order" + Never: "Buyer" / "Customer account" / "End user" + +PRICING: + Always: "₦185,000" (Nigerian Naira symbol + amount, no decimal if whole number) + Monospace-style font for all prices + Never: "NGN 185000" or "N185,000" +``` + +--- + +## 9. Tailwind Config + +```typescript +// tailwind.config.ts +import type { Config } from 'tailwindcss' + +const config: Config = { + content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'], + theme: { + extend: { + colors: { + saffron: 'var(--color-saffron)', + espresso: 'var(--color-espresso)', + berry: 'var(--color-berry)', + bianca: 'var(--color-bianca)', + mocha: 'var(--color-mocha)', + }, + fontFamily: { + cabinet: ['var(--font-cabinet)', 'sans-serif'], + syne: ['var(--font-syne)', 'sans-serif'], + }, + borderRadius: { + sm: '6px', + md: '10px', + lg: '14px', + xl: '20px', + full: '9999px', + }, + boxShadow: { + card: '0 1px 3px rgba(0, 0, 0, 0.08)', + modal: '0 4px 12px rgba(0, 0, 0, 0.15)', + }, + }, + }, + plugins: [], +} + +export default config +``` + +--- + +## 10. Implementation Checklist + +``` +Every component built must pass these checks: + +TYPOGRAPHY: + [ ] Cabinet Grotesque used for all interface text + [ ] Syne only used on landing/404/500 hero headlines + [ ] Logo displayed via SVG asset (not typed text) + [ ] No font hardcoded in JSX — always Tailwind font class + +COLOURS: + [ ] CSS variables used — no hardcoded hex values + [ ] Old Swifta green does not appear anywhere + [ ] Old Swifta navy does not appear anywhere + [ ] Correct theme surface used (light vs dark context) + +LAYOUT: + [ ] Starts at 375px — not desktop-first + [ ] Touch targets minimum 44×44px + [ ] Input font minimum 16px + [ ] next/image used for all images (not tags) + +ICONS: + [ ] Lucide React only + [ ] No emojis as icons or in any copy + [ ] Icons have correct size and colour for context + +BUILD: + [ ] cd apps/web && pnpm lint — zero errors + [ ] npx tsc --noEmit — zero errors + [ ] pnpm build — passes clean + +ACCESSIBILITY: + [ ] All images have descriptive alt text + [ ] Interactive elements have visible focus states + [ ] Colour contrast meets WCAG AA minimum +``` + +--- + +## 11. What This Document Does Not Cover + +``` +MOBILE APP: + When Expo mobile build begins, a separate + MOBILE_DESIGN.md will be created. + It will follow the same brand principles but with + React Native-specific implementation rules + (SafeAreaView, haptics, native navigation, etc.) + +DARK MODE (WEB): + Deferred to Phase 3. + Do not implement dark mode tokens or toggles now. + When built: it will use the Espresso-based dark theme + from TWIZRR_DESIGN_SYSTEM.md as the foundation. + +ANIMATION AND MOTION: + To be defined in a future update. + For now: use Tailwind transition utilities only. + Keep transitions under 200ms. + No heavy animations in product UI. +``` + +--- + +*This document is specific to the web app (apps/web).* +*For overall brand guidelines: see TWIZRR_DESIGN_SYSTEM.md* +*For questions or exceptions: contact Abdul Rasheed Brimah (Lead Designer)* diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 4f11a03d..40c3d680 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 3a4a4cc7..26f21a40 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -1,30 +1,23 @@ -import { withSentryConfig } from "@sentry/nextjs"; +const cloudinaryCloudName = + process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME ?? "twizrr-dev"; /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, images: { - domains: ["res.cloudinary.com", "ui-avatars.com"], + remotePatterns: [ + { + protocol: "https", + hostname: "res.cloudinary.com", + pathname: `/${cloudinaryCloudName}/image/upload/**`, + }, + { + protocol: "https", + hostname: "ui-avatars.com", + }, + ], }, - transpilePackages: ["@swifta/shared"], + transpilePackages: ["@twizrr/shared"], }; -export default withSentryConfig( - nextConfig, - { - // For all available options, see: - // https://github.com/getsentry/sentry-webpack-plugin#options - silent: true, // Suppresses all logs - org: process.env.SENTRY_ORG || "swifta", - project: process.env.SENTRY_PROJECT || "swifta-frontend", - }, - { - // For all available options, see: - // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ - widenClientFileUpload: true, - transpileClientSDK: true, - tunnelRoute: "/monitoring", - hideSourceMaps: true, - disableLogger: true, - }, -); +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 15abbf91..3cc53b9a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,45 +1,48 @@ { - "name": "@swifta/web", + "name": "@twizrr/web", "version": "1.0.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { - "@swifta/shared": "workspace:*", "@hookform/resolvers": "^5.2.2", - "@radix-ui/react-icons": "^1.3.0", - "@radix-ui/react-slot": "^1.0.2", - "@radix-ui/react-toast": "^1.1.5", - "@sentry/nextjs": "^10.40.0", + "@radix-ui/react-dialog": "^1.1.15", "@tanstack/react-query": "^5.0.0", - "@vercel/speed-insights": "^1.3.1", - "class-variance-authority": "^0.7.0", - "clsx": "^2.1.0", + "@twizrr/shared": "workspace:*", + "clsx": "^2.1.1", + "framer-motion": "^12.42.2", "lucide-react": "^0.330.0", - "next": "14.1.0", - "next-themes": "^0.4.6", + "next": "^14.2.35", "react": "^18", "react-dom": "^18", + "react-easy-crop": "^5.5.7", "react-hook-form": "^7.71.2", - "recharts": "^3.7.0", - "sonner": "^2.0.7", - "tailwind-merge": "^2.2.1", - "tailwindcss-animate": "^1.0.7", + "socket.io-client": "^4.8.3", + "tailwind-merge": "^3.6.0", "zod": "^4.3.6" }, "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@vitejs/plugin-react": "^6.0.3", "autoprefixer": "^10.0.1", "eslint": "^8", "eslint-config-next": "14.1.0", + "jsdom": "^29.1.1", "postcss": "^8", "tailwindcss": "^3.3.0", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" } } diff --git a/apps/web/public/default-cover.jpg b/apps/web/public/default-cover.jpg deleted file mode 100644 index b7fe8f3a..00000000 Binary files a/apps/web/public/default-cover.jpg and /dev/null differ diff --git a/apps/web/public/icon-192.svg b/apps/web/public/icon-192.svg deleted file mode 100644 index 87dbdbe8..00000000 --- a/apps/web/public/icon-192.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/apps/web/public/icon-512.svg b/apps/web/public/icon-512.svg deleted file mode 100644 index ded08ae2..00000000 --- a/apps/web/public/icon-512.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/apps/web/public/images/hero/slide-1.jpg b/apps/web/public/images/hero/slide-1.jpg deleted file mode 100644 index 9bfcf016..00000000 Binary files a/apps/web/public/images/hero/slide-1.jpg and /dev/null differ diff --git a/apps/web/public/images/hero/slide-2.jpg b/apps/web/public/images/hero/slide-2.jpg deleted file mode 100644 index 7d33bacd..00000000 Binary files a/apps/web/public/images/hero/slide-2.jpg and /dev/null differ diff --git a/apps/web/public/images/hero/slide-3.jpg b/apps/web/public/images/hero/slide-3.jpg deleted file mode 100644 index ca60f578..00000000 Binary files a/apps/web/public/images/hero/slide-3.jpg and /dev/null differ diff --git a/apps/web/public/images/hero/slide-4.jpg b/apps/web/public/images/hero/slide-4.jpg deleted file mode 100644 index f872325b..00000000 Binary files a/apps/web/public/images/hero/slide-4.jpg and /dev/null differ diff --git a/apps/web/public/images/hero/slide-5.jpg b/apps/web/public/images/hero/slide-5.jpg deleted file mode 100644 index 9108e1e9..00000000 Binary files a/apps/web/public/images/hero/slide-5.jpg and /dev/null differ diff --git a/apps/web/public/images/landing/hero-trade.png b/apps/web/public/images/landing/hero-trade.png deleted file mode 100644 index 55cceba2..00000000 Binary files a/apps/web/public/images/landing/hero-trade.png and /dev/null differ diff --git a/apps/web/public/images/landing/merchant-buyer.png b/apps/web/public/images/landing/merchant-buyer.png deleted file mode 100644 index 26e4a043..00000000 Binary files a/apps/web/public/images/landing/merchant-buyer.png and /dev/null differ diff --git a/apps/web/public/images/slideshow/slide-1.jpg b/apps/web/public/images/slideshow/slide-1.jpg deleted file mode 100644 index 9bfcf016..00000000 Binary files a/apps/web/public/images/slideshow/slide-1.jpg and /dev/null differ diff --git a/apps/web/public/images/slideshow/slide-2.jpg b/apps/web/public/images/slideshow/slide-2.jpg deleted file mode 100644 index 7d33bacd..00000000 Binary files a/apps/web/public/images/slideshow/slide-2.jpg and /dev/null differ diff --git a/apps/web/public/images/slideshow/slide-3.jpg b/apps/web/public/images/slideshow/slide-3.jpg deleted file mode 100644 index ca60f578..00000000 Binary files a/apps/web/public/images/slideshow/slide-3.jpg and /dev/null differ diff --git a/apps/web/public/images/slideshow/slide-4.jpg b/apps/web/public/images/slideshow/slide-4.jpg deleted file mode 100644 index f872325b..00000000 Binary files a/apps/web/public/images/slideshow/slide-4.jpg and /dev/null differ diff --git a/apps/web/public/images/slideshow/slide-5.jpg b/apps/web/public/images/slideshow/slide-5.jpg deleted file mode 100644 index 9108e1e9..00000000 Binary files a/apps/web/public/images/slideshow/slide-5.jpg and /dev/null differ diff --git a/apps/web/public/images/slideshow/slide-6.jpg b/apps/web/public/images/slideshow/slide-6.jpg deleted file mode 100644 index 9bfcf016..00000000 Binary files a/apps/web/public/images/slideshow/slide-6.jpg and /dev/null differ diff --git a/apps/web/public/images/slideshow/slide-7.jpg b/apps/web/public/images/slideshow/slide-7.jpg deleted file mode 100644 index ca60f578..00000000 Binary files a/apps/web/public/images/slideshow/slide-7.jpg and /dev/null differ diff --git a/apps/web/public/logo/logo.svg b/apps/web/public/logo/logo.svg new file mode 100644 index 00000000..ba22723e --- /dev/null +++ b/apps/web/public/logo/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/web/public/logo/logo2.svg b/apps/web/public/logo/logo2.svg new file mode 100644 index 00000000..07ea9072 --- /dev/null +++ b/apps/web/public/logo/logo2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/web/public/logo/logo3.svg b/apps/web/public/logo/logo3.svg new file mode 100644 index 00000000..0031fd82 --- /dev/null +++ b/apps/web/public/logo/logo3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/web/public/logo/mark.svg b/apps/web/public/logo/mark.svg new file mode 100644 index 00000000..02d287e7 --- /dev/null +++ b/apps/web/public/logo/mark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/public/logo/wordmark.svg b/apps/web/public/logo/wordmark.svg new file mode 100644 index 00000000..d0f3c83f --- /dev/null +++ b/apps/web/public/logo/wordmark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json deleted file mode 100644 index db609bc1..00000000 --- a/apps/web/public/manifest.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "Swifta", - "short_name": "Swifta", - "description": "Nigeria's WhatsApp E-Commerce Platform", - "start_url": "/", - "display": "standalone", - "background_color": "#0F2B4C", - "theme_color": "#00C853", - "icons": [ - { - "src": "/icon-192.svg", - "sizes": "192x192", - "type": "image/svg+xml" - }, - { - "src": "/icon-512.svg", - "sizes": "512x512", - "type": "image/svg+xml" - } - ] -} diff --git a/apps/web/sentry.client.config.ts b/apps/web/sentry.client.config.ts deleted file mode 100644 index 8812098b..00000000 --- a/apps/web/sentry.client.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import * as Sentry from "@sentry/nextjs"; - -Sentry.init({ - dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || "", - - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1, - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, -}); diff --git a/apps/web/sentry.edge.config.ts b/apps/web/sentry.edge.config.ts deleted file mode 100644 index 8812098b..00000000 --- a/apps/web/sentry.edge.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import * as Sentry from "@sentry/nextjs"; - -Sentry.init({ - dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || "", - - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1, - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, -}); diff --git a/apps/web/sentry.server.config.ts b/apps/web/sentry.server.config.ts deleted file mode 100644 index 8812098b..00000000 --- a/apps/web/sentry.server.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import * as Sentry from "@sentry/nextjs"; - -Sentry.init({ - dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || "", - - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1, - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, -}); diff --git a/apps/web/src/app/(admin)/admin/_components/AdminShell.test.tsx b/apps/web/src/app/(admin)/admin/_components/AdminShell.test.tsx new file mode 100644 index 00000000..74fe0723 --- /dev/null +++ b/apps/web/src/app/(admin)/admin/_components/AdminShell.test.tsx @@ -0,0 +1,152 @@ +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const replace = vi.fn(); +vi.mock("next/navigation", () => ({ + usePathname: () => "/admin/dashboard", + useRouter: () => ({ replace, push: vi.fn() }), +})); + +vi.mock("next/link", async () => { + const React = await import("react"); + return { + default: ({ href, children }: { href: string; children: ReactNode }) => + React.createElement("a", { href }, children), + }; +}); + +const post = vi.fn(); +vi.mock("@/lib/api", () => ({ + api: { post: (...args: unknown[]) => post(...args) }, +})); + +const fetchAdminDashboard = vi.fn(); +vi.mock("@/lib/admin", () => ({ + fetchAdminDashboard: (...args: unknown[]) => fetchAdminDashboard(...args), +})); + +// The realtime provider opens a socket and reads the profile via React Query; +// it is exercised in RealtimeNotifications.test.tsx. Stub it here so the shell +// chrome/auth-gate tests stay isolated and need no QueryClientProvider. +vi.mock("@/components/providers/RealtimeNotifications", () => ({ + RealtimeNotifications: () => null, +})); + +import { AdminShell } from "./AdminShell"; + +function stubMatchMedia(matches: boolean) { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia; +} + +beforeEach(() => { + replace.mockReset(); + post.mockReset().mockResolvedValue({}); + fetchAdminDashboard.mockReset().mockResolvedValue({ users: { total: 1 } }); + stubMatchMedia(true); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("AdminShell", () => { + it("renders the console chrome and children for an authorized admin", async () => { + render( + +
Child content
+
, + ); + + expect(await screen.findByText("Child content")).toBeInTheDocument(); + expect(screen.getByText("twizrr admin")).toBeInTheDocument(); + expect(screen.getByText("Dashboard")).toBeInTheDocument(); + expect(screen.getByText("Sign out")).toBeInTheDocument(); + // No removed-role wording anywhere in the shell. + expect(document.body.textContent).not.toMatch(/OPERATOR|SUPPORT|staff/i); + }); + + it("renders every expected admin nav item with its route", async () => { + render( + +
Child content
+
, + ); + await screen.findByText("Child content"); + + const NAV: Array<[label: string, href: string]> = [ + ["Dashboard", "/admin/dashboard"], + ["Users", "/admin/users"], + ["Stores", "/admin/stores"], + ["Orders", "/admin/orders"], + ["Payouts", "/admin/payouts"], + ["Payment exceptions", "/admin/payment-exceptions"], + ["Verifications", "/admin/verification"], + ["Disputes", "/admin/disputes"], + ["Moderation", "/admin/moderation"], + ["Deliveries", "/admin/deliveries"], + ["Audit logs", "/admin/audit-logs"], + ["Security Events", "/admin/security"], + ["Trust & Safety", "/admin/safety"], + ["Technical Ops", "/admin/ops"], + ]; + + for (const [label, href] of NAV) { + const link = screen.getByRole("link", { name: label }); + expect(link).toHaveAttribute("href", href); + } + }); + + it("shows admin-access-required for an authenticated non-admin (403)", async () => { + fetchAdminDashboard.mockRejectedValue({ statusCode: 403 }); + render( + +
Child content
+
, + ); + + expect( + await screen.findByText(/Admin access required/i), + ).toBeInTheDocument(); + expect(screen.queryByText("Child content")).not.toBeInTheDocument(); + }); + + it("shows the desktop-only blocker on small viewports", async () => { + stubMatchMedia(false); + render( + +
Child content
+
, + ); + + expect(await screen.findByText("Desktop only")).toBeInTheDocument(); + }); + + it("signs out and returns to the admin login", async () => { + render( + +
Child content
+
, + ); + await screen.findByText("Child content"); + + await userEvent.click(screen.getByRole("button", { name: /Sign out/i })); + + await waitFor(() => { + expect(post).toHaveBeenCalledWith("/auth/logout", {}); + }); + await waitFor(() => { + expect(replace).toHaveBeenCalledWith("/login"); + }); + }); +}); diff --git a/apps/web/src/app/(admin)/admin/_components/AdminShell.tsx b/apps/web/src/app/(admin)/admin/_components/AdminShell.tsx new file mode 100644 index 00000000..a13e2118 --- /dev/null +++ b/apps/web/src/app/(admin)/admin/_components/AdminShell.tsx @@ -0,0 +1,378 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect, useState, type ReactNode } from "react"; +import { + Activity, + AlertTriangle, + BadgeCheck, + ChevronLeft, + Flag, + LayoutDashboard, + LogOut, + Monitor, + ShieldCheck, + ShieldAlert, + MapPinned, + ShoppingBag, + Store, + Truck, + Users, + Wallet, +} from "lucide-react"; +import { api, type ApiError } from "@/lib/api"; +import { fetchAdminDashboard } from "@/lib/admin"; +import { PageLoadingState } from "@/components/ui/brand-loader"; +import { RealtimeNotifications } from "@/components/providers/RealtimeNotifications"; +import { cn } from "@/lib/utils"; + +type ProbeState = "loading" | "authorized" | "unauthorized" | "error"; + +// Admin is desktop-only (UX rule, not security — the backend still enforces +// SUPER_ADMIN regardless of device). Matches the Tailwind `xl` breakpoint. +const ADMIN_MIN_WIDTH = 1280; + +interface AdminShellProps { + children: ReactNode; +} + +/** Client viewport gate for the desktop-only admin shell. */ +function useIsDesktopViewport(): boolean | null { + const [isDesktop, setIsDesktop] = useState(null); + + useEffect(() => { + const query = window.matchMedia(`(min-width: ${ADMIN_MIN_WIDTH}px)`); + const update = () => setIsDesktop(query.matches); + update(); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + + return isDesktop; +} + +/** + * AdminShell — chrome + role gate for /admin/*. + * + * Cookie-based auth is already enforced by middleware (twizrr_access_token). + * Role enforcement (SUPER_ADMIN) lives in the backend + * RolesGuard, so the shell probes GET /admin/dashboard on mount: + * 200 → render full admin chrome + page children + * 401 / 403 → render an Unauthorized state with a link home + * network err → render an "unable to verify access" state with retry + * + * Why client-side probe: the codebase doesn't yet have a server-side + * cookie-forwarding helper (next/headers). Matching the existing pattern + * (client-side shell probes and page-level fetches) keeps this MVP tight. + * + * Audit-logs and broad order pages are deferred to a follow-up PR — listed in + * the nav as "Coming soon" so the surface is discoverable but unclickable. + */ +export function AdminShell({ children }: AdminShellProps) { + const pathname = usePathname(); + const isDesktop = useIsDesktopViewport(); + const [state, setState] = useState("loading"); + const [reload, setReload] = useState(0); + + useEffect(() => { + let cancelled = false; + + async function probe() { + try { + await fetchAdminDashboard(); + if (!cancelled) setState("authorized"); + } catch (err: unknown) { + if (cancelled) return; + const apiErr = err as ApiError & { statusCode?: number }; + const msg = (apiErr?.message ?? "").toLowerCase(); + if ( + apiErr?.code === "FORBIDDEN" || + apiErr?.statusCode === 401 || + apiErr?.statusCode === 403 || + msg.includes("unauthorized") || + msg.includes("forbidden") + ) { + setState("unauthorized"); + } else { + setState("error"); + } + } + } + + setState("loading"); + void probe(); + + return () => { + cancelled = true; + }; + }, [reload]); + + // Desktop-only gate. `null` = viewport not measured yet (SSR/first paint) — + // fall through to the normal loading state to avoid a hydration mismatch. + if (isDesktop === false) { + return ; + } + + if (state === "loading") { + return ; + } + + if (state === "unauthorized") { + return ; + } + + if (state === "error") { + return setReload((n) => n + 1)} />; + } + + return ( +
+ {/* + * Live push channel for signed-in operators. Mounted once here (not in + * individual admin pages) so there is exactly one socket per admin app + * lifecycle. It only mounts in the authorized branch — never on the + * loading, unauthorized, or public surfaces. The socket authenticates + * with the same HttpOnly cookie and joins only the admin's own + * user:{userId} room. + */} + +
+ +
+ {children} +
+
+
+ ); +} + +// ── Sidebar ────────────────────────────────────────────────────────────────── + +interface NavLink { + href: string; + label: string; + icon: typeof LayoutDashboard; + disabled?: boolean; + badge?: string; +} + +const NAV_LINKS: NavLink[] = [ + { href: "/admin/dashboard", label: "Dashboard", icon: LayoutDashboard }, + { href: "/admin/users", label: "Users", icon: Users }, + { href: "/admin/stores", label: "Stores", icon: Store }, + { href: "/admin/orders", label: "Orders", icon: ShoppingBag }, + { href: "/admin/payouts", label: "Payouts", icon: Wallet }, + { + href: "/admin/payment-exceptions", + label: "Payment exceptions", + icon: AlertTriangle, + }, + { href: "/admin/verification", label: "Verifications", icon: BadgeCheck }, + { href: "/admin/disputes", label: "Disputes", icon: AlertTriangle }, + { href: "/admin/moderation", label: "Moderation", icon: Flag }, + { href: "/admin/deliveries", label: "Deliveries", icon: Truck }, + { href: "/admin/audit-logs", label: "Audit logs", icon: ShieldCheck }, + { href: "/admin/security", label: "Security Events", icon: ShieldAlert }, + { href: "/admin/safety", label: "Trust & Safety", icon: ShieldAlert }, + { + href: "/admin/insights/location-demand-supply", + label: "Location insights", + icon: MapPinned, + }, + { href: "/admin/ops", label: "Technical Ops", icon: Activity }, +]; + +function AdminSidebar({ pathname }: { pathname: string }) { + return ( + + ); +} + +function SignOutButton() { + const router = useRouter(); + const [pending, setPending] = useState(false); + + async function signOut() { + setPending(true); + try { + await api.post("/auth/logout", {}); + } catch { + // Even if the request fails, clear the client and return to login — the + // backend session cookie is cleared server-side on logout regardless. + } finally { + router.replace("/login"); + } + } + + return ( + + ); +} + +function SidebarLink({ link, pathname }: { link: NavLink; pathname: string }) { + const Icon = link.icon; + const active = pathname === link.href || pathname.startsWith(`${link.href}/`); + + if (link.disabled) { + return ( + + + + {link.badge && ( + + {link.badge} + + )} + + ); + } + + return ( + +