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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-08-05 02:00:06 +09:00
parent 7e7d759b34
commit c03b3f2659
148 changed files with 3428 additions and 1380 deletions
@@ -515,20 +515,9 @@ def build_structured(parsed, sender_info, accounts, merchant_map):
return {**base, "left": cat, "right": carrier_acct}
return {**base, "left": "기타비용", "right": carrier_acct}
if kind == "card_cancel":
# 승인의 역분개. 승인이 {left: 비용, right: 카드} 이므로 좌우를 뒤집는다.
# 부분취소(취소액 != 승인액)도 취소 문자 금액 그대로 상쇄되어 맞는다.
if rule:
left = rule.get("left")
if left:
out = {**base, "left": rule.get("right") or carrier_acct, "right": left}
if rule.get("item"):
out["item"] = rule["item"]
return out
return None
if cat:
return {**base, "left": carrier_acct, "right": cat}
return {**base, "left": carrier_acct, "right": "기타비용"}
# card_cancel 은 여기서 payload 를 만들지 않는다 — 처리 루프의 _handle_card_cancel 이
# 원본 승인 거래를 찾아 PUT 으로 감액한다. 좌우를 뒤집은 역분개는 후잉이 거부한다
# (비용 계정은 차변 전용 → `r_account 잘못된 값입니다`) — 2026-08-03 회귀의 원인.
if kind == "withdrawal":
if rule:
@@ -559,6 +548,38 @@ def build_structured(parsed, sender_info, accounts, merchant_map):
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"))
@@ -754,6 +775,7 @@ def main():
sent_transfer = 0
sent_structured = 0
sent_raw = 0
sent_cancel = 0
failed = 0
new_failures = []
new_raw = [] # raw 폴백으로 성공 처리된 건 (구조화 실패 신호) — 텔레그램 알림용
@@ -811,8 +833,42 @@ def main():
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:
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)
@@ -1138,9 +1194,9 @@ def main():
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 재시도)")
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}건, 실패 {failed}건 (last={final_latest or latest})")
print(f"✅ 후잉 동기화: transfer {sent_transfer}건, structured {sent_structured}건, raw {sent_raw}건, 취소감액 {sent_cancel}건, 실패 {failed}건 (last={final_latest or latest})")
# 가희 잔액 리마인더 & 답신 자동 분개 (격리 — 실패해도 결제 sync 결과는 위에서 이미 출력됨)
run_gahee_reminder()