mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
557 lines
17 KiB
Go
557 lines
17 KiB
Go
package billing
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"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"
|
|
)
|
|
|
|
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")
|
|
)
|
|
|
|
var allowedWebhookEvents = []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",
|
|
}
|
|
|
|
type Service struct {
|
|
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 {
|
|
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) {
|
|
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: "paddle",
|
|
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) (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
|
|
}
|
|
|
|
priceID, resolvedPlanCode, resolvedCurrency := s.priceForPlan(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: "paddle",
|
|
Status: "inactive",
|
|
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
|
|
Currency: "czk",
|
|
}, s.cfg), nil
|
|
}
|
|
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
|
|
}
|
|
if s.client == nil {
|
|
return domain.PortalSessionResponse{}, ErrPaddleNotConfigured
|
|
}
|
|
|
|
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 !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
|
|
}
|
|
|
|
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.planCodeForPrice(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
|
|
}
|
|
|
|
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))
|
|
|
|
return domain.SubscriptionSnapshot{
|
|
TenantID: tenant.ID,
|
|
Provider: firstNonEmpty(record.BillingProvider, tenant.BillingProvider, "paddle"),
|
|
CustomerID: customerID,
|
|
SubscriptionID: firstNonEmpty(record.BillingSubscriptionID, derefString(tenant.BillingSubscription)),
|
|
Status: record.Status,
|
|
PlanCode: record.PlanCode,
|
|
Currency: record.Currency,
|
|
PriceID: record.PriceID,
|
|
CancelAtPeriodEnd: record.CancelAtPeriodEnd,
|
|
CurrentPeriodStart: record.CurrentPeriodStart,
|
|
CurrentPeriodEnd: record.CurrentPeriodEnd,
|
|
PaymentMethodBrand: record.PaymentMethodBrand,
|
|
PaymentMethodLast4: record.PaymentMethodLast4,
|
|
Entitlements: entitlementsForPlan(record.PlanCode),
|
|
DisplayPrices: displayPricesForPlan(record.PlanCode),
|
|
TrialDays: 30,
|
|
LastSyncedAt: record.LastSyncedAt,
|
|
CheckoutURLAvailable: checkoutAvailable(cfg, record.PlanCode),
|
|
SyncAvailable: cfg.PaddleConfigured(),
|
|
PortalAvailable: cfg.PaddleConfigured() && customerID != "",
|
|
}
|
|
}
|
|
|
|
func entitlementsForPlan(planCode string) domain.PlanEntitlements {
|
|
switch shared.NormalizePlanCode(planCode) {
|
|
case "starter":
|
|
return domain.PlanEntitlements{MaxLocations: 1, MaxStaff: 3, EmailReminders: true, AdvancedReporting: false, WidgetEmbedding: true, UmamiTracking: false}
|
|
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, EmailReminders: true, AdvancedReporting: true, WidgetEmbedding: true, UmamiTracking: true}
|
|
}
|
|
}
|
|
|
|
func (s *Service) planCodeForPrice(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) priceForPlan(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 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":
|
|
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 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 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 ""
|
|
}
|