Skip to content

Commit e3b2d08

Browse files
authored
Merge pull request #259 from CHEF-SAVY/feat/grace-period-config
feat: grace period config for late renewals
2 parents cc1fcef + a9f3086 commit e3b2d08

9 files changed

Lines changed: 472 additions & 6 deletions

File tree

backend/src/policy/renewal.constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ export const RENEWAL_OPEN_LEDGERS_BEFORE_EXPIRY = 120_960;
6565
*
6666
* Default: 17,280 ledgers ≈ 1 day (17,280 × 5 s = 86,400 s).
6767
* The last valid renewal ledger is endLedger + RENEWAL_GRACE_LEDGERS_AFTER_EXPIRY - 1.
68+
*
69+
* NOTE: This constant mirrors DEFAULT_GRACE_PERIOD_LEDGERS in the Soroban contract
70+
* (contracts/niffyinsure/src/ledger.rs). The live value is admin-configurable via
71+
* set_grace_period_ledgers(); fetch it from get_grace_period_ledgers() for
72+
* authoritative checks. This constant is used only as a UI fallback when the
73+
* on-chain value has not yet been fetched.
6874
*/
6975
export const RENEWAL_GRACE_LEDGERS_AFTER_EXPIRY = 17_280;
7076

contracts/niffyinsure/src/ledger.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,20 @@ pub const MIN_VOTING_DURATION_LEDGERS: u32 = LEDGERS_PER_DAY;
9292
pub const MAX_VOTING_DURATION_LEDGERS: u32 = 8 * LEDGERS_PER_WEEK;
9393

9494
/// Renewal window: holder may renew starting this many ledgers before expiry.
95-
/// Renewal is accepted while `end - RENEWAL_WINDOW_LEDGERS <= now < end`.
95+
/// Renewal is accepted while `end - RENEWAL_WINDOW_LEDGERS <= now < end + grace`.
9696
pub const RENEWAL_WINDOW_LEDGERS: u32 = 3 * LEDGERS_PER_DAY; // 51_840
9797

98+
/// Default grace period added after nominal expiry for late renewals.
99+
/// Renewal is accepted while `now < end + grace_period_ledgers`.
100+
/// Default: 17_280 ledgers ≈ 1 day.
101+
pub const DEFAULT_GRACE_PERIOD_LEDGERS: u32 = LEDGERS_PER_DAY; // 17_280
102+
103+
/// Minimum admin-settable grace period (1 hour).
104+
pub const MIN_GRACE_PERIOD_LEDGERS: u32 = LEDGERS_PER_HOUR; // 720
105+
106+
/// Maximum admin-settable grace period (7 days).
107+
pub const MAX_GRACE_PERIOD_LEDGERS: u32 = 7 * LEDGERS_PER_DAY; // 120_960
108+
98109
/// Rate-limit window: minimum ledgers between successive claim filings by the
99110
/// same policyholder. Prevents claim spam.
100111
pub const RATE_LIMIT_WINDOW_LEDGERS: u32 = LEDGERS_PER_DAY; // 17_280
@@ -155,6 +166,26 @@ pub fn is_in_renewal_window(now: u32, end: u32, window: u32) -> bool {
155166
is_within_window(now, renewal_start, end)
156167
}
157168

169+
/// Returns `true` if `now` is within the extended renewal window that includes
170+
/// the grace period: `[end - window, end + grace)`.
171+
///
172+
/// - `now < end` → standard renewal window (no gap)
173+
/// - `end <= now < end+grace` → grace period (late renewal, no coverage gap)
174+
/// - `now >= end + grace` → lapsed; renewal rejected
175+
#[inline]
176+
pub fn is_in_renewal_window_with_grace(now: u32, end: u32, window: u32, grace: u32) -> bool {
177+
let renewal_start = end.saturating_sub(window);
178+
let grace_end = end.saturating_add(grace);
179+
now >= renewal_start && now < grace_end
180+
}
181+
182+
/// Validates admin-supplied grace period before it is written to instance storage.
183+
/// Returns `true` if valid.
184+
#[inline]
185+
pub fn is_valid_grace_period_ledgers(v: u32) -> bool {
186+
(MIN_GRACE_PERIOD_LEDGERS..=MAX_GRACE_PERIOD_LEDGERS).contains(&v)
187+
}
188+
158189
/// Returns `true` if the voting deadline has not yet passed.
159190
///
160191
/// Votes are accepted while `now < filed_at + vote_window`.

