@sayren/storefront-sdk 0.10.0 → 0.11.1

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/src/client.ts CHANGED
@@ -37,6 +37,9 @@ import {
37
37
  type CreateCheckoutRequest,
38
38
  checkoutSessionSchema,
39
39
  createCheckoutRequestSchema,
40
+ type DeliveryQuoteRequest,
41
+ deliveryQuoteRequestSchema,
42
+ deliveryQuoteSchema,
40
43
  paymentStartSchema,
41
44
  paymentStatusSchema,
42
45
  type RetryPaymentRequest,
@@ -276,6 +279,19 @@ export function createStorefrontClient(options: StorefrontClientOptions) {
276
279
  http.request("POST", "/checkout", checkoutSessionSchema, {
277
280
  body: createCheckoutRequestSchema.parse(body),
278
281
  }),
282
+ /**
283
+ * `POST /checkout/{checkoutId}/delivery-quote` — 배송비 미리보기. 배송지 우편번호로 배송비를 다시 계산한다.
284
+ *
285
+ * 주문서를 만들 때는 배송지를 모르므로 제주·도서산간 추가 배송비가 빠져 있다. 구매자가 배송지를 입력하거나
286
+ * 고칠 때 이 메서드로 금액을 갱신하면 결제 직전에 금액이 바뀌는 일을 피할 수 있다. 주문서를 바꾸지 않는
287
+ * 읽기 계산이고, **최종 금액은 결제 시작 응답의 `amounts`다.**
288
+ *
289
+ * 에러(`ApiError.code`): 404 `CHECKOUT_NOT_FOUND`, 409 `CHECKOUT_EXPIRED`, 409 `ALREADY_PAID`
290
+ */
291
+ quoteDelivery: (checkoutId: string, body: DeliveryQuoteRequest) =>
292
+ http.request("POST", path`/checkout/${checkoutId}/delivery-quote`, deliveryQuoteSchema, {
293
+ body: deliveryQuoteRequestSchema.parse(body),
294
+ }),
279
295
  /**
280
296
  * `POST /checkout/{checkoutId}/payment` — 결제 시작. 배송지와 결제 옵션(`paymentOptions` 항목 또는 직접 쓴 옵션)으로
281
297
  * 결제 세션·첫 시도를 만들고 결제 서비스 주소(`payUrl`)를 돌려준다. 그 주소를 열어 결제를 진행하는 것은
@@ -1,5 +1,6 @@
1
1
  import type { createStorefrontClient } from "../client";
2
2
  import {
3
+ type CashReceiptRequest,
3
4
  type GuestInfo,
4
5
  type PaymentOption,
5
6
  type PaymentStart,
@@ -167,7 +168,7 @@ export function paymentResultOf(
167
168
  status: "CANCELED",
168
169
  paymentId,
169
170
  code: lastFailure?.code ?? "USER_CANCELED",
170
- message: lastFailure?.message ?? "결제를 취소했어요",
171
+ message: lastFailure?.message ?? "결제를 취소했습니다",
171
172
  };
172
173
  }
173
174
  if (result === "FAILED" || result === "EXPIRED") {
@@ -177,13 +178,13 @@ export function paymentResultOf(
177
178
  code: lastFailure?.code ?? result,
178
179
  message:
179
180
  lastFailure?.message ??
180
- (result === "EXPIRED" ? "결제 요청이 만료됐어요" : "결제에 실패했어요"),
181
+ (result === "EXPIRED" ? "결제 요청이 만료됐습니다" : "결제에 실패했습니다"),
181
182
  };
182
183
  }
183
184
  if (result === "PENDING") {
184
185
  // 결제창이 닫혔는데 아직 결제 전이면 구매자가 닫은 것이다
185
186
  return options.popupClosed
186
- ? { status: "CANCELED", paymentId, code: "POPUP_CLOSED", message: "결제창을 닫았어요" }
187
+ ? { status: "CANCELED", paymentId, code: "POPUP_CLOSED", message: "결제창을 닫았습니다" }
187
188
  : { status: "PROCESSING", paymentId };
188
189
  }
189
190
  return { status: "PROCESSING", paymentId };
@@ -236,6 +237,8 @@ export interface StartOptions {
236
237
  option: PaymentOption;
237
238
  shippingAddress: ShippingAddressInput;
238
239
  guest?: GuestInfo;
240
+ /** 현금영수증 신청 — 계좌이체·가상계좌 옵션에서만 보낸다 */
241
+ cashReceipt?: CashReceiptRequest;
239
242
  returnUrl?: string;
240
243
  mode?: PaymentMode;
241
244
  /** 클릭 시점에 {@link Payments.prepareWindow}로 미리 연 창. 폼 검증이 비동기라 클릭 태스크를 넘길 때 쓴다 */
@@ -246,6 +249,8 @@ export interface RetryOptions {
246
249
  option: PaymentOption;
247
250
  /** 생략하면 결제 시작 때의 복귀 주소를 쓴다 */
248
251
  returnUrl?: string;
252
+ /** 현금영수증 신청을 바꾼다. 생략하면 결제 시작 때의 신청을 그대로 둔다 */
253
+ cashReceipt?: CashReceiptRequest;
249
254
  mode?: PaymentMode;
250
255
  /** 클릭 시점에 {@link Payments.prepareWindow}로 미리 연 창 */
251
256
  window?: PreparedPaymentWindow;
@@ -335,7 +340,7 @@ export function createPayments(options: PaymentsOptions): Payments {
335
340
  return; // 주소를 읽지 못하면 검사하지 않는다
336
341
  }
337
342
  console.warn(
338
- `[sayren] returnUrl(${returnUrl})의 origin이 지금 페이지와 달라요 — 결제 서비스는 그 origin으로 결과 메시지를 보내므로 팝업 결과가 도달하지 않고 결제 상태 조회로만 끝나요`,
343
+ `[sayren] returnUrl(${returnUrl})의 origin이 지금 페이지와 다릅니다 — 결제 서비스는 그 origin으로 결과 메시지를 보내므로 팝업 결과가 도달하지 않고 결제 상태 조회로만 끝납니다`,
339
344
  );
340
345
  }
341
346
 
@@ -364,7 +369,7 @@ export function createPayments(options: PaymentsOptions): Payments {
364
369
  if (!given) return prepareWindow({ mode });
365
370
  if ((given as Partial<PreparedWindowInternal>)[PREPARED_WINDOW] !== brand) {
366
371
  throw new TypeError(
367
- "window는 같은 createPayments의 prepareWindow()가 돌려준 핸들이어야 해요",
372
+ "window는 같은 createPayments의 prepareWindow()가 돌려준 핸들이어야 합니다",
368
373
  );
369
374
  }
370
375
  return given as PreparedWindowInternal;
@@ -512,7 +517,7 @@ export function createPayments(options: PaymentsOptions): Payments {
512
517
  if (!returnUrl) {
513
518
  prepared.close();
514
519
  return Promise.reject(
515
- new Error("returnUrl이 필요해요 — createPayments 또는 start에 넘겨 주세요"),
520
+ new Error("returnUrl이 필요합니다 — createPayments 또는 start에 넘겨 주십시오"),
516
521
  );
517
522
  }
518
523
  warnReturnOrigin(returnUrl, prepared.mode);
@@ -521,6 +526,7 @@ export function createPayments(options: PaymentsOptions): Payments {
521
526
  option: startOptions.option,
522
527
  shippingAddress: startOptions.shippingAddress,
523
528
  guest: startOptions.guest,
529
+ ...(startOptions.cashReceipt ? { cashReceipt: startOptions.cashReceipt } : {}),
524
530
  returnUrl,
525
531
  }),
526
532
  );
@@ -531,7 +537,11 @@ export function createPayments(options: PaymentsOptions): Payments {
531
537
  const returnUrl = retryOptions.returnUrl ?? options.returnUrl;
532
538
  warnReturnOrigin(returnUrl, prepared.mode);
533
539
  return startWith(prepared, () =>
534
- client.payments.retry(paymentId, { option: retryOptions.option, returnUrl }),
540
+ client.payments.retry(paymentId, {
541
+ option: retryOptions.option,
542
+ returnUrl,
543
+ ...(retryOptions.cashReceipt ? { cashReceipt: retryOptions.cashReceipt } : {}),
544
+ }),
535
545
  );
536
546
  },
537
547
 
@@ -544,7 +554,7 @@ export function createPayments(options: PaymentsOptions): Payments {
544
554
  } catch {
545
555
  paymentId = null;
546
556
  }
547
- if (!paymentId) return { status: "INVALID", message: "결제 정보가 없는 주소예요" };
557
+ if (!paymentId) return { status: "INVALID", message: "결제 정보가 없는 주소입니다" };
548
558
  return paymentResultOf(await client.payments.getStatus(paymentId));
549
559
  },
550
560
  };
