@sayren/storefront-sdk 0.7.0 → 0.8.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.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { a as ANALYTICS_VISITOR_HEADER, c as analyticsClientEventSchema, i as ANALYTICS_VISITOR_COOKIE, l as analyticsIdSchema, n as ANALYTICS_SESSION_COOKIE, o as analyticsBatchSchema, r as ANALYTICS_SESSION_HEADER, s as analyticsClientEventNameSchema, t as ANALYTICS_MAX_BATCH_BYTES, u as analyticsServerEventNameSchema } from "./analytics-CgD70OqH.mjs";
2
2
  import { t as analyticsIdsFromCookie } from "./analytics-ids-COElZiI3.mjs";
3
- import { $ as pageSchema, A as createClaimResultSchema, B as paymentStatusSchema, C as publicInquirySchema, D as claimReasonSchema, E as customerInquirySchema, F as confirmPaymentResultSchema, G as productCardSchema, H as requestPaymentRequestSchema, I as createCheckoutRequestSchema, J as productPageSchema, K as productDetailSchema, L as guestInfoSchema, M as PAYMENT_STATUS_VALUES, N as checkoutSessionSchema, O as claimTypeSchema, P as confirmPaymentRequestSchema, Q as pageInfoSchema, R as paymentMethodSchema, S as createInquiryResultSchema, T as customerInquiryCategorySchema, U as shippingAddressInputSchema, V as pgParamsSchema, W as categoryNodeSchema, X as claimStatusSchema, Y as productSortSchema, Z as orderItemStatusSchema, _ as memberAddressSchema, _t as buildQuery, a as publicReviewSchema, at as idpAuthorizeRequestSchema, b as wishlistAddResultSchema, c as updateReviewRequestSchema, ct as linkIdentityRequestSchema, d as deliveryTrackingSchema, dt as signupRequestSchema, et as addCartItemRequestSchema, f as myOrderItemSchema, ft as socialProviderSchema, g as memberAddressRequestSchema, gt as ApiError, h as purchaseDecisionResultSchema, ht as withdrawalBlockedDetailsSchema, i as createReviewResultSchema, it as anonymousSessionSchema, j as myClaimSchema, k as createClaimRequestSchema, l as updateReviewResultSchema, lt as loginRequestSchema, m as orderShippingAddressSchema, mt as withdrawRequestSchema, n as storefrontStoreSchema, nt as cartSchema, o as reviewPageSchema, ot as idpAuthorizeResponseSchema, p as myOrderSchema, pt as tokenPairSchema, q as productFacetsSchema, r as createReviewRequestSchema, rt as updateCartItemRequestSchema, s as reviewSummarySchema, st as idpTokenRequestSchema, t as createStorefrontClient, tt as cartItemSchema, u as writableReviewSchema, ut as memberIdentitiesSchema, v as memberSchema, vt as toApiError, w as createCustomerInquiryRequestSchema, x as createInquiryRequestSchema, y as updateProfileRequestSchema, yt as unwrapData, z as paymentParamsSchema } from "./client-B1uXGefY.mjs";
3
+ import { $ as signupRequestSchema, A as createClaimResultSchema, B as pageInfoSchema, C as publicInquirySchema, D as claimReasonSchema, E as customerInquirySchema, F as productFacetsSchema, G as updateCartItemRequestSchema, H as addCartItemRequestSchema, I as productPageSchema, J as idpAuthorizeResponseSchema, K as anonymousSessionSchema, L as productSortSchema, M as categoryNodeSchema, N as productCardSchema, O as claimTypeSchema, P as productDetailSchema, Q as memberIdentitiesSchema, R as claimStatusSchema, S as createInquiryResultSchema, T as customerInquiryCategorySchema, U as cartItemSchema, V as pageSchema, W as cartSchema, X as linkIdentityRequestSchema, Y as idpTokenRequestSchema, Z as loginRequestSchema, _ as memberAddressSchema, a as publicReviewSchema, at as buildQuery, b as wishlistAddResultSchema, c as updateReviewRequestSchema, d as deliveryTrackingSchema, et as socialProviderSchema, f as myOrderItemSchema, g as memberAddressRequestSchema, h as purchaseDecisionResultSchema, i as createReviewResultSchema, it as ApiError, j as myClaimSchema, k as createClaimRequestSchema, l as updateReviewResultSchema, m as orderShippingAddressSchema, n as storefrontStoreSchema, nt as withdrawRequestSchema, o as reviewPageSchema, ot as toApiError, p as myOrderSchema, q as idpAuthorizeRequestSchema, r as createReviewRequestSchema, rt as withdrawalBlockedDetailsSchema, s as reviewSummarySchema, st as unwrapData, t as createStorefrontClient, tt as tokenPairSchema, u as writableReviewSchema, v as memberSchema, w as createCustomerInquiryRequestSchema, x as createInquiryRequestSchema, y as updateProfileRequestSchema, z as orderItemStatusSchema } from "./client-DwqExvZN.mjs";
4
+ import { _ as pgProviderSchema, a as createCheckoutRequestSchema, b as startPaymentRequestSchema, c as guestInfoSchema, d as paymentMethodSchema, f as paymentOptionKey, g as paymentStatusSchema, h as paymentStartSchema, i as checkoutSessionSchema, l as paymentFailureCategorySchema, m as paymentOptionSchema, n as PAYMENT_STATUS_VALUES, o as easyPayProviderResponseSchema, p as paymentOptionListSchema, r as availablePaymentOptionSchema, s as easyPayProviderSchema, t as PAYMENT_RESULT_VALUES, u as paymentMessageSchema, v as retryPaymentRequestSchema, y as shippingAddressInputSchema } from "./checkout-JFk3b8RG.mjs";
4
5
  //#region src/session.ts
