321 lines
11 KiB
Go
321 lines
11 KiB
Go
// Package application holds billing's use cases, transplanted from
|
|
// internal/handlers/billing.go with the html/template "planView"
|
|
// rendering left out — that's the gateway's job now (it calls
|
|
// GetPlanCatalog + GetOrCreateSubscription and builds the page itself),
|
|
// since presentation never belongs in a bounded context's core.
|
|
package application
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"enkly/pkg/eventbus"
|
|
"enkly/pkg/eventbus/events"
|
|
"enkly/services/billing/internal/domain"
|
|
"enkly/services/billing/internal/infrastructure/stripeapi"
|
|
)
|
|
|
|
type Publisher interface {
|
|
PublishBestEffort(ctx context.Context, routingKey string, payload any)
|
|
}
|
|
|
|
type Service struct {
|
|
Subscriptions domain.SubscriptionRepository
|
|
StripeEvents domain.StripeEventRepository
|
|
Usage domain.UsageRepository
|
|
Stripe *stripeapi.Client
|
|
WebhookSecret string
|
|
Events Publisher
|
|
Logger *slog.Logger
|
|
|
|
// GetPriceEnvVar resolves a plan's configured Stripe Price ID env
|
|
// var at startup time (see cmd/server/main.go) — injected rather
|
|
// than read from os.Getenv directly, same reasoning as the
|
|
// monolith's billing.PriceIDs(getenv).
|
|
GetEnv func(key string) string
|
|
|
|
priceMu sync.Mutex
|
|
planPrices map[domain.Plan]string
|
|
stripePrices map[string]domain.Plan
|
|
}
|
|
|
|
// GetOrCreateSubscription replaces db.GetOrCreateSubscription.
|
|
func (s *Service) GetOrCreateSubscription(ctx context.Context, userID int64) (*domain.Subscription, error) {
|
|
return s.Subscriptions.GetOrCreate(ctx, userID)
|
|
}
|
|
|
|
// PlanCatalogEntry is GetPlanCatalog's per-plan result.
|
|
type PlanCatalogEntry struct {
|
|
domain.PlanInfo
|
|
Available bool
|
|
}
|
|
|
|
// GetPlanCatalog replaces the plan-availability half of
|
|
// buildSubscriptionData: Free is always available, every paid plan is
|
|
// available whenever Stripe itself is configured (its Price is created
|
|
// on demand — see planPrice/EnsurePrice).
|
|
func (s *Service) GetPlanCatalog(ctx context.Context) []PlanCatalogEntry {
|
|
out := make([]PlanCatalogEntry, 0, len(domain.Catalog))
|
|
for _, p := range domain.Catalog {
|
|
out = append(out, PlanCatalogEntry{
|
|
PlanInfo: p,
|
|
Available: p.Plan == domain.PlanFree || s.Stripe.Configured(),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// CreateCheckoutSession replaces Handlers.CreateCheckout (minus the
|
|
// "plan==free redirects to the portal" edge case, which the gateway
|
|
// handles at the edge since it's pure routing, not a billing decision).
|
|
func (s *Service) CreateCheckoutSession(ctx context.Context, userID int64, email string, plan domain.Plan, successURL, cancelURL string) (string, error) {
|
|
if !s.Stripe.Configured() {
|
|
return "", fmt.Errorf("billing: STRIPE_SECRET_KEY is not configured")
|
|
}
|
|
priceID, err := s.planPrice(ctx, plan)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
sub, err := s.Subscriptions.GetOrCreate(ctx, userID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
_, checkoutURL, err := s.Stripe.CreateCheckoutSession(ctx, stripeapi.CheckoutParams{
|
|
PriceID: priceID,
|
|
CustomerID: sub.StripeCustomerID,
|
|
CustomerEmail: email,
|
|
ClientReferenceID: strconv.FormatInt(userID, 10),
|
|
UserID: userID,
|
|
SuccessURL: successURL,
|
|
CancelURL: cancelURL,
|
|
})
|
|
return checkoutURL, err
|
|
}
|
|
|
|
// SyncCheckoutSession replaces Handlers.syncCheckoutSession.
|
|
func (s *Service) SyncCheckoutSession(ctx context.Context, userID int64, sessionID string) (*domain.Subscription, error) {
|
|
sess, err := s.Stripe.GetCheckoutSession(ctx, sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if sess.ClientReferenceID != strconv.FormatInt(userID, 10) {
|
|
return nil, fmt.Errorf("session %s does not belong to user %d", sess.ID, userID)
|
|
}
|
|
if sess.Subscription == "" || sess.Customer == "" {
|
|
return nil, fmt.Errorf("session %s has no subscription yet", sess.ID)
|
|
}
|
|
stripeSub, err := s.Stripe.GetSubscription(ctx, sess.Subscription)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
plan, ok := s.planForPrice(ctx, stripeSub.PriceID())
|
|
if !ok {
|
|
return nil, fmt.Errorf("subscription %s: unrecognized price %q", stripeSub.ID, stripeSub.PriceID())
|
|
}
|
|
var periodEnd time.Time
|
|
if unix := stripeSub.PeriodEndUnix(); unix > 0 {
|
|
periodEnd = time.Unix(unix, 0)
|
|
}
|
|
if err := s.Subscriptions.ApplyStripeSubscriptionForUser(ctx, userID, sess.Customer, stripeSub.ID, stripeSub.PriceID(),
|
|
string(plan), stripeSub.Status, periodEnd, stripeSub.CancelAtPeriodEnd); err != nil {
|
|
return nil, err
|
|
}
|
|
s.publishChanged(ctx, userID, plan, stripeSub.Status, "checkout.session.completed")
|
|
return s.Subscriptions.GetOrCreate(ctx, userID)
|
|
}
|
|
|
|
// CreateBillingPortalSession replaces Handlers.BillingPortal.
|
|
func (s *Service) CreateBillingPortalSession(ctx context.Context, userID int64, returnURL string) (string, error) {
|
|
if !s.Stripe.Configured() {
|
|
return "", fmt.Errorf("billing: STRIPE_SECRET_KEY is not configured")
|
|
}
|
|
sub, err := s.Subscriptions.GetOrCreate(ctx, userID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if sub.StripeCustomerID == "" {
|
|
return "", errNoBillingAccount
|
|
}
|
|
return s.Stripe.CreateBillingPortalSession(ctx, sub.StripeCustomerID, returnURL)
|
|
}
|
|
|
|
var errNoBillingAccount = errors.New("billing: no stripe customer on file yet")
|
|
|
|
// IsNoBillingAccount reports whether err is the "subscribe to a paid
|
|
// plan first" case Handlers.BillingPortal used to check for via a bare
|
|
// StripeCustomerID == "" comparison.
|
|
func IsNoBillingAccount(err error) bool { return errors.Is(err, errNoBillingAccount) }
|
|
|
|
// CheckAndIncrementUsage replaces the daily-cap check inlined in
|
|
// Handlers.Chat, now its own use case since the agent service calls it
|
|
// once per turn over gRPC.
|
|
func (s *Service) CheckAndIncrementUsage(ctx context.Context, userID int64) (allowed bool, used, limit int, planName string, err error) {
|
|
sub, err := s.Subscriptions.GetOrCreate(ctx, userID)
|
|
if err != nil {
|
|
return false, 0, 0, "", err
|
|
}
|
|
info := domain.Get(sub.Plan)
|
|
if info.DailyMessageLimit == 0 {
|
|
// Unlimited plan: still counted (for the usage page), never capped.
|
|
n, err := s.Usage.IncrementDailyUsage(ctx, userID)
|
|
return true, n, 0, info.Name, err
|
|
}
|
|
used, err = s.Usage.DailyUsage(ctx, userID)
|
|
if err != nil {
|
|
return false, 0, info.DailyMessageLimit, info.Name, err
|
|
}
|
|
if used >= info.DailyMessageLimit {
|
|
return false, used, info.DailyMessageLimit, info.Name, nil
|
|
}
|
|
n, err := s.Usage.IncrementDailyUsage(ctx, userID)
|
|
return true, n, info.DailyMessageLimit, info.Name, err
|
|
}
|
|
|
|
// GetDailyUsage replaces db.DailyUsage: a read-only lookup for the
|
|
// /subscription page, which must not itself count as a sent message.
|
|
func (s *Service) GetDailyUsage(ctx context.Context, userID int64) (int, error) {
|
|
return s.Usage.DailyUsage(ctx, userID)
|
|
}
|
|
|
|
func (s *Service) publishChanged(ctx context.Context, userID int64, plan domain.Plan, status, eventType string) {
|
|
s.Events.PublishBestEffort(ctx, eventbus.RoutingKeySubscriptionChanged, events.SubscriptionChanged{
|
|
UserID: userID, Plan: string(plan), Status: status, EventType: eventType,
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Price resolution cache — replaces Handlers.planPrice / planForPrice.
|
|
// ---------------------------------------------------------------------
|
|
|
|
func (s *Service) planPrice(ctx context.Context, plan domain.Plan) (string, error) {
|
|
s.priceMu.Lock()
|
|
defer s.priceMu.Unlock()
|
|
if id, ok := s.planPrices[plan]; ok && id != "" {
|
|
return id, nil
|
|
}
|
|
id, err := s.Stripe.EnsurePrice(ctx, stripeapi.Info{
|
|
Plan: string(domain.Get(string(plan)).Plan), Name: domain.Get(string(plan)).Name, PriceEUR: domain.Get(string(plan)).PriceEUR,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if s.planPrices == nil {
|
|
s.planPrices = map[domain.Plan]string{}
|
|
}
|
|
if s.stripePrices == nil {
|
|
s.stripePrices = map[string]domain.Plan{}
|
|
}
|
|
s.planPrices[plan] = id
|
|
s.stripePrices[id] = plan
|
|
return id, nil
|
|
}
|
|
|
|
func (s *Service) planForPrice(ctx context.Context, priceID string) (domain.Plan, bool) {
|
|
s.priceMu.Lock()
|
|
plan, ok := s.stripePrices[priceID]
|
|
s.priceMu.Unlock()
|
|
if ok || !s.Stripe.Configured() {
|
|
return plan, ok
|
|
}
|
|
for _, p := range domain.Catalog {
|
|
if p.PriceEnvVar == "" {
|
|
continue
|
|
}
|
|
if _, err := s.planPrice(ctx, p.Plan); err != nil {
|
|
s.Logger.Warn("resolve stripe price", "plan", p.Name, "err", err)
|
|
}
|
|
}
|
|
s.priceMu.Lock()
|
|
defer s.priceMu.Unlock()
|
|
plan, ok = s.stripePrices[priceID]
|
|
return plan, ok
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Stripe webhook ingestion — replaces Handlers.StripeWebhook / applyStripeEvent.
|
|
// ---------------------------------------------------------------------
|
|
|
|
func (s *Service) IngestStripeWebhook(ctx context.Context, payload []byte, sigHeader string) error {
|
|
ev, err := stripeapi.ConstructEvent(payload, sigHeader, s.WebhookSecret)
|
|
if err != nil {
|
|
return fmt.Errorf("stripe webhook rejected: %w", err)
|
|
}
|
|
firstTime, err := s.StripeEvents.RecordIfNew(ctx, ev.ID, ev.Type)
|
|
if err != nil {
|
|
return fmt.Errorf("record stripe event: %w", err)
|
|
}
|
|
if !firstTime {
|
|
return nil // already applied on an earlier delivery
|
|
}
|
|
if err := s.applyStripeEvent(ctx, ev); err != nil {
|
|
// Logged, not propagated: the event is already recorded as
|
|
// seen, so returning an error here would just make the
|
|
// gateway tell Stripe to retry a delivery that (from Stripe's
|
|
// side) already succeeded — see the monolith's identical
|
|
// comment in StripeWebhook.
|
|
s.Logger.Error("apply stripe event", "err", err, "type", ev.Type, "event_id", ev.ID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) applyStripeEvent(ctx context.Context, ev *stripeapi.Event) error {
|
|
switch ev.Type {
|
|
case "checkout.session.completed":
|
|
var sess stripeapi.CheckoutSession
|
|
if err := json.Unmarshal(ev.Data.Object, &sess); err != nil {
|
|
return fmt.Errorf("decode checkout session: %w", err)
|
|
}
|
|
if sess.Customer == "" {
|
|
return nil
|
|
}
|
|
userID, err := strconv.ParseInt(sess.ClientReferenceID, 10, 64)
|
|
if err != nil {
|
|
return fmt.Errorf("checkout session %s: unusable client_reference_id %q", sess.ID, sess.ClientReferenceID)
|
|
}
|
|
return s.Subscriptions.SetStripeCustomer(ctx, userID, sess.Customer)
|
|
|
|
case "customer.subscription.created", "customer.subscription.updated":
|
|
var st stripeapi.StripeSubscription
|
|
if err := json.Unmarshal(ev.Data.Object, &st); err != nil {
|
|
return fmt.Errorf("decode subscription: %w", err)
|
|
}
|
|
plan, ok := s.planForPrice(ctx, st.PriceID())
|
|
if !ok {
|
|
return fmt.Errorf("subscription %s: unrecognized price %q (check the STRIPE_PRICE_* env vars)", st.ID, st.PriceID())
|
|
}
|
|
var periodEnd time.Time
|
|
if unix := st.PeriodEndUnix(); unix > 0 {
|
|
periodEnd = time.Unix(unix, 0)
|
|
}
|
|
err := s.Subscriptions.ApplyStripeSubscription(ctx, st.Customer, st.ID, st.PriceID(), string(plan), st.Status, periodEnd, st.CancelAtPeriodEnd)
|
|
if errors.Is(err, domain.ErrNotFound) {
|
|
userID, perr := strconv.ParseInt(st.Metadata["user_id"], 10, 64)
|
|
if perr != nil {
|
|
return fmt.Errorf("subscription %s: no matching customer row and no usable metadata.user_id", st.ID)
|
|
}
|
|
err = s.Subscriptions.ApplyStripeSubscriptionForUser(ctx, userID, st.Customer, st.ID, st.PriceID(), string(plan), st.Status, periodEnd, st.CancelAtPeriodEnd)
|
|
if err == nil {
|
|
s.publishChanged(ctx, userID, plan, st.Status, ev.Type)
|
|
}
|
|
return err
|
|
}
|
|
return err
|
|
|
|
case "customer.subscription.deleted":
|
|
var st stripeapi.StripeSubscription
|
|
if err := json.Unmarshal(ev.Data.Object, &st); err != nil {
|
|
return fmt.Errorf("decode subscription: %w", err)
|
|
}
|
|
return s.Subscriptions.CancelByStripeSubscriptionID(ctx, st.ID)
|
|
|
|
default:
|
|
return nil
|
|
}
|
|
}
|