@sayren/store-sdk 0.50.0 → 0.51.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,4 @@
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";
1
+ import { c as WEBHOOK_DELIVERY_ID_HEADER, d as WEBHOOK_HEADERS, f as WEBHOOK_SIGNATURE_HEADER, l as WEBHOOK_EVENT_ID_HEADER, p as WEBHOOK_USER_AGENT, r as CustomerUserinfoInput, s as WEBHOOK_DEFAULT_TOLERANCE_SEC, u as WEBHOOK_EVENT_TYPE_HEADER } from "./userinfo-n1JYxWAz.mjs";
2
2
  //#region src/member-delegation.d.ts
3
3
  /**
4
4
  * 구매자 위임 — 자체 회원 시스템을 가진 셀러 서버가 로그인한 사용자를 sayren 구매자 토큰으로 바꾼다.
@@ -31,6 +31,8 @@ export declare function delegateMember(options: {
31
31
  email?: string;
32
32
  name?: string;
33
33
  authenticatedAt?: Date;
34
+ /** OIDC 클레임(이슈 #264) — 준 클레임만 바뀌고 null은 지운다 */
35
+ userinfo?: CustomerUserinfoInput;
34
36
  fetch?: typeof globalThis.fetch;
35
37
  }): Promise<DelegatedMemberToken>;
36
38
  //#endregion
package/dist/server.mjs CHANGED
@@ -29,7 +29,8 @@ async function delegateMember(options) {
29
29
  ...options.uid ? { uid: options.uid } : {},
30
30
  ...options.email ? { email: options.email } : {},
31
31
  ...options.name ? { name: options.name } : {},
32
- ...options.authenticatedAt ? { authenticatedAt: options.authenticatedAt.toISOString() } : {}
32
+ ...options.authenticatedAt ? { authenticatedAt: options.authenticatedAt.toISOString() } : {},
33
+ ...options.userinfo ? { userinfo: options.userinfo } : {}
33
34
  })
34
35
  });
35
36
  const body = await res.json().catch(() => ({}));
