@sayren/store-sdk 0.10.0 → 0.12.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.10.0",
3
+ "version": "0.12.0",
4
4
  "main": "./dist/index.mjs",
5
5
  "types": "./dist/index.d.mts",
6
6
  "exports": {
package/src/client.ts CHANGED
@@ -101,6 +101,7 @@ import {
101
101
  type WebhookRequest,
102
102
  type WebhookTestRequest,
103
103
  webhookCreatedSchema,
104
+ webhookDeliveryDetailSchema,
104
105
  webhookDeliverySchema,
105
106
  webhookRedeliverFailedResultSchema,
106
107
  webhookRequestSchema,
@@ -128,6 +129,7 @@ import {
128
129
  purchaseDecisionExtendResultSchema,
129
130
  } from "./schemas/order";
130
131
  import {
132
+ cashReceiptSchema,
131
133
  type PaymentListParams,
132
134
  type PgEnvironment,
133
135
  type PgProvider,
@@ -530,6 +532,12 @@ export function createStoreClient(options: StoreClientOptions) {
530
532
  query: params as unknown as Query,
531
533
  }),
532
534
  get: (orderId: string) => storeHttp.request("GET", path`/orders/${orderId}`, orderSchema),
535
+ /**
536
+ * `POST /v1/orders/{orderId}/cash-receipt/reissue` — 실패했거나 재시도 대기 중인 현금영수증을 지금 다시 발급·취소한다.
537
+ * 콘솔 OWNER·ADMIN 전용(PAT·M2M 403 `INSUFFICIENT_ROLE`). 404 `CASH_RECEIPT_NOT_FOUND`, 409 `CASH_RECEIPT_NOT_REISSUABLE`
538
+ */
539
+ reissueCashReceipt: (orderId: string) =>
540
+ storeHttp.request("POST", path`/orders/${orderId}/cash-receipt/reissue`, cashReceiptSchema),
533
541
  },
534
542
 
535
543
  orderItems: {
@@ -977,6 +985,17 @@ export function createStoreClient(options: StoreClientOptions) {
977
985
  z.array(webhookDeliverySchema),
978
986
  { query: params as Query },
979
987
  ),
988
+ /**
989
+ * 전송 기록 한 건의 상세. 목록 항목에 보낸 요청 본문(최대 16KB)과 요청 헤더가 더해진다.
990
+ * 실패 원인 분류(`failureReason`)·응답 본문(`responseBodyExcerpt`)으로 원인을 찾고,
991
+ * `resource.id`를 API 로그 조회의 `q`로 넘겨 같은 리소스의 API 요청과 맞춰 본다.
992
+ */
993
+ getDelivery: (webhookId: string, deliveryId: string) =>
994
+ storeHttp.request(
995
+ "GET",
996
+ path`/webhooks/${webhookId}/deliveries/${deliveryId}`,
997
+ webhookDeliveryDetailSchema,
998
+ ),
980
999
  /**
981
1000
  * FAILED 전송 기록 한 건을 같은 eventId로 다시 보낸다(202). 새로 적재된 기록을 돌려주며
982
1001
  * `redeliveryOf`가 원본 deliveryId다. 원본이 FAILED가 아니면 409 `DELIVERY_NOT_FAILED`,
package/src/http.ts CHANGED
@@ -5,6 +5,11 @@ export const errorDetailSchema = z.object({
5
5
  code: z.string(),
6
6
  message: z.string(),
7
7
  traceId: z.string().optional(),
8
+ /**
9
+ * 이 에러 코드를 설명하는 문서 URL(선택). 서버가 코드별 매핑을 가진 코드에만 실린다 —
10
+ * 없으면 필드가 아예 없으므로 소비측은 있을 때만 쓴다
11
+ */
12
+ docs: z.url().optional().catch(undefined),
8
13
  /**
9
14
  * 에러 코드별 보조 데이터(선택). 예: 관리 API `LIVE_GATE_NOT_MET`·`LIVE_PAYMENTS_DISABLED`의
10
15
  * `{ conditions: LiveGateCondition[] }`. 형태는 에러 코드마다 다르므로 소비측이 코드별 스키마로 다시 파싱한다
@@ -50,6 +55,8 @@ export class ApiError extends Error {
50
55
  public readonly body?: unknown,
51
56
  /** 엔벨로프 `$.error.details` — 에러 코드별 보조 데이터. 없으면 undefined */
52
57
  public readonly details?: Record<string, unknown>,
58
+ /** 엔벨로프 `$.error.docs` — 이 에러 코드를 설명하는 문서 URL. 매핑이 없으면 undefined */
59
+ public readonly docs?: string,
53
60
  ) {
54
61
  super(message);
55
62
  this.name = "ApiError";
@@ -68,7 +75,15 @@ export function toApiError(status: number, raw: unknown): ApiError {
68
75
  ? envelope.data.error
69
76
  : (errorDetailSchema.safeParse(raw).data ?? null);
70
77
  if (detail) {
71
- return new ApiError(status, detail.code, detail.message, detail.traceId, raw, detail.details);
78
+ return new ApiError(
79
+ status,
80
+ detail.code,
81
+ detail.message,
82
+ detail.traceId,
83
+ raw,
84
+ detail.details,
85
+ detail.docs,
86
+ );
72
87
  }
73
88
  const oauth = envelope.success ? null : oauthErrorSchema.safeParse(raw).data;
74
89
  if (oauth) {
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export * from "./schemas/payment";
26
26
  export * from "./schemas/product";
27
27
  export * from "./schemas/refund-policy";
28
28
  export * from "./schemas/scope";
29
+ export * from "./schemas/shipping-policy";
29
30
  export * from "./schemas/store";
30
31
  export * from "./schemas/upload";
31
32
  export * from "./schemas/webhook-event";
@@ -68,7 +68,18 @@ export const apiLogListQuerySchema = z.object({
68
68
  ),
69
69
  principalId: z.string().optional().describe("호출 주체 ID 필터"),
70
70
  status: apiLogStatusFilterSchema.optional(),
71
- q: z.string().optional().describe("경로 부분 일치 검색어"),
71
+ q: z
72
+ .string()
73
+ .optional()
74
+ .describe(
75
+ "경로 부분 일치 검색어. 리소스 id(예: `ord_…`)를 넣으면 그 리소스를 건드린 요청만 남으므로 웹훅 전송 기록의 `resource.id`와 짝지어 쓴다",
76
+ ),
77
+ traceId: z
78
+ .string()
79
+ .optional()
80
+ .describe(
81
+ "추적 ID 필터(정확히 일치). 에러 응답 `error.traceId` 값을 그대로 넣어 그 요청 한 건을 찾는다",
82
+ ),
72
83
  cursor: z.string().optional().describe("이전 응답의 `nextCursor`. 생략하면 가장 최신부터"),
73
84
  limit: z.coerce.number().int().min(1).max(100).optional().describe("최대 건수 (1~100, 기본 50)"),
74
85
  });
@@ -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,
@@ -73,6 +91,12 @@ export const claimSchema = z.object({
73
91
  holdExpiresAt: z.string().nullable(),
74
92
  /** 보류를 이미 썼는지 — 보류는 클레임당 1회라 해제·만료 뒤에도 true */
75
93
  holdUsed: z.boolean(),
94
+ returnAddress: claimReturnAddressSchema
95
+ .nullable()
96
+ .describe(
97
+ "수거 안내에 쓸 반품지(이슈 #30). 상품이 지정한 반품지, 없으면 스토어 기본 반품지다. " +
98
+ "취소 클레임과 반품지를 등록하지 않은 스토어는 null",
99
+ ),
76
100
  });
77
101
 
78
102
  export type Claim = z.infer<typeof claimSchema>;
@@ -208,6 +208,71 @@ export const webhookDeliveryErrorCodeSchema = z
208
208
  );
209
209
  export type WebhookDeliveryErrorCode = z.infer<typeof webhookDeliveryErrorCodeSchema>;
210
210
 
211
+ /**
212
+ * 실패 원인 분류의 **현재** 값. 응답 스키마(`webhookFailureReasonSchema`)는 문자열로 열려 있다 —
213
+ * 서버가 값을 더해도 이전 SDK가 목록 파싱에 실패하지 않게 한다(택배 추적 `stage`와 같은 이유).
214
+ * 모르는 값은 `errorCode`로 판단한다.
215
+ */
216
+ export const WEBHOOK_FAILURE_REASONS = [
217
+ "DNS",
218
+ "TLS",
219
+ "TIMEOUT",
220
+ "CONNECTION",
221
+ "HTTP_4XX",
222
+ "HTTP_5XX",
223
+ "HTTP_OTHER",
224
+ "SSRF_BLOCKED",
225
+ "SUSPENDED",
226
+ ] as const;
227
+ /** 알려진 실패 원인. 응답에는 이 밖의 문자열이 올 수 있다 */
228
+ export type WebhookFailureReason = (typeof WEBHOOK_FAILURE_REASONS)[number];
229
+
230
+ export const webhookFailureReasonSchema = z
231
+ .string()
232
+ .describe(
233
+ "실패 원인 분류(진단용). 현재 값은 `DNS`=호스트 이름을 주소로 바꾸지 못함, `TLS`=인증서·핸드셰이크 실패, " +
234
+ "`TIMEOUT`=10초 안에 응답 없음, `CONNECTION`=연결 거부·끊김, `HTTP_4XX`·`HTTP_5XX`=그 대역의 응답, " +
235
+ "`HTTP_OTHER`=2xx·4xx·5xx가 아닌 응답(리다이렉트 등), `SSRF_BLOCKED`=사설·루프백 등 전송이 허용되지 않는 주소, " +
236
+ "`SUSPENDED`=구독 정지로 대기 중인 전송이 종료됨. `errorCode`보다 잘게 나눈 값이며 둘은 함께 쓸 수 있다. " +
237
+ "값이 추가될 수 있으므로 모르는 값은 `errorCode`로 판단한다",
238
+ );
239
+
240
+ /**
241
+ * 대상 리소스 종류의 **현재** 값. 응답 스키마는 문자열로 열려 있다(새 이벤트가 새 리소스를 가리킬 수 있다).
242
+ */
243
+ export const WEBHOOK_RESOURCE_TYPES = ["ORDER", "CLAIM", "PRODUCT", "REVIEW", "INQUIRY"] as const;
244
+ /** 알려진 리소스 종류. 응답에는 이 밖의 문자열이 올 수 있다 */
245
+ export type WebhookResourceType = (typeof WEBHOOK_RESOURCE_TYPES)[number];
246
+
247
+ export const webhookResourceTypeSchema = z
248
+ .string()
249
+ .describe(
250
+ "이벤트가 가리키는 리소스 종류. 현재 값은 `ORDER`·`CLAIM`·`PRODUCT`·`REVIEW`·`INQUIRY`이고 값이 추가될 수 있다",
251
+ );
252
+
253
+ /** 이벤트가 가리키는 대상 리소스 — 전송 기록을 주문·상품과 잇는 키 */
254
+ export const webhookDeliveryResourceSchema = z.object({
255
+ type: webhookResourceTypeSchema,
256
+ id: z
257
+ .string()
258
+ .describe(
259
+ "리소스 ID. 전송 기록 조회의 `resourceId`, API 로그 조회의 `q`로 그대로 넣을 수 있다",
260
+ ),
261
+ });
262
+ export type WebhookDeliveryResource = z.infer<typeof webhookDeliveryResourceSchema>;
263
+
264
+ /** 발송 요청 헤더 한 줄 (값 마스킹) */
265
+ export const webhookRequestHeaderSchema = z.object({
266
+ name: z.string().describe("헤더 이름"),
267
+ value: z
268
+ .string()
269
+ .nullable()
270
+ .describe(
271
+ "헤더 값. 서명(`Sayren-Signature`)처럼 비밀이 섞이는 헤더는 이름만 주고 값은 null이다",
272
+ ),
273
+ });
274
+ export type WebhookRequestHeader = z.infer<typeof webhookRequestHeaderSchema>;
275
+
211
276
  /** 웹훅 전송 기록 한 건 (전송 기록 조회 항목) */
212
277
  export const webhookDeliverySchema = z.object({
213
278
  deliveryId: z.string().describe("전송 기록 ID. 수신 요청의 `Sayren-Delivery-Id` 헤더 값"),
@@ -232,10 +297,27 @@ export const webhookDeliverySchema = z.object({
232
297
  responseBodyExcerpt: z
233
298
  .string()
234
299
  .nullable()
235
- .describe("마지막 시도의 응답 본문 앞부분(최대 1,024자). 응답이 없으면 null"),
300
+ .describe(
301
+ "마지막 시도의 응답 본문 앞부분(최대 4,096자). 응답이 없으면 null. 가린 뒤에 자른다. " +
302
+ "키 이름에 `authorization`·`token`·`secret`·`password`·`key`·`signature`·`credential`·`cookie`가 " +
303
+ "**들어 있으면**(부분 일치, 대소문자 무시. 예: `apiKey`·`access_token`·`keyword`) 그 값을 `***`로 가린다. " +
304
+ "값이 객체·배열이면 통째로 가린다. 평문 `Authorization: …` 같은 헤더 줄, `Bearer <토큰>`, " +
305
+ "쿼리 형식 `access_token=…`, 끝에서 잘려 닫히지 않은 값도 가린다",
306
+ ),
236
307
  errorCode: webhookDeliveryErrorCodeSchema
237
308
  .nullable()
238
309
  .describe("마지막 시도의 실패 사유. 성공했거나 미시도면 null"),
310
+ failureReason: webhookFailureReasonSchema
311
+ .nullable()
312
+ .describe(
313
+ "마지막 시도의 실패 원인 분류. 성공했거나 미시도면 null. `errorCode`를 더 잘게 나눈 진단 값이다",
314
+ ),
315
+ resource: webhookDeliveryResourceSchema
316
+ .nullable()
317
+ .describe(
318
+ "이벤트가 가리키는 대상 리소스. 리소스를 특정할 수 없는 이벤트면 null. " +
319
+ "이 값으로 전송 기록(`resourceId`)과 API 로그(`q`)를 오갈 수 있다",
320
+ ),
239
321
  attemptCount: z
240
322
  .number()
241
323
  .int()
@@ -260,9 +342,47 @@ export const webhookDeliverySchema = z.object({
260
342
  });
261
343
  export type WebhookDelivery = z.infer<typeof webhookDeliverySchema>;
262
344
 
345
+ /** 발송 본문 최대 노출 길이(바이트). 넘으면 잘라 주고 `payloadTruncated`가 true다 */
346
+ export const WEBHOOK_PAYLOAD_EXCERPT_MAX_BYTES = 16 * 1024;
347
+
348
+ /** 웹훅 전송 기록 상세 — `GET /webhooks/{id}/deliveries/{deliveryId}`. 목록 항목 + 요청 본문·헤더 */
349
+ export const webhookDeliveryDetailSchema = webhookDeliverySchema.extend({
350
+ payload: z
351
+ .string()
352
+ .describe(
353
+ "수신 서버로 보낸 요청 본문 그대로(JSON 문자열). 서명은 이 문자열을 대상으로 계산한다. " +
354
+ `최대 ${WEBHOOK_PAYLOAD_EXCERPT_MAX_BYTES / 1024}KB까지 주고 넘으면 잘라 준다`,
355
+ ),
356
+ payloadTruncated: z
357
+ .boolean()
358
+ .describe("본문이 길어서 잘렸으면 true. true면 `payload`는 유효한 JSON이 아니다"),
359
+ payloadMasked: z
360
+ .boolean()
361
+ .describe(
362
+ "민감 키 마스킹(`responseBodyExcerpt`와 같은 키 이름 규칙)이 본문을 바꿨으면 true. " +
363
+ "true면 `payload`는 보낸 본문과 달라 서명을 맞춰 볼 수 없다. 이벤트 본문에는 보통 민감 키가 없어 false다",
364
+ ),
365
+ requestHeaders: z
366
+ .array(webhookRequestHeaderSchema)
367
+ .describe(
368
+ "발송 요청에 실은 헤더. 값은 전송 시점 그대로이나 서명 헤더는 이름만 주고 값은 null이다. " +
369
+ "`Content-Length`는 실제로 보낸 본문(마스킹·자르기 전)의 바이트 수다. " +
370
+ "매 시도가 같은 헤더 집합을 쓰고 서명·타임스탬프만 달라진다",
371
+ ),
372
+ });
373
+ export type WebhookDeliveryDetail = z.infer<typeof webhookDeliveryDetailSchema>;
374
+
263
375
  /** 전송 기록 조회 쿼리 — `GET /webhooks/{id}/deliveries` */
264
376
  export const webhookDeliveryListQuerySchema = z.object({
265
377
  status: webhookDeliveryStatusSchema.optional().describe("전송 상태 필터. 생략하면 전체"),
378
+ eventType: eventTypeSchema.optional().describe("이벤트 타입 필터. 생략하면 전체"),
379
+ resourceId: z
380
+ .string()
381
+ .optional()
382
+ .describe(
383
+ "대상 리소스 ID 필터(정확히 일치). 주문·클레임·상품·리뷰·문의 id를 넣으면 그 리소스의 이벤트만 남는다. " +
384
+ "같은 id로 API 로그를 `q`로 검색하면 그 리소스를 건드린 API 요청을 볼 수 있다",
385
+ ),
266
386
  limit: z.coerce
267
387
  .number()
268
388
  .int()
@@ -299,6 +419,19 @@ export const webhookTestResultSchema = z.object({
299
419
  .describe("수신 서버의 HTTP 응답 코드. 연결 실패·타임아웃·차단이면 null"),
300
420
  responseTimeMs: z.number().int().nullable().describe("응답 시간(ms). 연결 실패면 null"),
301
421
  errorCode: webhookDeliveryErrorCodeSchema.nullable().describe("실패 사유. 2xx 응답이면 null"),
422
+ failureReason: webhookFailureReasonSchema
423
+ .nullable()
424
+ .describe("실패 원인 분류. 2xx 응답이면 null"),
425
+ responseBodyExcerpt: z
426
+ .string()
427
+ .nullable()
428
+ .describe(
429
+ "응답 본문 앞부분(최대 4,096자). 규칙은 전송 기록의 `responseBodyExcerpt`와 같다. 응답이 없으면 null",
430
+ ),
431
+ requestHeaders: z
432
+ .array(webhookRequestHeaderSchema)
433
+ .describe("보낸 요청 헤더. 서명 헤더는 이름만 주고 값은 null이다"),
434
+ payload: z.string().describe("보낸 요청 본문 그대로(JSON 문자열). 샘플 이벤트다"),
302
435
  });
303
436
  export type WebhookTestResult = z.infer<typeof webhookTestResultSchema>;
304
437
 
@@ -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(),
@@ -107,6 +107,13 @@ export const orderSchema = z.object({
107
107
  discountAmount: z.number().int(),
108
108
  totalAmount: z.number().int(),
109
109
  paidAt: z.string().nullable(),
110
+ cashReceipt: cashReceiptSchema
111
+ .nullable()
112
+ .optional()
113
+ .default(null)
114
+ .describe(
115
+ "현금영수증. 구매자가 계좌이체·가상계좌 결제에서 신청했을 때만 있고 그 밖에는 null. 서버는 항상 싣는다",
116
+ ),
110
117
  }),
111
118
  testPayment: z
112
119
  .boolean()
@@ -201,7 +208,14 @@ export const dispatchRequestSchema = z
201
208
  deliveryMethod: deliveryMethodSchema,
202
209
  carrierCode: z.string().optional(),
203
210
  trackingNumber: z.string().optional(),
204
- 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(),
205
219
  })
206
220
  .refine(
207
221
  (value) =>
@@ -227,11 +241,65 @@ export const deliveryInfoSchema = z.object({
227
241
 
228
242
  export type DeliveryInfo = z.infer<typeof deliveryInfoSchema>;
229
243
 
244
+ /**
245
+ * 택배 추적 단계. 응답에서는 문자열로 받는다(값이 추가될 수 있다) — 현재 값은 `ACCEPTED`(접수)·`IN_TRANSIT`(이동 중)·
246
+ * `OUT_FOR_DELIVERY`(배송 출발)·`DELIVERED`(배송완료)다.
247
+ */
248
+ export const trackingStageResponseSchema = z.string();
249
+
230
250
  /** `GET /v1/order-items/{orderItemId}/delivery` 응답 — 배송 정보 + 배송 추적 이벤트 */
231
251
  export const deliveryStatusSchema = deliveryInfoSchema.extend({
232
252
  trackingEvents: z
233
- .array(z.object({ status: z.string(), location: z.string(), occurredAt: z.string() }))
234
- .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
+ ),
235
303
  });
236
304
 
237
305
  export type DeliveryStatus = z.infer<typeof deliveryStatusSchema>;
@@ -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
 
@@ -257,6 +257,30 @@ export const variantStockSchema = z.object({
257
257
 
258
258
  export type VariantStock = z.infer<typeof variantStockSchema>;
259
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
+
260
284
  export const productCreateRequestSchema = z
261
285
  .object({
262
286
  name: z.string().min(1).max(100),
@@ -284,7 +308,15 @@ export const productCreateRequestSchema = z
284
308
  deliveryType: deliveryTypeSchema
285
309
  .default("PAID")
286
310
  .describe("배송비 유형. FREE 무료, PAID 유료, CONDITIONAL_FREE 조건부 무료"),
287
- 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
+ ),
288
320
  conditionalFreeAmount: z
289
321
  .number()
290
322
  .int()
@@ -306,8 +338,8 @@ export const productCreateRequestSchema = z
306
338
  "상품별 환불 정책 — 준 항목만 스토어 기본을 덮어쓴다. `null`이나 생략은 스토어 기본을 그대로 쓴다는 뜻이다. " +
307
339
  "`returnDeliveryFee`·`exchangeDeliveryFee`를 주면 `returnFee`·`exchangeFee`도 같은 값으로 맞춘다",
308
340
  ),
309
- shippingAddressId: z.string().optional(),
310
- returnAddressId: z.string().optional(),
341
+ shippingAddressId: z.string().nullable().optional().describe(ADDRESS_DESCRIPTIONS.shipping),
342
+ returnAddressId: z.string().nullable().optional().describe(ADDRESS_DESCRIPTIONS.return),
311
343
  })
312
344
  .extend(optionModelRequestSchema.shape);
313
345
 
@@ -319,6 +351,13 @@ export const productPatchRequestSchema = z
319
351
  salePrice: z.number().int().multipleOf(10).optional(),
320
352
  status: productStatusSchema.optional(),
321
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),
322
361
  })
323
362
  .refine((value) => Object.values(value).some((v) => v !== undefined), {
324
363
  message: "최소 1개 필드가 필요합니다",