316 lines
9.4 KiB
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))
|
||
|
|
}
|