@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.
package/dist/index.mjs CHANGED
@@ -975,6 +975,7 @@ const claimSchema = z.object({
975
975
  carrierCode: z.string().nullable(),
976
976
  trackingNumber: z.string().nullable()
977
977
  }).nullable(),
978
+ fulfillmentType: z.string().optional().default("SHIPPING").describe("클레임 대상 주문상품의 상품 유형(이슈 #48). 수거가 없는 비실물(`DOWNLOAD`·`CODE`·`SERVICE`) 반품은 수거 등록 없이 접수·보류에서 바로 승인한다. 값이 추가될 수 있어 문자열로 받는다. 서버는 항상 싣는다"),
978
979
  requestedAt: z.string(),
979
980
  completedAt: z.string().nullable(),
980
981
  /** 보류 중이면 자동 해제 시각(ISO 8601), 아니면 null */
@@ -1020,14 +1021,857 @@ const rejectClaimRequestSchema = z.object({
1020
1021
  });
1021
1022
  const approveClaimResultSchema = z.object({
1022
1023
  claimId: z.string(),
1023
- refundAmount: z.number().int().describe("실제 환불한 금액 — 수량 비율 상품 금액 + 배송비 환불 − 반품 배송비 − 조건부 무료배송 미달 차감 − 추가 차감(`deductAmount`)"),
1024
+ refundAmount: z.number().int().describe("실제 환불한 금액 — 수량 비율 상품 실결제 금액(쿠폰 배분을 뺀 금액) + 배송비 환불 − 반품 배송비 − 조건부 무료배송 미달 차감 − 쿠폰 조건 미달 차감(`couponChargeAmount`) + 되돌린 쿠폰 차감(`couponChargeRefundAmount`) − 추가 차감(`deductAmount`)"),
1024
1025
  deductAmount: z.number().int().describe("승인 때 셀러가 추가로 차감한 금액"),
1025
1026
  quantity: z.number().int().describe("환불한 수량"),
1026
1027
  deliveryFeeRefundAmount: z.number().int().describe("환불에 포함된 배송비. 배송 묶음 전량 취소나 판매자 귀책일 때만 0보다 크다"),
1027
1028
  deliveryFeeChargeAmount: z.number().int().describe("환불액에서 뺀 **배송비** — 반품·교환 배송비 + 조건부 무료배송 미달 차감. 추가 차감(`deductAmount`)은 포함하지 않는다"),
1029
+ couponChargeAmount: z.number().int().optional().default(0).describe("환불액에서 뺀 쿠폰 조건 미달 차감. 남은 주문상품이 쿠폰 조건을 잃었을 때만 0보다 크다. 서버는 항상 싣는다"),
1030
+ couponChargeRefundAmount: z.number().int().optional().default(0).describe("환불액에 더한, 앞선 환불에서 뺀 쿠폰 차감을 되돌린 몫. 서버는 항상 싣는다"),
1028
1031
  refundedAt: z.string()
1029
1032
  });
1030
1033
  //#endregion
