Sesi 6: Monitoring & Payments
Pembayaran, Status Otomatis, & Dashboard Portofolio
1
Payment Service (Logic Pembayaran)
Logic
Service ini menangani pencatatan pembayaran dan validasi status (Lunas/Kurang/Lebih).
/www/wwwroot/app-collection/ao/app/services/payment_service.py
from sqlalchemy.orm import Session
from datetime import date, timedelta
from app.models.loan import Payment, Loan, Notification
from decimal import Decimal
def pay_installment(db: Session, payment_id: int, amount_paid: Decimal):
"""
Membayar satu jadwal angsuran.
"""
payment = db.query(Payment).filter(Payment.id == payment_id).first()
if not payment:
raise ValueError("Payment schedule not found")
if payment.status == "default":
raise ValueError("Cannot pay a defaulted loan without restructure.")
# Update Data Pembayaran
payment.amount_paid += amount_paid
payment.paid_date = date.today() # Simpan tanggal bayar hari ini
# Validasi Status
if payment.amount_paid >= payment.amount_due:
payment.status = "on_time" # Jika lunas, anggap on_time untuk record ini
else:
# Jika bayar sebagian, status tetap late atau on_time tergantung tanggal jatuh tempo
pass
db.commit()
db.refresh(payment)
return payment
def check_and_flag_late_payments(db: Session):
"""
Fungsi ini seharusnya dijalankan oleh Cron Job setiap hari.
Ia memeriksa pembayaran yang belum dibayar dan tanggalnya sudah lewat.
"""
today = date.today()
# Cari pembayaran yang belum dibayar (amount_paid = 0) dan tanggal jatuh tempo kemarin/sebelumnya
late_payments = db.query(Payment).filter(
Payment.schedule_date < today,
Payment.amount_paid == 0,
Payment.status == "on_time" # Hanya update yang masih on_time
).all()
for p in late_payments:
# Update status menjadi late
p.status = "late"
# Buat Notifikasi di Database
notif = Notification(
loan_id=p.loan_id,
type="late_alert",
message=f"Angsuran tgl {p.schedule_date} belum dibayar.",
sent_to="system", # Di production, ini bisa diambil dari loan.customer.phone
status="pending"
)
db.add(notif)
db.commit()
return len(late_payments)
def get_portfolio_summary(db: Session):
"""
Menghitung statistik untuk Dashboard.
"""
# Total Pinjaman Aktif
total_loans = db.query(Loan).filter(Loan.status == "active").count()
# Total Portofolio (Principal Amount)
from sqlalchemy import func
total_principal = db.query(func.sum(Loan.principal_amount)).filter(Loan.status == "active").scalar() or 0
# Distribusi Risiko (Dari tabel loans)
low_risk = db.query(Loan).filter(Loan.segment == "low", Loan.status == "active").count()
med_risk = db.query(Loan).filter(Loan.segment == "medium", Loan.status == "active").count()
high_risk = db.query(Loan).filter(Loan.segment == "high", Loan.status == "active").count()
# Rasio Kelalaian (Delinquency) = Pembayaran Late / Total Pembayaran jatuh tempo
# Untuk simplifikasi dashboard, kita hitung jumlah payment yang status 'late'
late_payments_count = db.query(Payment).filter(Payment.status == "late").count()
due_payments_count = db.query(Payment).filter(Payment.schedule_date <= date.today()).count()
delinquency_rate = (late_payments_count / due_payments_count * 100) if due_payments_count > 0 else 0
return {
"total_active_loans": total_loans,
"total_principal": float(total_principal),
"risk_distribution": {
"low": low_risk,
"medium": med_risk,
"high": high_risk
},
"delinquency_rate": round(delinquency_rate, 2)
}
2
Schemas & Models Update
Data
/www/wwwroot/app-collection/ao/app/models/notification.py
from sqlalchemy import Column, BigInteger, Integer, String, Text, DateTime, ForeignKey, Enum
from app.models.base import TimestampMixin, Base
class NotificationType(str, enum.Enum):
late_alert = "late_alert"
default_alert = "default_alert"
general = "general"
class Notification(Base, TimestampMixin):
__tablename__ = "notifications"
id = Column(BigInteger, primary_key=True, index=True)
loan_id = Column(BigInteger, ForeignKey("loans.id"), nullable=False)
type = Column(Enum(NotificationType), default=NotificationType.general)
message = Column(String(255), nullable=False)
sent_to = Column(String(150)) # Bisa no HP atau Email
sent_at = Column(DateTime, nullable=True)
status = Column(String(20), default="pending") # pending, sent, failed
/www/wwwroot/app-collection/ao/app/schemas/payment.py
from pydantic import BaseModel
from datetime import date
from decimal import Decimal
class PaymentResponse(BaseModel):
id: int
loan_id: int
schedule_date: date
amount_due: Decimal
amount_paid: Decimal
status: str
class Config:
from_attributes = True
class PayRequest(BaseModel):
amount: Decimal
3
API Routes (Payments & Monitoring)
API
/www/wwwroot/app-collection/ao/app/api/payments.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from db import get_db
from app.models.loan import Payment
from app.schemas.payment import PaymentResponse, PayRequest
from app.services.payment_service import pay_installment, check_and_flag_late_payments
router = APIRouter()
@router.get("/{loan_id}", response_model=List[PaymentResponse])
def get_loan_schedule(loan_id: int, db: Session = Depends(get_db)):
"""Melihat jadwal pembayaran suatu pembiayaan."""
payments = db.query(Payment).filter(Payment.loan_id == loan_id).order_by(Payment.schedule_date).all()
return payments
@router.post("/{payment_id}/pay", response_model=PaymentResponse)
def make_payment(
payment_id: int,
req: PayRequest,
db: Session = Depends(get_db)
):
"""Membayar angsuran tertentu."""
try:
payment = pay_installment(db, payment_id, req.amount)
return payment
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/system/check-late")
def run_daily_checker(db: Session = Depends(get_db)):
"""
Endpoint Manual untuk Cron Job.
Biasanya dipanggil otomatis setiap hari jam 00:01.
"""
count = check_and_flag_late_payments(db)
return {"message": f"Checked and flagged {count} late payments."}
/www/wwwroot/app-collection/ao/app/api/monitoring.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from db import get_db
from app.services.payment_service import get_portfolio_summary
router = APIRouter()
@router.get("/dashboard")
def dashboard_stats(db: Session = Depends(get_db)):
"""
Endpoint Dashboard Utama.
Mengembalikan ringkasan portofolio dan risiko.
"""
stats = get_portfolio_summary(db)
return stats
@router.get("/early-warning")
def early_warning_list(db: Session = Depends(get_db)):
"""
Early Warning System (EWS).
Menampilkan daftar pinjaman berisiko tinggi (High Risk Score)
DAN memiliki pembayaran yang terlambat.
"""
from app.models.loan import Loan, LoanStatus
from sqlalchemy import or_, and_
# Logika EWS: Status High Risk OR (Medium Risk AND Late Payment exists)
high_risk_loans = db.query(Loan).filter(
Loan.segment == "high",
Loan.status == LoanStatus.active
).all()
# Konversi ke JSON sederhana
warnings = []
for loan in high_risk_loans:
warnings.append({
"loan_id": loan.id,
"customer_name": loan.customer.name if loan.customer else "Unknown",
"segment": loan.segment,
"default_probability": float(loan.default_probability) if loan.default_probability else 0,
"reason": "High ML Score"
})
return {
"alert_count": len(warnings),
"alerts": warnings
}
4
Integrasi Main.py
Finalize
Update main.py untuk memasukkan router Monitoring & Payments.
/www/wwwroot/app-collection/ao/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from db import engine
import config
# Import Routers
from app.api.auth import router as auth_router
from app.api.customers import router as cust_router
from app.api.loans import router as loan_router
from app.api.files import router as file_router
from app.api.predictions import router as pred_router
from app.api.payments import router as pay_router # NEW
from app.api.monitoring import router as mon_router # NEW
app = FastAPI(title=config.get_settings().APP_NAME, version="1.0.0")
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include Routes
app.include_router(auth_router, prefix="/api/v1/auth", tags=["Authentication"])
app.include_router(cust_router, prefix="/api/v1/customers", tags=["Customers"])
app.include_router(loan_router, prefix="/api/v1/loans", tags=["Loans"])
app.include_router(file_router, prefix="/api/v1/files", tags=["Files & Uploads"])
app.include_router(pred_router, prefix="/api/v1/predictions", tags=["Machine Learning (AI)"])
app.include_router(pay_router, prefix="/api/v1/payments", tags=["Payments & Installments"]) # NEW
app.include_router(mon_router, prefix="/api/v1/monitoring", tags=["Monitoring Dashboard"]) # NEW
@app.on_event("startup")
def on_startup():
try:
with engine.connect() as conn:
print("✅ DB Connected!")
import os
os.makedirs("/www/wwwroot/app-collection/ao/uploads", exist_ok=True)
print("✅ System Ready.")
except Exception as e:
print(f"❌ Error: {e}")
@app.get("/")
def root():
return {"status": "BPRS API Running", "session": "6 - Monitoring Active"}
5
Testing Scenario
Test
Restart server API dan buka Swagger UI.
1. Cek Jadwal Pembayaran
- Gunakan Loan ID yang dibuat di sesi sebelumnya.
- GET
/api/v1/payments/{loan_id}. - Anda akan melihat daftar 12 angsuran (atau sesuai tenor).
2. Simulasi Pembayaran
- Ambil ID angsuran pertama dari list di atas.
- POST
/api/v1/payments/{payment_id}/pay. - Body:
{ "amount": 1000000 }(Sesuai amount_due). - Cek lagi endpoint GET, status pembayaran dan tanggal bayar akan berubah.
3. Simulasi Telat Bayar (Late Payment)
Untuk mengetes fitur "Late", kita perlu mengubah tanggal database secara manual atau menjalankan checker.
- Jalankan endpoint Cron Job Manual:
POST /api/v1/payments/system/check-late. - Sistem akan memindai semua pembayaran yang
schedule_date < hari inidan belum dibayar. - Statusnya akan berubah menjadi
"late"dan masuk ke tabelnotifications.
4. Lihat Dashboard
- GET
/api/v1/monitoring/dashboard. - Lihat data
delinquency_rate(Rasio Kelalaian) danrisk_distribution.
Code copied to clipboard!