contracts/niffyinsure/src/lib.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ use soroban_sdk::{contract, contractevent, contractimpl, panic_with_error, Addre
2626
#[contract]
2727
pub struct NiffyInsure;
2828
pub use admin::AdminError;
29+
pub use policy::RenewalError;
2930

3031
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
3132
#[soroban_sdk::contracterror]
@@ -224,6 +225,43 @@ impl NiffyInsure {
224225
Ok(())
225226
}
226227

228+
// ── Grace period ──────────────────────────────────────────────────────────
229+
230+
/// Admin-only: set the grace period (in ledgers) after nominal expiry during
231+
/// which late renewals are still accepted. Emits GracePeriodUpdated.
232+
pub fn set_grace_period_ledgers(env: Env, ledgers: u32) -> Result<(), policy::RenewalError> {
233+
storage::bump_instance(&env);
234+
policy::set_grace_period_ledgers(&env, ledgers)
235+
}
236+
237+
pub fn get_grace_period_ledgers(env: Env) -> u32 {
238+
policy::get_grace_period_ledgers(&env)
239+
}
240+
241+
// ── Renewal ───────────────────────────────────────────────────────────────
242+
243+
/// Renew an existing active policy within the standard or grace window.
244+
pub fn renew_policy(
245+
env: Env,
246+
holder: Address,
247+
policy_id: u32,
248+
age_band: types::AgeBand,
249+
coverage_type: types::CoverageTier,
250+
safety_score: u32,
251+
base_amount: i128,
252+
) -> Result<types::Policy, policy::RenewalError> {
253+
storage::bump_instance(&env);
254+
policy::renew_policy(
255+
&env,
256+
holder,
257+
policy_id,
258+
age_band,
259+
coverage_type,
260+
safety_score,
261+
base_amount,
262+
)
263+
}
264+
227265
pub fn process_claim(env: Env, claim_id: u64) -> Result<(), validate::Error> {
228266
let admin = storage::get_admin(&env);
229267
admin.require_auth();
@@ -693,6 +731,19 @@ impl NiffyInsure {
693731
storage::remove_voter(&env, &holder);
694732
}
695733

734+
/// Test-only: advance a seeded policy's end_ledger to simulate a renewal
735+
/// without going through token transfer. Mirrors what renew_policy does
736+
/// to the policy record after premium collection.
737+
pub fn test_renew_policy(env: Env, holder: Address, policy_id: u32) {
738+
let mut policy = storage::get_policy(&env, &holder, policy_id)
739+
.expect("policy not found");
740+
let new_start = policy.end_ledger.saturating_add(1);
741+
let new_end = new_start + ledger::POLICY_DURATION_LEDGERS;
742+
policy.start_ledger = new_start;
743+
policy.end_ledger = new_end;
744+
storage::set_policy(&env, &holder, policy_id, &policy);
745+
}
746+
696747
pub fn admin_set_open_claim_count(
697748
env: Env,
698749
admin: Address,

contracts/niffyinsure/src/policy.rs

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ use crate::{
44
validate::{self, Error},
55
};
66
use soroban_sdk::{contracterror, contractevent, contracttype, Address, Env, String};
7-
87
pub use ledger::QUOTE_TTL_LEDGERS;
98

109
/// Current event schema version.
@@ -368,3 +367,143 @@ pub fn set_beneficiary(
368367

369368
Ok(())
370369
}
370+
371+
// ── Grace period admin setter ─────────────────────────────────────────────────
372+
373+
#[contractevent(topics = ["niffyinsure", "grace_period_updated"])]
374+
#[derive(Clone, Debug, Eq, PartialEq)]
375+
pub struct GracePeriodUpdated {
376+
pub old_ledgers: u32,
377+
pub new_ledgers: u32,
378+
}
379+
380+
pub fn set_grace_period_ledgers(env: &Env, ledgers: u32) -> Result<(), RenewalError> {
381+
crate::admin::require_admin(env);
382+
if !ledger::is_valid_grace_period_ledgers(ledgers) {
383+
return Err(RenewalError::GracePeriodOutOfBounds);
384+
}
385+
let old = storage::get_grace_period_ledgers(env);
386+
storage::set_grace_period_ledgers(env, ledgers);
387+
GracePeriodUpdated {
388+
old_ledgers: old,
389+
new_ledgers: ledgers,
390+
}
391+
.publish(env);
392+
Ok(())
393+
}
394+
395+
pub fn get_grace_period_ledgers(env: &Env) -> u32 {
396+
storage::get_grace_period_ledgers(env)
397+
}
398+
399+
// ── renew_policy ──────────────────────────────────────────────────────────────
400+
401+
#[contracterror]
402+
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
403+
#[repr(u32)]
404+
pub enum RenewalError {
405+
/// Policy not found.
406+
NotFound = 200,
407+
/// Policy is not active.
408+
Inactive = 201,
409+
/// Current ledger is outside the renewal + grace window.
410+
WindowClosed = 202,
411+
/// An open claim is blocking renewal.
412+
OpenClaimBlocking = 203,
413+
/// Premium computation failed.
414+
PremiumError = 204,
415+
/// Ledger arithmetic overflow.
416+
LedgerOverflow = 205,
417+
/// Grace period value outside allowed [min, max] range.
418+
GracePeriodOutOfBounds = 206,
419+
}
420+
421+
/// Renew an existing active policy.
422+
///
423+
/// Eligible window: `[end - RENEWAL_WINDOW_LEDGERS, end + grace_period_ledgers)`.
424+
/// Renewal within the grace period succeeds with no coverage gap — the new
425+
/// term starts at `old_end_ledger + 1`.
426+
/// Blocked when an open claim exists on the policy (mirrors the open-claim rule).
427+
pub fn renew_policy(
428+
env: &Env,
429+
holder: Address,
430+
policy_id: u32,
431+
age_band: crate::types::AgeBand,
432+
coverage_type: CoverageTier,
433+
safety_score: u32,
434+
base_amount: i128,
435+
) -> Result<Policy, RenewalError> {
436+
storage::assert_bind_not_paused(env);
437+
holder.require_auth();
438+
439+
let mut policy = storage::get_policy(env, &holder, policy_id)
440+
.ok_or(RenewalError::NotFound)?;
441+
442+
if !policy.is_active {
443+
return Err(RenewalError::Inactive);
444+
}
445+
446+
// Open-claim guard (mirrors the rule enforced in the backend).
447+
if storage::has_open_claim(env, &holder, policy_id) {
448+
return Err(RenewalError::OpenClaimBlocking);
449+
}
450+
451+
let now = env.ledger().sequence();
452+
let grace = storage::get_grace_period_ledgers(env);
453+
454+
if !ledger::is_in_renewal_window_with_grace(
455+
now,
456+
policy.end_ledger,
457+
ledger::RENEWAL_WINDOW_LEDGERS,
458+
grace,
459+
) {
460+
return Err(RenewalError::WindowClosed);
461+
}
462+
463+
// Recalculate premium with the same deterministic formula.
464+
let input = RiskInput {
465+
region: policy.region.clone(),
466+
age_band,
467+
coverage: coverage_type,
468+
safety_score,
469+
};
470+
let quote = crate::calculator::compute_quote(
471+
env,
472+
&input,
473+
base_amount,
474+
false,
475+
ledger::QUOTE_TTL_LEDGERS,
476+
)
477+
.map_err(|_| RenewalError::PremiumError)?;
478+
479+
let premium_amount = quote.total_premium;
480+
if premium_amount <= 0 {
481+
return Err(RenewalError::PremiumError);
482+
}
483+
484+
// Collect premium before any state mutation.
485+
token::collect_premium(env, &holder, &policy.asset, premium_amount);
486+
487+
// New term starts immediately after old end — no gap, no overlap.
488+
let new_start = policy.end_ledger.saturating_add(1);
489+
let new_end = new_start
490+
.checked_add(ledger::POLICY_DURATION_LEDGERS)
491+
.ok_or(RenewalError::LedgerOverflow)?;
492+
493+
policy.start_ledger = new_start;
494+
policy.end_ledger = new_end;
495+
policy.premium = premium_amount;
496+
497+
storage::set_policy(env, &holder, policy_id, &policy);
498+
499+
PolicyRenewed {
500+
version: POLICY_EVENT_VERSION,
501+
policy_id,
502+
holder: holder.clone(),
503+
premium: premium_amount,
504+
new_end_ledger: new_end,
505+
}
506+
.publish(env);
507+
508+
Ok(policy)
509+
}

contracts/niffyinsure/src/storage.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ pub enum DataKey {
5454
AppealVote(u64, Address),
5555
/// Configurable voting window in ledgers (set by admin via set_voting_duration_ledgers).
5656
VoteDurLedgers,
57+
/// Configurable grace period in ledgers after nominal expiry for late renewals.
58+
GracePeriodLedgers,
5759
}
5860

5961
// ── Instance bump ─────────────────────────────────────────────────────────────
@@ -149,6 +151,23 @@ pub fn get_voting_duration_ledgers(env: &Env) -> u32 {
149151
.unwrap_or(ledger::VOTE_WINDOW_LEDGERS)
150152
}
151153

154+
// ── Grace period (instance) ───────────────────────────────────────────────────
155+
156+
pub fn set_grace_period_ledgers(env: &Env, ledgers: u32) {
157+
env.storage()
158+
.instance()
159+
.set(&DataKey::GracePeriodLedgers, &ledgers);
160+
}
161+
162+
/// Grace period added after nominal expiry for late renewals.
163+
/// Defaults to [`ledger::DEFAULT_GRACE_PERIOD_LEDGERS`] when unset.
164+
pub fn get_grace_period_ledgers(env: &Env) -> u32 {
165+
env.storage()
166+
.instance()
167+
.get(&DataKey::GracePeriodLedgers)
168+
.unwrap_or(ledger::DEFAULT_GRACE_PERIOD_LEDGERS)
169+
}
170+
152171
// ── External calculator address ───────────────────────────────────────────────
153172

154173
pub fn set_calc_address(env: &Env, addr: &Address) {

contracts/niffyinsure/src/types.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ pub const SAFETY_SCORE_MAX: u32 = 100;
3434
// Conversion: 1 ledger ≈ 5 s on Stellar Mainnet (Protocol 20+).
3535
// See: https://developers.stellar.org/docs/learn/fundamentals/stellar-consensus-protocol
3636
pub use crate::ledger::{
37-
APPEAL_OPEN_WINDOW_LEDGERS, APPEAL_VOTE_WINDOW_LEDGERS, LEDGERS_PER_DAY, LEDGERS_PER_HOUR,
38-
LEDGERS_PER_MIN, LEDGERS_PER_WEEK, MAX_APPEALS_PER_CLAIM, MAX_VOTING_DURATION_LEDGERS,
37+
APPEAL_OPEN_WINDOW_LEDGERS, APPEAL_VOTE_WINDOW_LEDGERS, DEFAULT_GRACE_PERIOD_LEDGERS,
38+
LEDGERS_PER_DAY, LEDGERS_PER_HOUR, LEDGERS_PER_MIN, LEDGERS_PER_WEEK, MAX_APPEALS_PER_CLAIM,
39+
MAX_GRACE_PERIOD_LEDGERS, MAX_VOTING_DURATION_LEDGERS, MIN_GRACE_PERIOD_LEDGERS,
3940
MIN_VOTING_DURATION_LEDGERS, POLICY_DURATION_LEDGERS, QUOTE_TTL_LEDGERS,
4041
RATE_LIMIT_WINDOW_LEDGERS, RENEWAL_WINDOW_LEDGERS, SECS_PER_LEDGER, VOTE_WINDOW_LEDGERS,
4142
};

0 commit comments

Comments
 (0)