@sayren/store-sdk 0.6.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/server.d.mts CHANGED
@@ -1,4 +1,48 @@
1
1
  import { a as WEBHOOK_HEADERS, i as WEBHOOK_EVENT_TYPE_HEADER, n as WEBHOOK_DELIVERY_ID_HEADER, o as WEBHOOK_SIGNATURE_HEADER, r as WEBHOOK_EVENT_ID_HEADER, s as WEBHOOK_USER_AGENT, t as WEBHOOK_DEFAULT_TOLERANCE_SEC } from "./webhook-headers-D6QmPdEJ.mjs";
2
+ //#region src/member-delegation.d.ts
3
+ /**
4
+ * 구매자 위임 — 자체 회원 시스템을 가진 셀러 서버가 로그인한 사용자를 sayren 구매자 토큰으로 바꾼다
5
+ * (token-exchange의 `member-uid`·`member-ref` 확장, auth-spec §12-10). 클라이언트 시크릿을 쓰므로 **서버에서만** 부른다.
6
+ *
7
+ * 스토어에 바인딩된 confidential 클라이언트 + `member:delegate` 스코프가 필요하다. 응답은 구매자 액세스 토큰(30분)이고
8
+ * refresh는 없다 — 만료되면 같은 호출로 다시 받는다.
9
+ */
10
+ interface DelegatedMemberToken {
11
+ accessToken: string;
12
+ expiresIn: number;
13
+ memberId: string;
14
+ }
15
+ interface DelegationOptions {
16
+ /** api 주소(예: `https://api.sayren.app`) — `/v1/oauth2/token`을 붙인다 */
17
+ baseUrl: string;
18
+ clientId: string;
19
+ clientSecret: string;
20
+ fetch?: typeof globalThis.fetch;
21
+ }
22
+ /** 셀러 자체 인증의 사용자 id로 지목한다. 처음 보는 id면 구매자를 만든다(이메일·이름은 그때만 쓴다) */
23
+ export declare function delegateMemberByUid(options: DelegationOptions & {
24
+ uid: string;
25
+ email?: string;
26
+ name?: string;
27
+ /**
28
+ * 사용자가 내 서비스에서 실제로 로그인한 시각. 주면 구매자 토큰의 인증 시각이 되고(탈퇴처럼 최근 인증이 필요한 동작에
29
+ * 쓴다), 주지 않으면 인증 시각이 없는 토큰이 나온다
30
+ */
31
+ authenticatedAt?: Date;
32
+ }): Promise<DelegatedMemberToken>;
33
+ /** 이메일로 지목한다. 처음 보는 이메일이면 구매자를 만든다 */
34
+ export declare function delegateMemberByEmail(options: DelegationOptions & {
35
+ email: string;
36
+ name?: string;
37
+ }): Promise<DelegatedMemberToken>;
38
+ /** 위임 실패 — `error`는 OAuth 오류 코드(`invalid_client`·`invalid_scope`·`invalid_request` 등) */
39
+ export declare class MemberDelegationError extends Error {
40
+ readonly error: string;
41
+ readonly description: string;
42
+ readonly status: number;
43
+ constructor(error: string, description: string, status: number);
44
+ }
45
+ //#endregion
2
46
  //#region src/webhook-signature.d.ts