@@ -0,0 +1,90 @@
1
+ import * as z$1 from "zod";
2
+ //#region src/webhook-headers.d.ts
3
+ /** 웹훅 발송 요청 헤더 상수 (공개 계약). zod 의존 없이 서버 엔트리포인트에서도 가볍게 쓴다 */
4
+ /** 서명 헤더. 형식: `t=<unix초>,v1=<hex>[,v1=<hex>]` */
5
+ declare const WEBHOOK_SIGNATURE_HEADER = "Sayren-Signature";
6
+ /** 이벤트 ID 헤더. 본문 `id`와 같다. 중복 제거 키 */
7
+ declare const WEBHOOK_EVENT_ID_HEADER = "Sayren-Event-Id";
8
+ /** 이벤트 타입 헤더. 본문 `type`과 같다 */
9
+ declare const WEBHOOK_EVENT_TYPE_HEADER = "Sayren-Event-Type";
10
+ /** 전송 기록 ID 헤더. 전송 기록의 `deliveryId`와 같다 */
11
+ declare const WEBHOOK_DELIVERY_ID_HEADER = "Sayren-Delivery-Id";
12
+ /** 발송 요청의 `User-Agent` 값 */
13
+ declare const WEBHOOK_USER_AGENT = "Sayren-Webhooks/1.0";
14
+ /** 수신 측 권장 타임스탬프 허용 오차(초) */
15
+ declare const WEBHOOK_DEFAULT_TOLERANCE_SEC = 300;
16
+ declare const WEBHOOK_HEADERS: {
17
+ readonly signature: "Sayren-Signature";
18
+ readonly eventId: "Sayren-Event-Id";
19
+ readonly eventType: "Sayren-Event-Type";
20
+ readonly deliveryId: "Sayren-Delivery-Id";
21
+ };
22
+ //#endregion
23
+ //#region src/schemas/userinfo.d.ts
24
+ /**
25
+ * 구매자 정보의 OIDC userinfo 표준 클레임(OpenID Connect Core 1.0 5.1, 이슈 #264). 이름은 표준 그대로 snake_case다.
26
+ * 값이 없는 클레임은 응답에서 빠진다. 같은 형식이 스토어프론트 API(storefront-sdk `userinfoSchema`)에도 있다.
27
+ */
28
+ /** 생일 — `YYYY-MM-DD`, 연도를 모르면 `0000-MM-DD`(표준 형식) */
29
+ declare const CUSTOMER_BIRTHDATE_PATTERN: RegExp;
30
+ declare const customerUserinfoAddressSchema: z$1.ZodObject<{
31
+ formatted: z$1.ZodOptional<z$1.ZodString>;
32
+ street_address: z$1.ZodOptional<z$1.ZodString>;
33
+ locality: z$1.ZodOptional<z$1.ZodString>;
34
+ region: z$1.ZodOptional<z$1.ZodString>;
35
+ postal_code: z$1.ZodOptional<z$1.ZodString>;
36
+ country: z$1.ZodOptional<z$1.ZodString>;
37
+ }, z$1.core.$strip>;
38
+ declare const customerUserinfoSchema: z$1.ZodObject<{
39
+ sub: z$1.ZodString;
40
+ name: z$1.ZodOptional<z$1.ZodString>;
41
+ given_name: z$1.ZodOptional<z$1.ZodString>;
42
+ family_name: z$1.ZodOptional<z$1.ZodString>;
43
+ middle_name: z$1.ZodOptional<z$1.ZodString>;
44
+ nickname: z$1.ZodOptional<z$1.ZodString>;
45
+ preferred_username: z$1.ZodOptional<z$1.ZodString>;
46
+ profile: z$1.ZodOptional<z$1.ZodString>;
47
+ picture: z$1.ZodOptional<z$1.ZodString>;
48
+ website: z$1.ZodOptional<z$1.ZodString>;
49
+ email: z$1.ZodOptional<z$1.ZodString>;
50
+ email_verified: z$1.ZodOptional<z$1.ZodBoolean>;
51
+ gender: z$1.ZodOptional<z$1.ZodString>;
52
+ birthdate: z$1.ZodOptional<z$1.ZodString>;
53
+ zoneinfo: z$1.ZodOptional<z$1.ZodString>;
54
+ locale: z$1.ZodOptional<z$1.ZodString>;
55
+ phone_number: z$1.ZodOptional<z$1.ZodString>;
56
+ phone_number_verified: z$1.ZodOptional<z$1.ZodBoolean>;
57
+ address: z$1.ZodOptional<z$1.ZodObject<{
58
+ formatted: z$1.ZodOptional<z$1.ZodString>;
59
+ street_address: z$1.ZodOptional<z$1.ZodString>;
60
+ locality: z$1.ZodOptional<z$1.ZodString>;
61
+ region: z$1.ZodOptional<z$1.ZodString>;
62
+ postal_code: z$1.ZodOptional<z$1.ZodString>;
63
+ country: z$1.ZodOptional<z$1.ZodString>;
64
+ }, z$1.core.$strip>>;
65
+ updated_at: z$1.ZodOptional<z$1.ZodNumber>;
66
+ }, z$1.core.$strip>;
67
+ type CustomerUserinfo = z$1.infer<typeof customerUserinfoSchema>;
68
+ /**
69
+ * 셀러가 위임으로 바꿀 수 있는 클레임 — 준 클레임만 바뀌고 null은 지운다. `sub`·`email`·`phone_number`·`updated_at`·`address`는 받지 않는다. 확인 여부(`*_verified`)는 셀러가 확인한 값을 받는다
70
+ * (이메일·휴대폰은 각자의 칸, 주소는 배송지가 원천이다)
71
+ */
72
+ declare const customerUserinfoInputSchema: z$1.ZodObject<{
73
+ email_verified: z$1.ZodOptional<z$1.ZodBoolean>;
74
+ phone_number_verified: z$1.ZodOptional<z$1.ZodBoolean>;
75
+ given_name: z$1.ZodOptional<z$1.ZodNullable<z$1.ZodString>>;
76
+ family_name: z$1.ZodOptional<z$1.ZodNullable<z$1.ZodString>>;
77
+ middle_name: z$1.ZodOptional<z$1.ZodNullable<z$1.ZodString>>;
78
+ nickname: z$1.ZodOptional<z$1.ZodNullable<z$1.ZodString>>;
79
+ preferred_username: z$1.ZodOptional<z$1.ZodNullable<z$1.ZodString>>;
80
+ profile: z$1.ZodOptional<z$1.ZodNullable<z$1.ZodURL>>;
81
+ picture: z$1.ZodOptional<z$1.ZodNullable<z$1.ZodURL>>;
82
+ website: z$1.ZodOptional<z$1.ZodNullable<z$1.ZodURL>>;
83
+ gender: z$1.ZodOptional<z$1.ZodNullable<z$1.ZodString>>;
84
+ birthdate: z$1.ZodOptional<z$1.ZodNullable<z$1.ZodString>>;
85
+ zoneinfo: z$1.ZodOptional<z$1.ZodNullable<z$1.ZodString>>;
86
+ locale: z$1.ZodOptional<z$1.ZodNullable<z$1.ZodString>>;
87
+ }, z$1.core.$strict>;
88
+ type CustomerUserinfoInput = z$1.infer<typeof customerUserinfoInputSchema>;
89
+ //#endregion
90
+ export { customerUserinfoInputSchema as a, WEBHOOK_DELIVERY_ID_HEADER as c, WEBHOOK_HEADERS as d, WEBHOOK_SIGNATURE_HEADER as f, customerUserinfoAddressSchema as i, WEBHOOK_EVENT_ID_HEADER as l, CustomerUserinfo as n, customerUserinfoSchema as o, WEBHOOK_USER_AGENT as p, CustomerUserinfoInput as r, WEBHOOK_DEFAULT_TOLERANCE_SEC as s, CUSTOMER_BIRTHDATE_PATTERN as t, WEBHOOK_EVENT_TYPE_HEADER as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/store-sdk",
3
- "version": "0.50.0",
3
+ "version": "0.51.0",
4
4
  "main": "./dist/index.mjs",
5
5
  "types": "./dist/index.d.mts",
6
6
  "exports": {
package/src/client.ts CHANGED
@@ -168,11 +168,13 @@ import {
168
168
  import { bulkResultSchema, type PageParams, pageSchema } from "./schemas/common";
169
169
  import {
170
170
  type CreateCouponRequest,
171
+ couponDistributionSchema,
171
172
  couponIssueSchema,
172
173
  type couponListQuerySchema,
173
174
  couponRedemptionSchema,
174
175
  couponSchema,
175
176
  couponStatsSchema,
177
+ couponVoucherCodesSchema,
176
178
  createCouponRequestSchema,
177
179
  customerCouponSchema,
178
180
  type IssueCouponRequest,
@@ -1550,6 +1552,33 @@ export function createStoreClient(options: StoreClientOptions) {
1550
1552
  body: issueCouponRequestSchema.parse(body),
1551
1553
  headers: idempotency(idempotencyKey),
1552
1554
  }),
1555
+ /** 쿠폰 번호 전체(`VOUCHER`, #264) — 순번 순 */
1556
+ voucherCodes: (couponId: string) =>
1557
+ storeHttp.request(
1558
+ "GET",
1559
+ path`/coupons/${couponId}/voucher-codes`,
1560
+ couponVoucherCodesSchema,
1561
+ ),
1562
+ /** 발급 대상 전원 지급(#264) — 202로 작업을 만든다. 진행은 `distributions`·`distribution`으로 본다 */
1563
+ distribute: (couponId: string) =>
1564
+ storeHttp.request(
1565
+ "POST",
1566
+ path`/coupons/${couponId}/distributions`,
1567
+ couponDistributionSchema,
1568
+ ),
1569
+ /** 전원 지급 작업 목록(최근 20건, 최신순) */
1570
+ distributions: (couponId: string) =>
1571
+ storeHttp.request(
1572
+ "GET",
1573
+ path`/coupons/${couponId}/distributions`,
1574
+ z.array(couponDistributionSchema),
1575
+ ),
1576
+ distribution: (couponId: string, distributionId: string) =>
1577
+ storeHttp.request(
1578
+ "GET",
1579
+ path`/coupons/${couponId}/distributions/${distributionId}`,
1580
+ couponDistributionSchema,
1581
+ ),
1553
1582
  issues: (
1554
1583
  couponId: string,
1555
1584
  params?: PageParams & { status?: "AVAILABLE" | "RESERVED" | "USED" | "REVOKED" },
package/src/index.ts CHANGED
@@ -67,6 +67,7 @@ export * from "./schemas/storefront-hosting";
67
67
  export * from "./schemas/subscription";
68
68
  export * from "./schemas/telemetry";
69
69
  export * from "./schemas/upload";
70
+ export * from "./schemas/userinfo";
70
71
  export * from "./schemas/webhook-event";
71
72
  export {
72
73
  type AuthorizeOptions,
@@ -1,4 +1,5 @@
1
1
  import { SAYREN_API_ORIGIN } from "./http";
2
+ import type { CustomerUserinfoInput } from "./schemas/userinfo";
2
3
  /**
3
4
  * 구매자 위임 — 자체 회원 시스템을 가진 셀러 서버가 로그인한 사용자를 sayren 구매자 토큰으로 바꾼다.
4
5
  * 계정 토큰(개인 액세스 토큰 등)을 쓰므로 **서버에서만** 부른다.
@@ -37,6 +38,8 @@ export async function delegateMember(options: {
37
38
  email?: string;
38
39
  name?: string;
39
40
  authenticatedAt?: Date;
41
+ /** OIDC 클레임(이슈 #264) — 준 클레임만 바뀌고 null은 지운다 */
42
+ userinfo?: CustomerUserinfoInput;
40
43
  fetch?: typeof globalThis.fetch;
41
44
  }): Promise<DelegatedMemberToken> {
42
45
  const doFetch = options.fetch ?? globalThis.fetch;
@@ -56,6 +59,7 @@ export async function delegateMember(options: {
56
59
  ...(options.authenticatedAt
57
60
  ? { authenticatedAt: options.authenticatedAt.toISOString() }
58
61
  : {}),
62
+ ...(options.userinfo ? { userinfo: options.userinfo } : {}),
59
63
  }),
60
64
  },
61
65
  );
@@ -38,10 +38,60 @@ export function isConditionGroup(node: ConditionNode): node is ConditionGroup {
38
38
  * - `idSet`: 문자열 id(`in` — 값 목록 중 하나. 사실 값이 목록이면 하나라도 겹치면 참)
39
39
  * - `enumSet`: 정해진 선택지(`in`)
40
40
  * - `timeRange`: `HH:MM` 구간(`between` — `[시작, 끝]`, 끝 제외. 시작이 끝보다 크면 자정을 넘는 구간)
41
+ * - `birthdate`: 생일에서 나온 값(`BirthdateFact`). 만 나이 `ageBetween`(`[이상, 이하]`)·`age>=`·`age<=`, 생일 달 `birthMonthIn`(월 목록 1~12)·
42
+ * `birthMonthIsCurrent`(값 `true` — 판정 시각이 생일 달). 연도를 모르는 생일은 나이 연산자가 거짓이다
41
43
  */
42
- export type ConditionValueType = "integer" | "boolean" | "idSet" | "enumSet" | "timeRange";
44
+ export type ConditionValueType =
45
+ | "integer"
46
+ | "boolean"
47
+ | "idSet"
48
+ | "enumSet"
49
+ | "timeRange"
50
+ | "birthdate";
51
+
52
+ export type ConditionOperator =
53
+ | "="
54
+ | ">="
55
+ | "<="
56
+ | "between"
57
+ | "in"
58
+ | "ageBetween"
59
+ | "age>="
60
+ | "age<="
61
+ | "birthMonthIn"
62
+ | "birthMonthIsCurrent";
63
+
64
+ /** `birthdate` 사실 값 — 원본 날짜가 아니라 판정에 필요한 값만 둔다 */
65
+ export interface BirthdateFact {
66
+ /** 만 나이. 연도를 모르면 생략 */
67
+ age?: number;
68
+ /** 생일 달(1~12) */
69
+ month: number;
70
+ /** 판정 시각이 생일 달인가 */
71
+ isCurrentMonth: boolean;
72
+ }
73
+
74
+ const BIRTHDATE_TEXT = /^(\d{4})-(\d{2})-(\d{2})$/;
43
75
 
44
- export type ConditionOperator = "=" | ">=" | "<=" | "between" | "in";
76
+ /**
77
+ * 생일(`YYYY-MM-DD`, 연도를 모르면 `0000-MM-DD`)과 판정 날짜(쓰는 쪽 달력의 연·월·일)로 사실 값을 만든다. 읽지 못하면 undefined
78
+ */
79
+ export function birthdateFactOf(
80
+ birthdate: string | null | undefined,
81
+ today: { year: number; month: number; day: number },
82
+ ): BirthdateFact | undefined {
83
+ const match = birthdate ? BIRTHDATE_TEXT.exec(birthdate) : null;
84
+ if (!match) return undefined;
85
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
86
+ if (month < 1 || month > 12 || day < 1 || day > 31) return undefined;
87
+ const fact: BirthdateFact = { month, isCurrentMonth: month === today.month };
88
+ if (year > 0) {
89
+ const beforeBirthday = today.month < month || (today.month === month && today.day < day);
90
+ const age = today.year - year - (beforeBirthday ? 1 : 0);
91
+ if (age >= 0) fact.age = age;
92
+ }
93
+ return fact;
94
+ }
45
95
 
46
96
  export const CONDITION_OPERATORS_BY_TYPE: Readonly<
47
97
  Record<ConditionValueType, readonly ConditionOperator[]>
@@ -51,8 +101,12 @@ export const CONDITION_OPERATORS_BY_TYPE: Readonly<
51
101
  idSet: ["in"],
52
102
  enumSet: ["in"],
53
103
  timeRange: ["between"],
104
+ birthdate: ["ageBetween", "age>=", "age<=", "birthMonthIn", "birthMonthIsCurrent"],
54
105
  };
55
106
 
107
+ /** 나이 값 범위 */
108
+ const AGE_MAX = 150;
109
+
56
110
  /** 필드 정의 — `Ctx`는 쓰는 쪽의 평가 컨텍스트다 */
57
111
  export interface ConditionFieldDef<Ctx> {
58
112
  name: string;
@@ -183,6 +237,26 @@ function valueValid<Ctx>(
183
237
  value.every((v) => typeof v === "string" && TIME_PATTERN.test(v)) &&
184
238
  value[0] !== value[1]
185
239
  );
240
+ case "birthdate": {
241
+ const age = { min: 0, max: AGE_MAX };
242
+ if (operator === "ageBetween")
243
+ return (
244
+ Array.isArray(value) &&
245
+ value.length === 2 &&
246
+ isInteger(value[0], age) &&
247
+ isInteger(value[1], age) &&
248
+ value[0] <= value[1]
249
+ );
250
+ if (operator === "age>=" || operator === "age<=") return isInteger(value, age);
251
+ if (operator === "birthMonthIn")
252
+ return (
253
+ Array.isArray(value) &&
254
+ value.length > 0 &&
255
+ value.length <= 12 &&
256
+ value.every((v) => isInteger(v, { min: 1, max: 12 }))
257
+ );
258
+ return operator === "birthMonthIsCurrent" && value === true;
259
+ }
186
260
  }
187
261
  }
188
262
 
@@ -250,7 +324,9 @@ export function canonicalizeConditionRule(rule: ConditionRule): ConditionRule {
250
324
  const value =
251
325
  rule.operator === "in" && Array.isArray(rule.value)
252
326
  ? [...new Set(rule.value as string[])].sort()
253
- : rule.value;
327
+ : rule.operator === "birthMonthIn" && Array.isArray(rule.value)
328
+ ? [...new Set(rule.value as number[])].sort((a, b) => a - b)
329
+ : rule.value;
254
330
  return { field: rule.field, operator: rule.operator, value };
255
331
  }
256
332
 
@@ -311,6 +387,30 @@ function ruleHolds<Ctx>(rule: ConditionRule, catalog: ConditionCatalog<Ctx>, ctx
311
387
  // 끝 제외. 시작 > 끝이면 자정을 넘는 구간(22:00~02:00)
312
388
  return from < to ? at >= from && at < to : at >= from || at < to;
313
389
  }
390
+ case "birthdate": {
391
+ if (typeof fact !== "object") return false;
392
+ const birth = fact as BirthdateFact;
393
+ const age = birth.age;
394
+ if (operator === "ageBetween" && Array.isArray(value)) {
395
+ const [from, to] = value as [unknown, unknown];
396
+ return (
397
+ typeof age === "number" &&
398
+ typeof from === "number" &&
399
+ typeof to === "number" &&
400
+ age >= from &&
401
+ age <= to
402
+ );
403
+ }
404
+ if (operator === "age>=")
405
+ return typeof age === "number" && typeof value === "number" && age >= value;
406
+ if (operator === "age<=")
407
+ return typeof age === "number" && typeof value === "number" && age <= value;
408
+ if (operator === "birthMonthIn")
409
+ return Array.isArray(value) && (value as unknown[]).includes(birth.month);
410
+ if (operator === "birthMonthIsCurrent")
411
+ return value === true && birth.isCurrentMonth === true;
412
+ return false;
413
+ }
314
414
  }
