@sayren/store-sdk 0.48.0 → 0.50.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,4 +1,21 @@
1
1
  import * as z from "zod";
2
+ import { type ConditionGroup, conditionGroupSchema } from "./condition-tree";
3
+ import {
4
+ assumeCouponFactRules,
5
+ COUPON_STACKING_DEFAULTS,
6
+ type CouponFacts,
7
+ type CouponRuleTrees,
8
+ type CouponStacking,
9
+ type CouponTargetRules,
10
+ couponLineContextOf,
11
+ couponLineMatches,
12
+ couponRulesFromLegacy,
13
+ couponStackingSchema,
14
+ couponTargetRulesSchema,
15
+ couponTimeFactsAt,
16
+ evaluateCouponEligibility,
17
+ pickCouponFacts,
18
+ } from "./coupon-rules";
2
19
  import { CURRENCIES, KRW_MONEY_RULES, type MoneyRules } from "./currency";
3
20
  import { proratedRefundBase } from "./refund-policy";
4
21
 
@@ -144,9 +161,61 @@ export const couponRuleSchema = z.object({
144
161
  .datetime({ offset: true })
145
162
  .nullable()
146
163
  .describe("사용 종료 시각(ISO 8601, 시간대 포함, 이 시각부터 못 쓴다). null이면 제한이 없다"),
164
+ eligibility: (conditionGroupSchema as z.ZodType<ConditionGroup>)
165
+ .optional()
166
+ .describe(
167
+ "사용 조건 트리(이슈 #222). 없으면 `conditions`에서 만든다. 있으면 이것이 원천이고 `conditions`는 가장 가까운 옛 값이다",
168
+ ),
169
+ targetRules: couponTargetRulesSchema
170
+ .optional()
171
+ .describe(
172
+ "할인 대상 규칙(이슈 #222). 없으면 `target`·`conditions.stackWithInstantDiscount`에서 만든다",
173
+ ),
174
+ stacking: couponStackingSchema
175
+ .optional()
176
+ .describe("중복 사용 정책(이슈 #222). 없으면 종류별 한 장·서로 결합·적립금과 함께"),
147
177
  });
148
178
  export type CouponRule = z.infer<typeof couponRuleSchema>;
149
179
 
180
+ /** 규칙의 트리 — 트리 칸이 없으면 옛 칸에서 만든다(계산은 늘 이 한 경로) */
181
+ export function couponTreesOf(
182
+ rule: Pick<CouponRule, "conditions" | "target"> & {
183
+ eligibility?: ConditionGroup;
184
+ targetRules?: CouponTargetRules;
185
+ },
186
+ ): CouponRuleTrees {
187
+ const legacy = couponRulesFromLegacy(rule.conditions, rule.target);
188
+ return {
189
+ eligibility: rule.eligibility ?? legacy.eligibility,
190
+ targetRules: rule.targetRules ?? legacy.targetRules,
191
+ };
192
+ }
193
+
194
+ export function couponStackingOf(rule: { stacking?: CouponStacking }): CouponStacking {
195
+ return rule.stacking ?? COUPON_STACKING_DEFAULTS;
196
+ }
197
+
198
+ const STACKING_KEY: Record<CouponKind, keyof CouponStacking["combinesWith"]> = {
199
+ ORDER: "order",
200
+ PRODUCT: "product",
201
+ DELIVERY: "delivery",
202
+ };
203
+
204
+ /**
205
+ * 두 쿠폰을 함께 쓸 수 있는가 — 서로가 상대 종류를 허용해야 한다. 같은 쿠폰 정의의 사본끼리는 장수 규칙(`perOrderLimit`)이 보므로 늘 참이다.
206
+ * `quotePricing`과 최대 조합(`bestCouponSelection`)이 같은 함수를 쓴다
207
+ */
208
+ export function couponsCombinable(
209
+ a: { rule: Pick<CouponRule, "couponId" | "kind"> & { stacking?: CouponStacking } },
210
+ b: { rule: Pick<CouponRule, "couponId" | "kind"> & { stacking?: CouponStacking } },
211
+ ): boolean {
212
+ if (a.rule.couponId === b.rule.couponId) return true;
213
+ return (
214
+ couponStackingOf(a.rule).combinesWith[STACKING_KEY[b.rule.kind]] &&
215
+ couponStackingOf(b.rule).combinesWith[STACKING_KEY[a.rule.kind]]
216
+ );
217
+ }
218
+
150
219
  /** 쿠폰을 적용하지 못한 사유 */
151
220
  export const couponRejectReasonSchema = z
152
221
  .enum([
@@ -161,6 +230,8 @@ export const couponRejectReasonSchema = z
161
230
  "LINE_NOT_ELIGIBLE",
162
231
  "LINE_AMBIGUOUS",
163
232
  "NO_DISCOUNT",
233
+ "CONDITION_NOT_MET",
234
+ "NOT_COMBINABLE",
164
235
  ])
165
236
  .describe(
166
237
  "쿠폰을 적용하지 못한 사유. `DUPLICATE_COUPON`=같은 쿠폰을 두 번 넣음, " +
@@ -168,7 +239,8 @@ export const couponRejectReasonSchema = z
168
239
  "`NOT_STARTED`=사용 기간 전, `EXPIRED`=사용 기간 지남, `MEMBER_ONLY`=회원 전용, `FIRST_ORDER_ONLY`=첫 구매 전용, " +
169
240
  "`NO_ELIGIBLE_ITEMS`=대상 상품이 주문에 없음, `MIN_ORDER_AMOUNT`=최소 주문 금액 미달, " +
170
241
  "`LINE_NOT_ELIGIBLE`=지정한 주문상품이 대상이 아니거나 이미 상품 쿠폰이 있음, " +
171
- "`LINE_AMBIGUOUS`=지정한 `optionId`의 줄이 여럿이라 줄을 정할 수 없음(`lineId`로 지목한다), `NO_DISCOUNT`=할인할 금액이 없음",
242
+ "`LINE_AMBIGUOUS`=지정한 `optionId`의 줄이 여럿이라 줄을 정할 수 없음(`lineId`로 지목한다), `NO_DISCOUNT`=할인할 금액이 없음, " +
243
+ "`CONDITION_NOT_MET`=사용 조건(이슈 #222)을 채우지 못함, `NOT_COMBINABLE`=이미 적용된 쿠폰과 함께 쓸 수 없음",
172
244
  );
173
245
  export type CouponRejectReason = z.infer<typeof couponRejectReasonSchema>;
174
246
 
@@ -210,6 +282,11 @@ export interface PricingCoupon {
210
282
  /** 발급 쿠폰의 유효 시작·종료 — 쿠폰 사용 기간과 둘 중 좁은 쪽이 적용된다 */
211
283
  validFrom?: string | null;
212
284
  validUntil?: string | null;
285
+ /**
286
+ * 이 쿠폰을 판정할 사실 값 — 주문 스냅샷의 값(환불 정산). 주어지면 입력의 `facts`보다 우선한다.
287
+ * 결제 시작에서 쓴 구매자·시각 값을 그대로 다시 쓰므로 주문 뒤 바뀐 가입 일수·요일이 결과를 바꾸지 않는다
288
+ */
289
+ facts?: CouponFacts | null;
213
290
  }
214
291
 
215
292
  /**
@@ -239,7 +316,7 @@ export interface PricingPoints {
239
316
  }
240
317
 
241
318
  /** 적립금을 쓰지 못한 이유 */
242
- export type PointRejectReason = "MIN_BALANCE" | "MIN_AMOUNT" | "NOT_PAYABLE";
319
+ export type PointRejectReason = "MIN_BALANCE" | "MIN_AMOUNT" | "NOT_PAYABLE" | "COUPON_EXCLUSIVE";
243
320
 
244
321
  export interface PricingInput {
245
322
  lines: readonly PricingLine[];
@@ -248,16 +325,22 @@ export interface PricingInput {
248
325
  coupons: readonly PricingCoupon[];
249
326
  /** 배송비 견적. 없으면 배송비 0으로 계산하고 배송비 쿠폰은 할인할 금액이 없다 */
250
327
  delivery?: PricingDelivery | null;
251
- /** 이 상점에서 결제 완료 주문이 없는 회원인가 */
252
- firstOrder: boolean;
328
+ /**
329
+ * 이 상점에서 결제 완료 주문이 없는 회원인가 — 옛 입력이다. `facts.customer.paidOrderCount`가 없을 때 true면 0으로 읽는다
330
+ * (false는 값을 모르는 것으로 둔다 — 「결제 완료 주문 수 = 0」 규칙은 거짓)
331
+ */
332
+ firstOrder?: boolean;
253
333
  /** 회원 주문인가 */
254
334
  isMember: boolean;
255
- /** 사용 기간 판정 시각 */
335
+ /** 사용 기간 판정 시각 — 시각 사실 값(요일·시각)도 여기서 나온다 */
256
336
  now: Date;
337
+ /** 구매자 사실 값(이슈 #222) — 사용 조건 트리가 읽는다. 시각 값을 주지 않으면 `now`에서 만든다 */
338
+ facts?: CouponFacts | null;
257
339
  /**
258
- * 사용 기간·회원·첫 구매 조건을 다시 보지 않는다 — **환불 정산의 재계산 전용이다.** 그 조건은 결제 시작에서
340
+ * 사용 기간·회원 전용을 다시 보지 않는다 — **환불 정산의 재계산 전용이다.** 그 조건은 결제 시작에서
259
341
  * 이미 통과했다. 주문 시각(승인 시각)으로 기간을 다시 보면 결제 시작과 승인 사이에 끝난 쿠폰이 조건을 잃은
260
- * 것처럼 보이고, 탈퇴한 회원의 반품이 회원 전용 조건에 떨어진다. 대상과 최소 금액만 본다.
342
+ * 것처럼 보이고, 탈퇴한 회원의 반품이 회원 전용 조건에 떨어진다. 사용 조건 트리는 쿠폰의 스냅샷 사실 값(`PricingCoupon.facts`)으로
343
+ * 남은 금액과 함께 다시 평가한다(트리 일부만 건너뛰면 `or` 트리의 결과가 틀린다).
261
344
  */
262
345
  ignoreEligibility?: boolean;
263
346
  /**
@@ -296,6 +379,8 @@ export interface PricingCouponResult {
296
379
  lineId: string | null;
297
380
  /** 이 쿠폰의 대상 줄 — 판정에 쓴 순서 그대로다. 환불 정산이 주문 시점 대상을 되살리는 데 쓴다 */
298
381
  eligibleLineIds: string[];
382
+ /** 판정에 쓴 사실 값 중 이 쿠폰의 사용 조건이 읽은 것 — 주문 스냅샷에 굳힌다. 읽은 값이 없으면 빈 객체 */
383
+ facts: CouponFacts;
299
384
  }
300
385
 
301
386
  export interface PricingBundleResult {
@@ -380,26 +465,19 @@ export function allocateByWeight(total: number, weights: readonly number[]): num
380
465
  }
381
466
 
382
467
  /** 카테고리 경로(`/a/b/c/`)가 그 카테고리 또는 하위를 가리키는가 */
383
- function inCategory(categoryPath: string | null | undefined, categoryIds: readonly string[]) {
384
- if (!categoryPath || categoryIds.length === 0) return false;
385
- const chain = categoryPath.split("/").filter(Boolean);
386
- return categoryIds.some((id) => chain.includes(id));
387
- }
388
-
389
- /** 줄이 쿠폰 대상인가 — 제외가 포함보다 우선하고, 포함 목록이 모두 비면 전 상품이다 */
468
+ /**
469
+ * 줄이 쿠폰 대상인가 — 제외가 포함보다 우선하고, 포함이 비면 전 상품이다. 트리(`targetRules`)가 없으면 옛 칸에서 만든 트리로 본다.
470
+ * `quantity`를 생략하면 1로 본다(단가 조건은 금액 ÷ 수량).
471
+ */
390
472
  export function isCouponTargetLine(
391
- rule: Pick<CouponRule, "target" | "conditions">,
392
- line: Pick<PricingLine, "productId" | "categoryPath" | "baseAmount" | "amount">,
473
+ rule: Pick<CouponRule, "target" | "conditions"> & { targetRules?: CouponTargetRules },
474
+ line: Pick<PricingLine, "productId" | "categoryPath" | "baseAmount" | "amount"> & {
475
+ quantity?: number;
476
+ },
393
477
  ): boolean {
394
- const { target, conditions } = rule;
395
- if (!conditions.stackWithInstantDiscount && line.amount < line.baseAmount) return false;
396
- if (target.excludeProductIds.includes(line.productId)) return false;
397
- if (inCategory(line.categoryPath, target.excludeCategoryIds)) return false;
398
- const noInclude = target.includeProductIds.length === 0 && target.includeCategoryIds.length === 0;
399
- if (noInclude) return true;
400
- return (
401
- target.includeProductIds.includes(line.productId) ||
402
- inCategory(line.categoryPath, target.includeCategoryIds)
478
+ return couponLineMatches(
479
+ couponTreesOf(rule).targetRules,
480
+ couponLineContextOf({ ...line, quantity: line.quantity ?? 1 }),
403
481
  );
404
482
  }
405
483
 
@@ -456,9 +534,28 @@ export function quotePricing(input: PricingInput): PricingResult {
456
534
  rejectReason: null,
457
535
  lineId: null,
458
536
  eligibleLineIds: [],
537
+ facts: {},
459
538
  }));
