@yoonion/mimi-seed-mcp 0.15.6 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,12 +75,12 @@ export ANTHROPIC_API_KEY=sk-ant-...
75
75
 
76
76
  ---
77
77
 
78
- ## 제공 도구 (150+ 개 · 20개 영역)
78
+ ## 제공 도구 (150+ 개 · 21개 영역)
79
79
 
80
80
  | 영역 | 도구 수 | 주요 도구 |
81
81
  |------|---------|-----------|
82
- | App Store Connect | 61 | `appstore_submit_for_review` / `appstore_upload_screenshot` / `appstore_update_product_review_note` / `appstore_upload_product_review_screenshot` |
83
- | Google Play | 37 | `playstore_submit_release` / `playstore_promote_release` / `playstore_replace_images` / `playstore_reply_review` / `playstore_verify_service_account` |
82
+ | App Store Connect | 63 | `appstore_submit_for_review` / `appstore_upload_screenshot` / `appstore_update_product_review_note` / `appstore_upload_product_review_screenshot` |
83
+ | Google Play | 39 | `playstore_submit_release` / `playstore_promote_release` / `playstore_replace_images` / `playstore_reply_review` / `playstore_verify_service_account` |
84
84
  | Firebase | 20 | `firebase_create_project` / `firebase_create_android_app` / `firebase_get_android_config` / `firebase_create_ios_app` |
85
85
  | AdMob | 7 | `admob_list_apps` / `admob_create_ad_unit` / `admob_get_today_earnings` / `admob_get_report` |
86
86
  | CI/CD (GitHub Actions · GitLab) | 6 | `ci_trigger_build` / `ci_get_build_status` / `ci_list_workflows` / `ci_cancel_build` |
@@ -90,6 +90,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
90
90
  | Google Ads | 6 | `googleads_list_campaigns` / `googleads_get_uac_report` / `googleads_get_campaign_report` |
91
91
  | Facebook | 6 | `facebook_post_photo` / `facebook_post_multi_photo` / `facebook_list_pages` |
92
92
  | Google Cloud IAM | 5 | `iam_create_service_account` / `iam_create_key` / `iam_add_iam_policy_binding` |
93
+ | GCP Billing | 4 | `gcp_get_billing_info` / `gcp_list_billing_projects` / `gcp_list_budgets` / `gcp_create_budget` |
93
94
  | BigQuery | 5 | `bigquery_run_query` / `bigquery_list_datasets` / `bigquery_get_table_schema` |
94
95
  | Threads | 7 | `threads_post` / `threads_post_video` / `threads_post_carousel` / `threads_refresh_token` |
95
96
  | TikTok Business | 7 | `tiktok_business_plan_video_post` / `tiktok_business_publish_video` / `tiktok_business_get_publish_status` |
@@ -69,6 +69,8 @@ you can paste. Pick the row for the job; batching two rows in one `select:` call
69
69
  | Firebase apps + services (incl. web) | `select:firebase_list_android_apps,firebase_list_ios_apps,firebase_list_web_apps,firebase_create_web_app,firebase_get_web_config,firebase_enable_service,firebase_list_enabled_services,firebase_delete_android_app,firebase_delete_ios_app,firebase_delete_web_app` |
70
70
  | Analytics wiring (Firebase ↔ GA4 ↔ BigQuery) | `select:firebase_link_analytics,firebase_get_analytics_details,ga4_list_account_summaries,ga4_list_properties,ga4_create_property,ga4_list_data_streams,ga4_create_data_stream,ga4_plan_bigquery_link,ga4_create_bigquery_link,ga4_run_report` |
71
71
  | BigQuery | `select:bigquery_auth_status,bigquery_list_datasets,bigquery_list_tables,bigquery_get_table_schema,bigquery_run_query` |
72
+ | GCP billing (Blaze 여부 · 비용 범위 · 예산) | `select:gcp_get_billing_info,gcp_list_billing_projects,gcp_list_budgets,gcp_create_budget` |
73
+ | Real revenue (스토어 정산 원장 — sandbox·테스터가 섞이지 않는 유일한 창구) | `select:appstore_get_sales_report,appstore_get_finance_report,playstore_list_financial_reports,playstore_get_financial_report` |
72
74
  | AdMob | `select:admob_list_accounts,admob_list_apps,admob_create_app,admob_create_ad_unit,admob_list_ad_units,admob_get_today_earnings,admob_get_report` |
73
75
  | Google Ads (UAC) | `select:googleads_config_status,googleads_save_config,googleads_list_accessible_customers,googleads_list_campaigns,googleads_get_campaign_report,googleads_get_uac_report` |
74
76
  | Search Console | `select:gsc_list_sites,gsc_list_sitemaps,gsc_get_sitemap,gsc_submit_sitemap,gsc_inspect_url,gsc_search_analytics` |
@@ -2,6 +2,13 @@ export interface AppStoreCredentials {
2
2
  issuerId: string;
3
3
  keyId: string;
4
4
  privateKey: string;
5
+ /**
6
+ * Sales and Trends / Finance 리포트 전용 판매자 번호.
7
+ *
8
+ * **API 로는 조회할 수 없다** — ASC > 지급 및 재무 보고서 화면에서 눈으로 읽어
9
+ * 여기 적어두는 수밖에 없다. 리포트 도구에만 쓰이므로 없어도 나머지는 다 동작한다.
10
+ */
11
+ vendorNumber?: string;
5
12
  }
6
13
  export declare function getAppStoreCredentials(): AppStoreCredentials | null;
7
14
  export declare function saveAppStoreCredentials(creds: AppStoreCredentials): void;