5
6
  /** 세션 키 상수 — SDK가 소유한다. store-sdk와 겹치지 않는 별도 네임스페이스. */
6
7
  const STOREFRONT_SESSION_KEYS = {
@@ -124,4 +125,4 @@ function createStorefrontSession(options) {
124
125
  };
125
126
  }
126
127
  //#endregion
127
- export { ANALYTICS_MAX_BATCH_BYTES, ANALYTICS_SESSION_COOKIE, ANALYTICS_SESSION_HEADER, ANALYTICS_VISITOR_COOKIE, ANALYTICS_VISITOR_HEADER, ApiError, PAYMENT_STATUS_VALUES, STOREFRONT_SESSION_KEYS, addCartItemRequestSchema, analyticsBatchSchema, analyticsClientEventNameSchema, analyticsClientEventSchema, analyticsIdSchema, analyticsIdsFromCookie, analyticsServerEventNameSchema, anonymousSessionSchema, buildQuery, cartItemSchema, cartSchema, categoryNodeSchema, checkoutSessionSchema, claimReasonSchema, claimStatusSchema, claimTypeSchema, confirmPaymentRequestSchema, confirmPaymentResultSchema, createCheckoutRequestSchema, createClaimRequestSchema, createClaimResultSchema, createCustomerInquiryRequestSchema, createInquiryRequestSchema, createInquiryResultSchema, createReviewRequestSchema, createReviewResultSchema, createStorefrontClient, createStorefrontSession, customerInquiryCategorySchema, customerInquirySchema, deliveryTrackingSchema, guestInfoSchema, idpAuthorizeRequestSchema, idpAuthorizeResponseSchema, idpTokenRequestSchema, linkIdentityRequestSchema, loginRequestSchema, memberAddressRequestSchema, memberAddressSchema, memberIdentitiesSchema, memberSchema, myClaimSchema, myOrderItemSchema, myOrderSchema, orderItemStatusSchema, orderShippingAddressSchema, pageInfoSchema, pageSchema, paymentMethodSchema, paymentParamsSchema, paymentStatusSchema, pgParamsSchema, productCardSchema, productDetailSchema, productFacetsSchema, productPageSchema, productSortSchema, publicInquirySchema, publicReviewSchema, purchaseDecisionResultSchema, requestPaymentRequestSchema, reviewPageSchema, reviewSummarySchema, shippingAddressInputSchema, signupRequestSchema, socialProviderSchema, storefrontStoreSchema, tokenPairSchema, updateCartItemRequestSchema, updateProfileRequestSchema, updateReviewRequestSchema, updateReviewResultSchema, wishlistAddResultSchema, withdrawRequestSchema, withdrawalBlockedDetailsSchema, writableReviewSchema };
128
+ export { ANALYTICS_MAX_BATCH_BYTES, ANALYTICS_SESSION_COOKIE, ANALYTICS_SESSION_HEADER, ANALYTICS_VISITOR_COOKIE, ANALYTICS_VISITOR_HEADER, ApiError, PAYMENT_RESULT_VALUES, PAYMENT_STATUS_VALUES, STOREFRONT_SESSION_KEYS, addCartItemRequestSchema, analyticsBatchSchema, analyticsClientEventNameSchema, analyticsClientEventSchema, analyticsIdSchema, analyticsIdsFromCookie, analyticsServerEventNameSchema, anonymousSessionSchema, availablePaymentOptionSchema, buildQuery, cartItemSchema, cartSchema, categoryNodeSchema, checkoutSessionSchema, claimReasonSchema, claimStatusSchema, claimTypeSchema, createCheckoutRequestSchema, createClaimRequestSchema, createClaimResultSchema, createCustomerInquiryRequestSchema, createInquiryRequestSchema, createInquiryResultSchema, createReviewRequestSchema, createReviewResultSchema, createStorefrontClient, createStorefrontSession, customerInquiryCategorySchema, customerInquirySchema, deliveryTrackingSchema, easyPayProviderResponseSchema, easyPayProviderSchema, guestInfoSchema, idpAuthorizeRequestSchema, idpAuthorizeResponseSchema, idpTokenRequestSchema, linkIdentityRequestSchema, loginRequestSchema, memberAddressRequestSchema, memberAddressSchema, memberIdentitiesSchema, memberSchema, myClaimSchema, myOrderItemSchema, myOrderSchema, orderItemStatusSchema, orderShippingAddressSchema, pageInfoSchema, pageSchema, paymentFailureCategorySchema, paymentMessageSchema, paymentMethodSchema, paymentOptionKey, paymentOptionListSchema, paymentOptionSchema, paymentStartSchema, paymentStatusSchema, pgProviderSchema, productCardSchema, productDetailSchema, productFacetsSchema, productPageSchema, productSortSchema, publicInquirySchema, publicReviewSchema, purchaseDecisionResultSchema, retryPaymentRequestSchema, reviewPageSchema, reviewSummarySchema, shippingAddressInputSchema, signupRequestSchema, socialProviderSchema, startPaymentRequestSchema, storefrontStoreSchema, tokenPairSchema, updateCartItemRequestSchema, updateProfileRequestSchema, updateReviewRequestSchema, updateReviewResultSchema, wishlistAddResultSchema, withdrawRequestSchema, withdrawalBlockedDetailsSchema, writableReviewSchema };
@@ -0,0 +1,166 @@
1
+ import { A as createStorefrontClient, Ht as PaymentOption, Pt as GuestInfo, Ut as PaymentStart, Wt as PaymentStatus, qt as ShippingAddressInput } from "../index-BRXStiK2.mjs";
2
+ //#region src/payments/index.d.ts
3
+ /**
4
+ * 스토어프론트 결제 — 브라우저 전용(`@sayren/storefront-sdk/payments`).
5
+ *
6
+ * ```ts
7
+ * const payments = createPayments({ client: sdk, returnUrl: `${location.origin}/checkout/return` });
8
+ * const result = await payments.start(checkoutId, { option, shippingAddress }); // 결제창(팝업)
9
+ * if (result.status === "COMPLETED") location.assign(`/orders/${result.orderId}`);
10
+ * ```
11
+ *
12
+ * 결제창은 결제 서비스(`payUrl`)가 그린다. SDK는 그 창을 팝업으로 열고 결과 메시지를 받아 결제 상태 조회로 확정한다.
13
+ * 모바일(또는 `mode: "redirect"`)에서는 현재 탭을 `payUrl`로 보내고, 결제가 끝나면 구매자가 `returnUrl`로 돌아온다 —
14
+ * 복귀 화면에서 {@link Payments.result}를 부른다.
15
+ *
16
+ * **`returnUrl`은 결제를 시작한 페이지와 같은 origin이어야 한다.** 결제 서비스는 팝업 결과 메시지를 복귀 주소의
17
+ * origin으로 보내므로, origin이 다르면 메시지가 도달하지 않고 결제 상태 조회로만 결과가 정해진다(느려질 뿐 결과는 같다).
18
+ *
19
+ * 팝업 메시지는 "끝났다"는 신호일 뿐이다. 결과도 구매자에게 보여 줄 안내 문구도 결제 상태 조회의 `result`·`lastFailure`가
20
+ * 원천이다 — 메시지의 코드·메시지는 읽지 않는다(누구나 보낼 수 있는 값이다).
21
+ */
22
+ type StorefrontClient = Pick<ReturnType<typeof createStorefrontClient>, "checkout" | "payments">;
23
+ /** 결제창을 여는 방식 — `auto`는 모바일이면 리다이렉트, 아니면 팝업이다 */
24
+ export type PaymentMode = "auto" | "popup" | "redirect";
25
+ /** `auto` 판정에 쓰는 기기 정보 */
26
+ export interface PaymentDevice {
27
+ userAgent: string;
28
+ /** `matchMedia("(pointer: coarse)")` */
29
+ coarsePointer: boolean;
30
+ viewportWidth: number;
31
+ }
32
+ /** 결제 결과 */
33
+ export type PaymentResult = {
34
+ status: "COMPLETED";
35
+ paymentId: string;
36
+ /** 만들어진 주문 번호 — 주문 완료 화면으로 보낸다 */
37
+ orderId: string;
38
+ } | {
39
+ /** 구매자가 결제창을 닫았다. 같은 결제를 다른 옵션으로 `retry`할 수 있다 */
40
+ status: "CANCELED";
41
+ paymentId: string;
42
+ code: string;
43
+ message: string;
44
+ } | {
45
+ /** 카드 거절·결제 오류 등. 같은 결제를 다른 옵션으로 `retry`할 수 있다 */
46
+ status: "FAILED";
47
+ paymentId: string;
48
+ code: string;
49
+ message: string;
50
+ } | {
51
+ /** 결과 확인 중 — `getStatus(paymentId)`로 확정될 때까지 조회한다 */
52
+ status: "PROCESSING";
53
+ paymentId: string;
54
+ } | {
55
+ /** 복귀 주소에 결제 정보가 없다(직접 연 주소 등) */
56
+ status: "INVALID";
57
+ message: string;
58
+ };
59
+ /** 결제 서비스 창 — 브라우저에서는 `window.open`이 돌려준 창이다 */
60
+ export interface PaymentPopup {
61
+ readonly closed: boolean;
62
+ /** `event.source` 대조용 창 참조 */
63
+ readonly ref: unknown;
64
+ /** 결제 서비스 주소로 보낸다 */
65
+ navigate(url: string): void;
66
+ close(): void;
67
+ }
68
+ /** 결제 서비스가 보낸 postMessage — 브라우저 `MessageEvent`의 필요한 부분만 */
69
+ export interface PaymentPostMessage {
70
+ data: unknown;
71
+ origin: string;
72
+ source: unknown;
73
+ }
74
+ /** 브라우저 의존부 — 테스트는 가짜를 넣는다 */
75
+ export interface PaymentsEnv {
76
+ /** 클릭 시점에 빈 팝업을 연다. 팝업 차단이면 null */
77
+ openPopup(): PaymentPopup | null;
78
+ /** 현재 창을 이동시킨다(리다이렉트 결제) */
79
+ navigate(url: string): void;
80
+ /** 결제 서비스 메시지 구독 — 해제 함수를 돌려준다 */
81
+ subscribe(listener: (event: PaymentPostMessage) => void): () => void;
82
+ /** 복귀 화면의 현재 주소 — `result()`의 기본값 */
83
+ currentUrl(): string;
84
+ device(): PaymentDevice;
85
+ /** 만료 판정에 쓰는 시계. 브라우저 시계가 서버보다 앞서면 팝업 대기가 일찍 끝난다(결과는 PROCESSING) */
86
+ now(): number;
87
+ setInterval(handler: () => void, ms: number): unknown;
88
+ clearInterval(handle: unknown): void;
89
+ }
90
+ /**
91
+ * 결제창을 팝업으로 열지 리다이렉트로 열지 정한다 — `auto` 판정 한 곳(순수 함수).
92
+ * 모바일은 팝업이 탭 전환으로 열리고 간편결제 앱을 거치며 `opener`를 잃으므로 리다이렉트를 쓴다.
93
+ */
94
+ export declare function resolvePaymentMode(mode: PaymentMode | undefined, device: PaymentDevice): "popup" | "redirect";
95
+ /**
96
+ * 결제 상태를 화면 결과로 옮긴다 — 결과의 원천은 언제나 `GET /payments/{id}`다(메시지는 신호일 뿐).
97
+ * 모르는 `result` 값은 확인 중으로 다룬다.
98
+ */
99
+ export declare function paymentResultOf(status: PaymentStatus, options?: {
100
+ popupClosed?: boolean;
101
+ }): PaymentResult;
102
+ export interface PaymentsOptions {
103
+ client: StorefrontClient;
104
+ /** 결제를 마치면 돌아올 주소(스토어 결제 도메인 안). `start`·`retry`에서 바꿀 수 있다 */
105
+ returnUrl?: string;
106
+ env?: PaymentsEnv;
107
+ }
108
+ export interface StartOptions {
109
+ option: PaymentOption;
110
+ shippingAddress: ShippingAddressInput;
111
+ guest?: GuestInfo;
112
+ returnUrl?: string;
113
+ mode?: PaymentMode;
114
+ /** 클릭 시점에 {@link Payments.prepareWindow}로 미리 연 창. 폼 검증이 비동기라 클릭 태스크를 넘길 때 쓴다 */
115
+ window?: PreparedPaymentWindow;
116
+ }
117
+ export interface RetryOptions {
118
+ option: PaymentOption;
119
+ /** 생략하면 결제 시작 때의 복귀 주소를 쓴다 */
120
+ returnUrl?: string;
121
+ mode?: PaymentMode;
122
+ /** 클릭 시점에 {@link Payments.prepareWindow}로 미리 연 창 */
123
+ window?: PreparedPaymentWindow;
124
+ }
125
+ /** {@link Payments.prepareWindow}가 돌려준 핸들임을 나타내는 표식 — 직접 만든 객체는 받지 않는다 */
126
+ export declare const PREPARED_WINDOW: unique symbol;
127
+ /**
128
+ * 클릭 시점에 미리 연 결제창. 결제 시작 API가 서버 함수를 거쳐 느리게 끝나도 팝업 차단에 걸리지 않게
129
+ * {@link Payments.prepareWindow}로 먼저 열어 두고 {@link Payments.open}(또는 `start`·`retry`의 `window`)에 넘긴다.
130
+ */
131
+ export interface PreparedPaymentWindow {
132
+ /** `auto` 판정이 끝난 실제 모드 */
133
+ readonly mode: "popup" | "redirect";
134
+ /** 결제 시작에 실패했을 때 미리 연 팝업을 닫는다 */
135
+ close(): void;
136
+ /** 이 결제 인스턴스가 연 창이라는 표식. 값은 내부용이다 */
137
+ readonly [PREPARED_WINDOW]: object;
138
+ }
139
+ export interface Payments {
140
+ /**
141
+ * 결제 시작 — 결제 세션을 만들고 결제창을 연다. 팝업 모드는 결과가 나올 때까지 기다리고,
142
+ * 리다이렉트 모드는 페이지가 떠나므로 **끝나지 않는 Promise**를 돌려준다.
143
+ * 결제 시작 API가 실패하면 미리 연 팝업을 닫고 `ApiError`를 그대로 던진다.
144
+ */
145
+ start(checkoutId: string, options: StartOptions): Promise<PaymentResult>;
146
+ /** 같은 결제를 다른 결제 옵션으로 다시 시도한다 */
147
+ retry(paymentId: string, options: RetryOptions): Promise<PaymentResult>;
148
+ /**
149
+ * 이미 받은 결제 시작 값으로 결제창만 연다 — 결제 시작을 서버 함수로 하는 SSR 앱용.
150
+ * 클릭 핸들러에서 {@link Payments.prepareWindow}로 창을 먼저 열고 그 핸들을 `window`로 넘긴다.
151
+ */
152
+ open(start: PaymentStart, options?: {
153
+ mode?: PaymentMode;
154
+ window?: PreparedPaymentWindow;
155
+ }): Promise<PaymentResult>;
156
+ /** 클릭 시점에 빈 팝업을 연다(팝업 차단 회피). 반환 핸들을 {@link Payments.open}에 넘긴다 */
157
+ prepareWindow(options?: {
158
+ mode?: PaymentMode;
159
+ }): PreparedPaymentWindow;
160
+ /** 복귀 화면 — 주소의 `sayrenPaymentId`로 결제 상태를 조회해 결과를 만든다 */
161
+ result(url?: string): Promise<PaymentResult>;
162
+ /** 결제 상태 조회 — 결과가 `PROCESSING`이면 확정될 때까지 조회한다 */
163
+ getStatus(paymentId: string): Promise<PaymentStatus>;
164
+ }
165
+ export declare function createPayments(options: PaymentsOptions): Payments;
166
+ //#endregion
@@ -0,0 +1,310 @@
1
+ import { u as paymentMessageSchema } from "../checkout-JFk3b8RG.mjs";
2
+ //#region src/payments/index.ts
3
+ /** 복귀 주소에 결제 서비스가 붙이는 쿼리 */
4
+ const RETURN_PAYMENT_ID = "sayrenPaymentId";
5
+ /** 팝업 이름 — 같은 이름으로 열면 결제창이 하나만 뜬다 */
6
+ const POPUP_NAME = "sayren-payment";
7
+ const POPUP_FEATURES = "width=500,height=720,resizable=yes,scrollbars=yes";
8
+ /** 팝업이 메시지 없이 닫혔는지 보는 간격 */
9
+ const POPUP_POLL_MS = 500;
10
+ /** opener를 잃은 팝업 대비 — 결제 상태를 직접 조회하기 시작하는 시점(5초)과 그 뒤 간격(3초) */
11
+ const STATUS_POLL_FIRST_TICKS = 10;
12
+ const STATUS_POLL_EVERY_TICKS = 6;
13
+ /** 좁은 터치 화면은 팝업이 아니라 리다이렉트다 */
14
+ const POPUP_MIN_WIDTH = 820;
15
+ const MOBILE_UA = /Android|iPhone|iPad|iPod|IEMobile|Opera Mini|Mobile/i;
16
+ /**
17
+ * 결제창을 팝업으로 열지 리다이렉트로 열지 정한다 — `auto` 판정 한 곳(순수 함수).
18
+ * 모바일은 팝업이 탭 전환으로 열리고 간편결제 앱을 거치며 `opener`를 잃으므로 리다이렉트를 쓴다.
19
+ */
20
+ function resolvePaymentMode(mode, device) {
21
+ if (mode === "popup" || mode === "redirect") return mode;
22
+ if (MOBILE_UA.test(device.userAgent)) return "redirect";
23
+ if (device.coarsePointer && device.viewportWidth > 0 && device.viewportWidth < POPUP_MIN_WIDTH) return "redirect";
24
+ return "popup";
25
+ }
26
+ /**
27
+ * 결제 상태를 화면 결과로 옮긴다 — 결과의 원천은 언제나 `GET /payments/{id}`다(메시지는 신호일 뿐).
28
+ * 모르는 `result` 값은 확인 중으로 다룬다.
29
+ */
30
+ function paymentResultOf(status, options = {}) {
31
+ const { paymentId, result, lastFailure } = status;
32
+ if (result === "COMPLETED") return status.orderId ? {
33
+ status: "COMPLETED",
34
+ paymentId,
35
+ orderId: status.orderId
36
+ } : {
37
+ status: "PROCESSING",
38
+ paymentId
39
+ };
40
+ if (result === "CANCELED") return {
41
+ status: "CANCELED",
42
+ paymentId,
43
+ code: lastFailure?.code ?? "USER_CANCELED",
44
+ message: lastFailure?.message ?? "결제를 취소했어요"
45
+ };
46
+ if (result === "FAILED" || result === "EXPIRED") return {
47
+ status: "FAILED",
48
+ paymentId,
49
+ code: lastFailure?.code ?? result,
50
+ message: lastFailure?.message ?? (result === "EXPIRED" ? "결제 요청이 만료됐어요" : "결제에 실패했어요")
51
+ };
52
+ if (result === "PENDING") return options.popupClosed ? {
53
+ status: "CANCELED",
54
+ paymentId,
55
+ code: "POPUP_CLOSED",
56
+ message: "결제창을 닫았어요"
57
+ } : {
58
+ status: "PROCESSING",
59
+ paymentId
60
+ };
61
+ return {
62
+ status: "PROCESSING",
63
+ paymentId
64
+ };
65
+ }
66
+ function browserEnv() {
67
+ return {
68
+ openPopup() {
69
+ const popup = window.open("about:blank", POPUP_NAME, POPUP_FEATURES);
70
+ if (!popup) return null;
71
+ return {
72
+ get closed() {
73
+ return popup.closed;
74
+ },
75
+ ref: popup,
76
+ navigate: (url) => {
77
+ popup.location.href = url;
78
+ },
79
+ close: () => popup.close()
80
+ };
81
+ },
82
+ navigate: (url) => window.location.assign(url),
83
+ subscribe(listener) {
84
+ const handler = (event) => listener({
85
+ data: event.data,
86
+ origin: event.origin,
87
+ source: event.source
88
+ });
89
+ window.addEventListener("message", handler);
90
+ return () => window.removeEventListener("message", handler);
91
+ },
92
+ currentUrl: () => window.location.href,
93
+ device: () => ({
94
+ userAgent: typeof navigator === "undefined" ? "" : navigator.userAgent,
95
+ coarsePointer: typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches,
96
+ viewportWidth: window.innerWidth
97
+ }),
98
+ now: () => Date.now(),
99
+ setInterval: (handler, ms) => setInterval(handler, ms),
100
+ clearInterval: (handle) => clearInterval(handle)
101
+ };
102
+ }
103
+ /** {@link Payments.prepareWindow}가 돌려준 핸들임을 나타내는 표식 — 직접 만든 객체는 받지 않는다 */
104
+ const PREPARED_WINDOW = Symbol("sayren.payments.preparedWindow");
105
+ function popupUrl(payUrl) {
106
+ try {
107
+ const url = new URL(payUrl);
108
+ url.searchParams.set("mode", "popup");
109
+ return url.toString();
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+ function createPayments(options) {
115
+ const { client } = options;
116
+ const env = options.env ?? browserEnv();
117
+ /**
118
+ * 진행 중인 팝업 감시. 팝업은 이름이 같아 다시 열면 같은 창을 쓰므로 감시도 하나만 둔다 —
119
+ * 새 결제를 시작하면 이전 감시를 끝내고(그 Promise는 `PROCESSING`) 리스너·타이머를 거둔다.
120
+ */
121
+ let cancelWatch = null;
122
+ /** 이 인스턴스가 연 창인지 가리는 표식 */
123
+ const brand = {};
124
+ function stopWatch() {
125
+ const cancel = cancelWatch;
126
+ cancelWatch = null;
127
+ cancel?.();
128
+ }
129
+ /**
130
+ * 결제 서비스는 팝업 결과 메시지를 **복귀 주소(returnUrl)의 origin**으로 보낸다.
131
+ * 결제를 시작한 페이지와 origin이 다르면 메시지가 도달하지 않아 상태 조회로만 결과가 정해진다.
132
+ */
133
+ function warnReturnOrigin(returnUrl, mode) {
134
+ if (!returnUrl || mode !== "popup") return;
135
+ try {
136
+ if (new URL(returnUrl).origin === new URL(env.currentUrl()).origin) return;
137
+ } catch {
138
+ return;
139
+ }
140
+ console.warn(`[sayren] returnUrl(${returnUrl})의 origin이 지금 페이지와 달라요 — 결제 서비스는 그 origin으로 결과 메시지를 보내므로 팝업 결과가 도달하지 않고 결제 상태 조회로만 끝나요`);
141
+ }
142
+ function prepareWindow(prepareOptions = {}) {
143
+ stopWatch();
144
+ const popup = resolvePaymentMode(prepareOptions.mode, env.device()) === "popup" ? env.openPopup() : null;
145
+ return {
146
+ mode: popup ? "popup" : "redirect",
147
+ popup,
148
+ close: () => popup?.close(),
149
+ [PREPARED_WINDOW]: brand
150
+ };
151
+ }
152
+ /**
153
+ * 넘겨받은 창 핸들을 확인한다 — 이 인스턴스의 `prepareWindow()`가 돌려준 것만 받는다.
154
+ * 다른 인스턴스의 것이나 손으로 만든 객체는 창도 감시도 우리 것이 아니라 결제가 조용히 어긋난다.
155
+ */
156
+ function resolvePrepared(given, mode) {
157
+ if (!given) return prepareWindow({ mode });
158
+ if (given[PREPARED_WINDOW] !== brand) throw new TypeError("window는 같은 createPayments의 prepareWindow()가 돌려준 핸들이어야 해요");
159
+ return given;
160
+ }
161
+ /**
162
+ * 팝업에서 결제가 끝날 때까지 기다린다 — 메시지는 신호, 결과는 상태 조회다.
163
+ *
164
+ * 감시는 셋이다. (1) 결제 서비스의 postMessage, (2) 창이 닫혔는지, (3) 결제 상태 직접 조회.
165
+ * (3)이 있어야 팝업이 `opener`를 잃어(간편결제 앱 전환, 팝업 안에서 복귀 주소로 리다이렉트) 메시지도
166
+ * 닫힘도 오지 않는 경우에 멈추지 않는다. 어느 경로든 결과는 한 번만 돌려준다.
167
+ */
168
+ function awaitPopup(paymentId, popup, origin, expiresAt) {
169
+ return new Promise((resolve) => {
170
+ let done = false;
171
+ let ticks = 0;
172
+ let polling = false;
173
+ let timer;
174
+ let unsubscribe = () => {};
175
+ let cancelThis = null;
176
+ const deadline = Date.parse(expiresAt);
177
+ const teardown = () => {
178
+ unsubscribe();
179
+ env.clearInterval(timer);
180
+ if (cancelWatch === cancelThis) cancelWatch = null;
181
+ };
182
+ const finish = (result) => {
183
+ if (done) return;
184
+ done = true;
185
+ teardown();
186
+ resolve(result);
187
+ };
188
+ /** 메시지·닫힘 신호를 받았다 — 결과는 상태 조회로 정한다 */
189
+ const settle = async (popupClosed) => {
190
+ if (done) return;
191
+ done = true;
192
+ teardown();
193
+ try {
194
+ resolve(paymentResultOf(await client.payments.getStatus(paymentId), { popupClosed }));
195
+ } catch {
196
+ resolve({
197
+ status: "PROCESSING",
198
+ paymentId
199
+ });
200
+ }
201
+ };
202
+ unsubscribe = env.subscribe((event) => {
203
+ if (event.origin !== origin || event.source !== popup.ref) return;
204
+ const message = paymentMessageSchema.safeParse(event.data);
205
+ if (!message.success || message.data.paymentId !== paymentId) return;
206
+ settle(false);
207
+ });
208
+ timer = env.setInterval(() => {
209
+ if (done) return;
210
+ if (popup.closed) {
211
+ settle(true);
212
+ return;
213
+ }
214
+ if (Number.isFinite(deadline) && env.now() >= deadline) {
215
+ finish({
216
+ status: "PROCESSING",
217
+ paymentId
218
+ });
219
+ return;
220
+ }
221
+ ticks += 1;
222
+ const since = ticks - STATUS_POLL_FIRST_TICKS;
223
+ if (polling || since < 0 || since % STATUS_POLL_EVERY_TICKS !== 0) return;
224
+ polling = true;
225
+ client.payments.getStatus(paymentId).then((status) => {
226
+ if (done) return;
227
+ const result = paymentResultOf(status);
228
+ if (result.status === "PROCESSING") return;
229
+ try {
230
+ popup.close();
231
+ } catch {}
232
+ finish(result);
233
+ }).catch(() => {}).finally(() => {
234
+ polling = false;
235
+ });
236
+ }, POPUP_POLL_MS);
237
+ cancelThis = () => finish({
238
+ status: "PROCESSING",
239
+ paymentId
240
+ });
241
+ cancelWatch = cancelThis;
242
+ });
243
+ }
244
+ function open(start, openOptions = {}) {
245
+ stopWatch();
246
+ const popup = resolvePrepared(openOptions.window, openOptions.mode).popup;
247
+ const url = popup ? popupUrl(start.payUrl) : null;
248
+ if (!popup || !url) {
249
+ popup?.close();
250
+ env.navigate(start.payUrl);
251
+ return new Promise(() => {});
252
+ }
253
+ popup.navigate(url);
254
+ return awaitPopup(start.paymentId, popup, new URL(url).origin, start.expiresAt);
255
+ }
256
+ async function startWith(prepared, request) {
257
+ let start;
258
+ try {
259
+ start = await request();
260
+ } catch (error) {
261
+ prepared.close();
262
+ throw error;
263
+ }
264
+ return open(start, { window: prepared });
265
+ }
266
+ return {
267
+ prepareWindow,
268
+ open,
269
+ start(checkoutId, startOptions) {
270
+ const prepared = resolvePrepared(startOptions.window, startOptions.mode);
271
+ const returnUrl = startOptions.returnUrl ?? options.returnUrl;
272
+ if (!returnUrl) {
273
+ prepared.close();
274
+ return Promise.reject(/* @__PURE__ */ new Error("returnUrl이 필요해요 — createPayments 또는 start에 넘겨 주세요"));
275
+ }
276
+ warnReturnOrigin(returnUrl, prepared.mode);
277
+ return startWith(prepared, () => client.checkout.startPayment(checkoutId, {
278
+ option: startOptions.option,
279
+ shippingAddress: startOptions.shippingAddress,
280
+ guest: startOptions.guest,
281
+ returnUrl
282
+ }));
283
+ },
284
+ retry(paymentId, retryOptions) {
285
+ const prepared = resolvePrepared(retryOptions.window, retryOptions.mode);
286
+ const returnUrl = retryOptions.returnUrl ?? options.returnUrl;
287
+ warnReturnOrigin(returnUrl, prepared.mode);
288
+ return startWith(prepared, () => client.payments.retry(paymentId, {
289
+ option: retryOptions.option,
290
+ returnUrl
291
+ }));
292
+ },
293
+ getStatus: (paymentId) => client.payments.getStatus(paymentId),
294
+ async result(url) {
295
+ let paymentId = null;
296
+ try {
297
+ paymentId = new URL(url ?? env.currentUrl()).searchParams.get(RETURN_PAYMENT_ID);
298
+ } catch {
299
+ paymentId = null;
300
+ }
301
+ if (!paymentId) return {
302
+ status: "INVALID",
303
+ message: "결제 정보가 없는 주소예요"
304
+ };
305
+ return paymentResultOf(await client.payments.getStatus(paymentId));
306
+ }
307
+ };
308
+ }
309
+ //#endregion
310
+ export { PREPARED_WINDOW, createPayments, paymentResultOf, resolvePaymentMode };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/storefront-sdk",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "main": "./dist/index.mjs",
5
5
  "types": "./dist/index.d.mts",
6
6
  "exports": {
@@ -18,6 +18,11 @@
18
18
  "development": "./src/auth/index.ts",
19
19
  "types": "./dist/auth/index.d.mts",
20
20
  "default": "./dist/auth/index.mjs"
21
+ },
22
+ "./payments": {
23
+ "development": "./src/payments/index.ts",
24
+ "types": "./dist/payments/index.d.mts",
25
+ "default": "./dist/payments/index.mjs"
21
26
  }
22
27
  },
23
28
  "dependencies": {
@@ -42,7 +47,7 @@
42
47
  "dist",
43
48
  "src"
44
49
  ],
45
- "description": "sayren 스토어프론트 API SDK — zod 스키마 + fetch 클라이언트",
50
+ "description": "sayren 스토어프론트 API SDK — zod 스키마 + fetch 클라이언트 + 브라우저 결제·분석·인증 모듈",
46
51
  "scripts": {
47
52
  "build": "tsdown",
48
53
  "dev": "tsdown --watch",
package/src/client.ts CHANGED
@@ -36,13 +36,13 @@ import {
36
36
  import {
37
37
  type CreateCheckoutRequest,
38
38
  checkoutSessionSchema,
39
- confirmPaymentRequestSchema,
40
- confirmPaymentResultSchema,
41
39
  createCheckoutRequestSchema,
42
- paymentParamsSchema,
40
+ paymentStartSchema,
43
41
  paymentStatusSchema,
44
- type RequestPaymentRequest,
45
- requestPaymentRequestSchema,
42
+ type RetryPaymentRequest,
43
+ retryPaymentRequestSchema,
44
+ type StartPaymentRequest,
45
+ startPaymentRequestSchema,
46
46
  } from "./schemas/checkout";
47
47
  import {
48
48
  type CreateClaimRequest,
@@ -272,48 +272,41 @@ export function createStorefrontClient(options: StorefrontClientOptions) {
272
272
  body: createCheckoutRequestSchema.parse(body),
273
273
  }),
274
274
  /**
275
- * 결제 시작 — 결제 세션을 만들고 결제 팝업 URL(`pgParams.popupUrl`)을 돌려준다.
276
- * `pgProvider`는 첫 결제 시도의 PG다(현재 `tosspayments`·`portone`, 새 PG가 추가돼도 파싱은 깨지지 않는다).
275
+ * `POST /checkout/{checkoutId}/payment` — 결제 시작. 배송지와 결제 옵션(`paymentOptions` 항목 또는 직접 쓴 옵션)으로
276
+ * 결제 세션·첫 시도를 만들고 결제 서비스 주소(`payUrl`)를 돌려준다. 그 주소를 열어 결제를 진행하는 것은
277
+ * `@sayren/storefront-sdk/payments`의 `createPayments().start()`다 — 이 메서드는 값만 받는다.
277
278
  *
278
- * PG 관련 에러(`ApiError.code`) — 둘 다 "결제 수단 준비 중" 안내를 권장한다.
279
- * - 409 `PAYMENT_NOT_CONFIGURED`: 스토어에 사용 중인 PG가 없다. 결제 세션을 만들지 않는다
280
- * - 503 `PAYMENT_PROVIDER_UNAVAILABLE`: 사용 중인 PG가 모두 준비 장애다. 잠시 후 재시도할 수 있다
279
+ * 에러(`ApiError.code`):
280
+ * - 409 `PAYMENT_OPTION_UNAVAILABLE`: 이 스토어에서 쓸 수 없는 옵션. `details.paymentOptions`에 지금 목록
281
+ * - 409 `PAYMENT_NOT_CONFIGURED`: 사용 중인 PG가 없다
282
+ * - 400 `RETURN_URL_NOT_ALLOWED`: `returnUrl`이 https가 아니거나(테스트 결제의 localhost는 예외),
283
+ * 스토어가 결제 도메인을 등록했는데 그 안이 아니다. 등록하지 않았으면 https 주소는 모두 받는다
284
+ * - 503 `PAYMENT_PROVIDER_UNAVAILABLE`: 고른 PG의 결제창을 준비하지 못했다(다른 옵션으로 다시 시도)
281
285
  */
282
- requestPayment: (checkoutId: string, body: RequestPaymentRequest) =>
283
- http.request("POST", path`/checkout/${checkoutId}/payment`, paymentParamsSchema, {
284
- body: requestPaymentRequestSchema.parse(body),
285
- }),
286
- /**
287
- * 결제 승인 확정 — 같은 `paymentId + pgToken` 재호출은 저장된 결과를 돌려준다(멱등).
288
- * `options.attemptId`는 결제창이 돌려준 주문번호(시도 id)이고, 생략하면 현재 시도를 승인한다.
289
- *
290
- * 요청 본문은 보내기 전에 `confirmPaymentRequestSchema`로 검증한다. 빈 `pgToken`처럼 형식이 틀리면
291
- * 요청을 보내지 않고 **동기로 ZodError를 던진다**(Promise reject 아님 — 다른 메서드의 요청 검증과 같다).
292
- *
293
- * 카드 거절(402 `PG_DECLINED` 등)·402 `PAYMENT_NOT_APPROVED`·502 `PG_REQUEST_FAILED`·
294
- * 503 `PG_CREDENTIALS_UNAVAILABLE` 뒤에도 세션은 `pending`으로 남아 다시 시도할 수 있다.
295
- * 409 `LATE_APPROVAL_CANCELED`(미승인으로 판정된 뒤 늦게 온 승인을 서버가 PG 취소함)도 세션이 `pending`으로 남는다.
296
- * 409 `PAYMENT_RESULT_UNKNOWN`은 결과 확인 중이라 재시도하면 안 된다. 그 밖의 409:
297
- * `ATTEMPT_NOT_CURRENT`·`INVALID_ATTEMPT_STATE`·`PAYMENT_SESSION_OUTDATED`·`PG_AMOUNT_MISMATCH`.
298
- * 503 `PG_MERCHANT_MISMATCH`: 승인 시도의 가맹점과 현재 저장된 자격 증명의 가맹점이 다르다.
299
- */
300
- confirmPayment: (paymentId: string, pgToken: string, options?: { attemptId?: string }) =>
301
- http.request("POST", path`/payments/${paymentId}/confirm`, confirmPaymentResultSchema, {
302
- body: confirmPaymentRequestSchema.parse({ pgToken, attemptId: options?.attemptId }),
286
+ startPayment: (checkoutId: string, body: StartPaymentRequest) =>
287
+ http.request("POST", path`/checkout/${checkoutId}/payment`, paymentStartSchema, {
288
+ body: startPaymentRequestSchema.parse(body),
303
289
  }),
304
290
  },
305
291
 
306
292
  payments: {
307
293
  /**
308
- * `GET /storefront/v1/payments/{paymentId}` — 결제 상태 조회. 결제 팝업이 결과(postMessage)를 알리지 못하고 닫혔을 때
309
- * 부모 창이 `expiresAt`까지 폴링해 결과를 확인한다. `completed`면 `orderId`로 완료 화면, `processing`이면 계속 폴링,
310
- * `pending`이면 기존 안내, `failed`·`expired`면 실패 안내다. TTL이 지난 `pending`은 `expired`로 보고된다.
311
- * `testPayment`는 완료면 주문 플래그, 아니면 세션 환경이다.
294
+ * `GET /storefront/v1/payments/{paymentId}` — 결제 상태 조회. 복귀 화면에서 결과가 확인 중(`processing`)이면
295
+ * `expiresAt`까지 폴링한다. `completed`면 `orderId`로 완료 화면, `failed`·`expired`면 실패 안내다.
296
+ * TTL이 지난 `pending`은 `expired`로 보고된다. `testPayment`는 완료면 주문 플래그, 아니면 세션 환경이다.
312
297
  *
313
298
  * 에러: 404 `PAYMENT_NOT_FOUND`(없음·다른 스토어) · 410 `STORE_CLOSED` · 503 `STORE_SUSPENDED`
314
299
  */
315
300
  getStatus: (paymentId: string) =>
316
301
  http.request("GET", path`/payments/${paymentId}`, paymentStatusSchema),
302
+ /**
303
+ * `POST /payments/{paymentId}/attempts` — 같은 결제에서 다른 결제 옵션으로 다시 시도한다. 새 시도의 결제창 값을
304
+ * 돌려준다. 승인 중이거나 결과 확인 중이면 409 `PAYMENT_IN_PROGRESS`다.
305
+ */
306
+ retry: (paymentId: string, body: RetryPaymentRequest) =>
307
+ http.request("POST", path`/payments/${paymentId}/attempts`, paymentStartSchema, {
308
+ body: retryPaymentRequestSchema.parse(body),
309
+ }),
317
310
  },
318
311
 
319
312
  myOrders: {