@sayren/store-sdk 0.28.0 → 0.28.2

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.
@@ -3,9 +3,9 @@ import { z } from "zod";
3
3
  /**
4
4
  * 환불 정책 (이슈 #25) — 취소·반품·교환에서 **배송비를 누가 부담하는지**를 정한다.
5
5
  *
6
- * 두 층이다. 스토어 설정(`PATCH /v1/store`의 `refundPolicy`)이 기본값이고, 상품별 정책
6
+ * 두 층이다. 상점 설정(`PATCH /v1/store`의 `refundPolicy`)이 기본값이고, 상품별 정책
7
7
  * (`POST/PUT /v1/products`의 `refundPolicy`)이 그 상품에만 덮어쓴다. 상품이 정하지 않은 항목은
8
- * 스토어 기본을 따르고, 스토어도 정하지 않았으면 `REFUND_POLICY_DEFAULTS`다.
8
+ * 상점 기본을 따르고, 상점도 정하지 않았으면 `REFUND_POLICY_DEFAULTS`다.
9
9
  *
10
10
  * 한 주문에 정책이 섞이면 **상품별로 계산한다**. 배송비 묶음도 상품 단위다(주문서·장바구니의
11
11
  * 배송비 합산과 같은 규칙: 같은 상품의 주문상품이 한 묶음이고 묶음마다 배송비가 붙는다).
@@ -30,7 +30,7 @@ const descriptions = {
30
30
  "판매자 귀책(셀러 직권취소, 불량·오배송 반품)은 빼지 않는다. false면 차감 없이 실결제 금액만 나눠 환불한다. 기본 true",
31
31
  } as const;
32
32
 
33
- /** 유효 환불 정책 — 스토어 기본 + 상품별 덮어쓰기를 합친 결과 */
33
+ /** 유효 환불 정책 — 상점 기본 + 상품별 덮어쓰기를 합친 결과 */
34
34
  export const refundPolicySchema = z.object({
35
35
  preShipmentDeliveryFeeRefund: z.boolean().describe(descriptions.preShipmentDeliveryFeeRefund),
36
36
  returnDeliveryFee: z.number().int().min(0).describe(descriptions.returnDeliveryFee),
@@ -49,7 +49,7 @@ export const refundPolicySchema = z.object({
49
49
  export type RefundPolicy = z.infer<typeof refundPolicySchema>;
50
50
 
51
51
  /**
52
- * 스토어가 정하지 않았을 때 서버가 쓰는 값.
52
+ * 상점이 정하지 않았을 때 서버가 쓰는 값.
53
53
  *
54
54
  * `as const`가 아니라 `RefundPolicy`로 타입을 좁힌다 — 리터럴 타입이면 `Partial<typeof …>`로 일부만 바꿀 때
55
55
  * `true` 자리에 `false`를 넣을 수 없다.
@@ -64,7 +64,7 @@ export const REFUND_POLICY_DEFAULTS: RefundPolicy = {
64
64
  };
65
65
 
66
66
  /**
67
- * 부분 정책 — 스토어 설정 요청과 상품별 덮어쓰기가 쓴다. 준 항목만 바뀐다.
67
+ * 부분 정책 — 상점 설정 요청과 상품별 덮어쓰기가 쓴다. 준 항목만 바뀐다.
68
68
  *
69
69
  * `strictObject`라 모르는 키는 400이다. 오타(`returnFee` 등)를 조용히 무시하면 셀러는 저장됐다고
70
70
  * 믿는데 정책은 기본값으로 남는다.
@@ -93,7 +93,7 @@ export type RefundPolicyPatch = z.infer<typeof refundPolicyPatchSchema>;
93
93
 
94
94
  /**
95
95
  * 상품별 덮어쓰기 — 쿠폰 조건 미달 차감(`couponShortfallCharge`)은 주문 단위 규칙이라 상품이 정하지 않는다.
96
- * 스토어 정책만 읽는다(이슈 #47).
96
+ * 상점 정책만 읽는다(이슈 #47).
97
97
  */
98
98
  export const productRefundPolicyPatchSchema = refundPolicyPatchSchema.omit({
99
99
  couponShortfallCharge: true,
@@ -101,12 +101,12 @@ export const productRefundPolicyPatchSchema = refundPolicyPatchSchema.omit({
101
101
 
102
102
  /** 정책 해석에 쓰는 상품 값 — 상품별 덮어쓰기 */
103
103
  export interface RefundPolicyProductInput {
104
- /** 상품별 덮어쓰기. 없으면 스토어 기본을 그대로 쓴다 */
104
+ /** 상품별 덮어쓰기. 없으면 상점 기본을 그대로 쓴다 */
105
105
  refundPolicy?: RefundPolicyPatch | null;
106
106
  }
107
107
 
108
108
  /**
109
- * 유효 정책 해석 — 상품별 덮어쓰기 → 스토어 기본 → `REFUND_POLICY_DEFAULTS` 순이다.
109
+ * 유효 정책 해석 — 상품별 덮어쓰기 → 상점 기본 → `REFUND_POLICY_DEFAULTS` 순이다.
110
110
  *
111
111
  * 반품·교환 배송비의 원천은 정책 하나다(이슈 #57 결정 3). 상품의 레거시 반품비·교환비 필드와의 연결은 없앴고,
112
112
  * 레거시 값은 마이그레이션이 상품별 정책으로 옮겼다.
@@ -122,15 +122,15 @@ export function resolveRefundPolicy(
122
122
  /**
123
123
  * 상품의 **덮어쓰기 자체** — 덮어쓰기가 없으면 null.
124
124
  *
125
- * `resolveRefundPolicy`가 합친 결과로는 "상품이 정한 값"과 "스토어 기본을 물려받은 값"을 가를 수 없다.
126
- * 관리 API 응답의 `refundPolicy`와 콘솔 폼의 덮어쓰기 스위치가 이 함수를 원천으로 쓴다 — 가르지 못하면
127
- * 스토어 기본을 쓰는 상품을 저장할 때 그 순간의 기본값이 상품에 박힌다.
125
+ * `resolveRefundPolicy`가 합친 결과로는 "상품이 정한 값"과 "상점 기본을 물려받은 값"을 가를 수 없다.
126
+ * 관리 API 응답의 `refundPolicy`와 상점 플랫폼 폼의 덮어쓰기 스위치가 이 함수를 원천으로 쓴다 — 가르지 못하면
127
+ * 상점 기본을 쓰는 상품을 저장할 때 그 순간의 기본값이 상품에 박힌다.
128
128
  */
129
129
  export function productRefundPolicyOverride(
130
130
  product?: RefundPolicyProductInput | null,
131
131
  ): RefundPolicyPatch | null {
132
132
  if (product?.refundPolicy == null) return null;
133
- // 쿠폰 조건 미달 차감은 주문 단위라 스토어만 정한다 — 상품 행에 키가 있어도 덮어쓰기로 읽지 않는다(이슈 #47)
133
+ // 쿠폰 조건 미달 차감은 주문 단위라 상점만 정한다 — 상품 행에 키가 있어도 덮어쓰기로 읽지 않는다(이슈 #47)
134
134
  const { couponShortfallCharge: _storeOnly, ...override } = dropUndefined(product.refundPolicy);
135
135
  return override;
136
136
  }
@@ -16,7 +16,7 @@ import {
16
16
  * 달라 저장하지 않는다. 추가금은 조합별이 아니라 **값별**로 둔다 — 조합 옵션으로 불러오면 조합 추가금은 고른 값 추가금의 합이다.
17
17
  */
18
18
 
19
- /** 스토어당 저장 옵션 최대 개수 */
19
+ /** 상점당 저장 옵션 최대 개수 */
20
20
  export const MAX_SAVED_OPTION_SETS = 200;
21
21
  /** 저장 옵션 이름 최대 글자 수 */
22
22
  export const MAX_SAVED_OPTION_SET_NAME = 50;
@@ -2,10 +2,10 @@ import { z } from "zod";
2
2
  import type { StoreRole } from "./auth";
3
3
 
4
4
  /**
5
- * 스토어 스코프 카탈로그 (auth-spec §7) — 형식 `{리소스}:{r|rw}`, rw는 r을 함의한다.
5
+ * 상점 스코프 카탈로그 (auth-spec §7) — 형식 `{리소스}:{r|rw}`, rw는 r을 함의한다.
6
6
  *
7
7
  * 관리 API가 요구하는 스코프와 역할별 자동 부여 스코프의 **단일 원천**이다.
8
- * 서버(apps/api)가 이 상수를 그대로 가져다 역할→스코프를 산출하므로, 콘솔 권한 매트릭스와
8
+ * 서버(apps/api)가 이 상수를 그대로 가져다 역할→스코프를 산출하므로, 상점 플랫폼 권한 매트릭스와
9
9
  * 서버 인가가 갈라지지 않는다.
10
10
  */
11
11
 
@@ -49,7 +49,7 @@ export function isStoreScope(scope: string): boolean {
49
49
 
50
50
  /**
51
51
  * 역할별 자동 부여 스코프 (auth-spec §7).
52
- * 계정 토큰으로 스토어를 호출할 때의 스코프는 그 스토어에서의 역할로 이 표에서 결정된다.
52
+ * 계정 토큰으로 상점을 호출할 때의 스코프는 그 상점에서의 역할로 이 표에서 결정된다.
53
53
  */
54
54
  export const STORE_ROLE_SCOPES = {
55
55
  OWNER: [
@@ -9,7 +9,7 @@ import { type DeliveryType, type FulfillmentType, requiresShipping } from "./ful
9
9
  * (`deliveryBundleKeyOf`). 이슈 #25의 「묶음 = 같은 상품」 규칙을 일반화한 것이다 — 키를 상품 id에서
10
10
  * 정책 지문으로 바꿨고, 주문상품이 주문 시점 키를 스냅샷해(`order_items.delivery_bundle_key`)
11
11
  * 환불 정산(`refund-settlement.ts`)과 주문 스냅샷(`orders.delivery_fee_settlements`)이 같은 키를 쓴다.
12
- * 2. **무료 기준**: 상품별 조건부 무료(`CONDITIONAL_FREE`, 묶음 소계 기준)와 스토어 전체 무료 기준
12
+ * 2. **무료 기준**: 상품별 조건부 무료(`CONDITIONAL_FREE`, 묶음 소계 기준)와 상점 전체 무료 기준
13
13
  * (`freeThreshold`, 주문 상품 금액 합 기준)이 있다. 둘 중 하나라도 충족하면 그 배송비는 0이다.
14
14
  * 3. **지역 추가 배송비**: 우편번호가 제주·도서산간이면 **묶음마다** 추가된다. 묶음이 곧 한 번의 발송이라
15
15
  * 두 출고지에서 각각 보내면 추가 운임도 두 번 든다.
@@ -20,21 +20,21 @@ import { type DeliveryType, type FulfillmentType, requiresShipping } from "./ful
20
20
  * 계산은 `quoteDelivery` 하나다. 장바구니·주문서·결제 시작·환불 정산이 모두 이 함수를 쓴다 —
21
21
  * 네 곳이 각자 계산하면 화면 금액과 결제 금액이 갈라진다.
22
22
  *
23
- * **비실물 줄은 배송 계산에서 빠진다**(이슈 #48, `requiresShipping`). 묶음을 만들지 않고, 스토어 전체 무료 기준의
23
+ * **비실물 줄은 배송 계산에서 빠진다**(이슈 #48, `requiresShipping`). 묶음을 만들지 않고, 상점 전체 무료 기준의
24
24
  * 판정 금액(`shippableAmount`)에도 넣지 않는다 — 디지털 상품 금액이 실물 배송비를 면제시키지 않게 한다.
25
25
  */
26
26
 
27
27
  const descriptions = {
28
28
  baseDeliveryFee:
29
- "스토어 기본 배송비. 원 단위. 상품 등록·수정에서 `deliveryFee`를 생략하면 이 값을 쓴다. 기본 3000",
29
+ "상점 기본 배송비. 원 단위. 상품 등록·수정에서 `deliveryFee`를 생략하면 이 값을 쓴다. 기본 3000",
30
30
  freeThreshold:
31
- "스토어 전체 무료배송 기준액. 주문 상품 금액 합이 이 값 이상이면 모든 묶음의 배송비가 0이다. " +
31
+ "상점 전체 무료배송 기준액. 주문 상품 금액 합이 이 값 이상이면 모든 묶음의 배송비가 0이다. " +
32
32
  "null이면 전체 무료배송을 쓰지 않는다(상품별 조건부 무료는 그대로 동작한다). 기본 null",
33
33
  jejuSurcharge: "제주 지역 추가 배송비. 원 단위. **배송 묶음마다** 더한다. 기본 0",
34
34
  isolatedSurcharge: "도서산간 지역 추가 배송비. 원 단위. **배송 묶음마다** 더한다. 기본 0",
35
35
  } as const;
36
36
 
37
- /** 유효 배송비 정책 — 스토어가 정하지 않은 항목은 `SHIPPING_POLICY_DEFAULTS`로 채운 값이다 */
37
+ /** 유효 배송비 정책 — 상점이 정하지 않은 항목은 `SHIPPING_POLICY_DEFAULTS`로 채운 값이다 */
38
38
  export const shippingPolicySchema = z.object({
39
39
  baseDeliveryFee: z.number().int().min(0).describe(descriptions.baseDeliveryFee),
40
40
  freeThreshold: z.number().int().min(1).nullable().describe(descriptions.freeThreshold),
@@ -45,7 +45,7 @@ export const shippingPolicySchema = z.object({
45
45
  export type ShippingPolicy = z.infer<typeof shippingPolicySchema>;
46
46
 
47
47
  /**
48
- * 스토어가 정하지 않았을 때 서버가 쓰는 값.
48
+ * 상점이 정하지 않았을 때 서버가 쓰는 값.
49
49
  *
50
50
  * `as const`가 아니라 `ShippingPolicy`로 좁힌다 — 리터럴 타입이면 `Partial<typeof …>`로 일부만 바꿀 때
51
51
  * `null` 자리에 숫자를 넣을 수 없다(`REFUND_POLICY_DEFAULTS`와 같은 이유).
@@ -72,7 +72,7 @@ export const shippingPolicyPatchSchema = z.strictObject({
72
72
 
73
73
  export type ShippingPolicyPatch = z.infer<typeof shippingPolicyPatchSchema>;
74
74
 
75
- /** 유효 정책 해석 — 스토어가 정한 항목 + `SHIPPING_POLICY_DEFAULTS` */
75
+ /** 유효 정책 해석 — 상점이 정한 항목 + `SHIPPING_POLICY_DEFAULTS` */
76
76
  export function resolveShippingPolicy(patch?: ShippingPolicyPatch | null): ShippingPolicy {
77
77
  if (!patch) return { ...SHIPPING_POLICY_DEFAULTS };
78
78
  const given = Object.fromEntries(
@@ -89,10 +89,10 @@ export const remoteAreaSchema = z.enum(["NONE", "JEJU", "ISOLATED"]);
89
89
  export type RemoteArea = z.infer<typeof remoteAreaSchema>;
90
90
 
91
91
  /**
92
- * 제주·도서산간 우편번호 범위 — **코드 상수다.** 금액은 스토어가 정하고 범위는 플랫폼이 고정한다.
92
+ * 제주·도서산간 우편번호 범위 — **코드 상수다.** 금액은 상점이 정하고 범위는 플랫폼이 고정한다.
93
93
  *
94
94
  * 국내 택배사가 공표하는 제주·도서산간 할증 구간을 5자리 우편번호 범위로 옮긴 표다. 셀러마다
95
- * 범위가 다르면 같은 주소에 다른 금액이 나가고 CS가 셀러 수만큼 갈라진다. 스토어별 범위 편집은
95
+ * 범위가 다르면 같은 주소에 다른 금액이 나가고 CS가 셀러 수만큼 갈라진다. 상점별 범위 편집은
96
96
  * 후속 과제이고, 지금은 이 표가 유일한 원천이다.
97
97
  *
98
98
  * ## 근거
@@ -131,7 +131,7 @@ export type RemoteArea = z.infer<typeof remoteAreaSchema>;
131
131
  * 도로명주소·건물관리번호 단위 판정은 후속 과제다.
132
132
  *
133
133
  * 범위는 **닫힌 구간**(`from` ≤ zip ≤ `to`)이고 겹치지 않는다. 앞에서부터 처음 맞는 항목을 쓴다.
134
- * 순서는 콘솔 「추가 배송비 지역」 표의 행 순서이기도 하다(`remoteAreaGroups`). 제주를 먼저 두고
134
+ * 순서는 상점 플랫폼 「추가 배송비 지역」 표의 행 순서이기도 하다(`remoteAreaGroups`). 제주를 먼저 두고
135
135
  * 도서산간은 우편번호 오름차순으로 둔다.
136
136
  */
137
137
  export const REMOTE_AREA_ZIP_RANGES: ReadonlyArray<{
@@ -141,7 +141,7 @@ export const REMOTE_AREA_ZIP_RANGES: ReadonlyArray<{
141
141
  readonly label: string;
142
142
  }> = [
143
143
  // 제주 전지역. 추자면·우도는 택배사 표에서 더 높은 운임이지만 공개 계약의 지역 구분은
144
- // JEJU 하나이고 금액은 스토어가 정한다.
144
+ // JEJU 하나이고 금액은 상점이 정한다.
145
145
  { area: "JEJU", from: 63_000, to: 63_644, label: "제주특별자치도" },
146
146
  // 무의도·실미도·팔미도. 택배사 표는 「인천 중구 섬지역」이지만 2026-07-01 행정체제 개편으로
147
147
  // 중구가 나뉘면서 무의동은 영종구 소속이다.
@@ -220,7 +220,7 @@ export interface DeliveryBundleProduct {
220
220
  deliveryType: DeliveryType;
221
221
  deliveryFee: number;
222
222
  conditionalFreeAmount: number | null;
223
- /** 출고지 주소 id(`seller_addresses`). null이면 스토어 기본 출고지를 쓰는 상품끼리 한 묶음이다 */
223
+ /** 출고지 주소 id(`seller_addresses`). null이면 상점 기본 출고지를 쓰는 상품끼리 한 묶음이다 */
224
224
  shippingAddressId?: string | null;
225
225
  /**
226
226
  * 상품 유형 (이슈 #48). 생략·null이면 배송 상품이다. 배송이 필요 없는 유형은 묶음을 만들지 않는다.
@@ -265,7 +265,7 @@ export interface DeliveryFeeLine {
265
265
  * 기본 배송비를 면제한 근거 — 면제하지 않았으면 null이다.
266
266
  *
267
267
  * 환불 정산이 근거를 구분해야 한다. 조건부 무료 기준 미달 차감은 **상품별 조건부 무료(`CONDITIONAL`)로
268
- * 면제된 묶음만** 대상이다. 스토어 전체 무료 기준(`STORE_THRESHOLD`)이나 무료배송 상품(`FREE`)은 셀러가
268
+ * 면제된 묶음만** 대상이다. 상점 전체 무료 기준(`STORE_THRESHOLD`)이나 무료배송 상품(`FREE`)은 셀러가
269
269
  * 배송비를 받지 않기로 한 것이므로, 일부 취소로 기준이 깨져도 구매자에게 배송비를 물리지 않는다.
270
270
  *
271
271
  * 세 값이 겹치면 `FREE` → `STORE_THRESHOLD` → `CONDITIONAL` 순으로 센 근거를 남긴다.
@@ -277,7 +277,7 @@ export type DeliveryFeeWaiver = z.infer<typeof deliveryFeeWaiverSchema>;
277
277
  /** 묶음 하나의 견적 */
278
278
  export interface DeliveryBundleQuote {
279
279
  bundleKey: string;
280
- /** 이 묶음에 든 상품 id — 스냅샷 검증과 콘솔 안내에 쓴다 */
280
+ /** 이 묶음에 든 상품 id — 스냅샷 검증과 상점 플랫폼 안내에 쓴다 */
281
281
  productIds: string[];
282
282
  /** 실제 청구한 기본 배송비 — 무료배송·무료 기준 충족이면 0 */
283
283
  fee: number;
@@ -301,10 +301,10 @@ export interface DeliveryQuote {
301
301
  /** 청구할 배송비 총액 = `baseFee` + `remoteSurcharge` */
302
302
  deliveryFee: number;
303
303
  remoteArea: RemoteArea;
304
- /** 스토어 전체 무료 기준(`freeThreshold`) 충족으로 기본 배송비를 면제했는가 */
304
+ /** 상점 전체 무료 기준(`freeThreshold`) 충족으로 기본 배송비를 면제했는가 */
305
305
  freeByThreshold: boolean;
306
306
  /**
307
- * 배송이 필요한 줄의 상품 금액 합 — 스토어 전체 무료 기준의 판정 금액이다(이슈 #48). 비실물 줄은 빠진다.
307
+ * 배송이 필요한 줄의 상품 금액 합 — 상점 전체 무료 기준의 판정 금액이다(이슈 #48). 비실물 줄은 빠진다.
308
308
  * 주문 상품 금액 합(`productAmount`)과 다를 수 있다.
309
309
  */
310
310
  shippableAmount: number;
@@ -325,7 +325,7 @@ export interface DeliveryQuoteInput {
325
325
  * 배송비 계산 — **순수 함수다.** 장바구니 요약, 주문서 금액, 결제 시작의 최종 확정,
326
326
  * 주문 배송비 스냅샷이 모두 이 한 곳을 쓴다.
327
327
  *
328
- * 순서가 규칙이다. 묶음별 조건부 무료를 먼저 보고, 그다음 스토어 전체 무료 기준으로 기본 배송비를
328
+ * 순서가 규칙이다. 묶음별 조건부 무료를 먼저 보고, 그다음 상점 전체 무료 기준으로 기본 배송비를
329
329
  * 한꺼번에 0으로 만들고, 마지막에 지역 추가 배송비를 묶음마다 더한다. 추가 배송비는 무료 기준에
330
330
  * 걸리지 않는다.
331
331
  */
@@ -363,7 +363,7 @@ export function quoteDelivery(input: DeliveryQuoteInput): DeliveryQuote {
363
363
  product.deliveryType === "CONDITIONAL_FREE" && product.conditionalFreeAmount !== null;
364
364
  const freeThreshold = conditionalFree ? (product.conditionalFreeAmount ?? 0) : null;
365
365
  const listFee = product.deliveryType === "FREE" ? 0 : product.deliveryFee;
366
- // 면제 근거는 센 것부터 본다 — 무료배송 상품 > 스토어 전체 무료 기준 > 상품별 조건부 무료.
366
+ // 면제 근거는 센 것부터 본다 — 무료배송 상품 > 상점 전체 무료 기준 > 상품별 조건부 무료.
367
367
  // 환불 정산의 미달 차감은 CONDITIONAL만 대상이므로 근거를 뭉개면 안 된다.
368
368
  const waivedBy: DeliveryFeeWaiver | null =
369
369
  product.deliveryType === "FREE"
@@ -4,7 +4,7 @@ import { refundPolicyPatchSchema, refundPolicySchema } from "./refund-policy";
4
4
  import { shippingPolicyPatchSchema, shippingPolicySchema } from "./shipping-policy";
5
5
 
6
6
  /**
7
- * 스토어 코드 규칙 — 스토어프론트 API가 테넌트를 가르는 값이다(`X-Store-Code` 헤더).
7
+ * 상점 코드 규칙 — 스토어프론트 API가 테넌트를 가르는 값이다(`X-Store-Code` 헤더).
8
8
  * **개설할 때 서버가 랜덤으로 발급하고** 이후 바꿀 수 없다. 이미 나간 링크와 승인된 결제가
9
9
  * 이 값에 묶여 있어서 바꾸면 함께 깨진다.
10
10
  *
@@ -12,7 +12,7 @@ import { shippingPolicyPatchSchema, shippingPolicySchema } from "./shipping-poli
12
12
  */
13
13
  export const STORE_CODE_PATTERN = /^[a-z0-9](?:[a-z0-9-]{1,28}[a-z0-9])$/;
14
14
 
15
- /** 플랫폼이 쓰거나 쓸 호스트 이름 — 스토어 코드로 내주지 않는다 */
15
+ /** 플랫폼이 쓰거나 쓸 호스트 이름 — 상점 코드로 내주지 않는다 */
16
16
  export const RESERVED_STORE_CODES: readonly string[] = [
17
17
  "www",
18
18
  "api",
@@ -72,7 +72,7 @@ export const storeCodeSchema = z
72
72
  )
73
73
  .refine(
74
74
  (code) => !isReservedStoreCode(code),
75
- "플랫폼이 쓰는 이름이라 스토어 코드로 쓸 수 없습니다",
75
+ "플랫폼이 쓰는 이름이라 상점 코드로 쓸 수 없습니다",
76
76
  );
77
77
 
78
78
  /** `https://shop.example.com`처럼 경로·쿼리 없는 http(s) 오리진인가 */
@@ -108,7 +108,7 @@ export const MAX_ANALYTICS_ALLOWED_ORIGINS = 20;
108
108
  export const MAX_AUTO_TRANSITION_DAYS = 30;
109
109
 
110
110
  /**
111
- * 약관 버전 — 스토어가 정하는 문자열이다(`1`·`2`·`2026-09-26`·`v1.2` 등). 형식은 강제하지 않고 값이 다르면 다른 버전이다.
111
+ * 약관 버전 — 상점이 정하는 문자열이다(`1`·`2`·`2026-09-26`·`v1.2` 등). 형식은 강제하지 않고 값이 다르면 다른 버전이다.
112
112
  * 올리면 그 약관에 동의한 구매자의 `reconsentRequired`가 켜지고, 재동의가 이력에 남는다. 되돌리면 재동의 요구도 풀린다.
113
113
  */
114
114
  export const policyVersionSchema = z
@@ -128,6 +128,95 @@ export const policyDocumentUrlSchema = z
128
128
  .refine((value) => value.startsWith("https://"), "https 주소만 쓸 수 있습니다")
129
129
  .nullable();
130
130
 
131
+ /**
132
+ * 사업자등록번호 — 숫자 10자리. 하이픈(`123-45-67890`)과 공백은 받고 서버가 숫자만 남겨 저장한다.
133
+ * 국세청 검증번호(체크섬)는 보지 않는다.
134
+ */
135
+ export const BUSINESS_NUMBER_PATTERN = /^\d{3}[-\s]?\d{2}[-\s]?\d{5}$/;
136
+
137
+ /** 사업자등록번호에서 숫자만 남긴다 — 저장·비교는 이 값으로 한다 */
138
+ export function normalizeBusinessNumber(value: string): string {
139
+ return value.replace(/\D/g, "");
140
+ }
141
+
142
+ export const businessNumberSchema = z
143
+ .string()
144
+ .trim()
145
+ .regex(BUSINESS_NUMBER_PATTERN, "사업자등록번호는 숫자 10자리여야 합니다")
146
+ .nullable();
147
+
148
+ /** 사업자 정보 글자 필드 — 앞뒤 공백을 지우고 빈 값은 받지 않는다(지우려면 `null`) */
149
+ const businessText = (max: number) => z.string().trim().min(1).max(max).nullable();
150
+
151
+ /** 사업자 정보 필드의 길이 상한 — 상점 플랫폼 입력 칸과 같다 */
152
+ export const BUSINESS_INFO_MAX_LENGTH = {
153
+ businessName: 100,
154
+ representativeName: 50,
155
+ mailOrderSalesNumber: 50,
156
+ businessAddress: 200,
157
+ privacyOfficerName: 50,
158
+ } as const;
159
+
160
+ /**
161
+ * `PATCH /v1/store`의 사업자 정보 필드(이슈 #81) — 전자상거래법 표기(스토어프론트 푸터)용이다.
162
+ * 바꿀 필드만 보내고 `null`을 주면 지운다. 스토어프론트 `GET /store`가 같은 값을 싣는다.
163
+ */
164
+ export const businessInfoPatchShape = {
165
+ businessName: businessText(BUSINESS_INFO_MAX_LENGTH.businessName)
166
+ .optional()
167
+ .describe("상호(사업자등록증의 상호). 상점명(`name`)과 다를 수 있다. `null`을 주면 지운다"),
168
+ representativeName: businessText(BUSINESS_INFO_MAX_LENGTH.representativeName)
169
+ .optional()
170
+ .describe("대표자명. `null`을 주면 지운다"),
171
+ businessNumber: businessNumberSchema
172
+ .optional()
173
+ .describe(
174
+ "사업자등록번호. 숫자 10자리이고 하이픈(`123-45-67890`)을 넣어도 된다. 숫자만 저장한다. `null`을 주면 지운다",
175
+ ),
176
+ mailOrderSalesNumber: businessText(BUSINESS_INFO_MAX_LENGTH.mailOrderSalesNumber)
177
+ .optional()
178
+ .describe(
179
+ "통신판매업 신고번호(예: `2026-서울강남-01234`). 적은 그대로 저장한다. `null`을 주면 지운다",
180
+ ),
181
+ businessAddress: businessText(BUSINESS_INFO_MAX_LENGTH.businessAddress)
182
+ .optional()
183
+ .describe("사업장 주소. `null`을 주면 지운다"),
184
+ customerCenterEmail: z
185
+ .email()
186
+ .max(254)
187
+ .nullable()
188
+ .optional()
189
+ .describe("고객센터 이메일. `null`을 주면 지운다"),
190
+ privacyOfficerName: businessText(BUSINESS_INFO_MAX_LENGTH.privacyOfficerName)
191
+ .optional()
192
+ .describe("개인정보 보호책임자 성명. `null`을 주면 지운다"),
193
+ privacyOfficerEmail: z
194
+ .email()
195
+ .max(254)
196
+ .nullable()
197
+ .optional()
198
+ .describe("개인정보 보호책임자 이메일. `null`을 주면 지운다"),
199
+ };
200
+
201
+ /** 응답의 사업자 정보 필드 — 옛 api 응답에 없으면 null로 읽는다(상점 플랫폼이 api보다 먼저 배포된다) */
202
+ const nullableText = (description: string) =>
203
+ z
204
+ .string()
205
+ .nullable()
206
+ .optional()
207
+ .default(null)
208
+ .describe(`${description}. 없으면 null. 서버는 항상 싣는다`);
209
+
210
+ export const businessInfoResponseShape = {
211
+ businessName: nullableText("상호. 상점명과 다를 수 있다"),
212
+ representativeName: nullableText("대표자명"),
213
+ mailOrderSalesNumber: nullableText("통신판매업 신고번호"),
214
+ businessAddress: nullableText("사업장 주소"),
215
+ customerCenterEmail: nullableText("고객센터 이메일"),
216
+ privacyOfficerName: nullableText("개인정보 보호책임자 성명"),
217
+ privacyOfficerEmail: nullableText("개인정보 보호책임자 이메일"),
218
+ };
219
+
131
220
  const autoDeliverDaysDescription =
132
221
  "발송 후 자동 배송완료까지의 일수(0~30, 기본 7). 발송(`DISPATCHED`)·배송중(`DELIVERING`) 주문상품을 " +
133
222
  "이 기간이 지나면 배송완료(`DELIVERED`)로 바꾼다. `0`이면 자동 배송완료를 하지 않는다";
@@ -141,8 +230,13 @@ export const updateStoreRequestSchema = z
141
230
  .object({
142
231
  name: z.string().min(2).max(50).optional(),
143
232
  logoUrl: z.url().optional(),
144
- customerCenterPhone: z.string().optional(),
233
+ customerCenterPhone: z
234
+ .string()
235
+ .nullable()
236
+ .optional()
237
+ .describe("고객센터 전화번호. `null`을 주면 지운다"),
145
238
  businessHours: z.string().max(100).optional(),
239
+ ...businessInfoPatchShape,
146
240
  analyticsAllowedOrigins: z
147
241
  .array(analyticsOriginSchema)
148
242
  .max(MAX_ANALYTICS_ALLOWED_ORIGINS)
@@ -183,12 +277,12 @@ export const updateStoreRequestSchema = z
183
277
  refundPolicy: refundPolicyPatchSchema
184
278
  .optional()
185
279
  .describe(
186
- "스토어 기본 환불 정책 — 준 항목만 바꾼다. 상품별 정책(`products.refundPolicy`)이 없는 상품에 적용된다",
280
+ "상점 기본 환불 정책 — 준 항목만 바꾼다. 상품별 정책(`products.refundPolicy`)이 없는 상품에 적용된다",
187
281
  ),
188
282
  shippingPolicy: shippingPolicyPatchSchema
189
283
  .optional()
190
284
  .describe(
191
- "스토어 배송비 규칙 — 준 항목만 바꾼다. 기본 배송비, 전체 무료배송 기준액, 제주·도서산간 추가 배송비다. " +
285
+ "상점 배송비 규칙 — 준 항목만 바꾼다. 기본 배송비, 전체 무료배송 기준액, 제주·도서산간 추가 배송비다. " +
192
286
  "바꾼 값은 **새 주문서부터** 적용된다(이미 만든 주문서·주문의 배송비는 스냅샷이 원천이다)",
193
287
  ),
194
288
  termsVersion: policyVersionSchema
@@ -220,8 +314,9 @@ export const storeSchema = z.object({
220
314
  storefrontUrl: z.url(),
221
315
  status: z.enum(["ACTIVE", "SUSPENDED", "CLOSED"]),
222
316
  logoUrl: z.url().nullable(),
223
- businessNumber: z.string().nullable(),
317
+ businessNumber: z.string().nullable().describe("사업자등록번호(숫자 10자리). 없으면 null"),
224
318
  customerCenterPhone: z.string().nullable(),
319
+ ...businessInfoResponseShape,
225
320
  analyticsAllowedOrigins: z
226
321
  .array(z.string())
227
322
  .describe("방문 분석 수집을 받는 스토어프론트 오리진. 비어 있으면 모든 오리진에서 받는다"),
@@ -234,10 +329,10 @@ export const storeSchema = z.object({
234
329
  autoDeliverDays: z.number().int().describe(autoDeliverDaysDescription),
235
330
  autoConfirmDays: z.number().int().describe(autoConfirmDaysDescription),
236
331
  refundPolicy: refundPolicySchema.describe(
237
- "스토어 기본 환불 정책. 스토어가 정하지 않은 항목은 플랫폼 기본값으로 채워 항상 전부 싣는다",
332
+ "상점 기본 환불 정책. 상점이 정하지 않은 항목은 플랫폼 기본값으로 채워 항상 전부 싣는다",
238
333
  ),
239
334
  shippingPolicy: shippingPolicySchema.describe(
240
- "스토어 배송비 규칙. 스토어가 정하지 않은 항목은 플랫폼 기본값으로 채워 항상 전부 싣는다",
335
+ "상점 배송비 규칙. 상점이 정하지 않은 항목은 플랫폼 기본값으로 채워 항상 전부 싣는다",
241
336
  ),
242
337
  termsVersion: z.string().describe("현재 이용약관 버전. 가입·재동의가 이 값으로 이력에 남는다"),
243
338
  privacyVersion: z.string().describe("현재 개인정보 수집이용 동의 버전"),
@@ -270,7 +365,7 @@ export type StoreMember = z.infer<typeof storeMemberSchema>;
270
365
 
271
366
  /**
272
367
  * 초대 메일 발송 결과 — `SENDING`은 발송 요청 중(결과를 아직 적지 못함), `NOT_CONFIGURED`는 메일 발송이 설정되지 않아
273
- * 보내지 않은 것이다. 스토어의 월 메일 한도(요금제)를 다 써서 보내지 않았으면 `FAILED` + `deliveryErrorCode: MAIL_QUOTA_EXCEEDED`다. 발송 실패는 초대를 무효로 만들지 않는다 — 재발송으로 다시 보낸다
368
+ * 보내지 않은 것이다. 상점의 월 메일 한도(요금제)를 다 써서 보내지 않았으면 `FAILED` + `deliveryErrorCode: MAIL_QUOTA_EXCEEDED`다. 발송 실패는 초대를 무효로 만들지 않는다 — 재발송으로 다시 보낸다
274
369
  */
275
370
  export const invitationDeliveryStatusSchema = z.enum([
276
371
  "SENDING",