main.go 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. package main
  2. import (
  3. "bufio"
  4. "bytes"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "net/http"
  10. "net/url"
  11. "os"
  12. "strconv"
  13. "strings"
  14. "time"
  15. )
  16. const (
  17. baseURL = "https://api.abacatepay.com/v1"
  18. defaultTimeout = 15 * time.Second
  19. minWithdrawCents = 350
  20. )
  21. type apiEnvelope[T any] struct {
  22. Data T `json:"data"`
  23. Error json.RawMessage `json:"error"`
  24. }
  25. func getJSON[T any](path string, out *apiEnvelope[T]) error {
  26. apiKey := strings.TrimSpace(os.Getenv("ABACATEPAY_API_KEY"))
  27. if apiKey == "" {
  28. return errors.New("variável de ambiente ABACATEPAY_API_KEY não definida")
  29. }
  30. req, err := http.NewRequest(http.MethodGet, baseURL+path, nil)
  31. if err != nil {
  32. return err
  33. }
  34. req.Header.Set("Authorization", "Bearer "+apiKey)
  35. req.Header.Set("Accept", "application/json")
  36. client := &http.Client{Timeout: defaultTimeout}
  37. resp, err := client.Do(req)
  38. if err != nil {
  39. return err
  40. }
  41. defer resp.Body.Close()
  42. respBody, err := io.ReadAll(resp.Body)
  43. if err != nil {
  44. return err
  45. }
  46. if resp.StatusCode >= 400 {
  47. return fmt.Errorf("requisição falhou (%d): %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
  48. }
  49. if err := json.Unmarshal(respBody, out); err != nil {
  50. return fmt.Errorf("falha ao parsear resposta: %w", err)
  51. }
  52. return nil
  53. }
  54. func runCheckPixCommand(args []string) error {
  55. if len(args) != 1 {
  56. return errors.New("uso: abacate-cli checkpix <pix_id>")
  57. }
  58. pixID := strings.TrimSpace(args[0])
  59. if pixID == "" {
  60. return errors.New("o id do QRCode PIX não pode ser vazio")
  61. }
  62. query := url.Values{}
  63. query.Set("id", pixID)
  64. var envelope apiEnvelope[pixQrCodeStatusResponse]
  65. if err := getJSON("/pixQrCode/check?"+query.Encode(), &envelope); err != nil {
  66. return err
  67. }
  68. if len(envelope.Error) > 0 && string(envelope.Error) != "null" {
  69. return fmt.Errorf("erro ao checar QRCode: %s", sanitizeAPIError(envelope.Error))
  70. }
  71. fmt.Printf("status=%s\n", envelope.Data.Status)
  72. return nil
  73. }
  74. type pixQrCodeRequest struct {
  75. Amount int `json:"amount"`
  76. ExpiresIn int `json:"expiresIn,omitempty"`
  77. Description string `json:"description,omitempty"`
  78. Customer *pixCustomerInput `json:"customer,omitempty"`
  79. Metadata map[string]string `json:"metadata,omitempty"`
  80. }
  81. type pixCustomerInput struct {
  82. Name string `json:"name"`
  83. Cellphone string `json:"cellphone"`
  84. Email string `json:"email"`
  85. TaxID string `json:"taxId"`
  86. }
  87. type pixQrCodeResponse struct {
  88. ID string `json:"id"`
  89. Amount int `json:"amount"`
  90. Status string `json:"status"`
  91. BRCode string `json:"brCode"`
  92. BRCodeBase string `json:"brCodeBase64"`
  93. PlatformFee int `json:"platformFee"`
  94. CreatedAt string `json:"createdAt"`
  95. ExpiresAt string `json:"expiresAt"`
  96. }
  97. type pixQrCodeStatusResponse struct {
  98. Status string `json:"status"`
  99. ExpiresAt string `json:"expiresAt"`
  100. }
  101. type withdrawRequest struct {
  102. ExternalID string `json:"externalId"`
  103. Method string `json:"method"`
  104. Amount int `json:"amount"`
  105. Description string `json:"description,omitempty"`
  106. Pix withdrawPixData `json:"pix"`
  107. }
  108. type withdrawPixData struct {
  109. Type string `json:"type"`
  110. Key string `json:"key"`
  111. }
  112. type withdrawResponse struct {
  113. ID string `json:"id"`
  114. Status string `json:"status"`
  115. Kind string `json:"kind"`
  116. Amount int `json:"amount"`
  117. PlatformFee int `json:"platformFee"`
  118. ReceiptURL string `json:"receiptUrl"`
  119. ExternalID string `json:"externalId"`
  120. }
  121. func main() {
  122. if err := loadEnvFile(".env"); err != nil {
  123. exitWithError(fmt.Errorf("falha ao carregar .env: %w", err))
  124. }
  125. if len(os.Args) < 2 {
  126. printUsage()
  127. os.Exit(1)
  128. }
  129. cmd := os.Args[1]
  130. switch cmd {
  131. case "pix":
  132. if err := runPixCommand(os.Args[2:]); err != nil {
  133. exitWithError(err)
  134. }
  135. case "withdraw":
  136. if err := runWithdrawCommand(os.Args[2:]); err != nil {
  137. exitWithError(err)
  138. }
  139. case "checkpix":
  140. if err := runCheckPixCommand(os.Args[2:]); err != nil {
  141. exitWithError(err)
  142. }
  143. case "help", "-h", "--help":
  144. printUsage()
  145. default:
  146. exitWithError(fmt.Errorf("comando desconhecido: %s", cmd))
  147. }
  148. }
  149. func runPixCommand(args []string) error {
  150. if len(args) < 1 {
  151. return errors.New("uso: abacate-cli pix <valor> <descrição opcional> <tempo em segundos>")
  152. }
  153. if len(args) > 3 {
  154. return errors.New("muitos argumentos para o comando pix")
  155. }
  156. amountStr := args[0]
  157. description := ""
  158. expires := 1800
  159. if len(args) >= 2 {
  160. description = args[1]
  161. }
  162. if len(args) == 3 {
  163. secs, err := strconv.Atoi(args[2])
  164. if err != nil || secs <= 0 {
  165. return fmt.Errorf("tempo inválido: %q", args[2])
  166. }
  167. expires = secs
  168. }
  169. valueInCents, err := parseAmountToCents(amountStr)
  170. if err != nil {
  171. return err
  172. }
  173. req := pixQrCodeRequest{
  174. Amount: valueInCents,
  175. ExpiresIn: expires,
  176. Description: description,
  177. }
  178. var envelope apiEnvelope[pixQrCodeResponse]
  179. if err := postJSON("/pixQrCode/create", req, &envelope); err != nil {
  180. return err
  181. }
  182. if len(envelope.Error) > 0 && string(envelope.Error) != "null" {
  183. return fmt.Errorf("erro ao criar QRCode: %s", sanitizeAPIError(envelope.Error))
  184. }
  185. if envelope.Data.BRCode == "" || envelope.Data.ID == "" {
  186. return errors.New("resposta inválida: QRCode sem dados essenciais")
  187. }
  188. fmt.Println(envelope.Data.BRCode)
  189. fmt.Println("ID:", envelope.Data.ID)
  190. return nil
  191. }
  192. func runWithdrawCommand(args []string) error {
  193. if len(args) < 3 {
  194. return errors.New("uso: abacate-cli withdraw <valor> <tipo de chave> <chave> <descrição opcional>")
  195. }
  196. if len(args) > 4 {
  197. return errors.New("muitos argumentos para o comando withdraw")
  198. }
  199. amountStr := args[0]
  200. pixType := strings.ToUpper(args[1])
  201. pixKey := args[2]
  202. description := ""
  203. if len(args) == 4 {
  204. description = args[3]
  205. }
  206. valueInCents, err := parseAmountToCents(amountStr)
  207. if err != nil {
  208. return err
  209. }
  210. if valueInCents < minWithdrawCents {
  211. return fmt.Errorf("valor mínimo para saque é R$3,50")
  212. }
  213. req := withdrawRequest{
  214. ExternalID: fmt.Sprintf("withdraw-%d", time.Now().Unix()),
  215. Method: "PIX",
  216. Amount: valueInCents,
  217. Description: description,
  218. Pix: withdrawPixData{
  219. Type: pixType,
  220. Key: pixKey,
  221. },
  222. }
  223. var envelope apiEnvelope[withdrawResponse]
  224. if err := postJSON("/withdraw/create", req, &envelope); err != nil {
  225. return err
  226. }
  227. if len(envelope.Error) > 0 && string(envelope.Error) != "null" {
  228. return fmt.Errorf("erro ao criar saque: %s", sanitizeAPIError(envelope.Error))
  229. }
  230. resp := envelope.Data
  231. if resp.ID == "" {
  232. return errors.New("resposta inválida: saque sem identificador")
  233. }
  234. fmt.Printf("SUCESSO %s status=%s amount=%0.2f receipt=%s\n",
  235. resp.ID,
  236. resp.Status,
  237. float64(resp.Amount)/100,
  238. resp.ReceiptURL,
  239. )
  240. return nil
  241. }
  242. func parseAmountToCents(amount string) (int, error) {
  243. s := strings.TrimSpace(amount)
  244. if s == "" {
  245. return 0, errors.New("valor vazio")
  246. }
  247. s = strings.ReplaceAll(s, ",", ".")
  248. value, err := strconv.ParseFloat(s, 64)
  249. if err != nil {
  250. return 0, fmt.Errorf("valor inválido: %w", err)
  251. }
  252. if value <= 0 {
  253. return 0, errors.New("o valor precisa ser maior que zero")
  254. }
  255. cents := int(value*100 + 0.5)
  256. return cents, nil
  257. }
  258. func postJSON[T any](path string, payload any, out *apiEnvelope[T]) error {
  259. apiKey := strings.TrimSpace(os.Getenv("ABACATEPAY_API_KEY"))
  260. if apiKey == "" {
  261. return errors.New("variável de ambiente ABACATEPAY_API_KEY não definida")
  262. }
  263. body, err := json.Marshal(payload)
  264. if err != nil {
  265. return err
  266. }
  267. req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewBuffer(body))
  268. if err != nil {
  269. return err
  270. }
  271. req.Header.Set("Authorization", "Bearer "+apiKey)
  272. req.Header.Set("Content-Type", "application/json")
  273. client := &http.Client{Timeout: defaultTimeout}
  274. resp, err := client.Do(req)
  275. if err != nil {
  276. return err
  277. }
  278. defer resp.Body.Close()
  279. respBody, err := io.ReadAll(resp.Body)
  280. if err != nil {
  281. return err
  282. }
  283. if resp.StatusCode >= 400 {
  284. return fmt.Errorf("requisição falhou (%d): %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
  285. }
  286. if err := json.Unmarshal(respBody, out); err != nil {
  287. return fmt.Errorf("falha ao parsear resposta: %w", err)
  288. }
  289. return nil
  290. }
  291. func sanitizeAPIError(raw json.RawMessage) string {
  292. if len(raw) == 0 || string(raw) == "null" {
  293. return ""
  294. }
  295. var asObj map[string]any
  296. if err := json.Unmarshal(raw, &asObj); err == nil {
  297. if msg, ok := asObj["message"].(string); ok && msg != "" {
  298. if code, ok := asObj["code"].(string); ok && code != "" {
  299. return fmt.Sprintf("%s (%s)", msg, code)
  300. }
  301. return msg
  302. }
  303. return string(raw)
  304. }
  305. var asStr string
  306. if err := json.Unmarshal(raw, &asStr); err == nil && asStr != "" {
  307. return asStr
  308. }
  309. return string(raw)
  310. }
  311. func printUsage() {
  312. fmt.Println(`Uso:
  313. abacate-cli pix <valor> <descrição opcional> <tempo em segundos>
  314. abacate-cli withdraw <valor> <tipo de chave> <chave> <descrição opcional>
  315. abacate-cli checkpix <pix_id>
  316. Certifique-se de definir ABACATEPAY_API_KEY no ambiente.`)
  317. }
  318. func exitWithError(err error) {
  319. fmt.Fprintln(os.Stderr, "Erro:", err)
  320. os.Exit(1)
  321. }
  322. func loadEnvFile(path string) error {
  323. file, err := os.Open(path)
  324. if errors.Is(err, os.ErrNotExist) {
  325. return nil
  326. }
  327. if err != nil {
  328. return err
  329. }
  330. defer file.Close()
  331. scanner := bufio.NewScanner(file)
  332. for scanner.Scan() {
  333. line := strings.TrimSpace(scanner.Text())
  334. if line == "" || strings.HasPrefix(line, "#") {
  335. continue
  336. }
  337. key, value, found := strings.Cut(line, "=")
  338. if !found {
  339. continue
  340. }
  341. key = strings.TrimSpace(key)
  342. if key == "" {
  343. continue
  344. }
  345. value = strings.TrimSpace(value)
  346. value = strings.Trim(value, `"'`)
  347. if err := os.Setenv(key, value); err != nil {
  348. return err
  349. }
  350. }
  351. return scanner.Err()
  352. }