///| Payment allocation produces a deterministic, non-mutating matching plan.
///|
pub(all) enum AllocationState {
Applied
UnmatchedPayment
OverAllocation
CurrencyConflict
IgnoredPayment
} derive(Debug, Eq)
///|
pub(all) struct PaymentAllocation {
payment_id : String
order_id : String
currency : String
allocated_minor : Int
state : AllocationState
note : String
} derive(Debug, Eq)
///|
pub(all) struct AllocationPlan {
allocations : Array[PaymentAllocation]
unallocated_minor : Int
allocated_minor : Int
ignored_minor : Int
valid : Bool
} derive(Debug, Eq)
///|
pub(all) struct MatchingCandidate {
payment_id : String
order_id : String
score : Int
reason : String
} derive(Debug, Eq)
///|
pub fn allocation_state_label(state : AllocationState) -> String {
match state {
Applied => "applied"
UnmatchedPayment => "unmatched_payment"
OverAllocation => "over_allocation"
CurrencyConflict => "currency_conflict"
IgnoredPayment => "ignored_payment"
}
}
///|
pub fn empty_allocation_plan() -> AllocationPlan {
{
allocations: [],
unallocated_minor: 0,
allocated_minor: 0,
ignored_minor: 0,
valid: true,
}
}
///|
pub fn remaining_expected(balance : OrderBalance) -> Int {
let remaining = balance.expected_minor + balance.refunded_minor
if remaining < 0 {
0
} else {
remaining
}
}
///|
pub fn captured_payment_amount(payment : PaymentRecord) -> Int {
if payment.status == Captured {
payment.amount_minor
} else {
0
}
}
///|
pub fn is_eligible_payment(payment : PaymentRecord) -> Bool {
payment.status == Captured &&
payment.amount_minor > 0 &&
!is_blank(payment.order_id)
}
///|
pub fn payment_matches_order(
payment : PaymentRecord,
order : OrderRecord,
) -> Bool {
payment.order_id == order.id && payment.currency == order.currency
}
///|
pub fn candidate_score(payment : PaymentRecord, order : OrderRecord) -> Int {
if payment.order_id == order.id && payment.currency == order.currency {
100
} else if payment.order_id == order.id {
25
} else if payment.currency == order.currency &&
payment.amount_minor == order.amount_minor {
10
} else {
0
}
}
///|
pub fn matching_candidates(
input : LedgerInput,
payment : PaymentRecord,
) -> Array[MatchingCandidate] {
let candidates : Array[MatchingCandidate] = []
for order in input.orders {
let score = candidate_score(payment, order)
if score > 0 {
let reason = if score == 100 {
"exact order and currency"
} else if score == 25 {
"order id matches but currency differs"
} else {
"currency and amount heuristic"
}
candidates.push({
payment_id: payment.id,
order_id: order.id,
score,
reason,
})
}
}
candidates
}
///|
pub fn best_candidate(
input : LedgerInput,
payment : PaymentRecord,
) -> MatchingCandidate? {
let candidates = matching_candidates(input, payment)
if candidates.is_empty() {
return None
}
let mut best = candidates[0]
for candidate in candidates {
if candidate.score > best.score {
best = candidate
}
}
Some(best)
}
///|
fn allocation_for_ignored(payment : PaymentRecord) -> PaymentAllocation {
{
payment_id: payment.id,
order_id: payment.order_id,
currency: payment.currency,
allocated_minor: 0,
state: IgnoredPayment,
note: "payment is not captured or has a non-positive amount",
}
}
///|
fn allocation_for_unknown(payment : PaymentRecord) -> PaymentAllocation {
{
payment_id: payment.id,
order_id: payment.order_id,
currency: payment.currency,
allocated_minor: 0,
state: UnmatchedPayment,
note: "no order exists for payment reference",
}
}
///|
fn allocation_for_currency_conflict(
payment : PaymentRecord,
) -> PaymentAllocation {
{
payment_id: payment.id,
order_id: payment.order_id,
currency: payment.currency,
allocated_minor: 0,
state: CurrencyConflict,
note: "payment currency differs from target order currency",
}
}
///|
fn allocation_for_applied(
payment : PaymentRecord,
amount : Int,
) -> PaymentAllocation {
{
payment_id: payment.id,
order_id: payment.order_id,
currency: payment.currency,
allocated_minor: amount,
state: Applied,
note: "captured payment allocated to outstanding amount",
}
}
///|
fn allocation_for_excess(
payment : PaymentRecord,
amount : Int,
) -> PaymentAllocation {
{
payment_id: payment.id,
order_id: payment.order_id,
currency: payment.currency,
allocated_minor: amount,
state: OverAllocation,
note: "payment exceeds remaining expected amount",
}
}
///|
fn plan_add(
plan : AllocationPlan,
allocation : PaymentAllocation,
) -> AllocationPlan {
let allocations = plan.allocations.copy()
allocations.push(allocation)
let mut next = { ..plan, allocations, }
match allocation.state {
Applied =>
next = {
..next,
allocated_minor: next.allocated_minor + allocation.allocated_minor,
}
IgnoredPayment =>
next = {
..next,
ignored_minor: next.ignored_minor + allocation.allocated_minor,
}
UnmatchedPayment =>
next = {
..next,
unallocated_minor: next.unallocated_minor + allocation.allocated_minor,
valid: false,
}
OverAllocation =>
next = {
..next,
unallocated_minor: next.unallocated_minor + allocation.allocated_minor,
valid: false,
}
CurrencyConflict => next = { ..next, valid: false, }
}
next
}
///|
fn projected_remaining(
balances : Array[OrderBalance],
allocations : Array[PaymentAllocation],
order_id : String,
) -> Int {
match find_balance(balances, order_id) {
None => 0
Some(balance) => {
let mut remaining = remaining_expected(balance)
for allocation in allocations {
if allocation.order_id == order_id && allocation.state == Applied {
remaining = remaining - allocation.allocated_minor
}
}
if remaining < 0 {
0
} else {
remaining
}
}
}
}
///|
pub fn allocate_payments(
input : LedgerInput,
result : ReconciliationResult,
) -> AllocationPlan {
let mut plan = empty_allocation_plan()
for payment in input.payments {
if !is_eligible_payment(payment) {
plan = plan_add(plan, allocation_for_ignored(payment))
continue
}
match find_order(input, payment.order_id) {
None => {
let allocation = {
..allocation_for_unknown(payment),
allocated_minor: payment.amount_minor,
}
plan = plan_add(plan, allocation)
}
Some(order) => {
if order.currency != payment.currency {
plan = plan_add(plan, allocation_for_currency_conflict(payment))
continue
}
let remaining = projected_remaining(
result.balances,
plan.allocations,
order.id,
)
let allocated = if payment.amount_minor < remaining {
payment.amount_minor
} else {
remaining
}
if allocated > 0 {
plan = plan_add(plan, allocation_for_applied(payment, allocated))
}
let excess = payment.amount_minor - allocated
if excess > 0 {
plan = plan_add(plan, allocation_for_excess(payment, excess))
}
}
}
}
plan
}
///|
pub fn allocations_for_payment(
allocations : Array[PaymentAllocation],
payment_id : String,
) -> Array[PaymentAllocation] {
let selected : Array[PaymentAllocation] = []
for allocation in allocations {
if allocation.payment_id == payment_id {
selected.push(allocation)
}
}
selected
}
///|
pub fn allocations_for_order(
allocations : Array[PaymentAllocation],
order_id : String,
) -> Array[PaymentAllocation] {
let selected : Array[PaymentAllocation] = []
for allocation in allocations {
if allocation.order_id == order_id {
selected.push(allocation)
}
}
selected
}
///|
pub fn allocated_for_order(
allocations : Array[PaymentAllocation],
order_id : String,
) -> Int {
let mut total = 0
for allocation in allocations {
if allocation.order_id == order_id && allocation.state == Applied {
total = total + allocation.allocated_minor
}
}
total
}
///|
pub fn excess_for_payment(
allocations : Array[PaymentAllocation],
payment_id : String,
) -> Int {
let mut total = 0
for allocation in allocations {
if allocation.payment_id == payment_id && allocation.state == OverAllocation {
total = total + allocation.allocated_minor
}
}
total
}
///|
pub fn unmatched_allocations(plan : AllocationPlan) -> Array[PaymentAllocation] {
let selected : Array[PaymentAllocation] = []
for allocation in plan.allocations {
if allocation.state == UnmatchedPayment ||
allocation.state == CurrencyConflict {
selected.push(allocation)
}
}
selected
}
///|
pub fn over_allocations(plan : AllocationPlan) -> Array[PaymentAllocation] {
let selected : Array[PaymentAllocation] = []
for allocation in plan.allocations {
if allocation.state == OverAllocation {
selected.push(allocation)
}
}
selected
}
///|
pub fn ignored_allocations(plan : AllocationPlan) -> Array[PaymentAllocation] {
let selected : Array[PaymentAllocation] = []
for allocation in plan.allocations {
if allocation.state == IgnoredPayment {
selected.push(allocation)
}
}
selected
}
///|
pub fn allocation_plan_is_consistent(plan : AllocationPlan) -> Bool {
let mut applied = 0
let mut unallocated = 0
let mut ignored = 0
let mut valid = true
for allocation in plan.allocations {
if allocation.allocated_minor < 0 || is_blank(allocation.payment_id) {
return false
}
match allocation.state {
Applied => applied = applied + allocation.allocated_minor
IgnoredPayment => ignored = ignored + allocation.allocated_minor
UnmatchedPayment => {
unallocated = unallocated + allocation.allocated_minor
valid = false
}
OverAllocation => {
unallocated = unallocated + allocation.allocated_minor
valid = false
}
CurrencyConflict => valid = false
}
}
plan.allocated_minor == applied &&
plan.unallocated_minor == unallocated &&
plan.ignored_minor == ignored &&
plan.valid == valid
}
///|
pub fn render_allocation_plan(plan : AllocationPlan) -> String {
let mut output = "PAYMENT | ORDER | AMOUNT | STATE | NOTE\n"
for allocation in plan.allocations {
output = output +
"\{allocation.payment_id} | \{allocation.order_id} | \{allocation.allocated_minor} | \{allocation_state_label(allocation.state)} | \{allocation.note}\n"
}
output +
"allocated=\{plan.allocated_minor} unallocated=\{plan.unallocated_minor} ignored=\{plan.ignored_minor} valid=\{plan.valid}"
}