package httpapi import ( "net/http" "strconv" "github.com/descrybe/descrybe-v2/apps/api/internal/billing" "github.com/descrybe/descrybe-v2/apps/api/internal/sales" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) func (s *Server) salesSvc() *sales.Service { if s.Sales != nil { return s.Sales } s.Sales = &sales.Service{Pool: s.Pool} return s.Sales } type salesContactBody struct { Name string `json:"name"` Email string `json:"email"` CompanyName string `json:"company_name"` Phone string `json:"phone"` Message string `json:"message"` EstimatedSKUs *int `json:"estimated_skus"` Source string `json:"source"` } // handleSalesContact is public (CSRF required, session optional). func (s *Server) handleSalesContact(w http.ResponseWriter, r *http.Request) { var body salesContactBody if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } in := sales.CreateLeadInput{ Name: body.Name, Email: body.Email, CompanyName: body.CompanyName, Phone: body.Phone, Message: body.Message, EstimatedSKUs: body.EstimatedSKUs, Source: body.Source, } if uid, ok := UserIDFromContext(r.Context()); ok && uid != uuid.Nil { in.UserID = &uid } if cid, ok := CompanyIDFromContext(r.Context()); ok && cid != uuid.Nil { in.CompanyID = &cid } lead, err := s.salesSvc().CreateLead(r.Context(), in) if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not submit contact request", err, sales.ClientError) return } JSON(w, http.StatusCreated, map[string]any{"lead": lead}) } func (s *Server) handleAdminListSalesLeads(w http.ResponseWriter, r *http.Request) { status := r.URL.Query().Get("status") q := r.URL.Query().Get("q") limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) leads, total, err := s.salesSvc().ListLeads(r.Context(), status, q, limit, offset) if err != nil { ClientOrLog(w, http.StatusInternalServerError, "could not list sales leads", err, sales.ClientError) return } JSON(w, http.StatusOK, map[string]any{"leads": leads, "total": total}) } func (s *Server) handleAdminGetSalesLead(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } lead, err := s.salesSvc().GetLead(r.Context(), id) if err != nil { ClientOrLog(w, http.StatusNotFound, "lead not found", err, sales.ClientError) return } quotes, err := s.salesSvc().ListQuotesForLead(r.Context(), id) if err != nil { ClientOrLog(w, http.StatusInternalServerError, "could not list quotes", err, sales.ClientError) return } JSON(w, http.StatusOK, map[string]any{"lead": lead, "quotes": quotes}) } type adminUpdateSalesLeadBody struct { Status *string `json:"status"` CompanyID *uuid.UUID `json:"company_id"` ClearCompany bool `json:"clear_company"` AdminNotes *string `json:"admin_notes"` } func (s *Server) handleAdminUpdateSalesLead(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } var body adminUpdateSalesLeadBody if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } lead, err := s.salesSvc().UpdateLead(r.Context(), id, sales.UpdateLeadInput{ Status: body.Status, CompanyID: body.CompanyID, ClearCompany: body.ClearCompany, AdminNotes: body.AdminNotes, }) if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not update lead", err, sales.ClientError) return } JSON(w, http.StatusOK, map[string]any{"lead": lead}) } type adminCreateSalesQuoteBody struct { CompanyID uuid.UUID `json:"company_id"` PlanName string `json:"plan_name"` MonthlyCredits int `json:"monthly_credits"` MaxProducts *int `json:"max_products"` Currency string `json:"currency"` TotalAmountCents int `json:"total_amount_cents"` InstallmentCount int `json:"installment_count"` InstallmentInterval string `json:"installment_interval"` TermMonths *int `json:"term_months"` PrepareCheckout bool `json:"prepare_checkout"` } func (s *Server) handleAdminCreateSalesQuote(w http.ResponseWriter, r *http.Request) { leadID, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } var body adminCreateSalesQuoteBody if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } var createdBy *uuid.UUID if uid, ok := UserIDFromContext(r.Context()); ok && uid != uuid.Nil { createdBy = &uid } quote, err := s.salesSvc().CreateQuote(r.Context(), leadID, sales.CreateQuoteInput{ CompanyID: body.CompanyID, PlanName: body.PlanName, MonthlyCredits: body.MonthlyCredits, MaxProducts: body.MaxProducts, Currency: body.Currency, TotalAmountCents: body.TotalAmountCents, InstallmentCount: body.InstallmentCount, InstallmentInterval: body.InstallmentInterval, TermMonths: body.TermMonths, CreatedByUserID: createdBy, }) if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not create quote", err, sales.ClientError) return } if body.PrepareCheckout { quote, err = s.prepareSalesQuoteCheckout(r, quote) if err != nil { ClientOrLog(w, http.StatusBadRequest, "quote created but checkout failed", err, func(e error) (string, bool) { if msg, ok := sales.ClientError(e); ok { return msg, true } return billing.ClientError(e) }) return } } JSON(w, http.StatusCreated, map[string]any{"quote": quote}) } func (s *Server) handleAdminPrepareSalesQuoteCheckout(w http.ResponseWriter, r *http.Request) { quoteID, err := uuid.Parse(chi.URLParam(r, "quoteID")) if err != nil { Error(w, http.StatusBadRequest, "invalid quote id") return } quote, err := s.salesSvc().GetQuote(r.Context(), quoteID) if err != nil { ClientOrLog(w, http.StatusNotFound, "quote not found", err, sales.ClientError) return } quote, err = s.prepareSalesQuoteCheckout(r, quote) if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not prepare checkout", err, func(e error) (string, bool) { if msg, ok := sales.ClientError(e); ok { return msg, true } return billing.ClientError(e) }) return } JSON(w, http.StatusOK, map[string]any{"quote": quote}) } func (s *Server) handleAdminMarkSalesQuoteSent(w http.ResponseWriter, r *http.Request) { quoteID, err := uuid.Parse(chi.URLParam(r, "quoteID")) if err != nil { Error(w, http.StatusBadRequest, "invalid quote id") return } quote, err := s.salesSvc().MarkQuoteSent(r.Context(), quoteID) if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not mark quote sent", err, sales.ClientError) return } JSON(w, http.StatusOK, map[string]any{"quote": quote}) } func (s *Server) prepareSalesQuoteCheckout(r *http.Request, quote sales.Quote) (sales.Quote, error) { if quote.PlanID == nil || *quote.PlanID <= 0 { return quote, sales.ErrQuoteNotReady } if quote.Status == "paid" || quote.Status == "canceled" { return quote, sales.ErrQuoteNotReady } var companyName, billingEmail string _ = s.Pool.QueryRow(r.Context(), `SELECT name FROM companies WHERE id = $1`, quote.CompanyID).Scan(&companyName) lead, err := s.salesSvc().GetLead(r.Context(), quote.LeadID) if err == nil { billingEmail = lead.Email } res, err := s.stripeSvc().CreateSalesQuoteCheckout(r.Context(), billing.SalesQuoteCheckoutInput{ QuoteID: quote.ID, CompanyID: quote.CompanyID, PlanID: *quote.PlanID, PlanName: quote.PlanName, Email: billingEmail, CompanyName: companyName, Currency: quote.Currency, TotalAmountCents: quote.TotalAmountCents, InstallmentCount: quote.InstallmentCount, InstallmentInterval: quote.InstallmentInterval, InstallmentAmountCents: quote.InstallmentAmountCents, }) if err != nil { return quote, err } if res.Applied { updated, getErr := s.salesSvc().GetQuote(r.Context(), quote.ID) if getErr != nil { return quote, getErr } return updated, nil } return s.salesSvc().MarkQuoteCheckoutReady(r.Context(), quote.ID, res.ProductID, res.PriceID, res.SessionID, res.URL) }