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  }