@@ -17,6 +17,44 @@ export const createCheckoutRequestSchema = z
17
17
 
18
18
  export type CreateCheckoutRequest = z.infer<typeof createCheckoutRequestSchema>;
19
19
 
20
+ /** 지역 추가 배송비 구분 — 우편번호로 판정한다 */
21
+ export const remoteAreaSchema = z.enum(["NONE", "JEJU", "ISOLATED"]);
22
+
23
+ export type RemoteArea = z.infer<typeof remoteAreaSchema>;
24
+
25
+ /**
26
+ * 배송비 내역 (이슈 #29) — 배송비를 **몇 번, 왜** 부과했는지다.
27
+ *
28
+ * 배송비는 **배송 묶음**마다 붙는다. 같은 출고지 + 같은 배송 정책인 상품은 한 묶음이고 한 번만 낸다.
29
+ * 제주·도서산간 추가 배송비는 묶음마다 더하고, 무료배송이어도 붙는다.
30
+ *
31
+ * 주문서를 만들 때는 배송지를 모르므로 `zipCode`가 null이고 `remoteSurcharge`가 0이다.
32
+ * 배송지를 입력하면 `POST /storefront/v1/checkout/{checkoutId}/delivery-quote`로 다시 받고,
33
+ * **결제 시작이 보낸 배송지로 최종 확정한다** — 그래서 주문서 조회 금액과 실제 결제 금액이 다를 수 있다.
34
+ */
35
+ export const checkoutDeliverySchema = z.object({
36
+ baseFee: z.number().int().describe("기본 배송비 합계. 묶음마다 부과한 금액의 합이다"),
37
+ remoteSurcharge: z
38
+ .number()
39
+ .int()
40
+ .describe("제주·도서산간 추가 배송비 합계. 묶음마다 붙는다. 배송지를 모르면 0"),
41
+ remoteArea: remoteAreaSchema.describe("배송지 지역 구분. 배송지를 모르면 NONE"),
42
+ remoteAreaLabel: z
43
+ .string()
44
+ .nullable()
45
+ .describe("지역 안내 이름(예: 제주특별자치도). 추가 배송비 지역이 아니면 null"),
46
+ bundleCount: z.number().int().describe("배송 묶음 수 — 배송비를 부과한 횟수다"),
47
+ freeByThreshold: z
48
+ .boolean()
49
+ .describe("스토어 전체 무료배송 기준액을 넘겨 기본 배송비를 면제했는가"),
50
+ zipCode: z
51
+ .string()
52
+ .nullable()
53
+ .describe("이 금액에 반영한 배송지 우편번호. null이면 배송지 미반영(추가 배송비 0)이다"),
54
+ });
55
+
56
+ export type CheckoutDelivery = z.infer<typeof checkoutDeliverySchema>;
57
+
20
58
  export const checkoutSessionSchema = z.object({
21
59
  checkoutId: z.string(),
22
60
  items: z.array(
@@ -36,6 +74,9 @@ export const checkoutSessionSchema = z.object({
36
74
  deliveryFee: z.number().int(),
37
75
  totalAmount: z.number().int(),
38
76
  }),
77
+ delivery: checkoutDeliverySchema.describe(
78
+ "배송비 내역. 배송지를 입력하면 금액이 바뀔 수 있다 — 최종 금액은 결제 시작 응답의 `amounts`다",
79
+ ),
39
80
  expiresAt: z.string(),
40
81
  paymentOptions: z
41
82
  .lazy(() => paymentOptionListSchema)
@@ -45,12 +86,34 @@ export const checkoutSessionSchema = z.object({
45
86
  testPayment: z
46
87
  .boolean()
47
88
  .describe(
48
- "지금 결제하면 테스트 결제(샌드박스)인가. true면 실제로 돈이 나가지 않아요 — 주문서에 테스트 결제 안내를 표시해요. 결제 요청 시점에 스토어 결제 설정으로 다시 정해지니 최종 값은 결제 요청 응답의 `testPayment`예요",
89
+ "지금 결제하면 테스트 결제(샌드박스)인가. true면 실제로 돈이 나가지 않습니다 — 주문서에 테스트 결제 안내를 표시합니다. 결제 요청 시점에 스토어 결제 설정으로 다시 정해지니 최종 값은 결제 요청 응답의 `testPayment`입니다",
49
90
  ),
50
91
  });
51
92
 
52
93
  export type CheckoutSession = z.infer<typeof checkoutSessionSchema>;
53
94
 
95
+ /**
96
+ * 배송비 미리보기 요청 — 배송지 우편번호로 배송비를 다시 계산한다. 주문서를 바꾸지 않는다(읽기 계산이다).
97
+ *
98
+ * 구매자가 배송지를 입력하거나 고칠 때 부르면 제주·도서산간 추가 배송비가 반영된 금액을 미리 보여 줄 수 있다.
99
+ */
100
+ export const deliveryQuoteRequestSchema = z.object({
101
+ zipCode: z
102
+ .string()
103
+ .regex(/^[0-9]{5}$/, "우편번호 5자리를 입력해 주십시오")
104
+ .describe("배송지 우편번호 5자리"),
105
+ });
106
+
107
+ export type DeliveryQuoteRequest = z.infer<typeof deliveryQuoteRequestSchema>;
108
+
109
+ /** 배송비 미리보기 응답 — 이 배송지로 결제하면 나갈 금액이다 */
110
+ export const deliveryQuoteSchema = z.object({
111
+ amounts: checkoutSessionSchema.shape.amounts.describe("이 배송지 기준 금액"),
112
+ delivery: checkoutDeliverySchema,
113
+ });
114
+
115
+ export type DeliveryQuoteResult = z.infer<typeof deliveryQuoteSchema>;
116
+
54
117
  export const paymentMethodSchema = z.enum([
55
118
  "CARD",
56
119
  "BANK_TRANSFER",
@@ -127,10 +190,10 @@ export function paymentOptionKey(option: PaymentOption): string {
127
190
 
128
191
  export const shippingAddressInputSchema = z.object({
129
192
  addressId: z.string().optional(),
130
- receiverName: z.string().min(1, "수령인명을 입력해주세요").max(50),
193
+ receiverName: z.string().min(1, "수령인명을 입력해주십시오").max(50),
131
194
  phone: z.string().regex(/^01[0-9]{8,9}$/, "연락처 형식이 아닙니다 (예: 01012345678)"),
132
195
  zipCode: z.string().regex(/^[0-9]{5}$/, "우편번호는 5자리 숫자입니다"),
133
- address1: z.string().min(1, "기본 주소를 입력해주세요"),
196
+ address1: z.string().min(1, "기본 주소를 입력해주십시오"),
134
197
  address2: z.string().optional(),
135
198
  deliveryMemo: z.string().max(100).optional(),
136
199
  entranceCode: z.string().max(20).optional(),
@@ -147,6 +210,122 @@ export const guestInfoSchema = z.object({
147
210
 
148
211
  export type GuestInfo = z.infer<typeof guestInfoSchema>;
149
212
 
213
+ /** 현금영수증을 신청할 수 있는 결제수단 — 계좌이체·가상계좌 */
214
+ export const CASH_RECEIPT_METHODS = ["BANK_TRANSFER", "VIRTUAL_ACCOUNT"] as const;
215
+
216
+ /** 이 결제수단으로 현금영수증을 신청할 수 있는가 */
217
+ export function cashReceiptAvailable(method: string): boolean {
218
+ return (CASH_RECEIPT_METHODS as readonly string[]).includes(method);
219
+ }
220
+
221
+ /** 현금영수증 용도 — `INCOME_DEDUCTION` 소득공제(개인) · `EXPENSE_PROOF` 지출증빙(사업자) */
222
+ export const cashReceiptTypeSchema = z.enum(["INCOME_DEDUCTION", "EXPENSE_PROOF"]);
223
+ export type CashReceiptType = z.infer<typeof cashReceiptTypeSchema>;
224
+
225
+ /** 현금영수증 식별 번호 종류 — 휴대폰 번호 · 현금영수증카드 번호 · 사업자등록번호 */
226
+ export type CashReceiptIdentityType = "PHONE" | "CARD" | "BUSINESS";
227
+
228
+ /**
229
+ * 주민등록번호·외국인등록번호 형식인가 — 현금영수증 식별 번호로는 받지 않는다.
230
+ *
231
+ * 현금영수증카드 번호의 아래 끝(13자리)이 주민등록번호 길이와 겹친다. 개인 식별 번호는 최소로 받는다는 원칙이라
232
+ * 주민등록번호로 읽히는 값은 카드 번호 자리로도 받지 않는다.
233
+ *
234
+ * **13자리 숫자 전부를 막지 않는다.** 13자리 현금영수증카드를 쓰는 구매자가 신청할 수 없게 되기 때문이다.
235
+ * 대신 주민등록번호 형식 — 생년월일 6자리(월 01~12, 일 01~31) + 성별·세기 코드(1~8, 5~8은 외국인등록번호) — 으로
236
+ * 판정해 오탐을 줄인다(13자리 숫자 중 이 형식에 걸리는 비율은 약 3%다).
237
+ *
238
+ * 검증번호(뒤 1자리 mod 11)까지는 보지 않는다. 한 자리 오타가 난 주민등록번호도 생년월일을 담고 있어 막아야 하고,
239
+ * 2020년 10월 이후 발급된 외국인등록번호에는 검증번호 규칙이 없다.
240
+ *
241
+ * `cashReceiptIdentityTypeOf`와 같이 하이픈·공백은 빼고 판정한다.
242
+ */
243
+ export function residentNumberLike(identityNumber: string): boolean {
244
+ const digits = identityNumber.replace(/[\s-]/g, "");
245
+ if (!/^[0-9]{13}$/.test(digits)) return false;
246
+ const month = Number(digits.slice(2, 4));
247
+ const day = Number(digits.slice(4, 6));
248
+ const genderCode = Number(digits[6]);
249
+ return month >= 1 && month <= 12 && day >= 1 && day <= 31 && genderCode >= 1 && genderCode <= 8;
250
+ }
251
+
252
+ /**
253
+ * 식별 번호 → 종류. 하이픈·공백은 빼고 판정한다. 용도에 맞지 않는 번호면 null이다.
254
+ * - 소득공제: 휴대폰 번호(01로 시작하는 10~11자리) 또는 현금영수증카드 번호(13~19자리)
255
+ * - 지출증빙: 사업자등록번호(10자리) 또는 현금영수증카드 번호(13~19자리)
256
+ *
257
+ * 자릿수 근거. PG 문서는 식별 번호의 최대 길이만 정하고 종류별 자릿수는 정하지 않는다
258
+ * (토스페이먼츠 `customerIdentityNumber` "최대 길이는 30자", 소득공제는 휴대폰 번호·현금영수증카드 번호,
259
+ * 지출증빙은 사업자등록번호: https://docs.tosspayments.com/common/apis/cash-receipt /
260
+ * 포트원 V2는 종류만 PHONE·CARD·BUSINESS로 나눈다: https://developers.portone.io/api/rest-v2/payment.cashReceipt).
261
+ * 그래서 자릿수는 국세청이 정하는 발급수단 규격을 따른다 — 홈택스 소비자 발급수단 등록의 카드 번호는
262
+ * 13~19자리 숫자이고(https://thisthatbase.com/cash-receipts-card-registration/ 가 옮긴 홈택스 안내,
263
+ * 원본은 https://www.hometax.go.kr/ 소비자 발급수단 관리), 사업자등록번호는 10자리, 휴대폰 번호는 01X + 7~8자리다.
264
+ * 국세청 자진발급 번호(010-000-1234)도 휴대폰 형식으로 통과한다.
265
+ */
266
+ export function cashReceiptIdentityTypeOf(
267
+ type: CashReceiptType,
268
+ identityNumber: string,
269
+ ): CashReceiptIdentityType | null {
270
+ const digits = identityNumber.replace(/[\s-]/g, "");
271
+ if (!/^[0-9]+$/.test(digits)) return null;
272
+ if (residentNumberLike(digits)) return null;
273
+ if (/^[0-9]{13,19}$/.test(digits)) return "CARD";
274
+ if (type === "INCOME_DEDUCTION") return /^01[016789][0-9]{7,8}$/.test(digits) ? "PHONE" : null;
275
+ return /^[0-9]{10}$/.test(digits) ? "BUSINESS" : null;
276
+ }
277
+
278
+ /**
279
+ * 현금영수증 신청 — 계좌이체·가상계좌 결제에서만 보낼 수 있다. 결제가 승인되면 sayren이 결제한 PG로 발급하고,
280
+ * 환불하면 그만큼 취소한다. 식별 번호는 암호화해 저장하고 응답에는 가린 값만 나간다.
281
+ */
282
+ export const cashReceiptRequestSchema = z
283
+ .object({
284
+ type: cashReceiptTypeSchema.describe(
285
+ "용도. `INCOME_DEDUCTION` 소득공제(개인) · `EXPENSE_PROOF` 지출증빙(사업자)",
286
+ ),
287
+ identityNumber: z
288
+ .string()
289
+ .max(30)
290
+ .describe(
291
+ "식별 번호. 소득공제는 휴대폰 번호(01X + 7~8자리) 또는 현금영수증카드 번호(13~19자리), 지출증빙은 " +
292
+ "사업자등록번호(10자리) 또는 현금영수증카드 번호(13~19자리). 하이픈은 빼고 읽는다. " +
293
+ "주민등록번호는 받지 않는다(`RESIDENT_NUMBER_NOT_ALLOWED`)",
294
+ ),
295
+ })
296
+ .superRefine((value, ctx) => {
297
+ // 주민등록번호는 단순 형식 오류와 가른다 — 구매자에게 다른 번호를 입력하라고 안내해야 하고,
298
+ // 셀러·개발자가 필드 오류 코드로 이 경로를 집계할 수 있어야 한다.
299
+ // 필드 오류 코드는 메시지 끝의 `(CODE)`에서 나온다(api `zod-validation.pipe.ts`의 `extractCode`).
300
+ if (residentNumberLike(value.identityNumber)) {
301
+ ctx.addIssue({
302
+ code: "custom",
303
+ path: ["identityNumber"],
304
+ message:
305
+ "주민등록번호는 현금영수증 식별 번호로 쓸 수 없습니다. " +
306
+ "휴대폰 번호 또는 현금영수증카드 번호를 입력해주십시오 (RESIDENT_NUMBER_NOT_ALLOWED)",
307
+ });
308
+ return;
309
+ }
310
+ if (!cashReceiptIdentityTypeOf(value.type, value.identityNumber)) {
311
+ ctx.addIssue({
312
+ code: "custom",
313
+ path: ["identityNumber"],
314
+ message:
315
+ value.type === "INCOME_DEDUCTION"
316
+ ? "휴대폰 번호 또는 현금영수증카드 번호를 입력해주십시오"
317
+ : "사업자등록번호(10자리) 또는 현금영수증카드 번호를 입력해주십시오",
318
+ });
319
+ }
320
+ });
321
+ export type CashReceiptRequest = z.infer<typeof cashReceiptRequestSchema>;
322
+
323
+ const cashReceiptField = cashReceiptRequestSchema
324
+ .optional()
325
+ .describe(
326
+ "현금영수증 신청. 계좌이체·가상계좌 옵션에서만 보낼 수 있다(그 밖의 옵션이면 400 `CASH_RECEIPT_NOT_AVAILABLE`). 결제가 승인되면 발급한다",
327
+ );
328
+
150
329
  /**
151
330
  * 결제 시작 — 배송지를 확정하고 고른 결제 옵션으로 결제 시도를 연다. 응답의 `payUrl`(결제 서비스)을 팝업(`mode=popup`)이나
152
331
  * 전체 페이지로 열면 결제 서비스가 PG 결제창을 띄우고 승인까지 한다. 리다이렉트 결제가 끝나면 구매자는 `returnUrl`로 돌아온다.
@@ -162,14 +341,20 @@ export const startPaymentRequestSchema = z.object({
162
341
  ),
163
342
  shippingAddress: shippingAddressInputSchema,
164
343
  guest: guestInfoSchema.optional(),
344
+ cashReceipt: cashReceiptField,
165
345
  });
166
346
 
167
347
  export type StartPaymentRequest = z.infer<typeof startPaymentRequestSchema>;
168
348
 
169
- /** 같은 결제에서 다른 결제 옵션으로 다시 시도. `returnUrl`을 생략하면 결제 시작 때의 복귀 주소를 쓴다 */
349
+ /**
350
+ * 같은 결제에서 다른 결제 옵션으로 다시 시도. `returnUrl`을 생략하면 결제 시작 때의 복귀 주소를 쓴다.
351
+ * `cashReceipt`를 보내면 현금영수증 신청을 이 값으로 바꾸고, 생략하면 결제 시작 때의 신청을 그대로 둔다
352
+ * (승인된 결제수단이 계좌이체·가상계좌가 아니면 발급하지 않는다)
353
+ */
170
354
  export const retryPaymentRequestSchema = z.object({
171
355
  option: paymentOptionSchema,
172
356
  returnUrl: startPaymentRequestSchema.shape.returnUrl.optional(),
357
+ cashReceipt: cashReceiptField,
173
358
  });
174
359
 
175
360
  export type RetryPaymentRequest = z.infer<typeof retryPaymentRequestSchema>;
@@ -189,6 +374,12 @@ export const paymentStartSchema = z.object({
189
374
  .describe(
190
375
  "테스트 결제(샌드박스) 여부. true면 실제로 돈이 나가지 않는다 — 결제 화면에 테스트 결제 안내를 표시한다",
191
376
  ),
377
+ amounts: checkoutSessionSchema.shape.amounts.describe(
378
+ "**확정 결제 금액** — 보낸 배송지로 배송비를 다시 계산한 값이다. 주문서 조회 시점의 `amounts`와 다를 수 있다(제주·도서산간 추가 배송비)",
379
+ ),
380
+ delivery: checkoutDeliverySchema.describe(
381
+ "확정 배송비 내역. `zipCode`는 보낸 배송지의 우편번호다",
382
+ ),
192
383
  });
193
384
 
194
385
  export type PaymentStart = z.infer<typeof paymentStartSchema>;
@@ -231,14 +422,14 @@ export const paymentStatusSchema = z.object({
231
422
  .string()
232
423
  .describe(
233
424
  "결제 상태. 현재 값은 `pending` 결제 대기 · `processing` 승인 확인 중 · `completed` 결제 완료(주문 생성) · `failed` 실패 · " +
234
- "`expired` 만료예요. 상태가 추가될 수 있어 문자열로 받아요 — 모르는 값은 `processing`처럼 다루고 계속 조회하세요",
425
+ "`expired` 만료입니다. 상태가 추가될 수 있어 문자열로 받습니다 — 모르는 값은 `processing`처럼 다루고 계속 조회하십시오",
235
426
  ),
236
427
  result: z
237
428
  .string()
238
429
  .describe(
239
430
  "화면 분기용 결과. `COMPLETED` 결제 완료 · `PROCESSING` 결과 확인 중 · `CANCELED` 구매자가 결제창을 닫음 · " +
240
- "`FAILED` 결제 실패(다른 결제 옵션으로 재시도 가능) · `EXPIRED` 결제 요청 만료 · `PENDING` 결제 전이에요. " +
241
- "값이 추가될 수 있어 문자열로 받아요 — 모르는 값은 `PROCESSING`처럼 다루세요",
431
+ "`FAILED` 결제 실패(다른 결제 옵션으로 재시도 가능) · `EXPIRED` 결제 요청 만료 · `PENDING` 결제 전입니다. " +
432
+ "값이 추가될 수 있어 문자열로 받습니다 — 모르는 값은 `PROCESSING`처럼 다루십시오",
242
433
  ),
243
434
  orderId: z.string().nullable().describe("결제가 완료됐으면 주문 번호, 그 외에는 null"),
244
435
  lastFailure: z
@@ -250,14 +441,14 @@ export const paymentStatusSchema = z.object({
250
441
  code: z
251
442
  .string()
252
443
  .nullable()
253
- .describe("PG가 준 실패 코드. 내부 사유는 null이에요. 화면 분기는 `category`로 하세요"),
444
+ .describe("PG가 준 실패 코드. 내부 사유는 null입니다. 화면 분기는 `category`로 하십시오"),
254
445
  message: z.string().nullable().describe("구매자에게 보여 줄 안내 문구"),
255
446
  })
256
447
  .nullable()
257
448
  .describe("현재 결제 시도가 실패·취소로 끝났으면 그 내용, 아니면 null"),
258
449
  testPayment: z
259
450
  .boolean()
260
- .describe("테스트 결제(샌드박스)인가. true면 실제로 돈이 나가지 않았어요"),
451
+ .describe("테스트 결제(샌드박스)인가. true면 실제로 돈이 나가지 않았습니다"),
261
452
  expiresAt: z.string().describe("결제 요청 만료 시각"),
262
453
  });
263
454
 
@@ -56,6 +56,24 @@ export const createClaimResultSchema = z.object({
56
56
 
57
57
  export type CreateClaimResult = z.infer<typeof createClaimResultSchema>;
58
58
 
59
+ /**
60
+ * 반품지 (이슈 #30) — 반품·교환 수거가 안내하는 주소다. 스토어 주소록의 기본 반품지이거나,
61
+ * 상품이 반품지를 따로 지정했으면 그 주소다.
62
+ *
63
+ * 취소 클레임은 보낼 물건이 없어 null이고, 반품지를 등록하지 않은 스토어도 null이다.
64
+ * 구매자가 직접 보내는 수거(`BUYER_SEND`)에 이 주소가 필요하다.
65
+ */
66
+ export const claimReturnAddressSchema = z.object({
67
+ name: z.string().describe("주소 이름(셀러가 붙인 별칭). 예: 본사 물류센터"),
68
+ contactName: z.string().describe("수취인 이름"),
69
+ phone: z.string().describe("연락처"),
70
+ zipCode: z.string(),
71
+ address1: z.string(),
72
+ address2: z.string().nullable(),
73
+ });
74
+
75
+ export type ClaimReturnAddress = z.infer<typeof claimReturnAddressSchema>;
76
+
59
77
  export const myClaimSchema = z.object({
60
78
  claimId: z.string(),
61
79
  type: claimTypeSchema,
@@ -81,6 +99,11 @@ export const myClaimSchema = z.object({
81
99
  "이 필드가 생기기 전에 접수된 클레임은 null이다",
82
100
  ),
83
101
  rejectReason: z.string().nullable(),
102
+ returnAddress: claimReturnAddressSchema
103
+ .nullable()
104
+ .describe(
105
+ "반품·교환 물건을 보낼 반품지(이슈 #30). 직접 보내는 수거 안내에 쓴다. 취소 클레임은 null",
106
+ ),
84
107
  requestedAt: z.string(),
85
108
  completedAt: z.string().nullable(),
86
109
  });
@@ -73,24 +73,57 @@ export const myOrderSchema = z.object({
73
73
  testPayment: z
74
74
  .boolean()
75
75
  .describe(
76
- "테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 나가지 않았어요 — 주문 화면에 테스트 결제 배지를 표시해요",
76
+ "테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 나가지 않았습니다 — 주문 화면에 테스트 결제 배지를 표시합니다",
77
77
  ),
78
78
  });
79
79
 
80
80
  export type MyOrder = z.infer<typeof myOrderSchema>;
81
81
 
82
+ /**
83
+ * 택배 추적 단계. 응답에서는 문자열로 받는다(값이 추가될 수 있다) — 현재 값은 `ACCEPTED`(접수)·`IN_TRANSIT`(이동 중)·
84
+ * `OUT_FOR_DELIVERY`(배송 출발)·`DELIVERED`(배송완료)다.
85
+ */
86
+ export const trackingStageResponseSchema = z.string();
87
+
82
88
  export const deliveryTrackingSchema = z.object({
83
89
  carrierName: z.string(),
84
90
  trackingNumber: z.string(),
85
91
  trackingUrl: z.url(),
86
92
  status: z.enum(["DISPATCHED", "DELIVERING", "DELIVERED"]),
87
- trackingEvents: z.array(
88
- z.object({
89
- status: z.string(),
90
- location: z.string(),
91
- occurredAt: z.string(),
92
- }),
93
- ),
93
+ trackingEvents: z
94
+ .array(
95
+ z.object({
96
+ status: z.string().describe("택배사가 알려 준 처리 내용(예: 집화처리, 배송출발)"),
97
+ location: z.string().describe("처리 장소. 모르면 빈 문자열"),
98
+ occurredAt: z.string(),
99
+ stage: trackingStageResponseSchema
100
+ .nullable()
101
+ .optional()
102
+ .default(null)
103
+ .describe(
104
+ "이 이벤트의 추적 단계. 배달 실패처럼 단계로 나눌 수 없는 이벤트는 null. 서버는 항상 싣는다. " +
105
+ "현재 값은 `ACCEPTED`·`IN_TRANSIT`·`OUT_FOR_DELIVERY`·`DELIVERED`이고 값이 추가될 수 있어 문자열로 받는다",
106
+ ),
107
+ }),
108
+ )
109
+ .describe("배송 추적 이력. 시각 오름차순(오래된 것부터)"),
110
+ trackingStage: trackingStageResponseSchema
111
+ .nullable()
112
+ .optional()
113
+ .default(null)
114
+ .describe(
115
+ "현재 추적 단계. 택배 발송이 아니거나, 아직 조회되지 않았거나, 택배사 전산에 송장이 없으면 null. 서버는 항상 싣는다. " +
116
+ "현재 값은 `ACCEPTED`(접수)·`IN_TRANSIT`(이동 중)·`OUT_FOR_DELIVERY`(배송 출발)·`DELIVERED`(배송완료)이고 " +
117
+ "값이 추가될 수 있어 문자열로 받는다",
118
+ ),
119
+ trackingCheckedAt: z
120
+ .string()
121
+ .nullable()
122
+ .optional()
123
+ .default(null)
124
+ .describe(
125
+ "택배사에서 추적 정보를 마지막으로 받아 온 시각(ISO 8601). 받아 온 적이 없으면 null. 서버는 항상 싣는다",
126
+ ),
94
127
  });
95
128
 
96
129
  export type DeliveryTracking = z.infer<typeof deliveryTrackingSchema>;
@@ -40,7 +40,7 @@ export type WritableReview = z.infer<typeof writableReviewSchema>;
40
40
 
41
41
  export const createReviewRequestSchema = z.object({
42
42
  rating: z.number().int().min(1).max(5),
43
- content: z.string().min(10, "리뷰는 10자 이상 작성해주세요").max(2000),
43
+ content: z.string().min(10, "리뷰는 10자 이상 작성해주십시오").max(2000),
44
44
  images: z.array(z.url()).max(5).optional(),
45
45
  });
46
46