3
47
  interface SignWebhookPayloadOptions {
4
48
  /** 요청 본문 원문. 전송하는 바이트와 정확히 같아야 한다 */
@@ -56,4 +100,4 @@ interface VerifyWebhookSignatureOptions {
56
100
  */
57
101
  export declare function verifyWebhookSignature(options: VerifyWebhookSignatureOptions): Promise<WebhookSignatureVerification>;
58
102
  //#endregion
59
- export { type ParsedWebhookSignature, type SignWebhookPayloadOptions, type VerifyWebhookSignatureOptions, WEBHOOK_DEFAULT_TOLERANCE_SEC, WEBHOOK_DELIVERY_ID_HEADER, WEBHOOK_EVENT_ID_HEADER, WEBHOOK_EVENT_TYPE_HEADER, WEBHOOK_HEADERS, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_USER_AGENT, type WebhookSignatureFailureReason, type WebhookSignatureVerification };
103
+ export { type DelegatedMemberToken, type ParsedWebhookSignature, type SignWebhookPayloadOptions, type VerifyWebhookSignatureOptions, WEBHOOK_DEFAULT_TOLERANCE_SEC, WEBHOOK_DELIVERY_ID_HEADER, WEBHOOK_EVENT_ID_HEADER, WEBHOOK_EVENT_TYPE_HEADER, WEBHOOK_HEADERS, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_USER_AGENT, type WebhookSignatureFailureReason, type WebhookSignatureVerification };
package/dist/server.mjs CHANGED
@@ -1,4 +1,59 @@
1
1
  import { a as WEBHOOK_HEADERS, i as WEBHOOK_EVENT_TYPE_HEADER, n as WEBHOOK_DELIVERY_ID_HEADER, o as WEBHOOK_SIGNATURE_HEADER, r as WEBHOOK_EVENT_ID_HEADER, s as WEBHOOK_USER_AGENT, t as WEBHOOK_DEFAULT_TOLERANCE_SEC } from "./webhook-headers-ap_exsb9.mjs";
2
+ //#region src/member-delegation.ts
3
+ /** 셀러 자체 인증의 사용자 id로 지목한다. 처음 보는 id면 구매자를 만든다(이메일·이름은 그때만 쓴다) */
4
+ function delegateMemberByUid(options) {
5
+ return delegate(options, {
6
+ subject_token_type: "urn:sayren:params:oauth:token-type:member-uid",
7
+ subject_token: options.uid,
8
+ ...options.authenticatedAt ? { subject_auth_time: String(Math.floor(options.authenticatedAt.getTime() / 1e3)) } : {},
9
+ ...options.email ? { subject_email: options.email } : {},
10
+ ...options.name ? { subject_name: options.name } : {}
11
+ });
12
+ }
13
+ /** 이메일로 지목한다. 처음 보는 이메일이면 구매자를 만든다 */
14
+ function delegateMemberByEmail(options) {
15
+ return delegate(options, {
16
+ subject_token_type: "urn:sayren:params:oauth:token-type:member-ref",
17
+ subject_token: options.email,
18
+ ...options.name ? { subject_name: options.name } : {}
19
+ });
20
+ }
21
+ async function delegate(options, subject) {
22
+ const doFetch = options.fetch ?? globalThis.fetch;
23
+ const basic = btoa(`${options.clientId}:${options.clientSecret}`);
24
+ const res = await doFetch(`${options.baseUrl.replace(/\/+$/, "")}/v1/oauth2/token`, {
25
+ method: "POST",
26
+ headers: {
27
+ "content-type": "application/x-www-form-urlencoded",
28
+ authorization: `Basic ${basic}`
29
+ },
30
+ body: new URLSearchParams({
31
+ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
32
+ ...subject
33
+ }).toString()
34
+ });
35
+ const body = await res.json().catch(() => ({}));
36
+ if (!res.ok || typeof body.access_token !== "string") throw new MemberDelegationError(typeof body.error === "string" ? body.error : `http_${res.status}`, typeof body.error_description === "string" ? body.error_description : "", res.status);
37
+ return {
38
+ accessToken: body.access_token,
39
+ expiresIn: Number(body.expires_in),
40
+ memberId: String(body.memberId)
41
+ };
42
+ }
43
+ /** 위임 실패 — `error`는 OAuth 오류 코드(`invalid_client`·`invalid_scope`·`invalid_request` 등) */
44
+ var MemberDelegationError = class extends Error {
45
+ error;
46
+ description;
47
+ status;
48
+ constructor(error, description, status) {
49
+ super(description ? `${error}: ${description}` : error);
50
+ this.error = error;
51
+ this.description = description;
52
+ this.status = status;
53
+ this.name = "MemberDelegationError";
54
+ }
55
+ };
56
+ //#endregion
2
57
  //#region src/webhook-signature.ts
3
58
  /**
4
59
  * 웹훅 서명 (`Sayren-Signature`) 생성·파싱·검증.
@@ -122,4 +177,4 @@ async function verifyWebhookSignature(options) {
122
177
  };
123
178
  }
124
179
  //#endregion
125
- export { WEBHOOK_DEFAULT_TOLERANCE_SEC, WEBHOOK_DELIVERY_ID_HEADER, WEBHOOK_EVENT_ID_HEADER, WEBHOOK_EVENT_TYPE_HEADER, WEBHOOK_HEADERS, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_USER_AGENT, parseWebhookSignatureHeader, signWebhookPayload, verifyWebhookSignature };
180
+ export { MemberDelegationError, WEBHOOK_DEFAULT_TOLERANCE_SEC, WEBHOOK_DELIVERY_ID_HEADER, WEBHOOK_EVENT_ID_HEADER, WEBHOOK_EVENT_TYPE_HEADER, WEBHOOK_HEADERS, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_USER_AGENT, delegateMemberByEmail, delegateMemberByUid, parseWebhookSignatureHeader, signWebhookPayload, verifyWebhookSignature };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/store-sdk",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "main": "./dist/index.mjs",
5
5
  "types": "./dist/index.d.mts",
6
6
  "exports": {
package/src/client.ts CHANGED
@@ -2,13 +2,22 @@ import { z } from "zod";
2
2
  import { base64Utf8 } from "./base64";
3
3
  import { createHttp, path, type Query, resolveToken, type TokenSource, unwrapData } from "./http";
4
4
  import {
5
+ type AnalyticsPagesParams,
5
6
  type AnalyticsProductsParams,
6
7
  type AnalyticsRangeParams,
8
+ type AnalyticsSearchParams,
7
9
  type AnalyticsSourcesParams,
10
+ analyticsCheckoutSchema,
11
+ analyticsDevicesSchema,
8
12
  analyticsOverviewSchema,
13
+ analyticsPagesQuerySchema,
14
+ analyticsPagesSchema,
9
15
  analyticsProductsQuerySchema,
10
16
  analyticsProductsSchema,
11
17
  analyticsRangeQuerySchema,
18
+ analyticsRealtimeSchema,
19
+ analyticsSearchQuerySchema,
20
+ analyticsSearchSchema,
12
21
  analyticsSourcesQuerySchema,
13
22
  analyticsSourcesSchema,
14
23
  } from "./schemas/analytics";
@@ -719,6 +728,28 @@ export function createStoreClient(options: StoreClientOptions) {
719
728
  storeHttp.request("GET", "/analytics/sources", analyticsSourcesSchema, {
720
729
  query: analyticsSourcesQuerySchema.parse(params),
721
730
  }),
731
+ /** `GET /v1/analytics/realtime` — 최근 5분 방문자·세션·페이지(원본 이벤트 기준, 봇 제외) */
732
+ realtime: () => storeHttp.request("GET", "/analytics/realtime", analyticsRealtimeSchema),
733
+ /** `GET /v1/analytics/devices` — 기기별 세션 퍼널 */
734
+ devices: (params: AnalyticsRangeParams) =>
735
+ storeHttp.request("GET", "/analytics/devices", analyticsDevicesSchema, {
736
+ query: analyticsRangeQuerySchema.parse(params),
737
+ }),
738
+ /** `GET /v1/analytics/pages` — 첫 페이지(진입·반송)와 이탈 페이지 */
739
+ pages: (params: AnalyticsPagesParams) =>
740
+ storeHttp.request("GET", "/analytics/pages", analyticsPagesSchema, {
741
+ query: analyticsPagesQuerySchema.parse(params) as unknown as Query,
742
+ }),
743
+ /** `GET /v1/analytics/search` — 검색어별 검색·결과 0개·검색 후 구매 */
744
+ search: (params: AnalyticsSearchParams) =>
745
+ storeHttp.request("GET", "/analytics/search", analyticsSearchSchema, {
746
+ query: analyticsSearchQuerySchema.parse(params) as unknown as Query,
747
+ }),
748
+ /** `GET /v1/analytics/checkout` — 결제 세션의 결과별 수(결제 단계 이탈 이유) */
749
+ checkout: (params: AnalyticsRangeParams) =>
750
+ storeHttp.request("GET", "/analytics/checkout", analyticsCheckoutSchema, {
751
+ query: analyticsRangeQuerySchema.parse(params),
752
+ }),
722
753
  },
723
754
 
724
755
  /**
@@ -0,0 +1,98 @@
1
+ /**
2
+ * 구매자 위임 — 자체 회원 시스템을 가진 셀러 서버가 로그인한 사용자를 sayren 구매자 토큰으로 바꾼다
3
+ * (token-exchange의 `member-uid`·`member-ref` 확장, auth-spec §12-10). 클라이언트 시크릿을 쓰므로 **서버에서만** 부른다.
4
+ *
5
+ * 스토어에 바인딩된 confidential 클라이언트 + `member:delegate` 스코프가 필요하다. 응답은 구매자 액세스 토큰(30분)이고
6
+ * refresh는 없다 — 만료되면 같은 호출로 다시 받는다.
7
+ */
8
+
9
+ export interface DelegatedMemberToken {
10
+ accessToken: string;
11
+ expiresIn: number;
12
+ memberId: string;
13
+ }
14
+
15
+ interface DelegationOptions {
16
+ /** api 주소(예: `https://api.sayren.app`) — `/v1/oauth2/token`을 붙인다 */
17
+ baseUrl: string;
18
+ clientId: string;
19
+ clientSecret: string;
20
+ fetch?: typeof globalThis.fetch;
21
+ }
22
+
23
+ /** 셀러 자체 인증의 사용자 id로 지목한다. 처음 보는 id면 구매자를 만든다(이메일·이름은 그때만 쓴다) */
24
+ export function delegateMemberByUid(
25
+ options: DelegationOptions & {
26
+ uid: string;
27
+ email?: string;
28
+ name?: string;
29
+ /**
30
+ * 사용자가 내 서비스에서 실제로 로그인한 시각. 주면 구매자 토큰의 인증 시각이 되고(탈퇴처럼 최근 인증이 필요한 동작에
31
+ * 쓴다), 주지 않으면 인증 시각이 없는 토큰이 나온다
32
+ */
33
+ authenticatedAt?: Date;
34
+ },
35
+ ): Promise<DelegatedMemberToken> {
36
+ return delegate(options, {
37
+ subject_token_type: "urn:sayren:params:oauth:token-type:member-uid",
38
+ subject_token: options.uid,
39
+ ...(options.authenticatedAt
40
+ ? { subject_auth_time: String(Math.floor(options.authenticatedAt.getTime() / 1000)) }
41
+ : {}),
42
+ ...(options.email ? { subject_email: options.email } : {}),
43
+ ...(options.name ? { subject_name: options.name } : {}),
44
+ });
45
+ }
46
+
47
+ /** 이메일로 지목한다. 처음 보는 이메일이면 구매자를 만든다 */
48
+ export function delegateMemberByEmail(
49
+ options: DelegationOptions & { email: string; name?: string },
50
+ ): Promise<DelegatedMemberToken> {
51
+ return delegate(options, {
52
+ subject_token_type: "urn:sayren:params:oauth:token-type:member-ref",
53
+ subject_token: options.email,
54
+ ...(options.name ? { subject_name: options.name } : {}),
55
+ });
56
+ }
57
+
58
+ async function delegate(
59
+ options: DelegationOptions,
60
+ subject: Record<string, string>,
61
+ ): Promise<DelegatedMemberToken> {
62
+ const doFetch = options.fetch ?? globalThis.fetch;
63
+ const basic = btoa(`${options.clientId}:${options.clientSecret}`);
64
+ const res = await doFetch(`${options.baseUrl.replace(/\/+$/, "")}/v1/oauth2/token`, {
65
+ method: "POST",
66
+ headers: {
67
+ "content-type": "application/x-www-form-urlencoded",
68
+ authorization: `Basic ${basic}`,
69
+ },
70
+ body: new URLSearchParams({
71
+ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
72
+ ...subject,
73
+ }).toString(),
74
+ });
75
+ const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
76
+ if (!res.ok || typeof body.access_token !== "string") {
77
+ const error = typeof body.error === "string" ? body.error : `http_${res.status}`;
78
+ const description = typeof body.error_description === "string" ? body.error_description : "";
79
+ throw new MemberDelegationError(error, description, res.status);
80
+ }
81
+ return {
82
+ accessToken: body.access_token,
83
+ expiresIn: Number(body.expires_in),
84
+ memberId: String(body.memberId),
85
+ };
86
+ }
87
+
88
+ /** 위임 실패 — `error`는 OAuth 오류 코드(`invalid_client`·`invalid_scope`·`invalid_request` 등) */
89
+ export class MemberDelegationError extends Error {
90
+ constructor(
91
+ readonly error: string,
92
+ readonly description: string,
93
+ readonly status: number,
94
+ ) {
95
+ super(description ? `${error}: ${description}` : error);
96
+ this.name = "MemberDelegationError";
97
+ }
98
+ }
@@ -96,6 +96,10 @@ export const analyticsOverviewSchema = z.object({
96
96
  rows: z
97
97
  .array(analyticsTotalsSchema.extend({ date: z.string().describe("일자 (YYYY-MM-DD)") }))
98
98
  .describe("날짜별 값. 세션이 없는 날은 생략된다"),
99
+ excludedBotSessions: z
100
+ .number()
101
+ .int()
102
+ .describe("집계에서 뺀 봇 세션 수 — 봇 표시 이벤트가 하나라도 있는 세션은 모든 값에서 빠진다"),
99
103
  });
100
104
  export type AnalyticsOverview = z.infer<typeof analyticsOverviewSchema>;
101
105
 
@@ -132,6 +136,9 @@ export const analyticsProductRowSchema = z.object({
132
136
  "이 상품의 구매 금액 — 주문 상품 금액(할인 반영, 배송비 제외) 합계. 이후 취소·환불은 빼지 않는다",
133
137
  ),
134
138
  purchaseRate: rate("구매율 = 구매 / 조회 세션. 조회 세션이 없으면 null"),
139
+ clickThroughRate: rate(
140
+ "목록 → 상세 전환율 = 상세 조회 / 목록 노출. 노출이 없으면 null(검색·외부 링크로 바로 들어온 조회는 노출 없이 잡힌다)",
141
+ ),
135
142
  });
