@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.
@@ -27,6 +27,9 @@ const descriptions = {
27
27
  conditionalFreeShortfallCharge:
28
28
  "조건부 무료배송(`CONDITIONAL_FREE`) 상품에서 일부 취소로 남은 금액이 무료 기준액에 미달하면 그 배송비를 환불액에서 뺀다. " +
29
29
  "false면 추가 청구 없이 셀러가 부담한다. 기본 false",
30
+ couponShortfallCharge:
31
+ "쿠폰 조건 미달 차감. 일부 취소·반품으로 남은 주문상품이 쿠폰 조건(최소 주문 금액 등)을 잃으면 남은 주문상품이 받던 쿠폰 할인을 환불액에서 뺀다. " +
32
+ "판매자 귀책(셀러 직권취소, 불량·오배송 반품)은 빼지 않는다. false면 차감 없이 실결제 금액만 나눠 환불한다. 기본 true",
30
33
  } as const;
31
34
 
32
35
  /** 유효 환불 정책 — 스토어 기본 + 상품별 덮어쓰기를 합친 결과 */
@@ -36,6 +39,13 @@ export const refundPolicySchema = z.object({
36
39
  exchangeDeliveryFee: z.number().int().min(0).describe(descriptions.exchangeDeliveryFee),
37
40
  sellerFaultFreeShipping: z.boolean().describe(descriptions.sellerFaultFreeShipping),
38
41
  conditionalFreeShortfallCharge: z.boolean().describe(descriptions.conditionalFreeShortfallCharge),
42
+ couponShortfallCharge: z
43
+ .boolean()
44
+ .optional()
45
+ .default(true)
46
+ .describe(
47
+ `${descriptions.couponShortfallCharge}. 이 필드가 없는 이전 서버 응답은 true로 읽는다`,
48
+ ),
39
49
  });
40
50
 
41
51
  export type RefundPolicy = z.infer<typeof refundPolicySchema>;
@@ -52,6 +62,7 @@ export const REFUND_POLICY_DEFAULTS: RefundPolicy = {
52
62
  exchangeDeliveryFee: 0,
53
63
  sellerFaultFreeShipping: true,
54
64
  conditionalFreeShortfallCharge: false,
65
+ couponShortfallCharge: true,
55
66
  };
56
67
 
57
68
  /**
@@ -77,10 +88,19 @@ export const refundPolicyPatchSchema = z.strictObject({
77
88
  .boolean()
78
89
  .optional()
79
90
  .describe(descriptions.conditionalFreeShortfallCharge),
91
+ couponShortfallCharge: z.boolean().optional().describe(descriptions.couponShortfallCharge),
80
92
  });
81
93
 
82
94
  export type RefundPolicyPatch = z.infer<typeof refundPolicyPatchSchema>;
83
95
 
96
+ /**
97
+ * 상품별 덮어쓰기 — 쿠폰 조건 미달 차감(`couponShortfallCharge`)은 주문 단위 규칙이라 상품이 정하지 않는다.
98
+ * 스토어 정책만 읽는다(이슈 #47).
99
+ */
100
+ export const productRefundPolicyPatchSchema = refundPolicyPatchSchema.omit({
101
+ couponShortfallCharge: true,
102
+ });
103
+
84
104
  /** 정책 해석에 쓰는 상품 값 — 상품별 덮어쓰기와 레거시 배송비 필드 */
