@sayren/mcp 0.2.2 → 0.2.3

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/mcp",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sayren-mcp": "./dist/index.mjs"
@@ -12,8 +12,8 @@
12
12
  "dependencies": {
13
13
  "@modelcontextprotocol/sdk": "^1.22.0",
14
14
  "zod": "^4.6.5",
15
- "@sayren/store-sdk": "^0.10.0",
16
- "@sayren/storefront-sdk": "^0.10.0"
15
+ "@sayren/storefront-sdk": "^0.11.0",
16
+ "@sayren/store-sdk": "^0.11.0"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@biomejs/biome": "^2.5.14",
@@ -7,8 +7,8 @@
7
7
  "zod": "^4.6.5"
8
8
  },
9
9
  "versions": {
10
- "@sayren/store-sdk": "0.10.0",
11
- "@sayren/storefront-sdk": "0.10.0",
10
+ "@sayren/store-sdk": "0.11.0",
11
+ "@sayren/storefront-sdk": "0.11.0",
12
12
  "@sayren/ui": "0.1.0"
13
13
  }
14
14
  }
@@ -82,6 +82,12 @@ EU 등 분석 쿠키에 사전 동의가 필요한 지역의 구매자를 받는
82
82
  - 주문서(`/checkout`)는 들어올 때마다 주문서 세션을 새로 만든다. 조회 API가 없고, 가격과 재고를
83
83
  매번 다시 계산해야 하기 때문이다.
84
84
  - 비회원 장바구니는 서버가 발급한 토큰으로만 찾는다. 쿠키를 지우면 담은 상품이 사라진다.
85
+ - 배송비는 배송 묶음마다 한 번 붙는다. 같은 출고지 + 같은 배송 정책인 상품이 한 묶음이고, 제주·도서산간 추가
86
+ 배송비는 묶음마다 더하며 무료배송이어도 부과한다. 주문서를 만들 때는 배송지를 몰라 주문서의 `delivery.zipCode`가
87
+ null이고 추가 배송비가 0이다 — 주문서(`/checkout`)는 우편번호 입력을 마칠 때(blur) `quoteDelivery` 서버 함수로
88
+ `checkout.quoteDelivery(checkoutId, { zipCode })`를 불러 금액 요약을 갱신한다(주문서를 바꾸지 않는 읽기 계산이다).
89
+ **최종 금액은 결제 시작 응답의 `amounts`·`delivery`다** — 서버가 결제 시작이 보낸 배송지로 배송비를 다시 계산하므로
90
+ 미리보기와 다를 수 있고, 화면은 그 값으로 덮어쓴다.
85
91
  - 취소·반품은 수량 단위다. 주문 상세(`/orders/$orderId`)의 신청 수량 상한은 주문 상품의 `activeQuantity`
86
92
  (남은 수량)이고, 접수 결과의 `expectedRefundAmount`는 수량 비율 상품 금액에서 반품 배송비를 뺀 값이다.
87
93
  배송비 환불은 `deliveryFeeRefundAmount`로 따로 온다. 교환은 희망 옵션(`exchangeOptionId`)이 필요해 이 화면에
@@ -1,5 +1,9 @@
1
1
  import {
2
2
  ApiError,
3
+ type CheckoutDelivery,
4
+ cashReceiptAvailable,
5
+ cashReceiptRequestSchema,
6
+ type DeliveryQuoteResult,
3
7
  paymentOptionKey,
4
8
  paymentOptionSchema,
5
9
  paymentStartSchema,
@@ -47,6 +51,23 @@ const createCheckout = createServerFn({ method: "POST" })
47
51
  return { checkout, member, config };
48
52
  });
49
53
 
