use rust_decimal::{Decimal, RoundingStrategy};
use sqlx::{PgPool, Row};
use uuid::Uuid;
use backbone_orm::org_scope;
use super::banking_write_service::{BankingError, BankingWriteService};
#[derive(Debug, Clone)]
pub struct OpenCandidate {
pub payment_id: Uuid,
pub payment_number: String,
pub paid_amount: Decimal,
pub posting_date: chrono::NaiveDate,
pub reference_no: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RankedCandidate {
pub payment_id: Uuid,
pub payment_number: String,
pub open_amount: Decimal,
pub posting_date: chrono::NaiveDate,
pub score: i64,
pub reason: String,
}
#[derive(Debug, Clone)]
pub struct CandidateLineBasis {
pub bank_account_id: Uuid,
pub deposit: Decimal,
pub withdrawal: Decimal,
pub txn_date: chrono::NaiveDate,
pub reference_no: Option<String>,
}
#[async_trait::async_trait]
pub trait CandidatePoolPort: Send + Sync {
async fn open_candidates(
&self,
pool: &PgPool,
bank_account_id: Uuid,
) -> Result<Vec<OpenCandidate>, BankingError>;
}
pub struct PaymentPoolRead;
#[async_trait::async_trait]
impl CandidatePoolPort for PaymentPoolRead {
async fn open_candidates(
&self,
pool: &PgPool,
bank_account_id: Uuid,
) -> Result<Vec<OpenCandidate>, BankingError> {
let present: Option<String> =
sqlx::query_scalar("SELECT to_regclass('payment.payment_entries')::text")
.fetch_optional(pool)
.await
.map_err(BankingError::Db)?
.flatten();
if present.is_none() {
return Err(BankingError::CandidatePoolRefused(
"payment.payment_entries is absent — the candidate pool cannot be read".into(),
));
}
let mut tx = pool.begin().await.map_err(BankingError::Db)?;
if let Some(scope) = org_scope::current_org_scope() {
org_scope::bind_org_scope_on(&mut tx, &scope)
.await
.map_err(BankingError::Db)?;
backbone_orm::audit_context::relay_ambient_audit_on(&mut tx).await?;
}
let rows = sqlx::query(
r#"SELECT id, payment_number, paid_amount, posting_date, reference_no
FROM payment.payment_entries
WHERE bank_account_id IN (
SELECT gl_account_id FROM banking.bank_accounts WHERE id=$1
UNION
SELECT clearing_account_id FROM banking.bank_accounts WHERE id=$1
)
AND posting_state='posted' AND status IN ('in_flight','paid')
AND (metadata->>'deleted_at') IS NULL"#,
)
.bind(bank_account_id)
.fetch_all(&mut *tx)
.await
.map_err(BankingError::Db)?;
tx.commit().await.map_err(BankingError::Db)?;
Ok(rows
.into_iter()
.map(|r| OpenCandidate {
payment_id: r.get("id"),
payment_number: r.get("payment_number"),
paid_amount: r.get("paid_amount"),
posting_date: r.get("posting_date"),
reference_no: r.get("reference_no"),
})
.collect())
}
}
impl BankingWriteService {
pub async fn order_candidates(
&self,
preset_id: Uuid,
line_id: Uuid,
) -> Result<Vec<RankedCandidate>, BankingError> {
let preset = org_scope::fetch_optional_row_scoped(
&self.rpool(),
sqlx::query(
r#"SELECT name, match_on::text AS mo, tolerance_percent, days_window, status::text AS st
FROM banking.reconcile_presets WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
)
.bind(preset_id),
)
.await
.map_err(BankingError::Db)?
.ok_or(BankingError::PresetNotFound(preset_id))?;
let preset_status: String = preset.get("st");
if preset_status != "active" {
return Err(BankingError::PresetInactive(preset_id));
}
let preset_name: String = preset.get("name");
let match_on: String = preset.get("mo");
let tolerance: Option<Decimal> = preset.get("tolerance_percent");
let days_window: Option<i32> = preset.get("days_window");
let row = self
.repos
.transactions
.fetch_candidate_basis(&self.rpool(), line_id)
.await?
.ok_or(BankingError::TransactionNotFound(line_id))?;
let line = CandidateLineBasis {
bank_account_id: row.bank_account_id,
deposit: row.deposit,
withdrawal: row.withdrawal,
txn_date: row.txn_date,
reference_no: row.reference_no,
};
let pool_rows = self
.candidates
.open_candidates(&self.rpool(), line.bank_account_id)
.await?;
let ids: Vec<Uuid> = pool_rows.iter().map(|c| c.payment_id).collect();
let cleared = self
.repos
.clearances
.sums_cleared_for_payments(&self.rpool(), &ids)
.await?;
let line_amount = if line.deposit > Decimal::ZERO {
line.deposit
} else {
line.withdrawal
};
let mut ranked: Vec<RankedCandidate> = Vec::new();
for c in pool_rows {
let already = cleared.get(&c.payment_id).copied().unwrap_or(Decimal::ZERO);
let open = c.paid_amount - already;
if open <= Decimal::ZERO {
continue; }
let (score, reason) = match match_on.as_str() {
"reference_exact" => {
let hit = line
.reference_no
.as_deref()
.zip(c.reference_no.as_deref())
.map(|(l, p)| l == p)
.unwrap_or(false);
if !hit {
continue;
}
(
100,
format!(
"reference_exact: line ref '{}' equals payment ref '{}'",
line.reference_no.clone().unwrap_or_default(),
c.reference_no.clone().unwrap_or_default()
),
)
}
"amount_exact" => {
if open != line_amount {
continue;
}
(
90,
format!("amount_exact: open {open} equals line amount {line_amount}"),
)
}
"amount_within_tolerance" => {
let tol = tolerance.ok_or(BankingError::PresetMissingTolerance(preset_id))?;
if line_amount <= Decimal::ZERO {
continue;
}
let deviation = ((open - line_amount).abs() / line_amount * Decimal::from(100))
.round_dp_with_strategy(6, RoundingStrategy::MidpointAwayFromZero);
if deviation > tol {
continue;
}
(
80,
format!(
"amount_within_tolerance: open {open} deviates {deviation}% from line {line_amount} (tolerance {tol}%)"
),
)
}
"days_window" => {
let window =
days_window.ok_or(BankingError::PresetMissingDaysWindow(preset_id))? as i64;
let delta = (c.posting_date - line.txn_date).num_days().abs();
if delta > window {
continue;
}
(70, format!("days_window: payment dated {} is {delta}d from line date {} (window {window}d)", c.posting_date, line.txn_date))
}
"party" => {
continue;
}
other => return Err(BankingError::UnknownMatchOn(other.to_string())),
};
ranked.push(RankedCandidate {
payment_id: c.payment_id,
payment_number: c.payment_number,
open_amount: open,
posting_date: c.posting_date,
score,
reason: format!("[{preset_name}] {reason}"),
});
}
ranked.sort_by(|a, b| {
b.score
.cmp(&a.score)
.then_with(|| a.payment_number.cmp(&b.payment_number))
});
Ok(ranked)
}
}