@@ -0,0 +1,53 @@
1
+ export type SalesFrequency = 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
2
+ /** 리포트 한 줄 = TSV 헤더명 → 값. 컬럼 구성은 reportType 마다 다르다. */
3
+ export type ReportRow = Record<string, string>;
4
+ export declare function parseTsv(text: string): ReportRow[];
5
+ export interface SalesLine {
6
+ sku: string;
7
+ title: string;
8
+ /** Apple 의 Product Type Identifier. IA* / IAY 계열이 인앱결제·구독이다. */
9
+ productType: string;
10
+ country: string;
11
+ /** 환불은 **음수 units** 로 들어온다 — 합계에서 자연히 상쇄된다. */
12
+ units: number;
13
+ customerPrice: number;
14
+ customerCurrency: string;
15
+ /** 개발자 수취액 **1개당** 금액. 총액은 units 를 곱해야 한다. */
16
+ proceedsPerUnit: number;
17
+ proceedsCurrency: string;
18
+ proceedsTotal: number;
19
+ }
20
+ export interface SalesSummary {
21
+ /** 데이터가 존재한 날짜들. 요청 범위 중 리포트가 없던 날은 빠진다. */
22
+ datesWithData: string[];
23
+ datesWithoutData: string[];
24
+ lines: SalesLine[];
25
+ /** 통화별 개발자 수취액 합계. 통화가 섞이므로 절대 하나로 더하지 않는다. */
26
+ proceedsByCurrency: Record<string, number>;
27
+ /** 유료 결제 건수 합계 (customerPrice > 0 인 줄의 units). */
28
+ paidUnits: number;
29
+ /** 무료 다운로드/설치 units — 매출과 무관하지만 같은 리포트에 섞여 온다. */
30
+ freeUnits: number;
31
+ }
32
+ /** 날짜 문자열(YYYY-MM-DD)을 하루씩 증가시키며 나열. endDate 포함. */
33
+ export declare function eachDate(startDate: string, endDate: string, maxDays: number): string[];
34
+ export declare function getSalesReport(options: {
35
+ vendorNumber?: string;
36
+ startDate: string;
37
+ endDate?: string;
38
+ frequency?: SalesFrequency;
39
+ reportType?: string;
40
+ reportSubType?: string;
41
+ version?: string;
42
+ }): Promise<SalesSummary>;
43
+ export declare function getFinanceReport(options: {
44
+ vendorNumber?: string;
45
+ /** YYYY-MM. **Apple 회계월**이라 달력월과 어긋날 수 있다 — 도구 설명 참고. */
46
+ reportDate: string;
47
+ regionCode?: string;
48
+ reportType?: 'FINANCIAL' | 'FINANCE_DETAIL';
49
+ }): Promise<{
50
+ notFound: boolean;
51
+ rows: ReportRow[];
52
+ raw: string;
53
+ }>;
@@ -0,0 +1,175 @@
1
+ // App Store Connect Sales and Trends / Finance 리포트.
2
+ //
3
+ // 왜 별도 모듈인가: 이 두 엔드포인트만 **JSON 이 아니다.** gzip 으로 압축된 TSV 를
4
+ // 돌려주므로 appstore/http.ts 의 apiRequest(JSON.parse 전제)를 그대로 쓸 수 없다.
5
+ //
6
+ // 왜 이 도구가 필요한가: GA4 의 in_app_purchase / 자체 결제 이벤트는 **sandbox 를
7
+ // 구분하지 못한다.** TestFlight·Xcode 설치에서 일어난 결제는 실제 청구가 0원인데도
8
+ // 클라이언트 입장에선 성공한 결제라 그대로 이벤트가 나간다. 실매출을 세려면 애초에
9
+ // sandbox 가 들어오지 않는 창구를 봐야 하고, 그게 이 리포트다.
10
+ import zlib from 'node:zlib';
11
+ import { fetchWithTimeout } from '../lib/http.js';
12
+ import { getAppStoreCredentials } from './auth.js';
13
+ import { friendlyAppStoreError } from './errors.js';
14
+ import { authHeadersOrThrow, V1_BASE } from './http.js';
15
+ /**
16
+ * vendorNumber 해석: 명시 인자 > ~/.mimi-seed/appstore.json 의 vendorNumber.
17
+ *
18
+ * API 로 조회할 방법이 없어서 둘 다 없으면 여기서 끊는다. 빈 문자열로 요청을 보내면
19
+ * Apple 은 404 를 주는데, 그건 "그 날 매출 0" 과 **구분되지 않는다** — 설정 누락이
20
+ * 매출 0 으로 둔갑하는 게 이 도구에서 제일 위험한 오답이라 사전에 막는다.
21
+ */
22
+ function resolveVendorNumber(explicit) {
23
+ const vendorNumber = explicit?.trim() || getAppStoreCredentials()?.vendorNumber?.trim();
24
+ if (!vendorNumber) {
25
+ throw new Error([
26
+ '❌ vendorNumber 가 없어 — 매출 리포트는 판매자 번호가 반드시 필요해.',
27
+ '',
28
+ 'App Store Connect > 비즈니스(지급 및 재무 보고서) 상단에 있는 8~9자리 숫자야.',
29
+ 'API 로는 조회할 수 없으니 한 번만 저장해 두면 이후 생략할 수 있어:',
30
+ ' ~/.mimi-seed/appstore.json 에 "vendorNumber": "<번호>" 추가',
31
+ '또는 이 도구의 vendorNumber 인자로 직접 넘겨.',
32
+ ].join('\n'));
33
+ }
34
+ return vendorNumber;
35
+ }
36
+ /**
37
+ * gzip TSV 를 받아 헤더 기준 객체 배열로 만든다.
38
+ *
39
+ * Apple 은 **데이터가 없는 날짜에 404** 를 준다 — 인증 실패나 잘못된 vendorNumber 도
40
+ * 같은 404 로 올 수 있어서, 호출부가 "매출 0" 과 "설정 틀림" 을 구분할 수 있도록
41
+ * 여기서는 빈 배열로 뭉개지 않고 notFound 플래그를 그대로 올려보낸다.
42
+ */
43
+ async function fetchReport(resourcePath, params) {
44
+ const authHeaders = await authHeadersOrThrow();
45
+ const query = new URLSearchParams(params).toString();
46
+ const response = await fetchWithTimeout(`${V1_BASE}${resourcePath}?${query}`, {
47
+ headers: { ...authHeaders, Accept: 'application/a-gzip' },
48
+ });
49
+ if (response.status === 404)
50
+ return { notFound: true, rows: [], raw: '' };
51
+ if (!response.ok) {
52
+ throw friendlyAppStoreError(response.status, await response.text());
53
+ }
54
+ const buffer = Buffer.from(await response.arrayBuffer());
55
+ // 빈 리포트는 gzip 헤더조차 없이 0바이트로 오는 경우가 있다.
56
+ if (buffer.length === 0)
57
+ return { notFound: false, rows: [], raw: '' };
58
+ let text;
59
+ try {
60
+ text = zlib.gunzipSync(buffer).toString('utf-8');
61
+ }
62
+ catch {
63
+ // Accept 헤더가 무시되고 평문이 온 경우(에러 본문 포함) — 그대로 파싱을 시도한다.
64
+ text = buffer.toString('utf-8');
65
+ }
66
+ return { notFound: false, rows: parseTsv(text), raw: text };
67
+ }
68
+ export function parseTsv(text) {
69
+ const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
70
+ if (lines.length < 2)
71
+ return [];
72
+ const headers = lines[0].split('\t').map((h) => h.trim());
73
+ return lines.slice(1).map((line) => {
74
+ const cells = line.split('\t');
75
+ const row = {};
76
+ headers.forEach((header, i) => {
77
+ row[header] = (cells[i] ?? '').trim();
78
+ });
79
+ return row;
80
+ });
81
+ }
82
+ function toNumber(value) {
83
+ const n = Number.parseFloat((value ?? '').replace(/,/g, ''));
84
+ return Number.isFinite(n) ? n : 0;
85
+ }
86
+ /** SALES/SUMMARY 리포트 행을 매출 판독에 필요한 형태로만 좁힌다. */
87
+ function toSalesLine(row) {
88
+ const units = toNumber(row['Units']);
89
+ const proceedsPerUnit = toNumber(row['Developer Proceeds']);
90
+ return {
91
+ sku: row['SKU'] ?? '',
92
+ title: row['Title'] ?? '',
93
+ productType: row['Product Type Identifier'] ?? '',
94
+ country: row['Country Code'] ?? '',
95
+ units,
96
+ customerPrice: toNumber(row['Customer Price']),
97
+ customerCurrency: row['Customer Currency'] ?? '',
98
+ proceedsPerUnit,
99
+ proceedsCurrency: row['Currency of Proceeds'] ?? '',
100
+ proceedsTotal: units * proceedsPerUnit,
101
+ };
102
+ }
103
+ /** 날짜 문자열(YYYY-MM-DD)을 하루씩 증가시키며 나열. endDate 포함. */
104
+ export function eachDate(startDate, endDate, maxDays) {
105
+ const start = Date.parse(`${startDate}T00:00:00Z`);
106
+ const end = Date.parse(`${endDate}T00:00:00Z`);
107
+ if (!Number.isFinite(start) || !Number.isFinite(end)) {
108
+ throw new Error('날짜는 YYYY-MM-DD 형식이어야 해.');
109
+ }
110
+ if (end < start)
111
+ throw new Error('endDate 가 startDate 보다 앞설 수 없어.');
112
+ const dates = [];
113
+ for (let t = start; t <= end; t += 86_400_000) {
114
+ if (dates.length >= maxDays) {
115
+ throw new Error(`한 번에 조회할 수 있는 일수는 ${maxDays}일까지야. 범위를 나눠서 호출해.`);
116
+ }
117
+ dates.push(new Date(t).toISOString().slice(0, 10));
118
+ }
119
+ return dates;
120
+ }
121
+ /** DAILY 는 하루에 리포트 하나라, 범위 조회는 날짜별 호출을 합치는 수밖에 없다. */
122
+ const MAX_DAILY_RANGE = 62;
123
+ export async function getSalesReport(options) {
124
+ const vendorNumber = resolveVendorNumber(options.vendorNumber);
125
+ const frequency = options.frequency ?? 'DAILY';
126
+ const endDate = options.endDate ?? options.startDate;
127
+ // DAILY 만 날짜를 펼친다. WEEKLY/MONTHLY/YEARLY 는 reportDate 자체가 기간을 뜻해서
128
+ // 하루씩 도는 게 의미가 없고, 오히려 같은 기간을 며칠치 중복 합산하게 된다.
129
+ const reportDates = frequency === 'DAILY' ? eachDate(options.startDate, endDate, MAX_DAILY_RANGE) : [options.startDate];
130
+ const datesWithData = [];
131
+ const datesWithoutData = [];
132
+ const rows = [];
133
+ for (const reportDate of reportDates) {
134
+ const result = await fetchReport('/salesReports', {
135
+ 'filter[frequency]': frequency,
136
+ 'filter[reportType]': options.reportType ?? 'SALES',
137
+ 'filter[reportSubType]': options.reportSubType ?? 'SUMMARY',
138
+ 'filter[vendorNumber]': vendorNumber,
139
+ 'filter[reportDate]': reportDate,
140
+ 'filter[version]': options.version ?? '1_0',
141
+ });
142
+ if (result.notFound || result.rows.length === 0)
143
+ datesWithoutData.push(reportDate);
144
+ else {
145
+ datesWithData.push(reportDate);
146
+ rows.push(...result.rows);
147
+ }
148
+ }
149
+ const lines = rows.map(toSalesLine);
150
+ const proceedsByCurrency = {};
151
+ let paidUnits = 0;
152
+ let freeUnits = 0;
153
+ for (const line of lines) {
154
+ if (line.proceedsTotal !== 0) {
155
+ const currency = line.proceedsCurrency || 'UNKNOWN';
156
+ proceedsByCurrency[currency] = (proceedsByCurrency[currency] ?? 0) + line.proceedsTotal;
157
+ }
158
+ if (line.customerPrice > 0)
159
+ paidUnits += line.units;
160
+ else
161
+ freeUnits += line.units;
162
+ }
163
+ for (const currency of Object.keys(proceedsByCurrency)) {
164
+ proceedsByCurrency[currency] = Math.round(proceedsByCurrency[currency] * 100) / 100;
165
+ }
166
+ return { datesWithData, datesWithoutData, lines, proceedsByCurrency, paidUnits, freeUnits };
167
+ }
168
+ export async function getFinanceReport(options) {
169
+ return fetchReport('/financeReports', {
170
+ 'filter[vendorNumber]': resolveVendorNumber(options.vendorNumber),
171
+ 'filter[reportDate]': options.reportDate,
172
+ 'filter[regionCode]': options.regionCode ?? 'ZZ',
173
+ 'filter[reportType]': options.reportType ?? 'FINANCIAL',
174
+ });
175
+ }
@@ -0,0 +1,50 @@
1
+ import type { OAuth2Client } from 'google-auth-library';
2
+ /** `01F1F4-FD007B-2973A7` / `billingAccounts/01F1F4-...` 어느 형태로 줘도 정규화. */
3
+ export declare function normalizeBillingAccount(input: string): string;
4
+ export type BillingInfo = {
5
+ projectId: string;
6
+ billingEnabled: boolean;
7
+ billingAccountName: string | null;
8
+ /** 사람이 읽을 판정 — Blaze/Spark 오판을 막기 위해 문장으로 준다. */
9
+ verdict: string;
10
+ };
11
+ export declare function getBillingInfo(auth: OAuth2Client, projectId: string): Promise<BillingInfo>;
12
+ /**
13
+ * 결제 계정에 붙은 프로젝트 목록 = **비용 범위 확인**.
14
+ * 여기서 프로젝트가 여러 개면 계정 전체 예산은 의미가 없고 프로젝트 필터를 써야 한다.
15
+ */
16
+ export declare function listBillingProjects(auth: OAuth2Client, billingAccount: string, quotaProjectId?: string): Promise<{
17
+ billingAccount: string;
18
+ count: number;
19
+ shared: boolean;
20
+ projects: {
21
+ projectId: string | null | undefined;
22
+ billingEnabled: boolean;
23
+ }[];
24
+ }>;
25
+ export declare function listBudgets(auth: OAuth2Client, billingAccount: string, quotaProjectId?: string): Promise<{
26
+ name: string | null | undefined;
27
+ displayName: string | null | undefined;
28
+ amount: string;
29
+ projects: string[];
30
+ thresholds: (number | null | undefined)[];
31
+ }[]>;
32
+ export type CreateBudgetInput = {
33
+ billingAccount: string;
34
+ displayName: string;
35
+ /** 통화 단위 정수 금액 (KRW 는 소수 없음 — 10000 = ₩10,000). */
36
+ amountUnits: number;
37
+ currencyCode?: string;
38
+ /** 감시할 프로젝트 ID 목록. **비우면 결제 계정 전체** — 공용 계정이면 소음이 된다. */
39
+ projectIds?: string[];
40
+ /** 알림 임계 비율 (0~1). 기본 0.5 / 0.9 / 1.0. */
41
+ thresholds?: number[];
42
+ /** quota 주체 프로젝트 — Billing Budget API 를 켜 둔 프로젝트. quotaHeaders 주석 참고. */
43
+ quotaProjectId?: string;
44
+ };
45
+ export declare function createBudget(auth: OAuth2Client, input: CreateBudgetInput): Promise<{
46
+ name: string | null | undefined;
47
+ displayName: string | null | undefined;
48
+ projects: string[];
49
+ thresholds: number[];
50
+ }>;
@@ -0,0 +1,138 @@
1
+ import { google } from '../lib/googleapis-lite.js';
2
+ /**
3
+ * Cloud Billing + Billing Budgets 래퍼.
4
+ *
5
+ * 앱마다 전용 Firebase/GCP 프로젝트를 만드는 컨벤션이라, 새 프로젝트가 생길 때마다
6
+ * "이 프로젝트가 Blaze 인가 → 결제 계정이 공용인가 → 예산 알림을 걸었나"가 반복 작업이 된다.
7
+ *
8
+ * 실제로 겪은 함정 2개가 이 모듈의 존재 이유다:
9
+ *
10
+ * 1. **Billing API 가 꺼져 있으면 조회 자체가 403** 이고, 그 403 을 "Spark 이라서"로
11
+ * 오해하기 쉽다. 실제로는 이미 Blaze 였다. `getBillingInfo` 는 이 구분을 명확히 한다
12
+ * (API 미활성 → firebase_enable_service 안내 / 활성인데 billingEnabled=false → 진짜 Spark).
13
+ *
14
+ * 2. **결제 계정이 회사 공용이면 계정 전체 예산은 쓸 수 없다.** 기존 지출(BigQuery export 등)만으로
15
+ * 즉시 임계를 넘겨 알림이 소음이 된다. 그래서 `createBudget` 은 `projects` 필터를 1급으로 받는다 —
16
+ * 프로젝트 범위 예산이 신규 앱 감시의 기본형이다.
17
+ *
18
+ * ⚠️ 예산은 **알림만 한다. 지출을 막지 않는다.** 하드 차단은 Pub/Sub → 결제 해제 함수뿐이고
19
+ * 그건 앱을 죽이는 조치다. 실질 방어는 각 서비스의 상한(예: functions maxInstances)이다.
20
+ *
21
+ * ⚠️ 권한은 **결제 계정 레벨 IAM** 이다(프로젝트 IAM 과 별개). 서비스 계정 키로는 대개 403 이고
22
+ * 사용자 OAuth(cloud-platform)로 호출해야 한다.
23
+ */
24
+ const billing = () => google.cloudbilling('v1');
25
+ const budgets = () => google.billingbudgets('v1');
26
+ /**
27
+ * 🔴 quota 프로젝트 지정이 **필수**다.
28
+ *
29
+ * 사용자 OAuth 로 호출하면 GCP 는 quota/billing 을 **OAuth 클라이언트의 프로젝트**에 청구한다.
30
+ * mimi-seed 의 OAuth 클라이언트 프로젝트에는 Cloud Billing API 가 없으므로, 지정하지 않으면
31
+ * 대상 프로젝트가 아무리 정상이어도 항상 이렇게 실패한다 (2026-08-04 실측):
32
+ *
33
+ * "Cloud Billing API has not been used in project <OAuth 클라이언트 프로젝트 번호> before or it is disabled"
34
+ *
35
+ * 이 메시지의 프로젝트 번호는 **우리가 조회하려는 프로젝트가 아니라 OAuth 클라이언트 쪽**이다 —
36
+ * 그래서 "대상 프로젝트에서 API 를 켰는데도 왜 403 이냐"로 헤매게 된다.
37
+ * `x-goog-user-project` 로 우리가 통제하는(그리고 API 를 켜 둔) 프로젝트를 quota 주체로 넘긴다.
38
+ */
39
+ function quotaHeaders(quotaProjectId) {
40
+ return quotaProjectId ? { headers: { 'x-goog-user-project': quotaProjectId } } : {};
41
+ }
42
+ /** `01F1F4-FD007B-2973A7` / `billingAccounts/01F1F4-...` 어느 형태로 줘도 정규화. */
43
+ export function normalizeBillingAccount(input) {
44
+ const trimmed = input.trim();
45
+ return trimmed.startsWith('billingAccounts/') ? trimmed : `billingAccounts/${trimmed}`;
46
+ }
47
+ export async function getBillingInfo(auth, projectId) {
48
+ // 조회 대상 프로젝트 자신을 quota 주체로 쓴다 — 거기 Billing API 가 켜져 있어야 한다.
49
+ const res = await billing().projects.getBillingInfo({
50
+ auth,
51
+ name: `projects/${projectId}`,
52
+ ...quotaHeaders(projectId),
53
+ });
54
+ const enabled = res.data.billingEnabled ?? false;
55
+ const account = res.data.billingAccountName ?? null;
56
+ return {
57
+ projectId,
58
+ billingEnabled: enabled,
59
+ billingAccountName: account,
60
+ verdict: enabled
61
+ ? `Blaze — 결제 계정 ${account} 연결됨. Cloud Functions/Run 배포 가능.`
62
+ : 'Spark — 결제 계정 미연결. Cloud Functions 등 유료 서비스 배포 불가.',
63
+ };
64
+ }
65
+ /**
66
+ * 결제 계정에 붙은 프로젝트 목록 = **비용 범위 확인**.
67
+ * 여기서 프로젝트가 여러 개면 계정 전체 예산은 의미가 없고 프로젝트 필터를 써야 한다.
68
+ */
69
+ export async function listBillingProjects(auth, billingAccount, quotaProjectId) {
70
+ const name = normalizeBillingAccount(billingAccount);
71
+ const res = await billing().billingAccounts.projects.list({
72
+ auth,
73
+ name,
74
+ pageSize: 200,
75
+ ...quotaHeaders(quotaProjectId),
76
+ });
77
+ const projects = (res.data.projectBillingInfo ?? []).map((p) => ({
78
+ projectId: p.projectId,
79
+ billingEnabled: p.billingEnabled ?? false,
80
+ }));
81
+ return {
82
+ billingAccount: name,
83
+ count: projects.length,
84
+ shared: projects.length > 1,
85
+ projects,
86
+ };
87
+ }
88
+ export async function listBudgets(auth, billingAccount, quotaProjectId) {
89
+ const parent = normalizeBillingAccount(billingAccount);
90
+ const res = await budgets().billingAccounts.budgets.list({
91
+ auth,
92
+ parent,
93
+ pageSize: 200,
94
+ ...quotaHeaders(quotaProjectId),
95
+ });
96
+ return (res.data.budgets ?? []).map((b) => ({
97
+ name: b.name,
98
+ displayName: b.displayName,
99
+ amount: b.amount?.specifiedAmount
100
+ ? `${b.amount.specifiedAmount.units ?? '0'} ${b.amount.specifiedAmount.currencyCode ?? ''}`.trim()
101
+ : b.amount?.lastPeriodAmount
102
+ ? '(직전 기간 금액)'
103
+ : '(미지정)',
104
+ projects: b.budgetFilter?.projects ?? [],
105
+ thresholds: (b.thresholdRules ?? []).map((t) => t.thresholdPercent),
106
+ }));
107
+ }
108
+ export async function createBudget(auth, input) {
109
+ const parent = normalizeBillingAccount(input.billingAccount);
110
+ const thresholds = input.thresholds?.length ? input.thresholds : [0.5, 0.9, 1.0];
111
+ const res = await budgets().billingAccounts.budgets.create({
112
+ auth,
113
+ parent,
114
+ requestBody: {
115
+ displayName: input.displayName,
116
+ budgetFilter: {
117
+ // projects 는 `projects/<번호 또는 ID>` 형태를 받는다.
118
+ ...(input.projectIds?.length
119
+ ? { projects: input.projectIds.map((p) => (p.startsWith('projects/') ? p : `projects/${p}`)) }
120
+ : {}),
121
+ },
122
+ amount: {
123
+ specifiedAmount: {
124
+ currencyCode: input.currencyCode ?? 'KRW',
125
+ units: String(input.amountUnits),
126
+ },
127
+ },
128
+ thresholdRules: thresholds.map((t) => ({ thresholdPercent: t })),
129
+ },
130
+ ...quotaHeaders(input.quotaProjectId),
131
+ });
132
+ return {
133
+ name: res.data.name,
134
+ displayName: res.data.displayName,
135
+ projects: res.data.budgetFilter?.projects ?? [],
136
+ thresholds,
137
+ };
138
+ }
@@ -16,6 +16,8 @@ import { analyticsadmin } from 'googleapis/build/src/apis/analyticsadmin/index.j
16
16
  import { analyticsdata } from 'googleapis/build/src/apis/analyticsdata/index.js';
