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
315 lines
11 KiB
Go
315 lines
11 KiB
Go
package subscription
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/models/mixin"
|
|
"github.com/hanzoai/commerce/models/plan"
|
|
"github.com/hanzoai/commerce/models/types/accounts"
|
|
"github.com/hanzoai/commerce/models/types/currency"
|
|
"github.com/hanzoai/commerce/models/types/refs"
|
|
"github.com/hanzoai/commerce/util/hashid"
|
|
"github.com/hanzoai/commerce/util/json"
|
|
"github.com/hanzoai/commerce/util/timeutil"
|
|
"github.com/hanzoai/commerce/util/val"
|
|
"github.com/hanzoai/orm"
|
|
|
|
. "github.com/hanzoai/commerce/types"
|
|
)
|
|
|
|
// Based On Stripe Subscription
|
|
// Stripe\Subscription JSON: {
|
|
// "id": "sub_7OTicGsP51uH9F",
|
|
// "object": "subscription",
|
|
// "application_fee_percent": null,
|
|
// "cancel_at_period_end": false,
|
|
// "canceled_at": null,
|
|
// "current_period_end": 1450725048,
|
|
// "current_period_start": 1448133048,
|
|
// "customer": "cus_7OSfdiUiYYf0tS",
|
|
// "discount": null,
|
|
// "ended_at": null,
|
|
// "metadata": {
|
|
// },
|
|
// "plan": {
|
|
// ...
|
|
// },
|
|
// "quantity": 1,
|
|
// "start": 1448133048,
|
|
// "status": "active",
|
|
// "tax_percent": null,
|
|
// "trial_end": null,
|
|
// "trial_start": null
|
|
// }
|
|
|
|
var IgnoreFieldMismatch = datastore.IgnoreFieldMismatch
|
|
|
|
type BillingType string
|
|
|
|
const (
|
|
Charge BillingType = "charge_automatically"
|
|
Invoice BillingType = "send_invoice"
|
|
// External is a plan collected OUTSIDE Hanzo: the customer paid an invoice the
|
|
// processor's own dashboard raised, or a wire. Nothing here invoices it or charges
|
|
// it, and it moves to its next period only when that next payment is recorded
|
|
// (billing/engine.IsDue, api/billing.RecordSubscription).
|
|
External BillingType = "external"
|
|
)
|
|
|
|
type Status string
|
|
|
|
const (
|
|
Trialing Status = "trialing"
|
|
Active Status = "active"
|
|
PastDue Status = "past_due"
|
|
Canceled Status = "canceled"
|
|
Unpaid Status = "unpaid"
|
|
)
|
|
|
|
// CountsTowardMRR reports whether a subscription in this status is recurring
|
|
// revenue RIGHT NOW. Only an active one is.
|
|
//
|
|
// A trialing subscription is not yet revenue: nobody has been charged, and the
|
|
// trial may end in a cancel. Counting it books money that does not exist, and
|
|
// it does so worst exactly when a promotion drives a wave of trial signups —
|
|
// the revenue board climbs on the day the discounting starts. past_due and
|
|
// unpaid are billed but uncollected, and canceled is over; none of them is
|
|
// run-rate revenue either.
|
|
//
|
|
// This is the ONE definition, and it exists because there were three. commerce's
|
|
// rollup counted active only, the hanzoai/cloud admin summed active+trialing off
|
|
// the subscriptions wire, and the warehouse SQL summed everything except
|
|
// trialing — so one account reported three different MRRs depending on which
|
|
// screen you opened. Every summation now asks this.
|
|
//
|
|
// It answers "is this revenue", NOT "is this customer entitled". A trialing
|
|
// customer IS entitled to the product; that is the paywall's question and stays
|
|
// a separate predicate on purpose.
|
|
//
|
|
// It tolerates wire noise (case and surrounding space) because hanzoai/cloud
|
|
// reads the status off JSON before asking.
|
|
func (s Status) CountsTowardMRR() bool {
|
|
return Status(strings.ToLower(strings.TrimSpace(string(s)))) == Active
|
|
}
|
|
|
|
func init() { orm.Register[Subscription]("subscription") }
|
|
|
|
type Subscription struct {
|
|
mixin.Model[Subscription]
|
|
|
|
Number int `json:"number,omitempty" datastore:"-"`
|
|
|
|
// Immutable buyer data from time of payment, may or may not be associated
|
|
// with a user.
|
|
Buyer Buyer `json:"buyer"`
|
|
|
|
Type BillingType `json:"billing_type"`
|
|
|
|
PlanId string `json:"planId"`
|
|
UserId string `json:"userId"`
|
|
StoreId string `json:"storeId,omitempty"`
|
|
|
|
FeePercent float64 `json:"application_fee_percent"`
|
|
EndCancel bool `json:"cancel_at_period_end"`
|
|
|
|
PeriodStart time.Time `json:"current_period_start"`
|
|
PeriodEnd time.Time `json:"current_period_end"`
|
|
|
|
Start time.Time `json:"start"`
|
|
Ended time.Time `json:"ended_at"`
|
|
Canceled bool `json:"canceled"`
|
|
CanceledAt time.Time `json:"canceled_at"`
|
|
|
|
TrialStart time.Time `json:"trial_start"`
|
|
TrialEnd time.Time `json:"trial_end"`
|
|
|
|
Plan plan.Plan `json:"plan"`
|
|
Quantity int `json:"quantity"`
|
|
Status Status `json:"status"`
|
|
|
|
// PendingQuantity is the seat count the next period opens at, set when a
|
|
// change of seats waits for the renewal whose invoice charges it; 0 when none.
|
|
PendingQuantity int `json:"pendingQuantity,omitempty"`
|
|
|
|
// Lines are a team's other seat types, beside Plan and Quantity: a team bought
|
|
// with standard and premium seats holds Plan "team" and one line of
|
|
// "team-premium". Every period's invoice bills Plan's price times Quantity and
|
|
// each line's price times its quantity; the team is all of their seats.
|
|
Lines []Line `json:"lines,omitempty"`
|
|
|
|
// Assigned is which member holds which seat type, as the org's admin set it:
|
|
// never more members on a seat type than the seats of it paid for.
|
|
Assigned []Seat `json:"assigned,omitempty"`
|
|
|
|
Metadata Map `json:"metadata" datastore:"-" orm:"default:{}"`
|
|
Metadata_ string `json:"-" datastore:",noindex"`
|
|
|
|
// Provider-agnostic support
|
|
ProviderId string `json:"providerId,omitempty"` // External subscription ID (Stripe, etc.)
|
|
ProviderType string `json:"providerType,omitempty"` // "stripe", "internal"
|
|
|
|
// Billing configuration
|
|
DefaultPaymentMethod string `json:"defaultPaymentMethod,omitempty"` // "stripe:pm_xxx" | "balance"
|
|
BillingAnchor int `json:"billingAnchor,omitempty"` // Day of month 1-28
|
|
CurrentInvoiceId string `json:"currentInvoiceId,omitempty"`
|
|
|
|
// The promo this subscription was BOUGHT under, captured at signup and carried
|
|
// for its life. A renewal charges the deal the customer actually agreed to, not
|
|
// whatever promo happens to be running months later — and equally, a promo that
|
|
// has since ENDED keeps discounting a subscriber who signed up during it, which
|
|
// is what "50% off" on a monthly plan means to the person who clicked it.
|
|
// Zero percent = no promo; every invoice for this subscription prices the same way.
|
|
DiscountPercent int `json:"discountPercent,omitempty"`
|
|
DiscountName string `json:"discountName,omitempty"`
|
|
|
|
// The plan coupon the subscription was bought with (api/promo): CouponPercent
|
|
// off the plan fee of each of its first CouponMonths billing cycles, counted
|
|
// from CouponFrom, the first paid period, while the row bills monthly on a
|
|
// plan CouponPlans names. A cycle is an invoice (billing/engine.Billed), so
|
|
// nothing here counts down and a renewal run twice spends no month. Empty
|
|
// when the row was bought without a coupon.
|
|
CouponCode string `json:"couponCode,omitempty"`
|
|
CouponPercent int `json:"couponPercent,omitempty"`
|
|
CouponMonths int `json:"couponMonths,omitempty"`
|
|
CouponPlans []string `json:"couponPlans,omitempty"`
|
|
CouponFrom time.Time `json:"couponFrom"`
|
|
|
|
// NextCouponCode and NextCouponPercent are a percent off the NEXT renewal
|
|
// invoice only, set by staff (api/billing.SetNextCoupon). The renewal that
|
|
// bills the next period takes NextCouponPercent off its plan fee, after the
|
|
// promo and the plan coupon above, and clears both on the row it persists;
|
|
// every period after it bills as before. Empty and 0 when none waits.
|
|
NextCouponCode string `json:"nextCouponCode,omitempty"`
|
|
NextCouponPercent int `json:"nextCouponPercent,omitempty"`
|
|
|
|
// Stripe livemode
|
|
Live bool `json:"live"`
|
|
|
|
// Test is set when the subscription was bought in test mode, where a sandbox
|
|
// processor settles test cards and no money moves. It confers its plan only to
|
|
// a test-mode reader, never to a live one.
|
|
Test bool `json:"test,omitempty"`
|
|
|
|
Account accounts.Account `json:"account,omitempty"`
|
|
Ref refs.EcommerceRef `json:"ref,omitempty"`
|
|
}
|
|
|
|
// Line is one more seat type on a team's subscription: a plan of the catalog group
|
|
// the subscription's own plan is in, priced per seat for the row's period.
|
|
type Line struct {
|
|
// Plan is the line's catalog plan slug, e.g. "team-premium".
|
|
Plan string `json:"plan"`
|
|
// Name is the plan's display name, as the subscription bought it.
|
|
Name string `json:"name"`
|
|
// Price is one seat of it for the row's period, in cents, as the subscription
|
|
// bought it: a later catalog price does not move it.
|
|
Price currency.Cents `json:"price"`
|
|
// Quantity is the seats of it the row holds.
|
|
Quantity int `json:"quantity"`
|
|
// PendingQuantity is the seats of it the next period opens at, set when a
|
|
// change waits for the renewal whose invoice charges it; 0 when none.
|
|
PendingQuantity int `json:"pendingQuantity,omitempty"`
|
|
// Full is a seat type the row added after its sale. It is billed at its full
|
|
// price: the promotion the row was sold under (DiscountPercent) was sold for
|
|
// the seat types bought then, not for this one.
|
|
Full bool `json:"full,omitempty"`
|
|
}
|
|
|
|
// Seat is one member's seat on a team: the member, and the seat type (a plan
|
|
// slug the subscription holds seats of) they hold.
|
|
type Seat struct {
|
|
Member string `json:"member"`
|
|
Plan string `json:"plan"`
|
|
}
|
|
|
|
// Seats is every seat the row holds: Quantity and each line's.
|
|
func (s Subscription) Seats() int {
|
|
n := s.Quantity
|
|
for _, l := range s.Lines {
|
|
n += l.Quantity
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (s *Subscription) Load(ps []datastore.Property) (err error) {
|
|
// Ensure we're initialized
|
|
|
|
// Load supported properties
|
|
if err = IgnoreFieldMismatch(datastore.LoadStruct(s, ps)); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Set order number
|
|
num, err := s.NumberFromId()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.Number = num
|
|
|
|
// Deserialize from datastore
|
|
if len(s.Metadata_) > 0 {
|
|
err = json.DecodeBytes([]byte(s.Metadata_), &s.Metadata)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (s *Subscription) Save() (ps []datastore.Property, err error) {
|
|
// Serialize unsupported properties
|
|
s.Metadata_ = string(json.EncodeBytes(&s.Metadata))
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Save properties
|
|
return datastore.SaveStruct(s)
|
|
}
|
|
|
|
func (s *Subscription) Validator() *val.Validator {
|
|
return val.New()
|
|
}
|
|
|
|
func (s Subscription) NumberFromId() (i int, err error) {
|
|
if s.Id_ == "" {
|
|
return -1, errors.New("Subscription.NumberFromID(): Blank ID passed.")
|
|
}
|
|
|
|
ret, err := hashid.Decode(s.Id_)
|
|
|
|
return ret[1], err
|
|
}
|
|
|
|
func (s Subscription) TrialPeriodsRemaining() int {
|
|
years, months := timeutil.YearMonthDiff(s.TrialStart, s.TrialEnd)
|
|
|
|
if s.Plan.Interval == Monthly {
|
|
return months
|
|
}
|
|
return years
|
|
}
|
|
|
|
func (s Subscription) PeriodsRemaining() int {
|
|
months, years := timeutil.YearMonthDiff(s.PeriodStart, s.PeriodEnd)
|
|
|
|
if s.Plan.Interval == Monthly {
|
|
return months
|
|
}
|
|
return years
|
|
}
|
|
|
|
// New creates a new Subscription wired to the given datastore.
|
|
func New(db *datastore.Datastore) *Subscription {
|
|
s := new(Subscription)
|
|
s.Init(db)
|
|
return s
|
|
}
|
|
|
|
// Query returns a datastore query for subscriptions.
|
|
func Query(db *datastore.Datastore) datastore.Query {
|
|
return db.Query("subscription")
|
|
}
|