# whooing-sync

iMessage에 들어오는 카드/은행 결제 알림을 후잉(whooing.com) 웹훅으로 자동 전송한다.

## When to use

- 사용자가 "가계부 동기화" / "후잉 동기화" / "결제내역 정리" 요청할 때
- launchd가 매시 0/15/30/45분 자동 호출 (기본 운용 — **OpenClaw cron 아님**, 아래 "스케줄러" 참고)
- 매핑되지 않은 새 발신번호가 발견됐을 때 보고

## How

**on-demand 실행은 launchd 잡을 앞당겨 돌린다.** 스크립트를 직접 실행하지 말 것 —
에이전트 세션(코덱스)은 게이트웨이 node 를 TCC responsible process 로 물고 있어서
`imsg` 가 Messages DB 를 못 읽는다 (아래 "Full Disk Access" 참고). launchd 잡은 그 제약이 없다.

```bash
launchctl kickstart -p gui/$(id -u)/ai.openclaw.budget.whooing-sync
# 비동기 — 몇 초 뒤 결과 확인
tail -20 /Users/snowoyh/.openclaw/logs/whooing-sync.log
```

스크립트 직접 실행은 **FDA 를 가진 터미널 앱**(iTerm2 등)에서만 동작한다:

```bash
python3 /Users/snowoyh/.openclaw/agents/budget/workspace/skills/whooing-sync/scripts/whooing_sync.py
```

기본 동작:
1. `credentials/whooing.json`에서 webhook URL 로드 (없으면 종료)
2. `state/whooing_account_map.json`의 `confirmed: true` 발신번호만 대상으로 imsg history 조회
3. `state/whooing_synced.json`의 `last_message_at` 이후 메시지만 처리
4. 각 메시지 원문을 `message=<원문>` 형태로 후잉 웹훅에 POST
5. 후잉 응답 200이면 `whooing_synced.json` 업데이트, 아니면 `whooing_failures.json`에 기록
6. `confirmed: false`이거나 매핑 없는 발신번호 메시지가 들어오면 `whooing_failures.json`의 `unmapped` 섹션에 1회 기록 (반복 알림 방지)

## Output

마지막 줄에 한 줄 요약 출력:

```
✅ 후잉 동기화: transfer N건, structured N건, raw M건, 실패 K건 (last=2026-04-23T12:34:56+09:00)
```

launchd/cron 이 이 줄을 그대로 결과로 받는다.

### 텔레그램 알림 (골디)

`notify.py` 를 통해 관리자 텔레그램(openclaw.json `channels.telegram.accounts.budget`)으로 두 종류 알림이 발송된다:

- **실패 알림** — 후잉 웹훅이 거절(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()` 포맷.

같은 SMS 1건이 두 알림에 동시에 걸리는 일은 없다 (`if ok / else` mutually exclusive). 한 사이클에서 서로 다른 SMS 들이 각각 raw · 실패로 나뉘면 두 메시지가 함께 간다.

`--dry-run` 시에는 둘 다 발송 안 함.

## 스케줄러 (launchd)

15분 주기 자동 실행은 **launchd**가 담당. 이전엔 OpenClaw cron 의 `후잉 가계부 동기화` (agentTurn) 잡을 썼으나, 매 실행마다 LLM 세션 부팅해서 토큰 낭비가 커 (월 ~70M 토큰 추정) launchd 로 전환됨. 그 OpenClaw cron 잡은 2026-04-24 에 삭제됨 — **다시 만들지 말 것**.

- plist: `~/Library/LaunchAgents/ai.openclaw.budget.whooing-sync.plist`
- label: `ai.openclaw.budget.whooing-sync`
- 스케줄: StartCalendarInterval 매시 0/15/30/45분
- 로그: `/Users/snowoyh/.openclaw/logs/whooing-sync.log` (stdout), `.err.log` (stderr)
- PATH: plist 에 `/opt/homebrew/bin` 주입 (`imsg` 해석용)

제어 커맨드:

```bash
# 수동 실행 (1회)
launchctl kickstart -p gui/$(id -u)/ai.openclaw.budget.whooing-sync

# 상태 확인
launchctl print gui/$(id -u)/ai.openclaw.budget.whooing-sync

# 실시간 로그
tail -f /Users/snowoyh/.openclaw/logs/whooing-sync.log

# 언로드 / 재로드
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/ai.openclaw.budget.whooing-sync.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.openclaw.budget.whooing-sync.plist
```

## 전제조건: Full Disk Access (FDA)

`imsg` 가 `~/Library/Messages/chat.db` 를 읽으려면 FDA 허용이 필요. **판정 주체는 TCC responsible process** — 자기 자신이 아니라 프로세스 체인의 책임 프로세스다.

- launchd → `/usr/bin/python3`(플랫폼 바이너리) → `imsg`: 책임이 `imsg` 자신이라 **`/opt/homebrew/bin/imsg` 승인으로 통과**
- 터미널 앱 → … → `imsg`: 터미널 앱(iTerm2 등)의 FDA 로 통과
- 게이트웨이 node → codex → bash → `imsg`: **게이트웨이 node 바이너리**의 FDA 로 판정됨

등록 경로: 시스템 설정 → 개인정보 보호 및 보안 → 전체 디스크 접근 권한 → `+` 로 추가 → 토글 ON

**FDA 누락 증상**: `imsg` 가 stderr 로 다음을 낸다.

```
Permission Error: Cannot access Messages database
... requires Full Disk Access permission.
```

`imsg_cli.run_json()` 이 이걸 잡아 로그에 그대로 옮긴다:

```
⚠️  chat-id 7 history 실패: Messages DB 접근 거부 — 전체 디스크 접근 권한(FDA) 누락. ...
```

### 2026-08-03 사고: homebrew node 업그레이드로 FDA 무효화

node@22 가 2026-07-20 에 `22.22.2` → `22.23.1` 로 올라가며 **바이너리 경로가 바뀌어** 기존 FDA 승인이 무효화됐다 (시스템 TCC.db 에 22.22.2=허용 / 22.23.1=거부로 남아 있음). 게이트웨이가 7/21 새 바이너리로 재기동되면서 골디의 에이전트 세션에서 `imsg` 가 죽었다. **launchd 잡은 영향 없었다** — 그래서 자동 동기화는 정상인데 골디만 실패하는 그림이 됐고, 골디는 이걸 "문자 수집 실패"로 오진했다.

- 재발 시 확인: `lsof -p $(pgrep -f 'openclaw/dist/index.js gateway') -a -d txt | head -1` 로 실제 node 경로를 뽑아, 그 경로를 FDA 목록에 추가
- 그 다음 게이트웨이 재기동: `launchctl kickstart -k gui/$(id -u)/ai.openclaw.gateway`
- **on-demand 실행을 kickstart 로 바꾼 이유가 이것** — node 업그레이드마다 깨지는 의존을 실행 경로에서 뺐다

당시 오진을 만든 코드 결함 3개는 함께 고쳤다 (2026-08-03):

1. 호출부가 stderr·returncode 를 버려 권한 오류가 `Expecting value: line 1 column 1 (char 0)` 파싱 에러로 둔갑 → `imsg_cli.py` 공용 래퍼로 사유 노출
2. `resolve_chat_ids()` 가 권한 거부 시 **빈 chat id 매핑을 캐시에 저장** → 이후 launchd 포함 전 실행이 캐시 적중으로 조용히 0건 수집을 영구 반복. 이제 전 carrier 가 비면 저장 거부 + exit 3
3. `imsg chats` 기본값이 최근 20건이라 조용한 발신번호(현대카드 SMS·신한은행)가 목록에서 빠짐 → 캐시 재생성 시 그 carrier 수집이 조용히 끊김. `CHATS_LIMIT=200` 으로 전량 조회

맥 이전 / imsg 재설치 시에도 FDA 재등록 필요.

## 페어 매칭 (자기 계좌 간 이체)

같은 금액의 입↔출 SMS 가 5분 이내 쌍으로 도착하면 **1건의 자산↔자산 structured 이체**로 합성 POST 한다. 중복 엔트리 방지가 목적.

상수 (`scripts/whooing_sync.py` 상단):
- `PAIR_WINDOW_SECONDS = 300` — 두 SMS created_at 차이 허용 범위
- `HOLD_GRACE_SECONDS = 300` — 페어 없는 입출금이 이 나이 미만이면 hold (다음 cron 재시도)

조건: 양쪽 carrier 모두 `whooing_accounts.json` 의 `carrier_to_account` 에 자산 계정명이 매핑돼 있어야 함. 하나라도 비면 개별 처리로 폴백.

출력 예시:
```
🔄 [transfer] 하나→신한은행 2026-04-24T00:53:55Z 200 | 신한은행(효원) ← 하나은행(효원) 10,000원 (이체 하나→신한은행)
```

페어 미성립 케이스:
- 한쪽 은행이 confirmed=false → SMS 수집 안 됨. 메모 키워드("카뱅오픈방효원")가 `merchant_map.contains` 와 맞으면 withdrawal-only structured 로 처리. 아니면 raw.
- 시간창 벗어남 / 단독 입출금 / 외부 송금 → 각자 개별 처리.

### merchant_map 주의사항

`state/whooing_merchant_map.json` exact 룰에 `"방효원": { "left": "기초잔액(효원)" }` 가 등록돼 있다. 페어 미성립 시 이 룰로 폴백해서 **기초잔액을 경유한 잘못된 분개**가 기록될 수 있다. 한쪽 carrier 가 confirmed=false 일 때 구멍이 크다.

- 페어 매칭이 1차 방어선.
- 그래도 오류 기록이 보이면 `whooing_manual.py` 로 역분개하거나 후잉 UI 에서 삭제 후 올바른 이체로 재등록.
- 메모에 목적지 은행 키워드("카뱅", "신한" 등) 가 일관되게 들어오면 `merchant_map.contains` 에 등록해 해결 가능 (현재 미등록).

### 기본 Fallback: 모르는 비용 = 기타비용

`card_approval`, `withdrawal` 중 `exact` / `contains` 매칭이 없는 건은 **`기타비용 ← {carrier 자산}`** structured 로 자동 분개된다 (관리자님 지시, 2026-04-24).

- 사람 이름 송금(예: "박영춘", "이지윤")은 exact 룰로 등록하지 말고 default fallback 에 맡긴다. merchant_map 비대화 방지.
- `deposit` 은 default fallback 없음 — rule 없으면 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 에 규칙 추가한다.

### 우선 룰 (whooing_overrides.json)

`build_structured()` 보다 먼저 평가되는 우선순위 룰. 정기결제·시간대 기반 분개 등 default fallback(기타비용) 으로 떨어뜨리면 안 되는 케이스를 JSON 으로 정의한다. 코드 수정 없이 룰 추가/수정/삭제 가능.

파일: `state/whooing_overrides.json`. `whooing_sync.py` 가 매 실행마다 다시 읽어 launchd 재기동 불필요.

#### 룰 평가 흐름

1. `apply_overrides()` 가 `rules` 를 위에서 아래로 평가, 첫 매칭 룰의 `post` 를 후잉 structured payload 로 변환
2. 매칭 실패 / `enabled: false` / `left` 누락 / carrier_to_account 미등록 → 다음 룰
3. 모든 룰 매칭 실패 → `build_structured()` 폴백 (기존 merchant_map 룰 → default 기타비용)

#### 룰 스키마

```json
{
  "name": "사람이 읽을 이름",
  "enabled": true,
  "match": { "...": "매처들 (전부 AND)" },
  "post":  { "left": "...", "right": "...", "item": "...", "memo": "..." },
  "note":  "선택. 운영 메모"
}
```

#### 매처 (match — 모두 옵션, 지정한 것만 AND 평가)

| 키 | 의미 |
|---|---|
| `kind` | `card_approval` / `card_cancel` / `withdrawal` / `deposit` 중 하나와 정확히 일치 |
| `merchant_contains` | 가맹점 문자열에 해당 부분문자열 포함 |
| `merchant_regex` | 가맹점이 정규식과 매칭 (`re.match`) |
| `merchant_unmapped` | true 시 `merchant_map` exact/contains 에 등록되지 않은 가맹점만 |
| `amount_eq` | 금액 정확 일치 |
| `carrier_in` | carrier 가 리스트 안에 (`hana_bank`, `shinhan_card`, `hyundai_card` 등) |
| `weekday_in` | 거래일 요일이 리스트 안에 (월=0 ~ 일=6) |
| `scheduled_day_of_month` | 결제 예정일(월별 day). `weekend_policy` 와 짝 |
| `weekend_policy` | `exact`(기본, 그 날만) / `next_weekday`(주말이면 [원래일~다음 평일] 윈도우 매칭) |
| `time_kst_between` | KST 시각이 윈도우 안에 (예: `["13:00", "14:30"]`, 양 끝 포함) |

#### post 변수 치환

`left` / `right` / `item` / `memo` 문자열에 `{merchant}`, `{raw}`, `{amount}`, `{carrier_account}` 가 치환된다.

- `right` 미지정 → 자동으로 `{carrier_account}` (해당 카드/통장 자산명)
- `item` 미지정 → SMS merchant 사용
- `memo` 미지정 → SMS 원문(`{raw}`) 사용
- `left` 누락 → 룰 무효 처리 (다음 룰로 이동)
- 모든 문자열은 후잉 제한에 맞춰 200자 truncate

#### 현재 등록된 룰

1. **쿠팡 정기결제** — card_approval · 가맹점 "쿠팡" · 7,890원 · 매월 29일 (`next_weekday` 윈도우, 주말이면 다음 평일까지) → `지식,문화 ← {carrier}` (item=`계정결제_쿠팡`)
2. **평일 점심 인명송금** — withdrawal · carrier ∈ {하나/신한/카뱅} · 가맹점 한글 2~4자 · `merchant_map` 미등록 · 평일 13:00~14:30 KST → `식비 ← {carrier}` (item=`점심식사`, memo=`{merchant}에게 송금 | {raw}`)
3. **퇴직연금 자동이체** — withdrawal · 하나은행 · merchant 포함 "44891006239452" · 100,000원 · 매월 25일 (`next_weekday`) → `하나IRP(효원) ← 하나은행(효원)` (item=`퇴직연금`)
4. **프리드라이프 정기납부** — withdrawal · 하나은행 · merchant 포함 "프리드" · 33,000원 · 매월 25일 (`next_weekday`) → `의료,건강,보험 ← 하나은행(효원)` (item=`프리드라이프`)
5. **우체국보험 정기납부** — withdrawal · 하나은행 · merchant 포함 "우체" · 251,790원 · 매월 25일 (`next_weekday`) → `의료,건강,보험 ← 하나은행(효원)` (item=`보험료`)

3·4·5번은 `amount_eq` 포함 엄격 매칭 — 금액·결제일·채널 어긋나면 default fallback "기타비용" 으로 빠지면서 raw 폴백 텔레그램 알림이 트리거되어 이상 거래 감지에 활용. 보험료 인상 등 정상 변경 시 룰의 `amount_eq` 갱신 필요.

#### 룰 추가 / 수정 / 삭제

- 새 정기결제 등록 — `whooing_overrides.json` `rules` 끝에 객체 추가
- 일시 비활성화 — `enabled: false`
- 우선순위 변경 — 배열 순서 조정 (먼저 매칭되는 룰이 이김)
- 정기결제·자동이체·시간대 분개는 모두 `whooing_overrides.json` 으로. `whooing_merchant_map.json` 에는 일반 가맹점 분류 룰(스타벅스 → 식비 등) + 일회성 / 자체이체 같은 비-정기 분류만 둔다.

## Dry-run

```bash
python3 .../whooing_sync.py --dry-run
```

후잉으로 실제 POST는 하지 않고, 어떤 메시지가 어떤 발신번호에서 잡혀 어디로 갈지 stdout에 표시.

## 직접 입력 (iMessage 없이)

사용자가 "후잉에 직접 등록해줘" / "가계부에 한 건 추가" 요청할 때 `whooing_manual.py` 사용.

```bash
# structured (차트 검증 O)
python3 .../whooing_manual.py --item "스타벅스" --money 5800 --left "식비" --right "신한신용(효원)" [--date 20260423] [--memo "오전 커피"]

# raw (후잉 자체 파서에 위임)
python3 .../whooing_manual.py --message "스타벅스 5800원 신한신용"

# 사전 확인
python3 .../whooing_manual.py --dry-run --item ... --money ...
```

- left/right는 `state/whooing_accounts.json` 의 `categories` 에 있는 이름만 허용 (차트 외 이름은 후잉이 거부).
- `whooing_synced.json` 은 건드리지 않음 (SMS 흐름과 독립).
- 실패 시 `whooing_failures.json` 에 `source: "manual"` 로 기록.

### 에이전트 대화 프로토콜 (스텝바이스텝)

사용자가 에이전트(클로 또는 골디)를 통해 직접 입력할 때는 **한 번에 하나씩** 묻는다. 한 메시지에 여러 질문을 몰아넣지 않는다. 각 단계에서 사용자가 정보를 먼저 제시했으면 해당 단계는 건너뛴다.

1. **항목** — "어떤 항목이에요? (예: 스타벅스)"
2. **금액** — "얼마인가요? (원)"
3. **결제수단(right)** — 먼저 `whooing_accounts.json` 의 `부채`(카드) + `자산`(통장) 목록을 읽어 번호 매긴 후보로 제시. "어느 걸로 결제하셨어요?" 사용자가 모호하게 답하면 가장 가까운 후보로 확인받는다.
4. **카테고리(left)** — 결제면 `비용`, 입금이면 `수익`, 계좌이체면 `자산`/`부채`. 해당 카테고리 목록을 번호로 제시 후 선택. 애매하면 추측하지 말고 묻는다.
5. **날짜** — 기본 오늘(KST). "오늘로 할까요, 다른 날짜예요?" 짧게만.
6. **메모** — "메모 있으세요? (없으면 스킵)"
7. **확인 후 실행** — `--dry-run` 없이 `whooing_manual.py` structured 모드로 실제 POST. 결과 한 줄로 보고.

원칙:
- 차트에 없는 계정명으로 절대 추측 POST 금지. 모르면 반드시 재질문.
- 사용자가 이미 한 문장에 "스타벅스 5800원 신한카드로" 다 말했다면, 카테고리만 확인하고 바로 실행.
- 카드 취소/환불은 원본 승인 거래를 찾아 감액하는 게 원칙(위 `card_cancel` 항목). 후잉은 취소 문자를 자동 처리해주지 않으니 raw 로 보내고 끝내면 안 된다 — 원본이 그대로 남는다.

## 잔액 조회 (whooing_balance.py)

관리자님이 "잔액 확인", "후잉 잔액", "자산 얼마야", "가계부 현황", "순자산 얼마야", "○○카드 얼마 썼어", "○○계좌 잔고" 같은 요청을 하면 이걸로 조회한다.

```bash
# 기본 (오늘 기준, 모든 섹션)
python3 /Users/snowoyh/.openclaw/agents/budget/workspace/skills/whooing-sync/scripts/whooing_balance.py

# 특정 날짜 기준
python3 .../whooing_balance.py --as-of 2026-03-31

# 특정 섹션만 (멀티섹션 사용 시)
python3 .../whooing_balance.py --section-id s128867

# 구조화 JSON (필터링·가공 필요 시)
python3 .../whooing_balance.py --json
```

### 동작

1. `credentials/whooing.json` 의 `api` 블록(`app_id`/`token`/`signature`)으로 `X-API-KEY` 헤더 생성 (nounce+timestamp는 매 호출마다 자동).
2. `sections.json` → 섹션 목록 조회
3. `accounts.json?section_id=...` → account_id→이름 매핑 로드
4. `bs.json?section_id=...&start_date=19000101&end_date=<오늘>` → 자산/부채/자본 스냅샷
5. 마크다운으로 카테고리별 합계 + 계정별 금액을 금액 내림차순으로 출력 (0원 계정은 생략)

### 출력 형식

```
## 후잉 잔액 (기준일 2026-04-23)

### [s128867] 효원
- **자산 합계:** 352,136,595원
  - 롯데캐슬: 115,810,000원
  - ...
- **부채 합계:** 1,339,391원
  - ...
- **자본 합계:** 350,797,204원
```

관리자님께 보고할 때는 이 stdout을 그대로 쓰거나, 질문 맥락에 맞게 발췌해서 한 줄 요약으로 축약한다 (예: "순자산 350,797,204원 · 자산 3.52억 · 부채 1,339,391원").

### 특정 계정만 보고 싶을 때

`--json` 으로 돌려서 파이썬/jq로 필터한다.

```bash
python3 .../whooing_balance.py --json \
  | python3 -c 'import sys,json; d=json.load(sys.stdin); [print(f"{it[\"name\"]}: {it[\"money\"]:,}원") for s in d["sections"] for it in s["groups"]["자산"]["items"] if "신한" in it["name"]]'
```

### 원칙

- 잔액은 후잉에 실제 등록된 값이다. 의심스러우면 `--as-of` 로 다른 날짜 찍어 비교.
- 외부(메일/텔레그램)로 잔액 데이터를 내보내려면 관리자님 허락 먼저.
- 호출은 읽기 전용이라 dry-run 개념 없음. 바로 실행해도 된다.
- 실패 시 대부분 크리덴셜 문제 → `credentials/whooing.json` 의 `api` 블록 확인하고 보고.

## 가희 잔액 리마인더 & 자동분개 (gahee_reminder.py)

`whooing_sync.main()` 끝에서 `gahee_reminder.run(webhook_url, post_to_whooing, dry_run)` 가 매 사이클 호출된다. 별도 launchd plist 없이 whooing-sync 사이클(매 15분)에 piggyback.

### 흐름

1. **발신 게이트** — KST 기준 `day >= send_day_of_month` (catch-up 정책) 이고 시각이 `send_hour_kst` 이상이며 `last_sent_month` ≠ 이번 달이면 1회 발신. 25일에 Mac이 꺼져 있었어도 26~월말 사이 켜면 그 시점에 발신. 다음 달로 넘어가면 포기.
   - 발신은 **`--service imessage`** (2026-08-03 전환, 아래 사고 참고). 가희님은 iMessage 사용자다.
   - 성공 시 `last_sent_month` 와 함께 **`last_sent_at`** 을 찍는다 — 답신 미도착 감시의 시작점.
1-b. **답신 미도착 감시** (`_check_reply_overdue`) — 발신 후 `REPLY_GRACE_DAYS`(2일)가 지나도 가희님 답신이 없으면 관리자님께 텔레그램 1회. 유예 전이면 API 콜 0으로 즉시 반환하고, 답신이 확인되면 `last_sent_at` 을 지워 감시를 끝낸다. 같은 발신 건에 대한 재알림은 `reply_alert_for` 로 막는다. ⚠️ **재발신은 자동으로 하지 않는다** — 가희님이 늦게 답하는 경우 중복 독촉이 되고, 자동 발송 트리거를 늘리지 않는다는 방침에도 어긋난다.
2. **응답 폴링** — `imsg chats --json` → 가희 chat 찾기 → `imsg history --chat-id X --start <last_processed_message_at> --attachments --json`. 가희 발신(is_from_me≠true) 메시지만 처리.
3. **1차 스캔** (즉시 워터마크 갱신):
   - **이미지 첨부 있음** → 자동분개 X, 골디 텔레그램 알림만 ("이미지 답신 — 직접 처리 부탁")
   - **빈 메시지·우리 발신** → 스킵
   - **가희 텍스트** → 따로 모아둠 (분개 대상 후보)
4. **2차 처리 — 가희 텍스트가 여러 통이면 마지막만 분개**:
   - 가희가 수정·재발송했을 때 첫 메시지로 잘못 분개되는 사고 방지. 이전 N-1통은 워터마크만 갱신, 별도 알림으로 스킵 사실 보고.
   - **페어 0건** → 자동분개 X, 골디 텔레그램 알림 ("포맷 오류"). 워터마크 갱신 (재처리 무의미).
   - **페어 1건 이상** → 합계 계산 → 후잉 `가희주머니` 현재 잔고 조회 → 차액 분개:
     - `diff > 0`: `가희주머니 ← 가희비밀주머니_수익` (수익)
     - `diff < 0`: `기타비용 ← 가희주머니` (비용)
     - `diff == 0`: 분개 생략, 알림만
5. **분개 실패 시 워터마크 미갱신** — 후잉 API 일시 장애(503 등) 시 워터마크를 갱신하지 않아 다음 사이클(15분 후)에 같은 메시지가 다시 polling되어 재시도. 같은 사이클에 발송된 이미지·이전 텍스트 알림은 다음 사이클에 중복 발송될 수 있음 (실무상 허용 — 후잉 다운은 드물고 회복 빠름).
6. **완료 알림** — 골디 텔레그램으로 합계·차액·분개 방향 보고.

### state 파일

`state/gahee_reminder.json`:

```json
{
  "send_day_of_month": 25,
  "send_hour_kst": 10,
  "message_template": "가계부 업데이트 날이에요. 계좌잔액 보내주시면 자동으로 반영됩니다.",
  "last_sent_month": null,
  "last_processed_message_at": null,
  "last_sent_at": null,
  "reply_alert_for": null,
  "whooing_account_name": "가희주머니",
  "income_account": "가희비밀주머니_수익",
  "expense_account": "기타비용"
}
```

- `last_sent_month` = "YYYY-MM" 발신 직후 기록. 같은 달에 재발신 안 함.
- `last_processed_message_at` = imsg history `--start` 의 ISO 워터마크. 처리한 메시지의 created_at 최댓값.
- `last_sent_at` = 발신 시각(UTC Z). 답신 미도착 감시의 기준점. 답신 확인되면 지워진다.
- `reply_alert_for` = 이미 "답신 없음" 알림을 보낸 `last_sent_at` 값. 같은 발신 건 재알림 방지.
- 발신 문구·트리거 일자·계정명 변경은 모두 이 JSON 직접 편집. 코드 재배포 불필요. whooing-sync 가 매 사이클 다시 읽음.

### 수동 발신 (`--send-now`)

발송이 실패해 그 달을 놓쳤을 때의 복구 수단. 날짜 게이트를 무시하고 1회 발신하며 `last_sent_month` 는 건드리지 않아 정규 25일 발신이 살아있다.

```bash
python3 gahee_reminder.py --send-now [--dry-run]
```

⚠️ **에이전트 세션·코디 셸에서 실행하면 20초 타임아웃으로 실패한다.** `imsg send` 는 AppleEvents 로 Messages 를 구동하는데, **자동화 → 메시지** 권한이 `imsg` 와 iTerm2 에만 허용돼 있고 Claude Code 에는 거부로 박혀 있다. launchd 경유로 돌려야 한다 — 임시 oneshot plist 를 만들어 `kickstart` 하고 로그를 읽은 뒤 `bootout` + 삭제한다(2026-08-03 실행 방식).

### 2026-07-25 사고: 미발송이 성공으로 기록됨

7/25 리마인더가 실제로 발송되지 않았는데(`chat.db`: `service=SMS, is_sent=0, error=4`) 시스템은 성공으로 기록했다. 가희주머니 7월 잔액이 안 들어와 8/1 월간 결산에서 빠졌다.

- **왜 실패했나** — `--service sms` 강제가 이 수신자에게 **3전 3패**였다(5/25 ×2, 7/25 ×1 전부 `error=4`). 살아남은 2건(5/25·6/25)은 Messages 가 임의로 **RCS 로 바꿔** 보낸 운이었고, 7월엔 그 폴백이 안 일어났다. 전체 통계로도 iMessage 137/139 성공 vs SMS 실패 3건(전부 이 리마인더).
- **왜 몰랐나** — `imsg send` 는 Messages 에 넘기기만 성공하면 rc=0 이고, 실제 실패는 그 뒤 비동기로 `chat.db` 의 `error` 에 찍힌다. 코드가 rc 만 봐서 성공 처리 → 오탐 텔레그램 → `last_sent_month` 갱신 → 그 달 재시도 차단 → 8월엔 catch-up 이 당월만 보므로 7월분 영구 유실.
- **왜 발송 검증을 자동 경로에 안 넣었나** — `is_sent`/`error` 는 `chat.db` 에만 있고 `imsg history` JSON 은 노출하지 않는다(실패한 메시지도 정상 조회됨). 직접 sqlite 조회가 유일한 길인데 **launchd 의 `/usr/bin/python3` 는 FDA 가 없어 `authorization denied` 로 막힌다**(2026-08-03 실측). 그래서 자동 경로는 권한과 무관한 답신 기반 감시를 쓰고, 정밀 확인(`_verify_sent`)은 FDA 가 있는 컨텍스트에서 도는 `--send-now` 에만 붙였다.
- `error=4` 의 정확한 의미는 확증 못 함 — 사고 시점 imagent 로그가 이미 롤오프됐다.

### 수신자 설정

`/Users/snowoyh/.openclaw/credentials/gahee_imessage.json`:

```json
{
  "handle": "+821055595428",
  "display": "010-5559-5428",
  "normalized": "01055595428"
}
```

- `handle` 은 `imsg send --to` 에 그대로 사용. 국가코드 포함 E.164.
- `normalized` 는 chat identifier 매칭용 (양 끝 어떤 형식이든 `_normalize()` 가 같은 값으로 정규화).

### 파싱 규칙

정규식 `([가-힣A-Za-z][가-힣A-Za-z0-9()\s]{0,18}?)\s*[:：]\s*([\d,]+)` 로 `라벨 : 금액` 페어 추출. 풀와이드 콜론(`：`)·줄바꿈 모두 OK. 같은 라벨 중복 시 마지막 값 사용.

**안전장치 없음 (관리자님 결정 2026-05-21)** — 차액 임계치·금액 제한 검증 없이 무조건 분개. 자유 자연어 답신("국민 통장에 42995 있어요" 류)은 라벨이 이상하게 잡힐 수 있으나, 라벨이 어떻든 합계 자체는 보존되므로 분개 금액은 의도와 일치한다. 메모란에 원본 라벨이 그대로 박혀 사후 추적 가능.

### 분개 메모 형식

```
[자동] 가희 잔액 갱신 | 국민:42,995 신한:4,161,585 ... | 합계 15,349,787 (이전 25,372,598)
```

200자 truncate. 후잉 UI 거래내역에서 그대로 검색 가능.

### 트러블슈팅

- **첫 발신 후 응답이 안 잡힘** — `imsg chats --json` 으로 `+821055595428` chat 이 존재하는지 확인. 발신이 실패했으면 chat 자체가 안 생긴다.
- **계속 같은 메시지가 다시 분개됨** — `last_processed_message_at` 가 갱신 안 되고 있다는 뜻. dry-run 으로 호출되고 있는지 (`args.dry_run` true 면 state 저장 안 함) 확인.
- **이번 달 다시 발신하고 싶다** — `state/gahee_reminder.json` 의 `last_sent_month` 를 `null` 로 되돌리고 다음 사이클 대기.
- **발신·폴링 비활성화** — `state/gahee_reminder.json` 자체를 삭제하면 `run()` 이 즉시 return.
