@yoonion/mimi-seed-mcp 0.3.4 → 0.3.6

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
@@ -56,6 +56,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
56
56
  | Firebase | `firebase_list_projects` / `firebase_create_android_app` / `firebase_get_android_config` / `firebase_enable_service` |
57
57
  | AdMob | `admob_list_apps` / `admob_create_ad_unit` / `admob_get_today_earnings` / `admob_get_report` |
58
58
  | Google Play | `playstore_list_tracks` / `playstore_update_listing` / `playstore_replace_images` / `playstore_reply_review` / `playstore_verify_service_account` |
59
+ | 인앱 결제 | `playstore_create_onetime_product` / `playstore_create_subscription` / `appstore_create_inapp_purchase` / `appstore_create_subscription` |
59
60
  | Google Cloud IAM | `iam_list_service_accounts` / `iam_create_service_account` / `iam_list_keys` / `iam_create_key` / `iam_add_iam_policy_binding` |
60
61
  | App Store Connect | `appstore_list_apps` / `appstore_list_builds` / `appstore_upload_screenshot` / `appstore_update_whats_new` |
61
62
  | 제출 위험 점검 | `playstore_check_submission_risks` / `appstore_check_submission_risks` |
@@ -55,3 +55,69 @@ export declare function submitVersionForReview(versionId: string): Promise<{
55
55
  itemAttached: boolean;
56
56
  state: any;
57
57
  }>;
58
+ export type AppleIapType = 'CONSUMABLE' | 'NON_CONSUMABLE' | 'NON_RENEWING_SUBSCRIPTION';
59
+ export interface CreateInAppPurchaseInput {
60
+ appId: string;
61
+ productId: string;
62
+ referenceName: string;
63
+ inAppPurchaseType: AppleIapType;
64
+ reviewNote?: string;
65
+ familySharable?: boolean;
66
+ locale?: string;
67
+ displayName: string;
68
+ description: string;
69
+ priceUsd: number;
70
+ baseTerritory?: string;
71
+ autoSubmit?: boolean;
72
+ }
73
+ export type FailedStep = 'localization' | 'priceSchedule' | 'submission';
74
+ interface CreatedIapSummary {
75
+ iapId: string;
76
+ productId: string;
77
+ state?: string;
78
+ localizationId?: string;
79
+ priceScheduleId?: string;
80
+ submissionId?: string;
81
+ submissionState?: string;
82
+ failedStep?: FailedStep;
83
+ error?: string;
84
+ priceMatchWarning?: string;
85
+ consoleUrl: string;
86
+ }
87
+ export declare function createInAppPurchase(input: CreateInAppPurchaseInput): Promise<CreatedIapSummary>;
88
+ export interface CreateSubscriptionGroupInput {
89
+ appId: string;
90
+ referenceName: string;
91
+ }
92
+ export interface CreateSubscriptionInput {
93
+ appId: string;
94
+ groupReferenceName: string;
95
+ productId: string;
96
+ referenceName: string;
97
+ subscriptionPeriod: 'ONE_WEEK' | 'ONE_MONTH' | 'TWO_MONTHS' | 'THREE_MONTHS' | 'SIX_MONTHS' | 'ONE_YEAR';
98
+ reviewNote?: string;
99
+ familySharable?: boolean;
100
+ groupLevel?: number;
101
+ locale?: string;
102
+ displayName: string;
103
+ description: string;
104
+ priceUsd: number;
105
+ baseTerritory?: string;
106
+ autoSubmit?: boolean;
107
+ }
108
+ interface CreatedSubscriptionSummary {
109
+ subscriptionId: string;
110
+ groupId: string;
111
+ productId: string;
112
+ state?: string;
113
+ localizationId?: string;
114
+ pricesCreated?: boolean;
115
+ submissionId?: string;
116
+ submissionState?: string;
117
+ failedStep?: FailedStep;
118
+ error?: string;
119
+ priceMatchWarning?: string;
120
+ consoleUrl: string;
121
+ }
122
+ export declare function createAutoRenewableSubscription(input: CreateSubscriptionInput): Promise<CreatedSubscriptionSummary>;
123
+ export {};
@@ -374,3 +374,302 @@ export async function submitVersionForReview(versionId) {
374
374
  state: submitted?.data?.attributes?.state ?? 'WAITING_FOR_REVIEW',
375
375
  };
376
376
  }
