auto: 일일 백업 2026-08-05 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user