@sayren/store-sdk 0.9.0 → 0.10.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,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { type PageParams, pageQueryShape } from "./common";
3
+ import { refundPolicyPatchSchema } from "./refund-policy";
3
4
  import { UPLOAD_PURPOSE_LIMITS, uploadExtensionOf } from "./upload";
4
5
 
5
6
  export const productStatusSchema = z.enum(["SALE", "SUSPENDED", "OUT_OF_STOCK", "CLOSED"]);
@@ -90,6 +91,10 @@ export const productSchema = productSummarySchema.extend({
90
91
  conditionalFreeAmount: z.number().int().nullable().optional(),
91
92
  returnFee: z.number().int().optional(),
92
93
  exchangeFee: z.number().int().optional(),
94
+ refundPolicy: refundPolicyPatchSchema
95
+ .nullable()
96
+ .optional()
97
+ .describe("상품별 환불 정책 덮어쓰기. null이면 스토어 기본 환불 정책을 쓴다"),
93
98
  shippingAddressId: z.string().nullable().optional(),
94
99
  returnAddressId: z.string().nullable().optional(),
95
100
  createdAt: z.string().optional(),
@@ -285,8 +290,22 @@ export const productCreateRequestSchema = z
285
290
  .int()
286
291
  .optional()
287
292
  .describe("CONDITIONAL_FREE일 때 무료 배송이 되는 주문 금액. 원 단위"),
288
- returnFee: z.number().int().default(0).describe("반품 배송비. 원 단위"),
289
- exchangeFee: z.number().int().default(0).describe("교환 배송비. 원 단위"),
293
+ returnFee: z
294
+ .number()
295
+ .int()
296
+ .default(0)
297
+ .describe("반품 배송비. 원 단위. `refundPolicy.returnDeliveryFee`와 같은 값이다"),
298
+ exchangeFee: z
299
+ .number()
300
+ .int()
301
+ .default(0)
302
+ .describe("교환 배송비. 원 단위. `refundPolicy.exchangeDeliveryFee`와 같은 값이다"),
303
+ refundPolicy: refundPolicyPatchSchema
304
+ .nullish()
305
+ .describe(
306
+ "상품별 환불 정책 — 준 항목만 스토어 기본을 덮어쓴다. `null`이나 생략은 스토어 기본을 그대로 쓴다는 뜻이다. " +
307
+ "`returnDeliveryFee`·`exchangeDeliveryFee`를 주면 `returnFee`·`exchangeFee`도 같은 값으로 맞춘다",
308
+ ),
290
309
  shippingAddressId: z.string().optional(),
291
310
  returnAddressId: z.string().optional(),
292
311
  })