377
+ // ─── 인앱 구매 (IAP) 생성 ───
378
+ // 2023년 출시된 IAP v2 API (POST /v2/inAppPurchases) 사용.
379
+ // 흐름: (1) IAP draft 생성 → (2) 로컬라이제이션 → (3) priceSchedule → (4) submission(선택).
380
+ // 가격은 territory별 pricePoint ID 기반 — IAP를 만든 뒤 GET /pricePoints로 조회해 매칭.
381
+ // 자동 제출(submission)은 권장 가이드라인 (스크린샷·리뷰 노트 등)이 충족돼야 통과.
382
+ const IAP_V2_BASE = 'https://api.appstoreconnect.apple.com/v2';
383
+ async function apiPostV2(path, body) {
384
+ const headers = getAuthHeaders();
385
+ if (!headers) {
386
+ throw new Error('App Store Connect 인증 필요 — npx -p @yoonion/mimi-seed-mcp mimi-seed-appstore-auth');
387
+ }
388
+ const res = await fetch(`${IAP_V2_BASE}${path}`, {
389
+ method: 'POST',
390
+ headers: { ...headers, 'Content-Type': 'application/json' },
391
+ body: JSON.stringify(body),
392
+ });
393
+ if (!res.ok) {
394
+ const text = await res.text();
395
+ throw new Error(`App Store v2 ${res.status}: ${text}`);
396
+ }
397
+ const text = await res.text();
398
+ return text ? JSON.parse(text) : { ok: true };
399
+ }
400
+ async function findClosestPricePoint(resourceUrl, territory, targetPrice) {
401
+ // resourceUrl 예: '/inAppPurchases/{id}/pricePoints' 또는 '/subscriptions/{id}/pricePoints'
402
+ const data = await apiGet(resourceUrl, {
403
+ 'filter[territory]': territory,
404
+ 'limit': '200',
405
+ });
406
+ const points = (data?.data ?? []);
407
+ let best = null;
408
+ let bestDiff = Infinity;
409
+ for (const p of points) {
410
+ const priceStr = p.attributes?.customerPrice;
411
+ if (!priceStr)
412
+ continue;
413
+ const price = parseFloat(priceStr);
414
+ if (Number.isNaN(price))
415
+ continue;
416
+ const diff = Math.abs(price - targetPrice);
417
+ if (diff < bestDiff) {
418
+ bestDiff = diff;
419
+ best = { id: p.id, customerPrice: price };
420
+ }
421
+ }
422
+ return best;
423
+ }
424
+ const PRICE_MATCH_WARN_THRESHOLD = 0.10;
425
+ function priceMatchWarning(targetPrice, matched) {
426
+ const diff = Math.abs(matched - targetPrice);
427
+ if (diff < PRICE_MATCH_WARN_THRESHOLD)
428
+ return undefined;
429
+ return `요청 $${targetPrice} → 매칭 $${matched.toFixed(2)} (Apple tier 제약). 의도와 다르면 Console에서 수정.`;
430
+ }
431
+ export async function createInAppPurchase(input) {
432
+ const locale = input.locale ?? 'en-US';
433
+ const baseTerritory = input.baseTerritory ?? 'USA';
434
+ // 1) IAP draft 생성 (v2 endpoint)
435
+ const created = await apiPostV2('/inAppPurchases', {
436
+ data: {
437
+ type: 'inAppPurchases',
438
+ attributes: {
439
+ name: input.referenceName,
440
+ productId: input.productId,
441
+ inAppPurchaseType: input.inAppPurchaseType,
442
+ reviewNote: input.reviewNote,
443
+ familySharable: input.familySharable ?? false,
444
+ },
445
+ relationships: {
446
+ app: { data: { type: 'apps', id: input.appId } },
447
+ },
448
+ },
449
+ });
450
+ const iapId = created?.data?.id;
451
+ if (!iapId)
452
+ throw new Error(`IAP 생성 응답에 id 없음: ${JSON.stringify(created)}`);
453
+ const consoleUrl = `https://appstoreconnect.apple.com/apps/${input.appId}/distribution/iaps/${iapId}`;
454
+ const summary = {
455
+ iapId,
456
+ productId: input.productId,
457
+ state: created?.data?.attributes?.state,
458
+ consoleUrl,
459
+ };
460
+ // 2) 로컬라이제이션
461
+ try {
462
+ const loc = await apiPost('/inAppPurchaseLocalizations', {
463
+ data: {
464
+ type: 'inAppPurchaseLocalizations',
465
+ attributes: {
466
+ locale,
467
+ name: input.displayName,
468
+ description: input.description,
469
+ },
470
+ relationships: {
471
+ inAppPurchaseV2: { data: { type: 'inAppPurchases', id: iapId } },
472
+ },
473
+ },
474
+ });
475
+ summary.localizationId = loc?.data?.id;
476
+ }
477
+ catch (err) {
478
+ summary.failedStep = 'localization';
479
+ summary.error = err.message;
480
+ return summary;
481
+ }
482
+ // 3) priceSchedule (territory pricePoint 매칭 후 생성)
483
+ try {
484
+ const matched = await findClosestPricePoint(`/inAppPurchases/${iapId}/pricePoints`, baseTerritory, input.priceUsd);
485
+ if (!matched) {
486
+ summary.failedStep = 'priceSchedule';
487
+ summary.error = `${baseTerritory}에서 가격 point를 찾지 못함 — Console에서 수동 설정 필요.`;
488
+ return summary;
489
+ }
490
+ summary.priceMatchWarning = priceMatchWarning(input.priceUsd, matched.customerPrice);
491
+ const priceRefId = '${INAPP_PRICE}';
492
+ const sched = await apiPost('/inAppPurchasePriceSchedules', {
493
+ data: {
494
+ type: 'inAppPurchasePriceSchedules',
495
+ relationships: {
496
+ inAppPurchase: { data: { type: 'inAppPurchases', id: iapId } },
497
+ baseTerritory: { data: { type: 'territories', id: baseTerritory } },
498
+ manualPrices: { data: [{ type: 'inAppPurchasePrices', id: priceRefId }] },
499
+ },
500
+ },
501
+ included: [
502
+ {
503
+ type: 'inAppPurchasePrices',
504
+ id: priceRefId,
505
+ attributes: { startDate: null },
506
+ relationships: {
507
+ inAppPurchasePricePoint: {
508
+ data: { type: 'inAppPurchasePricePoints', id: matched.id },
509
+ },
510
+ inAppPurchaseV2: { data: { type: 'inAppPurchases', id: iapId } },
511
+ territory: { data: { type: 'territories', id: baseTerritory } },
512
+ },
513
+ },
514
+ ],
515
+ });
516
+ summary.priceScheduleId = sched?.data?.id;
517
+ }
518
+ catch (err) {
519
+ summary.failedStep = 'priceSchedule';
520
+ summary.error = err.message;
521
+ return summary;
522
+ }
523
+ // 4) 자동 제출 (옵션) — 실패해도 draft는 유지
524
+ if (input.autoSubmit !== false) {
525
+ try {
526
+ const sub = await apiPost('/inAppPurchaseSubmissions', {
527
+ data: {
528
+ type: 'inAppPurchaseSubmissions',
529
+ relationships: {
530
+ inAppPurchaseV2: { data: { type: 'inAppPurchases', id: iapId } },
531
+ },
532
+ },
533
+ });
534
+ summary.submissionId = sub?.data?.id;
535
+ summary.submissionState = sub?.data?.attributes?.state ?? 'WAITING_FOR_REVIEW';
536
+ }
537
+ catch (err) {
538
+ summary.failedStep = 'submission';
539
+ summary.error = err.message;
540
+ }
541
+ }
542
+ return summary;
543
+ }
544
+ async function findOrCreateSubscriptionGroup(appId, referenceName) {
545
+ // 기존 그룹 검색
546
+ const existing = await apiGet(`/apps/${appId}/subscriptionGroups`, {
547
+ 'fields[subscriptionGroups]': 'referenceName',
548
+ 'limit': '200',
549
+ });
550
+ const found = (existing?.data ?? []).find((g) => g.attributes?.referenceName === referenceName);
551
+ if (found)
552
+ return found.id;
553
+ const created = await apiPost('/subscriptionGroups', {
554
+ data: {
555
+ type: 'subscriptionGroups',
556
+ attributes: { referenceName },
557
+ relationships: {
558
+ app: { data: { type: 'apps', id: appId } },
559
+ },
560
+ },
561
+ });
562
+ const id = created?.data?.id;
563
+ if (!id)
564
+ throw new Error(`subscriptionGroup 생성 실패: ${JSON.stringify(created)}`);
565
+ return id;
566
+ }
567
+ export async function createAutoRenewableSubscription(input) {
568
+ const locale = input.locale ?? 'en-US';
569
+ const baseTerritory = input.baseTerritory ?? 'USA';
570
+ // 1) group 찾거나 생성
571
+ const groupId = await findOrCreateSubscriptionGroup(input.appId, input.groupReferenceName);
572
+ // 2) subscription draft 생성
573
+ // groupLevel은 그룹 내 unique여야 함 — 미지정 시 attribute 자체를 안 보내고 Apple이 자동 부여하게.
574
+ const subAttributes = {
575
+ name: input.referenceName,
576
+ productId: input.productId,
577
+ subscriptionPeriod: input.subscriptionPeriod,
578
+ familySharable: input.familySharable ?? false,
579
+ };
580
+ if (input.reviewNote !== undefined)
581
+ subAttributes.reviewNote = input.reviewNote;
582
+ if (input.groupLevel !== undefined)
583
+ subAttributes.groupLevel = input.groupLevel;
584
+ const created = await apiPost('/subscriptions', {
585
+ data: {
586
+ type: 'subscriptions',
587
+ attributes: subAttributes,
588
+ relationships: {
589
+ group: { data: { type: 'subscriptionGroups', id: groupId } },
590
+ },
591
+ },
592
+ });
593
+ const subscriptionId = created?.data?.id;
594
+ if (!subscriptionId) {
595
+ throw new Error(`subscription 생성 응답에 id 없음: ${JSON.stringify(created)}`);
596
+ }
597
+ const consoleUrl = `https://appstoreconnect.apple.com/apps/${input.appId}/distribution/subscriptions/${subscriptionId}`;
598
+ const summary = {
599
+ subscriptionId,
600
+ groupId,
601
+ productId: input.productId,
602
+ state: created?.data?.attributes?.state,
603
+ consoleUrl,
604
+ };
605
+ // 3) localization
606
+ try {
607
+ const loc = await apiPost('/subscriptionLocalizations', {
608
+ data: {
609
+ type: 'subscriptionLocalizations',
610
+ attributes: {
611
+ locale,
612
+ name: input.displayName,
613
+ description: input.description,
614
+ },
615
+ relationships: {
616
+ subscription: { data: { type: 'subscriptions', id: subscriptionId } },
617
+ },
618
+ },
619
+ });
620
+ summary.localizationId = loc?.data?.id;
621
+ }
622
+ catch (err) {
623
+ summary.failedStep = 'localization';
624
+ summary.error = err.message;
625
+ return summary;
626
+ }
627
+ // 4) price (subscriptionPrices — IAP의 priceSchedule보다 단순)
628
+ try {
629
+ const matched = await findClosestPricePoint(`/subscriptions/${subscriptionId}/pricePoints`, baseTerritory, input.priceUsd);
630
+ if (!matched) {
631
+ summary.failedStep = 'priceSchedule';
632
+ summary.error = `${baseTerritory}에서 가격 point를 찾지 못함 — Console에서 수동 설정 필요.`;
633
+ return summary;
634
+ }
635
+ summary.priceMatchWarning = priceMatchWarning(input.priceUsd, matched.customerPrice);
636
+ await apiPost('/subscriptionPrices', {
637
+ data: {
638
+ type: 'subscriptionPrices',
639
+ relationships: {
640
+ subscription: { data: { type: 'subscriptions', id: subscriptionId } },
641
+ subscriptionPricePoint: {
642
+ data: { type: 'subscriptionPricePoints', id: matched.id },
643
+ },
644
+ territory: { data: { type: 'territories', id: baseTerritory } },
645
+ },
646
+ },
647
+ });
648
+ summary.pricesCreated = true;
649
+ }
650
+ catch (err) {
651
+ summary.failedStep = 'priceSchedule';
652
+ summary.error = err.message;
653
+ return summary;
654
+ }
655
+ // 5) auto-submit
656
+ if (input.autoSubmit !== false) {
657
+ try {
658
+ const sub = await apiPost('/subscriptionSubmissions', {
659
+ data: {
660
+ type: 'subscriptionSubmissions',
661
+ relationships: {
662
+ subscription: { data: { type: 'subscriptions', id: subscriptionId } },
663
+ },
664
+ },
665
+ });
666
+ summary.submissionId = sub?.data?.id;
667
+ summary.submissionState = sub?.data?.attributes?.state ?? 'WAITING_FOR_REVIEW';
668
+ }
669
+ catch (err) {
670
+ summary.failedStep = 'submission';
671
+ summary.error = err.message;
672
+ }
673
+ }
674
+ return summary;
675
+ }
package/dist/index.js CHANGED
@@ -371,6 +371,111 @@ server.tool('playstore_list_subscriptions', 'Google Play 구독 상품 목록',
371
371
  const subs = await playstore.listSubscriptions(auth, packageName);
372
372
  return { content: [{ type: 'text', text: JSON.stringify(subs, null, 2) }] };
373
373
  });
