@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.
@@ -0,0 +1,163 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * 환불 정책 (이슈 #25) — 취소·반품·교환에서 **배송비를 누가 부담하는지**를 정한다.
5
+ *
6
+ * 두 층이다. 스토어 설정(`PATCH /v1/store`의 `refundPolicy`)이 기본값이고, 상품별 정책
7
+ * (`POST/PUT /v1/products`의 `refundPolicy`)이 그 상품에만 덮어쓴다. 상품이 정하지 않은 항목은
8
+ * 스토어 기본을 따르고, 스토어도 정하지 않았으면 `REFUND_POLICY_DEFAULTS`다.
9
+ *
10
+ * 한 주문에 정책이 섞이면 **상품별로 계산한다**. 배송비 묶음도 상품 단위다(주문서·장바구니의
11
+ * 배송비 합산과 같은 규칙: 같은 상품의 주문상품이 한 묶음이고 묶음마다 배송비가 붙는다).
12
+ */
13
+
14
+ const descriptions = {
15
+ preShipmentDeliveryFeeRefund:
16
+ "발송 전 취소로 배송 묶음(같은 상품의 주문상품)이 모두 취소되면 그 묶음의 배송비를 환불한다. " +
17
+ "false면 배송비를 환불하지 않는다. 기본 true",
18
+ returnDeliveryFee:
19
+ "단순 변심(구매자 귀책) 반품의 반품 배송비. 원 단위. 환불액에서 뺀다. 기본 0(무료). " +
20
+ "상품별 정책에서 이 값을 주면 상품의 `returnFee`도 같은 값으로 맞춘다",
21
+ exchangeDeliveryFee:
22
+ "단순 변심(구매자 귀책) 교환의 교환 배송비. 원 단위. 구매자에게 청구할 금액을 안내값으로 싣는다. 기본 0(무료). " +
23
+ "상품별 정책에서 이 값을 주면 상품의 `exchangeFee`도 같은 값으로 맞춘다",
24
+ sellerFaultFreeShipping:
25
+ "판매자 귀책(셀러 직권취소, 불량·오배송 반품)이면 배송비를 셀러가 부담한다 — 배송비를 환불하고 반품비를 받지 않는다. " +
26
+ "false면 구매자 귀책과 같은 규칙을 쓴다. 기본 true",
27
+ conditionalFreeShortfallCharge:
28
+ "조건부 무료배송(`CONDITIONAL_FREE`) 상품에서 일부 취소로 남은 금액이 무료 기준액에 미달하면 그 배송비를 환불액에서 뺀다. " +
29
+ "false면 추가 청구 없이 셀러가 부담한다. 기본 false",
30
+ } as const;
31
+
32
+ /** 유효 환불 정책 — 스토어 기본 + 상품별 덮어쓰기를 합친 결과 */
33
+ export const refundPolicySchema = z.object({
34
+ preShipmentDeliveryFeeRefund: z.boolean().describe(descriptions.preShipmentDeliveryFeeRefund),
35
+ returnDeliveryFee: z.number().int().min(0).describe(descriptions.returnDeliveryFee),
36
+ exchangeDeliveryFee: z.number().int().min(0).describe(descriptions.exchangeDeliveryFee),
37
+ sellerFaultFreeShipping: z.boolean().describe(descriptions.sellerFaultFreeShipping),
38
+ conditionalFreeShortfallCharge: z.boolean().describe(descriptions.conditionalFreeShortfallCharge),
39
+ });
40
+
41
+ export type RefundPolicy = z.infer<typeof refundPolicySchema>;
42
+
43
+ /**
44
+ * 스토어가 정하지 않았을 때 서버가 쓰는 값.
45
+ *
46
+ * `as const`가 아니라 `RefundPolicy`로 타입을 좁힌다 — 리터럴 타입이면 `Partial<typeof …>`로 일부만 바꿀 때
47
+ * `true` 자리에 `false`를 넣을 수 없다.
48
+ */
49
+ export const REFUND_POLICY_DEFAULTS: RefundPolicy = {
50
+ preShipmentDeliveryFeeRefund: true,
51
+ returnDeliveryFee: 0,
52
+ exchangeDeliveryFee: 0,
53
+ sellerFaultFreeShipping: true,
54
+ conditionalFreeShortfallCharge: false,
55
+ };
56
+
57
+ /**
58
+ * 부분 정책 — 스토어 설정 요청과 상품별 덮어쓰기가 쓴다. 준 항목만 바뀐다.
59
+ *
60
+ * `strictObject`라 모르는 키는 400이다. 오타(`returnFee` 등)를 조용히 무시하면 셀러는 저장됐다고
61
+ * 믿는데 정책은 기본값으로 남는다.
62
+ */
63
+ export const refundPolicyPatchSchema = z.strictObject({
64
+ preShipmentDeliveryFeeRefund: z
65
+ .boolean()
66
+ .optional()
67
+ .describe(descriptions.preShipmentDeliveryFeeRefund),
68
+ returnDeliveryFee: z.number().int().min(0).optional().describe(descriptions.returnDeliveryFee),
69
+ exchangeDeliveryFee: z
70
+ .number()
71
+ .int()
72
+ .min(0)
73
+ .optional()
74
+ .describe(descriptions.exchangeDeliveryFee),
75
+ sellerFaultFreeShipping: z.boolean().optional().describe(descriptions.sellerFaultFreeShipping),
76
+ conditionalFreeShortfallCharge: z
77
+ .boolean()
78
+ .optional()
79
+ .describe(descriptions.conditionalFreeShortfallCharge),
80
+ });
81
+
82
+ export type RefundPolicyPatch = z.infer<typeof refundPolicyPatchSchema>;
83
+
84
+ /** 정책 해석에 쓰는 상품 값 — 상품별 덮어쓰기와 레거시 배송비 필드 */
85
+ export interface RefundPolicyProductInput {
86
+ /** 상품별 덮어쓰기. 없으면 스토어 기본을 그대로 쓴다 */
87
+ refundPolicy?: RefundPolicyPatch | null;
88
+ /** @deprecated 레거시 상품 반품비 — `refundPolicy`가 없는 옛 상품의 값이다 */
89
+ returnFee?: number | null;
90
+ /** @deprecated 레거시 상품 교환비 — `refundPolicy`가 없는 옛 상품의 값이다 */
91
+ exchangeFee?: number | null;
92
+ }
93
+
94
+ /**
95
+ * 유효 정책 해석 — 상품별 덮어쓰기 → 스토어 기본 → `REFUND_POLICY_DEFAULTS` 순이다.
96
+ *
97
+ * 반품비·교환비는 레거시 필드(`products.returnFee`·`exchangeFee`)와 **연결**한다. 상품이
98
+ * `refundPolicy.returnDeliveryFee`를 정하면 그 값이 원천이고(서버가 `returnFee`도 같은 값으로 맞춘다),
99
+ * 정하지 않았으면 레거시 값이 0보다 클 때 그 값을 쓴다. 0이면 스토어 기본을 따른다 —
100
+ * 정책이 생기기 전에 만든 상품(반품비 0)이 스토어 기본을 물려받게 하는 규칙이다.
101
+ */
102
+ export function resolveRefundPolicy(
103
+ storePolicy?: RefundPolicyPatch | null,
104
+ product?: RefundPolicyProductInput | null,
105
+ ): RefundPolicy {
106
+ const store = { ...REFUND_POLICY_DEFAULTS, ...dropUndefined(storePolicy) };
107
+ return { ...store, ...(productRefundPolicyOverride(product) ?? {}) };
108
+ }
109
+
110
+ /**
111
+ * 상품의 **덮어쓰기 자체** — 레거시 반품비·교환비를 같은 형태로 승격한 값이다. 덮어쓰기가 없으면 null.
112
+ *
113
+ * `resolveRefundPolicy`가 합친 결과로는 "상품이 정한 값"과 "스토어 기본을 물려받은 값"을 가를 수 없다.
114
+ * 관리 API 응답의 `refundPolicy`와 콘솔 폼의 덮어쓰기 스위치가 이 함수를 원천으로 쓴다 — 가르지 못하면
115
+ * 스토어 기본을 쓰는 상품을 저장할 때 그 순간의 기본값이 상품에 박히거나, 레거시 값이 조용히 사라진다.
116
+ */
117
+ export function productRefundPolicyOverride(
118
+ product?: RefundPolicyProductInput | null,
119
+ ): RefundPolicyPatch | null {
120
+ const override = dropUndefined(product?.refundPolicy);
121
+ const legacy: RefundPolicyPatch = {};
122
+ if (override.returnDeliveryFee === undefined && (product?.returnFee ?? 0) > 0) {
123
+ legacy.returnDeliveryFee = product?.returnFee ?? 0;
124
+ }
125
+ if (override.exchangeDeliveryFee === undefined && (product?.exchangeFee ?? 0) > 0) {
126
+ legacy.exchangeDeliveryFee = product?.exchangeFee ?? 0;
127
+ }
128
+ const merged = { ...legacy, ...override };
129
+ if (product?.refundPolicy == null && Object.keys(merged).length === 0) return null;
130
+ return merged;
131
+ }
132
+
133
+ /** `undefined` 값을 빼고 얕게 복사한다 — 전개 순서로 덮어쓸 때 `undefined`가 기본값을 지우지 않게 */
134
+ function dropUndefined(patch?: RefundPolicyPatch | null): RefundPolicyPatch {
135
+ if (!patch) return {};
136
+ return Object.fromEntries(
137
+ Object.entries(patch).filter(([, value]) => value !== undefined),
138
+ ) as RefundPolicyPatch;
139
+ }
140
+
141
+ /**
142
+ * 수량 단위 부분 취소의 환불 기준액 — **누적 내림의 차분**이다.
143
+ *
144
+ * 원 단위 반올림 규칙: `floor(주문상품 금액 × 누적 수량 ÷ 전체 수량)`을 두 번 구해 그 차를 쓴다.
145
+ * 부분 취소를 몇 번 나눠도 합이 주문상품 금액과 정확히 같아서 1원이 남거나 넘치지 않는다.
146
+ * 할인은 이미 단가에 들어 있어(주문 단위 쿠폰이 없다) 별도 안분이 없다.
147
+ *
148
+ * @param totalPrice 주문상품 금액 (할인 반영 단가 × 전체 수량)
149
+ * @param quantity 주문상품 전체 수량
150
+ * @param settledQuantity 이미 취소·반품된 누적 수량
151
+ * @param cancelQuantity 이번에 취소·반품할 수량
152
+ */
153
+ export function proratedRefundBase(
154
+ totalPrice: number,
155
+ quantity: number,
156
+ settledQuantity: number,
157
+ cancelQuantity: number,
158
+ ): number {
159
+ if (quantity <= 0 || cancelQuantity <= 0) return 0;
160
+ const before = Math.floor((totalPrice * settledQuantity) / quantity);
161
+ const after = Math.floor((totalPrice * (settledQuantity + cancelQuantity)) / quantity);
162
+ return Math.max(0, after - before);
163
+ }
@@ -0,0 +1,375 @@
1
+ import { z } from "zod";
2
+ import type { DeliveryType } from "./product";
3
+
4
+ /**
5
+ * 배송비 규칙 (이슈 #29) — 배송비를 **몇 번, 얼마나** 부과하는지를 정한다.
6
+ *
7
+ * 세 축이다.
8
+ * 1. **배송 묶음**: 같은 출고지 + 같은 배송 정책인 주문상품은 한 묶음이고 배송비가 한 번만 붙는다
9
+ * (`deliveryBundleKeyOf`). 이슈 #25의 「묶음 = 같은 상품」 규칙을 일반화한 것이다 — 키를 상품 id에서
10
+ * 정책 지문으로 바꿨고, 주문상품이 주문 시점 키를 스냅샷해(`order_items.delivery_bundle_key`)
11
+ * 환불 정산(`refund-settlement.ts`)과 주문 스냅샷(`orders.delivery_fee_settlements`)이 같은 키를 쓴다.
12
+ * 2. **무료 기준**: 상품별 조건부 무료(`CONDITIONAL_FREE`, 묶음 소계 기준)와 스토어 전체 무료 기준
13
+ * (`freeThreshold`, 주문 상품 금액 합 기준)이 있다. 둘 중 하나라도 충족하면 그 배송비는 0이다.
14
+ * 3. **지역 추가 배송비**: 우편번호가 제주·도서산간이면 **묶음마다** 추가된다. 묶음이 곧 한 번의 발송이라
15
+ * 두 출고지에서 각각 보내면 추가 운임도 두 번 든다.
16
+ *
17
+ * 지역 추가 배송비는 **무료배송과 무관하게 부과한다.** 무료 기준은 셀러가 기본 운임을 부담하겠다는
18
+ * 뜻이지, 도서 지역 추가 운임까지 부담하겠다는 뜻은 아니다(국내 택배사 공통 관행).
19
+ *
20
+ * 계산은 `quoteDelivery` 하나다. 장바구니·주문서·결제 시작·환불 정산이 모두 이 함수를 쓴다 —
21
+ * 네 곳이 각자 계산하면 화면 금액과 결제 금액이 갈라진다.
22
+ */
23
+
24
+ const descriptions = {
25
+ baseDeliveryFee:
26
+ "스토어 기본 배송비. 원 단위. 상품 등록·수정에서 `deliveryFee`를 생략하면 이 값을 쓴다. 기본 3000",
27
+ freeThreshold:
28
+ "스토어 전체 무료배송 기준액. 주문 상품 금액 합이 이 값 이상이면 모든 묶음의 배송비가 0이다. " +
29
+ "null이면 전체 무료배송을 쓰지 않는다(상품별 조건부 무료는 그대로 동작한다). 기본 null",
30
+ jejuSurcharge: "제주 지역 추가 배송비. 원 단위. **배송 묶음마다** 더한다. 기본 0",
31
+ isolatedSurcharge: "도서산간 지역 추가 배송비. 원 단위. **배송 묶음마다** 더한다. 기본 0",
32
+ } as const;
33
+
34
+ /** 유효 배송비 정책 — 스토어가 정하지 않은 항목은 `SHIPPING_POLICY_DEFAULTS`로 채운 값이다 */
35
+ export const shippingPolicySchema = z.object({
36
+ baseDeliveryFee: z.number().int().min(0).describe(descriptions.baseDeliveryFee),
37
+ freeThreshold: z.number().int().min(1).nullable().describe(descriptions.freeThreshold),
38
+ jejuSurcharge: z.number().int().min(0).describe(descriptions.jejuSurcharge),
39
+ isolatedSurcharge: z.number().int().min(0).describe(descriptions.isolatedSurcharge),
40
+ });
41
+
42
+ export type ShippingPolicy = z.infer<typeof shippingPolicySchema>;
43
+
44
+ /**
45
+ * 스토어가 정하지 않았을 때 서버가 쓰는 값.
46
+ *
47
+ * `as const`가 아니라 `ShippingPolicy`로 좁힌다 — 리터럴 타입이면 `Partial<typeof …>`로 일부만 바꿀 때
48
+ * `null` 자리에 숫자를 넣을 수 없다(`REFUND_POLICY_DEFAULTS`와 같은 이유).
49
+ */
50
+ export const SHIPPING_POLICY_DEFAULTS: ShippingPolicy = {
51
+ baseDeliveryFee: 3_000,
52
+ freeThreshold: null,
53
+ jejuSurcharge: 0,
54
+ isolatedSurcharge: 0,
55
+ };
56
+
57
+ /**
58
+ * 부분 정책 — `PATCH /v1/store`의 `shippingPolicy`가 쓴다. 준 항목만 바뀐다.
59
+ *
60
+ * `strictObject`라 모르는 키는 400이다. 오타를 조용히 무시하면 셀러는 저장됐다고 믿는데
61
+ * 정책은 기본값으로 남는다(`refundPolicyPatchSchema`와 같은 이유).
62
+ */
63
+ export const shippingPolicyPatchSchema = z.strictObject({
64
+ baseDeliveryFee: z.number().int().min(0).optional().describe(descriptions.baseDeliveryFee),
65
+ freeThreshold: z.number().int().min(1).nullable().optional().describe(descriptions.freeThreshold),
66
+ jejuSurcharge: z.number().int().min(0).optional().describe(descriptions.jejuSurcharge),
67
+ isolatedSurcharge: z.number().int().min(0).optional().describe(descriptions.isolatedSurcharge),
68
+ });
69
+
70
+ export type ShippingPolicyPatch = z.infer<typeof shippingPolicyPatchSchema>;
71
+
72
+ /** 유효 정책 해석 — 스토어가 정한 항목 + `SHIPPING_POLICY_DEFAULTS` */
73
+ export function resolveShippingPolicy(patch?: ShippingPolicyPatch | null): ShippingPolicy {
74
+ if (!patch) return { ...SHIPPING_POLICY_DEFAULTS };
75
+ const given = Object.fromEntries(
76
+ Object.entries(patch).filter(([, value]) => value !== undefined),
77
+ ) as ShippingPolicyPatch;
78
+ return { ...SHIPPING_POLICY_DEFAULTS, ...given };
79
+ }
80
+
81
+ // ─── 지역 추가 배송비 ─────────────────────────────────────
82
+
83
+ /** 지역 구분 — 우편번호로 판정한다 */
84
+ export const remoteAreaSchema = z.enum(["NONE", "JEJU", "ISOLATED"]);
85
+
86
+ export type RemoteArea = z.infer<typeof remoteAreaSchema>;
87
+
88
+ /**
89
+ * 제주·도서산간 우편번호 범위 — **코드 상수다.** 금액은 스토어가 정하고 범위는 플랫폼이 고정한다.
90
+ *
91
+ * 국내 택배사가 공표하는 제주·도서산간 할증 구간을 5자리 우편번호 범위로 옮긴 표다. 셀러마다
92
+ * 범위가 다르면 같은 주소에 다른 금액이 나가고 CS가 셀러 수만큼 갈라진다. 스토어별 범위 편집은
93
+ * 후속 과제이고, 지금은 이 표가 유일한 원천이다.
94
+ *
95
+ * ## 근거
96
+ *
97
+ * 원천은 **CJ대한통운이 공표한 「제주 및 도서산간 지역 추가 운송비」 표**(구간 시작·끝 우편번호와
98
+ * 지역명이 붙은 그 표)다. 같은 표가 한진택배 공지와 쇼핑몰 솔루션 안내에도 같은 값으로 실린다.
99
+ * 서로 다른 게시자의 전재본 네 곳이 인천·전남·경남 구간까지 글자 그대로 일치하는 것을 확인했다.
100
+ *
101
+ * - CJ대한통운 표 전재 (구간별 금액 포함): https://ichibanhouse.com/article/공지뉴스/1/16/
102
+ * - 같은 표의 다른 전재본: https://peppytail.com/article/공지사항/1/4046/
103
+ * - 시행 시점(2020-02-01)과 구간별 행정구역명을 함께 적은 전재본:
104
+ * https://tosstoss.co.kr/bbs/content.php?co_id=pilotage
105
+ * - 솔루션 안내 「제주도 및 도서산간 지역 기준표」:
106
+ * https://help.campaignus.me/ko/articles/제주도-및-도서산간-지역-기준표-342d2e5e
107
+ *
108
+ * 라벨의 행정구역명은 우편번호 배정을 따로 확인해 고쳤다. 택배사 표의 지역명은 축약·오기가 있다.
109
+ * 확인한 것만 남기고, 확인하지 못한 구간은 이 표에서 뺀다 — 판정하지 못하면 받지 않는 쪽이
110
+ * `remoteAreaOf`의 규칙과 같다.
111
+ *
112
+ * ## 표에서 뺀 구간
113
+ *
114
+ * - **54000**: 택배사 표는 「경남 통영 섬지역3」으로 적었지만 이 번호는 **전북 군산시 옥도면**이다.
115
+ * 게다가 공정거래위원회가 2025-10-07 발표에서 연륙교로 이어진 신시도·야미도·선유도와 비연륙
116
+ * 도서(방축도·관리도)가 같은 우편번호를 쓰는 대표 사례로 이 지역을 들었다. 라벨도 부과 근거도
117
+ * 확정하지 못해 뺀다.
118
+ * - **강화군 본섬(23000~23003, 23011~23099)**: 택배사 표의 강화 할증 구간은 23004~23010
119
+ * (교동면·삼산면·서도면)뿐이다. 강화대교·초지대교로 이어진 본섬은 도선료가 없어 어느 택배사도
120
+ * 할증하지 않는다. 「누락」이 아니라 대상이 아니다.
121
+ *
122
+ * ## 한계
123
+ *
124
+ * 우편번호 단위 판정은 한 번호 안에 연륙도서와 비연륙도서가 섞인 구간에서 과대 부과가 된다.
125
+ * 공정위가 2025-10-07에 온라인몰 13곳의 이 방식을 시정하게 한 지적이 그것이다
126
+ * (https://eiec.kdi.re.kr/policy/materialView.do?num=272070). 아래 구간 중 33411(보령 오천면,
127
+ * 원산도가 해저터널로 연결), 46768~46771(강서구 가덕도, 거가대교로 연결)이 그런 혼재 구간이다.
128
+ * 도로명주소·건물관리번호 단위 판정은 후속 과제다.
129
+ *
130
+ * 범위는 **닫힌 구간**(`from` ≤ zip ≤ `to`)이고 겹치지 않는다. 앞에서부터 처음 맞는 항목을 쓴다.
131
+ * 순서는 콘솔 「추가 배송비 지역」 표의 행 순서이기도 하다(`remoteAreaGroups`). 제주를 먼저 두고
132
+ * 도서산간은 우편번호 오름차순으로 둔다.
133
+ */
134
+ export const REMOTE_AREA_ZIP_RANGES: ReadonlyArray<{
135
+ readonly area: Exclude<RemoteArea, "NONE">;
136
+ readonly from: number;
137
+ readonly to: number;
138
+ readonly label: string;
139
+ }> = [
140
+ // 제주 전지역. 추자면·우도는 택배사 표에서 더 높은 운임이지만 공개 계약의 지역 구분은
141
+ // JEJU 하나이고 금액은 스토어가 정한다.
142
+ { area: "JEJU", from: 63_000, to: 63_644, label: "제주특별자치도" },
143
+ // 무의도·실미도·팔미도. 택배사 표는 「인천 중구 섬지역」이지만 2026-07-01 행정체제 개편으로
144
+ // 중구가 나뉘면서 무의동은 영종구 소속이다.
145
+ { area: "ISOLATED", from: 22_386, to: 22_388, label: "인천 영종구 무의동" },
146
+ // 교동면·삼산면·서도면. 강화 본섬은 대상이 아니다(위 「표에서 뺀 구간」).
147
+ { area: "ISOLATED", from: 23_004, to: 23_010, label: "인천 강화군 도서" },
148
+ // 옹진군. 두 구간으로 끊긴 사이(23117~23123)는 영흥대교·선재대교로 이어진 영흥면이라
149
+ // 택배사 표에서도 빠져 있다.
150
+ { area: "ISOLATED", from: 23_100, to: 23_116, label: "인천 옹진군 도서" },
151
+ { area: "ISOLATED", from: 23_124, to: 23_136, label: "인천 옹진군 도서" },
152
+ { area: "ISOLATED", from: 31_708, to: 31_708, label: "충남 당진시 도서" },
153
+ { area: "ISOLATED", from: 32_133, to: 32_133, label: "충남 태안군 도서" },
154
+ { area: "ISOLATED", from: 33_411, to: 33_411, label: "충남 보령시 도서" },
155
+ { area: "ISOLATED", from: 40_200, to: 40_240, label: "경북 울릉군" },
156
+ { area: "ISOLATED", from: 46_768, to: 46_771, label: "부산 강서구 도서" },
157
+ { area: "ISOLATED", from: 52_570, to: 52_571, label: "경남 사천시 도서" },
158
+ { area: "ISOLATED", from: 53_031, to: 53_033, label: "경남 통영시 도서" },
159
+ { area: "ISOLATED", from: 53_089, to: 53_104, label: "경남 통영시 도서" },
160
+ { area: "ISOLATED", from: 56_347, to: 56_349, label: "전북 부안군 위도면" },
161
+ { area: "ISOLATED", from: 57_068, to: 57_069, label: "전남 영광군 낙월면" },
162
+ { area: "ISOLATED", from: 58_760, to: 58_762, label: "전남 목포시 도서" },
163
+ { area: "ISOLATED", from: 58_800, to: 58_810, label: "전남 신안군" },
164
+ { area: "ISOLATED", from: 58_816, to: 58_818, label: "전남 신안군" },
165
+ { area: "ISOLATED", from: 58_826, to: 58_826, label: "전남 신안군" },
166
+ { area: "ISOLATED", from: 58_828, to: 58_866, label: "전남 신안군" },
167
+ // 진도군 조도면. 진도대교로 이어진 진도 본섬(58900번대 앞쪽)은 대상이 아니다.
168
+ { area: "ISOLATED", from: 58_953, to: 58_958, label: "전남 진도군 조도면" },
169
+ { area: "ISOLATED", from: 59_102, to: 59_103, label: "전남 완도군 도서" },
170
+ { area: "ISOLATED", from: 59_106, to: 59_106, label: "전남 완도군 도서" },
171
+ { area: "ISOLATED", from: 59_127, to: 59_127, label: "전남 완도군 도서" },
172
+ { area: "ISOLATED", from: 59_129, to: 59_129, label: "전남 완도군 도서" },
173
+ { area: "ISOLATED", from: 59_137, to: 59_166, label: "전남 완도군 도서" },
174
+ { area: "ISOLATED", from: 59_421, to: 59_421, label: "전남 보성군 장도" },
175
+ { area: "ISOLATED", from: 59_531, to: 59_531, label: "전남 고흥군 도서" },
176
+ { area: "ISOLATED", from: 59_551, to: 59_551, label: "전남 고흥군 도서" },
177
+ { area: "ISOLATED", from: 59_563, to: 59_563, label: "전남 고흥군 도서" },
178
+ { area: "ISOLATED", from: 59_568, to: 59_568, label: "전남 고흥군 도서" },
179
+ { area: "ISOLATED", from: 59_650, to: 59_650, label: "전남 여수시 도서" },
180
+ { area: "ISOLATED", from: 59_766, to: 59_766, label: "전남 여수시 도서" },
181
+ { area: "ISOLATED", from: 59_781, to: 59_790, label: "전남 여수시 도서" },
182
+ ];
183
+
184
+ /**
185
+ * 우편번호 → 지역 구분. 5자리 숫자가 아니면 `NONE`이다.
186
+ *
187
+ * 형식 검증은 배송지 스키마(`/^[0-9]{5}$/`)가 하고, 여기서는 **판정하지 못하면 추가 배송비를 받지 않는다**.
188
+ * 반대로 두면 우편번호를 잘못 넣은 주문에 근거 없는 금액이 붙는다.
189
+ */
190
+ export function remoteAreaOf(zipCode: string | null | undefined): RemoteArea {
191
+ if (!zipCode || !/^[0-9]{5}$/.test(zipCode)) return "NONE";
192
+ const zip = Number.parseInt(zipCode, 10);
193
+ for (const range of REMOTE_AREA_ZIP_RANGES) {
194
+ if (zip >= range.from && zip <= range.to) return range.area;
195
+ }
196
+ return "NONE";
197
+ }
198
+
199
+ /** 지역 구분의 안내 이름 — 판정된 범위의 `label`이다. 판정되지 않으면 null */
200
+ export function remoteAreaLabelOf(zipCode: string | null | undefined): string | null {
201
+ if (!zipCode || !/^[0-9]{5}$/.test(zipCode)) return null;
202
+ const zip = Number.parseInt(zipCode, 10);
203
+ return REMOTE_AREA_ZIP_RANGES.find((r) => zip >= r.from && zip <= r.to)?.label ?? null;
204
+ }
205
+
206
+ /** 묶음 하나에 붙는 지역 추가 배송비 */
207
+ export function remoteSurchargeOf(policy: ShippingPolicy, area: RemoteArea): number {
208
+ if (area === "JEJU") return policy.jejuSurcharge;
209
+ if (area === "ISOLATED") return policy.isolatedSurcharge;
210
+ return 0;
211
+ }
212
+
213
+ // ─── 배송 묶음 ────────────────────────────────────────────
214
+
215
+ /** 묶음 판정에 쓰는 상품 값 — 출고지와 배송 정책이다 */
216
+ export interface DeliveryBundleProduct {
217
+ deliveryType: DeliveryType;
218
+ deliveryFee: number;
219
+ conditionalFreeAmount: number | null;
220
+ /** 출고지 주소 id(`seller_addresses`). null이면 스토어 기본 출고지를 쓰는 상품끼리 한 묶음이다 */
221
+ shippingAddressId?: string | null;
222
+ }
223
+
224
+ /**
225
+ * 배송 묶음 키 — **같은 출고지 + 같은 배송 정책이면 같은 키다.**
226
+ *
227
+ * 키는 주문 시점에 주문상품 행에 스냅샷된다(`order_items.delivery_bundle_key`). 셀러가 주문 뒤
228
+ * 출고지나 배송비를 바꿔도 그 주문의 묶음은 움직이지 않는다 — 움직이면 이미 환불한 묶음의 키가
229
+ * 달라져 `orders.delivery_fee_settlements`의 이중 환불 방지 표시가 무력해진다.
230
+ *
231
+ * `-`는 「값 없음」이다. 사람이 읽을 수 있게 남기고, 값 자체에 `:`가 들어갈 수 없는 형식만 쓴다
232
+ * (주소 id는 `adr_…`, 나머지는 열거형·정수).
233
+ */
234
+ export function deliveryBundleKeyOf(product: DeliveryBundleProduct): string {
235
+ const origin = product.shippingAddressId ?? "-";
236
+ const threshold =
237
+ product.deliveryType === "CONDITIONAL_FREE" ? (product.conditionalFreeAmount ?? 0) : "-";
238
+ const fee = product.deliveryType === "FREE" ? 0 : product.deliveryFee;
239
+ return `b:${origin}:${product.deliveryType}:${fee}:${threshold}`;
240
+ }
241
+
242
+ // ─── 배송비 계산 ──────────────────────────────────────────
243
+
244
+ /** 계산에 넣는 한 줄 — 주문상품·장바구니 항목·주문서 항목이 이 형태로 들어온다 */
245
+ export interface DeliveryFeeLine {
246
+ productId: string;
247
+ /** 상품 금액 (할인 반영 단가 × 수량) */
248
+ amount: number;
249
+ }
250
+
251
+ /**
252
+ * 기본 배송비를 면제한 근거 — 면제하지 않았으면 null이다.
253
+ *
254
+ * 환불 정산이 근거를 구분해야 한다. 조건부 무료 기준 미달 차감은 **상품별 조건부 무료(`CONDITIONAL`)로
255
+ * 면제된 묶음만** 대상이다. 스토어 전체 무료 기준(`STORE_THRESHOLD`)이나 무료배송 상품(`FREE`)은 셀러가
256
+ * 배송비를 받지 않기로 한 것이므로, 일부 취소로 기준이 깨져도 구매자에게 배송비를 물리지 않는다.
257
+ *
258
+ * 세 값이 겹치면 `FREE` → `STORE_THRESHOLD` → `CONDITIONAL` 순으로 센 근거를 남긴다.
259
+ */
260
+ export const deliveryFeeWaiverSchema = z.enum(["FREE", "STORE_THRESHOLD", "CONDITIONAL"]);
261
+
262
+ export type DeliveryFeeWaiver = z.infer<typeof deliveryFeeWaiverSchema>;
263
+
264
+ /** 묶음 하나의 견적 */
265
+ export interface DeliveryBundleQuote {
266
+ bundleKey: string;
267
+ /** 이 묶음에 든 상품 id — 스냅샷 검증과 콘솔 안내에 쓴다 */
268
+ productIds: string[];
269
+ /** 실제 청구한 기본 배송비 — 무료배송·무료 기준 충족이면 0 */
270
+ fee: number;
271
+ /** 기본 배송비를 면제한 근거. 청구했으면 null */
272
+ waivedBy: DeliveryFeeWaiver | null;
273
+ /** 배송비 정가 — 조건부 무료 미달 차감액의 상한이다 */
274
+ listFee: number;
275
+ /** 묶음 조건부 무료 기준액. 조건부 무료가 아니면 null */
276
+ freeThreshold: number | null;
277
+ /** 이 묶음에 붙은 지역 추가 배송비 */
278
+ remoteSurcharge: number;
279
+ }
280
+
281
+ /** 배송비 견적 — 합계와 묶음 내역 */
282
+ export interface DeliveryQuote {
283
+ bundles: DeliveryBundleQuote[];
284
+ /** 기본 배송비 합계 (지역 추가 제외) */
285
+ baseFee: number;
286
+ /** 지역 추가 배송비 합계 */
287
+ remoteSurcharge: number;
288
+ /** 청구할 배송비 총액 = `baseFee` + `remoteSurcharge` */
289
+ deliveryFee: number;
290
+ remoteArea: RemoteArea;
291
+ /** 스토어 전체 무료 기준(`freeThreshold`) 충족으로 기본 배송비를 면제했는가 */
292
+ freeByThreshold: boolean;
293
+ }
294
+
295
+ export interface DeliveryQuoteInput {
296
+ lines: DeliveryFeeLine[];
297
+ /** 상품 배송 정책 조회 — 못 읽은 상품은 계산에서 빠진다(배송비를 만들어 붙이지 않는다) */
298
+ productOf: (productId: string) => DeliveryBundleProduct | undefined;
299
+ policy: ShippingPolicy;
300
+ /** 배송지 우편번호. 아직 모르면 생략한다 — 지역 추가 배송비 0으로 계산한다 */
301
+ zipCode?: string | null;
302
+ }
303
+
304
+ /**
305
+ * 배송비 계산 — **순수 함수다.** 장바구니 요약, 주문서 금액, 결제 시작의 최종 확정,
306
+ * 주문 배송비 스냅샷이 모두 이 한 곳을 쓴다.
307
+ *
308
+ * 순서가 규칙이다. 묶음별 조건부 무료를 먼저 보고, 그다음 스토어 전체 무료 기준으로 기본 배송비를
309
+ * 한꺼번에 0으로 만들고, 마지막에 지역 추가 배송비를 묶음마다 더한다. 추가 배송비는 무료 기준에
310
+ * 걸리지 않는다.
311
+ */
312
+ export function quoteDelivery(input: DeliveryQuoteInput): DeliveryQuote {
313
+ const { policy } = input;
314
+ const area = remoteAreaOf(input.zipCode);
315
+ const surcharge = remoteSurchargeOf(policy, area);
316
+
317
+ // 묶음별 소계와 구성 상품
318
+ const groups = new Map<
319
+ string,
320
+ { product: DeliveryBundleProduct; ids: Set<string>; sum: number }
321
+ >();
322
+ let productAmount = 0;
323
+ for (const line of input.lines) {
324
+ const product = input.productOf(line.productId);
325
+ if (!product) continue;
326
+ productAmount += line.amount;
327
+ const key = deliveryBundleKeyOf(product);
328
+ const group = groups.get(key) ?? { product, ids: new Set<string>(), sum: 0 };
329
+ group.ids.add(line.productId);
330
+ group.sum += line.amount;
331
+ groups.set(key, group);
332
+ }
333
+
334
+ const freeByThreshold =
335
+ policy.freeThreshold !== null && productAmount >= policy.freeThreshold && groups.size > 0;
336
+
337
+ const bundles: DeliveryBundleQuote[] = [];
338
+ for (const [bundleKey, group] of groups) {
339
+ const { product } = group;
340
+ const conditionalFree =
341
+ product.deliveryType === "CONDITIONAL_FREE" && product.conditionalFreeAmount !== null;
342
+ const freeThreshold = conditionalFree ? (product.conditionalFreeAmount ?? 0) : null;
343
+ const listFee = product.deliveryType === "FREE" ? 0 : product.deliveryFee;
344
+ // 면제 근거는 센 것부터 본다 — 무료배송 상품 > 스토어 전체 무료 기준 > 상품별 조건부 무료.
345
+ // 환불 정산의 미달 차감은 CONDITIONAL만 대상이므로 근거를 뭉개면 안 된다.
346
+ const waivedBy: DeliveryFeeWaiver | null =
347
+ product.deliveryType === "FREE"
348
+ ? "FREE"
349
+ : freeByThreshold
350
+ ? "STORE_THRESHOLD"
351
+ : freeThreshold !== null && group.sum >= freeThreshold
352
+ ? "CONDITIONAL"
353
+ : null;
354
+ bundles.push({
355
+ bundleKey,
356
+ productIds: [...group.ids],
357
+ fee: waivedBy === null ? listFee : 0,
358
+ waivedBy,
359
+ listFee,
360
+ freeThreshold,
361
+ remoteSurcharge: surcharge,
362
+ });
363
+ }
364
+
365
+ const baseFee = bundles.reduce((sum, b) => sum + b.fee, 0);
366
+ const remoteSurcharge = bundles.reduce((sum, b) => sum + b.remoteSurcharge, 0);
367
+ return {
368
+ bundles,
369
+ baseFee,
370
+ remoteSurcharge,
371
+ deliveryFee: baseFee + remoteSurcharge,
372
+ remoteArea: area,
373
+ freeByThreshold,
374
+ };
375
+ }
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { storeRoleSchema } from "./auth";
3
+ import { refundPolicyPatchSchema, refundPolicySchema } from "./refund-policy";
4
+ import { shippingPolicyPatchSchema, shippingPolicySchema } from "./shipping-policy";
3
5
 
4
6
  /**
5
7
  * 스토어 코드 규칙 — 스토어프론트 API가 테넌트를 가르는 값이다(`X-Store-Code` 헤더).
@@ -105,6 +107,27 @@ export const MAX_ANALYTICS_ALLOWED_ORIGINS = 20;
105
107
  /** 자동 배송완료·자동 구매확정 대기일 상한 */
106
108
  export const MAX_AUTO_TRANSITION_DAYS = 30;
107
109
 
110
+ /**
111
+ * 약관 버전 — 스토어가 정하는 문자열이다(`1`·`2`·`2026-09-26`·`v1.2` 등). 형식은 강제하지 않고 값이 다르면 다른 버전이다.
112
+ * 올리면 그 약관에 동의한 구매자의 `reconsentRequired`가 켜지고, 재동의가 이력에 남는다. 되돌리면 재동의 요구도 풀린다.
113
+ */
114
+ export const policyVersionSchema = z
115
+ .string()
116
+ .trim()
117
+ .min(1)
118
+ .max(20)
119
+ .describe("약관 버전 문자열(1~20자). 예: `1`, `2026-09-26`");
120
+
121
+ /**
122
+ * 약관 문서 주소 — 구매자에게 보여 주는 약관 본문의 주소다. 동의 시점 값이 이력 행에 복사되므로
123
+ * 나중에 주소를 바꿔도 "무엇에 동의했는지"가 남는다. `null`로 지울 수 있다.
124
+ */
125
+ export const policyDocumentUrlSchema = z
126
+ .url()
127
+ .max(500)
128
+ .refine((value) => value.startsWith("https://"), "https 주소만 쓸 수 있어요")
129
+ .nullable();
130
+
108
131
  const autoDeliverDaysDescription =
109
132
  "발송 후 자동 배송완료까지의 일수(0~30, 기본 7). 발송(`DISPATCHED`)·배송중(`DELIVERING`) 주문상품을 " +
110
133
  "이 기간이 지나면 배송완료(`DELIVERED`)로 바꾼다. `0`이면 자동 배송완료를 하지 않는다";
@@ -157,6 +180,33 @@ export const updateStoreRequestSchema = z
157
180
  .max(MAX_AUTO_TRANSITION_DAYS)
158
181
  .optional()
159
182
  .describe(autoConfirmDaysDescription),
183
+ refundPolicy: refundPolicyPatchSchema
184
+ .optional()
185
+ .describe(
186
+ "스토어 기본 환불 정책 — 준 항목만 바꾼다. 상품별 정책(`products.refundPolicy`)이 없는 상품에 적용된다",
187
+ ),
188
+ shippingPolicy: shippingPolicyPatchSchema
189
+ .optional()
190
+ .describe(
191
+ "스토어 배송비 규칙 — 준 항목만 바꾼다. 기본 배송비, 전체 무료배송 기준액, 제주·도서산간 추가 배송비다. " +
192
+ "바꾼 값은 **새 주문서부터** 적용된다(이미 만든 주문서·주문의 배송비는 스냅샷이 원천이다)",
193
+ ),
194
+ termsVersion: policyVersionSchema
195
+ .optional()
196
+ .describe(
197
+ "이용약관 버전. 올리면 이 약관에 동의한 구매자에게 재동의를 요구한다(`GET /storefront/v1/me`의 `reconsentRequired`)",
198
+ ),
199
+ privacyVersion: policyVersionSchema
200
+ .optional()
201
+ .describe("개인정보 수집이용 동의 버전. 올리면 구매자에게 재동의를 요구한다"),
202
+ termsUrl: policyDocumentUrlSchema
203
+ .optional()
204
+ .describe(
205
+ "이용약관 문서 주소(https). 동의 시점의 값이 동의 이력에 복사된다. `null`을 주면 지운다",
206
+ ),
207
+ privacyUrl: policyDocumentUrlSchema
208
+ .optional()
209
+ .describe("개인정보 수집이용 동의 문서 주소(https). `null`을 주면 지운다"),
160
210
  })
