Skip to content

Commit db6f888

Browse files
parameshjavapkorrakuti-mvrkclaude
authored
EMI-model loans: schedules, prepayment, late fees, calculator + UI polish (#26)
Co-authored-by: Paramesh Korrakuti <pkorrakuti@mavvrik.ai> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fa0a52c commit db6f888

43 files changed

Lines changed: 5637 additions & 90 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/supabase-schema.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,8 @@ create table public.reference (
397397
name text not null,
398398
description text,
399399
value numeric not null,
400+
datatype text not null default 'number'
401+
check (datatype in ('inr', 'percentage', 'date', 'number')),
400402
updated_at timestamptz not null default now(),
401403
updated_by uuid references auth.users(id)
402404
);

docs/superpowers/plans/2026-06-13-loan-emi-model.md

Lines changed: 1064 additions & 0 deletions
Large diffs are not rendered by default.

docs/superpowers/specs/2026-06-13-loan-emi-model-design.md

Lines changed: 354 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
-- =============================================================================
2+
-- 037 — EMI model configuration keys.
3+
--
4+
-- Inserts 7 new key/value rows into public.reference and matching baseline
5+
-- rows into public.reference_history.
6+
--
7+
-- NOTE: emi_cutover_date default (20260701) is a placeholder — set the real
8+
-- cutover date before running in prod via the /admin/reference UI or a follow-up
9+
-- UPDATE, then close out the history row and append the corrected value.
10+
-- reference.value is numeric, so the date is stored as a YYYYMMDD integer;
11+
-- convert back with to_date(value::int::text,'YYYYMMDD').
12+
--
13+
-- IDEMPOTENT: `on conflict (key) do nothing` means re-running is safe and
14+
-- existing values are never overwritten.
15+
-- =============================================================================
16+
17+
begin;
18+
19+
insert into public.reference (key, name, description, value) values
20+
(
21+
'loan_interest_rate_pct',
22+
'Loan Interest Rate (%)',
23+
'Annual interest rate applied to EMI-model loans, expressed as a percentage.',
24+
8
25+
),
26+
(
27+
'loan_max_term_months',
28+
'Loan Maximum Term (months)',
29+
'Maximum repayment period for any new loan under the EMI model.',
30+
30
31+
),
32+
(
33+
'loan_default_waiver_medical',
34+
'Default Waiver — Medical (months)',
35+
'Default number of interest-free waiver months granted for medical-purpose loans.',
36+
6
37+
),
38+
(
39+
'loan_max_waiver_months',
40+
'Maximum Waiver (months)',
41+
'Absolute cap on interest-waiver months that can be granted to any single loan.',
42+
6
43+
),
44+
(
45+
'late_fee_pct',
46+
'Late Fee (%)',
47+
'Percentage of the overdue EMI amount charged as a late fee.',
48+
2
49+
),
50+
(
51+
'late_fee_overdue_months',
52+
'Late Fee Threshold (months overdue)',
53+
'Number of months a payment must be overdue before a late fee is applied.',
54+
2
55+
),
56+
(
57+
'emi_cutover_date',
58+
'EMI Cutover Date (YYYYMMDD)',
59+
'Date from which legacy loans convert to the EMI model, stored as a YYYYMMDD integer (20260701 = 2026-07-01). Placeholder — update before go-live.',
60+
20260701
61+
-- reference.value is numeric; emi_cutover_date is a YYYYMMDD integer.
62+
-- Convert back with to_date(value::int::text, ''YYYYMMDD'') in queries.
63+
)
64+
on conflict (key) do nothing;
65+
66+
-- Mirror each new key into reference_history (open-ended baseline).
67+
-- Pattern matches 005: effective_from = now()::date (runtime-added keys have
68+
-- no meaningful historical start, so we use today), effective_to = NULL.
69+
-- The select-where-not-exists guard makes this idempotent.
70+
insert into public.reference_history (key, value, effective_from, effective_to, notes)
71+
select r.key, r.value, now()::date, null, 'EMI model baseline — set by migration 037'
72+
from public.reference r
73+
where r.key in (
74+
'loan_interest_rate_pct',
75+
'loan_max_term_months',
76+
'loan_default_waiver_medical',
77+
'loan_max_waiver_months',
78+
'late_fee_pct',
79+
'late_fee_overdue_months',
80+
'emi_cutover_date'
81+
)
82+
and not exists (
83+
select 1 from public.reference_history h where h.key = r.key
84+
);
85+
86+
commit;
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
-- =============================================================================
2+
-- 038 — Loan EMI model: schedule + payments tables, loans columns.
3+
--
4+
-- Adds an optional EMI repayment track to the loan system. The existing
5+
-- "accrual" model is unchanged; new loans can opt into "emi" via the
6+
-- repayment_model discriminator column on public.loans.
7+
--
8+
-- New objects
9+
-- -----------
10+
-- public.loans (ALTER) — term_months, interest_rate_pct,
11+
-- emi_amount, schedule_generated_at,
12+
-- repayment_model
13+
-- public.loan_emi_schedule — one row per installment per loan
14+
-- public.loan_emi_payments — junction: installment ↔ transaction
15+
--
16+
-- RLS style mirrors 009_loan_interest_accruals:
17+
-- SELECT → every authenticated user
18+
-- ALL → authenticated + is_admin() guard
19+
-- =============================================================================
20+
21+
begin;
22+
23+
-- -----------------------------------------------------------------------------
24+
-- (a) Extend public.loans with EMI columns
25+
-- -----------------------------------------------------------------------------
26+
27+
alter table public.loans
28+
add column if not exists term_months integer,
29+
add column if not exists interest_rate_pct numeric,
30+
add column if not exists emi_amount numeric(12,2),
31+
add column if not exists schedule_generated_at timestamptz,
32+
add column if not exists repayment_model text not null default 'accrual'
33+
check (repayment_model in ('accrual', 'emi'));
34+
35+
-- -----------------------------------------------------------------------------
36+
-- (b) CREATE TABLE public.loan_emi_schedule
37+
-- One row per installment. Mirrors loan_interest_accruals in style.
38+
-- -----------------------------------------------------------------------------
39+
40+
create table if not exists public.loan_emi_schedule (
41+
id uuid primary key default gen_random_uuid(),
42+
loan_id uuid not null references public.loans(id) on delete cascade,
43+
installment_no integer not null,
44+
due_date date not null,
45+
opening_balance numeric(12,2) not null,
46+
emi_amount numeric(12,2) not null,
47+
principal_due numeric(12,2) not null,
48+
interest_due numeric(12,2) not null,
49+
closing_balance numeric(12,2) not null,
50+
principal_paid numeric(12,2) not null default 0,
51+
interest_paid numeric(12,2) not null default 0,
52+
status text not null default 'scheduled'
53+
check (status in ('scheduled', 'paid', 'partially_paid', 'overdue', 'waived')),
54+
late_fee_charged numeric(12,2) not null default 0,
55+
late_fee_txn_id uuid references public.transactions(id),
56+
paid_at timestamptz,
57+
created_at timestamptz not null default now(),
58+
unique (loan_id, installment_no)
59+
);
60+
61+
create index if not exists idx_emi_schedule_loan
62+
on public.loan_emi_schedule (loan_id);
63+
64+
create index if not exists idx_emi_schedule_due
65+
on public.loan_emi_schedule (due_date)
66+
where status in ('scheduled', 'partially_paid', 'overdue');
67+
68+
-- -----------------------------------------------------------------------------
69+
-- (c) CREATE TABLE public.loan_emi_payments
70+
-- Junction: installment ↔ transaction. Mirrors loan_interest_payments.
71+
-- -----------------------------------------------------------------------------
72+
73+
create table if not exists public.loan_emi_payments (
74+
schedule_id uuid not null references public.loan_emi_schedule(id) on delete restrict,
75+
transaction_id uuid not null references public.transactions(id) on delete restrict,
76+
principal_applied numeric(12,2) not null default 0,
77+
interest_applied numeric(12,2) not null default 0,
78+
applied_at timestamptz not null default now(),
79+
primary key (schedule_id, transaction_id)
80+
);
81+
82+
create index if not exists idx_emi_payments_txn
83+
on public.loan_emi_payments (transaction_id);
84+
85+
-- -----------------------------------------------------------------------------
86+
-- (d) RLS — mirrors 009_loan_interest_accruals exactly
87+
-- -----------------------------------------------------------------------------
88+
89+
alter table public.loan_emi_schedule enable row level security;
90+
drop policy if exists "emi_schedule_read_authenticated" on public.loan_emi_schedule;
91+
create policy "emi_schedule_read_authenticated"
92+
on public.loan_emi_schedule
93+
for select to authenticated using (true);
94+
drop policy if exists "emi_schedule_write_admin" on public.loan_emi_schedule;
95+
create policy "emi_schedule_write_admin"
96+
on public.loan_emi_schedule
97+
for all to authenticated
98+
using (public.is_admin()) with check (public.is_admin());
99+
100+
alter table public.loan_emi_payments enable row level security;
101+
drop policy if exists "emi_payments_read_authenticated" on public.loan_emi_payments;
102+
create policy "emi_payments_read_authenticated"
103+
on public.loan_emi_payments
104+
for select to authenticated using (true);
105+
drop policy if exists "emi_payments_write_admin" on public.loan_emi_payments;
106+
create policy "emi_payments_write_admin"
107+
on public.loan_emi_payments
108+
for all to authenticated
109+
using (public.is_admin()) with check (public.is_admin());
110+
111+
commit;

0 commit comments

Comments
 (0)