@sayren/store-sdk 0.9.0 → 0.11.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.9.0",
3
+ "version": "0.11.0",
4
4
  "main": "./dist/index.mjs",
5
5
  "types": "./dist/index.d.mts",
6
6
  "exports": {
package/src/client.ts CHANGED
@@ -70,6 +70,8 @@ import {
70
70
  } from "./schemas/claim";
71
71
  import { bulkResultSchema, type PageParams, pageSchema } from "./schemas/common";
72
72
  import {
73
+ type ConsentKind,
74
+ customerConsentSchema,
73
75
  customerDetailSchema,
74
76
  type customerListQuerySchema,
75
77
  customerOrderSchema,
@@ -126,6 +128,7 @@ import {
126
128
  purchaseDecisionExtendResultSchema,
127
129
  } from "./schemas/order";
128
130
  import {
131
+ cashReceiptSchema,
129
132
  type PaymentListParams,
130
133
  type PgEnvironment,
131
134
  type PgProvider,
@@ -528,6 +531,12 @@ export function createStoreClient(options: StoreClientOptions) {
528
531
  query: params as unknown as Query,
529
532
  }),
530
533
  get: (orderId: string) => storeHttp.request("GET", path`/orders/${orderId}`, orderSchema),
534
+ /**
535
+ * `POST /v1/orders/{orderId}/cash-receipt/reissue` — 실패했거나 재시도 대기 중인 현금영수증을 지금 다시 발급·취소한다.
536
+ * 콘솔 OWNER·ADMIN 전용(PAT·M2M 403 `INSUFFICIENT_ROLE`). 404 `CASH_RECEIPT_NOT_FOUND`, 409 `CASH_RECEIPT_NOT_REISSUABLE`
537
+ */
538
+ reissueCashReceipt: (orderId: string) =>
539
+ storeHttp.request("POST", path`/orders/${orderId}/cash-receipt/reissue`, cashReceiptSchema),
531
540
  },
532
541
 
533
542
  orderItems: {
@@ -708,6 +717,14 @@ export function createStoreClient(options: StoreClientOptions) {
708
717
  query: params as Query,
709
718
  },
710
719
  ),
720
+ /** 동의 이력 — 최근 순. 가입·재동의·마케팅 변경이 한 줄씩 남는다 */
721
+ consents: (customerId: string, params?: PageParams & { kind?: ConsentKind }) =>
722
+ storeHttp.request(
723
+ "GET",
724
+ path`/customers/${customerId}/consents`,
725
+ pageSchema(customerConsentSchema),
726
+ { query: params as Query },
727
+ ),
711
728
  withdraw: (customerId: string, body: WithdrawCustomerRequest) =>
712
729
  storeHttp.requestVoid("POST", path`/customers/${customerId}/withdraw`, {
713
730
  body: withdrawCustomerRequestSchema.parse(body),
package/src/index.ts CHANGED
@@ -24,7 +24,9 @@ export * from "./schemas/misc";
24
24
  export * from "./schemas/order";
25
25
  export * from "./schemas/payment";
26
26
  export * from "./schemas/product";
27
+ export * from "./schemas/refund-policy";
27
28
  export * from "./schemas/scope";
29
+ export * from "./schemas/shipping-policy";
28
30
  export * from "./schemas/store";
29
31
  export * from "./schemas/upload";
30
32
  export * from "./schemas/webhook-event";
@@ -30,6 +30,24 @@ export const claimReasonSchema = z.enum([
30
30
 
31
31
  export type ClaimReason = z.infer<typeof claimReasonSchema>;
32
32
 
33
+ /**
34
+ * 반품지 (이슈 #30) — 반품·교환 수거가 안내하는 주소다. 스토어 주소록의 기본 반품지이거나,
35
+ * 상품이 반품지를 따로 지정했으면 그 주소다.
36
+ *
37
+ * 취소 클레임은 보낼 물건이 없어 null이고, 반품지를 등록하지 않은 스토어도 null이다.
38
+ * 구매자가 직접 보내는 수거(`BUYER_SEND`)에 이 주소가 필요하다.
39
+ */
40
+ export const claimReturnAddressSchema = z.object({
41
+ name: z.string().describe("주소 이름(셀러가 붙인 별칭). 예: 본사 물류센터"),
42
+ contactName: z.string().describe("수취인 이름"),
43
+ phone: z.string().describe("연락처"),
44
+ zipCode: z.string(),
45
+ address1: z.string(),
46
+ address2: z.string().nullable(),
47
+ });
48
+
49
+ export type ClaimReturnAddress = z.infer<typeof claimReturnAddressSchema>;
50
+
33
51
  export const claimSchema = z.object({
34
52
  claimId: z.string(),
35
53
  type: claimTypeSchema,
@@ -41,8 +59,24 @@ export const claimSchema = z.object({
41
59
  reason: claimReasonSchema,
42
60
  reasonDetail: z.string().nullable(),
43
61
  responsibility: z.enum(["BUYER", "SELLER"]),
44
- claimDeliveryFee: z.number().int(),
45
- expectedRefundAmount: z.number().int().nullable(),
62
+ claimDeliveryFee: z.number().int().describe("구매자가 부담하는 반품·교환 배송비. 원 단위"),
63
+ expectedRefundAmount: z
64
+ .number()
65
+ .int()
66
+ .nullable()
67
+ .describe(
68
+ "접수 시점에 계산한 예상 환불액 — 수량 비율 상품 금액 − 반품 배송비 − 조건부 무료배송 미달 차감. " +
69
+ "**배송비 환불은 포함되지 않고 `expectedDeliveryFeeRefundAmount`로 따로 온다**" +
70
+ "(승인 시점의 배송 묶음 상태로 확정되기 때문이다). 교환은 환불이 없어 null이다",
71
+ ),
72
+ expectedDeliveryFeeRefundAmount: z
73
+ .number()
74
+ .int()
75
+ .nullable()
76
+ .describe(
77
+ "접수 시점에 계산한 예상 배송비 환불액. 배송 묶음(같은 상품의 주문상품)이 모두 취소되거나 " +
78
+ "판매자 귀책일 때만 0보다 크다. 이 필드가 생기기 전에 접수된 클레임은 null이다",
79
+ ),
46
80
  exchangeOption: z.object({ optionId: z.string(), optionName: z.string() }).nullable(),
47
81
  collect: z
48
82
  .object({
@@ -57,6 +91,12 @@ export const claimSchema = z.object({
57
91
  holdExpiresAt: z.string().nullable(),
58
92
  /** 보류를 이미 썼는지 — 보류는 클레임당 1회라 해제·만료 뒤에도 true */
59
93
  holdUsed: z.boolean(),
94
+ returnAddress: claimReturnAddressSchema
95
+ .nullable()
96
+ .describe(
97
+ "수거 안내에 쓸 반품지(이슈 #30). 상품이 지정한 반품지, 없으면 스토어 기본 반품지다. " +
98
+ "취소 클레임과 반품지를 등록하지 않은 스토어는 null",
99
+ ),
60
100
  });
61
101
 
62
102
  export type Claim = z.infer<typeof claimSchema>;
@@ -119,8 +159,26 @@ export type RejectClaimRequest = z.infer<typeof rejectClaimRequestSchema>;
119
159
 
120
160
  export const approveClaimResultSchema = z.object({
121
161
  claimId: z.string(),
122
- refundAmount: z.number().int(),
123
- deductAmount: z.number().int(),
162
+ refundAmount: z
163
+ .number()
164
+ .int()
165
+ .describe(
166
+ "실제 환불한 금액 — 수량 비율 상품 금액 + 배송비 환불 − 반품 배송비 − 조건부 무료배송 미달 차감 − " +
167
+ "추가 차감(`deductAmount`)",
168
+ ),
169
+ deductAmount: z.number().int().describe("승인 때 셀러가 추가로 차감한 금액"),
170
+ quantity: z.number().int().describe("환불한 수량"),
171
+ deliveryFeeRefundAmount: z
172
+ .number()
173
+ .int()
174
+ .describe("환불에 포함된 배송비. 배송 묶음 전량 취소나 판매자 귀책일 때만 0보다 크다"),
175
+ deliveryFeeChargeAmount: z
176
+ .number()
177
+ .int()
178
+ .describe(
179
+ "환불액에서 뺀 **배송비** — 반품·교환 배송비 + 조건부 무료배송 미달 차감. " +
180
+ "추가 차감(`deductAmount`)은 포함하지 않는다",
181
+ ),
124
182
  refundedAt: z.string(),
125
183
  });
126
184
 
@@ -58,6 +58,75 @@ export const customerAddressSchema = z.object({
58
58
  address2: z.string().nullable(),
59
59
  });
60
60
 
61
+ // ── 동의 이력 (이슈 #26) ────────────────────────────────────
62
+
63
+ /** 동의 종류 — 이용약관·개인정보 수집이용은 가입 필수, 마케팅 수신은 선택 */
64
+ export const consentKindSchema = z.enum(["TERMS", "PRIVACY", "MARKETING"]);
65
+ export type ConsentKind = z.infer<typeof consentKindSchema>;
66
+
67
+ export const consentActionSchema = z.enum(["AGREED", "WITHDRAWN"]);
68
+ export type ConsentAction = z.infer<typeof consentActionSchema>;
69
+
70
+ /**
71
+ * 동의가 일어난 경로. 값이 추가될 수 있어 모르는 값은 그대로 보여 준다.
72
+ * `SIGNUP` 비밀번호 가입 · `SOCIAL_SIGNUP` 소셜 가입 · `DELEGATED` 셀러 자체 회원 연동 ·
73
+ * `RECONSENT` 약관 개정 재동의 · `MEMBER_UPDATE` 구매자가 직접 변경 · `SELLER` 셀러가 처리
74
+ */
75
+ export const consentSourceSchema = z.enum([
76
+ "SIGNUP",
77
+ "SOCIAL_SIGNUP",
78
+ "DELEGATED",
79
+ "RECONSENT",
80
+ "MEMBER_UPDATE",
81
+ "SELLER",
82
+ ]);
83
+ export type ConsentSource = z.infer<typeof consentSourceSchema>;
84
+
85
+ /** 동의 이력 한 줄 — 추가만 되고 지워지지 않는다(구매자 탈퇴 시에만 함께 지워진다) */
86
+ export const customerConsentSchema = z.object({
87
+ consentId: z.string(),
88
+ kind: consentKindSchema,
89
+ /** 동의한 약관 버전. 마케팅은 버전이 없어 null */
90
+ version: z.string().nullable(),
91
+ /** 동의 시점의 약관 문서 주소. 스토어가 주소를 정하지 않았거나 마케팅이면 null */
92
+ documentUrl: z.string().nullable(),
93
+ action: consentActionSchema,
94
+ source: consentSourceSchema,
95
+ occurredAt: z.string(),
96
+ });
97
+ export type CustomerConsent = z.infer<typeof customerConsentSchema>;
98
+
99
+ /** 종류별 현재 동의 상태 */
100
+ export const customerConsentStateSchema = z.object({
101
+ kind: consentKindSchema,
102
+ agreed: z.boolean(),
103
+ /** 동의한 약관 버전. 마케팅이거나 이력이 없으면 null */
104
+ agreedVersion: z.string().nullable(),
105
+ /** 스토어의 현재 약관 버전. 마케팅은 null */
106
+ currentVersion: z.string().nullable(),
107
+ /** 스토어가 정한 현재 약관 문서 주소. 정하지 않았거나 마케팅이면 null */
108
+ currentDocumentUrl: z.string().nullable(),
109
+ updatedAt: z.string().nullable(),
110
+ /** 약관이 개정돼 다시 받아야 하는가. 마케팅은 항상 false */
111
+ reconsentRequired: z.boolean(),
112
+ });
113
+ export type CustomerConsentState = z.infer<typeof customerConsentStateSchema>;
114
+
115
+ export const customerConsentsSchema = z.object({
116
+ terms: customerConsentStateSchema,
117
+ privacy: customerConsentStateSchema,
118
+ marketing: customerConsentStateSchema,
119
+ /** 필수 동의 중 하나라도 현재 버전이 아니면 true */
120
+ reconsentRequired: z.boolean(),
121
+ });
122
+ export type CustomerConsents = z.infer<typeof customerConsentsSchema>;
123
+
124
+ /** `GET /v1/customers/{id}/consents` 쿼리 — 최근 순이다 */
125
+ export const customerConsentListQuerySchema = z.object({
126
+ kind: consentKindSchema.optional().describe("동의 종류 필터. 생략하면 전체"),
127
+ ...pageQueryShape(),
128
+ });
129
+
61
130
  /** 구매자 상세 — 휴대폰 전체를 보여 준다 */
62
131
  export const customerDetailSchema = z.object({
63
132
  customerId: z.string(),
@@ -76,6 +145,9 @@ export const customerDetailSchema = z.object({
76
145
  }),
77
146
  )
78
147
  .describe("로그인 방법 — 이메일·비밀번호(`password`)와 연결된 소셜 계정"),
148
+ consents: customerConsentsSchema.describe(
149
+ "약관·개인정보·마케팅 동의의 현재 상태. 이력은 `GET /v1/customers/{customerId}/consents`",
150
+ ),
79
151
  addresses: z.array(customerAddressSchema),
80
152
  stats: customerStatsSchema.extend({
81
153
  canceledOrReturnedCount: z.number().int().describe("취소된 주문 상품 + 완료된 반품 수"),
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { changeTypeSchema, orderItemStatusSchema, type PageParams, pageSchema } from "./common";
3
- import { easyPayProviderResponseSchema } from "./payment";
3
+ import { cashReceiptSchema, easyPayProviderResponseSchema } from "./payment";
4
4
 
5
5
  export const orderSummarySchema = z.object({
6
6
  orderId: z.string(),
@@ -40,10 +40,18 @@ export const orderItemSchema = z.object({
40
40
  optionId: z.string().nullable(),
41
41
  optionName: z.string().nullable(),
42
42
  sellerCode: z.string().nullable(),
43
- quantity: z.number().int(),
43
+ quantity: z.number().int().describe("주문 수량"),
44
+ canceledQuantity: z
45
+ .number()
46
+ .int()
47
+ .describe("취소·반품으로 환불된 누적 수량. 수량 단위 부분 취소가 없었으면 0이다"),
48
+ activeQuantity: z
49
+ .number()
50
+ .int()
51
+ .describe("살아 있는 수량 (`quantity` − `canceledQuantity`). 0이면 주문상품이 전량 취소됐다"),
44
52
  unitPrice: z.number().int(),
45
53
  discountAmount: z.number().int(),
46
- totalPrice: z.number().int(),
54
+ totalPrice: z.number().int().describe("주문상품 금액 (할인 반영 단가 × 주문 수량)"),
47
55
  hold: z.object({ reason: z.string(), expectedDispatchDate: z.string() }).nullable(),
48
56
  autoDecisionDate: z.string().nullable(),
49
57
  sellerMemo: z.string().nullable(),
@@ -99,6 +107,13 @@ export const orderSchema = z.object({
99
107
  discountAmount: z.number().int(),
100
108
  totalAmount: z.number().int(),
101
109
  paidAt: z.string().nullable(),
110
+ cashReceipt: cashReceiptSchema
111
+ .nullable()
112
+ .optional()
113
+ .default(null)
114
+ .describe(
115
+ "현금영수증. 구매자가 계좌이체·가상계좌 결제에서 신청했을 때만 있고 그 밖에는 null. 서버는 항상 싣는다",
116
+ ),
102
117
  }),
103
118
  testPayment: z
104
119
  .boolean()
@@ -193,7 +208,14 @@ export const dispatchRequestSchema = z
193
208
  deliveryMethod: deliveryMethodSchema,
194
209
  carrierCode: z.string().optional(),
195
210
  trackingNumber: z.string().optional(),
196
- dispatchedAt: z.string().optional(),
211
+ /**
212
+ * 발송 시각. ISO 8601이고 시간대(`Z` 또는 `+09:00`)를 포함해야 한다. 생략하면 현재 시각이다.
213
+ *
214
+ * 전에는 `z.string()`이라 아무 문자열이나 그대로 저장됐다. 이 값은 배송 정보 jsonb에 들어가
215
+ * 자동 배송완료·추적 동기화 잡의 대상 선별 조건이 된다 — 잘못된 한 행이 스토어 구분 없이 도는
216
+ * 잡 전체를 멈출 수 있어 입력에서 좁혔다. 검증을 통과한 값도 저장 전에 UTC ISO로 정규화한다.
217
+ */
218
+ dispatchedAt: z.iso.datetime({ offset: true }).optional(),
197
219
  })
198
220
  .refine(
199
221
  (value) =>
@@ -219,11 +241,65 @@ export const deliveryInfoSchema = z.object({
219
241
 
220
242
  export type DeliveryInfo = z.infer<typeof deliveryInfoSchema>;
221
243
 
244
+ /**
245
+ * 택배 추적 단계. 응답에서는 문자열로 받는다(값이 추가될 수 있다) — 현재 값은 `ACCEPTED`(접수)·`IN_TRANSIT`(이동 중)·
246
+ * `OUT_FOR_DELIVERY`(배송 출발)·`DELIVERED`(배송완료)다.
247
+ */
248
+ export const trackingStageResponseSchema = z.string();
249
+
222
250
  /** `GET /v1/order-items/{orderItemId}/delivery` 응답 — 배송 정보 + 배송 추적 이벤트 */
223
251
  export const deliveryStatusSchema = deliveryInfoSchema.extend({
224
252
  trackingEvents: z
225
- .array(z.object({ status: z.string(), location: z.string(), occurredAt: z.string() }))
226
- .optional(),
253
+ .array(
254
+ z.object({
255
+ status: z.string().describe("택배사가 알려 준 처리 내용(예: 집화처리, 배송출발)"),
256
+ location: z.string().describe("처리 장소. 모르면 빈 문자열"),
257
+ occurredAt: z.string(),
258
+ stage: trackingStageResponseSchema
259
+ .nullable()
260
+ .optional()
261
+ .default(null)
262
+ .describe(
263
+ "이 이벤트의 추적 단계. 배달 실패처럼 단계로 나눌 수 없는 이벤트는 null. 서버는 항상 싣는다. " +
264
+ "현재 값은 `ACCEPTED`·`IN_TRANSIT`·`OUT_FOR_DELIVERY`·`DELIVERED`이고 값이 추가될 수 있어 문자열로 받는다",
265
+ ),
266
+ }),
267
+ )
268
+ .optional()
269
+ .describe("배송 추적 이력. 시각 오름차순(오래된 것부터)"),
270
+ tracking: z
271
+ .object({
272
+ result: z
273
+ .string()
274
+ .nullable()
275
+ .describe(
276
+ "마지막 추적 조회 결과. 조회에 성공한 적이 없으면 null. " +
277
+ "현재 값은 `FOUND`(조회됨)·`NOT_FOUND`(택배사 전산에 송장 없음)·`UNSUPPORTED_CARRIER`(추적을 지원하지 않는 택배사)이고 " +
278
+ "값이 추가될 수 있어 문자열로 받는다",
279
+ ),
280
+ stage: trackingStageResponseSchema
281
+ .nullable()
282
+ .describe(
283
+ "현재 추적 단계. 조회되지 않았으면 null. 현재 값은 `ACCEPTED`·`IN_TRANSIT`·`OUT_FOR_DELIVERY`·`DELIVERED`이고 " +
284
+ "값이 추가될 수 있어 문자열로 받는다",
285
+ ),
286
+ checkedAt: z
287
+ .string()
288
+ .nullable()
289
+ .describe("택배사에서 추적 정보를 마지막으로 받아 온 시각(ISO 8601)"),
290
+ carrierDeliveredAt: z
291
+ .string()
292
+ .nullable()
293
+ .describe(
294
+ "택배사가 알려 준 배송완료 시각(ISO 8601). 추적으로 배송완료가 반영되면 주문상품의 `deliveredAt`은 반영 시각이라 이 값과 다를 수 있다",
295
+ ),
296
+ })
297
+ .nullable()
298
+ .optional()
299
+ .default(null)
300
+ .describe(
301
+ "택배 추적 요약. 택배 발송이 아니거나 추적을 쓰지 않으면 null. 추적이 배송완료면 주문상품이 자동으로 배송완료된다. 서버는 항상 싣는다",
302
+ ),
227
303
  });
228
304
 
229
305
  export type DeliveryStatus = z.infer<typeof deliveryStatusSchema>;
@@ -256,6 +332,15 @@ export type HoldOrderItemRequest = z.infer<typeof holdOrderItemRequestSchema>;
256
332
  export const cancelBySellerRequestSchema = z.object({
257
333
  reason: z.enum(["OUT_OF_STOCK", "PRICE_ERROR", "DELIVERY_IMPOSSIBLE", "ETC"]),
258
334
  detail: z.string().max(500).optional(),
335
+ quantity: z
336
+ .number()
337
+ .int()
338
+ .min(1)
339
+ .optional()
340
+ .describe(
341
+ "취소할 수량. 생략하면 남은 수량 전체다. 남은 수량보다 크면 400 `INVALID_QUANTITY`. " +
342
+ "일부만 취소하면 주문상품은 상태를 유지한 채 `canceledQuantity`가 늘고, 전 수량이 소진되면 `CANCELED`가 된다",
343
+ ),
259
344
  });
260
345
 
261
346
  export type CancelBySellerRequest = z.infer<typeof cancelBySellerRequestSchema>;
@@ -263,7 +348,22 @@ export type CancelBySellerRequest = z.infer<typeof cancelBySellerRequestSchema>;
263
348
  /** `POST /v1/order-items/{orderItemId}/cancel` 응답 — 셀러 직권취소 환불 결과 */
264
349
  export const cancelBySellerResultSchema = z.object({
265
350
  orderItemId: z.string(),
266
- refundAmount: z.number().int(),
351
+ refundAmount: z
352
+ .number()
353
+ .int()
354
+ .describe("실제 환불한 금액 — 수량 비율 상품 금액 + 배송비 환불 − 조건부 무료배송 미달 차감"),
355
+ quantity: z.number().int().describe("취소한 수량"),
356
+ canceledQuantity: z.number().int().describe("취소 후 누적 취소 수량"),
357
+ deliveryFeeRefundAmount: z
358
+ .number()
359
+ .int()
360
+ .describe(
361
+ "환불에 포함된 배송비. 배송 묶음(같은 상품)이 모두 취소됐고 환불 정책이 켜져 있을 때만 0보다 크다",
362
+ ),
363
+ deliveryFeeChargeAmount: z
364
+ .number()
365
+ .int()
366
+ .describe("조건부 무료배송 기준 미달로 환불액에서 뺀 배송비. 정책이 꺼져 있으면 항상 0이다"),
267
367
  refundedAt: z.string(),
268
368
  });
269
369
 
@@ -514,6 +514,57 @@ export const paymentAttemptStatusSchema = z
514
514
  "새 값이 추가될 수 있으니 모르는 값은 '진행 중이 아닌 시도'로 다룬다",
515
515
  );
516
516
 
517
+ /**
518
+ * 현금영수증 상태 — 값이 추가될 수 있어 응답은 문자열로 받는다. 화면 분기는 이 목록으로 하고 모르는 값은 처리 중으로 다룬다.
519
+ * - `REQUESTED`: 구매자가 신청했고 결제 승인 전이다
520
+ * - `PENDING`: 발급·취소를 기다린다(승인 직후, 또는 일시 오류로 자동 재시도 대기)
521
+ * - `ISSUED`: 발급됐다. 부분 환불이 있었으면 그만큼 취소(또는 재발급)된 금액이다
522
+ * - `CANCELED`: 전액 환불로 취소됐다
523
+ * - `FAILED`: 발급·취소가 실패했고 자동 재시도가 끝났다. `failCode`·`failMessage`를 보고 재발급한다
524
+ * - `SKIPPED`: 발급 대상이 아니다(승인된 결제수단이 계좌이체·가상계좌가 아님)
525
+ */
526
+ export const CASH_RECEIPT_STATUS_VALUES = [
527
+ "REQUESTED",
528
+ "PENDING",
529
+ "ISSUED",
530
+ "CANCELED",
531
+ "FAILED",
532
+ "SKIPPED",
533
+ ] as const;
534
+ export type KnownCashReceiptStatus = (typeof CASH_RECEIPT_STATUS_VALUES)[number];
535
+
536
+ export const cashReceiptSchema = z.object({
537
+ cashReceiptId: z.string(),
538
+ status: z
539
+ .string()
540
+ .describe(
541
+ "상태. `REQUESTED` 결제 전 · `PENDING` 발급 대기 · `ISSUED` 발급 · `CANCELED` 취소 · `FAILED` 실패 · `SKIPPED` 발급 대상 아님. 값이 추가될 수 있다",
542
+ ),
543
+ type: z.string().describe("용도. `INCOME_DEDUCTION` 소득공제 · `EXPENSE_PROOF` 지출증빙"),
544
+ identityType: z
545
+ .string()
546
+ .describe("식별 번호 종류. `PHONE` 휴대폰 · `CARD` 현금영수증카드 · `BUSINESS` 사업자등록번호"),
547
+ identityNumberMasked: z
548
+ .string()
549
+ .describe("가린 식별 번호 (예: 010****5678). 원문은 조회할 수 없다"),
550
+ amount: z
551
+ .number()
552
+ .int()
553
+ .describe("발급해야 할 금액 = 결제 금액 − 환불 완료 금액. 승인 전에는 결제 금액"),
554
+ issuedAmount: z.number().int().describe("PG에 현재 발급돼 있는 금액. 취소됐거나 발급 전이면 0"),
555
+ provider: pgProviderSchema.nullable().describe("발급 PG — 결제를 승인한 PG. 승인 전에는 null"),
556
+ issueNumber: z.string().nullable().describe("현금영수증 승인 번호"),
557
+ receiptUrl: z.string().nullable().describe("현금영수증 확인 주소"),
558
+ issuedAt: z.string().nullable().describe("최근 발급 시각 (ISO 8601)"),
559
+ canceledAt: z.string().nullable().describe("최근 취소 시각 (ISO 8601)"),
560
+ failCode: z.string().nullable().describe("마지막 실패 코드. 성공하면 비운다"),
561
+ failMessage: z.string().nullable().describe("마지막 실패 사유"),
562
+ reissuable: z.boolean().describe("콘솔 재발급으로 다시 시도할 수 있는가 (`FAILED`·`PENDING`)"),
563
+ requestedAt: z.string().describe("신청 시각 (ISO 8601)"),
564
+ updatedAt: z.string().describe("마지막 변경 시각 (ISO 8601)"),
565
+ });
566
+ export type CashReceipt = z.infer<typeof cashReceiptSchema>;
567
+
517
568
  export const paymentRecordSchema = z.object({
518
569
  paymentId: z.string().describe("결제 건 id. 상세 조회 키"),
519
570
  orderId: z.string().nullable().describe("승인돼 만들어진 주문. 승인 전에 끝난 결제는 null"),
@@ -542,6 +593,14 @@ export const paymentRecordSchema = z.object({
542
593
  failMessage: z.string().nullable().describe("실패 사유"),
543
594
  paidAt: z.string().nullable().describe("승인 시각 (ISO 8601)"),
544
595
  createdAt: z.string().describe("결제 시작 시각 (ISO 8601)"),
596
+ cashReceiptStatus: z
597
+ .string()
598
+ .nullable()
599
+ .optional()
600
+ .default(null)
601
+ .describe(
602
+ "현금영수증 상태(`cashReceiptSchema.status`와 같은 값). 신청이 없으면 null. 서버는 항상 싣는다",
603
+ ),
545
604
  });
546
605
  export type PaymentRecord = z.infer<typeof paymentRecordSchema>;
547
606
 
@@ -601,6 +660,11 @@ export const paymentRecordDetailSchema = paymentRecordSchema.extend({
601
660
  .describe("가맹점 식별자 지문 (토스 clientKey, 포트원 Store ID). 비밀 값은 없다"),
602
661
  attempts: z.array(paymentAttemptRecordSchema).describe("시도 이력. 순번 오름차순"),
603
662
  refunds: z.array(paymentRefundRecordSchema).describe("환불 이력. 실패한 요청도 남는다"),
663
+ cashReceipt: cashReceiptSchema
664
+ .nullable()
665
+ .optional()
666
+ .default(null)
667
+ .describe("현금영수증. 구매자가 신청하지 않았으면 null. 서버는 항상 싣는다"),
604
668
  });
605
669
  export type PaymentRecordDetail = z.infer<typeof paymentRecordDetailSchema>;
606
670
 
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { type PageParams, pageQueryShape } from "./common";
3
+ import { refundPolicyPatchSchema } from "./refund-policy";
3
4
  import { UPLOAD_PURPOSE_LIMITS, uploadExtensionOf } from "./upload";
4
5
 
5
6
  export const productStatusSchema = z.enum(["SALE", "SUSPENDED", "OUT_OF_STOCK", "CLOSED"]);
@@ -90,6 +91,10 @@ export const productSchema = productSummarySchema.extend({
90
91
  conditionalFreeAmount: z.number().int().nullable().optional(),
91
92
  returnFee: z.number().int().optional(),
92
93
  exchangeFee: z.number().int().optional(),
94
+ refundPolicy: refundPolicyPatchSchema
95
+ .nullable()
96
+ .optional()
97
+ .describe("상품별 환불 정책 덮어쓰기. null이면 스토어 기본 환불 정책을 쓴다"),
93
98
  shippingAddressId: z.string().nullable().optional(),
94
99
  returnAddressId: z.string().nullable().optional(),
95
100
  createdAt: z.string().optional(),
@@ -252,6 +257,30 @@ export const variantStockSchema = z.object({
252
257
 
253
258
  export type VariantStock = z.infer<typeof variantStockSchema>;
254
259
 
260
+ /**
261
+ * 출고지·반품지 지정 문구 — POST·PUT·PATCH가 같은 규칙을 쓰므로 설명도 한 곳이다 (이슈 #30).
262
+ *
263
+ * 세 값이 뜻이 다르다. **생략**은 「정하지 않음」이라 서버가 그 시점 스토어 기본 주소를 박아 저장하고,
264
+ * **null**은 「스토어 기본을 따른다」는 명시적 비우기다. 주소록 항목은 상품이 참조 중이면 지울 수 없어
265
+ * (`409 ADDRESS_IN_USE`) 참조를 푸는 수단이 필요한데, 생략을 기본값으로 되채우면 기본 주소는 영원히
266
+ * 풀리지 않는다 — null이 그 경로다. 배송 묶음 판정은 null을 그 시점 스토어 기본 출고지로 정규화하므로
267
+ * 두 표현이 배송비를 갈라놓지 않는다.
268
+ */
269
+ const ADDRESS_DESCRIPTIONS = {
270
+ shipping:
271
+ "출고지 주소 id(`GET /v1/seller-addresses?type=SHIPPING`). 생략하면 스토어 기본 출고지를 저장하고, " +
272
+ "`null`이면 비워 둔다(스토어 기본 출고지를 따른다). 이 스토어의 `SHIPPING` 주소가 아니면 거부한다",
273
+ return:
274
+ "반품지 주소 id(`GET /v1/seller-addresses?type=RETURN`). 생략하면 스토어 기본 반품지를 저장하고, " +
275
+ "`null`이면 비워 둔다(스토어 기본 반품지를 따른다). 이 스토어의 `RETURN` 주소가 아니면 거부한다",
276
+ patchShipping:
277
+ "출고지 주소 id. **생략은 바꾸지 않음**이고 `null`은 비우기다(스토어 기본 출고지를 따른다). " +
278
+ "이 스토어의 `SHIPPING` 주소가 아니면 거부한다",
279
+ patchReturn:
280
+ "반품지 주소 id. **생략은 바꾸지 않음**이고 `null`은 비우기다(스토어 기본 반품지를 따른다). " +
281
+ "이 스토어의 `RETURN` 주소가 아니면 거부한다",
282
+ } as const;
283
+
255
284
  export const productCreateRequestSchema = z
256
285
  .object({
257
286
  name: z.string().min(1).max(100),
@@ -279,16 +308,38 @@ export const productCreateRequestSchema = z
279
308
  deliveryType: deliveryTypeSchema
280
309
  .default("PAID")
281
310
  .describe("배송비 유형. FREE 무료, PAID 유료, CONDITIONAL_FREE 조건부 무료"),
282
- deliveryFee: z.number().int().default(0).describe("배송비. 원 단위"),
311
+ deliveryFee: z
312
+ .number()
313
+ .int()
314
+ .min(0)
315
+ .optional()
316
+ .describe(
317
+ "배송비. 원 단위. **생략하면 스토어 기본 배송비**(`GET /v1/store`의 `shippingPolicy.baseDeliveryFee`)를 쓴다. " +
318
+ "`deliveryType`이 FREE면 0으로 저장한다",
319
+ ),
283
320
  conditionalFreeAmount: z
284
321
  .number()
285
322
  .int()
286
323
  .optional()
287
324
  .describe("CONDITIONAL_FREE일 때 무료 배송이 되는 주문 금액. 원 단위"),
288
- returnFee: z.number().int().default(0).describe("반품 배송비. 원 단위"),
289
- exchangeFee: z.number().int().default(0).describe("교환 배송비. 원 단위"),
290
- shippingAddressId: z.string().optional(),
291
- returnAddressId: z.string().optional(),
325
+ returnFee: z
326
+ .number()
327
+ .int()
328
+ .default(0)
329
+ .describe("반품 배송비. 원 단위. `refundPolicy.returnDeliveryFee`와 같은 값이다"),
330
+ exchangeFee: z
331
+ .number()
332
+ .int()
333
+ .default(0)
334
+ .describe("교환 배송비. 원 단위. `refundPolicy.exchangeDeliveryFee`와 같은 값이다"),
335
+ refundPolicy: refundPolicyPatchSchema
336
+ .nullish()
337
+ .describe(
338
+ "상품별 환불 정책 — 준 항목만 스토어 기본을 덮어쓴다. `null`이나 생략은 스토어 기본을 그대로 쓴다는 뜻이다. " +
339
+ "`returnDeliveryFee`·`exchangeDeliveryFee`를 주면 `returnFee`·`exchangeFee`도 같은 값으로 맞춘다",
340
+ ),
341
+ shippingAddressId: z.string().nullable().optional().describe(ADDRESS_DESCRIPTIONS.shipping),
342
+ returnAddressId: z.string().nullable().optional().describe(ADDRESS_DESCRIPTIONS.return),
292
343
  })
293
344
  .extend(optionModelRequestSchema.shape);
294
345
 
@@ -300,6 +351,13 @@ export const productPatchRequestSchema = z
300
351
  salePrice: z.number().int().multipleOf(10).optional(),
301
352
  status: productStatusSchema.optional(),
302
353
  deliveryFee: z.number().int().optional(),
354
+ // PATCH의 생략은 「바꾸지 않음」이다 — 기본 주소로 되채우지 않는다. 비우려면 `null`을 준다
355
+ shippingAddressId: z
356
+ .string()
357
+ .nullable()
358
+ .optional()
359
+ .describe(ADDRESS_DESCRIPTIONS.patchShipping),
360
+ returnAddressId: z.string().nullable().optional().describe(ADDRESS_DESCRIPTIONS.patchReturn),
303
361
  })
304
362
  .refine((value) => Object.values(value).some((v) => v !== undefined), {
305
363
  message: "최소 1개 필드가 필요합니다",