package handler import ( "encoding/json" "errors" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/gofrs/uuid/v5" "github.com/saveinmed/backend-go/internal/domain" ) const uploadsDir = "./uploads" const maxFileSize = 10 << 20 // 10 MB // ledgerEntryResponse is the frontend-compatible ledger entry format. type ledgerEntryResponse struct { ID string `json:"id"` CompanyID string `json:"company_id"` Amount int64 `json:"amount"` BalanceAfter int64 `json:"balance_after"` Description string `json:"description"` Type string `json:"type"` // "CREDIT" or "DEBIT" ReferenceID *string `json:"reference_id"` CreatedAt time.Time `json:"created_at"` } // mapLedgerType converts domain ledger types to frontend-expected CREDIT/DEBIT. func mapLedgerType(domainType string) string { switch strings.ToUpper(domainType) { case "WITHDRAWAL", "FEE", "REFUND_DEBIT": return "DEBIT" default: return "CREDIT" } } // toLedgerResponse converts a domain LedgerEntry to the frontend-compatible format. func toLedgerResponse(e domain.LedgerEntry) ledgerEntryResponse { amount := e.AmountCents if amount < 0 { amount = -amount } resp := ledgerEntryResponse{ ID: e.ID.String(), CompanyID: e.CompanyID.String(), Amount: amount, Description: e.Description, Type: mapLedgerType(e.Type), CreatedAt: e.CreatedAt, } if e.ReferenceID != nil { s := e.ReferenceID.String() resp.ReferenceID = &s } return resp } // UploadDocument handles KYC/license document upload via multipart form. // Accepts: multipart/form-data with field "file" or "document" (PDF/JPG/PNG) // and optional "document_type" or "type" for the doc type. func (h *Handler) UploadDocument(w http.ResponseWriter, r *http.Request) { usr, err := h.getUserFromContext(r.Context()) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } // Support both multipart (file upload) and JSON (URL reference) contentType := r.Header.Get("Content-Type") if strings.Contains(contentType, "multipart/form-data") { // --- Multipart file upload --- if err := r.ParseMultipartForm(maxFileSize); err != nil { http.Error(w, "file too large or invalid form (max 10MB)", http.StatusBadRequest) return } // Accept "file" or "document" field name for compatibility file, header, err := r.FormFile("file") if err != nil { file, header, err = r.FormFile("document") if err != nil { http.Error(w, "field 'file' or 'document' is required", http.StatusBadRequest) return } } defer file.Close() ext := strings.ToLower(filepath.Ext(header.Filename)) allowed := map[string]bool{".pdf": true, ".jpg": true, ".jpeg": true, ".png": true} if !allowed[ext] { http.Error(w, "only PDF, JPG and PNG files are allowed", http.StatusBadRequest) return } // Accept "document_type" or "type" field name for compatibility docType := strings.ToUpper(r.FormValue("document_type")) if docType == "" { docType = strings.ToUpper(r.FormValue("type")) } if docType == "" { docType = "LICENSE" } targetCompanyID := usr.CompanyID if targetIDStr := r.FormValue("target_company_id"); targetIDStr != "" && usr.Superadmin { parsedID, err := uuid.FromString(targetIDStr) if err == nil { targetCompanyID = parsedID } } // Save file companyDir := filepath.Join(uploadsDir, targetCompanyID.String()) if err := os.MkdirAll(companyDir, 0755); err != nil { http.Error(w, "failed to create upload directory", http.StatusInternalServerError) return } filename := fmt.Sprintf("%s_%s%s", strings.ToLower(docType), targetCompanyID.String()[:8], ext) destPath := filepath.Join(companyDir, filename) dst, err := os.Create(destPath) if err != nil { http.Error(w, "failed to save file", http.StatusInternalServerError) return } defer dst.Close() if _, err := io.Copy(dst, file); err != nil { http.Error(w, "failed to write file", http.StatusInternalServerError) return } fileURL := fmt.Sprintf("/api/v1/files/%s/%s", targetCompanyID.String(), filename) doc, err := h.svc.UploadDocument(r.Context(), targetCompanyID, docType, fileURL) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(doc) return } // --- JSON fallback (URL reference) --- var req struct { Type string `json:"type"` URL string `json:"url"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } doc, err := h.svc.UploadDocument(r.Context(), usr.CompanyID, req.Type, req.URL) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(doc) } // GetDocuments lists company KYC docs. // Returns: { "documents": [...] } func (h *Handler) GetDocuments(w http.ResponseWriter, r *http.Request) { usr, err := h.getUserFromContext(r.Context()) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } // Admin can query documents for any company via ?target_company_id= companyID := usr.CompanyID if targetIDStr := r.URL.Query().Get("target_company_id"); targetIDStr != "" { parsedID, err := uuid.FromString(targetIDStr) if err == nil { companyID = parsedID } } docs, err := h.svc.GetCompanyDocuments(r.Context(), companyID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if docs == nil { docs = []domain.CompanyDocument{} } writeJSON(w, http.StatusOK, map[string]any{"documents": docs}) } // ServeFile serves uploaded files from the local uploads directory. // Path: /api/v1/files/{company_id}/{filename} func (h *Handler) ServeFile(w http.ResponseWriter, r *http.Request) { parts := splitPath(r.URL.Path) if len(parts) < 2 { http.NotFound(w, r) return } companyID := parts[len(parts)-2] filename := parts[len(parts)-1] if strings.Contains(companyID, "..") || strings.Contains(filename, "..") { http.Error(w, "invalid path", http.StatusBadRequest) return } filePath := filepath.Join(uploadsDir, companyID, filename) http.ServeFile(w, r, filePath) } // GetLedger returns financial history in frontend-compatible format. // Returns: { "entries": [...], "total": N } func (h *Handler) GetLedger(w http.ResponseWriter, r *http.Request) { usr, err := h.getUserFromContext(r.Context()) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } page, _ := strconv.Atoi(r.URL.Query().Get("page")) pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size")) // Also support "limit" query param used by financialService.ts if limit, _ := strconv.Atoi(r.URL.Query().Get("limit")); limit > 0 && pageSize == 0 { pageSize = limit } res, err := h.svc.GetFormattedLedger(r.Context(), usr.CompanyID, page, pageSize) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Map to frontend-compatible format entries := make([]ledgerEntryResponse, 0, len(res.Items)) for _, e := range res.Items { entries = append(entries, toLedgerResponse(e)) } writeJSON(w, http.StatusOK, map[string]any{ "entries": entries, "total": res.TotalCount, }) } // GetBalance returns current wallet balance. // Returns: { "balance": N } (in cents) func (h *Handler) GetBalance(w http.ResponseWriter, r *http.Request) { usr, err := h.getUserFromContext(r.Context()) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } bal, err := h.svc.GetBalance(r.Context(), usr.CompanyID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]int64{"balance": bal}) } // RequestWithdrawal initiates a payout. // Accepts: { "amount_cents": N, "bank_info": "..." } OR { "amount": N } (frontend compat) func (h *Handler) RequestWithdrawal(w http.ResponseWriter, r *http.Request) { usr, err := h.getUserFromContext(r.Context()) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var req struct { AmountCents int64 `json:"amount_cents"` Amount int64 `json:"amount"` // Frontend compatibility alias BankInfo string `json:"bank_info"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Use amount_cents if provided, fall back to amount (both are in cents) amountCents := req.AmountCents if amountCents == 0 && req.Amount > 0 { amountCents = req.Amount } wd, err := h.svc.RequestWithdrawal(r.Context(), usr.CompanyID, amountCents, req.BankInfo) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, http.StatusCreated, wd) } // ListWithdrawals shows history of payouts. func (h *Handler) ListWithdrawals(w http.ResponseWriter, r *http.Request) { usr, err := h.getUserFromContext(r.Context()) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } wds, err := h.svc.ListWithdrawals(r.Context(), usr.CompanyID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if wds == nil { wds = []domain.Withdrawal{} } writeJSON(w, http.StatusOK, wds) } // unused import guard var _ = errors.New