Files
zeekayandz cc9b2bc77e billing: a plan coupon discounts the months it names, not the first alone
A plan coupon carries Months (1..36; 0 reads as 1, the first month).
The sale grants the subscription the coupon from the period it sells
(on a trial, the first paid period after it). An invoice, the first
included, takes the percent off the plan fee while fewer than Months
billing cycles were invoiced since then, the row bills monthly, and the
coupon names its plan and every seat type sold.

A cycle is an invoice: engine.Billed counts the distinct plan-fee
periods invoiced since the first paid one, voided ones aside. Nothing
counts down, so a renewal re-run or a row not saved after its invoice
cannot spend a month twice, and days a gift adds or a late payment
that moves the period spend none.

50OFFBETA at three months bills Pro 1000, 1000, 1000, then 2000. A
renewal reads "50OFFBETA — 50% off, month 2 of 3" and records the
coupon, percent, cents and month on its invoice as the first month
does; the ledger draws the discounted amount. A year is never
discounted, and a plan the coupon does not name pays its price.

The plans quote and the sale receipt carry couponMonths. A coupon with
a trial now waits it out instead of being refused.

The staff renewal coupon stays its own fact: it is for the next
renewal whichever period that bills, monthly or yearly, and one row
can hold both, so it stacks off what the plan coupon left. Its invoice
metadata moves to renewalCoupon* so the two never share a key.

ExtendCoupon grants a coupon's current months to every live
subscription already bought with it, from its first paid period. It
never takes a month away, writes by Swap, reports cycles billed and
missed, dry-runs unless asked to apply, and records
subscription.coupon_extended in each org it changes.

Claude-Session: https://claude.ai/code/session_01BLLMyBFSvAfCt7ZGqYuiEz
2026-10-22 10:48:56 -07:00

143 lines
5.7 KiB
Go

// Copyright (c) 2014-present Hanzo AI, Inc.
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
package engine
import (
"cmp"
"slices"
"strconv"
"time"
"github.com/hanzoai/commerce/models/plan"
"github.com/hanzoai/commerce/models/subscription"
"github.com/hanzoai/commerce/types"
)
// DiscountCents is the ONE place a percent-off becomes money.
//
// It exists because the charge and the invoice are computed in two different
// packages — the card path in api/billing prices the first period, the engine
// prices every period after it — and the invariant they must satisfy is that
// the amount charged EQUALS the invoice's AmountDue. Two copies of "subtract a
// percent" is how a customer gets billed one number and shown another; one
// function is how they cannot.
//
// Rounding is half-up on the discount, so a fractional cent lands in the
// customer's favour: they are never charged more than the advertised percent
// implies. A percent outside 1..100 discounts nothing (0 and negatives are "no
// promo"; >100 would invert the sale), and the result never exceeds the
// subtotal, so a discount cannot turn a charge into a refund.
func DiscountCents(subtotalCents int64, percent int) int64 {
if subtotalCents <= 0 || percent <= 0 {
return 0
}
if percent >= 100 {
return subtotalCents
}
d := (subtotalCents*int64(percent) + 50) / 100
if d > subtotalCents {
return subtotalCents
}
return d
}
// Discounted is fee less each percent in turn, every percent priced off what the
// one before it left: the subscription's promo, then a coupon. The card path and
// the invoice both call it with the same percents in the same order, so the
// amount charged equals the invoice's AmountDue however many discounts stack.
func Discounted(fee int64, percents ...int) int64 {
for _, p := range percents {
fee -= DiscountCents(fee, p)
}
return fee
}
// Coupon is a percent off the plan fee of one invoice, of one of two kinds.
//
// A plan coupon is a code the subscription was bought with (api/promo). It
// discounts the first Months billing cycles the subscription pays for, counted
// from its first paid period, and Month is which of them the invoice bills. The
// row carries it from the sale (Grant), and an invoice is handed it while fewer
// than Months cycles have been billed before it (couponOf): the first one at the
// sale, the rest by renewals.
//
// A Renewal coupon is the one staff put on a subscription for its next renewal
// (subscription.NextCouponCode). RenewSubscription hands it to the invoice that
// renewal builds and the row stops carrying it.
//
// They are two kinds because they are two promises. A plan coupon was sold for
// the first cycles of a purchase; a renewal coupon is for whichever period the
// next renewal bills, on a month or a year. One row can hold both at once — a
// win-back inside the cycles a sale promised — and they stack, the renewal
// coupon off what the plan coupon left.
type Coupon struct {
Code string
Percent int
// Months is how many monthly cycles a plan coupon discounts, 1 for a
// first-month coupon, and Month which of them the invoice bills.
Month, Months int
// Renewal marks the staff coupon for the next renewal.
Renewal bool
}
// Label is how the coupon reads on the invoice it discounts.
func (c Coupon) Label() string {
if c.Code == "" || c.Percent <= 0 {
return ""
}
off := c.Code + " — " + strconv.Itoa(c.Percent) + "% off"
switch {
case c.Renewal:
return off + " this renewal"
case c.Months > 1:
return off + ", month " + strconv.Itoa(max(c.Month, 1)) + " of " + strconv.Itoa(c.Months)
}
return off + " first month"
}
// Grant puts plan coupon c on sub, the subscription bought with it, for the plan
// slugs plans: c.Percent off the plan fee of each of its first c.Months billing
// cycles (at least one), counted from from — at a sale the period the row holds
// (sub.PeriodStart), which on a trial is the first paid period after it.
func Grant(sub *subscription.Subscription, c Coupon, plans []string, from time.Time) {
if c.Code == "" || c.Percent < 1 {
return
}
sub.CouponCode, sub.CouponPercent, sub.CouponMonths = c.Code, c.Percent, max(c.Months, 1)
sub.CouponPlans = slices.Clone(plans)
sub.CouponFrom = from
}
// couponOf is the plan coupon sub was bought with as it discounts the period
// starting at start, when billed cycles have been invoiced since its first paid
// period (Billed), or the zero Coupon when it does not. It does while fewer than
// its Months cycles were billed before this one, the row bills monthly, and the
// coupon names the row's plan and every seat type the period bills at the price
// it was sold at. A cycle is an invoice, so days a gift adds, or a late payment
// that moves the period, spend no month; a cycle billed on a plan the coupon
// does not name, or on a year, spends one, and is billed at its price.
func couponOf(sub *subscription.Subscription, start time.Time, billed int) Coupon {
if sub.CouponCode == "" || sub.CouponPercent < 1 || sub.CouponMonths < 1 {
return Coupon{}
}
if start.Unix() < sub.CouponFrom.Unix() || billed >= sub.CouponMonths || !BillsMonthly(&sub.Plan) {
return Coupon{}
}
if !slices.Contains(sub.CouponPlans, cmp.Or(sub.Plan.Slug, sub.PlanId)) {
return Coupon{}
}
for _, l := range sub.Lines {
if !l.Full && l.Quantity > 0 && !slices.Contains(sub.CouponPlans, l.Plan) {
return Coupon{}
}
}
return Coupon{Code: sub.CouponCode, Percent: sub.CouponPercent, Month: billed + 1, Months: sub.CouponMonths}
}
// BillsMonthly reports whether p bills one month at a time, as Advance reads it: the
// only period a plan coupon discounts.
func BillsMonthly(p *plan.Plan) bool {
return (p.Interval == "" || p.Interval == types.Monthly) && p.IntervalCount <= 1
}