@sayren/store-sdk 0.29.2 → 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 +242 -73
- package/dist/index.mjs +172 -9
- package/package.json +1 -1
- package/src/schemas/claim.ts +18 -0
- 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/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
|
+
}
|
package/src/schemas/promotion.ts
CHANGED
|
@@ -219,8 +219,31 @@ export interface PricingDelivery {
|
|
|
219
219
|
bundles: ReadonlyArray<{ bundleKey: string; fee: number; remoteSurcharge: number }>;
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
/**
|
|
223
|
+
* 적립금 사용 입력(#93 P3) — 쿠폰 뒤 금액에 쓴다. 규칙은 상점 정책 `use`(`PointUsePolicy`)의 값이다.
|
|
224
|
+
* 금액은 요청·잔액·최대 비율 중 작은 값을 `unit`으로 내리고, 남는 결제 금액이 100원 아래로 내려가지 않게 줄인다(적립금 전액 결제는 아직 받지 않는다).
|
|
225
|
+
*/
|
|
226
|
+
export interface PricingPoints {
|
|
227
|
+
/** 구매자가 쓰겠다는 금액 */
|
|
228
|
+
requested: number;
|
|
229
|
+
/** 지금 잔액 */
|
|
230
|
+
balance: number;
|
|
231
|
+
rules: {
|
|
232
|
+
minBalance: number;
|
|
233
|
+
minAmount: number;
|
|
234
|
+
unit: number;
|
|
235
|
+
maxRatioBp: number;
|
|
236
|
+
includeDeliveryFee: boolean;
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 적립금을 쓰지 못한 이유 */
|
|
241
|
+
export type PointRejectReason = "MIN_BALANCE" | "MIN_AMOUNT" | "NOT_PAYABLE";
|
|
242
|
+
|
|
222
243
|
export interface PricingInput {
|
|
223
244
|
lines: readonly PricingLine[];
|
|
245
|
+
/** 적립금 사용(#93 P3). 없으면 쓰지 않는다 — 결과는 적립금 이전과 같다 */
|
|
246
|
+
points?: PricingPoints | null;
|
|
224
247
|
coupons: readonly PricingCoupon[];
|
|
225
248
|
/** 배송비 견적. 없으면 배송비 0으로 계산하고 배송비 쿠폰은 할인할 금액이 없다 */
|
|
226
249
|
delivery?: PricingDelivery | null;
|
|
@@ -251,6 +274,8 @@ export interface PricingLineResult {
|
|
|
251
274
|
/** 이 줄에 들어간 쿠폰 할인 합계 — 상품 쿠폰 + 주문 쿠폰 배분 */
|
|
252
275
|
couponDiscount: number;
|
|
253
276
|
allocations: CouponAllocation[];
|
|
277
|
+
/** 이 줄에 배분한 사용 적립금(#93 P3). 적립금을 쓰지 않으면 0 */
|
|
278
|
+
pointAllocation: number;
|
|
254
279
|
}
|
|
255
280
|
|
|
256
281
|
export interface PricingCouponResult {
|
|
@@ -270,6 +295,8 @@ export interface PricingCouponResult {
|
|
|
270
295
|
export interface PricingBundleResult {
|
|
271
296
|
bundleKey: string;
|
|
272
297
|
couponDiscount: number;
|
|
298
|
+
/** 이 배송 묶음에 배분한 사용 적립금(#93 P3) */
|
|
299
|
+
pointAllocation: number;
|
|
273
300
|
}
|
|
274
301
|
|
|
275
302
|
export interface PricingResult {
|
|
@@ -285,7 +312,15 @@ export interface PricingResult {
|
|
|
285
312
|
deliveryFee: number;
|
|
286
313
|
/** 배송비 쿠폰 할인 */
|
|
287
314
|
deliveryDiscountAmount: number;
|
|
288
|
-
/**
|
|
315
|
+
/** 사용 적립금(#93 P3). 쓰지 않으면 0 */
|
|
316
|
+
pointAmount: number;
|
|
317
|
+
/** 지금 쓸 수 있는 최대 적립금 — 주문서 안내값(잔액을 모두 요청했을 때의 금액). 적립금 입력이 없으면 0 */
|
|
318
|
+
pointMaxUsable: number;
|
|
319
|
+
/** 요청보다 적게 썼다면 그 이유 — `MIN_PAYMENT`: 결제 금액 100원을 남기려고 줄였다 */
|
|
320
|
+
pointAdjustReason: "MIN_PAYMENT" | null;
|
|
321
|
+
/** 요청했지만 쓰지 못했다면 그 이유 */
|
|
322
|
+
pointRejectReason: PointRejectReason | null;
|
|
323
|
+
/** 결제 금액 = productAmount − couponDiscountAmount + deliveryFee − deliveryDiscountAmount − pointAmount */
|
|
289
324
|
totalAmount: number;
|
|
290
325
|
}
|
|
291
326
|
|
|
@@ -541,6 +576,7 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
541
576
|
const bundles: PricingBundleResult[] = deliveryBundles.map((bundle) => ({
|
|
542
577
|
bundleKey: bundle.bundleKey,
|
|
543
578
|
couponDiscount: 0,
|
|
579
|
+
pointAllocation: 0,
|
|
544
580
|
}));
|
|
545
581
|
let deliverySlot = false;
|
|
546
582
|
let deliveryDiscountAmount = 0;
|
|
@@ -574,6 +610,7 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
574
610
|
amount: entry.line.amount,
|
|
575
611
|
couponDiscount: entry.allocations.reduce((sum, a) => sum + a.amount, 0),
|
|
576
612
|
allocations: entry.allocations,
|
|
613
|
+
pointAllocation: 0,
|
|
577
614
|
}));
|
|
578
615
|
const productAmount = lines.reduce((sum, entry) => sum + entry.line.amount, 0);
|
|
579
616
|
const couponDiscountAmount = lineResults.reduce((sum, line) => sum + line.couponDiscount, 0);
|
|
@@ -581,6 +618,33 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
581
618
|
(sum, bundle) => sum + bundle.fee + bundle.remoteSurcharge,
|
|
582
619
|
0,
|
|
583
620
|
);
|
|
621
|
+
const beforePoints = productAmount - couponDiscountAmount + deliveryFee - deliveryDiscountAmount;
|
|
622
|
+
|
|
623
|
+
// 5) 적립금(#93 P3) — 쿠폰 뒤 금액에 쓴다. 상품 줄에 먼저(줄 실결제 비율), 남으면 배송 묶음에(묶음 실배송비 비율) 배분한다
|
|
624
|
+
const productWeights = lineResults.map((line) => Math.max(0, line.amount - line.couponDiscount));
|
|
625
|
+
const bundleWeights = deliveryBundles.map((bundle, index) =>
|
|
626
|
+
Math.max(0, bundle.fee + bundle.remoteSurcharge - (bundles[index]?.couponDiscount ?? 0)),
|
|
627
|
+
);
|
|
628
|
+
const points = pointUseOf(input.points ?? null, {
|
|
629
|
+
productPaid: productWeights.reduce((sum, w) => sum + w, 0),
|
|
630
|
+
deliveryPaid: bundleWeights.reduce((sum, w) => sum + w, 0),
|
|
631
|
+
total: beforePoints,
|
|
632
|
+
});
|
|
633
|
+
if (points.amount > 0) {
|
|
634
|
+
const productPaid = productWeights.reduce((sum, w) => sum + w, 0);
|
|
635
|
+
const toLines = Math.min(points.amount, productPaid);
|
|
636
|
+
allocateByWeight(toLines, productWeights).forEach((share, i) => {
|
|
637
|
+
const line = lineResults[i];
|
|
638
|
+
if (line) line.pointAllocation = share;
|
|
639
|
+
});
|
|
640
|
+
const toBundles = points.amount - toLines;
|
|
641
|
+
if (toBundles > 0) {
|
|
642
|
+
allocateByWeight(toBundles, bundleWeights).forEach((share, i) => {
|
|
643
|
+
const bundle = bundles[i];
|
|
644
|
+
if (bundle) bundle.pointAllocation = share;
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
584
648
|
return {
|
|
585
649
|
lines: lineResults,
|
|
586
650
|
coupons: results,
|
|
@@ -589,10 +653,56 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
589
653
|
couponDiscountAmount,
|
|
590
654
|
deliveryFee,
|
|
591
655
|
deliveryDiscountAmount,
|
|
592
|
-
|
|
656
|
+
pointAmount: points.amount,
|
|
657
|
+
pointMaxUsable: points.maxUsable,
|
|
658
|
+
pointAdjustReason: points.adjustReason,
|
|
659
|
+
pointRejectReason: points.rejectReason,
|
|
660
|
+
totalAmount: beforePoints - points.amount,
|
|
593
661
|
};
|
|
594
662
|
}
|
|
595
663
|
|
|
664
|
+
/**
|
|
665
|
+
* 사용 적립금 금액(순수) — 요청·잔액·최대 비율·「결제 금액 100원 남기기」 중 작은 값을 단위로 내린다.
|
|
666
|
+
* 최소 보유액·최소 사용액에 못 미치면 0이다. 결제 금액이 0원이 되는 사용(적립금 전액 결제)은 아직 받지 않는다(P4).
|
|
667
|
+
*/
|
|
668
|
+
export function pointUseOf(
|
|
669
|
+
points: PricingPoints | null,
|
|
670
|
+
amounts: { productPaid: number; deliveryPaid: number; total: number },
|
|
671
|
+
): {
|
|
672
|
+
amount: number;
|
|
673
|
+
maxUsable: number;
|
|
674
|
+
adjustReason: "MIN_PAYMENT" | null;
|
|
675
|
+
rejectReason: PointRejectReason | null;
|
|
676
|
+
} {
|
|
677
|
+
const none = { amount: 0, maxUsable: 0, adjustReason: null, rejectReason: null };
|
|
678
|
+
if (!points) return none;
|
|
679
|
+
const { rules } = points;
|
|
680
|
+
const unit = Math.max(1, Math.floor(rules.unit));
|
|
681
|
+
const base = amounts.productPaid + (rules.includeDeliveryFee ? amounts.deliveryPaid : 0);
|
|
682
|
+
const byRatio = Math.floor((base * rules.maxRatioBp) / 10_000);
|
|
683
|
+
// 결제 금액은 0원 또는 100원 이상이어야 하고, 지금은 0원 결제를 받지 않으므로 100원을 남긴다
|
|
684
|
+
const byPayment = Math.max(0, amounts.total - MIN_PAYMENT_AMOUNT);
|
|
685
|
+
const balance = Math.max(0, points.balance);
|
|
686
|
+
const floorUnit = (value: number) => Math.floor(Math.max(0, value) / unit) * unit;
|
|
687
|
+
const eligible = balance >= rules.minBalance;
|
|
688
|
+
const ceiling = Math.min(balance, byRatio, byPayment);
|
|
689
|
+
const maxUsable = eligible && floorUnit(ceiling) >= rules.minAmount ? floorUnit(ceiling) : 0;
|
|
690
|
+
const requested = Math.max(0, Math.floor(points.requested));
|
|
691
|
+
if (requested === 0) return { ...none, maxUsable };
|
|
692
|
+
if (!eligible) return { amount: 0, maxUsable, adjustReason: null, rejectReason: "MIN_BALANCE" };
|
|
693
|
+
const amount = floorUnit(Math.min(requested, balance, byRatio, byPayment));
|
|
694
|
+
if (amount <= 0) {
|
|
695
|
+
return { amount: 0, maxUsable, adjustReason: null, rejectReason: "NOT_PAYABLE" };
|
|
696
|
+
}
|
|
697
|
+
if (amount < rules.minAmount) {
|
|
698
|
+
return { amount: 0, maxUsable, adjustReason: null, rejectReason: "MIN_AMOUNT" };
|
|
699
|
+
}
|
|
700
|
+
// 요청이 결제 금액 100원 남기기에 걸려 줄었다
|
|
701
|
+
const adjustReason =
|
|
702
|
+
floorUnit(Math.min(requested, balance, byRatio)) > amount ? "MIN_PAYMENT" : null;
|
|
703
|
+
return { amount, maxUsable, adjustReason, rejectReason: null };
|
|
704
|
+
}
|
|
705
|
+
|
|
596
706
|
// ─── 환불 정산: 조건 미달 차감 ─────────────────────────────
|
|
597
707
|
|
|
598
708
|
/**
|
|
@@ -799,10 +909,36 @@ export function settleCouponShortfall(input: CouponSettlementInput): CouponSettl
|
|
|
799
909
|
* 바로 나누면 몫이 항상 0 이상이고 전량 취소의 합이 실결제 금액과 정확히 같다.
|
|
800
910
|
*/
|
|
801
911
|
export function proratedPaidRefundBase(
|
|
802
|
-
item: {
|
|
912
|
+
item: {
|
|
913
|
+
totalPrice: number;
|
|
914
|
+
couponDiscountAmount?: number | null;
|
|
915
|
+
/** 주문상품에 배분된 사용 적립금(#93 P3) — 현금 환불 기준에서 뺀다. 없으면 0 */
|
|
916
|
+
pointAllocation?: number | null;
|
|
917
|
+
quantity: number;
|
|
918
|
+
},
|
|
803
919
|
settledQuantity: number,
|
|
804
920
|
cancelQuantity: number,
|
|
805
921
|
): number {
|
|
806
|
-
const paid = Math.max(
|
|
922
|
+
const paid = Math.max(
|
|
923
|
+
0,
|
|
924
|
+
item.totalPrice - (item.couponDiscountAmount ?? 0) - (item.pointAllocation ?? 0),
|
|
925
|
+
);
|
|
807
926
|
return proratedRefundBase(paid, item.quantity, settledQuantity, cancelQuantity);
|
|
808
927
|
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* 주문상품의 사용 적립금 중 이번 취소 수량의 몫(#93 P3) — 누적 내림의 차분이라 나눠 취소해도 합이 배분액과 같다.
|
|
931
|
+
* 현금 환불(`proratedPaidRefundBase`)과 따로 돌려준다(`pointRefundAmount`).
|
|
932
|
+
*/
|
|
933
|
+
export function proratedPointRefund(
|
|
934
|
+
item: { pointAllocation?: number | null; quantity: number },
|
|
935
|
+
settledQuantity: number,
|
|
936
|
+
cancelQuantity: number,
|
|
937
|
+
): number {
|
|
938
|
+
return proratedRefundBase(
|
|
939
|
+
item.pointAllocation ?? 0,
|
|
940
|
+
item.quantity,
|
|
941
|
+
settledQuantity,
|
|
942
|
+
cancelQuantity,
|
|
943
|
+
);
|
|
944
|
+
}
|
|
@@ -58,6 +58,14 @@ export const orderWebhookEventDataSchema = z.object({
|
|
|
58
58
|
"주문이 받은 상품·주문 쿠폰 할인 합계 (원). 즉시할인(`discountAmount`)과 별개다. " +
|
|
59
59
|
"쿠폰이 없거나 이 필드가 없는 이전 이벤트는 0으로 읽는다",
|
|
60
60
|
),
|
|
61
|
+
pointAmount: z
|
|
62
|
+
.number()
|
|
63
|
+
.int()
|
|
64
|
+
.optional()
|
|
65
|
+
.default(0)
|
|
66
|
+
.describe(
|
|
67
|
+
"주문에 쓴 적립금 (원, #93). 적립금을 쓰지 않았거나 이 필드가 없는 이전 이벤트는 0으로 읽는다",
|
|
68
|
+
),
|
|
61
69
|
orderItemId: z
|
|
62
70
|
.string()
|
|
63
71
|
.optional()
|
|
@@ -163,6 +171,15 @@ export const orderCanceledWebhookEventDataSchema = z.object({
|
|
|
163
171
|
"환불액에 더한 금액 (원) — 앞선 환불에서 뺀 쿠폰 조건 미달 차감을 되돌린 몫이다. " +
|
|
164
172
|
"전량 취소면 누적 차감이 모두 되돌아간다. 이 필드가 없는 이전 이벤트는 0으로 읽는다",
|
|
165
173
|
),
|
|
174
|
+
pointRefundAmount: z
|
|
175
|
+
.number()
|
|
176
|
+
.int()
|
|
177
|
+
.optional()
|
|
178
|
+
.default(0)
|
|
179
|
+
.describe(
|
|
180
|
+
"돌려준 적립금 (원, #93) — 결제에 쓴 적립금 중 이번 취소·반품 수량의 몫이다. 현금 환불(`refundAmount`)과 별개다. " +
|
|
181
|
+
"차감은 현금에서 먼저 빼고 모자란 몫만 여기서 뺀다. 적립금을 쓰지 않은 주문과 이 필드가 없는 이전 응답·이벤트는 0으로 읽는다",
|
|
182
|
+
),
|
|
166
183
|
testPayment: z
|
|
167
184
|
.boolean()
|
|
168
185
|
.describe("테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았다"),
|
|
@@ -234,6 +251,15 @@ export const claimWebhookEventDataSchema = z.object({
|
|
|
234
251
|
"환불액에 더한 금액 (원) — 앞선 환불에서 뺀 쿠폰 조건 미달 차감을 되돌린 몫이다. 접수 이벤트는 예상값이다. " +
|
|
235
252
|
"이 필드가 없는 이전 이벤트는 0으로 읽는다",
|
|
236
253
|
),
|
|
254
|
+
pointRefundAmount: z
|
|
255
|
+
.number()
|
|
256
|
+
.int()
|
|
257
|
+
.optional()
|
|
258
|
+
.default(0)
|
|
259
|
+
.describe(
|
|
260
|
+
"돌려준 적립금 (원, #93) — 결제에 쓴 적립금 중 이번 취소·반품 수량의 몫이다. 현금 환불(`refundAmount`)과 별개다. " +
|
|
261
|
+
"차감은 현금에서 먼저 빼고 모자란 몫만 여기서 뺀다. 적립금을 쓰지 않은 주문과 이 필드가 없는 이전 응답·이벤트는 0으로 읽는다",
|
|
262
|
+
),
|
|
237
263
|
testPayment: z
|
|
238
264
|
.boolean()
|
|
239
265
|
.optional()
|