Files
enkly-ai/services/billing/internal/application/service.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

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
}
}