Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
528 lines
16 KiB
Go
528 lines
16 KiB
Go
package sales
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/mail"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidInput = errors.New("invalid input")
|
|
ErrNotFound = errors.New("not found")
|
|
ErrConflict = errors.New("conflict")
|
|
ErrQuoteNotReady = errors.New("quote not ready for checkout")
|
|
ErrCompanyMissing = errors.New("company required")
|
|
)
|
|
|
|
// Service manages sales leads and payment quotes.
|
|
type Service struct {
|
|
Pool *pgxpool.Pool
|
|
}
|
|
|
|
type Lead struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
CompanyName *string `json:"company_name,omitempty"`
|
|
Phone *string `json:"phone,omitempty"`
|
|
Message string `json:"message"`
|
|
EstimatedSKUs *int `json:"estimated_skus,omitempty"`
|
|
Source string `json:"source"`
|
|
Status string `json:"status"`
|
|
CompanyID *uuid.UUID `json:"company_id,omitempty"`
|
|
UserID *uuid.UUID `json:"user_id,omitempty"`
|
|
AdminNotes *string `json:"admin_notes,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type Quote struct {
|
|
ID uuid.UUID `json:"id"`
|
|
LeadID uuid.UUID `json:"lead_id"`
|
|
CompanyID uuid.UUID `json:"company_id"`
|
|
PlanID *int64 `json:"plan_id,omitempty"`
|
|
PlanName string `json:"plan_name"`
|
|
MonthlyCredits int `json:"monthly_credits"`
|
|
MaxProducts *int `json:"max_products,omitempty"`
|
|
Currency string `json:"currency"`
|
|
TotalAmountCents int `json:"total_amount_cents"`
|
|
InstallmentCount int `json:"installment_count"`
|
|
InstallmentInterval string `json:"installment_interval"`
|
|
InstallmentAmountCents int `json:"installment_amount_cents"`
|
|
TermMonths *int `json:"term_months,omitempty"`
|
|
StripeProductID *string `json:"stripe_product_id,omitempty"`
|
|
StripePriceID *string `json:"stripe_price_id,omitempty"`
|
|
StripeCheckoutSessionID *string `json:"stripe_checkout_session_id,omitempty"`
|
|
CheckoutURL *string `json:"checkout_url,omitempty"`
|
|
Status string `json:"status"`
|
|
CreatedByUserID *uuid.UUID `json:"created_by_user_id,omitempty"`
|
|
PaidAt *time.Time `json:"paid_at,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type CreateLeadInput struct {
|
|
Name string
|
|
Email string
|
|
CompanyName string
|
|
Phone string
|
|
Message string
|
|
EstimatedSKUs *int
|
|
Source string
|
|
CompanyID *uuid.UUID
|
|
UserID *uuid.UUID
|
|
}
|
|
|
|
type UpdateLeadInput struct {
|
|
Status *string
|
|
CompanyID *uuid.UUID
|
|
ClearCompany bool
|
|
AdminNotes *string
|
|
}
|
|
|
|
type CreateQuoteInput struct {
|
|
CompanyID uuid.UUID
|
|
PlanName string
|
|
MonthlyCredits int
|
|
MaxProducts *int
|
|
Currency string
|
|
TotalAmountCents int
|
|
InstallmentCount int
|
|
InstallmentInterval string
|
|
TermMonths *int
|
|
CreatedByUserID *uuid.UUID
|
|
}
|
|
|
|
func ClientError(err error) (msg string, ok bool) {
|
|
switch {
|
|
case err == nil:
|
|
return "", false
|
|
case errors.Is(err, ErrInvalidInput),
|
|
errors.Is(err, ErrNotFound),
|
|
errors.Is(err, ErrConflict),
|
|
errors.Is(err, ErrQuoteNotReady),
|
|
errors.Is(err, ErrCompanyMissing):
|
|
return err.Error(), true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func (s *Service) CreateLead(ctx context.Context, in CreateLeadInput) (Lead, error) {
|
|
name := strings.TrimSpace(in.Name)
|
|
email := strings.TrimSpace(strings.ToLower(in.Email))
|
|
message := strings.TrimSpace(in.Message)
|
|
source := strings.TrimSpace(in.Source)
|
|
if source == "" {
|
|
source = "pricing"
|
|
}
|
|
if err := validateLeadFields(name, email, message, source, in.CompanyName, in.Phone, in.EstimatedSKUs); err != nil {
|
|
return Lead{}, err
|
|
}
|
|
var companyName, phone *string
|
|
if v := strings.TrimSpace(in.CompanyName); v != "" {
|
|
companyName = &v
|
|
}
|
|
if v := strings.TrimSpace(in.Phone); v != "" {
|
|
phone = &v
|
|
}
|
|
var lead Lead
|
|
err := s.Pool.QueryRow(ctx, `
|
|
INSERT INTO sales_leads (
|
|
name, email, company_name, phone, message, estimated_skus, source, company_id, user_id
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING `+leadCols(),
|
|
name, email, companyName, phone, message, in.EstimatedSKUs, source, in.CompanyID, in.UserID,
|
|
).Scan(leadScan(&lead)...)
|
|
if err != nil {
|
|
return Lead{}, err
|
|
}
|
|
return lead, nil
|
|
}
|
|
|
|
func (s *Service) ListLeads(ctx context.Context, status, q string, limit, offset int) ([]Lead, int, error) {
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
status = strings.TrimSpace(strings.ToLower(status))
|
|
q = strings.TrimSpace(q)
|
|
|
|
where := []string{"1=1"}
|
|
args := []any{}
|
|
argN := 1
|
|
if status != "" && status != "all" {
|
|
where = append(where, fmt.Sprintf("status = $%d", argN))
|
|
args = append(args, status)
|
|
argN++
|
|
}
|
|
if q != "" {
|
|
where = append(where, fmt.Sprintf(`(
|
|
name ILIKE $%d OR email ILIKE $%d OR COALESCE(company_name, '') ILIKE $%d OR message ILIKE $%d
|
|
)`, argN, argN, argN, argN))
|
|
args = append(args, "%"+q+"%")
|
|
argN++
|
|
}
|
|
whereSQL := strings.Join(where, " AND ")
|
|
|
|
var total int
|
|
if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM sales_leads WHERE `+whereSQL, args...).Scan(&total); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
args = append(args, limit, offset)
|
|
rows, err := s.Pool.Query(ctx, `
|
|
SELECT `+leadCols()+`
|
|
FROM sales_leads
|
|
WHERE `+whereSQL+`
|
|
ORDER BY created_at DESC
|
|
LIMIT $`+fmt.Sprint(argN)+` OFFSET $`+fmt.Sprint(argN+1), args...)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]Lead, 0)
|
|
for rows.Next() {
|
|
var lead Lead
|
|
if err := rows.Scan(leadScan(&lead)...); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
out = append(out, lead)
|
|
}
|
|
return out, total, rows.Err()
|
|
}
|
|
|
|
func (s *Service) GetLead(ctx context.Context, id uuid.UUID) (Lead, error) {
|
|
var lead Lead
|
|
err := s.Pool.QueryRow(ctx, `
|
|
SELECT `+leadCols()+` FROM sales_leads WHERE id = $1`, id).Scan(leadScan(&lead)...)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Lead{}, ErrNotFound
|
|
}
|
|
return lead, err
|
|
}
|
|
|
|
func (s *Service) UpdateLead(ctx context.Context, id uuid.UUID, in UpdateLeadInput) (Lead, error) {
|
|
lead, err := s.GetLead(ctx, id)
|
|
if err != nil {
|
|
return Lead{}, err
|
|
}
|
|
status := lead.Status
|
|
if in.Status != nil {
|
|
status = strings.TrimSpace(strings.ToLower(*in.Status))
|
|
if !validLeadStatus(status) {
|
|
return Lead{}, fmt.Errorf("%w: status", ErrInvalidInput)
|
|
}
|
|
}
|
|
companyID := lead.CompanyID
|
|
if in.ClearCompany {
|
|
companyID = nil
|
|
} else if in.CompanyID != nil {
|
|
companyID = in.CompanyID
|
|
}
|
|
adminNotes := lead.AdminNotes
|
|
if in.AdminNotes != nil {
|
|
notes := strings.TrimSpace(*in.AdminNotes)
|
|
if utf8.RuneCountInString(notes) > 10000 {
|
|
return Lead{}, fmt.Errorf("%w: admin_notes too long", ErrInvalidInput)
|
|
}
|
|
if notes == "" {
|
|
adminNotes = nil
|
|
} else {
|
|
adminNotes = ¬es
|
|
}
|
|
}
|
|
err = s.Pool.QueryRow(ctx, `
|
|
UPDATE sales_leads
|
|
SET status = $2, company_id = $3, admin_notes = $4, updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING `+leadCols(), id, status, companyID, adminNotes).Scan(leadScan(&lead)...)
|
|
return lead, err
|
|
}
|
|
|
|
func (s *Service) ListQuotesForLead(ctx context.Context, leadID uuid.UUID) ([]Quote, error) {
|
|
rows, err := s.Pool.Query(ctx, `
|
|
SELECT `+quoteCols()+`
|
|
FROM sales_quotes
|
|
WHERE lead_id = $1
|
|
ORDER BY created_at DESC`, leadID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]Quote, 0)
|
|
for rows.Next() {
|
|
var q Quote
|
|
if err := rows.Scan(quoteScan(&q)...); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, q)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Service) GetQuote(ctx context.Context, id uuid.UUID) (Quote, error) {
|
|
var q Quote
|
|
err := s.Pool.QueryRow(ctx, `
|
|
SELECT `+quoteCols()+` FROM sales_quotes WHERE id = $1`, id).Scan(quoteScan(&q)...)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Quote{}, ErrNotFound
|
|
}
|
|
return q, err
|
|
}
|
|
|
|
func (s *Service) CreateQuote(ctx context.Context, leadID uuid.UUID, in CreateQuoteInput) (Quote, error) {
|
|
lead, err := s.GetLead(ctx, leadID)
|
|
if err != nil {
|
|
return Quote{}, err
|
|
}
|
|
if in.CompanyID == uuid.Nil {
|
|
return Quote{}, ErrCompanyMissing
|
|
}
|
|
planName := strings.TrimSpace(in.PlanName)
|
|
interval := strings.ToLower(strings.TrimSpace(in.InstallmentInterval))
|
|
currency := strings.ToLower(strings.TrimSpace(in.Currency))
|
|
if currency == "" {
|
|
currency = "usd"
|
|
}
|
|
count := in.InstallmentCount
|
|
if count <= 0 {
|
|
count = 1
|
|
}
|
|
if err := validateQuoteFields(planName, currency, in.TotalAmountCents, count, interval, in.MonthlyCredits, in.MaxProducts, in.TermMonths); err != nil {
|
|
return Quote{}, err
|
|
}
|
|
installmentAmount := in.TotalAmountCents / count
|
|
if installmentAmount <= 0 {
|
|
return Quote{}, fmt.Errorf("%w: installment amount", ErrInvalidInput)
|
|
}
|
|
// Prefer exact split: first N-1 equal, last absorbs remainder — store equal floor for Stripe recurring.
|
|
// ASSUMPTION: Stripe charges installment_amount_cents * count; remainder cents may be dropped.
|
|
if installmentAmount*count != in.TotalAmountCents {
|
|
installmentAmount = (in.TotalAmountCents + count - 1) / count
|
|
}
|
|
|
|
tx, err := s.Pool.Begin(ctx)
|
|
if err != nil {
|
|
return Quote{}, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var planID int64
|
|
desc := fmt.Sprintf("Custom sales deal for lead %s", lead.Email)
|
|
uniqueName := planName
|
|
var nameTaken bool
|
|
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM plans WHERE lower(name) = lower($1))`, planName).Scan(&nameTaken); err != nil {
|
|
return Quote{}, err
|
|
}
|
|
if nameTaken {
|
|
uniqueName = fmt.Sprintf("%s (%s)", planName, uuid.NewString()[:8])
|
|
}
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, term)
|
|
VALUES ($1, $2, $3, NULL, $4, true, 'monthly')
|
|
RETURNING id`,
|
|
uniqueName, desc, in.MonthlyCredits, in.MaxProducts,
|
|
).Scan(&planID)
|
|
if err != nil {
|
|
return Quote{}, fmt.Errorf("create custom plan: %w", err)
|
|
}
|
|
planName = uniqueName
|
|
|
|
var q Quote
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO sales_quotes (
|
|
lead_id, company_id, plan_id, plan_name, monthly_credits, max_products,
|
|
currency, total_amount_cents, installment_count, installment_interval,
|
|
installment_amount_cents, term_months, status, created_by_user_id
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'draft', $13
|
|
)
|
|
RETURNING `+quoteCols(),
|
|
leadID, in.CompanyID, planID, planName, in.MonthlyCredits, in.MaxProducts,
|
|
currency, in.TotalAmountCents, count, interval, installmentAmount, in.TermMonths, in.CreatedByUserID,
|
|
).Scan(quoteScan(&q)...)
|
|
if err != nil {
|
|
return Quote{}, err
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE sales_leads
|
|
SET status = CASE WHEN status IN ('won', 'closed') THEN status ELSE 'quoted' END,
|
|
company_id = COALESCE(company_id, $2),
|
|
updated_at = now()
|
|
WHERE id = $1`, leadID, in.CompanyID)
|
|
if err != nil {
|
|
return Quote{}, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return Quote{}, err
|
|
}
|
|
return q, nil
|
|
}
|
|
|
|
func (s *Service) MarkQuoteCheckoutReady(ctx context.Context, quoteID uuid.UUID, productID, priceID, sessionID, checkoutURL string) (Quote, error) {
|
|
var q Quote
|
|
err := s.Pool.QueryRow(ctx, `
|
|
UPDATE sales_quotes
|
|
SET stripe_product_id = NULLIF($2, ''),
|
|
stripe_price_id = NULLIF($3, ''),
|
|
stripe_checkout_session_id = NULLIF($4, ''),
|
|
checkout_url = NULLIF($5, ''),
|
|
status = 'ready',
|
|
updated_at = now()
|
|
WHERE id = $1 AND status IN ('draft', 'ready', 'sent')
|
|
RETURNING `+quoteCols(),
|
|
quoteID, productID, priceID, sessionID, checkoutURL,
|
|
).Scan(quoteScan(&q)...)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Quote{}, ErrQuoteNotReady
|
|
}
|
|
return q, err
|
|
}
|
|
|
|
func (s *Service) MarkQuoteSent(ctx context.Context, quoteID uuid.UUID) (Quote, error) {
|
|
var q Quote
|
|
err := s.Pool.QueryRow(ctx, `
|
|
UPDATE sales_quotes
|
|
SET status = 'sent', updated_at = now()
|
|
WHERE id = $1 AND status IN ('ready', 'sent')
|
|
RETURNING `+quoteCols(), quoteID).Scan(quoteScan(&q)...)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Quote{}, ErrNotFound
|
|
}
|
|
return q, err
|
|
}
|
|
|
|
func (s *Service) MarkQuotePaid(ctx context.Context, quoteID uuid.UUID) error {
|
|
tx, err := s.Pool.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var leadID uuid.UUID
|
|
err = tx.QueryRow(ctx, `
|
|
UPDATE sales_quotes
|
|
SET status = 'paid', paid_at = COALESCE(paid_at, now()), updated_at = now()
|
|
WHERE id = $1 AND status <> 'canceled'
|
|
RETURNING lead_id`, quoteID).Scan(&leadID)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE sales_leads
|
|
SET status = 'won', updated_at = now()
|
|
WHERE id = $1 AND status <> 'closed'`, leadID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func validateLeadFields(name, email, message, source, companyName, phone string, skus *int) error {
|
|
if utf8.RuneCountInString(name) < 1 || utf8.RuneCountInString(name) > 200 {
|
|
return fmt.Errorf("%w: name", ErrInvalidInput)
|
|
}
|
|
if _, err := mail.ParseAddress(email); err != nil || utf8.RuneCountInString(email) > 320 {
|
|
return fmt.Errorf("%w: email", ErrInvalidInput)
|
|
}
|
|
if utf8.RuneCountInString(message) < 1 || utf8.RuneCountInString(message) > 10000 {
|
|
return fmt.Errorf("%w: message", ErrInvalidInput)
|
|
}
|
|
if utf8.RuneCountInString(source) < 1 || utf8.RuneCountInString(source) > 64 {
|
|
return fmt.Errorf("%w: source", ErrInvalidInput)
|
|
}
|
|
if utf8.RuneCountInString(strings.TrimSpace(companyName)) > 200 {
|
|
return fmt.Errorf("%w: company_name", ErrInvalidInput)
|
|
}
|
|
if utf8.RuneCountInString(strings.TrimSpace(phone)) > 40 {
|
|
return fmt.Errorf("%w: phone", ErrInvalidInput)
|
|
}
|
|
if skus != nil && *skus < 0 {
|
|
return fmt.Errorf("%w: estimated_skus", ErrInvalidInput)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateQuoteFields(planName, currency string, total, count int, interval string, credits int, maxProducts, termMonths *int) error {
|
|
if utf8.RuneCountInString(planName) < 1 || utf8.RuneCountInString(planName) > 120 {
|
|
return fmt.Errorf("%w: plan_name", ErrInvalidInput)
|
|
}
|
|
if utf8.RuneCountInString(currency) < 3 || utf8.RuneCountInString(currency) > 10 {
|
|
return fmt.Errorf("%w: currency", ErrInvalidInput)
|
|
}
|
|
if total <= 0 || total > 10_000_000_000 {
|
|
return fmt.Errorf("%w: total_amount_cents", ErrInvalidInput)
|
|
}
|
|
if count < 1 || count > 60 {
|
|
return fmt.Errorf("%w: installment_count", ErrInvalidInput)
|
|
}
|
|
switch interval {
|
|
case "month", "quarter", "year":
|
|
default:
|
|
return fmt.Errorf("%w: installment_interval", ErrInvalidInput)
|
|
}
|
|
if credits < 0 {
|
|
return fmt.Errorf("%w: monthly_credits", ErrInvalidInput)
|
|
}
|
|
if maxProducts != nil && *maxProducts < 0 {
|
|
return fmt.Errorf("%w: max_products", ErrInvalidInput)
|
|
}
|
|
if termMonths != nil && (*termMonths < 1 || *termMonths > 120) {
|
|
return fmt.Errorf("%w: term_months", ErrInvalidInput)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validLeadStatus(s string) bool {
|
|
switch s {
|
|
case "new", "contacted", "quoted", "won", "closed":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func leadCols() string {
|
|
return `id, name, email, company_name, phone, message, estimated_skus, source, status,
|
|
company_id, user_id, admin_notes, created_at, updated_at`
|
|
}
|
|
|
|
func leadScan(l *Lead) []any {
|
|
return []any{
|
|
&l.ID, &l.Name, &l.Email, &l.CompanyName, &l.Phone, &l.Message, &l.EstimatedSKUs,
|
|
&l.Source, &l.Status, &l.CompanyID, &l.UserID, &l.AdminNotes, &l.CreatedAt, &l.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func quoteCols() string {
|
|
return `id, lead_id, company_id, plan_id, plan_name, monthly_credits, max_products, currency,
|
|
total_amount_cents, installment_count, installment_interval, installment_amount_cents,
|
|
term_months, stripe_product_id, stripe_price_id, stripe_checkout_session_id, checkout_url,
|
|
status, created_by_user_id, paid_at, created_at, updated_at`
|
|
}
|
|
|
|
func quoteScan(q *Quote) []any {
|
|
return []any{
|
|
&q.ID, &q.LeadID, &q.CompanyID, &q.PlanID, &q.PlanName, &q.MonthlyCredits, &q.MaxProducts,
|
|
&q.Currency, &q.TotalAmountCents, &q.InstallmentCount, &q.InstallmentInterval, &q.InstallmentAmountCents,
|
|
&q.TermMonths, &q.StripeProductID, &q.StripePriceID, &q.StripeCheckoutSessionID, &q.CheckoutURL,
|
|
&q.Status, &q.CreatedByUserID, &q.PaidAt, &q.CreatedAt, &q.UpdatedAt,
|
|
}
|
|
}
|