1. 현행 결제 구조
결제사 분기
| PG | 대상 | 통화 | 정기결제 방식 | 실거래 |
|---|---|---|---|---|
| TossPayments | 국내 | KRW | 빌링키 저장 후 자체 워커가 매월 직접 청구 | success 16건 (₩4.2M) — 전부 Toss |
| Paddle | 해외 | USD 등 | Paddle이 자동 청구, webhook으로 동기화 | 0건 (product/price ID만 등록됨) |
분기 로직: payment-router.service.ts — KRW → "toss", 그 외 전부 "paddle".
금액 결정 지점 — 단 2곳
둘 다 billing_plans.amount(DB SSOT)를 읽어 Toss API에 그대로 전달한다.
| 시점 | 위치 | 로직 |
|---|---|---|
| 첫 결제 (구독 시작) | subscription-checkout.service.ts |
빌링키 발급 → 플랜 금액으로 즉시 승인 |
| 갱신 (매월) | recurring-billing-payment.service.ts:90 |
amount = plan.amount × quantity → POST /v1/billing/{billingKey} |
beta DB 현황
| 항목 | 값 |
|---|---|
| 플랜 가격 (SSOT) | Pro ₩198,000 / Team ₩495,000 (billing_plans.amount) · USD $129 / $329 (plan_prices + Paddle pri_*) |
| 구독 분포 | active 41 · trialing 64 · canceled 30 · expired 887 |
| 할인/쿠폰 테이블 | 없음 — 코드에도 타입 스텁만 존재, 로직 미구현 |
| 기존 프로모션 패턴 | trial_promo_submissions (SNS 후기 → 체험 7일 연장) |
| 흐름 | 가입 → 14일 trial(trialing) → 결제 시 정가 청구 · 실패 시 D+1/D+3/D+7 재시도(payment_retries) |
2. 공식문서 확인 결과
Toss 빌링 API
POST /v1/billing/{billingKey}의amount는 매 호출마다 가맹점 서버가 임의 지정 — 공식 가이드 명시: "금액이 바뀌었다면 자동결제 승인 API 호출 시 amount를 변경된 금액으로 설정하면 됩니다". 즉 첫달만 반값 청구 가능.- 스케줄·금액·횟수 전부 가맹점 책임 (토스는 자체 스케줄링 미제공).
- 쿠폰/할인/프로모션 네이티브 기능 없음. 카드사 혜택 조회 API는 카드사 주관 이벤트 정보용으로 가맹점 프로모션 도구가 아님.
- 출처: 자동결제(빌링) API 연동 가이드 · 코어 API 레퍼런스
Paddle Billing Discounts
POST /discounts—{ type: "percentage", amount: "50", recur: false }.recur: false(기본값)는 첫 transaction에만 적용, 이후 갱신은 별도 작업 없이 자동 정가 복원.recur: true + maximum_recurring_intervals: 1도 동등.- 트라이얼이 있는 구독은 트라이얼 종료 후 첫 유료 주기부터 적용 — 14일 trial 구조와 정확히 맞음.
- 적용: 체크아웃에서
Paddle.Checkout.open({ discountId })또는 고객 코드 입력.restrict_to로 특정 price 한정,usage_limit/expires_at으로 이벤트 기간 제어. - 인보이스에 할인 라인이 분리 기록되어 ARR 정합을 Paddle이 보장.
- 출처: Create a discount · Discounts guide
3. 적용 방안 — 결제사별로 다르게
Toss (KRW, 실고객 전부) — 자체 구현 필수
토스에는 할인 개념이 없으므로 "이 구독의 첫 정규 결제인가"를 우리 DB가 판정하고, 맞으면 반값을 청구한다.
- 할인 상태 저장 —
subscriptions.metadata에firstMonthDiscount: { percent: 50, appliedAt: null, campaignId }기록 (기존scheduledPlanChange패턴과 동일). 캠페인 규모가 커지면 별도discount_grants테이블로 승격. - 청구 시 차감 — 금액 결정 2곳에 공통 헬퍼
resolveChargeAmount(subscription, plan)추가: 미사용 할인 플래그가 있으면Math.round(amount × 0.5), 결제 success 확정 후appliedAt스탬프 → 다음 갱신부터 자동 정가. - 이력 분리 기록 —
payment_history.metadata에{ discount: { percent: 50, originalAmount } }저장. 토스 입장에선 그냥 "낮은 금액 결제"라 할인 추적은 전적으로 우리 DB 몫 (매출 리포팅·CS 환불 계산에 필요).
Paddle (해외 USD) — 네이티브 Discount
- Paddle Discounts API로 percentage 50 / one-time discount 생성 — 코드 수정 거의 불필요, DB도 건드릴 필요 없음.
restrict_to: Pro/Team price ID (pri_01knag8cdd…,pri_01knag95z…) 한정.
4. 함정 점검 (Preflight)
결제 실패 시 플래그 소진 금지
success 콜백에서만 appliedAt을 기록할 것. 실패 → D+1/D+3/D+7 재시도(payment_retries)에서도 반값이 유지돼야 한다. 청구 시도 시점에 플래그를 소진하면 실패 후 재시도에서 정가가 청구되는 버그.
할인 달 중 업그레이드 — proration 충돌
proration 로직(calculateProrationAmount)은 정가 기준이다. 반값 달에 Pro→Team 업그레이드하면 차액 계산이 꼬인다. 할인 적용 여부를 proration에도 반영하거나, 이벤트 기간엔 "업그레이드 시 할인 소멸" 정책을 명시할 것.
파밍 방지 — 유저당 1회 제한
"워크스페이스당 1회"가 아니라 유저(billing_customer)당 1회로 제한. 취소→재구독, 신규 워크스페이스 생성으로 반복 수령 가능한 구조 (Team 플랜은 워크스페이스 3개 허용). 판정 시 payment_history에서 해당 customer의 과거 할인 수령 이력을 조회할 것.
내부 계정 제외
infinite tier(내부용 무제한)와 manual provider 구독은 이벤트 대상에서 제외.
환불 기준액
반값 결제 건의 refund는 originalAmount가 아닌 실결제액 기준으로 계산할 것.
billing_plans.amount / plan_prices의 정가 자체를 바꾸지 말 것. 영구 할인 + 기존 구독자 전체 가격 변동 사고로 직결된다.
5. 결론 — DB 값 vs Paddle API
| 대상 | SSOT / 도구 | 작업 규모 |
|---|---|---|
| Toss 첫달 반값 | 우리 DB (subscriptions.metadata 또는 신규 테이블) + 청구 코드 2곳 수정 |
M~L — 본 작업 |
| Paddle 첫달 반값 | Paddle Discounts API — discount 생성, DB 수정 불필요 | S — API 호출 1회 |
| 정가 | billing_plans.amount + plan_prices — 변경 금지 |
— |
실고객이 전부 Toss이므로 Toss merchant-side 구현이 본 작업(청구 서비스 2곳 + 판정 헬퍼 + 어드민 노출), Paddle discount는 부수 작업이다.