315
415
  }
316
416
 
@@ -1,6 +1,8 @@
1
1
  import * as z from "zod";
2
2
  import {
3
3
  anyConditionHolds,
4
+ type BirthdateFact,
5
+ birthdateFactOf,
4
6
  type ConditionCatalog,
5
7
  type ConditionFieldDef,
6
8
  type ConditionGroup,
@@ -53,6 +55,62 @@ export interface CouponCustomerFacts {
53
55
  paidOrderCount?: number;
54
56
  /** 마지막 주문 뒤 지난 날 수(내림). 주문이 없으면 생략 */
55
57
  daysSinceLastOrder?: number;
58
+ /**
59
+ * 구매자 OIDC 클레임(이슈 #264)에서 나온 값 — `claims.*` 규칙이 읽는다. 주문 스냅샷에는 굳히지 않는다(`pickCouponFacts`) —
60
+ * 생일·성별을 주문에 남기지 않고, 클레임은 주문서 금액과 무관해 환불 재판정이 결제 시작의 판정을 그대로 따른다
61
+ */
62
+ claims?: CouponClaimFacts;
63
+ }
64
+
65
+ /** `claims.*` 사실 값 — 없는 클레임은 생략(그 규칙은 거짓) */
66
+ export interface CouponClaimFacts {
67
+ gender?: string;
68
+ /** 생일에서 나온 나이·생일 달 — 원본 날짜는 두지 않는다 */
69
+ birthdate?: BirthdateFact;
70
+ email_verified?: boolean;
71
+ phone_number_verified?: boolean;
72
+ /** 언어 태그와 그 앞부분(`en-US` → `["en-US", "en"]`) */
73
+ locale?: string[];
74
+ /** 기본 배송지 국가(ISO 3166-1 alpha-2) */
75
+ addressCountry?: string;
76
+ }
77
+
78
+ /** BCP 47 정규형(`ko-kr` → `ko-KR`) — 사실 값과 규칙 값이 같은 표기여야 목록 비교가 맞는다. 읽지 못하면 undefined */
79
+ export function canonicalLocale(tag: string | null | undefined): string | undefined {
80
+ if (!tag) return undefined;
81
+ try {
82
+ return Intl.getCanonicalLocales(tag)[0];
83
+ } catch {
84
+ return undefined;
85
+ }
86
+ }
87
+
88
+ /** 클레임 원본 → 사실 값. `now`는 판정 시각(생일 달·만 나이는 한국 날짜) */
89
+ export function couponClaimFactsOf(
90
+ claims: {
91
+ gender?: string | null;
92
+ birthdate?: string | null;
93
+ email_verified?: boolean | null;
94
+ phone_number_verified?: boolean | null;
95
+ locale?: string | null;
96
+ addressCountry?: string | null;
97
+ },
98
+ now: Date,
99
+ ): CouponClaimFacts {
100
+ const facts: CouponClaimFacts = {};
101
+ if (claims.gender) facts.gender = claims.gender;
102
+ const birth = birthdateFactOf(claims.birthdate, couponTodayAt(now));
103
+ if (birth) facts.birthdate = birth;
104
+ if (typeof claims.email_verified === "boolean") facts.email_verified = claims.email_verified;
105
+ if (typeof claims.phone_number_verified === "boolean")
106
+ facts.phone_number_verified = claims.phone_number_verified;
107
+ const locale = canonicalLocale(claims.locale);
108
+ if (locale) {
109
+ const primary = locale.split("-")[0] ?? locale;
110
+ facts.locale = [...new Set([locale, primary])];
111
+ }
112
+ if (claims.addressCountry) facts.addressCountry = claims.addressCountry.toUpperCase();
113
+ return facts;
56
114
  }
57
115
 
58
116
  export interface CouponTimeFacts {
@@ -95,6 +153,19 @@ const WEEKDAY_OF: Record<string, CouponWeekday> = {
95
153
  Sun: "SUN",
96
154
  };
97
155
 
156
+ const SEOUL_DATE = new Intl.DateTimeFormat("en-CA", {
157
+ timeZone: COUPON_TIME_ZONE,
158
+ year: "numeric",
159
+ month: "2-digit",
160
+ day: "2-digit",
161
+ });
162
+
163
+ /** 판정 시각의 한국 날짜 */
164
+ export function couponTodayAt(now: Date): { year: number; month: number; day: number } {
165
+ const parts = Object.fromEntries(SEOUL_DATE.formatToParts(now).map((p) => [p.type, p.value]));
166
+ return { year: Number(parts.year), month: Number(parts.month), day: Number(parts.day) };
167
+ }
168
+
98
169
  /** 판정 시각의 요일·시각(Asia/Seoul) */
99
170
  export function couponTimeFactsAt(now: Date): CouponTimeFacts {
100
171
  const parts = Object.fromEntries(SEOUL.formatToParts(now).map((p) => [p.type, p.value]));
@@ -209,6 +280,43 @@ const ORDER_FIELDS: readonly (ConditionFieldDef<CouponOrderContext> &
209
280
  max: DAY_MAX,
210
281
  resolve: (ctx) => ctx.facts.customer?.daysSinceLastOrder,
211
282
  },
283
+ {
284
+ name: "claims.gender",
285
+ type: "enumSet",
286
+ slots: ["audience", "eligibility"],
287
+ options: ["female", "male"],
288
+ resolve: (ctx) => ctx.facts.customer?.claims?.gender,
289
+ },
290
+ {
291
+ name: "claims.birthdate",
292
+ type: "birthdate",
293
+ slots: ["audience", "eligibility"],
294
+ resolve: (ctx) => ctx.facts.customer?.claims?.birthdate,
295
+ },
296
+ {
297
+ name: "claims.email_verified",
298
+ type: "boolean",
299
+ slots: ["audience", "eligibility"],
300
+ resolve: (ctx) => ctx.facts.customer?.claims?.email_verified,
301
+ },
302
+ {
303
+ name: "claims.phone_number_verified",
304
+ type: "boolean",
305
+ slots: ["audience", "eligibility"],
306
+ resolve: (ctx) => ctx.facts.customer?.claims?.phone_number_verified,
307
+ },
308
+ {
309
+ name: "claims.locale",
310
+ type: "idSet",
311
+ slots: ["audience", "eligibility"],
312
+ resolve: (ctx) => ctx.facts.customer?.claims?.locale,
313
+ },
314
+ {
315
+ name: "claims.address.country",
316
+ type: "idSet",
317
+ slots: ["audience", "eligibility"],
318
+ resolve: (ctx) => ctx.facts.customer?.claims?.addressCountry,
319
+ },
212
320
  {
213
321
  name: "cart.subtotal",
214
322
  type: "integer",
@@ -547,6 +655,7 @@ export function assumeCouponFactRules(group: ConditionGroup, facts: CouponFacts)
547
655
  return (
548
656
  facts.customer?.[field.slice("customer.".length) as keyof CouponCustomerFacts] !== undefined
549
657
  );
658
+ if (field.startsWith("claims.")) return facts.customer?.claims !== undefined;
550
659
  if (field.startsWith("time.")) return facts.time !== undefined;
551
660
  return true;
552
661
  };
@@ -588,6 +697,8 @@ export function couponCustomerFactNames(
588
697
  for (const field of conditionFields(group)) {
589
698
  if (field.startsWith("customer."))
590
699
  names.add(field.slice("customer.".length) as keyof CouponCustomerFacts);
700
+ // 클레임은 한 묶음으로 읽는다(회원 행 + 기본 배송지)
701
+ if (field.startsWith("claims.")) names.add("claims");
591
702
  }
592
703
  }
593
704
  return names;
@@ -598,6 +709,8 @@ export function pickCouponFacts(facts: CouponFacts, eligibility: ConditionGroup)
598
709
  const fields = conditionFields(eligibility);
599
710
  const customer: CouponCustomerFacts = {};
600
711
  for (const name of couponCustomerFactNames(eligibility)) {
712
+ // 클레임은 굳히지 않는다 — 환불 재판정은 없는 사실을 통과로 본다(`assumeCouponFactRules`)
713
+ if (name === "claims") continue;
601
714
  const value = facts.customer?.[name];
602
715
  if (value !== undefined) (customer as Record<string, unknown>)[name] = value;
603
716
  }
@@ -702,6 +815,38 @@ export function couponRuleIssues(input: {
702
815
  path: ["perMemberUseLimit"],
703
816
  message: "결제 완료 주문 수 조건이 있는 코드 입력형 쿠폰은 1인 사용 한도가 필요합니다",
704
817
  });
818
+ // 클레임 목록 값은 사실 값과 같은 표기만 받는다 — 국가는 대문자 두 자, 언어 태그는 BCP 47 정규형(`ko-KR`, 앞부분은 `ko`)
819
+ for (const slot of ["eligibility", "audience"] as const) {
820
+ const walk = (group: ConditionGroup, path: (string | number)[]): void => {
821
+ for (const [index, node] of group.rules.entries()) {
822
+ const at = [...path, "rules", index];
823
+ if (isConditionGroup(node)) {
824
+ walk(node, at);
825
+ continue;
826
+ }
827
+ if (!Array.isArray(node.value)) continue;
828
+ const values = node.value as unknown[];
829
+ if (
830
+ node.field === "claims.address.country" &&
831
+ !values.every((v) => typeof v === "string" && /^[A-Z]{2}$/.test(v))
832
+ )
833
+ issues.push({
834
+ path: [...at, "value"],
835
+ message: "국가는 대문자 두 자 코드(예: KR)입니다",
836
+ });
837
+ if (
838
+ node.field === "claims.locale" &&
839
+ !values.every((v) => typeof v === "string" && canonicalLocale(v) === v)
840
+ )
841
+ issues.push({
842
+ path: [...at, "value"],
843
+ message: "언어 태그는 정규 표기(예: ko, ko-KR)입니다",
844
+ });
845
+ }
846
+ };
847
+ const group = input[slot];
848
+ if (group) walk(group, [slot]);
849
+ }
705
850
  if (
706
851
  input.issueMethod === "SIGNUP" &&
707
852
  couponCustomerFactNames(input.audience ?? EMPTY_CONDITION_GROUP).has("daysSinceJoined")
@@ -751,6 +896,8 @@ const FIELD_LABELS: Record<string, string> = {
751
896
  "line.unitPrice": "판매가",
752
897
  };
753
898
 
899
+ const GENDER_LABELS: Record<string, string> = { female: "여성", male: "남성" };
900
+
754
901
  const number = new Intl.NumberFormat("ko-KR");
755
902
 
756
903
  function amountText(field: string, value: unknown, formatMoney: (minor: number) => string) {
@@ -785,6 +932,18 @@ export function describeCouponRule(
785
932
  const [from, to] = rule.value as [string, string];
786
933
  return `${from}~${to}`;
787
934
  }
935
+ case "claims.gender":
936
+ return (rule.value as string[]).map((g) => GENDER_LABELS[g] ?? g).join("·");
937
+ case "claims.email_verified":
938
+ return rule.value ? "이메일 확인 회원" : "이메일 미확인 회원";
939
+ case "claims.phone_number_verified":
940
+ return rule.value ? "휴대폰 확인 회원" : "휴대폰 미확인 회원";
941
+ case "claims.locale":
942
+ return `언어 ${(rule.value as string[]).join("·")}`;
943
+ case "claims.address.country":
944
+ return `국가 ${(rule.value as string[]).join("·")}`;
945
+ case "claims.birthdate":
946
+ return describeBirthdateRule(rule);
788
947
  }
789
948
  const text = (v: unknown) => amountText(rule.field, v, formatMoney);
790
949
  if (rule.operator === "between") {
@@ -797,6 +956,25 @@ export function describeCouponRule(
797
956
  return `${label} ${text(rule.value)}${suffix}`;
798
957
  }
799
958
 
959
+ function describeBirthdateRule(rule: ConditionRule): string {
960
+ switch (rule.operator) {
961
+ case "ageBetween": {
962
+ const [from, to] = rule.value as [number, number];
963
+ return `만 ${from}~${to}세`;
964
+ }
965
+ case "age>=":
966
+ return `만 ${rule.value}세 이상`;
967
+ case "age<=":
968
+ return `만 ${rule.value}세 이하`;
969
+ case "birthMonthIn":
970
+ return `생일 ${(rule.value as number[]).join("·")}월`;
971
+ case "birthMonthIsCurrent":
972
+ return "생일 달";
973
+ default:
974
+ return "생일";
975
+ }
976
+ }
977
+
800
978
  export function describeCouponGroup(
801
979
  group: ConditionGroup,
802
980
  formatMoney?: (minor: number) => string,