결제는 "성공 페이지를 보여주면 끝나는 일"이 아니다. PonsLink는 Polar를 활성 공급자로 두고, PayPal·LemonSqueezy·Stripe 웹훅 경로를 함께 유지한다. 공급자가 늘수록 문제는 기능이 아니라 중복·지연·재시도다.
현재 구성 (실측)
– `PAYMENT_PROVIDER=polar` — 체크아웃·구독의 주 경로
– PayPal sandbox (`PAYPAL_ENV=sandbox`)
– LemonSqueezy / Stripe — 이벤트 원장 테이블과 웹훅 핸들러 유지
– 관련 테이블: `subscriptions`, `drop_passes`, `stripe_events`, `paypal_events`, `lemonsqueezy_events`, `idempotency_keys`, `webhook_deliveries`
구독 컬럼은 공급자별 식별자를 나란히 둔다 (`stripe_subscription_id`, `paypal_subscription_id`, `lemonsqueezy_subscription_id` …). 각각 UNIQUE. "어느 공급자로 샀든 한 구독 행"을 만들기 위한 타협이다.
웹훅 처리 순서 — 절대 바꾸지 않는 것
1. **시그니처 검증** — secret 없는 바디는 버린다.
2. **이벤트 원장 INSERT** — 공급자 이벤트 id를 PK로. 이미 있으면 no-op 200.
3. **멱등성 키 확인** — `idempotency_keys`로 API/재시도 단위 중복을 죽인다.
4. **상태 전이** — subscriptions / drop_passes / entitlements.
5. **실패 시 데드레터** — `webhook_deliveries` + admin 재시도.
이 순서를 뒤집으면 "두 번 업그레이드" 또는 "돈은 나갔는데 플랜은 free"가 나온다. 프론트의 return URL(`/lemonsqueezy/return`, `/paypal/return`, Polar success URL)은 UX일 뿐 권한 근거가 아니다.
드롭 패스 — 구독이 아닌 1회 공유권
`drop_passes`는 상담 파일·자료 전달용 1회권이다. `max_total_bytes`, `max_file_bytes`, `retention_seconds`, `remaining_uses`를 고정의 조건으로 들고, 사용 시 `cloud_shares.drop_pass_id`로 연결된다. 구독 없이도 유료 전달이 가능하게 하려는 제품 결정이다.
크론이 메우는 구멍
– `check-trials` — 체험 종료
– `evaluate-dunning` — 결제 실패 후속
– `monthly-usage-reset` / `usage-rollup` / `report-overage` — 사용량 청구
– `reconcile-payments` — 공급자 상태와 로컬 원장 대사
– `cleanup-idempotency-records` — 키 만료 정리
웹훅만 믿으면 공급자 장애 하루에 운영이 멈춘다. 크론은 부끄러운 폴백이 아니라 설계의 일부다.
어드민 면
잠긴 요청, 결제 대기, 이메일 실패, 리스크·남용 큐가 `/admin/operations/queues` 아래에 있다. 결제 장애는 고객 지원 티켓이 아니라 운영 큐 항목이어야 한다. 감사 로그(`admin_audit_logs`)에 actor/ip/ua/request_id를 남긴다.
교훈
1. 공급자 SDK보다 **이벤트 원장 스키마**를 먼저 설계한다.
2. 성공 UI와 권한 부여를 분리한다.
3. 멱등성은 미들웨어 한 줄이 아니라 테이블이다.
4. 다중 PG는 "지원 배지"가 아니라 운영 복잡도다. 활성 공급자를 env로 고정(`PAYMENT_PROVIDER`)해 경로를 하나로 수렴시킨다.
5. 대사(reconcile) 없는 웹훅은 언젠가 잔고와 싸운다.
결제는 신뢰의 인프라다. 기능 목록에 로고를 늘리는 일보다, 한 번의 웹훅이 두 번 적용되지 않게 만드는 일이 먼저다.
댓글