@@ -0,0 +1,163 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * 환불 정책 (이슈 #25) — 취소·반품·교환에서 **배송비를 누가 부담하는지**를 정한다.
5
+ *
6
+ * 두 층이다. 스토어 설정(`PATCH /v1/store`의 `refundPolicy`)이 기본값이고, 상품별 정책
7
+ * (`POST/PUT /v1/products`의 `refundPolicy`)이 그 상품에만 덮어쓴다. 상품이 정하지 않은 항목은
8
+ * 스토어 기본을 따르고, 스토어도 정하지 않았으면 `REFUND_POLICY_DEFAULTS`다.
9
+ *
10
+ * 한 주문에 정책이 섞이면 **상품별로 계산한다**. 배송비 묶음도 상품 단위다(주문서·장바구니의
11
+ * 배송비 합산과 같은 규칙: 같은 상품의 주문상품이 한 묶음이고 묶음마다 배송비가 붙는다).
12
+ */
13
+
14
+ const descriptions = {
15
+ preShipmentDeliveryFeeRefund:
16
+ "발송 전 취소로 배송 묶음(같은 상품의 주문상품)이 모두 취소되면 그 묶음의 배송비를 환불한다. " +
17
+ "false면 배송비를 환불하지 않는다. 기본 true",
18
+ returnDeliveryFee:
19
+ "단순 변심(구매자 귀책) 반품의 반품 배송비. 원 단위. 환불액에서 뺀다. 기본 0(무료). " +
20
+ "상품별 정책에서 이 값을 주면 상품의 `returnFee`도 같은 값으로 맞춘다",
21
+ exchangeDeliveryFee:
22
+ "단순 변심(구매자 귀책) 교환의 교환 배송비. 원 단위. 구매자에게 청구할 금액을 안내값으로 싣는다. 기본 0(무료). " +
23
+ "상품별 정책에서 이 값을 주면 상품의 `exchangeFee`도 같은 값으로 맞춘다",
24
+ sellerFaultFreeShipping:
25
+ "판매자 귀책(셀러 직권취소, 불량·오배송 반품)이면 배송비를 셀러가 부담한다 — 배송비를 환불하고 반품비를 받지 않는다. " +
26
+ "false면 구매자 귀책과 같은 규칙을 쓴다. 기본 true",
27
+ conditionalFreeShortfallCharge:
28
+ "조건부 무료배송(`CONDITIONAL_FREE`) 상품에서 일부 취소로 남은 금액이 무료 기준액에 미달하면 그 배송비를 환불액에서 뺀다. " +
29
+ "false면 추가 청구 없이 셀러가 부담한다. 기본 false",
30
+ } as const;
31
+
32
+ /** 유효 환불 정책 — 스토어 기본 + 상품별 덮어쓰기를 합친 결과 */
33
+ export const refundPolicySchema = z.object({
34
+ preShipmentDeliveryFeeRefund: z.boolean().describe(descriptions.preShipmentDeliveryFeeRefund),
35
+ returnDeliveryFee: z.number().int().min(0).describe(descriptions.returnDeliveryFee),
36
+ exchangeDeliveryFee: z.number().int().min(0).describe(descriptions.exchangeDeliveryFee),
37
+ sellerFaultFreeShipping: z.boolean().describe(descriptions.sellerFaultFreeShipping),
38
+ conditionalFreeShortfallCharge: z.boolean().describe(descriptions.conditionalFreeShortfallCharge),
39
+ });
40
+
41
+ export type RefundPolicy = z.infer<typeof refundPolicySchema>;
42
+
43
+ /**
44
+ * 스토어가 정하지 않았을 때 서버가 쓰는 값.
45
+ *
46
+ * `as const`가 아니라 `RefundPolicy`로 타입을 좁힌다 — 리터럴 타입이면 `Partial<typeof …>`로 일부만 바꿀 때
47
+ * `true` 자리에 `false`를 넣을 수 없다.
48
+ */
49
+ export const REFUND_POLICY_DEFAULTS: RefundPolicy = {
50
+ preShipmentDeliveryFeeRefund: true,
51
+ returnDeliveryFee: 0,
52
+ exchangeDeliveryFee: 0,
53
+ sellerFaultFreeShipping: true,
54
+ conditionalFreeShortfallCharge: false,
55
+ };
56
+
57
+ /**
58
+ * 부분 정책 — 스토어 설정 요청과 상품별 덮어쓰기가 쓴다. 준 항목만 바뀐다.
59
+ *
60
+ * `strictObject`라 모르는 키는 400이다. 오타(`returnFee` 등)를 조용히 무시하면 셀러는 저장됐다고
61
+ * 믿는데 정책은 기본값으로 남는다.
62
+ */
63
+ export const refundPolicyPatchSchema = z.strictObject({
64
+ preShipmentDeliveryFeeRefund: z
65
+ .boolean()
66
+ .optional()
67
+ .describe(descriptions.preShipmentDeliveryFeeRefund),
68
+ returnDeliveryFee: z.number().int().min(0).optional().describe(descriptions.returnDeliveryFee),
69
+ exchangeDeliveryFee: z
70
+ .number()
71
+ .int()
72
+ .min(0)
73
+ .optional()
74
+ .describe(descriptions.exchangeDeliveryFee),
75
+ sellerFaultFreeShipping: z.boolean().optional().describe(descriptions.sellerFaultFreeShipping),
76
+ conditionalFreeShortfallCharge: z
77
+ .boolean()
78
+ .optional()
79
+ .describe(descriptions.conditionalFreeShortfallCharge),
80
+ });
81
+
82
+ export type RefundPolicyPatch = z.infer<typeof refundPolicyPatchSchema>;
83
+
84
+ /** 정책 해석에 쓰는 상품 값 — 상품별 덮어쓰기와 레거시 배송비 필드 */
85
+ export interface RefundPolicyProductInput {
86
+ /** 상품별 덮어쓰기. 없으면 스토어 기본을 그대로 쓴다 */
87
+ refundPolicy?: RefundPolicyPatch | null;
88
+ /** @deprecated 레거시 상품 반품비 — `refundPolicy`가 없는 옛 상품의 값이다 */
89
+ returnFee?: number | null;
90
+ /** @deprecated 레거시 상품 교환비 — `refundPolicy`가 없는 옛 상품의 값이다 */
91
+ exchangeFee?: number | null;
92
+ }
93
+
94
+ /**
95
+ * 유효 정책 해석 — 상품별 덮어쓰기 → 스토어 기본 → `REFUND_POLICY_DEFAULTS` 순이다.
96
+ *
97
+ * 반품비·교환비는 레거시 필드(`products.returnFee`·`exchangeFee`)와 **연결**한다. 상품이
98
+ * `refundPolicy.returnDeliveryFee`를 정하면 그 값이 원천이고(서버가 `returnFee`도 같은 값으로 맞춘다),
99
+ * 정하지 않았으면 레거시 값이 0보다 클 때 그 값을 쓴다. 0이면 스토어 기본을 따른다 —
100
+ * 정책이 생기기 전에 만든 상품(반품비 0)이 스토어 기본을 물려받게 하는 규칙이다.
101
+ */
102
+ export function resolveRefundPolicy(
103
+ storePolicy?: RefundPolicyPatch | null,
104
+ product?: RefundPolicyProductInput | null,
105
+ ): RefundPolicy {
106
+ const store = { ...REFUND_POLICY_DEFAULTS, ...dropUndefined(storePolicy) };
107
+ return { ...store, ...(productRefundPolicyOverride(product) ?? {}) };
108
+ }
109
+
110
+ /**
111
+ * 상품의 **덮어쓰기 자체** — 레거시 반품비·교환비를 같은 형태로 승격한 값이다. 덮어쓰기가 없으면 null.
112
+ *
113
+ * `resolveRefundPolicy`가 합친 결과로는 "상품이 정한 값"과 "스토어 기본을 물려받은 값"을 가를 수 없다.
114
+ * 관리 API 응답의 `refundPolicy`와 콘솔 폼의 덮어쓰기 스위치가 이 함수를 원천으로 쓴다 — 가르지 못하면
115
+ * 스토어 기본을 쓰는 상품을 저장할 때 그 순간의 기본값이 상품에 박히거나, 레거시 값이 조용히 사라진다.
116
+ */
117
+ export function productRefundPolicyOverride(
118
+ product?: RefundPolicyProductInput | null,
119
+ ): RefundPolicyPatch | null {
120
+ const override = dropUndefined(product?.refundPolicy);
121
+ const legacy: RefundPolicyPatch = {};
122
+ if (override.returnDeliveryFee === undefined && (product?.returnFee ?? 0) > 0) {
123
+ legacy.returnDeliveryFee = product?.returnFee ?? 0;
124
+ }
125
+ if (override.exchangeDeliveryFee === undefined && (product?.exchangeFee ?? 0) > 0) {
126
+ legacy.exchangeDeliveryFee = product?.exchangeFee ?? 0;
127
+ }
128
+ const merged = { ...legacy, ...override };
129
+ if (product?.refundPolicy == null && Object.keys(merged).length === 0) return null;
130
+ return merged;
131
+ }
132
+
133
+ /** `undefined` 값을 빼고 얕게 복사한다 — 전개 순서로 덮어쓸 때 `undefined`가 기본값을 지우지 않게 */
134
+ function dropUndefined(patch?: RefundPolicyPatch | null): RefundPolicyPatch {
135
+ if (!patch) return {};
136
+ return Object.fromEntries(
137
+ Object.entries(patch).filter(([, value]) => value !== undefined),
138
+ ) as RefundPolicyPatch;
139
+ }
140
+
141
+ /**
142
+ * 수량 단위 부분 취소의 환불 기준액 — **누적 내림의 차분**이다.
143
+ *
144
+ * 원 단위 반올림 규칙: `floor(주문상품 금액 × 누적 수량 ÷ 전체 수량)`을 두 번 구해 그 차를 쓴다.
145
+ * 부분 취소를 몇 번 나눠도 합이 주문상품 금액과 정확히 같아서 1원이 남거나 넘치지 않는다.
146
+ * 할인은 이미 단가에 들어 있어(주문 단위 쿠폰이 없다) 별도 안분이 없다.
147
+ *
148
+ * @param totalPrice 주문상품 금액 (할인 반영 단가 × 전체 수량)
149
+ * @param quantity 주문상품 전체 수량
150
+ * @param settledQuantity 이미 취소·반품된 누적 수량
151
+ * @param cancelQuantity 이번에 취소·반품할 수량
152
+ */
153
+ export function proratedRefundBase(
154
+ totalPrice: number,
155
+ quantity: number,
156
+ settledQuantity: number,
157
+ cancelQuantity: number,
158
+ ): number {
159
+ if (quantity <= 0 || cancelQuantity <= 0) return 0;
160
+ const before = Math.floor((totalPrice * settledQuantity) / quantity);
161
+ const after = Math.floor((totalPrice * (settledQuantity + cancelQuantity)) / quantity);
162
+ return Math.max(0, after - before);
163
+ }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { storeRoleSchema } from "./auth";
3
+ import { refundPolicyPatchSchema, refundPolicySchema } from "./refund-policy";
3
4
 
4
5
  /**
5
6
  * 스토어 코드 규칙 — 스토어프론트 API가 테넌트를 가르는 값이다(`X-Store-Code` 헤더).
@@ -105,6 +106,27 @@ export const MAX_ANALYTICS_ALLOWED_ORIGINS = 20;
105
106
  /** 자동 배송완료·자동 구매확정 대기일 상한 */
106
107
  export const MAX_AUTO_TRANSITION_DAYS = 30;
107
108
 
109
+ /**
110
+ * 약관 버전 — 스토어가 정하는 문자열이다(`1`·`2`·`2026-09-26`·`v1.2` 등). 형식은 강제하지 않고 값이 다르면 다른 버전이다.
111
+ * 올리면 그 약관에 동의한 구매자의 `reconsentRequired`가 켜지고, 재동의가 이력에 남는다. 되돌리면 재동의 요구도 풀린다.
112
+ */
113
+ export const policyVersionSchema = z
114
+ .string()
115
+ .trim()
116
+ .min(1)
117
+ .max(20)
118
+ .describe("약관 버전 문자열(1~20자). 예: `1`, `2026-09-26`");
119
+
120
+ /**
121
+ * 약관 문서 주소 — 구매자에게 보여 주는 약관 본문의 주소다. 동의 시점 값이 이력 행에 복사되므로
122
+ * 나중에 주소를 바꿔도 "무엇에 동의했는지"가 남는다. `null`로 지울 수 있다.
123
+ */
124
+ export const policyDocumentUrlSchema = z
125
+ .url()
126
+ .max(500)
127
+ .refine((value) => value.startsWith("https://"), "https 주소만 쓸 수 있어요")
128
+ .nullable();
129
+
108
130
  const autoDeliverDaysDescription =
109
131
  "발송 후 자동 배송완료까지의 일수(0~30, 기본 7). 발송(`DISPATCHED`)·배송중(`DELIVERING`) 주문상품을 " +
110
132
  "이 기간이 지나면 배송완료(`DELIVERED`)로 바꾼다. `0`이면 자동 배송완료를 하지 않는다";
@@ -157,6 +179,27 @@ export const updateStoreRequestSchema = z
157
179
  .max(MAX_AUTO_TRANSITION_DAYS)
158
180
  .optional()
159
181
  .describe(autoConfirmDaysDescription),
182
+ refundPolicy: refundPolicyPatchSchema
183
+ .optional()
184
+ .describe(
185
+ "스토어 기본 환불 정책 — 준 항목만 바꾼다. 상품별 정책(`products.refundPolicy`)이 없는 상품에 적용된다",
186
+ ),
187
+ termsVersion: policyVersionSchema
188
+ .optional()
189
+ .describe(
190
+ "이용약관 버전. 올리면 이 약관에 동의한 구매자에게 재동의를 요구한다(`GET /storefront/v1/me`의 `reconsentRequired`)",
191
+ ),
192
+ privacyVersion: policyVersionSchema
193
+ .optional()
194
+ .describe("개인정보 수집이용 동의 버전. 올리면 구매자에게 재동의를 요구한다"),
195
+ termsUrl: policyDocumentUrlSchema
196
+ .optional()
197
+ .describe(
198
+ "이용약관 문서 주소(https). 동의 시점의 값이 동의 이력에 복사된다. `null`을 주면 지운다",
199
+ ),
200
+ privacyUrl: policyDocumentUrlSchema
201
+ .optional()
202
+ .describe("개인정보 수집이용 동의 문서 주소(https). `null`을 주면 지운다"),
160
203
  })
