@sayren/store-sdk 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/store-sdk",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "main": "./dist/index.mjs",
5
5
  "types": "./dist/index.d.mts",
6
6
  "exports": {
package/src/client.ts CHANGED
@@ -78,6 +78,21 @@ import {
78
78
  rejectClaimRequestSchema,
79
79
  } from "./schemas/claim";
80
80
  import { bulkResultSchema, type PageParams, pageSchema } from "./schemas/common";
81
+ import {
82
+ type CreateCouponRequest,
83
+ couponIssueSchema,
84
+ type couponListQuerySchema,
85
+ couponRedemptionSchema,
86
+ couponSchema,
87
+ couponStatsSchema,
88
+ createCouponRequestSchema,
89
+ customerCouponSchema,
90
+ type IssueCouponRequest,
91
+ issueCouponRequestSchema,
92
+ issueCouponResultSchema,
93
+ type UpdateCouponRequest,
94
+ updateCouponRequestSchema,
95
+ } from "./schemas/coupon";
81
96
  import {
82
97
  type ConsentKind,
83
98
  customerConsentSchema,
@@ -88,6 +103,21 @@ import {
88
103
  type WithdrawCustomerRequest,
89
104
  withdrawCustomerRequestSchema,
90
105
  } from "./schemas/customer";
106
+ import {
107
+ type DigitalAssetAttachRequest,
108
+ digitalAssetAttachRequestSchema,
109
+ digitalAssetPreviewSchema,
110
+ digitalAssetSchema,
111
+ } from "./schemas/digital-asset";
112
+ import {
113
+ type FulfillmentCodeUploadRequest,
114
+ fulfillmentCodeBatchSchema,
115
+ type fulfillmentCodeListQuerySchema,
116
+ fulfillmentCodePageSchema,
117
+ fulfillmentCodeRevealSchema,
118
+ fulfillmentCodeSchema,
119
+ fulfillmentCodeUploadRequestSchema,
120
+ } from "./schemas/fulfillment-code";
91
121
  import {
92
122
  type LoginProvider,
93
123
  loginProviderTestSchema,
@@ -128,6 +158,10 @@ import {
128
158
  deliveryInfoSchema,
129
159
  deliveryStatusSchema,
130
160
  dispatchRequestSchema,
161
+ type FulfillRequest,
162
+ fulfillBulkRequestSchema,
163
+ fulfillmentMailResendSchema,
164
+ fulfillRequestSchema,
131
165
  type HoldOrderItemRequest,
132
166
  holdOrderItemRequestSchema,
133
167
  type OrderItemListParams,
@@ -170,12 +204,14 @@ import {
170
204
  type OptionModelRequest,
171
205
  optionModelRequestSchema,
172
206
  optionModelSchema,
173
- type ProductCreateRequest,
207
+ type ProductCreateRequestInput,
174
208
  type ProductListParams,
175
209
  type ProductPatchRequest,
210
+ type ProductReplaceRequestInput,
176
211
  type ProductStatus,
177
212
  productCreateRequestSchema,
178
213
  productPatchRequestSchema,
214
+ productReplaceRequestSchema,
179
215
  productSchema,
180
216
  productStatusSchema,
181
217
  productSummarySchema,
@@ -436,7 +472,8 @@ export function createStoreClient(options: StoreClientOptions) {
436
472
  });
437
473
  const put = await fetchFn(ticket.uploadUrl, {
438
474
  method: "PUT",
439
- headers: { "content-type": file.type || "application/octet-stream" },
475
+ // 발급 응답의 헤더를 함께 싣는다 — 판매 파일(이슈 #48)은 비공개 헤더가 빠지면 공개 객체가 된다
476
+ headers: { ...ticket.headers, "content-type": file.type || "application/octet-stream" },
440
477
  body: file,
441
478
  });
442
479
  if (!put.ok) {
@@ -453,14 +490,14 @@ export function createStoreClient(options: StoreClientOptions) {
453
490
  }),
454
491
  get: (productId: string) =>
455
492
  storeHttp.request("GET", path`/products/${productId}`, productSchema),
456
- create: (body: ProductCreateRequest, idempotencyKey?: string) =>
493
+ create: (body: ProductCreateRequestInput, idempotencyKey?: string) =>
457
494
  storeHttp.request("POST", "/products", productSchema, {
458
495
  body: productCreateRequestSchema.parse(body),
459
496
  headers: idempotency(idempotencyKey),
460
497
  }),
461
- update: (productId: string, body: ProductCreateRequest) =>
498
+ update: (productId: string, body: ProductReplaceRequestInput) =>
462
499
  storeHttp.request("PUT", path`/products/${productId}`, productSchema, {
463
- body: productCreateRequestSchema.parse(body),
500
+ body: productReplaceRequestSchema.parse(body),
464
501
  }),
465
502
  patch: (productId: string, body: ProductPatchRequest) =>
466
503
  storeHttp.requestVoid("PATCH", path`/products/${productId}`, {
@@ -499,6 +536,76 @@ export function createStoreClient(options: StoreClientOptions) {
499
536
  }),
500
537
  removeDiscount: (productId: string) =>
501
538
  storeHttp.requestVoid("DELETE", path`/products/${productId}/discount`),
539
+ /**
540
+ * 코드 일괄 업로드 (이슈 #48) — 코드 발급·자동 제공 상품의 코드 풀에 올린다. `codes`와 `csv` 중 하나만 보낸다.
541
+ * 받은 코드 수만큼 재고가 는다. 에러: 409 `NOT_CODE_PRODUCT`·`CODE_POOL_NOT_USED`·`CODE_UPLOAD_CONFLICT`, 400 `TOO_MANY_CODES`
542
+ */
543
+ uploadCodes: (
544
+ productId: string,
545
+ body: FulfillmentCodeUploadRequest,
546
+ idempotencyKey?: string,
547
+ ) =>
548
+ storeHttp.request("POST", path`/products/${productId}/codes`, fulfillmentCodeBatchSchema, {
549
+ body: fulfillmentCodeUploadRequestSchema.parse(body),
550
+ headers: idempotency(idempotencyKey),
551
+ }),
552
+ /** 코드 목록 — 가린 값(`hint`)만 싣는다. `available`은 필터와 무관한 판매 가능 코드 수다 */
553
+ listCodes: (
554
+ productId: string,
555
+ params?: PageParams & Omit<z.input<typeof fulfillmentCodeListQuerySchema>, "page" | "size">,
556
+ ) =>
557
+ storeHttp.request("GET", path`/products/${productId}/codes`, fulfillmentCodePageSchema, {
558
+ query: params as Query,
559
+ }),
560
+ /** 판매 가능 코드 폐기 — 재고가 1 준다. 배정된 코드는 409 `CODE_NOT_AVAILABLE` */
561
+ voidCode: (productId: string, codeId: string) =>
562
+ storeHttp.request(
563
+ "POST",
564
+ path`/products/${productId}/codes/${codeId}/void`,
565
+ fulfillmentCodeSchema,
566
+ ),
567
+ /**
568
+ * 판매 파일 연결 (이슈 #48) — `uploads.create({ purpose: "digital-asset" })`로 받은 `key`에 파일을 올린 뒤 연결한다.
569
+ * 이후 주문부터 이 파일을 준다. 에러: 400 `INVALID_ASSET_KEY`, 409 `NOT_DOWNLOAD_PRODUCT`·`ASSET_ALREADY_ATTACHED`
570
+ */
571
+ attachDigitalAsset: (productId: string, body: DigitalAssetAttachRequest) =>
572
+ storeHttp.request("POST", path`/products/${productId}/digital-assets`, digitalAssetSchema, {
573
+ body: digitalAssetAttachRequestSchema.parse(body),
574
+ }),
575
+ /** 판매 파일 목록 — 해제한 파일도 싣는다. 객체 키·주소는 없다 */
576
+ listDigitalAssets: (productId: string) =>
577
+ storeHttp.request(
578
+ "GET",
579
+ path`/products/${productId}/digital-assets`,
580
+ z.array(digitalAssetSchema),
581
+ ),
582
+ /** 판매 파일 해제 — 새 주문에 주지 않는다. 이미 산 구매자는 계속 받는다 */
583
+ removeDigitalAsset: (productId: string, assetId: string) =>
584
+ storeHttp.request(
585
+ "DELETE",
586
+ path`/products/${productId}/digital-assets/${assetId}`,
587
+ digitalAssetSchema,
588
+ ),
589
+ /** 판매 파일 확인 주소 — 수명 300초의 서명 주소 */
590
+ previewDigitalAsset: (productId: string, assetId: string) =>
591
+ storeHttp.request(
592
+ "POST",
593
+ path`/products/${productId}/digital-assets/${assetId}/preview-url`,
594
+ digitalAssetPreviewSchema,
595
+ ),
596
+ },
597
+
598
+ fulfillmentCodes: {
599
+ /**
600
+ * 코드 평문 열람 — 콘솔에서 로그인한 OWNER·ADMIN만 된다(403 `INSUFFICIENT_ROLE`). 열람 기록과 감사 로그가 남는다.
601
+ * 평문은 화면에 보인 뒤 저장하지 않는다
602
+ */
603
+ reveal: (codeId: string) =>
604
+ storeHttp.request(
605
+ "POST",
606
+ path`/fulfillment-codes/${codeId}/reveal`,
607
+ fulfillmentCodeRevealSchema,
608
+ ),
502
609
  },
503
610
 
504
611
  categories: {
@@ -624,6 +731,31 @@ export function createStoreClient(options: StoreClientOptions) {
624
731
  body: { orderItemIds },
625
732
  headers: idempotency(idempotencyKey),
626
733
  }),
734
+ /**
735
+ * 제공 안내 메일 재발송 (이슈 #48) — 제공된 주문상품의 안내 메일을 다시 보낸다. 주문상품당 하루 3회(429).
736
+ * 코드·파일 주소는 메일에 없다
737
+ */
738
+ resendFulfillmentMail: (orderItemId: string) =>
739
+ storeHttp.request(
740
+ "POST",
741
+ path`/order-items/${orderItemId}/fulfillment/mail`,
742
+ fulfillmentMailResendSchema,
743
+ ),
744
+ /**
745
+ * 제공 처리 (이슈 #48) — 수동 제공 비실물 주문상품(이용권·방문 수령)을 `PAID`·`CONFIRMED`에서 제공 완료(`DELIVERED`)로
746
+ * 바꾼다. `note`는 구매자 주문 상세에 이용 안내로 실린다. 배송 상품은 409 `SHIPPING_REQUIRED`
747
+ */
748
+ fulfill: (orderItemId: string, body: FulfillRequest = {}, idempotencyKey?: string) =>
749
+ storeHttp.request("POST", path`/order-items/${orderItemId}/fulfill`, orderItemSchema, {
750
+ body: fulfillRequestSchema.parse(body),
751
+ headers: idempotency(idempotencyKey),
752
+ }),
753
+ /** 제공 처리 일괄 — 항목별 부분 성공이다 */
754
+ fulfillBulk: (orderItemIds: string[], body: FulfillRequest = {}, idempotencyKey?: string) =>
755
+ storeHttp.request("POST", "/order-items/fulfill", bulkResultSchema, {
756
+ body: fulfillBulkRequestSchema.parse({ orderItemIds, ...body }),
757
+ headers: idempotency(idempotencyKey),
758
+ }),
627
759
  getDelivery: (orderItemId: string) =>
628
760
  storeHttp.request("GET", path`/order-items/${orderItemId}/delivery`, deliveryStatusSchema),
629
761
  },
@@ -741,12 +873,69 @@ export function createStoreClient(options: StoreClientOptions) {
741
873
  pageSchema(customerConsentSchema),
742
874
  { query: params as Query },
743
875
  ),
876
+ /** 보유 쿠폰 (이슈 #47) — 최근 지급 순, 최대 500장 */
877
+ coupons: (customerId: string) =>
878
+ storeHttp.request(
879
+ "GET",
880
+ path`/customers/${customerId}/coupons`,
881
+ z.array(customerCouponSchema),
882
+ ),
744
883
  withdraw: (customerId: string, body: WithdrawCustomerRequest) =>
745
884
  storeHttp.requestVoid("POST", path`/customers/${customerId}/withdraw`, {
746
885
  body: withdrawCustomerRequestSchema.parse(body),
747
886
  }),
748
887
  },
749
888
 
889
+ /** 쿠폰 (이슈 #47) — 조회 `promotion:r`, 쓰기 `promotion:rw` */
890
+ coupons: {
891
+ list: (params?: PageParams & Omit<z.input<typeof couponListQuerySchema>, "page" | "size">) =>
892
+ storeHttp.request("GET", "/coupons", pageSchema(couponSchema), { query: params as Query }),
893
+ create: (body: CreateCouponRequest, idempotencyKey?: string) =>
894
+ storeHttp.request("POST", "/coupons", couponSchema, {
895
+ body: createCouponRequestSchema.parse(body),
896
+ headers: idempotency(idempotencyKey),
897
+ }),
898
+ get: (couponId: string) => storeHttp.request("GET", path`/coupons/${couponId}`, couponSchema),
899
+ update: (couponId: string, body: UpdateCouponRequest) =>
900
+ storeHttp.request("PATCH", path`/coupons/${couponId}`, couponSchema, {
901
+ body: updateCouponRequestSchema.parse(body),
902
+ }),
903
+ pause: (couponId: string) =>
904
+ storeHttp.request("POST", path`/coupons/${couponId}/pause`, couponSchema),
905
+ resume: (couponId: string) =>
906
+ storeHttp.request("POST", path`/coupons/${couponId}/resume`, couponSchema),
907
+ end: (couponId: string) =>
908
+ storeHttp.request("POST", path`/coupons/${couponId}/end`, couponSchema),
909
+ /** 셀러 지급 — 한 번에 1,000명까지. 한도에 걸린 구매자는 `failed`에 담긴다 */
910
+ issue: (couponId: string, body: IssueCouponRequest, idempotencyKey?: string) =>
911
+ storeHttp.request("POST", path`/coupons/${couponId}/issues`, issueCouponResultSchema, {
912
+ body: issueCouponRequestSchema.parse(body),
913
+ headers: idempotency(idempotencyKey),
914
+ }),
915
+ issues: (
916
+ couponId: string,
917
+ params?: PageParams & { status?: "AVAILABLE" | "RESERVED" | "USED" | "REVOKED" },
918
+ ) =>
919
+ storeHttp.request("GET", path`/coupons/${couponId}/issues`, pageSchema(couponIssueSchema), {
920
+ query: params as Query,
921
+ }),
922
+ redemptions: (
923
+ couponId: string,
924
+ params?: PageParams & { status?: "RESERVED" | "USED" | "RELEASED" | "RESTORED" },
925
+ ) =>
926
+ storeHttp.request(
927
+ "GET",
928
+ path`/coupons/${couponId}/redemptions`,
929
+ pageSchema(couponRedemptionSchema),
930
+ { query: params as Query },
931
+ ),
932
+ stats: (couponId: string) =>
933
+ storeHttp.request("GET", path`/coupons/${couponId}/stats`, couponStatsSchema),
934
+ /** 발급 쿠폰 회수 — 사용 가능한 발급 쿠폰만 */
935
+ revokeIssue: (issueId: string) =>
936
+ storeHttp.request("POST", path`/coupon-issues/${issueId}/revoke`, couponIssueSchema),
937
+ },
938
+
750
939
  customerInquiries: {
751
940
  list: (
752
941
  params?: PageParams & {
package/src/index.ts CHANGED
@@ -25,12 +25,17 @@ export * from "./schemas/auth";
25
25
  export * from "./schemas/category";
26
26
  export * from "./schemas/claim";
27
27
  export * from "./schemas/common";
28
+ export * from "./schemas/coupon";
28
29
  export * from "./schemas/customer";
30
+ export * from "./schemas/digital-asset";
31
+ export * from "./schemas/fulfillment";
32
+ export * from "./schemas/fulfillment-code";
29
33
  export * from "./schemas/login";
30
34
  export * from "./schemas/misc";
31
35
  export * from "./schemas/order";
32
36
  export * from "./schemas/payment";
33
37
  export * from "./schemas/product";
38
+ export * from "./schemas/promotion";
34
39
  export * from "./schemas/refund-policy";
35
40
  export * from "./schemas/scope";
36
41
  export * from "./schemas/shipping-policy";
@@ -85,6 +85,14 @@ export const claimSchema = z.object({
85
85
  trackingNumber: z.string().nullable(),
86
86
  })
87
87
  .nullable(),
88
+ fulfillmentType: z
89
+ .string()
90
+ .optional()
91
+ .default("SHIPPING")
92
+ .describe(
93
+ "클레임 대상 주문상품의 상품 유형(이슈 #48). 수거가 없는 비실물(`DOWNLOAD`·`CODE`·`SERVICE`) 반품은 수거 등록 없이 " +
94
+ "접수·보류에서 바로 승인한다. 값이 추가될 수 있어 문자열로 받는다. 서버는 항상 싣는다",
95
+ ),
88
96
  requestedAt: z.string(),
89
97
  completedAt: z.string().nullable(),
90
98
  /** 보류 중이면 자동 해제 시각(ISO 8601), 아니면 null */
@@ -163,8 +171,8 @@ export const approveClaimResultSchema = z.object({
163
171
  .number()
164
172
  .int()
165
173
  .describe(
166
- "실제 환불한 금액 — 수량 비율 상품 금액 + 배송비 환불 − 반품 배송비 − 조건부 무료배송 미달 차감 − " +
167
- "추가 차감(`deductAmount`)",
174
+ "실제 환불한 금액 — 수량 비율 상품 실결제 금액(쿠폰 배분을 뺀 금액) + 배송비 환불 − 반품 배송비 − 조건부 무료배송 미달 차감 − " +
175
+ "쿠폰 조건 미달 차감(`couponChargeAmount`) + 되돌린 쿠폰 차감(`couponChargeRefundAmount`) − 추가 차감(`deductAmount`)",
168
176
  ),
169
177
  deductAmount: z.number().int().describe("승인 때 셀러가 추가로 차감한 금액"),
170
178
  quantity: z.number().int().describe("환불한 수량"),
@@ -179,6 +187,20 @@ export const approveClaimResultSchema = z.object({
179
187
  "환불액에서 뺀 **배송비** — 반품·교환 배송비 + 조건부 무료배송 미달 차감. " +
180
188
  "추가 차감(`deductAmount`)은 포함하지 않는다",
181
189
  ),
190
+ couponChargeAmount: z
191
+ .number()
192
+ .int()
193
+ .optional()
194
+ .default(0)
195
+ .describe(
196
+ "환불액에서 뺀 쿠폰 조건 미달 차감. 남은 주문상품이 쿠폰 조건을 잃었을 때만 0보다 크다. 서버는 항상 싣는다",
197
+ ),
198
+ couponChargeRefundAmount: z
199
+ .number()
200
+ .int()
201
+ .optional()
202
+ .default(0)
203
+ .describe("환불액에 더한, 앞선 환불에서 뺀 쿠폰 차감을 되돌린 몫. 서버는 항상 싣는다"),
182
204
  refundedAt: z.string(),
183
205
  });
184
206
 
@@ -0,0 +1,312 @@
1
+ import { z } from "zod";
2
+ import { pageQueryShape } from "./common";
3
+ import {
4
+ COUPON_CONDITIONS_DEFAULTS,
5
+ COUPON_TARGET_LIST_MAX,
6
+ couponBenefitSchema,
7
+ couponConditionsSchema,
8
+ couponKindSchema,
9
+ couponTargetSchema,
10
+ } from "./promotion";
11
+
12
+ /**
13
+ * 쿠폰 관리 API 계약 (이슈 #47) — `/v1/coupons/**`, 스코프 `promotion:r|rw`.
14
+ *
15
+ * 금액 계산 규칙은 `promotion.ts`(`quotePricing`)가 원천이고, 여기에는 셀러가 쿠폰을 만들고 나눠 주는 표면만 있다.
16
+ * 발급·사용이 시작된 쿠폰은 혜택·조건·대상·사용 시작일을 바꿀 수 없다(409 `COUPON_LOCKED`). 이름·설명·종료일 연장·
17
+ * 한도 상향·중지·재개·종료만 된다. 규칙을 바꾸려면 새 쿠폰을 만든다.
18
+ */
19
+
20
+ /** 쿠폰 상태 — `ACTIVE`(사용 중)·`PAUSED`(중지, 재개 가능)·`ENDED`(종료, 되돌릴 수 없음) */
21
+ export const couponStatusSchema = z
22
+ .enum(["ACTIVE", "PAUSED", "ENDED"])
23
+ .describe("쿠폰 상태. `ACTIVE`=사용 중, `PAUSED`=중지(재개 가능), `ENDED`=종료(되돌릴 수 없음)");
24
+ export type CouponStatus = z.infer<typeof couponStatusSchema>;
25
+
26
+ /** 지금 만들 수 있는 발급 방식 — 다운로드·가입 자동 지급은 뒤 단계다 */
27
+ export const couponIssueMethodSchema = z
28
+ .enum(["CODE", "MANUAL"])
29
+ .describe(
30
+ "발급 방식. `CODE`=구매자가 주문서에 코드를 입력한다, `MANUAL`=셀러가 회원을 골라 지급한다",
31
+ );
32
+ export type CouponIssueMethod = z.infer<typeof couponIssueMethodSchema>;
33
+
34
+ /** 코드 형식 — 영문·숫자·`-`·`_` 6~30자. 대소문자를 구분하지 않고 서버가 대문자로 저장한다 */
35
+ export const COUPON_CODE_PATTERN = /^[A-Za-z0-9_-]{6,30}$/;
36
+
37
+ /** 코드 정규화 — 대문자, 앞뒤 공백 제거. 입력·저장·조회가 같은 함수를 쓴다 */
38
+ export function normalizeCouponCode(code: string): string {
39
+ return code.trim().toUpperCase();
40
+ }
41
+
42
+ const limitField = (description: string) =>
43
+ z.number().int().min(1).max(10_000_000).nullable().optional().describe(description);
44
+
45
+ const descriptions = {
46
+ name: "구매자에게 보이는 쿠폰 이름. 1~60자",
47
+ description: "구매자에게 보이는 설명. 300자까지",
48
+ code: "쿠폰 코드 — 발급 방식이 `CODE`일 때 필수. 영문·숫자·`-`·`_` 6~30자, 대소문자 구분 없음(대문자로 저장). 스토어 안에서 유일하다",
49
+ startsAt: "사용 시작 시각(ISO 8601, 시간대 포함). 생략하면 바로 쓸 수 있다",
50
+ endsAt:
51
+ "사용 종료 시각(ISO 8601, 시간대 포함). 이 시각부터 못 쓴다. 생략하면 제한이 없다. " +
52
+ "이미 지급한 쿠폰의 유효 기간은 지급 때 정해지므로 뒤에 종료일을 늦춰도 늘어나지 않는다",
53
+ validDaysAfterIssue:
54
+ "발급 후 유효 일수 — 셀러 지급(`MANUAL`) 쿠폰만. 쿠폰 종료일과 둘 중 먼저 끝나는 쪽이 적용된다",
55
+ totalIssueLimit: "총 발급 수 한도. 생략하면 무제한",
56
+ totalUseLimit: "총 사용 수 한도. 결제 중인 예약도 센다. 생략하면 무제한",
57
+ perMemberIssueLimit: "1인 발급 수 한도(셀러 지급). 생략하면 1인 1장",
58
+ perMemberUseLimit:
59
+ "1인 사용 수 한도(코드 입력형). 생략하면 무제한. 비회원에게 여는 쿠폰은 둘 수 없다",
60
+ } as const;
61
+
62
+ /** 조건 입력 — 생략한 항목은 기본값(`COUPON_CONDITIONS_DEFAULTS`)이다 */
63
+ export const couponConditionsInputSchema = z.strictObject({
64
+ minOrderAmount: z.number().int().min(0).optional(),
65
+ firstOrderOnly: z.boolean().optional(),
66
+ memberOnly: z.boolean().optional(),
67
+ stackWithInstantDiscount: z.boolean().optional(),
68
+ });
69
+
70
+ /** 대상 입력 — 생략한 목록은 빈 목록이다(포함 목록이 모두 비면 전 상품) */
71
+ export const couponTargetInputSchema = z.strictObject({
72
+ includeProductIds: z.array(z.string().min(1)).max(COUPON_TARGET_LIST_MAX).optional(),
73
+ includeCategoryIds: z.array(z.string().min(1)).max(COUPON_TARGET_LIST_MAX).optional(),
74
+ excludeProductIds: z.array(z.string().min(1)).max(COUPON_TARGET_LIST_MAX).optional(),
75
+ excludeCategoryIds: z.array(z.string().min(1)).max(COUPON_TARGET_LIST_MAX).optional(),
76
+ });
77
+
78
+ const couponTimeField = (description: string) =>
79
+ z.iso.datetime({ offset: true }).nullable().optional().describe(description);
80
+
81
+ /** `POST /v1/coupons` */
82
+ export const createCouponRequestSchema = z
83
+ .strictObject({
84
+ name: z.string().trim().min(1).max(60).describe(descriptions.name),
85
+ description: z.string().max(300).nullable().optional().describe(descriptions.description),
86
+ kind: couponKindSchema,
87
+ benefit: couponBenefitSchema,
88
+ conditions: couponConditionsInputSchema
89
+ .optional()
90
+ .describe("사용 조건. 생략한 항목은 기본값(최소 금액 0, 회원 전용, 즉시할인과 중복 가능)"),
91
+ target: couponTargetInputSchema.optional().describe("대상 상품·카테고리. 생략하면 전 상품"),
92
+ issueMethod: couponIssueMethodSchema,
93
+ code: z.string().regex(COUPON_CODE_PATTERN).optional().describe(descriptions.code),
94
+ startsAt: couponTimeField(descriptions.startsAt),
95
+ endsAt: couponTimeField(descriptions.endsAt),
96
+ validDaysAfterIssue: z
97
+ .number()
98
+ .int()
99
+ .min(1)
100
+ .max(3650)
101
+ .nullable()
102
+ .optional()
103
+ .describe(descriptions.validDaysAfterIssue),
104
+ totalIssueLimit: limitField(descriptions.totalIssueLimit),
105
+ totalUseLimit: limitField(descriptions.totalUseLimit),
106
+ perMemberIssueLimit: limitField(descriptions.perMemberIssueLimit),
107
+ perMemberUseLimit: limitField(descriptions.perMemberUseLimit),
108
+ })
109
+ .superRefine((body, ctx) => {
110
+ const issue = (path: string, message: string) =>
111
+ ctx.addIssue({ code: "custom", path: [path], message });
112
+ if (body.issueMethod === "CODE" && !body.code)
113
+ issue("code", "코드 입력형 쿠폰은 코드가 필요합니다");
114
+ if (body.issueMethod !== "CODE" && body.code)
115
+ issue("code", "코드는 코드 입력형 쿠폰에만 둡니다");
116
+ if (body.issueMethod === "CODE" && body.validDaysAfterIssue != null) {
117
+ issue("validDaysAfterIssue", "발급 후 유효 일수는 셀러 지급 쿠폰에만 둡니다");
118
+ }
119
+ if (body.issueMethod === "CODE" && body.perMemberIssueLimit != null) {
120
+ issue("perMemberIssueLimit", "1인 발급 수 한도는 셀러 지급 쿠폰에만 둡니다");
121
+ }
122
+ if (body.issueMethod === "MANUAL" && body.perMemberUseLimit != null) {
123
+ issue(
124
+ "perMemberUseLimit",
125
+ "셀러 지급 쿠폰은 발급 한 장이 한 번 쓰입니다. 1인 발급 수 한도를 쓰십시오",
126
+ );
127
+ }
128
+ const memberOnly = body.conditions?.memberOnly ?? COUPON_CONDITIONS_DEFAULTS.memberOnly;
129
+ if (!memberOnly && (body.issueMethod !== "CODE" || body.perMemberUseLimit != null)) {
130
+ issue("conditions", "비회원에게 여는 쿠폰은 코드 입력형이면서 1인 사용 한도가 없어야 합니다");
131
+ }
132
+ if (body.startsAt && body.endsAt && Date.parse(body.endsAt) <= Date.parse(body.startsAt)) {
133
+ issue("endsAt", "종료 시각은 시작 시각보다 뒤여야 합니다");
134
+ }
135
+ });
136
+ export type CreateCouponRequest = z.infer<typeof createCouponRequestSchema>;
137
+
138
+ /**
139
+ * `PATCH /v1/coupons/{couponId}` — 준 항목만 바뀐다. 발급·사용이 시작된 쿠폰(`locked`)은 이름·설명·종료일 연장·
140
+ * 한도 상향만 받고, 그 밖의 항목이 현재 값과 다르면 409 `COUPON_LOCKED`다. 종류·발급 방식은 바꿀 수 없다.
141
+ */
142
+ export const updateCouponRequestSchema = z.strictObject({
143
+ name: z.string().trim().min(1).max(60).optional().describe(descriptions.name),
144
+ description: z.string().max(300).nullable().optional().describe(descriptions.description),
145
+ benefit: couponBenefitSchema.optional(),
146
+ conditions: couponConditionsInputSchema.optional(),
147
+ target: couponTargetInputSchema.optional(),
148
+ code: z.string().regex(COUPON_CODE_PATTERN).optional().describe(descriptions.code),
149
+ startsAt: couponTimeField(descriptions.startsAt),
150
+ endsAt: couponTimeField(descriptions.endsAt),
151
+ validDaysAfterIssue: z.number().int().min(1).max(3650).nullable().optional(),
152
+ totalIssueLimit: limitField(descriptions.totalIssueLimit),
153
+ totalUseLimit: limitField(descriptions.totalUseLimit),
154
+ perMemberIssueLimit: limitField(descriptions.perMemberIssueLimit),
155
+ perMemberUseLimit: limitField(descriptions.perMemberUseLimit),
156
+ });
157
+ export type UpdateCouponRequest = z.infer<typeof updateCouponRequestSchema>;
158
+
159
+ /** 쿠폰 응답 */
160
+ export const couponSchema = z.object({
161
+ couponId: z.string(),
162
+ name: z.string(),
163
+ description: z.string().nullable(),
164
+ kind: couponKindSchema,
165
+ benefit: couponBenefitSchema,
166
+ conditions: couponConditionsSchema,
167
+ target: couponTargetSchema,
168
+ issueMethod: z
169
+ .string()
170
+ .describe(
171
+ "발급 방식. 현재 값은 `CODE`·`MANUAL`이고 값이 추가될 수 있어 문자열로 받는다(다운로드·가입 자동 지급 예정)",
172
+ ),
173
+ code: z.string().nullable().describe("쿠폰 코드(대문자). 코드 입력형이 아니면 null"),
174
+ startsAt: z.string().nullable(),
175
+ endsAt: z.string().nullable(),
176
+ validDaysAfterIssue: z.number().int().nullable(),
177
+ totalIssueLimit: z.number().int().nullable(),
178
+ totalUseLimit: z.number().int().nullable(),
179
+ perMemberIssueLimit: z.number().int().nullable(),
180
+ perMemberUseLimit: z.number().int().nullable(),
181
+ issuedCount: z
182
+ .number()
183
+ .int()
184
+ .describe("발급 수 — 회수한 발급도 센다. 총 발급 한도(`totalIssueLimit`)가 이 값과 비교된다"),
185
+ reservedCount: z.number().int().describe("결제 중인 예약 수 — 결제가 끝나거나 만료되면 풀린다"),
186
+ usedCount: z.number().int().describe("사용 수 — 전량 취소로 복원되면 줄어든다"),
187
+ status: couponStatusSchema,
188
+ locked: z.boolean().describe("발급·사용이 시작돼 혜택·조건·대상·사용 시작일을 바꿀 수 없는가"),
189
+ lockedAt: z.string().nullable(),
190
+ createdAt: z.string(),
191
+ updatedAt: z.string(),
192
+ });
193
+ export type Coupon = z.infer<typeof couponSchema>;
194
+
195
+ /** `GET /v1/coupons` 쿼리 */
196
+ export const couponListQuerySchema = z.object({
197
+ status: couponStatusSchema.optional(),
198
+ kind: couponKindSchema.optional(),
199
+ issueMethod: couponIssueMethodSchema.optional(),
200
+ q: z.string().optional().describe("이름·코드 검색(포함)"),
201
+ ...pageQueryShape(),
202
+ });
203
+
204
+ /** 발급 쿠폰 상태 — 만료는 저장 상태가 아니라 조회 시각으로 판정한 값이다 */
205
+ export const couponIssueStatusSchema = z
206
+ .string()
207
+ .describe(
208
+ "발급 쿠폰 상태. 현재 값은 `AVAILABLE`(사용 가능)·`RESERVED`(결제 중)·`USED`(사용함)·`REVOKED`(회수)·" +
209
+ "`EXPIRED`(유효 기간 지남)이고 값이 추가될 수 있어 문자열로 받는다",
210
+ );
211
+
212
+ export const couponIssueSchema = z.object({
213
+ issueId: z.string(),
214
+ couponId: z.string(),
215
+ customerId: z.string(),
216
+ customerName: z.string().nullable().describe("구매자 이름. 탈퇴한 구매자는 null"),
217
+ status: couponIssueStatusSchema,
218
+ validFrom: z.string(),
219
+ validUntil: z.string().nullable(),
220
+ usedOrderId: z.string().nullable(),
221
+ issuedAt: z.string(),
222
+ revokedAt: z.string().nullable(),
223
+ });
224
+ export type CouponIssue = z.infer<typeof couponIssueSchema>;
225
+
226
+ export const couponIssueListQuerySchema = z.object({
227
+ status: z.enum(["AVAILABLE", "RESERVED", "USED", "REVOKED"]).optional(),
228
+ ...pageQueryShape(),
229
+ });
230
+
231
+ /** `POST /v1/coupons/{couponId}/issues` — 셀러 지급. 한 번에 1,000명까지 */
232
+ export const issueCouponRequestSchema = z.strictObject({
233
+ customerIds: z
234
+ .array(z.string().min(1))
235
+ .min(1)
236
+ .max(1000)
237
+ .refine((ids) => new Set(ids).size === ids.length, "같은 구매자를 두 번 넣었습니다")
238
+ .describe("지급할 구매자 id(1~1,000명, 중복 불가)"),
239
+ });
240
+ export type IssueCouponRequest = z.infer<typeof issueCouponRequestSchema>;
241
+
242
+ export const issueCouponResultSchema = z.object({
243
+ issued: z.array(z.object({ customerId: z.string(), issueId: z.string() })),
244
+ failed: z.array(
245
+ z.object({
246
+ customerId: z.string(),
247
+ code: z
248
+ .string()
249
+ .describe(
250
+ "실패 사유. `CUSTOMER_NOT_FOUND`(이 스토어 구매자가 아님)·`COUPON_LIMIT_REACHED`(1인 발급 한도)·" +
251
+ "`COUPON_EXHAUSTED`(총 발급 한도)",
252
+ ),
253
+ message: z.string(),
254
+ }),
255
+ ),
256
+ });
257
+ export type IssueCouponResult = z.infer<typeof issueCouponResultSchema>;
258
+
259
+ export const couponRedemptionSchema = z.object({
260
+ redemptionId: z.string(),
261
+ couponId: z.string(),
262
+ issueId: z.string().nullable(),
263
+ customerId: z.string().nullable().describe("구매자 id. 비회원 주문은 null"),
264
+ orderId: z.string().nullable().describe("주문 id. 결제가 끝나기 전의 예약·해제는 null"),
265
+ status: z
266
+ .string()
267
+ .describe(
268
+ "사용 기록 상태. 현재 값은 `RESERVED`(결제 중)·`USED`(사용함)·`RELEASED`(결제 실패·만료로 해제)·" +
269
+ "`RESTORED`(전량 취소로 복원)이고 값이 추가될 수 있어 문자열로 받는다",
270
+ ),
271
+ discountAmount: z.number().int(),
272
+ reservedAt: z.string(),
273
+ usedAt: z.string().nullable(),
274
+ releasedAt: z.string().nullable(),
275
+ restoredAt: z.string().nullable(),
276
+ restoreSkippedReason: z
277
+ .string()
278
+ .nullable()
279
+ .describe("전량 취소에도 복원하지 않은 사유 — `EXPIRED`(유효 기간 지남)·`ENDED`(종료된 쿠폰)"),
280
+ });
281
+ export type CouponRedemption = z.infer<typeof couponRedemptionSchema>;
282
+
283
+ export const couponRedemptionListQuerySchema = z.object({
284
+ status: z.enum(["RESERVED", "USED", "RELEASED", "RESTORED"]).optional(),
285
+ ...pageQueryShape(),
286
+ });
287
+
288
+ export const couponStatsSchema = z.object({
289
+ couponId: z.string(),
290
+ issuedCount: z.number().int(),
291
+ revokedCount: z.number().int(),
292
+ reservedCount: z.number().int(),
293
+ usedCount: z.number().int(),
294
+ restoredCount: z.number().int(),
295
+ releasedCount: z.number().int(),
296
+ discountTotal: z.number().int().describe("사용 중(`USED`)인 기록의 할인 합계 (원)"),
297
+ orderPaymentTotal: z
298
+ .number()
299
+ .int()
300
+ .describe(
301
+ "이 쿠폰을 쓴(`USED`) 주문의 결제 금액 합계 (원). 부분 취소·반품 환불은 빼지 않은 결제 시점 금액이다",
302
+ ),
303
+ });
304
+ export type CouponStats = z.infer<typeof couponStatsSchema>;
305
+
306
+ /** `GET /v1/customers/{customerId}/coupons` 항목 — 발급 쿠폰 + 쿠폰 이름·혜택 */
307
+ export const customerCouponSchema = couponIssueSchema.extend({
308
+ couponName: z.string(),
309
+ kind: couponKindSchema,
310
+ benefit: couponBenefitSchema,
311
+ });
312
+ export type CustomerCoupon = z.infer<typeof customerCouponSchema>;