-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathschema.py
More file actions
252 lines (210 loc) · 12.6 KB
/
Copy pathschema.py
File metadata and controls
252 lines (210 loc) · 12.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
"""
Multi-rail transaction schema for fraud detection.
Rails: UPI · AEPS · DMT · Cards · Loans
PCI-DSS: no raw PAN, no CVV/CVV2 storage beyond auth.
Aadhaar Act: raw biometric NEVER stored — quality metadata only.
"""
from __future__ import annotations
import hashlib
from datetime import datetime
from decimal import Decimal
from typing import Annotated, Literal, Optional
from pydantic import BaseModel, Field, field_validator, model_validator
# ── Rail type ─────────────────────────────────────────────────────────────────
Rail = Literal["upi", "aeps", "dmt", "cards", "loans", "utility"]
Channel = Literal[
"upi_collect",
"upi_push",
"aeps_withdrawal",
"aeps_balance",
"dmt_transfer",
"card_present",
"card_not_present",
"atm",
"ecommerce",
"recurring",
"loan_disbursement",
"loan_repayment",
"utility_payment",
]
DataSource = Literal["ieee_cis", "uc_san_diego", "synthetic", "real_org"]
LabelSource = Literal[
"confirmed_chargeback",
"uidai_dispute",
"bank_dispute",
"manual_review",
"rule_triggered",
"synthetic",
]
# ── Core transaction record ───────────────────────────────────────────────────
class TransactionRecord(BaseModel):
"""
Canonical multi-rail transaction schema.
Used for: data ingestion, synthetic generation, feature engineering input.
"""
model_config = {"frozen": True, "str_strip_whitespace": True}
# ── Identity ──────────────────────────────────────────────────────────────
transaction_id: str = Field(..., min_length=1, max_length=64)
timestamp_utc: datetime # always UTC; store/compare in UTC only
rail: Rail
channel: Channel
data_source: DataSource
# ── Amount ────────────────────────────────────────────────────────────────
amount_inr: Decimal = Field(..., ge=Decimal("0.01"), le=Decimal("10000000"))
amount_usd: Optional[Decimal] = Field(None, ge=Decimal("0"))
currency_code: str = Field(default="INR", min_length=3, max_length=3)
# ── Customer identity ─────────────────────────────────────────────────────
customer_id_hash: str = Field(..., description="SHA-256 of internal customer ID")
# Aadhaar: only last-4 safe to store for matching (not KYC anchor in model)
aadhaar_last4: Optional[str] = Field(None, min_length=4, max_length=4, pattern=r"^\d{4}$")
# ── Card (PCI-DSS compliant — no raw PAN anywhere) ────────────────────────
card_token: Optional[str] = Field(None, description="Tokenised — never raw PAN")
bin_8: Optional[str] = Field(None, min_length=6, max_length=8, pattern=r"^\d{6,8}$")
pan_last4: Optional[str] = Field(None, min_length=4, max_length=4, pattern=r"^\d{4}$")
card_type: Optional[Literal["credit", "debit", "prepaid", "corporate", "rupay"]] = None
is_prepaid: Optional[bool] = None
# ── UPI-specific ──────────────────────────────────────────────────────────
vpa_sender_hash: Optional[str] = Field(None, description="SHA-256 of sender VPA")
vpa_receiver_hash: Optional[str] = Field(None, description="SHA-256 of receiver VPA")
upi_app: Optional[str] = Field(None, max_length=32) # "gpay","phonepe","paytm","bhim"
upi_txn_ref: Optional[str] = Field(None, max_length=64) # UPI transaction reference
consent_timestamp_utc: Optional[datetime] = None # time user approved collect request
# ── AEPS-specific (NO raw biometric — Aadhaar Act §29) ───────────────────
biometric_quality_score: Optional[float] = Field(
None, ge=0.0, le=100.0,
description="UIDAI-returned match quality 0-100. Never raw fingerprint template."
)
biometric_attempt_count: Optional[int] = Field(None, ge=1, le=10)
session_duration_seconds: Optional[float] = Field(None, ge=0.0)
uidai_response_code: Optional[str] = Field(None, max_length=8)
# ── DMT-specific ──────────────────────────────────────────────────────────
beneficiary_account_hash: Optional[str] = Field(
None, description="SHA-256 of beneficiary account number"
)
beneficiary_bank_ifsc: Optional[str] = Field(None, min_length=11, max_length=11)
beneficiary_state: Optional[str] = Field(None, max_length=32)
beneficiary_is_new: Optional[bool] = None # first transfer to this account for sender
transfer_purpose: Optional[str] = Field(None, max_length=64)
# ── Merchant / Location ───────────────────────────────────────────────────
merchant_id: Optional[str] = Field(None, max_length=64)
mcc_code: Optional[str] = Field(None, min_length=4, max_length=4, pattern=r"^\d{4}$")
merchant_country: str = Field(default="IN", min_length=2, max_length=2)
merchant_state: Optional[str] = Field(None, max_length=32)
merchant_district: Optional[str] = Field(None, max_length=64)
# ── BC Agent / Device ─────────────────────────────────────────────────────
agent_id: Optional[str] = Field(None, max_length=64)
agent_district: Optional[str] = Field(None, max_length=64)
device_id_hash: Optional[str] = Field(
None, description="SHA-256 of micro-ATM device fingerprint"
)
device_firmware_version: Optional[str] = Field(None, max_length=32)
# ── Network ───────────────────────────────────────────────────────────────
ip_address_hash: Optional[str] = Field(
None, description="SHA-256+salt of IP; never raw IP"
)
# ── Geolocation (approximate — district/state level for AEPS) ─────────────
txn_lat: Optional[float] = Field(None, ge=-90.0, le=90.0)
txn_lon: Optional[float] = Field(None, ge=-180.0, le=180.0)
# ── Auth signals ──────────────────────────────────────────────────────────
cvv_result: Optional[Literal["match", "no_match", "not_provided"]] = None
avs_result: Optional[str] = Field(None, max_length=8)
three_ds_result: Optional[Literal["authenticated", "not_authenticated", "not_attempted"]] = None
# ── Loan-specific ─────────────────────────────────────────────────────────
loan_product_type: Optional[str] = Field(None, max_length=32)
loan_amount_inr: Optional[Decimal] = Field(None, ge=Decimal("0"))
bureau_pull_count_30d: Optional[int] = Field(None, ge=0)
# ── Label (training only — not present at inference) ──────────────────────
is_fraud: Optional[int] = Field(None, ge=0, le=1)
label_source: Optional[LabelSource] = None
label_confirmed_at_utc: Optional[datetime] = None # label arrival timestamp
# ── Metadata ──────────────────────────────────────────────────────────────
schema_version: str = Field(default="2.0.0")
# ── Validators ────────────────────────────────────────────────────────────
@field_validator("customer_id_hash", "device_id_hash", "ip_address_hash", mode="before")
@classmethod
def validate_hash_format(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
if len(v) not in (64, 128): # SHA-256 or SHA-512 hex
raise ValueError(f"Hash must be 64 or 128 hex chars, got {len(v)}")
return v.lower()
@model_validator(mode="after")
def validate_rail_fields(self) -> "TransactionRecord":
if self.rail == "upi" and self.vpa_sender_hash is None:
raise ValueError("UPI transactions require vpa_sender_hash")
if self.rail == "aeps" and self.agent_id is None:
raise ValueError("AEPS transactions require agent_id")
if self.rail == "dmt" and self.beneficiary_account_hash is None:
raise ValueError("DMT transactions require beneficiary_account_hash")
if self.rail == "cards" and self.card_token is None:
raise ValueError("Card transactions require card_token")
return self
@field_validator("aadhaar_last4", mode="before")
@classmethod
def no_full_aadhaar(cls, v: Optional[str]) -> Optional[str]:
if v and len(v) > 4:
raise ValueError("Full Aadhaar prohibited — store last-4 only")
return v
# ── Helpers ───────────────────────────────────────────────────────────────
def is_training_record(self) -> bool:
return self.is_fraud is not None
def rail_is_india_specific(self) -> bool:
return self.rail in ("upi", "aeps", "dmt")
@classmethod
def hash_value(cls, value: str, salt: str = "") -> str:
return hashlib.sha256(f"{salt}{value}".encode()).hexdigest()
# ── Inference request (subset of TransactionRecord, label excluded) ───────────
class TransactionScoreRequest(BaseModel):
"""API payload for POST /v1/transaction/score. No label fields."""
model_config = {"str_strip_whitespace": True}
transaction_id: str = Field(..., min_length=1, max_length=64)
timestamp_utc: datetime
rail: Rail
channel: Channel
amount_inr: Decimal = Field(..., ge=Decimal("0.01"))
currency_code: str = Field(default="INR")
customer_id_hash: str
client_id: Optional[str] = Field(None, max_length=64, description="BaaS tenant identifier")
aadhaar_last4: Optional[str] = None
card_token: Optional[str] = None
bin_8: Optional[str] = None
pan_last4: Optional[str] = None
card_type: Optional[str] = None
is_prepaid: Optional[bool] = None
vpa_sender_hash: Optional[str] = None
vpa_receiver_hash: Optional[str] = None
upi_app: Optional[str] = None
consent_timestamp_utc: Optional[datetime] = None
biometric_quality_score: Optional[float] = None
biometric_attempt_count: Optional[int] = None
session_duration_seconds: Optional[float] = None
beneficiary_account_hash: Optional[str] = None
beneficiary_bank_ifsc: Optional[str] = None
beneficiary_is_new: Optional[bool] = None
merchant_id: Optional[str] = None
mcc_code: Optional[str] = None
merchant_country: str = Field(default="IN")
agent_id: Optional[str] = None
device_id_hash: Optional[str] = None
ip_address_hash: Optional[str] = None
txn_lat: Optional[float] = None
txn_lon: Optional[float] = None
cvv_result: Optional[str] = None
three_ds_result: Optional[str] = None
# ── Inference response ────────────────────────────────────────────────────────
class ScoredReason(BaseModel):
feature: str
shap_value: float
direction: Literal["increases_risk", "decreases_risk"]
class TransactionScoreResponse(BaseModel):
transaction_id: str
decision: Literal["ALLOW", "REVIEW", "BLOCK"]
risk_score: float = Field(..., ge=0.0, le=1.0)
risk_score_int: int = Field(..., ge=0, le=1000)
model_scores: dict[str, Optional[float]] # optional models (xgb/catboost) are null when not loaded
rules_triggered: list[dict]
top_reasons: list[ScoredReason]
latency_ms: float
model_version: str
confidence: Literal["HIGH", "MEDIUM", "LOW"]
rail: Rail