use std::collections::VecDeque;
use crate::model::{Amount, Currency, Layer, PostingRecord, Side};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct Pair<const P: u8 = 2> {
pub ordinal: u16,
pub debit: u16,
pub credit: u16,
pub amount: Amount<P>,
}
#[must_use]
pub fn pairs<const P: u8>(postings: &[PostingRecord<P>]) -> Vec<Pair<P>> {
let mut groups: Vec<Group<P>> = Vec::new();
for (index, posting) in (0u16..).zip(postings) {
let key = (posting.currency, posting.layer);
if let Some(group) = groups.iter_mut().find(|group| group.key == key) {
group.add(index, posting);
} else {
let mut group = Group {
key,
debits: Vec::new(),
credits: Vec::new(),
};
group.add(index, posting);
groups.push(group);
}
}
let mut found: Vec<(u16, u16, Amount<P>)> = Vec::new();
for group in groups {
group.pair(&mut found);
}
found.sort_by_key(|(debit, credit, _)| (*debit, *credit));
(0u16..)
.zip(found)
.map(|(ordinal, (debit, credit, amount))| Pair {
ordinal,
debit,
credit,
amount,
})
.collect()
}
struct Group<const P: u8> {
key: (Currency, Layer),
debits: Vec<(u16, Amount<P>)>,
credits: Vec<(u16, Amount<P>)>,
}
impl<const P: u8> Group<P> {
fn add(&mut self, index: u16, posting: &PostingRecord<P>) {
match posting.side {
Side::Debit => self.debits.push((index, posting.amount)),
Side::Credit => self.credits.push((index, posting.amount)),
}
}
fn pair(mut self, found: &mut Vec<(u16, u16, Amount<P>)>) {
for debit in &mut self.debits {
let exact = self
.credits
.iter_mut()
.find(|credit| credit.1.is_positive() && credit.1 == debit.1);
if let Some(credit) = exact {
found.push((debit.0, credit.0, debit.1));
debit.1 = Amount::ZERO;
credit.1 = Amount::ZERO;
}
}
let open = |legs: Vec<(u16, Amount<P>)>| -> VecDeque<(u16, Amount<P>)> {
legs.into_iter().filter(|leg| leg.1.is_positive()).collect()
};
let (mut debits, mut credits) = (open(self.debits), open(self.credits));
while let (Some(debit), Some(credit)) = (debits.front_mut(), credits.front_mut()) {
let amount = debit.1.min(credit.1);
found.push((debit.0, credit.0, amount));
debit.1 = less(debit.1, amount);
credit.1 = less(credit.1, amount);
if !debit.1.is_positive() {
debits.pop_front();
}
if !credit.1.is_positive() {
credits.pop_front();
}
}
}
}
fn less<const P: u8>(amount: Amount<P>, part: Amount<P>) -> Amount<P> {
amount.checked_sub(part).unwrap_or(Amount::ZERO)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{AccountHandle, Dimensions, EUR, USD};
fn leg(side: Side, minor: i128) -> PostingRecord<2> {
PostingRecord {
handle: AccountHandle::new(1).unwrap(),
side,
amount: Amount::from_minor(minor).unwrap(),
currency: EUR,
layer: Layer::Settled,
dimensions: Dimensions::new(),
}
}
fn found(postings: &[PostingRecord<2>]) -> Vec<(u16, u16, i128)> {
pairs(postings)
.iter()
.map(|pair| (pair.debit, pair.credit, pair.amount.to_minor()))
.collect()
}
#[test]
fn exact_matches_come_before_the_greedy_merge() {
let legs = [
leg(Side::Debit, 50),
leg(Side::Debit, 100),
leg(Side::Credit, 100),
leg(Side::Credit, 50),
];
assert_eq!(found(&legs), [(0, 3, 50), (1, 2, 100)]);
let legs = [
leg(Side::Debit, 70),
leg(Side::Debit, 30),
leg(Side::Credit, 50),
leg(Side::Credit, 50),
];
assert_eq!(found(&legs), [(0, 2, 50), (0, 3, 20), (1, 3, 30)]);
}
#[test]
fn one_debit_many_credits_and_repeated_amounts() {
let legs = [
leg(Side::Credit, 10),
leg(Side::Debit, 30),
leg(Side::Credit, 10),
leg(Side::Credit, 10),
];
assert_eq!(found(&legs), [(1, 0, 10), (1, 2, 10), (1, 3, 10)]);
let legs = [
leg(Side::Debit, 10),
leg(Side::Debit, 10),
leg(Side::Credit, 10),
leg(Side::Credit, 10),
];
assert_eq!(found(&legs), [(0, 2, 10), (1, 3, 10)]);
let ordinals: Vec<u16> = pairs(&legs).iter().map(|pair| pair.ordinal).collect();
assert_eq!(ordinals, [0, 1]);
}
#[test]
fn groups_never_mix_currencies_or_layers() {
let mut dollars = leg(Side::Credit, 100);
dollars.currency = USD;
let mut pending = leg(Side::Credit, 100);
pending.layer = Layer::Pending;
let legs = [
leg(Side::Debit, 100),
dollars,
leg(Side::Credit, 100),
pending,
];
assert_eq!(found(&legs), [(0, 2, 100)]);
}
}