Add Phase 11 bank to Tally with multi-bank contra intelligence
This commit is contained in:
@@ -23,6 +23,8 @@ ANALYSIS_COLUMNS = [
|
||||
"auto_category", "auto_nature", "auto_group", "matched_rule_id",
|
||||
"matched_keyword", "suggested_ledger", "rule_confidence",
|
||||
"review_required", "review_note",
|
||||
"contra_pair_id", "contra_status", "contra_counter_statement_id",
|
||||
"contra_counter_bank", "contra_counter_account", "contra_confidence", "contra_reason",
|
||||
"category", "counterparty",
|
||||
]
|
||||
|
||||
@@ -428,6 +430,186 @@ def enrich(df, classification_enabled: bool = True):
|
||||
return _ensure_analysis_columns(x)
|
||||
|
||||
|
||||
|
||||
def _contra_normalized_ref(row) -> str:
|
||||
values = [
|
||||
row.get("transfer_reference"),
|
||||
row.get("reference_no"),
|
||||
]
|
||||
for value in values:
|
||||
value = re.sub(r"[^A-Z0-9]", "", _text(value).upper())
|
||||
if len(value) >= 6:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _account_tail(value: str) -> str:
|
||||
digits = re.sub(r"\D", "", _text(value))
|
||||
return digits[-6:] if len(digits) >= 4 else digits
|
||||
|
||||
|
||||
def _contra_candidate_score(left, right) -> tuple[int, list[str]]:
|
||||
if _direction(left) == _direction(right):
|
||||
return 0, []
|
||||
left_account = _text(left.get("account_number"))
|
||||
right_account = _text(right.get("account_number"))
|
||||
if not left_account or not right_account or clean_key(left_account) == clean_key(right_account):
|
||||
return 0, []
|
||||
|
||||
left_amount = round(_amount(left), 2)
|
||||
right_amount = round(_amount(right), 2)
|
||||
if left_amount <= 0 or abs(left_amount - right_amount) > 0.01:
|
||||
return 0, []
|
||||
|
||||
left_date = pd.to_datetime(left.get("transaction_date"), errors="coerce")
|
||||
right_date = pd.to_datetime(right.get("transaction_date"), errors="coerce")
|
||||
if pd.isna(left_date) or pd.isna(right_date):
|
||||
return 0, []
|
||||
days = abs((left_date.normalize() - right_date.normalize()).days)
|
||||
if days > 2:
|
||||
return 0, []
|
||||
|
||||
score = 55
|
||||
reasons = ["equal and opposite amount across different bank accounts"]
|
||||
if days == 0:
|
||||
score += 18
|
||||
reasons.append("same transaction date")
|
||||
elif days == 1:
|
||||
score += 13
|
||||
reasons.append("one-day settlement difference")
|
||||
else:
|
||||
score += 8
|
||||
reasons.append("two-day settlement difference")
|
||||
|
||||
lref = _contra_normalized_ref(left)
|
||||
rref = _contra_normalized_ref(right)
|
||||
if lref and rref and lref == rref:
|
||||
score += 30
|
||||
reasons.append("same bank transfer reference")
|
||||
|
||||
ln = _text(left.get("narration")).upper()
|
||||
rn = _text(right.get("narration")).upper()
|
||||
own_words = ("SELF", "OWN ACCOUNT", "OWN A/C", "TRANSFER TO", "TRANSFER FROM", "INTERNAL TRANSFER")
|
||||
if any(word in ln for word in own_words) or any(word in rn for word in own_words):
|
||||
score += 10
|
||||
reasons.append("own-account transfer wording")
|
||||
|
||||
ltail = _account_tail(left_account)
|
||||
rtail = _account_tail(right_account)
|
||||
if (ltail and ltail in rn) or (rtail and rtail in ln):
|
||||
score += 18
|
||||
reasons.append("counter bank account suffix appears in narration")
|
||||
|
||||
# Without a transfer reference or direct account evidence, do not call a generic
|
||||
# same-amount movement contra merely because dates happen to align.
|
||||
strong_identity = bool((lref and rref and lref == rref) or ((ltail and ltail in rn) or (rtail and rtail in ln)))
|
||||
if not strong_identity and not any(word in ln for word in own_words) and not any(word in rn for word in own_words):
|
||||
return 0, []
|
||||
|
||||
return min(100, score), reasons
|
||||
|
||||
|
||||
def detect_interbank_contra_pairs(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Conservatively pair transfers between different uploaded accounts.
|
||||
|
||||
The function only pairs equal-and-opposite movements across distinct account
|
||||
numbers within two days and requires transfer-reference, account-suffix or
|
||||
explicit own-account wording evidence. Each transaction is used in at most
|
||||
one pair.
|
||||
"""
|
||||
x = _ensure_analysis_columns(df)
|
||||
if x.empty:
|
||||
return x
|
||||
|
||||
for col, default in (
|
||||
("contra_pair_id", ""),
|
||||
("contra_status", ""),
|
||||
("contra_counter_statement_id", ""),
|
||||
("contra_counter_bank", ""),
|
||||
("contra_counter_account", ""),
|
||||
("contra_confidence", 0.0),
|
||||
("contra_reason", ""),
|
||||
):
|
||||
if col not in x.columns:
|
||||
x[col] = default
|
||||
|
||||
candidates = []
|
||||
rows = list(x.iterrows())
|
||||
for pos, (li, left) in enumerate(rows):
|
||||
for ri, right in rows[pos + 1:]:
|
||||
score, reasons = _contra_candidate_score(left, right)
|
||||
if score >= 85:
|
||||
candidates.append((score, li, ri, reasons))
|
||||
|
||||
candidates.sort(key=lambda item: (-item[0], item[1], item[2]))
|
||||
used = set()
|
||||
pair_no = 0
|
||||
|
||||
for score, li, ri, reasons in candidates:
|
||||
if li in used or ri in used:
|
||||
continue
|
||||
used.add(li)
|
||||
used.add(ri)
|
||||
pair_no += 1
|
||||
pair_id = f"CONTRA-{pair_no:04d}"
|
||||
|
||||
for current, other in ((li, ri), (ri, li)):
|
||||
x.at[current, "contra_pair_id"] = pair_id
|
||||
x.at[current, "contra_status"] = "Matched"
|
||||
x.at[current, "contra_counter_statement_id"] = _text(x.at[other, "statement_id"])
|
||||
x.at[current, "contra_counter_bank"] = _text(x.at[other, "bank_name"])
|
||||
x.at[current, "contra_counter_account"] = _text(x.at[other, "account_number"])
|
||||
x.at[current, "contra_confidence"] = float(score)
|
||||
x.at[current, "contra_reason"] = "; ".join(reasons)
|
||||
|
||||
x.at[current, "auto_party"] = "Own Bank Transfer"
|
||||
x.at[current, "auto_category"] = "Self Transfer / Contra"
|
||||
x.at[current, "auto_nature"] = "Contra"
|
||||
x.at[current, "auto_group"] = "Contra / Balance Sheet"
|
||||
x.at[current, "suggested_ledger"] = "Other Bank Account"
|
||||
x.at[current, "rule_confidence"] = float(score)
|
||||
if score >= 95:
|
||||
x.at[current, "review_required"] = False
|
||||
x.at[current, "review_note"] = ""
|
||||
else:
|
||||
x.at[current, "review_required"] = True
|
||||
x.at[current, "review_note"] = "Probable inter-bank contra; verify both bank accounts before posting."
|
||||
|
||||
return x
|
||||
|
||||
|
||||
def interbank_contra_summary(df: pd.DataFrame) -> pd.DataFrame:
|
||||
if df is None or df.empty or "contra_pair_id" not in df.columns:
|
||||
return pd.DataFrame(columns=[
|
||||
"contra_pair_id", "confidence", "debit_bank", "debit_account",
|
||||
"credit_bank", "credit_account", "amount", "date_from", "date_to", "reason",
|
||||
])
|
||||
matched = df[df["contra_pair_id"].fillna("").ne("")].copy()
|
||||
rows = []
|
||||
for pair_id, part in matched.groupby("contra_pair_id", sort=True):
|
||||
if len(part) != 2:
|
||||
continue
|
||||
debit = part[pd.to_numeric(part["debit"], errors="coerce").fillna(0).gt(0)]
|
||||
credit = part[pd.to_numeric(part["credit"], errors="coerce").fillna(0).gt(0)]
|
||||
if debit.empty or credit.empty:
|
||||
continue
|
||||
d = debit.iloc[0]
|
||||
c = credit.iloc[0]
|
||||
dates = pd.to_datetime(part["transaction_date"], errors="coerce").dropna()
|
||||
rows.append({
|
||||
"contra_pair_id": pair_id,
|
||||
"confidence": float(part["contra_confidence"].max() or 0),
|
||||
"debit_bank": d.get("bank_name", ""),
|
||||
"debit_account": d.get("account_number", ""),
|
||||
"credit_bank": c.get("bank_name", ""),
|
||||
"credit_account": c.get("account_number", ""),
|
||||
"amount": round(float(d.get("debit") or 0), 2),
|
||||
"date_from": dates.min() if not dates.empty else None,
|
||||
"date_to": dates.max() if not dates.empty else None,
|
||||
"reason": d.get("contra_reason", ""),
|
||||
})
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
def analyze_files(paths, customer_override="", account_override="", bank_hint="auto", classification_enabled=True):
|
||||
metas = []
|
||||
frames = []
|
||||
@@ -447,6 +629,7 @@ def analyze_files(paths, customer_override="", account_override="", bank_hint="a
|
||||
frames.append(df)
|
||||
combined = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
|
||||
all_df = _ensure_analysis_columns(enrich(combined, classification_enabled))
|
||||
all_df = detect_interbank_contra_pairs(all_df)
|
||||
|
||||
# IMPORTANT ACCOUNTING CONTROL:
|
||||
# Duplicate detection is advisory only. A bank may legitimately contain two
|
||||
@@ -598,7 +781,10 @@ def _workbook_columns(df):
|
||||
"statement_id", "transaction_date", "value_date", "narration", "transfer_bank_code", "transfer_reference",
|
||||
"transfer_comment", "auto_party", "party_match_method", "party_match_confidence",
|
||||
"auto_category", "auto_nature", "auto_group", "matched_rule_id", "matched_keyword",
|
||||
"suggested_ledger", "rule_confidence", "review_required", "mode", "direction", "debit", "credit",
|
||||
"suggested_ledger", "rule_confidence", "review_required",
|
||||
"contra_pair_id", "contra_status", "contra_counter_statement_id", "contra_counter_bank",
|
||||
"contra_counter_account", "contra_confidence", "contra_reason",
|
||||
"mode", "direction", "debit", "credit",
|
||||
"balance", "reference_no", "bank_name", "customer_name", "account_number", "source_file",
|
||||
"source_page", "parser_name", "exact_duplicate", "possible_duplicate", "duplicate_group_id",
|
||||
"duplicate_reason", "duplicate_confidence", "review_note",
|
||||
@@ -900,6 +1086,7 @@ def export_excel(output, metas, all_df, unique_df, financial_year="", selected_b
|
||||
exact_export.to_excel(writer, sheet_name="Exact Duplicates", index=False)
|
||||
possible_export.to_excel(writer, sheet_name="Possible Duplicates", index=False)
|
||||
duplicate_summary(all_df).to_excel(writer, sheet_name="Duplicate Summary", index=False)
|
||||
interbank_contra_summary(unique_df).to_excel(writer, sheet_name="Interbank Contra Matches", index=False)
|
||||
|
||||
# Masters first so validation ranges exist.
|
||||
max_master = max(len(categories), len(parties), len(natures), len(groups), 1)
|
||||
|
||||
@@ -265,6 +265,7 @@ def _process_job(job_id: str) -> None:
|
||||
"exact_duplicate_rows": int(all_df["exact_duplicate"].fillna(False).astype(bool).sum()) if not all_df.empty and "exact_duplicate" in all_df.columns else 0,
|
||||
"possible_duplicate_rows": int(all_df["possible_duplicate"].fillna(False).astype(bool).sum()) if not all_df.empty and "possible_duplicate" in all_df.columns else 0,
|
||||
"review_items": int(unique_df.review_note.fillna("").ne("").sum()) if not unique_df.empty and "review_note" in unique_df.columns else 0,
|
||||
"contra_pairs": len({str(v) for v in unique_df["contra_pair_id"].fillna("") if str(v).strip()}) if not unique_df.empty and "contra_pair_id" in unique_df.columns else 0,
|
||||
"categories": int(unique_df.category.nunique()) if not unique_df.empty and "category" in unique_df.columns else 0,
|
||||
"banks": sorted({meta.bank_name for meta in metas}),
|
||||
"customer_name": next((meta.customer_name for meta in metas if meta.customer_name), ""),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="mx-auto max-w-6xl space-y-6">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div><h1 class="text-2xl font-bold text-slate-900">Bank Statement Analyzer</h1><p class="mt-2 text-sm text-slate-600">Upload supported PDF statements. Up to three analyses run globally at one time; additional jobs are queued safely.</p></div>
|
||||
<div><h1 class="text-2xl font-bold text-slate-900">Bank Statement Analyzer</h1><p class="mt-2 text-sm text-slate-600">Upload one or several PDF statements for the same client. Multiple bank accounts can be analysed together so equal-and-opposite inter-bank transfers can be identified as probable contra. Up to three analyses run globally at one time; additional jobs are queued safely.</p></div>
|
||||
<a href="/tools/bank-statement-analyzer/jobs" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">My Analysis Jobs</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,7 +19,7 @@
|
||||
<div><label class="mb-1 block text-sm font-semibold text-slate-700">Account number override <span class="font-normal text-slate-400">(optional)</span></label><input name="account_number" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Use only when statement extraction needs correction"></div>
|
||||
</div>
|
||||
<div class="mt-5 rounded-xl border border-slate-200 bg-slate-50 p-4"><label class="flex items-start gap-3"><input type="checkbox" name="enable_classification" value="1" {% if classification_enabled %}checked{% endif %} class="mt-1 h-4 w-4 rounded border-slate-300"><span><span class="block text-sm font-semibold text-slate-800">Enable narration-based transaction classification</span><span class="mt-1 block text-xs text-slate-500">Adds category, party, review and draft financial helper sheets. Final classification must be verified with books and supporting records.</span></span></label></div>
|
||||
<div class="mt-5"><label class="mb-1 block text-sm font-semibold text-slate-700">PDF bank statements</label><input type="file" name="statements" accept="application/pdf,.pdf" multiple required class="block w-full rounded-xl border border-slate-300 bg-white px-3 py-3 text-sm"></div>
|
||||
<div class="mt-5 rounded-xl border border-brand-200 bg-brand-50 p-4 text-sm text-slate-700"><strong>Multi-bank contra detection:</strong> when the client has more than one bank account, upload all relevant statements in the same analysis job. The analyzer pairs only conservative equal-and-opposite transfers across different account numbers and keeps every pair reviewable.</div><div class="mt-5"><label class="mb-1 block text-sm font-semibold text-slate-700">PDF bank statements</label><input type="file" name="statements" accept="application/pdf,.pdf" multiple required class="block w-full rounded-xl border border-slate-300 bg-white px-3 py-3 text-sm"></div>
|
||||
<div class="mt-5 rounded-xl bg-slate-50 p-4 text-sm text-slate-600"><div class="font-semibold text-slate-800">Queue limits</div><div class="mt-1">Maximum three processing jobs across all users. Each user may have up to three queued or processing jobs. Completed workbooks and original uploaded statements remain available for 24 hours. Failed-job statements are also retained for 24 hours for debugging.</div></div>
|
||||
<div class="mt-6 flex flex-wrap gap-3"><button class="rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-brand-700">Submit Analysis</button></div>
|
||||
</form>
|
||||
@@ -30,7 +30,7 @@
|
||||
<div class="mt-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"><div class="rounded-xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Files</div><div class="mt-1 text-lg font-bold">{{ active_job.file_count }}</div></div><div class="rounded-xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Queue position</div><div id="queue-position" class="mt-1 text-lg font-bold">{{ active_job.queue_position or '—' }}</div></div><div class="rounded-xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Estimated wait</div><div id="estimated-wait" class="mt-1 text-sm font-bold">{{ active_job.estimated_wait or '—' }}</div></div><div class="rounded-xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Progress</div><div id="progress-text" class="mt-1 text-lg font-bold">{{ active_job.progress_percent }}%</div></div></div>
|
||||
<div class="mt-4 h-2 overflow-hidden rounded-full bg-slate-200"><div id="progress-bar" class="h-full bg-brand-600 transition-all" style="width: {{ active_job.progress_percent }}%"></div></div>
|
||||
{% if active_job.status == 'completed' %}
|
||||
<div class="mt-6 rounded-xl border border-emerald-200 bg-emerald-50 p-5"><h3 class="font-bold text-emerald-900">Analysis completed</h3><div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"><div><div class="text-xs text-emerald-700">Statements</div><div class="font-bold">{{ active_job.summary.statement_count or 0 }}</div></div><div><div class="text-xs text-emerald-700">Transactions extracted</div><div class="font-bold">{{ active_job.summary.rows_extracted or 0 }}</div></div><div><div class="text-xs text-emerald-700">Exact duplicates</div><div class="font-bold">{{ active_job.summary.exact_duplicate_rows or 0 }}</div></div><div><div class="text-xs text-emerald-700">Review items</div><div class="font-bold">{{ active_job.summary.review_items or 0 }}</div></div></div><div class="mt-5 flex flex-wrap gap-3"><a href="/tools/bank-statement-analyzer/jobs/{{ active_job.id }}/download" class="rounded-xl bg-emerald-700 px-5 py-2.5 text-sm font-semibold text-white">Download Excel</a>{% for file in active_job.original_files %}<a href="/tools/bank-statement-analyzer/jobs/{{ active_job.id }}/statements/{{ file.index }}/download" class="rounded-xl border border-emerald-300 bg-white px-5 py-2.5 text-sm font-semibold text-emerald-800">Download Statement {{ file.index }}</a>{% endfor %}<a href="/tools/bank-statement-analyzer" class="rounded-xl border border-emerald-300 bg-white px-5 py-2.5 text-sm font-semibold text-emerald-800">Analyze Another Bank</a></div><p class="mt-3 text-xs text-emerald-700">The workbook and original statement{{ 's' if active_job.file_count != 1 else '' }} remain available until {{ active_job.expires_at or '24 hours after completion' }}.</p></div>
|
||||
<div class="mt-6 rounded-xl border border-emerald-200 bg-emerald-50 p-5"><h3 class="font-bold text-emerald-900">Analysis completed</h3><div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"><div><div class="text-xs text-emerald-700">Statements</div><div class="font-bold">{{ active_job.summary.statement_count or 0 }}</div></div><div><div class="text-xs text-emerald-700">Transactions extracted</div><div class="font-bold">{{ active_job.summary.rows_extracted or 0 }}</div></div><div><div class="text-xs text-emerald-700">Exact duplicates</div><div class="font-bold">{{ active_job.summary.exact_duplicate_rows or 0 }}</div></div><div><div class="text-xs text-emerald-700">Review items</div><div class="font-bold">{{ active_job.summary.review_items or 0 }}</div></div><div><div class="text-xs text-emerald-700">Inter-bank contra pairs</div><div class="font-bold">{{ active_job.summary.contra_pairs or 0 }}</div></div></div><div class="mt-5 flex flex-wrap gap-3"><a href="/tools/bank-statement-analyzer/jobs/{{ active_job.id }}/download" class="rounded-xl bg-emerald-700 px-5 py-2.5 text-sm font-semibold text-white">Download Excel</a>{% for file in active_job.original_files %}<a href="/tools/bank-statement-analyzer/jobs/{{ active_job.id }}/statements/{{ file.index }}/download" class="rounded-xl border border-emerald-300 bg-white px-5 py-2.5 text-sm font-semibold text-emerald-800">Download Statement {{ file.index }}</a>{% endfor %}<a href="/tools/bank-statement-analyzer" class="rounded-xl border border-emerald-300 bg-white px-5 py-2.5 text-sm font-semibold text-emerald-800">Analyze Another Bank</a></div><p class="mt-3 text-xs text-emerald-700">The workbook and original statement{{ 's' if active_job.file_count != 1 else '' }} remain available until {{ active_job.expires_at or '24 hours after completion' }}.</p></div>
|
||||
{% elif active_job.status == 'failed' %}<div class="mt-5 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800"><strong>Analysis failed.</strong><div class="mt-1">{{ active_job.error_message }}</div>{% if active_job.original_files %}<div class="mt-4 flex flex-wrap gap-3">{% for file in active_job.original_files %}<a href="/tools/bank-statement-analyzer/jobs/{{ active_job.id }}/statements/{{ file.index }}/download" class="rounded-xl border border-red-300 bg-white px-4 py-2 text-sm font-semibold text-red-800">Download Statement {{ file.index }}</a>{% endfor %}</div><p class="mt-3 text-xs text-red-700">Original statement{{ 's are' if active_job.file_count != 1 else ' is' }} retained until {{ active_job.expires_at or '24 hours after failure' }} for debugging.</p>{% endif %}<a href="/tools/bank-statement-analyzer" class="mt-3 inline-block font-semibold underline">Analyze another statement</a></div>
|
||||
{% else %}<div id="live-message" class="mt-5 rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800">{% if active_job.status == 'queued' %}Your job is queued. You may safely leave this page and return through My Analysis Jobs.{% else %}Your statements are being processed.{% endif %}</div>{% endif %}
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user