use crate::hashing::settlement_idem_key;
use crate::types::{
AuditReasonCode, AuditStep, AuditStepKind, AuditStepStatus, MeteringSummary,
SettlementAuditReport, SettlementLine, SettlementSummary, SDK_VERSION,
};
use std::collections::HashMap;
const SHARE_PLACES: u32 = 6;
pub fn verify_settlement_reconciliation(
metering: &MeteringSummary,
settlement: &SettlementSummary,
) -> SettlementAuditReport {
let verified_at = chrono::Utc::now().to_rfc3339();
let mut steps: Vec<AuditStep> = Vec::new();
if settlement.org_id != metering.org_id {
steps.push(AuditStep {
target: "settlement.orgId".to_string(),
kind: AuditStepKind::SettlementLine,
status: AuditStepStatus::Invalid,
reason: Some(AuditReasonCode::SettlementOrgMismatch),
message: format!(
"settlement.orgId {:?} does not match metering.orgId {:?}",
settlement.org_id, metering.org_id
),
detail: None,
});
} else {
steps.push(valid_step(
"settlement.orgId",
AuditStepKind::SettlementLine,
"settlement orgId matches metering",
));
}
let meter_by_idem: HashMap<&str, &crate::types::MeterRecord> =
metering.records.iter().map(|r| (r.idem_key.as_str(), r)).collect();
let mut lines_by_meter: HashMap<&str, Vec<&SettlementLine>> = HashMap::new();
for line in settlement.lines.iter() {
lines_by_meter
.entry(line.meter_record_idem_key.as_str())
.or_default()
.push(line);
}
let mut computed_gross: i64 = 0;
let mut computed_net_to_tenant: i64 = 0;
let mut computed_platform_fee: i64 = 0;
for (i, line) in settlement.lines.iter().enumerate() {
let _meter = match meter_by_idem.get(line.meter_record_idem_key.as_str()) {
Some(m) => *m,
None => {
steps.push(AuditStep {
target: format!("settlement.lines[{i}].meterRecordIdemKey"),
kind: AuditStepKind::SettlementLine,
status: AuditStepStatus::Invalid,
reason: Some(AuditReasonCode::SettlementLineForUnknownMeter),
message: format!(
"settlement line references meterRecordIdemKey {:?} not in metering",
line.meter_record_idem_key
),
detail: None,
});
continue;
}
};
let expected_idem =
settlement_idem_key(&line.meter_record_idem_key, line.party_role.as_wire_str());
if line.idem_key != expected_idem {
steps.push(AuditStep {
target: format!("settlement.lines[{i}].idemKey"),
kind: AuditStepKind::SettlementLine,
status: AuditStepStatus::Invalid,
reason: Some(AuditReasonCode::SettlementIdemKeyMismatch),
message: "settlement-line idemKey does not equal sha256(meterIdemKey|partyRole)"
.to_string(),
detail: Some(serde_json::json!({
"expected": expected_idem,
"actual": line.idem_key,
})),
});
} else {
steps.push(valid_step(
&format!("settlement.lines[{i}].idemKey"),
AuditStepKind::SettlementLine,
"settlement idemKey matches reconstruction",
));
}
let gross = match settlement.meter_gross.get(&line.meter_record_idem_key) {
Some(g) => *g,
None => {
steps.push(AuditStep {
target: format!(
"settlement.meterGross.{}",
line.meter_record_idem_key
),
kind: AuditStepKind::SettlementLine,
status: AuditStepStatus::Invalid,
reason: Some(AuditReasonCode::SettlementLineForUnknownMeter),
message: format!(
"no gross amount for meterIdemKey {:?}",
line.meter_record_idem_key
),
detail: None,
});
continue;
}
};
let share_scaled = match parse_decimal_to_scaled(&line.share, SHARE_PLACES) {
Ok(v) => v,
Err(_) => {
steps.push(AuditStep {
target: format!("settlement.lines[{i}].share"),
kind: AuditStepKind::SettlementLine,
status: AuditStepStatus::Invalid,
reason: Some(AuditReasonCode::SettlementAmountMismatch),
message: "share value is not a valid decimal".to_string(),
detail: None,
});
continue;
}
};
let expected: i128 =
(gross as i128 * share_scaled) / (10_i128.pow(SHARE_PLACES));
if expected != line.amount_cents as i128 {
let group = lines_by_meter
.get(line.meter_record_idem_key.as_str())
.cloned()
.unwrap_or_default();
let is_largest = group.iter().all(|g| {
parse_decimal_to_scaled(&g.share, SHARE_PLACES).unwrap_or(0)
<= share_scaled
});
let drift = (expected - line.amount_cents as i128).unsigned_abs();
if !is_largest || drift > group.len() as u128 {
steps.push(AuditStep {
target: format!("settlement.lines[{i}].amountCents"),
kind: AuditStepKind::SettlementLine,
status: AuditStepStatus::Invalid,
reason: Some(AuditReasonCode::SettlementAmountMismatch),
message:
"amountCents does not match floor(grossCents * share) within rounding tolerance"
.to_string(),
detail: Some(serde_json::json!({
"expected": expected,
"actual": line.amount_cents,
"gross": gross,
"share": line.share,
})),
});
continue;
}
}
steps.push(valid_step(
&format!("settlement.lines[{i}].amountCents"),
AuditStepKind::SettlementLine,
&format!(
"amountCents={} matches gross={} * share={}",
line.amount_cents, gross, line.share
),
));
computed_gross += line.amount_cents;
match line.party_role {
crate::types::SettlementPartyRole::Tenant => {
computed_net_to_tenant += line.amount_cents
}
crate::types::SettlementPartyRole::Platform => {
computed_platform_fee += line.amount_cents
}
_ => {}
}
}
for (meter_idem, group) in lines_by_meter.iter() {
let sum_scaled: i128 = group
.iter()
.map(|l| parse_decimal_to_scaled(&l.share, SHARE_PLACES).unwrap_or(0))
.sum();
if sum_scaled != 10_i128.pow(SHARE_PLACES) {
steps.push(AuditStep {
target: format!("settlement.lines[meter={meter_idem}].share"),
kind: AuditStepKind::SettlementLine,
status: AuditStepStatus::Invalid,
reason: Some(AuditReasonCode::SettlementShareSumNotOne),
message: format!(
"shares for meter {meter_idem:?} sum to {}, not 1.000000",
format_scaled_decimal(sum_scaled, SHARE_PLACES)
),
detail: None,
});
} else {
steps.push(valid_step(
&format!("settlement.lines[meter={meter_idem}].share"),
AuditStepKind::SettlementLine,
&format!("shares for meter {meter_idem:?} sum to 1.000000"),
));
}
}
if let Some(totals) = &settlement.totals {
push_total_check(
&mut steps,
"grossCents",
totals.gross_cents,
computed_gross,
);
push_total_check(
&mut steps,
"netToTenantCents",
totals.net_to_tenant_cents,
computed_net_to_tenant,
);
push_total_check(
&mut steps,
"platformFeeCents",
totals.platform_fee_cents,
computed_platform_fee,
);
}
let any_invalid = steps.iter().any(|s| s.status == AuditStepStatus::Invalid);
SettlementAuditReport {
status: if any_invalid {
AuditStepStatus::Invalid
} else {
AuditStepStatus::Valid
},
verified_at,
sdk_version: SDK_VERSION.to_string(),
meter_record_count: metering.records.len(),
settlement_line_count: settlement.lines.len(),
steps,
}
}
fn push_total_check(steps: &mut Vec<AuditStep>, label: &str, claimed: i64, computed: i64) {
if claimed != computed {
steps.push(AuditStep {
target: format!("settlement.totals.{label}"),
kind: AuditStepKind::SettlementTotal,
status: AuditStepStatus::Invalid,
reason: Some(AuditReasonCode::SettlementTotalMismatch),
message: format!(
"claimed {label}={claimed} does not match recomputed {computed}"
),
detail: None,
});
} else {
steps.push(AuditStep {
target: format!("settlement.totals.{label}"),
kind: AuditStepKind::SettlementTotal,
status: AuditStepStatus::Valid,
reason: None,
message: format!("{label}={claimed} reconciles"),
detail: None,
});
}
}
fn parse_decimal_to_scaled(s: &str, places: u32) -> Result<i128, ()> {
let s = s.trim();
let (sign, body) = if let Some(rest) = s.strip_prefix('-') {
(-1i128, rest)
} else if let Some(rest) = s.strip_prefix('+') {
(1, rest)
} else {
(1, s)
};
let (int_part, frac_part) = match body.split_once('.') {
Some((a, b)) => (a, b),
None => (body, ""),
};
if int_part.is_empty() || !int_part.chars().all(|c| c.is_ascii_digit()) {
return Err(());
}
if !frac_part.is_empty() && !frac_part.chars().all(|c| c.is_ascii_digit()) {
return Err(());
}
let mut padded = String::from(frac_part);
while padded.len() < places as usize {
padded.push('0');
}
padded.truncate(places as usize);
let combined = format!("{int_part}{padded}");
combined.parse::<i128>().map(|n| sign * n).map_err(|_| ())
}
fn format_scaled_decimal(n: i128, places: u32) -> String {
let (sign, abs_n) = if n < 0 { ("-", -n) } else { ("", n) };
let mut s = abs_n.to_string();
while s.len() <= places as usize {
s.insert(0, '0');
}
let split = s.len() - places as usize;
format!("{sign}{}.{}", &s[..split], &s[split..])
}
fn valid_step(target: &str, kind: AuditStepKind, message: &str) -> AuditStep {
AuditStep {
target: target.to_string(),
kind,
status: AuditStepStatus::Valid,
reason: None,
message: message.to_string(),
detail: None,
}
}