@sayren/store-sdk 0.47.0 → 0.49.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.
@@ -0,0 +1,849 @@
1
+ import * as z from "zod";
2
+ import {
3
+ anyConditionHolds,
4
+ type ConditionCatalog,
5
+ type ConditionFieldDef,
6
+ type ConditionGroup,
7
+ type ConditionIssue,
8
+ type ConditionNode,
9
+ type ConditionRule,
10
+ type ConditionValue,
11
+ type ConditionValueType,
12
+ canonicalizeConditionGroup,
13
+ canonicalizeConditionRule,
14
+ conditionCatalog,
15
+ conditionFields,
16
+ conditionGroupSchema,
17
+ conditionIssues,
18
+ conditionRuleSchema,
19
+ evaluateCondition,
20
+ isConditionGroup,
21
+ } from "./condition-tree";
22
+
23
+ /**
24
+ * 쿠폰 조건 규칙 (이슈 #222) — 범용 조건 트리(`condition-tree.ts`)를 쿠폰에 쓰는 자리다.
25
+ *
26
+ * 쿠폰은 세 트리를 갖는다.
27
+ * - `eligibility`(사용 조건): 구매자·주문서·시각. 그룹 중첩 3단까지
28
+ * - `targetRules`(할인 대상): 줄 단위. `{ include, exclude }` 고정 형태 — 포함은 하나라도 맞으면 대상(비면 전 상품), 제외는 하나라도 맞으면 빠지고 포함보다 우선
29
+ * - `audience`(발급 대상): 누가 받을 수 있는가. 구매자 사실 값만
30
+ *
31
+ * 기간·한도·혜택·`memberOnly`·중복 정책은 트리 밖 칸이다. 옛 칸(`conditions`·`target`)은 프리셋 변환(`couponRulesFromLegacy`)으로 같은 트리가 된다 —
32
+ * 계산은 늘 트리 한 경로다.
33
+ */
34
+
35
+ // ─── 사실 값 ──────────────────────────────────────────
36
+
37
+ /** 요일 — 판정 시각의 Asia/Seoul 달력 요일 */
38
+ export const COUPON_WEEKDAYS = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"] as const;
39
+ export type CouponWeekday = (typeof COUPON_WEEKDAYS)[number];
40
+
41
+ /** 쿠폰 조건의 시각 기준 — 상점 시간대 칸이 없어 flow-spec 달력과 같은 Asia/Seoul로 고정한다 */
42
+ export const COUPON_TIME_ZONE = "Asia/Seoul";
43
+
44
+ /**
45
+ * 구매자 사실 값 — 평가 시점에 서버가 읽어 넣는다. 결제 시작에서 쓴 값은 주문 스냅샷에 굳혀 환불 정산이 그대로 다시 쓴다.
46
+ * 모르는 값은 생략한다(그 값을 쓰는 규칙은 거짓).
47
+ */
48
+ export interface CouponCustomerFacts {
49
+ /** 가입 뒤 지난 날 수(내림) */
50
+ daysSinceJoined?: number;
51
+ marketingAgreed?: boolean;
52
+ /** 이 상점의 결제 완료 주문 수(취소 포함) */
53
+ paidOrderCount?: number;
54
+ /** 마지막 주문 뒤 지난 날 수(내림). 주문이 없으면 생략 */
55
+ daysSinceLastOrder?: number;
56
+ }
57
+
58
+ export interface CouponTimeFacts {
59
+ weekday: CouponWeekday;
60
+ /** `HH:MM` */
61
+ timeOfDay: string;
62
+ }
63
+
64
+ export interface CouponFacts {
65
+ customer?: CouponCustomerFacts;
66
+ time?: CouponTimeFacts;
67
+ }
68
+
69
+ export const couponFactsSchema = z.object({
70
+ customer: z
71
+ .object({
72
+ daysSinceJoined: z.number().int().optional(),
73
+ marketingAgreed: z.boolean().optional(),
74
+ paidOrderCount: z.number().int().optional(),
75
+ daysSinceLastOrder: z.number().int().optional(),
76
+ })
77
+ .optional(),
78
+ time: z.object({ weekday: z.enum(COUPON_WEEKDAYS), timeOfDay: z.string() }).optional(),
79
+ });
80
+
81
+ const SEOUL = new Intl.DateTimeFormat("en-US", {
82
+ timeZone: COUPON_TIME_ZONE,
83
+ weekday: "short",
84
+ hour: "2-digit",
85
+ minute: "2-digit",
86
+ hourCycle: "h23",
87
+ });
88
+ const WEEKDAY_OF: Record<string, CouponWeekday> = {
89
+ Mon: "MON",
90
+ Tue: "TUE",
91
+ Wed: "WED",
92
+ Thu: "THU",
93
+ Fri: "FRI",
94
+ Sat: "SAT",
95
+ Sun: "SUN",
96
+ };
97
+
98
+ /** 판정 시각의 요일·시각(Asia/Seoul) */
99
+ export function couponTimeFactsAt(now: Date): CouponTimeFacts {
100
+ const parts = Object.fromEntries(SEOUL.formatToParts(now).map((p) => [p.type, p.value]));
101
+ return {
102
+ weekday: WEEKDAY_OF[parts.weekday ?? ""] ?? "MON",
103
+ timeOfDay: `${parts.hour ?? "00"}:${parts.minute ?? "00"}`,
104
+ };
105
+ }
106
+
107
+ /** 두 시각 사이의 지난 날 수(내림). 읽지 못하면 undefined */
108
+ export function daysBetween(from: string | Date | null | undefined, to: Date): number | undefined {
109
+ if (!from) return undefined;
110
+ const start = typeof from === "string" ? Date.parse(from) : from.getTime();
111
+ if (Number.isNaN(start)) return undefined;
112
+ return Math.max(0, Math.floor((to.getTime() - start) / 86_400_000));
113
+ }
114
+
115
+ // ─── 평가 컨텍스트 ────────────────────────────────────
116
+
117
+ /** 주문서 집계 — 즉시할인 반영, 쿠폰·적립금 전, 배송비 제외 */
118
+ export interface CouponCartFacts {
119
+ /** 주문서 전체 줄 금액 합 */
120
+ subtotal: number;
121
+ /** 할인 대상 줄 금액 합(지금의 최소 주문 금액 기준) */
122
+ targetSubtotal: number;
123
+ /** 할인 대상 줄 수량 합 */
124
+ targetQuantity: number;
125
+ }
126
+
127
+ export interface CouponOrderContext {
128
+ facts: CouponFacts;
129
+ /** 발급 시점처럼 주문서가 없으면 생략 — 주문서 필드는 거짓이 된다 */
130
+ cart?: CouponCartFacts;
131
+ }
132
+
133
+ export interface CouponLineContext {
134
+ productId: string;
135
+ /** 상품 카테고리와 그 조상 id — 하위 카테고리 상품도 상위 카테고리 대상에 걸린다 */
136
+ categoryIds: readonly string[];
137
+ hasInstantDiscount: boolean;
138
+ /** 즉시할인 반영 단가 */
139
+ unitPrice: number;
140
+ }
141
+
142
+ /** 줄 금액에서 줄 컨텍스트를 만든다 — `categoryPath`는 `/조상/…/자신/` */
143
+ export function couponLineContextOf(line: {
144
+ productId: string;
145
+ categoryPath?: string | null;
146
+ quantity: number;
147
+ baseAmount: number;
148
+ amount: number;
149
+ }): CouponLineContext {
150
+ return {
151
+ productId: line.productId,
152
+ categoryIds: line.categoryPath ? line.categoryPath.split("/").filter(Boolean) : [],
153
+ hasInstantDiscount: line.amount < line.baseAmount,
154
+ unitPrice: line.quantity > 0 ? Math.floor(line.amount / line.quantity) : line.amount,
155
+ };
156
+ }
157
+
158
+ // ─── 필드 카탈로그 ────────────────────────────────────
159
+
160
+ /** 트리 자리 */
161
+ export type CouponConditionSlot = "audience" | "eligibility" | "target";
162
+
163
+ /** 화면·문서용 필드 목록(직렬화 가능) — 상점 플랫폼 빌더가 이 목록으로 필드·연산자·값 편집기를 고른다 */
164
+ export interface CouponConditionFieldInfo {
165
+ name: string;
166
+ type: ConditionValueType;
167
+ slots: readonly CouponConditionSlot[];
168
+ /** 값의 단위 — `money`면 쿠폰 통화 최소 단위 */
169
+ unit?: "day" | "count" | "money" | "quantity";
170
+ options?: readonly string[];
171
+ }
172
+
173
+ const MONEY_MAX = 1_000_000_000_000;
174
+ const DAY_MAX = 36_500;
175
+ const COUNT_MAX = 1_000_000;
176
+
177
+ const ORDER_FIELDS: readonly (ConditionFieldDef<CouponOrderContext> &
178
+ Omit<CouponConditionFieldInfo, "type">)[] = [
179
+ {
180
+ name: "customer.daysSinceJoined",
181
+ type: "integer",
182
+ slots: ["audience", "eligibility"],
183
+ unit: "day",
184
+ min: 0,
185
+ max: DAY_MAX,
186
+ resolve: (ctx) => ctx.facts.customer?.daysSinceJoined,
187
+ },
188
+ {
189
+ name: "customer.marketingAgreed",
190
+ type: "boolean",
191
+ slots: ["audience", "eligibility"],
192
+ resolve: (ctx) => ctx.facts.customer?.marketingAgreed,
193
+ },
194
+ {
195
+ name: "customer.paidOrderCount",
196
+ type: "integer",
197
+ slots: ["audience", "eligibility"],
198
+ unit: "count",
199
+ min: 0,
200
+ max: COUNT_MAX,
201
+ resolve: (ctx) => ctx.facts.customer?.paidOrderCount,
202
+ },
203
+ {
204
+ name: "customer.daysSinceLastOrder",
205
+ type: "integer",
206
+ slots: ["audience", "eligibility"],
207
+ unit: "day",
208
+ min: 0,
209
+ max: DAY_MAX,
210
+ resolve: (ctx) => ctx.facts.customer?.daysSinceLastOrder,
211
+ },
212
+ {
213
+ name: "cart.subtotal",
214
+ type: "integer",
215
+ slots: ["eligibility"],
216
+ unit: "money",
217
+ operators: [">=", "<=", "between"],
218
+ min: 0,
219
+ max: MONEY_MAX,
220
+ resolve: (ctx) => ctx.cart?.subtotal,
221
+ },
222
+ {
223
+ name: "cart.targetSubtotal",
224
+ type: "integer",
225
+ slots: ["eligibility"],
226
+ unit: "money",
227
+ operators: [">=", "<=", "between"],
228
+ min: 0,
229
+ max: MONEY_MAX,
230
+ resolve: (ctx) => ctx.cart?.targetSubtotal,
231
+ },
232
+ {
233
+ name: "cart.targetQuantity",
234
+ type: "integer",
235
+ slots: ["eligibility"],
236
+ unit: "quantity",
237
+ operators: [">=", "<=", "between"],
238
+ min: 0,
239
+ max: COUNT_MAX,
240
+ resolve: (ctx) => ctx.cart?.targetQuantity,
241
+ },
242
+ {
243
+ name: "time.weekday",
244
+ type: "enumSet",
245
+ slots: ["eligibility"],
246
+ options: COUPON_WEEKDAYS,
247
+ resolve: (ctx) => ctx.facts.time?.weekday,
248
+ },
249
+ {
250
+ name: "time.timeOfDay",
251
+ type: "timeRange",
252
+ slots: ["eligibility"],
253
+ resolve: (ctx) => ctx.facts.time?.timeOfDay,
254
+ },
255
+ ];
256
+
257
+ const LINE_FIELDS: readonly (ConditionFieldDef<CouponLineContext> &
258
+ Omit<CouponConditionFieldInfo, "type">)[] = [
259
+ { name: "line.productId", type: "idSet", slots: ["target"], resolve: (ctx) => ctx.productId },
260
+ {
261
+ name: "line.categoryId",
262
+ type: "idSet",
263
+ slots: ["target"],
264
+ resolve: (ctx) => ctx.categoryIds,
265
+ },
266
+ {
267
+ name: "line.hasInstantDiscount",
268
+ type: "boolean",
269
+ slots: ["target"],
270
+ resolve: (ctx) => ctx.hasInstantDiscount,
271
+ },
272
+ {
273
+ name: "line.unitPrice",
274
+ type: "integer",
275
+ slots: ["target"],
276
+ unit: "money",
277
+ operators: [">=", "<=", "between"],
278
+ min: 0,
279
+ max: MONEY_MAX,
280
+ resolve: (ctx) => ctx.unitPrice,
281
+ },
282
+ ];
283
+
284
+ export const COUPON_ORDER_CONDITIONS: ConditionCatalog<CouponOrderContext> =
285
+ conditionCatalog(ORDER_FIELDS);
286
+ export const COUPON_LINE_CONDITIONS: ConditionCatalog<CouponLineContext> =
287
+ conditionCatalog(LINE_FIELDS);
288
+
289
+ export const COUPON_CONDITION_FIELDS: readonly CouponConditionFieldInfo[] = [
290
+ ...ORDER_FIELDS,
291
+ ...LINE_FIELDS,
292
+ ].map(({ name, type, slots, unit, options }) => ({
293
+ name,
294
+ type,
295
+ slots,
296
+ ...(unit ? { unit } : {}),
297
+ ...(options ? { options } : {}),
298
+ }));
299
+
300
+ const FIELD_INFO = new Map(COUPON_CONDITION_FIELDS.map((f) => [f.name, f]));
301
+
302
+ // ─── 규칙 형식 ────────────────────────────────────────
303
+
304
+ /** 할인 대상 — 포함·제외 규칙 목록(그룹 없음) */
305
+ export const couponTargetRulesSchema = z.strictObject({
306
+ include: z
307
+ .array(conditionRuleSchema)
308
+ .max(50)
309
+ .describe("포함 규칙 — 하나라도 맞으면 대상. 비면 전 상품"),
310
+ exclude: z
311
+ .array(conditionRuleSchema)
312
+ .max(50)
313
+ .describe("제외 규칙 — 하나라도 맞으면 대상에서 빠진다. 포함보다 우선한다"),
314
+ });
315
+ export type CouponTargetRules = z.infer<typeof couponTargetRulesSchema>;
316
+
317
+ export const COUPON_TARGET_RULES_ALL: CouponTargetRules = { include: [], exclude: [] };
318
+
319
+ export const EMPTY_CONDITION_GROUP: ConditionGroup = { combinator: "and", rules: [] };
320
+
321
+ /** 사용 조건·발급 대상 트리 */
322
+ export const couponConditionTreeSchema = conditionGroupSchema;
323
+
324
+ /** 중복 사용 정책 — 결합은 양쪽 쿠폰이 모두 허용해야 성립한다 */
325
+ export const couponStackingSchema = z.strictObject({
326
+ combinesWith: z
327
+ .strictObject({
328
+ order: z.boolean().describe("주문 쿠폰과 함께 쓸 수 있다"),
329
+ product: z.boolean().describe("상품 쿠폰과 함께 쓸 수 있다(다른 상품 쿠폰 포함)"),
330
+ delivery: z.boolean().describe("배송비 쿠폰과 함께 쓸 수 있다"),
331
+ })
332
+ .describe("다른 종류 쿠폰과의 결합. 상대 쿠폰도 이 쿠폰의 종류를 허용해야 함께 적용된다"),
333
+ withPoints: z
334
+ .boolean()
335
+ .describe("적립금과 함께 쓸 수 있다. false인 쿠폰이 적용되면 적립금을 쓰지 않는다"),
336
+ perOrderLimit: z
337
+ .number()
338
+ .int()
339
+ .min(1)
340
+ .max(100)
341
+ .describe("한 주문에 쓸 수 있는 이 쿠폰의 장수. 2 이상은 상품 쿠폰만(발급 사본마다 다른 줄)"),
342
+ });
343
+ export type CouponStacking = z.infer<typeof couponStackingSchema>;
344
+
345
+ /** 지금까지의 동작 — 종류별 한 장, 서로 결합, 적립금과 함께 */
346
+ export const COUPON_STACKING_DEFAULTS: CouponStacking = {
347
+ combinesWith: { order: true, product: true, delivery: true },
348
+ withPoints: true,
349
+ perOrderLimit: 1,
350
+ };
351
+
352
+ export function canonicalizeCouponTargetRules(rules: CouponTargetRules): CouponTargetRules {
353
+ return {
354
+ include: rules.include.map(canonicalizeConditionRule),
355
+ exclude: rules.exclude.map(canonicalizeConditionRule),
356
+ };
357
+ }
358
+
359
+ export function canonicalizeCouponStacking(stacking: CouponStacking): CouponStacking {
360
+ return {
361
+ combinesWith: {
362
+ order: stacking.combinesWith.order,
363
+ product: stacking.combinesWith.product,
364
+ delivery: stacking.combinesWith.delivery,
365
+ },
366
+ withPoints: stacking.withPoints,
367
+ perOrderLimit: stacking.perOrderLimit,
368
+ };
369
+ }
370
+
371
+ // ─── 프리셋 변환(옛 칸 ⇄ 트리) ────────────────────────
372
+
373
+ /** 옛 조건 칸 — `promotion.ts`의 `CouponConditions`와 같은 모양(순환 import를 피해 여기 둔다) */
374
+ export interface LegacyCouponConditions {
375
+ minOrderAmount: number;
376
+ firstOrderOnly: boolean;
377
+ memberOnly: boolean;
378
+ stackWithInstantDiscount: boolean;
379
+ }
380
+
381
+ export interface LegacyCouponTarget {
382
+ includeProductIds: string[];
383
+ includeCategoryIds: string[];
384
+ excludeProductIds: string[];
385
+ excludeCategoryIds: string[];
386
+ }
387
+
388
+ export interface CouponRuleTrees {
389
+ eligibility: ConditionGroup;
390
+ targetRules: CouponTargetRules;
391
+ }
392
+
393
+ const inRule = (field: string, ids: readonly string[]): ConditionRule[] =>
394
+ ids.length ? [{ field, operator: "in", value: [...new Set(ids)].sort() }] : [];
395
+
396
+ /**
397
+ * 옛 칸을 트리로 — 계산이 트리 한 경로를 지나게 하는 변환이다. 뜻이 같다:
398
+ * 최소 주문 금액 = 대상 상품 금액 이상, 첫 구매 = 결제 완료 주문 수 0, 즉시할인 중복 불가 = 즉시할인 줄 제외,
399
+ * 포함 목록이 모두 비면 전 상품, 제외가 포함보다 우선. `memberOnly`는 트리 밖이다.
400
+ */
401
+ export function couponRulesFromLegacy(
402
+ conditions: LegacyCouponConditions,
403
+ target: LegacyCouponTarget,
404
+ ): CouponRuleTrees {
405
+ const eligibility: ConditionRule[] = [];
406
+ if (conditions.firstOrderOnly)
407
+ eligibility.push({ field: "customer.paidOrderCount", operator: "=", value: 0 });
408
+ if (conditions.minOrderAmount > 0)
409
+ eligibility.push({
410
+ field: "cart.targetSubtotal",
411
+ operator: ">=",
412
+ value: conditions.minOrderAmount,
413
+ });
414
+ return {
415
+ eligibility: { combinator: "and", rules: eligibility },
416
+ targetRules: {
417
+ include: [
418
+ ...inRule("line.productId", target.includeProductIds),
419
+ ...inRule("line.categoryId", target.includeCategoryIds),
420
+ ],
421
+ exclude: [
422
+ ...inRule("line.productId", target.excludeProductIds),
423
+ ...inRule("line.categoryId", target.excludeCategoryIds),
424
+ ...(conditions.stackWithInstantDiscount
425
+ ? []
426
+ : [{ field: "line.hasInstantDiscount", operator: "=", value: true }]),
427
+ ],
428
+ },
429
+ };
430
+ }
431
+
432
+ const isMinOrderRule = (node: ConditionNode): boolean =>
433
+ !isConditionGroup(node) &&
434
+ node.field === "cart.targetSubtotal" &&
435
+ node.operator === ">=" &&
436
+ typeof node.value === "number";
437
+
438
+ const isFirstOrderRule = (node: ConditionNode): boolean =>
439
+ !isConditionGroup(node) &&
440
+ node.field === "customer.paidOrderCount" &&
441
+ node.operator === "=" &&
442
+ node.value === 0;
443
+
444
+ const isInstantExcludeRule = (rule: ConditionRule) =>
445
+ rule.field === "line.hasInstantDiscount" && rule.operator === "=" && rule.value === true;
446
+
447
+ const isIdRule = (rule: ConditionRule) =>
448
+ (rule.field === "line.productId" || rule.field === "line.categoryId") && rule.operator === "in";
449
+
450
+ /**
451
+ * 트리를 옛 칸으로 — 옛 응답 필드(`conditions`·`target`)를 채우고 롤링 중 옛 코드가 읽게 한다.
452
+ * 옛 칸으로 표현되지 않는 규칙이 하나라도 있으면 `rulesOnly: true`다(옛 칸은 가장 가까운 값).
453
+ */
454
+ export function legacyFromCouponRules(
455
+ trees: CouponRuleTrees,
456
+ memberOnly: boolean,
457
+ ): { conditions: LegacyCouponConditions; target: LegacyCouponTarget; rulesOnly: boolean } {
458
+ const top = trees.eligibility.combinator === "and" || trees.eligibility.rules.length <= 1;
459
+ const nodes = top ? trees.eligibility.rules : [];
460
+ const minRule = nodes.find(isMinOrderRule) as ConditionRule | undefined;
461
+ const firstRule = nodes.find(isFirstOrderRule) as ConditionRule | undefined;
462
+ const ids = (rules: readonly ConditionRule[], field: string) =>
463
+ [
464
+ ...new Set(
465
+ rules
466
+ .filter((r) => r.field === field && r.operator === "in" && Array.isArray(r.value))
467
+ .flatMap((r) => r.value as string[]),
468
+ ),
469
+ ].sort();
470
+ const { include, exclude } = trees.targetRules;
471
+ const mappedEligibility = nodes.filter((n) => n === minRule || n === firstRule).length;
472
+ const rulesOnly =
473
+ mappedEligibility !== trees.eligibility.rules.length ||
474
+ nodes.filter(isMinOrderRule).length > 1 ||
475
+ include.some((r) => !isIdRule(r)) ||
476
+ exclude.some((r) => !isIdRule(r) && !isInstantExcludeRule(r));
477
+ return {
478
+ conditions: {
479
+ minOrderAmount: minRule ? (minRule.value as number) : 0,
480
+ firstOrderOnly: Boolean(firstRule),
481
+ memberOnly,
482
+ stackWithInstantDiscount: !exclude.some(isInstantExcludeRule),
483
+ },
484
+ target: {
485
+ includeProductIds: ids(include, "line.productId"),
486
+ includeCategoryIds: ids(include, "line.categoryId"),
487
+ excludeProductIds: ids(exclude, "line.productId"),
488
+ excludeCategoryIds: ids(exclude, "line.categoryId"),
489
+ },
490
+ rulesOnly,
491
+ };
492
+ }
493
+
494
+ // ─── 평가(쿠폰용) ─────────────────────────────────────
495
+
496
+ /** 줄이 대상인가 — 제외가 포함보다 우선, 포함이 비면 전 상품 */
497
+ export function couponLineMatches(rules: CouponTargetRules, line: CouponLineContext): boolean {
498
+ if (anyConditionHolds(rules.exclude, COUPON_LINE_CONDITIONS, line)) return false;
499
+ if (rules.include.length === 0) return true;
500
+ return anyConditionHolds(rules.include, COUPON_LINE_CONDITIONS, line);
501
+ }
502
+
503
+ /** 사용 조건 실패 사유 — 옛 칸으로 옮겨지는 실패는 옛 코드를 그대로 쓴다 */
504
+ export type CouponConditionRejectReason =
505
+ | "FIRST_ORDER_ONLY"
506
+ | "MIN_ORDER_AMOUNT"
507
+ | "CONDITION_NOT_MET";
508
+
509
+ const usesCart = (node: ConditionNode) =>
510
+ [...(isConditionGroup(node) ? conditionFields(node) : [node.field])].some((f) =>
511
+ f.startsWith("cart."),
512
+ );
513
+
514
+ /**
515
+ * 사용 조건 평가 — 두 단계로 부른다.
516
+ * - `beforeLines`: 대상 줄을 가리기 전 — 최상위 `and`의 주문서 집계를 쓰지 않는 규칙만 본다(옛 판정 순서: 첫 구매가 대상 상품 없음보다 먼저)
517
+ * - `afterLines`: 주문서 집계를 넣어 나머지 — 최상위 `and`면 주문서 집계 규칙만(앞 단계가 나머지를 통과시켰다), `or`면 트리 전체
518
+ */
519
+ export function evaluateCouponEligibility(
520
+ eligibility: ConditionGroup,
521
+ ctx: CouponOrderContext,
522
+ phase: "beforeLines" | "afterLines",
523
+ ): CouponConditionRejectReason | null {
524
+ const topAnd = eligibility.combinator === "and" || eligibility.rules.length <= 1;
525
+ if (topAnd) {
526
+ const part: ConditionGroup = {
527
+ combinator: "and",
528
+ rules: eligibility.rules.filter((node) =>
529
+ phase === "beforeLines" ? !usesCart(node) : usesCart(node),
530
+ ),
531
+ };
532
+ return reasonOf(evaluateCondition(part, COUPON_ORDER_CONDITIONS, ctx).failed, true);
533
+ }
534
+ if (phase === "beforeLines") return null;
535
+ return evaluateCondition(eligibility, COUPON_ORDER_CONDITIONS, ctx).passed
536
+ ? null
537
+ : "CONDITION_NOT_MET";
538
+ }
539
+
540
+ /**
541
+ * 사실 값이 없는 구매자·시각 규칙을 참으로 바꾼 트리 — 환불 정산 전용이다. 결제 시작에서 그 규칙을 통과했으므로(스냅샷이 없는 옛 주문 포함)
542
+ * 주문서 집계 규칙만 남은 금액으로 다시 판정하게 한다. 참은 빈 `and` 그룹이다
543
+ */
544
+ export function assumeCouponFactRules(group: ConditionGroup, facts: CouponFacts): ConditionGroup {
545
+ const known = (field: string) => {
546
+ if (field.startsWith("customer."))
547
+ return (
548
+ facts.customer?.[field.slice("customer.".length) as keyof CouponCustomerFacts] !== undefined
549
+ );
550
+ if (field.startsWith("time.")) return facts.time !== undefined;
551
+ return true;
552
+ };
553
+ return {
554
+ combinator: group.combinator,
555
+ rules: group.rules.map((node) =>
556
+ isConditionGroup(node)
557
+ ? assumeCouponFactRules(node, facts)
558
+ : known(node.field)
559
+ ? node
560
+ : EMPTY_CONDITION_GROUP,
561
+ ),
562
+ };
563
+ }
564
+
565
+ function reasonOf(
566
+ failed: readonly ConditionNode[],
567
+ topAnd: boolean,
568
+ ): CouponConditionRejectReason | null {
569
+ const first = failed[0];
570
+ if (!first) return null;
571
+ if (!topAnd) return "CONDITION_NOT_MET";
572
+ if (isFirstOrderRule(first)) return "FIRST_ORDER_ONLY";
573
+ if (isMinOrderRule(first)) return "MIN_ORDER_AMOUNT";
574
+ return "CONDITION_NOT_MET";
575
+ }
576
+
577
+ /** 발급 대상 평가 — 구매자 사실 값만 본다. 빈 트리는 모두 대상 */
578
+ export function couponAudienceMatches(audience: ConditionGroup, facts: CouponFacts): boolean {
579
+ return evaluateCondition(audience, COUPON_ORDER_CONDITIONS, { facts }).passed;
580
+ }
581
+
582
+ /** 트리들에 나온 구매자 사실 값 이름(`daysSinceJoined` 등) — 서버가 필요한 값만 읽는다 */
583
+ export function couponCustomerFactNames(
584
+ ...groups: readonly ConditionGroup[]
585
+ ): Set<keyof CouponCustomerFacts> {
586
+ const names = new Set<keyof CouponCustomerFacts>();
587
+ for (const group of groups) {
588
+ for (const field of conditionFields(group)) {
589
+ if (field.startsWith("customer."))
590
+ names.add(field.slice("customer.".length) as keyof CouponCustomerFacts);
591
+ }
592
+ }
593
+ return names;
594
+ }
595
+
596
+ /** 사실 값 중 트리가 쓰는 것만 남긴다 — 스냅샷에 굳히는 값 */
597
+ export function pickCouponFacts(facts: CouponFacts, eligibility: ConditionGroup): CouponFacts {
598
+ const fields = conditionFields(eligibility);
599
+ const customer: CouponCustomerFacts = {};
600
+ for (const name of couponCustomerFactNames(eligibility)) {
601
+ const value = facts.customer?.[name];
602
+ if (value !== undefined) (customer as Record<string, unknown>)[name] = value;
603
+ }
604
+ const out: CouponFacts = {};
605
+ if (Object.keys(customer).length) out.customer = customer;
606
+ if (facts.time && [...fields].some((f) => f.startsWith("time."))) out.time = facts.time;
607
+ return out;
608
+ }
609
+
610
+ // ─── 검증 ─────────────────────────────────────────────
611
+
612
+ export interface CouponRuleIssue {
613
+ path: (string | number)[];
614
+ message: string;
615
+ }
616
+
617
+ const ISSUE_MESSAGES: Record<ConditionIssue["code"], string> = {
618
+ DEPTH: "조건 그룹은 3단까지 둘 수 있습니다",
619
+ TOO_MANY_RULES: "조건은 50개까지 둘 수 있습니다",
620
+ UNKNOWN_FIELD: "알 수 없는 조건 필드입니다",
621
+ FIELD_NOT_ALLOWED: "이 자리에 쓸 수 없는 조건 필드입니다",
622
+ OPERATOR: "이 필드에 쓸 수 없는 연산자입니다",
623
+ VALUE: "조건 값이 올바르지 않습니다",
624
+ };
625
+
626
+ const allowedIn = (slot: CouponConditionSlot) => (field: string) =>
627
+ FIELD_INFO.get(field)?.slots.includes(slot) ?? false;
628
+
629
+ /**
630
+ * 쿠폰 규칙 검증 — 관리 API 요청 스키마의 `superRefine`이 부른다(검증 원천 하나).
631
+ * 트리 형식·필드 자리에 더해 쿠폰 칸과 엮인 규칙을 본다.
632
+ * - 비회원에게 여는 쿠폰은 구매자 조건(`customer.*`)을 쓸 수 없다(비회원은 사실 값이 없다)
633
+ * - 코드형 쿠폰이 결제 완료 주문 수를 쓰면 1인 사용 한도가 필요하다(동시 결제는 1인 한도 조건부 UPDATE가 막는다)
634
+ * - 회원가입 쿠폰의 발급 대상에는 가입 경과 일수를 쓸 수 없다(가입 순간 늘 0)
635
+ */
636
+ export function couponRuleIssues(input: {
637
+ memberOnly: boolean;
638
+ issueMethod: string;
639
+ perMemberUseLimit?: number | null;
640
+ eligibility?: ConditionGroup;
641
+ targetRules?: CouponTargetRules;
642
+ audience?: ConditionGroup;
643
+ }): CouponRuleIssue[] {
644
+ const issues: CouponRuleIssue[] = [];
645
+ const push = (base: string[], list: ConditionIssue[]) => {
646
+ for (const issue of list) {
647
+ // 다른 자리의 필드(줄 필드를 사용 조건에)는 「모르는 필드」가 아니라 「이 자리에 못 쓰는 필드」다
648
+ const code =
649
+ issue.code === "UNKNOWN_FIELD" && issue.field && FIELD_INFO.has(issue.field)
650
+ ? "FIELD_NOT_ALLOWED"
651
+ : issue.code;
652
+ issues.push({ path: [...base, ...issue.path], message: ISSUE_MESSAGES[code] });
653
+ }
654
+ };
655
+ if (input.eligibility)
656
+ push(
657
+ ["eligibility"],
658
+ conditionIssues(input.eligibility, COUPON_ORDER_CONDITIONS, {
659
+ allowed: allowedIn("eligibility"),
660
+ }),
661
+ );
662
+ if (input.audience) {
663
+ if (input.issueMethod === "CODE" && input.audience.rules.length > 0)
664
+ issues.push({
665
+ path: ["audience"],
666
+ message: "코드 입력형 쿠폰에는 발급 대상을 둘 수 없습니다",
667
+ });
668
+ push(
669
+ ["audience"],
670
+ conditionIssues(input.audience, COUPON_ORDER_CONDITIONS, {
671
+ allowed: allowedIn("audience"),
672
+ }),
673
+ );
674
+ }
675
+ if (input.targetRules) {
676
+ for (const key of ["include", "exclude"] as const) {
677
+ push(
678
+ ["targetRules", key],
679
+ conditionIssues(
680
+ { combinator: "or", rules: input.targetRules[key] },
681
+ COUPON_LINE_CONDITIONS,
682
+ { allowed: allowedIn("target") },
683
+ ),
684
+ );
685
+ }
686
+ }
687
+ if (!input.memberOnly) {
688
+ for (const slot of ["eligibility", "audience"] as const) {
689
+ if (couponCustomerFactNames(input[slot] ?? EMPTY_CONDITION_GROUP).size > 0)
690
+ issues.push({
691
+ path: [slot],
692
+ message: "비회원에게 여는 쿠폰에는 구매자 조건을 둘 수 없습니다",
693
+ });
694
+ }
695
+ }
696
+ if (
697
+ input.issueMethod === "CODE" &&
698
+ couponCustomerFactNames(input.eligibility ?? EMPTY_CONDITION_GROUP).has("paidOrderCount") &&
699
+ input.perMemberUseLimit == null
700
+ )
701
+ issues.push({
702
+ path: ["perMemberUseLimit"],
703
+ message: "결제 완료 주문 수 조건이 있는 코드 입력형 쿠폰은 1인 사용 한도가 필요합니다",
704
+ });
705
+ if (
706
+ input.issueMethod === "SIGNUP" &&
707
+ couponCustomerFactNames(input.audience ?? EMPTY_CONDITION_GROUP).has("daysSinceJoined")
708
+ )
709
+ issues.push({
710
+ path: ["audience"],
711
+ message: "회원가입 쿠폰의 발급 대상에는 가입 경과 일수를 둘 수 없습니다",
712
+ });
713
+ return issues;
714
+ }
715
+
716
+ /** 정규형 트리 묶음 */
717
+ export function canonicalizeCouponTrees(trees: CouponRuleTrees): CouponRuleTrees {
718
+ return {
719
+ eligibility: canonicalizeConditionGroup(trees.eligibility),
720
+ targetRules: canonicalizeCouponTargetRules(trees.targetRules),
721
+ };
722
+ }
723
+
724
+ // ─── 구매자 문구 ──────────────────────────────────────
725
+
726
+ /** 스토어프론트에 싣는 조건 한 줄 — 구조화 값 + 문구. 그룹은 `field: "group"`이고 문구만 있다 */
727
+ export interface CouponRequirement {
728
+ field: string;
729
+ operator?: string;
730
+ value?: ConditionValue;
731
+ label: string;
732
+ }
733
+
734
+ const WEEKDAY_LABELS: Record<CouponWeekday, string> = {
735
+ MON: "월",
736
+ TUE: "화",
737
+ WED: "수",
738
+ THU: "목",
739
+ FRI: "금",
740
+ SAT: "토",
741
+ SUN: "일",
742
+ };
743
+
744
+ const FIELD_LABELS: Record<string, string> = {
745
+ "customer.daysSinceJoined": "가입",
746
+ "customer.paidOrderCount": "구매",
747
+ "customer.daysSinceLastOrder": "마지막 구매",
748
+ "cart.subtotal": "주문 금액",
749
+ "cart.targetSubtotal": "대상 상품",
750
+ "cart.targetQuantity": "대상 상품",
751
+ "line.unitPrice": "판매가",
752
+ };
753
+
754
+ const number = new Intl.NumberFormat("ko-KR");
755
+
756
+ function amountText(field: string, value: unknown, formatMoney: (minor: number) => string) {
757
+ const n = typeof value === "number" ? value : Number(value);
758
+ const unit = FIELD_INFO.get(field)?.unit;
759
+ if (unit === "money") return formatMoney(n);
760
+ if (unit === "day") return `${number.format(n)}일`;
761
+ if (unit === "count") return `${number.format(n)}회`;
762
+ if (unit === "quantity") return `${number.format(n)}개`;
763
+ return number.format(n);
764
+ }
765
+
766
+ /** 규칙 한 개의 문구 — 「가입 30일 이상」 「대상 상품 50,000원 이상」 「첫 구매」 */
767
+ export function describeCouponRule(
768
+ rule: ConditionRule,
769
+ formatMoney: (minor: number) => string = (n) => `${number.format(n)}원`,
770
+ ): string {
771
+ if (isFirstOrderRule(rule)) return "첫 구매";
772
+ const label = FIELD_LABELS[rule.field] ?? rule.field;
773
+ switch (rule.field) {
774
+ case "customer.marketingAgreed":
775
+ return rule.value ? "마케팅 수신 동의" : "마케팅 수신 미동의";
776
+ case "line.productId":
777
+ return `상품 ${number.format((rule.value as unknown[]).length)}개`;
778
+ case "line.categoryId":
779
+ return `카테고리 ${number.format((rule.value as unknown[]).length)}개`;
780
+ case "line.hasInstantDiscount":
781
+ return rule.value ? "즉시할인 상품" : "즉시할인 없는 상품";
782
+ case "time.weekday":
783
+ return `${(rule.value as CouponWeekday[]).map((d) => WEEKDAY_LABELS[d] ?? d).join("·")}요일`;
784
+ case "time.timeOfDay": {
785
+ const [from, to] = rule.value as [string, string];
786
+ return `${from}~${to}`;
787
+ }
788
+ }
789
+ const text = (v: unknown) => amountText(rule.field, v, formatMoney);
790
+ if (rule.operator === "between") {
791
+ const [from, to] = rule.value as [unknown, unknown];
792
+ return `${label} ${text(from)}~${text(to)}`;
793
+ }
794
+ const suffix = rule.operator === ">=" ? " 이상" : rule.operator === "<=" ? " 이하" : "";
795
+ if (rule.field === "customer.paidOrderCount" && rule.operator === "=")
796
+ return `${label} ${text(rule.value)}`;
797
+ return `${label} ${text(rule.value)}${suffix}`;
798
+ }
799
+
800
+ export function describeCouponGroup(
801
+ group: ConditionGroup,
802
+ formatMoney?: (minor: number) => string,
803
+ ): string {
804
+ const joint = group.combinator === "and" ? " 그리고 " : " 또는 ";
805
+ return group.rules
806
+ .map((node) =>
807
+ isConditionGroup(node)
808
+ ? `(${describeCouponGroup(node, formatMoney)})`
809
+ : describeCouponRule(node, formatMoney),
810
+ )
811
+ .join(joint);
812
+ }
813
+
814
+ /** 할인 대상 문구 — 「아우터 카테고리 1개 · 상품 1개, 즉시할인 상품 제외」. 포함이 비면 「전 상품」 */
815
+ export function describeCouponTargetRules(
816
+ rules: CouponTargetRules,
817
+ formatMoney?: (minor: number) => string,
818
+ ): string {
819
+ const include = rules.include.length
820
+ ? rules.include.map((r) => describeCouponRule(r, formatMoney)).join(" 또는 ")
821
+ : "전 상품";
822
+ if (rules.exclude.length === 0) return include;
823
+ const exclude = rules.exclude.map((r) => describeCouponRule(r, formatMoney)).join(", ");
824
+ return `${include} (${exclude} 제외)`;
825
+ }
826
+
827
+ /**
828
+ * 스토어프론트 `requirements` — 최상위 `and`면 노드마다 한 줄(그룹은 문구 한 줄), 최상위 `or`면 트리 전체를 한 줄로.
829
+ * `minOrderAmount`(옛 필드)와 같은 원천에서 나온다.
830
+ */
831
+ export function couponRequirementsOf(
832
+ eligibility: ConditionGroup,
833
+ formatMoney?: (minor: number) => string,
834
+ ): CouponRequirement[] {
835
+ const canonical = canonicalizeConditionGroup(eligibility);
836
+ if (canonical.rules.length === 0) return [];
837
+ const topAnd = canonical.combinator === "and" || canonical.rules.length <= 1;
838
+ if (!topAnd) return [{ field: "group", label: describeCouponGroup(canonical, formatMoney) }];
839
+ return canonical.rules.map((node) =>
840
+ isConditionGroup(node)
841
+ ? { field: "group", label: describeCouponGroup(node, formatMoney) }
842
+ : {
843
+ field: node.field,
844
+ operator: node.operator,
845
+ value: node.value,
846
+ label: describeCouponRule(node, formatMoney),
847
+ },
848
+ );
849
+ }