@yoonion/mimi-seed-mcp 0.3.5 → 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).',
@@ -105,6 +105,48 @@ export declare function listSubscriptions(auth: OAuth2Client, packageName: strin
105
105
  basePlans: import("googleapis").androidpublisher_v3.Schema$BasePlan[] | undefined;
106
106
  listings: import("googleapis").androidpublisher_v3.Schema$SubscriptionListing[] | undefined;
107
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
+ }>;
108
150
  export type ServiceAccountVerifyResult = {
109
151
  ok: true;
110
152
  clientEmail: string;
@@ -384,6 +384,166 @@ export async function listSubscriptions(auth, packageName) {
384
384
  listings: s.listings,
385
385
  }));
386
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
+ }
387
547
  export async function verifyServiceAccountJson(serviceAccountJson, packageName) {
388
548
  let parsed;
389
549
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.3.5",
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": {