stack-analysis
CHAPTER 78 / 90
읽기 약 2분
FUNCTION
쿠폰/할인 로직
핵심 개념
쿠폰 종류·중복 사용·검증·자동 적용 — 마케팅 표준.
본문
쿠폰 종류
1. 정액 (5,000원 할인)
2. 정률 (10% 할인)
3. 무료 배송
4. 1+1 / N개 구매 시 1개 무료
5. 카테고리별·상품별
6. 첫 구매 한정
7. 신규 가입 한정데이터 모델
CREATE TABLE coupons (
id UUID PRIMARY KEY,
code VARCHAR UNIQUE NOT NULL,
name VARCHAR NOT NULL,
description TEXT,
type VARCHAR NOT NULL, -- 'fixed', 'percentage', 'free_shipping', 'bogo'
value DECIMAL(10,2) NOT NULL, -- 5000 또는 10 또는 100
min_order_amount DECIMAL(10,0),
max_discount DECIMAL(10,0), -- 정률의 상한
usage_limit INT, -- 전체 사용 한도
usage_per_user INT DEFAULT 1,
applicable_to VARCHAR DEFAULT 'all', -- all, categories, products
applicable_ids UUID[], -- 대상 카테고리·상품
conditions JSONB, -- { firstOrder: true, minQuantity: 2, ... }
starts_at TIMESTAMP,
expires_at TIMESTAMP,
status VARCHAR DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE coupon_redemptions (
id UUID PRIMARY KEY,
coupon_id UUID NOT NULL,
user_id UUID NOT NULL,
order_id UUID,
amount_discounted DECIMAL(10,0),
used_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX ON coupon_redemptions(coupon_id);
CREATE INDEX ON coupon_redemptions(user_id, coupon_id);쿠폰 검증 + 적용
async function applyCoupon(
userId: string,
cartItems: CartItem[],
subtotal: number,
couponCode: string,
) {
const coupon = await db.coupon.findUnique({
where: { code: couponCode.toUpperCase() },
});
if (!coupon || coupon.status !== 'active') {
throw new NotFoundError('Coupon not found');
}
// 1. 기간 검증
const now = new Date();
if (coupon.startsAt && now < coupon.startsAt) {
throw new BadRequestError('Coupon not yet active');
}
if (coupon.expiresAt && now > coupon.expiresAt) {
throw new BadRequestError('Coupon expired');
}
// 2. 사용량 검증
if (coupon.usageLimit) {
const totalUsed = await db.couponRedemption.count({ where: { couponId: coupon.id } });
if (totalUsed >= coupon.usageLimit) {
throw new ConflictError('Coupon fully redeemed');
}
}
const userUsed = await db.couponRedemption.count({
where: { couponId: coupon.id, userId },
});
if (userUsed >= coupon.usagePerUser) {
throw new ConflictError('Already used');
}
// 3. 최소 주문금액
if (coupon.minOrderAmount && subtotal < coupon.minOrderAmount) {
throw new BadRequestError(`Minimum order: ₩${coupon.minOrderAmount.toLocaleString()}`);
}
// 4. 조건 검증 (firstOrder 등)
if (coupon.conditions?.firstOrder) {
const prevOrders = await db.order.count({
where: { userId, status: { in: ['paid', 'completed'] } },
});
if (prevOrders > 0) throw new ConflictError('First order only');
}
// 5. 적용 가능 상품 필터
const applicableItems = filterApplicable(cartItems, coupon);
const applicableSubtotal = applicableItems.reduce((s, i) => s + i.unitPrice * i.quantity, 0);
// 6. 할인액 계산
let discount = 0;
switch (coupon.type) {
case 'fixed':
discount = Math.min(coupon.value, applicableSubtotal);
break;
case 'percentage':
discount = applicableSubtotal * (coupon.value / 100);
if (coupon.maxDiscount) discount = Math.min(discount, coupon.maxDiscount);
break;
case 'free_shipping':
discount = 0; // 별도 처리
break;
}
return {
coupon,
discount: Math.round(discount),
applicableItems: applicableItems.map(i => i.id),
};
}
function filterApplicable(items: CartItem[], coupon: Coupon): CartItem[] {
if (coupon.applicableTo === 'all') return items;
if (coupon.applicableTo === 'products') {
return items.filter(i => coupon.applicableIds.includes(i.productId));
}
if (coupon.applicableTo === 'categories') {
return items.filter(i => coupon.applicableIds.includes(i.categoryId));
}
return items;
}중복 사용 (쿠폰 + 포인트)
const COUPON_STACKING_RULES = {
// 동시 사용 가능
free_shipping: ['fixed', 'percentage'],
// 단독 사용
fixed: [],
percentage: [],
};
async function applyMultipleCoupons(userId: string, cart: any, codes: string[]) {
if (codes.length > 2) throw new BadRequestError('Max 2 coupons');
const coupons = await Promise.all(codes.map(c => applyCoupon(userId, cart.items, cart.subtotal, c)));
// stacking 검증
if (coupons.length === 2) {
const [a, b] = coupons;
const aAllows = COUPON_STACKING_RULES[a.coupon.type] ?? [];
if (!aAllows.includes(b.coupon.type)) {
throw new ConflictError('Cannot combine these coupons');
}
}
return coupons;
}자동 적용 (가장 좋은 쿠폰)
async function findBestCoupon(userId: string, cart: any) {
const eligibleCoupons = await db.coupon.findMany({
where: {
status: 'active',
startsAt: { lte: new Date() },
OR: [
{ expiresAt: null },
{ expiresAt: { gt: new Date() } },
],
},
});
let best = null;
let bestDiscount = 0;
for (const coupon of eligibleCoupons) {
try {
const { discount } = await applyCoupon(userId, cart.items, cart.subtotal, coupon.code);
if (discount > bestDiscount) {
best = coupon;
bestDiscount = discount;
}
} catch {
// 적용 불가 → 무시
}
}
return best;
}결제 시 사용 기록
async function placeOrder(orderId: string, couponId: string | null) {
if (!couponId) return;
return db.$transaction(async (tx) => {
const order = await tx.order.findUnique({ where: { id: orderId } });
await tx.couponRedemption.create({
data: {
couponId,
userId: order!.userId,
orderId,
amountDiscounted: order!.discount,
},
});
});
}
// 환불 시 — 쿠폰 복구
async function refundOrder(orderId: string) {
const order = await db.order.findUnique({ where: { id: orderId } });
await db.couponRedemption.deleteMany({ where: { orderId } });
// → 사용자가 다시 사용 가능
}다음 챕터
CH.79 "관리자 패널: 주문/상품/사용자 관리".
AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude
무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6
내 프로젝트의 쿠폰 할인 부분을 분석해서 실전 분석 + 개선 우선순위를 알려줘.
ChatGPT
무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro
쿠폰 할인 관련 실제 서비스 5개를 비교 분석해서 패턴 추출를 알려줘.
Gemini
무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro
내 코드베이스에서 쿠폰 할인 최적화 가능 위치를 보고해줘.
Grok
무료: Grok 4.1 / SuperGrok $30/mo
2026년 한국 풀스택 시장의 쿠폰 할인 트렌드를 솔직히 알려줘.
⭐ 이것만 기억하세요
쿠폰/할인 로직은 이 3가지만 확실히 잡으세요
1.쿠폰 검증은 6단계 — 기간·전체사용량·사용자사용량·최소금액·조건·적용대상
2.중복 사용은 명시적 룰 (COUPON_STACKING_RULES)
3.환불 시 쿠폰 복구 — coupon_redemptions 삭제
공유하기
진행도 78 / 90