package billing import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "slices" "strings" "time" "bookra/apps/backend/internal/config" "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" "bookra/apps/backend/internal/shared" paddle "github.com/PaddleHQ/paddle-go-sdk/v5" "github.com/jackc/pgx/v5" "github.com/stripe/stripe-go/v81" portalsession "github.com/stripe/stripe-go/v81/billingportal/session" checkoutsession "github.com/stripe/stripe-go/v81/checkout/session" "github.com/stripe/stripe-go/v81/customer" "github.com/stripe/stripe-go/v81/subscription" "github.com/stripe/stripe-go/v81/webhook" ) 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") ErrStripeNotConfigured = errors.New("stripe is not configured") ErrStripeSignatureMissing = errors.New("stripe signature is missing") ErrStripeWebhookMissing = errors.New("stripe webhook secret is not configured") ) var allowedPaddleWebhookEvents = []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", } var allowedStripeWebhookEvents = []stripe.EventType{ stripe.EventTypeCheckoutSessionCompleted, stripe.EventTypeCustomerSubscriptionCreated, stripe.EventTypeCustomerSubscriptionUpdated, stripe.EventTypeCustomerSubscriptionDeleted, stripe.EventTypeInvoicePaid, stripe.EventTypeInvoicePaymentFailed, stripe.EventTypePaymentIntentSucceeded, stripe.EventTypePaymentIntentPaymentFailed, } type Service struct { cfg config.Config repo db.Repository client *paddle.SDK verifier *paddle.WebhookVerifier stripeEnabled bool } 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} // Initialize Paddle client 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 } } // Initialize Stripe if strings.TrimSpace(cfg.StripeAPIKey) != "" { stripe.Key = cfg.StripeAPIKey service.stripeEnabled = true } return service } // GetEntitlements returns the plan entitlements for a tenant (used by other services for limit enforcement) func (s *Service) GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error) { tenant, err := s.repo.GetTenantByID(ctx, tenantID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { // Default to Pro entitlements for tenants without billing return entitlementsForPlan("pro"), nil } return domain.PlanEntitlements{}, err } return entitlementsForPlan(tenant.PlanCode), nil } 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: s.cfg.BillingProvider(), Status: firstNonEmpty(membership.Tenant.SubscriptionStatus, "inactive"), PlanCode: shared.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, billingInterval 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 } // Default to monthly if not specified if billingInterval == "" { billingInterval = "monthly" } // Prefer Stripe if configured if s.cfg.StripeConfigured() { return s.createStripeCheckoutSession(ctx, principal, membership, planCode, currency, billingInterval) } // Fall back to Paddle return s.createPaddleCheckoutSession(ctx, principal, membership, planCode, currency) } func (s *Service) createStripeCheckoutSession(ctx context.Context, principal domain.Principal, membership db.TenantMembershipRecord, planCode string, currency string, billingInterval string) (domain.CheckoutLaunchResponse, error) { priceID, resolvedPlanCode, resolvedCurrency := s.stripePriceForPlan(planCode, currency, billingInterval) if priceID == "" { return domain.CheckoutLaunchResponse{}, ErrBillingPlanUnsupported } // Ensure customer exists (KV sync model: always pre-create customers) customerID := derefString(membership.Tenant.BillingCustomerID) if customerID == "" { cust, err := customer.New(&stripe.CustomerParams{ Email: stripe.String(strings.TrimSpace(principal.Email)), Metadata: map[string]string{ "tenantId": membership.Tenant.ID, "tenantSlug": membership.Tenant.Slug, "userId": principal.Subject, }, }) if err != nil { return domain.CheckoutLaunchResponse{}, fmt.Errorf("failed to create stripe customer: %w", err) } customerID = cust.ID if err := s.repo.UpdateTenantBillingCustomerID(ctx, membership.Tenant.ID, customerID); err != nil { return domain.CheckoutLaunchResponse{}, err } } // Create checkout session - 15-day free trial for Starter/Pro only (not Business) // Trial requires credit card to be entered trialDays := int64(0) if resolvedPlanCode == "starter" || resolvedPlanCode == "pro" { trialDays = 15 } params := &stripe.CheckoutSessionParams{ Customer: stripe.String(customerID), SuccessURL: stripe.String(strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=success&session_id={CHECKOUT_SESSION_ID}"), CancelURL: stripe.String(strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=cancelled"), Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), PaymentMethodCollection: stripe.String("always"), // Require credit card even for free trial LineItems: []*stripe.CheckoutSessionLineItemParams{ { Price: stripe.String(priceID), Quantity: stripe.Int64(1), }, }, SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{ TrialPeriodDays: stripe.Int64(trialDays), }, Metadata: map[string]string{ "tenantId": membership.Tenant.ID, "tenantSlug": membership.Tenant.Slug, "userId": principal.Subject, "userEmail": strings.TrimSpace(principal.Email), "planCode": resolvedPlanCode, "currency": resolvedCurrency, "billingInterval": billingInterval, "source": "bookra-dashboard", }, } sess, err := checkoutsession.New(params) if err != nil { return domain.CheckoutLaunchResponse{}, fmt.Errorf("failed to create stripe checkout session: %w", err) } return domain.CheckoutLaunchResponse{ CheckoutURL: sess.URL, SuccessRedirectURL: sess.SuccessURL, CancelRedirectURL: sess.CancelURL, }, nil } func (s *Service) createPaddleCheckoutSession(ctx context.Context, principal domain.Principal, membership db.TenantMembershipRecord, planCode string, currency string) (domain.CheckoutLaunchResponse, error) { priceID, resolvedPlanCode, resolvedCurrency := s.paddlePriceForPlan(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: s.cfg.BillingProvider(), Status: "inactive", PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode), Currency: "czk", }, s.cfg), nil } // Prefer Stripe if configured if s.cfg.StripeConfigured() { record, err := s.syncStripeDataToKV(ctx, membership.Tenant, customerID) if err != nil { return domain.SubscriptionSnapshot{}, err } return toSnapshot(membership.Tenant, record, s.cfg), nil } // Fall back to Paddle 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 } customerID := derefString(membership.Tenant.BillingCustomerID) if customerID == "" { return domain.PortalSessionResponse{}, ErrBillingCustomerMissing } // Prefer Stripe if configured if s.cfg.StripeConfigured() { return s.createStripePortalSession(customerID) } // Fall back to Paddle return s.createPaddlePortalSession(ctx, membership, customerID) } func (s *Service) createStripePortalSession(customerID string) (domain.PortalSessionResponse, error) { params := &stripe.BillingPortalSessionParams{ Customer: stripe.String(customerID), ReturnURL: stripe.String(s.cfg.FrontendURL + "/dashboard?billing=refresh"), } sess, err := portalsession.New(params) if err != nil { return domain.PortalSessionResponse{}, fmt.Errorf("failed to create stripe portal session: %w", err) } return domain.PortalSessionResponse{URL: sess.URL}, nil } func (s *Service) createPaddlePortalSession(ctx context.Context, membership db.TenantMembershipRecord, customerID string) (domain.PortalSessionResponse, error) { if s.client == nil { return domain.PortalSessionResponse{}, ErrPaddleNotConfigured } request := &paddle.CreateCustomerPortalSessionRequest{CustomerID: customerID} if subscriptionID := derefString(membership.Tenant.BillingSubscription); subscriptionID != "" { request.SubscriptionIDs = []string{subscriptionID} } sess, err := s.client.CreateCustomerPortalSession(ctx, request) if err != nil { return domain.PortalSessionResponse{}, err } url := strings.TrimSpace(sess.URLs.General.Overview) if url == "" && len(sess.URLs.Subscriptions) > 0 { url = firstNonEmpty( sess.URLs.Subscriptions[0].UpdateSubscriptionPaymentMethod, sess.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 { // Detect provider based on signature header stripeSig := req.Header.Get("Stripe-Signature") paddleSig := req.Header.Get("Paddle-Signature") if stripeSig != "" { return s.handleStripeWebhook(ctx, req) } if paddleSig != "" { return s.handlePaddleWebhook(ctx, req) } return errors.New("missing webhook signature header") } func (s *Service) HandleStripeWebhook(ctx context.Context, req *http.Request) error { return s.handleStripeWebhook(ctx, req) } func (s *Service) handleStripeWebhook(ctx context.Context, req *http.Request) error { if s.cfg.StripeWebhookKey == "" { return ErrStripeWebhookMissing } body, err := io.ReadAll(req.Body) if err != nil { return err } event, err := webhook.ConstructEvent(body, req.Header.Get("Stripe-Signature"), s.cfg.StripeWebhookKey) if err != nil { return fmt.Errorf("invalid stripe webhook signature: %w", err) } if !slices.Contains(allowedStripeWebhookEvents, event.Type) { return nil } // Extract customer ID from event data var customerID string var eventID = event.ID switch event.Type { case "checkout.session.completed": var sess stripe.CheckoutSession if err := json.Unmarshal(event.Data.Raw, &sess); err != nil { return err } customerID = sess.Customer.ID if sess.Metadata != nil { if tenantID := sess.Metadata["tenantId"]; tenantID != "" { tenant, err := s.repo.GetTenantByID(ctx, tenantID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil } return err } if customerID != "" && derefString(tenant.BillingCustomerID) == "" { if err := s.repo.UpdateTenantBillingCustomerID(ctx, tenant.ID, customerID); err != nil { return err } tenant.BillingCustomerID = &customerID } _, err = s.syncStripeDataToKV(ctx, tenant, customerID) return err } } default: var data struct { Customer struct { ID string `json:"id"` } `json:"customer"` } if err := json.Unmarshal(event.Data.Raw, &data); err != nil { return err } customerID = data.Customer.ID } if customerID == "" { return nil } tenant, err := s.repo.GetTenantByBillingCustomerID(ctx, customerID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil } return err } inserted, err := s.repo.RecordBillingEvent(ctx, tenant.ID, "stripe", eventID, string(event.Type), event.Data.Raw) if err != nil || !inserted { return err } _, err = s.syncStripeDataToKV(ctx, tenant, customerID) return err } func (s *Service) handlePaddleWebhook(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(allowedPaddleWebhookEvents, 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: shared.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.paddlePlanCodeForPrice(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 } // syncStripeDataToKV is the core sync function following the KV sync model. // It fetches full subscription state from Stripe and stores it in the database. // This function is called after checkout success and on every relevant webhook event. func (s *Service) syncStripeDataToKV(ctx context.Context, tenant db.TenantRecord, customerID string) (db.BillingSnapshotRecord, error) { // Fetch all subscriptions for this customer from Stripe iter := subscription.List(&stripe.SubscriptionListParams{ Customer: stripe.String(customerID), }) var selected *stripe.Subscription for iter.Next() { sub := iter.Subscription() if selected == nil || stripeSubscriptionRank(sub) > stripeSubscriptionRank(selected) { selected = sub } } if iter.Err() != nil { return db.BillingSnapshotRecord{}, fmt.Errorf("failed to list stripe subscriptions: %w", iter.Err()) } now := time.Now().UTC() record := db.BillingSnapshotRecord{ TenantID: tenant.ID, BillingProvider: "stripe", BillingCustomerID: customerID, BillingSubscriptionID: "", Status: "inactive", PlanCode: shared.NormalizePlanCode(tenant.PlanCode), Currency: "czk", PriceID: "", LastSyncedAt: &now, } if selected != nil { record.BillingSubscriptionID = selected.ID record.Status = normalizeStripeSubscriptionStatus(selected.Status) record.Currency = strings.ToLower(string(selected.Currency)) record.CancelAtPeriodEnd = selected.CancelAtPeriodEnd record.CurrentPeriodStart = stripeTimeToPtr(selected.CurrentPeriodStart) record.CurrentPeriodEnd = stripeTimeToPtr(selected.CurrentPeriodEnd) // Extract price ID from subscription items if len(selected.Items.Data) > 0 { record.PriceID = selected.Items.Data[0].Price.ID record.PlanCode = s.stripePlanCodeForPrice(record.PriceID, tenant.PlanCode) } // Get payment method info if available if selected.DefaultPaymentMethod != nil && selected.DefaultPaymentMethod.Card != nil { record.PaymentMethodBrand = string(selected.DefaultPaymentMethod.Card.Brand) record.PaymentMethodLast4 = selected.DefaultPaymentMethod.Card.Last4 } } // Store normalized snapshot in DB (KV cache) 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 stripeSubscriptionRank(sub *stripe.Subscription) int { switch sub.Status { case stripe.SubscriptionStatusActive: return 6 case stripe.SubscriptionStatusTrialing: return 5 case stripe.SubscriptionStatusPastDue: return 4 case stripe.SubscriptionStatusPaused: return 3 case stripe.SubscriptionStatusCanceled: return 2 default: return 1 } } func normalizeStripeSubscriptionStatus(status stripe.SubscriptionStatus) string { switch status { case stripe.SubscriptionStatusActive: return "active" case stripe.SubscriptionStatusTrialing: return "trialing" case stripe.SubscriptionStatusPastDue: return "past_due" case stripe.SubscriptionStatusPaused: return "paused" case stripe.SubscriptionStatusCanceled: return "canceled" case stripe.SubscriptionStatusUnpaid: return "canceled" default: return "inactive" } } func stripeTimeToPtr(t int64) *time.Time { if t == 0 { return nil } ts := time.Unix(t, 0).UTC() return &ts } func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg config.Config) domain.SubscriptionSnapshot { if record.PlanCode == "" { record.PlanCode = shared.NormalizePlanCode(tenant.PlanCode) } else { record.PlanCode = shared.NormalizePlanCode(record.PlanCode) } record.Currency = normalizeCurrency(record.Currency) if record.Status == "" { record.Status = firstNonEmpty(tenant.SubscriptionStatus, "inactive") } customerID := firstNonEmpty(record.BillingCustomerID, derefString(tenant.BillingCustomerID)) provider := firstNonEmpty(record.BillingProvider, tenant.BillingProvider, cfg.BillingProvider()) syncAvailable := cfg.BillingConfigured() portalAvailable := cfg.BillingConfigured() && customerID != "" checkoutAvailable := billingCheckoutAvailable(cfg, record.PlanCode) return domain.SubscriptionSnapshot{ TenantID: tenant.ID, Provider: provider, 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: func() int { if record.PlanCode == "starter" || record.PlanCode == "pro" { return 15 } return 0 }(), LastSyncedAt: record.LastSyncedAt, CheckoutURLAvailable: checkoutAvailable, SyncAvailable: syncAvailable, PortalAvailable: portalAvailable, } } func entitlementsForPlan(planCode string) domain.PlanEntitlements { switch shared.NormalizePlanCode(planCode) { case "starter": // Starter: 1 location, 1 staff, 50 bookings/month return domain.PlanEntitlements{ MaxLocations: 1, MaxStaff: 1, MaxBookingsMonth: 50, EmailReminders: false, AdvancedReporting: false, WidgetEmbedding: true, UmamiTracking: false, APIAccess: false, SMSAvailable: false, } case "business": // Business: Unlimited everything, API access, dedicated manager return domain.PlanEntitlements{ MaxLocations: -1, // Unlimited MaxStaff: -1, // Unlimited MaxBookingsMonth: -1, // Unlimited EmailReminders: true, AdvancedReporting: true, WidgetEmbedding: true, UmamiTracking: true, APIAccess: true, DedicatedManager: true, SMSAvailable: true, } default: // Pro: 3 locations, 10 staff, unlimited bookings, email reminders, analytics return domain.PlanEntitlements{ MaxLocations: 3, MaxStaff: 10, MaxBookingsMonth: -1, // Unlimited EmailReminders: true, AdvancedReporting: true, WidgetEmbedding: true, UmamiTracking: true, APIAccess: false, SMSAvailable: true, } } } func (s *Service) paddlePlanCodeForPrice(priceID string, fallback string) string { for planCode, currencies := range s.cfg.PaddlePriceMatrix { for _, configuredPriceID := range currencies { if configuredPriceID != "" && configuredPriceID == priceID { return shared.NormalizePlanCode(planCode) } } } return shared.NormalizePlanCode(fallback) } func (s *Service) stripePlanCodeForPrice(priceID string, fallback string) string { for planCode, currencies := range s.cfg.StripePriceMatrix { for _, configuredPriceID := range currencies { if configuredPriceID != "" && configuredPriceID == priceID { return shared.NormalizePlanCode(planCode) } } } return shared.NormalizePlanCode(fallback) } func (s *Service) paddlePriceForPlan(planCode string, currency string) (string, string, string) { resolvedPlan := shared.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 (s *Service) stripePriceForPlan(planCode string, currency string, billingInterval string) (string, string, string) { resolvedPlan := shared.NormalizePlanCode(strings.TrimSpace(planCode)) if resolvedPlan == "" { resolvedPlan = "pro" } resolvedCurrency := normalizeCurrency(currency) resolvedInterval := billingInterval if resolvedInterval == "" { resolvedInterval = "monthly" } // Build the price key: plan:currency:interval (e.g., "pro:usd:monthly", "pro:usd:yearly") priceKey := resolvedPlan + ":" + resolvedCurrency + ":" + resolvedInterval if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" { return priceID, resolvedPlan, resolvedCurrency } // Fall back to plan:currency format (for backwards compatibility) priceKey = resolvedPlan + ":" + resolvedCurrency if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" { return priceID, resolvedPlan, resolvedCurrency } // Try just plan code with interval if resolvedInterval != "monthly" { priceKey = resolvedPlan + ":" + resolvedInterval if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" { return priceID, resolvedPlan, resolvedCurrency } } // Default currency fallback if resolvedCurrency != "usd" { priceKey = resolvedPlan + ":usd:" + resolvedInterval if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" { return priceID, resolvedPlan, "usd" } } return s.cfg.StripePriceMatrix[resolvedPlan][resolvedPlan+":czk"], resolvedPlan, "czk" } 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 shared.NormalizePlanCode(planCode) { case "starter": // Starter: $5/month, $50/year (save $10 = ~17%) return []domain.PlanDisplayPrice{ {Currency: "czk", AmountCents: 11900, Formatted: "119 Kč/mo", YearlyAmountCents: 119000, YearlyFormatted: "1 190 Kč/yr", YearlySavings: "Save 199 Kč", YearlySavingsPercent: 17}, {Currency: "usd", AmountCents: 500, Formatted: "$5/mo", YearlyAmountCents: 5000, YearlyFormatted: "$50/yr", YearlySavings: "Save $10", YearlySavingsPercent: 17}, } case "business": // Business: $50/month, $500/year (save $100 = ~17%) return []domain.PlanDisplayPrice{ {Currency: "czk", AmountCents: 119900, Formatted: "1 199 Kč/mo", YearlyAmountCents: 1199000, YearlyFormatted: "11 990 Kč/yr", YearlySavings: "Save 1 999 Kč", YearlySavingsPercent: 17}, {Currency: "usd", AmountCents: 5000, Formatted: "$50/mo", YearlyAmountCents: 50000, YearlyFormatted: "$500/yr", YearlySavings: "Save $100", YearlySavingsPercent: 17}, } default: // Pro: $20/month, $200/year (save $40 = ~17%) return []domain.PlanDisplayPrice{ {Currency: "czk", AmountCents: 49900, Formatted: "499 Kč/mo", YearlyAmountCents: 499000, YearlyFormatted: "4 990 Kč/yr", YearlySavings: "Save 999 Kč", YearlySavingsPercent: 17}, {Currency: "usd", AmountCents: 2000, Formatted: "$20/mo", YearlyAmountCents: 20000, YearlyFormatted: "$200/yr", YearlySavings: "Save $40", YearlySavingsPercent: 17}, } } } 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 = shared.NormalizePlanCode(planCode) for _, priceID := range cfg.PaddlePriceMatrix[planCode] { if strings.TrimSpace(priceID) != "" { return true } } return false } func billingCheckoutAvailable(cfg config.Config, planCode string) bool { planCode = shared.NormalizePlanCode(planCode) // Prefer Stripe if cfg.StripeConfigured() && cfg.StripeWebhookConfigured() { for _, priceID := range cfg.StripePriceMatrix[planCode] { if strings.TrimSpace(priceID) != "" { return true } } } // Fall back to Paddle if cfg.PaddleConfigured() && cfg.PaddleWebhookConfigured() { 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 "" } // CheckAndSendTrialEndingEmails checks all tenants with trials and sends emails for those ending soon func (s *Service) CheckAndSendTrialEndingEmails(ctx context.Context, notificationService interface { SendTrialEndingEmail(ctx context.Context, tenantID string, daysRemaining int) error }) error { // Get all tenants with trial status tenants, _, err := s.repo.ListAllTenants(ctx, 1000, 0) if err != nil { return err } now := time.Now().UTC() for _, tenant := range tenants { if tenant.SubscriptionStatus != "trialing" && tenant.SubscriptionStatus != "trial" { continue } // Get subscription to check trial end date snapshot, err := s.repo.GetSubscriptionSnapshot(ctx, tenant.ID) if err != nil { continue } // Calculate trial end: assume 15-day trial from period start var trialEnd time.Time if snapshot.CurrentPeriodStart != nil { trialEnd = snapshot.CurrentPeriodStart.Add(15 * 24 * time.Hour) } else { // Default to 15 days from now if no start date trialEnd = now.Add(15 * 24 * time.Hour) } daysRemaining := int(trialEnd.Sub(now).Hours() / 24) // Send email if trial ends in 1-3 days if daysRemaining >= 1 && daysRemaining <= 3 { if err := notificationService.SendTrialEndingEmail(ctx, tenant.ID, daysRemaining); err != nil { // Log but don't fail fmt.Printf("Failed to send trial ending email for tenant %s: %v\n", tenant.ID, err) } } } return nil }