374
+ server.tool('playstore_create_onetime_product', [
375
+ 'Google Play에 일회성 인앱 상품을 생성/갱신 (upsert).',
376
+ '이미 같은 productId가 있으면 listings·가격을 덮어씀. 가격은 USD 기준 + EUR 자동 환산 + newRegionsConfig로 신규 지역 자동 사용.',
377
+ 'Play Console 권한: "Manage store presence" 필요. 활성화는 Console에서 별도 토글.',
378
+ ].join(' '), {
379
+ packageName: z.string().describe('패키지명 (예: com.example.app)'),
380
+ productId: z
381
+ .string()
382
+ .describe('상품 ID (소문자/숫자/언더스코어/점, 예: premium_unlock). 한 번 정하면 변경 불가.'),
383
+ title: z.string().describe('상품 제목 (최대 55자)'),
384
+ description: z.string().describe('상품 설명 (최대 200자)'),
385
+ priceUsd: z.number().describe('USD 가격 (예: 4.99)'),
386
+ languageCode: z.string().optional().describe('listing 언어 (기본 en-US)'),
387
+ purchaseOptionId: z.string().optional().describe('구매 옵션 ID (기본 default-buy)'),
388
+ legacyCompatible: z
389
+ .boolean()
390
+ .optional()
391
+ .describe('구 PBL 클라이언트 호환 (기본 true)'),
392
+ newRegionsPricing: z
393
+ .object({
394
+ usdPrice: z.number().describe('Play가 신규 지역 launch 시 사용할 USD 가격'),
395
+ eurPrice: z.number().describe('Play가 신규 지역 launch 시 사용할 EUR 가격'),
396
+ })
397
+ .optional()
398
+ .describe('신규 출시 지역에서 자동 사용할 가격 (선택). 미지정 시 신규 지역 OFF.'),
399
+ }, async (args) => {
400
+ const auth = requireAuth();
401
+ const result = await playstore.createOnetimeProduct(auth, args);
402
+ return {
403
+ content: [
404
+ {
405
+ type: 'text',
406
+ text: [
407
+ `✓ Play 일회성 상품 upsert 완료`,
408
+ `productId: ${args.productId}`,
409
+ `price: $${args.priceUsd} (US)`,
410
+ args.newRegionsPricing
411
+ ? `신규 지역: $${args.newRegionsPricing.usdPrice} / €${args.newRegionsPricing.eurPrice}`
412
+ : '신규 지역: OFF (newRegionsPricing 미지정)',
413
+ '⚠ 기존에 다국어/다지역 등록된 상품이면 listings·purchaseOptions가 통째 교체됨.',
414
+ '',
415
+ 'Play Console에서 활성화 확인:',
416
+ `https://play.google.com/console/u/0/developers/-/app/-/managed-products?package=${encodeURIComponent(args.packageName)}`,
417
+ '',
418
+ '응답:',
419
+ JSON.stringify(result, null, 2),
420
+ ].join('\n'),
421
+ },
422
+ ],
423
+ };
424
+ });
425
+ server.tool('playstore_create_subscription', [
426
+ 'Google Play에 자동 갱신 구독을 생성하고 baseplan을 활성화.',
427
+ '구독 셸 생성 → baseplan(가격·주기) 추가 → 자동 활성화 (autoActivate=false면 draft).',
428
+ '이미 같은 productId가 있으면 listings 갱신 + 동일 basePlanId가 있으면 baseplan 생성 단계 skip.',
429
+ ].join(' '), {
430
+ packageName: z.string().describe('패키지명'),
431
+ productId: z
432
+ .string()
433
+ .describe('구독 상품 ID (소문자/숫자/언더스코어/점, 1-40자, 예: premium_monthly)'),
434
+ title: z.string().describe('구독 제목'),
435
+ description: z.string().describe('구독 설명'),
436
+ benefits: z.array(z.string()).optional().describe('혜택 bullet 리스트 (선택)'),
437
+ languageCode: z.string().optional().describe('기본 en-US'),
438
+ basePlanId: z
439
+ .string()
440
+ .optional()
441
+ .describe('베이스플랜 ID (소문자/숫자/하이픈, 기본은 billingPeriod로 추론: P1M→monthly, P1Y→yearly)'),
442
+ billingPeriod: z
443
+ .enum(['P1W', 'P1M', 'P3M', 'P6M', 'P1Y'])
444
+ .describe('청구 주기 (ISO 8601: P1M=월, P1Y=년)'),
445
+ priceUsd: z.number().describe('USD 가격'),
446
+ gracePeriod: z
447
+ .enum(['P0D', 'P3D', 'P7D', 'P14D', 'P30D'])
448
+ .optional()
449
+ .describe('결제 실패 시 유예 기간 (기본 P7D)'),
450
+ autoActivate: z
451
+ .boolean()
452
+ .optional()
453
+ .describe('생성 후 baseplan 자동 활성화 (기본 true). false면 Console에서 수동 활성화.'),
454
+ newRegionsPricing: z
455
+ .object({
456
+ usdPrice: z.number(),
457
+ eurPrice: z.number(),
458
+ })
459
+ .optional()
460
+ .describe('신규 출시 지역 자동 가격 (선택). 미지정 시 신규 지역엔 노출 안 됨.'),
461
+ }, async (args) => {
462
+ const auth = requireAuth();
463
+ const result = await playstore.createSubscription(auth, args);
464
+ const lines = [
465
+ `✓ Play 구독 생성 완료`,
466
+ `productId: ${result.productId}`,
467
+ `basePlanId: ${result.basePlanId} (${args.billingPeriod}, $${args.priceUsd})`,
468
+ result.activated
469
+ ? `✓ baseplan 활성화됨 (state=${result.basePlanState ?? 'ACTIVE'})`
470
+ : result.activateError
471
+ ? `⚠ baseplan 자동 활성화 실패: ${result.activateError}\n → Console에서 수동 활성화 필요.`
472
+ : `⏸ baseplan은 draft 상태 (autoActivate=false). Console에서 활성화 필요.`,
473
+ '',
474
+ 'Play Console:',
475
+ `https://play.google.com/console/u/0/developers/-/app/-/subscriptions?package=${encodeURIComponent(args.packageName)}`,
476
+ ];
477
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
478
+ });
374
479
  server.tool('playstore_verify_service_account', [
375
480
  "서비스 계정 JSON이 주어진 packageName에 대해 Play Developer API 호출 가능한지 + 'View financial data' 권한까지 있는지 검증.",
376
481
  'onesub 같은 서버가 Play 영수증을 백그라운드로 검증하려면 OAuth 토큰 대신 서비스 계정 JSON이 필요 — 이 도구로 붙여넣기 전에 유효성 확인.',
@@ -688,6 +793,119 @@ server.tool('appstore_reply_review', [
688
793
  const result = await appstore.createReviewResponse(reviewId, responseBody);
689
794
  return { content: [{ type: 'text', text: `✅ 리뷰 ${reviewId}에 답변 등록됐어.\n\n${JSON.stringify(result, null, 2)}` }] };
690
795
  });
796
+ // --- App Store IAP / 구독 생성 ---
797
+ server.tool('appstore_create_inapp_purchase', [
798
+ 'App Store에 일회성 인앱 구매(IAP)를 생성 — type: CONSUMABLE / NON_CONSUMABLE / NON_RENEWING_SUBSCRIPTION.',
799
+ '내부 흐름: POST /v2/inAppPurchases(draft) → POST /v1/inAppPurchaseLocalizations(displayName/description) → priceSchedule(매칭되는 pricePoint) → submission(autoSubmit=true 시).',
800
+ 'autoSubmit이 실패해도 draft + 로컬라이제이션 + 가격은 유지됨. 어디까지 진행됐는지 응답에 표시.',
801
+ '제출이 막히는 흔한 이유: 리뷰 노트 부족 / 스크린샷 미첨부 / 부모 앱 메타데이터 미완 — 그땐 콘솔 링크로 이동해 마무리.',
802
+ ].join(' '), {
803
+ appId: z.string().describe('App Store 앱 ID (appstore_list_apps 결과의 id, 숫자형)'),
804
+ productId: z
805
+ .string()
806
+ .describe('상품 ID (글로벌 unique 권장: 예 com.example.coins_100)'),
807
+ referenceName: z.string().describe('내부 식별 이름 (Console 표시용)'),
808
+ inAppPurchaseType: z
809
+ .enum(['CONSUMABLE', 'NON_CONSUMABLE', 'NON_RENEWING_SUBSCRIPTION'])
810
+ .describe('IAP 유형'),
811
+ displayName: z.string().describe('사용자 노출 이름 (최대 30자)'),
812
+ description: z.string().describe('사용자 노출 설명 (최대 45자)'),
813
+ priceUsd: z.number().describe('USD 가격 (예: 0.99) — 가장 가까운 pricePoint를 자동 매칭'),
814
+ locale: z.string().optional().describe('로컬라이제이션 로케일 (기본 en-US)'),
815
+ baseTerritory: z
816
+ .string()
817
+ .optional()
818
+ .describe('기준 지역 ISO 3166-1 alpha-3 (기본 USA)'),
819
+ reviewNote: z.string().optional().describe('Apple 리뷰어를 위한 노트'),
820
+ familySharable: z.boolean().optional().describe('패밀리 공유 가능 (기본 false)'),
821
+ autoSubmit: z
822
+ .boolean()
823
+ .optional()
824
+ .describe('생성 후 자동 심사 제출 시도 (기본 true). 실패해도 draft는 유지.'),
825
+ }, async (args) => {
826
+ const result = await appstore.createInAppPurchase(args);
827
+ const lines = [
828
+ result.failedStep
829
+ ? `⚠ App Store IAP 일부 단계 실패 (failedStep=${result.failedStep})`
830
+ : `✓ App Store IAP 생성 완료`,
831
+ `iapId: ${result.iapId}`,
832
+ `productId: ${result.productId}`,
833
+ result.localizationId ? `✓ 로컬라이제이션 (${args.locale ?? 'en-US'})` : '⏸ 로컬라이제이션 미진행',
834
+ result.priceScheduleId
835
+ ? `✓ 가격 ($${args.priceUsd} ${args.baseTerritory ?? 'USA'})`
836
+ : '⏸ 가격 미설정',
837
+ result.priceMatchWarning ? `⚠ ${result.priceMatchWarning}` : '',
838
+ result.submissionId
839
+ ? `✓ 심사 제출됨 (state=${result.submissionState ?? 'WAITING_FOR_REVIEW'})`
840
+ : args.autoSubmit === false
841
+ ? '⏸ 자동 제출 skip (autoSubmit=false) — Console에서 수동 제출'
842
+ : result.failedStep === 'submission'
843
+ ? `⚠ 자동 제출 실패 (draft·가격은 유지): ${result.error}`
844
+ : '',
845
+ result.failedStep && result.failedStep !== 'submission'
846
+ ? `오류: ${result.error}`
847
+ : '',
848
+ '',
849
+ `Console: ${result.consoleUrl}`,
850
+ ].filter(Boolean);
851
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
852
+ });
853
+ server.tool('appstore_create_subscription', [
854
+ 'App Store에 자동 갱신 구독을 생성 — Subscription Group이 없으면 함께 생성.',
855
+ '내부 흐름: subscriptionGroup find/create → POST /subscriptions(draft) → /subscriptionLocalizations → /subscriptionPrices(매칭 pricePoint) → /subscriptionSubmissions(autoSubmit=true).',
856
+ 'groupReferenceName이 같은 그룹 내 구독은 사용자가 한 번에 하나만 가질 수 있음 (upgrade/downgrade 가능).',
857
+ 'autoSubmit 실패 시에도 draft + 가격까지는 유지 — 어디까지 됐는지 응답으로 알 수 있음.',
858
+ ].join(' '), {
859
+ appId: z.string().describe('App Store 앱 ID'),
860
+ groupReferenceName: z
861
+ .string()
862
+ .describe("구독 그룹 내부 이름 (예: 'premium'). 없으면 자동 생성, 있으면 기존 그룹 재사용."),
863
+ productId: z.string().describe('구독 productId (예: com.example.premium.monthly)'),
864
+ referenceName: z.string().describe('내부 식별 이름'),
865
+ subscriptionPeriod: z
866
+ .enum(['ONE_WEEK', 'ONE_MONTH', 'TWO_MONTHS', 'THREE_MONTHS', 'SIX_MONTHS', 'ONE_YEAR'])
867
+ .describe('구독 주기'),
868
+ displayName: z.string().describe('사용자 노출 이름'),
869
+ description: z.string().describe('사용자 노출 설명'),
870
+ priceUsd: z.number().describe('USD 가격'),
871
+ locale: z.string().optional().describe('기본 en-US'),
872
+ baseTerritory: z.string().optional().describe('기본 USA (alpha-3)'),
873
+ reviewNote: z.string().optional(),
874
+ familySharable: z.boolean().optional(),
875
+ groupLevel: z
876
+ .number()
877
+ .optional()
878
+ .describe('그룹 내 표시 순서. 미지정 시 Apple이 자동 부여 — 같은 그룹에 여러 구독 만들 때 명시적으로 충돌 안 하려면 비워둘 것.'),
879
+ autoSubmit: z.boolean().optional().describe('자동 심사 제출 (기본 true)'),
880
+ }, async (args) => {
881
+ const result = await appstore.createAutoRenewableSubscription(args);
882
+ const lines = [
883
+ result.failedStep
884
+ ? `⚠ App Store 구독 일부 단계 실패 (failedStep=${result.failedStep})`
885
+ : `✓ App Store 자동 갱신 구독 생성 완료`,
886
+ `subscriptionId: ${result.subscriptionId}`,
887
+ `groupId: ${result.groupId} (${args.groupReferenceName})`,
888
+ `productId: ${result.productId}`,
889
+ result.localizationId ? `✓ 로컬라이제이션 (${args.locale ?? 'en-US'})` : '⏸ 로컬라이제이션 미진행',
890
+ result.pricesCreated
891
+ ? `✓ 가격 ($${args.priceUsd} ${args.baseTerritory ?? 'USA'})`
892
+ : '⏸ 가격 미설정',
893
+ result.priceMatchWarning ? `⚠ ${result.priceMatchWarning}` : '',
894
+ result.submissionId
895
+ ? `✓ 심사 제출됨 (state=${result.submissionState ?? 'WAITING_FOR_REVIEW'})`
896
+ : args.autoSubmit === false
897
+ ? '⏸ 자동 제출 skip — Console에서 수동 제출'
898
+ : result.failedStep === 'submission'
899
+ ? `⚠ 자동 제출 실패 (draft·가격은 유지): ${result.error}`
900
+ : '',
901
+ result.failedStep && result.failedStep !== 'submission'
902
+ ? `오류: ${result.error}`
903
+ : '',
904
+ '',
905
+ `Console: ${result.consoleUrl}`,
906
+ ].filter(Boolean);
907
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
908
+ });
691
909
  // --- App Store 심사 제출 (Submit for Review) ---
692
910
  server.tool('appstore_submit_for_review', [
693
911
  'App Store 버전을 심사에 제출 — 새 reviewSubmissions API 사용 (옛 /appStoreVersionSubmissions는 2024-01 deprecated).',
@@ -718,6 +936,39 @@ server.tool('playstore_submit_release', [
718
936
  const result = await playstore.submitRelease(auth, packageName, track, versionCode, status);
719
937
  return { content: [{ type: 'text', text: `✅ ${packageName} ${track} v${versionCode}: ${result.previousStatus} → ${result.newStatus}\n\n${JSON.stringify(result, null, 2)}` }] };
720
938
  });
939
+ // --- Play Store 트랙 간 promote (internal → production 등) ---
940
+ server.tool('playstore_promote_release', [
941
+ 'Google Play 트랙 간 release promote — fromTrack(예: internal)의 versionCode를 toTrack(예: production)에 새 release로 추가.',
942
+ '단일 edit session에서 source 조회 → target 업데이트 → commit 까지 한 번에 처리.',
943
+ 'source의 releaseNotes를 자동 복사 (copyReleaseNotes=false 또는 releaseNotes로 덮어쓰기 가능).',
944
+ 'status="completed" + production 으로 전체 출시, status="inProgress" + userFraction 으로 단계 출시(예: 0.1 = 10%).',
945
+ 'target에 같은 versionCode가 이미 있으면 해당 항목을 교체. status="completed"면 target 활성 release를 통째로 새 것으로 대체.',
946
+ '⚠️ status="completed"는 비가역에 가까움. playstore_check_submission_risks로 사전 점검 권장.',
947
+ ].join(' '), {
948
+ packageName: z.string().describe('패키지명 (예: gg.pryzm.coffee)'),
949
+ fromTrack: z.enum(['production', 'beta', 'alpha', 'internal']).describe('출처 트랙 (예: internal)'),
950
+ toTrack: z.enum(['production', 'beta', 'alpha', 'internal']).describe('대상 트랙 (예: production)'),
951
+ versionCode: z.string().describe('promote할 versionCode (문자열)'),
952
+ status: z.enum(['completed', 'draft', 'inProgress', 'halted']).optional().describe('대상 트랙에서의 status (기본 completed)'),
953
+ userFraction: z.number().min(0).max(1).optional().describe('status="inProgress"일 때 단계 출시 비율 (0~1, 예: 0.1)'),
954
+ releaseName: z.string().optional().describe('release 이름 (미지정 시 source 이름 그대로)'),
955
+ copyReleaseNotes: z.boolean().optional().describe('source releaseNotes 복사 여부 (기본 true)'),
956
+ releaseNotes: z.array(z.object({
957
+ language: z.string().describe('언어 코드 (예: ko-KR, en-US)'),
958
+ text: z.string().describe('릴리스 노트 본문 (500자 이내)'),
959
+ })).optional().describe('대상 트랙용 릴리스 노트 (미지정 + copyReleaseNotes=true면 source 그대로)'),
960
+ }, async ({ packageName, fromTrack, toTrack, versionCode, status, userFraction, releaseName, copyReleaseNotes, releaseNotes }) => {
961
+ const auth = requireAuth();
962
+ const result = await playstore.promoteRelease(auth, packageName, fromTrack, toTrack, versionCode, {
963
+ status,
964
+ userFraction,
965
+ releaseName,
966
+ copyReleaseNotes,
967
+ releaseNotes,
968
+ });
969
+ const summary = `✅ ${packageName} ${fromTrack} → ${toTrack} v${versionCode} (status: ${result.newStatus}${result.userFraction != null ? `, userFraction: ${result.userFraction}` : ''})`;
970
+ return { content: [{ type: 'text', text: `${summary}\n\n${JSON.stringify(result, null, 2)}` }] };
971
+ });
721
972
  // --- 인증 상태 ---
722
973
  server.tool('mimi_seed_auth_start', [
723
974
  'Google OAuth 로그인 링크를 발급하고 백그라운드 콜백 서버를 시작.',
@@ -62,6 +62,28 @@ export declare function submitRelease(auth: OAuth2Client, packageName: string, t
62
62
  committed: boolean;
63
63
  result: import("googleapis").androidpublisher_v3.Schema$Track;
64
64
  }>;
65
+ export interface PromoteReleaseOptions {
66
+ status?: 'completed' | 'draft' | 'inProgress' | 'halted';
67
+ userFraction?: number;
68
+ releaseName?: string;
69
+ releaseNotes?: Array<{
70
+ language: string;
71
+ text: string;
72
+ }>;
73
+ copyReleaseNotes?: boolean;
74
+ }
75
+ export declare function promoteRelease(auth: OAuth2Client, packageName: string, fromTrack: string, toTrack: string, versionCode: string, options?: PromoteReleaseOptions): Promise<{
76
+ packageName: string;
77
+ fromTrack: string;
78
+ toTrack: string;
79
+ versionCode: string;
80
+ newStatus: "completed" | "draft" | "inProgress" | "halted";
81
+ userFraction: number | undefined;
82
+ releaseName: string | null | undefined;
83
+ releaseNotesLanguages: (string | null | undefined)[];
84
+ committed: boolean;
85
+ result: import("googleapis").androidpublisher_v3.Schema$Track;
86
+ }>;
65
87
  export declare function listReviews(auth: OAuth2Client, packageName: string): Promise<{
66
88
  reviewId: string | null | undefined;
67
89
  authorName: string | null | undefined;
@@ -83,6 +105,48 @@ export declare function listSubscriptions(auth: OAuth2Client, packageName: strin
83
105
  basePlans: import("googleapis").androidpublisher_v3.Schema$BasePlan[] | undefined;
84
106
  listings: import("googleapis").androidpublisher_v3.Schema$SubscriptionListing[] | undefined;
85
107
  }[]>;
108
+ export interface CreateOneTimeProductInput {
109
+ packageName: string;
110
+ productId: string;
111
+ title: string;
112
+ description: string;
113
+ languageCode?: string;
114
+ priceUsd: number;
115
+ purchaseOptionId?: string;
116
+ legacyCompatible?: boolean;
117
+ regionsVersion?: string;
118
+ newRegionsPricing?: {
119
+ usdPrice: number;
120
+ eurPrice: number;
121
+ };
122
+ }
123
+ export declare function createOnetimeProduct(auth: OAuth2Client, input: CreateOneTimeProductInput): Promise<import("googleapis").androidpublisher_v3.Schema$OneTimeProduct>;
124
+ export interface CreateSubscriptionInput {
125
+ packageName: string;
126
+ productId: string;
127
+ title: string;
128
+ description: string;
129
+ benefits?: string[];
130
+ languageCode?: string;
131
+ basePlanId?: string;
132
+ billingPeriod: 'P1W' | 'P1M' | 'P3M' | 'P6M' | 'P1Y';
133
+ priceUsd: number;
134
+ gracePeriod?: 'P0D' | 'P3D' | 'P7D' | 'P14D' | 'P30D';
135
+ resubscribeState?: 'RESUBSCRIBE_STATE_ACTIVE' | 'RESUBSCRIBE_STATE_INACTIVE';
136
+ autoActivate?: boolean;
137
+ regionsVersion?: string;
138
+ newRegionsPricing?: {
139
+ usdPrice: number;
140
+ eurPrice: number;
141
+ };
142
+ }
143
+ export declare function createSubscription(auth: OAuth2Client, input: CreateSubscriptionInput): Promise<{
144
+ productId: string;
145
+ basePlanId: string;
146
+ basePlanState?: string | null;
147
+ activated: boolean;
148
+ activateError?: string;
149
+ }>;
86
150
  export type ServiceAccountVerifyResult = {
87
151
  ok: true;
88
152
  clientEmail: string;
@@ -262,6 +262,79 @@ export async function submitRelease(auth, packageName, track, versionCode, statu
262
262
  };
263
263
  }, true);
264
264
  }
265
+ export async function promoteRelease(auth, packageName, fromTrack, toTrack, versionCode, options = {}) {
266
+ const { status = 'completed', userFraction, releaseName, releaseNotes, copyReleaseNotes = true, } = options;
267
+ if (fromTrack === toTrack) {
268
+ throw new Error('fromTrack과 toTrack이 같아. 다른 트랙으로 promote 해야 의미 있어.');
269
+ }
270
+ if (status === 'inProgress' && (userFraction == null || userFraction <= 0 || userFraction >= 1)) {
271
+ throw new Error('status="inProgress"일 때 userFraction은 0과 1 사이 필수 (예: 0.1 → 10%).');
272
+ }
273
+ return withEdit(auth, packageName, async (editId) => {
274
+ // 1. source 트랙에서 versionCode 매칭 release 찾기
275
+ const fromData = await publisher().edits.tracks.get({
276
+ auth, packageName, editId, track: fromTrack,
277
+ });
278
+ const fromReleases = fromData.data.releases ?? [];
279
+ const sourceRelease = fromReleases.find((r) => (r.versionCodes ?? []).some((v) => String(v) === String(versionCode)));
280
+ if (!sourceRelease) {
281
+ const available = fromReleases.map((r) => ({
282
+ name: r.name,
283
+ versionCodes: r.versionCodes,
284
+ status: r.status,
285
+ }));
286
+ throw new Error(`versionCode "${versionCode}"를 ${fromTrack} 트랙에서 찾을 수 없어. 가능한 릴리스: ${JSON.stringify(available)}`);
287
+ }
288
+ // 2. 새 release 객체 구성
289
+ const newRelease = {
290
+ name: releaseName ?? sourceRelease.name ?? versionCode,
291
+ versionCodes: [String(versionCode)],
292
+ status,
293
+ releaseNotes: releaseNotes ??
294
+ (copyReleaseNotes ? sourceRelease.releaseNotes ?? [] : []),
295
+ };
296
+ if (status === 'inProgress' && userFraction != null) {
297
+ newRelease.userFraction = userFraction;
298
+ }
299
+ // 3. target 트랙 현재 상태 조회 후 merge
300
+ // - 같은 versionCode가 이미 target에 있으면 그 항목을 새 release로 교체
301
+ // - status='completed'면 활성 release가 1개여야 하므로 [newRelease]로 통째 교체
302
+ // - 그 외(draft/inProgress)는 기존 release에 append
303
+ const toData = await publisher().edits.tracks.get({
304
+ auth, packageName, editId, track: toTrack,
305
+ });
306
+ const toReleases = toData.data.releases ?? [];
307
+ const existingIdx = toReleases.findIndex((r) => (r.versionCodes ?? []).some((v) => String(v) === String(versionCode)));
308
+ let mergedReleases;
309
+ if (existingIdx >= 0) {
310
+ mergedReleases = [...toReleases];
311
+ mergedReleases[existingIdx] = newRelease;
312
+ }
313
+ else if (status === 'completed') {
314
+ mergedReleases = [newRelease];
315
+ }
316
+ else {
317
+ mergedReleases = [...toReleases, newRelease];
318
+ }
319
+ // 4. target 트랙 업데이트 (commit은 withEdit 마지막에)
320
+ const updated = await publisher().edits.tracks.update({
321
+ auth, packageName, editId, track: toTrack,
322
+ requestBody: { track: toTrack, releases: mergedReleases },
323
+ });
324
+ return {
325
+ packageName,
326
+ fromTrack,
327
+ toTrack,
328
+ versionCode: String(versionCode),
329
+ newStatus: status,
330
+ userFraction: status === 'inProgress' ? userFraction : undefined,
331
+ releaseName: newRelease.name,
332
+ releaseNotesLanguages: (newRelease.releaseNotes ?? []).map((n) => n.language),
333
+ committed: true,
334
+ result: updated.data,
335
+ };
336
+ }, true);
337
+ }
265
338
  export async function listReviews(auth, packageName) {
266
339
  const res = await publisher().reviews.list({ auth, packageName });
267
340
  return (res.data.reviews ?? []).map((r) => ({
@@ -311,6 +384,166 @@ export async function listSubscriptions(auth, packageName) {
311
384
  listings: s.listings,
312
385
  }));
313
386
  }
387
+ function moneyFromNumber(amount, currencyCode) {
388
+ const units = Math.trunc(amount).toString();
389
+ const nanos = Math.round((amount - Math.trunc(amount)) * 1_000_000_000);
390
+ return { currencyCode, units, nanos };
391
+ }
392
+ export async function createOnetimeProduct(auth, input) {
393
+ const languageCode = input.languageCode ?? 'en-US';
394
+ const purchaseOptionId = input.purchaseOptionId ?? 'default-buy';
395
+ const regionsVersion = input.regionsVersion ?? '2022/02';
396
+ const usdPrice = moneyFromNumber(input.priceUsd, 'USD');
397
+ const newRegionsConfig = input.newRegionsPricing
398
+ ? {
399
+ availability: 'AVAILABLE',
400
+ usdPrice: moneyFromNumber(input.newRegionsPricing.usdPrice, 'USD'),
401
+ eurPrice: moneyFromNumber(input.newRegionsPricing.eurPrice, 'EUR'),
402
+ }
403
+ : { availability: 'UNAVAILABLE' };
404
+ const res = await publisher().monetization.onetimeproducts.patch({
405
+ auth,
406
+ packageName: input.packageName,
407
+ productId: input.productId,
408
+ allowMissing: true,
409
+ updateMask: 'listings,purchaseOptions',
410
+ 'regionsVersion.version': regionsVersion,
411
+ requestBody: {
412
+ packageName: input.packageName,
413
+ productId: input.productId,
414
+ listings: [
415
+ {
416
+ languageCode,
417
+ title: input.title,
418
+ description: input.description,
419
+ },
420
+ ],
421
+ purchaseOptions: [
422
+ {
423
+ purchaseOptionId,
424
+ buyOption: {
425
+ legacyCompatible: input.legacyCompatible ?? true,
426
+ },
427
+ regionalPricingAndAvailabilityConfigs: [
428
+ {
429
+ regionCode: 'US',
430
+ availability: 'AVAILABLE',
431
+ price: usdPrice,
432
+ },
433
+ ],
434
+ newRegionsConfig,
435
+ },
436
+ ],
437
+ },
438
+ });
439
+ return res.data;
440
+ }
441
+ function defaultBasePlanIdFor(period) {
442
+ switch (period) {
443
+ case 'P1W': return 'weekly';
444
+ case 'P1M': return 'monthly';
445
+ case 'P3M': return 'quarterly';
446
+ case 'P6M': return 'semi-yearly';
447
+ case 'P1Y': return 'yearly';
448
+ default: return 'default';
449
+ }
450
+ }
451
+ export async function createSubscription(auth, input) {
452
+ const languageCode = input.languageCode ?? 'en-US';
453
+ const basePlanId = input.basePlanId ?? defaultBasePlanIdFor(input.billingPeriod);
454
+ const regionsVersion = input.regionsVersion ?? '2022/02';
455
+ const usdPrice = moneyFromNumber(input.priceUsd, 'USD');
456
+ const otherRegionsConfig = input.newRegionsPricing
457
+ ? {
458
+ newSubscriberAvailability: true,
459
+ usdPrice: moneyFromNumber(input.newRegionsPricing.usdPrice, 'USD'),
460
+ eurPrice: moneyFromNumber(input.newRegionsPricing.eurPrice, 'EUR'),
461
+ }
462
+ : { newSubscriberAvailability: false };
463
+ // 1) subscription 셸 upsert (allowMissing=true로 idempotent)
464
+ // updateMask는 mutable 필드만 — packageName/productId는 immutable이라 포함 시 reject 가능.
465
+ await publisher().monetization.subscriptions.patch({
466
+ auth,
467
+ packageName: input.packageName,
468
+ productId: input.productId,
469
+ allowMissing: true,
470
+ updateMask: 'listings',
471
+ 'regionsVersion.version': regionsVersion,
472
+ requestBody: {
473
+ packageName: input.packageName,
474
+ productId: input.productId,
475
+ listings: [
476
+ {
477
+ languageCode,
478
+ title: input.title,
479
+ description: input.description,
480
+ benefits: input.benefits,
481
+ },
482
+ ],
483
+ },
484
+ });
485
+ // 2) basePlan 생성 (이미 있으면 conflict — 그땐 update 시도)
486
+ let basePlanState;
487
+ try {
488
+ const created = await publisher().monetization.subscriptions.basePlans.create({
489
+ auth,
490
+ packageName: input.packageName,
491
+ productId: input.productId,
492
+ basePlanId,
493
+ 'regionsVersion.version': regionsVersion,
494
+ requestBody: {
495
+ basePlanId,
496
+ autoRenewingBasePlanType: {
497
+ billingPeriodDuration: input.billingPeriod,
498
+ gracePeriodDuration: input.gracePeriod ?? 'P7D',
499
+ resubscribeState: input.resubscribeState ?? 'RESUBSCRIBE_STATE_ACTIVE',
500
+ },
501
+ regionalConfigs: [
502
+ {
503
+ regionCode: 'US',
504
+ newSubscriberAvailability: true,
505
+ price: usdPrice,
506
+ },
507
+ ],
508
+ otherRegionsConfig,
509
+ },
510
+ });
511
+ basePlanState = created.data?.state;
512
+ }
513
+ catch (err) {
514
+ // 이미 존재하는 경우 409 — 그냥 진행
515
+ const e = err;
516
+ if (e.code !== 409 && e.status !== 409) {
517
+ throw err;
518
+ }
519
+ }
520
+ // 3) 자동 활성화 (옵션)
521
+ let activated = false;
522
+ let activateError;
523
+ if (input.autoActivate !== false) {
524
+ try {
525
+ const activated_ = await publisher().monetization.subscriptions.basePlans.activate({
526
+ auth,
527
+ packageName: input.packageName,
528
+ productId: input.productId,
529
+ basePlanId,
530
+ requestBody: {},
531
+ });
532
+ activated = true;
533
+ basePlanState = (activated_.data?.basePlans?.find?.((b) => b.basePlanId === basePlanId)?.state) ?? basePlanState;
534
+ }
535
+ catch (err) {
536
+ activateError = err.message;
537
+ }
538
+ }
539
+ return {
540
+ productId: input.productId,
541
+ basePlanId,
542
+ basePlanState,
543
+ activated,
544
+ activateError,
545
+ };
546
+ }
314
547
  export async function verifyServiceAccountJson(serviceAccountJson, packageName) {
315
548
  let parsed;
316
549
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Mimi Seed MCP server — Firebase + AdMob + Google Play + App Store management for Claude Code / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {