@sayren/store-sdk 0.51.0 → 0.52.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/store-sdk",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
4
4
  "main": "./dist/index.mjs",
5
5
  "types": "./dist/index.d.mts",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  import { KRW_MONEY_RULES } from "./currency";
2
2
  import {
3
- couponDiscountOf,
3
+ couponRuleDiscountOf,
4
4
  couponStackingOf,
5
5
  couponsCombinable,
6
6
  type PricingCoupon,
@@ -139,14 +139,19 @@ export function bestCouponSelection(input: CouponSelectionInput): CouponSelectio
139
139
  const orders = solo.filter((e) => e.candidate.coupon.rule.kind === "ORDER");
140
140
  const deliveries = solo.filter((e) => e.candidate.coupon.rule.kind === "DELIVERY");
141
141
  const lineIds = input.lines.map((line) => line.lineId);
142
- const lineAmount = new Map(input.lines.map((line) => [line.lineId, line.amount]));
142
+ const lineById = new Map(input.lines.map((line) => [line.lineId, line]));
143
143
 
144
144
  /** 상품 쿠폰 한 장의 줄별 할인 — 대상 줄만, 줄 금액 기준 */
145
145
  const weightsOf = (entry: (typeof products)[number]) => {
146
146
  const eligible = new Set(entry.result?.eligibleLineIds ?? []);
147
147
  return lineIds.map((id) =>
148
148
  eligible.has(id)
149
- ? couponDiscountOf(entry.candidate.coupon.rule.benefit, lineAmount.get(id) ?? 0, rules)
149
+ ? couponRuleDiscountOf(
150
+ entry.candidate.coupon.rule,
151
+ lineById.get(id)?.amount ?? 0,
152
+ lineById.get(id) ?? null,
153
+ rules,
154
+ )
150
155
  : 0,
151
156
  );
152
157
  };
@@ -12,10 +12,12 @@ import {
12
12
  import { currencyCodeSchema } from "./currency";
13
13
  import {
14
14
  COUPON_CONDITIONS_DEFAULTS,
15
+ COUPON_REWARD_KINDS,
15
16
  COUPON_TARGET_LIST_MAX,
16
17
  couponBenefitSchema,
17
18
  couponConditionsSchema,
18
19
  couponKindSchema,
20
+ couponRewardSchema,
19
21
  couponTargetSchema,
20
22
  } from "./promotion";
21
23
  import { strictRequest } from "./strict";
@@ -103,6 +105,9 @@ const descriptions = {
103
105
  "발급 대상 트리(이슈 #222) — 셀러 지급·받기·회원가입 지급에서 누가 받을 수 있는가. 구매자 조건만. 코드 입력형에는 두지 않는다",
104
106
  stacking:
105
107
  "중복 사용 정책(이슈 #222). 생략하면 종류별 한 장·서로 결합·적립금과 함께. 결합은 양쪽 쿠폰이 모두 허용해야 성립한다",
108
+ reward:
109
+ "확장 혜택(구간 할인 `TIERED`·N+1 `BUY_X_GET_Y`·적립금 지급 `POINT`). 주면 `benefit`을 생략하고, 응답의 `benefit`은 가장 가까운 값이다. " +
110
+ "N+1은 상품 쿠폰만, 적립금 지급은 회원 전용 주문 쿠폰만이다. 수정에서 null이면 확장 혜택을 지운다(`benefit`이 필요하다)",
106
111
  issueStartsAt: "받기 시작 시각(받기 쿠폰만, 이슈 #222). 생략하면 사용 시작 시각을 따른다",
107
112
  issueEndsAt:
108
113
  "받기 종료 시각(받기 쿠폰만, 이 시각부터 못 받는다). 생략하면 사용 종료 시각을 따른다",
@@ -116,6 +121,7 @@ const ruleFields = {
116
121
  targetRules: couponTargetRulesSchema.optional().describe(descriptions.targetRules),
117
122
  audience: couponConditionTreeSchema.optional().describe(descriptions.audience),
118
123
  stacking: couponStackingSchema.optional().describe(descriptions.stacking),
124
+ reward: couponRewardSchema.nullable().optional().describe(descriptions.reward),
119
125
  };
120
126
 
121
127
  /**
@@ -141,6 +147,8 @@ export function couponRequestRuleIssues(body: {
141
147
  targetRules?: CouponRuleTrees["targetRules"];
142
148
  audience?: CouponRuleTrees["eligibility"];
143
149
  stacking?: { perOrderLimit: number };
150
+ reward?: { type: "TIERED" | "BUY_X_GET_Y" | "POINT" } | null;
151
+ benefit?: unknown;
144
152
  perMemberUseLimit?: number | null;
145
153
  dailyIssueLimit?: number | null;
146
154
  issueStartsAt?: string | null;
@@ -179,6 +187,27 @@ export function couponRequestRuleIssues(body: {
179
187
  ...(body.audience ? { audience: body.audience } : {}),
180
188
  }),
181
189
  );
190
+ if (!body.reward && body.benefit === undefined)
191
+ issues.push({ path: ["benefit"], message: "혜택(benefit 또는 reward)이 필요합니다" });
192
+ if (
193
+ body.reward &&
194
+ body.kind !== undefined &&
195
+ !(COUPON_REWARD_KINDS[body.reward.type] as readonly string[]).includes(body.kind)
196
+ )
197
+ issues.push({
198
+ path: ["reward", "type"],
199
+ message:
200
+ body.reward.type === "POINT"
201
+ ? "적립금 지급은 주문 쿠폰만 쓸 수 있습니다"
202
+ : body.reward.type === "BUY_X_GET_Y"
203
+ ? "N+1은 상품 쿠폰만 쓸 수 있습니다"
204
+ : "구간 할인은 배송비 쿠폰에 쓸 수 없습니다",
205
+ });
206
+ if (body.reward?.type === "POINT" && !conditions.memberOnly)
207
+ issues.push({
208
+ path: ["reward", "type"],
209
+ message: "적립금 지급은 회원 전용 쿠폰만 쓸 수 있습니다",
210
+ });
182
211
  if (body.stacking && body.stacking.perOrderLimit > 1 && body.kind !== "PRODUCT")
183
212
  issues.push({
184
213
  path: ["stacking", "perOrderLimit"],
@@ -250,7 +279,7 @@ export const createCouponRequestSchema = strictRequest(
250
279
  name: z.string().trim().min(1).max(60).describe(descriptions.name),
251
280
  description: z.string().max(300).nullable().optional().describe(descriptions.description),
252
281
  kind: couponKindSchema,
253
- benefit: couponBenefitSchema,
282
+ benefit: couponBenefitSchema.optional().describe("혜택. `reward`를 주면 생략한다"),
254
283
  conditions: couponConditionsInputSchema
255
284
  .optional()
256
285
  .describe("사용 조건. 생략한 항목은 기본값(최소 금액 0, 회원 전용, 즉시할인과 중복 가능)"),
@@ -288,6 +317,8 @@ export const createCouponRequestSchema = strictRequest(
288
317
  ctx.addIssue({ code: "custom", path: [path], message });
289
318
  if (body.issueMethod === "CODE" && !body.code)
290
319
  issue("code", "코드 입력형 쿠폰은 코드가 필요합니다");
320
+ if (body.reward && body.benefit)
321
+ issue("benefit", "확장 혜택(reward)을 주면 benefit을 생략합니다");
291
322
  if (body.issueMethod === "VOUCHER" && !body.voucher)
292
323
  issue("voucher", "쿠폰 번호 쿠폰은 번호 설정이 필요합니다");
293
324
  if (body.issueMethod !== "VOUCHER" && body.voucher)
@@ -423,6 +454,11 @@ export const couponSchema = z.object({
423
454
  .optional()
424
455
  .default(COUPON_STACKING_DEFAULTS)
425
456
  .describe("중복 사용 정책(이슈 #222). 서버는 항상 싣는다"),
457
+ reward: couponRewardSchema
458
+ .nullable()
459
+ .optional()
460
+ .default(null)
461
+ .describe("확장 혜택. 없으면 null이고 `benefit`이 혜택이다. 서버는 항상 싣는다"),
426
462
  rulesOnly: z
427
463
  .boolean()
428
464
  .optional()
@@ -180,6 +180,7 @@ export const POINT_LOT_SOURCES = [
180
180
  "ADJUST",
181
181
  "REFUND_RESTORE",
182
182
  "OPENING",
183
+ "COUPON",
183
184
  ] as const;
184
185
  export type PointLotSource = (typeof POINT_LOT_SOURCES)[number];
185
186
 
@@ -194,6 +195,7 @@ export const POINT_ENTRY_TYPES = [
194
195
  "EXPIRE",
195
196
  "ADJUST",
196
197
  "OPENING",
198
+ "EARN_COUPON",
197
199
  ] as const;
198
200
  export type PointEntryType = (typeof POINT_ENTRY_TYPES)[number];
199
201
 
@@ -257,7 +259,7 @@ export const responsePointEntryTypeSchema = z
257
259
  .describe(
258
260
  "유형. `EARN_REVIEW` 리뷰 적립 · `REVOKE_REVIEW` 리뷰 삭제 회수 · `EARN_PURCHASE` 구매 적립 · `EXPIRE` 소멸 · `ADJUST` 셀러 지급·차감 · " +
259
261
  "`OPENING` 기존 잔액 이전 · `USE_RESERVE` 결제 사용(결제 시작 때 빠짐) · `USE_RELEASE` 결제가 실패·만료되어 되돌림 · " +
260
- "`REFUND_RESTORE` 취소·반품 환불로 반환. 값이 추가될 수 있어 문자열로 받는다",
262
+ "`REFUND_RESTORE` 취소·반품 환불로 반환 · `EARN_COUPON` 적립금 지급 쿠폰. 값이 추가될 수 있어 문자열로 받는다",
261
263
  );
262
264
 
263
265
  /** 원장 항목 — 셀러 조회용(사유·행위자 포함) */
@@ -283,7 +285,7 @@ export const pointLotSchema = z.object({
283
285
  lotId: z.string(),
284
286
  source: z
285
287
  .union([z.enum(POINT_LOT_SOURCES), z.string()])
286
- .describe("출처. `REVIEW`·`PURCHASE`·`ADJUST`·`REFUND_RESTORE`·`OPENING` 등"),
288
+ .describe("출처. `REVIEW`·`PURCHASE`·`ADJUST`·`REFUND_RESTORE`·`OPENING`·`COUPON` 등"),
287
289
  amount: z.number().int().describe("적립한 금액"),
288
290
  remaining: z.number().int().describe("남은 금액"),
289
291
  expiresAt: z.string().nullable().describe("소멸 시각. null이면 소멸 없음"),
@@ -94,6 +94,142 @@ export const couponBenefitSchema = z
94
94
  });
95
95
  export type CouponBenefit = z.infer<typeof couponBenefitSchema>;
96
96
 
97
+ /** 구간 할인의 구간 수 상한 */
98
+ export const COUPON_REWARD_TIER_MAX = 5;
99
+
100
+ const rewardShortfallChargeSchema = z
101
+ .boolean()
102
+ .describe(
103
+ "부분 취소로 할인액이 줄면 그 차액을 환불에서 뺀다. 상점 환불 정책 `couponShortfallCharge`가 꺼져 있거나 판매자 귀책이면 빼지 않는다",
104
+ );
105
+
106
+ /**
107
+ * 확장 혜택(이슈 #222 혜택 확장) — 있으면 계산의 원천이고 `benefit`은 가장 가까운 값이다.
108
+ * - `TIERED`: 기준 금액(주문 쿠폰은 상품 쿠폰 차감 뒤 대상 합계, 상품 쿠폰은 그 줄 금액)이 넘은 가장 높은 구간의 할인. 첫 구간 미만이면 `MIN_ORDER_AMOUNT`
109
+ * - `BUY_X_GET_Y`: 상품 쿠폰만. 걸린 줄 수량을 `buy + get`개 묶음으로 세어 묶음마다 `get`개를 `rate`% 할인
110
+ * - `POINT`: 주문 쿠폰만. 할인 대신 대상 줄 실결제(쿠폰·적립금 뒤)에 `benefit`을 적용한 적립금을 구매확정 때 준다
111
+ */
112
+ export const couponRewardSchema = z
113
+ .discriminatedUnion("type", [
114
+ z.object({
115
+ type: z.literal("TIERED"),
116
+ tiers: z
117
+ .array(
118
+ z.object({
119
+ minAmount: z.number().int().min(0).describe("이 구간의 기준 금액 하한(원)"),
120
+ benefit: couponBenefitSchema,
121
+ }),
122
+ )
123
+ .min(2)
124
+ .max(COUPON_REWARD_TIER_MAX)
125
+ .describe("구간. 기준 금액 하한 오름차순"),
126
+ shortfallCharge: rewardShortfallChargeSchema,
127
+ }),
128
+ z.object({
129
+ type: z.literal("BUY_X_GET_Y"),
130
+ buy: z.number().int().min(1).max(99).describe("구매 수량(N+1의 N)"),
131
+ get: z.number().int().min(1).max(99).describe("할인 수량(N+1의 1)"),
132
+ rate: z.number().int().min(1).max(100).describe("할인 수량의 할인율(%). 100이면 무료"),
133
+ shortfallCharge: rewardShortfallChargeSchema,
134
+ }),
135
+ z.object({
136
+ type: z.literal("POINT"),
137
+ benefit: couponBenefitSchema.describe(
138
+ "적립 방식. `RATE`=대상 실결제의 %(최대 `maxDiscountAmount`), `AMOUNT`=고정 적립금",
139
+ ),
140
+ }),
141
+ ])
142
+ .superRefine((reward, ctx) => {
143
+ if (reward.type !== "TIERED") return;
144
+ reward.tiers.forEach((tier, index) => {
145
+ const prev = reward.tiers[index - 1];
146
+ if (prev && tier.minAmount <= prev.minAmount) {
147
+ ctx.addIssue({
148
+ code: "custom",
149
+ path: ["tiers", index, "minAmount"],
150
+ message: "구간 하한은 앞 구간보다 커야 합니다",
151
+ });
152
+ }
153
+ });
154
+ })
155
+ .describe("확장 혜택(구간 할인·N+1·적립금 지급). 없으면 `benefit`이 혜택이다");
156
+ export type CouponReward = z.infer<typeof couponRewardSchema>;
157
+
158
+ /** 확장 혜택을 쓸 수 있는 쿠폰 종류 */
159
+ export const COUPON_REWARD_KINDS: Record<CouponReward["type"], readonly CouponKind[]> = {
160
+ TIERED: ["ORDER", "PRODUCT"],
161
+ BUY_X_GET_Y: ["PRODUCT"],
162
+ POINT: ["ORDER"],
163
+ };
164
+
165
+ /** 응답·옛 칸의 `benefit` — 확장 혜택에서 가장 가까운 값(구간은 첫 구간, N+1은 묶음 평균 할인율, 적립금은 할인 0원에 가까운 1원) */
166
+ export function couponBenefitOfReward(reward: CouponReward): CouponBenefit {
167
+ if (reward.type === "TIERED") return (reward.tiers[0] as { benefit: CouponBenefit }).benefit;
168
+ if (reward.type === "BUY_X_GET_Y") {
169
+ // 묶음 평균 할인율(1+1 무료 → 50%) — 확장 혜택을 모르는 소비자에게 「100% 할인」으로 보이지 않게
170
+ const value = Math.max(1, Math.floor((reward.rate * reward.get) / (reward.buy + reward.get)));
171
+ return { type: "RATE", value, maxDiscountAmount: null };
172
+ }
173
+ return { type: "AMOUNT", value: 1, maxDiscountAmount: null };
174
+ }
175
+
176
+ /** 구간 할인의 적용 구간 — 기준 금액이 넘은 가장 높은 구간. 없으면 null */
177
+ export function couponTierOf(
178
+ reward: Extract<CouponReward, { type: "TIERED" }>,
179
+ base: number,
180
+ ): { index: number; benefit: CouponBenefit } | null {
181
+ for (let index = reward.tiers.length - 1; index >= 0; index--) {
182
+ const tier = reward.tiers[index];
183
+ if (tier && base >= tier.minAmount) return { index, benefit: tier.benefit };
184
+ }
185
+ return null;
186
+ }
187
+
188
+ /**
189
+ * 쿠폰 한 장의 할인액 — 확장 혜택까지 보는 원천이다(`quotePricing`·최대 조합이 같이 쓴다).
190
+ * `line`은 상품 쿠폰이 걸린 줄의 금액·수량이고 N+1만 수량을 읽는다. 적립금 지급은 할인이 0이다.
191
+ */
192
+ export function couponRuleDiscountOf(
193
+ rule: Pick<CouponRule, "benefit" | "reward">,
194
+ base: number,
195
+ line: { quantity: number } | null,
196
+ rules: MoneyRules = KRW_MONEY_RULES,
197
+ ): number {
198
+ const reward = rule.reward;
199
+ if (!reward) return couponDiscountOf(rule.benefit, base, rules);
200
+ if (reward.type === "POINT") return 0;
201
+ if (reward.type === "TIERED") {
202
+ const tier = couponTierOf(reward, base);
203
+ return tier ? couponDiscountOf(tier.benefit, base, rules) : 0;
204
+ }
205
+ const quantity = line?.quantity ?? 0;
206
+ if (base <= 0 || quantity <= 0) return 0;
207
+ const units = Math.floor(quantity / (reward.buy + reward.get)) * reward.get;
208
+ // 같은 줄은 단가가 같다 — 할인 수량의 금액은 줄 금액의 수량 비율(내림)이다
209
+ const unitsAmount = Math.floor((base * units) / quantity);
210
+ if (reward.rate === 100) return unitsAmount;
211
+ return couponDiscountOf(
212
+ { type: "RATE", value: reward.rate, maxDiscountAmount: null },
213
+ unitsAmount,
214
+ rules,
215
+ );
216
+ }
217
+
218
+ /**
219
+ * 적립금 지급 쿠폰의 적립금 — 대상 줄 실결제(쿠폰·적립금 뒤)에 혜택을 적용하고 줄에 실결제 비율로 나눈다.
220
+ * 결제 시작(`quotePricing`)과 구매확정 지급이 같은 함수다(구매확정은 주문상품의 실결제를 넣는다).
221
+ */
222
+ export function couponPointRewardOf(
223
+ reward: Extract<CouponReward, { type: "POINT" }>,
224
+ paidByLine: readonly number[],
225
+ rules: MoneyRules = KRW_MONEY_RULES,
226
+ ): { total: number; shares: number[] } {
227
+ const weights = paidByLine.map((paid) => Math.max(0, paid));
228
+ const base = weights.reduce((sum, w) => sum + w, 0);
229
+ const total = couponDiscountOf(reward.benefit, base, rules);
230
+ return { total, shares: total > 0 ? allocateByWeight(total, weights) : weights.map(() => 0) };
231
+ }
232
+
97
233
  /** 쿠폰 사용 조건 */
98
234
  export const couponConditionsSchema = z.object({
99
235
  minOrderAmount: z.number().int().min(0).describe(descriptions.minOrderAmount),
@@ -174,6 +310,7 @@ export const couponRuleSchema = z.object({
174
310
  stacking: couponStackingSchema
175
311
  .optional()
176
312
  .describe("중복 사용 정책(이슈 #222). 없으면 종류별 한 장·서로 결합·적립금과 함께"),
313
+ reward: couponRewardSchema.optional(),
177
314
  });
178
315
  export type CouponRule = z.infer<typeof couponRuleSchema>;
179
316
 
@@ -381,6 +518,10 @@ export interface PricingCouponResult {
381
518
  eligibleLineIds: string[];
382
519
  /** 판정에 쓴 사실 값 중 이 쿠폰의 사용 조건이 읽은 것 — 주문 스냅샷에 굳힌다. 읽은 값이 없으면 빈 객체 */
383
520
  facts: CouponFacts;
521
+ /** 적립금 지급 쿠폰이 구매확정 때 줄 적립금(지금 수량 기준). 그 밖은 0 */
522
+ rewardPoint: number;
523
+ /** 구간 할인이 적용한 구간 순번. 그 밖은 null — 환불 정산이 구간이 내려갔는지 본다 */
524
+ tierIndex: number | null;
384
525
  }
385
526
 
386
527
  export interface PricingBundleResult {
@@ -395,6 +536,8 @@ export interface PricingResult {
395
536
  coupons: PricingCouponResult[];
396
537
  /** 배송비 쿠폰의 묶음별 배분 */
397
538
  bundles: PricingBundleResult[];
539
+ /** 적립금 지급 쿠폰의 적립 예정 합계 — 결제 금액에 영향이 없다 */
540
+ rewardPointAmount: number;
398
541
  /** 상품 금액 합 (즉시할인 반영, 쿠폰 전) */
399
542
  productAmount: number;
400
543
  /** 상품·주문 쿠폰 할인 합계 */
@@ -535,6 +678,8 @@ export function quotePricing(input: PricingInput): PricingResult {
535
678
  lineId: null,
536
679
  eligibleLineIds: [],
537
680
  facts: {},
681
+ rewardPoint: 0,
682
+ tierIndex: null,
538
683
  }));
539
684
 
540
685
  const subtotal = lines.reduce((sum, entry) => sum + entry.line.amount, 0);
@@ -596,9 +741,11 @@ export function quotePricing(input: PricingInput): PricingResult {
596
741
  /** 적용된 쿠폰 — 같은 쿠폰 정의의 장수(`perOrderLimit`)와 결합 판정에 쓴다 */
597
742
  const appliedCount = new Map<string, number>();
598
743
  const applied: PricingCoupon[] = [];
599
- const apply = (index: number, discount: number, lineId: string | null) => {
744
+ const apply = (index: number, discount: number, lineId: string | null, base = 0) => {
600
745
  const result = results[index] as PricingCouponResult;
601
746
  const coupon = input.coupons[index] as PricingCoupon;
747
+ const reward = coupon.rule.reward;
748
+ if (reward?.type === "TIERED") result.tierIndex = couponTierOf(reward, base)?.index ?? null;
602
749
  result.applied = true;
603
750
  result.discount = discount;
604
751
  result.lineId = lineId;
@@ -622,6 +769,17 @@ export function quotePricing(input: PricingInput): PricingResult {
622
769
  return blocked;
623
770
  };
624
771
 
772
+ /** 상품 쿠폰을 줄 하나에 걸었을 때의 할인 — N+1은 그 줄 수량을 센다 */
773
+ const productDiscountOn = (coupon: PricingCoupon, lineId: string) => {
774
+ const line = lineOf(lineId)?.line;
775
+ return couponRuleDiscountOf(coupon.rule, line?.amount ?? 0, line ?? null, rules);
776
+ };
777
+ /** 할인이 0일 때의 사유 — 구간 할인이 첫 구간에 못 미치면 최소 금액 미달이다 */
778
+ const zeroReasonOf = (coupon: PricingCoupon, base: number): CouponRejectReason =>
779
+ coupon.rule.reward?.type === "TIERED" && couponTierOf(coupon.rule.reward, base) === null
780
+ ? "MIN_ORDER_AMOUNT"
781
+ : "NO_DISCOUNT";
782
+
625
783
  // 2) 상품 쿠폰 — 한 줄에 한 장. 줄을 지정한 쿠폰을 먼저 걸고, 나머지는 대상 줄이 적은 쿠폰부터
626
784
  // 할인이 가장 큰 남은 줄(같으면 앞 줄)에 건다. 대상이 넓은 쿠폰이 좁은 쿠폰의 유일한 줄을 먼저 가져가지 않게 한다
627
785
  const productIndexes = passed.filter((index) => input.coupons[index]?.rule.kind === "PRODUCT");
@@ -650,14 +808,14 @@ export function quotePricing(input: PricingInput): PricingResult {
650
808
  continue;
651
809
  }
652
810
  chosen = coupon.lineId;
653
- best = couponDiscountOf(coupon.rule.benefit, lineOf(chosen)?.line.amount ?? 0, rules);
811
+ best = productDiscountOn(coupon, chosen);
654
812
  } else {
655
813
  if (candidates.length === 0) {
656
814
  result.rejectReason = "STACK_LIMIT";
657
815
  continue;
658
816
  }
659
817
  for (const id of candidates) {
660
- const discount = couponDiscountOf(coupon.rule.benefit, lineOf(id)?.line.amount ?? 0, rules);
818
+ const discount = productDiscountOn(coupon, id);
661
819
  if (chosen === null || discount > best) {
662
820
  chosen = id;
663
821
  best = discount;
@@ -666,13 +824,13 @@ export function quotePricing(input: PricingInput): PricingResult {
666
824
  }
667
825
  const entry = chosen === null ? undefined : lineOf(chosen);
668
826
  if (!entry || chosen === null || best <= 0) {
669
- result.rejectReason = "NO_DISCOUNT";
827
+ result.rejectReason = zeroReasonOf(coupon, entry?.line.amount ?? 0);
670
828
  continue;
671
829
  }
672
830
  takenLines.add(chosen);
673
831
  entry.productDiscount += best;
674
832
  entry.allocations.push({ couponKey: result.couponKey, amount: best });
675
- apply(index, best, chosen);
833
+ apply(index, best, chosen, entry.line.amount);
676
834
  }
677
835
 
678
836
  // 3) 주문 쿠폰 — 상품 쿠폰 차감 뒤 금액 기준, 대상 줄에 금액 비율로 배분
@@ -692,9 +850,19 @@ export function quotePricing(input: PricingInput): PricingResult {
692
850
  .filter((entry): entry is (typeof lines)[number] => entry !== undefined);
693
851
  const weights = targets.map((entry) => Math.max(0, entry.line.amount - entry.productDiscount));
694
852
  const base = weights.reduce((sum, w) => sum + w, 0);
695
- const discount = couponDiscountOf(coupon.rule.benefit, base, rules);
853
+ // 적립금 지급은 할인 없이 자리를 차지하고, 적립금은 실결제가 정해진 뒤(5 뒤) 계산한다
854
+ if (coupon.rule.reward?.type === "POINT") {
855
+ if (base <= 0) {
856
+ result.rejectReason = "NO_DISCOUNT";
857
+ continue;
858
+ }
859
+ orderSlot = true;
860
+ apply(index, 0, null);
861
+ continue;
862
+ }
863
+ const discount = couponRuleDiscountOf(coupon.rule, base, null, rules);
696
864
  if (discount <= 0) {
697
- result.rejectReason = "NO_DISCOUNT";
865
+ result.rejectReason = zeroReasonOf(coupon, base);
698
866
  continue;
699
867
  }
700
868
  const shares = allocateByWeight(discount, weights);
@@ -703,7 +871,7 @@ export function quotePricing(input: PricingInput): PricingResult {
703
871
  if (share > 0) entry.allocations.push({ couponKey: result.couponKey, amount: share });
704
872
  });
705
873
  orderSlot = true;
706
- apply(index, discount, null);
874
+ apply(index, discount, null, base);
707
875
  }
708
876
 
709
877
  // 4) 배송비 쿠폰 — 기본 배송비(지역 추가 제외) 기준, 묶음에 기본 배송비 비율로 배분
@@ -727,7 +895,7 @@ export function quotePricing(input: PricingInput): PricingResult {
727
895
  if (notCombinable(index)) continue;
728
896
  const weights = deliveryBundles.map((bundle) => Math.max(0, bundle.fee));
729
897
  const base = weights.reduce((sum, w) => sum + w, 0);
730
- const discount = couponDiscountOf(coupon.rule.benefit, base, rules);
898
+ const discount = couponRuleDiscountOf(coupon.rule, base, null, rules);
731
899
  if (discount <= 0) {
732
900
  result.rejectReason = "NO_DISCOUNT";
733
901
  continue;
@@ -794,10 +962,24 @@ export function quotePricing(input: PricingInput): PricingResult {
794
962
  });
795
963
  }
796
964
  }
965
+ // 6) 적립금 지급 쿠폰 — 대상 줄의 실결제(쿠폰·사용 적립금 뒤)로 계산한다. 구매확정 지급도 같은 함수다
966
+ let rewardPointAmount = 0;
967
+ input.coupons.forEach((coupon, index) => {
968
+ const result = results[index] as PricingCouponResult;
969
+ const reward = coupon.rule.reward;
970
+ if (!result.applied || reward?.type !== "POINT") return;
971
+ const paid = result.eligibleLineIds.map((id) => {
972
+ const line = lineResults[lineIndex.get(id) ?? -1];
973
+ return line ? line.amount - line.couponDiscount - line.pointAllocation : 0;
974
+ });
975
+ result.rewardPoint = couponPointRewardOf(reward, paid, rules).total;
976
+ rewardPointAmount += result.rewardPoint;
977
+ });
797
978
  return {
798
979
  lines: lineResults,
799
980
  coupons: results,
800
981
  bundles,
982
+ rewardPointAmount,
801
983
  productAmount,
802
984
  couponDiscountAmount,
803
985
  deliveryFee,
@@ -927,7 +1109,7 @@ export interface CouponSettlementEntry {
927
1109
  allocatedRemaining: number;
928
1110
  /** 남은 주문상품으로 다시 계산한 할인 — 쿠폰 조건을 잃었으면 0 */
929
1111
  recomputed: number;
930
- /** 누적 미달분 목표 = 조건을 잃었으면 `allocatedRemaining`, 아니면 0 */
1112
+ /** 누적 미달분 목표 = 조건을 잃었으면 `allocatedRemaining`, 차액 정산 쿠폰은 `allocatedRemaining − recomputed`(음수면 더 돌려준다), 아니면 0 */
931
1113
  target: number;
932
1114
  charge: number;
933
1115
  refund: number;
@@ -949,6 +1131,47 @@ export const NO_COUPON_SETTLEMENT: CouponSettlementResult = {
949
1131
  entries: [],
950
1132
  };
951
1133
 
1134
+ /**
1135
+ * 주문이 받은 쿠폰을 수량 `quantityOf(줄)`로 다시 계산한다 — 환불 정산(`settleCouponShortfall`)과 적립금 지급 쿠폰의 구매확정 적립이 같이 쓴다.
1136
+ * 기간·회원 전용은 결제 시작에서 이미 통과했다. 사용 조건 트리는 주문 시점 사실 값 + 남은 금액으로 다시 보고,
1137
+ * 스냅샷에 없는 구매자·시각 값(옛 주문)은 통과한 것으로 둔다 — 주문서 집계(`cart.*`)만 남은 금액으로 다시 판정한다
1138
+ */
1139
+ export function recomputeOrderCoupons(
1140
+ coupons: readonly AppliedCouponSnapshot[],
1141
+ lines: readonly CouponSettlementLine[],
1142
+ quantityOf: (line: CouponSettlementLine) => number = (line) => line.activeQuantity,
1143
+ ): PricingResult {
1144
+ return quotePricing({
1145
+ lines: lines.map((line) => {
1146
+ const quantity = quantityOf(line);
1147
+ const amount = proratedRefundBase(line.totalPrice, line.quantity, 0, quantity);
1148
+ // 대상은 주문 시점 목록으로 고정하므로 즉시할인 판정에 쓰는 정가는 금액과 같게 둔다
1149
+ return {
1150
+ lineId: line.lineId,
1151
+ productId: line.productId,
1152
+ quantity,
1153
+ baseAmount: amount,
1154
+ amount,
1155
+ };
1156
+ }),
1157
+ coupons: coupons.map((coupon) => {
1158
+ const facts = coupon.facts ?? {};
1159
+ const eligibility = assumeCouponFactRules(couponTreesOf(coupon.rule).eligibility, facts);
1160
+ return {
1161
+ rule: { ...coupon.rule, eligibility },
1162
+ couponKey: coupon.couponKey,
1163
+ lineId: coupon.lineId,
1164
+ eligibleLineIds: coupon.eligibleLineIds,
1165
+ facts,
1166
+ };
1167
+ }),
1168
+ delivery: null,
1169
+ isMember: true,
1170
+ now: new Date(0), // ignoreEligibility라 기간을 보지 않는다. 시각 조건은 스냅샷 값이거나 통과로 둔다
1171
+ ignoreEligibility: true,
1172
+ });
1173
+ }
1174
+
952
1175
  /**
953
1176
  * 쿠폰 조건 미달 차감 — **순수 함수다.** 부분 취소·반품으로 남은 주문상품이 쿠폰 조건을 잃으면,
954
1177
  * 남은 주문상품이 받던 할인만큼을 이번 환불에서 뺀다.
@@ -959,6 +1182,9 @@ export const NO_COUPON_SETTLEMENT: CouponSettlementResult = {
959
1182
  * - 쿠폰이 **여전히 적용되면 목표 0**, 조건을 잃었으면 목표 = `allocatedRemaining`이다. 적용되는 쿠폰의
960
1183
  * 정률 10원 내림 차이까지 차감하면 조건을 지킨 구매자가 몇 원씩 잃는다 — 조건을 잃은 경우만 본다.
961
1184
  * 재계산 할인이 더 커지는 경우(정률 최대 할인액)도 차감하지 않고 더 돌려주지도 않는다.
1185
+ * - 확장 혜택(구간 할인·N+1)에서 셀러가 차액 정산(`reward.shortfallCharge`)을 켰으면 적용이 유지돼도 목표가
1186
+ * `allocatedRemaining − recomputed`다. 구간 할인은 구간이 내려갔을 때만 보고 0 아래로 내리지 않는다. N+1만 음수 목표를 허용한다
1187
+ * (남은 수량의 할인 개수가 배분보다 많으면 그만큼 더 돌려주고 누적 차감이 음수로 남는다). 뒤의 환불이 그 몫을 되찾을 때는 판매자 귀책·정책 꺼짐이어도 면제하지 않는다.
962
1188
  * - `delta` = 목표 − (이미 차감 + 이미 면제). 양수면 청구하고(판매자 귀책·정책 꺼짐이면 면제로 기록),
963
1189
  * 음수면 앞서 뺀 차감부터 돌려준다. 마지막 활성 수량까지 취소되면 목표가 0이라 누적 차감이 모두 되돌아가고,
964
1190
  * 전량 취소의 환불 합계가 결제액과 같아진다.
@@ -972,37 +1198,13 @@ export function settleCouponShortfall(input: CouponSettlementInput): CouponSettl
972
1198
  const coupons = input.coupons.filter((coupon) => coupon.rule.kind !== "DELIVERY");
973
1199
  if (coupons.length === 0) return NO_COUPON_SETTLEMENT;
974
1200
 
975
- const pricingLines: PricingLine[] = input.lines.map((line) => {
976
- const remaining = proratedRefundBase(line.totalPrice, line.quantity, 0, line.activeQuantity);
977
- // 대상은 주문 시점 목록으로 고정하므로 즉시할인 판정에 쓰는 정가는 금액과 같게 둔다
978
- return {
979
- lineId: line.lineId,
980
- productId: line.productId,
981
- quantity: line.activeQuantity,
982
- baseAmount: remaining,
983
- amount: remaining,
984
- };
985
- });
986
- const recomputed = quotePricing({
987
- lines: pricingLines,
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
- }),
1001
- delivery: null,
1002
- isMember: true,
1003
- now: new Date(0), // ignoreEligibility라 기간을 보지 않는다. 시각 조건은 스냅샷 값이거나 통과로 둔다
1004
- ignoreEligibility: true,
1005
- });
1201
+ const recompute = (quantityOf: (line: CouponSettlementLine) => number) =>
1202
+ recomputeOrderCoupons(coupons, input.lines, quantityOf);
1203
+ const recomputed = recompute((line) => line.activeQuantity);
1204
+ // 차액 차감(확장 혜택) — 구간 할인은 구간이 내려갔을 때만 본다(같은 구간의 정률 내림 차이로 몇 원씩 빼지 않는다)
1205
+ const original = coupons.some((coupon) => coupon.rule.reward?.type === "TIERED")
1206
+ ? recompute((line) => line.quantity)
1207
+ : null;
1006
1208
 
1007
1209
  const base = coupons.map((coupon, index) => {
1008
1210
  const allocatedRemaining = input.lines.reduce((sum, line) => {
@@ -1013,13 +1215,30 @@ export function settleCouponShortfall(input: CouponSettlementInput): CouponSettl
1013
1215
  }, 0);
1014
1216
  const result = recomputed.coupons[index];
1015
1217
  const stillApplied = result?.applied === true;
1218
+ const recomputedDiscount = stillApplied ? (result?.discount ?? 0) : 0;
1016
1219
  const state = input.settlements[coupon.couponKey] ?? {};
1220
+ const reward = coupon.rule.reward;
1221
+ // 차액 정산 쿠폰은 목표가 음수일 수 있다(남은 수량의 할인이 배분보다 크면 그만큼 더 돌려준다 — N+1 홀수 잔량)
1222
+ // 차액 정산 — 구간 할인은 구간이 내려갔을 때만, 0 아래로는 내리지 않는다(구간이 내려가 더 돌려주는 일은 없다).
1223
+ // N+1은 할인 개수가 정수라 남은 수량의 할인이 배분보다 클 수 있다 — 그 차이만 음수 목표(더 돌려줌)다
1224
+ const tiered =
1225
+ reward?.type === "TIERED" &&
1226
+ reward.shortfallCharge &&
1227
+ (result?.tierIndex ?? -1) < (original?.coupons[index]?.tierIndex ?? -1);
1228
+ const signed = reward?.type === "BUY_X_GET_Y" && reward.shortfallCharge;
1017
1229
  return {
1018
1230
  couponKey: coupon.couponKey,
1019
1231
  allocatedRemaining,
1020
- recomputed: stillApplied ? (result?.discount ?? 0) : 0,
1021
- target: stillApplied ? 0 : allocatedRemaining,
1022
- charged: Math.max(0, state.charged ?? 0),
1232
+ recomputed: recomputedDiscount,
1233
+ target: !stillApplied
1234
+ ? allocatedRemaining
1235
+ : signed
1236
+ ? allocatedRemaining - recomputedDiscount
1237
+ : tiered
1238
+ ? Math.max(0, allocatedRemaining - recomputedDiscount)
1239
+ : 0,
1240
+ signed,
1241
+ charged: signed ? (state.charged ?? 0) : Math.max(0, state.charged ?? 0),
1023
1242
  waived: Math.max(0, state.waived ?? 0),
1024
1243
  };
1025
1244
  });
@@ -1032,9 +1251,13 @@ export function settleCouponShortfall(input: CouponSettlementInput): CouponSettl
1032
1251
  let refund = 0;
1033
1252
  let waived = item.waived;
1034
1253
  if (delta < 0) {
1035
- // 구매자에게 유리하게 앞서 뺀 차감부터 돌려주고, 남은 초과분은 면제 기록에서 지운다
1036
- refund = Math.min(item.charged, -delta);
1037
- waived = Math.max(0, waived - (-delta - refund));
1254
+ // 구매자에게 유리하게 앞서 뺀 차감부터 돌려주고, 남은 초과분은 면제 기록에서 지운다.
1255
+ // 차액 정산 쿠폰은 그래도 남으면 더 돌려준다(누적 차감이 음수가 되고, 뒤의 환불이 목표로 돌아오며 다시 맞춘다)
1256
+ refund = Math.min(Math.max(0, item.charged), -delta);
1257
+ const rest = -delta - refund;
1258
+ const unwaived = Math.min(waived, rest);
1259
+ waived -= unwaived;
1260
+ if (item.signed) refund += rest - unwaived;
1038
1261
  }
1039
1262
  refundAmount += refund;
1040
1263
  entries.push({
@@ -1053,8 +1276,22 @@ export function settleCouponShortfall(input: CouponSettlementInput): CouponSettl
1053
1276
  let chargeAmount = 0;
1054
1277
  const chargeAllowed = input.shortfallCharge && !input.sellerFault;
1055
1278
  for (const entry of entries) {
1056
- const delta = entry.target - (entry.next.charged + entry.next.waived);
1279
+ let delta = entry.target - (entry.next.charged + entry.next.waived);
1057
1280
  if (delta <= 0) continue;
1281
+ // 앞서 더 돌려준 몫(음수 누적)을 되찾는 것은 차감이 아니다 — 판매자 귀책·정책 꺼짐에도 면제하지 않는다.
1282
+ // 면제로 적으면 환불 합계가 결제액을 넘는다
1283
+ const owed = Math.max(0, -entry.next.charged);
1284
+ if (owed > 0) {
1285
+ const reclaim = Math.min(delta, owed, capacity);
1286
+ if (reclaim > 0) {
1287
+ capacity -= reclaim;
1288
+ chargeAmount += reclaim;
1289
+ entry.charge += reclaim;
1290
+ entry.next = { ...entry.next, charged: entry.next.charged + reclaim };
1291
+ }
1292
+ delta -= Math.min(delta, owed);
1293
+ if (delta <= 0) continue;
1294
+ }
1058
1295
  if (!chargeAllowed) {
1059
1296
  entry.waive = delta;
1060
1297
  entry.next = { ...entry.next, waived: entry.next.waived + delta };
@@ -1064,7 +1301,7 @@ export function settleCouponShortfall(input: CouponSettlementInput): CouponSettl
1064
1301
  if (charge <= 0) continue;
1065
1302
  capacity -= charge;
1066
1303
  chargeAmount += charge;
1067
- entry.charge = charge;
1304
+ entry.charge += charge;
1068
1305
  entry.next = { ...entry.next, charged: entry.next.charged + charge };
1069
1306
  }
1070
1307
  return { chargeAmount, refundAmount, entries };