1034
+ //#region src/schemas/refund-policy.ts
1035
+ /**
1036
+ * 환불 정책 (이슈 #25) — 취소·반품·교환에서 **배송비를 누가 부담하는지**를 정한다.
1037
+ *
1038
+ * 두 층이다. 스토어 설정(`PATCH /v1/store`의 `refundPolicy`)이 기본값이고, 상품별 정책
1039
+ * (`POST/PUT /v1/products`의 `refundPolicy`)이 그 상품에만 덮어쓴다. 상품이 정하지 않은 항목은
1040
+ * 스토어 기본을 따르고, 스토어도 정하지 않았으면 `REFUND_POLICY_DEFAULTS`다.
1041
+ *
1042
+ * 한 주문에 정책이 섞이면 **상품별로 계산한다**. 배송비 묶음도 상품 단위다(주문서·장바구니의
1043
+ * 배송비 합산과 같은 규칙: 같은 상품의 주문상품이 한 묶음이고 묶음마다 배송비가 붙는다).
1044
+ */
1045
+ const descriptions$3 = {
1046
+ preShipmentDeliveryFeeRefund: "발송 전 취소로 배송 묶음(같은 상품의 주문상품)이 모두 취소되면 그 묶음의 배송비를 환불한다. false면 배송비를 환불하지 않는다. 기본 true",
1047
+ returnDeliveryFee: "단순 변심(구매자 귀책) 반품의 반품 배송비. 원 단위. 환불액에서 뺀다. 기본 0(무료). 상품별 정책에서 이 값을 주면 상품의 `returnFee`도 같은 값으로 맞춘다",
1048
+ exchangeDeliveryFee: "단순 변심(구매자 귀책) 교환의 교환 배송비. 원 단위. 구매자에게 청구할 금액을 안내값으로 싣는다. 기본 0(무료). 상품별 정책에서 이 값을 주면 상품의 `exchangeFee`도 같은 값으로 맞춘다",
1049
+ sellerFaultFreeShipping: "판매자 귀책(셀러 직권취소, 불량·오배송 반품)이면 배송비를 셀러가 부담한다 — 배송비를 환불하고 반품비를 받지 않는다. false면 구매자 귀책과 같은 규칙을 쓴다. 기본 true",
1050
+ conditionalFreeShortfallCharge: "조건부 무료배송(`CONDITIONAL_FREE`) 상품에서 일부 취소로 남은 금액이 무료 기준액에 미달하면 그 배송비를 환불액에서 뺀다. false면 추가 청구 없이 셀러가 부담한다. 기본 false",
1051
+ couponShortfallCharge: "쿠폰 조건 미달 차감. 일부 취소·반품으로 남은 주문상품이 쿠폰 조건(최소 주문 금액 등)을 잃으면 남은 주문상품이 받던 쿠폰 할인을 환불액에서 뺀다. 판매자 귀책(셀러 직권취소, 불량·오배송 반품)은 빼지 않는다. false면 차감 없이 실결제 금액만 나눠 환불한다. 기본 true"
1052
+ };
1053
+ /** 유효 환불 정책 — 스토어 기본 + 상품별 덮어쓰기를 합친 결과 */
1054
+ const refundPolicySchema = z.object({
1055
+ preShipmentDeliveryFeeRefund: z.boolean().describe(descriptions$3.preShipmentDeliveryFeeRefund),
1056
+ returnDeliveryFee: z.number().int().min(0).describe(descriptions$3.returnDeliveryFee),
1057
+ exchangeDeliveryFee: z.number().int().min(0).describe(descriptions$3.exchangeDeliveryFee),
1058
+ sellerFaultFreeShipping: z.boolean().describe(descriptions$3.sellerFaultFreeShipping),
1059
+ conditionalFreeShortfallCharge: z.boolean().describe(descriptions$3.conditionalFreeShortfallCharge),
1060
+ couponShortfallCharge: z.boolean().optional().default(true).describe(`${descriptions$3.couponShortfallCharge}. 이 필드가 없는 이전 서버 응답은 true로 읽는다`)
1061
+ });
1062
+ /**
1063
+ * 스토어가 정하지 않았을 때 서버가 쓰는 값.
1064
+ *
1065
+ * `as const`가 아니라 `RefundPolicy`로 타입을 좁힌다 — 리터럴 타입이면 `Partial<typeof …>`로 일부만 바꿀 때
1066
+ * `true` 자리에 `false`를 넣을 수 없다.
1067
+ */
1068
+ const REFUND_POLICY_DEFAULTS = {
1069
+ preShipmentDeliveryFeeRefund: true,
1070
+ returnDeliveryFee: 0,
1071
+ exchangeDeliveryFee: 0,
1072
+ sellerFaultFreeShipping: true,
1073
+ conditionalFreeShortfallCharge: false,
1074
+ couponShortfallCharge: true
1075
+ };
1076
+ /**
1077
+ * 부분 정책 — 스토어 설정 요청과 상품별 덮어쓰기가 쓴다. 준 항목만 바뀐다.
1078
+ *
1079
+ * `strictObject`라 모르는 키는 400이다. 오타(`returnFee` 등)를 조용히 무시하면 셀러는 저장됐다고
1080
+ * 믿는데 정책은 기본값으로 남는다.
1081
+ */
1082
+ const refundPolicyPatchSchema = z.strictObject({
1083
+ preShipmentDeliveryFeeRefund: z.boolean().optional().describe(descriptions$3.preShipmentDeliveryFeeRefund),
1084
+ returnDeliveryFee: z.number().int().min(0).optional().describe(descriptions$3.returnDeliveryFee),
1085
+ exchangeDeliveryFee: z.number().int().min(0).optional().describe(descriptions$3.exchangeDeliveryFee),
1086
+ sellerFaultFreeShipping: z.boolean().optional().describe(descriptions$3.sellerFaultFreeShipping),
1087
+ conditionalFreeShortfallCharge: z.boolean().optional().describe(descriptions$3.conditionalFreeShortfallCharge),
1088
+ couponShortfallCharge: z.boolean().optional().describe(descriptions$3.couponShortfallCharge)
1089
+ });
1090
+ /**
1091
+ * 상품별 덮어쓰기 — 쿠폰 조건 미달 차감(`couponShortfallCharge`)은 주문 단위 규칙이라 상품이 정하지 않는다.
1092
+ * 스토어 정책만 읽는다(이슈 #47).
1093
+ */
1094
+ const productRefundPolicyPatchSchema = refundPolicyPatchSchema.omit({ couponShortfallCharge: true });
1095
+ /**
1096
+ * 유효 정책 해석 — 상품별 덮어쓰기 → 스토어 기본 → `REFUND_POLICY_DEFAULTS` 순이다.
1097
+ *
1098
+ * 반품비·교환비는 레거시 필드(`products.returnFee`·`exchangeFee`)와 **연결**한다. 상품이
1099
+ * `refundPolicy.returnDeliveryFee`를 정하면 그 값이 원천이고(서버가 `returnFee`도 같은 값으로 맞춘다),
1100
+ * 정하지 않았으면 레거시 값이 0보다 클 때 그 값을 쓴다. 0이면 스토어 기본을 따른다 —
1101
+ * 정책이 생기기 전에 만든 상품(반품비 0)이 스토어 기본을 물려받게 하는 규칙이다.
1102
+ */
1103
+ function resolveRefundPolicy(storePolicy, product) {
1104
+ return {
1105
+ ...REFUND_POLICY_DEFAULTS,
1106
+ ...dropUndefined(storePolicy),
1107
+ ...productRefundPolicyOverride(product) ?? {}
1108
+ };
1109
+ }
1110
+ /**
1111
+ * 상품의 **덮어쓰기 자체** — 레거시 반품비·교환비를 같은 형태로 승격한 값이다. 덮어쓰기가 없으면 null.
1112
+ *
1113
+ * `resolveRefundPolicy`가 합친 결과로는 "상품이 정한 값"과 "스토어 기본을 물려받은 값"을 가를 수 없다.
1114
+ * 관리 API 응답의 `refundPolicy`와 콘솔 폼의 덮어쓰기 스위치가 이 함수를 원천으로 쓴다 — 가르지 못하면
1115
+ * 스토어 기본을 쓰는 상품을 저장할 때 그 순간의 기본값이 상품에 박히거나, 레거시 값이 조용히 사라진다.
1116
+ */
1117
+ function productRefundPolicyOverride(product) {
1118
+ const { couponShortfallCharge: _storeOnly, ...override } = dropUndefined(product?.refundPolicy);
1119
+ const legacy = {};
1120
+ if (override.returnDeliveryFee === void 0 && (product?.returnFee ?? 0) > 0) legacy.returnDeliveryFee = product?.returnFee ?? 0;
1121
+ if (override.exchangeDeliveryFee === void 0 && (product?.exchangeFee ?? 0) > 0) legacy.exchangeDeliveryFee = product?.exchangeFee ?? 0;
1122
+ const merged = {
1123
+ ...legacy,
1124
+ ...override
1125
+ };
1126
+ if (product?.refundPolicy == null && Object.keys(merged).length === 0) return null;
1127
+ return merged;
1128
+ }
1129
+ /** `undefined` 값을 빼고 얕게 복사한다 — 전개 순서로 덮어쓸 때 `undefined`가 기본값을 지우지 않게 */
1130
+ function dropUndefined(patch) {
1131
+ if (!patch) return {};
1132
+ return Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== void 0));
1133
+ }
1134
+ /**
1135
+ * 수량 단위 부분 취소의 환불 기준액 — **누적 내림의 차분**이다.
1136
+ *
1137
+ * 원 단위 반올림 규칙: `floor(주문상품 금액 × 누적 수량 ÷ 전체 수량)`을 두 번 구해 그 차를 쓴다.
1138
+ * 부분 취소를 몇 번 나눠도 합이 주문상품 금액과 정확히 같아서 1원이 남거나 넘치지 않는다.
1139
+ * 즉시할인은 이미 단가에 들어 있다. 쿠폰 할인은 단가에 들어가지 않으므로 환불은 실결제 금액을 나누는
1140
+ * `proratedPaidRefundBase`(promotion.ts)를 쓴다(이슈 #47).
1141
+ *
1142
+ * @param totalPrice 주문상품 금액 (할인 반영 단가 × 전체 수량)
1143
+ * @param quantity 주문상품 전체 수량
1144
+ * @param settledQuantity 이미 취소·반품된 누적 수량
1145
+ * @param cancelQuantity 이번에 취소·반품할 수량
1146
+ */
1147
+ function proratedRefundBase(totalPrice, quantity, settledQuantity, cancelQuantity) {
1148
+ if (quantity <= 0 || cancelQuantity <= 0) return 0;
1149
+ const before = Math.floor(totalPrice * settledQuantity / quantity);
1150
+ const after = Math.floor(totalPrice * (settledQuantity + cancelQuantity) / quantity);
1151
+ return Math.max(0, after - before);
1152
+ }
1153
+ //#endregion
1154
+ //#region src/schemas/promotion.ts
1155
+ /**
1156
+ * 쿠폰 금액 계산 (이슈 #47) — 쿠폰 할인을 **얼마나, 어느 주문상품에** 주는지를 정한다.
1157
+ *
1158
+ * 계산은 순수 함수 `quotePricing` 하나다. 장바구니 요약, 주문서 미리보기, 결제 시작의 최종 확정,
1159
+ * 주문 스냅샷, 환불 정산의 조건 미달 판정(`settleCouponShortfall`)이 모두 이 함수를 지난다.
1160
+ * 다섯 경로가 각자 계산하면 화면 금액, 결제 금액, 환불 금액이 갈라진다(`quoteDelivery`와 같은 원칙).
1161
+ *
1162
+ * 계산 순서
1163
+ * 1. 즉시할인(`products.discount`)은 이미 단가에 들어 있다. 입력 `amount`가 즉시할인 반영 금액이다.
1164
+ * 2. 상품 쿠폰(`PRODUCT`) — 주문상품 한 줄에 한 장. 그 줄 금액을 기준으로 할인한다.
1165
+ * 3. 주문 쿠폰(`ORDER`) — 주문에 한 장. **상품 쿠폰 차감 뒤 금액**의 대상 합계를 기준으로 할인하고,
1166
+ * 대상 줄에 금액 비율로 나눈다(누적 내림의 차분).
1167
+ * 4. 배송비 쿠폰(`DELIVERY`) — 주문에 한 장. 기본 배송비(지역 추가 배송비 제외)를 기준으로 할인하고
1168
+ * 배송 묶음에 기본 배송비 비율로 나눈다.
1169
+ *
1170
+ * 무료배송 기준(상품별 조건부 무료·스토어 전체 기준)은 **쿠폰 차감 전 금액**으로 판정한다. 그래서 배송비
1171
+ * 견적(`quoteDelivery`)은 쿠폰과 무관하게 먼저 계산되고, 이 함수는 그 결과만 입력으로 받는다.
1172
+ *
1173
+ * 최소 주문 금액은 대상 상품의 **즉시할인 반영 합계**(쿠폰 차감 전, 배송비 제외)로 판정한다.
1174
+ * 정률 할인은 10원 단위로 내리고(`discountedPriceOf`의 즉시할인과 같은 단위), 최대 할인액과 기준 금액을
1175
+ * 넘지 않는다.
1176
+ */
1177
+ const descriptions$2 = {
1178
+ kind: "쿠폰 종류. `ORDER`=주문 쿠폰(대상 상품 합계에서 할인), `PRODUCT`=상품 쿠폰(주문상품 한 줄에서 할인), `DELIVERY`=배송비 쿠폰(기본 배송비에서 할인, 지역 추가 배송비 제외)",
1179
+ benefitType: "할인 방식. `RATE`=정률(%), `AMOUNT`=정액(원)",
1180
+ benefitValue: "할인 값. `RATE`면 1~100(%), `AMOUNT`면 원 단위 금액",
1181
+ maxDiscountAmount: "정률 할인의 최대 할인액(원). null이면 상한이 없다. 정액 할인에는 쓰지 않는다(null)",
1182
+ minOrderAmount: "최소 주문 금액(원). 대상 상품의 즉시할인 반영 합계(배송비 제외)가 이 값 이상이어야 쓸 수 있다. 0이면 조건이 없다",
1183
+ firstOrderOnly: "이 스토어에서 결제 완료 주문이 없는 회원만 쓸 수 있다",
1184
+ memberOnly: "회원만 쓸 수 있다. false면 비회원도 쓸 수 있다 — 코드 입력형이면서 1인 한도가 없는 쿠폰만 false로 둘 수 있다",
1185
+ stackWithInstantDiscount: "상품 즉시할인과 함께 쓸 수 있다. false면 즉시할인이 걸린 주문상품은 쿠폰 대상에서 빠진다",
1186
+ includeProductIds: "대상 상품 id. 포함 목록(상품·카테고리)이 모두 비면 전 상품이 대상이다",
1187
+ includeCategoryIds: "대상 카테고리 id. 하위 카테고리의 상품도 대상이다",
1188
+ excludeProductIds: "제외 상품 id. 포함보다 우선한다",
1189
+ excludeCategoryIds: "제외 카테고리 id. 하위 카테고리의 상품도 제외한다. 포함보다 우선한다"
1190
+ };
1191
+ /** 쿠폰 종류 */
1192
+ const couponKindSchema = z.enum([
1193
+ "ORDER",
1194
+ "PRODUCT",
1195
+ "DELIVERY"
1196
+ ]).describe(descriptions$2.kind);
1197
+ /** 쿠폰 혜택 — 정률은 1~100%, 최대 할인액을 둘 수 있다. 정액은 최대 할인액이 없다 */
1198
+ const couponBenefitSchema = z.object({
1199
+ type: z.enum(["RATE", "AMOUNT"]).describe(descriptions$2.benefitType),
1200
+ value: z.number().int().min(1).describe(descriptions$2.benefitValue),
1201
+ maxDiscountAmount: z.number().int().min(1).nullable().describe(descriptions$2.maxDiscountAmount)
1202
+ }).superRefine((benefit, ctx) => {
1203
+ if (benefit.type === "RATE" && benefit.value > 100) ctx.addIssue({
1204
+ code: "custom",
1205
+ path: ["value"],
1206
+ message: "정률 할인은 1~100(%)이어야 합니다"
1207
+ });
1208
+ if (benefit.type === "AMOUNT" && benefit.maxDiscountAmount !== null) ctx.addIssue({
1209
+ code: "custom",
1210
+ path: ["maxDiscountAmount"],
1211
+ message: "정액 할인에는 최대 할인액을 두지 않습니다"
1212
+ });
1213
+ });
1214
+ /** 쿠폰 사용 조건 */
1215
+ const couponConditionsSchema = z.object({
1216
+ minOrderAmount: z.number().int().min(0).describe(descriptions$2.minOrderAmount),
1217
+ firstOrderOnly: z.boolean().describe(descriptions$2.firstOrderOnly),
1218
+ memberOnly: z.boolean().describe(descriptions$2.memberOnly),
1219
+ stackWithInstantDiscount: z.boolean().describe(descriptions$2.stackWithInstantDiscount)
1220
+ });
1221
+ /** 조건을 생략했을 때의 값 */
1222
+ const COUPON_CONDITIONS_DEFAULTS = {
1223
+ minOrderAmount: 0,
1224
+ firstOrderOnly: false,
1225
+ memberOnly: true,
1226
+ stackWithInstantDiscount: true
1227
+ };
1228
+ /** 상한 — 대상 목록은 규칙 스냅샷에 통째로 복사되므로 크기를 제한한다 */
1229
+ const COUPON_TARGET_LIST_MAX = 500;
1230
+ /** 쿠폰 대상 — 포함 목록이 모두 비면 전 상품이다. 제외가 포함보다 우선한다 */
1231
+ const couponTargetSchema = z.object({
1232
+ includeProductIds: z.array(z.string().min(1)).max(500).describe(descriptions$2.includeProductIds),
1233
+ includeCategoryIds: z.array(z.string().min(1)).max(500).describe(descriptions$2.includeCategoryIds),
1234
+ excludeProductIds: z.array(z.string().min(1)).max(500).describe(descriptions$2.excludeProductIds),
1235
+ excludeCategoryIds: z.array(z.string().min(1)).max(500).describe(descriptions$2.excludeCategoryIds)
1236
+ });
1237
+ const COUPON_TARGET_ALL = {
1238
+ includeProductIds: [],
1239
+ includeCategoryIds: [],
1240
+ excludeProductIds: [],
1241
+ excludeCategoryIds: []
1242
+ };
1243
+ /**
1244
+ * 쿠폰 규칙 스냅샷 — 계산에 필요한 값만 담는다. 결제 시작이 쿠폰을 읽어 이 형태로 결제 세션에 굳히고,
1245
+ * 주문 생성·환불 정산은 이 스냅샷만 읽는다. 결제 도중·주문 뒤 셀러가 쿠폰을 바꿔도 그 주문의 금액은 움직이지 않는다.
1246
+ */
1247
+ const couponRuleSchema = z.object({
1248
+ couponId: z.string().describe("쿠폰 id (`cpn_…`)"),
1249
+ name: z.string().describe("쿠폰 이름"),
1250
+ kind: couponKindSchema,
1251
+ benefit: couponBenefitSchema,
1252
+ conditions: couponConditionsSchema,
1253
+ target: couponTargetSchema,
1254
+ startsAt: z.iso.datetime({ offset: true }).nullable().describe("사용 시작 시각(ISO 8601, 시간대 포함). null이면 제한이 없다"),
1255
+ endsAt: z.iso.datetime({ offset: true }).nullable().describe("사용 종료 시각(ISO 8601, 시간대 포함, 이 시각부터 못 쓴다). null이면 제한이 없다")
1256
+ });
1257
+ /** 쿠폰을 적용하지 못한 사유 */
1258
+ const couponRejectReasonSchema = z.enum([
1259
+ "DUPLICATE_COUPON",
1260
+ "STACK_LIMIT",
1261
+ "NOT_STARTED",
1262
+ "EXPIRED",
1263
+ "MEMBER_ONLY",
1264
+ "FIRST_ORDER_ONLY",
1265
+ "NO_ELIGIBLE_ITEMS",
1266
+ "MIN_ORDER_AMOUNT",
1267
+ "LINE_NOT_ELIGIBLE",
1268
+ "NO_DISCOUNT"
1269
+ ]).describe("쿠폰을 적용하지 못한 사유. `DUPLICATE_COUPON`=같은 쿠폰을 두 번 넣음, `STACK_LIMIT`=같은 종류의 쿠폰이 이미 적용됨(주문 쿠폰·배송비 쿠폰은 주문당 1장, 상품 쿠폰은 주문상품 한 줄에 1장), `NOT_STARTED`=사용 기간 전, `EXPIRED`=사용 기간 지남, `MEMBER_ONLY`=회원 전용, `FIRST_ORDER_ONLY`=첫 구매 전용, `NO_ELIGIBLE_ITEMS`=대상 상품이 주문에 없음, `MIN_ORDER_AMOUNT`=최소 주문 금액 미달, `LINE_NOT_ELIGIBLE`=지정한 주문상품이 대상이 아니거나 이미 상품 쿠폰이 있음, `NO_DISCOUNT`=할인할 금액이 없음");
1270
+ /** 쿠폰 적용 뒤 결제 금액의 하한 — 0원 결제 경로가 없어 이보다 작으면 결제를 시작하지 않는다(400 `PAYMENT_AMOUNT_TOO_LOW`) */
1271
+ const MIN_PAYMENT_AMOUNT = 100;
1272
+ /**
1273
+ * 할인액 — 정률은 10원 단위 내림, 최대 할인액과 기준 금액을 넘지 않는다. 기준 금액이 0 이하면 0이다.
1274
+ *
1275
+ * 곱셈을 먼저 하고 나눈다. `base × 율 ÷ 1000`을 내려 10을 곱하면 `floor(base × 율 / 100 / 10) × 10`과 같고
1276
+ * 부동소수 오차가 끼지 않는다.
1277
+ */
1278
+ function couponDiscountOf(benefit, base) {
1279
+ if (base <= 0) return 0;
1280
+ let raw = benefit.type === "RATE" ? Math.floor(base * benefit.value / 1e3) * 10 : benefit.value;
1281
+ if (benefit.type === "RATE" && benefit.maxDiscountAmount !== null) raw = Math.min(raw, benefit.maxDiscountAmount);
1282
+ return Math.max(0, Math.min(raw, base));
1283
+ }
1284
+ /**
1285
+ * 금액을 가중치 비율로 나눈다 — **누적 내림의 차분**이다.
1286
+ *
1287
+ * `i`번째 몫 = `floor(total × W_i / W) − floor(total × W_{i−1} / W)`(`W_i`는 앞에서부터 누적 가중치).
1288
+ * 합이 `total`과 정확히 같고, 입력 순서가 같으면 결과도 같다. `total ≤ W`이면 몫이 가중치를 넘지 않는다.
1289
+ * 곱이 2^53을 넘을 수 있어 BigInt로 계산한다.
1290
+ */
1291
+ function allocateByWeight(total, weights) {
1292
+ if (!Number.isSafeInteger(total) || !weights.every((w) => Number.isSafeInteger(w))) throw new RangeError("allocateByWeight: 금액과 가중치는 안전한 정수여야 합니다");
1293
+ const sum = weights.reduce((acc, w) => acc + Math.max(0, w), 0);
1294
+ if (total <= 0 || sum <= 0) return weights.map(() => 0);
1295
+ const bigTotal = BigInt(total);
1296
+ const bigSum = BigInt(sum);
1297
+ let cumulative = 0n;
1298
+ let previous = 0n;
1299
+ return weights.map((weight) => {
1300
+ cumulative += BigInt(Math.max(0, weight));
1301
+ const upTo = bigTotal * cumulative / bigSum;
1302
+ const share = upTo - previous;
1303
+ previous = upTo;
1304
+ return Number(share);
1305
+ });
1306
+ }
1307
+ /** 카테고리 경로(`/a/b/c/`)가 그 카테고리 또는 하위를 가리키는가 */
1308
+ function inCategory(categoryPath, categoryIds) {
1309
+ if (!categoryPath || categoryIds.length === 0) return false;
1310
+ const chain = categoryPath.split("/").filter(Boolean);
1311
+ return categoryIds.some((id) => chain.includes(id));
1312
+ }
1313
+ /** 줄이 쿠폰 대상인가 — 제외가 포함보다 우선하고, 포함 목록이 모두 비면 전 상품이다 */
1314
+ function isCouponTargetLine(rule, line) {
1315
+ const { target, conditions } = rule;
1316
+ if (!conditions.stackWithInstantDiscount && line.amount < line.baseAmount) return false;
1317
+ if (target.excludeProductIds.includes(line.productId)) return false;
1318
+ if (inCategory(line.categoryPath, target.excludeCategoryIds)) return false;
1319
+ if (target.includeProductIds.length === 0 && target.includeCategoryIds.length === 0) return true;
1320
+ return target.includeProductIds.includes(line.productId) || inCategory(line.categoryPath, target.includeCategoryIds);
1321
+ }
1322
+ /** 시각 파싱 — 값이 없으면 null(제한 없음), 읽지 못하면 NaN(판정에서 거절한다) */
1323
+ function timeOf(value) {
1324
+ if (!value) return null;
1325
+ return Date.parse(value);
1326
+ }
1327
+ /**
1328
+ * 사용 기간 판정 — 쿠폰 기간과 발급 쿠폰 유효 기간 중 좁은 쪽. 시작은 포함, 종료는 제외.
1329
+ * 읽지 못하는 시각은 제한 없음이 아니라 거절이다(fail-closed).
1330
+ */
1331
+ function periodRejectOf(coupon, now) {
1332
+ const at = now.getTime();
1333
+ const starts = [timeOf(coupon.rule.startsAt), timeOf(coupon.validFrom)].filter((t) => t !== null);
1334
+ const ends = [timeOf(coupon.rule.endsAt), timeOf(coupon.validUntil)].filter((t) => t !== null);
1335
+ if (starts.some((t) => Number.isNaN(t) || at < t)) return "NOT_STARTED";
1336
+ if (ends.some((t) => Number.isNaN(t) || at >= t)) return "EXPIRED";
1337
+ return null;
1338
+ }
1339
+ /**
1340
+ * 쿠폰 금액 계산 — **순수 함수다.** DB를 읽지 않는다. 규칙 스냅샷을 만드는 조회와 한도·보유 판정
1341
+ * (발급 쿠폰 상태, 1인 한도)은 호출부 몫이다.
1342
+ *
1343
+ * 종류별로 상품 쿠폰 → 주문 쿠폰 → 배송비 쿠폰 순으로 적용한다. 같은 종류의 자리와 같은 쿠폰 id는
1344
+ * **적용된** 쿠폰만 차지한다 — 앞선 주문 쿠폰이 최소 금액 미달이면 뒤의 주문 쿠폰이 쓰이고, 같은 쿠폰의 발급
1345
+ * 사본 중 앞 장이 만료됐으면 뒤 장이 쓰인다. 결과의 `coupons`는 입력 순서 그대로다. 입력이 같으면 결과도 같다.
1346
+ */
1347
+ function quotePricing(input) {
1348
+ const lines = input.lines.map((line) => ({
1349
+ line,
1350
+ productDiscount: 0,
1351
+ allocations: []
1352
+ }));
1353
+ const lineIndex = new Map(lines.map((entry, index) => [entry.line.lineId, index]));
1354
+ const lineOf = (id) => {
1355
+ const index = lineIndex.get(id);
1356
+ return index === void 0 ? void 0 : lines[index];
1357
+ };
1358
+ const results = input.coupons.map((coupon) => ({
1359
+ couponKey: coupon.couponKey ?? coupon.rule.couponId,
1360
+ couponId: coupon.rule.couponId,
1361
+ kind: coupon.rule.kind,
1362
+ applied: false,
1363
+ discount: 0,
1364
+ rejectReason: null,
1365
+ lineId: null,
1366
+ eligibleLineIds: []
1367
+ }));
1368
+ const commonRejectOf = (coupon, result) => {
1369
+ if (!input.ignoreEligibility) {
1370
+ const period = periodRejectOf(coupon, input.now);
1371
+ if (period) return period;
1372
+ if (coupon.rule.conditions.memberOnly && !input.isMember) return "MEMBER_ONLY";
1373
+ if (coupon.rule.conditions.firstOrderOnly && !input.firstOrder) return "FIRST_ORDER_ONLY";
1374
+ }
1375
+ const pinned = coupon.eligibleLineIds ? new Set(coupon.eligibleLineIds) : null;
1376
+ const eligible = lines.filter((entry) => pinned ? pinned.has(entry.line.lineId) : isCouponTargetLine(coupon.rule, entry.line));
1377
+ result.eligibleLineIds = eligible.map((entry) => entry.line.lineId);
1378
+ if (eligible.length === 0) return "NO_ELIGIBLE_ITEMS";
1379
+ if (eligible.reduce((sum, entry) => sum + entry.line.amount, 0) < coupon.rule.conditions.minOrderAmount) return "MIN_ORDER_AMOUNT";
1380
+ return null;
1381
+ };
1382
+ const passed = [];
1383
+ input.coupons.forEach((coupon, index) => {
1384
+ const result = results[index];
1385
+ const reason = commonRejectOf(coupon, result);
1386
+ if (reason) result.rejectReason = reason;
1387
+ else passed.push(index);
1388
+ });
1389
+ /** 적용된 쿠폰 id — 같은 쿠폰 정의는 주문에 한 장 */
1390
+ const appliedCouponIds = /* @__PURE__ */ new Set();
1391
+ const apply = (index, discount, lineId) => {
1392
+ const result = results[index];
1393
+ result.applied = true;
1394
+ result.discount = discount;
1395
+ result.lineId = lineId;
1396
+ appliedCouponIds.add(result.couponId);
1397
+ };
1398
+ const duplicate = (index) => {
1399
+ const result = results[index];
1400
+ if (!appliedCouponIds.has(result.couponId)) return false;
1401
+ result.rejectReason = "DUPLICATE_COUPON";
1402
+ return true;
1403
+ };
1404
+ const productIndexes = passed.filter((index) => input.coupons[index]?.rule.kind === "PRODUCT");
1405
+ const productOrder = [...productIndexes.filter((index) => input.coupons[index]?.lineId), ...productIndexes.filter((index) => !input.coupons[index]?.lineId).map((index) => ({
1406
+ index,
1407
+ width: results[index]?.eligibleLineIds.length ?? 0
1408
+ })).sort((a, b) => a.width - b.width || a.index - b.index).map((entry) => entry.index)];
1409
+ const takenLines = /* @__PURE__ */ new Set();
1410
+ for (const index of productOrder) {
1411
+ const coupon = input.coupons[index];
1412
+ const result = results[index];
1413
+ if (duplicate(index)) continue;
1414
+ const candidates = result.eligibleLineIds.filter((id) => !takenLines.has(id));
1415
+ let chosen = null;
1416
+ let best = 0;
1417
+ if (coupon.lineId) {
1418
+ if (!candidates.includes(coupon.lineId)) {
1419
+ result.rejectReason = result.eligibleLineIds.includes(coupon.lineId) ? "STACK_LIMIT" : "LINE_NOT_ELIGIBLE";
1420
+ continue;
1421
+ }
1422
+ chosen = coupon.lineId;
1423
+ best = couponDiscountOf(coupon.rule.benefit, lineOf(chosen)?.line.amount ?? 0);
1424
+ } else {
1425
+ if (candidates.length === 0) {
1426
+ result.rejectReason = "STACK_LIMIT";
1427
+ continue;
1428
+ }
1429
+ for (const id of candidates) {
1430
+ const discount = couponDiscountOf(coupon.rule.benefit, lineOf(id)?.line.amount ?? 0);
1431
+ if (chosen === null || discount > best) {
1432
+ chosen = id;
1433
+ best = discount;
1434
+ }
1435
+ }
1436
+ }
1437
+ const entry = chosen === null ? void 0 : lineOf(chosen);
1438
+ if (!entry || chosen === null || best <= 0) {
1439
+ result.rejectReason = "NO_DISCOUNT";
1440
+ continue;
1441
+ }
1442
+ takenLines.add(chosen);
1443
+ entry.productDiscount += best;
1444
+ entry.allocations.push({
1445
+ couponKey: result.couponKey,
1446
+ amount: best
1447
+ });
1448
+ apply(index, best, chosen);
1449
+ }
1450
+ let orderSlot = false;
1451
+ for (const index of passed) {
1452
+ const coupon = input.coupons[index];
1453
+ const result = results[index];
1454
+ if (coupon.rule.kind !== "ORDER") continue;
1455
+ if (duplicate(index)) continue;
1456
+ if (orderSlot) {
1457
+ result.rejectReason = "STACK_LIMIT";
1458
+ continue;
1459
+ }
1460
+ const targets = result.eligibleLineIds.map((id) => lineOf(id)).filter((entry) => entry !== void 0);
1461
+ const weights = targets.map((entry) => Math.max(0, entry.line.amount - entry.productDiscount));
1462
+ const base = weights.reduce((sum, w) => sum + w, 0);
1463
+ const discount = couponDiscountOf(coupon.rule.benefit, base);
1464
+ if (discount <= 0) {
1465
+ result.rejectReason = "NO_DISCOUNT";
1466
+ continue;
1467
+ }
1468
+ const shares = allocateByWeight(discount, weights);
1469
+ targets.forEach((entry, i) => {
1470
+ const share = shares[i] ?? 0;
1471
+ if (share > 0) entry.allocations.push({
1472
+ couponKey: result.couponKey,
1473
+ amount: share
1474
+ });
1475
+ });
1476
+ orderSlot = true;
1477
+ apply(index, discount, null);
1478
+ }
1479
+ const deliveryBundles = input.delivery?.bundles ?? [];
1480
+ const bundles = deliveryBundles.map((bundle) => ({
1481
+ bundleKey: bundle.bundleKey,
1482
+ couponDiscount: 0
1483
+ }));
1484
+ let deliverySlot = false;
1485
+ let deliveryDiscountAmount = 0;
1486
+ for (const index of passed) {
1487
+ const coupon = input.coupons[index];
1488
+ const result = results[index];
1489
+ if (coupon.rule.kind !== "DELIVERY") continue;
1490
+ if (duplicate(index)) continue;
1491
+ if (deliverySlot) {
1492
+ result.rejectReason = "STACK_LIMIT";
1493
+ continue;
1494
+ }
1495
+ const weights = deliveryBundles.map((bundle) => Math.max(0, bundle.fee));
1496
+ const base = weights.reduce((sum, w) => sum + w, 0);
1497
+ const discount = couponDiscountOf(coupon.rule.benefit, base);
1498
+ if (discount <= 0) {
1499
+ result.rejectReason = "NO_DISCOUNT";
1500
+ continue;
1501
+ }
1502
+ allocateByWeight(discount, weights).forEach((share, i) => {
1503
+ const bundle = bundles[i];
1504
+ if (bundle) bundle.couponDiscount += share;
1505
+ });
1506
+ deliverySlot = true;
1507
+ deliveryDiscountAmount = discount;
1508
+ apply(index, discount, null);
1509
+ }
1510
+ const lineResults = lines.map((entry) => ({
1511
+ lineId: entry.line.lineId,
1512
+ amount: entry.line.amount,
1513
+ couponDiscount: entry.allocations.reduce((sum, a) => sum + a.amount, 0),
1514
+ allocations: entry.allocations
1515
+ }));
1516
+ const productAmount = lines.reduce((sum, entry) => sum + entry.line.amount, 0);
1517
+ const couponDiscountAmount = lineResults.reduce((sum, line) => sum + line.couponDiscount, 0);
1518
+ const deliveryFee = deliveryBundles.reduce((sum, bundle) => sum + bundle.fee + bundle.remoteSurcharge, 0);
1519
+ return {
1520
+ lines: lineResults,
1521
+ coupons: results,
1522
+ bundles,
1523
+ productAmount,
1524
+ couponDiscountAmount,
1525
+ deliveryFee,
1526
+ deliveryDiscountAmount,
1527
+ totalAmount: productAmount - couponDiscountAmount + deliveryFee - deliveryDiscountAmount
1528
+ };
1529
+ }
1530
+ const NO_COUPON_SETTLEMENT = {
1531
+ chargeAmount: 0,
1532
+ refundAmount: 0,
1533
+ entries: []
1534
+ };
1535
+ /**
1536
+ * 쿠폰 조건 미달 차감 — **순수 함수다.** 부분 취소·반품으로 남은 주문상품이 쿠폰 조건을 잃으면,
1537
+ * 남은 주문상품이 받던 할인만큼을 이번 환불에서 뺀다.
1538
+ *
1539
+ * 쿠폰마다
1540
+ * - `allocatedRemaining` = 남은 수량에 배분된 할인(`proratedRefundBase`로 수량 비율)
1541
+ * - `recomputed` = 주문 시점 규칙 스냅샷과 주문 시점 대상 줄로, 남은 금액만 넣어 `quotePricing`을 다시 돌린 할인
1542
+ * - 쿠폰이 **여전히 적용되면 목표 0**, 조건을 잃었으면 목표 = `allocatedRemaining`이다. 적용되는 쿠폰의
1543
+ * 정률 10원 내림 차이까지 차감하면 조건을 지킨 구매자가 몇 원씩 잃는다 — 조건을 잃은 경우만 본다.
1544
+ * 재계산 할인이 더 커지는 경우(정률 최대 할인액)도 차감하지 않고 더 돌려주지도 않는다.
1545
+ * - `delta` = 목표 − (이미 차감 + 이미 면제). 양수면 청구하고(판매자 귀책·정책 꺼짐이면 면제로 기록),
1546
+ * 음수면 앞서 뺀 차감부터 돌려준다. 마지막 활성 수량까지 취소되면 목표가 0이라 누적 차감이 모두 되돌아가고,
1547
+ * 전량 취소의 환불 합계가 결제액과 같아진다.
1548
+ * - 청구는 이번 환불에서 실제로 뺄 수 있는 금액(`chargeableAmount` + 이번에 되돌린 금액)을 넘지 않고,
1549
+ * **실제로 뺀 금액만** 기록한다. 못 뺀 나머지는 기록하지 않아 다음 환불이 다시 본다.
1550
+ *
1551
+ * 배송비 쿠폰은 여기서 다루지 않는다. 배송비 쿠폰을 발급하게 되면(3단계) 묶음 배송비 환불
1552
+ * (`computeDeliveryFeeSettlement`)이 그 묶음의 `couponDiscount`를 빼야 한다 — 빼지 않으면 받지 않은 배송비를 돌려준다.
1553
+ */
1554
+ function settleCouponShortfall(input) {
1555
+ const coupons = input.coupons.filter((coupon) => coupon.rule.kind !== "DELIVERY");
1556
+ if (coupons.length === 0) return NO_COUPON_SETTLEMENT;
1557
+ const recomputed = quotePricing({
1558
+ lines: input.lines.map((line) => {
1559
+ const remaining = proratedRefundBase(line.totalPrice, line.quantity, 0, line.activeQuantity);
1560
+ return {
1561
+ lineId: line.lineId,
1562
+ productId: line.productId,
1563
+ quantity: line.activeQuantity,
1564
+ baseAmount: remaining,
1565
+ amount: remaining
1566
+ };
1567
+ }),
1568
+ coupons: coupons.map((coupon) => ({
1569
+ rule: coupon.rule,
1570
+ couponKey: coupon.couponKey,
1571
+ lineId: coupon.lineId,
1572
+ eligibleLineIds: coupon.eligibleLineIds
1573
+ })),
1574
+ delivery: null,
1575
+ firstOrder: true,
1576
+ isMember: true,
1577
+ now: /* @__PURE__ */ new Date(0),
1578
+ ignoreEligibility: true
1579
+ });
1580
+ const base = coupons.map((coupon, index) => {
1581
+ const allocatedRemaining = input.lines.reduce((sum, line) => {
1582
+ return sum + proratedRefundBase(line.allocations.filter((a) => a.couponKey === coupon.couponKey).reduce((acc, a) => acc + a.amount, 0), line.quantity, 0, line.activeQuantity);
1583
+ }, 0);
1584
+ const result = recomputed.coupons[index];
1585
+ const stillApplied = result?.applied === true;
1586
+ const state = input.settlements[coupon.couponKey] ?? {};
1587
+ return {
1588
+ couponKey: coupon.couponKey,
1589
+ allocatedRemaining,
1590
+ recomputed: stillApplied ? result?.discount ?? 0 : 0,
1591
+ target: stillApplied ? 0 : allocatedRemaining,
1592
+ charged: Math.max(0, state.charged ?? 0),
1593
+ waived: Math.max(0, state.waived ?? 0)
1594
+ };
1595
+ });
1596
+ const entries = [];
1597
+ let refundAmount = 0;
1598
+ for (const item of base) {
1599
+ const delta = item.target - (item.charged + item.waived);
1600
+ let refund = 0;
1601
+ let waived = item.waived;
1602
+ if (delta < 0) {
1603
+ refund = Math.min(item.charged, -delta);
1604
+ waived = Math.max(0, waived - (-delta - refund));
1605
+ }
1606
+ refundAmount += refund;
1607
+ entries.push({
1608
+ couponKey: item.couponKey,
1609
+ allocatedRemaining: item.allocatedRemaining,
1610
+ recomputed: item.recomputed,
1611
+ target: item.target,
1612
+ charge: 0,
1613
+ refund,
1614
+ waive: 0,
1615
+ next: {
1616
+ charged: item.charged - refund,
1617
+ waived
1618
+ }
1619
+ });
1620
+ }
1621
+ let capacity = Math.max(0, input.chargeableAmount) + refundAmount;
1622
+ let chargeAmount = 0;
1623
+ const chargeAllowed = input.shortfallCharge && !input.sellerFault;
1624
+ for (const entry of entries) {
1625
+ const delta = entry.target - (entry.next.charged + entry.next.waived);
1626
+ if (delta <= 0) continue;
1627
+ if (!chargeAllowed) {
1628
+ entry.waive = delta;
1629
+ entry.next = {
1630
+ ...entry.next,
1631
+ waived: entry.next.waived + delta
1632
+ };
1633
+ continue;
1634
+ }
1635
+ const charge = Math.min(delta, capacity);
1636
+ if (charge <= 0) continue;
1637
+ capacity -= charge;
1638
+ chargeAmount += charge;
1639
+ entry.charge = charge;
1640
+ entry.next = {
1641
+ ...entry.next,
1642
+ charged: entry.next.charged + charge
1643
+ };
1644
+ }
1645
+ return {
1646
+ chargeAmount,
1647
+ refundAmount,
1648
+ entries
1649
+ };
1650
+ }
1651
+ /**
1652
+ * 주문상품의 실결제 금액 중 이번 취소 수량의 몫 — **실결제 금액(`totalPrice − couponDiscountAmount`)을
1653
+ * 누적 내림의 차분으로 나눈다.**
1654
+ *
1655
+ * 상품 금액과 쿠폰 배분을 따로 나눠 빼면 합은 같지만 한 번의 몫이 −1원이 되는 경우가 있다
1656
+ * (예: 금액 3원·할인 2원·수량 5개에서 셋째 개). 음수를 0으로 자르면 합이 1원 넘친다. 실결제 금액을
1657
+ * 바로 나누면 몫이 항상 0 이상이고 전량 취소의 합이 실결제 금액과 정확히 같다.
1658
+ */
1659
+ function proratedPaidRefundBase(item, settledQuantity, cancelQuantity) {
1660
+ return proratedRefundBase(Math.max(0, item.totalPrice - (item.couponDiscountAmount ?? 0)), item.quantity, settledQuantity, cancelQuantity);
1661
+ }
1662
+ //#endregion
1663
+ //#region src/schemas/coupon.ts
1664
+ /**
1665
+ * 쿠폰 관리 API 계약 (이슈 #47) — `/v1/coupons/**`, 스코프 `promotion:r|rw`.
1666
+ *
1667
+ * 금액 계산 규칙은 `promotion.ts`(`quotePricing`)가 원천이고, 여기에는 셀러가 쿠폰을 만들고 나눠 주는 표면만 있다.
1668
+ * 발급·사용이 시작된 쿠폰은 혜택·조건·대상·사용 시작일을 바꿀 수 없다(409 `COUPON_LOCKED`). 이름·설명·종료일 연장·
1669
+ * 한도 상향·중지·재개·종료만 된다. 규칙을 바꾸려면 새 쿠폰을 만든다.
1670
+ */
1671
+ /** 쿠폰 상태 — `ACTIVE`(사용 중)·`PAUSED`(중지, 재개 가능)·`ENDED`(종료, 되돌릴 수 없음) */
1672
+ const couponStatusSchema = z.enum([
1673
+ "ACTIVE",
1674
+ "PAUSED",
1675
+ "ENDED"
1676
+ ]).describe("쿠폰 상태. `ACTIVE`=사용 중, `PAUSED`=중지(재개 가능), `ENDED`=종료(되돌릴 수 없음)");
1677
+ /** 지금 만들 수 있는 발급 방식 — 다운로드·가입 자동 지급은 뒤 단계다 */
1678
+ const couponIssueMethodSchema = z.enum(["CODE", "MANUAL"]).describe("발급 방식. `CODE`=구매자가 주문서에 코드를 입력한다, `MANUAL`=셀러가 회원을 골라 지급한다");
1679
+ /** 코드 형식 — 영문·숫자·`-`·`_` 6~30자. 대소문자를 구분하지 않고 서버가 대문자로 저장한다 */
1680
+ const COUPON_CODE_PATTERN = /^[A-Za-z0-9_-]{6,30}$/;
1681
+ /** 코드 정규화 — 대문자, 앞뒤 공백 제거. 입력·저장·조회가 같은 함수를 쓴다 */
1682
+ function normalizeCouponCode(code) {
1683
+ return code.trim().toUpperCase();
1684
+ }
1685
+ const limitField = (description) => z.number().int().min(1).max(1e7).nullable().optional().describe(description);
1686
+ const descriptions$1 = {
1687
+ name: "구매자에게 보이는 쿠폰 이름. 1~60자",
1688
+ description: "구매자에게 보이는 설명. 300자까지",
1689
+ code: "쿠폰 코드 — 발급 방식이 `CODE`일 때 필수. 영문·숫자·`-`·`_` 6~30자, 대소문자 구분 없음(대문자로 저장). 스토어 안에서 유일하다",
1690
+ startsAt: "사용 시작 시각(ISO 8601, 시간대 포함). 생략하면 바로 쓸 수 있다",
1691
+ endsAt: "사용 종료 시각(ISO 8601, 시간대 포함). 이 시각부터 못 쓴다. 생략하면 제한이 없다. 이미 지급한 쿠폰의 유효 기간은 지급 때 정해지므로 뒤에 종료일을 늦춰도 늘어나지 않는다",
1692
+ validDaysAfterIssue: "발급 후 유효 일수 — 셀러 지급(`MANUAL`) 쿠폰만. 쿠폰 종료일과 둘 중 먼저 끝나는 쪽이 적용된다",
1693
+ totalIssueLimit: "총 발급 수 한도. 생략하면 무제한",
1694
+ totalUseLimit: "총 사용 수 한도. 결제 중인 예약도 센다. 생략하면 무제한",
1695
+ perMemberIssueLimit: "1인 발급 수 한도(셀러 지급). 생략하면 1인 1장",
1696
+ perMemberUseLimit: "1인 사용 수 한도(코드 입력형). 생략하면 무제한. 비회원에게 여는 쿠폰은 둘 수 없다"
1697
+ };
1698
+ /** 조건 입력 — 생략한 항목은 기본값(`COUPON_CONDITIONS_DEFAULTS`)이다 */
1699
+ const couponConditionsInputSchema = z.strictObject({
1700
+ minOrderAmount: z.number().int().min(0).optional(),
1701
+ firstOrderOnly: z.boolean().optional(),
1702
+ memberOnly: z.boolean().optional(),
1703
+ stackWithInstantDiscount: z.boolean().optional()
1704
+ });
1705
+ /** 대상 입력 — 생략한 목록은 빈 목록이다(포함 목록이 모두 비면 전 상품) */
1706
+ const couponTargetInputSchema = z.strictObject({
1707
+ includeProductIds: z.array(z.string().min(1)).max(500).optional(),
1708
+ includeCategoryIds: z.array(z.string().min(1)).max(500).optional(),
1709
+ excludeProductIds: z.array(z.string().min(1)).max(500).optional(),
1710
+ excludeCategoryIds: z.array(z.string().min(1)).max(500).optional()
1711
+ });
1712
+ const couponTimeField = (description) => z.iso.datetime({ offset: true }).nullable().optional().describe(description);
1713
+ /** `POST /v1/coupons` */
1714
+ const createCouponRequestSchema = z.strictObject({
1715
+ name: z.string().trim().min(1).max(60).describe(descriptions$1.name),
1716
+ description: z.string().max(300).nullable().optional().describe(descriptions$1.description),
1717
+ kind: couponKindSchema,
1718
+ benefit: couponBenefitSchema,
1719
+ conditions: couponConditionsInputSchema.optional().describe("사용 조건. 생략한 항목은 기본값(최소 금액 0, 회원 전용, 즉시할인과 중복 가능)"),
1720
+ target: couponTargetInputSchema.optional().describe("대상 상품·카테고리. 생략하면 전 상품"),
1721
+ issueMethod: couponIssueMethodSchema,
1722
+ code: z.string().regex(COUPON_CODE_PATTERN).optional().describe(descriptions$1.code),
1723
+ startsAt: couponTimeField(descriptions$1.startsAt),
1724
+ endsAt: couponTimeField(descriptions$1.endsAt),
1725
+ validDaysAfterIssue: z.number().int().min(1).max(3650).nullable().optional().describe(descriptions$1.validDaysAfterIssue),
1726
+ totalIssueLimit: limitField(descriptions$1.totalIssueLimit),
1727
+ totalUseLimit: limitField(descriptions$1.totalUseLimit),
1728
+ perMemberIssueLimit: limitField(descriptions$1.perMemberIssueLimit),
1729
+ perMemberUseLimit: limitField(descriptions$1.perMemberUseLimit)
1730
+ }).superRefine((body, ctx) => {
1731
+ const issue = (path, message) => ctx.addIssue({
1732
+ code: "custom",
1733
+ path: [path],
1734
+ message
1735
+ });
1736
+ if (body.issueMethod === "CODE" && !body.code) issue("code", "코드 입력형 쿠폰은 코드가 필요합니다");
1737
+ if (body.issueMethod !== "CODE" && body.code) issue("code", "코드는 코드 입력형 쿠폰에만 둡니다");
1738
+ if (body.issueMethod === "CODE" && body.validDaysAfterIssue != null) issue("validDaysAfterIssue", "발급 후 유효 일수는 셀러 지급 쿠폰에만 둡니다");
1739
+ if (body.issueMethod === "CODE" && body.perMemberIssueLimit != null) issue("perMemberIssueLimit", "1인 발급 수 한도는 셀러 지급 쿠폰에만 둡니다");
1740
+ if (body.issueMethod === "MANUAL" && body.perMemberUseLimit != null) issue("perMemberUseLimit", "셀러 지급 쿠폰은 발급 한 장이 한 번 쓰입니다. 1인 발급 수 한도를 쓰십시오");
1741
+ if (!(body.conditions?.memberOnly ?? COUPON_CONDITIONS_DEFAULTS.memberOnly) && (body.issueMethod !== "CODE" || body.perMemberUseLimit != null)) issue("conditions", "비회원에게 여는 쿠폰은 코드 입력형이면서 1인 사용 한도가 없어야 합니다");
1742
+ if (body.startsAt && body.endsAt && Date.parse(body.endsAt) <= Date.parse(body.startsAt)) issue("endsAt", "종료 시각은 시작 시각보다 뒤여야 합니다");
1743
+ });
1744
+ /**
1745
+ * `PATCH /v1/coupons/{couponId}` — 준 항목만 바뀐다. 발급·사용이 시작된 쿠폰(`locked`)은 이름·설명·종료일 연장·
1746
+ * 한도 상향만 받고, 그 밖의 항목이 현재 값과 다르면 409 `COUPON_LOCKED`다. 종류·발급 방식은 바꿀 수 없다.
1747
+ */
1748
+ const updateCouponRequestSchema = z.strictObject({
1749
+ name: z.string().trim().min(1).max(60).optional().describe(descriptions$1.name),
1750
+ description: z.string().max(300).nullable().optional().describe(descriptions$1.description),
1751
+ benefit: couponBenefitSchema.optional(),
1752
+ conditions: couponConditionsInputSchema.optional(),
1753
+ target: couponTargetInputSchema.optional(),
1754
+ code: z.string().regex(COUPON_CODE_PATTERN).optional().describe(descriptions$1.code),
1755
+ startsAt: couponTimeField(descriptions$1.startsAt),
1756
+ endsAt: couponTimeField(descriptions$1.endsAt),
1757
+ validDaysAfterIssue: z.number().int().min(1).max(3650).nullable().optional(),
1758
+ totalIssueLimit: limitField(descriptions$1.totalIssueLimit),
1759
+ totalUseLimit: limitField(descriptions$1.totalUseLimit),
1760
+ perMemberIssueLimit: limitField(descriptions$1.perMemberIssueLimit),
1761
+ perMemberUseLimit: limitField(descriptions$1.perMemberUseLimit)
1762
+ });
1763
+ /** 쿠폰 응답 */
1764
+ const couponSchema = z.object({
1765
+ couponId: z.string(),
1766
+ name: z.string(),
1767
+ description: z.string().nullable(),
1768
+ kind: couponKindSchema,
1769
+ benefit: couponBenefitSchema,
1770
+ conditions: couponConditionsSchema,
1771
+ target: couponTargetSchema,
1772
+ issueMethod: z.string().describe("발급 방식. 현재 값은 `CODE`·`MANUAL`이고 값이 추가될 수 있어 문자열로 받는다(다운로드·가입 자동 지급 예정)"),
1773
+ code: z.string().nullable().describe("쿠폰 코드(대문자). 코드 입력형이 아니면 null"),
1774
+ startsAt: z.string().nullable(),
1775
+ endsAt: z.string().nullable(),
1776
+ validDaysAfterIssue: z.number().int().nullable(),
1777
+ totalIssueLimit: z.number().int().nullable(),
1778
+ totalUseLimit: z.number().int().nullable(),
1779
+ perMemberIssueLimit: z.number().int().nullable(),
1780
+ perMemberUseLimit: z.number().int().nullable(),
1781
+ issuedCount: z.number().int().describe("발급 수 — 회수한 발급도 센다. 총 발급 한도(`totalIssueLimit`)가 이 값과 비교된다"),
1782
+ reservedCount: z.number().int().describe("결제 중인 예약 수 — 결제가 끝나거나 만료되면 풀린다"),
1783
+ usedCount: z.number().int().describe("사용 수 — 전량 취소로 복원되면 줄어든다"),
1784
+ status: couponStatusSchema,
1785
+ locked: z.boolean().describe("발급·사용이 시작돼 혜택·조건·대상·사용 시작일을 바꿀 수 없는가"),
1786
+ lockedAt: z.string().nullable(),
1787
+ createdAt: z.string(),
1788
+ updatedAt: z.string()
1789
+ });
1790
+ /** `GET /v1/coupons` 쿼리 */
1791
+ const couponListQuerySchema = z.object({
1792
+ status: couponStatusSchema.optional(),
1793
+ kind: couponKindSchema.optional(),
1794
+ issueMethod: couponIssueMethodSchema.optional(),
1795
+ q: z.string().optional().describe("이름·코드 검색(포함)"),
1796
+ ...pageQueryShape()
1797
+ });
1798
+ /** 발급 쿠폰 상태 — 만료는 저장 상태가 아니라 조회 시각으로 판정한 값이다 */
1799
+ const couponIssueStatusSchema = z.string().describe("발급 쿠폰 상태. 현재 값은 `AVAILABLE`(사용 가능)·`RESERVED`(결제 중)·`USED`(사용함)·`REVOKED`(회수)·`EXPIRED`(유효 기간 지남)이고 값이 추가될 수 있어 문자열로 받는다");
1800
+ const couponIssueSchema = z.object({
1801
+ issueId: z.string(),
1802
+ couponId: z.string(),
1803
+ customerId: z.string(),
1804
+ customerName: z.string().nullable().describe("구매자 이름. 탈퇴한 구매자는 null"),
1805
+ status: couponIssueStatusSchema,
1806
+ validFrom: z.string(),
1807
+ validUntil: z.string().nullable(),
1808
+ usedOrderId: z.string().nullable(),
1809
+ issuedAt: z.string(),
1810
+ revokedAt: z.string().nullable()
1811
+ });
1812
+ const couponIssueListQuerySchema = z.object({
1813
+ status: z.enum([
1814
+ "AVAILABLE",
1815
+ "RESERVED",
1816
+ "USED",
1817
+ "REVOKED"
1818
+ ]).optional(),
1819
+ ...pageQueryShape()
1820
+ });
1821
+ /** `POST /v1/coupons/{couponId}/issues` — 셀러 지급. 한 번에 1,000명까지 */
1822
+ const issueCouponRequestSchema = z.strictObject({ customerIds: z.array(z.string().min(1)).min(1).max(1e3).refine((ids) => new Set(ids).size === ids.length, "같은 구매자를 두 번 넣었습니다").describe("지급할 구매자 id(1~1,000명, 중복 불가)") });
1823
+ const issueCouponResultSchema = z.object({
1824
+ issued: z.array(z.object({
1825
+ customerId: z.string(),
1826
+ issueId: z.string()
1827
+ })),
1828
+ failed: z.array(z.object({
1829
+ customerId: z.string(),
1830
+ code: z.string().describe("실패 사유. `CUSTOMER_NOT_FOUND`(이 스토어 구매자가 아님)·`COUPON_LIMIT_REACHED`(1인 발급 한도)·`COUPON_EXHAUSTED`(총 발급 한도)"),
1831
+ message: z.string()
1832
+ }))
1833
+ });
1834
+ const couponRedemptionSchema = z.object({
1835
+ redemptionId: z.string(),
1836
+ couponId: z.string(),
1837
+ issueId: z.string().nullable(),
1838
+ customerId: z.string().nullable().describe("구매자 id. 비회원 주문은 null"),
1839
+ orderId: z.string().nullable().describe("주문 id. 결제가 끝나기 전의 예약·해제는 null"),
1840
+ status: z.string().describe("사용 기록 상태. 현재 값은 `RESERVED`(결제 중)·`USED`(사용함)·`RELEASED`(결제 실패·만료로 해제)·`RESTORED`(전량 취소로 복원)이고 값이 추가될 수 있어 문자열로 받는다"),
1841
+ discountAmount: z.number().int(),
1842
+ reservedAt: z.string(),
1843
+ usedAt: z.string().nullable(),
1844
+ releasedAt: z.string().nullable(),
1845
+ restoredAt: z.string().nullable(),
1846
+ restoreSkippedReason: z.string().nullable().describe("전량 취소에도 복원하지 않은 사유 — `EXPIRED`(유효 기간 지남)·`ENDED`(종료된 쿠폰)")
1847
+ });
1848
+ const couponRedemptionListQuerySchema = z.object({
1849
+ status: z.enum([
1850
+ "RESERVED",
1851
+ "USED",
1852
+ "RELEASED",
1853
+ "RESTORED"
1854
+ ]).optional(),
1855
+ ...pageQueryShape()
1856
+ });
1857
+ const couponStatsSchema = z.object({
1858
+ couponId: z.string(),
1859
+ issuedCount: z.number().int(),
1860
+ revokedCount: z.number().int(),
1861
+ reservedCount: z.number().int(),
1862
+ usedCount: z.number().int(),
1863
+ restoredCount: z.number().int(),
1864
+ releasedCount: z.number().int(),
1865
+ discountTotal: z.number().int().describe("사용 중(`USED`)인 기록의 할인 합계 (원)"),
1866
+ orderPaymentTotal: z.number().int().describe("이 쿠폰을 쓴(`USED`) 주문의 결제 금액 합계 (원). 부분 취소·반품 환불은 빼지 않은 결제 시점 금액이다")
1867
+ });
1868
+ /** `GET /v1/customers/{customerId}/coupons` 항목 — 발급 쿠폰 + 쿠폰 이름·혜택 */
1869
+ const customerCouponSchema = couponIssueSchema.extend({
1870
+ couponName: z.string(),
1871
+ kind: couponKindSchema,
1872
+ benefit: couponBenefitSchema
1873
+ });
1874
+ //#endregion
1031
1875
  //#region src/schemas/customer.ts
