Skip to content

Commit 7fac45b

Browse files
authored
Merge pull request #248 from favourawaku/feat/policy-expired-event
Feat/policy expired event
2 parents 9dd505f + f97919e commit 7fac45b

5 files changed

Lines changed: 540 additions & 1 deletion

File tree

contracts/niffyinsure/src/lib.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,48 @@ impl NiffyInsure {
472472
storage::get_active_policy_count(&env, &holder)
473473
}
474474

475+
/// If set, the `end_ledger` for which a [`policy::PolicyExpired`] event was already recorded
476+
/// (one row per policy). Indexers may use this with `get_policy` for idempotency checks.
477+
/// Name is shortened to satisfy the 32-char Soroban export limit.
478+
pub fn get_pol_exp_evt_end_ledger(
479+
env: Env,
480+
holder: Address,
481+
policy_id: u32,
482+
) -> Option<u32> {
483+
storage::get_policy_expired_event_end_ledger(&env, &holder, policy_id)
484+
}
485+
486+
/// Keeper hook: when `ledger_sequence >= policy.end_ledger`, emit [`policy::PolicyExpired`]
487+
/// once per policy term (see `policy` module docs for notification delay). Reverts if the
488+
/// policy does not exist or is not yet expired.
489+
pub fn process_expired(
490+
env: Env,
491+
holder: Address,
492+
policy_id: u32,
493+
) -> Result<(), policy::PolicyError> {
494+
policy::process_expired(&env, holder, policy_id)
495+
}
496+
497+
/// Renew before `end_ledger` (renewal window). If already expired, emits [`policy::PolicyExpired`]
498+
/// when due and returns [`types::RenewPolicyOutcome::Lapsed`] in **`Ok`** (see type docs).
499+
pub fn renew_policy(
500+
env: Env,
501+
holder: Address,
502+
policy_id: u32,
503+
age_band: types::AgeBand,
504+
coverage_type: types::CoverageType,
505+
safety_score: u32,
506+
) -> Result<types::RenewPolicyOutcome, policy::PolicyError> {
507+
policy::renew_policy(
508+
&env,
509+
holder,
510+
policy_id,
511+
age_band,
512+
coverage_type,
513+
safety_score,
514+
)
515+
}
516+
475517
pub fn terminate_policy(
476518
env: Env,
477519
holder: Address,

contracts/niffyinsure/src/policy.rs

Lines changed: 172 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ pub enum PolicyError {
3939
NotFound = 111,
4040
/// Policy is already active.
4141
AlreadyActive = 112,
42+
/// Keeper `process_expired`: policy is not yet at `end_ledger`.
43+
NotYetExpired = 113,
44+
/// `renew_policy` called before the renewal window opens.
45+
NotInRenewalWindow = 114,
46+
/// `renew_policy`: policy is inactive (terminated or deactivated).
47+
PolicyInactive = 115,
48+
/// `renew_policy`: an open claim exists for this policy.
49+
OpenClaimBlocksRenewal = 116,
50+
/// `renew_policy`: strike count blocks renewal (see `STRIKE_DEACTIVATION_THRESHOLD`).
51+
TooManyStrikesForRenewal = 117,
52+
/// Reserved / legacy: expired renewals now return [`crate::types::RenewPolicyOutcome::Lapsed`] `Ok`.
53+
Expired = 118,
4254
}
4355

4456
#[contracttype]
@@ -80,7 +92,6 @@ pub struct BeneficiaryUpdated {
8092
/// Event emitted by `renew_policy`.
8193
#[contractevent]
8294
#[derive(Clone, Debug)]
83-
#[allow(dead_code)]
8495
pub struct PolicyRenewed {
8596
#[topic]
8697
pub holder: Address,
@@ -90,6 +101,26 @@ pub struct PolicyRenewed {
90101
pub new_end_ledger: u32,
91102
}
92103

104+
/// Emitted at most once per `(holder, policy_id, end_ledger)` term when expiry is detected.
105+
///
106+
/// **Timing:** `reported_at_ledger` is the ledger of the transaction that observes expiry.
107+
/// It may be **strictly greater** than `expiry_ledger` if no call ran exactly at expiry
108+
/// (keeper delay is normal). Indexers and notification services should **deduplicate on
109+
/// `policy_id`** (and holder) and must not assume the event fires on the expiry ledger itself.
110+
///
111+
/// **`renew_policy` on an expired policy:** the call returns [`crate::types::RenewPolicyOutcome::Lapsed`]
112+
/// in **`Ok`** (not `Err`) so this event and idempotency storage are not rolled back.
113+
#[contractevent(topics = ["niffyinsure", "policy_expired"])]
114+
#[derive(Clone, Debug, Eq, PartialEq)]
115+
pub struct PolicyExpired {
116+
#[topic]
117+
pub holder: Address,
118+
#[topic]
119+
pub policy_id: u32,
120+
pub expiry_ledger: u32,
121+
pub reported_at_ledger: u32,
122+
}
123+
93124
pub fn generate_premium(
94125
env: &Env,
95126
region: RegionTier,
@@ -507,3 +538,143 @@ pub fn renew_policy(
507538

508539
Ok(policy)
509540
}
541+
542+
/// Emit [`PolicyExpired`] if `now >= end_ledger` and we have not yet recorded an event for this term.
543+
pub fn publish_policy_expired_if_due(env: &Env, policy: &Policy, now: u32) {
544+
if !ledger::is_expired(now, policy.end_ledger) {
545+
return;
546+
}
547+
if storage::get_policy_expired_event_end_ledger(env, &policy.holder, policy.policy_id)
548+
== Some(policy.end_ledger)
549+
{
550+
return;
551+
}
552+
PolicyExpired {
553+
holder: policy.holder.clone(),
554+
policy_id: policy.policy_id,
555+
expiry_ledger: policy.end_ledger,
556+
reported_at_ledger: now,
557+
}
558+
.publish(env);
559+
storage::set_policy_expired_event_end_ledger(
560+
env,
561+
&policy.holder,
562+
policy.policy_id,
563+
policy.end_ledger,
564+
);
565+
storage::bump_instance(env);
566+
}
567+
568+
/// Keeper entrypoint: observe expiry for indexers / notification pipelines.
569+
///
570+
/// Reverts with [`PolicyError::NotYetExpired`] if `now < end_ledger`. If expiry was already
571+
/// notified for this policy term, succeeds without emitting a duplicate event.
572+
pub fn process_expired(env: &Env, holder: Address, policy_id: u32) -> Result<(), PolicyError> {
573+
storage::bump_instance(env);
574+
let policy = storage::get_policy(env, &holder, policy_id).ok_or(PolicyError::NotFound)?;
575+
let now = env.ledger().sequence();
576+
if !ledger::is_expired(now, policy.end_ledger) {
577+
return Err(PolicyError::NotYetExpired);
578+
}
579+
publish_policy_expired_if_due(env, &policy, now);
580+
Ok(())
581+
}
582+
583+
/// Extend policy duration after premium payment (renewal window only).
584+
///
585+
/// If the policy is already expired, records [`PolicyExpired`] if not yet recorded for this
586+
/// term, then returns [`crate::types::RenewPolicyOutcome::Lapsed`] in **`Ok`** so storage and
587+
/// events persist (an `Err` would roll back the contract invocation).
588+
#[allow(clippy::too_many_arguments)]
589+
pub fn renew_policy(
590+
env: &Env,
591+
holder: Address,
592+
policy_id: u32,
593+
age_band: AgeBand,
594+
coverage_type: CoverageType,
595+
safety_score: u32,
596+
) -> Result<crate::types::RenewPolicyOutcome, PolicyError> {
597+
storage::assert_bind_not_paused(env);
598+
holder.require_auth();
599+
600+
let mut policy = storage::get_policy(env, &holder, policy_id).ok_or(PolicyError::NotFound)?;
601+
let now = env.ledger().sequence();
602+
603+
if ledger::is_expired(now, policy.end_ledger) {
604+
publish_policy_expired_if_due(env, &policy, now);
605+
return Ok(crate::types::RenewPolicyOutcome::Lapsed);
606+
}
607+
608+
if !policy.is_active {
609+
return Err(PolicyError::PolicyInactive);
610+
}
611+
612+
if storage::has_open_claim(env, &holder, policy_id) {
613+
return Err(PolicyError::OpenClaimBlocksRenewal);
614+
}
615+
616+
if policy.strike_count >= STRIKE_DEACTIVATION_THRESHOLD {
617+
return Err(PolicyError::TooManyStrikesForRenewal);
618+
}
619+
620+
if !ledger::is_in_renewal_window(
621+
now,
622+
policy.end_ledger,
623+
ledger::RENEWAL_WINDOW_LEDGERS,
624+
) {
625+
return Err(PolicyError::NotInRenewalWindow);
626+
}
627+
628+
if safety_score > 100 {
629+
return Err(PolicyError::InvalidRiskScore);
630+
}
631+
632+
if !storage::is_allowed_asset(env, &policy.asset) {
633+
return Err(PolicyError::AssetNotAllowed);
634+
}
635+
636+
let input = RiskInput {
637+
region: policy.region.clone(),
638+
age_band: age_band.clone(),
639+
coverage: coverage_type,
640+
safety_score,
641+
};
642+
643+
let quote =
644+
crate::calculator::compute_quote(env, &input, policy.coverage, false, QUOTE_TTL_LEDGERS)
645+
.map_err(|e| match e {
646+
Error::CalculatorPaused => PolicyError::ContractPaused,
647+
Error::CalculatorCallFailed | Error::CalculatorNotSet => PolicyError::PremiumOverflow,
648+
_ => PolicyError::PremiumOverflow,
649+
})?;
650+
651+
let premium_amount = quote.total_premium;
652+
if premium_amount <= 0 {
653+
return Err(PolicyError::InvalidPremium);
654+
}
655+
656+
token::collect_premium(env, &holder, &policy.asset, premium_amount);
657+
658+
let new_end = policy
659+
.end_ledger
660+
.checked_add(ledger::POLICY_DURATION_LEDGERS)
661+
.ok_or(PolicyError::LedgerOverflow)?;
662+
663+
policy.premium = premium_amount;
664+
policy.end_ledger = new_end;
665+
666+
validate::check_policy(&policy).map_err(|_| PolicyError::PolicyValidation)?;
667+
668+
storage::set_policy(env, &holder, policy_id, &policy);
669+
670+
PolicyRenewed {
671+
version: POLICY_EVENT_VERSION,
672+
holder: holder.clone(),
673+
policy_id,
674+
premium: premium_amount,
675+
new_end_ledger: new_end,
676+
}
677+
.publish(env);
678+
679+
Ok(crate::types::RenewPolicyOutcome::Renewed(policy))
680+
}

contracts/niffyinsure/src/storage.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,3 +590,29 @@ pub fn set_rolling_claim_state(
590590
.persistent()
591591
.extend_ttl(&key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);
592592
}
593+
594+
// ── Policy expiry notification (instance) ─────────────────────────────────────
595+
596+
/// Last `end_ledger` for which `PolicyExpired` was emitted for this policy term.
597+
pub fn get_policy_expired_event_end_ledger(
598+
env: &Env,
599+
holder: &Address,
600+
policy_id: u32,
601+
) -> Option<u32> {
602+
env.storage().instance().get(&DataKey::PolicyExpiredEventEndLedger(
603+
holder.clone(),
604+
policy_id,
605+
))
606+
}
607+
608+
pub fn set_policy_expired_event_end_ledger(
609+
env: &Env,
610+
holder: &Address,
611+
policy_id: u32,
612+
end_ledger: u32,
613+
) {
614+
env.storage().instance().set(
615+
&DataKey::PolicyExpiredEventEndLedger(holder.clone(), policy_id),
616+
&end_ledger,
617+
);
618+
}

contracts/niffyinsure/src/types.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,19 @@ pub struct Policy {
346346
pub strike_count: u32,
347347
}
348348

349+
/// Return value of [`crate::policy::renew_policy`].
350+
///
351+
/// When the policy is already at or past `end_ledger`, the call **succeeds** with [`Lapsed`](Self::Lapsed)
352+
/// so that [`crate::policy::PolicyExpired`] and idempotency storage are committed (a failed `Result::Err`
353+
/// invocation would roll those writes back).
354+
#[contracttype]
355+
#[derive(Clone)]
356+
pub enum RenewPolicyOutcome {
357+
Renewed(Policy),
358+
/// Ledger is at or after `end_ledger`; expiry notice recorded if due; no premium taken.
359+
Lapsed,
360+
}
361+
349362
/// On-chain claim record.
350363
///
351364
/// `filed_at` is the ledger sequence at which the claim was filed.

0 commit comments

Comments
 (0)