460
539
 
461
- // 1) 쿠폰마다 공통 조건 — 기간·회원·첫 구매·대상·최소 금액
540
+ const subtotal = lines.reduce((sum, entry) => sum + entry.line.amount, 0);
541
+ const inputTime = input.facts?.time ?? couponTimeFactsAt(input.now);
542
+ /**
543
+ * 쿠폰 하나의 사실 값 — 스냅샷 값 → 입력 값 순. 트리가 없는 옛 쿠폰(첫 구매 = 옛 칸)만 옛 입력 `firstOrder: true`를
544
+ * 결제 완료 주문 0으로 읽는다. 트리 쿠폰은 호출부가 실제 사실 값을 넘긴다(옛 입력은 첫 구매 칸이 있을 때만 계산된 값이다)
545
+ */
546
+ const factsOf = (coupon: PricingCoupon): CouponFacts => {
547
+ const own = coupon.facts ?? null;
548
+ const customer = { ...(input.facts?.customer ?? {}), ...(own?.customer ?? {}) };
549
+ if (
550
+ customer.paidOrderCount === undefined &&
551
+ input.firstOrder === true &&
552
+ coupon.rule.eligibility === undefined
553
+ )
554
+ customer.paidOrderCount = 0;
555
+ return { customer, time: own?.time ?? inputTime };
556
+ };
557
+
558
+ // 1) 쿠폰마다 공통 조건 — 기간·회원 → 구매자·시각 조건 → 대상 줄 → 주문서 집계 조건(옛 판정 순서와 같다)
462
559
  const commonRejectOf = (
463
560
  coupon: PricingCoupon,
464
561
  result: PricingCouponResult,
@@ -467,17 +564,26 @@ export function quotePricing(input: PricingInput): PricingResult {
467
564
  const period = periodRejectOf(coupon, input.now);
468
565
  if (period) return period;
469
566
  if (coupon.rule.conditions.memberOnly && !input.isMember) return "MEMBER_ONLY";
470
- if (coupon.rule.conditions.firstOrderOnly && !input.firstOrder) return "FIRST_ORDER_ONLY";
471
567
  }
568
+ const trees = couponTreesOf(coupon.rule);
569
+ const facts = factsOf(coupon);
570
+ result.facts = pickCouponFacts(facts, trees.eligibility);
571
+ const early = evaluateCouponEligibility(trees.eligibility, { facts }, "beforeLines");
572
+ if (early) return early;
472
573
  const pinned = coupon.eligibleLineIds ? new Set(coupon.eligibleLineIds) : null;
473
574
  const eligible = lines.filter((entry) =>
474
- pinned ? pinned.has(entry.line.lineId) : isCouponTargetLine(coupon.rule, entry.line),
575
+ pinned
576
+ ? pinned.has(entry.line.lineId)
577
+ : couponLineMatches(trees.targetRules, couponLineContextOf(entry.line)),
475
578
  );
476
579
  result.eligibleLineIds = eligible.map((entry) => entry.line.lineId);
477
580
  if (eligible.length === 0) return "NO_ELIGIBLE_ITEMS";
478
- const targetAmount = eligible.reduce((sum, entry) => sum + entry.line.amount, 0);
479
- if (targetAmount < coupon.rule.conditions.minOrderAmount) return "MIN_ORDER_AMOUNT";
480
- return null;
581
+ const cart = {
582
+ subtotal,
583
+ targetSubtotal: eligible.reduce((sum, entry) => sum + entry.line.amount, 0),
584
+ targetQuantity: eligible.reduce((sum, entry) => sum + entry.line.quantity, 0),
585
+ };
586
+ return evaluateCouponEligibility(trees.eligibility, { facts, cart }, "afterLines");
481
587
  };
