diff --git a/docker-compose.yml b/docker-compose.yml index c0c9344..388b718 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -554,6 +554,8 @@ services: BEAUTY_BUSINESS_SERVICE_URL: ${BEAUTY_BUSINESS_SERVICE_URL:-http://beauty-business-service:8011} NEXT_PUBLIC_HOSPITALITY_API_URL: ${NEXT_PUBLIC_HOSPITALITY_API_URL:-http://localhost:8009} HOSPITALITY_SERVICE_URL: ${HOSPITALITY_SERVICE_URL:-http://hospitality-service:8009} + NEXT_PUBLIC_PAYMENT_API_URL: ${NEXT_PUBLIC_PAYMENT_API_URL:-http://localhost:8012} + PAYMENT_SERVICE_URL: ${PAYMENT_SERVICE_URL:-http://payment-service:8012} # polling برای hot-reload پایدار روی volume mount ویندوز WATCHPACK_POLLING: "true" CHOKIDAR_USEPOLLING: "true" diff --git a/docs/next-steps.md b/docs/next-steps.md index c1f81a9..7e97928 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -4,9 +4,9 @@ ## Payment Platform — MVP complete (Torbat Pay) -Phases **payment-14.0 through payment-14.5** are implemented (v**0.14.5.0**) per [phase-handover/phase-14-5.md](phase-handover/phase-14-5.md) and [service-snapshots/payment.yaml](service-snapshots/payment.yaml). +Phases **payment-14.0 through payment-14.5** backend (v**0.14.5.0**) per [phase-handover/phase-14-5.md](phase-handover/phase-14-5.md). **Payment frontend (payment-frontend)** complete per [phase-handover/phase-payment-frontend.md](phase-handover/phase-payment-frontend.md) and [service-snapshots/payment-frontend.yaml](service-snapshots/payment-frontend.yaml). -**Next implementation phase:** `payment-14.6` Refunds & Reversals (**LATER** priority — requires Communication integration). +**Next implementation phase:** `payment-14.6` Refunds & Reversals (**LATER** priority — requires Communication integration). No 14.6+ payment UI until backend phase lands. Remaining documented phases: 14.6–14.10 (refunds, splits, connectors, reconciliation, enterprise validation). @@ -28,7 +28,7 @@ Pick one track per team capacity (see [roadmap.md](roadmap.md) and [phase-manife | Track | Next phase | Notes | | --- | --- | --- | -| **Payment (Torbat Pay)** | `payment-14.6` Refunds & Reversals | MVP 14.0–14.5 complete; LATER priority | +| **Payment (Torbat Pay)** | `payment-14.6` Refunds & Reversals | Backend MVP 14.0–14.5 + frontend complete; LATER priority | | Delivery | `delivery-10.2` Fleet & Vehicle Types | After Driver Management 10.1 | | Loyalty | `loyalty-7.7` Gift Card Platform | After Wallet 7.6 | | Hospitality | `hospitality-12.9` AI Assistant | After Analytics 12.8 | diff --git a/docs/phase-handover/phase-payment-frontend.md b/docs/phase-handover/phase-payment-frontend.md new file mode 100644 index 0000000..0674c9b --- /dev/null +++ b/docs/phase-handover/phase-payment-frontend.md @@ -0,0 +1,46 @@ +# Phase Handover — Payment Frontend (payment-frontend) + +**Completed:** 2026-07-27 +**Service:** Torbat Pay — Enterprise Payment Platform +**Backend version at handover:** 0.14.5.0 (phases 14.0–14.5) + +## Summary + +Full MVP admin portal for Torbat Pay delivered under `frontend/modules/payment/` and `frontend/app/payment/**`. Persian labels, real API wiring via BFF to port 8012, recharts on dashboard/KPIs. + +## Deliverables + +- 26 feature modules under `modules/payment/features/` +- Thin Next.js app routes with loading/error boundaries +- BFF `app/api/payment/[...path]/route.ts` (Idempotency-Key forwarded) +- `scripts/validate-payment-routes.mjs` + `npm run validate:payment` +- Apps catalog entry: Torbat Pay → `/payment/hub` +- Docker env: `NEXT_PUBLIC_PAYMENT_API_URL`, `PAYMENT_SERVICE_URL` +- Snapshots/docs: `payment-frontend.yaml`, architecture + validation reports + +## Checkout session mapping + +`PaymentRequest` detail page (`/payment/requests/[id]`) is the checkout session view — cancel, verify, redirect URL display. + +## Stop boundary + +**Do not implement 14.6+ UI** (refunds, splits, subscriptions) until backend phases complete. + +## Next backend phase + +`payment-14.6` Refunds & Reversals — frontend remains list/read for MVP until explicitly scoped. + +## Verification + +```bash +cd frontend +npm run validate:payment +npm run typecheck +``` + +## References + +- [payment-frontend-architecture.md](../../frontend/docs/payment-frontend-architecture.md) +- [payment-frontend-validation-report.md](../../frontend/docs/payment-frontend-validation-report.md) +- [service-snapshots/payment-frontend.yaml](../service-snapshots/payment-frontend.yaml) +- [service-snapshots/payment.yaml](../service-snapshots/payment.yaml) diff --git a/docs/progress.md b/docs/progress.md index a9a3b6d..92e0e04 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1075,6 +1075,21 @@ Remaining (honest): dispatch/fleet/tracking/maps live/realtime/settlement screen --- +## Phase Payment Frontend — Torbat Pay Admin Portal ✅ + +> `frontend/modules/payment/` + `frontend/app/payment/**` — MVP UI for phases 14.0–14.5 only. + +- [x] 26 feature screens (hub, dashboard, KPIs, transactions, ledger, PSP, merchants, requests, audit, bundles, toggles, capabilities, settings, metrics, monitoring, API explorer, OpenAPI) +- [x] BFF `/api/payment` → port 8012; Idempotency-Key forwarded +- [x] Checkout session = `PaymentRequest` detail (`/payment/requests/[id]`) +- [x] Apps catalog: Torbat Pay → `/payment/hub` +- [x] Validation: `npm run validate:payment` +- [x] Handover: [phase-handover/phase-payment-frontend.md](phase-handover/phase-payment-frontend.md) +- [x] Snapshot: [service-snapshots/payment-frontend.yaml](service-snapshots/payment-frontend.yaml) +- [x] **14.6+ UI not started** (refunds, splits — by design) + +--- + ## Related Documents - [Next Steps](next-steps.md) diff --git a/docs/project-status.md b/docs/project-status.md new file mode 100644 index 0000000..5acb908 --- /dev/null +++ b/docs/project-status.md @@ -0,0 +1,122 @@ +# Project Status — TorbatYar Single Execution Dashboard (v2) + +**Authoritative machine file:** [`project-status.yaml`](project-status.yaml) + +This is the **first document** every AI implementation run must read. When it exists, agents must **not** full-scan the repository or inspect unrelated services. + +| Field | Value | +| --- | --- | +| Schema | **2** | +| Project version | `0.14.5-platform` | +| Last updated | 2026-07-27 | +| Last completed phase | `platform-project-status-v2` | + +**Deprecated:** `next_recommended_phase` → replaced by `execution_priority` + `critical_path` + `deployment_readiness`. + +--- + +## Resume rules (every execution) + +1. Read `docs/project-status.yaml` **FIRST**. +2. Never inspect unrelated services. +3. Never scan the repository for completed work. +4. Determine remaining work **exclusively** from this file. +5. Load only documents required by [Runtime Read Policy](ai-framework/runtime-read-policy.md) for the active service. +6. Execute higher `execution_priority` before lower. +7. Follow `critical_path` order for commercial-release sequencing. + +--- + +## Critical path (first commercial release) + +Ordered remaining milestones: + +1. **Payment Backend MVP** — `payment-14.6` … `payment-14.10` +2. **Payment Frontend** +3. **Experience Frontend** — Publish Registry `experience-fe-11.6+` +4. **Restaurant Production** — `hospitality-12.9` … `12.10` +5. **Driver Production** — `delivery-10.2` … `10.10` +6. **Medical Booking** +7. **Booking** +8. **Short Links** +9. **QR** +10. **Digital Card** + +--- + +## Execution priority + +| Bucket | Phases | +| --- | --- | +| **critical** | `payment-14.6`–`14.10`, `payment-frontend`, `hospitality-12.9`–`12.10` | +| **high** | `delivery-10.2`–`10.10`, `experience-fe-11.6`, `communication-frontend-complete` | +| **medium** | `loyalty-7.7`–`7.10`, `sports-center-9.8`–`9.10`, `accounting-5.12` | +| **low** | Identity / CRM / Healthcare / Beauty frontend polish | +| **future** | Ecommerce, Marketplace, Automation, Academy, Clinic, Hotel, Tourism, AI, SMS Panel, Short Links, QR, Digital Card, Booking, Notification, File Storage, Live Chat, Messenger | + +--- + +## Global statistics + +| Metric | Value | +| --- | --- | +| Number of services | 27 | +| Completed services | 6 | +| In progress | 7 | +| Planned / scaffolded | 14 | +| Backend completion | ~48% | +| Frontend completion | ~22% | +| Overall completion | ~35% | +| Critical path remaining | 10 | + +--- + +## Deployment readiness + +Official indicator per service: + +```yaml +deployment_readiness: + backend: NOT_STARTED | IN_PROGRESS | COMPLETE + frontend: NOT_STARTED | IN_PROGRESS | COMPLETE + database: NOT_STARTED | IN_PROGRESS | COMPLETE + api: NOT_STARTED | IN_PROGRESS | COMPLETE + tests: NOT_STARTED | IN_PROGRESS | COMPLETE + documentation: NOT_STARTED | IN_PROGRESS | COMPLETE + production_ready: NOT_STARTED | IN_PROGRESS | COMPLETE +``` + +--- + +## Service completion (canonical) + +| Service | Completed | Remaining | BE% | FE% | Overall% | Prod ready | +| --- | --- | --- | --- | --- | --- | --- | +| Core | onboarding-4 | — | 100 | 100 | 100 | COMPLETE | +| Identity | identity-2 | FE polish | 100 | 70 | 85 | COMPLETE | +| Accounting | 5.11 | 5.12 | 92 | 40 | 66 | IN_PROGRESS | +| CRM | 6.3 | FE polish | 100 | 55 | 78 | COMPLETE | +| Loyalty | 7.6 | 7.7–7.10 | 60 | 0 | 30 | IN_PROGRESS | +| Communication | 8 | FE complete | 100 | 65 | 82 | COMPLETE | +| Sports Center | 9.7 | 9.8–9.10 | 70 | 0 | 35 | IN_PROGRESS | +| Delivery | 10.1 | 10.2–10.10 | 18 | 25 | 22 | IN_PROGRESS | +| Experience | FE-11.5 | FE-11.6+ | 100 | 83 | 92 | IN_PROGRESS | +| Hospitality | 12.8 | 12.9–12.10 | 80 | 0 | 40 | IN_PROGRESS | +| Healthcare | 13-7 | FE polish | 100 | 55 | 78 | COMPLETE | +| Beauty | 14-7 | FE polish | 100 | 55 | 78 | COMPLETE | +| Payment | 14.5 | 14.6–14.10 + FE | 50 | 0 | 25 | IN_PROGRESS | +| Future products | — | foundations | 0–5 | 0 | 0–2 | NOT_STARTED | + +## Dependency graph + +Use `depends_on` / `required_by` on each service row — do not derive by scanning code. + +## Automatic updates + +After every Complete phase, update: completed/remaining phases, deployment readiness, percentages, critical path, execution priorities, service completion, timestamps. No manual editing when following the Documentation Update stage. + +## Framework refs + +[Runtime Read Policy](ai-framework/runtime-read-policy.md) · [Context Cache](ai-framework/context-cache-policy.md) · [Service Snapshot](ai-framework/service-snapshot-policy.md) · [Development Loop](ai-framework/development-loop.md) · [Master Prompt](ai-framework/master-prompt.md) + +Handover: [phase-af-project-status-v2.md](phase-handover/phase-af-project-status-v2.md) diff --git a/docs/project-status.yaml b/docs/project-status.yaml new file mode 100644 index 0000000..93e4e5c --- /dev/null +++ b/docs/project-status.yaml @@ -0,0 +1,1200 @@ +# Project Status Registry v2 — single execution dashboard for all future AI runs. +# FIRST document every implementation phase reads. +# Normative: docs/ai-framework/runtime-read-policy.md, master-prompt.md, development-loop.md +# Update AUTOMATICALLY after every completed phase. Do NOT full-project-scan when this file exists. +# Human-readable companion: docs/project-status.md +# +# DEPRECATED: next_recommended_phase — replaced by execution_priority + critical_path + deployment_readiness. + +schema_version: 2 +project_version: "0.14.5-platform" +last_updated: "2026-07-27" +last_completed_phase: payment-frontend + +# --- Resume Rules (mandatory for every AI execution) --- +resume_rules: + - Read docs/project-status.yaml FIRST. + - Never inspect unrelated services. + - Never scan the repository for completed work. + - Determine remaining work exclusively from this file. + - Only load additional documents required by Runtime Read Policy for the active service. + - Execute higher execution_priority work before lower priority. + - Follow critical_path order for commercial-release milestones. + +# --- Automatic Update Rules (after every Complete phase) --- +automatic_update_rules: + - completed_phase / last_completed_phase + - remaining_phases per service + - deployment_readiness per service + - backend_percent / frontend_percent / overall_percent per service + - global_statistics + - critical_path (remove completed milestones; reorder if needed) + - execution_priority (move completed phases out of all buckets) + - service completion summary fields + - depends_on / required_by if registration changes + - last_updated timestamp + - project-status.md companion + - No manual editing required when agent follows Documentation Update stage + +authority: + role: single_execution_dashboard + read_first: true + replaces_full_project_scan: true + replaces_next_recommended_phase: true + companions: + - docs/project-status.md + - docs/ai-framework/project-index.yaml + - docs/ai-framework/phase-manifest.yaml + - docs/ai-framework/service-manifest.yaml + +# --- Critical Path (ordered milestones to first commercial release) --- +# Replaces next_recommended_phase as the commercial-release sequencer. +critical_path: + - id: payment-backend-mvp + label: Payment Backend MVP (14.6–14.10) + phases: [payment-14.6, payment-14.7, payment-14.8, payment-14.9, payment-14.10] + status: remaining + - id: payment-frontend + label: Payment Frontend + phases: [payment-frontend] + status: complete + - id: experience-frontend-registry + label: Experience Frontend (Publish Registry FE-11.6+) + phases: [experience-fe-11.6] + status: remaining + - id: hospitality-production + label: Restaurant Production (Hospitality 12.9–12.10) + phases: [hospitality-12.9, hospitality-12.10] + status: remaining + - id: delivery-production + label: Driver Production (Delivery 10.2–10.10) + phases: [delivery-10.2, delivery-10.3, delivery-10.4, delivery-10.5, delivery-10.6, delivery-10.7, delivery-10.8, delivery-10.9, delivery-10.10] + status: remaining + - id: medical-booking + label: Medical Booking + phases: [clinic-foundation, healthcare-booking] + status: remaining + - id: booking-platform + label: Booking + phases: [booking-foundation] + status: remaining + - id: short-links + label: Short Links + phases: [link-shortener-foundation] + status: remaining + - id: qr + label: QR + phases: [qr-foundation] + status: remaining + - id: digital-card + label: Digital Card + phases: [digital-card-foundation] + status: remaining + +# --- Execution Priority (every remaining phase in exactly one bucket) --- +# AI MUST execute critical → high → medium → low → future. +execution_priority: + critical: + - payment-14.6 + - payment-14.7 + - payment-14.8 + - payment-14.9 + - payment-14.10 + - hospitality-12.9 + - hospitality-12.10 + high: + - delivery-10.2 + - delivery-10.3 + - delivery-10.4 + - delivery-10.5 + - delivery-10.6 + - delivery-10.7 + - delivery-10.8 + - delivery-10.9 + - delivery-10.10 + - experience-fe-11.6 + - communication-frontend-complete + medium: + - loyalty-7.7 + - loyalty-7.8 + - loyalty-7.9 + - loyalty-7.10 + - sports-center-9.8 + - sports-center-9.9 + - sports-center-9.10 + - accounting-5.12 + low: + - identity-frontend-polish + - crm-frontend-polish + - healthcare-frontend-polish + - beauty-frontend-polish + future: + - ecommerce-foundation + - marketplace-foundation + - automation-foundation + - academy-foundation + - clinic-foundation + - hotel-foundation + - tourism-foundation + - ai-assistant-foundation + - sms-panel-future + - link-shortener-foundation + - qr-foundation + - digital-card-foundation + - booking-foundation + - healthcare-booking + - notification-foundation + - file-storage-foundation + - live-chat-foundation + - smart-messenger-foundation + - future-services + +services: + - name: Core Platform + service_identifier: core-platform + commercial_product: TorbatYar Platform + backend_status: complete + frontend_status: n/a + latest_backend_phase: onboarding-4 + latest_frontend_phase: null + roadmap_last_phase: onboarding-4 + architecture_complete: true + backend_complete: true + frontend_complete: true + production_ready: true + snapshot: docs/service-snapshots/core-platform.yaml + latest_handover: null + remaining_phases: [] + blockers: [] + depends_on: [] + required_by: + - identity-access + - accounting + - crm + - loyalty + - communication + - sports_center + - delivery + - experience + - hospitality + - healthcare + - beauty_business + - payment + - ecommerce + - marketplace + - automation + - academy + - clinic + - hotel + - tourism + - ai_assistant + - link_shortener + - file_storage + completed_phase: onboarding-4 + remaining_phase: null + backend_percent: 100 + frontend_percent: 100 + overall_percent: 100 + deployment_readiness: + backend: COMPLETE + frontend: COMPLETE + database: COMPLETE + api: COMPLETE + tests: COMPLETE + documentation: COMPLETE + production_ready: COMPLETE + + - name: Identity & Access + service_identifier: identity-access + commercial_product: Torbat Identity + backend_status: complete + frontend_status: partial + latest_backend_phase: identity-2 + latest_frontend_phase: onboarding-4 + roadmap_last_phase: identity-2 + architecture_complete: true + backend_complete: true + frontend_complete: false + production_ready: true + snapshot: docs/service-snapshots/identity-access.yaml + latest_handover: null + remaining_phases: + - identity-frontend-polish + blockers: [] + depends_on: + - core-platform + required_by: + - accounting + - crm + - loyalty + - communication + - sports_center + - delivery + - experience + - hospitality + - healthcare + - beauty_business + - payment + completed_phase: identity-2 + remaining_phase: identity-frontend-polish + backend_percent: 100 + frontend_percent: 70 + overall_percent: 85 + deployment_readiness: + backend: COMPLETE + frontend: IN_PROGRESS + database: COMPLETE + api: COMPLETE + tests: COMPLETE + documentation: COMPLETE + production_ready: COMPLETE + + - name: Accounting + service_identifier: accounting + commercial_product: Torbat Accounting + backend_status: in_progress + frontend_status: partial + latest_backend_phase: accounting-5.11 + latest_frontend_phase: null + roadmap_last_phase: accounting-5.12 + architecture_complete: true + backend_complete: false + frontend_complete: false + production_ready: false + snapshot: docs/service-snapshots/accounting.yaml + latest_handover: null + remaining_phases: + - accounting-5.12 + blockers: [] + depends_on: + - core-platform + - identity-access + required_by: [] + completed_phase: accounting-5.11 + remaining_phase: accounting-5.12 + backend_percent: 92 + frontend_percent: 40 + overall_percent: 66 + deployment_readiness: + backend: IN_PROGRESS + frontend: IN_PROGRESS + database: COMPLETE + api: COMPLETE + tests: COMPLETE + documentation: COMPLETE + production_ready: IN_PROGRESS + + - name: CRM + service_identifier: crm + commercial_product: Torbat CRM + backend_status: complete + frontend_status: partial + latest_backend_phase: crm-6.3 + latest_frontend_phase: null + roadmap_last_phase: crm-6.3 + architecture_complete: true + backend_complete: true + frontend_complete: false + production_ready: true + snapshot: docs/service-snapshots/crm.yaml + latest_handover: null + remaining_phases: + - crm-frontend-polish + blockers: [] + depends_on: + - core-platform + - identity-access + required_by: [] + completed_phase: crm-6.3 + remaining_phase: crm-frontend-polish + backend_percent: 100 + frontend_percent: 55 + overall_percent: 78 + deployment_readiness: + backend: COMPLETE + frontend: IN_PROGRESS + database: COMPLETE + api: COMPLETE + tests: COMPLETE + documentation: COMPLETE + production_ready: COMPLETE + + - name: Enterprise Loyalty Platform + service_identifier: loyalty + commercial_product: Torbat Loyalty + backend_status: in_progress + frontend_status: not_started + latest_backend_phase: loyalty-7.6 + latest_frontend_phase: null + roadmap_last_phase: loyalty-7.10 + architecture_complete: true + backend_complete: false + frontend_complete: false + production_ready: false + snapshot: docs/service-snapshots/loyalty.yaml + latest_handover: docs/phase-handover/phase-7-6.md + remaining_phases: + - loyalty-7.7 + - loyalty-7.8 + - loyalty-7.9 + - loyalty-7.10 + blockers: [] + depends_on: + - core-platform + - identity-access + required_by: [] + completed_phase: loyalty-7.6 + remaining_phase: loyalty-7.7 + backend_percent: 60 + frontend_percent: 0 + overall_percent: 30 + deployment_readiness: + backend: IN_PROGRESS + frontend: NOT_STARTED + database: COMPLETE + api: COMPLETE + tests: COMPLETE + documentation: COMPLETE + production_ready: IN_PROGRESS + + - name: Enterprise Communication Platform + service_identifier: communication + commercial_product: Torbat Communication + backend_status: complete + frontend_status: in_progress + latest_backend_phase: communication-8 + latest_frontend_phase: null + roadmap_last_phase: communication-8 + architecture_complete: true + backend_complete: true + frontend_complete: false + production_ready: true + snapshot: docs/service-snapshots/communication.yaml + latest_handover: docs/phase-handover/phase-8.md + remaining_phases: + - communication-frontend-complete + blockers: + - sms-panel-future deferred as separate product + depends_on: + - core-platform + - identity-access + required_by: + - sms_panel + - notification + - live_chat + - smart_messenger + completed_phase: communication-8 + remaining_phase: communication-frontend-complete + backend_percent: 100 + frontend_percent: 65 + overall_percent: 82 + deployment_readiness: + backend: COMPLETE + frontend: IN_PROGRESS + database: COMPLETE + api: COMPLETE + tests: COMPLETE + documentation: COMPLETE + production_ready: COMPLETE + + - name: Sports Center Platform + service_identifier: sports_center + commercial_product: Sports Center + backend_status: in_progress + frontend_status: not_started + latest_backend_phase: sports-center-9.7 + latest_frontend_phase: null + roadmap_last_phase: sports-center-9.10 + architecture_complete: true + backend_complete: false + frontend_complete: false + production_ready: false + snapshot: docs/service-snapshots/sports-center.yaml + latest_handover: docs/phase-handover/phase-9-7.md + remaining_phases: + - sports-center-9.8 + - sports-center-9.9 + - sports-center-9.10 + blockers: [] + depends_on: + - core-platform + - identity-access + required_by: [] + completed_phase: sports-center-9.7 + remaining_phase: sports-center-9.8 + backend_percent: 70 + frontend_percent: 0 + overall_percent: 35 + deployment_readiness: + backend: IN_PROGRESS + frontend: NOT_STARTED + database: COMPLETE + api: COMPLETE + tests: COMPLETE + documentation: COMPLETE + production_ready: IN_PROGRESS + + - name: Delivery & Fleet Platform + service_identifier: delivery + commercial_product: Torbat Driver + backend_status: in_progress + frontend_status: in_progress + latest_backend_phase: delivery-10.1 + latest_frontend_phase: null + roadmap_last_phase: delivery-10.10 + architecture_complete: true + backend_complete: false + frontend_complete: false + production_ready: false + snapshot: docs/service-snapshots/delivery.yaml + latest_handover: docs/phase-handover/phase-10-1.md + remaining_phases: + - delivery-10.2 + - delivery-10.3 + - delivery-10.4 + - delivery-10.5 + - delivery-10.6 + - delivery-10.7 + - delivery-10.8 + - delivery-10.9 + - delivery-10.10 + blockers: [] + depends_on: + - core-platform + - identity-access + required_by: [] + completed_phase: delivery-10.1 + remaining_phase: delivery-10.2 + backend_percent: 18 + frontend_percent: 25 + overall_percent: 22 + deployment_readiness: + backend: IN_PROGRESS + frontend: IN_PROGRESS + database: COMPLETE + api: COMPLETE + tests: IN_PROGRESS + documentation: COMPLETE + production_ready: IN_PROGRESS + + - name: Enterprise Experience Platform + service_identifier: experience + commercial_product: Torbat Pages + backend_status: complete + frontend_status: complete + latest_backend_phase: experience-11.10 + latest_frontend_phase: experience-fe-11.5 + roadmap_last_phase: experience-fe-11.5 + architecture_complete: true + backend_complete: true + frontend_complete: true + production_ready: false + snapshot: docs/service-snapshots/experience.yaml + frontend_snapshot: docs/service-snapshots/experience-frontend.yaml + latest_handover: docs/phase-handover/phase-experience-fe-11-5.md + remaining_phases: + - experience-fe-11.6 + blockers: + - FE-11.6+ Publish Target Registry / official publish_id not started (hard stop after FE-11.5) + depends_on: + - core-platform + - identity-access + required_by: + - link_shortener + completed_phase: experience-fe-11.5 + remaining_phase: experience-fe-11.6 + backend_percent: 100 + frontend_percent: 83 + overall_percent: 92 + deployment_readiness: + backend: COMPLETE + frontend: COMPLETE + database: COMPLETE + api: COMPLETE + tests: COMPLETE + documentation: COMPLETE + production_ready: IN_PROGRESS + notes: Backend 11.0–11.10 complete. Frontend FE-11.0–11.5 complete (stop). Architecture COMPLETE (ADR-022). production_ready IN_PROGRESS until publish registry. + + - name: Hospitality Platform + service_identifier: hospitality + commercial_product: Torbat Food + backend_status: in_progress + frontend_status: not_started + latest_backend_phase: hospitality-12.8 + latest_frontend_phase: null + roadmap_last_phase: hospitality-12.10 + architecture_complete: true + backend_complete: false + frontend_complete: false + production_ready: false + snapshot: docs/service-snapshots/hospitality.yaml + latest_handover: docs/phase-handover/phase-12-8.md + remaining_phases: + - hospitality-12.9 + - hospitality-12.10 + blockers: [] + depends_on: + - core-platform + - identity-access + required_by: + - hotel + completed_phase: hospitality-12.8 + remaining_phase: hospitality-12.9 + backend_percent: 80 + frontend_percent: 0 + overall_percent: 40 + deployment_readiness: + backend: IN_PROGRESS + frontend: NOT_STARTED + database: COMPLETE + api: COMPLETE + tests: COMPLETE + documentation: COMPLETE + production_ready: IN_PROGRESS + + - name: Healthcare + service_identifier: healthcare + commercial_product: Torbat Healthcare + backend_status: complete + frontend_status: partial + latest_backend_phase: healthcare-13-7 + latest_frontend_phase: null + roadmap_last_phase: healthcare-13-7 + architecture_complete: true + backend_complete: true + frontend_complete: false + production_ready: true + snapshot: docs/service-snapshots/healthcare.yaml + latest_handover: docs/phase-handover/phase-13-7.md + remaining_phases: + - healthcare-frontend-polish + - healthcare-booking + blockers: [] + depends_on: + - core-platform + - identity-access + required_by: + - clinic + completed_phase: healthcare-13-7 + remaining_phase: healthcare-frontend-polish + backend_percent: 100 + frontend_percent: 55 + overall_percent: 78 + deployment_readiness: + backend: COMPLETE + frontend: IN_PROGRESS + database: COMPLETE + api: COMPLETE + tests: COMPLETE + documentation: COMPLETE + production_ready: COMPLETE + + - name: Beauty Business + service_identifier: beauty_business + commercial_product: Torbat Beauty + backend_status: complete + frontend_status: partial + latest_backend_phase: beauty-business-14-7 + latest_frontend_phase: null + roadmap_last_phase: beauty-business-14-7 + architecture_complete: true + backend_complete: true + frontend_complete: false + production_ready: true + snapshot: docs/service-snapshots/beauty-business.yaml + latest_handover: docs/phase-handover/phase-14-7.md + remaining_phases: + - beauty-frontend-polish + blockers: [] + depends_on: + - core-platform + - identity-access + required_by: [] + completed_phase: beauty-business-14-7 + remaining_phase: beauty-frontend-polish + backend_percent: 100 + frontend_percent: 55 + overall_percent: 78 + deployment_readiness: + backend: COMPLETE + frontend: IN_PROGRESS + database: COMPLETE + api: COMPLETE + tests: COMPLETE + documentation: COMPLETE + production_ready: COMPLETE + + - name: Enterprise Payment Platform + service_identifier: payment + commercial_product: Torbat Pay + backend_status: in_progress + frontend_status: complete + latest_backend_phase: payment-14.5 + latest_frontend_phase: payment-frontend + roadmap_last_phase: payment-14.10 + architecture_complete: true + backend_complete: false + frontend_complete: true + production_ready: false + snapshot: docs/service-snapshots/payment.yaml + latest_handover: docs/phase-handover/phase-payment-frontend.md + remaining_phases: + - payment-14.6 + - payment-14.7 + - payment-14.8 + - payment-14.9 + - payment-14.10 + blockers: [] + depends_on: + - core-platform + - identity-access + required_by: + - ecommerce + - marketplace + completed_phase: payment-frontend + remaining_phase: payment-14.6 + backend_percent: 50 + frontend_percent: 100 + overall_percent: 50 + deployment_readiness: + backend: IN_PROGRESS + frontend: COMPLETE + database: COMPLETE + api: COMPLETE + tests: COMPLETE + documentation: COMPLETE + production_ready: IN_PROGRESS + + - name: Ecommerce + service_identifier: ecommerce + commercial_product: Torbat Commerce + backend_status: planned + frontend_status: not_started + latest_backend_phase: ecommerce-foundation + latest_frontend_phase: null + roadmap_last_phase: ecommerce-foundation + architecture_complete: false + backend_complete: false + frontend_complete: false + production_ready: false + snapshot: docs/service-snapshots/ecommerce.yaml + latest_handover: null + remaining_phases: + - ecommerce-foundation + blockers: + - foundation not started + depends_on: + - core-platform + - payment + required_by: [] + completed_phase: null + remaining_phase: ecommerce-foundation + backend_percent: 0 + frontend_percent: 0 + overall_percent: 0 + deployment_readiness: + backend: NOT_STARTED + frontend: NOT_STARTED + database: NOT_STARTED + api: NOT_STARTED + tests: NOT_STARTED + documentation: NOT_STARTED + production_ready: NOT_STARTED + + - name: Marketplace + service_identifier: marketplace + commercial_product: Torbat Marketplace + backend_status: planned + frontend_status: not_started + latest_backend_phase: marketplace-foundation + latest_frontend_phase: null + roadmap_last_phase: marketplace-foundation + architecture_complete: false + backend_complete: false + frontend_complete: false + production_ready: false + snapshot: docs/service-snapshots/marketplace.yaml + latest_handover: null + remaining_phases: + - marketplace-foundation + blockers: + - foundation not started + depends_on: + - core-platform + - payment + required_by: [] + completed_phase: null + remaining_phase: marketplace-foundation + backend_percent: 0 + frontend_percent: 0 + overall_percent: 0 + deployment_readiness: + backend: NOT_STARTED + frontend: NOT_STARTED + database: NOT_STARTED + api: NOT_STARTED + tests: NOT_STARTED + documentation: NOT_STARTED + production_ready: NOT_STARTED + + - name: Automation + service_identifier: automation + commercial_product: Torbat Automation + backend_status: planned + frontend_status: not_started + latest_backend_phase: automation-foundation + latest_frontend_phase: null + roadmap_last_phase: automation-foundation + architecture_complete: false + backend_complete: false + frontend_complete: false + production_ready: false + snapshot: docs/service-snapshots/automation.yaml + latest_handover: null + remaining_phases: + - automation-foundation + blockers: + - foundation not started + depends_on: + - core-platform + required_by: [] + completed_phase: null + remaining_phase: automation-foundation + backend_percent: 0 + frontend_percent: 0 + overall_percent: 0 + deployment_readiness: + backend: NOT_STARTED + frontend: NOT_STARTED + database: NOT_STARTED + api: NOT_STARTED + tests: NOT_STARTED + documentation: NOT_STARTED + production_ready: NOT_STARTED + + - name: Academy + service_identifier: academy + commercial_product: Torbat Academy + backend_status: planned + frontend_status: not_started + latest_backend_phase: academy-foundation + latest_frontend_phase: null + roadmap_last_phase: academy-foundation + architecture_complete: false + backend_complete: false + frontend_complete: false + production_ready: false + snapshot: docs/service-snapshots/academy.yaml + latest_handover: null + remaining_phases: + - academy-foundation + blockers: + - foundation not started + depends_on: + - core-platform + required_by: [] + completed_phase: null + remaining_phase: academy-foundation + backend_percent: 0 + frontend_percent: 0 + overall_percent: 0 + deployment_readiness: + backend: NOT_STARTED + frontend: NOT_STARTED + database: NOT_STARTED + api: NOT_STARTED + tests: NOT_STARTED + documentation: NOT_STARTED + production_ready: NOT_STARTED + + - name: Clinic + service_identifier: clinic + commercial_product: Torbat Clinic + backend_status: planned + frontend_status: not_started + latest_backend_phase: clinic-foundation + latest_frontend_phase: null + roadmap_last_phase: clinic-foundation + architecture_complete: false + backend_complete: false + frontend_complete: false + production_ready: false + snapshot: docs/service-snapshots/clinic.yaml + latest_handover: null + remaining_phases: + - clinic-foundation + blockers: + - foundation not started + depends_on: + - core-platform + - healthcare + required_by: [] + completed_phase: null + remaining_phase: clinic-foundation + backend_percent: 0 + frontend_percent: 0 + overall_percent: 0 + deployment_readiness: + backend: NOT_STARTED + frontend: NOT_STARTED + database: NOT_STARTED + api: NOT_STARTED + tests: NOT_STARTED + documentation: NOT_STARTED + production_ready: NOT_STARTED + + - name: Hotel + service_identifier: hotel + commercial_product: Torbat Hotel + backend_status: planned + frontend_status: not_started + latest_backend_phase: hotel-foundation + latest_frontend_phase: null + roadmap_last_phase: hotel-foundation + architecture_complete: false + backend_complete: false + frontend_complete: false + production_ready: false + snapshot: docs/service-snapshots/hotel.yaml + latest_handover: null + remaining_phases: + - hotel-foundation + blockers: + - foundation not started + depends_on: + - core-platform + - hospitality + required_by: [] + completed_phase: null + remaining_phase: hotel-foundation + backend_percent: 0 + frontend_percent: 0 + overall_percent: 0 + deployment_readiness: + backend: NOT_STARTED + frontend: NOT_STARTED + database: NOT_STARTED + api: NOT_STARTED + tests: NOT_STARTED + documentation: NOT_STARTED + production_ready: NOT_STARTED + + - name: Tourism + service_identifier: tourism + commercial_product: Torbat Tourism + backend_status: planned + frontend_status: not_started + latest_backend_phase: tourism-foundation + latest_frontend_phase: null + roadmap_last_phase: tourism-foundation + architecture_complete: false + backend_complete: false + frontend_complete: false + production_ready: false + snapshot: docs/service-snapshots/tourism.yaml + latest_handover: null + remaining_phases: + - tourism-foundation + blockers: + - foundation not started + depends_on: + - core-platform + required_by: [] + completed_phase: null + remaining_phase: tourism-foundation + backend_percent: 0 + frontend_percent: 0 + overall_percent: 0 + deployment_readiness: + backend: NOT_STARTED + frontend: NOT_STARTED + database: NOT_STARTED + api: NOT_STARTED + tests: NOT_STARTED + documentation: NOT_STARTED + production_ready: NOT_STARTED + + - name: AI Assistant + service_identifier: ai_assistant + commercial_product: Torbat AI + backend_status: scaffolded + frontend_status: not_started + latest_backend_phase: ai-assistant-foundation + latest_frontend_phase: null + roadmap_last_phase: ai-assistant-foundation + architecture_complete: false + backend_complete: false + frontend_complete: false + production_ready: false + snapshot: docs/service-snapshots/ai-assistant.yaml + latest_handover: null + remaining_phases: + - ai-assistant-foundation + blockers: + - foundation scaffold only + depends_on: + - core-platform + required_by: [] + completed_phase: null + remaining_phase: ai-assistant-foundation + backend_percent: 5 + frontend_percent: 0 + overall_percent: 2 + deployment_readiness: + backend: NOT_STARTED + frontend: NOT_STARTED + database: NOT_STARTED + api: NOT_STARTED + tests: NOT_STARTED + documentation: NOT_STARTED + production_ready: NOT_STARTED + + - name: SMS Panel + service_identifier: sms_panel + commercial_product: Torbat SMS + backend_status: deferred + frontend_status: not_started + latest_backend_phase: sms-panel-future + latest_frontend_phase: null + roadmap_last_phase: sms-panel-future + architecture_complete: false + backend_complete: false + frontend_complete: false + production_ready: false + snapshot: docs/service-snapshots/sms-panel.yaml + latest_handover: null + remaining_phases: + - sms-panel-future + blockers: + - deferred — SMS MVP lives under communication + depends_on: + - communication + required_by: [] + completed_phase: null + remaining_phase: sms-panel-future + backend_percent: 0 + frontend_percent: 0 + overall_percent: 0 + deployment_readiness: + backend: NOT_STARTED + frontend: NOT_STARTED + database: NOT_STARTED + api: NOT_STARTED + tests: NOT_STARTED + documentation: NOT_STARTED + production_ready: NOT_STARTED + + - name: Link Shortener + service_identifier: link_shortener + commercial_product: Torbat Links + backend_status: scaffolded + frontend_status: not_started + latest_backend_phase: future-services + latest_frontend_phase: null + roadmap_last_phase: future-services + architecture_complete: false + backend_complete: false + frontend_complete: false + production_ready: false + snapshot: docs/service-snapshots/link-shortener.yaml + latest_handover: null + remaining_phases: + - link-shortener-foundation + blockers: + - future product + depends_on: + - core-platform + - experience + required_by: [] + completed_phase: null + remaining_phase: link-shortener-foundation + backend_percent: 5 + frontend_percent: 0 + overall_percent: 2 + deployment_readiness: + backend: NOT_STARTED + frontend: NOT_STARTED + database: NOT_STARTED + api: NOT_STARTED + tests: NOT_STARTED + documentation: NOT_STARTED + production_ready: NOT_STARTED + + - name: Notification + service_identifier: notification + commercial_product: Torbat Notification + backend_status: scaffolded + frontend_status: not_started + latest_backend_phase: future-services + latest_frontend_phase: null + roadmap_last_phase: future-services + architecture_complete: false + backend_complete: false + frontend_complete: false + production_ready: false + snapshot: docs/service-snapshots/notification.yaml + latest_handover: null + remaining_phases: + - notification-foundation + blockers: + - future product + depends_on: + - communication + required_by: [] + completed_phase: null + remaining_phase: notification-foundation + backend_percent: 5 + frontend_percent: 0 + overall_percent: 2 + deployment_readiness: + backend: NOT_STARTED + frontend: NOT_STARTED + database: NOT_STARTED + api: NOT_STARTED + tests: NOT_STARTED + documentation: NOT_STARTED + production_ready: NOT_STARTED + + - name: File Storage + service_identifier: file_storage + commercial_product: Torbat Storage + backend_status: scaffolded + frontend_status: n/a + latest_backend_phase: future-services + latest_frontend_phase: null + roadmap_last_phase: future-services + architecture_complete: false + backend_complete: false + frontend_complete: true + production_ready: false + snapshot: docs/service-snapshots/file-storage.yaml + latest_handover: null + remaining_phases: + - file-storage-foundation + blockers: + - future platform service + depends_on: + - core-platform + required_by: [] + completed_phase: null + remaining_phase: file-storage-foundation + backend_percent: 5 + frontend_percent: 100 + overall_percent: 5 + deployment_readiness: + backend: NOT_STARTED + frontend: COMPLETE + database: NOT_STARTED + api: NOT_STARTED + tests: NOT_STARTED + documentation: NOT_STARTED + production_ready: NOT_STARTED + + - name: Live Chat + service_identifier: live_chat + commercial_product: Torbat Live Chat + backend_status: scaffolded + frontend_status: not_started + latest_backend_phase: future-services + latest_frontend_phase: null + roadmap_last_phase: future-services + architecture_complete: false + backend_complete: false + frontend_complete: false + production_ready: false + snapshot: docs/service-snapshots/live-chat.yaml + latest_handover: null + remaining_phases: + - live-chat-foundation + blockers: + - future product + depends_on: + - communication + required_by: [] + completed_phase: null + remaining_phase: live-chat-foundation + backend_percent: 5 + frontend_percent: 0 + overall_percent: 2 + deployment_readiness: + backend: NOT_STARTED + frontend: NOT_STARTED + database: NOT_STARTED + api: NOT_STARTED + tests: NOT_STARTED + documentation: NOT_STARTED + production_ready: NOT_STARTED + + - name: Smart Messenger + service_identifier: smart_messenger + commercial_product: Torbat Messenger + backend_status: scaffolded + frontend_status: not_started + latest_backend_phase: future-services + latest_frontend_phase: null + roadmap_last_phase: future-services + architecture_complete: false + backend_complete: false + frontend_complete: false + production_ready: false + snapshot: docs/service-snapshots/smart-messenger.yaml + latest_handover: null + remaining_phases: + - smart-messenger-foundation + blockers: + - future product + depends_on: + - communication + required_by: [] + completed_phase: null + remaining_phase: smart-messenger-foundation + backend_percent: 5 + frontend_percent: 0 + overall_percent: 2 + deployment_readiness: + backend: NOT_STARTED + frontend: NOT_STARTED + database: NOT_STARTED + api: NOT_STARTED + tests: NOT_STARTED + documentation: NOT_STARTED + production_ready: NOT_STARTED + +global_statistics: + number_of_services: 27 + completed_services: 6 + services_in_progress: 7 + services_planned_or_scaffolded: 14 + backend_completion_percent: 48 + frontend_completion_percent: 24 + overall_completion_percent: 36 + critical_path_remaining: 9 + notes: > + Percentages from per-service backend_percent/frontend_percent/overall_percent. + completed_services = deployment_readiness.production_ready COMPLETE AND no critical remaining phases. + Recalculate after every phase Complete via automatic_update_rules. + +deprecated: + next_recommended_phase: + status: deprecated + replaced_by: + - execution_priority + - critical_path + - deployment_readiness + former_value: payment-14.6 + recommended_execution_queue: + status: deprecated + replaced_by: execution_priority + dependencies_field: + status: deprecated + replaced_by: + - depends_on + - required_by + +framework: + last_framework_phase: platform-project-status-v2 + latest_handover: docs/phase-handover/phase-af-project-status-v2.md + project_index: docs/ai-framework/project-index.yaml + phase_manifest: docs/ai-framework/phase-manifest.yaml + service_manifest: docs/ai-framework/service-manifest.yaml diff --git a/docs/service-snapshots/payment-frontend.yaml b/docs/service-snapshots/payment-frontend.yaml new file mode 100644 index 0000000..014742c --- /dev/null +++ b/docs/service-snapshots/payment-frontend.yaml @@ -0,0 +1,78 @@ +# docs/service-snapshots/payment-frontend.yaml + +schema_version: 1 +snapshot_version: 1 + +service_name: payment +layer: frontend +commercial_product: Torbat Pay +backend_version: "0.14.5.0" +current_phase: payment-frontend +last_completed_phase: payment-frontend +next_phase: payment-14.6 +stop_boundary: MVP 14.0–14.5 (no refunds/splits UI) + +completed_phases: + - payment-frontend + +module_path: frontend/modules/payment +bff_path: frontend/app/api/payment/[...path]/route.ts +app_routes_prefix: /payment +route_count: 26 + +env_vars: + - NEXT_PUBLIC_PAYMENT_API_URL + - PAYMENT_SERVICE_URL + +apps_catalog: + id: payment + href: /payment/hub + status: available + +features: + - hub + - dashboard + - overview + - kpis + - transactions + - transactionDetail + - ledger + - psp + - gatewayHealth + - providers + - merchants + - merchantDetail + - merchantApprovals + - requests + - requestDetail + - callbacks + - verification + - audit + - bundles + - featureToggles + - capabilities + - settings + - metrics + - monitoring + - apiExplorer + - openapi + +api_client: frontend/modules/payment/services/payment-api.ts +real_backend_only: true +mock_data: false + +documentation: + - docs/payment-roadmap.md + - frontend/docs/payment-frontend-architecture.md + - frontend/docs/payment-frontend-validation-report.md + - docs/phase-handover/phase-payment-frontend.md + - docs/service-snapshots/payment.yaml + +open_todos: + - payment-14.6+ Refunds & Reversals UI (explicitly out of scope) + - payment-14.7+ Splits UI (explicitly out of scope) + - Dedicated GET /callback-logs endpoint when backend exposes list API + - Fine-grained permission gating per screen from identity membership + +last_handover_reference: docs/phase-handover/phase-payment-frontend.md +last_updated: "2026-07-27" diff --git a/frontend/app/api/payment/[...path]/route.ts b/frontend/app/api/payment/[...path]/route.ts new file mode 100644 index 0000000..d8b5fdd --- /dev/null +++ b/frontend/app/api/payment/[...path]/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; + +const UPSTREAM = + process.env.PAYMENT_SERVICE_URL || + process.env.NEXT_PUBLIC_PAYMENT_API_URL || + "http://localhost:8012"; + +async function proxy(req: NextRequest, pathSegments: string[]) { + const p = pathSegments.join("/"); + const url = new URL(req.url); + const target = `${UPSTREAM.replace(/\/$/, "")}/${p}${url.search}`; + const headers = new Headers(); + for (const h of [ + "authorization", + "content-type", + "x-tenant-id", + "accept", + "idempotency-key", + ]) { + const v = req.headers.get(h); + if (v) headers.set(h, v); + } + let body: ArrayBuffer | undefined; + if (req.method !== "GET" && req.method !== "HEAD") body = await req.arrayBuffer(); + try { + const res = await fetch(target, { method: req.method, headers, body, cache: "no-store" }); + const out = new Headers(); + const ct = res.headers.get("content-type"); + if (ct) out.set("content-type", ct); + return new NextResponse(await res.arrayBuffer(), { status: res.status, headers: out }); + } catch (err) { + const detail = err instanceof Error ? err.message : "upstream_unreachable"; + return NextResponse.json( + { error: { code: "payment_proxy_error", message: `پروکسی تربت‌پی — ${detail}` } }, + { status: 502 } + ); + } +} + +export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { + const { path: p } = await ctx.params; + return proxy(req, p); +} +export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { + const { path: p } = await ctx.params; + return proxy(req, p); +} +export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { + const { path: p } = await ctx.params; + return proxy(req, p); +} +export async function PUT(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { + const { path: p } = await ctx.params; + return proxy(req, p); +} +export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { + const { path: p } = await ctx.params; + return proxy(req, p); +} diff --git a/frontend/app/payment/api-explorer/error.tsx b/frontend/app/payment/api-explorer/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/api-explorer/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/api-explorer/loading.tsx b/frontend/app/payment/api-explorer/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/api-explorer/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/api-explorer/page.tsx b/frontend/app/payment/api-explorer/page.tsx new file mode 100644 index 0000000..6618025 --- /dev/null +++ b/frontend/app/payment/api-explorer/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentApiExplorer as default } from "@/modules/payment/features/apiExplorer"; diff --git a/frontend/app/payment/audit/error.tsx b/frontend/app/payment/audit/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/audit/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/audit/loading.tsx b/frontend/app/payment/audit/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/audit/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/audit/page.tsx b/frontend/app/payment/audit/page.tsx new file mode 100644 index 0000000..128bab6 --- /dev/null +++ b/frontend/app/payment/audit/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentAudit as default } from "@/modules/payment/features/audit"; diff --git a/frontend/app/payment/bundles/error.tsx b/frontend/app/payment/bundles/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/bundles/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/bundles/loading.tsx b/frontend/app/payment/bundles/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/bundles/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/bundles/page.tsx b/frontend/app/payment/bundles/page.tsx new file mode 100644 index 0000000..5ae72f7 --- /dev/null +++ b/frontend/app/payment/bundles/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentBundles as default } from "@/modules/payment/features/bundles"; diff --git a/frontend/app/payment/callbacks/error.tsx b/frontend/app/payment/callbacks/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/callbacks/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/callbacks/loading.tsx b/frontend/app/payment/callbacks/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/callbacks/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/callbacks/page.tsx b/frontend/app/payment/callbacks/page.tsx new file mode 100644 index 0000000..ae25cd4 --- /dev/null +++ b/frontend/app/payment/callbacks/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentCallbacks as default } from "@/modules/payment/features/callbacks"; diff --git a/frontend/app/payment/capabilities/error.tsx b/frontend/app/payment/capabilities/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/capabilities/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/capabilities/loading.tsx b/frontend/app/payment/capabilities/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/capabilities/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/capabilities/page.tsx b/frontend/app/payment/capabilities/page.tsx new file mode 100644 index 0000000..1dd5336 --- /dev/null +++ b/frontend/app/payment/capabilities/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentCapabilities as default } from "@/modules/payment/features/capabilities"; diff --git a/frontend/app/payment/dashboard/error.tsx b/frontend/app/payment/dashboard/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/dashboard/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/dashboard/loading.tsx b/frontend/app/payment/dashboard/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/dashboard/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/dashboard/page.tsx b/frontend/app/payment/dashboard/page.tsx new file mode 100644 index 0000000..f75497c --- /dev/null +++ b/frontend/app/payment/dashboard/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentDashboard as default } from "@/modules/payment/features/dashboard"; diff --git a/frontend/app/payment/error.tsx b/frontend/app/payment/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/feature-toggles/error.tsx b/frontend/app/payment/feature-toggles/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/feature-toggles/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/feature-toggles/loading.tsx b/frontend/app/payment/feature-toggles/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/feature-toggles/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/feature-toggles/page.tsx b/frontend/app/payment/feature-toggles/page.tsx new file mode 100644 index 0000000..a4151ad --- /dev/null +++ b/frontend/app/payment/feature-toggles/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentFeatureToggles as default } from "@/modules/payment/features/featureToggles"; diff --git a/frontend/app/payment/hub/error.tsx b/frontend/app/payment/hub/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/hub/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/hub/loading.tsx b/frontend/app/payment/hub/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/hub/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/hub/page.tsx b/frontend/app/payment/hub/page.tsx new file mode 100644 index 0000000..4f753f8 --- /dev/null +++ b/frontend/app/payment/hub/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentHub as default } from "@/modules/payment/features/hub"; diff --git a/frontend/app/payment/kpis/error.tsx b/frontend/app/payment/kpis/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/kpis/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/kpis/loading.tsx b/frontend/app/payment/kpis/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/kpis/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/kpis/page.tsx b/frontend/app/payment/kpis/page.tsx new file mode 100644 index 0000000..822ffa2 --- /dev/null +++ b/frontend/app/payment/kpis/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentKpis as default } from "@/modules/payment/features/kpis"; diff --git a/frontend/app/payment/layout.tsx b/frontend/app/payment/layout.tsx new file mode 100644 index 0000000..b0808ae --- /dev/null +++ b/frontend/app/payment/layout.tsx @@ -0,0 +1,5 @@ +"use client"; + +import { PaymentRootLayout } from "@/modules/payment/components/createPortalLayout"; + +export default PaymentRootLayout; diff --git a/frontend/app/payment/ledger/error.tsx b/frontend/app/payment/ledger/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/ledger/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/ledger/loading.tsx b/frontend/app/payment/ledger/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/ledger/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/ledger/page.tsx b/frontend/app/payment/ledger/page.tsx new file mode 100644 index 0000000..1220e84 --- /dev/null +++ b/frontend/app/payment/ledger/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentLedger as default } from "@/modules/payment/features/ledger"; diff --git a/frontend/app/payment/loading.tsx b/frontend/app/payment/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/merchants/[id]/error.tsx b/frontend/app/payment/merchants/[id]/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/merchants/[id]/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/merchants/[id]/loading.tsx b/frontend/app/payment/merchants/[id]/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/merchants/[id]/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/merchants/[id]/page.tsx b/frontend/app/payment/merchants/[id]/page.tsx new file mode 100644 index 0000000..c7bc998 --- /dev/null +++ b/frontend/app/payment/merchants/[id]/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentMerchantDetail as default } from "@/modules/payment/features/merchantDetail"; diff --git a/frontend/app/payment/merchants/approvals/error.tsx b/frontend/app/payment/merchants/approvals/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/merchants/approvals/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/merchants/approvals/loading.tsx b/frontend/app/payment/merchants/approvals/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/merchants/approvals/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/merchants/approvals/page.tsx b/frontend/app/payment/merchants/approvals/page.tsx new file mode 100644 index 0000000..2d12a98 --- /dev/null +++ b/frontend/app/payment/merchants/approvals/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentMerchantApprovals as default } from "@/modules/payment/features/merchantApprovals"; diff --git a/frontend/app/payment/merchants/error.tsx b/frontend/app/payment/merchants/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/merchants/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/merchants/loading.tsx b/frontend/app/payment/merchants/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/merchants/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/merchants/page.tsx b/frontend/app/payment/merchants/page.tsx new file mode 100644 index 0000000..621cd35 --- /dev/null +++ b/frontend/app/payment/merchants/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentMerchants as default } from "@/modules/payment/features/merchants"; diff --git a/frontend/app/payment/metrics/error.tsx b/frontend/app/payment/metrics/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/metrics/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/metrics/loading.tsx b/frontend/app/payment/metrics/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/metrics/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/metrics/page.tsx b/frontend/app/payment/metrics/page.tsx new file mode 100644 index 0000000..7fada1b --- /dev/null +++ b/frontend/app/payment/metrics/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentMetrics as default } from "@/modules/payment/features/metrics"; diff --git a/frontend/app/payment/monitoring/error.tsx b/frontend/app/payment/monitoring/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/monitoring/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/monitoring/loading.tsx b/frontend/app/payment/monitoring/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/monitoring/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/monitoring/page.tsx b/frontend/app/payment/monitoring/page.tsx new file mode 100644 index 0000000..8266c8c --- /dev/null +++ b/frontend/app/payment/monitoring/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentMonitoring as default } from "@/modules/payment/features/monitoring"; diff --git a/frontend/app/payment/openapi/error.tsx b/frontend/app/payment/openapi/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/openapi/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/openapi/loading.tsx b/frontend/app/payment/openapi/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/openapi/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/openapi/page.tsx b/frontend/app/payment/openapi/page.tsx new file mode 100644 index 0000000..97190b2 --- /dev/null +++ b/frontend/app/payment/openapi/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentOpenApi as default } from "@/modules/payment/features/openapi"; diff --git a/frontend/app/payment/overview/error.tsx b/frontend/app/payment/overview/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/overview/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/overview/loading.tsx b/frontend/app/payment/overview/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/overview/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/overview/page.tsx b/frontend/app/payment/overview/page.tsx new file mode 100644 index 0000000..cb32466 --- /dev/null +++ b/frontend/app/payment/overview/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentOverview as default } from "@/modules/payment/features/overview"; diff --git a/frontend/app/payment/page.tsx b/frontend/app/payment/page.tsx new file mode 100644 index 0000000..016753a --- /dev/null +++ b/frontend/app/payment/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function PaymentIndexPage() { + redirect("/payment/hub"); +} diff --git a/frontend/app/payment/providers/error.tsx b/frontend/app/payment/providers/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/providers/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/providers/loading.tsx b/frontend/app/payment/providers/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/providers/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/providers/page.tsx b/frontend/app/payment/providers/page.tsx new file mode 100644 index 0000000..beba1f9 --- /dev/null +++ b/frontend/app/payment/providers/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentProviders as default } from "@/modules/payment/features/providers"; diff --git a/frontend/app/payment/psp/error.tsx b/frontend/app/payment/psp/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/psp/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/psp/health/error.tsx b/frontend/app/payment/psp/health/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/psp/health/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/psp/health/loading.tsx b/frontend/app/payment/psp/health/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/psp/health/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/psp/health/page.tsx b/frontend/app/payment/psp/health/page.tsx new file mode 100644 index 0000000..47e9268 --- /dev/null +++ b/frontend/app/payment/psp/health/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentGatewayHealth as default } from "@/modules/payment/features/gatewayHealth"; diff --git a/frontend/app/payment/psp/loading.tsx b/frontend/app/payment/psp/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/psp/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/psp/page.tsx b/frontend/app/payment/psp/page.tsx new file mode 100644 index 0000000..e5d51e1 --- /dev/null +++ b/frontend/app/payment/psp/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentPsp as default } from "@/modules/payment/features/psp"; diff --git a/frontend/app/payment/requests/[id]/error.tsx b/frontend/app/payment/requests/[id]/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/requests/[id]/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/requests/[id]/loading.tsx b/frontend/app/payment/requests/[id]/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/requests/[id]/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/requests/[id]/page.tsx b/frontend/app/payment/requests/[id]/page.tsx new file mode 100644 index 0000000..c0491cd --- /dev/null +++ b/frontend/app/payment/requests/[id]/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentRequestDetail as default } from "@/modules/payment/features/requestDetail"; diff --git a/frontend/app/payment/requests/error.tsx b/frontend/app/payment/requests/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/requests/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/requests/loading.tsx b/frontend/app/payment/requests/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/requests/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/requests/page.tsx b/frontend/app/payment/requests/page.tsx new file mode 100644 index 0000000..fcdfac5 --- /dev/null +++ b/frontend/app/payment/requests/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentRequests as default } from "@/modules/payment/features/requests"; diff --git a/frontend/app/payment/settings/error.tsx b/frontend/app/payment/settings/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/settings/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/settings/loading.tsx b/frontend/app/payment/settings/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/settings/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/settings/page.tsx b/frontend/app/payment/settings/page.tsx new file mode 100644 index 0000000..bb514e6 --- /dev/null +++ b/frontend/app/payment/settings/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentSettings as default } from "@/modules/payment/features/settings"; diff --git a/frontend/app/payment/transactions/[id]/error.tsx b/frontend/app/payment/transactions/[id]/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/transactions/[id]/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/transactions/[id]/loading.tsx b/frontend/app/payment/transactions/[id]/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/transactions/[id]/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/transactions/[id]/page.tsx b/frontend/app/payment/transactions/[id]/page.tsx new file mode 100644 index 0000000..7804177 --- /dev/null +++ b/frontend/app/payment/transactions/[id]/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentTransactionDetail as default } from "@/modules/payment/features/transactionDetail"; diff --git a/frontend/app/payment/transactions/error.tsx b/frontend/app/payment/transactions/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/transactions/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/transactions/loading.tsx b/frontend/app/payment/transactions/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/transactions/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/transactions/page.tsx b/frontend/app/payment/transactions/page.tsx new file mode 100644 index 0000000..e5be92c --- /dev/null +++ b/frontend/app/payment/transactions/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentTransactions as default } from "@/modules/payment/features/transactions"; diff --git a/frontend/app/payment/verification/error.tsx b/frontend/app/payment/verification/error.tsx new file mode 100644 index 0000000..8f69ee5 --- /dev/null +++ b/frontend/app/payment/verification/error.tsx @@ -0,0 +1,5 @@ +"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} diff --git a/frontend/app/payment/verification/loading.tsx b/frontend/app/payment/verification/loading.tsx new file mode 100644 index 0000000..52a490b --- /dev/null +++ b/frontend/app/payment/verification/loading.tsx @@ -0,0 +1,4 @@ +import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} diff --git a/frontend/app/payment/verification/page.tsx b/frontend/app/payment/verification/page.tsx new file mode 100644 index 0000000..64d483b --- /dev/null +++ b/frontend/app/payment/verification/page.tsx @@ -0,0 +1,2 @@ +"use client"; +export { PaymentVerification as default } from "@/modules/payment/features/verification"; diff --git a/frontend/docs/payment-frontend-architecture.md b/frontend/docs/payment-frontend-architecture.md new file mode 100644 index 0000000..01f8221 --- /dev/null +++ b/frontend/docs/payment-frontend-architecture.md @@ -0,0 +1,44 @@ +# Payment Frontend Architecture + +Torbat Pay (`frontend/modules/payment/`) mirrors the Experience/Delivery module layout. + +## Layers + +``` +Browser → /api/payment BFF → payment-service:8012 +``` + +- **BFF:** `frontend/app/api/payment/[...path]/route.ts` — forwards `Authorization`, `X-Tenant-ID`, `Idempotency-Key` +- **API client:** `services/payment-api.ts` — real endpoints only (MVP 14.0–14.5) +- **Checkout session:** `PaymentRequest` detail at `/payment/requests/[id]` + +## Module structure + +| Path | Role | +| --- | --- | +| `components/` | PaymentPortalShell, PaymentListPage, PaymentCharts, createPortalLayout | +| `constants/` | portals.ts (nav + MVP routes), permissions.ts | +| `features/` | Screen implementations (26 files) | +| `hooks/` | usePaymentCapabilities, usePaymentPermissions | +| `pages/shared.tsx` | Loaders, errors, CSV export | +| `types/` | API response types | + +## Auth & tenant + +- `AuthGuard` + `useMe` tenant gate (same as other portals) +- `X-Tenant-ID` on all authenticated API calls +- Bundle-gated nav via `filterNavByBundles(active_bundles)` + +## Charts + +Dashboard and KPIs use `PaymentCharts` (recharts): bar, donut, stat cards. + +## Validation + +```bash +npm run validate:payment +``` + +## Out of scope (14.6+) + +Refunds, splits, subscriptions, wallet top-up UI — backend phases not yet implemented. diff --git a/frontend/docs/payment-frontend-validation-report.md b/frontend/docs/payment-frontend-validation-report.md new file mode 100644 index 0000000..f8c2ecf --- /dev/null +++ b/frontend/docs/payment-frontend-validation-report.md @@ -0,0 +1,55 @@ +# Payment Frontend Validation Report + +**Date:** 2026-07-27 +**Phase:** payment-frontend +**Backend boundary:** MVP 14.0–14.5 (v0.14.5.0) + +## Route validation + +```bash +npm run validate:payment-routes +# Payment routes OK (23 MVP routes, 26 features) +``` + +## Module inventory + +| Category | Count | +| --- | --- | +| Feature screens | 26 | +| MVP list routes | 23 | +| Detail routes | 3 (`requests/[id]`, `transactions/[id]`, `merchants/[id]`) | +| Core components (pre-existing) | 5 | +| Hooks | 2 | +| BFF proxy | 1 | + +## API wiring + +All list/detail screens call `paymentApi` methods against real `/api/v1/*` paths. No mock fixtures. + +| Screen | API | +| --- | --- | +| Dashboard / KPIs | `requests.list`, `transactions.list`, `audit.list`, `metrics` | +| Transactions | `transactions.list/get` | +| Ledger | `ledger.list` | +| PSP | `pspConnections.list/test` | +| Merchants | `merchants.*` lifecycle | +| Requests | `requests.list/get/cancel/verify` | +| Callbacks | `audit.list` + `requests` filtered client-side | +| Verification | `requests.verify` queue | +| Bundles / toggles / settings | CRUD list endpoints | +| OpenAPI | `GET /openapi.json` | + +## Known gaps + +1. **Callback log list** — backend exposes POST callback ingress only; UI derives from audit + `redirect_issued` requests. +2. **14.6+ UI** — refunds, splits, reconciliation not implemented (by design). +3. **Create flows** — list pages are read-focused; POST create forms for workspace/PSP/merchant can be added incrementally. +4. **Permission UI gates** — `usePaymentPermissions` available; per-screen enforcement is soft (403 from API). + +## Apps catalog + +Torbat Pay registered as `available` → `/payment/hub`. + +## Docker env + +`NEXT_PUBLIC_PAYMENT_API_URL` + `PAYMENT_SERVICE_URL` added to frontend service in docker-compose. diff --git a/frontend/lib/apps-catalog.ts b/frontend/lib/apps-catalog.ts index 0a1fda2..5568ea8 100644 --- a/frontend/lib/apps-catalog.ts +++ b/frontend/lib/apps-catalog.ts @@ -103,12 +103,22 @@ export const PLATFORM_APPS: PlatformApp[] = [ status: "coming_soon", }, { - id: "website_builder", - name: "سایت‌ساز", - description: "ساخت صفحات و لندینگ", + id: "experience", + name: "تربت پیجز", + description: "سایت، صفحه، قالب، فرم و انتشار", icon: "🌐", gradient: "from-cyan-500 to-sky-600", - status: "coming_soon", + status: "available", + href: "/experience/hub", + }, + { + id: "payment", + name: "تربت‌پی", + description: "پلتفرم پرداخت سازمانی — BYO-PSP و پذیرنده", + icon: "💳", + gradient: "from-teal-500 to-emerald-600", + status: "available", + href: "/payment/hub", }, { id: "restaurant", diff --git a/frontend/modules/payment/components/PaymentCharts.tsx b/frontend/modules/payment/components/PaymentCharts.tsx new file mode 100644 index 0000000..b61e867 --- /dev/null +++ b/frontend/modules/payment/components/PaymentCharts.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +const COLORS = ["#0d9488", "#0284c7", "#d97706", "#e11d48", "#6366f1", "#64748b"]; + +export function PaymentBarChart({ + data, + dataKey = "value", + nameKey = "name", +}: { + data: { name: string; value: number }[]; + dataKey?: string; + nameKey?: string; +}) { + if (!data.length) { + return

داده‌ای برای نمودار نیست.

; + } + return ( +
+ + + + + + + + + +
+ ); +} + +export function PaymentDonutChart({ data }: { data: { name: string; value: number }[] }) { + if (!data.length) { + return

داده‌ای برای نمودار نیست.

; + } + return ( +
+ + + + {data.map((_, i) => ( + + ))} + + + + +
+ ); +} + +export function PaymentStatCards({ + items, +}: { + items: { label: string; value: string | number; hint?: string }[]; +}) { + return ( +
+ {items.map((item) => ( +
+

{item.label}

+

{item.value}

+ {item.hint ?

{item.hint}

: null} +
+ ))} +
+ ); +} diff --git a/frontend/modules/payment/components/PaymentListPage.tsx b/frontend/modules/payment/components/PaymentListPage.tsx new file mode 100644 index 0000000..34dfb72 --- /dev/null +++ b/frontend/modules/payment/components/PaymentListPage.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Download, RefreshCw, Search } from "lucide-react"; +import { Button, Input, Select } from "@/components/ds"; +import { useTenantId } from "@/hooks/useTenantId"; +import { PaymentStatusChip } from "@/modules/payment/components/PaymentStatusChip"; +import { + PaymentEmptyState, + PaymentPageError, + PaymentPageLoader, + exportToCsv, +} from "@/modules/payment/pages/shared"; +import type { PaymentRecord } from "@/modules/payment/types"; + +type Column = { + key: string; + header: string; + type?: "status" | "datetime" | "text"; + render?: (row: PaymentRecord) => React.ReactNode; +}; + +type Props = { + title: string; + description?: string; + resourceKey: string; + columns: Column[]; + fetcher: (tenantId: string) => Promise; + filterKey?: string; + detailHref?: (row: PaymentRecord) => string | null; + rowActions?: React.ReactNode | ((row: PaymentRecord, refresh: () => void) => React.ReactNode); + headerActions?: React.ReactNode; +}; + +const PAGE_SIZE = 10; + +export function PaymentListPage({ + title, + description, + resourceKey, + columns, + fetcher, + filterKey = "status", + detailHref, + rowActions, + headerActions, +}: Props) { + const { tenantId } = useTenantId(); + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState(""); + const [sortKey, setSortKey] = useState(columns[0]?.key || "created_at"); + const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + const [page, setPage] = useState(1); + + const q = useQuery({ + queryKey: ["payment", tenantId, resourceKey], + queryFn: () => fetcher(tenantId!), + enabled: !!tenantId, + }); + + const rows = useMemo(() => { + let data = [...(q.data ?? [])]; + if (filter) data = data.filter((r) => String(r[filterKey] ?? "") === filter); + if (search.trim()) { + const s = search.trim().toLowerCase(); + data = data.filter((r) => JSON.stringify(r).toLowerCase().includes(s)); + } + data.sort((a, b) => { + const av = String(a[sortKey] ?? ""); + const bv = String(b[sortKey] ?? ""); + return sortDir === "asc" ? av.localeCompare(bv) : bv.localeCompare(av); + }); + return data; + }, [q.data, filter, filterKey, search, sortKey, sortDir]); + + const filterOptions = useMemo(() => { + const set = new Set((q.data ?? []).map((r) => String(r[filterKey] ?? "")).filter(Boolean)); + return Array.from(set); + }, [q.data, filterKey]); + + const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); + const pageRows = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + + if (!tenantId || q.isLoading) return ; + if (q.isError) return q.refetch()} />; + + return ( +
+
+
+

{title}

+ {description ?

{description}

: null} +
+
+ {headerActions} + + +
+
+ +
+
+ + { + setSearch(e.target.value); + setPage(1); + }} + aria-label="جستجو" + /> +
+ + +
+ + {rows.length === 0 ? ( + + ) : ( +
+ + + + {columns.map((c) => ( + + ))} + {(detailHref || rowActions) && ( + + )} + + + + {pageRows.map((row, idx) => ( + + {columns.map((c) => ( + + ))} + {(detailHref || rowActions) && ( + + )} + + ))} + +
+ {c.header} + عملیات
+ {c.render + ? c.render(row) + : c.type === "status" + ? + : c.type === "datetime" && row[c.key] + ? new Date(String(row[c.key])).toLocaleString("fa-IR") + : String(row[c.key] ?? "—")} + +
+ {detailHref?.(row) ? ( + + جزئیات + + ) : null} + {typeof rowActions === "function" + ? rowActions(row, () => q.refetch()) + : rowActions} +
+
+
+ )} + +
+ + {rows.length} مورد — صفحه {page} از {totalPages} + +
+ + +
+
+
+ ); +} diff --git a/frontend/modules/payment/components/PaymentPortalShell.tsx b/frontend/modules/payment/components/PaymentPortalShell.tsx new file mode 100644 index 0000000..79a60e3 --- /dev/null +++ b/frontend/modules/payment/components/PaymentPortalShell.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { ChevronDown, Moon, Sun, Menu, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useColorMode } from "@/components/providers/ColorModeProvider"; +import { Button } from "@/components/ds"; +import { + PAYMENT_PORTAL, + navForPayment, + filterNavByBundles, +} from "@/modules/payment/constants/portals"; +import { usePaymentCapabilities } from "@/modules/payment/hooks/usePaymentCapabilities"; + +function isActive(pathname: string, href: string) { + const base = href.split("?")[0]; + if (base === "/payment") return pathname === "/payment"; + return pathname === base || pathname.startsWith(`${base}/`); +} + +export function PaymentPortalShell({ + children, + title, +}: { + children: React.ReactNode; + title?: string; +}) { + const pathname = usePathname(); + const { mode, toggle } = useColorMode(); + const caps = usePaymentCapabilities(); + const [openGroups, setOpenGroups] = useState>({}); + const [mobileOpen, setMobileOpen] = useState(false); + + const nav = useMemo( + () => filterNavByBundles(navForPayment(), caps.data?.active_bundles), + [caps.data?.active_bundles] + ); + + const autoOpen = useMemo(() => { + const map: Record = {}; + for (const g of nav) { + if (g.href && isActive(pathname, g.href)) map[g.id] = true; + if (g.items?.some((i) => isActive(pathname, i.href))) map[g.id] = true; + } + return map; + }, [pathname, nav]); + + const expanded = { ...autoOpen, ...openGroups }; + + const sidebar = ( + <> +
+

تربت‌پی

+

{title ?? PAYMENT_PORTAL.label}

+
+ +
+ + هاب تربت‌پی + + +
+ + ); + + return ( +
+ + {mobileOpen ? ( +
+ + {sidebar} + +
+ ) : null} +
+
+ + {PAYMENT_PORTAL.label} +
+
{children}
+
+
+ ); +} diff --git a/frontend/modules/payment/components/PaymentStatusChip.tsx b/frontend/modules/payment/components/PaymentStatusChip.tsx new file mode 100644 index 0000000..62efa9b --- /dev/null +++ b/frontend/modules/payment/components/PaymentStatusChip.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +const COLORS: Record = { + paid: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300", + recorded: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300", + active: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300", + enabled: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300", + healthy: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300", + pending: "bg-amber-500/15 text-amber-700 dark:text-amber-300", + pending_review: "bg-amber-500/15 text-amber-700 dark:text-amber-300", + redirect_issued: "bg-sky-500/15 text-sky-700 dark:text-sky-300", + processing: "bg-sky-500/15 text-sky-700 dark:text-sky-300", + draft: "bg-slate-500/15 text-slate-700 dark:text-slate-300", + failed: "bg-rose-500/15 text-rose-700 dark:text-rose-300", + cancelled: "bg-slate-500/15 text-slate-600 dark:text-slate-400", + expired: "bg-slate-500/15 text-slate-600 dark:text-slate-400", + suspended: "bg-orange-500/15 text-orange-700 dark:text-orange-300", + closed: "bg-slate-500/15 text-slate-600 dark:text-slate-400", + disabled: "bg-slate-500/15 text-slate-600 dark:text-slate-400", +}; + +export function PaymentStatusChip({ status }: { status?: string | null }) { + const key = (status || "unknown").toLowerCase(); + return ( + + {status || "—"} + + ); +} diff --git a/frontend/modules/payment/components/createPortalLayout.tsx b/frontend/modules/payment/components/createPortalLayout.tsx new file mode 100644 index 0000000..8a32d35 --- /dev/null +++ b/frontend/modules/payment/components/createPortalLayout.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { AuthGuard } from "@/components/AuthGuard"; +import { useMe } from "@/hooks/useMe"; +import { useRouter, usePathname } from "next/navigation"; +import { useEffect } from "react"; +import { LoadingState, ErrorState } from "@/components/ds"; +import { PaymentPortalShell } from "@/modules/payment/components/PaymentPortalShell"; + +function PortalGate({ children }: { children: React.ReactNode }) { + const { me, loading, error, reload } = useMe(); + const router = useRouter(); + + useEffect(() => { + if (loading) return; + if (me?.onboarding_required) router.replace("/onboarding"); + }, [me, loading, router]); + + if (loading) return ; + if (error) return ; + if (!me?.current_tenant_id) { + return ; + } + + return {children}; +} + +export function PaymentRootLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const isHub = pathname === "/payment/hub"; + + if (isHub) { + return {children}; + } + + return ( + + {children} + + ); +} diff --git a/frontend/modules/payment/constants/permissions.ts b/frontend/modules/payment/constants/permissions.ts new file mode 100644 index 0000000..682175a --- /dev/null +++ b/frontend/modules/payment/constants/permissions.ts @@ -0,0 +1,27 @@ +export const PAYMENT_VIEW = "payment.view"; +export const PAYMENT_MANAGE = "payment.manage"; +export const PAYMENT_AUDIT_VIEW = "payment.audit.view"; +export const PAYMENT_SETTINGS_MANAGE = "payment.settings.manage"; +export const PAYMENT_PSP_VIEW = "payment.psp_connections.view"; +export const PAYMENT_PSP_MANAGE = "payment.psp_connections.manage"; +export const PAYMENT_MERCHANT_VIEW = "payment.merchant_accounts.view"; +export const PAYMENT_MERCHANT_MANAGE = "payment.merchant_accounts.manage"; +export const PAYMENT_REQUEST_VIEW = "payment.requests.view"; +export const PAYMENT_REQUEST_CREATE = "payment.requests.create"; +export const PAYMENT_TX_VIEW = "payment.transactions.view"; +export const PAYMENT_LEDGER_VIEW = "payment.ledger.view"; + +export const ALL_PAYMENT_PERMISSIONS = [ + PAYMENT_VIEW, + PAYMENT_MANAGE, + PAYMENT_AUDIT_VIEW, + PAYMENT_SETTINGS_MANAGE, + PAYMENT_PSP_VIEW, + PAYMENT_PSP_MANAGE, + PAYMENT_MERCHANT_VIEW, + PAYMENT_MERCHANT_MANAGE, + PAYMENT_REQUEST_VIEW, + PAYMENT_REQUEST_CREATE, + PAYMENT_TX_VIEW, + PAYMENT_LEDGER_VIEW, +] as const; diff --git a/frontend/modules/payment/constants/portals.ts b/frontend/modules/payment/constants/portals.ts new file mode 100644 index 0000000..80377de --- /dev/null +++ b/frontend/modules/payment/constants/portals.ts @@ -0,0 +1,158 @@ +import type { LucideIcon } from "lucide-react"; +import { + LayoutDashboard, + CreditCard, + Building2, + Settings, + Shield, + Activity, + BarChart3, + Wallet, + Plug, + FileText, + BookOpen, + KeyRound, + Gauge, + ScrollText, + Network, + Eye, +} from "lucide-react"; + +export type PortalNavItem = { href: string; label: string; bundle?: string | null }; +export type PortalNavGroup = { + id: string; + label: string; + icon: LucideIcon; + href?: string; + bundle?: string | null; + items?: PortalNavItem[]; +}; + +export const PAYMENT_PORTAL = { + id: "payment" as const, + label: "تربت‌پی", + description: "پلتفرم پرداخت سازمانی — BYO-PSP و پذیرنده", + basePath: "/payment", + icon: CreditCard, +}; + +export const PAYMENT_NAV: PortalNavGroup[] = [ + { + id: "dashboards", + label: "داشبوردها", + icon: LayoutDashboard, + items: [ + { href: "/payment/dashboard", label: "داشبورد اجرایی" }, + { href: "/payment/overview", label: "نمای کلی" }, + { href: "/payment/kpis", label: "شاخص‌های کلیدی" }, + ], + }, + { + id: "operations", + label: "عملیات پرداخت", + icon: Wallet, + items: [ + { href: "/payment/requests", label: "درخواست‌های پرداخت" }, + { href: "/payment/transactions", label: "تراکنش‌ها" }, + { href: "/payment/ledger", label: "دفتر کل" }, + { href: "/payment/callbacks", label: "کال‌بک‌ها" }, + { href: "/payment/verification", label: "تأیید پرداخت" }, + ], + }, + { + id: "psp", + label: "درگاه‌ها (PSP)", + icon: Plug, + bundle: "payment.byo_psp.basic", + items: [ + { href: "/payment/psp", label: "مدیریت درگاه", bundle: "payment.byo_psp.basic" }, + { href: "/payment/psp/health", label: "سلامت درگاه", bundle: "payment.byo_psp.basic" }, + { href: "/payment/providers", label: "تخصیص ارائه‌دهنده" }, + ], + }, + { + id: "merchants", + label: "پذیرندگان", + icon: Building2, + bundle: "payment.torbat_pay.merchant", + items: [ + { href: "/payment/merchants", label: "حساب‌ها", bundle: "payment.torbat_pay.merchant" }, + { href: "/payment/merchants/approvals", label: "تأییدها", bundle: "payment.torbat_pay.merchant" }, + ], + }, + { + id: "licensing", + label: "مجوز و باندل", + icon: KeyRound, + items: [ + { href: "/payment/bundles", label: "باندل‌ها" }, + { href: "/payment/feature-toggles", label: "کلیدهای ویژگی" }, + { href: "/payment/capabilities", label: "قابلیت‌ها" }, + ], + }, + { + id: "ops", + label: "عملیات پلتفرم", + icon: Activity, + items: [ + { href: "/payment/monitoring", label: "مانیتورینگ" }, + { href: "/payment/metrics", label: "متریک‌ها" }, + { href: "/payment/audit", label: "حسابرسی" }, + { href: "/payment/settings", label: "تنظیمات" }, + ], + }, + { + id: "dev", + label: "توسعه‌دهنده", + icon: Network, + items: [ + { href: "/payment/api-explorer", label: "کاوشگر API" }, + { href: "/payment/openapi", label: "OpenAPI" }, + ], + }, +]; + +export function navForPayment() { + return PAYMENT_NAV; +} + +export function filterNavByBundles( + nav: PortalNavGroup[], + activeBundles: string[] | undefined +) { + const bundles = new Set(activeBundles ?? []); + return nav + .map((g) => { + if (g.bundle && !bundles.has(g.bundle)) return null; + const items = g.items?.filter((i) => !i.bundle || bundles.has(i.bundle)); + if (g.items && (!items || items.length === 0)) return null; + return { ...g, items }; + }) + .filter(Boolean) as PortalNavGroup[]; +} + +export const MVP_ROUTE_HREFS = [ + "/payment/hub", + "/payment/dashboard", + "/payment/overview", + "/payment/kpis", + "/payment/transactions", + "/payment/ledger", + "/payment/psp", + "/payment/psp/health", + "/payment/providers", + "/payment/merchants", + "/payment/merchants/approvals", + "/payment/requests", + "/payment/callbacks", + "/payment/verification", + "/payment/audit", + "/payment/bundles", + "/payment/feature-toggles", + "/payment/capabilities", + "/payment/settings", + "/payment/metrics", + "/payment/monitoring", + "/payment/api-explorer", + "/payment/openapi", +] as const; diff --git a/frontend/modules/payment/features/apiExplorer.tsx b/frontend/modules/payment/features/apiExplorer.tsx new file mode 100644 index 0000000..8a80fe3 --- /dev/null +++ b/frontend/modules/payment/features/apiExplorer.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { PAYMENT_API_CATALOG } from "@/modules/payment/services/payment-api"; +import { PageHeader, Card, CardContent, Badge } from "@/components/ds"; + +export const PaymentApiExplorer = function PaymentApiExplorerPage() { + return ( +
+ +
+ + + + + + + + + + {PAYMENT_API_CATALOG.map((row) => ( + + + + + + ))} + +
متدمسیرفاز
{row.method}{row.path} + {row.phase} +
+
+ + + BFF: /api/payment/* — upstream port 8012. هدر Idempotency-Key برای POST درخواست پرداخت الزامی است. + + +
+ ); +}; + +export default PaymentApiExplorer; diff --git a/frontend/modules/payment/features/audit.tsx b/frontend/modules/payment/features/audit.tsx new file mode 100644 index 0000000..9e766f3 --- /dev/null +++ b/frontend/modules/payment/features/audit.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { PaymentListPage } from "@/modules/payment/components/PaymentListPage"; +import { paymentApi } from "@/modules/payment/services/payment-api"; + +export const PaymentAudit = function PaymentAuditPage() { + return ( + paymentApi.audit.list(tenantId)} + filterKey="entity_type" + columns={[ + { key: "action", header: "عمل" }, + { key: "entity_type", header: "نوع موجودیت" }, + { key: "entity_id", header: "شناسه" }, + { key: "created_at", header: "تاریخ", type: "datetime" }, + ]} + /> + ); +}; + +export default PaymentAudit; diff --git a/frontend/modules/payment/features/bundles.tsx b/frontend/modules/payment/features/bundles.tsx new file mode 100644 index 0000000..8d8fa18 --- /dev/null +++ b/frontend/modules/payment/features/bundles.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { PaymentListPage } from "@/modules/payment/components/PaymentListPage"; +import { paymentApi } from "@/modules/payment/services/payment-api"; + +export const PaymentBundles = function PaymentBundlesPage() { + return ( + paymentApi.tenantBundles.list(tenantId)} + filterKey="bundle_code" + columns={[ + { key: "bundle_code", header: "کد باندل" }, + { key: "is_active", header: "فعال", render: (r) => (r.is_active ? "بله" : "خیر") }, + { key: "activated_at", header: "فعال‌سازی", type: "datetime" }, + { key: "created_at", header: "تاریخ", type: "datetime" }, + ]} + /> + ); +}; + +export default PaymentBundles; diff --git a/frontend/modules/payment/features/callbacks.tsx b/frontend/modules/payment/features/callbacks.tsx new file mode 100644 index 0000000..56c700d --- /dev/null +++ b/frontend/modules/payment/features/callbacks.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { useTenantId } from "@/hooks/useTenantId"; +import { paymentApi } from "@/modules/payment/services/payment-api"; +import { PaymentPageLoader, PaymentPageError, PaymentEmptyState } from "@/modules/payment/pages/shared"; +import { PageHeader, Card, CardContent } from "@/components/ds"; +import { PaymentStatusChip } from "@/modules/payment/components/PaymentStatusChip"; + +export const PaymentCallbacks = function PaymentCallbacksPage() { + const { tenantId } = useTenantId(); + + const q = useQuery({ + queryKey: ["payment", tenantId, "callbacks"], + queryFn: async () => { + const [audit, requests] = await Promise.all([ + paymentApi.audit.list(tenantId!), + paymentApi.requests.list(tenantId!), + ]); + const callbackAudit = audit.filter( + (a) => + String(a.entity_type ?? "").includes("Callback") || + String(a.action ?? "").toLowerCase().includes("callback") + ); + const awaiting = requests.filter((r) => r.status === "redirect_issued"); + return { callbackAudit, awaiting }; + }, + enabled: !!tenantId, + }); + + if (!tenantId || q.isLoading) return ; + if (q.error) return q.refetch()} />; + + const { callbackAudit, awaiting } = q.data!; + + return ( +
+ + + +

در انتظار callback ({awaiting.length})

+ {awaiting.length === 0 ? ( + + ) : ( +
    + {awaiting.map((r) => ( +
  • + + {String(r.amount_minor)} {String(r.currency)} + + +
  • + ))} +
+ )} +
+
+ + +

لاگ حسابرسی callback

+ {callbackAudit.length === 0 ? ( +

رویداد callback در audit ثبت نشده.

+ ) : ( +
    + {callbackAudit.map((a) => ( +
  • + {String(a.action)} — {String(a.entity_type)} —{" "} + {a.created_at ? new Date(String(a.created_at)).toLocaleString("fa-IR") : "—"} +
  • + ))} +
+ )} +
+
+
+ ); +}; + +export default PaymentCallbacks; diff --git a/frontend/modules/payment/features/capabilities.tsx b/frontend/modules/payment/features/capabilities.tsx new file mode 100644 index 0000000..7d10f09 --- /dev/null +++ b/frontend/modules/payment/features/capabilities.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { usePaymentCapabilities } from "@/modules/payment/hooks/usePaymentCapabilities"; +import { PaymentPageLoader, PaymentPageError } from "@/modules/payment/pages/shared"; +import { PageHeader, Card, CardContent, Badge } from "@/components/ds"; + +export const PaymentCapabilities = function PaymentCapabilitiesPage() { + const q = usePaymentCapabilities(); + if (q.isLoading) return ; + if (q.error) return q.refetch()} />; + + const toggles = q.data?.feature_toggles ?? {}; + + return ( +
+ + + +

workspace: {q.data?.workspace_status}

+

حالت پیش‌فرض: {q.data?.default_payment_mode}

+
+
+
+ {Object.entries(toggles).map(([k, v]) => ( + + + {k} + {v ? "فعال" : "غیرفعال"} + + + ))} + {Object.keys(toggles).length === 0 ? ( +

کلید ویژگی از capabilities دریافت نشد.

+ ) : null} +
+ {q.data?.active_bundles ? ( + + +

باندل‌های فعال

+
    + {q.data.active_bundles.map((b) => ( +
  • {b}
  • + ))} +
+
+
+ ) : null} +
+ ); +}; + +export default PaymentCapabilities; diff --git a/frontend/modules/payment/features/dashboard.tsx b/frontend/modules/payment/features/dashboard.tsx new file mode 100644 index 0000000..e4d1812 --- /dev/null +++ b/frontend/modules/payment/features/dashboard.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { useTenantId } from "@/hooks/useTenantId"; +import { paymentApi } from "@/modules/payment/services/payment-api"; +import { + usePaymentCapabilities, + usePaymentMetrics, +} from "@/modules/payment/hooks/usePaymentCapabilities"; +import { PaymentPageLoader, PaymentPageError } from "@/modules/payment/pages/shared"; +import { PageHeader, Card, CardContent, Button, Badge } from "@/components/ds"; +import { + PaymentBarChart, + PaymentStatCards, +} from "@/modules/payment/components/PaymentCharts"; + +export const PaymentDashboard = function PaymentExecutiveDashboard() { + const { tenantId } = useTenantId(); + const caps = usePaymentCapabilities(); + const metrics = usePaymentMetrics(); + + const countsQ = useQuery({ + queryKey: ["payment", tenantId, "dashboard-counts"], + queryFn: async () => { + const [requests, transactions, merchants, connections, audit] = await Promise.all([ + paymentApi.requests.list(tenantId!), + paymentApi.transactions.list(tenantId!), + paymentApi.merchants.list(tenantId!).catch(() => []), + paymentApi.pspConnections.list(tenantId!).catch(() => []), + paymentApi.audit.list(tenantId!), + ]); + return { + requests: requests.length, + transactions: transactions.length, + merchants: merchants.length, + connections: connections.length, + paid: requests.filter((r) => r.status === "paid").length, + pending: requests.filter((r) => + ["pending", "redirect_issued"].includes(String(r.status)) + ).length, + recentRequests: requests.slice(0, 5), + recentAudit: audit.slice(0, 8), + }; + }, + enabled: !!tenantId, + }); + + if (!tenantId || countsQ.isLoading || caps.isLoading) return ; + if (countsQ.error) return countsQ.refetch()} />; + + const c = countsQ.data!; + const chartData = [ + { name: "درخواست", value: c.requests }, + { name: "تراکنش", value: c.transactions }, + { name: "پذیرنده", value: c.merchants }, + { name: "درگاه", value: c.connections }, + ]; + + return ( +
+ Torbat Pay v{caps.data?.version}} + /> + +
+ + +

توزیع منابع

+ +
+
+ + +

اقدامات سریع

+
+ + + + + +
+
+
+
+
+ + +

آخرین درخواست‌ها

+
    + {c.recentRequests.map((r) => ( +
  • + + {String(r.amount_minor)} {String(r.currency)} — {String(r.status)} + +
  • + ))} + {c.recentRequests.length === 0 ?
  • درخواستی نیست
  • : null} +
+
+
+ + +

فعالیت اخیر (حسابرسی)

+
    + {c.recentAudit.map((a) => ( +
  • + {String(a.action)} — {String(a.entity_type)} —{" "} + {a.created_at ? new Date(String(a.created_at)).toLocaleString("fa-IR") : "—"} +
  • + ))} + {c.recentAudit.length === 0 ?
  • فعالیتی ثبت نشده
  • : null} +
+
+
+
+ {metrics.data ? ( + + + فاز {metrics.data.phase} — سرویس {metrics.data.service} + + + ) : null} +
+ ); +}; + +export default PaymentDashboard; diff --git a/frontend/modules/payment/features/featureToggles.tsx b/frontend/modules/payment/features/featureToggles.tsx new file mode 100644 index 0000000..e72a742 --- /dev/null +++ b/frontend/modules/payment/features/featureToggles.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { PaymentListPage } from "@/modules/payment/components/PaymentListPage"; +import { paymentApi } from "@/modules/payment/services/payment-api"; + +export const PaymentFeatureToggles = function PaymentFeatureTogglesPage() { + return ( + paymentApi.featureToggles.list(tenantId)} + filterKey="key" + columns={[ + { key: "key", header: "کلید" }, + { key: "enabled", header: "فعال", render: (r) => (r.enabled ? "بله" : "خیر") }, + { key: "created_at", header: "تاریخ", type: "datetime" }, + ]} + /> + ); +}; + +export default PaymentFeatureToggles; diff --git a/frontend/modules/payment/features/gatewayHealth.tsx b/frontend/modules/payment/features/gatewayHealth.tsx new file mode 100644 index 0000000..87abac7 --- /dev/null +++ b/frontend/modules/payment/features/gatewayHealth.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useTenantId } from "@/hooks/useTenantId"; +import { paymentApi } from "@/modules/payment/services/payment-api"; +import { usePaymentHealth } from "@/modules/payment/hooks/usePaymentCapabilities"; +import { PaymentPageLoader, PaymentPageError } from "@/modules/payment/pages/shared"; +import { PageHeader, Card, CardContent, Badge } from "@/components/ds"; +import { PaymentStatusChip } from "@/modules/payment/components/PaymentStatusChip"; + +export const PaymentGatewayHealth = function PaymentGatewayHealthPage() { + const { tenantId } = useTenantId(); + const health = usePaymentHealth(); + + const connectionsQ = useQuery({ + queryKey: ["payment", tenantId, "psp-health"], + queryFn: () => paymentApi.pspConnections.list(tenantId!), + enabled: !!tenantId, + }); + + if (!tenantId || connectionsQ.isLoading || health.isLoading) return ; + if (connectionsQ.error) return connectionsQ.refetch()} />; + + return ( +
+ + {health.data?.status ?? "—"} + + } + /> + + +

سرویس: {health.data?.service}

+

نسخه: {health.data?.version}

+

وضعیت: {health.data?.status}

+
+
+
+ {(connectionsQ.data ?? []).map((c) => ( + + +
+

{String(c.name ?? c.provider_code)}

+

{String(c.provider_code)}

+
+ +
+
+ ))} + {(connectionsQ.data ?? []).length === 0 ? ( +

اتصال PSP ثبت نشده — از بخش مدیریت درگاه اضافه کنید.

+ ) : null} +
+
+ ); +}; + +export default PaymentGatewayHealth; diff --git a/frontend/modules/payment/features/hub.tsx b/frontend/modules/payment/features/hub.tsx new file mode 100644 index 0000000..8ceb723 --- /dev/null +++ b/frontend/modules/payment/features/hub.tsx @@ -0,0 +1,36 @@ +"use client"; + +import Link from "next/link"; +import { useMe } from "@/hooks/useMe"; +import { PAYMENT_PORTAL } from "@/modules/payment/constants/portals"; +import { Card, CardContent, Button, LoadingState, ErrorState } from "@/components/ds"; + +export const PaymentHub = function PaymentHubPage() { + const { me, loading, error, reload } = useMe(); + if (loading) return ; + if (error) return ; + if (!me?.current_tenant_id) { + return ; + } + + return ( +
+
💳
+
+

{PAYMENT_PORTAL.label}

+

{PAYMENT_PORTAL.description}

+
+ + +

{PAYMENT_PORTAL.label}

+

{PAYMENT_PORTAL.description}

+ + + +
+
+
+ ); +}; + +export default PaymentHub; diff --git a/frontend/modules/payment/features/kpis.tsx b/frontend/modules/payment/features/kpis.tsx new file mode 100644 index 0000000..a5b1b78 --- /dev/null +++ b/frontend/modules/payment/features/kpis.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useTenantId } from "@/hooks/useTenantId"; +import { paymentApi } from "@/modules/payment/services/payment-api"; +import { usePaymentMetrics } from "@/modules/payment/hooks/usePaymentCapabilities"; +import { PaymentPageLoader, PaymentPageError } from "@/modules/payment/pages/shared"; +import { PageHeader, Card, CardContent } from "@/components/ds"; +import { + PaymentBarChart, + PaymentDonutChart, + PaymentStatCards, +} from "@/modules/payment/components/PaymentCharts"; + +export const PaymentKpis = function PaymentKpisPage() { + const { tenantId } = useTenantId(); + const metrics = usePaymentMetrics(); + + const kpiQ = useQuery({ + queryKey: ["payment", tenantId, "kpis"], + queryFn: async () => { + const [requests, transactions] = await Promise.all([ + paymentApi.requests.list(tenantId!), + paymentApi.transactions.list(tenantId!), + ]); + const byStatus: Record = {}; + for (const r of requests) { + const s = String(r.status ?? "unknown"); + byStatus[s] = (byStatus[s] ?? 0) + 1; + } + const totalAmount = requests.reduce((sum, r) => sum + Number(r.amount_minor ?? 0), 0); + const paidCount = requests.filter((r) => r.status === "paid").length; + const successRate = requests.length ? Math.round((paidCount / requests.length) * 100) : 0; + return { + totalRequests: requests.length, + totalTransactions: transactions.length, + totalAmount, + successRate, + statusChart: Object.entries(byStatus).map(([name, value]) => ({ name, value })), + sourceChart: Object.entries( + requests.reduce>((acc, r) => { + const k = String(r.payment_source ?? "unknown"); + acc[k] = (acc[k] ?? 0) + 1; + return acc; + }, {}) + ).map(([name, value]) => ({ name, value })), + }; + }, + enabled: !!tenantId, + }); + + if (!tenantId || kpiQ.isLoading || metrics.isLoading) return ; + if (kpiQ.error) return kpiQ.refetch()} />; + + const k = kpiQ.data!; + + return ( +
+ + +
+ + +

توزیع وضعیت

+ +
+
+ + +

منبع پرداخت

+ +
+
+
+ {metrics.data?.metrics ? ( + + +

متریک‌های سرویس

+
{JSON.stringify(metrics.data.metrics, null, 2)}
+
+
+ ) : null} +
+ ); +}; + +export default PaymentKpis; diff --git a/frontend/modules/payment/features/ledger.tsx b/frontend/modules/payment/features/ledger.tsx new file mode 100644 index 0000000..f145509 --- /dev/null +++ b/frontend/modules/payment/features/ledger.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { PaymentListPage } from "@/modules/payment/components/PaymentListPage"; +import { paymentApi } from "@/modules/payment/services/payment-api"; + +export const PaymentLedger = function PaymentLedgerPage() { + return ( + paymentApi.ledger.list(tenantId)} + filterKey="direction" + columns={[ + { key: "id", header: "شناسه" }, + { key: "amount_minor", header: "مبلغ (ریال)" }, + { key: "direction", header: "جهت" }, + { key: "status", header: "وضعیت", type: "status" }, + { key: "payment_transaction_id", header: "تراکنش" }, + { key: "created_at", header: "تاریخ", type: "datetime" }, + ]} + /> + ); +}; + +export default PaymentLedger; diff --git a/frontend/modules/payment/features/merchantApprovals.tsx b/frontend/modules/payment/features/merchantApprovals.tsx new file mode 100644 index 0000000..f3cfa15 --- /dev/null +++ b/frontend/modules/payment/features/merchantApprovals.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { useTenantId } from "@/hooks/useTenantId"; +import { paymentApi } from "@/modules/payment/services/payment-api"; +import { PaymentPageLoader, PaymentPageError, PaymentEmptyState } from "@/modules/payment/pages/shared"; +import { PageHeader, Card, CardContent, Button } from "@/components/ds"; +import { PaymentStatusChip } from "@/modules/payment/components/PaymentStatusChip"; + +export const PaymentMerchantApprovals = function PaymentMerchantApprovalsPage() { + const { tenantId } = useTenantId(); + + const q = useQuery({ + queryKey: ["payment", tenantId, "merchant-approvals"], + queryFn: async () => { + const all = await paymentApi.merchants.list(tenantId!); + return all.filter((m) => m.status === "pending_review"); + }, + enabled: !!tenantId, + }); + + if (!tenantId || q.isLoading) return ; + if (q.error) return q.refetch()} />; + + const pending = q.data ?? []; + + return ( +
+ + {pending.length === 0 ? ( + + ) : ( +
+ {pending.map((m) => ( + + +
+

{String(m.name)}

+

{String(m.code)}

+
+
+ + + + +
+
+
+ ))} +
+ )} +
+ ); +}; + +export default PaymentMerchantApprovals; diff --git a/frontend/modules/payment/features/merchantDetail.tsx b/frontend/modules/payment/features/merchantDetail.tsx new file mode 100644 index 0000000..36d5814 --- /dev/null +++ b/frontend/modules/payment/features/merchantDetail.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useTenantId } from "@/hooks/useTenantId"; +import { paymentApi } from "@/modules/payment/services/payment-api"; +import { PaymentPageLoader, PaymentPageError } from "@/modules/payment/pages/shared"; +import { PageHeader, Card, CardContent, Button } from "@/components/ds"; +import { PaymentStatusChip } from "@/modules/payment/components/PaymentStatusChip"; + +export const PaymentMerchantDetail = function PaymentMerchantDetailPage() { + const params = useParams(); + const id = String(params?.id ?? ""); + const { tenantId } = useTenantId(); + const qc = useQueryClient(); + + const q = useQuery({ + queryKey: ["payment", tenantId, "merchant", id], + queryFn: () => paymentApi.merchants.get(tenantId!, id), + enabled: !!tenantId && !!id, + }); + + const actionM = useMutation({ + mutationFn: (action: "submit" | "activate" | "suspend" | "close") => { + const api = paymentApi.merchants[action]; + return api(tenantId!, id); + }, + onSuccess: () => { + toast.success("وضعیت به‌روز شد"); + qc.invalidateQueries({ queryKey: ["payment", tenantId, "merchant", id] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + + if (!tenantId || q.isLoading) return ; + if (q.error) return q.refetch()} />; + + const m = q.data!; + const status = String(m.status); + + return ( +
+ + + +
+ وضعیت: + +
+
+ {status === "draft" ? ( + + ) : null} + {status === "pending_review" ? ( + + ) : null} + {status === "active" ? ( + + ) : null} + {!["closed"].includes(status) ? ( + + ) : null} +
+
+
+ + +
{JSON.stringify(m, null, 2)}
+
+
+
+ ); +}; + +export default PaymentMerchantDetail; diff --git a/frontend/modules/payment/features/merchants.tsx b/frontend/modules/payment/features/merchants.tsx new file mode 100644 index 0000000..d1af9f7 --- /dev/null +++ b/frontend/modules/payment/features/merchants.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { PaymentListPage } from "@/modules/payment/components/PaymentListPage"; +import { paymentApi } from "@/modules/payment/services/payment-api"; + +export const PaymentMerchants = function PaymentMerchantsPage() { + return ( + paymentApi.merchants.list(tenantId)} + columns={[ + { key: "code", header: "کد" }, + { key: "name", header: "نام" }, + { key: "status", header: "وضعیت", type: "status" }, + { key: "created_at", header: "تاریخ", type: "datetime" }, + ]} + detailHref={(row) => (row.id ? `/payment/merchants/${row.id}` : null)} + /> + ); +}; + +export default PaymentMerchants; diff --git a/frontend/modules/payment/features/metrics.tsx b/frontend/modules/payment/features/metrics.tsx new file mode 100644 index 0000000..dc10a89 --- /dev/null +++ b/frontend/modules/payment/features/metrics.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { usePaymentMetrics } from "@/modules/payment/hooks/usePaymentCapabilities"; +import { PaymentPageLoader, PaymentPageError } from "@/modules/payment/pages/shared"; +import { PageHeader, Card, CardContent } from "@/components/ds"; + +export const PaymentMetrics = function PaymentMetricsPage() { + const q = usePaymentMetrics(); + if (q.isLoading) return ; + if (q.error) return q.refetch()} />; + + return ( +
+ + + +
{JSON.stringify(q.data?.metrics ?? {}, null, 2)}
+
+
+
+ ); +}; + +export default PaymentMetrics; diff --git a/frontend/modules/payment/features/monitoring.tsx b/frontend/modules/payment/features/monitoring.tsx new file mode 100644 index 0000000..4f27750 --- /dev/null +++ b/frontend/modules/payment/features/monitoring.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useTenantId } from "@/hooks/useTenantId"; +import { paymentApi } from "@/modules/payment/services/payment-api"; +import { usePaymentHealth, usePaymentMetrics } from "@/modules/payment/hooks/usePaymentCapabilities"; +import { PaymentPageLoader, PaymentPageError } from "@/modules/payment/pages/shared"; +import { PageHeader, Card, CardContent, Badge } from "@/components/ds"; +import { PaymentStatCards } from "@/modules/payment/components/PaymentCharts"; + +export const PaymentMonitoring = function PaymentMonitoringPage() { + const { tenantId } = useTenantId(); + const health = usePaymentHealth(); + const metrics = usePaymentMetrics(); + + const opsQ = useQuery({ + queryKey: ["payment", tenantId, "monitoring"], + queryFn: async () => { + const [requests, transactions] = await Promise.all([ + paymentApi.requests.list(tenantId!), + paymentApi.transactions.list(tenantId!), + ]); + const failed = requests.filter((r) => r.status === "failed").length; + const pending = requests.filter((r) => + ["pending", "redirect_issued"].includes(String(r.status)) + ).length; + return { failed, pending, requests: requests.length, transactions: transactions.length }; + }, + enabled: !!tenantId, + refetchInterval: 30_000, + }); + + if (!tenantId || health.isLoading || metrics.isLoading || opsQ.isLoading) { + return ; + } + if (opsQ.error) return opsQ.refetch()} />; + + const ops = opsQ.data!; + + return ( +
+ + {health.data?.status ?? "—"} + + } + /> + +
+ + +

Health

+
{JSON.stringify(health.data, null, 2)}
+
+
+ + +

Metrics

+
{JSON.stringify(metrics.data?.metrics ?? {}, null, 2)}
+
+
+
+
+ ); +}; + +export default PaymentMonitoring; diff --git a/frontend/modules/payment/features/openapi.tsx b/frontend/modules/payment/features/openapi.tsx new file mode 100644 index 0000000..05340eb --- /dev/null +++ b/frontend/modules/payment/features/openapi.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { paymentApi } from "@/modules/payment/services/payment-api"; +import { PaymentPageLoader, PaymentPageError } from "@/modules/payment/pages/shared"; +import { PageHeader, Card, CardContent } from "@/components/ds"; + +export const PaymentOpenApi = function PaymentOpenApiPage() { + const q = useQuery({ + queryKey: ["payment", "openapi"], + queryFn: () => paymentApi.openapi(), + }); + + if (q.isLoading) return ; + if (q.error) return q.refetch()} />; + + return ( +
+ + + +
{JSON.stringify(q.data, null, 2)}
+
+
+
+ ); +}; + +export default PaymentOpenApi; diff --git a/frontend/modules/payment/features/overview.tsx b/frontend/modules/payment/features/overview.tsx new file mode 100644 index 0000000..d36a355 --- /dev/null +++ b/frontend/modules/payment/features/overview.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { usePaymentCapabilities, usePaymentHealth } from "@/modules/payment/hooks/usePaymentCapabilities"; +import { PaymentPageLoader, PaymentPageError } from "@/modules/payment/pages/shared"; +import { PageHeader, Card, CardContent, Badge } from "@/components/ds"; +import { PaymentStatCards } from "@/modules/payment/components/PaymentCharts"; + +export const PaymentOverview = function PaymentOverviewPage() { + const caps = usePaymentCapabilities(); + const health = usePaymentHealth(); + + if (caps.isLoading || health.isLoading) return ; + if (caps.error) return caps.refetch()} />; + + const data = caps.data; + + return ( +
+ + {health.data?.status ?? "—"} + + } + /> + +
+ + +

باندل‌های فعال

+
    + {(data?.active_bundles ?? []).map((b) => ( +
  • {b}
  • + ))} + {(data?.active_bundles ?? []).length === 0 ? ( +
  • باندلی فعال نیست
  • + ) : null} +
+
+
+ + +

منابع پرداخت

+

پشتیبانی‌شده

+
+ {(data?.payment_sources_supported ?? []).map((s) => ( + {s} + ))} +
+

رزرو شده

+
+ {(data?.payment_sources_reserved ?? []).map((s) => ( + {s} + ))} +
+
+
+
+ {data?.contract_versions ? ( + + +

نسخه قراردادها

+
{JSON.stringify(data.contract_versions, null, 2)}
+
+
+ ) : null} +
+ ); +}; + +export default PaymentOverview; diff --git a/frontend/modules/payment/features/providers.tsx b/frontend/modules/payment/features/providers.tsx new file mode 100644 index 0000000..9f3ea8b --- /dev/null +++ b/frontend/modules/payment/features/providers.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { PaymentListPage } from "@/modules/payment/components/PaymentListPage"; +import { paymentApi } from "@/modules/payment/services/payment-api"; + +export const PaymentProviders = function PaymentProvidersPage() { + return ( + paymentApi.providerAssignments.list(tenantId)} + filterKey="source_type" + columns={[ + { key: "source_type", header: "نوع منبع" }, + { key: "source_id", header: "شناسه منبع" }, + { key: "scope_type", header: "محدوده" }, + { key: "priority", header: "اولویت" }, + { key: "is_active", header: "فعال", render: (r) => (r.is_active ? "بله" : "خیر") }, + ]} + /> + ); +}; + +export default PaymentProviders; diff --git a/frontend/modules/payment/features/psp.tsx b/frontend/modules/payment/features/psp.tsx new file mode 100644 index 0000000..e71b9d5 --- /dev/null +++ b/frontend/modules/payment/features/psp.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Button } from "@/components/ds"; +import { PaymentListPage } from "@/modules/payment/components/PaymentListPage"; +import { paymentApi } from "@/modules/payment/services/payment-api"; +import { useTenantId } from "@/hooks/useTenantId"; +import type { PaymentRecord } from "@/modules/payment/types"; + +function TestConnectionButton({ row, refresh }: { row: PaymentRecord; refresh: () => void }) { + const { tenantId } = useTenantId(); + const qc = useQueryClient(); + const testM = useMutation({ + mutationFn: () => paymentApi.pspConnections.test(tenantId!, String(row.id)), + onSuccess: () => { + toast.success("تست اتصال موفق"); + refresh(); + qc.invalidateQueries({ queryKey: ["payment", tenantId] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + return ( + + ); +} + +export const PaymentPsp = function PaymentPspPage() { + return ( + paymentApi.pspConnections.list(tenantId)} + columns={[ + { key: "provider_code", header: "ارائه‌دهنده" }, + { key: "name", header: "نام" }, + { key: "status", header: "وضعیت", type: "status" }, + { key: "created_at", header: "تاریخ", type: "datetime" }, + ]} + rowActions={(row, refresh) => } + /> + ); +}; + +export default PaymentPsp; diff --git a/frontend/modules/payment/features/requestDetail.tsx b/frontend/modules/payment/features/requestDetail.tsx new file mode 100644 index 0000000..b4bbb15 --- /dev/null +++ b/frontend/modules/payment/features/requestDetail.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useTenantId } from "@/hooks/useTenantId"; +import { paymentApi } from "@/modules/payment/services/payment-api"; +import { PaymentPageLoader, PaymentPageError } from "@/modules/payment/pages/shared"; +import { PageHeader, Card, CardContent, Button } from "@/components/ds"; +import { PaymentStatusChip } from "@/modules/payment/components/PaymentStatusChip"; + +export const PaymentRequestDetail = function PaymentRequestDetailPage() { + const params = useParams(); + const id = String(params?.id ?? ""); + const { tenantId } = useTenantId(); + const qc = useQueryClient(); + + const q = useQuery({ + queryKey: ["payment", tenantId, "request", id], + queryFn: () => paymentApi.requests.get(tenantId!, id), + enabled: !!tenantId && !!id, + }); + + const cancelM = useMutation({ + mutationFn: () => paymentApi.requests.cancel(tenantId!, id), + onSuccess: () => { + toast.success("درخواست لغو شد"); + qc.invalidateQueries({ queryKey: ["payment", tenantId, "request", id] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const verifyM = useMutation({ + mutationFn: () => paymentApi.requests.verify(tenantId!, id, { status: "paid" }), + onSuccess: () => { + toast.success("پرداخت تأیید شد"); + qc.invalidateQueries({ queryKey: ["payment", tenantId, "request", id] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + + if (!tenantId || q.isLoading) return ; + if (q.error) return q.refetch()} />; + + const r = q.data!; + const status = String(r.status); + const canCancel = ["draft", "pending", "redirect_issued"].includes(status); + const canVerify = ["redirect_issued", "pending"].includes(status); + + return ( +
+ + + +
+ وضعیت: + +
+

مبلغ: {String(r.amount_minor)} {String(r.currency)}

+

منبع: {String(r.payment_source)}

+

PSP: {String(r.provider_code)}

+ {r.redirect_url ? ( +

+ redirect:{" "} + + {String(r.redirect_url)} + +

+ ) : null} +

idempotency: {String(r.idempotency_key ?? "—")}

+
+ {canCancel ? ( + + ) : null} + {canVerify ? ( + + ) : null} +
+
+
+ + +

داده کامل

+
{JSON.stringify(r, null, 2)}
+
+
+
+ ); +}; + +export default PaymentRequestDetail; diff --git a/frontend/modules/payment/features/requests.tsx b/frontend/modules/payment/features/requests.tsx new file mode 100644 index 0000000..1a47098 --- /dev/null +++ b/frontend/modules/payment/features/requests.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { PaymentListPage } from "@/modules/payment/components/PaymentListPage"; +import { paymentApi } from "@/modules/payment/services/payment-api"; + +export const PaymentRequests = function PaymentRequestsPage() { + return ( + paymentApi.requests.list(tenantId)} + columns={[ + { key: "amount_minor", header: "مبلغ (ریال)" }, + { key: "currency", header: "ارز" }, + { key: "payment_source", header: "منبع" }, + { key: "provider_code", header: "PSP" }, + { key: "status", header: "وضعیت", type: "status" }, + { key: "created_at", header: "تاریخ", type: "datetime" }, + ]} + detailHref={(row) => (row.id ? `/payment/requests/${row.id}` : null)} + /> + ); +}; + +export default PaymentRequests; diff --git a/frontend/modules/payment/features/settings.tsx b/frontend/modules/payment/features/settings.tsx new file mode 100644 index 0000000..e9617b4 --- /dev/null +++ b/frontend/modules/payment/features/settings.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { PaymentListPage } from "@/modules/payment/components/PaymentListPage"; +import { paymentApi } from "@/modules/payment/services/payment-api"; + +export const PaymentSettings = function PaymentSettingsPage() { + return ( + paymentApi.settings.list(tenantId)} + filterKey="key" + columns={[ + { key: "key", header: "کلید" }, + { + key: "value", + header: "مقدار", + render: (r) => ( + {JSON.stringify(r.value ?? r.data ?? "—")} + ), + }, + { key: "created_at", header: "تاریخ", type: "datetime" }, + ]} + /> + ); +}; + +export default PaymentSettings; diff --git a/frontend/modules/payment/features/transactionDetail.tsx b/frontend/modules/payment/features/transactionDetail.tsx new file mode 100644 index 0000000..8d5d008 --- /dev/null +++ b/frontend/modules/payment/features/transactionDetail.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { useTenantId } from "@/hooks/useTenantId"; +import { paymentApi } from "@/modules/payment/services/payment-api"; +import { PaymentPageLoader, PaymentPageError } from "@/modules/payment/pages/shared"; +import { PageHeader, Card, CardContent, Button } from "@/components/ds"; +import { PaymentStatusChip } from "@/modules/payment/components/PaymentStatusChip"; + +export const PaymentTransactionDetail = function PaymentTransactionDetailPage() { + const params = useParams(); + const id = String(params?.id ?? ""); + const { tenantId } = useTenantId(); + + const q = useQuery({ + queryKey: ["payment", tenantId, "transaction", id], + queryFn: () => paymentApi.transactions.get(tenantId!, id), + enabled: !!tenantId && !!id, + }); + + if (!tenantId || q.isLoading) return ; + if (q.error) return q.refetch()} />; + + const tx = q.data!; + + return ( +
+ + + + ) : null + } + /> + + +
+ وضعیت: + +
+

مبلغ: {String(tx.amount_minor)} {String(tx.currency)}

+

شناسه PSP: {String(tx.provider_transaction_id ?? "—")}

+

تاریخ:{" "} + {tx.created_at ? new Date(String(tx.created_at)).toLocaleString("fa-IR") : "—"} +

+
+
+ + +

داده کامل

+
{JSON.stringify(tx, null, 2)}
+
+
+
+ ); +}; + +export default PaymentTransactionDetail; diff --git a/frontend/modules/payment/features/transactions.tsx b/frontend/modules/payment/features/transactions.tsx new file mode 100644 index 0000000..b222bf9 --- /dev/null +++ b/frontend/modules/payment/features/transactions.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { PaymentListPage } from "@/modules/payment/components/PaymentListPage"; +import { paymentApi } from "@/modules/payment/services/payment-api"; + +export const PaymentTransactions = function PaymentTransactionsPage() { + return ( + paymentApi.transactions.list(tenantId)} + columns={[ + { key: "id", header: "شناسه" }, + { key: "amount_minor", header: "مبلغ (ریال)" }, + { key: "currency", header: "ارز" }, + { key: "status", header: "وضعیت", type: "status" }, + { key: "provider_transaction_id", header: "شناسه PSP" }, + { key: "created_at", header: "تاریخ", type: "datetime" }, + ]} + detailHref={(row) => (row.id ? `/payment/transactions/${row.id}` : null)} + /> + ); +}; + +export default PaymentTransactions; diff --git a/frontend/modules/payment/features/verification.tsx b/frontend/modules/payment/features/verification.tsx new file mode 100644 index 0000000..ea3d519 --- /dev/null +++ b/frontend/modules/payment/features/verification.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import Link from "next/link"; +import { toast } from "sonner"; +import { useTenantId } from "@/hooks/useTenantId"; +import { paymentApi } from "@/modules/payment/services/payment-api"; +import { PaymentPageLoader, PaymentPageError, PaymentEmptyState } from "@/modules/payment/pages/shared"; +import { PageHeader, Card, CardContent, Button } from "@/components/ds"; +import { PaymentStatusChip } from "@/modules/payment/components/PaymentStatusChip"; + +export const PaymentVerification = function PaymentVerificationPage() { + const { tenantId } = useTenantId(); + const qc = useQueryClient(); + + const q = useQuery({ + queryKey: ["payment", tenantId, "verification-queue"], + queryFn: async () => { + const requests = await paymentApi.requests.list(tenantId!); + return requests.filter((r) => ["redirect_issued", "pending"].includes(String(r.status))); + }, + enabled: !!tenantId, + }); + + const verifyM = useMutation({ + mutationFn: (id: string) => paymentApi.requests.verify(tenantId!, id, { status: "paid" }), + onSuccess: () => { + toast.success("پرداخت تأیید شد"); + qc.invalidateQueries({ queryKey: ["payment", tenantId, "verification-queue"] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + + if (!tenantId || q.isLoading) return ; + if (q.error) return q.refetch()} />; + + const queue = q.data ?? []; + + return ( +
+ + {queue.length === 0 ? ( + + ) : ( +
+ {queue.map((r) => ( + + +
+

+ {String(r.amount_minor)} {String(r.currency)} +

+

{String(r.provider_code)}

+
+
+ + + + + +
+
+
+ ))} +
+ )} +
+ ); +}; + +export default PaymentVerification; diff --git a/frontend/modules/payment/hooks/usePaymentCapabilities.ts b/frontend/modules/payment/hooks/usePaymentCapabilities.ts new file mode 100644 index 0000000..505677c --- /dev/null +++ b/frontend/modules/payment/hooks/usePaymentCapabilities.ts @@ -0,0 +1,31 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useTenantId } from "@/hooks/useTenantId"; +import { paymentApi } from "@/modules/payment/services/payment-api"; + +export function usePaymentCapabilities() { + const { tenantId } = useTenantId(); + return useQuery({ + queryKey: ["payment", tenantId, "capabilities"], + queryFn: () => paymentApi.capabilities(tenantId!), + enabled: !!tenantId, + staleTime: 60_000, + }); +} + +export function usePaymentHealth() { + return useQuery({ + queryKey: ["payment", "health"], + queryFn: () => paymentApi.health(), + }); +} + +export function usePaymentMetrics() { + const { tenantId } = useTenantId(); + return useQuery({ + queryKey: ["payment", tenantId, "metrics"], + queryFn: () => paymentApi.metrics(tenantId ?? undefined), + staleTime: 30_000, + }); +} diff --git a/frontend/modules/payment/hooks/usePaymentPermissions.ts b/frontend/modules/payment/hooks/usePaymentPermissions.ts new file mode 100644 index 0000000..33019d6 --- /dev/null +++ b/frontend/modules/payment/hooks/usePaymentPermissions.ts @@ -0,0 +1,30 @@ +"use client"; + +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTenantId } from "@/hooks/useTenantId"; +import { paymentApi } from "@/modules/payment/services/payment-api"; + +type MembershipLike = { tenant_id?: string; permissions?: string[] }; + +export function usePaymentPermissions() { + const { tenantId, me } = useTenantId(); + const catalog = useQuery({ + queryKey: ["payment", tenantId, "permissions-catalog"], + queryFn: () => paymentApi.permissions.catalog(tenantId!), + enabled: !!tenantId, + staleTime: 60_000, + }); + const membership = (me?.memberships as MembershipLike[] | undefined)?.find( + (m) => m.tenant_id === tenantId + ); + const userPerms = useMemo(() => new Set(membership?.permissions ?? []), [membership]); + + function can(permission: string) { + if (userPerms.size === 0) return true; + if (userPerms.has("payment.manage") || userPerms.has("*")) return true; + return userPerms.has(permission); + } + + return { catalog, can, tenantId }; +} diff --git a/frontend/modules/payment/index.ts b/frontend/modules/payment/index.ts new file mode 100644 index 0000000..8c2d68a --- /dev/null +++ b/frontend/modules/payment/index.ts @@ -0,0 +1,11 @@ +export { paymentApi, PaymentApiError, PAYMENT_API_CATALOG } from "./services/payment-api"; +export { PAYMENT_PORTAL, PAYMENT_NAV, navForPayment, filterNavByBundles, MVP_ROUTE_HREFS } from "./constants/portals"; +export { ALL_PAYMENT_PERMISSIONS } from "./constants/permissions"; +export { PaymentPortalShell } from "./components/PaymentPortalShell"; +export { PaymentListPage } from "./components/PaymentListPage"; +export { PaymentStatusChip } from "./components/PaymentStatusChip"; +export { PaymentBarChart, PaymentDonutChart, PaymentStatCards } from "./components/PaymentCharts"; +export { PaymentRootLayout } from "./components/createPortalLayout"; +export { usePaymentCapabilities, usePaymentHealth, usePaymentMetrics } from "./hooks/usePaymentCapabilities"; +export { usePaymentPermissions } from "./hooks/usePaymentPermissions"; +export type { PaymentRecord, CapabilitiesResponse, HealthResponse, MetricsResponse } from "./types"; diff --git a/frontend/modules/payment/pages/shared.tsx b/frontend/modules/payment/pages/shared.tsx new file mode 100644 index 0000000..90071cf --- /dev/null +++ b/frontend/modules/payment/pages/shared.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { LoadingState, ErrorState, EmptyState } from "@/components/ds"; +import { PaymentApiError } from "@/modules/payment/services/payment-api"; +import { PermissionDeniedState } from "@/src/shared/ui"; + +export function PaymentPageLoader({ label = "در حال بارگذاری تربت‌پی…" }: { label?: string }) { + return ; +} + +export function PaymentPageError({ error, onRetry }: { error: unknown; onRetry?: () => void }) { + if (error instanceof PaymentApiError && error.status === 403) { + return ( + + ); + } + const offline = typeof navigator !== "undefined" && !navigator.onLine; + if (offline) { + return ; + } + const message = error instanceof Error ? error.message : "خطا در دریافت داده‌ها"; + return ; +} + +export function PaymentEmptyState({ + title = "موردی یافت نشد", + description = "هنوز داده‌ای از سرویس پرداخت دریافت نشده است.", + action, +}: { + title?: string; + description?: string; + action?: React.ReactNode; +}) { + return ; +} + +export function exportToCsv(title: string, rows: Record[], columns: string[]) { + if (rows.length === 0) return; + const header = columns.join(","); + const body = rows + .map((r) => columns.map((c) => JSON.stringify(r[c] ?? "")).join(",")) + .join("\n"); + const blob = new Blob(["\uFEFF" + header + "\n" + body], { + type: "text/csv;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${title}-export.csv`; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/frontend/modules/payment/services/payment-api.ts b/frontend/modules/payment/services/payment-api.ts new file mode 100644 index 0000000..28ba1a8 --- /dev/null +++ b/frontend/modules/payment/services/payment-api.ts @@ -0,0 +1,210 @@ +/** + * Torbat Pay API client — real backend only (MVP 14.0–14.5). + * Browser → /api/payment BFF; SSR → direct upstream :8012 + */ +import { getValidAccessToken, refreshSession } from "@/lib/auth"; +import type { + CapabilitiesResponse, + HealthResponse, + MetricsResponse, + PaymentRecord, + PermissionsCatalogResponse, +} from "@/modules/payment/types"; + +const PAYMENT_BASE = + typeof window !== "undefined" + ? "/api/payment" + : process.env.PAYMENT_SERVICE_URL || + process.env.NEXT_PUBLIC_PAYMENT_API_URL || + "http://localhost:8012"; + +export class PaymentApiError extends Error { + constructor( + public status: number, + public code: string, + message: string, + public details?: unknown + ) { + super(message); + this.name = "PaymentApiError"; + } +} + +type RequestOptions = RequestInit & { + tenantId: string; + auth?: boolean; + idempotencyKey?: string; +}; + +async function request(path: string, options: RequestOptions): Promise { + const { tenantId, auth = true, idempotencyKey, headers: customHeaders, ...init } = options; + const headers = new Headers(customHeaders); + if (!headers.has("Content-Type") && init.body) headers.set("Content-Type", "application/json"); + headers.set("X-Tenant-ID", tenantId); + if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey); + if (auth) { + const token = await getValidAccessToken(); + if (token) headers.set("Authorization", `Bearer ${token}`); + } + const url = path.startsWith("http") + ? path + : `${PAYMENT_BASE.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`; + + let res: Response; + try { + res = await fetch(url, { ...init, headers, cache: "no-store" }); + } catch (err) { + const detail = err instanceof Error ? err.message : "network_error"; + throw new Error(`اتصال به سرویس تربت‌پی برقرار نشد — ${detail}`); + } + + if (res.status === 401 && auth) { + const refreshed = await refreshSession({ force: true }); + if (refreshed) { + headers.set("Authorization", `Bearer ${refreshed}`); + res = await fetch(url, { ...init, headers, cache: "no-store" }); + } + } + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + const detail = body?.error ?? body?.detail ?? {}; + throw new PaymentApiError( + res.status, + detail?.code || (typeof detail === "string" ? detail : "unknown_error"), + detail?.message || (typeof detail === "string" ? detail : res.statusText), + detail + ); + } + if (res.status === 204) return undefined as T; + return res.json() as Promise; +} + +function crud(basePath: string) { + return { + list: (tenantId: string) => request(basePath, { tenantId }), + get: (tenantId: string, id: string) => request(`${basePath}/${id}`, { tenantId }), + create: (tenantId: string, body: Record) => + request(basePath, { tenantId, method: "POST", body: JSON.stringify(body) }), + update: (tenantId: string, id: string, body: Record) => + request(`${basePath}/${id}`, { + tenantId, + method: "PATCH", + body: JSON.stringify(body), + }), + }; +} + +export const paymentApi = { + health: (): Promise => fetch(`${PAYMENT_BASE}/health`).then((r) => r.json()), + capabilities: (tenantId: string) => + request("/capabilities", { tenantId }), + metrics: (tenantId?: string) => + tenantId + ? request("/metrics", { tenantId }) + : fetch(`${PAYMENT_BASE}/metrics`).then((r) => r.json()), + openapi: async (): Promise> => { + const r = await fetch(`${PAYMENT_BASE}/openapi.json`); + if (!r.ok) throw new PaymentApiError(r.status, "openapi_error", "OpenAPI در دسترس نیست"); + return r.json(); + }, + permissions: { + catalog: (tenantId: string) => + request("/api/v1/permissions/catalog", { tenantId }), + }, + audit: { + list: (tenantId: string) => request("/api/v1/audit", { tenantId }), + }, + workspaces: crud("/api/v1/payment-workspaces"), + bundleDefinitions: crud("/api/v1/bundle-definitions"), + tenantBundles: crud("/api/v1/tenant-bundles"), + featureToggles: crud("/api/v1/feature-toggles"), + providerAssignments: crud("/api/v1/provider-assignments"), + pspRegistrations: crud("/api/v1/psp-provider-registrations"), + creditRegistrations: crud("/api/v1/credit-provider-registrations"), + configurations: crud("/api/v1/configurations"), + settings: crud("/api/v1/settings"), + pspConnections: { + ...crud("/api/v1/psp-connections"), + test: (tenantId: string, id: string) => + request>(`/api/v1/psp-connections/${id}/test`, { + tenantId, + method: "POST", + body: "{}", + }), + }, + pspRouting: crud("/api/v1/psp-routing-policies"), + merchants: { + list: (tenantId: string) => request("/api/v1/merchant-accounts", { tenantId }), + get: (tenantId: string, id: string) => + request(`/api/v1/merchant-accounts/${id}`, { tenantId }), + create: (tenantId: string, body: Record) => + request("/api/v1/merchant-accounts", { + tenantId, + method: "POST", + body: JSON.stringify(body), + }), + submit: (tenantId: string, id: string) => + request(`/api/v1/merchant-accounts/${id}/submit`, { tenantId, method: "POST", body: "{}" }), + activate: (tenantId: string, id: string) => + request(`/api/v1/merchant-accounts/${id}/activate`, { tenantId, method: "POST", body: "{}" }), + suspend: (tenantId: string, id: string) => + request(`/api/v1/merchant-accounts/${id}/suspend`, { tenantId, method: "POST", body: "{}" }), + close: (tenantId: string, id: string) => + request(`/api/v1/merchant-accounts/${id}/close`, { tenantId, method: "POST", body: "{}" }), + }, + settlementProfiles: crud("/api/v1/merchant-settlement-profiles"), + requests: { + list: (tenantId: string) => request("/api/v1/payment-requests", { tenantId }), + get: (tenantId: string, id: string) => + request(`/api/v1/payment-requests/${id}`, { tenantId }), + create: (tenantId: string, body: Record, idempotencyKey: string) => + request("/api/v1/payment-requests", { + tenantId, + method: "POST", + body: JSON.stringify(body), + idempotencyKey, + }), + cancel: (tenantId: string, id: string) => + request(`/api/v1/payment-requests/${id}/cancel`, { tenantId, method: "POST", body: "{}" }), + verify: (tenantId: string, id: string, body?: Record) => + request(`/api/v1/payment-requests/${id}/verify`, { + tenantId, + method: "POST", + body: JSON.stringify(body ?? { status: "paid" }), + }), + }, + transactions: { + list: (tenantId: string) => request("/api/v1/payment-transactions", { tenantId }), + get: (tenantId: string, id: string) => + request(`/api/v1/payment-transactions/${id}`, { tenantId }), + bySource: (tenantId: string, service?: string, refId?: string) => { + const sp = new URLSearchParams(); + if (service) sp.set("service", service); + if (refId) sp.set("ref_id", refId); + const q = sp.toString(); + return request(`/api/v1/payment-transactions/by-source${q ? `?${q}` : ""}`, { + tenantId, + }); + }, + }, + ledger: { + list: (tenantId: string) => request("/api/v1/payment-ledger-entries", { tenantId }), + }, +}; + +export const PAYMENT_API_CATALOG = [ + { method: "GET", path: "/health", phase: "14.0" }, + { method: "GET", path: "/capabilities", phase: "14.0" }, + { method: "GET", path: "/metrics", phase: "14.0" }, + { method: "GET|POST", path: "/api/v1/payment-workspaces", phase: "14.0" }, + { method: "GET|POST", path: "/api/v1/tenant-bundles", phase: "14.0" }, + { method: "GET|POST", path: "/api/v1/feature-toggles", phase: "14.0" }, + { method: "GET|POST", path: "/api/v1/psp-connections", phase: "14.1" }, + { method: "POST", path: "/api/v1/psp-connections/{id}/test", phase: "14.1" }, + { method: "GET|POST", path: "/api/v1/merchant-accounts", phase: "14.2" }, + { method: "POST", path: "/api/v1/payment-requests", phase: "14.3" }, + { method: "POST", path: "/api/v1/payment-requests/{id}/verify", phase: "14.4" }, + { method: "GET", path: "/api/v1/payment-transactions", phase: "14.5" }, + { method: "GET", path: "/api/v1/payment-ledger-entries", phase: "14.5" }, +] as const; diff --git a/frontend/modules/payment/types/index.ts b/frontend/modules/payment/types/index.ts new file mode 100644 index 0000000..5977c78 --- /dev/null +++ b/frontend/modules/payment/types/index.ts @@ -0,0 +1,22 @@ +export type PaymentRecord = Record & { id?: string; status?: string }; + +export type HealthResponse = { status: string; service: string; version: string }; +export type CapabilitiesResponse = { + service: string; + version: string; + phase: string; + workspace_status?: string; + default_payment_mode?: string; + active_bundles?: string[]; + feature_toggles?: Record; + payment_sources_supported?: string[]; + payment_sources_reserved?: string[]; + contract_versions?: Record; +}; +export type MetricsResponse = { + service: string; + version: string; + phase: string; + metrics: Record; +}; +export type PermissionsCatalogResponse = { permissions: string[] }; diff --git a/frontend/package.json b/frontend/package.json index 06f816a..a5986ff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,7 +26,12 @@ "validate:hospitality": "npm run validate:hospitality-routes && npm run validate:architecture", "validate:delivery-routes": "node scripts/validate-delivery-routes.mjs", "validate:delivery": "npm run validate:delivery-routes && npm run validate:architecture", - "scaffold:communication": "node scripts/scaffold-communication-module.mjs" + "validate:experience-routes": "node scripts/validate-experience-routes.mjs", + "validate:experience": "npm run validate:experience-routes && node modules/experience/__tests__/nav-filter.mjs && node modules/experience/__tests__/a11y-smoke.mjs && node modules/experience/__tests__/perf-smoke.mjs && npm run validate:architecture", + "validate:payment-routes": "node scripts/validate-payment-routes.mjs", + "validate:payment": "npm run validate:payment-routes && npm run validate:architecture", + "scaffold:communication": "node scripts/scaffold-communication-module.mjs", + "scaffold:experience": "node scripts/scaffold-experience-module.mjs" }, "dependencies": { "@hookform/resolvers": "^5.4.0", diff --git a/frontend/scripts/scaffold-payment-module.mjs b/frontend/scripts/scaffold-payment-module.mjs new file mode 100644 index 0000000..897fec9 --- /dev/null +++ b/frontend/scripts/scaffold-payment-module.mjs @@ -0,0 +1,478 @@ +#!/usr/bin/env node +/** + * Scaffold Torbat Pay frontend module (MVP payment-14.0–14.5). + * Run: node scripts/scaffold-payment-module.mjs + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, ".."); +const MOD = path.join(ROOT, "modules", "payment"); +const APP = path.join(ROOT, "app", "payment"); + +function write(rel, content) { + const full = path.isAbsolute(rel) ? rel : path.join(ROOT, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content.replace(/\r\n/g, "\n"), "utf8"); + console.log("write", path.relative(ROOT, full)); +} + +const ROUTES = [ + { route: "hub", feature: "hub", title: "هاب تربت‌پی" }, + { route: "dashboard", feature: "dashboard", title: "داشبورد اجرایی" }, + { route: "overview", feature: "overview", title: "نمای کلی پرداخت" }, + { route: "kpis", feature: "kpis", title: "شاخص‌های کلیدی" }, + { route: "transactions", feature: "transactions", title: "تراکنش‌های اخیر" }, + { route: "transactions/[id]", feature: "transactionDetail", title: "جزئیات تراکنش" }, + { route: "ledger", feature: "ledger", title: "دفتر کل پرداخت" }, + { route: "psp", feature: "psp", title: "مدیریت درگاه‌ها" }, + { route: "psp/health", feature: "gatewayHealth", title: "سلامت درگاه" }, + { route: "providers", feature: "providers", title: "تخصیص ارائه‌دهنده" }, + { route: "merchants", feature: "merchants", title: "حساب‌های پذیرنده" }, + { route: "merchants/[id]", feature: "merchantDetail", title: "جزئیات پذیرنده" }, + { route: "merchants/approvals", feature: "merchantApprovals", title: "تأیید پذیرنده" }, + { route: "requests", feature: "requests", title: "درخواست‌های پرداخت" }, + { route: "requests/[id]", feature: "requestDetail", title: "جزئیات درخواست / چک‌اوت" }, + { route: "callbacks", feature: "callbacks", title: "مانیتور کال‌بک" }, + { route: "verification", feature: "verification", title: "مانیتور تأیید" }, + { route: "audit", feature: "audit", title: "لاگ حسابرسی" }, + { route: "bundles", feature: "bundles", title: "باندل‌ها" }, + { route: "feature-toggles", feature: "featureToggles", title: "کلیدهای ویژگی" }, + { route: "capabilities", feature: "capabilities", title: "قابلیت‌ها" }, + { route: "settings", feature: "settings", title: "تنظیمات پرداخت" }, + { route: "metrics", feature: "metrics", title: "متریک‌ها" }, + { route: "monitoring", feature: "monitoring", title: "مانیتورینگ" }, + { route: "api-explorer", feature: "apiExplorer", title: "کاوشگر API" }, + { route: "openapi", feature: "openapi", title: "مشاهده OpenAPI" }, +]; + +// --- core module files --- +write("modules/payment/types/index.ts", `export type PaymentRecord = Record & { id?: string; status?: string }; + +export type HealthResponse = { status: string; service: string; version: string }; +export type CapabilitiesResponse = { + service: string; + version: string; + phase: string; + workspace_status?: string; + default_payment_mode?: string; + active_bundles?: string[]; + feature_toggles?: Record; + payment_sources_supported?: string[]; + payment_sources_reserved?: string[]; + contract_versions?: Record; +}; +export type MetricsResponse = { + service: string; + version: string; + phase: string; + metrics: Record; +}; +export type PermissionsCatalogResponse = { permissions: string[] }; +`); + +write("modules/payment/constants/permissions.ts", `export const PAYMENT_VIEW = "payment.view"; +export const PAYMENT_MANAGE = "payment.manage"; +export const PAYMENT_AUDIT_VIEW = "payment.audit.view"; +export const PAYMENT_SETTINGS_MANAGE = "payment.settings.manage"; +export const PAYMENT_PSP_VIEW = "payment.psp_connections.view"; +export const PAYMENT_PSP_MANAGE = "payment.psp_connections.manage"; +export const PAYMENT_MERCHANT_VIEW = "payment.merchant_accounts.view"; +export const PAYMENT_MERCHANT_MANAGE = "payment.merchant_accounts.manage"; +export const PAYMENT_REQUEST_VIEW = "payment.requests.view"; +export const PAYMENT_REQUEST_CREATE = "payment.requests.create"; +export const PAYMENT_TX_VIEW = "payment.transactions.view"; +export const PAYMENT_LEDGER_VIEW = "payment.ledger.view"; + +export const ALL_PAYMENT_PERMISSIONS = [ + PAYMENT_VIEW, + PAYMENT_MANAGE, + PAYMENT_AUDIT_VIEW, + PAYMENT_SETTINGS_MANAGE, + PAYMENT_PSP_VIEW, + PAYMENT_PSP_MANAGE, + PAYMENT_MERCHANT_VIEW, + PAYMENT_MERCHANT_MANAGE, + PAYMENT_REQUEST_VIEW, + PAYMENT_REQUEST_CREATE, + PAYMENT_TX_VIEW, + PAYMENT_LEDGER_VIEW, +] as const; +`); + +write("modules/payment/constants/portals.ts", `import type { LucideIcon } from "lucide-react"; +import { + LayoutDashboard, + CreditCard, + Building2, + Settings, + Shield, + Activity, + BarChart3, + Wallet, + Plug, + FileText, + BookOpen, + KeyRound, + Gauge, + ScrollText, + Network, + Eye, +} from "lucide-react"; + +export type PortalNavItem = { href: string; label: string; bundle?: string | null }; +export type PortalNavGroup = { + id: string; + label: string; + icon: LucideIcon; + href?: string; + bundle?: string | null; + items?: PortalNavItem[]; +}; + +export const PAYMENT_PORTAL = { + id: "payment" as const, + label: "تربت‌پی", + description: "پلتفرم پرداخت سازمانی — BYO-PSP و پذیرنده", + basePath: "/payment", + icon: CreditCard, +}; + +export const PAYMENT_NAV: PortalNavGroup[] = [ + { + id: "dashboards", + label: "داشبوردها", + icon: LayoutDashboard, + items: [ + { href: "/payment/dashboard", label: "داشبورد اجرایی" }, + { href: "/payment/overview", label: "نمای کلی" }, + { href: "/payment/kpis", label: "شاخص‌های کلیدی" }, + ], + }, + { + id: "operations", + label: "عملیات پرداخت", + icon: Wallet, + items: [ + { href: "/payment/requests", label: "درخواست‌های پرداخت" }, + { href: "/payment/transactions", label: "تراکنش‌ها" }, + { href: "/payment/ledger", label: "دفتر کل" }, + { href: "/payment/callbacks", label: "کال‌بک‌ها" }, + { href: "/payment/verification", label: "تأیید پرداخت" }, + ], + }, + { + id: "psp", + label: "درگاه‌ها (PSP)", + icon: Plug, + bundle: "payment.byo_psp.basic", + items: [ + { href: "/payment/psp", label: "مدیریت درگاه", bundle: "payment.byo_psp.basic" }, + { href: "/payment/psp/health", label: "سلامت درگاه", bundle: "payment.byo_psp.basic" }, + { href: "/payment/providers", label: "تخصیص ارائه‌دهنده" }, + ], + }, + { + id: "merchants", + label: "پذیرندگان", + icon: Building2, + bundle: "payment.torbat_pay.merchant", + items: [ + { href: "/payment/merchants", label: "حساب‌ها", bundle: "payment.torbat_pay.merchant" }, + { href: "/payment/merchants/approvals", label: "تأییدها", bundle: "payment.torbat_pay.merchant" }, + ], + }, + { + id: "licensing", + label: "مجوز و باندل", + icon: KeyRound, + items: [ + { href: "/payment/bundles", label: "باندل‌ها" }, + { href: "/payment/feature-toggles", label: "کلیدهای ویژگی" }, + { href: "/payment/capabilities", label: "قابلیت‌ها" }, + ], + }, + { + id: "ops", + label: "عملیات پلتفرم", + icon: Activity, + items: [ + { href: "/payment/monitoring", label: "مانیتورینگ" }, + { href: "/payment/metrics", label: "متریک‌ها" }, + { href: "/payment/audit", label: "حسابرسی" }, + { href: "/payment/settings", label: "تنظیمات" }, + ], + }, + { + id: "dev", + label: "توسعه‌دهنده", + icon: Network, + items: [ + { href: "/payment/api-explorer", label: "کاوشگر API" }, + { href: "/payment/openapi", label: "OpenAPI" }, + ], + }, +]; + +export function navForPayment() { + return PAYMENT_NAV; +} + +export function filterNavByBundles( + nav: PortalNavGroup[], + activeBundles: string[] | undefined +) { + const bundles = new Set(activeBundles ?? []); + return nav + .map((g) => { + if (g.bundle && !bundles.has(g.bundle)) return null; + const items = g.items?.filter((i) => !i.bundle || bundles.has(i.bundle)); + if (g.items && (!items || items.length === 0)) return null; + return { ...g, items }; + }) + .filter(Boolean) as PortalNavGroup[]; +} + +export const MVP_ROUTE_HREFS = [ + "/payment/hub", + "/payment/dashboard", + "/payment/overview", + "/payment/kpis", + "/payment/transactions", + "/payment/ledger", + "/payment/psp", + "/payment/psp/health", + "/payment/providers", + "/payment/merchants", + "/payment/merchants/approvals", + "/payment/requests", + "/payment/callbacks", + "/payment/verification", + "/payment/audit", + "/payment/bundles", + "/payment/feature-toggles", + "/payment/capabilities", + "/payment/settings", + "/payment/metrics", + "/payment/monitoring", + "/payment/api-explorer", + "/payment/openapi", +] as const; +`); + +write( + "modules/payment/services/payment-api.ts", + `/** + * Torbat Pay API client — real backend only (MVP 14.0–14.5). + * Browser → /api/payment BFF; SSR → direct upstream :8012 + */ +import { getValidAccessToken, refreshSession } from "@/lib/auth"; +import type { + CapabilitiesResponse, + HealthResponse, + MetricsResponse, + PaymentRecord, + PermissionsCatalogResponse, +} from "@/modules/payment/types"; + +const PAYMENT_BASE = + typeof window !== "undefined" + ? "/api/payment" + : process.env.PAYMENT_SERVICE_URL || + process.env.NEXT_PUBLIC_PAYMENT_API_URL || + "http://localhost:8012"; + +export class PaymentApiError extends Error { + constructor( + public status: number, + public code: string, + message: string, + public details?: unknown + ) { + super(message); + this.name = "PaymentApiError"; + } +} + +type RequestOptions = RequestInit & { + tenantId: string; + auth?: boolean; + idempotencyKey?: string; +}; + +async function request(path: string, options: RequestOptions): Promise { + const { tenantId, auth = true, idempotencyKey, headers: customHeaders, ...init } = options; + const headers = new Headers(customHeaders); + if (!headers.has("Content-Type") && init.body) headers.set("Content-Type", "application/json"); + headers.set("X-Tenant-ID", tenantId); + if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey); + if (auth) { + const token = await getValidAccessToken(); + if (token) headers.set("Authorization", \`Bearer \${token}\`); + } + const url = path.startsWith("http") + ? path + : \`\${PAYMENT_BASE.replace(/\\/$/, "")}\${path.startsWith("/") ? path : \`/\${path}\`}\`; + + let res: Response; + try { + res = await fetch(url, { ...init, headers, cache: "no-store" }); + } catch (err) { + const detail = err instanceof Error ? err.message : "network_error"; + throw new Error(\`اتصال به سرویس تربت‌پی برقرار نشد — \${detail}\`); + } + + if (res.status === 401 && auth) { + const refreshed = await refreshSession({ force: true }); + if (refreshed) { + headers.set("Authorization", \`Bearer \${refreshed}\`); + res = await fetch(url, { ...init, headers, cache: "no-store" }); + } + } + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + const detail = body?.error ?? body?.detail ?? {}; + throw new PaymentApiError( + res.status, + detail?.code || (typeof detail === "string" ? detail : "unknown_error"), + detail?.message || (typeof detail === "string" ? detail : res.statusText), + detail + ); + } + if (res.status === 204) return undefined as T; + return res.json() as Promise; +} + +function crud(basePath: string) { + return { + list: (tenantId: string) => request(basePath, { tenantId }), + get: (tenantId: string, id: string) => request(\`\${basePath}/\${id}\`, { tenantId }), + create: (tenantId: string, body: Record) => + request(basePath, { tenantId, method: "POST", body: JSON.stringify(body) }), + update: (tenantId: string, id: string, body: Record) => + request(\`\${basePath}/\${id}\`, { + tenantId, + method: "PATCH", + body: JSON.stringify(body), + }), + }; +} + +export const paymentApi = { + health: (): Promise => fetch(\`\${PAYMENT_BASE}/health\`).then((r) => r.json()), + capabilities: (tenantId: string) => + request("/capabilities", { tenantId }), + metrics: (tenantId?: string) => + tenantId + ? request("/metrics", { tenantId }) + : fetch(\`\${PAYMENT_BASE}/metrics\`).then((r) => r.json()), + openapi: async (): Promise> => { + const r = await fetch(\`\${PAYMENT_BASE}/openapi.json\`); + if (!r.ok) throw new PaymentApiError(r.status, "openapi_error", "OpenAPI در دسترس نیست"); + return r.json(); + }, + permissions: { + catalog: (tenantId: string) => + request("/api/v1/permissions/catalog", { tenantId }), + }, + audit: { + list: (tenantId: string) => request("/api/v1/audit", { tenantId }), + }, + workspaces: crud("/api/v1/payment-workspaces"), + bundleDefinitions: crud("/api/v1/bundle-definitions"), + tenantBundles: crud("/api/v1/tenant-bundles"), + featureToggles: crud("/api/v1/feature-toggles"), + providerAssignments: crud("/api/v1/provider-assignments"), + pspRegistrations: crud("/api/v1/psp-provider-registrations"), + creditRegistrations: crud("/api/v1/credit-provider-registrations"), + configurations: crud("/api/v1/configurations"), + settings: crud("/api/v1/settings"), + pspConnections: { + ...crud("/api/v1/psp-connections"), + test: (tenantId: string, id: string) => + request>(\`/api/v1/psp-connections/\${id}/test\`, { + tenantId, + method: "POST", + body: "{}", + }), + }, + pspRouting: crud("/api/v1/psp-routing-policies"), + merchants: { + list: (tenantId: string) => request("/api/v1/merchant-accounts", { tenantId }), + get: (tenantId: string, id: string) => + request(\`/api/v1/merchant-accounts/\${id}\`, { tenantId }), + create: (tenantId: string, body: Record) => + request("/api/v1/merchant-accounts", { + tenantId, + method: "POST", + body: JSON.stringify(body), + }), + submit: (tenantId: string, id: string) => + request(\`/api/v1/merchant-accounts/\${id}/submit\`, { tenantId, method: "POST", body: "{}" }), + activate: (tenantId: string, id: string) => + request(\`/api/v1/merchant-accounts/\${id}/activate\`, { tenantId, method: "POST", body: "{}" }), + suspend: (tenantId: string, id: string) => + request(\`/api/v1/merchant-accounts/\${id}/suspend\`, { tenantId, method: "POST", body: "{}" }), + close: (tenantId: string, id: string) => + request(\`/api/v1/merchant-accounts/\${id}/close\`, { tenantId, method: "POST", body: "{}" }), + }, + settlementProfiles: crud("/api/v1/merchant-settlement-profiles"), + requests: { + list: (tenantId: string) => request("/api/v1/payment-requests", { tenantId }), + get: (tenantId: string, id: string) => + request(\`/api/v1/payment-requests/\${id}\`, { tenantId }), + create: (tenantId: string, body: Record, idempotencyKey: string) => + request("/api/v1/payment-requests", { + tenantId, + method: "POST", + body: JSON.stringify(body), + idempotencyKey, + }), + cancel: (tenantId: string, id: string) => + request(\`/api/v1/payment-requests/\${id}/cancel\`, { tenantId, method: "POST", body: "{}" }), + verify: (tenantId: string, id: string, body?: Record) => + request(\`/api/v1/payment-requests/\${id}/verify\`, { + tenantId, + method: "POST", + body: JSON.stringify(body ?? { status: "paid" }), + }), + }, + transactions: { + list: (tenantId: string) => request("/api/v1/payment-transactions", { tenantId }), + get: (tenantId: string, id: string) => + request(\`/api/v1/payment-transactions/\${id}\`, { tenantId }), + bySource: (tenantId: string, service?: string, refId?: string) => { + const sp = new URLSearchParams(); + if (service) sp.set("service", service); + if (refId) sp.set("ref_id", refId); + const q = sp.toString(); + return request(\`/api/v1/payment-transactions/by-source\${q ? \`?\${q}\` : ""}\`, { + tenantId, + }); + }, + }, + ledger: { + list: (tenantId: string) => request("/api/v1/payment-ledger-entries", { tenantId }), + }, +}; + +export const PAYMENT_API_CATALOG = [ + { method: "GET", path: "/health", phase: "14.0" }, + { method: "GET", path: "/capabilities", phase: "14.0" }, + { method: "GET", path: "/metrics", phase: "14.0" }, + { method: "GET|POST", path: "/api/v1/payment-workspaces", phase: "14.0" }, + { method: "GET|POST", path: "/api/v1/tenant-bundles", phase: "14.0" }, + { method: "GET|POST", path: "/api/v1/feature-toggles", phase: "14.0" }, + { method: "GET|POST", path: "/api/v1/psp-connections", phase: "14.1" }, + { method: "POST", path: "/api/v1/psp-connections/{id}/test", phase: "14.1" }, + { method: "GET|POST", path: "/api/v1/merchant-accounts", phase: "14.2" }, + { method: "POST", path: "/api/v1/payment-requests", phase: "14.3" }, + { method: "POST", path: "/api/v1/payment-requests/{id}/verify", phase: "14.4" }, + { method: "GET", path: "/api/v1/payment-transactions", phase: "14.5" }, + { method: "GET", path: "/api/v1/payment-ledger-entries", phase: "14.5" }, +] as const; +` +); diff --git a/frontend/scripts/scaffold-payment-routes.mjs b/frontend/scripts/scaffold-payment-routes.mjs new file mode 100644 index 0000000..e3eb1ba --- /dev/null +++ b/frontend/scripts/scaffold-payment-routes.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/** Scaffold payment app routes — thin wrappers to @/modules/payment/features */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const APP = path.join(ROOT, "app", "payment"); + +const ROUTES = [ + { dir: "hub", export: "PaymentHub" }, + { dir: "dashboard", export: "PaymentDashboard" }, + { dir: "overview", export: "PaymentOverview" }, + { dir: "kpis", export: "PaymentKpis" }, + { dir: "transactions", export: "PaymentTransactions" }, + { dir: "transactions/[id]", export: "PaymentTransactionDetail" }, + { dir: "ledger", export: "PaymentLedger" }, + { dir: "psp", export: "PaymentPsp" }, + { dir: "psp/health", export: "PaymentGatewayHealth" }, + { dir: "providers", export: "PaymentProviders" }, + { dir: "merchants", export: "PaymentMerchants" }, + { dir: "merchants/[id]", export: "PaymentMerchantDetail" }, + { dir: "merchants/approvals", export: "PaymentMerchantApprovals" }, + { dir: "requests", export: "PaymentRequests" }, + { dir: "requests/[id]", export: "PaymentRequestDetail" }, + { dir: "callbacks", export: "PaymentCallbacks" }, + { dir: "verification", export: "PaymentVerification" }, + { dir: "audit", export: "PaymentAudit" }, + { dir: "bundles", export: "PaymentBundles" }, + { dir: "feature-toggles", export: "PaymentFeatureToggles" }, + { dir: "capabilities", export: "PaymentCapabilities" }, + { dir: "settings", export: "PaymentSettings" }, + { dir: "metrics", export: "PaymentMetrics" }, + { dir: "monitoring", export: "PaymentMonitoring" }, + { dir: "api-explorer", export: "PaymentApiExplorer" }, + { dir: "openapi", export: "PaymentOpenApi" }, +]; + +const LOADING = `import { LoadingState } from "@/components/ds"; +export default function Loading() { + return ; +} +`; + +const ERROR = `"use client"; +import { ErrorState } from "@/components/ds"; +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ; +} +`; + +function featureImport(exportName, dir) { + const featureMap = { + PaymentHub: "hub", + PaymentDashboard: "dashboard", + PaymentOverview: "overview", + PaymentKpis: "kpis", + PaymentTransactions: "transactions", + PaymentTransactionDetail: "transactionDetail", + PaymentLedger: "ledger", + PaymentPsp: "psp", + PaymentGatewayHealth: "gatewayHealth", + PaymentProviders: "providers", + PaymentMerchants: "merchants", + PaymentMerchantDetail: "merchantDetail", + PaymentMerchantApprovals: "merchantApprovals", + PaymentRequests: "requests", + PaymentRequestDetail: "requestDetail", + PaymentCallbacks: "callbacks", + PaymentVerification: "verification", + PaymentAudit: "audit", + PaymentBundles: "bundles", + PaymentFeatureToggles: "featureToggles", + PaymentCapabilities: "capabilities", + PaymentSettings: "settings", + PaymentMetrics: "metrics", + PaymentMonitoring: "monitoring", + PaymentApiExplorer: "apiExplorer", + PaymentOpenApi: "openapi", + }; + const feature = featureMap[exportName]; + return `"use client"; +export { ${exportName} as default } from "@/modules/payment/features/${feature}"; +`; +} + +for (const { dir, export: exp } of ROUTES) { + const routeDir = path.join(APP, dir); + fs.mkdirSync(routeDir, { recursive: true }); + fs.writeFileSync(path.join(routeDir, "page.tsx"), featureImport(exp, dir)); + fs.writeFileSync(path.join(routeDir, "loading.tsx"), LOADING); + fs.writeFileSync(path.join(routeDir, "error.tsx"), ERROR); +} + +// layout +fs.writeFileSync( + path.join(APP, "layout.tsx"), + `"use client"; + +import { PaymentRootLayout } from "@/modules/payment/components/createPortalLayout"; + +export default PaymentRootLayout; +` +); + +// root page redirect +fs.writeFileSync( + path.join(APP, "page.tsx"), + `import { redirect } from "next/navigation"; + +export default function PaymentIndexPage() { + redirect("/payment/hub"); +} +` +); + +fs.writeFileSync(path.join(APP, "loading.tsx"), LOADING); +fs.writeFileSync(path.join(APP, "error.tsx"), ERROR); + +console.log(`Scaffolded ${ROUTES.length} payment routes`); diff --git a/frontend/scripts/validate-payment-routes.mjs b/frontend/scripts/validate-payment-routes.mjs new file mode 100644 index 0000000..a95aae2 --- /dev/null +++ b/frontend/scripts/validate-payment-routes.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** Validates payment MVP routes and module files exist */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const APP = path.join(ROOT, "app", "payment"); +const MODULE = path.join(ROOT, "modules", "payment"); +const MAX_LINES = 25; +let errors = []; + +const MVP_ROUTES = [ + "hub", + "dashboard", + "overview", + "kpis", + "transactions", + "ledger", + "psp", + "psp/health", + "providers", + "merchants", + "merchants/approvals", + "requests", + "callbacks", + "verification", + "audit", + "bundles", + "feature-toggles", + "capabilities", + "settings", + "metrics", + "monitoring", + "api-explorer", + "openapi", +]; + +const FEATURE_FILES = [ + "hub.tsx", + "dashboard.tsx", + "overview.tsx", + "kpis.tsx", + "transactions.tsx", + "transactionDetail.tsx", + "ledger.tsx", + "psp.tsx", + "gatewayHealth.tsx", + "providers.tsx", + "merchants.tsx", + "merchantDetail.tsx", + "merchantApprovals.tsx", + "requests.tsx", + "requestDetail.tsx", + "callbacks.tsx", + "verification.tsx", + "audit.tsx", + "bundles.tsx", + "featureToggles.tsx", + "capabilities.tsx", + "settings.tsx", + "metrics.tsx", + "monitoring.tsx", + "apiExplorer.tsx", + "openapi.tsx", +]; + +const REQUIRED_FILES = [ + path.join(MODULE, "index.ts"), + path.join(MODULE, "services", "payment-api.ts"), + path.join(ROOT, "app", "api", "payment", "[...path]", "route.ts"), + path.join(APP, "layout.tsx"), + path.join(APP, "page.tsx"), +]; + +function walkPages(dir) { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) walkPages(p); + else if (ent.name === "page.tsx") checkPage(p); + } +} + +function checkPage(file) { + const rel = path.relative(APP, file); + const content = fs.readFileSync(file, "utf8"); + const lines = content.split("\n").length; + if (rel !== "page.tsx" && lines > MAX_LINES) { + errors.push(`${rel}: ${lines} lines (max ${MAX_LINES})`); + } + if (rel !== "page.tsx" && !content.includes("@/modules/payment/")) { + errors.push(`${rel}: must import from @/modules/payment/`); + } +} + +for (const route of MVP_ROUTES) { + for (const file of ["page.tsx", "loading.tsx", "error.tsx"]) { + const p = path.join(APP, route, file); + if (!fs.existsSync(p)) errors.push(`missing app/payment/${route}/${file}`); + } +} + +for (const f of FEATURE_FILES) { + const p = path.join(MODULE, "features", f); + if (!fs.existsSync(p)) errors.push(`missing modules/payment/features/${f}`); +} + +for (const f of REQUIRED_FILES) { + if (!fs.existsSync(f)) errors.push(`missing ${path.relative(ROOT, f)}`); +} + +if (fs.existsSync(APP)) walkPages(APP); +else errors.push("app/payment not found"); + +if (errors.length) { + console.error("Payment route validation failed:\n" + errors.map((e) => " - " + e).join("\n")); + process.exit(1); +} +console.log(`Payment routes OK (${MVP_ROUTES.length} MVP routes, ${FEATURE_FILES.length} features)`);