Files
zooqueenandHanzo Dev 78296e121f feat(billing): card-verified free trial on the card subscription path
A plan with trialPeriodDays sold through POST /v1/billing/subscribe/card to a
buyer and a card that have not had its trial charges nothing today. The card
is vaulted through saveCard, which validates it; the subscription opens
trialing on it, Square-backed and carrying any promo; the receipt answers
amountCents 0, trialEnd and trialEndCents; the operator notice says it is a
trial.

One trial per buyer and per card. The buyer rule reads the subject's own
subscriptions: a subject that has held the plan before, in any status, is not
offered the trial. The card rule spans every buyer org: each buyer's sale
runs in its own org's namespace, so a taken trial is recorded with the
platform's other per-card offer uses, as a couponredemption row in the
platform namespace keyed by the plan's trial and the hashed card
(promo.TakeTrial, under the offer's lock). A sandbox trial is its own offer.
A refused trial sells exactly as a plan without one, charged now. A store
that cannot answer ends the sale with nothing charged. A coupon on a sale
that would open a trial is refused (400) and the trial given back: a coupon
discounts a charge made today, and a trial makes none.

The renewal sweep charges the trial's end. engine.IsDue counts a trialing row
on a card once its TrialEnd has passed, and RenewSubscription treats the
trial as what the row has been served (servedTo), so the first paid period is
invoiced and collected through CollectInvoice under the per-period key. Paid
moves the row active; a decline leaves it past due with the invoice open, as
for any renewal. A trial canceled at its end ends with it, unbilled. A trial
with no card on it is never due, so the signup on-ramp is unchanged.


Co-authored-by: Hanzo Dev <[email protected]>
2026-10-14 21:56:54 -07:00

144 lines
4.5 KiB
Go

// Copyright (c) 2014-present Hanzo AI, Inc.
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
package billing
import (
"context"
"fmt"
netmail "net/mail"
"os"
"strings"
"time"
"github.com/hanzoai/commerce/billing/engine"
"github.com/hanzoai/commerce/log"
"github.com/hanzoai/commerce/mail"
"github.com/hanzoai/commerce/models/plan"
)
// saleNotifyEnv names the addresses told about every paid plan sale, comma
// separated. Unset tells nobody. The value is deployment configuration, set by
// the operator; no address is written in source.
const saleNotifyEnv = "BILLING_SALE_NOTIFY_TO"
// saleNoticeTimeout bounds one notice's delivery. The sale has already answered
// by then; this only stops a stuck mail rail from holding a goroutine forever.
const saleNoticeTimeout = 30 * time.Second
// saleNote is one paid sale, as the operator is told about it.
type saleNote struct {
Org string
Email string
Plan *plan.Plan // the plan as bought: its name, slug and period
Seats int64
Sale *Sale
ListCents int64 // the first period at catalog price, before promo and coupon
PromoName string
Coupon engine.Coupon
Test bool
}
// noticeSale tells the operator about a sale that is already sealed. It never
// holds the sale up and never fails it: delivery runs on its own goroutine,
// detached from the request, and a failure is logged and dropped. It is called
// once per fresh sale and never for a replay, which returns before a sale is made.
func noticeSale(ctx context.Context, n saleNote) {
to := saleRecipients()
if len(to) == 0 || n.Sale == nil {
return
}
subject, body := n.render(time.Now().UTC())
go func() {
// A panic in the mail rail is the notice's failure, never the process's:
// this goroutine runs inside whatever binary embeds commerce.
defer func() {
if r := recover(); r != nil {
log.Error("sale notice for subscription %s (org %s) panicked: %v", n.Sale.SubscriptionID, n.Org, r)
}
}()
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), saleNoticeTimeout)
defer cancel()
if err := mail.Send(ctx, to, subject, body); err != nil {
log.Warn("sale notice for subscription %s (org %s) was not sent: %v", n.Sale.SubscriptionID, n.Org, err)
}
}()
}
// saleRecipients reads saleNotifyEnv: every well-formed address in it.
func saleRecipients() []string {
var to []string
for _, a := range strings.Split(os.Getenv(saleNotifyEnv), ",") {
if addr, err := netmail.ParseAddress(strings.TrimSpace(a)); err == nil {
to = append(to, addr.Address)
}
}
return to
}
// render is the notice's subject and plain-text body.
func (n saleNote) render(at time.Time) (subject, body string) {
cur := strings.ToUpper(n.Sale.Currency)
name := n.Plan.Name
if name == "" {
name = n.Sale.PlanID
}
coupon := "none"
if label := n.Coupon.Label(); label != "" {
coupon = label
}
email := strings.TrimSpace(n.Email)
if email == "" {
email = "(not given)"
}
mode := "live"
if n.Test {
mode = "test"
}
trial := n.Sale.TrialEnd != nil
subject = fmt.Sprintf("Sale: %s (%s) for %s, %s", name, n.Sale.Interval, n.Org, cents(n.Sale.AmountCents, cur))
if trial {
subject = fmt.Sprintf("Trial: %s (%s) for %s, %s from %s", name, n.Sale.Interval, n.Org,
cents(n.Sale.TrialEndCents, cur), n.Sale.TrialEnd.UTC().Format(time.DateOnly))
}
if n.Coupon.Code != "" {
subject += ", coupon " + n.Coupon.Code
}
var b strings.Builder
line := func(k, v string) { fmt.Fprintf(&b, "%-16s%s\n", k+":", v) }
if trial {
b.WriteString("A free trial of a paid plan was started. Its card is charged when the trial ends.\n\n")
} else {
b.WriteString("A paid plan subscription was sold.\n\n")
}
line("Org", n.Org)
line("Customer email", email)
line("Plan", fmt.Sprintf("%s (%s)", name, n.Sale.PlanID))
line("Interval", string(n.Sale.Interval))
if n.Seats > 1 {
line("Seats", fmt.Sprintf("%d", n.Seats))
}
line("Charged", cents(n.Sale.AmountCents, cur))
if trial {
line("Trial ends", n.Sale.TrialEnd.UTC().Format(time.RFC3339))
line("Then charged", cents(n.Sale.TrialEndCents, cur))
}
line("List price", cents(n.ListCents, cur))
if n.PromoName != "" {
line("Promo", n.PromoName)
}
line("Coupon", coupon)
line("Time", at.Format(time.RFC3339))
line("Subscription", n.Sale.SubscriptionID)
line("Invoice", n.Sale.InvoiceID)
line("Mode", mode)
return subject, b.String()
}
// cents renders minor units as an amount: 1050, "USD" is "10.50 USD".
func cents(v int64, cur string) string {
return fmt.Sprintf("%d.%02d %s", v/100, v%100, cur)
}