Files
openclaw/agents/budget/workspace/skills/whooing-sync/scripts/whooing_sync.py
T
hyowons fed3526b20 Initial commit: OpenClaw 워크스페이스 버전관리 시작
설정·스크립트·스킬·문서·큐레이션 메모리 추적.
시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)·
백업(clobbered/bak)·dream 캐시는 .gitignore로 제외.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:39:41 +09:00

1124 lines
51 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 subprocess
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 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
if payment_offset and actual_whoo_b != whoo_b:
whoo_line = (
f"• 후잉: <b>{actual_whoo_b:,}원</b>"
f" + 카드대금 보정 <b>{payment_offset:,}원</b>"
f" → <b>{whoo_b:,}원</b>"
)
matched_word = "후잉(카드대금 보정 후)"
else:
whoo_line = f"• 후잉: <b>{whoo_b:,}원</b>"
matched_word = "후잉"
if diff == 0:
recovered = abs(prev_diff) if prev_diff is not None else 0
recovered_line = f"\n회복 금액: <b>{recovered:,}원</b>" if recovered else ""
hint_line = f"\n반영 항목: <b>{notify.escape_html(recovery_hint)}</b>" if recovery_hint else ""
return (
f"✅ <b>{kind_word} 정합 회복</b>\n\n"
f"<b>{label}</b> ({acct})\n"
f"SMS / {matched_word} 모두 <b>{sms_b:,}원</b> 일치"
f"{recovered_line}"
f"{hint_line}"
)
sign = "+" if diff > 0 else ""
if side == "liability":
direction = "결제 누락 의심" if diff > 0 else "사용 거래 누락 의심"
else:
direction = "출금 누락 또는 입금 중복" if diff > 0 else "입금 누락 의심"
body = (
f"⚠️ <b>{kind_word} 불일치</b>\n\n"
f"<b>{label}</b> ({acct})\n"
f"• SMS: <b>{sms_b:,}원</b>\n"
f"{whoo_line}\n"
f"• 차이: <b>{sign}{diff:,}원</b> ({direction})"
)
if prev_diff is not None and prev_diff != diff:
body += f"\n <i>이전 차이: {prev_diff:+,}원 → 변동</i>"
return body + "\n\n<i>SMS 누락·잘못 분개·기간 외 거래 등 점검 필요.</i>"
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원 승인취소 순서로 온다.
# 선승인/취소는 후잉에 기록하지 않고, 실제 결제만 차량유지비/주유비로 남긴다.
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}
try:
chats_raw = subprocess.run(
["imsg", "chats", "--json"],
capture_output=True, text=True, timeout=IMSG_TIMEOUT,
)
chats = [json.loads(l) for l in chats_raw.stdout.splitlines() if l.strip()]
except FileNotFoundError:
print("❌ imsg CLI가 설치되어 있지 않습니다.", file=sys.stderr)
sys.exit(3)
except Exception as e:
print(f"❌ imsg chats 실행 실패: {e}", 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
save_json(CHAT_IDS_CACHE, result)
return result
def fetch_messages(chat_id, since):
cmd = ["imsg", "history", "--chat-id", str(chat_id), "--limit", str(HISTORY_LIMIT), "--json"]
if since:
cmd += ["--start", since]
try:
raw = subprocess.run(cmd, capture_output=True, text=True, timeout=IMSG_TIMEOUT)
return [json.loads(l) for l in raw.stdout.splitlines() if l.strip()]
except subprocess.TimeoutExpired:
print(f"⚠️ chat-id {chat_id} history 타임아웃", file=sys.stderr)
return []
except Exception as e:
print(f"⚠️ chat-id {chat_id} history 실패: {e}", file=sys.stderr)
return []
# --- 룰 엔진 (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}
if kind == "card_cancel":
return None # raw 폴백 (후잉이 원거래 찾아 상쇄)
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 _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
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
structured = None
if parsed:
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
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 == "kakaobank"
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>카뱅 은행이자 자동 분개</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}건, 실패 {failed}건 — {blocked_at} 에서 중단(다음 cron 재시도)")
else:
print(f"✅ 후잉 동기화: transfer {sent_transfer}건, structured {sent_structured}건, raw {sent_raw}건, 실패 {failed}건 (last={final_latest or latest})")
# 가희 잔액 리마인더 & 답신 자동 분개 (격리 — 실패해도 결제 sync 결과는 위에서 이미 출력됨)
run_gahee_reminder()
if __name__ == "__main__":
main()