@sayren/store-sdk 0.29.1 → 0.29.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +687 -208
- package/dist/index.mjs +333 -24
- package/package.json +1 -1
- package/src/client.ts +9 -1
- package/src/schemas/claim.ts +18 -0
- package/src/schemas/collection.ts +258 -11
- package/src/schemas/order.ts +26 -0
- package/src/schemas/payment.ts +7 -1
- package/src/schemas/points.ts +111 -3
- package/src/schemas/promotion.ts +140 -4
- package/src/schemas/webhook-event.ts +26 -0
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -56,11 +56,14 @@ import {
|
|
|
56
56
|
type CollectionCreateRequestInput,
|
|
57
57
|
type CollectionListParams,
|
|
58
58
|
type CollectionPatchRequest,
|
|
59
|
+
type CollectionPreviewRequest,
|
|
59
60
|
type CollectionProductsAddRequest,
|
|
60
61
|
type CollectionProductsReplaceRequest,
|
|
61
62
|
collectionCreateRequestSchema,
|
|
62
63
|
collectionPageSchema,
|
|
63
64
|
collectionPatchRequestSchema,
|
|
65
|
+
collectionPreviewRequestSchema,
|
|
66
|
+
collectionPreviewSchema,
|
|
64
67
|
collectionProductSchema,
|
|
65
68
|
collectionProductsAddRequestSchema,
|
|
66
69
|
collectionProductsReplaceRequestSchema,
|
|
@@ -560,7 +563,7 @@ export function createStoreClient(options: StoreClientOptions) {
|
|
|
560
563
|
}),
|
|
561
564
|
remove: (collectionId: string) =>
|
|
562
565
|
storeHttp.requestVoid("DELETE", path`/collections/${collectionId}`),
|
|
563
|
-
/** 담긴 상품 — 직접 정한
|
|
566
|
+
/** 담긴 상품 — 직접 선택은 정한 순서, 조건 자동은 지금 조건을 만족하는 판매 중 상품(기본 정렬 순) */
|
|
564
567
|
listProducts: (collectionId: string, params?: { page?: number; size?: number }) =>
|
|
565
568
|
storeHttp.request(
|
|
566
569
|
"GET",
|
|
@@ -580,6 +583,11 @@ export function createStoreClient(options: StoreClientOptions) {
|
|
|
580
583
|
}),
|
|
581
584
|
removeProduct: (collectionId: string, productId: string) =>
|
|
582
585
|
storeHttp.requestVoid("DELETE", path`/collections/${collectionId}/products/${productId}`),
|
|
586
|
+
/** 조건 자동 미리보기 — 저장하지 않고 조건을 만족하는 판매 중 상품 수와 앞 20개를 본다 */
|
|
587
|
+
preview: (body: CollectionPreviewRequest) =>
|
|
588
|
+
storeHttp.request("POST", "/collections/preview", collectionPreviewSchema, {
|
|
589
|
+
body: collectionPreviewRequestSchema.parse(body),
|
|
590
|
+
}),
|
|
583
591
|
},
|
|
584
592
|
|
|
585
593
|
categories: {
|
package/src/schemas/claim.ts
CHANGED
|
@@ -77,6 +77,15 @@ export const claimSchema = z.object({
|
|
|
77
77
|
"접수 시점에 계산한 예상 배송비 환불액. 배송 묶음(같은 상품의 주문상품)이 모두 취소되거나 " +
|
|
78
78
|
"판매자 귀책일 때만 0보다 크다. 이 필드가 생기기 전에 접수된 클레임은 null이다",
|
|
79
79
|
),
|
|
80
|
+
expectedPointRefundAmount: z
|
|
81
|
+
.number()
|
|
82
|
+
.int()
|
|
83
|
+
.optional()
|
|
84
|
+
.default(0)
|
|
85
|
+
.describe(
|
|
86
|
+
"예상 반환 적립금 (원, #93) — 결제에 쓴 적립금 중 이 수량의 몫이다. 예상 환불액(현금)과 별개이고, 차감은 현금에서 먼저 뺀다. " +
|
|
87
|
+
"교환·적립금을 쓰지 않은 주문은 0이다. 서버는 항상 싣는다",
|
|
88
|
+
),
|
|
80
89
|
exchangeOption: z.object({ optionId: z.string(), optionName: z.string() }).nullable(),
|
|
81
90
|
collect: z
|
|
82
91
|
.object({
|
|
@@ -201,6 +210,15 @@ export const approveClaimResultSchema = z.object({
|
|
|
201
210
|
.optional()
|
|
202
211
|
.default(0)
|
|
203
212
|
.describe("환불액에 더한, 앞선 환불에서 뺀 쿠폰 차감을 되돌린 몫. 서버는 항상 싣는다"),
|
|
213
|
+
pointRefundAmount: z
|
|
214
|
+
.number()
|
|
215
|
+
.int()
|
|
216
|
+
.optional()
|
|
217
|
+
.default(0)
|
|
218
|
+
.describe(
|
|
219
|
+
"돌려준 적립금 (원, #93) — 결제에 쓴 적립금 중 이번 취소·반품 수량의 몫이다. 현금 환불(`refundAmount`)과 별개다. " +
|
|
220
|
+
"차감은 현금에서 먼저 빼고 모자란 몫만 여기서 뺀다. 적립금을 쓰지 않은 주문과 이 필드가 없는 이전 응답·이벤트는 0으로 읽는다",
|
|
221
|
+
),
|
|
204
222
|
refundedAt: z.string(),
|
|
205
223
|
});
|
|
206
224
|
|
|
@@ -6,8 +6,8 @@ import { productImageUrlSchema } from "./product";
|
|
|
6
6
|
* 컬렉션 (이슈 #83) — 상품을 어디에 보여 줄지 정하는 묶음이다. 카테고리는 상품이 무엇인지를 정하고 상품당 하나지만, 컬렉션은
|
|
7
7
|
* 다대다다. 이벤트·기획전은 기간이 있는 직접 선택 컬렉션이다. 권한은 카테고리와 같다(`category:r`·`category:rw`).
|
|
8
8
|
*
|
|
9
|
-
* 유형은 만들 때 정하고 바꿀 수 없다.
|
|
10
|
-
* 응답의 `type`은 값이 늘 수 있어 문자열이다.
|
|
9
|
+
* 유형은 만들 때 정하고 바꿀 수 없다. 직접 선택(`MANUAL`)은 셀러가 상품을 담고 순서를 정하고, 조건 자동(`AUTOMATIC`)은 조건(`rules`)을
|
|
10
|
+
* 모두 만족하는 판매 중 상품을 조회할 때마다 계산한다(결과를 저장하지 않는다). 응답의 `type`은 값이 늘 수 있어 문자열이다.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
/** 상점당 컬렉션 최대 개수 — 넘으면 409 `COLLECTION_LIMIT_EXCEEDED` */
|
|
@@ -23,7 +23,7 @@ export const MAX_COLLECTION_DESCRIPTION = 500;
|
|
|
23
23
|
/** 주소(slug) 형식 — 영문 소문자·숫자·하이픈, 첫 글자는 영문 소문자나 숫자, 64자까지 */
|
|
24
24
|
export const COLLECTION_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
25
25
|
|
|
26
|
-
/** 유형 — `MANUAL`(직접 선택)·`AUTOMATIC`(조건
|
|
26
|
+
/** 유형 — `MANUAL`(직접 선택)·`AUTOMATIC`(조건 자동) */
|
|
27
27
|
export const COLLECTION_TYPES = ["MANUAL", "AUTOMATIC"] as const;
|
|
28
28
|
export type CollectionType = (typeof COLLECTION_TYPES)[number];
|
|
29
29
|
|
|
@@ -65,6 +65,210 @@ export function collectionStateOf(
|
|
|
65
65
|
return "ACTIVE";
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/** 조건 자동 컬렉션의 조건 최대 개수 */
|
|
69
|
+
export const MAX_COLLECTION_RULES = 10;
|
|
70
|
+
/** 카테고리 조건 하나에 넣는 카테고리 최대 개수 */
|
|
71
|
+
export const MAX_COLLECTION_RULE_CATEGORIES = 20;
|
|
72
|
+
/** 등록일 조건의 최대 일수 */
|
|
73
|
+
export const MAX_COLLECTION_CREATED_WITHIN_DAYS = 365;
|
|
74
|
+
/** 미리보기(`POST /v1/collections/preview`)가 싣는 상품 최대 개수 */
|
|
75
|
+
export const COLLECTION_PREVIEW_SIZE = 20;
|
|
76
|
+
|
|
77
|
+
const createdWithinDaysRuleSchema = z.object({
|
|
78
|
+
field: z.literal("createdWithinDays"),
|
|
79
|
+
value: z
|
|
80
|
+
.number()
|
|
81
|
+
.int()
|
|
82
|
+
.min(1)
|
|
83
|
+
.max(MAX_COLLECTION_CREATED_WITHIN_DAYS)
|
|
84
|
+
.describe("등록한 지 N일 안의 상품(1~365). 조회 시각에서 N일을 뺀 시각 이후에 등록한 상품이다"),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const categoryRuleSchema = z.object({
|
|
88
|
+
field: z.literal("category"),
|
|
89
|
+
categoryIds: z
|
|
90
|
+
.array(z.string().min(1))
|
|
91
|
+
.min(1)
|
|
92
|
+
.max(MAX_COLLECTION_RULE_CATEGORIES)
|
|
93
|
+
.describe(
|
|
94
|
+
"카테고리 id(1~20개). 하위 카테고리의 상품도 포함한다. 상점에 없는 id는 400 `COLLECTION_CATEGORY_NOT_FOUND`다",
|
|
95
|
+
),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const priceRuleSchema = z.object({
|
|
99
|
+
field: z.literal("price"),
|
|
100
|
+
min: z.number().int().min(0).optional().describe("최저가(포함, 원)"),
|
|
101
|
+
max: z.number().int().min(0).optional().describe("최고가(포함, 원)"),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const discountedRuleSchema = z.object({
|
|
105
|
+
field: z.literal("discounted"),
|
|
106
|
+
value: z.literal(true).describe("즉시할인이 지금 적용되는 상품"),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 조건 하나 — `field`로 가른다. 가격은 즉시할인을 반영한 판매가(`discountedPrice ?? salePrice`)다.
|
|
111
|
+
* 판매량 상위(`bestSelling`)는 판매량 집계가 열릴 때 더한다.
|
|
112
|
+
*/
|
|
113
|
+
export const collectionRuleSchema = z
|
|
114
|
+
.discriminatedUnion("field", [
|
|
115
|
+
createdWithinDaysRuleSchema,
|
|
116
|
+
categoryRuleSchema,
|
|
117
|
+
priceRuleSchema,
|
|
118
|
+
discountedRuleSchema,
|
|
119
|
+
])
|
|
120
|
+
.superRefine((rule, ctx) => {
|
|
121
|
+
if (rule.field !== "price") return;
|
|
122
|
+
if (rule.min === undefined && rule.max === undefined) {
|
|
123
|
+
ctx.addIssue({
|
|
124
|
+
code: "custom",
|
|
125
|
+
path: ["min"],
|
|
126
|
+
message: "최저가나 최고가 중 하나는 있어야 합니다",
|
|
127
|
+
});
|
|
128
|
+
} else if (rule.min !== undefined && rule.max !== undefined && rule.min > rule.max) {
|
|
129
|
+
ctx.addIssue({ code: "custom", path: ["max"], message: "최고가는 최저가 이상이어야 합니다" });
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
export type CollectionRule = z.infer<typeof collectionRuleSchema>;
|
|
134
|
+
export type CollectionRuleField = CollectionRule["field"];
|
|
135
|
+
|
|
136
|
+
/** 조건 목록 — 모두 만족해야 한다(AND). 같은 `field`는 한 번만 쓴다 */
|
|
137
|
+
export const collectionRulesSchema = z
|
|
138
|
+
.array(collectionRuleSchema)
|
|
139
|
+
.min(1)
|
|
140
|
+
.max(MAX_COLLECTION_RULES)
|
|
141
|
+
.superRefine((rules, ctx) => {
|
|
142
|
+
const seen = new Set<string>();
|
|
143
|
+
rules.forEach((rule, index) => {
|
|
144
|
+
if (seen.has(rule.field)) {
|
|
145
|
+
ctx.addIssue({
|
|
146
|
+
code: "custom",
|
|
147
|
+
path: [index, "field"],
|
|
148
|
+
message: "같은 조건을 두 번 쓸 수 없습니다",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
seen.add(rule.field);
|
|
152
|
+
});
|
|
153
|
+
})
|
|
154
|
+
.describe(
|
|
155
|
+
"조건 목록. 모두 만족하는 판매 중 상품이 컬렉션 상품이다(AND). 같은 조건(`field`)은 한 번만 쓴다",
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 응답의 조건 — 이 SDK가 아는 조건이거나 뒤에 더해질 조건(`field`만 확인한다)이다. 새 조건이 생겨도 옛 SDK의 응답 검증이 깨지지 않는다.
|
|
160
|
+
*/
|
|
161
|
+
export const collectionRuleViewSchema = z.union([
|
|
162
|
+
createdWithinDaysRuleSchema,
|
|
163
|
+
categoryRuleSchema,
|
|
164
|
+
priceRuleSchema,
|
|
165
|
+
discountedRuleSchema,
|
|
166
|
+
z.looseObject({ field: z.string() }),
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
export type CollectionRuleView = z.infer<typeof collectionRuleViewSchema>;
|
|
170
|
+
|
|
171
|
+
/** 조건 판정에 쓰는 상품 값 — 즉시할인가는 호출부가 계산해 넘긴다 */
|
|
172
|
+
export interface CollectionRuleProduct {
|
|
173
|
+
createdAt: string;
|
|
174
|
+
categoryId: string | null;
|
|
175
|
+
salePrice: number;
|
|
176
|
+
/** 지금 적용되는 즉시할인가. 할인이 없으면 null */
|
|
177
|
+
discountedPrice: number | null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface CollectionRuleContext {
|
|
181
|
+
/** 판정 시각 — 등록일 조건의 기준이다 */
|
|
182
|
+
now: Date;
|
|
183
|
+
/** 상점 카테고리(부모 관계) — 카테고리 조건의 하위 포함 판정에 쓴다 */
|
|
184
|
+
categories: ReadonlyArray<{ categoryId: string; parentId: string | null }>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const DAY_MS = 86_400_000;
|
|
188
|
+
|
|
189
|
+
/** 카테고리 자신 + 모든 하위 카테고리 id */
|
|
190
|
+
export function descendantCategoryIdsOf(
|
|
191
|
+
roots: readonly string[],
|
|
192
|
+
categories: CollectionRuleContext["categories"],
|
|
193
|
+
): Set<string> {
|
|
194
|
+
const ids = new Set(roots);
|
|
195
|
+
const children = new Map<string, string[]>();
|
|
196
|
+
for (const category of categories) {
|
|
197
|
+
if (!category.parentId) continue;
|
|
198
|
+
const list = children.get(category.parentId);
|
|
199
|
+
if (list) list.push(category.categoryId);
|
|
200
|
+
else children.set(category.parentId, [category.categoryId]);
|
|
201
|
+
}
|
|
202
|
+
const queue = [...roots];
|
|
203
|
+
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
|
|
204
|
+
for (const child of children.get(next) ?? []) {
|
|
205
|
+
if (ids.has(child)) continue;
|
|
206
|
+
ids.add(child);
|
|
207
|
+
queue.push(child);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return ids;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 조건 판정 함수를 만든다 — 모든 조건을 만족해야 true다(AND). 카테고리 트리는 여기서 한 번만 푼다.
|
|
215
|
+
* 알 수 없는 조건(`field`)은 만족하지 않는 것으로 본다. 모르는 조건을 건너뛰면 상품이 예고 없이 늘어난다.
|
|
216
|
+
* 판매 상태는 보지 않는다. 판매 중만 거르는 것은 호출부다.
|
|
217
|
+
*/
|
|
218
|
+
export function collectionRulesPredicate(
|
|
219
|
+
rules: readonly CollectionRuleView[],
|
|
220
|
+
context: CollectionRuleContext,
|
|
221
|
+
): (product: CollectionRuleProduct) => boolean {
|
|
222
|
+
const checks = rules.map((rule): ((product: CollectionRuleProduct) => boolean) => {
|
|
223
|
+
const known = collectionRuleSchema.safeParse(rule);
|
|
224
|
+
if (!known.success) return () => false;
|
|
225
|
+
const parsed = known.data;
|
|
226
|
+
switch (parsed.field) {
|
|
227
|
+
case "createdWithinDays": {
|
|
228
|
+
const since = context.now.getTime() - parsed.value * DAY_MS;
|
|
229
|
+
return (product) => Date.parse(product.createdAt) >= since;
|
|
230
|
+
}
|
|
231
|
+
case "category": {
|
|
232
|
+
const ids = descendantCategoryIdsOf(parsed.categoryIds, context.categories);
|
|
233
|
+
return (product) => product.categoryId !== null && ids.has(product.categoryId);
|
|
234
|
+
}
|
|
235
|
+
case "price":
|
|
236
|
+
return (product) => {
|
|
237
|
+
const price = product.discountedPrice ?? product.salePrice;
|
|
238
|
+
return (
|
|
239
|
+
(parsed.min === undefined || price >= parsed.min) &&
|
|
240
|
+
(parsed.max === undefined || price <= parsed.max)
|
|
241
|
+
);
|
|
242
|
+
};
|
|
243
|
+
case "discounted":
|
|
244
|
+
return (product) =>
|
|
245
|
+
product.discountedPrice !== null && product.discountedPrice < product.salePrice;
|
|
246
|
+
default:
|
|
247
|
+
return () => false;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
return (product) => checks.length > 0 && checks.every((check) => check(product));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 상품 하나가 조건을 모두 만족하는가 — 여러 상품을 판정할 때는 `collectionRulesPredicate`를 한 번 만들어 쓴다 */
|
|
254
|
+
export function matchesCollectionRules(
|
|
255
|
+
product: CollectionRuleProduct,
|
|
256
|
+
rules: readonly CollectionRuleView[],
|
|
257
|
+
context: CollectionRuleContext,
|
|
258
|
+
): boolean {
|
|
259
|
+
return collectionRulesPredicate(rules, context)(product);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** 조건이 참조하는 카테고리 id — 카테고리 삭제의 참조 검사에 쓴다 */
|
|
263
|
+
export function collectionRuleCategoryIds(rules: readonly CollectionRuleView[]): string[] {
|
|
264
|
+
const ids: string[] = [];
|
|
265
|
+
for (const rule of rules) {
|
|
266
|
+
const parsed = categoryRuleSchema.safeParse(rule);
|
|
267
|
+
if (parsed.success) ids.push(...parsed.data.categoryIds);
|
|
268
|
+
}
|
|
269
|
+
return ids;
|
|
270
|
+
}
|
|
271
|
+
|
|
68
272
|
export const collectionSchema = z.object({
|
|
69
273
|
collectionId: z.string(),
|
|
70
274
|
type: z.string().describe("유형. 현재 값은 `MANUAL`·`AUTOMATIC`이고 값이 늘 수 있어 문자열이다"),
|
|
@@ -73,6 +277,10 @@ export const collectionSchema = z.object({
|
|
|
73
277
|
description: z.string(),
|
|
74
278
|
imageUrl: z.string().nullable().describe("대표 이미지 주소"),
|
|
75
279
|
productSort: z.string().describe("스토어프론트 상품 기본 정렬"),
|
|
280
|
+
rules: z
|
|
281
|
+
.array(collectionRuleViewSchema)
|
|
282
|
+
.nullable()
|
|
283
|
+
.describe("조건 자동 컬렉션의 조건(AND). 직접 선택 컬렉션은 null"),
|
|
76
284
|
visible: z.boolean().describe("노출 여부. false면 기간과 무관하게 스토어프론트에 보이지 않는다"),
|
|
77
285
|
startsAt: z.string().nullable().describe("노출 시작 시각. null이면 바로 노출한다"),
|
|
78
286
|
endsAt: z.string().nullable().describe("노출 종료 시각. null이면 끝나지 않는다"),
|
|
@@ -80,7 +288,12 @@ export const collectionSchema = z.object({
|
|
|
80
288
|
metaTitle: z.string().nullable(),
|
|
81
289
|
metaDescription: z.string().nullable(),
|
|
82
290
|
state: collectionStateSchema.describe("지금 노출 상태"),
|
|
83
|
-
productCount: z
|
|
291
|
+
productCount: z
|
|
292
|
+
.number()
|
|
293
|
+
.int()
|
|
294
|
+
.describe(
|
|
295
|
+
"상품 수. 직접 선택은 담긴 상품 수(판매 상태 무관), 조건 자동은 지금 조건을 만족하는 판매 중 상품 수",
|
|
296
|
+
),
|
|
84
297
|
revision: z
|
|
85
298
|
.number()
|
|
86
299
|
.int()
|
|
@@ -132,20 +345,28 @@ const periodRefine = (
|
|
|
132
345
|
};
|
|
133
346
|
|
|
134
347
|
/**
|
|
135
|
-
* `POST /v1/collections` —
|
|
136
|
-
* `productIds
|
|
348
|
+
* `POST /v1/collections` — 컬렉션을 만든다. `slug`를 생략하면 `c-{id 뒤 8자}`로 만든다.
|
|
349
|
+
* 직접 선택(`MANUAL`)은 `productIds`가 처음 담을 상품과 순서다. 조건 자동(`AUTOMATIC`)은 `rules`가 있어야 하고 `productIds`를 받지 않는다.
|
|
350
|
+
* `productSort`를 생략하면 직접 선택은 `MANUAL`, 조건 자동은 `LATEST`다. 조건 자동은 `MANUAL`을 쓸 수 없다.
|
|
137
351
|
*/
|
|
138
352
|
export const collectionCreateRequestSchema = z
|
|
139
353
|
.object({
|
|
140
354
|
title: z.string().trim().min(1).max(MAX_COLLECTION_TITLE).describe("제목"),
|
|
141
355
|
slug: slugField.optional(),
|
|
142
356
|
type: z
|
|
143
|
-
.enum(
|
|
357
|
+
.enum(COLLECTION_TYPES)
|
|
144
358
|
.default("MANUAL")
|
|
145
|
-
.describe("유형.
|
|
359
|
+
.describe("유형. `MANUAL`(직접 선택)·`AUTOMATIC`(조건 자동). 만든 뒤 바꿀 수 없다"),
|
|
360
|
+
rules: collectionRulesSchema
|
|
361
|
+
.optional()
|
|
362
|
+
.describe("조건 자동 컬렉션의 조건(AND). 조건 자동이면 필수, 직접 선택이면 줄 수 없다"),
|
|
146
363
|
description: commonFields.description.default(""),
|
|
147
364
|
imageUrl: commonFields.imageUrl.optional(),
|
|
148
|
-
productSort: commonFields.productSort
|
|
365
|
+
productSort: commonFields.productSort
|
|
366
|
+
.optional()
|
|
367
|
+
.describe(
|
|
368
|
+
"스토어프론트 상품 기본 정렬. 생략하면 직접 선택은 `MANUAL`, 조건 자동은 `LATEST`다. 조건 자동은 `MANUAL`을 쓸 수 없다",
|
|
369
|
+
),
|
|
149
370
|
visible: commonFields.visible.default(true),
|
|
150
371
|
startsAt: commonFields.startsAt,
|
|
151
372
|
endsAt: commonFields.endsAt,
|
|
@@ -168,6 +389,11 @@ export const collectionPatchRequestSchema = z
|
|
|
168
389
|
title: z.string().trim().min(1).max(MAX_COLLECTION_TITLE).optional(),
|
|
169
390
|
slug: slugField.optional(),
|
|
170
391
|
type: z.string().optional().describe("바꿀 수 없다. 주면 400 `COLLECTION_TYPE_IMMUTABLE`"),
|
|
392
|
+
rules: collectionRulesSchema
|
|
393
|
+
.optional()
|
|
394
|
+
.describe(
|
|
395
|
+
"조건 자동 컬렉션의 조건을 통째로 바꾼다. 직접 선택 컬렉션에 주면 400 `COLLECTION_RULES_NOT_ALLOWED`",
|
|
396
|
+
),
|
|
171
397
|
description: commonFields.description.optional(),
|
|
172
398
|
imageUrl: commonFields.imageUrl.optional(),
|
|
173
399
|
productSort: commonFields.productSort.optional(),
|
|
@@ -207,8 +433,11 @@ export const collectionProductSchema = z.object({
|
|
|
207
433
|
thumbnailUrl: z.string().nullable(),
|
|
208
434
|
status: z.string().describe("판매 상태. 스토어프론트에는 `SALE`만 보인다"),
|
|
209
435
|
salePrice: z.number().int(),
|
|
210
|
-
position: z
|
|
211
|
-
|
|
436
|
+
position: z
|
|
437
|
+
.number()
|
|
438
|
+
.int()
|
|
439
|
+
.describe("순서(1부터). 직접 선택은 셀러가 정한 순서, 조건 자동은 컬렉션 기본 정렬의 순위"),
|
|
440
|
+
addedAt: z.string().describe("담은 시각. 조건 자동은 상품 등록 시각"),
|
|
212
441
|
});
|
|
213
442
|
|
|
214
443
|
export type CollectionProduct = z.infer<typeof collectionProductSchema>;
|
|
@@ -248,3 +477,21 @@ export const collectionListQuerySchema = z.object({
|
|
|
248
477
|
export const collectionProductListQuerySchema = z.object({
|
|
249
478
|
...pageQueryShape({ defaultSize: 100, maxSize: MAX_COLLECTION_PRODUCTS }),
|
|
250
479
|
});
|
|
480
|
+
|
|
481
|
+
/** `POST /v1/collections/preview` — 저장하지 않고 조건으로 나오는 상품을 본다 */
|
|
482
|
+
export const collectionPreviewRequestSchema = z.object({
|
|
483
|
+
rules: collectionRulesSchema,
|
|
484
|
+
productSort: collectionProductSortRequestSchema
|
|
485
|
+
.optional()
|
|
486
|
+
.describe("미리보기 순서. 생략하거나 `MANUAL`이면 `LATEST`다"),
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
export type CollectionPreviewRequest = z.infer<typeof collectionPreviewRequestSchema>;
|
|
490
|
+
|
|
491
|
+
/** 미리보기 응답 — 조건을 만족하는 판매 중 상품 수와 앞 20개 */
|
|
492
|
+
export const collectionPreviewSchema = z.object({
|
|
493
|
+
count: z.number().int().describe("조건을 만족하는 판매 중 상품 수(조회 시각 기준)"),
|
|
494
|
+
products: z.array(collectionProductSchema).describe("앞 20개(정렬 순)"),
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
export type CollectionPreview = z.infer<typeof collectionPreviewSchema>;
|
package/src/schemas/order.ts
CHANGED
|
@@ -103,6 +103,15 @@ export const orderItemSchema = z.object({
|
|
|
103
103
|
"이 주문상품에 배분된 쿠폰 할인 — 상품 쿠폰 + 주문 쿠폰 배분. 실결제 금액은 `totalPrice − couponDiscountAmount`이고 " +
|
|
104
104
|
"취소·반품 환불은 실결제 금액을 수량 비율로 나눈다. 쿠폰이 없으면 0. 서버는 항상 싣는다",
|
|
105
105
|
),
|
|
106
|
+
pointAllocation: z
|
|
107
|
+
.number()
|
|
108
|
+
.int()
|
|
109
|
+
.optional()
|
|
110
|
+
.default(0)
|
|
111
|
+
.describe(
|
|
112
|
+
"이 주문상품에 배분된 사용 적립금 (원, #93). 현금 실결제는 `totalPrice − couponDiscountAmount − pointAllocation`이고 " +
|
|
113
|
+
"취소·반품은 현금과 적립금을 따로 수량 비율로 돌려준다. 적립금을 쓰지 않았으면 0. 서버는 항상 싣는다",
|
|
114
|
+
),
|
|
106
115
|
hold: z.object({ reason: z.string(), expectedDispatchDate: z.string() }).nullable(),
|
|
107
116
|
fulfillment: orderItemFulfillmentSchema
|
|
108
117
|
.nullable()
|
|
@@ -202,6 +211,14 @@ export const orderSchema = z.object({
|
|
|
202
211
|
.optional()
|
|
203
212
|
.default(0)
|
|
204
213
|
.describe("배송비 쿠폰 할인. 쿠폰이 없으면 0. 서버는 항상 싣는다"),
|
|
214
|
+
pointAmount: z
|
|
215
|
+
.number()
|
|
216
|
+
.int()
|
|
217
|
+
.optional()
|
|
218
|
+
.default(0)
|
|
219
|
+
.describe(
|
|
220
|
+
"사용 적립금 (원, #93) — 쿠폰 뒤 금액에서 뺐다. `totalAmount`는 적립금을 뺀 결제(현금) 금액이다. 적립금을 쓰지 않았으면 0. 서버는 항상 싣는다",
|
|
221
|
+
),
|
|
205
222
|
discounts: z
|
|
206
223
|
.array(orderDiscountSchema)
|
|
207
224
|
.optional()
|
|
@@ -518,6 +535,15 @@ export const cancelBySellerResultSchema = z.object({
|
|
|
518
535
|
.optional()
|
|
519
536
|
.default(0)
|
|
520
537
|
.describe("환불액에 더한, 앞선 환불에서 뺀 쿠폰 차감을 되돌린 몫. 서버는 항상 싣는다"),
|
|
538
|
+
pointRefundAmount: z
|
|
539
|
+
.number()
|
|
540
|
+
.int()
|
|
541
|
+
.optional()
|
|
542
|
+
.default(0)
|
|
543
|
+
.describe(
|
|
544
|
+
"돌려준 적립금 (원, #93) — 결제에 쓴 적립금 중 이번 취소·반품 수량의 몫이다. 현금 환불(`refundAmount`)과 별개다. " +
|
|
545
|
+
"차감은 현금에서 먼저 빼고 모자란 몫만 여기서 뺀다. 적립금을 쓰지 않은 주문과 이 필드가 없는 이전 응답·이벤트는 0으로 읽는다",
|
|
546
|
+
),
|
|
521
547
|
refundedAt: z.string(),
|
|
522
548
|
});
|
|
523
549
|
|
package/src/schemas/payment.ts
CHANGED
|
@@ -677,7 +677,13 @@ export type PaymentAttemptRecord = z.infer<typeof paymentAttemptRecordSchema>;
|
|
|
677
677
|
|
|
678
678
|
export const paymentRefundRecordSchema = z.object({
|
|
679
679
|
refundId: z.string(),
|
|
680
|
-
amount: z.number().int(),
|
|
680
|
+
amount: z.number().int().describe("PG로 돌려준 현금"),
|
|
681
|
+
pointRefundAmount: z
|
|
682
|
+
.number()
|
|
683
|
+
.int()
|
|
684
|
+
.optional()
|
|
685
|
+
.default(0)
|
|
686
|
+
.describe("같은 환불에서 돌려준 적립금 (#93). 적립금을 쓰지 않은 결제는 0"),
|
|
681
687
|
reason: z.string().describe("환불 사유 코드"),
|
|
682
688
|
kind: z
|
|
683
689
|
.string()
|
package/src/schemas/points.ts
CHANGED
|
@@ -44,6 +44,40 @@ export const purchaseEarnPolicySchema = z.object({
|
|
|
44
44
|
});
|
|
45
45
|
export type PurchaseEarnPolicy = z.infer<typeof purchaseEarnPolicySchema>;
|
|
46
46
|
|
|
47
|
+
/** 사용 단위 — 1·10·100원 */
|
|
48
|
+
export const POINT_USE_UNITS = [1, 10, 100] as const;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 적립금 사용 규칙(P3) — 주문서에서 결제 금액을 줄인다. 적용 순서는 즉시할인 → 쿠폰 → 적립금이고, 상품 금액(쿠폰 뒤)에 먼저 쓰고
|
|
52
|
+
* 남으면 배송비에 쓴다(`includeDeliveryFee`). 결제 금액이 100원 아래로 내려가지 않게 적립금을 줄인다.
|
|
53
|
+
*/
|
|
54
|
+
export const pointUsePolicySchema = z.object({
|
|
55
|
+
enabled: z.boolean().describe("적립금 사용 여부. 꺼도 잔액은 남는다"),
|
|
56
|
+
minBalance: z
|
|
57
|
+
.number()
|
|
58
|
+
.int()
|
|
59
|
+
.min(0)
|
|
60
|
+
.max(10_000_000)
|
|
61
|
+
.describe("이 잔액 이상이어야 쓸 수 있다(원). 0이면 제한 없음"),
|
|
62
|
+
minAmount: z
|
|
63
|
+
.number()
|
|
64
|
+
.int()
|
|
65
|
+
.min(0)
|
|
66
|
+
.max(10_000_000)
|
|
67
|
+
.describe("한 번에 쓰는 최소 금액(원). 0이면 제한 없음"),
|
|
68
|
+
unit: z
|
|
69
|
+
.union([z.literal(1), z.literal(10), z.literal(100)])
|
|
70
|
+
.describe("사용 단위(원). 요청 금액을 이 단위로 내린다"),
|
|
71
|
+
maxRatioBp: z
|
|
72
|
+
.number()
|
|
73
|
+
.int()
|
|
74
|
+
.min(1)
|
|
75
|
+
.max(10_000)
|
|
76
|
+
.describe("결제 기준 금액(쿠폰 뒤) 대비 최대 사용 비율(만분율, 10000 = 100%)"),
|
|
77
|
+
includeDeliveryFee: z.boolean().describe("배송비에도 쓰는가. 끄면 상품 금액(쿠폰 뒤)에만 쓴다"),
|
|
78
|
+
});
|
|
79
|
+
export type PointUsePolicy = z.infer<typeof pointUsePolicySchema>;
|
|
80
|
+
|
|
47
81
|
const expiryDays = z
|
|
48
82
|
.number()
|
|
49
83
|
.int()
|
|
@@ -58,6 +92,7 @@ const expiryDays = z
|
|
|
58
92
|
export const pointPolicySchema = z.object({
|
|
59
93
|
reviewReward: reviewRewardPolicySchema,
|
|
60
94
|
purchaseEarn: purchaseEarnPolicySchema,
|
|
95
|
+
use: pointUsePolicySchema,
|
|
61
96
|
expiryDays: expiryDays.nullable().describe("적립금 유효 기간(일). 정하지 않았으면 null"),
|
|
62
97
|
});
|
|
63
98
|
export type PointPolicy = z.infer<typeof pointPolicySchema>;
|
|
@@ -66,6 +101,14 @@ export type PointPolicy = z.infer<typeof pointPolicySchema>;
|
|
|
66
101
|
export const POINT_POLICY_DEFAULTS: PointPolicy = {
|
|
67
102
|
reviewReward: { enabled: false, textPoint: 0, photoPoint: 0 },
|
|
68
103
|
purchaseEarn: { enabled: false, rateBp: 0 },
|
|
104
|
+
use: {
|
|
105
|
+
enabled: false,
|
|
106
|
+
minBalance: 0,
|
|
107
|
+
minAmount: 0,
|
|
108
|
+
unit: 10,
|
|
109
|
+
maxRatioBp: 10_000,
|
|
110
|
+
includeDeliveryFee: true,
|
|
111
|
+
},
|
|
69
112
|
expiryDays: null,
|
|
70
113
|
};
|
|
71
114
|
|
|
@@ -78,10 +121,12 @@ export function resolvePointPolicy(stored: unknown): PointPolicy {
|
|
|
78
121
|
const raw = stored as Record<string, unknown>;
|
|
79
122
|
const review = reviewRewardPolicySchema.safeParse(raw.reviewReward);
|
|
80
123
|
const purchase = purchaseEarnPolicySchema.safeParse(raw.purchaseEarn);
|
|
124
|
+
const use = pointUsePolicySchema.safeParse(raw.use);
|
|
81
125
|
const expiry = expiryDays.nullable().safeParse(raw.expiryDays ?? null);
|
|
82
126
|
return {
|
|
83
127
|
reviewReward: review.success ? review.data : POINT_POLICY_DEFAULTS.reviewReward,
|
|
84
128
|
purchaseEarn: purchase.success ? purchase.data : POINT_POLICY_DEFAULTS.purchaseEarn,
|
|
129
|
+
use: use.success ? use.data : POINT_POLICY_DEFAULTS.use,
|
|
85
130
|
expiryDays: expiry.success ? expiry.data : null,
|
|
86
131
|
};
|
|
87
132
|
}
|
|
@@ -137,7 +182,7 @@ export const POINT_LOT_SOURCES = [
|
|
|
137
182
|
] as const;
|
|
138
183
|
export type PointLotSource = (typeof POINT_LOT_SOURCES)[number];
|
|
139
184
|
|
|
140
|
-
/** 원장 항목
|
|
185
|
+
/** 원장 항목 유형 */
|
|
141
186
|
export const POINT_ENTRY_TYPES = [
|
|
142
187
|
"EARN_REVIEW",
|
|
143
188
|
"REVOKE_REVIEW",
|
|
@@ -173,6 +218,20 @@ export const storePointPolicySchema = z.object({
|
|
|
173
218
|
purchaseEarn: purchaseEarnPolicySchema.describe(
|
|
174
219
|
"구매 적립. 구매확정 때 주문상품 실결제 금액(배송비 제외) × 적립률을 준다. 정하지 않았으면 끔·0",
|
|
175
220
|
),
|
|
221
|
+
useAvailable: z
|
|
222
|
+
.boolean()
|
|
223
|
+
.optional()
|
|
224
|
+
.default(false)
|
|
225
|
+
.describe(
|
|
226
|
+
"적립금 사용(결제에 쓰기)을 켤 수 있는가 (#93). false면 준비 중이라 `use.enabled`를 켜는 저장이 409 `POINTS_UNAVAILABLE`이다. " +
|
|
227
|
+
"필드가 없는 이전 응답은 false로 읽는다",
|
|
228
|
+
),
|
|
229
|
+
use: pointUsePolicySchema
|
|
230
|
+
.optional()
|
|
231
|
+
.default(POINT_POLICY_DEFAULTS.use)
|
|
232
|
+
.describe(
|
|
233
|
+
"적립금 사용 규칙. 적용 순서는 즉시할인 → 쿠폰 → 적립금이다. 정하지 않았으면 끔. 필드가 없는 이전 응답은 끔으로 읽는다",
|
|
234
|
+
),
|
|
176
235
|
expiryDays: pointPolicySchema.shape.expiryDays,
|
|
177
236
|
notice: pointPolicyNoticeSchema,
|
|
178
237
|
});
|
|
@@ -185,6 +244,7 @@ export type StorePointPolicy = z.infer<typeof storePointPolicySchema>;
|
|
|
185
244
|
export const updatePointPolicyRequestSchema = z.object({
|
|
186
245
|
reviewReward: reviewRewardPolicySchema.optional(),
|
|
187
246
|
purchaseEarn: purchaseEarnPolicySchema.optional(),
|
|
247
|
+
use: pointUsePolicySchema.optional(),
|
|
188
248
|
expiryDays: expiryDays.optional(),
|
|
189
249
|
});
|
|
190
250
|
export type UpdatePointPolicyRequest = z.infer<typeof updatePointPolicyRequestSchema>;
|
|
@@ -196,7 +256,8 @@ export const responsePointEntryTypeSchema = z
|
|
|
196
256
|
.union([z.enum(POINT_ENTRY_TYPES), z.string()])
|
|
197
257
|
.describe(
|
|
198
258
|
"유형. `EARN_REVIEW` 리뷰 적립 · `REVOKE_REVIEW` 리뷰 삭제 회수 · `EARN_PURCHASE` 구매 적립 · `EXPIRE` 소멸 · `ADJUST` 셀러 지급·차감 · " +
|
|
199
|
-
"`OPENING` 기존 잔액
|
|
259
|
+
"`OPENING` 기존 잔액 이전 · `USE_RESERVE` 결제 사용(결제 시작 때 빠짐) · `USE_RELEASE` 결제가 실패·만료되어 되돌림 · " +
|
|
260
|
+
"`REFUND_RESTORE` 취소·반품 환불로 반환. 값이 추가될 수 있어 문자열로 받는다",
|
|
200
261
|
);
|
|
201
262
|
|
|
202
263
|
/** 원장 항목 — 셀러 조회용(사유·행위자 포함) */
|
|
@@ -222,7 +283,7 @@ export const pointLotSchema = z.object({
|
|
|
222
283
|
lotId: z.string(),
|
|
223
284
|
source: z
|
|
224
285
|
.union([z.enum(POINT_LOT_SOURCES), z.string()])
|
|
225
|
-
.describe("출처. `REVIEW`·`PURCHASE`·`ADJUST`·`OPENING` 등"),
|
|
286
|
+
.describe("출처. `REVIEW`·`PURCHASE`·`ADJUST`·`REFUND_RESTORE`·`OPENING` 등"),
|
|
226
287
|
amount: z.number().int().describe("적립한 금액"),
|
|
227
288
|
remaining: z.number().int().describe("남은 금액"),
|
|
228
289
|
expiresAt: z.string().nullable().describe("소멸 시각. null이면 소멸 없음"),
|
|
@@ -283,3 +344,50 @@ export const pointAdjustmentResultSchema = z.object({
|
|
|
283
344
|
balance: z.number().int().describe("조정 뒤 잔액"),
|
|
284
345
|
});
|
|
285
346
|
export type PointAdjustmentResult = z.infer<typeof pointAdjustmentResultSchema>;
|
|
347
|
+
|
|
348
|
+
// ── 사용·환불 (P3) ─────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
/** 결제 시작에 쓴 적립 묶음 — 해제·환불 반환이 이 값으로 되돌리고 반환 적립금의 만료를 정한다 */
|
|
351
|
+
export interface PointLotUsage {
|
|
352
|
+
lotId: string;
|
|
353
|
+
amount: number;
|
|
354
|
+
expiresAt: string | null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const RESTORE_GRACE_MS = 30 * DAY_MS;
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 환불로 돌려주는 적립금의 만료 — 결제에 쓴 묶음 중 가장 늦은 만료이고, 그 시각이 이미 지났으면 지금 + 30일(KST 날짜 끝)이다.
|
|
361
|
+
* 쓴 묶음 중 만료 없는 묶음(기존 잔액 등)이 있으면 만료 없음(null)이다. 쓴 기록이 없으면 지금 + 30일이다(사용자 결정 2026-10-02).
|
|
362
|
+
*/
|
|
363
|
+
export function pointRestoreExpiresAtOf(usages: readonly PointLotUsage[], now: Date): Date | null {
|
|
364
|
+
if (usages.some((usage) => usage.expiresAt === null)) return null;
|
|
365
|
+
const latest = usages.reduce<number | null>((max, usage) => {
|
|
366
|
+
const at = Date.parse(usage.expiresAt as string);
|
|
367
|
+
return Number.isFinite(at) && (max === null || at > max) ? at : max;
|
|
368
|
+
}, null);
|
|
369
|
+
if (latest !== null && latest > now.getTime()) return new Date(latest);
|
|
370
|
+
return pointExpiresAtOf(new Date(now.getTime() + RESTORE_GRACE_MS), 0);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 환불 금액 나누기 — 현금 몫·적립금 몫에서 차감(반품 배송비·쿠폰 조건 미달·추가 차감)을 **현금에서 먼저** 빼고 모자란 몫만 적립금에서 뺀다.
|
|
375
|
+
* 둘 다 모자라면 0에서 멈추고 못 받은 차감액(`uncollected`)을 돌려준다(호출부가 감사 로그에 남긴다).
|
|
376
|
+
*/
|
|
377
|
+
export function splitRefund(input: { cash: number; points: number; charges: number }): {
|
|
378
|
+
cashRefund: number;
|
|
379
|
+
pointRefund: number;
|
|
380
|
+
uncollected: number;
|
|
381
|
+
} {
|
|
382
|
+
const cash = Math.max(0, input.cash);
|
|
383
|
+
const points = Math.max(0, input.points);
|
|
384
|
+
const charges = Math.max(0, input.charges);
|
|
385
|
+
const fromCash = Math.min(cash, charges);
|
|
386
|
+
const rest = charges - fromCash;
|
|
387
|
+
const fromPoints = Math.min(points, rest);
|
|
388
|
+
return {
|
|
389
|
+
cashRefund: cash - fromCash,
|
|
390
|
+
pointRefund: points - fromPoints,
|
|
391
|
+
uncollected: rest - fromPoints,
|
|
392
|
+
};
|
|
393
|
+
}
|