@sayren/store-sdk 0.50.0 → 0.52.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.d.mts +1303 -666
- package/dist/index.mjs +584 -62
- package/dist/server.d.mts +3 -1
- package/dist/server.mjs +2 -1
- package/dist/userinfo-n1JYxWAz.d.mts +90 -0
- package/package.json +1 -1
- package/src/client.ts +29 -0
- package/src/index.ts +1 -0
- package/src/member-delegation.ts +4 -0
- package/src/schemas/condition-tree.ts +103 -3
- package/src/schemas/coupon-rules.ts +178 -0
- package/src/schemas/coupon-selection.ts +8 -3
- package/src/schemas/coupon.ts +112 -6
- package/src/schemas/customer-delegation.ts +6 -0
- package/src/schemas/customer.ts +4 -0
- package/src/schemas/points.ts +4 -2
- package/src/schemas/promotion.ts +286 -49
- package/src/schemas/userinfo.ts +118 -0
- package/dist/webhook-headers-D6QmPdEJ.d.mts +0 -22
package/dist/server.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
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,
|
package/src/member-delegation.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { KRW_MONEY_RULES } from "./currency";
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
couponRuleDiscountOf,
|
|
4
4
|
couponStackingOf,
|
|
5
5
|
couponsCombinable,
|
|
6
6
|
type PricingCoupon,
|
|
@@ -139,14 +139,19 @@ export function bestCouponSelection(input: CouponSelectionInput): CouponSelectio
|
|
|
139
139
|
const orders = solo.filter((e) => e.candidate.coupon.rule.kind === "ORDER");
|
|
140
140
|
const deliveries = solo.filter((e) => e.candidate.coupon.rule.kind === "DELIVERY");
|
|
141
141
|
const lineIds = input.lines.map((line) => line.lineId);
|
|
142
|
-
const
|
|
142
|
+
const lineById = new Map(input.lines.map((line) => [line.lineId, line]));
|
|
143
143
|
|
|
144
144
|
/** 상품 쿠폰 한 장의 줄별 할인 — 대상 줄만, 줄 금액 기준 */
|
|
145
145
|
const weightsOf = (entry: (typeof products)[number]) => {
|
|
146
146
|
const eligible = new Set(entry.result?.eligibleLineIds ?? []);
|
|
147
147
|
return lineIds.map((id) =>
|
|
148
148
|
eligible.has(id)
|
|
149
|
-
?
|
|
149
|
+
? couponRuleDiscountOf(
|
|
150
|
+
entry.candidate.coupon.rule,
|
|
151
|
+
lineById.get(id)?.amount ?? 0,
|
|
152
|
+
lineById.get(id) ?? null,
|
|
153
|
+
rules,
|
|
154
|
+
)
|
|
150
155
|
: 0,
|
|
151
156
|
);
|
|
152
157
|
};
|