@sayren/store-sdk 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,850 @@ 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(무료). 배송 상품에만 있다",
1048
+ exchangeDeliveryFee: "단순 변심(구매자 귀책) 교환의 교환 배송비. 원 단위. 구매자에게 청구할 금액을 안내값으로 싣는다. 기본 0(무료). 배송 상품에만 있다",
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
+ * 반품·교환 배송비의 원천은 정책 하나다(이슈 #57 결정 3). 상품의 레거시 반품비·교환비 필드와의 연결은 없앴고,
1099
+ * 레거시 값은 마이그레이션이 상품별 정책으로 옮겼다.
1100
+ */
1101
+ function resolveRefundPolicy(storePolicy, product) {
1102
+ return {
1103
+ ...REFUND_POLICY_DEFAULTS,
1104
+ ...dropUndefined(storePolicy),
1105
+ ...productRefundPolicyOverride(product) ?? {}
1106
+ };
1107
+ }
1108
+ /**
1109
+ * 상품의 **덮어쓰기 자체** — 덮어쓰기가 없으면 null.
1110
+ *
1111
+ * `resolveRefundPolicy`가 합친 결과로는 "상품이 정한 값"과 "스토어 기본을 물려받은 값"을 가를 수 없다.
1112
+ * 관리 API 응답의 `refundPolicy`와 콘솔 폼의 덮어쓰기 스위치가 이 함수를 원천으로 쓴다 — 가르지 못하면
1113
+ * 스토어 기본을 쓰는 상품을 저장할 때 그 순간의 기본값이 상품에 박힌다.
1114
+ */
1115
+ function productRefundPolicyOverride(product) {
1116
+ if (product?.refundPolicy == null) return null;
1117
+ const { couponShortfallCharge: _storeOnly, ...override } = dropUndefined(product.refundPolicy);
1118
+ return override;
1119
+ }
1120
+ /** 반품·교환 배송비 키 — 배송이 없는 상품의 정책에는 두지 않는다 */
1121
+ const REFUND_POLICY_DELIVERY_FEE_KEYS = ["returnDeliveryFee", "exchangeDeliveryFee"];
1122
+ /** `undefined` 값을 빼고 얕게 복사한다 — 전개 순서로 덮어쓸 때 `undefined`가 기본값을 지우지 않게 */
1123
+ function dropUndefined(patch) {
1124
+ if (!patch) return {};
1125
+ return Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== void 0));
1126
+ }
1127
+ /**
1128
+ * 수량 단위 부분 취소의 환불 기준액 — **누적 내림의 차분**이다.
1129
+ *
1130
+ * 원 단위 반올림 규칙: `floor(주문상품 금액 × 누적 수량 ÷ 전체 수량)`을 두 번 구해 그 차를 쓴다.
1131
+ * 부분 취소를 몇 번 나눠도 합이 주문상품 금액과 정확히 같아서 1원이 남거나 넘치지 않는다.
1132
+ * 즉시할인은 이미 단가에 들어 있다. 쿠폰 할인은 단가에 들어가지 않으므로 환불은 실결제 금액을 나누는
1133
+ * `proratedPaidRefundBase`(promotion.ts)를 쓴다(이슈 #47).
1134
+ *
1135
+ * @param totalPrice 주문상품 금액 (할인 반영 단가 × 전체 수량)
1136
+ * @param quantity 주문상품 전체 수량
1137
+ * @param settledQuantity 이미 취소·반품된 누적 수량
1138
+ * @param cancelQuantity 이번에 취소·반품할 수량
1139
+ */
1140
+ function proratedRefundBase(totalPrice, quantity, settledQuantity, cancelQuantity) {
1141
+ if (quantity <= 0 || cancelQuantity <= 0) return 0;
1142
+ const before = Math.floor(totalPrice * settledQuantity / quantity);
1143
+ const after = Math.floor(totalPrice * (settledQuantity + cancelQuantity) / quantity);
1144
+ return Math.max(0, after - before);
1145
+ }
1146
+ //#endregion
1147
+ //#region src/schemas/promotion.ts
1148
+ /**
1149
+ * 쿠폰 금액 계산 (이슈 #47) — 쿠폰 할인을 **얼마나, 어느 주문상품에** 주는지를 정한다.
1150
+ *
1151
+ * 계산은 순수 함수 `quotePricing` 하나다. 장바구니 요약, 주문서 미리보기, 결제 시작의 최종 확정,
1152
+ * 주문 스냅샷, 환불 정산의 조건 미달 판정(`settleCouponShortfall`)이 모두 이 함수를 지난다.
1153
+ * 다섯 경로가 각자 계산하면 화면 금액, 결제 금액, 환불 금액이 갈라진다(`quoteDelivery`와 같은 원칙).
1154
+ *
1155
+ * 계산 순서
1156
+ * 1. 즉시할인(`products.discount`)은 이미 단가에 들어 있다. 입력 `amount`가 즉시할인 반영 금액이다.
1157
+ * 2. 상품 쿠폰(`PRODUCT`) — 주문상품 한 줄에 한 장. 그 줄 금액을 기준으로 할인한다.
1158
+ * 3. 주문 쿠폰(`ORDER`) — 주문에 한 장. **상품 쿠폰 차감 뒤 금액**의 대상 합계를 기준으로 할인하고,
1159
+ * 대상 줄에 금액 비율로 나눈다(누적 내림의 차분).
1160
+ * 4. 배송비 쿠폰(`DELIVERY`) — 주문에 한 장. 기본 배송비(지역 추가 배송비 제외)를 기준으로 할인하고
1161
+ * 배송 묶음에 기본 배송비 비율로 나눈다.
1162
+ *
1163
+ * 무료배송 기준(상품별 조건부 무료·스토어 전체 기준)은 **쿠폰 차감 전 금액**으로 판정한다. 그래서 배송비
1164
+ * 견적(`quoteDelivery`)은 쿠폰과 무관하게 먼저 계산되고, 이 함수는 그 결과만 입력으로 받는다.
1165
+ *
1166
+ * 최소 주문 금액은 대상 상품의 **즉시할인 반영 합계**(쿠폰 차감 전, 배송비 제외)로 판정한다.
1167
+ * 정률 할인은 10원 단위로 내리고(`discountedPriceOf`의 즉시할인과 같은 단위), 최대 할인액과 기준 금액을
1168
+ * 넘지 않는다.
1169
+ */
1170
+ const descriptions$2 = {
1171
+ kind: "쿠폰 종류. `ORDER`=주문 쿠폰(대상 상품 합계에서 할인), `PRODUCT`=상품 쿠폰(주문상품 한 줄에서 할인), `DELIVERY`=배송비 쿠폰(기본 배송비에서 할인, 지역 추가 배송비 제외)",
1172
+ benefitType: "할인 방식. `RATE`=정률(%), `AMOUNT`=정액(원)",
1173
+ benefitValue: "할인 값. `RATE`면 1~100(%), `AMOUNT`면 원 단위 금액",
1174
+ maxDiscountAmount: "정률 할인의 최대 할인액(원). null이면 상한이 없다. 정액 할인에는 쓰지 않는다(null)",
1175
+ minOrderAmount: "최소 주문 금액(원). 대상 상품의 즉시할인 반영 합계(배송비 제외)가 이 값 이상이어야 쓸 수 있다. 0이면 조건이 없다",
1176
+ firstOrderOnly: "이 스토어에서 결제 완료 주문이 없는 회원만 쓸 수 있다",
1177
+ memberOnly: "회원만 쓸 수 있다. false면 비회원도 쓸 수 있다 — 코드 입력형이면서 1인 한도가 없는 쿠폰만 false로 둘 수 있다",
1178
+ stackWithInstantDiscount: "상품 즉시할인과 함께 쓸 수 있다. false면 즉시할인이 걸린 주문상품은 쿠폰 대상에서 빠진다",
1179
+ includeProductIds: "대상 상품 id. 포함 목록(상품·카테고리)이 모두 비면 전 상품이 대상이다",
1180
+ includeCategoryIds: "대상 카테고리 id. 하위 카테고리의 상품도 대상이다",
1181
+ excludeProductIds: "제외 상품 id. 포함보다 우선한다",
1182
+ excludeCategoryIds: "제외 카테고리 id. 하위 카테고리의 상품도 제외한다. 포함보다 우선한다"
1183
+ };
1184
+ /** 쿠폰 종류 */
1185
+ const couponKindSchema = z.enum([
1186
+ "ORDER",
1187
+ "PRODUCT",
1188
+ "DELIVERY"
1189
+ ]).describe(descriptions$2.kind);
1190
+ /** 쿠폰 혜택 — 정률은 1~100%, 최대 할인액을 둘 수 있다. 정액은 최대 할인액이 없다 */
1191
+ const couponBenefitSchema = z.object({
1192
+ type: z.enum(["RATE", "AMOUNT"]).describe(descriptions$2.benefitType),
1193
+ value: z.number().int().min(1).describe(descriptions$2.benefitValue),
1194
+ maxDiscountAmount: z.number().int().min(1).nullable().describe(descriptions$2.maxDiscountAmount)
1195
+ }).superRefine((benefit, ctx) => {
1196
+ if (benefit.type === "RATE" && benefit.value > 100) ctx.addIssue({
1197
+ code: "custom",
1198
+ path: ["value"],
1199
+ message: "정률 할인은 1~100(%)이어야 합니다"
1200
+ });
1201
+ if (benefit.type === "AMOUNT" && benefit.maxDiscountAmount !== null) ctx.addIssue({
1202
+ code: "custom",
1203
+ path: ["maxDiscountAmount"],
1204
+ message: "정액 할인에는 최대 할인액을 두지 않습니다"
1205
+ });
1206
+ });
1207
+ /** 쿠폰 사용 조건 */
1208
+ const couponConditionsSchema = z.object({
1209
+ minOrderAmount: z.number().int().min(0).describe(descriptions$2.minOrderAmount),
1210
+ firstOrderOnly: z.boolean().describe(descriptions$2.firstOrderOnly),
1211
+ memberOnly: z.boolean().describe(descriptions$2.memberOnly),
1212
+ stackWithInstantDiscount: z.boolean().describe(descriptions$2.stackWithInstantDiscount)
1213
+ });
1214
+ /** 조건을 생략했을 때의 값 */
1215
+ const COUPON_CONDITIONS_DEFAULTS = {
1216
+ minOrderAmount: 0,
1217
+ firstOrderOnly: false,
1218
+ memberOnly: true,
1219
+ stackWithInstantDiscount: true
1220
+ };
1221
+ /** 상한 — 대상 목록은 규칙 스냅샷에 통째로 복사되므로 크기를 제한한다 */
1222
+ const COUPON_TARGET_LIST_MAX = 500;
1223
+ /** 쿠폰 대상 — 포함 목록이 모두 비면 전 상품이다. 제외가 포함보다 우선한다 */
1224
+ const couponTargetSchema = z.object({
1225
+ includeProductIds: z.array(z.string().min(1)).max(500).describe(descriptions$2.includeProductIds),
1226
+ includeCategoryIds: z.array(z.string().min(1)).max(500).describe(descriptions$2.includeCategoryIds),
1227
+ excludeProductIds: z.array(z.string().min(1)).max(500).describe(descriptions$2.excludeProductIds),
1228
+ excludeCategoryIds: z.array(z.string().min(1)).max(500).describe(descriptions$2.excludeCategoryIds)
1229
+ });
1230
+ const COUPON_TARGET_ALL = {
1231
+ includeProductIds: [],
1232
+ includeCategoryIds: [],
1233
+ excludeProductIds: [],
1234
+ excludeCategoryIds: []
1235
+ };
1236
+ /**
1237
+ * 쿠폰 규칙 스냅샷 — 계산에 필요한 값만 담는다. 결제 시작이 쿠폰을 읽어 이 형태로 결제 세션에 굳히고,
1238
+ * 주문 생성·환불 정산은 이 스냅샷만 읽는다. 결제 도중·주문 뒤 셀러가 쿠폰을 바꿔도 그 주문의 금액은 움직이지 않는다.
1239
+ */
1240
+ const couponRuleSchema = z.object({
1241
+ couponId: z.string().describe("쿠폰 id (`cpn_…`)"),
1242
+ name: z.string().describe("쿠폰 이름"),
1243
+ kind: couponKindSchema,
1244
+ benefit: couponBenefitSchema,
1245
+ conditions: couponConditionsSchema,
1246
+ target: couponTargetSchema,
1247
+ startsAt: z.iso.datetime({ offset: true }).nullable().describe("사용 시작 시각(ISO 8601, 시간대 포함). null이면 제한이 없다"),
1248
+ endsAt: z.iso.datetime({ offset: true }).nullable().describe("사용 종료 시각(ISO 8601, 시간대 포함, 이 시각부터 못 쓴다). null이면 제한이 없다")
1249
+ });
1250
+ /** 쿠폰을 적용하지 못한 사유 */
1251
+ const couponRejectReasonSchema = z.enum([
1252
+ "DUPLICATE_COUPON",
1253
+ "STACK_LIMIT",
1254
+ "NOT_STARTED",
1255
+ "EXPIRED",
1256
+ "MEMBER_ONLY",
1257
+ "FIRST_ORDER_ONLY",
1258
+ "NO_ELIGIBLE_ITEMS",
1259
+ "MIN_ORDER_AMOUNT",
1260
+ "LINE_NOT_ELIGIBLE",
1261
+ "NO_DISCOUNT"
1262
+ ]).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`=할인할 금액이 없음");
1263
+ /** 쿠폰 적용 뒤 결제 금액의 하한 — 0원 결제 경로가 없어 이보다 작으면 결제를 시작하지 않는다(400 `PAYMENT_AMOUNT_TOO_LOW`) */
1264
+ const MIN_PAYMENT_AMOUNT = 100;
1265
+ /**
1266
+ * 할인액 — 정률은 10원 단위 내림, 최대 할인액과 기준 금액을 넘지 않는다. 기준 금액이 0 이하면 0이다.
1267
+ *
1268
+ * 곱셈을 먼저 하고 나눈다. `base × 율 ÷ 1000`을 내려 10을 곱하면 `floor(base × 율 / 100 / 10) × 10`과 같고
1269
+ * 부동소수 오차가 끼지 않는다.
1270
+ */
1271
+ function couponDiscountOf(benefit, base) {
1272
+ if (base <= 0) return 0;
1273
+ let raw = benefit.type === "RATE" ? Math.floor(base * benefit.value / 1e3) * 10 : benefit.value;
1274
+ if (benefit.type === "RATE" && benefit.maxDiscountAmount !== null) raw = Math.min(raw, benefit.maxDiscountAmount);
1275
+ return Math.max(0, Math.min(raw, base));
1276
+ }
1277
+ /**
1278
+ * 금액을 가중치 비율로 나눈다 — **누적 내림의 차분**이다.
1279
+ *
1280
+ * `i`번째 몫 = `floor(total × W_i / W) − floor(total × W_{i−1} / W)`(`W_i`는 앞에서부터 누적 가중치).
1281
+ * 합이 `total`과 정확히 같고, 입력 순서가 같으면 결과도 같다. `total ≤ W`이면 몫이 가중치를 넘지 않는다.
1282
+ * 곱이 2^53을 넘을 수 있어 BigInt로 계산한다.
1283
+ */
1284
+ function allocateByWeight(total, weights) {
1285
+ if (!Number.isSafeInteger(total) || !weights.every((w) => Number.isSafeInteger(w))) throw new RangeError("allocateByWeight: 금액과 가중치는 안전한 정수여야 합니다");
1286
+ const sum = weights.reduce((acc, w) => acc + Math.max(0, w), 0);
1287
+ if (total <= 0 || sum <= 0) return weights.map(() => 0);
1288
+ const bigTotal = BigInt(total);
1289
+ const bigSum = BigInt(sum);
1290
+ let cumulative = 0n;
1291
+ let previous = 0n;
1292
+ return weights.map((weight) => {
1293
+ cumulative += BigInt(Math.max(0, weight));
1294
+ const upTo = bigTotal * cumulative / bigSum;
1295
+ const share = upTo - previous;
1296
+ previous = upTo;
1297
+ return Number(share);
1298
+ });
1299
+ }
1300
+ /** 카테고리 경로(`/a/b/c/`)가 그 카테고리 또는 하위를 가리키는가 */
1301
+ function inCategory(categoryPath, categoryIds) {
1302
+ if (!categoryPath || categoryIds.length === 0) return false;
1303
+ const chain = categoryPath.split("/").filter(Boolean);
1304
+ return categoryIds.some((id) => chain.includes(id));
1305
+ }
1306
+ /** 줄이 쿠폰 대상인가 — 제외가 포함보다 우선하고, 포함 목록이 모두 비면 전 상품이다 */
1307
+ function isCouponTargetLine(rule, line) {
1308
+ const { target, conditions } = rule;
1309
+ if (!conditions.stackWithInstantDiscount && line.amount < line.baseAmount) return false;
1310
+ if (target.excludeProductIds.includes(line.productId)) return false;
1311
+ if (inCategory(line.categoryPath, target.excludeCategoryIds)) return false;
1312
+ if (target.includeProductIds.length === 0 && target.includeCategoryIds.length === 0) return true;
1313
+ return target.includeProductIds.includes(line.productId) || inCategory(line.categoryPath, target.includeCategoryIds);
1314
+ }
1315
+ /** 시각 파싱 — 값이 없으면 null(제한 없음), 읽지 못하면 NaN(판정에서 거절한다) */
1316
+ function timeOf(value) {
1317
+ if (!value) return null;
1318
+ return Date.parse(value);
1319
+ }
1320
+ /**
1321
+ * 사용 기간 판정 — 쿠폰 기간과 발급 쿠폰 유효 기간 중 좁은 쪽. 시작은 포함, 종료는 제외.
1322
+ * 읽지 못하는 시각은 제한 없음이 아니라 거절이다(fail-closed).
1323
+ */
1324
+ function periodRejectOf(coupon, now) {
1325
+ const at = now.getTime();
1326
+ const starts = [timeOf(coupon.rule.startsAt), timeOf(coupon.validFrom)].filter((t) => t !== null);
1327
+ const ends = [timeOf(coupon.rule.endsAt), timeOf(coupon.validUntil)].filter((t) => t !== null);
1328
+ if (starts.some((t) => Number.isNaN(t) || at < t)) return "NOT_STARTED";
1329
+ if (ends.some((t) => Number.isNaN(t) || at >= t)) return "EXPIRED";
1330
+ return null;
1331
+ }
1332
+ /**
1333
+ * 쿠폰 금액 계산 — **순수 함수다.** DB를 읽지 않는다. 규칙 스냅샷을 만드는 조회와 한도·보유 판정
1334
+ * (발급 쿠폰 상태, 1인 한도)은 호출부 몫이다.
1335
+ *
1336
+ * 종류별로 상품 쿠폰 → 주문 쿠폰 → 배송비 쿠폰 순으로 적용한다. 같은 종류의 자리와 같은 쿠폰 id는
1337
+ * **적용된** 쿠폰만 차지한다 — 앞선 주문 쿠폰이 최소 금액 미달이면 뒤의 주문 쿠폰이 쓰이고, 같은 쿠폰의 발급
1338
+ * 사본 중 앞 장이 만료됐으면 뒤 장이 쓰인다. 결과의 `coupons`는 입력 순서 그대로다. 입력이 같으면 결과도 같다.
1339
+ */
1340
+ function quotePricing(input) {
1341
+ const lines = input.lines.map((line) => ({
1342
+ line,
1343
+ productDiscount: 0,
1344
+ allocations: []
1345
+ }));
1346
+ const lineIndex = new Map(lines.map((entry, index) => [entry.line.lineId, index]));
1347
+ const lineOf = (id) => {
1348
+ const index = lineIndex.get(id);
1349
+ return index === void 0 ? void 0 : lines[index];
1350
+ };
1351
+ const results = input.coupons.map((coupon) => ({
1352
+ couponKey: coupon.couponKey ?? coupon.rule.couponId,
1353
+ couponId: coupon.rule.couponId,
1354
+ kind: coupon.rule.kind,
1355
+ applied: false,
1356
+ discount: 0,
1357
+ rejectReason: null,
1358
+ lineId: null,
1359
+ eligibleLineIds: []
1360
+ }));
1361
+ const commonRejectOf = (coupon, result) => {
1362
+ if (!input.ignoreEligibility) {
1363
+ const period = periodRejectOf(coupon, input.now);
1364
+ if (period) return period;
1365
+ if (coupon.rule.conditions.memberOnly && !input.isMember) return "MEMBER_ONLY";
1366
+ if (coupon.rule.conditions.firstOrderOnly && !input.firstOrder) return "FIRST_ORDER_ONLY";
1367
+ }
1368
+ const pinned = coupon.eligibleLineIds ? new Set(coupon.eligibleLineIds) : null;
1369
+ const eligible = lines.filter((entry) => pinned ? pinned.has(entry.line.lineId) : isCouponTargetLine(coupon.rule, entry.line));
1370
+ result.eligibleLineIds = eligible.map((entry) => entry.line.lineId);
1371
+ if (eligible.length === 0) return "NO_ELIGIBLE_ITEMS";
1372
+ if (eligible.reduce((sum, entry) => sum + entry.line.amount, 0) < coupon.rule.conditions.minOrderAmount) return "MIN_ORDER_AMOUNT";
1373
+ return null;
1374
+ };
1375
+ const passed = [];
1376
+ input.coupons.forEach((coupon, index) => {
1377
+ const result = results[index];
1378
+ const reason = commonRejectOf(coupon, result);
1379
+ if (reason) result.rejectReason = reason;
1380
+ else passed.push(index);
1381
+ });
1382
+ /** 적용된 쿠폰 id — 같은 쿠폰 정의는 주문에 한 장 */
1383
+ const appliedCouponIds = /* @__PURE__ */ new Set();
1384
+ const apply = (index, discount, lineId) => {
1385
+ const result = results[index];
1386
+ result.applied = true;
1387
+ result.discount = discount;
1388
+ result.lineId = lineId;
1389
+ appliedCouponIds.add(result.couponId);
1390
+ };
1391
+ const duplicate = (index) => {
1392
+ const result = results[index];
1393
+ if (!appliedCouponIds.has(result.couponId)) return false;
1394
+ result.rejectReason = "DUPLICATE_COUPON";
1395
+ return true;
1396
+ };
1397
+ const productIndexes = passed.filter((index) => input.coupons[index]?.rule.kind === "PRODUCT");
1398
+ const productOrder = [...productIndexes.filter((index) => input.coupons[index]?.lineId), ...productIndexes.filter((index) => !input.coupons[index]?.lineId).map((index) => ({
1399
+ index,
1400
+ width: results[index]?.eligibleLineIds.length ?? 0
1401
+ })).sort((a, b) => a.width - b.width || a.index - b.index).map((entry) => entry.index)];
1402
+ const takenLines = /* @__PURE__ */ new Set();
1403
+ for (const index of productOrder) {
1404
+ const coupon = input.coupons[index];
1405
+ const result = results[index];
1406
+ if (duplicate(index)) continue;
1407
+ const candidates = result.eligibleLineIds.filter((id) => !takenLines.has(id));
1408
+ let chosen = null;
1409
+ let best = 0;
1410
+ if (coupon.lineId) {
1411
+ if (!candidates.includes(coupon.lineId)) {
1412
+ result.rejectReason = result.eligibleLineIds.includes(coupon.lineId) ? "STACK_LIMIT" : "LINE_NOT_ELIGIBLE";
1413
+ continue;
1414
+ }
1415
+ chosen = coupon.lineId;
1416
+ best = couponDiscountOf(coupon.rule.benefit, lineOf(chosen)?.line.amount ?? 0);
1417
+ } else {
1418
+ if (candidates.length === 0) {
1419
+ result.rejectReason = "STACK_LIMIT";
1420
+ continue;
1421
+ }
1422
+ for (const id of candidates) {
1423
+ const discount = couponDiscountOf(coupon.rule.benefit, lineOf(id)?.line.amount ?? 0);
1424
+ if (chosen === null || discount > best) {
1425
+ chosen = id;
1426
+ best = discount;
1427
+ }
1428
+ }
1429
+ }
1430
+ const entry = chosen === null ? void 0 : lineOf(chosen);
1431
+ if (!entry || chosen === null || best <= 0) {
1432
+ result.rejectReason = "NO_DISCOUNT";
1433
+ continue;
1434
+ }
1435
+ takenLines.add(chosen);
1436
+ entry.productDiscount += best;
1437
+ entry.allocations.push({
1438
+ couponKey: result.couponKey,
1439
+ amount: best
1440
+ });
1441
+ apply(index, best, chosen);
1442
+ }
1443
+ let orderSlot = false;
1444
+ for (const index of passed) {
1445
+ const coupon = input.coupons[index];
1446
+ const result = results[index];
1447
+ if (coupon.rule.kind !== "ORDER") continue;
1448
+ if (duplicate(index)) continue;
1449
+ if (orderSlot) {
1450
+ result.rejectReason = "STACK_LIMIT";
1451
+ continue;
1452
+ }
1453
+ const targets = result.eligibleLineIds.map((id) => lineOf(id)).filter((entry) => entry !== void 0);
1454
+ const weights = targets.map((entry) => Math.max(0, entry.line.amount - entry.productDiscount));
1455
+ const base = weights.reduce((sum, w) => sum + w, 0);
1456
+ const discount = couponDiscountOf(coupon.rule.benefit, base);
1457
+ if (discount <= 0) {
1458
+ result.rejectReason = "NO_DISCOUNT";
1459
+ continue;
1460
+ }
1461
+ const shares = allocateByWeight(discount, weights);
1462
+ targets.forEach((entry, i) => {
1463
+ const share = shares[i] ?? 0;
1464
+ if (share > 0) entry.allocations.push({
1465
+ couponKey: result.couponKey,
1466
+ amount: share
1467
+ });
1468
+ });
1469
+ orderSlot = true;
1470
+ apply(index, discount, null);
1471
+ }
1472
+ const deliveryBundles = input.delivery?.bundles ?? [];
1473
+ const bundles = deliveryBundles.map((bundle) => ({
1474
+ bundleKey: bundle.bundleKey,
1475
+ couponDiscount: 0
1476
+ }));
1477
+ let deliverySlot = false;
1478
+ let deliveryDiscountAmount = 0;
1479
+ for (const index of passed) {
1480
+ const coupon = input.coupons[index];
1481
+ const result = results[index];
1482
+ if (coupon.rule.kind !== "DELIVERY") continue;
1483
+ if (duplicate(index)) continue;
1484
+ if (deliverySlot) {
1485
+ result.rejectReason = "STACK_LIMIT";
1486
+ continue;
1487
+ }
1488
+ const weights = deliveryBundles.map((bundle) => Math.max(0, bundle.fee));
1489
+ const base = weights.reduce((sum, w) => sum + w, 0);
1490
+ const discount = couponDiscountOf(coupon.rule.benefit, base);
1491
+ if (discount <= 0) {
1492
+ result.rejectReason = "NO_DISCOUNT";
1493
+ continue;
1494
+ }
1495
+ allocateByWeight(discount, weights).forEach((share, i) => {
1496
+ const bundle = bundles[i];
1497
+ if (bundle) bundle.couponDiscount += share;
1498
+ });
1499
+ deliverySlot = true;
1500
+ deliveryDiscountAmount = discount;
1501
+ apply(index, discount, null);
1502
+ }
1503
+ const lineResults = lines.map((entry) => ({
1504
+ lineId: entry.line.lineId,
1505
+ amount: entry.line.amount,
1506
+ couponDiscount: entry.allocations.reduce((sum, a) => sum + a.amount, 0),
1507
+ allocations: entry.allocations
1508
+ }));
1509
+ const productAmount = lines.reduce((sum, entry) => sum + entry.line.amount, 0);
1510
+ const couponDiscountAmount = lineResults.reduce((sum, line) => sum + line.couponDiscount, 0);
1511
+ const deliveryFee = deliveryBundles.reduce((sum, bundle) => sum + bundle.fee + bundle.remoteSurcharge, 0);
1512
+ return {
1513
+ lines: lineResults,
1514
+ coupons: results,
1515
+ bundles,
1516
+ productAmount,
1517
+ couponDiscountAmount,
1518
+ deliveryFee,
1519
+ deliveryDiscountAmount,
1520
+ totalAmount: productAmount - couponDiscountAmount + deliveryFee - deliveryDiscountAmount
1521
+ };
1522
+ }
1523
+ const NO_COUPON_SETTLEMENT = {
1524
+ chargeAmount: 0,
1525
+ refundAmount: 0,
1526
+ entries: []
1527
+ };
1528
+ /**
1529
+ * 쿠폰 조건 미달 차감 — **순수 함수다.** 부분 취소·반품으로 남은 주문상품이 쿠폰 조건을 잃으면,
1530
+ * 남은 주문상품이 받던 할인만큼을 이번 환불에서 뺀다.
1531
+ *
1532
+ * 쿠폰마다
1533
+ * - `allocatedRemaining` = 남은 수량에 배분된 할인(`proratedRefundBase`로 수량 비율)
1534
+ * - `recomputed` = 주문 시점 규칙 스냅샷과 주문 시점 대상 줄로, 남은 금액만 넣어 `quotePricing`을 다시 돌린 할인
1535
+ * - 쿠폰이 **여전히 적용되면 목표 0**, 조건을 잃었으면 목표 = `allocatedRemaining`이다. 적용되는 쿠폰의
1536
+ * 정률 10원 내림 차이까지 차감하면 조건을 지킨 구매자가 몇 원씩 잃는다 — 조건을 잃은 경우만 본다.
1537
+ * 재계산 할인이 더 커지는 경우(정률 최대 할인액)도 차감하지 않고 더 돌려주지도 않는다.
1538
+ * - `delta` = 목표 − (이미 차감 + 이미 면제). 양수면 청구하고(판매자 귀책·정책 꺼짐이면 면제로 기록),
1539
+ * 음수면 앞서 뺀 차감부터 돌려준다. 마지막 활성 수량까지 취소되면 목표가 0이라 누적 차감이 모두 되돌아가고,
1540
+ * 전량 취소의 환불 합계가 결제액과 같아진다.
1541
+ * - 청구는 이번 환불에서 실제로 뺄 수 있는 금액(`chargeableAmount` + 이번에 되돌린 금액)을 넘지 않고,
1542
+ * **실제로 뺀 금액만** 기록한다. 못 뺀 나머지는 기록하지 않아 다음 환불이 다시 본다.
1543
+ *
1544
+ * 배송비 쿠폰은 여기서 다루지 않는다. 배송비 쿠폰을 발급하게 되면(3단계) 묶음 배송비 환불
1545
+ * (`computeDeliveryFeeSettlement`)이 그 묶음의 `couponDiscount`를 빼야 한다 — 빼지 않으면 받지 않은 배송비를 돌려준다.
1546
+ */
1547
+ function settleCouponShortfall(input) {
1548
+ const coupons = input.coupons.filter((coupon) => coupon.rule.kind !== "DELIVERY");
1549
+ if (coupons.length === 0) return NO_COUPON_SETTLEMENT;
1550
+ const recomputed = quotePricing({
1551
+ lines: input.lines.map((line) => {
1552
+ const remaining = proratedRefundBase(line.totalPrice, line.quantity, 0, line.activeQuantity);
1553
+ return {
1554
+ lineId: line.lineId,
1555
+ productId: line.productId,
1556
+ quantity: line.activeQuantity,
1557
+ baseAmount: remaining,
1558
+ amount: remaining
1559
+ };
1560
+ }),
1561
+ coupons: coupons.map((coupon) => ({
1562
+ rule: coupon.rule,
1563
+ couponKey: coupon.couponKey,
1564
+ lineId: coupon.lineId,
1565
+ eligibleLineIds: coupon.eligibleLineIds
1566
+ })),
1567
+ delivery: null,
1568
+ firstOrder: true,
1569
+ isMember: true,
1570
+ now: /* @__PURE__ */ new Date(0),
1571
+ ignoreEligibility: true
1572
+ });
1573
+ const base = coupons.map((coupon, index) => {
1574
+ const allocatedRemaining = input.lines.reduce((sum, line) => {
1575
+ return sum + proratedRefundBase(line.allocations.filter((a) => a.couponKey === coupon.couponKey).reduce((acc, a) => acc + a.amount, 0), line.quantity, 0, line.activeQuantity);
1576
+ }, 0);
1577
+ const result = recomputed.coupons[index];
1578
+ const stillApplied = result?.applied === true;
1579
+ const state = input.settlements[coupon.couponKey] ?? {};
1580
+ return {
1581
+ couponKey: coupon.couponKey,
1582
+ allocatedRemaining,
1583
+ recomputed: stillApplied ? result?.discount ?? 0 : 0,
1584
+ target: stillApplied ? 0 : allocatedRemaining,
1585
+ charged: Math.max(0, state.charged ?? 0),
1586
+ waived: Math.max(0, state.waived ?? 0)
1587
+ };
1588
+ });
1589
+ const entries = [];
1590
+ let refundAmount = 0;
1591
+ for (const item of base) {
1592
+ const delta = item.target - (item.charged + item.waived);
1593
+ let refund = 0;
1594
+ let waived = item.waived;
1595
+ if (delta < 0) {
1596
+ refund = Math.min(item.charged, -delta);
1597
+ waived = Math.max(0, waived - (-delta - refund));
1598
+ }
1599
+ refundAmount += refund;
1600
+ entries.push({
1601
+ couponKey: item.couponKey,
1602
+ allocatedRemaining: item.allocatedRemaining,
1603
+ recomputed: item.recomputed,
1604
+ target: item.target,
1605
+ charge: 0,
1606
+ refund,
1607
+ waive: 0,
1608
+ next: {
1609
+ charged: item.charged - refund,
1610
+ waived
1611
+ }
1612
+ });
1613
+ }
1614
+ let capacity = Math.max(0, input.chargeableAmount) + refundAmount;
1615
+ let chargeAmount = 0;
1616
+ const chargeAllowed = input.shortfallCharge && !input.sellerFault;
1617
+ for (const entry of entries) {
1618
+ const delta = entry.target - (entry.next.charged + entry.next.waived);
1619
+ if (delta <= 0) continue;
1620
+ if (!chargeAllowed) {
1621
+ entry.waive = delta;
1622
+ entry.next = {
1623
+ ...entry.next,
1624
+ waived: entry.next.waived + delta
1625
+ };
1626
+ continue;
1627
+ }
1628
+ const charge = Math.min(delta, capacity);
1629
+ if (charge <= 0) continue;
1630
+ capacity -= charge;
1631
+ chargeAmount += charge;
1632
+ entry.charge = charge;
1633
+ entry.next = {
1634
+ ...entry.next,
1635
+ charged: entry.next.charged + charge
1636
+ };
1637
+ }
1638
+ return {
1639
+ chargeAmount,
1640
+ refundAmount,
1641
+ entries
1642
+ };
1643
+ }
1644
+ /**
1645
+ * 주문상품의 실결제 금액 중 이번 취소 수량의 몫 — **실결제 금액(`totalPrice − couponDiscountAmount`)을
1646
+ * 누적 내림의 차분으로 나눈다.**
1647
+ *
1648
+ * 상품 금액과 쿠폰 배분을 따로 나눠 빼면 합은 같지만 한 번의 몫이 −1원이 되는 경우가 있다
1649
+ * (예: 금액 3원·할인 2원·수량 5개에서 셋째 개). 음수를 0으로 자르면 합이 1원 넘친다. 실결제 금액을
1650
+ * 바로 나누면 몫이 항상 0 이상이고 전량 취소의 합이 실결제 금액과 정확히 같다.
1651
+ */
1652
+ function proratedPaidRefundBase(item, settledQuantity, cancelQuantity) {
1653
+ return proratedRefundBase(Math.max(0, item.totalPrice - (item.couponDiscountAmount ?? 0)), item.quantity, settledQuantity, cancelQuantity);
1654
+ }
1655
+ //#endregion
1656
+ //#region src/schemas/coupon.ts
1657
+ /**
1658
+ * 쿠폰 관리 API 계약 (이슈 #47) — `/v1/coupons/**`, 스코프 `promotion:r|rw`.
1659
+ *
1660
+ * 금액 계산 규칙은 `promotion.ts`(`quotePricing`)가 원천이고, 여기에는 셀러가 쿠폰을 만들고 나눠 주는 표면만 있다.
1661
+ * 발급·사용이 시작된 쿠폰은 혜택·조건·대상·사용 시작일을 바꿀 수 없다(409 `COUPON_LOCKED`). 이름·설명·종료일 연장·
1662
+ * 한도 상향·중지·재개·종료만 된다. 규칙을 바꾸려면 새 쿠폰을 만든다.
1663
+ */
1664
+ /** 쿠폰 상태 — `ACTIVE`(사용 중)·`PAUSED`(중지, 재개 가능)·`ENDED`(종료, 되돌릴 수 없음) */
1665
+ const couponStatusSchema = z.enum([
1666
+ "ACTIVE",
1667
+ "PAUSED",
1668
+ "ENDED"
1669
+ ]).describe("쿠폰 상태. `ACTIVE`=사용 중, `PAUSED`=중지(재개 가능), `ENDED`=종료(되돌릴 수 없음)");
1670
+ /** 지금 만들 수 있는 발급 방식 — 다운로드·가입 자동 지급은 뒤 단계다 */
1671
+ const couponIssueMethodSchema = z.enum(["CODE", "MANUAL"]).describe("발급 방식. `CODE`=구매자가 주문서에 코드를 입력한다, `MANUAL`=셀러가 회원을 골라 지급한다");
1672
+ /** 코드 형식 — 영문·숫자·`-`·`_` 6~30자. 대소문자를 구분하지 않고 서버가 대문자로 저장한다 */
1673
+ const COUPON_CODE_PATTERN = /^[A-Za-z0-9_-]{6,30}$/;
1674
+ /** 코드 정규화 — 대문자, 앞뒤 공백 제거. 입력·저장·조회가 같은 함수를 쓴다 */
1675
+ function normalizeCouponCode(code) {
1676
+ return code.trim().toUpperCase();
1677
+ }
1678
+ const limitField = (description) => z.number().int().min(1).max(1e7).nullable().optional().describe(description);
1679
+ const descriptions$1 = {
1680
+ name: "구매자에게 보이는 쿠폰 이름. 1~60자",
1681
+ description: "구매자에게 보이는 설명. 300자까지",
1682
+ code: "쿠폰 코드 — 발급 방식이 `CODE`일 때 필수. 영문·숫자·`-`·`_` 6~30자, 대소문자 구분 없음(대문자로 저장). 스토어 안에서 유일하다",
1683
+ startsAt: "사용 시작 시각(ISO 8601, 시간대 포함). 생략하면 바로 쓸 수 있다",
1684
+ endsAt: "사용 종료 시각(ISO 8601, 시간대 포함). 이 시각부터 못 쓴다. 생략하면 제한이 없다. 이미 지급한 쿠폰의 유효 기간은 지급 때 정해지므로 뒤에 종료일을 늦춰도 늘어나지 않는다",
1685
+ validDaysAfterIssue: "발급 후 유효 일수 — 셀러 지급(`MANUAL`) 쿠폰만. 쿠폰 종료일과 둘 중 먼저 끝나는 쪽이 적용된다",
1686
+ totalIssueLimit: "총 발급 수 한도. 생략하면 무제한",
1687
+ totalUseLimit: "총 사용 수 한도. 결제 중인 예약도 센다. 생략하면 무제한",
1688
+ perMemberIssueLimit: "1인 발급 수 한도(셀러 지급). 생략하면 1인 1장",
1689
+ perMemberUseLimit: "1인 사용 수 한도(코드 입력형). 생략하면 무제한. 비회원에게 여는 쿠폰은 둘 수 없다"
1690
+ };
1691
+ /** 조건 입력 — 생략한 항목은 기본값(`COUPON_CONDITIONS_DEFAULTS`)이다 */
1692
+ const couponConditionsInputSchema = z.strictObject({
1693
+ minOrderAmount: z.number().int().min(0).optional(),
1694
+ firstOrderOnly: z.boolean().optional(),
1695
+ memberOnly: z.boolean().optional(),
1696
+ stackWithInstantDiscount: z.boolean().optional()
1697
+ });
1698
+ /** 대상 입력 — 생략한 목록은 빈 목록이다(포함 목록이 모두 비면 전 상품) */
1699
+ const couponTargetInputSchema = z.strictObject({
1700
+ includeProductIds: z.array(z.string().min(1)).max(500).optional(),
1701
+ includeCategoryIds: z.array(z.string().min(1)).max(500).optional(),
1702
+ excludeProductIds: z.array(z.string().min(1)).max(500).optional(),
1703
+ excludeCategoryIds: z.array(z.string().min(1)).max(500).optional()
1704
+ });
1705
+ const couponTimeField = (description) => z.iso.datetime({ offset: true }).nullable().optional().describe(description);
1706
+ /** `POST /v1/coupons` */
1707
+ const createCouponRequestSchema = z.strictObject({
1708
+ name: z.string().trim().min(1).max(60).describe(descriptions$1.name),
1709
+ description: z.string().max(300).nullable().optional().describe(descriptions$1.description),
1710
+ kind: couponKindSchema,
1711
+ benefit: couponBenefitSchema,
1712
+ conditions: couponConditionsInputSchema.optional().describe("사용 조건. 생략한 항목은 기본값(최소 금액 0, 회원 전용, 즉시할인과 중복 가능)"),
1713
+ target: couponTargetInputSchema.optional().describe("대상 상품·카테고리. 생략하면 전 상품"),
1714
+ issueMethod: couponIssueMethodSchema,
1715
+ code: z.string().regex(COUPON_CODE_PATTERN).optional().describe(descriptions$1.code),
1716
+ startsAt: couponTimeField(descriptions$1.startsAt),
1717
+ endsAt: couponTimeField(descriptions$1.endsAt),
1718
+ validDaysAfterIssue: z.number().int().min(1).max(3650).nullable().optional().describe(descriptions$1.validDaysAfterIssue),
1719
+ totalIssueLimit: limitField(descriptions$1.totalIssueLimit),
1720
+ totalUseLimit: limitField(descriptions$1.totalUseLimit),
1721
+ perMemberIssueLimit: limitField(descriptions$1.perMemberIssueLimit),
1722
+ perMemberUseLimit: limitField(descriptions$1.perMemberUseLimit)
1723
+ }).superRefine((body, ctx) => {
1724
+ const issue = (path, message) => ctx.addIssue({
1725
+ code: "custom",
1726
+ path: [path],
1727
+ message
1728
+ });
1729
+ if (body.issueMethod === "CODE" && !body.code) issue("code", "코드 입력형 쿠폰은 코드가 필요합니다");
1730
+ if (body.issueMethod !== "CODE" && body.code) issue("code", "코드는 코드 입력형 쿠폰에만 둡니다");
1731
+ if (body.issueMethod === "CODE" && body.validDaysAfterIssue != null) issue("validDaysAfterIssue", "발급 후 유효 일수는 셀러 지급 쿠폰에만 둡니다");
1732
+ if (body.issueMethod === "CODE" && body.perMemberIssueLimit != null) issue("perMemberIssueLimit", "1인 발급 수 한도는 셀러 지급 쿠폰에만 둡니다");
1733
+ if (body.issueMethod === "MANUAL" && body.perMemberUseLimit != null) issue("perMemberUseLimit", "셀러 지급 쿠폰은 발급 한 장이 한 번 쓰입니다. 1인 발급 수 한도를 쓰십시오");
1734
+ if (!(body.conditions?.memberOnly ?? COUPON_CONDITIONS_DEFAULTS.memberOnly) && (body.issueMethod !== "CODE" || body.perMemberUseLimit != null)) issue("conditions", "비회원에게 여는 쿠폰은 코드 입력형이면서 1인 사용 한도가 없어야 합니다");
1735
+ if (body.startsAt && body.endsAt && Date.parse(body.endsAt) <= Date.parse(body.startsAt)) issue("endsAt", "종료 시각은 시작 시각보다 뒤여야 합니다");
1736
+ });
1737
+ /**
1738
+ * `PATCH /v1/coupons/{couponId}` — 준 항목만 바뀐다. 발급·사용이 시작된 쿠폰(`locked`)은 이름·설명·종료일 연장·
1739
+ * 한도 상향만 받고, 그 밖의 항목이 현재 값과 다르면 409 `COUPON_LOCKED`다. 종류·발급 방식은 바꿀 수 없다.
1740
+ */
1741
+ const updateCouponRequestSchema = z.strictObject({
1742
+ name: z.string().trim().min(1).max(60).optional().describe(descriptions$1.name),
1743
+ description: z.string().max(300).nullable().optional().describe(descriptions$1.description),
1744
+ benefit: couponBenefitSchema.optional(),
1745
+ conditions: couponConditionsInputSchema.optional(),
1746
+ target: couponTargetInputSchema.optional(),
1747
+ code: z.string().regex(COUPON_CODE_PATTERN).optional().describe(descriptions$1.code),
1748
+ startsAt: couponTimeField(descriptions$1.startsAt),
1749
+ endsAt: couponTimeField(descriptions$1.endsAt),
1750
+ validDaysAfterIssue: z.number().int().min(1).max(3650).nullable().optional(),
1751
+ totalIssueLimit: limitField(descriptions$1.totalIssueLimit),
1752
+ totalUseLimit: limitField(descriptions$1.totalUseLimit),
1753
+ perMemberIssueLimit: limitField(descriptions$1.perMemberIssueLimit),
1754
+ perMemberUseLimit: limitField(descriptions$1.perMemberUseLimit)
1755
+ });
1756
+ /** 쿠폰 응답 */
1757
+ const couponSchema = z.object({
1758
+ couponId: z.string(),
1759
+ name: z.string(),
1760
+ description: z.string().nullable(),
1761
+ kind: couponKindSchema,
1762
+ benefit: couponBenefitSchema,
1763
+ conditions: couponConditionsSchema,
1764
+ target: couponTargetSchema,
1765
+ issueMethod: z.string().describe("발급 방식. 현재 값은 `CODE`·`MANUAL`이고 값이 추가될 수 있어 문자열로 받는다(다운로드·가입 자동 지급 예정)"),
1766
+ code: z.string().nullable().describe("쿠폰 코드(대문자). 코드 입력형이 아니면 null"),
1767
+ startsAt: z.string().nullable(),
1768
+ endsAt: z.string().nullable(),
1769
+ validDaysAfterIssue: z.number().int().nullable(),
1770
+ totalIssueLimit: z.number().int().nullable(),
1771
+ totalUseLimit: z.number().int().nullable(),
1772
+ perMemberIssueLimit: z.number().int().nullable(),
1773
+ perMemberUseLimit: z.number().int().nullable(),
1774
+ issuedCount: z.number().int().describe("발급 수 — 회수한 발급도 센다. 총 발급 한도(`totalIssueLimit`)가 이 값과 비교된다"),
1775
+ reservedCount: z.number().int().describe("결제 중인 예약 수 — 결제가 끝나거나 만료되면 풀린다"),
1776
+ usedCount: z.number().int().describe("사용 수 — 전량 취소로 복원되면 줄어든다"),
1777
+ status: couponStatusSchema,
1778
+ locked: z.boolean().describe("발급·사용이 시작돼 혜택·조건·대상·사용 시작일을 바꿀 수 없는가"),
1779
+ lockedAt: z.string().nullable(),
1780
+ createdAt: z.string(),
1781
+ updatedAt: z.string()
1782
+ });
1783
+ /** `GET /v1/coupons` 쿼리 */
1784
+ const couponListQuerySchema = z.object({
1785
+ status: couponStatusSchema.optional(),
1786
+ kind: couponKindSchema.optional(),
1787
+ issueMethod: couponIssueMethodSchema.optional(),
1788
+ q: z.string().optional().describe("이름·코드 검색(포함)"),
1789
+ ...pageQueryShape()
1790
+ });
1791
+ /** 발급 쿠폰 상태 — 만료는 저장 상태가 아니라 조회 시각으로 판정한 값이다 */
1792
+ const couponIssueStatusSchema = z.string().describe("발급 쿠폰 상태. 현재 값은 `AVAILABLE`(사용 가능)·`RESERVED`(결제 중)·`USED`(사용함)·`REVOKED`(회수)·`EXPIRED`(유효 기간 지남)이고 값이 추가될 수 있어 문자열로 받는다");
1793
+ const couponIssueSchema = z.object({
1794
+ issueId: z.string(),
1795
+ couponId: z.string(),
1796
+ customerId: z.string(),
1797
+ customerName: z.string().nullable().describe("구매자 이름. 탈퇴한 구매자는 null"),
1798
+ status: couponIssueStatusSchema,
1799
+ validFrom: z.string(),
1800
+ validUntil: z.string().nullable(),
1801
+ usedOrderId: z.string().nullable(),
1802
+ issuedAt: z.string(),
1803
+ revokedAt: z.string().nullable()
1804
+ });
1805
+ const couponIssueListQuerySchema = z.object({
1806
+ status: z.enum([
1807
+ "AVAILABLE",
1808
+ "RESERVED",
1809
+ "USED",
1810
+ "REVOKED"
1811
+ ]).optional(),
1812
+ ...pageQueryShape()
1813
+ });
1814
+ /** `POST /v1/coupons/{couponId}/issues` — 셀러 지급. 한 번에 1,000명까지 */
1815
+ 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명, 중복 불가)") });
1816
+ const issueCouponResultSchema = z.object({
1817
+ issued: z.array(z.object({
1818
+ customerId: z.string(),
1819
+ issueId: z.string()
1820
+ })),
1821
+ failed: z.array(z.object({
1822
+ customerId: z.string(),
1823
+ code: z.string().describe("실패 사유. `CUSTOMER_NOT_FOUND`(이 스토어 구매자가 아님)·`COUPON_LIMIT_REACHED`(1인 발급 한도)·`COUPON_EXHAUSTED`(총 발급 한도)"),
1824
+ message: z.string()
1825
+ }))
1826
+ });
1827
+ const couponRedemptionSchema = z.object({
1828
+ redemptionId: z.string(),
1829
+ couponId: z.string(),
1830
+ issueId: z.string().nullable(),
1831
+ customerId: z.string().nullable().describe("구매자 id. 비회원 주문은 null"),
1832
+ orderId: z.string().nullable().describe("주문 id. 결제가 끝나기 전의 예약·해제는 null"),
1833
+ status: z.string().describe("사용 기록 상태. 현재 값은 `RESERVED`(결제 중)·`USED`(사용함)·`RELEASED`(결제 실패·만료로 해제)·`RESTORED`(전량 취소로 복원)이고 값이 추가될 수 있어 문자열로 받는다"),
1834
+ discountAmount: z.number().int(),
1835
+ reservedAt: z.string(),
1836
+ usedAt: z.string().nullable(),
1837
+ releasedAt: z.string().nullable(),
1838
+ restoredAt: z.string().nullable(),
1839
+ restoreSkippedReason: z.string().nullable().describe("전량 취소에도 복원하지 않은 사유 — `EXPIRED`(유효 기간 지남)·`ENDED`(종료된 쿠폰)")
1840
+ });
1841
+ const couponRedemptionListQuerySchema = z.object({
1842
+ status: z.enum([
1843
+ "RESERVED",
1844
+ "USED",
1845
+ "RELEASED",
1846
+ "RESTORED"
1847
+ ]).optional(),
1848
+ ...pageQueryShape()
1849
+ });
1850
+ const couponStatsSchema = z.object({
1851
+ couponId: z.string(),
1852
+ issuedCount: z.number().int(),
1853
+ revokedCount: z.number().int(),
1854
+ reservedCount: z.number().int(),
1855
+ usedCount: z.number().int(),
1856
+ restoredCount: z.number().int(),
1857
+ releasedCount: z.number().int(),
1858
+ discountTotal: z.number().int().describe("사용 중(`USED`)인 기록의 할인 합계 (원)"),
1859
+ orderPaymentTotal: z.number().int().describe("이 쿠폰을 쓴(`USED`) 주문의 결제 금액 합계 (원). 부분 취소·반품 환불은 빼지 않은 결제 시점 금액이다")
1860
+ });
1861
+ /** `GET /v1/customers/{customerId}/coupons` 항목 — 발급 쿠폰 + 쿠폰 이름·혜택 */
1862
+ const customerCouponSchema = couponIssueSchema.extend({
1863
+ couponName: z.string(),
1864
+ kind: couponKindSchema,
1865
+ benefit: couponBenefitSchema
1866
+ });
1867
+ //#endregion
1031
1868
  //#region src/schemas/customer.ts
