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, } }