d262f734f0
* mobile: wire notification registration and listener
* implement backend components for push notifications
* refactor: agentic comment cleanup
* docs: use proper module name for particle processor
* set required env variables for push notifications
* bump version
* fix: always upsert push token on mobile start
* Revert "fix: always upsert push token on mobile start"
This reverts commit 90ff18a788.
* send push notifications regardless of online status
291 lines
8.3 KiB
Go
291 lines
8.3 KiB
Go
package billing
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/stripe/stripe-go/v85"
|
|
billingportalsession "github.com/stripe/stripe-go/v85/billingportal/session"
|
|
checkoutsession "github.com/stripe/stripe-go/v85/checkout/session"
|
|
stripecustomer "github.com/stripe/stripe-go/v85/customer"
|
|
stripeprice "github.com/stripe/stripe-go/v85/price"
|
|
stripesub "github.com/stripe/stripe-go/v85/subscription"
|
|
)
|
|
|
|
//go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go
|
|
|
|
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
|
|
|
|
// GetUsage reports today's freemium quota state for a network.
|
|
// Pro networks get Limit=nil (unlimited); free networks get Limit=&FreemiumDailyLimit.
|
|
GetUsage(ctx context.Context, networkID string) (*Usage, error)
|
|
IncrementDailyUsage(ctx context.Context, networkID string, at time.Time) error
|
|
}
|
|
|
|
var (
|
|
ErrNoActiveSubscription = errors.New("network has no stripe customer yet")
|
|
ErrInvalidCadence = errors.New("invalid billing cadence")
|
|
)
|
|
|
|
type serviceImpl struct {
|
|
cfg Config
|
|
repo repository
|
|
usageRepo usageRepository
|
|
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),
|
|
usageRepo: newUsageRepository(pool),
|
|
priceMonthlyCents: monthly.UnitAmount,
|
|
priceAnnualCents: annual.UnitAmount,
|
|
}, nil
|
|
}
|
|
|
|
// NewServiceForWorker skips Stripe client setup since workers only exercise
|
|
// the usage-tracking path. No API key required.
|
|
func NewServiceForWorker(pool *pgxpool.Pool) Service {
|
|
return &serviceImpl{
|
|
usageRepo: newUsageRepository(pool),
|
|
repo: newRepository(pool),
|
|
}
|
|
}
|
|
|
|
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) IncrementDailyUsage(ctx context.Context, networkID string, at time.Time) error {
|
|
return s.usageRepo.incrementDaily(ctx, networkID, at)
|
|
}
|
|
|
|
func (s *serviceImpl) GetUsage(ctx context.Context, networkID string) (*Usage, error) {
|
|
now := time.Now()
|
|
used, err := s.usageRepo.getDaily(ctx, networkID, now)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read daily usage: %w", err)
|
|
}
|
|
|
|
sub, err := s.GetStatus(ctx, networkID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to get network billing status: %w", err)
|
|
}
|
|
|
|
u := &Usage{
|
|
Plan: sub.Plan,
|
|
Used: used,
|
|
ResetAt: nextUTCMidnight(now),
|
|
Limit: nil,
|
|
}
|
|
if sub.Plan == PlanFree {
|
|
limit := FreemiumDailyLimit
|
|
u.Limit = &limit
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func nextUTCMidnight(now time.Time) time.Time {
|
|
utc := now.UTC()
|
|
return time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC).Add(24 * time.Hour)
|
|
}
|
|
|
|
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
|
|
}
|