@sayren/store-sdk 0.29.2 → 0.29.4

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.
@@ -28,7 +28,7 @@ export const COLLECTION_TYPES = ["MANUAL", "AUTOMATIC"] as const;
28
28
  export type CollectionType = (typeof COLLECTION_TYPES)[number];
29
29
 
30
30
  /**
31
- * 상품 정렬 — `MANUAL`은 직접 정한 순서(직접 선택 컬렉션만)다. `BEST_SELLING`(판매량순)은 판매량 집계가 열릴 때 받는다.
31
+ * 상품 정렬 — `MANUAL`은 직접 정한 순서(직접 선택 컬렉션만)다. `BEST_SELLING`(판매량순)은 판매 수량이 많은 순이다(M3).
32
32
  * 저장 값 전체 목록이고, 쓰기에서 받는 값은 `collectionProductSortRequestSchema`다.
33
33
  */
34
34
  export const COLLECTION_PRODUCT_SORTS = [
@@ -44,9 +44,19 @@ export const COLLECTION_PRODUCT_SORTS = [
44
44
  export type CollectionProductSort = (typeof COLLECTION_PRODUCT_SORTS)[number];
45
45
 
46
46
  export const collectionProductSortRequestSchema = z
47
- .enum(["MANUAL", "LATEST", "PRICE_ASC", "PRICE_DESC", "REVIEW_COUNT", "RATING_DESC", "RECOMMEND"])
47
+ .enum([
48
+ "MANUAL",
49
+ "LATEST",
50
+ "BEST_SELLING",
51
+ "PRICE_ASC",
52
+ "PRICE_DESC",
53
+ "REVIEW_COUNT",
54
+ "RATING_DESC",
55
+ "RECOMMEND",
56
+ ])
48
57
  .describe(
49
- "스토어프론트 상품 기본 정렬. `MANUAL`(직접 정한 순서)·`LATEST`(최신순)·`PRICE_ASC`·`PRICE_DESC`·`REVIEW_COUNT`·`RATING_DESC`·`RECOMMEND`(추천순)",
58
+ "스토어프론트 상품 기본 정렬. `MANUAL`(직접 정한 순서)·`LATEST`(최신순)·`BEST_SELLING`(판매량순)·`PRICE_ASC`·`PRICE_DESC`·`REVIEW_COUNT`·`RATING_DESC`·`RECOMMEND`(추천순). " +
59
+ "판매량순의 기간은 판매량 상위 조건의 기간이고, 조건이 없으면 최근 30일이다",
50
60
  );
51
61
 
52
62
  /** 노출 상태 — 노출 여부와 기간에서 나온 값이다. 숨김이 기간보다 앞선다 */
@@ -73,6 +83,13 @@ export const MAX_COLLECTION_RULE_CATEGORIES = 20;
73
83
  export const MAX_COLLECTION_CREATED_WITHIN_DAYS = 365;
74
84
  /** 미리보기(`POST /v1/collections/preview`)가 싣는 상품 최대 개수 */
75
85
  export const COLLECTION_PREVIEW_SIZE = 20;
86
+ /** 판매량 상위 조건의 기간 선택지(일, 오늘 포함 한국 날짜) */
87
+ export const COLLECTION_BEST_SELLING_DAYS = [7, 30, 90] as const;
88
+ export type CollectionBestSellingDays = (typeof COLLECTION_BEST_SELLING_DAYS)[number];
89
+ /** 판매량 상위 조건이 담는 상품 최대 개수 */
90
+ export const MAX_COLLECTION_BEST_SELLING_TOP = 100;
91
+ /** 판매량순(`BEST_SELLING`) 정렬의 기간 — 판매량 상위 조건이 없을 때 쓴다 */
92
+ export const COLLECTION_SALES_SORT_DEFAULT_DAYS: CollectionBestSellingDays = 30;
76
93
 
77
94
  const createdWithinDaysRuleSchema = z.object({
78
95
  field: z.literal("createdWithinDays"),
@@ -106,9 +123,23 @@ const discountedRuleSchema = z.object({
106
123
  value: z.literal(true).describe("즉시할인이 지금 적용되는 상품"),
107
124
  });
108
125
 
126
+ const bestSellingRuleSchema = z.object({
127
+ field: z.literal("bestSelling"),
128
+ days: z
129
+ .union([z.literal(7), z.literal(30), z.literal(90)])
130
+ .describe("판매량을 세는 기간(일). 7·30·90 중 하나이고 오늘을 포함한 한국 날짜 기준이다"),
131
+ top: z
132
+ .number()
133
+ .int()
134
+ .min(1)
135
+ .max(MAX_COLLECTION_BEST_SELLING_TOP)
136
+ .describe("담을 상품 수(1~100). 다른 조건을 만족하는 상품 중 판매 수량 상위 N개다"),
137
+ });
138
+
109
139
  /**
110
140
  * 조건 하나 — `field`로 가른다. 가격은 즉시할인을 반영한 판매가(`discountedPrice ?? salePrice`)다.
111
- * 판매량 상위(`bestSelling`)는 판매량 집계가 열릴 때 더한다.
141
+ * 판매량 상위(`bestSelling`)는 술어가 아니라 순위 자르기다. 다른 조건을 모두 만족한 상품 중 판매 수량 상위 N개만 남긴다(`rankBestSelling`).
142
+ * 판매 수량은 실결제 주문의 결제 수량에서 취소·반품 수량을 뺀 값이고, 날짜는 주문 시각의 한국 날짜다. 집계는 최대 10분 늦다.
112
143
  */
113
144
  export const collectionRuleSchema = z
114
145
  .discriminatedUnion("field", [
@@ -116,6 +147,7 @@ export const collectionRuleSchema = z
116
147
  categoryRuleSchema,
117
148
  priceRuleSchema,
118
149
  discountedRuleSchema,
150
+ bestSellingRuleSchema,
119
151
  ])
120
152
  .superRefine((rule, ctx) => {
121
153
  if (rule.field !== "price") return;
@@ -163,6 +195,7 @@ export const collectionRuleViewSchema = z.union([
163
195
  categoryRuleSchema,
164
196
  priceRuleSchema,
165
197
  discountedRuleSchema,
198
+ bestSellingRuleSchema,
166
199
  z.looseObject({ field: z.string() }),
167
200
  ]);
168
201
 
@@ -213,6 +246,7 @@ export function descendantCategoryIdsOf(
213
246
  /**
214
247
  * 조건 판정 함수를 만든다 — 모든 조건을 만족해야 true다(AND). 카테고리 트리는 여기서 한 번만 푼다.
215
248
  * 알 수 없는 조건(`field`)은 만족하지 않는 것으로 본다. 모르는 조건을 건너뛰면 상품이 예고 없이 늘어난다.
249
+ * 판매량 상위(`bestSelling`)는 여기서 모두 통과시킨다. 순위 자르기는 판매량을 아는 호출부가 `rankBestSelling`으로 한다.
216
250
  * 판매 상태는 보지 않는다. 판매 중만 거르는 것은 호출부다.
217
251
  */
218
252
  export function collectionRulesPredicate(
@@ -243,6 +277,8 @@ export function collectionRulesPredicate(
243
277
  case "discounted":
244
278
  return (product) =>
245
279
  product.discountedPrice !== null && product.discountedPrice < product.salePrice;
280
+ case "bestSelling":
281
+ return () => true;
246
282
  default:
247
283
  return () => false;
248
284
  }
@@ -259,6 +295,45 @@ export function matchesCollectionRules(
259
295
  return collectionRulesPredicate(rules, context)(product);
260
296
  }
261
297
 
298
+ /** 판매량 상위 조건 — 없으면 null. 같은 조건은 한 번만 쓰므로 첫 번째다 */
299
+ export function bestSellingRuleOf(
300
+ rules: readonly CollectionRuleView[],
301
+ ): { days: CollectionBestSellingDays; top: number } | null {
302
+ for (const rule of rules) {
303
+ const parsed = bestSellingRuleSchema.safeParse(rule);
304
+ if (parsed.success) return { days: parsed.data.days, top: parsed.data.top };
305
+ }
306
+ return null;
307
+ }
308
+
309
+ /**
310
+ * 판매량 비교 — 판매 수량이 많은 순, 같으면 최근 등록 순, 그것도 같으면 상품 id 순이다. 결과가 입력 순서에 기대지 않는다.
311
+ * `sales`에 없는 상품은 0개다.
312
+ */
313
+ export function compareBySales<T extends { productId: string; createdAt: string }>(
314
+ sales: ReadonlyMap<string, number>,
315
+ ): (a: T, b: T) => number {
316
+ return (a, b) =>
317
+ (sales.get(b.productId) ?? 0) - (sales.get(a.productId) ?? 0) ||
318
+ Date.parse(b.createdAt) - Date.parse(a.createdAt) ||
319
+ (a.productId < b.productId ? -1 : a.productId > b.productId ? 1 : 0);
320
+ }
321
+
322
+ /**
323
+ * 판매량 상위 자르기 — 다른 조건을 통과한 상품 중 판매 수량이 1개 이상인 상품을 `compareBySales` 순으로 `top`개 남긴다.
324
+ * 판매가 없는 상품은 넣지 않는다.
325
+ */
326
+ export function rankBestSelling<T extends { productId: string; createdAt: string }>(
327
+ products: readonly T[],
328
+ sales: ReadonlyMap<string, number>,
329
+ top: number,
330
+ ): T[] {
331
+ return products
332
+ .filter((product) => (sales.get(product.productId) ?? 0) > 0)
333
+ .sort(compareBySales(sales))
334
+ .slice(0, Math.max(0, top));
335
+ }
336
+
262
337
  /** 조건이 참조하는 카테고리 id — 카테고리 삭제의 참조 검사에 쓴다 */
263
338
  export function collectionRuleCategoryIds(rules: readonly CollectionRuleView[]): string[] {
264
339
  const ids: string[] = [];
@@ -288,6 +363,14 @@ export const collectionSchema = z.object({
288
363
  metaTitle: z.string().nullable(),
289
364
  metaDescription: z.string().nullable(),
290
365
  state: collectionStateSchema.describe("지금 노출 상태"),
366
+ salesComputedAt: z
367
+ .string()
368
+ .nullable()
369
+ .default(null)
370
+ .describe(
371
+ "판매량 기준 시각. 판매량 상위 조건이나 판매량순 정렬을 쓰는 컬렉션은 이 시각까지의 주문을 센다(최대 10분 늦다). " +
372
+ "판매량을 쓰지 않거나 아직 집계 전이면 null",
373
+ ),
291
374
  productCount: z
292
375
  .number()
293
376
  .int()
@@ -347,7 +430,7 @@ const periodRefine = (
347
430
  /**
348
431
  * `POST /v1/collections` — 컬렉션을 만든다. `slug`를 생략하면 `c-{id 뒤 8자}`로 만든다.
349
432
  * 직접 선택(`MANUAL`)은 `productIds`가 처음 담을 상품과 순서다. 조건 자동(`AUTOMATIC`)은 `rules`가 있어야 하고 `productIds`를 받지 않는다.
350
- * `productSort`를 생략하면 직접 선택은 `MANUAL`, 조건 자동은 `LATEST`다. 조건 자동은 `MANUAL`을 쓸 수 없다.
433
+ * `productSort`를 생략하면 직접 선택은 `MANUAL`, 조건 자동은 `LATEST`(판매량 상위 조건이 있으면 `BEST_SELLING`)다. 조건 자동은 `MANUAL`을 쓸 수 없다.
351
434
  */
352
435
  export const collectionCreateRequestSchema = z
353
436
  .object({
@@ -365,7 +448,7 @@ export const collectionCreateRequestSchema = z
365
448
  productSort: commonFields.productSort
366
449
  .optional()
367
450
  .describe(
368
- "스토어프론트 상품 기본 정렬. 생략하면 직접 선택은 `MANUAL`, 조건 자동은 `LATEST`다. 조건 자동은 `MANUAL`을 쓸 수 없다",
451
+ "스토어프론트 상품 기본 정렬. 생략하면 직접 선택은 `MANUAL`, 조건 자동은 `LATEST`(판매량 상위 조건이 있으면 `BEST_SELLING`)다. 조건 자동은 `MANUAL`을 쓸 수 없다",
369
452
  ),
370
453
  visible: commonFields.visible.default(true),
371
454
  startsAt: commonFields.startsAt,
@@ -483,7 +566,9 @@ export const collectionPreviewRequestSchema = z.object({
483
566
  rules: collectionRulesSchema,
484
567
  productSort: collectionProductSortRequestSchema
485
568
  .optional()
486
- .describe("미리보기 순서. 생략하거나 `MANUAL`이면 `LATEST`다"),
569
+ .describe(
570
+ "미리보기 순서. 생략하면 `LATEST`(판매량 상위 조건이 있으면 `BEST_SELLING`)이고, `MANUAL`이면 `LATEST`다",
571
+ ),
487
572
  });
488
573
 
489
574
  export type CollectionPreviewRequest = z.infer<typeof collectionPreviewRequestSchema>;
@@ -492,6 +577,13 @@ export type CollectionPreviewRequest = z.infer<typeof collectionPreviewRequestSc
492
577
  export const collectionPreviewSchema = z.object({
493
578
  count: z.number().int().describe("조건을 만족하는 판매 중 상품 수(조회 시각 기준)"),
494
579
  products: z.array(collectionProductSchema).describe("앞 20개(정렬 순)"),
580
+ salesComputedAt: z
581
+ .string()
582
+ .nullable()
583
+ .default(null)
584
+ .describe(
585
+ "판매량 기준 시각. 판매량 상위 조건이나 판매량순 정렬을 쓰면 이 시각까지의 주문을 센다. 판매량을 쓰지 않거나 집계 전이면 null",
586
+ ),
495
587
  });
496
588
 
497
589
  export type CollectionPreview = z.infer<typeof collectionPreviewSchema>;
@@ -103,6 +103,15 @@ export const orderItemSchema = z.object({
103
103
  "이 주문상품에 배분된 쿠폰 할인 — 상품 쿠폰 + 주문 쿠폰 배분. 실결제 금액은 `totalPrice − couponDiscountAmount`이고 " +
104
104
  "취소·반품 환불은 실결제 금액을 수량 비율로 나눈다. 쿠폰이 없으면 0. 서버는 항상 싣는다",
105
105
  ),
106
+ pointAllocation: z
107
+ .number()
108
+ .int()
109
+ .optional()
110
+ .default(0)
111
+ .describe(
112
+ "이 주문상품에 배분된 사용 적립금 (원, #93). 현금 실결제는 `totalPrice − couponDiscountAmount − pointAllocation`이고 " +
113
+ "취소·반품은 현금과 적립금을 따로 수량 비율로 돌려준다. 적립금을 쓰지 않았으면 0. 서버는 항상 싣는다",
114
+ ),
106
115
  hold: z.object({ reason: z.string(), expectedDispatchDate: z.string() }).nullable(),
107
116
  fulfillment: orderItemFulfillmentSchema
108
117
  .nullable()
@@ -202,6 +211,14 @@ export const orderSchema = z.object({
202
211
  .optional()
203
212
  .default(0)
204
213
  .describe("배송비 쿠폰 할인. 쿠폰이 없으면 0. 서버는 항상 싣는다"),
214
+ pointAmount: z
215
+ .number()
216
+ .int()
217
+ .optional()
218
+ .default(0)
219
+ .describe(
220
+ "사용 적립금 (원, #93) — 쿠폰 뒤 금액에서 뺐다. `totalAmount`는 적립금을 뺀 결제(현금) 금액이다. 적립금을 쓰지 않았으면 0. 서버는 항상 싣는다",
221
+ ),
205
222
  discounts: z
206
223
  .array(orderDiscountSchema)
207
224
  .optional()
@@ -518,6 +535,15 @@ export const cancelBySellerResultSchema = z.object({
518
535
  .optional()
519
536
  .default(0)
520
537
  .describe("환불액에 더한, 앞선 환불에서 뺀 쿠폰 차감을 되돌린 몫. 서버는 항상 싣는다"),
538
+ pointRefundAmount: z
539
+ .number()
540
+ .int()
541
+ .optional()
542
+ .default(0)
543
+ .describe(
544
+ "돌려준 적립금 (원, #93) — 결제에 쓴 적립금 중 이번 취소·반품 수량의 몫이다. 현금 환불(`refundAmount`)과 별개다. " +
545
+ "차감은 현금에서 먼저 빼고 모자란 몫만 여기서 뺀다. 적립금을 쓰지 않은 주문과 이 필드가 없는 이전 응답·이벤트는 0으로 읽는다",
546
+ ),
521
547
  refundedAt: z.string(),
522
548
  });
523
549
 
@@ -677,7 +677,13 @@ export type PaymentAttemptRecord = z.infer<typeof paymentAttemptRecordSchema>;
677
677
 
678
678
  export const paymentRefundRecordSchema = z.object({
679
679
  refundId: z.string(),
680
- amount: z.number().int(),
680
+ amount: z.number().int().describe("PG로 돌려준 현금"),
681
+ pointRefundAmount: z
682
+ .number()
683
+ .int()
684
+ .optional()
685
+ .default(0)
686
+ .describe("같은 환불에서 돌려준 적립금 (#93). 적립금을 쓰지 않은 결제는 0"),
681
687
  reason: z.string().describe("환불 사유 코드"),
682
688
  kind: z
683
689
  .string()
@@ -44,6 +44,40 @@ export const purchaseEarnPolicySchema = z.object({
44
44
  });
45
45
  export type PurchaseEarnPolicy = z.infer<typeof purchaseEarnPolicySchema>;
46
46
 
47
+ /** 사용 단위 — 1·10·100원 */
48
+ export const POINT_USE_UNITS = [1, 10, 100] as const;
49
+
50
+ /**
51
+ * 적립금 사용 규칙(P3) — 주문서에서 결제 금액을 줄인다. 적용 순서는 즉시할인 → 쿠폰 → 적립금이고, 상품 금액(쿠폰 뒤)에 먼저 쓰고
52
+ * 남으면 배송비에 쓴다(`includeDeliveryFee`). 결제 금액이 100원 아래로 내려가지 않게 적립금을 줄인다.
53
+ */
54
+ export const pointUsePolicySchema = z.object({
55
+ enabled: z.boolean().describe("적립금 사용 여부. 꺼도 잔액은 남는다"),
56
+ minBalance: z
57
+ .number()
58
+ .int()
59
+ .min(0)
60
+ .max(10_000_000)
61
+ .describe("이 잔액 이상이어야 쓸 수 있다(원). 0이면 제한 없음"),
62
+ minAmount: z
63
+ .number()
64
+ .int()
65
+ .min(0)
66
+ .max(10_000_000)
67
+ .describe("한 번에 쓰는 최소 금액(원). 0이면 제한 없음"),
68
+ unit: z
69
+ .union([z.literal(1), z.literal(10), z.literal(100)])
70
+ .describe("사용 단위(원). 요청 금액을 이 단위로 내린다"),
71
+ maxRatioBp: z
72
+ .number()
73
+ .int()
74
+ .min(1)
75
+ .max(10_000)
76
+ .describe("결제 기준 금액(쿠폰 뒤) 대비 최대 사용 비율(만분율, 10000 = 100%)"),
77
+ includeDeliveryFee: z.boolean().describe("배송비에도 쓰는가. 끄면 상품 금액(쿠폰 뒤)에만 쓴다"),
78
+ });
79
+ export type PointUsePolicy = z.infer<typeof pointUsePolicySchema>;
80
+
47
81
  const expiryDays = z
48
82
  .number()
49
83
  .int()
@@ -58,6 +92,7 @@ const expiryDays = z
58
92
  export const pointPolicySchema = z.object({
59
93
  reviewReward: reviewRewardPolicySchema,
60
94
  purchaseEarn: purchaseEarnPolicySchema,
95
+ use: pointUsePolicySchema,
61
96
  expiryDays: expiryDays.nullable().describe("적립금 유효 기간(일). 정하지 않았으면 null"),
62
97
  });
63
98
  export type PointPolicy = z.infer<typeof pointPolicySchema>;
@@ -66,6 +101,14 @@ export type PointPolicy = z.infer<typeof pointPolicySchema>;
66
101
  export const POINT_POLICY_DEFAULTS: PointPolicy = {
67
102
  reviewReward: { enabled: false, textPoint: 0, photoPoint: 0 },
68
103
  purchaseEarn: { enabled: false, rateBp: 0 },
104
+ use: {
105
+ enabled: false,
106
+ minBalance: 0,
107
+ minAmount: 0,
108
+ unit: 10,
109
+ maxRatioBp: 10_000,
110
+ includeDeliveryFee: true,
111
+ },
69
112
  expiryDays: null,
70
113
  };
71
114
 
@@ -78,10 +121,12 @@ export function resolvePointPolicy(stored: unknown): PointPolicy {
78
121
  const raw = stored as Record<string, unknown>;
79
122
  const review = reviewRewardPolicySchema.safeParse(raw.reviewReward);
80
123
  const purchase = purchaseEarnPolicySchema.safeParse(raw.purchaseEarn);
124
+ const use = pointUsePolicySchema.safeParse(raw.use);
81
125
  const expiry = expiryDays.nullable().safeParse(raw.expiryDays ?? null);
82
126
  return {
83
127
  reviewReward: review.success ? review.data : POINT_POLICY_DEFAULTS.reviewReward,
84
128
  purchaseEarn: purchase.success ? purchase.data : POINT_POLICY_DEFAULTS.purchaseEarn,
129
+ use: use.success ? use.data : POINT_POLICY_DEFAULTS.use,
85
130
  expiryDays: expiry.success ? expiry.data : null,
86
131
  };
87
132
  }
@@ -137,7 +182,7 @@ export const POINT_LOT_SOURCES = [
137
182
  ] as const;
138
183
  export type PointLotSource = (typeof POINT_LOT_SOURCES)[number];
139
184
 
140
- /** 원장 항목 유형. 지금 쓰는 것은 리뷰 적립·회수이고 나머지는 다음 단계 자리다 */
185
+ /** 원장 항목 유형 */
141
186
  export const POINT_ENTRY_TYPES = [
142
187
  "EARN_REVIEW",
143
188
  "REVOKE_REVIEW",
@@ -173,6 +218,20 @@ export const storePointPolicySchema = z.object({
173
218
  purchaseEarn: purchaseEarnPolicySchema.describe(
174
219
  "구매 적립. 구매확정 때 주문상품 실결제 금액(배송비 제외) × 적립률을 준다. 정하지 않았으면 끔·0",
175
220
  ),
221
+ useAvailable: z
222
+ .boolean()
223
+ .optional()
224
+ .default(false)
225
+ .describe(
226
+ "적립금 사용(결제에 쓰기)을 켤 수 있는가 (#93). false면 준비 중이라 `use.enabled`를 켜는 저장이 409 `POINTS_UNAVAILABLE`이다. " +
227
+ "필드가 없는 이전 응답은 false로 읽는다",
228
+ ),
229
+ use: pointUsePolicySchema
230
+ .optional()
231
+ .default(POINT_POLICY_DEFAULTS.use)
232
+ .describe(
233
+ "적립금 사용 규칙. 적용 순서는 즉시할인 → 쿠폰 → 적립금이다. 정하지 않았으면 끔. 필드가 없는 이전 응답은 끔으로 읽는다",
234
+ ),
176
235
  expiryDays: pointPolicySchema.shape.expiryDays,
177
236
  notice: pointPolicyNoticeSchema,
178
237
  });
@@ -185,6 +244,7 @@ export type StorePointPolicy = z.infer<typeof storePointPolicySchema>;
185
244
  export const updatePointPolicyRequestSchema = z.object({
186
245
  reviewReward: reviewRewardPolicySchema.optional(),
187
246
  purchaseEarn: purchaseEarnPolicySchema.optional(),
247
+ use: pointUsePolicySchema.optional(),
188
248
  expiryDays: expiryDays.optional(),
189
249
  });
190
250
  export type UpdatePointPolicyRequest = z.infer<typeof updatePointPolicyRequestSchema>;
@@ -196,7 +256,8 @@ export const responsePointEntryTypeSchema = z
196
256
  .union([z.enum(POINT_ENTRY_TYPES), z.string()])
197
257
  .describe(
198
258
  "유형. `EARN_REVIEW` 리뷰 적립 · `REVOKE_REVIEW` 리뷰 삭제 회수 · `EARN_PURCHASE` 구매 적립 · `EXPIRE` 소멸 · `ADJUST` 셀러 지급·차감 · " +
199
- "`OPENING` 기존 잔액 이전. 값이 추가될 수 있어 문자열로 받는다",
259
+ "`OPENING` 기존 잔액 이전 · `USE_RESERVE` 결제 사용(결제 시작 때 빠짐) · `USE_RELEASE` 결제가 실패·만료되어 되돌림 · " +
260
+ "`REFUND_RESTORE` 취소·반품 환불로 반환. 값이 추가될 수 있어 문자열로 받는다",
200
261
  );
201
262
 
202
263
  /** 원장 항목 — 셀러 조회용(사유·행위자 포함) */
@@ -222,7 +283,7 @@ export const pointLotSchema = z.object({
222
283
  lotId: z.string(),
223
284
  source: z
224
285
  .union([z.enum(POINT_LOT_SOURCES), z.string()])
225
- .describe("출처. `REVIEW`·`PURCHASE`·`ADJUST`·`OPENING` 등"),
286
+ .describe("출처. `REVIEW`·`PURCHASE`·`ADJUST`·`REFUND_RESTORE`·`OPENING` 등"),
226
287
  amount: z.number().int().describe("적립한 금액"),
227
288
  remaining: z.number().int().describe("남은 금액"),
228
289
  expiresAt: z.string().nullable().describe("소멸 시각. null이면 소멸 없음"),
@@ -283,3 +344,50 @@ export const pointAdjustmentResultSchema = z.object({
283
344
  balance: z.number().int().describe("조정 뒤 잔액"),
284
345
  });
285
346
  export type PointAdjustmentResult = z.infer<typeof pointAdjustmentResultSchema>;
347
+
348
+ // ── 사용·환불 (P3) ─────────────────────────────────────
349
+
350
+ /** 결제 시작에 쓴 적립 묶음 — 해제·환불 반환이 이 값으로 되돌리고 반환 적립금의 만료를 정한다 */
351
+ export interface PointLotUsage {
352
+ lotId: string;
353
+ amount: number;
354
+ expiresAt: string | null;
355
+ }
356
+
357
+ const RESTORE_GRACE_MS = 30 * DAY_MS;
358
+
359
+ /**
360
+ * 환불로 돌려주는 적립금의 만료 — 결제에 쓴 묶음 중 가장 늦은 만료이고, 그 시각이 이미 지났으면 지금 + 30일(KST 날짜 끝)이다.
361
+ * 쓴 묶음 중 만료 없는 묶음(기존 잔액 등)이 있으면 만료 없음(null)이다. 쓴 기록이 없으면 지금 + 30일이다(사용자 결정 2026-10-02).
362
+ */
363
+ export function pointRestoreExpiresAtOf(usages: readonly PointLotUsage[], now: Date): Date | null {
364
+ if (usages.some((usage) => usage.expiresAt === null)) return null;
365
+ const latest = usages.reduce<number | null>((max, usage) => {
366
+ const at = Date.parse(usage.expiresAt as string);
367
+ return Number.isFinite(at) && (max === null || at > max) ? at : max;
368
+ }, null);
369
+ if (latest !== null && latest > now.getTime()) return new Date(latest);
370
+ return pointExpiresAtOf(new Date(now.getTime() + RESTORE_GRACE_MS), 0);
371
+ }
372
+
373
+ /**
374
+ * 환불 금액 나누기 — 현금 몫·적립금 몫에서 차감(반품 배송비·쿠폰 조건 미달·추가 차감)을 **현금에서 먼저** 빼고 모자란 몫만 적립금에서 뺀다.
375
+ * 둘 다 모자라면 0에서 멈추고 못 받은 차감액(`uncollected`)을 돌려준다(호출부가 감사 로그에 남긴다).
376
+ */
377
+ export function splitRefund(input: { cash: number; points: number; charges: number }): {
378
+ cashRefund: number;
379
+ pointRefund: number;
380
+ uncollected: number;
381
+ } {
382
+ const cash = Math.max(0, input.cash);
383
+ const points = Math.max(0, input.points);
384
+ const charges = Math.max(0, input.charges);
385
+ const fromCash = Math.min(cash, charges);
386
+ const rest = charges - fromCash;
387
+ const fromPoints = Math.min(points, rest);
388
+ return {
389
+ cashRefund: cash - fromCash,
390
+ pointRefund: points - fromPoints,
391
+ uncollected: rest - fromPoints,
392
+ };
393
+ }
@@ -219,8 +219,31 @@ export interface PricingDelivery {
219
219
  bundles: ReadonlyArray<{ bundleKey: string; fee: number; remoteSurcharge: number }>;
220
220
  }
221
221
 
222
+ /**
223
+ * 적립금 사용 입력(#93 P3) — 쿠폰 뒤 금액에 쓴다. 규칙은 상점 정책 `use`(`PointUsePolicy`)의 값이다.
224
+ * 금액은 요청·잔액·최대 비율 중 작은 값을 `unit`으로 내리고, 남는 결제 금액이 100원 아래로 내려가지 않게 줄인다(적립금 전액 결제는 아직 받지 않는다).
225
+ */
226
+ export interface PricingPoints {
227
+ /** 구매자가 쓰겠다는 금액 */
228
+ requested: number;
229
+ /** 지금 잔액 */
230
+ balance: number;
231
+ rules: {
232
+ minBalance: number;
233
+ minAmount: number;
234
+ unit: number;
235
+ maxRatioBp: number;
236
+ includeDeliveryFee: boolean;
237
+ };
238
+ }
239
+
240
+ /** 적립금을 쓰지 못한 이유 */
241
+ export type PointRejectReason = "MIN_BALANCE" | "MIN_AMOUNT" | "NOT_PAYABLE";
242
+
222
243
  export interface PricingInput {
223
244
  lines: readonly PricingLine[];
245
+ /** 적립금 사용(#93 P3). 없으면 쓰지 않는다 — 결과는 적립금 이전과 같다 */
246
+ points?: PricingPoints | null;
224
247
  coupons: readonly PricingCoupon[];
225
248
  /** 배송비 견적. 없으면 배송비 0으로 계산하고 배송비 쿠폰은 할인할 금액이 없다 */
226
249
  delivery?: PricingDelivery | null;
@@ -251,6 +274,8 @@ export interface PricingLineResult {
251
274
  /** 이 줄에 들어간 쿠폰 할인 합계 — 상품 쿠폰 + 주문 쿠폰 배분 */
252
275
  couponDiscount: number;
253
276
  allocations: CouponAllocation[];
277
+ /** 이 줄에 배분한 사용 적립금(#93 P3). 적립금을 쓰지 않으면 0 */
278
+ pointAllocation: number;
254
279
  }
255
280
 
256
281
  export interface PricingCouponResult {
@@ -270,6 +295,8 @@ export interface PricingCouponResult {
270
295
  export interface PricingBundleResult {
271
296
  bundleKey: string;
272
297
  couponDiscount: number;
298
+ /** 이 배송 묶음에 배분한 사용 적립금(#93 P3) */
299
+ pointAllocation: number;
273
300
  }
274
301
 
275
302
  export interface PricingResult {
@@ -285,7 +312,15 @@ export interface PricingResult {
285
312
  deliveryFee: number;
286
313
  /** 배송비 쿠폰 할인 */
287
314
  deliveryDiscountAmount: number;
288
- /** 결제 금액 = productAmount − couponDiscountAmount + deliveryFee − deliveryDiscountAmount */
315
+ /** 사용 적립금(#93 P3). 쓰지 않으면 0 */
316
+ pointAmount: number;
317
+ /** 지금 쓸 수 있는 최대 적립금 — 주문서 안내값(잔액을 모두 요청했을 때의 금액). 적립금 입력이 없으면 0 */
318
+ pointMaxUsable: number;
319
+ /** 요청보다 적게 썼다면 그 이유 — `MIN_PAYMENT`: 결제 금액 100원을 남기려고 줄였다 */
320
+ pointAdjustReason: "MIN_PAYMENT" | null;
321
+ /** 요청했지만 쓰지 못했다면 그 이유 */
322
+ pointRejectReason: PointRejectReason | null;
323
+ /** 결제 금액 = productAmount − couponDiscountAmount + deliveryFee − deliveryDiscountAmount − pointAmount */
289
324
  totalAmount: number;
290
325
  }
291
326
 
@@ -541,6 +576,7 @@ export function quotePricing(input: PricingInput): PricingResult {
541
576
  const bundles: PricingBundleResult[] = deliveryBundles.map((bundle) => ({
542
577
  bundleKey: bundle.bundleKey,
543
578
  couponDiscount: 0,
579
+ pointAllocation: 0,
544
580
  }));
545
581
  let deliverySlot = false;
546
582
  let deliveryDiscountAmount = 0;
@@ -574,6 +610,7 @@ export function quotePricing(input: PricingInput): PricingResult {
574
610
  amount: entry.line.amount,
575
611
  couponDiscount: entry.allocations.reduce((sum, a) => sum + a.amount, 0),
576
612
  allocations: entry.allocations,
613
+ pointAllocation: 0,
577
614
  }));
578
615
  const productAmount = lines.reduce((sum, entry) => sum + entry.line.amount, 0);
579
616
  const couponDiscountAmount = lineResults.reduce((sum, line) => sum + line.couponDiscount, 0);
@@ -581,6 +618,33 @@ export function quotePricing(input: PricingInput): PricingResult {
581
618
  (sum, bundle) => sum + bundle.fee + bundle.remoteSurcharge,
582
619
  0,
583
620
  );
621
+ const beforePoints = productAmount - couponDiscountAmount + deliveryFee - deliveryDiscountAmount;
622
+
623
+ // 5) 적립금(#93 P3) — 쿠폰 뒤 금액에 쓴다. 상품 줄에 먼저(줄 실결제 비율), 남으면 배송 묶음에(묶음 실배송비 비율) 배분한다
624
+ const productWeights = lineResults.map((line) => Math.max(0, line.amount - line.couponDiscount));
625
+ const bundleWeights = deliveryBundles.map((bundle, index) =>
626
+ Math.max(0, bundle.fee + bundle.remoteSurcharge - (bundles[index]?.couponDiscount ?? 0)),
627
+ );
628
+ const points = pointUseOf(input.points ?? null, {
629
+ productPaid: productWeights.reduce((sum, w) => sum + w, 0),
630
+ deliveryPaid: bundleWeights.reduce((sum, w) => sum + w, 0),
631
+ total: beforePoints,
632
+ });
633
+ if (points.amount > 0) {
634
+ const productPaid = productWeights.reduce((sum, w) => sum + w, 0);
635
+ const toLines = Math.min(points.amount, productPaid);
636
+ allocateByWeight(toLines, productWeights).forEach((share, i) => {
637
+ const line = lineResults[i];
638
+ if (line) line.pointAllocation = share;
639
+ });
640
+ const toBundles = points.amount - toLines;
641
+ if (toBundles > 0) {
642
+ allocateByWeight(toBundles, bundleWeights).forEach((share, i) => {
643
+ const bundle = bundles[i];
644
+ if (bundle) bundle.pointAllocation = share;
645
+ });
646
+ }
647
+ }
584
648
  return {
585
649
  lines: lineResults,
586
650
  coupons: results,
@@ -589,10 +653,56 @@ export function quotePricing(input: PricingInput): PricingResult {
589
653
  couponDiscountAmount,
590
654
  deliveryFee,
591
655
  deliveryDiscountAmount,
592
- totalAmount: productAmount - couponDiscountAmount + deliveryFee - deliveryDiscountAmount,
656
+ pointAmount: points.amount,
657
+ pointMaxUsable: points.maxUsable,
658
+ pointAdjustReason: points.adjustReason,
659
+ pointRejectReason: points.rejectReason,
660
+ totalAmount: beforePoints - points.amount,
593
661
  };
594
662
  }
595
663
 
664
+ /**
665
+ * 사용 적립금 금액(순수) — 요청·잔액·최대 비율·「결제 금액 100원 남기기」 중 작은 값을 단위로 내린다.
666
+ * 최소 보유액·최소 사용액에 못 미치면 0이다. 결제 금액이 0원이 되는 사용(적립금 전액 결제)은 아직 받지 않는다(P4).
667
+ */
668
+ export function pointUseOf(
669
+ points: PricingPoints | null,
670
+ amounts: { productPaid: number; deliveryPaid: number; total: number },
671
+ ): {
672
+ amount: number;
673
+ maxUsable: number;
674
+ adjustReason: "MIN_PAYMENT" | null;
675
+ rejectReason: PointRejectReason | null;
676
+ } {
677
+ const none = { amount: 0, maxUsable: 0, adjustReason: null, rejectReason: null };
678
+ if (!points) return none;
679
+ const { rules } = points;
680
+ const unit = Math.max(1, Math.floor(rules.unit));
681
+ const base = amounts.productPaid + (rules.includeDeliveryFee ? amounts.deliveryPaid : 0);
682
+ const byRatio = Math.floor((base * rules.maxRatioBp) / 10_000);
683
+ // 결제 금액은 0원 또는 100원 이상이어야 하고, 지금은 0원 결제를 받지 않으므로 100원을 남긴다
684
+ const byPayment = Math.max(0, amounts.total - MIN_PAYMENT_AMOUNT);
685
+ const balance = Math.max(0, points.balance);
686
+ const floorUnit = (value: number) => Math.floor(Math.max(0, value) / unit) * unit;
687
+ const eligible = balance >= rules.minBalance;
688
+ const ceiling = Math.min(balance, byRatio, byPayment);
689
+ const maxUsable = eligible && floorUnit(ceiling) >= rules.minAmount ? floorUnit(ceiling) : 0;
690
+ const requested = Math.max(0, Math.floor(points.requested));
691
+ if (requested === 0) return { ...none, maxUsable };
692
+ if (!eligible) return { amount: 0, maxUsable, adjustReason: null, rejectReason: "MIN_BALANCE" };
693
+ const amount = floorUnit(Math.min(requested, balance, byRatio, byPayment));
694
+ if (amount <= 0) {
695
+ return { amount: 0, maxUsable, adjustReason: null, rejectReason: "NOT_PAYABLE" };
696
+ }
697
+ if (amount < rules.minAmount) {
698
+ return { amount: 0, maxUsable, adjustReason: null, rejectReason: "MIN_AMOUNT" };
699
+ }
700
+ // 요청이 결제 금액 100원 남기기에 걸려 줄었다
701
+ const adjustReason =
702
+ floorUnit(Math.min(requested, balance, byRatio)) > amount ? "MIN_PAYMENT" : null;
703
+ return { amount, maxUsable, adjustReason, rejectReason: null };
704
+ }
705
+
596
706
  // ─── 환불 정산: 조건 미달 차감 ─────────────────────────────
597
707
 
598
708
  /**
@@ -799,10 +909,36 @@ export function settleCouponShortfall(input: CouponSettlementInput): CouponSettl
799
909
  * 바로 나누면 몫이 항상 0 이상이고 전량 취소의 합이 실결제 금액과 정확히 같다.
800
910
  */
801
911
  export function proratedPaidRefundBase(
802
- item: { totalPrice: number; couponDiscountAmount?: number | null; quantity: number },
912
+ item: {
913
+ totalPrice: number;
914
+ couponDiscountAmount?: number | null;
915
+ /** 주문상품에 배분된 사용 적립금(#93 P3) — 현금 환불 기준에서 뺀다. 없으면 0 */
916
+ pointAllocation?: number | null;
917
+ quantity: number;
918
+ },
803
919
  settledQuantity: number,
804
920
  cancelQuantity: number,
805
921
  ): number {
806
- const paid = Math.max(0, item.totalPrice - (item.couponDiscountAmount ?? 0));
922
+ const paid = Math.max(
923
+ 0,
924
+ item.totalPrice - (item.couponDiscountAmount ?? 0) - (item.pointAllocation ?? 0),
925
+ );
807
926
  return proratedRefundBase(paid, item.quantity, settledQuantity, cancelQuantity);
808
927
  }
928
+
929
+ /**
930
+ * 주문상품의 사용 적립금 중 이번 취소 수량의 몫(#93 P3) — 누적 내림의 차분이라 나눠 취소해도 합이 배분액과 같다.
931
+ * 현금 환불(`proratedPaidRefundBase`)과 따로 돌려준다(`pointRefundAmount`).
932
+ */
933
+ export function proratedPointRefund(
934
+ item: { pointAllocation?: number | null; quantity: number },
935
+ settledQuantity: number,
936
+ cancelQuantity: number,
937
+ ): number {
938
+ return proratedRefundBase(
939
+ item.pointAllocation ?? 0,
940
+ item.quantity,
941
+ settledQuantity,
942
+ cancelQuantity,
943
+ );
944
+ }