@sayren/store-sdk 0.63.0 → 0.65.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.
package/src/schemas/ai.ts CHANGED
@@ -399,6 +399,25 @@ export const storeSetupBriefSchema = z.object({
399
399
  });
400
400
  export type StoreSetupBrief = z.infer<typeof storeSetupBriefSchema>;
401
401
 
402
+ /**
403
+ * 보내는 사람의 시간대(IANA 이름, 예 `Asia/Seoul`) — 브라우저 `Intl.DateTimeFormat().resolvedOptions().timeZone`. AI는 시각을 이 시간대로 쓴다.
404
+ * 생략하면 `Asia/Seoul`이다
405
+ */
406
+ export const aiTimeZoneSchema = z
407
+ .string()
408
+ .max(64)
409
+ .refine(
410
+ (value) => {
411
+ try {
412
+ new Intl.DateTimeFormat("en-US", { timeZone: value });
413
+ return true;
414
+ } catch {
415
+ return false;
416
+ }
417
+ },
418
+ { message: "IANA 시간대 이름이어야 합니다" },
419
+ );
420
+
402
421
  export const createAiConversationRequestSchema = strictRequest(
403
422
  z.object({
404
423
  /** 웹사이트에서 남긴 초안(#124 M2) — 연결한 사용자만 쓴다. 주면 첫 메시지로 실행을 시작한다 */
@@ -407,6 +426,8 @@ export const createAiConversationRequestSchema = strictRequest(
407
426
  brief: storeSetupBriefSchema.optional(),
408
427
  /** AI 종류 — 주지 않으면 초안으로 시작하면 `DESIGNER`, 아니면 `STORE`다 */
409
428
  agent: aiAgentSchema.optional(),
429
+ /** 첫 실행의 시간대(`draftId`로 시작할 때) */
430
+ timeZone: aiTimeZoneSchema.optional(),
410
431
  }),
411
432
  );
412
433
  export type CreateAiConversationRequest = z.infer<typeof createAiConversationRequestSchema>;
@@ -429,6 +450,8 @@ export const sendAiMessageRequestSchema = strictRequest(
429
450
  selection: aiPreviewSelectionSchema.optional(),
430
451
  /** 디자인 탭에서 고른 아트보드(2026-10-14) — 모델은 그 아트보드를 기준으로 고친다 */
431
452
  designSelection: aiDesignSelectionSchema.optional(),
453
+ /** 보내는 사람의 시간대 — 이 실행의 AI가 시각을 이 시간대로 쓴다 */
454
+ timeZone: aiTimeZoneSchema.optional(),
432
455
  })
