Files
commerce/models/order/coupon.go
zeekay 1943f1c093 Hanzo Commerce
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans,
usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0.

Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
2026-09-23 21:52:16 -07:00

219 lines
5.3 KiB
Go

package order
import (
"errors"
"strings"
"github.com/hanzoai/commerce/log"
"github.com/hanzoai/commerce/models/coupon"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/models/lineitem"
)
// Get line items from datastore
func (o *Order) GetCoupons() error {
o.DedupeCouponCodes()
db := o.Datastore()
ctx := db.Context
log.Debug("CouponCodes: %#v", o.CouponCodes)
num := len(o.CouponCodes)
o.Coupons = make([]coupon.Coupon, num, num)
for i := 0; i < num; i++ {
cpn := coupon.New(db)
code := strings.TrimSpace(o.CouponCodes[i])
log.Debug("CODE: %s", code)
err := cpn.GetById(code)
if err != nil {
log.Warn("Could not find CouponCodes[%v] => %v, Error: %v", i, code, err, ctx)
return errors.New("Invalid coupon code: " + code)
}
o.Coupons[i] = *cpn
}
return nil
}
func (o *Order) DedupeCouponCodes() {
found := make(map[string]bool)
j := 0
for i, code := range o.CouponCodes {
if !found[code] {
found[code] = true
o.CouponCodes[j] = o.CouponCodes[i]
j++
}
}
o.CouponCodes = o.CouponCodes[:j]
}
func (o *Order) CalcCouponDiscount() currency.Cents {
var discount currency.Cents
num := len(o.CouponCodes)
ctx := o.Datastore().Context
log.Debug("Applying coupons: %v", o.CouponCodes, ctx)
for i := 0; i < num; i++ {
c := &o.Coupons[i]
if !c.ValidFor(o.CreatedAt) {
continue
}
log.Debug("Applying coupon '%v'", c.Code(), ctx)
if c.ItemId() == "" {
log.Debug("Coupon applies to order", ctx)
// Not per product
switch c.Type {
case coupon.Flat:
log.Warn("Flat", ctx)
discount += currency.Cents(c.Amount)
case coupon.Percent:
log.Warn("Percent", ctx)
// One coupon on the order is ONE discount, so the base is summed and
// rounded once. Rounding each line and adding the results would make
// this disagree with OrderDB.ApplyCoupon, which takes the same
// percentage off LineTotal in a single step — one coupon, two answers,
// differing by up to a minor unit per line.
var base currency.Cents
for _, item := range o.Items {
base += item.TotalPrice()
}
discount += base.Percent(c.Amount)
case coupon.FreeShipping:
log.Warn("FreeShipping", ctx)
discount += currency.Cents(int(o.Shipping))
}
} else {
log.Debug("Coupon applies to '%v'", c.ItemId(), ctx)
// Coupons per product
for _, item := range o.Items {
log.Debug("Coupon.ProductId: %v, Item.ProductId: %v", c.ProductId, item.ProductId, ctx)
if item.Id() == c.ItemId() {
switch c.Type {
case coupon.Flat:
log.Debug("Flat %d", c.Amount, ctx)
quantity := item.Quantity
if c.Once {
quantity = 1
}
discount += currency.Cents(quantity * c.Amount)
case coupon.Percent:
log.Debug("Percent %d", c.Amount, ctx)
// Scoped to one item, so it rounds per line — and must, because
// CalcItemCouponTaxableDiscount reduces the taxable base line by
// line and the two have to agree on every item.
discount += item.TotalPrice().Percent(c.Amount)
case coupon.FreeItem:
log.Debug("FreeShipping", ctx)
discount += currency.Cents(item.Price)
}
// Break out unless required to apply to each product
if c.Once {
break
}
}
}
}
}
return discount
}
// CalcItemCouponTaxableDiscount returns the taxable discount from item-specific coupons only.
// Order-wide coupons (no ProductId/VariantId) do not reduce the taxable base.
func (o *Order) CalcItemCouponTaxableDiscount() currency.Cents {
var taxableDiscount currency.Cents
for i := range o.Coupons {
c := &o.Coupons[i]
if c.ItemId() == "" {
continue // order-level coupons don't reduce taxable base
}
if !c.ValidFor(o.CreatedAt) {
continue
}
for _, item := range o.Items {
if item.Id() != c.ItemId() || !item.Taxable {
continue
}
switch c.Type {
case coupon.Flat:
qty := item.Quantity
if c.Once {
qty = 1
}
taxableDiscount += currency.Cents(qty * c.Amount)
case coupon.Percent:
// Same per-line rounding as the discount itself in CalcCouponDiscount:
// tax must be charged on the amount the customer was actually billed.
taxableDiscount += item.TotalPrice().Percent(c.Amount)
}
if c.Once {
break
}
}
}
return taxableDiscount
}
// Update discount using coupon codes/order info.
// Refactor later when we have more time to think about it
func (o *Order) UpdateCouponItems() error {
nCodes := len(o.CouponCodes)
items := make([]lineitem.LineItem, 0)
for _, item := range o.Items {
if item.AddedBy != "coupon" {
items = append(items, item)
}
}
o.Items = items
for i := 0; i < nCodes; i++ {
c := &o.Coupons[i]
if !c.ValidFor(o.CreatedAt) {
continue
}
if c.ProductId == "" {
switch c.Type {
case coupon.FreeItem:
o.Items = append(o.Items, lineitem.LineItem{
ProductId: c.FreeProductId,
VariantId: c.FreeVariantId,
Quantity: c.FreeQuantity,
Free: true,
AddedBy: "coupon",
})
}
} else {
for _, item := range o.Items {
if item.ProductId == c.ProductId {
switch c.Type {
case coupon.FreeItem:
o.Items = append(o.Items, lineitem.LineItem{
ProductId: c.FreeProductId,
VariantId: c.FreeVariantId,
Quantity: c.FreeQuantity,
Free: true,
AddedBy: "coupon",
})
}
}
}
}
}
return nil
}