Spaces:
Running
Running
github-actions commited on
Commit ·
d8ffec9
0
Parent(s):
deploy: clean backend production release
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +19 -0
- .sqlx/query-5574d58fbcbd8d25f772c38a1aa87b08fb00ca58bd635570528271c95bfc9184.json +19 -0
- .sqlx/query-af708ad96d5c6081d4d4ee814d5bd24008ea285217e846f547c8c3d9fec58f25.json +54 -0
- .sqlx/query-c8f23356524b430677693128e4956749f2644b6d8109a974bae1f9b4e32c3a57.json +18 -0
- Cargo.lock +0 -0
- Cargo.toml +54 -0
- Dockerfile +60 -0
- README.md +17 -0
- migrations/0001_baseline.sql +145 -0
- migrations/0002_api_keys.sql +13 -0
- migrations/0003_orders_jsonb_proof.sql +13 -0
- migrations/0004_webhooks.sql +13 -0
- migrations/0005_audit_request_id.sql +7 -0
- migrations/0006_order_risk_flags.sql +3 -0
- migrations/0007_indian_market_hardening.sql +11 -0
- migrations/0008_carrier_registry.sql +17 -0
- migrations/0009_dispute_evidence.sql +17 -0
- migrations/0010_sovereign_intelligence.sql +17 -0
- migrations/0011_forensic_chain.sql +10 -0
- migrations/0012_multi_item_orders.sql +14 -0
- migrations/0013_merchant_trust_score.sql +5 -0
- migrations/0014_product_feedback.sql +12 -0
- migrations/0015_merchant_verification.sql +6 -0
- migrations/0016_carrier_registry.sql +15 -0
- migrations/0017_product_inventory.sql +3 -0
- migrations/0018_merchant_ux_expansion.sql +3 -0
- migrations/0019_growth_suite.sql +20 -0
- migrations/0020_social_growth.sql +5 -0
- migrations/0021_settlement_ledger.sql +18 -0
- migrations/0022_velocity_guard.sql +28 -0
- migrations/0023_merchant_plan.sql +2 -0
- migrations/0024_subscriptions.sql +55 -0
- migrations/0025_payouts.sql +55 -0
- migrations/0026_ai_engineer.sql +23 -0
- migrations/0027_ai_engineer_test_diff.sql +2 -0
- migrations/0028_ai_engineer_feedback.sql +3 -0
- migrations/0029_secure_upi_mandate_hardening.sql +11 -0
- migrations/0030_blind_indexing_and_payout_gating.sql +8 -0
- migrations/0031_oauth_accounts.sql +21 -0
- migrations/0032_add_platform_fee_utr.sql +6 -0
- migrations/0033_postpaid_billing_cycle.sql +19 -0
- src/application/mod.rs +2 -0
- src/application/reconciliation.rs +214 -0
- src/application/services/arbitration.rs +81 -0
- src/application/services/auth.rs +366 -0
- src/application/services/background.rs +299 -0
- src/application/services/billing.rs +125 -0
- src/application/services/checkout.rs +233 -0
- src/application/services/checkout_impl/mod.rs +873 -0
- src/application/services/customer.rs +189 -0
.dockerignore
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.gitignore
|
| 3 |
+
.env
|
| 4 |
+
.env.*
|
| 5 |
+
!.env.example
|
| 6 |
+
target
|
| 7 |
+
/frontend/node_modules
|
| 8 |
+
/frontend/dist
|
| 9 |
+
/frontend/playwright-report
|
| 10 |
+
/frontend/test-results
|
| 11 |
+
node_modules
|
| 12 |
+
*.log
|
| 13 |
+
backend.log
|
| 14 |
+
backend_run.log
|
| 15 |
+
cargo_check.log
|
| 16 |
+
errors.log
|
| 17 |
+
uploads
|
| 18 |
+
scratch
|
| 19 |
+
.DS_Store
|
.sqlx/query-5574d58fbcbd8d25f772c38a1aa87b08fb00ca58bd635570528271c95bfc9184.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"db_name": "PostgreSQL",
|
| 3 |
+
"query": "INSERT INTO idempotency_keys (key, merchant_id, action_scope, request_hash, response_data, status) VALUES ($1, $2, $3, $4, $5, $6)",
|
| 4 |
+
"describe": {
|
| 5 |
+
"columns": [],
|
| 6 |
+
"parameters": {
|
| 7 |
+
"Left": [
|
| 8 |
+
"Varchar",
|
| 9 |
+
"Varchar",
|
| 10 |
+
"Varchar",
|
| 11 |
+
"Varchar",
|
| 12 |
+
"Text",
|
| 13 |
+
"Varchar"
|
| 14 |
+
]
|
| 15 |
+
},
|
| 16 |
+
"nullable": []
|
| 17 |
+
},
|
| 18 |
+
"hash": "5574d58fbcbd8d25f772c38a1aa87b08fb00ca58bd635570528271c95bfc9184"
|
| 19 |
+
}
|
.sqlx/query-af708ad96d5c6081d4d4ee814d5bd24008ea285217e846f547c8c3d9fec58f25.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"db_name": "PostgreSQL",
|
| 3 |
+
"query": "SELECT key, merchant_id, action_scope, request_hash, response_data, status FROM idempotency_keys WHERE key = $1 AND merchant_id = $2 AND action_scope = $3",
|
| 4 |
+
"describe": {
|
| 5 |
+
"columns": [
|
| 6 |
+
{
|
| 7 |
+
"ordinal": 0,
|
| 8 |
+
"name": "key",
|
| 9 |
+
"type_info": "Varchar"
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"ordinal": 1,
|
| 13 |
+
"name": "merchant_id",
|
| 14 |
+
"type_info": "Varchar"
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"ordinal": 2,
|
| 18 |
+
"name": "action_scope",
|
| 19 |
+
"type_info": "Varchar"
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"ordinal": 3,
|
| 23 |
+
"name": "request_hash",
|
| 24 |
+
"type_info": "Varchar"
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"ordinal": 4,
|
| 28 |
+
"name": "response_data",
|
| 29 |
+
"type_info": "Text"
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"ordinal": 5,
|
| 33 |
+
"name": "status",
|
| 34 |
+
"type_info": "Varchar"
|
| 35 |
+
}
|
| 36 |
+
],
|
| 37 |
+
"parameters": {
|
| 38 |
+
"Left": [
|
| 39 |
+
"Text",
|
| 40 |
+
"Text",
|
| 41 |
+
"Text"
|
| 42 |
+
]
|
| 43 |
+
},
|
| 44 |
+
"nullable": [
|
| 45 |
+
false,
|
| 46 |
+
false,
|
| 47 |
+
false,
|
| 48 |
+
false,
|
| 49 |
+
true,
|
| 50 |
+
false
|
| 51 |
+
]
|
| 52 |
+
},
|
| 53 |
+
"hash": "af708ad96d5c6081d4d4ee814d5bd24008ea285217e846f547c8c3d9fec58f25"
|
| 54 |
+
}
|
.sqlx/query-c8f23356524b430677693128e4956749f2644b6d8109a974bae1f9b4e32c3a57.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"db_name": "PostgreSQL",
|
| 3 |
+
"query": "UPDATE idempotency_keys SET response_data = $1, status = $2 WHERE key = $3 AND merchant_id = $4 AND action_scope = $5",
|
| 4 |
+
"describe": {
|
| 5 |
+
"columns": [],
|
| 6 |
+
"parameters": {
|
| 7 |
+
"Left": [
|
| 8 |
+
"Text",
|
| 9 |
+
"Varchar",
|
| 10 |
+
"Text",
|
| 11 |
+
"Text",
|
| 12 |
+
"Text"
|
| 13 |
+
]
|
| 14 |
+
},
|
| 15 |
+
"nullable": []
|
| 16 |
+
},
|
| 17 |
+
"hash": "c8f23356524b430677693128e4956749f2644b6d8109a974bae1f9b4e32c3a57"
|
| 18 |
+
}
|
Cargo.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
Cargo.toml
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[package]
|
| 2 |
+
name = "rtix"
|
| 3 |
+
version = "0.2.0"
|
| 4 |
+
edition = "2021"
|
| 5 |
+
|
| 6 |
+
[dependencies]
|
| 7 |
+
axum = { version = "0.7.5", features = ["ws", "macros"] }
|
| 8 |
+
tokio = { version = "1.38", features = ["full"] }
|
| 9 |
+
serde = { version = "1.0", features = ["derive"] }
|
| 10 |
+
serde_json = "1.0"
|
| 11 |
+
uuid = { version = "1.8", features = ["v4", "serde"] }
|
| 12 |
+
tower-http = { version = "0.5.2", features = ["cors", "trace", "compression-gzip", "compression-br"] }
|
| 13 |
+
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "uuid", "macros", "chrono", "migrate"] }
|
| 14 |
+
dotenvy = "0.15"
|
| 15 |
+
jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] }
|
| 16 |
+
argon2 = "0.5.3"
|
| 17 |
+
rand = "0.8.5"
|
| 18 |
+
tracing = "0.1"
|
| 19 |
+
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
| 20 |
+
base64 = "0.22"
|
| 21 |
+
sha2 = "0.10"
|
| 22 |
+
validator = { version = "0.18", features = ["derive"] }
|
| 23 |
+
tower = { version = "0.4", features = ["util", "buffer", "limit"] }
|
| 24 |
+
regex = "1.10"
|
| 25 |
+
once_cell = "1.19"
|
| 26 |
+
futures-util = "0.3"
|
| 27 |
+
chrono = { version = "0.4", features = ["serde"] }
|
| 28 |
+
thiserror = "1.0"
|
| 29 |
+
async-trait = "0.1"
|
| 30 |
+
metrics = "0.24"
|
| 31 |
+
metrics-exporter-prometheus = "0.16"
|
| 32 |
+
urlencoding = "2.1"
|
| 33 |
+
parking_lot = "0.12"
|
| 34 |
+
dashmap = "6.0"
|
| 35 |
+
mimalloc = "0.1.43"
|
| 36 |
+
smol_str = { version = "0.2.1", features = ["serde"] }
|
| 37 |
+
reqwest = { version = "0.12", features = ["json"] }
|
| 38 |
+
hmac = "0.12"
|
| 39 |
+
hex = "0.4"
|
| 40 |
+
aes-gcm = "0.10"
|
| 41 |
+
aead = "0.5"
|
| 42 |
+
opentelemetry = "0.21"
|
| 43 |
+
opentelemetry_sdk = { version = "0.21", features = ["rt-tokio"] }
|
| 44 |
+
opentelemetry-otlp = { version = "0.14", default-features = false, features = ["http-proto", "reqwest-client", "trace"] }
|
| 45 |
+
tracing-opentelemetry = "0.22"
|
| 46 |
+
tracing-core = "0.1.36"
|
| 47 |
+
octocrab = "0.51.0"
|
| 48 |
+
|
| 49 |
+
[profile.release]
|
| 50 |
+
opt-level = 3
|
| 51 |
+
lto = "thin"
|
| 52 |
+
codegen-units = 16
|
| 53 |
+
panic = "abort"
|
| 54 |
+
strip = true
|
Dockerfile
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Stage 1: Build dependencies & application
|
| 2 |
+
FROM rust:1.94-slim-bookworm AS builder
|
| 3 |
+
USER root
|
| 4 |
+
|
| 5 |
+
# Install system dependencies required for compiling Rust libraries (like openssl, cmake, etc.)
|
| 6 |
+
RUN apt-get update \
|
| 7 |
+
&& apt-get install -y --no-install-recommends pkg-config libssl-dev cmake g++ ca-certificates \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
# Copy dependency manifests first
|
| 13 |
+
COPY Cargo.toml Cargo.lock ./
|
| 14 |
+
|
| 15 |
+
# Create dummy source files so we can cache dependency compilation
|
| 16 |
+
RUN mkdir -p src \
|
| 17 |
+
&& echo "" > src/lib.rs \
|
| 18 |
+
&& echo "fn main() {}" > src/main.rs
|
| 19 |
+
|
| 20 |
+
# Build dependencies in release mode (caching layer)
|
| 21 |
+
RUN cargo build --release
|
| 22 |
+
|
| 23 |
+
# Copy the real source code
|
| 24 |
+
COPY . .
|
| 25 |
+
|
| 26 |
+
# Touch source files to ensure cargo compiles them with the real contents
|
| 27 |
+
RUN touch src/lib.rs src/main.rs
|
| 28 |
+
|
| 29 |
+
# Set SQLx offline compilation mode so we don't need a database connection at compile time
|
| 30 |
+
ENV SQLX_OFFLINE=true
|
| 31 |
+
|
| 32 |
+
# Recompile the actual application
|
| 33 |
+
RUN cargo build --release --bin rtix
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# Stage 2: Minimal Runtime
|
| 37 |
+
FROM debian:bookworm-slim AS runtime
|
| 38 |
+
|
| 39 |
+
# Install runtime SSL dependencies and curl for health check
|
| 40 |
+
RUN apt-get update \
|
| 41 |
+
&& apt-get install -y --no-install-recommends libssl3 ca-certificates curl \
|
| 42 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 43 |
+
|
| 44 |
+
WORKDIR /app
|
| 45 |
+
|
| 46 |
+
# Copy the compiled binary from builder
|
| 47 |
+
COPY --from=builder /app/target/release/rtix /usr/local/bin/rtix
|
| 48 |
+
|
| 49 |
+
# Hugging Face Spaces requires port 7860
|
| 50 |
+
ENV PORT=7860
|
| 51 |
+
ENV SERVER_PORT=7860
|
| 52 |
+
# OAuth defaults (override with HF Secrets in Space settings)
|
| 53 |
+
ENV OAUTH_REDIRECT_BASE=https://gowtham851-rtix.hf.space
|
| 54 |
+
ENV FRONTEND_URL=https://rtix.vercel.app
|
| 55 |
+
EXPOSE 7860
|
| 56 |
+
|
| 57 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
| 58 |
+
CMD curl -fsS "http://127.0.0.1:7860/health" > /dev/null || exit 1
|
| 59 |
+
|
| 60 |
+
CMD ["rtix"]
|
README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Rtix Secure API
|
| 3 |
+
emoji: ⚡
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# Rtix Secure Production API (Hugging Face Spaces)
|
| 11 |
+
|
| 12 |
+
This is the production backend for the Rtix payment-sensitive storefront application.
|
| 13 |
+
|
| 14 |
+
## Specifications
|
| 15 |
+
* **Runtime**: Rust Axum (Dockerized)
|
| 16 |
+
* **Port**: 3000
|
| 17 |
+
* **Host**: 0.0.0.0
|
migrations/0001_baseline.sql
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 001_baseline.sql
|
| 2 |
+
|
| 3 |
+
-- Merchants Table
|
| 4 |
+
CREATE TABLE IF NOT EXISTS merchants (
|
| 5 |
+
merchant_id VARCHAR PRIMARY KEY,
|
| 6 |
+
email VARCHAR NOT NULL UNIQUE,
|
| 7 |
+
password_hash VARCHAR NOT NULL,
|
| 8 |
+
brand_name VARCHAR NOT NULL,
|
| 9 |
+
slug VARCHAR NOT NULL UNIQUE,
|
| 10 |
+
social_url VARCHAR,
|
| 11 |
+
upi_id VARCHAR,
|
| 12 |
+
recovery_key VARCHAR,
|
| 13 |
+
session_version BIGINT NOT NULL DEFAULT 1,
|
| 14 |
+
delivery_rate_per_km DOUBLE PRECISION NOT NULL DEFAULT 10.0,
|
| 15 |
+
delivery_base_fee DOUBLE PRECISION NOT NULL DEFAULT 20.0,
|
| 16 |
+
base_pincode VARCHAR NOT NULL DEFAULT '560001',
|
| 17 |
+
auto_settle_threshold DOUBLE PRECISION DEFAULT 0.5,
|
| 18 |
+
business_address TEXT,
|
| 19 |
+
logistics_config JSONB DEFAULT '{}'::jsonb,
|
| 20 |
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 21 |
+
);
|
| 22 |
+
|
| 23 |
+
-- Product Links Table
|
| 24 |
+
CREATE TABLE IF NOT EXISTS product_links (
|
| 25 |
+
link_id VARCHAR PRIMARY KEY,
|
| 26 |
+
merchant_id VARCHAR NOT NULL,
|
| 27 |
+
product_name VARCHAR NOT NULL,
|
| 28 |
+
price_inr DOUBLE PRECISION NOT NULL,
|
| 29 |
+
image_data TEXT,
|
| 30 |
+
expected_weight DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
| 31 |
+
link_views BIGINT NOT NULL DEFAULT 0,
|
| 32 |
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 33 |
+
);
|
| 34 |
+
|
| 35 |
+
-- Orders Table
|
| 36 |
+
CREATE TABLE IF NOT EXISTS orders (
|
| 37 |
+
transaction_id VARCHAR PRIMARY KEY,
|
| 38 |
+
merchant_id VARCHAR NOT NULL,
|
| 39 |
+
link_id VARCHAR NOT NULL,
|
| 40 |
+
buyer_phone VARCHAR NOT NULL,
|
| 41 |
+
buyer_name VARCHAR NOT NULL DEFAULT '',
|
| 42 |
+
buyer_email VARCHAR NOT NULL DEFAULT '',
|
| 43 |
+
shipping_pincode VARCHAR,
|
| 44 |
+
delivery_address TEXT,
|
| 45 |
+
price_inr DOUBLE PRECISION NOT NULL,
|
| 46 |
+
status VARCHAR NOT NULL,
|
| 47 |
+
vpa VARCHAR NOT NULL DEFAULT '',
|
| 48 |
+
outbound_weight DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
| 49 |
+
return_weight DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
| 50 |
+
proof_data TEXT,
|
| 51 |
+
proof_received_at TIMESTAMP,
|
| 52 |
+
settled_at TIMESTAMP,
|
| 53 |
+
paid_at TIMESTAMP,
|
| 54 |
+
payu_id VARCHAR NOT NULL DEFAULT '',
|
| 55 |
+
shipped_at TIMESTAMP,
|
| 56 |
+
delivered_at TIMESTAMP,
|
| 57 |
+
estimated_delivery_at TIMESTAMP,
|
| 58 |
+
shipping_method VARCHAR,
|
| 59 |
+
is_payment BOOLEAN NOT NULL DEFAULT FALSE,
|
| 60 |
+
platform_fee_paid BOOLEAN NOT NULL DEFAULT FALSE,
|
| 61 |
+
platform_fee DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
| 62 |
+
delivery_fee DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
| 63 |
+
distance_km DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
| 64 |
+
risk_score DOUBLE PRECISION DEFAULT 0.0,
|
| 65 |
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 66 |
+
);
|
| 67 |
+
|
| 68 |
+
-- Feedback Table
|
| 69 |
+
CREATE TABLE IF NOT EXISTS feedback (
|
| 70 |
+
id BIGSERIAL PRIMARY KEY,
|
| 71 |
+
merchant_id VARCHAR,
|
| 72 |
+
category VARCHAR,
|
| 73 |
+
message TEXT NOT NULL,
|
| 74 |
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 75 |
+
);
|
| 76 |
+
|
| 77 |
+
-- Risk Audit Logs Table
|
| 78 |
+
CREATE TABLE IF NOT EXISTS risk_audit_logs (
|
| 79 |
+
id BIGSERIAL PRIMARY KEY,
|
| 80 |
+
transaction_id VARCHAR,
|
| 81 |
+
merchant_id VARCHAR NOT NULL,
|
| 82 |
+
event_type VARCHAR NOT NULL,
|
| 83 |
+
risk_level VARCHAR NOT NULL,
|
| 84 |
+
details TEXT,
|
| 85 |
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 86 |
+
);
|
| 87 |
+
|
| 88 |
+
-- Idempotency Keys Table
|
| 89 |
+
CREATE TABLE IF NOT EXISTS idempotency_keys (
|
| 90 |
+
key VARCHAR NOT NULL,
|
| 91 |
+
merchant_id VARCHAR,
|
| 92 |
+
action_scope VARCHAR NOT NULL DEFAULT 'global',
|
| 93 |
+
request_hash VARCHAR NOT NULL,
|
| 94 |
+
response_data TEXT,
|
| 95 |
+
status VARCHAR NOT NULL,
|
| 96 |
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 97 |
+
expires_at TIMESTAMP,
|
| 98 |
+
PRIMARY KEY (key, merchant_id, action_scope)
|
| 99 |
+
);
|
| 100 |
+
|
| 101 |
+
-- Customers Table
|
| 102 |
+
CREATE TABLE IF NOT EXISTS customers (
|
| 103 |
+
customer_id VARCHAR PRIMARY KEY,
|
| 104 |
+
phone VARCHAR NOT NULL UNIQUE,
|
| 105 |
+
name VARCHAR,
|
| 106 |
+
email VARCHAR,
|
| 107 |
+
password_hash VARCHAR NOT NULL,
|
| 108 |
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 109 |
+
);
|
| 110 |
+
|
| 111 |
+
-- Security Blocks Table
|
| 112 |
+
CREATE TABLE IF NOT EXISTS security_blocks (
|
| 113 |
+
ip VARCHAR PRIMARY KEY,
|
| 114 |
+
reason VARCHAR,
|
| 115 |
+
block_level VARCHAR NOT NULL,
|
| 116 |
+
expires_at TIMESTAMP NOT NULL,
|
| 117 |
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 118 |
+
);
|
| 119 |
+
|
| 120 |
+
-- Login Attempts Table
|
| 121 |
+
CREATE TABLE IF NOT EXISTS login_attempts (
|
| 122 |
+
id BIGSERIAL PRIMARY KEY,
|
| 123 |
+
email VARCHAR NOT NULL,
|
| 124 |
+
ip_address VARCHAR NOT NULL,
|
| 125 |
+
successful BOOLEAN NOT NULL DEFAULT FALSE,
|
| 126 |
+
attempted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 127 |
+
);
|
| 128 |
+
|
| 129 |
+
-- Indices
|
| 130 |
+
CREATE INDEX IF NOT EXISTS idx_merchants_email ON merchants(email);
|
| 131 |
+
CREATE INDEX IF NOT EXISTS idx_merchants_slug ON merchants(slug);
|
| 132 |
+
CREATE INDEX IF NOT EXISTS idx_product_links_merchant ON product_links(merchant_id);
|
| 133 |
+
CREATE INDEX IF NOT EXISTS idx_orders_merchant_status ON orders(merchant_id, status);
|
| 134 |
+
CREATE INDEX IF NOT EXISTS idx_orders_link ON orders(link_id);
|
| 135 |
+
CREATE INDEX IF NOT EXISTS idx_orders_phone ON orders(buyer_phone);
|
| 136 |
+
CREATE INDEX IF NOT EXISTS idx_risk_merchant ON risk_audit_logs(merchant_id);
|
| 137 |
+
CREATE INDEX IF NOT EXISTS idx_login_attempts_email_time ON login_attempts(email, attempted_at);
|
| 138 |
+
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip_time ON login_attempts(ip_address, attempted_at);
|
| 139 |
+
CREATE INDEX IF NOT EXISTS idx_idempotency_lookup ON idempotency_keys(key, merchant_id, action_scope);
|
| 140 |
+
CREATE INDEX IF NOT EXISTS idx_orders_pincode_prefix ON orders ((LEFT(shipping_pincode, 3)));
|
| 141 |
+
CREATE INDEX IF NOT EXISTS idx_orders_status_created ON orders(status, created_at);
|
| 142 |
+
CREATE INDEX IF NOT EXISTS idx_orders_merchant_created ON orders(merchant_id, created_at DESC);
|
| 143 |
+
CREATE INDEX IF NOT EXISTS idx_orders_buyer_merchant ON orders(buyer_phone, merchant_id);
|
| 144 |
+
CREATE INDEX IF NOT EXISTS idx_security_blocks_expires ON security_blocks(ip, expires_at);
|
| 145 |
+
CREATE INDEX IF NOT EXISTS idx_customers_phone_unique ON customers(phone);
|
migrations/0002_api_keys.sql
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0002_api_keys.sql
|
| 2 |
+
|
| 3 |
+
CREATE TABLE IF NOT EXISTS api_keys (
|
| 4 |
+
key_id VARCHAR PRIMARY KEY, -- This is the public part of the key
|
| 5 |
+
merchant_id VARCHAR NOT NULL REFERENCES merchants(merchant_id) ON DELETE CASCADE,
|
| 6 |
+
name VARCHAR NOT NULL DEFAULT 'Default Key',
|
| 7 |
+
secret_hash VARCHAR NOT NULL, -- Hashed secret
|
| 8 |
+
scopes JSONB NOT NULL DEFAULT '["read", "write"]'::jsonb,
|
| 9 |
+
last_used_at TIMESTAMP,
|
| 10 |
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 11 |
+
);
|
| 12 |
+
|
| 13 |
+
CREATE INDEX IF NOT EXISTS idx_api_keys_merchant ON api_keys(merchant_id);
|
migrations/0003_orders_jsonb_proof.sql
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0003_orders_jsonb_proof.sql
|
| 2 |
+
|
| 3 |
+
-- 1. Create a temporary column
|
| 4 |
+
ALTER TABLE orders ADD COLUMN proof_data_new JSONB DEFAULT '[]'::jsonb;
|
| 5 |
+
|
| 6 |
+
-- 2. Migrate existing data
|
| 7 |
+
UPDATE orders
|
| 8 |
+
SET proof_data_new = jsonb_build_array(proof_data)
|
| 9 |
+
WHERE proof_data IS NOT NULL AND proof_data != '';
|
| 10 |
+
|
| 11 |
+
-- 3. Drop old column and rename new one
|
| 12 |
+
ALTER TABLE orders DROP COLUMN proof_data;
|
| 13 |
+
ALTER TABLE orders RENAME COLUMN proof_data_new TO proof_data;
|
migrations/0004_webhooks.sql
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0004_webhooks.sql
|
| 2 |
+
|
| 3 |
+
CREATE TABLE IF NOT EXISTS merchant_webhooks (
|
| 4 |
+
webhook_id VARCHAR PRIMARY KEY,
|
| 5 |
+
merchant_id VARCHAR NOT NULL REFERENCES merchants(merchant_id) ON DELETE CASCADE,
|
| 6 |
+
url TEXT NOT NULL,
|
| 7 |
+
secret VARCHAR NOT NULL,
|
| 8 |
+
events JSONB NOT NULL DEFAULT '["order.created", "order.shipped", "order.delivered", "order.disputed", "risk.alert"]'::jsonb,
|
| 9 |
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 10 |
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 11 |
+
);
|
| 12 |
+
|
| 13 |
+
CREATE INDEX IF NOT EXISTS idx_webhooks_merchant ON merchant_webhooks(merchant_id);
|
migrations/0005_audit_request_id.sql
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0005_audit_request_id.sql
|
| 2 |
+
|
| 3 |
+
ALTER TABLE risk_audit_logs ADD COLUMN request_id VARCHAR;
|
| 4 |
+
ALTER TABLE login_attempts ADD COLUMN request_id VARCHAR;
|
| 5 |
+
|
| 6 |
+
CREATE INDEX IF NOT EXISTS idx_risk_request_id ON risk_audit_logs(request_id);
|
| 7 |
+
CREATE INDEX IF NOT EXISTS idx_login_request_id ON login_attempts(request_id);
|
migrations/0006_order_risk_flags.sql
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0006_order_risk_flags.sql
|
| 2 |
+
|
| 3 |
+
ALTER TABLE orders ADD COLUMN risk_flags JSONB DEFAULT '{}'::jsonb;
|
migrations/0007_indian_market_hardening.sql
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0007_indian_market_hardening.sql
|
| 2 |
+
|
| 3 |
+
ALTER TABLE merchants ADD COLUMN gstin VARCHAR;
|
| 4 |
+
ALTER TABLE merchants ADD COLUMN state_code INT DEFAULT 29; -- Default to Karnataka (29)
|
| 5 |
+
|
| 6 |
+
ALTER TABLE orders ADD COLUMN cgst DOUBLE PRECISION DEFAULT 0.0;
|
| 7 |
+
ALTER TABLE orders ADD COLUMN sgst DOUBLE PRECISION DEFAULT 0.0;
|
| 8 |
+
ALTER TABLE orders ADD COLUMN igst DOUBLE PRECISION DEFAULT 0.0;
|
| 9 |
+
ALTER TABLE orders ADD COLUMN utr_number VARCHAR;
|
| 10 |
+
|
| 11 |
+
CREATE INDEX IF NOT EXISTS idx_orders_utr ON orders(utr_number);
|
migrations/0008_carrier_registry.sql
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0008_carrier_registry.sql
|
| 2 |
+
|
| 3 |
+
CREATE TABLE IF NOT EXISTS carrier_registry (
|
| 4 |
+
carrier_id VARCHAR PRIMARY KEY,
|
| 5 |
+
name VARCHAR NOT NULL,
|
| 6 |
+
service_type VARCHAR NOT NULL, -- 'LOCAL', 'REGIONAL', 'NATIONAL'
|
| 7 |
+
base_rate DOUBLE PRECISION NOT NULL,
|
| 8 |
+
per_kg_rate DOUBLE PRECISION NOT NULL,
|
| 9 |
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 10 |
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 11 |
+
);
|
| 12 |
+
|
| 13 |
+
-- Seed data for standard Indian carriers
|
| 14 |
+
INSERT INTO carrier_registry (carrier_id, name, service_type, base_rate, per_kg_rate) VALUES
|
| 15 |
+
('shadowfax_local', 'Shadowfax Local', 'LOCAL', 40.0, 10.0),
|
| 16 |
+
('delhivery_regional', 'Delhivery Regional', 'REGIONAL', 65.0, 25.0),
|
| 17 |
+
('bluedart_national', 'BlueDart National', 'NATIONAL', 120.0, 45.0);
|
migrations/0009_dispute_evidence.sql
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0009_dispute_evidence.sql
|
| 2 |
+
|
| 3 |
+
CREATE TABLE IF NOT EXISTS dispute_evidence (
|
| 4 |
+
evidence_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 5 |
+
transaction_id VARCHAR NOT NULL REFERENCES orders(transaction_id),
|
| 6 |
+
evidence_url TEXT NOT NULL,
|
| 7 |
+
uploader_role VARCHAR NOT NULL, -- 'MERCHANT', 'BUYER', 'ADMIN'
|
| 8 |
+
metadata JSONB DEFAULT '{}'::jsonb,
|
| 9 |
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 10 |
+
);
|
| 11 |
+
|
| 12 |
+
ALTER TABLE orders ADD COLUMN delivery_gps_lat DOUBLE PRECISION;
|
| 13 |
+
ALTER TABLE orders ADD COLUMN delivery_gps_lng DOUBLE PRECISION;
|
| 14 |
+
ALTER TABLE orders ADD COLUMN is_geofence_verified BOOLEAN;
|
| 15 |
+
|
| 16 |
+
-- New status for held settlements
|
| 17 |
+
-- 'DISPUTED_HELD'
|
migrations/0010_sovereign_intelligence.sql
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0010_secure_intelligence.sql
|
| 2 |
+
|
| 3 |
+
CREATE TABLE IF NOT EXISTS pincode_stats (
|
| 4 |
+
pincode VARCHAR PRIMARY KEY,
|
| 5 |
+
total_orders BIGINT DEFAULT 0,
|
| 6 |
+
total_disputes BIGINT DEFAULT 0,
|
| 7 |
+
avg_delivery_time_hours DOUBLE PRECISION DEFAULT 0.0,
|
| 8 |
+
volatility_score DOUBLE PRECISION DEFAULT 0.0, -- 0.0 to 1.0
|
| 9 |
+
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 10 |
+
);
|
| 11 |
+
|
| 12 |
+
ALTER TABLE merchants ADD COLUMN reliability_score DOUBLE PRECISION DEFAULT 1.0;
|
| 13 |
+
ALTER TABLE merchants ADD COLUMN network_rank INT;
|
| 14 |
+
|
| 15 |
+
ALTER TABLE orders ADD COLUMN pincode_volatility_at_checkout DOUBLE PRECISION DEFAULT 0.0;
|
| 16 |
+
|
| 17 |
+
CREATE INDEX IF NOT EXISTS idx_pincode_volatility ON pincode_stats(volatility_score);
|
migrations/0011_forensic_chain.sql
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0011_smart_chain.sql
|
| 2 |
+
|
| 3 |
+
ALTER TABLE risk_audit_logs ADD COLUMN entry_hash VARCHAR(64);
|
| 4 |
+
ALTER TABLE risk_audit_logs ADD COLUMN previous_hash VARCHAR(64);
|
| 5 |
+
|
| 6 |
+
ALTER TABLE login_attempts ADD COLUMN entry_hash VARCHAR(64);
|
| 7 |
+
ALTER TABLE login_attempts ADD COLUMN previous_hash VARCHAR(64);
|
| 8 |
+
|
| 9 |
+
CREATE INDEX IF NOT EXISTS idx_risk_audit_hash ON risk_audit_logs(entry_hash);
|
| 10 |
+
CREATE INDEX IF NOT EXISTS idx_login_attempts_hash ON login_attempts(entry_hash);
|
migrations/0012_multi_item_orders.sql
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0012_multi_item_orders.sql
|
| 2 |
+
|
| 3 |
+
CREATE TABLE IF NOT EXISTS order_items (
|
| 4 |
+
id BIGSERIAL PRIMARY KEY,
|
| 5 |
+
transaction_id VARCHAR NOT NULL REFERENCES orders(transaction_id) ON DELETE CASCADE,
|
| 6 |
+
product_id VARCHAR NOT NULL,
|
| 7 |
+
product_name VARCHAR NOT NULL,
|
| 8 |
+
quantity INTEGER NOT NULL DEFAULT 1,
|
| 9 |
+
price_at_checkout DOUBLE PRECISION NOT NULL,
|
| 10 |
+
weight_at_checkout DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
| 11 |
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 12 |
+
);
|
| 13 |
+
|
| 14 |
+
CREATE INDEX IF NOT EXISTS idx_order_items_transaction ON order_items(transaction_id);
|
migrations/0013_merchant_trust_score.sql
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Add trust score to merchants to enable autonomous settlement thresholds
|
| 2 |
+
ALTER TABLE merchants ADD COLUMN trust_score DOUBLE PRECISION DEFAULT 100.0;
|
| 3 |
+
|
| 4 |
+
-- Index for analytics and scoring performance
|
| 5 |
+
CREATE INDEX idx_merchants_trust_score ON merchants(trust_score);
|
migrations/0014_product_feedback.sql
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Customer feedback system for product improvement
|
| 2 |
+
CREATE TABLE product_feedback (
|
| 3 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 4 |
+
transaction_id VARCHAR(255) NOT NULL REFERENCES orders(transaction_id),
|
| 5 |
+
product_id UUID NOT NULL,
|
| 6 |
+
rating INTEGER CHECK (rating >= 1 AND rating <= 5),
|
| 7 |
+
comment TEXT,
|
| 8 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 9 |
+
);
|
| 10 |
+
|
| 11 |
+
CREATE INDEX idx_feedback_product ON product_feedback(product_id);
|
| 12 |
+
CREATE INDEX idx_feedback_transaction ON product_feedback(transaction_id);
|
migrations/0015_merchant_verification.sql
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Merchant verification tiers for scaling and risk management
|
| 2 |
+
ALTER TABLE merchants ADD COLUMN verification_level VARCHAR(50) DEFAULT 'UNVERIFIED'; -- UNVERIFIED, SILVER, GOLD, PLATINUM
|
| 3 |
+
ALTER TABLE merchants ADD COLUMN max_order_value_inr DOUBLE PRECISION DEFAULT 10000.0;
|
| 4 |
+
|
| 5 |
+
-- Index for risk-based query optimization
|
| 6 |
+
CREATE INDEX idx_merchants_verification ON merchants(verification_level);
|
migrations/0016_carrier_registry.sql
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Carrier registry for trusted logistics partners
|
| 2 |
+
CREATE TABLE carriers (
|
| 3 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 4 |
+
name VARCHAR(255) NOT NULL,
|
| 5 |
+
api_endpoint VARCHAR(255),
|
| 6 |
+
trust_level VARCHAR(50) DEFAULT 'STANDARD', -- STANDARD, VERIFIED, STRATEGIC
|
| 7 |
+
supported_pincodes TEXT[], -- Optional: array of pincodes they specialize in
|
| 8 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 9 |
+
);
|
| 10 |
+
|
| 11 |
+
-- Seed some initial data
|
| 12 |
+
INSERT INTO carriers (name, trust_level) VALUES
|
| 13 |
+
('Secure Express', 'STRATEGIC'),
|
| 14 |
+
('National Logistics Hub', 'VERIFIED'),
|
| 15 |
+
('Local Rapid Relay', 'STANDARD');
|
migrations/0017_product_inventory.sql
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Basic inventory management for product links
|
| 2 |
+
ALTER TABLE product_links ADD COLUMN inventory_count INTEGER DEFAULT 100;
|
| 3 |
+
ALTER TABLE product_links ADD COLUMN is_unlimited BOOLEAN DEFAULT FALSE;
|
migrations/0018_merchant_ux_expansion.sql
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Add merchant announcements and product categorization
|
| 2 |
+
ALTER TABLE merchants ADD COLUMN announcement_banner TEXT;
|
| 3 |
+
ALTER TABLE product_links ADD COLUMN category TEXT;
|
migrations/0019_growth_suite.sql
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Rtix Growth Suite: Coupons and Featured Products
|
| 2 |
+
CREATE TABLE coupons (
|
| 3 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 4 |
+
merchant_id TEXT NOT NULL REFERENCES merchants(merchant_id) ON DELETE CASCADE,
|
| 5 |
+
code TEXT NOT NULL,
|
| 6 |
+
discount_type TEXT NOT NULL, -- 'PERCENTAGE', 'FIXED'
|
| 7 |
+
discount_value FLOAT8 NOT NULL,
|
| 8 |
+
min_order_amount FLOAT8 DEFAULT 0.0,
|
| 9 |
+
max_discount_amount FLOAT8,
|
| 10 |
+
expiry_date TIMESTAMP,
|
| 11 |
+
is_active BOOLEAN DEFAULT TRUE,
|
| 12 |
+
usage_count INT DEFAULT 0,
|
| 13 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 14 |
+
UNIQUE(merchant_id, code)
|
| 15 |
+
);
|
| 16 |
+
|
| 17 |
+
ALTER TABLE product_links ADD COLUMN is_featured BOOLEAN DEFAULT FALSE;
|
| 18 |
+
|
| 19 |
+
ALTER TABLE orders ADD COLUMN discount_amount FLOAT8 DEFAULT 0.0;
|
| 20 |
+
ALTER TABLE orders ADD COLUMN coupon_code TEXT;
|
migrations/0020_social_growth.sql
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Phase 3: Social Growth & Sales Velocity
|
| 2 |
+
ALTER TABLE product_links ADD COLUMN sale_price_inr DECIMAL(12,2);
|
| 3 |
+
ALTER TABLE product_links ADD COLUMN sale_ends_at TIMESTAMP;
|
| 4 |
+
|
| 5 |
+
ALTER TABLE product_feedback ADD COLUMN is_public BOOLEAN DEFAULT TRUE;
|
migrations/0021_settlement_ledger.sql
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0021_settlement_ledger.sql
|
| 2 |
+
|
| 3 |
+
CREATE TABLE IF NOT EXISTS settlements (
|
| 4 |
+
settlement_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 5 |
+
transaction_id VARCHAR NOT NULL REFERENCES orders(transaction_id),
|
| 6 |
+
merchant_id VARCHAR NOT NULL REFERENCES merchants(merchant_id),
|
| 7 |
+
gross_amount_inr DOUBLE PRECISION NOT NULL,
|
| 8 |
+
platform_fee_inr DOUBLE PRECISION NOT NULL,
|
| 9 |
+
delivery_fee_inr DOUBLE PRECISION NOT NULL,
|
| 10 |
+
tax_amount_inr DOUBLE PRECISION NOT NULL,
|
| 11 |
+
net_payout_inr DOUBLE PRECISION NOT NULL,
|
| 12 |
+
utr_number VARCHAR,
|
| 13 |
+
settled_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 14 |
+
status VARCHAR NOT NULL DEFAULT 'COMPLETED' -- 'COMPLETED', 'FAILED', 'REVERSED'
|
| 15 |
+
);
|
| 16 |
+
|
| 17 |
+
CREATE INDEX idx_settlements_merchant_id ON settlements(merchant_id);
|
| 18 |
+
CREATE INDEX idx_settlements_transaction_id ON settlements(transaction_id);
|
migrations/0022_velocity_guard.sql
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0022_velocity_guard.sql
|
| 2 |
+
|
| 3 |
+
-- Track transaction activity per fingerprint and IP for velocity monitoring
|
| 4 |
+
CREATE TABLE IF NOT EXISTS velocity_metrics (
|
| 5 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 6 |
+
fingerprint VARCHAR,
|
| 7 |
+
ip_address VARCHAR,
|
| 8 |
+
merchant_id VARCHAR REFERENCES merchants(merchant_id),
|
| 9 |
+
activity_count INT DEFAULT 1,
|
| 10 |
+
last_activity_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 11 |
+
window_start_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 12 |
+
is_blocked BOOLEAN DEFAULT FALSE
|
| 13 |
+
);
|
| 14 |
+
|
| 15 |
+
CREATE INDEX idx_velocity_fingerprint ON velocity_metrics(fingerprint);
|
| 16 |
+
CREATE INDEX idx_velocity_ip ON velocity_metrics(ip_address);
|
| 17 |
+
CREATE INDEX idx_velocity_merchant ON velocity_metrics(merchant_id);
|
| 18 |
+
|
| 19 |
+
-- Persistent blacklist for known fraudulent devices
|
| 20 |
+
CREATE TABLE IF NOT EXISTS device_blacklist (
|
| 21 |
+
fingerprint VARCHAR PRIMARY KEY,
|
| 22 |
+
reason TEXT,
|
| 23 |
+
blacklisted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 24 |
+
severity VARCHAR DEFAULT 'HIGH' -- 'MEDIUM', 'HIGH', 'CRITICAL'
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
-- Add device_fingerprint to risk_audit_logs if not exists
|
| 28 |
+
ALTER TABLE risk_audit_logs ADD COLUMN IF NOT EXISTS device_fingerprint VARCHAR;
|
migrations/0023_merchant_plan.sql
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0023_merchant_plan.sql
|
| 2 |
+
ALTER TABLE merchants ADD COLUMN IF NOT EXISTS plan VARCHAR(50) NOT NULL DEFAULT 'FREE';
|
migrations/0024_subscriptions.sql
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0024_subscriptions.sql
|
| 2 |
+
-- Subscription & Recurring Billing Engine for Rtix
|
| 3 |
+
-- Supports SaaS merchants offering recurring payment products.
|
| 4 |
+
|
| 5 |
+
CREATE TABLE IF NOT EXISTS subscription_plans (
|
| 6 |
+
id VARCHAR(36) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
| 7 |
+
merchant_id VARCHAR(36) NOT NULL REFERENCES merchants(merchant_id) ON DELETE CASCADE,
|
| 8 |
+
name VARCHAR(255) NOT NULL,
|
| 9 |
+
description TEXT,
|
| 10 |
+
price_inr NUMERIC(12, 2) NOT NULL CHECK (price_inr > 0),
|
| 11 |
+
interval_days INT NOT NULL CHECK (interval_days > 0), -- e.g. 30 = monthly, 365 = annual
|
| 12 |
+
trial_days INT NOT NULL DEFAULT 0,
|
| 13 |
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 14 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 15 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 16 |
+
);
|
| 17 |
+
|
| 18 |
+
CREATE TABLE IF NOT EXISTS subscriptions (
|
| 19 |
+
id VARCHAR(36) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
| 20 |
+
merchant_id VARCHAR(36) NOT NULL REFERENCES merchants(merchant_id) ON DELETE CASCADE,
|
| 21 |
+
plan_id VARCHAR(36) NOT NULL REFERENCES subscription_plans(id),
|
| 22 |
+
subscriber_email VARCHAR(255) NOT NULL,
|
| 23 |
+
subscriber_phone VARCHAR(20),
|
| 24 |
+
subscriber_name VARCHAR(255) NOT NULL,
|
| 25 |
+
status VARCHAR(20) NOT NULL DEFAULT 'TRIAL'
|
| 26 |
+
CHECK (status IN ('TRIAL', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'EXPIRED')),
|
| 27 |
+
current_period_start TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 28 |
+
current_period_end TIMESTAMPTZ NOT NULL,
|
| 29 |
+
cancelled_at TIMESTAMPTZ,
|
| 30 |
+
cancel_reason TEXT,
|
| 31 |
+
metadata JSONB DEFAULT '{}',
|
| 32 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 33 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 34 |
+
);
|
| 35 |
+
|
| 36 |
+
CREATE TABLE IF NOT EXISTS subscription_billing_events (
|
| 37 |
+
id VARCHAR(36) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
| 38 |
+
subscription_id VARCHAR(36) NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
|
| 39 |
+
merchant_id VARCHAR(36) NOT NULL,
|
| 40 |
+
amount_inr NUMERIC(12, 2) NOT NULL,
|
| 41 |
+
status VARCHAR(20) NOT NULL DEFAULT 'PENDING'
|
| 42 |
+
CHECK (status IN ('PENDING', 'SUCCESS', 'FAILED', 'REFUNDED')),
|
| 43 |
+
transaction_id VARCHAR(36), -- links to orders.transaction_id on success
|
| 44 |
+
attempt_count INT NOT NULL DEFAULT 1,
|
| 45 |
+
billed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 46 |
+
settled_at TIMESTAMPTZ,
|
| 47 |
+
failure_reason TEXT
|
| 48 |
+
);
|
| 49 |
+
|
| 50 |
+
CREATE INDEX IF NOT EXISTS idx_subscriptions_merchant ON subscriptions(merchant_id);
|
| 51 |
+
CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status);
|
| 52 |
+
CREATE INDEX IF NOT EXISTS idx_subscriptions_period_end ON subscriptions(current_period_end) WHERE status = 'ACTIVE';
|
| 53 |
+
CREATE INDEX IF NOT EXISTS idx_billing_events_subscription ON subscription_billing_events(subscription_id);
|
| 54 |
+
CREATE INDEX IF NOT EXISTS idx_billing_events_merchant ON subscription_billing_events(merchant_id);
|
| 55 |
+
CREATE INDEX IF NOT EXISTS idx_subscription_plans_merchant ON subscription_plans(merchant_id);
|
migrations/0025_payouts.sql
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0025_payouts.sql
|
| 2 |
+
-- Automated Payout Engine for Rtix
|
| 3 |
+
-- Tracks bank payout schedules, disbursements, and reconciliation.
|
| 4 |
+
|
| 5 |
+
CREATE TABLE IF NOT EXISTS merchant_bank_accounts (
|
| 6 |
+
id VARCHAR(36) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
| 7 |
+
merchant_id VARCHAR(36) NOT NULL REFERENCES merchants(merchant_id) ON DELETE CASCADE,
|
| 8 |
+
account_holder VARCHAR(255) NOT NULL,
|
| 9 |
+
account_number VARCHAR(50) NOT NULL, -- stored encrypted in practice
|
| 10 |
+
ifsc_code VARCHAR(20) NOT NULL,
|
| 11 |
+
bank_name VARCHAR(100),
|
| 12 |
+
is_primary BOOLEAN NOT NULL DEFAULT TRUE,
|
| 13 |
+
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
| 14 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 15 |
+
UNIQUE(merchant_id, account_number)
|
| 16 |
+
);
|
| 17 |
+
|
| 18 |
+
CREATE TABLE IF NOT EXISTS payouts (
|
| 19 |
+
id VARCHAR(36) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
| 20 |
+
merchant_id VARCHAR(36) NOT NULL REFERENCES merchants(merchant_id) ON DELETE CASCADE,
|
| 21 |
+
bank_account_id VARCHAR(36) REFERENCES merchant_bank_accounts(id),
|
| 22 |
+
amount_inr NUMERIC(14, 2) NOT NULL CHECK (amount_inr > 0),
|
| 23 |
+
fee_inr NUMERIC(10, 2) NOT NULL DEFAULT 0.0,
|
| 24 |
+
net_inr NUMERIC(14, 2) GENERATED ALWAYS AS (amount_inr - fee_inr) STORED,
|
| 25 |
+
status VARCHAR(20) NOT NULL DEFAULT 'PENDING'
|
| 26 |
+
CHECK (status IN ('PENDING', 'PROCESSING', 'SUCCESS', 'FAILED', 'REVERSED')),
|
| 27 |
+
mode VARCHAR(10) NOT NULL DEFAULT 'NEFT'
|
| 28 |
+
CHECK (mode IN ('NEFT', 'IMPS', 'RTGS', 'UPI')),
|
| 29 |
+
utr_number VARCHAR(50), -- Unique Transaction Reference from bank
|
| 30 |
+
initiated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 31 |
+
processed_at TIMESTAMPTZ,
|
| 32 |
+
failure_reason TEXT,
|
| 33 |
+
order_ids JSONB DEFAULT '[]', -- array of settled order IDs in this payout
|
| 34 |
+
notes TEXT
|
| 35 |
+
);
|
| 36 |
+
|
| 37 |
+
CREATE TABLE IF NOT EXISTS notification_log (
|
| 38 |
+
id VARCHAR(36) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
| 39 |
+
merchant_id VARCHAR(36),
|
| 40 |
+
recipient_email VARCHAR(255) NOT NULL,
|
| 41 |
+
event_type VARCHAR(100) NOT NULL, -- e.g. ORDER_PLACED, PAYOUT_SUCCESS, SUB_RENEWAL
|
| 42 |
+
subject VARCHAR(500) NOT NULL,
|
| 43 |
+
status VARCHAR(20) NOT NULL DEFAULT 'SENT'
|
| 44 |
+
CHECK (status IN ('SENT', 'FAILED', 'BOUNCED')),
|
| 45 |
+
provider_id VARCHAR(255), -- external message ID from Resend/SES
|
| 46 |
+
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 47 |
+
error_message TEXT
|
| 48 |
+
);
|
| 49 |
+
|
| 50 |
+
CREATE INDEX IF NOT EXISTS idx_payouts_merchant ON payouts(merchant_id);
|
| 51 |
+
CREATE INDEX IF NOT EXISTS idx_payouts_status ON payouts(status);
|
| 52 |
+
CREATE INDEX IF NOT EXISTS idx_payouts_initiated ON payouts(initiated_at DESC);
|
| 53 |
+
CREATE INDEX IF NOT EXISTS idx_bank_accounts_merchant ON merchant_bank_accounts(merchant_id);
|
| 54 |
+
CREATE INDEX IF NOT EXISTS idx_notification_log_merchant ON notification_log(merchant_id);
|
| 55 |
+
CREATE INDEX IF NOT EXISTS idx_notification_log_event ON notification_log(event_type);
|
migrations/0026_ai_engineer.sql
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
CREATE TABLE IF NOT EXISTS ai_engineer_insights (
|
| 2 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 3 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 4 |
+
status VARCHAR(50) NOT NULL DEFAULT 'PENDING_REVIEW', -- PENDING_REVIEW, APPROVED, REJECTED, IMPLEMENTED
|
| 5 |
+
issue_summary TEXT NOT NULL,
|
| 6 |
+
root_cause_analysis TEXT NOT NULL,
|
| 7 |
+
proposed_solution TEXT NOT NULL,
|
| 8 |
+
suggested_code_diff TEXT, -- Git diff format
|
| 9 |
+
metrics_affected JSONB NOT NULL DEFAULT '{}'::jsonb
|
| 10 |
+
);
|
| 11 |
+
|
| 12 |
+
CREATE TABLE IF NOT EXISTS error_telemetry (
|
| 13 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 14 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 15 |
+
source VARCHAR(50) NOT NULL, -- FRONTEND, BACKEND, SYSTEM
|
| 16 |
+
error_level VARCHAR(20) NOT NULL,
|
| 17 |
+
message TEXT NOT NULL,
|
| 18 |
+
stack_trace TEXT,
|
| 19 |
+
user_context JSONB NOT NULL DEFAULT '{}'::jsonb,
|
| 20 |
+
analyzed BOOLEAN NOT NULL DEFAULT false
|
| 21 |
+
);
|
| 22 |
+
|
| 23 |
+
CREATE INDEX IF NOT EXISTS idx_error_telemetry_analyzed ON error_telemetry(analyzed);
|
migrations/0027_ai_engineer_test_diff.sql
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Migration: Add suggested_test_code_diff to ai_engineer_insights
|
| 2 |
+
ALTER TABLE ai_engineer_insights ADD COLUMN IF NOT EXISTS suggested_test_code_diff TEXT;
|
migrations/0028_ai_engineer_feedback.sql
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Migration: Add pr_url and error_logs to SRE ai_engineer_insights table
|
| 2 |
+
ALTER TABLE ai_engineer_insights ADD COLUMN IF NOT EXISTS pr_url TEXT;
|
| 3 |
+
ALTER TABLE ai_engineer_insights ADD COLUMN IF NOT EXISTS error_logs TEXT;
|
migrations/0029_secure_upi_mandate_hardening.sql
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0029_secure_upi_mandate_hardening.sql
|
| 2 |
+
|
| 3 |
+
-- 1. Create a partial unique index on payu_id to prevent duplicate Razorpay payment IDs (replay attacks)
|
| 4 |
+
-- Ignoring empty strings (which represent unpaid orders)
|
| 5 |
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_payu_id_unique ON orders (payu_id)
|
| 6 |
+
WHERE payu_id <> '';
|
| 7 |
+
|
| 8 |
+
-- 2. Create a partial unique index on utr_number to prevent duplicate direct UPI UTR numbers (double spending)
|
| 9 |
+
-- Ignoring NULLs and empty strings (since unpaid orders have no UTR)
|
| 10 |
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_utr_number_unique ON orders (utr_number)
|
| 11 |
+
WHERE utr_number IS NOT NULL AND utr_number <> '';
|
migrations/0030_blind_indexing_and_payout_gating.sql
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0030_blind_indexing_and_payout_gating.sql
|
| 2 |
+
-- Introduce Cryptographic Blind Indexing for orders
|
| 3 |
+
|
| 4 |
+
-- 1. Add buyer_phone_hash column to allow O(1) indexed searches on encrypted PII
|
| 5 |
+
ALTER TABLE orders ADD COLUMN IF NOT EXISTS buyer_phone_hash VARCHAR;
|
| 6 |
+
|
| 7 |
+
-- 2. Create the index for sub-millisecond directory lookups
|
| 8 |
+
CREATE INDEX IF NOT EXISTS idx_orders_buyer_phone_hash ON orders(buyer_phone_hash);
|
migrations/0031_oauth_accounts.sql
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0031_oauth_accounts.sql
|
| 2 |
+
-- OAuth provider accounts for Google & GitHub sign-in
|
| 3 |
+
-- Links a provider identity to a Rtix merchant account.
|
| 4 |
+
-- One merchant can have multiple OAuth providers linked.
|
| 5 |
+
|
| 6 |
+
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
| 7 |
+
id VARCHAR(36) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
| 8 |
+
merchant_id VARCHAR(36) NOT NULL REFERENCES merchants(merchant_id) ON DELETE CASCADE,
|
| 9 |
+
provider VARCHAR(20) NOT NULL CHECK (provider IN ('google', 'github')),
|
| 10 |
+
provider_user_id VARCHAR(255) NOT NULL,
|
| 11 |
+
email VARCHAR(255) NOT NULL,
|
| 12 |
+
display_name VARCHAR(255),
|
| 13 |
+
avatar_url TEXT,
|
| 14 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 15 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 16 |
+
-- A given provider user can only be linked to one merchant account
|
| 17 |
+
UNIQUE (provider, provider_user_id)
|
| 18 |
+
);
|
| 19 |
+
|
| 20 |
+
CREATE INDEX IF NOT EXISTS idx_oauth_merchant ON oauth_accounts(merchant_id);
|
| 21 |
+
CREATE INDEX IF NOT EXISTS idx_oauth_provider ON oauth_accounts(provider, provider_user_id);
|
migrations/0032_add_platform_fee_utr.sql
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0032_add_platform_fee_utr.sql
|
| 2 |
+
|
| 3 |
+
ALTER TABLE orders ADD COLUMN IF NOT EXISTS platform_fee_utr VARCHAR;
|
| 4 |
+
|
| 5 |
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_platform_fee_utr_unique ON orders (platform_fee_utr)
|
| 6 |
+
WHERE platform_fee_utr IS NOT NULL AND platform_fee_utr <> '';
|
migrations/0033_postpaid_billing_cycle.sql
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0033_postpaid_billing_cycle.sql
|
| 2 |
+
|
| 3 |
+
ALTER TABLE merchants ADD COLUMN IF NOT EXISTS is_frozen BOOLEAN NOT NULL DEFAULT FALSE;
|
| 4 |
+
ALTER TABLE merchants ADD COLUMN IF NOT EXISTS billing_cycle_start TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
| 5 |
+
|
| 6 |
+
CREATE TABLE IF NOT EXISTS merchant_invoices (
|
| 7 |
+
invoice_id VARCHAR PRIMARY KEY,
|
| 8 |
+
merchant_id VARCHAR NOT NULL REFERENCES merchants(merchant_id) ON DELETE CASCADE,
|
| 9 |
+
amount_inr DOUBLE PRECISION NOT NULL,
|
| 10 |
+
order_count INT NOT NULL,
|
| 11 |
+
status VARCHAR NOT NULL, -- 'UNPAID', 'PAID', 'OVERDUE'
|
| 12 |
+
billing_period_start TIMESTAMP NOT NULL,
|
| 13 |
+
billing_period_end TIMESTAMP NOT NULL,
|
| 14 |
+
due_at TIMESTAMP NOT NULL,
|
| 15 |
+
paid_at TIMESTAMP,
|
| 16 |
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 17 |
+
);
|
| 18 |
+
|
| 19 |
+
CREATE INDEX IF NOT EXISTS idx_merchant_invoices_merchant ON merchant_invoices(merchant_id);
|
src/application/mod.rs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pub mod reconciliation;
|
| 2 |
+
pub mod services;
|
src/application/reconciliation.rs
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::time::Duration;
|
| 2 |
+
|
| 3 |
+
use futures_util::stream::{self, StreamExt};
|
| 4 |
+
use sqlx::Row;
|
| 5 |
+
use tokio::time::sleep;
|
| 6 |
+
|
| 7 |
+
use crate::domain::constants::{
|
| 8 |
+
ORDER_STATUS_DELIVERED_PENDING_APPROVAL, ORDER_STATUS_PAID_PENDING_DELIVERY,
|
| 9 |
+
ORDER_STATUS_PAYMENT_FAILED, ORDER_STATUS_PENDING_PAYMENT, ORDER_STATUS_SETTLED,
|
| 10 |
+
};
|
| 11 |
+
use crate::interfaces::http::api::{AppState, RealtimeEvent};
|
| 12 |
+
|
| 13 |
+
pub async fn spawn_reconciliation_worker(state: AppState) {
|
| 14 |
+
tracing::info!("Reconciliation worker active.");
|
| 15 |
+
|
| 16 |
+
let mut interval = tokio::time::interval(Duration::from_secs(300));
|
| 17 |
+
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
| 18 |
+
|
| 19 |
+
loop {
|
| 20 |
+
interval.tick().await;
|
| 21 |
+
metrics::counter!("rtix_reconciliation_cycles_total").increment(1);
|
| 22 |
+
|
| 23 |
+
match reconcile_pending_orders(&state).await {
|
| 24 |
+
Ok(count) if count > 0 => {
|
| 25 |
+
tracing::info!("Reconciliation cycle resolved {} orders.", count);
|
| 26 |
+
metrics::counter!("rtix_reconciliation_resolved_total").increment(count as u64);
|
| 27 |
+
}
|
| 28 |
+
Ok(_) => {}
|
| 29 |
+
Err(e) => {
|
| 30 |
+
tracing::error!("Reconciliation cycle failed: {:?}", e);
|
| 31 |
+
metrics::counter!("rtix_reconciliation_errors_total").increment(1);
|
| 32 |
+
// On DB error, wait a bit before next tick
|
| 33 |
+
sleep(Duration::from_secs(10)).await;
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
pub async fn reconcile_pending_orders(state: &AppState) -> Result<usize, sqlx::Error> {
|
| 40 |
+
let mut reconciled_count = 0;
|
| 41 |
+
|
| 42 |
+
let stale_pending_orders = sqlx::query(
|
| 43 |
+
"UPDATE orders SET status = $1 WHERE status = $2 AND created_at < NOW() - INTERVAL '60 minutes' RETURNING transaction_id, merchant_id",
|
| 44 |
+
)
|
| 45 |
+
.bind(ORDER_STATUS_PAYMENT_FAILED)
|
| 46 |
+
.bind(ORDER_STATUS_PENDING_PAYMENT)
|
| 47 |
+
.fetch_all(&state.pool)
|
| 48 |
+
.await?;
|
| 49 |
+
|
| 50 |
+
if !stale_pending_orders.is_empty() {
|
| 51 |
+
let stale_count = stale_pending_orders.len();
|
| 52 |
+
|
| 53 |
+
let stale_pending_orders_stream = stream::iter(stale_pending_orders)
|
| 54 |
+
.map(|row| {
|
| 55 |
+
let state = state.clone();
|
| 56 |
+
async move {
|
| 57 |
+
let txnid: String = row.get("transaction_id");
|
| 58 |
+
let merchant_id: String = row.get("merchant_id");
|
| 59 |
+
|
| 60 |
+
if let Ok(mut conn) = state.pool.acquire().await {
|
| 61 |
+
crate::domain::audit::log_risk_event(
|
| 62 |
+
&mut conn,
|
| 63 |
+
Some(&txnid),
|
| 64 |
+
&merchant_id,
|
| 65 |
+
"PAYMENT_TIMEOUT",
|
| 66 |
+
"MEDIUM",
|
| 67 |
+
Some("Pending payment expired after 30 minutes without a verified callback."),
|
| 68 |
+
None,
|
| 69 |
+
None,
|
| 70 |
+
None,
|
| 71 |
+
Some(&state.tx),
|
| 72 |
+
)
|
| 73 |
+
.await;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
let _ = state.tx.send(RealtimeEvent::OrderStatusChanged {
|
| 77 |
+
transaction_id: txnid,
|
| 78 |
+
merchant_id,
|
| 79 |
+
new_status: ORDER_STATUS_PAYMENT_FAILED.to_string(),
|
| 80 |
+
});
|
| 81 |
+
}
|
| 82 |
+
})
|
| 83 |
+
.buffer_unordered(10);
|
| 84 |
+
|
| 85 |
+
stale_pending_orders_stream.collect::<Vec<()>>().await;
|
| 86 |
+
reconciled_count += stale_count;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
let aged_shipped_orders = sqlx::query(
|
| 90 |
+
"UPDATE orders SET status = $1 WHERE status = $2 AND shipped_at IS NOT NULL AND shipped_at < NOW() - INTERVAL '7 days' RETURNING transaction_id, merchant_id",
|
| 91 |
+
)
|
| 92 |
+
.bind(ORDER_STATUS_DELIVERED_PENDING_APPROVAL)
|
| 93 |
+
.bind(ORDER_STATUS_PAID_PENDING_DELIVERY)
|
| 94 |
+
.fetch_all(&state.pool)
|
| 95 |
+
.await?;
|
| 96 |
+
|
| 97 |
+
if !aged_shipped_orders.is_empty() {
|
| 98 |
+
let aged_count = aged_shipped_orders.len();
|
| 99 |
+
|
| 100 |
+
let aged_shipped_orders_stream = stream::iter(aged_shipped_orders)
|
| 101 |
+
.map(|row| {
|
| 102 |
+
let state = state.clone();
|
| 103 |
+
async move {
|
| 104 |
+
let txnid: String = row.get("transaction_id");
|
| 105 |
+
let merchant_id: String = row.get("merchant_id");
|
| 106 |
+
|
| 107 |
+
if let Ok(mut conn) = state.pool.acquire().await {
|
| 108 |
+
crate::domain::audit::log_risk_event(
|
| 109 |
+
&mut conn,
|
| 110 |
+
Some(&txnid),
|
| 111 |
+
&merchant_id,
|
| 112 |
+
"AUTO_DELIVERY_CONFIRMATION",
|
| 113 |
+
"LOW",
|
| 114 |
+
Some("Order auto-transitioned after a 7-day shipped window."),
|
| 115 |
+
None,
|
| 116 |
+
None,
|
| 117 |
+
None,
|
| 118 |
+
Some(&state.tx),
|
| 119 |
+
)
|
| 120 |
+
.await;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
let _ = state.tx.send(RealtimeEvent::OrderStatusChanged {
|
| 124 |
+
transaction_id: txnid,
|
| 125 |
+
merchant_id,
|
| 126 |
+
new_status: ORDER_STATUS_DELIVERED_PENDING_APPROVAL.to_string(),
|
| 127 |
+
});
|
| 128 |
+
}
|
| 129 |
+
})
|
| 130 |
+
.buffer_unordered(10);
|
| 131 |
+
|
| 132 |
+
aged_shipped_orders_stream.collect::<Vec<()>>().await;
|
| 133 |
+
reconciled_count += aged_count;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// 3. Autonomous Settlement for Aged Delivered Orders (48-hour window)
|
| 137 |
+
let aged_delivered_orders = sqlx::query(
|
| 138 |
+
"UPDATE orders SET status = $1, settled_at = CURRENT_TIMESTAMP WHERE status = $2 AND delivered_at IS NOT NULL AND delivered_at < NOW() - INTERVAL '48 hours' RETURNING transaction_id, merchant_id, price_inr",
|
| 139 |
+
)
|
| 140 |
+
.bind(crate::domain::constants::ORDER_STATUS_SETTLED)
|
| 141 |
+
.bind(ORDER_STATUS_DELIVERED_PENDING_APPROVAL)
|
| 142 |
+
.fetch_all(&state.pool)
|
| 143 |
+
.await?;
|
| 144 |
+
|
| 145 |
+
if !aged_delivered_orders.is_empty() {
|
| 146 |
+
let aged_delivered_count = aged_delivered_orders.len();
|
| 147 |
+
|
| 148 |
+
let aged_delivered_orders_stream = stream::iter(aged_delivered_orders)
|
| 149 |
+
.map(|row| {
|
| 150 |
+
let state = state.clone();
|
| 151 |
+
async move {
|
| 152 |
+
let txnid: String = row.get("transaction_id");
|
| 153 |
+
let merchant_id: String = row.get("merchant_id");
|
| 154 |
+
let price: f64 = row.get("price_inr");
|
| 155 |
+
|
| 156 |
+
if let Ok(mut conn) = state.pool.acquire().await {
|
| 157 |
+
crate::domain::audit::log_risk_event(
|
| 158 |
+
&mut conn,
|
| 159 |
+
Some(&txnid),
|
| 160 |
+
&merchant_id,
|
| 161 |
+
"AUTONOMOUS_SETTLEMENT",
|
| 162 |
+
"LOW",
|
| 163 |
+
Some(
|
| 164 |
+
"Order auto-settled after 48-hour verification window closed without disputes.",
|
| 165 |
+
),
|
| 166 |
+
None,
|
| 167 |
+
None,
|
| 168 |
+
None,
|
| 169 |
+
Some(&state.tx),
|
| 170 |
+
)
|
| 171 |
+
.await;
|
| 172 |
+
|
| 173 |
+
// Dynamic Trust Scoring: Reward successful autonomous settlement
|
| 174 |
+
// Reuse the acquired connection `conn` here instead of checkout from pool
|
| 175 |
+
let _ = sqlx::query("UPDATE merchants SET trust_score = LEAST(100.0, trust_score + 0.1) WHERE merchant_id = $1")
|
| 176 |
+
.bind(&merchant_id)
|
| 177 |
+
.execute(&mut *conn)
|
| 178 |
+
.await;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
let _ = state.tx.send(RealtimeEvent::OrderStatusChanged {
|
| 182 |
+
transaction_id: txnid,
|
| 183 |
+
merchant_id: merchant_id.clone(),
|
| 184 |
+
new_status: ORDER_STATUS_SETTLED.to_string(),
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
metrics::counter!("rtix_settlement_volume_inr_total").increment(price as u64);
|
| 188 |
+
}
|
| 189 |
+
})
|
| 190 |
+
.buffer_unordered(10);
|
| 191 |
+
|
| 192 |
+
aged_delivered_orders_stream.collect::<Vec<()>>().await;
|
| 193 |
+
reconciled_count += aged_delivered_count;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
// 4. Handle Stale Unpaid Orders (Expiration)
|
| 197 |
+
let expired_count = sqlx::query(
|
| 198 |
+
"UPDATE orders SET status = $1 WHERE status = $2 AND created_at < NOW() - INTERVAL '2 hours'"
|
| 199 |
+
)
|
| 200 |
+
.bind(crate::domain::constants::ORDER_STATUS_PAYMENT_FAILED)
|
| 201 |
+
.bind(crate::domain::constants::ORDER_STATUS_PENDING_PAYMENT)
|
| 202 |
+
.execute(&state.pool)
|
| 203 |
+
.await?
|
| 204 |
+
.rows_affected();
|
| 205 |
+
|
| 206 |
+
if expired_count > 0 {
|
| 207 |
+
tracing::info!(
|
| 208 |
+
"Reconciliation: Expired {} stale unpaid orders.",
|
| 209 |
+
expired_count
|
| 210 |
+
);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
Ok(reconciled_count)
|
| 214 |
+
}
|
src/application/services/arbitration.rs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use crate::domain::error::AppResult;
|
| 2 |
+
use crate::infrastructure::db::DbPool;
|
| 3 |
+
use serde::{Deserialize, Serialize};
|
| 4 |
+
use uuid::Uuid;
|
| 5 |
+
|
| 6 |
+
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
| 7 |
+
pub struct DisputeEvidence {
|
| 8 |
+
pub evidence_id: Uuid,
|
| 9 |
+
pub transaction_id: String,
|
| 10 |
+
pub evidence_url: String,
|
| 11 |
+
pub uploader_role: String,
|
| 12 |
+
pub metadata: serde_json::Value,
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
pub struct ArbitrationService {
|
| 16 |
+
pool: DbPool,
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
impl ArbitrationService {
|
| 20 |
+
pub fn new(pool: DbPool) -> Self {
|
| 21 |
+
Self { pool }
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
pub async fn upload_evidence(
|
| 25 |
+
&self,
|
| 26 |
+
transaction_id: &str,
|
| 27 |
+
evidence_url: &str,
|
| 28 |
+
role: &str,
|
| 29 |
+
) -> AppResult<Uuid> {
|
| 30 |
+
let evidence_id = Uuid::new_v4();
|
| 31 |
+
sqlx::query(
|
| 32 |
+
"INSERT INTO dispute_evidence (evidence_id, transaction_id, evidence_url, uploader_role) VALUES ($1, $2, $3, $4)"
|
| 33 |
+
)
|
| 34 |
+
.bind(evidence_id)
|
| 35 |
+
.bind(transaction_id)
|
| 36 |
+
.bind(evidence_url)
|
| 37 |
+
.bind(role)
|
| 38 |
+
.execute(&self.pool)
|
| 39 |
+
.await?;
|
| 40 |
+
|
| 41 |
+
Ok(evidence_id)
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
pub async fn get_evidence_for_transaction(
|
| 45 |
+
&self,
|
| 46 |
+
transaction_id: &str,
|
| 47 |
+
) -> AppResult<Vec<DisputeEvidence>> {
|
| 48 |
+
let evidence = sqlx::query_as::<_, DisputeEvidence>(
|
| 49 |
+
r#"SELECT evidence_id, transaction_id, evidence_url, uploader_role, metadata FROM dispute_evidence WHERE transaction_id = $1"#
|
| 50 |
+
)
|
| 51 |
+
.bind(transaction_id)
|
| 52 |
+
.fetch_all(&self.pool)
|
| 53 |
+
.await?;
|
| 54 |
+
|
| 55 |
+
Ok(evidence)
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
pub async fn resolve_dispute(
|
| 59 |
+
&self,
|
| 60 |
+
transaction_id: &str,
|
| 61 |
+
resolution: &str, // 'SETTLED', 'REVERSED'
|
| 62 |
+
) -> AppResult<()> {
|
| 63 |
+
let status = match resolution {
|
| 64 |
+
"SETTLED" => crate::domain::constants::ORDER_STATUS_SETTLED,
|
| 65 |
+
"REVERSED" => "REVERSED", // New status
|
| 66 |
+
_ => {
|
| 67 |
+
return Err(crate::domain::error::AppError::BadRequest(
|
| 68 |
+
"Invalid resolution".into(),
|
| 69 |
+
))
|
| 70 |
+
}
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
sqlx::query("UPDATE orders SET status = $1 WHERE transaction_id = $2")
|
| 74 |
+
.bind(status)
|
| 75 |
+
.bind(transaction_id)
|
| 76 |
+
.execute(&self.pool)
|
| 77 |
+
.await?;
|
| 78 |
+
|
| 79 |
+
Ok(())
|
| 80 |
+
}
|
| 81 |
+
}
|
src/application/services/auth.rs
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use crate::domain::error::{AppError, AppResult};
|
| 2 |
+
use crate::domain::models::Merchant;
|
| 3 |
+
use crate::infrastructure::repositories::MerchantRepository;
|
| 4 |
+
use argon2::{
|
| 5 |
+
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
| 6 |
+
Argon2,
|
| 7 |
+
};
|
| 8 |
+
use async_trait::async_trait;
|
| 9 |
+
use dashmap::DashMap;
|
| 10 |
+
use once_cell::sync::Lazy;
|
| 11 |
+
use std::sync::Arc;
|
| 12 |
+
use std::time::{Duration, Instant};
|
| 13 |
+
use uuid::Uuid;
|
| 14 |
+
|
| 15 |
+
// ─── Session Cache ────────────────────────────────────────────────────────────
|
| 16 |
+
|
| 17 |
+
struct CachedSession {
|
| 18 |
+
version: i64,
|
| 19 |
+
expires_at: Instant,
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
static SESSION_CACHE: Lazy<DashMap<String, CachedSession>> = Lazy::new(DashMap::new);
|
| 23 |
+
|
| 24 |
+
// ─── Login Attempt Tracker — Brute-Force Protection ──────────────────────────
|
| 25 |
+
//
|
| 26 |
+
// Keyed by lowercase email address. Tracks consecutive failed attempts within
|
| 27 |
+
// a rolling 15-minute window. After MAX_FAILURES attempts the account is locked
|
| 28 |
+
// until the window expires. A successful login resets the counter.
|
| 29 |
+
//
|
| 30 |
+
// This is intentionally in-memory (not DB) so it:
|
| 31 |
+
// a) adds zero latency to the happy path
|
| 32 |
+
// b) cannot be bypassed by hitting a different DB replica
|
| 33 |
+
// c) auto-recovers on restart (acceptable for in-memory rate limiting)
|
| 34 |
+
//
|
| 35 |
+
// For persistent cross-replica locking, wire this to Redis or a Postgres
|
| 36 |
+
// advisory lock keyed on the email hash.
|
| 37 |
+
|
| 38 |
+
const MAX_FAILURES: u32 = 5;
|
| 39 |
+
const LOCKOUT_WINDOW: Duration = Duration::from_secs(15 * 60); // 15 minutes
|
| 40 |
+
|
| 41 |
+
struct LoginAttempt {
|
| 42 |
+
failures: u32,
|
| 43 |
+
first_failure: Instant,
|
| 44 |
+
locked_until: Option<Instant>,
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
static LOGIN_ATTEMPTS: Lazy<DashMap<String, LoginAttempt>> = Lazy::new(DashMap::new);
|
| 48 |
+
|
| 49 |
+
/// Check if an email is currently locked out. Returns `Err(AppError::RateLimited)`
|
| 50 |
+
/// if the account should be refused, `Ok(())` if the attempt may proceed.
|
| 51 |
+
fn check_lockout(email: &str) -> AppResult<()> {
|
| 52 |
+
let key = email.to_lowercase();
|
| 53 |
+
if let Some(attempt) = LOGIN_ATTEMPTS.get(&key) {
|
| 54 |
+
// If a hard lockout timestamp is set and still in the future, refuse.
|
| 55 |
+
if let Some(locked_until) = attempt.locked_until {
|
| 56 |
+
if Instant::now() < locked_until {
|
| 57 |
+
tracing::warn!(
|
| 58 |
+
email = %email,
|
| 59 |
+
"Login blocked: account locked out after {} failed attempts",
|
| 60 |
+
attempt.failures
|
| 61 |
+
);
|
| 62 |
+
return Err(AppError::RateLimited);
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
// Rolling window: if the first failure was > 15 minutes ago, the window
|
| 66 |
+
// has expired and the counter will be reset on the next record_failure call.
|
| 67 |
+
}
|
| 68 |
+
Ok(())
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/// Record a failed login. Increments the counter; locks the account after
|
| 72 |
+
/// MAX_FAILURES attempts within LOCKOUT_WINDOW.
|
| 73 |
+
fn record_failure(email: &str) {
|
| 74 |
+
let key = email.to_lowercase();
|
| 75 |
+
let now = Instant::now();
|
| 76 |
+
|
| 77 |
+
let mut entry = LOGIN_ATTEMPTS.entry(key).or_insert_with(|| LoginAttempt {
|
| 78 |
+
failures: 0,
|
| 79 |
+
first_failure: now,
|
| 80 |
+
locked_until: None,
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
// Reset window if the previous failure was outside the rolling window
|
| 84 |
+
if now.duration_since(entry.first_failure) > LOCKOUT_WINDOW {
|
| 85 |
+
entry.failures = 0;
|
| 86 |
+
entry.first_failure = now;
|
| 87 |
+
entry.locked_until = None;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
entry.failures += 1;
|
| 91 |
+
|
| 92 |
+
if entry.failures >= MAX_FAILURES {
|
| 93 |
+
entry.locked_until = Some(now + LOCKOUT_WINDOW);
|
| 94 |
+
tracing::warn!(
|
| 95 |
+
email = %email,
|
| 96 |
+
failures = entry.failures,
|
| 97 |
+
"Login lockout triggered: too many failed attempts"
|
| 98 |
+
);
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/// Reset the login attempt counter on successful authentication.
|
| 103 |
+
fn record_success(email: &str) {
|
| 104 |
+
LOGIN_ATTEMPTS.remove(&email.to_lowercase());
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
// ─── Auth Service Trait ───────────────────────────────────────────────────────
|
| 108 |
+
|
| 109 |
+
#[async_trait]
|
| 110 |
+
pub trait AuthService: Send + Sync {
|
| 111 |
+
async fn register(
|
| 112 |
+
&self,
|
| 113 |
+
email: &str,
|
| 114 |
+
password: &str,
|
| 115 |
+
brand_name: &str,
|
| 116 |
+
slug: Option<&str>,
|
| 117 |
+
social_url: Option<&str>,
|
| 118 |
+
upi_id: Option<&str>,
|
| 119 |
+
) -> AppResult<(Merchant, String, String)>;
|
| 120 |
+
async fn login(&self, email: &str, password: &str) -> AppResult<(Merchant, String)>;
|
| 121 |
+
async fn reset_password(
|
| 122 |
+
&self,
|
| 123 |
+
email: &str,
|
| 124 |
+
recovery_key: &str,
|
| 125 |
+
new_password: &str,
|
| 126 |
+
) -> AppResult<()>;
|
| 127 |
+
async fn refresh_token(&self, merchant_id: &str) -> AppResult<String>;
|
| 128 |
+
async fn verify_session(&self, merchant_id: &str, version: i64) -> AppResult<()>;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
pub struct RtixAuthService {
|
| 132 |
+
merchant_repo: Arc<dyn MerchantRepository>,
|
| 133 |
+
jwt_secret: Vec<u8>,
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
impl RtixAuthService {
|
| 137 |
+
pub fn new(merchant_repo: Arc<dyn MerchantRepository>, jwt_secret: Vec<u8>) -> Self {
|
| 138 |
+
Self {
|
| 139 |
+
merchant_repo,
|
| 140 |
+
jwt_secret,
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
fn hash_password(&self, password: &str) -> AppResult<String> {
|
| 145 |
+
let mut rng = rand::thread_rng();
|
| 146 |
+
let salt = SaltString::generate(&mut rng);
|
| 147 |
+
let argon2 = Argon2::default();
|
| 148 |
+
argon2
|
| 149 |
+
.hash_password(password.as_bytes(), &salt)
|
| 150 |
+
.map(|h| h.to_string())
|
| 151 |
+
.map_err(|e| AppError::Internal(format!("Hashing failed: {}", e)))
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
fn verify_password(&self, password: &str, hash: &str) -> AppResult<()> {
|
| 155 |
+
let parsed_hash = PasswordHash::new(hash)
|
| 156 |
+
.map_err(|_| AppError::Internal("Invalid hash stored".to_string()))?;
|
| 157 |
+
Argon2::default()
|
| 158 |
+
.verify_password(password.as_bytes(), &parsed_hash)
|
| 159 |
+
.map_err(|_| AppError::Auth("Invalid credentials".to_string()))
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
fn issue_token(&self, merchant: &crate::domain::models::Merchant) -> AppResult<String> {
|
| 163 |
+
let claims = crate::interfaces::http::routes::auth::Claims {
|
| 164 |
+
sub: merchant.merchant_id.clone(),
|
| 165 |
+
email: merchant.email.clone(),
|
| 166 |
+
brand_name: merchant.brand_name.clone(),
|
| 167 |
+
slug: merchant.slug.clone(),
|
| 168 |
+
role: Some(merchant.role.clone()),
|
| 169 |
+
version: merchant.session_version,
|
| 170 |
+
exp: crate::core::session::access_token_expiry(),
|
| 171 |
+
};
|
| 172 |
+
|
| 173 |
+
jsonwebtoken::encode(
|
| 174 |
+
&jsonwebtoken::Header::default(),
|
| 175 |
+
&claims,
|
| 176 |
+
&jsonwebtoken::EncodingKey::from_secret(&self.jwt_secret),
|
| 177 |
+
)
|
| 178 |
+
.map_err(|e| AppError::Internal(format!("Token issuance failed: {}", e)))
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
#[async_trait]
|
| 183 |
+
impl AuthService for RtixAuthService {
|
| 184 |
+
async fn register(
|
| 185 |
+
&self,
|
| 186 |
+
email: &str,
|
| 187 |
+
password: &str,
|
| 188 |
+
brand_name: &str,
|
| 189 |
+
slug: Option<&str>,
|
| 190 |
+
social_url: Option<&str>,
|
| 191 |
+
upi_id: Option<&str>,
|
| 192 |
+
) -> AppResult<(Merchant, String, String)> {
|
| 193 |
+
let existing: Option<Merchant> = self.merchant_repo.find_by_email(email).await?;
|
| 194 |
+
if existing.is_some() {
|
| 195 |
+
return Err(AppError::BadRequest("Email already exists".to_string()));
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
let merchant_id = Uuid::new_v4().to_string();
|
| 199 |
+
let password_hash = self.hash_password(password)?;
|
| 200 |
+
let recovery_key = format!(
|
| 201 |
+
"VTX-{}",
|
| 202 |
+
&Uuid::new_v4().to_string().replace('-', "")[..16].to_uppercase()
|
| 203 |
+
);
|
| 204 |
+
let recovery_key_hash = self.hash_password(&recovery_key)?;
|
| 205 |
+
|
| 206 |
+
let mut final_slug = slug.unwrap_or(brand_name).to_lowercase().replace(' ', "-");
|
| 207 |
+
|
| 208 |
+
// Pillar IV: Identity Resilience - Automatic collision resolution
|
| 209 |
+
let mut retry_count = 0;
|
| 210 |
+
while self
|
| 211 |
+
.merchant_repo
|
| 212 |
+
.find_by_slug(&final_slug)
|
| 213 |
+
.await?
|
| 214 |
+
.is_some()
|
| 215 |
+
{
|
| 216 |
+
retry_count += 1;
|
| 217 |
+
if retry_count > 5 {
|
| 218 |
+
return Err(AppError::Internal(
|
| 219 |
+
"Could not generate a unique slug".to_string(),
|
| 220 |
+
));
|
| 221 |
+
}
|
| 222 |
+
let suffix = &Uuid::new_v4().to_string()[..4].to_lowercase();
|
| 223 |
+
final_slug = format!(
|
| 224 |
+
"{}-{}",
|
| 225 |
+
slug.unwrap_or(brand_name).to_lowercase().replace(' ', "-"),
|
| 226 |
+
suffix
|
| 227 |
+
);
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
let merchant = Merchant {
|
| 231 |
+
merchant_id: merchant_id.clone(),
|
| 232 |
+
email: email.to_string(),
|
| 233 |
+
password_hash,
|
| 234 |
+
brand_name: brand_name.to_string(),
|
| 235 |
+
slug: final_slug,
|
| 236 |
+
social_url: social_url.map(ToString::to_string),
|
| 237 |
+
upi_id: upi_id.map(ToString::to_string),
|
| 238 |
+
business_address: None,
|
| 239 |
+
recovery_key: Some(recovery_key_hash),
|
| 240 |
+
session_version: 1,
|
| 241 |
+
delivery_rate_per_km: 10.0,
|
| 242 |
+
delivery_base_fee: 20.0,
|
| 243 |
+
logistics_config: serde_json::json!({
|
| 244 |
+
"complexity_bias": 1.0,
|
| 245 |
+
"weight_coefficient": 0.02,
|
| 246 |
+
"distance_coefficient": 1.0
|
| 247 |
+
}),
|
| 248 |
+
base_pincode: "560001".to_string(),
|
| 249 |
+
auto_settle_threshold: 50.0,
|
| 250 |
+
trust_score: 100.0,
|
| 251 |
+
verification_level: "UNVERIFIED".to_string(),
|
| 252 |
+
max_order_value_inr: 10000.0,
|
| 253 |
+
created_at: None,
|
| 254 |
+
state_code: Some(29),
|
| 255 |
+
gstin: None,
|
| 256 |
+
announcement_banner: None,
|
| 257 |
+
plan: "FREE".to_string(),
|
| 258 |
+
role: "MERCHANT".to_string(),
|
| 259 |
+
is_frozen: false,
|
| 260 |
+
billing_cycle_start: None,
|
| 261 |
+
};
|
| 262 |
+
|
| 263 |
+
self.merchant_repo.create(&merchant).await?;
|
| 264 |
+
|
| 265 |
+
let token = self.issue_token(&merchant)?;
|
| 266 |
+
|
| 267 |
+
Ok((merchant, token, recovery_key))
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
/// Authenticate a merchant with brute-force protection.
|
| 271 |
+
///
|
| 272 |
+
/// # Brute-Force Guard
|
| 273 |
+
/// - After **5 consecutive failed attempts** within a 15-minute window the
|
| 274 |
+
/// email is locked and every further attempt returns HTTP 429 immediately
|
| 275 |
+
/// (before any DB query or Argon2 verification, preventing timing oracles).
|
| 276 |
+
/// - A successful login resets the attempt counter.
|
| 277 |
+
/// - The lockout is in-memory and resets on server restart (acceptable for
|
| 278 |
+
/// single-replica deployments; use Redis for multi-replica hardening).
|
| 279 |
+
async fn login(&self, email: &str, password: &str) -> AppResult<(Merchant, String)> {
|
| 280 |
+
// ── Brute-force check BEFORE any DB work ──────────────────────────────
|
| 281 |
+
check_lockout(email)?;
|
| 282 |
+
|
| 283 |
+
let merchant: Option<Merchant> = self.merchant_repo.find_by_email(email).await?;
|
| 284 |
+
let merchant = merchant.ok_or_else(|| {
|
| 285 |
+
// Record failure even on unknown email to prevent user enumeration
|
| 286 |
+
// via timing differences (non-existent vs. wrong-password paths).
|
| 287 |
+
record_failure(email);
|
| 288 |
+
AppError::Auth("Invalid email or password".to_string())
|
| 289 |
+
})?;
|
| 290 |
+
|
| 291 |
+
// ── Argon2 password verification ──────────────────────────────────────
|
| 292 |
+
if let Err(e) = self.verify_password(password, &merchant.password_hash) {
|
| 293 |
+
record_failure(email);
|
| 294 |
+
tracing::warn!(
|
| 295 |
+
email = %email,
|
| 296 |
+
"Failed login attempt recorded"
|
| 297 |
+
);
|
| 298 |
+
return Err(e);
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
// ── Success: clear attempt counter ────────────────────────────────────
|
| 302 |
+
record_success(email);
|
| 303 |
+
|
| 304 |
+
let token = self.issue_token(&merchant)?;
|
| 305 |
+
Ok((merchant, token))
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
async fn reset_password(
|
| 309 |
+
&self,
|
| 310 |
+
email: &str,
|
| 311 |
+
recovery_key: &str,
|
| 312 |
+
new_password: &str,
|
| 313 |
+
) -> AppResult<()> {
|
| 314 |
+
let merchant: Option<Merchant> = self.merchant_repo.find_by_email(email).await?;
|
| 315 |
+
let merchant = merchant.ok_or(AppError::NotFound("Merchant not found".to_string()))?;
|
| 316 |
+
|
| 317 |
+
if let Some(hash) = &merchant.recovery_key {
|
| 318 |
+
self.verify_password(recovery_key, hash)?;
|
| 319 |
+
} else {
|
| 320 |
+
return Err(AppError::Auth("No recovery key set".to_string()));
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
let new_hash = self.hash_password(new_password)?;
|
| 324 |
+
self.merchant_repo.update_password(email, &new_hash).await?;
|
| 325 |
+
|
| 326 |
+
Ok(())
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
async fn refresh_token(&self, merchant_id: &str) -> AppResult<String> {
|
| 330 |
+
let merchant = self.merchant_repo.find_by_id(merchant_id).await?;
|
| 331 |
+
let merchant = merchant.ok_or(AppError::NotFound("Merchant not found".to_string()))?;
|
| 332 |
+
|
| 333 |
+
let token = self.issue_token(&merchant)?;
|
| 334 |
+
Ok(token)
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
async fn verify_session(&self, merchant_id: &str, version: i64) -> AppResult<()> {
|
| 338 |
+
let now = Instant::now();
|
| 339 |
+
if let Some(cached) = SESSION_CACHE.get(merchant_id) {
|
| 340 |
+
if cached.version == version && cached.expires_at > now {
|
| 341 |
+
return Ok(());
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
let merchant = self.merchant_repo.find_by_id(merchant_id).await?;
|
| 346 |
+
let merchant = merchant.ok_or(AppError::Auth("Session invalid".to_string()))?;
|
| 347 |
+
|
| 348 |
+
if merchant.session_version != version {
|
| 349 |
+
return Err(AppError::Auth("Session version mismatch".to_string()));
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
if !SESSION_CACHE.contains_key(merchant_id) && SESSION_CACHE.len() > 10_000 {
|
| 353 |
+
SESSION_CACHE.retain(|_, v| v.expires_at > now);
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
SESSION_CACHE.insert(
|
| 357 |
+
merchant_id.to_string(),
|
| 358 |
+
CachedSession {
|
| 359 |
+
version,
|
| 360 |
+
expires_at: now + Duration::from_secs(60),
|
| 361 |
+
},
|
| 362 |
+
);
|
| 363 |
+
|
| 364 |
+
Ok(())
|
| 365 |
+
}
|
| 366 |
+
}
|
src/application/services/background.rs
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use crate::application::services::MerchantService;
|
| 2 |
+
use crate::domain::constants::*;
|
| 3 |
+
use crate::infrastructure::db::DbPool;
|
| 4 |
+
use futures_util::stream::{self, StreamExt};
|
| 5 |
+
use std::sync::Arc;
|
| 6 |
+
use std::time::Duration;
|
| 7 |
+
use tokio::time::sleep;
|
| 8 |
+
|
| 9 |
+
pub struct ProtocolSentinel {
|
| 10 |
+
merchant_service: Arc<dyn MerchantService>,
|
| 11 |
+
pool: DbPool,
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
impl ProtocolSentinel {
|
| 15 |
+
pub fn new(merchant_service: Arc<dyn MerchantService>, pool: DbPool) -> Self {
|
| 16 |
+
Self {
|
| 17 |
+
merchant_service,
|
| 18 |
+
pool,
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
pub async fn run(&self) {
|
| 23 |
+
tracing::info!("Protocol Sentinel Active: Secure Health Guard Initialized.");
|
| 24 |
+
loop {
|
| 25 |
+
// 1. Process Auto-Settlements (Liquidity Guard)
|
| 26 |
+
if let Err(e) = self.process_auto_settlements().await {
|
| 27 |
+
tracing::error!("Sentinel Error [Auto-Settlement]: {:?}", e);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// 2. Cleanup Stale Intents (Resource Guard)
|
| 31 |
+
if let Err(e) = self.cleanup_stale_intents().await {
|
| 32 |
+
tracing::error!("Sentinel Error [Stale-Cleanup]: {:?}", e);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// 3. Finalize Aged Deliveries (Custodial Guard)
|
| 36 |
+
if let Err(e) = self.enforce_custodial_deadlines().await {
|
| 37 |
+
tracing::error!("Sentinel Error [Custodial-Enforcement]: {:?}", e);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// 4. Escalate Stale Forensic Holds (Arbitration Guard)
|
| 41 |
+
if let Err(e) = self.escalate_stale_holds().await {
|
| 42 |
+
tracing::error!("Sentinel Error [Hold-Escalation]: {:?}", e);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// 5. Generate Monthly Billing (Postpaid Billing Guard)
|
| 46 |
+
if let Err(e) = self.generate_monthly_billing().await {
|
| 47 |
+
tracing::error!("Sentinel Error [Monthly-Billing]: {:?}", e);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// 6. Freeze Overdue Merchants (Billing Enforcement Guard)
|
| 51 |
+
if let Err(e) = self.freeze_overdue_merchants().await {
|
| 52 |
+
tracing::error!("Sentinel Error [Billing-Enforcement]: {:?}", e);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// ─── Jitter Sleep ──────────────────────────────────────────────────
|
| 56 |
+
// Add ±300s random jitter to the 1-hour base interval.
|
| 57 |
+
// This prevents the thundering herd problem on cold starts / restarts
|
| 58 |
+
// where every replica would otherwise fire all tasks in perfect sync,
|
| 59 |
+
// creating a coordinated spike against the database every 3600 seconds.
|
| 60 |
+
let jitter_secs = rand::random::<u64>() % 300;
|
| 61 |
+
let sleep_duration = Duration::from_secs(3600 + jitter_secs);
|
| 62 |
+
tracing::debug!(
|
| 63 |
+
"Protocol Sentinel: sleeping {}s until next pulse (base 3600s + {}s jitter)",
|
| 64 |
+
sleep_duration.as_secs(),
|
| 65 |
+
jitter_secs
|
| 66 |
+
);
|
| 67 |
+
sleep(sleep_duration).await;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
async fn escalate_stale_holds(&self) -> Result<(), sqlx::Error> {
|
| 72 |
+
// Escalate DISPUTED_HELD orders after 48 hours to DISPUTED_IN_REVIEW
|
| 73 |
+
let result = sqlx::query(
|
| 74 |
+
"UPDATE orders SET status = $1 WHERE status = $2 AND created_at < CURRENT_TIMESTAMP - INTERVAL '48 hours'"
|
| 75 |
+
)
|
| 76 |
+
.bind(ORDER_STATUS_DISPUTED)
|
| 77 |
+
.bind(ORDER_STATUS_DISPUTED_HELD)
|
| 78 |
+
.execute(&self.pool)
|
| 79 |
+
.await?;
|
| 80 |
+
|
| 81 |
+
if result.rows_affected() > 0 {
|
| 82 |
+
tracing::info!(
|
| 83 |
+
"Sentinel: Escalated {} stale forensic holds to full review.",
|
| 84 |
+
result.rows_affected()
|
| 85 |
+
);
|
| 86 |
+
}
|
| 87 |
+
Ok(())
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
async fn process_auto_settlements(&self) -> Result<(), sqlx::Error> {
|
| 91 |
+
// ── FOR UPDATE SKIP LOCKED ─────────────────────────────────────────────
|
| 92 |
+
// SKIP LOCKED ensures each replica claims a disjoint set of rows.
|
| 93 |
+
let eligible_orders = sqlx::query(
|
| 94 |
+
r#"
|
| 95 |
+
SELECT o.transaction_id, o.merchant_id, m.auto_settle_threshold, o.risk_score
|
| 96 |
+
FROM orders o
|
| 97 |
+
JOIN merchants m ON o.merchant_id = m.merchant_id
|
| 98 |
+
WHERE o.status = $1
|
| 99 |
+
AND o.delivered_at < CURRENT_TIMESTAMP - INTERVAL '24 hours'
|
| 100 |
+
AND o.risk_score <= m.auto_settle_threshold
|
| 101 |
+
FOR UPDATE SKIP LOCKED
|
| 102 |
+
"#,
|
| 103 |
+
)
|
| 104 |
+
.bind(ORDER_STATUS_DELIVERED_PENDING_APPROVAL)
|
| 105 |
+
.fetch_all(&self.pool)
|
| 106 |
+
.await?;
|
| 107 |
+
|
| 108 |
+
let merchant_service = self.merchant_service.clone();
|
| 109 |
+
let _results = stream::iter(eligible_orders)
|
| 110 |
+
.map(|order| {
|
| 111 |
+
let ms = merchant_service.clone();
|
| 112 |
+
async move {
|
| 113 |
+
use sqlx::Row;
|
| 114 |
+
let tid: String = order.get("transaction_id");
|
| 115 |
+
let mid: String = order.get("merchant_id");
|
| 116 |
+
|
| 117 |
+
let _ = ms.approve_settlement(&mid, &tid, None, None, None).await;
|
| 118 |
+
}
|
| 119 |
+
})
|
| 120 |
+
.buffer_unordered(10)
|
| 121 |
+
.collect::<Vec<()>>()
|
| 122 |
+
.await;
|
| 123 |
+
|
| 124 |
+
Ok(())
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
async fn cleanup_stale_intents(&self) -> Result<(), sqlx::Error> {
|
| 128 |
+
// Expire orders stuck in PENDING_PAYMENT for > 6 hours
|
| 129 |
+
let result = sqlx::query(
|
| 130 |
+
"UPDATE orders SET status = 'EXPIRED_VOID' WHERE status = $1 AND created_at < CURRENT_TIMESTAMP - INTERVAL '6 hours'"
|
| 131 |
+
)
|
| 132 |
+
.bind(ORDER_STATUS_PENDING_PAYMENT)
|
| 133 |
+
.execute(&self.pool)
|
| 134 |
+
.await?;
|
| 135 |
+
|
| 136 |
+
if result.rows_affected() > 0 {
|
| 137 |
+
tracing::info!(
|
| 138 |
+
"Sentinel: Purged {} stale payment intents.",
|
| 139 |
+
result.rows_affected()
|
| 140 |
+
);
|
| 141 |
+
}
|
| 142 |
+
Ok(())
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
async fn enforce_custodial_deadlines(&self) -> Result<(), sqlx::Error> {
|
| 146 |
+
// ── FOR UPDATE SKIP LOCKED ─────────────────────────────────────────────
|
| 147 |
+
let eligible_orders = sqlx::query(
|
| 148 |
+
"SELECT transaction_id, merchant_id FROM orders WHERE status = $1 AND delivered_at < CURRENT_TIMESTAMP - INTERVAL '48 hours' FOR UPDATE SKIP LOCKED"
|
| 149 |
+
)
|
| 150 |
+
.bind(ORDER_STATUS_DELIVERED_PENDING_APPROVAL)
|
| 151 |
+
.fetch_all(&self.pool)
|
| 152 |
+
.await?;
|
| 153 |
+
|
| 154 |
+
let merchant_service = self.merchant_service.clone();
|
| 155 |
+
let _results = stream::iter(eligible_orders)
|
| 156 |
+
.map(|order| {
|
| 157 |
+
let ms = merchant_service.clone();
|
| 158 |
+
async move {
|
| 159 |
+
use sqlx::Row;
|
| 160 |
+
let tid: String = order.get("transaction_id");
|
| 161 |
+
let mid: String = order.get("merchant_id");
|
| 162 |
+
|
| 163 |
+
tracing::info!(
|
| 164 |
+
"Sentinel: Enforcing custodial deadline for {}. Finalizing liquidity.",
|
| 165 |
+
tid
|
| 166 |
+
);
|
| 167 |
+
let _ = ms.approve_settlement(&mid, &tid, None, None, None).await;
|
| 168 |
+
}
|
| 169 |
+
})
|
| 170 |
+
.buffer_unordered(10)
|
| 171 |
+
.collect::<Vec<()>>()
|
| 172 |
+
.await;
|
| 173 |
+
|
| 174 |
+
Ok(())
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
pub async fn generate_monthly_billing(&self) -> Result<(), sqlx::Error> {
|
| 178 |
+
// Query merchants whose billing_cycle_start has passed 30 days
|
| 179 |
+
let merchants = sqlx::query(
|
| 180 |
+
"SELECT merchant_id, billing_cycle_start FROM merchants WHERE billing_cycle_start <= CURRENT_TIMESTAMP - INTERVAL '30 days'"
|
| 181 |
+
)
|
| 182 |
+
.fetch_all(&self.pool)
|
| 183 |
+
.await?;
|
| 184 |
+
|
| 185 |
+
for row in merchants {
|
| 186 |
+
use sqlx::Row;
|
| 187 |
+
let merchant_id: String = row.get("merchant_id");
|
| 188 |
+
let billing_cycle_start: chrono::NaiveDateTime = row.get("billing_cycle_start");
|
| 189 |
+
|
| 190 |
+
let mut tx = self.pool.begin().await?;
|
| 191 |
+
|
| 192 |
+
// Count successful orders placed within this billing cycle
|
| 193 |
+
// Statuses like PENDING_PAYMENT, PAYMENT_FAILED, EXPIRED_VOID are excluded.
|
| 194 |
+
let order_count: i32 = sqlx::query_scalar(
|
| 195 |
+
"SELECT COUNT(*)::INT FROM orders \
|
| 196 |
+
WHERE merchant_id = $1 \
|
| 197 |
+
AND created_at >= $2 \
|
| 198 |
+
AND created_at < $2 + INTERVAL '30 days' \
|
| 199 |
+
AND status NOT IN ('PENDING_PAYMENT', 'PAYMENT_FAILED', 'EXPIRED_VOID')"
|
| 200 |
+
)
|
| 201 |
+
.bind(&merchant_id)
|
| 202 |
+
.bind(billing_cycle_start)
|
| 203 |
+
.fetch_one(&mut *tx)
|
| 204 |
+
.await?;
|
| 205 |
+
|
| 206 |
+
let amount_inr = order_count as f64 * 2.0;
|
| 207 |
+
let invoice_id = format!("INV-{}", uuid::Uuid::new_v4().to_string()[..8].to_uppercase());
|
| 208 |
+
|
| 209 |
+
// Generate invoice
|
| 210 |
+
sqlx::query(
|
| 211 |
+
"INSERT INTO merchant_invoices (invoice_id, merchant_id, amount_inr, order_count, status, billing_period_start, billing_period_end, due_at) \
|
| 212 |
+
VALUES ($1, $2, $3, $4, 'UNPAID', $5, $5 + INTERVAL '30 days', CURRENT_TIMESTAMP + INTERVAL '7 days')"
|
| 213 |
+
)
|
| 214 |
+
.bind(&invoice_id)
|
| 215 |
+
.bind(&merchant_id)
|
| 216 |
+
.bind(amount_inr)
|
| 217 |
+
.bind(order_count)
|
| 218 |
+
.bind(billing_cycle_start)
|
| 219 |
+
.execute(&mut *tx)
|
| 220 |
+
.await?;
|
| 221 |
+
|
| 222 |
+
// Update merchant's billing_cycle_start
|
| 223 |
+
sqlx::query(
|
| 224 |
+
"UPDATE merchants SET billing_cycle_start = billing_cycle_start + INTERVAL '30 days' WHERE merchant_id = $1"
|
| 225 |
+
)
|
| 226 |
+
.bind(&merchant_id)
|
| 227 |
+
.execute(&mut *tx)
|
| 228 |
+
.await?;
|
| 229 |
+
|
| 230 |
+
tx.commit().await?;
|
| 231 |
+
|
| 232 |
+
tracing::info!(
|
| 233 |
+
merchant_id = %merchant_id,
|
| 234 |
+
invoice_id = %invoice_id,
|
| 235 |
+
amount = amount_inr,
|
| 236 |
+
"Sentinel: Generated monthly postpaid invoice."
|
| 237 |
+
);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
Ok(())
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
pub async fn freeze_overdue_merchants(&self) -> Result<(), sqlx::Error> {
|
| 244 |
+
// Find merchants with unpaid/overdue invoices past due_at
|
| 245 |
+
let overdue_merchants = sqlx::query(
|
| 246 |
+
"SELECT DISTINCT merchant_id FROM merchant_invoices WHERE status = 'UNPAID' AND due_at < CURRENT_TIMESTAMP"
|
| 247 |
+
)
|
| 248 |
+
.fetch_all(&self.pool)
|
| 249 |
+
.await?;
|
| 250 |
+
|
| 251 |
+
for row in overdue_merchants {
|
| 252 |
+
use sqlx::Row;
|
| 253 |
+
let merchant_id: String = row.get("merchant_id");
|
| 254 |
+
|
| 255 |
+
let mut tx = self.pool.begin().await?;
|
| 256 |
+
|
| 257 |
+
// Freeze merchant account
|
| 258 |
+
sqlx::query("UPDATE merchants SET is_frozen = TRUE WHERE merchant_id = $1")
|
| 259 |
+
.bind(&merchant_id)
|
| 260 |
+
.execute(&mut *tx)
|
| 261 |
+
.await?;
|
| 262 |
+
|
| 263 |
+
// Mark invoice(s) as OVERDUE
|
| 264 |
+
sqlx::query("UPDATE merchant_invoices SET status = 'OVERDUE' WHERE merchant_id = $1 AND status = 'UNPAID' AND due_at < CURRENT_TIMESTAMP")
|
| 265 |
+
.bind(&merchant_id)
|
| 266 |
+
.execute(&mut *tx)
|
| 267 |
+
.await?;
|
| 268 |
+
|
| 269 |
+
tx.commit().await?;
|
| 270 |
+
|
| 271 |
+
tracing::warn!(
|
| 272 |
+
merchant_id = %merchant_id,
|
| 273 |
+
"Sentinel: Merchant account frozen due to overdue invoice."
|
| 274 |
+
);
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
// Auto-unfreeze merchants who have no outstanding unpaid/overdue invoices past due date
|
| 278 |
+
// (This acts as a self-healing sweep)
|
| 279 |
+
let unfrozen = sqlx::query(
|
| 280 |
+
"UPDATE merchants SET is_frozen = FALSE \
|
| 281 |
+
WHERE is_frozen = TRUE \
|
| 282 |
+
AND merchant_id NOT IN ( \
|
| 283 |
+
SELECT DISTINCT merchant_id FROM merchant_invoices \
|
| 284 |
+
WHERE status IN ('UNPAID', 'OVERDUE') AND due_at < CURRENT_TIMESTAMP \
|
| 285 |
+
)"
|
| 286 |
+
)
|
| 287 |
+
.execute(&self.pool)
|
| 288 |
+
.await?;
|
| 289 |
+
|
| 290 |
+
if unfrozen.rows_affected() > 0 {
|
| 291 |
+
tracing::info!(
|
| 292 |
+
"Sentinel: Auto-unfroze {} merchants with settled invoices.",
|
| 293 |
+
unfrozen.rows_affected()
|
| 294 |
+
);
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
Ok(())
|
| 298 |
+
}
|
| 299 |
+
}
|
src/application/services/billing.rs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use crate::domain::models::{Merchant, OrderRecord};
|
| 2 |
+
|
| 3 |
+
pub struct BillingService;
|
| 4 |
+
|
| 5 |
+
impl BillingService {
|
| 6 |
+
pub fn generate_invoice_html(order: &OrderRecord, merchant: &Merchant) -> String {
|
| 7 |
+
let subtotal = order.price_inr;
|
| 8 |
+
let total_tax = order.cgst + order.sgst + order.igst;
|
| 9 |
+
let grand_total = subtotal + total_tax + order.delivery_fee;
|
| 10 |
+
|
| 11 |
+
format!(
|
| 12 |
+
r#"<!DOCTYPE html>
|
| 13 |
+
<html lang="en">
|
| 14 |
+
<head>
|
| 15 |
+
<meta charset="UTF-8">
|
| 16 |
+
<title>Invoice - {tx_id}</title>
|
| 17 |
+
<style>
|
| 18 |
+
body {{ font-family: 'Inter', sans-serif; color: #111; margin: 0; padding: 40px; line-height: 1.5; }}
|
| 19 |
+
.invoice-box {{ max-width: 800px; margin: auto; border: 1px solid #eee; padding: 30px; border-radius: 8px; }}
|
| 20 |
+
.header {{ display: flex; justify-content: space-between; margin-bottom: 40px; border-bottom: 2px solid #000; padding-bottom: 20px; }}
|
| 21 |
+
.brand {{ font-size: 24px; font-weight: 800; text-transform: uppercase; letter-spacing: 2px; }}
|
| 22 |
+
.meta {{ text-align: right; }}
|
| 23 |
+
.details {{ display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-bottom: 40px; }}
|
| 24 |
+
.section-title {{ font-size: 10px; font-weight: 800; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }}
|
| 25 |
+
table {{ width: 100%; border-collapse: collapse; margin-bottom: 40px; }}
|
| 26 |
+
th {{ text-align: left; background: #fafafa; padding: 12px; font-size: 12px; text-transform: uppercase; border-bottom: 1px solid #eee; }}
|
| 27 |
+
td {{ padding: 12px; font-size: 14px; border-bottom: 1px solid #eee; }}
|
| 28 |
+
.totals {{ margin-left: auto; width: 300px; }}
|
| 29 |
+
.total-row {{ display: flex; justify-content: space-between; padding: 8px 0; }}
|
| 30 |
+
.grand-total {{ font-size: 18px; font-weight: 800; border-top: 2px solid #000; margin-top: 10px; padding-top: 10px; }}
|
| 31 |
+
.footer {{ margin-top: 60px; text-align: center; color: #888; font-size: 12px; }}
|
| 32 |
+
</style>
|
| 33 |
+
</head>
|
| 34 |
+
<body>
|
| 35 |
+
<div class="invoice-box">
|
| 36 |
+
<div class="header">
|
| 37 |
+
<div class="brand">{brand_name}</div>
|
| 38 |
+
<div class="meta">
|
| 39 |
+
<div style="font-weight: 700;">INVOICE</div>
|
| 40 |
+
<div style="font-size: 12px; color: #666;">#{tx_id}</div>
|
| 41 |
+
<div style="font-size: 12px; color: #666;">Date: {date}</div>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<div class="details">
|
| 46 |
+
<div>
|
| 47 |
+
<div class="section-title">Sold By</div>
|
| 48 |
+
<div style="font-weight: 600;">{brand_name}</div>
|
| 49 |
+
<div style="font-size: 13px; color: #444; max-width: 250px;">{merchant_addr}</div>
|
| 50 |
+
</div>
|
| 51 |
+
<div>
|
| 52 |
+
<div class="section-title">Ship To</div>
|
| 53 |
+
<div style="font-weight: 600;">{buyer_name}</div>
|
| 54 |
+
<div style="font-size: 13px; color: #444;">{buyer_email}</div>
|
| 55 |
+
<div style="font-size: 13px; color: #444; max-width: 250px;">{buyer_addr}</div>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<table>
|
| 60 |
+
<thead>
|
| 61 |
+
<tr>
|
| 62 |
+
<th>Item Description</th>
|
| 63 |
+
<th style="text-align: right;">Amount</th>
|
| 64 |
+
</tr>
|
| 65 |
+
</thead>
|
| 66 |
+
<tbody>
|
| 67 |
+
<tr>
|
| 68 |
+
<td>Product Purchase ({link_id})</td>
|
| 69 |
+
<td style="text-align: right;">₹{subtotal:.2}</td>
|
| 70 |
+
</tr>
|
| 71 |
+
</tbody>
|
| 72 |
+
</table>
|
| 73 |
+
|
| 74 |
+
<div class="totals">
|
| 75 |
+
<div class="total-row">
|
| 76 |
+
<span>Subtotal</span>
|
| 77 |
+
<span>₹{subtotal:.2}</span>
|
| 78 |
+
</div>
|
| 79 |
+
<div class="total-row">
|
| 80 |
+
<span>Tax (GST)</span>
|
| 81 |
+
<span>₹{total_tax:.2}</span>
|
| 82 |
+
</div>
|
| 83 |
+
<div class="total-row">
|
| 84 |
+
<span>Shipping</span>
|
| 85 |
+
<span>₹{shipping:.2}</span>
|
| 86 |
+
</div>
|
| 87 |
+
{discount_row}
|
| 88 |
+
<div class="total-row grand-total">
|
| 89 |
+
<span>Grand Total</span>
|
| 90 |
+
<span>₹{total:.2}</span>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<div class="footer">
|
| 95 |
+
Generated by Rtix Sovereign Settlement Protocol. Secure Transaction Verified.
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
</body>
|
| 99 |
+
</html>"#,
|
| 100 |
+
brand_name = merchant.brand_name,
|
| 101 |
+
tx_id = order.transaction_id,
|
| 102 |
+
date = order
|
| 103 |
+
.created_at
|
| 104 |
+
.map(|t| t.format("%d %b %Y").to_string())
|
| 105 |
+
.unwrap_or_else(|| "N/A".to_string()),
|
| 106 |
+
merchant_addr = merchant.business_address.as_deref().unwrap_or("N/A"),
|
| 107 |
+
buyer_name = order.buyer_name,
|
| 108 |
+
buyer_email = order.buyer_email,
|
| 109 |
+
buyer_addr = order.delivery_address.as_deref().unwrap_or("N/A"),
|
| 110 |
+
link_id = order.link_id,
|
| 111 |
+
subtotal = subtotal,
|
| 112 |
+
total_tax = total_tax,
|
| 113 |
+
shipping = order.delivery_fee,
|
| 114 |
+
total = grand_total,
|
| 115 |
+
discount_row = if order.discount_amount > 0.0 {
|
| 116 |
+
format!(
|
| 117 |
+
r#"<div class="total-row" style="color: #00E59B;"><span>Discount</span><span>-₹{:.2}</span></div>"#,
|
| 118 |
+
order.discount_amount
|
| 119 |
+
)
|
| 120 |
+
} else {
|
| 121 |
+
String::new()
|
| 122 |
+
}
|
| 123 |
+
)
|
| 124 |
+
}
|
| 125 |
+
}
|
src/application/services/checkout.rs
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use crate::domain::error::{AppError, AppResult};
|
| 2 |
+
use crate::domain::models::{OrderRecord, ProductLink};
|
| 3 |
+
use crate::infrastructure::db::DbPool;
|
| 4 |
+
use crate::infrastructure::repositories::{OrderRepository, ProductRepository};
|
| 5 |
+
use async_trait::async_trait;
|
| 6 |
+
use std::sync::Arc;
|
| 7 |
+
|
| 8 |
+
#[async_trait]
|
| 9 |
+
pub trait CheckoutService: Send + Sync {
|
| 10 |
+
async fn get_checkout_view(&self, link_id: &str) -> AppResult<ProductLink>;
|
| 11 |
+
#[allow(clippy::too_many_arguments)]
|
| 12 |
+
async fn execute_checkout(
|
| 13 |
+
&self,
|
| 14 |
+
link_id: &str,
|
| 15 |
+
buyer_phone: &str,
|
| 16 |
+
buyer_name: &str,
|
| 17 |
+
buyer_email: &str,
|
| 18 |
+
shipping_pincode: &str,
|
| 19 |
+
delivery_address: &str,
|
| 20 |
+
coupon_code: Option<String>,
|
| 21 |
+
request_id: Option<String>,
|
| 22 |
+
client_ip: &str,
|
| 23 |
+
lat: Option<f64>,
|
| 24 |
+
lng: Option<f64>,
|
| 25 |
+
device_fingerprint: Option<String>,
|
| 26 |
+
) -> AppResult<OrderRecord>;
|
| 27 |
+
#[allow(clippy::too_many_arguments)]
|
| 28 |
+
async fn execute_cart_checkout(
|
| 29 |
+
&self,
|
| 30 |
+
items: Vec<(String, u32)>, // (link_id, quantity)
|
| 31 |
+
buyer_phone: &str,
|
| 32 |
+
buyer_name: &str,
|
| 33 |
+
buyer_email: &str,
|
| 34 |
+
shipping_pincode: &str,
|
| 35 |
+
delivery_address: &str,
|
| 36 |
+
coupon_code: Option<String>,
|
| 37 |
+
request_id: Option<String>,
|
| 38 |
+
client_ip: &str,
|
| 39 |
+
lat: Option<f64>,
|
| 40 |
+
lng: Option<f64>,
|
| 41 |
+
device_fingerprint: Option<String>,
|
| 42 |
+
) -> AppResult<OrderRecord>;
|
| 43 |
+
async fn submit_delivery_proof(
|
| 44 |
+
&self,
|
| 45 |
+
transaction_id: &str,
|
| 46 |
+
proof_data: &str,
|
| 47 |
+
proof_token: &str,
|
| 48 |
+
lat: Option<f64>,
|
| 49 |
+
lng: Option<f64>,
|
| 50 |
+
) -> AppResult<()>;
|
| 51 |
+
async fn estimate_delivery(
|
| 52 |
+
&self,
|
| 53 |
+
link_id: &str,
|
| 54 |
+
pincode: &str,
|
| 55 |
+
_buyer_phone: Option<String>,
|
| 56 |
+
) -> AppResult<(f64, f64)>;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
pub struct RtixCheckoutService {
|
| 60 |
+
product_repo: Arc<dyn ProductRepository>,
|
| 61 |
+
merchant_repo: Arc<dyn crate::infrastructure::repositories::MerchantRepository>,
|
| 62 |
+
order_repo: Arc<dyn OrderRepository>,
|
| 63 |
+
assets: Arc<dyn crate::infrastructure::storage::assets::AssetProvider>,
|
| 64 |
+
tx: tokio::sync::broadcast::Sender<crate::interfaces::http::api::RealtimeEvent>,
|
| 65 |
+
pool: DbPool,
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
impl RtixCheckoutService {
|
| 69 |
+
pub fn new(
|
| 70 |
+
product_repo: Arc<dyn ProductRepository>,
|
| 71 |
+
merchant_repo: Arc<dyn crate::infrastructure::repositories::MerchantRepository>,
|
| 72 |
+
order_repo: Arc<dyn OrderRepository>,
|
| 73 |
+
assets: Arc<dyn crate::infrastructure::storage::assets::AssetProvider>,
|
| 74 |
+
tx: tokio::sync::broadcast::Sender<crate::interfaces::http::api::RealtimeEvent>,
|
| 75 |
+
pool: DbPool,
|
| 76 |
+
) -> Self {
|
| 77 |
+
Self {
|
| 78 |
+
product_repo,
|
| 79 |
+
merchant_repo,
|
| 80 |
+
order_repo,
|
| 81 |
+
assets,
|
| 82 |
+
tx,
|
| 83 |
+
pool,
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
#[async_trait]
|
| 89 |
+
impl CheckoutService for RtixCheckoutService {
|
| 90 |
+
async fn get_checkout_view(&self, link_id: &str) -> AppResult<ProductLink> {
|
| 91 |
+
let product = self.product_repo.find_by_id(link_id).await?;
|
| 92 |
+
let mut product =
|
| 93 |
+
product.ok_or_else(|| AppError::NotFound("Product link not found".to_string()))?;
|
| 94 |
+
|
| 95 |
+
let _ = self.product_repo.increment_views(link_id).await;
|
| 96 |
+
product.image_data = crate::core::utils::hydrate_file_to_base64(product.image_data).await;
|
| 97 |
+
|
| 98 |
+
Ok(product)
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
#[allow(clippy::too_many_arguments)]
|
| 102 |
+
async fn execute_checkout(
|
| 103 |
+
&self,
|
| 104 |
+
link_id: &str,
|
| 105 |
+
buyer_phone: &str,
|
| 106 |
+
buyer_name: &str,
|
| 107 |
+
buyer_email: &str,
|
| 108 |
+
shipping_pincode: &str,
|
| 109 |
+
delivery_address: &str,
|
| 110 |
+
coupon_code: Option<String>,
|
| 111 |
+
request_id: Option<String>,
|
| 112 |
+
client_ip: &str,
|
| 113 |
+
lat: Option<f64>,
|
| 114 |
+
lng: Option<f64>,
|
| 115 |
+
device_fingerprint: Option<String>,
|
| 116 |
+
) -> AppResult<OrderRecord> {
|
| 117 |
+
crate::application::services::checkout_impl::execute_checkout_helper(
|
| 118 |
+
&self.product_repo,
|
| 119 |
+
&self.merchant_repo,
|
| 120 |
+
&self.order_repo,
|
| 121 |
+
&self.pool,
|
| 122 |
+
&self.tx,
|
| 123 |
+
link_id,
|
| 124 |
+
buyer_phone,
|
| 125 |
+
buyer_name,
|
| 126 |
+
buyer_email,
|
| 127 |
+
shipping_pincode,
|
| 128 |
+
delivery_address,
|
| 129 |
+
coupon_code,
|
| 130 |
+
request_id,
|
| 131 |
+
client_ip,
|
| 132 |
+
lat,
|
| 133 |
+
lng,
|
| 134 |
+
device_fingerprint,
|
| 135 |
+
)
|
| 136 |
+
.await
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
#[allow(clippy::too_many_arguments)]
|
| 140 |
+
async fn execute_cart_checkout(
|
| 141 |
+
&self,
|
| 142 |
+
items: Vec<(String, u32)>,
|
| 143 |
+
buyer_phone: &str,
|
| 144 |
+
buyer_name: &str,
|
| 145 |
+
buyer_email: &str,
|
| 146 |
+
shipping_pincode: &str,
|
| 147 |
+
delivery_address: &str,
|
| 148 |
+
coupon_code: Option<String>,
|
| 149 |
+
request_id: Option<String>,
|
| 150 |
+
client_ip: &str,
|
| 151 |
+
lat: Option<f64>,
|
| 152 |
+
lng: Option<f64>,
|
| 153 |
+
device_fingerprint: Option<String>,
|
| 154 |
+
) -> AppResult<OrderRecord> {
|
| 155 |
+
crate::application::services::checkout_impl::execute_cart_checkout_helper(
|
| 156 |
+
&self.product_repo,
|
| 157 |
+
&self.merchant_repo,
|
| 158 |
+
&self.order_repo,
|
| 159 |
+
&self.pool,
|
| 160 |
+
&self.tx,
|
| 161 |
+
items,
|
| 162 |
+
buyer_phone,
|
| 163 |
+
buyer_name,
|
| 164 |
+
buyer_email,
|
| 165 |
+
shipping_pincode,
|
| 166 |
+
delivery_address,
|
| 167 |
+
coupon_code,
|
| 168 |
+
request_id,
|
| 169 |
+
client_ip,
|
| 170 |
+
lat,
|
| 171 |
+
lng,
|
| 172 |
+
device_fingerprint,
|
| 173 |
+
)
|
| 174 |
+
.await
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
async fn submit_delivery_proof(
|
| 178 |
+
&self,
|
| 179 |
+
transaction_id: &str,
|
| 180 |
+
proof_data: &str,
|
| 181 |
+
proof_token: &str,
|
| 182 |
+
lat: Option<f64>,
|
| 183 |
+
lng: Option<f64>,
|
| 184 |
+
) -> AppResult<()> {
|
| 185 |
+
crate::application::services::checkout_impl::execute_submit_delivery_proof_helper(
|
| 186 |
+
&self.order_repo,
|
| 187 |
+
&self.assets,
|
| 188 |
+
&self.tx,
|
| 189 |
+
transaction_id,
|
| 190 |
+
proof_data,
|
| 191 |
+
proof_token,
|
| 192 |
+
lat,
|
| 193 |
+
lng,
|
| 194 |
+
)
|
| 195 |
+
.await
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
async fn estimate_delivery(
|
| 199 |
+
&self,
|
| 200 |
+
link_id: &str,
|
| 201 |
+
pincode: &str,
|
| 202 |
+
_buyer_phone: Option<String>,
|
| 203 |
+
) -> AppResult<(f64, f64)> {
|
| 204 |
+
let product = self
|
| 205 |
+
.product_repo
|
| 206 |
+
.find_by_id(link_id)
|
| 207 |
+
.await?
|
| 208 |
+
.ok_or_else(|| AppError::NotFound("Product link not found".to_string()))?;
|
| 209 |
+
|
| 210 |
+
let merchant = self
|
| 211 |
+
.merchant_repo
|
| 212 |
+
.find_by_id(&product.merchant_id)
|
| 213 |
+
.await?
|
| 214 |
+
.ok_or_else(|| AppError::NotFound("Merchant not found".to_string()))?;
|
| 215 |
+
|
| 216 |
+
let distance_km =
|
| 217 |
+
crate::domain::distance::estimate_distance_km(&merchant.base_pincode, pincode);
|
| 218 |
+
|
| 219 |
+
let pricing_features = crate::application::services::pricing::PricingFeatures {
|
| 220 |
+
distance_km,
|
| 221 |
+
user_rate_per_km: merchant.delivery_rate_per_km,
|
| 222 |
+
product_weight: product.expected_weight,
|
| 223 |
+
base_charge: merchant.delivery_base_fee,
|
| 224 |
+
config: serde_json::from_value(merchant.logistics_config.clone()).unwrap_or_default(),
|
| 225 |
+
};
|
| 226 |
+
|
| 227 |
+
let fee = crate::application::services::pricing::PricingEngine::estimate_delivery_fee(
|
| 228 |
+
pricing_features,
|
| 229 |
+
);
|
| 230 |
+
|
| 231 |
+
Ok((fee, distance_km))
|
| 232 |
+
}
|
| 233 |
+
}
|
src/application/services/checkout_impl/mod.rs
ADDED
|
@@ -0,0 +1,873 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use crate::domain::error::{AppError, AppResult};
|
| 2 |
+
use crate::domain::models::OrderRecord;
|
| 3 |
+
use crate::infrastructure::db::DbPool;
|
| 4 |
+
use crate::infrastructure::repositories::{MerchantRepository, OrderRepository, ProductRepository};
|
| 5 |
+
use crate::infrastructure::storage::assets::AssetProvider;
|
| 6 |
+
use crate::interfaces::http::api::RealtimeEvent;
|
| 7 |
+
use std::sync::Arc;
|
| 8 |
+
use tokio::sync::broadcast::Sender;
|
| 9 |
+
use uuid::Uuid;
|
| 10 |
+
|
| 11 |
+
#[allow(clippy::too_many_arguments)]
|
| 12 |
+
pub async fn execute_checkout_helper(
|
| 13 |
+
product_repo: &Arc<dyn ProductRepository>,
|
| 14 |
+
merchant_repo: &Arc<dyn MerchantRepository>,
|
| 15 |
+
order_repo: &Arc<dyn OrderRepository>,
|
| 16 |
+
pool: &DbPool,
|
| 17 |
+
tx_sender: &Sender<RealtimeEvent>,
|
| 18 |
+
link_id: &str,
|
| 19 |
+
buyer_phone: &str,
|
| 20 |
+
buyer_name: &str,
|
| 21 |
+
buyer_email: &str,
|
| 22 |
+
shipping_pincode: &str,
|
| 23 |
+
delivery_address: &str,
|
| 24 |
+
coupon_code: Option<String>,
|
| 25 |
+
request_id: Option<String>,
|
| 26 |
+
client_ip: &str,
|
| 27 |
+
lat: Option<f64>,
|
| 28 |
+
lng: Option<f64>,
|
| 29 |
+
device_fingerprint: Option<String>,
|
| 30 |
+
) -> AppResult<OrderRecord> {
|
| 31 |
+
let product = product_repo.find_by_id(link_id).await?;
|
| 32 |
+
let mut product =
|
| 33 |
+
product.ok_or_else(|| AppError::NotFound("Product link not found".to_string()))?;
|
| 34 |
+
|
| 35 |
+
let _ = product_repo.increment_views(link_id).await;
|
| 36 |
+
product.image_data = crate::core::utils::hydrate_file_to_base64(product.image_data).await;
|
| 37 |
+
|
| 38 |
+
let merchant = merchant_repo
|
| 39 |
+
.find_by_id(&product.merchant_id)
|
| 40 |
+
.await?
|
| 41 |
+
.ok_or_else(|| AppError::NotFound("Merchant not found".to_string()))?;
|
| 42 |
+
|
| 43 |
+
if merchant.is_frozen {
|
| 44 |
+
return Err(AppError::Forbidden("Merchant account is frozen due to unpaid outstanding invoices.".to_string()));
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// 1. Secure Logistics Circuit Breaker
|
| 48 |
+
// Check if the pincode has high smart volatility (recent violations)
|
| 49 |
+
let volatility_count = sqlx::query_scalar::<_, i64>(
|
| 50 |
+
"SELECT COUNT(*) FROM risk_audit_logs WHERE details LIKE $1 AND created_at > NOW() - INTERVAL '1 hour'"
|
| 51 |
+
)
|
| 52 |
+
.bind(format!("%{}%", shipping_pincode))
|
| 53 |
+
.fetch_one(order_repo.find_pool())
|
| 54 |
+
.await
|
| 55 |
+
.unwrap_or(0);
|
| 56 |
+
|
| 57 |
+
if volatility_count > 5 {
|
| 58 |
+
return Err(AppError::Forbidden(format!(
|
| 59 |
+
"Logistics Circuit Breaker Active for zone {}. High volatility detected in recent smart audit cycles.",
|
| 60 |
+
shipping_pincode
|
| 61 |
+
)));
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// 2. Institutional Velocity Guard (Anti-Abuse)
|
| 65 |
+
let intelligence =
|
| 66 |
+
crate::application::services::intelligence::IntelligenceService::new(pool.clone());
|
| 67 |
+
let velocity_risk = intelligence
|
| 68 |
+
.evaluate_velocity_risk(
|
| 69 |
+
device_fingerprint.as_deref(),
|
| 70 |
+
Some(client_ip),
|
| 71 |
+
&product.merchant_id,
|
| 72 |
+
)
|
| 73 |
+
.await?;
|
| 74 |
+
|
| 75 |
+
if velocity_risk >= 90.0 {
|
| 76 |
+
// Log Critical Security Event
|
| 77 |
+
let _ = sqlx::query(
|
| 78 |
+
"INSERT INTO risk_audit_logs (merchant_id, event_type, risk_level, details, device_fingerprint) VALUES ($1, $2, $3, $4, $5)"
|
| 79 |
+
)
|
| 80 |
+
.bind(&product.merchant_id)
|
| 81 |
+
.bind("VELOCITY_BLOCK")
|
| 82 |
+
.bind("CRITICAL")
|
| 83 |
+
.bind(format!("Transaction blocked due to high velocity risk ({}). Fingerprint: {:?}, IP: {}", velocity_risk, device_fingerprint, client_ip))
|
| 84 |
+
.bind(device_fingerprint.as_deref())
|
| 85 |
+
.execute(pool)
|
| 86 |
+
.await;
|
| 87 |
+
|
| 88 |
+
return Err(AppError::Forbidden(
|
| 89 |
+
"Security Protocol Active: High-frequency transaction activity detected from this device/network. Access restricted for protocol safety.".to_string()
|
| 90 |
+
));
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
let distance_km =
|
| 94 |
+
crate::domain::distance::estimate_distance_km(&merchant.base_pincode, shipping_pincode);
|
| 95 |
+
|
| 96 |
+
let pricing_features = crate::application::services::pricing::PricingFeatures {
|
| 97 |
+
distance_km,
|
| 98 |
+
user_rate_per_km: merchant.delivery_rate_per_km,
|
| 99 |
+
product_weight: product.expected_weight,
|
| 100 |
+
base_charge: merchant.delivery_base_fee,
|
| 101 |
+
config: serde_json::from_value(merchant.logistics_config.clone()).unwrap_or_default(),
|
| 102 |
+
};
|
| 103 |
+
let delivery_fee = crate::application::services::pricing::PricingEngine::estimate_delivery_fee(
|
| 104 |
+
pricing_features,
|
| 105 |
+
);
|
| 106 |
+
|
| 107 |
+
// 3. Precision Geofence Check
|
| 108 |
+
let mut geofence_verified = None;
|
| 109 |
+
if let (Some(l_lat), Some(l_lng)) = (lat, lng) {
|
| 110 |
+
let intelligence =
|
| 111 |
+
crate::application::services::intelligence::IntelligenceService::new(pool.clone());
|
| 112 |
+
if !intelligence
|
| 113 |
+
.verify_geofence_with_precision(shipping_pincode, l_lat, l_lng)
|
| 114 |
+
.await?
|
| 115 |
+
{
|
| 116 |
+
return Err(AppError::Forbidden(format!(
|
| 117 |
+
"Geofence Verification Failed: Your current GPS coordinates do not match the shipping pincode {}. Forensic integrity active.",
|
| 118 |
+
shipping_pincode
|
| 119 |
+
)));
|
| 120 |
+
}
|
| 121 |
+
geofence_verified = Some(true);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
let order_count = order_repo
|
| 125 |
+
.count_by_buyer(&product.merchant_id, buyer_phone)
|
| 126 |
+
.await?;
|
| 127 |
+
|
| 128 |
+
let transaction_id = Uuid::new_v4().to_string();
|
| 129 |
+
|
| 130 |
+
// 2. Merchant Transaction Limits
|
| 131 |
+
if merchant.plan == "FREE" && product.price_inr > 10000.0 {
|
| 132 |
+
return Err(AppError::Forbidden(
|
| 133 |
+
"Transaction value exceeds the ₹10,000 limit for merchants on the FREE plan. Upgrade to PRO to accept higher-value payments without limitations.".to_string()
|
| 134 |
+
));
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
if product.price_inr > merchant.max_order_value_inr {
|
| 138 |
+
return Err(AppError::Forbidden(format!(
|
| 139 |
+
"Transaction value ₹{} exceeds the current limit for this merchant (₹{}). Increase merchant verification level to lift this restriction.",
|
| 140 |
+
product.price_inr, merchant.max_order_value_inr
|
| 141 |
+
)));
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
// Start Transaction for Atomicity
|
| 145 |
+
let mut tx = pool.begin().await.map_err(AppError::Database)?;
|
| 146 |
+
|
| 147 |
+
// 3. Inventory Enforcement
|
| 148 |
+
if !product.is_unlimited {
|
| 149 |
+
let rows_affected = sqlx::query(
|
| 150 |
+
"UPDATE product_links SET inventory_count = inventory_count - 1 WHERE link_id = $1 AND inventory_count > 0",
|
| 151 |
+
)
|
| 152 |
+
.bind(&product.link_id)
|
| 153 |
+
.execute(&mut *tx)
|
| 154 |
+
.await
|
| 155 |
+
.map_err(AppError::Database)?
|
| 156 |
+
.rows_affected();
|
| 157 |
+
|
| 158 |
+
if rows_affected == 0 {
|
| 159 |
+
return Err(AppError::BadRequest(
|
| 160 |
+
"Product is currently out of stock.".to_string(),
|
| 161 |
+
));
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
let current_price =
|
| 166 |
+
if let (Some(sale_price), Some(ends_at)) = (product.sale_price_inr, product.sale_ends_at) {
|
| 167 |
+
if ends_at > chrono::Utc::now().naive_utc() {
|
| 168 |
+
sale_price
|
| 169 |
+
} else {
|
| 170 |
+
product.price_inr
|
| 171 |
+
}
|
| 172 |
+
} else {
|
| 173 |
+
product.price_inr
|
| 174 |
+
};
|
| 175 |
+
|
| 176 |
+
let platform_fee = crate::application::services::pricing::PricingEngine::calculate_platform_fee(
|
| 177 |
+
current_price,
|
| 178 |
+
merchant.trust_score,
|
| 179 |
+
);
|
| 180 |
+
|
| 181 |
+
// Calculate preliminary risk score
|
| 182 |
+
let mut calculated_risk = 0.0;
|
| 183 |
+
calculated_risk += (volatility_count as f64 * 5.0).min(30.0);
|
| 184 |
+
calculated_risk += velocity_risk * 0.4; // Incorporate velocity guard signal
|
| 185 |
+
if geofence_verified == Some(true) {
|
| 186 |
+
calculated_risk *= 0.8; // Lower risk if GPS verified
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
let mut order = OrderRecord {
|
| 190 |
+
transaction_id: transaction_id.clone(),
|
| 191 |
+
merchant_id: product.merchant_id.clone(),
|
| 192 |
+
link_id: link_id.to_string(),
|
| 193 |
+
buyer_phone: buyer_phone.to_string(),
|
| 194 |
+
buyer_phone_hash: None, // populated by encrypt_pii() at persist time
|
| 195 |
+
buyer_name: buyer_name.to_string(),
|
| 196 |
+
buyer_email: buyer_email.to_string(),
|
| 197 |
+
shipping_pincode: Some(shipping_pincode.to_string()),
|
| 198 |
+
delivery_address: Some(delivery_address.to_string()),
|
| 199 |
+
price_inr: current_price,
|
| 200 |
+
status: crate::domain::constants::ORDER_STATUS_PENDING_PAYMENT.to_string(),
|
| 201 |
+
vpa: None,
|
| 202 |
+
payu_id: String::new(),
|
| 203 |
+
outbound_weight: product.expected_weight,
|
| 204 |
+
return_weight: 0.0,
|
| 205 |
+
proof_data: None,
|
| 206 |
+
settled_at: None,
|
| 207 |
+
shipped_at: None,
|
| 208 |
+
delivered_at: None,
|
| 209 |
+
shipping_method: None,
|
| 210 |
+
estimated_delivery_at: None,
|
| 211 |
+
is_payment: false,
|
| 212 |
+
platform_fee_paid: false,
|
| 213 |
+
platform_fee,
|
| 214 |
+
delivery_fee,
|
| 215 |
+
distance_km,
|
| 216 |
+
risk_score: calculated_risk,
|
| 217 |
+
risk_flags: None,
|
| 218 |
+
cgst: 0.0,
|
| 219 |
+
sgst: 0.0,
|
| 220 |
+
igst: 0.0,
|
| 221 |
+
utr_number: None,
|
| 222 |
+
platform_fee_utr: None,
|
| 223 |
+
delivery_gps_lat: None,
|
| 224 |
+
delivery_gps_lng: None,
|
| 225 |
+
is_geofence_verified: geofence_verified,
|
| 226 |
+
pincode_volatility_at_checkout: 0.0,
|
| 227 |
+
discount_amount: 0.0,
|
| 228 |
+
coupon_code: None,
|
| 229 |
+
checkout_gps_lat: lat,
|
| 230 |
+
checkout_gps_lng: lng,
|
| 231 |
+
device_fingerprint: device_fingerprint.clone(),
|
| 232 |
+
paid_at: None,
|
| 233 |
+
proof_received_at: None,
|
| 234 |
+
created_at: None,
|
| 235 |
+
brand_name: None,
|
| 236 |
+
};
|
| 237 |
+
|
| 238 |
+
if let Some(ref code) = coupon_code {
|
| 239 |
+
let coupon = sqlx::query_as::<_, crate::domain::models::Coupon>(
|
| 240 |
+
"SELECT * FROM coupons WHERE merchant_id = $1 AND code = $2 AND is_active = TRUE",
|
| 241 |
+
)
|
| 242 |
+
.bind(&product.merchant_id)
|
| 243 |
+
.bind(code.to_uppercase())
|
| 244 |
+
.fetch_optional(&mut *tx)
|
| 245 |
+
.await?;
|
| 246 |
+
|
| 247 |
+
if let Some(c) = coupon {
|
| 248 |
+
if c.is_valid(order.price_inr) {
|
| 249 |
+
let discount = c.calculate_discount(order.price_inr);
|
| 250 |
+
order.discount_amount = discount;
|
| 251 |
+
order.price_inr -= discount;
|
| 252 |
+
order.coupon_code = Some(code.clone());
|
| 253 |
+
|
| 254 |
+
let _ =
|
| 255 |
+
sqlx::query("UPDATE coupons SET usage_count = usage_count + 1 WHERE id = $1")
|
| 256 |
+
.bind(c.id)
|
| 257 |
+
.execute(&mut *tx)
|
| 258 |
+
.await;
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
let gst_breakdown = crate::application::services::india_tax::IndiaTaxService::calculate_gst(
|
| 264 |
+
order.price_inr,
|
| 265 |
+
merchant.state_code.unwrap_or(29),
|
| 266 |
+
order.shipping_pincode.as_deref().unwrap_or_default(),
|
| 267 |
+
0.18, // 18% standard rate
|
| 268 |
+
);
|
| 269 |
+
order.cgst = gst_breakdown.cgst;
|
| 270 |
+
order.sgst = gst_breakdown.sgst;
|
| 271 |
+
order.igst = gst_breakdown.igst;
|
| 272 |
+
|
| 273 |
+
let volatility =
|
| 274 |
+
crate::application::services::intelligence::IntelligenceService::new(pool.clone())
|
| 275 |
+
.get_pincode_volatility(order.shipping_pincode.as_deref().unwrap_or_default())
|
| 276 |
+
.await
|
| 277 |
+
.unwrap_or(0.0);
|
| 278 |
+
order.pincode_volatility_at_checkout = volatility;
|
| 279 |
+
|
| 280 |
+
if volatility > 0.5 {
|
| 281 |
+
let _ = tx_sender.send(
|
| 282 |
+
crate::interfaces::http::api::RealtimeEvent::NetworkVolatilityAlert {
|
| 283 |
+
pincode: order.shipping_pincode.clone().unwrap_or_default(),
|
| 284 |
+
volatility_score: volatility,
|
| 285 |
+
message: format!(
|
| 286 |
+
"High logistics volatility detected for pincode {}.",
|
| 287 |
+
order.shipping_pincode.clone().unwrap_or_default()
|
| 288 |
+
),
|
| 289 |
+
},
|
| 290 |
+
);
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
let (risk_score, risk_flags) =
|
| 294 |
+
crate::application::services::risk::RiskEngine::calculate_risk_score(
|
| 295 |
+
&order,
|
| 296 |
+
order_count,
|
| 297 |
+
volatility,
|
| 298 |
+
);
|
| 299 |
+
order.risk_score = risk_score;
|
| 300 |
+
order.risk_flags = Some(risk_flags);
|
| 301 |
+
|
| 302 |
+
if order.risk_score >= 80.0 {
|
| 303 |
+
// Log Critical/High Security Event outside the transaction so it's persisted even when rolled back
|
| 304 |
+
if let Ok(mut conn) = pool.acquire().await {
|
| 305 |
+
crate::domain::audit::log_risk_event(
|
| 306 |
+
&mut *conn,
|
| 307 |
+
Some(&transaction_id),
|
| 308 |
+
&product.merchant_id,
|
| 309 |
+
"HIGH_RISK_BLOCK",
|
| 310 |
+
"CRITICAL",
|
| 311 |
+
Some(&format!(
|
| 312 |
+
"Transaction blocked due to high risk score ({:.1}) during checkout for link {}. Flags: {:?}",
|
| 313 |
+
order.risk_score, link_id, order.risk_flags
|
| 314 |
+
)),
|
| 315 |
+
Some(order.risk_score),
|
| 316 |
+
request_id.as_deref(),
|
| 317 |
+
device_fingerprint.as_deref(),
|
| 318 |
+
Some(tx_sender),
|
| 319 |
+
)
|
| 320 |
+
.await;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
if order.risk_score > 90.0 {
|
| 324 |
+
crate::interfaces::http::middleware::block_ip_persistently(
|
| 325 |
+
pool,
|
| 326 |
+
client_ip,
|
| 327 |
+
&format!("Automated Defense: High Risk Score ({:.1}) detected during checkout for link {}", order.risk_score, link_id),
|
| 328 |
+
Some(tx_sender)
|
| 329 |
+
).await;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
return Err(AppError::Forbidden(format!(
|
| 333 |
+
"Security restriction: High risk profile detected (Score: {:.1}). This transaction has been blocked to prevent potential fraud.",
|
| 334 |
+
order.risk_score
|
| 335 |
+
)));
|
| 336 |
+
} else if order.risk_score > 60.0 {
|
| 337 |
+
crate::domain::audit::log_risk_event(
|
| 338 |
+
&mut tx,
|
| 339 |
+
Some(&transaction_id),
|
| 340 |
+
&product.merchant_id,
|
| 341 |
+
"HIGH_RISK_ORDER",
|
| 342 |
+
"HIGH",
|
| 343 |
+
Some(&format!(
|
| 344 |
+
"Order {} flagged with risk score {}",
|
| 345 |
+
transaction_id, order.risk_score
|
| 346 |
+
)),
|
| 347 |
+
Some(order.risk_score),
|
| 348 |
+
request_id.as_deref(),
|
| 349 |
+
device_fingerprint.as_deref(),
|
| 350 |
+
Some(tx_sender),
|
| 351 |
+
)
|
| 352 |
+
.await;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
// Persist Order using transaction
|
| 356 |
+
order.created_at = Some(chrono::Utc::now().naive_utc());
|
| 357 |
+
crate::domain::models::OrderRecord::create_with_tx(&mut tx, &order).await?;
|
| 358 |
+
|
| 359 |
+
tx.commit().await.map_err(AppError::Database)?;
|
| 360 |
+
|
| 361 |
+
Ok(order)
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
#[allow(clippy::too_many_arguments)]
|
| 365 |
+
pub async fn execute_submit_delivery_proof_helper(
|
| 366 |
+
order_repo: &Arc<dyn OrderRepository>,
|
| 367 |
+
assets: &Arc<dyn AssetProvider>,
|
| 368 |
+
tx_sender: &Sender<RealtimeEvent>,
|
| 369 |
+
transaction_id: &str,
|
| 370 |
+
proof_data: &str,
|
| 371 |
+
proof_token: &str,
|
| 372 |
+
lat: Option<f64>,
|
| 373 |
+
lng: Option<f64>,
|
| 374 |
+
) -> AppResult<()> {
|
| 375 |
+
let transaction_id = crate::domain::validation::sanitize_filename(transaction_id);
|
| 376 |
+
|
| 377 |
+
if crate::core::session::verify_proof_token(proof_token, &transaction_id).is_err() {
|
| 378 |
+
return Err(AppError::Forbidden(
|
| 379 |
+
"Invalid proof authorization token".to_string(),
|
| 380 |
+
));
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
let order = order_repo.find_by_id(&transaction_id).await?;
|
| 384 |
+
let order = order.ok_or_else(|| AppError::NotFound("Order not found".to_string()))?;
|
| 385 |
+
|
| 386 |
+
if order.status != crate::domain::constants::ORDER_STATUS_PAID_PENDING_DELIVERY {
|
| 387 |
+
return Err(AppError::BadRequest(
|
| 388 |
+
"Order is not in a state to accept delivery proof".to_string(),
|
| 389 |
+
));
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
let mut tx = order_repo
|
| 393 |
+
.find_pool()
|
| 394 |
+
.begin()
|
| 395 |
+
.await
|
| 396 |
+
.map_err(AppError::Database)?;
|
| 397 |
+
|
| 398 |
+
let is_video = proof_data.contains("video") && proof_data.contains("mp4");
|
| 399 |
+
let is_png = proof_data.contains("image/png");
|
| 400 |
+
let file_extension = if is_video {
|
| 401 |
+
"mp4"
|
| 402 |
+
} else if is_png {
|
| 403 |
+
"png"
|
| 404 |
+
} else {
|
| 405 |
+
"jpg"
|
| 406 |
+
};
|
| 407 |
+
let filename = format!("proof_{}.{}", Uuid::new_v4(), file_extension);
|
| 408 |
+
|
| 409 |
+
let bytes = crate::domain::validation::validate_base64_payload(proof_data, 10 * 1024 * 1024)
|
| 410 |
+
.map_err(|e| AppError::BadRequest(e.message))?;
|
| 411 |
+
|
| 412 |
+
// Institutional Enforcement: Require Video for High-Value Orders (> ₹5,000)
|
| 413 |
+
if order.price_inr > 5000.0 && !is_video {
|
| 414 |
+
return Err(AppError::BadRequest(
|
| 415 |
+
"High-value order detected. Smart video proof is mandatory for this transaction."
|
| 416 |
+
.to_string(),
|
| 417 |
+
));
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
if !is_video {
|
| 421 |
+
let allowed_headers: [Vec<u8>; 2] = [
|
| 422 |
+
vec![0xFF, 0xD8, 0xFF],
|
| 423 |
+
vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
|
| 424 |
+
];
|
| 425 |
+
if !allowed_headers
|
| 426 |
+
.iter()
|
| 427 |
+
.any(|header| bytes.starts_with(header))
|
| 428 |
+
{
|
| 429 |
+
return Err(AppError::BadRequest("Invalid image format".to_string()));
|
| 430 |
+
}
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
let merchant_plan: String =
|
| 434 |
+
sqlx::query_scalar("SELECT plan FROM merchants WHERE merchant_id = $1")
|
| 435 |
+
.bind(&order.merchant_id)
|
| 436 |
+
.fetch_one(&mut *tx)
|
| 437 |
+
.await
|
| 438 |
+
.map_err(AppError::Database)?;
|
| 439 |
+
|
| 440 |
+
let lat_val = lat.unwrap_or(0.0);
|
| 441 |
+
let lng_val = lng.unwrap_or(0.0);
|
| 442 |
+
let mut zk_proof = crate::core::crypto::CryptoService::generate_zk_telemetry_proof(
|
| 443 |
+
&transaction_id,
|
| 444 |
+
&bytes,
|
| 445 |
+
lat_val,
|
| 446 |
+
lng_val,
|
| 447 |
+
);
|
| 448 |
+
|
| 449 |
+
if merchant_plan == "PRO" {
|
| 450 |
+
let asset_path = assets
|
| 451 |
+
.store_asset(&filename, &bytes)
|
| 452 |
+
.await
|
| 453 |
+
.map_err(AppError::Internal)?;
|
| 454 |
+
|
| 455 |
+
if let Some(obj) = zk_proof.as_object_mut() {
|
| 456 |
+
obj.insert("is_zero_storage".to_string(), serde_json::json!(false));
|
| 457 |
+
obj.insert("asset_path".to_string(), serde_json::json!(asset_path));
|
| 458 |
+
}
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
let mut is_geofence_verified = None;
|
| 462 |
+
if let (Some(l_lat), Some(l_lng), Some(pincode)) = (lat, lng, &order.shipping_pincode) {
|
| 463 |
+
let (p_lat, p_lng) = crate::domain::geofence::GeofenceService::get_coordinates(pincode)
|
| 464 |
+
.unwrap_or((12.9716, 77.5946)); // Default to Bangalore Central
|
| 465 |
+
|
| 466 |
+
let distance = crate::domain::geofence::GeofenceService::calculate_distance_km(
|
| 467 |
+
l_lat, l_lng, p_lat, p_lng,
|
| 468 |
+
);
|
| 469 |
+
is_geofence_verified = Some(distance < 5.0); // 5km tolerance
|
| 470 |
+
|
| 471 |
+
if !is_geofence_verified.unwrap_or(false) {
|
| 472 |
+
crate::domain::audit::log_risk_event(
|
| 473 |
+
&mut tx,
|
| 474 |
+
Some(&transaction_id),
|
| 475 |
+
&order.merchant_id,
|
| 476 |
+
"GEOFENCE_VIOLATION",
|
| 477 |
+
"MEDIUM",
|
| 478 |
+
Some(&format!(
|
| 479 |
+
"Delivery proof submitted from {} km away from shipping pincode {}.",
|
| 480 |
+
distance.round(),
|
| 481 |
+
pincode
|
| 482 |
+
)),
|
| 483 |
+
Some(distance),
|
| 484 |
+
None,
|
| 485 |
+
order.device_fingerprint.as_deref(),
|
| 486 |
+
Some(tx_sender),
|
| 487 |
+
)
|
| 488 |
+
.await;
|
| 489 |
+
|
| 490 |
+
// Trust Score Penalty for logistics deviations
|
| 491 |
+
let _ = sqlx::query("UPDATE merchants SET trust_score = GREATEST(0.0, trust_score - 2.0) WHERE merchant_id = $1")
|
| 492 |
+
.bind(&order.merchant_id)
|
| 493 |
+
.execute(&mut *tx)
|
| 494 |
+
.await;
|
| 495 |
+
}
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
sqlx::query(
|
| 499 |
+
"UPDATE orders SET proof_data = proof_data || $1::jsonb, status = $2, delivered_at = CURRENT_TIMESTAMP, delivery_gps_lat = $3, delivery_gps_lng = $4, is_geofence_verified = $5 WHERE transaction_id = $6 AND status = $7",
|
| 500 |
+
)
|
| 501 |
+
.bind(serde_json::json!([zk_proof]))
|
| 502 |
+
.bind(crate::domain::constants::ORDER_STATUS_DELIVERED_PENDING_APPROVAL)
|
| 503 |
+
.bind(lat)
|
| 504 |
+
.bind(lng)
|
| 505 |
+
.bind(is_geofence_verified)
|
| 506 |
+
.bind(&transaction_id)
|
| 507 |
+
.bind(crate::domain::constants::ORDER_STATUS_PAID_PENDING_DELIVERY)
|
| 508 |
+
.execute(&mut *tx)
|
| 509 |
+
.await
|
| 510 |
+
.map_err(AppError::Database)?;
|
| 511 |
+
|
| 512 |
+
tx.commit().await.map_err(AppError::Database)?;
|
| 513 |
+
|
| 514 |
+
let _ = tx_sender.send(
|
| 515 |
+
crate::interfaces::http::api::RealtimeEvent::OrderStatusChanged {
|
| 516 |
+
transaction_id: transaction_id.to_string(),
|
| 517 |
+
merchant_id: order.merchant_id,
|
| 518 |
+
new_status: crate::domain::constants::ORDER_STATUS_DELIVERED_PENDING_APPROVAL
|
| 519 |
+
.to_string(),
|
| 520 |
+
},
|
| 521 |
+
);
|
| 522 |
+
|
| 523 |
+
Ok(())
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
#[allow(clippy::too_many_arguments)]
|
| 527 |
+
pub async fn execute_cart_checkout_helper(
|
| 528 |
+
product_repo: &Arc<dyn crate::infrastructure::repositories::ProductRepository>,
|
| 529 |
+
merchant_repo: &Arc<dyn crate::infrastructure::repositories::MerchantRepository>,
|
| 530 |
+
_order_repo: &Arc<dyn crate::infrastructure::repositories::OrderRepository>,
|
| 531 |
+
pool: &crate::infrastructure::db::DbPool,
|
| 532 |
+
tx_sender: &tokio::sync::broadcast::Sender<crate::interfaces::http::api::RealtimeEvent>,
|
| 533 |
+
items: Vec<(String, u32)>,
|
| 534 |
+
buyer_phone: &str,
|
| 535 |
+
buyer_name: &str,
|
| 536 |
+
buyer_email: &str,
|
| 537 |
+
shipping_pincode: &str,
|
| 538 |
+
delivery_address: &str,
|
| 539 |
+
coupon_code: Option<String>,
|
| 540 |
+
_request_id: Option<String>,
|
| 541 |
+
client_ip: &str,
|
| 542 |
+
lat: Option<f64>,
|
| 543 |
+
lng: Option<f64>,
|
| 544 |
+
device_fingerprint: Option<String>,
|
| 545 |
+
) -> AppResult<crate::domain::models::OrderRecord> {
|
| 546 |
+
let transaction_id = format!(
|
| 547 |
+
"TX_{}",
|
| 548 |
+
uuid::Uuid::new_v4().to_string()[..8].to_uppercase()
|
| 549 |
+
);
|
| 550 |
+
|
| 551 |
+
let mut total_price = 0.0;
|
| 552 |
+
let mut total_weight = 0.0;
|
| 553 |
+
let mut first_merchant_id = String::new();
|
| 554 |
+
let mut product_details = Vec::new();
|
| 555 |
+
|
| 556 |
+
for (link_id, qty) in &items {
|
| 557 |
+
let product = product_repo
|
| 558 |
+
.find_by_id(link_id)
|
| 559 |
+
.await?
|
| 560 |
+
.ok_or_else(|| AppError::NotFound(format!("Product {} not found", link_id)))?;
|
| 561 |
+
|
| 562 |
+
if first_merchant_id.is_empty() {
|
| 563 |
+
first_merchant_id = product.merchant_id.clone();
|
| 564 |
+
} else if first_merchant_id != product.merchant_id {
|
| 565 |
+
return Err(AppError::BadRequest(
|
| 566 |
+
"Cross-merchant checkout not allowed in single cart".into(),
|
| 567 |
+
));
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
let current_price = if let (Some(sale_price), Some(ends_at)) =
|
| 571 |
+
(product.sale_price_inr, product.sale_ends_at)
|
| 572 |
+
{
|
| 573 |
+
if ends_at > chrono::Utc::now().naive_utc() {
|
| 574 |
+
sale_price
|
| 575 |
+
} else {
|
| 576 |
+
product.price_inr
|
| 577 |
+
}
|
| 578 |
+
} else {
|
| 579 |
+
product.price_inr
|
| 580 |
+
};
|
| 581 |
+
|
| 582 |
+
total_price += current_price * (*qty as f64);
|
| 583 |
+
total_weight += product.expected_weight * (*qty as f64);
|
| 584 |
+
|
| 585 |
+
// Inventory Enforcement for Cart (Preliminary Check)
|
| 586 |
+
if !product.is_unlimited && product.inventory_count < *qty as i32 {
|
| 587 |
+
return Err(AppError::BadRequest(format!(
|
| 588 |
+
"Product '{}' is low on stock ({} available).",
|
| 589 |
+
product.product_name, product.inventory_count
|
| 590 |
+
)));
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
product_details.push((product, *qty));
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
if first_merchant_id.is_empty() {
|
| 597 |
+
return Err(AppError::BadRequest("Cart is empty".into()));
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
let merchant = merchant_repo
|
| 601 |
+
.find_by_id(&first_merchant_id)
|
| 602 |
+
.await?
|
| 603 |
+
.ok_or_else(|| AppError::NotFound("Merchant not found".into()))?;
|
| 604 |
+
|
| 605 |
+
if merchant.is_frozen {
|
| 606 |
+
return Err(AppError::Forbidden("Merchant account is frozen due to unpaid outstanding invoices.".to_string()));
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
// 2. Institutional Velocity Guard (Anti-Abuse)
|
| 610 |
+
let intelligence =
|
| 611 |
+
crate::application::services::intelligence::IntelligenceService::new(pool.clone());
|
| 612 |
+
let velocity_risk = intelligence
|
| 613 |
+
.evaluate_velocity_risk(
|
| 614 |
+
device_fingerprint.as_deref(),
|
| 615 |
+
Some(client_ip),
|
| 616 |
+
&first_merchant_id,
|
| 617 |
+
)
|
| 618 |
+
.await?;
|
| 619 |
+
|
| 620 |
+
if velocity_risk >= 90.0 {
|
| 621 |
+
return Err(AppError::Forbidden(
|
| 622 |
+
"Security Protocol Active: High-frequency transaction activity detected from this device/network. Access restricted for protocol safety.".to_string()
|
| 623 |
+
));
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
let distance_km =
|
| 627 |
+
crate::domain::distance::estimate_distance_km(&merchant.base_pincode, shipping_pincode);
|
| 628 |
+
|
| 629 |
+
let pricing_features = crate::application::services::pricing::PricingFeatures {
|
| 630 |
+
distance_km,
|
| 631 |
+
user_rate_per_km: merchant.delivery_rate_per_km,
|
| 632 |
+
product_weight: total_weight,
|
| 633 |
+
base_charge: merchant.delivery_base_fee,
|
| 634 |
+
config: serde_json::from_value(merchant.logistics_config.clone()).unwrap_or_default(),
|
| 635 |
+
};
|
| 636 |
+
|
| 637 |
+
let delivery_fee = crate::application::services::pricing::PricingEngine::estimate_delivery_fee(
|
| 638 |
+
pricing_features,
|
| 639 |
+
);
|
| 640 |
+
|
| 641 |
+
// Precision Geofence Check
|
| 642 |
+
let mut geofence_verified = None;
|
| 643 |
+
if let (Some(l_lat), Some(l_lng)) = (lat, lng) {
|
| 644 |
+
let intelligence =
|
| 645 |
+
crate::application::services::intelligence::IntelligenceService::new(pool.clone());
|
| 646 |
+
if !intelligence
|
| 647 |
+
.verify_geofence_with_precision(shipping_pincode, l_lat, l_lng)
|
| 648 |
+
.await?
|
| 649 |
+
{
|
| 650 |
+
return Err(AppError::Forbidden(format!(
|
| 651 |
+
"Geofence Verification Failed: Your current GPS coordinates do not match the shipping pincode {}. Forensic integrity active.",
|
| 652 |
+
shipping_pincode
|
| 653 |
+
)));
|
| 654 |
+
}
|
| 655 |
+
geofence_verified = Some(true);
|
| 656 |
+
}
|
| 657 |
+
let platform_fee = crate::application::services::pricing::PricingEngine::calculate_platform_fee(
|
| 658 |
+
total_price,
|
| 659 |
+
merchant.trust_score,
|
| 660 |
+
);
|
| 661 |
+
|
| 662 |
+
let gst_breakdown = crate::application::services::india_tax::IndiaTaxService::calculate_gst(
|
| 663 |
+
total_price,
|
| 664 |
+
merchant.state_code.unwrap_or(29),
|
| 665 |
+
shipping_pincode,
|
| 666 |
+
0.18,
|
| 667 |
+
);
|
| 668 |
+
|
| 669 |
+
let volatility =
|
| 670 |
+
crate::application::services::intelligence::IntelligenceService::new(pool.clone())
|
| 671 |
+
.get_pincode_volatility(shipping_pincode)
|
| 672 |
+
.await
|
| 673 |
+
.unwrap_or(0.0);
|
| 674 |
+
|
| 675 |
+
// Calculate preliminary risk score
|
| 676 |
+
let mut calculated_risk = 0.0;
|
| 677 |
+
calculated_risk += (volatility * 50.0).min(30.0);
|
| 678 |
+
calculated_risk += velocity_risk * 0.4;
|
| 679 |
+
if geofence_verified == Some(true) {
|
| 680 |
+
calculated_risk *= 0.8;
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
let mut order = crate::domain::models::OrderRecord {
|
| 684 |
+
transaction_id: transaction_id.clone(),
|
| 685 |
+
merchant_id: first_merchant_id.clone(),
|
| 686 |
+
link_id: "CART_TRANSACTION".into(),
|
| 687 |
+
buyer_phone: buyer_phone.to_string(),
|
| 688 |
+
buyer_phone_hash: None, // populated by encrypt_pii() at persist time
|
| 689 |
+
buyer_name: buyer_name.to_string(),
|
| 690 |
+
buyer_email: buyer_email.to_string(),
|
| 691 |
+
shipping_pincode: Some(shipping_pincode.to_string()),
|
| 692 |
+
delivery_address: Some(delivery_address.to_string()),
|
| 693 |
+
price_inr: total_price,
|
| 694 |
+
status: crate::domain::constants::ORDER_STATUS_PENDING_PAYMENT.to_string(),
|
| 695 |
+
vpa: Some(String::new()),
|
| 696 |
+
outbound_weight: total_weight,
|
| 697 |
+
return_weight: 0.0,
|
| 698 |
+
proof_data: Some(serde_json::json!([])),
|
| 699 |
+
settled_at: None,
|
| 700 |
+
shipped_at: None,
|
| 701 |
+
delivered_at: None,
|
| 702 |
+
shipping_method: None,
|
| 703 |
+
estimated_delivery_at: None,
|
| 704 |
+
payu_id: String::new(),
|
| 705 |
+
is_payment: false,
|
| 706 |
+
platform_fee_paid: false,
|
| 707 |
+
platform_fee,
|
| 708 |
+
delivery_fee,
|
| 709 |
+
distance_km,
|
| 710 |
+
risk_score: calculated_risk,
|
| 711 |
+
risk_flags: None,
|
| 712 |
+
cgst: gst_breakdown.cgst,
|
| 713 |
+
sgst: gst_breakdown.sgst,
|
| 714 |
+
igst: gst_breakdown.igst,
|
| 715 |
+
utr_number: None,
|
| 716 |
+
platform_fee_utr: None,
|
| 717 |
+
delivery_gps_lat: None,
|
| 718 |
+
delivery_gps_lng: None,
|
| 719 |
+
is_geofence_verified: geofence_verified,
|
| 720 |
+
pincode_volatility_at_checkout: volatility,
|
| 721 |
+
discount_amount: 0.0,
|
| 722 |
+
coupon_code: None,
|
| 723 |
+
checkout_gps_lat: lat,
|
| 724 |
+
checkout_gps_lng: lng,
|
| 725 |
+
device_fingerprint: device_fingerprint.clone(),
|
| 726 |
+
paid_at: None,
|
| 727 |
+
proof_received_at: None,
|
| 728 |
+
created_at: None,
|
| 729 |
+
brand_name: None,
|
| 730 |
+
};
|
| 731 |
+
|
| 732 |
+
let mut tx = pool.begin().await.map_err(AppError::Database)?;
|
| 733 |
+
|
| 734 |
+
// Enforce cart inventory atomically within the transaction
|
| 735 |
+
for (p, qty) in &product_details {
|
| 736 |
+
if !p.is_unlimited {
|
| 737 |
+
let rows_affected = sqlx::query(
|
| 738 |
+
"UPDATE product_links SET inventory_count = inventory_count - $1 WHERE link_id = $2 AND inventory_count >= $3",
|
| 739 |
+
)
|
| 740 |
+
.bind(*qty as i32)
|
| 741 |
+
.bind(&p.link_id)
|
| 742 |
+
.bind(*qty as i32)
|
| 743 |
+
.execute(&mut *tx)
|
| 744 |
+
.await
|
| 745 |
+
.map_err(AppError::Database)?
|
| 746 |
+
.rows_affected();
|
| 747 |
+
|
| 748 |
+
if rows_affected == 0 {
|
| 749 |
+
return Err(AppError::BadRequest(format!(
|
| 750 |
+
"Product '{}' went out of stock or has insufficient quantity.",
|
| 751 |
+
p.product_name
|
| 752 |
+
)));
|
| 753 |
+
}
|
| 754 |
+
}
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
if let Some(ref code) = coupon_code {
|
| 758 |
+
let coupon = sqlx::query_as::<_, crate::domain::models::Coupon>(
|
| 759 |
+
"SELECT * FROM coupons WHERE merchant_id = $1 AND code = $2 AND is_active = TRUE",
|
| 760 |
+
)
|
| 761 |
+
.bind(&first_merchant_id)
|
| 762 |
+
.bind(code.to_uppercase())
|
| 763 |
+
.fetch_optional(&mut *tx)
|
| 764 |
+
.await?;
|
| 765 |
+
|
| 766 |
+
if let Some(c) = coupon {
|
| 767 |
+
if c.is_valid(order.price_inr) {
|
| 768 |
+
let discount = c.calculate_discount(order.price_inr);
|
| 769 |
+
order.discount_amount = discount;
|
| 770 |
+
order.price_inr -= discount;
|
| 771 |
+
order.coupon_code = Some(code.clone());
|
| 772 |
+
|
| 773 |
+
let _ =
|
| 774 |
+
sqlx::query("UPDATE coupons SET usage_count = usage_count + 1 WHERE id = $1")
|
| 775 |
+
.bind(c.id)
|
| 776 |
+
.execute(&mut *tx)
|
| 777 |
+
.await;
|
| 778 |
+
}
|
| 779 |
+
}
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
let (risk_score, risk_flags) =
|
| 783 |
+
crate::application::services::risk::RiskEngine::calculate_risk_score(&order, 0, volatility);
|
| 784 |
+
order.risk_score = risk_score;
|
| 785 |
+
order.risk_flags = Some(risk_flags);
|
| 786 |
+
|
| 787 |
+
if order.risk_score >= 80.0 {
|
| 788 |
+
// Log Critical/High Security Event outside the transaction so it's persisted even when rolled back
|
| 789 |
+
if let Ok(mut conn) = pool.acquire().await {
|
| 790 |
+
crate::domain::audit::log_risk_event(
|
| 791 |
+
&mut *conn,
|
| 792 |
+
Some(&transaction_id),
|
| 793 |
+
&first_merchant_id,
|
| 794 |
+
"HIGH_RISK_BLOCK",
|
| 795 |
+
"CRITICAL",
|
| 796 |
+
Some(&format!(
|
| 797 |
+
"Cart transaction blocked due to high risk score ({:.1}) during checkout. Flags: {:?}",
|
| 798 |
+
order.risk_score, order.risk_flags
|
| 799 |
+
)),
|
| 800 |
+
Some(order.risk_score),
|
| 801 |
+
None,
|
| 802 |
+
device_fingerprint.as_deref(),
|
| 803 |
+
Some(tx_sender),
|
| 804 |
+
)
|
| 805 |
+
.await;
|
| 806 |
+
}
|
| 807 |
+
|
| 808 |
+
if order.risk_score > 90.0 {
|
| 809 |
+
crate::interfaces::http::middleware::block_ip_persistently(
|
| 810 |
+
pool,
|
| 811 |
+
client_ip,
|
| 812 |
+
&format!(
|
| 813 |
+
"Automated Defense: High Risk Score ({:.1}) detected during cart checkout",
|
| 814 |
+
order.risk_score
|
| 815 |
+
),
|
| 816 |
+
Some(tx_sender),
|
| 817 |
+
)
|
| 818 |
+
.await;
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
return Err(AppError::Forbidden(format!(
|
| 822 |
+
"Security restriction: High risk profile detected (Score: {:.1}). This transaction has been blocked to prevent potential fraud.",
|
| 823 |
+
order.risk_score
|
| 824 |
+
)));
|
| 825 |
+
} else if order.risk_score > 60.0 {
|
| 826 |
+
crate::domain::audit::log_risk_event(
|
| 827 |
+
&mut tx,
|
| 828 |
+
Some(&transaction_id),
|
| 829 |
+
&first_merchant_id,
|
| 830 |
+
"HIGH_RISK_ORDER",
|
| 831 |
+
"HIGH",
|
| 832 |
+
Some(&format!(
|
| 833 |
+
"Cart order {} flagged with risk score {}",
|
| 834 |
+
transaction_id, order.risk_score
|
| 835 |
+
)),
|
| 836 |
+
Some(order.risk_score),
|
| 837 |
+
None,
|
| 838 |
+
device_fingerprint.as_deref(),
|
| 839 |
+
Some(tx_sender),
|
| 840 |
+
)
|
| 841 |
+
.await;
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
// Persist Order
|
| 845 |
+
order.created_at = Some(chrono::Utc::now().naive_utc());
|
| 846 |
+
crate::domain::models::OrderRecord::create_with_tx(&mut tx, &order).await?;
|
| 847 |
+
|
| 848 |
+
// Persist Order Items
|
| 849 |
+
for (p, qty) in product_details {
|
| 850 |
+
sqlx::query(
|
| 851 |
+
"INSERT INTO order_items (transaction_id, product_id, product_name, quantity, price_at_checkout, weight_at_checkout) VALUES ($1, $2, $3, $4, $5, $6)"
|
| 852 |
+
)
|
| 853 |
+
.bind(&transaction_id)
|
| 854 |
+
.bind(&p.link_id)
|
| 855 |
+
.bind(&p.product_name)
|
| 856 |
+
.bind(qty as i32)
|
| 857 |
+
.bind(p.price_inr)
|
| 858 |
+
.bind(p.expected_weight)
|
| 859 |
+
.execute(&mut *tx)
|
| 860 |
+
.await
|
| 861 |
+
.map_err(AppError::Database)?;
|
| 862 |
+
}
|
| 863 |
+
tx.commit().await.map_err(AppError::Database)?;
|
| 864 |
+
|
| 865 |
+
let _ = tx_sender.send(crate::interfaces::http::api::RealtimeEvent::NewOrder {
|
| 866 |
+
transaction_id: transaction_id.clone(),
|
| 867 |
+
merchant_id: order.merchant_id.clone(),
|
| 868 |
+
amount: total_price,
|
| 869 |
+
buyer_phone: buyer_phone.to_string(),
|
| 870 |
+
});
|
| 871 |
+
|
| 872 |
+
Ok(order)
|
| 873 |
+
}
|
src/application/services/customer.rs
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use crate::domain::error::{AppError, AppResult};
|
| 2 |
+
use crate::domain::models::{Customer, CustomerProfile, OrderRecord};
|
| 3 |
+
use crate::infrastructure::db::DbPool;
|
| 4 |
+
use argon2::{
|
| 5 |
+
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
| 6 |
+
Argon2,
|
| 7 |
+
};
|
| 8 |
+
use async_trait::async_trait;
|
| 9 |
+
use jsonwebtoken::{encode, EncodingKey, Header};
|
| 10 |
+
use serde::{Deserialize, Serialize};
|
| 11 |
+
#[allow(unused_imports)]
|
| 12 |
+
use std::sync::Arc;
|
| 13 |
+
use uuid::Uuid;
|
| 14 |
+
|
| 15 |
+
#[derive(Debug, Serialize, Deserialize)]
|
| 16 |
+
pub struct CustomerClaims {
|
| 17 |
+
pub sub: String, // customer_id
|
| 18 |
+
pub phone: String,
|
| 19 |
+
pub role: String,
|
| 20 |
+
pub exp: usize,
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
#[async_trait]
|
| 24 |
+
pub trait CustomerService: Send + Sync {
|
| 25 |
+
async fn signup(
|
| 26 |
+
&self,
|
| 27 |
+
phone: &str,
|
| 28 |
+
password: &str,
|
| 29 |
+
name: Option<&str>,
|
| 30 |
+
email: Option<&str>,
|
| 31 |
+
) -> AppResult<String>;
|
| 32 |
+
async fn login(&self, phone: &str, password: &str) -> AppResult<String>;
|
| 33 |
+
async fn get_orders(
|
| 34 |
+
&self,
|
| 35 |
+
customer_id: &str,
|
| 36 |
+
merchant_id: Option<&str>,
|
| 37 |
+
) -> AppResult<Vec<OrderRecord>>;
|
| 38 |
+
async fn get_profile(&self, customer_id: &str) -> AppResult<Option<CustomerProfile>>;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
pub struct RtixCustomerService {
|
| 42 |
+
pool: DbPool,
|
| 43 |
+
jwt_secret: Vec<u8>,
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
impl RtixCustomerService {
|
| 47 |
+
pub fn new(pool: DbPool, jwt_secret: Vec<u8>) -> Self {
|
| 48 |
+
Self { pool, jwt_secret }
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
fn hash_password(&self, password: &str) -> AppResult<String> {
|
| 52 |
+
let salt = SaltString::generate(&mut rand::thread_rng());
|
| 53 |
+
Argon2::default()
|
| 54 |
+
.hash_password(password.as_bytes(), &salt)
|
| 55 |
+
.map(|h| h.to_string())
|
| 56 |
+
.map_err(|e| AppError::Internal(format!("Hashing failed: {}", e)))
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
fn verify_password(&self, password: &str, hash: &str) -> AppResult<()> {
|
| 60 |
+
let parsed_hash = PasswordHash::new(hash)
|
| 61 |
+
.map_err(|_| AppError::Internal("Invalid hash stored".to_string()))?;
|
| 62 |
+
Argon2::default()
|
| 63 |
+
.verify_password(password.as_bytes(), &parsed_hash)
|
| 64 |
+
.map_err(|_| AppError::Auth("Invalid credentials".to_string()))
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
#[async_trait]
|
| 69 |
+
impl CustomerService for RtixCustomerService {
|
| 70 |
+
async fn signup(
|
| 71 |
+
&self,
|
| 72 |
+
phone: &str,
|
| 73 |
+
password: &str,
|
| 74 |
+
name: Option<&str>,
|
| 75 |
+
email: Option<&str>,
|
| 76 |
+
) -> AppResult<String> {
|
| 77 |
+
let password_hash = self.hash_password(password)?;
|
| 78 |
+
let customer_id = Uuid::new_v4().to_string();
|
| 79 |
+
|
| 80 |
+
let res = sqlx::query(
|
| 81 |
+
"INSERT INTO customers (customer_id, phone, name, email, password_hash) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (phone) DO NOTHING"
|
| 82 |
+
)
|
| 83 |
+
.bind(&customer_id)
|
| 84 |
+
.bind(phone)
|
| 85 |
+
.bind(name)
|
| 86 |
+
.bind(email)
|
| 87 |
+
.bind(password_hash)
|
| 88 |
+
.execute(&self.pool)
|
| 89 |
+
.await?;
|
| 90 |
+
|
| 91 |
+
if res.rows_affected() == 0 {
|
| 92 |
+
return Err(AppError::Conflict(
|
| 93 |
+
"An account with this phone number already exists".into(),
|
| 94 |
+
));
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
Ok(customer_id)
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
async fn login(&self, phone: &str, password: &str) -> AppResult<String> {
|
| 101 |
+
let customer = sqlx::query_as::<_, Customer>("SELECT * FROM customers WHERE phone = $1 OR email = $1")
|
| 102 |
+
.bind(phone)
|
| 103 |
+
.fetch_optional(&self.pool)
|
| 104 |
+
.await?
|
| 105 |
+
.ok_or_else(|| AppError::Auth("Invalid credentials".into()))?;
|
| 106 |
+
|
| 107 |
+
self.verify_password(password, &customer.password_hash)?;
|
| 108 |
+
|
| 109 |
+
let exp = (chrono::Utc::now() + chrono::Duration::days(7)).timestamp() as usize;
|
| 110 |
+
let claims = CustomerClaims {
|
| 111 |
+
sub: customer.customer_id.clone(),
|
| 112 |
+
phone: customer.phone.clone(),
|
| 113 |
+
role: "customer".into(),
|
| 114 |
+
exp,
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
let token = encode(
|
| 118 |
+
&Header::default(),
|
| 119 |
+
&claims,
|
| 120 |
+
&EncodingKey::from_secret(&self.jwt_secret),
|
| 121 |
+
)
|
| 122 |
+
.map_err(|_| AppError::Internal("Token generation failed".into()))?;
|
| 123 |
+
|
| 124 |
+
Ok(token)
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
async fn get_orders(
|
| 128 |
+
&self,
|
| 129 |
+
customer_id: &str,
|
| 130 |
+
merchant_id: Option<&str>,
|
| 131 |
+
) -> AppResult<Vec<OrderRecord>> {
|
| 132 |
+
use crate::core::crypto::CryptoService;
|
| 133 |
+
use sqlx::Row;
|
| 134 |
+
|
| 135 |
+
// 1. Retrieve the customer's plaintext phone number from the auth table
|
| 136 |
+
let row = sqlx::query("SELECT phone FROM customers WHERE customer_id = $1")
|
| 137 |
+
.bind(customer_id)
|
| 138 |
+
.fetch_optional(&self.pool)
|
| 139 |
+
.await?
|
| 140 |
+
.ok_or_else(|| AppError::NotFound("Customer not found".into()))?;
|
| 141 |
+
let target_phone: String = row.get("phone");
|
| 142 |
+
|
| 143 |
+
// 2. Compute a deterministic HMAC-SHA256 blind index of the phone number.
|
| 144 |
+
// This allows an O(1) indexed DB lookup without exposing PII in the query.
|
| 145 |
+
let phone_hash = CryptoService::deterministic_hash(&target_phone);
|
| 146 |
+
|
| 147 |
+
// 3. Run an indexed query — hit buyer_phone_hash directly, no full-table scan.
|
| 148 |
+
let raw_orders: Vec<OrderRecord> = if let Some(m_id) = merchant_id {
|
| 149 |
+
sqlx::query_as::<_, OrderRecord>(
|
| 150 |
+
"SELECT o.*, m.brand_name FROM orders o LEFT JOIN merchants m ON o.merchant_id = m.merchant_id WHERE o.buyer_phone_hash = $1 AND o.merchant_id = $2 ORDER BY o.created_at DESC",
|
| 151 |
+
)
|
| 152 |
+
.bind(&phone_hash)
|
| 153 |
+
.bind(m_id)
|
| 154 |
+
.fetch_all(&self.pool)
|
| 155 |
+
.await?
|
| 156 |
+
} else {
|
| 157 |
+
sqlx::query_as::<_, OrderRecord>(
|
| 158 |
+
"SELECT o.*, m.brand_name FROM orders o LEFT JOIN merchants m ON o.merchant_id = m.merchant_id WHERE o.buyer_phone_hash = $1 ORDER BY o.created_at DESC",
|
| 159 |
+
)
|
| 160 |
+
.bind(&phone_hash)
|
| 161 |
+
.fetch_all(&self.pool)
|
| 162 |
+
.await?
|
| 163 |
+
};
|
| 164 |
+
|
| 165 |
+
// 4. Decrypt only the matched rows (typically a very small set per customer)
|
| 166 |
+
let matched_orders = raw_orders
|
| 167 |
+
.into_iter()
|
| 168 |
+
.map(|mut order| {
|
| 169 |
+
order.decrypt_pii();
|
| 170 |
+
if order.vpa.as_deref() == Some("") {
|
| 171 |
+
order.vpa = None;
|
| 172 |
+
}
|
| 173 |
+
order
|
| 174 |
+
})
|
| 175 |
+
.collect();
|
| 176 |
+
|
| 177 |
+
Ok(matched_orders)
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
async fn get_profile(&self, customer_id: &str) -> AppResult<Option<CustomerProfile>> {
|
| 181 |
+
let profile = sqlx::query_as::<_, CustomerProfile>(
|
| 182 |
+
"SELECT customer_id, phone, email, name FROM customers WHERE customer_id = $1",
|
| 183 |
+
)
|
| 184 |
+
.bind(customer_id)
|
| 185 |
+
.fetch_optional(&self.pool)
|
| 186 |
+
.await?;
|
| 187 |
+
Ok(profile)
|
| 188 |
+
}
|
| 189 |
+
}
|