implement core foundation
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
SecretKey string
|
||||
WebhookSecret string
|
||||
PriceMonthlyID string
|
||||
PriceAnnualID string
|
||||
|
||||
SuccessURL string
|
||||
CancelURL string
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
required := map[string]string{
|
||||
"SecretKey": c.SecretKey,
|
||||
"WebhookSecret": c.WebhookSecret,
|
||||
"PriceMonthlyID": c.PriceMonthlyID,
|
||||
"PriceAnnualID": c.PriceAnnualID,
|
||||
"SuccessURL": c.SuccessURL,
|
||||
"CancelURL": c.CancelURL,
|
||||
}
|
||||
var missing []string
|
||||
for name, value := range required {
|
||||
if value == "" {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("billing config missing: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package billing
|
||||
|
||||
import "time"
|
||||
|
||||
type Cadence string
|
||||
|
||||
const (
|
||||
CadenceMonthly Cadence = "monthly"
|
||||
CadenceAnnual Cadence = "annual"
|
||||
)
|
||||
|
||||
func (c Cadence) IsValid() bool {
|
||||
return c == CadenceMonthly || c == CadenceAnnual
|
||||
}
|
||||
|
||||
type Plan string
|
||||
|
||||
const (
|
||||
PlanFree Plan = "free"
|
||||
PlanPro Plan = "pro"
|
||||
)
|
||||
|
||||
// Subscription is the persisted projection of a Stripe subscription,
|
||||
// reconciled on every webhook event.
|
||||
type Subscription struct {
|
||||
ID string
|
||||
NetworkID string
|
||||
StripeCustomerID string
|
||||
Status string
|
||||
PriceID string
|
||||
Cadence Cadence
|
||||
Quantity int
|
||||
CancelAtPeriodEnd bool
|
||||
CurrentPeriodStart time.Time
|
||||
CurrentPeriodEnd time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Plan Plan `json:"plan"`
|
||||
PlanStatus string `json:"plan_status"`
|
||||
Cadence *Cadence `json:"cadence"`
|
||||
Seats int `json:"seats"` // 0 on free plan; sub.Quantity on pro
|
||||
CurrentPeriodEnd *time.Time `json:"current_period_end"`
|
||||
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
||||
PriceMonthlyCents int64 `json:"price_monthly_cents"`
|
||||
PriceAnnualCents int64 `json:"price_annual_cents"`
|
||||
}
|
||||
|
||||
type CheckoutParams struct {
|
||||
NetworkID string
|
||||
AdminHumanID string
|
||||
AdminEmail string
|
||||
Cadence Cadence
|
||||
Seats int
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Noop returns a billing service for binaries that depend on network.Service
|
||||
// but never mutate membership (jobs, pusher). Stripe isn't configured.
|
||||
func Noop() Service { return noopService{} }
|
||||
|
||||
type noopService struct{}
|
||||
|
||||
var errNoopBilling = errors.New("billing: not configured in this process")
|
||||
|
||||
func (noopService) GetStatus(context.Context, string) (*Status, error) {
|
||||
return nil, errNoopBilling
|
||||
}
|
||||
func (noopService) CreateCheckoutSession(context.Context, CheckoutParams) (string, error) {
|
||||
return "", errNoopBilling
|
||||
}
|
||||
func (noopService) CreatePortalSession(context.Context, string) (string, error) {
|
||||
return "", errNoopBilling
|
||||
}
|
||||
func (noopService) SyncSeats(context.Context, string, int) error { return nil }
|
||||
func (noopService) HandleWebhook(context.Context, []byte, string) error { return errNoopBilling }
|
||||
@@ -0,0 +1,112 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var errNotFound = errors.New("not found")
|
||||
|
||||
type repository interface {
|
||||
getSubscriptionByNetworkID(ctx context.Context, networkID string) (*Subscription, error)
|
||||
upsertSubscription(ctx context.Context, sub *Subscription) error
|
||||
deleteSubscriptionByID(ctx context.Context, subscriptionID string) error
|
||||
|
||||
getStripeCustomerID(ctx context.Context, networkID string) (string, error)
|
||||
setStripeCustomerID(ctx context.Context, networkID, customerID string) error
|
||||
}
|
||||
|
||||
type repositoryImpl struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func newRepository(pool *pgxpool.Pool) repository {
|
||||
return &repositoryImpl{pool: pool}
|
||||
}
|
||||
|
||||
const subscriptionColumns = `id, network_id, stripe_customer_id, status, price_id, cadence, quantity, cancel_at_period_end, current_period_start, current_period_end, created_at, updated_at`
|
||||
|
||||
func scanSubscription(row pgx.Row, s *Subscription) error {
|
||||
return row.Scan(
|
||||
&s.ID, &s.NetworkID, &s.StripeCustomerID, &s.Status, &s.PriceID,
|
||||
&s.Cadence, &s.Quantity, &s.CancelAtPeriodEnd,
|
||||
&s.CurrentPeriodStart, &s.CurrentPeriodEnd,
|
||||
&s.CreatedAt, &s.UpdatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getSubscriptionByNetworkID(ctx context.Context, networkID string) (*Subscription, error) {
|
||||
var s Subscription
|
||||
err := scanSubscription(
|
||||
r.pool.QueryRow(ctx,
|
||||
`SELECT `+subscriptionColumns+` FROM network_subscriptions WHERE network_id = $1`,
|
||||
networkID,
|
||||
),
|
||||
&s,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) upsertSubscription(ctx context.Context, sub *Subscription) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO network_subscriptions (
|
||||
id, network_id, stripe_customer_id, status, price_id, cadence,
|
||||
quantity, cancel_at_period_end, current_period_start, current_period_end
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
stripe_customer_id = EXCLUDED.stripe_customer_id,
|
||||
status = EXCLUDED.status,
|
||||
price_id = EXCLUDED.price_id,
|
||||
cadence = EXCLUDED.cadence,
|
||||
quantity = EXCLUDED.quantity,
|
||||
cancel_at_period_end = EXCLUDED.cancel_at_period_end,
|
||||
current_period_start = EXCLUDED.current_period_start,
|
||||
current_period_end = EXCLUDED.current_period_end,
|
||||
updated_at = NOW()
|
||||
`,
|
||||
sub.ID, sub.NetworkID, sub.StripeCustomerID, sub.Status, sub.PriceID, sub.Cadence,
|
||||
sub.Quantity, sub.CancelAtPeriodEnd, sub.CurrentPeriodStart, sub.CurrentPeriodEnd,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) deleteSubscriptionByID(ctx context.Context, subscriptionID string) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM network_subscriptions WHERE id = $1`,
|
||||
subscriptionID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getStripeCustomerID(ctx context.Context, networkID string) (string, error) {
|
||||
var customerID string
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT stripe_customer_id FROM network_stripe_customers WHERE network_id = $1`,
|
||||
networkID,
|
||||
).Scan(&customerID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", errNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return customerID, nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) setStripeCustomerID(ctx context.Context, networkID, customerID string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO network_stripe_customers (network_id, stripe_customer_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (network_id) DO NOTHING
|
||||
`, networkID, customerID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stripe/stripe-go/v81"
|
||||
billingportalsession "github.com/stripe/stripe-go/v81/billingportal/session"
|
||||
checkoutsession "github.com/stripe/stripe-go/v81/checkout/session"
|
||||
stripecustomer "github.com/stripe/stripe-go/v81/customer"
|
||||
stripeprice "github.com/stripe/stripe-go/v81/price"
|
||||
stripesub "github.com/stripe/stripe-go/v81/subscription"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
GetStatus(ctx context.Context, networkID string) (*Status, error)
|
||||
CreateCheckoutSession(ctx context.Context, p CheckoutParams) (url string, err error)
|
||||
CreatePortalSession(ctx context.Context, networkID string) (url string, err error)
|
||||
// SyncSeats updates the Stripe subscription quantity with proration.
|
||||
// No-op if the network has no active subscription.
|
||||
SyncSeats(ctx context.Context, networkID string, seats int) error
|
||||
HandleWebhook(ctx context.Context, payload []byte, signature string) error
|
||||
}
|
||||
|
||||
var (
|
||||
ErrNoActiveSubscription = errors.New("network has no stripe customer yet")
|
||||
ErrInvalidCadence = errors.New("invalid billing cadence")
|
||||
)
|
||||
|
||||
type serviceImpl struct {
|
||||
cfg Config
|
||||
repo repository
|
||||
priceMonthlyCents int64
|
||||
priceAnnualCents int64
|
||||
}
|
||||
|
||||
func NewService(ctx context.Context, pool *pgxpool.Pool, cfg Config) (Service, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stripe.Key = cfg.SecretKey
|
||||
|
||||
monthly, err := stripeprice.Get(cfg.PriceMonthlyID, &stripe.PriceParams{
|
||||
Params: stripe.Params{Context: ctx},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch stripe monthly price: %w", err)
|
||||
}
|
||||
annual, err := stripeprice.Get(cfg.PriceAnnualID, &stripe.PriceParams{
|
||||
Params: stripe.Params{Context: ctx},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch stripe annual price: %w", err)
|
||||
}
|
||||
|
||||
return &serviceImpl{
|
||||
cfg: cfg,
|
||||
repo: newRepository(pool),
|
||||
priceMonthlyCents: monthly.UnitAmount,
|
||||
priceAnnualCents: annual.UnitAmount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *serviceImpl) GetStatus(ctx context.Context, networkID string) (*Status, error) {
|
||||
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
|
||||
if err != nil && !errors.Is(err, errNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
status := &Status{
|
||||
Plan: PlanFree,
|
||||
PlanStatus: "active",
|
||||
PriceMonthlyCents: s.priceMonthlyCents,
|
||||
PriceAnnualCents: s.priceAnnualCents,
|
||||
}
|
||||
|
||||
if sub != nil {
|
||||
cadence := sub.Cadence
|
||||
periodEnd := sub.CurrentPeriodEnd
|
||||
// past_due keeps access: Stripe still considers the subscription live
|
||||
// during the dunning window.
|
||||
switch stripe.SubscriptionStatus(sub.Status) {
|
||||
case stripe.SubscriptionStatusActive,
|
||||
stripe.SubscriptionStatusTrialing,
|
||||
stripe.SubscriptionStatusPastDue:
|
||||
status.Plan = PlanPro
|
||||
}
|
||||
status.PlanStatus = sub.Status
|
||||
status.Cadence = &cadence
|
||||
status.Seats = sub.Quantity
|
||||
status.CurrentPeriodEnd = &periodEnd
|
||||
status.CancelAtPeriodEnd = sub.CancelAtPeriodEnd
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (s *serviceImpl) CreateCheckoutSession(ctx context.Context, p CheckoutParams) (string, error) {
|
||||
if !p.Cadence.IsValid() {
|
||||
return "", ErrInvalidCadence
|
||||
}
|
||||
if p.Seats < 1 {
|
||||
return "", fmt.Errorf("seats must be >= 1")
|
||||
}
|
||||
|
||||
customerID, err := s.ensureStripeCustomer(ctx, p)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ensure stripe customer: %w", err)
|
||||
}
|
||||
|
||||
priceID := s.cfg.PriceAnnualID
|
||||
switch p.Cadence {
|
||||
case CadenceAnnual:
|
||||
priceID = s.cfg.PriceAnnualID
|
||||
break
|
||||
case CadenceMonthly:
|
||||
priceID = s.cfg.PriceMonthlyID
|
||||
break
|
||||
}
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||
Customer: stripe.String(customerID),
|
||||
ClientReferenceID: stripe.String(p.NetworkID),
|
||||
SuccessURL: stripe.String(s.cfg.SuccessURL),
|
||||
CancelURL: stripe.String(s.cfg.CancelURL),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{{
|
||||
Price: stripe.String(priceID),
|
||||
Quantity: stripe.Int64(int64(p.Seats)),
|
||||
}},
|
||||
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
|
||||
Metadata: map[string]string{
|
||||
"network_id": p.NetworkID,
|
||||
"admin_human_id": p.AdminHumanID,
|
||||
"cadence": string(p.Cadence),
|
||||
},
|
||||
},
|
||||
}
|
||||
params.Context = ctx
|
||||
|
||||
sess, err := checkoutsession.New(params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stripe checkout: %w", err)
|
||||
}
|
||||
return sess.URL, nil
|
||||
}
|
||||
|
||||
func (s *serviceImpl) CreatePortalSession(ctx context.Context, networkID string) (string, error) {
|
||||
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return "", ErrNoActiveSubscription
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
params := &stripe.BillingPortalSessionParams{
|
||||
Customer: stripe.String(sub.StripeCustomerID),
|
||||
ReturnURL: stripe.String(s.cfg.SuccessURL),
|
||||
}
|
||||
params.Context = ctx
|
||||
|
||||
sess, err := billingportalsession.New(params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stripe portal: %w", err)
|
||||
}
|
||||
return sess.URL, nil
|
||||
}
|
||||
|
||||
func (s *serviceImpl) SyncSeats(ctx context.Context, networkID string, seats int) error {
|
||||
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sub.Quantity == seats {
|
||||
return nil
|
||||
}
|
||||
|
||||
liveSub, err := stripesub.Get(sub.ID, &stripe.SubscriptionParams{
|
||||
Params: stripe.Params{Context: ctx},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch stripe subscription: %w", err)
|
||||
}
|
||||
if len(liveSub.Items.Data) == 0 {
|
||||
return fmt.Errorf("stripe subscription %s has no items", sub.ID)
|
||||
}
|
||||
|
||||
params := &stripe.SubscriptionParams{
|
||||
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)),
|
||||
Items: []*stripe.SubscriptionItemsParams{{
|
||||
ID: stripe.String(liveSub.Items.Data[0].ID),
|
||||
Quantity: stripe.Int64(int64(seats)),
|
||||
}},
|
||||
}
|
||||
params.Context = ctx
|
||||
|
||||
if _, err := stripesub.Update(sub.ID, params); err != nil {
|
||||
return fmt.Errorf("update stripe subscription quantity: %w", err)
|
||||
}
|
||||
// customer.subscription.updated webhook arrives within seconds and
|
||||
// reconciles quantity in our DB.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *serviceImpl) ensureStripeCustomer(ctx context.Context, p CheckoutParams) (string, error) {
|
||||
existing, err := s.repo.getStripeCustomerID(ctx, p.NetworkID)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
}
|
||||
if !errors.Is(err, errNotFound) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
params := &stripe.CustomerParams{
|
||||
Email: stripe.String(p.AdminEmail),
|
||||
Metadata: map[string]string{
|
||||
"network_id": p.NetworkID,
|
||||
"admin_human_id": p.AdminHumanID,
|
||||
},
|
||||
}
|
||||
params.Context = ctx
|
||||
|
||||
cust, err := stripecustomer.New(params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create stripe customer: %w", err)
|
||||
}
|
||||
if err := s.repo.setStripeCustomerID(ctx, p.NetworkID, cust.ID); err != nil {
|
||||
return "", fmt.Errorf("persist stripe customer id: %w", err)
|
||||
}
|
||||
return cust.ID, nil
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/stripe/stripe-go/v81"
|
||||
"github.com/stripe/stripe-go/v81/webhook"
|
||||
)
|
||||
|
||||
func (s *serviceImpl) HandleWebhook(ctx context.Context, payload []byte, signature string) error {
|
||||
event, err := webhook.ConstructEvent(payload, signature, s.cfg.WebhookSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify stripe signature: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("stripe webhook", "type", event.Type, "id", event.ID)
|
||||
|
||||
switch event.Type {
|
||||
case "checkout.session.completed":
|
||||
// subscription.created fires right after with full detail; we handle
|
||||
// the subscription there.
|
||||
return nil
|
||||
case "customer.subscription.created", "customer.subscription.updated":
|
||||
return s.handleSubscriptionUpsert(ctx, event)
|
||||
case "customer.subscription.deleted":
|
||||
return s.handleSubscriptionDeleted(ctx, event)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *serviceImpl) handleSubscriptionUpsert(ctx context.Context, event stripe.Event) error {
|
||||
var sub stripe.Subscription
|
||||
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||
return fmt.Errorf("decode subscription: %w", err)
|
||||
}
|
||||
|
||||
networkID := sub.Metadata["network_id"]
|
||||
if networkID == "" {
|
||||
return fmt.Errorf("subscription %s missing network_id metadata", sub.ID)
|
||||
}
|
||||
|
||||
local, err := subscriptionFromStripe(&sub, networkID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.upsertSubscription(ctx, local); err != nil {
|
||||
return fmt.Errorf("upsert subscription: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *serviceImpl) handleSubscriptionDeleted(ctx context.Context, event stripe.Event) error {
|
||||
var sub stripe.Subscription
|
||||
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||
return fmt.Errorf("decode subscription: %w", err)
|
||||
}
|
||||
|
||||
networkID := sub.Metadata["network_id"]
|
||||
if networkID == "" {
|
||||
return fmt.Errorf("subscription %s missing network_id metadata", sub.ID)
|
||||
}
|
||||
|
||||
if err := s.repo.deleteSubscriptionByID(ctx, sub.ID); err != nil {
|
||||
return fmt.Errorf("delete subscription: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func subscriptionFromStripe(sub *stripe.Subscription, networkID string) (*Subscription, error) {
|
||||
if len(sub.Items.Data) == 0 {
|
||||
return nil, fmt.Errorf("subscription %s has no items", sub.ID)
|
||||
}
|
||||
item := sub.Items.Data[0]
|
||||
cadence, err := cadenceFromInterval(item.Price)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("subscription %s: %w", sub.ID, err)
|
||||
}
|
||||
|
||||
var customerID string
|
||||
if sub.Customer != nil {
|
||||
customerID = sub.Customer.ID
|
||||
}
|
||||
|
||||
return &Subscription{
|
||||
ID: sub.ID,
|
||||
NetworkID: networkID,
|
||||
StripeCustomerID: customerID,
|
||||
Status: string(sub.Status),
|
||||
PriceID: item.Price.ID,
|
||||
Cadence: cadence,
|
||||
Quantity: int(item.Quantity),
|
||||
CancelAtPeriodEnd: sub.CancelAtPeriodEnd,
|
||||
CurrentPeriodStart: time.Unix(sub.CurrentPeriodStart, 0).UTC(),
|
||||
CurrentPeriodEnd: time.Unix(sub.CurrentPeriodEnd, 0).UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cadenceFromInterval(price *stripe.Price) (Cadence, error) {
|
||||
if price == nil || price.Recurring == nil {
|
||||
return "", fmt.Errorf("price is not recurring")
|
||||
}
|
||||
switch price.Recurring.Interval {
|
||||
case stripe.PriceRecurringIntervalMonth:
|
||||
return CadenceMonthly, nil
|
||||
case stripe.PriceRecurringIntervalYear:
|
||||
return CadenceAnnual, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported price interval %q", price.Recurring.Interval)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user