161
211
  .refine((value) => Object.values(value).some((v) => v !== undefined), {
162
212
  message: "최소 1개 필드가 필요합니다",
@@ -183,6 +233,24 @@ export const storeSchema = z.object({
183
233
  guestCheckoutEnabled: z.boolean().describe("비회원 주문 허용. false면 회원만 주문할 수 있다"),
184
234
  autoDeliverDays: z.number().int().describe(autoDeliverDaysDescription),
185
235
  autoConfirmDays: z.number().int().describe(autoConfirmDaysDescription),
236
+ refundPolicy: refundPolicySchema.describe(
237
+ "스토어 기본 환불 정책. 스토어가 정하지 않은 항목은 플랫폼 기본값으로 채워 항상 전부 싣는다",
238
+ ),
239
+ shippingPolicy: shippingPolicySchema.describe(
240
+ "스토어 배송비 규칙. 스토어가 정하지 않은 항목은 플랫폼 기본값으로 채워 항상 전부 싣는다",
241
+ ),
242
+ termsVersion: z.string().describe("현재 이용약관 버전. 가입·재동의가 이 값으로 이력에 남는다"),
243
+ privacyVersion: z.string().describe("현재 개인정보 수집이용 동의 버전"),
244
+ termsVersionUpdatedAt: z
245
+ .string()
246
+ .nullable()
247
+ .describe("이용약관 버전을 마지막으로 바꾼 시각. 바꾼 적이 없으면 null"),
248
+ privacyVersionUpdatedAt: z.string().nullable(),
249
+ termsUrl: z
250
+ .string()
251
+ .nullable()
252
+ .describe("이용약관 문서 주소. 정하지 않았으면 null이고 동의 이력에도 주소가 남지 않는다"),
253
+ privacyUrl: z.string().nullable().describe("개인정보 수집이용 동의 문서 주소"),
186
254
  memberCount: z.number().int(),
187
255
  createdAt: z.string(),
188
256
  });