Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
259 lines
8.4 KiB
Go
259 lines
8.4 KiB
Go
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)
|
|
}
|