482
588
  const passed: number[] = [];
483
589
  input.coupons.forEach((coupon, index) => {
@@ -487,21 +593,34 @@ export function quotePricing(input: PricingInput): PricingResult {
487
593
  else passed.push(index);
488
594
  });
489
595
 
490
- /** 적용된 쿠폰 id — 같은 쿠폰 정의는 주문에 한 장 */
491
- const appliedCouponIds = new Set<string>();
596
+ /** 적용된 쿠폰 — 같은 쿠폰 정의의 장수(`perOrderLimit`)와 결합 판정에 쓴다 */
597
+ const appliedCount = new Map<string, number>();
598
+ const applied: PricingCoupon[] = [];
492
599
  const apply = (index: number, discount: number, lineId: string | null) => {
493
600
  const result = results[index] as PricingCouponResult;
601
+ const coupon = input.coupons[index] as PricingCoupon;
494
602
  result.applied = true;
495
603
  result.discount = discount;
496
604
  result.lineId = lineId;
497
- appliedCouponIds.add(result.couponId);
605
+ appliedCount.set(result.couponId, (appliedCount.get(result.couponId) ?? 0) + 1);
606
+ applied.push(coupon);
498
607
  };
608
+ /** 같은 쿠폰 정의의 장수 — 상품 쿠폰만 `perOrderLimit`까지, 나머지는 한 장 */
499
609
  const duplicate = (index: number) => {
500
610
  const result = results[index] as PricingCouponResult;
501
- if (!appliedCouponIds.has(result.couponId)) return false;
611
+ const coupon = input.coupons[index] as PricingCoupon;
612
+ const limit = coupon.rule.kind === "PRODUCT" ? couponStackingOf(coupon.rule).perOrderLimit : 1;
613
+ if ((appliedCount.get(result.couponId) ?? 0) < limit) return false;
502
614
  result.rejectReason = "DUPLICATE_COUPON";
503
615
  return true;
504
616
  };
617
+ /** 결합 — 이미 적용된 쿠폰과 이 쿠폰이 서로의 종류를 허용해야 한다(같은 쿠폰 정의의 사본끼리는 장수 규칙이 본다) */
618
+ const notCombinable = (index: number) => {
619
+ const coupon = input.coupons[index] as PricingCoupon;
620
+ const blocked = applied.some((other) => !couponsCombinable(coupon, other));
621
+ if (blocked) (results[index] as PricingCouponResult).rejectReason = "NOT_COMBINABLE";
622
+ return blocked;
623
+ };
505
624
 
506
625
  // 2) 상품 쿠폰 — 한 줄에 한 장. 줄을 지정한 쿠폰을 먼저 걸고, 나머지는 대상 줄이 적은 쿠폰부터
507
626
  // 할인이 가장 큰 남은 줄(같으면 앞 줄)에 건다. 대상이 넓은 쿠폰이 좁은 쿠폰의 유일한 줄을 먼저 가져가지 않게 한다
@@ -519,6 +638,7 @@ export function quotePricing(input: PricingInput): PricingResult {
519
638
  const coupon = input.coupons[index] as PricingCoupon;
520
639
  const result = results[index] as PricingCouponResult;
521
640
  if (duplicate(index)) continue;
641
+ if (notCombinable(index)) continue;
522
642
  const candidates = result.eligibleLineIds.filter((id) => !takenLines.has(id));
523
643
  let chosen: string | null = null;
524
644
  let best = 0;
@@ -566,6 +686,7 @@ export function quotePricing(input: PricingInput): PricingResult {
566
686
  result.rejectReason = "STACK_LIMIT";
567
687
  continue;
568
688
  }
689
+ if (notCombinable(index)) continue;
569
690
  const targets = result.eligibleLineIds
570
691
  .map((id) => lineOf(id))
571
692
  .filter((entry): entry is (typeof lines)[number] => entry !== undefined);
@@ -603,6 +724,7 @@ export function quotePricing(input: PricingInput): PricingResult {
603
724
  result.rejectReason = "STACK_LIMIT";
604
725
  continue;
605
726
  }
727
+ if (notCombinable(index)) continue;
606
728
  const weights = deliveryBundles.map((bundle) => Math.max(0, bundle.fee));
607
729
  const base = weights.reduce((sum, w) => sum + w, 0);
608
730
  const discount = couponDiscountOf(coupon.rule.benefit, base, rules);
@@ -639,15 +761,24 @@ export function quotePricing(input: PricingInput): PricingResult {
639
761
  const bundleWeights = deliveryBundles.map((bundle, index) =>
640
762
  Math.max(0, bundle.fee + bundle.remoteSurcharge - (bundles[index]?.couponDiscount ?? 0)),
641
763
  );
642
- const points = pointUseOf(
643
- input.points ?? null,
644
- {
645
- productPaid: productWeights.reduce((sum, w) => sum + w, 0),
646
- deliveryPaid: bundleWeights.reduce((sum, w) => sum + w, 0),
647
- total: beforePoints,
648
- },
649
- rules,
650
- );
764
+ // 적립금과 함께 쓸 수 없는 쿠폰이 적용됐으면 적립금을 쓰지 않는다(쿠폰 우선, 이슈 #222)
765
+ const pointsBlocked = applied.some((entry) => !couponStackingOf(entry.rule).withPoints);
766
+ const points = pointsBlocked
767
+ ? {
768
+ amount: 0,
769
+ maxUsable: 0,
770
+ adjustReason: null,
771
+ rejectReason: (input.points?.requested ?? 0) > 0 ? ("COUPON_EXCLUSIVE" as const) : null,
772
+ }
773
+ : pointUseOf(
774
+ input.points ?? null,
775
+ {
776
+ productPaid: productWeights.reduce((sum, w) => sum + w, 0),
777
+ deliveryPaid: bundleWeights.reduce((sum, w) => sum + w, 0),
778
+ total: beforePoints,
779
+ },
780
+ rules,
781
+ );
651
782
  if (points.amount > 0) {
652
783
  const productPaid = productWeights.reduce((sum, w) => sum + w, 0);
653
784
  const toLines = Math.min(points.amount, productPaid);
@@ -750,6 +881,8 @@ export interface AppliedCouponSnapshot {
750
881
  eligibleLineIds: string[];
751
882
  /** 주문 시점 할인액 */
752
883
  discount: number;
884
+ /** 주문 시점 사실 값(이슈 #222) — 사용 조건 트리가 읽은 구매자·시각 값. 없으면 빈 값(옛 주문) */
885
+ facts?: CouponFacts | null;
753
886
  }
754
887
 
755
888
  /** 정산이 보는 주문상품 한 줄 — 이번 취소를 **반영한 뒤**의 활성 수량이다 */
@@ -852,17 +985,22 @@ export function settleCouponShortfall(input: CouponSettlementInput): CouponSettl
852
985
  });
853
986
  const recomputed = quotePricing({
854
987
  lines: pricingLines,
855
- coupons: coupons.map((coupon) => ({
856
- rule: coupon.rule,
857
- couponKey: coupon.couponKey,
858
- lineId: coupon.lineId,
859
- eligibleLineIds: coupon.eligibleLineIds,
860
- })),
988
+ coupons: coupons.map((coupon) => {
989
+ // 기간·회원 전용은 결제 시작에서 이미 통과했다. 사용 조건 트리는 주문 시점 사실 값 + 남은 금액으로 다시 본다.
990
+ // 스냅샷에 없는 구매자·시각 값(옛 주문)은 결제 시작에서 통과한 것으로 둔다 — 주문서 집계(`cart.*`)만 남은 금액으로 다시 판정한다
991
+ const facts = coupon.facts ?? {};
992
+ const eligibility = assumeCouponFactRules(couponTreesOf(coupon.rule).eligibility, facts);
993
+ return {
994
+ rule: { ...coupon.rule, eligibility },
995
+ couponKey: coupon.couponKey,
996
+ lineId: coupon.lineId,
997
+ eligibleLineIds: coupon.eligibleLineIds,
998
+ facts,
999
+ };
1000
+ }),
861
1001
  delivery: null,
862
- // 기간·회원·첫 구매 조건은 결제 시작에서 이미 통과했다 — 대상과 최소 금액만 남은 금액으로 다시 본다
863
- firstOrder: true,
864
1002
  isMember: true,
865
- now: new Date(0), // ignoreEligibility라 기간을 보지 않는다
1003
+ now: new Date(0), // ignoreEligibility라 기간을 보지 않는다. 시각 조건은 스냅샷 값이거나 통과로 둔다
866
1004
  ignoreEligibility: true,
867
1005
  });
868
1006