중간 사이클 구독 업그레이드의 숨은 수학
출처: Dev.to

Here’ s a question that sounds trivial and isn’ t.
여기서부터는 사소해 보이는 질문이지만, 사실은 그렇지 않습니다.
A customer is on Basic, 3 seats.
고객은 Basic, 3석을 사용하고 있습니다.
On day 12 of a 30- day billing cycle they switch to Pro, 2 seats.
30일 청구 주기 중 12일에 Pro, 2석으로 전환합니다.
How much do you charge them today, and what do they renew at next month?
오늘 청구할 금액은 얼마이며, 다음 달에는 무엇을 갱신하나요?
If your first instinct was “new price times new quantity,” you’ ve already shipped the bug. That’ s the renewal amount, not what they owe now. The amount due today is a proration, and getting it wrong means either robbing the customer or robbing yourself — quietly, on every mid- cycle change, forever.
첫 번째 생각이 “새 가격 × 새 수량”이라면, 이미 버그를 배포한 것입니다. 이는 갱신 금액이며, 지금 그들이 지불해야 하는 금액은 아닙니다. 오늘 청구되는 금액은 할당(프로레이션)된 금액이며, 이를 잘못 계산하면 고객을 혹은 자신에게 조용히 평생 매 사이클마다 손해를 입힐 수 있습니다.
I wrote this logic for a real billing integration, covered it in comments, and I’ m glad I did, because six months later I’ couldn’ t reconstruct it from memory. Here’ s the part no payment SDK gives you.
실제 청구 통합 로직을 작성해 주석으로 정리했으며, 다행히도 기억에서 재구성하지 못해六个月后 난감했습니다. 결제 SDK가 제공하지 않는 부분을 여기 보여드립니다.
What proration actually means
The customer already paid for the full cycle up front. When they change plans mid- cycle, two things are true:
-
They have unused credit on the old plan for the days remaining.
구 플랜의 남은 일수에 대한 미사용 크레딧이 있습니다. -
They owe the cost of the new plan for those same remaining days.
같은 남은 날짜에 대해 새로운 플랜 비용을 지불해야 합니다.
What you charge today is the difference between those two. Not the new full price — just the delta for the slice of the cycle they haven’ t used yet.
오늘 청구하는 금액은 이 두 값의 차이입니다. 전체 새 가격이 아니라, 아직 사용하지 않은 사이클 조각에 대한 델타만 적용됩니다.
So for our example, with 18 of 30 days remaining (60% of the cycle left):
-
Unused Basic credit ≈ 60% of (Basic price × 3 seats)
구 Basic 크레딧 ≈ 전체 Basic 가격 × 3석의 60% -
New Pro cost ≈ 60% of (Pro price × 2 seats)
신규 Pro 비용 ≈ 전체 Pro 가격 × 2석의 60% -
Charge today = new cost − unused credit
오늘 청구 금액 = 새 비용 − 미사용 크레딧
The next renewal then bills the full Pro × 2 at the normal cycle boundary. Two completely different numbers, and conflating them is the classic mistake.
다음 갱신 시에는 전체 Pro × 2 금액을 정상 사이클 경계에서 청구합니다. 두 완전히 다른 숫자이며, 이를 혼동하는 것이 전형적인 실수입니다.
The four cases, one of which “should never happen”
Once seats enter the picture, a single change can be any of four shapes, and they don’ t behave the same:
Upgrade + more seats — charge the prorated plan delta on existing seats and the prorated cost of the new seats.
업그레이드 + 추가 좌석 — 기존 좌석에 대한 할당 플랜 델타와 새로운 좌석의 할당 비용을 모두 청구합니다.
Upgrade + fewer seats — charge the plan delta only on the seats that survive; the removed seats generate credit, not charge.
업그레이드 + 좌석 감소 — 남은 좌석에 대한 플랜 델타만 청구하고, 제거된 좌석은 청구가 아닌 크레딧을 생성합니다.
Downgrade — typically costs 0 today. You don’ t refund mid- cycle cash; the lower price takes effect next renewal.
다운그레이드 — 일반적으로 오늘 비용이 0입니다. 중간 사이클 현금 환불은 없으며, 낮은 가격은 다음 갱신 시 적용됩니다.
First purchase — no proration at all, just a clean charge.
첫 구매 — 전혀 할당되지 않으며, 단순히 깨끗한 청구만 합니다.
In my code one branch is commented “this should never happen given the UI… but it’ s supported if needed.” That comment is doing real work. Defensive billing code that handles the impossible case is cheaper than a support ticket from the one customer who found a way to trigger it.
제 코드에서 한 가지 분기는 주석 처리되어 “UI 기준으로는 발생하지 않아야 하지만 필요 시 지원됩니다.” 이 주석은 실제 작업 중입니다. 불가능한 경우를 처리하는 방어적 청구 코드는 한 고객이 이를 트리거한 지원 티켓보다 저렴합니다.
The trap that actually bit me: dates
The subtle bug wasn’ t the money math. It was making the new cycle start exactly where the old one ended.
실제 버그는 금액 계산이 아니라, 새 사이클을 구 former과 정확히 일치하도록 시작시키는 것이었습니다.
You take the provider’s NextBillingTime, and if you carry the time‑of‑day along with it, the new cycle and the old one drift apart by hours — enough to double‑bill a fraction of a day or leave a gap. The fix was to collapse to a date (drop the time component entirely) so the boundary lines up cleanly. In .NET that’ s exactly what DateOnly is for. A whole class of off‑by‑a‑few‑hours billing weirdness disappears the moment you stop carrying the clock around.
프로바이더의 NextBillingTime을 사용하고, 시간‑대까지 들고가면 새 사이클과 구 former이 몇 시간만큼 어긋나게 되어, 하루의 일부분을 두 번 청구하거나 간격을 남길 수 있습니다. 해결책은 날짜로 축소(시간 구성 요소를 완전히 제거)하여 경계를 정확히 맞게 하는 것이었습니다. .NET에서는 정확히 DateOnly를 사용하는 것이 해결책입니다. 클록을 더 이상 들고 다니지 않는 순간, 몇 시간 정도의 청구 오류가 사라집니다.
The design call: compute, log, never surprise the user
The last decision was philosophical. Proration is the kind of calculation that, if it’ s ever wrong, is wrong in a way the customer notices on their card statement. So I didn’ t expose intermediate failures to the user at all. Every branch produces a computed result object — the amount, the case it took, the dates it used — and logs the inputs aggressively. If a charge ever looks off, I can replay exactly which path ran and why, instead of guessing from a stack trace.
마지막 결정은 철학적인 것이었습니다. 프로레이션은 고객이 카드 명세서에서 오류를 느낄 수 있는 방식으로 잘못될 수 있는 계산입니다. 전혀 사용자에게 중간 실패를 노출하지 않았습니다. 모든 분기는 계산된 결과 객체(금액, 적용된 경우, 사용된 날짜)를 생성하고 입력을 공격적으로 기록합니다. 청구 금액이 이상하게 보이면 스택 트레이스에 의존하지 않고 정확히 어떤 경로가 실행됐는지 재생할 수 있습니다.
That’ s the real lesson, more than the arithmetic: for money math, observability is part of the feature. The calculation that you can’ t reconstruct after the fact is the one that costs you a chargeback.
이는 연산보다도 실제 교훈을 보여줍니다: 금융 계산에서는 관측 가능성(Observability)이 기능의 일부입니다. 후에 재구성할 수 없는 계산은 환불 청구(chargeback)를 초래합니다.
Takeaway
Mid- cycle subscription changes aren’ t “new price × quantity.” They’ re a difference of two prorated amounts, branching into four cases, anchored to a date boundary you have to normalize by hand. Write it once, comment the impossible branch, log the inputs, and let DateOnly save you from the clock. Then never touch it again if you can help it.