package billing import ( "context" "encoding/json" "errors" "io" "net/http" "slices" "strings" "time" "bookra/apps/backend/internal/config" "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" paddle "github.com/PaddleHQ/paddle-go-sdk/v5" "github.com/jackc/pgx/v5" ) var ( ErrBillingMembership = errors.New("billing membership not found") ErrBillingPlanUnsupported = errors.New("billing plan is not configured") ErrBillingCustomerMissing = errors.New("billing customer is not available") ErrPaddleNotConfigured = errors.New("paddle is not configured") ErrPaddleSignatureMissing = errors.New("paddle signature is missing") ErrPaddleWebhookMissing = errors.New("paddle webhook secret is not configured") ) var allowedWebhookEvents = []string{ "subscription.created", "subscription.updated", "subscription.activated", "subscription.canceled", "subscription.paused", "subscription.resumed", "subscription.trialing", "transaction.completed", "transaction.updated", "transaction.payment_failed", "transaction.past_due", } type Service struct { cfg config.Config repo db.Repository client *paddle.SDK verifier *paddle.WebhookVerifier } type webhookEnvelope struct { EventID string `json:"event_id"` EventType string `json:"event_type"` Data struct { ID string `json:"id"` CustomerID string `json:"customer_id"` SubscriptionID string `json:"subscription_id"` CustomData map[string]any `json:"custom_data"` } `json:"data"` } func NewService(cfg config.Config, repo db.Repository) *Service { service := &Service{cfg: cfg, repo: repo} if strings.TrimSpace(cfg.PaddleAPIKey) != "" { var client *paddle.SDK var err error if cfg.PaddleEnvironment == "live" { client, err = paddle.New(cfg.PaddleAPIKey, paddle.WithBaseURL(paddle.ProductionBaseURL)) } else { client, err = paddle.NewSandbox(cfg.PaddleAPIKey) } if err == nil { service.client = client } } if strings.TrimSpace(cfg.PaddleWebhookKey) != "" { service.verifier = paddle.NewWebhookVerifier(cfg.PaddleWebhookKey, paddle.VerifierWithTimestampTolerance(5*time.Minute)) } return service } func (s *Service) GetSubscription(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) { membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.SubscriptionSnapshot{}, ErrBillingMembership } return domain.SubscriptionSnapshot{}, err } record, err := s.repo.GetSubscriptionSnapshot(ctx, membership.Tenant.ID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{ TenantID: membership.Tenant.ID, BillingProvider: "paddle", Status: firstNonEmpty(membership.Tenant.SubscriptionStatus, "inactive"), PlanCode: normalizePlanCode(membership.Tenant.PlanCode), Currency: "czk", }, s.cfg), nil } return domain.SubscriptionSnapshot{}, err } return toSnapshot(membership.Tenant, record, s.cfg), nil } func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Principal, planCode string, currency string) (domain.CheckoutLaunchResponse, error) { membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.CheckoutLaunchResponse{}, ErrBillingMembership } return domain.CheckoutLaunchResponse{}, err } priceID, resolvedPlanCode, resolvedCurrency := s.priceForPlan(planCode, currency) if priceID == "" { return domain.CheckoutLaunchResponse{}, ErrBillingPlanUnsupported } if !checkoutAvailable(s.cfg, resolvedPlanCode) { return domain.CheckoutLaunchResponse{}, ErrPaddleNotConfigured } return domain.CheckoutLaunchResponse{ PriceID: priceID, CustomerID: derefString(membership.Tenant.BillingCustomerID), CustomerEmail: strings.TrimSpace(principal.Email), SuccessRedirectURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=success", CancelRedirectURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=cancelled", CustomData: map[string]string{ "tenantId": membership.Tenant.ID, "tenantSlug": membership.Tenant.Slug, "userId": principal.Subject, "userEmail": strings.TrimSpace(principal.Email), "planCode": resolvedPlanCode, "currency": resolvedCurrency, "source": "bookra-dashboard", }, }, nil } func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) { membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.SubscriptionSnapshot{}, ErrBillingMembership } return domain.SubscriptionSnapshot{}, err } customerID := derefString(membership.Tenant.BillingCustomerID) if customerID == "" { return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{ TenantID: membership.Tenant.ID, BillingProvider: "paddle", Status: "inactive", PlanCode: normalizePlanCode(membership.Tenant.PlanCode), Currency: "czk", }, s.cfg), nil } if s.client == nil { return domain.SubscriptionSnapshot{}, ErrPaddleNotConfigured } record, err := s.syncPaddleData(ctx, membership.Tenant, customerID) if err != nil { return domain.SubscriptionSnapshot{}, err } return toSnapshot(membership.Tenant, record, s.cfg), nil } func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Principal) (domain.PortalSessionResponse, error) { membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.PortalSessionResponse{}, ErrBillingMembership } return domain.PortalSessionResponse{}, err } if s.client == nil { return domain.PortalSessionResponse{}, ErrPaddleNotConfigured } customerID := derefString(membership.Tenant.BillingCustomerID) if customerID == "" { return domain.PortalSessionResponse{}, ErrBillingCustomerMissing } request := &paddle.CreateCustomerPortalSessionRequest{CustomerID: customerID} if subscriptionID := derefString(membership.Tenant.BillingSubscription); subscriptionID != "" { request.SubscriptionIDs = []string{subscriptionID} } session, err := s.client.CreateCustomerPortalSession(ctx, request) if err != nil { return domain.PortalSessionResponse{}, err } url := strings.TrimSpace(session.URLs.General.Overview) if url == "" && len(session.URLs.Subscriptions) > 0 { url = firstNonEmpty( session.URLs.Subscriptions[0].UpdateSubscriptionPaymentMethod, session.URLs.Subscriptions[0].CancelSubscription, ) } if url == "" { return domain.PortalSessionResponse{}, ErrBillingCustomerMissing } return domain.PortalSessionResponse{URL: url}, nil } func (s *Service) HandleWebhook(ctx context.Context, req *http.Request) error { if s.verifier == nil { return ErrPaddleWebhookMissing } if strings.TrimSpace(req.Header.Get("Paddle-Signature")) == "" { return ErrPaddleSignatureMissing } ok, err := s.verifier.Verify(req) if err != nil { return err } if !ok { return errors.New("invalid paddle webhook signature") } payload, err := io.ReadAll(req.Body) if err != nil { return err } var event webhookEnvelope if err := json.Unmarshal(payload, &event); err != nil { return err } if !slices.Contains(allowedWebhookEvents, event.EventType) { return nil } tenant, customerID, err := s.resolveWebhookTenant(ctx, event) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil } return err } if tenant.ID == "" { return nil } inserted, err := s.repo.RecordBillingEvent(ctx, tenant.ID, "paddle", event.EventID, event.EventType, payload) if err != nil || !inserted { return err } if customerID != "" && derefString(tenant.BillingCustomerID) == "" { if err := s.repo.UpdateTenantBillingCustomerID(ctx, tenant.ID, customerID); err != nil { return err } tenant.BillingCustomerID = &customerID } customerID = firstNonEmpty(customerID, derefString(tenant.BillingCustomerID)) if customerID == "" || s.client == nil { return nil } _, err = s.syncPaddleData(ctx, tenant, customerID) return err } func (s *Service) resolveWebhookTenant(ctx context.Context, event webhookEnvelope) (db.TenantRecord, string, error) { customerID := strings.TrimSpace(event.Data.CustomerID) if tenantID := customDataString(event.Data.CustomData, "tenantId"); tenantID != "" { tenant, err := s.repo.GetTenantByID(ctx, tenantID) return tenant, customerID, err } if customerID != "" { tenant, err := s.repo.GetTenantByBillingCustomerID(ctx, customerID) return tenant, customerID, err } return db.TenantRecord{}, customerID, pgx.ErrNoRows } func (s *Service) syncPaddleData(ctx context.Context, tenant db.TenantRecord, customerID string) (db.BillingSnapshotRecord, error) { if s.client == nil { return db.BillingSnapshotRecord{}, ErrPaddleNotConfigured } subscriptions, err := s.client.ListSubscriptions(ctx, &paddle.ListSubscriptionsRequest{ CustomerID: []string{customerID}, }) if err != nil { return db.BillingSnapshotRecord{}, err } var selected *paddle.Subscription if err := subscriptions.Iter(ctx, func(subscription *paddle.Subscription) (bool, error) { if subscription == nil { return true, nil } if selected == nil || subscriptionRank(subscription) > subscriptionRank(selected) { selected = subscription } return true, nil }); err != nil { return db.BillingSnapshotRecord{}, err } now := time.Now().UTC() record := db.BillingSnapshotRecord{ TenantID: tenant.ID, BillingProvider: "paddle", BillingCustomerID: customerID, BillingSubscriptionID: "", Status: "inactive", PlanCode: normalizePlanCode(tenant.PlanCode), Currency: "czk", PriceID: "", LastSyncedAt: &now, } if selected != nil { record.BillingSubscriptionID = selected.ID record.Status = normalizeSubscriptionStatus(string(selected.Status)) record.Currency = normalizeCurrency(string(selected.CurrencyCode)) record.CancelAtPeriodEnd = selected.ScheduledChange != nil && string(selected.ScheduledChange.Action) == "cancel" record.CurrentPeriodStart = parseRFC3339Ptr(timePeriodStart(selected.CurrentBillingPeriod)) record.CurrentPeriodEnd = parseRFC3339Ptr(timePeriodEnd(selected.CurrentBillingPeriod)) if len(selected.Items) > 0 { record.PriceID = selected.Items[0].Price.ID record.PlanCode = s.planCodeForPrice(record.PriceID, tenant.PlanCode) } } if err := s.repo.UpsertSubscriptionSnapshot(ctx, record); err != nil { return db.BillingSnapshotRecord{}, err } if err := s.repo.UpdateTenantBillingState(ctx, tenant.ID, record.PlanCode, record.Status, record.BillingSubscriptionID); err != nil { return db.BillingSnapshotRecord{}, err } return record, nil } func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg config.Config) domain.SubscriptionSnapshot { if record.PlanCode == "" { record.PlanCode = normalizePlanCode(tenant.PlanCode) } else { record.PlanCode = normalizePlanCode(record.PlanCode) } record.Currency = normalizeCurrency(record.Currency) if record.Status == "" { record.Status = firstNonEmpty(tenant.SubscriptionStatus, "inactive") } customerID := firstNonEmpty(record.BillingCustomerID, derefString(tenant.BillingCustomerID)) return domain.SubscriptionSnapshot{ TenantID: tenant.ID, Provider: firstNonEmpty(record.BillingProvider, tenant.BillingProvider, "paddle"), CustomerID: customerID, SubscriptionID: firstNonEmpty(record.BillingSubscriptionID, derefString(tenant.BillingSubscription)), Status: record.Status, PlanCode: record.PlanCode, Currency: record.Currency, PriceID: record.PriceID, CancelAtPeriodEnd: record.CancelAtPeriodEnd, CurrentPeriodStart: record.CurrentPeriodStart, CurrentPeriodEnd: record.CurrentPeriodEnd, PaymentMethodBrand: record.PaymentMethodBrand, PaymentMethodLast4: record.PaymentMethodLast4, Entitlements: entitlementsForPlan(record.PlanCode), DisplayPrices: displayPricesForPlan(record.PlanCode), TrialDays: 30, LastSyncedAt: record.LastSyncedAt, CheckoutURLAvailable: checkoutAvailable(cfg, record.PlanCode), SyncAvailable: cfg.PaddleConfigured(), PortalAvailable: cfg.PaddleConfigured() && customerID != "", } } func entitlementsForPlan(planCode string) domain.PlanEntitlements { switch normalizePlanCode(planCode) { case "starter": return domain.PlanEntitlements{MaxLocations: 1, MaxStaff: 3, EmailReminders: true, AdvancedReporting: false, WidgetEmbedding: true, UmamiTracking: false} case "business": return domain.PlanEntitlements{MaxLocations: 10, MaxStaff: 30, EmailReminders: true, AdvancedReporting: true, WidgetEmbedding: true, UmamiTracking: true} default: return domain.PlanEntitlements{MaxLocations: 3, MaxStaff: 10, EmailReminders: true, AdvancedReporting: true, WidgetEmbedding: true, UmamiTracking: true} } } func (s *Service) planCodeForPrice(priceID string, fallback string) string { for planCode, currencies := range s.cfg.PaddlePriceMatrix { for _, configuredPriceID := range currencies { if configuredPriceID != "" && configuredPriceID == priceID { return normalizePlanCode(planCode) } } } return normalizePlanCode(fallback) } func (s *Service) priceForPlan(planCode string, currency string) (string, string, string) { resolvedPlan := normalizePlanCode(strings.TrimSpace(planCode)) if resolvedPlan == "" { resolvedPlan = "pro" } resolvedCurrency := normalizeCurrency(currency) if priceID := s.cfg.PaddlePriceMatrix[resolvedPlan][resolvedCurrency]; priceID != "" { return priceID, resolvedPlan, resolvedCurrency } if resolvedCurrency != "czk" { if priceID := s.cfg.PaddlePriceMatrix[resolvedPlan]["czk"]; priceID != "" { return priceID, resolvedPlan, "czk" } } return s.cfg.PaddlePriceMatrix[resolvedPlan]["usd"], resolvedPlan, "usd" } func subscriptionRank(subscription *paddle.Subscription) int { switch subscription.Status { case paddle.SubscriptionStatusActive: return 6 case paddle.SubscriptionStatusTrialing: return 5 case paddle.SubscriptionStatusPastDue: return 4 case paddle.SubscriptionStatusPaused: return 3 case paddle.SubscriptionStatusCanceled: return 2 default: return 1 } } func displayPricesForPlan(planCode string) []domain.PlanDisplayPrice { switch normalizePlanCode(planCode) { case "starter": return []domain.PlanDisplayPrice{ {Currency: "czk", AmountCents: 11900, Formatted: "119 Kc"}, {Currency: "usd", AmountCents: 500, Formatted: "$5"}, } case "business": return []domain.PlanDisplayPrice{ {Currency: "czk", AmountCents: 119900, Formatted: "1 199 Kc"}, {Currency: "usd", AmountCents: 5000, Formatted: "$50"}, } default: return []domain.PlanDisplayPrice{ {Currency: "czk", AmountCents: 49900, Formatted: "499 Kc"}, {Currency: "usd", AmountCents: 2000, Formatted: "$20"}, } } } func normalizePlanCode(planCode string) string { switch strings.TrimSpace(planCode) { case "growth": return "pro" case "multi-location": return "business" default: return strings.TrimSpace(planCode) } } func normalizeCurrency(currency string) string { switch strings.ToLower(strings.TrimSpace(currency)) { case "usd": return "usd" case "eur": return "eur" default: return "czk" } } func normalizeSubscriptionStatus(status string) string { switch strings.TrimSpace(strings.ToLower(status)) { case "active", "trialing", "past_due", "paused", "canceled": return strings.TrimSpace(strings.ToLower(status)) default: return "inactive" } } func checkoutAvailable(cfg config.Config, planCode string) bool { if !cfg.PaddleConfigured() || !cfg.PaddleWebhookConfigured() { return false } planCode = normalizePlanCode(planCode) for _, priceID := range cfg.PaddlePriceMatrix[planCode] { if strings.TrimSpace(priceID) != "" { return true } } return false } func customDataString(data map[string]any, key string) string { if data == nil { return "" } value, ok := data[key] if !ok { return "" } switch typed := value.(type) { case string: return strings.TrimSpace(typed) default: return "" } } func parseRFC3339Ptr(value string) *time.Time { if strings.TrimSpace(value) == "" { return nil } parsed, err := time.Parse(time.RFC3339, value) if err != nil { return nil } utc := parsed.UTC() return &utc } func timePeriodStart(period *paddle.TimePeriod) string { if period == nil { return "" } return period.StartsAt } func timePeriodEnd(period *paddle.TimePeriod) string { if period == nil { return "" } return period.EndsAt } func derefString(value *string) string { if value == nil { return "" } return *value } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" }