auto: 일일 백업 2026-09-08 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-09-08 02:00:02 +09:00
parent d786f75487
commit db1639596b
138 changed files with 2003 additions and 1716 deletions
@@ -51,6 +51,24 @@ launchd/cron 이 이 줄을 그대로 결과로 받는다.
- **실패 알림** — 후잉 웹훅이 거절(HTTP 에러, 본문 `fail` / `Error :`)한 건. 실패 1건당 1메시지, 4건 이상이면 앞 3건 + 초과분 요약. `_format_sync_failure()` 포맷.
- **raw 폴백 알림** — 후잉은 200 받았지만 structured 매칭 실패로 raw 모드로 넘어간 건. sync 사이클당 **1메시지**(최대 3건 나열, 초과 시 카운트). parser / carrier_to_account / merchant_map 보완 신호. `_format_raw_fallback()` 포맷.
- **기초잔액(자본) 분개 보류 알림** (2026-09-07) — 분개의 좌·우 어느 쪽이든 `순자산` 계정(`기초잔액(효원)`/`기초잔액(가희)`)이면 **후잉에 넣지 않고 알림만** 보낸다(관리자님 지시). `_format_capital_blocked()` 포맷, 기록은 `whooing_failures.json` 의 `capital_blocked` 키.
- 배경: `merchant_map` 의 `방효원` exact 룰(`left: 기초잔액(효원)`, "본인 명의 자체이체")은 **짝이 되는 출금 SMS 가 같이 와서 상계되는 것**을 전제한다. 짝이 없으면 자본 한쪽만 남아 **순자산이 조용히 부풀려진다.** 2026-09-07 카카오뱅크 879,993 실사고 — 처제 대납금 상환인데 입금자명이 "방효원"이라 이 룰에 걸렸고, 금액이 08/27 클럽호핑과 정확히 일치해서 겨우 눈에 걸렸다(어중간한 금액이면 지나갔다).
- ⚠️ **커서는 `latest_skip` 으로 진행시킨다** — 재시도해도 같은 룰이 같은 결과를 내므로 여기서 커서를 막으면 **이 메시지 뒤의 모든 거래가 영구히 멈춘다.** `blocked_at` 을 세우지 않는 이유.
- 후잉엔 아무것도 안 들어가므로 그 계좌의 **잔액 불일치 알림이 뒤이어 뜬다** — 알림을 놓쳐도 두 번째 그물이 있다.
- 분개는 관리자님이 직접: 진짜 자체이체면 **실제 출금 계좌**를 상대편으로, 아니면 성격에 맞는 계정으로.
- **은행이자 자동 분개 + 보류 알림** — 자산 계정에서 `diff < 0` 이고 `|diff| <= 5,000` 이면 차액 전액을 `계정 ← 기타수익 / 이자` 로 분개한다(카카오·신한·하나). 잔돈 단위 이자 SMS 가 안 오는 걸 메우는 장치.
- ⚠️ **같은 사이클에 raw 폴백이 있던 계정은 분개하지 않고 알림만 보낸다** (2026-09-07 추가). 입금 SMS 는 structured 매칭에 실패해 raw 로 넘어가는데, 후잉은 200 만 주고 분개를 만들지 않는다. 그러면 diff 가 그 입금액만큼 음수가 되고, 이 heuristic 이 **그걸 이자로 오인해 가짜 수익을 원장에 굳혀버린다.**
- 실사고: 2026-08-14 카카오뱅크 `+3,700원 은행이자` — 한재성 입금 10,900 미기록이 원인(기존 +7,200 드리프트와 상쇄돼 diff 가 −3,700). **진짜 8월 이자는 08/10 에 SMS 로 6,735원이 이미 들어와 있었다.** 한 달에 이자가 두 번 나올 리 없다는 게 판별점.
- 판별 원칙: **진짜 은행이자는 SMS 로 온다**(06/10 1,711원·07/10 1,552원·08/10 6,735원). SMS 없이 자동분개된 건은 원래 1~300원대 잔돈이어야 정상이고, 네 자리 이상이면 의심한다.
- **잔액 불일치 알림** — SMS 잔액(자산)·누적사용액(카드)과 후잉 잔액이 어긋난 건. `_format_balance_mismatch()` 포맷. 차이가 직전과 같으면 재알림하지 않는다.
- **누적 연속성 검사** (2026-09-07): 카드(부채) 계정에서 `diff < 0`(사용 거래 누락 의심)일 때만 `_cumulative_gap_scan()` 이 돌아 **어느 승인 사이에 얼마가 빠졌는지**까지 알림에 붙인다. 현대카드 SMS 의 `누적`은 승인마다 `직전 누적 + 승인액`이어야 하므로, 어긋난 구간이 곧 **문자가 안 오는 승인**(톨비 등)이다. 잔액 비교만으로는 "얼마 틀어졌다"까지만 알 수 있어 발생 시점을 못 짚었다.
- ⚠️ **양의 격차만 센다** — 카드대금 상환·정산은 누적을 떨어뜨리는데 이걸 격차로 세면 오탐이다.
- ⚠️ **이미 손으로 보정해 둔 건은 구간에서 차감한다** — 메모가 SMS 원문으로 파싱되지 않는 항목(`현대카드 잔액차 보정: 톨비` 등)을 앞선 구간부터 소진시킨다. 안 그러면 톨비를 넣어준 뒤에도 같은 구간이 영원히 미해결로 뜬다. 한 보정이 여러 구간에 중복 차감되지 않게 순서대로 소비한다.
- ⚠️ **호출 조건을 넓히지 말 것** — 후잉 entries 조회가 들어가므로 평시(diff 0)엔 돌지 않는다.
- 자동 분개는 하지 않는다. 톨비인지 다른 누락인지는 관리자님이 판단한다.
같은 SMS 1건이 두 알림에 동시에 걸리는 일은 없다 (`if ok / else` mutually exclusive). 한 사이클에서 서로 다른 SMS 들이 각각 raw · 실패로 나뉘면 두 메시지가 함께 간다.
`--dry-run` 시에는 둘 다 발송 안 함.
@@ -80,7 +80,7 @@ def _format_raw_fallback(new_raw: list) -> str:
def _format_balance_mismatch(info: dict, sms_b: int, whoo_b: int, diff: int, prev_diff,
recovery_hint=None) -> str:
recovery_hint=None, gaps=None) -> str:
"""SMS 잔액(자산) 또는 누적사용액(부채/카드)과 후잉 잔액 차이 → 텔레그램 메시지(HTML)."""
label = notify.escape_html(info["label"])
acct = notify.escape_html(info["account"])
@@ -128,11 +128,115 @@ def _format_balance_mismatch(info: dict, sms_b: int, whoo_b: int, diff: int, pre
lines.append(f"• 차이: <b>{sign}{diff:,}원</b> → {direction}")
if prev_diff is not None and prev_diff != diff:
lines.append(f"• 직전 차이: {prev_diff:+,}원 (변동)")
if gaps:
lines.append("─────────────")
lines.append("🔎 <b>누적 끊김 구간</b> (SMS 미수신 승인 의심)")
for g in gaps[:3]:
lines.append(f"• {notify.escape_html(g['from'])} ~ {notify.escape_html(g['to'])}"
f" · <b>{g['amount']:,}원</b>")
if len(gaps) > 3:
lines.append(f"• 그 외 {len(gaps) - 3}건")
lines.append("<i>톨비처럼 문자가 안 오는 승인일 수 있어요.</i>")
lines.append("")
lines.append("<i>ℹ️ SMS 누락·잘못 분개·기간 외 거래 등 점검이 필요해요.</i>")
return "\n".join(lines)
def _cumulative_gap_scan(account_name: str, days: int = 45) -> list:
"""카드 승인 SMS 의 누적사용액 연속성을 검사해 'SMS 없이 누적에만 반영된 승인'을 찾는다.
누적은 승인마다 `직전 누적 + 승인액`(취소는 -)이 되어야 한다. 어긋난 구간이 있으면
그 사이에 문자가 오지 않는 승인(톨비 등)이 있었다는 뜻이다. 잔액 차이만으로는
"얼마 틀어졌다"까지만 알 수 있지만, 이 검사는 어느 승인 사이에 얼마가 빠졌는지 짚는다.
⚠️ 후잉 조회가 들어가므로 diff != 0 인 카드 계정에서만 호출한다.
⚠️ 양의 격차만 본다 — 카드대금 상환·정산은 누적을 떨어뜨리는데 이걸 격차로 세면 오탐이다.
⚠️ 이미 손으로 보정해 둔 건(메모가 SMS 원문이 아닌 항목)은 구간에서 차감한다.
안 그러면 톨비를 넣어준 뒤에도 같은 구간이 계속 미해결로 뜬다.
"""
start = (datetime.now(KST) - timedelta(days=days)).strftime("%Y%m%d")
end = datetime.now(KST).strftime("%Y%m%d")
api_key = whooing_balance.api_key_from_cred()
sections = whooing_balance._section_list(api_key)
if not sections:
return []
sid = sections[0].get("section_id") or sections[0].get("id")
aid = str(whooing_balance._name_to_account_id(api_key, sid).get(account_name))
rows = whooing_balance.api_request("GET", "entries.json", api_key, {
"section_id": sid, "start_date": start, "end_date": end, "limit": 500,
})
sms, corrections = [], []
for r in (rows.get("rows") or []):
if aid not in (str(r.get("l_account_id")), str(r.get("r_account_id"))):
continue
entry_date = str(r.get("entry_date") or "").split(".")[0]
memo = r.get("memo") or ""
parsed = sms_parsers.parse_hyundai_card(memo, "1970-01-01T00:00:00Z")
stamp = re.search(r"\d{2}/\d{2}\s+\d{2}:\d{2}", memo)
if parsed and parsed.get("cumulative") is not None and stamp:
sms.append({
"sort": (entry_date, stamp.group(0)),
"date": entry_date,
"when": stamp.group(0),
"cumulative": parsed["cumulative"],
"amount": parsed["amount"],
"kind": parsed["kind"],
})
else:
corrections.append({"date": entry_date, "money": int(r.get("money") or 0)})
sms.sort(key=lambda x: x["sort"])
gaps = []
for prev, cur in zip(sms, sms[1:]):
sign = -1 if cur["kind"] == "card_cancel" else 1
missing = cur["cumulative"] - (prev["cumulative"] + sign * cur["amount"])
if missing > 0:
gaps.append({
"from": prev["when"], "to": cur["when"],
"from_date": prev["date"], "to_date": cur["date"],
"amount": missing,
})
# 이미 보정해 둔 항목을 구간별로 소진시킨다. 한 보정이 여러 구간에 중복 차감되지 않도록
# 앞선 구간부터 하나씩 소비한다.
unused = sorted(corrections, key=lambda c: c["date"])
for gap in gaps:
for corr in unused:
if corr["money"] <= 0 or gap["amount"] <= 0:
continue
if gap["from_date"] <= corr["date"] <= gap["to_date"]:
applied = min(corr["money"], gap["amount"])
corr["money"] -= applied
gap["amount"] -= applied
return [g for g in gaps if g["amount"] > 0]
def _format_capital_blocked(blocked: list) -> str:
"""기초잔액(자본) 분개로 갈 뻔해 보류한 건 → 텔레그램 메시지(HTML).
후잉에 아무것도 넣지 않았으므로 관리자님이 직접 분개해야 한다. 그대로 두면
해당 계좌의 잔액 불일치 알림이 뒤이어 뜨므로 놓쳐도 두 번째 그물이 있다.
"""
lines = [f"⛔ <b>기초잔액 분개 보류 {len(blocked)}건</b>", ""]
for b in blocked[:5]:
pay = b.get("payload") or {}
try:
money = f"{int(pay.get('money', 0)):,}원"
except Exception:
money = f"{pay.get('money', '')}원"
lines.append(
f"• <b>{notify.escape_html(b['label'])}</b> · {notify.format_kst(b['created_at'])}"
f"\n {money} · {notify.escape_html(pay.get('item') or '')}"
f"\n <i>{notify.escape_html(pay.get('left') or '')} ← {notify.escape_html(pay.get('right') or '')}</i>"
)
if len(blocked) > 5:
lines.append(f"• 외 {len(blocked)-5}건")
lines.append("")
lines.append("<i>후잉에 반영하지 않았어요. 직접 분개해 주세요 — 자체이체면 실제 출금 계좌로, 아니면 성격에 맞는 계정으로요.</i>")
return "\n".join(lines)
def _format_sync_failure(f: dict) -> str:
"""SMS 동기화 실패 1건 → 텔레그램 메시지(HTML)."""
label = notify.escape_html(f["label"])
@@ -782,6 +886,8 @@ def main():
recovery_hints: dict[str, str] = {}
liability_accounts = set(accounts.get("categories", {}).get("부채", []))
liability_payment_offsets_to_add: dict[str, int] = {}
capital_accounts = set(accounts.get("categories", {}).get("순자산", []))
capital_blocked = [] # 기초잔액(자본) 분개로 갈 뻔한 건 — 후잉에 넣지 않고 알림만
latest = last_message_at
blocked_at = None # 첫 실패/hold 의 created_at — 이후 메시지는 처리/커서 진행 모두 중단
@@ -882,6 +988,27 @@ def main():
mode = "raw"
display = c["text"][:60].replace("\n", " ")
# ⛔ 기초잔액(자본) 분개는 하지 않는다 (2026-09-07 관리자님 지시). 후잉에 넣지 않고 알림만.
# `방효원` 같은 자체이체 룰이 left=기초잔액 을 주는데, 짝이 되는 출금 SMS 가 안 오면
# 자본 한쪽만 남아 **순자산이 조용히 부풀려진다**(2026-09-07 카카오뱅크 879,993 실사고 —
# 처제 대납금 상환이었는데 입금자명이 "방효원"이라 자체이체 룰에 걸렸다).
# ⚠️ 커서는 latest_skip 으로 진행시킨다 — 재시도해도 같은 룰이 같은 결과를 내므로
# 여기서 커서를 막으면 이 메시지 뒤의 모든 거래가 영구히 멈춘다.
if mode == "structured" and (
structured.get("left") in capital_accounts
or structured.get("right") in capital_accounts
):
capital_blocked.append({
"label": c["info"]["label"],
"created_at": c["created_at"],
"payload": payload,
"blocked_at": datetime.now(KST).isoformat(),
})
if c["created_at"] > (latest_skip or ""):
latest_skip = c["created_at"]
print(f" ⛔ [capital-block] {c['info']['label']} {c['created_at']} | {display} — 미반영, 알림만")
continue
ok, status, body = post_to_whooing(webhook_url, payload, dry_run=args.dry_run)
if ok:
if mode == "structured":
@@ -905,6 +1032,7 @@ def main():
sent_raw += 1
new_raw.append({
"label": c["info"]["label"],
"carrier": c["info"].get("carrier"),
"created_at": c["created_at"],
"text": c["text"],
"parsed": parsed,
@@ -937,12 +1065,19 @@ def main():
f"<i>failures.json 에서 확인 부탁드립니다.</i>"
)
if capital_blocked and not args.dry_run:
failures.setdefault("capital_blocked", []).extend(capital_blocked)
save_json(FAILURES_FILE, failures)
notify.send(_format_capital_blocked(capital_blocked))
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:
# 이번 사이클에 raw 로 넘어간(=구조화 실패) 계정들. 이자 자동분개 가드에 쓴다.
raw_carriers = {r.get("carrier") for r in new_raw if r.get("carrier")}
last_balance_by_carrier: dict = {}
for c in candidates:
parsed = c.get("parsed") or {}
@@ -1012,12 +1147,28 @@ def main():
# 횟수·날짜 제한 없이 매번 자동 분개. 매 발화마다 텔레그램 알림으로 누적 가시성 확보.
# 정상 SMS가 와서 deposit이 후잉에 들어가면 diff>=0이 되어 안전하게 미발화.
cur_month = datetime.now(KST).strftime("%Y-%m")
if (
interest_candidate = (
info.get("side") == "asset"
and carrier in {"kakaobank", "shinhan_bank", "hana_bank"}
and diff < 0
and abs(diff) <= 5000
):
)
# ⚠️ 이 사이클에 raw 로 샌 메시지가 있는 계정은 이자로 단정하지 않는다.
# 입금 SMS 는 structured 매칭에 실패해 raw 로 넘어가고, 후잉은 200 만 주고
# 분개를 만들지 않는다. 그러면 diff 가 그 입금액만큼 음수가 되는데, 이 heuristic 은
# 그걸 이자로 오인해 가짜 수익을 원장에 굳혀버린다 (2026-08-14 카카오뱅크 3,700원
# 실사고 — 한재성 입금 10,900 미기록이 원인이었고, 진짜 8월 이자는 08/10 에
# SMS 로 6,735원이 이미 들어와 있었다). 이 경우 분개 대신 알림만 보낸다.
if interest_candidate and carrier in raw_carriers:
notify.send(
f"🔍 <b>{notify.escape_html(info['label'])} 미기록 입금 의심</b>\n"
f"차이 <b>{diff:+,}원</b> · 이 사이클에 raw 폴백이 있어 "
f"이자 자동분개를 보류했어요.\n"
f"<i>입금 SMS 가 후잉에 안 들어갔을 수 있어요. 확인 부탁드려요.</i>"
)
print(f" 🔍 [interest-hold] {info['label']} diff={diff:+,} — raw 폴백 존재로 이자 분개 보류")
interest_candidate = False
if interest_candidate:
interest_amount = -diff
interest_payload = {
"entry_date": datetime.now(KST).strftime("%Y%m%d"),
@@ -1153,6 +1304,14 @@ def main():
changed = True
print(f" 📊 [balance-init] {info['label']} sms={sms_b:,} whoo={whoo_b:,} diff={diff:+,} (notify skip)")
continue
# 카드 누적사용액이 후잉보다 크면 = 우리가 못 받은 승인이 있다.
# 그 구간과 금액을 누적 연속성으로 짚어 알림에 붙인다(조회는 이때만).
gaps = None
if info.get("side") == "liability" and diff < 0:
try:
gaps = _cumulative_gap_scan(info["account"])
except Exception as e:
print(f" ⚠️ [cum-gap] {info['label']} 누적 연속성 검사 실패: {e}")
notify.send(_format_balance_mismatch(
{
**info,
@@ -1160,7 +1319,7 @@ def main():
"liability_payment_offset": liability_payment_offset,
},
sms_b, whoo_b, diff, prev_diff,
recovery_hints.get(carrier),
recovery_hints.get(carrier), gaps,
))
carriers_state[carrier] = {
"account": info["account"],