mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
refactor(db): replace sqlc with handwritten pgx repos
- Finish Paddle-to-Stripe billing migration; drop paddle/stripe libs - Renumber migrations; add 00012 memberships+manage_token, 00013 location_type_capacity - Add tests: admin, auth, bookings customer service - Remove graphify-out artifacts and stale env files Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
2039669e2c
commit
f2f7439050
93 files changed
+6987
-69060
No files matched your search
@@ -16,7 +16,6 @@ import (
|
||||
"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"
|
||||
@@ -30,28 +29,11 @@ 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,
|
||||
@@ -64,48 +46,15 @@ var allowedStripeWebhookEvents = []stripe.EventType{
|
||||
}
|
||||
|
||||
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"`
|
||||
cfg config.Config
|
||||
repo db.Repository
|
||||
}
|
||||
|
||||
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
|
||||
return &Service{cfg: cfg, repo: repo}
|
||||
}
|
||||
|
||||
// GetEntitlements returns the plan entitlements for a tenant (used by other services for limit enforcement)
|
||||
@@ -122,7 +71,7 @@ func (s *Service) GetEntitlements(ctx context.Context, tenantID string) (domain.
|
||||
}
|
||||
|
||||
func (s *Service) GetSubscription(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.SubscriptionSnapshot{}, ErrBillingMembership
|
||||
@@ -135,7 +84,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: s.cfg.BillingProvider(),
|
||||
BillingProvider: "stripe",
|
||||
Status: firstNonEmpty(membership.Tenant.SubscriptionStatus, "inactive"),
|
||||
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
|
||||
Currency: "czk",
|
||||
@@ -148,26 +97,23 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa
|
||||
}
|
||||
|
||||
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)
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.CheckoutLaunchResponse{}, ErrBillingMembership
|
||||
}
|
||||
return domain.CheckoutLaunchResponse{}, err
|
||||
}
|
||||
if !s.cfg.StripeConfigured() {
|
||||
return domain.CheckoutLaunchResponse{}, ErrStripeNotConfigured
|
||||
}
|
||||
|
||||
// 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)
|
||||
return s.createStripeCheckoutSession(ctx, principal, membership, planCode, currency, billingInterval)
|
||||
}
|
||||
|
||||
func (s *Service) createStripeCheckoutSession(ctx context.Context, principal domain.Principal, membership db.TenantMembershipRecord, planCode string, currency string, billingInterval string) (domain.CheckoutLaunchResponse, error) {
|
||||
@@ -191,7 +137,7 @@ func (s *Service) createStripeCheckoutSession(ctx context.Context, principal dom
|
||||
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 {
|
||||
if err := s.repo.UpdateTenantBillingCustomerID(ctx, membership.Tenant.ID, "stripe", customerID); err != nil {
|
||||
return domain.CheckoutLaunchResponse{}, err
|
||||
}
|
||||
}
|
||||
@@ -241,35 +187,8 @@ func (s *Service) createStripeCheckoutSession(ctx context.Context, principal dom
|
||||
}, 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)
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.SubscriptionSnapshot{}, ErrBillingMembership
|
||||
@@ -281,36 +200,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: s.cfg.BillingProvider(),
|
||||
BillingProvider: "stripe",
|
||||
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
|
||||
if !s.cfg.StripeConfigured() {
|
||||
return domain.SubscriptionSnapshot{}, ErrStripeNotConfigured
|
||||
}
|
||||
|
||||
// Fall back to Paddle
|
||||
if s.client == nil {
|
||||
return domain.SubscriptionSnapshot{}, ErrPaddleNotConfigured
|
||||
}
|
||||
record, err := s.syncPaddleData(ctx, membership.Tenant, customerID)
|
||||
record, err := s.syncStripeDataToKV(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)
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.PortalSessionResponse{}, ErrBillingMembership
|
||||
@@ -322,14 +231,11 @@ func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Prin
|
||||
if customerID == "" {
|
||||
return domain.PortalSessionResponse{}, ErrBillingCustomerMissing
|
||||
}
|
||||
|
||||
// Prefer Stripe if configured
|
||||
if s.cfg.StripeConfigured() {
|
||||
return s.createStripePortalSession(customerID)
|
||||
if !s.cfg.StripeConfigured() {
|
||||
return domain.PortalSessionResponse{}, ErrStripeNotConfigured
|
||||
}
|
||||
|
||||
// Fall back to Paddle
|
||||
return s.createPaddlePortalSession(ctx, membership, customerID)
|
||||
return s.createStripePortalSession(customerID)
|
||||
}
|
||||
|
||||
func (s *Service) createStripePortalSession(customerID string) (domain.PortalSessionResponse, error) {
|
||||
@@ -344,51 +250,6 @@ func (s *Service) createStripePortalSession(customerID string) (domain.PortalSes
|
||||
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)
|
||||
}
|
||||
@@ -397,6 +258,9 @@ func (s *Service) handleStripeWebhook(ctx context.Context, req *http.Request) er
|
||||
if s.cfg.StripeWebhookKey == "" {
|
||||
return ErrStripeWebhookMissing
|
||||
}
|
||||
if strings.TrimSpace(req.Header.Get("Stripe-Signature")) == "" {
|
||||
return ErrStripeSignatureMissing
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
@@ -433,7 +297,7 @@ func (s *Service) handleStripeWebhook(ctx context.Context, req *http.Request) er
|
||||
return err
|
||||
}
|
||||
if customerID != "" && derefString(tenant.BillingCustomerID) == "" {
|
||||
if err := s.repo.UpdateTenantBillingCustomerID(ctx, tenant.ID, customerID); err != nil {
|
||||
if err := s.repo.UpdateTenantBillingCustomerID(ctx, tenant.ID, "stripe", customerID); err != nil {
|
||||
return err
|
||||
}
|
||||
tenant.BillingCustomerID = &customerID
|
||||
@@ -476,141 +340,6 @@ func (s *Service) handleStripeWebhook(ctx context.Context, req *http.Request) er
|
||||
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.
|
||||
@@ -669,7 +398,7 @@ func (s *Service) syncStripeDataToKV(ctx context.Context, tenant db.TenantRecord
|
||||
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 {
|
||||
if err := s.repo.UpdateTenantBillingState(ctx, tenant.ID, "stripe", record.PlanCode, record.Status, record.BillingSubscriptionID); err != nil {
|
||||
return db.BillingSnapshotRecord{}, err
|
||||
}
|
||||
|
||||
@@ -732,11 +461,7 @@ 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)
|
||||
provider := firstNonEmpty(record.BillingProvider, tenant.BillingProvider, "stripe")
|
||||
|
||||
return domain.SubscriptionSnapshot{
|
||||
TenantID: tenant.ID,
|
||||
@@ -761,9 +486,9 @@ func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg con
|
||||
return 0
|
||||
}(),
|
||||
LastSyncedAt: record.LastSyncedAt,
|
||||
CheckoutURLAvailable: checkoutAvailable,
|
||||
SyncAvailable: syncAvailable,
|
||||
PortalAvailable: portalAvailable,
|
||||
CheckoutURLAvailable: billingCheckoutAvailable(cfg, record.PlanCode),
|
||||
SyncAvailable: cfg.StripeConfigured(),
|
||||
PortalAvailable: cfg.StripeConfigured() && customerID != "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,17 +537,6 @@ func entitlementsForPlan(planCode string) domain.PlanEntitlements {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -834,23 +548,6 @@ func (s *Service) stripePlanCodeForPrice(priceID string, fallback string) string
|
||||
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 == "" {
|
||||
@@ -893,23 +590,6 @@ func (s *Service) stripePriceForPlan(planCode string, currency string, billingIn
|
||||
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":
|
||||
@@ -944,21 +624,12 @@ func normalizeCurrency(currency string) string {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
func billingCheckoutAvailable(cfg config.Config, planCode string) bool {
|
||||
if !cfg.StripeConfigured() || !cfg.StripeWebhookConfigured() {
|
||||
return false
|
||||
}
|
||||
planCode = shared.NormalizePlanCode(planCode)
|
||||
for _, priceID := range cfg.PaddlePriceMatrix[planCode] {
|
||||
for _, priceID := range cfg.StripePriceMatrix[planCode] {
|
||||
if strings.TrimSpace(priceID) != "" {
|
||||
return true
|
||||
}
|
||||
@@ -966,72 +637,6 @@ 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 ""
|
||||
}
|
||||
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 ""
|
||||
|
||||
Reference in new issue
Block a user