Files

528 lines
16 KiB
Go
Raw Permalink Normal View History

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 = &notes
}
}
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,
}
}