///| Analytical views for reconciliation runs. All functions are deterministic.
///|
pub(all) struct FindingMetrics {
total : Int
info : Int
warning : Int
error : Int
blocking : Int
} derive(Debug, Eq)
///|
pub(all) struct CodeCount {
code : FindingCode
count : Int
} derive(Debug, Eq)
///|
pub(all) struct ResolutionCount {
resolution : Resolution
count : Int
} derive(Debug, Eq)
///|
pub(all) struct CurrencyTotal {
currency : String
order_count : Int
expected_minor : Int
captured_minor : Int
refunded_minor : Int
outstanding_minor : Int
} derive(Debug, Eq)
///|
pub(all) struct AccountTotal {
account_id : String
order_count : Int
expected_minor : Int
captured_minor : Int
refunded_minor : Int
outstanding_minor : Int
} derive(Debug, Eq)
///|
pub(all) struct AgeBucket {
label : String
order_count : Int
outstanding_minor : Int
} derive(Debug, Eq)
///|
pub(all) struct ResultMetrics {
finding_metrics : FindingMetrics
settled_orders : Int
awaiting_payment_orders : Int
over_collected_orders : Int
refund_risk_orders : Int
review_orders : Int
expected_minor : Int
captured_minor : Int
refunded_minor : Int
outstanding_minor : Int
} derive(Debug, Eq)
///|
pub(all) struct FindingQuery {
minimum_severity : Severity
code : FindingCode?
order_id : String?
record_kind : RecordKind?
} derive(Debug, Eq)
///|
pub fn empty_finding_metrics() -> FindingMetrics {
{ total: 0, info: 0, warning: 0, error: 0, blocking: 0, }
}
///|
pub fn empty_result_metrics() -> ResultMetrics {
{
finding_metrics: empty_finding_metrics(),
settled_orders: 0,
awaiting_payment_orders: 0,
over_collected_orders: 0,
refund_risk_orders: 0,
review_orders: 0,
expected_minor: 0,
captured_minor: 0,
refunded_minor: 0,
outstanding_minor: 0,
}
}
///|
pub fn severity_rank(severity : Severity) -> Int {
match severity {
Info => 1
Warning => 2
Error => 3
}
}
///|
pub fn resolution_rank(resolution : Resolution) -> Int {
match resolution {
Settled => 1
AwaitingPayment => 2
OverCollected => 3
RefundRisk => 4
NeedsReview => 5
}
}
///|
pub fn finding_metrics(findings : Array[Finding]) -> FindingMetrics {
let mut metrics = empty_finding_metrics()
for finding in findings {
metrics = { ..metrics, total: metrics.total + 1, }
match finding.severity {
Info => metrics = { ..metrics, info: metrics.info + 1, }
Warning => metrics = { ..metrics, warning: metrics.warning + 1, }
Error =>
metrics = {
..metrics,
error: metrics.error + 1,
blocking: metrics.blocking + 1,
}
}
}
metrics
}
///|
fn add_balance_metrics(
metrics : ResultMetrics,
balance : OrderBalance,
) -> ResultMetrics {
let mut next = {
..metrics,
expected_minor: metrics.expected_minor + balance.expected_minor,
captured_minor: metrics.captured_minor + balance.captured_minor,
refunded_minor: metrics.refunded_minor + balance.refunded_minor,
outstanding_minor: metrics.outstanding_minor + balance.outstanding_minor,
}
match balance.resolution {
Settled => next = { ..next, settled_orders: next.settled_orders + 1, }
AwaitingPayment =>
next = {
..next,
awaiting_payment_orders: next.awaiting_payment_orders + 1,
}
OverCollected =>
next = { ..next, over_collected_orders: next.over_collected_orders + 1, }
RefundRisk =>
next = { ..next, refund_risk_orders: next.refund_risk_orders + 1, }
NeedsReview => next = { ..next, review_orders: next.review_orders + 1, }
}
next
}
///|
pub fn result_metrics(result : ReconciliationResult) -> ResultMetrics {
let mut metrics = {
..empty_result_metrics(),
finding_metrics: finding_metrics(result.findings),
}
for balance in result.balances {
metrics = add_balance_metrics(metrics, balance)
}
metrics
}
///|
pub fn count_findings_by_code(
findings : Array[Finding],
code : FindingCode,
) -> Int {
let mut count = 0
for finding in findings {
if finding.code == code {
count = count + 1
}
}
count
}
///|
pub fn finding_code_counts(findings : Array[Finding]) -> Array[CodeCount] {
let values : Array[FindingCode] = [
DuplicateOrderId,
DuplicatePaymentId,
DuplicateRefundId,
DuplicateAdjustmentId,
InvalidAmount,
InvalidCurrency,
UnknownOrder,
CurrencyMismatch,
PaymentAfterDeadline,
PaymentForCancelledOrder,
RefundExceedsPayment,
Overpaid,
Underpaid,
OpenPastDue,
PaymentVoided,
MissingReference,
]
let counts : Array[CodeCount] = []
for code in values {
let count = count_findings_by_code(findings, code)
if count > 0 {
counts.push({ code, count, })
}
}
counts
}
///|
pub fn count_balances_by_resolution(
balances : Array[OrderBalance],
resolution : Resolution,
) -> Int {
let mut count = 0
for balance in balances {
if balance.resolution == resolution {
count = count + 1
}
}
count
}
///|
pub fn resolution_counts(
result : ReconciliationResult,
) -> Array[ResolutionCount] {
let values : Array[Resolution] = [
Settled,
AwaitingPayment,
OverCollected,
RefundRisk,
NeedsReview,
]
let counts : Array[ResolutionCount] = []
for resolution in values {
let count = count_balances_by_resolution(result.balances, resolution)
if count > 0 {
counts.push({ resolution, count, })
}
}
counts
}
///|
fn query_matches_finding(query : FindingQuery, finding : Finding) -> Bool {
if severity_rank(finding.severity) < severity_rank(query.minimum_severity) {
return false
}
match query.code {
Some(code) => if finding.code != code { return false }
None => ()
}
match query.order_id {
Some(order_id) => if finding.order_id != order_id { return false }
None => ()
}
match query.record_kind {
Some(kind) => if finding.record_kind != kind { return false }
None => ()
}
true
}
///|
pub fn filter_findings(
findings : Array[Finding],
query : FindingQuery,
) -> Array[Finding] {
let filtered : Array[Finding] = []
for finding in findings {
if query_matches_finding(query, finding) {
filtered.push(finding)
}
}
filtered
}
///|
pub fn error_findings(findings : Array[Finding]) -> Array[Finding] {
filter_findings(findings, {
minimum_severity: Error,
code: None,
order_id: None,
record_kind: None,
})
}
///|
pub fn warning_or_higher_findings(findings : Array[Finding]) -> Array[Finding] {
filter_findings(findings, {
minimum_severity: Warning,
code: None,
order_id: None,
record_kind: None,
})
}
///|
pub fn findings_for_order(
findings : Array[Finding],
order_id : String,
) -> Array[Finding] {
filter_findings(findings, {
minimum_severity: Info,
code: None,
order_id: Some(order_id),
record_kind: None,
})
}
///|
pub fn findings_for_kind(
findings : Array[Finding],
kind : RecordKind,
) -> Array[Finding] {
filter_findings(findings, {
minimum_severity: Info,
code: None,
order_id: None,
record_kind: Some(kind),
})
}
///|
pub fn balances_above_outstanding(
balances : Array[OrderBalance],
threshold_minor : Int,
) -> Array[OrderBalance] {
let selected : Array[OrderBalance] = []
for balance in balances {
if balance.outstanding_minor > threshold_minor {
selected.push(balance)
}
}
selected
}
///|
pub fn balances_below_outstanding(
balances : Array[OrderBalance],
threshold_minor : Int,
) -> Array[OrderBalance] {
let selected : Array[OrderBalance] = []
for balance in balances {
if balance.outstanding_minor < threshold_minor {
selected.push(balance)
}
}
selected
}
///|
pub fn settled_balances(balances : Array[OrderBalance]) -> Array[OrderBalance] {
let selected : Array[OrderBalance] = []
for balance in balances {
if balance.resolution == Settled {
selected.push(balance)
}
}
selected
}
///|
fn currency_position(totals : Array[CurrencyTotal], currency : String) -> Int {
for index = 0; index < totals.length(); index = index + 1 {
if totals[index].currency == currency {
return index
}
}
-1
}
///|
pub fn totals_by_currency(
input : LedgerInput,
result : ReconciliationResult,
) -> Array[CurrencyTotal] {
let totals : Array[CurrencyTotal] = []
for order in input.orders {
match find_balance(result.balances, order.id) {
None => ()
Some(balance) => {
let index = currency_position(totals, order.currency)
if index < 0 {
totals.push({
currency: order.currency,
order_count: 1,
expected_minor: balance.expected_minor,
captured_minor: balance.captured_minor,
refunded_minor: balance.refunded_minor,
outstanding_minor: balance.outstanding_minor,
})
} else {
let current = totals[index]
totals[index] = {
..current,
order_count: current.order_count + 1,
expected_minor: current.expected_minor + balance.expected_minor,
captured_minor: current.captured_minor + balance.captured_minor,
refunded_minor: current.refunded_minor + balance.refunded_minor,
outstanding_minor: current.outstanding_minor +
balance.outstanding_minor,
}
}
}
}
}
totals
}
///|
fn account_position(totals : Array[AccountTotal], account_id : String) -> Int {
for index = 0; index < totals.length(); index = index + 1 {
if totals[index].account_id == account_id {
return index
}
}
-1
}
///|
pub fn totals_by_account(
input : LedgerInput,
result : ReconciliationResult,
) -> Array[AccountTotal] {
let totals : Array[AccountTotal] = []
for order in input.orders {
match find_balance(result.balances, order.id) {
None => ()
Some(balance) => {
let index = account_position(totals, order.account_id)
if index < 0 {
totals.push({
account_id: order.account_id,
order_count: 1,
expected_minor: balance.expected_minor,
captured_minor: balance.captured_minor,
refunded_minor: balance.refunded_minor,
outstanding_minor: balance.outstanding_minor,
})
} else {
let current = totals[index]
totals[index] = {
..current,
order_count: current.order_count + 1,
expected_minor: current.expected_minor + balance.expected_minor,
captured_minor: current.captured_minor + balance.captured_minor,
refunded_minor: current.refunded_minor + balance.refunded_minor,
outstanding_minor: current.outstanding_minor +
balance.outstanding_minor,
}
}
}
}
}
totals
}
///|
pub fn find_balance(
balances : Array[OrderBalance],
order_id : String,
) -> OrderBalance? {
for balance in balances {
if balance.order_id == order_id {
return Some(balance)
}
}
None
}
///|
pub fn max_outstanding_balance(balances : Array[OrderBalance]) -> OrderBalance? {
if balances.is_empty() {
return None
}
let mut largest = balances[0]
for balance in balances {
if balance.outstanding_minor > largest.outstanding_minor {
largest = balance
}
}
Some(largest)
}
///|
pub fn min_outstanding_balance(balances : Array[OrderBalance]) -> OrderBalance? {
if balances.is_empty() {
return None
}
let mut smallest = balances[0]
for balance in balances {
if balance.outstanding_minor < smallest.outstanding_minor {
smallest = balance
}
}
Some(smallest)
}
///|
pub fn sort_balances_by_outstanding(
balances : Array[OrderBalance],
) -> Array[OrderBalance] {
let sorted = balances.copy()
for outer = 0; outer < sorted.length(); outer = outer + 1 {
for inner = 0; inner + 1 < sorted.length() - outer; inner = inner + 1 {
if sorted[inner].outstanding_minor < sorted[inner + 1].outstanding_minor {
let saved = sorted[inner]
sorted[inner] = sorted[inner + 1]
sorted[inner + 1] = saved
}
}
}
sorted
}
///|
pub fn sort_findings_by_severity(findings : Array[Finding]) -> Array[Finding] {
let sorted = findings.copy()
for outer = 0; outer < sorted.length(); outer = outer + 1 {
for inner = 0; inner + 1 < sorted.length() - outer; inner = inner + 1 {
if severity_rank(sorted[inner].severity) <
severity_rank(sorted[inner + 1].severity) {
let saved = sorted[inner]
sorted[inner] = sorted[inner + 1]
sorted[inner + 1] = saved
}
}
}
sorted
}
///|
pub fn balance_age(
input : LedgerInput,
order_id : String,
current_day : Int,
) -> Int? {
match find_order(input, order_id) {
None => None
Some(order) =>
Some(
if current_day > order.created_day {
current_day - order.created_day
} else {
0
},
)
}
}
///|
pub fn age_bucket_label(age_days : Int) -> String {
if age_days < 0 {
"invalid"
} else if age_days <= 7 {
"0-7"
} else if age_days <= 30 {
"8-30"
} else if age_days <= 60 {
"31-60"
} else {
"61+"
}
}
///|
fn bucket_position(buckets : Array[AgeBucket], label : String) -> Int {
for index = 0; index < buckets.length(); index = index + 1 {
if buckets[index].label == label {
return index
}
}
-1
}
///|
pub fn outstanding_by_age(
input : LedgerInput,
result : ReconciliationResult,
current_day : Int,
) -> Array[AgeBucket] {
let buckets : Array[AgeBucket] = []
for balance in result.balances {
if balance.outstanding_minor <= 0 {
continue
}
match balance_age(input, balance.order_id, current_day) {
None => ()
Some(age) => {
let label = age_bucket_label(age)
let index = bucket_position(buckets, label)
if index < 0 {
buckets.push({
label,
order_count: 1,
outstanding_minor: balance.outstanding_minor,
})
} else {
let bucket = buckets[index]
buckets[index] = {
..bucket,
order_count: bucket.order_count + 1,
outstanding_minor: bucket.outstanding_minor +
balance.outstanding_minor,
}
}
}
}
}
buckets
}
///|
pub fn quality_score(result : ReconciliationResult) -> Int {
let metrics = result_metrics(result)
let raw = 100 -
metrics.finding_metrics.error * 20 -
metrics.finding_metrics.warning * 5 -
metrics.finding_metrics.info
if raw < 0 {
0
} else {
raw
}
}
///|
pub fn is_healthy(result : ReconciliationResult) -> Bool {
result.valid && quality_score(result) >= 90
}