Skip to content
Open
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
1,022 changes: 627 additions & 395 deletions src/lib.rs

Large diffs are not rendered by default.

128 changes: 128 additions & 0 deletions src/tax_bucket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,139 @@ pub struct TaxBucketResult {
pub capital_gains: i128,
}

/// Per-holder, per-fiscal-year accumulated tax summary.
///
/// Returned by `get_holder_tax_year`. Accumulated on every `rollover_distribution`
/// call by incrementing the active fiscal year's entry in persistent storage.
///
/// Fields match the tax-bucket breakdown expected by integrators:
/// - `ordinary_income`: Ordinary taxable income (dividends, interest, etc.)
/// - `capital_gains`: Capital gains (profit from sale of securities)
/// - `return_of_capital`: Return of capital (non-taxable distribution)
///
/// Currently the system only populates `return_of_capital` and `capital_gains`.
/// The `ordinary_income` field is reserved for future tax-bucket expansion.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct TaxYearSummary {
/// Ordinary taxable income (dividends, interest, etc.).
pub ordinary_income: i128,
/// Total capital gains (taxable) for this fiscal year.
pub capital_gains: i128,
/// Total return of capital (non-taxable) for this fiscal year.
pub return_of_capital: i128,
}

// ── Timestamp helpers ────────────────────────────────────────────────────────
//
// These convert a Unix timestamp (seconds since epoch) into calendar year and
// month, then compute the fiscal year given the offering's configured fiscal
// start month. The algorithms are adapted from common calendar routines and
// use no external date libraries, keeping the contract `#![no_std]`.

const SECS_PER_DAY: u64 = 86_400;

/// Returns `true` if `year` is a Gregorian leap year.
fn is_leap_year(year: u32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}

/// Days in each month for a given year (0‑indexed: January = 0).
const MONTH_DAYS_NON_LEAP: [u64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const MONTH_DAYS_LEAP: [u64; 12] = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

/// Convert a Unix timestamp (seconds since epoch) to a Gregorian calendar year.
pub fn timestamp_to_year(ts: u64) -> u32 {
let days = ts / SECS_PER_DAY;
let mut year = 1970u32;
let mut remaining = days;
loop {
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
if remaining < days_in_year {
break;
}
remaining -= days_in_year;
year += 1;
}
year
}

/// Convert a Unix timestamp (seconds since epoch) to a Gregorian calendar month
/// (1‑based: January = 1, February = 2, …).
pub fn timestamp_to_month(ts: u64) -> u32 {
let days = ts / SECS_PER_DAY;
let mut year = 1970u32;
let mut remaining = days;
loop {
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
if remaining < days_in_year {
break;
}
remaining -= days_in_year;
year += 1;
}
let month_table = if is_leap_year(year) { MONTH_DAYS_LEAP } else { MONTH_DAYS_NON_LEAP };
let mut month: u32 = 0;
for &md in month_table.iter() {
if remaining < md {
break;
}
remaining -= md;
month += 1;
}
// month is 0‑indexed; return 1‑based
month + 1
}

/// Compute the fiscal year that contains `ts`, given the fiscal year start
/// month (1‑12) configured for the offering.
///
/// For example, if the fiscal year starts in April (`fiscal_start_month = 4`):
/// - Timestamps in Apr 2024 – Mar 2025 → fiscal year 2024.
/// - Timestamps in Apr 2023 – Mar 2024 → fiscal year 2023.
pub fn fiscal_year_from_ts(ts: u64, fiscal_start_month: u32) -> u64 {
let year = timestamp_to_year(ts);
let month = timestamp_to_month(ts);
if month < fiscal_start_month {
(year - 1) as u64
} else {
year as u64
}
}

/// Default fiscal year start month (January = 1).
pub const DEFAULT_FISCAL_START_MONTH: u32 = 1;

pub fn track_cost_basis(env: &Env, offering_id: &OfferingId, holder: &Address, cost_basis: i128) {
let key = DataKey2::RemainingBasis(offering_id.clone(), holder.clone());
env.storage().persistent().set(&key, &cost_basis);
}

/// Update the tax-year accumulator for a holder's distribution.
///
/// Called from `rollover_distribution` (and from `claim`) to increment the
/// per-holder, per-fiscal-year `TaxYearSummary` entry in persistent storage.
pub fn update_tax_year_accumulator(
env: &Env,
offering_id: &OfferingId,
holder: &Address,
fiscal_year: u64,
ordinary_income: i128,
capital_gains: i128,
return_of_capital: i128,
) {
let year_key = DataKey2::TaxYearEntry(offering_id.clone(), holder.clone(), fiscal_year);
let mut summary: TaxYearSummary = env.storage().persistent().get(&year_key).unwrap_or(TaxYearSummary {
ordinary_income: 0,
capital_gains: 0,
return_of_capital: 0,
});
summary.ordinary_income = summary.ordinary_income.saturating_add(ordinary_income);
summary.capital_gains = summary.capital_gains.saturating_add(capital_gains);
summary.return_of_capital = summary.return_of_capital.saturating_add(return_of_capital);
env.storage().persistent().set(&year_key, &summary);
}

pub fn rollover_distribution(
env: &Env,
offering_id: &OfferingId,
Expand Down
144 changes: 144 additions & 0 deletions src/test_accrual_ledger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,147 @@ fn checkpoint_compression_threshold_not_set_uses_default() {

assert_eq!(client.get_checkpoint_threshold(&issuer, &symbol_short!("def"), &token), 1_000);
}

// ── get_holder_accrued_unclaimed tests ──────────────────────────────────────

#[test]
fn accrued_unclaimed_returns_zero_for_blacklisted_holder() {
let (_env, client, issuer, token, payout_asset) = setup_offering();
let holder = Address::generate(&_env);

client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &5_000);
client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &1);

client.blacklist_add(&issuer, &symbol_short!("def"), &token, &holder);

let accrued = client.get_holder_accrued_unclaimed(
&issuer, &symbol_short!("def"), &token, &holder,
);
assert_eq!(accrued, 0, "blacklisted holder should get 0");
}

