c03b3f2659
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1207 lines
56 KiB
Python
Executable File
1207 lines
56 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
from __future__ import annotations
|
||
|
||
"""whooing-sync: iMessage 결제/입출금 알림 -> 후잉 웹훅 자동 전송.
|
||
|
||
전략:
|
||
1. confirmed=true 발신번호 메시지만 대상.
|
||
2. parsers.py로 구조화 시도. 카테고리 매칭(merchant_map) 성공 시 structured POST.
|
||
3. 파싱/매칭 실패 시 raw 폴백 (후잉 자체 파서에 위임).
|
||
4. dedupe는 created_at 기준.
|
||
"""
|
||
import argparse
|
||
import json
|
||
import re
|
||
import sys
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from zoneinfo import ZoneInfo
|
||
|
||
import parsers as sms_parsers
|
||
import imsg_cli
|
||
import notify
|
||
import whooing_balance
|
||
import gahee_reminder
|
||
|
||
# 후잉 웹훅은 HTTP 200을 반환하면서 본문에 "fail"/"Error : ..." 를 줄 수 있어 본문 검증 필수.
|
||
SUCCESS_BODY = "done"
|
||
|
||
|
||
def _infer_raw_details(text: str, parsed: dict | None = None) -> tuple[str, str]:
|
||
"""raw 폴백 알림용 금액/상호명 요약.
|
||
|
||
SMS 원문 전체는 텔레그램 anti-spam 오탐 위험이 있어 보내지 않고,
|
||
분류 확인에 필요한 금액과 상호명만 best-effort 로 추출한다.
|
||
"""
|
||
if parsed:
|
||
amount = parsed.get("amount")
|
||
merchant = (parsed.get("merchant") or "").strip()
|
||
amount_s = f"{int(amount):,}원" if amount else "금액불명"
|
||
return amount_s, merchant or "상호명불명"
|
||
|
||
amount_s = "금액불명"
|
||
m = re.search(r"(\d[\d,]*)\s*원", text or "")
|
||
if m:
|
||
amount_s = f"{int(m.group(1).replace(',', '')):,}원"
|
||
|
||
# 흔한 카드 승인 문자는 금액 다음 줄이 가맹점인 경우가 많다.
|
||
lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
|
||
merchant = "상호명불명"
|
||
for i, line in enumerate(lines):
|
||
if re.search(r"\d[\d,]*\s*원", line):
|
||
for cand in lines[i + 1:i + 4]:
|
||
if any(skip in cand for skip in ("누적", "잔액", "일시불", "승인", "취소")):
|
||
continue
|
||
merchant = cand
|
||
break
|
||
break
|
||
return amount_s, merchant
|
||
|
||
|
||
def _format_raw_fallback(new_raw: list) -> str:
|
||
"""raw 폴백 처리된 건들 → 텔레그램 메시지(HTML).
|
||
|
||
SMS 원문은 의도적으로 제외. 결제 SMS 형식이 텔레그램 anti-spam smishing 필터에 오탐돼 봇 동결을 유발할 수 있음. 원문 디버깅은 iMessage DB / state 로그에서.
|
||
"""
|
||
header = f"⚠️ <b>후잉 raw 폴백 {len(new_raw)}건</b>"
|
||
blocks = []
|
||
for r in new_raw[:5]:
|
||
label = notify.escape_html(r["label"])
|
||
when = notify.format_kst(r["created_at"])
|
||
amount_s, merchant = _infer_raw_details(r.get("text", ""), r.get("parsed"))
|
||
merchant = notify.escape_html(merchant)
|
||
blocks.append(f"\n• <b>{label}</b> · {when} · <b>{amount_s}</b> · {merchant}")
|
||
more = f"\n<i>외 {len(new_raw)-5}건 더 있음.</i>" if len(new_raw) > 5 else ""
|
||
footer = "\n\n<i>structured 분류 실패 — parsers.py / carrier_to_account / merchant_map 점검 필요.</i>"
|
||
return header + "".join(blocks) + more + footer
|
||
|
||
|
||
def _format_balance_mismatch(info: dict, sms_b: int, whoo_b: int, diff: int, prev_diff,
|
||
recovery_hint=None) -> str:
|
||
"""SMS 잔액(자산) 또는 누적사용액(부채/카드)과 후잉 잔액 차이 → 텔레그램 메시지(HTML)."""
|
||
label = notify.escape_html(info["label"])
|
||
acct = notify.escape_html(info["account"])
|
||
side = info.get("side", "asset")
|
||
kind_word = "누적사용액" if side == "liability" else "잔액"
|
||
actual_whoo_b = info.get("actual_whooing_balance", whoo_b)
|
||
payment_offset = info.get("liability_payment_offset", 0) or 0
|
||
has_offset = bool(payment_offset and actual_whoo_b != whoo_b)
|
||
matched_word = "후잉(카드대금 보정 후)" if has_offset else "후잉"
|
||
|
||
if diff == 0:
|
||
recovered = abs(prev_diff) if prev_diff is not None else 0
|
||
lines = [
|
||
f"✅ <b>{kind_word} 정합 회복</b>",
|
||
"",
|
||
f"📌 <b>{label}</b> ({acct})",
|
||
f"• SMS · {matched_word}: <b>{sms_b:,}원</b> (일치)",
|
||
]
|
||
if recovered:
|
||
lines.append(f"• 불일치 해소: <b>{recovered:,}원</b>")
|
||
if recovery_hint:
|
||
lines.append("─────────────")
|
||
lines.append(f"• 마지막 반영 거래: <b>{notify.escape_html(recovery_hint)}</b>")
|
||
return "\n".join(lines)
|
||
|
||
sign = "+" if diff > 0 else ""
|
||
if side == "liability":
|
||
direction = "결제 누락 의심" if diff > 0 else "사용 거래 누락 의심"
|
||
else:
|
||
direction = "출금 누락 또는 입금 중복" if diff > 0 else "입금 누락 의심"
|
||
lines = [
|
||
f"⚠️ <b>{kind_word} 불일치</b>",
|
||
"",
|
||
f"📌 <b>{label}</b> ({acct})",
|
||
f"• SMS {kind_word}: <b>{sms_b:,}원</b>",
|
||
]
|
||
if has_offset:
|
||
lines.append(
|
||
f"• 후잉 {kind_word}: <b>{actual_whoo_b:,}원</b>"
|
||
f" + 카드대금 보정 <b>{payment_offset:,}원</b>"
|
||
f" = <b>{whoo_b:,}원</b>"
|
||
)
|
||
else:
|
||
lines.append(f"• 후잉 {kind_word}: <b>{whoo_b:,}원</b>")
|
||
lines.append(f"• 차이: <b>{sign}{diff:,}원</b> → {direction}")
|
||
if prev_diff is not None and prev_diff != diff:
|
||
lines.append(f"• 직전 차이: {prev_diff:+,}원 (변동)")
|
||
lines.append("")
|
||
lines.append("<i>ℹ️ SMS 누락·잘못 분개·기간 외 거래 등 점검이 필요해요.</i>")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _format_sync_failure(f: dict) -> str:
|
||
"""SMS 동기화 실패 1건 → 텔레그램 메시지(HTML)."""
|
||
label = notify.escape_html(f["label"])
|
||
when = notify.format_kst(f["created_at"])
|
||
reason = notify.escape_html(notify.clean_reason(f.get("response", ""), f.get("status", 0)))
|
||
payload = f.get("payload") or {}
|
||
mode = f.get("mode")
|
||
|
||
header = f"❌ <b>후잉 동기화 실패</b>\n└ {label} · {when}"
|
||
|
||
if mode == "structured":
|
||
item = notify.escape_html(payload.get("item", ""))
|
||
money = payload.get("money", "")
|
||
try:
|
||
money_fmt = f"{int(money):,}원"
|
||
except Exception:
|
||
money_fmt = f"{money}원"
|
||
left = notify.escape_html(payload.get("left", ""))
|
||
right = notify.escape_html(payload.get("right", ""))
|
||
body_block = (
|
||
f"\n\n💳 <b>{left}</b> ← <b>{right}</b>"
|
||
f"\n {money_fmt} · {item}"
|
||
)
|
||
else:
|
||
body_block = "\n\n📩 <i>raw 폴백 — SMS 원문은 텔레그램 비표시 (anti-spam 회피)</i>"
|
||
|
||
reason_block = f"\n\n⚠️ <b>사유</b>\n{reason}"
|
||
footer = "\n\n<i>다음 cron 까지 대기 중. 동일 실패 반복되면 failures.json 확인.</i>"
|
||
return header + body_block + reason_block + footer
|
||
|
||
KST = ZoneInfo("Asia/Seoul")
|
||
ROOT = Path("/Users/snowoyh/.openclaw")
|
||
WORKSPACE = ROOT / "agents" / "budget" / "workspace"
|
||
CREDENTIALS = ROOT / "credentials" / "whooing.json"
|
||
ACCOUNT_MAP_FILE = WORKSPACE / "state" / "whooing_account_map.json"
|
||
ACCOUNTS_FILE = WORKSPACE / "state" / "whooing_accounts.json"
|
||
MERCHANT_MAP_FILE = WORKSPACE / "state" / "whooing_merchant_map.json"
|
||
OVERRIDES_FILE = WORKSPACE / "state" / "whooing_overrides.json"
|
||
SYNC_STATE_FILE = WORKSPACE / "state" / "whooing_synced.json"
|
||
FAILURES_FILE = WORKSPACE / "state" / "whooing_failures.json"
|
||
BALANCE_ALERT_FILE = WORKSPACE / "state" / "whooing_balance_alerts.json"
|
||
CHAT_IDS_CACHE = WORKSPACE / "state" / "whooing_chat_ids.json"
|
||
# 한국 공휴일은 stock agent의 KRX 휴장일 파일을 그대로 재사용 (read-only).
|
||
# 파일 부재/오류 시 토·일만 비영업일로 보는 fallback 동작.
|
||
HOLIDAYS_FILE = ROOT / "agents" / "stock" / "workspace" / "state" / "market_holidays.json"
|
||
IMSG_TIMEOUT = 15
|
||
HISTORY_LIMIT = 100
|
||
|
||
# 본인 계좌 간 이체 pair 매칭 (출금 SMS + 입금 SMS 같은 금액, 시간창 내).
|
||
# 창 안에 짝이 오면 1건의 자산↔자산 이체로 합성 POST.
|
||
PAIR_WINDOW_SECONDS = 300
|
||
# 페어 미발견 입출금은 이 나이까지는 hold (아직 상대편 SMS 도착 전일 수 있음).
|
||
# 이 시간이 지났는데도 짝이 없으면 개별 SMS로 처리 (외부 송금/입금으로 간주).
|
||
HOLD_GRACE_SECONDS = 300
|
||
|
||
# 결제/입출금이 아닌 메시지(광고·공지·이벤트)를 후잉으로 보내지 않기 위한 패턴.
|
||
SKIP_PATTERNS = (
|
||
"(광고)",
|
||
"[광고]",
|
||
"광고)",
|
||
"이벤트",
|
||
"안내드립니다",
|
||
"수신거부",
|
||
"인증번호",
|
||
"간편인증서",
|
||
"개설되었습니다",
|
||
"계좌개설",
|
||
)
|
||
|
||
# 후잉 처리 대상은 금액이 있는 결제/입금/출금 알림으로 제한한다.
|
||
# 확인/인증/계좌개설 안내처럼 confirmed 발신번호에서 온 비거래 문자가 raw 로 들어가는 것을 막는다.
|
||
# 후잉 처리 대상 금액 패턴. 신한은행 SMS는 "출금 20"처럼 "원" 없이 오는 경우가 있다.
|
||
MONEY_AMOUNT_RE = re.compile(r"(?:\d[\d,]*\s*원|(?:입금|출금)\s+\d[\d,]*)")
|
||
|
||
# 주유소 카드 결제는 보통 150,000원 선승인 → 실제 주유금액 승인 → 150,000원 승인취소 순서로 온다.
|
||
# 선승인/취소는 후잉에 기록하지 않고, 실제 결제만 차량유지비/주유비로 남긴다.
|
||
# "현대가스"·"한경에너"는 상호에 주유/석유가 안 들어가는 충전소라 직접 등록 (2026-08-03 관리자님 확인).
|
||
# 새 주유소를 만나면 여기와 whooing_merchant_map.json contains **양쪽에** 상호를 추가한다 —
|
||
# 이 상수는 선승인/취소 스킵, merchant_map 은 실제 주유 결제 분류로 한 쌍이다.
|
||
# ⚠️ 한경에너는 선승인이 149,900원이라 FUEL_PREAUTH_AMOUNT 정확일치엔 안 걸린다(분류만 적용).
|
||
FUEL_KEYWORDS = ("석유", "주유소", "주유", "현대가스", "한경에너")
|
||
FUEL_PREAUTH_AMOUNT = 150_000
|
||
|
||
|
||
def _is_fuel_text(text: str) -> bool:
|
||
return any(k in (text or "") for k in FUEL_KEYWORDS)
|
||
|
||
|
||
def _should_skip_fuel_preauth(parsed: dict | None, text: str) -> bool:
|
||
"""주유소 15만원 선승인/취소 스킵.
|
||
|
||
parsed 가 있으면 구조화 필드 기준으로 판단한다. 파서가 못 읽은 형식은 폴백으로
|
||
raw 텍스트에서 '취소' + '150,000원' + 주유 키워드 조합만 보수적으로 스킵한다.
|
||
"""
|
||
if parsed:
|
||
merchant = parsed.get("merchant") or ""
|
||
if (
|
||
parsed.get("kind") in ("card_approval", "card_cancel")
|
||
and parsed.get("amount") == FUEL_PREAUTH_AMOUNT
|
||
and _is_fuel_text(merchant)
|
||
):
|
||
return True
|
||
compact = (text or "").replace(" ", "")
|
||
return _is_fuel_text(text) and "취소" in text and "150,000원" in compact
|
||
|
||
|
||
def _should_skip_hyundai_insurance_duplicate(c: dict, balance_alerts: dict) -> bool:
|
||
"""이미 월 1회 보정된 현대카드 라이나생명 31,100원 지연 SMS는 중복 raw 전송하지 않는다."""
|
||
info = c.get("info") or {}
|
||
if info.get("carrier") != "hyundai_card":
|
||
return False
|
||
text = c.get("text") or ""
|
||
compact = text.replace(" ", "")
|
||
if not ("라이나생명" in text and "31,100원" in compact):
|
||
return False
|
||
state = (balance_alerts.get("carriers") or {}).get("hyundai_card") or {}
|
||
try:
|
||
msg_month = _parse_iso_utc(c.get("created_at") or "").astimezone(KST).strftime("%Y-%m")
|
||
except Exception:
|
||
msg_month = datetime.now(KST).strftime("%Y-%m")
|
||
return state.get("last_hyundai_insurance_month") == msg_month
|
||
|
||
|
||
def load_json(path, default):
|
||
if path.exists():
|
||
try:
|
||
return json.loads(path.read_text())
|
||
except Exception:
|
||
return default
|
||
return default
|
||
|
||
|
||
def save_json(path, data):
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2))
|
||
|
||
|
||
def load_webhook_url():
|
||
cred = load_json(CREDENTIALS, {})
|
||
url = (cred.get("webhook_url") or "").strip()
|
||
if not url:
|
||
print("❌ credentials/whooing.json 의 webhook_url 이 비어 있습니다.", file=sys.stderr)
|
||
sys.exit(2)
|
||
return url
|
||
|
||
|
||
def resolve_chat_ids(carriers):
|
||
cached = load_json(CHAT_IDS_CACHE, {})
|
||
cached_keys = set(cached.keys())
|
||
needed = set(carriers.keys())
|
||
if needed.issubset(cached_keys):
|
||
return {k: cached[k] for k in needed}
|
||
chats, err = imsg_cli.chats(IMSG_TIMEOUT)
|
||
if err:
|
||
print(f"❌ imsg chats 실행 실패: {err}", file=sys.stderr)
|
||
sys.exit(3)
|
||
result = {}
|
||
for sender in carriers.keys():
|
||
ids = [c["id"] for c in chats if c.get("identifier") == sender and c.get("id") is not None]
|
||
result[sender] = ids
|
||
# 전 carrier 가 빈 리스트면 조회 자체가 실패한 것으로 본다. 이 상태를 캐시에 저장하면
|
||
# 이후 모든 실행(launchd 포함)이 캐시에 적중해 조용히 0건 수집을 영구 반복한다.
|
||
# 일부만 비는 건 정상 — 메시지 이력이 없는 carrier 가 있을 수 있다.
|
||
if not any(result.values()):
|
||
print("❌ imsg chats 결과에 매핑된 발신번호가 하나도 없습니다 — 캐시 저장 안 함", file=sys.stderr)
|
||
sys.exit(3)
|
||
save_json(CHAT_IDS_CACHE, result)
|
||
return result
|
||
|
||
|
||
def fetch_messages(chat_id, since):
|
||
args = ["history", "--chat-id", str(chat_id), "--limit", str(HISTORY_LIMIT), "--json"]
|
||
if since:
|
||
args += ["--start", since]
|
||
rows, err = imsg_cli.run_json(args, IMSG_TIMEOUT)
|
||
if err:
|
||
print(f"⚠️ chat-id {chat_id} history 실패: {err}", file=sys.stderr)
|
||
return []
|
||
return rows
|
||
|
||
|
||
# --- 룰 엔진 (whooing_overrides.json) -----------------------------------------
|
||
# build_structured 보다 먼저 평가되는 우선 룰. 정기결제·시간대 기반 분개 등.
|
||
# 매처 종류는 _rule_matches() 참고. 새 매처 추가 시 거기 케이스만 늘리면 된다.
|
||
|
||
_HOLIDAYS_CACHE: "set[str] | None" = None
|
||
|
||
|
||
def _load_holidays() -> "set[str]":
|
||
"""KRX 휴장일 set (YYYY-MM-DD). 파일 부재/오류 시 빈 set."""
|
||
global _HOLIDAYS_CACHE
|
||
if _HOLIDAYS_CACHE is None:
|
||
try:
|
||
with open(HOLIDAYS_FILE, encoding="utf-8") as f:
|
||
_HOLIDAYS_CACHE = set(json.load(f).get("holidays", {}).keys())
|
||
except Exception:
|
||
_HOLIDAYS_CACHE = set()
|
||
return _HOLIDAYS_CACHE
|
||
|
||
|
||
def _is_non_business_day(d) -> bool:
|
||
"""토·일·공휴일 → True."""
|
||
return d.weekday() >= 5 or d.isoformat() in _load_holidays()
|
||
|
||
|
||
def _matches_scheduled_day(dt_kst: datetime, day: int, policy: str) -> bool:
|
||
"""결제일 매처. policy:
|
||
- "exact": 그 달의 day 와 거래일이 같아야 매칭.
|
||
- "next_weekday": day 가 영업일이면 그 날만, 비영업일(토·일·공휴일)이면 [원래일, 다음 영업일] 윈도우 모두 매칭.
|
||
"""
|
||
try:
|
||
target = dt_kst.replace(day=day, hour=0, minute=0, second=0, microsecond=0)
|
||
except ValueError:
|
||
return False # 그 달에 그 일이 없는 경우 (예: 2/30)
|
||
if policy == "exact":
|
||
return dt_kst.date() == target.date()
|
||
if policy == "next_weekday":
|
||
if not _is_non_business_day(target.date()):
|
||
return dt_kst.date() == target.date()
|
||
rolled = target
|
||
while _is_non_business_day(rolled.date()):
|
||
rolled = rolled + timedelta(days=1)
|
||
return target.date() <= dt_kst.date() <= rolled.date()
|
||
return False
|
||
|
||
|
||
def _in_time_window_kst(dt_kst: datetime, start: str, end: str) -> bool:
|
||
"""start/end: "HH:MM" KST. 양 끝 포함."""
|
||
def to_min(s):
|
||
h, m = s.split(":")
|
||
return int(h) * 60 + int(m)
|
||
cur = dt_kst.hour * 60 + dt_kst.minute
|
||
return to_min(start) <= cur <= to_min(end)
|
||
|
||
|
||
def _rule_matches(match: dict, parsed: dict, carrier: str, merchant: str,
|
||
merchant_map: dict, dt_kst: datetime) -> bool:
|
||
if "kind" in match and parsed.get("kind") != match["kind"]:
|
||
return False
|
||
if "merchant_contains" in match and match["merchant_contains"] not in merchant:
|
||
return False
|
||
if "merchant_regex" in match and not re.match(match["merchant_regex"], merchant):
|
||
return False
|
||
if match.get("merchant_unmapped"):
|
||
rule_hit, cat_hit = lookup_category(merchant, merchant_map)
|
||
if rule_hit or cat_hit:
|
||
return False
|
||
if "amount_eq" in match and int(parsed.get("amount") or 0) != int(match["amount_eq"]):
|
||
return False
|
||
if "carrier_in" in match and carrier not in match["carrier_in"]:
|
||
return False
|
||
if "weekday_in" in match and dt_kst.weekday() not in match["weekday_in"]:
|
||
return False
|
||
if "scheduled_day_of_month" in match:
|
||
if not _matches_scheduled_day(
|
||
dt_kst, int(match["scheduled_day_of_month"]),
|
||
match.get("weekend_policy", "exact"),
|
||
):
|
||
return False
|
||
if "time_kst_between" in match:
|
||
s, e = match["time_kst_between"]
|
||
if not _in_time_window_kst(dt_kst, s, e):
|
||
return False
|
||
return True
|
||
|
||
|
||
def _render_post(post: dict, parsed: dict, ctx: dict):
|
||
"""post 의 left/right/item/memo 문자열에 ctx 변수 치환. left 비면 None 반환 (룰 무효).
|
||
|
||
입금(deposit) 룰에서는 post.left 를 수익/상대 계정으로 해석해
|
||
실제 후잉 분개는 carrier 자산 ← post.left 형태로 만든다.
|
||
"""
|
||
def fmt(v):
|
||
if not isinstance(v, str):
|
||
return v
|
||
for k, val in ctx.items():
|
||
v = v.replace("{" + k + "}", str(val))
|
||
return v
|
||
|
||
left = fmt(post.get("left"))
|
||
if not left:
|
||
return None
|
||
item = fmt(post.get("item")) or ctx["merchant"]
|
||
memo = fmt(post.get("memo"))
|
||
if memo is None:
|
||
memo = parsed.get("raw", "")
|
||
if parsed.get("kind") == "deposit":
|
||
right = left
|
||
left = fmt(post.get("right")) or ctx["carrier_account"]
|
||
else:
|
||
right = fmt(post.get("right")) or ctx["carrier_account"]
|
||
return {
|
||
"entry_date": parsed["entry_date"],
|
||
"money": parsed["amount"],
|
||
"item": item,
|
||
"left": left,
|
||
"right": right,
|
||
"memo": (memo or "")[:200],
|
||
}
|
||
|
||
|
||
def apply_overrides(rules: list, parsed: dict, sender_info: dict,
|
||
accounts: dict, merchant_map: dict, created_at: str):
|
||
"""whooing_overrides.json rules 를 위에서 아래로 평가, 첫 매칭 룰의 post 를 후잉 structured payload 로 변환.
|
||
|
||
매칭 실패 / parsed 없음 / carrier_to_account 미등록 / left 미지정 → None (build_structured 폴백).
|
||
"""
|
||
if not parsed or not rules:
|
||
return None
|
||
carrier = sender_info.get("carrier")
|
||
carrier_acct = accounts.get("carrier_to_account", {}).get(carrier, "")
|
||
if not carrier_acct:
|
||
return None
|
||
try:
|
||
dt_kst = datetime.fromisoformat(created_at.replace("Z", "+00:00")).astimezone(KST)
|
||
except Exception:
|
||
return None
|
||
|
||
merchant = (parsed.get("merchant") or "").strip()
|
||
ctx = {
|
||
"carrier_account": carrier_acct,
|
||
"merchant": merchant,
|
||
"raw": parsed.get("raw", ""),
|
||
"amount": str(parsed.get("amount", 0)),
|
||
}
|
||
for rule in rules:
|
||
if not rule.get("enabled", True):
|
||
continue
|
||
if not _rule_matches(rule.get("match", {}), parsed, carrier, merchant, merchant_map, dt_kst):
|
||
continue
|
||
return _render_post(rule.get("post", {}), parsed, ctx)
|
||
return None
|
||
|
||
|
||
def lookup_category(merchant: str, merchant_map: dict):
|
||
"""가맹점/메모 → (rule|None, contains_category|None).
|
||
rule은 exact 매칭(dict), contains_category는 키워드 매칭(str).
|
||
contains 값이 dict 인 경우엔 exact rule 처럼 (dict, None) 으로 반환한다."""
|
||
rule = merchant_map.get("exact", {}).get(merchant)
|
||
if rule:
|
||
return rule, None
|
||
for keyword, value in (merchant_map.get("contains") or {}).items():
|
||
if keyword in merchant:
|
||
if isinstance(value, dict):
|
||
return value, None
|
||
return None, value
|
||
return None, None
|
||
|
||
|
||
def build_structured(parsed, sender_info, accounts, merchant_map):
|
||
"""파싱 결과 + 매핑 → 후잉 structured payload. 매칭 실패 시 None (raw 폴백)."""
|
||
carrier_key = sender_info.get("carrier")
|
||
carrier_acct = accounts.get("carrier_to_account", {}).get(carrier_key, "")
|
||
if not carrier_acct:
|
||
return None
|
||
|
||
merchant = parsed["merchant"]
|
||
rule, cat = lookup_category(merchant, merchant_map)
|
||
|
||
base = {
|
||
"entry_date": parsed["entry_date"],
|
||
"money": parsed["amount"],
|
||
"item": merchant,
|
||
"memo": parsed["raw"][:200],
|
||
}
|
||
|
||
kind = parsed["kind"]
|
||
|
||
if kind == "card_approval":
|
||
if rule:
|
||
left = rule.get("left")
|
||
right = rule.get("right") or carrier_acct
|
||
if left:
|
||
out = {**base, "left": left, "right": right}
|
||
if rule.get("item"):
|
||
out["item"] = rule["item"]
|
||
return out
|
||
return None
|
||
if cat:
|
||
return {**base, "left": cat, "right": carrier_acct}
|
||
return {**base, "left": "기타비용", "right": carrier_acct}
|
||
|
||
# card_cancel 은 여기서 payload 를 만들지 않는다 — 처리 루프의 _handle_card_cancel 이
|
||
# 원본 승인 거래를 찾아 PUT 으로 감액한다. 좌우를 뒤집은 역분개는 후잉이 거부한다
|
||
# (비용 계정은 차변 전용 → `r_account 잘못된 값입니다`) — 2026-08-03 회귀의 원인.
|
||
|
||
if kind == "withdrawal":
|
||
if rule:
|
||
left = rule.get("left")
|
||
right = rule.get("right") or carrier_acct
|
||
if left:
|
||
out = {**base, "left": left, "right": right}
|
||
if rule.get("item"):
|
||
out["item"] = rule["item"]
|
||
return out
|
||
return None
|
||
if cat:
|
||
return {**base, "left": cat, "right": carrier_acct}
|
||
return {**base, "left": "기타비용", "right": carrier_acct}
|
||
|
||
if kind == "deposit":
|
||
if rule:
|
||
# 입금 시 left=carrier자산, right=수익/송금처
|
||
counterpart = rule.get("left")
|
||
if counterpart:
|
||
out = {**base, "left": carrier_acct, "right": counterpart}
|
||
if rule.get("item"):
|
||
out["item"] = rule["item"]
|
||
return out
|
||
return None
|
||
return None # 자동 입금 분류는 안전하게 raw 폴백
|
||
|
||
return None
|
||
|
||
|
||
def _handle_card_cancel(parsed, sender_info, accounts, dry_run=False):
|
||
"""카드 취소 → 원본 승인 거래를 찾아 취소액만큼 감액.
|
||
|
||
반환 (ok, detail). ok=False 면 원본을 특정하지 못한 것이니 호출측이 raw 폴백으로 넘긴다.
|
||
⚠️ 폴백은 자동 처리가 아니다 — 후잉은 취소 문자를 받아도 원본을 지워주지 않으므로
|
||
(공식 FAQ 도 환불은 수동 처리하라고 안내한다) 관리자님이 후잉 UI 에서 손봐야 한다.
|
||
그래서 폴백 건은 raw 폴백 알림에 그대로 실어 보낸다.
|
||
|
||
전액취소면 금액이 0 이 되어 줄은 남는다. 후잉 API 로는 거래를 지울 수 없기 때문이다
|
||
(whooing_balance.api_request 주석 참고). 잔액·합계에는 영향이 없다.
|
||
"""
|
||
carrier_acct = accounts.get("carrier_to_account", {}).get(sender_info.get("carrier"), "")
|
||
if not carrier_acct:
|
||
return False, "카드 계정 매핑 없음"
|
||
|
||
money = parsed["amount"]
|
||
cands = whooing_balance.find_card_entries(
|
||
carrier_acct, money, parsed["merchant"], parsed["entry_date"]
|
||
)
|
||
if len(cands) != 1:
|
||
return False, f"원본 승인 후보 {len(cands)}건 — 1건일 때만 감액"
|
||
|
||
orig = cands[0]
|
||
new_money = (orig.get("money") or 0) - money
|
||
detail = (f"{orig.get('item')} {orig.get('money'):,}원 → {new_money:,}원 "
|
||
f"(취소 {money:,}원, entry {orig['entry_id']})")
|
||
if dry_run:
|
||
return True, f"(dry-run) {detail}"
|
||
whooing_balance.set_entry_money(orig, new_money)
|
||
return True, detail
|
||
|
||
|
||
def _parse_iso_utc(iso: str) -> datetime:
|
||
return datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||
|
||
|
||
def detect_pairs(candidates, accounts):
|
||
"""자기 계좌 간 이체 pair 를 찾아 structured 이체 payload 로 변환.
|
||
|
||
기준:
|
||
- 한쪽은 withdrawal, 다른 쪽은 deposit
|
||
- 금액 동일
|
||
- created_at 차이 < PAIR_WINDOW_SECONDS
|
||
- 양쪽 모두 carrier_to_account 매핑이 있어야 함 (자산 계정명 확보)
|
||
|
||
반환:
|
||
pairs: [(c_withdraw, c_deposit, payload), ...]
|
||
used_idx: set — candidates 내 인덱스 중 pair 로 소비된 것
|
||
"""
|
||
pairs = []
|
||
used = set()
|
||
ctoa = accounts.get("carrier_to_account", {})
|
||
|
||
for i, ci in enumerate(candidates):
|
||
if i in used or not ci.get("parsed"):
|
||
continue
|
||
pi = ci["parsed"]
|
||
if pi["kind"] not in ("withdrawal", "deposit"):
|
||
continue
|
||
opposite = "deposit" if pi["kind"] == "withdrawal" else "withdrawal"
|
||
dti = _parse_iso_utc(ci["created_at"])
|
||
|
||
for j, cj in enumerate(candidates):
|
||
if j == i or j in used or not cj.get("parsed"):
|
||
continue
|
||
pj = cj["parsed"]
|
||
if pj["kind"] != opposite or pj["amount"] != pi["amount"]:
|
||
continue
|
||
dtj = _parse_iso_utc(cj["created_at"])
|
||
if abs((dti - dtj).total_seconds()) > PAIR_WINDOW_SECONDS:
|
||
continue
|
||
|
||
# pair 확정. 송금/수신 구분.
|
||
c_w, c_d = (ci, cj) if pi["kind"] == "withdrawal" else (cj, ci)
|
||
acct_w = ctoa.get(c_w["info"]["carrier"])
|
||
acct_d = ctoa.get(c_d["info"]["carrier"])
|
||
if not acct_w or not acct_d:
|
||
break # 자산 계정명 중 하나라도 비면 이체 구성 불가 → 개별 처리로 폴백
|
||
if acct_w == acct_d:
|
||
break # 같은 계좌끼리 pair 는 의미 없음
|
||
|
||
balance_parts = []
|
||
if c_w["parsed"].get("balance") is not None:
|
||
balance_parts.append(f"{c_w['info']['label']} {c_w['parsed']['balance']:,}원")
|
||
if c_d["parsed"].get("balance") is not None:
|
||
balance_parts.append(f"{c_d['info']['label']} {c_d['parsed']['balance']:,}원")
|
||
payload = {
|
||
"entry_date": c_w["parsed"]["entry_date"],
|
||
"money": c_w["parsed"]["amount"],
|
||
"item": "이체",
|
||
"left": acct_d, # 수신 자산 증가 (차변)
|
||
"right": acct_w, # 송신 자산 감소 (대변)
|
||
"memo": ("잔액 : " + " / ".join(balance_parts)) if balance_parts else "",
|
||
}
|
||
pairs.append((c_w, c_d, payload))
|
||
used.add(i)
|
||
used.add(j)
|
||
break
|
||
return pairs, used
|
||
|
||
|
||
def post_to_whooing(webhook_url, payload, dry_run=False):
|
||
"""payload는 dict. 반환: (ok: bool, status: int, body: str).
|
||
HTTP 2xx + body가 'done' 으로 시작해야 성공. 그 외(HTTP 200 + 'fail' 포함) 모두 실패."""
|
||
if dry_run:
|
||
return True, 200, "dry-run"
|
||
# structured 모드는 memo 비우면 후잉이 거절. 비면 공백 1개로 정규화.
|
||
if "memo" in payload and not str(payload.get("memo", "")).strip():
|
||
payload = {**payload, "memo": " "}
|
||
# 후잉은 '+' 를 공백으로 디코드하지 않음. quote_via=quote 로 공백을 %20 으로 보내야 함.
|
||
encoded = urllib.parse.urlencode(payload, quote_via=urllib.parse.quote).encode()
|
||
req = urllib.request.Request(webhook_url, data=encoded, method="POST")
|
||
# 후잉 파서는 '; charset=utf-8' 가 붙으면 거절. 순수 미디어타입만 보냄.
|
||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=15) as r:
|
||
body = r.read().decode("utf-8", errors="replace")
|
||
ok = (200 <= r.status < 300) and body.strip().lower().startswith(SUCCESS_BODY)
|
||
return ok, r.status, body
|
||
except urllib.error.HTTPError as e:
|
||
body = e.read().decode("utf-8", errors="replace") if hasattr(e, "read") else str(e)
|
||
return False, e.code, body
|
||
except Exception as e:
|
||
return False, 0, str(e)
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--dry-run", action="store_true", help="후잉 POST 안 함, 계획만 출력")
|
||
args = ap.parse_args()
|
||
|
||
webhook_url = load_webhook_url() if not args.dry_run else "(dry-run)"
|
||
account_map = load_json(ACCOUNT_MAP_FILE, {}).get("carriers", {})
|
||
accounts = load_json(ACCOUNTS_FILE, {})
|
||
merchant_map = load_json(MERCHANT_MAP_FILE, {})
|
||
overrides = load_json(OVERRIDES_FILE, {"rules": []}).get("rules", [])
|
||
sync_state = load_json(SYNC_STATE_FILE, {"last_message_at": None})
|
||
balance_alerts = load_json(BALANCE_ALERT_FILE, {"carriers": {}})
|
||
failures = load_json(FAILURES_FILE, {"failures": []})
|
||
failures.setdefault("failures", [])
|
||
|
||
def run_gahee_reminder():
|
||
# 가희 잔액 리마인더는 결제 SMS 유무와 독립적으로 매 sync 사이클마다 확인한다.
|
||
gahee_reminder.run(webhook_url, post_to_whooing, dry_run=args.dry_run)
|
||
|
||
confirmed = {k: v for k, v in account_map.items() if v.get("confirmed")}
|
||
if not confirmed:
|
||
print("🟡 confirmed=true 인 발신번호가 없습니다.")
|
||
run_gahee_reminder()
|
||
return
|
||
|
||
chat_ids_by_sender = resolve_chat_ids(confirmed)
|
||
last_message_at = sync_state.get("last_message_at") or ""
|
||
latest_skip = ""
|
||
|
||
candidates = []
|
||
for sender, info in confirmed.items():
|
||
ids = chat_ids_by_sender.get(sender) or []
|
||
if not ids:
|
||
print(f"🟡 {info['label']}({sender}) 대화방 없음")
|
||
continue
|
||
for chat_id in ids:
|
||
for msg in fetch_messages(chat_id, last_message_at):
|
||
text = (msg.get("text") or "").strip()
|
||
created = msg.get("created_at") or ""
|
||
if not text or not created:
|
||
continue
|
||
if last_message_at and created <= last_message_at:
|
||
continue
|
||
if any(p in text for p in SKIP_PATTERNS) or not MONEY_AMOUNT_RE.search(text):
|
||
print(f" ⊘ [skip] {info['label']} {created} | {text[:50].replace(chr(10),' ')}")
|
||
if created > (last_message_at or ""):
|
||
latest_skip = created
|
||
continue
|
||
candidates.append({
|
||
"sender": sender, "info": info,
|
||
"created_at": created, "text": text,
|
||
})
|
||
|
||
candidates.sort(key=lambda x: x["created_at"])
|
||
|
||
if not candidates:
|
||
# 광고만 있고 결제는 없을 수도 있으니, latest_skip을 last_message_at으로 반영
|
||
if latest_skip and latest_skip > last_message_at and not args.dry_run:
|
||
sync_state["last_message_at"] = latest_skip
|
||
sync_state["last_synced_at"] = datetime.now(KST).isoformat()
|
||
save_json(SYNC_STATE_FILE, sync_state)
|
||
print(f"🟢 새 결제 메시지 없음 (last={latest_skip or last_message_at or '없음'})")
|
||
run_gahee_reminder()
|
||
return
|
||
|
||
# 모든 후보를 먼저 파싱해서 pair 탐지에 쓴다. (기존엔 루프 안에서 lazy parse)
|
||
for c in candidates:
|
||
c["parsed"] = sms_parsers.parse(c["sender"], c["text"], c["created_at"])
|
||
|
||
# 주유소 150,000원 선승인/취소와 이미 월 보정된 현대카드 보험료 지연 SMS는 후잉 기록 대상이 아니다.
|
||
# pair 탐지/개별 처리 전에 후보에서 제거하고 커서는 진행한다.
|
||
filtered_candidates = []
|
||
for c in candidates:
|
||
if _should_skip_fuel_preauth(c.get("parsed"), c.get("text", "")):
|
||
print(f" ⊘ [skip-fuel-preauth] {c['info']['label']} {c['created_at']} | {c['text'][:50].replace(chr(10),' ')}")
|
||
if c["created_at"] > (latest_skip or ""):
|
||
latest_skip = c["created_at"]
|
||
continue
|
||
if _should_skip_hyundai_insurance_duplicate(c, balance_alerts):
|
||
print(f" ⊘ [skip-hyundai-insurance-duplicate] {c['info']['label']} {c['created_at']} | 라이나생명 31,100원 이미 월 보정됨")
|
||
if c["created_at"] > (latest_skip or ""):
|
||
latest_skip = c["created_at"]
|
||
continue
|
||
filtered_candidates.append(c)
|
||
candidates = filtered_candidates
|
||
|
||
if not candidates:
|
||
if latest_skip and latest_skip > last_message_at and not args.dry_run:
|
||
sync_state["last_message_at"] = latest_skip
|
||
sync_state["last_synced_at"] = datetime.now(KST).isoformat()
|
||
save_json(SYNC_STATE_FILE, sync_state)
|
||
print(f"🟢 새 결제 메시지 없음 (last={latest_skip or last_message_at or '없음'})")
|
||
run_gahee_reminder()
|
||
return
|
||
|
||
pairs, used_idx = detect_pairs(candidates, accounts)
|
||
now_utc = datetime.now(timezone.utc)
|
||
|
||
sent_transfer = 0
|
||
sent_structured = 0
|
||
sent_raw = 0
|
||
sent_cancel = 0
|
||
failed = 0
|
||
new_failures = []
|
||
new_raw = [] # raw 폴백으로 성공 처리된 건 (구조화 실패 신호) — 텔레그램 알림용
|
||
recovery_hints: dict[str, str] = {}
|
||
liability_accounts = set(accounts.get("categories", {}).get("부채", []))
|
||
liability_payment_offsets_to_add: dict[str, int] = {}
|
||
latest = last_message_at
|
||
blocked_at = None # 첫 실패/hold 의 created_at — 이후 메시지는 처리/커서 진행 모두 중단
|
||
|
||
# 1) 페어 이체부터 처리 (한 건의 자산↔자산 이체 엔트리로 POST)
|
||
for c_w, c_d, payload in pairs:
|
||
if blocked_at:
|
||
break
|
||
payload_str = {k: str(v) for k, v in payload.items()}
|
||
display = f"{payload['left']} ← {payload['right']} {payload['money']:,}원 ({payload['item']})"
|
||
ok, status, body = post_to_whooing(webhook_url, payload_str, dry_run=args.dry_run)
|
||
pair_latest = max(c_w["created_at"], c_d["created_at"])
|
||
if ok:
|
||
sent_transfer += 1
|
||
for carrier_key in (c_w["info"].get("carrier"), c_d["info"].get("carrier")):
|
||
if carrier_key:
|
||
recovery_hints[carrier_key] = display
|
||
if pair_latest > (latest or ""):
|
||
latest = pair_latest
|
||
print(f" 🔄 [transfer] {c_w['info']['label']}→{c_d['info']['label']} {pair_latest} {status} | {display}")
|
||
else:
|
||
failed += 1
|
||
failure_record = {
|
||
"sender": f"{c_w['sender']}+{c_d['sender']}",
|
||
"label": f"{c_w['info']['label']}→{c_d['info']['label']}",
|
||
"created_at": pair_latest,
|
||
"text": payload.get("memo", ""),
|
||
"mode": "structured", "payload": payload_str,
|
||
"status": status, "response": body[:500],
|
||
"failed_at": datetime.now(KST).isoformat(),
|
||
}
|
||
new_failures.append(failure_record)
|
||
print(f" ❌ [transfer] {c_w['info']['label']}→{c_d['info']['label']} {pair_latest} {status} body={body[:120]!r}")
|
||
# 쌍 중 이른 시각 이후로 커서 잠금 (재시도 시 둘 다 다시 걸리게)
|
||
blocked_at = min(c_w["created_at"], c_d["created_at"])
|
||
|
||
# 2) 페어에 소비되지 않은 개별 SMS 처리 (단, 최근 HOLD_GRACE 이내 입출금은 hold)
|
||
for idx, c in enumerate(candidates):
|
||
if idx in used_idx:
|
||
continue
|
||
if blocked_at:
|
||
break
|
||
parsed = c.get("parsed")
|
||
|
||
# hold: 아직 상대편 SMS 가 도착할 시간이 남아 있으면 이번 사이클 보류
|
||
if parsed and parsed.get("kind") in ("withdrawal", "deposit"):
|
||
age = (now_utc - _parse_iso_utc(c["created_at"])).total_seconds()
|
||
if age < HOLD_GRACE_SECONDS:
|
||
print(f" ⏸ [hold] {c['info']['label']} {c['created_at']} — 상대편 pair 대기 (age {int(age)}s)")
|
||
blocked_at = c["created_at"]
|
||
break
|
||
|
||
# 카드 취소는 상쇄 줄을 더하지 않고 원본 승인 거래를 감액한다.
|
||
# 원본을 못 찾으면 아래 raw 폴백으로 흘러간다(build_structured 가 None 을 준다).
|
||
if parsed and parsed.get("kind") == "card_cancel":
|
||
try:
|
||
cancel_ok, cancel_detail = _handle_card_cancel(
|
||
parsed, c["info"], accounts, args.dry_run
|
||
)
|
||
except Exception as e:
|
||
failed += 1
|
||
detail = f"{type(e).__name__}: {e}"
|
||
new_failures.append({
|
||
"sender": c["sender"], "label": c["info"]["label"],
|
||
"created_at": c["created_at"], "text": c["text"],
|
||
"mode": "cancel_reduce", "payload": {},
|
||
"status": 0, "response": detail[:500],
|
||
"failed_at": datetime.now(KST).isoformat(),
|
||
})
|
||
print(f" ❌ [cancel] {c['info']['label']} {c['created_at']} | {detail[:120]}")
|
||
blocked_at = c["created_at"]
|
||
break
|
||
if cancel_ok:
|
||
sent_cancel += 1
|
||
carrier_key = c["info"].get("carrier")
|
||
if carrier_key:
|
||
recovery_hints[carrier_key] = cancel_detail
|
||
print(f" ➖ [cancel] {c['info']['label']} {c['created_at']} | {cancel_detail}")
|
||
if c["created_at"] > (latest or ""):
|
||
latest = c["created_at"]
|
||
continue
|
||
print(f" ↩︎ [cancel→raw] {c['info']['label']} {c['created_at']} | {cancel_detail}")
|
||
|
||
# card_cancel 이 여기까지 오면 감액 실패 → raw 폴백만 남았다.
|
||
# apply_overrides 는 match 에 kind 가 없는 룰을 모든 kind 에 적용하므로, 그런 룰이
|
||
# 추가되면 취소가 좌우 뒤집힌 payload 로 새어나갈 수 있어 여기서 끊는다.
|
||
structured = None
|
||
if parsed and parsed.get("kind") != "card_cancel":
|
||
structured = apply_overrides(overrides, parsed, c["info"], accounts, merchant_map, c["created_at"])
|
||
if structured is None:
|
||
structured = build_structured(parsed, c["info"], accounts, merchant_map)
|
||
|
||
if structured:
|
||
payload = {k: str(v) for k, v in structured.items()}
|
||
mode = "structured"
|
||
display = f"{structured['left']} ← {structured['right']} {structured['money']:,}원 ({structured['item']})"
|
||
else:
|
||
payload = {"message": c["text"]}
|
||
mode = "raw"
|
||
display = c["text"][:60].replace("\n", " ")
|
||
|
||
ok, status, body = post_to_whooing(webhook_url, payload, dry_run=args.dry_run)
|
||
if ok:
|
||
if mode == "structured":
|
||
sent_structured += 1
|
||
carrier_key = c["info"].get("carrier")
|
||
if carrier_key:
|
||
recovery_hints[carrier_key] = display
|
||
# 은행계좌에서 카드대금이 빠지는 건 `카드부채 ← 은행계좌`로 분개한다.
|
||
# 카드사 SMS 누적사용액은 이 상환 감소분을 별도 SMS로 알려주지 않는 경우가 있어,
|
||
# 잔액 정합성 체크에서는 이 금액을 기대 보정치로 누적해 오탐을 막는다.
|
||
if (
|
||
(parsed or {}).get("kind") == "withdrawal"
|
||
and structured.get("left") in liability_accounts
|
||
):
|
||
liability_acct = structured["left"]
|
||
liability_payment_offsets_to_add[liability_acct] = (
|
||
liability_payment_offsets_to_add.get(liability_acct, 0)
|
||
+ int(structured.get("money", 0) or 0)
|
||
)
|
||
else:
|
||
sent_raw += 1
|
||
new_raw.append({
|
||
"label": c["info"]["label"],
|
||
"created_at": c["created_at"],
|
||
"text": c["text"],
|
||
"parsed": parsed,
|
||
})
|
||
print(f" ✅ [{mode}] {c['info']['label']} {c['created_at']} {status} | {display}")
|
||
if c["created_at"] > (latest or ""):
|
||
latest = c["created_at"]
|
||
else:
|
||
failed += 1
|
||
failure_record = {
|
||
"sender": c["sender"], "label": c["info"]["label"],
|
||
"created_at": c["created_at"], "text": c["text"],
|
||
"mode": mode, "payload": payload,
|
||
"status": status, "response": body[:500],
|
||
"failed_at": datetime.now(KST).isoformat(),
|
||
}
|
||
new_failures.append(failure_record)
|
||
print(f" ❌ [{mode}] {c['info']['label']} {c['created_at']} {status} body={body[:120]!r}")
|
||
blocked_at = c["created_at"] # 커서가 이 메시지를 넘지 않게 잠금
|
||
|
||
if new_failures and not args.dry_run:
|
||
failures["failures"].extend(new_failures)
|
||
save_json(FAILURES_FILE, failures)
|
||
# 텔레그램 알림 (실패 1건당 1메시지, 최대 3건만)
|
||
for f in new_failures[:3]:
|
||
notify.send(_format_sync_failure(f))
|
||
if len(new_failures) > 3:
|
||
notify.send(
|
||
f"⚠️ <b>후잉 동기화 실패 {len(new_failures)-3}건 추가</b>\n"
|
||
f"<i>failures.json 에서 확인 부탁드립니다.</i>"
|
||
)
|
||
|
||
if new_raw and not args.dry_run:
|
||
notify.send(_format_raw_fallback(new_raw))
|
||
|
||
# 잔액 정합성 체크: 부분 처리(blocked_at) 사이클은 skip — 후잉이 모든 거래를 받지 못한 상태에서
|
||
# 비교하면 잘못된 알림이 발생할 수 있음.
|
||
if not blocked_at and not args.dry_run:
|
||
last_balance_by_carrier: dict = {}
|
||
for c in candidates:
|
||
parsed = c.get("parsed") or {}
|
||
# 자산은 parsed.balance, 부채(카드 누적)는 parsed.cumulative
|
||
if parsed.get("balance") is not None:
|
||
sms_b = parsed["balance"]
|
||
side = "asset"
|
||
elif parsed.get("cumulative") is not None:
|
||
sms_b = parsed["cumulative"]
|
||
side = "liability"
|
||
else:
|
||
continue
|
||
carrier_key = c["info"].get("carrier")
|
||
acct_name = accounts.get("carrier_to_account", {}).get(carrier_key)
|
||
if not acct_name:
|
||
continue
|
||
cur = last_balance_by_carrier.get(carrier_key)
|
||
if cur is None or c["created_at"] > cur["created_at"]:
|
||
last_balance_by_carrier[carrier_key] = {
|
||
"sms_balance": sms_b,
|
||
"side": side,
|
||
"account": acct_name,
|
||
"label": c["info"]["label"],
|
||
"created_at": c["created_at"],
|
||
}
|
||
if last_balance_by_carrier:
|
||
try:
|
||
whoo_balances = whooing_balance.fetch_balances(
|
||
[v["account"] for v in last_balance_by_carrier.values()],
|
||
sides=("assets", "liabilities"),
|
||
)
|
||
except Exception as e:
|
||
print(f"⚠️ 잔액 조회 실패 — 정합성 체크 skip: {e}")
|
||
whoo_balances = None
|
||
if whoo_balances is not None:
|
||
state = load_json(BALANCE_ALERT_FILE, {"carriers": {}})
|
||
carriers_state = state.setdefault("carriers", {})
|
||
liability_payment_offsets = state.setdefault("liability_payment_offsets", {})
|
||
for acct, amount in liability_payment_offsets_to_add.items():
|
||
if amount:
|
||
liability_payment_offsets[acct] = int(liability_payment_offsets.get(acct, 0) or 0) + amount
|
||
changed = bool(liability_payment_offsets_to_add)
|
||
for carrier, info in last_balance_by_carrier.items():
|
||
sms_b = info["sms_balance"]
|
||
actual_whoo_b = whoo_balances.get(info["account"])
|
||
if actual_whoo_b is None:
|
||
continue
|
||
liability_payment_offset = 0
|
||
if info.get("side") == "liability":
|
||
liability_payment_offset = int(liability_payment_offsets.get(info["account"], 0) or 0)
|
||
whoo_b = actual_whoo_b + liability_payment_offset
|
||
# 카드사 누적사용액이 카드대금 상환을 반영하는지는 문자 형식/시점에 따라 다르다.
|
||
# 실제 후잉 잔액이 보정 후 값보다 SMS에 더 가깝다면 보정치를 소진된 것으로 본다.
|
||
if (
|
||
liability_payment_offset
|
||
and abs(actual_whoo_b - sms_b) < abs(whoo_b - sms_b)
|
||
):
|
||
liability_payment_offsets[info["account"]] = 0
|
||
liability_payment_offset = 0
|
||
whoo_b = actual_whoo_b
|
||
changed = True
|
||
diff = whoo_b - sms_b
|
||
prev = carriers_state.get(carrier, {})
|
||
prev_diff = prev.get("last_diff")
|
||
|
||
# 은행 자동 이자/캐시백 보정: 자산 + 후잉이 SMS보다 적고(deposit 누락), 차이 ≤ 5,000원.
|
||
# 횟수·날짜 제한 없이 매번 자동 분개. 매 발화마다 텔레그램 알림으로 누적 가시성 확보.
|
||
# 정상 SMS가 와서 deposit이 후잉에 들어가면 diff>=0이 되어 안전하게 미발화.
|
||
cur_month = datetime.now(KST).strftime("%Y-%m")
|
||
if (
|
||
info.get("side") == "asset"
|
||
and carrier in {"kakaobank", "shinhan_bank", "hana_bank"}
|
||
and diff < 0
|
||
and abs(diff) <= 5000
|
||
):
|
||
interest_amount = -diff
|
||
interest_payload = {
|
||
"entry_date": datetime.now(KST).strftime("%Y%m%d"),
|
||
"money": str(interest_amount),
|
||
"item": "이자",
|
||
"left": info["account"],
|
||
"right": "기타수익",
|
||
"memo": "은행이자",
|
||
}
|
||
ok, status, body = post_to_whooing(webhook_url, interest_payload, dry_run=False)
|
||
if ok:
|
||
notify.send(
|
||
f"💰 <b>{notify.escape_html(info['label'])} 은행이자 자동 분개</b>\n"
|
||
f"+{interest_amount:,}원 ({notify.escape_html(info['account'])} ← 기타수익)"
|
||
)
|
||
carriers_state[carrier] = {
|
||
"account": info["account"],
|
||
"label": info["label"],
|
||
"sms_balance": sms_b,
|
||
"whooing_balance": whoo_b + interest_amount,
|
||
"last_diff": 0,
|
||
"last_interest_month": cur_month,
|
||
"last_sms_fee_month": prev.get("last_sms_fee_month"),
|
||
"last_hyundai_insurance_month": prev.get("last_hyundai_insurance_month"),
|
||
"alerted_at": datetime.now(KST).isoformat(),
|
||
}
|
||
changed = True
|
||
print(f" 💰 [interest] {info['label']} +{interest_amount:,}원 자동 분개")
|
||
continue
|
||
else:
|
||
print(f" ❌ [interest] 분개 실패 status={status} body={body[:120]!r}")
|
||
|
||
# 현대카드 문자메시지 이용료 자동 보정: 별도 승인 알림 없이 누적액에만 300원이 붙는 경우.
|
||
# 현대카드 부채 + 후잉(카드대금 보정 후)이 SMS보다 정확히 300원 적고, 월 1회만 처리한다.
|
||
if (
|
||
info.get("side") == "liability"
|
||
and carrier == "hyundai_card"
|
||
and diff == -300
|
||
and prev.get("last_sms_fee_month") != cur_month
|
||
):
|
||
sms_fee_payload = {
|
||
"entry_date": datetime.now(KST).strftime("%Y%m%d"),
|
||
"money": "300",
|
||
"item": "문자메시지 이용료",
|
||
"left": "주거,통신",
|
||
"right": info["account"],
|
||
"memo": "현대카드 문자메시지 이용료 자동보정: 승인 알림 없이 누적액에 반영된 월 1회 300원",
|
||
}
|
||
ok, status, body = post_to_whooing(webhook_url, sms_fee_payload, dry_run=False)
|
||
if ok:
|
||
notify.send(
|
||
f"📨 <b>현대카드 문자메시지 이용료 자동 분개</b>\n"
|
||
f"300원 (주거,통신 ← {notify.escape_html(info['account'])})"
|
||
)
|
||
carriers_state[carrier] = {
|
||
"account": info["account"],
|
||
"label": info["label"],
|
||
"sms_balance": sms_b,
|
||
"whooing_balance": actual_whoo_b + 300,
|
||
"whooing_balance_adjusted": whoo_b + 300,
|
||
"liability_payment_offset": liability_payment_offset,
|
||
"last_diff": 0,
|
||
"last_interest_month": prev.get("last_interest_month"),
|
||
"last_sms_fee_month": cur_month,
|
||
"last_hyundai_insurance_month": prev.get("last_hyundai_insurance_month"),
|
||
"alerted_at": datetime.now(KST).isoformat(),
|
||
}
|
||
changed = True
|
||
print(f" 📨 [sms-fee] {info['label']} 300원 자동 분개")
|
||
continue
|
||
else:
|
||
print(f" ❌ [sms-fee] 분개 실패 status={status} body={body[:120]!r}")
|
||
|
||
# 현대카드 보험료 자동 보정: 라이나생명 보험료 SMS가 익일 지연 도착하지만
|
||
# 카드 누적사용액에는 당일 반영되는 경우. 현대카드 부채 + 후잉(카드대금 보정 후)이
|
||
# SMS보다 정확히 31,100원 적고, 월 1회만 처리한다.
|
||
if (
|
||
info.get("side") == "liability"
|
||
and carrier == "hyundai_card"
|
||
and diff == -31100
|
||
and prev.get("last_hyundai_insurance_month") != cur_month
|
||
):
|
||
insurance_payload = {
|
||
"entry_date": datetime.now(KST).strftime("%Y%m%d"),
|
||
"money": "31100",
|
||
"item": "보험료",
|
||
"left": "의료,건강,보험",
|
||
"right": info["account"],
|
||
"memo": "현대카드 보험료 자동보정: 라이나생명 SMS 익일 지연, 누적사용액 차이 월 1회 31,100원",
|
||
}
|
||
ok, status, body = post_to_whooing(webhook_url, insurance_payload, dry_run=False)
|
||
if ok:
|
||
notify.send(
|
||
f"🛡️ <b>현대카드 보험료 자동 분개</b>\n"
|
||
f"31,100원 (의료,건강,보험 ← {notify.escape_html(info['account'])})"
|
||
)
|
||
carriers_state[carrier] = {
|
||
"account": info["account"],
|
||
"label": info["label"],
|
||
"sms_balance": sms_b,
|
||
"whooing_balance": actual_whoo_b + 31100,
|
||
"whooing_balance_adjusted": whoo_b + 31100,
|
||
"liability_payment_offset": liability_payment_offset,
|
||
"last_diff": 0,
|
||
"last_interest_month": prev.get("last_interest_month"),
|
||
"last_sms_fee_month": prev.get("last_sms_fee_month"),
|
||
"last_hyundai_insurance_month": cur_month,
|
||
"alerted_at": datetime.now(KST).isoformat(),
|
||
}
|
||
changed = True
|
||
print(f" 🛡️ [hyundai-insurance] {info['label']} 31,100원 자동 분개")
|
||
continue
|
||
else:
|
||
print(f" ❌ [hyundai-insurance] 분개 실패 status={status} body={body[:120]!r}")
|
||
|
||
if prev_diff == diff:
|
||
continue # 동일 차이 → 알림 skip
|
||
if prev_diff is None and diff == 0:
|
||
# 첫 관측부터 이미 일치하는 계정은 "정합 회복" 알림을 보내지 않는다.
|
||
carriers_state[carrier] = {
|
||
"account": info["account"],
|
||
"label": info["label"],
|
||
"sms_balance": sms_b,
|
||
"whooing_balance": actual_whoo_b,
|
||
"whooing_balance_adjusted": whoo_b,
|
||
"liability_payment_offset": liability_payment_offset,
|
||
"last_diff": diff,
|
||
"last_interest_month": prev.get("last_interest_month"),
|
||
"last_sms_fee_month": prev.get("last_sms_fee_month"),
|
||
"last_hyundai_insurance_month": prev.get("last_hyundai_insurance_month"),
|
||
"alerted_at": datetime.now(KST).isoformat(),
|
||
}
|
||
changed = True
|
||
print(f" 📊 [balance-init] {info['label']} sms={sms_b:,} whoo={whoo_b:,} diff={diff:+,} (notify skip)")
|
||
continue
|
||
notify.send(_format_balance_mismatch(
|
||
{
|
||
**info,
|
||
"actual_whooing_balance": actual_whoo_b,
|
||
"liability_payment_offset": liability_payment_offset,
|
||
},
|
||
sms_b, whoo_b, diff, prev_diff,
|
||
recovery_hints.get(carrier),
|
||
))
|
||
carriers_state[carrier] = {
|
||
"account": info["account"],
|
||
"label": info["label"],
|
||
"sms_balance": sms_b,
|
||
"whooing_balance": actual_whoo_b,
|
||
"whooing_balance_adjusted": whoo_b,
|
||
"liability_payment_offset": liability_payment_offset,
|
||
"last_diff": diff,
|
||
"last_interest_month": prev.get("last_interest_month"),
|
||
"last_sms_fee_month": prev.get("last_sms_fee_month"),
|
||
"last_hyundai_insurance_month": prev.get("last_hyundai_insurance_month"),
|
||
"alerted_at": datetime.now(KST).isoformat(),
|
||
}
|
||
changed = True
|
||
print(f" 📊 [balance] {info['label']} sms={sms_b:,} whoo={whoo_b:,} diff={diff:+,}")
|
||
if changed:
|
||
save_json(BALANCE_ALERT_FILE, state)
|
||
|
||
# 커서 진행 규칙:
|
||
# - 실패가 없으면: latest(성공 마지막) 또는 latest_skip(광고 마지막) 중 큰 값
|
||
# - 실패가 있으면: 그 실패 메시지 직전까지만. latest(성공 마지막)으로 고정. latest_skip는 무시
|
||
# (실패 이후 시각의 광고로 커서가 점프해 실패 메시지를 건너뛰는 사고 방지)
|
||
if blocked_at:
|
||
final_latest = latest
|
||
else:
|
||
final_latest = max(latest or "", latest_skip or "")
|
||
if final_latest and final_latest != last_message_at and not args.dry_run:
|
||
sync_state["last_message_at"] = final_latest
|
||
sync_state["last_synced_at"] = datetime.now(KST).isoformat()
|
||
save_json(SYNC_STATE_FILE, sync_state)
|
||
|
||
if blocked_at:
|
||
print(f"⚠️ 후잉 동기화: transfer {sent_transfer}건, structured {sent_structured}건, raw {sent_raw}건, 취소감액 {sent_cancel}건, 실패 {failed}건 — {blocked_at} 에서 중단(다음 cron 재시도)")
|
||
else:
|
||
print(f"✅ 후잉 동기화: transfer {sent_transfer}건, structured {sent_structured}건, raw {sent_raw}건, 취소감액 {sent_cancel}건, 실패 {failed}건 (last={final_latest or latest})")
|
||
|
||
# 가희 잔액 리마인더 & 답신 자동 분개 (격리 — 실패해도 결제 sync 결과는 위에서 이미 출력됨)
|
||
run_gahee_reminder()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|