Download app.py from satvaSolutions/Transaction_Reconciliation: direct link, hf CLI and curl.
- Browser
- Download file 10.9 kB
-
https://huggingface.co/spaces/satvaSolutions/Transaction_Reconciliation/resolve/main/app.py
- Command line
-
hf download hf://spaces/satvaSolutions/Transaction_Reconciliation/app.py
-
curl -L -o app.py https://huggingface.co/spaces/satvaSolutions/Transaction_Reconciliation/resolve/main/app.py
10.9 kB
| from fastapi import FastAPI, HTTPException, Query | |
| from pydantic import BaseModel | |
| from typing import List, Optional, Dict, Any | |
| from datetime import datetime, date | |
| import re | |
| from difflib import SequenceMatcher | |
| import uvicorn | |
| app = FastAPI( | |
| title="Transaction Reconciliation API", | |
| description="Reconcile bank and credit card transactions using fuzzy matching", | |
| version="1.0.0" | |
| ) | |
| # Pydantic Models | |
| class Transaction(BaseModel): | |
| id: str | |
| date: str | |
| amount: float | |
| description: str | |
| type: str | |
| reference_number: Optional[str] = None | |
| class ReconciliationInput(BaseModel): | |
| bank_transactions: List[Transaction] | |
| credit_card_transactions: List[Transaction] | |
| class MatchedTransaction(BaseModel): | |
| bank_id: str | |
| credit_card_id: str | |
| match_score: float | |
| match_reason: str | |
| description: str | |
| amount: float | |
| class UnmatchedTransaction(BaseModel): | |
| id: str | |
| date: str | |
| amount: float | |
| description: str | |
| type: str | |
| reference_number: Optional[str] = None | |
| class ReconciliationOutput(BaseModel): | |
| matched_transactions: List[MatchedTransaction] | |
| unmatched_bank_transactions: List[UnmatchedTransaction] | |
| unmatched_credit_card_transactions: List[UnmatchedTransaction] | |
| class ReconciliationService: | |
| def __init__(self, | |
| description_threshold: float = 0.7, | |
| amount_tolerance: float = 0.01, | |
| max_date_diff_days: int = 7): | |
| self.description_threshold = description_threshold | |
| self.amount_tolerance = amount_tolerance | |
| self.max_date_diff_days = max_date_diff_days | |
| def fuzzy_match_description(self, desc1: str, desc2: str) -> float: | |
| """Calculate fuzzy match score between two descriptions""" | |
| # Clean descriptions for better matching | |
| clean_desc1 = self._clean_description(desc1.lower()) | |
| clean_desc2 = self._clean_description(desc2.lower()) | |
| # Use SequenceMatcher for fuzzy matching | |
| similarity = SequenceMatcher(None, clean_desc1, clean_desc2).ratio() | |
| # Additional check for common transaction patterns | |
| if self._check_common_patterns(clean_desc1, clean_desc2): | |
| similarity = max(similarity, 0.8) | |
| return similarity | |
| def _clean_description(self, description: str) -> str: | |
| """Clean description for better matching""" | |
| # Remove special characters and extra spaces | |
| cleaned = re.sub(r'[^\w\s]', ' ', description) | |
| cleaned = re.sub(r'\s+', ' ', cleaned).strip() | |
| return cleaned | |
| def _check_common_patterns(self, desc1: str, desc2: str) -> bool: | |
| """Check for common transaction patterns""" | |
| patterns = [ | |
| (r'uber', r'uber'), | |
| (r'amazon|amzn', r'amazon|amzn'), | |
| (r'invoice\s*#?\s*(\d+)', r'invoice\s*#?\s*(\d+)'), | |
| (r'payment.*invoice', r'payment.*invoice'), | |
| (r'trip\s*id\s*(\d+)', r'trip\s*id\s*(\d+)') | |
| ] | |
| for pattern1, pattern2 in patterns: | |
| if re.search(pattern1, desc1) and re.search(pattern2, desc2): | |
| return True | |
| return False | |
| def calculate_date_difference(self, date1: str, date2: str) -> int: | |
| """Calculate difference in days between two dates""" | |
| try: | |
| d1 = datetime.strptime(date1, "%Y-%m-%d").date() | |
| d2 = datetime.strptime(date2, "%Y-%m-%d").date() | |
| return abs((d1 - d2).days) | |
| except ValueError: | |
| return float('inf') | |
| def amounts_match(self, amount1: float, amount2: float) -> bool: | |
| """Check if amounts are close enough to match""" | |
| return abs(abs(amount1) - abs(amount2)) <= self.amount_tolerance | |
| def types_match(self, bank_type: str, cc_type: str) -> bool: | |
| """Check if transaction types match according to business logic""" | |
| type_mappings = { | |
| ('debit', 'payment'), | |
| ('credit', 'receipt'), | |
| ('withdrawal', 'payment'), | |
| ('deposit', 'receipt') | |
| } | |
| return (bank_type.lower(), cc_type.lower()) in type_mappings or bank_type.lower() == cc_type.lower() | |
| def calculate_match_score(self, bank_txn: Transaction, cc_txn: Transaction) -> tuple[float, str]: | |
| """Calculate overall match score and reason""" | |
| scores = [] | |
| reasons = [] | |
| # Amount matching (weight: 0.4) | |
| if self.amounts_match(bank_txn.amount, cc_txn.amount): | |
| scores.append(0.4) | |
| reasons.append("amounts match") | |
| else: | |
| amount_diff = abs(abs(bank_txn.amount) - abs(cc_txn.amount)) | |
| amount_score = max(0, 0.4 * (1 - amount_diff / max(abs(bank_txn.amount), abs(cc_txn.amount)))) | |
| scores.append(amount_score) | |
| if amount_score > 0.2: | |
| reasons.append("amounts close") | |
| # Reference number matching (weight: 0.3) | |
| if (bank_txn.reference_number and cc_txn.reference_number and | |
| bank_txn.reference_number == cc_txn.reference_number): | |
| scores.append(0.3) | |
| reasons.append("reference numbers match") | |
| else: | |
| scores.append(0) | |
| # Description matching (weight: 0.2) | |
| desc_score = self.fuzzy_match_description(bank_txn.description, cc_txn.description) | |
| scores.append(0.2 * desc_score) | |
| if desc_score >= self.description_threshold: | |
| reasons.append("descriptions match") | |
| # Date matching (weight: 0.1) | |
| date_diff = self.calculate_date_difference(bank_txn.date, cc_txn.date) | |
| if date_diff <= self.max_date_diff_days: | |
| date_score = 0.1 * (1 - date_diff / self.max_date_diff_days) | |
| scores.append(date_score) | |
| if date_diff <= 1: | |
| reasons.append("dates match") | |
| else: | |
| reasons.append("dates close") | |
| else: | |
| scores.append(0) | |
| total_score = sum(scores) | |
| reason = ", ".join(reasons) if reasons else "partial match" | |
| return total_score, reason | |
| def reconcile(self, input_data: ReconciliationInput) -> ReconciliationOutput: | |
| """Main reconciliation logic""" | |
| matched_transactions = [] | |
| unmatched_bank = list(input_data.bank_transactions) | |
| unmatched_cc = list(input_data.credit_card_transactions) | |
| # Find matches | |
| for bank_txn in input_data.bank_transactions: | |
| best_match = None | |
| best_score = 0 | |
| best_reason = "" | |
| for cc_txn in input_data.credit_card_transactions: | |
| # Check if types match first | |
| if not self.types_match(bank_txn.type, cc_txn.type): | |
| continue | |
| score, reason = self.calculate_match_score(bank_txn, cc_txn) | |
| # Minimum threshold for considering a match | |
| if score >= 0.6 and score > best_score: | |
| best_match = cc_txn | |
| best_score = score | |
| best_reason = reason | |
| if best_match: | |
| matched_transactions.append(MatchedTransaction( | |
| bank_id=bank_txn.id, | |
| credit_card_id=best_match.id, | |
| match_score=round(best_score, 2), | |
| match_reason=best_reason, | |
| description=bank_txn.description, | |
| amount=bank_txn.amount | |
| )) | |
| # Remove matched transactions from unmatched lists | |
| if bank_txn in unmatched_bank: | |
| unmatched_bank.remove(bank_txn) | |
| if best_match in unmatched_cc: | |
| unmatched_cc.remove(best_match) | |
| # Convert remaining unmatched transactions | |
| unmatched_bank_list = [ | |
| UnmatchedTransaction( | |
| id=txn.id, | |
| date=txn.date, | |
| amount=txn.amount, | |
| description=txn.description, | |
| type=txn.type, | |
| reference_number=txn.reference_number | |
| ) for txn in unmatched_bank | |
| ] | |
| unmatched_cc_list = [ | |
| UnmatchedTransaction( | |
| id=txn.id, | |
| date=txn.date, | |
| amount=txn.amount, | |
| description=txn.description, | |
| type=txn.type, | |
| reference_number=txn.reference_number | |
| ) for txn in unmatched_cc | |
| ] | |
| return ReconciliationOutput( | |
| matched_transactions=matched_transactions, | |
| unmatched_bank_transactions=unmatched_bank_list, | |
| unmatched_credit_card_transactions=unmatched_cc_list | |
| ) | |
| # Initialize service | |
| reconciliation_service = ReconciliationService() | |
| async def root(): | |
| """Health check endpoint""" | |
| return { | |
| "message": "Transaction Reconciliation API is running", | |
| "status": "healthy", | |
| "version": "1.0.0" | |
| } | |
| async def reconcile_transactions(input_data: ReconciliationInput): | |
| """ | |
| Reconcile bank and credit card transactions | |
| This endpoint matches transactions based on: | |
| - Amount similarity (within tolerance) | |
| - Date proximity (within 7 days) | |
| - Description fuzzy matching (70% threshold) | |
| - Transaction type compatibility | |
| - Reference number exact matching | |
| """ | |
| try: | |
| result = reconciliation_service.reconcile(input_data) | |
| return result | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Reconciliation failed: {str(e)}") | |
| async def health_check(): | |
| """Health check for deployment""" | |
| return {"status": "ok", "service": "Transaction Reconciliation API"} | |
| async def reconcile_with_custom_params( | |
| input_data: ReconciliationInput, | |
| description_threshold: float = Query(0.7, ge=0, le=1, description="Fuzzy match threshold for descriptions"), | |
| amount_tolerance: float = Query(0.01, ge=0, description="Maximum allowed difference in amounts"), | |
| max_date_diff_days: int = Query(7, ge=0, description="Maximum allowed date difference in days") | |
| ): | |
| """ | |
| Reconcile transactions with custom matching parameters | |
| """ | |
| try: | |
| custom_service = ReconciliationService( | |
| description_threshold=description_threshold, | |
| amount_tolerance=amount_tolerance, | |
| max_date_diff_days=max_date_diff_days | |
| ) | |
| result = custom_service.reconcile(input_data) | |
| return result | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Reconciliation failed: {str(e)}") | |
| if __name__ == "__main__": | |
| uvicorn.run(app, host="0.0.0.0", port=8000) |