This commit is contained in:
Tomas Dvorak
2026-05-05 09:48:07 +02:00
parent d854614a87
commit 48c3e15a38
295 changed files with 178381 additions and 1039 deletions
+401 -170
View File
@@ -4,48 +4,82 @@ 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"
paddle "github.com/PaddleHQ/paddle-go-sdk/v5"
"github.com/jackc/pgx/v5"
"github.com/stripe/stripe-go/v83"
"github.com/stripe/stripe-go/v83/checkout/session"
"github.com/stripe/stripe-go/v83/customer"
"github.com/stripe/stripe-go/v83/subscription"
"github.com/stripe/stripe-go/v83/webhook"
)
var (
ErrBillingMembership = errors.New("billing membership not found")
ErrBillingPlanUnsupported = errors.New("billing plan is not configured")
ErrStripeSignatureMissing = errors.New("stripe signature is missing")
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{
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"customer.subscription.paused",
"customer.subscription.resumed",
"invoice.paid",
"invoice.payment_failed",
"payment_intent.succeeded",
"payment_intent.payment_failed",
"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
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 {
return &Service{cfg: cfg, repo: repo}
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) {
@@ -56,80 +90,57 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa
}
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,
StripeCustomerID: derefString(membership.Tenant.StripeCustomerID),
Status: membership.Tenant.SubscriptionStatus,
PlanCode: membership.Tenant.PlanCode,
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) (domain.CheckoutSessionResponse, error) {
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.CheckoutSessionResponse{}, ErrBillingMembership
return domain.CheckoutLaunchResponse{}, ErrBillingMembership
}
return domain.CheckoutSessionResponse{}, err
return domain.CheckoutLaunchResponse{}, err
}
priceID := s.cfg.StripePriceIDs[planCode]
priceID, resolvedPlanCode, resolvedCurrency := s.priceForPlan(planCode, currency)
if priceID == "" {
return domain.CheckoutSessionResponse{}, ErrBillingPlanUnsupported
return domain.CheckoutLaunchResponse{}, ErrBillingPlanUnsupported
}
if !checkoutAvailable(s.cfg, resolvedPlanCode) {
return domain.CheckoutLaunchResponse{}, ErrPaddleNotConfigured
}
if s.cfg.StripeSecretKey == "" {
mockURL := fmt.Sprintf("%s/dashboard?billing=mock-checkout&plan=%s", s.cfg.FrontendURL, planCode)
return domain.CheckoutSessionResponse{URL: mockURL}, nil
}
stripe.Key = s.cfg.StripeSecretKey
customerID := derefString(membership.Tenant.StripeCustomerID)
if customerID == "" {
params := &stripe.CustomerParams{
Name: stripe.String(membership.Tenant.Name),
Email: stripe.String(principal.Email),
Metadata: map[string]string{"tenant_id": membership.Tenant.ID, "tenant_slug": membership.Tenant.Slug},
}
createdCustomer, err := customer.New(params)
if err != nil {
return domain.CheckoutSessionResponse{}, err
}
customerID = createdCustomer.ID
if err := s.repo.UpdateTenantStripeCustomerID(ctx, membership.Tenant.ID, customerID); err != nil {
return domain.CheckoutSessionResponse{}, err
}
}
params := &stripe.CheckoutSessionParams{
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
Customer: stripe.String(customerID),
SuccessURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=success", s.cfg.FrontendURL)),
CancelURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=cancelled", s.cfg.FrontendURL)),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(priceID),
Quantity: stripe.Int64(1),
},
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",
},
Metadata: map[string]string{
"tenant_id": membership.Tenant.ID,
"plan_code": planCode,
},
}
checkoutSession, err := session.New(params)
if err != nil {
return domain.CheckoutSessionResponse{}, err
}
return domain.CheckoutSessionResponse{URL: checkoutSession.URL}, nil
}, nil
}
func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) {
@@ -140,139 +151,226 @@ func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (doma
}
return domain.SubscriptionSnapshot{}, err
}
customerID := derefString(membership.Tenant.StripeCustomerID)
customerID := derefString(membership.Tenant.BillingCustomerID)
if customerID == "" {
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
TenantID: membership.Tenant.ID,
StripeCustomerID: "",
Status: "none",
PlanCode: membership.Tenant.PlanCode,
BillingProvider: "paddle",
Status: "inactive",
PlanCode: normalizePlanCode(membership.Tenant.PlanCode),
Currency: "czk",
}, s.cfg), nil
}
record, err := s.syncStripeData(ctx, membership.Tenant, customerID)
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) HandleWebhook(ctx context.Context, signature string, payload []byte) error {
if s.cfg.StripeSecretKey == "" {
return 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 signature == "" {
return ErrStripeSignatureMissing
if s.client == nil {
return domain.PortalSessionResponse{}, ErrPaddleNotConfigured
}
event, err := webhook.ConstructEvent(payload, signature, s.cfg.StripeWebhookKey)
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 !slices.Contains(allowedWebhookEvents, string(event.Type)) {
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
}
customerID := extractCustomerID(event)
if customerID == "" {
return nil
}
tenant, err := s.repo.GetTenantByStripeCustomerID(ctx, customerID)
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.RecordStripeEvent(ctx, tenant.ID, event.ID, string(event.Type), payload)
inserted, err := s.repo.RecordBillingEvent(ctx, tenant.ID, "paddle", event.EventID, event.EventType, payload)
if err != nil || !inserted {
return err
}
_, err = s.syncStripeData(ctx, tenant, customerID)
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) syncStripeData(ctx context.Context, tenant db.TenantRecord, customerID string) (db.BillingSnapshotRecord, error) {
if s.cfg.StripeSecretKey == "" {
now := time.Now().UTC()
record := db.BillingSnapshotRecord{
TenantID: tenant.ID,
StripeCustomerID: customerID,
StripeSubscriptionID: "",
Status: tenant.SubscriptionStatus,
PlanCode: tenant.PlanCode,
PriceID: s.cfg.StripePriceIDs[tenant.PlanCode],
LastSyncedAt: &now,
}
if err := s.repo.UpsertSubscriptionSnapshot(ctx, record); err != nil {
return db.BillingSnapshotRecord{}, err
}
return record, nil
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
}
stripe.Key = s.cfg.StripeSecretKey
params := &stripe.SubscriptionListParams{Customer: stripe.String(customerID)}
params.Status = stripe.String("all")
params.AddExpand("data.default_payment_method")
params.AddExpand("data.items.data.price")
subscriptions, err := s.client.ListSubscriptions(ctx, &paddle.ListSubscriptionsRequest{
CustomerID: []string{customerID},
})
if err != nil {
return db.BillingSnapshotRecord{}, err
}
iter := subscription.List(params)
if iter.Err() != nil {
return db.BillingSnapshotRecord{}, iter.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,
StripeCustomerID: customerID,
StripeSubscriptionID: "",
Status: "none",
PlanCode: tenant.PlanCode,
PriceID: "",
LastSyncedAt: &now,
TenantID: tenant.ID,
BillingProvider: "paddle",
BillingCustomerID: customerID,
BillingSubscriptionID: "",
Status: "inactive",
PlanCode: normalizePlanCode(tenant.PlanCode),
Currency: "czk",
PriceID: "",
LastSyncedAt: &now,
}
if iter.Next() {
subscriptionRecord := iter.Subscription()
record.StripeSubscriptionID = subscriptionRecord.ID
record.Status = string(subscriptionRecord.Status)
record.CancelAtPeriodEnd = subscriptionRecord.CancelAtPeriodEnd
if len(subscriptionRecord.Items.Data) > 0 {
record.PriceID = subscriptionRecord.Items.Data[0].Price.ID
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)
record.CurrentPeriodStart = toTimePtr(subscriptionRecord.Items.Data[0].CurrentPeriodStart)
record.CurrentPeriodEnd = toTimePtr(subscriptionRecord.Items.Data[0].CurrentPeriodEnd)
}
if subscriptionRecord.DefaultPaymentMethod != nil && subscriptionRecord.DefaultPaymentMethod.Card != nil {
record.PaymentMethodBrand = string(subscriptionRecord.DefaultPaymentMethod.Card.Brand)
record.PaymentMethodLast4 = subscriptionRecord.DefaultPaymentMethod.Card.Last4
}
}
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.StripeSubscriptionID); err != nil {
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 = tenant.PlanCode
record.PlanCode = normalizePlanCode(tenant.PlanCode)
} else {
record.PlanCode = normalizePlanCode(record.PlanCode)
}
record.Currency = normalizeCurrency(record.Currency)
if record.Status == "" {
record.Status = tenant.SubscriptionStatus
record.Status = firstNonEmpty(tenant.SubscriptionStatus, "inactive")
}
customerID := firstNonEmpty(record.BillingCustomerID, derefString(tenant.BillingCustomerID))
return domain.SubscriptionSnapshot{
TenantID: tenant.ID,
CustomerID: record.StripeCustomerID,
SubscriptionID: record.StripeSubscriptionID,
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,
@@ -280,29 +378,175 @@ func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg con
PaymentMethodBrand: record.PaymentMethodBrand,
PaymentMethodLast4: record.PaymentMethodLast4,
Entitlements: entitlementsForPlan(record.PlanCode),
DisplayPrices: displayPricesForPlan(record.PlanCode),
TrialDays: 30,
LastSyncedAt: record.LastSyncedAt,
CheckoutURLAvailable: cfg.StripePriceIDs[record.PlanCode] != "",
CheckoutURLAvailable: checkoutAvailable(cfg, record.PlanCode),
SyncAvailable: cfg.PaddleConfigured(),
PortalAvailable: cfg.PaddleConfigured() && customerID != "",
}
}
func entitlementsForPlan(planCode string) domain.PlanEntitlements {
switch planCode {
switch normalizePlanCode(planCode) {
case "starter":
return domain.PlanEntitlements{MaxLocations: 1, MaxStaff: 3, SMSAddonAvailable: false, AdvancedReporting: false}
case "multi-location":
return domain.PlanEntitlements{MaxLocations: 10, MaxStaff: 30, SMSAddonAvailable: true, AdvancedReporting: true}
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, SMSAddonAvailable: true, AdvancedReporting: true}
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 code, configuredPriceID := range s.cfg.StripePriceIDs {
if configuredPriceID != "" && configuredPriceID == priceID {
return code
for planCode, currencies := range s.cfg.PaddlePriceMatrix {
for _, configuredPriceID := range currencies {
if configuredPriceID != "" && configuredPriceID == priceID {
return normalizePlanCode(planCode)
}
}
}
return fallback
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 {
@@ -312,24 +556,11 @@ func derefString(value *string) string {
return *value
}
func toTimePtr(value int64) *time.Time {
if value == 0 {
return nil
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
timestamp := time.Unix(value, 0).UTC()
return &timestamp
}
func extractCustomerID(event stripe.Event) string {
var payload map[string]any
if err := json.Unmarshal(event.Data.Raw, &payload); err != nil {
return ""
}
value, ok := payload["customer"]
if !ok {
return ""
}
customerID, _ := value.(string)
return customerID
return ""
}
+108 -34
View File
@@ -9,13 +9,21 @@ import (
"bookra/apps/backend/internal/domain"
)
func TestGetSubscriptionFallsBackToSnapshotAndEntitlements(t *testing.T) {
service := NewService(config.Config{
FrontendURL: "http://localhost:3000",
StripePriceIDs: map[string]string{
"growth": "price_growth_123",
func testConfig() config.Config {
return config.Config{
FrontendURL: "http://localhost:3000",
PaddleAPIKey: "pdl_sdbx_apikey_123",
PaddleWebhookKey: "pdl_ntf_123",
PaddlePriceMatrix: map[string]map[string]string{
"starter": {"czk": "pri_starter_czk", "usd": "pri_starter_usd"},
"pro": {"czk": "pri_pro_czk", "usd": "pri_pro_usd"},
"business": {"czk": "pri_business_czk", "usd": "pri_business_usd"},
},
}, db.NewMemoryRepository())
}
}
func TestGetSubscriptionFallsBackToSnapshotAndEntitlements(t *testing.T) {
service := NewService(testConfig(), db.NewMemoryRepository())
snapshot, err := service.GetSubscription(context.Background(), domain.Principal{
Subject: "demo-owner",
@@ -25,51 +33,117 @@ func TestGetSubscriptionFallsBackToSnapshotAndEntitlements(t *testing.T) {
t.Fatalf("get subscription: %v", err)
}
if snapshot.PlanCode != "growth" {
t.Fatalf("expected growth, got %s", snapshot.PlanCode)
if snapshot.PlanCode != "pro" {
t.Fatalf("expected pro, got %s", snapshot.PlanCode)
}
if snapshot.Provider != "paddle" {
t.Fatalf("expected paddle provider, got %s", snapshot.Provider)
}
if snapshot.Entitlements.MaxLocations != 3 {
t.Fatalf("expected 3 locations, got %d", snapshot.Entitlements.MaxLocations)
}
}
func TestCreateCheckoutUsesMockURLWithoutStripeKey(t *testing.T) {
service := NewService(config.Config{
FrontendURL: "http://localhost:3000",
StripePriceIDs: map[string]string{
"growth": "price_growth_123",
},
}, db.NewMemoryRepository())
func TestCreateCheckoutRequiresPaddleConfig(t *testing.T) {
cfg := testConfig()
cfg.PaddleAPIKey = ""
service := NewService(cfg, db.NewMemoryRepository())
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
Subject: "demo-owner",
Email: "owner@bookra.dev",
}, "growth")
if err != nil {
t.Fatalf("create checkout: %v", err)
}
if response.URL == "" {
t.Fatal("expected checkout url")
}, "pro", "czk")
if err != ErrPaddleNotConfigured {
t.Fatalf("expected ErrPaddleNotConfigured, got response=%v err=%v", response, err)
}
}
func TestRefreshReturnsSnapshotWithoutStripeKey(t *testing.T) {
service := NewService(config.Config{
FrontendURL: "http://localhost:3000",
StripePriceIDs: map[string]string{
"growth": "price_growth_123",
},
}, db.NewMemoryRepository())
func TestCreateCheckoutReturnsLaunchPayload(t *testing.T) {
service := NewService(testConfig(), db.NewMemoryRepository())
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
Subject: "demo-owner",
Email: "owner@bookra.dev",
}, "pro", "czk")
if err != nil {
t.Fatalf("create checkout: %v", err)
}
if response.PriceID != "pri_pro_czk" {
t.Fatalf("expected pri_pro_czk, got %s", response.PriceID)
}
if response.CustomData["tenantId"] == "" {
t.Fatal("expected tenantId in customData")
}
if response.SuccessRedirectURL == "" || response.CancelRedirectURL == "" {
t.Fatal("expected redirect URLs")
}
}
func TestRefreshRequiresPaddleKeyWhenCustomerExists(t *testing.T) {
cfg := testConfig()
cfg.PaddleAPIKey = ""
service := NewService(cfg, db.NewMemoryRepository())
snapshot, err := service.Refresh(context.Background(), domain.Principal{
Subject: "demo-owner",
Email: "owner@bookra.dev",
})
if err != nil {
t.Fatalf("refresh: %v", err)
}
if snapshot.Status != "active" {
t.Fatalf("expected active status, got %s", snapshot.Status)
if err != ErrPaddleNotConfigured {
t.Fatalf("expected ErrPaddleNotConfigured, got snapshot=%v err=%v", snapshot, err)
}
}
func TestGetSubscriptionDisablesCheckoutWhenWebhookMissing(t *testing.T) {
cfg := testConfig()
cfg.PaddleWebhookKey = ""
service := NewService(cfg, db.NewMemoryRepository())
snapshot, err := service.GetSubscription(context.Background(), domain.Principal{
Subject: "demo-owner",
Email: "owner@bookra.dev",
})
if err != nil {
t.Fatalf("get subscription: %v", err)
}
if snapshot.CheckoutURLAvailable {
t.Fatal("expected checkout unavailable without webhook secret")
}
}
func TestGetSubscriptionEnablesCheckoutWhenPaddleConfigured(t *testing.T) {
service := NewService(testConfig(), db.NewMemoryRepository())
snapshot, err := service.GetSubscription(context.Background(), domain.Principal{
Subject: "demo-owner",
Email: "owner@bookra.dev",
})
if err != nil {
t.Fatalf("get subscription: %v", err)
}
if !snapshot.CheckoutURLAvailable {
t.Fatal("expected checkout available when paddle is configured")
}
if !snapshot.PortalAvailable {
t.Fatal("expected portal available when customer exists")
}
}
func TestCreatePortalSessionRequiresCustomer(t *testing.T) {
repo := db.NewMemoryRepository()
membership, err := repo.GetTenantMembershipByUserID(context.Background(), "demo-owner")
if err != nil {
t.Fatalf("get membership: %v", err)
}
if err := repo.UpdateTenantBillingCustomerID(context.Background(), membership.Tenant.ID, ""); err != nil {
t.Fatalf("clear billing customer: %v", err)
}
service := NewService(testConfig(), repo)
_, err = service.CreatePortalSession(context.Background(), domain.Principal{
Subject: "demo-owner",
Email: "owner@bookra.dev",
})
if err != ErrBillingCustomerMissing {
t.Fatalf("expected ErrBillingCustomerMissing, got %v", err)
}
}