Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
305 lines
10 KiB
Go
305 lines
10 KiB
Go
package billing
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// SalesQuoteCheckoutInput drives Checkout for an admin-prepared custom deal.
|
|
type SalesQuoteCheckoutInput struct {
|
|
QuoteID uuid.UUID
|
|
CompanyID uuid.UUID
|
|
PlanID int64
|
|
PlanName string
|
|
Email string
|
|
CompanyName string
|
|
Currency string
|
|
TotalAmountCents int
|
|
InstallmentCount int
|
|
InstallmentInterval string // month | quarter | year
|
|
InstallmentAmountCents int
|
|
}
|
|
|
|
// SalesQuoteCheckoutResult is returned to admin after preparing Checkout for a quote.
|
|
type SalesQuoteCheckoutResult struct {
|
|
URL string `json:"url"`
|
|
Mock bool `json:"mock"`
|
|
Applied bool `json:"applied,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
ProductID string `json:"product_id,omitempty"`
|
|
PriceID string `json:"price_id,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
}
|
|
|
|
// CreateSalesQuoteCheckout creates a Stripe Price + Checkout Session for a sales quote.
|
|
//
|
|
// ASSUMPTION (installments):
|
|
// - installment_count == 1 → Checkout mode=payment (one-time Price).
|
|
// - installment_count > 1 → Checkout mode=subscription with a recurring Price equal to
|
|
// installment_amount_cents; subscription_data.cancel_at ends billing after N intervals
|
|
// (month / quarter=3 months / year). Stripe collects the first installment at Checkout;
|
|
// later invoices are charged automatically on the subscription.
|
|
func (s *StripeService) CreateSalesQuoteCheckout(ctx context.Context, in SalesQuoteCheckoutInput) (SalesQuoteCheckoutResult, error) {
|
|
if in.QuoteID == uuid.Nil || in.CompanyID == uuid.Nil || in.PlanID <= 0 {
|
|
return SalesQuoteCheckoutResult{}, fmt.Errorf("%w: quote identifiers", ErrStripePlanUnsupported)
|
|
}
|
|
if in.InstallmentAmountCents <= 0 || in.TotalAmountCents <= 0 {
|
|
return SalesQuoteCheckoutResult{}, fmt.Errorf("%w: amounts", ErrStripePlanUnsupported)
|
|
}
|
|
count := in.InstallmentCount
|
|
if count <= 0 {
|
|
count = 1
|
|
}
|
|
interval := strings.ToLower(strings.TrimSpace(in.InstallmentInterval))
|
|
if interval == "" {
|
|
interval = "month"
|
|
}
|
|
currency := strings.ToLower(strings.TrimSpace(in.Currency))
|
|
if currency == "" {
|
|
currency = "usd"
|
|
}
|
|
|
|
ctx, cfg, err := s.bindCfg(ctx)
|
|
if err != nil {
|
|
return SalesQuoteCheckoutResult{}, err
|
|
}
|
|
web := strings.TrimRight(cfg.WebOrigin, "/")
|
|
if web == "" {
|
|
web = "http://localhost:5174"
|
|
}
|
|
|
|
if cfg.AllowMockPurchase() {
|
|
if err := s.applySalesQuotePurchase(ctx, in.CompanyID, in.PlanID, in.QuoteID, "cus_mock_"+in.CompanyID.String()[:8], "sub_mock_"+uuid.NewString()[:8], "price_mock_quote"); err != nil {
|
|
return SalesQuoteCheckoutResult{}, err
|
|
}
|
|
return SalesQuoteCheckoutResult{
|
|
URL: web + "/billing?checkout=success&mock=1&sales_quote=" + url.QueryEscape(in.QuoteID.String()),
|
|
Mock: true,
|
|
Applied: true,
|
|
Message: "Mock mode: custom sales quote plan assigned without Stripe.",
|
|
}, nil
|
|
}
|
|
if cfg.MockMode() {
|
|
return SalesQuoteCheckoutResult{}, ErrStripeNotConfigured
|
|
}
|
|
|
|
productID, err := s.ensureSalesQuoteProduct(ctx, in)
|
|
if err != nil {
|
|
return SalesQuoteCheckoutResult{}, err
|
|
}
|
|
priceID, err := s.createSalesQuotePrice(ctx, productID, in, count == 1)
|
|
if err != nil {
|
|
return SalesQuoteCheckoutResult{}, err
|
|
}
|
|
|
|
customerID, err := s.ensureCustomer(ctx, in.CompanyID, in.Email, in.CompanyName)
|
|
if err != nil {
|
|
return SalesQuoteCheckoutResult{}, err
|
|
}
|
|
|
|
form := url.Values{}
|
|
form.Set("success_url", web+"/billing?checkout=success&sales_quote="+url.QueryEscape(in.QuoteID.String()))
|
|
form.Set("cancel_url", web+"/billing?checkout=cancel&sales_quote="+url.QueryEscape(in.QuoteID.String()))
|
|
form.Set("client_reference_id", in.CompanyID.String())
|
|
form.Set("metadata[company_id]", in.CompanyID.String())
|
|
form.Set("metadata[kind]", "sales_quote")
|
|
form.Set("metadata[quote_id]", in.QuoteID.String())
|
|
form.Set("metadata[plan_id]", strconv.FormatInt(in.PlanID, 10))
|
|
form.Set("metadata[plan]", strings.ToLower(strings.TrimSpace(in.PlanName)))
|
|
form.Set("metadata[installment_count]", strconv.Itoa(count))
|
|
form.Set("metadata[installment_interval]", interval)
|
|
form.Set("line_items[0][price]", priceID)
|
|
form.Set("line_items[0][quantity]", "1")
|
|
form.Set("allow_promotion_codes", "true")
|
|
if customerID != "" {
|
|
form.Set("customer", customerID)
|
|
} else if in.Email != "" {
|
|
form.Set("customer_email", in.Email)
|
|
}
|
|
|
|
if count == 1 {
|
|
form.Set("mode", "payment")
|
|
form.Set("payment_intent_data[metadata][company_id]", in.CompanyID.String())
|
|
form.Set("payment_intent_data[metadata][kind]", "sales_quote")
|
|
form.Set("payment_intent_data[metadata][quote_id]", in.QuoteID.String())
|
|
form.Set("payment_intent_data[metadata][plan_id]", strconv.FormatInt(in.PlanID, 10))
|
|
} else {
|
|
form.Set("mode", "subscription")
|
|
form.Set("subscription_data[metadata][company_id]", in.CompanyID.String())
|
|
form.Set("subscription_data[metadata][kind]", "sales_quote")
|
|
form.Set("subscription_data[metadata][quote_id]", in.QuoteID.String())
|
|
form.Set("subscription_data[metadata][plan_id]", strconv.FormatInt(in.PlanID, 10))
|
|
form.Set("subscription_data[metadata][plan]", strings.ToLower(strings.TrimSpace(in.PlanName)))
|
|
cancelAt, err := salesQuoteCancelAt(time.Now().UTC(), count, interval)
|
|
if err != nil {
|
|
return SalesQuoteCheckoutResult{}, err
|
|
}
|
|
form.Set("subscription_data[cancel_at]", strconv.FormatInt(cancelAt.Unix(), 10))
|
|
}
|
|
|
|
var sess struct {
|
|
ID string `json:"id"`
|
|
URL string `json:"url"`
|
|
}
|
|
if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/checkout/sessions", form, &sess); err != nil {
|
|
return SalesQuoteCheckoutResult{}, err
|
|
}
|
|
if sess.URL == "" {
|
|
return SalesQuoteCheckoutResult{}, errors.New("stripe checkout session missing url")
|
|
}
|
|
return SalesQuoteCheckoutResult{
|
|
URL: sess.URL,
|
|
Mock: false,
|
|
ProductID: productID,
|
|
PriceID: priceID,
|
|
SessionID: sess.ID,
|
|
}, nil
|
|
}
|
|
|
|
func (s *StripeService) ensureSalesQuoteProduct(ctx context.Context, in SalesQuoteCheckoutInput) (string, error) {
|
|
metaKey := in.QuoteID.String()
|
|
q := url.QueryEscape(fmt.Sprintf("active:'true' AND metadata['descrybe_sales_quote']:'%s'", metaKey))
|
|
var search struct {
|
|
Data []struct {
|
|
ID string `json:"id"`
|
|
} `json:"data"`
|
|
}
|
|
if err := s.stripeGET(ctx, "https://api.stripe.com/v1/products/search?query="+q+"&limit=1", &search); err != nil {
|
|
return "", err
|
|
}
|
|
if len(search.Data) > 0 && strings.TrimSpace(search.Data[0].ID) != "" {
|
|
return search.Data[0].ID, nil
|
|
}
|
|
|
|
form := url.Values{}
|
|
name := strings.TrimSpace(in.PlanName)
|
|
if name == "" {
|
|
name = "Custom Descrybe plan"
|
|
}
|
|
form.Set("name", "Descrybe — "+name)
|
|
form.Set("description", fmt.Sprintf("Sales quote %s (%d installments)", in.QuoteID.String(), in.InstallmentCount))
|
|
form.Set("metadata[descrybe_sales_quote]", metaKey)
|
|
form.Set("metadata[company_id]", in.CompanyID.String())
|
|
form.Set("metadata[plan_id]", strconv.FormatInt(in.PlanID, 10))
|
|
var product struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/products", form, &product); err != nil {
|
|
return "", err
|
|
}
|
|
if strings.TrimSpace(product.ID) == "" {
|
|
return "", fmt.Errorf("stripe product missing id")
|
|
}
|
|
return product.ID, nil
|
|
}
|
|
|
|
func (s *StripeService) createSalesQuotePrice(ctx context.Context, productID string, in SalesQuoteCheckoutInput, oneTime bool) (string, error) {
|
|
form := url.Values{}
|
|
form.Set("product", productID)
|
|
form.Set("currency", strings.ToLower(strings.TrimSpace(in.Currency)))
|
|
if form.Get("currency") == "" {
|
|
form.Set("currency", "usd")
|
|
}
|
|
form.Set("unit_amount", strconv.Itoa(in.InstallmentAmountCents))
|
|
form.Set("metadata[descrybe_sales_quote]", in.QuoteID.String())
|
|
form.Set("metadata[plan_id]", strconv.FormatInt(in.PlanID, 10))
|
|
if oneTime {
|
|
// default type one_time
|
|
} else {
|
|
stripeInterval, intervalCount, err := stripeRecurringFromInstallment(in.InstallmentInterval)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
form.Set("recurring[interval]", stripeInterval)
|
|
if intervalCount > 1 {
|
|
form.Set("recurring[interval_count]", strconv.Itoa(intervalCount))
|
|
}
|
|
}
|
|
var price struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/prices", form, &price); err != nil {
|
|
return "", err
|
|
}
|
|
if strings.TrimSpace(price.ID) == "" {
|
|
return "", fmt.Errorf("stripe price missing id")
|
|
}
|
|
return price.ID, nil
|
|
}
|
|
|
|
func stripeRecurringFromInstallment(interval string) (stripeInterval string, intervalCount int, err error) {
|
|
switch strings.ToLower(strings.TrimSpace(interval)) {
|
|
case "month", "":
|
|
return "month", 1, nil
|
|
case "quarter":
|
|
return "month", 3, nil
|
|
case "year":
|
|
return "year", 1, nil
|
|
default:
|
|
return "", 0, fmt.Errorf("%w: installment interval %q", ErrStripePlanUnsupported, interval)
|
|
}
|
|
}
|
|
|
|
func salesQuoteCancelAt(now time.Time, count int, interval string) (time.Time, error) {
|
|
if count < 1 {
|
|
return time.Time{}, fmt.Errorf("%w: installment count", ErrStripePlanUnsupported)
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(interval)) {
|
|
case "month", "":
|
|
return now.AddDate(0, count, 0), nil
|
|
case "quarter":
|
|
return now.AddDate(0, count*3, 0), nil
|
|
case "year":
|
|
return now.AddDate(count, 0, 0), nil
|
|
default:
|
|
return time.Time{}, fmt.Errorf("%w: installment interval %q", ErrStripePlanUnsupported, interval)
|
|
}
|
|
}
|
|
|
|
func (s *StripeService) applySalesQuotePurchase(ctx context.Context, companyID uuid.UUID, planID int64, quoteID uuid.UUID, customerID, subscriptionID, priceID string) error {
|
|
if s.Billing == nil {
|
|
return errors.New("billing service not configured")
|
|
}
|
|
if planID <= 0 {
|
|
return fmt.Errorf("%w: plan_id", ErrStripePlanUnsupported)
|
|
}
|
|
if err := s.Billing.AssignPlan(ctx, companyID, planID, false, 0); err != nil {
|
|
return err
|
|
}
|
|
if customerID != "" {
|
|
_, _ = s.Pool.Exec(ctx, `
|
|
UPDATE companies SET stripe_customer_id = $2, updated_at = now() WHERE id = $1`,
|
|
companyID, customerID)
|
|
}
|
|
_, _ = s.Pool.Exec(ctx, `
|
|
UPDATE company_plans
|
|
SET stripe_subscription_id = NULLIF($2, ''), stripe_price_id = NULLIF($3, ''), updated_at = now()
|
|
WHERE company_id = $1 AND is_active = true`,
|
|
companyID, subscriptionID, priceID)
|
|
_ = s.setSubscriptionStatusNote(ctx, companyID, "active")
|
|
|
|
if quoteID != uuid.Nil {
|
|
_, err := s.Pool.Exec(ctx, `
|
|
UPDATE sales_quotes
|
|
SET status = 'paid', paid_at = COALESCE(paid_at, now()), updated_at = now()
|
|
WHERE id = $1 AND status <> 'canceled'`, quoteID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, _ = s.Pool.Exec(ctx, `
|
|
UPDATE sales_leads
|
|
SET status = 'won', updated_at = now()
|
|
WHERE id = (SELECT lead_id FROM sales_quotes WHERE id = $1)
|
|
AND status <> 'closed'`, quoteID)
|
|
}
|
|
return nil
|
|
}
|