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]>
68 lines
2.3 KiB
Go
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
|
|
}
|