136
143
  export type AnalyticsProductRow = z.infer<typeof analyticsProductRowSchema>;
137
144
 
@@ -198,3 +205,141 @@ export const analyticsSourcesSchema = z.object({
198
205
  rows: z.array(analyticsSourceRowSchema).describe("세션 수 내림차순"),
199
206
  });
200
207
  export type AnalyticsSources = z.infer<typeof analyticsSourcesSchema>;
208
+
209
+ // ─── 2차(#6): 실시간·기기별 퍼널·첫/이탈 페이지·검색어·결제 단계 이탈 ─────────────────────
210
+
211
+ export const analyticsRealtimeSchema = z.object({
212
+ asOf: z.string().describe("기준 시각(ISO 8601)"),
213
+ windowMinutes: z.literal(5).describe("최근 몇 분 동안 활동한 방문을 세는가"),
214
+ visitors: z.number().int().describe("최근 5분 안에 이벤트가 있는 방문자 수(봇 제외)"),
215
+ sessions: z.number().int(),
216
+ pageViews: z.number().int(),
217
+ pages: z
218
+ .array(z.object({ path: z.string(), visitors: z.number().int(), pageViews: z.number().int() }))
219
+ .describe("최근 5분 페이지뷰가 있는 경로. 방문자 수 내림차순 최대 10개"),
220
+ });
221
+ export type AnalyticsRealtime = z.infer<typeof analyticsRealtimeSchema>;
222
+
223
+ export const analyticsDeviceRowSchema = z.object({
224
+ device: z.enum(["desktop", "mobile", "tablet", "unknown"]),
225
+ sessions: z.number().int(),
226
+ productViewSessions: z.number().int(),
227
+ addToCartSessions: z.number().int(),
228
+ beginCheckoutSessions: z.number().int(),
229
+ purchaseSessions: z.number().int(),
230
+ conversionRate: rate("전환율 = 구매 세션 / 세션"),
231
+ });
232
+ export type AnalyticsDeviceRow = z.infer<typeof analyticsDeviceRowSchema>;
233
+
234
+ export const analyticsDevicesSchema = z.object({
235
+ ...rangeMeta,
236
+ rows: z.array(analyticsDeviceRowSchema).describe("기기별 세션 기준 퍼널. 세션 수 내림차순"),
237
+ });
238
+ export type AnalyticsDevices = z.infer<typeof analyticsDevicesSchema>;
239
+
240
+ const limitParam = z.coerce
241
+ .number()
242
+ .int()
243
+ .min(1)
244
+ .max(100)
245
+ .optional()
246
+ .describe("최대 행 수. 기본 20, 최대 100");
247
+
248
+ export const analyticsPagesQuerySchema = analyticsRangeQuerySchema.extend({ limit: limitParam });
249
+ export type AnalyticsPagesParams = z.infer<typeof analyticsPagesQuerySchema>;
250
+
251
+ export const analyticsPagesSchema = z.object({
252
+ ...rangeMeta,
253
+ landings: z
254
+ .array(
255
+ z.object({
256
+ path: z.string(),
257
+ entrances: z.number().int().describe("이 페이지로 시작한 세션 수"),
258
+ bounces: z.number().int().describe("이 페이지로 시작해 참여하지 않고 끝난 세션 수"),
259
+ bounceRate: rate("반송률 = 반송 / 진입"),
260
+ }),
261
+ )
262
+ .describe("첫 페이지. 진입 수 내림차순"),
263
+ exits: z
264
+ .array(
265
+ z.object({
266
+ path: z.string(),
267
+ pageViews: z.number().int(),
268
+ exits: z.number().int().describe("이 페이지가 세션의 마지막 페이지뷰였던 수"),
269
+ exitRate: rate("이탈률 = 이탈 / 페이지뷰"),
270
+ }),
271
+ )
272
+ .describe("이탈 페이지. 이탈 수 내림차순"),
273
+ });
274
+ export type AnalyticsPages = z.infer<typeof analyticsPagesSchema>;
275
+
276
+ export const analyticsSearchQuerySchema = analyticsRangeQuerySchema.extend({ limit: limitParam });
277
+ export type AnalyticsSearchParams = z.infer<typeof analyticsSearchQuerySchema>;
278
+
279
+ export const analyticsSearchSchema = z.object({
280
+ ...rangeMeta,
281
+ summary: z.object({
282
+ searches: z.number().int(),
283
+ zeroResultSearches: z.number().int().describe("결과가 0개였던 검색 수"),
284
+ searchSessions: z.number().int().describe("검색한 세션 수(세션당 한 번)"),
285
+ purchaseSessions: z
286
+ .number()
287
+ .int()
288
+ .describe(
289
+ "검색한 뒤 같은 날 구매한 세션 수(세션당 한 번). 검색 전에 끝난 구매는 세지 않는다",
290
+ ),
291
+ conversionRate: rate("검색 후 구매율 = 구매 세션 / 검색 세션"),
292
+ }),
293
+ terms: z
294
+ .array(
295
+ z.object({
296
+ term: z.string().describe("검색어 — 소문자, 공백 정리"),
297
+ searches: z.number().int(),
298
+ zeroResultSearches: z.number().int(),
299
+ searchSessions: z.number().int().describe("이 검색어로 검색한 세션 수"),
300
+ purchaseSessions: z.number().int().describe("이 검색어로 검색한 뒤 같은 날 구매한 세션 수"),
301
+ conversionRate: rate("검색 후 구매율 = 구매 세션 / 검색 세션"),
302
+ }),
303
+ )
304
+ .describe(
305
+ "검색 수 내림차순. 세션 수는 검색어마다 세므로 여러 검색어를 쓴 세션은 검색어마다 잡힌다(더하면 요약보다 크다)",
306
+ ),
307
+ zeroResultTerms: z
308
+ .array(
309
+ z.object({
310
+ term: z.string(),
311
+ searches: z.number().int(),
312
+ zeroResultSearches: z.number().int(),
313
+ }),
314
+ )
315
+ .describe(
316
+ "결과가 0개였던 적이 있는 검색어. 결과 없음 수 내림차순 — 검색 수 상위(`terms`)에 들지 않는 드문 검색어도 여기 잡힌다",
317
+ ),
318
+ });
319
+ export type AnalyticsSearch = z.infer<typeof analyticsSearchSchema>;
320
+
321
+ export const analyticsCheckoutOutcomeSchema = z
322
+ .enum([
323
+ "COMPLETED",
324
+ "NOT_OPENED",
325
+ "USER_CANCELED",
326
+ "DECLINED",
327
+ "PREPARE_FAILURE",
328
+ "OTHER",
329
+ "IN_PROGRESS",
330
+ ])
331
+ .describe(
332
+ "결제 세션의 최종 결과 — `COMPLETED`(결제 완료), `NOT_OPENED`(결제창을 열지 않고 끝남), `USER_CANCELED`(결제창에서 취소), " +
333
+ "`DECLINED`(카드 거절 등 승인 거절), `PREPARE_FAILURE`(결제창을 띄우기 전 장애), `OTHER`(그 밖의 이탈·만료), " +
334
+ "`IN_PROGRESS`(아직 진행 중 — 만료 전 대기·승인 중). 실패 분류는 세션의 마지막 결제 시도 기준이다",
335
+ );
336
+ export type AnalyticsCheckoutOutcome = z.infer<typeof analyticsCheckoutOutcomeSchema>;
337
+
338
+ export const analyticsCheckoutSchema = z.object({
339
+ ...rangeMeta,
340
+ sessions: z.number().int().describe("기간 안에 시작한 결제 세션 수(결제 요청 단위)"),
341
+ outcomes: z
342
+ .array(z.object({ outcome: analyticsCheckoutOutcomeSchema, sessions: z.number().int() }))
343
+ .describe("결과별 결제 세션 수. 결과마다 한 행"),
344
+ });
345
+ export type AnalyticsCheckout = z.infer<typeof analyticsCheckoutSchema>;
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { changeTypeSchema, orderItemStatusSchema, type PageParams, pageSchema } from "./common";
3
+ import { easyPayProviderResponseSchema } from "./payment";
3
4
 