17
17
  import { androidpublisher } from 'googleapis/build/src/apis/androidpublisher/index.js';
18
18
  import { bigquery } from 'googleapis/build/src/apis/bigquery/index.js';
19
+ import { billingbudgets } from 'googleapis/build/src/apis/billingbudgets/index.js';
20
+ import { cloudbilling } from 'googleapis/build/src/apis/cloudbilling/index.js';
19
21
  import { cloudresourcemanager } from 'googleapis/build/src/apis/cloudresourcemanager/index.js';
20
22
  import { firebase } from 'googleapis/build/src/apis/firebase/index.js';
21
23
  import { iam } from 'googleapis/build/src/apis/iam/index.js';
@@ -30,6 +32,8 @@ export declare const google: {
30
32
  analyticsdata: typeof analyticsdata;
31
33
  androidpublisher: typeof androidpublisher;
32
34
  bigquery: typeof bigquery;
35
+ billingbudgets: typeof billingbudgets;
36
+ cloudbilling: typeof cloudbilling;
33
37
  cloudresourcemanager: typeof cloudresourcemanager;
34
38
  firebase: typeof firebase;
35
39
  iam: typeof iam;
@@ -16,6 +16,8 @@ import { analyticsadmin } from 'googleapis/build/src/apis/analyticsadmin/index.j
16
16
  import { analyticsdata } from 'googleapis/build/src/apis/analyticsdata/index.js';
17
17
  import { androidpublisher } from 'googleapis/build/src/apis/androidpublisher/index.js';
18
18
  import { bigquery } from 'googleapis/build/src/apis/bigquery/index.js';
19
+ import { billingbudgets } from 'googleapis/build/src/apis/billingbudgets/index.js';
20
+ import { cloudbilling } from 'googleapis/build/src/apis/cloudbilling/index.js';
19
21
  import { cloudresourcemanager } from 'googleapis/build/src/apis/cloudresourcemanager/index.js';
20
22
  import { auth, firebase } from 'googleapis/build/src/apis/firebase/index.js';
21
23
  import { iam } from 'googleapis/build/src/apis/iam/index.js';
@@ -29,6 +31,8 @@ export const google = {
29
31
  analyticsdata,
30
32
  androidpublisher,
31
33
  bigquery,
34
+ billingbudgets,
35
+ cloudbilling,
32
36
  cloudresourcemanager,
33
37
  firebase,
34
38
  iam,
@@ -0,0 +1,65 @@
1
+ export type PlayFinancialReportType = 'earnings' | 'sales';
2
+ /**
3
+ * 버킷 이름 해석: 명시 인자 > ~/.mimi-seed/play-financials.json.
4
+ *
5
+ * 버킷 이름은 Play Console 에만 있고 API 로 못 얻는다(개발자 계정마다 다르다).
6
+ * 없으면 조용히 빈 결과를 주는 대신 여기서 끊는다 — "매출 0" 과 "설정 누락" 이
7
+ * 구분되지 않는 게 이 도구의 가장 위험한 오답이다.
8
+ */
9
+ export declare function resolveBucket(explicit?: string, packageName?: string): string;
10
+ export interface FinancialReportObject {
11
+ name: string;
12
+ size: number;
13
+ updated: string;
14
+ /** 파일명에서 뽑은 YYYYMM. 못 뽑으면 빈 문자열. */
15
+ yearMonth: string;
16
+ }
17
+ export declare function listFinancialReports(options: {
18
+ packageName?: string;
19
+ bucket?: string;
20
+ reportType?: PlayFinancialReportType;
21
+ }): Promise<{
22
+ bucket: string;
23
+ objects: FinancialReportObject[];
24
+ }>;
25
+ export interface EarningsSummary {
26
+ bucket: string;
27
+ files: string[];
28
+ rowCount: number;
29
+ /** 정산 통화별 순액 합계 — 수수료·세금·환불이 음수로 들어와 이미 상쇄된 값이다. */
30
+ netByMerchantCurrency: Record<string, number>;
31
+ /** Transaction Type 별 건수·금액. 'Charge' 만이 실제 구매다. */
32
+ byTransactionType: Record<string, {
33
+ count: number;
34
+ amount: number;
35
+ }>;
36
+ /**
37
+ * 상품별 집계 (Charge 행만). **통화별로 행이 갈린다** — 여러 통화를 한 숫자로 더하면
38
+ * IDR 1,000,000 과 KRW 3,000 이 섞여 아무 의미 없는 값이 된다.
39
+ */
40
+ byProduct: Array<{
41
+ productId: string;
42
+ title: string;
43
+ currency: string;
44
+ count: number;
45
+ amount: number;
46
+ }>;
47
+ /** 판독을 위해 남기는 원본 행. 기본은 생략된다. */
48
+ rows?: Array<Record<string, string>>;
49
+ }
50
+ export declare function getFinancialReport(options: {
51
+ yearMonth: string;
52
+ packageName?: string;
53
+ bucket?: string;
54
+ reportType?: PlayFinancialReportType;
55
+ includeRows?: boolean;
56
+ }): Promise<EarningsSummary>;
57
+ /**
58
+ * 최소 ZIP 리더 (stored + deflate).
59
+ *
60
+ * Node 에 zip 해제가 없고, 이 하나 때문에 의존성을 늘리고 싶지 않다. Play 리포트는
61
+ * 항상 단일/소수 CSV 엔트리라 중앙 디렉터리만 훑으면 충분하다.
62
+ */
63
+ export declare function unzip(buffer: Buffer): Buffer[];
64
+ /** 따옴표·따옴표 안 쉼표를 처리하는 최소 CSV 파서. 헤더 기준 객체 배열을 만든다. */
65
+ export declare function parseCsv(text: string): Array<Record<string, string>>;
@@ -0,0 +1,319 @@
1
+ // Google Play 재무 리포트 (Cloud Storage).
2
+ //
3
+ // **Play Developer API 에는 매출 엔드포인트가 없다.** purchases.* 는 구매 토큰을 이미
4
+ // 알고 있어야 하고, Reporting API 는 vitals 뿐이다. 실제 정산 데이터는 Play Console 이
5
+ // 매달 개발자 소유 GCS 버킷에 떨궈주는 CSV 가 유일한 소스라서, 여기서는 그걸 읽는다.
6
+ //
7
+ // 왜 중요한가: GA4 의 결제 이벤트는 라이선스 테스터·내부 테스트 트랙 결제를 실결제와
8
+ // 구분하지 못한다(둘 다 install_source 가 com.android.vending 이다). 청구가 0원인
9
+ // 테스터 주문은 **이 리포트에 아예 나타나지 않으므로**, 실매출 판별의 기준선이 된다.
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import zlib from 'node:zlib';
14
+ import { JWT } from 'google-auth-library';
15
+ import { fetchWithTimeout, HTTP_TRANSFER_TIMEOUT_MS } from '../lib/http.js';
16
+ import { requireServiceAccountJson } from '../helpers.js';
17
+ const CONFIG_PATH = path.join(os.homedir(), '.mimi-seed', 'play-financials.json');
18
+ const STORAGE_SCOPE = 'https://www.googleapis.com/auth/devstorage.read_only';
19
+ /** 리포트 종류 → 버킷 내 접두사·파일명 규칙. */
20
+ const REPORT_PREFIX = {
21
+ earnings: 'earnings/',
22
+ sales: 'sales/',
23
+ };
24
+ /**
25
+ * 버킷 이름 해석: 명시 인자 > ~/.mimi-seed/play-financials.json.
26
+ *
27
+ * 버킷 이름은 Play Console 에만 있고 API 로 못 얻는다(개발자 계정마다 다르다).
28
+ * 없으면 조용히 빈 결과를 주는 대신 여기서 끊는다 — "매출 0" 과 "설정 누락" 이
29
+ * 구분되지 않는 게 이 도구의 가장 위험한 오답이다.
30
+ */
31
+ export function resolveBucket(explicit, packageName) {
32
+ const fromArg = explicit?.trim();
33
+ if (fromArg)
34
+ return normalizeBucket(fromArg);
35
+ let config = {};
36
+ try {
37
+ if (fs.existsSync(CONFIG_PATH))
38
+ config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
39
+ }
40
+ catch {
41
+ // 손상된 설정은 없는 것으로 취급하고 아래 안내로 떨어뜨린다.
42
+ }
43
+ const stored = (packageName && config[packageName]) || config.default || config.bucket;
44
+ if (stored)
45
+ return normalizeBucket(stored);
46
+ throw new Error([
47
+ '❌ Play 재무 리포트 버킷을 몰라 — API 로는 알아낼 수 없어.',
48
+ '',
49
+ 'Play Console > 다운로드 보고서 > 재무 화면 아래쪽에 있는',
50
+ ' gs://pubsite_prod_XXXXXXXXXXXXXXXXXXX',
51
+ '이 버킷 이름을 한 번만 저장해 두면 이후 생략할 수 있어:',
52
+ ` ${CONFIG_PATH} 에 {"default": "pubsite_prod_..."}`,
53
+ '패키지마다 다르면 {"<packageName>": "pubsite_prod_..."} 로 키를 나눠도 돼.',
54
+ '또는 이 도구의 bucket 인자로 직접 넘겨.',
55
+ '',
56
+ '⚠️ 접근 권한은 **GCP IAM 이 아니라 Play Console 권한**이다 — 아래 403 안내 참고.',
57
+ ].join('\n'));
58
+ }
59
+ function normalizeBucket(raw) {
60
+ return raw.replace(/^gs:\/\//, '').replace(/\/+$/, '');
61
+ }
62
+ /**
63
+ * GCS 전용 JWT.
64
+ *
65
+ * 기존 Play SA 클라이언트(getServiceAccountClient)에 storage 스코프를 얹지 않고 따로
66
+ * 만든다 — 그쪽은 모든 Play 도구가 공유하는 경로라, 리포트 하나 때문에 스코프를 넓히면
67
+ * 실패 범위가 도구 전체로 번진다.
68
+ */
69
+ function storageClient(packageName) {
70
+ const parsed = JSON.parse(requireServiceAccountJson(packageName));
71
+ return new JWT({
72
+ email: parsed.client_email,
73
+ key: parsed.private_key,
74
+ scopes: [STORAGE_SCOPE],
75
+ });
76
+ }
77
+ async function storageFetch(client, url, timeoutMs) {
78
+ const token = await client.getAccessToken();
79
+ const response = await fetchWithTimeout(url, { headers: { Authorization: `Bearer ${token.token ?? ''}` } }, timeoutMs);
80
+ if (!response.ok) {
81
+ const body = await response.text();
82
+ if (response.status === 403) {
83
+ throw new Error([
84
+ '❌ 버킷 접근 거부 (403).',
85
+ '',
86
+ '⚠️ **GCP IAM 으로는 못 고친다.** pubsite_prod_* 버킷은 개발자 프로젝트가 아니라',
87
+ 'Google 소유라, 프로젝트에 roles/storage.* 를 아무리 줘도 닿지 않는다.',
88
+ '접근은 오직 **Play Console 사용자 권한**으로 열린다:',
89
+ '',
90
+ ' Play Console > 사용자 및 권한 > 새 사용자 초대',
91
+ ' → 서비스 계정 이메일(...iam.gserviceaccount.com)을 그대로 초대하고',
92
+ ' → "앱 정보 보기(읽기 전용)" = 전체(Global)',
93
+ ' → "재무 데이터 보기" = **전체(Global)** ← 재무 리포트는 이게 없으면 무조건 403',
94
+ '',
95
+ '반영에 몇 분 걸릴 수 있다. Play Developer API 접근(앱 게시용)과는 별개 권한이라,',
96
+ '릴리스가 잘 돌아간다고 해서 리포트가 열려 있는 것은 아니다.',
97
+ '',
98
+ body.slice(0, 500),
99
+ ].join('\n'));
100
+ }
101
+ throw new Error(`❌ Cloud Storage ${response.status}: ${body.slice(0, 500)}`);
102
+ }
103
+ return response;
104
+ }
105
+ export async function listFinancialReports(options) {
106
+ const bucket = resolveBucket(options.bucket, options.packageName);
107
+ const client = storageClient(options.packageName);
108
+ // 반드시 페이지를 끝까지 돈다. 이 버킷에는 stats/ 하위 CSV 가 수천 개 쌓여 있어서
109
+ // 첫 페이지만 읽으면 earnings/·sales/ 가 통째로 안 보인다 — 그런데 응답은 성공이라
110
+ // "그 달 리포트가 없다"는 오답으로 조용히 둔갑한다. prefix 를 줘도 마찬가지다.
111
+ const items = [];
112
+ let pageToken;
113
+ do {
114
+ const params = new URLSearchParams({ maxResults: '1000' });
115
+ if (options.reportType)
116
+ params.set('prefix', REPORT_PREFIX[options.reportType]);
117
+ if (pageToken)
118
+ params.set('pageToken', pageToken);
119
+ const response = await storageFetch(client, `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucket)}/o?${params}`);
120
+ const page = (await response.json());
121
+ items.push(...(page.items ?? []));
122
+ pageToken = page.nextPageToken;
123
+ } while (pageToken);
124
+ const objects = items.map((item) => ({
125
+ name: item.name ?? '',
126
+ size: Number.parseInt(item.size ?? '0', 10) || 0,
127
+ updated: item.updated ?? '',
128
+ yearMonth: /_(\d{6})[_.]/.exec(item.name ?? '')?.[1] ?? '',
129
+ }));
130
+ objects.sort((a, b) => b.name.localeCompare(a.name));
131
+ return { bucket, objects };
132
+ }
133
+ export async function getFinancialReport(options) {
134
+ const reportType = options.reportType ?? 'earnings';
135
+ const bucket = resolveBucket(options.bucket, options.packageName);
136
+ const client = storageClient(options.packageName);
137
+ const yearMonth = options.yearMonth.replace('-', '');
138
+ if (!/^\d{6}$/.test(yearMonth)) {
139
+ throw new Error('yearMonth 는 YYYYMM 또는 YYYY-MM 형식이어야 해.');
140
+ }
141
+ const { objects } = await listFinancialReports({
142
+ packageName: options.packageName,
143
+ bucket,
144
+ reportType,
145
+ });
146
+ // 정산 통화마다 파일이 따로 떨어지므로(earnings_YYYYMM_<id>-<currency>.zip) 한 달이
147
+ // 파일 하나라고 가정하면 통화 하나만 세고 나머지를 조용히 버리게 된다.
148
+ const targets = objects.filter((o) => o.name.includes(yearMonth));
149
+ if (targets.length === 0) {
150
+ throw new Error([
151
+ `❌ ${yearMonth} 의 ${reportType} 리포트가 버킷에 없어.`,
152
+ '',
153
+ 'Play 재무 리포트는 **월 마감 후에야** 올라온다 — 이번 달 것은 아직 없는 게 정상이다.',
154
+ `버킷에 있는 파일: ${objects.slice(0, 10).map((o) => o.name).join(', ') || '(없음)'}`,
155
+ ].join('\n'));
156
+ }
157
+ const rows = [];
158
+ for (const target of targets) {
159
+ const response = await storageFetch(client, `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucket)}` +
160
+ `/o/${encodeURIComponent(target.name)}?alt=media`, HTTP_TRANSFER_TIMEOUT_MS);
161
+ const zipped = Buffer.from(await response.arrayBuffer());
162
+ for (const entry of unzip(zipped)) {
163
+ rows.push(...parseCsv(entry.toString('utf-8')));
164
+ }
165
+ }
166
+ return {
167
+ bucket,
168
+ files: targets.map((t) => t.name),
169
+ rowCount: rows.length,
170
+ ...summarize(rows),
171
+ ...(options.includeRows ? { rows } : {}),
172
+ };
173
+ }
174
+ /** earnings/sales 양쪽 컬럼명을 모두 받아, 있는 쪽으로 집계한다. */
175
+ function summarize(rows) {
176
+ const netByMerchantCurrency = {};
177
+ const byTransactionType = {};
178
+ const byProductMap = new Map();
179
+ for (const row of rows) {
180
+ const currency = row['Merchant Currency'] || row['Currency of Sale'] || 'UNKNOWN';
181
+ const amount = toNumber(row['Amount (Merchant Currency)'] ?? row['Charged Amount'] ?? row['Item Price']);
182
+ const type = row['Transaction Type'] || row['Financial Status'] || 'UNKNOWN';
183
+ // 컬럼명이 리포트마다 다르다. earnings 는 'Product id'/'Sku Id', sales 는 **'SKU ID'**
184
+ // (전부 대문자). 하나라도 빠뜨리면 상품별 집계가 통째로 빈 키('')로 뭉개진다.
185
+ const productId = row['Product id'] || row['Product ID'] || row['SKU ID'] || row['Sku Id'] ||
186
+ row['Sku ID'] || row['Package ID'] || '';
187
+ const title = row['Product Title'] || '';
188
+ netByMerchantCurrency[currency] = (netByMerchantCurrency[currency] ?? 0) + amount;
189
+ const bucketForType = (byTransactionType[type] ??= { count: 0, amount: 0 });
190
+ bucketForType.count += 1;
191
+ bucketForType.amount += amount;
192
+ // 수수료·세금 행은 상품 매출이 아니다 — 상품별 집계에 섞으면 판매량이 부풀려진다.
193
+ if (/^charge$/i.test(type) || /^charged$/i.test(type)) {
194
+ const key = `${productId || title}${currency}`;
195
+ const entry = byProductMap.get(key) ?? { productId, title, currency, count: 0, amount: 0 };
196
+ entry.count += 1;
197
+ entry.amount += amount;
198
+ byProductMap.set(key, entry);
199
+ }
200
+ }
201
+ const round = (n) => Math.round(n * 100) / 100;
202
+ for (const key of Object.keys(netByMerchantCurrency)) {
203
+ netByMerchantCurrency[key] = round(netByMerchantCurrency[key]);
204
+ }
205
+ for (const key of Object.keys(byTransactionType)) {
206
+ byTransactionType[key].amount = round(byTransactionType[key].amount);
207
+ }
208
+ // 통화가 다른 행끼리는 금액 크기를 비교해도 뜻이 없으므로 통화로 먼저 묶어 정렬한다.
209
+ const byProduct = [...byProductMap.values()]
210
+ .map((p) => ({ ...p, amount: round(p.amount) }))
211
+ .sort((a, b) => a.currency.localeCompare(b.currency) || b.amount - a.amount);
212
+ return { netByMerchantCurrency, byTransactionType, byProduct };
213
+ }
214
+ function toNumber(value) {
215
+ const n = Number.parseFloat((value ?? '').replace(/[,"]/g, ''));
216
+ return Number.isFinite(n) ? n : 0;
217
+ }
218
+ /**
219
+ * 최소 ZIP 리더 (stored + deflate).
220
+ *
221
+ * Node 에 zip 해제가 없고, 이 하나 때문에 의존성을 늘리고 싶지 않다. Play 리포트는
222
+ * 항상 단일/소수 CSV 엔트리라 중앙 디렉터리만 훑으면 충분하다.
223
+ */
224
+ export function unzip(buffer) {
225
+ const EOCD_SIG = 0x06054b50;
226
+ const CEN_SIG = 0x02014b50;
227
+ let eocd = -1;
228
+ for (let i = buffer.length - 22; i >= 0; i--) {
229
+ if (buffer.readUInt32LE(i) === EOCD_SIG) {
230
+ eocd = i;
231
+ break;
232
+ }
233
+ }
234
+ if (eocd < 0)
235
+ throw new Error('ZIP 형식이 아니야 — 재무 리포트 파일이 손상됐을 수 있어.');
236
+ const entryCount = buffer.readUInt16LE(eocd + 10);
237
+ let offset = buffer.readUInt32LE(eocd + 16);
238
+ const files = [];
239
+ for (let i = 0; i < entryCount; i++) {
240
+ if (buffer.readUInt32LE(offset) !== CEN_SIG)
241
+ break;
242
+ const method = buffer.readUInt16LE(offset + 10);
243
+ const compressedSize = buffer.readUInt32LE(offset + 20);
244
+ const nameLength = buffer.readUInt16LE(offset + 28);
245
+ const extraLength = buffer.readUInt16LE(offset + 30);
246
+ const commentLength = buffer.readUInt16LE(offset + 32);
247
+ const localOffset = buffer.readUInt32LE(offset + 42);
248
+ // 로컬 헤더는 중앙 디렉터리와 extra 필드 길이가 다를 수 있어 반드시 다시 읽는다.
249
+ const localNameLength = buffer.readUInt16LE(localOffset + 26);
250
+ const localExtraLength = buffer.readUInt16LE(localOffset + 28);
251
+ const dataStart = localOffset + 30 + localNameLength + localExtraLength;
252
+ const data = buffer.subarray(dataStart, dataStart + compressedSize);
253
+ if (method === 0)
254
+ files.push(Buffer.from(data));
255
+ else if (method === 8)
256
+ files.push(zlib.inflateRawSync(data));
257
+ else
258
+ throw new Error(`지원하지 않는 ZIP 압축 방식(${method})이야.`);
259
+ offset += 46 + nameLength + extraLength + commentLength;
260
+ }
261
+ return files;
262
+ }
263
+ /** 따옴표·따옴표 안 쉼표를 처리하는 최소 CSV 파서. 헤더 기준 객체 배열을 만든다. */
264
+ export function parseCsv(text) {
265
+ const rows = splitCsvRows(text);
266
+ if (rows.length < 2)
267
+ return [];
268
+ const headers = rows[0].map((h) => h.trim());
269
+ return rows.slice(1)
270
+ .filter((cells) => cells.some((c) => c.trim().length > 0))
271
+ .map((cells) => {
272
+ const row = {};
273
+ headers.forEach((header, i) => {
274
+ row[header] = (cells[i] ?? '').trim();
275
+ });
276
+ return row;
277
+ });
278
+ }
279
+ function splitCsvRows(text) {
280
+ const rows = [];
281
+ let cells = [];
282
+ let field = '';
283
+ let inQuotes = false;
284
+ for (let i = 0; i < text.length; i++) {
285
+ const ch = text[i];
286
+ if (inQuotes) {
287
+ if (ch === '"') {
288
+ if (text[i + 1] === '"') {
289
+ field += '"';
290
+ i++;
291
+ }
292
+ else
293
+ inQuotes = false;
294
+ }
295
+ else
296
+ field += ch;
297
+ continue;
298
+ }
299
+ if (ch === '"')
300
+ inQuotes = true;
301
+ else if (ch === ',') {
302
+ cells.push(field);
303
+ field = '';
304
+ }
305
+ else if (ch === '\n') {
306
+ cells.push(field);
307
+ rows.push(cells);
308
+ cells = [];
309
+ field = '';
310
+ }
311
+ else if (ch !== '\r')
312
+ field += ch;
313
+ }
314
+ if (field.length > 0 || cells.length > 0) {
315
+ cells.push(field);
316
+ rows.push(cells);
317
+ }
318
+ return rows;
319
+ }
@@ -7,6 +7,7 @@ import * as appstoreRelease from '../appstore/release.js';
7
7
  import * as appstoreDeclarations from '../appstore/declarations.js';
8
8
  import * as testflight from '../appstore/testflight.js';
9
9
  import * as previews from '../appstore/previews.js';
10
+ import * as appstoreSales from '../appstore/sales.js';
10
11
  import { createAppleOneTimePurchase, createAppleSubscription, updateAppleProduct, deleteAppleProduct, listAppleProducts, } from '@onesub/providers';
11
12
  import { requireAppStoreCreds } from '../helpers.js';
12
13
  import { buildAppStoreReleasePlan } from '../checks/plan.js';
@@ -1492,4 +1493,59 @@ export function registerAppstoreTools(server) {
1492
1493
  : await previews.deletePreviewSet(setId);
1493
1494
  return textResult(`✅ 삭제 완료 — ${r.id}`);
1494
1495
  });
1496
+ server.tool('appstore_get_sales_report', [
1497
+ 'Sales and Trends 리포트 = **실매출의 기준선** — GET /v1/salesReports (gzip TSV 를 파싱해 돌려준다).',
1498
+ '분석 이벤트(GA4 in_app_purchase 등)로 매출을 세면 안 되는 이유가 여기 있다:',
1499
+ 'TestFlight·Xcode 설치의 결제는 **sandbox 라 청구가 0원인데도** 클라이언트에는 성공한 결제로 보여',
1500
+ '이벤트가 그대로 나간다. 이 리포트에는 sandbox 가 애초에 들어오지 않으므로, 둘을 비교하면',
1501
+ '"진짜 돈이 들어온 건수"가 곧바로 갈린다.',
1502
+ '⚠️ **Developer Proceeds 는 1개당 금액이다** — 총액은 units 를 곱해야 한다(이 도구는 곱해서 준다).',
1503
+ '⚠️ 환불은 units 가 **음수**로 들어와 합계에서 상쇄된다. 즉 합계는 순매출이다.',
1504
+ '⚠️ 데이터가 없는 날짜는 Apple 이 404 를 주므로 datesWithoutData 로 따로 돌려준다 —',
1505
+ '"매출 0" 과 "리포트 미생성/설정 오류"를 섞지 말 것. 당일치는 보통 아직 없다.',
1506
+ 'vendorNumber 는 ~/.mimi-seed/appstore.json 에 저장해두면 생략 가능.',
1507
+ ].join(' '), {
1508
+ startDate: z.string().describe('시작일 YYYY-MM-DD (DAILY 가 아니면 이 값이 곧 reportDate)'),
1509
+ endDate: z
1510
+ .string()
1511
+ .optional()
1512
+ .describe('종료일 YYYY-MM-DD (포함). DAILY 에서만 의미가 있고 최대 62일'),
1513
+ frequency: z
1514
+ .enum(['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'])
1515
+ .optional()
1516
+ .describe('집계 단위 (기본 DAILY)'),
1517
+ reportType: z
1518
+ .string()
1519
+ .optional()
1520
+ .describe('기본 SALES. 구독 분석은 SUBSCRIPTION / SUBSCRIPTION_EVENT / SUBSCRIBER'),
1521
+ reportSubType: z.string().optional().describe('기본 SUMMARY. 상세는 DETAILED'),
1522
+ version: z
1523
+ .string()
1524
+ .optional()
1525
+ .describe('리포트 버전 (기본 1_0). SUBSCRIPTION 계열은 1_3 등 다른 버전을 요구할 수 있다'),
1526
+ vendorNumber: z
1527
+ .string()
1528
+ .optional()
1529
+ .describe('판매자 번호. 생략 시 ~/.mimi-seed/appstore.json 의 vendorNumber 사용'),
1530
+ }, async (args) => jsonResult(await appstoreSales.getSalesReport(args)));
1531
+ server.tool('appstore_get_finance_report', [
1532
+ '재무(정산) 리포트 — GET /v1/financeReports. 실제로 지급되는 금액 기준이라',
1533
+ 'Sales and Trends(판매 시점 집계)와 숫자가 다를 수 있다.',
1534
+ '⚠️ **reportDate 는 Apple 회계월이다** — 달력월과 어긋난다(회계연도가 9월 말에 시작).',
1535
+ '요청한 달과 다른 기간이 돌아오면 버그가 아니라 이것이다. 매출 건수를 세는 목적이면',
1536
+ 'appstore_get_sales_report(달력 날짜 기준)를 쓰는 편이 낫다.',
1537
+ 'regionCode 는 ZZ(전 지역 통합)가 기본이고, FINANCE_DETAIL 은 보통 Z1 을 쓴다.',
1538
+ '컬럼 구성이 리포트마다 달라 파싱한 행을 그대로 돌려준다.',
1539
+ ].join(' '), {
1540
+ reportDate: z.string().describe('YYYY-MM (Apple 회계월)'),
1541
+ regionCode: z.string().optional().describe('지역 코드 (기본 ZZ = 전 지역 통합)'),
1542
+ reportType: z
1543
+ .enum(['FINANCIAL', 'FINANCE_DETAIL'])
1544
+ .optional()
1545
+ .describe('기본 FINANCIAL'),
1546
+ vendorNumber: z
1547
+ .string()
1548
+ .optional()
1549
+ .describe('판매자 번호. 생략 시 ~/.mimi-seed/appstore.json 의 vendorNumber 사용'),
1550
+ }, async (args) => jsonResult(await appstoreSales.getFinanceReport(args)));
1495
1551
  }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerBillingTools(server: McpServer): void;
@@ -0,0 +1,102 @@
1
+ import { z } from 'zod';
2
+ import * as billing from '../billing/tools.js';
3
+ import { requireAuth } from '../helpers.js';
4
+ import { CLOUD_PLATFORM_SCOPE } from '../auth/scopes.js';
5
+ import { jsonResult } from '../lib/mcp-response.js';
6
+ export function registerBillingTools(server) {
7
+ server.tool('gcp_get_billing_info', [
8
+ '프로젝트의 결제 상태 조회 — Blaze(결제 계정 연결) 여부와 연결된 결제 계정 ID.',
9
+ 'Cloud Functions/Run 배포 전 필수 확인.',
10
+ '⚠️ Cloud Billing API 가 꺼져 있으면 403 이 난다 — 그건 "Spark 이라서"가 아니라 조회 자체가 막힌 것이다.',
11
+ '그 경우 firebase_enable_service(projectId, "cloudbilling.googleapis.com") 로 먼저 켠 뒤 다시 호출.',
12
+ ].join(' '), {
13
+ projectId: z.string().describe('GCP/Firebase 프로젝트 ID'),
14
+ }, async ({ projectId }) => {
15
+ const auth = await requireAuth(CLOUD_PLATFORM_SCOPE);
16
+ return jsonResult(await billing.getBillingInfo(auth, projectId));
17
+ });
18
+ server.tool('gcp_list_billing_projects', [
19
+ '결제 계정에 붙은 프로젝트 목록 = **비용 범위 확인**.',
20
+ '프로젝트가 2개 이상이면(shared=true) 그 계정은 공용이므로 **계정 전체 예산은 쓰지 말 것** —',
21
+ '기존 지출만으로 임계를 즉시 넘겨 알림이 소음이 된다. gcp_create_budget 에 projectIds 를 넘겨 범위를 좁힌다.',
22
+ ].join(' '), {
23
+ billingAccount: z
24
+ .string()
25
+ .describe('결제 계정 ID (예: 01F1F4-FD007B-2973A7 또는 billingAccounts/01F1F4-...)'),
26
+ quotaProjectId: z
27
+ .string()
28
+ .optional()
29
+ .describe('quota 주체 프로젝트 — Cloud Billing/Budget API 를 켜 둔 프로젝트 ID. 생략하면 OAuth 클라이언트 프로젝트로 quota 가 잡혀 403 이 난다(에러 메시지의 프로젝트 번호는 조회 대상이 아니라 OAuth 쪽이다).'),
30
+ }, async ({ billingAccount, quotaProjectId }) => {
31
+ const auth = await requireAuth(CLOUD_PLATFORM_SCOPE);
32
+ return jsonResult(await billing.listBillingProjects(auth, billingAccount, quotaProjectId));
33
+ });
34
+ server.tool('gcp_list_budgets', '결제 계정의 예산 목록 (금액·프로젝트 필터·알림 임계). 중복 생성 전 확인용.', {
35
+ billingAccount: z.string().describe('결제 계정 ID'),
36
+ quotaProjectId: z
37
+ .string()
38
+ .optional()
39
+ .describe('quota 주체 프로젝트 — Cloud Billing/Budget API 를 켜 둔 프로젝트 ID. 생략하면 OAuth 클라이언트 프로젝트로 quota 가 잡혀 403 이 난다(에러 메시지의 프로젝트 번호는 조회 대상이 아니라 OAuth 쪽이다).'),
40
+ }, async ({ billingAccount, quotaProjectId }) => {
41
+ const auth = await requireAuth(CLOUD_PLATFORM_SCOPE);
42
+ return jsonResult(await billing.listBudgets(auth, billingAccount, quotaProjectId));
43
+ });
44
+ server.tool('gcp_create_budget', [
45
+ '예산 + 알림 임계 생성.',
46
+ '⚠️ **예산은 알림만 한다. 지출을 막지 않는다.** 하드 차단은 Pub/Sub→결제해제 함수뿐이고 그건 앱을 죽인다.',
47
+ '실질 방어는 각 서비스의 상한(예: Cloud Functions maxInstances)이고 예산은 트립와이어다.',
48
+ '⚠️ GCP 에 **일 예산은 없다** — 기간은 월(기본)/분기/연/사용자지정뿐. "하루 N원"을 원하면 월 환산하거나,',
49
+ '실사용이 적을 때는 작은 월 예산을 알람으로 쓰는 게 더 빨리 잡힌다.',
50
+ 'projectIds 를 반드시 고려할 것 — 공용 결제 계정에서 생략하면 알림이 소음이 된다(gcp_list_billing_projects 로 먼저 확인).',
51
+ '권한은 결제 계정 레벨 IAM 이라 서비스 계정 키로는 대개 403 — 사용자 OAuth 로 호출된다.',
52
+ ].join(' '), {
53
+ billingAccount: z.string().describe('결제 계정 ID'),
54
+ displayName: z.string().describe('예산 이름 (예: "my-app functions 감시")'),
55
+ amountUnits: z
56
+ .number()
57
+ .int()
58
+ .positive()
59
+ .describe('통화 단위 정수 금액. KRW 는 소수 없음 — 10000 = ₩10,000'),
60
+ currencyCode: z.string().optional().describe('통화 코드 (기본 KRW). 결제 계정 통화와 일치해야 한다'),
61
+ projectIds: z
62
+ .array(z.string())
63
+ .optional()
64
+ .describe('감시할 프로젝트 ID 목록. 생략하면 결제 계정 전체 — 공용 계정이면 권장하지 않음'),
65
+ thresholds: z
66
+ .array(z.number().min(0).max(1))
67
+ .optional()
68
+ .describe('알림 임계 비율 0~1 (기본 [0.5, 0.9, 1.0])'),
69
+ quotaProjectId: z
70
+ .string()
71
+ .optional()
72
+ .describe('quota 주체 프로젝트 — Cloud Billing/Budget API 를 켜 둔 프로젝트 ID. 생략하면 OAuth 클라이언트 프로젝트로 quota 가 잡혀 403 이 난다(에러 메시지의 프로젝트 번호는 조회 대상이 아니라 OAuth 쪽이다).'),
73
+ }, async ({ billingAccount, displayName, amountUnits, currencyCode, projectIds, thresholds, quotaProjectId, }) => {
74
+ const auth = await requireAuth(CLOUD_PLATFORM_SCOPE);
75
+ const budget = await billing.createBudget(auth, {
76
+ billingAccount,
77
+ displayName,
78
+ amountUnits,
79
+ currencyCode,
80
+ projectIds,
81
+ thresholds,
82
+ quotaProjectId,
83
+ });
84
+ return {
85
+ content: [
86
+ {
87
+ type: 'text',
88
+ text: [
89
+ '✓ 예산 생성 완료',
90
+ '',
91
+ `**name**: \`${budget.name}\``,
92
+ `**displayName**: ${budget.displayName}`,
93
+ `**범위**: ${budget.projects.length ? budget.projects.join(', ') : '결제 계정 전체'}`,
94
+ `**알림 임계**: ${budget.thresholds.map((t) => `${Math.round(t * 100)}%`).join(' / ')}`,
95
+ '',
96
+ '⚠️ 이 예산은 **알림만** 한다 — 임계를 넘어도 지출은 계속된다.',
97
+ ].join('\n'),
98
+ },
99
+ ],
100
+ };
101
+ });
102
+ }
@@ -7,6 +7,7 @@ import { saveServiceAccountJsonForPackage, listRegisteredServiceAccounts, delete
7
7
  import { createGoogleOneTimePurchase, createGoogleSubscription, updateGoogleProduct, deleteGoogleProduct, listGoogleProducts, } from '@onesub/providers';
8
8
  import { requirePlayStoreAuth, requireServiceAccountJson, requireAuth } from '../helpers.js';
9
9
  import { PLAY_DEVELOPER_REPORTING_SCOPE } from '../auth/scopes.js';
10
+ import * as playFinancials from '../playstore/financials.js';
10
11
  import * as iam from '../iam/tools.js';
11
12
  import { buildPlayStoreReleasePlan } from '../checks/plan.js';
12
13
  import { validatePlayReleaseNotes, formatIssuesForUser } from '../lib/text-validators.js';
@@ -913,4 +914,45 @@ export function registerPlaystoreTools(server) {
913
914
  const r = await playstore.cancelRecoveryAction(auth, packageName, appRecoveryId);
914
915
  return textResult(`✅ 복구 액션 취소 — ${r.appRecoveryId}`);
915
916
  });
917
+ server.tool('playstore_list_financial_reports', [
918
+ 'Play 재무 리포트 파일 목록 (Cloud Storage 버킷).',
919
+ '⚠️ **Play Developer API 에는 매출 엔드포인트가 없다.** purchases.* 는 구매 토큰을 이미 알아야 하고',
920
+ 'Reporting API 는 vitals 뿐이다. 실제 정산 데이터는 Play Console 이 매달 개발자 소유 GCS 버킷에',
921
+ '떨궈주는 CSV 가 유일한 소스라, 이 도구는 그 버킷을 훑는다.',
922
+ '버킷 이름(gs://pubsite_prod_...)은 Play Console > 다운로드 보고서 > 재무 에만 있고 API 로 못 얻는다 —',
923
+ '~/.mimi-seed/play-financials.json 에 한 번 저장해두면 이후 생략 가능.',
924
+ '⚠️ 접근 권한은 **GCP IAM 이 아니다.** 이 버킷은 Google 소유라 프로젝트에 roles/storage.* 를 줘도 닿지 않는다 —',
925
+ 'Play Console > 사용자 및 권한 에서 서비스 계정 이메일을 초대하고 "재무 데이터 보기"를 전체(Global)로 줘야 열린다.',
926
+ '앱 게시용 Play Developer API 권한과 별개라, 릴리스가 되는 계정이라고 리포트가 열려 있지는 않다.',
927
+ '재무 리포트는 월 마감 후에 올라오므로 이번 달 파일이 없는 것은 정상이다.',
928
+ ].join(' '), {
929
+ packageName: z.string().optional().describe('패키지명 — 패키지별 서비스 계정·버킷 설정을 고를 때 사용'),
930
+ bucket: z.string().optional().describe('버킷 이름 또는 gs:// URI. 생략 시 저장된 설정 사용'),
931
+ reportType: z
932
+ .enum(['earnings', 'sales'])
933
+ .optional()
934
+ .describe('생략하면 버킷 전체. earnings=정산(수수료·세금 포함), sales=주문 단위'),
935
+ }, async (args) => jsonResult(await playFinancials.listFinancialReports(args)));
936
+ server.tool('playstore_get_financial_report', [
937
+ 'Play 재무 리포트를 내려받아 파싱·집계한다 = **Play 실매출의 기준선**.',
938
+ '분석 이벤트로는 라이선스 테스터·내부 테스트 결제를 실결제와 구분할 수 없다',
939
+ '(둘 다 install_source 가 com.android.vending 이다). 청구가 0원인 테스터 주문은',
940
+ '이 리포트에 **아예 나타나지 않으므로**, 여기 없으면 실매출이 아니다.',
941
+ 'earnings: Transaction Type 이 Charge/Google fee/Tax/환불로 나뉘고 수수료·세금이 음수로 들어와',
942
+ 'netByMerchantCurrency 는 이미 순액이다. **Charge 행만이 실제 구매다.**',
943
+ 'sales: 주문 번호 단위라 개별 주문을 짚을 때 쓴다.',
944
+ '⚠️ 정산 통화마다 파일이 따로 떨어지므로 한 달치가 여러 파일일 수 있다 — 전부 합쳐서 집계한다.',
945
+ ].join(' '), {
946
+ yearMonth: z.string().describe('YYYYMM 또는 YYYY-MM'),
947
+ packageName: z.string().optional().describe('패키지명 — 패키지별 서비스 계정·버킷 설정을 고를 때 사용'),
948
+ bucket: z.string().optional().describe('버킷 이름 또는 gs:// URI. 생략 시 저장된 설정 사용'),
949
+ reportType: z
950
+ .enum(['earnings', 'sales'])
951
+ .optional()
952
+ .describe('기본 earnings(정산). 주문 단위로 보려면 sales'),
953
+ includeRows: z
954
+ .boolean()
955
+ .optional()
956
+ .describe('원본 행까지 전부 반환. 건수가 많으면 응답이 매우 커진다'),
957
+ }, async (args) => jsonResult(await playFinancials.getFinancialReport(args)));
916
958
  }
package/dist/server.js CHANGED
@@ -3,6 +3,7 @@ import { registerFirebaseTools } from './registers/firebase.js';
3
3
  import { registerAdmobTools } from './registers/admob.js';
4
4
  import { registerPlaystoreTools } from './registers/playstore.js';
5
5
  import { registerIamTools } from './registers/iam.js';
6
+ import { registerBillingTools } from './registers/billing.js';
6
7
  import { registerAppstoreTools } from './registers/appstore.js';
7
8
  import { registerChecksTools } from './registers/checks.js';
8
9
  import { registerAiTools } from './registers/ai.js';
@@ -38,6 +39,7 @@ export function buildServer(version) {
38
39
  registerAdmobTools(server);
39
40
  registerPlaystoreTools(server);
40
41
  registerIamTools(server);
42
+ registerBillingTools(server);
41
43
  registerAppstoreTools(server);
42
44
  registerChecksTools(server);
43
45
  registerAiTools(server);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.15.6",
3
+ "version": "0.17.0",
4
4
  "description": "Mimi Seed MCP server \u2014 Firebase + AdMob + Google Play + App Store management for Claude Code / Codex / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "등록된 MCP 도구 + 도메인 메타데이터(label·credential·summary)의 SSOT. src/__tests__/tool-manifest.test.ts 가 실제 서버 등록 목록과 diff 하고 메타데이터 정합성을 검사한다. 도구 추가/삭제/개명 시 이 파일을 함께 갱신할 것 — 산문 문서에는 정확한 개수를 쓰지 말고 이 파일을 가리킬 것. mimi-seed://tools/catalog 리소스가 이 파일을 그대로 서빙한다.",
3
- "total": 222,
3
+ "total": 230,
4
4
  "domains": {
5
5
  "admob": {
6
6
  "label": "AdMob",
@@ -38,7 +38,7 @@
38
38
  "appstore": {
39
39
  "label": "App Store Connect",
40
40
  "credential": "ASC API 키 (mimi-seed auth appstore)",
41
- "summary": "버전·빌드 attach·What's New·스크린샷·IAP 심사 메타데이터·심사 제출",
41
+ "summary": "버전·빌드 attach·What's New·스크린샷·IAP 심사 메타데이터·심사 제출·매출/재무 리포트",
42
42
  "tools": [
43
43
  "appstore_list_apps",
44
44
  "appstore_verify_credentials",
@@ -100,7 +100,9 @@
100
100
  "appstore_notify_beta_testers",
101
101
  "appstore_list_previews",
102
102
  "appstore_upload_preview",
103
- "appstore_delete_preview"
103
+ "appstore_delete_preview",
104
+ "appstore_get_sales_report",
105
+ "appstore_get_finance_report"
104
106
  ]
105
107
  },
106
108
  "auth": {
@@ -126,6 +128,17 @@
126
128
  "bigquery_auth_status"
127
129
  ]
128
130
  },
131
+ "billing": {
132
+ "label": "GCP Billing",
133
+ "credential": "Google OAuth",
134
+ "summary": "결제 상태(Blaze 여부)·비용 범위(공용 계정 판별)·예산 알림",
135
+ "tools": [
136
+ "gcp_get_billing_info",
137
+ "gcp_list_billing_projects",
138
+ "gcp_list_budgets",
139
+ "gcp_create_budget"
140
+ ]
141
+ },
129
142
  "checks": {
130
143
  "label": "출시 점검",
131
144
  "credential": "점검 대상 스토어의 자격증명",
@@ -274,7 +287,7 @@
274
287
  "playstore": {
275
288
  "label": "Google Play",
276
289
  "credential": "Google OAuth (CI/헤드리스는 Play 서비스 계정)",
277
- "summary": "리스팅·트랙 릴리스·이미지·리뷰 답변·통계·서비스 계정 등록",
290
+ "summary": "리스팅·트랙 릴리스·이미지·리뷰 답변·통계·서비스 계정 등록·재무 리포트",
278
291
  "tools": [
279
292
  "playstore_get_app",
280
293
  "playstore_update_details",
@@ -312,7 +325,9 @@
312
325
  "playstore_list_recovery_actions",
313
326
  "playstore_create_recovery_action",
314
327
  "playstore_deploy_recovery_action",
315
- "playstore_cancel_recovery_action"
328
+ "playstore_cancel_recovery_action",
329
+ "playstore_list_financial_reports",
330
+ "playstore_get_financial_report"
316
331
  ]
317
332
  },
318
333
  "threads": {