1use rust_decimal::{Decimal, RoundingStrategy};
27use sqlx::PgPool;
28use std::sync::Arc;
29use uuid::Uuid;
30
31use crate::infrastructure::persistence::{
32 BankAccountRepository, BankClearanceRepository, BankReconciliationRepository, BankRepository,
33 BankStatementImportRepository, BankTransactionRepository, NewBankAccountRow, NewBankRow,
34};
35
36use super::banking_events::{BankingEventSink, LoggingSink};
37use super::iban_validation::{is_iban_shaped, normalize_iban, validate_iban_with, IbanError, IbanValidationPolicy};
38
39pub(super) fn money(v: Decimal) -> Decimal {
40 v.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero)
41}
42
43#[derive(Debug, Clone)]
46pub struct NewBank {
47 pub name: String,
48 pub swift_bic: Option<String>,
49 pub country: Option<String>,
50}
51
52#[derive(Debug, Clone)]
53pub struct NewBankAccount {
54 pub branch_id: Option<Uuid>,
55 pub bank_id: Uuid,
56 pub account_name: String,
57 pub account_number: String,
58 pub gl_account_id: Uuid,
59 pub clearing_account_id: Uuid,
60 pub currency: Option<String>,
61 pub account_type: Option<String>,
62}
63
64#[derive(Debug, Clone)]
65pub struct NewStatementLine {
66 pub txn_date: chrono::NaiveDate,
67 pub description: Option<String>,
68 pub reference_no: Option<String>,
69 pub deposit: Decimal,
70 pub withdrawal: Decimal,
71}
72
73#[derive(Debug, Clone)]
74pub struct NewStatementImport {
75 pub bank_account_id: Uuid,
76 pub source_format: Option<String>,
77 pub period_start: chrono::NaiveDate,
78 pub period_end: chrono::NaiveDate,
79 pub opening_balance: Decimal,
80 pub closing_balance: Decimal,
81 pub file_ref: Option<String>,
82 pub lines: Vec<NewStatementLine>,
83}
84
85#[derive(Debug, Clone)]
88pub struct MatchCandidate {
89 pub source_type: String,
90 pub source_id: Uuid,
91 pub amount: Decimal,
92 pub reference: Option<String>,
93}
94
95#[derive(Debug, Clone)]
96pub struct NewClearance {
97 pub bank_transaction_id: Uuid,
98 pub matched_source_type: String,
99 pub matched_source_id: Uuid,
100 pub matched_source_amount: Decimal,
103 pub matched_amount: Decimal,
104 pub match_method: Option<String>,
105 pub clearance_date: chrono::NaiveDate,
106}
107
108#[derive(Debug, Clone)]
109pub struct NewCharge {
110 pub bank_transaction_id: Uuid,
111 pub charge_account_id: Uuid,
112 pub amount: Decimal,
113 pub clearance_date: chrono::NaiveDate,
114}
115
116#[derive(Debug, Clone)]
117pub struct NewReconciliation {
118 pub bank_account_id: Uuid,
119 pub from_date: chrono::NaiveDate,
120 pub to_date: chrono::NaiveDate,
121 pub statement_closing_balance: Decimal,
122 pub ledger_balance: Decimal,
123}
124
125#[derive(Debug, Clone)]
126pub struct ClearOutcome {
127 pub clearance_id: Uuid,
128 pub post_id: Uuid,
129 pub journal_id: Uuid,
130 pub fully_reconciled: bool,
131}
132
133#[derive(Debug, Clone)]
134pub struct ReconcileOutcome {
135 pub id: Uuid,
136 pub difference: Decimal,
137 pub unreconciled_count: i64,
139 pub status: String,
141}
142
143#[derive(Debug)]
146pub enum BankingError {
147 EmptyStatement,
148 BalanceMismatch {
149 expected: Decimal,
150 computed: Decimal,
151 },
152 NegativeAmount,
153 NonPositiveAmount,
154 OverAllocated {
155 line_net: Decimal,
156 already: Decimal,
157 attempted: Decimal,
158 },
159 SettlementOverCleared {
160 settlement_amount: Decimal,
161 already_cleared: Decimal,
162 attempted: Decimal,
163 },
164 UnbalancedPost,
165 UnsupportedCurrency(String),
166 TransactionNotFound(Uuid),
167 AccountNotFound(Uuid),
168 GlRejected {
169 code: String,
170 message: String,
171 },
172 ReconcileRefused {
176 code: String,
177 message: String,
178 },
179 PresetNotFound(Uuid),
180 PresetInactive(Uuid),
181 PresetMissingTolerance(Uuid),
182 PresetMissingDaysWindow(Uuid),
183 UnknownMatchOn(String),
184 CandidatePoolRefused(String),
187 InvalidIban(String),
190 IbanUnknownCountry(String),
194 Db(sqlx::Error),
195}
196
197impl BankingError {
198 pub fn code(&self) -> String {
199 match self {
200 BankingError::EmptyStatement => "empty_statement".into(),
201 BankingError::BalanceMismatch { .. } => "balance_mismatch".into(),
202 BankingError::NegativeAmount => "negative_amount".into(),
203 BankingError::NonPositiveAmount => "non_positive_amount".into(),
204 BankingError::OverAllocated { .. } => "over_allocated".into(),
205 BankingError::SettlementOverCleared { .. } => "settlement_over_cleared".into(),
206 BankingError::UnbalancedPost => "unbalanced_post".into(),
207 BankingError::UnsupportedCurrency(_) => "unsupported_currency".into(),
208 BankingError::TransactionNotFound(_) => "transaction_not_found".into(),
209 BankingError::AccountNotFound(_) => "account_not_found".into(),
210 BankingError::GlRejected { code, .. } => code.clone(),
211 BankingError::ReconcileRefused { code, .. } => code.clone(),
212 BankingError::PresetNotFound(_) => "preset_not_found".into(),
213 BankingError::PresetInactive(_) => "preset_inactive".into(),
214 BankingError::PresetMissingTolerance(_) => "preset_missing_tolerance".into(),
215 BankingError::PresetMissingDaysWindow(_) => "preset_missing_days_window".into(),
216 BankingError::UnknownMatchOn(_) => "unknown_match_on".into(),
217 BankingError::CandidatePoolRefused(_) => "candidate_pool_refused".into(),
218 BankingError::InvalidIban(_) => "invalid_iban".into(),
219 BankingError::IbanUnknownCountry(_) => "iban_unknown_country".into(),
220 BankingError::Db(_) => "internal_error".into(),
221 }
222 }
223 pub fn http_status(&self) -> u16 {
224 match self {
225 BankingError::TransactionNotFound(_)
226 | BankingError::AccountNotFound(_)
227 | BankingError::PresetNotFound(_) => 404,
228 BankingError::CandidatePoolRefused(_) | BankingError::Db(_) => 500,
229 _ => 422,
230 }
231 }
232}
233impl std::fmt::Display for BankingError {
234 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235 match self {
236 BankingError::GlRejected { code, message } => write!(f, "{code}: {message}"),
237 BankingError::ReconcileRefused { code, message } => write!(f, "{code}: {message}"),
238 BankingError::BalanceMismatch { expected, computed } => write!(
239 f,
240 "balance_mismatch: expected {expected}, computed {computed}"
241 ),
242 other => write!(f, "{}", other.code()),
243 }
244 }
245}
246impl std::error::Error for BankingError {}
247impl From<sqlx::Error> for BankingError {
248 fn from(e: sqlx::Error) -> Self {
249 BankingError::Db(e)
250 }
251}
252
253pub(super) struct Repos {
258 pub(super) banks: BankRepository,
259 pub(super) bank_accounts: BankAccountRepository,
260 pub(super) imports: BankStatementImportRepository,
261 pub(super) transactions: BankTransactionRepository,
262 pub(super) clearances: BankClearanceRepository,
263 pub(super) reconciliations: BankReconciliationRepository,
264}
265
266impl Repos {
267 fn new(db_pool: &PgPool) -> Self {
268 Self {
269 banks: BankRepository::new(db_pool.clone()),
270 bank_accounts: BankAccountRepository::new(db_pool.clone()),
271 imports: BankStatementImportRepository::new(db_pool.clone()),
272 transactions: BankTransactionRepository::new(db_pool.clone()),
273 clearances: BankClearanceRepository::new(db_pool.clone()),
274 reconciliations: BankReconciliationRepository::new(db_pool.clone()),
275 }
276 }
277}
278
279#[derive(Clone)]
280pub struct BankingWriteService {
281 pub(super) db_pool: PgPool,
282 pub(super) repos: Arc<Repos>,
283 pub(super) sink: Arc<dyn BankingEventSink>,
284 pub(super) outbox_schema: Option<String>,
289 pub(super) candidates: Arc<dyn super::reconcile_preset_candidates::CandidatePoolPort>,
292 pub(super) iban_policy: IbanValidationPolicy,
295}
296
297impl BankingWriteService {
298 pub(super) fn rpool(&self) -> PgPool {
301 crate::request_pool::current().unwrap_or_else(|| self.db_pool.clone())
302 }
303
304 pub fn new(db_pool: PgPool) -> Self {
305 Self::with_sink(db_pool, Arc::new(LoggingSink))
306 }
307 pub fn with_sink(db_pool: PgPool, sink: Arc<dyn BankingEventSink>) -> Self {
308 let repos = Arc::new(Repos::new(&db_pool));
309 Self {
310 db_pool,
311 repos,
312 sink,
313 outbox_schema: None,
314 candidates: Arc::new(super::reconcile_preset_candidates::PaymentPoolRead),
315 iban_policy: IbanValidationPolicy::FAIL_CLOSED,
316 }
317 }
318 pub fn with_iban_policy(mut self, policy: IbanValidationPolicy) -> Self {
322 self.iban_policy = policy;
323 self
324 }
325 pub fn with_outbox_schema(mut self, schema: impl Into<String>) -> Self {
329 self.outbox_schema = Some(schema.into());
330 self
331 }
332 pub fn with_candidate_port(
335 mut self,
336 port: Arc<dyn super::reconcile_preset_candidates::CandidatePoolPort>,
337 ) -> Self {
338 self.candidates = port;
339 self
340 }
341
342 pub async fn create_bank(&self, b: NewBank) -> Result<Uuid, BankingError> {
345 let id = Uuid::new_v4();
349 let country = b.country.unwrap_or_else(|| "ID".into());
350 self.repos
351 .banks
352 .insert_bank(
353 &self.rpool(),
354 &NewBankRow {
355 id,
356 name: &b.name,
357 swift_bic: b.swift_bic.as_deref(),
358 country: &country,
359 },
360 )
361 .await?;
362 Ok(id)
363 }
364
365 pub async fn create_bank_account(&self, a: NewBankAccount) -> Result<Uuid, BankingError> {
366 let account_number = if is_iban_shaped(&normalize_iban(&a.account_number)) {
373 match validate_iban_with(&self.iban_policy, &a.account_number) {
374 Ok(canonical) => canonical,
375 Err(IbanError::UnknownCountry(country)) => {
376 return Err(BankingError::IbanUnknownCountry(country));
377 }
378 Err(e) => {
379 return Err(BankingError::InvalidIban(format!("{} ({e})", a.account_number)));
380 }
381 }
382 } else {
383 a.account_number.clone()
384 };
385 let id = Uuid::new_v4();
387 let currency = a.currency.unwrap_or_else(|| "IDR".into());
388 let account_type = a.account_type.unwrap_or_else(|| "checking".into());
389 self.repos
390 .bank_accounts
391 .insert_bank_account(
392 &self.rpool(),
393 &NewBankAccountRow {
394 id,
395 branch_id: a.branch_id,
396 bank_id: a.bank_id,
397 account_name: &a.account_name,
398 account_number: &account_number,
399 gl_account_id: a.gl_account_id,
400 clearing_account_id: a.clearing_account_id,
401 currency: ¤cy,
402 account_type: &account_type,
403 },
404 )
405 .await?;
406 Ok(id)
407 }
408}