@sayren/store-sdk 0.52.0 → 0.54.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.
@@ -1,6 +1,5 @@
1
1
  import * as z from "zod";
2
2
  import {
3
- anyConditionHolds,
4
3
  type BirthdateFact,
5
4
  birthdateFactOf,
6
5
  type ConditionCatalog,
@@ -19,6 +18,7 @@ import {
19
18
  conditionIssues,
20
19
  conditionRuleSchema,
21
20
  evaluateCondition,
21
+ evaluateConditionNode,
22
22
  isConditionGroup,
23
23
  } from "./condition-tree";
24
24
 
@@ -27,7 +27,8 @@ import {
27
27
  *
28
28
  * 쿠폰은 세 트리를 갖는다.
29
29
  * - `eligibility`(사용 조건): 구매자·주문서·시각. 그룹 중첩 3단까지
30
- * - `targetRules`(할인 대상): 줄 단위. `{ include, exclude }` 고정 형태 — 포함은 하나라도 맞으면 대상(비면 전 상품), 제외는 하나라도 맞으면 빠지고 포함보다 우선
30
+ * - `targetTree`(할인 대상, 이슈 #263): 줄 단위. `{ include, exclude }` 각각 조건 그룹(3단) — 포함이 참이면 대상(비면 전 상품), 제외가 참이면 빠지고 포함보다 우선(비면 제외 없음).
31
+ * 옛 칸 `targetRules`(평평한 「하나라도」 목록)는 `couponTargetTreeFromRules`로 같은 트리가 되고, 응답의 `targetRules`는 트리에서 만든 가장 가까운 값이다
31
32
  * - `audience`(발급 대상): 누가 받을 수 있는가. 구매자 사실 값만
32
33
  *
33
34
  * 기간·한도·혜택·`memberOnly`·중복 정책은 트리 밖 칸이다. 옛 칸(`conditions`·`target`)은 프리셋 변환(`couponRulesFromLegacy`)으로 같은 트리가 된다 —
@@ -424,6 +425,20 @@ export type CouponTargetRules = z.infer<typeof couponTargetRulesSchema>;
424
425
 
425
426
  export const COUPON_TARGET_RULES_ALL: CouponTargetRules = { include: [], exclude: [] };
426
427
 
428
+ /** 할인 대상 트리(이슈 #263) — 포함·제외 각각 조건 그룹(3단, 규칙 50개). 다른 자리와 같은 그룹 형식이다 */
429
+ export const couponTargetTreeSchema = z.strictObject({
430
+ include: (conditionGroupSchema as z.ZodType<ConditionGroup>).describe(
431
+ "포함 그룹 — 참인 줄이 대상. 비면 전 상품",
432
+ ),
433
+ exclude: (conditionGroupSchema as z.ZodType<ConditionGroup>).describe(
434
+ "제외 그룹 — 참인 줄은 대상에서 빠진다. 포함보다 우선한다. 비면 제외 없음",
435
+ ),
436
+ });
437
+ export interface CouponTargetTree {
438
+ include: ConditionGroup;
439
+ exclude: ConditionGroup;
440
+ }
441
+
427
442
  export const EMPTY_CONDITION_GROUP: ConditionGroup = { combinator: "and", rules: [] };
428
443
 
429
444
  /** 사용 조건·발급 대상 트리 */
@@ -464,6 +479,60 @@ export function canonicalizeCouponTargetRules(rules: CouponTargetRules): CouponT
464
479
  };
465
480
  }
466
481
 
482
+ /**
483
+ * 규칙 1개 이하 그룹은 결합어와 무관하다 — `or`로 고정해 옛 쿠폰의 `{or,[r]}`와 화면이 보낸 `{and,[r]}`를 같은 값으로 본다.
484
+ * 규칙 없는 하위 그룹은 뺀다 — 빈 그룹은 참으로 평가되므로 남기면 포함 「하나 이상」은 전 상품, 제외는 모든 상품을 뺀다(화면 `treeOf`와 같은 뜻)
485
+ */
486
+ function canonicalTargetGroup(group: ConditionGroup): ConditionGroup {
487
+ const rules = group.rules.flatMap((node): ConditionNode[] => {
488
+ if (!isConditionGroup(node)) return [canonicalizeConditionRule(node)];
489
+ const child = canonicalTargetGroup(node);
490
+ return child.rules.length > 0 ? [child] : [];
491
+ });
492
+ return { combinator: rules.length <= 1 ? "or" : group.combinator, rules };
493
+ }
494
+
495
+ /** 할인 대상 트리의 정규형 — 저장·잠금 비교·승인 비교·스냅샷이 같은 함수다 */
496
+ export function canonicalizeCouponTargetTree(tree: CouponTargetTree): CouponTargetTree {
497
+ return {
498
+ include: canonicalTargetGroup(tree.include),
499
+ exclude: canonicalTargetGroup(tree.exclude),
500
+ };
501
+ }
502
+
503
+ /** 옛 평평한 목록 → 트리. 「하나라도」가 그대로 `or` 그룹이다(같은 줄 판정) */
504
+ export function couponTargetTreeFromRules(rules: CouponTargetRules): CouponTargetTree {
505
+ return {
506
+ include: { combinator: "or", rules: rules.include },
507
+ exclude: { combinator: "or", rules: rules.exclude },
508
+ };
509
+ }
510
+
511
+ /** 그룹이 평평한 「하나라도」 목록으로 같은 뜻인가 — 하위 그룹이 없고 `or`이거나 규칙이 1개 이하 */
512
+ function flatRulesOf(group: ConditionGroup): ConditionRule[] | null {
513
+ if (group.rules.some(isConditionGroup)) return null;
514
+ if (group.combinator === "and" && group.rules.length > 1) return null;
515
+ return group.rules as ConditionRule[];
516
+ }
517
+
518
+ /**
519
+ * 트리 → 옛 평평한 목록. 같은 뜻으로 옮길 수 없으면(`모두`에 규칙 2개 이상, 하위 그룹) `rulesOnly: true`이고 목록은
520
+ * 그룹 최상위 규칙을 평평하게 모은 가장 가까운 값이다
521
+ */
522
+ export function couponTargetRulesFromTree(tree: CouponTargetTree): {
523
+ targetRules: CouponTargetRules;
524
+ rulesOnly: boolean;
525
+ } {
526
+ const include = flatRulesOf(tree.include);
527
+ const exclude = flatRulesOf(tree.exclude);
528
+ const top = (group: ConditionGroup) =>
529
+ group.rules.filter((node): node is ConditionRule => !isConditionGroup(node));
530
+ return {
531
+ targetRules: { include: include ?? top(tree.include), exclude: exclude ?? top(tree.exclude) },
532
+ rulesOnly: include === null || exclude === null,
533
+ };
534
+ }
535
+
467
536
  export function canonicalizeCouponStacking(stacking: CouponStacking): CouponStacking {
468
537
  return {
469
538
  combinesWith: {
@@ -495,7 +564,15 @@ export interface LegacyCouponTarget {
495
564
 
496
565
  export interface CouponRuleTrees {
497
566
  eligibility: ConditionGroup;
567
+ /** 할인 대상의 옛 평평한 목록 — `targetTree`가 있으면 그 트리에서 만든 가장 가까운 값이다 */
498
568
  targetRules: CouponTargetRules;
569
+ /** 할인 대상 트리(이슈 #263). 없으면 `targetRules`에서 만든다(`couponTargetTreeOf`) — 계산은 트리만 읽는다 */
570
+ targetTree?: CouponTargetTree;
571
+ }
572
+
573
+ /** 묶음의 할인 대상 트리 — 트리가 없으면 옛 목록에서 만든다(읽기 한 경로) */
574
+ export function couponTargetTreeOf(trees: Pick<CouponRuleTrees, "targetRules" | "targetTree">) {
575
+ return trees.targetTree ?? couponTargetTreeFromRules(trees.targetRules);
499
576
  }
500
577
 
501
578
  const inRule = (field: string, ids: readonly string[]): ConditionRule[] =>
@@ -552,6 +629,10 @@ const isFirstOrderRule = (node: ConditionNode): boolean =>
552
629
  const isInstantExcludeRule = (rule: ConditionRule) =>
553
630
  rule.field === "line.hasInstantDiscount" && rule.operator === "=" && rule.value === true;
554
631
 
632
+ const instantExcluded = (exclude: ConditionGroup) =>
633
+ (exclude.combinator === "or" || exclude.rules.length <= 1) &&
634
+ exclude.rules.some((node) => !isConditionGroup(node) && isInstantExcludeRule(node));
635
+
555
636
  const isIdRule = (rule: ConditionRule) =>
556
637
  (rule.field === "line.productId" || rule.field === "line.categoryId") && rule.operator === "in";
557
638
 
@@ -575,9 +656,11 @@ export function legacyFromCouponRules(
575
656
  .flatMap((r) => r.value as string[]),
576
657
  ),
577
658
  ].sort();
578
- const { include, exclude } = trees.targetRules;
659
+ const flat = couponTargetRulesFromTree(couponTargetTreeOf(trees));
660
+ const { include, exclude } = flat.targetRules;
579
661
  const mappedEligibility = nodes.filter((n) => n === minRule || n === firstRule).length;
580
662
  const rulesOnly =
663
+ flat.rulesOnly ||
581
664
  mappedEligibility !== trees.eligibility.rules.length ||
582
665
  nodes.filter(isMinOrderRule).length > 1 ||
583
666
  include.some((r) => !isIdRule(r)) ||
@@ -587,7 +670,8 @@ export function legacyFromCouponRules(
587
670
  minOrderAmount: minRule ? (minRule.value as number) : 0,
588
671
  firstOrderOnly: Boolean(firstRule),
589
672
  memberOnly,
590
- stackWithInstantDiscount: !exclude.some(isInstantExcludeRule),
673
+ // 즉시할인 줄을 모두 빼는 것은 제외가 「하나 이상」(또는 규칙 하나)이고 즉시할인 규칙이 최상위에 있을 때뿐이다 — 「모두」 묶음의 일부면 일부 줄만 빠진다
674
+ stackWithInstantDiscount: !instantExcluded(couponTargetTreeOf(trees).exclude),
591
675
  },
592
676
  target: {
593
677
  includeProductIds: ids(include, "line.productId"),
@@ -601,11 +685,22 @@ export function legacyFromCouponRules(
601
685
 
602
686
  // ─── 평가(쿠폰용) ─────────────────────────────────────
603
687
 
604
- /** 줄이 대상인가 — 제외가 포함보다 우선, 포함이 비면 전 상품 */
688
+ /**
689
+ * 줄이 대상인가(트리) — 제외가 포함보다 우선, 포함이 비면 전 상품, 제외가 비면 제외 없음(빈 그룹 참 규칙을 쓰지 않는다).
690
+ * 계산(`quotePricing`·최대 조합·대상 상품 판정)은 모두 이 함수를 지난다
691
+ */
692
+ export function couponTargetTreeMatches(tree: CouponTargetTree, line: CouponLineContext): boolean {
693
+ if (
694
+ tree.exclude.rules.length > 0 &&
695
+ evaluateConditionNode(tree.exclude, COUPON_LINE_CONDITIONS, line)
696
+ )
697
+ return false;
698
+ return evaluateConditionNode(tree.include, COUPON_LINE_CONDITIONS, line);
699
+ }
700
+
701
+ /** 줄이 대상인가(옛 평평한 목록) — 트리로 바꿔 같은 판정 함수를 지난다 */
605
702
  export function couponLineMatches(rules: CouponTargetRules, line: CouponLineContext): boolean {
606
- if (anyConditionHolds(rules.exclude, COUPON_LINE_CONDITIONS, line)) return false;
607
- if (rules.include.length === 0) return true;
608
- return anyConditionHolds(rules.include, COUPON_LINE_CONDITIONS, line);
703
+ return couponTargetTreeMatches(couponTargetTreeFromRules(rules), line);
609
704
  }
610
705
 
611
706
  /** 사용 조건 실패 사유 — 옛 칸으로 옮겨지는 실패는 옛 코드를 그대로 쓴다 */
@@ -752,6 +847,7 @@ export function couponRuleIssues(input: {
752
847
  perMemberUseLimit?: number | null;
753
848
  eligibility?: ConditionGroup;
754
849
  targetRules?: CouponTargetRules;
850
+ targetTree?: CouponTargetTree;
755
851
  audience?: ConditionGroup;
756
852
  }): CouponRuleIssue[] {
757
853
  const issues: CouponRuleIssue[] = [];
@@ -785,7 +881,16 @@ export function couponRuleIssues(input: {
785
881
  }),
786
882
  );
787
883
  }
788
- if (input.targetRules) {
884
+ if (input.targetTree) {
885
+ for (const key of ["include", "exclude"] as const) {
886
+ push(
887
+ ["targetTree", key],
888
+ conditionIssues(input.targetTree[key], COUPON_LINE_CONDITIONS, {
889
+ allowed: allowedIn("target"),
890
+ }),
891
+ );
892
+ }
893
+ } else if (input.targetRules) {
789
894
  for (const key of ["include", "exclude"] as const) {
790
895
  push(
791
896
  ["targetRules", key],
@@ -860,9 +965,14 @@ export function couponRuleIssues(input: {
860
965
 
861
966
  /** 정규형 트리 묶음 */
862
967
  export function canonicalizeCouponTrees(trees: CouponRuleTrees): CouponRuleTrees {
968
+ const eligibility = canonicalizeConditionGroup(trees.eligibility);
969
+ if (!trees.targetTree)
970
+ return { eligibility, targetRules: canonicalizeCouponTargetRules(trees.targetRules) };
971
+ const targetTree = canonicalizeCouponTargetTree(trees.targetTree);
863
972
  return {
864
- eligibility: canonicalizeConditionGroup(trees.eligibility),
865
- targetRules: canonicalizeCouponTargetRules(trees.targetRules),
973
+ eligibility,
974
+ targetRules: canonicalizeCouponTargetRules(couponTargetRulesFromTree(targetTree).targetRules),
975
+ targetTree,
866
976
  };
867
977
  }
868
978
 
@@ -1002,6 +1112,23 @@ export function describeCouponTargetRules(
1002
1112
  return `${include} (${exclude} 제외)`;
1003
1113
  }
1004
1114
 
1115
+ /**
1116
+ * 할인 대상 문구(트리) — 「상품 1개 그리고 판매가 30,000원 이상 (즉시할인 상품 제외)」. 포함이 비면 「전 상품」.
1117
+ * 「하나라도」만 쓴 옛 쿠폰은 `describeCouponTargetRules`와 같은 문구다
1118
+ */
1119
+ export function describeCouponTargetTree(
1120
+ tree: CouponTargetTree,
1121
+ formatMoney?: (minor: number) => string,
1122
+ ): string {
1123
+ const flat = couponTargetRulesFromTree(tree);
1124
+ if (!flat.rulesOnly) return describeCouponTargetRules(flat.targetRules, formatMoney);
1125
+ const include = tree.include.rules.length
1126
+ ? describeCouponGroup(tree.include, formatMoney)
1127
+ : "전 상품";
1128
+ if (tree.exclude.rules.length === 0) return include;
1129
+ return `${include} (${describeCouponGroup(tree.exclude, formatMoney)} 제외)`;
1130
+ }
1131
+
1005
1132
  /**
1006
1133
  * 스토어프론트 `requirements` — 최상위 `and`면 노드마다 한 줄(그룹은 문구 한 줄), 최상위 `or`면 트리 전체를 한 줄로.
1007
1134
  * `minOrderAmount`(옛 필드)와 같은 원천에서 나온다.
@@ -8,6 +8,7 @@ import {
8
8
  couponRulesFromLegacy,
9
9
  couponStackingSchema,
10
10
  couponTargetRulesSchema,
11
+ couponTargetTreeSchema,
11
12
  } from "./coupon-rules";
12
13
  import { currencyCodeSchema } from "./currency";
13
14
  import {
@@ -100,7 +101,12 @@ const descriptions = {
100
101
  "주면 `conditions`의 최소 주문 금액·첫 구매를 쓰지 않는다(`conditions`에는 `memberOnly`만 둔다). 필드 목록은 `COUPON_CONDITION_FIELDS`",
101
102
  targetRules:
102
103
  "할인 대상 규칙(이슈 #222) — `include`(하나라도 맞으면 대상, 비면 전 상품)·`exclude`(하나라도 맞으면 제외, 포함보다 우선). " +
103
- "주면 `target`과 `conditions.stackWithInstantDiscount`를 쓰지 않는다(즉시할인 제외는 `line.hasInstantDiscount` 규칙)",
104
+ "주면 `target`과 `conditions.stackWithInstantDiscount`를 쓰지 않는다(즉시할인 제외는 `line.hasInstantDiscount` 규칙). " +
105
+ "포함·제외를 「모두」나 묶음으로 걸려면 `targetTree`를 쓴다",
106
+ targetTree:
107
+ "할인 대상 트리(이슈 #263) — `include`·`exclude` 각각 사용 조건과 같은 그룹 `{ combinator: and|or, rules }`(3단·규칙 50개, 줄 필드만). " +
108
+ "포함 그룹이 참인 줄이 대상(비면 전 상품), 제외 그룹이 참인 줄은 빠진다(포함보다 우선, 비면 제외 없음). " +
109
+ "주면 `targetRules`·`target`·`conditions.stackWithInstantDiscount`를 함께 주지 않는다",
104
110
  audience:
105
111
  "발급 대상 트리(이슈 #222) — 셀러 지급·받기·회원가입 지급에서 누가 받을 수 있는가. 구매자 조건만. 코드 입력형에는 두지 않는다",
106
112
  stacking:
@@ -119,6 +125,7 @@ const descriptions = {
119
125
  const ruleFields = {
120
126
  eligibility: couponConditionTreeSchema.optional().describe(descriptions.eligibility),
121
127
  targetRules: couponTargetRulesSchema.optional().describe(descriptions.targetRules),
128
+ targetTree: couponTargetTreeSchema.optional().describe(descriptions.targetTree),
122
129
  audience: couponConditionTreeSchema.optional().describe(descriptions.audience),
123
130
  stacking: couponStackingSchema.optional().describe(descriptions.stacking),
124
131
  reward: couponRewardSchema.nullable().optional().describe(descriptions.reward),
@@ -145,6 +152,7 @@ export function couponRequestRuleIssues(body: {
145
152
  >;
146
153
  eligibility?: CouponRuleTrees["eligibility"];
147
154
  targetRules?: CouponRuleTrees["targetRules"];
155
+ targetTree?: CouponRuleTrees["targetTree"];
148
156
  audience?: CouponRuleTrees["eligibility"];
149
157
  stacking?: { perOrderLimit: number };
150
158
  reward?: { type: "TIERED" | "BUY_X_GET_Y" | "POINT" } | null;
@@ -171,6 +179,14 @@ export function couponRequestRuleIssues(body: {
171
179
  path: ["target"],
172
180
  message: "할인 대상 규칙을 주면 대상 상품·즉시할인 제외는 규칙으로 둡니다",
173
181
  });
182
+ if (
183
+ body.targetTree &&
184
+ (body.targetRules || body.target || body.conditions?.stackWithInstantDiscount !== undefined)
185
+ )
186
+ issues.push({
187
+ path: ["targetTree"],
188
+ message: "할인 대상 트리를 주면 할인 대상 규칙·대상 상품·즉시할인 제외는 트리로 둡니다",
189
+ });
174
190
  const legacy = couponRulesFromLegacy(conditions, {
175
191
  includeProductIds: body.target?.includeProductIds ?? [],
176
192
  includeCategoryIds: body.target?.includeCategoryIds ?? [],
@@ -184,6 +200,7 @@ export function couponRequestRuleIssues(body: {
184
200
  perMemberUseLimit: body.perMemberUseLimit ?? null,
185
201
  eligibility: body.eligibility ?? legacy.eligibility,
186
202
  targetRules: body.targetRules ?? legacy.targetRules,
203
+ ...(body.targetTree ? { targetTree: body.targetTree } : {}),
187
204
  ...(body.audience ? { audience: body.audience } : {}),
188
205
  }),
189
206
  );
@@ -445,7 +462,12 @@ export const couponSchema = z.object({
445
462
  targetRules: couponTargetRulesSchema
446
463
  .optional()
447
464
  .describe(
448
- "할인 대상 규칙(이슈 #222). 옛 쿠폰은 `target`에서 만든 규칙이다. 서버는 항상 싣는다",
465
+ "할인 대상 규칙(이슈 #222). 옛 쿠폰은 `target`에서 만든 규칙이다. `targetTree`가 「하나라도」 목록으로 나타나지 않으면 가장 가까운 값이고 `rulesOnly`가 true다. 서버는 항상 싣는다",
466
+ ),
467
+ targetTree: couponTargetTreeSchema
468
+ .optional()
469
+ .describe(
470
+ "할인 대상 트리(이슈 #263). 할인 대상의 원천이다. 옛 쿠폰은 `targetRules`에서 만든 트리(「하나 이상」 그룹)다. 서버는 항상 싣는다",
449
471
  ),
450
472
  audience: couponConditionTreeSchema
451
473
  .optional()
@@ -10,8 +10,8 @@ const limitOrContract = (description: string) =>
10
10
  z.number().int().nullable().describe(`${description}. null이면 계약(엔터프라이즈)·한도 없음`);
11
11
 
12
12
  /**
13
- * 요금제 기능 키 값 — 요금제 비교표·상점 플랫폼 요금제 화면이 쓴다. 키는 서버 판정의 기능 키와 같다(추가만 한다). 상위 요금제는 하위 요금제
14
- * 기능을 모두 포함한다
13
+ * 요금제 기능 키 값 — 요금제 비교표·상점 플랫폼 요금제 화면이 쓴다. 키는 서버 판정의 기능 키와 같다(추가만 한다 — 예외는 0.53에서 지운
14
+ * `limits.ai_credits_monthly`). 상위 요금제는 하위 요금제 기능을 모두 포함한다
15
15
  */
16
16
  export const planEntitlementsSchema = z
17
17
  .object({
@@ -24,6 +24,10 @@ export const planEntitlementsSchema = z
24
24
  "관리 API(개인 액세스 토큰·외부 앱 토큰 호출). 상점 플랫폼·CLI·MCP는 모든 요금제에서 쓴다",
25
25
  ),
26
26
  "features.analytics": z.boolean().describe("애널리틱스 화면·조회 API"),
27
+ "features.ai_assistant": z
28
+ .boolean()
29
+ .optional()
30
+ .describe("AI 상점 도우미. false면 새 실행을 시작할 수 없다(403 `PLAN_FEATURE_REQUIRED`)"),
27
31
  "limits.ai_reads_monthly": limitOrContract("MCP 읽기 월 한도"),
28
32
  "limits.ai_actions_monthly": limitOrContract(
29
33
  "MCP 쓰기(AI 작업) 월 한도 — 주문 처리는 막지 않는다",
@@ -34,9 +38,6 @@ export const planEntitlementsSchema = z
34
38
  "limits.private_apps": limitOrContract(
35
39
  "비공개 앱(이 상점 전용 앱) 설치 수(0이면 설치할 수 없다)",
36
40
  ),
37
- "limits.ai_credits_monthly": limitOrContract(
38
- "AI 상점 도우미 월 크레딧(1 크레딧 = 모델 원가 $0.01, 이월 없음)",
39
- ),
40
41
  "limits.storefront_preview_hours_monthly": limitOrContract("편집 환경 시간(월, 시간)"),
41
42
  "limits.storefront_build_minutes_monthly": limitOrContract("빌드 시간(월, 분)"),
42
43
  "limits.storefront_requests_monthly": limitOrContract(
@@ -7,11 +7,15 @@ import {
7
7
  type CouponRuleTrees,
8
8
  type CouponStacking,
9
9
  type CouponTargetRules,
10
+ type CouponTargetTree,
10
11
  couponLineContextOf,
11
- couponLineMatches,
12
12
  couponRulesFromLegacy,
13
13
  couponStackingSchema,
14
+ couponTargetRulesFromTree,
14
15
  couponTargetRulesSchema,
16
+ couponTargetTreeFromRules,
17
+ couponTargetTreeMatches,
18
+ couponTargetTreeSchema,
15
19
  couponTimeFactsAt,
16
20
  evaluateCouponEligibility,
17
21
  pickCouponFacts,
@@ -307,6 +311,11 @@ export const couponRuleSchema = z.object({
307
311
  .describe(
308
312
  "할인 대상 규칙(이슈 #222). 없으면 `target`·`conditions.stackWithInstantDiscount`에서 만든다",
309
313
  ),
314
+ targetTree: couponTargetTreeSchema
315
+ .optional()
316
+ .describe(
317
+ "할인 대상 트리(이슈 #263). 없으면 `targetRules`(없으면 옛 칸)에서 만든다. 있으면 이것이 원천이고 `targetRules`는 가장 가까운 옛 값이다",
318
+ ),
310
319
  stacking: couponStackingSchema
311
320
  .optional()
312
321
  .describe("중복 사용 정책(이슈 #222). 없으면 종류별 한 장·서로 결합·적립금과 함께"),
@@ -314,17 +323,22 @@ export const couponRuleSchema = z.object({
314
323
  });
315
324
  export type CouponRule = z.infer<typeof couponRuleSchema>;
316
325
 
317
- /** 규칙의 트리 — 트리 칸이 없으면 옛 칸에서 만든다(계산은 늘 이 한 경로) */
326
+ /** 규칙의 트리 — 트리 칸이 없으면 옛 칸에서 만든다(계산은 늘 이 한 경로). 할인 대상은 늘 트리(`targetTree`)까지 채운다 */
318
327
  export function couponTreesOf(
319
328
  rule: Pick<CouponRule, "conditions" | "target"> & {
320
329
  eligibility?: ConditionGroup;
321
330
  targetRules?: CouponTargetRules;
331
+ targetTree?: CouponTargetTree;
322
332
  },
323
- ): CouponRuleTrees {
333
+ ): CouponRuleTrees & { targetTree: CouponTargetTree } {
324
334
  const legacy = couponRulesFromLegacy(rule.conditions, rule.target);
335
+ const targetRules = rule.targetTree
336
+ ? couponTargetRulesFromTree(rule.targetTree).targetRules
337
+ : (rule.targetRules ?? legacy.targetRules);
325
338
  return {
326
339
  eligibility: rule.eligibility ?? legacy.eligibility,
327
- targetRules: rule.targetRules ?? legacy.targetRules,
340
+ targetRules,
341
+ targetTree: rule.targetTree ?? couponTargetTreeFromRules(targetRules),
328
342
  };
329
343
  }
330
344
 
@@ -609,17 +623,20 @@ export function allocateByWeight(total: number, weights: readonly number[]): num
609
623
 
610
624
  /** 카테고리 경로(`/a/b/c/`)가 그 카테고리 또는 하위를 가리키는가 */
611
625
  /**
612
- * 줄이 쿠폰 대상인가 — 제외가 포함보다 우선하고, 포함이 비면 전 상품이다. 트리(`targetRules`)가 없으면 옛 칸에서 만든 트리로 본다.
626
+ * 줄이 쿠폰 대상인가 — 제외가 포함보다 우선하고, 포함이 비면 전 상품이다. 트리(`targetTree`, 없으면 `targetRules`)가 없으면 옛 칸에서 만든 트리로 본다.
613
627
  * `quantity`를 생략하면 1로 본다(단가 조건은 금액 ÷ 수량).
614
628
  */
615
629
  export function isCouponTargetLine(
616
- rule: Pick<CouponRule, "target" | "conditions"> & { targetRules?: CouponTargetRules },
630
+ rule: Pick<CouponRule, "target" | "conditions"> & {
631
+ targetRules?: CouponTargetRules;
632
+ targetTree?: CouponTargetTree;
633
+ },
617
634
  line: Pick<PricingLine, "productId" | "categoryPath" | "baseAmount" | "amount"> & {
618
635
  quantity?: number;
619
636
  },
620
637
  ): boolean {
621
- return couponLineMatches(
622
- couponTreesOf(rule).targetRules,
638
+ return couponTargetTreeMatches(
639
+ couponTreesOf(rule).targetTree,
623
640
  couponLineContextOf({ ...line, quantity: line.quantity ?? 1 }),
624
641
  );
625
642
  }
@@ -719,7 +736,7 @@ export function quotePricing(input: PricingInput): PricingResult {
719
736
  const eligible = lines.filter((entry) =>
720
737
  pinned
721
738
  ? pinned.has(entry.line.lineId)
722
- : couponLineMatches(trees.targetRules, couponLineContextOf(entry.line)),
739
+ : couponTargetTreeMatches(trees.targetTree, couponLineContextOf(entry.line)),
723
740
  );
724
741
  result.eligibleLineIds = eligible.map((entry) => entry.line.lineId);
725
742
  if (eligible.length === 0) return "NO_ELIGIBLE_ITEMS";
@@ -49,7 +49,6 @@ export const subscriptionLimitsSchema = z.object({
49
49
  adminApiRequestsMonthly: limit("관리 API 요청 월 포함량(넘으면 속도만 낮춘다, 주문 처리 제외)"),
50
50
  publicApps: limit("공개 앱(앱 스토어에 게시된 앱) 설치 수").optional(),
51
51
  privateApps: limit("비공개 앱(이 상점 전용 앱) 설치 수(0이면 설치할 수 없다)").optional(),
52
- aiCreditsMonthly: limit("AI 상점 도우미 월 크레딧").optional(),
53
52
  });
54
53
  export type SubscriptionLimits = z.infer<typeof subscriptionLimitsSchema>;
55
54
 
@@ -98,6 +97,10 @@ export const storeSubscriptionSchema = z.object({
98
97
  .boolean()
99
98
  .optional()
100
99
  .describe("소유자 외 구성원이 상점에 접근할 수 있다(false면 소유자만, 구성원 목록은 남는다)"),
100
+ aiAssistant: z
101
+ .boolean()
102
+ .optional()
103
+ .describe("AI 상점 도우미를 쓸 수 있다(false면 새 실행을 시작할 수 없다)"),
101
104
  storeClone: z.boolean().describe("더 쓰지 않는다 — 상점 복제 기능을 없애 항상 false"),
102
105
  removeBadge: z.boolean().describe("더 쓰지 않는다 — 배지 키를 없애 항상 false"),
103
106
  }),
@@ -116,31 +119,6 @@ export const storeSubscriptionSchema = z.object({
116
119
  resetsAt: z.string().describe("한도가 초기화되는 시각(ISO 8601)"),
117
120
  })
118
121
  .describe("MCP 읽기 한도 요약"),
119
- aiCredits: z
120
- .object({
121
- monthly: z.number().int().nullable().describe("요금제 월 크레딧. null이면 한도 없음"),
122
- granted: z.number().int().describe("이번 기간에 쓸 수 있는 추가 지급 크레딧"),
123
- used: z.number().int().describe("이번 기간 사용한 크레딧"),
124
- monthlyUsed: z.number().int().optional().describe("이번 기간 월 크레딧에서 쓴 양"),
125
- purchased: z
126
- .number()
127
- .int()
128
- .optional()
129
- .describe("남은 구매 크레딧 — 구매일부터 1년, 월 크레딧이 초기화돼도 남는다"),
130
- complimentary: z.number().int().optional().describe("남은 무상 지급 크레딧(이벤트·보상)"),
131
- remaining: z
132
- .number()
133
- .int()
134
- .nullable()
135
- .describe("남은 크레딧(월 + 구매 + 무상). null이면 한도 없음"),
136
- resetsAt: z
137
- .string()
138
- .describe("월 크레딧이 초기화되는 시각(ISO 8601). 월 크레딧은 이월하지 않는다"),
139
- })
140
- .optional()
141
- .describe(
142
- "AI 상점 도우미 크레딧 — 1 크레딧은 모델 원가 $0.01. 남은 크레딧이 없으면 새 실행을 만들 수 없다",
143
- ),
144
122
  salesFeePercent: z.literal(0).describe("판매 수수료(%). 모든 요금제 0이다"),
145
123
  });
146
124
  export type StoreSubscription = z.infer<typeof storeSubscriptionSchema>;