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
@@ -154,7 +154,16 @@ node@22 가 2026-07-20 에 `22.22.2` → `22.23.1` 로 올라가며 **바이너
- 사람 이름 송금(예: "박영춘", "이지윤")은 exact 룰로 등록하지 말고 default fallback 에 맡긴다. merchant_map 비대화 방지.
- `deposit` 은 default fallback 없음 — rule 없으면 raw 폴백 (수익/이체/환급 구분 위험 때문).
- `card_cancel` 은 **승인의 역분개**로 좌우를 뒤집어 POST 한다 (2026-08-03). 승인이 `{비용 ← 카드}` 이므로 취소는 `{카드 ← 비용}`. 부분취소도 취소 문자 금액 그대로 상쇄되어 맞는다. 이전엔 raw 폴백으로 후잉 자체 파서에 맡겼는데, 결과는 맞았지만 파싱을 외부에 의존하고 raw 폴백 알림이 매번 울렸다. 현대카드·신한카드(`매입취소`/`승인취소`) 공통.
- ⚠️ **후잉은 계정 그룹별로 쓸 수 있는 변이 정해져 있다**`비용(expenses)`**차변(left) 전용**, `수익(income)`은 **대변(right) 전용**이고 `자산·부채`만 양쪽 자유다. 위반하면 `[r_account] => 잘못된 값입니다`(비용을 우측에) 또는 `[l_account] => 잘못된 값입니다`(수익을 좌측에)로 **HTTP 200 + 실패 본문**이 온다. 이 제약을 몰라서 2026-08-03~04 회귀가 났다. 새 분개 방향을 만들 때 반드시 확인할 것.
- ⚠️ **후잉은 취소·환불 문자를 자동 처리하지 않는다.** 공식 FAQ(`/help/faqs/inserting/refund`)가 "기존의 구입 거래를 **찾아서 지워주거나**, 금액을 마이너스로..." 라고 **사용자에게 수동 안내**한다. raw 로 취소 원문을 보내도 원본 승인은 그대로 남는다. 2026-08-04 에 과거 3건(07-04 카카오 월드킹·06-09 현대 쿠팡·06-13 현대 네이버페이)에서 원본이 사라진 걸 보고 "후잉 파서가 지운다"고 판단했는데, **관리자님이 그동안 수동 삭제해 오신 것**이었다. 두 설명 모두 "건수 −1, 순액 일치"라 검산으로는 구분되지 않는다 — 같은 착각을 반복하지 말 것.
- `card_cancel`**원본 승인 거래를 찾아 PUT 으로 감액**한다 (2026-08-04, 관리자님 결정 — 수동 삭제를 없애는 게 목적). `_handle_card_cancel` → `whooing_balance.find_card_entries` 로 같은 카드·같은 상호(memo 토큰 매칭)·금액이 취소액 이상인 거래를 30일 창에서 찾고, **정확히 1건일 때만** `set_entry_money``원금액 취소액` 으로 줄인다. 전액취소면 0원 줄이 남지만 잔액·합계에 영향은 없다.
- ⚠️ **좌우를 뒤집은 역분개로 바꾸지 말 것** — 비용이 대변으로 가서 후잉이 거부한다(위 제약). 2026-08-03 에 이 방식으로 바꿨다가 **첫 취소 문자에서 동기화가 통째로 멈췄다**(`blocked_at` 이 커서를 잠가 뒤의 정상 결제까지 전부 대기).
- ⚠️ **거래 삭제는 API 로 불가능하다** — 후잉 OpenAPI 의 DELETE 는 `section_id` 를 쿼리·본문(urlencoded/JSON)·쿠키·경로 어디에서도 읽지 못한다(2026-08-04 전수 실측, 같은 본문을 PUT 으로 보내면 정상). 0원 줄을 지우려면 후잉 UI 에서 수동으로. 마이너스 분개(후잉 공식 안내안)도 가능하지만 ± 두 줄이 남아 채택하지 않았다.
- **후보가 0건이거나 2건 이상이면 감액하지 않고 raw 폴백**(오감액보다 안전). 이때는 **후잉이 알아서 처리해주지 않으므로 관리자님 수동 작업이 필요하다** — 그래서 폴백 건은 `new_raw` 에 그대로 실어 raw 폴백 텔레그램 알림이 울리게 둔다. 알림을 끄면 수동 작업이 필요한 걸 모르고 지나친다.
- ⚠️ **매칭은 `item` 이 아니라 `memo` 로 한다**`merchant_map` contains 룰이 `item` 을 재작성해서(동행복권→`복권`) item 은 상호와 다를 수 있지만 memo 는 항상 승인 SMS 원문이다.
- ⚠️ **memo 매칭은 토큰 단위**(`_memo_has_merchant`) — 단순 substring 이면 `쿠팡``쿠팡페이주` 승인까지 잡는다. 2026-08-04 에 두 상호가 같은 날 같은 금액(22,700원)으로 실제로 있었다.
- ⚠️ **`card_cancel` 을 내는 파서는 현대카드·신한카드(`매입취소`/`승인취소`) 둘뿐이다.** 카카오뱅크 `카드결제취소``KAKAOBANK_CARD_RE``카드결제\s+` 가 안 맞아 **파싱 자체가 실패**(`kind=None`)하고, 하나·신한은행은 입금/출금만 있다. **이들은 감액 대상이 아니라 raw 로 가고, 후잉도 처리하지 않으므로 관리자님 수동 삭제가 계속 필요하다.** 자동화하려면 `parsers.py` 보강 + 2026-08-03 픽스처 회귀 재실행이 필요하다 — **2026-08-04 관리자님 지시로 "실제 취소 문자가 올 때 처리"로 보류**. 판단 근거는 파싱이 실패해도 `_infer_raw_details` 가 정규식으로 금액·상호를 뽑아 raw 폴백 알림에 실어주기 때문(실측: 카카오 취소 문자 → `2,000원 · (주)월드킹`). 알림을 보고 그때 대응하면 된다.
- **삼성카드는 아예 동기화 대상이 아니다** — 발신번호 `+8215888700``whooing_account_map.json` 에서 `confirmed=False` 라 수집조차 안 되고, 실제 오는 문자는 전부 `(광고)` 이며 후잉 `삼성신용(효원)`(x77) 은 최근 90일 거래 0건이다(2026-08-04 확인). 취소를 기다릴 대상이 아니라, **다시 사용하기 시작하면 승인 문자부터 누락**되니 그때 `confirmed` 등록이 먼저다.
- 기존 contains(예: "스타벅스 → 식비") / exact(예: "방효원 → 기초잔액(효원)") 는 계속 유효. fallback 은 둘 다 miss 일 때만 탄다.
- ⚠️ **주유소는 두 곳을 함께 손대야 한다**`whooing_sync.py``FUEL_KEYWORDS`(150,000원 선승인/취소 스킵)와 `whooing_merchant_map.json` contains 룰(실제 결제 → `차량유지비/주유비`)이 한 쌍이다. 한쪽만 넣으면 선승인이 후잉에 남거나 실제 주유가 기타비용으로 샌다. 상호에 주유/석유가 없는 충전소(현대가스·한경에너)는 상호 자체를 등록. 한경에너는 선승인이 149,900원이라 `FUEL_PREAUTH_AMOUNT`(150,000 정확일치) 스킵엔 안 걸리고 분류만 적용된다.
- 결과적으로 자잘한 인명 송금·가맹점 미등록 건은 전부 기타비용으로 자동 분류되고, 분류가 필요한 것만 후잉 UI 에서 사후 조정하거나 merchant_map 에 규칙 추가한다.
@@ -267,7 +276,7 @@ python3 .../whooing_manual.py --dry-run --item ... --money ...
원칙:
- 차트에 없는 계정명으로 절대 추측 POST 금지. 모르면 반드시 재질문.
- 사용자가 이미 한 문장에 "스타벅스 5800원 신한카드로" 다 말했다면, 카테고리만 확인하고 바로 실행.
- 카드 취소/환불이면 structured 대신 `--message` raw 모드로 원문 보내 후잉이 상쇄하게 한다.
- 카드 취소/환불은 원본 승인 거래를 찾아 감액하는 게 원칙(위 `card_cancel` 항목). 후잉은 취소 문자를 자동 처리해주지 않으니 raw 로 보내고 끝내면 안 된다 — 원본이 그대로 남는다.
## 잔액 조회 (whooing_balance.py)
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""후잉 OpenAPI 잔액(bs.json) 조회.
"""후잉 OpenAPI 클라이언트 — 잔액(bs.json) 조회 + 거래(entries.json) 조회·감액.
Usage:
whooing_balance.py # 모든 섹션의 현재 잔액
@@ -11,11 +11,13 @@ from __future__ import annotations
import argparse
import json
import re
import secrets
import sys
import time
import urllib.parse
import urllib.request
from datetime import datetime, timedelta
from pathlib import Path
CRED_PATH = Path("/Users/snowoyh/.openclaw/credentials/whooing.json")
@@ -41,6 +43,130 @@ def api_get(endpoint: str, api_key: str, params: dict | None = None) -> dict:
return data["results"]
def api_key_from_cred() -> str:
"""credentials 에서 API 키 문자열 1회 생성."""
api_cfg = json.loads(CRED_PATH.read_text())["api"]
return build_api_key(api_cfg["app_id"], api_cfg["token"], api_cfg["signature"])
def api_request(method: str, endpoint: str, api_key: str,
params: dict | None = None, body: dict | None = None) -> dict:
"""후잉 OpenAPI 호출. GET 은 쿼리스트링, PUT 은 본문으로 파라미터를 보낸다.
⚠️ DELETE 는 쓸 수 없다. 후잉 서버가 DELETE 요청의 파라미터를 쿼리·본문(urlencoded/JSON)·
쿠키·경로 어디에서도 읽지 못해 항상 `section_id parameter is required` 로 실패한다
(2026-08-04 전수 실측. 같은 본문을 PUT 으로 보내면 정상이라 후잉 쪽 문제다).
거래를 지우는 대신 PUT 으로 금액을 줄이는 이유가 이것이다.
"""
url = f"{BASE}/{endpoint}"
if params:
url += "?" + urllib.parse.urlencode(params)
data = None
if body:
# 후잉은 '+' 를 공백으로 디코드하지 않는다. quote_via=quote 로 공백을 %20 으로 보낸다.
data = urllib.parse.urlencode(body, quote_via=urllib.parse.quote).encode()
req = urllib.request.Request(url, data=data, method=method, headers={"X-API-KEY": api_key})
if data:
req.add_header("Content-Type", "application/x-www-form-urlencoded")
with urllib.request.urlopen(req, timeout=15) as resp:
parsed = json.loads(resp.read().decode("utf-8"))
if parsed.get("code") != 200:
raise RuntimeError(
f"후잉 API error {parsed.get('code')}: {parsed.get('message')} "
f"{parsed.get('error_parameters') or ''} ({method} {endpoint})"
)
return parsed["results"]
def _section_list(api_key: str) -> list:
sections = api_request("GET", "sections.json", api_key)
if isinstance(sections, dict):
return sections.get("sections") or sections.get("rows") or list(sections.values())
return sections
def _name_to_account_id(api_key: str, section_id) -> dict[str, str]:
raw = api_request("GET", "accounts.json", api_key, {"section_id": section_id})
out: dict[str, str] = {}
for acc_list in raw.values():
if not isinstance(acc_list, list):
continue
for a in acc_list:
aid = str(a.get("account_id"))
out[a.get("title") or aid] = aid
return out
def _memo_has_merchant(memo: str, merchant: str) -> bool:
"""memo 에 merchant 가 토큰 단위로 들어있는지.
단순 substring 이면 '쿠팡''쿠팡페이주' 승인까지 잡아 엉뚱한 거래를 감액한다
(2026-08-04 실제로 두 상호가 같은 날 같은 금액으로 있었다). 카드 SMS 는 상호를
줄바꿈·공백으로 구분하므로 앞뒤가 공백이거나 문자열 끝일 때만 인정한다.
"""
if not memo or not merchant:
return False
for m in re.finditer(re.escape(merchant), memo):
before = memo[m.start() - 1] if m.start() > 0 else " "
after = memo[m.end()] if m.end() < len(memo) else " "
if before.isspace() and after.isspace():
return True
return False
def find_card_entries(card_account: str, money: int, merchant: str,
on_date: str, window_days: int = 30) -> list[dict]:
"""카드 취소의 원본 승인 거래 후보를 찾는다.
card_account 로 결제된 거래 중 memo 에 merchant 가 들어있고 금액이 취소액 이상인 것을
[on_date - window_days, on_date] 구간에서 찾는다. 금액 일치(전액취소)를 우선 반환하고,
없으면 초과분(부분취소 후보)을 반환한다.
⚠️ item 이 아니라 memo 로 매칭한다 — merchant_map 의 contains 룰이 item 을 재작성해서
(동행복권→'복권') item 은 상호와 다를 수 있지만, memo 는 항상 승인 SMS 원문이다.
⚠️ 판단은 호출측이 한다. 정확히 1건일 때만 손대는 게 안전하다.
"""
api_key = api_key_from_cred()
start = (datetime.strptime(on_date, "%Y%m%d") - timedelta(days=window_days)).strftime("%Y%m%d")
exact, over = [], []
for sec in _section_list(api_key):
sid = sec.get("section_id") or sec.get("id")
card_id = _name_to_account_id(api_key, sid).get(card_account)
if not card_id:
continue
rows = api_request("GET", "entries.json", api_key, {
"section_id": sid, "start_date": start, "end_date": on_date,
})
for row in (rows.get("rows") or []):
if str(row.get("r_account_id")) != card_id:
continue
if not _memo_has_merchant(row.get("memo") or "", merchant):
continue
row_money = row.get("money") or 0
if row_money == money:
exact.append({**row, "section_id": sid})
elif row_money > money:
over.append({**row, "section_id": sid})
return exact or over
def set_entry_money(entry: dict, money: int) -> None:
"""거래 금액을 money 로 바꾼다(PUT). money=0 도 후잉이 받는다(2026-08-04 실측).
조회한 나머지 필드를 그대로 되돌려보내 보존한다 — 부분 PUT 의 필드 보존 동작에
기대지 않는 편이 안전하다. entry_date 는 조회 시 '20260804.0000' 형태라 소수부를 뗀다.
"""
api_request("PUT", f"entries/{entry['entry_id']}.json", api_key_from_cred(), body={
"section_id": entry["section_id"],
"entry_date": str(entry.get("entry_date") or "").split(".")[0],
"l_account_id": entry.get("l_account_id"),
"r_account_id": entry.get("r_account_id"),
"item": entry.get("item") or "",
"money": str(money),
"memo": entry.get("memo") or "",
})
def fmt_won(n: int) -> str:
return f"{n:,}"
@@ -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()