mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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]>
144 lines
4.5 KiB
Go
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)
|
|
}
|