Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions bill_payments/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,15 +379,16 @@ impl BillPayments {
/// Get bill IDs for a specific owner and currency
fn get_bills_by_owner_currency(env: &Env, owner: &Address, currency: &String) -> Vec<u32> {
let idx = Self::get_currency_index(env);
idx.get((owner.clone(), currency.clone())).unwrap_or_else(|| Vec::new(env))
idx.get((owner.clone(), currency.clone()))
.unwrap_or_else(|| Vec::new(env))
}

/// Add a bill ID to the currency index for (owner, currency)
fn index_add_currency(env: &Env, owner: &Address, currency: &String, bill_id: u32) {
let mut idx = Self::get_currency_index(env);
let key = (owner.clone(), currency.clone());
let mut ids = idx.get(key.clone()).unwrap_or_else(|| Vec::new(env));

// Insert in ascending order
let mut new_ids: Vec<u32> = Vec::new(env);
let mut inserted = false;
Expand All @@ -405,7 +406,7 @@ impl BillPayments {
if !inserted {
new_ids.push_back(bill_id);
}

idx.set(key, new_ids);
Self::save_currency_index(env, &idx);
}
Expand All @@ -431,7 +432,12 @@ impl BillPayments {
}

/// Remove multiple bill IDs from the currency index for (owner, currency)
fn index_remove_currency_batch(env: &Env, owner: &Address, currency: &String, bill_ids: &Vec<u32>) {
fn index_remove_currency_batch(
env: &Env,
owner: &Address,
currency: &String,
bill_ids: &Vec<u32>,
) {
let mut idx = Self::get_currency_index(env);
let key = (owner.clone(), currency.clone());
if let Some(ids) = idx.get(key.clone()) {
Expand Down
20 changes: 13 additions & 7 deletions bill_payments/tests/tests_overdue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,10 +235,7 @@ fn test_overdue_empty_when_all_bills_paid() {
client.pay_bill(&owner, &id2);

let page = client.get_overdue_bills(&0, &100);
assert_eq!(
page.count, 0,
"all bills paid: overdue list must be empty"
);
assert_eq!(page.count, 0, "all bills paid: overdue list must be empty");
}

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -374,7 +371,10 @@ fn test_overdue_owner_isolation_no_cross_contamination() {
set_time(&env, BASE_TIME);

let page = client.get_overdue_bills(&0, &100);
assert_eq!(page.count, 3, "all 3 overdue bills must appear in global list");
assert_eq!(
page.count, 3,
"all 3 overdue bills must appear in global list"
);

let mut a_count = 0u32;
let mut b_count = 0u32;
Expand All @@ -395,8 +395,14 @@ fn test_overdue_owner_isolation_no_cross_contamination() {
panic!("unexpected owner in overdue list");
}
}
assert_eq!(a_count, 2, "owner A must have 2 overdue bills in global list");
assert_eq!(b_count, 1, "owner B must have 1 overdue bill in global list");
assert_eq!(
a_count, 2,
"owner A must have 2 overdue bills in global list"
);
assert_eq!(
b_count, 1,
"owner B must have 1 overdue bill in global list"
);
}

/// Paying one owner's overdue bill does not affect the other owner's overdue count.
Expand Down
63 changes: 52 additions & 11 deletions data_migration/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2211,7 +2211,11 @@ mod tests {
let result = import_from_json(&bytes, &mut tracker, 123_456);
assert!(matches!(
result.unwrap_err(),
MigrationError::IncompatibleVersion { found: 0, min: 1, max: 1 }
MigrationError::IncompatibleVersion {
found: 0,
min: 1,
max: 1
}
));
}

Expand All @@ -2224,7 +2228,11 @@ mod tests {
let result = import_from_json(&bytes, &mut tracker, 123_456);
assert!(matches!(
result.unwrap_err(),
MigrationError::IncompatibleVersion { found: 2, min: 1, max: 1 }
MigrationError::IncompatibleVersion {
found: 2,
min: 1,
max: 1
}
));
}

Expand All @@ -2247,7 +2255,11 @@ mod tests {
let result = import_from_binary(&bytes, &mut tracker, 123_456);
assert!(matches!(
result.unwrap_err(),
MigrationError::IncompatibleVersion { found: 0, min: 1, max: 1 }
MigrationError::IncompatibleVersion {
found: 0,
min: 1,
max: 1
}
));
}

Expand All @@ -2260,7 +2272,11 @@ mod tests {
let result = import_from_binary(&bytes, &mut tracker, 123_456);
assert!(matches!(
result.unwrap_err(),
MigrationError::IncompatibleVersion { found: 2, min: 1, max: 1 }
MigrationError::IncompatibleVersion {
found: 2,
min: 1,
max: 1
}
));
}

Expand All @@ -2282,7 +2298,11 @@ mod tests {
let result = import_from_json_untracked(&bytes);
assert!(matches!(
result.unwrap_err(),
MigrationError::IncompatibleVersion { found: 0, min: 1, max: 1 }
MigrationError::IncompatibleVersion {
found: 0,
min: 1,
max: 1
}
));
}

Expand All @@ -2294,7 +2314,11 @@ mod tests {
let result = import_from_json_untracked(&bytes);
assert!(matches!(
result.unwrap_err(),
MigrationError::IncompatibleVersion { found: 2, min: 1, max: 1 }
MigrationError::IncompatibleVersion {
found: 2,
min: 1,
max: 1
}
));
}

Expand All @@ -2315,7 +2339,11 @@ mod tests {
let result = import_from_binary_untracked(&bytes);
assert!(matches!(
result.unwrap_err(),
MigrationError::IncompatibleVersion { found: 0, min: 1, max: 1 }
MigrationError::IncompatibleVersion {
found: 0,
min: 1,
max: 1
}
));
}

Expand All @@ -2327,7 +2355,11 @@ mod tests {
let result = import_from_binary_untracked(&bytes);
assert!(matches!(
result.unwrap_err(),
MigrationError::IncompatibleVersion { found: 2, min: 1, max: 1 }
MigrationError::IncompatibleVersion {
found: 2,
min: 1,
max: 1
}
));
}

Expand Down Expand Up @@ -2566,7 +2598,10 @@ mod tests {
let csv_string = String::from_utf8_lossy(&exported_bytes);

// Tab is not a formula injection character, so it should not be escaped
assert!(csv_string.contains("\tSUM(A1:A10)"), "Tab should not be escaped");
assert!(
csv_string.contains("\tSUM(A1:A10)"),
"Tab should not be escaped"
);
}

#[test]
Expand All @@ -2588,7 +2623,10 @@ mod tests {
let csv_string = String::from_utf8_lossy(&exported_bytes);

// Backslash is not a formula injection character, so it should not be escaped
assert!(csv_string.contains("\\SUM(A1:A10)"), "Backslash should not be escaped");
assert!(
csv_string.contains("\\SUM(A1:A10)"),
"Backslash should not be escaped"
);
}

#[test]
Expand All @@ -2610,7 +2648,10 @@ mod tests {
let csv_string = String::from_utf8_lossy(&exported_bytes);

// Pipe is not a formula injection character, so it should not be escaped
assert!(csv_string.contains("|SUM(A1:A10)"), "Pipe should not be escaped");
assert!(
csv_string.contains("|SUM(A1:A10)"),
"Pipe should not be escaped"
);
}

#[test]
Expand Down
46 changes: 46 additions & 0 deletions docs/insurance-bootstrap-guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Insurance Bootstrap Guards

This note documents the bootstrap safety contract for `insurance`.

## Initialization

`Insurance::init(owner)` is single-shot.

- First call: stores `Initialized = true`, records `Owner = owner`, resets
`PolicyCount`, and creates an empty active-policy index.
- Later calls: return `InsuranceError::AlreadyInitialized`.

## Current authorization model

`init` does not currently call `require_auth`.

That means bootstrap safety relies on:

- deploying and initializing in a trusted flow
- the `AlreadyInitialized` guard preventing later ownership takeover

Once initialization succeeds, the stored owner becomes the only contract-level
admin for owner-only operations such as `set_external_ref`.

## Pre-init behavior

Before initialization:

- Mutators return `InsuranceError::NotInitialized`:
- `create_policy`
- `pay_premium`
- `batch_pay_premiums`
- `deactivate_policy`
- `set_external_ref`
- Read paths are deterministic and non-panicking:
- `get_policy` returns `Ok(None)`
- `get_active_policies` returns an empty page
- `get_total_monthly_premium` returns `Ok(0)`

## Post-init privileges

After initialization:

- policy owners may pay and deactivate their own policies
- the stored contract owner may perform owner-only admin updates
- a different address cannot re-run `init` or assume owner-only privileges
56 changes: 56 additions & 0 deletions docs/reporting-family-spending.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Family Spending Report

`ReportingContract::get_family_spending_report(caller, user, period_start, period_end)`
builds a family-wallet spending snapshot from the configured `family_wallet`
dependency.

## Authorization

- `user.require_auth()` is enforced, matching the other user-facing reporting
endpoints.
- `caller` is currently unused and kept for signature consistency with the
savings, bills, insurance, and financial-health report methods.

## Data source

The report reads two family-wallet views:

1. `get_member_addresses_page(cursor, limit)` to enumerate the member set
without fixed-limit truncation.
2. `get_spending_tracker(member)` to read each member's current cumulative
spending amount.

## Output semantics

`FamilySpendingReport` now includes:

- `member_breakdown`: one entry per unique member address.
- `total_members`: number of unique members observed from the dependency.
- `total_spending`: sum of successfully read member spending totals.
- `average_per_member`: `total_spending / total_members`, or `0` when there are
no members.
- `data_availability`: report completeness signal.

Each `FamilyMemberSpending` entry contains:

- `member`: member address.
- `total_spending`: tracked spending amount, or `0` when no tracker exists or
the per-member read failed.
- `data_available`: `false` when that member's spending read failed.

## DataAvailability rules

- `Complete`: member enumeration succeeded and every member spending read
succeeded.
- `Partial`: pagination hit `MAX_DEP_PAGES`, a later member page failed, a
per-member spending read failed, or aggregate addition overflowed and had to
clamp with saturating arithmetic.
- `Missing`: the first member-page read failed or the dependency returned zero
members on the first page.

## Arithmetic safety

- Aggregate `i128` totals use `checked_add`.
- On overflow, the report does not panic. It marks
`data_availability = Partial` and clamps the aggregate with
`saturating_add`.
Loading
Loading