54
+ /**
55
+ * 배송비 미리보기 — 주문서를 만들 때는 배송지를 몰라 제주·도서산간 추가 배송비가 빠져 있다.
56
+ * 우편번호를 받으면 다시 계산해 보여 준다(주문서를 바꾸지 않는 읽기 계산이다).
57
+ * 결제 시작과 마찬가지로 서버 함수로 부른다 — 구매자 토큰이 브라우저 JS에 나가지 않는다.
58
+ */
59
+ const quoteDelivery = createServerFn({ method: "POST" })
60
+ .validator(z.object({ checkoutId: z.string(), zipCode: z.string() }))
61
+ .handler(async ({ data }) => {
62
+ const api = apiFor({ accessToken: readToken(), cartToken: readCartToken() });
63
+ try {
64
+ const quote = await api.checkout.quoteDelivery(data.checkoutId, { zipCode: data.zipCode });
65
+ return { quote, error: null };
66
+ } catch {
67
+ return { quote: null, error: "배송비를 확인하지 못했어요. 우편번호를 다시 확인해주세요." };
68
+ }
69
+ });
70
+
50
71
  const paymentForm = z.object({
51
72
  checkoutId: z.string(),
52
73
  option: paymentOptionSchema,
@@ -61,6 +82,8 @@ const paymentForm = z.object({
61
82
  guestName: z.string(),
62
83
  email: z.string(),
63
84
  orderPassword: z.string(),
85
+ /** 현금영수증 신청 — 계좌이체·가상계좌에서 구매자가 켰을 때만 */
86
+ cashReceipt: cashReceiptRequestSchema.optional(),
64
87
  });
65
88
 
66
89
  /** 결제 시작은 서버에서 한다 — 토큰이 브라우저 JS에 노출되지 않는다. 결제창 호출 값(공개 값)만 내려준다 */
@@ -92,6 +115,7 @@ const startPayment = createServerFn({ method: "POST" })
92
115
  email: data.email,
93
116
  orderPassword: data.orderPassword,
94
117
  },
118
+ cashReceipt: data.cashReceipt,
95
119
  });
96
120
  // 결제 시작 값은 JSON으로 넘기고 브라우저에서 SDK 스키마로 다시 읽는다 — 서버 함수 직렬화 타입에 맞춘다
97
121
  return { start: JSON.stringify(start), error: null };
