@sayren/storefront-sdk 0.13.0 → 0.15.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.
@@ -0,0 +1,169 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * 스토어프론트 쿠폰 계약 (이슈 #47).
5
+ *
6
+ * 구매자는 주문서에서 쿠폰을 고르거나 코드를 입력한다. 금액 미리보기(`POST /checkout/{checkoutId}/pricing`)는
7
+ * 주문서를 바꾸지 않는 읽기 계산이고, **확정은 결제 시작(`coupons` 필드)만 한다** — 결제 시작이 쿠폰을 예약하고 그 금액으로
8
+ * 결제 금액을 정한다. 결제가 실패하거나 만료되면 예약이 풀려 같은 쿠폰을 다시 쓸 수 있다.
9
+ *
10
+ * 한 주문에 주문 쿠폰 1장, 주문상품 한 줄에 상품 쿠폰 1장까지다. 같은 쿠폰을 두 번 쓸 수 없다.
11
+ */
12
+
13
+ /** 적용할 쿠폰 한 장 — 발급 쿠폰 id 또는 코드 중 하나 */
14
+ export const couponApplicationSchema = z
15
+ .object({
16
+ issueId: z
17
+ .string()
18
+ .min(1)
19
+ .optional()
20
+ .describe("내 쿠폰(발급 쿠폰) id — `GET /me/coupons`의 `issueId`"),
21
+ code: z
22
+ .string()
23
+ .trim()
24
+ .min(1)
25
+ .max(30)
26
+ .optional()
27
+ .describe("쿠폰 코드. 대소문자를 구분하지 않는다"),
28
+ optionId: z
29
+ .string()
30
+ .optional()
31
+ .describe(
32
+ "상품 쿠폰을 걸 주문서 항목의 `optionId`. 생략하면 할인이 가장 큰 항목에 건다. 주문 쿠폰에는 쓰지 않는다",
33
+ ),
34
+ })
35
+ .refine((value) => (value.issueId ? 1 : 0) + (value.code ? 1 : 0) === 1, {
36
+ message: "issueId와 code 중 하나만 보내 주십시오",
37
+ });
38
+ export type CouponApplication = z.infer<typeof couponApplicationSchema>;
39
+
40
+ /** 한 번에 적용할 수 있는 쿠폰 수 */
41
+ export const MAX_COUPONS_PER_CHECKOUT = 5;
42
+
43
+ export const couponApplicationListSchema = z
44
+ .array(couponApplicationSchema)
45
+ .max(MAX_COUPONS_PER_CHECKOUT)
46
+ .describe("적용할 쿠폰. 주문 쿠폰 1장, 주문상품 한 줄에 상품 쿠폰 1장까지 함께 쓴다");
47
+
48
+ /** 쿠폰 혜택 — 응답 표시용. 값이 늘 수 있어 방식은 문자열이다 */
49
+ export const couponBenefitViewSchema = z.object({
50
+ type: z.string().describe("할인 방식. 현재 값은 `RATE`(정률 %)·`AMOUNT`(정액 원)"),
51
+ value: z.number().int().describe("정률이면 %, 정액이면 원"),
52
+ maxDiscountAmount: z.number().int().nullable().describe("정률 할인의 최대 할인액. 없으면 null"),
53
+ });
54
+
55
+ /** 쿠폰 한 장의 적용 결과 */
56
+ export const couponResultSchema = z.object({
57
+ couponId: z.string(),
58
+ issueId: z.string().nullable().describe("발급 쿠폰 id. 코드로 적용했으면 null"),
59
+ code: z.string().nullable().describe("코드로 적용했으면 그 코드(대문자). 그 밖은 null"),
60
+ name: z.string(),
61
+ kind: z.string().describe("쿠폰 종류. 현재 값은 `ORDER`(주문 쿠폰)·`PRODUCT`(상품 쿠폰)"),
62
+ benefit: couponBenefitViewSchema,
63
+ applied: z.boolean(),
64
+ discountAmount: z.number().int().describe("이 쿠폰의 할인액. 적용하지 못했으면 0"),
65
+ rejectReason: z
66
+ .string()
67
+ .nullable()
68
+ .describe(
69
+ "적용하지 못한 사유. `MIN_ORDER_AMOUNT`(최소 주문 금액 미달)·`NO_ELIGIBLE_ITEMS`(대상 상품 없음)·`NOT_STARTED`·`EXPIRED`·" +
70
+ "`MEMBER_ONLY`(회원 전용)·`STACK_LIMIT`(같은 종류 쿠폰이 이미 적용됨)·`DUPLICATE_COUPON`·`LINE_NOT_ELIGIBLE`·`NO_DISCOUNT`·" +
71
+ "`IN_USE`(다른 결제에서 쓰는 중)·`LIMIT_REACHED`(1인 사용 한도)·`EXHAUSTED`(총 사용 한도)·`INACTIVE`(중지된 쿠폰). 값이 늘 수 있다",
72
+ ),
73
+ optionId: z
74
+ .string()
75
+ .nullable()
76
+ .describe("상품 쿠폰이 걸린 주문서 항목의 `optionId`. 그 밖은 null"),
77
+ });
78
+ export type CouponResult = z.infer<typeof couponResultSchema>;
79
+
80
+ /** 쿠폰을 반영한 금액 — 결제 금액 = productAmount − couponDiscountAmount + deliveryFee − deliveryDiscountAmount */
81
+ export const pricingAmountsSchema = z.object({
82
+ productAmount: z.number().int(),
83
+ deliveryFee: z.number().int(),
84
+ couponDiscountAmount: z.number().int(),
85
+ deliveryDiscountAmount: z.number().int(),
86
+ totalAmount: z.number().int().describe("결제 금액"),
87
+ });
88
+
89
+ /** `POST /checkout/{checkoutId}/pricing` 요청 */
90
+ export const checkoutPricingRequestSchema = z.object({
91
+ zipCode: z
92
+ .string()
93
+ .regex(/^[0-9]{5}$/, "우편번호 5자리를 입력해 주십시오")
94
+ .optional()
95
+ .describe("배송지 우편번호. 주면 제주·도서산간 추가 배송비까지 반영한다"),
96
+ coupons: couponApplicationListSchema,
97
+ });
98
+ export type CheckoutPricingRequest = z.infer<typeof checkoutPricingRequestSchema>;
99
+
100
+ /** 결제 금액의 하한 — 쿠폰 적용 뒤 결제 금액이 이보다 작으면 결제를 시작할 수 없다 */
101
+ export const MIN_PAYMENT_AMOUNT = 100;
102
+
103
+ export const checkoutPricingSchema = z.object({
104
+ amounts: pricingAmountsSchema,
105
+ coupons: z.array(couponResultSchema).describe("요청한 쿠폰마다 한 줄, 요청 순서대로"),
106
+ items: z
107
+ .array(
108
+ z.object({
109
+ optionId: z.string().nullable(),
110
+ couponDiscountAmount: z.number().int().describe("이 항목에 들어간 쿠폰 할인"),
111
+ }),
112
+ )
113
+ .describe("주문서 항목별 쿠폰 할인 — 주문서 `items`와 같은 순서"),
114
+ payable: z
115
+ .boolean()
116
+ .describe(
117
+ "이 금액으로 결제를 시작할 수 있는가 — 적용하지 못한 쿠폰이 있거나 결제 금액이 100원 미만이면 false",
118
+ ),
119
+ });
120
+ export type CheckoutPricing = z.infer<typeof checkoutPricingSchema>;
121
+
122
+ /** `GET /checkout/{checkoutId}/coupons` 항목 — 이 주문서에 쓸 수 있는 내 쿠폰 */
123
+ export const applicableCouponSchema = z.object({
124
+ issueId: z.string(),
125
+ couponId: z.string(),
126
+ name: z.string(),
127
+ description: z.string().nullable(),
128
+ kind: z.string(),
129
+ benefit: couponBenefitViewSchema,
130
+ minOrderAmount: z.number().int(),
131
+ validUntil: z.string().nullable().describe("유효 기간 끝(이 시각부터 못 쓴다). 없으면 null"),
132
+ applicable: z.boolean().describe("이 주문서에 혼자 적용하면 할인이 되는가"),
133
+ expectedDiscountAmount: z.number().int().describe("혼자 적용했을 때의 예상 할인액"),
134
+ rejectReason: z
135
+ .string()
136
+ .nullable()
137
+ .describe("적용할 수 없는 사유(`couponResultSchema.rejectReason`과 같은 값)"),
138
+ });
139
+ export type ApplicableCoupon = z.infer<typeof applicableCouponSchema>;
140
+
141
+ /** `GET /me/coupons` 항목 */
142
+ export const myCouponSchema = z.object({
143
+ issueId: z.string(),
144
+ couponId: z.string(),
145
+ name: z.string(),
146
+ description: z.string().nullable(),
147
+ kind: z.string(),
148
+ benefit: couponBenefitViewSchema,
149
+ minOrderAmount: z.number().int(),
150
+ status: z
151
+ .string()
152
+ .describe(
153
+ "상태. 현재 값은 `AVAILABLE`(사용 가능)·`RESERVED`(결제 중)·`USED`(사용함)·`EXPIRED`(기간 지남)이고 값이 늘 수 있다",
154
+ ),
155
+ validFrom: z.string(),
156
+ validUntil: z.string().nullable(),
157
+ usedOrderId: z.string().nullable(),
158
+ issuedAt: z.string(),
159
+ });
160
+ export type MyCoupon = z.infer<typeof myCouponSchema>;
161
+
162
+ export const myCouponListQuerySchema = z.object({
163
+ status: z
164
+ .enum(["available", "used", "expired"])
165
+ .optional()
166
+ .describe(
167
+ "`available`=사용 가능(결제 중 포함), `used`=사용함, `expired`=기간 지남. 생략하면 전부",
168
+ ),
169
+ });
@@ -0,0 +1,181 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * 구매자용 상품 이행 (이슈 #57) — 관리 API의 상품 이행에서 구매자에게 필요한 것만 싣는다. 유형, 배송 필요 여부, 배송 안내(배송비·
5
+ * 무료 기준·반품·교환 배송비), 청약철회 제한 여부, 방문 수령지 표시값이다. 셀러 내부 값(출고지·반품지 id, 코드 풀 여부, 재고 추적 여부)은
6
+ * 싣지 않는다.
7
+ *
8
+ * 유형 이름은 응답에서 문자열이고, 모르는 유형(서버가 나중에 더한 유형, 예: #56 `RESERVATION`)은 `{ type: "OTHER", rawType,
9
+ * requiresShipping }`로 읽는다. `requiresShipping`은 판별 값에서 파생되지만 **모든 가지에 싣는다** — 모르는 유형에서도 주문서가
10
+ * 배송지를 받을지 정할 수 있어야 한다.
11
+ */
12
+
13
+ const KNOWN_TYPES: readonly string[] = ["SHIPPING", "DOWNLOAD", "CODE", "SERVICE", "PICKUP"];
14
+
15
+ /** 모르는 유형 — SDK가 아직 모르는 유형이다 */
16
+ export interface UnknownFulfillmentView {
17
+ type: "OTHER";
18
+ /** 서버가 보낸 유형 이름 */
19
+ rawType: string;
20
+ requiresShipping: boolean;
21
+ }
22
+
23
+ /** 알려진 유형만 판별 유니온으로 검사하고 모르는 유형은 `OTHER`로 읽는다. 알려진 유형의 모양이 틀리면 파싱 오류다 */
24
+ function openUnion<T extends z.ZodType>(known: T) {
25
+ return z.union([
26
+ known,
27
+ z
28
+ .looseObject({
29
+ type: z.string().refine((type) => !KNOWN_TYPES.includes(type), {
30
+ message: "알려진 유형은 해당 유형의 모양이어야 합니다",
31
+ }),
32
+ requiresShipping: z.boolean(),
33
+ })
34
+ .transform(
35
+ (value): UnknownFulfillmentView => ({
36
+ type: "OTHER",
37
+ rawType: value.type,
38
+ requiresShipping: value.requiresShipping,
39
+ }),
40
+ ),
41
+ ]);
42
+ }
43
+
44
+ const requiresShippingDescription =
45
+ "택배 배송이 필요한가. false면 배송비가 없고 주문서가 배송지를 받지 않는다";
46
+
47
+ const withdrawalRestrictedField = z
48
+ .boolean()
49
+ .describe(
50
+ "청약철회 제한 대상인가. true면 주문서가 제한 고지 동의를 받고, 첫 다운로드·열람 뒤 단순 변심 취소·반품을 받지 않는다",
51
+ );
52
+
53
+ /** 방문 수령 장소 표시값 — 주소 id는 싣지 않는다 */
54
+ export const pickupLocationSchema = z.object({
55
+ name: z.string().describe("수령 장소 이름"),
56
+ zipCode: z.string(),
57
+ address1: z.string(),
58
+ address2: z.string().nullable(),
59
+ phone: z.string().describe("수령 장소 연락처"),
60
+ });
61
+
62
+ export type PickupLocation = z.infer<typeof pickupLocationSchema>;
63
+
64
+ /** 상품 카드(목록·찜)의 이행 — 유형과 배송 필요 여부만. 배송비·환불 정책은 상세에서 본다 */
65
+ export const productCardFulfillmentSchema = z.object({
66
+ type: z.string().describe("상품 유형. 값이 추가될 수 있어 문자열로 받는다"),
67
+ requiresShipping: z.boolean().describe(requiresShippingDescription),
68
+ });
69
+
70
+ export type ProductCardFulfillment = z.infer<typeof productCardFulfillmentSchema>;
71
+
72
+ /** 상품 상세의 이행 */
73
+ export const productFulfillmentViewSchema = openUnion(
74
+ z.discriminatedUnion("type", [
75
+ z.object({
76
+ type: z.literal("SHIPPING"),
77
+ requiresShipping: z.literal(true),
78
+ shipping: z.object({
79
+ deliveryType: z
80
+ .string()
81
+ .describe("배송비 유형. 현재 값은 `FREE`·`PAID`·`CONDITIONAL_FREE`"),
82
+ deliveryFee: z
83
+ .number()
84
+ .int()
85
+ .describe("기본 배송비. 원 단위. 묶음·지역 추가 배송비는 장바구니·주문서 견적이 정한다"),
86
+ conditionalFreeAmount: z
87
+ .number()
88
+ .int()
89
+ .nullable()
90
+ .describe("조건부 무료 기준 금액. 조건부 무료가 아니면 null"),
91
+ estimatedDays: z.string().describe("예상 배송 기간 안내 문구"),
92
+ returnDeliveryFee: z
93
+ .number()
94
+ .int()
95
+ .describe("단순 변심 반품 배송비(유효 환불 정책). 원 단위"),
96
+ exchangeDeliveryFee: z
97
+ .number()
98
+ .int()
99
+ .describe("단순 변심 교환 배송비(유효 환불 정책). 원 단위"),
100
+ }),
101
+ }),
102
+ z.object({
103
+ type: z.literal("DOWNLOAD"),
104
+ requiresShipping: z.literal(false),
105
+ download: z.object({
106
+ limit: z.number().int().describe("다운로드 횟수 한도(수량 1개당)"),
107
+ days: z.number().int().describe("다운로드 기간(제공일부터 일수)"),
108
+ withdrawalRestricted: withdrawalRestrictedField,
109
+ }),
110
+ }),
111
+ z.object({
112
+ type: z.literal("CODE"),
113
+ requiresShipping: z.literal(false),
114
+ code: z.object({ withdrawalRestricted: withdrawalRestrictedField }),
115
+ }),
116
+ z.object({ type: z.literal("SERVICE"), requiresShipping: z.literal(false) }),
117
+ z.object({
118
+ type: z.literal("PICKUP"),
119
+ requiresShipping: z.literal(false),
120
+ pickup: z.object({
121
+ location: pickupLocationSchema
122
+ .nullable()
123
+ .describe("방문 수령 장소. 수령지가 지워졌으면 null이다(판매자에게 문의)"),
124
+ }),
125
+ }),
126
+ ]),
127
+ ).describe(
128
+ "상품 이행. `type`이 유형이고 같은 이름의 키가 유형별 안내다. 값이 추가될 수 있어 SDK는 모르는 유형을 `OTHER`로 읽는다",
129
+ );
130
+
131
+ export type ProductFulfillmentView = z.infer<typeof productFulfillmentViewSchema>;
132
+
133
+ /** 장바구니 줄·주문서 줄의 이행 — 주문서 줄은 주문서 시점 스냅샷이다 */
134
+ export const lineFulfillmentSchema = z.object({
135
+ type: z.string().describe("상품 유형. 값이 추가될 수 있어 문자열로 받는다"),
136
+ requiresShipping: z.boolean().describe(requiresShippingDescription),
137
+ withdrawalRestricted: withdrawalRestrictedField,
138
+ });
139
+
140
+ export type LineFulfillment = z.infer<typeof lineFulfillmentSchema>;
141
+
142
+ /**
143
+ * 내 주문 주문상품의 주문 시점 이행 스냅샷. 상품을 나중에 고쳐도 바뀌지 않는다. 실제 제공 기록(코드 가린 값·다운로드 남은 횟수)은
144
+ * 주문상품의 `fulfillment`다.
145
+ */
146
+ export const myOrderItemFulfillmentSnapshotSchema = openUnion(
147
+ z.discriminatedUnion("type", [
148
+ z.object({ type: z.literal("SHIPPING"), requiresShipping: z.literal(true) }),
149
+ z.object({
150
+ type: z.literal("DOWNLOAD"),
151
+ requiresShipping: z.literal(false),
152
+ download: z.object({ withdrawalRestricted: withdrawalRestrictedField }),
153
+ }),
154
+ z.object({
155
+ type: z.literal("CODE"),
156
+ requiresShipping: z.literal(false),
157
+ code: z.object({ withdrawalRestricted: withdrawalRestrictedField }),
158
+ }),
159
+ z.object({ type: z.literal("SERVICE"), requiresShipping: z.literal(false) }),
160
+ z.object({
161
+ type: z.literal("PICKUP"),
162
+ requiresShipping: z.literal(false),
163
+ pickup: z.object({
164
+ location: pickupLocationSchema
165
+ .nullable()
166
+ .describe("주문 시점 방문 수령 장소. 수령지가 지워졌으면 null이다(판매자에게 문의)"),
167
+ }),
168
+ }),
169
+ ]),
170
+ ).describe("주문 시점 이행 스냅샷. 값이 추가될 수 있어 SDK는 모르는 유형을 `OTHER`로 읽는다");
171
+
172
+ export type MyOrderItemFulfillmentSnapshot = z.infer<typeof myOrderItemFulfillmentSnapshotSchema>;
173
+
174
+ /** 청약철회 제한 대상인가 — 파일 다운로드·코드 발급만 켤 수 있다 */
175
+ export function withdrawalRestrictedOfView(
176
+ fulfillment: ProductFulfillmentView | MyOrderItemFulfillmentSnapshot,
177
+ ): boolean {
178
+ if (fulfillment.type === "DOWNLOAD") return fulfillment.download.withdrawalRestricted;
179
+ if (fulfillment.type === "CODE") return fulfillment.code.withdrawalRestricted;
180
+ return false;
181
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { easyPayProviderResponseSchema, paymentMethodSchema } from "./checkout";
3
3
  import { orderItemStatusSchema } from "./common";
4
+ import { myOrderItemFulfillmentSnapshotSchema } from "./fulfillment";
4
5
 
5
6
  export const myOrderItemSchema = z.object({
6
7
  orderItemId: z.string(),
@@ -20,8 +21,69 @@ export const myOrderItemSchema = z.object({
20
21
  .number()
21
22
  .int()
22
23
  .describe("남은 수량 (`quantity` − `canceledQuantity`). 취소·반품·교환 요청 수량의 상한이다"),
23
- totalPrice: z.number().int(),
24
+ totalPrice: z.number().int().describe("주문상품 금액 (즉시할인 반영, 쿠폰 전)"),
25
+ couponDiscountAmount: z
26
+ .number()
27
+ .int()
28
+ .optional()
29
+ .default(0)
30
+ .describe(
31
+ "이 주문상품에 배분된 쿠폰 할인. 실결제 금액은 `totalPrice − couponDiscountAmount`다. 쿠폰이 없으면 0. 서버는 항상 싣는다",
32
+ ),
24
33
  status: orderItemStatusSchema,
34
+ fulfillmentSnapshot: myOrderItemFulfillmentSnapshotSchema.describe(
35
+ "주문 시점 이행 스냅샷(이슈 #57). `type`이 주문 시점 상품 유형이다. 배송 상품이 아니면 `DELIVERED`는 제공 완료(방문 수령은 수령 완료)다. " +
36
+ "청약철회 제한 주문상품(`download`·`code`의 `withdrawalRestricted`)은 제공이 시작되면(다운로드·열람) 구매자 취소·반품을 받지 않는다",
37
+ ),
38
+ fulfillment: z
39
+ .object({
40
+ status: z
41
+ .string()
42
+ .describe(
43
+ "제공 상태. `FULFILLED`(제공함)·`REVOKED`(환불 등으로 회수함). 값이 추가될 수 있어 문자열로 받는다",
44
+ ),
45
+ fulfilledAt: z.string().describe("제공 시각 (ISO 8601)"),
46
+ note: z.string().nullable().describe("셀러가 남긴 이용 안내. 없으면 null"),
47
+ codes: z
48
+ .array(
49
+ z.object({
50
+ codeId: z.string(),
51
+ hint: z.string().describe("가린 코드 — 끝 4자만 보인다(예: `****AB12`)"),
52
+ revealedAt: z.string().nullable().describe("처음 열람한 시각. 열람 전이면 null"),
53
+ }),
54
+ )
55
+ .optional()
56
+ .default([])
57
+ .describe(
58
+ "코드 발급 상품의 코드(가린 값). 평문은 `fulfillment/reveal`로 연다. 환불로 회수된 코드는 싣지 않는다. 서버는 항상 싣는다",
59
+ ),
60
+ download: z
61
+ .object({
62
+ count: z.number().int().describe("지금까지 받은 횟수"),
63
+ limit: z.number().int().describe("받을 수 있는 횟수"),
64
+ expiresAt: z.string().nullable().describe("받을 수 있는 기한 (ISO 8601)"),
65
+ files: z.array(
66
+ z.object({
67
+ assetId: z.string(),
68
+ fileName: z.string(),
69
+ sizeBytes: z.number().int().nullable(),
70
+ }),
71
+ ),
72
+ })
73
+ .nullable()
74
+ .optional()
75
+ .default(null)
76
+ .describe(
77
+ "파일 다운로드 상품의 횟수·기한·파일 목록. 파일 주소는 `downloads`로 받는다. 다른 유형은 null. 서버는 항상 싣는다",
78
+ ),
79
+ })
80
+ .nullable()
81
+ .optional()
82
+ .default(null)
83
+ .describe(
84
+ "비실물 상품(이용권·방문 수령 등)의 제공 정보(이슈 #48). 제공 전이거나 배송 상품이면 null이다. " +
85
+ "비실물의 `DELIVERED`는 제공 완료(방문 수령은 수령 완료)로 표시한다. 서버는 항상 싣는다",
86
+ ),
25
87
  claimStatus: z.string().nullable(),
26
88
  reviewWritten: z.boolean(),
27
89
  autoDecisionDate: z.string().nullable(),
@@ -52,6 +114,11 @@ export const myOrderSchema = z.object({
52
114
  orderId: z.string(),
53
115
  orderedAt: z.string(),
54
116
  items: z.array(myOrderItemSchema),
117
+ requiresShipping: z
118
+ .boolean()
119
+ .describe(
120
+ "택배 배송이 필요한 주문상품이 있는가. false면 `shippingAddress`는 주문자 이름·연락처만 있고 주소는 빈 문자열이다",
121
+ ),
55
122
  shippingAddress: orderShippingAddressSchema,
56
123
  payment: z.object({
57
124
  method: paymentMethodSchema.describe(
@@ -66,10 +133,46 @@ export const myOrderSchema = z.object({
66
133
  "그 밖의 결제수단과 간편결제 이전 주문은 null. 서버는 항상 싣는다. " +
67
134
  "현재 값은 `NAVERPAY`·`KAKAOPAY`·`TOSSPAY`·`PAYCO`이고 값이 추가될 수 있어 문자열로 받는다",
68
135
  ),
69
- totalAmount: z.number().int(),
136
+ couponDiscountAmount: z
137
+ .number()
138
+ .int()
139
+ .optional()
140
+ .default(0)
141
+ .describe("상품·주문 쿠폰 할인 합계. 쿠폰이 없으면 0. 서버는 항상 싣는다"),
142
+ deliveryDiscountAmount: z
143
+ .number()
144
+ .int()
145
+ .optional()
146
+ .default(0)
147
+ .describe("배송비 쿠폰 할인. 쿠폰이 없으면 0. 서버는 항상 싣는다"),
148
+ discounts: z
149
+ .array(
150
+ z.object({
151
+ couponId: z.string(),
152
+ name: z.string().describe("주문 시점 쿠폰 이름"),
153
+ kind: z
154
+ .string()
155
+ .describe(
156
+ "쿠폰 종류. 현재 값은 `ORDER`·`PRODUCT`·`DELIVERY`이고 값이 추가될 수 있어 문자열로 받는다",
157
+ ),
158
+ discountAmount: z.number().int(),
159
+ }),
160
+ )
161
+ .optional()
162
+ .default([])
163
+ .describe("주문이 받은 쿠폰. 쿠폰이 없으면 빈 배열. 서버는 항상 싣는다"),
164
+ totalAmount: z.number().int().describe("결제 금액 (쿠폰 할인 반영)"),
70
165
  paidAt: z.string().nullable(),
71
166
  receiptUrl: z.url().nullable(),
72
167
  }),
168
+ withdrawalAgreedAt: z
169
+ .string()
170
+ .nullable()
171
+ .optional()
172
+ .default(null)
173
+ .describe(
174
+ "청약철회 제한 안내에 동의한 시각(이슈 #48). 제한 상품이 없던 주문은 null. 서버는 항상 싣는다",
175
+ ),
73
176
  testPayment: z
74
177
  .boolean()
75
178
  .describe(
@@ -136,3 +239,43 @@ export const purchaseDecisionResultSchema = z.object({
136
239
  });
137
240
 
138
241
  export type PurchaseDecisionResult = z.infer<typeof purchaseDecisionResultSchema>;
242
+
243
+ /**
244
+ * 코드 열람 응답 (이슈 #48) — `POST /storefront/v1/me/order-items/{orderItemId}/fulfillment/reveal`(회원)·
245
+ * `POST /storefront/v1/guest/orders/{orderId}/order-items/{orderItemId}/fulfillment/reveal`(비회원).
246
+ * 평문 코드는 이 응답에만 있다. 화면에 보인 뒤 저장하지 않는다(응답은 `Cache-Control: no-store`).
247
+ * 첫 열람 시각은 청약철회 판단의 근거(제공 개시)로 남는다.
248
+ */
249
+ export const fulfillmentRevealSchema = z.object({
250
+ orderItemId: z.string(),
251
+ codes: z.array(
252
+ z.object({
253
+ codeId: z.string(),
254
+ code: z.string().describe("코드 평문"),
255
+ revealedAt: z.string().describe("처음 열람한 시각"),
256
+ }),
257
+ ),
258
+ });
259
+
260
+ export type FulfillmentReveal = z.infer<typeof fulfillmentRevealSchema>;
261
+
262
+ /**
263
+ * 다운로드 요청 (이슈 #48) — `POST /storefront/v1/me/order-items/{orderItemId}/downloads`(회원)·
264
+ * `POST /storefront/v1/guest/orders/{orderId}/order-items/{orderItemId}/downloads`(비회원, 연락처·비밀번호를 함께 보낸다).
265
+ * 파일이 하나면 `assetId`를 생략할 수 있다.
266
+ */
267
+ export const downloadRequestSchema = z.object({
268
+ assetId: z.string().min(1).max(64).optional().describe("받을 파일. 파일이 여러 개면 필수다"),
269
+ });
270
+
271
+ export type DownloadRequest = z.infer<typeof downloadRequestSchema>;
272
+
273
+ /** 다운로드 응답 — 수명 300초의 서명 주소. 받을 때마다 횟수가 하나 준다 */
274
+ export const downloadLinkSchema = z.object({
275
+ url: z.url().describe("서명 주소. 300초 뒤 만료된다 — 받아서 바로 쓰고 저장하지 않는다"),
276
+ expiresAt: z.string().describe("서명 주소 만료 시각"),
277
+ fileName: z.string(),
278
+ remaining: z.number().int().describe("남은 다운로드 횟수"),
279
+ });
280
+
281
+ export type DownloadLink = z.infer<typeof downloadLinkSchema>;