85
105
  export interface RefundPolicyProductInput {
86
106
  /** 상품별 덮어쓰기. 없으면 스토어 기본을 그대로 쓴다 */
@@ -117,7 +137,8 @@ export function resolveRefundPolicy(
117
137
  export function productRefundPolicyOverride(
118
138
  product?: RefundPolicyProductInput | null,
119
139
  ): RefundPolicyPatch | null {
120
- const override = dropUndefined(product?.refundPolicy);
140
+ // 쿠폰 조건 미달 차감은 주문 단위라 스토어만 정한다 — 상품 행에 키가 있어도 덮어쓰기로 읽지 않는다(이슈 #47)
141
+ const { couponShortfallCharge: _storeOnly, ...override } = dropUndefined(product?.refundPolicy);
121
142
  const legacy: RefundPolicyPatch = {};
122
143
  if (override.returnDeliveryFee === undefined && (product?.returnFee ?? 0) > 0) {
123
144
  legacy.returnDeliveryFee = product?.returnFee ?? 0;
@@ -143,7 +164,8 @@ function dropUndefined(patch?: RefundPolicyPatch | null): RefundPolicyPatch {
143
164
  *
144
165
  * 원 단위 반올림 규칙: `floor(주문상품 금액 × 누적 수량 ÷ 전체 수량)`을 두 번 구해 그 차를 쓴다.
145
166
  * 부분 취소를 몇 번 나눠도 합이 주문상품 금액과 정확히 같아서 1원이 남거나 넘치지 않는다.
146
- * 할인은 이미 단가에 들어 있어(주문 단위 쿠폰이 없다) 별도 안분이 없다.
167
+ * 즉시할인은 이미 단가에 들어 있다. 쿠폰 할인은 단가에 들어가지 않으므로 환불은 실결제 금액을 나누는
168
+ * `proratedPaidRefundBase`(promotion.ts)를 쓴다(이슈 #47).
147
169
  *
148
170
  * @param totalPrice 주문상품 금액 (할인 반영 단가 × 전체 수량)
149
171
  * @param quantity 주문상품 전체 수량
@@ -29,6 +29,7 @@ export const STORE_SCOPE_RESOURCES = {
29
29
  member: ["r", "rw"],
30
30
  customer: ["r", "rw"],
31
31
  audit: ["r"],
32
+ promotion: ["r", "rw"],
32
33
  } as const satisfies Record<string, readonly ScopeAccess[]>;
33
34
 
34
35
  export type StoreScopeResource = keyof typeof STORE_SCOPE_RESOURCES;
@@ -65,6 +66,7 @@ export const STORE_ROLE_SCOPES = {
65
66
  "member:rw",
66
67
  "customer:rw",
67
68
  "audit:r",
69
+ "promotion:rw",
68
70
  ],
69
71
  ADMIN: [
70
72
  "product:rw",
@@ -79,6 +81,7 @@ export const STORE_ROLE_SCOPES = {
79
81
  "member:rw",
80
82
  "customer:rw",
81
83
  "audit:r",
84
+ "promotion:rw",
82
85
  ],
83
86
  STAFF: [
84
87
  "product:rw",
@@ -88,5 +91,6 @@ export const STORE_ROLE_SCOPES = {
88
91
  "inquiry:rw",
89
92
  "review:rw",
90
93
  "customer:r",
94
+ "promotion:r",
91
95
  ],
92
96
  } as const satisfies Record<StoreRole, readonly string[]>;
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { type FulfillmentType, requiresShipping } from "./fulfillment";
2
3
  import type { DeliveryType } from "./product";
3
4
 
4
5
  /**
@@ -19,6 +20,9 @@ import type { DeliveryType } from "./product";
19
20
  *
20
21
  * 계산은 `quoteDelivery` 하나다. 장바구니·주문서·결제 시작·환불 정산이 모두 이 함수를 쓴다 —
21
22
  * 네 곳이 각자 계산하면 화면 금액과 결제 금액이 갈라진다.
23
+ *
24
+ * **비실물 줄은 배송 계산에서 빠진다**(이슈 #48, `requiresShipping`). 묶음을 만들지 않고, 스토어 전체 무료 기준의
25
+ * 판정 금액(`shippableAmount`)에도 넣지 않는다 — 디지털 상품 금액이 실물 배송비를 면제시키지 않게 한다.
22
26
  */
23
27
 
24
28
  const descriptions = {
@@ -219,6 +223,11 @@ export interface DeliveryBundleProduct {
219
223
  conditionalFreeAmount: number | null;
220
224
  /** 출고지 주소 id(`seller_addresses`). null이면 스토어 기본 출고지를 쓰는 상품끼리 한 묶음이다 */
221
225
  shippingAddressId?: string | null;
226
+ /**
227
+ * 상품 유형 (이슈 #48). 생략·null이면 배송 상품이다. 배송이 필요 없는 유형은 묶음을 만들지 않는다.
228
+ * 줄(`DeliveryFeeLine.fulfillmentType`)에 값이 있으면 그 값이 이긴다.
229
+ */
230
+ fulfillmentType?: FulfillmentType | null;
222
231
  }
223
232
 
224
233
  /**
@@ -246,6 +255,11 @@ export interface DeliveryFeeLine {
246
255
  productId: string;
247
256
  /** 상품 금액 (할인 반영 단가 × 수량) */
248
257
  amount: number;
258
+ /**
259
+ * 이 줄의 상품 유형 — 주문서 스냅샷 값이다(이슈 #48). 주면 상품의 현재 유형보다 우선한다. 주문서를 만든 뒤 셀러가
260
+ * 유형을 바꿔도 결제 시작의 견적과 주문상품 스냅샷이 같은 유형을 보게 한다. 생략하면 상품 값을 쓴다.
261
+ */
262
+ fulfillmentType?: FulfillmentType | null;
249
263
  }
250
264
 
251
265
  /**
@@ -290,6 +304,13 @@ export interface DeliveryQuote {
290
304
  remoteArea: RemoteArea;
291
305
  /** 스토어 전체 무료 기준(`freeThreshold`) 충족으로 기본 배송비를 면제했는가 */
292
306
  freeByThreshold: boolean;
307
+ /**
308
+ * 배송이 필요한 줄의 상품 금액 합 — 스토어 전체 무료 기준의 판정 금액이다(이슈 #48). 비실물 줄은 빠진다.
309
+ * 주문 상품 금액 합(`productAmount`)과 다를 수 있다.
310
+ */
311
+ shippableAmount: number;
312
+ /** 배송이 필요한 줄이 하나라도 있는가 — 배송 묶음이 하나라도 있는가다. false면 배송지를 받지 않는다 */
313
+ requiresShipping: boolean;
293
314
  }
294
315
 
295
316
  export interface DeliveryQuoteInput {
@@ -319,11 +340,13 @@ export function quoteDelivery(input: DeliveryQuoteInput): DeliveryQuote {
319
340
  string,
320
341
  { product: DeliveryBundleProduct; ids: Set<string>; sum: number }
321
342
  >();
322
- let productAmount = 0;
343
+ let shippableAmount = 0;
323
344
  for (const line of input.lines) {
324
345
  const product = input.productOf(line.productId);
325
346
  if (!product) continue;
326
- productAmount += line.amount;
347
+ // 비실물은 묶음도 무료 기준 판정 금액도 만들지 않는다 — 디지털 금액이 실물 배송비를 면제시키지 않게 한다
348
+ if (!requiresShipping(line.fulfillmentType ?? product.fulfillmentType)) continue;
349
+ shippableAmount += line.amount;
327
350
  const key = deliveryBundleKeyOf(product);
328
351
  const group = groups.get(key) ?? { product, ids: new Set<string>(), sum: 0 };
329
352
  group.ids.add(line.productId);
@@ -332,7 +355,7 @@ export function quoteDelivery(input: DeliveryQuoteInput): DeliveryQuote {
332
355
  }
333
356
 
334
357
  const freeByThreshold =
335
- policy.freeThreshold !== null && productAmount >= policy.freeThreshold && groups.size > 0;
358
+ policy.freeThreshold !== null && shippableAmount >= policy.freeThreshold && groups.size > 0;
336
359
 
337
360
  const bundles: DeliveryBundleQuote[] = [];
338
361
  for (const [bundleKey, group] of groups) {
@@ -371,5 +394,7 @@ export function quoteDelivery(input: DeliveryQuoteInput): DeliveryQuote {
371
394
  deliveryFee: baseFee + remoteSurcharge,
372
395
  remoteArea: area,
373
396
  freeByThreshold,
397
+ shippableAmount,
398
+ requiresShipping: bundles.length > 0,
374
399
  };
375
400
  }
@@ -166,6 +166,22 @@ export const updateStoreRequestSchema = z
166
166
  "비회원 주문 허용. false(회원만)면 로그인하지 않은 구매자의 주문서 생성·결제 요청이 403 `GUEST_CHECKOUT_DISABLED`다. " +
167
167
  "장바구니 담기와 이미 받은 비회원 주문의 조회·취소는 막지 않는다",
168
168
  ),
169
+ fulfillmentMailEnabled: z
170
+ .boolean()
171
+ .optional()
172
+ .describe(
173
+ "비실물 상품 제공 안내 메일(이슈 #48). 켜면(기본) 제공이 끝났을 때 구매자에게 안내 메일을 보낸다. 코드·파일 주소는 싣지 않는다",
174
+ ),
175
+ withdrawalNotice: z
176
+ .string()
177
+ .trim()
178
+ .min(1)
179
+ .max(1000)
180
+ .nullable()
181
+ .optional()
182
+ .describe(
183
+ "청약철회 제한 고지 문구(이슈 #48). 제한 상품이 있는 주문서에 보인다. null이면 플랫폼 기본 문구를 쓴다",
184
+ ),
169
185
  autoDeliverDays: z
170
186
  .number()
171
187
  .int()
@@ -231,6 +247,17 @@ export const storeSchema = z.object({
231
247
  "스토어프론트 도메인 — 소셜 로그인 리디렉트와 방문 분석 수집이 함께 쓴다(`analyticsAllowedOrigins`와 같은 값)",
232
248
  ),
233
249
  guestCheckoutEnabled: z.boolean().describe("비회원 주문 허용. false면 회원만 주문할 수 있다"),
250
+ fulfillmentMailEnabled: z
251
+ .boolean()
252
+ .optional()
253
+ .default(true)
254
+ .describe("비실물 상품 제공 안내 메일(이슈 #48). 서버는 항상 싣는다"),
255
+ withdrawalNotice: z
256
+ .string()
257
+ .nullable()
258
+ .optional()
259
+ .default(null)
260
+ .describe("청약철회 제한 고지 문구(이슈 #48). null이면 플랫폼 기본 문구다. 서버는 항상 싣는다"),
234
261
  autoDeliverDays: z.number().int().describe(autoDeliverDaysDescription),
235
262
  autoConfirmDays: z.number().int().describe(autoConfirmDaysDescription),
236
263
  refundPolicy: refundPolicySchema.describe(
@@ -333,7 +360,11 @@ export const storeMemberRoleUpdatedSchema = z.object({
333
360
 
334
361
  export type StoreMemberRoleUpdated = z.infer<typeof storeMemberRoleUpdatedSchema>;
335
362
 
336
- export const addressTypeSchema = z.enum(["SHIPPING", "RETURN"]);
363
+ /**
364
+ * 주소 유형 — 출고지·반품지·방문 수령지(이슈 #48). 방문 수령지(`PICKUP`)는 목록에서 `type=PICKUP`으로 조회할 때만 나온다
365
+ * (이 값을 모르는 옛 SDK의 목록 파싱이 깨지지 않게).
366
+ */
367
+ export const addressTypeSchema = z.enum(["SHIPPING", "RETURN", "PICKUP"]);
337
368
 
338
369
  export type AddressType = z.infer<typeof addressTypeSchema>;
339
370
 
@@ -5,8 +5,10 @@ import { z } from "zod";
5
5
  *
6
6
  * - `product-image`: 상품 목록·상세의 상품 이미지. 목록 썸네일로 쓰이므로 정지 이미지만 받는다.
7
7
  * - `description`: 상세설명 본문에 삽입하는 미디어. 동영상과 svg도 받는다.
8
+ * - `digital-asset`: 파일 다운로드 상품의 판매 파일(이슈 #48). **비공개 객체**로 올라가고 공개 주소가 없다 — 구매자는 제공된
9
+ * 주문상품에서 수명 300초의 서명 주소로만 받는다.
8
10
  */
9
- export const uploadPurposeSchema = z.enum(["product-image", "description"]);
11
+ export const uploadPurposeSchema = z.enum(["product-image", "description", "digital-asset"]);
10
12
 
11
13
  export type UploadPurpose = z.infer<typeof uploadPurposeSchema>;
12
14
 
@@ -34,6 +36,37 @@ export const UPLOAD_PURPOSE_LIMITS: Record<UploadPurpose, UploadPurposeLimit> =
34
36
  extensions: ["jpg", "jpeg", "png", "gif", "webp", "avif", "svg", "mp4", "webm", "mov"],
35
37
  maxBytes: 50 * MB,
36
38
  },
39
+ // 판매 파일 — 실행 파일(exe·dmg 등)은 받지 않는다. 크기 상한은 CDN 업로드 한도 안으로 잡는다
40
+ "digital-asset": {
41
+ extensions: [
42
+ "pdf",
43
+ "epub",
44
+ "zip",
45
+ "mp3",
46
+ "wav",
47
+ "flac",
48
+ "m4a",
49
+ "mp4",
50
+ "mov",
51
+ "jpg",
52
+ "jpeg",
53
+ "png",
54
+ "webp",
55
+ "psd",
56
+ "ai",
57
+ "txt",
58
+ "csv",
59
+ "xlsx",
60
+ "docx",
61
+ "pptx",
62
+ ],
63
+ maxBytes: 200 * MB,
64
+ },
65
+ };
66
+
67
+ /** 비공개 업로드(판매 파일)의 PUT 필수 헤더 — CDN이 이 값으로 공개 조회를 막는다 */
68
+ export const PRIVATE_UPLOAD_HEADERS: Readonly<Record<string, string>> = {
69
+ "x-amz-meta-visibility": "private",
37
70
  };
38
71
 
39
72
  /** 파일명의 소문자 확장자. 확장자가 없으면 `""` */
@@ -100,10 +133,23 @@ export const uploadRequestSchema = z.object({
100
133
  export type UploadRequest = z.infer<typeof uploadRequestSchema>;
101
134
 
102
135
  export const uploadTicketSchema = z.object({
103
- /** 업로드 완료 후 공개될 주소 */
136
+ /** 업로드 완료 후 공개될 주소. 비공개 업로드(`digital-asset`)는 이 주소로 받을 수 없다 */
104
137
  url: z.url(),
105
138
  /** 파일 바이트를 PUT할 presigned URL. 1시간 유효 */
106
139
  uploadUrl: z.url(),
140
+ key: z
141
+ .string()
142
+ .optional()
143
+ .describe(
144
+ "객체 키. 비공개 업로드(`digital-asset`)는 이 값으로 상품에 파일을 연결한다(`POST /v1/products/{productId}/digital-assets`). 공개 업로드에는 없다",
145
+ ),
146
+ headers: z
147
+ .record(z.string(), z.string())
148
+ .optional()
149
+ .default({})
150
+ .describe(
151
+ "PUT에 반드시 실을 헤더. 비공개 업로드는 `x-amz-meta-visibility: private`이고 빠뜨리면 공개 객체가 된다. 공개 업로드는 빈 객체다",
152
+ ),
107
153
  });
108
154
 
109
155
  export type UploadTicket = z.infer<typeof uploadTicketSchema>;
@@ -23,6 +23,14 @@ const envelopeFields = {
23
23
  id: z.string().describe("이벤트 ID (`evt_…`, 테스트 전송은 `evt_test_…`). 중복 제거 키"),
24
24
  createdAt: z.string().describe("이벤트 발생 일시 (ISO 8601)"),
25
25
  storeId: z.string().describe("이벤트가 발생한 스토어 ID"),
26
+ sequence: z
27
+ .number()
28
+ .int()
29
+ .positive()
30
+ .optional()
31
+ .describe(
32
+ "리소스별 순번. 같은 주문·클레임·문의·리뷰·상품의 이벤트마다 1씩 커진다. 도착 순서는 보장하지 않으므로 같은 리소스의 이벤트는 이 값으로 정렬한다. 이 필드가 생기기 전의 이벤트와 테스트 전송에는 없다",
33
+ ),
26
34
  };
27
35
 
28
36
  export const orderWebhookEventDataSchema = z.object({
@@ -35,9 +43,58 @@ export const orderWebhookEventDataSchema = z.object({
35
43
  .describe(
36
44
  "테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았다. 이 필드가 없는 이전 이벤트는 false로 읽는다",
37
45
  ),
46
+ couponDiscountAmount: z
47
+ .number()
48
+ .int()
49
+ .optional()
50
+ .default(0)
51
+ .describe(
52
+ "주문이 받은 상품·주문 쿠폰 할인 합계 (원). 즉시할인(`discountAmount`)과 별개다. " +
53
+ "쿠폰이 없거나 이 필드가 없는 이전 이벤트는 0으로 읽는다",
54
+ ),
55
+ orderItemId: z
56
+ .string()
57
+ .optional()
58
+ .describe(
59
+ "이벤트를 만든 주문상품 ID. 비실물 상품 제공으로 보내는 `ORDER.DELIVERED`에만 실린다. " +
60
+ "택배 배송완료와 그 밖의 주문 이벤트에는 없다",
61
+ ),
62
+ fulfillmentType: z
63
+ .string()
64
+ .optional()
65
+ .describe(
66
+ "이벤트를 만든 주문상품의 상품 유형. 비실물 상품의 제공 완료는 `ORDER.DELIVERED`로도 오고 이 값이 `SHIPPING`이 아니다. " +
67
+ "현재 값은 `SHIPPING`·`DOWNLOAD`·`CODE`·`SERVICE`·`PICKUP`이고 값이 추가될 수 있어 문자열로 받는다. " +
68
+ "비실물 제공이 아닌 이벤트에는 없고, 없으면 `SHIPPING`으로 읽는다",
69
+ ),
38
70
  });
39
71
  export type OrderWebhookEventData = z.infer<typeof orderWebhookEventDataSchema>;
40
72
 
73
+ /**
74
+ * `ORDER.FULFILLED` — 비실물 주문상품(파일·코드·이용권·방문 수령)을 제공했다(이슈 #48). 자동 제공은 결제 확정과 함께,
75
+ * 수동 제공은 셀러의 제공 처리(`POST /v1/order-items/{orderItemId}/fulfill`)로 온다. 기존 구독자를 위해 같은 순간
76
+ * `ORDER.DELIVERED`도 함께 보낸다. **코드·파일 주소는 싣지 않는다** — 관리 API로 조회한다.
77
+ */
78
+ export const orderFulfilledWebhookEventDataSchema = z.object({
79
+ orderId: z.string().describe("주문 ID"),
80
+ orderItemId: z.string().describe("제공한 주문상품 ID"),
81
+ 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`(셀러 제공 처리)"),
91
+ fulfilledAt: z.string().describe("제공 시각 (ISO 8601). 주문상품의 `deliveredAt`과 같다"),
92
+ testPayment: z
93
+ .boolean()
94
+ .describe("테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았다"),
95
+ });
96
+ export type OrderFulfilledWebhookEventData = z.infer<typeof orderFulfilledWebhookEventDataSchema>;
97
+
41
98
  /**
42
99
  * `ORDER.CANCELED` — 셀러 직권취소(`POST /v1/order-items/{orderItemId}/cancel`)로 주문상품이 취소됐다.
43
100
  * 수량 단위 부분 취소도 이 이벤트다(`quantity` < 주문 수량이면 주문상품 상태가 그대로다).
@@ -64,7 +121,8 @@ export const orderCanceledWebhookEventDataSchema = z.object({
64
121
  .number()
65
122
  .int()
66
123
  .describe(
67
- "환불액 (원). 수량 비율 상품 금액 + 배송비 환불 − 조건부 무료배송 미달 차감이다. " +
124
+ "환불액 (원). 수량 비율 상품 실결제 금액(쿠폰 배분을 뺀 금액) + 배송비 환불 − 조건부 무료배송 미달 차감 " +
125
+ "+ 되돌린 쿠폰 차감(`couponChargeRefundAmount`)이다. " +
68
126
  "결제사 환불 결과는 결제 내역(`GET /v1/payments/{paymentId}`)으로 확인한다",
69
127
  ),
70
128
  deliveryFeeRefundAmount: z.number().int().describe("환불액에 포함된 배송비 (원)"),
@@ -72,6 +130,23 @@ export const orderCanceledWebhookEventDataSchema = z.object({
72
130
  .number()
73
131
  .int()
74
132
  .describe("환불액에서 뺀 배송비 (원) — 조건부 무료배송 기준 미달 차감"),
133
+ couponChargeAmount: z
134
+ .number()
135
+ .int()
136
+ .optional()
137
+ .default(0)
138
+ .describe(
139
+ "환불액에서 뺀 쿠폰 조건 미달 차감 (원). 판매자 취소는 차감하지 않으므로 늘 0이다. 이 필드가 없는 이전 이벤트는 0으로 읽는다",
140
+ ),
141
+ couponChargeRefundAmount: z
142
+ .number()
143
+ .int()
144
+ .optional()
145
+ .default(0)
146
+ .describe(
147
+ "환불액에 더한 금액 (원) — 앞선 환불에서 뺀 쿠폰 조건 미달 차감을 되돌린 몫이다. " +
148
+ "전량 취소면 누적 차감이 모두 되돌아간다. 이 필드가 없는 이전 이벤트는 0으로 읽는다",
149
+ ),
75
150
  testPayment: z
76
151
  .boolean()
77
152
  .describe("테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았다"),
@@ -124,6 +199,25 @@ export const claimWebhookEventDataSchema = z.object({
124
199
  "환불액에서 뺀 추가 차감 (원) — 셀러가 클레임 승인에 넣은 금액이다(상품 훼손 등). " +
125
200
  "배송비 차감과 별개이므로 `deliveryFeeChargeAmount`와 더해서 뺀다. 접수 이벤트와 이 필드가 없는 이전 이벤트는 0이다",
126
201
  ),
202
+ couponChargeAmount: z
203
+ .number()
204
+ .int()
205
+ .optional()
206
+ .default(0)
207
+ .describe(
208
+ "환불액에서 뺀 쿠폰 조건 미달 차감 (원) — 일부 취소·반품으로 남은 주문상품이 쿠폰 조건(최소 주문 금액 등)을 " +
209
+ "잃어 남은 주문상품이 받던 할인을 뺀 금액이다. 배송비 차감·추가 차감과 별개다. 접수 이벤트는 예상값이다. " +
210
+ "이 필드가 없는 이전 이벤트는 0으로 읽는다",
211
+ ),
212
+ couponChargeRefundAmount: z
213
+ .number()
214
+ .int()
215
+ .optional()
216
+ .default(0)
217
+ .describe(
218
+ "환불액에 더한 금액 (원) — 앞선 환불에서 뺀 쿠폰 조건 미달 차감을 되돌린 몫이다. 접수 이벤트는 예상값이다. " +
219
+ "이 필드가 없는 이전 이벤트는 0으로 읽는다",
220
+ ),
127
221
  testPayment: z
128
222
  .boolean()
129
223
  .optional()
@@ -171,6 +265,12 @@ export const orderWebhookEventSchema = z.object({
171
265
  data: orderWebhookEventDataSchema,
172
266
  });
173
267
 
268
+ export const orderFulfilledWebhookEventSchema = z.object({
269
+ ...envelopeFields,
270
+ type: z.literal("ORDER.FULFILLED"),
271
+ data: orderFulfilledWebhookEventDataSchema,
272
+ });
273
+
174
274
  export const orderCanceledWebhookEventSchema = z.object({
175
275
  ...envelopeFields,
176
276
  type: z.literal("ORDER.CANCELED"),
@@ -210,6 +310,7 @@ export const productOutOfStockWebhookEventSchema = z.object({
210
310
  export const webhookEventSchema = z
211
311
  .discriminatedUnion("type", [
212
312
  orderWebhookEventSchema,
313
+ orderFulfilledWebhookEventSchema,
213
314
  orderCanceledWebhookEventSchema,
214
315
  claimWebhookEventSchema,
215
316
  inquiryWebhookEventSchema,