@sayren/store-sdk 0.28.3 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -903,6 +903,174 @@ const approveClaimResultSchema = z.object({
903
903
  refundedAt: z.string()
904
904
  });
905
905
  //#endregion
906
+ //#region src/schemas/fulfillment.ts
907
+ /**
908
+ * 상품 유형 (이슈 #48·#57) — 배송이 필요한 상품과 필요 없는 상품을 가르는 축이다.
909
+ *
910
+ * **상품 유형은 이행 방식 하나로 표현한다.** `fulfillment.type`이 배송 필요 여부, 이행 흐름, 클레임 규칙을 정한다.
911
+ * 코드 곳곳에서 유형 이름으로 분기하지 않고 이 파일의 판정 함수(`requiresShipping`·`fulfillmentRuleOf`)를 지난다.
912
+ *
913
+ * 주문상품 상태 enum은 바꾸지 않는다. 비실물의 **제공 완료는 `DELIVERED`를 재사용한다** — 새 상태를 더하면 기존 소비자의
914
+ * 상태 분기(배송완료 뒤 구매확정 대기, 반품 가능 판정)가 새 값을 모른다. 표시는 유형으로 가른다.
915
+ */
916
+ /**
917
+ * 상품 유형.
918
+ * - `SHIPPING` 배송 상품. 택배로 발송하고 송장·배송 추적으로 배송완료가 된다
919
+ * - `MANUAL` 배송 없는 상품(비실물). 셀러가 제공 처리(`POST /v1/order-items/{orderItemId}/fulfill`)로 제공 완료를 기록한다
920
+ *
921
+ * 비실물의 세부 구분(파일 다운로드·코드 발급·이용권·방문 수령)은 2026-09-28에 제품에서 뺐다(이슈 #48 범위 축소).
922
+ * 이름은 이행 방식이다 — `SHIPPING`이 택배 발송이듯 `MANUAL`은 셀러가 직접 제공한다는 뜻이다.
923
+ */
924
+ const fulfillmentTypeSchema = z.enum(["SHIPPING", "MANUAL"]);
925
+ /**
926
+ * 응답 스키마용 유형 — 서버가 나중에 유형을 더해도 옛 SDK가 응답 파싱에서 깨지지 않게 문자열로 받는다.
927
+ * 요청은 `fulfillmentTypeSchema`(닫힌 enum)로 검증한다.
928
+ */
929
+ const fulfillmentTypeResponseSchema = z.string().describe("상품 유형. 현재 값은 `SHIPPING`(배송 상품)·`MANUAL`(배송 없는 상품, 셀러가 제공 처리)이고 값이 추가될 수 있어 문자열로 받는다");
930
+ /** 유형별 규칙표 — **유일한 원천이다.** */
931
+ const FULFILLMENT_RULES = {
932
+ SHIPPING: {
933
+ requiresShipping: true,
934
+ requiresCollection: true,
935
+ exchangeSupported: true
936
+ },
937
+ MANUAL: {
938
+ requiresShipping: false,
939
+ requiresCollection: false,
940
+ exchangeSupported: false
941
+ }
942
+ };
943
+ /**
944
+ * 저장된 값 → 유형. null·모르는 값은 `SHIPPING`이다 — 이 필드 이전의 상품·주문상품은 모두 배송 상품이었다.
945
+ */
946
+ function fulfillmentTypeOf(value) {
947
+ const parsed = fulfillmentTypeSchema.safeParse(value);
948
+ return parsed.success ? parsed.data : "SHIPPING";
949
+ }
950
+ /** 유형의 규칙 — 모르는 값은 배송 상품 규칙이다 */
951
+ function fulfillmentRuleOf(type) {
952
+ return FULFILLMENT_RULES[fulfillmentTypeOf(type)];
953
+ }
954
+ /** 택배 배송이 필요한가 — 배송비 묶음·배송지·발송 흐름의 판정은 이 함수 하나를 지난다 */
955
+ function requiresShipping(type) {
956
+ return fulfillmentRuleOf(type).requiresShipping;
957
+ }
958
+ /** 반품에 수거가 필요한가 — 비실물 반품은 수거 등록·수거 완료 없이 승인한다 */
959
+ function requiresCollection(type) {
960
+ return fulfillmentRuleOf(type).requiresCollection;
961
+ }
962
+ /**
963
+ * 배송이 없는 주문상품의 배송 묶음 키 (`order_items.delivery_bundle_key`).
964
+ *
965
+ * null로 두면 환불 정산이 #29 이전 주문의 레거시 폴백(「묶음 = 같은 상품」, 상품의 **현재** 배송비)을 탄다.
966
+ * 이 키를 만나면 배송비 환불·차감이 0이고 `orders.delivery_fee_settlements`에 항목을 만들지 않는다.
967
+ * 정책 지문 키(`b:…`)와 겹치지 않는다.
968
+ */
969
+ const NO_SHIPPING_BUNDLE_KEY = "none";
970
+ /** 배송비 유형 — 무료·유료·조건부 무료 */
971
+ const deliveryTypeSchema = z.enum([
972
+ "FREE",
973
+ "PAID",
974
+ "CONDITIONAL_FREE"
975
+ ]);
976
+ const KNOWN_FULFILLMENT_TYPES = fulfillmentTypeSchema.options;
977
+ const ADDRESS_ID_DESCRIPTIONS = {
978
+ shipping: "출고지 주소 id(`GET /v1/seller-addresses?type=SHIPPING`). 등록에서 생략하면 그 시점 상점 기본 출고지를 저장한다. `null`이면 비워 둔다(상점 기본 출고지를 따른다). 이 상점의 `SHIPPING` 주소가 아니면 거부한다",
979
+ return: "반품지 주소 id(`GET /v1/seller-addresses?type=RETURN`). 등록에서 생략하면 그 시점 상점 기본 반품지를 저장한다. `null`이면 비워 둔다(상점 기본 반품지를 따른다). 이 상점의 `RETURN` 주소가 아니면 거부한다"
980
+ };
981
+ /**
982
+ * 배송비 필드 사이의 일관성 — FREE의 배송비, 조건부 무료가 아닌 상품의 무료 기준을 조용히 0·null로 바꿔 저장하지 않고 400으로 돌려준다
983
+ * (셀러가 저장됐다고 믿는 값이 사라지지 않게). 조건부 무료는 기준 금액이 필수다 — 기준 없는 조건부 무료는 계산상 유료 배송이다.
984
+ */
985
+ function refineShippingConsistency(value, ctx) {
986
+ if (value.deliveryType === "FREE" && value.deliveryFee !== void 0 && value.deliveryFee !== 0) ctx.addIssue({
987
+ code: "custom",
988
+ path: ["deliveryFee"],
989
+ message: "무료 배송(FREE)의 배송비는 0이거나 생략해야 합니다"
990
+ });
991
+ const threshold = value.conditionalFreeAmount;
992
+ const hasThreshold = threshold !== void 0 && threshold !== null;
993
+ if (value.deliveryType === "CONDITIONAL_FREE" && !hasThreshold) ctx.addIssue({
994
+ code: "custom",
995
+ path: ["conditionalFreeAmount"],
996
+ message: "조건부 무료(CONDITIONAL_FREE)는 무료 기준 금액이 필요합니다"
997
+ });
998
+ if (value.deliveryType !== "CONDITIONAL_FREE" && hasThreshold) ctx.addIssue({
999
+ code: "custom",
1000
+ path: ["conditionalFreeAmount"],
1001
+ message: "무료 기준 금액은 조건부 무료(CONDITIONAL_FREE)에서만 받습니다"
1002
+ });
1003
+ }
1004
+ const deliveryTypeDescription = "배송비 유형. `FREE` 무료, `PAID` 유료, `CONDITIONAL_FREE` 조건부 무료";
1005
+ const shippingCreateSchema = z.strictObject({
1006
+ deliveryType: deliveryTypeSchema.default("PAID").describe(deliveryTypeDescription),
1007
+ deliveryFee: z.number().int().min(0).optional().describe("배송비. 원 단위. 생략하면 상점 기본 배송비(`GET /v1/store`의 `shippingPolicy.baseDeliveryFee`)를 저장한다. FREE면 0이거나 생략한다"),
1008
+ conditionalFreeAmount: z.number().int().min(0).nullable().optional().describe("무료 배송이 되는 묶음 소계. 원 단위. CONDITIONAL_FREE에서만 받고 필수다"),
1009
+ shippingAddressId: z.string().nullable().optional().describe(ADDRESS_ID_DESCRIPTIONS.shipping),
1010
+ returnAddressId: z.string().nullable().optional().describe(ADDRESS_ID_DESCRIPTIONS.return)
1011
+ }).superRefine(refineShippingConsistency);
1012
+ /**
1013
+ * 상품 이행 — 등록(POST). 배송 상품의 `shipping`을 생략하면 기본값(유료 배송·상점 기본 배송비·기본 출고지·반품지)이다.
1014
+ */
1015
+ const productFulfillmentCreateSchema = z.discriminatedUnion("type", [z.strictObject({
1016
+ type: z.literal("SHIPPING"),
1017
+ shipping: shippingCreateSchema.optional()
1018
+ }), z.strictObject({ type: z.literal("MANUAL") })]);
1019
+ const shippingUpdateSchema = z.strictObject({
1020
+ deliveryType: deliveryTypeSchema.describe(deliveryTypeDescription),
1021
+ deliveryFee: z.number().int().min(0).describe("배송비. 원 단위. FREE면 0"),
1022
+ conditionalFreeAmount: z.number().int().min(0).nullable().describe("무료 배송이 되는 묶음 소계. CONDITIONAL_FREE면 필수, 아니면 null"),
1023
+ shippingAddressId: z.string().nullable().describe(ADDRESS_ID_DESCRIPTIONS.shipping),
1024
+ returnAddressId: z.string().nullable().describe(ADDRESS_ID_DESCRIPTIONS.return)
1025
+ }).superRefine(refineShippingConsistency);
1026
+ /**
1027
+ * 상품 이행 — 수정(PUT·PATCH). 통째 교체이고 유형 키와 그 안의 필드가 **모두 필수**다. 서버는 현재 값과 병합하지 않는다.
1028
+ * `fulfillment` 자체를 생략하면 이행을 바꾸지 않는다.
1029
+ */
1030
+ const productFulfillmentUpdateSchema = z.discriminatedUnion("type", [z.strictObject({
1031
+ type: z.literal("SHIPPING"),
1032
+ shipping: shippingUpdateSchema
1033
+ }), z.strictObject({ type: z.literal("MANUAL") })]);
1034
+ /**
1035
+ * 저장된 상품 이행 — 알려진 유형의 닫힌 유니온. 서버의 상품 엔티티와 응답이 이 모양을 쓴다.
1036
+ * 응답 파싱은 `productFulfillmentResponseSchema`(열린 유니온)를 쓴다.
1037
+ */
1038
+ const productFulfillmentSchema = z.discriminatedUnion("type", [z.object({
1039
+ type: z.literal("SHIPPING"),
1040
+ shipping: z.object({
1041
+ deliveryType: deliveryTypeSchema,
1042
+ deliveryFee: z.number().int().describe("배송비. 원 단위. FREE면 0"),
1043
+ conditionalFreeAmount: z.number().int().nullable().describe("조건부 무료 기준 묶음 소계. CONDITIONAL_FREE가 아니면 null"),
1044
+ shippingAddressId: z.string().nullable().describe("출고지 주소 id. null이면 상점 기본 출고지를 따른다"),
1045
+ returnAddressId: z.string().nullable().describe("반품지 주소 id. null이면 상점 기본 반품지를 따른다")
1046
+ })
1047
+ }), z.object({ type: z.literal("MANUAL") })]);
1048
+ /**
1049
+ * 열린 응답 유니온 — 알려진 유형은 판별 유니온으로 검사하고, **모르는 유형만** `{ type: "OTHER", rawType }`로 읽는다.
1050
+ *
1051
+ * `type: z.string()` 가지를 그대로 두면 TS가 `type === "SHIPPING"`으로 좁혀도 문자열 가지가 남는다. `OTHER` 리터럴로 바꿔 좁히기를
1052
+ * 지킨다. 알려진 유형의 모양이 틀린 응답은 폴백으로 숨기지 않고 파싱 오류로 둔다.
1053
+ */
1054
+ function openFulfillmentUnion(known) {
1055
+ return z.union([known, z.looseObject({ type: z.string().refine((type) => !KNOWN_FULFILLMENT_TYPES.includes(type), { message: "알려진 유형은 해당 유형의 모양이어야 합니다" }) }).transform((value) => ({
1056
+ type: "OTHER",
1057
+ rawType: value.type
1058
+ }))]);
1059
+ }
1060
+ const productFulfillmentResponseSchema = openFulfillmentUnion(productFulfillmentSchema).describe("상품 이행. `type`이 유형이고 같은 이름의 키가 유형별 속성이다. 값이 추가될 수 있어 SDK는 모르는 유형을 `{ type: \"OTHER\", rawType }`로 읽는다");
1061
+ /**
1062
+ * 주문상품의 주문 시점 이행 — 결제 확정이 주문서 줄에서 굳힌다. 상품의 현재 값이 아니다. 서버는 주문상품 컬럼
1063
+ * (`fulfillment_type`·`delivery_bundle_key`)과 jsonb(`fulfillment_snapshot`)를 합쳐 이 모양을 만든다.
1064
+ */
1065
+ const orderItemFulfillmentSnapshotSchema = z.discriminatedUnion("type", [z.object({
1066
+ type: z.literal("SHIPPING"),
1067
+ shipping: z.object({
1068
+ bundleKey: z.string().nullable().describe("배송 묶음 키. 같은 키의 주문상품이 배송비를 한 번 낸 한 묶음이다. #29 이전 주문은 null"),
1069
+ returnAddressId: z.string().nullable().describe("주문 시점 반품지 주소 id. null이거나 지워진 주소면 상점 기본 반품지로 안내한다")
1070
+ })
1071
+ }), z.object({ type: z.literal("MANUAL") })]);
1072
+ const orderItemFulfillmentSnapshotResponseSchema = openFulfillmentUnion(orderItemFulfillmentSnapshotSchema).describe("주문 시점 이행 스냅샷. 상품을 나중에 고쳐도 바뀌지 않는다. 제공 기록은 `fulfillment`에 있다. 값이 추가될 수 있어 SDK는 모르는 유형을 `OTHER`로 읽는다");
1073
+ //#endregion
906
1074
  //#region src/schemas/refund-policy.ts
907
1075
  /**
908
1076
  * 환불 정책 (이슈 #25) — 취소·반품·교환에서 **배송비를 누가 부담하는지**를 정한다.
@@ -1016,2257 +1184,2271 @@ function proratedRefundBase(totalPrice, quantity, settledQuantity, cancelQuantit
1016
1184
  return Math.max(0, after - before);
1017
1185
  }
1018
1186
  //#endregion
1019
- //#region src/schemas/promotion.ts
1187
+ //#region src/schemas/upload.ts
1020
1188
  /**
1021
- * 쿠폰 금액 계산 (이슈 #47) — 쿠폰 할인을 **얼마나, 어느 주문상품에** 주는지를 정한다.
1022
- *
1023
- * 계산은 순수 함수 `quotePricing` 하나다. 장바구니 요약, 주문서 미리보기, 결제 시작의 최종 확정,
1024
- * 주문 스냅샷, 환불 정산의 조건 미달 판정(`settleCouponShortfall`)이 모두 이 함수를 지난다.
1025
- * 다섯 경로가 각자 계산하면 화면 금액, 결제 금액, 환불 금액이 갈라진다(`quoteDelivery`와 같은 원칙).
1026
- *
1027
- * 계산 순서
1028
- * 1. 즉시할인(`products.discount`)은 이미 단가에 들어 있다. 입력 `amount`가 즉시할인 반영 금액이다.
1029
- * 2. 상품 쿠폰(`PRODUCT`) — 주문상품 한 줄에 한 장. 그 줄 금액을 기준으로 할인한다.
1030
- * 3. 주문 쿠폰(`ORDER`) — 주문에 한 장. **상품 쿠폰 차감 뒤 금액**의 대상 합계를 기준으로 할인하고,
1031
- * 대상 줄에 금액 비율로 나눈다(누적 내림의 차분).
1032
- * 4. 배송비 쿠폰(`DELIVERY`) — 주문에 한 장. 기본 배송비(지역 추가 배송비 제외)를 기준으로 할인하고
1033
- * 배송 묶음에 기본 배송비 비율로 나눈다.
1189
+ * 업로드 용도. 허용 확장자와 크기 상한이 용도별로 다르다.
1034
1190
  *
1035
- * 무료배송 기준(상품별 조건부 무료·상점 전체 기준)은 **쿠폰 차감 전 금액**으로 판정한다. 그래서 배송비
1036
- * 견적(`quoteDelivery`)은 쿠폰과 무관하게 먼저 계산되고, 이 함수는 그 결과만 입력으로 받는다.
1191
+ * - `product-image`: 상품 목록·상세의 상품 이미지. 목록 썸네일로 쓰이므로 정지 이미지만 받는다.
1192
+ * - `description`: 상세설명 본문에 삽입하는 미디어. 동영상과 svg도 받는다.
1193
+ */
1194
+ const uploadPurposeSchema = z.enum(["product-image", "description"]);
1195
+ const MB = 1048576;
1196
+ /**
1197
+ * 용도별 업로드 제한 — 서버(`POST /v1/uploads`)와 클라이언트가 공유하는 단일 원천이다.
1037
1198
  *
1038
- * 최소 주문 금액은 대상 상품의 **즉시할인 반영 합계**(쿠폰 차감 전, 배송비 제외)로 판정한다.
1039
- * 정률 할인은 10원 단위로 내리고(`discountedPriceOf`의 즉시할인과 같은 단위), 최대 할인액과 기준 금액을
1040
- * 넘지 않는다.
1199
+ * 크기는 **요청이 스스로 신고한 값**으로만 검사한다. 바이트는 api를 거치지 않고 CDN으로
1200
+ * 직접 PUT되므로 이 검사는 셀러가 올리기 전에 알게 하는 장치이고, 실제 상한은 CDN이 강제한다.
1041
1201
  */
1042
- const descriptions$2 = {
1043
- kind: "쿠폰 종류. `ORDER`=주문 쿠폰(대상 상품 합계에서 할인), `PRODUCT`=상품 쿠폰(주문상품 한 줄에서 할인), `DELIVERY`=배송비 쿠폰(기본 배송비에서 할인, 지역 추가 배송비 제외)",
1044
- benefitType: "할인 방식. `RATE`=정률(%), `AMOUNT`=정액(원)",
1045
- benefitValue: "할인 값. `RATE`면 1~100(%), `AMOUNT`면 원 단위 금액",
1046
- maxDiscountAmount: "정률 할인의 최대 할인액(원). null이면 상한이 없다. 정액 할인에는 쓰지 않는다(null)",
1047
- minOrderAmount: "최소 주문 금액(원). 대상 상품의 즉시할인 반영 합계(배송비 제외)가 이 값 이상이어야 쓸 수 있다. 0이면 조건이 없다",
1048
- firstOrderOnly: "이 상점에서 결제 완료 주문이 없는 회원만 쓸 수 있다",
1049
- memberOnly: "회원만 쓸 수 있다. false면 비회원도 쓸 수 있다 — 코드 입력형이면서 1인 한도가 없는 쿠폰만 false로 둘 수 있다",
1050
- stackWithInstantDiscount: "상품 즉시할인과 함께 쓸 수 있다. false면 즉시할인이 걸린 주문상품은 쿠폰 대상에서 빠진다",
1051
- includeProductIds: "대상 상품 id. 포함 목록(상품·카테고리)이 모두 비면 전 상품이 대상이다",
1052
- includeCategoryIds: "대상 카테고리 id. 하위 카테고리의 상품도 대상이다",
1053
- excludeProductIds: "제외 상품 id. 포함보다 우선한다",
1054
- excludeCategoryIds: "제외 카테고리 id. 하위 카테고리의 상품도 제외한다. 포함보다 우선한다"
1202
+ const UPLOAD_PURPOSE_LIMITS = {
1203
+ "product-image": {
1204
+ extensions: [
1205
+ "jpg",
1206
+ "jpeg",
1207
+ "png",
1208
+ "webp",
1209
+ "avif",
1210
+ "gif"
1211
+ ],
1212
+ maxBytes: 10 * MB
1213
+ },
1214
+ description: {
1215
+ extensions: [
1216
+ "jpg",
1217
+ "jpeg",
1218
+ "png",
1219
+ "gif",
1220
+ "webp",
1221
+ "avif",
1222
+ "svg",
1223
+ "mp4",
1224
+ "webm",
1225
+ "mov"
1226
+ ],
1227
+ maxBytes: 50 * MB
1228
+ }
1055
1229
  };
1056
- /** 쿠폰 종류 */
1057
- const couponKindSchema = z.enum([
1058
- "ORDER",
1059
- "PRODUCT",
1060
- "DELIVERY"
1061
- ]).describe(descriptions$2.kind);
1062
- /** 쿠폰 혜택 — 정률은 1~100%, 최대 할인액을 둘 수 있다. 정액은 최대 할인액이 없다 */
1063
- const couponBenefitSchema = z.object({
1064
- type: z.enum(["RATE", "AMOUNT"]).describe(descriptions$2.benefitType),
1065
- value: z.number().int().min(1).describe(descriptions$2.benefitValue),
1066
- maxDiscountAmount: z.number().int().min(1).nullable().describe(descriptions$2.maxDiscountAmount)
1067
- }).superRefine((benefit, ctx) => {
1068
- if (benefit.type === "RATE" && benefit.value > 100) ctx.addIssue({
1069
- code: "custom",
1070
- path: ["value"],
1071
- message: "정률 할인은 1~100(%)이어야 합니다"
1072
- });
1073
- if (benefit.type === "AMOUNT" && benefit.maxDiscountAmount !== null) ctx.addIssue({
1074
- code: "custom",
1075
- path: ["maxDiscountAmount"],
1076
- message: "정액 할인에는 최대 할인액을 두지 않습니다"
1077
- });
1230
+ /** 파일명의 소문자 확장자. 확장자가 없으면 `""` */
1231
+ function uploadExtensionOf(filename) {
1232
+ const dot = filename.lastIndexOf(".");
1233
+ if (dot < 0 || dot === filename.length - 1) return "";
1234
+ return filename.slice(dot + 1).toLowerCase();
1235
+ }
1236
+ /**
1237
+ * 용도 제한 위반 여부. 통과하면 `null`.
1238
+ * 서버가 던지는 오류와 상점 플랫폼이 먼저 띄우는 안내가 갈리지 않도록 한 함수로 판정한다.
1239
+ */
1240
+ function uploadRejection(purpose, filename, size) {
1241
+ const limit = UPLOAD_PURPOSE_LIMITS[purpose];
1242
+ if (!limit.extensions.includes(uploadExtensionOf(filename))) return {
1243
+ status: 400,
1244
+ code: "UNSUPPORTED_FILE_TYPE",
1245
+ message: `지원하지 않는 파일 형식입니다. ${limit.extensions.join(", ")}만 올릴 수 있습니다`
1246
+ };
1247
+ if (size !== void 0 && size > limit.maxBytes) return {
1248
+ status: 413,
1249
+ code: "FILE_TOO_LARGE",
1250
+ message: `파일이 너무 큽니다. ${Math.floor(limit.maxBytes / MB)}MB까지 올릴 수 있습니다`
1251
+ };
1252
+ return null;
1253
+ }
1254
+ /**
1255
+ * 미디어 업로드 자리 발급 — `POST /v1/uploads`.
1256
+ *
1257
+ * 파일 바이트는 api를 거치지 않는다. 응답의 `uploadUrl`로 클라이언트가 직접 PUT하고,
1258
+ * 끝나면 `url`이 공개 주소가 된다. 서버가 객체 키를 정하므로 요청은 파일명만 보낸다
1259
+ * (확장자만 쓰이고 이름 자체는 저장되지 않는다).
1260
+ */
1261
+ const uploadRequestSchema = z.object({
1262
+ filename: z.string().min(1).max(255),
1263
+ purpose: uploadPurposeSchema.default("description").describe("업로드 용도. 허용 확장자와 크기 상한이 용도별로 다르다"),
1264
+ size: z.number().int().positive().optional().describe("파일 바이트 크기. 주면 용도별 상한을 발급 전에 검사한다")
1078
1265
  });
1079
- /** 쿠폰 사용 조건 */
1080
- const couponConditionsSchema = z.object({
1081
- minOrderAmount: z.number().int().min(0).describe(descriptions$2.minOrderAmount),
1082
- firstOrderOnly: z.boolean().describe(descriptions$2.firstOrderOnly),
1083
- memberOnly: z.boolean().describe(descriptions$2.memberOnly),
1084
- stackWithInstantDiscount: z.boolean().describe(descriptions$2.stackWithInstantDiscount)
1266
+ const uploadTicketSchema = z.object({
1267
+ /** 업로드 완료 후 공개될 주소 */
1268
+ url: z.url(),
1269
+ /** 파일 바이트를 PUT할 presigned URL. 1시간 유효 */
1270
+ uploadUrl: z.url()
1085
1271
  });
1086
- /** 조건을 생략했을 때의 값 */
1087
- const COUPON_CONDITIONS_DEFAULTS = {
1088
- minOrderAmount: 0,
1089
- firstOrderOnly: false,
1090
- memberOnly: true,
1091
- stackWithInstantDiscount: true
1092
- };
1093
- /** 상한 — 대상 목록은 규칙 스냅샷에 통째로 복사되므로 크기를 제한한다 */
1094
- const COUPON_TARGET_LIST_MAX = 500;
1095
- /** 쿠폰 대상 — 포함 목록이 모두 비면 전 상품이다. 제외가 포함보다 우선한다 */
1096
- const couponTargetSchema = z.object({
1097
- includeProductIds: z.array(z.string().min(1)).max(500).describe(descriptions$2.includeProductIds),
1098
- includeCategoryIds: z.array(z.string().min(1)).max(500).describe(descriptions$2.includeCategoryIds),
1099
- excludeProductIds: z.array(z.string().min(1)).max(500).describe(descriptions$2.excludeProductIds),
1100
- excludeCategoryIds: z.array(z.string().min(1)).max(500).describe(descriptions$2.excludeCategoryIds)
1272
+ //#endregion
1273
+ //#region src/schemas/product.ts
1274
+ const productStatusSchema = z.enum([
1275
+ "SALE",
1276
+ "SUSPENDED",
1277
+ "OUT_OF_STOCK",
1278
+ "CLOSED"
1279
+ ]);
1280
+ const productSummarySchema = z.object({
1281
+ productId: z.string(),
1282
+ name: z.string(),
1283
+ status: productStatusSchema,
1284
+ salePrice: z.number().int().describe("판매가. 원(KRW) 단위 정수"),
1285
+ discountedPrice: z.number().int().nullable().describe("즉시 할인 적용가. 원 단위. 할인이 없으면 null"),
1286
+ /** usable variant 재고 합계 (서버 계산) */
1287
+ stockQuantity: z.number().int().describe("판매 가능한 옵션 재고의 합계. 서버가 계산한다"),
1288
+ categoryId: z.string().nullable(),
1289
+ thumbnailUrl: z.url().optional(),
1290
+ /** optionGroups.length > 0 */
1291
+ hasOptions: z.boolean(),
1292
+ fulfillment: z.object({ type: fulfillmentTypeResponseSchema }).describe("상품 이행의 유형. 유형별 속성은 단건 조회(`GET /v1/products/{productId}`)의 `fulfillment`에 있다"),
1293
+ updatedAt: z.string(),
1294
+ sample: z.boolean().optional().default(false).describe("상점을 만들 때 넣은 샘플 상품인가(이슈 #70). 상점 플랫폼이 「샘플」 배지를 단다. 스토어프론트에는 싣지 않는다. 서버는 항상 싣는다")
1295
+ });
1296
+ /** 옵션 축(그룹)이 가지는 값 하나 — 예: 축 "색상"의 값 "블랙" */
1297
+ const optionValueSchema = z.object({
1298
+ valueId: z.string(),
1299
+ name: z.string(),
1300
+ sortOrder: z.number().int()
1301
+ });
1302
+ /** 옵션 축(그룹) — 예: "색상", "사이즈" */
1303
+ const optionGroupSchema = z.object({
1304
+ groupId: z.string(),
1305
+ name: z.string(),
1306
+ sortOrder: z.number().int(),
1307
+ values: z.array(optionValueSchema)
1101
1308
  });
1102
- const COUPON_TARGET_ALL = {
1103
- includeProductIds: [],
1104
- includeCategoryIds: [],
1105
- excludeProductIds: [],
1106
- excludeCategoryIds: []
1107
- };
1108
1309
  /**
1109
- * 쿠폰 규칙 스냅샷 — 계산에 필요한 값만 담는다. 결제 시작이 쿠폰을 읽어 이 형태로 결제 세션에 굳히고,
1110
- * 주문 생성·환불 정산은 이 스냅샷만 읽는다. 결제 도중·주문 뒤 셀러가 쿠폰을 바꿔도 그 주문의 금액은 움직이지 않는다.
1310
+ * 판매 단위(SKU). variantId는 기존 optionId 값 공간을 승계하므로
1311
+ * 카트·주문·리뷰·클레임의 optionId가 곧 variantId다.
1111
1312
  */
1112
- const couponRuleSchema = z.object({
1113
- couponId: z.string().describe("쿠폰 id (`cpn_…`)"),
1114
- name: z.string().describe("쿠폰 이름"),
1115
- kind: couponKindSchema,
1116
- benefit: couponBenefitSchema,
1117
- conditions: couponConditionsSchema,
1118
- target: couponTargetSchema,
1119
- startsAt: z.iso.datetime({ offset: true }).nullable().describe("사용 시작 시각(ISO 8601, 시간대 포함). null이면 제한이 없다"),
1120
- endsAt: z.iso.datetime({ offset: true }).nullable().describe("사용 종료 시각(ISO 8601, 시간대 포함, 이 시각부터 못 쓴다). null이면 제한이 없다")
1313
+ const productVariantSchema = z.object({
1314
+ variantId: z.string(),
1315
+ /** 축 sortOrder 순서와 1:1 대응. 옵션 없는 상품은 [] */
1316
+ valueIds: z.array(z.string()),
1317
+ /** 값 이름을 " / "로 이은 파생 표시명. 옵션 없으면 "" */
1318
+ name: z.string(),
1319
+ additionalPrice: z.number().int(),
1320
+ stockQuantity: z.number().int(),
1321
+ sku: z.string().nullable(),
1322
+ barcode: z.string().nullable(),
1323
+ costPrice: z.number().int().nullable(),
1324
+ weightGram: z.number().int().nullable(),
1325
+ sellerCode: z.string().nullable(),
1326
+ usable: z.boolean(),
1327
+ hasOrders: z.boolean().optional().describe("주문에 담긴 적이 있는 조합인가(이슈 #59). true인 조합은 옵션 교체에서 뺄 수 없고(409 `OPTION_HAS_ORDERS`) `usable: false`로 판매를 끈다. 서버는 항상 싣는다. 요청에는 쓰지 않는다")
1121
1328
  });
1122
- /** 쿠폰을 적용하지 못한 사유 */
1123
- const couponRejectReasonSchema = z.enum([
1124
- "DUPLICATE_COUPON",
1125
- "STACK_LIMIT",
1126
- "NOT_STARTED",
1127
- "EXPIRED",
1128
- "MEMBER_ONLY",
1129
- "FIRST_ORDER_ONLY",
1130
- "NO_ELIGIBLE_ITEMS",
1131
- "MIN_ORDER_AMOUNT",
1132
- "LINE_NOT_ELIGIBLE",
1133
- "LINE_AMBIGUOUS",
1134
- "NO_DISCOUNT"
1135
- ]).describe("쿠폰을 적용하지 못한 사유. `DUPLICATE_COUPON`=같은 쿠폰을 두 번 넣음, `STACK_LIMIT`=같은 종류의 쿠폰이 이미 적용됨(주문 쿠폰·배송비 쿠폰은 주문당 1장, 상품 쿠폰은 주문상품 한 줄에 1장), `NOT_STARTED`=사용 기간 전, `EXPIRED`=사용 기간 지남, `MEMBER_ONLY`=회원 전용, `FIRST_ORDER_ONLY`=첫 구매 전용, `NO_ELIGIBLE_ITEMS`=대상 상품이 주문에 없음, `MIN_ORDER_AMOUNT`=최소 주문 금액 미달, `LINE_NOT_ELIGIBLE`=지정한 주문상품이 대상이 아니거나 이미 상품 쿠폰이 있음, `LINE_AMBIGUOUS`=지정한 `optionId`의 줄이 여럿이라 줄을 정할 수 없음(`lineId`로 지목한다), `NO_DISCOUNT`=할인할 금액이 없음");
1136
- /** 쿠폰 적용 뒤 결제 금액의 하한 — 0원 결제 경로가 없어 이보다 작으면 결제를 시작하지 않는다(400 `PAYMENT_AMOUNT_TOO_LOW`) */
1137
- const MIN_PAYMENT_AMOUNT = 100;
1329
+ /** 옵션 축(그룹) 최대 개수 — 스키마 상한의 단일 원천 */
1330
+ const MAX_OPTION_GROUPS = 3;
1331
+ /** 축 하나가 가질 수 있는 값의 최대 개수 */
1332
+ const MAX_VALUES_PER_GROUP = 100;
1333
+ /** 상품 하나가 가질 수 있는 variant(SKU) 최대 개수 */
1334
+ const MAX_VARIANTS = 500;
1335
+ /** 추가 선택 옵션명 최대 개수 */
1336
+ const MAX_ADDON_GROUPS = 5;
1337
+ /** 직접 입력 항목 최대 개수 */
1338
+ const MAX_CUSTOM_INPUTS = 5;
1339
+ /** 직접 입력 항목 최대 글자 수의 상한 */
1340
+ const MAX_CUSTOM_INPUT_LENGTH = 200;
1341
+ /** 조합 수가 이 값 이상이면 상점 플랫폼이 경고한다(상한은 `MAX_VARIANTS`) */
1342
+ const VARIANT_COUNT_WARNING = 100;
1138
1343
  /**
1139
- * 할인액 — 정률은 10원 단위 내림, 최대 할인액과 기준 금액을 넘지 않는다. 기준 금액이 0 이하면 0이다.
1140
- *
1141
- * 곱셈을 먼저 하고 나눈다. `base × 율 ÷ 1000`을 내려 10을 곱하면 `floor(base × 율 / 100 / 10) × 10`과 같고
1142
- * 부동소수 오차가 끼지 않는다.
1344
+ * 옵션 값 정렬 순서 — 등록순·가나다순·낮은 추가금순·높은 추가금순. 서버가 저장할 때 이 순서로 값의 `sortOrder`를 매긴다.
1345
+ * 추가금순은 추가 선택 값이면 값의 추가금, 조합 값이면 그 값이 든 조합 추가금의 최솟값으로 비교하고 같으면 등록순이다.
1143
1346
  */
1144
- function couponDiscountOf(benefit, base) {
1145
- if (base <= 0) return 0;
1146
- let raw = benefit.type === "RATE" ? Math.floor(base * benefit.value / 1e3) * 10 : benefit.value;
1147
- if (benefit.type === "RATE" && benefit.maxDiscountAmount !== null) raw = Math.min(raw, benefit.maxDiscountAmount);
1148
- return Math.max(0, Math.min(raw, base));
1149
- }
1347
+ const optionSortSchema = z.enum([
1348
+ "REGISTERED",
1349
+ "NAME",
1350
+ "PRICE_ASC",
1351
+ "PRICE_DESC"
1352
+ ]);
1353
+ /** 추가 선택 값 — 예: 옵션명 "선물 포장"의 값 "포장함"(+2,000원) */
1354
+ const addonValueSchema = z.object({
1355
+ valueId: z.string(),
1356
+ name: z.string(),
1357
+ sortOrder: z.number().int(),
1358
+ additionalPrice: z.number().int().describe("이 값을 고르면 단가에 더하는 금액(원, 0 이상)"),
1359
+ usable: z.boolean().describe("사용 여부. false면 구매자에게 보이지 않고 고를 수 없다")
1360
+ });
1361
+ /** 추가 선택 옵션명 */
1362
+ const addonGroupSchema = z.object({
1363
+ groupId: z.string(),
1364
+ name: z.string(),
1365
+ sortOrder: z.number().int(),
1366
+ required: z.boolean().describe("구매자가 꼭 골라야 하는가. false면 고르지 않아도 된다"),
1367
+ values: z.array(addonValueSchema)
1368
+ });
1369
+ /** 직접 입력 항목 — 예: 각인 문구 */
1370
+ const customInputSchema = z.object({
1371
+ inputId: z.string(),
1372
+ label: z.string().describe("항목명"),
1373
+ placeholder: z.string().nullable().describe("입력 안내(구매자 입력 칸의 안내 문구). 없으면 null"),
1374
+ maxLength: z.number().int().describe("최대 글자 수"),
1375
+ required: z.boolean(),
1376
+ usable: z.boolean().describe("사용 여부. false면 구매자에게 보이지 않는다"),
1377
+ sortOrder: z.number().int()
1378
+ });
1150
1379
  /**
1151
- * 금액을 가중치 비율로 나눈다 — **누적 내림의 차분**이다.
1152
- *
1153
- * `i`번째 몫 = `floor(total × W_i / W) − floor(total × W_{i−1} / W)`(`W_i`는 앞에서부터 누적 가중치).
1154
- * 합이 `total`과 정확히 같고, 입력 순서가 같으면 결과도 같다. `total ≤ W`이면 몫이 가중치를 넘지 않는다.
1155
- * 곱이 2^53을 넘을 수 있어 BigInt로 계산한다.
1380
+ * 직접 입력 항목명에 쓸 수 없는 개인정보성 단어 — 상점 플랫폼 검사와 서버 400(`CUSTOM_INPUT_LABEL_FORBIDDEN`)이 이 목록 하나를 쓴다.
1381
+ * 구매자 개인정보는 주문 정보로 받는다. 직접 입력 항목으로 받으면 보관·파기 규칙 밖에 남는다.
1156
1382
  */
1157
- function allocateByWeight(total, weights) {
1158
- if (!Number.isSafeInteger(total) || !weights.every((w) => Number.isSafeInteger(w))) throw new RangeError("allocateByWeight: 금액과 가중치는 안전한 정수여야 합니다");
1159
- const sum = weights.reduce((acc, w) => acc + Math.max(0, w), 0);
1160
- if (total <= 0 || sum <= 0) return weights.map(() => 0);
1161
- const bigTotal = BigInt(total);
1162
- const bigSum = BigInt(sum);
1163
- let cumulative = 0n;
1164
- let previous = 0n;
1165
- return weights.map((weight) => {
1166
- cumulative += BigInt(Math.max(0, weight));
1167
- const upTo = bigTotal * cumulative / bigSum;
1168
- const share = upTo - previous;
1169
- previous = upTo;
1170
- return Number(share);
1171
- });
1172
- }
1173
- /** 카테고리 경로(`/a/b/c/`)가 그 카테고리 또는 하위를 가리키는가 */
1174
- function inCategory(categoryPath, categoryIds) {
1175
- if (!categoryPath || categoryIds.length === 0) return false;
1176
- const chain = categoryPath.split("/").filter(Boolean);
1177
- return categoryIds.some((id) => chain.includes(id));
1178
- }
1179
- /** 줄이 쿠폰 대상인가 — 제외가 포함보다 우선하고, 포함 목록이 모두 비면 전 상품이다 */
1180
- function isCouponTargetLine(rule, line) {
1181
- const { target, conditions } = rule;
1182
- if (!conditions.stackWithInstantDiscount && line.amount < line.baseAmount) return false;
1183
- if (target.excludeProductIds.includes(line.productId)) return false;
1184
- if (inCategory(line.categoryPath, target.excludeCategoryIds)) return false;
1185
- if (target.includeProductIds.length === 0 && target.includeCategoryIds.length === 0) return true;
1186
- return target.includeProductIds.includes(line.productId) || inCategory(line.categoryPath, target.includeCategoryIds);
1187
- }
1188
- /** 시각 파싱 — 값이 없으면 null(제한 없음), 읽지 못하면 NaN(판정에서 거절한다) */
1189
- function timeOf(value) {
1190
- if (!value) return null;
1191
- return Date.parse(value);
1383
+ const CUSTOM_INPUT_FORBIDDEN_WORDS = [
1384
+ "연락처",
1385
+ "전화",
1386
+ "휴대폰",
1387
+ "핸드폰",
1388
+ "폰번호",
1389
+ "메일",
1390
+ "주소",
1391
+ "주민",
1392
+ "생년월일",
1393
+ "생일",
1394
+ "실명",
1395
+ "계좌",
1396
+ "카드번호",
1397
+ "비밀번호",
1398
+ "여권",
1399
+ "통관",
1400
+ "phone",
1401
+ "mobile",
1402
+ "mail",
1403
+ "address"
1404
+ ];
1405
+ /** 금지어 비교용 정규화 — NFKC(전각·호환 문자를 보통 문자로)·소문자 뒤 문자·숫자만 남긴다(공백·기호·제로폭 문자로 쪼갠 우회를 막는다) */
1406
+ function forbiddenWordKeyOf(text) {
1407
+ return text.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
1192
1408
  }
1193
1409
  /**
1194
- * 사용 기간 판정 — 쿠폰 기간과 발급 쿠폰 유효 기간 중 좁은 쪽. 시작은 포함, 종료는 제외.
1195
- * 읽지 못하는 시각은 제한 없음이 아니라 거절이다(fail-closed).
1410
+ * 직접 입력 항목명·입력 안내에 든 개인정보성 금지어. 없으면 null. 공백·기호·대소문자·전각을 무시한다.
1411
+ * 서버는 항목명과 입력 안내 둘 다 이 함수로 검사한다(안내에 「연락처를 적어 주십시오」라고 쓰는 우회를 막는다).
1196
1412
  */
1197
- function periodRejectOf(coupon, now) {
1198
- const at = now.getTime();
1199
- const starts = [timeOf(coupon.rule.startsAt), timeOf(coupon.validFrom)].filter((t) => t !== null);
1200
- const ends = [timeOf(coupon.rule.endsAt), timeOf(coupon.validUntil)].filter((t) => t !== null);
1201
- if (starts.some((t) => Number.isNaN(t) || at < t)) return "NOT_STARTED";
1202
- if (ends.some((t) => Number.isNaN(t) || at >= t)) return "EXPIRED";
1203
- return null;
1413
+ function customInputLabelForbiddenWord(label) {
1414
+ const normalized = forbiddenWordKeyOf(label);
1415
+ return CUSTOM_INPUT_FORBIDDEN_WORDS.find((word) => normalized.includes(forbiddenWordKeyOf(word))) ?? null;
1204
1416
  }
1417
+ /** 옵션명·값 이름 — 앞뒤 공백을 지우고 빈 값은 받지 않는다 */
1418
+ const optionNameSchema = z.string().trim().min(1).max(50);
1419
+ const addonValueRequestSchema = z.object({
1420
+ /** 전달 시 기존 값 행을 유지(UPDATE). 생략하면 신규 발급 */
1421
+ valueId: z.string().optional(),
1422
+ name: optionNameSchema,
1423
+ additionalPrice: z.number().int().min(0).default(0).describe("추가금(원, 0 이상)"),
1424
+ usable: z.boolean().default(true)
1425
+ });
1426
+ const addonGroupRequestSchema = z.object({
1427
+ /** 전달 시 기존 옵션명 행을 유지(UPDATE). 조합 옵션명의 id는 줄 수 없다(방식을 바꾸려면 새 옵션명으로 보낸다) */
1428
+ groupId: z.string().optional(),
1429
+ name: optionNameSchema,
1430
+ required: z.boolean().default(false),
1431
+ values: z.array(addonValueRequestSchema).min(1).max(100)
1432
+ });
1433
+ const customInputRequestSchema = z.object({
1434
+ /** 전달 시 기존 항목 행을 유지(UPDATE). 생략하면 신규 발급 */
1435
+ inputId: z.string().optional(),
1436
+ label: z.string().trim().min(1).max(30),
1437
+ placeholder: z.string().trim().max(100).nullish(),
1438
+ maxLength: z.number().int().min(1).max(200),
1439
+ required: z.boolean().default(false),
1440
+ usable: z.boolean().default(true)
1441
+ });
1205
1442
  /**
1206
- * 쿠폰 금액 계산 — **순수 함수다.** DB를 읽지 않는다. 규칙 스냅샷을 만드는 조회와 한도·보유 판정
1207
- * (발급 쿠폰 상태, 1인 한도)은 호출부 몫이다.
1443
+ * 주문상품·장바구니·주문서 줄의 옵션 선택 스냅샷 한 칸 — 주문 시점 이름이다(상품을 고쳐도 바뀌지 않는다).
1444
+ * 조합 옵션은 옵션명마다 한 칸이고 추가금은 조합 단위라 0이다(조합 추가금은 단가에 들어 있다). 추가 선택은 값의 추가금이다.
1445
+ */
1446
+ const optionSelectionSchema = z.object({
1447
+ kind: z.string().describe("옵션명 방식. 현재 값은 `combination`·`addon`이고 값이 추가될 수 있어 문자열로 받는다"),
1448
+ groupName: z.string(),
1449
+ valueName: z.string(),
1450
+ additionalPrice: z.number().int()
1451
+ });
1452
+ /** 구매자가 적은 직접 입력값 — 주문 시점 항목명과 값 */
1453
+ const customInputValueSchema = z.object({
1454
+ label: z.string(),
1455
+ value: z.string()
1456
+ });
1457
+ const discountSchema = z.object({
1458
+ productId: z.string(),
1459
+ discountType: z.enum(["RATE", "AMOUNT"]),
1460
+ discountValue: z.number().int(),
1461
+ discountedPrice: z.number().int(),
1462
+ startAt: z.string().nullable(),
1463
+ endAt: z.string().nullable()
1464
+ });
1465
+ const productSchema = productSummarySchema.extend({
1466
+ description: z.string().optional(),
1467
+ /**
1468
+ * 상세설명 에디터(@avarlabs/editor)의 직렬화 상태. 재편집 때 이 값으로 문서를 복원한다.
1469
+ * 표시는 `description`(HTML)이 맡는다 — 소비자는 이 필드를 몰라도 된다.
1470
+ */
1471
+ descriptionJson: z.string().nullable().optional(),
1472
+ originalPrice: z.number().int().nullable().optional().describe("정가(할인 전 표시 가격). 원 단위. 비교용 표시 값이고 결제 금액 계산에 쓰지 않는다 — 구매자가 내는 가격은 즉시할인이 있으면 `discountedPrice`, 없으면 `salePrice`다. 스토어프론트 상품 응답에도 같은 값이 실린다. 정하지 않았으면 null"),
1473
+ images: z.array(z.url()).optional(),
1474
+ attributes: z.array(z.object({
1475
+ attributeId: z.string(),
1476
+ value: z.string()
1477
+ })).optional(),
1478
+ optionGroups: z.array(optionGroupSchema),
1479
+ /** 모든 상품이 최소 1개 variant를 갖는다 (옵션 없으면 default variant 1개) */
1480
+ variants: z.array(productVariantSchema),
1481
+ addonGroups: z.array(addonGroupSchema).optional().default([]).describe("추가 선택 옵션명(이슈 #59). 없으면 빈 배열. 서버는 항상 싣는다"),
1482
+ customInputs: z.array(customInputSchema).optional().default([]).describe("직접 입력 항목(이슈 #59). 사용하지 않는 항목도 싣는다. 없으면 빈 배열. 서버는 항상 싣는다"),
1483
+ optionSort: optionSortSchema.optional().default("REGISTERED").describe("옵션 값 정렬 순서(이슈 #59). 서버는 항상 싣는다"),
1484
+ fulfillment: productFulfillmentResponseSchema,
1485
+ fulfillmentTypeLocked: z.boolean().describe("판매 이력(주문상품)이 있어 상품 유형(`fulfillment.type`)을 바꿀 수 없는가. true면 다른 유형으로 수정하는 요청이 409 `FULFILLMENT_TYPE_LOCKED`다. 같은 유형 안의 속성은 바꿀 수 있다"),
1486
+ refundPolicy: productRefundPolicyPatchSchema.nullable().describe("상품별 환불 정책 덮어쓰기(상품이 정한 항목만). null이면 상점 기본 환불 정책을 쓴다. 반품·교환 배송비(`returnDeliveryFee`·`exchangeDeliveryFee`)는 배송 상품에만 있다"),
1487
+ /**
1488
+ * 설정된 즉시할인. `GET /products/{productId}/discount`의 응답과 같은 형태다(이슈 #51).
1489
+ * 할인이 없으면 null이다. 이 필드를 싣기 전 서버의 응답에는 없으므로 optional이다.
1490
+ */
1491
+ discount: discountSchema.nullable().optional().describe("설정된 즉시할인. GET /v1/products/{productId}/discount 응답과 같은 형태다. 할인이 없으면 null"),
1492
+ createdAt: z.string().optional()
1493
+ });
1494
+ /** 상품 하나에 등록할 수 있는 이미지 최대 장수 — 첫 장이 대표(썸네일)다 */
1495
+ const MAX_PRODUCT_IMAGES = 10;
1496
+ /**
1497
+ * 상품 이미지 주소의 와이어 형식 — URL이고 2048자 이하다.
1208
1498
  *
1209
- * 종류별로 상품 쿠폰 → 주문 쿠폰 → 배송비 쿠폰 순으로 적용한다. 같은 종류의 자리와 같은 쿠폰 id는
1210
- * **적용된** 쿠폰만 차지한다 — 앞선 주문 쿠폰이 최소 금액 미달이면 뒤의 주문 쿠폰이 쓰이고, 같은 쿠폰의 발급
1211
- * 사본 중 앞 장이 만료됐으면 뒤 장이 쓰인다. 결과의 `coupons`는 입력 순서 그대로다. 입력이 같으면 결과도 같다.
1499
+ * https·확장자 규칙은 여기서 강제하지 않고 `productImageRejection`이 판정한다. 서버가
1500
+ * **새로 추가된 주소에만** 그 규칙을 적용해야 하기 때문이다(규칙이 생기기 전에 저장된
1501
+ * 주소 하나로 상품 수정 전체가 막히면 셀러가 빠져나갈 길이 없다).
1502
+ *
1503
+ * 응답 스키마(`productSchema.images`)도 넓게 둔다. 과거에 저장된 주소를 파싱 오류로
1504
+ * 만들면 상품 조회 자체가 막힌다.
1212
1505
  */
1213
- function quotePricing(input) {
1214
- const lines = input.lines.map((line) => ({
1215
- line,
1216
- productDiscount: 0,
1217
- allocations: []
1218
- }));
1219
- const lineIndex = new Map(lines.map((entry, index) => [entry.line.lineId, index]));
1220
- const lineOf = (id) => {
1221
- const index = lineIndex.get(id);
1222
- return index === void 0 ? void 0 : lines[index];
1223
- };
1224
- const results = input.coupons.map((coupon) => ({
1225
- couponKey: coupon.couponKey ?? coupon.rule.couponId,
1226
- couponId: coupon.rule.couponId,
1227
- kind: coupon.rule.kind,
1228
- applied: false,
1229
- discount: 0,
1230
- rejectReason: null,
1231
- lineId: null,
1232
- eligibleLineIds: []
1233
- }));
1234
- const commonRejectOf = (coupon, result) => {
1235
- if (!input.ignoreEligibility) {
1236
- const period = periodRejectOf(coupon, input.now);
1237
- if (period) return period;
1238
- if (coupon.rule.conditions.memberOnly && !input.isMember) return "MEMBER_ONLY";
1239
- if (coupon.rule.conditions.firstOrderOnly && !input.firstOrder) return "FIRST_ORDER_ONLY";
1240
- }
1241
- const pinned = coupon.eligibleLineIds ? new Set(coupon.eligibleLineIds) : null;
1242
- const eligible = lines.filter((entry) => pinned ? pinned.has(entry.line.lineId) : isCouponTargetLine(coupon.rule, entry.line));
1243
- result.eligibleLineIds = eligible.map((entry) => entry.line.lineId);
1244
- if (eligible.length === 0) return "NO_ELIGIBLE_ITEMS";
1245
- if (eligible.reduce((sum, entry) => sum + entry.line.amount, 0) < coupon.rule.conditions.minOrderAmount) return "MIN_ORDER_AMOUNT";
1246
- return null;
1506
+ const productImageUrlSchema = z.url().max(2048).describe("상품 이미지 주소. https만 허용하고, 확장자가 있으면 이미지 형식이어야 한다");
1507
+ /**
1508
+ * 상품에 **새로 붙이는** 이미지 주소의 규칙. 통과하면 `null`.
1509
+ *
1510
+ * - https만 받는다. http는 https 스토어프론트에서 혼합 콘텐츠로 차단되고 `data:`·`javascript:`는
1511
+ * 상세 화면에 박히는 주입 경로가 된다.
1512
+ * - 호스트는 제한하지 않는다 — 셀러가 이미 쓰는 이미지 호스트를 그대로 붙일 수 있어야 한다.
1513
+ * - 경로에 확장자가 있으면 상품 이미지 형식이어야 한다(svg·동영상 거부). **확장자가 없으면 통과시킨다**
1514
+ * — 외부 이미지 CDN은 확장자 없는 키를 쓰는 경우가 흔하고, 주소만으로는 실제 형식을 알 수 없다.
1515
+ */
1516
+ function productImageRejection(url) {
1517
+ if (url.length > 2048) return {
1518
+ code: "INVALID_IMAGE_URL",
1519
+ message: "이미지 주소가 너무 깁니다"
1247
1520
  };
1248
- const passed = [];
1249
- input.coupons.forEach((coupon, index) => {
1250
- const result = results[index];
1251
- const reason = commonRejectOf(coupon, result);
1252
- if (reason) result.rejectReason = reason;
1253
- else passed.push(index);
1254
- });
1255
- /** 적용된 쿠폰 id — 같은 쿠폰 정의는 주문에 한 장 */
1256
- const appliedCouponIds = /* @__PURE__ */ new Set();
1257
- const apply = (index, discount, lineId) => {
1258
- const result = results[index];
1259
- result.applied = true;
1260
- result.discount = discount;
1261
- result.lineId = lineId;
1262
- appliedCouponIds.add(result.couponId);
1521
+ let parsed;
1522
+ try {
1523
+ parsed = new URL(url);
1524
+ } catch {
1525
+ return {
1526
+ code: "INVALID_IMAGE_URL",
1527
+ message: "이미지 주소 형식이 아닙니다"
1528
+ };
1529
+ }
1530
+ if (parsed.protocol !== "https:") return {
1531
+ code: "INVALID_IMAGE_URL",
1532
+ message: "이미지 주소는 https로 시작해야 합니다"
1263
1533
  };
1264
- const duplicate = (index) => {
1265
- const result = results[index];
1266
- if (!appliedCouponIds.has(result.couponId)) return false;
1267
- result.rejectReason = "DUPLICATE_COUPON";
1268
- return true;
1534
+ const extension = uploadExtensionOf(parsed.pathname);
1535
+ const allowed = UPLOAD_PURPOSE_LIMITS["product-image"].extensions;
1536
+ if (extension && !allowed.includes(extension)) return {
1537
+ code: "UNSUPPORTED_FILE_TYPE",
1538
+ message: `상품 이미지는 ${allowed.join(", ")}만 쓸 수 있습니다`
1269
1539
  };
1270
- const productIndexes = passed.filter((index) => input.coupons[index]?.rule.kind === "PRODUCT");
1271
- const productOrder = [...productIndexes.filter((index) => input.coupons[index]?.lineId), ...productIndexes.filter((index) => !input.coupons[index]?.lineId).map((index) => ({
1272
- index,
1273
- width: results[index]?.eligibleLineIds.length ?? 0
1274
- })).sort((a, b) => a.width - b.width || a.index - b.index).map((entry) => entry.index)];
1275
- const takenLines = /* @__PURE__ */ new Set();
1276
- for (const index of productOrder) {
1277
- const coupon = input.coupons[index];
1278
- const result = results[index];
1279
- if (duplicate(index)) continue;
1280
- const candidates = result.eligibleLineIds.filter((id) => !takenLines.has(id));
1281
- let chosen = null;
1282
- let best = 0;
1283
- if (coupon.lineId) {
1284
- if (!candidates.includes(coupon.lineId)) {
1285
- result.rejectReason = result.eligibleLineIds.includes(coupon.lineId) ? "STACK_LIMIT" : "LINE_NOT_ELIGIBLE";
1286
- continue;
1287
- }
1288
- chosen = coupon.lineId;
1289
- best = couponDiscountOf(coupon.rule.benefit, lineOf(chosen)?.line.amount ?? 0);
1290
- } else {
1291
- if (candidates.length === 0) {
1292
- result.rejectReason = "STACK_LIMIT";
1293
- continue;
1294
- }
1295
- for (const id of candidates) {
1296
- const discount = couponDiscountOf(coupon.rule.benefit, lineOf(id)?.line.amount ?? 0);
1297
- if (chosen === null || discount > best) {
1298
- chosen = id;
1299
- best = discount;
1300
- }
1301
- }
1302
- }
1303
- const entry = chosen === null ? void 0 : lineOf(chosen);
1304
- if (!entry || chosen === null || best <= 0) {
1305
- result.rejectReason = "NO_DISCOUNT";
1306
- continue;
1307
- }
1308
- takenLines.add(chosen);
1309
- entry.productDiscount += best;
1310
- entry.allocations.push({
1311
- couponKey: result.couponKey,
1312
- amount: best
1313
- });
1314
- apply(index, best, chosen);
1315
- }
1316
- let orderSlot = false;
1317
- for (const index of passed) {
1318
- const coupon = input.coupons[index];
1319
- const result = results[index];
1320
- if (coupon.rule.kind !== "ORDER") continue;
1321
- if (duplicate(index)) continue;
1322
- if (orderSlot) {
1323
- result.rejectReason = "STACK_LIMIT";
1324
- continue;
1325
- }
1326
- const targets = result.eligibleLineIds.map((id) => lineOf(id)).filter((entry) => entry !== void 0);
1327
- const weights = targets.map((entry) => Math.max(0, entry.line.amount - entry.productDiscount));
1328
- const base = weights.reduce((sum, w) => sum + w, 0);
1329
- const discount = couponDiscountOf(coupon.rule.benefit, base);
1330
- if (discount <= 0) {
1331
- result.rejectReason = "NO_DISCOUNT";
1332
- continue;
1333
- }
1334
- const shares = allocateByWeight(discount, weights);
1335
- targets.forEach((entry, i) => {
1336
- const share = shares[i] ?? 0;
1337
- if (share > 0) entry.allocations.push({
1338
- couponKey: result.couponKey,
1339
- amount: share
1340
- });
1341
- });
1342
- orderSlot = true;
1343
- apply(index, discount, null);
1344
- }
1345
- const deliveryBundles = input.delivery?.bundles ?? [];
1346
- const bundles = deliveryBundles.map((bundle) => ({
1347
- bundleKey: bundle.bundleKey,
1348
- couponDiscount: 0
1349
- }));
1350
- let deliverySlot = false;
1351
- let deliveryDiscountAmount = 0;
1352
- for (const index of passed) {
1353
- const coupon = input.coupons[index];
1354
- const result = results[index];
1355
- if (coupon.rule.kind !== "DELIVERY") continue;
1356
- if (duplicate(index)) continue;
1357
- if (deliverySlot) {
1358
- result.rejectReason = "STACK_LIMIT";
1359
- continue;
1360
- }
1361
- const weights = deliveryBundles.map((bundle) => Math.max(0, bundle.fee));
1362
- const base = weights.reduce((sum, w) => sum + w, 0);
1363
- const discount = couponDiscountOf(coupon.rule.benefit, base);
1364
- if (discount <= 0) {
1365
- result.rejectReason = "NO_DISCOUNT";
1366
- continue;
1367
- }
1368
- allocateByWeight(discount, weights).forEach((share, i) => {
1369
- const bundle = bundles[i];
1370
- if (bundle) bundle.couponDiscount += share;
1371
- });
1372
- deliverySlot = true;
1373
- deliveryDiscountAmount = discount;
1374
- apply(index, discount, null);
1375
- }
1376
- const lineResults = lines.map((entry) => ({
1377
- lineId: entry.line.lineId,
1378
- amount: entry.line.amount,
1379
- couponDiscount: entry.allocations.reduce((sum, a) => sum + a.amount, 0),
1380
- allocations: entry.allocations
1381
- }));
1382
- const productAmount = lines.reduce((sum, entry) => sum + entry.line.amount, 0);
1383
- const couponDiscountAmount = lineResults.reduce((sum, line) => sum + line.couponDiscount, 0);
1384
- const deliveryFee = deliveryBundles.reduce((sum, bundle) => sum + bundle.fee + bundle.remoteSurcharge, 0);
1385
- return {
1386
- lines: lineResults,
1387
- coupons: results,
1388
- bundles,
1389
- productAmount,
1390
- couponDiscountAmount,
1391
- deliveryFee,
1392
- deliveryDiscountAmount,
1393
- totalAmount: productAmount - couponDiscountAmount + deliveryFee - deliveryDiscountAmount
1394
- };
1395
- }
1396
- const NO_COUPON_SETTLEMENT = {
1397
- chargeAmount: 0,
1398
- refundAmount: 0,
1399
- entries: []
1400
- };
1401
- /**
1402
- * 쿠폰 조건 미달 차감 — **순수 함수다.** 부분 취소·반품으로 남은 주문상품이 쿠폰 조건을 잃으면,
1403
- * 남은 주문상품이 받던 할인만큼을 이번 환불에서 뺀다.
1404
- *
1405
- * 쿠폰마다
1406
- * - `allocatedRemaining` = 남은 수량에 배분된 할인(`proratedRefundBase`로 수량 비율)
1407
- * - `recomputed` = 주문 시점 규칙 스냅샷과 주문 시점 대상 줄로, 남은 금액만 넣어 `quotePricing`을 다시 돌린 할인
1408
- * - 쿠폰이 **여전히 적용되면 목표 0**, 조건을 잃었으면 목표 = `allocatedRemaining`이다. 적용되는 쿠폰의
1409
- * 정률 10원 내림 차이까지 차감하면 조건을 지킨 구매자가 몇 원씩 잃는다 — 조건을 잃은 경우만 본다.
1410
- * 재계산 할인이 더 커지는 경우(정률 최대 할인액)도 차감하지 않고 더 돌려주지도 않는다.
1411
- * - `delta` = 목표 − (이미 차감 + 이미 면제). 양수면 청구하고(판매자 귀책·정책 꺼짐이면 면제로 기록),
1412
- * 음수면 앞서 뺀 차감부터 돌려준다. 마지막 활성 수량까지 취소되면 목표가 0이라 누적 차감이 모두 되돌아가고,
1413
- * 전량 취소의 환불 합계가 결제액과 같아진다.
1414
- * - 청구는 이번 환불에서 실제로 뺄 수 있는 금액(`chargeableAmount` + 이번에 되돌린 금액)을 넘지 않고,
1415
- * **실제로 뺀 금액만** 기록한다. 못 뺀 나머지는 기록하지 않아 다음 환불이 다시 본다.
1416
- *
1417
- * 배송비 쿠폰은 여기서 다루지 않는다. 배송비 쿠폰을 발급하게 되면(3단계) 묶음 배송비 환불
1418
- * (`computeDeliveryFeeSettlement`)이 그 묶음의 `couponDiscount`를 빼야 한다 — 빼지 않으면 받지 않은 배송비를 돌려준다.
1419
- */
1420
- function settleCouponShortfall(input) {
1421
- const coupons = input.coupons.filter((coupon) => coupon.rule.kind !== "DELIVERY");
1422
- if (coupons.length === 0) return NO_COUPON_SETTLEMENT;
1423
- const recomputed = quotePricing({
1424
- lines: input.lines.map((line) => {
1425
- const remaining = proratedRefundBase(line.totalPrice, line.quantity, 0, line.activeQuantity);
1426
- return {
1427
- lineId: line.lineId,
1428
- productId: line.productId,
1429
- quantity: line.activeQuantity,
1430
- baseAmount: remaining,
1431
- amount: remaining
1432
- };
1433
- }),
1434
- coupons: coupons.map((coupon) => ({
1435
- rule: coupon.rule,
1436
- couponKey: coupon.couponKey,
1437
- lineId: coupon.lineId,
1438
- eligibleLineIds: coupon.eligibleLineIds
1439
- })),
1440
- delivery: null,
1441
- firstOrder: true,
1442
- isMember: true,
1443
- now: /* @__PURE__ */ new Date(0),
1444
- ignoreEligibility: true
1445
- });
1446
- const base = coupons.map((coupon, index) => {
1447
- const allocatedRemaining = input.lines.reduce((sum, line) => {
1448
- return sum + proratedRefundBase(line.allocations.filter((a) => a.couponKey === coupon.couponKey).reduce((acc, a) => acc + a.amount, 0), line.quantity, 0, line.activeQuantity);
1449
- }, 0);
1450
- const result = recomputed.coupons[index];
1451
- const stillApplied = result?.applied === true;
1452
- const state = input.settlements[coupon.couponKey] ?? {};
1453
- return {
1454
- couponKey: coupon.couponKey,
1455
- allocatedRemaining,
1456
- recomputed: stillApplied ? result?.discount ?? 0 : 0,
1457
- target: stillApplied ? 0 : allocatedRemaining,
1458
- charged: Math.max(0, state.charged ?? 0),
1459
- waived: Math.max(0, state.waived ?? 0)
1460
- };
1461
- });
1462
- const entries = [];
1463
- let refundAmount = 0;
1464
- for (const item of base) {
1465
- const delta = item.target - (item.charged + item.waived);
1466
- let refund = 0;
1467
- let waived = item.waived;
1468
- if (delta < 0) {
1469
- refund = Math.min(item.charged, -delta);
1470
- waived = Math.max(0, waived - (-delta - refund));
1471
- }
1472
- refundAmount += refund;
1473
- entries.push({
1474
- couponKey: item.couponKey,
1475
- allocatedRemaining: item.allocatedRemaining,
1476
- recomputed: item.recomputed,
1477
- target: item.target,
1478
- charge: 0,
1479
- refund,
1480
- waive: 0,
1481
- next: {
1482
- charged: item.charged - refund,
1483
- waived
1484
- }
1485
- });
1486
- }
1487
- let capacity = Math.max(0, input.chargeableAmount) + refundAmount;
1488
- let chargeAmount = 0;
1489
- const chargeAllowed = input.shortfallCharge && !input.sellerFault;
1490
- for (const entry of entries) {
1491
- const delta = entry.target - (entry.next.charged + entry.next.waived);
1492
- if (delta <= 0) continue;
1493
- if (!chargeAllowed) {
1494
- entry.waive = delta;
1495
- entry.next = {
1496
- ...entry.next,
1497
- waived: entry.next.waived + delta
1498
- };
1499
- continue;
1500
- }
1501
- const charge = Math.min(delta, capacity);
1502
- if (charge <= 0) continue;
1503
- capacity -= charge;
1504
- chargeAmount += charge;
1505
- entry.charge = charge;
1506
- entry.next = {
1507
- ...entry.next,
1508
- charged: entry.next.charged + charge
1509
- };
1510
- }
1511
- return {
1512
- chargeAmount,
1513
- refundAmount,
1514
- entries
1515
- };
1516
- }
1517
- /**
1518
- * 주문상품의 실결제 금액 중 이번 취소 수량의 몫 — **실결제 금액(`totalPrice − couponDiscountAmount`)을
1519
- * 누적 내림의 차분으로 나눈다.**
1520
- *
1521
- * 상품 금액과 쿠폰 배분을 따로 나눠 빼면 합은 같지만 한 번의 몫이 −1원이 되는 경우가 있다
1522
- * (예: 금액 3원·할인 2원·수량 5개에서 셋째 개). 음수를 0으로 자르면 합이 1원 넘친다. 실결제 금액을
1523
- * 바로 나누면 몫이 항상 0 이상이고 전량 취소의 합이 실결제 금액과 정확히 같다.
1524
- */
1525
- function proratedPaidRefundBase(item, settledQuantity, cancelQuantity) {
1526
- return proratedRefundBase(Math.max(0, item.totalPrice - (item.couponDiscountAmount ?? 0)), item.quantity, settledQuantity, cancelQuantity);
1527
- }
1528
- //#endregion
1529
- //#region src/schemas/coupon.ts
1530
- /**
1531
- * 쿠폰 관리 API 계약 (이슈 #47) — `/v1/coupons/**`, 스코프 `promotion:r|rw`.
1532
- *
1533
- * 금액 계산 규칙은 `promotion.ts`(`quotePricing`)가 원천이고, 여기에는 셀러가 쿠폰을 만들고 나눠 주는 표면만 있다.
1534
- * 발급·사용이 시작된 쿠폰은 혜택·조건·대상·사용 시작일을 바꿀 수 없다(409 `COUPON_LOCKED`). 이름·설명·종료일 연장·
1535
- * 한도 상향·중지·재개·종료만 된다. 규칙을 바꾸려면 새 쿠폰을 만든다.
1536
- */
1537
- /** 쿠폰 상태 — `ACTIVE`(사용 중)·`PAUSED`(중지, 재개 가능)·`ENDED`(종료, 되돌릴 수 없음) */
1538
- const couponStatusSchema = z.enum([
1539
- "ACTIVE",
1540
- "PAUSED",
1541
- "ENDED"
1542
- ]).describe("쿠폰 상태. `ACTIVE`=사용 중, `PAUSED`=중지(재개 가능), `ENDED`=종료(되돌릴 수 없음)");
1543
- /** 지금 만들 수 있는 발급 방식 — 다운로드·가입 자동 지급은 뒤 단계다 */
1544
- const couponIssueMethodSchema = z.enum(["CODE", "MANUAL"]).describe("발급 방식. `CODE`=구매자가 주문서에 코드를 입력한다, `MANUAL`=셀러가 회원을 골라 지급한다");
1545
- /** 코드 형식 — 영문·숫자·`-`·`_` 6~30자. 대소문자를 구분하지 않고 서버가 대문자로 저장한다 */
1546
- const COUPON_CODE_PATTERN = /^[A-Za-z0-9_-]{6,30}$/;
1547
- /** 코드 정규화 — 대문자, 앞뒤 공백 제거. 입력·저장·조회가 같은 함수를 쓴다 */
1548
- function normalizeCouponCode(code) {
1549
- return code.trim().toUpperCase();
1550
- }
1551
- const limitField = (description) => z.number().int().min(1).max(1e7).nullable().optional().describe(description);
1552
- const descriptions$1 = {
1553
- name: "구매자에게 보이는 쿠폰 이름. 1~60자",
1554
- description: "구매자에게 보이는 설명. 300자까지",
1555
- code: "쿠폰 코드 — 발급 방식이 `CODE`일 때 필수. 영문·숫자·`-`·`_` 6~30자, 대소문자 구분 없음(대문자로 저장). 상점 안에서 유일하다",
1556
- startsAt: "사용 시작 시각(ISO 8601, 시간대 포함). 생략하면 바로 쓸 수 있다",
1557
- endsAt: "사용 종료 시각(ISO 8601, 시간대 포함). 이 시각부터 못 쓴다. 생략하면 제한이 없다. 이미 지급한 쿠폰의 유효 기간은 지급 때 정해지므로 뒤에 종료일을 늦춰도 늘어나지 않는다",
1558
- validDaysAfterIssue: "발급 후 유효 일수 — 셀러 지급(`MANUAL`) 쿠폰만. 쿠폰 종료일과 둘 중 먼저 끝나는 쪽이 적용된다",
1559
- totalIssueLimit: "총 발급 수 한도. 생략하면 무제한",
1560
- totalUseLimit: "총 사용 수 한도. 결제 중인 예약도 센다. 생략하면 무제한",
1561
- perMemberIssueLimit: "1인 발급 수 한도(셀러 지급). 생략하면 1인 1장",
1562
- perMemberUseLimit: "1인 사용 수 한도(코드 입력형). 생략하면 무제한. 비회원에게 여는 쿠폰은 둘 수 없다"
1563
- };
1564
- /** 조건 입력 — 생략한 항목은 기본값(`COUPON_CONDITIONS_DEFAULTS`)이다 */
1565
- const couponConditionsInputSchema = z.strictObject({
1566
- minOrderAmount: z.number().int().min(0).optional(),
1567
- firstOrderOnly: z.boolean().optional(),
1568
- memberOnly: z.boolean().optional(),
1569
- stackWithInstantDiscount: z.boolean().optional()
1570
- });
1571
- /** 대상 입력 — 생략한 목록은 빈 목록이다(포함 목록이 모두 비면 전 상품) */
1572
- const couponTargetInputSchema = z.strictObject({
1573
- includeProductIds: z.array(z.string().min(1)).max(500).optional(),
1574
- includeCategoryIds: z.array(z.string().min(1)).max(500).optional(),
1575
- excludeProductIds: z.array(z.string().min(1)).max(500).optional(),
1576
- excludeCategoryIds: z.array(z.string().min(1)).max(500).optional()
1577
- });
1578
- const couponTimeField = (description) => z.iso.datetime({ offset: true }).nullable().optional().describe(description);
1579
- /** `POST /v1/coupons` */
1580
- const createCouponRequestSchema = z.strictObject({
1581
- name: z.string().trim().min(1).max(60).describe(descriptions$1.name),
1582
- description: z.string().max(300).nullable().optional().describe(descriptions$1.description),
1583
- kind: couponKindSchema,
1584
- benefit: couponBenefitSchema,
1585
- conditions: couponConditionsInputSchema.optional().describe("사용 조건. 생략한 항목은 기본값(최소 금액 0, 회원 전용, 즉시할인과 중복 가능)"),
1586
- target: couponTargetInputSchema.optional().describe("대상 상품·카테고리. 생략하면 전 상품"),
1587
- issueMethod: couponIssueMethodSchema,
1588
- code: z.string().regex(COUPON_CODE_PATTERN).optional().describe(descriptions$1.code),
1589
- startsAt: couponTimeField(descriptions$1.startsAt),
1590
- endsAt: couponTimeField(descriptions$1.endsAt),
1591
- validDaysAfterIssue: z.number().int().min(1).max(3650).nullable().optional().describe(descriptions$1.validDaysAfterIssue),
1592
- totalIssueLimit: limitField(descriptions$1.totalIssueLimit),
1593
- totalUseLimit: limitField(descriptions$1.totalUseLimit),
1594
- perMemberIssueLimit: limitField(descriptions$1.perMemberIssueLimit),
1595
- perMemberUseLimit: limitField(descriptions$1.perMemberUseLimit)
1596
- }).superRefine((body, ctx) => {
1597
- const issue = (path, message) => ctx.addIssue({
1598
- code: "custom",
1599
- path: [path],
1600
- message
1601
- });
1602
- if (body.issueMethod === "CODE" && !body.code) issue("code", "코드 입력형 쿠폰은 코드가 필요합니다");
1603
- if (body.issueMethod !== "CODE" && body.code) issue("code", "코드는 코드 입력형 쿠폰에만 둡니다");
1604
- if (body.issueMethod === "CODE" && body.validDaysAfterIssue != null) issue("validDaysAfterIssue", "발급 후 유효 일수는 셀러 지급 쿠폰에만 둡니다");
1605
- if (body.issueMethod === "CODE" && body.perMemberIssueLimit != null) issue("perMemberIssueLimit", "1인 발급 수 한도는 셀러 지급 쿠폰에만 둡니다");
1606
- if (body.issueMethod === "MANUAL" && body.perMemberUseLimit != null) issue("perMemberUseLimit", "셀러 지급 쿠폰은 발급 한 장이 한 번 쓰입니다. 1인 발급 수 한도를 쓰십시오");
1607
- if (!(body.conditions?.memberOnly ?? COUPON_CONDITIONS_DEFAULTS.memberOnly) && (body.issueMethod !== "CODE" || body.perMemberUseLimit != null)) issue("conditions", "비회원에게 여는 쿠폰은 코드 입력형이면서 1인 사용 한도가 없어야 합니다");
1608
- if (body.startsAt && body.endsAt && Date.parse(body.endsAt) <= Date.parse(body.startsAt)) issue("endsAt", "종료 시각은 시작 시각보다 뒤여야 합니다");
1609
- });
1610
- /**
1611
- * `PATCH /v1/coupons/{couponId}` — 준 항목만 바뀐다. 발급·사용이 시작된 쿠폰(`locked`)은 이름·설명·종료일 연장·
1612
- * 한도 상향만 받고, 그 밖의 항목이 현재 값과 다르면 409 `COUPON_LOCKED`다. 종류·발급 방식은 바꿀 수 없다.
1613
- */
1614
- const updateCouponRequestSchema = z.strictObject({
1615
- name: z.string().trim().min(1).max(60).optional().describe(descriptions$1.name),
1616
- description: z.string().max(300).nullable().optional().describe(descriptions$1.description),
1617
- benefit: couponBenefitSchema.optional(),
1618
- conditions: couponConditionsInputSchema.optional(),
1619
- target: couponTargetInputSchema.optional(),
1620
- code: z.string().regex(COUPON_CODE_PATTERN).optional().describe(descriptions$1.code),
1621
- startsAt: couponTimeField(descriptions$1.startsAt),
1622
- endsAt: couponTimeField(descriptions$1.endsAt),
1623
- validDaysAfterIssue: z.number().int().min(1).max(3650).nullable().optional(),
1624
- totalIssueLimit: limitField(descriptions$1.totalIssueLimit),
1625
- totalUseLimit: limitField(descriptions$1.totalUseLimit),
1626
- perMemberIssueLimit: limitField(descriptions$1.perMemberIssueLimit),
1627
- perMemberUseLimit: limitField(descriptions$1.perMemberUseLimit)
1628
- });
1629
- /** 쿠폰 응답 */
1630
- const couponSchema = z.object({
1631
- couponId: z.string(),
1632
- name: z.string(),
1633
- description: z.string().nullable(),
1634
- kind: couponKindSchema,
1635
- benefit: couponBenefitSchema,
1636
- conditions: couponConditionsSchema,
1637
- target: couponTargetSchema,
1638
- issueMethod: z.string().describe("발급 방식. 현재 값은 `CODE`·`MANUAL`이고 값이 추가될 수 있어 문자열로 받는다(다운로드·가입 자동 지급 예정)"),
1639
- code: z.string().nullable().describe("쿠폰 코드(대문자). 코드 입력형이 아니면 null"),
1640
- startsAt: z.string().nullable(),
1641
- endsAt: z.string().nullable(),
1642
- validDaysAfterIssue: z.number().int().nullable(),
1643
- totalIssueLimit: z.number().int().nullable(),
1644
- totalUseLimit: z.number().int().nullable(),
1645
- perMemberIssueLimit: z.number().int().nullable(),
1646
- perMemberUseLimit: z.number().int().nullable(),
1647
- issuedCount: z.number().int().describe("발급 수 — 회수한 발급도 센다. 총 발급 한도(`totalIssueLimit`)가 이 값과 비교된다"),
1648
- reservedCount: z.number().int().describe("결제 중인 예약 수 — 결제가 끝나거나 만료되면 풀린다"),
1649
- usedCount: z.number().int().describe("사용 수 — 전량 취소로 복원되면 줄어든다"),
1650
- status: couponStatusSchema,
1651
- locked: z.boolean().describe("발급·사용이 시작돼 혜택·조건·대상·사용 시작일을 바꿀 수 없는가"),
1652
- lockedAt: z.string().nullable(),
1653
- createdAt: z.string(),
1654
- updatedAt: z.string()
1655
- });
1656
- /** `GET /v1/coupons` 쿼리 */
1657
- const couponListQuerySchema = z.object({
1658
- status: couponStatusSchema.optional(),
1659
- kind: couponKindSchema.optional(),
1660
- issueMethod: couponIssueMethodSchema.optional(),
1661
- q: z.string().optional().describe("이름·코드 검색(포함)"),
1662
- ...pageQueryShape()
1663
- });
1664
- /** 발급 쿠폰 상태 — 만료는 저장 상태가 아니라 조회 시각으로 판정한 값이다 */
1665
- const couponIssueStatusSchema = z.string().describe("발급 쿠폰 상태. 현재 값은 `AVAILABLE`(사용 가능)·`RESERVED`(결제 중)·`USED`(사용함)·`REVOKED`(회수)·`EXPIRED`(유효 기간 지남)이고 값이 추가될 수 있어 문자열로 받는다");
1666
- const couponIssueSchema = z.object({
1667
- issueId: z.string(),
1668
- couponId: z.string(),
1669
- customerId: z.string(),
1670
- customerName: z.string().nullable().describe("구매자 이름. 탈퇴한 구매자는 null"),
1671
- status: couponIssueStatusSchema,
1672
- validFrom: z.string(),
1673
- validUntil: z.string().nullable(),
1674
- usedOrderId: z.string().nullable(),
1675
- issuedAt: z.string(),
1676
- revokedAt: z.string().nullable()
1677
- });
1678
- const couponIssueListQuerySchema = z.object({
1679
- status: z.enum([
1680
- "AVAILABLE",
1681
- "RESERVED",
1682
- "USED",
1683
- "REVOKED"
1684
- ]).optional(),
1685
- ...pageQueryShape()
1686
- });
1687
- /** `POST /v1/coupons/{couponId}/issues` — 셀러 지급. 한 번에 1,000명까지 */
1688
- 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명, 중복 불가)") });
1689
- const issueCouponResultSchema = z.object({
1690
- issued: z.array(z.object({
1691
- customerId: z.string(),
1692
- issueId: z.string()
1693
- })),
1694
- failed: z.array(z.object({
1695
- customerId: z.string(),
1696
- code: z.string().describe("실패 사유. `CUSTOMER_NOT_FOUND`(이 상점 구매자가 아님)·`COUPON_LIMIT_REACHED`(1인 발급 한도)·`COUPON_EXHAUSTED`(총 발급 한도)"),
1697
- message: z.string()
1698
- }))
1699
- });
1700
- const couponRedemptionSchema = z.object({
1701
- redemptionId: z.string(),
1702
- couponId: z.string(),
1703
- issueId: z.string().nullable(),
1704
- customerId: z.string().nullable().describe("구매자 id. 비회원 주문은 null"),
1705
- orderId: z.string().nullable().describe("주문 id. 결제가 끝나기 전의 예약·해제는 null"),
1706
- status: z.string().describe("사용 기록 상태. 현재 값은 `RESERVED`(결제 중)·`USED`(사용함)·`RELEASED`(결제 실패·만료로 해제)·`RESTORED`(전량 취소로 복원)이고 값이 추가될 수 있어 문자열로 받는다"),
1707
- discountAmount: z.number().int(),
1708
- reservedAt: z.string(),
1709
- usedAt: z.string().nullable(),
1710
- releasedAt: z.string().nullable(),
1711
- restoredAt: z.string().nullable(),
1712
- restoreSkippedReason: z.string().nullable().describe("전량 취소에도 복원하지 않은 사유 — `EXPIRED`(유효 기간 지남)·`ENDED`(종료된 쿠폰)")
1713
- });
1714
- const couponRedemptionListQuerySchema = z.object({
1715
- status: z.enum([
1716
- "RESERVED",
1717
- "USED",
1718
- "RELEASED",
1719
- "RESTORED"
1720
- ]).optional(),
1721
- ...pageQueryShape()
1722
- });
1723
- const couponStatsSchema = z.object({
1724
- couponId: z.string(),
1725
- issuedCount: z.number().int(),
1726
- revokedCount: z.number().int(),
1727
- reservedCount: z.number().int(),
1728
- usedCount: z.number().int(),
1729
- restoredCount: z.number().int(),
1730
- releasedCount: z.number().int(),
1731
- discountTotal: z.number().int().describe("사용 중(`USED`)인 기록의 할인 합계 (원)"),
1732
- orderPaymentTotal: z.number().int().describe("이 쿠폰을 쓴(`USED`) 주문의 결제 금액 합계 (원). 부분 취소·반품 환불은 빼지 않은 결제 시점 금액이다")
1733
- });
1734
- /** `GET /v1/customers/{customerId}/coupons` 항목 — 발급 쿠폰 + 쿠폰 이름·혜택 */
1735
- const customerCouponSchema = couponIssueSchema.extend({
1736
- couponName: z.string(),
1737
- kind: couponKindSchema,
1738
- benefit: couponBenefitSchema
1739
- });
1740
- //#endregion
1741
- //#region src/schemas/customer.ts
1742
- /** 구매자 목록 정렬 */
1743
- const customerSortSchema = z.enum([
1744
- "joined_desc",
1745
- "joined_asc",
1746
- "total_paid_desc",
1747
- "order_count_desc",
1748
- "last_ordered_desc"
1749
- ]).describe("정렬. `joined_desc`(기본, 최근 가입순)·`joined_asc`·`total_paid_desc`(구매 금액순)·`order_count_desc`(주문 수순)·`last_ordered_desc`(최근 주문순)");
1750
- /** `GET /v1/customers` 쿼리 */
1751
- const customerListQuerySchema = z.object({
1752
- q: z.string().optional().describe("검색어 — 이름·이메일(포함). 숫자 4자리면 휴대폰 뒤 4자리로도 찾는다"),
1753
- joinedFrom: z.string().optional().describe("가입일 시작(YYYY-MM-DD, KST, 포함)"),
1754
- joinedTo: z.string().optional().describe("가입일 끝(YYYY-MM-DD, KST, 포함)"),
1755
- marketing: z.enum(["agreed", "declined"]).optional().describe("마케팅 수신 동의 필터. 생략하면 전체"),
1756
- sort: customerSortSchema.optional(),
1757
- ...pageQueryShape()
1758
- });
1759
- /** 구매 통계 — 구매 금액은 취소된 주문 상품을 빼고, 완료된 반품은 수량 비율로 뺀 금액이다 */
1760
- const customerStatsSchema = z.object({
1761
- orderCount: z.number().int().describe("주문 수(취소 포함)"),
1762
- totalPaid: z.number().int().describe("구매 금액(원) — 취소·완료된 반품 제외"),
1763
- lastOrderedAt: z.string().nullable().describe("최근 주문 시각")
1764
- });
1765
- /** 목록 행 — 휴대폰은 가운데를 가린다(`010-****-1234`) */
1766
- const customerSummarySchema = z.object({
1767
- customerId: z.string(),
1768
- name: z.string(),
1769
- /** 소셜 가입에서 이메일을 받지 못했으면 null */
1770
- email: z.string().nullable(),
1771
- maskedPhone: z.string().nullable(),
1772
- joinedAt: z.string(),
1773
- point: z.number().int(),
1774
- marketingAgreed: z.boolean()
1775
- }).extend(customerStatsSchema.shape);
1776
- const customerAddressSchema = z.object({
1777
- addressId: z.string(),
1778
- alias: z.string(),
1779
- isDefault: z.boolean(),
1780
- receiverName: z.string(),
1781
- phone: z.string(),
1782
- zipCode: z.string(),
1783
- address1: z.string(),
1784
- address2: z.string().nullable()
1785
- });
1786
- /** 동의 종류 — 이용약관·개인정보 수집이용은 가입 필수, 마케팅 수신은 선택 */
1787
- const consentKindSchema = z.enum([
1788
- "TERMS",
1789
- "PRIVACY",
1790
- "MARKETING"
1791
- ]);
1792
- const consentActionSchema = z.enum(["AGREED", "WITHDRAWN"]);
1793
- /**
1794
- * 동의가 일어난 경로. 값이 추가될 수 있어 모르는 값은 그대로 보여 준다.
1795
- * `SIGNUP` 비밀번호 가입 · `SOCIAL_SIGNUP` 소셜 가입 · `DELEGATED` 셀러 자체 회원 연동 ·
1796
- * `RECONSENT` 약관 개정 재동의 · `MEMBER_UPDATE` 구매자가 직접 변경 · `SELLER` 셀러가 처리
1797
- */
1798
- const consentSourceSchema = z.enum([
1799
- "SIGNUP",
1800
- "SOCIAL_SIGNUP",
1801
- "DELEGATED",
1802
- "RECONSENT",
1803
- "MEMBER_UPDATE",
1804
- "SELLER"
1805
- ]);
1806
- /** 동의 이력 한 줄 — 추가만 되고 지워지지 않는다(구매자 탈퇴 시에만 함께 지워진다) */
1807
- const customerConsentSchema = z.object({
1808
- consentId: z.string(),
1809
- kind: consentKindSchema,
1810
- /** 동의한 약관 버전. 마케팅은 버전이 없어 null */
1811
- version: z.string().nullable(),
1812
- /** 동의 시점의 약관 문서 주소. 상점이 주소를 정하지 않았거나 마케팅이면 null */
1813
- documentUrl: z.string().nullable(),
1814
- action: consentActionSchema,
1815
- source: consentSourceSchema,
1816
- occurredAt: z.string()
1817
- });
1818
- /** 종류별 현재 동의 상태 */
1819
- const customerConsentStateSchema = z.object({
1820
- kind: consentKindSchema,
1821
- agreed: z.boolean(),
1822
- /** 동의한 약관 버전. 마케팅이거나 이력이 없으면 null */
1823
- agreedVersion: z.string().nullable(),
1824
- /** 상점의 현재 약관 버전. 마케팅은 null */
1825
- currentVersion: z.string().nullable(),
1826
- /** 상점이 정한 현재 약관 문서 주소. 정하지 않았거나 마케팅이면 null */
1827
- currentDocumentUrl: z.string().nullable(),
1828
- updatedAt: z.string().nullable(),
1829
- /** 약관이 개정돼 다시 받아야 하는가. 마케팅은 항상 false */
1830
- reconsentRequired: z.boolean()
1831
- });
1832
- const customerConsentsSchema = z.object({
1833
- terms: customerConsentStateSchema,
1834
- privacy: customerConsentStateSchema,
1835
- marketing: customerConsentStateSchema,
1836
- /** 필수 동의 중 하나라도 현재 버전이 아니면 true */
1837
- reconsentRequired: z.boolean()
1838
- });
1839
- /** `GET /v1/customers/{id}/consents` 쿼리 — 최근 순이다 */
1840
- const customerConsentListQuerySchema = z.object({
1841
- kind: consentKindSchema.optional().describe("동의 종류 필터. 생략하면 전체"),
1842
- ...pageQueryShape()
1843
- });
1844
- /** 구매자 상세 — 휴대폰 전체를 보여 준다 */
1845
- const customerDetailSchema = z.object({
1846
- customerId: z.string(),
1847
- name: z.string(),
1848
- email: z.string().nullable(),
1849
- phone: z.string().nullable(),
1850
- joinedAt: z.string(),
1851
- point: z.number().int(),
1852
- marketingAgreed: z.boolean(),
1853
- loginMethods: z.array(z.object({
1854
- method: z.enum([
1855
- "password",
1856
- "kakao",
1857
- "naver",
1858
- "google"
1859
- ]),
1860
- /** 소셜 연결 시각. 비밀번호는 가입 시각 */
1861
- linkedAt: z.string()
1862
- })).describe("로그인 방법 — 이메일·비밀번호(`password`)와 연결된 소셜 계정"),
1863
- consents: customerConsentsSchema.describe("약관·개인정보·마케팅 동의의 현재 상태. 이력은 `GET /v1/customers/{customerId}/consents`"),
1864
- addresses: z.array(customerAddressSchema),
1865
- stats: customerStatsSchema.extend({
1866
- canceledOrReturnedCount: z.number().int().describe("취소된 주문 상품 + 완료된 반품 수"),
1867
- reviewCount: z.number().int(),
1868
- inquiryCount: z.number().int().describe("상품 문의 + 1:1 문의")
1869
- })
1870
- });
1871
- /** 구매자의 주문 — 전체 기간 */
1872
- const customerOrderSchema = z.object({
1873
- orderId: z.string(),
1874
- orderedAt: z.string(),
1875
- totalAmount: z.number().int().describe("결제 금액"),
1876
- testPayment: z.boolean(),
1877
- items: z.array(z.object({
1878
- orderItemId: z.string(),
1879
- productName: z.string(),
1880
- optionName: z.string().nullable(),
1881
- quantity: z.number().int(),
1882
- status: orderItemStatusSchema,
1883
- claimStatus: z.enum([
1884
- "CANCEL_REQUESTED",
1885
- "RETURN_REQUESTED",
1886
- "EXCHANGE_REQUESTED"
1887
- ]).nullable()
1888
- }))
1889
- });
1890
- /** `POST /v1/customers/{id}/withdraw` 본문 */
1891
- const withdrawCustomerRequestSchema = z.object({
1892
- reason: z.enum([
1893
- "BUYER_REQUEST",
1894
- "DORMANT",
1895
- "ABUSE",
1896
- "OTHER"
1897
- ]).describe("처리 사유 — 구매자 요청·장기 미이용·부정 이용·기타. 감사 로그에 남는다"),
1898
- memo: z.string().max(200).optional().describe("사유 보충(선택)")
1899
- });
1900
- /** `WITHDRAWAL_BLOCKED`의 `error.details` — 탈퇴를 막는 항목 수(스토어프론트 탈퇴와 같다) */
1901
- const customerWithdrawalBlockedDetailsSchema = z.object({
1902
- inProgressOrderItems: z.number().int(),
1903
- openClaims: z.number().int(),
1904
- pendingPayments: z.number().int()
1905
- });
1906
- //#endregion
1907
- //#region src/schemas/login.ts
1908
- /** 소셜 로그인 공급자 */
1909
- const loginProviderSchema = z.enum([
1910
- "kakao",
1911
- "naver",
1912
- "google"
1913
- ]);
1914
- /** 공급자 연결 상태 — 비밀 값은 앞 4자리만 */
1915
- const loginProviderStatusSchema = z.object({
1916
- provider: loginProviderSchema,
1917
- connected: z.boolean(),
1918
- /** 스토어프론트에서 사용 중인지. 연결 안 됐으면 false */
1919
- enabled: z.boolean(),
1920
- clientIdPrefix: z.string().nullable().describe("클라이언트 ID(카카오 REST API 키) 앞 4자리"),
1921
- secretPrefix: z.string().nullable(),
1922
- lastTestedAt: z.string().nullable(),
1923
- lastTestOk: z.boolean().nullable(),
1924
- linkedMemberCount: z.number().int().describe("이 공급자로 연결된 구매자 수"),
1925
- /** 이 공급자로만 로그인하는 구매자 수 — 연결을 해제하면 로그인할 방법이 없어진다 */
1926
- onlyMethodMemberCount: z.number().int(),
1927
- callbackUrl: z.url().describe("공급자 개발자 콘솔에 등록할 리디렉트 URI")
1928
- });
1929
- /** `GET /v1/store/login-settings` */
1930
- const loginSettingsSchema = z.object({
1931
- passwordLoginEnabled: z.boolean(),
1932
- /** 비밀번호로만 로그인하는 구매자 수 — 이메일·비밀번호를 끄면 이들은 로그인할 수 없다 */
1933
- passwordOnlyMemberCount: z.number().int(),
1934
- storefrontOrigins: z.array(z.string()),
1935
- providers: z.array(loginProviderStatusSchema)
1936
- });
1937
- /** `PATCH /v1/store/login-settings` */
1938
- const updateLoginSettingsRequestSchema = z.object({ passwordLoginEnabled: z.boolean() });
1939
- /** `PUT /v1/store/auth-providers/{provider}` — 저장 전에 연결 테스트를 한다 */
1940
- const saveLoginProviderRequestSchema = z.object({
1941
- clientId: z.string().trim().min(1).max(200),
1942
- clientSecret: z.string().trim().min(1).max(500)
1943
- });
1944
- /** `PATCH /v1/store/auth-providers/{provider}` */
1945
- const updateLoginProviderRequestSchema = z.object({ enabled: z.boolean() });
1946
- /** 연결 테스트 결과 */
1947
- const loginProviderTestSchema = z.object({
1948
- ok: z.boolean(),
1949
- /** 실패 이유 — `INVALID_CLIENT`(키가 틀림)·`PROVIDER_UNAVAILABLE`(공급자 응답 없음) */
1950
- code: z.enum(["INVALID_CLIENT", "PROVIDER_UNAVAILABLE"]).nullable(),
1951
- testedAt: z.string()
1952
- });
1953
- //#endregion
1954
- //#region src/schemas/misc.ts
1955
- const inquirySchema = z.object({
1956
- inquiryId: z.string(),
1957
- productId: z.string(),
1958
- productName: z.string(),
1959
- orderItemId: z.string().nullable(),
1960
- category: z.enum([
1961
- "PRODUCT",
1962
- "DELIVERY",
1963
- "RETURN",
1964
- "EXCHANGE",
1965
- "ETC"
1966
- ]),
1967
- content: z.string(),
1968
- secret: z.boolean(),
1969
- writerMaskedName: z.string(),
1970
- answered: z.boolean(),
1971
- answer: z.object({
1972
- content: z.string(),
1973
- answeredAt: z.string(),
1974
- modifiedCount: z.number().int().max(1)
1975
- }).nullable(),
1976
- createdAt: z.string()
1977
- });
1978
- const customerInquiryCategorySchema = z.enum([
1979
- "DELIVERY",
1980
- "RETURN",
1981
- "EXCHANGE",
1982
- "PAYMENT",
1983
- "ETC"
1984
- ]);
1985
- /** 셀러 뷰 1:1 문의 — 작성자 마스킹명 + 답변 수정횟수 포함 */
1986
- const sellerCustomerInquirySchema = z.object({
1987
- inquiryId: z.string(),
1988
- memberMaskedName: z.string(),
1989
- category: customerInquiryCategorySchema,
1990
- title: z.string(),
1991
- content: z.string(),
1992
- orderItemId: z.string().nullable(),
1993
- answered: z.boolean(),
1994
- answer: z.object({
1995
- content: z.string(),
1996
- answeredAt: z.string(),
1997
- modifiedCount: z.number().int().max(1)
1998
- }).nullable(),
1999
- createdAt: z.string()
2000
- });
2001
- const answeredQuery = z.enum(["true", "false"]).optional().describe("답변 여부 필터. `true`면 답변된 문의만, `false`면 미답변만, 생략하면 전부");
2002
- /** `GET /v1/inquiries` 쿼리(상품 문의) — OpenAPI 문서용 형태(값은 쿼리 문자열). 최신순이다 */
2003
- const inquiryListQuerySchema = z.object({
2004
- answered: answeredQuery,
2005
- productId: z.string().optional().describe("상품 id 필터"),
2006
- from: z.string().optional().describe("작성 시각 조회 시작 (ISO 8601, 포함)"),
2007
- ...pageQueryShape()
2008
- });
2009
- /** `GET /v1/customer-inquiries` 쿼리(1:1 문의) — OpenAPI 문서용 형태(값은 쿼리 문자열). 최신순이다 */
2010
- const customerInquiryListQuerySchema = z.object({
2011
- answered: answeredQuery,
2012
- category: customerInquiryCategorySchema.optional().describe("문의 유형 필터"),
2013
- from: z.string().optional().describe("작성 시각 조회 시작 (ISO 8601, 포함)"),
2014
- ...pageQueryShape()
2015
- });
2016
- const settlementStatusSchema = z.enum([
2017
- "SCHEDULED",
2018
- "PAID",
2019
- "HELD"
2020
- ]);
2021
- const settlementSummarySchema = z.object({
2022
- settlementId: z.string(),
2023
- settlementDate: z.string(),
2024
- payoutDate: z.string().nullable(),
2025
- status: settlementStatusSchema,
2026
- totalPaymentAmount: z.number().int(),
2027
- totalCommission: z.number().int(),
2028
- totalDeliveryFee: z.number().int(),
2029
- adjustmentAmount: z.number().int(),
2030
- settlementAmount: z.number().int(),
2031
- itemCount: z.number().int()
1540
+ return null;
1541
+ }
1542
+ /**
1543
+ * 식별 코드 — 빈 문자열·공백만 있는 값은 거부한다.
1544
+ * `""`를 통과시키면 서버 중복 사전 검사(falsy skip)와 DB partial unique
1545
+ * (`WHERE ... is not null`)의 판정이 갈려 409 대신 500이 나간다.
1546
+ */
1547
+ const codeSchema = z.string().trim().min(1).max(64);
1548
+ const optionValueRequestSchema = z.object({
1549
+ /** 전달 시 기존 값 행을 유지(UPDATE). 생략하면 신규 발급 */
1550
+ valueId: z.string().optional(),
1551
+ name: z.string().min(1).max(50)
2032
1552
  });
2033
- const settlementDetailSchema = z.object({
2034
- orderItemId: z.string(),
2035
- productName: z.string(),
2036
- settlementType: z.enum([
2037
- "SALE",
2038
- "REFUND_DEDUCTION",
2039
- "ADJUSTMENT"
2040
- ]),
2041
- paymentAmount: z.number().int(),
2042
- salesCommission: z.number().int(),
2043
- paymentCommission: z.number().int(),
2044
- commissionRate: z.number(),
2045
- deliveryFee: z.number().int(),
2046
- settlementAmount: z.number().int(),
2047
- purchaseDecidedAt: z.string().nullable()
1553
+ const optionGroupRequestSchema = z.object({
1554
+ /** 전달 시 기존 축 행을 유지(UPDATE). 생략하면 신규 발급 */
1555
+ groupId: z.string().optional(),
1556
+ name: z.string().min(1).max(50),
1557
+ values: z.array(optionValueRequestSchema).min(1).max(100)
2048
1558
  });
2049
- /** `GET /v1/settlements/{settlementId}` 응답 — 정산 요약 + 상세 내역(`size`건까지) */
2050
- const settlementWithDetailsSchema = settlementSummarySchema.extend({ details: z.array(settlementDetailSchema).optional() });
2051
- /** `GET /v1/settlements` 쿼리 — OpenAPI 문서용 형태(값은 쿼리 문자열). 정산일 최신순이다 */
2052
- const settlementListQuerySchema = z.object({
2053
- from: z.string().describe("정산일 조회 시작 (ISO 8601, 포함). 필수"),
2054
- to: z.string().optional().describe("정산일 조회 끝 (ISO 8601, 포함). 생략하면 from부터 31일. 기간은 최대 31일"),
2055
- status: settlementStatusSchema.optional().describe("정산 상태 필터"),
2056
- ...pageQueryShape()
1559
+ const productVariantRequestSchema = z.object({
1560
+ /** 기존 variant 유지 시 전달. 생략하면 신규 생성 */
1561
+ variantId: z.string().optional(),
1562
+ /** optionGroups 순서대로 각 축에서 고른 값 이름. 서버가 이름 → valueId를 해석한다 */
1563
+ valueNames: z.array(z.string()).default([]),
1564
+ additionalPrice: z.number().int().default(0),
1565
+ /**
1566
+ * 생략 = 기존 재고 유지 (신규 variant는 0).
1567
+ * 구조 교체(PUT)가 폼 로드 시점의 재고를 되쓰지 않게 하는 옵트인 필드다 —
1568
+ * 재고만 바꿀 때는 PATCH /products/{id}/variants/{variantId}/stock을 쓴다.
1569
+ */
1570
+ stockQuantity: z.number().int().min(0).optional(),
1571
+ sku: codeSchema.optional(),
1572
+ barcode: codeSchema.optional(),
1573
+ costPrice: z.number().int().min(0).optional(),
1574
+ weightGram: z.number().int().min(0).optional(),
1575
+ sellerCode: codeSchema.optional(),
1576
+ usable: z.boolean().default(true)
2057
1577
  });
2058
- /** `GET /v1/settlements/{settlementId}` 쿼리 — `size`는 돌려줄 상세 내역 건수다(`page`는 무시) */
2059
- const settlementDetailQuerySchema = z.object(pageQueryShape({
2060
- defaultSize: 100,
2061
- maxSize: 300
2062
- }));
2063
- const eventTypeSchema = z.enum([
2064
- "ORDER.PAID",
2065
- "ORDER.CONFIRMED",
2066
- "ORDER.DISPATCHED",
2067
- "ORDER.DELIVERED",
2068
- "ORDER.PURCHASE_DECIDED",
2069
- "ORDER.ADDRESS_CHANGE_REQUESTED",
2070
- "ORDER.CANCELED",
2071
- "ORDER.FULFILLED",
2072
- "CLAIM.CANCEL_REQUESTED",
2073
- "CLAIM.RETURN_REQUESTED",
2074
- "CLAIM.EXCHANGE_REQUESTED",
2075
- "CLAIM.COMPLETED",
2076
- "INQUIRY.CREATED",
2077
- "REVIEW.CREATED",
2078
- "PRODUCT.OUT_OF_STOCK"
2079
- ]).describe("웹훅 이벤트 타입. `ORDER.ADDRESS_CHANGE_REQUESTED`는 현재 발송하지 않음(호환을 위해 값만 남았고, 구독해도 이벤트가 오지 않는다)");
2080
- const webhookStatusSchema = z.enum(["ACTIVE", "SUSPENDED"]).describe("구독 상태. `ACTIVE`=발송 대상, `SUSPENDED`=24시간 이상 연속 전송 실패로 발송이 정지된 상태. 정지 중 발생한 이벤트는 적재하지 않는다. 재개(`POST /webhooks/{id}/resume`)하거나 구독을 수정하면 ACTIVE로 돌아간다");
2081
- const webhookSchema = z.object({
2082
- webhookId: z.string(),
2083
- url: z.url(),
2084
- eventTypes: z.array(eventTypeSchema),
2085
- status: webhookStatusSchema,
2086
- secretPrefix: z.string().describe("서명 시크릿 앞부분(식별용, 서명 검증에 사용할 수 없음). 예: `whsec_1a2b`"),
2087
- suspendedAt: z.string().nullable().describe("발송이 정지된 일시 (ISO 8601). `ACTIVE`면 null"),
2088
- createdAt: z.string()
1578
+ /** 축 × 값 → variant 전체 교체 요청 (PUT /products/{productId}/options) */
1579
+ const optionModelRequestSchema = z.object({
1580
+ optionGroups: z.array(optionGroupRequestSchema).max(3).default([]),
1581
+ variants: z.array(productVariantRequestSchema).min(1).max(500),
1582
+ addonGroups: z.array(addonGroupRequestSchema).max(5).optional().describe("추가 선택 옵션명 전체(이슈 #59). 주면 통째로 바꾸고, 생략하면 등록은 없음·수정은 현재 값을 그대로 둔다. 끄려면 빈 배열을 보낸다. 기능이 열리기 전에는 비어 있지 않은 값이 409 `OPTION_ADDONS_UNAVAILABLE`이다"),
1583
+ customInputs: z.array(customInputRequestSchema).max(5).optional().describe("직접 입력 항목 전체(이슈 #59). 배열 순서가 표시 순서다. 주면 통째로 바꾸고, 생략하면 등록은 없음·수정은 현재 값을 그대로 둔다. 항목명에 개인정보성 단어(`CUSTOM_INPUT_FORBIDDEN_WORDS`)가 있으면 400 `CUSTOM_INPUT_LABEL_FORBIDDEN`, 기능이 열리기 전에는 비어 있지 않은 값이 409 `OPTION_ADDONS_UNAVAILABLE`이다"),
1584
+ optionSort: optionSortSchema.optional().describe("옵션 값 정렬 순서(이슈 #59). 서버가 이 순서로 값의 `sortOrder`를 다시 매긴다. 생략하면 등록은 `REGISTERED`, 수정은 현재 값이다")
2089
1585
  });
2090
- /** 등록 응답 — 서명 시크릿 평문은 이 응답에서만 노출된다 */
2091
- const webhookCreatedSchema = webhookSchema.extend({ secret: z.string().describe("서명 시크릿 평문. 등록 응답에서만 노출되며 다시 조회할 수 없다") });
2092
- /** 시크릿 교체 응답 */
2093
- const webhookSecretRotatedSchema = z.object({
2094
- secret: z.string().describe("새 서명 시크릿 평문. 이 응답에서만 노출된다"),
2095
- oldSecretExpiresAt: z.string().describe("기존 시크릿의 병행 유효 종료 일시 (ISO 8601). 이때까지는 기존 시크릿 서명도 함께 붙는다")
1586
+ /** GET /products/{productId}/options 응답 */
1587
+ const optionModelSchema = z.object({
1588
+ optionGroups: z.array(optionGroupSchema),
1589
+ variants: z.array(productVariantSchema),
1590
+ addonGroups: z.array(addonGroupSchema).optional().default([]).describe("추가 선택 옵션명(이슈 #59). 없으면 빈 배열. 서버는 항상 싣는다"),
1591
+ customInputs: z.array(customInputSchema).optional().default([]).describe("직접 입력 항목(이슈 #59). 없으면 빈 배열. 서버는 항상 싣는다"),
1592
+ optionSort: optionSortSchema.optional().default("REGISTERED").describe("옵션 값 정렬 순서(이슈 #59). 서버는 항상 싣는다")
2096
1593
  });
2097
- const webhookDeliveryStatusSchema = z.enum([
2098
- "PENDING",
2099
- "PENDING_RETRY",
2100
- "SUCCESS",
2101
- "FAILED"
2102
- ]).describe("전송 상태. `PENDING`=첫 시도 전, `PENDING_RETRY`=실패 후 재시도 대기, `SUCCESS`=2xx 응답 수신, `FAILED`=재시도를 모두 소진했거나 구독 정지로 종료");
2103
- const webhookDeliveryErrorCodeSchema = z.enum([
2104
- "TIMEOUT",
2105
- "CONNECTION_FAILED",
2106
- "BLOCKED_ADDRESS",
2107
- "NON_2XX",
2108
- "WEBHOOK_SUSPENDED"
2109
- ]).describe("전송 실패 사유. `TIMEOUT`=10초 안에 응답 없음, `CONNECTION_FAILED`=연결 실패(DNS·TLS 포함), `BLOCKED_ADDRESS`=사설·루프백 등 전송이 허용되지 않는 주소, `NON_2XX`=2xx가 아닌 응답(리다이렉트 포함), `WEBHOOK_SUSPENDED`=구독 정지로 대기 중인 전송이 종료됨");
2110
1594
  /**
2111
- * 실패 원인 분류의 **현재** 값. 응답 스키마(`webhookFailureReasonSchema`)는 문자열로 열려 있다 —
2112
- * 서버가 값을 더해도 이전 SDK가 목록 파싱에 실패하지 않게 한다(택배 추적 `stage`와 같은 이유).
2113
- * 모르는 값은 `errorCode`로 판단한다.
1595
+ * 재고 변경 택일 위반 문구 — SDK `.refine`과 서버 DomainException이 공유하는 단일 원천.
1596
+ * 경로에 따라 셀러가 다른 문구를 받지 않게 한다.
2114
1597
  */
2115
- const WEBHOOK_FAILURE_REASONS = [
2116
- "DNS",
2117
- "TLS",
2118
- "TIMEOUT",
2119
- "CONNECTION",
2120
- "HTTP_4XX",
2121
- "HTTP_5XX",
2122
- "HTTP_OTHER",
2123
- "SSRF_BLOCKED",
2124
- "SUSPENDED"
2125
- ];
2126
- const webhookFailureReasonSchema = z.string().describe("실패 원인 분류(진단용). 현재 값은 `DNS`=호스트 이름을 주소로 바꾸지 못함, `TLS`=인증서·핸드셰이크 실패, `TIMEOUT`=10초 안에 응답 없음, `CONNECTION`=연결 거부·끊김, `HTTP_4XX`·`HTTP_5XX`=그 대역의 응답, `HTTP_OTHER`=2xx·4xx·5xx가 아닌 응답(리다이렉트 등), `SSRF_BLOCKED`=사설·루프백 등 전송이 허용되지 않는 주소, `SUSPENDED`=구독 정지로 대기 중인 전송이 종료됨. `errorCode`보다 잘게 나눈 값이며 둘은 함께 쓸 수 있다. 값이 추가될 수 있으므로 모르는 값은 `errorCode`로 판단한다");
1598
+ const VARIANT_STOCK_EXCLUSIVE_MESSAGE = "stockQuantity 또는 adjustment 중 하나만 전달해야 합니다";
2127
1599
  /**
2128
- * 대상 리소스 종류의 **현재** 값. 응답 스키마는 문자열로 열려 있다(새 이벤트가 새 리소스를 가리킬 수 있다).
1600
+ * variant 재고 변경 요청 — 절대값(`stockQuantity`) 또는 증감(`adjustment`) 택일.
1601
+ * 서버도 같은 규칙을 400 VALIDATION_FAILED로 검증한다(이중 방어).
2129
1602
  */
2130
- const WEBHOOK_RESOURCE_TYPES = [
2131
- "ORDER",
2132
- "CLAIM",
2133
- "PRODUCT",
2134
- "REVIEW",
2135
- "INQUIRY"
2136
- ];
2137
- const webhookResourceTypeSchema = z.string().describe("이벤트가 가리키는 리소스 종류. 현재 값은 `ORDER`·`CLAIM`·`PRODUCT`·`REVIEW`·`INQUIRY`이고 값이 추가될 수 있다");
2138
- /** 이벤트가 가리키는 대상 리소스 — 전송 기록을 주문·상품과 잇는 키 */
2139
- const webhookDeliveryResourceSchema = z.object({
2140
- type: webhookResourceTypeSchema,
2141
- id: z.string().describe("리소스 ID. 전송 기록 조회의 `resourceId`, API 로그 조회의 `q`로 그대로 넣을 수 있다")
2142
- });
2143
- /** 발송 요청 헤더 한 줄 (값 마스킹) */
2144
- const webhookRequestHeaderSchema = z.object({
2145
- name: z.string().describe("헤더 이름"),
2146
- value: z.string().nullable().describe("헤더 값. 서명(`Sayren-Signature`)처럼 비밀이 섞이는 헤더는 이름만 주고 값은 null이다")
2147
- });
2148
- /** 웹훅 전송 기록 한 건 (전송 기록 조회 항목) */
2149
- const webhookDeliverySchema = z.object({
2150
- deliveryId: z.string().describe("전송 기록 ID. 수신 요청의 `Sayren-Delivery-Id` 헤더 값"),
2151
- eventId: z.string().describe("이벤트 ID. 재시도·재전송해도 같은 이벤트면 같은 값이다. 수신 측 중복 제거 키로 쓴다"),
2152
- eventType: eventTypeSchema,
2153
- url: z.string().describe("전송 시점의 대상 URL"),
2154
- status: webhookDeliveryStatusSchema,
2155
- responseStatusCode: z.number().int().nullable().describe("마지막 시도의 HTTP 응답 코드. 미시도·연결 실패·타임아웃·차단이면 null"),
2156
- responseTimeMs: z.number().int().nullable().describe("마지막 시도의 응답 시간(ms). 미시도·연결 실패면 null"),
2157
- responseBodyExcerpt: z.string().nullable().describe("마지막 시도의 응답 본문 앞부분(최대 4,096자). 응답이 없으면 null. 가린 뒤에 자른다. 키 이름에 `authorization`·`token`·`secret`·`password`·`key`·`signature`·`credential`·`cookie`가 **들어 있으면**(부분 일치, 대소문자 무시. 예: `apiKey`·`access_token`·`keyword`) 그 값을 `***`로 가린다. 값이 객체·배열이면 통째로 가린다. 평문 `Authorization: …` 같은 헤더 줄, `Bearer <토큰>`, 쿼리 형식 `access_token=…`, 끝에서 잘려 닫히지 않은 값도 가린다"),
2158
- errorCode: webhookDeliveryErrorCodeSchema.nullable().describe("마지막 시도의 실패 사유. 성공했거나 미시도면 null"),
2159
- failureReason: webhookFailureReasonSchema.nullable().describe("마지막 시도의 실패 원인 분류. 성공했거나 미시도면 null. `errorCode`를 더 잘게 나눈 진단 값이다"),
2160
- resource: webhookDeliveryResourceSchema.nullable().describe("이벤트가 가리키는 대상 리소스. 리소스를 특정할 수 없는 이벤트면 null. 이 값으로 전송 기록(`resourceId`)과 API 로그(`q`)를 오갈 수 있다"),
2161
- attemptCount: z.number().int().min(0).describe("지금까지 전송을 시작한 횟수. 최초 시도 1회 + 재시도 최대 4회. 전송 도중 장애로 결과를 기록하지 못한 시도도 센다"),
2162
- createdAt: z.string().describe("전송 기록이 적재된 일시 (ISO 8601)"),
2163
- attemptedAt: z.string().nullable().describe("마지막 시도 일시 (ISO 8601). 미시도면 null"),
2164
- nextAttemptAt: z.string().nullable().describe("다음 시도 예정 일시 (ISO 8601). `PENDING`·`PENDING_RETRY`일 때만 값이 있다"),
2165
- redeliveryOf: z.string().nullable().describe("수동 재전송으로 생긴 기록이면 원본 전송 기록 ID. 아니면 null"),
2166
- redeliveredAs: z.string().nullable().describe("이 기록을 원본으로 수동 재전송해 생긴 기록의 전송 기록 ID. 아직 재전송하지 않았으면 null. 재전송은 원본당 1회다")
2167
- });
2168
- /** 발송 본문 최대 노출 길이(바이트). 넘으면 잘라 주고 `payloadTruncated`가 true다 */
2169
- const WEBHOOK_PAYLOAD_EXCERPT_MAX_BYTES = 16384;
2170
- /** 웹훅 전송 기록 상세 — `GET /webhooks/{id}/deliveries/{deliveryId}`. 목록 항목 + 요청 본문·헤더 */
2171
- const webhookDeliveryDetailSchema = webhookDeliverySchema.extend({
2172
- payload: z.string().describe(`수신 서버로 보낸 요청 본문 그대로(JSON 문자열). 서명은 이 문자열을 대상으로 계산한다. 최대 ${WEBHOOK_PAYLOAD_EXCERPT_MAX_BYTES / 1024}KB까지 주고 넘으면 잘라 준다`),
2173
- payloadTruncated: z.boolean().describe("본문이 길어서 잘렸으면 true. true면 `payload`는 유효한 JSON이 아니다"),
2174
- payloadMasked: z.boolean().describe("민감 키 마스킹(`responseBodyExcerpt`와 같은 키 이름 규칙)이 본문을 바꿨으면 true. true면 `payload`는 보낸 본문과 달라 서명을 맞춰 볼 수 없다. 이벤트 본문에는 보통 민감 키가 없어 false다"),
2175
- requestHeaders: z.array(webhookRequestHeaderSchema).describe("발송 요청에 실은 헤더. 값은 전송 시점 그대로이나 서명 헤더는 이름만 주고 값은 null이다. `Content-Length`는 실제로 보낸 본문(마스킹·자르기 전)의 바이트 수다. 매 시도가 같은 헤더 집합을 쓰고 서명·타임스탬프만 달라진다")
2176
- });
2177
- /** 전송 기록 조회 쿼리 — `GET /webhooks/{id}/deliveries` */
2178
- const webhookDeliveryListQuerySchema = z.object({
2179
- status: webhookDeliveryStatusSchema.optional().describe("전송 상태 필터. 생략하면 전체"),
2180
- eventType: eventTypeSchema.optional().describe("이벤트 타입 필터. 생략하면 전체"),
2181
- resourceId: z.string().optional().describe("대상 리소스 ID 필터(정확히 일치). 주문·클레임·상품·리뷰·문의 id를 넣으면 그 리소스의 이벤트만 남는다. 같은 id로 API 로그를 `q`로 검색하면 그 리소스를 건드린 API 요청을 볼 수 있다"),
2182
- limit: z.coerce.number().int().min(1).max(100).optional().describe("최대 건수 (1~100, 기본 50). 최신 적재순으로 반환하며 보관 기간은 30일이다")
2183
- });
2184
- /** 실패 건 일괄 재전송 응답 — `POST /webhooks/{id}/deliveries/redeliver-failed` */
2185
- const webhookRedeliverFailedResultSchema = z.object({ queued: z.number().int().min(0).describe("재전송 대기열에 올린 건수. 최근 72시간의 `FAILED` 중 아직 재전송되지 않은 건이 대상이며 최대 500건이다") });
2186
- const webhookTestRequestSchema = z.object({ eventType: eventTypeSchema.describe("샘플 페이로드로 보낼 이벤트 타입") });
2187
- /** 테스트 이벤트 발송 결과 (동기 반환). 재시도·정지 판정·전송 기록이 없다 */
2188
- const webhookTestResultSchema = z.object({
2189
- responseStatusCode: z.number().int().nullable().describe("수신 서버의 HTTP 응답 코드. 연결 실패·타임아웃·차단이면 null"),
2190
- responseTimeMs: z.number().int().nullable().describe("응답 시간(ms). 연결 실패면 null"),
2191
- errorCode: webhookDeliveryErrorCodeSchema.nullable().describe("실패 사유. 2xx 응답이면 null"),
2192
- failureReason: webhookFailureReasonSchema.nullable().describe("실패 원인 분류. 2xx 응답이면 null"),
2193
- responseBodyExcerpt: z.string().nullable().describe("응답 본문 앞부분(최대 4,096자). 규칙은 전송 기록의 `responseBodyExcerpt`와 같다. 응답이 없으면 null"),
2194
- requestHeaders: z.array(webhookRequestHeaderSchema).describe("보낸 요청 헤더. 서명 헤더는 이름만 주고 값은 null이다"),
2195
- payload: z.string().describe("보낸 요청 본문 그대로(JSON 문자열). 샘플 이벤트다")
1603
+ const variantStockRequestSchema = z.object({
1604
+ stockQuantity: z.number().int().min(0).optional(),
1605
+ adjustment: z.number().int().optional()
1606
+ }).refine((v) => v.stockQuantity !== void 0 !== (v.adjustment !== void 0), { message: VARIANT_STOCK_EXCLUSIVE_MESSAGE });
1607
+ /** PATCH /products/{productId}/variants/{variantId}/stock 응답 */
1608
+ const variantStockSchema = z.object({
1609
+ variantId: z.string(),
1610
+ stockQuantity: z.number().int()
2196
1611
  });
2197
- const webhookRequestSchema = z.object({
2198
- url: z.url().startsWith("https://", "웹훅 URL은 HTTPS만 허용됩니다"),
2199
- eventTypes: z.array(eventTypeSchema).min(1)
1612
+ /** 상품 쓰기 요청의 이미지 목록 — 등록·전체 수정·부분 수정이 같은 항목 형식을 쓴다. 첫 장이 대표(썸네일)다 */
1613
+ const productImagesRequestSchema = z.array(productImageUrlSchema).min(1).max(10);
1614
+ /**
1615
+ * 상품 쓰기 요청의 공통 필드 — 등록(POST)과 전체 수정(PUT)이 같다. 이행(`fulfillment`)만 두 요청의 스키마가 다르다.
1616
+ *
1617
+ * 최상위가 `strictObject`다. 모르는 필드는 400이다 — 이슈 #57 이전의 평면 필드(`deliveryFee`·`fulfillmentType`·`returnFee` 등)를
1618
+ * 보낸 요청이 조용히 무시되지 않게 한다. 옛 필드는 `fulfillment.{유형}.…`과 `refundPolicy`로 옮겼다.
1619
+ */
1620
+ const productWriteShape = {
1621
+ name: z.string().min(1).max(100),
1622
+ categoryId: z.string().min(1, "카테고리를 선택해주십시오"),
1623
+ salePrice: z.number().int().min(10).multipleOf(10, "판매가는 10원 단위여야 합니다 (INVALID_PRICE_UNIT)").describe("판매가. 원(KRW) 단위 정수, 최소 10원, 10원 단위"),
1624
+ originalPrice: z.number().int().optional().describe("정가(할인 전 표시 가격). 원 단위. 비교용 표시 값이고 결제 금액은 판매가(즉시할인이 있으면 할인 적용가)다. 스토어프론트가 판매가보다 클 때 취소선·할인율로 보인다. 즉시할인은 `PUT /v1/products/{productId}/discount`로 따로 정한다"),
1625
+ description: z.string().optional(),
1626
+ /**
1627
+ * 상세설명 에디터(@avarlabs/editor)의 직렬화 상태. 재편집 때 이 값으로 문서를 복원한다.
1628
+ * 표시는 `description`(HTML)이 맡는다 — 소비자는 이 필드를 몰라도 된다.
1629
+ */
1630
+ descriptionJson: z.string().nullable().optional(),
1631
+ images: productImagesRequestSchema.optional().describe("상품 이미지 주소 목록. 첫 장이 대표 이미지(썸네일)다"),
1632
+ attributes: z.array(z.object({
1633
+ attributeId: z.string(),
1634
+ value: z.string()
1635
+ })).optional(),
1636
+ refundPolicy: productRefundPolicyPatchSchema.nullish().describe("상품별 환불 정책 — 준 항목만 상점 기본을 덮어쓴다. `null`이나 생략은 상점 기본을 그대로 쓴다는 뜻이다. 반품·교환 배송비(`returnDeliveryFee`·`exchangeDeliveryFee`)는 배송 상품에만 줄 수 있다(다른 유형은 400)"),
1637
+ ...optionModelRequestSchema.shape,
1638
+ sourceOptionSetIds: z.array(z.string()).max(10).optional().describe("쓰기 전용(이슈 #59) — 이 저장으로 옵션을 불러온 저장 옵션 id. 상품 저장과 같은 트랜잭션에서 불러오기 기록을 남겨 저장 옵션의 `importedProductCount`를 센다. 없는 id는 무시한다")
1639
+ };
1640
+ const productCreateRequestSchema = z.strictObject({
1641
+ ...productWriteShape,
1642
+ fulfillment: productFulfillmentCreateSchema.optional().describe("상품 이행. `type`이 유형이고 같은 이름의 키가 유형별 속성이다. 생략하면 배송 상품(기본값)이다. 배송 상품이 아닌 유형은 판매가 열린 유형만 등록할 수 있다(409 `FULFILLMENT_TYPE_NOT_AVAILABLE`)")
2200
1643
  });
2201
- const reviewSchema = z.object({
2202
- reviewId: z.string(),
2203
- productId: z.string(),
2204
- productName: z.string(),
2205
- orderItemId: z.string(),
2206
- optionName: z.string().nullable(),
2207
- rating: z.number().int().min(1).max(5),
2208
- content: z.string(),
2209
- images: z.array(z.url()),
2210
- writerMaskedName: z.string(),
2211
- blinded: z.boolean(),
2212
- reply: z.object({
2213
- content: z.string(),
2214
- repliedAt: z.string(),
2215
- updatedAt: z.string().nullable()
2216
- }).nullable(),
2217
- createdAt: z.string()
1644
+ const fulfillmentUpdateDescription = "상품 이행. 생략하면 바꾸지 않는다. 주면 유형 안 필드를 모두 담아 통째로 바꾼다(현재 값과 병합하지 않는다). 판매 이력이 있으면 유형을 바꿀 수 없다(409 `FULFILLMENT_TYPE_LOCKED`)";
1645
+ /** 전체 수정(PUT) — 공통 필드는 등록과 같고, 이행은 생략하면 현재 값 유지, 주면 필드가 모두 필수다 */
1646
+ const productReplaceRequestSchema = z.strictObject({
1647
+ ...productWriteShape,
1648
+ fulfillment: productFulfillmentUpdateSchema.optional().describe(fulfillmentUpdateDescription)
2218
1649
  });
2219
- const reviewSortSchema = z.enum([
1650
+ const productPatchRequestSchema = z.strictObject({
1651
+ name: z.string().max(100).optional(),
1652
+ salePrice: z.number().int().multipleOf(10).optional(),
1653
+ status: productStatusSchema.optional(),
1654
+ images: productImagesRequestSchema.optional().describe("상품 이미지 주소 목록. 주면 통째로 바꾼다(현재 목록과 병합하지 않는다). 첫 장이 대표 이미지(썸네일)다. 생략하면 바꾸지 않는다"),
1655
+ fulfillment: productFulfillmentUpdateSchema.optional().describe(fulfillmentUpdateDescription),
1656
+ refundPolicy: productRefundPolicyPatchSchema.nullable().optional().describe("상품별 환불 정책. 주면 통째로 바꾼다(준 항목만 상점 기본을 덮어쓴다). `null`은 덮어쓰기를 지워 상점 기본을 따른다. 생략하면 바꾸지 않는다")
1657
+ }).refine((value) => Object.values(value).some((v) => v !== void 0), { message: "최소 1개 필드가 필요합니다" });
1658
+ const productSortSchema = z.enum([
2220
1659
  "createdAt.desc",
2221
1660
  "createdAt.asc",
2222
- "rating.asc",
2223
- "rating.desc"
1661
+ "salePrice.asc",
1662
+ "salePrice.desc",
1663
+ "updatedAt.desc"
2224
1664
  ]);
2225
- /** `GET /v1/reviews` 쿼리 — OpenAPI 문서용 형태(값은 쿼리 문자열) */
2226
- const reviewListQuerySchema = z.object({
2227
- productId: z.string().optional().describe("상품 id 필터"),
2228
- rating: z.enum([
2229
- "1",
2230
- "2",
2231
- "3",
2232
- "4",
2233
- "5"
2234
- ]).optional().describe("별점 필터 (일치)"),
2235
- replied: z.enum(["true", "false"]).optional().describe("답글 여부 필터. `true`면 답글 단 리뷰만, `false`면 답글 없는 리뷰만"),
2236
- hasImage: z.enum(["true", "false"]).optional().describe("`true`면 이미지가 있는 리뷰만"),
2237
- from: z.string().optional().describe("작성 시각 조회 시작 (ISO 8601, 포함)"),
2238
- sort: reviewSortSchema.optional().describe("정렬 (기본 createdAt.desc)"),
1665
+ /** `GET /v1/products` 쿼리 — OpenAPI 문서용 형태(값은 쿼리 문자열) */
1666
+ const productListQuerySchema = z.object({
1667
+ status: productStatusSchema.optional().describe("판매 상태 필터"),
1668
+ categoryId: z.string().optional().describe("카테고리 필터 — 하위 카테고리의 상품도 포함"),
1669
+ keyword: z.string().optional().describe("상품명 검색 (포함)"),
1670
+ sellerCode: z.string().optional().describe("판매자 상품코드 — 이 코드가 붙은 variant를 가진 상품"),
1671
+ sort: productSortSchema.optional().describe("정렬 (기본 createdAt.desc)"),
2239
1672
  ...pageQueryShape()
2240
1673
  });
2241
- /** `POST /v1/reviews/{reviewId}/report` 응답(202) — 신고 접수 */
2242
- const reviewReportResultSchema = z.object({
2243
- reviewId: z.string(),
2244
- status: z.literal("PENDING_REVIEW")
1674
+ const discountRequestSchema = z.object({
1675
+ discountType: z.enum(["RATE", "AMOUNT"]),
1676
+ discountValue: z.number().int().min(1),
1677
+ startAt: z.string().optional(),
1678
+ endAt: z.string().optional()
2245
1679
  });
2246
1680
  //#endregion
2247
- //#region src/schemas/fulfillment.ts
1681
+ //#region src/schemas/collection.ts
1682
+ /**
1683
+ * 컬렉션 (이슈 #83) — 상품을 어디에 보여 줄지 정하는 묶음이다. 카테고리는 상품이 무엇인지를 정하고 상품당 하나지만, 컬렉션은
1684
+ * 다대다다. 이벤트·기획전은 기간이 있는 직접 선택 컬렉션이다. 권한은 카테고리와 같다(`category:r`·`category:rw`).
1685
+ *
1686
+ * 유형은 만들 때 정하고 바꿀 수 없다. 지금은 직접 선택(`MANUAL`)만 만들 수 있다. 조건 자동(`AUTOMATIC`)은 다음 단계에서 열리고,
1687
+ * 응답의 `type`은 값이 늘 수 있어 문자열이다.
1688
+ */
1689
+ /** 상점당 컬렉션 최대 개수 — 넘으면 409 `COLLECTION_LIMIT_EXCEEDED` */
1690
+ const MAX_COLLECTIONS = 200;
1691
+ /** 직접 선택 컬렉션 하나에 담는 상품 최대 개수 — 넘으면 409 `COLLECTION_PRODUCT_LIMIT_EXCEEDED` */
1692
+ const MAX_COLLECTION_PRODUCTS = 1e3;
1693
+ /** 상품 뒤에 붙이기(`POST /v1/collections/{id}/products`) 한 번에 보내는 상품 최대 개수 */
1694
+ const MAX_COLLECTION_PRODUCTS_PER_ADD = 500;
1695
+ /** 제목 최대 글자 수 */
1696
+ const MAX_COLLECTION_TITLE = 60;
1697
+ /** 설명 최대 글자 수 */
1698
+ const MAX_COLLECTION_DESCRIPTION = 500;
1699
+ /** 주소(slug) 형식 — 영문 소문자·숫자·하이픈, 첫 글자는 영문 소문자나 숫자, 64자까지 */
1700
+ const COLLECTION_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
1701
+ /** 유형 — `MANUAL`(직접 선택)·`AUTOMATIC`(조건 자동, 아직 만들 수 없다) */
1702
+ const COLLECTION_TYPES = ["MANUAL", "AUTOMATIC"];
1703
+ /**
1704
+ * 상품 정렬 — `MANUAL`은 직접 정한 순서(직접 선택 컬렉션만)다. `BEST_SELLING`(판매량순)은 판매량 집계가 열릴 때 받는다.
1705
+ * 저장 값 전체 목록이고, 쓰기에서 받는 값은 `collectionProductSortRequestSchema`다.
1706
+ */
1707
+ const COLLECTION_PRODUCT_SORTS = [
1708
+ "MANUAL",
1709
+ "LATEST",
1710
+ "BEST_SELLING",
1711
+ "PRICE_ASC",
1712
+ "PRICE_DESC",
1713
+ "REVIEW_COUNT",
1714
+ "RATING_DESC",
1715
+ "RECOMMEND"
1716
+ ];
1717
+ const collectionProductSortRequestSchema = z.enum([
1718
+ "MANUAL",
1719
+ "LATEST",
1720
+ "PRICE_ASC",
1721
+ "PRICE_DESC",
1722
+ "REVIEW_COUNT",
1723
+ "RATING_DESC",
1724
+ "RECOMMEND"
1725
+ ]).describe("스토어프론트 상품 기본 정렬. `MANUAL`(직접 정한 순서)·`LATEST`(최신순)·`PRICE_ASC`·`PRICE_DESC`·`REVIEW_COUNT`·`RATING_DESC`·`RECOMMEND`(추천순)");
1726
+ /** 노출 상태 — 노출 여부와 기간에서 나온 값이다. 숨김이 기간보다 앞선다 */
1727
+ const collectionStateSchema = z.enum([
1728
+ "ACTIVE",
1729
+ "SCHEDULED",
1730
+ "ENDED",
1731
+ "HIDDEN"
1732
+ ]);
1733
+ /** 노출 상태 판정 — 숨김 → 시작 전(예약) → 종료 → 노출 중. `now`는 판정 시각이다 */
1734
+ function collectionStateOf(collection, now) {
1735
+ if (!collection.visible) return "HIDDEN";
1736
+ const at = now.getTime();
1737
+ if (collection.startsAt && Date.parse(collection.startsAt) > at) return "SCHEDULED";
1738
+ if (collection.endsAt && Date.parse(collection.endsAt) <= at) return "ENDED";
1739
+ return "ACTIVE";
1740
+ }
1741
+ const collectionSchema = z.object({
1742
+ collectionId: z.string(),
1743
+ type: z.string().describe("유형. 현재 값은 `MANUAL`·`AUTOMATIC`이고 값이 늘 수 있어 문자열이다"),
1744
+ title: z.string(),
1745
+ slug: z.string().describe("스토어프론트 주소에 쓰는 값. 상점 안에서 유일하다"),
1746
+ description: z.string(),
1747
+ imageUrl: z.string().nullable().describe("대표 이미지 주소"),
1748
+ productSort: z.string().describe("스토어프론트 상품 기본 정렬"),
1749
+ visible: z.boolean().describe("노출 여부. false면 기간과 무관하게 스토어프론트에 보이지 않는다"),
1750
+ startsAt: z.string().nullable().describe("노출 시작 시각. null이면 바로 노출한다"),
1751
+ endsAt: z.string().nullable().describe("노출 종료 시각. null이면 끝나지 않는다"),
1752
+ sortOrder: z.number().int().describe("스토어프론트 컬렉션 목록 순서(작을수록 앞)"),
1753
+ metaTitle: z.string().nullable(),
1754
+ metaDescription: z.string().nullable(),
1755
+ state: collectionStateSchema.describe("지금 노출 상태"),
1756
+ productCount: z.number().int().describe("담긴 상품 수(판매 상태 무관)"),
1757
+ revision: z.number().int().describe("수정 번호. 쓰기마다 1씩 오른다. 수정 본문에 주면 그 사이 다른 수정이 있을 때 409다"),
1758
+ createdAt: z.string(),
1759
+ updatedAt: z.string()
1760
+ });
1761
+ const timeField = (description) => z.iso.datetime({ offset: true }).nullable().optional().describe(description);
1762
+ const slugField = z.string().regex(COLLECTION_SLUG_PATTERN).describe("주소. 영문 소문자·숫자·하이픈 64자까지, 상점 안에서 유일하다(겹치면 409 `COLLECTION_SLUG_TAKEN`)");
1763
+ const productIdsField = z.array(z.string().min(1)).max(MAX_COLLECTION_PRODUCTS).describe("상품 id. 배열 순서가 직접 정한 순서다. 겹치는 id는 처음 것만 남긴다");
1764
+ const commonFields = {
1765
+ description: z.string().max(500).describe("설명(텍스트)"),
1766
+ imageUrl: productImageUrlSchema.nullable().describe("대표 이미지 주소. https만 받는다"),
1767
+ productSort: collectionProductSortRequestSchema,
1768
+ visible: z.boolean().describe("노출 여부"),
1769
+ startsAt: timeField("노출 시작 시각(ISO 8601, 시간대 포함). null이면 바로 노출한다"),
1770
+ endsAt: timeField("노출 종료 시각(ISO 8601, 시간대 포함). null이면 끝나지 않는다"),
1771
+ sortOrder: z.number().int().min(0).max(1e6).describe("목록 순서(작을수록 앞)"),
1772
+ metaTitle: z.string().trim().max(60).nullable().describe("검색 결과 제목"),
1773
+ metaDescription: z.string().trim().max(160).nullable().describe("검색 결과 설명")
1774
+ };
1775
+ const periodRefine = (body, ctx) => {
1776
+ if (body.startsAt && body.endsAt && Date.parse(body.endsAt) <= Date.parse(body.startsAt)) ctx.addIssue({
1777
+ code: "custom",
1778
+ path: ["endsAt"],
1779
+ message: "종료 시각은 시작 시각보다 뒤여야 합니다"
1780
+ });
1781
+ };
1782
+ /**
1783
+ * `POST /v1/collections` — 직접 선택 컬렉션을 만든다. `slug`를 생략하면 `c-{id 뒤 8자}`로 만든다.
1784
+ * `productIds`는 처음 담을 상품과 순서다.
1785
+ */
1786
+ const collectionCreateRequestSchema = z.object({
1787
+ title: z.string().trim().min(1).max(60).describe("제목"),
1788
+ slug: slugField.optional(),
1789
+ type: z.enum(["MANUAL"]).default("MANUAL").describe("유형. 지금은 `MANUAL`(직접 선택)만 받는다. 만든 뒤 바꿀 수 없다"),
1790
+ description: commonFields.description.default(""),
1791
+ imageUrl: commonFields.imageUrl.optional(),
1792
+ productSort: commonFields.productSort.default("MANUAL"),
1793
+ visible: commonFields.visible.default(true),
1794
+ startsAt: commonFields.startsAt,
1795
+ endsAt: commonFields.endsAt,
1796
+ sortOrder: commonFields.sortOrder.optional().describe("목록 순서. 생략하면 맨 뒤"),
1797
+ metaTitle: commonFields.metaTitle.optional(),
1798
+ metaDescription: commonFields.metaDescription.optional(),
1799
+ productIds: productIdsField.optional()
1800
+ }).superRefine(periodRefine);
1801
+ /**
1802
+ * `PATCH /v1/collections/{collectionId}` — 준 항목만 바뀐다. `type`을 주면 400 `COLLECTION_TYPE_IMMUTABLE`이다.
1803
+ * `revision`을 주면 그 번호일 때만 바뀌고, 그 사이 다른 수정이 있었으면 409 `COLLECTION_CHANGED`다.
1804
+ */
1805
+ const collectionPatchRequestSchema = z.object({
1806
+ title: z.string().trim().min(1).max(60).optional(),
1807
+ slug: slugField.optional(),
1808
+ type: z.string().optional().describe("바꿀 수 없다. 주면 400 `COLLECTION_TYPE_IMMUTABLE`"),
1809
+ description: commonFields.description.optional(),
1810
+ imageUrl: commonFields.imageUrl.optional(),
1811
+ productSort: commonFields.productSort.optional(),
1812
+ visible: commonFields.visible.optional(),
1813
+ startsAt: commonFields.startsAt,
1814
+ endsAt: commonFields.endsAt,
1815
+ sortOrder: commonFields.sortOrder.optional(),
1816
+ metaTitle: commonFields.metaTitle.optional(),
1817
+ metaDescription: commonFields.metaDescription.optional(),
1818
+ revision: z.number().int().min(0).optional().describe("읽은 수정 번호")
1819
+ }).superRefine(periodRefine);
1820
+ /** `PUT /v1/collections/{collectionId}/products` — 담긴 상품을 통째로 바꾼다(순서 포함) */
1821
+ const collectionProductsReplaceRequestSchema = z.object({
1822
+ productIds: productIdsField,
1823
+ revision: z.number().int().min(0).optional().describe("읽은 수정 번호")
1824
+ });
1825
+ /** `POST /v1/collections/{collectionId}/products` — 뒤에 붙인다. 이미 담긴 상품은 그 자리에 둔다 */
1826
+ const collectionProductsAddRequestSchema = z.object({ productIds: z.array(z.string().min(1)).min(1).max(500) });
1827
+ /** 컬렉션에 담긴 상품 한 줄 */
1828
+ const collectionProductSchema = z.object({
1829
+ productId: z.string(),
1830
+ name: z.string(),
1831
+ thumbnailUrl: z.string().nullable(),
1832
+ status: z.string().describe("판매 상태. 스토어프론트에는 `SALE`만 보인다"),
1833
+ salePrice: z.number().int(),
1834
+ position: z.number().int().describe("직접 정한 순서(1부터)"),
1835
+ addedAt: z.string()
1836
+ });
1837
+ const collectionStateCountsSchema = z.object({
1838
+ ALL: z.number().int(),
1839
+ ACTIVE: z.number().int(),
1840
+ SCHEDULED: z.number().int(),
1841
+ ENDED: z.number().int(),
1842
+ HIDDEN: z.number().int()
1843
+ });
1844
+ /** 목록 응답 — 페이지 + 상태별 건수(`q`·`type`·`productId`를 적용하고 `state`는 적용하기 전) */
1845
+ const collectionPageSchema = pageSchema(collectionSchema).extend({ stateCounts: collectionStateCountsSchema });
1846
+ /** `GET /v1/collections` 쿼리 — OpenAPI 문서용 형태(값은 쿼리 문자열) */
1847
+ const collectionListQuerySchema = z.object({
1848
+ q: z.string().optional().describe("제목·주소 검색(포함, 대소문자 무시)"),
1849
+ type: z.enum(COLLECTION_TYPES).optional().describe("유형"),
1850
+ state: collectionStateSchema.optional().describe("노출 상태"),
1851
+ productId: z.string().optional().describe("이 상품이 담긴 직접 선택 컬렉션만"),
1852
+ ...pageQueryShape({
1853
+ defaultSize: 20,
1854
+ maxSize: 200
1855
+ })
1856
+ });
1857
+ /** `GET /v1/collections/{collectionId}/products` 쿼리 */
1858
+ const collectionProductListQuerySchema = z.object({ ...pageQueryShape({
1859
+ defaultSize: 100,
1860
+ maxSize: MAX_COLLECTION_PRODUCTS
1861
+ }) });
1862
+ //#endregion
1863
+ //#region src/schemas/promotion.ts
1864
+ /**
1865
+ * 쿠폰 금액 계산 (이슈 #47) — 쿠폰 할인을 **얼마나, 어느 주문상품에** 주는지를 정한다.
1866
+ *
1867
+ * 계산은 순수 함수 `quotePricing` 하나다. 장바구니 요약, 주문서 미리보기, 결제 시작의 최종 확정,
1868
+ * 주문 스냅샷, 환불 정산의 조건 미달 판정(`settleCouponShortfall`)이 모두 이 함수를 지난다.
1869
+ * 다섯 경로가 각자 계산하면 화면 금액, 결제 금액, 환불 금액이 갈라진다(`quoteDelivery`와 같은 원칙).
1870
+ *
1871
+ * 계산 순서
1872
+ * 1. 즉시할인(`products.discount`)은 이미 단가에 들어 있다. 입력 `amount`가 즉시할인 반영 금액이다.
1873
+ * 2. 상품 쿠폰(`PRODUCT`) — 주문상품 한 줄에 한 장. 그 줄 금액을 기준으로 할인한다.
1874
+ * 3. 주문 쿠폰(`ORDER`) — 주문에 한 장. **상품 쿠폰 차감 뒤 금액**의 대상 합계를 기준으로 할인하고,
1875
+ * 대상 줄에 금액 비율로 나눈다(누적 내림의 차분).
1876
+ * 4. 배송비 쿠폰(`DELIVERY`) — 주문에 한 장. 기본 배송비(지역 추가 배송비 제외)를 기준으로 할인하고
1877
+ * 배송 묶음에 기본 배송비 비율로 나눈다.
1878
+ *
1879
+ * 무료배송 기준(상품별 조건부 무료·상점 전체 기준)은 **쿠폰 차감 전 금액**으로 판정한다. 그래서 배송비
1880
+ * 견적(`quoteDelivery`)은 쿠폰과 무관하게 먼저 계산되고, 이 함수는 그 결과만 입력으로 받는다.
1881
+ *
1882
+ * 최소 주문 금액은 대상 상품의 **즉시할인 반영 합계**(쿠폰 차감 전, 배송비 제외)로 판정한다.
1883
+ * 정률 할인은 10원 단위로 내리고(`discountedPriceOf`의 즉시할인과 같은 단위), 최대 할인액과 기준 금액을
1884
+ * 넘지 않는다.
1885
+ */
1886
+ const descriptions$2 = {
1887
+ kind: "쿠폰 종류. `ORDER`=주문 쿠폰(대상 상품 합계에서 할인), `PRODUCT`=상품 쿠폰(주문상품 한 줄에서 할인), `DELIVERY`=배송비 쿠폰(기본 배송비에서 할인, 지역 추가 배송비 제외)",
1888
+ benefitType: "할인 방식. `RATE`=정률(%), `AMOUNT`=정액(원)",
1889
+ benefitValue: "할인 값. `RATE`면 1~100(%), `AMOUNT`면 원 단위 금액",
1890
+ maxDiscountAmount: "정률 할인의 최대 할인액(원). null이면 상한이 없다. 정액 할인에는 쓰지 않는다(null)",
1891
+ minOrderAmount: "최소 주문 금액(원). 대상 상품의 즉시할인 반영 합계(배송비 제외)가 이 값 이상이어야 쓸 수 있다. 0이면 조건이 없다",
1892
+ firstOrderOnly: "이 상점에서 결제 완료 주문이 없는 회원만 쓸 수 있다",
1893
+ memberOnly: "회원만 쓸 수 있다. false면 비회원도 쓸 수 있다 — 코드 입력형이면서 1인 한도가 없는 쿠폰만 false로 둘 수 있다",
1894
+ stackWithInstantDiscount: "상품 즉시할인과 함께 쓸 수 있다. false면 즉시할인이 걸린 주문상품은 쿠폰 대상에서 빠진다",
1895
+ includeProductIds: "대상 상품 id. 포함 목록(상품·카테고리)이 모두 비면 전 상품이 대상이다",
1896
+ includeCategoryIds: "대상 카테고리 id. 하위 카테고리의 상품도 대상이다",
1897
+ excludeProductIds: "제외 상품 id. 포함보다 우선한다",
1898
+ excludeCategoryIds: "제외 카테고리 id. 하위 카테고리의 상품도 제외한다. 포함보다 우선한다"
1899
+ };
1900
+ /** 쿠폰 종류 */
1901
+ const couponKindSchema = z.enum([
1902
+ "ORDER",
1903
+ "PRODUCT",
1904
+ "DELIVERY"
1905
+ ]).describe(descriptions$2.kind);
1906
+ /** 쿠폰 혜택 — 정률은 1~100%, 최대 할인액을 둘 수 있다. 정액은 최대 할인액이 없다 */
1907
+ const couponBenefitSchema = z.object({
1908
+ type: z.enum(["RATE", "AMOUNT"]).describe(descriptions$2.benefitType),
1909
+ value: z.number().int().min(1).describe(descriptions$2.benefitValue),
1910
+ maxDiscountAmount: z.number().int().min(1).nullable().describe(descriptions$2.maxDiscountAmount)
1911
+ }).superRefine((benefit, ctx) => {
1912
+ if (benefit.type === "RATE" && benefit.value > 100) ctx.addIssue({
1913
+ code: "custom",
1914
+ path: ["value"],
1915
+ message: "정률 할인은 1~100(%)이어야 합니다"
1916
+ });
1917
+ if (benefit.type === "AMOUNT" && benefit.maxDiscountAmount !== null) ctx.addIssue({
1918
+ code: "custom",
1919
+ path: ["maxDiscountAmount"],
1920
+ message: "정액 할인에는 최대 할인액을 두지 않습니다"
1921
+ });
1922
+ });
1923
+ /** 쿠폰 사용 조건 */
1924
+ const couponConditionsSchema = z.object({
1925
+ minOrderAmount: z.number().int().min(0).describe(descriptions$2.minOrderAmount),
1926
+ firstOrderOnly: z.boolean().describe(descriptions$2.firstOrderOnly),
1927
+ memberOnly: z.boolean().describe(descriptions$2.memberOnly),
1928
+ stackWithInstantDiscount: z.boolean().describe(descriptions$2.stackWithInstantDiscount)
1929
+ });
1930
+ /** 조건을 생략했을 때의 값 */
1931
+ const COUPON_CONDITIONS_DEFAULTS = {
1932
+ minOrderAmount: 0,
1933
+ firstOrderOnly: false,
1934
+ memberOnly: true,
1935
+ stackWithInstantDiscount: true
1936
+ };
1937
+ /** 상한 — 대상 목록은 규칙 스냅샷에 통째로 복사되므로 크기를 제한한다 */
1938
+ const COUPON_TARGET_LIST_MAX = 500;
1939
+ /** 쿠폰 대상 — 포함 목록이 모두 비면 전 상품이다. 제외가 포함보다 우선한다 */
1940
+ const couponTargetSchema = z.object({
1941
+ includeProductIds: z.array(z.string().min(1)).max(500).describe(descriptions$2.includeProductIds),
1942
+ includeCategoryIds: z.array(z.string().min(1)).max(500).describe(descriptions$2.includeCategoryIds),
1943
+ excludeProductIds: z.array(z.string().min(1)).max(500).describe(descriptions$2.excludeProductIds),
1944
+ excludeCategoryIds: z.array(z.string().min(1)).max(500).describe(descriptions$2.excludeCategoryIds)
1945
+ });
1946
+ const COUPON_TARGET_ALL = {
1947
+ includeProductIds: [],
1948
+ includeCategoryIds: [],
1949
+ excludeProductIds: [],
1950
+ excludeCategoryIds: []
1951
+ };
1952
+ /**
1953
+ * 쿠폰 규칙 스냅샷 — 계산에 필요한 값만 담는다. 결제 시작이 쿠폰을 읽어 이 형태로 결제 세션에 굳히고,
1954
+ * 주문 생성·환불 정산은 이 스냅샷만 읽는다. 결제 도중·주문 뒤 셀러가 쿠폰을 바꿔도 그 주문의 금액은 움직이지 않는다.
1955
+ */
1956
+ const couponRuleSchema = z.object({
1957
+ couponId: z.string().describe("쿠폰 id (`cpn_…`)"),
1958
+ name: z.string().describe("쿠폰 이름"),
1959
+ kind: couponKindSchema,
1960
+ benefit: couponBenefitSchema,
1961
+ conditions: couponConditionsSchema,
1962
+ target: couponTargetSchema,
1963
+ startsAt: z.iso.datetime({ offset: true }).nullable().describe("사용 시작 시각(ISO 8601, 시간대 포함). null이면 제한이 없다"),
1964
+ endsAt: z.iso.datetime({ offset: true }).nullable().describe("사용 종료 시각(ISO 8601, 시간대 포함, 이 시각부터 못 쓴다). null이면 제한이 없다")
1965
+ });
1966
+ /** 쿠폰을 적용하지 못한 사유 */
1967
+ const couponRejectReasonSchema = z.enum([
1968
+ "DUPLICATE_COUPON",
1969
+ "STACK_LIMIT",
1970
+ "NOT_STARTED",
1971
+ "EXPIRED",
1972
+ "MEMBER_ONLY",
1973
+ "FIRST_ORDER_ONLY",
1974
+ "NO_ELIGIBLE_ITEMS",
1975
+ "MIN_ORDER_AMOUNT",
1976
+ "LINE_NOT_ELIGIBLE",
1977
+ "LINE_AMBIGUOUS",
1978
+ "NO_DISCOUNT"
1979
+ ]).describe("쿠폰을 적용하지 못한 사유. `DUPLICATE_COUPON`=같은 쿠폰을 두 번 넣음, `STACK_LIMIT`=같은 종류의 쿠폰이 이미 적용됨(주문 쿠폰·배송비 쿠폰은 주문당 1장, 상품 쿠폰은 주문상품 한 줄에 1장), `NOT_STARTED`=사용 기간 전, `EXPIRED`=사용 기간 지남, `MEMBER_ONLY`=회원 전용, `FIRST_ORDER_ONLY`=첫 구매 전용, `NO_ELIGIBLE_ITEMS`=대상 상품이 주문에 없음, `MIN_ORDER_AMOUNT`=최소 주문 금액 미달, `LINE_NOT_ELIGIBLE`=지정한 주문상품이 대상이 아니거나 이미 상품 쿠폰이 있음, `LINE_AMBIGUOUS`=지정한 `optionId`의 줄이 여럿이라 줄을 정할 수 없음(`lineId`로 지목한다), `NO_DISCOUNT`=할인할 금액이 없음");
1980
+ /** 쿠폰 적용 뒤 결제 금액의 하한 — 0원 결제 경로가 없어 이보다 작으면 결제를 시작하지 않는다(400 `PAYMENT_AMOUNT_TOO_LOW`) */
1981
+ const MIN_PAYMENT_AMOUNT = 100;
2248
1982
  /**
2249
- * 상품 유형 (이슈 #48·#57) — 배송이 필요한 상품과 필요 없는 상품을 가르는 축이다.
2250
- *
2251
- * **상품 유형은 이행 방식 하나로 표현한다.** `fulfillment.type`이 배송 필요 여부, 이행 흐름, 클레임 규칙을 정한다.
2252
- * 코드 곳곳에서 유형 이름으로 분기하지 않고 이 파일의 판정 함수(`requiresShipping`·`fulfillmentRuleOf`)를 지난다.
1983
+ * 할인액 — 정률은 10원 단위 내림, 최대 할인액과 기준 금액을 넘지 않는다. 기준 금액이 0 이하면 0이다.
2253
1984
  *
2254
- * 주문상품 상태 enum은 바꾸지 않는다. 비실물의 **제공 완료는 `DELIVERED`를 재사용한다** — 새 상태를 더하면 기존 소비자의
2255
- * 상태 분기(배송완료 뒤 구매확정 대기, 반품 가능 판정)가 새 값을 모른다. 표시는 유형으로 가른다.
1985
+ * 곱셈을 먼저 하고 나눈다. `base × 율 ÷ 1000`을 내려 10을 곱하면 `floor(base × 율 / 100 / 10) × 10`과 같고
1986
+ * 부동소수 오차가 끼지 않는다.
2256
1987
  */
1988
+ function couponDiscountOf(benefit, base) {
1989
+ if (base <= 0) return 0;
1990
+ let raw = benefit.type === "RATE" ? Math.floor(base * benefit.value / 1e3) * 10 : benefit.value;
1991
+ if (benefit.type === "RATE" && benefit.maxDiscountAmount !== null) raw = Math.min(raw, benefit.maxDiscountAmount);
1992
+ return Math.max(0, Math.min(raw, base));
1993
+ }
2257
1994
  /**
2258
- * 상품 유형.
2259
- * - `SHIPPING` 배송 상품. 택배로 발송하고 송장·배송 추적으로 배송완료가 된다
2260
- * - `MANUAL` 배송 없는 상품(비실물). 셀러가 제공 처리(`POST /v1/order-items/{orderItemId}/fulfill`)로 제공 완료를 기록한다
1995
+ * 금액을 가중치 비율로 나눈다 — **누적 내림의 차분**이다.
2261
1996
  *
2262
- * 비실물의 세부 구분(파일 다운로드·코드 발급·이용권·방문 수령)은 2026-09-28에 제품에서 뺐다(이슈 #48 범위 축소).
2263
- * 이름은 이행 방식이다 — `SHIPPING`이 택배 발송이듯 `MANUAL`은 셀러가 직접 제공한다는 뜻이다.
1997
+ * `i`번째 몫 = `floor(total × W_i / W) − floor(total × W_{i−1} / W)`(`W_i`는 앞에서부터 누적 가중치).
1998
+ * 합이 `total`과 정확히 같고, 입력 순서가 같으면 결과도 같다. `total ≤ W`이면 몫이 가중치를 넘지 않는다.
1999
+ * 곱이 2^53을 넘을 수 있어 BigInt로 계산한다.
2264
2000
  */
2265
- const fulfillmentTypeSchema = z.enum(["SHIPPING", "MANUAL"]);
2001
+ function allocateByWeight(total, weights) {
2002
+ if (!Number.isSafeInteger(total) || !weights.every((w) => Number.isSafeInteger(w))) throw new RangeError("allocateByWeight: 금액과 가중치는 안전한 정수여야 합니다");
2003
+ const sum = weights.reduce((acc, w) => acc + Math.max(0, w), 0);
2004
+ if (total <= 0 || sum <= 0) return weights.map(() => 0);
2005
+ const bigTotal = BigInt(total);
2006
+ const bigSum = BigInt(sum);
2007
+ let cumulative = 0n;
2008
+ let previous = 0n;
2009
+ return weights.map((weight) => {
2010
+ cumulative += BigInt(Math.max(0, weight));
2011
+ const upTo = bigTotal * cumulative / bigSum;
2012
+ const share = upTo - previous;
2013
+ previous = upTo;
2014
+ return Number(share);
2015
+ });
2016
+ }
2017
+ /** 카테고리 경로(`/a/b/c/`)가 그 카테고리 또는 하위를 가리키는가 */
2018
+ function inCategory(categoryPath, categoryIds) {
2019
+ if (!categoryPath || categoryIds.length === 0) return false;
2020
+ const chain = categoryPath.split("/").filter(Boolean);
2021
+ return categoryIds.some((id) => chain.includes(id));
2022
+ }
2023
+ /** 줄이 쿠폰 대상인가 — 제외가 포함보다 우선하고, 포함 목록이 모두 비면 전 상품이다 */
2024
+ function isCouponTargetLine(rule, line) {
2025
+ const { target, conditions } = rule;
2026
+ if (!conditions.stackWithInstantDiscount && line.amount < line.baseAmount) return false;
2027
+ if (target.excludeProductIds.includes(line.productId)) return false;
2028
+ if (inCategory(line.categoryPath, target.excludeCategoryIds)) return false;
2029
+ if (target.includeProductIds.length === 0 && target.includeCategoryIds.length === 0) return true;
2030
+ return target.includeProductIds.includes(line.productId) || inCategory(line.categoryPath, target.includeCategoryIds);
2031
+ }
2032
+ /** 시각 파싱 — 값이 없으면 null(제한 없음), 읽지 못하면 NaN(판정에서 거절한다) */
2033
+ function timeOf(value) {
2034
+ if (!value) return null;
2035
+ return Date.parse(value);
2036
+ }
2266
2037
  /**
2267
- * 응답 스키마용 유형 — 서버가 나중에 유형을 더해도 옛 SDK가 응답 파싱에서 깨지지 않게 문자열로 받는다.
2268
- * 요청은 `fulfillmentTypeSchema`(닫힌 enum)로 검증한다.
2038
+ * 사용 기간 판정 — 쿠폰 기간과 발급 쿠폰 유효 기간 중 좁은 쪽. 시작은 포함, 종료는 제외.
2039
+ * 읽지 못하는 시각은 제한 없음이 아니라 거절이다(fail-closed).
2269
2040
  */
2270
- const fulfillmentTypeResponseSchema = z.string().describe("상품 유형. 현재 값은 `SHIPPING`(배송 상품)·`MANUAL`(배송 없는 상품, 셀러가 제공 처리)이고 값이 추가될 수 있어 문자열로 받는다");
2271
- /** 유형별 규칙표 — **유일한 원천이다.** */
2272
- const FULFILLMENT_RULES = {
2273
- SHIPPING: {
2274
- requiresShipping: true,
2275
- requiresCollection: true,
2276
- exchangeSupported: true
2277
- },
2278
- MANUAL: {
2279
- requiresShipping: false,
2280
- requiresCollection: false,
2281
- exchangeSupported: false
2282
- }
2283
- };
2041
+ function periodRejectOf(coupon, now) {
2042
+ const at = now.getTime();
2043
+ const starts = [timeOf(coupon.rule.startsAt), timeOf(coupon.validFrom)].filter((t) => t !== null);
2044
+ const ends = [timeOf(coupon.rule.endsAt), timeOf(coupon.validUntil)].filter((t) => t !== null);
2045
+ if (starts.some((t) => Number.isNaN(t) || at < t)) return "NOT_STARTED";
2046
+ if (ends.some((t) => Number.isNaN(t) || at >= t)) return "EXPIRED";
2047
+ return null;
2048
+ }
2284
2049
  /**
2285
- * 저장된 값 → 유형. null·모르는 값은 `SHIPPING`이다 — 이 필드 이전의 상품·주문상품은 모두 배송 상품이었다.
2050
+ * 쿠폰 금액 계산 — **순수 함수다.** DB를 읽지 않는다. 규칙 스냅샷을 만드는 조회와 한도·보유 판정
2051
+ * (발급 쿠폰 상태, 1인 한도)은 호출부 몫이다.
2052
+ *
2053
+ * 종류별로 상품 쿠폰 → 주문 쿠폰 → 배송비 쿠폰 순으로 적용한다. 같은 종류의 자리와 같은 쿠폰 id는
2054
+ * **적용된** 쿠폰만 차지한다 — 앞선 주문 쿠폰이 최소 금액 미달이면 뒤의 주문 쿠폰이 쓰이고, 같은 쿠폰의 발급
2055
+ * 사본 중 앞 장이 만료됐으면 뒤 장이 쓰인다. 결과의 `coupons`는 입력 순서 그대로다. 입력이 같으면 결과도 같다.
2286
2056
  */
2287
- function fulfillmentTypeOf(value) {
2288
- const parsed = fulfillmentTypeSchema.safeParse(value);
2289
- return parsed.success ? parsed.data : "SHIPPING";
2290
- }
2291
- /** 유형의 규칙 — 모르는 값은 배송 상품 규칙이다 */
2292
- function fulfillmentRuleOf(type) {
2293
- return FULFILLMENT_RULES[fulfillmentTypeOf(type)];
2294
- }
2295
- /** 택배 배송이 필요한가 — 배송비 묶음·배송지·발송 흐름의 판정은 이 함수 하나를 지난다 */
2296
- function requiresShipping(type) {
2297
- return fulfillmentRuleOf(type).requiresShipping;
2298
- }
2299
- /** 반품에 수거가 필요한가 — 비실물 반품은 수거 등록·수거 완료 없이 승인한다 */
2300
- function requiresCollection(type) {
2301
- return fulfillmentRuleOf(type).requiresCollection;
2057
+ function quotePricing(input) {
2058
+ const lines = input.lines.map((line) => ({
2059
+ line,
2060
+ productDiscount: 0,
2061
+ allocations: []
2062
+ }));
2063
+ const lineIndex = new Map(lines.map((entry, index) => [entry.line.lineId, index]));
2064
+ const lineOf = (id) => {
2065
+ const index = lineIndex.get(id);
2066
+ return index === void 0 ? void 0 : lines[index];
2067
+ };
2068
+ const results = input.coupons.map((coupon) => ({
2069
+ couponKey: coupon.couponKey ?? coupon.rule.couponId,
2070
+ couponId: coupon.rule.couponId,
2071
+ kind: coupon.rule.kind,
2072
+ applied: false,
2073
+ discount: 0,
2074
+ rejectReason: null,
2075
+ lineId: null,
2076
+ eligibleLineIds: []
2077
+ }));
2078
+ const commonRejectOf = (coupon, result) => {
2079
+ if (!input.ignoreEligibility) {
2080
+ const period = periodRejectOf(coupon, input.now);
2081
+ if (period) return period;
2082
+ if (coupon.rule.conditions.memberOnly && !input.isMember) return "MEMBER_ONLY";
2083
+ if (coupon.rule.conditions.firstOrderOnly && !input.firstOrder) return "FIRST_ORDER_ONLY";
2084
+ }
2085
+ const pinned = coupon.eligibleLineIds ? new Set(coupon.eligibleLineIds) : null;
2086
+ const eligible = lines.filter((entry) => pinned ? pinned.has(entry.line.lineId) : isCouponTargetLine(coupon.rule, entry.line));
2087
+ result.eligibleLineIds = eligible.map((entry) => entry.line.lineId);
2088
+ if (eligible.length === 0) return "NO_ELIGIBLE_ITEMS";
2089
+ if (eligible.reduce((sum, entry) => sum + entry.line.amount, 0) < coupon.rule.conditions.minOrderAmount) return "MIN_ORDER_AMOUNT";
2090
+ return null;
2091
+ };
2092
+ const passed = [];
2093
+ input.coupons.forEach((coupon, index) => {
2094
+ const result = results[index];
2095
+ const reason = commonRejectOf(coupon, result);
2096
+ if (reason) result.rejectReason = reason;
2097
+ else passed.push(index);
2098
+ });
2099
+ /** 적용된 쿠폰 id — 같은 쿠폰 정의는 주문에 한 장 */
2100
+ const appliedCouponIds = /* @__PURE__ */ new Set();
2101
+ const apply = (index, discount, lineId) => {
2102
+ const result = results[index];
2103
+ result.applied = true;
2104
+ result.discount = discount;
2105
+ result.lineId = lineId;
2106
+ appliedCouponIds.add(result.couponId);
2107
+ };
2108
+ const duplicate = (index) => {
2109
+ const result = results[index];
2110
+ if (!appliedCouponIds.has(result.couponId)) return false;
2111
+ result.rejectReason = "DUPLICATE_COUPON";
2112
+ return true;
2113
+ };
2114
+ const productIndexes = passed.filter((index) => input.coupons[index]?.rule.kind === "PRODUCT");
2115
+ const productOrder = [...productIndexes.filter((index) => input.coupons[index]?.lineId), ...productIndexes.filter((index) => !input.coupons[index]?.lineId).map((index) => ({
2116
+ index,
2117
+ width: results[index]?.eligibleLineIds.length ?? 0
2118
+ })).sort((a, b) => a.width - b.width || a.index - b.index).map((entry) => entry.index)];
2119
+ const takenLines = /* @__PURE__ */ new Set();
2120
+ for (const index of productOrder) {
2121
+ const coupon = input.coupons[index];
2122
+ const result = results[index];
2123
+ if (duplicate(index)) continue;
2124
+ const candidates = result.eligibleLineIds.filter((id) => !takenLines.has(id));
2125
+ let chosen = null;
2126
+ let best = 0;
2127
+ if (coupon.lineId) {
2128
+ if (!candidates.includes(coupon.lineId)) {
2129
+ result.rejectReason = result.eligibleLineIds.includes(coupon.lineId) ? "STACK_LIMIT" : "LINE_NOT_ELIGIBLE";
2130
+ continue;
2131
+ }
2132
+ chosen = coupon.lineId;
2133
+ best = couponDiscountOf(coupon.rule.benefit, lineOf(chosen)?.line.amount ?? 0);
2134
+ } else {
2135
+ if (candidates.length === 0) {
2136
+ result.rejectReason = "STACK_LIMIT";
2137
+ continue;
2138
+ }
2139
+ for (const id of candidates) {
2140
+ const discount = couponDiscountOf(coupon.rule.benefit, lineOf(id)?.line.amount ?? 0);
2141
+ if (chosen === null || discount > best) {
2142
+ chosen = id;
2143
+ best = discount;
2144
+ }
2145
+ }
2146
+ }
2147
+ const entry = chosen === null ? void 0 : lineOf(chosen);
2148
+ if (!entry || chosen === null || best <= 0) {
2149
+ result.rejectReason = "NO_DISCOUNT";
2150
+ continue;
2151
+ }
2152
+ takenLines.add(chosen);
2153
+ entry.productDiscount += best;
2154
+ entry.allocations.push({
2155
+ couponKey: result.couponKey,
2156
+ amount: best
2157
+ });
2158
+ apply(index, best, chosen);
2159
+ }
2160
+ let orderSlot = false;
2161
+ for (const index of passed) {
2162
+ const coupon = input.coupons[index];
2163
+ const result = results[index];
2164
+ if (coupon.rule.kind !== "ORDER") continue;
2165
+ if (duplicate(index)) continue;
2166
+ if (orderSlot) {
2167
+ result.rejectReason = "STACK_LIMIT";
2168
+ continue;
2169
+ }
2170
+ const targets = result.eligibleLineIds.map((id) => lineOf(id)).filter((entry) => entry !== void 0);
2171
+ const weights = targets.map((entry) => Math.max(0, entry.line.amount - entry.productDiscount));
2172
+ const base = weights.reduce((sum, w) => sum + w, 0);
2173
+ const discount = couponDiscountOf(coupon.rule.benefit, base);
2174
+ if (discount <= 0) {
2175
+ result.rejectReason = "NO_DISCOUNT";
2176
+ continue;
2177
+ }
2178
+ const shares = allocateByWeight(discount, weights);
2179
+ targets.forEach((entry, i) => {
2180
+ const share = shares[i] ?? 0;
2181
+ if (share > 0) entry.allocations.push({
2182
+ couponKey: result.couponKey,
2183
+ amount: share
2184
+ });
2185
+ });
2186
+ orderSlot = true;
2187
+ apply(index, discount, null);
2188
+ }
2189
+ const deliveryBundles = input.delivery?.bundles ?? [];
2190
+ const bundles = deliveryBundles.map((bundle) => ({
2191
+ bundleKey: bundle.bundleKey,
2192
+ couponDiscount: 0
2193
+ }));
2194
+ let deliverySlot = false;
2195
+ let deliveryDiscountAmount = 0;
2196
+ for (const index of passed) {
2197
+ const coupon = input.coupons[index];
2198
+ const result = results[index];
2199
+ if (coupon.rule.kind !== "DELIVERY") continue;
2200
+ if (duplicate(index)) continue;
2201
+ if (deliverySlot) {
2202
+ result.rejectReason = "STACK_LIMIT";
2203
+ continue;
2204
+ }
2205
+ const weights = deliveryBundles.map((bundle) => Math.max(0, bundle.fee));
2206
+ const base = weights.reduce((sum, w) => sum + w, 0);
2207
+ const discount = couponDiscountOf(coupon.rule.benefit, base);
2208
+ if (discount <= 0) {
2209
+ result.rejectReason = "NO_DISCOUNT";
2210
+ continue;
2211
+ }
2212
+ allocateByWeight(discount, weights).forEach((share, i) => {
2213
+ const bundle = bundles[i];
2214
+ if (bundle) bundle.couponDiscount += share;
2215
+ });
2216
+ deliverySlot = true;
2217
+ deliveryDiscountAmount = discount;
2218
+ apply(index, discount, null);
2219
+ }
2220
+ const lineResults = lines.map((entry) => ({
2221
+ lineId: entry.line.lineId,
2222
+ amount: entry.line.amount,
2223
+ couponDiscount: entry.allocations.reduce((sum, a) => sum + a.amount, 0),
2224
+ allocations: entry.allocations
2225
+ }));
2226
+ const productAmount = lines.reduce((sum, entry) => sum + entry.line.amount, 0);
2227
+ const couponDiscountAmount = lineResults.reduce((sum, line) => sum + line.couponDiscount, 0);
2228
+ const deliveryFee = deliveryBundles.reduce((sum, bundle) => sum + bundle.fee + bundle.remoteSurcharge, 0);
2229
+ return {
2230
+ lines: lineResults,
2231
+ coupons: results,
2232
+ bundles,
2233
+ productAmount,
2234
+ couponDiscountAmount,
2235
+ deliveryFee,
2236
+ deliveryDiscountAmount,
2237
+ totalAmount: productAmount - couponDiscountAmount + deliveryFee - deliveryDiscountAmount
2238
+ };
2302
2239
  }
2303
- /**
2304
- * 배송이 없는 주문상품의 배송 묶음 키 (`order_items.delivery_bundle_key`).
2305
- *
2306
- * null로 두면 환불 정산이 #29 이전 주문의 레거시 폴백(「묶음 = 같은 상품」, 상품의 **현재** 배송비)을 탄다.
2307
- * 이 키를 만나면 배송비 환불·차감이 0이고 `orders.delivery_fee_settlements`에 항목을 만들지 않는다.
2308
- * 정책 지문 키(`b:…`)와 겹치지 않는다.
2309
- */
2310
- const NO_SHIPPING_BUNDLE_KEY = "none";
2311
- /** 배송비 유형 — 무료·유료·조건부 무료 */
2312
- const deliveryTypeSchema = z.enum([
2313
- "FREE",
2314
- "PAID",
2315
- "CONDITIONAL_FREE"
2316
- ]);
2317
- const KNOWN_FULFILLMENT_TYPES = fulfillmentTypeSchema.options;
2318
- const ADDRESS_ID_DESCRIPTIONS = {
2319
- shipping: "출고지 주소 id(`GET /v1/seller-addresses?type=SHIPPING`). 등록에서 생략하면 그 시점 상점 기본 출고지를 저장한다. `null`이면 비워 둔다(상점 기본 출고지를 따른다). 이 상점의 `SHIPPING` 주소가 아니면 거부한다",
2320
- return: "반품지 주소 id(`GET /v1/seller-addresses?type=RETURN`). 등록에서 생략하면 그 시점 상점 기본 반품지를 저장한다. `null`이면 비워 둔다(상점 기본 반품지를 따른다). 이 상점의 `RETURN` 주소가 아니면 거부한다"
2240
+ const NO_COUPON_SETTLEMENT = {
2241
+ chargeAmount: 0,
2242
+ refundAmount: 0,
2243
+ entries: []
2321
2244
  };
2322
2245
  /**
2323
- * 배송비 필드 사이의 일관성 — FREE의 배송비, 조건부 무료가 아닌 상품의 무료 기준을 조용히 0·null로 바꿔 저장하지 않고 400으로 돌려준다
2324
- * (셀러가 저장됐다고 믿는 값이 사라지지 않게). 조건부 무료는 기준 금액이 필수다 — 기준 없는 조건부 무료는 계산상 유료 배송이다.
2246
+ * 쿠폰 조건 미달 차감 — **순수 함수다.** 부분 취소·반품으로 남은 주문상품이 쿠폰 조건을 잃으면,
2247
+ * 남은 주문상품이 받던 할인만큼을 이번 환불에서 뺀다.
2248
+ *
2249
+ * 쿠폰마다
2250
+ * - `allocatedRemaining` = 남은 수량에 배분된 할인(`proratedRefundBase`로 수량 비율)
2251
+ * - `recomputed` = 주문 시점 규칙 스냅샷과 주문 시점 대상 줄로, 남은 금액만 넣어 `quotePricing`을 다시 돌린 할인
2252
+ * - 쿠폰이 **여전히 적용되면 목표 0**, 조건을 잃었으면 목표 = `allocatedRemaining`이다. 적용되는 쿠폰의
2253
+ * 정률 10원 내림 차이까지 차감하면 조건을 지킨 구매자가 몇 원씩 잃는다 — 조건을 잃은 경우만 본다.
2254
+ * 재계산 할인이 더 커지는 경우(정률 최대 할인액)도 차감하지 않고 더 돌려주지도 않는다.
2255
+ * - `delta` = 목표 − (이미 차감 + 이미 면제). 양수면 청구하고(판매자 귀책·정책 꺼짐이면 면제로 기록),
2256
+ * 음수면 앞서 뺀 차감부터 돌려준다. 마지막 활성 수량까지 취소되면 목표가 0이라 누적 차감이 모두 되돌아가고,
2257
+ * 전량 취소의 환불 합계가 결제액과 같아진다.
2258
+ * - 청구는 이번 환불에서 실제로 뺄 수 있는 금액(`chargeableAmount` + 이번에 되돌린 금액)을 넘지 않고,
2259
+ * **실제로 뺀 금액만** 기록한다. 못 뺀 나머지는 기록하지 않아 다음 환불이 다시 본다.
2260
+ *
2261
+ * 배송비 쿠폰은 여기서 다루지 않는다. 배송비 쿠폰을 발급하게 되면(3단계) 묶음 배송비 환불
2262
+ * (`computeDeliveryFeeSettlement`)이 그 묶음의 `couponDiscount`를 빼야 한다 — 빼지 않으면 받지 않은 배송비를 돌려준다.
2325
2263
  */
2326
- function refineShippingConsistency(value, ctx) {
2327
- if (value.deliveryType === "FREE" && value.deliveryFee !== void 0 && value.deliveryFee !== 0) ctx.addIssue({
2328
- code: "custom",
2329
- path: ["deliveryFee"],
2330
- message: "무료 배송(FREE)의 배송비는 0이거나 생략해야 합니다"
2331
- });
2332
- const threshold = value.conditionalFreeAmount;
2333
- const hasThreshold = threshold !== void 0 && threshold !== null;
2334
- if (value.deliveryType === "CONDITIONAL_FREE" && !hasThreshold) ctx.addIssue({
2335
- code: "custom",
2336
- path: ["conditionalFreeAmount"],
2337
- message: "조건부 무료(CONDITIONAL_FREE)는 무료 기준 금액이 필요합니다"
2264
+ function settleCouponShortfall(input) {
2265
+ const coupons = input.coupons.filter((coupon) => coupon.rule.kind !== "DELIVERY");
2266
+ if (coupons.length === 0) return NO_COUPON_SETTLEMENT;
2267
+ const recomputed = quotePricing({
2268
+ lines: input.lines.map((line) => {
2269
+ const remaining = proratedRefundBase(line.totalPrice, line.quantity, 0, line.activeQuantity);
2270
+ return {
2271
+ lineId: line.lineId,
2272
+ productId: line.productId,
2273
+ quantity: line.activeQuantity,
2274
+ baseAmount: remaining,
2275
+ amount: remaining
2276
+ };
2277
+ }),
2278
+ coupons: coupons.map((coupon) => ({
2279
+ rule: coupon.rule,
2280
+ couponKey: coupon.couponKey,
2281
+ lineId: coupon.lineId,
2282
+ eligibleLineIds: coupon.eligibleLineIds
2283
+ })),
2284
+ delivery: null,
2285
+ firstOrder: true,
2286
+ isMember: true,
2287
+ now: /* @__PURE__ */ new Date(0),
2288
+ ignoreEligibility: true
2338
2289
  });
2339
- if (value.deliveryType !== "CONDITIONAL_FREE" && hasThreshold) ctx.addIssue({
2340
- code: "custom",
2341
- path: ["conditionalFreeAmount"],
2342
- message: "무료 기준 금액은 조건부 무료(CONDITIONAL_FREE)에서만 받습니다"
2290
+ const base = coupons.map((coupon, index) => {
2291
+ const allocatedRemaining = input.lines.reduce((sum, line) => {
2292
+ return sum + proratedRefundBase(line.allocations.filter((a) => a.couponKey === coupon.couponKey).reduce((acc, a) => acc + a.amount, 0), line.quantity, 0, line.activeQuantity);
2293
+ }, 0);
2294
+ const result = recomputed.coupons[index];
2295
+ const stillApplied = result?.applied === true;
2296
+ const state = input.settlements[coupon.couponKey] ?? {};
2297
+ return {
2298
+ couponKey: coupon.couponKey,
2299
+ allocatedRemaining,
2300
+ recomputed: stillApplied ? result?.discount ?? 0 : 0,
2301
+ target: stillApplied ? 0 : allocatedRemaining,
2302
+ charged: Math.max(0, state.charged ?? 0),
2303
+ waived: Math.max(0, state.waived ?? 0)
2304
+ };
2343
2305
  });
2344
- }
2345
- const deliveryTypeDescription = "배송비 유형. `FREE` 무료, `PAID` 유료, `CONDITIONAL_FREE` 조건부 무료";
2346
- const shippingCreateSchema = z.strictObject({
2347
- deliveryType: deliveryTypeSchema.default("PAID").describe(deliveryTypeDescription),
2348
- deliveryFee: z.number().int().min(0).optional().describe("배송비. 원 단위. 생략하면 상점 기본 배송비(`GET /v1/store`의 `shippingPolicy.baseDeliveryFee`)를 저장한다. FREE면 0이거나 생략한다"),
2349
- conditionalFreeAmount: z.number().int().min(0).nullable().optional().describe("무료 배송이 되는 묶음 소계. 원 단위. CONDITIONAL_FREE에서만 받고 필수다"),
2350
- shippingAddressId: z.string().nullable().optional().describe(ADDRESS_ID_DESCRIPTIONS.shipping),
2351
- returnAddressId: z.string().nullable().optional().describe(ADDRESS_ID_DESCRIPTIONS.return)
2352
- }).superRefine(refineShippingConsistency);
2353
- /**
2354
- * 상품 이행 — 등록(POST). 배송 상품의 `shipping`을 생략하면 기본값(유료 배송·상점 기본 배송비·기본 출고지·반품지)이다.
2355
- */
2356
- const productFulfillmentCreateSchema = z.discriminatedUnion("type", [z.strictObject({
2357
- type: z.literal("SHIPPING"),
2358
- shipping: shippingCreateSchema.optional()
2359
- }), z.strictObject({ type: z.literal("MANUAL") })]);
2360
- const shippingUpdateSchema = z.strictObject({
2361
- deliveryType: deliveryTypeSchema.describe(deliveryTypeDescription),
2362
- deliveryFee: z.number().int().min(0).describe("배송비. 원 단위. FREE면 0"),
2363
- conditionalFreeAmount: z.number().int().min(0).nullable().describe("무료 배송이 되는 묶음 소계. CONDITIONAL_FREE면 필수, 아니면 null"),
2364
- shippingAddressId: z.string().nullable().describe(ADDRESS_ID_DESCRIPTIONS.shipping),
2365
- returnAddressId: z.string().nullable().describe(ADDRESS_ID_DESCRIPTIONS.return)
2366
- }).superRefine(refineShippingConsistency);
2367
- /**
2368
- * 상품 이행 — 수정(PUT·PATCH). 통째 교체이고 유형 키와 그 안의 필드가 **모두 필수**다. 서버는 현재 값과 병합하지 않는다.
2369
- * `fulfillment` 자체를 생략하면 이행을 바꾸지 않는다.
2370
- */
2371
- const productFulfillmentUpdateSchema = z.discriminatedUnion("type", [z.strictObject({
2372
- type: z.literal("SHIPPING"),
2373
- shipping: shippingUpdateSchema
2374
- }), z.strictObject({ type: z.literal("MANUAL") })]);
2375
- /**
2376
- * 저장된 상품 이행 — 알려진 유형의 닫힌 유니온. 서버의 상품 엔티티와 응답이 이 모양을 쓴다.
2377
- * 응답 파싱은 `productFulfillmentResponseSchema`(열린 유니온)를 쓴다.
2378
- */
2379
- const productFulfillmentSchema = z.discriminatedUnion("type", [z.object({
2380
- type: z.literal("SHIPPING"),
2381
- shipping: z.object({
2382
- deliveryType: deliveryTypeSchema,
2383
- deliveryFee: z.number().int().describe("배송비. 원 단위. FREE면 0"),
2384
- conditionalFreeAmount: z.number().int().nullable().describe("조건부 무료 기준 묶음 소계. CONDITIONAL_FREE가 아니면 null"),
2385
- shippingAddressId: z.string().nullable().describe("출고지 주소 id. null이면 상점 기본 출고지를 따른다"),
2386
- returnAddressId: z.string().nullable().describe("반품지 주소 id. null이면 상점 기본 반품지를 따른다")
2387
- })
2388
- }), z.object({ type: z.literal("MANUAL") })]);
2389
- /**
2390
- * 열린 응답 유니온 — 알려진 유형은 판별 유니온으로 검사하고, **모르는 유형만** `{ type: "OTHER", rawType }`로 읽는다.
2391
- *
2392
- * `type: z.string()` 가지를 그대로 두면 TS가 `type === "SHIPPING"`으로 좁혀도 문자열 가지가 남는다. `OTHER` 리터럴로 바꿔 좁히기를
2393
- * 지킨다. 알려진 유형의 모양이 틀린 응답은 폴백으로 숨기지 않고 파싱 오류로 둔다.
2394
- */
2395
- function openFulfillmentUnion(known) {
2396
- return z.union([known, z.looseObject({ type: z.string().refine((type) => !KNOWN_FULFILLMENT_TYPES.includes(type), { message: "알려진 유형은 해당 유형의 모양이어야 합니다" }) }).transform((value) => ({
2397
- type: "OTHER",
2398
- rawType: value.type
2399
- }))]);
2400
- }
2401
- const productFulfillmentResponseSchema = openFulfillmentUnion(productFulfillmentSchema).describe("상품 이행. `type`이 유형이고 같은 이름의 키가 유형별 속성이다. 값이 추가될 수 있어 SDK는 모르는 유형을 `{ type: \"OTHER\", rawType }`로 읽는다");
2402
- /**
2403
- * 주문상품의 주문 시점 이행 — 결제 확정이 주문서 줄에서 굳힌다. 상품의 현재 값이 아니다. 서버는 주문상품 컬럼
2404
- * (`fulfillment_type`·`delivery_bundle_key`)과 jsonb(`fulfillment_snapshot`)를 합쳐 이 모양을 만든다.
2405
- */
2406
- const orderItemFulfillmentSnapshotSchema = z.discriminatedUnion("type", [z.object({
2407
- type: z.literal("SHIPPING"),
2408
- shipping: z.object({
2409
- bundleKey: z.string().nullable().describe("배송 묶음 키. 같은 키의 주문상품이 배송비를 한 번 낸 한 묶음이다. #29 이전 주문은 null"),
2410
- returnAddressId: z.string().nullable().describe("주문 시점 반품지 주소 id. null이거나 지워진 주소면 상점 기본 반품지로 안내한다")
2411
- })
2412
- }), z.object({ type: z.literal("MANUAL") })]);
2413
- const orderItemFulfillmentSnapshotResponseSchema = openFulfillmentUnion(orderItemFulfillmentSnapshotSchema).describe("주문 시점 이행 스냅샷. 상품을 나중에 고쳐도 바뀌지 않는다. 제공 기록은 `fulfillment`에 있다. 값이 추가될 수 있어 SDK는 모르는 유형을 `OTHER`로 읽는다");
2414
- //#endregion
2415
- //#region src/schemas/payment.ts
2306
+ const entries = [];
2307
+ let refundAmount = 0;
2308
+ for (const item of base) {
2309
+ const delta = item.target - (item.charged + item.waived);
2310
+ let refund = 0;
2311
+ let waived = item.waived;
2312
+ if (delta < 0) {
2313
+ refund = Math.min(item.charged, -delta);
2314
+ waived = Math.max(0, waived - (-delta - refund));
2315
+ }
2316
+ refundAmount += refund;
2317
+ entries.push({
2318
+ couponKey: item.couponKey,
2319
+ allocatedRemaining: item.allocatedRemaining,
2320
+ recomputed: item.recomputed,
2321
+ target: item.target,
2322
+ charge: 0,
2323
+ refund,
2324
+ waive: 0,
2325
+ next: {
2326
+ charged: item.charged - refund,
2327
+ waived
2328
+ }
2329
+ });
2330
+ }
2331
+ let capacity = Math.max(0, input.chargeableAmount) + refundAmount;
2332
+ let chargeAmount = 0;
2333
+ const chargeAllowed = input.shortfallCharge && !input.sellerFault;
2334
+ for (const entry of entries) {
2335
+ const delta = entry.target - (entry.next.charged + entry.next.waived);
2336
+ if (delta <= 0) continue;
2337
+ if (!chargeAllowed) {
2338
+ entry.waive = delta;
2339
+ entry.next = {
2340
+ ...entry.next,
2341
+ waived: entry.next.waived + delta
2342
+ };
2343
+ continue;
2344
+ }
2345
+ const charge = Math.min(delta, capacity);
2346
+ if (charge <= 0) continue;
2347
+ capacity -= charge;
2348
+ chargeAmount += charge;
2349
+ entry.charge = charge;
2350
+ entry.next = {
2351
+ ...entry.next,
2352
+ charged: entry.next.charged + charge
2353
+ };
2354
+ }
2355
+ return {
2356
+ chargeAmount,
2357
+ refundAmount,
2358
+ entries
2359
+ };
2360
+ }
2416
2361
  /**
2417
- * 상점 결제(PG) 설정과 매출 리포트 계약.
2362
+ * 주문상품의 실결제 금액 중 이번 취소 수량의 몫 — **실결제 금액(`totalPrice − couponDiscountAmount`)을
2363
+ * 누적 내림의 차분으로 나눈다.**
2418
2364
  *
2419
- * PG는 셀러가 직접 계약한다. 자격 증명은 상점별로 저장되고, 비밀 값(시크릿 키·API Secret·
2420
- * 웹훅 시크릿)은 저장 후 다시 조회할 수 없다. 응답에는 앞 4자리 마스킹만 나온다. 공개 값
2421
- * (클라이언트 키·Store ID·채널 키)은 원래 브라우저 결제창에 노출되는 값이라 그대로 돌려준다.
2422
- */
2423
- const pgProviderSchema = z.enum(["tosspayments", "portone"]).describe("PG 식별자. `tosspayments`=토스페이먼츠, `portone`=포트원(V2)");
2424
- const pgPaymentMethodSchema = z.enum([
2425
- "CARD",
2426
- "BANK_TRANSFER",
2427
- "VIRTUAL_ACCOUNT",
2428
- "MOBILE",
2429
- "EASY_PAY"
2430
- ]).describe("결제수단 (스토어프론트 결제 요청의 `paymentMethod`와 같은 값)");
2431
- /**
2432
- * 간편결제사 — 결제 옵션(스토어프론트 `paymentOptions`)과 PG 설정의 `easyPayProviders`에 쓴다.
2433
- * `@sayren/storefront-sdk`의 `easyPayProviderSchema`와 같은 값이다(api 계약 테스트가 고정한다)
2434
- */
2435
- const easyPayProviderSchema = z.enum([
2436
- "NAVERPAY",
2437
- "KAKAOPAY",
2438
- "TOSSPAY",
2439
- "PAYCO"
2440
- ]);
2441
- /**
2442
- * 응답에 실리는 간편결제사 — 서버가 간편결제사를 추가해도 파싱이 깨지지 않게 문자열로 받는다
2443
- * (`pgProvider`·결제수단과 같은 방식). 요청(`easyPayProviders`)은 값을 골라 보내는 자리라 enum 그대로다.
2444
- * 현재 값은 `NAVERPAY`·`KAKAOPAY`·`TOSSPAY`·`PAYCO`이고 화면은 모르는 값을 원문 그대로 보여 준다.
2365
+ * 상품 금액과 쿠폰 배분을 따로 나눠 빼면 합은 같지만 한 번의 몫이 −1원이 되는 경우가 있다
2366
+ * (예: 금액 3원·할인 2원·수량 5개에서 셋째 개). 음수를 0으로 자르면 합이 1원 넘친다. 실결제 금액을
2367
+ * 바로 나누면 몫이 항상 0 이상이고 전량 취소의 합이 실결제 금액과 정확히 같다.
2445
2368
  */
2446
- const easyPayProviderResponseSchema = z.string();
2447
- const pgEnvironmentSchema = z.enum(["TEST", "LIVE"]).describe("PG 결제 환경. `TEST`=PG 샌드박스(테스트 키, 실제로 돈이 오가지 않음), `LIVE`=실결제(운영 키)");
2448
- const pgVerificationResultSchema = z.enum(["SUCCESS", "FAILED"]).describe("연결 테스트 결과");
2449
- /** 비밀 값 입력 — 저장 후 다시 조회할 수 없다 */
2450
- const secretInput = () => z.string().trim().min(8).max(512);
2369
+ function proratedPaidRefundBase(item, settledQuantity, cancelQuantity) {
2370
+ return proratedRefundBase(Math.max(0, item.totalPrice - (item.couponDiscountAmount ?? 0)), item.quantity, settledQuantity, cancelQuantity);
2371
+ }
2372
+ //#endregion
2373
+ //#region src/schemas/coupon.ts
2451
2374
  /**
2452
- * 토스페이먼츠 결제위젯 키 접두사. 결제창은 API 개별 연동(`*_ck_`·`*_sk_`)만 쓴다. 위젯 키는 결제창 SDK가
2453
- * `NOT_SUPPORTED_WIDGET_KEY`로, 서버 API가 404 `NOT_FOUND_MERCHANT`로 거부하므로 저장 전에 막는다.
2454
- * 요청 스키마·api 어댑터·상점 플랫폼 폼이 같은 상수를 쓴다(원천). `g` 플래그가 없어 `.test()`가 상태를 갖지 않는다.
2375
+ * 쿠폰 관리 API 계약 (이슈 #47) — `/v1/coupons/**`, 스코프 `promotion:r|rw`.
2376
+ *
2377
+ * 금액 계산 규칙은 `promotion.ts`(`quotePricing`)가 원천이고, 여기에는 셀러가 쿠폰을 만들고 나눠 주는 표면만 있다.
2378
+ * 발급·사용이 시작된 쿠폰은 혜택·조건·대상·사용 시작일을 바꿀 수 없다(409 `COUPON_LOCKED`). 이름·설명·종료일 연장·
2379
+ * 한도 상향·중지·재개·종료만 된다. 규칙을 바꾸려면 새 쿠폰을 만든다.
2455
2380
  */
2456
- const TOSS_WIDGET_CLIENT_KEY_PATTERN = /^(test|live)_gck_/;
2457
- /** 토스페이먼츠 결제위젯 시크릿 키 접두사. {@link TOSS_WIDGET_CLIENT_KEY_PATTERN} 참고 */
2458
- const TOSS_WIDGET_SECRET_KEY_PATTERN = /^(test|live)_gsk_/;
2459
- /** 결제위젯 키를 거부할 때의 필드 메시지 */
2460
- const TOSS_WIDGET_KEY_MESSAGE = "결제위젯 키가 아니라 API 개별 연동 키를 입력해주십시오";
2461
- const tossPaymentsCredentialsInputSchema = z.object({
2462
- clientKey: z.string().trim().min(8).max(200).describe("클라이언트 키 (`test_ck_…`/`live_ck_…`). 공개 값 — 결제창에서 쓰인다"),
2463
- secretKey: secretInput().optional().describe("시크릿 키 (`test_sk_…`/`live_sk_…`). 비밀 값. 최초 연결 시 필수, 수정 시 생략하면 기존 값을 유지한다"),
2464
- webhookSecret: secretInput().nullable().optional().describe("웹훅 서명 검증용 보안 키. 비밀 값. 생략하면 기존 값 유지, null이면 삭제")
2465
- }).superRefine((value, ctx) => {
2466
- if (TOSS_WIDGET_CLIENT_KEY_PATTERN.test(value.clientKey)) ctx.addIssue({
2467
- code: "custom",
2468
- path: ["clientKey"],
2469
- message: TOSS_WIDGET_KEY_MESSAGE
2470
- });
2471
- if (value.secretKey !== void 0 && TOSS_WIDGET_SECRET_KEY_PATTERN.test(value.secretKey)) ctx.addIssue({
2381
+ /** 쿠폰 상태 — `ACTIVE`(사용 중)·`PAUSED`(중지, 재개 가능)·`ENDED`(종료, 되돌릴 수 없음) */
2382
+ const couponStatusSchema = z.enum([
2383
+ "ACTIVE",
2384
+ "PAUSED",
2385
+ "ENDED"
2386
+ ]).describe("쿠폰 상태. `ACTIVE`=사용 중, `PAUSED`=중지(재개 가능), `ENDED`=종료(되돌릴 수 없음)");
2387
+ /** 지금 만들 수 있는 발급 방식 — 다운로드·가입 자동 지급은 뒤 단계다 */
2388
+ const couponIssueMethodSchema = z.enum(["CODE", "MANUAL"]).describe("발급 방식. `CODE`=구매자가 주문서에 코드를 입력한다, `MANUAL`=셀러가 회원을 골라 지급한다");
2389
+ /** 코드 형식 — 영문·숫자·`-`·`_` 6~30자. 대소문자를 구분하지 않고 서버가 대문자로 저장한다 */
2390
+ const COUPON_CODE_PATTERN = /^[A-Za-z0-9_-]{6,30}$/;
2391
+ /** 코드 정규화 — 대문자, 앞뒤 공백 제거. 입력·저장·조회가 같은 함수를 쓴다 */
2392
+ function normalizeCouponCode(code) {
2393
+ return code.trim().toUpperCase();
2394
+ }
2395
+ const limitField = (description) => z.number().int().min(1).max(1e7).nullable().optional().describe(description);
2396
+ const descriptions$1 = {
2397
+ name: "구매자에게 보이는 쿠폰 이름. 1~60자",
2398
+ description: "구매자에게 보이는 설명. 300자까지",
2399
+ code: "쿠폰 코드 — 발급 방식이 `CODE`일 때 필수. 영문·숫자·`-`·`_` 6~30자, 대소문자 구분 없음(대문자로 저장). 상점 안에서 유일하다",
2400
+ startsAt: "사용 시작 시각(ISO 8601, 시간대 포함). 생략하면 바로 쓸 수 있다",
2401
+ endsAt: "사용 종료 시각(ISO 8601, 시간대 포함). 이 시각부터 못 쓴다. 생략하면 제한이 없다. 이미 지급한 쿠폰의 유효 기간은 지급 때 정해지므로 뒤에 종료일을 늦춰도 늘어나지 않는다",
2402
+ validDaysAfterIssue: "발급 후 유효 일수 — 셀러 지급(`MANUAL`) 쿠폰만. 쿠폰 종료일과 둘 중 먼저 끝나는 쪽이 적용된다",
2403
+ totalIssueLimit: "총 발급 수 한도. 생략하면 무제한",
2404
+ totalUseLimit: "총 사용 수 한도. 결제 중인 예약도 센다. 생략하면 무제한",
2405
+ perMemberIssueLimit: "1인 발급 수 한도(셀러 지급). 생략하면 1인 1장",
2406
+ perMemberUseLimit: "1인 사용 수 한도(코드 입력형). 생략하면 무제한. 비회원에게 여는 쿠폰은 둘 수 없다"
2407
+ };
2408
+ /** 조건 입력 — 생략한 항목은 기본값(`COUPON_CONDITIONS_DEFAULTS`)이다 */
2409
+ const couponConditionsInputSchema = z.strictObject({
2410
+ minOrderAmount: z.number().int().min(0).optional(),
2411
+ firstOrderOnly: z.boolean().optional(),
2412
+ memberOnly: z.boolean().optional(),
2413
+ stackWithInstantDiscount: z.boolean().optional()
2414
+ });
2415
+ /** 대상 입력 — 생략한 목록은 빈 목록이다(포함 목록이 모두 비면 전 상품) */
2416
+ const couponTargetInputSchema = z.strictObject({
2417
+ includeProductIds: z.array(z.string().min(1)).max(500).optional(),
2418
+ includeCategoryIds: z.array(z.string().min(1)).max(500).optional(),
2419
+ excludeProductIds: z.array(z.string().min(1)).max(500).optional(),
2420
+ excludeCategoryIds: z.array(z.string().min(1)).max(500).optional()
2421
+ });
2422
+ const couponTimeField = (description) => z.iso.datetime({ offset: true }).nullable().optional().describe(description);
2423
+ /** `POST /v1/coupons` */
2424
+ const createCouponRequestSchema = z.strictObject({
2425
+ name: z.string().trim().min(1).max(60).describe(descriptions$1.name),
2426
+ description: z.string().max(300).nullable().optional().describe(descriptions$1.description),
2427
+ kind: couponKindSchema,
2428
+ benefit: couponBenefitSchema,
2429
+ conditions: couponConditionsInputSchema.optional().describe("사용 조건. 생략한 항목은 기본값(최소 금액 0, 회원 전용, 즉시할인과 중복 가능)"),
2430
+ target: couponTargetInputSchema.optional().describe("대상 상품·카테고리. 생략하면 전 상품"),
2431
+ issueMethod: couponIssueMethodSchema,
2432
+ code: z.string().regex(COUPON_CODE_PATTERN).optional().describe(descriptions$1.code),
2433
+ startsAt: couponTimeField(descriptions$1.startsAt),
2434
+ endsAt: couponTimeField(descriptions$1.endsAt),
2435
+ validDaysAfterIssue: z.number().int().min(1).max(3650).nullable().optional().describe(descriptions$1.validDaysAfterIssue),
2436
+ totalIssueLimit: limitField(descriptions$1.totalIssueLimit),
2437
+ totalUseLimit: limitField(descriptions$1.totalUseLimit),
2438
+ perMemberIssueLimit: limitField(descriptions$1.perMemberIssueLimit),
2439
+ perMemberUseLimit: limitField(descriptions$1.perMemberUseLimit)
2440
+ }).superRefine((body, ctx) => {
2441
+ const issue = (path, message) => ctx.addIssue({
2472
2442
  code: "custom",
2473
- path: ["secretKey"],
2474
- message: TOSS_WIDGET_KEY_MESSAGE
2443
+ path: [path],
2444
+ message
2475
2445
  });
2446
+ if (body.issueMethod === "CODE" && !body.code) issue("code", "코드 입력형 쿠폰은 코드가 필요합니다");
2447
+ if (body.issueMethod !== "CODE" && body.code) issue("code", "코드는 코드 입력형 쿠폰에만 둡니다");
2448
+ if (body.issueMethod === "CODE" && body.validDaysAfterIssue != null) issue("validDaysAfterIssue", "발급 후 유효 일수는 셀러 지급 쿠폰에만 둡니다");
2449
+ if (body.issueMethod === "CODE" && body.perMemberIssueLimit != null) issue("perMemberIssueLimit", "1인 발급 수 한도는 셀러 지급 쿠폰에만 둡니다");
2450
+ if (body.issueMethod === "MANUAL" && body.perMemberUseLimit != null) issue("perMemberUseLimit", "셀러 지급 쿠폰은 발급 한 장이 한 번 쓰입니다. 1인 발급 수 한도를 쓰십시오");
2451
+ if (!(body.conditions?.memberOnly ?? COUPON_CONDITIONS_DEFAULTS.memberOnly) && (body.issueMethod !== "CODE" || body.perMemberUseLimit != null)) issue("conditions", "비회원에게 여는 쿠폰은 코드 입력형이면서 1인 사용 한도가 없어야 합니다");
2452
+ if (body.startsAt && body.endsAt && Date.parse(body.endsAt) <= Date.parse(body.startsAt)) issue("endsAt", "종료 시각은 시작 시각보다 뒤여야 합니다");
2476
2453
  });
2477
- /** 포트원 채널 목록 최소 개수. 결제수단을 받으려면 채널이 하나는 있어야 한다 */
2478
- const PORTONE_MIN_CHANNELS = 1;
2479
- /** 포트원 채널 목록 최대 개수 */
2480
- const PORTONE_MAX_CHANNELS = 20;
2481
- /** 포트원 채널 표시 이름 최대 길이 */
2482
- const PORTONE_CHANNEL_NAME_MAX_LENGTH = 50;
2483
- const portOneChannelSchema = z.object({
2484
- channelKey: z.string().trim().min(8).max(200).describe("포트원 콘솔 [연동 정보] > [채널 관리]의 채널 키. 공개 값"),
2485
- name: z.string().trim().max(50).nullable().optional().describe("채널 표시 이름 (예: 카드 전용)"),
2486
- payMethods: z.array(pgPaymentMethodSchema).min(1).max(pgPaymentMethodSchema.options.length).describe("이 채널로 받을 결제수단. 결제수단마다 먼저 나열된 채널이 쓰인다")
2454
+ /**
2455
+ * `PATCH /v1/coupons/{couponId}` — 준 항목만 바뀐다. 발급·사용이 시작된 쿠폰(`locked`)은 이름·설명·종료일 연장·
2456
+ * 한도 상향만 받고, 그 밖의 항목이 현재 값과 다르면 409 `COUPON_LOCKED`다. 종류·발급 방식은 바꿀 수 없다.
2457
+ */
2458
+ const updateCouponRequestSchema = z.strictObject({
2459
+ name: z.string().trim().min(1).max(60).optional().describe(descriptions$1.name),
2460
+ description: z.string().max(300).nullable().optional().describe(descriptions$1.description),
2461
+ benefit: couponBenefitSchema.optional(),
2462
+ conditions: couponConditionsInputSchema.optional(),
2463
+ target: couponTargetInputSchema.optional(),
2464
+ code: z.string().regex(COUPON_CODE_PATTERN).optional().describe(descriptions$1.code),
2465
+ startsAt: couponTimeField(descriptions$1.startsAt),
2466
+ endsAt: couponTimeField(descriptions$1.endsAt),
2467
+ validDaysAfterIssue: z.number().int().min(1).max(3650).nullable().optional(),
2468
+ totalIssueLimit: limitField(descriptions$1.totalIssueLimit),
2469
+ totalUseLimit: limitField(descriptions$1.totalUseLimit),
2470
+ perMemberIssueLimit: limitField(descriptions$1.perMemberIssueLimit),
2471
+ perMemberUseLimit: limitField(descriptions$1.perMemberUseLimit)
2487
2472
  });
2488
- const portOneCredentialsInputSchema = z.object({
2489
- storeId: z.string().trim().min(8).max(200).describe("포트원 Store ID (`store-…`). 공개 값"),
2490
- channels: z.array(portOneChannelSchema).min(1).max(20).describe("채널 목록. 포트원 V2는 PG·결제수단별로 채널을 따로 추가하므로 여러 개를 등록할 수 있다"),
2491
- apiSecret: secretInput().optional().describe("V2 API Secret. 비밀 값. 최초 연결 시 필수, 수정 시 생략하면 기존 값을 유지한다"),
2492
- webhookSecret: secretInput().nullable().optional().describe("웹훅 서명 검증용 시크릿. 비밀 값. 생략하면 기존 값 유지, null이면 삭제")
2473
+ /** 쿠폰 응답 */
2474
+ const couponSchema = z.object({
2475
+ couponId: z.string(),
2476
+ name: z.string(),
2477
+ description: z.string().nullable(),
2478
+ kind: couponKindSchema,
2479
+ benefit: couponBenefitSchema,
2480
+ conditions: couponConditionsSchema,
2481
+ target: couponTargetSchema,
2482
+ issueMethod: z.string().describe("발급 방식. 현재 값은 `CODE`·`MANUAL`이고 값이 추가될 수 있어 문자열로 받는다(다운로드·가입 자동 지급 예정)"),
2483
+ code: z.string().nullable().describe("쿠폰 코드(대문자). 코드 입력형이 아니면 null"),
2484
+ startsAt: z.string().nullable(),
2485
+ endsAt: z.string().nullable(),
2486
+ validDaysAfterIssue: z.number().int().nullable(),
2487
+ totalIssueLimit: z.number().int().nullable(),
2488
+ totalUseLimit: z.number().int().nullable(),
2489
+ perMemberIssueLimit: z.number().int().nullable(),
2490
+ perMemberUseLimit: z.number().int().nullable(),
2491
+ issuedCount: z.number().int().describe("발급 수 — 회수한 발급도 센다. 총 발급 한도(`totalIssueLimit`)가 이 값과 비교된다"),
2492
+ reservedCount: z.number().int().describe("결제 중인 예약 수 — 결제가 끝나거나 만료되면 풀린다"),
2493
+ usedCount: z.number().int().describe("사용 수 — 전량 취소로 복원되면 줄어든다"),
2494
+ status: couponStatusSchema,
2495
+ locked: z.boolean().describe("발급·사용이 시작돼 혜택·조건·대상·사용 시작일을 바꿀 수 없는가"),
2496
+ lockedAt: z.string().nullable(),
2497
+ createdAt: z.string(),
2498
+ updatedAt: z.string()
2493
2499
  });
2494
- const upsertEnvironment = () => pgEnvironmentSchema.describe("저장할 자격 증명 세트. PG마다 `TEST`(샌드박스)·`LIVE`(실결제) 세트를 따로 둔다. 어느 세트로 결제할지는 이 값이 아니라 샌드박스 스위치(`sandboxMode`)가 정한다");
2495
- const upsertTossPaymentsProviderRequestSchema = z.object({
2496
- environment: upsertEnvironment(),
2497
- credentials: tossPaymentsCredentialsInputSchema
2500
+ /** `GET /v1/coupons` 쿼리 */
2501
+ const couponListQuerySchema = z.object({
2502
+ status: couponStatusSchema.optional(),
2503
+ kind: couponKindSchema.optional(),
2504
+ issueMethod: couponIssueMethodSchema.optional(),
2505
+ q: z.string().optional().describe("이름·코드 검색(포함)"),
2506
+ ...pageQueryShape()
2507
+ });
2508
+ /** 발급 쿠폰 상태 — 만료는 저장 상태가 아니라 조회 시각으로 판정한 값이다 */
2509
+ const couponIssueStatusSchema = z.string().describe("발급 쿠폰 상태. 현재 값은 `AVAILABLE`(사용 가능)·`RESERVED`(결제 중)·`USED`(사용함)·`REVOKED`(회수)·`EXPIRED`(유효 기간 지남)이고 값이 추가될 수 있어 문자열로 받는다");
2510
+ const couponIssueSchema = z.object({
2511
+ issueId: z.string(),
2512
+ couponId: z.string(),
2513
+ customerId: z.string(),
2514
+ customerName: z.string().nullable().describe("구매자 이름. 탈퇴한 구매자는 null"),
2515
+ status: couponIssueStatusSchema,
2516
+ validFrom: z.string(),
2517
+ validUntil: z.string().nullable(),
2518
+ usedOrderId: z.string().nullable(),
2519
+ issuedAt: z.string(),
2520
+ revokedAt: z.string().nullable()
2521
+ });
2522
+ const couponIssueListQuerySchema = z.object({
2523
+ status: z.enum([
2524
+ "AVAILABLE",
2525
+ "RESERVED",
2526
+ "USED",
2527
+ "REVOKED"
2528
+ ]).optional(),
2529
+ ...pageQueryShape()
2530
+ });
2531
+ /** `POST /v1/coupons/{couponId}/issues` — 셀러 지급. 한 번에 1,000명까지 */
2532
+ 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명, 중복 불가)") });
2533
+ const issueCouponResultSchema = z.object({
2534
+ issued: z.array(z.object({
2535
+ customerId: z.string(),
2536
+ issueId: z.string()
2537
+ })),
2538
+ failed: z.array(z.object({
2539
+ customerId: z.string(),
2540
+ code: z.string().describe("실패 사유. `CUSTOMER_NOT_FOUND`(이 상점 구매자가 아님)·`COUPON_LIMIT_REACHED`(1인 발급 한도)·`COUPON_EXHAUSTED`(총 발급 한도)"),
2541
+ message: z.string()
2542
+ }))
2543
+ });
2544
+ const couponRedemptionSchema = z.object({
2545
+ redemptionId: z.string(),
2546
+ couponId: z.string(),
2547
+ issueId: z.string().nullable(),
2548
+ customerId: z.string().nullable().describe("구매자 id. 비회원 주문은 null"),
2549
+ orderId: z.string().nullable().describe("주문 id. 결제가 끝나기 전의 예약·해제는 null"),
2550
+ status: z.string().describe("사용 기록 상태. 현재 값은 `RESERVED`(결제 중)·`USED`(사용함)·`RELEASED`(결제 실패·만료로 해제)·`RESTORED`(전량 취소로 복원)이고 값이 추가될 수 있어 문자열로 받는다"),
2551
+ discountAmount: z.number().int(),
2552
+ reservedAt: z.string(),
2553
+ usedAt: z.string().nullable(),
2554
+ releasedAt: z.string().nullable(),
2555
+ restoredAt: z.string().nullable(),
2556
+ restoreSkippedReason: z.string().nullable().describe("전량 취소에도 복원하지 않은 사유 — `EXPIRED`(유효 기간 지남)·`ENDED`(종료된 쿠폰)")
2498
2557
  });
2499
- const upsertPortOneProviderRequestSchema = z.object({
2500
- environment: upsertEnvironment(),
2501
- credentials: portOneCredentialsInputSchema
2558
+ const couponRedemptionListQuerySchema = z.object({
2559
+ status: z.enum([
2560
+ "RESERVED",
2561
+ "USED",
2562
+ "RELEASED",
2563
+ "RESTORED"
2564
+ ]).optional(),
2565
+ ...pageQueryShape()
2502
2566
  });
2503
- const updatePaymentProviderRequestSchema = z.object({
2504
- enabled: z.boolean().optional().describe("사용 여부. 켜려면 활성 환경(샌드박스면 TEST, 아니면 LIVE) 자격 증명이 모두 저장돼 있어야 한다(`PAYMENT_PROVIDER_INCOMPLETE`)"),
2505
- sandboxMode: z.boolean().optional().describe("샌드박스 스위치. `true`면 새 결제에 TEST 세트를 쓴다(항상 허용). `false`(라이브 전환)는 라이브 개방 조건을 모두 충족해야 한다(`LIVE_GATE_NOT_MET`)"),
2506
- easyPayProviders: z.array(easyPayProviderSchema).max(easyPayProviderSchema.options.length).optional().describe("이 PG로 받는 간편결제사 — 통째로 바꾼다. PG 계약(포트원은 채널)에 추가한 간편결제만 켠다. 켠 간편결제사마다 스토어프론트 결제 옵션이 하나씩 생긴다(포트원은 간편결제를 받는 채널이 있어야 한다)")
2507
- }).refine((body) => body.enabled !== void 0 || body.sandboxMode !== void 0 || body.easyPayProviders !== void 0, { message: "enabled·sandboxMode·easyPayProviders 중 하나는 보내야 합니다" });
2508
- const verifyPaymentProviderRequestSchema = z.object({ environment: pgEnvironmentSchema.optional().describe("연결 테스트할 자격 증명 세트. 생략하면 활성 환경(샌드박스면 TEST, 아니면 LIVE)") });
2509
- const updatePaymentSettingsRequestSchema = z.object({
2510
- priority: z.array(pgProviderSchema).min(1).max(50).optional().describe("PG 우선순위(앞이 1순위) — 스토어프론트 결제 옵션 목록의 PG 순서다. 연결된 PG 수만큼 **모두 한 번씩** 나열한다(길이 상한 50은 안전장치다). 빠지거나 중복되거나 연결되지 않은 PG가 있으면 `INVALID_PRIORITY`"),
2511
- checkoutOrigins: z.array(z.string().max(200)).max(20).optional().describe("결제 도메인 — 통째로 바꾼다. 결제를 마친 구매자가 돌아올 스토어프론트 origin(`https://shop.example.com`, https·경로 없음). 결제 시작의 `returnUrl`은 이 목록 안이어야 한다. 비어 있으면 실결제를 시작할 수 없다(테스트 결제는 localhost 허용). 같은 도메인을 PG 가맹 정보에도 등록한다. 형식이 틀리면 `INVALID_CHECKOUT_ORIGIN`")
2512
- }).refine((body) => body.priority !== void 0 || body.checkoutOrigins !== void 0, { message: "priority 또는 checkoutOrigins 중 하나는 보내야 합니다" });
2513
- const maskedSecretSchema = z.object({
2514
- field: z.string().describe("비밀 필드 이름 (`secretKey`·`apiSecret`·`webhookSecret`)"),
2515
- configured: z.boolean().describe("값이 저장돼 있는가"),
2516
- masked: z.string().nullable().describe("앞 4자리만 남긴 마스킹 값 (예: `test****`). 저장되지 않았으면 null")
2567
+ const couponStatsSchema = z.object({
2568
+ couponId: z.string(),
2569
+ issuedCount: z.number().int(),
2570
+ revokedCount: z.number().int(),
2571
+ reservedCount: z.number().int(),
2572
+ usedCount: z.number().int(),
2573
+ restoredCount: z.number().int(),
2574
+ releasedCount: z.number().int(),
2575
+ discountTotal: z.number().int().describe("사용 중(`USED`)인 기록의 할인 합계 (원)"),
2576
+ orderPaymentTotal: z.number().int().describe("이 쿠폰을 쓴(`USED`) 주문의 결제 금액 합계 (원). 부분 취소·반품 환불은 빼지 않은 결제 시점 금액이다")
2517
2577
  });
2518
- const publicConfigSchema = z.union([z.object({ clientKey: z.string() }), z.object({
2519
- storeId: z.string(),
2520
- channels: z.array(portOneChannelSchema)
2521
- })]);
2522
- const verificationSchema = z.object({
2523
- result: pgVerificationResultSchema,
2524
- message: z.string().nullable(),
2525
- checkedAt: z.string()
2578
+ /** `GET /v1/customers/{customerId}/coupons` 항목 — 발급 쿠폰 + 쿠폰 이름·혜택 */
2579
+ const customerCouponSchema = couponIssueSchema.extend({
2580
+ couponName: z.string(),
2581
+ kind: couponKindSchema,
2582
+ benefit: couponBenefitSchema
2526
2583
  });
2527
- const paymentCredentialSetSchema = z.object({
2528
- environment: pgEnvironmentSchema,
2529
- publicConfig: publicConfigSchema.describe("공개 값. 토스페이먼츠는 `{clientKey}`, 포트원은 `{storeId, channels}`"),
2530
- secrets: z.array(maskedSecretSchema).describe("비밀 값의 저장 여부와 마스킹. 평문은 어떤 응답에도 나오지 않는다"),
2531
- lastVerification: verificationSchema.nullable().describe("이 세트의 마지막 연결 테스트 결과. 세트를 바꾸면 초기화된다"),
2532
- updatedAt: z.string()
2584
+ //#endregion
2585
+ //#region src/schemas/customer.ts
2586
+ /** 구매자 목록 정렬 */
2587
+ const customerSortSchema = z.enum([
2588
+ "joined_desc",
2589
+ "joined_asc",
2590
+ "total_paid_desc",
2591
+ "order_count_desc",
2592
+ "last_ordered_desc"
2593
+ ]).describe("정렬. `joined_desc`(기본, 최근 가입순)·`joined_asc`·`total_paid_desc`(구매 금액순)·`order_count_desc`(주문 수순)·`last_ordered_desc`(최근 주문순)");
2594
+ /** `GET /v1/customers` 쿼리 */
2595
+ const customerListQuerySchema = z.object({
2596
+ q: z.string().optional().describe("검색어 — 이름·이메일(포함). 숫자 4자리면 휴대폰 뒤 4자리로도 찾는다"),
2597
+ joinedFrom: z.string().optional().describe("가입일 시작(YYYY-MM-DD, KST, 포함)"),
2598
+ joinedTo: z.string().optional().describe("가입일 끝(YYYY-MM-DD, KST, 포함)"),
2599
+ marketing: z.enum(["agreed", "declined"]).optional().describe("마케팅 수신 동의 필터. 생략하면 전체"),
2600
+ sort: customerSortSchema.optional(),
2601
+ ...pageQueryShape()
2533
2602
  });
2534
- const liveGateConditionCodeSchema = z.enum([
2535
- "TEST_CONNECTION",
2536
- "SANDBOX_PAYMENT",
2537
- "SANDBOX_REFUND",
2538
- "LIVE_CONNECTION",
2539
- "PLATFORM_LIVE_ENABLED"
2540
- ]).describe("라이브 개방 조건. `TEST_CONNECTION`=TEST 세트 연결 테스트 성공, `SANDBOX_PAYMENT`=현재 TEST 세트로 샌드박스 결제 1건 승인, `SANDBOX_REFUND`=그 샌드박스 결제의 환불 1건 성공, `LIVE_CONNECTION`=LIVE 세트 연결 테스트 성공, `PLATFORM_LIVE_ENABLED`=플랫폼이 실결제를 열어 둠");
2541
- const liveGateConditionSchema = z.object({
2542
- code: liveGateConditionCodeSchema,
2543
- met: z.boolean().describe("충족 여부"),
2544
- message: z.string().describe("조건 설명 또는 미충족 사유")
2603
+ /** 구매 통계 — 구매 금액은 취소된 주문 상품을 빼고, 완료된 반품은 수량 비율로 뺀 금액이다 */
2604
+ const customerStatsSchema = z.object({
2605
+ orderCount: z.number().int().describe("주문 수(취소 포함)"),
2606
+ totalPaid: z.number().int().describe("구매 금액(원) — 취소·완료된 반품 제외"),
2607
+ lastOrderedAt: z.string().nullable().describe("최근 주문 시각")
2545
2608
  });
2546
- const liveGateSchema = z.object({
2547
- status: z.enum(["LOCKED", "OPEN"]).describe("`OPEN`=조건을 모두 충족해 샌드박스를 끌 수 있다, `LOCKED`=아직 끌 수 없다"),
2548
- conditions: z.array(liveGateConditionSchema),
2549
- openedAt: z.string().nullable().describe("마지막으로 라이브로 전환한 시각. 없으면 null")
2609
+ /** 목록 행 — 휴대폰은 가운데를 가린다(`010-****-1234`) */
2610
+ const customerSummarySchema = z.object({
2611
+ customerId: z.string(),
2612
+ name: z.string(),
2613
+ /** 소셜 가입에서 이메일을 받지 못했으면 null */
2614
+ email: z.string().nullable(),
2615
+ maskedPhone: z.string().nullable(),
2616
+ joinedAt: z.string(),
2617
+ point: z.number().int(),
2618
+ marketingAgreed: z.boolean()
2619
+ }).extend(customerStatsSchema.shape);
2620
+ const customerAddressSchema = z.object({
2621
+ addressId: z.string(),
2622
+ alias: z.string(),
2623
+ isDefault: z.boolean(),
2624
+ receiverName: z.string(),
2625
+ phone: z.string(),
2626
+ zipCode: z.string(),
2627
+ address1: z.string(),
2628
+ address2: z.string().nullable()
2550
2629
  });
2630
+ /** 동의 종류 — 이용약관·개인정보 수집이용은 가입 필수, 마케팅 수신은 선택 */
2631
+ const consentKindSchema = z.enum([
2632
+ "TERMS",
2633
+ "PRIVACY",
2634
+ "MARKETING"
2635
+ ]);
2636
+ const consentActionSchema = z.enum(["AGREED", "WITHDRAWN"]);
2551
2637
  /**
2552
- * 409 `LIVE_GATE_NOT_MET`·`LIVE_PAYMENTS_DISABLED`의 `ApiError.details` 형태.
2553
- * 응답 파싱용이라 관대하다 — 알 수 없는 키는 버리고, 형태가 다르면 safeParse 실패로 처리한다.
2638
+ * 동의가 일어난 경로. 값이 추가될 수 있어 모르는 값은 그대로 보여 준다.
2639
+ * `SIGNUP` 비밀번호 가입 · `SOCIAL_SIGNUP` 소셜 가입 · `DELEGATED` 셀러 자체 회원 연동 ·
2640
+ * `RECONSENT` 약관 개정 재동의 · `MEMBER_UPDATE` 구매자가 직접 변경 · `SELLER` 셀러가 처리
2554
2641
  */
2555
- const liveGateErrorDetailsSchema = z.object({ conditions: z.array(liveGateConditionSchema).describe("라이브 개방 조건별 충족 여부 (순서 고정)") });
2556
- const paymentProviderSchema = z.object({
2557
- provider: pgProviderSchema,
2558
- displayName: z.string().describe("표시 이름 (토스페이먼츠·포트원)"),
2559
- connected: z.boolean().describe("자격 증명이 저장돼 있는가"),
2560
- enabled: z.boolean().describe("결제에 사용 중인가"),
2561
- priority: z.number().int().nullable().describe("우선순위 (1이 가장 높다). 연결되지 않았으면 null"),
2562
- environment: pgEnvironmentSchema.nullable().describe("새 결제에 쓰는 환경(활성 환경). 샌드박스 스위치가 켜져 있으면 `TEST`, 꺼져 있으면 `LIVE`. 연결되지 않았으면 null"),
2563
- publicConfig: publicConfigSchema.nullable().describe("활성 환경 세트의 공개 값. 토스페이먼츠는 `{clientKey}`, 포트원은 `{storeId, channels}`. 활성 세트가 없으면 null"),
2564
- secrets: z.array(maskedSecretSchema).describe("활성 환경 세트의 비밀 값 저장 여부와 마스킹. 평문은 어떤 응답에도 나오지 않는다"),
2565
- lastVerification: verificationSchema.nullable().describe("활성 환경 세트의 마지막 연결 테스트 결과. 세트를 바꾸면 초기화된다"),
2642
+ const consentSourceSchema = z.enum([
2643
+ "SIGNUP",
2644
+ "SOCIAL_SIGNUP",
2645
+ "DELEGATED",
2646
+ "RECONSENT",
2647
+ "MEMBER_UPDATE",
2648
+ "SELLER"
2649
+ ]);
2650
+ /** 동의 이력 한 줄 — 추가만 되고 지워지지 않는다(구매자 탈퇴 시에만 함께 지워진다) */
2651
+ const customerConsentSchema = z.object({
2652
+ consentId: z.string(),
2653
+ kind: consentKindSchema,
2654
+ /** 동의한 약관 버전. 마케팅은 버전이 없어 null */
2655
+ version: z.string().nullable(),
2656
+ /** 동의 시점의 약관 문서 주소. 상점이 주소를 정하지 않았거나 마케팅이면 null */
2657
+ documentUrl: z.string().nullable(),
2658
+ action: consentActionSchema,
2659
+ source: consentSourceSchema,
2660
+ occurredAt: z.string()
2661
+ });
2662
+ /** 종류별 현재 동의 상태 */
2663
+ const customerConsentStateSchema = z.object({
2664
+ kind: consentKindSchema,
2665
+ agreed: z.boolean(),
2666
+ /** 동의한 약관 버전. 마케팅이거나 이력이 없으면 null */
2667
+ agreedVersion: z.string().nullable(),
2668
+ /** 상점의 현재 약관 버전. 마케팅은 null */
2669
+ currentVersion: z.string().nullable(),
2670
+ /** 상점이 정한 현재 약관 문서 주소. 정하지 않았거나 마케팅이면 null */
2671
+ currentDocumentUrl: z.string().nullable(),
2566
2672
  updatedAt: z.string().nullable(),
2567
- sandboxMode: z.boolean().describe("샌드박스 스위치. `true`면 새 결제에 TEST 세트를 쓴다(실제로 돈이 오가지 않는다). 연결되지 않았으면 true"),
2568
- credentialSets: z.object({
2569
- TEST: paymentCredentialSetSchema.nullable(),
2570
- LIVE: paymentCredentialSetSchema.nullable()
2571
- }).describe("환경별 자격 증명 세트. 등록되지 않은 세트는 null"),
2572
- liveGate: liveGateSchema.describe("라이브 개방 조건. 조건을 모두 충족해야 샌드박스를 끌 수 있다"),
2573
- easyPayProviders: z.array(easyPayProviderResponseSchema).describe("이 PG로 받는 간편결제사. 연결되지 않았으면 빈 목록. 현재 값은 `NAVERPAY`·`KAKAOPAY`·`TOSSPAY`·`PAYCO`이고 값이 추가될 수 있어 문자열로 받는다"),
2574
- retiredCredentialCount: z.number().int().describe("보관된(가맹점 교체·연결 해제로 새 결제에 더 쓰이지 않는) 자격 증명 수. 그 가맹점으로 받은 결제의 환불·대사에 계속 쓰인다"),
2575
- routingExclusion: z.string().nullable().optional().default(null).describe("지금 새 결제의 후보가 아닌 사유. 후보면 null. 판정 원천은 서버 라우팅이다(상점 플랫폼이 다시 계산하지 않는다). 후보가 아닌 PG에는 항상 사유 코드를 붙인다(연결 안 된 PG 포함) — 모든 PG는 `candidates`에 있거나 이 값이 null이 아니다. 현재 값: `NOT_CONNECTED`(연결 안 됨) · `DISABLED`(사용 중지) · `CREDENTIAL_MISSING`(활성 환경 자격 증명 없음) · `SANDBOX_IN_LIVE_STORE`(다른 PG가 라이브라 이 샌드박스 PG는 실구매자 결제에 쓰지 않음) · `PLATFORM_BLOCKED`(플랫폼이 실결제를 막음) · `MODE_MISMATCH`(라이브를 연 서버 모드와 현재 모드가 다름). 값이 추가될 수 있어 문자열로 받는다 — 모르는 값은 후보 아님으로 다룬다. 서버는 항상 싣는다. 이 필드가 없는 이전 서버 응답은 기본값(null)으로 읽는다")
2673
+ /** 약관이 개정돼 다시 받아야 하는가. 마케팅은 항상 false */
2674
+ reconsentRequired: z.boolean()
2576
2675
  });
2577
- /**
2578
- * `PaymentProvider.routingExclusion`의 현재 값 — 응답 스키마는 서버가 사유를 추가해도 파싱이 깨지지 않게 문자열로 받는다.
2579
- * 화면 문구 분기는 이 목록으로 하고, 모르는 값은 "후보 아님"으로 다룬다.
2580
- */
2581
- const PAYMENT_ROUTING_EXCLUSION_CODES = [
2582
- "NOT_CONNECTED",
2583
- "DISABLED",
2584
- "CREDENTIAL_MISSING",
2585
- "SANDBOX_IN_LIVE_STORE",
2586
- "PLATFORM_BLOCKED",
2587
- "MODE_MISMATCH"
2588
- ];
2589
- /**
2590
- * 결제수단 반복 실패 경고(#86 후속) — 같은 PG·결제수단(·간편결제사)이 가맹점 설정 문제(구매자가 고칠 수 없는 실패, 예: KG이니시스
2591
- * `V105 [NO MPI SET] 전용화면 미설정`)로 기간 안에 여러 번 실패했다. 구매자에게는 「이 결제수단은 지금 사용할 수 없습니다」가 보이고,
2592
- * PG 관리자에서 그 결제수단의 전용 화면·채널을 설정해야 다시 쓸 수 있다
2593
- */
2594
- const paymentFailureWarningSchema = z.object({
2595
- provider: z.string().describe("PG(`tosspayments`·`portone`). 값이 추가될 수 있어 문자열로 받는다"),
2596
- method: z.string().describe("결제수단(`CARD`·`BANK_TRANSFER`·`VIRTUAL_ACCOUNT`·`MOBILE`·`EASY_PAY`)"),
2597
- easyPayProvider: easyPayProviderResponseSchema.nullable().describe("간편결제사. 간편결제가 아니면 null"),
2598
- code: z.string().nullable().describe("PG 실패 코드(예: `V105`, `AllChannelsNotSatisfied`). PG 원문 문구로만 판정했으면 null"),
2599
- count: z.number().int().describe("기간 안의 실패 횟수"),
2600
- lastAt: z.string().describe("마지막 실패 시각"),
2601
- message: z.string().nullable().describe("마지막 실패의 PG 원문 사유. 구매자에게는 보이지 않는다")
2676
+ const customerConsentsSchema = z.object({
2677
+ terms: customerConsentStateSchema,
2678
+ privacy: customerConsentStateSchema,
2679
+ marketing: customerConsentStateSchema,
2680
+ /** 필수 동의 중 하나라도 현재 버전이 아니면 true */
2681
+ reconsentRequired: z.boolean()
2682
+ });
2683
+ /** `GET /v1/customers/{id}/consents` 쿼리 — 최근 순이다 */
2684
+ const customerConsentListQuerySchema = z.object({
2685
+ kind: consentKindSchema.optional().describe("동의 종류 필터. 생략하면 전체"),
2686
+ ...pageQueryShape()
2687
+ });
2688
+ /** 구매자 상세 — 휴대폰 전체를 보여 준다 */
2689
+ const customerDetailSchema = z.object({
2690
+ customerId: z.string(),
2691
+ name: z.string(),
2692
+ email: z.string().nullable(),
2693
+ phone: z.string().nullable(),
2694
+ joinedAt: z.string(),
2695
+ point: z.number().int(),
2696
+ marketingAgreed: z.boolean(),
2697
+ loginMethods: z.array(z.object({
2698
+ method: z.enum([
2699
+ "password",
2700
+ "kakao",
2701
+ "naver",
2702
+ "google"
2703
+ ]),
2704
+ /** 소셜 연결 시각. 비밀번호는 가입 시각 */
2705
+ linkedAt: z.string()
2706
+ })).describe("로그인 방법 — 이메일·비밀번호(`password`)와 연결된 소셜 계정"),
2707
+ consents: customerConsentsSchema.describe("약관·개인정보·마케팅 동의의 현재 상태. 이력은 `GET /v1/customers/{customerId}/consents`"),
2708
+ addresses: z.array(customerAddressSchema),
2709
+ stats: customerStatsSchema.extend({
2710
+ canceledOrReturnedCount: z.number().int().describe("취소된 주문 상품 + 완료된 반품 수"),
2711
+ reviewCount: z.number().int(),
2712
+ inquiryCount: z.number().int().describe("상품 문의 + 1:1 문의")
2713
+ })
2714
+ });
2715
+ /** 구매자의 주문 — 전체 기간 */
2716
+ const customerOrderSchema = z.object({
2717
+ orderId: z.string(),
2718
+ orderedAt: z.string(),
2719
+ totalAmount: z.number().int().describe("결제 금액"),
2720
+ testPayment: z.boolean(),
2721
+ items: z.array(z.object({
2722
+ orderItemId: z.string(),
2723
+ productName: z.string(),
2724
+ optionName: z.string().nullable(),
2725
+ quantity: z.number().int(),
2726
+ status: orderItemStatusSchema,
2727
+ claimStatus: z.enum([
2728
+ "CANCEL_REQUESTED",
2729
+ "RETURN_REQUESTED",
2730
+ "EXCHANGE_REQUESTED"
2731
+ ]).nullable()
2732
+ }))
2602
2733
  });
2603
- const paymentSettingsSchema = z.object({
2604
- checkoutOrigins: z.array(z.string()).describe("결제 도메인 — 결제를 마친 구매자가 돌아올 스토어프론트 origin 목록"),
2605
- providers: z.array(paymentProviderSchema).describe("지원하는 모든 PG. 연결된 것은 우선순위순, 연결되지 않은 것은 뒤에 온다"),
2606
- environment: pgEnvironmentSchema.nullable().optional().default(null).describe("지금 결제를 시작하면 쓰는 결제 환경. 사용 가능한 PG 중 하나라도 라이브면 `LIVE`(샌드박스 PG는 제외), 아니면 `TEST`. 사용 가능한 PG가 없으면 null. 서버는 항상 싣는다. 이 필드가 없는 이전 서버 응답은 기본값(null)으로 읽는다"),
2607
- candidates: z.array(z.string()).optional().default(() => []).describe("지금 결제를 시작하면 쓰는 PG, 우선순위순(`tosspayments`·`portone`). 비어 있으면 구매자가 결제할 수 없다. 빠진 PG의 사유는 `providers[].routingExclusion` — 후보가 아닌 PG에는 항상 사유 코드를 붙인다. 서버는 항상 싣는다. 이 필드가 없는 이전 서버 응답은 기본값(빈 배열)으로 읽는다"),
2608
- warnings: z.array(paymentFailureWarningSchema).optional().default(() => []).describe("최근 24시간 안에 같은 PG·결제수단·간편결제사가 가맹점 설정 문제로 3번 이상 실패한 조합, 마지막 실패가 최근인 순. 판정 규칙은 서버가 정하고 바뀔 수 있다. 서버는 항상 싣는다. 이 필드가 없는 이전 서버 응답은 기본값(빈 배열)으로 읽는다")
2734
+ /** `POST /v1/customers/{id}/withdraw` 본문 */
2735
+ const withdrawCustomerRequestSchema = z.object({
2736
+ reason: z.enum([
2737
+ "BUYER_REQUEST",
2738
+ "DORMANT",
2739
+ "ABUSE",
2740
+ "OTHER"
2741
+ ]).describe("처리 사유 — 구매자 요청·장기 미이용·부정 이용·기타. 감사 로그에 남는다"),
2742
+ memo: z.string().max(200).optional().describe("사유 보충(선택)")
2609
2743
  });
2610
- const paymentProviderVerificationSchema = z.object({
2611
- provider: pgProviderSchema,
2612
- environment: pgEnvironmentSchema.describe("연결 테스트한 자격 증명 세트"),
2613
- result: pgVerificationResultSchema,
2614
- message: z.string().nullable().describe("실패 사유 또는 참고 메시지"),
2615
- checkedAt: z.string()
2744
+ /** `WITHDRAWAL_BLOCKED`의 `error.details` — 탈퇴를 막는 항목 수(스토어프론트 탈퇴와 같다) */
2745
+ const customerWithdrawalBlockedDetailsSchema = z.object({
2746
+ inProgressOrderItems: z.number().int(),
2747
+ openClaims: z.number().int(),
2748
+ pendingPayments: z.number().int()
2616
2749
  });
2617
- const salesReportGroupBySchema = z.enum([
2618
- "DAY",
2619
- "PROVIDER",
2620
- "METHOD",
2621
- "PROVIDER_METHOD"
2622
- ]).describe("행 집계 기준. 기본 `DAY`");
2623
- const reportDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "날짜 형식은 YYYY-MM-DD 입니다");
2624
- const salesReportQuerySchema = z.object({
2625
- from: reportDate.describe("시작일 (YYYY-MM-DD, Asia/Seoul 기준, 포함)"),
2626
- to: reportDate.describe("종료일 (YYYY-MM-DD, 포함). from부터 최대 92일"),
2627
- provider: pgProviderSchema.optional().describe("PG 필터"),
2628
- method: pgPaymentMethodSchema.optional().describe("결제수단 필터 — **실제 결제수단**(PG가 보고한 수단, 모르면 요청 수단) 기준. 결제창에서 결제수단이 바뀔 수 있다(카드 결제창의 간편결제 탭 등). 관리 주문 API의 method는 호환용이며 간편결제를 CARD로 표시한다. 실제 수단은 approvedMethod를 본다"),
2629
- groupBy: salesReportGroupBySchema.optional(),
2630
- includeTest: z.enum(["true", "false"]).optional().describe("테스트 결제(샌드박스 결제) 포함 여부. 기본 `false` — 테스트 결제의 승인·환불은 집계에서 빠진다")
2750
+ //#endregion
2751
+ //#region src/schemas/login.ts
2752
+ /** 소셜 로그인 공급자 */
2753
+ const loginProviderSchema = z.enum([
2754
+ "kakao",
2755
+ "naver",
2756
+ "google"
2757
+ ]);
2758
+ /** 공급자 연결 상태 — 비밀 값은 앞 4자리만 */
2759
+ const loginProviderStatusSchema = z.object({
2760
+ provider: loginProviderSchema,
2761
+ connected: z.boolean(),
2762
+ /** 스토어프론트에서 사용 중인지. 연결 안 됐으면 false */
2763
+ enabled: z.boolean(),
2764
+ clientIdPrefix: z.string().nullable().describe("클라이언트 ID(카카오 REST API 키) 앞 4자리"),
2765
+ secretPrefix: z.string().nullable(),
2766
+ lastTestedAt: z.string().nullable(),
2767
+ lastTestOk: z.boolean().nullable(),
2768
+ linkedMemberCount: z.number().int().describe("이 공급자로 연결된 구매자 수"),
2769
+ /** 이 공급자로만 로그인하는 구매자 수 — 연결을 해제하면 로그인할 방법이 없어진다 */
2770
+ onlyMethodMemberCount: z.number().int(),
2771
+ callbackUrl: z.url().describe("공급자 개발자 콘솔에 등록할 리디렉트 URI")
2631
2772
  });
2632
- const salesTotalsSchema = z.object({
2633
- paymentCount: z.number().int().describe("승인 건수"),
2634
- paymentAmount: z.number().int().describe("승인 금액 합계"),
2635
- refundCount: z.number().int().describe("환불(취소) 건수. 부분 취소도 1건"),
2636
- refundAmount: z.number().int().describe("환불 금액 합계"),
2637
- netAmount: z.number().int().describe("순매출 = 승인 금액 - 환불 금액"),
2638
- pgFeeAmount: z.number().int().nullable().describe("PG 수수료 (참고용). PG에서 확정 값을 받지 못하면 null")
2773
+ /** `GET /v1/store/login-settings` */
2774
+ const loginSettingsSchema = z.object({
2775
+ passwordLoginEnabled: z.boolean(),
2776
+ /** 비밀번호로만 로그인하는 구매자 수 — 이메일·비밀번호를 끄면 이들은 로그인할 수 없다 */
2777
+ passwordOnlyMemberCount: z.number().int(),
2778
+ storefrontOrigins: z.array(z.string()),
2779
+ providers: z.array(loginProviderStatusSchema)
2639
2780
  });
2640
- const salesReportRowSchema = salesTotalsSchema.extend({
2641
- date: z.string().nullable().describe("일자 (YYYY-MM-DD, Asia/Seoul). groupBy=DAY일 때만 값"),
2642
- provider: pgProviderSchema.nullable().describe("PG. groupBy에 PROVIDER가 포함될 때만 값"),
2643
- method: pgPaymentMethodSchema.nullable().describe("실제 결제수단(PG가 보고한 수단, 모르면 요청 수단). groupBy에 METHOD가 포함될 때만 값. 관리 주문 API의 method는 호환용이며 간편결제를 CARD로 표시한다. 실제 수단은 approvedMethod를 본다")
2781
+ /** `PATCH /v1/store/login-settings` */
2782
+ const updateLoginSettingsRequestSchema = z.object({ passwordLoginEnabled: z.boolean() });
2783
+ /** `PUT /v1/store/auth-providers/{provider}` — 저장 전에 연결 테스트를 한다 */
2784
+ const saveLoginProviderRequestSchema = z.object({
2785
+ clientId: z.string().trim().min(1).max(200),
2786
+ clientSecret: z.string().trim().min(1).max(500)
2644
2787
  });
2645
- const salesReportSchema = z.object({
2646
- from: z.string(),
2647
- to: z.string(),
2648
- timezone: z.literal("Asia/Seoul"),
2649
- currency: z.literal("KRW"),
2650
- groupBy: salesReportGroupBySchema,
2651
- provider: pgProviderSchema.nullable().describe("적용된 PG 필터"),
2652
- method: pgPaymentMethodSchema.nullable().describe("적용된 결제수단 필터"),
2653
- includeTest: z.boolean().describe("테스트 결제(샌드박스 결제)를 집계에 포함했는가"),
2654
- summary: salesTotalsSchema.describe("기간 전체 합계"),
2655
- rows: z.array(salesReportRowSchema).describe("집계 행. 활동이 없는 날짜·PG는 생략된다")
2788
+ /** `PATCH /v1/store/auth-providers/{provider}` */
2789
+ const updateLoginProviderRequestSchema = z.object({ enabled: z.boolean() });
2790
+ /** 연결 테스트 결과 */
2791
+ const loginProviderTestSchema = z.object({
2792
+ ok: z.boolean(),
2793
+ /** 실패 이유 — `INVALID_CLIENT`(키가 틀림)·`PROVIDER_UNAVAILABLE`(공급자 응답 없음) */
2794
+ code: z.enum(["INVALID_CLIENT", "PROVIDER_UNAVAILABLE"]).nullable(),
2795
+ testedAt: z.string()
2656
2796
  });
2657
- /**
2658
- * 결제 내역 — 결제 건(세션) 단위 공개 상태. 주문 단위 상태와 다르다.
2659
- * - `APPROVED`: 승인됐고 환불이 없다
2660
- * - `PARTIALLY_REFUNDED`·`REFUNDED`: 승인 뒤 일부·전액을 환불했다
2661
- * - `PENDING`: 승인 결과를 아직 확정하지 못했다(대사 잡이 확정한다)
2662
- * - `FAILED`: 승인되지 않고 끝났다(거절·준비 장애·구매자 취소·만료)
2663
- */
2664
- const paymentRecordStatusSchema = z.enum([
2665
- "APPROVED",
2666
- "PARTIALLY_REFUNDED",
2667
- "REFUNDED",
2668
- "PENDING",
2669
- "FAILED"
2797
+ //#endregion
2798
+ //#region src/schemas/misc.ts
2799
+ const inquirySchema = z.object({
2800
+ inquiryId: z.string(),
2801
+ productId: z.string(),
2802
+ productName: z.string(),
2803
+ orderItemId: z.string().nullable(),
2804
+ category: z.enum([
2805
+ "PRODUCT",
2806
+ "DELIVERY",
2807
+ "RETURN",
2808
+ "EXCHANGE",
2809
+ "ETC"
2810
+ ]),
2811
+ content: z.string(),
2812
+ secret: z.boolean(),
2813
+ writerMaskedName: z.string(),
2814
+ answered: z.boolean(),
2815
+ answer: z.object({
2816
+ content: z.string(),
2817
+ answeredAt: z.string(),
2818
+ modifiedCount: z.number().int().max(1)
2819
+ }).nullable(),
2820
+ createdAt: z.string()
2821
+ });
2822
+ const customerInquiryCategorySchema = z.enum([
2823
+ "DELIVERY",
2824
+ "RETURN",
2825
+ "EXCHANGE",
2826
+ "PAYMENT",
2827
+ "ETC"
2670
2828
  ]);
2671
- const paymentAttemptStatusSchema = z.string().describe("시도 상태 — ready·approving·approved·unknown·declined·prepare_failed·canceled·failed·abandoned·voided. 새 값이 추가될 수 있으니 모르는 값은 '진행 중이 아닌 시도'로 다룬다");
2672
- /**
2673
- * 현금영수증 상태 — 값이 추가될 수 있어 응답은 문자열로 받는다. 화면 분기는 이 목록으로 하고 모르는 값은 처리 중으로 다룬다.
2674
- * - `REQUESTED`: 구매자가 신청했고 결제 승인 전이다
2675
- * - `PENDING`: 발급·취소를 기다린다(승인 직후, 또는 일시 오류로 자동 재시도 대기)
2676
- * - `ISSUED`: 발급됐다. 부분 환불이 있었으면 그만큼 취소(또는 재발급)된 금액이다
2677
- * - `CANCELED`: 전액 환불로 취소됐다
2678
- * - `FAILED`: 발급·취소가 실패했고 자동 재시도가 끝났다. `failCode`·`failMessage`를 보고 재발급한다
2679
- * - `SKIPPED`: 발급 대상이 아니다(승인된 결제수단이 계좌이체·가상계좌가 아님)
2680
- */
2681
- const CASH_RECEIPT_STATUS_VALUES = [
2682
- "REQUESTED",
2683
- "PENDING",
2684
- "ISSUED",
2685
- "CANCELED",
2686
- "FAILED",
2687
- "SKIPPED"
2688
- ];
2689
- const cashReceiptSchema = z.object({
2690
- cashReceiptId: z.string(),
2691
- status: z.string().describe("상태. `REQUESTED` 결제 전 · `PENDING` 발급 대기 · `ISSUED` 발급 · `CANCELED` 취소 · `FAILED` 실패 · `SKIPPED` 발급 대상 아님. 값이 추가될 수 있다"),
2692
- type: z.string().describe("용도. `INCOME_DEDUCTION` 소득공제 · `EXPENSE_PROOF` 지출증빙"),
2693
- identityType: z.string().describe("식별 번호 종류. `PHONE` 휴대폰 · `CARD` 현금영수증카드 · `BUSINESS` 사업자등록번호"),
2694
- identityNumberMasked: z.string().describe("가린 식별 번호 (예: 010****5678). 원문은 조회할 수 없다"),
2695
- amount: z.number().int().describe("발급해야 할 금액 = 결제 금액 − 환불 완료 금액. 승인 전에는 결제 금액"),
2696
- issuedAmount: z.number().int().describe("PG에 현재 발급돼 있는 금액. 취소됐거나 발급 전이면 0"),
2697
- provider: pgProviderSchema.nullable().describe("발급 PG — 결제를 승인한 PG. 승인 전에는 null"),
2698
- issueNumber: z.string().nullable().describe("현금영수증 승인 번호"),
2699
- receiptUrl: z.string().nullable().describe("현금영수증 확인 주소"),
2700
- issuedAt: z.string().nullable().describe("최근 발급 시각 (ISO 8601)"),
2701
- canceledAt: z.string().nullable().describe("최근 취소 시각 (ISO 8601)"),
2702
- failCode: z.string().nullable().describe("마지막 실패 코드. 성공하면 비운다"),
2703
- failMessage: z.string().nullable().describe("마지막 실패 사유"),
2704
- reissuable: z.boolean().describe("상점 플랫폼 재발급으로 다시 시도할 수 있는가 (`FAILED`·`PENDING`)"),
2705
- requestedAt: z.string().describe("신청 시각 (ISO 8601)"),
2706
- updatedAt: z.string().describe("마지막 변경 시각 (ISO 8601)")
2829
+ /** 셀러 뷰 1:1 문의 — 작성자 마스킹명 + 답변 수정횟수 포함 */
2830
+ const sellerCustomerInquirySchema = z.object({
2831
+ inquiryId: z.string(),
2832
+ memberMaskedName: z.string(),
2833
+ category: customerInquiryCategorySchema,
2834
+ title: z.string(),
2835
+ content: z.string(),
2836
+ orderItemId: z.string().nullable(),
2837
+ answered: z.boolean(),
2838
+ answer: z.object({
2839
+ content: z.string(),
2840
+ answeredAt: z.string(),
2841
+ modifiedCount: z.number().int().max(1)
2842
+ }).nullable(),
2843
+ createdAt: z.string()
2707
2844
  });
2708
- const paymentRecordSchema = z.object({
2709
- paymentId: z.string().describe("결제 건 id. 상세 조회 키"),
2710
- orderId: z.string().nullable().describe("승인돼 만들어진 주문. 승인 전에 끝난 결제는 null"),
2711
- orderName: z.string().describe("주문명 (예: 무선 이어폰 프로 외 1건)"),
2712
- customerName: z.string().describe("구매자명"),
2713
- status: paymentRecordStatusSchema,
2714
- amount: z.number().int().describe("결제 금액"),
2715
- refundedAmount: z.number().int().describe("환불 완료 금액 합계. 실패한 환불은 빼고 센다"),
2716
- provider: pgProviderSchema.nullable().describe("승인한 PG. 승인 전에 끝났으면 마지막 시도의 PG"),
2717
- method: pgPaymentMethodSchema.nullable().describe("실제 결제수단. 모르면 요청 수단"),
2718
- easyPayProvider: easyPayProviderResponseSchema.nullable().optional().default(null).describe("간편결제사. `method`가 `EASY_PAY`일 때만 값이 있고, PG가 알려 주지 않았으면 요청한 간편결제사다. 그 밖의 결제수단과 간편결제 이전 결제는 null. 서버는 항상 싣는다. 현재 값은 `NAVERPAY`·`KAKAOPAY`·`TOSSPAY`·`PAYCO`이고 값이 추가될 수 있어 문자열로 받는다"),
2719
- environment: pgEnvironmentSchema.nullable().describe("결제 환경"),
2720
- testPayment: z.boolean().describe("샌드박스 결제인가"),
2721
- pgTransactionId: z.string().nullable().describe("PG 거래 id (토스 paymentKey, 포트원 txId)"),
2722
- attemptId: z.string().nullable().describe("PG에 보낸 주문번호 = 시도 id"),
2723
- attemptCount: z.number().int().describe("시도 횟수"),
2724
- failCode: z.string().nullable().describe("실패 코드. 실패로 끝난 결제만"),
2725
- failMessage: z.string().nullable().describe("실패 사유"),
2726
- paidAt: z.string().nullable().describe("승인 시각 (ISO 8601)"),
2727
- createdAt: z.string().describe("결제 시작 시각 (ISO 8601)"),
2728
- cashReceiptStatus: z.string().nullable().optional().default(null).describe("현금영수증 상태(`cashReceiptSchema.status`와 같은 값). 신청이 없으면 null. 서버는 항상 싣는다")
2845
+ const answeredQuery = z.enum(["true", "false"]).optional().describe("답변 여부 필터. `true`면 답변된 문의만, `false`면 미답변만, 생략하면 전부");
2846
+ /** `GET /v1/inquiries` 쿼리(상품 문의) — OpenAPI 문서용 형태(값은 쿼리 문자열). 최신순이다 */
2847
+ const inquiryListQuerySchema = z.object({
2848
+ answered: answeredQuery,
2849
+ productId: z.string().optional().describe("상품 id 필터"),
2850
+ from: z.string().optional().describe("작성 시각 조회 시작 (ISO 8601, 포함)"),
2851
+ ...pageQueryShape()
2852
+ });
2853
+ /** `GET /v1/customer-inquiries` 쿼리(1:1 문의) — OpenAPI 문서용 형태(값은 쿼리 문자열). 최신순이다 */
2854
+ const customerInquiryListQuerySchema = z.object({
2855
+ answered: answeredQuery,
2856
+ category: customerInquiryCategorySchema.optional().describe("문의 유형 필터"),
2857
+ from: z.string().optional().describe("작성 시각 조회 시작 (ISO 8601, 포함)"),
2858
+ ...pageQueryShape()
2859
+ });
2860
+ const settlementStatusSchema = z.enum([
2861
+ "SCHEDULED",
2862
+ "PAID",
2863
+ "HELD"
2864
+ ]);
2865
+ const settlementSummarySchema = z.object({
2866
+ settlementId: z.string(),
2867
+ settlementDate: z.string(),
2868
+ payoutDate: z.string().nullable(),
2869
+ status: settlementStatusSchema,
2870
+ totalPaymentAmount: z.number().int(),
2871
+ totalCommission: z.number().int(),
2872
+ totalDeliveryFee: z.number().int(),
2873
+ adjustmentAmount: z.number().int(),
2874
+ settlementAmount: z.number().int(),
2875
+ itemCount: z.number().int()
2729
2876
  });
2730
- const paymentRecordPageSchema = pageSchema(paymentRecordSchema);
2731
- const paymentAttemptRecordSchema = z.object({
2732
- attemptId: z.string(),
2733
- sequence: z.number().int().describe("결제 건 안에서의 순번 (1부터)"),
2734
- provider: pgProviderSchema,
2735
- status: paymentAttemptStatusSchema,
2736
- method: pgPaymentMethodSchema.describe("주문서에서 고른 결제수단"),
2737
- easyPayProvider: easyPayProviderResponseSchema.nullable().optional().default(null).describe("주문서에서 고른 간편결제사. 간편결제가 아니면 null. 서버는 항상 싣는다(값이 추가될 수 있어 문자열)"),
2738
- approvedMethod: pgPaymentMethodSchema.nullable().describe("PG가 보고한 실제 결제수단"),
2739
- approvedEasyPayProvider: easyPayProviderResponseSchema.nullable().optional().default(null).describe("PG가 보고한 실제 간편결제사. 모르거나 간편결제가 아니면 null. 서버는 항상 싣는다(값이 추가될 수 있어 문자열)"),
2740
- amount: z.number().int(),
2741
- environment: pgEnvironmentSchema,
2742
- pgTransactionId: z.string().nullable(),
2743
- failCode: z.string().nullable(),
2744
- failMessage: z.string().nullable(),
2745
- createdAt: z.string(),
2746
- updatedAt: z.string()
2877
+ const settlementDetailSchema = z.object({
2878
+ orderItemId: z.string(),
2879
+ productName: z.string(),
2880
+ settlementType: z.enum([
2881
+ "SALE",
2882
+ "REFUND_DEDUCTION",
2883
+ "ADJUSTMENT"
2884
+ ]),
2885
+ paymentAmount: z.number().int(),
2886
+ salesCommission: z.number().int(),
2887
+ paymentCommission: z.number().int(),
2888
+ commissionRate: z.number(),
2889
+ deliveryFee: z.number().int(),
2890
+ settlementAmount: z.number().int(),
2891
+ purchaseDecidedAt: z.string().nullable()
2747
2892
  });
2748
- const paymentRefundRecordSchema = z.object({
2749
- refundId: z.string(),
2750
- amount: z.number().int(),
2751
- reason: z.string().describe("환불 사유 코드"),
2752
- kind: z.string().describe("환불 원인 — SELLER_CANCEL·CLAIM·COMPENSATION·DUPLICATE. 새 값이 추가될 수 있다"),
2753
- status: z.string().describe("SUCCEEDED·FAILED"),
2754
- failMessage: z.string().nullable(),
2755
- requestedAt: z.string(),
2756
- completedAt: z.string().nullable()
2893
+ /** `GET /v1/settlements/{settlementId}` 응답 — 정산 요약 + 상세 내역(`size`건까지) */
2894
+ const settlementWithDetailsSchema = settlementSummarySchema.extend({ details: z.array(settlementDetailSchema).optional() });
2895
+ /** `GET /v1/settlements` 쿼리 — OpenAPI 문서용 형태(값은 쿼리 문자열). 정산일 최신순이다 */
2896
+ const settlementListQuerySchema = z.object({
2897
+ from: z.string().describe("정산일 조회 시작 (ISO 8601, 포함). 필수"),
2898
+ to: z.string().optional().describe("정산일 조회 끝 (ISO 8601, 포함). 생략하면 from부터 31일. 기간은 최대 31일"),
2899
+ status: settlementStatusSchema.optional().describe("정산 상태 필터"),
2900
+ ...pageQueryShape()
2757
2901
  });
2758
- const paymentRecordDetailSchema = paymentRecordSchema.extend({
2759
- refundableAmount: z.number().int().describe("환불 가능 잔액 = 승인 금액 - 환불 완료 금액"),
2760
- merchantId: z.string().nullable().describe("가맹점 식별자 지문 (토스 clientKey, 포트원 Store ID). 비밀 값은 없다"),
2761
- attempts: z.array(paymentAttemptRecordSchema).describe("시도 이력. 순번 오름차순"),
2762
- refunds: z.array(paymentRefundRecordSchema).describe("환불 이력. 실패한 요청도 남는다"),
2763
- cashReceipt: cashReceiptSchema.nullable().optional().default(null).describe("현금영수증. 구매자가 신청하지 않았으면 null. 서버는 항상 싣는다")
2902
+ /** `GET /v1/settlements/{settlementId}` 쿼리 — `size`는 돌려줄 상세 내역 건수다(`page`는 무시) */
2903
+ const settlementDetailQuerySchema = z.object(pageQueryShape({
2904
+ defaultSize: 100,
2905
+ maxSize: 300
2906
+ }));
2907
+ const eventTypeSchema = z.enum([
2908
+ "ORDER.PAID",
2909
+ "ORDER.CONFIRMED",
2910
+ "ORDER.DISPATCHED",
2911
+ "ORDER.DELIVERED",
2912
+ "ORDER.PURCHASE_DECIDED",
2913
+ "ORDER.ADDRESS_CHANGE_REQUESTED",
2914
+ "ORDER.CANCELED",
2915
+ "ORDER.FULFILLED",
2916
+ "CLAIM.CANCEL_REQUESTED",
2917
+ "CLAIM.RETURN_REQUESTED",
2918
+ "CLAIM.EXCHANGE_REQUESTED",
2919
+ "CLAIM.COMPLETED",
2920
+ "INQUIRY.CREATED",
2921
+ "REVIEW.CREATED",
2922
+ "PRODUCT.OUT_OF_STOCK"
2923
+ ]).describe("웹훅 이벤트 타입. `ORDER.ADDRESS_CHANGE_REQUESTED`는 현재 발송하지 않음(호환을 위해 값만 남았고, 구독해도 이벤트가 오지 않는다)");
2924
+ const webhookStatusSchema = z.enum(["ACTIVE", "SUSPENDED"]).describe("구독 상태. `ACTIVE`=발송 대상, `SUSPENDED`=24시간 이상 연속 전송 실패로 발송이 정지된 상태. 정지 중 발생한 이벤트는 적재하지 않는다. 재개(`POST /webhooks/{id}/resume`)하거나 구독을 수정하면 ACTIVE로 돌아간다");
2925
+ const webhookSchema = z.object({
2926
+ webhookId: z.string(),
2927
+ url: z.url(),
2928
+ eventTypes: z.array(eventTypeSchema),
2929
+ status: webhookStatusSchema,
2930
+ secretPrefix: z.string().describe("서명 시크릿 앞부분(식별용, 서명 검증에 사용할 수 없음). 예: `whsec_1a2b`"),
2931
+ suspendedAt: z.string().nullable().describe("발송이 정지된 일시 (ISO 8601). `ACTIVE`면 null"),
2932
+ createdAt: z.string()
2764
2933
  });
2765
- /** `GET /v1/payments` 쿼리 — OpenAPI 문서용 형태(값은 쿼리 문자열) */
2766
- const paymentListQuerySchema = z.object({
2767
- from: z.string().optional().describe("조회 시작 시각 (ISO 8601, 포함). 생략하면 최근 30일"),
2768
- to: z.string().optional().describe("조회 끝 시각 (ISO 8601, 포함). 기간은 최대 92일"),
2769
- status: z.string().optional().describe("결제 상태 필터 (PaymentRecordStatus)"),
2770
- provider: z.string().optional().describe("PG 필터"),
2771
- method: z.string().optional().describe("실제 결제수단 필터"),
2772
- keyword: z.string().optional().describe("결제 id·주문번호·PG 거래 id·시도 id(일치), 구매자명(포함)"),
2773
- includeTest: z.string().optional().describe("테스트 결제 포함 여부 (`true`·`false`. 기본 false — 실결제만)"),
2774
- page: z.string().optional().describe("페이지 번호 (1부터)"),
2775
- size: z.string().optional().describe("페이지 크기 (기본 20, 최대 100)")
2934
+ /** 등록 응답 — 서명 시크릿 평문은 이 응답에서만 노출된다 */
2935
+ const webhookCreatedSchema = webhookSchema.extend({ secret: z.string().describe("서명 시크릿 평문. 등록 응답에서만 노출되며 다시 조회할 수 없다") });
2936
+ /** 시크릿 교체 응답 */
2937
+ const webhookSecretRotatedSchema = z.object({
2938
+ secret: z.string().describe("새 서명 시크릿 평문. 이 응답에서만 노출된다"),
2939
+ oldSecretExpiresAt: z.string().describe("기존 시크릿의 병행 유효 종료 일시 (ISO 8601). 이때까지는 기존 시크릿 서명도 함께 붙는다")
2776
2940
  });
2777
- //#endregion
2778
- //#region src/schemas/upload.ts
2779
- /**
2780
- * 업로드 용도. 허용 확장자와 크기 상한이 용도별로 다르다.
2781
- *
2782
- * - `product-image`: 상품 목록·상세의 상품 이미지. 목록 썸네일로 쓰이므로 정지 이미지만 받는다.
2783
- * - `description`: 상세설명 본문에 삽입하는 미디어. 동영상과 svg도 받는다.
2784
- */
2785
- const uploadPurposeSchema = z.enum(["product-image", "description"]);
2786
- const MB = 1048576;
2787
- /**
2788
- * 용도별 업로드 제한 — 서버(`POST /v1/uploads`)와 클라이언트가 공유하는 단일 원천이다.
2789
- *
2790
- * 크기는 **요청이 스스로 신고한 값**으로만 검사한다. 바이트는 api를 거치지 않고 CDN으로
2791
- * 직접 PUT되므로 이 검사는 셀러가 올리기 전에 알게 하는 장치이고, 실제 상한은 CDN이 강제한다.
2792
- */
2793
- const UPLOAD_PURPOSE_LIMITS = {
2794
- "product-image": {
2795
- extensions: [
2796
- "jpg",
2797
- "jpeg",
2798
- "png",
2799
- "webp",
2800
- "avif",
2801
- "gif"
2802
- ],
2803
- maxBytes: 10 * MB
2804
- },
2805
- description: {
2806
- extensions: [
2807
- "jpg",
2808
- "jpeg",
2809
- "png",
2810
- "gif",
2811
- "webp",
2812
- "avif",
2813
- "svg",
2814
- "mp4",
2815
- "webm",
2816
- "mov"
2817
- ],
2818
- maxBytes: 50 * MB
2819
- }
2820
- };
2821
- /** 파일명의 소문자 확장자. 확장자가 없으면 `""` */
2822
- function uploadExtensionOf(filename) {
2823
- const dot = filename.lastIndexOf(".");
2824
- if (dot < 0 || dot === filename.length - 1) return "";
2825
- return filename.slice(dot + 1).toLowerCase();
2826
- }
2941
+ const webhookDeliveryStatusSchema = z.enum([
2942
+ "PENDING",
2943
+ "PENDING_RETRY",
2944
+ "SUCCESS",
2945
+ "FAILED"
2946
+ ]).describe("전송 상태. `PENDING`=첫 시도 전, `PENDING_RETRY`=실패 후 재시도 대기, `SUCCESS`=2xx 응답 수신, `FAILED`=재시도를 모두 소진했거나 구독 정지로 종료");
2947
+ const webhookDeliveryErrorCodeSchema = z.enum([
2948
+ "TIMEOUT",
2949
+ "CONNECTION_FAILED",
2950
+ "BLOCKED_ADDRESS",
2951
+ "NON_2XX",
2952
+ "WEBHOOK_SUSPENDED"
2953
+ ]).describe("전송 실패 사유. `TIMEOUT`=10초 안에 응답 없음, `CONNECTION_FAILED`=연결 실패(DNS·TLS 포함), `BLOCKED_ADDRESS`=사설·루프백 등 전송이 허용되지 않는 주소, `NON_2XX`=2xx가 아닌 응답(리다이렉트 포함), `WEBHOOK_SUSPENDED`=구독 정지로 대기 중인 전송이 종료됨");
2827
2954
  /**
2828
- * 용도 제한 위반 여부. 통과하면 `null`.
2829
- * 서버가 던지는 오류와 상점 플랫폼이 먼저 띄우는 안내가 갈리지 않도록 한 함수로 판정한다.
2955
+ * 실패 원인 분류의 **현재** 값. 응답 스키마(`webhookFailureReasonSchema`)는 문자열로 열려 있다 —
2956
+ * 서버가 값을 더해도 이전 SDK가 목록 파싱에 실패하지 않게 한다(택배 추적 `stage`와 같은 이유).
2957
+ * 모르는 값은 `errorCode`로 판단한다.
2830
2958
  */
2831
- function uploadRejection(purpose, filename, size) {
2832
- const limit = UPLOAD_PURPOSE_LIMITS[purpose];
2833
- if (!limit.extensions.includes(uploadExtensionOf(filename))) return {
2834
- status: 400,
2835
- code: "UNSUPPORTED_FILE_TYPE",
2836
- message: `지원하지 않는 파일 형식입니다. ${limit.extensions.join(", ")}만 올릴 수 있습니다`
2837
- };
2838
- if (size !== void 0 && size > limit.maxBytes) return {
2839
- status: 413,
2840
- code: "FILE_TOO_LARGE",
2841
- message: `파일이 너무 큽니다. ${Math.floor(limit.maxBytes / MB)}MB까지 올릴 수 있습니다`
2842
- };
2843
- return null;
2844
- }
2959
+ const WEBHOOK_FAILURE_REASONS = [
2960
+ "DNS",
2961
+ "TLS",
2962
+ "TIMEOUT",
2963
+ "CONNECTION",
2964
+ "HTTP_4XX",
2965
+ "HTTP_5XX",
2966
+ "HTTP_OTHER",
2967
+ "SSRF_BLOCKED",
2968
+ "SUSPENDED"
2969
+ ];
2970
+ const webhookFailureReasonSchema = z.string().describe("실패 원인 분류(진단용). 현재 값은 `DNS`=호스트 이름을 주소로 바꾸지 못함, `TLS`=인증서·핸드셰이크 실패, `TIMEOUT`=10초 안에 응답 없음, `CONNECTION`=연결 거부·끊김, `HTTP_4XX`·`HTTP_5XX`=그 대역의 응답, `HTTP_OTHER`=2xx·4xx·5xx가 아닌 응답(리다이렉트 등), `SSRF_BLOCKED`=사설·루프백 등 전송이 허용되지 않는 주소, `SUSPENDED`=구독 정지로 대기 중인 전송이 종료됨. `errorCode`보다 잘게 나눈 값이며 둘은 함께 쓸 수 있다. 값이 추가될 수 있으므로 모르는 값은 `errorCode`로 판단한다");
2845
2971
  /**
2846
- * 미디어 업로드 자리 발급 — `POST /v1/uploads`.
2847
- *
2848
- * 파일 바이트는 api를 거치지 않는다. 응답의 `uploadUrl`로 클라이언트가 직접 PUT하고,
2849
- * 끝나면 `url`이 공개 주소가 된다. 서버가 객체 키를 정하므로 요청은 파일명만 보낸다
2850
- * (확장자만 쓰이고 이름 자체는 저장되지 않는다).
2972
+ * 대상 리소스 종류의 **현재** 값. 응답 스키마는 문자열로 열려 있다(새 이벤트가 새 리소스를 가리킬 수 있다).
2851
2973
  */
2852
- const uploadRequestSchema = z.object({
2853
- filename: z.string().min(1).max(255),
2854
- purpose: uploadPurposeSchema.default("description").describe("업로드 용도. 허용 확장자와 크기 상한이 용도별로 다르다"),
2855
- size: z.number().int().positive().optional().describe("파일 바이트 크기. 주면 용도별 상한을 발급 전에 검사한다")
2974
+ const WEBHOOK_RESOURCE_TYPES = [
2975
+ "ORDER",
2976
+ "CLAIM",
2977
+ "PRODUCT",
2978
+ "REVIEW",
2979
+ "INQUIRY"
2980
+ ];
2981
+ const webhookResourceTypeSchema = z.string().describe("이벤트가 가리키는 리소스 종류. 현재 값은 `ORDER`·`CLAIM`·`PRODUCT`·`REVIEW`·`INQUIRY`이고 값이 추가될 수 있다");
2982
+ /** 이벤트가 가리키는 대상 리소스 — 전송 기록을 주문·상품과 잇는 키 */
2983
+ const webhookDeliveryResourceSchema = z.object({
2984
+ type: webhookResourceTypeSchema,
2985
+ id: z.string().describe("리소스 ID. 전송 기록 조회의 `resourceId`, API 로그 조회의 `q`로 그대로 넣을 수 있다")
2986
+ });
2987
+ /** 발송 요청 헤더 한 줄 (값 마스킹) */
2988
+ const webhookRequestHeaderSchema = z.object({
2989
+ name: z.string().describe("헤더 이름"),
2990
+ value: z.string().nullable().describe("헤더 값. 서명(`Sayren-Signature`)처럼 비밀이 섞이는 헤더는 이름만 주고 값은 null이다")
2991
+ });
2992
+ /** 웹훅 전송 기록 한 건 (전송 기록 조회 항목) */
2993
+ const webhookDeliverySchema = z.object({
2994
+ deliveryId: z.string().describe("전송 기록 ID. 수신 요청의 `Sayren-Delivery-Id` 헤더 값"),
2995
+ eventId: z.string().describe("이벤트 ID. 재시도·재전송해도 같은 이벤트면 같은 값이다. 수신 측 중복 제거 키로 쓴다"),
2996
+ eventType: eventTypeSchema,
2997
+ url: z.string().describe("전송 시점의 대상 URL"),
2998
+ status: webhookDeliveryStatusSchema,
2999
+ responseStatusCode: z.number().int().nullable().describe("마지막 시도의 HTTP 응답 코드. 미시도·연결 실패·타임아웃·차단이면 null"),
3000
+ responseTimeMs: z.number().int().nullable().describe("마지막 시도의 응답 시간(ms). 미시도·연결 실패면 null"),
3001
+ responseBodyExcerpt: z.string().nullable().describe("마지막 시도의 응답 본문 앞부분(최대 4,096자). 응답이 없으면 null. 가린 뒤에 자른다. 키 이름에 `authorization`·`token`·`secret`·`password`·`key`·`signature`·`credential`·`cookie`가 **들어 있으면**(부분 일치, 대소문자 무시. 예: `apiKey`·`access_token`·`keyword`) 그 값을 `***`로 가린다. 값이 객체·배열이면 통째로 가린다. 평문 `Authorization: …` 같은 헤더 줄, `Bearer <토큰>`, 쿼리 형식 `access_token=…`, 끝에서 잘려 닫히지 않은 값도 가린다"),
3002
+ errorCode: webhookDeliveryErrorCodeSchema.nullable().describe("마지막 시도의 실패 사유. 성공했거나 미시도면 null"),
3003
+ failureReason: webhookFailureReasonSchema.nullable().describe("마지막 시도의 실패 원인 분류. 성공했거나 미시도면 null. `errorCode`를 더 잘게 나눈 진단 값이다"),
3004
+ resource: webhookDeliveryResourceSchema.nullable().describe("이벤트가 가리키는 대상 리소스. 리소스를 특정할 수 없는 이벤트면 null. 이 값으로 전송 기록(`resourceId`)과 API 로그(`q`)를 오갈 수 있다"),
3005
+ attemptCount: z.number().int().min(0).describe("지금까지 전송을 시작한 횟수. 최초 시도 1회 + 재시도 최대 4회. 전송 도중 장애로 결과를 기록하지 못한 시도도 센다"),
3006
+ createdAt: z.string().describe("전송 기록이 적재된 일시 (ISO 8601)"),
3007
+ attemptedAt: z.string().nullable().describe("마지막 시도 일시 (ISO 8601). 미시도면 null"),
3008
+ nextAttemptAt: z.string().nullable().describe("다음 시도 예정 일시 (ISO 8601). `PENDING`·`PENDING_RETRY`일 때만 값이 있다"),
3009
+ redeliveryOf: z.string().nullable().describe("수동 재전송으로 생긴 기록이면 원본 전송 기록 ID. 아니면 null"),
3010
+ redeliveredAs: z.string().nullable().describe("이 기록을 원본으로 수동 재전송해 생긴 기록의 전송 기록 ID. 아직 재전송하지 않았으면 null. 재전송은 원본당 1회다")
3011
+ });
3012
+ /** 발송 본문 최대 노출 길이(바이트). 넘으면 잘라 주고 `payloadTruncated`가 true다 */
3013
+ const WEBHOOK_PAYLOAD_EXCERPT_MAX_BYTES = 16384;
3014
+ /** 웹훅 전송 기록 상세 — `GET /webhooks/{id}/deliveries/{deliveryId}`. 목록 항목 + 요청 본문·헤더 */
3015
+ const webhookDeliveryDetailSchema = webhookDeliverySchema.extend({
3016
+ payload: z.string().describe(`수신 서버로 보낸 요청 본문 그대로(JSON 문자열). 서명은 이 문자열을 대상으로 계산한다. 최대 ${WEBHOOK_PAYLOAD_EXCERPT_MAX_BYTES / 1024}KB까지 주고 넘으면 잘라 준다`),
3017
+ payloadTruncated: z.boolean().describe("본문이 길어서 잘렸으면 true. true면 `payload`는 유효한 JSON이 아니다"),
3018
+ payloadMasked: z.boolean().describe("민감 키 마스킹(`responseBodyExcerpt`와 같은 키 이름 규칙)이 본문을 바꿨으면 true. true면 `payload`는 보낸 본문과 달라 서명을 맞춰 볼 수 없다. 이벤트 본문에는 보통 민감 키가 없어 false다"),
3019
+ requestHeaders: z.array(webhookRequestHeaderSchema).describe("발송 요청에 실은 헤더. 값은 전송 시점 그대로이나 서명 헤더는 이름만 주고 값은 null이다. `Content-Length`는 실제로 보낸 본문(마스킹·자르기 전)의 바이트 수다. 매 시도가 같은 헤더 집합을 쓰고 서명·타임스탬프만 달라진다")
2856
3020
  });
2857
- const uploadTicketSchema = z.object({
2858
- /** 업로드 완료 후 공개될 주소 */
2859
- url: z.url(),
2860
- /** 파일 바이트를 PUT할 presigned URL. 1시간 유효 */
2861
- uploadUrl: z.url()
3021
+ /** 전송 기록 조회 쿼리 — `GET /webhooks/{id}/deliveries` */
3022
+ const webhookDeliveryListQuerySchema = z.object({
3023
+ status: webhookDeliveryStatusSchema.optional().describe("전송 상태 필터. 생략하면 전체"),
3024
+ eventType: eventTypeSchema.optional().describe("이벤트 타입 필터. 생략하면 전체"),
3025
+ resourceId: z.string().optional().describe("대상 리소스 ID 필터(정확히 일치). 주문·클레임·상품·리뷰·문의 id를 넣으면 그 리소스의 이벤트만 남는다. 같은 id로 API 로그를 `q`로 검색하면 그 리소스를 건드린 API 요청을 볼 수 있다"),
3026
+ limit: z.coerce.number().int().min(1).max(100).optional().describe("최대 건수 (1~100, 기본 50). 최신 적재순으로 반환하며 보관 기간은 30일이다")
2862
3027
  });
2863
- //#endregion
2864
- //#region src/schemas/product.ts
2865
- const productStatusSchema = z.enum([
2866
- "SALE",
2867
- "SUSPENDED",
2868
- "OUT_OF_STOCK",
2869
- "CLOSED"
2870
- ]);
2871
- const productSummarySchema = z.object({
3028
+ /** 실패 건 일괄 재전송 응답 — `POST /webhooks/{id}/deliveries/redeliver-failed` */
3029
+ const webhookRedeliverFailedResultSchema = z.object({ queued: z.number().int().min(0).describe("재전송 대기열에 올린 건수. 최근 72시간의 `FAILED` 중 아직 재전송되지 않은 건이 대상이며 최대 500건이다") });
3030
+ const webhookTestRequestSchema = z.object({ eventType: eventTypeSchema.describe("샘플 페이로드로 보낼 이벤트 타입") });
3031
+ /** 테스트 이벤트 발송 결과 (동기 반환). 재시도·정지 판정·전송 기록이 없다 */
3032
+ const webhookTestResultSchema = z.object({
3033
+ responseStatusCode: z.number().int().nullable().describe("수신 서버의 HTTP 응답 코드. 연결 실패·타임아웃·차단이면 null"),
3034
+ responseTimeMs: z.number().int().nullable().describe("응답 시간(ms). 연결 실패면 null"),
3035
+ errorCode: webhookDeliveryErrorCodeSchema.nullable().describe("실패 사유. 2xx 응답이면 null"),
3036
+ failureReason: webhookFailureReasonSchema.nullable().describe("실패 원인 분류. 2xx 응답이면 null"),
3037
+ responseBodyExcerpt: z.string().nullable().describe("응답 본문 앞부분(최대 4,096자). 규칙은 전송 기록의 `responseBodyExcerpt`와 같다. 응답이 없으면 null"),
3038
+ requestHeaders: z.array(webhookRequestHeaderSchema).describe("보낸 요청 헤더. 서명 헤더는 이름만 주고 값은 null이다"),
3039
+ payload: z.string().describe("보낸 요청 본문 그대로(JSON 문자열). 샘플 이벤트다")
3040
+ });
3041
+ const webhookRequestSchema = z.object({
3042
+ url: z.url().startsWith("https://", "웹훅 URL은 HTTPS만 허용됩니다"),
3043
+ eventTypes: z.array(eventTypeSchema).min(1)
3044
+ });
3045
+ const reviewSchema = z.object({
3046
+ reviewId: z.string(),
2872
3047
  productId: z.string(),
2873
- name: z.string(),
2874
- status: productStatusSchema,
2875
- salePrice: z.number().int().describe("판매가. 원(KRW) 단위 정수"),
2876
- discountedPrice: z.number().int().nullable().describe("즉시 할인 적용가. 원 단위. 할인이 없으면 null"),
2877
- /** usable variant 재고 합계 (서버 계산) */
2878
- stockQuantity: z.number().int().describe("판매 가능한 옵션 재고의 합계. 서버가 계산한다"),
2879
- categoryId: z.string().nullable(),
2880
- thumbnailUrl: z.url().optional(),
2881
- /** optionGroups.length > 0 */
2882
- hasOptions: z.boolean(),
2883
- fulfillment: z.object({ type: fulfillmentTypeResponseSchema }).describe("상품 이행의 유형. 유형별 속성은 단건 조회(`GET /v1/products/{productId}`)의 `fulfillment`에 있다"),
2884
- updatedAt: z.string(),
2885
- sample: z.boolean().optional().default(false).describe("상점을 만들 때 넣은 샘플 상품인가(이슈 #70). 상점 플랫폼이 「샘플」 배지를 단다. 스토어프론트에는 싣지 않는다. 서버는 항상 싣는다")
3048
+ productName: z.string(),
3049
+ orderItemId: z.string(),
3050
+ optionName: z.string().nullable(),
3051
+ rating: z.number().int().min(1).max(5),
3052
+ content: z.string(),
3053
+ images: z.array(z.url()),
3054
+ writerMaskedName: z.string(),
3055
+ blinded: z.boolean(),
3056
+ reply: z.object({
3057
+ content: z.string(),
3058
+ repliedAt: z.string(),
3059
+ updatedAt: z.string().nullable()
3060
+ }).nullable(),
3061
+ createdAt: z.string()
2886
3062
  });
2887
- /** 옵션 축(그룹)이 가지는 값 하나 — 예: 축 "색상"의 값 "블랙" */
2888
- const optionValueSchema = z.object({
2889
- valueId: z.string(),
2890
- name: z.string(),
2891
- sortOrder: z.number().int()
3063
+ const reviewSortSchema = z.enum([
3064
+ "createdAt.desc",
3065
+ "createdAt.asc",
3066
+ "rating.asc",
3067
+ "rating.desc"
3068
+ ]);
3069
+ /** `GET /v1/reviews` 쿼리 — OpenAPI 문서용 형태(값은 쿼리 문자열) */
3070
+ const reviewListQuerySchema = z.object({
3071
+ productId: z.string().optional().describe("상품 id 필터"),
3072
+ rating: z.enum([
3073
+ "1",
3074
+ "2",
3075
+ "3",
3076
+ "4",
3077
+ "5"
3078
+ ]).optional().describe("별점 필터 (일치)"),
3079
+ replied: z.enum(["true", "false"]).optional().describe("답글 여부 필터. `true`면 답글 단 리뷰만, `false`면 답글 없는 리뷰만"),
3080
+ hasImage: z.enum(["true", "false"]).optional().describe("`true`면 이미지가 있는 리뷰만"),
3081
+ from: z.string().optional().describe("작성 시각 조회 시작 (ISO 8601, 포함)"),
3082
+ sort: reviewSortSchema.optional().describe("정렬 (기본 createdAt.desc)"),
3083
+ ...pageQueryShape()
2892
3084
  });
2893
- /** 옵션 축(그룹) — 예: "색상", "사이즈" */
2894
- const optionGroupSchema = z.object({
2895
- groupId: z.string(),
2896
- name: z.string(),
2897
- sortOrder: z.number().int(),
2898
- values: z.array(optionValueSchema)
3085
+ /** `POST /v1/reviews/{reviewId}/report` 응답(202) — 신고 접수 */
3086
+ const reviewReportResultSchema = z.object({
3087
+ reviewId: z.string(),
3088
+ status: z.literal("PENDING_REVIEW")
2899
3089
  });
3090
+ //#endregion
3091
+ //#region src/schemas/payment.ts
2900
3092
  /**
2901
- * 판매 단위(SKU). variantId는 기존 optionId 값 공간을 승계하므로
2902
- * 카트·주문·리뷰·클레임의 optionId가 곧 variantId다.
3093
+ * 상점 결제(PG) 설정과 매출 리포트 계약.
3094
+ *
3095
+ * PG는 셀러가 직접 계약한다. 자격 증명은 상점별로 저장되고, 비밀 값(시크릿 키·API Secret·
3096
+ * 웹훅 시크릿)은 저장 후 다시 조회할 수 없다. 응답에는 앞 4자리 마스킹만 나온다. 공개 값
3097
+ * (클라이언트 키·Store ID·채널 키)은 원래 브라우저 결제창에 노출되는 값이라 그대로 돌려준다.
2903
3098
  */
2904
- const productVariantSchema = z.object({
2905
- variantId: z.string(),
2906
- /** 축 sortOrder 순서와 1:1 대응. 옵션 없는 상품은 [] */
2907
- valueIds: z.array(z.string()),
2908
- /** 값 이름을 " / "로 이은 파생 표시명. 옵션 없으면 "" */
2909
- name: z.string(),
2910
- additionalPrice: z.number().int(),
2911
- stockQuantity: z.number().int(),
2912
- sku: z.string().nullable(),
2913
- barcode: z.string().nullable(),
2914
- costPrice: z.number().int().nullable(),
2915
- weightGram: z.number().int().nullable(),
2916
- sellerCode: z.string().nullable(),
2917
- usable: z.boolean(),
2918
- hasOrders: z.boolean().optional().describe("주문에 담긴 적이 있는 조합인가(이슈 #59). true인 조합은 옵션 교체에서 뺄 수 없고(409 `OPTION_HAS_ORDERS`) `usable: false`로 판매를 끈다. 서버는 항상 싣는다. 요청에는 쓰지 않는다")
2919
- });
2920
- /** 옵션 축(그룹) 최대 개수 — 스키마 상한의 단일 원천 */
2921
- const MAX_OPTION_GROUPS = 3;
2922
- /** 축 하나가 가질 수 있는 값의 최대 개수 */
2923
- const MAX_VALUES_PER_GROUP = 100;
2924
- /** 상품 하나가 가질 수 있는 variant(SKU) 최대 개수 */
2925
- const MAX_VARIANTS = 500;
2926
- /** 추가 선택 옵션명 최대 개수 */
2927
- const MAX_ADDON_GROUPS = 5;
2928
- /** 직접 입력 항목 최대 개수 */
2929
- const MAX_CUSTOM_INPUTS = 5;
2930
- /** 직접 입력 항목 최대 글자 수의 상한 */
2931
- const MAX_CUSTOM_INPUT_LENGTH = 200;
2932
- /** 조합 수가 이 값 이상이면 상점 플랫폼이 경고한다(상한은 `MAX_VARIANTS`) */
2933
- const VARIANT_COUNT_WARNING = 100;
3099
+ const pgProviderSchema = z.enum(["tosspayments", "portone"]).describe("PG 식별자. `tosspayments`=토스페이먼츠, `portone`=포트원(V2)");
3100
+ const pgPaymentMethodSchema = z.enum([
3101
+ "CARD",
3102
+ "BANK_TRANSFER",
3103
+ "VIRTUAL_ACCOUNT",
3104
+ "MOBILE",
3105
+ "EASY_PAY"
3106
+ ]).describe("결제수단 (스토어프론트 결제 요청의 `paymentMethod`와 같은 값)");
2934
3107
  /**
2935
- * 옵션 값 정렬 순서 — 등록순·가나다순·낮은 추가금순·높은 추가금순. 서버가 저장할 때 이 순서로 값의 `sortOrder`를 매긴다.
2936
- * 추가금순은 추가 선택 값이면 값의 추가금, 조합 값이면 그 값이 든 조합 추가금의 최솟값으로 비교하고 같으면 등록순이다.
3108
+ * 간편결제사 — 결제 옵션(스토어프론트 `paymentOptions`)과 PG 설정의 `easyPayProviders`에 쓴다.
3109
+ * `@sayren/storefront-sdk`의 `easyPayProviderSchema`와 같은 값이다(api 계약 테스트가 고정한다)
2937
3110
  */
2938
- const optionSortSchema = z.enum([
2939
- "REGISTERED",
2940
- "NAME",
2941
- "PRICE_ASC",
2942
- "PRICE_DESC"
3111
+ const easyPayProviderSchema = z.enum([
3112
+ "NAVERPAY",
3113
+ "KAKAOPAY",
3114
+ "TOSSPAY",
3115
+ "PAYCO"
2943
3116
  ]);
2944
- /** 추가 선택 값 — 예: 옵션명 "선물 포장"의 값 "포장함"(+2,000원) */
2945
- const addonValueSchema = z.object({
2946
- valueId: z.string(),
2947
- name: z.string(),
2948
- sortOrder: z.number().int(),
2949
- additionalPrice: z.number().int().describe("이 값을 고르면 단가에 더하는 금액(원, 0 이상)"),
2950
- usable: z.boolean().describe("사용 여부. false면 구매자에게 보이지 않고 고를 수 없다")
2951
- });
2952
- /** 추가 선택 옵션명 */
2953
- const addonGroupSchema = z.object({
2954
- groupId: z.string(),
2955
- name: z.string(),
2956
- sortOrder: z.number().int(),
2957
- required: z.boolean().describe("구매자가 꼭 골라야 하는가. false면 고르지 않아도 된다"),
2958
- values: z.array(addonValueSchema)
2959
- });
2960
- /** 직접 입력 항목 — 예: 각인 문구 */
2961
- const customInputSchema = z.object({
2962
- inputId: z.string(),
2963
- label: z.string().describe("항목명"),
2964
- placeholder: z.string().nullable().describe("입력 안내(구매자 입력 칸의 안내 문구). 없으면 null"),
2965
- maxLength: z.number().int().describe("최대 글자 수"),
2966
- required: z.boolean(),
2967
- usable: z.boolean().describe("사용 여부. false면 구매자에게 보이지 않는다"),
2968
- sortOrder: z.number().int()
2969
- });
2970
3117
  /**
2971
- * 직접 입력 항목명에 쓸 수 없는 개인정보성 단어 — 상점 플랫폼 검사와 서버 400(`CUSTOM_INPUT_LABEL_FORBIDDEN`)이 이 목록 하나를 쓴다.
2972
- * 구매자 개인정보는 주문 정보로 받는다. 직접 입력 항목으로 받으면 보관·파기 규칙 밖에 남는다.
3118
+ * 응답에 실리는 간편결제사 — 서버가 간편결제사를 추가해도 파싱이 깨지지 않게 문자열로 받는다
3119
+ * (`pgProvider`·결제수단과 같은 방식). 요청(`easyPayProviders`)은 값을 골라 보내는 자리라 enum 그대로다.
3120
+ * 현재 값은 `NAVERPAY`·`KAKAOPAY`·`TOSSPAY`·`PAYCO`이고 화면은 모르는 값을 원문 그대로 보여 준다.
2973
3121
  */
2974
- const CUSTOM_INPUT_FORBIDDEN_WORDS = [
2975
- "연락처",
2976
- "전화",
2977
- "휴대폰",
2978
- "핸드폰",
2979
- "폰번호",
2980
- "메일",
2981
- "주소",
2982
- "주민",
2983
- "생년월일",
2984
- "생일",
2985
- "실명",
2986
- "계좌",
2987
- "카드번호",
2988
- "비밀번호",
2989
- "여권",
2990
- "통관",
2991
- "phone",
2992
- "mobile",
2993
- "mail",
2994
- "address"
2995
- ];
2996
- /** 금지어 비교용 정규화 — NFKC(전각·호환 문자를 보통 문자로)·소문자 뒤 문자·숫자만 남긴다(공백·기호·제로폭 문자로 쪼갠 우회를 막는다) */
2997
- function forbiddenWordKeyOf(text) {
2998
- return text.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
2999
- }
3122
+ const easyPayProviderResponseSchema = z.string();
3123
+ const pgEnvironmentSchema = z.enum(["TEST", "LIVE"]).describe("PG 결제 환경. `TEST`=PG 샌드박스(테스트 키, 실제로 돈이 오가지 않음), `LIVE`=실결제(운영 키)");
3124
+ const pgVerificationResultSchema = z.enum(["SUCCESS", "FAILED"]).describe("연결 테스트 결과");
3125
+ /** 비밀 값 입력 — 저장 후 다시 조회할 수 없다 */
3126
+ const secretInput = () => z.string().trim().min(8).max(512);
3000
3127
  /**
3001
- * 직접 입력 항목명·입력 안내에 든 개인정보성 금지어. 없으면 null. 공백·기호·대소문자·전각을 무시한다.
3002
- * 서버는 항목명과 입력 안내 둘 다 이 함수로 검사한다(안내에 「연락처를 적어 주십시오」라고 쓰는 우회를 막는다).
3128
+ * 토스페이먼츠 결제위젯 키 접두사. 결제창은 API 개별 연동(`*_ck_`·`*_sk_`)만 쓴다. 위젯 키는 결제창 SDK가
3129
+ * `NOT_SUPPORTED_WIDGET_KEY`로, 서버 API가 404 `NOT_FOUND_MERCHANT`로 거부하므로 저장 전에 막는다.
3130
+ * 요청 스키마·api 어댑터·상점 플랫폼 폼이 같은 상수를 쓴다(원천). `g` 플래그가 없어 `.test()`가 상태를 갖지 않는다.
3003
3131
  */
3004
- function customInputLabelForbiddenWord(label) {
3005
- const normalized = forbiddenWordKeyOf(label);
3006
- return CUSTOM_INPUT_FORBIDDEN_WORDS.find((word) => normalized.includes(forbiddenWordKeyOf(word))) ?? null;
3007
- }
3008
- /** 옵션명·값 이름 — 앞뒤 공백을 지우고 빈 값은 받지 않는다 */
3009
- const optionNameSchema = z.string().trim().min(1).max(50);
3010
- const addonValueRequestSchema = z.object({
3011
- /** 전달 시 기존 값 행을 유지(UPDATE). 생략하면 신규 발급 */
3012
- valueId: z.string().optional(),
3013
- name: optionNameSchema,
3014
- additionalPrice: z.number().int().min(0).default(0).describe("추가금(원, 0 이상)"),
3015
- usable: z.boolean().default(true)
3132
+ const TOSS_WIDGET_CLIENT_KEY_PATTERN = /^(test|live)_gck_/;
3133
+ /** 토스페이먼츠 결제위젯 시크릿 키 접두사. {@link TOSS_WIDGET_CLIENT_KEY_PATTERN} 참고 */
3134
+ const TOSS_WIDGET_SECRET_KEY_PATTERN = /^(test|live)_gsk_/;
3135
+ /** 결제위젯 키를 거부할 때의 필드 메시지 */
3136
+ const TOSS_WIDGET_KEY_MESSAGE = "결제위젯 키가 아니라 API 개별 연동 키를 입력해주십시오";
3137
+ const tossPaymentsCredentialsInputSchema = z.object({
3138
+ clientKey: z.string().trim().min(8).max(200).describe("클라이언트 키 (`test_ck_…`/`live_ck_…`). 공개 값 — 결제창에서 쓰인다"),
3139
+ secretKey: secretInput().optional().describe("시크릿 키 (`test_sk_…`/`live_sk_…`). 비밀 값. 최초 연결 시 필수, 수정 시 생략하면 기존 값을 유지한다"),
3140
+ webhookSecret: secretInput().nullable().optional().describe("웹훅 서명 검증용 보안 키. 비밀 값. 생략하면 기존 값 유지, null이면 삭제")
3141
+ }).superRefine((value, ctx) => {
3142
+ if (TOSS_WIDGET_CLIENT_KEY_PATTERN.test(value.clientKey)) ctx.addIssue({
3143
+ code: "custom",
3144
+ path: ["clientKey"],
3145
+ message: TOSS_WIDGET_KEY_MESSAGE
3146
+ });
3147
+ if (value.secretKey !== void 0 && TOSS_WIDGET_SECRET_KEY_PATTERN.test(value.secretKey)) ctx.addIssue({
3148
+ code: "custom",
3149
+ path: ["secretKey"],
3150
+ message: TOSS_WIDGET_KEY_MESSAGE
3151
+ });
3016
3152
  });
3017
- const addonGroupRequestSchema = z.object({
3018
- /** 전달 시 기존 옵션명 행을 유지(UPDATE). 조합 옵션명의 id는 줄 수 없다(방식을 바꾸려면 새 옵션명으로 보낸다) */
3019
- groupId: z.string().optional(),
3020
- name: optionNameSchema,
3021
- required: z.boolean().default(false),
3022
- values: z.array(addonValueRequestSchema).min(1).max(100)
3153
+ /** 포트원 채널 목록 최소 개수. 결제수단을 받으려면 채널이 하나는 있어야 한다 */
3154
+ const PORTONE_MIN_CHANNELS = 1;
3155
+ /** 포트원 채널 목록 최대 개수 */
3156
+ const PORTONE_MAX_CHANNELS = 20;
3157
+ /** 포트원 채널 표시 이름 최대 길이 */
3158
+ const PORTONE_CHANNEL_NAME_MAX_LENGTH = 50;
3159
+ const portOneChannelSchema = z.object({
3160
+ channelKey: z.string().trim().min(8).max(200).describe("포트원 콘솔 [연동 정보] > [채널 관리]의 채널 키. 공개 값"),
3161
+ name: z.string().trim().max(50).nullable().optional().describe("채널 표시 이름 (예: 카드 전용)"),
3162
+ payMethods: z.array(pgPaymentMethodSchema).min(1).max(pgPaymentMethodSchema.options.length).describe("이 채널로 받을 결제수단. 결제수단마다 먼저 나열된 채널이 쓰인다")
3023
3163
  });
3024
- const customInputRequestSchema = z.object({
3025
- /** 전달 시 기존 항목 행을 유지(UPDATE). 생략하면 신규 발급 */
3026
- inputId: z.string().optional(),
3027
- label: z.string().trim().min(1).max(30),
3028
- placeholder: z.string().trim().max(100).nullish(),
3029
- maxLength: z.number().int().min(1).max(200),
3030
- required: z.boolean().default(false),
3031
- usable: z.boolean().default(true)
3164
+ const portOneCredentialsInputSchema = z.object({
3165
+ storeId: z.string().trim().min(8).max(200).describe("포트원 Store ID (`store-…`). 공개 값"),
3166
+ channels: z.array(portOneChannelSchema).min(1).max(20).describe("채널 목록. 포트원 V2는 PG·결제수단별로 채널을 따로 추가하므로 여러 개를 등록할 수 있다"),
3167
+ apiSecret: secretInput().optional().describe("V2 API Secret. 비밀 값. 최초 연결 시 필수, 수정 시 생략하면 기존 값을 유지한다"),
3168
+ webhookSecret: secretInput().nullable().optional().describe("웹훅 서명 검증용 시크릿. 비밀 값. 생략하면 기존 값 유지, null이면 삭제")
3032
3169
  });
3033
- /**
3034
- * 주문상품·장바구니·주문서 줄의 옵션 선택 스냅샷 한 칸 — 주문 시점 이름이다(상품을 고쳐도 바뀌지 않는다).
3035
- * 조합 옵션은 옵션명마다 한 칸이고 추가금은 조합 단위라 0이다(조합 추가금은 단가에 들어 있다). 추가 선택은 값의 추가금이다.
3036
- */
3037
- const optionSelectionSchema = z.object({
3038
- kind: z.string().describe("옵션명 방식. 현재 값은 `combination`·`addon`이고 값이 추가될 수 있어 문자열로 받는다"),
3039
- groupName: z.string(),
3040
- valueName: z.string(),
3041
- additionalPrice: z.number().int()
3170
+ const upsertEnvironment = () => pgEnvironmentSchema.describe("저장할 자격 증명 세트. PG마다 `TEST`(샌드박스)·`LIVE`(실결제) 세트를 따로 둔다. 어느 세트로 결제할지는 이 값이 아니라 샌드박스 스위치(`sandboxMode`)가 정한다");
3171
+ const upsertTossPaymentsProviderRequestSchema = z.object({
3172
+ environment: upsertEnvironment(),
3173
+ credentials: tossPaymentsCredentialsInputSchema
3042
3174
  });
3043
- /** 구매자가 적은 직접 입력값 — 주문 시점 항목명과 값 */
3044
- const customInputValueSchema = z.object({
3045
- label: z.string(),
3046
- value: z.string()
3175
+ const upsertPortOneProviderRequestSchema = z.object({
3176
+ environment: upsertEnvironment(),
3177
+ credentials: portOneCredentialsInputSchema
3047
3178
  });
3048
- const discountSchema = z.object({
3049
- productId: z.string(),
3050
- discountType: z.enum(["RATE", "AMOUNT"]),
3051
- discountValue: z.number().int(),
3052
- discountedPrice: z.number().int(),
3053
- startAt: z.string().nullable(),
3054
- endAt: z.string().nullable()
3179
+ const updatePaymentProviderRequestSchema = z.object({
3180
+ enabled: z.boolean().optional().describe("사용 여부. 켜려면 활성 환경(샌드박스면 TEST, 아니면 LIVE) 자격 증명이 모두 저장돼 있어야 한다(`PAYMENT_PROVIDER_INCOMPLETE`)"),
3181
+ sandboxMode: z.boolean().optional().describe("샌드박스 스위치. `true`면 새 결제에 TEST 세트를 쓴다(항상 허용). `false`(라이브 전환)는 라이브 개방 조건을 모두 충족해야 한다(`LIVE_GATE_NOT_MET`)"),
3182
+ easyPayProviders: z.array(easyPayProviderSchema).max(easyPayProviderSchema.options.length).optional().describe("이 PG로 받는 간편결제사 — 통째로 바꾼다. PG 계약(포트원은 채널)에 추가한 간편결제만 켠다. 켠 간편결제사마다 스토어프론트 결제 옵션이 하나씩 생긴다(포트원은 간편결제를 받는 채널이 있어야 한다)")
3183
+ }).refine((body) => body.enabled !== void 0 || body.sandboxMode !== void 0 || body.easyPayProviders !== void 0, { message: "enabled·sandboxMode·easyPayProviders 중 하나는 보내야 합니다" });
3184
+ const verifyPaymentProviderRequestSchema = z.object({ environment: pgEnvironmentSchema.optional().describe("연결 테스트할 자격 증명 세트. 생략하면 활성 환경(샌드박스면 TEST, 아니면 LIVE)") });
3185
+ const updatePaymentSettingsRequestSchema = z.object({
3186
+ priority: z.array(pgProviderSchema).min(1).max(50).optional().describe("PG 우선순위(앞이 1순위) — 스토어프론트 결제 옵션 목록의 PG 순서다. 연결된 PG 수만큼 **모두 한 번씩** 나열한다(길이 상한 50은 안전장치다). 빠지거나 중복되거나 연결되지 않은 PG가 있으면 `INVALID_PRIORITY`"),
3187
+ checkoutOrigins: z.array(z.string().max(200)).max(20).optional().describe("결제 도메인 — 통째로 바꾼다. 결제를 마친 구매자가 돌아올 스토어프론트 origin(`https://shop.example.com`, https·경로 없음). 결제 시작의 `returnUrl`은 이 목록 안이어야 한다. 비어 있으면 실결제를 시작할 수 없다(테스트 결제는 localhost 허용). 같은 도메인을 PG 가맹 정보에도 등록한다. 형식이 틀리면 `INVALID_CHECKOUT_ORIGIN`")
3188
+ }).refine((body) => body.priority !== void 0 || body.checkoutOrigins !== void 0, { message: "priority 또는 checkoutOrigins 중 하나는 보내야 합니다" });
3189
+ const maskedSecretSchema = z.object({
3190
+ field: z.string().describe("비밀 필드 이름 (`secretKey`·`apiSecret`·`webhookSecret`)"),
3191
+ configured: z.boolean().describe("값이 저장돼 있는가"),
3192
+ masked: z.string().nullable().describe("앞 4자리만 남긴 마스킹 값 (예: `test****`). 저장되지 않았으면 null")
3055
3193
  });
3056
- const productSchema = productSummarySchema.extend({
3057
- description: z.string().optional(),
3058
- /**
3059
- * 상세설명 에디터(@avarlabs/editor)의 직렬화 상태. 재편집 때 이 값으로 문서를 복원한다.
3060
- * 표시는 `description`(HTML)이 맡는다 — 소비자는 이 필드를 몰라도 된다.
3061
- */
3062
- descriptionJson: z.string().nullable().optional(),
3063
- originalPrice: z.number().int().nullable().optional().describe("정가(할인 전 표시 가격). 원 단위. 비교용 표시 값이고 결제 금액 계산에 쓰지 않는다 — 구매자가 내는 가격은 즉시할인이 있으면 `discountedPrice`, 없으면 `salePrice`다. 스토어프론트 상품 응답에도 같은 값이 실린다. 정하지 않았으면 null"),
3064
- images: z.array(z.url()).optional(),
3065
- attributes: z.array(z.object({
3066
- attributeId: z.string(),
3067
- value: z.string()
3068
- })).optional(),
3069
- optionGroups: z.array(optionGroupSchema),
3070
- /** 모든 상품이 최소 1개 variant를 갖는다 (옵션 없으면 default variant 1개) */
3071
- variants: z.array(productVariantSchema),
3072
- addonGroups: z.array(addonGroupSchema).optional().default([]).describe("추가 선택 옵션명(이슈 #59). 없으면 빈 배열. 서버는 항상 싣는다"),
3073
- customInputs: z.array(customInputSchema).optional().default([]).describe("직접 입력 항목(이슈 #59). 사용하지 않는 항목도 싣는다. 없으면 빈 배열. 서버는 항상 싣는다"),
3074
- optionSort: optionSortSchema.optional().default("REGISTERED").describe("옵션 값 정렬 순서(이슈 #59). 서버는 항상 싣는다"),
3075
- fulfillment: productFulfillmentResponseSchema,
3076
- fulfillmentTypeLocked: z.boolean().describe("판매 이력(주문상품)이 있어 상품 유형(`fulfillment.type`)을 바꿀 수 없는가. true면 다른 유형으로 수정하는 요청이 409 `FULFILLMENT_TYPE_LOCKED`다. 같은 유형 안의 속성은 바꿀 수 있다"),
3077
- refundPolicy: productRefundPolicyPatchSchema.nullable().describe("상품별 환불 정책 덮어쓰기(상품이 정한 항목만). null이면 상점 기본 환불 정책을 쓴다. 반품·교환 배송비(`returnDeliveryFee`·`exchangeDeliveryFee`)는 배송 상품에만 있다"),
3078
- /**
3079
- * 설정된 즉시할인. `GET /products/{productId}/discount`의 응답과 같은 형태다(이슈 #51).
3080
- * 할인이 없으면 null이다. 이 필드를 싣기 전 서버의 응답에는 없으므로 optional이다.
3081
- */
3082
- discount: discountSchema.nullable().optional().describe("설정된 즉시할인. GET /v1/products/{productId}/discount 응답과 같은 형태다. 할인이 없으면 null"),
3083
- createdAt: z.string().optional()
3194
+ const publicConfigSchema = z.union([z.object({ clientKey: z.string() }), z.object({
3195
+ storeId: z.string(),
3196
+ channels: z.array(portOneChannelSchema)
3197
+ })]);
3198
+ const verificationSchema = z.object({
3199
+ result: pgVerificationResultSchema,
3200
+ message: z.string().nullable(),
3201
+ checkedAt: z.string()
3202
+ });
3203
+ const paymentCredentialSetSchema = z.object({
3204
+ environment: pgEnvironmentSchema,
3205
+ publicConfig: publicConfigSchema.describe("공개 값. 토스페이먼츠는 `{clientKey}`, 포트원은 `{storeId, channels}`"),
3206
+ secrets: z.array(maskedSecretSchema).describe("비밀 값의 저장 여부와 마스킹. 평문은 어떤 응답에도 나오지 않는다"),
3207
+ lastVerification: verificationSchema.nullable().describe("이 세트의 마지막 연결 테스트 결과. 세트를 바꾸면 초기화된다"),
3208
+ updatedAt: z.string()
3209
+ });
3210
+ const liveGateConditionCodeSchema = z.enum([
3211
+ "TEST_CONNECTION",
3212
+ "SANDBOX_PAYMENT",
3213
+ "SANDBOX_REFUND",
3214
+ "LIVE_CONNECTION",
3215
+ "PLATFORM_LIVE_ENABLED"
3216
+ ]).describe("라이브 개방 조건. `TEST_CONNECTION`=TEST 세트 연결 테스트 성공, `SANDBOX_PAYMENT`=현재 TEST 세트로 샌드박스 결제 1건 승인, `SANDBOX_REFUND`=그 샌드박스 결제의 환불 1건 성공, `LIVE_CONNECTION`=LIVE 세트 연결 테스트 성공, `PLATFORM_LIVE_ENABLED`=플랫폼이 실결제를 열어 둠");
3217
+ const liveGateConditionSchema = z.object({
3218
+ code: liveGateConditionCodeSchema,
3219
+ met: z.boolean().describe("충족 여부"),
3220
+ message: z.string().describe("조건 설명 또는 미충족 사유")
3221
+ });
3222
+ const liveGateSchema = z.object({
3223
+ status: z.enum(["LOCKED", "OPEN"]).describe("`OPEN`=조건을 모두 충족해 샌드박스를 끌 수 있다, `LOCKED`=아직 끌 수 없다"),
3224
+ conditions: z.array(liveGateConditionSchema),
3225
+ openedAt: z.string().nullable().describe("마지막으로 라이브로 전환한 시각. 없으면 null")
3084
3226
  });
3085
- /** 상품 하나에 등록할 수 있는 이미지 최대 장수 — 첫 장이 대표(썸네일)다 */
3086
- const MAX_PRODUCT_IMAGES = 10;
3087
3227
  /**
3088
- * 상품 이미지 주소의 와이어 형식 — URL이고 2048자 이하다.
3089
- *
3090
- * https·확장자 규칙은 여기서 강제하지 않고 `productImageRejection`이 판정한다. 서버가
3091
- * **새로 추가된 주소에만** 그 규칙을 적용해야 하기 때문이다(규칙이 생기기 전에 저장된
3092
- * 주소 하나로 상품 수정 전체가 막히면 셀러가 빠져나갈 길이 없다).
3093
- *
3094
- * 응답 스키마(`productSchema.images`)도 넓게 둔다. 과거에 저장된 주소를 파싱 오류로
3095
- * 만들면 상품 조회 자체가 막힌다.
3228
+ * 409 `LIVE_GATE_NOT_MET`·`LIVE_PAYMENTS_DISABLED`의 `ApiError.details` 형태.
3229
+ * 응답 파싱용이라 관대하다 — 알 수 없는 키는 버리고, 형태가 다르면 safeParse 실패로 처리한다.
3096
3230
  */
3097
- const productImageUrlSchema = z.url().max(2048).describe("상품 이미지 주소. https만 허용하고, 확장자가 있으면 이미지 형식이어야 한다");
3231
+ const liveGateErrorDetailsSchema = z.object({ conditions: z.array(liveGateConditionSchema).describe("라이브 개방 조건별 충족 여부 (순서 고정)") });
3232
+ const paymentProviderSchema = z.object({
3233
+ provider: pgProviderSchema,
3234
+ displayName: z.string().describe("표시 이름 (토스페이먼츠·포트원)"),
3235
+ connected: z.boolean().describe("자격 증명이 저장돼 있는가"),
3236
+ enabled: z.boolean().describe("결제에 사용 중인가"),
3237
+ priority: z.number().int().nullable().describe("우선순위 (1이 가장 높다). 연결되지 않았으면 null"),
3238
+ environment: pgEnvironmentSchema.nullable().describe("새 결제에 쓰는 환경(활성 환경). 샌드박스 스위치가 켜져 있으면 `TEST`, 꺼져 있으면 `LIVE`. 연결되지 않았으면 null"),
3239
+ publicConfig: publicConfigSchema.nullable().describe("활성 환경 세트의 공개 값. 토스페이먼츠는 `{clientKey}`, 포트원은 `{storeId, channels}`. 활성 세트가 없으면 null"),
3240
+ secrets: z.array(maskedSecretSchema).describe("활성 환경 세트의 비밀 값 저장 여부와 마스킹. 평문은 어떤 응답에도 나오지 않는다"),
3241
+ lastVerification: verificationSchema.nullable().describe("활성 환경 세트의 마지막 연결 테스트 결과. 세트를 바꾸면 초기화된다"),
3242
+ updatedAt: z.string().nullable(),
3243
+ sandboxMode: z.boolean().describe("샌드박스 스위치. `true`면 새 결제에 TEST 세트를 쓴다(실제로 돈이 오가지 않는다). 연결되지 않았으면 true"),
3244
+ credentialSets: z.object({
3245
+ TEST: paymentCredentialSetSchema.nullable(),
3246
+ LIVE: paymentCredentialSetSchema.nullable()
3247
+ }).describe("환경별 자격 증명 세트. 등록되지 않은 세트는 null"),
3248
+ liveGate: liveGateSchema.describe("라이브 개방 조건. 조건을 모두 충족해야 샌드박스를 끌 수 있다"),
3249
+ easyPayProviders: z.array(easyPayProviderResponseSchema).describe("이 PG로 받는 간편결제사. 연결되지 않았으면 빈 목록. 현재 값은 `NAVERPAY`·`KAKAOPAY`·`TOSSPAY`·`PAYCO`이고 값이 추가될 수 있어 문자열로 받는다"),
3250
+ retiredCredentialCount: z.number().int().describe("보관된(가맹점 교체·연결 해제로 새 결제에 더 쓰이지 않는) 자격 증명 수. 그 가맹점으로 받은 결제의 환불·대사에 계속 쓰인다"),
3251
+ routingExclusion: z.string().nullable().optional().default(null).describe("지금 새 결제의 후보가 아닌 사유. 후보면 null. 판정 원천은 서버 라우팅이다(상점 플랫폼이 다시 계산하지 않는다). 후보가 아닌 PG에는 항상 사유 코드를 붙인다(연결 안 된 PG 포함) — 모든 PG는 `candidates`에 있거나 이 값이 null이 아니다. 현재 값: `NOT_CONNECTED`(연결 안 됨) · `DISABLED`(사용 중지) · `CREDENTIAL_MISSING`(활성 환경 자격 증명 없음) · `SANDBOX_IN_LIVE_STORE`(다른 PG가 라이브라 이 샌드박스 PG는 실구매자 결제에 쓰지 않음) · `PLATFORM_BLOCKED`(플랫폼이 실결제를 막음) · `MODE_MISMATCH`(라이브를 연 서버 모드와 현재 모드가 다름). 값이 추가될 수 있어 문자열로 받는다 — 모르는 값은 후보 아님으로 다룬다. 서버는 항상 싣는다. 이 필드가 없는 이전 서버 응답은 기본값(null)으로 읽는다")
3252
+ });
3098
3253
  /**
3099
- * 상품에 **새로 붙이는** 이미지 주소의 규칙. 통과하면 `null`.
3100
- *
3101
- * - https만 받는다. http는 https 스토어프론트에서 혼합 콘텐츠로 차단되고 `data:`·`javascript:`는
3102
- * 상세 화면에 박히는 주입 경로가 된다.
3103
- * - 호스트는 제한하지 않는다 — 셀러가 이미 쓰는 이미지 호스트를 그대로 붙일 수 있어야 한다.
3104
- * - 경로에 확장자가 있으면 상품 이미지 형식이어야 한다(svg·동영상 거부). **확장자가 없으면 통과시킨다**
3105
- * — 외부 이미지 CDN은 확장자 없는 키를 쓰는 경우가 흔하고, 주소만으로는 실제 형식을 알 수 없다.
3254
+ * `PaymentProvider.routingExclusion`의 현재 값 — 응답 스키마는 서버가 사유를 추가해도 파싱이 깨지지 않게 문자열로 받는다.
3255
+ * 화면 문구 분기는 이 목록으로 하고, 모르는 값은 "후보 아님"으로 다룬다.
3106
3256
  */
3107
- function productImageRejection(url) {
3108
- if (url.length > 2048) return {
3109
- code: "INVALID_IMAGE_URL",
3110
- message: "이미지 주소가 너무 깁니다"
3111
- };
3112
- let parsed;
3113
- try {
3114
- parsed = new URL(url);
3115
- } catch {
3116
- return {
3117
- code: "INVALID_IMAGE_URL",
3118
- message: "이미지 주소 형식이 아닙니다"
3119
- };
3120
- }
3121
- if (parsed.protocol !== "https:") return {
3122
- code: "INVALID_IMAGE_URL",
3123
- message: "이미지 주소는 https로 시작해야 합니다"
3124
- };
3125
- const extension = uploadExtensionOf(parsed.pathname);
3126
- const allowed = UPLOAD_PURPOSE_LIMITS["product-image"].extensions;
3127
- if (extension && !allowed.includes(extension)) return {
3128
- code: "UNSUPPORTED_FILE_TYPE",
3129
- message: `상품 이미지는 ${allowed.join(", ")}만 쓸 수 있습니다`
3130
- };
3131
- return null;
3132
- }
3257
+ const PAYMENT_ROUTING_EXCLUSION_CODES = [
3258
+ "NOT_CONNECTED",
3259
+ "DISABLED",
3260
+ "CREDENTIAL_MISSING",
3261
+ "SANDBOX_IN_LIVE_STORE",
3262
+ "PLATFORM_BLOCKED",
3263
+ "MODE_MISMATCH"
3264
+ ];
3133
3265
  /**
3134
- * 식별 코드 — 빈 문자열·공백만 있는 값은 거부한다.
3135
- * `""`를 통과시키면 서버 중복 사전 검사(falsy skip)와 DB partial unique
3136
- * (`WHERE ... is not null`)의 판정이 갈려 409 대신 500이 나간다.
3266
+ * 결제수단 반복 실패 경고(#86 후속) — 같은 PG·결제수단(·간편결제사)이 가맹점 설정 문제(구매자가 고칠 수 없는 실패, 예: KG이니시스
3267
+ * `V105 [NO MPI SET] 전용화면 미설정`)로 기간 안에 여러 번 실패했다. 구매자에게는 「이 결제수단은 지금 사용할 수 없습니다」가 보이고,
3268
+ * PG 관리자에서 그 결제수단의 전용 화면·채널을 설정해야 다시 쓸 수 있다
3137
3269
  */
3138
- const codeSchema = z.string().trim().min(1).max(64);
3139
- const optionValueRequestSchema = z.object({
3140
- /** 전달 시 기존 값 행을 유지(UPDATE). 생략하면 신규 발급 */
3141
- valueId: z.string().optional(),
3142
- name: z.string().min(1).max(50)
3270
+ const paymentFailureWarningSchema = z.object({
3271
+ provider: z.string().describe("PG(`tosspayments`·`portone`). 값이 추가될 수 있어 문자열로 받는다"),
3272
+ method: z.string().describe("결제수단(`CARD`·`BANK_TRANSFER`·`VIRTUAL_ACCOUNT`·`MOBILE`·`EASY_PAY`)"),
3273
+ easyPayProvider: easyPayProviderResponseSchema.nullable().describe("간편결제사. 간편결제가 아니면 null"),
3274
+ code: z.string().nullable().describe("PG 실패 코드(예: `V105`, `AllChannelsNotSatisfied`). PG 원문 문구로만 판정했으면 null"),
3275
+ count: z.number().int().describe("기간 안의 실패 횟수"),
3276
+ lastAt: z.string().describe("마지막 실패 시각"),
3277
+ message: z.string().nullable().describe("마지막 실패의 PG 원문 사유. 구매자에게는 보이지 않는다")
3143
3278
  });
3144
- const optionGroupRequestSchema = z.object({
3145
- /** 전달 시 기존 축 행을 유지(UPDATE). 생략하면 신규 발급 */
3146
- groupId: z.string().optional(),
3147
- name: z.string().min(1).max(50),
3148
- values: z.array(optionValueRequestSchema).min(1).max(100)
3279
+ const paymentSettingsSchema = z.object({
3280
+ checkoutOrigins: z.array(z.string()).describe("결제 도메인 — 결제를 마친 구매자가 돌아올 스토어프론트 origin 목록"),
3281
+ providers: z.array(paymentProviderSchema).describe("지원하는 모든 PG. 연결된 것은 우선순위순, 연결되지 않은 것은 뒤에 온다"),
3282
+ environment: pgEnvironmentSchema.nullable().optional().default(null).describe("지금 결제를 시작하면 쓰는 결제 환경. 사용 가능한 PG 중 하나라도 라이브면 `LIVE`(샌드박스 PG는 제외), 아니면 `TEST`. 사용 가능한 PG가 없으면 null. 서버는 항상 싣는다. 이 필드가 없는 이전 서버 응답은 기본값(null)으로 읽는다"),
3283
+ candidates: z.array(z.string()).optional().default(() => []).describe("지금 결제를 시작하면 쓰는 PG, 우선순위순(`tosspayments`·`portone`). 비어 있으면 구매자가 결제할 수 없다. 빠진 PG의 사유는 `providers[].routingExclusion` — 후보가 아닌 PG에는 항상 사유 코드를 붙인다. 서버는 항상 싣는다. 이 필드가 없는 이전 서버 응답은 기본값(빈 배열)으로 읽는다"),
3284
+ warnings: z.array(paymentFailureWarningSchema).optional().default(() => []).describe("최근 24시간 안에 같은 PG·결제수단·간편결제사가 가맹점 설정 문제로 3번 이상 실패한 조합, 마지막 실패가 최근인 순. 판정 규칙은 서버가 정하고 바뀔 수 있다. 서버는 항상 싣는다. 이 필드가 없는 이전 서버 응답은 기본값(빈 배열)으로 읽는다")
3285
+ });
3286
+ const paymentProviderVerificationSchema = z.object({
3287
+ provider: pgProviderSchema,
3288
+ environment: pgEnvironmentSchema.describe("연결 테스트한 자격 증명 세트"),
3289
+ result: pgVerificationResultSchema,
3290
+ message: z.string().nullable().describe("실패 사유 또는 참고 메시지"),
3291
+ checkedAt: z.string()
3292
+ });
3293
+ const salesReportGroupBySchema = z.enum([
3294
+ "DAY",
3295
+ "PROVIDER",
3296
+ "METHOD",
3297
+ "PROVIDER_METHOD"
3298
+ ]).describe("행 집계 기준. 기본 `DAY`");
3299
+ const reportDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "날짜 형식은 YYYY-MM-DD 입니다");
3300
+ const salesReportQuerySchema = z.object({
3301
+ from: reportDate.describe("시작일 (YYYY-MM-DD, Asia/Seoul 기준, 포함)"),
3302
+ to: reportDate.describe("종료일 (YYYY-MM-DD, 포함). from부터 최대 92일"),
3303
+ provider: pgProviderSchema.optional().describe("PG 필터"),
3304
+ method: pgPaymentMethodSchema.optional().describe("결제수단 필터 — **실제 결제수단**(PG가 보고한 수단, 모르면 요청 수단) 기준. 결제창에서 결제수단이 바뀔 수 있다(카드 결제창의 간편결제 탭 등). 관리 주문 API의 method는 호환용이며 간편결제를 CARD로 표시한다. 실제 수단은 approvedMethod를 본다"),
3305
+ groupBy: salesReportGroupBySchema.optional(),
3306
+ includeTest: z.enum(["true", "false"]).optional().describe("테스트 결제(샌드박스 결제) 포함 여부. 기본 `false` — 테스트 결제의 승인·환불은 집계에서 빠진다")
3149
3307
  });
3150
- const productVariantRequestSchema = z.object({
3151
- /** 기존 variant 유지 시 전달. 생략하면 신규 생성 */
3152
- variantId: z.string().optional(),
3153
- /** optionGroups 순서대로 각 축에서 고른 값 이름. 서버가 이름 → valueId를 해석한다 */
3154
- valueNames: z.array(z.string()).default([]),
3155
- additionalPrice: z.number().int().default(0),
3156
- /**
3157
- * 생략 = 기존 재고 유지 (신규 variant는 0).
3158
- * 구조 교체(PUT)가 폼 로드 시점의 재고를 되쓰지 않게 하는 옵트인 필드다 —
3159
- * 재고만 바꿀 때는 PATCH /products/{id}/variants/{variantId}/stock을 쓴다.
3160
- */
3161
- stockQuantity: z.number().int().min(0).optional(),
3162
- sku: codeSchema.optional(),
3163
- barcode: codeSchema.optional(),
3164
- costPrice: z.number().int().min(0).optional(),
3165
- weightGram: z.number().int().min(0).optional(),
3166
- sellerCode: codeSchema.optional(),
3167
- usable: z.boolean().default(true)
3308
+ const salesTotalsSchema = z.object({
3309
+ paymentCount: z.number().int().describe("승인 건수"),
3310
+ paymentAmount: z.number().int().describe("승인 금액 합계"),
3311
+ refundCount: z.number().int().describe("환불(취소) 건수. 부분 취소도 1건"),
3312
+ refundAmount: z.number().int().describe("환불 금액 합계"),
3313
+ netAmount: z.number().int().describe("순매출 = 승인 금액 - 환불 금액"),
3314
+ pgFeeAmount: z.number().int().nullable().describe("PG 수수료 (참고용). PG에서 확정 값을 받지 못하면 null")
3168
3315
  });
3169
- /** 축 × 값 → variant 전체 교체 요청 (PUT /products/{productId}/options) */
3170
- const optionModelRequestSchema = z.object({
3171
- optionGroups: z.array(optionGroupRequestSchema).max(3).default([]),
3172
- variants: z.array(productVariantRequestSchema).min(1).max(500),
3173
- addonGroups: z.array(addonGroupRequestSchema).max(5).optional().describe("추가 선택 옵션명 전체(이슈 #59). 주면 통째로 바꾸고, 생략하면 등록은 없음·수정은 현재 값을 그대로 둔다. 끄려면 빈 배열을 보낸다. 기능이 열리기 전에는 비어 있지 않은 값이 409 `OPTION_ADDONS_UNAVAILABLE`이다"),
3174
- customInputs: z.array(customInputRequestSchema).max(5).optional().describe("직접 입력 항목 전체(이슈 #59). 배열 순서가 표시 순서다. 주면 통째로 바꾸고, 생략하면 등록은 없음·수정은 현재 값을 그대로 둔다. 항목명에 개인정보성 단어(`CUSTOM_INPUT_FORBIDDEN_WORDS`)가 있으면 400 `CUSTOM_INPUT_LABEL_FORBIDDEN`, 기능이 열리기 전에는 비어 있지 않은 값이 409 `OPTION_ADDONS_UNAVAILABLE`이다"),
3175
- optionSort: optionSortSchema.optional().describe("옵션 값 정렬 순서(이슈 #59). 서버가 이 순서로 값의 `sortOrder`를 다시 매긴다. 생략하면 등록은 `REGISTERED`, 수정은 현재 값이다")
3316
+ const salesReportRowSchema = salesTotalsSchema.extend({
3317
+ date: z.string().nullable().describe("일자 (YYYY-MM-DD, Asia/Seoul). groupBy=DAY일 때만 값"),
3318
+ provider: pgProviderSchema.nullable().describe("PG. groupBy에 PROVIDER가 포함될 때만 값"),
3319
+ method: pgPaymentMethodSchema.nullable().describe("실제 결제수단(PG가 보고한 수단, 모르면 요청 수단). groupBy에 METHOD가 포함될 때만 값. 관리 주문 API의 method는 호환용이며 간편결제를 CARD로 표시한다. 실제 수단은 approvedMethod를 본다")
3176
3320
  });
3177
- /** GET /products/{productId}/options 응답 */
3178
- const optionModelSchema = z.object({
3179
- optionGroups: z.array(optionGroupSchema),
3180
- variants: z.array(productVariantSchema),
3181
- addonGroups: z.array(addonGroupSchema).optional().default([]).describe("추가 선택 옵션명(이슈 #59). 없으면 빈 배열. 서버는 항상 싣는다"),
3182
- customInputs: z.array(customInputSchema).optional().default([]).describe("직접 입력 항목(이슈 #59). 없으면 빈 배열. 서버는 항상 싣는다"),
3183
- optionSort: optionSortSchema.optional().default("REGISTERED").describe("옵션 값 정렬 순서(이슈 #59). 서버는 항상 싣는다")
3321
+ const salesReportSchema = z.object({
3322
+ from: z.string(),
3323
+ to: z.string(),
3324
+ timezone: z.literal("Asia/Seoul"),
3325
+ currency: z.literal("KRW"),
3326
+ groupBy: salesReportGroupBySchema,
3327
+ provider: pgProviderSchema.nullable().describe("적용된 PG 필터"),
3328
+ method: pgPaymentMethodSchema.nullable().describe("적용된 결제수단 필터"),
3329
+ includeTest: z.boolean().describe("테스트 결제(샌드박스 결제)를 집계에 포함했는가"),
3330
+ summary: salesTotalsSchema.describe("기간 전체 합계"),
3331
+ rows: z.array(salesReportRowSchema).describe("집계 행. 활동이 없는 날짜·PG는 생략된다")
3184
3332
  });
3185
3333
  /**
3186
- * 재고 변경 택일 위반 문구 — SDK `.refine`과 서버 DomainException이 공유하는 단일 원천.
3187
- * 경로에 따라 셀러가 다른 문구를 받지 않게 한다.
3334
+ * 결제 내역 — 결제 건(세션) 단위 공개 상태. 주문 단위 상태와 다르다.
3335
+ * - `APPROVED`: 승인됐고 환불이 없다
3336
+ * - `PARTIALLY_REFUNDED`·`REFUNDED`: 승인 뒤 일부·전액을 환불했다
3337
+ * - `PENDING`: 승인 결과를 아직 확정하지 못했다(대사 잡이 확정한다)
3338
+ * - `FAILED`: 승인되지 않고 끝났다(거절·준비 장애·구매자 취소·만료)
3188
3339
  */
3189
- const VARIANT_STOCK_EXCLUSIVE_MESSAGE = "stockQuantity 또는 adjustment 중 하나만 전달해야 합니다";
3340
+ const paymentRecordStatusSchema = z.enum([
3341
+ "APPROVED",
3342
+ "PARTIALLY_REFUNDED",
3343
+ "REFUNDED",
3344
+ "PENDING",
3345
+ "FAILED"
3346
+ ]);
3347
+ const paymentAttemptStatusSchema = z.string().describe("시도 상태 — ready·approving·approved·unknown·declined·prepare_failed·canceled·failed·abandoned·voided. 새 값이 추가될 수 있으니 모르는 값은 '진행 중이 아닌 시도'로 다룬다");
3190
3348
  /**
3191
- * variant 재고 변경 요청 — 절대값(`stockQuantity`) 또는 증감(`adjustment`) 택일.
3192
- * 서버도 같은 규칙을 400 VALIDATION_FAILED로 검증한다(이중 방어).
3349
+ * 현금영수증 상태 — 값이 추가될 수 있어 응답은 문자열로 받는다. 화면 분기는 이 목록으로 하고 모르는 값은 처리 중으로 다룬다.
3350
+ * - `REQUESTED`: 구매자가 신청했고 결제 승인 전이다
3351
+ * - `PENDING`: 발급·취소를 기다린다(승인 직후, 또는 일시 오류로 자동 재시도 대기)
3352
+ * - `ISSUED`: 발급됐다. 부분 환불이 있었으면 그만큼 취소(또는 재발급)된 금액이다
3353
+ * - `CANCELED`: 전액 환불로 취소됐다
3354
+ * - `FAILED`: 발급·취소가 실패했고 자동 재시도가 끝났다. `failCode`·`failMessage`를 보고 재발급한다
3355
+ * - `SKIPPED`: 발급 대상이 아니다(승인된 결제수단이 계좌이체·가상계좌가 아님)
3193
3356
  */
3194
- const variantStockRequestSchema = z.object({
3195
- stockQuantity: z.number().int().min(0).optional(),
3196
- adjustment: z.number().int().optional()
3197
- }).refine((v) => v.stockQuantity !== void 0 !== (v.adjustment !== void 0), { message: VARIANT_STOCK_EXCLUSIVE_MESSAGE });
3198
- /** PATCH /products/{productId}/variants/{variantId}/stock 응답 */
3199
- const variantStockSchema = z.object({
3200
- variantId: z.string(),
3201
- stockQuantity: z.number().int()
3357
+ const CASH_RECEIPT_STATUS_VALUES = [
3358
+ "REQUESTED",
3359
+ "PENDING",
3360
+ "ISSUED",
3361
+ "CANCELED",
3362
+ "FAILED",
3363
+ "SKIPPED"
3364
+ ];
3365
+ const cashReceiptSchema = z.object({
3366
+ cashReceiptId: z.string(),
3367
+ status: z.string().describe("상태. `REQUESTED` 결제 전 · `PENDING` 발급 대기 · `ISSUED` 발급 · `CANCELED` 취소 · `FAILED` 실패 · `SKIPPED` 발급 대상 아님. 값이 추가될 수 있다"),
3368
+ type: z.string().describe("용도. `INCOME_DEDUCTION` 소득공제 · `EXPENSE_PROOF` 지출증빙"),
3369
+ identityType: z.string().describe("식별 번호 종류. `PHONE` 휴대폰 · `CARD` 현금영수증카드 · `BUSINESS` 사업자등록번호"),
3370
+ identityNumberMasked: z.string().describe("가린 식별 번호 (예: 010****5678). 원문은 조회할 수 없다"),
3371
+ amount: z.number().int().describe("발급해야 할 금액 = 결제 금액 − 환불 완료 금액. 승인 전에는 결제 금액"),
3372
+ issuedAmount: z.number().int().describe("PG에 현재 발급돼 있는 금액. 취소됐거나 발급 전이면 0"),
3373
+ provider: pgProviderSchema.nullable().describe("발급 PG — 결제를 승인한 PG. 승인 전에는 null"),
3374
+ issueNumber: z.string().nullable().describe("현금영수증 승인 번호"),
3375
+ receiptUrl: z.string().nullable().describe("현금영수증 확인 주소"),
3376
+ issuedAt: z.string().nullable().describe("최근 발급 시각 (ISO 8601)"),
3377
+ canceledAt: z.string().nullable().describe("최근 취소 시각 (ISO 8601)"),
3378
+ failCode: z.string().nullable().describe("마지막 실패 코드. 성공하면 비운다"),
3379
+ failMessage: z.string().nullable().describe("마지막 실패 사유"),
3380
+ reissuable: z.boolean().describe("상점 플랫폼 재발급으로 다시 시도할 수 있는가 (`FAILED`·`PENDING`)"),
3381
+ requestedAt: z.string().describe("신청 시각 (ISO 8601)"),
3382
+ updatedAt: z.string().describe("마지막 변경 시각 (ISO 8601)")
3202
3383
  });
3203
- /** 상품 쓰기 요청의 이미지 목록 — 등록·전체 수정·부분 수정이 같은 항목 형식을 쓴다. 첫 장이 대표(썸네일)다 */
3204
- const productImagesRequestSchema = z.array(productImageUrlSchema).min(1).max(10);
3205
- /**
3206
- * 상품 쓰기 요청의 공통 필드 — 등록(POST)과 전체 수정(PUT)이 같다. 이행(`fulfillment`)만 두 요청의 스키마가 다르다.
3207
- *
3208
- * 최상위가 `strictObject`다. 모르는 필드는 400이다 — 이슈 #57 이전의 평면 필드(`deliveryFee`·`fulfillmentType`·`returnFee` 등)를
3209
- * 보낸 요청이 조용히 무시되지 않게 한다. 옛 필드는 `fulfillment.{유형}.…`과 `refundPolicy`로 옮겼다.
3210
- */
3211
- const productWriteShape = {
3212
- name: z.string().min(1).max(100),
3213
- categoryId: z.string().min(1, "카테고리를 선택해주십시오"),
3214
- salePrice: z.number().int().min(10).multipleOf(10, "판매가는 10원 단위여야 합니다 (INVALID_PRICE_UNIT)").describe("판매가. 원(KRW) 단위 정수, 최소 10원, 10원 단위"),
3215
- originalPrice: z.number().int().optional().describe("정가(할인 전 표시 가격). 원 단위. 비교용 표시 값이고 결제 금액은 판매가(즉시할인이 있으면 할인 적용가)다. 스토어프론트가 판매가보다 클 때 취소선·할인율로 보인다. 즉시할인은 `PUT /v1/products/{productId}/discount`로 따로 정한다"),
3216
- description: z.string().optional(),
3217
- /**
3218
- * 상세설명 에디터(@avarlabs/editor)의 직렬화 상태. 재편집 때 이 값으로 문서를 복원한다.
3219
- * 표시는 `description`(HTML)이 맡는다 — 소비자는 이 필드를 몰라도 된다.
3220
- */
3221
- descriptionJson: z.string().nullable().optional(),
3222
- images: productImagesRequestSchema.optional().describe("상품 이미지 주소 목록. 첫 장이 대표 이미지(썸네일)다"),
3223
- attributes: z.array(z.object({
3224
- attributeId: z.string(),
3225
- value: z.string()
3226
- })).optional(),
3227
- refundPolicy: productRefundPolicyPatchSchema.nullish().describe("상품별 환불 정책 — 준 항목만 상점 기본을 덮어쓴다. `null`이나 생략은 상점 기본을 그대로 쓴다는 뜻이다. 반품·교환 배송비(`returnDeliveryFee`·`exchangeDeliveryFee`)는 배송 상품에만 줄 수 있다(다른 유형은 400)"),
3228
- ...optionModelRequestSchema.shape,
3229
- sourceOptionSetIds: z.array(z.string()).max(10).optional().describe("쓰기 전용(이슈 #59) — 이 저장으로 옵션을 불러온 저장 옵션 id. 상품 저장과 같은 트랜잭션에서 불러오기 기록을 남겨 저장 옵션의 `importedProductCount`를 센다. 없는 id는 무시한다")
3230
- };
3231
- const productCreateRequestSchema = z.strictObject({
3232
- ...productWriteShape,
3233
- fulfillment: productFulfillmentCreateSchema.optional().describe("상품 이행. `type`이 유형이고 같은 이름의 키가 유형별 속성이다. 생략하면 배송 상품(기본값)이다. 배송 상품이 아닌 유형은 판매가 열린 유형만 등록할 수 있다(409 `FULFILLMENT_TYPE_NOT_AVAILABLE`)")
3384
+ const paymentRecordSchema = z.object({
3385
+ paymentId: z.string().describe("결제 건 id. 상세 조회 키"),
3386
+ orderId: z.string().nullable().describe("승인돼 만들어진 주문. 승인 전에 끝난 결제는 null"),
3387
+ orderName: z.string().describe("주문명 (예: 무선 이어폰 프로 외 1건)"),
3388
+ customerName: z.string().describe("구매자명"),
3389
+ status: paymentRecordStatusSchema,
3390
+ amount: z.number().int().describe("결제 금액"),
3391
+ refundedAmount: z.number().int().describe("환불 완료 금액 합계. 실패한 환불은 빼고 센다"),
3392
+ provider: pgProviderSchema.nullable().describe("승인한 PG. 승인 전에 끝났으면 마지막 시도의 PG"),
3393
+ method: pgPaymentMethodSchema.nullable().describe("실제 결제수단. 모르면 요청 수단"),
3394
+ easyPayProvider: easyPayProviderResponseSchema.nullable().optional().default(null).describe("간편결제사. `method`가 `EASY_PAY`일 때만 값이 있고, PG가 알려 주지 않았으면 요청한 간편결제사다. 그 밖의 결제수단과 간편결제 이전 결제는 null. 서버는 항상 싣는다. 현재 값은 `NAVERPAY`·`KAKAOPAY`·`TOSSPAY`·`PAYCO`이고 값이 추가될 수 있어 문자열로 받는다"),
3395
+ environment: pgEnvironmentSchema.nullable().describe("결제 환경"),
3396
+ testPayment: z.boolean().describe("샌드박스 결제인가"),
3397
+ pgTransactionId: z.string().nullable().describe("PG 거래 id (토스 paymentKey, 포트원 txId)"),
3398
+ attemptId: z.string().nullable().describe("PG에 보낸 주문번호 = 시도 id"),
3399
+ attemptCount: z.number().int().describe("시도 횟수"),
3400
+ failCode: z.string().nullable().describe("실패 코드. 실패로 끝난 결제만"),
3401
+ failMessage: z.string().nullable().describe("실패 사유"),
3402
+ paidAt: z.string().nullable().describe("승인 시각 (ISO 8601)"),
3403
+ createdAt: z.string().describe("결제 시작 시각 (ISO 8601)"),
3404
+ cashReceiptStatus: z.string().nullable().optional().default(null).describe("현금영수증 상태(`cashReceiptSchema.status`와 같은 값). 신청이 없으면 null. 서버는 항상 싣는다")
3234
3405
  });
3235
- const fulfillmentUpdateDescription = "상품 이행. 생략하면 바꾸지 않는다. 주면 유형 안 필드를 모두 담아 통째로 바꾼다(현재 값과 병합하지 않는다). 판매 이력이 있으면 유형을 바꿀 수 없다(409 `FULFILLMENT_TYPE_LOCKED`)";
3236
- /** 전체 수정(PUT) — 공통 필드는 등록과 같고, 이행은 생략하면 현재 값 유지, 주면 필드가 모두 필수다 */
3237
- const productReplaceRequestSchema = z.strictObject({
3238
- ...productWriteShape,
3239
- fulfillment: productFulfillmentUpdateSchema.optional().describe(fulfillmentUpdateDescription)
3406
+ const paymentRecordPageSchema = pageSchema(paymentRecordSchema);
3407
+ const paymentAttemptRecordSchema = z.object({
3408
+ attemptId: z.string(),
3409
+ sequence: z.number().int().describe("결제 건 안에서의 순번 (1부터)"),
3410
+ provider: pgProviderSchema,
3411
+ status: paymentAttemptStatusSchema,
3412
+ method: pgPaymentMethodSchema.describe("주문서에서 고른 결제수단"),
3413
+ easyPayProvider: easyPayProviderResponseSchema.nullable().optional().default(null).describe("주문서에서 고른 간편결제사. 간편결제가 아니면 null. 서버는 항상 싣는다(값이 추가될 수 있어 문자열)"),
3414
+ approvedMethod: pgPaymentMethodSchema.nullable().describe("PG가 보고한 실제 결제수단"),
3415
+ approvedEasyPayProvider: easyPayProviderResponseSchema.nullable().optional().default(null).describe("PG가 보고한 실제 간편결제사. 모르거나 간편결제가 아니면 null. 서버는 항상 싣는다(값이 추가될 수 있어 문자열)"),
3416
+ amount: z.number().int(),
3417
+ environment: pgEnvironmentSchema,
3418
+ pgTransactionId: z.string().nullable(),
3419
+ failCode: z.string().nullable(),
3420
+ failMessage: z.string().nullable(),
3421
+ createdAt: z.string(),
3422
+ updatedAt: z.string()
3240
3423
  });
3241
- const productPatchRequestSchema = z.strictObject({
3242
- name: z.string().max(100).optional(),
3243
- salePrice: z.number().int().multipleOf(10).optional(),
3244
- status: productStatusSchema.optional(),
3245
- images: productImagesRequestSchema.optional().describe("상품 이미지 주소 목록. 주면 통째로 바꾼다(현재 목록과 병합하지 않는다). 첫 장이 대표 이미지(썸네일)다. 생략하면 바꾸지 않는다"),
3246
- fulfillment: productFulfillmentUpdateSchema.optional().describe(fulfillmentUpdateDescription),
3247
- refundPolicy: productRefundPolicyPatchSchema.nullable().optional().describe("상품별 환불 정책. 주면 통째로 바꾼다(준 항목만 상점 기본을 덮어쓴다). `null`은 덮어쓰기를 지워 상점 기본을 따른다. 생략하면 바꾸지 않는다")
3248
- }).refine((value) => Object.values(value).some((v) => v !== void 0), { message: "최소 1개 필드가 필요합니다" });
3249
- const productSortSchema = z.enum([
3250
- "createdAt.desc",
3251
- "createdAt.asc",
3252
- "salePrice.asc",
3253
- "salePrice.desc",
3254
- "updatedAt.desc"
3255
- ]);
3256
- /** `GET /v1/products` 쿼리 — OpenAPI 문서용 형태(값은 쿼리 문자열) */
3257
- const productListQuerySchema = z.object({
3258
- status: productStatusSchema.optional().describe("판매 상태 필터"),
3259
- categoryId: z.string().optional().describe("카테고리 필터 — 하위 카테고리의 상품도 포함"),
3260
- keyword: z.string().optional().describe("상품명 검색 (포함)"),
3261
- sellerCode: z.string().optional().describe("판매자 상품코드 — 이 코드가 붙은 variant를 가진 상품"),
3262
- sort: productSortSchema.optional().describe("정렬 (기본 createdAt.desc)"),
3263
- ...pageQueryShape()
3424
+ const paymentRefundRecordSchema = z.object({
3425
+ refundId: z.string(),
3426
+ amount: z.number().int(),
3427
+ reason: z.string().describe("환불 사유 코드"),
3428
+ kind: z.string().describe("환불 원인 — SELLER_CANCEL·CLAIM·COMPENSATION·DUPLICATE. 새 값이 추가될 수 있다"),
3429
+ status: z.string().describe("SUCCEEDED·FAILED"),
3430
+ failMessage: z.string().nullable(),
3431
+ requestedAt: z.string(),
3432
+ completedAt: z.string().nullable()
3264
3433
  });
3265
- const discountRequestSchema = z.object({
3266
- discountType: z.enum(["RATE", "AMOUNT"]),
3267
- discountValue: z.number().int().min(1),
3268
- startAt: z.string().optional(),
3269
- endAt: z.string().optional()
3434
+ const paymentRecordDetailSchema = paymentRecordSchema.extend({
3435
+ refundableAmount: z.number().int().describe("환불 가능 잔액 = 승인 금액 - 환불 완료 금액"),
3436
+ merchantId: z.string().nullable().describe("가맹점 식별자 지문 (토스 clientKey, 포트원 Store ID). 비밀 값은 없다"),
3437
+ attempts: z.array(paymentAttemptRecordSchema).describe("시도 이력. 순번 오름차순"),
3438
+ refunds: z.array(paymentRefundRecordSchema).describe("환불 이력. 실패한 요청도 남는다"),
3439
+ cashReceipt: cashReceiptSchema.nullable().optional().default(null).describe("현금영수증. 구매자가 신청하지 않았으면 null. 서버는 항상 싣는다")
3440
+ });
3441
+ /** `GET /v1/payments` 쿼리 — OpenAPI 문서용 형태(값은 쿼리 문자열) */
3442
+ const paymentListQuerySchema = z.object({
3443
+ from: z.string().optional().describe("조회 시작 시각 (ISO 8601, 포함). 생략하면 최근 30일"),
3444
+ to: z.string().optional().describe("조회 끝 시각 (ISO 8601, 포함). 기간은 최대 92일"),
3445
+ status: z.string().optional().describe("결제 상태 필터 (PaymentRecordStatus)"),
3446
+ provider: z.string().optional().describe("PG 필터"),
3447
+ method: z.string().optional().describe("실제 결제수단 필터"),
3448
+ keyword: z.string().optional().describe("결제 id·주문번호·PG 거래 id·시도 id(일치), 구매자명(포함)"),
3449
+ includeTest: z.string().optional().describe("테스트 결제 포함 여부 (`true`·`false`. 기본 false — 실결제만)"),
3450
+ page: z.string().optional().describe("페이지 번호 (1부터)"),
3451
+ size: z.string().optional().describe("페이지 크기 (기본 20, 최대 100)")
3270
3452
  });
3271
3453
  //#endregion
3272
3454
  //#region src/schemas/order.ts
@@ -5561,6 +5743,25 @@ function createStoreClient(options) {
5561
5743
  /** 상품의 현재 옵션을 저장 옵션으로 만든다(또는 `replaceOptionSetId`를 덮어쓴다) */
5562
5744
  saveFromProduct: (body) => storeHttp.request("POST", "/saved-option-sets/from-product", savedOptionSetFromProductResultSchema, { body: savedOptionSetFromProductRequestSchema.parse(body) })
5563
5745
  },
5746
+ /**
5747
+ * 컬렉션 (이슈 #83) — 상품을 어디에 보여 줄지 정하는 묶음(다대다). 권한은 카테고리와 같다(`category:r`·`category:rw`).
5748
+ * 쓰기마다 `revision`이 오른다. 수정 본문에 읽은 `revision`을 주면 그 사이 다른 수정이 있을 때 409 `COLLECTION_CHANGED`다.
5749
+ */
5750
+ collections: {
5751
+ list: (params) => storeHttp.request("GET", "/collections", collectionPageSchema, { query: params }),
5752
+ get: (collectionId) => storeHttp.request("GET", path`/collections/${collectionId}`, collectionSchema),
5753
+ /** 주소가 겹치면 409 `COLLECTION_SLUG_TAKEN`, 상점당 200개를 넘으면 409 `COLLECTION_LIMIT_EXCEEDED` */
5754
+ create: (body) => storeHttp.request("POST", "/collections", collectionSchema, { body: collectionCreateRequestSchema.parse(body) }),
5755
+ update: (collectionId, body) => storeHttp.request("PATCH", path`/collections/${collectionId}`, collectionSchema, { body: collectionPatchRequestSchema.parse(body) }),
5756
+ remove: (collectionId) => storeHttp.requestVoid("DELETE", path`/collections/${collectionId}`),
5757
+ /** 담긴 상품 — 직접 정한 순서 */
5758
+ listProducts: (collectionId, params) => storeHttp.request("GET", path`/collections/${collectionId}/products`, pageSchema(collectionProductSchema), { query: params }),
5759
+ /** 담긴 상품을 통째로 바꾼다(순서 포함) */
5760
+ replaceProducts: (collectionId, body) => storeHttp.request("PUT", path`/collections/${collectionId}/products`, collectionSchema, { body: collectionProductsReplaceRequestSchema.parse(body) }),
5761
+ /** 뒤에 붙인다. 이미 담긴 상품은 그 자리에 둔다 */
5762
+ addProducts: (collectionId, body) => storeHttp.request("POST", path`/collections/${collectionId}/products`, collectionSchema, { body: collectionProductsAddRequestSchema.parse(body) }),
5763
+ removeProduct: (collectionId, productId) => storeHttp.requestVoid("DELETE", path`/collections/${collectionId}/products/${productId}`)
5764
+ },
5564
5765
  categories: {
5565
5766
  list: (params) => storeHttp.request("GET", "/categories", z.array(categorySchema), { query: params }),
5566
5767
  tree: () => storeHttp.request("GET", "/categories", z.array(categoryTreeNodeSchema), { query: { tree: true } }),
@@ -6642,4 +6843,4 @@ function createStoreSession(options) {
6642
6843
  };
6643
6844
  }
6644
6845
  //#endregion
6645
- export { APPROVAL_ALREADY_DECIDED_CODE, APPROVAL_REQUIRED_CODE, ApiError, ApprovalPendingError, BUSINESS_INFO_MAX_LENGTH, BUSINESS_NUMBER_PATTERN, CASH_RECEIPT_STATUS_VALUES, COUPON_CODE_PATTERN, COUPON_CONDITIONS_DEFAULTS, COUPON_TARGET_ALL, COUPON_TARGET_LIST_MAX, CUSTOM_INPUT_FORBIDDEN_WORDS, FULFILLMENT_RULES, MAX_ADDON_GROUPS, MAX_ANALYTICS_ALLOWED_ORIGINS, MAX_AUTO_TRANSITION_DAYS, MAX_CUSTOM_INPUTS, MAX_CUSTOM_INPUT_LENGTH, MAX_OPTION_GROUPS, MAX_PRODUCT_IMAGES, MAX_SAVED_OPTION_SETS, MAX_SAVED_OPTION_SET_NAME, 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, REFUND_POLICY_DEFAULTS, REFUND_POLICY_DELIVERY_FEE_KEYS, REMOTE_AREA_ZIP_RANGES, RESERVED_STOREFRONT_SLUGS, RESERVED_STORE_CODES, SHIPPING_POLICY_DEFAULTS, STOREFRONT_BLOCKED_PATH_SEGMENTS, STOREFRONT_BUILD_STAGES, STOREFRONT_BUILD_TERMINAL_STATUSES, STOREFRONT_CHECK_TERMINAL_STATUSES, STOREFRONT_CUSTOM_DOMAIN_LIMIT_DEFAULT, STOREFRONT_DOMAIN_DNS_GRACE_DAYS, STOREFRONT_DOMAIN_DNS_WAIT_DAYS, STOREFRONT_DOMAIN_TXT_LABEL, STOREFRONT_EDIT_ACTIVITY_TIMEOUT_MINUTES, STOREFRONT_EDIT_CAPACITY, STOREFRONT_EDIT_IDLE_MINUTES, STOREFRONT_EDIT_PREVIEW_SUFFIX, STOREFRONT_EDIT_START_STAGES, STOREFRONT_FILE_BATCH_MAX, STOREFRONT_FILE_MAX_BYTES, STOREFRONT_PATH_SEGMENT_PATTERN, STOREFRONT_PLATFORM_DOMAINS, STOREFRONT_PUBLIC_SUFFIXES, STOREFRONT_SEARCH_REGEX_MAX_LENGTH, STOREFRONT_SHOWCASE_SLUG_PREFIX, STOREFRONT_SITE_CREATE_LIMIT, STOREFRONT_SLUG_CHANGE_LIMIT, STOREFRONT_SLUG_CHANGE_WINDOW_DAYS, STOREFRONT_SLUG_HOLD_DAYS, STOREFRONT_SLUG_PATTERN, STOREFRONT_SOURCE_BLOB_BATCH_MAX, STOREFRONT_SOURCE_PART_BYTES, STOREFRONT_WORKSPACE_MAX_BYTES, STOREFRONT_WORKSPACE_MAX_FILES, 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_COUNT_WARNING, 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, addStorefrontDomainRequestSchema, addonGroupRequestSchema, addonGroupSchema, addonValueRequestSchema, addonValueSchema, 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, apiLogErrorSchema, apiLogListQuerySchema, apiLogPageSchema, apiLogPrincipalSchema, apiLogPrincipalTypeSchema, apiLogSchema, apiLogStatusFilterSchema, apiLogSummarySchema, approvalDecisionRequestSchema, approvalListQuerySchema, approvalPageSchema, approvalRequestSchema, approvalStatusSchema, approveClaimResultSchema, auditActorSchema, auditActorTypeSchema, auditLogListQuerySchema, auditLogOutcomeSchema, auditLogPageSchema, auditLogSchema, auditLogSourceSchema, buildAuthorizeUrl, buildQuery, bulkResultSchema, businessInfoPatchShape, businessInfoResponseShape, businessNumberSchema, cancelBySellerRequestSchema, cancelBySellerResultSchema, carrierSchema, cashReceiptSchema, categoryAttributeSchema, categoryCreateRequestSchema, categoryDeleteQuerySchema, categoryDeleteStrategySchema, categoryListQuerySchema, categoryPatchRequestSchema, categoryProductSortSchema, categorySchema, categorySearchResultSchema, categoryTreeNodeSchema, categoryWithPathSchema, changeStorefrontSlugRequestSchema, changeStorefrontTemplateRequestSchema, changeTypeSchema, claimDetailSchema, claimHoldResultSchema, claimListQuerySchema, claimReasonSchema, claimReturnAddressSchema, claimSchema, claimStatusSchema, claimTypeSchema, claimWebhookEventDataSchema, claimWebhookEventSchema, 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, createStorefrontEditPreviewLinkRequestSchema, createStorefrontPreviewLinkRequestSchema, createStorefrontSiteRequestSchema, createStorefrontVersionRequestSchema, customInputLabelForbiddenWord, customInputRequestSchema, customInputSchema, customInputValueSchema, customerAddressSchema, customerConsentListQuerySchema, customerConsentSchema, customerConsentStateSchema, customerConsentsSchema, customerCouponSchema, customerDelegationRequestSchema, customerDelegationSchema, customerDetailSchema, customerInquiryCategorySchema, customerInquiryListQuerySchema, customerListQuerySchema, customerOrderSchema, customerSortSchema, customerStatsSchema, customerSummarySchema, customerWithdrawalBlockedDetailsSchema, deleteStorefrontFileRequestSchema, deliveryBundleKeyOf, deliveryFeeWaiverSchema, deliveryInfoSchema, deliveryMethodSchema, deliveryStatusSchema, deliveryTypeSchema, disableStorefrontHostingRequestSchema, discountRequestSchema, discountSchema, dispatchRequestSchema, easyPayProviderResponseSchema, easyPayProviderSchema, eventTypeSchema, exchangeAuthorizationCode, fulfillBulkRequestSchema, fulfillRequestSchema, fulfillmentRuleOf, fulfillmentTypeOf, fulfillmentTypeResponseSchema, fulfillmentTypeSchema, holdOrderItemRequestSchema, inquiryListQuerySchema, inquirySchema, inquiryWebhookEventDataSchema, inquiryWebhookEventSchema, invitationDeliveryStatusSchema, invitationListQuerySchema, invitationSchema, inviteMemberRequestSchema, isCouponTargetLine, isEditableStorefrontPath, isReservedStoreCode, isReservedStorefrontSlug, isShowcaseStorefrontSlug, isStoreScope, isWebOrigin, issueCouponRequestSchema, issueCouponResultSchema, liveGateConditionCodeSchema, liveGateConditionSchema, liveGateErrorDetailsSchema, liveGateSchema, loginProviderSchema, loginProviderStatusSchema, loginProviderTestSchema, loginSettingsSchema, maskedSecretSchema, mcpRuleResultSchema, mcpToolEventSchema, moveStorefrontFileRequestSchema, myAccountSchema, myStoreSchema, normalizeBusinessNumber, normalizeCouponCode, normalizeStorefrontHostname, normalizeStorefrontPath, oauthTokenResponseSchema, openFulfillmentUnion, optionGroupKindSchema, optionGroupRequestSchema, optionGroupSchema, optionModelRequestSchema, optionModelSchema, optionSelectionSchema, optionSortSchema, optionValueRequestSchema, optionValueSchema, orderCanceledWebhookEventDataSchema, orderCanceledWebhookEventSchema, orderDiscountSchema, orderFulfilledWebhookEventDataSchema, orderFulfilledWebhookEventSchema, orderItemFulfillmentSchema, orderItemFulfillmentSnapshotResponseSchema, orderItemFulfillmentSnapshotSchema, orderItemListQuerySchema, orderItemMemoResultSchema, orderItemPageSchema, orderItemSchema, orderItemStatusSchema, orderListQuerySchema, orderSchema, orderSummaryPageSchema, orderSummarySchema, orderWebhookEventDataSchema, orderWebhookEventSchema, pageInfoSchema, pageQueryShape, pageSchema, paymentAttemptRecordSchema, paymentAttemptStatusSchema, paymentCredentialSetSchema, paymentFailureWarningSchema, paymentListQuerySchema, paymentProviderSchema, paymentProviderVerificationSchema, paymentRecordDetailSchema, paymentRecordPageSchema, paymentRecordSchema, paymentRecordStatusSchema, paymentRefundRecordSchema, paymentSettingsSchema, pgEnvironmentSchema, pgPaymentMethodSchema, pgProviderSchema, pgVerificationResultSchema, planCodeSchema, planEntitlementsSchema, planErrorDetailsSchema, policyDocumentUrlSchema, policyVersionSchema, portOneChannelSchema, portOneCredentialsInputSchema, productCreateRequestSchema, productFulfillmentCreateSchema, productFulfillmentResponseSchema, productFulfillmentSchema, productFulfillmentUpdateSchema, productImageRejection, productImageUrlSchema, productListQuerySchema, productOutOfStockWebhookEventDataSchema, productOutOfStockWebhookEventSchema, productPatchRequestSchema, productRefundPolicyOverride, productRefundPolicyPatchSchema, productReplaceRequestSchema, productSchema, productSortSchema, productStatusSchema, productSummarySchema, productVariantRequestSchema, productVariantSchema, proratedPaidRefundBase, proratedRefundBase, publicPlanSchema, publicPlansSchema, publishStorefrontVersionRequestSchema, purchaseDecisionExtendResultSchema, purchaseDecisionRequestResultSchema, quoteDelivery, quotePricing, refreshUserToken, refundPolicyPatchSchema, refundPolicySchema, rejectClaimRequestSchema, remoteAreaLabelOf, remoteAreaOf, remoteAreaSchema, remoteSurchargeOf, replaceStorefrontSourceRequestSchema, requiresCollection, requiresShipping, resolveRefundPolicy, resolveShippingPolicy, responsePlanCodeSchema, reviewListQuerySchema, reviewReportResultSchema, reviewSchema, reviewSortSchema, reviewWebhookEventDataSchema, reviewWebhookEventSchema, rollbackStorefrontRequestSchema, runStorefrontCheckRequestSchema, salesReportGroupBySchema, salesReportQuerySchema, salesReportRowSchema, salesReportSchema, salesTotalsSchema, saveLoginProviderRequestSchema, savedCustomInputRequestSchema, savedCustomInputSchema, savedOptionGroupRequestSchema, savedOptionGroupSchema, savedOptionSetCompositionSchema, savedOptionSetFromProductRequestSchema, savedOptionSetFromProductResultSchema, savedOptionSetListQuerySchema, savedOptionSetRequestSchema, savedOptionSetSchema, savedOptionSetSortSchema, savedOptionValueRequestSchema, savedOptionValueSchema, scopeAccessSchema, searchStorefrontFilesRequestSchema, sellerAddressListQuerySchema, sellerAddressRequestSchema, sellerAddressSchema, sellerCustomerInquirySchema, setStorefrontVisibilityRequestSchema, settleCouponShortfall, settlementDetailQuerySchema, settlementDetailSchema, settlementListQuerySchema, settlementStatusSchema, settlementSummarySchema, settlementWithDetailsSchema, shippingAddressSchema, shippingPolicyPatchSchema, shippingPolicySchema, storeCodeSchema, storeMemberRoleUpdatedSchema, storeMemberSchema, storeRoleSchema, storeSchema, storeScopeResourceSchema, storeSubscriptionSchema, storefrontBuildDependenciesSchema, storefrontBuildListQuerySchema, storefrontBuildSchema, storefrontBuildStatusSchema, storefrontCheckSchema, storefrontCheckStatusSchema, storefrontCustomDomainStatusSchema, storefrontCustomHostnameProblem, storefrontDependencyFindingSchema, storefrontDeploymentLogSchema, storefrontDeploymentSchema, storefrontDeploymentStepSchema, storefrontDnsRecordSchema, storefrontDomainSchema, storefrontDomainTxtNameOf, storefrontEditPreviewLabelOf, storefrontEditPreviewSchema, storefrontEditSessionSchema, storefrontEditSessionStatusSchema, storefrontEditingSchema, storefrontFileConflictDetailsSchema, storefrontFileContentQuerySchema, storefrontFileContentSchema, storefrontFileDeleteResultSchema, storefrontFileEncodingSchema, storefrontFileEntrySchema, storefrontFileHashSchema, storefrontFileListQuerySchema, storefrontFileListSchema, storefrontFileMoveResultSchema, storefrontFilePathSchema, storefrontFileSearchResultSchema, storefrontFileWriteResultSchema, storefrontHostingSchema, storefrontPreviewLinkSchema, storefrontProvisioningSchema, storefrontRuleResultSchema, storefrontRuleSeveritySchema, storefrontSiteDomainSchema, storefrontSiteDomainsSchema, storefrontSiteSchema, storefrontSiteStatusSchema, storefrontSlugAvailabilityQuerySchema, storefrontSlugAvailabilitySchema, storefrontSlugSchema, storefrontSourceArchivePartSchema, storefrontSourceArchiveQuerySchema, storefrontSourceBlobsResultSchema, storefrontSourceModeSchema, storefrontSourceRefSchema, storefrontSourceReplacedSchema, storefrontSourceSchema, storefrontSuspensionSchema, storefrontTemplateSchema, storefrontVerifyReportSchema, storefrontVersionCreatedSchema, storefrontVersionSchema, storefrontVersionStatusSchema, storefrontVisibilitySchema, subscriptionLimitsSchema, subscriptionStatusSchema, subscriptionUsageSchema, tossPaymentsCredentialsInputSchema, trackingStageResponseSchema, trackingUpdateResultSchema, updateCouponRequestSchema, updateLoginProviderRequestSchema, updateLoginSettingsRequestSchema, updateMemberRoleRequestSchema, updatePaymentProviderRequestSchema, updatePaymentSettingsRequestSchema, updateStoreRequestSchema, uploadExtensionOf, uploadPurposeSchema, uploadRejection, uploadRequestSchema, uploadStorefrontSourceBlobsRequestSchema, uploadTicketSchema, upsertPortOneProviderRequestSchema, upsertTossPaymentsProviderRequestSchema, variantStockRequestSchema, variantStockSchema, verifyPaymentProviderRequestSchema, webhookCreatedSchema, webhookDeliveryDetailSchema, webhookDeliveryErrorCodeSchema, webhookDeliveryListQuerySchema, webhookDeliveryResourceSchema, webhookDeliverySchema, webhookDeliveryStatusSchema, webhookEventSchema, webhookFailureReasonSchema, webhookRedeliverFailedResultSchema, webhookRequestHeaderSchema, webhookRequestSchema, webhookResourceTypeSchema, webhookSchema, webhookSecretRotatedSchema, webhookStatusSchema, webhookTestRequestSchema, webhookTestResultSchema, withdrawCustomerRequestSchema, writeDryRunResultSchema, writePreviewSchema, writeStorefrontFilesRequestSchema };
6846
+ export { APPROVAL_ALREADY_DECIDED_CODE, APPROVAL_REQUIRED_CODE, ApiError, ApprovalPendingError, BUSINESS_INFO_MAX_LENGTH, BUSINESS_NUMBER_PATTERN, CASH_RECEIPT_STATUS_VALUES, COLLECTION_PRODUCT_SORTS, COLLECTION_SLUG_PATTERN, COLLECTION_TYPES, COUPON_CODE_PATTERN, COUPON_CONDITIONS_DEFAULTS, COUPON_TARGET_ALL, COUPON_TARGET_LIST_MAX, CUSTOM_INPUT_FORBIDDEN_WORDS, FULFILLMENT_RULES, MAX_ADDON_GROUPS, MAX_ANALYTICS_ALLOWED_ORIGINS, MAX_AUTO_TRANSITION_DAYS, MAX_COLLECTIONS, MAX_COLLECTION_DESCRIPTION, MAX_COLLECTION_PRODUCTS, MAX_COLLECTION_PRODUCTS_PER_ADD, MAX_COLLECTION_TITLE, MAX_CUSTOM_INPUTS, MAX_CUSTOM_INPUT_LENGTH, MAX_OPTION_GROUPS, MAX_PRODUCT_IMAGES, MAX_SAVED_OPTION_SETS, MAX_SAVED_OPTION_SET_NAME, 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, REFUND_POLICY_DEFAULTS, REFUND_POLICY_DELIVERY_FEE_KEYS, REMOTE_AREA_ZIP_RANGES, RESERVED_STOREFRONT_SLUGS, RESERVED_STORE_CODES, SHIPPING_POLICY_DEFAULTS, STOREFRONT_BLOCKED_PATH_SEGMENTS, STOREFRONT_BUILD_STAGES, STOREFRONT_BUILD_TERMINAL_STATUSES, STOREFRONT_CHECK_TERMINAL_STATUSES, STOREFRONT_CUSTOM_DOMAIN_LIMIT_DEFAULT, STOREFRONT_DOMAIN_DNS_GRACE_DAYS, STOREFRONT_DOMAIN_DNS_WAIT_DAYS, STOREFRONT_DOMAIN_TXT_LABEL, STOREFRONT_EDIT_ACTIVITY_TIMEOUT_MINUTES, STOREFRONT_EDIT_CAPACITY, STOREFRONT_EDIT_IDLE_MINUTES, STOREFRONT_EDIT_PREVIEW_SUFFIX, STOREFRONT_EDIT_START_STAGES, STOREFRONT_FILE_BATCH_MAX, STOREFRONT_FILE_MAX_BYTES, STOREFRONT_PATH_SEGMENT_PATTERN, STOREFRONT_PLATFORM_DOMAINS, STOREFRONT_PUBLIC_SUFFIXES, STOREFRONT_SEARCH_REGEX_MAX_LENGTH, STOREFRONT_SHOWCASE_SLUG_PREFIX, STOREFRONT_SITE_CREATE_LIMIT, STOREFRONT_SLUG_CHANGE_LIMIT, STOREFRONT_SLUG_CHANGE_WINDOW_DAYS, STOREFRONT_SLUG_HOLD_DAYS, STOREFRONT_SLUG_PATTERN, STOREFRONT_SOURCE_BLOB_BATCH_MAX, STOREFRONT_SOURCE_PART_BYTES, STOREFRONT_WORKSPACE_MAX_BYTES, STOREFRONT_WORKSPACE_MAX_FILES, 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_COUNT_WARNING, 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, addStorefrontDomainRequestSchema, addonGroupRequestSchema, addonGroupSchema, addonValueRequestSchema, addonValueSchema, 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, apiLogErrorSchema, apiLogListQuerySchema, apiLogPageSchema, apiLogPrincipalSchema, apiLogPrincipalTypeSchema, apiLogSchema, apiLogStatusFilterSchema, apiLogSummarySchema, approvalDecisionRequestSchema, approvalListQuerySchema, approvalPageSchema, approvalRequestSchema, approvalStatusSchema, approveClaimResultSchema, auditActorSchema, auditActorTypeSchema, auditLogListQuerySchema, auditLogOutcomeSchema, auditLogPageSchema, auditLogSchema, auditLogSourceSchema, buildAuthorizeUrl, buildQuery, bulkResultSchema, businessInfoPatchShape, businessInfoResponseShape, businessNumberSchema, cancelBySellerRequestSchema, cancelBySellerResultSchema, carrierSchema, cashReceiptSchema, categoryAttributeSchema, categoryCreateRequestSchema, categoryDeleteQuerySchema, categoryDeleteStrategySchema, categoryListQuerySchema, categoryPatchRequestSchema, categoryProductSortSchema, categorySchema, categorySearchResultSchema, categoryTreeNodeSchema, categoryWithPathSchema, changeStorefrontSlugRequestSchema, changeStorefrontTemplateRequestSchema, changeTypeSchema, claimDetailSchema, claimHoldResultSchema, claimListQuerySchema, claimReasonSchema, claimReturnAddressSchema, claimSchema, claimStatusSchema, claimTypeSchema, claimWebhookEventDataSchema, claimWebhookEventSchema, collectionCreateRequestSchema, collectionListQuerySchema, collectionPageSchema, collectionPatchRequestSchema, collectionProductListQuerySchema, collectionProductSchema, collectionProductSortRequestSchema, collectionProductsAddRequestSchema, collectionProductsReplaceRequestSchema, collectionSchema, collectionStateCountsSchema, collectionStateOf, collectionStateSchema, 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, createStorefrontEditPreviewLinkRequestSchema, createStorefrontPreviewLinkRequestSchema, createStorefrontSiteRequestSchema, createStorefrontVersionRequestSchema, customInputLabelForbiddenWord, customInputRequestSchema, customInputSchema, customInputValueSchema, customerAddressSchema, customerConsentListQuerySchema, customerConsentSchema, customerConsentStateSchema, customerConsentsSchema, customerCouponSchema, customerDelegationRequestSchema, customerDelegationSchema, customerDetailSchema, customerInquiryCategorySchema, customerInquiryListQuerySchema, customerListQuerySchema, customerOrderSchema, customerSortSchema, customerStatsSchema, customerSummarySchema, customerWithdrawalBlockedDetailsSchema, deleteStorefrontFileRequestSchema, deliveryBundleKeyOf, deliveryFeeWaiverSchema, deliveryInfoSchema, deliveryMethodSchema, deliveryStatusSchema, deliveryTypeSchema, disableStorefrontHostingRequestSchema, discountRequestSchema, discountSchema, dispatchRequestSchema, easyPayProviderResponseSchema, easyPayProviderSchema, eventTypeSchema, exchangeAuthorizationCode, fulfillBulkRequestSchema, fulfillRequestSchema, fulfillmentRuleOf, fulfillmentTypeOf, fulfillmentTypeResponseSchema, fulfillmentTypeSchema, holdOrderItemRequestSchema, inquiryListQuerySchema, inquirySchema, inquiryWebhookEventDataSchema, inquiryWebhookEventSchema, invitationDeliveryStatusSchema, invitationListQuerySchema, invitationSchema, inviteMemberRequestSchema, isCouponTargetLine, isEditableStorefrontPath, isReservedStoreCode, isReservedStorefrontSlug, isShowcaseStorefrontSlug, isStoreScope, isWebOrigin, issueCouponRequestSchema, issueCouponResultSchema, liveGateConditionCodeSchema, liveGateConditionSchema, liveGateErrorDetailsSchema, liveGateSchema, loginProviderSchema, loginProviderStatusSchema, loginProviderTestSchema, loginSettingsSchema, maskedSecretSchema, mcpRuleResultSchema, mcpToolEventSchema, moveStorefrontFileRequestSchema, myAccountSchema, myStoreSchema, normalizeBusinessNumber, normalizeCouponCode, normalizeStorefrontHostname, normalizeStorefrontPath, oauthTokenResponseSchema, openFulfillmentUnion, optionGroupKindSchema, optionGroupRequestSchema, optionGroupSchema, optionModelRequestSchema, optionModelSchema, optionSelectionSchema, optionSortSchema, optionValueRequestSchema, optionValueSchema, orderCanceledWebhookEventDataSchema, orderCanceledWebhookEventSchema, orderDiscountSchema, orderFulfilledWebhookEventDataSchema, orderFulfilledWebhookEventSchema, orderItemFulfillmentSchema, orderItemFulfillmentSnapshotResponseSchema, orderItemFulfillmentSnapshotSchema, orderItemListQuerySchema, orderItemMemoResultSchema, orderItemPageSchema, orderItemSchema, orderItemStatusSchema, orderListQuerySchema, orderSchema, orderSummaryPageSchema, orderSummarySchema, orderWebhookEventDataSchema, orderWebhookEventSchema, pageInfoSchema, pageQueryShape, pageSchema, paymentAttemptRecordSchema, paymentAttemptStatusSchema, paymentCredentialSetSchema, paymentFailureWarningSchema, paymentListQuerySchema, paymentProviderSchema, paymentProviderVerificationSchema, paymentRecordDetailSchema, paymentRecordPageSchema, paymentRecordSchema, paymentRecordStatusSchema, paymentRefundRecordSchema, paymentSettingsSchema, pgEnvironmentSchema, pgPaymentMethodSchema, pgProviderSchema, pgVerificationResultSchema, planCodeSchema, planEntitlementsSchema, planErrorDetailsSchema, policyDocumentUrlSchema, policyVersionSchema, portOneChannelSchema, portOneCredentialsInputSchema, productCreateRequestSchema, productFulfillmentCreateSchema, productFulfillmentResponseSchema, productFulfillmentSchema, productFulfillmentUpdateSchema, productImageRejection, productImageUrlSchema, productListQuerySchema, productOutOfStockWebhookEventDataSchema, productOutOfStockWebhookEventSchema, productPatchRequestSchema, productRefundPolicyOverride, productRefundPolicyPatchSchema, productReplaceRequestSchema, productSchema, productSortSchema, productStatusSchema, productSummarySchema, productVariantRequestSchema, productVariantSchema, proratedPaidRefundBase, proratedRefundBase, publicPlanSchema, publicPlansSchema, publishStorefrontVersionRequestSchema, purchaseDecisionExtendResultSchema, purchaseDecisionRequestResultSchema, quoteDelivery, quotePricing, refreshUserToken, refundPolicyPatchSchema, refundPolicySchema, rejectClaimRequestSchema, remoteAreaLabelOf, remoteAreaOf, remoteAreaSchema, remoteSurchargeOf, replaceStorefrontSourceRequestSchema, requiresCollection, requiresShipping, resolveRefundPolicy, resolveShippingPolicy, responsePlanCodeSchema, reviewListQuerySchema, reviewReportResultSchema, reviewSchema, reviewSortSchema, reviewWebhookEventDataSchema, reviewWebhookEventSchema, rollbackStorefrontRequestSchema, runStorefrontCheckRequestSchema, salesReportGroupBySchema, salesReportQuerySchema, salesReportRowSchema, salesReportSchema, salesTotalsSchema, saveLoginProviderRequestSchema, savedCustomInputRequestSchema, savedCustomInputSchema, savedOptionGroupRequestSchema, savedOptionGroupSchema, savedOptionSetCompositionSchema, savedOptionSetFromProductRequestSchema, savedOptionSetFromProductResultSchema, savedOptionSetListQuerySchema, savedOptionSetRequestSchema, savedOptionSetSchema, savedOptionSetSortSchema, savedOptionValueRequestSchema, savedOptionValueSchema, scopeAccessSchema, searchStorefrontFilesRequestSchema, sellerAddressListQuerySchema, sellerAddressRequestSchema, sellerAddressSchema, sellerCustomerInquirySchema, setStorefrontVisibilityRequestSchema, settleCouponShortfall, settlementDetailQuerySchema, settlementDetailSchema, settlementListQuerySchema, settlementStatusSchema, settlementSummarySchema, settlementWithDetailsSchema, shippingAddressSchema, shippingPolicyPatchSchema, shippingPolicySchema, storeCodeSchema, storeMemberRoleUpdatedSchema, storeMemberSchema, storeRoleSchema, storeSchema, storeScopeResourceSchema, storeSubscriptionSchema, storefrontBuildDependenciesSchema, storefrontBuildListQuerySchema, storefrontBuildSchema, storefrontBuildStatusSchema, storefrontCheckSchema, storefrontCheckStatusSchema, storefrontCustomDomainStatusSchema, storefrontCustomHostnameProblem, storefrontDependencyFindingSchema, storefrontDeploymentLogSchema, storefrontDeploymentSchema, storefrontDeploymentStepSchema, storefrontDnsRecordSchema, storefrontDomainSchema, storefrontDomainTxtNameOf, storefrontEditPreviewLabelOf, storefrontEditPreviewSchema, storefrontEditSessionSchema, storefrontEditSessionStatusSchema, storefrontEditingSchema, storefrontFileConflictDetailsSchema, storefrontFileContentQuerySchema, storefrontFileContentSchema, storefrontFileDeleteResultSchema, storefrontFileEncodingSchema, storefrontFileEntrySchema, storefrontFileHashSchema, storefrontFileListQuerySchema, storefrontFileListSchema, storefrontFileMoveResultSchema, storefrontFilePathSchema, storefrontFileSearchResultSchema, storefrontFileWriteResultSchema, storefrontHostingSchema, storefrontPreviewLinkSchema, storefrontProvisioningSchema, storefrontRuleResultSchema, storefrontRuleSeveritySchema, storefrontSiteDomainSchema, storefrontSiteDomainsSchema, storefrontSiteSchema, storefrontSiteStatusSchema, storefrontSlugAvailabilityQuerySchema, storefrontSlugAvailabilitySchema, storefrontSlugSchema, storefrontSourceArchivePartSchema, storefrontSourceArchiveQuerySchema, storefrontSourceBlobsResultSchema, storefrontSourceModeSchema, storefrontSourceRefSchema, storefrontSourceReplacedSchema, storefrontSourceSchema, storefrontSuspensionSchema, storefrontTemplateSchema, storefrontVerifyReportSchema, storefrontVersionCreatedSchema, storefrontVersionSchema, storefrontVersionStatusSchema, storefrontVisibilitySchema, subscriptionLimitsSchema, subscriptionStatusSchema, subscriptionUsageSchema, tossPaymentsCredentialsInputSchema, trackingStageResponseSchema, trackingUpdateResultSchema, updateCouponRequestSchema, updateLoginProviderRequestSchema, updateLoginSettingsRequestSchema, updateMemberRoleRequestSchema, updatePaymentProviderRequestSchema, updatePaymentSettingsRequestSchema, updateStoreRequestSchema, uploadExtensionOf, uploadPurposeSchema, uploadRejection, uploadRequestSchema, uploadStorefrontSourceBlobsRequestSchema, uploadTicketSchema, upsertPortOneProviderRequestSchema, upsertTossPaymentsProviderRequestSchema, variantStockRequestSchema, variantStockSchema, verifyPaymentProviderRequestSchema, webhookCreatedSchema, webhookDeliveryDetailSchema, webhookDeliveryErrorCodeSchema, webhookDeliveryListQuerySchema, webhookDeliveryResourceSchema, webhookDeliverySchema, webhookDeliveryStatusSchema, webhookEventSchema, webhookFailureReasonSchema, webhookRedeliverFailedResultSchema, webhookRequestHeaderSchema, webhookRequestSchema, webhookResourceTypeSchema, webhookSchema, webhookSecretRotatedSchema, webhookStatusSchema, webhookTestRequestSchema, webhookTestResultSchema, withdrawCustomerRequestSchema, writeDryRunResultSchema, writePreviewSchema, writeStorefrontFilesRequestSchema };