Skip to content

Commit e0ae862

Browse files
authored
Merge pull request #131 from luhrhenz/feat/policy-termination
feat: implement policy termination with voter management
2 parents 9984424 + 8f5d15c commit e0ae862

15 files changed

Lines changed: 5705 additions & 10 deletions

contracts/niffyinsure/README.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,12 @@ Record the contract ID and the SHA-256 from `make sha` in your release notes so
6969

7070
```
7171
src/
72-
lib.rs # contract entry, initialize
73-
types.rs # Policy, Claim, VoteOption, ClaimStatus
74-
storage.rs # DataKey, typed read/write helpers
75-
premium.rs # compute_premium (risk factors → stroops)
76-
policy.rs # generate_premium, initiate, renew, terminate
77-
claim.rs # file_claim, vote_on_claim
78-
token.rs # token transfer wrapper
72+
lib.rs # contract entry, initialize
73+
types.rs # Policy, Claim, TerminationReason, …
74+
storage.rs # DataKey, policies, voters, open-claim counts
75+
premium.rs # compute_premium (risk factors → stroops)
76+
policy.rs # generate_premium (quote)
77+
policy_lifecycle.rs # initiate_policy, terminate, admin terminate
78+
claim.rs # file_claim, vote_on_claim (planned)
79+
token.rs # token transfer wrapper
7980
```

