thread-master/apps/backend/internal/module/billing/service.go

316 lines
9.4 KiB
Go

package billing
import (
"context"
"fmt"
"net/url"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
type Service struct {
Repo Repository
Provider Provider
Enabled bool
WebhookSecret string
StarterPriceID string
ProPriceID string
SuccessURL string
CancelURL string
PortalReturnURL string
Now func() time.Time
}
func (s *Service) available() error {
if s == nil || !s.Enabled || s.Repo == nil || s.Provider == nil || strings.TrimSpace(s.StarterPriceID) == "" || strings.TrimSpace(s.ProPriceID) == "" || strings.TrimSpace(s.SuccessURL) == "" || strings.TrimSpace(s.CancelURL) == "" || strings.TrimSpace(s.PortalReturnURL) == "" {
return ErrDisabled
}
return nil
}
func (s *Service) PriceForPlan(plan string) (string, error) {
switch strings.ToLower(strings.TrimSpace(plan)) {
case PlanStarter:
return s.StarterPriceID, nil
case PlanPro:
return s.ProPriceID, nil
default:
return "", ErrInvalidPlan
}
}
func (s *Service) PlanForPrice(price string) (string, error) {
switch price {
case s.StarterPriceID:
return PlanStarter, nil
case s.ProPriceID:
return PlanPro, nil
default:
return "", ErrInvalidPlan
}
}
func (s *Service) CreateCheckout(ctx context.Context, uid int64, planID, requestID string) (*CheckoutAttempt, error) {
if err := s.available(); err != nil {
return nil, err
}
requestID = strings.TrimSpace(requestID)
if uid <= 0 || requestID == "" || len(requestID) > 128 {
return nil, ErrInvalidRequest
}
priceID, err := s.PriceForPlan(planID)
if err != nil {
return nil, err
}
now := time.Now().UTC()
if s.Now != nil {
now = s.Now().UTC()
}
a := &CheckoutAttempt{ID: uuid.NewString(), UID: uid, RequestID: requestID, PlanID: strings.ToLower(strings.TrimSpace(planID)), PriceID: priceID, Status: "creating", FulfillmentStatus: "pending", CreatedAt: now.UnixNano(), UpdatedAt: now.UnixNano()}
inserted, err := s.Repo.InsertCheckout(ctx, a)
if err != nil {
return nil, err
}
if !inserted {
existing, err := s.Repo.GetCheckoutByRequest(ctx, uid, requestID)
if err != nil {
return nil, err
}
if existing.PlanID != a.PlanID {
return nil, ErrConflict
}
if existing.SessionID != "" {
return existing, nil
}
a = existing
}
idempotencyKey := fmt.Sprintf("billing-checkout-%d-%s", uid, requestID)
state, err := s.Repo.GetSubscription(ctx, uid)
if err != nil {
return nil, err
}
successURL := replaceURLTokens(s.SuccessURL, a.ID, a.PlanID)
cancelURL := replaceURLTokens(s.CancelURL, a.ID, a.PlanID)
co, err := s.Provider.CreateCheckout(ctx, CheckoutParams{UID: uid, AttemptID: a.ID, PlanID: a.PlanID, PriceID: priceID, CustomerID: state.CustomerID, SuccessURL: successURL, CancelURL: cancelURL, IdempotencyKey: idempotencyKey})
if err != nil {
a.Status, a.Error, a.UpdatedAt = "failed", err.Error(), time.Now().UTC().UnixNano()
_ = s.Repo.UpdateCheckout(ctx, a)
return nil, err
}
s.applyCheckout(a, co)
if err := s.Repo.UpdateCheckout(ctx, a); err != nil {
return nil, err
}
return a, nil
}
func (s *Service) GetCheckout(ctx context.Context, uid int64, id string) (*CheckoutAttempt, error) {
if err := s.available(); err != nil {
return nil, err
}
a, err := s.Repo.GetCheckout(ctx, uid, id)
if err != nil {
return nil, err
}
if a.SessionID == "" {
return a, nil
}
co, err := s.Provider.GetCheckout(ctx, a.SessionID)
if err != nil {
return nil, err
}
s.applyCheckout(a, co)
if co.PriceID != "" {
plan, mapErr := s.PlanForPrice(co.PriceID)
if mapErr != nil {
return nil, mapErr
}
a.PlanID = plan
}
_ = s.Repo.UpdateCheckout(ctx, a)
return a, nil
}
func (s *Service) applyCheckout(a *CheckoutAttempt, co *ProviderCheckout) {
a.SessionID, a.URL, a.ExpiresAt = co.SessionID, co.URL, co.ExpiresAt
a.Status, a.PaymentStatus = co.Status, co.PaymentStatus
a.CustomerID, a.SubscriptionID = co.CustomerID, co.SubscriptionID
if co.PriceID != "" {
a.PriceID = co.PriceID
}
a.UpdatedAt = time.Now().UTC().UnixNano()
}
func (s *Service) Subscription(ctx context.Context, uid int64) (*SubscriptionState, error) {
if s == nil || s.Repo == nil {
return nil, ErrDisabled
}
return s.Repo.GetSubscription(ctx, uid)
}
func (s *Service) Portal(ctx context.Context, uid int64) (string, error) {
if err := s.available(); err != nil {
return "", err
}
state, err := s.Repo.GetSubscription(ctx, uid)
if err != nil {
return "", err
}
if state.CustomerID == "" {
return "", ErrCustomerRequired
}
return s.Provider.CreatePortal(ctx, state.CustomerID, s.PortalReturnURL)
}
func (s *Service) HandleWebhook(ctx context.Context, payload []byte, signature string) error {
if err := s.available(); err != nil {
return err
}
if strings.TrimSpace(s.WebhookSecret) == "" {
return ErrDisabled
}
e, err := s.Provider.VerifyEvent(payload, signature, s.WebhookSecret)
if err != nil {
return err
}
done, err := s.Repo.WebhookProcessed(ctx, e.ID)
if err != nil || done {
return err
}
if err := s.processEvent(ctx, e); err != nil {
return err
}
return s.Repo.MarkWebhookProcessed(ctx, e.ID, e.Type, e.Created)
}
func (s *Service) processEvent(ctx context.Context, e *Event) error {
switch e.Type {
case "checkout.session.expired", "checkout.session.async_payment_failed":
a, err := s.Repo.GetCheckoutBySession(ctx, e.ObjectID)
if err != nil {
return err
}
a.Status = strings.TrimPrefix(e.Type, "checkout.session.")
a.FulfillmentStatus = "failed"
a.UpdatedAt = time.Now().UTC().UnixNano()
return s.Repo.UpdateCheckout(ctx, a)
case "checkout.session.completed", "checkout.session.async_payment_succeeded":
return s.fulfillCheckout(ctx, e)
case "customer.subscription.updated", "customer.subscription.deleted":
return s.applySubscriptionEvent(ctx, e)
case "invoice.paid":
return s.applyInvoiceEvent(ctx, e)
default:
return nil
}
}
func (s *Service) fulfillCheckout(ctx context.Context, e *Event) error {
a, err := s.Repo.GetCheckoutBySession(ctx, e.ObjectID)
if err != nil {
return err
}
co, err := s.Provider.GetCheckout(ctx, e.ObjectID)
if err != nil {
return err
}
plan, err := s.PlanForPrice(co.PriceID)
if err != nil {
return err
}
if co.PaymentStatus != "paid" && co.PaymentStatus != "no_payment_required" {
s.applyCheckout(a, co)
a.FulfillmentStatus = "pending"
return s.Repo.UpdateCheckout(ctx, a)
}
sub, err := s.Provider.GetSubscription(ctx, co.SubscriptionID)
if err != nil {
return err
}
if sub.PriceID != co.PriceID {
return ErrInvalidPlan
}
s.applyCheckout(a, co)
if err := s.apply(ctx, e, a.UID, plan, sub, a.SessionID); err != nil {
return err
}
a.PlanID, a.FulfillmentStatus = plan, "fulfilled"
return s.Repo.UpdateCheckout(ctx, a)
}
func (s *Service) applySubscriptionEvent(ctx context.Context, e *Event) error {
uid, err := s.Repo.FindUIDByCustomer(ctx, e.CustomerID)
if err != nil {
return err
}
plan := PlanFree
if e.Type != "customer.subscription.deleted" {
plan, err = s.PlanForPrice(e.PriceID)
if err != nil {
return err
}
} else if e.PriceID != "" {
if mapped, mapErr := s.PlanForPrice(e.PriceID); mapErr == nil {
plan = mapped
}
} else if state, stateErr := s.Repo.GetSubscription(ctx, uid); stateErr == nil && state.PaidPlanID != "" {
plan = state.PaidPlanID
}
sub := &ProviderSubscription{ID: e.ObjectID, CustomerID: e.CustomerID, Status: e.Status, PriceID: e.PriceID, CurrentPeriodStart: e.PeriodStart, CurrentPeriodEnd: e.PeriodEnd, CancelAtPeriodEnd: e.CancelAtPeriodEnd}
if e.Type == "customer.subscription.deleted" {
sub.Status = "canceled"
}
return s.apply(ctx, e, uid, plan, sub, "")
}
func (s *Service) applyInvoiceEvent(ctx context.Context, e *Event) error {
uid, err := s.Repo.FindUIDByCustomer(ctx, e.CustomerID)
if err != nil {
return err
}
subscriptionID := e.SubscriptionID
if subscriptionID == "" {
state, stateErr := s.Repo.GetSubscription(ctx, uid)
if stateErr != nil {
return stateErr
}
subscriptionID = state.SubscriptionID
}
sub, err := s.Provider.GetSubscription(ctx, subscriptionID)
if err != nil {
return err
}
plan, err := s.PlanForPrice(sub.PriceID)
if err != nil {
return err
}
if sub.CustomerID == "" {
sub.CustomerID = e.CustomerID
}
sub.Status = "active"
return s.apply(ctx, e, uid, plan, sub, "")
}
func (s *Service) apply(ctx context.Context, e *Event, uid int64, plan string, sub *ProviderSubscription, sessionID string) error {
activePlan := plan
if sub.Status != "active" && sub.Status != "trialing" {
activePlan = PlanFree
}
_, err := s.Repo.ApplyEntitlement(ctx, EntitlementUpdate{EventID: e.ID, EventCreatedAt: e.Created * int64(time.Second), UID: uid, PlanID: activePlan, PaidPlanID: plan, Status: sub.Status, CustomerID: sub.CustomerID, SubscriptionID: sub.ID, CurrentPeriodStart: sub.CurrentPeriodStart * int64(time.Second), CurrentPeriodEnd: sub.CurrentPeriodEnd * int64(time.Second), CancelAtPeriodEnd: sub.CancelAtPeriodEnd})
if err != nil {
return err
}
// Audit upsert must still run when entitlement was already applied.
return s.Repo.UpsertPurchase(ctx, &PurchaseAudit{ID: e.ID, UID: uid, PlanID: plan, StripeEventID: e.ID, StripeSessionID: sessionID, StripeCustomerID: sub.CustomerID, StripeSubscriptionID: sub.ID, Status: sub.Status, CreatedAt: e.Created * int64(time.Second)})
}
func ParseUID(v string) (int64, error) { return strconv.ParseInt(v, 10, 64) }
func replaceURLTokens(value, checkoutID, planID string) string {
value = strings.ReplaceAll(value, "{CHECKOUT_ID}", url.QueryEscape(checkoutID))
return strings.ReplaceAll(value, "{PLAN_ID}", url.QueryEscape(planID))
}