@sayren/store-sdk 0.16.0 → 0.17.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,806 @@
1
+ import { z } from "zod";
2
+ import { proratedRefundBase } from "./refund-policy";
3
+
4
+ /**
5
+ * 쿠폰 금액 계산 (이슈 #47) — 쿠폰 할인을 **얼마나, 어느 주문상품에** 주는지를 정한다.
6
+ *
7
+ * 계산은 순수 함수 `quotePricing` 하나다. 장바구니 요약, 주문서 미리보기, 결제 시작의 최종 확정,
8
+ * 주문 스냅샷, 환불 정산의 조건 미달 판정(`settleCouponShortfall`)이 모두 이 함수를 지난다.
9
+ * 다섯 경로가 각자 계산하면 화면 금액, 결제 금액, 환불 금액이 갈라진다(`quoteDelivery`와 같은 원칙).
10
+ *
11
+ * 계산 순서
12
+ * 1. 즉시할인(`products.discount`)은 이미 단가에 들어 있다. 입력 `amount`가 즉시할인 반영 금액이다.
13
+ * 2. 상품 쿠폰(`PRODUCT`) — 주문상품 한 줄에 한 장. 그 줄 금액을 기준으로 할인한다.
14
+ * 3. 주문 쿠폰(`ORDER`) — 주문에 한 장. **상품 쿠폰 차감 뒤 금액**의 대상 합계를 기준으로 할인하고,
15
+ * 대상 줄에 금액 비율로 나눈다(누적 내림의 차분).
16
+ * 4. 배송비 쿠폰(`DELIVERY`) — 주문에 한 장. 기본 배송비(지역 추가 배송비 제외)를 기준으로 할인하고
17
+ * 배송 묶음에 기본 배송비 비율로 나눈다.
18
+ *
19
+ * 무료배송 기준(상품별 조건부 무료·스토어 전체 기준)은 **쿠폰 차감 전 금액**으로 판정한다. 그래서 배송비
20
+ * 견적(`quoteDelivery`)은 쿠폰과 무관하게 먼저 계산되고, 이 함수는 그 결과만 입력으로 받는다.
21
+ *
22
+ * 최소 주문 금액은 대상 상품의 **즉시할인 반영 합계**(쿠폰 차감 전, 배송비 제외)로 판정한다.
23
+ * 정률 할인은 10원 단위로 내리고(`discountedPriceOf`의 즉시할인과 같은 단위), 최대 할인액과 기준 금액을
24
+ * 넘지 않는다.
25
+ */
26
+
27
+ const descriptions = {
28
+ kind:
29
+ "쿠폰 종류. `ORDER`=주문 쿠폰(대상 상품 합계에서 할인), `PRODUCT`=상품 쿠폰(주문상품 한 줄에서 할인), " +
30
+ "`DELIVERY`=배송비 쿠폰(기본 배송비에서 할인, 지역 추가 배송비 제외)",
31
+ benefitType: "할인 방식. `RATE`=정률(%), `AMOUNT`=정액(원)",
32
+ benefitValue: "할인 값. `RATE`면 1~100(%), `AMOUNT`면 원 단위 금액",
33
+ maxDiscountAmount:
34
+ "정률 할인의 최대 할인액(원). null이면 상한이 없다. 정액 할인에는 쓰지 않는다(null)",
35
+ minOrderAmount:
36
+ "최소 주문 금액(원). 대상 상품의 즉시할인 반영 합계(배송비 제외)가 이 값 이상이어야 쓸 수 있다. 0이면 조건이 없다",
37
+ firstOrderOnly: "이 스토어에서 결제 완료 주문이 없는 회원만 쓸 수 있다",
38
+ memberOnly:
39
+ "회원만 쓸 수 있다. false면 비회원도 쓸 수 있다 — 코드 입력형이면서 1인 한도가 없는 쿠폰만 false로 둘 수 있다",
40
+ stackWithInstantDiscount:
41
+ "상품 즉시할인과 함께 쓸 수 있다. false면 즉시할인이 걸린 주문상품은 쿠폰 대상에서 빠진다",
42
+ includeProductIds: "대상 상품 id. 포함 목록(상품·카테고리)이 모두 비면 전 상품이 대상이다",
43
+ includeCategoryIds: "대상 카테고리 id. 하위 카테고리의 상품도 대상이다",
44
+ excludeProductIds: "제외 상품 id. 포함보다 우선한다",
45
+ excludeCategoryIds: "제외 카테고리 id. 하위 카테고리의 상품도 제외한다. 포함보다 우선한다",
46
+ } as const;
47
+
48
+ /** 쿠폰 종류 */
49
+ export const couponKindSchema = z
50
+ .enum(["ORDER", "PRODUCT", "DELIVERY"])
51
+ .describe(descriptions.kind);
52
+ export type CouponKind = z.infer<typeof couponKindSchema>;
53
+
54
+ /** 쿠폰 혜택 — 정률은 1~100%, 최대 할인액을 둘 수 있다. 정액은 최대 할인액이 없다 */
55
+ export const couponBenefitSchema = z
56
+ .object({
57
+ type: z.enum(["RATE", "AMOUNT"]).describe(descriptions.benefitType),
58
+ value: z.number().int().min(1).describe(descriptions.benefitValue),
59
+ maxDiscountAmount: z.number().int().min(1).nullable().describe(descriptions.maxDiscountAmount),
60
+ })
61
+ .superRefine((benefit, ctx) => {
62
+ if (benefit.type === "RATE" && benefit.value > 100) {
63
+ ctx.addIssue({
64
+ code: "custom",
65
+ path: ["value"],
66
+ message: "정률 할인은 1~100(%)이어야 합니다",
67
+ });
68
+ }
69
+ if (benefit.type === "AMOUNT" && benefit.maxDiscountAmount !== null) {
70
+ ctx.addIssue({
71
+ code: "custom",
72
+ path: ["maxDiscountAmount"],
73
+ message: "정액 할인에는 최대 할인액을 두지 않습니다",
74
+ });
75
+ }
76
+ });
77
+ export type CouponBenefit = z.infer<typeof couponBenefitSchema>;
78
+
79
+ /** 쿠폰 사용 조건 */
80
+ export const couponConditionsSchema = z.object({
81
+ minOrderAmount: z.number().int().min(0).describe(descriptions.minOrderAmount),
82
+ firstOrderOnly: z.boolean().describe(descriptions.firstOrderOnly),
83
+ memberOnly: z.boolean().describe(descriptions.memberOnly),
84
+ stackWithInstantDiscount: z.boolean().describe(descriptions.stackWithInstantDiscount),
85
+ });
86
+ export type CouponConditions = z.infer<typeof couponConditionsSchema>;
87
+
88
+ /** 조건을 생략했을 때의 값 */
89
+ export const COUPON_CONDITIONS_DEFAULTS: CouponConditions = {
90
+ minOrderAmount: 0,
91
+ firstOrderOnly: false,
92
+ memberOnly: true,
93
+ stackWithInstantDiscount: true,
94
+ };
95
+
96
+ /** 상한 — 대상 목록은 규칙 스냅샷에 통째로 복사되므로 크기를 제한한다 */
97
+ export const COUPON_TARGET_LIST_MAX = 500;
98
+
99
+ /** 쿠폰 대상 — 포함 목록이 모두 비면 전 상품이다. 제외가 포함보다 우선한다 */
100
+ export const couponTargetSchema = z.object({
101
+ includeProductIds: z
102
+ .array(z.string().min(1))
103
+ .max(COUPON_TARGET_LIST_MAX)
104
+ .describe(descriptions.includeProductIds),
105
+ includeCategoryIds: z
106
+ .array(z.string().min(1))
107
+ .max(COUPON_TARGET_LIST_MAX)
108
+ .describe(descriptions.includeCategoryIds),
109
+ excludeProductIds: z
110
+ .array(z.string().min(1))
111
+ .max(COUPON_TARGET_LIST_MAX)
112
+ .describe(descriptions.excludeProductIds),
113
+ excludeCategoryIds: z
114
+ .array(z.string().min(1))
115
+ .max(COUPON_TARGET_LIST_MAX)
116
+ .describe(descriptions.excludeCategoryIds),
117
+ });
118
+ export type CouponTarget = z.infer<typeof couponTargetSchema>;
119
+
120
+ export const COUPON_TARGET_ALL: CouponTarget = {
121
+ includeProductIds: [],
122
+ includeCategoryIds: [],
123
+ excludeProductIds: [],
124
+ excludeCategoryIds: [],
125
+ };
126
+
127
+ /**
128
+ * 쿠폰 규칙 스냅샷 — 계산에 필요한 값만 담는다. 결제 시작이 쿠폰을 읽어 이 형태로 결제 세션에 굳히고,
129
+ * 주문 생성·환불 정산은 이 스냅샷만 읽는다. 결제 도중·주문 뒤 셀러가 쿠폰을 바꿔도 그 주문의 금액은 움직이지 않는다.
130
+ */
131
+ export const couponRuleSchema = z.object({
132
+ couponId: z.string().describe("쿠폰 id (`cpn_…`)"),
133
+ name: z.string().describe("쿠폰 이름"),
134
+ kind: couponKindSchema,
135
+ benefit: couponBenefitSchema,
136
+ conditions: couponConditionsSchema,
137
+ target: couponTargetSchema,
138
+ startsAt: z.iso
139
+ .datetime({ offset: true })
140
+ .nullable()
141
+ .describe("사용 시작 시각(ISO 8601, 시간대 포함). null이면 제한이 없다"),
142
+ endsAt: z.iso
143
+ .datetime({ offset: true })
144
+ .nullable()
145
+ .describe("사용 종료 시각(ISO 8601, 시간대 포함, 이 시각부터 못 쓴다). null이면 제한이 없다"),
146
+ });
147
+ export type CouponRule = z.infer<typeof couponRuleSchema>;
148
+
149
+ /** 쿠폰을 적용하지 못한 사유 */
150
+ export const couponRejectReasonSchema = z
151
+ .enum([
152
+ "DUPLICATE_COUPON",
153
+ "STACK_LIMIT",
154
+ "NOT_STARTED",
155
+ "EXPIRED",
156
+ "MEMBER_ONLY",
157
+ "FIRST_ORDER_ONLY",
158
+ "NO_ELIGIBLE_ITEMS",
159
+ "MIN_ORDER_AMOUNT",
160
+ "LINE_NOT_ELIGIBLE",
161
+ "NO_DISCOUNT",
162
+ ])
163
+ .describe(
164
+ "쿠폰을 적용하지 못한 사유. `DUPLICATE_COUPON`=같은 쿠폰을 두 번 넣음, " +
165
+ "`STACK_LIMIT`=같은 종류의 쿠폰이 이미 적용됨(주문 쿠폰·배송비 쿠폰은 주문당 1장, 상품 쿠폰은 주문상품 한 줄에 1장), " +
166
+ "`NOT_STARTED`=사용 기간 전, `EXPIRED`=사용 기간 지남, `MEMBER_ONLY`=회원 전용, `FIRST_ORDER_ONLY`=첫 구매 전용, " +
167
+ "`NO_ELIGIBLE_ITEMS`=대상 상품이 주문에 없음, `MIN_ORDER_AMOUNT`=최소 주문 금액 미달, " +
168
+ "`LINE_NOT_ELIGIBLE`=지정한 주문상품이 대상이 아니거나 이미 상품 쿠폰이 있음, `NO_DISCOUNT`=할인할 금액이 없음",
169
+ );
170
+ export type CouponRejectReason = z.infer<typeof couponRejectReasonSchema>;
171
+
172
+ /** 쿠폰 적용 뒤 결제 금액의 하한 — 0원 결제 경로가 없어 이보다 작으면 결제를 시작하지 않는다(400 `PAYMENT_AMOUNT_TOO_LOW`) */
173
+ export const MIN_PAYMENT_AMOUNT = 100;
174
+
175
+ // ─── 계산 ─────────────────────────────────────────────
176
+
177
+ /** 계산에 넣는 주문상품 한 줄 */
178
+ export interface PricingLine {
179
+ /** 줄 식별자 — 주문서는 항목 순번, 주문은 주문상품 id. 배분 결과가 이 키로 나온다 */
180
+ lineId: string;
181
+ productId: string;
182
+ /** 상품 카테고리 경로(`/조상/…/자신/`). 없으면 카테고리 대상에 걸리지 않는다 */
183
+ categoryPath?: string | null;
184
+ /** 수량 — 계산에는 쓰지 않는다(금액이 이미 수량을 반영한다). 화면·로그용이다 */
185
+ quantity: number;
186
+ /** 즉시할인 전 금액 (정가 단가 × 수량) */
187
+ baseAmount: number;
188
+ /** 즉시할인 반영 금액 (할인 단가 × 수량) — 쿠폰 계산의 기준이다 */
189
+ amount: number;
190
+ }
191
+
192
+ /** 계산에 넣는 쿠폰 한 장 */
193
+ export interface PricingCoupon {
194
+ rule: CouponRule;
195
+ /** 결과·배분의 키. 생략하면 `rule.couponId` */
196
+ couponKey?: string;
197
+ /**
198
+ * 상품 쿠폰을 걸 줄. 생략하면 계산이 고른다 — 줄을 지정한 쿠폰을 먼저 걸고, 나머지는 대상 줄이 적은 쿠폰부터
199
+ * 할인이 가장 큰 남은 줄(같으면 앞 줄)에 건다
200
+ */
201
+ lineId?: string | null;
202
+ /**
203
+ * 대상 줄을 이미 정했으면 그 목록 — 환불 정산의 재계산이 쓴다. 주어지면 `target`·즉시할인 조건을 다시
204
+ * 판정하지 않는다(주문 뒤 상품의 카테고리·즉시할인이 바뀌어도 주문 시점 대상이 그대로다).
205
+ */
206
+ eligibleLineIds?: readonly string[] | null;
207
+ /** 발급 쿠폰의 유효 시작·종료 — 쿠폰 사용 기간과 둘 중 좁은 쪽이 적용된다 */
208
+ validFrom?: string | null;
209
+ validUntil?: string | null;
210
+ }
211
+
212
+ /**
213
+ * 배송비 쿠폰이 보는 배송비 견적 — `quoteDelivery` 결과의 묶음 내역이다. 배송비 총액은 묶음의
214
+ * `fee + remoteSurcharge` 합으로 계산한다(총액을 따로 받으면 묶음과 어긋날 수 있다).
215
+ */
216
+ export interface PricingDelivery {
217
+ bundles: ReadonlyArray<{ bundleKey: string; fee: number; remoteSurcharge: number }>;
218
+ }
219
+
220
+ export interface PricingInput {
221
+ lines: readonly PricingLine[];
222
+ coupons: readonly PricingCoupon[];
223
+ /** 배송비 견적. 없으면 배송비 0으로 계산하고 배송비 쿠폰은 할인할 금액이 없다 */
224
+ delivery?: PricingDelivery | null;
225
+ /** 이 스토어에서 결제 완료 주문이 없는 회원인가 */
226
+ firstOrder: boolean;
227
+ /** 회원 주문인가 */
228
+ isMember: boolean;
229
+ /** 사용 기간 판정 시각 */
230
+ now: Date;
231
+ /**
232
+ * 사용 기간·회원·첫 구매 조건을 다시 보지 않는다 — **환불 정산의 재계산 전용이다.** 그 조건은 결제 시작에서
233
+ * 이미 통과했다. 주문 시각(승인 시각)으로 기간을 다시 보면 결제 시작과 승인 사이에 끝난 쿠폰이 조건을 잃은
234
+ * 것처럼 보이고, 탈퇴한 회원의 반품이 회원 전용 조건에 떨어진다. 대상과 최소 금액만 본다.
235
+ */
236
+ ignoreEligibility?: boolean;
237
+ }
238
+
239
+ /** 줄 하나에 들어간 쿠폰별 할인 */
240
+ export interface CouponAllocation {
241
+ couponKey: string;
242
+ amount: number;
243
+ }
244
+
245
+ export interface PricingLineResult {
246
+ lineId: string;
247
+ /** 즉시할인 반영 금액 (쿠폰 전) */
248
+ amount: number;
249
+ /** 이 줄에 들어간 쿠폰 할인 합계 — 상품 쿠폰 + 주문 쿠폰 배분 */
250
+ couponDiscount: number;
251
+ allocations: CouponAllocation[];
252
+ }
253
+
254
+ export interface PricingCouponResult {
255
+ couponKey: string;
256
+ couponId: string;
257
+ kind: CouponKind;
258
+ applied: boolean;
259
+ /** 이 쿠폰의 할인액. 적용하지 못했으면 0 */
260
+ discount: number;
261
+ rejectReason: CouponRejectReason | null;
262
+ /** 상품 쿠폰이 걸린 줄. 그 밖은 null */
263
+ lineId: string | null;
264
+ /** 이 쿠폰의 대상 줄 — 판정에 쓴 순서 그대로다. 환불 정산이 주문 시점 대상을 되살리는 데 쓴다 */
265
+ eligibleLineIds: string[];
266
+ }
267
+
268
+ export interface PricingBundleResult {
269
+ bundleKey: string;
270
+ couponDiscount: number;
271
+ }
272
+
273
+ export interface PricingResult {
274
+ lines: PricingLineResult[];
275
+ coupons: PricingCouponResult[];
276
+ /** 배송비 쿠폰의 묶음별 배분 */
277
+ bundles: PricingBundleResult[];
278
+ /** 상품 금액 합 (즉시할인 반영, 쿠폰 전) */
279
+ productAmount: number;
280
+ /** 상품·주문 쿠폰 할인 합계 */
281
+ couponDiscountAmount: number;
282
+ /** 배송비 (쿠폰 전) */
283
+ deliveryFee: number;
284
+ /** 배송비 쿠폰 할인 */
285
+ deliveryDiscountAmount: number;
286
+ /** 결제 금액 = productAmount − couponDiscountAmount + deliveryFee − deliveryDiscountAmount */
287
+ totalAmount: number;
288
+ }
289
+
290
+ /**
291
+ * 할인액 — 정률은 10원 단위 내림, 최대 할인액과 기준 금액을 넘지 않는다. 기준 금액이 0 이하면 0이다.
292
+ *
293
+ * 곱셈을 먼저 하고 나눈다. `base × 율 ÷ 1000`을 내려 10을 곱하면 `floor(base × 율 / 100 / 10) × 10`과 같고
294
+ * 부동소수 오차가 끼지 않는다.
295
+ */
296
+ export function couponDiscountOf(benefit: CouponBenefit, base: number): number {
297
+ if (base <= 0) return 0;
298
+ let raw =
299
+ benefit.type === "RATE" ? Math.floor((base * benefit.value) / 1000) * 10 : benefit.value;
300
+ if (benefit.type === "RATE" && benefit.maxDiscountAmount !== null) {
301
+ raw = Math.min(raw, benefit.maxDiscountAmount);
302
+ }
303
+ return Math.max(0, Math.min(raw, base));
304
+ }
305
+
306
+ /**
307
+ * 금액을 가중치 비율로 나눈다 — **누적 내림의 차분**이다.
308
+ *
309
+ * `i`번째 몫 = `floor(total × W_i / W) − floor(total × W_{i−1} / W)`(`W_i`는 앞에서부터 누적 가중치).
310
+ * 합이 `total`과 정확히 같고, 입력 순서가 같으면 결과도 같다. `total ≤ W`이면 몫이 가중치를 넘지 않는다.
311
+ * 곱이 2^53을 넘을 수 있어 BigInt로 계산한다.
312
+ */
313
+ export function allocateByWeight(total: number, weights: readonly number[]): number[] {
314
+ if (!Number.isSafeInteger(total) || !weights.every((w) => Number.isSafeInteger(w))) {
315
+ throw new RangeError("allocateByWeight: 금액과 가중치는 안전한 정수여야 합니다");
316
+ }
317
+ const sum = weights.reduce((acc, w) => acc + Math.max(0, w), 0);
318
+ if (total <= 0 || sum <= 0) return weights.map(() => 0);
319
+ const bigTotal = BigInt(total);
320
+ const bigSum = BigInt(sum);
321
+ let cumulative = 0n;
322
+ let previous = 0n;
323
+ return weights.map((weight) => {
324
+ cumulative += BigInt(Math.max(0, weight));
325
+ const upTo = (bigTotal * cumulative) / bigSum;
326
+ const share = upTo - previous;
327
+ previous = upTo;
328
+ return Number(share);
329
+ });
330
+ }
331
+
332
+ /** 카테고리 경로(`/a/b/c/`)가 그 카테고리 또는 하위를 가리키는가 */
333
+ function inCategory(categoryPath: string | null | undefined, categoryIds: readonly string[]) {
334
+ if (!categoryPath || categoryIds.length === 0) return false;
335
+ const chain = categoryPath.split("/").filter(Boolean);
336
+ return categoryIds.some((id) => chain.includes(id));
337
+ }
338
+
339
+ /** 줄이 쿠폰 대상인가 — 제외가 포함보다 우선하고, 포함 목록이 모두 비면 전 상품이다 */
340
+ export function isCouponTargetLine(
341
+ rule: Pick<CouponRule, "target" | "conditions">,
342
+ line: Pick<PricingLine, "productId" | "categoryPath" | "baseAmount" | "amount">,
343
+ ): boolean {
344
+ const { target, conditions } = rule;
345
+ if (!conditions.stackWithInstantDiscount && line.amount < line.baseAmount) return false;
346
+ if (target.excludeProductIds.includes(line.productId)) return false;
347
+ if (inCategory(line.categoryPath, target.excludeCategoryIds)) return false;
348
+ const noInclude = target.includeProductIds.length === 0 && target.includeCategoryIds.length === 0;
349
+ if (noInclude) return true;
350
+ return (
351
+ target.includeProductIds.includes(line.productId) ||
352
+ inCategory(line.categoryPath, target.includeCategoryIds)
353
+ );
354
+ }
355
+
356
+ /** 시각 파싱 — 값이 없으면 null(제한 없음), 읽지 못하면 NaN(판정에서 거절한다) */
357
+ function timeOf(value: string | null | undefined): number | null {
358
+ if (!value) return null;
359
+ return Date.parse(value);
360
+ }
361
+
362
+ /**
363
+ * 사용 기간 판정 — 쿠폰 기간과 발급 쿠폰 유효 기간 중 좁은 쪽. 시작은 포함, 종료는 제외.
364
+ * 읽지 못하는 시각은 제한 없음이 아니라 거절이다(fail-closed).
365
+ */
366
+ function periodRejectOf(coupon: PricingCoupon, now: Date): CouponRejectReason | null {
367
+ const at = now.getTime();
368
+ const starts = [timeOf(coupon.rule.startsAt), timeOf(coupon.validFrom)].filter(
369
+ (t): t is number => t !== null,
370
+ );
371
+ const ends = [timeOf(coupon.rule.endsAt), timeOf(coupon.validUntil)].filter(
372
+ (t): t is number => t !== null,
373
+ );
374
+ if (starts.some((t) => Number.isNaN(t) || at < t)) return "NOT_STARTED";
375
+ if (ends.some((t) => Number.isNaN(t) || at >= t)) return "EXPIRED";
376
+ return null;
377
+ }
378
+
379
+ /**
380
+ * 쿠폰 금액 계산 — **순수 함수다.** DB를 읽지 않는다. 규칙 스냅샷을 만드는 조회와 한도·보유 판정
381
+ * (발급 쿠폰 상태, 1인 한도)은 호출부 몫이다.
382
+ *
383
+ * 종류별로 상품 쿠폰 → 주문 쿠폰 → 배송비 쿠폰 순으로 적용한다. 같은 종류의 자리와 같은 쿠폰 id는
384
+ * **적용된** 쿠폰만 차지한다 — 앞선 주문 쿠폰이 최소 금액 미달이면 뒤의 주문 쿠폰이 쓰이고, 같은 쿠폰의 발급
385
+ * 사본 중 앞 장이 만료됐으면 뒤 장이 쓰인다. 결과의 `coupons`는 입력 순서 그대로다. 입력이 같으면 결과도 같다.
386
+ */
387
+ export function quotePricing(input: PricingInput): PricingResult {
388
+ const lines = input.lines.map((line) => ({
389
+ line,
390
+ productDiscount: 0,
391
+ allocations: [] as CouponAllocation[],
392
+ }));
393
+ const lineIndex = new Map(lines.map((entry, index) => [entry.line.lineId, index]));
394
+ const lineOf = (id: string) => {
395
+ const index = lineIndex.get(id);
396
+ return index === undefined ? undefined : lines[index];
397
+ };
398
+
399
+ const results: PricingCouponResult[] = input.coupons.map((coupon) => ({
400
+ couponKey: coupon.couponKey ?? coupon.rule.couponId,
401
+ couponId: coupon.rule.couponId,
402
+ kind: coupon.rule.kind,
403
+ applied: false,
404
+ discount: 0,
405
+ rejectReason: null,
406
+ lineId: null,
407
+ eligibleLineIds: [],
408
+ }));
409
+
410
+ // 1) 쿠폰마다 공통 조건 — 기간·회원·첫 구매·대상·최소 금액
411
+ const commonRejectOf = (
412
+ coupon: PricingCoupon,
413
+ result: PricingCouponResult,
414
+ ): CouponRejectReason | null => {
415
+ if (!input.ignoreEligibility) {
416
+ const period = periodRejectOf(coupon, input.now);
417
+ if (period) return period;
418
+ if (coupon.rule.conditions.memberOnly && !input.isMember) return "MEMBER_ONLY";
419
+ if (coupon.rule.conditions.firstOrderOnly && !input.firstOrder) return "FIRST_ORDER_ONLY";
420
+ }
421
+ const pinned = coupon.eligibleLineIds ? new Set(coupon.eligibleLineIds) : null;
422
+ const eligible = lines.filter((entry) =>
423
+ pinned ? pinned.has(entry.line.lineId) : isCouponTargetLine(coupon.rule, entry.line),
424
+ );
425
+ result.eligibleLineIds = eligible.map((entry) => entry.line.lineId);
426
+ if (eligible.length === 0) return "NO_ELIGIBLE_ITEMS";
427
+ const targetAmount = eligible.reduce((sum, entry) => sum + entry.line.amount, 0);
428
+ if (targetAmount < coupon.rule.conditions.minOrderAmount) return "MIN_ORDER_AMOUNT";
429
+ return null;
430
+ };
431
+ const passed: number[] = [];
432
+ input.coupons.forEach((coupon, index) => {
433
+ const result = results[index] as PricingCouponResult;
434
+ const reason = commonRejectOf(coupon, result);
435
+ if (reason) result.rejectReason = reason;
436
+ else passed.push(index);
437
+ });
438
+
439
+ /** 적용된 쿠폰 id — 같은 쿠폰 정의는 주문에 한 장 */
440
+ const appliedCouponIds = new Set<string>();
441
+ const apply = (index: number, discount: number, lineId: string | null) => {
442
+ const result = results[index] as PricingCouponResult;
443
+ result.applied = true;
444
+ result.discount = discount;
445
+ result.lineId = lineId;
446
+ appliedCouponIds.add(result.couponId);
447
+ };
448
+ const duplicate = (index: number) => {
449
+ const result = results[index] as PricingCouponResult;
450
+ if (!appliedCouponIds.has(result.couponId)) return false;
451
+ result.rejectReason = "DUPLICATE_COUPON";
452
+ return true;
453
+ };
454
+
455
+ // 2) 상품 쿠폰 — 한 줄에 한 장. 줄을 지정한 쿠폰을 먼저 걸고, 나머지는 대상 줄이 적은 쿠폰부터
456
+ // 할인이 가장 큰 남은 줄(같으면 앞 줄)에 건다. 대상이 넓은 쿠폰이 좁은 쿠폰의 유일한 줄을 먼저 가져가지 않게 한다
457
+ const productIndexes = passed.filter((index) => input.coupons[index]?.rule.kind === "PRODUCT");
458
+ const productOrder = [
459
+ ...productIndexes.filter((index) => input.coupons[index]?.lineId),
460
+ ...productIndexes
461
+ .filter((index) => !input.coupons[index]?.lineId)
462
+ .map((index) => ({ index, width: results[index]?.eligibleLineIds.length ?? 0 }))
463
+ .sort((a, b) => a.width - b.width || a.index - b.index)
464
+ .map((entry) => entry.index),
465
+ ];
466
+ const takenLines = new Set<string>();
467
+ for (const index of productOrder) {
468
+ const coupon = input.coupons[index] as PricingCoupon;
469
+ const result = results[index] as PricingCouponResult;
470
+ if (duplicate(index)) continue;
471
+ const candidates = result.eligibleLineIds.filter((id) => !takenLines.has(id));
472
+ let chosen: string | null = null;
473
+ let best = 0;
474
+ if (coupon.lineId) {
475
+ if (!candidates.includes(coupon.lineId)) {
476
+ result.rejectReason = result.eligibleLineIds.includes(coupon.lineId)
477
+ ? "STACK_LIMIT"
478
+ : "LINE_NOT_ELIGIBLE";
479
+ continue;
480
+ }
481
+ chosen = coupon.lineId;
482
+ best = couponDiscountOf(coupon.rule.benefit, lineOf(chosen)?.line.amount ?? 0);
483
+ } else {
484
+ if (candidates.length === 0) {
485
+ result.rejectReason = "STACK_LIMIT";
486
+ continue;
487
+ }
488
+ for (const id of candidates) {
489
+ const discount = couponDiscountOf(coupon.rule.benefit, lineOf(id)?.line.amount ?? 0);
490
+ if (chosen === null || discount > best) {
491
+ chosen = id;
492
+ best = discount;
493
+ }
494
+ }
495
+ }
496
+ const entry = chosen === null ? undefined : lineOf(chosen);
497
+ if (!entry || chosen === null || best <= 0) {
498
+ result.rejectReason = "NO_DISCOUNT";
499
+ continue;
500
+ }
501
+ takenLines.add(chosen);
502
+ entry.productDiscount += best;
503
+ entry.allocations.push({ couponKey: result.couponKey, amount: best });
504
+ apply(index, best, chosen);
505
+ }
506
+
507
+ // 3) 주문 쿠폰 — 상품 쿠폰 차감 뒤 금액 기준, 대상 줄에 금액 비율로 배분
508
+ let orderSlot = false;
509
+ for (const index of passed) {
510
+ const coupon = input.coupons[index] as PricingCoupon;
511
+ const result = results[index] as PricingCouponResult;
512
+ if (coupon.rule.kind !== "ORDER") continue;
513
+ if (duplicate(index)) continue;
514
+ if (orderSlot) {
515
+ result.rejectReason = "STACK_LIMIT";
516
+ continue;
517
+ }
518
+ const targets = result.eligibleLineIds
519
+ .map((id) => lineOf(id))
520
+ .filter((entry): entry is (typeof lines)[number] => entry !== undefined);
521
+ const weights = targets.map((entry) => Math.max(0, entry.line.amount - entry.productDiscount));
522
+ const base = weights.reduce((sum, w) => sum + w, 0);
523
+ const discount = couponDiscountOf(coupon.rule.benefit, base);
524
+ if (discount <= 0) {
525
+ result.rejectReason = "NO_DISCOUNT";
526
+ continue;
527
+ }
528
+ const shares = allocateByWeight(discount, weights);
529
+ targets.forEach((entry, i) => {
530
+ const share = shares[i] ?? 0;
531
+ if (share > 0) entry.allocations.push({ couponKey: result.couponKey, amount: share });
532
+ });
533
+ orderSlot = true;
534
+ apply(index, discount, null);
535
+ }
536
+
537
+ // 4) 배송비 쿠폰 — 기본 배송비(지역 추가 제외) 기준, 묶음에 기본 배송비 비율로 배분
538
+ const deliveryBundles = input.delivery?.bundles ?? [];
539
+ const bundles: PricingBundleResult[] = deliveryBundles.map((bundle) => ({
540
+ bundleKey: bundle.bundleKey,
541
+ couponDiscount: 0,
542
+ }));
543
+ let deliverySlot = false;
544
+ let deliveryDiscountAmount = 0;
545
+ for (const index of passed) {
546
+ const coupon = input.coupons[index] as PricingCoupon;
547
+ const result = results[index] as PricingCouponResult;
548
+ if (coupon.rule.kind !== "DELIVERY") continue;
549
+ if (duplicate(index)) continue;
550
+ if (deliverySlot) {
551
+ result.rejectReason = "STACK_LIMIT";
552
+ continue;
553
+ }
554
+ const weights = deliveryBundles.map((bundle) => Math.max(0, bundle.fee));
555
+ const base = weights.reduce((sum, w) => sum + w, 0);
556
+ const discount = couponDiscountOf(coupon.rule.benefit, base);
557
+ if (discount <= 0) {
558
+ result.rejectReason = "NO_DISCOUNT";
559
+ continue;
560
+ }
561
+ allocateByWeight(discount, weights).forEach((share, i) => {
562
+ const bundle = bundles[i];
563
+ if (bundle) bundle.couponDiscount += share;
564
+ });
565
+ deliverySlot = true;
566
+ deliveryDiscountAmount = discount;
567
+ apply(index, discount, null);
568
+ }
569
+
570
+ const lineResults: PricingLineResult[] = lines.map((entry) => ({
571
+ lineId: entry.line.lineId,
572
+ amount: entry.line.amount,
573
+ couponDiscount: entry.allocations.reduce((sum, a) => sum + a.amount, 0),
574
+ allocations: entry.allocations,
575
+ }));
576
+ const productAmount = lines.reduce((sum, entry) => sum + entry.line.amount, 0);
577
+ const couponDiscountAmount = lineResults.reduce((sum, line) => sum + line.couponDiscount, 0);
578
+ const deliveryFee = deliveryBundles.reduce(
579
+ (sum, bundle) => sum + bundle.fee + bundle.remoteSurcharge,
580
+ 0,
581
+ );
582
+ return {
583
+ lines: lineResults,
584
+ coupons: results,
585
+ bundles,
586
+ productAmount,
587
+ couponDiscountAmount,
588
+ deliveryFee,
589
+ deliveryDiscountAmount,
590
+ totalAmount: productAmount - couponDiscountAmount + deliveryFee - deliveryDiscountAmount,
591
+ };
592
+ }
593
+
594
+ // ─── 환불 정산: 조건 미달 차감 ─────────────────────────────
595
+
596
+ /**
597
+ * 주문이 받은 쿠폰 한 장 — 주문 시점 규칙과 대상 줄을 굳힌 값이다(`orders.discounts`의 항목).
598
+ * 환불 정산은 이 값만 읽는다. 셀러가 쿠폰을 바꾸거나 상품의 카테고리가 바뀌어도 결과가 같다.
599
+ */
600
+ export interface AppliedCouponSnapshot {
601
+ couponKey: string;
602
+ rule: CouponRule;
603
+ /** 상품 쿠폰이 걸린 주문상품 id. 그 밖은 null */
604
+ lineId: string | null;
605
+ /** 주문 시점 대상 주문상품 id — 주문서 항목 순서 그대로다 */
606
+ eligibleLineIds: string[];
607
+ /** 주문 시점 할인액 */
608
+ discount: number;
609
+ }
610
+
611
+ /** 정산이 보는 주문상품 한 줄 — 이번 취소를 **반영한 뒤**의 활성 수량이다 */
612
+ export interface CouponSettlementLine {
613
+ lineId: string;
614
+ productId: string;
615
+ quantity: number;
616
+ /** 남은 활성 수량 (이번 취소 반영 뒤) */
617
+ activeQuantity: number;
618
+ /** 주문상품 금액 (즉시할인 반영 단가 × 전체 수량, 쿠폰 전) */
619
+ totalPrice: number;
620
+ /** 주문 시점 쿠폰별 배분 */
621
+ allocations: readonly CouponAllocation[];
622
+ }
623
+
624
+ /** 쿠폰 한 장의 정산 누적 — `orders.coupon_settlements[couponKey]` */
625
+ export interface CouponSettlementState {
626
+ /** 지금까지 환불에서 뺀 조건 미달 차감 (되돌린 금액을 뺀 값) */
627
+ charged: number;
628
+ /** 판매자 귀책·정책 꺼짐으로 받지 않기로 한 미달분 — 뒤의 환불이 다시 청구하지 않게 남긴다 */
629
+ waived: number;
630
+ }
631
+
632
+ export interface CouponSettlementInput {
633
+ coupons: readonly AppliedCouponSnapshot[];
634
+ lines: readonly CouponSettlementLine[];
635
+ settlements: Readonly<Record<string, Partial<CouponSettlementState> | undefined>>;
636
+ /** 스토어 환불 정책 `couponShortfallCharge` */
637
+ shortfallCharge: boolean;
638
+ /** 판매자 귀책(직권취소, 불량·오배송 반품) — 미달분을 청구하지 않는다 */
639
+ sellerFault: boolean;
640
+ /**
641
+ * 이번 환불에서 실제로 뺄 수 있는 금액 — 차감의 상한이다. 넘겨 차감하면 환불은 0원으로 잘리는데 기록에는
642
+ * 전액이 남아, 뒤에 되돌릴 때 받지 않은 돈을 돌려준다(배송비 미달 차감의 `chargeableAmount`와 같은 이유).
643
+ */
644
+ chargeableAmount: number;
645
+ }
646
+
647
+ export interface CouponSettlementEntry {
648
+ couponKey: string;
649
+ /** 남은 주문상품에 배분된 할인 */
650
+ allocatedRemaining: number;
651
+ /** 남은 주문상품으로 다시 계산한 할인 — 쿠폰 조건을 잃었으면 0 */
652
+ recomputed: number;
653
+ /** 누적 미달분 목표 = 조건을 잃었으면 `allocatedRemaining`, 아니면 0 */
654
+ target: number;
655
+ charge: number;
656
+ refund: number;
657
+ waive: number;
658
+ next: CouponSettlementState;
659
+ }
660
+
661
+ export interface CouponSettlementResult {
662
+ /** 이번 환불에서 뺄 금액 — 조건 미달 차감 */
663
+ chargeAmount: number;
664
+ /** 이번 환불에 더할 금액 — 앞선 환불에서 뺀 차감을 되돌린 몫 */
665
+ refundAmount: number;
666
+ entries: CouponSettlementEntry[];
667
+ }
668
+
669
+ export const NO_COUPON_SETTLEMENT: CouponSettlementResult = {
670
+ chargeAmount: 0,
671
+ refundAmount: 0,
672
+ entries: [],
673
+ };
674
+
675
+ /**
676
+ * 쿠폰 조건 미달 차감 — **순수 함수다.** 부분 취소·반품으로 남은 주문상품이 쿠폰 조건을 잃으면,
677
+ * 남은 주문상품이 받던 할인만큼을 이번 환불에서 뺀다.
678
+ *
679
+ * 쿠폰마다
680
+ * - `allocatedRemaining` = 남은 수량에 배분된 할인(`proratedRefundBase`로 수량 비율)
681
+ * - `recomputed` = 주문 시점 규칙 스냅샷과 주문 시점 대상 줄로, 남은 금액만 넣어 `quotePricing`을 다시 돌린 할인
682
+ * - 쿠폰이 **여전히 적용되면 목표 0**, 조건을 잃었으면 목표 = `allocatedRemaining`이다. 적용되는 쿠폰의
683
+ * 정률 10원 내림 차이까지 차감하면 조건을 지킨 구매자가 몇 원씩 잃는다 — 조건을 잃은 경우만 본다.
684
+ * 재계산 할인이 더 커지는 경우(정률 최대 할인액)도 차감하지 않고 더 돌려주지도 않는다.
685
+ * - `delta` = 목표 − (이미 차감 + 이미 면제). 양수면 청구하고(판매자 귀책·정책 꺼짐이면 면제로 기록),
686
+ * 음수면 앞서 뺀 차감부터 돌려준다. 마지막 활성 수량까지 취소되면 목표가 0이라 누적 차감이 모두 되돌아가고,
687
+ * 전량 취소의 환불 합계가 결제액과 같아진다.
688
+ * - 청구는 이번 환불에서 실제로 뺄 수 있는 금액(`chargeableAmount` + 이번에 되돌린 금액)을 넘지 않고,
689
+ * **실제로 뺀 금액만** 기록한다. 못 뺀 나머지는 기록하지 않아 다음 환불이 다시 본다.
690
+ *
691
+ * 배송비 쿠폰은 여기서 다루지 않는다. 배송비 쿠폰을 발급하게 되면(3단계) 묶음 배송비 환불
692
+ * (`computeDeliveryFeeSettlement`)이 그 묶음의 `couponDiscount`를 빼야 한다 — 빼지 않으면 받지 않은 배송비를 돌려준다.
693
+ */
694
+ export function settleCouponShortfall(input: CouponSettlementInput): CouponSettlementResult {
695
+ const coupons = input.coupons.filter((coupon) => coupon.rule.kind !== "DELIVERY");
696
+ if (coupons.length === 0) return NO_COUPON_SETTLEMENT;
697
+
698
+ const pricingLines: PricingLine[] = input.lines.map((line) => {
699
+ const remaining = proratedRefundBase(line.totalPrice, line.quantity, 0, line.activeQuantity);
700
+ // 대상은 주문 시점 목록으로 고정하므로 즉시할인 판정에 쓰는 정가는 금액과 같게 둔다
701
+ return {
702
+ lineId: line.lineId,
703
+ productId: line.productId,
704
+ quantity: line.activeQuantity,
705
+ baseAmount: remaining,
706
+ amount: remaining,
707
+ };
708
+ });
709
+ const recomputed = quotePricing({
710
+ lines: pricingLines,
711
+ coupons: coupons.map((coupon) => ({
712
+ rule: coupon.rule,
713
+ couponKey: coupon.couponKey,
714
+ lineId: coupon.lineId,
715
+ eligibleLineIds: coupon.eligibleLineIds,
716
+ })),
717
+ delivery: null,
718
+ // 기간·회원·첫 구매 조건은 결제 시작에서 이미 통과했다 — 대상과 최소 금액만 남은 금액으로 다시 본다
719
+ firstOrder: true,
720
+ isMember: true,
721
+ now: new Date(0), // ignoreEligibility라 기간을 보지 않는다
722
+ ignoreEligibility: true,
723
+ });
724
+
725
+ const base = coupons.map((coupon, index) => {
726
+ const allocatedRemaining = input.lines.reduce((sum, line) => {
727
+ const allocation = line.allocations
728
+ .filter((a) => a.couponKey === coupon.couponKey)
729
+ .reduce((acc, a) => acc + a.amount, 0);
730
+ return sum + proratedRefundBase(allocation, line.quantity, 0, line.activeQuantity);
731
+ }, 0);
732
+ const result = recomputed.coupons[index];
733
+ const stillApplied = result?.applied === true;
734
+ const state = input.settlements[coupon.couponKey] ?? {};
735
+ return {
736
+ couponKey: coupon.couponKey,
737
+ allocatedRemaining,
738
+ recomputed: stillApplied ? (result?.discount ?? 0) : 0,
739
+ target: stillApplied ? 0 : allocatedRemaining,
740
+ charged: Math.max(0, state.charged ?? 0),
741
+ waived: Math.max(0, state.waived ?? 0),
742
+ };
743
+ });
744
+
745
+ // 되돌림을 먼저 정한다 — 되돌린 금액만큼 이번 환불에서 뺄 수 있는 여유가 생긴다
746
+ const entries: CouponSettlementEntry[] = [];
747
+ let refundAmount = 0;
748
+ for (const item of base) {
749
+ const delta = item.target - (item.charged + item.waived);
750
+ let refund = 0;
751
+ let waived = item.waived;
752
+ if (delta < 0) {
753
+ // 구매자에게 유리하게 앞서 뺀 차감부터 돌려주고, 남은 초과분은 면제 기록에서 지운다
754
+ refund = Math.min(item.charged, -delta);
755
+ waived = Math.max(0, waived - (-delta - refund));
756
+ }
757
+ refundAmount += refund;
758
+ entries.push({
759
+ couponKey: item.couponKey,
760
+ allocatedRemaining: item.allocatedRemaining,
761
+ recomputed: item.recomputed,
762
+ target: item.target,
763
+ charge: 0,
764
+ refund,
765
+ waive: 0,
766
+ next: { charged: item.charged - refund, waived },
767
+ });
768
+ }
769
+
770
+ let capacity = Math.max(0, input.chargeableAmount) + refundAmount;
771
+ let chargeAmount = 0;
772
+ const chargeAllowed = input.shortfallCharge && !input.sellerFault;
773
+ for (const entry of entries) {
774
+ const delta = entry.target - (entry.next.charged + entry.next.waived);
775
+ if (delta <= 0) continue;
776
+ if (!chargeAllowed) {
777
+ entry.waive = delta;
778
+ entry.next = { ...entry.next, waived: entry.next.waived + delta };
779
+ continue;
780
+ }
781
+ const charge = Math.min(delta, capacity);
782
+ if (charge <= 0) continue;
783
+ capacity -= charge;
784
+ chargeAmount += charge;
785
+ entry.charge = charge;
786
+ entry.next = { ...entry.next, charged: entry.next.charged + charge };
787
+ }
788
+ return { chargeAmount, refundAmount, entries };
789
+ }
790
+
791
+ /**
792
+ * 주문상품의 실결제 금액 중 이번 취소 수량의 몫 — **실결제 금액(`totalPrice − couponDiscountAmount`)을
793
+ * 누적 내림의 차분으로 나눈다.**
794
+ *
795
+ * 상품 금액과 쿠폰 배분을 따로 나눠 빼면 합은 같지만 한 번의 몫이 −1원이 되는 경우가 있다
796
+ * (예: 금액 3원·할인 2원·수량 5개에서 셋째 개). 음수를 0으로 자르면 합이 1원 넘친다. 실결제 금액을
797
+ * 바로 나누면 몫이 항상 0 이상이고 전량 취소의 합이 실결제 금액과 정확히 같다.
798
+ */
799
+ export function proratedPaidRefundBase(
800
+ item: { totalPrice: number; couponDiscountAmount?: number | null; quantity: number },
801
+ settledQuantity: number,
802
+ cancelQuantity: number,
803
+ ): number {
804
+ const paid = Math.max(0, item.totalPrice - (item.couponDiscountAmount ?? 0));
805
+ return proratedRefundBase(paid, item.quantity, settledQuantity, cancelQuantity);
806
+ }