433
456
  .refine((body) => body.text.length > 0 || body.attachments.length > 0, {
434
457
  message: "내용이나 첨부가 있어야 합니다",
@@ -45,7 +45,7 @@ export const appHandleSchema = z
45
45
 
46
46
  /**
47
47
  * 설치 앱에 열지 않는 스코프 — 구성원(초대로 권한 상승), 상점 설정 전반(`storefrontOrigins`로 소셜 로그인 code 가로채기), 스토어 웹훅
48
- * (앱 구독으로 대체, 제거 뒤에도 남는 문제), 스토어프론트 편집·발행, 감사 로그. 커스텀 연동은 계정 액세스 토큰(#71)이 맡는다
48
+ * (앱 구독으로 대체, 제거 뒤에도 남는 문제), 스토어프론트 편집·발행, 감사 로그, 구매자 개인정보 원문(#217). 커스텀 연동은 계정 액세스 토큰(#71)이 맡는다
49
49
  */
50
50
  export const NON_INSTALLABLE_SCOPES: ReadonlySet<string> = new Set([
51
51
  "member:r",
@@ -56,6 +56,7 @@ export const NON_INSTALLABLE_SCOPES: ReadonlySet<string> = new Set([
56
56
  "storefront:r",
57
57
  "storefront:rw",
58
58
  "audit:r",
59
+ "privacy:r",
59
60
  ]);
60
61
 
61
62
  /** 앱이 요청할 수 있는 스코프 — 카탈로그 전체에서 위를 뺀 것 */
@@ -134,6 +135,7 @@ export const APP_EVENT_SCOPES: Record<AppEventType, string | null> = {
134
135
  "ORDER.FLOW_NOTIFICATION": "order:r",
135
136
  "ORDER.FLOW_STATE_CHANGED": "order:r",
136
137
  "ORDER.PAYMENT_ADDED": "order:r",
138
+ "ORDER.REFUNDED": "order:r",
137
139
  "ORDER.PAYMENT_REQUESTED": "order:r",
138
140
  "CLAIM.CANCEL_REQUESTED": "claim:r",
139
141
  "CLAIM.RETURN_REQUESTED": "claim:r",
@@ -181,6 +181,41 @@ export const rejectClaimRequestSchema = strictRequest(
181
181
 
182
182
  export type RejectClaimRequest = z.infer<typeof rejectClaimRequestSchema>;
183
183
 
184
+ /** `POST /v1/claims/{claimId}/approve` 요청 — 본문은 생략할 수 있다 */
185
+ export const approveClaimRequestSchema = strictRequest(
186
+ z.object({
187
+ deductAmount: z
188
+ .number()
189
+ .int()
190
+ .min(0)
191
+ .optional()
192
+ .describe(
193
+ "환불액에서 뺄 추가 차감(원). 생략하면 주문 흐름이 접수 때 계산한 차감액, 그것도 없으면 0이다",
194
+ ),
195
+ refundAmount: z
196
+ .number()
197
+ .int()
198
+ .min(0)
199
+ .optional()
200
+ .describe(
201
+ "최종 현금 환불액(#365, 원). 생략하면 서버 계산값이다. 배송비 환불 여부·귀책과 무관하게 판매자가 정한다. " +
202
+ "배송비·쿠폰 정산 기록과 적립금 반환은 계산값대로 하고 현금만 이 값이다. 결제 잔액(`availableRefundAmount`)을 넘으면 409 `REFUND_EXCEEDS_BALANCE`. " +
203
+ "`deductAmount`와 함께 주면 400이다. 응답·웹훅의 `calculatedRefundAmount`가 서버 계산값이다",
204
+ ),
205
+ holdRefund: z
206
+ .boolean()
207
+ .optional()
208
+ .describe(
209
+ "true면 승인만 하고 결제 환불은 대기로 남긴다(#115). 결제 상세의 `POST /v1/payments/refunds/{refundId}/process`로 따로 환불한다",
210
+ ),
211
+ }),
212
+ ).refine((body) => body.refundAmount === undefined || body.deductAmount === undefined, {
213
+ message: "refundAmount와 deductAmount는 함께 줄 수 없습니다",
214
+ path: ["refundAmount"],
215
+ });
216
+
217
+ export type ApproveClaimRequest = z.infer<typeof approveClaimRequestSchema>;
218
+
184
219
  export const approveClaimResultSchema = z.object({
185
220
  claimId: z.string(),
186
221
  refundAmount: z
@@ -188,8 +223,16 @@ export const approveClaimResultSchema = z.object({
188
223
  .int()
189
224
  .describe(
190
225
  "실제 환불한 금액 — 수량 비율 상품 실결제 금액(쿠폰 배분을 뺀 금액) + 배송비 환불 − 반품 배송비 − 조건부 무료배송 미달 차감 − " +
191
- "쿠폰 조건 미달 차감(`couponChargeAmount`) + 되돌린 쿠폰 차감(`couponChargeRefundAmount`) − 추가 차감(`deductAmount`)",
226
+ "쿠폰 조건 미달 차감(`couponChargeAmount`) + 되돌린 쿠폰 차감(`couponChargeRefundAmount`) − 추가 차감(`deductAmount`). 판매자가 `refundAmount`를 정했으면 그 값이다",
192
227
  ),
228
+ calculatedRefundAmount: z
229
+ .number()
230
+ .int()
231
+ .optional()
232
+ .describe(
233
+ "서버가 계산한 현금 환불액(#365). 판매자가 `refundAmount`를 정하지 않았으면 `refundAmount`와 같다. 이 필드가 없는 이전 응답은 `refundAmount`와 같다고 읽는다",
234
+ ),
235
+
193
236
  deductAmount: z.number().int().describe("승인 때 셀러가 추가로 차감한 금액"),
194
237
  quantity: z.number().int().describe("환불한 수량"),
195
238
  deliveryFeeRefundAmount: z
@@ -54,6 +54,79 @@ export const customerSummarySchema = z
54
54
  .extend(customerStatsSchema.shape);
55
55
  export type CustomerSummary = z.infer<typeof customerSummarySchema>;
56
56
 
57
+ // ── 내보내기 (이슈 #217) ────────────────────────────────────
58
+
59
+ /** 내보내기에서 고를 수 있는 필드 */
60
+ export const customerExportFieldSchema = z
61
+ .enum([
62
+ "name",
63
+ "email",
64
+ "phone",
65
+ "joinedAt",
66
+ "orderCount",
67
+ "totalPaid",
68
+ "lastOrderedAt",
69
+ "point",
70
+ "marketingAgreed",
71
+ "marketingUpdatedAt",
72
+ "birthdate",
73
+ "gender",
74
+ "defaultAddress",
75
+ ])
76
+ .describe(
77
+ "내보낼 필드. `marketingUpdatedAt`은 마케팅 수신 동의·철회가 마지막으로 바뀐 시각, `defaultAddress`는 기본 배송지다",
78
+ );
79
+ export type CustomerExportField = z.infer<typeof customerExportFieldSchema>;
80
+
81
+ export const CUSTOMER_EXPORT_MAX_SIZE = 500;
82
+
83
+ /** `GET /v1/customers/export` 쿼리 — 목록과 같은 조건 + 내보낼 필드 */
84
+ export const customerExportQuerySchema = customerListQuerySchema.extend({
85
+ fields: z
86
+ .string()
87
+ .describe(
88
+ "내보낼 필드(쉼표로 구분, 하나 이상). `customerExportFieldSchema`의 값이다. 모르는 값은 400이다",
89
+ ),
90
+ ...pageQueryShape({ defaultSize: 100, maxSize: CUSTOMER_EXPORT_MAX_SIZE }),
91
+ });
92
+
93
+ /** 내보내기 기본 배송지 — 휴대폰은 `phone`과 같은 규칙으로 가린다 */
94
+ export const customerExportAddressSchema = z.object({
95
+ receiverName: z.string(),
96
+ phone: z.string().nullable(),
97
+ zipCode: z.string(),
98
+ address1: z.string(),
99
+ address2: z.string().nullable(),
100
+ countryCode: z.string(),
101
+ city: z.string().nullable(),
102
+ region: z.string().nullable(),
103
+ });
104
+
105
+ /** 내보내기 한 줄 — 고른 필드만 싣는다 */
106
+ export const customerExportRowSchema = z.object({
107
+ customerId: z.string(),
108
+ name: z.string().optional(),
109
+ email: z.string().nullable().optional(),
110
+ phone: z
111
+ .string()
112
+ .nullable()
113
+ .optional()
114
+ .describe(
115
+ "휴대폰. 스코프 `privacy:r`이 있으면 전체 번호, 없으면 가운데를 가린 번호(`010-****-1234`)",
116
+ ),
117
+ joinedAt: z.string().optional(),
118
+ orderCount: z.number().int().optional(),
119
+ totalPaid: z.number().int().optional().describe("구매 금액(원) — 목록과 같은 기준"),
120
+ lastOrderedAt: z.string().nullable().optional(),
121
+ point: z.number().int().optional(),
122
+ marketingAgreed: z.boolean().optional(),
123
+ marketingUpdatedAt: z.string().nullable().optional(),
124
+ birthdate: z.string().nullable().optional(),
125
+ gender: z.string().nullable().optional(),
126
+ defaultAddress: customerExportAddressSchema.nullable().optional(),
127
+ });
128
+ export type CustomerExportRow = z.infer<typeof customerExportRowSchema>;
129
+
57
130
  export const customerAddressSchema = z.object({
58
131
  addressId: z.string(),
59
132
  alias: z.string(),
@@ -148,12 +221,18 @@ export const customerConsentListQuerySchema = z.object({
148
221
  ...pageQueryShape(),
149
222
  });
150
223
 
151
- /** 구매자 상세 — 휴대폰 전체를 보여 준다 */
224
+ /**
225
+ * 구매자 상세 — 휴대폰(구매자·배송지)은 스코프 `privacy:r`이 있으면 전체 번호, 없으면 가린 번호(`010-****-1234`)다(#217).
226
+ * 가린 경우 `userinfo.phone_number`는 싣지 않는다
227
+ */
152
228
  export const customerDetailSchema = z.object({
153
229
  customerId: z.string(),
154
230
  name: z.string(),
155
231
  email: z.string().nullable(),
156
- phone: z.string().nullable(),
232
+ phone: z
233
+ .string()
234
+ .nullable()
235
+ .describe("휴대폰. `privacy:r`이 있으면 전체 번호, 없으면 가운데를 가린 번호"),
157
236
  joinedAt: z.string(),
158
237
  point: z.number().int(),
159
238
  marketingAgreed: z.boolean(),
@@ -169,6 +169,8 @@ export const eventTypeSchema = z
169
169
  "ORDER.PAYMENT_ADDED",
170
170
  // 흐름 노드 「결제 요청」이 결제 링크를 만듦(이슈 #117)
171
171
  "ORDER.PAYMENT_REQUESTED",
172
+ // 판매자가 금액만 환불함(#365)
173
+ "ORDER.REFUNDED",
172
174
  ])
173
175
  .describe(
174
176
  "웹훅 이벤트 타입. `ORDER.ADDRESS_CHANGE_REQUESTED`는 현재 발송하지 않음(호환을 위해 값만 남았고, 구독해도 이벤트가 오지 않는다)",
@@ -690,6 +690,16 @@ export const cancelBySellerRequestSchema = strictRequest(
690
690
  .describe(
691
691
  "환불액에서 뺄 추가 차감(#301, 원). 생략하면 0이다. 현금에서 먼저 빼고 모자란 몫만 돌려줄 적립금에서 뺀다. 환불 원장·`ORDER.CANCELED` 웹훅에 따로 남는다",
692
692
  ),
693
+ refundAmount: z
694
+ .number()
695
+ .int()
696
+ .min(0)
697
+ .optional()
698
+ .describe(
699
+ "최종 현금 환불액(#365, 원). 생략하면 서버 계산값이다. 배송비 환불 여부·귀책과 무관하게 판매자가 정한다. " +
700
+ "배송비·쿠폰 정산 기록과 적립금 반환은 계산값대로 하고 현금만 이 값이다. 결제 잔액(`availableRefundAmount`)을 넘으면 409 `REFUND_EXCEEDS_BALANCE`. " +
701
+ "`deductAmount`와 함께 주면 400이다. 응답·웹훅의 `calculatedRefundAmount`가 서버 계산값이다",
702
+ ),
693
703
  restock: z
694
704
  .boolean()
695
705
  .optional()
@@ -705,10 +715,43 @@ export const cancelBySellerRequestSchema = strictRequest(
705
715
  "회원 잔액이 모자라면 있는 만큼만 회수한다(리뷰 적립 회수와 같은 규칙)",
706
716
  ),
707
717
  }),
708
- );
718
+ ).refine((body) => body.refundAmount === undefined || body.deductAmount === undefined, {
719
+ message: "refundAmount와 deductAmount는 함께 줄 수 없습니다",
720
+ path: ["refundAmount"],
721
+ });
709
722
 
710
723
  export type CancelBySellerRequest = z.infer<typeof cancelBySellerRequestSchema>;
711
724
 
725
+ /**
726
+ * 금액만 환불 요청(#365) — `POST /v1/orders/{orderId}/refunds`. 주문상품 상태·수량·재고·적립금·쿠폰·배송비 정산을 바꾸지 않고
727
+ * 결제에서 금액만 돌려준다. 헤더 `Idempotency-Key`가 필수다
728
+ */
729
+ export const sellerRefundRequestSchema = strictRequest(
730
+ z.object({
731
+ amount: z
732
+ .number()
733
+ .int()
734
+ .min(1)
735
+ .describe(
736
+ "돌려줄 현금 (원). 결제의 남은 금액(결제 상세 `availableRefundAmount`)을 넘으면 409 `REFUND_EXCEEDS_BALANCE`",
737
+ ),
738
+ reason: z
739
+ .string()
740
+ .trim()
741
+ .min(1)
742
+ .max(500)
743
+ .describe("환불 사유. 감사 로그와 결제 상세 환불 이력에 남는다"),
744
+ paymentId: z
745
+ .string()
746
+ .optional()
747
+ .describe(
748
+ "환불할 결제(주문 응답 `payments[].paymentId`). 생략하면 주문의 첫 결제다. 추가 결제가 있는 주문은 이 값으로 고른다",
749
+ ),
750
+ }),
751
+ );
752
+
753
+ export type SellerRefundRequest = z.infer<typeof sellerRefundRequestSchema>;
754
+
712
755
  /**
713
756
  * 제공 처리 요청 (이슈 #48) — `POST /v1/order-items/{orderItemId}/fulfill`. 배송 없는 주문상품(`MANUAL`)을
714
757
  * 제공 완료(`DELIVERED`)로 바꾼다.
@@ -744,7 +787,14 @@ export const cancelBySellerResultSchema = z.object({
744
787
  .int()
745
788
  .describe(
746
789
  "실제 환불한 금액 — 수량 비율 상품 실결제 금액(쿠폰 배분을 뺀 금액) + 배송비 환불 − 조건부 무료배송 미달 차감 " +
747
- "− 쿠폰 조건 미달 차감 + 되돌린 쿠폰 차감(`couponChargeRefundAmount`) − 추가 차감(`deductAmount`)",
790
+ "− 쿠폰 조건 미달 차감 + 되돌린 쿠폰 차감(`couponChargeRefundAmount`) − 추가 차감(`deductAmount`). 판매자가 `refundAmount`를 정했으면 그 값이다",
791
+ ),
792
+ calculatedRefundAmount: z
793
+ .number()
794
+ .int()
795
+ .optional()
796
+ .describe(
797
+ "서버가 계산한 현금 환불액(#365). 판매자가 `refundAmount`를 정하지 않았으면 `refundAmount`와 같다. 이 필드가 없는 이전 응답은 `refundAmount`와 같다고 읽는다",
748
798
  ),
749
799
  quantity: z.number().int().describe("취소한 수량"),
750
800
  canceledQuantity: z.number().int().describe("취소 후 누적 취소 수량"),
@@ -910,10 +910,21 @@ export const paymentRefundRecordSchema = z.object({
910
910
  .optional()
911
911
  .default(0)
912
912
  .describe("같은 환불에서 돌려준 적립금 (#93). 적립금을 쓰지 않은 결제는 0"),
913
+ calculatedAmount: z
914
+ .number()
915
+ .int()
916
+ .nullable()
917
+ .optional()
918
+ .default(null)
919
+ .describe(
920
+ "서버가 계산한 현금 환불액 (#365). 판매자가 최종 금액을 정했으면 `amount`와 다르다. 계산이 없는 환불(결제 오류 보상 등)과 이전 환불은 null이다",
921
+ ),
913
922
  reason: z.string().describe("환불 사유 코드"),
914
923
  kind: z
915
924
  .string()
916
- .describe("환불 원인 — SELLER_CANCEL·CLAIM·COMPENSATION·DUPLICATE. 새 값이 추가될 수 있다"),
925
+ .describe(
926
+ "환불 원인 — SELLER_CANCEL·CLAIM·SELLER_REFUND(금액만 환불)·COMPENSATION·DUPLICATE. 새 값이 추가될 수 있다",
927
+ ),
917
928
  status: z
918
929
  .string()
919
930
  .describe(
@@ -927,6 +938,13 @@ export type PaymentRefundRecord = z.infer<typeof paymentRefundRecordSchema>;
927
938
 
928
939
  export const paymentRecordDetailSchema = paymentRecordSchema.extend({
929
940
  refundableAmount: z.number().int().describe("환불 가능 잔액 = 승인 금액 - 환불 완료 금액"),
941
+ availableRefundAmount: z
942
+ .number()
943
+ .int()
944
+ .optional()
945
+ .describe(
946
+ "지금 더 환불할 수 있는 금액 = 승인 금액 - (환불 완료 + 처리 중 + 대기 환불). 이보다 큰 환불은 409 `REFUND_EXCEEDS_BALANCE`다. 승인되지 않은 결제는 0. 이 필드가 없는 이전 api는 `refundableAmount`로 읽는다",
947
+ ),
930
948
  merchantId: z
931
949
  .string()
932
950
  .nullable()
@@ -25,6 +25,10 @@ export const SCOPE_DESCRIPTIONS: Record<StoreScopeResource, { r: string; rw: str
25
25
  r: "위치별 재고와 재고 변동 내역을 읽습니다",
26
26
  rw: "재고를 조정·실사하고 위치 사이에서 이동합니다",
27
27
  },
28
+ privacy: {
29
+ r: "구매자 개인정보(휴대폰 전체 번호)를 내보냅니다",
30
+ rw: "구매자 개인정보(휴대폰 전체 번호)를 내보냅니다",
31
+ },
28
32
  };
29
33
 
30
34
  /** `{resource}:{r|rw}` → 문장. 카탈로그에 없는 스코프는 null */
@@ -35,6 +35,8 @@ export const STORE_SCOPE_RESOURCES = {
35
35
  flow: ["r", "rw"],
36
36
  // 위치별 재고·재고 조정·이동·변동 내역(이슈 #132) — 기존 재고 표면(조합 재고 수정, 상품·옵션의 `stockQuantity`)은 product 스코프다
37
37
  inventory: ["r", "rw"],
38
+ // 구매자 개인정보 원문(이슈 #217) — 내보내기의 휴대폰 전체 번호. 없으면 가린 번호다. 쓰기 API가 없어 `r`만 존재한다
39
+ privacy: ["r"],
38
40
  } as const satisfies Record<string, readonly ScopeAccess[]>;
39
41
 
40
42
  export type StoreScopeResource = keyof typeof STORE_SCOPE_RESOURCES;
@@ -74,6 +76,7 @@ export const STORE_ROLE_SCOPES = {
74
76
  "storefront:rw",
75
77
  "flow:rw",
76
78
  "inventory:rw",
79
+ "privacy:r",
77
80
  ],
78
81
  ADMIN: [
79
82
  "product:rw",
@@ -92,6 +95,7 @@ export const STORE_ROLE_SCOPES = {
92
95
  "storefront:rw",
93
96
  "flow:rw",
94
97
  "inventory:rw",
98
+ "privacy:r",
95
99
  ],
96
100
  STAFF: [
97
101
  "product:rw",
@@ -177,7 +177,14 @@ export const orderCanceledWebhookEventDataSchema = z.object({
177
177
  .describe(
178
178
  "환불액 (원). 수량 비율 상품 실결제 금액(쿠폰 배분을 뺀 금액) + 배송비 환불 − 조건부 무료배송 미달 차감 " +
179
179
  "− 쿠폰 조건 미달 차감 + 되돌린 쿠폰 차감(`couponChargeRefundAmount`) − 추가 차감(`deductAmount`)이다. " +
180
- "결제사 환불 결과는 결제 내역(`GET /v1/payments/{paymentId}`)으로 확인한다",
180
+ "판매자가 `refundAmount`를 정했으면 그 값이다. 결제사 환불 결과는 결제 내역(`GET /v1/payments/{paymentId}`)으로 확인한다",
181
+ ),
182
+ calculatedRefundAmount: z
183
+ .number()
184
+ .int()
185
+ .optional()
186
+ .describe(
187
+ "서버가 계산한 환불액 (원, #365). 판매자가 최종 금액을 정하지 않았으면 `refundAmount`와 같다. 이 필드가 없는 이전 이벤트는 `refundAmount`와 같다고 읽는다",
181
188
  ),
182
189
  deliveryFeeRefundAmount: z.number().int().describe("환불액에 포함된 배송비 (원)"),
183
190
  deliveryFeeChargeAmount: z
@@ -257,9 +264,16 @@ export const claimWebhookEventDataSchema = z.object({
257
264
  .optional()
258
265
  .default(null)
259
266
  .describe(
260
- "환불액 (원). 배송비 환불을 포함하고 배송비 차감·추가 차감을 뺀 실제 금액이다. " +
267
+ "환불액 (원). 배송비 환불을 포함하고 배송비 차감·추가 차감을 뺀 실제 금액이다. 판매자가 승인에 최종 금액을 정했으면 그 값이다. " +
261
268
  "환불이 확정되지 않은 접수 이벤트는 예상 환불액이고, 교환처럼 환불이 없으면 null이다",
262
269
  ),
270
+ calculatedRefundAmount: z
271
+ .number()
272
+ .int()
273
+ .optional()
274
+ .describe(
275
+ "서버가 계산한 환불액 (원, #365) — 완료 이벤트(`CLAIM.COMPLETED`)에만 싣는다. 판매자가 최종 금액을 정하지 않았으면 `refundAmount`와 같다. 이 필드가 없는 이벤트는 `refundAmount`와 같다고 읽는다",
276
+ ),
263
277
  deliveryFeeRefundAmount: z
264
278
  .number()
265
279
  .int()
@@ -542,6 +556,27 @@ export const orderPaymentRequestedWebhookEventSchema = z.object({
542
556
  data: orderPaymentRequestedWebhookEventDataSchema,
543
557
  });
544
558
 
559
+ /**
560
+ * 금액만 환불(#365) — 판매자가 주문상품을 취소하지 않고 결제에서 금액만 돌려주기로 했다. 결제사 환불 결과는 결제 상세
561
+ * (`GET /v1/payments/{paymentId}`의 `refunds[]`, `refundId`)로 확인한다. 개인정보는 싣지 않는다
562
+ */
563
+ export const orderRefundedWebhookEventDataSchema = z.object({
564
+ orderId: z.string().describe("주문 ID"),
565
+ orderNo: z.string().nullable().describe("주문번호. 번호 체계 이전 주문은 null"),
566
+ paymentId: z.string().describe("환불한 결제"),
567
+ refundId: z.string().describe("환불 이력 id(결제 상세 `refunds[].refundId`)"),
568
+ amount: z.number().int().describe("돌려준 현금 (원)"),
569
+ reason: z.string().describe("판매자가 적은 환불 사유"),
570
+ currency: z.string().describe("주문 통화"),
571
+ testPayment: z.boolean(),
572
+ });
573
+
574
+ export const orderRefundedWebhookEventSchema = z.object({
575
+ ...envelopeFields,
576
+ type: z.literal("ORDER.REFUNDED"),
577
+ data: orderRefundedWebhookEventDataSchema,
578
+ });
579
+
545
580
  /** 웹훅 요청 본문. `type`으로 판별하며 `data` 형태가 타입별로 다르다 */
546
581
  export const webhookEventSchema = z
547
582
  .discriminatedUnion("type", [
@@ -557,6 +592,7 @@ export const webhookEventSchema = z
557
592
  inventoryChangedWebhookEventSchema,
558
593
  orderPaymentAddedWebhookEventSchema,
559
594
  orderPaymentRequestedWebhookEventSchema,
595
+ orderRefundedWebhookEventSchema,
560
596
  ])
561
597
  .describe(
562
598
  "웹훅 요청 본문 (얇은 이벤트). 개인정보를 싣지 않으며 `data`의 id로 관리 API를 조회한다",