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
179 lines
4.4 KiB
Go
179 lines
4.4 KiB
Go
package coupon
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/log"
|
|
"github.com/hanzoai/commerce/models/mixin"
|
|
"github.com/hanzoai/commerce/util/hashid"
|
|
"github.com/hanzoai/commerce/util/timeutil"
|
|
"github.com/hanzoai/orm"
|
|
)
|
|
|
|
func init() { orm.Register[Coupon]("coupon") }
|
|
|
|
type Type string
|
|
|
|
const (
|
|
Flat Type = "flat"
|
|
Percent = "percent"
|
|
FreeShipping = "free-shipping"
|
|
FreeItem = "free-item"
|
|
)
|
|
|
|
var Types = []Type{Flat, Percent, FreeShipping}
|
|
|
|
// PlanFilter marks a coupon that discounts a subscription PLAN purchase rather
|
|
// than a cart: the first Months monthly periods of a plan bought with its code.
|
|
const PlanFilter = "plan"
|
|
|
|
type Coupon struct {
|
|
mixin.Model[Coupon]
|
|
|
|
Name string `json:"name"`
|
|
|
|
// Possible values: flat, percent, free_shipping.
|
|
Type Type `json:"type"`
|
|
|
|
// Coupon code (must be unique).
|
|
Code_ string `json:"code" datastore:"Code"`
|
|
RawCode string `json:"-" datastore:"-"`
|
|
|
|
// Indicates whether or not the Code is dynamically checked (for something like user-generated coupons)
|
|
Dynamic bool `json:"dynamic"`
|
|
|
|
CampaignId string `json:"campaignId,omitempty"`
|
|
ReferrerId string `json:"referrerId,omitempty"`
|
|
|
|
// Range in which coupon is valid
|
|
StartDate time.Time `json:"startDate"`
|
|
EndDate time.Time `json:"endDate"`
|
|
|
|
// Possible values: order, product, plan (PlanFilter).
|
|
Filter string `json:"filter"`
|
|
|
|
// Indicates whether this coupon may be applied once or more than once at checkout.
|
|
Once bool `json:"once"`
|
|
|
|
// The number of times this coupon can be used before it is used up and useless. 0 = unlimited
|
|
Limit int `json:"limit"`
|
|
|
|
// Product id for product-specific coupons.
|
|
ProductId string `json:"productId,omitempty"`
|
|
|
|
// Plans are the plan slugs a plan coupon applies to, beside ProductId. Both
|
|
// empty means every paid plan.
|
|
Plans []string `json:"plans,omitempty"`
|
|
|
|
// Whether coupon is valid.
|
|
Enabled bool `json:"enabled" orm:"default:true"`
|
|
|
|
// Coupon amount. $5 should be 500 (prices in basic currency unit, like cents). 10% should be 10.
|
|
// TODO: This needs to be currency.Cents in Hanzo.
|
|
Amount int `json:"amount"`
|
|
|
|
// Months is how many monthly periods a plan coupon discounts, from the first
|
|
// paid one. 0, as every row stored before it was, reads as 1: the first month.
|
|
Months int `json:"months,omitempty"`
|
|
|
|
// Number of times coupon was redeemed.
|
|
Used int `json:"used"`
|
|
|
|
// Free product with coupon
|
|
FreeProductId string `json:"freeProductId"`
|
|
FreeVariantId string `json:"freeVariantId"`
|
|
FreeQuantity int `json:"freeQuantity"`
|
|
|
|
// List of buyer email addresses who have redeemed coupon.
|
|
// Buyers []string `json:"buyers"`
|
|
}
|
|
|
|
func (co *Coupon) Load(ps []datastore.Property) (err error) {
|
|
// Load supported properties
|
|
if err = datastore.LoadStruct(co, ps); err != nil {
|
|
return err
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (co *Coupon) Save() (ps []datastore.Property, err error) {
|
|
|
|
co.Code_ = strings.ToUpper(co.Code_)
|
|
|
|
// Save properties
|
|
return datastore.SaveStruct(co)
|
|
}
|
|
|
|
func (c Coupon) Code() string {
|
|
if c.RawCode != "" && c.RawCode != c.Code_ {
|
|
return c.RawCode
|
|
} else {
|
|
return c.Code_
|
|
}
|
|
}
|
|
|
|
func (c Coupon) DynamicCode() string {
|
|
if c.RawCode == c.Code_ {
|
|
return ""
|
|
}
|
|
|
|
return c.RawCode
|
|
}
|
|
|
|
func (c *Coupon) CodeFromId(uniqueid string) string {
|
|
cid := c.Key()
|
|
uid, err := hashid.DecodeKey(c.Context(), uniqueid)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
// Normal kind id for coupon is 3, this is 3333 to prevent accidental
|
|
// decoding as normal hashid
|
|
return hashid.Encode(3333, int(cid.IntID()), int(uid.IntID()))
|
|
}
|
|
|
|
func (c Coupon) ValidFor(t time.Time) bool {
|
|
if !c.Enabled {
|
|
log.Warn("Coupon Not Enabled", c.Context())
|
|
return false // currently active, no need to check?
|
|
}
|
|
|
|
if !timeutil.IsZero(c.StartDate) && c.StartDate.After(t) {
|
|
log.Warn("Coupon not yet Usable: %v > %v", c.StartDate.Unix(), t, c.Context())
|
|
return false
|
|
}
|
|
|
|
if !timeutil.IsZero(c.EndDate) && c.EndDate.Before(t) {
|
|
log.Warn("Coupon is Expired: %v < %v", c.EndDate.Unix(), t, c.Context())
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (c Coupon) ItemId() string {
|
|
if c.ProductId != "" {
|
|
return c.ProductId
|
|
}
|
|
if c.FreeProductId != "" {
|
|
return c.FreeProductId
|
|
}
|
|
if c.FreeVariantId != "" {
|
|
return c.FreeProductId
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func New(db *datastore.Datastore) *Coupon {
|
|
c := new(Coupon)
|
|
c.Init(db)
|
|
return c
|
|
}
|
|
|
|
func Query(db *datastore.Datastore) datastore.Query {
|
|
return db.Query("coupon")
|
|
}
|