Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user