mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-04 04:22:59 +00:00
7d3e3448cf
Implement a complete SMS messaging system including: - Integration with SMS Manager.cz API for sending messages. - Metered billing via Stripe using monthly aggregate invoice items. - Backend services for managing SMS settings, usage logging, and monthly reporting. - Database migrations for tenant settings, usage logs, and monthly reports. - Frontend dashboard components for SMS configuration, usage tracking, and history. - Support for customer phone numbers in the booking flow. Includes new migrations, backend services, and frontend UI components.
1095 lines
34 KiB
Go
1095 lines
34 KiB
Go
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
|
|
}
|