from __future__ import annotations from .shared import ( Any, CROSS_TRANSFER_MATCH_TAG, CROSS_TRANSFER_REVIEW_TAG, _append_memo_tag, _memo_has_tag, _normalize_counterparty_for_match, _party_match_score, current_kst_date, date, datetime, db, fail, normalize_date, now_iso, ok, re, read_db, read_operation, row_to_dict, ) from .review_decision_service import attach_review_decisions def _build_cross_transfer_index(transactions: list[dict[str, Any]]) -> dict[tuple[str, int, str], list[dict[str, Any]]]: index: dict[tuple[str, int, str], list[dict[str, Any]]] = {} for tx in transactions: if tx.get("status") != "active": continue key = (tx["transaction_date"], int(tx["amount"]), tx["type"]) index.setdefault(key, []).append(tx) return index def _fetch_transfer_candidates(db_conn: Any, source: str) -> list[dict[str, Any]]: rows = db_conn.execute( """ SELECT t.id, t.transaction_datetime, t.transaction_date, t.type, t.amount, t.category, t.subcategory, t.counterparty, t.memo, t.person, t.account_id, t.payment_method, t.status, a.holder_name, t.merchant FROM transactions t LEFT JOIN accounts a ON a.id = t.account_id WHERE t.source=? AND t.type IN ('income','expense') AND ( t.category='이체' OR t.subcategory IN ('확인필요', '본인계좌이동', '투자이동') OR t.payment_method IN ('내계좌간자동이전', '내계좌간자동이체', '오픈뱅킹', '펌뱅킹출금', '펌뱅킹입금', '입금', '출금', 'ATM출금', '적금개설') OR t.merchant LIKE '%이체%' ) """, (source,), ).fetchall() return [dict(r) for r in rows] def _parse_tx_datetime(value: str | None) -> datetime | None: if not value: return None text = value.strip() for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"): try: return datetime.strptime(text, fmt) except ValueError: continue try: return datetime.fromisoformat(text) except ValueError: return None def _self_transfer_direction_score(source_type: str, candidate_type: str) -> int: if source_type == "expense": if candidate_type == "income": return 3 if candidate_type == "transfer": return 2 elif source_type == "income": if candidate_type == "expense": return 3 if candidate_type == "transfer": return 2 elif source_type == "transfer": if candidate_type in {"income", "expense"}: return 2 if candidate_type == "transfer": return 1 return 0 def _normalize_cancel_lookup_text(value: str) -> str: if not value: return "" text = value.lower().strip() replacements = [ "kb체크취소", "승인취소", "취소", "환불", "환급", "refund", "reversal", "카드입금", "카드환급", "매출취소", "입금", "출금", "체크카드", "신용카드", ] for token in replacements: text = text.replace(token, " ") text = re.sub(r"\s+", " ", text) return _normalize_counterparty_for_match(text) def _cancel_channel(value: str) -> str: text = (value or "").lower() if any(token in text for token in ("카드", "check", "credit")): return "card" if any(token in text for token in ("이체", "오픈뱅킹", "출금", "입금", "bank")): return "bank" return "other" def _is_cancel_like_transaction(tx: dict[str, Any]) -> bool: if str(tx.get("type") or "") == "refund": return True combined = " ".join( str(tx.get(key) or "") for key in ("merchant", "raw_summary", "payment_method", "memo", "description") ).lower() keywords = ("취소", "환불", "환급", "refund", "reversal", "카드입금", "매출취소") return str(tx.get("type") or "") == "income" and any(token in combined for token in keywords) def find_cancel_original_candidates(transaction_id: str, limit: int = 20, lookback_days: int = 365, db_path: str | None = None) -> dict[str, Any]: limit = max(1, min(int(limit), 50)) lookback_days = max(1, min(int(lookback_days), 730)) with db(db_path) as conn: source_row = conn.execute( """ SELECT t.*, a.name AS account_name, a.institution, a.holder_name FROM transactions t LEFT JOIN accounts a ON a.id = t.account_id WHERE t.id=? """, (transaction_id,), ).fetchone() if not source_row: return fail("not_found", f"transaction not found: {transaction_id}") source = row_to_dict(source_row) explicit_cancel_like = _is_cancel_like_transaction(source) source_type = str(source.get("type") or "") if source_type not in {"income", "refund"} and not explicit_cancel_like: return fail("not_cancel_like", "cancel/original lookup is intended for 취소·환급성 거래") source_date = normalize_date(source.get("transaction_date"), default_today=False) source_dt = _parse_tx_datetime(source.get("transaction_datetime")) source_day = date.fromisoformat(source_date) start_date = date.fromordinal(source_day.toordinal() - lookback_days).isoformat() source_text = " ".join(str(source.get(key) or "") for key in ("merchant", "counterparty", "raw_summary", "payment_method")) source_norm_text = _normalize_cancel_lookup_text(source_text) source_channel = _cancel_channel(str(source.get("payment_method") or "") + " " + str(source.get("raw_summary") or "") + " " + str(source.get("merchant") or "")) candidate_rows = conn.execute( """ SELECT t.*, a.name AS account_name, a.institution, a.holder_name FROM transactions t LEFT JOIN accounts a ON a.id = t.account_id WHERE t.status='active' AND t.id<>? AND t.amount=? AND t.transaction_date BETWEEN ? AND ? AND t.type IN ('expense', 'adjustment') ORDER BY t.transaction_date DESC, t.transaction_datetime DESC, t.created_at DESC """, (transaction_id, int(source.get("amount") or 0), start_date, source_date), ).fetchall() candidates: list[dict[str, Any]] = [] for row in candidate_rows: candidate = row_to_dict(row) candidate_date = normalize_date(candidate.get("transaction_date"), default_today=False) candidate_day = date.fromisoformat(candidate_date) day_delta = (source_day - candidate_day).days if day_delta < 0: continue candidate_dt = _parse_tx_datetime(candidate.get("transaction_datetime")) time_delta_seconds = abs(int((source_dt - candidate_dt).total_seconds())) if source_dt and candidate_dt else None same_account = bool(source.get("account_id") and source.get("account_id") == candidate.get("account_id")) source_same = bool(source.get("source") and source.get("source") == candidate.get("source")) candidate_text = " ".join(str(candidate.get(key) or "") for key in ("merchant", "counterparty", "raw_summary", "payment_method")) candidate_norm_text = _normalize_cancel_lookup_text(candidate_text) merchant_score = 0 if source_norm_text and candidate_norm_text: if source_norm_text == candidate_norm_text: merchant_score = 4 elif source_norm_text in candidate_norm_text or candidate_norm_text in source_norm_text: merchant_score = 2 channel_score = 0 candidate_channel = _cancel_channel(str(candidate.get("payment_method") or "") + " " + str(candidate.get("raw_summary") or "") + " " + str(candidate.get("merchant") or "")) if source_channel != "other" and source_channel == candidate_channel: channel_score = 2 if not explicit_cancel_like and not (same_account and merchant_score >= 2 and channel_score >= 1): continue recency_score = 0 if day_delta == 0: recency_score = 5 elif day_delta <= 7: recency_score = 4 elif day_delta <= 30: recency_score = 3 elif day_delta <= 90: recency_score = 2 else: recency_score = 1 time_score = 0 if time_delta_seconds is not None: if time_delta_seconds <= 3600: time_score = 2 elif time_delta_seconds <= 86400: time_score = 1 score = merchant_score + channel_score + recency_score + time_score + (3 if same_account else 0) + (1 if source_same else 0) reason_bits = [ f"same_account={int(same_account)}", f"merchant={merchant_score}", f"channel={channel_score}", f"days={day_delta}", ] if time_delta_seconds is not None: reason_bits.append(f"time_delta={time_delta_seconds}s") candidates.append({ "id": candidate.get("id"), "transaction_date": candidate.get("transaction_date"), "transaction_datetime": candidate.get("transaction_datetime"), "source": candidate.get("source"), "type": candidate.get("type"), "amount": candidate.get("amount"), "category": candidate.get("category"), "subcategory": candidate.get("subcategory"), "merchant": candidate.get("merchant"), "raw_summary": candidate.get("raw_summary"), "counterparty": candidate.get("counterparty"), "account_id": candidate.get("account_id"), "account_name": candidate.get("account_name"), "institution": candidate.get("institution"), "payment_method": candidate.get("payment_method"), "memo": candidate.get("memo"), "score": score, "day_delta": day_delta, "time_delta_seconds": time_delta_seconds, "reason": ", ".join(reason_bits), }) candidates.sort(key=lambda item: (-int(item.get("score", 0)), int(item.get("day_delta", 9999)), int(item.get("time_delta_seconds") or 99999999), str(item.get("transaction_datetime") or ""))) return ok(source_tx=source, count=len(candidates[:limit]), candidates=candidates[:limit], source="cancel_original_candidate_search") def find_self_transfer_candidates(transaction_id: str, limit: int = 20, db_path: str | None = None) -> dict[str, Any]: limit = max(1, min(int(limit), 50)) with db(db_path) as conn: source_row = conn.execute( """ SELECT t.*, a.name AS account_name, a.institution, a.holder_name FROM transactions t LEFT JOIN accounts a ON a.id = t.account_id WHERE t.id=? """, (transaction_id,), ).fetchone() if not source_row: return fail("not_found", f"transaction not found: {transaction_id}") source = row_to_dict(source_row) source_date = normalize_date(source.get("transaction_date"), default_today=False) source_dt = _parse_tx_datetime(source.get("transaction_datetime")) source_holder = _normalize_counterparty_for_match(source.get("holder_name", "")) anchor_date = date.fromisoformat(source_date) if source_date else current_kst_date() window_start = anchor_date.toordinal() - 1 window_end = anchor_date.toordinal() + 1 candidate_rows = conn.execute( """ SELECT t.*, a.name AS account_name, a.institution, a.holder_name FROM transactions t LEFT JOIN accounts a ON a.id = t.account_id WHERE t.status='active' AND t.id<>? AND COALESCE(t.account_id, '')<>COALESCE(?, '') AND t.amount=? AND t.transaction_date BETWEEN ? AND ? ORDER BY t.transaction_date, t.transaction_datetime, t.created_at """, ( transaction_id, source.get("account_id", ""), int(source.get("amount") or 0), date.fromordinal(window_start).isoformat(), date.fromordinal(window_end).isoformat(), ), ).fetchall() candidates: list[dict[str, Any]] = [] for row in candidate_rows: candidate = row_to_dict(row) direction_score = _self_transfer_direction_score(str(source.get("type", "")), str(candidate.get("type", ""))) if direction_score == 0: continue candidate_dt = _parse_tx_datetime(candidate.get("transaction_datetime")) day_delta = abs((date.fromisoformat(candidate.get("transaction_date", source_date)) - date.fromisoformat(source_date)).days) time_delta_seconds = abs(int((candidate_dt - source_dt).total_seconds())) if candidate_dt and source_dt else None same_owner = bool(source_holder and source_holder == _normalize_counterparty_for_match(candidate.get("holder_name", ""))) party_score = max( _party_match_score(str(source.get("counterparty", "")), str(candidate.get("counterparty", ""))), _party_match_score(str(source.get("counterparty", "")), str(candidate.get("holder_name", ""))), _party_match_score(str(source.get("merchant", "")), str(candidate.get("counterparty", ""))), _party_match_score(str(source.get("merchant", "")), str(candidate.get("holder_name", ""))), ) source_gap_score = 1 if source.get("source") != candidate.get("source") else 0 day_score = 3 if day_delta == 0 else 1 time_score = 0 if time_delta_seconds is not None: if time_delta_seconds <= 120: time_score = 3 elif time_delta_seconds <= 3600: time_score = 2 elif time_delta_seconds <= 86400: time_score = 1 score = direction_score + party_score + source_gap_score + day_score + time_score + (3 if same_owner else 0) reason_bits = [ f"direction={direction_score}", f"party={party_score}", f"same_owner={int(same_owner)}", f"same_source={int(source.get('source') == candidate.get('source'))}", f"day_delta={day_delta}", ] if time_delta_seconds is not None: reason_bits.append(f"time_delta={time_delta_seconds}s") candidates.append( { "id": candidate.get("id"), "transaction_date": candidate.get("transaction_date"), "transaction_datetime": candidate.get("transaction_datetime"), "source": candidate.get("source"), "type": candidate.get("type"), "amount": candidate.get("amount"), "category": candidate.get("category"), "subcategory": candidate.get("subcategory"), "merchant": candidate.get("merchant"), "counterparty": candidate.get("counterparty"), "account_id": candidate.get("account_id"), "account_name": candidate.get("account_name"), "institution": candidate.get("institution"), "holder_name": candidate.get("holder_name"), "memo": candidate.get("memo"), "score": score, "same_owner": same_owner, "day_delta": day_delta, "time_delta_seconds": time_delta_seconds, "reason": ", ".join(reason_bits), } ) candidates.sort(key=lambda item: (-int(item.get("score", 0)), int(item.get("day_delta", 99)), int(item.get("time_delta_seconds") or 999999), str(item.get("transaction_datetime") or ""))) return ok(source_tx=source, count=len(candidates[:limit]), candidates=candidates[:limit], source="self_transfer_candidate_search") def link_self_transfer_pair(source_transaction_id: str, candidate_transaction_id: str, db_path: str | None = None) -> dict[str, Any]: if not source_transaction_id or not candidate_transaction_id: return fail("validation_error", "both transaction ids are required") if source_transaction_id == candidate_transaction_id: return fail("validation_error", "source and candidate transaction must differ") preview = find_self_transfer_candidates(source_transaction_id, limit=50, db_path=db_path) if not preview.get("success"): return preview candidates = preview.get("candidates") or [] candidate = next((item for item in candidates if item.get("id") == candidate_transaction_id), None) if candidate is None: return fail("candidate_not_found", f"candidate not found for source transaction: {candidate_transaction_id}") link_tag = f"manual_self_transfer_link:{source_transaction_id}|{candidate_transaction_id}" ts = now_iso() with db(db_path) as conn: source_row = conn.execute("SELECT * FROM transactions WHERE id=?", (source_transaction_id,)).fetchone() candidate_row = conn.execute("SELECT * FROM transactions WHERE id=?", (candidate_transaction_id,)).fetchone() if not source_row or not candidate_row: return fail("not_found", "source or candidate transaction not found") source_memo = _append_memo_tag(source_row["memo"], link_tag) candidate_memo = _append_memo_tag(candidate_row["memo"], link_tag) conn.execute( "UPDATE transactions SET category='이체', subcategory='본인계좌이동', memo=?, updated_at=? WHERE id=?", (source_memo, ts, source_transaction_id), ) conn.execute( "UPDATE transactions SET category='이체', subcategory='본인계좌이동', memo=?, updated_at=? WHERE id=?", (candidate_memo, ts, candidate_transaction_id), ) updated_source = conn.execute("SELECT * FROM transactions WHERE id=?", (source_transaction_id,)).fetchone() updated_candidate = conn.execute("SELECT * FROM transactions WHERE id=?", (candidate_transaction_id,)).fetchone() return ok( source_transaction=row_to_dict(updated_source), candidate_transaction=row_to_dict(updated_candidate), link_tag=link_tag, score=candidate.get("score"), reason=candidate.get("reason"), source="manual_self_transfer_link", ) def link_cancel_to_original(cancel_transaction_id: str, original_transaction_id: str, db_path: str | None = None) -> dict[str, Any]: if not cancel_transaction_id or not original_transaction_id: return fail("validation_error", "both transaction ids are required") if cancel_transaction_id == original_transaction_id: return fail("validation_error", "cancel and original transaction must differ") preview = find_cancel_original_candidates(cancel_transaction_id, limit=50, db_path=db_path) if not preview.get("success"): return preview candidates = preview.get("candidates") or [] original = next((item for item in candidates if item.get("id") == original_transaction_id), None) if original is None: return fail("candidate_not_found", f"original transaction candidate not found: {original_transaction_id}") link_tag = f"manual_cancel_link:cancel={cancel_transaction_id}|original={original_transaction_id}" ts = now_iso() with db(db_path) as conn: cancel_row = conn.execute("SELECT * FROM transactions WHERE id=?", (cancel_transaction_id,)).fetchone() original_row = conn.execute("SELECT * FROM transactions WHERE id=?", (original_transaction_id,)).fetchone() if not cancel_row or not original_row: return fail("not_found", "cancel or original transaction not found") cancel_memo = _append_memo_tag(cancel_row["memo"], link_tag) original_memo = _append_memo_tag(original_row["memo"], link_tag) original_category = original_row["category"] or "수입" original_subcategory = original_row["subcategory"] or "환급" original_person = original_row["person"] or "" conn.execute( "UPDATE transactions SET type='refund', category=?, subcategory=?, person=?, memo=?, updated_at=? WHERE id=?", (original_category, original_subcategory, original_person, cancel_memo, ts, cancel_transaction_id), ) conn.execute( "UPDATE transactions SET memo=?, updated_at=? WHERE id=?", (original_memo, ts, original_transaction_id), ) updated_cancel = conn.execute("SELECT * FROM transactions WHERE id=?", (cancel_transaction_id,)).fetchone() updated_original = conn.execute("SELECT * FROM transactions WHERE id=?", (original_transaction_id,)).fetchone() return ok( cancel_transaction=row_to_dict(updated_cancel), original_transaction=row_to_dict(updated_original), link_tag=link_tag, score=original.get("score"), reason=original.get("reason"), source="manual_cancel_link", ) def _tag_memos(conn: Any, tx_id: str, tag: str) -> None: existing = conn.execute("SELECT memo FROM transactions WHERE id=?", (tx_id,)).fetchone() if not existing: return memo = _append_memo_tag(existing["memo"], tag) conn.execute("UPDATE transactions SET memo=?, updated_at=? WHERE id=?", (memo, now_iso(), tx_id)) def _run_cross_account_transfer_match(limit: int = 500, dry_run: bool = True, db_path: str | None = None) -> dict[str, Any]: limit = max(1, min(int(limit), 1000)) matched_pairs: list[dict[str, Any]] = [] review_items: list[dict[str, Any]] = [] matched_tx_ids: set[str] = set() connection_context = db if not dry_run else read_db with connection_context(db_path) as conn: kb_rows = _fetch_transfer_candidates(conn, "kb_bank_pdf") toss_rows = _fetch_transfer_candidates(conn, "toss_bank_pdf") toss_index = _build_cross_transfer_index(toss_rows) for kb in kb_rows: if kb["status"] != "active": continue if _memo_has_tag(kb.get("memo", ""), CROSS_TRANSFER_MATCH_TAG): continue required_type = "income" if kb["type"] == "expense" else "expense" candidates = [ t for t in toss_index.get((kb["transaction_date"], int(kb["amount"]), required_type), []) if t.get("status") == "active" and not _memo_has_tag(t.get("memo", ""), CROSS_TRANSFER_MATCH_TAG) ] if not candidates: review_items.append( { "source_tx_id": kb["id"], "source": "kb_bank_pdf", "target_tx_id": "", "reason": "no_candidate_found", "type": kb["type"], "amount": kb["amount"], "transaction_date": kb["transaction_date"], "counterparty": kb["counterparty"], } ) continue scored = [] for candidate in candidates: score = _party_match_score(kb.get("counterparty", ""), candidate.get("counterparty", "")) scored.append((score, candidate["transaction_datetime"], candidate)) scored.sort(key=lambda item: (item[0], item[1]), reverse=True) best_score = scored[0][0] top = [c for c in scored if c[0] == best_score] if best_score == 0 or len(top) != 1: review_items.append( { "source_tx_id": kb["id"], "source": "kb_bank_pdf", "target_tx_id": "", "reason": "ambiguous_or_name_mismatch", "candidate_count": len(candidates), "best_name_score": best_score, "type": kb["type"], "amount": kb["amount"], "transaction_date": kb["transaction_date"], "counterparty": kb["counterparty"], } ) continue toss = top[0][2] if toss["id"] in matched_tx_ids: review_items.append( { "source_tx_id": kb["id"], "source": "kb_bank_pdf", "target_tx_id": toss["id"], "reason": "target_already_matched", "type": kb["type"], "amount": kb["amount"], "transaction_date": kb["transaction_date"], "counterparty": kb["counterparty"], } ) continue kb_party = _normalize_counterparty_for_match(kb.get("counterparty", "")) toss_party = _normalize_counterparty_for_match(toss.get("counterparty", "")) owner_candidates = { _normalize_counterparty_for_match(kb.get("holder_name", "")), _normalize_counterparty_for_match(toss.get("holder_name", "")), } if (kb_party and kb_party in owner_candidates) or (toss_party and toss_party in owner_candidates): subcategory = "투자이동" else: subcategory = "확인필요" matched_pairs.append( { "kb_tx_id": kb["id"], "toss_tx_id": toss["id"], "transaction_date": kb["transaction_date"], "amount": kb["amount"], "counterparty_kb": kb["counterparty"], "counterparty_toss": toss["counterparty"], "subcategory": subcategory, "name_score": best_score, "reason": "paired_by_date_amount_party", } ) if not dry_run: review_tag = f"{CROSS_TRANSFER_MATCH_TAG}kb={kb['id']}|toss={toss['id']}" conn.execute( "UPDATE transactions SET category='이체', subcategory=?, memo=?, updated_at=? WHERE id=?", (subcategory, _append_memo_tag(kb.get("memo", ""), review_tag), now_iso(), kb["id"]), ) conn.execute( "UPDATE transactions SET category='이체', subcategory=?, memo=?, updated_at=? WHERE id=?", (subcategory, _append_memo_tag(toss.get("memo", ""), review_tag), now_iso(), toss["id"]), ) if not dry_run: _tag_memos(conn, kb["id"], f"{CROSS_TRANSFER_REVIEW_TAG}paired") _tag_memos(conn, toss["id"], f"{CROSS_TRANSFER_REVIEW_TAG}paired") matched_tx_ids.add(kb["id"]) matched_tx_ids.add(toss["id"]) if len(matched_pairs) >= limit: break for review in review_items: if review["source_tx_id"] in matched_tx_ids: continue if not dry_run: _tag_memos(conn, review["source_tx_id"], f"{CROSS_TRANSFER_REVIEW_TAG}{review['reason']}") return ok( dry_run=dry_run, matched_count=len(matched_pairs), review_count=len(review_items), matched_pairs=matched_pairs[:limit], review_items=review_items[:limit], source="kb_toss_cross_transfer_match", ) @read_operation def preview_cross_account_transfers(limit: int = 500, db_path: str | None = None) -> dict[str, Any]: return _run_cross_account_transfer_match(limit=limit, dry_run=True, db_path=db_path) def run_cross_account_transfer_match(limit: int = 500, db_path: str | None = None) -> dict[str, Any]: return _run_cross_account_transfer_match(limit=limit, dry_run=False, db_path=db_path) @read_operation def list_cross_transfer_review_queue(limit: int = 300, db_path: str | None = None) -> dict[str, Any]: clauses = ["t.status='active'", "t.source IN ('kb_bank_pdf','toss_bank_pdf')"] clauses.append("t.memo LIKE '%cross_transfer_review:%'") where = " WHERE " + " AND ".join(clauses) with read_db(db_path) as conn: rows = conn.execute( f"SELECT * FROM transactions t{where} ORDER BY t.transaction_date DESC, t.created_at DESC LIMIT ?", (max(1, min(int(limit), 1000)),), ).fetchall() transactions = attach_review_decisions(conn, [row_to_dict(r) for r in rows], "cross_transfer", "id") return ok(transactions=transactions, count=len(transactions), source="cross_transfer_review_queue")