Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user