github.com/benoitkugler/goacve@v0.0.0-20201217100549-151ce6e55dc8/server/acvegestion/hello_asso.go (about) 1 package acvegestion 2 3 import ( 4 "encoding/json" 5 "fmt" 6 "io/ioutil" 7 "net/http" 8 "strconv" 9 "time" 10 11 "github.com/benoitkugler/goACVE/server/core/apiserver" 12 rd "github.com/benoitkugler/goACVE/server/core/rawdata" 13 "github.com/benoitkugler/goACVE/server/shared" 14 15 "github.com/benoitkugler/goACVE/logs" 16 ) 17 18 const ( 19 dateLayoutHelloAsso = "2006-01-02T15:04:05" // format personnalisé imposé par HelloAsso 20 maxTry = 3 21 retryDelayMax = 10 // en seconds 22 typeDon = "DONATION" 23 24 resultsPerPage = 1000000 25 ) 26 27 type paramsNotificationHelloAsso struct { 28 // L’identifiant du paiement 29 Id string `json:"id" form:"id"` 30 // Action ID à requeter pour les infos complémentaires 31 ActionId string `json:"action_id" form:"action_id"` 32 } 33 34 type actionHelloAsso struct { 35 Id string `json:"id"` 36 Type string `json:"type"` 37 Amount float64 `json:"amount"` 38 39 // IdCampaign string `json:"id_campaign"` 40 // IdOrganism string `json:"id_organism"` 41 // IdPayment string `json:"id_payment"` 42 // DateString string `json:"date"` 43 // FirstName string `json:"first_name"` 44 // LastName string `json:"last_name"` 45 // Address string `json:"address"` 46 // ZipCode string `json:"zip_code"` 47 // City string `json:"city"` 48 // Country string `json:"country"` 49 // Email string `json:"email"` 50 // Status string `json:"status"` 51 // option_label ; custom_infos sont ignorés 52 } 53 54 type paiementHelloAsso struct { 55 // L’identifiant du paiement 56 Id string `json:"id"` 57 // La date 58 DateString string `json:"date"` 59 // Le montant du paiement 60 Amount float64 `json:"amount"` 61 // Type de paiement 62 Type string `json:"type"` 63 // Mode de paiement 64 Mean string `json:"mean"` 65 66 PayerFirstName string `json:"payer_first_name"` 67 PayerLastName string `json:"payer_last_name"` 68 PayerAddress string `json:"payer_address"` 69 PayerZipCode string `json:"payer_zip_code"` 70 PayerCity string `json:"payer_city"` 71 PayerCountry string `json:"payer_country"` 72 PayerEmail string `json:"payer_email"` 73 PayerSociety string `json:"payer_society"` 74 PayerIsSociety bool `json:"payer_is_society"` 75 76 // L’url du reçu 77 UrlReceipt string `json:"url_receipt" form:"url_receipt"` 78 Actions []actionHelloAsso `json:"actions"` 79 // champs ignorés : url_tax_receipt, actions 80 } 81 82 // vérifie qu'au moins une action liée est un don 83 func (p paiementHelloAsso) isDon() bool { 84 for _, ac := range p.Actions { 85 if ac.Type == typeDon { 86 return true 87 } 88 } 89 return false 90 } 91 92 type pagination struct { 93 Page int `json:"page"` 94 MaxPage int `json:"max_page"` 95 ResultPerPage int `json:"result_per_page"` 96 } 97 98 type paymentsResponse struct { 99 Resources []paiementHelloAsso `json:"resources,omitempty"` 100 Pagination pagination `json:"pagination,omitempty"` 101 } 102 103 // PingHelloAsso effectue une requête de test 104 // et renvoie l'éventuelle erreur. 105 func PingHelloAsso() error { 106 url := "https://api.helloasso.com/v3/organizations.json?results_per_page=1000" 107 _, err := requeteWithRetry(url, logs.HelloAsso) 108 return err 109 } 110 111 func parseDateHelloAsso(d string) rd.Date { 112 date, err := time.Parse(dateLayoutHelloAsso, d) 113 if err != nil { 114 date = time.Now() 115 } 116 return rd.Date(date) 117 } 118 119 func requeteWithRetry(url string, credences logs.CredencesHelloAsso) ([]byte, error) { 120 toRetry := func() (resp *http.Response, delay time.Duration, err error) { 121 req, err := http.NewRequest(http.MethodGet, url, nil) 122 if err != nil { 123 return nil, 0, fmt.Errorf("La requête vers HelloAsso est invalide : %s", err) 124 } 125 req.SetBasicAuth(credences.Id, credences.Key) 126 resp, err = http.DefaultClient.Do(req) 127 if err != nil { 128 return nil, 0, fmt.Errorf("La requête vers HelloAsso a échoué : %s", err) 129 } 130 if resp.StatusCode == 503 { // retry 131 retry := resp.Header.Get("Retry-After") 132 secs, err := strconv.Atoi(retry) 133 if err != nil || secs > retryDelayMax { 134 secs = 2 // on essaye 135 } 136 resp.Body.Close() // on relache les éventuelles ressources 137 return nil, time.Duration(secs) * time.Second, nil 138 } 139 return resp, 0, nil 140 } 141 142 nbRetry := 0 143 var ( 144 resp *http.Response 145 delay time.Duration 146 err error 147 ) 148 for nbRetry < maxTry { 149 resp, delay, err = toRetry() 150 if err != nil { // erreur innatendue : on s'arrête 151 return nil, err 152 } 153 if resp == nil { // on re-essaie 154 nbRetry += 1 155 time.Sleep(delay) 156 } else { 157 break 158 } 159 } 160 defer resp.Body.Close() 161 if resp.StatusCode != 200 { 162 return nil, fmt.Errorf("La requête vers HelloAsso a reçu le code : %d", resp.StatusCode) 163 } 164 165 b, err := ioutil.ReadAll(resp.Body) 166 if err != nil { 167 return nil, fmt.Errorf("Réponse du serveur HelloAsso invalide (code %d) : %s", resp.StatusCode, err) 168 } 169 return b, nil 170 } 171 172 func newDonFromPayment(payment paiementHelloAsso) apiserver.DonDonateur { 173 don := rd.Don{ 174 Valeur: rd.Euros(payment.Amount), 175 DateReception: parseDateHelloAsso(payment.DateString), 176 ModePaiement: rd.MPHelloasso, 177 Details: rd.String(payment.Id), // pourra être modifié 178 Infos: rd.InfoDon{IdPaiementHelloAsso: payment.Id}, 179 } 180 // la plupart des paiements sont français, 181 // et on utilise dans notre base plutôt le nom complet France 182 if payment.PayerCountry == "FRA" { 183 payment.PayerCountry = "France" 184 } 185 donateur := rd.BasePersonne{ 186 Prenom: rd.String(payment.PayerFirstName), 187 Nom: rd.String(payment.PayerLastName), 188 Adresse: rd.String(payment.PayerAddress), 189 CodePostal: rd.String(payment.PayerZipCode), 190 Ville: rd.String(payment.PayerCity), 191 Pays: rd.String(payment.PayerCountry), 192 Mail: rd.String(payment.PayerEmail), 193 } 194 return apiserver.DonDonateur{Don: don, Donateur: donateur} 195 } 196 197 func fetchAllPaiements(credences logs.CredencesHelloAsso) ([]paiementHelloAsso, error) { 198 url := fmt.Sprintf("https://api.helloasso.com/v3/payments.json?results_per_page=%d", resultsPerPage) 199 body, err := requeteWithRetry(url, logs.HelloAsso) 200 if err != nil { 201 return nil, err 202 } 203 var rep paymentsResponse 204 if err := json.Unmarshal(body, &rep); err != nil { 205 return nil, err 206 } 207 if rep.Pagination.MaxPage > 1 { 208 // on doit recommencer pour les résultats manquants 209 // cela ne devrait pas arriver en pratique, car `resultsPerPage` 210 // est très grand 211 return nil, fmt.Errorf("Le nombre de paiements à lire (%d) dépasse la quantité prévue (%d)", rep.Pagination.ResultPerPage*rep.Pagination.MaxPage, resultsPerPage) 212 } 213 return rep.Resources, nil 214 } 215 216 // cherche les nouveaux dons 217 func (ct Controller) importDonsHelloasso() ([]apiserver.DonDonateur, error) { 218 ps, err := fetchAllPaiements(logs.HelloAsso) 219 if err != nil { 220 return nil, err 221 } 222 223 dons, err := rd.SelectAllDons(ct.DB) 224 if err != nil { 225 return nil, err 226 } 227 228 // on ne renvoie que les dons n'ayant pas encore été importés 229 alreadyImported := map[string]bool{} 230 for _, don := range dons { 231 if don.ModePaiement == rd.MPHelloasso { 232 alreadyImported[don.Infos.IdPaiementHelloAsso] = true 233 } 234 } 235 var out []apiserver.DonDonateur 236 for _, paiement := range ps { 237 if alreadyImported[paiement.Id] { 238 continue 239 } 240 // un paiement pourrait ne pas venir d'un don 241 if !paiement.isDon() { 242 continue 243 } 244 out = append(out, newDonFromPayment(paiement)) 245 } 246 return out, nil 247 } 248 249 func (ct Controller) identifieDon(params apiserver.IdentifieDonIn) (out apiserver.IdentifieDonOut, err error) { 250 tx, err := ct.DB.Begin() 251 if err != nil { 252 return out, err 253 } 254 255 // on commence par le donateur 256 // la procédure de rattachement est simplifiée, car 257 // le profil entrant est n'est pas un élément de la DB. 258 if params.Rattache { 259 pers, err := rd.SelectPersonne(tx, params.IdPersonneTarget) 260 if err != nil { 261 return out, shared.Rollback(tx, err) 262 } 263 pers.BasePersonne = params.Modifications 264 out.Personne, err = pers.Update(tx) 265 } else { 266 pers := rd.Personne{BasePersonne: params.Modifications} 267 out.Personne, err = pers.Insert(tx) 268 } 269 if err != nil { 270 return out, shared.Rollback(tx, err) 271 } 272 273 // ... puis on crée le don 274 out.Don, err = params.Don.Insert(tx) 275 if err != nil { 276 return out, shared.Rollback(tx, err) 277 } 278 279 // ... puis le lien 280 out.Donateur = rd.DonDonateur{ 281 IdDon: out.Don.Id, 282 IdPersonne: rd.NewOptionnalId(out.Personne.Id), 283 } 284 err = rd.InsertManyDonDonateurs(tx, out.Donateur) 285 if err != nil { 286 return out, shared.Rollback(tx, err) 287 } 288 289 err = tx.Commit() 290 return out, err 291 }