161
204
  .refine((value) => Object.values(value).some((v) => v !== undefined), {
162
205
  message: "최소 1개 필드가 필요합니다",
@@ -183,6 +226,21 @@ export const storeSchema = z.object({
183
226
  guestCheckoutEnabled: z.boolean().describe("비회원 주문 허용. false면 회원만 주문할 수 있다"),
184
227
  autoDeliverDays: z.number().int().describe(autoDeliverDaysDescription),
185
228
  autoConfirmDays: z.number().int().describe(autoConfirmDaysDescription),
229
+ refundPolicy: refundPolicySchema.describe(
230
+ "스토어 기본 환불 정책. 스토어가 정하지 않은 항목은 플랫폼 기본값으로 채워 항상 전부 싣는다",
231
+ ),
232
+ termsVersion: z.string().describe("현재 이용약관 버전. 가입·재동의가 이 값으로 이력에 남는다"),
233
+ privacyVersion: z.string().describe("현재 개인정보 수집이용 동의 버전"),
234
+ termsVersionUpdatedAt: z
235
+ .string()
236
+ .nullable()
237
+ .describe("이용약관 버전을 마지막으로 바꾼 시각. 바꾼 적이 없으면 null"),
238
+ privacyVersionUpdatedAt: z.string().nullable(),
239
+ termsUrl: z
240
+ .string()
241
+ .nullable()
242
+ .describe("이용약관 문서 주소. 정하지 않았으면 null이고 동의 이력에도 주소가 남지 않는다"),
243
+ privacyUrl: z.string().nullable().describe("개인정보 수집이용 동의 문서 주소"),
186
244
  memberCount: z.number().int(),
187
245
  createdAt: z.string(),
188
246
  });
@@ -44,6 +44,46 @@ export const claimWebhookEventDataSchema = z.object({
44
44
  orderItemId: z.string().describe("클레임 대상 주문 상품 ID"),
45
45
  claimType: claimTypeSchema.describe("클레임 유형"),
46
46
  status: claimStatusSchema.describe("이벤트 발생 시점의 클레임 상태"),
47
+ quantity: z
48
+ .number()
49
+ .int()
50
+ .optional()
51
+ .default(0)
52
+ .describe("클레임 대상 수량. 이 필드가 없는 이전 이벤트는 0으로 읽는다"),
53
+ refundAmount: z
54
+ .number()
55
+ .int()
56
+ .nullable()
57
+ .optional()
58
+ .default(null)
59
+ .describe(
60
+ "환불액 (원). 배송비 환불을 포함하고 배송비 차감·추가 차감을 뺀 실제 금액이다. " +
61
+ "환불이 확정되지 않은 접수 이벤트는 예상 환불액이고, 교환처럼 환불이 없으면 null이다",
62
+ ),
63
+ deliveryFeeRefundAmount: z
64
+ .number()
65
+ .int()
66
+ .optional()
67
+ .default(0)
68
+ .describe("환불액에 포함된 배송비 (원). 이 필드가 없는 이전 이벤트는 0으로 읽는다"),
69
+ deliveryFeeChargeAmount: z
70
+ .number()
71
+ .int()
72
+ .optional()
73
+ .default(0)
74
+ .describe(
75
+ "환불액에서 뺀 배송비 (원) — 반품·교환 배송비와 조건부 무료배송 미달 차감의 합. " +
76
+ "셀러가 승인에 넣은 추가 차감은 `deductAmount`로 따로 온다. 이 필드가 없는 이전 이벤트는 0으로 읽는다",
77
+ ),
78
+ deductAmount: z
79
+ .number()
80
+ .int()
81
+ .optional()
82
+ .default(0)
83
+ .describe(
84
+ "환불액에서 뺀 추가 차감 (원) — 셀러가 클레임 승인에 넣은 금액이다(상품 훼손 등). " +
85
+ "배송비 차감과 별개이므로 `deliveryFeeChargeAmount`와 더해서 뺀다. 접수 이벤트와 이 필드가 없는 이전 이벤트는 0이다",
86
+ ),
47
87
  testPayment: z
48
88
  .boolean()
49
89
  .optional()