use rust_decimal::{Decimal, RoundingStrategy};
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
use crate::infrastructure::persistence::{
BankAccountRepository, BankClearanceRepository, BankReconciliationRepository, BankRepository,
BankStatementImportRepository, BankTransactionRepository, NewBankAccountRow, NewBankRow,
};
use super::banking_events::{BankingEventSink, LoggingSink};
use super::iban_validation::{is_iban_shaped, normalize_iban, validate_iban_with, IbanError, IbanValidationPolicy};
pub(super) fn money(v: Decimal) -> Decimal {
v.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero)
}
#[derive(Debug, Clone)]
pub struct NewBank {
pub name: String,
pub swift_bic: Option<String>,
pub country: Option<String>,
}
#[derive(Debug, Clone)]
pub struct NewBankAccount {
pub branch_id: Option<Uuid>,
pub bank_id: Uuid,
pub account_name: String,
pub account_number: String,
pub gl_account_id: Uuid,
pub clearing_account_id: Uuid,
pub currency: Option<String>,
pub account_type: Option<String>,
}
#[derive(Debug, Clone)]
pub struct NewStatementLine {
pub txn_date: chrono::NaiveDate,
pub description: Option<String>,
pub reference_no: Option<String>,
pub deposit: Decimal,
pub withdrawal: Decimal,
}
#[derive(Debug, Clone)]
pub struct NewStatementImport {
pub bank_account_id: Uuid,
pub source_format: Option<String>,
pub period_start: chrono::NaiveDate,
pub period_end: chrono::NaiveDate,
pub opening_balance: Decimal,
pub closing_balance: Decimal,
pub file_ref: Option<String>,
pub lines: Vec<NewStatementLine>,
}
#[derive(Debug, Clone)]
pub struct MatchCandidate {
pub source_type: String,
pub source_id: Uuid,
pub amount: Decimal,
pub reference: Option<String>,
}
#[derive(Debug, Clone)]
pub struct NewClearance {
pub bank_transaction_id: Uuid,
pub matched_source_type: String,
pub matched_source_id: Uuid,
pub matched_source_amount: Decimal,
pub matched_amount: Decimal,
pub match_method: Option<String>,
pub clearance_date: chrono::NaiveDate,
}
#[derive(Debug, Clone)]
pub struct NewCharge {
pub bank_transaction_id: Uuid,
pub charge_account_id: Uuid,
pub amount: Decimal,
pub clearance_date: chrono::NaiveDate,
}
#[derive(Debug, Clone)]
pub struct NewReconciliation {
pub bank_account_id: Uuid,
pub from_date: chrono::NaiveDate,
pub to_date: chrono::NaiveDate,
pub statement_closing_balance: Decimal,
pub ledger_balance: Decimal,
}
#[derive(Debug, Clone)]
pub struct ClearOutcome {
pub clearance_id: Uuid,
pub post_id: Uuid,
pub journal_id: Uuid,
pub fully_reconciled: bool,
}
#[derive(Debug, Clone)]
pub struct ReconcileOutcome {
pub id: Uuid,
pub difference: Decimal,
pub unreconciled_count: i64,
pub status: String,
}
#[derive(Debug)]
pub enum BankingError {
EmptyStatement,
BalanceMismatch {
expected: Decimal,
computed: Decimal,
},
NegativeAmount,
NonPositiveAmount,
OverAllocated {
line_net: Decimal,
already: Decimal,
attempted: Decimal,
},
SettlementOverCleared {
settlement_amount: Decimal,
already_cleared: Decimal,
attempted: Decimal,
},
UnbalancedPost,
UnsupportedCurrency(String),
TransactionNotFound(Uuid),
AccountNotFound(Uuid),
GlRejected {
code: String,
message: String,
},
ReconcileRefused {
code: String,
message: String,
},
PresetNotFound(Uuid),
PresetInactive(Uuid),
PresetMissingTolerance(Uuid),
PresetMissingDaysWindow(Uuid),
UnknownMatchOn(String),
CandidatePoolRefused(String),
InvalidIban(String),
IbanUnknownCountry(String),
Db(sqlx::Error),
}
impl BankingError {
pub fn code(&self) -> String {
match self {
BankingError::EmptyStatement => "empty_statement".into(),
BankingError::BalanceMismatch { .. } => "balance_mismatch".into(),
BankingError::NegativeAmount => "negative_amount".into(),
BankingError::NonPositiveAmount => "non_positive_amount".into(),
BankingError::OverAllocated { .. } => "over_allocated".into(),
BankingError::SettlementOverCleared { .. } => "settlement_over_cleared".into(),
BankingError::UnbalancedPost => "unbalanced_post".into(),
BankingError::UnsupportedCurrency(_) => "unsupported_currency".into(),
BankingError::TransactionNotFound(_) => "transaction_not_found".into(),
BankingError::AccountNotFound(_) => "account_not_found".into(),
BankingError::GlRejected { code, .. } => code.clone(),
BankingError::ReconcileRefused { code, .. } => code.clone(),
BankingError::PresetNotFound(_) => "preset_not_found".into(),
BankingError::PresetInactive(_) => "preset_inactive".into(),
BankingError::PresetMissingTolerance(_) => "preset_missing_tolerance".into(),
BankingError::PresetMissingDaysWindow(_) => "preset_missing_days_window".into(),
BankingError::UnknownMatchOn(_) => "unknown_match_on".into(),
BankingError::CandidatePoolRefused(_) => "candidate_pool_refused".into(),
BankingError::InvalidIban(_) => "invalid_iban".into(),
BankingError::IbanUnknownCountry(_) => "iban_unknown_country".into(),
BankingError::Db(_) => "internal_error".into(),
}
}
pub fn http_status(&self) -> u16 {
match self {
BankingError::TransactionNotFound(_)
| BankingError::AccountNotFound(_)
| BankingError::PresetNotFound(_) => 404,
BankingError::CandidatePoolRefused(_) | BankingError::Db(_) => 500,
_ => 422,
}
}
}
impl std::fmt::Display for BankingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BankingError::GlRejected { code, message } => write!(f, "{code}: {message}"),
BankingError::ReconcileRefused { code, message } => write!(f, "{code}: {message}"),
BankingError::BalanceMismatch { expected, computed } => write!(
f,
"balance_mismatch: expected {expected}, computed {computed}"
),
other => write!(f, "{}", other.code()),
}
}
}
impl std::error::Error for BankingError {}
impl From<sqlx::Error> for BankingError {
fn from(e: sqlx::Error) -> Self {
BankingError::Db(e)
}
}
pub(super) struct Repos {
pub(super) banks: BankRepository,
pub(super) bank_accounts: BankAccountRepository,
pub(super) imports: BankStatementImportRepository,
pub(super) transactions: BankTransactionRepository,
pub(super) clearances: BankClearanceRepository,
pub(super) reconciliations: BankReconciliationRepository,
}
impl Repos {
fn new(db_pool: &PgPool) -> Self {
Self {
banks: BankRepository::new(db_pool.clone()),
bank_accounts: BankAccountRepository::new(db_pool.clone()),
imports: BankStatementImportRepository::new(db_pool.clone()),
transactions: BankTransactionRepository::new(db_pool.clone()),
clearances: BankClearanceRepository::new(db_pool.clone()),
reconciliations: BankReconciliationRepository::new(db_pool.clone()),
}
}
}
#[derive(Clone)]
pub struct BankingWriteService {
pub(super) db_pool: PgPool,
pub(super) repos: Arc<Repos>,
pub(super) sink: Arc<dyn BankingEventSink>,
pub(super) outbox_schema: Option<String>,
pub(super) candidates: Arc<dyn super::reconcile_preset_candidates::CandidatePoolPort>,
pub(super) iban_policy: IbanValidationPolicy,
}
impl BankingWriteService {
pub(super) fn rpool(&self) -> PgPool {
crate::request_pool::current().unwrap_or_else(|| self.db_pool.clone())
}
pub fn new(db_pool: PgPool) -> Self {
Self::with_sink(db_pool, Arc::new(LoggingSink))
}
pub fn with_sink(db_pool: PgPool, sink: Arc<dyn BankingEventSink>) -> Self {
let repos = Arc::new(Repos::new(&db_pool));
Self {
db_pool,
repos,
sink,
outbox_schema: None,
candidates: Arc::new(super::reconcile_preset_candidates::PaymentPoolRead),
iban_policy: IbanValidationPolicy::FAIL_CLOSED,
}
}
pub fn with_iban_policy(mut self, policy: IbanValidationPolicy) -> Self {
self.iban_policy = policy;
self
}
pub fn with_outbox_schema(mut self, schema: impl Into<String>) -> Self {
self.outbox_schema = Some(schema.into());
self
}
pub fn with_candidate_port(
mut self,
port: Arc<dyn super::reconcile_preset_candidates::CandidatePoolPort>,
) -> Self {
self.candidates = port;
self
}
pub async fn create_bank(&self, b: NewBank) -> Result<Uuid, BankingError> {
let id = Uuid::new_v4();
let country = b.country.unwrap_or_else(|| "ID".into());
self.repos
.banks
.insert_bank(
&self.rpool(),
&NewBankRow {
id,
name: &b.name,
swift_bic: b.swift_bic.as_deref(),
country: &country,
},
)
.await?;
Ok(id)
}
pub async fn create_bank_account(&self, a: NewBankAccount) -> Result<Uuid, BankingError> {
let account_number = if is_iban_shaped(&normalize_iban(&a.account_number)) {
match validate_iban_with(&self.iban_policy, &a.account_number) {
Ok(canonical) => canonical,
Err(IbanError::UnknownCountry(country)) => {
return Err(BankingError::IbanUnknownCountry(country));
}
Err(e) => {
return Err(BankingError::InvalidIban(format!("{} ({e})", a.account_number)));
}
}
} else {
a.account_number.clone()
};
let id = Uuid::new_v4();
let currency = a.currency.unwrap_or_else(|| "IDR".into());
let account_type = a.account_type.unwrap_or_else(|| "checking".into());
self.repos
.bank_accounts
.insert_bank_account(
&self.rpool(),
&NewBankAccountRow {
id,
branch_id: a.branch_id,
bank_id: a.bank_id,
account_name: &a.account_name,
account_number: &account_number,
gl_account_id: a.gl_account_id,
clearing_account_id: a.clearing_account_id,
currency: ¤cy,
account_type: &account_type,
},
)
.await?;
Ok(id)
}
}