github-actions commited on
Commit
d8ffec9
·
0 Parent(s):

deploy: clean backend production release

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +19 -0
  2. .sqlx/query-5574d58fbcbd8d25f772c38a1aa87b08fb00ca58bd635570528271c95bfc9184.json +19 -0
  3. .sqlx/query-af708ad96d5c6081d4d4ee814d5bd24008ea285217e846f547c8c3d9fec58f25.json +54 -0
  4. .sqlx/query-c8f23356524b430677693128e4956749f2644b6d8109a974bae1f9b4e32c3a57.json +18 -0
  5. Cargo.lock +0 -0
  6. Cargo.toml +54 -0
  7. Dockerfile +60 -0
  8. README.md +17 -0
  9. migrations/0001_baseline.sql +145 -0
  10. migrations/0002_api_keys.sql +13 -0
  11. migrations/0003_orders_jsonb_proof.sql +13 -0
  12. migrations/0004_webhooks.sql +13 -0
  13. migrations/0005_audit_request_id.sql +7 -0
  14. migrations/0006_order_risk_flags.sql +3 -0
  15. migrations/0007_indian_market_hardening.sql +11 -0
  16. migrations/0008_carrier_registry.sql +17 -0
  17. migrations/0009_dispute_evidence.sql +17 -0
  18. migrations/0010_sovereign_intelligence.sql +17 -0
  19. migrations/0011_forensic_chain.sql +10 -0
  20. migrations/0012_multi_item_orders.sql +14 -0
  21. migrations/0013_merchant_trust_score.sql +5 -0
  22. migrations/0014_product_feedback.sql +12 -0
  23. migrations/0015_merchant_verification.sql +6 -0
  24. migrations/0016_carrier_registry.sql +15 -0
  25. migrations/0017_product_inventory.sql +3 -0
  26. migrations/0018_merchant_ux_expansion.sql +3 -0
  27. migrations/0019_growth_suite.sql +20 -0
  28. migrations/0020_social_growth.sql +5 -0
  29. migrations/0021_settlement_ledger.sql +18 -0
  30. migrations/0022_velocity_guard.sql +28 -0
  31. migrations/0023_merchant_plan.sql +2 -0
  32. migrations/0024_subscriptions.sql +55 -0
  33. migrations/0025_payouts.sql +55 -0
  34. migrations/0026_ai_engineer.sql +23 -0
  35. migrations/0027_ai_engineer_test_diff.sql +2 -0
  36. migrations/0028_ai_engineer_feedback.sql +3 -0
  37. migrations/0029_secure_upi_mandate_hardening.sql +11 -0
  38. migrations/0030_blind_indexing_and_payout_gating.sql +8 -0
  39. migrations/0031_oauth_accounts.sql +21 -0
  40. migrations/0032_add_platform_fee_utr.sql +6 -0
  41. migrations/0033_postpaid_billing_cycle.sql +19 -0
  42. src/application/mod.rs +2 -0
  43. src/application/reconciliation.rs +214 -0
  44. src/application/services/arbitration.rs +81 -0
  45. src/application/services/auth.rs +366 -0
  46. src/application/services/background.rs +299 -0
  47. src/application/services/billing.rs +125 -0
  48. src/application/services/checkout.rs +233 -0
  49. src/application/services/checkout_impl/mod.rs +873 -0
  50. 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
+ }