@sayren/store-sdk 0.31.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/store-sdk",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
4
4
  "main": "./dist/index.mjs",
5
5
  "types": "./dist/index.d.mts",
6
6
  "exports": {
package/src/client.ts CHANGED
@@ -162,6 +162,22 @@ import {
162
162
  pickupLocationRequestSchema,
163
163
  pickupLocationSchema,
164
164
  } from "./schemas/fulfillment";
165
+ import {
166
+ type ClaimCollectCompleteRequest,
167
+ claimCollectCompleteRequestSchema,
168
+ type InventoryAdjustmentRequest,
169
+ type InventoryLevelListQuery,
170
+ type InventoryLocationRequest,
171
+ type InventoryMovementListQuery,
172
+ type InventoryTransferRequest,
173
+ inventoryAdjustmentRequestSchema,
174
+ inventoryLevelPageSchema,
175
+ inventoryLocationRequestSchema,
176
+ inventoryLocationSchema,
177
+ inventoryMovementPageSchema,
178
+ inventoryOperationResultSchema,
179
+ inventoryTransferRequestSchema,
180
+ } from "./schemas/inventory";
165
181
  import {
166
182
  type LoginProvider,
167
183
  loginProviderTestSchema,
@@ -171,6 +187,13 @@ import {
171
187
  type UpdateLoginSettingsRequest,
172
188
  updateLoginSettingsRequestSchema,
173
189
  } from "./schemas/login";
190
+ import {
191
+ type CreateMarketRequestInput,
192
+ createMarketRequestSchema,
193
+ marketSchema,
194
+ type UpdateMarketRequestInput,
195
+ updateMarketRequestSchema,
196
+ } from "./schemas/market";
174
197
  import {
175
198
  type CustomerInquiryCategory,
176
199
  inquirySchema,
@@ -238,12 +261,14 @@ import {
238
261
  type UpdatePaymentSettingsRequest,
239
262
  type UpsertKakaoPayProviderRequest,
240
263
  type UpsertNaverPayProviderRequest,
264
+ type UpsertPayPalProviderRequest,
241
265
  type UpsertPortOneProviderRequest,
242
266
  type UpsertTossPaymentsProviderRequest,
243
267
  updatePaymentProviderRequestSchema,
244
268
  updatePaymentSettingsRequestSchema,
245
269
  upsertKakaoPayProviderRequestSchema,
246
270
  upsertNaverPayProviderRequestSchema,
271
+ upsertPayPalProviderRequestSchema,
247
272
  upsertPortOneProviderRequestSchema,
248
273
  upsertTossPaymentsProviderRequestSchema,
249
274
  type VerifyPaymentProviderRequest,
@@ -1049,11 +1074,26 @@ export function createStoreClient(options: StoreClientOptions) {
1049
1074
  claimId: string,
1050
1075
  body: { collectMethod: "SELLER_PICKUP" | "BUYER_SEND"; carrierCode?: string },
1051
1076
  ) => storeHttp.requestVoid("POST", path`/claims/${claimId}/collect`, { body }),
1052
- completeCollect: (claimId: string) =>
1053
- storeHttp.requestVoid("POST", path`/claims/${claimId}/collect/complete`),
1054
- registerRedelivery: (claimId: string, carrierCode: string, trackingNumber: string) =>
1077
+ /** 수거 완료 — `restock`(이슈 #132)으로 돌아온 조합·재입고 위치를 지정한다. 생략하면 할당 순서·반품지 위치다 */
1078
+ completeCollect: (claimId: string, body?: ClaimCollectCompleteRequest) =>
1079
+ storeHttp.requestVoid("POST", path`/claims/${claimId}/collect/complete`, {
1080
+ ...(body ? { body: claimCollectCompleteRequestSchema.parse(body) } : {}),
1081
+ }),
1082
+ /** 교환 재배송 — `inventoryLocationId`(이슈 #132)로 교환품을 보낼 위치를 지정한다. 생략하면 그 상품의 기준 위치다 */
1083
+ registerRedelivery: (
1084
+ claimId: string,
1085
+ carrierCode: string,
1086
+ trackingNumber: string,
1087
+ options: { inventoryLocationId?: string } = {},
1088
+ ) =>
1055
1089
  storeHttp.requestVoid("POST", path`/claims/${claimId}/redelivery`, {
1056
- body: { carrierCode, trackingNumber },
1090
+ body: {
1091
+ carrierCode,
1092
+ trackingNumber,
1093
+ ...(options.inventoryLocationId
1094
+ ? { inventoryLocationId: options.inventoryLocationId }
1095
+ : {}),
1096
+ },
1057
1097
  }),
1058
1098
  hold: (claimId: string, reason: string) =>
1059
1099
  storeHttp.request("POST", path`/claims/${claimId}/hold`, claimHoldResultSchema, {
@@ -1418,6 +1458,14 @@ export function createStoreClient(options: StoreClientOptions) {
1418
1458
  storeHttp.request("PUT", "/store/payment-providers/naverpay", paymentProviderSchema, {
1419
1459
  body: upsertNaverPayProviderRequestSchema.parse(body),
1420
1460
  }),
1461
+ /**
1462
+ * `PUT /v1/store/payment-providers/paypal` — PayPal(직접 연동) 자격 증명 세트 저장. 규칙·에러는 `upsertTossPayments`와 같다
1463
+ * (가맹점 식별자는 `clientId`, 비밀 필드는 `clientSecret`). 결제 옵션은 PayPal 간편결제 하나이고 `currencies`의 외화 주문서에만 나온다.
1464
+ */
1465
+ upsertPayPal: (body: UpsertPayPalProviderRequest) =>
1466
+ storeHttp.request("PUT", "/store/payment-providers/paypal", paymentProviderSchema, {
1467
+ body: upsertPayPalProviderRequestSchema.parse(body),
1468
+ }),
1421
1469
  /**
1422
1470
  * `PATCH /v1/store/payment-providers/{provider}` — 사용 여부(`enabled`)·샌드박스 스위치(`sandboxMode`) 변경.
1423
1471
  * 하나 이상 보내야 한다(아니면 요청 전에 ZodError). 검증을 모두 통과해야 한 번에 적용된다(부분 적용 없음).
@@ -1752,6 +1800,65 @@ export function createStoreClient(options: StoreClientOptions) {
1752
1800
  },
1753
1801
 
1754
1802
  /** 픽업 장소 (이슈 #115) — 방문 수령 주문의 수령 장소. 조회 `store:r`, 쓰기 `store:rw`(상점 플랫폼 OWNER·ADMIN) */
1803
+ /**
1804
+ * 위치별 재고 (이슈 #132) — 재고 위치·위치별 가용 재고·재고 조정·실사·이동·변동 내역. 위치 쓰기는 상점 설정 표면(상점 플랫폼 OWNER·ADMIN)이고
1805
+ * 조회는 `inventory:r`, 조정·이동은 `inventory:rw`다. 같은 `idempotencyKey`로 다시 보내면 첫 결과를 돌려준다(다른 본문이면 409)
1806
+ */
1807
+ inventory: {
1808
+ locations: {
1809
+ list: () =>
1810
+ storeHttp.request("GET", "/inventory-locations", z.array(inventoryLocationSchema)),
1811
+ create: (body: InventoryLocationRequest) =>
1812
+ storeHttp.request("POST", "/inventory-locations", inventoryLocationSchema, {
1813
+ body: inventoryLocationRequestSchema.parse(body),
1814
+ }),
1815
+ update: (inventoryLocationId: string, body: InventoryLocationRequest) =>
1816
+ storeHttp.request(
1817
+ "PUT",
1818
+ path`/inventory-locations/${inventoryLocationId}`,
1819
+ inventoryLocationSchema,
1820
+ { body: inventoryLocationRequestSchema.parse(body) },
1821
+ ),
1822
+ remove: (inventoryLocationId: string) =>
1823
+ storeHttp.requestVoid("DELETE", path`/inventory-locations/${inventoryLocationId}`),
1824
+ },
1825
+ /** 커서 페이지네이션 — 다음 페이지는 응답의 `nextCursor`를 `cursor`로 넘긴다 */
1826
+ levels: (params?: InventoryLevelListQuery) =>
1827
+ storeHttp.request("GET", "/inventory/levels", inventoryLevelPageSchema, {
1828
+ query: params,
1829
+ }),
1830
+ /** 순번 오름차순 — 다음 쪽은 `nextAfter`를 `after`로 넘긴다 */
1831
+ movements: (params?: InventoryMovementListQuery) =>
1832
+ storeHttp.request("GET", "/inventory/movements", inventoryMovementPageSchema, {
1833
+ query: params,
1834
+ }),
1835
+ adjust: (body: InventoryAdjustmentRequest, idempotencyKey?: string) =>
1836
+ storeHttp.request("POST", "/inventory/adjustments", inventoryOperationResultSchema, {
1837
+ body: inventoryAdjustmentRequestSchema.parse(body),
1838
+ headers: idempotency(idempotencyKey),
1839
+ }),
1840
+ transfer: (body: InventoryTransferRequest, idempotencyKey?: string) =>
1841
+ storeHttp.request("POST", "/inventory/transfers", inventoryOperationResultSchema, {
1842
+ body: inventoryTransferRequestSchema.parse(body),
1843
+ headers: idempotency(idempotencyKey),
1844
+ }),
1845
+ },
1846
+
1847
+ /** 판매 권역(이슈 #130) — 국가·통화·도메인. 쓰기는 다중 통화가 켜진 상점에서만 된다 */
1848
+ markets: {
1849
+ list: () => storeHttp.request("GET", "/markets", z.array(marketSchema)),
1850
+ get: (marketId: string) => storeHttp.request("GET", path`/markets/${marketId}`, marketSchema),
1851
+ create: (body: CreateMarketRequestInput) =>
1852
+ storeHttp.request("POST", "/markets", marketSchema, {
1853
+ body: createMarketRequestSchema.parse(body),
1854
+ }),
1855
+ update: (marketId: string, body: UpdateMarketRequestInput) =>
1856
+ storeHttp.request("PATCH", path`/markets/${marketId}`, marketSchema, {
1857
+ body: updateMarketRequestSchema.parse(body),
1858
+ }),
1859
+ archive: (marketId: string) => storeHttp.requestVoid("DELETE", path`/markets/${marketId}`),
1860
+ },
1861
+
1755
1862
  pickupLocations: {
1756
1863
  list: () => storeHttp.request("GET", "/pickup-locations", z.array(pickupLocationSchema)),
1757
1864
  create: (body: PickupLocationRequestInput) =>
package/src/index.ts CHANGED
@@ -30,10 +30,14 @@ export * from "./schemas/commerce-flow";
30
30
  export * from "./schemas/commerce-number";
31
31
  export * from "./schemas/common";
32
32
  export * from "./schemas/coupon";
33
+ export * from "./schemas/currency";
34
+ export * from "./schemas/currency-prices";
33
35
  export * from "./schemas/customer";
34
36
  export * from "./schemas/customer-delegation";
35
37
  export * from "./schemas/fulfillment";
38
+ export * from "./schemas/inventory";
36
39
  export * from "./schemas/login";
40
+ export * from "./schemas/market";
37
41
  export * from "./schemas/misc";
38
42
  export * from "./schemas/order";
39
43
  export * from "./schemas/payment";
@@ -100,6 +100,22 @@ export const analyticsOverviewSchema = z.object({
100
100
  .number()
101
101
  .int()
102
102
  .describe("집계에서 뺀 봇 세션 수 — 봇 표시 이벤트가 하나라도 있는 세션은 모든 값에서 빠진다"),
103
+ byCurrency: z
104
+ .array(
105
+ z.object({
106
+ currency: z.string().describe("구매 통화(ISO 4217)"),
107
+ purchases: z.number().int().describe("실결제 구매 수(테스트 결제 제외)"),
108
+ revenue: z
109
+ .number()
110
+ .int()
111
+ .describe("매출(그 통화의 최소 단위). `includeTest`면 테스트 결제 매출을 더한다"),
112
+ }),
113
+ )
114
+ .optional()
115
+ .default([])
116
+ .describe(
117
+ "통화별 매출(이슈 #130) — 기간 합계, 기본 통화(KRW) 포함. 통화 사이는 환산하지 않는다. `summary.revenue`는 기본 통화 매출이다. 옛 서버 응답은 빈 목록",
118
+ ),
103
119
  });
104
120
  export type AnalyticsOverview = z.infer<typeof analyticsOverviewSchema>;
105
121
 
@@ -140,6 +140,7 @@ export const APP_EVENT_SCOPES: Record<AppEventType, string | null> = {
140
140
  "INQUIRY.CREATED": "inquiry:r",
141
141
  "REVIEW.CREATED": "review:r",
142
142
  "PRODUCT.OUT_OF_STOCK": "product:r",
143
+ "INVENTORY.CHANGED": "inventory:r",
143
144
  "APP.INSTALLED": null,
144
145
  "APP.UNINSTALLED": null,
145
146
  "APP.SETTINGS_UPDATED": null,
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { pageQueryShape } from "./common";
3
+ import { currencyCodeSchema } from "./currency";
3
4
  import {
4
5
  COUPON_CONDITIONS_DEFAULTS,
5
6
  COUPON_TARGET_LIST_MAX,
@@ -111,6 +112,11 @@ export const createCouponRequestSchema = z
111
112
  totalUseLimit: limitField(descriptions.totalUseLimit),
112
113
  perMemberIssueLimit: limitField(descriptions.perMemberIssueLimit),
113
114
  perMemberUseLimit: limitField(descriptions.perMemberUseLimit),
115
+ currency: currencyCodeSchema
116
+ .optional()
117
+ .describe(
118
+ "쿠폰 통화(이슈 #130). 정액·최대 할인액·최소 주문 금액이 이 통화 값이고 같은 통화 주문서에만 적용된다. 생략하면 기본 통화(KRW). 판매 통화여야 하고 만든 뒤에는 바꿀 수 없다",
119
+ ),
114
120
  })
115
121
  .superRefine((body, ctx) => {
116
122
  const issue = (path: string, message: string) =>
@@ -197,6 +203,13 @@ export const couponSchema = z.object({
197
203
  .describe("발급 수 — 회수한 발급도 센다. 총 발급 한도(`totalIssueLimit`)가 이 값과 비교된다"),
198
204
  reservedCount: z.number().int().describe("결제 중인 예약 수 — 결제가 끝나거나 만료되면 풀린다"),
199
205
  usedCount: z.number().int().describe("사용 수 — 전량 취소로 복원되면 줄어든다"),
206
+ currency: z
207
+ .string()
208
+ .optional()
209
+ .default("KRW")
210
+ .describe(
211
+ "쿠폰 통화(이슈 #130). 금액 필드는 이 통화의 최소 단위다. 옛 서버 응답은 KRW로 읽는다",
212
+ ),
200
213
  status: couponStatusSchema,
201
214
  locked: z.boolean().describe("발급·사용이 시작돼 혜택·조건·대상·사용 시작일을 바꿀 수 없는가"),
202
215
  lockedAt: z.string().nullable(),
@@ -0,0 +1,138 @@
1
+ import { z } from "zod";
2
+ import { BASE_CURRENCY, currencyCodeSchema, isSaleUnitPrice } from "./currency";
3
+
4
+ /**
5
+ * 통화별 가격 입력·응답 (이슈 #130 설계 D3·D6·§8.1) — **외화만 담는다.** 기본 통화(KRW) 값은 지금의 평면 필드(`salePrice`·`deliveryFee` 등)다.
6
+ * 금액은 그 통화의 최소 단위 정수다. 목록에 없는 외화로는 그 상품을 팔지 않는다. 쓰기(PUT·POST)는 목록 전체 교체이고 생략하면 유지한다.
7
+ * 통화는 상점의 판매 통화여야 한다(400 `CURRENCY_NOT_SOLD`).
8
+ */
9
+
10
+ /** 외화만·통화마다 한 번 — 기본 통화 원소와 같은 통화 두 번은 400이다 */
11
+ function foreignOnce<T extends { currency: string }>(items: readonly T[], ctx: z.RefinementCtx) {
12
+ const seen = new Set<string>();
13
+ items.forEach((item, index) => {
14
+ if (item.currency === BASE_CURRENCY) {
15
+ ctx.addIssue({
16
+ code: "custom",
17
+ path: [index, "currency"],
18
+ message: "기본 통화는 prices에 넣지 않습니다",
19
+ });
20
+ }
21
+ if (seen.has(item.currency)) {
22
+ ctx.addIssue({ code: "custom", path: [index, "currency"], message: "통화가 겹칩니다" });
23
+ }
24
+ seen.add(item.currency);
25
+ });
26
+ }
27
+
28
+ /** 상품 판매가의 외화 값 */
29
+ export const productCurrencyPriceSchema = z
30
+ .strictObject({
31
+ currency: currencyCodeSchema,
32
+ salePrice: z
33
+ .number()
34
+ .int()
35
+ .describe(
36
+ "이 통화의 판매가. 통화의 최소 단위이고 판매가 단위(KRW 10, JPY 1, USD 1센트, TWD 100)의 배수다",
37
+ ),
38
+ originalPrice: z
39
+ .number()
40
+ .int()
41
+ .min(0)
42
+ .nullable()
43
+ .optional()
44
+ .describe("이 통화의 정가(표시용). 생략은 null"),
45
+ discountAmount: z
46
+ .number()
47
+ .int()
48
+ .min(0)
49
+ .nullable()
50
+ .optional()
51
+ .describe(
52
+ "정액 즉시할인의 이 통화 값. 정률 할인은 모든 통화에 그대로 적용된다. null이면 이 통화에는 정액 할인이 없다",
53
+ ),
54
+ })
55
+ .superRefine((value, ctx) => {
56
+ if (!isSaleUnitPrice(value.salePrice, value.currency)) {
57
+ ctx.addIssue({
58
+ code: "custom",
59
+ path: ["salePrice"],
60
+ message: "판매가가 이 통화의 판매가 단위가 아닙니다 (INVALID_PRICE_UNIT)",
61
+ });
62
+ }
63
+ });
64
+
65
+ export const productCurrencyPricesSchema = z
66
+ .array(productCurrencyPriceSchema)
67
+ .max(15)
68
+ .superRefine(foreignOnce)
69
+ .describe(
70
+ "외화 판매가(이슈 #130). 기본 통화는 넣지 않는다. 주면 판매 중인 외화 안에서 목록 전체를 바꾸고(판매하지 않는 통화의 저장 값은 유지한다) 생략하면 유지한다. 목록에 없는 외화로는 팔지 않는다",
71
+ );
72
+
73
+ export type ProductCurrencyPrice = z.infer<typeof productCurrencyPriceSchema>;
74
+
75
+ /** 상품 배송의 외화 값 — 배송비 유형·출고지는 상품 하나다 */
76
+ export const shippingCurrencyPriceSchema = z.strictObject({
77
+ currency: currencyCodeSchema,
78
+ deliveryFee: z
79
+ .number()
80
+ .int()
81
+ .min(0)
82
+ .optional()
83
+ .describe(
84
+ "이 통화의 배송비. 생략하면 그 통화의 상점 기본 배송비이고, 상점 값도 없으면 이 통화로 배송 상품을 팔지 않는다. FREE면 0",
85
+ ),
86
+ conditionalFreeAmount: z
87
+ .number()
88
+ .int()
89
+ .min(1)
90
+ .nullable()
91
+ .optional()
92
+ .describe("이 통화의 조건부 무료 기준 묶음 소계. CONDITIONAL_FREE가 아니면 무시한다"),
93
+ });
94
+
95
+ export const shippingCurrencyPricesSchema = z
96
+ .array(shippingCurrencyPriceSchema)
97
+ .max(15)
98
+ .superRefine(foreignOnce)
99
+ .describe(
100
+ "외화 배송비(이슈 #130). 기본 통화는 넣지 않는다. 주면 판매 중인 외화 안에서 목록 전체를 바꾸고(판매하지 않는 통화의 저장 값은 유지한다) 생략하면 유지한다",
101
+ );
102
+
103
+ /** 저장된 외화 배송 값(응답) */
104
+ export const storedShippingCurrencyPriceSchema = z.object({
105
+ currency: z.string(),
106
+ deliveryFee: z.number().int(),
107
+ conditionalFreeAmount: z.number().int().nullable(),
108
+ });
109
+
110
+ /** 저장된 외화 판매가(응답) */
111
+ export const storedProductCurrencyPriceSchema = z.object({
112
+ currency: z.string(),
113
+ salePrice: z.number().int(),
114
+ originalPrice: z.number().int().nullable(),
115
+ discountAmount: z.number().int().nullable(),
116
+ });
117
+
118
+ /** 조합·추가 선택 값 추가금의 외화 값 — 추가금은 판매가 단위 규칙이 없다(설계 D4) */
119
+ export const additionalCurrencyPriceSchema = z.strictObject({
120
+ currency: currencyCodeSchema,
121
+ additionalPrice: z.number().int().min(0).describe("이 통화의 추가금. 통화의 최소 단위(0 이상)"),
122
+ });
123
+
124
+ export const additionalCurrencyPricesSchema = z
125
+ .array(additionalCurrencyPriceSchema)
126
+ .max(15)
127
+ .superRefine(foreignOnce)
128
+ .describe(
129
+ "외화 추가금(이슈 #130). 기본 통화는 넣지 않는다. 주면 판매 중인 외화 안에서 목록 전체를 바꾸고(판매하지 않는 통화의 저장 값은 유지한다) 생략하면 유지한다. 기본 통화 추가금이 0이 아닌데 목록에 없는 외화로는 이 조합·값을 팔지 않는다",
130
+ );
131
+
132
+ export type AdditionalCurrencyPrice = z.infer<typeof additionalCurrencyPriceSchema>;
133
+
134
+ /** 저장된 외화 추가금(응답) */
135
+ export const storedAdditionalCurrencyPriceSchema = z.object({
136
+ currency: z.string(),
137
+ additionalPrice: z.number().int(),
138
+ });
@@ -0,0 +1,184 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * 통화표와 금액 변환(이슈 #130). 숫자만 다룬다 — 사람이 읽는 문자열(「1,000원」·「$10.99」)은 각 앱의 `formatMoney`가 만든다.
5
+ *
6
+ * 모든 금액은 통화의 **최소 단위 정수**다(KRW 1원, USD 1센트, JPY 1엔). 주 단위(10.99달러)는 표시·입력·PG 요청 형식에만 쓴다.
7
+ * storefront-sdk `schemas/currency.ts`는 이 파일의 사본이다(storefront-sdk는 store-sdk에 의존하지 않는다). api 유닛 테스트가 두 표가 같은지 고정한다.
8
+ * 고치면 사본도 같이 고친다.
9
+ */
10
+
11
+ export interface CurrencySpec {
12
+ /** ISO 4217 코드 */
13
+ code: string;
14
+ /** 소수 자릿수(ISO 4217) — 주 단위 1 = 최소 단위 10^digits */
15
+ digits: number;
16
+ /** 판매가 단위(최소 단위). 판매가는 이 값의 배수이고 최소값도 이 값이다. 추가금에는 적용하지 않는다 */
17
+ saleUnit: number;
18
+ /** 통화표 최소 결제액(최소 단위). 실제 하한은 PG 하한과의 큰 값이다(`minPaymentOf`) */
19
+ minPayment: number;
20
+ /** 주문 최대 금액(최소 단위). int4와 PG 상한 중 작은 쪽 */
21
+ maxOrderAmount: number;
22
+ }
23
+
24
+ const INT4_MAX = 2_147_483_647;
25
+ /** Stripe 카드 외 결제 상한이 8자리(999,999.99)라 2자리 통화는 이 값이 int4보다 먼저 걸린다 */
26
+ const PG_8_DIGIT_MAX = 99_999_999;
27
+
28
+ /**
29
+ * 1차 판매 통화 16종(설계 Q6). 판매가 단위는 KRW 10원(현행 규칙), JPY 1엔, 2자리 통화 1센트, TWD 100(= 1달러, PayPal이 TWD를 정수로만 받는다).
30
+ * 최소 결제액은 KRW가 현행 100원이고, 그 밖은 Stripe 최소 청구액(https://docs.stripe.com/currencies#minimum-and-maximum-charge-amounts)을 따른다.
31
+ * Stripe 표에 없는 CNY·TWD·PHP는 USD 0.50 근처의 근사값이다(PG 하한은 어댑터 선언이 따로 더한다).
32
+ */
33
+ export const CURRENCIES = {
34
+ KRW: { code: "KRW", digits: 0, saleUnit: 10, minPayment: 100, maxOrderAmount: INT4_MAX },
35
+ USD: { code: "USD", digits: 2, saleUnit: 1, minPayment: 50, maxOrderAmount: PG_8_DIGIT_MAX },
36
+ EUR: { code: "EUR", digits: 2, saleUnit: 1, minPayment: 50, maxOrderAmount: PG_8_DIGIT_MAX },
37
+ JPY: { code: "JPY", digits: 0, saleUnit: 1, minPayment: 50, maxOrderAmount: PG_8_DIGIT_MAX },
38
+ GBP: { code: "GBP", digits: 2, saleUnit: 1, minPayment: 30, maxOrderAmount: PG_8_DIGIT_MAX },
39
+ CNY: { code: "CNY", digits: 2, saleUnit: 1, minPayment: 400, maxOrderAmount: PG_8_DIGIT_MAX },
40
+ HKD: { code: "HKD", digits: 2, saleUnit: 1, minPayment: 400, maxOrderAmount: PG_8_DIGIT_MAX },
41
+ TWD: { code: "TWD", digits: 2, saleUnit: 100, minPayment: 2_000, maxOrderAmount: PG_8_DIGIT_MAX },
42
+ SGD: { code: "SGD", digits: 2, saleUnit: 1, minPayment: 50, maxOrderAmount: PG_8_DIGIT_MAX },
43
+ AUD: { code: "AUD", digits: 2, saleUnit: 1, minPayment: 50, maxOrderAmount: PG_8_DIGIT_MAX },
44
+ CAD: { code: "CAD", digits: 2, saleUnit: 1, minPayment: 50, maxOrderAmount: PG_8_DIGIT_MAX },
45
+ THB: { code: "THB", digits: 2, saleUnit: 1, minPayment: 1_000, maxOrderAmount: PG_8_DIGIT_MAX },
46
+ MYR: { code: "MYR", digits: 2, saleUnit: 1, minPayment: 200, maxOrderAmount: PG_8_DIGIT_MAX },
47
+ PHP: { code: "PHP", digits: 2, saleUnit: 1, minPayment: 3_000, maxOrderAmount: PG_8_DIGIT_MAX },
48
+ NZD: { code: "NZD", digits: 2, saleUnit: 1, minPayment: 50, maxOrderAmount: PG_8_DIGIT_MAX },
49
+ CHF: { code: "CHF", digits: 2, saleUnit: 1, minPayment: 50, maxOrderAmount: PG_8_DIGIT_MAX },
50
+ } as const satisfies Record<string, CurrencySpec>;
51
+
52
+ export type CurrencyCode = keyof typeof CURRENCIES;
53
+
54
+ export const CURRENCY_CODES = Object.keys(CURRENCIES) as [CurrencyCode, ...CurrencyCode[]];
55
+
56
+ /** 기본 통화 — 1차는 KRW 고정(설계 Q9). 기본 권역의 통화이고 지금의 금액 컬럼이 이 통화 값이다 */
57
+ export const BASE_CURRENCY = "KRW" satisfies CurrencyCode;
58
+
59
+ export const currencyCodeSchema = z
60
+ .enum(CURRENCY_CODES)
61
+ .describe("통화(ISO 4217). 금액 필드는 이 통화의 최소 단위 정수다");
62
+
63
+ export function isCurrencyCode(value: string): value is CurrencyCode {
64
+ return Object.hasOwn(CURRENCIES, value);
65
+ }
66
+
67
+ /** 통화표 항목. 모르는 통화면 RangeError — 판매 통화가 아닌 값이 계산까지 오면 원천의 검증 누락이다 */
68
+ export function currencySpecOf(code: string): CurrencySpec {
69
+ if (!isCurrencyCode(code)) throw new RangeError(`unsupported currency: ${code}`);
70
+ return CURRENCIES[code];
71
+ }
72
+
73
+ function scaleOf(spec: CurrencySpec): number {
74
+ return 10 ** spec.digits;
75
+ }
76
+
77
+ /** 주 단위 → 최소 단위(반올림). 입력·화면 값 변환용이고 PG 응답 해석은 `fromDecimalString`이다 */
78
+ export function toMinor(major: number, currency: string): number {
79
+ return Math.round(major * scaleOf(currencySpecOf(currency)));
80
+ }
81
+
82
+ /** 최소 단위 → 주 단위(표시용 숫자) */
83
+ export function minorToMajor(minor: number, currency: string): number {
84
+ return minor / scaleOf(currencySpecOf(currency));
85
+ }
86
+
87
+ /** 최소 단위 → PG 요청 형식 십진 문자열(`1099` USD → `"10.99"`, `1000` KRW → `"1000"`). 정수 연산만 쓴다 */
88
+ export function toDecimalString(minor: number, currency: string): string {
89
+ if (!Number.isSafeInteger(minor)) throw new RangeError(`amount must be an integer: ${minor}`);
90
+ const { digits } = currencySpecOf(currency);
91
+ const sign = minor < 0 ? "-" : "";
92
+ const abs = String(Math.abs(minor));
93
+ if (digits === 0) return `${sign}${abs}`;
94
+ const padded = abs.padStart(digits + 1, "0");
95
+ return `${sign}${padded.slice(0, -digits)}.${padded.slice(-digits)}`;
96
+ }
97
+
98
+ /**
99
+ * PG 응답의 십진 문자열 → 최소 단위 정수. 통화 자릿수보다 긴 소수는 끝이 0일 때만 받는다(`"10.990"` → 1099, `"10.999"`는 RangeError).
100
+ * 형식이 틀리면 RangeError다 — 금액을 추정하지 않는다.
101
+ */
102
+ export function fromDecimalString(value: string, currency: string): number {
103
+ const { digits } = currencySpecOf(currency);
104
+ const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(value.trim());
105
+ if (!match) throw new RangeError(`invalid decimal amount: ${value}`);
106
+ const [, sign, whole, fraction = ""] = match;
107
+ const extra = fraction.slice(digits);
108
+ if (/[^0]/.test(extra))
109
+ throw new RangeError(`too many fraction digits for ${currency}: ${value}`);
110
+ const minor = Number(`${whole}${fraction.slice(0, digits).padEnd(digits, "0")}`);
111
+ if (!Number.isSafeInteger(minor)) throw new RangeError(`amount out of range: ${value}`);
112
+ return sign === "-" && minor !== 0 ? -minor : minor;
113
+ }
114
+
115
+ /** 판매가 단위로 내림 — 할인·정률 쿠폰 계산의 단위 맞춤(KRW 10원 내림은 현행 규칙 그대로) */
116
+ export function floorToSaleUnit(amount: number, currency: string): number {
117
+ const { saleUnit } = currencySpecOf(currency);
118
+ return Math.floor(amount / saleUnit) * saleUnit;
119
+ }
120
+
121
+ /** 판매가 규칙(판매가 단위의 배수, 최소 판매가 단위)을 지키는가 */
122
+ export function isSaleUnitPrice(amount: number, currency: string): boolean {
123
+ const { saleUnit } = currencySpecOf(currency);
124
+ return Number.isInteger(amount) && amount >= saleUnit && amount % saleUnit === 0;
125
+ }
126
+
127
+ /** 판매가 규칙 위반 — api가 400 `INVALID_PRICE_UNIT`으로 바꾼다 */
128
+ export class InvalidPriceUnitError extends Error {
129
+ readonly code = "INVALID_PRICE_UNIT";
130
+ constructor(
131
+ readonly amount: number,
132
+ readonly currency: string,
133
+ readonly saleUnit: number,
134
+ ) {
135
+ super(
136
+ `sale price ${amount} ${currency} must be a multiple of ${saleUnit} and at least ${saleUnit}`,
137
+ );
138
+ this.name = "InvalidPriceUnitError";
139
+ }
140
+ }
141
+
142
+ /** 판매가 단위 검증의 원천 — zod `superRefine`과 api 상품 쓰기가 같이 부른다 */
143
+ export function assertSaleUnit(amount: number, currency: string): void {
144
+ if (!isSaleUnitPrice(amount, currency)) {
145
+ throw new InvalidPriceUnitError(amount, currency, currencySpecOf(currency).saleUnit);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * 최소 결제액 = max(통화표 값, 후보들). 후보는 PG 어댑터 선언의 하한(`descriptor.minAmount(currency)`, #131)이고 null은 「하한 없음」이다.
151
+ * 서버의 최소 결제 검사는 이 값 하나를 쓴다.
152
+ */
153
+ export function minPaymentOf(
154
+ currency: string,
155
+ candidates: ReadonlyArray<number | null | undefined> = [],
156
+ ): number {
157
+ let floor = currencySpecOf(currency).minPayment;
158
+ for (const candidate of candidates) {
159
+ if (candidate !== null && candidate !== undefined && candidate > floor) floor = candidate;
160
+ }
161
+ return floor;
162
+ }
163
+
164
+ /** 통화 규칙 값 — 순수 금액 함수가 상수 대신 입력으로 받는다. 생략하면 KRW 규칙이다(`KRW_MONEY_RULES`) */
165
+ export interface MoneyRules {
166
+ currency: string;
167
+ saleUnit: number;
168
+ minPayment: number;
169
+ maxOrderAmount: number;
170
+ }
171
+
172
+ /** 통화의 규칙 값. `minPayment`를 주면(서버가 `minPaymentOf`로 구한 값) 통화표 값 대신 쓴다 */
173
+ export function moneyRulesOf(currency: string, options: { minPayment?: number } = {}): MoneyRules {
174
+ const spec = currencySpecOf(currency);
175
+ return {
176
+ currency: spec.code,
177
+ saleUnit: spec.saleUnit,
178
+ minPayment: options.minPayment ?? spec.minPayment,
179
+ maxOrderAmount: spec.maxOrderAmount,
180
+ };
181
+ }
182
+
183
+ /** 기본 통화(KRW) 규칙 — 규칙 값을 넘기지 않는 옛 호출부의 기본값 */
184
+ export const KRW_MONEY_RULES: MoneyRules = moneyRulesOf(BASE_CURRENCY);
@@ -28,7 +28,12 @@ export const customerListQuerySchema = z.object({
28
28
  /** 구매 통계 — 구매 금액은 취소된 주문 상품을 빼고, 완료된 반품은 수량 비율로 뺀 금액이다 */
29
29
  export const customerStatsSchema = z.object({
30
30
  orderCount: z.number().int().describe("주문 수(취소 포함)"),
31
- totalPaid: z.number().int().describe("구매 금액(원) — 취소·완료된 반품 제외"),
31
+ totalPaid: z
32
+ .number()
33
+ .int()
34
+ .describe(
35
+ "구매 금액(원) — 취소·완료된 반품 제외. 원화 주문만 더한다(외화 주문은 환산하지 않고 빠진다)",
36
+ ),
32
37
  lastOrderedAt: z.string().nullable().describe("최근 주문 시각"),
33
38
  });
34
39
 
@@ -56,6 +61,20 @@ export const customerAddressSchema = z.object({
56
61
  zipCode: z.string(),
57
62
  address1: z.string(),
58
63
  address2: z.string().nullable(),
64
+ countryCode: z
65
+ .string()
66
+ .optional()
67
+ .default("KR")
68
+ .describe(
69
+ "국가(ISO 3166-1 alpha-2, 이슈 #130). 해외 주소의 `zipCode`는 그 나라 우편번호이고 없으면 빈 문자열이다",
70
+ ),
71
+ city: z.string().nullable().optional().default(null).describe("해외 주소의 도시. 국내는 null"),
72
+ region: z
73
+ .string()
74
+ .nullable()
75
+ .optional()
76
+ .default(null)
77
+ .describe("해외 주소의 주·도·현. 국내는 null"),
59
78
  });
60
79
 
61
80
  // ── 동의 이력 (이슈 #26) ────────────────────────────────────
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { shippingCurrencyPricesSchema, storedShippingCurrencyPriceSchema } from "./currency-prices";
2
3
 
3
4
  /**
4
5
  * 상품 유형 (이슈 #48·#57) — 배송이 필요한 상품과 필요 없는 상품을 가르는 축이다.
@@ -271,6 +272,7 @@ const shippingCreateSchema = z
271
272
  .describe("무료 배송이 되는 묶음 소계. 원 단위. CONDITIONAL_FREE에서만 받고 필수다"),
272
273
  shippingAddressId: z.string().nullable().optional().describe(ADDRESS_ID_DESCRIPTIONS.shipping),
273
274
  returnAddressId: z.string().nullable().optional().describe(ADDRESS_ID_DESCRIPTIONS.return),
275
+ prices: shippingCurrencyPricesSchema.optional(),
274
276
  })
275
277
  .superRefine(refineShippingConsistency);
276
278
 
@@ -304,6 +306,7 @@ const shippingUpdateSchema = z
304
306
  .describe("무료 배송이 되는 묶음 소계. CONDITIONAL_FREE면 필수, 아니면 null"),
305
307
  shippingAddressId: z.string().nullable().describe(ADDRESS_ID_DESCRIPTIONS.shipping),
306
308
  returnAddressId: z.string().nullable().describe(ADDRESS_ID_DESCRIPTIONS.return),
309
+ prices: shippingCurrencyPricesSchema.optional(),
307
310
  })
308
311
  .superRefine(refineShippingConsistency);
309
312
 
@@ -347,6 +350,10 @@ export const productFulfillmentSchema = z.discriminatedUnion("type", [
347
350
  .string()
348
351
  .nullable()
349
352
  .describe("반품지 주소 id. null이면 상점 기본 반품지를 따른다"),
353
+ prices: z
354
+ .array(storedShippingCurrencyPriceSchema)
355
+ .optional()
356
+ .describe("외화 배송비(이슈 #130). 관리 API 상품 조회만 싣는다"),
350
357
  }),
351
358
  methods: storedMethodsSchema,
352
359
  }),
@@ -476,6 +483,13 @@ export const pickupLocationSchema = z.object({
476
483
  hours: z.string().nullable().describe("수령 가능 시간 안내. 없으면 null"),
477
484
  usable: z.boolean().describe("사용 여부. false면 구매자에게 보이지 않고 주문서에서 고를 수 없다"),
478
485
  sortOrder: z.number().int(),
486
+ inventoryLocationId: z
487
+ .string()
488
+ .nullable()
489
+ .optional()
490
+ .describe(
491
+ "이 장소의 재고 위치 ID — 방문 수령 주문을 이 위치 재고로 판다. 위치를 하나만 쓰는 상점은 기본 위치다",
492
+ ),
479
493
  createdAt: z.string(),
480
494
  });
481
495
 
@@ -487,6 +501,11 @@ export const pickupLocationRequestSchema = z.strictObject({
487
501
  phone: z.string().trim().max(30).nullish(),
488
502
  hours: z.string().trim().max(100).nullish(),
489
503
  usable: z.boolean().default(true),
504
+ inventoryLocationId: z
505
+ .string()
506
+ .min(1)
507
+ .optional()
508
+ .describe("재고 위치 ID. 등록에서 생략하면 기본 위치, 수정에서 생략하면 지금 연결을 유지한다"),
490
509
  });
491
510
 
492
511
  export type PickupLocationRequest = z.infer<typeof pickupLocationRequestSchema>;