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

68 lines
2.3 KiB
Go

// Copyright (c) 2014-present Hanzo AI, Inc.
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
package promo
// A plan's free trial is an offer each card takes once, whichever org buys the
// plan. Buyers' subscriptions live in their own orgs' namespaces, so the record
// that spans them lives with the platform's other per-card offer uses: a taken
// trial is a models/couponredemption row in the platform namespace, keyed by
// (the plan's trial, the card), written under its offer's lock so two buyers
// racing with one card cannot both take it.
import (
"context"
"errors"
"strings"
"github.com/hanzoai/commerce/models/couponredemption"
)
// ErrTrialTaken is a trial refused because the card has already had it.
var ErrTrialTaken = errors.New("this card has already had this plan's free trial")
// trialCode is the offer a plan's trial is recorded as. The colon keeps it apart
// from every coupon code, which CouponCode never admits one into. A sandbox trial
// is its own offer, so a test card never spends a live trial.
func trialCode(slug string, test bool) string {
if test {
return "trial-test:" + slug
}
return "trial:" + slug
}
// TakeTrial records that the card instrument takes plan slug's free trial, or
// answers ErrTrialTaken when that card has had it. instrument is the card's
// stable identity, as Reserve takes it. release gives the trial back, for a sale
// that took it and then did not open it.
func TakeTrial(ctx context.Context, slug, instrument string, test bool) (release func(), err error) {
slug, instrument = strings.TrimSpace(slug), strings.TrimSpace(instrument)
if slug == "" || instrument == "" {
return nil, errors.New("trial: a trial needs a plan and a card")
}
code := trialCode(slug, test)
card := cardRedeemer(instrument)
id := couponredemption.DeterministicID(code, card)
mu := couponLock(code)
mu.Lock()
defer mu.Unlock()
db := platform(ctx)
if ok, err := redemptionExists(db, id); err != nil {
return nil, err
} else if ok {
return nil, ErrTrialTaken
}
if err := putRedemption(db, id, code, card); err != nil {
return nil, err
}
// The release must still run when the buyer has hung up, so it keeps the
// context's values and drops its cancellation.
keep := context.WithoutCancel(ctx)
return func() {
mu.Lock()
defer mu.Unlock()
removeRedemption(platform(keep), code, id)
}, nil
}