feat: implement freemium restrictions
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stripe/stripe-go/v85"
|
||||
@@ -22,6 +23,14 @@ type Service interface {
|
||||
// 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 is called by the particle processor worker for each
|
||||
// qualifying particle (non-container). Idempotency is the caller's concern
|
||||
// — the worker guards this via processed_particles.
|
||||
IncrementDailyUsage(ctx context.Context, networkID string, at time.Time) error
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -32,6 +41,7 @@ var (
|
||||
type serviceImpl struct {
|
||||
cfg Config
|
||||
repo repository
|
||||
usageRepo usageRepository
|
||||
priceMonthlyCents int64
|
||||
priceAnnualCents int64
|
||||
}
|
||||
@@ -58,11 +68,22 @@ func NewService(ctx context.Context, pool *pgxpool.Pool, cfg Config) (Service, e
|
||||
return &serviceImpl{
|
||||
cfg: cfg,
|
||||
repo: newRepository(pool),
|
||||
usageRepo: newUsageRepository(pool),
|
||||
priceMonthlyCents: monthly.UnitAmount,
|
||||
priceAnnualCents: annual.UnitAmount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewServiceForWorker builds a minimal billing Service suitable for the
|
||||
// particle processor worker: only the usage-tracking path is exercised, so
|
||||
// we skip Stripe client setup (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) {
|
||||
@@ -208,6 +229,59 @@ func (s *serviceImpl) SyncSeats(ctx context.Context, networkID string, seats int
|
||||
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)
|
||||
}
|
||||
|
||||
plan, err := s.resolvePlan(ctx, networkID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u := &Usage{
|
||||
Plan: plan,
|
||||
Used: used,
|
||||
ResetAt: nextUTCMidnight(now),
|
||||
}
|
||||
if plan == PlanFree {
|
||||
limit := FreemiumDailyLimit
|
||||
u.Limit = &limit
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// resolvePlan is a lightweight read: it infers free/pro from the local
|
||||
// subscription row without hitting Stripe, so it is safe to call from the
|
||||
// particle processor worker (no Stripe client required).
|
||||
func (s *serviceImpl) resolvePlan(ctx context.Context, networkID string) (Plan, error) {
|
||||
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return PlanFree, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch stripe.SubscriptionStatus(sub.Status) {
|
||||
case stripe.SubscriptionStatusActive,
|
||||
stripe.SubscriptionStatusTrialing,
|
||||
stripe.SubscriptionStatusPastDue:
|
||||
return PlanPro, nil
|
||||
}
|
||||
return PlanFree, 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 {
|
||||
|
||||
Reference in New Issue
Block a user