"""Foreclosure payment allocation. Triggered from PHP (updateAdvanceAmount) right after a foreclosure payment is taken. This script is the SOLE writer of the foreclosure ledger for new cases. It writes only the granular breakup rows, in a single all-or-nothing transaction. Historical cases retain their legacy monolithic 'Foreclosure' row and are not converted. Waterfall (per product spec): BPI -> Moratorium -> Interest -> Principal -> PF -> Bounce -> Penal. - Moratorium is intentionally skipped (foreclosure_breakup currently returns 'Moratorium due' = 0). - Advance/down-payment is intentionally skipped (collected upfront). - Any amount left after the waterfall is parked as 'foreclosure_over_payment'. Idempotency: if an active granular breakup row already exists, the call is a no-op. An active monolith also makes the call a no-op so historical cases remain monolith-only. """ import pandas as pd from datetime import datetime import pytz from utils import ( execute_query, insert_payment_transactions_atomic, get_cloudwatch_logger, ) from scripts.foreclosure_breakup import function1 as foreclosure_func logger = get_cloudwatch_logger(stream_name="foreclosure_allocation") local_tz = pytz.timezone("Asia/Kolkata") LABEL_OVER_PAYMENT = "foreclosure_over_payment" # (breakup-due key, payment_for label) in strict waterfall order. # 'Moratorium due' is omitted on purpose (always 0); 'Advance due' omitted (collected upfront). WATERFALL = [ ("Pre-EMI due", "foreclosure_bpi"), ("Interest due", "foreclosure_interest"), ("Principal due", "foreclosure_principal"), ("PF due", "processing_fee"), ("Bounce charges due", "foreclosure_bounce"), ("Penal charges due", "foreclosure_penal"), ] def _make_row(data, amount, label): """Clone the incoming payment data into a single payment_transactions row.""" row = dict(data) row["amount"] = int(round(amount)) row["payment_for"] = label return row def _existing_foreclosure_rows(application_id): """Returns (splits_exist, monolith_exists) for active foreclosure rows.""" qry = """ select sum(case when payment_for in ('foreclosure_bpi', 'foreclosure_interest', 'foreclosure_principal', 'foreclosure_bounce', 'foreclosure_penal', 'foreclosure_over_payment') then 1 else 0 end) as split_count, sum(case when payment_for = 'Foreclosure' then 1 else 0 end) as monolith_count from payment_transactions where application_id = :app_id and deleted_at is null and is_deleted_at is null and refunded_at is null """ df = execute_query(qry, params={"app_id": application_id}) if isinstance(df, str): # execute_query only returns a str on non-SELECT; defensive guard. raise RuntimeError(df) if not isinstance(df, pd.DataFrame) or df.empty: return False, False row = df.iloc[0].fillna(0).to_dict() return int(row.get("split_count", 0)) > 0, int(row.get("monolith_count", 0)) > 0 async def function1(data): application_id = data.get("application_id") amount = data.get("amount") logger.send_log_event(f"INFO: In foreclosure_allocation. Logging request: {data}") if application_id is None: return {"status": "FAILED", "message": "application_id not provided."} if amount is None or int(amount) <= 0: return {"status": "FAILED", "message": "Foreclosure amount should be greater than 0."} amount = int(amount) # ---- Idempotency guard ------------------------------------------------- splits_exist, monolith_exists = _existing_foreclosure_rows(application_id) if splits_exist: msg = f"Foreclosure breakup already exists for application_id: {application_id}. Skipping." logger.send_log_event(f"INFO: {msg}") return {"status": "Success", "message": msg, "skipped": True} if monolith_exists: msg = f"Historical foreclosure monolith exists for application_id: {application_id}. Skipping conversion." logger.send_log_event(f"INFO: {msg}") return {"status": "Success", "message": msg, "skipped": True} # ---- Get foreclosure dues --------------------------------------------- foreclosure_response = await foreclosure_func(data={"application_id": application_id}) if foreclosure_response.get("status") != "Success": return {"status": "ERROR", "message": foreclosure_response.get("message", "Foreclosure breakup failed")} dues = foreclosure_response["message"] # ---- Build granular waterfall splits and leftover ---------------------- rows = [] remaining = amount allocations = {} for due_key, label in WATERFALL: if remaining <= 0: break due = dues.get(due_key) or 0 if due <= 0: continue alloc = int(round(min(remaining, due))) if alloc >= 1: rows.append(_make_row(data, alloc, label)) allocations[label] = alloc remaining -= alloc logger.send_log_event(f"INFO: Allocated {alloc} to {label}. Remaining: {remaining}") # Anything left after the waterfall is an over-payment. if remaining >= 1: rows.append(_make_row(data, remaining, LABEL_OVER_PAYMENT)) allocations[LABEL_OVER_PAYMENT] = remaining logger.send_log_event(f"INFO: Parked {remaining} as {LABEL_OVER_PAYMENT}.") # ---- Persist atomically (all-or-nothing) ------------------------------ insert_payment_transactions_atomic(rows) logger.send_log_event( f"INFO: Foreclosure allocation done for application_id: {application_id}. " f"allocations={allocations}" ) # ---- Waive off remaining bounce and penal charges ----------------------- df_reconciled_list = foreclosure_response.get("data", []) if df_reconciled_list: try: foreclosure_reconcile(application_id, df_reconciled_list, allocations) except Exception as e: logger.send_log_event(f"ERROR: Foreclosure waive-off failed for application_id {application_id}: {str(e)}") return { "status": "Success", "message": f"Foreclosure allocation completed for application_id: {application_id}.", "allocations": allocations, } def foreclosure_reconcile(application_id, df_reconciled_list, allocations): """ Waive off the unpaid bounce and penal charges across EMIs after foreclosure allocation. """ now = datetime.now(local_tz).strftime('%Y-%m-%d %H:%M:%S') allocated_bounce = allocations.get('foreclosure_bounce', 0) allocated_penal = allocations.get('foreclosure_penal', 0) df_reconciled = pd.DataFrame(df_reconciled_list) # Filter out non-EMI rows (emi_payment_id == 0 are pre_part_payments) if 'emi_payment_id' not in df_reconciled.columns: return df_emis = df_reconciled[df_reconciled['emi_payment_id'] != 0].copy() if df_emis.empty: return # Sort by order_date or due_date ascending to allocate to oldest EMI first if 'order_date' in df_emis.columns: df_emis = df_emis.sort_values(by='order_date') elif 'due_date' in df_emis.columns: df_emis['due_date_dt'] = pd.to_datetime(df_emis['due_date'], format='%d-%m-%Y', errors='coerce') df_emis = df_emis.sort_values(by='due_date_dt') updated_count = 0 logs_created = 0 for idx, row in df_emis.iterrows(): bounce_charges = float(row.bounce_charges) if pd.notna(row.bounce_charges) else 0 bounce_paid = float(row.bounce_paid) if 'bounce_paid' in row and pd.notna(row.bounce_paid) else 0 unpaid_bounce = max(0.0, bounce_charges - bounce_paid) delay_penalty = float(row.delay_penalty) if pd.notna(row.delay_penalty) else 0 penal_paid = float(row.penal_paid) if 'penal_paid' in row and pd.notna(row.penal_paid) else 0 unpaid_penal = max(0.0, delay_penalty - penal_paid) pay_bounce = min(unpaid_bounce, allocated_bounce) allocated_bounce -= pay_bounce waived_bounce = unpaid_bounce - pay_bounce pay_penal = min(unpaid_penal, allocated_penal) allocated_penal -= pay_penal waived_penal = unpaid_penal - pay_penal charges_to_waive = waived_bounce + waived_penal if charges_to_waive > 0: total_paid = float(row.total_amount_paid) if 'total_amount_paid' in row and pd.notna(row.total_amount_paid) else 0 emi_payment_id = int(row.emi_payment_id) update_ep_query = f""" update emi_payments set charges_waived = charges_waived + GREATEST(0, FLOOR({charges_to_waive})), total_payable = (pre_emi_amount + emi_amount + bounce_charges + delay_penalty) - charges_waived, amount_due = total_payable - GREATEST(0, FLOOR({total_paid})), updated_at = '{now}' where application_id = {application_id} AND id = {emi_payment_id}; """ execute_query(update_ep_query) updated_count += 1 insert_waive_log_query = f""" insert into waive_off_logs ( emi_payment_id, waived_by, status, created_at, updated_at, amount, application_id ) VALUES ( {emi_payment_id}, 107, 'Active', '{now}', '{now}', {charges_to_waive}, {application_id} ); """ execute_query(insert_waive_log_query) logs_created += 1 if updated_count > 0 or logs_created > 0: logger.send_log_event(f"INFO: Foreclosure Waive-off: Updated emi_payments for {updated_count} rows for application_id: {application_id}.") logger.send_log_event(f"INFO: Foreclosure Waive-off: Inserted waive_off_logs for {logs_created} rows for application_id: {application_id}.")