4
5
  export const orderSummarySchema = z.object({
5
6
  orderId: z.string(),
@@ -84,6 +85,15 @@ export const orderSchema = z.object({
84
85
  "매출 리포트·스토어프론트·환불 원장과 같은 값이다. 값이 추가될 수 있어 문자열로 받는다. PG가 알려 주지 않았으면 요청 수단, " +
85
86
  "시도 기록이 없는 오래된 주문은 null. 서버는 항상 싣는다",
86
87
  ),
88
+ easyPayProvider: easyPayProviderResponseSchema
89
+ .nullable()
90
+ .optional()
91
+ .default(null)
92
+ .describe(
93
+ "승인된 간편결제사. `approvedMethod`가 `EASY_PAY`일 때만 값이 있고, PG가 알려 주지 않았으면 요청한 간편결제사다. " +
94
+ "그 밖의 결제수단과 간편결제 이전 주문은 null. 서버는 항상 싣는다. " +
95
+ "현재 값은 `NAVERPAY`·`KAKAOPAY`·`TOSSPAY`·`PAYCO`이고 값이 추가될 수 있어 문자열로 받는다",
96
+ ),
87
97
  productAmount: z.number().int(),
88
98
  deliveryFee: z.number().int(),
89
99
  discountAmount: z.number().int(),
@@ -19,20 +19,19 @@ export const pgPaymentMethodSchema = z
19
19
  .describe("결제수단 (스토어프론트 결제 요청의 `paymentMethod`와 같은 값)");
20
20
  export type PgPaymentMethod = z.infer<typeof pgPaymentMethodSchema>;
21
21
 
22
- export const paymentRoutingModeSchema = z
23
- .enum(["PRIORITY_DISPLAY", "AUTO_FAILOVER"])
24
- .describe(
25
- "PG가 둘 이상 사용 중일 때의 라우팅 방식. `PRIORITY_DISPLAY`=결제창에 사용 중인 PG를 모두 보여 주고 1순위를 기본 선택, " +
26
- "`AUTO_FAILOVER`=1순위로 결제창을 열고, 결제창을 띄우기 전 장애(PG 준비 실패)일 때만 다음 순위로 자동 전환",
27
- );
28
- export type PaymentRoutingMode = z.infer<typeof paymentRoutingModeSchema>;
22
+ /**
23
+ * 간편결제사 — 결제 옵션(스토어프론트 `paymentOptions`)과 PG 설정의 `easyPayProviders`에 쓴다.
24
+ * `@sayren/storefront-sdk`의 `easyPayProviderSchema`와 같은 값이다(api 계약 테스트가 고정한다)
25
+ */
26
+ export const easyPayProviderSchema = z.enum(["NAVERPAY", "KAKAOPAY", "TOSSPAY", "PAYCO"]);
27
+ export type EasyPayProvider = z.infer<typeof easyPayProviderSchema>;
29
28
 
30
- export const effectivePaymentRoutingModeSchema = z
31
- .enum(["NONE", "SINGLE", "PRIORITY_DISPLAY", "AUTO_FAILOVER"])
32
- .describe(
33
- "실제로 적용되는 라우팅. 사용 중인 PG가 0개면 `NONE`(결제 불가), 1개면 `SINGLE`, 2개 이상이면 `routingMode` 값",
34
- );
35
- export type EffectivePaymentRoutingMode = z.infer<typeof effectivePaymentRoutingModeSchema>;
29
+ /**
30
+ * 응답에 실리는 간편결제사 — 서버가 간편결제사를 추가해도 파싱이 깨지지 않게 문자열로 받는다
31
+ * (`pgProvider`·결제수단과 같은 방식). 요청(`easyPayProviders`)은 값을 골라 보내는 자리라 enum 그대로다.
32
+ * 현재 값은 `NAVERPAY`·`KAKAOPAY`·`TOSSPAY`·`PAYCO`이고 화면은 모르는 값을 원문 그대로 보여 준다.
33
+ */
34
+ export const easyPayProviderResponseSchema = z.string();
36
35
 
37
36
  export const pgEnvironmentSchema = z
38
37
  .enum(["TEST", "LIVE"])
@@ -174,10 +173,22 @@ export const updatePaymentProviderRequestSchema = z
174
173
  "샌드박스 스위치. `true`면 새 결제에 TEST 세트를 쓴다(항상 허용). `false`(라이브 전환)는 라이브 개방 조건을 " +
175
174
  "모두 충족해야 한다(`LIVE_GATE_NOT_MET`)",
176
175
  ),
176
+ easyPayProviders: z
177
+ .array(easyPayProviderSchema)
178
+ .max(easyPayProviderSchema.options.length)
179
+ .optional()
180
+ .describe(
181
+ "이 PG로 받는 간편결제사 — 통째로 바꾼다. PG 계약(포트원은 채널)에 추가한 간편결제만 켠다. " +
182
+ "켠 간편결제사마다 스토어프론트 결제 옵션이 하나씩 생긴다(포트원은 간편결제를 받는 채널이 있어야 한다)",
183
+ ),
177
184
  })