contracts/niffyinsure/src/claim.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
// Claim lifecycle and DAO voting will be implemented here.
2+
//
3+
// Planned public functions:
4+
// file_claim(env, policy_id, amount, details, image_urls)
5+
// vote_on_claim(env, voter, claim_id, vote)
6+
//
7+
// Open claim accounting: `storage::OpenClaimCount(holder, policy_id)` must be
8+
// incremented when a claim enters `Processing` and decremented when it reaches
9+
// a terminal status (`Approved` / `Rejected`), so policy termination can block
10+
// or audit in-flight claims. Until `file_claim` ships, admins may use
11+
// `admin_set_open_claim_count` in tests or break-glass ops only.
112
use crate::{
213
ledger,
314
storage,

contracts/niffyinsure/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,4 +233,4 @@ impl NiffyInsure {
233233
}
234234

235235
// Re-export error type so tests can reference it without the module path.
236-
pub use claim::ContractError;
236+
pub use claim::ContractError;
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
//! Policy bind/terminate: auth, voter registry, termination metadata, events.
2+
3+
use crate::{
4+
storage,
5+
types::{Policy, PolicyType, RegionTier, TerminationReason},
6+
validate,
7+
};
8+
use soroban_sdk::{contracterror, contractevent, Address, Env};
9+
10+
#[contracterror]
11+
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
12+
#[repr(u32)]
13+
pub enum PolicyError {
14+
PolicyNotFound = 1,
15+
Unauthorized = 2,
16+
AlreadyInactive = 3,
17+
OpenClaimsMustFinalize = 4,
18+
InvalidCoverage = 5,
19+
InvalidPremium = 6,
20+
InvalidTermLedgers = 7,
21+
LedgerOverflow = 8,
22+
InvalidTerminationReason = 9,
23+
HolderMismatch = 10,
24+
}
25+
26+
pub fn initiate_policy(
27+
env: &Env,
28+
holder: Address,
29+
policy_type: PolicyType,
30+
region: RegionTier,
31+
coverage: i128,
32+
premium: i128,
33+
term_ledgers: u32,
34+
) -> Result<u32, PolicyError> {
35+
holder.require_auth();
36+
37+
if coverage <= 0 {
38+
return Err(PolicyError::InvalidCoverage);
39+
}
40+
if premium <= 0 {
41+
return Err(PolicyError::InvalidPremium);
42+
}
43+
if term_ledgers == 0 {
44+
return Err(PolicyError::InvalidTermLedgers);
45+
}
46+
47+
let now = env.ledger().sequence();
48+
let end_ledger = now
49+
.checked_add(term_ledgers)
50+
.ok_or(PolicyError::LedgerOverflow)?;
51+
52+
let policy_id = storage::next_policy_id(env, &holder);
53+
54+
let policy = Policy {
55+
holder: holder.clone(),
56+
policy_id,
57+
policy_type,
58+
region,
59+
premium,
60+
coverage,
61+
is_active: true,
62+
start_ledger: now,
63+
end_ledger,
64+
terminated_at_ledger: 0,
65+
termination_reason: TerminationReason::None,
66+
terminated_by_admin: false,
67+
};
68+
69+
validate::check_policy(&policy).map_err(|e| match e {
70+
validate::Error::ZeroCoverage => PolicyError::InvalidCoverage,
71+
validate::Error::ZeroPremium => PolicyError::InvalidPremium,
72+
validate::Error::InvalidLedgerWindow => PolicyError::InvalidTermLedgers,
73+
_ => PolicyError::InvalidCoverage,
74+
})?;
75+
76+
storage::set_policy(env, &policy);
77+
storage::increment_holder_active_policies(env, &holder);
78+
storage::voters_ensure_holder(env, &holder);
79+
80+
Ok(policy_id)
81+
}
82+
83+
/// Holder-initiated termination. Blocks while `OpenClaimCount(holder, policy_id) > 0`.
84+
pub fn terminate_policy(
85+
env: &Env,
86+
holder: Address,
87+
policy_id: u32,
88+
reason: TerminationReason,
89+
) -> Result<(), PolicyError> {
90+
holder.require_auth();
91+
terminate_inner(env, &holder, policy_id, reason, false, false)
92+
}
93+
94+
/// Admin termination (audited). `allow_open_claims` documents explicit acceptance
95+
/// that in-flight claims may lack a normal resolution path — indexers read the flag.
96+
pub fn admin_terminate_policy(
97+
env: &Env,
98+
admin: Address,
99+
holder: Address,
100+
policy_id: u32,
101+
reason: TerminationReason,
102+
allow_open_claims: bool,
103+
) -> Result<(), PolicyError> {
104+
admin.require_auth();
105+
let expected = storage::get_admin(env);
106+
if admin != expected {
107+
return Err(PolicyError::Unauthorized);
108+
}
109+
110+
terminate_inner(env, &holder, policy_id, reason, true, allow_open_claims)
111+
}
112+
113+
fn terminate_inner(
114+
env: &Env,
115+
holder: &Address,
116+
policy_id: u32,
117+
reason: TerminationReason,
118+
by_admin: bool,
119+
allow_open_claim_bypass: bool,
120+
) -> Result<(), PolicyError> {
121+
if reason == TerminationReason::None {
122+
return Err(PolicyError::InvalidTerminationReason);
123+
}
124+
125+
let mut policy =
126+
storage::get_policy(env, holder, policy_id).ok_or(PolicyError::PolicyNotFound)?;
127+
128+
if policy.holder != *holder {
129+
return Err(PolicyError::HolderMismatch);
130+
}
131+
132+
if !policy.is_active {
133+
return Err(PolicyError::AlreadyInactive);
134+
}
135+
136+
let open = storage::get_open_claim_count(env, holder, policy_id);
137+
if open > 0 && (!by_admin || !allow_open_claim_bypass) {
138+
return Err(PolicyError::OpenClaimsMustFinalize);
139+
}
140+
141+
let now = env.ledger().sequence();
142+
policy.is_active = false;
143+
policy.terminated_at_ledger = now;
144+
policy.termination_reason = reason;
145+
policy.terminated_by_admin = by_admin;
146+
147+
storage::set_policy(env, &policy);
148+
storage::decrement_holder_active_policies(env, holder);
149+
if storage::get_holder_active_policy_count(env, holder) == 0 {
150+
storage::voters_remove_holder(env, holder);
151+
}
152+
153+
emit_policy_terminated(
154+
env,
155+
holder,
156+
policy_id,
157+
reason,
158+
by_admin,
159+
allow_open_claim_bypass && open > 0,
160+
open,
161+
);
162+
163+
Ok(())
164+
}
165+
166+
#[contractevent(topics = ["niffyinsure", "policy_terminated"])]
167+
#[derive(Clone, Debug, Eq, PartialEq)]
168+
pub struct PolicyTerminated {
169+
#[topic]
170+
pub holder: Address,
171+
#[topic]
172+
pub policy_id: u32,
173+
pub reason_code: u32,
174+
pub terminated_by_admin: u32,
175+
pub open_claim_bypass: u32,
176+
pub open_claims: u32,
177+
pub at_ledger: u32,
178+
}
179+
180+
fn emit_policy_terminated(
181+
env: &Env,
182+
holder: &Address,
183+
policy_id: u32,
184+
reason: TerminationReason,
185+
terminated_by_admin: bool,
186+
open_claim_bypass: bool,
187+
open_claims: u32,
188+
) {
189+
let reason_code = termination_reason_tag(reason);
190+
let bypass_flag: u32 = if open_claim_bypass { 1 } else { 0 };
191+
let admin_flag: u32 = if terminated_by_admin { 1 } else { 0 };
192+
PolicyTerminated {
193+
holder: holder.clone(),
194+
policy_id,
195+
reason_code,
196+
terminated_by_admin: admin_flag,
197+
open_claim_bypass: bypass_flag,
198+
open_claims,
199+
at_ledger: env.ledger().sequence(),
200+
}
201+
.publish(env);
202+
}
203+
204+
fn termination_reason_tag(reason: TerminationReason) -> u32 {
205+
match reason {
206+
TerminationReason::None => 0,
207+
TerminationReason::VoluntaryCancellation => 1,
208+
TerminationReason::LapsedNonPayment => 2,
209+
TerminationReason::UnderwritingVoid => 3,
210+
TerminationReason::FraudOrMisrepresentation => 4,
211+
TerminationReason::RegulatoryAction => 5,
212+
TerminationReason::AdminOverride => 6,
213+
}
214+
}

contracts/niffyinsure/src/premium.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,3 +289,89 @@ impl MultiplierKind {
289289
}
290290
}
291291
}
292+
293+
#[allow(dead_code)]
294+
pub fn type_factor(policy_type: &PolicyType) -> i128 {
295+
match policy_type {
296+
PolicyType::Auto => 15,
297+
PolicyType::Health => 20,
298+
PolicyType::Property => 10,
299+
}
300+
}
301+
302+
#[allow(dead_code)]
303+
pub fn region_factor(region: &RegionTier) -> i128 {
304+
match region {
305+
RegionTier::Low => 8,
306+
RegionTier::Medium => 10,
307+
RegionTier::High => 14,
308+
}
309+
}
310+
311+
#[allow(dead_code)]
312+
pub fn age_factor(age: u32) -> i128 {
313+
if age < 25 {
314+
15
315+
} else if age > 60 {
316+
13
317+
} else {
318+
10
319+
}
320+
}
321+
322+
pub fn compute_premium_checked(
323+
policy_type: &PolicyType,
324+
region: &RegionTier,
325+
age: u32,
326+
risk_score: u32,
327+
) -> Option<i128> {
328+
let tf = type_factor(policy_type);
329+
let rf = region_factor(region);
330+
let af = age_factor(age);
331+
let raw = tf
332+
.checked_add(rf)?
333+
.checked_add(af)?
334+
.checked_add(risk_score as i128)?;
335+
BASE.checked_mul(raw)?.checked_div(10)
336+
}
337+
338+
pub fn build_line_items(
339+
env: &Env,
340+
policy_type: &PolicyType,
341+
region: &RegionTier,
342+
age: u32,
343+
risk_score: u32,
344+
) -> Option<Vec<PremiumQuoteLineItem>> {
345+
let tf = type_factor(policy_type);
346+
let rf = region_factor(region);
347+
let af = age_factor(age);
348+
let rsk = risk_score as i128;
349+
350+
let base_type = BASE.checked_mul(tf)?.checked_div(10)?;
351+
let base_region = BASE.checked_mul(rf)?.checked_div(10)?;
352+
let base_age = BASE.checked_mul(af)?.checked_div(10)?;
353+
let base_risk = BASE.checked_mul(rsk)?.checked_div(10)?;
354+
355+
let mut items = Vec::new(env);
356+
items.push_back(PremiumQuoteLineItem {
357+
component: String::from_str(env, "type"),
358+
factor: tf,
359+
amount: base_type,
360+
});
361+
items.push_back(PremiumQuoteLineItem {
362+
component: String::from_str(env, "region"),
363+
factor: rf,
364+
amount: base_region,
365+
});
366+
items.push_back(PremiumQuoteLineItem {
367+
component: String::from_str(env, "age"),
368+
factor: af,
369+
amount: base_age,
370+
});
371+
items.push_back(PremiumQuoteLineItem {
372+
component: String::from_str(env, "risk_score"),
373+
factor: rsk,
374+
amount: base_risk,
375+
});
376+
Some(items)
377+
}

0 commit comments

Comments
 (0)