549545bde6
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1124 lines
51 KiB
Python
Executable File
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()
|