@sayren/store-sdk 0.29.0 → 0.29.2

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.
@@ -0,0 +1,285 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * 적립금 (이슈 #93) — 상점 정책·원장 유형·순수 계산. 정책 해석은 `resolvePointPolicy`, 리뷰 적립 금액은
5
+ * `reviewRewardPointOf`, 적립 묶음(lot) 만료 시각은 `pointExpiresAtOf` 한 곳이다. 서버·상점 플랫폼이 같은 함수를 쓴다.
6
+ *
7
+ * 정책이 없으면(null) 모든 적립이 꺼져 있다(사용자 결정 2026-10-02). 셀러가 켜야 적립이 시작된다.
8
+ * 구매 적립 금액은 `pointEarnOf`, 적용 적립률은 `purchaseEarnRateOf`(상품 덮어쓰기 우선)다. 사용 규칙은 다음 단계(P3)에서 가산한다.
9
+ */
10
+
11
+ /** 리뷰 적립·회수 금액 상한 — 한 리뷰에 주는 금액 */
12
+ export const POINT_REVIEW_REWARD_MAX = 100_000;
13
+ /** 유효 기간 범위(일) — 「소멸 없음」 선택지는 두지 않는다(사용자 결정 2026-10-02) */
14
+ export const POINT_EXPIRY_DAYS_MIN = 30;
15
+ export const POINT_EXPIRY_DAYS_MAX = 1825;
16
+
17
+ /** 구매 적립률 상한(만분율) — 3000 = 30% */
18
+ export const POINT_EARN_RATE_BP_MAX = 3000;
19
+
20
+ const rewardAmount = z.number().int().min(0).max(POINT_REVIEW_REWARD_MAX);
21
+
22
+ /** 구매 적립률(만분율, 100 = 1%). 상품별 덮어쓰기도 같은 범위다 */
23
+ export const pointEarnRateBpSchema = z
24
+ .number()
25
+ .int()
26
+ .min(0)
27
+ .max(POINT_EARN_RATE_BP_MAX)
28
+ .describe("구매 적립률(만분율, 100 = 1%). 0~3000");
29
+
30
+ export const reviewRewardPolicySchema = z.object({
31
+ enabled: z.boolean().describe("리뷰 적립 사용 여부"),
32
+ textPoint: rewardAmount.describe("리뷰를 작성하면 주는 적립금(원). 0~100,000"),
33
+ photoPoint: rewardAmount.describe(
34
+ "사진이 있는 리뷰에 **더** 주는 적립금(원). 0~100,000. 사진 리뷰는 `textPoint + photoPoint`를 받는다",
35
+ ),
36
+ });
37
+ export type ReviewRewardPolicy = z.infer<typeof reviewRewardPolicySchema>;
38
+
39
+ export const purchaseEarnPolicySchema = z.object({
40
+ enabled: z.boolean().describe("구매 적립 사용 여부"),
41
+ rateBp: pointEarnRateBpSchema.describe(
42
+ "상점 기본 구매 적립률(만분율, 100 = 1%). 0~3000. 상품의 `pointEarnRateBp`가 있으면 그 값이 우선한다",
43
+ ),
44
+ });
45
+ export type PurchaseEarnPolicy = z.infer<typeof purchaseEarnPolicySchema>;
46
+
47
+ const expiryDays = z
48
+ .number()
49
+ .int()
50
+ .min(POINT_EXPIRY_DAYS_MIN)
51
+ .max(POINT_EXPIRY_DAYS_MAX)
52
+ .describe(
53
+ "적립금 유효 기간(일, 30~1825). 적립한 날로부터 이 기간이 지난 날의 끝(한국 시간 23:59:59)에 소멸한다. " +
54
+ "바꿔도 이미 적립된 적립금의 유효 기간은 바뀌지 않는다",
55
+ );
56
+
57
+ /** 저장된 상점 정책(`stores.point_policy`) — 해석은 `resolvePointPolicy` */
58
+ export const pointPolicySchema = z.object({
59
+ reviewReward: reviewRewardPolicySchema,
60
+ purchaseEarn: purchaseEarnPolicySchema,
61
+ expiryDays: expiryDays.nullable().describe("적립금 유효 기간(일). 정하지 않았으면 null"),
62
+ });
63
+ export type PointPolicy = z.infer<typeof pointPolicySchema>;
64
+
65
+ /** 정책이 없을 때 — 모두 끔 */
66
+ export const POINT_POLICY_DEFAULTS: PointPolicy = {
67
+ reviewReward: { enabled: false, textPoint: 0, photoPoint: 0 },
68
+ purchaseEarn: { enabled: false, rateBp: 0 },
69
+ expiryDays: null,
70
+ };
71
+
72
+ /**
73
+ * 저장된 정책 → 유효 정책. null·형식이 틀린 값은 기본(모두 끔)이다. 키 하나가 틀려도 그 묶음만 기본으로 읽는다
74
+ * (정책 형식이 바뀌어 옛 행이 남아 있을 때 적립을 잘못 켜지 않게 한다).
75
+ */
76
+ export function resolvePointPolicy(stored: unknown): PointPolicy {
77
+ if (!stored || typeof stored !== "object") return POINT_POLICY_DEFAULTS;
78
+ const raw = stored as Record<string, unknown>;
79
+ const review = reviewRewardPolicySchema.safeParse(raw.reviewReward);
80
+ const purchase = purchaseEarnPolicySchema.safeParse(raw.purchaseEarn);
81
+ const expiry = expiryDays.nullable().safeParse(raw.expiryDays ?? null);
82
+ return {
83
+ reviewReward: review.success ? review.data : POINT_POLICY_DEFAULTS.reviewReward,
84
+ purchaseEarn: purchase.success ? purchase.data : POINT_POLICY_DEFAULTS.purchaseEarn,
85
+ expiryDays: expiry.success ? expiry.data : null,
86
+ };
87
+ }
88
+
89
+ /** 적립을 하나라도 켰는가 — 켜면 유효 기간이 필수다 */
90
+ export function pointEarningEnabled(policy: PointPolicy): boolean {
91
+ return policy.reviewReward.enabled || policy.purchaseEarn.enabled;
92
+ }
93
+
94
+ /**
95
+ * 주문상품에 적용할 구매 적립률 — 구매 적립이 꺼져 있으면 0, 상품 덮어쓰기(`products.point_earn_rate_bp`)가 있으면 그 값
96
+ * (0 = 적립 안 함), 없으면 상점 기본이다. 결제 확정 때 주문상품에 스냅샷한다.
97
+ */
98
+ export function purchaseEarnRateOf(policy: PointPolicy, productRateBp: number | null): number {
99
+ if (!policy.purchaseEarn.enabled) return 0;
100
+ return productRateBp ?? policy.purchaseEarn.rateBp;
101
+ }
102
+
103
+ /** 구매 적립 금액 — 적립 기준 금액 × 적립률, 1원 미만은 내린다 */
104
+ export function pointEarnOf(input: { paidBase: number; rateBp: number }): number {
105
+ if (input.paidBase <= 0 || input.rateBp <= 0) return 0;
106
+ return Math.floor((input.paidBase * input.rateBp) / 10_000);
107
+ }
108
+
109
+ /** 리뷰 하나의 적립 금액 — 정책이 꺼져 있으면 0이다. 사진은 추가분이다 */
110
+ export function reviewRewardPointOf(policy: PointPolicy, hasPhoto: boolean): number {
111
+ const { reviewReward } = policy;
112
+ if (!reviewReward.enabled) return 0;
113
+ return reviewReward.textPoint + (hasPhoto ? reviewReward.photoPoint : 0);
114
+ }
115
+
116
+ const DAY_MS = 86_400_000;
117
+ const KST_OFFSET_MS = 9 * 3_600_000;
118
+
119
+ /**
120
+ * 적립 묶음의 만료 시각 — 적립 시각 + `days`일이 속한 한국 시간 날짜의 끝(23:59:59.999 KST). 구매자 안내가 날짜 단위라
121
+ * 시각을 날짜 끝에 맞춘다. `days`가 null이면 만료 없음(null)이다.
122
+ */
123
+ export function pointExpiresAtOf(earnedAt: Date, days: number | null): Date | null {
124
+ if (days === null) return null;
125
+ const shifted = earnedAt.getTime() + days * DAY_MS + KST_OFFSET_MS;
126
+ const endOfDay = Math.floor(shifted / DAY_MS) * DAY_MS + DAY_MS - 1;
127
+ return new Date(endOfDay - KST_OFFSET_MS);
128
+ }
129
+
130
+ /** 적립 묶음 출처 — 같은 출처 id에는 묶음이 하나뿐이다(DB 부분 유니크) */
131
+ export const POINT_LOT_SOURCES = [
132
+ "REVIEW",
133
+ "PURCHASE",
134
+ "ADJUST",
135
+ "REFUND_RESTORE",
136
+ "OPENING",
137
+ ] as const;
138
+ export type PointLotSource = (typeof POINT_LOT_SOURCES)[number];
139
+
140
+ /** 원장 항목 유형. 지금 쓰는 것은 리뷰 적립·회수이고 나머지는 다음 단계 자리다 */
141
+ export const POINT_ENTRY_TYPES = [
142
+ "EARN_REVIEW",
143
+ "REVOKE_REVIEW",
144
+ "EARN_PURCHASE",
145
+ "USE_RESERVE",
146
+ "USE_RELEASE",
147
+ "REFUND_RESTORE",
148
+ "EXPIRE",
149
+ "ADJUST",
150
+ "OPENING",
151
+ ] as const;
152
+ export type PointEntryType = (typeof POINT_ENTRY_TYPES)[number];
153
+
154
+ /** 상점 플랫폼 알림 — `REVIEW_REWARD_PAUSED` 리뷰 자동 적립이 멈춤 · `POINTS_AVAILABLE` 적립금 설정을 쓸 수 있음 */
155
+ export const pointPolicyNoticeSchema = z
156
+ .enum(["REVIEW_REWARD_PAUSED", "POINTS_AVAILABLE"])
157
+ .nullable()
158
+ .describe(
159
+ "상점에 보일 안내. `REVIEW_REWARD_PAUSED`: 지난 90일에 리뷰 적립이 있었는데 적립금 기본값이 끔으로 바뀌어 리뷰 자동 적립(500원 + 사진 500원)이 멈췄다. " +
160
+ "`POINTS_AVAILABLE`: 적립금 설정을 열었으니 다시 켤 수 있다. 안내할 것이 없으면 null",
161
+ );
162
+ export type PointPolicyNotice = z.infer<typeof pointPolicyNoticeSchema>;
163
+
164
+ /** `GET /v1/store/point-policy` 응답 */
165
+ export const storePointPolicySchema = z.object({
166
+ available: z
167
+ .boolean()
168
+ .describe(
169
+ "적립금 설정을 바꿀 수 있는가. false면 준비 중이라 정책 저장이 409 `POINTS_UNAVAILABLE`이고 모든 적립이 멈춰 있다",
170
+ ),
171
+ configured: z.boolean().describe("셀러가 정책을 저장한 적이 있는가. false면 기본(모두 끔)이다"),
172
+ reviewReward: reviewRewardPolicySchema.describe("리뷰 적립. 정하지 않았으면 끔·0원"),
173
+ purchaseEarn: purchaseEarnPolicySchema.describe(
174
+ "구매 적립. 구매확정 때 주문상품 실결제 금액(배송비 제외) × 적립률을 준다. 정하지 않았으면 끔·0",
175
+ ),
176
+ expiryDays: pointPolicySchema.shape.expiryDays,
177
+ notice: pointPolicyNoticeSchema,
178
+ });
179
+ export type StorePointPolicy = z.infer<typeof storePointPolicySchema>;
180
+
181
+ /**
182
+ * `PUT /v1/store/point-policy` 본문 — 준 항목만 바꾼다(생략 = 저장된 값 유지). 리뷰 적립·구매 적립 중 하나라도 켜면 유효 기간이 있어야 한다
183
+ * (본문이나 저장된 값, 없으면 400 `POINT_EXPIRY_REQUIRED`).
184
+ */
185
+ export const updatePointPolicyRequestSchema = z.object({
186
+ reviewReward: reviewRewardPolicySchema.optional(),
187
+ purchaseEarn: purchaseEarnPolicySchema.optional(),
188
+ expiryDays: expiryDays.optional(),
189
+ });
190
+ export type UpdatePointPolicyRequest = z.infer<typeof updatePointPolicyRequestSchema>;
191
+
192
+ // ── 잔액·원장 조회와 셀러 조정 ─────────────────────────────
193
+
194
+ /** 응답의 원장 유형 — 값이 추가될 수 있어 문자열로 받는다 */
195
+ export const responsePointEntryTypeSchema = z
196
+ .union([z.enum(POINT_ENTRY_TYPES), z.string()])
197
+ .describe(
198
+ "유형. `EARN_REVIEW` 리뷰 적립 · `REVOKE_REVIEW` 리뷰 삭제 회수 · `EARN_PURCHASE` 구매 적립 · `EXPIRE` 소멸 · `ADJUST` 셀러 지급·차감 · " +
199
+ "`OPENING` 기존 잔액 이전. 값이 추가될 수 있어 문자열로 받는다",
200
+ );
201
+
202
+ /** 원장 항목 — 셀러 조회용(사유·행위자 포함) */
203
+ export const pointEntrySchema = z.object({
204
+ entryId: z.string(),
205
+ memberId: z.string(),
206
+ type: responsePointEntryTypeSchema,
207
+ amount: z.number().int().describe("변동액. 적립 +, 회수·차감·소멸 −"),
208
+ balanceAfter: z.number().int().describe("이 변동 직후 잔액"),
209
+ refType: z
210
+ .string()
211
+ .nullable()
212
+ .describe("가리키는 대상 종류(`REVIEW`·`ORDER_ITEM`·`LOT`·`ADJUSTMENT`)"),
213
+ refId: z.string().nullable(),
214
+ memo: z.string().nullable().describe("셀러 조정 사유. 그 밖에는 null"),
215
+ actor: z.string().nullable().describe("셀러 조정을 한 행위자. 그 밖에는 null"),
216
+ createdAt: z.string(),
217
+ });
218
+ export type PointEntry = z.infer<typeof pointEntrySchema>;
219
+
220
+ /** 적립 묶음 요약 — 남은 금액이 있는 묶음 */
221
+ export const pointLotSchema = z.object({
222
+ lotId: z.string(),
223
+ source: z
224
+ .union([z.enum(POINT_LOT_SOURCES), z.string()])
225
+ .describe("출처. `REVIEW`·`PURCHASE`·`ADJUST`·`OPENING` 등"),
226
+ amount: z.number().int().describe("적립한 금액"),
227
+ remaining: z.number().int().describe("남은 금액"),
228
+ expiresAt: z.string().nullable().describe("소멸 시각. null이면 소멸 없음"),
229
+ createdAt: z.string(),
230
+ });
231
+ export type PointLot = z.infer<typeof pointLotSchema>;
232
+
233
+ /** `GET /v1/customers/{customerId}/points` */
234
+ export const customerPointsSchema = z.object({
235
+ balance: z.number().int().describe("잔액"),
236
+ expiringSoon: z.number().int().describe("30일 안에 소멸할 금액"),
237
+ lots: z.array(pointLotSchema).describe("남은 금액이 있는 묶음(소멸이 이른 순, 최대 100개)"),
238
+ });
239
+ export type CustomerPoints = z.infer<typeof customerPointsSchema>;
240
+
241
+ /** 원장 조회 쿼리 — 상점 전체(`GET /v1/points/history`)는 회원·유형·기간으로 거른다 */
242
+ export const pointHistoryQuerySchema = z.object({
243
+ page: z.coerce.number().int().min(1).optional(),
244
+ size: z.coerce.number().int().min(1).max(100).optional(),
245
+ type: z.string().optional().describe("유형(`EARN_PURCHASE` 등)"),
246
+ memberId: z.string().optional().describe("회원(구매자) id"),
247
+ from: z.iso.datetime({ offset: true }).optional().describe("이 시각 이후(포함)"),
248
+ to: z.iso.datetime({ offset: true }).optional().describe("이 시각 이전(미포함)"),
249
+ });
250
+ export type PointHistoryQuery = z.infer<typeof pointHistoryQuerySchema>;
251
+
252
+ /** 셀러 조정 금액 한도 — 한 번에 천만 원 */
253
+ export const POINT_ADJUSTMENT_MAX = 10_000_000;
254
+
255
+ /**
256
+ * `POST /v1/customers/{customerId}/points/adjustments` — 셀러 지급(+)·차감(−). 헤더 `Idempotency-Key`가 필수다.
257
+ * 지급의 소멸 시각은 `expiresAt`(생략하면 정책 유효 기간, 정책이 없으면 400 `POINT_EXPIRY_REQUIRED`), 차감은 소멸이 이른 적립금부터 뺀다
258
+ * (잔액보다 많으면 409 `POINT_BALANCE_INSUFFICIENT`).
259
+ */
260
+ export const pointAdjustmentRequestSchema = z
261
+ .object({
262
+ amount: z
263
+ .number()
264
+ .int()
265
+ .min(-POINT_ADJUSTMENT_MAX)
266
+ .max(POINT_ADJUSTMENT_MAX)
267
+ .refine((value) => value !== 0, "0원은 조정할 수 없습니다")
268
+ .describe("지급은 양수, 차감은 음수(원)"),
269
+ reason: z.string().trim().min(1).max(200).describe("사유(필수). 구매자에게는 보이지 않는다"),
270
+ expiresAt: z.iso
271
+ .datetime({ offset: true })
272
+ .optional()
273
+ .describe("지급분의 소멸 시각. 생략하면 정책 유효 기간으로 정한다. 차감에는 주지 않는다"),
274
+ })
275
+ .refine((value) => !(value.amount < 0 && value.expiresAt), {
276
+ message: "차감에는 소멸 시각을 줄 수 없습니다",
277
+ path: ["expiresAt"],
278
+ });
279
+ export type PointAdjustmentRequest = z.infer<typeof pointAdjustmentRequestSchema>;
280
+
281
+ export const pointAdjustmentResultSchema = z.object({
282
+ entry: pointEntrySchema,
283
+ balance: z.number().int().describe("조정 뒤 잔액"),
284
+ });
285
+ export type PointAdjustmentResult = z.infer<typeof pointAdjustmentResultSchema>;
@@ -6,6 +6,7 @@ import {
6
6
  productFulfillmentResponseSchema,
7
7
  productFulfillmentUpdateSchema,
8
8
  } from "./fulfillment";
9
+ import { pointEarnRateBpSchema } from "./points";
9
10
  import { productRefundPolicyPatchSchema } from "./refund-policy";
10
11
  import { UPLOAD_PURPOSE_LIMITS, uploadExtensionOf } from "./upload";
11
12
 
@@ -292,6 +293,12 @@ export const productSchema = productSummarySchema.extend({
292
293
  "정가(할인 전 표시 가격). 원 단위. 비교용 표시 값이고 결제 금액 계산에 쓰지 않는다 — 구매자가 내는 가격은 " +
293
294
  "즉시할인이 있으면 `discountedPrice`, 없으면 `salePrice`다. 스토어프론트 상품 응답에도 같은 값이 실린다. 정하지 않았으면 null",
294
295
  ),
296
+ pointEarnRateBp: pointEarnRateBpSchema
297
+ .nullable()
298
+ .optional()
299
+ .describe(
300
+ "구매 적립률 덮어쓰기(만분율, 100 = 1%, 0~3000, 이슈 #93). null이면 상점 기본 적립률, 0이면 이 상품은 적립하지 않는다. 결제 확정 시점 값이 주문상품에 남는다",
301
+ ),
295
302
  images: z.array(z.url()).optional(),
296
303
  attributes: z.array(z.object({ attributeId: z.string(), value: z.string() })).optional(),
297
304
  optionGroups: z.array(optionGroupSchema),
@@ -547,6 +554,12 @@ const productWriteShape = {
547
554
  "정가(할인 전 표시 가격). 원 단위. 비교용 표시 값이고 결제 금액은 판매가(즉시할인이 있으면 할인 적용가)다. " +
548
555
  "스토어프론트가 판매가보다 클 때 취소선·할인율로 보인다. 즉시할인은 `PUT /v1/products/{productId}/discount`로 따로 정한다",
549
556
  ),
557
+ pointEarnRateBp: pointEarnRateBpSchema
558
+ .nullable()
559
+ .optional()
560
+ .describe(
561
+ "구매 적립률 덮어쓰기(만분율, 100 = 1%, 0~3000, 이슈 #93). null이면 상점 기본 적립률, 0이면 이 상품은 적립하지 않는다. 결제 확정 시점 값이 주문상품에 남는다. 전체 수정(PUT)에서 생략하면 지금 값을 유지한다",
562
+ ),
550
563
  description: z.string().optional(),
551
564
  /**
552
565
  * 상세설명 에디터(@avarlabs/editor)의 직렬화 상태. 재편집 때 이 값으로 문서를 복원한다.
@@ -616,6 +629,12 @@ export const productPatchRequestSchema = z
616
629
  .describe(
617
630
  "상품별 환불 정책. 주면 통째로 바꾼다(준 항목만 상점 기본을 덮어쓴다). `null`은 덮어쓰기를 지워 상점 기본을 따른다. 생략하면 바꾸지 않는다",
618
631
  ),
632
+ pointEarnRateBp: pointEarnRateBpSchema
633
+ .nullable()
634
+ .optional()
635
+ .describe(
636
+ "구매 적립률 덮어쓰기(만분율, 100 = 1%, 0~3000, 이슈 #93). null이면 상점 기본 적립률, 0이면 이 상품은 적립하지 않는다. 결제 확정 시점 값이 주문상품에 남는다. 생략하면 바꾸지 않는다",
637
+ ),
619
638
  })
620
639
  .refine((value) => Object.values(value).some((v) => v !== undefined), {
621
640
  message: "최소 1개 필드가 필요합니다",