@sayren/store-sdk 0.32.0 → 0.33.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/dist/index.d.mts +2569 -843
- package/dist/index.mjs +528 -62
- package/package.json +1 -1
- package/src/client.ts +32 -0
- package/src/index.ts +3 -0
- package/src/schemas/analytics.ts +16 -0
- package/src/schemas/coupon.ts +13 -0
- package/src/schemas/currency-prices.ts +138 -0
- package/src/schemas/currency.ts +184 -0
- package/src/schemas/customer.ts +20 -1
- package/src/schemas/fulfillment.ts +7 -0
- package/src/schemas/market.ts +76 -0
- package/src/schemas/order.ts +16 -1
- package/src/schemas/payment.ts +107 -14
- package/src/schemas/product.ts +32 -3
- package/src/schemas/promotion.ts +36 -17
- package/src/schemas/refund-policy.ts +55 -2
- package/src/schemas/shipping-policy.ts +56 -7
- package/src/schemas/webhook-event.ts +24 -0
package/src/schemas/payment.ts
CHANGED
|
@@ -10,12 +10,21 @@ import { type PageParams, pageSchema } from "./common";
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
export const pgProviderSchema = z
|
|
13
|
-
.enum(["tosspayments", "portone", "kakaopay", "naverpay"])
|
|
13
|
+
.enum(["tosspayments", "portone", "kakaopay", "naverpay", "paypal"])
|
|
14
14
|
.describe(
|
|
15
|
-
"PG 식별자. `tosspayments`=토스페이먼츠, `portone`=포트원(V2), `kakaopay`=카카오페이(직접 연동), `naverpay`=네이버페이 결제형(직접 연동)",
|
|
15
|
+
"PG 식별자. `tosspayments`=토스페이먼츠, `portone`=포트원(V2), `kakaopay`=카카오페이(직접 연동), `naverpay`=네이버페이 결제형(직접 연동), `paypal`=PayPal(직접 연동, 외화)",
|
|
16
16
|
);
|
|
17
17
|
export type PgProvider = z.infer<typeof pgProviderSchema>;
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* 응답에 실리는 PG — 서버가 PG를 추가해도 응답 파싱이 깨지지 않게 문자열로 받는다(`easyPayProviderResponseSchema`와 같은 방식, 이슈 #131 CD2).
|
|
21
|
+
* 요청(우선순위·리포트 필터·경로)은 값을 골라 보내는 자리라 `pgProviderSchema` enum 그대로다. 현재 값은 `pgProviderSchema`의 값이고
|
|
22
|
+
* 화면은 모르는 값을 원문 그대로 보여 준다
|
|
23
|
+
*/
|
|
24
|
+
export const pgProviderResponseSchema = z
|
|
25
|
+
.string()
|
|
26
|
+
.describe("PG 식별자(`pgProviderSchema`의 값). 모르는 값이 와도 응답 파싱이 깨지지 않는다");
|
|
27
|
+
|
|
19
28
|
/**
|
|
20
29
|
* 간편결제 전용 PG — 결제수단이 간편결제 하나이고 간편결제사가 PG 자신이다(`kakaopay` → `KAKAOPAY`).
|
|
21
30
|
* 이 PG는 `easyPayProviders` 설정 없이 결제 옵션이 하나(`EASY_PAY` + 그 간편결제사) 나온다
|
|
@@ -23,6 +32,7 @@ export type PgProvider = z.infer<typeof pgProviderSchema>;
|
|
|
23
32
|
export const DEDICATED_EASY_PAY_PROVIDER = {
|
|
24
33
|
kakaopay: "KAKAOPAY",
|
|
25
34
|
naverpay: "NAVERPAY",
|
|
35
|
+
paypal: "PAYPAL",
|
|
26
36
|
} as const satisfies Partial<Record<PgProvider, string>>;
|
|
27
37
|
|
|
28
38
|
export const pgPaymentMethodSchema = z
|
|
@@ -34,7 +44,7 @@ export type PgPaymentMethod = z.infer<typeof pgPaymentMethodSchema>;
|
|
|
34
44
|
* 간편결제사 — 결제 옵션(스토어프론트 `paymentOptions`)과 PG 설정의 `easyPayProviders`에 쓴다.
|
|
35
45
|
* `@sayren/storefront-sdk`의 `easyPayProviderSchema`와 같은 값이다(api 계약 테스트가 고정한다)
|
|
36
46
|
*/
|
|
37
|
-
export const easyPayProviderSchema = z.enum(["NAVERPAY", "KAKAOPAY", "TOSSPAY", "PAYCO"]);
|
|
47
|
+
export const easyPayProviderSchema = z.enum(["NAVERPAY", "KAKAOPAY", "TOSSPAY", "PAYCO", "PAYPAL"]);
|
|
38
48
|
export type EasyPayProvider = z.infer<typeof easyPayProviderSchema>;
|
|
39
49
|
|
|
40
50
|
/**
|
|
@@ -188,6 +198,55 @@ export const naverPayCredentialsInputSchema = z.object({
|
|
|
188
198
|
});
|
|
189
199
|
export type NaverPayCredentialsInput = z.infer<typeof naverPayCredentialsInputSchema>;
|
|
190
200
|
|
|
201
|
+
/**
|
|
202
|
+
* PayPal이 받는 통화 중 sayren이 여는 것(이슈 #131 설계 §3b) — PayPal 통화 코드(https://developer.paypal.com/api/rest/reference/currency-codes/) ∩ 통화표
|
|
203
|
+
* − 원화(PayPal이 받지 않는다) − CNY·MYR(현지 계정 전용) − TWD(부분 환불 소수 문제, 설계 Q4)
|
|
204
|
+
*/
|
|
205
|
+
export const PAYPAL_CURRENCIES = [
|
|
206
|
+
"USD",
|
|
207
|
+
"EUR",
|
|
208
|
+
"JPY",
|
|
209
|
+
"GBP",
|
|
210
|
+
"HKD",
|
|
211
|
+
"SGD",
|
|
212
|
+
"AUD",
|
|
213
|
+
"CAD",
|
|
214
|
+
"THB",
|
|
215
|
+
"PHP",
|
|
216
|
+
"NZD",
|
|
217
|
+
"CHF",
|
|
218
|
+
] as const;
|
|
219
|
+
|
|
220
|
+
export const payPalCredentialsInputSchema = z.object({
|
|
221
|
+
clientId: z
|
|
222
|
+
.string()
|
|
223
|
+
.trim()
|
|
224
|
+
.min(10)
|
|
225
|
+
.max(200)
|
|
226
|
+
.describe("PayPal REST 앱의 Client ID(테스트 세트는 샌드박스 앱). 공개 값"),
|
|
227
|
+
currencies: z
|
|
228
|
+
.array(z.enum(PAYPAL_CURRENCIES))
|
|
229
|
+
.min(1)
|
|
230
|
+
.max(PAYPAL_CURRENCIES.length)
|
|
231
|
+
.describe(
|
|
232
|
+
"받는 통화 — PayPal 계정에 열어 둔 통화를 고른다. 결제 세션 통화가 여기 있는 주문서에만 PayPal 옵션이 나온다(원화는 받지 않는다)",
|
|
233
|
+
),
|
|
234
|
+
webhookId: z
|
|
235
|
+
.string()
|
|
236
|
+
.trim()
|
|
237
|
+
.min(1)
|
|
238
|
+
.max(100)
|
|
239
|
+
.nullable()
|
|
240
|
+
.optional()
|
|
241
|
+
.describe(
|
|
242
|
+
"PayPal 앱에 등록한 웹훅의 ID(선택). 주면 서버 통지의 서명을 검증해 결제 확인을 앞당긴다. 생략·null이면 통지를 받지 않고 주기 확인만 한다",
|
|
243
|
+
),
|
|
244
|
+
clientSecret: secretInput()
|
|
245
|
+
.optional()
|
|
246
|
+
.describe("Secret. 비밀 값. 최초 연결 시 필수, 수정 시 생략하면 기존 값을 유지한다"),
|
|
247
|
+
});
|
|
248
|
+
export type PayPalCredentialsInput = z.infer<typeof payPalCredentialsInputSchema>;
|
|
249
|
+
|
|
191
250
|
const upsertEnvironment = () =>
|
|
192
251
|
pgEnvironmentSchema.describe(
|
|
193
252
|
"저장할 자격 증명 세트. PG마다 `TEST`(샌드박스)·`LIVE`(실결제) 세트를 따로 둔다. " +
|
|
@@ -220,6 +279,12 @@ export const upsertNaverPayProviderRequestSchema = z.object({
|
|
|
220
279
|
});
|
|
221
280
|
export type UpsertNaverPayProviderRequest = z.infer<typeof upsertNaverPayProviderRequestSchema>;
|
|
222
281
|
|
|
282
|
+
export const upsertPayPalProviderRequestSchema = z.object({
|
|
283
|
+
environment: upsertEnvironment(),
|
|
284
|
+
credentials: payPalCredentialsInputSchema,
|
|
285
|
+
});
|
|
286
|
+
export type UpsertPayPalProviderRequest = z.infer<typeof upsertPayPalProviderRequestSchema>;
|
|
287
|
+
|
|
223
288
|
export const updatePaymentProviderRequestSchema = z
|
|
224
289
|
.object({
|
|
225
290
|
enabled: z
|
|
@@ -304,6 +369,11 @@ const publicConfigSchema = z.union([
|
|
|
304
369
|
z.object({ storeId: z.string(), channels: z.array(portOneChannelSchema) }),
|
|
305
370
|
z.object({ cid: z.string() }),
|
|
306
371
|
z.object({ clientId: z.string(), chainId: z.string() }),
|
|
372
|
+
z.object({
|
|
373
|
+
clientId: z.string(),
|
|
374
|
+
currencies: z.array(z.string()),
|
|
375
|
+
webhookId: z.string().nullable(),
|
|
376
|
+
}),
|
|
307
377
|
]);
|
|
308
378
|
|
|
309
379
|
const verificationSchema = z.object({
|
|
@@ -315,7 +385,7 @@ const verificationSchema = z.object({
|
|
|
315
385
|
export const paymentCredentialSetSchema = z.object({
|
|
316
386
|
environment: pgEnvironmentSchema,
|
|
317
387
|
publicConfig: publicConfigSchema.describe(
|
|
318
|
-
"공개 값. 토스페이먼츠는 `{clientKey}`, 포트원은 `{storeId, channels}`, 카카오페이는 `{cid}`, 네이버페이는 `{clientId, chainId}`",
|
|
388
|
+
"공개 값. 토스페이먼츠는 `{clientKey}`, 포트원은 `{storeId, channels}`, 카카오페이는 `{cid}`, 네이버페이는 `{clientId, chainId}`, PayPal은 `{clientId, currencies, webhookId}`",
|
|
319
389
|
),
|
|
320
390
|
secrets: z
|
|
321
391
|
.array(maskedSecretSchema)
|
|
@@ -371,7 +441,7 @@ export const liveGateErrorDetailsSchema = z.object({
|
|
|
371
441
|
export type LiveGateErrorDetails = z.infer<typeof liveGateErrorDetailsSchema>;
|
|
372
442
|
|
|
373
443
|
export const paymentProviderSchema = z.object({
|
|
374
|
-
provider:
|
|
444
|
+
provider: pgProviderResponseSchema,
|
|
375
445
|
displayName: z.string().describe("표시 이름 (토스페이먼츠·포트원)"),
|
|
376
446
|
connected: z.boolean().describe("자격 증명이 저장돼 있는가"),
|
|
377
447
|
enabled: z.boolean().describe("결제에 사용 중인가"),
|
|
@@ -499,7 +569,7 @@ export const paymentSettingsSchema = z.object({
|
|
|
499
569
|
providers: z
|
|
500
570
|
.array(paymentProviderSchema)
|
|
501
571
|
.describe(
|
|
502
|
-
"연결된 PG(우선순위순)와 연결되지 않은 토스페이먼츠·포트원.
|
|
572
|
+
"연결된 PG(우선순위순)와 연결되지 않은 토스페이먼츠·포트원. 그 밖의 PG(카카오페이·네이버페이 등)는 연결했을 때만 여기에 온다" +
|
|
503
573
|
"(이 PG를 모르는 이전 SDK가 응답을 읽을 수 있게). 연결할 수 있는 PG 전체는 `connectableProviders`다",
|
|
504
574
|
),
|
|
505
575
|
connectableProviders: z
|
|
@@ -539,7 +609,7 @@ export const paymentSettingsSchema = z.object({
|
|
|
539
609
|
export type PaymentSettings = z.infer<typeof paymentSettingsSchema>;
|
|
540
610
|
|
|
541
611
|
export const paymentProviderVerificationSchema = z.object({
|
|
542
|
-
provider:
|
|
612
|
+
provider: pgProviderResponseSchema,
|
|
543
613
|
environment: pgEnvironmentSchema.describe("연결 테스트한 자격 증명 세트"),
|
|
544
614
|
result: pgVerificationResultSchema,
|
|
545
615
|
message: z.string().nullable().describe("실패 사유 또는 참고 메시지"),
|
|
@@ -592,7 +662,7 @@ export type SalesTotals = z.infer<typeof salesTotalsSchema>;
|
|
|
592
662
|
|
|
593
663
|
export const salesReportRowSchema = salesTotalsSchema.extend({
|
|
594
664
|
date: z.string().nullable().describe("일자 (YYYY-MM-DD, Asia/Seoul). groupBy=DAY일 때만 값"),
|
|
595
|
-
provider:
|
|
665
|
+
provider: pgProviderResponseSchema.nullable().describe("PG. groupBy에 PROVIDER가 포함될 때만 값"),
|
|
596
666
|
method: pgPaymentMethodSchema
|
|
597
667
|
.nullable()
|
|
598
668
|
.describe(
|
|
@@ -602,17 +672,36 @@ export const salesReportRowSchema = salesTotalsSchema.extend({
|
|
|
602
672
|
});
|
|
603
673
|
export type SalesReportRow = z.infer<typeof salesReportRowSchema>;
|
|
604
674
|
|
|
675
|
+
/** 통화별 매출(이슈 #130) — 환산하지 않는다. 금액은 그 통화의 최소 단위다 */
|
|
676
|
+
export const salesReportCurrencySchema = z.object({
|
|
677
|
+
currency: z.string().describe("결제 통화(ISO 4217)"),
|
|
678
|
+
summary: salesTotalsSchema.describe("이 통화의 기간 전체 합계"),
|
|
679
|
+
rows: z.array(salesReportRowSchema).describe("이 통화의 집계 행(`groupBy` 기준)"),
|
|
680
|
+
});
|
|
681
|
+
export type SalesReportCurrency = z.infer<typeof salesReportCurrencySchema>;
|
|
682
|
+
|
|
605
683
|
export const salesReportSchema = z.object({
|
|
606
684
|
from: z.string(),
|
|
607
685
|
to: z.string(),
|
|
608
686
|
timezone: z.literal("Asia/Seoul"),
|
|
609
|
-
currency: z
|
|
687
|
+
currency: z
|
|
688
|
+
.literal("KRW")
|
|
689
|
+
.describe(
|
|
690
|
+
"`summary`·`rows`의 통화. 기본 통화(원화) 결제만 집계한다. 외화 결제는 `byCurrency`에 있다",
|
|
691
|
+
),
|
|
610
692
|
groupBy: salesReportGroupBySchema,
|
|
611
|
-
provider:
|
|
693
|
+
provider: pgProviderResponseSchema.nullable().describe("적용된 PG 필터"),
|
|
612
694
|
method: pgPaymentMethodSchema.nullable().describe("적용된 결제수단 필터"),
|
|
613
695
|
includeTest: z.boolean().describe("테스트 결제(샌드박스 결제)를 집계에 포함했는가"),
|
|
614
696
|
summary: salesTotalsSchema.describe("기간 전체 합계"),
|
|
615
697
|
rows: z.array(salesReportRowSchema).describe("집계 행. 활동이 없는 날짜·PG는 생략된다"),
|
|
698
|
+
byCurrency: z
|
|
699
|
+
.array(salesReportCurrencySchema)
|
|
700
|
+
.optional()
|
|
701
|
+
.default([])
|
|
702
|
+
.describe(
|
|
703
|
+
"통화별 매출(이슈 #130) — 활동이 있는 통화마다 한 항목이고 기본 통화(KRW)도 포함한다. 통화 사이는 환산하지 않는다. 옛 서버 응답은 빈 목록",
|
|
704
|
+
),
|
|
616
705
|
});
|
|
617
706
|
export type SalesReport = z.infer<typeof salesReportSchema>;
|
|
618
707
|
|
|
@@ -677,7 +766,9 @@ export const cashReceiptSchema = z.object({
|
|
|
677
766
|
.int()
|
|
678
767
|
.describe("발급해야 할 금액 = 결제 금액 − 환불 완료 금액. 승인 전에는 결제 금액"),
|
|
679
768
|
issuedAmount: z.number().int().describe("PG에 현재 발급돼 있는 금액. 취소됐거나 발급 전이면 0"),
|
|
680
|
-
provider:
|
|
769
|
+
provider: pgProviderResponseSchema
|
|
770
|
+
.nullable()
|
|
771
|
+
.describe("발급 PG — 결제를 승인한 PG. 승인 전에는 null"),
|
|
681
772
|
issueNumber: z.string().nullable().describe("현금영수증 승인 번호"),
|
|
682
773
|
receiptUrl: z.string().nullable().describe("현금영수증 확인 주소"),
|
|
683
774
|
issuedAt: z.string().nullable().describe("최근 발급 시각 (ISO 8601)"),
|
|
@@ -708,7 +799,9 @@ export const paymentRecordSchema = z.object({
|
|
|
708
799
|
.describe(
|
|
709
800
|
"환불 대기 금액 합계(#115) — 주문만 취소하고 아직 환불하지 않은 몫. 결제 상세에서 환불 처리한다",
|
|
710
801
|
),
|
|
711
|
-
provider:
|
|
802
|
+
provider: pgProviderResponseSchema
|
|
803
|
+
.nullable()
|
|
804
|
+
.describe("승인한 PG. 승인 전에 끝났으면 마지막 시도의 PG"),
|
|
712
805
|
method: pgPaymentMethodSchema.nullable().describe("실제 결제수단. 모르면 요청 수단"),
|
|
713
806
|
easyPayProvider: easyPayProviderResponseSchema
|
|
714
807
|
.nullable()
|
|
@@ -764,7 +857,7 @@ export const paymentAttemptRecordSchema = z.object({
|
|
|
764
857
|
"PG에 보낸 주문번호(이슈 #107) — 토스페이먼츠 orderId·포트원 paymentId. 새 시도는 `{주문번호}-{시도 순번 2자리}`이고, 번호 체계 이전 시도는 시도 id(`attemptId`)와 같습니다. PG 관리자 화면에서 결제를 찾을 때 씁니다",
|
|
765
858
|
),
|
|
766
859
|
sequence: z.number().int().describe("결제 건 안에서의 순번 (1부터)"),
|
|
767
|
-
provider:
|
|
860
|
+
provider: pgProviderResponseSchema,
|
|
768
861
|
status: paymentAttemptStatusSchema,
|
|
769
862
|
method: pgPaymentMethodSchema.describe("주문서에서 고른 결제수단"),
|
|
770
863
|
easyPayProvider: easyPayProviderResponseSchema
|
|
@@ -876,7 +969,7 @@ export type SetPlatformTestKeysRequest = z.infer<typeof setPlatformTestKeysReque
|
|
|
876
969
|
|
|
877
970
|
/** 결제 상태 PG 한 줄 — 비밀 값·공개 키 없이 연결·사용·환경·테스트모드 키만 */
|
|
878
971
|
export const paymentStatusProviderSchema = z.object({
|
|
879
|
-
provider:
|
|
972
|
+
provider: pgProviderResponseSchema,
|
|
880
973
|
displayName: z.string(),
|
|
881
974
|
connected: z.boolean().describe("자격 증명이 저장돼 있는가"),
|
|
882
975
|
enabled: z.boolean().describe("결제에 사용 중인가"),
|
package/src/schemas/product.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { type PageParams, pageQueryShape } from "./common";
|
|
3
|
+
import { BASE_CURRENCY, CURRENCIES } from "./currency";
|
|
4
|
+
import {
|
|
5
|
+
additionalCurrencyPricesSchema,
|
|
6
|
+
productCurrencyPricesSchema,
|
|
7
|
+
storedAdditionalCurrencyPriceSchema,
|
|
8
|
+
storedProductCurrencyPriceSchema,
|
|
9
|
+
} from "./currency-prices";
|
|
3
10
|
import {
|
|
4
11
|
fulfillmentTypeResponseSchema,
|
|
5
12
|
productFulfillmentCreateSchema,
|
|
@@ -95,6 +102,10 @@ export const productVariantSchema = z.object({
|
|
|
95
102
|
/** 값 이름을 " / "로 이은 파생 표시명. 옵션 없으면 "" */
|
|
96
103
|
name: z.string(),
|
|
97
104
|
additionalPrice: z.number().int(),
|
|
105
|
+
prices: z
|
|
106
|
+
.array(storedAdditionalCurrencyPriceSchema)
|
|
107
|
+
.optional()
|
|
108
|
+
.describe("외화 추가금(이슈 #130, 관리 API만). 기본 통화 값은 `additionalPrice`다"),
|
|
98
109
|
stockQuantity: z.number().int(),
|
|
99
110
|
sku: z.string().nullable(),
|
|
100
111
|
barcode: z.string().nullable(),
|
|
@@ -154,6 +165,10 @@ export const addonValueSchema = z.object({
|
|
|
154
165
|
name: z.string(),
|
|
155
166
|
sortOrder: z.number().int(),
|
|
156
167
|
additionalPrice: z.number().int().describe("이 값을 고르면 단가에 더하는 금액(원, 0 이상)"),
|
|
168
|
+
prices: z
|
|
169
|
+
.array(storedAdditionalCurrencyPriceSchema)
|
|
170
|
+
.optional()
|
|
171
|
+
.describe("외화 추가금(이슈 #130, 관리 API만). 기본 통화 값은 `additionalPrice`다"),
|
|
157
172
|
usable: z.boolean().describe("사용 여부. false면 구매자에게 보이지 않고 고를 수 없다"),
|
|
158
173
|
});
|
|
159
174
|
|
|
@@ -272,6 +287,7 @@ export const addonValueRequestSchema = z.object({
|
|
|
272
287
|
valueId: z.string().optional(),
|
|
273
288
|
name: optionNameSchema,
|
|
274
289
|
additionalPrice: z.number().int().min(0).default(0).describe("추가금(원, 0 이상)"),
|
|
290
|
+
prices: additionalCurrencyPricesSchema.optional(),
|
|
275
291
|
usable: z.boolean().default(true),
|
|
276
292
|
});
|
|
277
293
|
|
|
@@ -350,6 +366,10 @@ export const discountSchema = z.object({
|
|
|
350
366
|
export type Discount = z.infer<typeof discountSchema>;
|
|
351
367
|
|
|
352
368
|
export const productSchema = productSummarySchema.extend({
|
|
369
|
+
prices: z
|
|
370
|
+
.array(storedProductCurrencyPriceSchema)
|
|
371
|
+
.optional()
|
|
372
|
+
.describe("외화 판매가(이슈 #130). 기본 통화 값은 `salePrice`·`originalPrice`다"),
|
|
353
373
|
description: z.string().optional(),
|
|
354
374
|
/**
|
|
355
375
|
* 상세설명 에디터(@avarlabs/editor)의 직렬화 상태. 재편집 때 이 값으로 문서를 복원한다.
|
|
@@ -516,6 +536,7 @@ export const productVariantRequestSchema = z.object({
|
|
|
516
536
|
/** optionGroups 순서대로 각 축에서 고른 값 이름. 서버가 이름 → valueId를 해석한다 */
|
|
517
537
|
valueNames: z.array(z.string()).default([]),
|
|
518
538
|
additionalPrice: z.number().int().default(0),
|
|
539
|
+
prices: additionalCurrencyPricesSchema.optional(),
|
|
519
540
|
/**
|
|
520
541
|
* 생략 = 기존 재고 유지 (신규 variant는 0).
|
|
521
542
|
* 구조 교체(PUT)가 폼 로드 시점의 재고를 되쓰지 않게 하는 옵트인 필드다 —
|
|
@@ -627,14 +648,20 @@ const productImagesRequestSchema = z.array(productImageUrlSchema).min(1).max(MAX
|
|
|
627
648
|
* 최상위가 `strictObject`다. 모르는 필드는 400이다 — 이슈 #57 이전의 평면 필드(`deliveryFee`·`fulfillmentType`·`returnFee` 등)를
|
|
628
649
|
* 보낸 요청이 조용히 무시되지 않게 한다. 옛 필드는 `fulfillment.{유형}.…`과 `refundPolicy`로 옮겼다.
|
|
629
650
|
*/
|
|
651
|
+
/**
|
|
652
|
+
* 기본 통화 판매가 단위 — 통화표가 원천이다(이슈 #130). zod `multipleOf`로 두어 OpenAPI에 그대로 드러난다.
|
|
653
|
+
* 외화 판매가(`prices[]`)는 통화마다 `isSaleUnitPrice`로 검사한다
|
|
654
|
+
*/
|
|
655
|
+
const BASE_SALE_UNIT = CURRENCIES[BASE_CURRENCY].saleUnit;
|
|
656
|
+
|
|
630
657
|
const productWriteShape = {
|
|
631
658
|
name: z.string().min(1).max(100),
|
|
632
659
|
categoryId: z.string().min(1, "카테고리를 선택해주십시오"),
|
|
633
660
|
salePrice: z
|
|
634
661
|
.number()
|
|
635
662
|
.int()
|
|
636
|
-
.min(
|
|
637
|
-
.multipleOf(
|
|
663
|
+
.min(BASE_SALE_UNIT)
|
|
664
|
+
.multipleOf(BASE_SALE_UNIT, "판매가는 10원 단위여야 합니다 (INVALID_PRICE_UNIT)")
|
|
638
665
|
.describe("판매가. 원(KRW) 단위 정수, 최소 10원, 10원 단위"),
|
|
639
666
|
originalPrice: z
|
|
640
667
|
.number()
|
|
@@ -650,6 +677,7 @@ const productWriteShape = {
|
|
|
650
677
|
.describe(
|
|
651
678
|
"구매 적립률 덮어쓰기(만분율, 100 = 1%, 0~3000, 이슈 #93). null이면 상점 기본 적립률, 0이면 이 상품은 적립하지 않는다. 결제 확정 시점 값이 주문상품에 남는다. 전체 수정(PUT)에서 생략하면 지금 값을 유지한다",
|
|
652
679
|
),
|
|
680
|
+
prices: productCurrencyPricesSchema.optional(),
|
|
653
681
|
description: z.string().optional(),
|
|
654
682
|
/**
|
|
655
683
|
* 상세설명 에디터(@avarlabs/editor)의 직렬화 상태. 재편집 때 이 값으로 문서를 복원한다.
|
|
@@ -717,7 +745,7 @@ export type ProductReplaceRequestInput = z.input<typeof productReplaceRequestSch
|
|
|
717
745
|
export const productPatchRequestSchema = z
|
|
718
746
|
.strictObject({
|
|
719
747
|
name: z.string().max(100).optional(),
|
|
720
|
-
salePrice: z.number().int().multipleOf(
|
|
748
|
+
salePrice: z.number().int().multipleOf(BASE_SALE_UNIT).optional(),
|
|
721
749
|
status: productStatusSchema.optional(),
|
|
722
750
|
flowId: z
|
|
723
751
|
.string()
|
|
@@ -732,6 +760,7 @@ export const productPatchRequestSchema = z
|
|
|
732
760
|
"상품 이미지 주소 목록. 주면 통째로 바꾼다(현재 목록과 병합하지 않는다). 첫 장이 대표 이미지(썸네일)다. 생략하면 바꾸지 않는다",
|
|
733
761
|
),
|
|
734
762
|
fulfillment: productFulfillmentUpdateSchema.optional().describe(fulfillmentUpdateDescription),
|
|
763
|
+
prices: productCurrencyPricesSchema.optional(),
|
|
735
764
|
refundPolicy: productRefundPolicyPatchSchema
|
|
736
765
|
.nullable()
|
|
737
766
|
.optional()
|
package/src/schemas/promotion.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { CURRENCIES, KRW_MONEY_RULES, type MoneyRules } from "./currency";
|
|
2
3
|
import { proratedRefundBase } from "./refund-policy";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -172,7 +173,7 @@ export const couponRejectReasonSchema = z
|
|
|
172
173
|
export type CouponRejectReason = z.infer<typeof couponRejectReasonSchema>;
|
|
173
174
|
|
|
174
175
|
/** 쿠폰 적용 뒤 결제 금액의 하한 — 0원 결제 경로가 없어 이보다 작으면 결제를 시작하지 않는다(400 `PAYMENT_AMOUNT_TOO_LOW`) */
|
|
175
|
-
export const MIN_PAYMENT_AMOUNT =
|
|
176
|
+
export const MIN_PAYMENT_AMOUNT = CURRENCIES.KRW.minPayment;
|
|
176
177
|
|
|
177
178
|
// ─── 계산 ─────────────────────────────────────────────
|
|
178
179
|
|
|
@@ -259,6 +260,11 @@ export interface PricingInput {
|
|
|
259
260
|
* 것처럼 보이고, 탈퇴한 회원의 반품이 회원 전용 조건에 떨어진다. 대상과 최소 금액만 본다.
|
|
260
261
|
*/
|
|
261
262
|
ignoreEligibility?: boolean;
|
|
263
|
+
/**
|
|
264
|
+
* 주문 통화의 규칙 값(이슈 #130) — 정률 할인 내림 단위(판매가 단위)와 적립금 사용 뒤 남길 최소 결제액. 생략하면 KRW 규칙이다.
|
|
265
|
+
* 금액 입력은 모두 이 통화의 최소 단위다
|
|
266
|
+
*/
|
|
267
|
+
moneyRules?: MoneyRules;
|
|
262
268
|
}
|
|
263
269
|
|
|
264
270
|
/** 줄 하나에 들어간 쿠폰별 할인 */
|
|
@@ -325,15 +331,22 @@ export interface PricingResult {
|
|
|
325
331
|
}
|
|
326
332
|
|
|
327
333
|
/**
|
|
328
|
-
* 할인액 — 정률은 10원
|
|
334
|
+
* 할인액 — 정률은 판매가 단위(`rules.saleUnit`, KRW 10원) 내림, 최대 할인액과 기준 금액을 넘지 않는다. 기준 금액이 0 이하면 0이다.
|
|
329
335
|
*
|
|
330
|
-
* 곱셈을 먼저 하고 나눈다. `base × 율 ÷
|
|
331
|
-
* 부동소수 오차가 끼지
|
|
336
|
+
* 곱셈을 먼저 하고 나눈다. `base × 율 ÷ (100 × 단위)`를 내려 단위를 곱하면 `floor(base × 율 / 100 / 단위) × 단위`와 같고
|
|
337
|
+
* 부동소수 오차가 끼지 않는다(KRW는 지금의 `÷ 1000 × 10` 그대로).
|
|
332
338
|
*/
|
|
333
|
-
export function couponDiscountOf(
|
|
339
|
+
export function couponDiscountOf(
|
|
340
|
+
benefit: CouponBenefit,
|
|
341
|
+
base: number,
|
|
342
|
+
rules: MoneyRules = KRW_MONEY_RULES,
|
|
343
|
+
): number {
|
|
334
344
|
if (base <= 0) return 0;
|
|
345
|
+
const unit = rules.saleUnit;
|
|
335
346
|
let raw =
|
|
336
|
-
benefit.type === "RATE"
|
|
347
|
+
benefit.type === "RATE"
|
|
348
|
+
? Math.floor((base * benefit.value) / (100 * unit)) * unit
|
|
349
|
+
: benefit.value;
|
|
337
350
|
if (benefit.type === "RATE" && benefit.maxDiscountAmount !== null) {
|
|
338
351
|
raw = Math.min(raw, benefit.maxDiscountAmount);
|
|
339
352
|
}
|
|
@@ -422,6 +435,7 @@ function periodRejectOf(coupon: PricingCoupon, now: Date): CouponRejectReason |
|
|
|
422
435
|
* 사본 중 앞 장이 만료됐으면 뒤 장이 쓰인다. 결과의 `coupons`는 입력 순서 그대로다. 입력이 같으면 결과도 같다.
|
|
423
436
|
*/
|
|
424
437
|
export function quotePricing(input: PricingInput): PricingResult {
|
|
438
|
+
const rules = input.moneyRules ?? KRW_MONEY_RULES;
|
|
425
439
|
const lines = input.lines.map((line) => ({
|
|
426
440
|
line,
|
|
427
441
|
productDiscount: 0,
|
|
@@ -516,14 +530,14 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
516
530
|
continue;
|
|
517
531
|
}
|
|
518
532
|
chosen = coupon.lineId;
|
|
519
|
-
best = couponDiscountOf(coupon.rule.benefit, lineOf(chosen)?.line.amount ?? 0);
|
|
533
|
+
best = couponDiscountOf(coupon.rule.benefit, lineOf(chosen)?.line.amount ?? 0, rules);
|
|
520
534
|
} else {
|
|
521
535
|
if (candidates.length === 0) {
|
|
522
536
|
result.rejectReason = "STACK_LIMIT";
|
|
523
537
|
continue;
|
|
524
538
|
}
|
|
525
539
|
for (const id of candidates) {
|
|
526
|
-
const discount = couponDiscountOf(coupon.rule.benefit, lineOf(id)?.line.amount ?? 0);
|
|
540
|
+
const discount = couponDiscountOf(coupon.rule.benefit, lineOf(id)?.line.amount ?? 0, rules);
|
|
527
541
|
if (chosen === null || discount > best) {
|
|
528
542
|
chosen = id;
|
|
529
543
|
best = discount;
|
|
@@ -557,7 +571,7 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
557
571
|
.filter((entry): entry is (typeof lines)[number] => entry !== undefined);
|
|
558
572
|
const weights = targets.map((entry) => Math.max(0, entry.line.amount - entry.productDiscount));
|
|
559
573
|
const base = weights.reduce((sum, w) => sum + w, 0);
|
|
560
|
-
const discount = couponDiscountOf(coupon.rule.benefit, base);
|
|
574
|
+
const discount = couponDiscountOf(coupon.rule.benefit, base, rules);
|
|
561
575
|
if (discount <= 0) {
|
|
562
576
|
result.rejectReason = "NO_DISCOUNT";
|
|
563
577
|
continue;
|
|
@@ -591,7 +605,7 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
591
605
|
}
|
|
592
606
|
const weights = deliveryBundles.map((bundle) => Math.max(0, bundle.fee));
|
|
593
607
|
const base = weights.reduce((sum, w) => sum + w, 0);
|
|
594
|
-
const discount = couponDiscountOf(coupon.rule.benefit, base);
|
|
608
|
+
const discount = couponDiscountOf(coupon.rule.benefit, base, rules);
|
|
595
609
|
if (discount <= 0) {
|
|
596
610
|
result.rejectReason = "NO_DISCOUNT";
|
|
597
611
|
continue;
|
|
@@ -625,11 +639,15 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
625
639
|
const bundleWeights = deliveryBundles.map((bundle, index) =>
|
|
626
640
|
Math.max(0, bundle.fee + bundle.remoteSurcharge - (bundles[index]?.couponDiscount ?? 0)),
|
|
627
641
|
);
|
|
628
|
-
const points = pointUseOf(
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
642
|
+
const points = pointUseOf(
|
|
643
|
+
input.points ?? null,
|
|
644
|
+
{
|
|
645
|
+
productPaid: productWeights.reduce((sum, w) => sum + w, 0),
|
|
646
|
+
deliveryPaid: bundleWeights.reduce((sum, w) => sum + w, 0),
|
|
647
|
+
total: beforePoints,
|
|
648
|
+
},
|
|
649
|
+
rules,
|
|
650
|
+
);
|
|
633
651
|
if (points.amount > 0) {
|
|
634
652
|
const productPaid = productWeights.reduce((sum, w) => sum + w, 0);
|
|
635
653
|
const toLines = Math.min(points.amount, productPaid);
|
|
@@ -670,6 +688,7 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
670
688
|
export function pointUseOf(
|
|
671
689
|
points: PricingPoints | null,
|
|
672
690
|
amounts: { productPaid: number; deliveryPaid: number; total: number },
|
|
691
|
+
moneyRules: MoneyRules = KRW_MONEY_RULES,
|
|
673
692
|
): {
|
|
674
693
|
amount: number;
|
|
675
694
|
maxUsable: number;
|
|
@@ -690,8 +709,8 @@ export function pointUseOf(
|
|
|
690
709
|
if (total > 0 && cap >= total) return { amount: total, adjusted: false };
|
|
691
710
|
const amount = floorUnit(Math.min(cap, total));
|
|
692
711
|
const remaining = total - amount;
|
|
693
|
-
if (remaining > 0 && remaining <
|
|
694
|
-
return { amount: floorUnit(total -
|
|
712
|
+
if (remaining > 0 && remaining < moneyRules.minPayment) {
|
|
713
|
+
return { amount: floorUnit(total - moneyRules.minPayment), adjusted: true };
|
|
695
714
|
}
|
|
696
715
|
return { amount, adjusted: false };
|
|
697
716
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { BASE_CURRENCY, currencyCodeSchema } from "./currency";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* 환불 정책 (이슈 #25) — 취소·반품·교환에서 **배송비를 누가 부담하는지**를 정한다.
|
|
@@ -28,8 +29,26 @@ const descriptions = {
|
|
|
28
29
|
couponShortfallCharge:
|
|
29
30
|
"쿠폰 조건 미달 차감. 일부 취소·반품으로 남은 주문상품이 쿠폰 조건(최소 주문 금액 등)을 잃으면 남은 주문상품이 받던 쿠폰 할인을 환불액에서 뺀다. " +
|
|
30
31
|
"판매자 귀책(셀러 직권취소, 불량·오배송 반품)은 빼지 않는다. false면 차감 없이 실결제 금액만 나눠 환불한다. 기본 true",
|
|
32
|
+
currencies:
|
|
33
|
+
"외화별 반품 배송비·교환 배송비(이슈 #130). 키는 판매 통화 코드이고 기본 통화(KRW)는 넣지 않는다. 금액은 그 통화의 최소 단위다. " +
|
|
34
|
+
"상품이 정한 통화 값이 상점 값보다 앞선다. 정하지 않은 외화로는 배송 상품을 팔지 않는다. 주면 맵 전체를 바꾼다",
|
|
31
35
|
} as const;
|
|
32
36
|
|
|
37
|
+
/** 외화 한 통화의 반품·교환 배송비 */
|
|
38
|
+
export const currencyRefundFeesSchema = z.strictObject({
|
|
39
|
+
returnDeliveryFee: z.number().int().min(0).describe("이 통화의 반품 배송비. 통화의 최소 단위"),
|
|
40
|
+
exchangeDeliveryFee: z.number().int().min(0).describe("이 통화의 교환 배송비. 통화의 최소 단위"),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export type CurrencyRefundFees = z.infer<typeof currencyRefundFeesSchema>;
|
|
44
|
+
|
|
45
|
+
const currencyRefundMapSchema = z
|
|
46
|
+
.partialRecord(currencyCodeSchema, currencyRefundFeesSchema)
|
|
47
|
+
.refine((map) => !(BASE_CURRENCY in map), {
|
|
48
|
+
message: "기본 통화는 currencies에 넣지 않습니다",
|
|
49
|
+
})
|
|
50
|
+
.describe(descriptions.currencies);
|
|
51
|
+
|
|
33
52
|
/** 유효 환불 정책 — 상점 기본 + 상품별 덮어쓰기를 합친 결과 */
|
|
34
53
|
export const refundPolicySchema = z.object({
|
|
35
54
|
preShipmentDeliveryFeeRefund: z.boolean().describe(descriptions.preShipmentDeliveryFeeRefund),
|
|
@@ -44,6 +63,7 @@ export const refundPolicySchema = z.object({
|
|
|
44
63
|
.describe(
|
|
45
64
|
`${descriptions.couponShortfallCharge}. 이 필드가 없는 이전 서버 응답은 true로 읽는다`,
|
|
46
65
|
),
|
|
66
|
+
currencies: currencyRefundMapSchema.optional(),
|
|
47
67
|
});
|
|
48
68
|
|
|
49
69
|
export type RefundPolicy = z.infer<typeof refundPolicySchema>;
|
|
@@ -87,6 +107,7 @@ export const refundPolicyPatchSchema = z.strictObject({
|
|
|
87
107
|
.optional()
|
|
88
108
|
.describe(descriptions.conditionalFreeShortfallCharge),
|
|
89
109
|
couponShortfallCharge: z.boolean().optional().describe(descriptions.couponShortfallCharge),
|
|
110
|
+
currencies: currencyRefundMapSchema.optional(),
|
|
90
111
|
});
|
|
91
112
|
|
|
92
113
|
export type RefundPolicyPatch = z.infer<typeof refundPolicyPatchSchema>;
|
|
@@ -114,9 +135,41 @@ export interface RefundPolicyProductInput {
|
|
|
114
135
|
export function resolveRefundPolicy(
|
|
115
136
|
storePolicy?: RefundPolicyPatch | null,
|
|
116
137
|
product?: RefundPolicyProductInput | null,
|
|
117
|
-
): RefundPolicy
|
|
138
|
+
): RefundPolicy;
|
|
139
|
+
/**
|
|
140
|
+
* 통화의 유효 정책(이슈 #130) — 기본 통화는 위와 같다. 외화는 반품·교환 배송비를 상품 `currencies[통화]` → 상점 `currencies[통화]` 순으로 찾고
|
|
141
|
+
* (부담 규칙 boolean은 통화와 무관하다), 둘 다 없으면 null이다 — 그 통화로 배송 상품을 팔지 않는다(`NO_REFUND_FEE`).
|
|
142
|
+
* 결과의 `currencies`는 지우고 금액 필드가 그 통화 값이다. 통화별 반품비 해석은 이 함수 하나다
|
|
143
|
+
*/
|
|
144
|
+
export function resolveRefundPolicy(
|
|
145
|
+
storePolicy: RefundPolicyPatch | null | undefined,
|
|
146
|
+
product: RefundPolicyProductInput | null | undefined,
|
|
147
|
+
currency: string,
|
|
148
|
+
): RefundPolicy | null;
|
|
149
|
+
export function resolveRefundPolicy(
|
|
150
|
+
storePolicy?: RefundPolicyPatch | null,
|
|
151
|
+
product?: RefundPolicyProductInput | null,
|
|
152
|
+
currency: string = BASE_CURRENCY,
|
|
153
|
+
): RefundPolicy | null {
|
|
118
154
|
const store = { ...REFUND_POLICY_DEFAULTS, ...dropUndefined(storePolicy) };
|
|
119
|
-
|
|
155
|
+
const override = productRefundPolicyOverride(product) ?? {};
|
|
156
|
+
const merged: RefundPolicy = { ...store, ...override };
|
|
157
|
+
if (currency === BASE_CURRENCY) {
|
|
158
|
+
// 상품 덮어쓰기의 currencies가 상점 맵을 통째로 덮지 않게 통화마다 합친다
|
|
159
|
+
if (store.currencies || override.currencies) {
|
|
160
|
+
merged.currencies = { ...store.currencies, ...override.currencies };
|
|
161
|
+
}
|
|
162
|
+
return merged;
|
|
163
|
+
}
|
|
164
|
+
const code = currency as keyof NonNullable<RefundPolicy["currencies"]>;
|
|
165
|
+
const fees = override.currencies?.[code] ?? store.currencies?.[code];
|
|
166
|
+
if (!fees) return null;
|
|
167
|
+
const { currencies: _byCurrency, ...rest } = merged;
|
|
168
|
+
return {
|
|
169
|
+
...rest,
|
|
170
|
+
returnDeliveryFee: fees.returnDeliveryFee,
|
|
171
|
+
exchangeDeliveryFee: fees.exchangeDeliveryFee,
|
|
172
|
+
};
|
|
120
173
|
}
|
|
121
174
|
|
|
122
175
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { BASE_CURRENCY, currencyCodeSchema } from "./currency";
|
|
2
3
|
import { type DeliveryType, type FulfillmentType, requiresShipping } from "./fulfillment";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -32,14 +33,38 @@ const descriptions = {
|
|
|
32
33
|
"null이면 전체 무료배송을 쓰지 않는다(상품별 조건부 무료는 그대로 동작한다). 기본 null",
|
|
33
34
|
jejuSurcharge: "제주 지역 추가 배송비. 원 단위. **배송 묶음마다** 더한다. 기본 0",
|
|
34
35
|
isolatedSurcharge: "도서산간 지역 추가 배송비. 원 단위. **배송 묶음마다** 더한다. 기본 0",
|
|
36
|
+
currencies:
|
|
37
|
+
"외화별 기본 배송비·전체 무료배송 기준액(이슈 #130). 키는 판매 통화 코드이고 기본 통화(KRW)는 넣지 않는다. 금액은 그 통화의 최소 단위다. " +
|
|
38
|
+
"정하지 않은 외화로는 배송 상품을 팔지 않는다. 지역 추가 배송비는 국내(기본 통화) 값만 있다. 주면 맵 전체를 바꾼다",
|
|
35
39
|
} as const;
|
|
36
40
|
|
|
41
|
+
/** 외화 한 통화의 상점 배송 값 */
|
|
42
|
+
export const currencyShippingPolicySchema = z.strictObject({
|
|
43
|
+
baseDeliveryFee: z.number().int().min(0).describe("이 통화의 기본 배송비. 통화의 최소 단위"),
|
|
44
|
+
freeThreshold: z
|
|
45
|
+
.number()
|
|
46
|
+
.int()
|
|
47
|
+
.min(1)
|
|
48
|
+
.nullable()
|
|
49
|
+
.describe("이 통화의 전체 무료배송 기준액. null이면 쓰지 않는다"),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export type CurrencyShippingPolicy = z.infer<typeof currencyShippingPolicySchema>;
|
|
53
|
+
|
|
54
|
+
const currencyShippingMapSchema = z
|
|
55
|
+
.partialRecord(currencyCodeSchema, currencyShippingPolicySchema)
|
|
56
|
+
.refine((map) => !(BASE_CURRENCY in map), {
|
|
57
|
+
message: "기본 통화는 currencies에 넣지 않습니다",
|
|
58
|
+
})
|
|
59
|
+
.describe(descriptions.currencies);
|
|
60
|
+
|
|
37
61
|
/** 유효 배송비 정책 — 상점이 정하지 않은 항목은 `SHIPPING_POLICY_DEFAULTS`로 채운 값이다 */
|
|
38
62
|
export const shippingPolicySchema = z.object({
|
|
39
63
|
baseDeliveryFee: z.number().int().min(0).describe(descriptions.baseDeliveryFee),
|
|
40
64
|
freeThreshold: z.number().int().min(1).nullable().describe(descriptions.freeThreshold),
|
|
41
65
|
jejuSurcharge: z.number().int().min(0).describe(descriptions.jejuSurcharge),
|
|
42
66
|
isolatedSurcharge: z.number().int().min(0).describe(descriptions.isolatedSurcharge),
|
|
67
|
+
currencies: currencyShippingMapSchema.optional(),
|
|
43
68
|
});
|
|
44
69
|
|
|
45
70
|
export type ShippingPolicy = z.infer<typeof shippingPolicySchema>;
|
|
@@ -68,17 +93,41 @@ export const shippingPolicyPatchSchema = z.strictObject({
|
|
|
68
93
|
freeThreshold: z.number().int().min(1).nullable().optional().describe(descriptions.freeThreshold),
|
|
69
94
|
jejuSurcharge: z.number().int().min(0).optional().describe(descriptions.jejuSurcharge),
|
|
70
95
|
isolatedSurcharge: z.number().int().min(0).optional().describe(descriptions.isolatedSurcharge),
|
|
96
|
+
currencies: currencyShippingMapSchema.optional(),
|
|
71
97
|
});
|
|
72
98
|
|
|
73
99
|
export type ShippingPolicyPatch = z.infer<typeof shippingPolicyPatchSchema>;
|
|
74
100
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
101
|
+
/**
|
|
102
|
+
* 유효 정책 해석 — 상점이 정한 항목 + `SHIPPING_POLICY_DEFAULTS`.
|
|
103
|
+
*
|
|
104
|
+
* `currency`(이슈 #130)를 주면 그 통화의 정책이다. 기본 통화는 지금과 같고, 외화는 `currencies[통화]`의 기본 배송비·무료 기준액에
|
|
105
|
+
* 지역 추가 배송비 0이다(할증 구간은 국내 값). 외화 값이 없으면 null — 그 통화로 배송 상품을 팔지 않는다. 통화별 값 해석은 이 함수 하나다.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveShippingPolicy(patch?: ShippingPolicyPatch | null): ShippingPolicy;
|
|
108
|
+
export function resolveShippingPolicy(
|
|
109
|
+
patch: ShippingPolicyPatch | null | undefined,
|
|
110
|
+
currency: string,
|
|
111
|
+
): ShippingPolicy | null;
|
|
112
|
+
export function resolveShippingPolicy(
|
|
113
|
+
patch?: ShippingPolicyPatch | null,
|
|
114
|
+
currency: string = BASE_CURRENCY,
|
|
115
|
+
): ShippingPolicy | null {
|
|
116
|
+
const given = patch
|
|
117
|
+
? (Object.fromEntries(
|
|
118
|
+
Object.entries(patch).filter(([, value]) => value !== undefined),
|
|
119
|
+
) as ShippingPolicyPatch)
|
|
120
|
+
: {};
|
|
121
|
+
const base: ShippingPolicy = { ...SHIPPING_POLICY_DEFAULTS, ...given };
|
|
122
|
+
if (currency === BASE_CURRENCY) return base;
|
|
123
|
+
const foreign = base.currencies?.[currency as keyof typeof base.currencies];
|
|
124
|
+
if (!foreign) return null;
|
|
125
|
+
return {
|
|
126
|
+
baseDeliveryFee: foreign.baseDeliveryFee,
|
|
127
|
+
freeThreshold: foreign.freeThreshold,
|
|
128
|
+
jejuSurcharge: 0,
|
|
129
|
+
isolatedSurcharge: 0,
|
|
130
|
+
};
|
|
82
131
|
}
|
|
83
132
|
|
|
84
133
|
// ─── 지역 추가 배송비 ─────────────────────────────────────
|