@sayren/store-sdk 0.17.0 → 0.18.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,9 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { type PageParams, pageQueryShape } from "./common";
3
3
  import {
4
- fulfillmentModeSchema,
5
4
  fulfillmentTypeResponseSchema,
6
- fulfillmentTypeSchema,
5
+ productFulfillmentCreateSchema,
6
+ productFulfillmentResponseSchema,
7
+ productFulfillmentUpdateSchema,
7
8
  } from "./fulfillment";
8
9
  import { productRefundPolicyPatchSchema } from "./refund-policy";
9
10
  import { UPLOAD_PURPOSE_LIMITS, uploadExtensionOf } from "./upload";
@@ -12,10 +13,6 @@ export const productStatusSchema = z.enum(["SALE", "SUSPENDED", "OUT_OF_STOCK",
12
13
 
13
14
  export type ProductStatus = z.infer<typeof productStatusSchema>;
14
15
 
15
- export const deliveryTypeSchema = z.enum(["FREE", "PAID", "CONDITIONAL_FREE"]);
16
-
17
- export type DeliveryType = z.infer<typeof deliveryTypeSchema>;
18
-
19
16
  export const productSummarySchema = z.object({
20
17
  productId: z.string(),
21
18
  name: z.string(),
@@ -32,7 +29,11 @@ export const productSummarySchema = z.object({
32
29
  thumbnailUrl: z.url().optional(),
33
30
  /** optionGroups.length > 0 */
34
31
  hasOptions: z.boolean(),
35
- fulfillmentType: fulfillmentTypeResponseSchema.optional().default("SHIPPING"),
32
+ fulfillment: z
33
+ .object({ type: fulfillmentTypeResponseSchema })
34
+ .describe(
35
+ "상품 이행의 유형. 유형별 속성은 단건 조회(`GET /v1/products/{productId}`)의 `fulfillment`에 있다",
36
+ ),
36
37
  updatedAt: z.string(),
37
38
  });
38
39
 
@@ -90,58 +91,6 @@ export const discountSchema = z.object({
90
91
 
91
92
  export type Discount = z.infer<typeof discountSchema>;
92
93
 
93
- /**
94
- * 상품 유형 응답 필드 (이슈 #48) — 이 필드 이전 서버의 응답에는 없으므로 기본값을 둔다. 서버는 항상 싣는다.
95
- */
96
- function productFulfillmentResponseShape() {
97
- return {
98
- fulfillmentType: fulfillmentTypeResponseSchema.optional().default("SHIPPING"),
99
- fulfillmentMode: z
100
- .string()
101
- .nullable()
102
- .optional()
103
- .default(null)
104
- .describe(
105
- "제공 방식. `AUTO`(결제 확정과 함께 제공)·`MANUAL`(셀러가 제공 처리). 배송 상품은 null. 값이 추가될 수 있어 문자열로 받는다",
106
- ),
107
- stockTracking: z
108
- .boolean()
109
- .optional()
110
- .default(true)
111
- .describe("재고를 세는가. false면 재고와 무관하게 판다(파일 다운로드 기본)"),
112
- downloadLimit: z
113
- .number()
114
- .int()
115
- .nullable()
116
- .optional()
117
- .default(null)
118
- .describe("파일 다운로드 횟수 한도. 파일 다운로드 상품이 아니면 null"),
119
- downloadDays: z
120
- .number()
121
- .int()
122
- .nullable()
123
- .optional()
124
- .default(null)
125
- .describe("파일 다운로드 기간(제공일부터 일수). 파일 다운로드 상품이 아니면 null"),
126
- withdrawalRestricted: z
127
- .boolean()
128
- .optional()
129
- .default(false)
130
- .describe(
131
- "청약철회 제한 대상인가. 파일 다운로드·코드 발급 상품만 켤 수 있다(그 밖의 유형은 false로 저장한다). " +
132
- "true면 주문서에서 제한 고지에 대한 구매자 동의를 받고, 첫 다운로드·열람 뒤 구매자 취소·반품을 받지 않는다",
133
- ),
134
- pickupAddressId: z
135
- .string()
136
- .nullable()
137
- .optional()
138
- .default(null)
139
- .describe(
140
- "방문 수령지 주소 id(`seller_addresses`, 유형 PICKUP). 방문 수령 상품이 아니면 null",
141
- ),
142
- };
143
- }
144
-
145
94
  export const productSchema = productSummarySchema.extend({
146
95
  description: z.string().optional(),
147
96
  /**
@@ -155,18 +104,18 @@ export const productSchema = productSummarySchema.extend({
155
104
  optionGroups: z.array(optionGroupSchema),
156
105
  /** 모든 상품이 최소 1개 variant를 갖는다 (옵션 없으면 default variant 1개) */
157
106
  variants: z.array(productVariantSchema),
158
- deliveryType: deliveryTypeSchema.optional(),
159
- deliveryFee: z.number().int().optional(),
160
- conditionalFreeAmount: z.number().int().nullable().optional(),
161
- returnFee: z.number().int().optional(),
162
- exchangeFee: z.number().int().optional(),
107
+ fulfillment: productFulfillmentResponseSchema,
108
+ fulfillmentTypeLocked: z
109
+ .boolean()
110
+ .describe(
111
+ "판매 이력(주문상품)이 있어 상품 유형(`fulfillment.type`)을 바꿀 수 없는가. true면 다른 유형으로 수정하는 요청이 409 `FULFILLMENT_TYPE_LOCKED`다. 같은 유형 안의 속성은 바꿀 수 있다",
112
+ ),
163
113
  refundPolicy: productRefundPolicyPatchSchema
164
114
  .nullable()
165
- .optional()
166
- .describe("상품별 환불 정책 덮어쓰기. null이면 스토어 기본 환불 정책을 쓴다"),
167
- shippingAddressId: z.string().nullable().optional(),
168
- returnAddressId: z.string().nullable().optional(),
169
- ...productFulfillmentResponseShape(),
115
+ .describe(
116
+ "상품별 환불 정책 덮어쓰기(상품이 정한 항목만). null이면 스토어 기본 환불 정책을 쓴다. " +
117
+ "반품·교환 배송비(`returnDeliveryFee`·`exchangeDeliveryFee`)는 배송 상품에만 있다",
118
+ ),
170
119
  /**
171
120
  * 설정된 즉시할인. `GET /products/{productId}/discount`의 응답과 같은 형태다(이슈 #51).
172
121
  * 할인이 없으면 null이다. 이 필드를 싣기 전 서버의 응답에는 없으므로 optional이다.
@@ -338,170 +287,81 @@ export const variantStockSchema = z.object({
338
287
  export type VariantStock = z.infer<typeof variantStockSchema>;
339
288
 
340
289
  /**
341
- * 출고지·반품지 지정 문구 — POST·PUT·PATCH가 같은 규칙을 쓰므로 설명도 한 곳이다 (이슈 #30).
290
+ * 상품 쓰기 요청의 공통 필드 — 등록(POST)과 전체 수정(PUT)이 같다. 이행(`fulfillment`)만 두 요청의 스키마가 다르다.
342
291
  *
343
- * 세 값이 뜻이 다르다. **생략**은 「정하지 않음」이라 서버가 그 시점 스토어 기본 주소를 박아 저장하고,
344
- * **null**은 「스토어 기본을 따른다」는 명시적 비우기다. 주소록 항목은 상품이 참조 중이면 지울 수 없어
345
- * (`409 ADDRESS_IN_USE`) 참조를 푸는 수단이 필요한데, 생략을 기본값으로 되채우면 기본 주소는 영원히
346
- * 풀리지 않는다 — null이 그 경로다. 배송 묶음 판정은 null을 그 시점 스토어 기본 출고지로 정규화하므로
347
- * 두 표현이 배송비를 갈라놓지 않는다.
292
+ * 최상위가 `strictObject`다. 모르는 필드는 400이다 — 이슈 #57 이전의 평면 필드(`deliveryFee`·`fulfillmentType`·`returnFee` 등)를
293
+ * 보낸 요청이 조용히 무시되지 않게 한다. 옛 필드는 `fulfillment.{유형}.…`과 `refundPolicy`로 옮겼다.
348
294
  */
349
- const ADDRESS_DESCRIPTIONS = {
350
- shipping:
351
- "출고지 주소 id(`GET /v1/seller-addresses?type=SHIPPING`). 생략하면 스토어 기본 출고지를 저장하고, " +
352
- "`null`이면 비워 둔다(스토어 기본 출고지를 따른다). 이 스토어의 `SHIPPING` 주소가 아니면 거부한다",
353
- return:
354
- "반품지 주소 id(`GET /v1/seller-addresses?type=RETURN`). 생략하면 스토어 기본 반품지를 저장하고, " +
355
- "`null`이면 비워 둔다(스토어 기본 반품지를 따른다). 이 스토어의 `RETURN` 주소가 아니면 거부한다",
356
- patchShipping:
357
- "출고지 주소 id. **생략은 바꾸지 않음**이고 `null`은 비우기다(스토어 기본 출고지를 따른다). " +
358
- "이 스토어의 `SHIPPING` 주소가 아니면 거부한다",
359
- patchReturn:
360
- "반품지 주소 id. **생략은 바꾸지 않음**이고 `null`은 비우기다(스토어 기본 반품지를 따른다). " +
361
- "이 스토어의 `RETURN` 주소가 아니면 거부한다",
362
- } as const;
363
-
364
- /**
365
- * 상품 유형 요청 필드 (이슈 #48) — 등록·전체 수정·부분 수정이 같은 필드를 받는다.
366
- *
367
- * 생략의 뜻: 등록은 배송 상품(`SHIPPING`)과 유형 기본값이고, 전체 수정(PUT)·부분 수정(PATCH)은 **현재 값을 유지**한다.
368
- * 이 필드를 모르는 옛 클라이언트가 전체 수정을 보내도 유형이 배송 상품으로 되돌아가지 않게 한다.
369
- */
370
- function productFulfillmentRequestShape() {
371
- return {
372
- fulfillmentType: fulfillmentTypeSchema
373
- .optional()
374
- .describe(
375
- "상품 유형. `SHIPPING` 배송 상품 · `DOWNLOAD` 파일 다운로드 · `CODE` 코드 발급 · `SERVICE` 이용권·서비스 · `PICKUP` 방문 수령. " +
376
- "등록에서 생략하면 SHIPPING이고, 수정에서 생략하면 바꾸지 않는다. 판매 이력이 있는 상품의 유형은 바꿀 수 없다" +
377
- "(409 `FULFILLMENT_TYPE_LOCKED`). 배송 상품이 아니면 배송비·출고지·반품지·반품비·교환비를 무시하고 0·null로 저장한다",
378
- ),
379
- fulfillmentMode: fulfillmentModeSchema
380
- .nullable()
381
- .optional()
382
- .describe(
383
- "제공 방식. `AUTO` 결제 확정과 함께 제공 · `MANUAL` 셀러가 제공 처리. 생략·null이면 유형 기본값이다" +
384
- "(코드 발급 AUTO, 이용권 MANUAL, 파일 다운로드 AUTO만, 방문 수령 MANUAL만). 배송 상품은 무시한다",
385
- ),
386
- stockTracking: z
387
- .boolean()
388
- .optional()
389
- .describe(
390
- "재고를 세는가. 파일 다운로드 상품만 끌 수 있다(기본 false). 그 밖의 유형은 항상 true",
391
- ),
392
- downloadLimit: z
393
- .number()
394
- .int()
395
- .min(1)
396
- .max(100)
397
- .nullable()
398
- .optional()
399
- .describe("파일 다운로드 횟수 한도. 파일 다운로드 상품만 쓰고 생략·null이면 5회"),
400
- downloadDays: z
401
- .number()
402
- .int()
403
- .min(1)
404
- .max(3650)
405
- .nullable()
406
- .optional()
407
- .describe(
408
- "파일 다운로드 기간(제공일부터 일수). 파일 다운로드 상품만 쓰고 생략·null이면 30일",
409
- ),
410
- withdrawalRestricted: z
411
- .boolean()
412
- .optional()
413
- .describe(
414
- "청약철회 제한 대상인가. true면 주문서가 제한 고지를 보여 주고 구매자 동의를 받는다. 배송 상품은 false로 저장한다",
415
- ),
416
- pickupAddressId: z
417
- .string()
418
- .nullable()
419
- .optional()
420
- .describe(
421
- "방문 수령지 주소 id(`GET /v1/seller-addresses?type=PICKUP`). 방문 수령 상품만 쓴다. 생략하면 스토어 기본 방문 수령지를 저장한다",
422
- ),
423
- };
424
- }
295
+ const productWriteShape = {
296
+ name: z.string().min(1).max(100),
297
+ categoryId: z.string().min(1, "카테고리를 선택해주십시오"),
298
+ salePrice: z
299
+ .number()
300
+ .int()
301
+ .min(10)
302
+ .multipleOf(10, "판매가는 10원 단위여야 합니다 (INVALID_PRICE_UNIT)")
303
+ .describe("판매가. 원(KRW) 단위 정수, 최소 10원, 10원 단위"),
304
+ originalPrice: z.number().int().optional().describe("정가(할인 전 표시 가격). 원 단위"),
305
+ description: z.string().optional(),
306
+ /**
307
+ * 상세설명 에디터(@avarlabs/editor)의 직렬화 상태. 재편집 때 이 값으로 문서를 복원한다.
308
+ * 표시는 `description`(HTML)이 맡는다 — 소비자는 이 필드를 몰라도 된다.
309
+ */
310
+ descriptionJson: z.string().nullable().optional(),
311
+ images: z
312
+ .array(productImageUrlSchema)
313
+ .min(1)
314
+ .max(MAX_PRODUCT_IMAGES)
315
+ .optional()
316
+ .describe("상품 이미지 주소 목록. 첫 장이 대표 이미지(썸네일)다"),
317
+ attributes: z.array(z.object({ attributeId: z.string(), value: z.string() })).optional(),
318
+ refundPolicy: productRefundPolicyPatchSchema
319
+ .nullish()
320
+ .describe(
321
+ "상품별 환불 정책 — 준 항목만 스토어 기본을 덮어쓴다. `null`이나 생략은 스토어 기본을 그대로 쓴다는 뜻이다. " +
322
+ "반품·교환 배송비(`returnDeliveryFee`·`exchangeDeliveryFee`)는 배송 상품에만 줄 수 있다(다른 유형은 400)",
323
+ ),
324
+ ...optionModelRequestSchema.shape,
325
+ };
425
326
 
426
- export const productCreateRequestSchema = z
427
- .object({
428
- name: z.string().min(1).max(100),
429
- categoryId: z.string().min(1, "카테고리를 선택해주십시오"),
430
- salePrice: z
431
- .number()
432
- .int()
433
- .min(10)
434
- .multipleOf(10, "판매가는 10원 단위여야 합니다 (INVALID_PRICE_UNIT)")
435
- .describe("판매가. 원(KRW) 단위 정수, 최소 10원, 10원 단위"),
436
- originalPrice: z.number().int().optional().describe("정가(할인 전 표시 가격). 원 단위"),
437
- description: z.string().optional(),
438
- /**
439
- * 상세설명 에디터(@avarlabs/editor)의 직렬화 상태. 재편집 때 이 값으로 문서를 복원한다.
440
- * 표시는 `description`(HTML)이 맡는다 — 소비자는 이 필드를 몰라도 된다.
441
- */
442
- descriptionJson: z.string().nullable().optional(),
443
- images: z
444
- .array(productImageUrlSchema)
445
- .min(1)
446
- .max(MAX_PRODUCT_IMAGES)
447
- .optional()
448
- .describe("상품 이미지 주소 목록. 첫 장이 대표 이미지(썸네일)다"),
449
- attributes: z.array(z.object({ attributeId: z.string(), value: z.string() })).optional(),
450
- deliveryType: deliveryTypeSchema
451
- .default("PAID")
452
- .describe("배송비 유형. FREE 무료, PAID 유료, CONDITIONAL_FREE 조건부 무료"),
453
- deliveryFee: z
454
- .number()
455
- .int()
456
- .min(0)
457
- .optional()
458
- .describe(
459
- "배송비. 원 단위. **생략하면 스토어 기본 배송비**(`GET /v1/store`의 `shippingPolicy.baseDeliveryFee`)를 쓴다. " +
460
- "`deliveryType`이 FREE면 0으로 저장한다",
461
- ),
462
- conditionalFreeAmount: z
463
- .number()
464
- .int()
465
- .optional()
466
- .describe("CONDITIONAL_FREE일 때 무료 배송이 되는 주문 금액. 원 단위"),
467
- returnFee: z
468
- .number()
469
- .int()
470
- .default(0)
471
- .describe("반품 배송비. 원 단위. `refundPolicy.returnDeliveryFee`와 같은 값이다"),
472
- exchangeFee: z
473
- .number()
474
- .int()
475
- .default(0)
476
- .describe("교환 배송비. 원 단위. `refundPolicy.exchangeDeliveryFee`와 같은 값이다"),
477
- refundPolicy: productRefundPolicyPatchSchema
478
- .nullish()
479
- .describe(
480
- "상품별 환불 정책 — 준 항목만 스토어 기본을 덮어쓴다. `null`이나 생략은 스토어 기본을 그대로 쓴다는 뜻이다. " +
481
- "`returnDeliveryFee`·`exchangeDeliveryFee`를 주면 `returnFee`·`exchangeFee`도 같은 값으로 맞춘다",
482
- ),
483
- shippingAddressId: z.string().nullable().optional().describe(ADDRESS_DESCRIPTIONS.shipping),
484
- returnAddressId: z.string().nullable().optional().describe(ADDRESS_DESCRIPTIONS.return),
485
- ...productFulfillmentRequestShape(),
486
- })
487
- .extend(optionModelRequestSchema.shape);
327
+ export const productCreateRequestSchema = z.strictObject({
328
+ ...productWriteShape,
329
+ fulfillment: productFulfillmentCreateSchema
330
+ .optional()
331
+ .describe(
332
+ "상품 이행. `type`이 유형이고 같은 이름의 키가 유형별 속성이다. 생략하면 배송 상품(기본값)이다. " +
333
+ "배송 상품이 아닌 유형은 판매가 열린 유형만 등록할 수 있다(409 `FULFILLMENT_TYPE_NOT_AVAILABLE`)",
334
+ ),
335
+ });
488
336
 
489
337
  export type ProductCreateRequest = z.infer<typeof productCreateRequestSchema>;
338
+ export type ProductCreateRequestInput = z.input<typeof productCreateRequestSchema>;
339
+
340
+ const fulfillmentUpdateDescription =
341
+ "상품 이행. 생략하면 바꾸지 않는다. 주면 유형 안 필드를 모두 담아 통째로 바꾼다(현재 값과 병합하지 않는다). " +
342
+ "판매 이력이 있으면 유형을 바꿀 수 없다(409 `FULFILLMENT_TYPE_LOCKED`)";
343
+
344
+ /** 전체 수정(PUT) — 공통 필드는 등록과 같고, 이행은 생략하면 현재 값 유지, 주면 필드가 모두 필수다 */
345
+ export const productReplaceRequestSchema = z.strictObject({
346
+ ...productWriteShape,
347
+ fulfillment: productFulfillmentUpdateSchema.optional().describe(fulfillmentUpdateDescription),
348
+ });
349
+
350
+ export type ProductReplaceRequest = z.infer<typeof productReplaceRequestSchema>;
351
+ export type ProductReplaceRequestInput = z.input<typeof productReplaceRequestSchema>;
490
352
 
491
353
  export const productPatchRequestSchema = z
492
- .object({
354
+ .strictObject({
493
355
  name: z.string().max(100).optional(),
494
356
  salePrice: z.number().int().multipleOf(10).optional(),
495
357
  status: productStatusSchema.optional(),
496
- deliveryFee: z.number().int().optional(),
497
- // PATCH의 생략은 「바꾸지 않음」이다 — 기본 주소로 되채우지 않는다. 비우려면 `null`을 준다
498
- shippingAddressId: z
499
- .string()
358
+ fulfillment: productFulfillmentUpdateSchema.optional().describe(fulfillmentUpdateDescription),
359
+ refundPolicy: productRefundPolicyPatchSchema
500
360
  .nullable()
501
361
  .optional()
502
- .describe(ADDRESS_DESCRIPTIONS.patchShipping),
503
- returnAddressId: z.string().nullable().optional().describe(ADDRESS_DESCRIPTIONS.patchReturn),
504
- ...productFulfillmentRequestShape(),
362
+ .describe(
363
+ "상품별 환불 정책. 주면 통째로 바꾼다(준 항목만 스토어 기본을 덮어쓴다). `null`은 덮어쓰기를 지워 스토어 기본을 따른다. 생략하면 바꾸지 않는다",
364
+ ),
505
365
  })
506
366
  .refine((value) => Object.values(value).some((v) => v !== undefined), {
507
367
  message: "최소 1개 필드가 필요합니다",
@@ -16,11 +16,9 @@ const descriptions = {
16
16
  "발송 전 취소로 배송 묶음(같은 상품의 주문상품)이 모두 취소되면 그 묶음의 배송비를 환불한다. " +
17
17
  "false면 배송비를 환불하지 않는다. 기본 true",
18
18
  returnDeliveryFee:
19
- "단순 변심(구매자 귀책) 반품의 반품 배송비. 원 단위. 환불액에서 뺀다. 기본 0(무료). " +
20
- "상품별 정책에서 이 값을 주면 상품의 `returnFee`도 같은 값으로 맞춘다",
19
+ "단순 변심(구매자 귀책) 반품의 반품 배송비. 원 단위. 환불액에서 뺀다. 기본 0(무료). 배송 상품에만 있다",
21
20
  exchangeDeliveryFee:
22
- "단순 변심(구매자 귀책) 교환의 교환 배송비. 원 단위. 구매자에게 청구할 금액을 안내값으로 싣는다. 기본 0(무료). " +
23
- "상품별 정책에서 이 값을 주면 상품의 `exchangeFee`도 같은 값으로 맞춘다",
21
+ "단순 변심(구매자 귀책) 교환의 교환 배송비. 원 단위. 구매자에게 청구할 금액을 안내값으로 싣는다. 기본 0(무료). 배송 상품에만 있다",
24
22
  sellerFaultFreeShipping:
25
23
  "판매자 귀책(셀러 직권취소, 불량·오배송 반품)이면 배송비를 셀러가 부담한다 — 배송비를 환불하고 반품비를 받지 않는다. " +
26
24
  "false면 구매자 귀책과 같은 규칙을 쓴다. 기본 true",
@@ -101,23 +99,17 @@ export const productRefundPolicyPatchSchema = refundPolicyPatchSchema.omit({
101
99
  couponShortfallCharge: true,
102
100
  });
103
101
 
104
- /** 정책 해석에 쓰는 상품 값 — 상품별 덮어쓰기와 레거시 배송비 필드 */
102
+ /** 정책 해석에 쓰는 상품 값 — 상품별 덮어쓰기 */
105
103
  export interface RefundPolicyProductInput {
106
104
  /** 상품별 덮어쓰기. 없으면 스토어 기본을 그대로 쓴다 */
107
105
  refundPolicy?: RefundPolicyPatch | null;
108
- /** @deprecated 레거시 상품 반품비 — `refundPolicy`가 없는 옛 상품의 값이다 */
109
- returnFee?: number | null;
110
- /** @deprecated 레거시 상품 교환비 — `refundPolicy`가 없는 옛 상품의 값이다 */
111
- exchangeFee?: number | null;
112
106
  }
113
107
 
114
108
  /**
115
109
  * 유효 정책 해석 — 상품별 덮어쓰기 → 스토어 기본 → `REFUND_POLICY_DEFAULTS` 순이다.
116
110
  *
117
- * 반품비·교환비는 레거시 필드(`products.returnFee`·`exchangeFee`)와 **연결**한다. 상품이
118
- * `refundPolicy.returnDeliveryFee`를 정하면 그 값이 원천이고(서버가 `returnFee`도 같은 값으로 맞춘다),
119
- * 정하지 않았으면 레거시 값이 0보다 클 때 그 값을 쓴다. 0이면 스토어 기본을 따른다 —
120
- * 정책이 생기기 전에 만든 상품(반품비 0)이 스토어 기본을 물려받게 하는 규칙이다.
111
+ * 반품·교환 배송비의 원천은 정책 하나다(이슈 #57 결정 3). 상품의 레거시 반품비·교환비 필드와의 연결은 없앴고,
112
+ * 레거시 값은 마이그레이션이 상품별 정책으로 옮겼다.
121
113
  */
122
114
  export function resolveRefundPolicy(
123
115
  storePolicy?: RefundPolicyPatch | null,
@@ -128,29 +120,27 @@ export function resolveRefundPolicy(
128
120
  }
129
121
 
130
122
  /**
131
- * 상품의 **덮어쓰기 자체** — 레거시 반품비·교환비를 같은 형태로 승격한 값이다. 덮어쓰기가 없으면 null.
123
+ * 상품의 **덮어쓰기 자체** — 덮어쓰기가 없으면 null.
132
124
  *
133
125
  * `resolveRefundPolicy`가 합친 결과로는 "상품이 정한 값"과 "스토어 기본을 물려받은 값"을 가를 수 없다.
134
126
  * 관리 API 응답의 `refundPolicy`와 콘솔 폼의 덮어쓰기 스위치가 이 함수를 원천으로 쓴다 — 가르지 못하면
135
- * 스토어 기본을 쓰는 상품을 저장할 때 그 순간의 기본값이 상품에 박히거나, 레거시 값이 조용히 사라진다.
127
+ * 스토어 기본을 쓰는 상품을 저장할 때 그 순간의 기본값이 상품에 박힌다.
136
128
  */
137
129
  export function productRefundPolicyOverride(
138
130
  product?: RefundPolicyProductInput | null,
139
131
  ): RefundPolicyPatch | null {
132
+ if (product?.refundPolicy == null) return null;
140
133
  // 쿠폰 조건 미달 차감은 주문 단위라 스토어만 정한다 — 상품 행에 키가 있어도 덮어쓰기로 읽지 않는다(이슈 #47)
141
- const { couponShortfallCharge: _storeOnly, ...override } = dropUndefined(product?.refundPolicy);
142
- const legacy: RefundPolicyPatch = {};
143
- if (override.returnDeliveryFee === undefined && (product?.returnFee ?? 0) > 0) {
144
- legacy.returnDeliveryFee = product?.returnFee ?? 0;
145
- }
146
- if (override.exchangeDeliveryFee === undefined && (product?.exchangeFee ?? 0) > 0) {
147
- legacy.exchangeDeliveryFee = product?.exchangeFee ?? 0;
148
- }
149
- const merged = { ...legacy, ...override };
150
- if (product?.refundPolicy == null && Object.keys(merged).length === 0) return null;
151
- return merged;
134
+ const { couponShortfallCharge: _storeOnly, ...override } = dropUndefined(product.refundPolicy);
135
+ return override;
152
136
  }
153
137
 
138
+ /** 반품·교환 배송비 키 — 배송이 없는 상품의 정책에는 두지 않는다 */
139
+ export const REFUND_POLICY_DELIVERY_FEE_KEYS = [
140
+ "returnDeliveryFee",
141
+ "exchangeDeliveryFee",
142
+ ] as const;
143
+
154
144
  /** `undefined` 값을 빼고 얕게 복사한다 — 전개 순서로 덮어쓸 때 `undefined`가 기본값을 지우지 않게 */
155
145
  function dropUndefined(patch?: RefundPolicyPatch | null): RefundPolicyPatch {
156
146
  if (!patch) return {};
@@ -1,6 +1,5 @@
1
1
  import { z } from "zod";
2
- import { type FulfillmentType, requiresShipping } from "./fulfillment";
3
- import type { DeliveryType } from "./product";
2
+ import { type DeliveryType, type FulfillmentType, requiresShipping } from "./fulfillment";
4
3
 
5
4
  /**
6
5
  * 배송비 규칙 (이슈 #29) — 배송비를 **몇 번, 얼마나** 부과하는지를 정한다.
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { claimStatusSchema, claimTypeSchema } from "./claim";
3
3
  import { orderItemStatusSchema } from "./common";
4
+ import { orderItemFulfillmentSnapshotResponseSchema } from "./fulfillment";
4
5
 
5
6
  /**
6
7
  * 웹훅 발송 형식 (공개 계약). 본문은 얇은 이벤트다. 개인정보를 싣지 않으며
@@ -59,13 +60,11 @@ export const orderWebhookEventDataSchema = z.object({
59
60
  "이벤트를 만든 주문상품 ID. 비실물 상품 제공으로 보내는 `ORDER.DELIVERED`에만 실린다. " +
60
61
  "택배 배송완료와 그 밖의 주문 이벤트에는 없다",
61
62
  ),
62
- fulfillmentType: z
63
- .string()
63
+ fulfillmentSnapshot: orderItemFulfillmentSnapshotResponseSchema
64
64
  .optional()
65
65
  .describe(
66
- "이벤트를 만든 주문상품의 상품 유형. 비실물 상품의 제공 완료는 `ORDER.DELIVERED`로도 오고 이 값이 `SHIPPING`이 아니다. " +
67
- "현재 값은 `SHIPPING`·`DOWNLOAD`·`CODE`·`SERVICE`·`PICKUP`이고 값이 추가될 수 있어 문자열로 받는다. " +
68
- "비실물 제공이 아닌 이벤트에는 없고, 없으면 `SHIPPING`으로 읽는다",
66
+ "이벤트를 만든 주문상품의 주문 시점 이행 스냅샷(주문상품 응답의 `fulfillmentSnapshot`과 같다, 이슈 #57). " +
67
+ "비실물 상품의 제공 완료는 `ORDER.DELIVERED`로도 오고 `type`이 `SHIPPING`이 아니다. `orderItemId`가 있는 이벤트에만 실린다",
69
68
  ),
70
69
  });
71
70
  export type OrderWebhookEventData = z.infer<typeof orderWebhookEventDataSchema>;
@@ -79,15 +78,18 @@ export const orderFulfilledWebhookEventDataSchema = z.object({
79
78
  orderId: z.string().describe("주문 ID"),
80
79
  orderItemId: z.string().describe("제공한 주문상품 ID"),
81
80
  status: orderItemStatusSchema.describe("이벤트 발생 시점의 주문 대표 상태"),
82
- fulfillmentType: z
83
- .string()
84
- .describe(
85
- "상품 유형. 현재 값은 `DOWNLOAD`·`CODE`·`SERVICE`·`PICKUP`이고 값이 추가될 수 있어 문자열로 받는다",
86
- ),
87
- fulfillmentMode: z
88
- .string()
89
- .nullable()
90
- .describe("제공 방식. `AUTO`(결제 확정과 함께)·`MANUAL`(셀러 제공 처리)"),
81
+ fulfillmentSnapshot: orderItemFulfillmentSnapshotResponseSchema.describe(
82
+ "제공한 주문상품의 주문 시점 이행 스냅샷(주문상품 응답의 `fulfillmentSnapshot`과 같다, 이슈 #57)",
83
+ ),
84
+ fulfillment: z
85
+ .object({
86
+ mode: z
87
+ .string()
88
+ .describe(
89
+ "실제 제공 방식. `AUTO`(결제 확정과 함께)·`MANUAL`(셀러 제공 처리). 주문상품 제공 기록의 `mode`와 같다",
90
+ ),
91
+ })
92
+ .describe("제공 기록"),
91
93
  fulfilledAt: z.string().describe("제공 시각 (ISO 8601). 주문상품의 `deliveredAt`과 같다"),
92
94
  testPayment: z
93
95
  .boolean()