zombee11 commited on
Commit
ede4a2e
·
verified ·
1 Parent(s): d533fd2

Upload 32 files

Browse files
Files changed (33) hide show
  1. .env.example +11 -0
  2. .gitattributes +1 -0
  3. 0001_initial_schema.py +144 -0
  4. Dockerfile +17 -0
  5. README.md +11 -6
  6. __init__.py +1 -0
  7. alembic.ini +41 -0
  8. audit.py +50 -0
  9. bureau.py +22 -0
  10. config.py +31 -0
  11. context.py +45 -0
  12. dependencies.py +25 -0
  13. enums.py +37 -0
  14. env.py +41 -0
  15. hash.py +16 -0
  16. health.py +34 -0
  17. index.html +892 -0
  18. init_db.py +27 -0
  19. main.py +23 -0
  20. operators.py +76 -0
  21. requirements.txt +12 -0
  22. rule.py +58 -0
  23. rule_engine.py +48 -0
  24. rule_repository.py +125 -0
  25. rules.py +83 -0
  26. runtime.txt +2 -0
  27. scorecard.db +3 -0
  28. scorecard.py +96 -0
  29. scorecard_service.py +198 -0
  30. script.py.mako +25 -0
  31. seed_rules.py +444 -0
  32. session.py +25 -0
  33. ui.py +21 -0
.env.example ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ APP_NAME=Two Wheeler Scorecard API
2
+ ENVIRONMENT=local
3
+ DATABASE_URL=postgresql+psycopg://scorecard:scorecard@postgres:5432/scorecard
4
+ REDIS_URL=redis://redis:6379/0
5
+ JWT_SECRET_KEY=change-me-in-production
6
+ JWT_ALGORITHM=HS256
7
+ ACCESS_TOKEN_EXPIRE_MINUTES=60
8
+ DEFAULT_TENANT_ID=default
9
+ DEFAULT_PRODUCT_TYPE=TWO_WHEELER_LOAN
10
+ ENABLE_AUTH=false
11
+
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ scorecard.db filter=lfs diff=lfs merge=lfs -text
0001_initial_schema.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """initial schema
2
+
3
+ Revision ID: 0001_initial_schema
4
+ Revises:
5
+ Create Date: 2026-05-06
6
+ """
7
+ from typing import Sequence, Union
8
+
9
+ from alembic import op
10
+ import sqlalchemy as sa
11
+
12
+ revision: str = "0001_initial_schema"
13
+ down_revision: Union[str, None] = None
14
+ branch_labels: Union[str, Sequence[str], None] = None
15
+ depends_on: Union[str, Sequence[str], None] = None
16
+
17
+
18
+ def upgrade() -> None:
19
+ op.create_table(
20
+ "rule_master",
21
+ sa.Column("id", sa.Integer(), nullable=False),
22
+ sa.Column("tenant_id", sa.String(length=100), nullable=False),
23
+ sa.Column("product_type", sa.String(length=50), nullable=False),
24
+ sa.Column("dealer_id", sa.String(length=100), nullable=True),
25
+ sa.Column("rule_code", sa.String(length=100), nullable=False),
26
+ sa.Column("rule_name", sa.String(length=255), nullable=False),
27
+ sa.Column("rule_type", sa.String(length=50), nullable=False),
28
+ sa.Column("field_name", sa.String(length=100), nullable=False),
29
+ sa.Column("operator", sa.String(length=20), nullable=False),
30
+ sa.Column("field_value", sa.String(length=255), nullable=False),
31
+ sa.Column("score_value", sa.Numeric(10, 2), nullable=True),
32
+ sa.Column("score_component", sa.String(length=50), nullable=True),
33
+ sa.Column("decision_action", sa.String(length=50), nullable=True),
34
+ sa.Column("reason_code", sa.String(length=100), nullable=False),
35
+ sa.Column("priority", sa.Integer(), nullable=False),
36
+ sa.Column("version", sa.Integer(), nullable=False),
37
+ sa.Column("is_active", sa.Boolean(), nullable=False),
38
+ sa.Column("created_by", sa.String(length=100), nullable=True),
39
+ sa.Column("created_at", sa.DateTime(), nullable=False),
40
+ sa.Column("updated_at", sa.DateTime(), nullable=False),
41
+ sa.PrimaryKeyConstraint("id"),
42
+ sa.UniqueConstraint("rule_code", "version", "tenant_id", "product_type", "dealer_id"),
43
+ )
44
+ op.create_index(op.f("ix_rule_master_dealer_id"), "rule_master", ["dealer_id"], unique=False)
45
+ op.create_index(op.f("ix_rule_master_id"), "rule_master", ["id"], unique=False)
46
+ op.create_index(op.f("ix_rule_master_is_active"), "rule_master", ["is_active"], unique=False)
47
+ op.create_index(op.f("ix_rule_master_product_type"), "rule_master", ["product_type"], unique=False)
48
+ op.create_index(op.f("ix_rule_master_rule_code"), "rule_master", ["rule_code"], unique=False)
49
+ op.create_index(op.f("ix_rule_master_rule_type"), "rule_master", ["rule_type"], unique=False)
50
+ op.create_index(op.f("ix_rule_master_tenant_id"), "rule_master", ["tenant_id"], unique=False)
51
+
52
+ op.create_table(
53
+ "scorecard_result",
54
+ sa.Column("id", sa.Integer(), nullable=False),
55
+ sa.Column("tenant_id", sa.String(length=100), nullable=False),
56
+ sa.Column("product_type", sa.String(length=50), nullable=False),
57
+ sa.Column("lead_id", sa.String(length=100), nullable=False),
58
+ sa.Column("final_score", sa.Numeric(10, 2), nullable=False),
59
+ sa.Column("decision", sa.String(length=50), nullable=False),
60
+ sa.Column("underwriting_required", sa.Boolean(), nullable=False),
61
+ sa.Column("reject_flag", sa.Boolean(), nullable=False),
62
+ sa.Column("journey_action", sa.String(length=50), nullable=False),
63
+ sa.Column("bureau_risk", sa.String(length=50), nullable=False),
64
+ sa.Column("score_breakup", sa.JSON(), nullable=False),
65
+ sa.Column("triggered_rules", sa.JSON(), nullable=False),
66
+ sa.Column("reason_codes", sa.JSON(), nullable=False),
67
+ sa.Column("request_hash", sa.String(length=64), nullable=False),
68
+ sa.Column("created_at", sa.DateTime(), nullable=False),
69
+ sa.PrimaryKeyConstraint("id"),
70
+ )
71
+ op.create_index(op.f("ix_scorecard_result_id"), "scorecard_result", ["id"], unique=False)
72
+ op.create_index(op.f("ix_scorecard_result_lead_id"), "scorecard_result", ["lead_id"], unique=False)
73
+ op.create_index(
74
+ op.f("ix_scorecard_result_product_type"), "scorecard_result", ["product_type"], unique=False
75
+ )
76
+ op.create_index(
77
+ op.f("ix_scorecard_result_request_hash"), "scorecard_result", ["request_hash"], unique=False
78
+ )
79
+ op.create_index(op.f("ix_scorecard_result_tenant_id"), "scorecard_result", ["tenant_id"], unique=False)
80
+
81
+ op.create_table(
82
+ "rule_audit_log",
83
+ sa.Column("id", sa.Integer(), nullable=False),
84
+ sa.Column("rule_id", sa.Integer(), nullable=True),
85
+ sa.Column("action", sa.String(length=50), nullable=False),
86
+ sa.Column("actor", sa.String(length=100), nullable=True),
87
+ sa.Column("before", sa.JSON(), nullable=True),
88
+ sa.Column("after", sa.JSON(), nullable=True),
89
+ sa.Column("created_at", sa.DateTime(), nullable=False),
90
+ sa.PrimaryKeyConstraint("id"),
91
+ )
92
+ op.create_index(op.f("ix_rule_audit_log_id"), "rule_audit_log", ["id"], unique=False)
93
+ op.create_index(op.f("ix_rule_audit_log_rule_id"), "rule_audit_log", ["rule_id"], unique=False)
94
+
95
+ op.create_table(
96
+ "scorecard_execution_audit",
97
+ sa.Column("id", sa.Integer(), nullable=False),
98
+ sa.Column("lead_id", sa.String(length=100), nullable=False),
99
+ sa.Column("tenant_id", sa.String(length=100), nullable=False),
100
+ sa.Column("product_type", sa.String(length=50), nullable=False),
101
+ sa.Column("request_hash", sa.String(length=64), nullable=False),
102
+ sa.Column("matched_rules", sa.JSON(), nullable=False),
103
+ sa.Column("skipped_rules", sa.JSON(), nullable=False),
104
+ sa.Column("status", sa.String(length=50), nullable=False),
105
+ sa.Column("error_message", sa.Text(), nullable=True),
106
+ sa.Column("latency_ms", sa.Integer(), nullable=False),
107
+ sa.Column("created_at", sa.DateTime(), nullable=False),
108
+ sa.PrimaryKeyConstraint("id"),
109
+ )
110
+ op.create_index(
111
+ op.f("ix_scorecard_execution_audit_id"), "scorecard_execution_audit", ["id"], unique=False
112
+ )
113
+ op.create_index(
114
+ op.f("ix_scorecard_execution_audit_lead_id"),
115
+ "scorecard_execution_audit",
116
+ ["lead_id"],
117
+ unique=False,
118
+ )
119
+ op.create_index(
120
+ op.f("ix_scorecard_execution_audit_product_type"),
121
+ "scorecard_execution_audit",
122
+ ["product_type"],
123
+ unique=False,
124
+ )
125
+ op.create_index(
126
+ op.f("ix_scorecard_execution_audit_request_hash"),
127
+ "scorecard_execution_audit",
128
+ ["request_hash"],
129
+ unique=False,
130
+ )
131
+ op.create_index(
132
+ op.f("ix_scorecard_execution_audit_tenant_id"),
133
+ "scorecard_execution_audit",
134
+ ["tenant_id"],
135
+ unique=False,
136
+ )
137
+
138
+
139
+ def downgrade() -> None:
140
+ op.drop_table("scorecard_execution_audit")
141
+ op.drop_table("rule_audit_log")
142
+ op.drop_table("scorecard_result")
143
+ op.drop_table("rule_master")
144
+
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1
4
+ ENV PYTHONUNBUFFERED=1
5
+ ENV DATABASE_URL=sqlite:///./scorecard.db
6
+ ENV ENABLE_AUTH=false
7
+
8
+ WORKDIR /app
9
+
10
+ COPY requirements.txt .
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
+
13
+ COPY . .
14
+
15
+ EXPOSE 7860
16
+
17
+ CMD ["sh", "-c", "python -m app.db.init_db && uvicorn app.main:app --host 0.0.0.0 --port 7860"]
README.md CHANGED
@@ -1,12 +1,17 @@
1
  ---
2
- title: SCORECARDAPI2
3
- emoji:
4
- colorFrom: purple
5
  colorTo: green
6
  sdk: docker
 
 
7
  pinned: false
8
- license: mit
9
- short_description: SCORECARDAPI FOR STP
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
1
  ---
2
+ title: Scorecard STP API
3
+ emoji: 🏦
4
+ colorFrom: blue
5
  colorTo: green
6
  sdk: docker
7
+ app_port: 7860
8
+ base_path: /generate-scorecard
9
  pinned: false
 
 
10
  ---
11
 