@@ -109,6 +133,8 @@ function startErrorMessage(error: unknown): string {
109
133
  return "결제창을 준비하지 못했어요. 다른 결제수단으로 시도해주세요.";
110
134
  case "PAYMENT_NOT_CONFIGURED":
111
135
  return "결제 수단을 준비하고 있어요. 잠시 후 다시 시도해주세요.";
136
+ case "CASH_RECEIPT_NOT_AVAILABLE":
137
+ return "현금영수증은 계좌이체·가상계좌 결제에서만 신청할 수 있어요.";
112
138
  case "RETURN_URL_NOT_ALLOWED":
113
139
  return "이 쇼핑몰 주소에서는 결제할 수 없어요. 셀러 콘솔 설정 › 결제에서 결제 도메인을 확인해주세요.";
114
140
  default:
@@ -131,6 +157,38 @@ function Checkout() {
131
157
  const [submitting, setSubmitting] = useState(false);
132
158
  const options = checkout.paymentOptions;
133
159
  const [selected, setSelected] = useState(options[0] ? paymentOptionKey(options[0]) : "");
160
+ /**
161
+ * 배송지를 반영한 금액. 배송비 미리보기와 결제 시작 응답이 덮어쓴다 — 최종 금액은 결제 시작 응답의 `amounts`다
162
+ * (서버가 보낸 배송지로 배송비를 다시 계산한다).
163
+ */
164
+ const [quote, setQuote] = useState<DeliveryQuoteResult | null>(null);
165
+ /** 같은 우편번호로 blur가 반복돼도 다시 부르지 않는다 */
166
+ const [quotedZip, setQuotedZip] = useState<string | null>(null);
167
+ const amounts = quote?.amounts ?? checkout.amounts;
168
+ const delivery = quote?.delivery ?? checkout.delivery;
169
+
170
+ // 디바운스 타이머 대신 blur에서 부른다 — 우편번호는 5자리를 한 번에 채우는 값이고, FormData 폼에
171
+ // 입력 상태를 따로 들지 않아도 된다
172
+ const handleZipBlur = (zipCode: string) => {
173
+ if (!/^[0-9]{5}$/.test(zipCode) || zipCode === quotedZip) return;
174
+ setQuotedZip(zipCode);
175
+ void quoteDelivery({ data: { checkoutId: checkout.checkoutId, zipCode } })
176
+ .then(({ quote: next, error }) => {
177
+ if (next) setQuote(next);
178
+ else {
179
+ setQuotedZip(null);
180
+ setNotice(error);
181
+ }
182
+ })
183
+ .catch(() => setQuotedZip(null));
184
+ };
185
+ const [cashReceiptOn, setCashReceiptOn] = useState(false);
186
+ const [cashReceiptType, setCashReceiptType] = useState<"INCOME_DEDUCTION" | "EXPENSE_PROOF">(
187
+ "INCOME_DEDUCTION",
188
+ );
189
+ const selectedOption = options.find((item) => paymentOptionKey(item) === selected);
190
+ // 현금영수증은 계좌이체·가상계좌에서만 받는다
191
+ const cashReceiptShown = !!selectedOption && cashReceiptAvailable(selectedOption.method);
134
192
 
135
193
  const settle = (result: PaymentResult, back: string) => {
136
194
  if (result.status === "COMPLETED") {
@@ -168,6 +226,18 @@ function Checkout() {
168
226
  }
169
227
  const form = new FormData(event.currentTarget);
170
228
  const field = (name: string) => String(form.get(name) ?? "");
229
+ let cashReceipt: z.infer<typeof cashReceiptRequestSchema> | undefined;
230
+ if (cashReceiptShown && cashReceiptOn) {
231
+ const parsed = cashReceiptRequestSchema.safeParse({
232
+ type: cashReceiptType,
233
+ identityNumber: field("cashReceiptNumber"),
234
+ });
235
+ if (!parsed.success) {
236
+ setNotice(parsed.error.issues[0]?.message ?? "현금영수증 번호를 확인해주세요.");
237
+ return;
238
+ }
239
+ cashReceipt = parsed.data;
240
+ }
171
241
  const back = window.location.pathname + window.location.search;
172
242
  const payments = paymentsFor(config);
173
243
  // 결제창은 클릭 시점에 열어야 팝업 차단에 걸리지 않는다 — 서버 함수를 기다리기 전에 빈 창을 먼저 연다
@@ -188,6 +258,7 @@ function Checkout() {
188
258
  guestName: field("guestName"),
189
259
  email: field("email"),
190
260
  orderPassword: field("orderPassword"),
261
+ cashReceipt,
191
262
  },
192
263
  })
193
264
  .then(async ({ start, error }) => {
@@ -196,13 +267,11 @@ function Checkout() {
196
267
  setNotice(error);
197
268
  return;
198
269
  }
270
+ const paymentStart = paymentStartSchema.parse(JSON.parse(start));
271
+ // 확정 금액 — 서버가 보낸 배송지로 배송비를 다시 계산했다. 화면에 보여 준 금액과 다를 수 있다
272
+ setQuote({ amounts: paymentStart.amounts, delivery: paymentStart.delivery });
199
273
  // 결제 서비스가 결제창을 그린다. 팝업이면 결과를 여기서 받고, 리다이렉트면 이 탭이 떠난다
200
- settle(
201
- await payments.open(paymentStartSchema.parse(JSON.parse(start)), {
202
- window: paymentWindow,
203
- }),
204
- back,
205
- );
274
+ settle(await payments.open(paymentStart, { window: paymentWindow }), back);
206
275
  })
207
276
  .catch(() => {
208
277
  paymentWindow.close();
@@ -218,7 +287,14 @@ function Checkout() {
218
287
  <h2 className="font-bold text-lg">배송지</h2>
219
288
  <Field label="수령인" name="receiverName" defaultValue={member?.name ?? ""} required />
220
289
  <Field label="연락처" name="phone" placeholder="01012345678" required />
221
- <Field label="우편번호" name="zipCode" placeholder="04524" required />
290
+ <Field
291
+ label="우편번호"
292
+ name="zipCode"
293
+ placeholder="04524"
294
+ required
295
+ onBlur={handleZipBlur}
296
+ hint="입력하면 지역 추가 배송비까지 반영한 배송비를 보여 줘요"
297
+ />
222
298
  <Field label="주소" name="address1" required />
223
299
  <Field label="상세 주소" name="address2" />
224
300
  <Field label="배송 메모" name="deliveryMemo" />
@@ -272,11 +348,52 @@ function Checkout() {
272
348
  )}
273
349
  </fieldset>
274
350
 
351
+ {cashReceiptShown ? (
352
+ <fieldset className="space-y-3">
353
+ <legend className="mb-3 font-bold text-lg">현금영수증</legend>
354
+ <label className="flex items-center gap-2 text-sm">
355
+ <input
356
+ type="checkbox"
357
+ checked={cashReceiptOn}
358
+ onChange={(event) => setCashReceiptOn(event.target.checked)}
359
+ />
360
+ 현금영수증 신청
361
+ </label>
362
+ {cashReceiptOn ? (
363
+ <>
364
+ <label className="block space-y-1">
365
+ <span className="text-sm">용도</span>
366
+ <select
367
+ name="cashReceiptType"
368
+ value={cashReceiptType}
369
+ onChange={(event) =>
370
+ setCashReceiptType(event.target.value as typeof cashReceiptType)
371
+ }
372
+ className="w-full rounded-md border border-line px-3 py-2"
373
+ >
374
+ <option value="INCOME_DEDUCTION">소득공제</option>
375
+ <option value="EXPENSE_PROOF">지출증빙</option>
376
+ </select>
377
+ </label>
378
+ <Field
379
+ label={
380
+ cashReceiptType === "EXPENSE_PROOF"
381
+ ? "사업자등록번호"
382
+ : "휴대폰 번호 또는 현금영수증카드 번호"
383
+ }
384
+ name="cashReceiptNumber"
385
+ required
386
+ />
387
+ </>
388
+ ) : null}
389
+ </fieldset>
390
+ ) : null}
391
+
275
392
  <SubmitButton
276
393
  disabled={submitting || options.length === 0}
277
394
  className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
278
395
  >
279
- {formatPrice(checkout.amounts.totalAmount)} 결제하기
396
+ {formatPrice(amounts.totalAmount)} 결제하기
280
397
  </SubmitButton>
281
398
  </form>
282
399
 
@@ -300,13 +417,25 @@ function Checkout() {
300
417
  ))}
301
418
  </ul>
302
419
  <dl className="space-y-1 border-line border-t pt-3 text-sm">
303
- <Row label="상품 금액" value={formatPrice(checkout.amounts.productAmount)} />
304
- <Row label="배송비" value={formatPrice(checkout.amounts.deliveryFee)} />
420
+ <Row label="상품 금액" value={formatPrice(amounts.productAmount)} />
421
+ {/* 배송비는 배송 묶음(같은 출고지 + 같은 배송 정책)마다 한 번 붙고, 지역 추가 배송비는 묶음마다 더한다 */}
422
+ <Row label="기본 배송비" value={formatPrice(delivery.baseFee)} />
423
+ {delivery.remoteSurcharge > 0 ? (
424
+ <Row
425
+ label={`${delivery.remoteAreaLabel ?? "지역"} 추가 배송비`}
426
+ value={formatPrice(delivery.remoteSurcharge)}
427
+ />
428
+ ) : null}
305
429
  <div className="flex justify-between border-line border-t pt-2 font-bold">
306
430
  <dt>총 결제금액</dt>
307
- <dd>{formatPrice(checkout.amounts.totalAmount)}</dd>
431
+ <dd>{formatPrice(amounts.totalAmount)}</dd>
308
432
  </div>
309
433
  </dl>
434
+ {deliveryNotes(delivery).map((note) => (
435
+ <p key={note} className="text-muted text-xs">
436
+ {note}
437
+ </p>
438
+ ))}
310
439
  {checkout.testPayment ? (
311
440
  <p className="rounded-md bg-chip px-3 py-2 text-xs">
312
441
  테스트 결제예요. 실제로 돈이 오가지 않아요.
@@ -318,6 +447,17 @@ function Checkout() {
318
447
  );
319
448
  }
320
449
 
450
+ /** 배송비를 몇 번, 왜 부과했는지 알려 주는 안내 문구 */
451
+ function deliveryNotes(delivery: CheckoutDelivery): string[] {
452
+ const notes: string[] = [];
453
+ if (delivery.bundleCount >= 2) {
454
+ notes.push(`배송 묶음 ${delivery.bundleCount}건 — 묶음마다 배송비가 붙어요.`);
455
+ }
456
+ if (delivery.freeByThreshold) notes.push("무료배송 기준 금액을 넘겨 기본 배송비가 빠졌어요.");
457
+ if (delivery.zipCode === null) notes.push("배송지를 입력하면 배송비가 확정돼요.");
458
+ return notes;
459
+ }
460
+
321
461
  function Field({
322
462
  label,
323
463
  name,
@@ -326,6 +466,7 @@ function Field({
326
466
  placeholder,
327
467
  required,
328
468
  hint,
469
+ onBlur,
329
470
  }: {
330
471
  label: string;
331
472
  name: string;
@@ -334,6 +475,7 @@ function Field({
334
475
  placeholder?: string;
335
476
  required?: boolean;
336
477
  hint?: string;
478
+ onBlur?: (value: string) => void;
337
479
  }) {
338
480
  return (
339
481
  <label className="block space-y-1">
@@ -348,6 +490,7 @@ function Field({
348
490
  defaultValue={defaultValue}
349
491
  placeholder={placeholder}
350
492
  required={required}
493
+ onBlur={onBlur ? (event) => onBlur(event.target.value) : undefined}
351
494
  className="h-11 w-full rounded-md border border-line px-3"
352
495
  />
353
496
  {hint ? <span className="text-muted text-xs">{hint}</span> : null}