@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/src/schemas/promotion.ts
CHANGED
|
@@ -94,6 +94,142 @@ export const couponBenefitSchema = z
|
|
|
94
94
|
});
|
|
95
95
|
export type CouponBenefit = z.infer<typeof couponBenefitSchema>;
|
|
96
96
|
|
|
97
|
+
/** 구간 할인의 구간 수 상한 */
|
|
98
|
+
export const COUPON_REWARD_TIER_MAX = 5;
|
|
99
|
+
|
|
100
|
+
const rewardShortfallChargeSchema = z
|
|
101
|
+
.boolean()
|
|
102
|
+
.describe(
|
|
103
|
+
"부분 취소로 할인액이 줄면 그 차액을 환불에서 뺀다. 상점 환불 정책 `couponShortfallCharge`가 꺼져 있거나 판매자 귀책이면 빼지 않는다",
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 확장 혜택(이슈 #222 혜택 확장) — 있으면 계산의 원천이고 `benefit`은 가장 가까운 값이다.
|
|
108
|
+
* - `TIERED`: 기준 금액(주문 쿠폰은 상품 쿠폰 차감 뒤 대상 합계, 상품 쿠폰은 그 줄 금액)이 넘은 가장 높은 구간의 할인. 첫 구간 미만이면 `MIN_ORDER_AMOUNT`
|
|
109
|
+
* - `BUY_X_GET_Y`: 상품 쿠폰만. 걸린 줄 수량을 `buy + get`개 묶음으로 세어 묶음마다 `get`개를 `rate`% 할인
|
|
110
|
+
* - `POINT`: 주문 쿠폰만. 할인 대신 대상 줄 실결제(쿠폰·적립금 뒤)에 `benefit`을 적용한 적립금을 구매확정 때 준다
|
|
111
|
+
*/
|
|
112
|
+
export const couponRewardSchema = z
|
|
113
|
+
.discriminatedUnion("type", [
|
|
114
|
+
z.object({
|
|
115
|
+
type: z.literal("TIERED"),
|
|
116
|
+
tiers: z
|
|
117
|
+
.array(
|
|
118
|
+
z.object({
|
|
119
|
+
minAmount: z.number().int().min(0).describe("이 구간의 기준 금액 하한(원)"),
|
|
120
|
+
benefit: couponBenefitSchema,
|
|
121
|
+
}),
|
|
122
|
+
)
|
|
123
|
+
.min(2)
|
|
124
|
+
.max(COUPON_REWARD_TIER_MAX)
|
|
125
|
+
.describe("구간. 기준 금액 하한 오름차순"),
|
|
126
|
+
shortfallCharge: rewardShortfallChargeSchema,
|
|
127
|
+
}),
|
|
128
|
+
z.object({
|
|
129
|
+
type: z.literal("BUY_X_GET_Y"),
|
|
130
|
+
buy: z.number().int().min(1).max(99).describe("구매 수량(N+1의 N)"),
|
|
131
|
+
get: z.number().int().min(1).max(99).describe("할인 수량(N+1의 1)"),
|
|
132
|
+
rate: z.number().int().min(1).max(100).describe("할인 수량의 할인율(%). 100이면 무료"),
|
|
133
|
+
shortfallCharge: rewardShortfallChargeSchema,
|
|
134
|
+
}),
|
|
135
|
+
z.object({
|
|
136
|
+
type: z.literal("POINT"),
|
|
137
|
+
benefit: couponBenefitSchema.describe(
|
|
138
|
+
"적립 방식. `RATE`=대상 실결제의 %(최대 `maxDiscountAmount`), `AMOUNT`=고정 적립금",
|
|
139
|
+
),
|
|
140
|
+
}),
|
|
141
|
+
])
|
|
142
|
+
.superRefine((reward, ctx) => {
|
|
143
|
+
if (reward.type !== "TIERED") return;
|
|
144
|
+
reward.tiers.forEach((tier, index) => {
|
|
145
|
+
const prev = reward.tiers[index - 1];
|
|
146
|
+
if (prev && tier.minAmount <= prev.minAmount) {
|
|
147
|
+
ctx.addIssue({
|
|
148
|
+
code: "custom",
|
|
149
|
+
path: ["tiers", index, "minAmount"],
|
|
150
|
+
message: "구간 하한은 앞 구간보다 커야 합니다",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
})
|
|
155
|
+
.describe("확장 혜택(구간 할인·N+1·적립금 지급). 없으면 `benefit`이 혜택이다");
|
|
156
|
+
export type CouponReward = z.infer<typeof couponRewardSchema>;
|
|
157
|
+
|
|
158
|
+
/** 확장 혜택을 쓸 수 있는 쿠폰 종류 */
|
|
159
|
+
export const COUPON_REWARD_KINDS: Record<CouponReward["type"], readonly CouponKind[]> = {
|
|
160
|
+
TIERED: ["ORDER", "PRODUCT"],
|
|
161
|
+
BUY_X_GET_Y: ["PRODUCT"],
|
|
162
|
+
POINT: ["ORDER"],
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** 응답·옛 칸의 `benefit` — 확장 혜택에서 가장 가까운 값(구간은 첫 구간, N+1은 묶음 평균 할인율, 적립금은 할인 0원에 가까운 1원) */
|
|
166
|
+
export function couponBenefitOfReward(reward: CouponReward): CouponBenefit {
|
|
167
|
+
if (reward.type === "TIERED") return (reward.tiers[0] as { benefit: CouponBenefit }).benefit;
|
|
168
|
+
if (reward.type === "BUY_X_GET_Y") {
|
|
169
|
+
// 묶음 평균 할인율(1+1 무료 → 50%) — 확장 혜택을 모르는 소비자에게 「100% 할인」으로 보이지 않게
|
|
170
|
+
const value = Math.max(1, Math.floor((reward.rate * reward.get) / (reward.buy + reward.get)));
|
|
171
|
+
return { type: "RATE", value, maxDiscountAmount: null };
|
|
172
|
+
}
|
|
173
|
+
return { type: "AMOUNT", value: 1, maxDiscountAmount: null };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 구간 할인의 적용 구간 — 기준 금액이 넘은 가장 높은 구간. 없으면 null */
|
|
177
|
+
export function couponTierOf(
|
|
178
|
+
reward: Extract<CouponReward, { type: "TIERED" }>,
|
|
179
|
+
base: number,
|
|
180
|
+
): { index: number; benefit: CouponBenefit } | null {
|
|
181
|
+
for (let index = reward.tiers.length - 1; index >= 0; index--) {
|
|
182
|
+
const tier = reward.tiers[index];
|
|
183
|
+
if (tier && base >= tier.minAmount) return { index, benefit: tier.benefit };
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 쿠폰 한 장의 할인액 — 확장 혜택까지 보는 원천이다(`quotePricing`·최대 조합이 같이 쓴다).
|
|
190
|
+
* `line`은 상품 쿠폰이 걸린 줄의 금액·수량이고 N+1만 수량을 읽는다. 적립금 지급은 할인이 0이다.
|
|
191
|
+
*/
|
|
192
|
+
export function couponRuleDiscountOf(
|
|
193
|
+
rule: Pick<CouponRule, "benefit" | "reward">,
|
|
194
|
+
base: number,
|
|
195
|
+
line: { quantity: number } | null,
|
|
196
|
+
rules: MoneyRules = KRW_MONEY_RULES,
|
|
197
|
+
): number {
|
|
198
|
+
const reward = rule.reward;
|
|
199
|
+
if (!reward) return couponDiscountOf(rule.benefit, base, rules);
|
|
200
|
+
if (reward.type === "POINT") return 0;
|
|
201
|
+
if (reward.type === "TIERED") {
|
|
202
|
+
const tier = couponTierOf(reward, base);
|
|
203
|
+
return tier ? couponDiscountOf(tier.benefit, base, rules) : 0;
|
|
204
|
+
}
|
|
205
|
+
const quantity = line?.quantity ?? 0;
|
|
206
|
+
if (base <= 0 || quantity <= 0) return 0;
|
|
207
|
+
const units = Math.floor(quantity / (reward.buy + reward.get)) * reward.get;
|
|
208
|
+
// 같은 줄은 단가가 같다 — 할인 수량의 금액은 줄 금액의 수량 비율(내림)이다
|
|
209
|
+
const unitsAmount = Math.floor((base * units) / quantity);
|
|
210
|
+
if (reward.rate === 100) return unitsAmount;
|
|
211
|
+
return couponDiscountOf(
|
|
212
|
+
{ type: "RATE", value: reward.rate, maxDiscountAmount: null },
|
|
213
|
+
unitsAmount,
|
|
214
|
+
rules,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 적립금 지급 쿠폰의 적립금 — 대상 줄 실결제(쿠폰·적립금 뒤)에 혜택을 적용하고 줄에 실결제 비율로 나눈다.
|
|
220
|
+
* 결제 시작(`quotePricing`)과 구매확정 지급이 같은 함수다(구매확정은 주문상품의 실결제를 넣는다).
|
|
221
|
+
*/
|
|
222
|
+
export function couponPointRewardOf(
|
|
223
|
+
reward: Extract<CouponReward, { type: "POINT" }>,
|
|
224
|
+
paidByLine: readonly number[],
|
|
225
|
+
rules: MoneyRules = KRW_MONEY_RULES,
|
|
226
|
+
): { total: number; shares: number[] } {
|
|
227
|
+
const weights = paidByLine.map((paid) => Math.max(0, paid));
|
|
228
|
+
const base = weights.reduce((sum, w) => sum + w, 0);
|
|
229
|
+
const total = couponDiscountOf(reward.benefit, base, rules);
|
|
230
|
+
return { total, shares: total > 0 ? allocateByWeight(total, weights) : weights.map(() => 0) };
|
|
231
|
+
}
|
|
232
|
+
|
|
97
233
|
/** 쿠폰 사용 조건 */
|
|
98
234
|
export const couponConditionsSchema = z.object({
|
|
99
235
|
minOrderAmount: z.number().int().min(0).describe(descriptions.minOrderAmount),
|
|
@@ -174,6 +310,7 @@ export const couponRuleSchema = z.object({
|
|
|
174
310
|
stacking: couponStackingSchema
|
|
175
311
|
.optional()
|
|
176
312
|
.describe("중복 사용 정책(이슈 #222). 없으면 종류별 한 장·서로 결합·적립금과 함께"),
|
|
313
|
+
reward: couponRewardSchema.optional(),
|
|
177
314
|
});
|
|
178
315
|
export type CouponRule = z.infer<typeof couponRuleSchema>;
|
|
179
316
|
|
|
@@ -381,6 +518,10 @@ export interface PricingCouponResult {
|
|
|
381
518
|
eligibleLineIds: string[];
|
|
382
519
|
/** 판정에 쓴 사실 값 중 이 쿠폰의 사용 조건이 읽은 것 — 주문 스냅샷에 굳힌다. 읽은 값이 없으면 빈 객체 */
|
|
383
520
|
facts: CouponFacts;
|
|
521
|
+
/** 적립금 지급 쿠폰이 구매확정 때 줄 적립금(지금 수량 기준). 그 밖은 0 */
|
|
522
|
+
rewardPoint: number;
|
|
523
|
+
/** 구간 할인이 적용한 구간 순번. 그 밖은 null — 환불 정산이 구간이 내려갔는지 본다 */
|
|
524
|
+
tierIndex: number | null;
|
|
384
525
|
}
|
|
385
526
|
|
|
386
527
|
export interface PricingBundleResult {
|
|
@@ -395,6 +536,8 @@ export interface PricingResult {
|
|
|
395
536
|
coupons: PricingCouponResult[];
|
|
396
537
|
/** 배송비 쿠폰의 묶음별 배분 */
|
|
397
538
|
bundles: PricingBundleResult[];
|
|
539
|
+
/** 적립금 지급 쿠폰의 적립 예정 합계 — 결제 금액에 영향이 없다 */
|
|
540
|
+
rewardPointAmount: number;
|
|
398
541
|
/** 상품 금액 합 (즉시할인 반영, 쿠폰 전) */
|
|
399
542
|
productAmount: number;
|
|
400
543
|
/** 상품·주문 쿠폰 할인 합계 */
|
|
@@ -535,6 +678,8 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
535
678
|
lineId: null,
|
|
536
679
|
eligibleLineIds: [],
|
|
537
680
|
facts: {},
|
|
681
|
+
rewardPoint: 0,
|
|
682
|
+
tierIndex: null,
|
|
538
683
|
}));
|
|
539
684
|
|
|
540
685
|
const subtotal = lines.reduce((sum, entry) => sum + entry.line.amount, 0);
|
|
@@ -596,9 +741,11 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
596
741
|
/** 적용된 쿠폰 — 같은 쿠폰 정의의 장수(`perOrderLimit`)와 결합 판정에 쓴다 */
|
|
597
742
|
const appliedCount = new Map<string, number>();
|
|
598
743
|
const applied: PricingCoupon[] = [];
|
|
599
|
-
const apply = (index: number, discount: number, lineId: string | null) => {
|
|
744
|
+
const apply = (index: number, discount: number, lineId: string | null, base = 0) => {
|
|
600
745
|
const result = results[index] as PricingCouponResult;
|
|
601
746
|
const coupon = input.coupons[index] as PricingCoupon;
|
|
747
|
+
const reward = coupon.rule.reward;
|
|
748
|
+
if (reward?.type === "TIERED") result.tierIndex = couponTierOf(reward, base)?.index ?? null;
|
|
602
749
|
result.applied = true;
|
|
603
750
|
result.discount = discount;
|
|
604
751
|
result.lineId = lineId;
|
|
@@ -622,6 +769,17 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
622
769
|
return blocked;
|
|
623
770
|
};
|
|
624
771
|
|
|
772
|
+
/** 상품 쿠폰을 줄 하나에 걸었을 때의 할인 — N+1은 그 줄 수량을 센다 */
|
|
773
|
+
const productDiscountOn = (coupon: PricingCoupon, lineId: string) => {
|
|
774
|
+
const line = lineOf(lineId)?.line;
|
|
775
|
+
return couponRuleDiscountOf(coupon.rule, line?.amount ?? 0, line ?? null, rules);
|
|
776
|
+
};
|
|
777
|
+
/** 할인이 0일 때의 사유 — 구간 할인이 첫 구간에 못 미치면 최소 금액 미달이다 */
|
|
778
|
+
const zeroReasonOf = (coupon: PricingCoupon, base: number): CouponRejectReason =>
|
|
779
|
+
coupon.rule.reward?.type === "TIERED" && couponTierOf(coupon.rule.reward, base) === null
|
|
780
|
+
? "MIN_ORDER_AMOUNT"
|
|
781
|
+
: "NO_DISCOUNT";
|
|
782
|
+
|
|
625
783
|
// 2) 상품 쿠폰 — 한 줄에 한 장. 줄을 지정한 쿠폰을 먼저 걸고, 나머지는 대상 줄이 적은 쿠폰부터
|
|
626
784
|
// 할인이 가장 큰 남은 줄(같으면 앞 줄)에 건다. 대상이 넓은 쿠폰이 좁은 쿠폰의 유일한 줄을 먼저 가져가지 않게 한다
|
|
627
785
|
const productIndexes = passed.filter((index) => input.coupons[index]?.rule.kind === "PRODUCT");
|
|
@@ -650,14 +808,14 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
650
808
|
continue;
|
|
651
809
|
}
|
|
652
810
|
chosen = coupon.lineId;
|
|
653
|
-
best =
|
|
811
|
+
best = productDiscountOn(coupon, chosen);
|
|
654
812
|
} else {
|
|
655
813
|
if (candidates.length === 0) {
|
|
656
814
|
result.rejectReason = "STACK_LIMIT";
|
|
657
815
|
continue;
|
|
658
816
|
}
|
|
659
817
|
for (const id of candidates) {
|
|
660
|
-
const discount =
|
|
818
|
+
const discount = productDiscountOn(coupon, id);
|
|
661
819
|
if (chosen === null || discount > best) {
|
|
662
820
|
chosen = id;
|
|
663
821
|
best = discount;
|
|
@@ -666,13 +824,13 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
666
824
|
}
|
|
667
825
|
const entry = chosen === null ? undefined : lineOf(chosen);
|
|
668
826
|
if (!entry || chosen === null || best <= 0) {
|
|
669
|
-
result.rejectReason =
|
|
827
|
+
result.rejectReason = zeroReasonOf(coupon, entry?.line.amount ?? 0);
|
|
670
828
|
continue;
|
|
671
829
|
}
|
|
672
830
|
takenLines.add(chosen);
|
|
673
831
|
entry.productDiscount += best;
|
|
674
832
|
entry.allocations.push({ couponKey: result.couponKey, amount: best });
|
|
675
|
-
apply(index, best, chosen);
|
|
833
|
+
apply(index, best, chosen, entry.line.amount);
|
|
676
834
|
}
|
|
677
835
|
|
|
678
836
|
// 3) 주문 쿠폰 — 상품 쿠폰 차감 뒤 금액 기준, 대상 줄에 금액 비율로 배분
|
|
@@ -692,9 +850,19 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
692
850
|
.filter((entry): entry is (typeof lines)[number] => entry !== undefined);
|
|
693
851
|
const weights = targets.map((entry) => Math.max(0, entry.line.amount - entry.productDiscount));
|
|
694
852
|
const base = weights.reduce((sum, w) => sum + w, 0);
|
|
695
|
-
|
|
853
|
+
// 적립금 지급은 할인 없이 자리를 차지하고, 적립금은 실결제가 정해진 뒤(5 뒤) 계산한다
|
|
854
|
+
if (coupon.rule.reward?.type === "POINT") {
|
|
855
|
+
if (base <= 0) {
|
|
856
|
+
result.rejectReason = "NO_DISCOUNT";
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
orderSlot = true;
|
|
860
|
+
apply(index, 0, null);
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
const discount = couponRuleDiscountOf(coupon.rule, base, null, rules);
|
|
696
864
|
if (discount <= 0) {
|
|
697
|
-
result.rejectReason =
|
|
865
|
+
result.rejectReason = zeroReasonOf(coupon, base);
|
|
698
866
|
continue;
|
|
699
867
|
}
|
|
700
868
|
const shares = allocateByWeight(discount, weights);
|
|
@@ -703,7 +871,7 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
703
871
|
if (share > 0) entry.allocations.push({ couponKey: result.couponKey, amount: share });
|
|
704
872
|
});
|
|
705
873
|
orderSlot = true;
|
|
706
|
-
apply(index, discount, null);
|
|
874
|
+
apply(index, discount, null, base);
|
|
707
875
|
}
|
|
708
876
|
|
|
709
877
|
// 4) 배송비 쿠폰 — 기본 배송비(지역 추가 제외) 기준, 묶음에 기본 배송비 비율로 배분
|
|
@@ -727,7 +895,7 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
727
895
|
if (notCombinable(index)) continue;
|
|
728
896
|
const weights = deliveryBundles.map((bundle) => Math.max(0, bundle.fee));
|
|
729
897
|
const base = weights.reduce((sum, w) => sum + w, 0);
|
|
730
|
-
const discount =
|
|
898
|
+
const discount = couponRuleDiscountOf(coupon.rule, base, null, rules);
|
|
731
899
|
if (discount <= 0) {
|
|
732
900
|
result.rejectReason = "NO_DISCOUNT";
|
|
733
901
|
continue;
|
|
@@ -794,10 +962,24 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
794
962
|
});
|
|
795
963
|
}
|
|
796
964
|
}
|
|
965
|
+
// 6) 적립금 지급 쿠폰 — 대상 줄의 실결제(쿠폰·사용 적립금 뒤)로 계산한다. 구매확정 지급도 같은 함수다
|
|
966
|
+
let rewardPointAmount = 0;
|
|
967
|
+
input.coupons.forEach((coupon, index) => {
|
|
968
|
+
const result = results[index] as PricingCouponResult;
|
|
969
|
+
const reward = coupon.rule.reward;
|
|
970
|
+
if (!result.applied || reward?.type !== "POINT") return;
|
|
971
|
+
const paid = result.eligibleLineIds.map((id) => {
|
|
972
|
+
const line = lineResults[lineIndex.get(id) ?? -1];
|
|
973
|
+
return line ? line.amount - line.couponDiscount - line.pointAllocation : 0;
|
|
974
|
+
});
|
|
975
|
+
result.rewardPoint = couponPointRewardOf(reward, paid, rules).total;
|
|
976
|
+
rewardPointAmount += result.rewardPoint;
|
|
977
|
+
});
|
|
797
978
|
return {
|
|
798
979
|
lines: lineResults,
|
|
799
980
|
coupons: results,
|
|
800
981
|
bundles,
|
|
982
|
+
rewardPointAmount,
|
|
801
983
|
productAmount,
|
|
802
984
|
couponDiscountAmount,
|
|
803
985
|
deliveryFee,
|
|
@@ -927,7 +1109,7 @@ export interface CouponSettlementEntry {
|
|
|
927
1109
|
allocatedRemaining: number;
|
|
928
1110
|
/** 남은 주문상품으로 다시 계산한 할인 — 쿠폰 조건을 잃었으면 0 */
|
|
929
1111
|
recomputed: number;
|
|
930
|
-
/** 누적 미달분 목표 = 조건을 잃었으면 `allocatedRemaining`, 아니면 0 */
|
|
1112
|
+
/** 누적 미달분 목표 = 조건을 잃었으면 `allocatedRemaining`, 차액 정산 쿠폰은 `allocatedRemaining − recomputed`(음수면 더 돌려준다), 아니면 0 */
|
|
931
1113
|
target: number;
|
|
932
1114
|
charge: number;
|
|
933
1115
|
refund: number;
|
|
@@ -949,6 +1131,47 @@ export const NO_COUPON_SETTLEMENT: CouponSettlementResult = {
|
|
|
949
1131
|
entries: [],
|
|
950
1132
|
};
|
|
951
1133
|
|
|
1134
|
+
/**
|
|
1135
|
+
* 주문이 받은 쿠폰을 수량 `quantityOf(줄)`로 다시 계산한다 — 환불 정산(`settleCouponShortfall`)과 적립금 지급 쿠폰의 구매확정 적립이 같이 쓴다.
|
|
1136
|
+
* 기간·회원 전용은 결제 시작에서 이미 통과했다. 사용 조건 트리는 주문 시점 사실 값 + 남은 금액으로 다시 보고,
|
|
1137
|
+
* 스냅샷에 없는 구매자·시각 값(옛 주문)은 통과한 것으로 둔다 — 주문서 집계(`cart.*`)만 남은 금액으로 다시 판정한다
|
|
1138
|
+
*/
|
|
1139
|
+
export function recomputeOrderCoupons(
|
|
1140
|
+
coupons: readonly AppliedCouponSnapshot[],
|
|
1141
|
+
lines: readonly CouponSettlementLine[],
|
|
1142
|
+
quantityOf: (line: CouponSettlementLine) => number = (line) => line.activeQuantity,
|
|
1143
|
+
): PricingResult {
|
|
1144
|
+
return quotePricing({
|
|
1145
|
+
lines: lines.map((line) => {
|
|
1146
|
+
const quantity = quantityOf(line);
|
|
1147
|
+
const amount = proratedRefundBase(line.totalPrice, line.quantity, 0, quantity);
|
|
1148
|
+
// 대상은 주문 시점 목록으로 고정하므로 즉시할인 판정에 쓰는 정가는 금액과 같게 둔다
|
|
1149
|
+
return {
|
|
1150
|
+
lineId: line.lineId,
|
|
1151
|
+
productId: line.productId,
|
|
1152
|
+
quantity,
|
|
1153
|
+
baseAmount: amount,
|
|
1154
|
+
amount,
|
|
1155
|
+
};
|
|
1156
|
+
}),
|
|
1157
|
+
coupons: coupons.map((coupon) => {
|
|
1158
|
+
const facts = coupon.facts ?? {};
|
|
1159
|
+
const eligibility = assumeCouponFactRules(couponTreesOf(coupon.rule).eligibility, facts);
|
|
1160
|
+
return {
|
|
1161
|
+
rule: { ...coupon.rule, eligibility },
|
|
1162
|
+
couponKey: coupon.couponKey,
|
|
1163
|
+
lineId: coupon.lineId,
|
|
1164
|
+
eligibleLineIds: coupon.eligibleLineIds,
|
|
1165
|
+
facts,
|
|
1166
|
+
};
|
|
1167
|
+
}),
|
|
1168
|
+
delivery: null,
|
|
1169
|
+
isMember: true,
|
|
1170
|
+
now: new Date(0), // ignoreEligibility라 기간을 보지 않는다. 시각 조건은 스냅샷 값이거나 통과로 둔다
|
|
1171
|
+
ignoreEligibility: true,
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
|
|
952
1175
|
/**
|
|
953
1176
|
* 쿠폰 조건 미달 차감 — **순수 함수다.** 부분 취소·반품으로 남은 주문상품이 쿠폰 조건을 잃으면,
|
|
954
1177
|
* 남은 주문상품이 받던 할인만큼을 이번 환불에서 뺀다.
|
|
@@ -959,6 +1182,9 @@ export const NO_COUPON_SETTLEMENT: CouponSettlementResult = {
|
|
|
959
1182
|
* - 쿠폰이 **여전히 적용되면 목표 0**, 조건을 잃었으면 목표 = `allocatedRemaining`이다. 적용되는 쿠폰의
|
|
960
1183
|
* 정률 10원 내림 차이까지 차감하면 조건을 지킨 구매자가 몇 원씩 잃는다 — 조건을 잃은 경우만 본다.
|
|
961
1184
|
* 재계산 할인이 더 커지는 경우(정률 최대 할인액)도 차감하지 않고 더 돌려주지도 않는다.
|
|
1185
|
+
* - 확장 혜택(구간 할인·N+1)에서 셀러가 차액 정산(`reward.shortfallCharge`)을 켰으면 적용이 유지돼도 목표가
|
|
1186
|
+
* `allocatedRemaining − recomputed`다. 구간 할인은 구간이 내려갔을 때만 보고 0 아래로 내리지 않는다. N+1만 음수 목표를 허용한다
|
|
1187
|
+
* (남은 수량의 할인 개수가 배분보다 많으면 그만큼 더 돌려주고 누적 차감이 음수로 남는다). 뒤의 환불이 그 몫을 되찾을 때는 판매자 귀책·정책 꺼짐이어도 면제하지 않는다.
|
|
962
1188
|
* - `delta` = 목표 − (이미 차감 + 이미 면제). 양수면 청구하고(판매자 귀책·정책 꺼짐이면 면제로 기록),
|
|
963
1189
|
* 음수면 앞서 뺀 차감부터 돌려준다. 마지막 활성 수량까지 취소되면 목표가 0이라 누적 차감이 모두 되돌아가고,
|
|
964
1190
|
* 전량 취소의 환불 합계가 결제액과 같아진다.
|
|
@@ -972,37 +1198,13 @@ export function settleCouponShortfall(input: CouponSettlementInput): CouponSettl
|
|
|
972
1198
|
const coupons = input.coupons.filter((coupon) => coupon.rule.kind !== "DELIVERY");
|
|
973
1199
|
if (coupons.length === 0) return NO_COUPON_SETTLEMENT;
|
|
974
1200
|
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
baseAmount: remaining,
|
|
983
|
-
amount: remaining,
|
|
984
|
-
};
|
|
985
|
-
});
|
|
986
|
-
const recomputed = quotePricing({
|
|
987
|
-
lines: pricingLines,
|
|
988
|
-
coupons: coupons.map((coupon) => {
|
|
989
|
-
// 기간·회원 전용은 결제 시작에서 이미 통과했다. 사용 조건 트리는 주문 시점 사실 값 + 남은 금액으로 다시 본다.
|
|
990
|
-
// 스냅샷에 없는 구매자·시각 값(옛 주문)은 결제 시작에서 통과한 것으로 둔다 — 주문서 집계(`cart.*`)만 남은 금액으로 다시 판정한다
|
|
991
|
-
const facts = coupon.facts ?? {};
|
|
992
|
-
const eligibility = assumeCouponFactRules(couponTreesOf(coupon.rule).eligibility, facts);
|
|
993
|
-
return {
|
|
994
|
-
rule: { ...coupon.rule, eligibility },
|
|
995
|
-
couponKey: coupon.couponKey,
|
|
996
|
-
lineId: coupon.lineId,
|
|
997
|
-
eligibleLineIds: coupon.eligibleLineIds,
|
|
998
|
-
facts,
|
|
999
|
-
};
|
|
1000
|
-
}),
|
|
1001
|
-
delivery: null,
|
|
1002
|
-
isMember: true,
|
|
1003
|
-
now: new Date(0), // ignoreEligibility라 기간을 보지 않는다. 시각 조건은 스냅샷 값이거나 통과로 둔다
|
|
1004
|
-
ignoreEligibility: true,
|
|
1005
|
-
});
|
|
1201
|
+
const recompute = (quantityOf: (line: CouponSettlementLine) => number) =>
|
|
1202
|
+
recomputeOrderCoupons(coupons, input.lines, quantityOf);
|
|
1203
|
+
const recomputed = recompute((line) => line.activeQuantity);
|
|
1204
|
+
// 차액 차감(확장 혜택) — 구간 할인은 구간이 내려갔을 때만 본다(같은 구간의 정률 내림 차이로 몇 원씩 빼지 않는다)
|
|
1205
|
+
const original = coupons.some((coupon) => coupon.rule.reward?.type === "TIERED")
|
|
1206
|
+
? recompute((line) => line.quantity)
|
|
1207
|
+
: null;
|
|
1006
1208
|
|
|
1007
1209
|
const base = coupons.map((coupon, index) => {
|
|
1008
1210
|
const allocatedRemaining = input.lines.reduce((sum, line) => {
|
|
@@ -1013,13 +1215,30 @@ export function settleCouponShortfall(input: CouponSettlementInput): CouponSettl
|
|
|
1013
1215
|
}, 0);
|
|
1014
1216
|
const result = recomputed.coupons[index];
|
|
1015
1217
|
const stillApplied = result?.applied === true;
|
|
1218
|
+
const recomputedDiscount = stillApplied ? (result?.discount ?? 0) : 0;
|
|
1016
1219
|
const state = input.settlements[coupon.couponKey] ?? {};
|
|
1220
|
+
const reward = coupon.rule.reward;
|
|
1221
|
+
// 차액 정산 쿠폰은 목표가 음수일 수 있다(남은 수량의 할인이 배분보다 크면 그만큼 더 돌려준다 — N+1 홀수 잔량)
|
|
1222
|
+
// 차액 정산 — 구간 할인은 구간이 내려갔을 때만, 0 아래로는 내리지 않는다(구간이 내려가 더 돌려주는 일은 없다).
|
|
1223
|
+
// N+1은 할인 개수가 정수라 남은 수량의 할인이 배분보다 클 수 있다 — 그 차이만 음수 목표(더 돌려줌)다
|
|
1224
|
+
const tiered =
|
|
1225
|
+
reward?.type === "TIERED" &&
|
|
1226
|
+
reward.shortfallCharge &&
|
|
1227
|
+
(result?.tierIndex ?? -1) < (original?.coupons[index]?.tierIndex ?? -1);
|
|
1228
|
+
const signed = reward?.type === "BUY_X_GET_Y" && reward.shortfallCharge;
|
|
1017
1229
|
return {
|
|
1018
1230
|
couponKey: coupon.couponKey,
|
|
1019
1231
|
allocatedRemaining,
|
|
1020
|
-
recomputed:
|
|
1021
|
-
target: stillApplied
|
|
1022
|
-
|
|
1232
|
+
recomputed: recomputedDiscount,
|
|
1233
|
+
target: !stillApplied
|
|
1234
|
+
? allocatedRemaining
|
|
1235
|
+
: signed
|
|
1236
|
+
? allocatedRemaining - recomputedDiscount
|
|
1237
|
+
: tiered
|
|
1238
|
+
? Math.max(0, allocatedRemaining - recomputedDiscount)
|
|
1239
|
+
: 0,
|
|
1240
|
+
signed,
|
|
1241
|
+
charged: signed ? (state.charged ?? 0) : Math.max(0, state.charged ?? 0),
|
|
1023
1242
|
waived: Math.max(0, state.waived ?? 0),
|
|
1024
1243
|
};
|
|
1025
1244
|
});
|
|
@@ -1032,9 +1251,13 @@ export function settleCouponShortfall(input: CouponSettlementInput): CouponSettl
|
|
|
1032
1251
|
let refund = 0;
|
|
1033
1252
|
let waived = item.waived;
|
|
1034
1253
|
if (delta < 0) {
|
|
1035
|
-
// 구매자에게 유리하게 앞서 뺀 차감부터 돌려주고, 남은 초과분은 면제 기록에서
|
|
1036
|
-
|
|
1037
|
-
|
|
1254
|
+
// 구매자에게 유리하게 앞서 뺀 차감부터 돌려주고, 남은 초과분은 면제 기록에서 지운다.
|
|
1255
|
+
// 차액 정산 쿠폰은 그래도 남으면 더 돌려준다(누적 차감이 음수가 되고, 뒤의 환불이 목표로 돌아오며 다시 맞춘다)
|
|
1256
|
+
refund = Math.min(Math.max(0, item.charged), -delta);
|
|
1257
|
+
const rest = -delta - refund;
|
|
1258
|
+
const unwaived = Math.min(waived, rest);
|
|
1259
|
+
waived -= unwaived;
|
|
1260
|
+
if (item.signed) refund += rest - unwaived;
|
|
1038
1261
|
}
|
|
1039
1262
|
refundAmount += refund;
|
|
1040
1263
|
entries.push({
|
|
@@ -1053,8 +1276,22 @@ export function settleCouponShortfall(input: CouponSettlementInput): CouponSettl
|
|
|
1053
1276
|
let chargeAmount = 0;
|
|
1054
1277
|
const chargeAllowed = input.shortfallCharge && !input.sellerFault;
|
|
1055
1278
|
for (const entry of entries) {
|
|
1056
|
-
|
|
1279
|
+
let delta = entry.target - (entry.next.charged + entry.next.waived);
|
|
1057
1280
|
if (delta <= 0) continue;
|
|
1281
|
+
// 앞서 더 돌려준 몫(음수 누적)을 되찾는 것은 차감이 아니다 — 판매자 귀책·정책 꺼짐에도 면제하지 않는다.
|
|
1282
|
+
// 면제로 적으면 환불 합계가 결제액을 넘는다
|
|
1283
|
+
const owed = Math.max(0, -entry.next.charged);
|
|
1284
|
+
if (owed > 0) {
|
|
1285
|
+
const reclaim = Math.min(delta, owed, capacity);
|
|
1286
|
+
if (reclaim > 0) {
|
|
1287
|
+
capacity -= reclaim;
|
|
1288
|
+
chargeAmount += reclaim;
|
|
1289
|
+
entry.charge += reclaim;
|
|
1290
|
+
entry.next = { ...entry.next, charged: entry.next.charged + reclaim };
|
|
1291
|
+
}
|
|
1292
|
+
delta -= Math.min(delta, owed);
|
|
1293
|
+
if (delta <= 0) continue;
|
|
1294
|
+
}
|
|
1058
1295
|
if (!chargeAllowed) {
|
|
1059
1296
|
entry.waive = delta;
|
|
1060
1297
|
entry.next = { ...entry.next, waived: entry.next.waived + delta };
|
|
@@ -1064,7 +1301,7 @@ export function settleCouponShortfall(input: CouponSettlementInput): CouponSettl
|
|
|
1064
1301
|
if (charge <= 0) continue;
|
|
1065
1302
|
capacity -= charge;
|
|
1066
1303
|
chargeAmount += charge;
|
|
1067
|
-
entry.charge
|
|
1304
|
+
entry.charge += charge;
|
|
1068
1305
|
entry.next = { ...entry.next, charged: entry.next.charged + charge };
|
|
1069
1306
|
}
|
|
1070
1307
|
return { chargeAmount, refundAmount, entries };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 구매자 정보의 OIDC userinfo 표준 클레임(OpenID Connect Core 1.0 5.1, 이슈 #264). 이름은 표준 그대로 snake_case다.
|
|
5
|
+
* 값이 없는 클레임은 응답에서 빠진다. 같은 형식이 스토어프론트 API(storefront-sdk `userinfoSchema`)에도 있다.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** 생일 — `YYYY-MM-DD`, 연도를 모르면 `0000-MM-DD`(표준 형식) */
|
|
9
|
+
export const CUSTOMER_BIRTHDATE_PATTERN = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
|
10
|
+
|
|
11
|
+
export const customerUserinfoAddressSchema = z.object({
|
|
12
|
+
formatted: z.string().optional(),
|
|
13
|
+
street_address: z.string().optional(),
|
|
14
|
+
locality: z.string().optional(),
|
|
15
|
+
region: z.string().optional(),
|
|
16
|
+
postal_code: z.string().optional(),
|
|
17
|
+
country: z.string().optional().describe("ISO 3166-1 alpha-2"),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const customerUserinfoSchema = z
|
|
21
|
+
.object({
|
|
22
|
+
sub: z.string().describe("구매자 id(상점 안에서 유일)"),
|
|
23
|
+
name: z.string().optional(),
|
|
24
|
+
given_name: z.string().optional(),
|
|
25
|
+
family_name: z.string().optional(),
|
|
26
|
+
middle_name: z.string().optional(),
|
|
27
|
+
nickname: z.string().optional(),
|
|
28
|
+
preferred_username: z.string().optional(),
|
|
29
|
+
profile: z.string().optional(),
|
|
30
|
+
picture: z.string().optional(),
|
|
31
|
+
website: z.string().optional(),
|
|
32
|
+
email: z.string().optional(),
|
|
33
|
+
email_verified: z
|
|
34
|
+
.boolean()
|
|
35
|
+
.optional()
|
|
36
|
+
.describe("이메일 확인 여부 — 인증된 소셜 가입·셀러 위임만 true다. 비밀번호 가입은 false"),
|
|
37
|
+
gender: z.string().optional().describe("표준 값은 `female`·`male`이고 다른 값도 온다"),
|
|
38
|
+
birthdate: z.string().optional().describe("`YYYY-MM-DD`, 연도를 모르면 `0000-MM-DD`"),
|
|
39
|
+
zoneinfo: z.string().optional().describe("IANA 시간대(예: `Asia/Seoul`)"),
|
|
40
|
+
locale: z.string().optional().describe("BCP 47 언어 태그(예: `ko-KR`)"),
|
|
41
|
+
phone_number: z.string().optional().describe("E.164(예: `+821012345678`)"),
|
|
42
|
+
phone_number_verified: z.boolean().optional(),
|
|
43
|
+
address: customerUserinfoAddressSchema.optional().describe("기본 배송지에서 만든다"),
|
|
44
|
+
updated_at: z.number().int().optional().describe("프로필을 바꾼 시각(epoch 초)"),
|
|
45
|
+
})
|
|
46
|
+
.describe("OIDC userinfo 표준 클레임(이슈 #264)");
|
|
47
|
+
export type CustomerUserinfo = z.infer<typeof customerUserinfoSchema>;
|
|
48
|
+
|
|
49
|
+
const text = (max: number) => z.string().trim().min(1).max(max).nullable().optional();
|
|
50
|
+
const httpsUrl = z
|
|
51
|
+
.url({ protocol: /^https$/ })
|
|
52
|
+
.max(2048)
|
|
53
|
+
.nullable()
|
|
54
|
+
.optional()
|
|
55
|
+
.describe("https 주소");
|
|
56
|
+
|
|
57
|
+
/** 시간대·언어 태그 검사 — 런타임(Intl)이 아는 값만 받는다 */
|
|
58
|
+
const zoneinfo = z
|
|
59
|
+
.string()
|
|
60
|
+
.max(64)
|
|
61
|
+
.refine((value) => {
|
|
62
|
+
try {
|
|
63
|
+
new Intl.DateTimeFormat("en", { timeZone: value });
|
|
64
|
+
return true;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}, "IANA 시간대가 아닙니다")
|
|
69
|
+
.nullable()
|
|
70
|
+
.optional();
|
|
71
|
+
const locale = z
|
|
72
|
+
.string()
|
|
73
|
+
.max(35)
|
|
74
|
+
.refine((value) => {
|
|
75
|
+
try {
|
|
76
|
+
return Intl.getCanonicalLocales(value).length === 1;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}, "BCP 47 언어 태그가 아닙니다")
|
|
81
|
+
.nullable()
|
|
82
|
+
.optional();
|
|
83
|
+
const birthdate = z
|
|
84
|
+
.string()
|
|
85
|
+
.regex(CUSTOMER_BIRTHDATE_PATTERN, "생일은 YYYY-MM-DD(연도를 모르면 0000-MM-DD)입니다")
|
|
86
|
+
.refine((value) => {
|
|
87
|
+
const [y, m, d] = value.split("-").map(Number) as [number, number, number];
|
|
88
|
+
// 연도를 모르면 윤년(2000)으로 날짜만 본다
|
|
89
|
+
const day = new Date(Date.UTC(y === 0 ? 2000 : y, m - 1, d));
|
|
90
|
+
return day.getUTCMonth() === m - 1 && day.getUTCDate() === d;
|
|
91
|
+
}, "없는 날짜입니다")
|
|
92
|
+
.nullable()
|
|
93
|
+
.optional();
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 셀러가 위임으로 바꿀 수 있는 클레임 — 준 클레임만 바뀌고 null은 지운다. `sub`·`email`·`phone_number`·`updated_at`·`address`는 받지 않는다. 확인 여부(`*_verified`)는 셀러가 확인한 값을 받는다
|
|
97
|
+
* (이메일·휴대폰은 각자의 칸, 주소는 배송지가 원천이다)
|
|
98
|
+
*/
|
|
99
|
+
export const customerUserinfoInputSchema = z
|
|
100
|
+
.strictObject({
|
|
101
|
+
/** 셀러가 확인한 값 그대로 받는다 — 셀러 서비스가 인증한 회원이다 */
|
|
102
|
+
email_verified: z.boolean().optional(),
|
|
103
|
+
phone_number_verified: z.boolean().optional(),
|
|
104
|
+
given_name: text(50),
|
|
105
|
+
family_name: text(50),
|
|
106
|
+
middle_name: text(50),
|
|
107
|
+
nickname: text(50),
|
|
108
|
+
preferred_username: text(50),
|
|
109
|
+
profile: httpsUrl,
|
|
110
|
+
picture: httpsUrl,
|
|
111
|
+
website: httpsUrl,
|
|
112
|
+
gender: text(30),
|
|
113
|
+
birthdate,
|
|
114
|
+
zoneinfo,
|
|
115
|
+
locale,
|
|
116
|
+
})
|
|
117
|
+
.describe("바꿀 OIDC 클레임 — 준 것만 바뀌고 null은 지운다");
|
|
118
|
+
export type CustomerUserinfoInput = z.infer<typeof customerUserinfoInputSchema>;
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
//#region src/webhook-headers.d.ts
|
|
2
|
-
/** 웹훅 발송 요청 헤더 상수 (공개 계약). zod 의존 없이 서버 엔트리포인트에서도 가볍게 쓴다 */
|
|
3
|
-
/** 서명 헤더. 형식: `t=<unix초>,v1=<hex>[,v1=<hex>]` */
|
|
4
|
-
declare const WEBHOOK_SIGNATURE_HEADER = "Sayren-Signature";
|
|
5
|
-
/** 이벤트 ID 헤더. 본문 `id`와 같다. 중복 제거 키 */
|
|
6
|
-
declare const WEBHOOK_EVENT_ID_HEADER = "Sayren-Event-Id";
|
|
7
|
-
/** 이벤트 타입 헤더. 본문 `type`과 같다 */
|
|
8
|
-
declare const WEBHOOK_EVENT_TYPE_HEADER = "Sayren-Event-Type";
|
|
9
|
-
/** 전송 기록 ID 헤더. 전송 기록의 `deliveryId`와 같다 */
|
|
10
|
-
declare const WEBHOOK_DELIVERY_ID_HEADER = "Sayren-Delivery-Id";
|
|
11
|
-
/** 발송 요청의 `User-Agent` 값 */
|
|
12
|
-
declare const WEBHOOK_USER_AGENT = "Sayren-Webhooks/1.0";
|
|
13
|
-
/** 수신 측 권장 타임스탬프 허용 오차(초) */
|
|
14
|
-
declare const WEBHOOK_DEFAULT_TOLERANCE_SEC = 300;
|
|
15
|
-
declare const WEBHOOK_HEADERS: {
|
|
16
|
-
readonly signature: "Sayren-Signature";
|
|
17
|
-
readonly eventId: "Sayren-Event-Id";
|
|
18
|
-
readonly eventType: "Sayren-Event-Type";
|
|
19
|
-
readonly deliveryId: "Sayren-Delivery-Id";
|
|
20
|
-
};
|
|
21
|
-
//#endregion
|
|
22
|
-
export { WEBHOOK_HEADERS as a, WEBHOOK_EVENT_TYPE_HEADER as i, WEBHOOK_DELIVERY_ID_HEADER as n, WEBHOOK_SIGNATURE_HEADER as o, WEBHOOK_EVENT_ID_HEADER as r, WEBHOOK_USER_AGENT as s, WEBHOOK_DEFAULT_TOLERANCE_SEC as t };
|