@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.d.mts +3239 -250
- package/dist/index.mjs +1615 -184
- package/package.json +1 -1
- package/src/client.ts +194 -5
- package/src/index.ts +5 -0
- package/src/schemas/claim.ts +24 -2
- package/src/schemas/coupon.ts +312 -0
- package/src/schemas/digital-asset.ts +65 -0
- package/src/schemas/fulfillment-code.ts +143 -0
- package/src/schemas/fulfillment.ts +576 -0
- package/src/schemas/misc.ts +5 -1
- package/src/schemas/order.ts +196 -6
- package/src/schemas/product.ts +107 -114
- package/src/schemas/promotion.ts +806 -0
- package/src/schemas/refund-policy.ts +39 -27
- package/src/schemas/scope.ts +4 -0
- package/src/schemas/shipping-policy.ts +28 -4
- package/src/schemas/store.ts +32 -1
- package/src/schemas/upload.ts +48 -2
- package/src/schemas/webhook-event.ts +104 -1
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("
|
|
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
|
-
|
|
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([
|
|
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
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
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
|
-
*
|
|
3829
|
+
* 상품 쓰기 요청의 공통 필드 — 등록(POST)과 전체 수정(PUT)이 같다. 이행(`fulfillment`)만 두 요청의 스키마가 다르다.
|
|
2513
3830
|
*
|
|
2514
|
-
*
|
|
2515
|
-
*
|
|
2516
|
-
* (`409 ADDRESS_IN_USE`) 참조를 푸는 수단이 필요한데, 생략을 기본값으로 되채우면 기본 주소는 영원히
|
|
2517
|
-
* 풀리지 않는다 — null이 그 경로다. 배송 묶음 판정은 null을 그 시점 스토어 기본 출고지로 정규화하므로
|
|
2518
|
-
* 두 표현이 배송비를 갈라놓지 않는다.
|
|
3831
|
+
* 최상위가 `strictObject`다. 모르는 필드는 400이다 — 이슈 #57 이전의 평면 필드(`deliveryFee`·`fulfillmentType`·`returnFee` 등)를
|
|
3832
|
+
* 보낸 요청이 조용히 무시되지 않게 한다. 옛 필드는 `fulfillment.{유형}.…`과 `refundPolicy`로 옮겼다.
|
|
2519
3833
|
*/
|
|
2520
|
-
const
|
|
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
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
const
|
|
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
|
-
|
|
2556
|
-
|
|
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
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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: {
|
|
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:
|
|
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("환불액 (원). 수량 비율 상품 금액 + 배송비 환불 − 조건부 무료배송 미달
|
|
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 };
|