@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.
@@ -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(10)
637
- .multipleOf(10, "판매가는 10원 단위여야 합니다 (INVALID_PRICE_UNIT)")
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(10).optional(),
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()
@@ -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 = 100;
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원 단위 내림, 최대 할인액과 기준 금액을 넘지 않는다. 기준 금액이 0 이하면 0이다.
334
+ * 할인액 — 정률은 판매가 단위(`rules.saleUnit`, KRW 10원) 내림, 최대 할인액과 기준 금액을 넘지 않는다. 기준 금액이 0 이하면 0이다.
329
335
  *
330
- * 곱셈을 먼저 하고 나눈다. `base × 율 ÷ 1000`을 내려 10을 곱하면 `floor(base × 율 / 100 / 10) × 10`과 같고
331
- * 부동소수 오차가 끼지 않는다.
336
+ * 곱셈을 먼저 하고 나눈다. `base × 율 ÷ (100 × 단위)`를 내려 단위를 곱하면 `floor(base × 율 / 100 / 단위) × 단위`와 같고
337
+ * 부동소수 오차가 끼지 않는다(KRW는 지금의 `÷ 1000 × 10` 그대로).
332
338
  */
333
- export function couponDiscountOf(benefit: CouponBenefit, base: number): number {
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" ? Math.floor((base * benefit.value) / 1000) * 10 : benefit.value;
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(input.points ?? null, {
629
- productPaid: productWeights.reduce((sum, w) => sum + w, 0),
630
- deliveryPaid: bundleWeights.reduce((sum, w) => sum + w, 0),
631
- total: beforePoints,
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 < MIN_PAYMENT_AMOUNT) {
694
- return { amount: floorUnit(total - MIN_PAYMENT_AMOUNT), adjusted: true };
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
- return { ...store, ...(productRefundPolicyOverride(product) ?? {}) };
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
  /**
@@ -21,6 +21,10 @@ export const SCOPE_DESCRIPTIONS: Record<StoreScopeResource, { r: string; rw: str
21
21
  promotion: { r: "쿠폰·적립금 설정을 읽습니다", rw: "쿠폰을 발급하고 적립금을 지급합니다" },
22
22
  storefront: { r: "스토어프론트 설정을 읽습니다", rw: "스토어프론트를 편집하고 발행합니다" },
23
23
  flow: { r: "주문 흐름 설정을 읽습니다", rw: "주문 흐름을 만들고 수정합니다" },
24
+ inventory: {
25
+ r: "위치별 재고와 재고 변동 내역을 읽습니다",
26
+ rw: "재고를 조정·실사하고 위치 사이에서 이동합니다",
27
+ },
24
28
  };
25
29
 
26
30
  /** `{resource}:{r|rw}` → 문장. 카탈로그에 없는 스코프는 null */
@@ -33,6 +33,8 @@ export const STORE_SCOPE_RESOURCES = {
33
33
  storefront: ["r", "rw"],
34
34
  // 커머스 플로우 정의·할당(이슈 #115) — 주문 처리 행동은 order 스코프다
35
35
  flow: ["r", "rw"],
36
+ // 위치별 재고·재고 조정·이동·변동 내역(이슈 #132) — 기존 재고 표면(조합 재고 수정, 상품·옵션의 `stockQuantity`)은 product 스코프다
37
+ inventory: ["r", "rw"],
36
38
  } as const satisfies Record<string, readonly ScopeAccess[]>;
37
39
 
38
40
  export type StoreScopeResource = keyof typeof STORE_SCOPE_RESOURCES;
@@ -71,6 +73,7 @@ export const STORE_ROLE_SCOPES = {
71
73
  "promotion:rw",
72
74
  "storefront:rw",
73
75
  "flow:rw",
76
+ "inventory:rw",
74
77
  ],
75
78
  ADMIN: [
76
79
  "product:rw",
@@ -88,6 +91,7 @@ export const STORE_ROLE_SCOPES = {
88
91
  "promotion:rw",
89
92
  "storefront:rw",
90
93
  "flow:rw",
94
+ "inventory:rw",
91
95
  ],
92
96
  STAFF: [
93
97
  "product:rw",
@@ -99,5 +103,6 @@ export const STORE_ROLE_SCOPES = {
99
103
  "customer:r",
100
104
  "promotion:r",
101
105
  "flow:r",
106
+ "inventory:rw",
102
107
  ],
103
108
  } as const satisfies Record<StoreRole, readonly string[]>;
@@ -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
- /** 유효 정책 해석 — 상점이 정한 항목 + `SHIPPING_POLICY_DEFAULTS` */
76
- export function resolveShippingPolicy(patch?: ShippingPolicyPatch | null): ShippingPolicy {
77
- if (!patch) return { ...SHIPPING_POLICY_DEFAULTS };
78
- const given = Object.fromEntries(
79
- Object.entries(patch).filter(([, value]) => value !== undefined),
80
- ) as ShippingPolicyPatch;
81
- return { ...SHIPPING_POLICY_DEFAULTS, ...given };
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
  // ─── 지역 추가 배송비 ─────────────────────────────────────
@@ -461,6 +461,13 @@ export const sellerAddressSchema = z.object({
461
461
  zipCode: z.string(),
462
462
  address1: z.string(),
463
463
  address2: z.string().optional(),
464
+ inventoryLocationId: z
465
+ .string()
466
+ .nullable()
467
+ .optional()
468
+ .describe(
469
+ "이 주소의 재고 위치 ID — 출고지면 배송 주문을 이 위치 재고로 팔고, 반품지면 반품을 이 위치로 들인다. 위치를 하나만 쓰는 상점은 기본 위치다",
470
+ ),
464
471
  createdAt: z.string(),
465
472
  });
466
473
 
@@ -474,6 +481,11 @@ export const sellerAddressRequestSchema = z.object({
474
481
  zipCode: z.string().regex(/^[0-9]{5}$/),
475
482
  address1: z.string().min(1),
476
483
  address2: z.string().optional(),
484
+ inventoryLocationId: z
485
+ .string()
486
+ .min(1)
487
+ .optional()
488
+ .describe("재고 위치 ID. 등록에서 생략하면 기본 위치, 수정에서 생략하면 지금 연결을 유지한다"),
477
489
  });
478
490
 
479
491
  export type SellerAddressRequest = z.infer<typeof sellerAddressRequestSchema>;
@@ -50,6 +50,12 @@ export const orderWebhookEventDataSchema = z.object({
50
50
  "주문번호(이슈 #107) — 숫자 14자리. 이 필드가 없는 이전 이벤트와 번호 체계 이전 주문은 null로 읽는다",
51
51
  ),
52
52
  status: orderItemStatusSchema.describe("이벤트 발생 시점의 주문 대표 상태"),
53
+ currency: z
54
+ .string()
55
+ .optional()
56
+ .describe(
57
+ "주문 통화(ISO 4217, 이슈 #130). 이 이벤트의 금액은 모두 이 통화의 최소 단위다. 외화 주문이 생기면 이 값을 확인한다. 이 필드가 없는 이전 이벤트는 KRW다",
58
+ ),
53
59
  testPayment: z
54
60
  .boolean()
55
61
  .optional()
@@ -129,6 +135,12 @@ export const orderFulfilledWebhookEventDataSchema = z.object({
129
135
  '**Deprecated** — 항상 `{ mode: "MANUAL" }`이다. 자동 제공을 없앴고(이슈 #48) store-sdk 0.19 이하 호환으로만 싣는다. 게시된 연동이 store-sdk 0.20 이상으로 넘어간 뒤 다음 릴리스에서 뺀다',
130
136
  ),
131
137
  fulfilledAt: z.string().describe("제공 시각 (ISO 8601). 주문상품의 `deliveredAt`과 같다"),
138
+ currency: z
139
+ .string()
140
+ .optional()
141
+ .describe(
142
+ "주문 통화(ISO 4217, 이슈 #130). 이 이벤트의 금액은 모두 이 통화의 최소 단위다. 외화 주문이 생기면 이 값을 확인한다. 이 필드가 없는 이전 이벤트는 KRW다",
143
+ ),
132
144
  testPayment: z
133
145
  .boolean()
134
146
  .describe("테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았다"),
@@ -204,6 +216,12 @@ export const orderCanceledWebhookEventDataSchema = z.object({
204
216
  "돌려준 적립금 (원, #93) — 결제에 쓴 적립금 중 이번 취소·반품 수량의 몫이다. 현금 환불(`refundAmount`)과 별개다. " +
205
217
  "차감은 현금에서 먼저 빼고 모자란 몫만 여기서 뺀다. 적립금을 쓰지 않은 주문과 이 필드가 없는 이전 응답·이벤트는 0으로 읽는다",
206
218
  ),
219
+ currency: z
220
+ .string()
221
+ .optional()
222
+ .describe(
223
+ "주문 통화(ISO 4217, 이슈 #130). 이 이벤트의 금액은 모두 이 통화의 최소 단위다. 외화 주문이 생기면 이 값을 확인한다. 이 필드가 없는 이전 이벤트는 KRW다",
224
+ ),
207
225
  testPayment: z
208
226
  .boolean()
209
227
  .describe("테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았다"),
@@ -292,6 +310,12 @@ export const claimWebhookEventDataSchema = z.object({
292
310
  "돌려준 적립금 (원, #93) — 결제에 쓴 적립금 중 이번 취소·반품 수량의 몫이다. 현금 환불(`refundAmount`)과 별개다. " +
293
311
  "차감은 현금에서 먼저 빼고 모자란 몫만 여기서 뺀다. 적립금을 쓰지 않은 주문과 이 필드가 없는 이전 응답·이벤트는 0으로 읽는다",
294
312
  ),
313
+ currency: z
314
+ .string()
315
+ .optional()
316
+ .describe(
317
+ "주문 통화(ISO 4217, 이슈 #130). 이 이벤트의 금액은 모두 이 통화의 최소 단위다. 외화 주문이 생기면 이 값을 확인한다. 이 필드가 없는 이전 이벤트는 KRW다",
318
+ ),
295
319
  testPayment: z
296
320
  .boolean()
297
321
  .optional()
@@ -320,11 +344,55 @@ export const productOutOfStockWebhookEventDataSchema = z.object({
320
344
  variantId: z
321
345
  .string()
322
346
  .describe("재고가 0이 된 판매 단위(variant) ID. 주문 상품의 `optionId`와 같은 값 공간이다"),
347
+ inventoryLocationId: z
348
+ .string()
349
+ .optional()
350
+ .describe(
351
+ "재고가 0이 된 재고 위치 ID. 품절은 위치별 가용 재고의 0 전이다. 위치를 하나만 쓰는 상점은 기본 위치다(옛 이벤트에는 없다)",
352
+ ),
323
353
  });
324
354
  export type ProductOutOfStockWebhookEventData = z.infer<
325
355
  typeof productOutOfStockWebhookEventDataSchema
326
356
  >;
327
357
 
358
+ /**
359
+ * 위치별 재고 변화(이슈 #132) — 재고 변동 내역 한 행마다 한 건이다. 같은 위치·조합의 순서는 `levelVersion`(빈 번호 없음)으로 맞추고,
360
+ * 상점 전체 순서는 `movementSeq`(재고 변동 내역 조회의 `after` 커서와 같은 값)다. 자기가 한 조정의 반향은 `actorType`·`actorId`·`operationId`로 거른다.
361
+ * 출고 대기·실재고(상태 전이) 변화는 이벤트를 내지 않는다 — 필요하면 위치별 재고 조회를 다시 읽는다
362
+ */
363
+ export const inventoryChangedWebhookEventDataSchema = z.object({
364
+ productId: z.string().describe("상품 ID"),
365
+ variantId: z
366
+ .string()
367
+ .describe("판매 단위(variant) ID. 주문 상품의 `optionId`와 같은 값 공간이다"),
368
+ inventoryLocationId: z.string().describe("재고 위치 ID"),
369
+ available: z.number().int().describe("변화 뒤 가용 재고"),
370
+ delta: z.number().int().describe("가용 재고 변화량(0이 아니다)"),
371
+ kind: z
372
+ .string()
373
+ .describe(
374
+ "변동 종류 — `OPENING`·`SALE`·`RESTORE`·`REVERT`·`EXCHANGE_OUT`·`ADJUST`·`COUNT`·`TRANSFER_OUT`·`TRANSFER_IN`·`REMOVED`. 값이 늘 수 있다",
375
+ ),
376
+ reason: z.string().nullable().describe("조정·실사 사유 코드. 없으면 null"),
377
+ levelVersion: z
378
+ .number()
379
+ .int()
380
+ .describe("위치별 재고 행의 버전 — 같은 위치·조합 안에서 빈 번호 없이 오른다"),
381
+ movementSeq: z.number().int().describe("상점별 재고 변동 내역 순번"),
382
+ actorType: z.string().describe("행위자 유형 — `seller`·`app`·`agent`·`system`"),
383
+ actorId: z.string().nullable().describe("행위자 ID. 시스템이면 null"),
384
+ refType: z
385
+ .string()
386
+ .nullable()
387
+ .describe("출처 유형 — `order_item`·`claim`·`operation`. 없으면 null"),
388
+ refId: z.string().nullable().describe("출처 ID"),
389
+ operationId: z.string().nullable().describe("재고 조정·이동 요청 ID. 조정·이동이 아니면 null"),
390
+ occurredAt: z.string().describe("변화 시각"),
391
+ });
392
+ export type InventoryChangedWebhookEventData = z.infer<
393
+ typeof inventoryChangedWebhookEventDataSchema
394
+ >;
395
+
328
396
  export const orderWebhookEventSchema = z.object({
329
397
  ...envelopeFields,
330
398
  type: z.enum([
@@ -380,6 +448,12 @@ export const productOutOfStockWebhookEventSchema = z.object({
380
448
  data: productOutOfStockWebhookEventDataSchema,
381
449
  });
382
450
 
451
+ export const inventoryChangedWebhookEventSchema = z.object({
452
+ ...envelopeFields,
453
+ type: z.literal("INVENTORY.CHANGED"),
454
+ data: inventoryChangedWebhookEventDataSchema,
455
+ });
456
+
383
457
  /**
384
458
  * 플로우 알림(이슈 #115) — 상점이 플로우에 둔 알림 블록(`core.notify`, 채널 `webhook`)이 보낸다. 구매자 알림(알림톡·메일)은 이 이벤트를 받아
385
459
  * 상점이 보낸다(플랫폼은 구매자에게 직접 보내지 않는다). 개인정보는 싣지 않는다
@@ -436,6 +510,7 @@ export const webhookEventSchema = z
436
510
  productOutOfStockWebhookEventSchema,
437
511
  flowNotificationWebhookEventSchema,
438
512
  flowStateChangedWebhookEventSchema,
513
+ inventoryChangedWebhookEventSchema,
439
514
  ])
440
515
  .describe(
441
516
  "웹훅 요청 본문 (얇은 이벤트). 개인정보를 싣지 않으며 `data`의 id로 관리 API를 조회한다",