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