1032
1876
  /** 구매자 목록 정렬 */
1033
1877
  const customerSortSchema = z.enum([
@@ -1194,6 +2038,140 @@ const customerWithdrawalBlockedDetailsSchema = z.object({
1194
2038
  pendingPayments: z.number().int()
1195
2039
  });
1196
2040
  //#endregion
2041
+ //#region src/schemas/digital-asset.ts
2042
+ /**
2043
+ * 파일 다운로드 상품의 판매 파일 (이슈 #48 단계 3).
2044
+ *
2045
+ * 파일은 CDN **비공개 객체**다. `POST /v1/uploads`(`purpose: "digital-asset"`)로 자리를 받아 응답의 `headers`와 함께 PUT하고,
2046
+ * 응답의 `key`로 상품에 연결한다. 객체 키와 파일 주소는 어떤 응답에도 싣지 않는다 — 구매자는 제공된 주문상품에서
2047
+ * 수명 300초의 서명 주소로만 받는다. 주문은 주문 시점 파일을 스냅샷하므로 파일을 바꿔도 이미 산 구매자는 옛 파일을 받는다.
2048
+ */
2049
+ const digitalAssetAttachRequestSchema = z.object({
2050
+ key: z.string().min(1).max(512).describe("업로드 자리 발급 응답의 `key`. 이 스토어의 비공개 업로드 키여야 한다"),
2051
+ fileName: z.string().trim().min(1).max(200).describe("구매자에게 보일 파일 이름(확장자 포함)"),
2052
+ sizeBytes: z.number().int().positive().optional().describe("파일 크기(바이트). 안내용이다"),
2053
+ contentType: z.string().max(200).optional().describe("파일 형식(MIME). 안내용이다"),
2054
+ variantId: z.string().optional().describe("이 파일을 줄 옵션(variant). 생략하면 모든 옵션의 구매자가 받는다")
2055
+ });
2056
+ /** 판매 파일 — 객체 키·주소는 싣지 않는다 */
2057
+ const digitalAssetSchema = z.object({
2058
+ assetId: z.string(),
2059
+ productId: z.string(),
2060
+ variantId: z.string().nullable().describe("옵션 전용 파일이면 그 옵션, 모든 옵션 공통이면 null"),
2061
+ fileName: z.string(),
2062
+ contentType: z.string().nullable(),
2063
+ sizeBytes: z.number().int().nullable(),
2064
+ status: z.string().describe("`ACTIVE`(새 주문에 제공)·`REMOVED`(해제 — 이미 산 구매자는 계속 받는다). 값이 추가될 수 있어 문자열로 받는다"),
2065
+ createdAt: z.string()
2066
+ });
2067
+ /** 셀러 확인용 서명 주소 — `POST /v1/products/{productId}/digital-assets/{assetId}/preview-url` */
2068
+ const digitalAssetPreviewSchema = z.object({
2069
+ url: z.url().describe("수명 300초의 서명 주소"),
2070
+ expiresAt: z.string()
2071
+ });
2072
+ /** 주문상품의 다운로드 제공 정보 — 주문 응답의 `fulfillment.download` */
2073
+ const orderItemDownloadSchema = z.object({
2074
+ count: z.number().int().describe("지금까지 받은 횟수"),
2075
+ limit: z.number().int().describe("받을 수 있는 횟수"),
2076
+ expiresAt: z.string().nullable().describe("받을 수 있는 기한 (ISO 8601)"),
2077
+ firstDownloadedAt: z.string().nullable().describe("처음 받은 시각 — 제공 개시 증거다"),
2078
+ files: z.array(z.object({
2079
+ assetId: z.string(),
2080
+ fileName: z.string(),
2081
+ sizeBytes: z.number().int().nullable()
2082
+ }))
2083
+ });
2084
+ //#endregion
2085
+ //#region src/schemas/fulfillment-code.ts
2086
+ /**
2087
+ * 코드 발급 상품의 코드 풀 (이슈 #48 단계 2) — 라이선스 키·기프트 코드처럼 결제 확정과 함께 한 개씩 배정하는 코드다.
2088
+ *
2089
+ * **평문 코드는 두 경로로만 나간다** — 구매자 열람(`POST /storefront/v1/me/order-items/{id}/fulfillment/reveal`)과 셀러 콘솔
2090
+ * 열람(`POST /v1/fulfillment-codes/{codeId}/reveal`, 콘솔 전용). 목록·주문·웹훅·API 로그에는 가린 값(`hint`)만 싣는다.
2091
+ * 코드는 결제 자격 증명과 같은 키링·AES-256-GCM으로 암호화해 저장한다.
2092
+ */
2093
+ /**
2094
+ * 코드 상태.
2095
+ * - `AVAILABLE` 판매 가능 — 재고로 센다
2096
+ * - `ASSIGNED` 주문상품에 배정됨
2097
+ * - `REVOKED` 환불로 회수됨(구매자 화면에서 숨긴다. 코드 자체의 무효화는 셀러 시스템 몫이다)
2098
+ * - `EXPIRED` 유효기간이 지나 배정하지 않음
2099
+ * - `VOID` 셀러가 폐기함
2100
+ */
2101
+ const fulfillmentCodeStatusSchema = z.enum([
2102
+ "AVAILABLE",
2103
+ "ASSIGNED",
2104
+ "REVOKED",
2105
+ "EXPIRED",
2106
+ "VOID"
2107
+ ]);
2108
+ /** 한 번에 올릴 수 있는 코드 수 */
2109
+ const MAX_CODES_PER_UPLOAD = 1e4;
2110
+ /** 코드 한 개의 최대 길이 */
2111
+ const MAX_CODE_LENGTH = 200;
2112
+ /**
2113
+ * 코드 일괄 업로드 요청 — `POST /v1/products/{productId}/codes`. `codes`(배열)와 `csv`(본문) 중 하나를 보낸다.
2114
+ * 앞뒤 공백은 지우고 빈 줄은 건너뛴다. 같은 상품에 이미 있는 코드와 요청 안의 중복은 받지 않는다(`duplicates`).
2115
+ */
2116
+ const fulfillmentCodeUploadRequestSchema = z.object({
2117
+ variantId: z.string().optional().describe("코드를 채울 옵션(variant) ID. 옵션 없는 상품은 생략한다(기본 옵션)"),
2118
+ codes: z.array(z.string()).max(MAX_CODES_PER_UPLOAD).optional().describe(`코드 목록. 한 번에 ${MAX_CODES_PER_UPLOAD}개까지. \`csv\`와 둘 중 하나만 보낸다`),
2119
+ csv: z.string().max(4e6).optional().describe(`CSV 본문 — 줄마다 첫 칸이 코드다. 첫 줄의 첫 칸이 \`code\`면 머리글로 보고 건너뛴다. 한 번에 ${MAX_CODES_PER_UPLOAD}줄까지`),
2120
+ expiresAt: z.iso.datetime({ offset: true }).optional().describe("이 업로드 코드의 유효기간 (ISO 8601). 지나면 배정하지 않고 만료로 바꾼다")
2121
+ }).refine((value) => value.codes === void 0 !== (value.csv === void 0), { message: "codes와 csv 중 하나만 보내야 합니다" });
2122
+ /** 받지 않은 줄 */
2123
+ const fulfillmentCodeRejectionSchema = z.object({
2124
+ line: z.number().int().describe("요청 안의 위치 (1부터). CSV는 머리글을 포함한 줄 번호다"),
2125
+ reason: z.string().describe("사유. `DUPLICATE`(이미 있는 코드이거나 요청 안에서 겹침)·`TOO_LONG`(200자 초과)·`INVALID_CHARACTER`(제어 문자). 값이 추가될 수 있어 문자열로 받는다")
2126
+ });
2127
+ /** 업로드 결과 — 배치 요약 */
2128
+ const fulfillmentCodeBatchSchema = z.object({
2129
+ batchId: z.string(),
2130
+ productId: z.string(),
2131
+ variantId: z.string(),
2132
+ total: z.number().int().describe("요청에서 읽은 코드 수(빈 줄·머리글 제외)"),
2133
+ accepted: z.number().int().describe("받은 코드 수 — 재고가 이만큼 는다"),
2134
+ duplicates: z.number().int().describe("중복이라 받지 않은 코드 수"),
2135
+ rejected: z.array(fulfillmentCodeRejectionSchema).describe("받지 않은 줄(중복 포함). 최대 1,000개까지 싣는다"),
2136
+ expiresAt: z.string().nullable(),
2137
+ createdAt: z.string()
2138
+ });
2139
+ /** 코드 목록의 한 줄 — **가린 값만** 싣는다 */
2140
+ const fulfillmentCodeSchema = z.object({
2141
+ codeId: z.string(),
2142
+ productId: z.string(),
2143
+ variantId: z.string(),
2144
+ batchId: z.string().nullable().describe("업로드 배치. 수동 제공으로 셀러가 넘긴 코드는 null"),
2145
+ hint: z.string().describe("가린 코드 — 끝 4자만 보인다(예: `****AB12`)"),
2146
+ status: z.string().describe("코드 상태. `AVAILABLE`·`ASSIGNED`·`REVOKED`·`EXPIRED`·`VOID`. 값이 추가될 수 있어 문자열로 받는다"),
2147
+ orderItemId: z.string().nullable().describe("배정된 주문상품. 배정 전이면 null"),
2148
+ assignedAt: z.string().nullable(),
2149
+ revealedAt: z.string().nullable().describe("구매자가 처음 열람한 시각 — 제공 개시 증거다"),
2150
+ expiresAt: z.string().nullable(),
2151
+ createdAt: z.string()
2152
+ });
2153
+ const fulfillmentCodePageSchema = pageSchema(fulfillmentCodeSchema).extend({ available: z.number().int().describe("판매 가능한 코드 수(필터와 무관한 상품 전체)") });
2154
+ /** `GET /v1/products/{productId}/codes` 쿼리 */
2155
+ const fulfillmentCodeListQuerySchema = z.object({
2156
+ status: fulfillmentCodeStatusSchema.optional().describe("상태 필터"),
2157
+ variantId: z.string().optional().describe("옵션 필터"),
2158
+ batchId: z.string().optional().describe("업로드 배치 필터"),
2159
+ orderItemId: z.string().optional().describe("주문상품 필터"),
2160
+ ...pageQueryShape()
2161
+ });
2162
+ /** 셀러 콘솔 코드 열람 응답 — `POST /v1/fulfillment-codes/{codeId}/reveal` */
2163
+ const fulfillmentCodeRevealSchema = z.object({
2164
+ codeId: z.string(),
2165
+ code: z.string().describe("코드 평문. 응답은 캐시하지 않는다(`Cache-Control: no-store`)")
2166
+ });
2167
+ /** 주문상품에 배정된 코드의 가린 값 — 주문 응답의 `fulfillment.codes` */
2168
+ const orderItemCodeSchema = z.object({
2169
+ codeId: z.string(),
2170
+ hint: z.string().describe("가린 코드 — 끝 4자만 보인다"),
2171
+ status: z.string().describe("`ASSIGNED`(유효)·`REVOKED`(환불로 회수). 값이 추가될 수 있어 문자열로 받는다"),
2172
+ revealedAt: z.string().nullable().describe("구매자가 처음 열람한 시각. 열람 전이면 null")
2173
+ });
2174
+ //#endregion
1197
2175
  //#region src/schemas/login.ts
1198
2176
  /** 소셜 로그인 공급자 */
1199
2177
  const loginProviderSchema = z.enum([
@@ -1358,6 +2336,7 @@ const eventTypeSchema = z.enum([
1358
2336
  "ORDER.PURCHASE_DECIDED",
1359
2337
  "ORDER.ADDRESS_CHANGE_REQUESTED",
1360
2338
  "ORDER.CANCELED",
2339
+ "ORDER.FULFILLED",
1361
2340
  "CLAIM.CANCEL_REQUESTED",
1362
2341
  "CLAIM.RETURN_REQUESTED",
1363
2342
  "CLAIM.EXCHANGE_REQUESTED",
@@ -1447,7 +2426,7 @@ const webhookDeliverySchema = z.object({
1447
2426
  errorCode: webhookDeliveryErrorCodeSchema.nullable().describe("마지막 시도의 실패 사유. 성공했거나 미시도면 null"),
1448
2427
  failureReason: webhookFailureReasonSchema.nullable().describe("마지막 시도의 실패 원인 분류. 성공했거나 미시도면 null. `errorCode`를 더 잘게 나눈 진단 값이다"),
1449
2428
  resource: webhookDeliveryResourceSchema.nullable().describe("이벤트가 가리키는 대상 리소스. 리소스를 특정할 수 없는 이벤트면 null. 이 값으로 전송 기록(`resourceId`)과 API 로그(`q`)를 오갈 수 있다"),
1450
- attemptCount: z.number().int().min(0).describe("지금까지의 시도 횟수. 최초 시도 1회 + 재시도 최대 4회"),
2429
+ attemptCount: z.number().int().min(0).describe("지금까지 전송을 시작한 횟수. 최초 시도 1회 + 재시도 최대 4회. 전송 도중 장애로 결과를 기록하지 못한 시도도 센다"),
1451
2430
  createdAt: z.string().describe("전송 기록이 적재된 일시 (ISO 8601)"),
1452
2431
  attemptedAt: z.string().nullable().describe("마지막 시도 일시 (ISO 8601). 미시도면 null"),
1453
2432
  nextAttemptAt: z.string().nullable().describe("다음 시도 예정 일시 (ISO 8601). `PENDING`·`PENDING_RETRY`일 때만 값이 있다"),
@@ -1533,6 +2512,121 @@ const reviewReportResultSchema = z.object({
1533
2512
  status: z.literal("PENDING_REVIEW")
1534
2513
  });
1535
2514
  //#endregion
2515
+ //#region src/schemas/fulfillment.ts
2516
+ /**
2517
+ * 상품 유형과 제공 방식 (이슈 #48) — 배송이 필요 없는 상품을 팔기 위한 축이다.
2518
+ *
2519
+ * **상품 유형은 제공 방식 하나로 표현한다.** `fulfillmentType` 한 필드가 배송 필요 여부, 이행 흐름, 재고 의미를 정한다.
2520
+ * 코드 곳곳에서 유형 이름으로 분기하지 않고 이 파일의 판정 함수(`requiresShipping`·`fulfillmentRuleOf`)를 지난다.
2521
+ * 유형이 늘어나도 판정표 한 곳만 고치면 배송비 계산·결제 시작·클레임·콘솔 버튼이 같이 따라온다.
2522
+ *
2523
+ * 주문상품 상태 enum은 바꾸지 않는다. 비실물의 **제공 완료는 `DELIVERED`를 재사용한다** — 새 상태를 더하면 기존 소비자의
2524
+ * 상태 분기(배송완료 뒤 구매확정 대기, 반품 가능 판정)가 새 값을 모른다. 표시는 `fulfillmentType`으로 가른다.
2525
+ */
2526
+ /**
2527
+ * 상품 유형.
2528
+ * - `SHIPPING` 배송 상품(기본, 이 필드 이전의 상품 전부)
2529
+ * - `DOWNLOAD` 파일 다운로드
2530
+ * - `CODE` 코드 발급(라이선스 키·기프트 코드)
2531
+ * - `SERVICE` 이용권·상품권·서비스(예약 없음)
2532
+ * - `PICKUP` 방문 수령 실물
2533
+ */
2534
+ const fulfillmentTypeSchema = z.enum([
2535
+ "SHIPPING",
2536
+ "DOWNLOAD",
2537
+ "CODE",
2538
+ "SERVICE",
2539
+ "PICKUP"
2540
+ ]);
2541
+ /** 제공 방식 — `AUTO`는 결제 확정과 함께 제공하고, `MANUAL`은 셀러가 제공 처리한다 */
2542
+ const fulfillmentModeSchema = z.enum(["AUTO", "MANUAL"]);
2543
+ /**
2544
+ * 응답 스키마용 유형 — 서버가 나중에 유형을 더해도 옛 SDK가 응답 파싱에서 깨지지 않게 문자열로 받는다.
2545
+ * 요청은 `fulfillmentTypeSchema`(닫힌 enum)로 검증한다.
2546
+ */
2547
+ const fulfillmentTypeResponseSchema = z.string().describe("상품 유형. 현재 값은 `SHIPPING`(배송 상품)·`DOWNLOAD`(파일 다운로드)·`CODE`(코드 발급)·`SERVICE`(이용권·서비스)·`PICKUP`(방문 수령)이고 값이 추가될 수 있어 문자열로 받는다");
2548
+ /**
2549
+ * 유형별 규칙표 — **유일한 원천이다.**
2550
+ *
2551
+ * `PICKUP`은 실물이라 반품 수거와 교환을 받지만 택배 배송은 없다. `DOWNLOAD`는 결제 즉시 권한을 발급하므로 `AUTO`만,
2552
+ * `PICKUP`은 셀러가 수령 완료를 누르므로 `MANUAL`만 둔다.
2553
+ */
2554
+ const FULFILLMENT_RULES = {
2555
+ SHIPPING: {
2556
+ requiresShipping: true,
2557
+ modes: [],
2558
+ defaultMode: null,
2559
+ requiresCollection: true,
2560
+ exchangeSupported: true
2561
+ },
2562
+ DOWNLOAD: {
2563
+ requiresShipping: false,
2564
+ modes: ["AUTO"],
2565
+ defaultMode: "AUTO",
2566
+ requiresCollection: false,
2567
+ exchangeSupported: false
2568
+ },
2569
+ CODE: {
2570
+ requiresShipping: false,
2571
+ modes: ["AUTO", "MANUAL"],
2572
+ defaultMode: "AUTO",
2573
+ requiresCollection: false,
2574
+ exchangeSupported: false
2575
+ },
2576
+ SERVICE: {
2577
+ requiresShipping: false,
2578
+ modes: ["MANUAL", "AUTO"],
2579
+ defaultMode: "MANUAL",
2580
+ requiresCollection: false,
2581
+ exchangeSupported: false
2582
+ },
2583
+ PICKUP: {
2584
+ requiresShipping: false,
2585
+ modes: ["MANUAL"],
2586
+ defaultMode: "MANUAL",
2587
+ requiresCollection: true,
2588
+ exchangeSupported: true
2589
+ }
2590
+ };
2591
+ /**
2592
+ * 저장된 값 → 유형. null·모르는 값은 `SHIPPING`이다 — 이 필드 이전의 주문상품(`order_items.fulfillment_type` null)과
2593
+ * 상품은 모두 배송 상품이었다.
2594
+ */
2595
+ function fulfillmentTypeOf(value) {
2596
+ const parsed = fulfillmentTypeSchema.safeParse(value);
2597
+ return parsed.success ? parsed.data : "SHIPPING";
2598
+ }
2599
+ /** 유형의 규칙 — 모르는 값은 배송 상품 규칙이다 */
2600
+ function fulfillmentRuleOf(type) {
2601
+ return FULFILLMENT_RULES[fulfillmentTypeOf(type)];
2602
+ }
2603
+ /** 택배 배송이 필요한가 — 배송비 묶음·배송지·발송 흐름의 판정은 이 함수 하나를 지난다 */
2604
+ function requiresShipping(type) {
2605
+ return fulfillmentRuleOf(type).requiresShipping;
2606
+ }
2607
+ /** 반품에 수거가 필요한가 — 비실물 반품은 수거 등록·수거 완료 없이 승인한다 */
2608
+ function requiresCollection(type) {
2609
+ return fulfillmentRuleOf(type).requiresCollection;
2610
+ }
2611
+ /**
2612
+ * 유효 제공 방식 — 요청 값이 유형에 허용되면 그 값, 생략이면 유형 기본값이다. 배송 상품은 null이다.
2613
+ * 허용되지 않는 값은 `null`을 돌려주지 않고 호출부가 거부하도록 `undefined`를 돌려준다.
2614
+ */
2615
+ function resolveFulfillmentMode(type, mode) {
2616
+ const rule = FULFILLMENT_RULES[type];
2617
+ if (rule.modes.length === 0) return null;
2618
+ if (mode === null || mode === void 0) return rule.defaultMode;
2619
+ return rule.modes.includes(mode) ? mode : void 0;
2620
+ }
2621
+ /**
2622
+ * 배송이 없는 주문상품의 배송 묶음 키 (`order_items.delivery_bundle_key`).
2623
+ *
2624
+ * null로 두면 환불 정산이 #29 이전 주문의 레거시 폴백(「묶음 = 같은 상품」, 상품의 **현재** 배송비)을 탄다.
2625
+ * 이 키를 만나면 배송비 환불·차감이 0이고 `orders.delivery_fee_settlements`에 항목을 만들지 않는다.
2626
+ * 정책 지문 키(`b:…`)와 겹치지 않는다.
2627
+ */
2628
+ const NO_SHIPPING_BUNDLE_KEY = "none";
2629
+ //#endregion
1536
2630
  //#region src/schemas/payment.ts
1537
2631
  /**
1538
2632
  * 스토어 결제(PG) 설정과 매출 리포트 계약.
@@ -1900,6 +2994,22 @@ const shippingAddressSchema = z.object({
1900
2994
  deliveryMemo: z.string().nullish(),
1901
2995
  entranceCode: z.string().nullish()
1902
2996
  });
2997
+ /** 비실물 주문상품의 제공 정보 (이슈 #48) */
2998
+ const orderItemFulfillmentSchema = z.object({
2999
+ status: z.string().describe("제공 상태. `FULFILLED`(제공함)·`REVOKED`(환불 등으로 회수함). 값이 추가될 수 있어 문자열로 받는다"),
3000
+ mode: z.string().nullable().describe("제공 방식. `AUTO`·`MANUAL`"),
3001
+ fulfilledAt: z.string().describe("제공 시각 (ISO 8601)"),
3002
+ note: z.string().nullable().describe("셀러가 남긴 이용 안내. 없으면 null"),
3003
+ codes: z.array(orderItemCodeSchema).optional().default([]).describe("코드 발급 상품에 배정한 코드의 가린 값. 평문은 싣지 않는다. 코드 상품이 아니면 빈 배열. 서버는 항상 싣는다"),
3004
+ download: orderItemDownloadSchema.nullable().optional().default(null).describe("파일 다운로드 상품의 횟수·기한·파일 목록. 파일 주소는 싣지 않는다. 다른 유형은 null. 서버는 항상 싣는다"),
3005
+ mail: z.object({
3006
+ status: z.string().describe("제공 안내 메일 상태. 현재 값은 `PENDING`(보낼 예정)·`SENDING`·`SENT`·`FAILED`·`SKIPPED`(보내지 않음: 스토어 설정 꺼짐·받을 주소 없음)이고 값이 추가될 수 있다"),
3007
+ sentAt: z.string().nullable(),
3008
+ errorCode: z.string().nullable().describe("실패·건너뜀 사유 코드")
3009
+ }).nullable().optional().default(null).describe("구매자에게 보낸 제공 안내 메일(이슈 #48). 기록이 없으면 null. 서버는 항상 싣는다")
3010
+ });
3011
+ /** 제공 안내 메일 재발송 결과 */
3012
+ const fulfillmentMailResendSchema = z.object({ mailStatus: z.string().describe("재발송 뒤 메일 상태 — `SENT`·`PENDING`(곧 다시 보낸다)·`FAILED`·`SKIPPED`") });
1903
3013
  const orderItemSchema = z.object({
1904
3014
  orderItemId: z.string(),
1905
3015
  orderId: z.string(),
@@ -1914,23 +3024,35 @@ const orderItemSchema = z.object({
1914
3024
  optionId: z.string().nullable(),
1915
3025
  optionName: z.string().nullable(),
1916
3026
  sellerCode: z.string().nullable(),
3027
+ fulfillmentType: fulfillmentTypeResponseSchema.optional().default("SHIPPING").describe("주문 시점 상품 유형. 현재 값은 `SHIPPING`·`DOWNLOAD`·`CODE`·`SERVICE`·`PICKUP`이고 값이 추가될 수 있어 문자열로 받는다. 배송 상품이 아니면 발송·배송완료 대신 제공 처리를 하고, `DELIVERED`는 제공 완료(방문 수령은 수령 완료)다. 서버는 항상 싣는다"),
1917
3028
  quantity: z.number().int().describe("주문 수량"),
1918
3029
  canceledQuantity: z.number().int().describe("취소·반품으로 환불된 누적 수량. 수량 단위 부분 취소가 없었으면 0이다"),
1919
3030
  activeQuantity: z.number().int().describe("살아 있는 수량 (`quantity` − `canceledQuantity`). 0이면 주문상품이 전량 취소됐다"),
1920
3031
  unitPrice: z.number().int(),
1921
3032
  discountAmount: z.number().int(),
1922
- totalPrice: z.number().int().describe("주문상품 금액 (할인 반영 단가 × 주문 수량)"),
3033
+ totalPrice: z.number().int().describe("주문상품 금액 (즉시할인 반영 단가 × 주문 수량). 쿠폰 할인 전 금액이다"),
3034
+ couponDiscountAmount: z.number().int().optional().default(0).describe("이 주문상품에 배분된 쿠폰 할인 — 상품 쿠폰 + 주문 쿠폰 배분. 실결제 금액은 `totalPrice − couponDiscountAmount`이고 취소·반품 환불은 실결제 금액을 수량 비율로 나눈다. 쿠폰이 없으면 0. 서버는 항상 싣는다"),
1923
3035
  hold: z.object({
1924
3036
  reason: z.string(),
1925
3037
  expectedDispatchDate: z.string()
1926
3038
  }).nullable(),
3039
+ fulfillment: orderItemFulfillmentSchema.nullable().optional().default(null).describe("비실물 상품의 제공 정보(이슈 #48). 제공 전이거나 배송 상품이면 null이다. 코드 평문·파일 주소는 싣지 않는다. 서버는 항상 싣는다"),
1927
3040
  autoDecisionDate: z.string().nullable(),
1928
3041
  sellerMemo: z.string().nullable(),
1929
3042
  orderedAt: z.string(),
1930
3043
  lastChangedAt: z.string(),
1931
3044
  lastChangedType: changeTypeSchema,
3045
+ withdrawalRestricted: z.boolean().optional().default(false).describe("청약철회 제한 주문상품인가(이슈 #48). 구매자가 결제 전에 동의했고 제공이 시작되면(다운로드·열람·이용 시작) 구매자 취소·반품을 받지 않는다. 서버는 항상 싣는다"),
1932
3046
  testPayment: z.boolean().describe("소속 주문이 테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았습니다")
1933
3047
  });
3048
+ /** 주문이 받은 쿠폰 한 장 (이슈 #47) */
3049
+ const orderDiscountSchema = z.object({
3050
+ couponId: z.string().describe("쿠폰 id"),
3051
+ name: z.string().describe("주문 시점 쿠폰 이름"),
3052
+ kind: z.string().describe("쿠폰 종류. 현재 값은 `ORDER`(주문 쿠폰)·`PRODUCT`(상품 쿠폰)·`DELIVERY`(배송비 쿠폰)이고 값이 추가될 수 있어 문자열로 받는다"),
3053
+ discountAmount: z.number().int().describe("이 쿠폰의 할인액 (원)"),
3054
+ issueId: z.string().nullable().describe("발급 쿠폰 id. 코드 입력형은 null")
3055
+ });
1934
3056
  const orderSchema = z.object({
1935
3057
  orderId: z.string(),
1936
3058
  orderedAt: z.string(),
@@ -1939,6 +3061,7 @@ const orderSchema = z.object({
1939
3061
  phone: z.string(),
1940
3062
  email: z.email().optional()
1941
3063
  }),
3064
+ requiresShipping: z.boolean().optional().default(true).describe("택배 배송이 필요한 주문상품이 있는가. false면 `shippingAddress`는 주문자 이름·연락처만 있고 주소는 빈 문자열이다. 서버는 항상 싣는다"),
1942
3065
  shippingAddress: shippingAddressSchema,
1943
3066
  payment: z.object({
1944
3067
  method: z.enum([
@@ -1951,13 +3074,17 @@ const orderSchema = z.object({
1951
3074
  ]).describe("method는 호환용이며 간편결제를 CARD로 표시한다. 실제 수단은 approvedMethod를 본다"),
1952
3075
  approvedMethod: z.string().nullable().optional().default(null).describe("실제 결제수단. method는 호환용이며 간편결제를 CARD로 표시한다. 실제 수단은 approvedMethod를 본다. 현재 값은 `pgPaymentMethodSchema`(`PgPaymentMethod`: CARD·BANK_TRANSFER·VIRTUAL_ACCOUNT·MOBILE·EASY_PAY)이고 매출 리포트·스토어프론트·환불 원장과 같은 값이다. 값이 추가될 수 있어 문자열로 받는다. PG가 알려 주지 않았으면 요청 수단, 시도 기록이 없는 오래된 주문은 null. 서버는 항상 싣는다"),
1953
3076
  easyPayProvider: easyPayProviderResponseSchema.nullable().optional().default(null).describe("승인된 간편결제사. `approvedMethod`가 `EASY_PAY`일 때만 값이 있고, PG가 알려 주지 않았으면 요청한 간편결제사다. 그 밖의 결제수단과 간편결제 이전 주문은 null. 서버는 항상 싣는다. 현재 값은 `NAVERPAY`·`KAKAOPAY`·`TOSSPAY`·`PAYCO`이고 값이 추가될 수 있어 문자열로 받는다"),
1954
- productAmount: z.number().int(),
1955
- deliveryFee: z.number().int(),
1956
- discountAmount: z.number().int(),
1957
- totalAmount: z.number().int(),
3077
+ productAmount: z.number().int().describe("상품 금액 합 (즉시할인 반영, 쿠폰 전)"),
3078
+ deliveryFee: z.number().int().describe("배송비 (쿠폰 전)"),
3079
+ discountAmount: z.number().int().describe("즉시할인 합 (정가 − 할인 단가) × 수량. 쿠폰 할인은 `couponDiscountAmount`로 따로 온다"),
3080
+ couponDiscountAmount: z.number().int().optional().default(0).describe("상품·주문 쿠폰 할인 합계. 쿠폰이 없으면 0. 서버는 항상 싣는다"),
3081
+ deliveryDiscountAmount: z.number().int().optional().default(0).describe("배송비 쿠폰 할인. 쿠폰이 없으면 0. 서버는 항상 싣는다"),
3082
+ discounts: z.array(orderDiscountSchema).optional().default([]).describe("주문이 받은 쿠폰. 쿠폰이 없으면 빈 배열. 서버는 항상 싣는다"),
3083
+ totalAmount: z.number().int().describe("결제 금액 = productAmount + deliveryFee − couponDiscountAmount − deliveryDiscountAmount. 쿠폰이 없는 주문은 productAmount + deliveryFee다"),
1958
3084
  paidAt: z.string().nullable(),
1959
3085
  cashReceipt: cashReceiptSchema.nullable().optional().default(null).describe("현금영수증. 구매자가 계좌이체·가상계좌 결제에서 신청했을 때만 있고 그 밖에는 null. 서버는 항상 싣는다")
1960
3086
  }),
3087
+ withdrawalAgreedAt: z.string().nullable().optional().default(null).describe("청약철회 제한 안내에 동의한 시각(이슈 #48). 제한 상품이 없던 주문은 null. 서버는 항상 싣는다"),
1961
3088
  testPayment: z.boolean().describe("테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았습니다. 상품을 발송하지 않도록 주의하십시오"),
1962
3089
  items: z.array(orderItemSchema)
1963
3090
  });
@@ -1977,6 +3104,7 @@ const orderListQuerySchema = z.object({
1977
3104
  orderedTo: z.string().optional().describe("조회 끝 시각 (ISO 8601, 포함). 생략하면 orderedFrom부터 31일. 기간은 최대 31일"),
1978
3105
  status: orderItemStatusSchema.optional().describe("주문상품 상태 필터 — 이 상태의 주문상품이 하나라도 있는 주문"),
1979
3106
  keyword: z.string().optional().describe("주문번호(일치)·주문자명·수령인명(포함) 검색"),
3107
+ fulfillmentType: z.string().optional().describe("상품 유형 필터(이슈 #48) — 이 유형(`SHIPPING`·`DOWNLOAD`·`CODE`·`SERVICE`·`PICKUP`)의 주문상품이 하나라도 있는 주문"),
1980
3108
  testPayment: testPaymentQuery,
1981
3109
  ...pageQuery,
1982
3110
  size: z.string().optional().describe("페이지 크기 (기본 20, 최대 100)")
@@ -2078,14 +3206,29 @@ const cancelBySellerRequestSchema = z.object({
2078
3206
  detail: z.string().max(500).optional(),
2079
3207
  quantity: z.number().int().min(1).optional().describe("취소할 수량. 생략하면 남은 수량 전체다. 남은 수량보다 크면 400 `INVALID_QUANTITY`. 일부만 취소하면 주문상품은 상태를 유지한 채 `canceledQuantity`가 늘고, 전 수량이 소진되면 `CANCELED`가 된다")
2080
3208
  });
3209
+ /**
3210
+ * 제공 처리 요청 (이슈 #48) — `POST /v1/order-items/{orderItemId}/fulfill`. 수동 제공 비실물 주문상품(이용권·방문 수령 등)을
3211
+ * 제공 완료(`DELIVERED`)로 바꾼다.
3212
+ */
3213
+ const fulfillRequestSchema = z.object({
3214
+ note: z.string().trim().max(1e3).optional().describe("구매자에게 보일 이용 안내(예: 예약 방법, 수령 장소). 주문 상세에 실린다"),
3215
+ codes: z.array(z.string().trim().min(1).max(200)).min(1).max(100).optional().describe("수동 제공 코드 발급 상품의 코드 — 셀러 시스템이 발급한 코드를 넘긴다. 남은 수량만큼 보낸다(`CODE_COUNT_MISMATCH`). 암호화해 저장하고 구매자가 열람할 때만 평문으로 나간다. 다른 유형은 보내지 않는다")
3216
+ });
3217
+ /** 제공 처리 일괄 요청 — `POST /v1/order-items/fulfill` */
3218
+ const fulfillBulkRequestSchema = z.object({
3219
+ orderItemIds: z.array(z.string()).min(1).max(100),
3220
+ note: fulfillRequestSchema.shape.note
3221
+ });
2081
3222
  /** `POST /v1/order-items/{orderItemId}/cancel` 응답 — 셀러 직권취소 환불 결과 */
2082
3223
  const cancelBySellerResultSchema = z.object({
2083
3224
  orderItemId: z.string(),
2084
- refundAmount: z.number().int().describe("실제 환불한 금액 — 수량 비율 상품 금액 + 배송비 환불 − 조건부 무료배송 미달 차감"),
3225
+ refundAmount: z.number().int().describe("실제 환불한 금액 — 수량 비율 상품 실결제 금액(쿠폰 배분을 뺀 금액) + 배송비 환불 − 조건부 무료배송 미달 차감 + 되돌린 쿠폰 차감(`couponChargeRefundAmount`)"),
2085
3226
  quantity: z.number().int().describe("취소한 수량"),
2086
3227
  canceledQuantity: z.number().int().describe("취소 후 누적 취소 수량"),
2087
3228
  deliveryFeeRefundAmount: z.number().int().describe("환불에 포함된 배송비. 배송 묶음(같은 상품)이 모두 취소됐고 환불 정책이 켜져 있을 때만 0보다 크다"),
2088
3229
  deliveryFeeChargeAmount: z.number().int().describe("조건부 무료배송 기준 미달로 환불액에서 뺀 배송비. 정책이 꺼져 있으면 항상 0이다"),
3230
+ couponChargeAmount: z.number().int().optional().default(0).describe("쿠폰 조건 미달 차감. 판매자 취소는 차감하지 않으므로 늘 0이다. 서버는 항상 싣는다"),
3231
+ couponChargeRefundAmount: z.number().int().optional().default(0).describe("환불액에 더한, 앞선 환불에서 뺀 쿠폰 차감을 되돌린 몫. 서버는 항상 싣는다"),
2089
3232
  refundedAt: z.string()
2090
3233
  });
2091
3234
  /** `PUT /v1/order-items/{orderItemId}/memo` 응답 — 빈 문자열로 저장하면 메모가 지워져 `null`이다 */
@@ -2110,124 +3253,20 @@ const purchaseDecisionExtendResultSchema = z.object({
2110
3253
  */
2111
3254
  const addressChangeRequestListSchema = z.array(z.record(z.string(), z.unknown())).describe("배송지 변경 요청 목록 (현재 항상 빈 배열)");
2112
3255
  //#endregion
2113
- //#region src/schemas/refund-policy.ts
2114
- /**
2115
- * 환불 정책 (이슈 #25) — 취소·반품·교환에서 **배송비를 누가 부담하는지**를 정한다.
2116
- *
2117
- * 두 층이다. 스토어 설정(`PATCH /v1/store`의 `refundPolicy`)이 기본값이고, 상품별 정책
2118
- * (`POST/PUT /v1/products`의 `refundPolicy`)이 그 상품에만 덮어쓴다. 상품이 정하지 않은 항목은
2119
- * 스토어 기본을 따르고, 스토어도 정하지 않았으면 `REFUND_POLICY_DEFAULTS`다.
2120
- *
2121
- * 한 주문에 정책이 섞이면 **상품별로 계산한다**. 배송비 묶음도 상품 단위다(주문서·장바구니의
2122
- * 배송비 합산과 같은 규칙: 같은 상품의 주문상품이 한 묶음이고 묶음마다 배송비가 붙는다).
2123
- */
2124
- const descriptions$1 = {
2125
- preShipmentDeliveryFeeRefund: "발송 전 취소로 배송 묶음(같은 상품의 주문상품)이 모두 취소되면 그 묶음의 배송비를 환불한다. false면 배송비를 환불하지 않는다. 기본 true",
2126
- returnDeliveryFee: "단순 변심(구매자 귀책) 반품의 반품 배송비. 원 단위. 환불액에서 뺀다. 기본 0(무료). 상품별 정책에서 이 값을 주면 상품의 `returnFee`도 같은 값으로 맞춘다",
2127
- exchangeDeliveryFee: "단순 변심(구매자 귀책) 교환의 교환 배송비. 원 단위. 구매자에게 청구할 금액을 안내값으로 싣는다. 기본 0(무료). 상품별 정책에서 이 값을 주면 상품의 `exchangeFee`도 같은 값으로 맞춘다",
2128
- sellerFaultFreeShipping: "판매자 귀책(셀러 직권취소, 불량·오배송 반품)이면 배송비를 셀러가 부담한다 — 배송비를 환불하고 반품비를 받지 않는다. false면 구매자 귀책과 같은 규칙을 쓴다. 기본 true",
2129
- conditionalFreeShortfallCharge: "조건부 무료배송(`CONDITIONAL_FREE`) 상품에서 일부 취소로 남은 금액이 무료 기준액에 미달하면 그 배송비를 환불액에서 뺀다. false면 추가 청구 없이 셀러가 부담한다. 기본 false"
2130
- };
2131
- /** 유효 환불 정책 — 스토어 기본 + 상품별 덮어쓰기를 합친 결과 */
2132
- const refundPolicySchema = z.object({
2133
- preShipmentDeliveryFeeRefund: z.boolean().describe(descriptions$1.preShipmentDeliveryFeeRefund),
2134
- returnDeliveryFee: z.number().int().min(0).describe(descriptions$1.returnDeliveryFee),
2135
- exchangeDeliveryFee: z.number().int().min(0).describe(descriptions$1.exchangeDeliveryFee),
2136
- sellerFaultFreeShipping: z.boolean().describe(descriptions$1.sellerFaultFreeShipping),
2137
- conditionalFreeShortfallCharge: z.boolean().describe(descriptions$1.conditionalFreeShortfallCharge)
2138
- });
2139
- /**
2140
- * 스토어가 정하지 않았을 때 서버가 쓰는 값.
2141
- *
2142
- * `as const`가 아니라 `RefundPolicy`로 타입을 좁힌다 — 리터럴 타입이면 `Partial<typeof …>`로 일부만 바꿀 때
2143
- * `true` 자리에 `false`를 넣을 수 없다.
2144
- */
2145
- const REFUND_POLICY_DEFAULTS = {
2146
- preShipmentDeliveryFeeRefund: true,
2147
- returnDeliveryFee: 0,
2148
- exchangeDeliveryFee: 0,
2149
- sellerFaultFreeShipping: true,
2150
- conditionalFreeShortfallCharge: false
2151
- };
2152
- /**
2153
- * 부분 정책 — 스토어 설정 요청과 상품별 덮어쓰기가 쓴다. 준 항목만 바뀐다.
2154
- *
2155
- * `strictObject`라 모르는 키는 400이다. 오타(`returnFee` 등)를 조용히 무시하면 셀러는 저장됐다고
2156
- * 믿는데 정책은 기본값으로 남는다.
2157
- */
2158
- const refundPolicyPatchSchema = z.strictObject({
2159
- preShipmentDeliveryFeeRefund: z.boolean().optional().describe(descriptions$1.preShipmentDeliveryFeeRefund),
2160
- returnDeliveryFee: z.number().int().min(0).optional().describe(descriptions$1.returnDeliveryFee),
2161
- exchangeDeliveryFee: z.number().int().min(0).optional().describe(descriptions$1.exchangeDeliveryFee),
2162
- sellerFaultFreeShipping: z.boolean().optional().describe(descriptions$1.sellerFaultFreeShipping),
2163
- conditionalFreeShortfallCharge: z.boolean().optional().describe(descriptions$1.conditionalFreeShortfallCharge)
2164
- });
2165
- /**
2166
- * 유효 정책 해석 — 상품별 덮어쓰기 → 스토어 기본 → `REFUND_POLICY_DEFAULTS` 순이다.
2167
- *
2168
- * 반품비·교환비는 레거시 필드(`products.returnFee`·`exchangeFee`)와 **연결**한다. 상품이
2169
- * `refundPolicy.returnDeliveryFee`를 정하면 그 값이 원천이고(서버가 `returnFee`도 같은 값으로 맞춘다),
2170
- * 정하지 않았으면 레거시 값이 0보다 클 때 그 값을 쓴다. 0이면 스토어 기본을 따른다 —
2171
- * 정책이 생기기 전에 만든 상품(반품비 0)이 스토어 기본을 물려받게 하는 규칙이다.
2172
- */
2173
- function resolveRefundPolicy(storePolicy, product) {
2174
- return {
2175
- ...REFUND_POLICY_DEFAULTS,
2176
- ...dropUndefined(storePolicy),
2177
- ...productRefundPolicyOverride(product) ?? {}
2178
- };
2179
- }
2180
- /**
2181
- * 상품의 **덮어쓰기 자체** — 레거시 반품비·교환비를 같은 형태로 승격한 값이다. 덮어쓰기가 없으면 null.
2182
- *
2183
- * `resolveRefundPolicy`가 합친 결과로는 "상품이 정한 값"과 "스토어 기본을 물려받은 값"을 가를 수 없다.
2184
- * 관리 API 응답의 `refundPolicy`와 콘솔 폼의 덮어쓰기 스위치가 이 함수를 원천으로 쓴다 — 가르지 못하면
2185
- * 스토어 기본을 쓰는 상품을 저장할 때 그 순간의 기본값이 상품에 박히거나, 레거시 값이 조용히 사라진다.
2186
- */
2187
- function productRefundPolicyOverride(product) {
2188
- const override = dropUndefined(product?.refundPolicy);
2189
- const legacy = {};
2190
- if (override.returnDeliveryFee === void 0 && (product?.returnFee ?? 0) > 0) legacy.returnDeliveryFee = product?.returnFee ?? 0;
2191
- if (override.exchangeDeliveryFee === void 0 && (product?.exchangeFee ?? 0) > 0) legacy.exchangeDeliveryFee = product?.exchangeFee ?? 0;
2192
- const merged = {
2193
- ...legacy,
2194
- ...override
2195
- };
2196
- if (product?.refundPolicy == null && Object.keys(merged).length === 0) return null;
2197
- return merged;
2198
- }
2199
- /** `undefined` 값을 빼고 얕게 복사한다 — 전개 순서로 덮어쓸 때 `undefined`가 기본값을 지우지 않게 */
2200
- function dropUndefined(patch) {
2201
- if (!patch) return {};
2202
- return Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== void 0));
2203
- }
2204
- /**
2205
- * 수량 단위 부분 취소의 환불 기준액 — **누적 내림의 차분**이다.
2206
- *
2207
- * 원 단위 반올림 규칙: `floor(주문상품 금액 × 누적 수량 ÷ 전체 수량)`을 두 번 구해 그 차를 쓴다.
2208
- * 부분 취소를 몇 번 나눠도 합이 주문상품 금액과 정확히 같아서 1원이 남거나 넘치지 않는다.
2209
- * 할인은 이미 단가에 들어 있어(주문 단위 쿠폰이 없다) 별도 안분이 없다.
2210
- *
2211
- * @param totalPrice 주문상품 금액 (할인 반영 단가 × 전체 수량)
2212
- * @param quantity 주문상품 전체 수량
2213
- * @param settledQuantity 이미 취소·반품된 누적 수량
2214
- * @param cancelQuantity 이번에 취소·반품할 수량
2215
- */
2216
- function proratedRefundBase(totalPrice, quantity, settledQuantity, cancelQuantity) {
2217
- if (quantity <= 0 || cancelQuantity <= 0) return 0;
2218
- const before = Math.floor(totalPrice * settledQuantity / quantity);
2219
- const after = Math.floor(totalPrice * (settledQuantity + cancelQuantity) / quantity);
2220
- return Math.max(0, after - before);
2221
- }
2222
- //#endregion
2223
3256
  //#region src/schemas/upload.ts
2224
3257
  /**
2225
3258
  * 업로드 용도. 허용 확장자와 크기 상한이 용도별로 다르다.
2226
3259
  *
2227
3260
  * - `product-image`: 상품 목록·상세의 상품 이미지. 목록 썸네일로 쓰이므로 정지 이미지만 받는다.
2228
3261
  * - `description`: 상세설명 본문에 삽입하는 미디어. 동영상과 svg도 받는다.
3262
+ * - `digital-asset`: 파일 다운로드 상품의 판매 파일(이슈 #48). **비공개 객체**로 올라가고 공개 주소가 없다 — 구매자는 제공된
3263
+ * 주문상품에서 수명 300초의 서명 주소로만 받는다.
2229
3264
  */
2230
- const uploadPurposeSchema = z.enum(["product-image", "description"]);
3265
+ const uploadPurposeSchema = z.enum([
3266
+ "product-image",
3267
+ "description",
3268
+ "digital-asset"
3269
+ ]);
2231
3270
  const MB = 1048576;
2232
3271
  /**
2233
3272
  * 용도별 업로드 제한 — 서버(`POST /v1/uploads`)와 클라이언트가 공유하는 단일 원천이다.
@@ -2261,8 +3300,35 @@ const UPLOAD_PURPOSE_LIMITS = {
2261
3300
  "mov"
2262
3301
  ],
2263
3302
  maxBytes: 50 * MB
3303
+ },
3304
+ "digital-asset": {
3305
+ extensions: [
3306
+ "pdf",
3307
+ "epub",
3308
+ "zip",
3309
+ "mp3",
3310
+ "wav",
3311
+ "flac",
3312
+ "m4a",
3313
+ "mp4",
3314
+ "mov",
3315
+ "jpg",
3316
+ "jpeg",
3317
+ "png",
3318
+ "webp",
3319
+ "psd",
3320
+ "ai",
3321
+ "txt",
3322
+ "csv",
3323
+ "xlsx",
3324
+ "docx",
3325
+ "pptx"
3326
+ ],
3327
+ maxBytes: 200 * MB
2264
3328
  }
2265
3329
  };
3330
+ /** 비공개 업로드(판매 파일)의 PUT 필수 헤더 — CDN이 이 값으로 공개 조회를 막는다 */
3331
+ const PRIVATE_UPLOAD_HEADERS = { "x-amz-meta-visibility": "private" };
2266
3332
  /** 파일명의 소문자 확장자. 확장자가 없으면 `""` */
2267
3333
  function uploadExtensionOf(filename) {
2268
3334
  const dot = filename.lastIndexOf(".");
@@ -2300,10 +3366,12 @@ const uploadRequestSchema = z.object({
2300
3366
  size: z.number().int().positive().optional().describe("파일 바이트 크기. 주면 용도별 상한을 발급 전에 검사한다")
2301
3367
  });
2302
3368
  const uploadTicketSchema = z.object({
2303
- /** 업로드 완료 후 공개될 주소 */
3369
+ /** 업로드 완료 후 공개될 주소. 비공개 업로드(`digital-asset`)는 이 주소로 받을 수 없다 */
2304
3370
  url: z.url(),
2305
3371
  /** 파일 바이트를 PUT할 presigned URL. 1시간 유효 */
2306
- uploadUrl: z.url()
3372
+ uploadUrl: z.url(),
3373
+ key: z.string().optional().describe("객체 키. 비공개 업로드(`digital-asset`)는 이 값으로 상품에 파일을 연결한다(`POST /v1/products/{productId}/digital-assets`). 공개 업로드에는 없다"),
3374
+ headers: z.record(z.string(), z.string()).optional().default({}).describe("PUT에 반드시 실을 헤더. 비공개 업로드는 `x-amz-meta-visibility: private`이고 빠뜨리면 공개 객체가 된다. 공개 업로드는 빈 객체다")
2307
3375
  });
2308
3376
  //#endregion
2309
3377
  //#region src/schemas/product.ts
@@ -2330,6 +3398,7 @@ const productSummarySchema = z.object({
2330
3398
  thumbnailUrl: z.url().optional(),
2331
3399
  /** optionGroups.length > 0 */
2332
3400
  hasOptions: z.boolean(),
3401
+ fulfillmentType: fulfillmentTypeResponseSchema.optional().default("SHIPPING"),
2333
3402
  updatedAt: z.string()
2334
3403
  });
2335
3404
  /** 옵션 축(그룹)이 가지는 값 하나 — 예: 축 "색상"의 값 "블랙" */
@@ -2364,6 +3433,28 @@ const productVariantSchema = z.object({
2364
3433
  sellerCode: z.string().nullable(),
2365
3434
  usable: z.boolean()
2366
3435
  });
3436
+ const discountSchema = z.object({
3437
+ productId: z.string(),
3438
+ discountType: z.enum(["RATE", "AMOUNT"]),
3439
+ discountValue: z.number().int(),
3440
+ discountedPrice: z.number().int(),
3441
+ startAt: z.string().nullable(),
3442
+ endAt: z.string().nullable()
3443
+ });
3444
+ /**
3445
+ * 상품 유형 응답 필드 (이슈 #48) — 이 필드 이전 서버의 응답에는 없으므로 기본값을 둔다. 서버는 항상 싣는다.
3446
+ */
3447
+ function productFulfillmentResponseShape() {
3448
+ return {
3449
+ fulfillmentType: fulfillmentTypeResponseSchema.optional().default("SHIPPING"),
3450
+ fulfillmentMode: z.string().nullable().optional().default(null).describe("제공 방식. `AUTO`(결제 확정과 함께 제공)·`MANUAL`(셀러가 제공 처리). 배송 상품은 null. 값이 추가될 수 있어 문자열로 받는다"),
3451
+ stockTracking: z.boolean().optional().default(true).describe("재고를 세는가. false면 재고와 무관하게 판다(파일 다운로드 기본)"),
3452
+ downloadLimit: z.number().int().nullable().optional().default(null).describe("파일 다운로드 횟수 한도. 파일 다운로드 상품이 아니면 null"),
3453
+ downloadDays: z.number().int().nullable().optional().default(null).describe("파일 다운로드 기간(제공일부터 일수). 파일 다운로드 상품이 아니면 null"),
3454
+ withdrawalRestricted: z.boolean().optional().default(false).describe("청약철회 제한 대상인가. 파일 다운로드·코드 발급 상품만 켤 수 있다(그 밖의 유형은 false로 저장한다). true면 주문서에서 제한 고지에 대한 구매자 동의를 받고, 첫 다운로드·열람 뒤 구매자 취소·반품을 받지 않는다"),
3455
+ pickupAddressId: z.string().nullable().optional().default(null).describe("방문 수령지 주소 id(`seller_addresses`, 유형 PICKUP). 방문 수령 상품이 아니면 null")
3456
+ };
3457
+ }
2367
3458
  const productSchema = productSummarySchema.extend({
2368
3459
  description: z.string().optional(),
2369
3460
  /**
@@ -2385,9 +3476,15 @@ const productSchema = productSummarySchema.extend({
2385
3476
  conditionalFreeAmount: z.number().int().nullable().optional(),
2386
3477
  returnFee: z.number().int().optional(),
2387
3478
  exchangeFee: z.number().int().optional(),
2388
- refundPolicy: refundPolicyPatchSchema.nullable().optional().describe("상품별 환불 정책 덮어쓰기. null이면 스토어 기본 환불 정책을 쓴다"),
3479
+ refundPolicy: productRefundPolicyPatchSchema.nullable().optional().describe("상품별 환불 정책 덮어쓰기. null이면 스토어 기본 환불 정책을 쓴다"),
2389
3480
  shippingAddressId: z.string().nullable().optional(),
2390
3481
  returnAddressId: z.string().nullable().optional(),
3482
+ ...productFulfillmentResponseShape(),
3483
+ /**
3484
+ * 설정된 즉시할인. `GET /products/{productId}/discount`의 응답과 같은 형태다(이슈 #51).
3485
+ * 할인이 없으면 null이다. 이 필드를 싣기 전 서버의 응답에는 없으므로 optional이다.
3486
+ */
3487
+ discount: discountSchema.nullable().optional().describe("설정된 즉시할인. GET /v1/products/{productId}/discount 응답과 같은 형태다. 할인이 없으면 null"),
2391
3488
  createdAt: z.string().optional()
2392
3489
  });
2393
3490
  /** 상품 하나에 등록할 수 있는 이미지 최대 장수 — 첫 장이 대표(썸네일)다 */
@@ -2523,6 +3620,23 @@ const ADDRESS_DESCRIPTIONS = {
2523
3620
  patchShipping: "출고지 주소 id. **생략은 바꾸지 않음**이고 `null`은 비우기다(스토어 기본 출고지를 따른다). 이 스토어의 `SHIPPING` 주소가 아니면 거부한다",
2524
3621
  patchReturn: "반품지 주소 id. **생략은 바꾸지 않음**이고 `null`은 비우기다(스토어 기본 반품지를 따른다). 이 스토어의 `RETURN` 주소가 아니면 거부한다"
2525
3622
  };
3623
+ /**
3624
+ * 상품 유형 요청 필드 (이슈 #48) — 등록·전체 수정·부분 수정이 같은 필드를 받는다.
3625
+ *
3626
+ * 생략의 뜻: 등록은 배송 상품(`SHIPPING`)과 유형 기본값이고, 전체 수정(PUT)·부분 수정(PATCH)은 **현재 값을 유지**한다.
3627
+ * 이 필드를 모르는 옛 클라이언트가 전체 수정을 보내도 유형이 배송 상품으로 되돌아가지 않게 한다.
3628
+ */
3629
+ function productFulfillmentRequestShape() {
3630
+ return {
3631
+ fulfillmentType: fulfillmentTypeSchema.optional().describe("상품 유형. `SHIPPING` 배송 상품 · `DOWNLOAD` 파일 다운로드 · `CODE` 코드 발급 · `SERVICE` 이용권·서비스 · `PICKUP` 방문 수령. 등록에서 생략하면 SHIPPING이고, 수정에서 생략하면 바꾸지 않는다. 판매 이력이 있는 상품의 유형은 바꿀 수 없다(409 `FULFILLMENT_TYPE_LOCKED`). 배송 상품이 아니면 배송비·출고지·반품지·반품비·교환비를 무시하고 0·null로 저장한다"),
3632
+ fulfillmentMode: fulfillmentModeSchema.nullable().optional().describe("제공 방식. `AUTO` 결제 확정과 함께 제공 · `MANUAL` 셀러가 제공 처리. 생략·null이면 유형 기본값이다(코드 발급 AUTO, 이용권 MANUAL, 파일 다운로드 AUTO만, 방문 수령 MANUAL만). 배송 상품은 무시한다"),
3633
+ stockTracking: z.boolean().optional().describe("재고를 세는가. 파일 다운로드 상품만 끌 수 있다(기본 false). 그 밖의 유형은 항상 true"),
3634
+ downloadLimit: z.number().int().min(1).max(100).nullable().optional().describe("파일 다운로드 횟수 한도. 파일 다운로드 상품만 쓰고 생략·null이면 5회"),
3635
+ downloadDays: z.number().int().min(1).max(3650).nullable().optional().describe("파일 다운로드 기간(제공일부터 일수). 파일 다운로드 상품만 쓰고 생략·null이면 30일"),
3636
+ withdrawalRestricted: z.boolean().optional().describe("청약철회 제한 대상인가. true면 주문서가 제한 고지를 보여 주고 구매자 동의를 받는다. 배송 상품은 false로 저장한다"),
3637
+ pickupAddressId: z.string().nullable().optional().describe("방문 수령지 주소 id(`GET /v1/seller-addresses?type=PICKUP`). 방문 수령 상품만 쓴다. 생략하면 스토어 기본 방문 수령지를 저장한다")
3638
+ };
3639
+ }
2526
3640
  const productCreateRequestSchema = z.object({
2527
3641
  name: z.string().min(1).max(100),
2528
3642
  categoryId: z.string().min(1, "카테고리를 선택해주십시오"),
@@ -2544,9 +3658,10 @@ const productCreateRequestSchema = z.object({
2544
3658
  conditionalFreeAmount: z.number().int().optional().describe("CONDITIONAL_FREE일 때 무료 배송이 되는 주문 금액. 원 단위"),
2545
3659
  returnFee: z.number().int().default(0).describe("반품 배송비. 원 단위. `refundPolicy.returnDeliveryFee`와 같은 값이다"),
2546
3660
  exchangeFee: z.number().int().default(0).describe("교환 배송비. 원 단위. `refundPolicy.exchangeDeliveryFee`와 같은 값이다"),
2547
- refundPolicy: refundPolicyPatchSchema.nullish().describe("상품별 환불 정책 — 준 항목만 스토어 기본을 덮어쓴다. `null`이나 생략은 스토어 기본을 그대로 쓴다는 뜻이다. `returnDeliveryFee`·`exchangeDeliveryFee`를 주면 `returnFee`·`exchangeFee`도 같은 값으로 맞춘다"),
3661
+ refundPolicy: productRefundPolicyPatchSchema.nullish().describe("상품별 환불 정책 — 준 항목만 스토어 기본을 덮어쓴다. `null`이나 생략은 스토어 기본을 그대로 쓴다는 뜻이다. `returnDeliveryFee`·`exchangeDeliveryFee`를 주면 `returnFee`·`exchangeFee`도 같은 값으로 맞춘다"),
2548
3662
  shippingAddressId: z.string().nullable().optional().describe(ADDRESS_DESCRIPTIONS.shipping),
2549
- returnAddressId: z.string().nullable().optional().describe(ADDRESS_DESCRIPTIONS.return)
3663
+ returnAddressId: z.string().nullable().optional().describe(ADDRESS_DESCRIPTIONS.return),
3664
+ ...productFulfillmentRequestShape()
2550
3665
  }).extend(optionModelRequestSchema.shape);
2551
3666
  const productPatchRequestSchema = z.object({
2552
3667
  name: z.string().max(100).optional(),
@@ -2554,7 +3669,8 @@ const productPatchRequestSchema = z.object({
2554
3669
  status: productStatusSchema.optional(),
2555
3670
  deliveryFee: z.number().int().optional(),
2556
3671
  shippingAddressId: z.string().nullable().optional().describe(ADDRESS_DESCRIPTIONS.patchShipping),
2557
- returnAddressId: z.string().nullable().optional().describe(ADDRESS_DESCRIPTIONS.patchReturn)
3672
+ returnAddressId: z.string().nullable().optional().describe(ADDRESS_DESCRIPTIONS.patchReturn),
3673
+ ...productFulfillmentRequestShape()
2558
3674
  }).refine((value) => Object.values(value).some((v) => v !== void 0), { message: "최소 1개 필드가 필요합니다" });
2559
3675
  const productSortSchema = z.enum([
2560
3676
  "createdAt.desc",
@@ -2572,14 +3688,6 @@ const productListQuerySchema = z.object({
2572
3688
  sort: productSortSchema.optional().describe("정렬 (기본 createdAt.desc)"),
2573
3689
  ...pageQueryShape()
2574
3690
  });
2575
- const discountSchema = z.object({
2576
- productId: z.string(),
2577
- discountType: z.enum(["RATE", "AMOUNT"]),
2578
- discountValue: z.number().int(),
2579
- discountedPrice: z.number().int(),
2580
- startAt: z.string().nullable(),
2581
- endAt: z.string().nullable()
2582
- });
2583
3691
  const discountRequestSchema = z.object({
2584
3692
  discountType: z.enum(["RATE", "AMOUNT"]),
2585
3693
  discountValue: z.number().int().min(1),
@@ -2606,6 +3714,9 @@ const discountRequestSchema = z.object({
2606
3714
  *
2607
3715
  * 계산은 `quoteDelivery` 하나다. 장바구니·주문서·결제 시작·환불 정산이 모두 이 함수를 쓴다 —
2608
3716
  * 네 곳이 각자 계산하면 화면 금액과 결제 금액이 갈라진다.
3717
+ *
3718
+ * **비실물 줄은 배송 계산에서 빠진다**(이슈 #48, `requiresShipping`). 묶음을 만들지 않고, 스토어 전체 무료 기준의
3719
+ * 판정 금액(`shippableAmount`)에도 넣지 않는다 — 디지털 상품 금액이 실물 배송비를 면제시키지 않게 한다.
2609
3720
  */
2610
3721
  const descriptions = {
2611
3722
  baseDeliveryFee: "스토어 기본 배송비. 원 단위. 상품 등록·수정에서 `deliveryFee`를 생략하면 이 값을 쓴다. 기본 3000",
@@ -2978,11 +4089,12 @@ function quoteDelivery(input) {
2978
4089
  const area = remoteAreaOf(input.zipCode);
2979
4090
  const surcharge = remoteSurchargeOf(policy, area);
2980
4091
  const groups = /* @__PURE__ */ new Map();
2981
- let productAmount = 0;
4092
+ let shippableAmount = 0;
2982
4093
  for (const line of input.lines) {
2983
4094
  const product = input.productOf(line.productId);
2984
4095
  if (!product) continue;
2985
- productAmount += line.amount;
4096
+ if (!requiresShipping(line.fulfillmentType ?? product.fulfillmentType)) continue;
4097
+ shippableAmount += line.amount;
2986
4098
  const key = deliveryBundleKeyOf(product);
2987
4099
  const group = groups.get(key) ?? {
2988
4100
  product,
@@ -2993,7 +4105,7 @@ function quoteDelivery(input) {
2993
4105
  group.sum += line.amount;
2994
4106
  groups.set(key, group);
2995
4107
  }
2996
- const freeByThreshold = policy.freeThreshold !== null && productAmount >= policy.freeThreshold && groups.size > 0;
4108
+ const freeByThreshold = policy.freeThreshold !== null && shippableAmount >= policy.freeThreshold && groups.size > 0;
2997
4109
  const bundles = [];
2998
4110
  for (const [bundleKey, group] of groups) {
2999
4111
  const { product } = group;
@@ -3018,7 +4130,9 @@ function quoteDelivery(input) {
3018
4130
  remoteSurcharge,
3019
4131
  deliveryFee: baseFee + remoteSurcharge,
3020
4132
  remoteArea: area,
3021
- freeByThreshold
4133
+ freeByThreshold,
4134
+ shippableAmount,
4135
+ requiresShipping: bundles.length > 0
3022
4136
  };
3023
4137
  }
3024
4138
  //#endregion
@@ -3118,6 +4232,8 @@ const updateStoreRequestSchema = z.object({
3118
4232
  analyticsAllowedOrigins: z.array(analyticsOriginSchema).max(20).optional().describe("@deprecated `storefrontOrigins`를 쓴다(같은 목록). 방문 분석 수집을 받을 스토어프론트 오리진(최대 20개). 비우면 모든 오리진에서 받는다. 넣으면 목록에 없는 오리진의 수집 요청은 403 `ORIGIN_NOT_ALLOWED`이고, 페이지 URL이 목록 밖인 이벤트는 버린다"),
3119
4233
  storefrontOrigins: z.array(analyticsOriginSchema).max(20).optional().describe("스토어프론트 도메인(최대 20개) — 소셜 로그인 뒤 돌아갈 주소와 방문 분석 수집을 함께 제한한다. `analyticsAllowedOrigins`와 같은 목록이다(둘 다 주면 이 값이 이긴다)"),
3120
4234
  guestCheckoutEnabled: z.boolean().optional().describe("비회원 주문 허용. false(회원만)면 로그인하지 않은 구매자의 주문서 생성·결제 요청이 403 `GUEST_CHECKOUT_DISABLED`다. 장바구니 담기와 이미 받은 비회원 주문의 조회·취소는 막지 않는다"),
4235
+ fulfillmentMailEnabled: z.boolean().optional().describe("비실물 상품 제공 안내 메일(이슈 #48). 켜면(기본) 제공이 끝났을 때 구매자에게 안내 메일을 보낸다. 코드·파일 주소는 싣지 않는다"),
4236
+ withdrawalNotice: z.string().trim().min(1).max(1e3).nullable().optional().describe("청약철회 제한 고지 문구(이슈 #48). 제한 상품이 있는 주문서에 보인다. null이면 플랫폼 기본 문구를 쓴다"),
3121
4237
  autoDeliverDays: z.number().int().min(0).max(30).optional().describe(autoDeliverDaysDescription),
3122
4238
  autoConfirmDays: z.number().int().min(0).max(30).optional().describe(autoConfirmDaysDescription),
3123
4239
  refundPolicy: refundPolicyPatchSchema.optional().describe("스토어 기본 환불 정책 — 준 항목만 바꾼다. 상품별 정책(`products.refundPolicy`)이 없는 상품에 적용된다"),
@@ -3143,6 +4259,8 @@ const storeSchema = z.object({
3143
4259
  analyticsAllowedOrigins: z.array(z.string()).describe("방문 분석 수집을 받는 스토어프론트 오리진. 비어 있으면 모든 오리진에서 받는다"),
3144
4260
  storefrontOrigins: z.array(z.string()).describe("스토어프론트 도메인 — 소셜 로그인 리디렉트와 방문 분석 수집이 함께 쓴다(`analyticsAllowedOrigins`와 같은 값)"),
3145
4261
  guestCheckoutEnabled: z.boolean().describe("비회원 주문 허용. false면 회원만 주문할 수 있다"),
4262
+ fulfillmentMailEnabled: z.boolean().optional().default(true).describe("비실물 상품 제공 안내 메일(이슈 #48). 서버는 항상 싣는다"),
4263
+ withdrawalNotice: z.string().nullable().optional().default(null).describe("청약철회 제한 고지 문구(이슈 #48). null이면 플랫폼 기본 문구다. 서버는 항상 싣는다"),
3146
4264
  autoDeliverDays: z.number().int().describe(autoDeliverDaysDescription),
3147
4265
  autoConfirmDays: z.number().int().describe(autoConfirmDaysDescription),
3148
4266
  refundPolicy: refundPolicySchema.describe("스토어 기본 환불 정책. 스토어가 정하지 않은 항목은 플랫폼 기본값으로 채워 항상 전부 싣는다"),
@@ -3213,7 +4331,15 @@ const storeMemberRoleUpdatedSchema = z.object({
3213
4331
  userId: z.string(),
3214
4332
  role: z.enum(["ADMIN", "STAFF"])
3215
4333
  });
3216
- const addressTypeSchema = z.enum(["SHIPPING", "RETURN"]);
4334
+ /**
4335
+ * 주소 유형 — 출고지·반품지·방문 수령지(이슈 #48). 방문 수령지(`PICKUP`)는 목록에서 `type=PICKUP`으로 조회할 때만 나온다
4336
+ * (이 값을 모르는 옛 SDK의 목록 파싱이 깨지지 않게).
4337
+ */
4338
+ const addressTypeSchema = z.enum([
4339
+ "SHIPPING",
4340
+ "RETURN",
4341
+ "PICKUP"
4342
+ ]);
3217
4343
  /** `GET /v1/seller-addresses` 쿼리 — OpenAPI 문서용 형태. 기본 주소 우선, 그다음 등록순이다 */
3218
4344
  const sellerAddressListQuerySchema = z.object({ type: addressTypeSchema.optional().describe("주소 유형 필터") });
3219
4345
  const sellerAddressSchema = z.object({
@@ -3387,7 +4513,10 @@ function createStoreClient(options) {
3387
4513
  }) });
3388
4514
  const put = await fetchFn(ticket.uploadUrl, {
3389
4515
  method: "PUT",
3390
- headers: { "content-type": file.type || "application/octet-stream" },
4516
+ headers: {
4517
+ ...ticket.headers,
4518
+ "content-type": file.type || "application/octet-stream"
4519
+ },
3391
4520
  body: file
3392
4521
  });
3393
4522
  if (!put.ok) throw new ApiError(put.status, "UPLOAD_PUT_FAILED", `업로드에 실패했습니다: ${filename}`);
@@ -3422,8 +4551,37 @@ function createStoreClient(options) {
3422
4551
  updateVariantStock: (productId, variantId, body) => storeHttp.request("PATCH", path`/products/${productId}/variants/${variantId}/stock`, variantStockSchema, { body: variantStockRequestSchema.parse(body) }),
3423
4552
  getDiscount: (productId) => storeHttp.request("GET", path`/products/${productId}/discount`, discountSchema),
3424
4553
  setDiscount: (productId, body) => storeHttp.request("PUT", path`/products/${productId}/discount`, discountSchema, { body: discountRequestSchema.parse(body) }),
3425
- removeDiscount: (productId) => storeHttp.requestVoid("DELETE", path`/products/${productId}/discount`)
4554
+ removeDiscount: (productId) => storeHttp.requestVoid("DELETE", path`/products/${productId}/discount`),
4555
+ /**
4556
+ * 코드 일괄 업로드 (이슈 #48) — 코드 발급·자동 제공 상품의 코드 풀에 올린다. `codes`와 `csv` 중 하나만 보낸다.
4557
+ * 받은 코드 수만큼 재고가 는다. 에러: 409 `NOT_CODE_PRODUCT`·`CODE_POOL_NOT_USED`·`CODE_UPLOAD_CONFLICT`, 400 `TOO_MANY_CODES`
4558
+ */
4559
+ uploadCodes: (productId, body, idempotencyKey) => storeHttp.request("POST", path`/products/${productId}/codes`, fulfillmentCodeBatchSchema, {
4560
+ body: fulfillmentCodeUploadRequestSchema.parse(body),
4561
+ headers: idempotency(idempotencyKey)
4562
+ }),
4563
+ /** 코드 목록 — 가린 값(`hint`)만 싣는다. `available`은 필터와 무관한 판매 가능 코드 수다 */
4564
+ listCodes: (productId, params) => storeHttp.request("GET", path`/products/${productId}/codes`, fulfillmentCodePageSchema, { query: params }),
4565
+ /** 판매 가능 코드 폐기 — 재고가 1 준다. 배정된 코드는 409 `CODE_NOT_AVAILABLE` */
4566
+ voidCode: (productId, codeId) => storeHttp.request("POST", path`/products/${productId}/codes/${codeId}/void`, fulfillmentCodeSchema),
4567
+ /**
4568
+ * 판매 파일 연결 (이슈 #48) — `uploads.create({ purpose: "digital-asset" })`로 받은 `key`에 파일을 올린 뒤 연결한다.
4569
+ * 이후 주문부터 이 파일을 준다. 에러: 400 `INVALID_ASSET_KEY`, 409 `NOT_DOWNLOAD_PRODUCT`·`ASSET_ALREADY_ATTACHED`
4570
+ */
4571
+ attachDigitalAsset: (productId, body) => storeHttp.request("POST", path`/products/${productId}/digital-assets`, digitalAssetSchema, { body: digitalAssetAttachRequestSchema.parse(body) }),
4572
+ /** 판매 파일 목록 — 해제한 파일도 싣는다. 객체 키·주소는 없다 */
4573
+ listDigitalAssets: (productId) => storeHttp.request("GET", path`/products/${productId}/digital-assets`, z.array(digitalAssetSchema)),
4574
+ /** 판매 파일 해제 — 새 주문에 주지 않는다. 이미 산 구매자는 계속 받는다 */
4575
+ removeDigitalAsset: (productId, assetId) => storeHttp.request("DELETE", path`/products/${productId}/digital-assets/${assetId}`, digitalAssetSchema),
4576
+ /** 판매 파일 확인 주소 — 수명 300초의 서명 주소 */
4577
+ previewDigitalAsset: (productId, assetId) => storeHttp.request("POST", path`/products/${productId}/digital-assets/${assetId}/preview-url`, digitalAssetPreviewSchema)
3426
4578
  },
4579
+ fulfillmentCodes: {
4580
+ /**
4581
+ * 코드 평문 열람 — 콘솔에서 로그인한 OWNER·ADMIN만 된다(403 `INSUFFICIENT_ROLE`). 열람 기록과 감사 로그가 남는다.
4582
+ * 평문은 화면에 보인 뒤 저장하지 않는다
4583
+ */
4584
+ reveal: (codeId) => storeHttp.request("POST", path`/fulfillment-codes/${codeId}/reveal`, fulfillmentCodeRevealSchema) },
3427
4585
  categories: {
3428
4586
  list: (params) => storeHttp.request("GET", "/categories", z.array(categorySchema), { query: params }),
3429
4587
  tree: () => storeHttp.request("GET", "/categories", z.array(categoryTreeNodeSchema), { query: { tree: true } }),
@@ -3489,6 +4647,27 @@ function createStoreClient(options) {
3489
4647
  body: { orderItemIds },
3490
4648
  headers: idempotency(idempotencyKey)
3491
4649
  }),
4650
+ /**
4651
+ * 제공 안내 메일 재발송 (이슈 #48) — 제공된 주문상품의 안내 메일을 다시 보낸다. 주문상품당 하루 3회(429).
4652
+ * 코드·파일 주소는 메일에 없다
4653
+ */
4654
+ resendFulfillmentMail: (orderItemId) => storeHttp.request("POST", path`/order-items/${orderItemId}/fulfillment/mail`, fulfillmentMailResendSchema),
4655
+ /**
4656
+ * 제공 처리 (이슈 #48) — 수동 제공 비실물 주문상품(이용권·방문 수령)을 `PAID`·`CONFIRMED`에서 제공 완료(`DELIVERED`)로
4657
+ * 바꾼다. `note`는 구매자 주문 상세에 이용 안내로 실린다. 배송 상품은 409 `SHIPPING_REQUIRED`
4658
+ */
4659
+ fulfill: (orderItemId, body = {}, idempotencyKey) => storeHttp.request("POST", path`/order-items/${orderItemId}/fulfill`, orderItemSchema, {
4660
+ body: fulfillRequestSchema.parse(body),
4661
+ headers: idempotency(idempotencyKey)
4662
+ }),
4663
+ /** 제공 처리 일괄 — 항목별 부분 성공이다 */
4664
+ fulfillBulk: (orderItemIds, body = {}, idempotencyKey) => storeHttp.request("POST", "/order-items/fulfill", bulkResultSchema, {
4665
+ body: fulfillBulkRequestSchema.parse({
4666
+ orderItemIds,
4667
+ ...body
4668
+ }),
4669
+ headers: idempotency(idempotencyKey)
4670
+ }),
3492
4671
  getDelivery: (orderItemId) => storeHttp.request("GET", path`/order-items/${orderItemId}/delivery`, deliveryStatusSchema)
3493
4672
  },
3494
4673
  carriers: { list: () => storeHttp.request("GET", "/carriers", z.array(carrierSchema)) },
@@ -3540,8 +4719,33 @@ function createStoreClient(options) {
3540
4719
  orders: (customerId, params) => storeHttp.request("GET", path`/customers/${customerId}/orders`, pageSchema(customerOrderSchema), { query: params }),
3541
4720
  /** 동의 이력 — 최근 순. 가입·재동의·마케팅 변경이 한 줄씩 남는다 */
3542
4721
  consents: (customerId, params) => storeHttp.request("GET", path`/customers/${customerId}/consents`, pageSchema(customerConsentSchema), { query: params }),
4722
+ /** 보유 쿠폰 (이슈 #47) — 최근 지급 순, 최대 500장 */
4723
+ coupons: (customerId) => storeHttp.request("GET", path`/customers/${customerId}/coupons`, z.array(customerCouponSchema)),
3543
4724
  withdraw: (customerId, body) => storeHttp.requestVoid("POST", path`/customers/${customerId}/withdraw`, { body: withdrawCustomerRequestSchema.parse(body) })
3544
4725
  },
4726
+ /** 쿠폰 (이슈 #47) — 조회 `promotion:r`, 쓰기 `promotion:rw` */
4727
+ coupons: {
4728
+ list: (params) => storeHttp.request("GET", "/coupons", pageSchema(couponSchema), { query: params }),
4729
+ create: (body, idempotencyKey) => storeHttp.request("POST", "/coupons", couponSchema, {
4730
+ body: createCouponRequestSchema.parse(body),
4731
+ headers: idempotency(idempotencyKey)
4732
+ }),
4733
+ get: (couponId) => storeHttp.request("GET", path`/coupons/${couponId}`, couponSchema),
4734
+ update: (couponId, body) => storeHttp.request("PATCH", path`/coupons/${couponId}`, couponSchema, { body: updateCouponRequestSchema.parse(body) }),
4735
+ pause: (couponId) => storeHttp.request("POST", path`/coupons/${couponId}/pause`, couponSchema),
4736
+ resume: (couponId) => storeHttp.request("POST", path`/coupons/${couponId}/resume`, couponSchema),
4737
+ end: (couponId) => storeHttp.request("POST", path`/coupons/${couponId}/end`, couponSchema),
4738
+ /** 셀러 지급 — 한 번에 1,000명까지. 한도에 걸린 구매자는 `failed`에 담긴다 */
4739
+ issue: (couponId, body, idempotencyKey) => storeHttp.request("POST", path`/coupons/${couponId}/issues`, issueCouponResultSchema, {
4740
+ body: issueCouponRequestSchema.parse(body),
4741
+ headers: idempotency(idempotencyKey)
4742
+ }),
4743
+ issues: (couponId, params) => storeHttp.request("GET", path`/coupons/${couponId}/issues`, pageSchema(couponIssueSchema), { query: params }),
4744
+ redemptions: (couponId, params) => storeHttp.request("GET", path`/coupons/${couponId}/redemptions`, pageSchema(couponRedemptionSchema), { query: params }),
4745
+ stats: (couponId) => storeHttp.request("GET", path`/coupons/${couponId}/stats`, couponStatsSchema),
4746
+ /** 발급 쿠폰 회수 — 사용 가능한 발급 쿠폰만 */
4747
+ revokeIssue: (issueId) => storeHttp.request("POST", path`/coupon-issues/${issueId}/revoke`, couponIssueSchema)
4748
+ },
3545
4749
  customerInquiries: {
3546
4750
  list: (params) => storeHttp.request("GET", "/customer-inquiries", pageSchema(sellerCustomerInquirySchema), { query: params }),
3547
4751
  get: (inquiryId) => storeHttp.request("GET", path`/customer-inquiries/${inquiryId}`, sellerCustomerInquirySchema),
@@ -3889,7 +5093,8 @@ const STORE_SCOPE_RESOURCES = {
3889
5093
  store: ["r", "rw"],
3890
5094
  member: ["r", "rw"],
3891
5095
  customer: ["r", "rw"],
3892
- audit: ["r"]
5096
+ audit: ["r"],
5097
+ promotion: ["r", "rw"]
3893
5098
  };
3894
5099
  const storeScopeResourceSchema = z.enum(Object.keys(STORE_SCOPE_RESOURCES)).describe("스코프 리소스");
3895
5100
  /** 카탈로그상 존재하는 스코프 문자열인가 (예: `settlement:rw`는 false) */
@@ -3917,7 +5122,8 @@ const STORE_ROLE_SCOPES = {
3917
5122
  "store:rw",
3918
5123
  "member:rw",
3919
5124
  "customer:rw",
3920
- "audit:r"
5125
+ "audit:r",
5126
+ "promotion:rw"
3921
5127
  ],
3922
5128
  ADMIN: [
3923
5129
  "product:rw",
@@ -3931,7 +5137,8 @@ const STORE_ROLE_SCOPES = {
3931
5137
  "store:rw",
3932
5138
  "member:rw",
3933
5139
  "customer:rw",
3934
- "audit:r"
5140
+ "audit:r",
5141
+ "promotion:rw"
3935
5142
  ],
3936
5143
  STAFF: [
3937
5144
  "product:rw",
@@ -3940,7 +5147,8 @@ const STORE_ROLE_SCOPES = {
3940
5147
  "claim:rw",
3941
5148
  "inquiry:rw",
3942
5149
  "review:rw",
3943
- "customer:r"
5150
+ "customer:r",
5151
+ "promotion:r"
3944
5152
  ]
3945
5153
  };
3946
5154
  //#endregion
@@ -3986,12 +5194,30 @@ const mcpToolEventSchema = z.object({
3986
5194
  const envelopeFields = {
3987
5195
  id: z.string().describe("이벤트 ID (`evt_…`, 테스트 전송은 `evt_test_…`). 중복 제거 키"),
3988
5196
  createdAt: z.string().describe("이벤트 발생 일시 (ISO 8601)"),
3989
- storeId: z.string().describe("이벤트가 발생한 스토어 ID")
5197
+ storeId: z.string().describe("이벤트가 발생한 스토어 ID"),
5198
+ sequence: z.number().int().positive().optional().describe("리소스별 순번. 같은 주문·클레임·문의·리뷰·상품의 이벤트마다 1씩 커진다. 도착 순서는 보장하지 않으므로 같은 리소스의 이벤트는 이 값으로 정렬한다. 이 필드가 생기기 전의 이벤트와 테스트 전송에는 없다")
3990
5199
  };
3991
5200
  const orderWebhookEventDataSchema = z.object({
3992
5201
  orderId: z.string().describe("주문 ID"),
3993
5202
  status: orderItemStatusSchema.describe("이벤트 발생 시점의 주문 대표 상태"),
3994
- testPayment: z.boolean().optional().default(false).describe("테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았다. 이 필드가 없는 이전 이벤트는 false로 읽는다")
5203
+ testPayment: z.boolean().optional().default(false).describe("테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았다. 이 필드가 없는 이전 이벤트는 false로 읽는다"),
5204
+ couponDiscountAmount: z.number().int().optional().default(0).describe("주문이 받은 상품·주문 쿠폰 할인 합계 (원). 즉시할인(`discountAmount`)과 별개다. 쿠폰이 없거나 이 필드가 없는 이전 이벤트는 0으로 읽는다"),
5205
+ orderItemId: z.string().optional().describe("이벤트를 만든 주문상품 ID. 비실물 상품 제공으로 보내는 `ORDER.DELIVERED`에만 실린다. 택배 배송완료와 그 밖의 주문 이벤트에는 없다"),
5206
+ fulfillmentType: z.string().optional().describe("이벤트를 만든 주문상품의 상품 유형. 비실물 상품의 제공 완료는 `ORDER.DELIVERED`로도 오고 이 값이 `SHIPPING`이 아니다. 현재 값은 `SHIPPING`·`DOWNLOAD`·`CODE`·`SERVICE`·`PICKUP`이고 값이 추가될 수 있어 문자열로 받는다. 비실물 제공이 아닌 이벤트에는 없고, 없으면 `SHIPPING`으로 읽는다")
5207
+ });
5208
+ /**
5209
+ * `ORDER.FULFILLED` — 비실물 주문상품(파일·코드·이용권·방문 수령)을 제공했다(이슈 #48). 자동 제공은 결제 확정과 함께,
5210
+ * 수동 제공은 셀러의 제공 처리(`POST /v1/order-items/{orderItemId}/fulfill`)로 온다. 기존 구독자를 위해 같은 순간
5211
+ * `ORDER.DELIVERED`도 함께 보낸다. **코드·파일 주소는 싣지 않는다** — 관리 API로 조회한다.
5212
+ */
5213
+ const orderFulfilledWebhookEventDataSchema = z.object({
5214
+ orderId: z.string().describe("주문 ID"),
5215
+ orderItemId: z.string().describe("제공한 주문상품 ID"),
5216
+ status: orderItemStatusSchema.describe("이벤트 발생 시점의 주문 대표 상태"),
5217
+ fulfillmentType: z.string().describe("상품 유형. 현재 값은 `DOWNLOAD`·`CODE`·`SERVICE`·`PICKUP`이고 값이 추가될 수 있어 문자열로 받는다"),
5218
+ fulfillmentMode: z.string().nullable().describe("제공 방식. `AUTO`(결제 확정과 함께)·`MANUAL`(셀러 제공 처리)"),
5219
+ fulfilledAt: z.string().describe("제공 시각 (ISO 8601). 주문상품의 `deliveredAt`과 같다"),
5220
+ testPayment: z.boolean().describe("테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았다")
3995
5221
  });
3996
5222
  /**
3997
5223
  * `ORDER.CANCELED` — 셀러 직권취소(`POST /v1/order-items/{orderItemId}/cancel`)로 주문상품이 취소됐다.
@@ -4006,9 +5232,11 @@ const orderCanceledWebhookEventDataSchema = z.object({
4006
5232
  reason: z.string().describe("취소 사유 코드 — 판매자 취소 요청의 `reason`(`OUT_OF_STOCK`·`PRICE_ERROR`·`DELIVERY_IMPOSSIBLE`·`ETC`). 사유가 늘어날 수 있어 문자열이다. 모르는 값은 `ETC`처럼 다룬다"),
4007
5233
  quantity: z.number().int().describe("이번에 취소한 수량"),
4008
5234
  canceledQuantity: z.number().int().describe("취소 후 누적 취소 수량"),
4009
- refundAmount: z.number().int().describe("환불액 (원). 수량 비율 상품 금액 + 배송비 환불 − 조건부 무료배송 미달 차감이다. 결제사 환불 결과는 결제 내역(`GET /v1/payments/{paymentId}`)으로 확인한다"),
5235
+ refundAmount: z.number().int().describe("환불액 (원). 수량 비율 상품 실결제 금액(쿠폰 배분을 뺀 금액) + 배송비 환불 − 조건부 무료배송 미달 차감 + 되돌린 쿠폰 차감(`couponChargeRefundAmount`)이다. 결제사 환불 결과는 결제 내역(`GET /v1/payments/{paymentId}`)으로 확인한다"),
4010
5236
  deliveryFeeRefundAmount: z.number().int().describe("환불액에 포함된 배송비 (원)"),
4011
5237
  deliveryFeeChargeAmount: z.number().int().describe("환불액에서 뺀 배송비 (원) — 조건부 무료배송 기준 미달 차감"),
5238
+ couponChargeAmount: z.number().int().optional().default(0).describe("환불액에서 뺀 쿠폰 조건 미달 차감 (원). 판매자 취소는 차감하지 않으므로 늘 0이다. 이 필드가 없는 이전 이벤트는 0으로 읽는다"),
5239
+ couponChargeRefundAmount: z.number().int().optional().default(0).describe("환불액에 더한 금액 (원) — 앞선 환불에서 뺀 쿠폰 조건 미달 차감을 되돌린 몫이다. 전량 취소면 누적 차감이 모두 되돌아간다. 이 필드가 없는 이전 이벤트는 0으로 읽는다"),
4012
5240
  testPayment: z.boolean().describe("테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았다")
4013
5241
  });
4014
5242
  const claimWebhookEventDataSchema = z.object({
@@ -4022,6 +5250,8 @@ const claimWebhookEventDataSchema = z.object({
4022
5250
  deliveryFeeRefundAmount: z.number().int().optional().default(0).describe("환불액에 포함된 배송비 (원). 이 필드가 없는 이전 이벤트는 0으로 읽는다"),
4023
5251
  deliveryFeeChargeAmount: z.number().int().optional().default(0).describe("환불액에서 뺀 배송비 (원) — 반품·교환 배송비와 조건부 무료배송 미달 차감의 합. 셀러가 승인에 넣은 추가 차감은 `deductAmount`로 따로 온다. 이 필드가 없는 이전 이벤트는 0으로 읽는다"),
4024
5252
  deductAmount: z.number().int().optional().default(0).describe("환불액에서 뺀 추가 차감 (원) — 셀러가 클레임 승인에 넣은 금액이다(상품 훼손 등). 배송비 차감과 별개이므로 `deliveryFeeChargeAmount`와 더해서 뺀다. 접수 이벤트와 이 필드가 없는 이전 이벤트는 0이다"),
5253
+ couponChargeAmount: z.number().int().optional().default(0).describe("환불액에서 뺀 쿠폰 조건 미달 차감 (원) — 일부 취소·반품으로 남은 주문상품이 쿠폰 조건(최소 주문 금액 등)을 잃어 남은 주문상품이 받던 할인을 뺀 금액이다. 배송비 차감·추가 차감과 별개다. 접수 이벤트는 예상값이다. 이 필드가 없는 이전 이벤트는 0으로 읽는다"),
5254
+ couponChargeRefundAmount: z.number().int().optional().default(0).describe("환불액에 더한 금액 (원) — 앞선 환불에서 뺀 쿠폰 조건 미달 차감을 되돌린 몫이다. 접수 이벤트는 예상값이다. 이 필드가 없는 이전 이벤트는 0으로 읽는다"),
4025
5255
  testPayment: z.boolean().optional().default(false).describe("테스트 결제(샌드박스 결제) 주문의 클레임인가. 이 필드가 없는 이전 이벤트는 false로 읽는다")
4026
5256
  });
4027
5257
  const inquiryWebhookEventDataSchema = z.object({
@@ -4049,6 +5279,11 @@ const orderWebhookEventSchema = z.object({
4049
5279
  ]),
4050
5280
  data: orderWebhookEventDataSchema
4051
5281
  });
5282
+ const orderFulfilledWebhookEventSchema = z.object({
5283
+ ...envelopeFields,
5284
+ type: z.literal("ORDER.FULFILLED"),
5285
+ data: orderFulfilledWebhookEventDataSchema
5286
+ });
4052
5287
  const orderCanceledWebhookEventSchema = z.object({
4053
5288
  ...envelopeFields,
4054
5289
  type: z.literal("ORDER.CANCELED"),
@@ -4082,6 +5317,7 @@ const productOutOfStockWebhookEventSchema = z.object({
4082
5317
  /** 웹훅 요청 본문. `type`으로 판별하며 `data` 형태가 타입별로 다르다 */
4083
5318
  const webhookEventSchema = z.discriminatedUnion("type", [
4084
5319
  orderWebhookEventSchema,
5320
+ orderFulfilledWebhookEventSchema,
4085
5321
  orderCanceledWebhookEventSchema,
4086
5322
  claimWebhookEventSchema,
4087
5323
  inquiryWebhookEventSchema,
@@ -4238,4 +5474,4 @@ function createStoreSession(options) {
4238
5474
  };
4239
5475
  }
4240
5476
  //#endregion
4241
- export { APPROVAL_ALREADY_DECIDED_CODE, APPROVAL_REQUIRED_CODE, ApiError, ApprovalPendingError, CASH_RECEIPT_STATUS_VALUES, MAX_ANALYTICS_ALLOWED_ORIGINS, MAX_AUTO_TRANSITION_DAYS, MAX_OPTION_GROUPS, MAX_PRODUCT_IMAGES, MAX_VALUES_PER_GROUP, MAX_VARIANTS, MCP_TELEMETRY_MAX_RESULTS, PAYMENT_ROUTING_EXCLUSION_CODES, PORTONE_CHANNEL_NAME_MAX_LENGTH, PORTONE_MAX_CHANNELS, PORTONE_MIN_CHANNELS, REFUND_POLICY_DEFAULTS, REMOTE_AREA_ZIP_RANGES, RESERVED_STORE_CODES, SHIPPING_POLICY_DEFAULTS, STORE_CODE_PATTERN, STORE_ROLE_SCOPES, STORE_SCOPE_RESOURCES, STORE_SESSION_KEYS, TOSS_WIDGET_CLIENT_KEY_PATTERN, TOSS_WIDGET_KEY_MESSAGE, TOSS_WIDGET_SECRET_KEY_PATTERN, UPLOAD_PURPOSE_LIMITS, VARIANT_STOCK_EXCLUSIVE_MESSAGE, WEBHOOK_DEFAULT_TOLERANCE_SEC, WEBHOOK_DELIVERY_ID_HEADER, WEBHOOK_EVENT_ID_HEADER, WEBHOOK_EVENT_TYPE_HEADER, WEBHOOK_FAILURE_REASONS, WEBHOOK_HEADERS, WEBHOOK_PAYLOAD_EXCERPT_MAX_BYTES, WEBHOOK_RESOURCE_TYPES, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_USER_AGENT, addressChangeRequestListSchema, addressTypeSchema, agentPolicyDecisionSchema, analyticsChannelSchema, analyticsCheckoutOutcomeSchema, analyticsCheckoutSchema, analyticsDeviceRowSchema, analyticsDevicesSchema, analyticsFunnelStepSchema, analyticsOriginSchema, analyticsOverviewSchema, analyticsPagesQuerySchema, analyticsPagesSchema, analyticsProductRowSchema, analyticsProductSortSchema, analyticsProductsQuerySchema, analyticsProductsSchema, analyticsRangeQuerySchema, analyticsRealtimeSchema, analyticsSearchQuerySchema, analyticsSearchSchema, analyticsSourceGroupBySchema, analyticsSourceRowSchema, analyticsSourcesQuerySchema, analyticsSourcesSchema, analyticsTotalsSchema, apiClientListQuerySchema, apiClientSchema, apiClientSecretRotatedSchema, apiClientStatusSchema, apiClientTypeSchema, apiLogErrorSchema, apiLogListQuerySchema, apiLogPageSchema, apiLogPrincipalSchema, apiLogPrincipalTypeSchema, apiLogSchema, apiLogStatusFilterSchema, apiLogSummarySchema, apiTokenCreateRequestSchema, apiTokenCreatedSchema, apiTokenSchema, approvalDecisionRequestSchema, approvalListQuerySchema, approvalPageSchema, approvalRequestSchema, approvalStatusSchema, approveClaimResultSchema, auditActorSchema, auditActorTypeSchema, auditLogListQuerySchema, auditLogOutcomeSchema, auditLogPageSchema, auditLogSchema, auditLogSourceSchema, buildAuthorizeUrl, buildQuery, bulkResultSchema, cancelBySellerRequestSchema, cancelBySellerResultSchema, carrierSchema, cashReceiptSchema, categoryAttributeSchema, categoryCreateRequestSchema, categoryDeleteQuerySchema, categoryDeleteStrategySchema, categoryListQuerySchema, categoryPatchRequestSchema, categoryProductSortSchema, categorySchema, categorySearchResultSchema, categoryTreeNodeSchema, categoryWithPathSchema, changeTypeSchema, claimDetailSchema, claimHoldResultSchema, claimListQuerySchema, claimReasonSchema, claimReturnAddressSchema, claimSchema, claimStatusSchema, claimTypeSchema, claimWebhookEventDataSchema, claimWebhookEventSchema, clientCredentialsTokenResponseSchema, consentActionSchema, consentKindSchema, consentSourceSchema, createPkcePair, createStoreClient, createStoreSession, customerAddressSchema, customerConsentListQuerySchema, customerConsentSchema, customerConsentStateSchema, customerConsentsSchema, customerDetailSchema, customerInquiryCategorySchema, customerInquiryListQuerySchema, customerListQuerySchema, customerOrderSchema, customerSortSchema, customerStatsSchema, customerSummarySchema, customerWithdrawalBlockedDetailsSchema, deliveryBundleKeyOf, deliveryFeeWaiverSchema, deliveryInfoSchema, deliveryMethodSchema, deliveryStatusSchema, deliveryTypeSchema, discountRequestSchema, discountSchema, dispatchRequestSchema, easyPayProviderResponseSchema, easyPayProviderSchema, eventTypeSchema, exchangeAuthorizationCode, exchangeStoreTokenViaOAuth, holdOrderItemRequestSchema, inquiryListQuerySchema, inquirySchema, inquiryWebhookEventDataSchema, inquiryWebhookEventSchema, invitationDeliveryStatusSchema, invitationListQuerySchema, invitationSchema, inviteMemberRequestSchema, isReservedStoreCode, isStoreScope, isWebOrigin, liveGateConditionCodeSchema, liveGateConditionSchema, liveGateErrorDetailsSchema, liveGateSchema, loginProviderSchema, loginProviderStatusSchema, loginProviderTestSchema, loginSettingsSchema, maskedSecretSchema, mcpRuleResultSchema, mcpToolEventSchema, myAccountSchema, myStoreSchema, oauthClientRegistrationRequestSchema, oauthClientRegistrationResponseSchema, oauthTokenResponseSchema, optionGroupRequestSchema, optionGroupSchema, optionModelRequestSchema, optionModelSchema, optionValueRequestSchema, optionValueSchema, orderCanceledWebhookEventDataSchema, orderCanceledWebhookEventSchema, orderItemListQuerySchema, orderItemMemoResultSchema, orderItemPageSchema, orderItemSchema, orderItemStatusSchema, orderListQuerySchema, orderSchema, orderSummaryPageSchema, orderSummarySchema, orderWebhookEventDataSchema, orderWebhookEventSchema, pageInfoSchema, pageQueryShape, pageSchema, paymentAttemptRecordSchema, paymentAttemptStatusSchema, paymentCredentialSetSchema, paymentListQuerySchema, paymentProviderSchema, paymentProviderVerificationSchema, paymentRecordDetailSchema, paymentRecordPageSchema, paymentRecordSchema, paymentRecordStatusSchema, paymentRefundRecordSchema, paymentSettingsSchema, pgEnvironmentSchema, pgPaymentMethodSchema, pgProviderSchema, pgVerificationResultSchema, policyDocumentUrlSchema, policyVersionSchema, portOneChannelSchema, portOneCredentialsInputSchema, productCreateRequestSchema, productImageRejection, productImageUrlSchema, productListQuerySchema, productOutOfStockWebhookEventDataSchema, productOutOfStockWebhookEventSchema, productPatchRequestSchema, productRefundPolicyOverride, productSchema, productSortSchema, productStatusSchema, productSummarySchema, productVariantRequestSchema, productVariantSchema, proratedRefundBase, purchaseDecisionExtendResultSchema, purchaseDecisionRequestResultSchema, quoteDelivery, refreshUserToken, refundPolicyPatchSchema, refundPolicySchema, rejectClaimRequestSchema, remoteAreaLabelOf, remoteAreaOf, remoteAreaSchema, remoteSurchargeOf, resolveRefundPolicy, resolveShippingPolicy, reviewListQuerySchema, reviewReportResultSchema, reviewSchema, reviewSortSchema, reviewWebhookEventDataSchema, reviewWebhookEventSchema, salesReportGroupBySchema, salesReportQuerySchema, salesReportRowSchema, salesReportSchema, salesTotalsSchema, saveLoginProviderRequestSchema, scopeAccessSchema, sellerAddressListQuerySchema, sellerAddressRequestSchema, sellerAddressSchema, sellerCustomerInquirySchema, settlementDetailQuerySchema, settlementDetailSchema, settlementListQuerySchema, settlementStatusSchema, settlementSummarySchema, settlementWithDetailsSchema, shippingAddressSchema, shippingPolicyPatchSchema, shippingPolicySchema, storeCodeSchema, storeMemberRoleUpdatedSchema, storeMemberSchema, storeRoleSchema, storeSchema, storeScopeResourceSchema, storeTokenResponseSchema, tossPaymentsCredentialsInputSchema, trackingStageResponseSchema, trackingUpdateResultSchema, updateLoginProviderRequestSchema, updateLoginSettingsRequestSchema, updateMemberRoleRequestSchema, updatePaymentProviderRequestSchema, updatePaymentSettingsRequestSchema, updateStoreRequestSchema, uploadExtensionOf, uploadPurposeSchema, uploadRejection, uploadRequestSchema, uploadTicketSchema, upsertPortOneProviderRequestSchema, upsertTossPaymentsProviderRequestSchema, userTokenResponseSchema, variantStockRequestSchema, variantStockSchema, verifyPaymentProviderRequestSchema, webhookCreatedSchema, webhookDeliveryDetailSchema, webhookDeliveryErrorCodeSchema, webhookDeliveryListQuerySchema, webhookDeliveryResourceSchema, webhookDeliverySchema, webhookDeliveryStatusSchema, webhookEventSchema, webhookFailureReasonSchema, webhookRedeliverFailedResultSchema, webhookRequestHeaderSchema, webhookRequestSchema, webhookResourceTypeSchema, webhookSchema, webhookSecretRotatedSchema, webhookStatusSchema, webhookTestRequestSchema, webhookTestResultSchema, withdrawCustomerRequestSchema, writeDryRunResultSchema, writePreviewSchema };
5477
+ export { APPROVAL_ALREADY_DECIDED_CODE, APPROVAL_REQUIRED_CODE, ApiError, ApprovalPendingError, CASH_RECEIPT_STATUS_VALUES, COUPON_CODE_PATTERN, COUPON_CONDITIONS_DEFAULTS, COUPON_TARGET_ALL, COUPON_TARGET_LIST_MAX, FULFILLMENT_RULES, MAX_ANALYTICS_ALLOWED_ORIGINS, MAX_AUTO_TRANSITION_DAYS, MAX_CODES_PER_UPLOAD, MAX_CODE_LENGTH, MAX_OPTION_GROUPS, MAX_PRODUCT_IMAGES, MAX_VALUES_PER_GROUP, MAX_VARIANTS, MCP_TELEMETRY_MAX_RESULTS, MIN_PAYMENT_AMOUNT, NO_COUPON_SETTLEMENT, NO_SHIPPING_BUNDLE_KEY, PAYMENT_ROUTING_EXCLUSION_CODES, PORTONE_CHANNEL_NAME_MAX_LENGTH, PORTONE_MAX_CHANNELS, PORTONE_MIN_CHANNELS, PRIVATE_UPLOAD_HEADERS, REFUND_POLICY_DEFAULTS, REMOTE_AREA_ZIP_RANGES, RESERVED_STORE_CODES, SHIPPING_POLICY_DEFAULTS, STORE_CODE_PATTERN, STORE_ROLE_SCOPES, STORE_SCOPE_RESOURCES, STORE_SESSION_KEYS, TOSS_WIDGET_CLIENT_KEY_PATTERN, TOSS_WIDGET_KEY_MESSAGE, TOSS_WIDGET_SECRET_KEY_PATTERN, UPLOAD_PURPOSE_LIMITS, VARIANT_STOCK_EXCLUSIVE_MESSAGE, WEBHOOK_DEFAULT_TOLERANCE_SEC, WEBHOOK_DELIVERY_ID_HEADER, WEBHOOK_EVENT_ID_HEADER, WEBHOOK_EVENT_TYPE_HEADER, WEBHOOK_FAILURE_REASONS, WEBHOOK_HEADERS, WEBHOOK_PAYLOAD_EXCERPT_MAX_BYTES, WEBHOOK_RESOURCE_TYPES, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_USER_AGENT, addressChangeRequestListSchema, addressTypeSchema, agentPolicyDecisionSchema, allocateByWeight, analyticsChannelSchema, analyticsCheckoutOutcomeSchema, analyticsCheckoutSchema, analyticsDeviceRowSchema, analyticsDevicesSchema, analyticsFunnelStepSchema, analyticsOriginSchema, analyticsOverviewSchema, analyticsPagesQuerySchema, analyticsPagesSchema, analyticsProductRowSchema, analyticsProductSortSchema, analyticsProductsQuerySchema, analyticsProductsSchema, analyticsRangeQuerySchema, analyticsRealtimeSchema, analyticsSearchQuerySchema, analyticsSearchSchema, analyticsSourceGroupBySchema, analyticsSourceRowSchema, analyticsSourcesQuerySchema, analyticsSourcesSchema, analyticsTotalsSchema, apiClientListQuerySchema, apiClientSchema, apiClientSecretRotatedSchema, apiClientStatusSchema, apiClientTypeSchema, apiLogErrorSchema, apiLogListQuerySchema, apiLogPageSchema, apiLogPrincipalSchema, apiLogPrincipalTypeSchema, apiLogSchema, apiLogStatusFilterSchema, apiLogSummarySchema, apiTokenCreateRequestSchema, apiTokenCreatedSchema, apiTokenSchema, approvalDecisionRequestSchema, approvalListQuerySchema, approvalPageSchema, approvalRequestSchema, approvalStatusSchema, approveClaimResultSchema, auditActorSchema, auditActorTypeSchema, auditLogListQuerySchema, auditLogOutcomeSchema, auditLogPageSchema, auditLogSchema, auditLogSourceSchema, buildAuthorizeUrl, buildQuery, bulkResultSchema, cancelBySellerRequestSchema, cancelBySellerResultSchema, carrierSchema, cashReceiptSchema, categoryAttributeSchema, categoryCreateRequestSchema, categoryDeleteQuerySchema, categoryDeleteStrategySchema, categoryListQuerySchema, categoryPatchRequestSchema, categoryProductSortSchema, categorySchema, categorySearchResultSchema, categoryTreeNodeSchema, categoryWithPathSchema, changeTypeSchema, claimDetailSchema, claimHoldResultSchema, claimListQuerySchema, claimReasonSchema, claimReturnAddressSchema, claimSchema, claimStatusSchema, claimTypeSchema, claimWebhookEventDataSchema, claimWebhookEventSchema, clientCredentialsTokenResponseSchema, consentActionSchema, consentKindSchema, consentSourceSchema, couponBenefitSchema, couponConditionsInputSchema, couponConditionsSchema, couponDiscountOf, couponIssueListQuerySchema, couponIssueMethodSchema, couponIssueSchema, couponIssueStatusSchema, couponKindSchema, couponListQuerySchema, couponRedemptionListQuerySchema, couponRedemptionSchema, couponRejectReasonSchema, couponRuleSchema, couponSchema, couponStatsSchema, couponStatusSchema, couponTargetInputSchema, couponTargetSchema, createCouponRequestSchema, createPkcePair, createStoreClient, createStoreSession, customerAddressSchema, customerConsentListQuerySchema, customerConsentSchema, customerConsentStateSchema, customerConsentsSchema, customerCouponSchema, customerDetailSchema, customerInquiryCategorySchema, customerInquiryListQuerySchema, customerListQuerySchema, customerOrderSchema, customerSortSchema, customerStatsSchema, customerSummarySchema, customerWithdrawalBlockedDetailsSchema, deliveryBundleKeyOf, deliveryFeeWaiverSchema, deliveryInfoSchema, deliveryMethodSchema, deliveryStatusSchema, deliveryTypeSchema, digitalAssetAttachRequestSchema, digitalAssetPreviewSchema, digitalAssetSchema, discountRequestSchema, discountSchema, dispatchRequestSchema, easyPayProviderResponseSchema, easyPayProviderSchema, eventTypeSchema, exchangeAuthorizationCode, exchangeStoreTokenViaOAuth, fulfillBulkRequestSchema, fulfillRequestSchema, fulfillmentCodeBatchSchema, fulfillmentCodeListQuerySchema, fulfillmentCodePageSchema, fulfillmentCodeRejectionSchema, fulfillmentCodeRevealSchema, fulfillmentCodeSchema, fulfillmentCodeStatusSchema, fulfillmentCodeUploadRequestSchema, fulfillmentMailResendSchema, fulfillmentModeSchema, fulfillmentRuleOf, fulfillmentTypeOf, fulfillmentTypeResponseSchema, fulfillmentTypeSchema, holdOrderItemRequestSchema, inquiryListQuerySchema, inquirySchema, inquiryWebhookEventDataSchema, inquiryWebhookEventSchema, invitationDeliveryStatusSchema, invitationListQuerySchema, invitationSchema, inviteMemberRequestSchema, isCouponTargetLine, isReservedStoreCode, isStoreScope, isWebOrigin, issueCouponRequestSchema, issueCouponResultSchema, liveGateConditionCodeSchema, liveGateConditionSchema, liveGateErrorDetailsSchema, liveGateSchema, loginProviderSchema, loginProviderStatusSchema, loginProviderTestSchema, loginSettingsSchema, maskedSecretSchema, mcpRuleResultSchema, mcpToolEventSchema, myAccountSchema, myStoreSchema, normalizeCouponCode, oauthClientRegistrationRequestSchema, oauthClientRegistrationResponseSchema, oauthTokenResponseSchema, optionGroupRequestSchema, optionGroupSchema, optionModelRequestSchema, optionModelSchema, optionValueRequestSchema, optionValueSchema, orderCanceledWebhookEventDataSchema, orderCanceledWebhookEventSchema, orderDiscountSchema, orderFulfilledWebhookEventDataSchema, orderFulfilledWebhookEventSchema, orderItemCodeSchema, orderItemDownloadSchema, orderItemFulfillmentSchema, orderItemListQuerySchema, orderItemMemoResultSchema, orderItemPageSchema, orderItemSchema, orderItemStatusSchema, orderListQuerySchema, orderSchema, orderSummaryPageSchema, orderSummarySchema, orderWebhookEventDataSchema, orderWebhookEventSchema, pageInfoSchema, pageQueryShape, pageSchema, paymentAttemptRecordSchema, paymentAttemptStatusSchema, paymentCredentialSetSchema, paymentListQuerySchema, paymentProviderSchema, paymentProviderVerificationSchema, paymentRecordDetailSchema, paymentRecordPageSchema, paymentRecordSchema, paymentRecordStatusSchema, paymentRefundRecordSchema, paymentSettingsSchema, pgEnvironmentSchema, pgPaymentMethodSchema, pgProviderSchema, pgVerificationResultSchema, policyDocumentUrlSchema, policyVersionSchema, portOneChannelSchema, portOneCredentialsInputSchema, productCreateRequestSchema, productImageRejection, productImageUrlSchema, productListQuerySchema, productOutOfStockWebhookEventDataSchema, productOutOfStockWebhookEventSchema, productPatchRequestSchema, productRefundPolicyOverride, productRefundPolicyPatchSchema, productSchema, productSortSchema, productStatusSchema, productSummarySchema, productVariantRequestSchema, productVariantSchema, proratedPaidRefundBase, proratedRefundBase, purchaseDecisionExtendResultSchema, purchaseDecisionRequestResultSchema, quoteDelivery, quotePricing, refreshUserToken, refundPolicyPatchSchema, refundPolicySchema, rejectClaimRequestSchema, remoteAreaLabelOf, remoteAreaOf, remoteAreaSchema, remoteSurchargeOf, requiresCollection, requiresShipping, resolveFulfillmentMode, resolveRefundPolicy, resolveShippingPolicy, reviewListQuerySchema, reviewReportResultSchema, reviewSchema, reviewSortSchema, reviewWebhookEventDataSchema, reviewWebhookEventSchema, salesReportGroupBySchema, salesReportQuerySchema, salesReportRowSchema, salesReportSchema, salesTotalsSchema, saveLoginProviderRequestSchema, scopeAccessSchema, sellerAddressListQuerySchema, sellerAddressRequestSchema, sellerAddressSchema, sellerCustomerInquirySchema, settleCouponShortfall, settlementDetailQuerySchema, settlementDetailSchema, settlementListQuerySchema, settlementStatusSchema, settlementSummarySchema, settlementWithDetailsSchema, shippingAddressSchema, shippingPolicyPatchSchema, shippingPolicySchema, storeCodeSchema, storeMemberRoleUpdatedSchema, storeMemberSchema, storeRoleSchema, storeSchema, storeScopeResourceSchema, storeTokenResponseSchema, tossPaymentsCredentialsInputSchema, trackingStageResponseSchema, trackingUpdateResultSchema, updateCouponRequestSchema, updateLoginProviderRequestSchema, updateLoginSettingsRequestSchema, updateMemberRoleRequestSchema, updatePaymentProviderRequestSchema, updatePaymentSettingsRequestSchema, updateStoreRequestSchema, uploadExtensionOf, uploadPurposeSchema, uploadRejection, uploadRequestSchema, uploadTicketSchema, upsertPortOneProviderRequestSchema, upsertTossPaymentsProviderRequestSchema, userTokenResponseSchema, variantStockRequestSchema, variantStockSchema, verifyPaymentProviderRequestSchema, webhookCreatedSchema, webhookDeliveryDetailSchema, webhookDeliveryErrorCodeSchema, webhookDeliveryListQuerySchema, webhookDeliveryResourceSchema, webhookDeliverySchema, webhookDeliveryStatusSchema, webhookEventSchema, webhookFailureReasonSchema, webhookRedeliverFailedResultSchema, webhookRequestHeaderSchema, webhookRequestSchema, webhookResourceTypeSchema, webhookSchema, webhookSecretRotatedSchema, webhookStatusSchema, webhookTestRequestSchema, webhookTestResultSchema, withdrawCustomerRequestSchema, writeDryRunResultSchema, writePreviewSchema };