#[test]
fn accrued_unclaimed_matches_claimable_for_single_period() {
let (_env, client, issuer, token, payout_asset) = setup_offering();
let holder = Address::generate(&_env);

client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &5_000);
client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &1);

let accrued = client.get_holder_accrued_unclaimed(
&issuer, &symbol_short!("def"), &token, &holder,
);
let claimable = client.get_claimable(&issuer, &symbol_short!("def"), &token, &holder);
assert_eq!(accrued, claimable, "accrued should match claimable for single period");
assert_eq!(accrued, 50_000, "50% of 100_000");
}

#[test]
fn accrued_unclaimed_matches_claimable_after_share_change() {
let (_env, client, issuer, token, payout_asset) = setup_offering();
let holder = Address::generate(&_env);

client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &5_000);
client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &1);
client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &2_500);
client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &2);

let accrued = client.get_holder_accrued_unclaimed(
&issuer, &symbol_short!("def"), &token, &holder,
);
let claimable = client.get_claimable(&issuer, &symbol_short!("def"), &token, &holder);
assert_eq!(accrued, claimable, "accrued should match claimable after share change");
assert_eq!(accrued, 75_000, "50_000 + 25_000");
}

#[test]
fn accrued_unclaimed_returns_zero_for_holder_with_no_share() {
let (_env, client, issuer, token, payout_asset) = setup_offering();
let holder = Address::generate(&_env);

client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &1);

let accrued = client.get_holder_accrued_unclaimed(
&issuer, &symbol_short!("def"), &token, &holder,
);
assert_eq!(accrued, 0, "holder without share should get 0");
}

#[test]
fn accrued_unclaimed_respects_partial_claim() {
let (_env, client, issuer, token, payout_asset) = setup_offering();
let holder = Address::generate(&_env);

client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &5_000);
client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &1);
client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &2);

// Claim only first period
let payout = client.claim(&holder, &issuer, &symbol_short!("def"), &token, &1);
assert_eq!(payout, 50_000, "first period claimed: 50k");

// Accrued unclaimed should now be only the second period
let accrued = client.get_holder_accrued_unclaimed(
&issuer, &symbol_short!("def"), &token, &holder,
);
assert_eq!(accrued, 50_000, "remaining unclaimed: second period 50k");

// Match get_claimable
let claimable = client.get_claimable(&issuer, &symbol_short!("def"), &token, &holder);
assert_eq!(accrued, claimable);
}

#[test]
fn accrued_unclaimed_after_full_claim_returns_zero() {
let (_env, client, issuer, token, payout_asset) = setup_offering();
let holder = Address::generate(&_env);

client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &5_000);
client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &1);

client.claim(&holder, &issuer, &symbol_short!("def"), &token, &0);

let accrued = client.get_holder_accrued_unclaimed(
&issuer, &symbol_short!("def"), &token, &holder,
);
assert_eq!(accrued, 0, "after full claim, accrued should be 0");
}

#[test]
fn accrued_unclaimed_zero_share_does_not_erase_past_accrual() {
let (_env, client, issuer, token, payout_asset) = setup_offering();
let holder = Address::generate(&_env);

client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &5_000);
client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &1);
client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &0);

let accrued = client.get_holder_accrued_unclaimed(
&issuer, &symbol_short!("def"), &token, &holder,
);
assert_eq!(accrued, 50_000, "accrual from when holder had 50%% share persists");
}

#[test]
fn accrued_unclaimed_matches_claimable_after_partial_claim_with_share_change() {
let (_env, client, issuer, token, payout_asset) = setup_offering();
let holder = Address::generate(&_env);

client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &5_000);
client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &1);
client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &2_500);
client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &2);
client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &3);

// Claim first two periods
let payout = client.claim(&holder, &issuer, &symbol_short!("def"), &token, &2);
assert_eq!(payout, 75_000, "period 1 (50k) + period 2 (25k)");

let accrued = client.get_holder_accrued_unclaimed(
&issuer, &symbol_short!("def"), &token, &holder,
);
assert_eq!(accrued, 25_000, "remaining: period 3 at 25%% = 25k");

let claimable = client.get_claimable(&issuer, &symbol_short!("def"), &token, &holder);
assert_eq!(accrued, claimable);
}
Loading
Loading