From 5ce81a2198cd822824edeb253be1bea1e467affb Mon Sep 17 00:00:00 2001 From: Blake Mizerany Date: Thu, 9 Mar 2023 04:31:19 +0000 Subject: [PATCH] api: add tax behavior --- api/api.go | 12 ++++----- api/api_test.go | 3 ++- api/apitypes/apitypes.go | 17 +++++-------- api/apitypes/model.go | 48 ++++++++++++++++++++++++++++++----- api/materialize/views.go | 10 +++++--- api/materialize/views_test.go | 27 ++++++++++++++++++++ client/tier/client.go | 5 ++-- cmd/tier/tier.go | 9 ++++--- control/client.go | 13 ++++++++-- control/client_test.go | 9 +++++++ control/schedule.go | 44 +++++++++++++++++--------------- types/tax/tax.go | 9 +++++++ 12 files changed, 148 insertions(+), 58 deletions(-) create mode 100644 types/tax/tax.go diff --git a/api/api.go b/api/api.go index 05aa7bf..880f24f 100644 --- a/api/api.go +++ b/api/api.go @@ -249,10 +249,10 @@ func (h *Handler) serveSubscribe(w http.ResponseWriter, r *http.Request) error { return err } phases = append(phases, control.Phase{ - Trial: p.Trial, - Effective: p.Effective, - Features: fs, - AutomaticTax: sr.Tax.Automatic, + Trial: p.Trial, + Effective: p.Effective, + Features: fs, + Tax: sr.Tax, }) } } @@ -342,9 +342,7 @@ func (h *Handler) servePhase(w http.ResponseWriter, r *http.Request) error { Plans: p.Plans, Fragments: p.Fragments(), Trial: p.Trial, - Tax: apitypes.Taxation{ - Automatic: p.AutomaticTax, - }, + Tax: p.Tax, }) } } diff --git a/api/api_test.go b/api/api_test.go index 850f6b2..a59f868 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -18,6 +18,7 @@ import ( "tier.run/refs" "tier.run/stripe" "tier.run/stripe/stroke" + "tier.run/types/tax" "tier.run/types/they" ) @@ -319,7 +320,7 @@ func TestScheduleAutomaticTax(t *testing.T) { } }) _, err := tc.Schedule(ctx, "org:test", &tier.ScheduleParams{ - Tax: tier.Taxation{Automatic: true}, + Tax: tax.Applied{Automatically: true}, Phases: []apitypes.Phase{ { Features: []string{"plan:test@0"}, diff --git a/api/apitypes/apitypes.go b/api/apitypes/apitypes.go index 6bf7a23..38d9f5c 100644 --- a/api/apitypes/apitypes.go +++ b/api/apitypes/apitypes.go @@ -7,6 +7,7 @@ import ( "tier.run/refs" "tier.run/types/payment" + "tier.run/types/tax" ) type Error struct { @@ -26,10 +27,6 @@ type Phase struct { Features []string `json:"features,omitempty"` } -type Taxation struct { - Automatic bool `json:"automatic,omitempty"` -} - type PhaseResponse struct { Effective time.Time `json:"effective,omitempty"` End time.Time `json:"end,omitempty"` @@ -37,7 +34,7 @@ type PhaseResponse struct { Plans []refs.Plan `json:"plans,omitempty"` Fragments []refs.FeaturePlan `json:"fragments,omitempty"` Trial bool `json:"trial,omitempty"` - Tax Taxation `json:"tax,omitempty"` + Tax tax.Applied `json:"tax"` } func (pr PhaseResponse) MarshalJSON() ([]byte, error) { @@ -91,11 +88,11 @@ type CheckoutRequest struct { } type ScheduleRequest struct { - Org string `json:"org"` - PaymentMethodID string `json:"payment_method_id"` - Info *OrgInfo `json:"info"` - Phases []Phase `json:"phases"` - Tax Taxation `json:"tax"` + Org string `json:"org"` + PaymentMethodID string `json:"payment_method_id"` + Info *OrgInfo `json:"info"` + Phases []Phase `json:"phases"` + Tax tax.Applied `json:"tax"` } // ScheduleResponse is the expected response from a schedule request. It is diff --git a/api/apitypes/model.go b/api/apitypes/model.go index 3d4ccdc..55ce73d 100644 --- a/api/apitypes/model.go +++ b/api/apitypes/model.go @@ -4,6 +4,7 @@ import ( "encoding/json" "tier.run/refs" + "tier.run/types/tax" "tier.run/values" ) @@ -53,12 +54,47 @@ type Divide struct { } type Feature struct { - Title string `json:"title,omitempty"` - Base float64 `json:"base,omitempty"` - Mode string `json:"mode,omitempty"` - Aggregate string `json:"aggregate,omitempty"` - Tiers []Tier `json:"tiers,omitempty"` - Divide *Divide `json:"divide,omitempty"` + Title string `json:"title,omitempty"` + Base float64 `json:"base,omitempty"` + Mode string `json:"mode,omitempty"` + Aggregate string `json:"aggregate,omitempty"` + Tiers []Tier `json:"tiers,omitempty"` + Divide Divide `json:"divide"` + Tax tax.Settings `json:"tax"` +} + +func (v Feature) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Title string `json:"title,omitempty"` + Base float64 `json:"base,omitempty"` + Mode string `json:"mode,omitempty"` + Aggregate string `json:"aggregate,omitempty"` + Tiers []Tier `json:"tiers,omitempty"` + Divide *Divide `json:"divide,omitempty"` + Tax *tax.Settings `json:"tax,omitempty"` + }{ + Title: v.Title, + Base: v.Base, + Mode: v.Mode, + Aggregate: v.Aggregate, + Tiers: v.Tiers, + Divide: zeroAsNil(v.Divide), + Tax: zeroAsNil(v.Tax), + }) +} + +// zeroAsNil returns a pointer to v if v is not the zero value for its type. +// If v implements IsZero, it is used to determine if v is the zero value. +func zeroAsNil[T comparable](v T) *T { + z, ok := any(v).(interface{ IsZero() bool }) + if ok && z.IsZero() { + return nil + } + var zero T + if v == zero { + return nil + } + return &v } type Plan struct { diff --git a/api/materialize/views.go b/api/materialize/views.go index 001ecd9..8f2cd69 100644 --- a/api/materialize/views.go +++ b/api/materialize/views.go @@ -40,7 +40,6 @@ func FromPricingHuJSON(data []byte) (fs []control.Feature, err error) { for feature, f := range p.Features { fn := feature.WithPlan(plan) - divide := values.Coalesce(f.Divide, &apitypes.Divide{}) ff := control.Feature{ FeaturePlan: fn, @@ -55,8 +54,10 @@ func FromPricingHuJSON(data []byte) (fs []control.Feature, err error) { Mode: values.Coalesce(f.Mode, "graduated"), Aggregate: values.Coalesce(f.Aggregate, "sum"), - TransformDenominator: divide.By, - TransformRoundUp: divide.Rounding == "up", + TransformDenominator: f.Divide.By, + TransformRoundUp: f.Divide.Rounding == "up", + + Tax: f.Tax, } if len(f.Tiers) > 0 { @@ -110,13 +111,14 @@ func ToPricingJSON(fs []control.Feature) ([]byte, error) { Mode: values.ZeroIf(f.Mode, "graduated"), Aggregate: values.ZeroIf(f.Aggregate, "sum"), Tiers: tiers, + Tax: f.Tax, } if f.TransformDenominator != 0 { var round string if f.TransformRoundUp { round = "up" } - af.Divide = &apitypes.Divide{ + af.Divide = apitypes.Divide{ By: f.TransformDenominator, Rounding: round, } diff --git a/api/materialize/views_test.go b/api/materialize/views_test.go index 6389797..b182dfb 100644 --- a/api/materialize/views_test.go +++ b/api/materialize/views_test.go @@ -11,6 +11,7 @@ import ( "tier.run/client/tier" "tier.run/control" "tier.run/refs" + "tier.run/types/tax" ) func TestPricingHuJSON(t *testing.T) { @@ -41,6 +42,13 @@ func TestPricingHuJSON(t *testing.T) { } }, }, + "plan:tax@1": { + "features": { + "feature:tax:not:included": { + "tax": {"included": true}, + }, + }, + }, } }`) @@ -63,6 +71,16 @@ func TestPricingHuJSON(t *testing.T) { Aggregate: "sum", // defaults Base: 100, }, + { + PlanTitle: "plan:tax@1", + Title: "feature:tax:not:included@plan:tax@1", + FeaturePlan: refs.MustParseFeaturePlan("feature:tax:not:included@plan:tax@1"), + Currency: "usd", + Interval: "@monthly", + Mode: "graduated", // defaults + Aggregate: "sum", + Tax: tax.Settings{Included: true}, + }, { PlanTitle: "Just an example plan to show off features", Title: "feature:volume@plan:example@1", @@ -123,6 +141,14 @@ func TestPricingHuJSON(t *testing.T) { "divide": {"by": 100, "rounding": "up"}, } } + }, + "plan:tax@1": { + "title": "plan:tax@1", + "features": { + "feature:tax:not:included": { + "tax": {"included": true} + } + } } } }`) @@ -134,6 +160,7 @@ func diffJSON(t *testing.T, got, want []byte) { t.Helper() format := func(b []byte) string { + t.Helper() b, err := hujson.Standardize(b) if err != nil { t.Fatal(err) diff --git a/client/tier/client.go b/client/tier/client.go index 2dcfe8e..9d7d794 100644 --- a/client/tier/client.go +++ b/client/tier/client.go @@ -17,6 +17,7 @@ import ( "tier.run/api/apitypes" "tier.run/fetch" "tier.run/refs" + "tier.run/types/tax" ) // ClockHeader is the header used to pass the clock ID to the tier sidecar. @@ -305,14 +306,12 @@ type CheckoutParams struct { RequireBillingAddress bool } -type Taxation = apitypes.Taxation - type ScheduleParams struct { Info *OrgInfo Phases []Phase PaymentMethodID string - Tax Taxation + Tax tax.Applied } func (c *Client) Schedule(ctx context.Context, org string, p *ScheduleParams) (*apitypes.ScheduleResponse, error) { diff --git a/cmd/tier/tier.go b/cmd/tier/tier.go index d4d4af6..0e55d36 100644 --- a/cmd/tier/tier.go +++ b/cmd/tier/tier.go @@ -29,6 +29,7 @@ import ( "tier.run/control" "tier.run/profile" "tier.run/stripe" + "tier.run/types/tax" "tier.run/version" ) @@ -279,7 +280,7 @@ func runTier(cmd string, args []string) (err error) { cancelURL := fs.String("cancel_url", "", "sets the cancel URL for use with -checkout") requireBillingAddress := fs.Bool("require_billing_address", false, "require billing address for use with --checkout") paymentMethod := fs.String("paymentmethod", "", "sets the Stripe payment method for the subscription (e.g. pm_123). It is ignored with --checkout") - tax := fs.String("tax", "", "sets the Stripe tax rate for the subscription ('auto' is currently the only supported value)") + taxtype := fs.String("tax", "", "sets the Stripe tax rate for the subscription ('auto' is currently the only supported value)") if err := fs.Parse(args); err != nil { return err } @@ -293,8 +294,8 @@ func runTier(cmd string, args []string) (err error) { fmt.Fprintln(stderr, "tier: the -cancel flag must be used without arguments") return errUsage } - if *tax != "" && *tax != "auto" { - fmt.Fprintf(stderr, "tier: invalid tax rate %q\n", *tax) + if *taxtype != "" && *taxtype != "auto" { + fmt.Fprintf(stderr, "tier: invalid tax rate %q\n", *taxtype) return errUsage } @@ -324,7 +325,7 @@ func runTier(cmd string, args []string) (err error) { Email: *email, }, PaymentMethodID: *paymentMethod, - Tax: tier.Taxation{Automatic: *tax == "auto"}, + Tax: tax.Applied{Automatically: *taxtype == "auto"}, } switch { case *trial > 0: diff --git a/control/client.go b/control/client.go index e747446..e9a518c 100644 --- a/control/client.go +++ b/control/client.go @@ -12,6 +12,7 @@ import ( "golang.org/x/sync/errgroup" "tier.run/refs" "tier.run/stripe" + "tier.run/types/tax" "tier.run/values" ) @@ -106,6 +107,8 @@ type Feature struct { TransformDenominator int // the denominator for transforming usage TransformRoundUp bool // whether to round up transformed usage; otherwise round down + + Tax tax.Settings } // TODO(bmizerany): remove FQN and replace with simply adding the version to @@ -330,9 +333,13 @@ func (c *Client) pushFeature(ctx context.Context, f Feature) (providerID string, data.Set("metadata", "tier.limit", limit) } + if f.Tax.Included { + data.Set("tax_behavior", "inclusive") + } else { + data.Set("tax_behavior", "exclusive") + } + // TODO(bmizerany): data.Set("active", ?) - // TODO(bmizerany): data.Set("tax_behavior", "?") - // TODO(bmizerany): data.Set("transform_quantity", "?") // TODO(bmizerany): data.Set("currency_options", "?") var v struct { @@ -374,6 +381,7 @@ type stripePrice struct { DivideBy int `json:"divide_by"` Round string `json:"round"` } `json:"transform_quantity"` + TaxBehavior string `json:"tax_behavior"` } func stripePriceToFeature(p stripePrice) Feature { @@ -388,6 +396,7 @@ func stripePriceToFeature(p stripePrice) Feature { Aggregate: aggregateFromStripe[p.Recurring.AggregateUsage], TransformDenominator: p.TransformQuantity.DivideBy, TransformRoundUp: p.TransformQuantity.Round == "up", + Tax: tax.Settings{Included: p.TaxBehavior == "inclusive"}, } if len(p.Tiers) == 0 && p.Recurring.UsageType == "metered" { diff --git a/control/client_test.go b/control/client_test.go index 60ce70f..e255ef2 100644 --- a/control/client_test.go +++ b/control/client_test.go @@ -10,6 +10,7 @@ import ( "kr.dev/diff" "tier.run/refs" "tier.run/stripe/stroke" + "tier.run/types/tax" ) func newTestClient(t *testing.T) *Client { @@ -80,6 +81,14 @@ func TestRoundTrip(t *testing.T) { {Upto: 1, Price: 100, Base: 0}, }, }, + { + FeaturePlan: refs.MustParseFeaturePlan("feature:tax@0"), + Interval: "@daily", + Currency: "eur", + Title: "Test2", + Base: 1000, + Tax: tax.Settings{Included: true}, + }, } if !slices.IsSortedFunc(want, func(a, b Feature) bool { diff --git a/control/schedule.go b/control/schedule.go index bb43466..94d4e78 100644 --- a/control/schedule.go +++ b/control/schedule.go @@ -13,6 +13,7 @@ import ( "tier.run/refs" "tier.run/stripe" "tier.run/types/payment" + "tier.run/types/tax" "tier.run/values" ) @@ -88,7 +89,7 @@ type Phase struct { // "fragmented". Plans []refs.Plan - AutomaticTax bool + Tax tax.Applied } // Valid reports if the Phase is one that would be retured from the Stripe API. @@ -108,16 +109,16 @@ func (p *Phase) Fragments() []refs.FeaturePlan { } type subscription struct { - ID string - ScheduleID string - Status string - Name string - Effective time.Time - TrialEnd time.Time - EndDate time.Time - CanceledAt time.Time - Features []Feature - AutomaticTax bool + ID string + ScheduleID string + Status string + Name string + Effective time.Time + TrialEnd time.Time + EndDate time.Time + CanceledAt time.Time + Features []Feature + Tax tax.Applied } func (c *Client) lookupSubscription(ctx context.Context, org, name string) (sub subscription, err error) { @@ -184,12 +185,14 @@ func (c *Client) lookupSubscription(ctx context.Context, org, name string) (sub } s := subscription{ - ID: v.ProviderID(), - ScheduleID: v.Schedule.ID, - Effective: time.Unix(v.StartDate, 0), - Status: v.Status, - Features: fs, - AutomaticTax: v.AutomaticTax.Enabled, + ID: v.ProviderID(), + ScheduleID: v.Schedule.ID, + Effective: time.Unix(v.StartDate, 0), + Status: v.Status, + Features: fs, + Tax: tax.Applied{ + Automatically: v.AutomaticTax.Enabled, + }, } if v.TrialEnd > 0 { s.TrialEnd = time.Unix(v.TrialEnd, 0) @@ -328,7 +331,7 @@ func (c *Client) lookupPhases(ctx context.Context, org string, s subscription, n Trial: p.TrialEnd > 0, - AutomaticTax: s.AutomaticTax, + Tax: s.Tax, } all = append(all, p) if p.Current { @@ -413,12 +416,11 @@ func (c *Client) cancelSubscription(ctx context.Context, subID string) (err erro func addPhases(ctx context.Context, c *Client, f *stripe.Form, update bool, name string, phases []Phase) error { var automaticTax bool for i, p := range phases { - if i > 0 && p.AutomaticTax != automaticTax { + if i > 0 && p.Tax.Automatically != automaticTax { // TODO(bmizerany): make sentinel error return errors.New("stripe: automatic tax must be consistent across phases") } - automaticTax = p.AutomaticTax - f.Set("default_settings", "automatic_tax", "enabled", automaticTax) + f.Set("default_settings", "automatic_tax", "enabled", p.Tax.Automatically) if len(p.Features) == 0 { if i != len(phases)-1 { diff --git a/types/tax/tax.go b/types/tax/tax.go new file mode 100644 index 0000000..271e04e --- /dev/null +++ b/types/tax/tax.go @@ -0,0 +1,9 @@ +package tax + +type Settings struct { + Included bool `json:"included,omitempty"` +} + +type Applied struct { + Automatically bool `json:"automatically,omitempty"` +}