178
- .refine((body) => body.enabled !== undefined || body.sandboxMode !== undefined, {
179
- message: "enabled 또는 sandboxMode 중 하나는 보내야 해요",
180
- });
185
+ .refine(
186
+ (body) =>
187
+ body.enabled !== undefined ||
188
+ body.sandboxMode !== undefined ||
189
+ body.easyPayProviders !== undefined,
190
+ { message: "enabled·sandboxMode·easyPayProviders 중 하나는 보내야 해요" },
191
+ );
181
192
  export type UpdatePaymentProviderRequest = z.infer<typeof updatePaymentProviderRequestSchema>;
182
193
 
183
194
  export const verifyPaymentProviderRequestSchema = z.object({
@@ -187,18 +198,31 @@ export const verifyPaymentProviderRequestSchema = z.object({
187
198
  });
188
199
  export type VerifyPaymentProviderRequest = z.infer<typeof verifyPaymentProviderRequestSchema>;
189
200
 
190
- export const updatePaymentSettingsRequestSchema = z.object({
191
- routingMode: paymentRoutingModeSchema,
192
- priority: z
193
- .array(pgProviderSchema)
194
- .min(1)
195
- // PG 개수와 무관한 안전장치 — 거대한 배열이 파싱을 거치지 않게만 막는다. 실제 개수는 서버가 본다
196
- .max(50)
197
- .describe(
198
- "PG 우선순위(앞이 1순위). 연결된 PG 수만큼 **모두 한 번씩** 나열한다(길이 상한 50은 안전장치다). " +
199
- "빠지거나 중복되거나 연결되지 않은 PG가 있으면 `INVALID_PRIORITY`",
200
- ),
201
- });
201
+ export const updatePaymentSettingsRequestSchema = z
202
+ .object({
203
+ priority: z
204
+ .array(pgProviderSchema)
205
+ .min(1)
206
+ // PG 개수와 무관한 안전장치 — 거대한 배열이 파싱을 거치지 않게만 막는다. 실제 개수는 서버가 본다
207
+ .max(50)
208
+ .optional()
209
+ .describe(
210
+ "PG 우선순위(앞이 1순위) — 스토어프론트 결제 옵션 목록의 PG 순서다. 연결된 PG 수만큼 **모두 한 번씩** 나열한다" +
211
+ "(길이 상한 50은 안전장치다). 빠지거나 중복되거나 연결되지 않은 PG가 있으면 `INVALID_PRIORITY`",
212
+ ),
213
+ checkoutOrigins: z
214
+ .array(z.string().max(200))
215
+ .max(20)
216
+ .optional()
217
+ .describe(
218
+ "결제 도메인 — 통째로 바꾼다. 결제를 마친 구매자가 돌아올 스토어프론트 origin(`https://shop.example.com`, https·경로 없음). " +
219
+ "결제 시작의 `returnUrl`은 이 목록 안이어야 한다. 비어 있으면 실결제를 시작할 수 없다(테스트 결제는 localhost 허용). " +
220
+ "같은 도메인을 PG 가맹 정보에도 등록한다. 형식이 틀리면 `INVALID_CHECKOUT_ORIGIN`",
221
+ ),
222
+ })
223
+ .refine((body) => body.priority !== undefined || body.checkoutOrigins !== undefined, {
224
+ message: "priority 또는 checkoutOrigins 중 하나는 보내야 해요",
225
+ });
202
226
  export type UpdatePaymentSettingsRequest = z.infer<typeof updatePaymentSettingsRequestSchema>;
203
227
 
204
228
  // ─── 조회 응답 ─────────────────────────────────────────────
@@ -318,6 +342,12 @@ export const paymentProviderSchema = z.object({
318
342
  })
319
343
  .describe("환경별 자격 증명 세트. 등록되지 않은 세트는 null"),
320
344
  liveGate: liveGateSchema.describe("라이브 개방 조건. 조건을 모두 충족해야 샌드박스를 끌 수 있다"),
345
+ easyPayProviders: z
346
+ .array(easyPayProviderResponseSchema)
347
+ .describe(
348
+ "이 PG로 받는 간편결제사. 연결되지 않았으면 빈 목록. " +
349
+ "현재 값은 `NAVERPAY`·`KAKAOPAY`·`TOSSPAY`·`PAYCO`이고 값이 추가될 수 있어 문자열로 받는다",
350
+ ),
321
351
  retiredCredentialCount: z
322
352
  .number()
323
353
  .int()
@@ -356,10 +386,9 @@ export const PAYMENT_ROUTING_EXCLUSION_CODES = [
356
386
  export type KnownPaymentRoutingExclusion = (typeof PAYMENT_ROUTING_EXCLUSION_CODES)[number];
357
387
 
358
388
  export const paymentSettingsSchema = z.object({
359
- routingMode: paymentRoutingModeSchema.describe(
360
- "저장된 라우팅 방식 (기본 `AUTO_FAILOVER`). PG가 하나뿐이면 무시된다",
361
- ),
362
- effectiveRoutingMode: effectivePaymentRoutingModeSchema,
389
+ checkoutOrigins: z
390
+ .array(z.string())
391
+ .describe("결제 도메인 — 결제를 마친 구매자가 돌아올 스토어프론트 origin 목록"),
363
392
  providers: z
364
393
  .array(paymentProviderSchema)
365
394
  .describe("지원하는 모든 PG. 연결된 것은 우선순위순, 연결되지 않은 것은 뒤에 온다"),
@@ -495,6 +524,15 @@ export const paymentRecordSchema = z.object({
495
524
  refundedAmount: z.number().int().describe("환불 완료 금액 합계. 실패한 환불은 빼고 센다"),
496
525
  provider: pgProviderSchema.nullable().describe("승인한 PG. 승인 전에 끝났으면 마지막 시도의 PG"),
497
526
  method: pgPaymentMethodSchema.nullable().describe("실제 결제수단. 모르면 요청 수단"),
527
+ easyPayProvider: easyPayProviderResponseSchema
528
+ .nullable()
529
+ .optional()
530
+ .default(null)
531
+ .describe(
532
+ "간편결제사. `method`가 `EASY_PAY`일 때만 값이 있고, PG가 알려 주지 않았으면 요청한 간편결제사다. " +
533
+ "그 밖의 결제수단과 간편결제 이전 결제는 null. 서버는 항상 싣는다. " +
534
+ "현재 값은 `NAVERPAY`·`KAKAOPAY`·`TOSSPAY`·`PAYCO`이고 값이 추가될 수 있어 문자열로 받는다",
535
+ ),
498
536
  environment: pgEnvironmentSchema.nullable().describe("결제 환경"),
499
537
  testPayment: z.boolean().describe("샌드박스 결제인가"),
500
538
  pgTransactionId: z.string().nullable().describe("PG 거래 id (토스 paymentKey, 포트원 txId)"),
@@ -516,7 +554,21 @@ export const paymentAttemptRecordSchema = z.object({
516
554
  provider: pgProviderSchema,
517
555
  status: paymentAttemptStatusSchema,
518
556
  method: pgPaymentMethodSchema.describe("주문서에서 고른 결제수단"),
557
+ easyPayProvider: easyPayProviderResponseSchema
558
+ .nullable()
559
+ .optional()
560
+ .default(null)
561
+ .describe(
562
+ "주문서에서 고른 간편결제사. 간편결제가 아니면 null. 서버는 항상 싣는다(값이 추가될 수 있어 문자열)",
563
+ ),
519
564
  approvedMethod: pgPaymentMethodSchema.nullable().describe("PG가 보고한 실제 결제수단"),
565
+ approvedEasyPayProvider: easyPayProviderResponseSchema
566
+ .nullable()
567
+ .optional()
568
+ .default(null)
569
+ .describe(
570
+ "PG가 보고한 실제 간편결제사. 모르거나 간편결제가 아니면 null. 서버는 항상 싣는다(값이 추가될 수 있어 문자열)",
571
+ ),
520
572
  amount: z.number().int(),
521
573
  environment: pgEnvironmentSchema,
522
574
  pgTransactionId: z.string().nullable(),
package/src/server.ts CHANGED
@@ -3,6 +3,12 @@
3
3
  * 브라우저 번들에서 import하지 않는다.
4
4
  */
5
5
 
6
+ export {
7
+ type DelegatedMemberToken,
8
+ delegateMemberByEmail,
9
+ delegateMemberByUid,
10
+ MemberDelegationError,
11
+ } from "./member-delegation";
6
12
  export {
7
13
  WEBHOOK_DEFAULT_TOLERANCE_SEC,
8
14
  WEBHOOK_DELIVERY_ID_HEADER,