feat(core): consolidate auth service into backend and implement stripe billing
CI / Frontend (push) Successful in 9m54s
CI / Go - apps/auth-service (push) Failing after 24s
CI / Go - apps/backend (push) Failing after 5m43s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped

This commit performs a major architectural refactor by migrating the standalone `auth-service` into the main `backend` application, enabling a unified codebase and simplified deployment. It also introduces comprehensive Stripe billing support and a new administrative dashboard.

Key changes:
- **Architecture**: Deleted `apps/auth-service` and integrated its functionality (JWT, magic links, OAuth, user management) into `apps/backend`.
- **Billing**: Added Stripe integration to `backend`, supporting both monthly and yearly subscription cycles with automatic plan entitlement enforcement (e.g., location limits).
- **Admin Dashboard**: Implemented a new administrative service and API endpoints to manage tenants, users, and view platform-wide statistics.
- **Frontend**:
    - Added a new pricing page with monthly/yearly toggle and comparison table.
    - Integrated Stripe and Sentry for payments and error tracking.
    - Improved dashboard UX/UI and added i18n support for new features.
    - Enhanced the public booking flow with better validation and contact form integration.
- **Database**: Added migrations for users, magic links, password resets, OAuth states, admin audit logs, and refresh tokens.
- **DevOps**: Updated environment configurations for Railway and Vercel, and streamlined the project's `package.json` scripts.
This commit is contained in:
Tomas Dvorak
2026-05-09 18:25:25 +02:00
parent cf3315e8fc
commit 164a37e997
69 changed files with 4630 additions and 5260 deletions
+587 -52
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"slices"
@@ -17,6 +18,12 @@ import (
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 (
@@ -26,9 +33,12 @@ var (
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 allowedWebhookEvents = []string{
var allowedPaddleWebhookEvents = []string{
"subscription.created",
"subscription.updated",
"subscription.activated",
@@ -42,11 +52,23 @@ var allowedWebhookEvents = []string{
"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
cfg config.Config
repo db.Repository
client *paddle.SDK
verifier *paddle.WebhookVerifier
stripeEnabled bool
}
type webhookEnvelope struct {
@@ -63,6 +85,7 @@ type webhookEnvelope struct {
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
@@ -76,13 +99,28 @@ func NewService(cfg config.Config, repo db.Repository) *Service {
}
}
if strings.TrimSpace(cfg.PaddleWebhookKey) != "" {
service.verifier = paddle.NewWebhookVerifier(cfg.PaddleWebhookKey, paddle.VerifierWithTimestampTolerance(5*time.Minute))
// 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 {
@@ -97,7 +135,7 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa
if errors.Is(err, pgx.ErrNoRows) {
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
TenantID: membership.Tenant.ID,
BillingProvider: "paddle",
BillingProvider: s.cfg.BillingProvider(),
Status: firstNonEmpty(membership.Tenant.SubscriptionStatus, "inactive"),
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
Currency: "czk",
@@ -109,7 +147,7 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa
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) {
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) {
@@ -118,7 +156,93 @@ func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Pr
return domain.CheckoutLaunchResponse{}, err
}
priceID, resolvedPlanCode, resolvedCurrency := s.priceForPlan(planCode, currency)
// 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
}
@@ -157,16 +281,26 @@ func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (doma
if customerID == "" {
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
TenantID: membership.Tenant.ID,
BillingProvider: "paddle",
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
@@ -183,30 +317,53 @@ func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Prin
}
return domain.PortalSessionResponse{}, err
}
if s.client == nil {
return domain.PortalSessionResponse{}, ErrPaddleNotConfigured
}
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}
}
session, err := s.client.CreateCustomerPortalSession(ctx, request)
sess, 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 := strings.TrimSpace(sess.URLs.General.Overview)
if url == "" && len(sess.URLs.Subscriptions) > 0 {
url = firstNonEmpty(
session.URLs.Subscriptions[0].UpdateSubscriptionPaymentMethod,
session.URLs.Subscriptions[0].CancelSubscription,
sess.URLs.Subscriptions[0].UpdateSubscriptionPaymentMethod,
sess.URLs.Subscriptions[0].CancelSubscription,
)
}
if url == "" {
@@ -217,6 +374,109 @@ func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Prin
}
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
}
@@ -241,7 +501,7 @@ func (s *Service) HandleWebhook(ctx context.Context, req *http.Request) error {
if err := json.Unmarshal(payload, &event); err != nil {
return err
}
if !slices.Contains(allowedWebhookEvents, event.EventType) {
if !slices.Contains(allowedPaddleWebhookEvents, event.EventType) {
return nil
}
@@ -337,7 +597,7 @@ func (s *Service) syncPaddleData(ctx context.Context, tenant db.TenantRecord, cu
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)
record.PlanCode = s.paddlePlanCodeForPrice(record.PriceID, tenant.PlanCode)
}
}
@@ -351,6 +611,115 @@ func (s *Service) syncPaddleData(ctx context.Context, tenant db.TenantRecord, cu
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)
@@ -363,43 +732,84 @@ func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg con
}
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: 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,
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(cfg, record.PlanCode),
SyncAvailable: cfg.PaddleConfigured(),
PortalAvailable: cfg.PaddleConfigured() && customerID != "",
CheckoutURLAvailable: checkoutAvailable,
SyncAvailable: syncAvailable,
PortalAvailable: portalAvailable,
}
}
func entitlementsForPlan(planCode string) domain.PlanEntitlements {
switch shared.NormalizePlanCode(planCode) {
case "starter":
return domain.PlanEntitlements{MaxLocations: 1, MaxStaff: 3, EmailReminders: true, AdvancedReporting: false, WidgetEmbedding: true, UmamiTracking: false}
// 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,
}
case "business":
return domain.PlanEntitlements{MaxLocations: 10, MaxStaff: 30, EmailReminders: true, AdvancedReporting: true, WidgetEmbedding: true, UmamiTracking: true}
// 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,
}
default:
return domain.PlanEntitlements{MaxLocations: 3, MaxStaff: 10, EmailReminders: true, AdvancedReporting: true, WidgetEmbedding: true, UmamiTracking: true}
// 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,
}
}
}
func (s *Service) planCodeForPrice(priceID string, fallback string) string {
func (s *Service) paddlePlanCodeForPrice(priceID string, fallback string) string {
for planCode, currencies := range s.cfg.PaddlePriceMatrix {
for _, configuredPriceID := range currencies {
if configuredPriceID != "" && configuredPriceID == priceID {
@@ -410,7 +820,18 @@ func (s *Service) planCodeForPrice(priceID string, fallback string) string {
return shared.NormalizePlanCode(fallback)
}
func (s *Service) priceForPlan(planCode string, currency string) (string, string, string) {
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"
@@ -427,6 +848,48 @@ func (s *Service) priceForPlan(planCode string, currency string) (string, string
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:
@@ -447,19 +910,22 @@ func subscriptionRank(subscription *paddle.Subscription) int {
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 Kc"},
{Currency: "usd", AmountCents: 500, Formatted: "$5"},
{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 Kc"},
{Currency: "usd", AmountCents: 5000, Formatted: "$50"},
{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 Kc"},
{Currency: "usd", AmountCents: 2000, Formatted: "$20"},
{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},
}
}
}
@@ -497,6 +963,30 @@ func checkoutAvailable(cfg config.Config, planCode string) bool {
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 ""
@@ -554,3 +1044,48 @@ func firstNonEmpty(values ...string) string {
}
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
}