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