1032
1869
  /** 구매자 목록 정렬 */
1033
1870
  const customerSortSchema = z.enum([
@@ -1194,6 +2031,140 @@ const customerWithdrawalBlockedDetailsSchema = z.object({
1194
2031
  pendingPayments: z.number().int()
1195
2032
  });
1196
2033
  //#endregion
2034
+ //#region src/schemas/digital-asset.ts
2035
+ /**
2036
+ * 파일 다운로드 상품의 판매 파일 (이슈 #48 단계 3).
2037
+ *
2038
+ * 파일은 CDN **비공개 객체**다. `POST /v1/uploads`(`purpose: "digital-asset"`)로 자리를 받아 응답의 `headers`와 함께 PUT하고,
2039
+ * 응답의 `key`로 상품에 연결한다. 객체 키와 파일 주소는 어떤 응답에도 싣지 않는다 — 구매자는 제공된 주문상품에서
2040
+ * 수명 300초의 서명 주소로만 받는다. 주문은 주문 시점 파일을 스냅샷하므로 파일을 바꿔도 이미 산 구매자는 옛 파일을 받는다.
2041
+ */
2042
+ const digitalAssetAttachRequestSchema = z.object({
2043
+ key: z.string().min(1).max(512).describe("업로드 자리 발급 응답의 `key`. 이 스토어의 비공개 업로드 키여야 한다"),
2044
+ fileName: z.string().trim().min(1).max(200).describe("구매자에게 보일 파일 이름(확장자 포함)"),
2045
+ sizeBytes: z.number().int().positive().optional().describe("파일 크기(바이트). 안내용이다"),
2046
+ contentType: z.string().max(200).optional().describe("파일 형식(MIME). 안내용이다"),
2047
+ variantId: z.string().optional().describe("이 파일을 줄 옵션(variant). 생략하면 모든 옵션의 구매자가 받는다")
2048
+ });
2049
+ /** 판매 파일 — 객체 키·주소는 싣지 않는다 */
2050
+ const digitalAssetSchema = z.object({
2051
+ assetId: z.string(),
2052
+ productId: z.string(),
2053
+ variantId: z.string().nullable().describe("옵션 전용 파일이면 그 옵션, 모든 옵션 공통이면 null"),
2054
+ fileName: z.string(),
2055
+ contentType: z.string().nullable(),
2056
+ sizeBytes: z.number().int().nullable(),
2057
+ status: z.string().describe("`ACTIVE`(새 주문에 제공)·`REMOVED`(해제 — 이미 산 구매자는 계속 받는다). 값이 추가될 수 있어 문자열로 받는다"),
2058
+ createdAt: z.string()
2059
+ });
2060
+ /** 셀러 확인용 서명 주소 — `POST /v1/products/{productId}/digital-assets/{assetId}/preview-url` */
2061
+ const digitalAssetPreviewSchema = z.object({
2062
+ url: z.url().describe("수명 300초의 서명 주소"),
2063
+ expiresAt: z.string()
2064
+ });
2065
+ /** 주문상품의 다운로드 제공 정보 — 주문 응답의 `fulfillment.download` */
2066
+ const orderItemDownloadSchema = z.object({
2067
+ count: z.number().int().describe("지금까지 받은 횟수"),
2068
+ limit: z.number().int().describe("받을 수 있는 횟수"),
2069
+ expiresAt: z.string().nullable().describe("받을 수 있는 기한 (ISO 8601)"),
2070
+ firstDownloadedAt: z.string().nullable().describe("처음 받은 시각 — 제공 개시 증거다"),
2071
+ files: z.array(z.object({
2072
+ assetId: z.string(),
2073
+ fileName: z.string(),
2074
+ sizeBytes: z.number().int().nullable()
2075
+ }))
2076
+ });
2077
+ //#endregion
2078
+ //#region src/schemas/fulfillment-code.ts
2079
+ /**
2080
+ * 코드 발급 상품의 코드 풀 (이슈 #48 단계 2) — 라이선스 키·기프트 코드처럼 결제 확정과 함께 한 개씩 배정하는 코드다.
2081
+ *
2082
+ * **평문 코드는 두 경로로만 나간다** — 구매자 열람(`POST /storefront/v1/me/order-items/{id}/fulfillment/reveal`)과 셀러 콘솔
2083
+ * 열람(`POST /v1/fulfillment-codes/{codeId}/reveal`, 콘솔 전용). 목록·주문·웹훅·API 로그에는 가린 값(`hint`)만 싣는다.
2084
+ * 코드는 결제 자격 증명과 같은 키링·AES-256-GCM으로 암호화해 저장한다.
2085
+ */
2086
+ /**
2087
+ * 코드 상태.
2088
+ * - `AVAILABLE` 판매 가능 — 재고로 센다
2089
+ * - `ASSIGNED` 주문상품에 배정됨
2090
+ * - `REVOKED` 환불로 회수됨(구매자 화면에서 숨긴다. 코드 자체의 무효화는 셀러 시스템 몫이다)
2091
+ * - `EXPIRED` 유효기간이 지나 배정하지 않음
2092
+ * - `VOID` 셀러가 폐기함
2093
+ */
2094
+ const fulfillmentCodeStatusSchema = z.enum([
2095
+ "AVAILABLE",
2096
+ "ASSIGNED",
2097
+ "REVOKED",
2098
+ "EXPIRED",
2099
+ "VOID"
2100
+ ]);
2101
+ /** 한 번에 올릴 수 있는 코드 수 */
2102
+ const MAX_CODES_PER_UPLOAD = 1e4;
2103
+ /** 코드 한 개의 최대 길이 */
2104
+ const MAX_CODE_LENGTH = 200;
2105
+ /**
2106
+ * 코드 일괄 업로드 요청 — `POST /v1/products/{productId}/codes`. `codes`(배열)와 `csv`(본문) 중 하나를 보낸다.
2107
+ * 앞뒤 공백은 지우고 빈 줄은 건너뛴다. 같은 상품에 이미 있는 코드와 요청 안의 중복은 받지 않는다(`duplicates`).
2108
+ */
2109
+ const fulfillmentCodeUploadRequestSchema = z.object({
2110
+ variantId: z.string().optional().describe("코드를 채울 옵션(variant) ID. 옵션 없는 상품은 생략한다(기본 옵션)"),
2111
+ codes: z.array(z.string()).max(MAX_CODES_PER_UPLOAD).optional().describe(`코드 목록. 한 번에 ${MAX_CODES_PER_UPLOAD}개까지. \`csv\`와 둘 중 하나만 보낸다`),
2112
+ csv: z.string().max(4e6).optional().describe(`CSV 본문 — 줄마다 첫 칸이 코드다. 첫 줄의 첫 칸이 \`code\`면 머리글로 보고 건너뛴다. 한 번에 ${MAX_CODES_PER_UPLOAD}줄까지`),
2113
+ expiresAt: z.iso.datetime({ offset: true }).optional().describe("이 업로드 코드의 유효기간 (ISO 8601). 지나면 배정하지 않고 만료로 바꾼다")
2114
+ }).refine((value) => value.codes === void 0 !== (value.csv === void 0), { message: "codes와 csv 중 하나만 보내야 합니다" });
2115
+ /** 받지 않은 줄 */
2116
+ const fulfillmentCodeRejectionSchema = z.object({
2117
+ line: z.number().int().describe("요청 안의 위치 (1부터). CSV는 머리글을 포함한 줄 번호다"),
2118
+ reason: z.string().describe("사유. `DUPLICATE`(이미 있는 코드이거나 요청 안에서 겹침)·`TOO_LONG`(200자 초과)·`INVALID_CHARACTER`(제어 문자). 값이 추가될 수 있어 문자열로 받는다")
2119
+ });
2120
+ /** 업로드 결과 — 배치 요약 */
2121
+ const fulfillmentCodeBatchSchema = z.object({
2122
+ batchId: z.string(),
2123
+ productId: z.string(),
2124
+ variantId: z.string(),
2125
+ total: z.number().int().describe("요청에서 읽은 코드 수(빈 줄·머리글 제외)"),
2126
+ accepted: z.number().int().describe("받은 코드 수 — 재고가 이만큼 는다"),
2127
+ duplicates: z.number().int().describe("중복이라 받지 않은 코드 수"),
2128
+ rejected: z.array(fulfillmentCodeRejectionSchema).describe("받지 않은 줄(중복 포함). 최대 1,000개까지 싣는다"),
2129
+ expiresAt: z.string().nullable(),
2130
+ createdAt: z.string()
2131
+ });
2132
+ /** 코드 목록의 한 줄 — **가린 값만** 싣는다 */
2133
+ const fulfillmentCodeSchema = z.object({
2134
+ codeId: z.string(),
2135
+ productId: z.string(),
2136
+ variantId: z.string(),
2137
+ batchId: z.string().nullable().describe("업로드 배치. 수동 제공으로 셀러가 넘긴 코드는 null"),
2138
+ hint: z.string().describe("가린 코드 — 끝 4자만 보인다(예: `****AB12`)"),
2139
+ status: z.string().describe("코드 상태. `AVAILABLE`·`ASSIGNED`·`REVOKED`·`EXPIRED`·`VOID`. 값이 추가될 수 있어 문자열로 받는다"),
2140
+ orderItemId: z.string().nullable().describe("배정된 주문상품. 배정 전이면 null"),
2141
+ assignedAt: z.string().nullable(),
2142
+ revealedAt: z.string().nullable().describe("구매자가 처음 열람한 시각 — 제공 개시 증거다"),
2143
+ expiresAt: z.string().nullable(),
2144
+ createdAt: z.string()
2145
+ });
2146
+ const fulfillmentCodePageSchema = pageSchema(fulfillmentCodeSchema).extend({ available: z.number().int().describe("판매 가능한 코드 수(필터와 무관한 상품 전체)") });
2147
+ /** `GET /v1/products/{productId}/codes` 쿼리 */
2148
+ const fulfillmentCodeListQuerySchema = z.object({
2149
+ status: fulfillmentCodeStatusSchema.optional().describe("상태 필터"),
2150
+ variantId: z.string().optional().describe("옵션 필터"),
2151
+ batchId: z.string().optional().describe("업로드 배치 필터"),
2152
+ orderItemId: z.string().optional().describe("주문상품 필터"),
2153
+ ...pageQueryShape()
2154
+ });
2155
+ /** 셀러 콘솔 코드 열람 응답 — `POST /v1/fulfillment-codes/{codeId}/reveal` */
2156
+ const fulfillmentCodeRevealSchema = z.object({
2157
+ codeId: z.string(),
2158
+ code: z.string().describe("코드 평문. 응답은 캐시하지 않는다(`Cache-Control: no-store`)")
2159
+ });
2160
+ /** 주문상품에 배정된 코드의 가린 값 — 주문 응답의 `fulfillment.codes` */
2161
+ const orderItemCodeSchema = z.object({
2162
+ codeId: z.string(),
2163
+ hint: z.string().describe("가린 코드 — 끝 4자만 보인다"),
2164
+ status: z.string().describe("`ASSIGNED`(유효)·`REVOKED`(환불로 회수). 값이 추가될 수 있어 문자열로 받는다"),
2165
+ revealedAt: z.string().nullable().describe("구매자가 처음 열람한 시각. 열람 전이면 null")
2166
+ });
2167
+ //#endregion
1197
2168
  //#region src/schemas/login.ts
1198
2169
  /** 소셜 로그인 공급자 */
1199
2170
  const loginProviderSchema = z.enum([
@@ -1358,6 +2329,7 @@ const eventTypeSchema = z.enum([
1358
2329
  "ORDER.PURCHASE_DECIDED",
1359
2330
  "ORDER.ADDRESS_CHANGE_REQUESTED",
1360
2331
  "ORDER.CANCELED",
2332
+ "ORDER.FULFILLED",
1361
2333
  "CLAIM.CANCEL_REQUESTED",
1362
2334
  "CLAIM.RETURN_REQUESTED",
1363
2335
  "CLAIM.EXCHANGE_REQUESTED",
@@ -1447,7 +2419,7 @@ const webhookDeliverySchema = z.object({
1447
2419
  errorCode: webhookDeliveryErrorCodeSchema.nullable().describe("마지막 시도의 실패 사유. 성공했거나 미시도면 null"),
1448
2420
  failureReason: webhookFailureReasonSchema.nullable().describe("마지막 시도의 실패 원인 분류. 성공했거나 미시도면 null. `errorCode`를 더 잘게 나눈 진단 값이다"),
1449
2421
  resource: webhookDeliveryResourceSchema.nullable().describe("이벤트가 가리키는 대상 리소스. 리소스를 특정할 수 없는 이벤트면 null. 이 값으로 전송 기록(`resourceId`)과 API 로그(`q`)를 오갈 수 있다"),
1450
- attemptCount: z.number().int().min(0).describe("지금까지의 시도 횟수. 최초 시도 1회 + 재시도 최대 4회"),
2422
+ attemptCount: z.number().int().min(0).describe("지금까지 전송을 시작한 횟수. 최초 시도 1회 + 재시도 최대 4회. 전송 도중 장애로 결과를 기록하지 못한 시도도 센다"),
1451
2423
  createdAt: z.string().describe("전송 기록이 적재된 일시 (ISO 8601)"),
1452
2424
  attemptedAt: z.string().nullable().describe("마지막 시도 일시 (ISO 8601). 미시도면 null"),
1453
2425
  nextAttemptAt: z.string().nullable().describe("다음 시도 예정 일시 (ISO 8601). `PENDING`·`PENDING_RETRY`일 때만 값이 있다"),
@@ -1533,6 +2505,374 @@ const reviewReportResultSchema = z.object({
1533
2505
  status: z.literal("PENDING_REVIEW")
1534
2506
  });
1535
2507
  //#endregion
2508
+ //#region src/schemas/fulfillment.ts
2509
+ /**
2510
+ * 상품 유형과 제공 방식 (이슈 #48) — 배송이 필요 없는 상품을 팔기 위한 축이다.
2511
+ *
2512
+ * **상품 유형은 제공 방식 하나로 표현한다.** `fulfillmentType` 한 필드가 배송 필요 여부, 이행 흐름, 재고 의미를 정한다.
2513
+ * 코드 곳곳에서 유형 이름으로 분기하지 않고 이 파일의 판정 함수(`requiresShipping`·`fulfillmentRuleOf`)를 지난다.
2514
+ * 유형이 늘어나도 판정표 한 곳만 고치면 배송비 계산·결제 시작·클레임·콘솔 버튼이 같이 따라온다.
2515
+ *
2516
+ * 주문상품 상태 enum은 바꾸지 않는다. 비실물의 **제공 완료는 `DELIVERED`를 재사용한다** — 새 상태를 더하면 기존 소비자의
2517
+ * 상태 분기(배송완료 뒤 구매확정 대기, 반품 가능 판정)가 새 값을 모른다. 표시는 `fulfillmentType`으로 가른다.
2518
+ */
2519
+ /**
2520
+ * 상품 유형.
2521
+ * - `SHIPPING` 배송 상품(기본, 이 필드 이전의 상품 전부)
2522
+ * - `DOWNLOAD` 파일 다운로드
2523
+ * - `CODE` 코드 발급(라이선스 키·기프트 코드)
2524
+ * - `SERVICE` 이용권·상품권·서비스(예약 없음)
2525
+ * - `PICKUP` 방문 수령 실물
2526
+ */
2527
+ const fulfillmentTypeSchema = z.enum([
2528
+ "SHIPPING",
2529
+ "DOWNLOAD",
2530
+ "CODE",
2531
+ "SERVICE",
2532
+ "PICKUP"
2533
+ ]);
2534
+ /** 제공 방식 — `AUTO`는 결제 확정과 함께 제공하고, `MANUAL`은 셀러가 제공 처리한다 */
2535
+ const fulfillmentModeSchema = z.enum(["AUTO", "MANUAL"]);
2536
+ /**
2537
+ * 응답 스키마용 유형 — 서버가 나중에 유형을 더해도 옛 SDK가 응답 파싱에서 깨지지 않게 문자열로 받는다.
2538
+ * 요청은 `fulfillmentTypeSchema`(닫힌 enum)로 검증한다.
2539
+ */
2540
+ const fulfillmentTypeResponseSchema = z.string().describe("상품 유형. 현재 값은 `SHIPPING`(배송 상품)·`DOWNLOAD`(파일 다운로드)·`CODE`(코드 발급)·`SERVICE`(이용권·서비스)·`PICKUP`(방문 수령)이고 값이 추가될 수 있어 문자열로 받는다");
2541
+ /**
2542
+ * 유형별 규칙표 — **유일한 원천이다.**
2543
+ *
2544
+ * `PICKUP`은 실물이라 반품 수거와 교환을 받지만 택배 배송은 없다. `DOWNLOAD`는 결제 즉시 권한을 발급하므로 `AUTO`만,
2545
+ * `PICKUP`은 셀러가 수령 완료를 누르므로 `MANUAL`만 둔다.
2546
+ */
2547
+ const FULFILLMENT_RULES = {
2548
+ SHIPPING: {
2549
+ requiresShipping: true,
2550
+ modes: [],
2551
+ defaultMode: null,
2552
+ requiresCollection: true,
2553
+ exchangeSupported: true
2554
+ },
2555
+ DOWNLOAD: {
2556
+ requiresShipping: false,
2557
+ modes: ["AUTO"],
2558
+ defaultMode: "AUTO",
2559
+ requiresCollection: false,
2560
+ exchangeSupported: false
2561
+ },
2562
+ CODE: {
2563
+ requiresShipping: false,
2564
+ modes: ["AUTO", "MANUAL"],
2565
+ defaultMode: "AUTO",
2566
+ requiresCollection: false,
2567
+ exchangeSupported: false
2568
+ },
2569
+ SERVICE: {
2570
+ requiresShipping: false,
2571
+ modes: ["MANUAL", "AUTO"],
2572
+ defaultMode: "MANUAL",
2573
+ requiresCollection: false,
2574
+ exchangeSupported: false
2575
+ },
2576
+ PICKUP: {
2577
+ requiresShipping: false,
2578
+ modes: ["MANUAL"],
2579
+ defaultMode: "MANUAL",
2580
+ requiresCollection: true,
2581
+ exchangeSupported: true
2582
+ }
2583
+ };
2584
+ /**
2585
+ * 저장된 값 → 유형. null·모르는 값은 `SHIPPING`이다 — 이 필드 이전의 주문상품(`order_items.fulfillment_type` null)과
2586
+ * 상품은 모두 배송 상품이었다.
2587
+ */
2588
+ function fulfillmentTypeOf(value) {
2589
+ const parsed = fulfillmentTypeSchema.safeParse(value);
2590
+ return parsed.success ? parsed.data : "SHIPPING";
2591
+ }
2592
+ /** 유형의 규칙 — 모르는 값은 배송 상품 규칙이다 */
2593
+ function fulfillmentRuleOf(type) {
2594
+ return FULFILLMENT_RULES[fulfillmentTypeOf(type)];
2595
+ }
2596
+ /** 택배 배송이 필요한가 — 배송비 묶음·배송지·발송 흐름의 판정은 이 함수 하나를 지난다 */
2597
+ function requiresShipping(type) {
2598
+ return fulfillmentRuleOf(type).requiresShipping;
2599
+ }
2600
+ /** 반품에 수거가 필요한가 — 비실물 반품은 수거 등록·수거 완료 없이 승인한다 */
2601
+ function requiresCollection(type) {
2602
+ return fulfillmentRuleOf(type).requiresCollection;
2603
+ }
2604
+ /**
2605
+ * 유효 제공 방식 — 요청 값이 유형에 허용되면 그 값, 생략이면 유형 기본값이다. 배송 상품은 null이다.
2606
+ * 허용되지 않는 값은 `null`을 돌려주지 않고 호출부가 거부하도록 `undefined`를 돌려준다.
2607
+ */
2608
+ function resolveFulfillmentMode(type, mode) {
2609
+ const rule = FULFILLMENT_RULES[type];
2610
+ if (rule.modes.length === 0) return null;
2611
+ if (mode === null || mode === void 0) return rule.defaultMode;
2612
+ return rule.modes.includes(mode) ? mode : void 0;
2613
+ }
2614
+ /**
2615
+ * 배송이 없는 주문상품의 배송 묶음 키 (`order_items.delivery_bundle_key`).
2616
+ *
2617
+ * null로 두면 환불 정산이 #29 이전 주문의 레거시 폴백(「묶음 = 같은 상품」, 상품의 **현재** 배송비)을 탄다.
2618
+ * 이 키를 만나면 배송비 환불·차감이 0이고 `orders.delivery_fee_settlements`에 항목을 만들지 않는다.
2619
+ * 정책 지문 키(`b:…`)와 겹치지 않는다.
2620
+ */
2621
+ const NO_SHIPPING_BUNDLE_KEY = "none";
2622
+ /** 배송비 유형 — 무료·유료·조건부 무료 */
2623
+ const deliveryTypeSchema = z.enum([
2624
+ "FREE",
2625
+ "PAID",
2626
+ "CONDITIONAL_FREE"
2627
+ ]);
2628
+ /** 파일 다운로드 기본값 — 횟수 5회·기간 30일(#48 결정 8) */
2629
+ const DOWNLOAD_DEFAULTS = {
2630
+ limit: 5,
2631
+ days: 30
2632
+ };
2633
+ const KNOWN_FULFILLMENT_TYPES = fulfillmentTypeSchema.options;
2634
+ const ADDRESS_ID_DESCRIPTIONS = {
2635
+ shipping: "출고지 주소 id(`GET /v1/seller-addresses?type=SHIPPING`). 등록에서 생략하면 그 시점 스토어 기본 출고지를 저장한다. `null`이면 비워 둔다(스토어 기본 출고지를 따른다). 이 스토어의 `SHIPPING` 주소가 아니면 거부한다",
2636
+ return: "반품지 주소 id(`GET /v1/seller-addresses?type=RETURN`). 등록에서 생략하면 그 시점 스토어 기본 반품지를 저장한다. `null`이면 비워 둔다(스토어 기본 반품지를 따른다). 이 스토어의 `RETURN` 주소가 아니면 거부한다",
2637
+ pickup: "방문 수령지 주소 id(`GET /v1/seller-addresses?type=PICKUP`). 등록에서 생략하면 그 시점 스토어 기본 방문 수령지를 저장하고, 기본 방문 수령지도 없으면 400 `PICKUP_ADDRESS_REQUIRED`다. 이 스토어의 `PICKUP` 주소가 아니면 거부한다"
2638
+ };
2639
+ /**
2640
+ * 배송비 필드 사이의 일관성 — FREE의 배송비, 조건부 무료가 아닌 상품의 무료 기준을 조용히 0·null로 바꿔 저장하지 않고 400으로 돌려준다
2641
+ * (셀러가 저장됐다고 믿는 값이 사라지지 않게). 조건부 무료는 기준 금액이 필수다 — 기준 없는 조건부 무료는 계산상 유료 배송이다.
2642
+ */
2643
+ function refineShippingConsistency(value, ctx) {
2644
+ if (value.deliveryType === "FREE" && value.deliveryFee !== void 0 && value.deliveryFee !== 0) ctx.addIssue({
2645
+ code: "custom",
2646
+ path: ["deliveryFee"],
2647
+ message: "무료 배송(FREE)의 배송비는 0이거나 생략해야 합니다"
2648
+ });
2649
+ const threshold = value.conditionalFreeAmount;
2650
+ const hasThreshold = threshold !== void 0 && threshold !== null;
2651
+ if (value.deliveryType === "CONDITIONAL_FREE" && !hasThreshold) ctx.addIssue({
2652
+ code: "custom",
2653
+ path: ["conditionalFreeAmount"],
2654
+ message: "조건부 무료(CONDITIONAL_FREE)는 무료 기준 금액이 필요합니다"
2655
+ });
2656
+ if (value.deliveryType !== "CONDITIONAL_FREE" && hasThreshold) ctx.addIssue({
2657
+ code: "custom",
2658
+ path: ["conditionalFreeAmount"],
2659
+ message: "무료 기준 금액은 조건부 무료(CONDITIONAL_FREE)에서만 받습니다"
2660
+ });
2661
+ }
2662
+ const deliveryTypeDescription = "배송비 유형. `FREE` 무료, `PAID` 유료, `CONDITIONAL_FREE` 조건부 무료";
2663
+ const shippingCreateSchema = z.strictObject({
2664
+ deliveryType: deliveryTypeSchema.default("PAID").describe(deliveryTypeDescription),
2665
+ deliveryFee: z.number().int().min(0).optional().describe("배송비. 원 단위. 생략하면 스토어 기본 배송비(`GET /v1/store`의 `shippingPolicy.baseDeliveryFee`)를 저장한다. FREE면 0이거나 생략한다"),
2666
+ conditionalFreeAmount: z.number().int().min(0).nullable().optional().describe("무료 배송이 되는 묶음 소계. 원 단위. CONDITIONAL_FREE에서만 받고 필수다"),
2667
+ shippingAddressId: z.string().nullable().optional().describe(ADDRESS_ID_DESCRIPTIONS.shipping),
2668
+ returnAddressId: z.string().nullable().optional().describe(ADDRESS_ID_DESCRIPTIONS.return)
2669
+ }).superRefine(refineShippingConsistency);
2670
+ const downloadCreateSchema = z.strictObject({
2671
+ limit: z.number().int().min(1).max(100).default(DOWNLOAD_DEFAULTS.limit).describe("다운로드 횟수 한도(수량 1개당). 생략하면 5회"),
2672
+ days: z.number().int().min(1).max(3650).default(DOWNLOAD_DEFAULTS.days).describe("다운로드 기간(제공일부터 일수). 생략하면 30일"),
2673
+ stockTracking: z.boolean().default(false).describe("재고를 세는가. 생략하면 false(재고와 무관하게 판다)"),
2674
+ withdrawalRestricted: z.boolean().default(false).describe("청약철회 제한 대상인가. true면 주문서가 제한 고지 동의를 받고, 첫 다운로드 뒤 구매자 단순 변심 취소·반품을 받지 않는다")
2675
+ });
2676
+ const codeCreateSchema = z.strictObject({
2677
+ mode: fulfillmentModeSchema.default("AUTO").describe("제공 방식. `AUTO`는 코드 풀에서 결제 확정과 함께 배정하고, `MANUAL`은 셀러가 제공 처리 때 코드를 넘긴다"),
2678
+ withdrawalRestricted: z.boolean().default(false).describe("청약철회 제한 대상인가. true면 첫 열람 뒤 구매자 단순 변심 취소·반품을 받지 않는다")
2679
+ });
2680
+ const serviceCreateSchema = z.strictObject({ mode: fulfillmentModeSchema.default("MANUAL").describe("제공 방식. `AUTO`는 결제 확정과 함께 제공 완료, `MANUAL`은 셀러가 제공 처리한다") });
2681
+ const pickupCreateSchema = z.strictObject({ addressId: z.string().optional().describe(ADDRESS_ID_DESCRIPTIONS.pickup) });
2682
+ /**
2683
+ * 상품 이행 — 등록(POST). 유형 키를 생략하면 그 유형의 기본값이다(`{ "type": "DOWNLOAD" }`만으로 등록할 수 있다).
2684
+ */
2685
+ const productFulfillmentCreateSchema = z.discriminatedUnion("type", [
2686
+ z.strictObject({
2687
+ type: z.literal("SHIPPING"),
2688
+ shipping: shippingCreateSchema.optional()
2689
+ }),
2690
+ z.strictObject({
2691
+ type: z.literal("DOWNLOAD"),
2692
+ download: downloadCreateSchema.default({
2693
+ limit: DOWNLOAD_DEFAULTS.limit,
2694
+ days: DOWNLOAD_DEFAULTS.days,
2695
+ stockTracking: false,
2696
+ withdrawalRestricted: false
2697
+ })
2698
+ }),
2699
+ z.strictObject({
2700
+ type: z.literal("CODE"),
2701
+ code: codeCreateSchema.default({
2702
+ mode: "AUTO",
2703
+ withdrawalRestricted: false
2704
+ })
2705
+ }),
2706
+ z.strictObject({
2707
+ type: z.literal("SERVICE"),
2708
+ service: serviceCreateSchema.default({ mode: "MANUAL" })
2709
+ }),
2710
+ z.strictObject({
2711
+ type: z.literal("PICKUP"),
2712
+ pickup: pickupCreateSchema.default({})
2713
+ })
2714
+ ]);
2715
+ const shippingUpdateSchema = z.strictObject({
2716
+ deliveryType: deliveryTypeSchema.describe(deliveryTypeDescription),
2717
+ deliveryFee: z.number().int().min(0).describe("배송비. 원 단위. FREE면 0"),
2718
+ conditionalFreeAmount: z.number().int().min(0).nullable().describe("무료 배송이 되는 묶음 소계. CONDITIONAL_FREE면 필수, 아니면 null"),
2719
+ shippingAddressId: z.string().nullable().describe(ADDRESS_ID_DESCRIPTIONS.shipping),
2720
+ returnAddressId: z.string().nullable().describe(ADDRESS_ID_DESCRIPTIONS.return)
2721
+ }).superRefine(refineShippingConsistency);
2722
+ /**
2723
+ * 상품 이행 — 수정(PUT·PATCH). 통째 교체이고 유형 키와 그 안의 필드가 **모두 필수**다. 서버는 현재 값과 병합하지 않는다.
2724
+ * `fulfillment` 자체를 생략하면 이행을 바꾸지 않는다.
2725
+ */
2726
+ const productFulfillmentUpdateSchema = z.discriminatedUnion("type", [
2727
+ z.strictObject({
2728
+ type: z.literal("SHIPPING"),
2729
+ shipping: shippingUpdateSchema
2730
+ }),
2731
+ z.strictObject({
2732
+ type: z.literal("DOWNLOAD"),
2733
+ download: z.strictObject({
2734
+ limit: z.number().int().min(1).max(100),
2735
+ days: z.number().int().min(1).max(3650),
2736
+ stockTracking: z.boolean(),
2737
+ withdrawalRestricted: z.boolean()
2738
+ })
2739
+ }),
2740
+ z.strictObject({
2741
+ type: z.literal("CODE"),
2742
+ code: z.strictObject({
2743
+ mode: fulfillmentModeSchema,
2744
+ withdrawalRestricted: z.boolean()
2745
+ })
2746
+ }),
2747
+ z.strictObject({
2748
+ type: z.literal("SERVICE"),
2749
+ service: z.strictObject({ mode: fulfillmentModeSchema })
2750
+ }),
2751
+ z.strictObject({
2752
+ type: z.literal("PICKUP"),
2753
+ pickup: z.strictObject({ addressId: z.string().describe(ADDRESS_ID_DESCRIPTIONS.pickup) })
2754
+ })
2755
+ ]);
2756
+ /**
2757
+ * 저장된 상품 이행 — 알려진 유형의 닫힌 유니온. 서버의 상품 엔티티와 응답이 이 모양을 쓴다.
2758
+ * 응답 파싱은 `productFulfillmentResponseSchema`(열린 유니온)를 쓴다.
2759
+ */
2760
+ const productFulfillmentSchema = z.discriminatedUnion("type", [
2761
+ z.object({
2762
+ type: z.literal("SHIPPING"),
2763
+ shipping: z.object({
2764
+ deliveryType: deliveryTypeSchema,
2765
+ deliveryFee: z.number().int().describe("배송비. 원 단위. FREE면 0"),
2766
+ conditionalFreeAmount: z.number().int().nullable().describe("조건부 무료 기준 묶음 소계. CONDITIONAL_FREE가 아니면 null"),
2767
+ shippingAddressId: z.string().nullable().describe("출고지 주소 id. null이면 스토어 기본 출고지를 따른다"),
2768
+ returnAddressId: z.string().nullable().describe("반품지 주소 id. null이면 스토어 기본 반품지를 따른다")
2769
+ })
2770
+ }),
2771
+ z.object({
2772
+ type: z.literal("DOWNLOAD"),
2773
+ download: z.object({
2774
+ limit: z.number().int().describe("다운로드 횟수 한도(수량 1개당)"),
2775
+ days: z.number().int().describe("다운로드 기간(제공일부터 일수)"),
2776
+ stockTracking: z.boolean().describe("재고를 세는가"),
2777
+ withdrawalRestricted: z.boolean().describe("청약철회 제한 대상인가")
2778
+ })
2779
+ }),
2780
+ z.object({
2781
+ type: z.literal("CODE"),
2782
+ code: z.object({
2783
+ mode: fulfillmentModeSchema.describe("제공 방식"),
2784
+ withdrawalRestricted: z.boolean().describe("청약철회 제한 대상인가")
2785
+ })
2786
+ }),
2787
+ z.object({
2788
+ type: z.literal("SERVICE"),
2789
+ service: z.object({ mode: fulfillmentModeSchema.describe("제공 방식") })
2790
+ }),
2791
+ z.object({
2792
+ type: z.literal("PICKUP"),
2793
+ pickup: z.object({ addressId: z.string().nullable().describe("방문 수령지 주소 id. null은 수령지가 지워진 상품이다(수령지를 다시 지정할 때까지 팔 수 없다)") })
2794
+ })
2795
+ ]);
2796
+ /**
2797
+ * 열린 응답 유니온 — 알려진 유형은 판별 유니온으로 검사하고, **모르는 유형만** `{ type: "OTHER", rawType }`로 읽는다.
2798
+ *
2799
+ * `type: z.string()` 가지를 그대로 두면 TS가 `type === "SHIPPING"`으로 좁혀도 문자열 가지가 남는다. `OTHER` 리터럴로 바꿔 좁히기를
2800
+ * 지킨다. 알려진 유형의 모양이 틀린 응답은 폴백으로 숨기지 않고 파싱 오류로 둔다.
2801
+ */
2802
+ function openFulfillmentUnion(known) {
2803
+ return z.union([known, z.looseObject({ type: z.string().refine((type) => !KNOWN_FULFILLMENT_TYPES.includes(type), { message: "알려진 유형은 해당 유형의 모양이어야 합니다" }) }).transform((value) => ({
2804
+ type: "OTHER",
2805
+ rawType: value.type
2806
+ }))]);
2807
+ }
2808
+ const productFulfillmentResponseSchema = openFulfillmentUnion(productFulfillmentSchema).describe("상품 이행. `type`이 유형이고 같은 이름의 키가 유형별 속성이다. 값이 추가될 수 있어 SDK는 모르는 유형을 `{ type: \"OTHER\", rawType }`로 읽는다");
2809
+ /** 제공 방식 — CODE·SERVICE는 저장된 값, DOWNLOAD는 AUTO, PICKUP은 MANUAL, 배송 상품은 null */
2810
+ function fulfillmentModeOf(fulfillment) {
2811
+ switch (fulfillment.type) {
2812
+ case "CODE": return fulfillment.code.mode;
2813
+ case "SERVICE": return fulfillment.service.mode;
2814
+ default: return FULFILLMENT_RULES[fulfillment.type].defaultMode;
2815
+ }
2816
+ }
2817
+ /** 재고를 세는가 — 파일 다운로드만 끌 수 있다 */
2818
+ function stockTrackingOf(fulfillment) {
2819
+ return fulfillment.type === "DOWNLOAD" ? fulfillment.download.stockTracking : true;
2820
+ }
2821
+ /** 청약철회 제한 대상인가 — 파일 다운로드·코드 발급만 켤 수 있다(전자상거래법 제17조 제2항 제5호, 디지털 콘텐츠) */
2822
+ function withdrawalRestrictedOf(fulfillment) {
2823
+ if (fulfillment.type === "DOWNLOAD") return fulfillment.download.withdrawalRestricted;
2824
+ if (fulfillment.type === "CODE") return fulfillment.code.withdrawalRestricted;
2825
+ return false;
2826
+ }
2827
+ /** 코드 풀을 쓰는가 — 코드 발급 + 자동 제공. 재고가 판매 가능 코드 수다 */
2828
+ function usesCodePool(fulfillment) {
2829
+ return fulfillment.type === "CODE" && fulfillment.code.mode === "AUTO";
2830
+ }
2831
+ /** 결제 확정과 함께 제공까지 끝내는가 — 비실물 + 자동 제공 */
2832
+ function autoFulfillableOf(fulfillment) {
2833
+ return !FULFILLMENT_RULES[fulfillment.type].requiresShipping && fulfillmentModeOf(fulfillment) === "AUTO";
2834
+ }
2835
+ /**
2836
+ * 주문상품의 주문 시점 이행 — 결제 확정이 주문서 줄에서 굳힌다. 상품의 현재 값이 아니다. 서버는 주문상품 컬럼
2837
+ * (`fulfillment_type`·`delivery_bundle_key`·`stock_tracked`·`withdrawal_restricted`)과 jsonb(`fulfillment_snapshot`)를 합쳐 이 모양을 만든다.
2838
+ *
2839
+ * `mode`는 **주문 시점 상품의 방식**이다. 실제 제공 방식은 제공 기록(`fulfillment.mode`)에 있고 다를 수 있다(자동 제공 상품의 수동 복구).
2840
+ */
2841
+ const orderItemFulfillmentSnapshotSchema = z.discriminatedUnion("type", [
2842
+ z.object({
2843
+ type: z.literal("SHIPPING"),
2844
+ shipping: z.object({
2845
+ bundleKey: z.string().nullable().describe("배송 묶음 키. 같은 키의 주문상품이 배송비를 한 번 낸 한 묶음이다. #29 이전 주문은 null"),
2846
+ returnAddressId: z.string().nullable().describe("주문 시점 반품지 주소 id. null이거나 지워진 주소면 스토어 기본 반품지로 안내한다")
2847
+ })
2848
+ }),
2849
+ z.object({
2850
+ type: z.literal("DOWNLOAD"),
2851
+ download: z.object({
2852
+ limit: z.number().int().describe("주문 시점 다운로드 횟수 한도(수량 1개당)"),
2853
+ days: z.number().int().describe("주문 시점 다운로드 기간(제공일부터 일수)"),
2854
+ stockTracked: z.boolean().describe("주문 시점에 재고를 뺐는가"),
2855
+ withdrawalRestricted: z.boolean().describe("구매자가 제한 고지에 동의한 청약철회 제한 주문상품인가")
2856
+ })
2857
+ }),
2858
+ z.object({
2859
+ type: z.literal("CODE"),
2860
+ code: z.object({
2861
+ mode: z.string().describe("주문 시점 상품의 제공 방식. 현재 값은 `AUTO`·`MANUAL`"),
2862
+ withdrawalRestricted: z.boolean().describe("구매자가 제한 고지에 동의한 청약철회 제한 주문상품인가")
2863
+ })
2864
+ }),
2865
+ z.object({
2866
+ type: z.literal("SERVICE"),
2867
+ service: z.object({ mode: z.string().describe("주문 시점 상품의 제공 방식. 현재 값은 `AUTO`·`MANUAL`") })
2868
+ }),
2869
+ z.object({
2870
+ type: z.literal("PICKUP"),
2871
+ pickup: z.object({ addressId: z.string().nullable().describe("주문 시점 방문 수령지 주소 id") })
2872
+ })
2873
+ ]);
2874
+ const orderItemFulfillmentSnapshotResponseSchema = openFulfillmentUnion(orderItemFulfillmentSnapshotSchema).describe("주문 시점 이행 스냅샷. 상품을 나중에 고쳐도 바뀌지 않는다. 실제 제공 기록은 `fulfillment`에 있다. 값이 추가될 수 있어 SDK는 모르는 유형을 `OTHER`로 읽는다");
2875
+ //#endregion
1536
2876
  //#region src/schemas/payment.ts
1537
2877
  /**
1538
2878
  * 스토어 결제(PG) 설정과 매출 리포트 계약.
@@ -1900,6 +3240,22 @@ const shippingAddressSchema = z.object({
1900
3240
  deliveryMemo: z.string().nullish(),
1901
3241
  entranceCode: z.string().nullish()
1902
3242
  });
3243
+ /** 비실물 주문상품의 제공 정보 (이슈 #48) */
3244
+ const orderItemFulfillmentSchema = z.object({
3245
+ status: z.string().describe("제공 상태. `FULFILLED`(제공함)·`REVOKED`(환불 등으로 회수함). 값이 추가될 수 있어 문자열로 받는다"),
3246
+ mode: z.string().nullable().describe("제공 방식. `AUTO`·`MANUAL`"),
3247
+ fulfilledAt: z.string().describe("제공 시각 (ISO 8601)"),
3248
+ note: z.string().nullable().describe("셀러가 남긴 이용 안내. 없으면 null"),
3249
+ codes: z.array(orderItemCodeSchema).optional().default([]).describe("코드 발급 상품에 배정한 코드의 가린 값. 평문은 싣지 않는다. 코드 상품이 아니면 빈 배열. 서버는 항상 싣는다"),
3250
+ download: orderItemDownloadSchema.nullable().optional().default(null).describe("파일 다운로드 상품의 횟수·기한·파일 목록. 파일 주소는 싣지 않는다. 다른 유형은 null. 서버는 항상 싣는다"),
3251
+ mail: z.object({
3252
+ status: z.string().describe("제공 안내 메일 상태. 현재 값은 `PENDING`(보낼 예정)·`SENDING`·`SENT`·`FAILED`·`SKIPPED`(보내지 않음: 스토어 설정 꺼짐·받을 주소 없음)이고 값이 추가될 수 있다"),
3253
+ sentAt: z.string().nullable(),
3254
+ errorCode: z.string().nullable().describe("실패·건너뜀 사유 코드")
3255
+ }).nullable().optional().default(null).describe("구매자에게 보낸 제공 안내 메일(이슈 #48). 기록이 없으면 null. 서버는 항상 싣는다")
3256
+ });
3257
+ /** 제공 안내 메일 재발송 결과 */
3258
+ const fulfillmentMailResendSchema = z.object({ mailStatus: z.string().describe("재발송 뒤 메일 상태 — `SENT`·`PENDING`(곧 다시 보낸다)·`FAILED`·`SKIPPED`") });
1903
3259
  const orderItemSchema = z.object({
1904
3260
  orderItemId: z.string(),
1905
3261
  orderId: z.string(),
@@ -1914,16 +3270,19 @@ const orderItemSchema = z.object({
1914
3270
  optionId: z.string().nullable(),
1915
3271
  optionName: z.string().nullable(),
1916
3272
  sellerCode: z.string().nullable(),
3273
+ fulfillmentSnapshot: orderItemFulfillmentSnapshotResponseSchema.describe("주문 시점 이행 스냅샷(이슈 #57). `type`이 주문 시점 상품 유형이다. 배송 상품이 아니면 발송·배송완료 대신 제공 처리를 하고, `DELIVERED`는 제공 완료(방문 수령은 수령 완료)다. 상품을 나중에 고쳐도 바뀌지 않는다. 실제 제공 기록은 `fulfillment`에 있다"),
1917
3274
  quantity: z.number().int().describe("주문 수량"),
1918
3275
  canceledQuantity: z.number().int().describe("취소·반품으로 환불된 누적 수량. 수량 단위 부분 취소가 없었으면 0이다"),
1919
3276
  activeQuantity: z.number().int().describe("살아 있는 수량 (`quantity` − `canceledQuantity`). 0이면 주문상품이 전량 취소됐다"),
1920
3277
  unitPrice: z.number().int(),
1921
3278
  discountAmount: z.number().int(),
1922
- totalPrice: z.number().int().describe("주문상품 금액 (할인 반영 단가 × 주문 수량)"),
3279
+ totalPrice: z.number().int().describe("주문상품 금액 (즉시할인 반영 단가 × 주문 수량). 쿠폰 할인 전 금액이다"),
3280
+ couponDiscountAmount: z.number().int().optional().default(0).describe("이 주문상품에 배분된 쿠폰 할인 — 상품 쿠폰 + 주문 쿠폰 배분. 실결제 금액은 `totalPrice − couponDiscountAmount`이고 취소·반품 환불은 실결제 금액을 수량 비율로 나눈다. 쿠폰이 없으면 0. 서버는 항상 싣는다"),
1923
3281
  hold: z.object({
1924
3282
  reason: z.string(),
1925
3283
  expectedDispatchDate: z.string()
1926
3284
  }).nullable(),
3285
+ fulfillment: orderItemFulfillmentSchema.nullable().optional().default(null).describe("비실물 상품의 제공 정보(이슈 #48). 제공 전이거나 배송 상품이면 null이다. 코드 평문·파일 주소는 싣지 않는다. 서버는 항상 싣는다"),
1927
3286
  autoDecisionDate: z.string().nullable(),
1928
3287
  sellerMemo: z.string().nullable(),
1929
3288
  orderedAt: z.string(),
@@ -1931,6 +3290,14 @@ const orderItemSchema = z.object({
1931
3290
  lastChangedType: changeTypeSchema,
1932
3291
  testPayment: z.boolean().describe("소속 주문이 테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았습니다")
1933
3292
  });
3293
+ /** 주문이 받은 쿠폰 한 장 (이슈 #47) */
3294
+ const orderDiscountSchema = z.object({
3295
+ couponId: z.string().describe("쿠폰 id"),
3296
+ name: z.string().describe("주문 시점 쿠폰 이름"),
3297
+ kind: z.string().describe("쿠폰 종류. 현재 값은 `ORDER`(주문 쿠폰)·`PRODUCT`(상품 쿠폰)·`DELIVERY`(배송비 쿠폰)이고 값이 추가될 수 있어 문자열로 받는다"),
3298
+ discountAmount: z.number().int().describe("이 쿠폰의 할인액 (원)"),
3299
+ issueId: z.string().nullable().describe("발급 쿠폰 id. 코드 입력형은 null")
3300
+ });
1934
3301
  const orderSchema = z.object({
1935
3302
  orderId: z.string(),
1936
3303
  orderedAt: z.string(),
@@ -1939,6 +3306,7 @@ const orderSchema = z.object({
1939
3306
  phone: z.string(),
1940
3307
  email: z.email().optional()
1941
3308
  }),
3309
+ requiresShipping: z.boolean().optional().default(true).describe("택배 배송이 필요한 주문상품이 있는가. false면 `shippingAddress`는 주문자 이름·연락처만 있고 주소는 빈 문자열이다. 서버는 항상 싣는다"),
1942
3310
  shippingAddress: shippingAddressSchema,
1943
3311
  payment: z.object({
1944
3312
  method: z.enum([
@@ -1951,13 +3319,17 @@ const orderSchema = z.object({
1951
3319
  ]).describe("method는 호환용이며 간편결제를 CARD로 표시한다. 실제 수단은 approvedMethod를 본다"),
1952
3320
  approvedMethod: z.string().nullable().optional().default(null).describe("실제 결제수단. method는 호환용이며 간편결제를 CARD로 표시한다. 실제 수단은 approvedMethod를 본다. 현재 값은 `pgPaymentMethodSchema`(`PgPaymentMethod`: CARD·BANK_TRANSFER·VIRTUAL_ACCOUNT·MOBILE·EASY_PAY)이고 매출 리포트·스토어프론트·환불 원장과 같은 값이다. 값이 추가될 수 있어 문자열로 받는다. PG가 알려 주지 않았으면 요청 수단, 시도 기록이 없는 오래된 주문은 null. 서버는 항상 싣는다"),
1953
3321
  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(),
3322
+ productAmount: z.number().int().describe("상품 금액 합 (즉시할인 반영, 쿠폰 전)"),
3323
+ deliveryFee: z.number().int().describe("배송비 (쿠폰 전)"),
3324
+ discountAmount: z.number().int().describe("즉시할인 합 (정가 − 할인 단가) × 수량. 쿠폰 할인은 `couponDiscountAmount`로 따로 온다"),
3325
+ couponDiscountAmount: z.number().int().optional().default(0).describe("상품·주문 쿠폰 할인 합계. 쿠폰이 없으면 0. 서버는 항상 싣는다"),
3326
+ deliveryDiscountAmount: z.number().int().optional().default(0).describe("배송비 쿠폰 할인. 쿠폰이 없으면 0. 서버는 항상 싣는다"),
3327
+ discounts: z.array(orderDiscountSchema).optional().default([]).describe("주문이 받은 쿠폰. 쿠폰이 없으면 빈 배열. 서버는 항상 싣는다"),
3328
+ totalAmount: z.number().int().describe("결제 금액 = productAmount + deliveryFee − couponDiscountAmount − deliveryDiscountAmount. 쿠폰이 없는 주문은 productAmount + deliveryFee다"),
1958
3329
  paidAt: z.string().nullable(),
1959
3330
  cashReceipt: cashReceiptSchema.nullable().optional().default(null).describe("현금영수증. 구매자가 계좌이체·가상계좌 결제에서 신청했을 때만 있고 그 밖에는 null. 서버는 항상 싣는다")
1960
3331
  }),
3332
+ withdrawalAgreedAt: z.string().nullable().optional().default(null).describe("청약철회 제한 안내에 동의한 시각(이슈 #48). 제한 상품이 없던 주문은 null. 서버는 항상 싣는다"),
1961
3333
  testPayment: z.boolean().describe("테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았습니다. 상품을 발송하지 않도록 주의하십시오"),
1962
3334
  items: z.array(orderItemSchema)
1963
3335
  });
@@ -1977,6 +3349,7 @@ const orderListQuerySchema = z.object({
1977
3349
  orderedTo: z.string().optional().describe("조회 끝 시각 (ISO 8601, 포함). 생략하면 orderedFrom부터 31일. 기간은 최대 31일"),
1978
3350
  status: orderItemStatusSchema.optional().describe("주문상품 상태 필터 — 이 상태의 주문상품이 하나라도 있는 주문"),
1979
3351
  keyword: z.string().optional().describe("주문번호(일치)·주문자명·수령인명(포함) 검색"),
3352
+ fulfillmentType: z.string().optional().describe("상품 유형 필터(이슈 #48) — 이 유형(`SHIPPING`·`DOWNLOAD`·`CODE`·`SERVICE`·`PICKUP`)의 주문상품이 하나라도 있는 주문"),
1980
3353
  testPayment: testPaymentQuery,
1981
3354
  ...pageQuery,
1982
3355
  size: z.string().optional().describe("페이지 크기 (기본 20, 최대 100)")
@@ -2078,14 +3451,29 @@ const cancelBySellerRequestSchema = z.object({
2078
3451
  detail: z.string().max(500).optional(),
2079
3452
  quantity: z.number().int().min(1).optional().describe("취소할 수량. 생략하면 남은 수량 전체다. 남은 수량보다 크면 400 `INVALID_QUANTITY`. 일부만 취소하면 주문상품은 상태를 유지한 채 `canceledQuantity`가 늘고, 전 수량이 소진되면 `CANCELED`가 된다")
2080
3453
  });
3454
+ /**
3455
+ * 제공 처리 요청 (이슈 #48) — `POST /v1/order-items/{orderItemId}/fulfill`. 수동 제공 비실물 주문상품(이용권·방문 수령 등)을
3456
+ * 제공 완료(`DELIVERED`)로 바꾼다.
3457
+ */
3458
+ const fulfillRequestSchema = z.object({
3459
+ note: z.string().trim().max(1e3).optional().describe("구매자에게 보일 이용 안내(예: 예약 방법, 수령 장소). 주문 상세에 실린다"),
3460
+ codes: z.array(z.string().trim().min(1).max(200)).min(1).max(100).optional().describe("수동 제공 코드 발급 상품의 코드 — 셀러 시스템이 발급한 코드를 넘긴다. 남은 수량만큼 보낸다(`CODE_COUNT_MISMATCH`). 암호화해 저장하고 구매자가 열람할 때만 평문으로 나간다. 다른 유형은 보내지 않는다")
3461
+ });
3462
+ /** 제공 처리 일괄 요청 — `POST /v1/order-items/fulfill` */
3463
+ const fulfillBulkRequestSchema = z.object({
3464
+ orderItemIds: z.array(z.string()).min(1).max(100),
3465
+ note: fulfillRequestSchema.shape.note
3466
+ });
2081
3467
  /** `POST /v1/order-items/{orderItemId}/cancel` 응답 — 셀러 직권취소 환불 결과 */
2082
3468
  const cancelBySellerResultSchema = z.object({
2083
3469
  orderItemId: z.string(),
2084
- refundAmount: z.number().int().describe("실제 환불한 금액 — 수량 비율 상품 금액 + 배송비 환불 − 조건부 무료배송 미달 차감"),
3470
+ refundAmount: z.number().int().describe("실제 환불한 금액 — 수량 비율 상품 실결제 금액(쿠폰 배분을 뺀 금액) + 배송비 환불 − 조건부 무료배송 미달 차감 + 되돌린 쿠폰 차감(`couponChargeRefundAmount`)"),
2085
3471
  quantity: z.number().int().describe("취소한 수량"),
2086
3472
  canceledQuantity: z.number().int().describe("취소 후 누적 취소 수량"),
2087
3473
  deliveryFeeRefundAmount: z.number().int().describe("환불에 포함된 배송비. 배송 묶음(같은 상품)이 모두 취소됐고 환불 정책이 켜져 있을 때만 0보다 크다"),
2088
3474
  deliveryFeeChargeAmount: z.number().int().describe("조건부 무료배송 기준 미달로 환불액에서 뺀 배송비. 정책이 꺼져 있으면 항상 0이다"),
3475
+ couponChargeAmount: z.number().int().optional().default(0).describe("쿠폰 조건 미달 차감. 판매자 취소는 차감하지 않으므로 늘 0이다. 서버는 항상 싣는다"),
3476
+ couponChargeRefundAmount: z.number().int().optional().default(0).describe("환불액에 더한, 앞선 환불에서 뺀 쿠폰 차감을 되돌린 몫. 서버는 항상 싣는다"),
2089
3477
  refundedAt: z.string()
2090
3478
  });
2091
3479
  /** `PUT /v1/order-items/{orderItemId}/memo` 응답 — 빈 문자열로 저장하면 메모가 지워져 `null`이다 */
@@ -2110,124 +3498,20 @@ const purchaseDecisionExtendResultSchema = z.object({
2110
3498
  */
2111
3499
  const addressChangeRequestListSchema = z.array(z.record(z.string(), z.unknown())).describe("배송지 변경 요청 목록 (현재 항상 빈 배열)");
2112
3500
  //#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
3501
  //#region src/schemas/upload.ts
2224
3502
  /**
2225
3503
  * 업로드 용도. 허용 확장자와 크기 상한이 용도별로 다르다.
2226
3504
  *
2227
3505
  * - `product-image`: 상품 목록·상세의 상품 이미지. 목록 썸네일로 쓰이므로 정지 이미지만 받는다.
2228
3506
  * - `description`: 상세설명 본문에 삽입하는 미디어. 동영상과 svg도 받는다.
3507
+ * - `digital-asset`: 파일 다운로드 상품의 판매 파일(이슈 #48). **비공개 객체**로 올라가고 공개 주소가 없다 — 구매자는 제공된
3508
+ * 주문상품에서 수명 300초의 서명 주소로만 받는다.
2229
3509
  */
2230
- const uploadPurposeSchema = z.enum(["product-image", "description"]);
3510
+ const uploadPurposeSchema = z.enum([
3511
+ "product-image",
3512
+ "description",
3513
+ "digital-asset"
3514
+ ]);
2231
3515
  const MB = 1048576;
2232
3516
  /**
2233
3517
  * 용도별 업로드 제한 — 서버(`POST /v1/uploads`)와 클라이언트가 공유하는 단일 원천이다.
@@ -2261,8 +3545,35 @@ const UPLOAD_PURPOSE_LIMITS = {
2261
3545
  "mov"
2262
3546
  ],
2263
3547
  maxBytes: 50 * MB
3548
+ },
3549
+ "digital-asset": {
3550
+ extensions: [
3551
+ "pdf",
3552
+ "epub",
3553
+ "zip",
3554
+ "mp3",
3555
+ "wav",
3556
+ "flac",
3557
+ "m4a",
3558
+ "mp4",
3559
+ "mov",
3560
+ "jpg",
3561
+ "jpeg",
3562
+ "png",
3563
+ "webp",
3564
+ "psd",
3565
+ "ai",
3566
+ "txt",
3567
+ "csv",
3568
+ "xlsx",
3569
+ "docx",
3570
+ "pptx"
3571
+ ],
3572
+ maxBytes: 200 * MB
2264
3573
  }
2265
3574
  };
3575
+ /** 비공개 업로드(판매 파일)의 PUT 필수 헤더 — CDN이 이 값으로 공개 조회를 막는다 */
3576
+ const PRIVATE_UPLOAD_HEADERS = { "x-amz-meta-visibility": "private" };
2266
3577
  /** 파일명의 소문자 확장자. 확장자가 없으면 `""` */
2267
3578
  function uploadExtensionOf(filename) {
2268
3579
  const dot = filename.lastIndexOf(".");
@@ -2300,10 +3611,12 @@ const uploadRequestSchema = z.object({
2300
3611
  size: z.number().int().positive().optional().describe("파일 바이트 크기. 주면 용도별 상한을 발급 전에 검사한다")
2301
3612
  });
2302
3613
  const uploadTicketSchema = z.object({
2303
- /** 업로드 완료 후 공개될 주소 */
3614
+ /** 업로드 완료 후 공개될 주소. 비공개 업로드(`digital-asset`)는 이 주소로 받을 수 없다 */
2304
3615
  url: z.url(),
2305
3616
  /** 파일 바이트를 PUT할 presigned URL. 1시간 유효 */
2306
- uploadUrl: z.url()
3617
+ uploadUrl: z.url(),
3618
+ key: z.string().optional().describe("객체 키. 비공개 업로드(`digital-asset`)는 이 값으로 상품에 파일을 연결한다(`POST /v1/products/{productId}/digital-assets`). 공개 업로드에는 없다"),
3619
+ headers: z.record(z.string(), z.string()).optional().default({}).describe("PUT에 반드시 실을 헤더. 비공개 업로드는 `x-amz-meta-visibility: private`이고 빠뜨리면 공개 객체가 된다. 공개 업로드는 빈 객체다")
2307
3620
  });
2308
3621
  //#endregion
2309
3622
  //#region src/schemas/product.ts
@@ -2313,11 +3626,6 @@ const productStatusSchema = z.enum([
2313
3626
  "OUT_OF_STOCK",
2314
3627
  "CLOSED"
2315
3628
  ]);
2316
- const deliveryTypeSchema = z.enum([
2317
- "FREE",
2318
- "PAID",
2319
- "CONDITIONAL_FREE"
2320
- ]);
2321
3629
  const productSummarySchema = z.object({
2322
3630
  productId: z.string(),
2323
3631
  name: z.string(),
@@ -2330,6 +3638,7 @@ const productSummarySchema = z.object({
2330
3638
  thumbnailUrl: z.url().optional(),
2331
3639
  /** optionGroups.length > 0 */
2332
3640
  hasOptions: z.boolean(),
3641
+ fulfillment: z.object({ type: fulfillmentTypeResponseSchema }).describe("상품 이행의 유형. 유형별 속성은 단건 조회(`GET /v1/products/{productId}`)의 `fulfillment`에 있다"),
2333
3642
  updatedAt: z.string()
2334
3643
  });
2335
3644
  /** 옵션 축(그룹)이 가지는 값 하나 — 예: 축 "색상"의 값 "블랙" */
@@ -2364,6 +3673,14 @@ const productVariantSchema = z.object({
2364
3673
  sellerCode: z.string().nullable(),
2365
3674
  usable: z.boolean()
2366
3675
  });
3676
+ const discountSchema = z.object({
3677
+ productId: z.string(),
3678
+ discountType: z.enum(["RATE", "AMOUNT"]),
3679
+ discountValue: z.number().int(),
3680
+ discountedPrice: z.number().int(),
3681
+ startAt: z.string().nullable(),
3682
+ endAt: z.string().nullable()
3683
+ });
2367
3684
  const productSchema = productSummarySchema.extend({
2368
3685
  description: z.string().optional(),
2369
3686
  /**
@@ -2380,14 +3697,14 @@ const productSchema = productSummarySchema.extend({
2380
3697
  optionGroups: z.array(optionGroupSchema),
2381
3698
  /** 모든 상품이 최소 1개 variant를 갖는다 (옵션 없으면 default variant 1개) */
2382
3699
  variants: z.array(productVariantSchema),
2383
- deliveryType: deliveryTypeSchema.optional(),
2384
- deliveryFee: z.number().int().optional(),
2385
- conditionalFreeAmount: z.number().int().nullable().optional(),
2386
- returnFee: z.number().int().optional(),
2387
- exchangeFee: z.number().int().optional(),
2388
- refundPolicy: refundPolicyPatchSchema.nullable().optional().describe("상품별 환불 정책 덮어쓰기. null이면 스토어 기본 환불 정책을 쓴다"),
2389
- shippingAddressId: z.string().nullable().optional(),
2390
- returnAddressId: z.string().nullable().optional(),
3700
+ fulfillment: productFulfillmentResponseSchema,
3701
+ fulfillmentTypeLocked: z.boolean().describe("판매 이력(주문상품)이 있어 상품 유형(`fulfillment.type`)을 바꿀 수 없는가. true면 다른 유형으로 수정하는 요청이 409 `FULFILLMENT_TYPE_LOCKED`다. 같은 유형 안의 속성은 바꿀 수 있다"),
3702
+ refundPolicy: productRefundPolicyPatchSchema.nullable().describe("상품별 환불 정책 덮어쓰기(상품이 정한 항목만). null이면 스토어 기본 환불 정책을 쓴다. 반품·교환 배송비(`returnDeliveryFee`·`exchangeDeliveryFee`)는 배송 상품에만 있다"),
3703
+ /**
3704
+ * 설정된 즉시할인. `GET /products/{productId}/discount`의 응답과 같은 형태다(이슈 #51).
3705
+ * 할인이 없으면 null이다. 이 필드를 싣기 전 서버의 응답에는 없으므로 optional이다.
3706
+ */
3707
+ discount: discountSchema.nullable().optional().describe("설정된 즉시할인. GET /v1/products/{productId}/discount 응답과 같은 형태다. 할인이 없으면 null"),
2391
3708
  createdAt: z.string().optional()
2392
3709
  });
2393
3710
  /** 상품 하나에 등록할 수 있는 이미지 최대 장수 — 첫 장이 대표(썸네일)다 */
@@ -2509,21 +3826,12 @@ const variantStockSchema = z.object({
2509
3826
  stockQuantity: z.number().int()
2510
3827
  });
2511
3828
  /**
2512
- * 출고지·반품지 지정 문구 — POST·PUT·PATCH가 같은 규칙을 쓰므로 설명도 한 곳이다 (이슈 #30).
3829
+ * 상품 쓰기 요청의 공통 필드 — 등록(POST)과 전체 수정(PUT)이 같다. 이행(`fulfillment`)만 두 요청의 스키마가 다르다.
2513
3830
  *
2514
- * 세 값이 뜻이 다르다. **생략**은 「정하지 않음」이라 서버가 그 시점 스토어 기본 주소를 박아 저장하고,
2515
- * **null**은 「스토어 기본을 따른다」는 명시적 비우기다. 주소록 항목은 상품이 참조 중이면 지울 수 없어
2516
- * (`409 ADDRESS_IN_USE`) 참조를 푸는 수단이 필요한데, 생략을 기본값으로 되채우면 기본 주소는 영원히
2517
- * 풀리지 않는다 — null이 그 경로다. 배송 묶음 판정은 null을 그 시점 스토어 기본 출고지로 정규화하므로
2518
- * 두 표현이 배송비를 갈라놓지 않는다.
3831
+ * 최상위가 `strictObject`다. 모르는 필드는 400이다 — 이슈 #57 이전의 평면 필드(`deliveryFee`·`fulfillmentType`·`returnFee` 등)를
3832
+ * 보낸 요청이 조용히 무시되지 않게 한다. 옛 필드는 `fulfillment.{유형}.…`과 `refundPolicy`로 옮겼다.
2519
3833
  */
2520
- const ADDRESS_DESCRIPTIONS = {
2521
- shipping: "출고지 주소 id(`GET /v1/seller-addresses?type=SHIPPING`). 생략하면 스토어 기본 출고지를 저장하고, `null`이면 비워 둔다(스토어 기본 출고지를 따른다). 이 스토어의 `SHIPPING` 주소가 아니면 거부한다",
2522
- return: "반품지 주소 id(`GET /v1/seller-addresses?type=RETURN`). 생략하면 스토어 기본 반품지를 저장하고, `null`이면 비워 둔다(스토어 기본 반품지를 따른다). 이 스토어의 `RETURN` 주소가 아니면 거부한다",
2523
- patchShipping: "출고지 주소 id. **생략은 바꾸지 않음**이고 `null`은 비우기다(스토어 기본 출고지를 따른다). 이 스토어의 `SHIPPING` 주소가 아니면 거부한다",
2524
- patchReturn: "반품지 주소 id. **생략은 바꾸지 않음**이고 `null`은 비우기다(스토어 기본 반품지를 따른다). 이 스토어의 `RETURN` 주소가 아니면 거부한다"
2525
- };
2526
- const productCreateRequestSchema = z.object({
3834
+ const productWriteShape = {
2527
3835
  name: z.string().min(1).max(100),
2528
3836
  categoryId: z.string().min(1, "카테고리를 선택해주십시오"),
2529
3837
  salePrice: z.number().int().min(10).multipleOf(10, "판매가는 10원 단위여야 합니다 (INVALID_PRICE_UNIT)").describe("판매가. 원(KRW) 단위 정수, 최소 10원, 10원 단위"),
@@ -2539,22 +3847,25 @@ const productCreateRequestSchema = z.object({
2539
3847
  attributeId: z.string(),
2540
3848
  value: z.string()
2541
3849
  })).optional(),
2542
- deliveryType: deliveryTypeSchema.default("PAID").describe("배송비 유형. FREE 무료, PAID 유료, CONDITIONAL_FREE 조건부 무료"),
2543
- deliveryFee: z.number().int().min(0).optional().describe("배송비. 원 단위. **생략하면 스토어 기본 배송비**(`GET /v1/store`의 `shippingPolicy.baseDeliveryFee`)를 쓴다. `deliveryType`이 FREE면 0으로 저장한다"),
2544
- conditionalFreeAmount: z.number().int().optional().describe("CONDITIONAL_FREE일 때 무료 배송이 되는 주문 금액. 원 단위"),
2545
- returnFee: z.number().int().default(0).describe("반품 배송비. 원 단위. `refundPolicy.returnDeliveryFee`와 같은 값이다"),
2546
- exchangeFee: z.number().int().default(0).describe("교환 배송비. 원 단위. `refundPolicy.exchangeDeliveryFee`와 같은 값이다"),
2547
- refundPolicy: refundPolicyPatchSchema.nullish().describe("상품별 환불 정책 — 준 항목만 스토어 기본을 덮어쓴다. `null`이나 생략은 스토어 기본을 그대로 쓴다는 뜻이다. `returnDeliveryFee`·`exchangeDeliveryFee`를 주면 `returnFee`·`exchangeFee`도 같은 값으로 맞춘다"),
2548
- shippingAddressId: z.string().nullable().optional().describe(ADDRESS_DESCRIPTIONS.shipping),
2549
- returnAddressId: z.string().nullable().optional().describe(ADDRESS_DESCRIPTIONS.return)
2550
- }).extend(optionModelRequestSchema.shape);
2551
- const productPatchRequestSchema = z.object({
3850
+ refundPolicy: productRefundPolicyPatchSchema.nullish().describe("상품별 환불 정책 — 준 항목만 스토어 기본을 덮어쓴다. `null`이나 생략은 스토어 기본을 그대로 쓴다는 뜻이다. 반품·교환 배송비(`returnDeliveryFee`·`exchangeDeliveryFee`)는 배송 상품에만 줄 수 있다(다른 유형은 400)"),
3851
+ ...optionModelRequestSchema.shape
3852
+ };
3853
+ const productCreateRequestSchema = z.strictObject({
3854
+ ...productWriteShape,
3855
+ fulfillment: productFulfillmentCreateSchema.optional().describe("상품 이행. `type`이 유형이고 같은 이름의 키가 유형별 속성이다. 생략하면 배송 상품(기본값)이다. 배송 상품이 아닌 유형은 판매가 열린 유형만 등록할 수 있다(409 `FULFILLMENT_TYPE_NOT_AVAILABLE`)")
3856
+ });
3857
+ const fulfillmentUpdateDescription = "상품 이행. 생략하면 바꾸지 않는다. 주면 유형 안 필드를 모두 담아 통째로 바꾼다(현재 값과 병합하지 않는다). 판매 이력이 있으면 유형을 바꿀 수 없다(409 `FULFILLMENT_TYPE_LOCKED`)";
3858
+ /** 전체 수정(PUT) — 공통 필드는 등록과 같고, 이행은 생략하면 현재 값 유지, 주면 필드가 모두 필수다 */
3859
+ const productReplaceRequestSchema = z.strictObject({
3860
+ ...productWriteShape,
3861
+ fulfillment: productFulfillmentUpdateSchema.optional().describe(fulfillmentUpdateDescription)
3862
+ });
3863
+ const productPatchRequestSchema = z.strictObject({
2552
3864
  name: z.string().max(100).optional(),
2553
3865
  salePrice: z.number().int().multipleOf(10).optional(),
2554
3866
  status: productStatusSchema.optional(),
2555
- deliveryFee: z.number().int().optional(),
2556
- shippingAddressId: z.string().nullable().optional().describe(ADDRESS_DESCRIPTIONS.patchShipping),
2557
- returnAddressId: z.string().nullable().optional().describe(ADDRESS_DESCRIPTIONS.patchReturn)
3867
+ fulfillment: productFulfillmentUpdateSchema.optional().describe(fulfillmentUpdateDescription),
3868
+ refundPolicy: productRefundPolicyPatchSchema.nullable().optional().describe("상품별 환불 정책. 주면 통째로 바꾼다(준 항목만 스토어 기본을 덮어쓴다). `null`은 덮어쓰기를 지워 스토어 기본을 따른다. 생략하면 바꾸지 않는다")
2558
3869
  }).refine((value) => Object.values(value).some((v) => v !== void 0), { message: "최소 1개 필드가 필요합니다" });
2559
3870
  const productSortSchema = z.enum([
2560
3871
  "createdAt.desc",
@@ -2572,14 +3883,6 @@ const productListQuerySchema = z.object({
2572
3883
  sort: productSortSchema.optional().describe("정렬 (기본 createdAt.desc)"),
2573
3884
  ...pageQueryShape()
2574
3885
  });
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
3886
  const discountRequestSchema = z.object({
2584
3887
  discountType: z.enum(["RATE", "AMOUNT"]),
2585
3888
  discountValue: z.number().int().min(1),
@@ -2606,6 +3909,9 @@ const discountRequestSchema = z.object({
2606
3909
  *
2607
3910
  * 계산은 `quoteDelivery` 하나다. 장바구니·주문서·결제 시작·환불 정산이 모두 이 함수를 쓴다 —
2608
3911
  * 네 곳이 각자 계산하면 화면 금액과 결제 금액이 갈라진다.
3912
+ *
3913
+ * **비실물 줄은 배송 계산에서 빠진다**(이슈 #48, `requiresShipping`). 묶음을 만들지 않고, 스토어 전체 무료 기준의
3914
+ * 판정 금액(`shippableAmount`)에도 넣지 않는다 — 디지털 상품 금액이 실물 배송비를 면제시키지 않게 한다.
2609
3915
  */
2610
3916
  const descriptions = {
2611
3917
  baseDeliveryFee: "스토어 기본 배송비. 원 단위. 상품 등록·수정에서 `deliveryFee`를 생략하면 이 값을 쓴다. 기본 3000",
@@ -2978,11 +4284,12 @@ function quoteDelivery(input) {
2978
4284
  const area = remoteAreaOf(input.zipCode);
2979
4285
  const surcharge = remoteSurchargeOf(policy, area);
2980
4286
  const groups = /* @__PURE__ */ new Map();
2981
- let productAmount = 0;
4287
+ let shippableAmount = 0;
2982
4288
  for (const line of input.lines) {
2983
4289
  const product = input.productOf(line.productId);
2984
4290
  if (!product) continue;
2985
- productAmount += line.amount;
4291
+ if (!requiresShipping(line.fulfillmentType ?? product.fulfillmentType)) continue;
4292
+ shippableAmount += line.amount;
2986
4293
  const key = deliveryBundleKeyOf(product);
2987
4294
  const group = groups.get(key) ?? {
2988
4295
  product,
@@ -2993,7 +4300,7 @@ function quoteDelivery(input) {
2993
4300
  group.sum += line.amount;
2994
4301
  groups.set(key, group);
2995
4302
  }
2996
- const freeByThreshold = policy.freeThreshold !== null && productAmount >= policy.freeThreshold && groups.size > 0;
4303
+ const freeByThreshold = policy.freeThreshold !== null && shippableAmount >= policy.freeThreshold && groups.size > 0;
2997
4304
  const bundles = [];
2998
4305
  for (const [bundleKey, group] of groups) {
2999
4306
  const { product } = group;
@@ -3018,7 +4325,9 @@ function quoteDelivery(input) {
3018
4325
  remoteSurcharge,
3019
4326
  deliveryFee: baseFee + remoteSurcharge,
3020
4327
  remoteArea: area,
3021
- freeByThreshold
4328
+ freeByThreshold,
4329
+ shippableAmount,
4330
+ requiresShipping: bundles.length > 0
3022
4331
  };
3023
4332
  }
3024
4333
  //#endregion
@@ -3118,6 +4427,8 @@ const updateStoreRequestSchema = z.object({
3118
4427
  analyticsAllowedOrigins: z.array(analyticsOriginSchema).max(20).optional().describe("@deprecated `storefrontOrigins`를 쓴다(같은 목록). 방문 분석 수집을 받을 스토어프론트 오리진(최대 20개). 비우면 모든 오리진에서 받는다. 넣으면 목록에 없는 오리진의 수집 요청은 403 `ORIGIN_NOT_ALLOWED`이고, 페이지 URL이 목록 밖인 이벤트는 버린다"),
3119
4428
  storefrontOrigins: z.array(analyticsOriginSchema).max(20).optional().describe("스토어프론트 도메인(최대 20개) — 소셜 로그인 뒤 돌아갈 주소와 방문 분석 수집을 함께 제한한다. `analyticsAllowedOrigins`와 같은 목록이다(둘 다 주면 이 값이 이긴다)"),
3120
4429
  guestCheckoutEnabled: z.boolean().optional().describe("비회원 주문 허용. false(회원만)면 로그인하지 않은 구매자의 주문서 생성·결제 요청이 403 `GUEST_CHECKOUT_DISABLED`다. 장바구니 담기와 이미 받은 비회원 주문의 조회·취소는 막지 않는다"),
4430
+ fulfillmentMailEnabled: z.boolean().optional().describe("비실물 상품 제공 안내 메일(이슈 #48). 켜면(기본) 제공이 끝났을 때 구매자에게 안내 메일을 보낸다. 코드·파일 주소는 싣지 않는다"),
4431
+ withdrawalNotice: z.string().trim().min(1).max(1e3).nullable().optional().describe("청약철회 제한 고지 문구(이슈 #48). 제한 상품이 있는 주문서에 보인다. null이면 플랫폼 기본 문구를 쓴다"),
3121
4432
  autoDeliverDays: z.number().int().min(0).max(30).optional().describe(autoDeliverDaysDescription),
3122
4433
  autoConfirmDays: z.number().int().min(0).max(30).optional().describe(autoConfirmDaysDescription),
3123
4434
  refundPolicy: refundPolicyPatchSchema.optional().describe("스토어 기본 환불 정책 — 준 항목만 바꾼다. 상품별 정책(`products.refundPolicy`)이 없는 상품에 적용된다"),
@@ -3143,6 +4454,8 @@ const storeSchema = z.object({
3143
4454
  analyticsAllowedOrigins: z.array(z.string()).describe("방문 분석 수집을 받는 스토어프론트 오리진. 비어 있으면 모든 오리진에서 받는다"),
3144
4455
  storefrontOrigins: z.array(z.string()).describe("스토어프론트 도메인 — 소셜 로그인 리디렉트와 방문 분석 수집이 함께 쓴다(`analyticsAllowedOrigins`와 같은 값)"),
3145
4456
  guestCheckoutEnabled: z.boolean().describe("비회원 주문 허용. false면 회원만 주문할 수 있다"),
4457
+ fulfillmentMailEnabled: z.boolean().optional().default(true).describe("비실물 상품 제공 안내 메일(이슈 #48). 서버는 항상 싣는다"),
4458
+ withdrawalNotice: z.string().nullable().optional().default(null).describe("청약철회 제한 고지 문구(이슈 #48). null이면 플랫폼 기본 문구다. 서버는 항상 싣는다"),
3146
4459
  autoDeliverDays: z.number().int().describe(autoDeliverDaysDescription),
3147
4460
  autoConfirmDays: z.number().int().describe(autoConfirmDaysDescription),
3148
4461
  refundPolicy: refundPolicySchema.describe("스토어 기본 환불 정책. 스토어가 정하지 않은 항목은 플랫폼 기본값으로 채워 항상 전부 싣는다"),
@@ -3213,7 +4526,15 @@ const storeMemberRoleUpdatedSchema = z.object({
3213
4526
  userId: z.string(),
3214
4527
  role: z.enum(["ADMIN", "STAFF"])
3215
4528
  });
3216
- const addressTypeSchema = z.enum(["SHIPPING", "RETURN"]);
4529
+ /**
4530
+ * 주소 유형 — 출고지·반품지·방문 수령지(이슈 #48). 방문 수령지(`PICKUP`)는 목록에서 `type=PICKUP`으로 조회할 때만 나온다
4531
+ * (이 값을 모르는 옛 SDK의 목록 파싱이 깨지지 않게).
4532
+ */
4533
+ const addressTypeSchema = z.enum([
4534
+ "SHIPPING",
4535
+ "RETURN",
4536
+ "PICKUP"
4537
+ ]);
3217
4538
  /** `GET /v1/seller-addresses` 쿼리 — OpenAPI 문서용 형태. 기본 주소 우선, 그다음 등록순이다 */
3218
4539
  const sellerAddressListQuerySchema = z.object({ type: addressTypeSchema.optional().describe("주소 유형 필터") });
3219
4540
  const sellerAddressSchema = z.object({
@@ -3387,7 +4708,10 @@ function createStoreClient(options) {
3387
4708
  }) });
3388
4709
  const put = await fetchFn(ticket.uploadUrl, {
3389
4710
  method: "PUT",
3390
- headers: { "content-type": file.type || "application/octet-stream" },
4711
+ headers: {
4712
+ ...ticket.headers,
4713
+ "content-type": file.type || "application/octet-stream"
4714
+ },
3391
4715
  body: file
3392
4716
  });
3393
4717
  if (!put.ok) throw new ApiError(put.status, "UPLOAD_PUT_FAILED", `업로드에 실패했습니다: ${filename}`);
@@ -3401,7 +4725,7 @@ function createStoreClient(options) {
3401
4725
  body: productCreateRequestSchema.parse(body),
3402
4726
  headers: idempotency(idempotencyKey)
3403
4727
  }),
3404
- update: (productId, body) => storeHttp.request("PUT", path`/products/${productId}`, productSchema, { body: productCreateRequestSchema.parse(body) }),
4728
+ update: (productId, body) => storeHttp.request("PUT", path`/products/${productId}`, productSchema, { body: productReplaceRequestSchema.parse(body) }),
3405
4729
  patch: (productId, body) => storeHttp.requestVoid("PATCH", path`/products/${productId}`, { body: productPatchRequestSchema.parse(body) }),
3406
4730
  remove: (productId) => storeHttp.requestVoid("DELETE", path`/products/${productId}`),
3407
4731
  bulkUpdateStatus: (productIds, status, idempotencyKey) => storeHttp.request("POST", "/products/bulk/status", bulkResultSchema, {
@@ -3422,8 +4746,37 @@ function createStoreClient(options) {
3422
4746
  updateVariantStock: (productId, variantId, body) => storeHttp.request("PATCH", path`/products/${productId}/variants/${variantId}/stock`, variantStockSchema, { body: variantStockRequestSchema.parse(body) }),
3423
4747
  getDiscount: (productId) => storeHttp.request("GET", path`/products/${productId}/discount`, discountSchema),
3424
4748
  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`)
4749
+ removeDiscount: (productId) => storeHttp.requestVoid("DELETE", path`/products/${productId}/discount`),
4750
+ /**
4751
+ * 코드 일괄 업로드 (이슈 #48) — 코드 발급·자동 제공 상품의 코드 풀에 올린다. `codes`와 `csv` 중 하나만 보낸다.
4752
+ * 받은 코드 수만큼 재고가 는다. 에러: 409 `NOT_CODE_PRODUCT`·`CODE_POOL_NOT_USED`·`CODE_UPLOAD_CONFLICT`, 400 `TOO_MANY_CODES`
4753
+ */
4754
+ uploadCodes: (productId, body, idempotencyKey) => storeHttp.request("POST", path`/products/${productId}/codes`, fulfillmentCodeBatchSchema, {
4755
+ body: fulfillmentCodeUploadRequestSchema.parse(body),
4756
+ headers: idempotency(idempotencyKey)
4757
+ }),
4758
+ /** 코드 목록 — 가린 값(`hint`)만 싣는다. `available`은 필터와 무관한 판매 가능 코드 수다 */
4759
+ listCodes: (productId, params) => storeHttp.request("GET", path`/products/${productId}/codes`, fulfillmentCodePageSchema, { query: params }),
4760
+ /** 판매 가능 코드 폐기 — 재고가 1 준다. 배정된 코드는 409 `CODE_NOT_AVAILABLE` */
4761
+ voidCode: (productId, codeId) => storeHttp.request("POST", path`/products/${productId}/codes/${codeId}/void`, fulfillmentCodeSchema),
4762
+ /**
4763
+ * 판매 파일 연결 (이슈 #48) — `uploads.create({ purpose: "digital-asset" })`로 받은 `key`에 파일을 올린 뒤 연결한다.
4764
+ * 이후 주문부터 이 파일을 준다. 에러: 400 `INVALID_ASSET_KEY`, 409 `NOT_DOWNLOAD_PRODUCT`·`ASSET_ALREADY_ATTACHED`
4765
+ */
4766
+ attachDigitalAsset: (productId, body) => storeHttp.request("POST", path`/products/${productId}/digital-assets`, digitalAssetSchema, { body: digitalAssetAttachRequestSchema.parse(body) }),
4767
+ /** 판매 파일 목록 — 해제한 파일도 싣는다. 객체 키·주소는 없다 */
4768
+ listDigitalAssets: (productId) => storeHttp.request("GET", path`/products/${productId}/digital-assets`, z.array(digitalAssetSchema)),
4769
+ /** 판매 파일 해제 — 새 주문에 주지 않는다. 이미 산 구매자는 계속 받는다 */
4770
+ removeDigitalAsset: (productId, assetId) => storeHttp.request("DELETE", path`/products/${productId}/digital-assets/${assetId}`, digitalAssetSchema),
4771
+ /** 판매 파일 확인 주소 — 수명 300초의 서명 주소 */
4772
+ previewDigitalAsset: (productId, assetId) => storeHttp.request("POST", path`/products/${productId}/digital-assets/${assetId}/preview-url`, digitalAssetPreviewSchema)
3426
4773
  },
4774
+ fulfillmentCodes: {
4775
+ /**
4776
+ * 코드 평문 열람 — 콘솔에서 로그인한 OWNER·ADMIN만 된다(403 `INSUFFICIENT_ROLE`). 열람 기록과 감사 로그가 남는다.
4777
+ * 평문은 화면에 보인 뒤 저장하지 않는다
4778
+ */
4779
+ reveal: (codeId) => storeHttp.request("POST", path`/fulfillment-codes/${codeId}/reveal`, fulfillmentCodeRevealSchema) },
3427
4780
  categories: {
3428
4781
  list: (params) => storeHttp.request("GET", "/categories", z.array(categorySchema), { query: params }),
3429
4782
  tree: () => storeHttp.request("GET", "/categories", z.array(categoryTreeNodeSchema), { query: { tree: true } }),
@@ -3489,6 +4842,27 @@ function createStoreClient(options) {
3489
4842
  body: { orderItemIds },
3490
4843
  headers: idempotency(idempotencyKey)
3491
4844
  }),
4845
+ /**
4846
+ * 제공 안내 메일 재발송 (이슈 #48) — 제공된 주문상품의 안내 메일을 다시 보낸다. 주문상품당 하루 3회(429).
4847
+ * 코드·파일 주소는 메일에 없다
4848
+ */
4849
+ resendFulfillmentMail: (orderItemId) => storeHttp.request("POST", path`/order-items/${orderItemId}/fulfillment/mail`, fulfillmentMailResendSchema),
4850
+ /**
4851
+ * 제공 처리 (이슈 #48) — 수동 제공 비실물 주문상품(이용권·방문 수령)을 `PAID`·`CONFIRMED`에서 제공 완료(`DELIVERED`)로
4852
+ * 바꾼다. `note`는 구매자 주문 상세에 이용 안내로 실린다. 배송 상품은 409 `SHIPPING_REQUIRED`
4853
+ */
4854
+ fulfill: (orderItemId, body = {}, idempotencyKey) => storeHttp.request("POST", path`/order-items/${orderItemId}/fulfill`, orderItemSchema, {
4855
+ body: fulfillRequestSchema.parse(body),
4856
+ headers: idempotency(idempotencyKey)
4857
+ }),
4858
+ /** 제공 처리 일괄 — 항목별 부분 성공이다 */
4859
+ fulfillBulk: (orderItemIds, body = {}, idempotencyKey) => storeHttp.request("POST", "/order-items/fulfill", bulkResultSchema, {
4860
+ body: fulfillBulkRequestSchema.parse({
4861
+ orderItemIds,
4862
+ ...body
4863
+ }),
4864
+ headers: idempotency(idempotencyKey)
4865
+ }),
3492
4866
  getDelivery: (orderItemId) => storeHttp.request("GET", path`/order-items/${orderItemId}/delivery`, deliveryStatusSchema)
3493
4867
  },
3494
4868
  carriers: { list: () => storeHttp.request("GET", "/carriers", z.array(carrierSchema)) },
@@ -3540,8 +4914,33 @@ function createStoreClient(options) {
3540
4914
  orders: (customerId, params) => storeHttp.request("GET", path`/customers/${customerId}/orders`, pageSchema(customerOrderSchema), { query: params }),
3541
4915
  /** 동의 이력 — 최근 순. 가입·재동의·마케팅 변경이 한 줄씩 남는다 */
3542
4916
  consents: (customerId, params) => storeHttp.request("GET", path`/customers/${customerId}/consents`, pageSchema(customerConsentSchema), { query: params }),
4917
+ /** 보유 쿠폰 (이슈 #47) — 최근 지급 순, 최대 500장 */
4918
+ coupons: (customerId) => storeHttp.request("GET", path`/customers/${customerId}/coupons`, z.array(customerCouponSchema)),
3543
4919
  withdraw: (customerId, body) => storeHttp.requestVoid("POST", path`/customers/${customerId}/withdraw`, { body: withdrawCustomerRequestSchema.parse(body) })
3544
4920
  },
4921
+ /** 쿠폰 (이슈 #47) — 조회 `promotion:r`, 쓰기 `promotion:rw` */
4922
+ coupons: {
4923
+ list: (params) => storeHttp.request("GET", "/coupons", pageSchema(couponSchema), { query: params }),
4924
+ create: (body, idempotencyKey) => storeHttp.request("POST", "/coupons", couponSchema, {
4925
+ body: createCouponRequestSchema.parse(body),
4926
+ headers: idempotency(idempotencyKey)
4927
+ }),
4928
+ get: (couponId) => storeHttp.request("GET", path`/coupons/${couponId}`, couponSchema),
4929
+ update: (couponId, body) => storeHttp.request("PATCH", path`/coupons/${couponId}`, couponSchema, { body: updateCouponRequestSchema.parse(body) }),
4930
+ pause: (couponId) => storeHttp.request("POST", path`/coupons/${couponId}/pause`, couponSchema),
4931
+ resume: (couponId) => storeHttp.request("POST", path`/coupons/${couponId}/resume`, couponSchema),
4932
+ end: (couponId) => storeHttp.request("POST", path`/coupons/${couponId}/end`, couponSchema),
4933
+ /** 셀러 지급 — 한 번에 1,000명까지. 한도에 걸린 구매자는 `failed`에 담긴다 */
4934
+ issue: (couponId, body, idempotencyKey) => storeHttp.request("POST", path`/coupons/${couponId}/issues`, issueCouponResultSchema, {
4935
+ body: issueCouponRequestSchema.parse(body),
4936
+ headers: idempotency(idempotencyKey)
4937
+ }),
4938
+ issues: (couponId, params) => storeHttp.request("GET", path`/coupons/${couponId}/issues`, pageSchema(couponIssueSchema), { query: params }),
4939
+ redemptions: (couponId, params) => storeHttp.request("GET", path`/coupons/${couponId}/redemptions`, pageSchema(couponRedemptionSchema), { query: params }),
4940
+ stats: (couponId) => storeHttp.request("GET", path`/coupons/${couponId}/stats`, couponStatsSchema),
4941
+ /** 발급 쿠폰 회수 — 사용 가능한 발급 쿠폰만 */
4942
+ revokeIssue: (issueId) => storeHttp.request("POST", path`/coupon-issues/${issueId}/revoke`, couponIssueSchema)
4943
+ },
3545
4944
  customerInquiries: {
3546
4945
  list: (params) => storeHttp.request("GET", "/customer-inquiries", pageSchema(sellerCustomerInquirySchema), { query: params }),
3547
4946
  get: (inquiryId) => storeHttp.request("GET", path`/customer-inquiries/${inquiryId}`, sellerCustomerInquirySchema),
@@ -3889,7 +5288,8 @@ const STORE_SCOPE_RESOURCES = {
3889
5288
  store: ["r", "rw"],
3890
5289
  member: ["r", "rw"],
3891
5290
  customer: ["r", "rw"],
3892
- audit: ["r"]
5291
+ audit: ["r"],
5292
+ promotion: ["r", "rw"]
3893
5293
  };
3894
5294
  const storeScopeResourceSchema = z.enum(Object.keys(STORE_SCOPE_RESOURCES)).describe("스코프 리소스");
3895
5295
  /** 카탈로그상 존재하는 스코프 문자열인가 (예: `settlement:rw`는 false) */
@@ -3917,7 +5317,8 @@ const STORE_ROLE_SCOPES = {
3917
5317
  "store:rw",
3918
5318
  "member:rw",
3919
5319
  "customer:rw",
3920
- "audit:r"
5320
+ "audit:r",
5321
+ "promotion:rw"
3921
5322
  ],
3922
5323
  ADMIN: [
3923
5324
  "product:rw",
@@ -3931,7 +5332,8 @@ const STORE_ROLE_SCOPES = {
3931
5332
  "store:rw",
3932
5333
  "member:rw",
3933
5334
  "customer:rw",
3934
- "audit:r"
5335
+ "audit:r",
5336
+ "promotion:rw"
3935
5337
  ],
3936
5338
  STAFF: [
3937
5339
  "product:rw",
@@ -3940,7 +5342,8 @@ const STORE_ROLE_SCOPES = {
3940
5342
  "claim:rw",
3941
5343
  "inquiry:rw",
3942
5344
  "review:rw",
3943
- "customer:r"
5345
+ "customer:r",
5346
+ "promotion:r"
3944
5347
  ]
3945
5348
  };
3946
5349
  //#endregion
@@ -3986,12 +5389,30 @@ const mcpToolEventSchema = z.object({
3986
5389
  const envelopeFields = {
3987
5390
  id: z.string().describe("이벤트 ID (`evt_…`, 테스트 전송은 `evt_test_…`). 중복 제거 키"),
3988
5391
  createdAt: z.string().describe("이벤트 발생 일시 (ISO 8601)"),
3989
- storeId: z.string().describe("이벤트가 발생한 스토어 ID")
5392
+ storeId: z.string().describe("이벤트가 발생한 스토어 ID"),
5393
+ sequence: z.number().int().positive().optional().describe("리소스별 순번. 같은 주문·클레임·문의·리뷰·상품의 이벤트마다 1씩 커진다. 도착 순서는 보장하지 않으므로 같은 리소스의 이벤트는 이 값으로 정렬한다. 이 필드가 생기기 전의 이벤트와 테스트 전송에는 없다")
3990
5394
  };
3991
5395
  const orderWebhookEventDataSchema = z.object({
3992
5396
  orderId: z.string().describe("주문 ID"),
3993
5397
  status: orderItemStatusSchema.describe("이벤트 발생 시점의 주문 대표 상태"),
3994
- testPayment: z.boolean().optional().default(false).describe("테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았다. 이 필드가 없는 이전 이벤트는 false로 읽는다")
5398
+ testPayment: z.boolean().optional().default(false).describe("테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았다. 이 필드가 없는 이전 이벤트는 false로 읽는다"),
5399
+ couponDiscountAmount: z.number().int().optional().default(0).describe("주문이 받은 상품·주문 쿠폰 할인 합계 (원). 즉시할인(`discountAmount`)과 별개다. 쿠폰이 없거나 이 필드가 없는 이전 이벤트는 0으로 읽는다"),
5400
+ orderItemId: z.string().optional().describe("이벤트를 만든 주문상품 ID. 비실물 상품 제공으로 보내는 `ORDER.DELIVERED`에만 실린다. 택배 배송완료와 그 밖의 주문 이벤트에는 없다"),
5401
+ fulfillmentSnapshot: orderItemFulfillmentSnapshotResponseSchema.optional().describe("이벤트를 만든 주문상품의 주문 시점 이행 스냅샷(주문상품 응답의 `fulfillmentSnapshot`과 같다, 이슈 #57). 비실물 상품의 제공 완료는 `ORDER.DELIVERED`로도 오고 `type`이 `SHIPPING`이 아니다. `orderItemId`가 있는 이벤트에만 실린다")
5402
+ });
5403
+ /**
5404
+ * `ORDER.FULFILLED` — 비실물 주문상품(파일·코드·이용권·방문 수령)을 제공했다(이슈 #48). 자동 제공은 결제 확정과 함께,
5405
+ * 수동 제공은 셀러의 제공 처리(`POST /v1/order-items/{orderItemId}/fulfill`)로 온다. 기존 구독자를 위해 같은 순간
5406
+ * `ORDER.DELIVERED`도 함께 보낸다. **코드·파일 주소는 싣지 않는다** — 관리 API로 조회한다.
5407
+ */
5408
+ const orderFulfilledWebhookEventDataSchema = z.object({
5409
+ orderId: z.string().describe("주문 ID"),
5410
+ orderItemId: z.string().describe("제공한 주문상품 ID"),
5411
+ status: orderItemStatusSchema.describe("이벤트 발생 시점의 주문 대표 상태"),
5412
+ fulfillmentSnapshot: orderItemFulfillmentSnapshotResponseSchema.describe("제공한 주문상품의 주문 시점 이행 스냅샷(주문상품 응답의 `fulfillmentSnapshot`과 같다, 이슈 #57)"),
5413
+ fulfillment: z.object({ mode: z.string().describe("실제 제공 방식. `AUTO`(결제 확정과 함께)·`MANUAL`(셀러 제공 처리). 주문상품 제공 기록의 `mode`와 같다") }).describe("제공 기록"),
5414
+ fulfilledAt: z.string().describe("제공 시각 (ISO 8601). 주문상품의 `deliveredAt`과 같다"),
5415
+ testPayment: z.boolean().describe("테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았다")
3995
5416
  });
3996
5417
  /**
3997
5418
  * `ORDER.CANCELED` — 셀러 직권취소(`POST /v1/order-items/{orderItemId}/cancel`)로 주문상품이 취소됐다.
@@ -4006,9 +5427,11 @@ const orderCanceledWebhookEventDataSchema = z.object({
4006
5427
  reason: z.string().describe("취소 사유 코드 — 판매자 취소 요청의 `reason`(`OUT_OF_STOCK`·`PRICE_ERROR`·`DELIVERY_IMPOSSIBLE`·`ETC`). 사유가 늘어날 수 있어 문자열이다. 모르는 값은 `ETC`처럼 다룬다"),
4007
5428
  quantity: z.number().int().describe("이번에 취소한 수량"),
4008
5429
  canceledQuantity: z.number().int().describe("취소 후 누적 취소 수량"),
4009
- refundAmount: z.number().int().describe("환불액 (원). 수량 비율 상품 금액 + 배송비 환불 − 조건부 무료배송 미달 차감이다. 결제사 환불 결과는 결제 내역(`GET /v1/payments/{paymentId}`)으로 확인한다"),
5430
+ refundAmount: z.number().int().describe("환불액 (원). 수량 비율 상품 실결제 금액(쿠폰 배분을 뺀 금액) + 배송비 환불 − 조건부 무료배송 미달 차감 + 되돌린 쿠폰 차감(`couponChargeRefundAmount`)이다. 결제사 환불 결과는 결제 내역(`GET /v1/payments/{paymentId}`)으로 확인한다"),
4010
5431
  deliveryFeeRefundAmount: z.number().int().describe("환불액에 포함된 배송비 (원)"),
4011
5432
  deliveryFeeChargeAmount: z.number().int().describe("환불액에서 뺀 배송비 (원) — 조건부 무료배송 기준 미달 차감"),
5433
+ couponChargeAmount: z.number().int().optional().default(0).describe("환불액에서 뺀 쿠폰 조건 미달 차감 (원). 판매자 취소는 차감하지 않으므로 늘 0이다. 이 필드가 없는 이전 이벤트는 0으로 읽는다"),
5434
+ couponChargeRefundAmount: z.number().int().optional().default(0).describe("환불액에 더한 금액 (원) — 앞선 환불에서 뺀 쿠폰 조건 미달 차감을 되돌린 몫이다. 전량 취소면 누적 차감이 모두 되돌아간다. 이 필드가 없는 이전 이벤트는 0으로 읽는다"),
4012
5435
  testPayment: z.boolean().describe("테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았다")
4013
5436
  });
4014
5437
  const claimWebhookEventDataSchema = z.object({
@@ -4022,6 +5445,8 @@ const claimWebhookEventDataSchema = z.object({
4022
5445
  deliveryFeeRefundAmount: z.number().int().optional().default(0).describe("환불액에 포함된 배송비 (원). 이 필드가 없는 이전 이벤트는 0으로 읽는다"),
4023
5446
  deliveryFeeChargeAmount: z.number().int().optional().default(0).describe("환불액에서 뺀 배송비 (원) — 반품·교환 배송비와 조건부 무료배송 미달 차감의 합. 셀러가 승인에 넣은 추가 차감은 `deductAmount`로 따로 온다. 이 필드가 없는 이전 이벤트는 0으로 읽는다"),
4024
5447
  deductAmount: z.number().int().optional().default(0).describe("환불액에서 뺀 추가 차감 (원) — 셀러가 클레임 승인에 넣은 금액이다(상품 훼손 등). 배송비 차감과 별개이므로 `deliveryFeeChargeAmount`와 더해서 뺀다. 접수 이벤트와 이 필드가 없는 이전 이벤트는 0이다"),
5448
+ couponChargeAmount: z.number().int().optional().default(0).describe("환불액에서 뺀 쿠폰 조건 미달 차감 (원) — 일부 취소·반품으로 남은 주문상품이 쿠폰 조건(최소 주문 금액 등)을 잃어 남은 주문상품이 받던 할인을 뺀 금액이다. 배송비 차감·추가 차감과 별개다. 접수 이벤트는 예상값이다. 이 필드가 없는 이전 이벤트는 0으로 읽는다"),
5449
+ couponChargeRefundAmount: z.number().int().optional().default(0).describe("환불액에 더한 금액 (원) — 앞선 환불에서 뺀 쿠폰 조건 미달 차감을 되돌린 몫이다. 접수 이벤트는 예상값이다. 이 필드가 없는 이전 이벤트는 0으로 읽는다"),
4025
5450
  testPayment: z.boolean().optional().default(false).describe("테스트 결제(샌드박스 결제) 주문의 클레임인가. 이 필드가 없는 이전 이벤트는 false로 읽는다")
4026
5451
  });
4027
5452
  const inquiryWebhookEventDataSchema = z.object({
@@ -4049,6 +5474,11 @@ const orderWebhookEventSchema = z.object({
4049
5474
  ]),
4050
5475
  data: orderWebhookEventDataSchema
4051
5476
  });
5477
+ const orderFulfilledWebhookEventSchema = z.object({
5478
+ ...envelopeFields,
5479
+ type: z.literal("ORDER.FULFILLED"),
5480
+ data: orderFulfilledWebhookEventDataSchema
5481
+ });
4052
5482
  const orderCanceledWebhookEventSchema = z.object({
4053
5483
  ...envelopeFields,
4054
5484
  type: z.literal("ORDER.CANCELED"),
@@ -4082,6 +5512,7 @@ const productOutOfStockWebhookEventSchema = z.object({
4082
5512
  /** 웹훅 요청 본문. `type`으로 판별하며 `data` 형태가 타입별로 다르다 */
4083
5513
  const webhookEventSchema = z.discriminatedUnion("type", [
4084
5514
  orderWebhookEventSchema,
5515
+ orderFulfilledWebhookEventSchema,
4085
5516
  orderCanceledWebhookEventSchema,
4086
5517
  claimWebhookEventSchema,
4087
5518
  inquiryWebhookEventSchema,
@@ -4238,4 +5669,4 @@ function createStoreSession(options) {
4238
5669
  };
4239
5670
  }
4240
5671
  //#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 };
5672
+ 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, DOWNLOAD_DEFAULTS, 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, REFUND_POLICY_DELIVERY_FEE_KEYS, 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, autoFulfillableOf, 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, fulfillmentModeOf, 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, openFulfillmentUnion, optionGroupRequestSchema, optionGroupSchema, optionModelRequestSchema, optionModelSchema, optionValueRequestSchema, optionValueSchema, orderCanceledWebhookEventDataSchema, orderCanceledWebhookEventSchema, orderDiscountSchema, orderFulfilledWebhookEventDataSchema, orderFulfilledWebhookEventSchema, orderItemCodeSchema, orderItemDownloadSchema, orderItemFulfillmentSchema, orderItemFulfillmentSnapshotResponseSchema, orderItemFulfillmentSnapshotSchema, 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, productFulfillmentCreateSchema, productFulfillmentResponseSchema, productFulfillmentSchema, productFulfillmentUpdateSchema, productImageRejection, productImageUrlSchema, productListQuerySchema, productOutOfStockWebhookEventDataSchema, productOutOfStockWebhookEventSchema, productPatchRequestSchema, productRefundPolicyOverride, productRefundPolicyPatchSchema, productReplaceRequestSchema, 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, stockTrackingOf, 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, usesCodePool, variantStockRequestSchema, variantStockSchema, verifyPaymentProviderRequestSchema, webhookCreatedSchema, webhookDeliveryDetailSchema, webhookDeliveryErrorCodeSchema, webhookDeliveryListQuerySchema, webhookDeliveryResourceSchema, webhookDeliverySchema, webhookDeliveryStatusSchema, webhookEventSchema, webhookFailureReasonSchema, webhookRedeliverFailedResultSchema, webhookRequestHeaderSchema, webhookRequestSchema, webhookResourceTypeSchema, webhookSchema, webhookSecretRotatedSchema, webhookStatusSchema, webhookTestRequestSchema, webhookTestResultSchema, withdrawCustomerRequestSchema, withdrawalRestrictedOf, writeDryRunResultSchema, writePreviewSchema };