12
+ # Scorecard STP API
13
+
14
+ FastAPI scorecard API and browser testing console.
15
+
16
+ Open `/generate-scorecard` for the testing UI.
17
+ Open `/docs` for Swagger API documentation.
__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
alembic.ini ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [alembic]
2
+ script_location = alembic
3
+ prepend_sys_path = .
4
+ sqlalchemy.url = sqlite:///./scorecard.db
5
+
6
+ [post_write_hooks]
7
+
8
+ [loggers]
9
+ keys = root,sqlalchemy,alembic
10
+
11
+ [handlers]
12
+ keys = console
13
+
14
+ [formatters]
15
+ keys = generic
16
+
17
+ [logger_root]
18
+ level = WARN
19
+ handlers = console
20
+ qualname =
21
+
22
+ [logger_sqlalchemy]
23
+ level = WARN
24
+ handlers =
25
+ qualname = sqlalchemy.engine
26
+
27
+ [logger_alembic]
28
+ level = INFO
29
+ handlers =
30
+ qualname = alembic
31
+
32
+ [handler_console]
33
+ class = StreamHandler
34
+ args = (sys.stderr,)
35
+ level = NOTSET
36
+ formatter = generic
37
+
38
+ [formatter_generic]
39
+ format = %(levelname)-5.5s [%(name)s] %(message)s
40
+ datefmt = %H:%M:%S
41
+
audit.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ from sqlalchemy.orm import Session
4
+
5
+ from app.models.audit import RuleAuditLog
6
+ from app.models.rule import RuleMaster
7
+
8
+
9
+ def rule_to_dict(rule: RuleMaster | None) -> dict[str, Any] | None:
10
+ if not rule:
11
+ return None
12
+ return {
13
+ "id": rule.id,
14
+ "tenant_id": rule.tenant_id,
15
+ "product_type": rule.product_type,
16
+ "dealer_id": rule.dealer_id,
17
+ "rule_code": rule.rule_code,
18
+ "rule_name": rule.rule_name,
19
+ "rule_type": rule.rule_type,
20
+ "field_name": rule.field_name,
21
+ "operator": rule.operator,
22
+ "field_value": rule.field_value,
23
+ "score_value": str(rule.score_value) if rule.score_value is not None else None,
24
+ "score_component": rule.score_component,
25
+ "decision_action": rule.decision_action,
26
+ "reason_code": rule.reason_code,
27
+ "priority": rule.priority,
28
+ "version": rule.version,
29
+ "is_active": rule.is_active,
30
+ }
31
+
32
+
33
+ def write_rule_audit(
34
+ db: Session,
35
+ rule_id: int | None,
36
+ action: str,
37
+ actor: str | None,
38
+ before: dict[str, Any] | None,
39
+ after: dict[str, Any] | None,
40
+ ) -> None:
41
+ db.add(
42
+ RuleAuditLog(
43
+ rule_id=rule_id,
44
+ action=action,
45
+ actor=actor,
46
+ before=before,
47
+ after=after,
48
+ )
49
+ )
50
+
bureau.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.core.enums import BureauRisk
2
+
3
+
4
+ class BureauRiskClassifier:
5
+ def classify(self, context: dict) -> BureauRisk:
6
+ max_dpd = context.get("maxDPD") or 0
7
+ dpd60 = context.get("dpd60Count") or 0
8
+ dpd90 = context.get("dpd90Count") or 0
9
+ enquiries = context.get("creditEnquiries6M") or 0
10
+ unsecured = context.get("activeUnsecuredLoans") or 0
11
+ cibil = context.get("cibilScore") or 0
12
+ overdue = context.get("overdueAmount") or 0
13
+ dpd30 = context.get("dpd30Count") or 0
14
+
15
+ if dpd60 > 0 or dpd90 > 0 or max_dpd > 90 or enquiries > 5 or unsecured > 2:
16
+ return BureauRisk.HIGH
17
+ if dpd30 > 0 or max_dpd > 0 or enquiries > 2 or 700 <= cibil <= 750:
18
+ return BureauRisk.MEDIUM
19
+ if cibil > 750 and overdue <= 0 and enquiries <= 2:
20
+ return BureauRisk.LOW
21
+ return BureauRisk.MEDIUM
22
+
config.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import lru_cache
2
+
3
+ from pydantic import Field, field_validator
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+
7
+ class Settings(BaseSettings):
8
+ app_name: str = "Two Wheeler Scorecard API"
9
+ environment: str = "local"
10
+ database_url: str = "sqlite:///./scorecard.db"
11
+ redis_url: str | None = None
12
+ jwt_secret_key: str = "change-me"
13
+ jwt_algorithm: str = "HS256"
14
+ access_token_expire_minutes: int = 60
15
+ default_tenant_id: str = "default"
16
+ default_product_type: str = "TWO_WHEELER_LOAN"
17
+ enable_auth: bool = Field(default=False)
18
+
19
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
20
+
21
+ @field_validator("database_url")
22
+ @classmethod
23
+ def normalize_database_url(cls, value: str) -> str:
24
+ if value.startswith("postgresql://"):
25
+ return value.replace("postgresql://", "postgresql+psycopg://", 1)
26
+ return value
27
+
28
+
29
+ @lru_cache
30
+ def get_settings() -> Settings:
31
+ return Settings()
context.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from app.schemas.scorecard import ScorecardRequest
6
+
7
+
8
+ ALIASES = {
9
+ "leadId": "leadId",
10
+ "dealerId": "dealerId",
11
+ "enquiries6M": "creditEnquiries6M",
12
+ "creditEnquiries6M": "creditEnquiries6M",
13
+ "enquiries12M": "creditEnquiries12M",
14
+ "creditEnquiries12M": "creditEnquiries12M",
15
+ "fraudFlag": "fraudFlag",
16
+ "writtenOffAccounts": "writtenOffAccounts",
17
+ "settledAccounts": "settledAccounts",
18
+ }
19
+
20
+
21
+ def _dump(model: BaseModel) -> dict[str, Any]:
22
+ return model.model_dump(by_alias=False)
23
+
24
+
25
+ def build_evaluation_context(request: ScorecardRequest) -> dict[str, Any]:
26
+ context = {}
27
+ context.update(_dump(request.application))
28
+ context.update(_dump(request.bureau))
29
+ context["tenantId"] = request.tenantId
30
+ context["productType"] = request.productType
31
+ for alias, target in ALIASES.items():
32
+ if target in context:
33
+ context[alias] = context[target]
34
+ context["kycVerified"] = bool(context.get("panVerified")) and bool(context.get("aadhaarVerified"))
35
+ context["stableEmployment"] = context.get("employmentType") in {"SALARIED", "SELF_EMPLOYED"}
36
+ context["noDpd"] = (
37
+ (context.get("maxDPD") or 0) <= 0
38
+ and (context.get("dpd30Count") or 0) <= 0
39
+ and (context.get("dpd60Count") or 0) <= 0
40
+ and (context.get("dpd90Count") or 0) <= 0
41
+ )
42
+ context["noFraud"] = not bool(context.get("fraudFlag") or context.get("blacklistMatch"))
43
+ context["noOverdue"] = (context.get("overdueAmount") or 0) <= 0
44
+ return context
45
+
dependencies.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Depends, HTTPException, status
2
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
3
+ from jose import JWTError, jwt
4
+
5
+ from app.core.config import get_settings
6
+
7
+
8
+ bearer = HTTPBearer(auto_error=False)
9
+
10
+
11
+ def require_auth(credentials: HTTPAuthorizationCredentials | None = Depends(bearer)) -> dict:
12
+ settings = get_settings()
13
+ if not settings.enable_auth:
14
+ return {"sub": "local-dev"}
15
+ if not credentials:
16
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
17
+ try:
18
+ return jwt.decode(
19
+ credentials.credentials,
20
+ settings.jwt_secret_key,
21
+ algorithms=[settings.jwt_algorithm],
22
+ )
23
+ except JWTError as exc:
24
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
25
+
enums.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import StrEnum
2
+
3
+
4
+ class RuleType(StrEnum):
5
+ SCORE = "SCORE"
6
+ HARD_REJECT = "HARD_REJECT"
7
+ NON_STP = "NON_STP"
8
+ STP_ELIGIBILITY = "STP_ELIGIBILITY"
9
+
10
+
11
+ class DecisionAction(StrEnum):
12
+ STP = "STP"
13
+ CONDITIONAL_STP = "CONDITIONAL_STP"
14
+ NON_STP = "NON_STP"
15
+ UW = "UW"
16
+ REJECT = "REJECT"
17
+ SCORE = "SCORE"
18
+
19
+
20
+ class ScorecardDecision(StrEnum):
21
+ STP = "STP"
22
+ CONDITIONAL_STP = "CONDITIONAL_STP"
23
+ NON_STP = "NON_STP"
24
+ REJECT = "REJECT"
25
+
26
+
27
+ class JourneyAction(StrEnum):
28
+ SKIP_UNDERWRITING = "SKIP_UNDERWRITING"
29
+ SEND_TO_UNDERWRITING = "SEND_TO_UNDERWRITING"
30
+ REJECT_APPLICATION = "REJECT_APPLICATION"
31
+
32
+
33
+ class BureauRisk(StrEnum):
34
+ LOW = "LOW"
35
+ MEDIUM = "MEDIUM"
36
+ HIGH = "HIGH"
37
+
env.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from logging.config import fileConfig
2
+
3
+ from alembic import context
4
+ from sqlalchemy import engine_from_config, pool
5
+
6
+ from app.core.config import get_settings
7
+ from app.db.session import Base
8
+ from app.models import audit, rule, scorecard # noqa: F401
9
+
10
+ config = context.config
11
+ if config.config_file_name is not None:
12
+ fileConfig(config.config_file_name)
13
+
14
+ target_metadata = Base.metadata
15
+ config.set_main_option("sqlalchemy.url", get_settings().database_url)
16
+
17
+
18
+ def run_migrations_offline() -> None:
19
+ url = config.get_main_option("sqlalchemy.url")
20
+ context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
21
+ with context.begin_transaction():
22
+ context.run_migrations()
23
+
24
+
25
+ def run_migrations_online() -> None:
26
+ connectable = engine_from_config(
27
+ config.get_section(config.config_ini_section, {}),
28
+ prefix="sqlalchemy.",
29
+ poolclass=pool.NullPool,
30
+ )
31
+ with connectable.connect() as connection:
32
+ context.configure(connection=connection, target_metadata=target_metadata)
33
+ with context.begin_transaction():
34
+ context.run_migrations()
35
+
36
+
37
+ if context.is_offline_mode():
38
+ run_migrations_offline()
39
+ else:
40
+ run_migrations_online()
41
+
hash.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import json
3
+ from decimal import Decimal
4
+ from typing import Any
5
+
6
+
7
+ def _json_default(value: Any) -> str:
8
+ if isinstance(value, Decimal):
9
+ return str(value)
10
+ return str(value)
11
+
12
+
13
+ def stable_payload_hash(payload: dict[str, Any]) -> str:
14
+ serialized = json.dumps(payload, sort_keys=True, default=_json_default, separators=(",", ":"))
15
+ return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
16
+
health.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from redis import Redis
3
+ from sqlalchemy import text
4
+ from sqlalchemy.orm import Session
5
+
6
+ from app.core.config import get_settings
7
+ from app.db.session import get_db
8
+ from app.schemas.scorecard import HealthResponse
9
+
10
+ router = APIRouter(tags=["Health"])
11
+
12
+
13
+ @router.get("/health", response_model=HealthResponse)
14
+ def health(db: Session = Depends(get_db)) -> HealthResponse:
15
+ database = "ok"
16
+ redis_status = "disabled"
17
+ details = {}
18
+ try:
19
+ db.execute(text("SELECT 1"))
20
+ except Exception as exc:
21
+ database = "error"
22
+ details["database_error"] = str(exc)
23
+
24
+ settings = get_settings()
25
+ if settings.redis_url:
26
+ try:
27
+ Redis.from_url(settings.redis_url).ping()
28
+ redis_status = "ok"
29
+ except Exception as exc:
30
+ redis_status = "error"
31
+ details["redis_error"] = str(exc)
32
+ status = "ok" if database == "ok" and redis_status in {"ok", "disabled"} else "degraded"
33
+ return HealthResponse(status=status, database=database, redis=redis_status, details=details)
34
+
index.html ADDED
@@ -0,0 +1,892 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Two Wheeler Scorecard Console</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f4f7fb;
10
+ --panel: #ffffff;
11
+ --line: #d9e2ec;
12
+ --text: #172033;
13
+ --muted: #637083;
14
+ --accent: #116d6e;
15
+ --accent-2: #204b8c;
16
+ --danger: #b42318;
17
+ --warn: #a15c07;
18
+ --ok: #16794c;
19
+ }
20
+
21
+ * {
22
+ box-sizing: border-box;
23
+ }
24
+
25
+ body {
26
+ margin: 0;
27
+ background: var(--bg);
28
+ color: var(--text);
29
+ font-family: Arial, Helvetica, sans-serif;
30
+ }
31
+
32
+ header {
33
+ background: #0d1b2a;
34
+ color: white;
35
+ padding: 18px 24px;
36
+ }
37
+
38
+ header h1 {
39
+ margin: 0;
40
+ font-size: 22px;
41
+ font-weight: 700;
42
+ }
43
+
44
+ header p {
45
+ margin: 6px 0 0;
46
+ color: #cbd5e1;
47
+ font-size: 14px;
48
+ }
49
+
50
+ main {
51
+ display: grid;
52
+ grid-template-columns: minmax(360px, 1.05fr) minmax(360px, 0.95fr);
53
+ gap: 16px;
54
+ padding: 16px;
55
+ }
56
+
57
+ section {
58
+ background: var(--panel);
59
+ border: 1px solid var(--line);
60
+ border-radius: 8px;
61
+ min-width: 0;
62
+ }
63
+
64
+ .section-head {
65
+ display: flex;
66
+ justify-content: space-between;
67
+ gap: 12px;
68
+ align-items: center;
69
+ padding: 14px 16px;
70
+ border-bottom: 1px solid var(--line);
71
+ }
72
+
73
+ h2 {
74
+ margin: 0;
75
+ font-size: 17px;
76
+ }
77
+
78
+ .body {
79
+ padding: 16px;
80
+ }
81
+
82
+ .row {
83
+ display: grid;
84
+ grid-template-columns: repeat(4, minmax(0, 1fr));
85
+ gap: 10px;
86
+ margin-bottom: 10px;
87
+ }
88
+
89
+ .api-row {
90
+ display: grid;
91
+ grid-template-columns: minmax(220px, 1.2fr) minmax(170px, 0.8fr) minmax(150px, 0.7fr);
92
+ gap: 10px;
93
+ margin-bottom: 10px;
94
+ }
95
+
96
+ label {
97
+ display: block;
98
+ color: var(--muted);
99
+ font-size: 12px;
100
+ margin-bottom: 4px;
101
+ }
102
+
103
+ input,
104
+ select,
105
+ textarea {
106
+ width: 100%;
107
+ border: 1px solid var(--line);
108
+ border-radius: 6px;
109
+ padding: 9px 10px;
110
+ background: white;
111
+ color: var(--text);
112
+ font-size: 14px;
113
+ }
114
+
115
+ textarea {
116
+ min-height: 320px;
117
+ resize: vertical;
118
+ font-family: Consolas, monospace;
119
+ line-height: 1.35;
120
+ }
121
+
122
+ button {
123
+ border: 0;
124
+ border-radius: 6px;
125
+ background: var(--accent);
126
+ color: white;
127
+ cursor: pointer;
128
+ font-weight: 700;
129
+ padding: 10px 13px;
130
+ }
131
+
132
+ button.secondary {
133
+ background: var(--accent-2);
134
+ }
135
+
136
+ button.ghost {
137
+ background: #e8eef6;
138
+ color: var(--text);
139
+ }
140
+
141
+ button.danger {
142
+ background: var(--danger);
143
+ }
144
+
145
+ .actions {
146
+ display: flex;
147
+ flex-wrap: wrap;
148
+ gap: 8px;
149
+ margin: 12px 0;
150
+ }
151
+
152
+ .decision {
153
+ display: grid;
154
+ grid-template-columns: repeat(4, minmax(0, 1fr));
155
+ gap: 10px;
156
+ margin-top: 12px;
157
+ }
158
+
159
+ .metric {
160
+ border: 1px solid var(--line);
161
+ border-radius: 8px;
162
+ padding: 12px;
163
+ background: #fbfdff;
164
+ }
165
+
166
+ .metric span {
167
+ color: var(--muted);
168
+ display: block;
169
+ font-size: 12px;
170
+ }
171
+
172
+ .metric strong {
173
+ display: block;
174
+ font-size: 18px;
175
+ margin-top: 5px;
176
+ overflow-wrap: anywhere;
177
+ }
178
+
179
+ .STP {
180
+ color: var(--ok);
181
+ }
182
+
183
+ .CONDITIONAL_STP {
184
+ color: var(--warn);
185
+ }
186
+
187
+ .NON_STP,
188
+ .REJECT {
189
+ color: var(--danger);
190
+ }
191
+
192
+ pre {
193
+ background: #101827;
194
+ color: #dbeafe;
195
+ border-radius: 8px;
196
+ padding: 12px;
197
+ overflow: auto;
198
+ max-height: 360px;
199
+ font-size: 13px;
200
+ }
201
+
202
+ table {
203
+ width: 100%;
204
+ border-collapse: collapse;
205
+ font-size: 13px;
206
+ }
207
+
208
+ th,
209
+ td {
210
+ border-bottom: 1px solid var(--line);
211
+ padding: 9px 7px;
212
+ text-align: left;
213
+ vertical-align: top;
214
+ }
215
+
216
+ th {
217
+ color: var(--muted);
218
+ font-weight: 700;
219
+ background: #f8fafc;
220
+ position: sticky;
221
+ top: 0;
222
+ }
223
+
224
+ .table-wrap {
225
+ border: 1px solid var(--line);
226
+ border-radius: 8px;
227
+ overflow: auto;
228
+ max-height: 390px;
229
+ }
230
+
231
+ .status {
232
+ min-height: 20px;
233
+ color: var(--muted);
234
+ font-size: 13px;
235
+ }
236
+
237
+ .status.ok {
238
+ color: var(--ok);
239
+ }
240
+
241
+ .status.error {
242
+ color: var(--danger);
243
+ }
244
+
245
+ .hint {
246
+ color: var(--muted);
247
+ font-size: 12px;
248
+ line-height: 1.45;
249
+ margin: 4px 0 12px;
250
+ }
251
+
252
+ .request-line {
253
+ background: #eef6f6;
254
+ border: 1px solid var(--line);
255
+ border-radius: 6px;
256
+ color: var(--accent);
257
+ font-family: Consolas, monospace;
258
+ font-size: 13px;
259
+ margin: 8px 0 12px;
260
+ overflow-wrap: anywhere;
261
+ padding: 9px 10px;
262
+ }
263
+
264
+ .edit-banner {
265
+ display: none;
266
+ align-items: center;
267
+ justify-content: space-between;
268
+ gap: 10px;
269
+ border: 1px solid #f2c94c;
270
+ background: #fff8db;
271
+ border-radius: 8px;
272
+ padding: 10px 12px;
273
+ margin-bottom: 12px;
274
+ color: #6b4e00;
275
+ font-size: 13px;
276
+ }
277
+
278
+ .rule-form {
279
+ display: grid;
280
+ grid-template-columns: repeat(3, minmax(0, 1fr));
281
+ gap: 10px;
282
+ margin-bottom: 12px;
283
+ }
284
+
285
+ .wide {
286
+ grid-column: span 2;
287
+ }
288
+
289
+ @media (max-width: 980px) {
290
+ main,
291
+ .api-row,
292
+ .row,
293
+ .decision,
294
+ .rule-form {
295
+ grid-template-columns: 1fr;
296
+ }
297
+
298
+ .wide {
299
+ grid-column: auto;
300
+ }
301
+ }
302
+ </style>
303
+ </head>
304
+ <body>
305
+ <header>
306
+ <h1>Two Wheeler Loan STP Scorecard Console</h1>
307
+ <p>Configure rules, simulate decisions, and generate persisted scorecards from one screen.</p>
308
+ </header>
309
+
310
+ <main>
311
+ <section>
312
+ <div class="section-head">
313
+ <h2>Scorecard Tester</h2>
314
+ <button class="ghost" onclick="loadSample()">Load Sample</button>
315
+ </div>
316
+ <div class="body">
317
+ <div class="row">
318
+ <div>
319
+ <label>Tenant</label>
320
+ <input id="tenantId" value="default" />
321
+ </div>
322
+ <div>
323
+ <label>Product</label>
324
+ <select id="productType" onchange="syncProductChange()">
325
+ <option value="TWO_WHEELER_LOAN">Two Wheeler Loan</option>
326
+ <option value="NEW_CAR_LOAN">New Car Loan</option>
327
+ <option value="USED_CAR_LOAN">Used Car Loan</option>
328
+ <option value="NEW_COMMERCIAL_VEHICLE_LOAN">New Commercial Vehicle</option>
329
+ <option value="USED_COMMERCIAL_VEHICLE_LOAN">Used Commercial Vehicle</option>
330
+ <option value="NEW_TRACTOR_LOAN">New Tractor Loan</option>
331
+ <option value="USED_TRACTOR_LOAN">Used Tractor Loan</option>
332
+ </select>
333
+ </div>
334
+ <div>
335
+ <label>Lead ID</label>
336
+ <input id="leadId" value="TW10001" />
337
+ </div>
338
+ <div>
339
+ <label>Journey Trigger</label>
340
+ <select id="dueDiligenceStatus">
341
+ <option value="APPROVED">Due Diligence Approved</option>
342
+ <option value="PENDING">Due Diligence Pending</option>
343
+ <option value="REJECTED">Due Diligence Rejected</option>
344
+ </select>
345
+ </div>
346
+ </div>
347
+
348
+ <div class="api-row">
349
+ <div>
350
+ <label>API Base URL</label>
351
+ <input id="apiBaseUrl" />
352
+ </div>
353
+ <div>
354
+ <label>Endpoint</label>
355
+ <select id="mode" onchange="refreshRequestPreview()">
356
+ <option value="simulate-scorecard">POST /simulate-scorecard</option>
357
+ <option value="generate-scorecard">POST /generate-scorecard</option>
358
+ </select>
359
+ </div>
360
+ <div>
361
+ <label>Method</label>
362
+ <input id="method" value="POST" disabled />
363
+ </div>
364
+ </div>
365
+ <div class="hint">
366
+ Use simulate for testing only. Use generate after due diligence approval when you want the scorecard result saved against the lead.
367
+ </div>
368
+ <div class="request-line" id="requestPreview"></div>
369
+
370
+ <label>Request JSON</label>
371
+ <textarea id="payload"></textarea>
372
+
373
+ <div class="actions">
374
+ <button onclick="runScorecard()">Send API Request</button>
375
+ <button class="secondary" onclick="triggerAfterDueDiligence()">Trigger After Due Diligence</button>
376
+ <button class="secondary" onclick="getSavedResult()">Get Saved Result</button>
377
+ <button class="ghost" onclick="openDocs()">Swagger Docs</button>
378
+ <button class="ghost" onclick="copyCurl()">Copy cURL</button>
379
+ </div>
380
+ <div class="status" id="scoreStatus"></div>
381
+
382
+ <div class="decision">
383
+ <div class="metric">
384
+ <span>Decision</span>
385
+ <strong id="decision">-</strong>
386
+ </div>
387
+ <div class="metric">
388
+ <span>Final Score</span>
389
+ <strong id="finalScore">-</strong>
390
+ </div>
391
+ <div class="metric">
392
+ <span>Journey Action</span>
393
+ <strong id="journeyAction">-</strong>
394
+ </div>
395
+ <div class="metric">
396
+ <span>Bureau Risk</span>
397
+ <strong id="bureauRisk">-</strong>
398
+ </div>
399
+ </div>
400
+
401
+ <pre id="scoreOutput">{}</pre>
402
+ <label>Request Preview</label>
403
+ <pre id="requestOutput">{}</pre>
404
+ </div>
405
+ </section>
406
+
407
+ <section>
408
+ <div class="section-head">
409
+ <h2>Rule Configuration</h2>
410
+ <button class="ghost" onclick="loadRules()">Refresh Rules</button>
411
+ </div>
412
+ <div class="body">
413
+ <div class="rule-form">
414
+ <input id="editingRuleId" type="hidden" />
415
+ <div>
416
+ <label>Rule Code</label>
417
+ <input id="ruleCode" placeholder="CIBIL_SUPER_HIGH" />
418
+ </div>
419
+ <div class="wide">
420
+ <label>Rule Name</label>
421
+ <input id="ruleName" placeholder="CIBIL score above 800" />
422
+ </div>
423
+ <div>
424
+ <label>Rule Type</label>
425
+ <select id="ruleType">
426
+ <option>SCORE</option>
427
+ <option>HARD_REJECT</option>
428
+ <option>NON_STP</option>
429
+ <option>STP_ELIGIBILITY</option>
430
+ </select>
431
+ </div>
432
+ <div>
433
+ <label>Field Name</label>
434
+ <input id="fieldName" placeholder="cibilScore" />
435
+ </div>
436
+ <div>
437
+ <label>Operator</label>
438
+ <select id="operator">
439
+ <option>&gt;=</option>
440
+ <option>&gt;</option>
441
+ <option>&lt;=</option>
442
+ <option>&lt;</option>
443
+ <option>=</option>
444
+ <option>!=</option>
445
+ <option>BETWEEN</option>
446
+ <option>IN</option>
447
+ <option>NOT_IN</option>
448
+ </select>
449
+ </div>
450
+ <div>
451
+ <label>Value</label>
452
+ <input id="fieldValue" placeholder="750 or 700,749" />
453
+ </div>
454
+ <div>
455
+ <label>Score Value</label>
456
+ <input id="scoreValue" placeholder="10" />
457
+ </div>
458
+ <div>
459
+ <label>Score Component</label>
460
+ <input id="scoreComponent" placeholder="cibil" />
461
+ </div>
462
+ <div>
463
+ <label>Decision Action</label>
464
+ <select id="decisionAction">
465
+ <option value="">None</option>
466
+ <option>SCORE</option>
467
+ <option>STP</option>
468
+ <option>UW</option>
469
+ <option>REJECT</option>
470
+ <option>NON_STP</option>
471
+ </select>
472
+ </div>
473
+ <div>
474
+ <label>Reason Code</label>
475
+ <input id="reasonCode" placeholder="HIGH_CIBIL" />
476
+ </div>
477
+ <div>
478
+ <label>Priority</label>
479
+ <input id="priority" value="100" />
480
+ </div>
481
+ </div>
482
+
483
+ <div class="edit-banner" id="editBanner">
484
+ <span id="editText">Editing rule</span>
485
+ <button class="ghost" onclick="cancelEdit()">Cancel Edit</button>
486
+ </div>
487
+
488
+ <div class="actions">
489
+ <button id="saveRuleButton" onclick="saveRule()">Create Rule</button>
490
+ <button class="ghost" onclick="clearRuleForm()">Clear</button>
491
+ <button class="secondary" onclick="exportRules()">Export Product Rules</button>
492
+ </div>
493
+ <div class="status" id="ruleStatus"></div>
494
+
495
+ <div class="table-wrap">
496
+ <table>
497
+ <thead>
498
+ <tr>
499
+ <th>ID</th>
500
+ <th>Code</th>
501
+ <th>Type</th>
502
+ <th>Condition</th>
503
+ <th>Score/Action</th>
504
+ <th>Status</th>
505
+ <th>Actions</th>
506
+ </tr>
507
+ </thead>
508
+ <tbody id="rulesTable"></tbody>
509
+ </table>
510
+ </div>
511
+ </div>
512
+ </section>
513
+ </main>
514
+
515
+ <script>
516
+ const samplePayload = {
517
+ tenantId: "default",
518
+ productType: "TWO_WHEELER_LOAN",
519
+ application: {
520
+ leadId: "TW10001",
521
+ customerName: "ABC",
522
+ mobile: "9999999999",
523
+ employmentType: "SALARIED",
524
+ monthlyIncome: 45000,
525
+ existingEMI: 8000,
526
+ foir: 32,
527
+ loanAmount: 95000,
528
+ assetCost: 125000,
529
+ ltv: 76,
530
+ tenure: 36,
531
+ dealerId: "DL101",
532
+ state: "Karnataka",
533
+ city: "Bangalore",
534
+ negativeArea: false,
535
+ fraudFlag: false,
536
+ faceMatchScore: 92,
537
+ panVerified: true,
538
+ aadhaarVerified: true,
539
+ bankingSurrogate: true,
540
+ coApplicant: false
541
+ },
542
+ bureau: {
543
+ cibilScore: 753,
544
+ creditVintageMonths: 123,
545
+ activeAccounts: 2,
546
+ closedAccounts: 47,
547
+ overdueAmount: 0,
548
+ writtenOffAccounts: 0,
549
+ settledAccounts: 0,
550
+ active90PlusDpd: false,
551
+ maxDPD: 0,
552
+ dpd30Count: 0,
553
+ dpd60Count: 0,
554
+ dpd90Count: 0,
555
+ creditEnquiries6M: 2,
556
+ creditEnquiries12M: 4,
557
+ activeUnsecuredLoans: 1,
558
+ activeSecuredLoans: 1,
559
+ jointAccounts: 0,
560
+ creditUtilization: 2
561
+ }
562
+ };
563
+
564
+ function setStatus(id, message) {
565
+ const element = document.getElementById(id);
566
+ element.textContent = message;
567
+ element.className = "status";
568
+ }
569
+
570
+ function setStatusState(id, message, state) {
571
+ const element = document.getElementById(id);
572
+ element.textContent = message;
573
+ element.className = `status ${state || ""}`;
574
+ }
575
+
576
+ function loadSample() {
577
+ document.getElementById("payload").value = JSON.stringify(samplePayload, null, 2);
578
+ document.getElementById("leadId").value = samplePayload.application.leadId;
579
+ refreshRequestPreview();
580
+ }
581
+
582
+ function syncProductChange() {
583
+ try {
584
+ const payload = JSON.parse(document.getElementById("payload").value);
585
+ payload.productType = document.getElementById("productType").value;
586
+ document.getElementById("payload").value = JSON.stringify(payload, null, 2);
587
+ } catch (_) {
588
+ // Keep user-entered JSON untouched if it is mid-edit.
589
+ }
590
+ cancelEdit();
591
+ loadRules();
592
+ refreshRequestPreview();
593
+ }
594
+
595
+ function syncHeaderFields(payload) {
596
+ payload.tenantId = document.getElementById("tenantId").value || "default";
597
+ payload.productType = document.getElementById("productType").value || "TWO_WHEELER_LOAN";
598
+ payload.application.leadId = document.getElementById("leadId").value || payload.application.leadId;
599
+ return payload;
600
+ }
601
+
602
+ function getBaseUrl() {
603
+ return (document.getElementById("apiBaseUrl").value || window.location.origin).replace(/\/$/, "");
604
+ }
605
+
606
+ function buildUrl(path) {
607
+ return `${getBaseUrl()}${path.startsWith("/") ? path : `/${path}`}`;
608
+ }
609
+
610
+ function getScorecardPath() {
611
+ return `/${document.getElementById("mode").value}`;
612
+ }
613
+
614
+ function refreshRequestPreview() {
615
+ const path = getScorecardPath();
616
+ document.getElementById("requestPreview").textContent = `POST ${buildUrl(path)}`;
617
+ }
618
+
619
+ async function api(path, options = {}) {
620
+ const response = await fetch(buildUrl(path), {
621
+ headers: { "Content-Type": "application/json", ...(options.headers || {}) },
622
+ ...options
623
+ });
624
+ const text = await response.text();
625
+ let body = null;
626
+ try {
627
+ body = text ? JSON.parse(text) : null;
628
+ } catch (_) {
629
+ body = text;
630
+ }
631
+ if (!response.ok) {
632
+ throw new Error(body?.detail || body || response.statusText);
633
+ }
634
+ return { body, status: response.status, statusText: response.statusText };
635
+ }
636
+
637
+ function readPayload() {
638
+ const payload = syncHeaderFields(JSON.parse(document.getElementById("payload").value));
639
+ document.getElementById("payload").value = JSON.stringify(payload, null, 2);
640
+ return payload;
641
+ }
642
+
643
+ function renderRequest(path, payload) {
644
+ const request = {
645
+ method: "POST",
646
+ url: buildUrl(path),
647
+ headers: { "Content-Type": "application/json" },
648
+ body: payload
649
+ };
650
+ document.getElementById("requestOutput").textContent = JSON.stringify(request, null, 2);
651
+ }
652
+
653
+ function renderScore(body) {
654
+ document.getElementById("scoreOutput").textContent = JSON.stringify(body, null, 2);
655
+ const decision = document.getElementById("decision");
656
+ decision.textContent = body.decision || "-";
657
+ decision.className = body.decision || "";
658
+ document.getElementById("finalScore").textContent = body.finalScore ?? "-";
659
+ document.getElementById("journeyAction").textContent = body.journeyAction || "-";
660
+ document.getElementById("bureauRisk").textContent = body.bureauRisk || "-";
661
+ }
662
+
663
+ async function runScorecard() {
664
+ try {
665
+ setStatus("scoreStatus", "Sending API request...");
666
+ const payload = readPayload();
667
+ const path = getScorecardPath();
668
+ renderRequest(path, payload);
669
+ const result = await api(path, { method: "POST", body: JSON.stringify(payload) });
670
+ renderScore(result.body);
671
+ setStatusState("scoreStatus", `HTTP ${result.status} ${result.statusText} - scorecard completed.`, "ok");
672
+ } catch (error) {
673
+ setStatusState("scoreStatus", `Error: ${error.message}`, "error");
674
+ }
675
+ }
676
+
677
+ async function triggerAfterDueDiligence() {
678
+ const dueDiligenceStatus = document.getElementById("dueDiligenceStatus").value;
679
+ if (dueDiligenceStatus !== "APPROVED") {
680
+ setStatusState(
681
+ "scoreStatus",
682
+ `API not triggered because due diligence is ${dueDiligenceStatus}. Select Approved to call the scorecard.`,
683
+ "error"
684
+ );
685
+ return;
686
+ }
687
+ document.getElementById("mode").value = "generate-scorecard";
688
+ refreshRequestPreview();
689
+ await runScorecard();
690
+ }
691
+
692
+ async function getSavedResult() {
693
+ try {
694
+ const leadId = document.getElementById("leadId").value;
695
+ setStatus("scoreStatus", "Fetching saved result...");
696
+ const result = await api(`/scorecard/${encodeURIComponent(leadId)}`);
697
+ renderScore(result.body);
698
+ setStatusState("scoreStatus", `HTTP ${result.status} ${result.statusText} - saved result loaded.`, "ok");
699
+ } catch (error) {
700
+ setStatusState("scoreStatus", `Error: ${error.message}`, "error");
701
+ }
702
+ }
703
+
704
+ async function loadRules() {
705
+ try {
706
+ setStatus("ruleStatus", "Loading rules...");
707
+ const tenant = encodeURIComponent(document.getElementById("tenantId").value || "default");
708
+ const product = encodeURIComponent(document.getElementById("productType").value || "TWO_WHEELER_LOAN");
709
+ const result = await api(`/rules?tenant_id=${tenant}&product_type=${product}`);
710
+ const rules = result.body;
711
+ const rows = rules.map(rule => `
712
+ <tr>
713
+ <td>${rule.id}</td>
714
+ <td>${rule.rule_code}<br><small>${rule.reason_code}</small></td>
715
+ <td>${rule.rule_type}</td>
716
+ <td>${rule.field_name} ${rule.operator} ${rule.field_value}</td>
717
+ <td>${rule.score_value ?? ""} ${rule.decision_action ?? ""}</td>
718
+ <td>${rule.is_active ? "Active" : "Disabled"}</td>
719
+ <td>
720
+ <button class="ghost" onclick='editRule(${JSON.stringify(rule)})'>Edit</button>
721
+ <button class="${rule.is_active ? "danger" : "secondary"}" onclick="toggleRule(${rule.id}, ${!rule.is_active})">
722
+ ${rule.is_active ? "Disable" : "Enable"}
723
+ </button>
724
+ </td>
725
+ </tr>
726
+ `).join("");
727
+ document.getElementById("rulesTable").innerHTML = rows;
728
+ setStatus("ruleStatus", `${rules.length} rules loaded.`);
729
+ } catch (error) {
730
+ setStatus("ruleStatus", `Error: ${error.message}`);
731
+ }
732
+ }
733
+
734
+ function buildRulePayload() {
735
+ const scoreValue = document.getElementById("scoreValue").value;
736
+ return {
737
+ tenant_id: document.getElementById("tenantId").value || "default",
738
+ product_type: document.getElementById("productType").value || "TWO_WHEELER_LOAN",
739
+ rule_code: document.getElementById("ruleCode").value,
740
+ rule_name: document.getElementById("ruleName").value,
741
+ rule_type: document.getElementById("ruleType").value,
742
+ field_name: document.getElementById("fieldName").value,
743
+ operator: document.getElementById("operator").value,
744
+ field_value: document.getElementById("fieldValue").value,
745
+ score_value: scoreValue ? Number(scoreValue) : null,
746
+ score_component: document.getElementById("scoreComponent").value || null,
747
+ decision_action: document.getElementById("decisionAction").value || null,
748
+ reason_code: document.getElementById("reasonCode").value,
749
+ priority: Number(document.getElementById("priority").value || 100),
750
+ is_active: true
751
+ };
752
+ }
753
+
754
+ async function saveRule() {
755
+ const editingRuleId = document.getElementById("editingRuleId").value;
756
+ if (editingRuleId) {
757
+ await updateRule(editingRuleId);
758
+ } else {
759
+ await createRule();
760
+ }
761
+ }
762
+
763
+ async function createRule() {
764
+ try {
765
+ setStatus("ruleStatus", "Creating rule...");
766
+ const payload = buildRulePayload();
767
+ await api("/rules", { method: "POST", body: JSON.stringify(payload) });
768
+ clearRuleForm();
769
+ await loadRules();
770
+ setStatus("ruleStatus", "Rule created.");
771
+ } catch (error) {
772
+ setStatus("ruleStatus", `Error: ${error.message}`);
773
+ }
774
+ }
775
+
776
+ async function updateRule(ruleId) {
777
+ try {
778
+ setStatus("ruleStatus", "Updating rule...");
779
+ const payload = buildRulePayload();
780
+ delete payload.tenant_id;
781
+ delete payload.product_type;
782
+ delete payload.rule_code;
783
+ delete payload.rule_type;
784
+ payload.actor = "admin-ui";
785
+ await api(`/rules/${ruleId}`, { method: "PUT", body: JSON.stringify(payload) });
786
+ clearRuleForm();
787
+ await loadRules();
788
+ setStatus("ruleStatus", "Rule updated.");
789
+ } catch (error) {
790
+ setStatus("ruleStatus", `Error: ${error.message}`);
791
+ }
792
+ }
793
+
794
+ function editRule(rule) {
795
+ document.getElementById("editingRuleId").value = rule.id;
796
+ document.getElementById("ruleCode").value = rule.rule_code || "";
797
+ document.getElementById("ruleName").value = rule.rule_name || "";
798
+ document.getElementById("ruleType").value = rule.rule_type || "SCORE";
799
+ document.getElementById("fieldName").value = rule.field_name || "";
800
+ document.getElementById("operator").value = rule.operator || "=";
801
+ document.getElementById("fieldValue").value = rule.field_value || "";
802
+ document.getElementById("scoreValue").value = rule.score_value ?? "";
803
+ document.getElementById("scoreComponent").value = rule.score_component || "";
804
+ document.getElementById("decisionAction").value = rule.decision_action || "";
805
+ document.getElementById("reasonCode").value = rule.reason_code || "";
806
+ document.getElementById("priority").value = rule.priority || 100;
807
+ document.getElementById("saveRuleButton").textContent = "Save Rule Changes";
808
+ document.getElementById("editText").textContent = `Editing #${rule.id} - ${rule.rule_code}`;
809
+ document.getElementById("editBanner").style.display = "flex";
810
+ setStatus("ruleStatus", "Rule loaded into form. Change values and save.");
811
+ window.scrollTo({ top: 0, behavior: "smooth" });
812
+ }
813
+
814
+ async function toggleRule(id, isActive) {
815
+ try {
816
+ await api(`/rules/${id}/status`, {
817
+ method: "PATCH",
818
+ body: JSON.stringify({ is_active: isActive, actor: "admin-ui" })
819
+ });
820
+ await loadRules();
821
+ } catch (error) {
822
+ setStatus("ruleStatus", `Error: ${error.message}`);
823
+ }
824
+ }
825
+
826
+ function clearRuleForm() {
827
+ [
828
+ "ruleCode",
829
+ "ruleName",
830
+ "fieldName",
831
+ "fieldValue",
832
+ "scoreValue",
833
+ "scoreComponent",
834
+ "reasonCode"
835
+ ].forEach(id => document.getElementById(id).value = "");
836
+ document.getElementById("priority").value = "100";
837
+ cancelEdit();
838
+ }
839
+
840
+ function cancelEdit() {
841
+ document.getElementById("editingRuleId").value = "";
842
+ document.getElementById("saveRuleButton").textContent = "Create Rule";
843
+ document.getElementById("editBanner").style.display = "none";
844
+ }
845
+
846
+ async function exportRules() {
847
+ try {
848
+ const tenant = encodeURIComponent(document.getElementById("tenantId").value || "default");
849
+ const product = encodeURIComponent(document.getElementById("productType").value || "TWO_WHEELER_LOAN");
850
+ const result = await api(`/rules?tenant_id=${tenant}&product_type=${product}`);
851
+ const rules = result.body;
852
+ const blob = new Blob([JSON.stringify(rules, null, 2)], { type: "application/json" });
853
+ const url = URL.createObjectURL(blob);
854
+ const link = document.createElement("a");
855
+ link.href = url;
856
+ link.download = `${product}_rules.json`;
857
+ link.click();
858
+ URL.revokeObjectURL(url);
859
+ setStatus("ruleStatus", "Rules exported.");
860
+ } catch (error) {
861
+ setStatus("ruleStatus", `Error: ${error.message}`);
862
+ }
863
+ }
864
+
865
+ function openDocs() {
866
+ window.open(buildUrl("/docs"), "_blank");
867
+ }
868
+
869
+ async function copyCurl() {
870
+ try {
871
+ const payload = readPayload();
872
+ const path = getScorecardPath();
873
+ renderRequest(path, payload);
874
+ const curl = [
875
+ `curl -X POST "${buildUrl(path)}"`,
876
+ `-H "Content-Type: application/json"`,
877
+ `-d '${JSON.stringify(payload)}'`
878
+ ].join(" ");
879
+ await navigator.clipboard.writeText(curl);
880
+ setStatusState("scoreStatus", "cURL copied.", "ok");
881
+ } catch (error) {
882
+ setStatusState("scoreStatus", `Error: ${error.message}`, "error");
883
+ }
884
+ }
885
+
886
+ document.getElementById("apiBaseUrl").value = window.location.origin;
887
+ document.getElementById("apiBaseUrl").addEventListener("input", refreshRequestPreview);
888
+ loadSample();
889
+ loadRules();
890
+ </script>
891
+ </body>
892
+ </html>
init_db.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy.orm import Session
2
+
3
+ from app.db.session import Base, engine
4
+ from app.models import RuleMaster
5
+ from app.services.seed_rules import seed_all_product_rules
6
+
7
+
8
+ def create_database() -> None:
9
+ Base.metadata.create_all(bind=engine)
10
+
11
+
12
+ def seed_database(db: Session) -> int:
13
+ return seed_all_product_rules(db)
14
+
15
+
16
+ if __name__ == "__main__":
17
+ from app.db.session import SessionLocal
18
+
19
+ create_database()
20
+ session = SessionLocal()
21
+ try:
22
+ inserted = seed_database(session)
23
+ session.commit()
24
+ print(f"Seeded {inserted} rules")
25
+ print(f"Total rules: {session.query(RuleMaster).count()}")
26
+ finally:
27
+ session.close()
main.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.staticfiles import StaticFiles
3
+
4
+ from app.api.routes import health, rules, scorecard, ui
5
+ from app.core.config import get_settings
6
+
7
+
8
+ def create_app() -> FastAPI:
9
+ settings = get_settings()
10
+ app = FastAPI(
11
+ title=settings.app_name,
12
+ version="0.1.0",
13
+ description="Configurable STP scorecard engine for two wheeler loan LOS integrations.",
14
+ )
15
+ app.include_router(health.router)
16
+ app.include_router(scorecard.router)
17
+ app.include_router(rules.router)
18
+ app.include_router(ui.router)
19
+ app.mount("/static", StaticFiles(directory="app/static"), name="static")
20
+ return app
21
+
22
+
23
+ app = create_app()
operators.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from decimal import Decimal, InvalidOperation
2
+ from typing import Any
3
+
4
+
5
+ def _to_bool(value: Any) -> bool | None:
6
+ if isinstance(value, bool):
7
+ return value
8
+ if isinstance(value, str):
9
+ lowered = value.strip().lower()
10
+ if lowered in {"true", "1", "yes", "y"}:
11
+ return True
12
+ if lowered in {"false", "0", "no", "n"}:
13
+ return False
14
+ return None
15
+
16
+
17
+ def _to_decimal(value: Any) -> Decimal | None:
18
+ if value is None:
19
+ return None
20
+ try:
21
+ return Decimal(str(value))
22
+ except (InvalidOperation, ValueError):
23
+ return None
24
+
25
+
26
+ def _split_values(expected: str) -> list[str]:
27
+ return [item.strip() for item in expected.split(",") if item.strip()]
28
+
29
+
30
+ def evaluate_operator(actual: Any, operator: str, expected: str) -> bool:
31
+ op = operator.strip().upper()
32
+ if actual is None:
33
+ return False
34
+
35
+ actual_bool = _to_bool(actual)
36
+ expected_bool = _to_bool(expected)
37
+ if actual_bool is not None and expected_bool is not None and op in {"=", "==", "!=", "<>"}:
38
+ return actual_bool == expected_bool if op in {"=", "=="} else actual_bool != expected_bool
39
+
40
+ actual_num = _to_decimal(actual)
41
+ if op == "BETWEEN":
42
+ bounds = _split_values(expected)
43
+ if actual_num is None or len(bounds) != 2:
44
+ return False
45
+ low = _to_decimal(bounds[0])
46
+ high = _to_decimal(bounds[1])
47
+ return low is not None and high is not None and low <= actual_num <= high
48
+
49
+ expected_num = _to_decimal(expected)
50
+ if actual_num is not None and expected_num is not None:
51
+ if op in {"=", "=="}:
52
+ return actual_num == expected_num
53
+ if op in {"!=", "<>"}:
54
+ return actual_num != expected_num
55
+ if op == ">":
56
+ return actual_num > expected_num
57
+ if op == ">=":
58
+ return actual_num >= expected_num
59
+ if op == "<":
60
+ return actual_num < expected_num
61
+ if op == "<=":
62
+ return actual_num <= expected_num
63
+
64
+ actual_str = str(actual).strip().upper()
65
+ expected_str = str(expected).strip().upper()
66
+ if op in {"=", "=="}:
67
+ return actual_str == expected_str
68
+ if op in {"!=", "<>"}:
69
+ return actual_str != expected_str
70
+ if op in {"IN", "NOT_IN"}:
71
+ values = {value.upper() for value in _split_values(expected)}
72
+ matched = actual_str in values
73
+ return matched if op == "IN" else not matched
74
+
75
+ raise ValueError(f"Unsupported operator: {operator}")
76
+
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ alembic==1.13.2
2
+ fastapi==0.115.0
3
+ httpx==0.27.2
4
+ passlib[bcrypt]==1.7.4
5
+ psycopg[binary]==3.2.1
6
+ pydantic-settings==2.5.2
7
+ python-jose[cryptography]==3.3.0
8
+ python-multipart==0.0.9
9
+ redis==5.0.8
10
+ sqlalchemy==2.0.34
11
+ uvicorn[standard]==0.30.6
12
+
rule.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from decimal import Decimal
3
+
4
+ from pydantic import BaseModel, ConfigDict, Field
5
+
6
+ from app.core.enums import DecisionAction, RuleType
7
+
8
+
9
+ class RuleBase(BaseModel):
10
+ tenant_id: str = "default"
11
+ product_type: str = "TWO_WHEELER_LOAN"
12
+ dealer_id: str | None = None
13
+ rule_code: str = Field(min_length=2, max_length=100)
14
+ rule_name: str = Field(min_length=2, max_length=255)
15
+ rule_type: RuleType
16
+ field_name: str = Field(min_length=1, max_length=100)
17
+ operator: str = Field(min_length=1, max_length=20)
18
+ field_value: str = Field(min_length=1, max_length=255)
19
+ score_value: Decimal | None = None
20
+ score_component: str | None = None
21
+ decision_action: DecisionAction | None = None
22
+ reason_code: str = Field(min_length=2, max_length=100)
23
+ priority: int = 100
24
+ is_active: bool = True
25
+
26
+
27
+ class RuleCreate(RuleBase):
28
+ created_by: str | None = "system"
29
+
30
+
31
+ class RuleUpdate(BaseModel):
32
+ rule_name: str | None = None
33
+ field_name: str | None = None
34
+ operator: str | None = None
35
+ field_value: str | None = None
36
+ score_value: Decimal | None = None
37
+ score_component: str | None = None
38
+ decision_action: DecisionAction | None = None
39
+ reason_code: str | None = None
40
+ priority: int | None = None
41
+ is_active: bool | None = None
42
+ actor: str | None = "system"
43
+
44
+
45
+ class RuleStatusUpdate(BaseModel):
46
+ is_active: bool
47
+ actor: str | None = "system"
48
+
49
+
50
+ class RuleResponse(RuleBase):
51
+ id: int
52
+ version: int
53
+ created_by: str | None
54
+ created_at: datetime
55
+ updated_at: datetime
56
+
57
+ model_config = ConfigDict(from_attributes=True)
58
+
rule_engine.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass, field
2
+ from decimal import Decimal
3
+
4
+ from app.core.enums import RuleType
5
+ from app.models.rule import RuleMaster
6
+ from app.services.operators import evaluate_operator
7
+
8
+
9
+ @dataclass
10
+ class RuleEvaluation:
11
+ matched: list[RuleMaster] = field(default_factory=list)
12
+ skipped: list[str] = field(default_factory=list)
13
+
14
+ @property
15
+ def codes(self) -> list[str]:
16
+ return [rule.rule_code for rule in self.matched]
17
+
18
+ @property
19
+ def reason_codes(self) -> list[str]:
20
+ return [rule.reason_code for rule in self.matched]
21
+
22
+
23
+ class RuleEngine:
24
+ def evaluate(self, rules: list[RuleMaster], context: dict) -> RuleEvaluation:
25
+ result = RuleEvaluation()
26
+ for rule in sorted(rules, key=lambda item: item.priority):
27
+ try:
28
+ if evaluate_operator(context.get(rule.field_name), rule.operator, rule.field_value):
29
+ result.matched.append(rule)
30
+ else:
31
+ result.skipped.append(rule.rule_code)
32
+ except ValueError:
33
+ result.skipped.append(rule.rule_code)
34
+ return result
35
+
36
+ def score(self, rules: list[RuleMaster], context: dict) -> tuple[Decimal, dict[str, Decimal], RuleEvaluation]:
37
+ score_rules = [rule for rule in rules if rule.rule_type == RuleType.SCORE]
38
+ evaluation = self.evaluate(score_rules, context)
39
+ total = Decimal("0")
40
+ breakup: dict[str, Decimal] = {}
41
+ for rule in evaluation.matched:
42
+ value = Decimal(rule.score_value or 0)
43
+ component = rule.score_component or "other"
44
+ breakup[component] = breakup.get(component, Decimal("0")) + value
45
+ total += value
46
+ capped = max(Decimal("0"), min(Decimal("100"), total))
47
+ return capped, breakup, evaluation
48
+
rule_repository.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from decimal import Decimal
3
+ from typing import Any
4
+
5
+ from redis import Redis
6
+ from sqlalchemy import and_, or_, select
7
+ from sqlalchemy.orm import Session
8
+
9
+ from app.core.config import get_settings
10
+ from app.models.rule import RuleMaster
11
+
12
+
13
+ def _serialize_rule(rule: RuleMaster) -> dict[str, Any]:
14
+ return {
15
+ "id": rule.id,
16
+ "tenant_id": rule.tenant_id,
17
+ "product_type": rule.product_type,
18
+ "dealer_id": rule.dealer_id,
19
+ "rule_code": rule.rule_code,
20
+ "rule_name": rule.rule_name,
21
+ "rule_type": rule.rule_type,
22
+ "field_name": rule.field_name,
23
+ "operator": rule.operator,
24
+ "field_value": rule.field_value,
25
+ "score_value": str(rule.score_value) if rule.score_value is not None else None,
26
+ "score_component": rule.score_component,
27
+ "decision_action": rule.decision_action,
28
+ "reason_code": rule.reason_code,
29
+ "priority": rule.priority,
30
+ "version": rule.version,
31
+ "is_active": rule.is_active,
32
+ "created_by": rule.created_by,
33
+ "created_at": rule.created_at.isoformat(),
34
+ "updated_at": rule.updated_at.isoformat(),
35
+ }
36
+
37
+
38
+ def _hydrate_rule(data: dict[str, Any]) -> RuleMaster:
39
+ rule = RuleMaster(**{k: v for k, v in data.items() if k not in {"created_at", "updated_at"}})
40
+ if data.get("score_value") is not None:
41
+ rule.score_value = Decimal(data["score_value"])
42
+ return rule
43
+
44
+
45
+ class RuleRepository:
46
+ def __init__(self) -> None:
47
+ self.settings = get_settings()
48
+ self.redis: Redis | None = None
49
+ if self.settings.redis_url:
50
+ try:
51
+ self.redis = Redis.from_url(self.settings.redis_url, decode_responses=True)
52
+ except Exception:
53
+ self.redis = None
54
+
55
+ def cache_key(self, tenant_id: str, product_type: str, dealer_id: str | None) -> str:
56
+ dealer = dealer_id or "ALL"
57
+ return f"rules:{tenant_id}:{product_type}:{dealer}"
58
+
59
+ def get_active_rules(
60
+ self, db: Session, tenant_id: str, product_type: str, dealer_id: str | None
61
+ ) -> list[RuleMaster]:
62
+ key = self.cache_key(tenant_id, product_type, dealer_id)
63
+ if self.redis:
64
+ try:
65
+ cached = self.redis.get(key)
66
+ if cached:
67
+ return [_hydrate_rule(item) for item in json.loads(cached)]
68
+ except Exception:
69
+ pass
70
+
71
+ stmt = (
72
+ select(RuleMaster)
73
+ .where(
74
+ RuleMaster.is_active.is_(True),
75
+ RuleMaster.tenant_id == tenant_id,
76
+ RuleMaster.product_type == product_type,
77
+ or_(RuleMaster.dealer_id.is_(None), RuleMaster.dealer_id == dealer_id),
78
+ )
79
+ .order_by(RuleMaster.priority.asc(), RuleMaster.id.asc())
80
+ )
81
+ rules = list(db.scalars(stmt).all())
82
+ if self.redis:
83
+ try:
84
+ self.redis.setex(key, 300, json.dumps([_serialize_rule(rule) for rule in rules]))
85
+ except Exception:
86
+ pass
87
+ return rules
88
+
89
+ def invalidate(self, tenant_id: str, product_type: str, dealer_id: str | None = None) -> None:
90
+ if not self.redis:
91
+ return
92
+ keys = [
93
+ self.cache_key(tenant_id, product_type, dealer_id),
94
+ self.cache_key(tenant_id, product_type, None),
95
+ ]
96
+ try:
97
+ self.redis.delete(*set(keys))
98
+ except Exception:
99
+ pass
100
+
101
+ def list_rules(
102
+ self,
103
+ db: Session,
104
+ tenant_id: str | None = None,
105
+ product_type: str | None = None,
106
+ dealer_id: str | None = None,
107
+ rule_type: str | None = None,
108
+ is_active: bool | None = None,
109
+ ) -> list[RuleMaster]:
110
+ filters = []
111
+ if tenant_id:
112
+ filters.append(RuleMaster.tenant_id == tenant_id)
113
+ if product_type:
114
+ filters.append(RuleMaster.product_type == product_type)
115
+ if dealer_id:
116
+ filters.append(RuleMaster.dealer_id == dealer_id)
117
+ if rule_type:
118
+ filters.append(RuleMaster.rule_type == rule_type)
119
+ if is_active is not None:
120
+ filters.append(RuleMaster.is_active.is_(is_active))
121
+ stmt = select(RuleMaster).order_by(RuleMaster.priority.asc(), RuleMaster.id.asc())
122
+ if filters:
123
+ stmt = stmt.where(and_(*filters))
124
+ return list(db.scalars(stmt).all())
125
+
rules.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
2
+ from sqlalchemy import func, select
3
+ from sqlalchemy.orm import Session
4
+
5
+ from app.api.dependencies import require_auth
6
+ from app.db.session import get_db
7
+ from app.models.rule import RuleMaster
8
+ from app.schemas.rule import RuleCreate, RuleResponse, RuleStatusUpdate, RuleUpdate
9
+ from app.services.audit import rule_to_dict, write_rule_audit
10
+ from app.services.rule_repository import RuleRepository
11
+
12
+ router = APIRouter(prefix="/rules", tags=["Rule Management"], dependencies=[Depends(require_auth)])
13
+ repo = RuleRepository()
14
+
15
+
16
+ @router.get("", response_model=list[RuleResponse])
17
+ def list_rules(
18
+ tenant_id: str | None = None,
19
+ product_type: str | None = None,
20
+ dealer_id: str | None = None,
21
+ rule_type: str | None = None,
22
+ is_active: bool | None = Query(default=None),
23
+ db: Session = Depends(get_db),
24
+ ) -> list[RuleMaster]:
25
+ return repo.list_rules(db, tenant_id, product_type, dealer_id, rule_type, is_active)
26
+
27
+
28
+ @router.post("", response_model=RuleResponse, status_code=status.HTTP_201_CREATED)
29
+ def create_rule(payload: RuleCreate, db: Session = Depends(get_db)) -> RuleMaster:
30
+ dealer_filter = (
31
+ RuleMaster.dealer_id.is_(None)
32
+ if payload.dealer_id is None
33
+ else RuleMaster.dealer_id == payload.dealer_id
34
+ )
35
+ latest_version = db.scalar(
36
+ select(func.max(RuleMaster.version)).where(
37
+ RuleMaster.rule_code == payload.rule_code,
38
+ RuleMaster.tenant_id == payload.tenant_id,
39
+ RuleMaster.product_type == payload.product_type,
40
+ dealer_filter,
41
+ )
42
+ )
43
+ rule = RuleMaster(**payload.model_dump(), version=(latest_version or 0) + 1)
44
+ db.add(rule)
45
+ db.flush()
46
+ write_rule_audit(db, rule.id, "CREATE", payload.created_by, None, rule_to_dict(rule))
47
+ db.commit()
48
+ db.refresh(rule)
49
+ repo.invalidate(rule.tenant_id, rule.product_type, rule.dealer_id)
50
+ return rule
51
+
52
+
53
+ @router.put("/{rule_id}", response_model=RuleResponse)
54
+ def update_rule(rule_id: int, payload: RuleUpdate, db: Session = Depends(get_db)) -> RuleMaster:
55
+ rule = db.get(RuleMaster, rule_id)
56
+ if not rule:
57
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found")
58
+ before = rule_to_dict(rule)
59
+ updates = payload.model_dump(exclude_unset=True)
60
+ actor = updates.pop("actor", "system")
61
+ for field, value in updates.items():
62
+ setattr(rule, field, value)
63
+ write_rule_audit(db, rule.id, "UPDATE", actor, before, rule_to_dict(rule))
64
+ db.commit()
65
+ db.refresh(rule)
66
+ repo.invalidate(rule.tenant_id, rule.product_type, rule.dealer_id)
67
+ return rule
68
+
69
+
70
+ @router.patch("/{rule_id}/status", response_model=RuleResponse)
71
+ def update_rule_status(
72
+ rule_id: int, payload: RuleStatusUpdate, db: Session = Depends(get_db)
73
+ ) -> RuleMaster:
74
+ rule = db.get(RuleMaster, rule_id)
75
+ if not rule:
76
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found")
77
+ before = rule_to_dict(rule)
78
+ rule.is_active = payload.is_active
79
+ write_rule_audit(db, rule.id, "STATUS_UPDATE", payload.actor, before, rule_to_dict(rule))
80
+ db.commit()
81
+ db.refresh(rule)
82
+ repo.invalidate(rule.tenant_id, rule.product_type, rule.dealer_id)
83
+ return rule
runtime.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ python-3.11.9
2
+
scorecard.db ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:772cdc993e65c56670c27fa77ae32e7c17df26231adeb8a603b06cf49c18e400
3
+ size 184320
scorecard.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from decimal import Decimal
2
+ from typing import Any
3
+
4
+ from pydantic import BaseModel, ConfigDict, Field
5
+
6
+
7
+ class ApplicationDetails(BaseModel):
8
+ leadId: str
9
+ customerName: str | None = None
10
+ mobile: str | None = None
11
+ employmentType: str | None = None
12
+ monthlyIncome: Decimal | None = None
13
+ existingEMI: Decimal | None = None
14
+ foir: Decimal | None = None
15
+ loanAmount: Decimal | None = None
16
+ assetCost: Decimal | None = None
17
+ ltv: Decimal | None = None
18
+ tenure: int | None = None
19
+ dealerId: str | None = None
20
+ state: str | None = None
21
+ city: str | None = None
22
+ pincode: str | None = None
23
+ negativeArea: bool | None = None
24
+ fraudFlag: bool | None = False
25
+ blacklistMatch: bool | None = False
26
+ fakeKyc: bool | None = False
27
+ panMismatch: bool | None = False
28
+ faceMatchScore: Decimal | None = None
29
+ panVerified: bool | None = None
30
+ aadhaarVerified: bool | None = None
31
+ bankingSurrogate: bool | None = None
32
+ coApplicant: bool | None = None
33
+
34
+
35
+ class BureauData(BaseModel):
36
+ cibilScore: int | None = None
37
+ creditVintageMonths: int | None = None
38
+ activeAccounts: int | None = None
39
+ closedAccounts: int | None = None
40
+ overdueAmount: Decimal | None = None
41
+ writtenOffAccounts: int | None = None
42
+ settledAccounts: int | None = None
43
+ suitFiledAccounts: int | None = 0
44
+ active90PlusDpd: bool | None = False
45
+ maxDPD: int | None = None
46
+ dpd30Count: int | None = None
47
+ dpd60Count: int | None = None
48
+ dpd90Count: int | None = None
49
+ creditEnquiries6M: int | None = None
50
+ creditEnquiries12M: int | None = None
51
+ activeUnsecuredLoans: int | None = None
52
+ activeSecuredLoans: int | None = None
53
+ jointAccounts: int | None = None
54
+ creditUtilization: Decimal | None = None
55
+ bureauRisk: str | None = None
56
+
57
+
58
+ class ScorecardRequest(BaseModel):
59
+ application: ApplicationDetails
60
+ bureau: BureauData
61
+ tenantId: str = "default"
62
+ productType: str = "TWO_WHEELER_LOAN"
63
+ forceRecalculate: bool = False
64
+
65
+
66
+ class ScorecardResponse(BaseModel):
67
+ leadId: str
68
+ finalScore: Decimal
69
+ decision: str
70
+ underwritingRequired: bool
71
+ rejectFlag: bool
72
+ journeyAction: str
73
+ bureauRisk: str
74
+ reasonCodes: list[str]
75
+ triggeredRules: list[str]
76
+ scoreBreakup: dict[str, Decimal]
77
+
78
+ model_config = ConfigDict(from_attributes=True)
79
+
80
+
81
+ class SimulationResponse(ScorecardResponse):
82
+ persisted: bool = False
83
+
84
+
85
+ class StoredScorecardResponse(ScorecardResponse):
86
+ tenantId: str
87
+ productType: str
88
+ createdAt: str
89
+
90
+
91
+ class HealthResponse(BaseModel):
92
+ status: str
93
+ database: str
94
+ redis: str
95
+ details: dict[str, Any] = Field(default_factory=dict)
96
+
scorecard_service.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from decimal import Decimal
3
+
4
+ from sqlalchemy import select
5
+ from sqlalchemy.orm import Session
6
+
7
+ from app.core.enums import BureauRisk, JourneyAction, RuleType, ScorecardDecision
8
+ from app.models.audit import ScorecardExecutionAudit
9
+ from app.models.rule import RuleMaster
10
+ from app.models.scorecard import ScorecardResult
11
+ from app.schemas.scorecard import ScorecardRequest, ScorecardResponse
12
+ from app.services.bureau import BureauRiskClassifier
13
+ from app.services.context import build_evaluation_context
14
+ from app.services.hash import stable_payload_hash
15
+ from app.services.rule_engine import RuleEngine
16
+ from app.services.rule_repository import RuleRepository
17
+
18
+
19
+ class ScorecardService:
20
+ def __init__(self) -> None:
21
+ self.rules = RuleRepository()
22
+ self.engine = RuleEngine()
23
+ self.bureau = BureauRiskClassifier()
24
+
25
+ def _existing_result(
26
+ self, db: Session, request: ScorecardRequest, request_hash: str
27
+ ) -> ScorecardResult | None:
28
+ stmt = (
29
+ select(ScorecardResult)
30
+ .where(
31
+ ScorecardResult.lead_id == request.application.leadId,
32
+ ScorecardResult.tenant_id == request.tenantId,
33
+ ScorecardResult.product_type == request.productType,
34
+ ScorecardResult.request_hash == request_hash,
35
+ )
36
+ .order_by(ScorecardResult.created_at.desc())
37
+ )
38
+ return db.scalars(stmt).first()
39
+
40
+ def generate(self, db: Session, request: ScorecardRequest, persist: bool = True) -> ScorecardResponse:
41
+ start = time.perf_counter()
42
+ payload_hash = stable_payload_hash(request.model_dump())
43
+ if persist and not request.forceRecalculate:
44
+ existing = self._existing_result(db, request, payload_hash)
45
+ if existing:
46
+ return self._to_response(existing)
47
+
48
+ audit = ScorecardExecutionAudit(
49
+ lead_id=request.application.leadId,
50
+ tenant_id=request.tenantId,
51
+ product_type=request.productType,
52
+ request_hash=payload_hash,
53
+ matched_rules=[],
54
+ skipped_rules=[],
55
+ status="STARTED",
56
+ )
57
+ if persist:
58
+ db.add(audit)
59
+ db.flush()
60
+
61
+ try:
62
+ response = self._execute(db, request)
63
+ if persist:
64
+ result = ScorecardResult(
65
+ tenant_id=request.tenantId,
66
+ product_type=request.productType,
67
+ lead_id=response.leadId,
68
+ final_score=response.finalScore,
69
+ decision=response.decision,
70
+ underwriting_required=response.underwritingRequired,
71
+ reject_flag=response.rejectFlag,
72
+ journey_action=response.journeyAction,
73
+ bureau_risk=response.bureauRisk,
74
+ score_breakup={k: str(v) for k, v in response.scoreBreakup.items()},
75
+ triggered_rules=response.triggeredRules,
76
+ reason_codes=response.reasonCodes,
77
+ request_hash=payload_hash,
78
+ )
79
+ db.add(result)
80
+ audit.matched_rules = response.triggeredRules
81
+ audit.status = "SUCCESS"
82
+ audit.latency_ms = int((time.perf_counter() - start) * 1000)
83
+ db.commit()
84
+ db.refresh(result)
85
+ return self._to_response(result)
86
+ return response
87
+ except Exception as exc:
88
+ if persist:
89
+ audit.status = "FAILED"
90
+ audit.error_message = str(exc)
91
+ audit.latency_ms = int((time.perf_counter() - start) * 1000)
92
+ db.commit()
93
+ raise
94
+
95
+ def _execute(self, db: Session, request: ScorecardRequest) -> ScorecardResponse:
96
+ context = build_evaluation_context(request)
97
+ rules = self.rules.get_active_rules(
98
+ db, request.tenantId, request.productType, request.application.dealerId
99
+ )
100
+
101
+ hard_reject = self.engine.evaluate(
102
+ [rule for rule in rules if rule.rule_type == RuleType.HARD_REJECT], context
103
+ )
104
+ score, breakup, score_eval = self.engine.score(rules, context)
105
+ bureau_risk = self.bureau.classify(context)
106
+ non_stp = self.engine.evaluate(
107
+ [rule for rule in rules if rule.rule_type == RuleType.NON_STP], context
108
+ )
109
+ stp_eligibility_rules = [
110
+ rule for rule in rules if rule.rule_type == RuleType.STP_ELIGIBILITY
111
+ ]
112
+ stp_eval = self.engine.evaluate(stp_eligibility_rules, context)
113
+
114
+ decision = self._decide(
115
+ score=score,
116
+ hard_reject_rules=hard_reject.matched,
117
+ non_stp_rules=non_stp.matched,
118
+ stp_rules=stp_eligibility_rules,
119
+ stp_matched=stp_eval.matched,
120
+ bureau_risk=bureau_risk,
121
+ )
122
+ underwriting_required = decision in {
123
+ ScorecardDecision.NON_STP,
124
+ ScorecardDecision.CONDITIONAL_STP,
125
+ }
126
+ reject_flag = decision == ScorecardDecision.REJECT
127
+ journey_action = self._journey_action(decision)
128
+
129
+ triggered_rules = (
130
+ hard_reject.codes + score_eval.codes + non_stp.codes + stp_eval.codes
131
+ )
132
+ reason_codes = list(
133
+ dict.fromkeys(
134
+ hard_reject.reason_codes
135
+ + score_eval.reason_codes
136
+ + non_stp.reason_codes
137
+ + stp_eval.reason_codes
138
+ )
139
+ )
140
+
141
+ return ScorecardResponse(
142
+ leadId=request.application.leadId,
143
+ finalScore=score,
144
+ decision=decision,
145
+ underwritingRequired=underwriting_required,
146
+ rejectFlag=reject_flag,
147
+ journeyAction=journey_action,
148
+ bureauRisk=bureau_risk,
149
+ reasonCodes=reason_codes,
150
+ triggeredRules=triggered_rules,
151
+ scoreBreakup=breakup,
152
+ )
153
+
154
+ def _decide(
155
+ self,
156
+ score: Decimal,
157
+ hard_reject_rules: list[RuleMaster],
158
+ non_stp_rules: list[RuleMaster],
159
+ stp_rules: list[RuleMaster],
160
+ stp_matched: list[RuleMaster],
161
+ bureau_risk: BureauRisk,
162
+ ) -> ScorecardDecision:
163
+ if hard_reject_rules:
164
+ return ScorecardDecision.REJECT
165
+ all_stp_rules_match = bool(stp_rules) and len(stp_rules) == len(stp_matched)
166
+ if (
167
+ all_stp_rules_match
168
+ and score >= Decimal("80")
169
+ and not non_stp_rules
170
+ and bureau_risk != BureauRisk.HIGH
171
+ ):
172
+ return ScorecardDecision.STP
173
+ if non_stp_rules or bureau_risk == BureauRisk.HIGH:
174
+ return ScorecardDecision.NON_STP
175
+ if Decimal("65") <= score <= Decimal("79"):
176
+ return ScorecardDecision.CONDITIONAL_STP
177
+ return ScorecardDecision.NON_STP
178
+
179
+ def _journey_action(self, decision: ScorecardDecision) -> JourneyAction:
180
+ if decision == ScorecardDecision.STP:
181
+ return JourneyAction.SKIP_UNDERWRITING
182
+ if decision == ScorecardDecision.REJECT:
183
+ return JourneyAction.REJECT_APPLICATION
184
+ return JourneyAction.SEND_TO_UNDERWRITING
185
+
186
+ def _to_response(self, result: ScorecardResult) -> ScorecardResponse:
187
+ return ScorecardResponse(
188
+ leadId=result.lead_id,
189
+ finalScore=result.final_score,
190
+ decision=result.decision,
191
+ underwritingRequired=result.underwriting_required,
192
+ rejectFlag=result.reject_flag,
193
+ journeyAction=result.journey_action,
194
+ bureauRisk=result.bureau_risk,
195
+ reasonCodes=list(result.reason_codes or []),
196
+ triggeredRules=list(result.triggered_rules or []),
197
+ scoreBreakup={k: Decimal(str(v)) for k, v in (result.score_breakup or {}).items()},
198
+ )
script.py.mako ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+ """
7
+ from typing import Sequence, Union
8
+
9
+ from alembic import op
10
+ import sqlalchemy as sa
11
+ ${imports if imports else ""}
12
+
13
+ revision: str = ${repr(up_revision)}
14
+ down_revision: Union[str, None] = ${repr(down_revision)}
15
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
16
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
17
+
18
+
19
+ def upgrade() -> None:
20
+ ${upgrades if upgrades else "pass"}
21
+
22
+
23
+ def downgrade() -> None:
24
+ ${downgrades if downgrades else "pass"}
25
+
seed_rules.py ADDED
@@ -0,0 +1,444 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from decimal import Decimal
2
+
3
+ from sqlalchemy import select
4
+ from sqlalchemy.orm import Session
5
+
6
+ from app.core.enums import DecisionAction, RuleType
7
+ from app.models.rule import RuleMaster
8
+
9
+
10
+ DEFAULT_RULES = [
11
+ {
12
+ "rule_code": "CIBIL_HIGH",
13
+ "rule_name": "High CIBIL score",
14
+ "rule_type": RuleType.SCORE,
15
+ "field_name": "cibilScore",
16
+ "operator": ">=",
17
+ "field_value": "750",
18
+ "score_value": Decimal("20"),
19
+ "score_component": "cibil",
20
+ "decision_action": DecisionAction.SCORE,
21
+ "reason_code": "HIGH_CIBIL",
22
+ "priority": 100,
23
+ },
24
+ {
25
+ "rule_code": "CIBIL_MEDIUM",
26
+ "rule_name": "Medium CIBIL score",
27
+ "rule_type": RuleType.SCORE,
28
+ "field_name": "cibilScore",
29
+ "operator": "BETWEEN",
30
+ "field_value": "700,749",
31
+ "score_value": Decimal("14"),
32
+ "score_component": "cibil",
33
+ "decision_action": DecisionAction.SCORE,
34
+ "reason_code": "MEDIUM_CIBIL",
35
+ "priority": 110,
36
+ },
37
+ {
38
+ "rule_code": "BUREAU_CLEAN",
39
+ "rule_name": "Clean repayment behaviour",
40
+ "rule_type": RuleType.SCORE,
41
+ "field_name": "noDpd",
42
+ "operator": "=",
43
+ "field_value": "true",
44
+ "score_value": Decimal("25"),
45
+ "score_component": "bureau",
46
+ "decision_action": DecisionAction.SCORE,
47
+ "reason_code": "NO_DELINQUENCY",
48
+ "priority": 120,
49
+ },
50
+ {
51
+ "rule_code": "FOIR_GOOD",
52
+ "rule_name": "Low FOIR",
53
+ "rule_type": RuleType.SCORE,
54
+ "field_name": "foir",
55
+ "operator": "<=",
56
+ "field_value": "35",
57
+ "score_value": Decimal("15"),
58
+ "score_component": "foir",
59
+ "decision_action": DecisionAction.SCORE,
60
+ "reason_code": "LOW_FOIR",
61
+ "priority": 130,
62
+ },
63
+ {
64
+ "rule_code": "FOIR_ACCEPTABLE",
65
+ "rule_name": "Acceptable FOIR",
66
+ "rule_type": RuleType.SCORE,
67
+ "field_name": "foir",
68
+ "operator": "BETWEEN",
69
+ "field_value": "36,50",
70
+ "score_value": Decimal("8"),
71
+ "score_component": "foir",
72
+ "decision_action": DecisionAction.SCORE,
73
+ "reason_code": "ACCEPTABLE_FOIR",
74
+ "priority": 131,
75
+ },
76
+ {
77
+ "rule_code": "LTV_GOOD",
78
+ "rule_name": "Low LTV",
79
+ "rule_type": RuleType.SCORE,
80
+ "field_name": "ltv",
81
+ "operator": "<=",
82
+ "field_value": "75",
83
+ "score_value": Decimal("10"),
84
+ "score_component": "ltv",
85
+ "decision_action": DecisionAction.SCORE,
86
+ "reason_code": "LOW_LTV",
87
+ "priority": 140,
88
+ },
89
+ {
90
+ "rule_code": "LTV_ACCEPTABLE",
91
+ "rule_name": "Acceptable LTV",
92
+ "rule_type": RuleType.SCORE,
93
+ "field_name": "ltv",
94
+ "operator": "BETWEEN",
95
+ "field_value": "76,85",
96
+ "score_value": Decimal("8"),
97
+ "score_component": "ltv",
98
+ "decision_action": DecisionAction.SCORE,
99
+ "reason_code": "ACCEPTABLE_LTV",
100
+ "priority": 141,
101
+ },
102
+ {
103
+ "rule_code": "NO_FRAUD",
104
+ "rule_name": "No fraud indicators",
105
+ "rule_type": RuleType.SCORE,
106
+ "field_name": "noFraud",
107
+ "operator": "=",
108
+ "field_value": "true",
109
+ "score_value": Decimal("10"),
110
+ "score_component": "fraud",
111
+ "decision_action": DecisionAction.SCORE,
112
+ "reason_code": "NO_FRAUD",
113
+ "priority": 150,
114
+ },
115
+ {
116
+ "rule_code": "STABLE_EMPLOYMENT",
117
+ "rule_name": "Stable employment type",
118
+ "rule_type": RuleType.SCORE,
119
+ "field_name": "stableEmployment",
120
+ "operator": "=",
121
+ "field_value": "true",
122
+ "score_value": Decimal("10"),
123
+ "score_component": "employment",
124
+ "decision_action": DecisionAction.SCORE,
125
+ "reason_code": "STABLE_EMPLOYMENT",
126
+ "priority": 160,
127
+ },
128
+ {
129
+ "rule_code": "BANKING_SURROGATE_OK",
130
+ "rule_name": "Banking surrogate available",
131
+ "rule_type": RuleType.SCORE,
132
+ "field_name": "bankingSurrogate",
133
+ "operator": "=",
134
+ "field_value": "true",
135
+ "score_value": Decimal("5"),
136
+ "score_component": "banking",
137
+ "decision_action": DecisionAction.SCORE,
138
+ "reason_code": "BANKING_SURROGATE",
139
+ "priority": 170,
140
+ },
141
+ {
142
+ "rule_code": "KYC_VERIFIED",
143
+ "rule_name": "PAN and Aadhaar verified",
144
+ "rule_type": RuleType.SCORE,
145
+ "field_name": "kycVerified",
146
+ "operator": "=",
147
+ "field_value": "true",
148
+ "score_value": Decimal("5"),
149
+ "score_component": "kyc",
150
+ "decision_action": DecisionAction.SCORE,
151
+ "reason_code": "KYC_VERIFIED",
152
+ "priority": 180,
153
+ },
154
+ {
155
+ "rule_code": "WRITTEN_OFF_REJECT",
156
+ "rule_name": "Written off account reject",
157
+ "rule_type": RuleType.HARD_REJECT,
158
+ "field_name": "writtenOffAccounts",
159
+ "operator": ">",
160
+ "field_value": "0",
161
+ "decision_action": DecisionAction.REJECT,
162
+ "reason_code": "WRITTEN_OFF_ACCOUNT",
163
+ "priority": 10,
164
+ },
165
+ {
166
+ "rule_code": "SETTLED_REJECT",
167
+ "rule_name": "Settled account reject",
168
+ "rule_type": RuleType.HARD_REJECT,
169
+ "field_name": "settledAccounts",
170
+ "operator": ">",
171
+ "field_value": "0",
172
+ "decision_action": DecisionAction.REJECT,
173
+ "reason_code": "SETTLED_ACCOUNT",
174
+ "priority": 11,
175
+ },
176
+ {
177
+ "rule_code": "OVERDUE_REJECT",
178
+ "rule_name": "Current overdue above threshold",
179
+ "rule_type": RuleType.HARD_REJECT,
180
+ "field_name": "overdueAmount",
181
+ "operator": ">",
182
+ "field_value": "5000",
183
+ "decision_action": DecisionAction.REJECT,
184
+ "reason_code": "CURRENT_OVERDUE",
185
+ "priority": 12,
186
+ },
187
+ {
188
+ "rule_code": "FRAUD_REJECT",
189
+ "rule_name": "Fraud flag reject",
190
+ "rule_type": RuleType.HARD_REJECT,
191
+ "field_name": "fraudFlag",
192
+ "operator": "=",
193
+ "field_value": "true",
194
+ "decision_action": DecisionAction.REJECT,
195
+ "reason_code": "FRAUD_FLAG",
196
+ "priority": 13,
197
+ },
198
+ {
199
+ "rule_code": "PAN_MISMATCH_REJECT",
200
+ "rule_name": "PAN mismatch reject",
201
+ "rule_type": RuleType.HARD_REJECT,
202
+ "field_name": "panMismatch",
203
+ "operator": "=",
204
+ "field_value": "true",
205
+ "decision_action": DecisionAction.REJECT,
206
+ "reason_code": "PAN_MISMATCH",
207
+ "priority": 14,
208
+ },
209
+ {
210
+ "rule_code": "DPD_90_REJECT",
211
+ "rule_name": "90 plus DPD reject",
212
+ "rule_type": RuleType.HARD_REJECT,
213
+ "field_name": "active90PlusDpd",
214
+ "operator": "=",
215
+ "field_value": "true",
216
+ "decision_action": DecisionAction.REJECT,
217
+ "reason_code": "ACTIVE_90_PLUS_DPD",
218
+ "priority": 15,
219
+ },
220
+ {
221
+ "rule_code": "MAX_DPD_UW",
222
+ "rule_name": "Max DPD above STP threshold",
223
+ "rule_type": RuleType.NON_STP,
224
+ "field_name": "maxDPD",
225
+ "operator": ">",
226
+ "field_value": "30",
227
+ "decision_action": DecisionAction.UW,
228
+ "reason_code": "MAX_DPD_ABOVE_30",
229
+ "priority": 200,
230
+ },
231
+ {
232
+ "rule_code": "DPD_60_UW",
233
+ "rule_name": "60 DPD history",
234
+ "rule_type": RuleType.NON_STP,
235
+ "field_name": "dpd60Count",
236
+ "operator": ">",
237
+ "field_value": "0",
238
+ "decision_action": DecisionAction.UW,
239
+ "reason_code": "DPD_60_HISTORY",
240
+ "priority": 201,
241
+ },
242
+ {
243
+ "rule_code": "DPD_90_UW",
244
+ "rule_name": "90 DPD history",
245
+ "rule_type": RuleType.NON_STP,
246
+ "field_name": "dpd90Count",
247
+ "operator": ">",
248
+ "field_value": "0",
249
+ "decision_action": DecisionAction.UW,
250
+ "reason_code": "DPD_90_HISTORY",
251
+ "priority": 202,
252
+ },
253
+ {
254
+ "rule_code": "ENQUIRY_HIGH_UW",
255
+ "rule_name": "High enquiry burst",
256
+ "rule_type": RuleType.NON_STP,
257
+ "field_name": "creditEnquiries6M",
258
+ "operator": ">",
259
+ "field_value": "5",
260
+ "decision_action": DecisionAction.UW,
261
+ "reason_code": "HIGH_ENQUIRY_BURST",
262
+ "priority": 203,
263
+ },
264
+ {
265
+ "rule_code": "FOIR_HIGH_UW",
266
+ "rule_name": "High FOIR underwriting",
267
+ "rule_type": RuleType.NON_STP,
268
+ "field_name": "foir",
269
+ "operator": ">",
270
+ "field_value": "50",
271
+ "decision_action": DecisionAction.UW,
272
+ "reason_code": "HIGH_FOIR",
273
+ "priority": 204,
274
+ },
275
+ {
276
+ "rule_code": "UNSECURED_LOANS_UW",
277
+ "rule_name": "Multiple active unsecured loans",
278
+ "rule_type": RuleType.NON_STP,
279
+ "field_name": "activeUnsecuredLoans",
280
+ "operator": ">",
281
+ "field_value": "1",
282
+ "decision_action": DecisionAction.UW,
283
+ "reason_code": "MULTIPLE_UNSECURED_LOANS",
284
+ "priority": 205,
285
+ },
286
+ {
287
+ "rule_code": "FACE_MATCH_LOW_UW",
288
+ "rule_name": "Low face match",
289
+ "rule_type": RuleType.NON_STP,
290
+ "field_name": "faceMatchScore",
291
+ "operator": "<",
292
+ "field_value": "75",
293
+ "decision_action": DecisionAction.UW,
294
+ "reason_code": "LOW_FACE_MATCH",
295
+ "priority": 206,
296
+ },
297
+ {
298
+ "rule_code": "STP_CIBIL",
299
+ "rule_name": "STP CIBIL threshold",
300
+ "rule_type": RuleType.STP_ELIGIBILITY,
301
+ "field_name": "cibilScore",
302
+ "operator": ">=",
303
+ "field_value": "750",
304
+ "decision_action": DecisionAction.STP,
305
+ "reason_code": "STP_CIBIL_OK",
306
+ "priority": 300,
307
+ },
308
+ {
309
+ "rule_code": "STP_NO_WRITEOFF",
310
+ "rule_name": "STP no written off",
311
+ "rule_type": RuleType.STP_ELIGIBILITY,
312
+ "field_name": "writtenOffAccounts",
313
+ "operator": "=",
314
+ "field_value": "0",
315
+ "decision_action": DecisionAction.STP,
316
+ "reason_code": "STP_NO_WRITEOFF",
317
+ "priority": 301,
318
+ },
319
+ {
320
+ "rule_code": "STP_NO_SETTLEMENT",
321
+ "rule_name": "STP no settlement",
322
+ "rule_type": RuleType.STP_ELIGIBILITY,
323
+ "field_name": "settledAccounts",
324
+ "operator": "=",
325
+ "field_value": "0",
326
+ "decision_action": DecisionAction.STP,
327
+ "reason_code": "STP_NO_SETTLEMENT",
328
+ "priority": 302,
329
+ },
330
+ {
331
+ "rule_code": "STP_NO_OVERDUE",
332
+ "rule_name": "STP no overdue",
333
+ "rule_type": RuleType.STP_ELIGIBILITY,
334
+ "field_name": "overdueAmount",
335
+ "operator": "=",
336
+ "field_value": "0",
337
+ "decision_action": DecisionAction.STP,
338
+ "reason_code": "STP_NO_OVERDUE",
339
+ "priority": 303,
340
+ },
341
+ {
342
+ "rule_code": "STP_MAX_DPD",
343
+ "rule_name": "STP max DPD threshold",
344
+ "rule_type": RuleType.STP_ELIGIBILITY,
345
+ "field_name": "maxDPD",
346
+ "operator": "<=",
347
+ "field_value": "30",
348
+ "decision_action": DecisionAction.STP,
349
+ "reason_code": "STP_DPD_OK",
350
+ "priority": 304,
351
+ },
352
+ {
353
+ "rule_code": "STP_FOIR",
354
+ "rule_name": "STP FOIR threshold",
355
+ "rule_type": RuleType.STP_ELIGIBILITY,
356
+ "field_name": "foir",
357
+ "operator": "<=",
358
+ "field_value": "40",
359
+ "decision_action": DecisionAction.STP,
360
+ "reason_code": "STP_FOIR_OK",
361
+ "priority": 305,
362
+ },
363
+ {
364
+ "rule_code": "STP_ENQUIRY",
365
+ "rule_name": "STP enquiry threshold",
366
+ "rule_type": RuleType.STP_ELIGIBILITY,
367
+ "field_name": "creditEnquiries6M",
368
+ "operator": "<=",
369
+ "field_value": "3",
370
+ "decision_action": DecisionAction.STP,
371
+ "reason_code": "STP_ENQUIRY_OK",
372
+ "priority": 306,
373
+ },
374
+ {
375
+ "rule_code": "STP_FACE_MATCH",
376
+ "rule_name": "STP face match threshold",
377
+ "rule_type": RuleType.STP_ELIGIBILITY,
378
+ "field_name": "faceMatchScore",
379
+ "operator": ">=",
380
+ "field_value": "85",
381
+ "decision_action": DecisionAction.STP,
382
+ "reason_code": "STP_FACE_MATCH_OK",
383
+ "priority": 307,
384
+ },
385
+ {
386
+ "rule_code": "STP_KYC",
387
+ "rule_name": "STP KYC verified",
388
+ "rule_type": RuleType.STP_ELIGIBILITY,
389
+ "field_name": "kycVerified",
390
+ "operator": "=",
391
+ "field_value": "true",
392
+ "decision_action": DecisionAction.STP,
393
+ "reason_code": "STP_KYC_OK",
394
+ "priority": 308,
395
+ },
396
+ ]
397
+
398
+ SUPPORTED_PRODUCT_TYPES = [
399
+ "TWO_WHEELER_LOAN",
400
+ "NEW_CAR_LOAN",
401
+ "USED_CAR_LOAN",
402
+ "NEW_COMMERCIAL_VEHICLE_LOAN",
403
+ "USED_COMMERCIAL_VEHICLE_LOAN",
404
+ "NEW_TRACTOR_LOAN",
405
+ "USED_TRACTOR_LOAN",
406
+ ]
407
+
408
+
409
+ def seed_default_rules(
410
+ db: Session, tenant_id: str = "default", product_type: str = "TWO_WHEELER_LOAN"
411
+ ) -> int:
412
+ inserted = 0
413
+ for item in DEFAULT_RULES:
414
+ exists = db.scalar(
415
+ select(RuleMaster.id).where(
416
+ RuleMaster.tenant_id == tenant_id,
417
+ RuleMaster.product_type == product_type,
418
+ RuleMaster.rule_code == item["rule_code"],
419
+ RuleMaster.version == 1,
420
+ RuleMaster.dealer_id.is_(None),
421
+ )
422
+ )
423
+ if exists:
424
+ continue
425
+ db.add(
426
+ RuleMaster(
427
+ tenant_id=tenant_id,
428
+ product_type=product_type,
429
+ dealer_id=None,
430
+ version=1,
431
+ is_active=True,
432
+ created_by="seed",
433
+ **item,
434
+ )
435
+ )
436
+ inserted += 1
437
+ return inserted
438
+
439
+
440
+ def seed_all_product_rules(db: Session, tenant_id: str = "default") -> int:
441
+ inserted = 0
442
+ for product_type in SUPPORTED_PRODUCT_TYPES:
443
+ inserted += seed_default_rules(db, tenant_id=tenant_id, product_type=product_type)
444
+ return inserted
session.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections.abc import Generator
2
+
3
+ from sqlalchemy import create_engine
4
+ from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
5
+
6
+ from app.core.config import get_settings
7
+
8
+
9
+ class Base(DeclarativeBase):
10
+ pass
11
+
12
+
13
+ settings = get_settings()
14
+ connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
15
+ engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=connect_args)
16
+ SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
17
+
18
+
19
+ def get_db() -> Generator[Session, None, None]:
20
+ db = SessionLocal()
21
+ try:
22
+ yield db
23
+ finally:
24
+ db.close()
25
+
ui.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from fastapi import APIRouter
4
+ from fastapi.responses import FileResponse
5
+
6
+ router = APIRouter(tags=["UI"])
7
+
8
+
9
+ @router.get("/", include_in_schema=False)
10
+ def scorecard_console() -> FileResponse:
11
+ return FileResponse(Path("app/static/index.html"))
12
+
13
+
14
+ @router.get("/generate-scorecard", include_in_schema=False)
15
+ def generate_scorecard_console() -> FileResponse:
16
+ return FileResponse(Path("app/static/index.html"))
17
+
18
+
19
+ @router.get("/simulate-scorecard", include_in_schema=False)
20
+ def simulate_scorecard_console() -> FileResponse:
21
+ return FileResponse(Path("app/static/index.html"))