mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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
785 lines
30 KiB
Go
785 lines
30 KiB
Go
package engine
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"slices"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/hanzoai/commerce/datastore"
|
||
"github.com/hanzoai/commerce/models/billinginvoice"
|
||
"github.com/hanzoai/commerce/models/idempotencykey"
|
||
"github.com/hanzoai/commerce/models/plan"
|
||
"github.com/hanzoai/commerce/models/subscription"
|
||
"github.com/hanzoai/commerce/models/types/currency"
|
||
"github.com/hanzoai/commerce/types"
|
||
"github.com/hanzoai/money"
|
||
)
|
||
|
||
// StartSubscription initializes a new subscription: sets the initial state,
|
||
// computes period dates, and handles trial logic.
|
||
func StartSubscription(sub *subscription.Subscription, p *plan.Plan) {
|
||
now := time.Now()
|
||
sub.Plan = *p
|
||
sub.PlanId = p.Id()
|
||
sub.Start = now
|
||
|
||
if p.TrialPeriodDays > 0 {
|
||
sub.Status = subscription.Trialing
|
||
sub.TrialStart = now
|
||
sub.TrialEnd = now.AddDate(0, 0, p.TrialPeriodDays)
|
||
sub.PeriodStart = sub.TrialEnd
|
||
sub.PeriodEnd = Advance(sub.TrialEnd, p)
|
||
} else {
|
||
sub.Status = subscription.Active
|
||
sub.PeriodStart = now
|
||
sub.PeriodEnd = Advance(now, p)
|
||
}
|
||
}
|
||
|
||
// RenewSubscription bills the period a due subscription owes next — in advance:
|
||
// the row holds the period it has paid for, and when that period ends the next one
|
||
// is invoiced and collected, and the row moves onto it only once it is paid. So a
|
||
// row is never served a period it has not paid for, and one canceled at the end of
|
||
// its period ends with the last period it paid for.
|
||
//
|
||
// A trial is billed the same way. The trial is what a trialing row has been
|
||
// served (servedTo), so when it ends the period after it — the first paid one —
|
||
// is invoiced and collected, and the row becomes active once that is paid or past
|
||
// due when it is not. A trial canceled at its end ends with the trial, unbilled.
|
||
//
|
||
// It is idempotent per (subscription, period): a PastDue renewal re-runs the
|
||
// SAME period (the row only moves on a successful collection), so this must NEVER
|
||
// mint a second invoice for a period already invoiced. If an invoice for that
|
||
// period already exists it is never charged again here; retrying collection on an
|
||
// unpaid invoice is the dunning workflow's job (billing/workflows/dunning.go), not
|
||
// this generator's. What the existing invoice says still decides the row, because
|
||
// the row may not have been told: a paid one moves the row onto its period, and an
|
||
// unpaid one leaves it past due.
|
||
func RenewSubscription(ctx context.Context, db *datastore.Datastore, sub *subscription.Subscription, prepaid Prepaid, chargeProvider ProviderCharger) (*billinginvoice.BillingInvoice, *CollectionResult, error) {
|
||
return renewAt(ctx, db, sub, prepaid, chargeProvider, time.Now())
|
||
}
|
||
|
||
// renewAt is RenewSubscription as of now: what is due, owed and ended is judged
|
||
// at that instant.
|
||
func renewAt(ctx context.Context, db *datastore.Datastore, sub *subscription.Subscription, prepaid Prepaid, chargeProvider ProviderCharger, now time.Time) (*billinginvoice.BillingInvoice, *CollectionResult, error) {
|
||
// A row canceled at the end of its period ends when that period does, is never
|
||
// invoiced again, and owes nothing it will not be served.
|
||
if ended(sub, now) {
|
||
if err := VoidOpen(db, sub); err != nil {
|
||
return nil, nil, fmt.Errorf("failed to void the ended subscription's open invoices: %w", err)
|
||
}
|
||
return nil, &CollectionResult{Error: "subscription was canceled at the end of its period"}, nil
|
||
}
|
||
|
||
// Idempotency (period): if the owed period already has an invoice, NEVER charge
|
||
// it again here. Dunning retries collection via PayInvoice, not this generator.
|
||
next := owed(sub, now)
|
||
|
||
// When the period after the held one has already gone by, its invoice still
|
||
// decides first: paid — however late — it moves the row on; unpaid, the period
|
||
// was never served, so it is owed no longer and its invoice is void.
|
||
if after := (period{servedTo(sub), Advance(servedTo(sub), &sub.Plan)}); after.start.Unix() != next.start.Unix() {
|
||
prior, err := findInvoiceForPeriod(db, sub, after)
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("failed to look up the lapsed period's invoice: %w", err)
|
||
}
|
||
switch {
|
||
case prior != nil && prior.Status == billinginvoice.Paid:
|
||
settle(sub, prior)
|
||
return prior, resultFromInvoice(prior), nil
|
||
case prior != nil:
|
||
if err := VoidOpen(db, sub); err != nil {
|
||
return nil, nil, fmt.Errorf("failed to void the lapsed period's invoice: %w", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
existing, err := findInvoiceForPeriod(db, sub, next)
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("failed to look up existing invoice for period: %w", err)
|
||
}
|
||
if existing != nil {
|
||
settle(sub, existing)
|
||
return existing, resultFromInvoice(existing), nil
|
||
}
|
||
|
||
// Only CREATE (and charge) a new period invoice when the held period is actually
|
||
// over (its end has passed) or the subscription is already PastDue. This stops a
|
||
// MANUAL renew from pre-billing periods early — N manual renews must never bill N
|
||
// periods. The cycle already filters on IsDue; this is the authoritative gate for
|
||
// every caller.
|
||
if !IsDue(sub, now) {
|
||
return nil, &CollectionResult{Error: "subscription period is not due for renewal"}, nil
|
||
}
|
||
|
||
// Atomic per-(subscription, period) guard: of two concurrent renews — the
|
||
// cron sweep and a manual renew, on one replica or two — one swaps the guard in
|
||
// and builds and collects; the other finds it and re-reads the invoice
|
||
// (idempotencykey.Begin). The card charge (via chargeProvider) also carries a
|
||
// stable per-period Square idempotency key.
|
||
guardKey := "period:" + strconv.FormatInt(next.start.Unix(), 10)
|
||
rec, replay, gerr := idempotencykey.Begin(db, "billing-renew:"+sub.Id(), guardKey)
|
||
if gerr == nil && replay {
|
||
if again, e := findInvoiceForPeriod(db, sub, next); e == nil && again != nil {
|
||
settle(sub, again)
|
||
return again, resultFromInvoice(again), nil
|
||
}
|
||
// Concurrent in-flight; its invoice is not yet visible. Do NOT run a second
|
||
// collection alongside it.
|
||
return nil, &CollectionResult{Error: "renewal already in progress for this period"}, nil
|
||
}
|
||
|
||
// Re-check after winning the guard: a racer in the concurrent-first window may
|
||
// have persisted this period's invoice between our findInvoiceForPeriod above and
|
||
// here. If so, use it — never build a second row for the same period.
|
||
if again, e := findInvoiceForPeriod(db, sub, next); e == nil && again != nil {
|
||
if rec != nil {
|
||
_ = idempotencykey.Complete(rec, again.Id())
|
||
}
|
||
settle(sub, again)
|
||
return again, resultFromInvoice(again), nil
|
||
}
|
||
|
||
// A change of seats waiting for this period takes effect here: the period's
|
||
// invoice bills it, and the row holds it from now on. A team's other seat
|
||
// types take theirs the same way.
|
||
quantity, pending := sub.Quantity, sub.PendingQuantity
|
||
if pending > 0 {
|
||
sub.Quantity, sub.PendingQuantity = pending, 0
|
||
}
|
||
lines := slices.Clone(sub.Lines)
|
||
for i := range sub.Lines {
|
||
if l := &sub.Lines[i]; l.PendingQuantity > 0 {
|
||
l.Quantity, l.PendingQuantity = l.PendingQuantity, 0
|
||
}
|
||
}
|
||
|
||
// A coupon staff put on the row for its next renewal discounts this invoice
|
||
// and no other, so the row stops carrying it here; it gets it back only when
|
||
// no invoice was built. The plan coupon the row was bought with is not taken
|
||
// off: its window says which periods it discounts, this one or not.
|
||
renewal := Coupon{Code: sub.NextCouponCode, Percent: sub.NextCouponPercent, Renewal: true}
|
||
sub.NextCouponCode, sub.NextCouponPercent = "", 0
|
||
|
||
// Generate a fresh, sequentially-numbered invoice: the owed period's fee in
|
||
// advance, and the metered usage since the held period began, in arrears.
|
||
inv, err := buildPeriodInvoice(db, sub, next, period{sub.PeriodStart, next.start}, renewal)
|
||
if err != nil {
|
||
sub.Quantity, sub.PendingQuantity, sub.Lines = quantity, pending, lines
|
||
sub.NextCouponCode, sub.NextCouponPercent = renewal.Code, renewal.Percent
|
||
if rec != nil {
|
||
_ = rec.Delete() // release the guard so a later attempt can rebuild
|
||
}
|
||
return inv, nil, err
|
||
}
|
||
|
||
// Attempt collection: prepaid (credits, then the balance), then the vaulted
|
||
// card (chargeProvider).
|
||
result, err := CollectInvoice(ctx, db, inv, prepaid, chargeProvider)
|
||
if err != nil {
|
||
return inv, result, fmt.Errorf("collection error: %w", err)
|
||
}
|
||
|
||
// Update invoice after collection
|
||
if err := inv.Update(); err != nil {
|
||
return inv, result, fmt.Errorf("failed to update invoice: %w", err)
|
||
}
|
||
|
||
settle(sub, inv)
|
||
|
||
// The invoice now exists, so findInvoiceForPeriod short-circuits every future
|
||
// renew BEFORE this guard — seal it (best-effort; it has done its job).
|
||
if rec != nil {
|
||
_ = idempotencykey.Complete(rec, inv.Id())
|
||
}
|
||
|
||
return inv, result, nil
|
||
}
|
||
|
||
// period is one billing period, [start, end).
|
||
type period struct{ start, end time.Time }
|
||
|
||
// owed is the period a due row pays for next: the one after the period it holds.
|
||
// A row whose next period has already passed — an active one whose renewal was
|
||
// missed, a past-due one that went unpaid through it — owes the period running
|
||
// now, and the periods that ended in between are not billed after the fact: a
|
||
// customer is never charged for a period it was not served, nor at once for every
|
||
// period since.
|
||
func owed(sub *subscription.Subscription, now time.Time) period {
|
||
end := servedTo(sub)
|
||
p := period{end, Advance(end, &sub.Plan)}
|
||
if end.IsZero() {
|
||
return p
|
||
}
|
||
for !p.end.After(now) {
|
||
p = period{p.end, Advance(p.end, &sub.Plan)}
|
||
}
|
||
return p
|
||
}
|
||
|
||
// IsDue reports whether a subscription has been served everything it holds and
|
||
// is eligible to be (re)invoiced: Active or PastDue with a PeriodEnd in the past,
|
||
// or Trialing on a card with a TrialEnd in the past. The single definition of
|
||
// "due", shared by the billing-cycle filter and RenewSubscription's new-period
|
||
// check.
|
||
//
|
||
// An externally collected subscription is never due. Its customer pays the
|
||
// processor directly, so an invoice built here would bill a period nobody owes us
|
||
// for, and collecting it would burn their credits, then their balance, then a card.
|
||
// Its next period is recorded when that payment arrives, not renewed.
|
||
//
|
||
// A trial with no card on it is never due either: nothing was agreed to pay for
|
||
// the period after it, so it lapses at its end and is never invoiced.
|
||
func IsDue(sub *subscription.Subscription, now time.Time) bool {
|
||
if sub.Type == subscription.External {
|
||
return false
|
||
}
|
||
switch sub.Status {
|
||
case subscription.Active, subscription.PastDue:
|
||
case subscription.Trialing:
|
||
if sub.DefaultPaymentMethod == "" {
|
||
return false
|
||
}
|
||
default:
|
||
return false
|
||
}
|
||
end := servedTo(sub)
|
||
return !end.IsZero() && now.After(end)
|
||
}
|
||
|
||
// servedTo is the end of what a row has been served: its trial while it is
|
||
// trialing, else the period it holds. The period after it is the one it owes next.
|
||
func servedTo(sub *subscription.Subscription) time.Time {
|
||
if sub.Status == subscription.Trialing {
|
||
return sub.TrialEnd
|
||
}
|
||
return sub.PeriodEnd
|
||
}
|
||
|
||
// CreatePaidFirstInvoice builds the invoice for the period the subscription holds,
|
||
// marks it PAID by an already-settled charge (method + providerRef) and persists
|
||
// it. The caller has already taken the money for this period, so this only records
|
||
// the invoice as paid — it does NOT charge again. The row stays on the period it
|
||
// paid for; RenewSubscription bills the next one when this one ends. Idempotent per
|
||
// period: if an invoice for the held period already exists it is returned as-is (no
|
||
// duplicate, no state change), so a retried subscribe never double-invoices.
|
||
//
|
||
// The plan coupon the row was granted (Grant) discounts it as month 1 when the
|
||
// held period starts its window; the charge already taken was priced with it.
|
||
func CreatePaidFirstInvoice(db *datastore.Datastore, sub *subscription.Subscription, method, providerRef string) (*billinginvoice.BillingInvoice, error) {
|
||
held := period{sub.PeriodStart, sub.PeriodEnd}
|
||
if existing, err := findInvoiceForPeriod(db, sub, held); err != nil {
|
||
return nil, fmt.Errorf("failed to look up existing invoice for period: %w", err)
|
||
} else if existing != nil {
|
||
return existing, nil
|
||
}
|
||
|
||
inv, err := buildPeriodInvoice(db, sub, held, period{}, Coupon{})
|
||
if err != nil {
|
||
return inv, err
|
||
}
|
||
if err := inv.MarkPaid(method, providerRef); err != nil {
|
||
return inv, fmt.Errorf("failed to mark first invoice paid: %w", err)
|
||
}
|
||
if err := inv.Update(); err != nil {
|
||
return inv, fmt.Errorf("failed to persist paid first invoice: %w", err)
|
||
}
|
||
sub.CurrentInvoiceId = inv.Id()
|
||
return inv, nil
|
||
}
|
||
|
||
// buildPeriodInvoice constructs, numbers, finalizes and persists a new invoice
|
||
// for the plan fee of period fee and the metered usage of period use (none when
|
||
// use is empty), less the subscription's promo, then its plan coupon while fewer
|
||
// than its months have been billed (Billed), then renewal: the coupon staff put
|
||
// on the row for its next renewal, or the zero Coupon. The invoice number is a
|
||
// sequential per-org counter, mirroring the credit-note numbering in refunds.go.
|
||
func buildPeriodInvoice(db *datastore.Datastore, sub *subscription.Subscription, fee, use period, renewal Coupon) (*billinginvoice.BillingInvoice, error) {
|
||
inv := billinginvoice.New(db)
|
||
inv.UserId = sub.UserId
|
||
inv.SubscriptionId = sub.Id()
|
||
inv.PeriodStart = fee.start
|
||
inv.PeriodEnd = fee.end
|
||
inv.Currency = sub.Plan.Currency
|
||
|
||
// Add subscription line item: plan fee × billable seats (1 for flat plans).
|
||
if sub.Plan.Price > 0 {
|
||
n := seats(&sub.Plan, sub.Quantity)
|
||
inv.LineItems = append(inv.LineItems, billinginvoice.LineItem{
|
||
Id: "li_plan_" + sub.PlanId,
|
||
Type: billinginvoice.LineSubscription,
|
||
Description: sub.Plan.Name + " subscription",
|
||
PlanId: sub.PlanId,
|
||
PlanName: sub.Plan.Name,
|
||
Quantity: n,
|
||
UnitPrice: int64(sub.Plan.Price),
|
||
Amount: int64(sub.Plan.Price) * n,
|
||
Currency: sub.Plan.Currency,
|
||
PeriodStart: fee.start,
|
||
PeriodEnd: fee.end,
|
||
})
|
||
}
|
||
// A team's other seat types, each its own plan line: its price per seat times
|
||
// its seats. A seat type added after the sale (Line.Full) is billed in full,
|
||
// outside the promotion the row was sold under.
|
||
full := int64(0)
|
||
for _, l := range sub.Lines {
|
||
if l.Price <= 0 || l.Quantity < 1 {
|
||
continue
|
||
}
|
||
inv.LineItems = append(inv.LineItems, billinginvoice.LineItem{
|
||
Id: "li_plan_" + l.Plan,
|
||
Type: billinginvoice.LineSubscription,
|
||
Description: l.Name + " subscription",
|
||
PlanId: l.Plan,
|
||
PlanName: l.Name,
|
||
Quantity: int64(l.Quantity),
|
||
UnitPrice: int64(l.Price),
|
||
Amount: int64(l.Price) * int64(l.Quantity),
|
||
Currency: sub.Plan.Currency,
|
||
PeriodStart: fee.start,
|
||
PeriodEnd: fee.end,
|
||
})
|
||
if l.Full {
|
||
full += int64(l.Price) * int64(l.Quantity)
|
||
}
|
||
}
|
||
|
||
// Add usage line items (non-fatal: an aggregation error yields no usage).
|
||
if use.end.After(use.start) {
|
||
if usageItems, _, err := AggregateUsage(db, sub.UserId, use.start, use.end); err == nil {
|
||
inv.LineItems = append(inv.LineItems, usageItems...)
|
||
}
|
||
}
|
||
|
||
// Calculate totals
|
||
inv.RecalculateSubtotal()
|
||
|
||
// The promo the subscription was bought under, then the plan coupon it was
|
||
// bought with, then the renewal coupon, each priced off what the one before it
|
||
// left and off the PLAN fee only — metered usage above is real consumption and
|
||
// is never discounted by a promo or a coupon. The promo and the plan coupon
|
||
// were sold for the seat types bought then, so a seat type added since (full)
|
||
// pays its price beside them; the renewal coupon staff set takes its percent
|
||
// off the whole plan fee left. Discounted is the function the card path priced
|
||
// the charge with, so the two agree to the cent. Finalize() folds Discount
|
||
// into AmountDue, so this must land before it.
|
||
bought := Coupon{}
|
||
if sub.CouponCode != "" {
|
||
n, err := Billed(db, sub, fee.start)
|
||
if err != nil {
|
||
return inv, fmt.Errorf("the cycles its coupon has discounted could not be counted: %w", err)
|
||
}
|
||
bought = couponOf(sub, fee.start, n)
|
||
}
|
||
if inv.Subtotal > 0 && (sub.DiscountPercent > 0 || bought.Percent > 0 || renewal.Percent > 0) {
|
||
planFee := int64(0)
|
||
for _, li := range inv.LineItems {
|
||
if li.Type == billinginvoice.LineSubscription {
|
||
planFee += li.Amount
|
||
}
|
||
}
|
||
sold := Discounted(planFee-full, sub.DiscountPercent)
|
||
kept := Discounted(sold, bought.Percent)
|
||
due := Discounted(kept+full, renewal.Percent)
|
||
names := make([]string, 0, 3)
|
||
if sold < planFee-full {
|
||
names = append(names, sub.DiscountName)
|
||
}
|
||
note := func(kv ...interface{}) {
|
||
if inv.Metadata == nil {
|
||
inv.Metadata = map[string]interface{}{}
|
||
}
|
||
for i := 0; i+1 < len(kv); i += 2 {
|
||
inv.Metadata[kv[i].(string)] = kv[i+1]
|
||
}
|
||
}
|
||
if cents := sold - kept; cents > 0 {
|
||
names = append(names, bought.Label())
|
||
note("coupon", bought.Code, "couponPercent", bought.Percent, "couponCents", cents,
|
||
"couponMonth", bought.Month, "couponMonths", bought.Months)
|
||
}
|
||
if cents := kept + full - due; cents > 0 {
|
||
names = append(names, renewal.Label())
|
||
note("renewalCoupon", renewal.Code, "renewalCouponPercent", renewal.Percent, "renewalCouponCents", cents)
|
||
}
|
||
if d := planFee - due; d > 0 {
|
||
inv.Discount = d
|
||
inv.DiscountName = strings.Join(names, "; ")
|
||
}
|
||
}
|
||
|
||
// Assign a sequential per-org invoice number BEFORE persisting.
|
||
assignInvoiceNumber(db, inv)
|
||
|
||
// Finalize (draft -> open)
|
||
if err := inv.Finalize(); err != nil {
|
||
return inv, fmt.Errorf("failed to finalize invoice: %w", err)
|
||
}
|
||
|
||
// Persist invoice
|
||
if err := inv.Create(); err != nil {
|
||
return inv, fmt.Errorf("failed to create invoice: %w", err)
|
||
}
|
||
|
||
return inv, nil
|
||
}
|
||
|
||
// Billed is how many billing cycles of sub have been invoiced in
|
||
// [sub.CouponFrom, before): the distinct periods its invoices bill a plan fee
|
||
// for, voided ones aside, since a voided period was never served. It reads under
|
||
// no ancestor, for the reason findInvoiceForPeriod does. It is the count a plan
|
||
// coupon's months are spent against, so it moves only when an invoice is
|
||
// written: a renewal run twice finds the invoice it already wrote and counts
|
||
// nothing new.
|
||
func Billed(db *datastore.Datastore, sub *subscription.Subscription, before time.Time) (int, error) {
|
||
invs := make([]*billinginvoice.BillingInvoice, 0)
|
||
q := billinginvoice.Query(db).
|
||
Filter("SubscriptionId=", sub.Id()).
|
||
Filter("UserId=", sub.UserId)
|
||
if _, err := q.GetAll(&invs); err != nil {
|
||
return 0, err
|
||
}
|
||
from, until := sub.CouponFrom.Unix(), before.Unix()
|
||
seen := map[int64]bool{}
|
||
for _, inv := range invs {
|
||
at := inv.PeriodStart.Unix()
|
||
if inv.Status == billinginvoice.Void || at < from || at >= until || seen[at] || !BillsPlan(inv) {
|
||
continue
|
||
}
|
||
seen[at] = true
|
||
}
|
||
return len(seen), nil
|
||
}
|
||
|
||
// BillsPlan reports whether inv bills a plan fee: a period of a subscription
|
||
// rather than an ad hoc charge raised against it.
|
||
func BillsPlan(inv *billinginvoice.BillingInvoice) bool {
|
||
for _, li := range inv.LineItems {
|
||
if li.Type == billinginvoice.LineSubscription {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// findInvoiceForPeriod returns the existing invoice for this subscription and
|
||
// the exact billing period p, or nil if none exists. Periods are months/years apart,
|
||
// so PeriodStart/PeriodEnd are matched at second precision to be robust against
|
||
// sub-second serialization differences across the storage round-trip.
|
||
//
|
||
// It reads by the subscription alone, under no ancestor: an invoice saved again
|
||
// after it was loaded by id is stored without the synckey parent it was created
|
||
// under, so an ancestor filter loses exactly the invoice a payment settled.
|
||
func findInvoiceForPeriod(db *datastore.Datastore, sub *subscription.Subscription, p period) (*billinginvoice.BillingInvoice, error) {
|
||
existing := make([]*billinginvoice.BillingInvoice, 0)
|
||
q := billinginvoice.Query(db).
|
||
Filter("SubscriptionId=", sub.Id()).
|
||
Filter("UserId=", sub.UserId)
|
||
if _, err := q.GetAll(&existing); err != nil {
|
||
return nil, err
|
||
}
|
||
// By the period's start alone, the renewal guard's own key: a period is one
|
||
// invoice however its end is later computed.
|
||
for _, inv := range existing {
|
||
if inv.PeriodStart.Unix() == p.start.Unix() {
|
||
return inv, nil
|
||
}
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
// assignInvoiceNumber sets a sequential per-org invoice number: one past the
|
||
// highest the org has issued, read from that one invoice rather than by loading
|
||
// them all. It reads under no ancestor, for the reason findInvoiceForPeriod does.
|
||
// If the read fails it falls back to 1.
|
||
func assignInvoiceNumber(db *datastore.Datastore, inv *billinginvoice.BillingInvoice) {
|
||
top := make([]*billinginvoice.BillingInvoice, 0, 1)
|
||
if _, err := billinginvoice.Query(db).Order("-Number").Limit(1).GetAll(&top); err == nil && len(top) == 1 {
|
||
inv.SetNumber(top[0].Number + 1)
|
||
} else {
|
||
inv.SetNumber(1)
|
||
}
|
||
}
|
||
|
||
// Over reports whether a row canceled at the end of what it has been served
|
||
// (servedTo: its period, or its trial) has reached that end. Such a row confers
|
||
// nothing from then on, whether or not a sweep has yet marked it canceled
|
||
// ([ended] is that mark), so a reader never serves a plan past its last day
|
||
// because the renewal sweep has not run.
|
||
func Over(sub *subscription.Subscription, now time.Time) bool {
|
||
end := servedTo(sub)
|
||
return sub.EndCancel && !end.IsZero() && !now.Before(end)
|
||
}
|
||
|
||
// ended ends a row canceled at the end of what it has been served once that is
|
||
// over (Over): it is canceled as of that end and reports true. Any other row is
|
||
// untouched.
|
||
func ended(sub *subscription.Subscription, now time.Time) bool {
|
||
if !Over(sub, now) {
|
||
return false
|
||
}
|
||
end := servedTo(sub)
|
||
switch sub.Status {
|
||
case subscription.Active, subscription.PastDue, subscription.Trialing:
|
||
default:
|
||
return false
|
||
}
|
||
sub.Status = subscription.Canceled
|
||
sub.Canceled = true
|
||
sub.Ended = end
|
||
return true
|
||
}
|
||
|
||
// VoidOpen voids the unpaid invoices a subscription still carries. A row that has
|
||
// ended is served no further period, so it owes none; an invoice something was
|
||
// already paid toward is left for a person to settle.
|
||
//
|
||
// Each is voided under the guard a payment of it takes, and read again inside it,
|
||
// so a payment landing at the same moment either finds it void or leaves it paid —
|
||
// never paid and then voided over.
|
||
func VoidOpen(db *datastore.Datastore, sub *subscription.Subscription) error {
|
||
invs := make([]*billinginvoice.BillingInvoice, 0)
|
||
keys, err := billinginvoice.Query(db).Filter("SubscriptionId=", sub.Id()).GetAll(&invs)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for i, listed := range invs {
|
||
if listed.Status != billinginvoice.Open || listed.AmountPaid > 0 || i >= len(keys) {
|
||
continue
|
||
}
|
||
if err := voidUnpaid(db, keys[i]); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// voidUnpaid voids one invoice if, read under its payment guard, it is still open
|
||
// with nothing paid toward it.
|
||
func voidUnpaid(db *datastore.Datastore, key datastore.Key) error {
|
||
inv := billinginvoice.New(db)
|
||
if err := inv.Get(key); err != nil {
|
||
return err
|
||
}
|
||
rec, replay, err := idempotencykey.Begin(db, "billing-pay", "invoice:"+inv.Id())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if replay {
|
||
return nil // a payment of it is in flight or done
|
||
}
|
||
defer func() { _ = rec.Delete() }()
|
||
if err := inv.Get(key); err != nil {
|
||
return err
|
||
}
|
||
if inv.Status != billinginvoice.Open || inv.AmountPaid > 0 {
|
||
return nil
|
||
}
|
||
if err := inv.MarkVoid(); err != nil {
|
||
return err
|
||
}
|
||
return inv.Update()
|
||
}
|
||
|
||
// settle moves the row by what its owed period's invoice says: paid moves it onto
|
||
// that period and makes it active, anything else leaves it past due. Only a row
|
||
// the cycle renews is moved — active, past due, or trialing into its first paid
|
||
// period; a canceled one is not the engine's.
|
||
func settle(sub *subscription.Subscription, inv *billinginvoice.BillingInvoice) {
|
||
switch sub.Status {
|
||
case subscription.Active, subscription.PastDue, subscription.Trialing:
|
||
default:
|
||
return
|
||
}
|
||
// An invoice built with the row's renewal coupon has billed it. Found here, it
|
||
// is the invoice of a renewal whose row was not saved after it, and the coupon
|
||
// the row still carries is not handed to another period.
|
||
if code, _ := inv.Metadata["renewalCoupon"].(string); code != "" && code == sub.NextCouponCode {
|
||
sub.NextCouponCode, sub.NextCouponPercent = "", 0
|
||
}
|
||
if inv.Status != billinginvoice.Paid {
|
||
// The row holds everything before the period it owes, so the period it owes
|
||
// stays the one this invoice bills, however far a missed renewal moved it.
|
||
sub.Status = subscription.PastDue
|
||
sub.PeriodEnd = inv.PeriodStart
|
||
return
|
||
}
|
||
// A row served nothing until it paid — past due, or paying only after the
|
||
// period was over — is served the whole period it paid for from the moment it
|
||
// paid, not what was left of the invoice's.
|
||
sub.CurrentInvoiceId = inv.Id()
|
||
sub.PeriodStart, sub.PeriodEnd = inv.PeriodStart, inv.PeriodEnd
|
||
if !inv.PaidAt.IsZero() && (sub.Status == subscription.PastDue || !inv.PaidAt.Before(inv.PeriodEnd)) {
|
||
sub.PeriodStart, sub.PeriodEnd = inv.PaidAt, Advance(inv.PaidAt, &sub.Plan)
|
||
}
|
||
sub.Status = subscription.Active
|
||
}
|
||
|
||
// resultFromInvoice synthesizes a collection result from an invoice's persisted
|
||
// state — used when RenewSubscription returns an already-generated invoice so
|
||
// callers (e.g. the billing cycle) get a non-nil result reflecting whether the
|
||
// period is settled.
|
||
func resultFromInvoice(inv *billinginvoice.BillingInvoice) *CollectionResult {
|
||
return &CollectionResult{
|
||
Success: inv.Status == billinginvoice.Paid,
|
||
CreditUsed: inv.CreditApplied,
|
||
AmountCharged: inv.AmountPaid,
|
||
}
|
||
}
|
||
|
||
// TransitionTrialToActive moves a trialing subscription to active.
|
||
func TransitionTrialToActive(sub *subscription.Subscription) error {
|
||
if sub.Status != subscription.Trialing {
|
||
return fmt.Errorf("subscription is not trialing, current status: %s", sub.Status)
|
||
}
|
||
sub.Status = subscription.Active
|
||
return nil
|
||
}
|
||
|
||
// CancelSubscription cancels a subscription, either immediately or at period end.
|
||
func CancelSubscription(sub *subscription.Subscription, atPeriodEnd bool) error {
|
||
if sub.Status == subscription.Canceled {
|
||
return fmt.Errorf("subscription is already canceled")
|
||
}
|
||
|
||
now := time.Now()
|
||
|
||
if atPeriodEnd {
|
||
sub.EndCancel = true
|
||
sub.CanceledAt = now
|
||
} else {
|
||
sub.Status = subscription.Canceled
|
||
sub.Canceled = true
|
||
sub.CanceledAt = now
|
||
sub.Ended = now
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// ReactivateSubscription reverses a pending cancellation.
|
||
func ReactivateSubscription(sub *subscription.Subscription) error {
|
||
if sub.Status == subscription.Canceled && !sub.Ended.IsZero() {
|
||
return fmt.Errorf("cannot reactivate a fully ended subscription")
|
||
}
|
||
|
||
sub.EndCancel = false
|
||
sub.Canceled = false
|
||
sub.CanceledAt = time.Time{}
|
||
|
||
if sub.Status == subscription.Canceled {
|
||
sub.Status = subscription.Active
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// ChangePlan updates a subscription to a new plan. If prorate is true,
|
||
// a proration line item will be added to the current period's invoice.
|
||
//
|
||
// A team holding more than one seat type (sub.Lines) is refused: a move prices
|
||
// one plan's seats against another's, and such a team changes its seats instead,
|
||
// which its next renewal charges.
|
||
func ChangePlan(sub *subscription.Subscription, newPlan *plan.Plan, prorate bool) (*billinginvoice.LineItem, error) {
|
||
if len(sub.Lines) > 0 {
|
||
return nil, fmt.Errorf("a team with more than one seat type changes its seats, not its plan")
|
||
}
|
||
oldPlan := sub.Plan
|
||
sub.Plan = *newPlan
|
||
sub.PlanId = newPlan.Id()
|
||
|
||
if !prorate {
|
||
return nil, nil
|
||
}
|
||
|
||
// Calculate proration
|
||
now := time.Now()
|
||
totalDays := sub.PeriodEnd.Sub(sub.PeriodStart).Hours() / 24
|
||
remainingDays := sub.PeriodEnd.Sub(now).Hours() / 24
|
||
|
||
if totalDays <= 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
fraction := remainingDays / totalDays
|
||
|
||
// Credit for unused portion of old plan (× its billable seats)
|
||
oldCredit, err := proration(oldPlan.Price*currency.Cents(seats(&oldPlan, sub.Quantity)), fraction)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Charge for remaining portion of new plan (× its billable seats)
|
||
newCharge, err := proration(newPlan.Price*currency.Cents(seats(newPlan, sub.Quantity)), fraction)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
net := newCharge - oldCredit
|
||
|
||
item := &billinginvoice.LineItem{
|
||
Id: fmt.Sprintf("li_proration_%d", now.Unix()),
|
||
Type: billinginvoice.LineProration,
|
||
Description: fmt.Sprintf("Proration: %s -> %s", oldPlan.Name, newPlan.Name),
|
||
PlanId: newPlan.Id(),
|
||
PlanName: newPlan.Name,
|
||
Amount: net,
|
||
Currency: newPlan.Currency,
|
||
PeriodStart: now,
|
||
PeriodEnd: sub.PeriodEnd,
|
||
}
|
||
|
||
return item, nil
|
||
}
|
||
|
||
// seats returns the billable multiplier for a plan on a subscription: the
|
||
// subscription quantity (floored at 1) when the plan bills per seat, else 1.
|
||
// proration returns the part of amount that fraction covers, rounded half away from zero.
|
||
//
|
||
// It is one function because a plan change computes two of these and subtracts them, and
|
||
// they have to round the same way. The spelling it replaces truncated both — the credit for
|
||
// the unused old plan, which is a cent the customer does not get back, and the charge for
|
||
// the new one, which is a cent we do not bill. Neither direction was chosen by anyone; they
|
||
// were just what int64() does to a fraction. Half away from zero is the rule the discount
|
||
// and coupon paths already use, so a plan change cannot disagree with a coupon about what a
|
||
// fraction of a cent is worth, and a downgrade credit is the upgrade charge reversed.
|
||
//
|
||
// A fraction that is not a number is REPORTED rather than converted: a period whose start
|
||
// and end are the same instant makes remaining/total a NaN, and NaN to int64 is undefined
|
||
// in Go. A proration line for an undefined number of cents is worse than no line at all.
|
||
func proration(amount currency.Cents, fraction float64) (int64, error) {
|
||
rate, err := money.RateFromFloat(fraction)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return money.ScaleMinor(int64(amount), rate)
|
||
}
|
||
|
||
func seats(p *plan.Plan, quantity int) int64 {
|
||
if !p.PerSeat || quantity < 1 {
|
||
return 1
|
||
}
|
||
return int64(quantity)
|
||
}
|
||
|
||
// Advance computes the next period end date based on the plan interval.
|
||
func Advance(from time.Time, p *plan.Plan) time.Time {
|
||
count := p.IntervalCount
|
||
if count <= 0 {
|
||
count = 1
|
||
}
|
||
|
||
switch p.Interval {
|
||
case types.Monthly:
|
||
return from.AddDate(0, count, 0)
|
||
case types.Yearly:
|
||
return from.AddDate(count, 0, 0)
|
||
default:
|
||
// Default to monthly
|
||
return from.AddDate(0, count, 0)
|
||
}
|
||
}
|