[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# 💼 현빈 스킬
|
||||
|
||||
_재사용 가능한 패턴 모음. memory.md는 모든 활동의 로그(append-only firehose),
|
||||
이 폴더는 **검증된 패턴만 골라낸 것**입니다. 각 `*.md` 파일은 다음 호출 시
|
||||
현빈의 system prompt에 자동 주입됩니다._
|
||||
|
||||
## 어떻게 채우나요?
|
||||
- 텔레그램에서 `/skill` (직전 산출물 자동 승격)
|
||||
- VS Code 명령 팔레트: `Connect AI: 방금 산출물 → 스킬로 저장`
|
||||
- 직접 이 폴더에 `<주제>.md` 파일을 만들어도 됩니다 (`# 제목` + 본문)
|
||||
|
||||
`README.md` 자체는 system prompt에 주입되지 않습니다.
|
||||
@@ -0,0 +1,86 @@
|
||||
<!-- version: paypal_revenue_v1 -->
|
||||
# 💰 PayPal 매출 자동 분석
|
||||
|
||||
비즈니스 에이전트가 본인 PayPal 계정의 매출을 직접 분석. 일별/주별/월별 매출 + 통화별 + 환불 비율 + 최근 거래 마크다운 리포트.
|
||||
|
||||
## 한 번만 설정 — PayPal Developer App
|
||||
|
||||
### 1. PayPal Developer Dashboard
|
||||
- 접속: https://developer.paypal.com/dashboard/applications
|
||||
- 로그인 (PayPal Business 계정이 있어야 함)
|
||||
|
||||
### 2. 앱 생성
|
||||
- **Apps & Credentials** 메뉴
|
||||
- 처음 사용자 → 'Default Application' 이미 있음. 그거 써도 됨.
|
||||
- 새 앱 원하면 **Create App** 클릭
|
||||
- 앱 이름: "Connect AI Business Agent" 같은 식
|
||||
|
||||
### 3. 키 복사
|
||||
- 앱 상세 페이지에서:
|
||||
- **Client ID** 복사
|
||||
- **Client Secret** 복사 (show 클릭해서 보기)
|
||||
- 도구 설정에 붙여넣기
|
||||
|
||||
### 4. 권한 확인
|
||||
앱 상세 페이지 하단 **Features** 섹션에서:
|
||||
- ✅ **Transaction Search** 켜져있어야 함
|
||||
- 안 켜져있으면 토글 ON
|
||||
|
||||
## 모드
|
||||
|
||||
| MODE | 용도 | URL |
|
||||
|---|---|---|
|
||||
| **sandbox** | 테스트 (가짜 계정·가짜 돈) | api-m.sandbox.paypal.com |
|
||||
| **live** | 실제 운영 | api-m.paypal.com |
|
||||
|
||||
처음엔 **sandbox** 로 시작. 가짜 거래 만들어서 도구 동작 확인 후 live 전환.
|
||||
|
||||
샌드박스 거래 만들기: sandbox.paypal.com 에서 PayPal Developer 가 발급한 가짜 buyer/seller 계정으로 결제 시뮬레이션.
|
||||
|
||||
## 설정 (config)
|
||||
|
||||
| 키 | 의미 |
|
||||
|---|---|
|
||||
| `MODE` | `sandbox` 또는 `live` |
|
||||
| `CLIENT_ID` | PayPal 앱 Client ID |
|
||||
| `CLIENT_SECRET` | PayPal 앱 Client Secret (UI에서 password 필드로 가려짐) |
|
||||
| `LOOKBACK_DAYS` | 분석할 과거 일수 (기본 30) |
|
||||
| `CURRENCY` | 기본 통화 (USD/KRW/EUR). 비우면 모든 통화 표시 |
|
||||
|
||||
## 출력
|
||||
|
||||
마크다운 리포트:
|
||||
- 통화별 매출 표 (Gross, 환불, 수수료, 순매출, 거래수)
|
||||
- 기간별 매출 (오늘 · 지난 7일 · 지난 30일)
|
||||
- 평균/최대/최소 거래액
|
||||
- 최근 거래 10건 (일시·금액·종류)
|
||||
- 환불율 경고 (10% 초과 시)
|
||||
- 다음 액션 인사이트
|
||||
|
||||
## 사용 예시 (대화)
|
||||
|
||||
```
|
||||
사용자: "비즈니스 에이전트, 우리 회사 PayPal 매출 어때?"
|
||||
→ CEO → business 분배
|
||||
→ business: <run_command>cd "..." && python3 paypal_revenue.py</run_command>
|
||||
→ 결과 분석 + "이번 주가 평균 대비 +20% — 무엇이 잘됐는지 파악 필요" 같은 인사이트
|
||||
```
|
||||
|
||||
## 한계
|
||||
|
||||
- PayPal Transaction Search API: 최근 3년 데이터까지
|
||||
- 한 번 호출 = 최대 31일 × 500건 (자동 페이지네이션 처리)
|
||||
- Rate limit: 무료 계정 분당 60 요청 — 일반 사용엔 충분
|
||||
- 분쟁·세금·환율 변환은 안 함 (분석만)
|
||||
|
||||
## 보안
|
||||
|
||||
- `CLIENT_SECRET` 은 도구 설정 (password 필드) 에 저장. `.gitignore` 적용된 `_agents/business/tools/*.json` 에만 있음.
|
||||
- API 호출은 Connect AI 익스텐션이 로컬에서 직접 → 외부 서버 경유 없음.
|
||||
- token 메모리에만 존재, 디스크 저장 X.
|
||||
|
||||
## 다음 단계 (계획)
|
||||
|
||||
- Stripe·Toss 매출 통합 → 전체 결제 채널 한 리포트
|
||||
- 일별 추세 그래프 (matplotlib)
|
||||
- 월별 P&L 자동 생성 → `_company/_shared/pnl_<month>.md`
|
||||
@@ -0,0 +1,468 @@
|
||||
#!/usr/bin/env python3
|
||||
# version: paypal_revenue_v3
|
||||
"""PayPal 매출 자동 분석 — Connect AI 비즈니스 에이전트 전용.
|
||||
|
||||
흐름:
|
||||
1. CLIENT_ID + CLIENT_SECRET 으로 OAuth2 access token 발급
|
||||
2. Transaction Search API 호출 (LOOKBACK_DAYS 기간)
|
||||
3. 거래 파싱 → 매출·환불·평균액·통화별 집계
|
||||
4. 마크다운 리포트 stdout 출력
|
||||
|
||||
config (paypal_revenue.json):
|
||||
MODE — 'sandbox' (테스트) | 'live' (실제). 기본 sandbox
|
||||
CLIENT_ID — PayPal Developer Dashboard 에서 발급
|
||||
CLIENT_SECRET — 같은 곳, 비밀로 보관 (password 필드)
|
||||
LOOKBACK_DAYS — 분석할 과거 일수 (기본 30)
|
||||
CURRENCY — 기본 통화 (USD/KRW 등). 비우면 모든 통화 표시
|
||||
|
||||
발급: https://developer.paypal.com/dashboard/applications → Apps & Credentials
|
||||
샌드박스 테스트: sandbox.paypal.com 계정 무료 생성 가능
|
||||
"""
|
||||
import os, sys, json, base64, urllib.request, urllib.parse, urllib.error
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CONFIG = os.path.join(HERE, "paypal_revenue.json")
|
||||
|
||||
|
||||
def _log(msg, kind="info"):
|
||||
prefix = {"info": "💰", "ok": "✅", "warn": "⚠️ ", "err": "❌", "step": "▸"}.get(kind, "•")
|
||||
print(f"{prefix} {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _load():
|
||||
if not os.path.exists(CONFIG):
|
||||
return {}
|
||||
try:
|
||||
with open(CONFIG, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _base_url(mode: str) -> str:
|
||||
return "https://api-m.paypal.com" if mode.lower() == "live" else "https://api-m.sandbox.paypal.com"
|
||||
|
||||
|
||||
def _has_reporting_scope(token_response: dict) -> bool:
|
||||
"""v2: OAuth 응답의 scope 필드에 Reporting (Transaction Search) 권한 있는지 검사.
|
||||
PayPal Dashboard 앱 설정 → Features → Transaction Search 체크 + Save 안 했으면 False.
|
||||
사용자에게 친절한 안내 띄우는 용도."""
|
||||
scopes = (token_response.get("scope") or "").split()
|
||||
return any("reporting" in s for s in scopes)
|
||||
|
||||
|
||||
def _get_access_token_full(base_url: str, client_id: str, client_secret: str) -> dict:
|
||||
"""v2: OAuth2 client_credentials grant — token + scope 둘 다 반환.
|
||||
scope 검사로 사용자 안내 (Transaction Search 권한 부재 사전 감지)."""
|
||||
auth = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/v1/oauth2/token",
|
||||
data=b"grant_type=client_credentials",
|
||||
headers={
|
||||
"Authorization": f"Basic {auth}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return json.loads(r.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
err_body = e.read().decode(errors="ignore")[:200]
|
||||
raise RuntimeError(f"OAuth 실패 (HTTP {e.code}): {err_body}")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"OAuth 요청 실패: {e}")
|
||||
|
||||
|
||||
def _get_access_token(base_url: str, client_id: str, client_secret: str) -> str:
|
||||
"""레거시 호환 — token 만 반환."""
|
||||
return _get_access_token_full(base_url, client_id, client_secret)["access_token"]
|
||||
|
||||
|
||||
def _fetch_transactions(base_url: str, token: str, start: datetime, end: datetime, currency_filter: str = ""):
|
||||
"""Transaction Search API — 페이지네이션 자동 처리.
|
||||
공식 API 는 한 번에 최대 31일·500건 → 여러 페이지로 나눠 호출."""
|
||||
all_txs = []
|
||||
cur = start
|
||||
while cur < end:
|
||||
page_end = min(cur + timedelta(days=31), end)
|
||||
params = {
|
||||
# v3: PayPal Transaction Search 는 마이크로초 포함 ISO 형식 거부.
|
||||
# 초 단위까지만 + Z timezone 으로 강제. strftime 으로 안전 포맷.
|
||||
"start_date": cur.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"end_date": page_end.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"fields": "all",
|
||||
"page_size": "500",
|
||||
"page": "1",
|
||||
}
|
||||
if currency_filter:
|
||||
params["transaction_currency"] = currency_filter
|
||||
url = f"{base_url}/v1/reporting/transactions?" + urllib.parse.urlencode(params)
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
data = json.loads(r.read().decode())
|
||||
txs = data.get("transaction_details", [])
|
||||
all_txs.extend(txs)
|
||||
_log(f"{cur.date()} ~ {page_end.date()}: {len(txs)}건 수신", "step")
|
||||
total_pages = int(data.get("total_pages", 1))
|
||||
if total_pages > 1:
|
||||
for p in range(2, total_pages + 1):
|
||||
params["page"] = str(p)
|
||||
url2 = f"{base_url}/v1/reporting/transactions?" + urllib.parse.urlencode(params)
|
||||
req2 = urllib.request.Request(url2, headers={"Authorization": f"Bearer {token}"})
|
||||
with urllib.request.urlopen(req2, timeout=20) as r2:
|
||||
d2 = json.loads(r2.read().decode())
|
||||
all_txs.extend(d2.get("transaction_details", []))
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode(errors="ignore")[:300]
|
||||
_log(f"거래 조회 실패 ({cur.date()}~{page_end.date()}): HTTP {e.code} {body}", "warn")
|
||||
except Exception as e:
|
||||
_log(f"거래 조회 예외: {e}", "warn")
|
||||
cur = page_end
|
||||
return all_txs
|
||||
|
||||
|
||||
def _parse_project_from_subject(subject: str):
|
||||
"""v2: PayPal createOrder 의 description 에서 게임/프로젝트 + 아이템 추출.
|
||||
규약: "{Project Name} — {Item Name}" (em-dash 또는 -- 또는 :).
|
||||
예시:
|
||||
"Neon Survivor — Premium Pack" → ("neon-survivor", "Premium Pack")
|
||||
"Neon Survivor — Revive" → ("neon-survivor", "Revive")
|
||||
"Chick Game: Custom Skin" → ("chick-game", "Custom Skin")
|
||||
구분자 못 찾으면 전체를 프로젝트로 취급 + item = "(unspecified)".
|
||||
"""
|
||||
if not subject:
|
||||
return "(unknown)", "(unspecified)"
|
||||
s = subject.strip()
|
||||
for sep in [" — ", " -- ", " – ", ": "]:
|
||||
if sep in s:
|
||||
proj, item = s.split(sep, 1)
|
||||
slug = proj.strip().lower().replace(" ", "-")
|
||||
return slug or "(unknown)", item.strip() or "(unspecified)"
|
||||
slug = s.lower().replace(" ", "-")
|
||||
return slug or "(unknown)", "(unspecified)"
|
||||
|
||||
|
||||
def _summarize(txs, default_currency: str = ""):
|
||||
"""거래 리스트 → 마크다운 리포트."""
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = datetime(now.year, now.month, now.day, tzinfo=timezone.utc)
|
||||
week_start = today_start - timedelta(days=7)
|
||||
month_start = today_start - timedelta(days=30)
|
||||
|
||||
by_currency = {} # {USD: {"gross": float, "fees": float, "refunds": float, "count": int}}
|
||||
by_period = {"today": 0.0, "week": 0.0, "month": 0.0}
|
||||
by_project = {} # v2: {"neon-survivor": {"gross": float, "count": int, "currency": "USD",
|
||||
# "items": {"Premium Pack": {"gross": float, "count": int}}}}
|
||||
transactions_clean = [] # 정상 거래 (T0000 = 일반 결제)
|
||||
refunds = []
|
||||
|
||||
for t in txs:
|
||||
info = t.get("transaction_info", {})
|
||||
amount = info.get("transaction_amount", {})
|
||||
currency = amount.get("currency_code", "USD")
|
||||
value = float(amount.get("value", "0") or 0)
|
||||
status = info.get("transaction_status", "")
|
||||
event_code = info.get("transaction_event_code", "")
|
||||
ts_str = info.get("transaction_initiation_date", "")
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
ts = None
|
||||
|
||||
if currency not in by_currency:
|
||||
by_currency[currency] = {"gross": 0.0, "fees": 0.0, "refunds": 0.0, "count": 0}
|
||||
c = by_currency[currency]
|
||||
|
||||
is_refund = event_code.startswith("T1") or "REFUND" in event_code or value < 0
|
||||
if is_refund:
|
||||
c["refunds"] += abs(value)
|
||||
refunds.append((ts, value, currency))
|
||||
else:
|
||||
c["gross"] += value
|
||||
c["count"] += 1
|
||||
transactions_clean.append((ts, value, currency))
|
||||
if ts and currency == (default_currency or currency):
|
||||
if ts >= today_start:
|
||||
by_period["today"] += value
|
||||
if ts >= week_start:
|
||||
by_period["week"] += value
|
||||
if ts >= month_start:
|
||||
by_period["month"] += value
|
||||
# v2: 프로젝트별 그룹화 (정상 거래만 집계 — 환불은 별도 통계)
|
||||
subject = info.get("transaction_subject", "") or info.get("transaction_note", "")
|
||||
proj, item = _parse_project_from_subject(subject)
|
||||
if proj not in by_project:
|
||||
by_project[proj] = {"gross": 0.0, "count": 0, "currency": currency, "items": {}}
|
||||
p = by_project[proj]
|
||||
p["gross"] += value
|
||||
p["count"] += 1
|
||||
if item not in p["items"]:
|
||||
p["items"][item] = {"gross": 0.0, "count": 0}
|
||||
p["items"][item]["gross"] += value
|
||||
p["items"][item]["count"] += 1
|
||||
fee = float(info.get("fee_amount", {}).get("value", "0") or 0)
|
||||
c["fees"] += abs(fee)
|
||||
|
||||
# 마크다운 리포트
|
||||
lines = []
|
||||
lines.append(f"# 💰 PayPal 매출 분석")
|
||||
lines.append(f"_{now.isoformat(timespec='minutes')} · 최근 거래 {len(txs)}건_")
|
||||
lines.append("")
|
||||
|
||||
if not txs:
|
||||
lines.append("> ⚠️ 분석 기간에 거래가 없어요. PayPal Developer Dashboard 에서 모드(sandbox/live)·기간·계정을 확인하세요.")
|
||||
lines.append("")
|
||||
lines.append("**가능한 원인:**")
|
||||
lines.append("- 샌드박스 모드인데 실제 결제 데이터가 없음 → sandbox.paypal.com 에서 거래 시뮬레이션")
|
||||
lines.append("- API 권한 부족 → Developer Dashboard 에서 'Transaction Search' 권한 활성화")
|
||||
lines.append("- 너무 짧은 기간 → LOOKBACK_DAYS 늘려보기")
|
||||
return "\n".join(lines)
|
||||
|
||||
# 통화별 집계
|
||||
lines.append("## 📊 통화별 매출")
|
||||
lines.append("")
|
||||
lines.append("| 통화 | 매출 (Gross) | 환불 | 수수료 | 순매출 | 거래수 |")
|
||||
lines.append("|---|---|---|---|---|---|")
|
||||
for cur, d in sorted(by_currency.items()):
|
||||
net = d["gross"] - d["refunds"] - d["fees"]
|
||||
lines.append(f"| **{cur}** | {d['gross']:,.2f} | -{d['refunds']:,.2f} | -{d['fees']:,.2f} | **{net:,.2f}** | {d['count']}건 |")
|
||||
lines.append("")
|
||||
|
||||
# v2: 프로젝트(게임) 별 매출 — 카탈로그에 있는 게임들이 description 으로 자동 분류됨
|
||||
if by_project:
|
||||
lines.append("## 🎮 프로젝트별 매출")
|
||||
lines.append("")
|
||||
lines.append("| 프로젝트 | 거래 수 | 매출 | 통화 | 상위 아이템 |")
|
||||
lines.append("|---|---|---|---|---|")
|
||||
sorted_projects = sorted(by_project.items(), key=lambda x: -x[1]["gross"])
|
||||
for proj, p in sorted_projects:
|
||||
top_items = sorted(p["items"].items(), key=lambda x: -x[1]["gross"])[:2]
|
||||
top_str = ", ".join(f"{name} ({d['count']}건)" for name, d in top_items)
|
||||
lines.append(f"| **{proj}** | {p['count']}건 | {p['gross']:,.2f} | {p['currency']} | {top_str} |")
|
||||
lines.append("")
|
||||
# 상세 아이템 분해 (각 프로젝트별)
|
||||
for proj, p in sorted_projects:
|
||||
if len(p["items"]) <= 1:
|
||||
continue
|
||||
lines.append(f"### 🎯 {proj} 아이템 분해")
|
||||
lines.append("")
|
||||
lines.append("| 아이템 | 거래 수 | 매출 | ARPU |")
|
||||
lines.append("|---|---|---|---|")
|
||||
for name, d in sorted(p["items"].items(), key=lambda x: -x[1]["gross"]):
|
||||
arpu = d["gross"] / d["count"] if d["count"] > 0 else 0
|
||||
lines.append(f"| {name} | {d['count']}건 | {d['gross']:,.2f} | {arpu:,.2f} |")
|
||||
lines.append("")
|
||||
|
||||
# 기간별 (default_currency 기준)
|
||||
primary_cur = default_currency or (sorted(by_currency.items(), key=lambda x: -x[1]["gross"])[0][0] if by_currency else "USD")
|
||||
lines.append(f"## 📅 기간별 매출 ({primary_cur})")
|
||||
lines.append("")
|
||||
lines.append(f"- **오늘**: {by_period['today']:,.2f} {primary_cur}")
|
||||
lines.append(f"- **지난 7일**: {by_period['week']:,.2f} {primary_cur}")
|
||||
lines.append(f"- **지난 30일**: {by_period['month']:,.2f} {primary_cur}")
|
||||
lines.append("")
|
||||
# 평균 거래액
|
||||
if transactions_clean:
|
||||
primary_txs = [v for (_, v, c) in transactions_clean if c == primary_cur]
|
||||
if primary_txs:
|
||||
avg = sum(primary_txs) / len(primary_txs)
|
||||
lines.append(f"- 평균 거래액 ({primary_cur}): **{avg:,.2f}**")
|
||||
lines.append(f"- 최대 거래: {max(primary_txs):,.2f}")
|
||||
lines.append(f"- 최소 거래: {min(primary_txs):,.2f}")
|
||||
lines.append("")
|
||||
|
||||
# 최근 거래 10건
|
||||
lines.append("## 🕐 최근 거래 10건")
|
||||
lines.append("")
|
||||
lines.append("| 일시 | 금액 | 통화 | 종류 |")
|
||||
lines.append("|---|---|---|---|")
|
||||
sorted_txs = sorted(
|
||||
[(ts, v, c, "결제") for ts, v, c in transactions_clean] +
|
||||
[(ts, -v, c, "환불") for ts, v, c in refunds],
|
||||
key=lambda x: x[0] or datetime.min.replace(tzinfo=timezone.utc),
|
||||
reverse=True
|
||||
)[:10]
|
||||
for ts, v, c, kind in sorted_txs:
|
||||
ts_str = ts.strftime("%Y-%m-%d %H:%M") if ts else "?"
|
||||
sign = "+" if kind == "결제" else "-"
|
||||
lines.append(f"| {ts_str} | {sign}{abs(v):,.2f} | {c} | {kind} |")
|
||||
lines.append("")
|
||||
|
||||
# 환불 비율 경고
|
||||
total_count = sum(d["count"] for d in by_currency.values())
|
||||
if refunds and total_count > 0:
|
||||
refund_rate = len(refunds) / (total_count + len(refunds)) * 100
|
||||
if refund_rate > 10:
|
||||
lines.append(f"> 🚨 **환불율 경고**: {refund_rate:.1f}% — 평균(2~5%)보다 높음. 원인 분석 권장.")
|
||||
lines.append("")
|
||||
|
||||
# 인사이트
|
||||
lines.append("## 💡 다음 액션")
|
||||
if by_period['month'] > 0:
|
||||
weekly_avg = by_period['month'] / 4
|
||||
if by_period['week'] > weekly_avg * 1.2:
|
||||
lines.append(f"- 🚀 이번 주 매출({by_period['week']:,.0f})이 월 평균({weekly_avg:,.0f})보다 20%↑ — 무엇이 잘됐는지 파악")
|
||||
elif by_period['week'] < weekly_avg * 0.7:
|
||||
lines.append(f"- ⚠️ 이번 주 매출({by_period['week']:,.0f})이 월 평균({weekly_avg:,.0f})보다 30%↓ — 원인 점검")
|
||||
else:
|
||||
lines.append(f"- 📈 이번 주 매출({by_period['week']:,.0f})은 월 평균 추세 유지")
|
||||
if len(by_currency) > 1:
|
||||
lines.append(f"- 💱 {len(by_currency)}개 통화로 매출 발생 — 환율 변동 위험 분산 또는 헤지 검토")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _json_dump(txs, default_currency: str = ""):
|
||||
"""v2: OUTPUT=json 모드에서 호출. 마크다운 대신 watcher / 대시보드가 파싱하기
|
||||
쉬운 구조화 JSON 출력. 새 결제 감지 + 대시보드 그래프 양쪽에서 사용."""
|
||||
out = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(timespec='seconds'),
|
||||
"currency_filter": default_currency,
|
||||
"totals": {"by_currency": {}, "by_period": {"today": 0.0, "week": 0.0, "month": 0.0}},
|
||||
"by_project": {},
|
||||
"by_day": {}, # {"2026-05-12": {"USD": {"gross": float, "count": int}}}
|
||||
"transactions": [], # 최근 100건만
|
||||
}
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = datetime(now.year, now.month, now.day, tzinfo=timezone.utc)
|
||||
week_start = today_start - timedelta(days=7)
|
||||
month_start = today_start - timedelta(days=30)
|
||||
|
||||
for t in txs:
|
||||
info = t.get("transaction_info", {})
|
||||
amount = info.get("transaction_amount", {})
|
||||
currency = amount.get("currency_code", "USD")
|
||||
value = float(amount.get("value", "0") or 0)
|
||||
event_code = info.get("transaction_event_code", "")
|
||||
tx_id = info.get("transaction_id", "")
|
||||
subject = info.get("transaction_subject", "") or info.get("transaction_note", "")
|
||||
ts_str = info.get("transaction_initiation_date", "")
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
ts = None
|
||||
is_refund = event_code.startswith("T1") or "REFUND" in event_code or value < 0
|
||||
|
||||
# totals
|
||||
cur_bucket = out["totals"]["by_currency"].setdefault(currency, {"gross": 0.0, "refunds": 0.0, "fees": 0.0, "count": 0})
|
||||
if is_refund:
|
||||
cur_bucket["refunds"] += abs(value)
|
||||
else:
|
||||
cur_bucket["gross"] += value
|
||||
cur_bucket["count"] += 1
|
||||
if ts and currency == (default_currency or currency):
|
||||
if ts >= today_start: out["totals"]["by_period"]["today"] += value
|
||||
if ts >= week_start: out["totals"]["by_period"]["week"] += value
|
||||
if ts >= month_start: out["totals"]["by_period"]["month"] += value
|
||||
cur_bucket["fees"] += abs(float(info.get("fee_amount", {}).get("value", "0") or 0))
|
||||
|
||||
# by_project
|
||||
if not is_refund:
|
||||
proj, item = _parse_project_from_subject(subject)
|
||||
p = out["by_project"].setdefault(proj, {"gross": 0.0, "count": 0, "currency": currency, "items": {}})
|
||||
p["gross"] += value
|
||||
p["count"] += 1
|
||||
it = p["items"].setdefault(item, {"gross": 0.0, "count": 0})
|
||||
it["gross"] += value
|
||||
it["count"] += 1
|
||||
|
||||
# by_day (last 30 days)
|
||||
if ts and ts >= month_start and not is_refund:
|
||||
day_key = ts.strftime("%Y-%m-%d")
|
||||
d = out["by_day"].setdefault(day_key, {})
|
||||
db = d.setdefault(currency, {"gross": 0.0, "count": 0})
|
||||
db["gross"] += value
|
||||
db["count"] += 1
|
||||
|
||||
# transactions (recent first, cap 100)
|
||||
out["transactions"].append({
|
||||
"id": tx_id,
|
||||
"ts": ts.isoformat() if ts else "",
|
||||
"ts_epoch": int(ts.timestamp()) if ts else 0,
|
||||
"value": value,
|
||||
"currency": currency,
|
||||
"subject": subject,
|
||||
"event_code": event_code,
|
||||
"is_refund": is_refund,
|
||||
})
|
||||
|
||||
out["transactions"].sort(key=lambda x: x["ts_epoch"], reverse=True)
|
||||
out["transactions"] = out["transactions"][:100]
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
cfg = _load()
|
||||
mode = (cfg.get("MODE") or "sandbox").strip().lower()
|
||||
client_id = (cfg.get("CLIENT_ID") or "").strip()
|
||||
client_secret = (cfg.get("CLIENT_SECRET") or "").strip()
|
||||
lookback = int(os.environ.get("LOOKBACK_DAYS", cfg.get("LOOKBACK_DAYS", 30)))
|
||||
currency = (cfg.get("CURRENCY") or "").strip().upper()
|
||||
output_mode = (os.environ.get("OUTPUT") or "markdown").strip().lower()
|
||||
|
||||
if not client_id or not client_secret:
|
||||
_log("CLIENT_ID 또는 CLIENT_SECRET 비어있음. PayPal Developer Dashboard 에서 발급:", "err")
|
||||
_log(" https://developer.paypal.com/dashboard/applications", "info")
|
||||
_log(" → Apps & Credentials → 본인 앱 → Client ID + Secret 복사", "info")
|
||||
sys.exit(1)
|
||||
|
||||
base = _base_url(mode)
|
||||
_log(f"PayPal {mode.upper()} 모드 · 최근 {lookback}일 분석", "info")
|
||||
|
||||
try:
|
||||
token_resp = _get_access_token_full(base, client_id, client_secret)
|
||||
token = token_resp["access_token"]
|
||||
_log("OAuth 인증 성공", "ok")
|
||||
except Exception as e:
|
||||
_log(f"OAuth 실패: {e}", "err")
|
||||
sys.exit(1)
|
||||
|
||||
# v2: scope 검사 → Reporting (Transaction Search) 권한 없으면 친절 안내 후 종료
|
||||
if not _has_reporting_scope(token_resp):
|
||||
_log("Transaction Search (Reporting) 권한이 토큰에 없음", "err")
|
||||
_log(" PayPal Developer Dashboard → 본인 앱 → Features → ", "info")
|
||||
_log(" ☑ Transaction search 체크 → Save Changes (반드시!)", "info")
|
||||
_log(" 변경 후 1~3분 대기 → 다시 시도", "info")
|
||||
_log("", "info")
|
||||
_log(" 💡 자주 놓치는 곳:", "info")
|
||||
_log(" - Default Application 사용 중이면 새 앱 만들기 (Features 잠금 가능)", "info")
|
||||
_log(" - 좌상단 Sandbox/Live 토글이 입력한 자격증명과 같은 환경인지", "info")
|
||||
_log(" - Live 환경은 PayPal 비즈니스 인증 + 별도 권한 신청 필요할 수 있음", "info")
|
||||
if output_mode == "json":
|
||||
print(json.dumps({
|
||||
"error": "reporting_scope_missing",
|
||||
"message": "OAuth 토큰에 Transaction Search 권한 없음",
|
||||
"scope": token_resp.get("scope", ""),
|
||||
"fix": "PayPal Dashboard 앱 Features 에서 Transaction search 체크 + Save"
|
||||
}, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print("# 💰 PayPal 매출 분석\n")
|
||||
print("> ❌ **Transaction Search 권한 없음** — PayPal Dashboard 에서 활성화 필요")
|
||||
print()
|
||||
print("**해결 단계:**")
|
||||
print("1. https://developer.paypal.com/dashboard/applications")
|
||||
print("2. 좌상단 Sandbox/Live 토글 확인 (현재 모드: `" + mode + "`)")
|
||||
print("3. 본인 앱 클릭")
|
||||
print("4. **Features** 섹션 → ☑ **Transaction search** 체크")
|
||||
print("5. 페이지 하단 **Save Changes** 클릭 (필수!)")
|
||||
print("6. 1~3분 대기 후 매출 대시보드 다시 새로고침")
|
||||
sys.exit(2)
|
||||
|
||||
end = datetime.now(timezone.utc)
|
||||
start = end - timedelta(days=lookback)
|
||||
txs = _fetch_transactions(base, token, start, end, currency)
|
||||
_log(f"총 {len(txs)}건 거래 수집", "ok")
|
||||
|
||||
if output_mode == "json":
|
||||
print(json.dumps(_json_dump(txs, currency), ensure_ascii=False, indent=2))
|
||||
else:
|
||||
report = _summarize(txs, currency)
|
||||
print(report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user