Aller au contenu

Gestion des dons

Permet aux clubs de collecter des dons pour une association bénéficiaire directement depuis la billetterie. L'idée c'est que ça s'intègre dans le tunnel d'achat existant (Panier → Commande → Paiement) sans tout casser.


Approche technique : STI sur Article

Don hérite de Article via STI. C'est l'approche la plus propre parce que ça évite de toucher à Panier, Commande, MonteurCommande et tout le tunnel de paiement. Billets::Generator est déjà compatible — il skip les articles sans modele_article. La justification fonctionne via les LigneArticle que Don crée lui-même à l'after_create.

classDiagram
    class Article {
        +bigint id
        +string type
        +bigint campagne_don_id
        +bigint modele_article_id
        +bigint panier_id
        +bigint commande_id
        +integer total_ttc_cents
        +integer total_club_ttc_cents
        +boolean vendu
    }

    class Don {
        +generer_billet!() nil
        -initialiser_don()
    }

    class CampagneDon {
        +bigint id
        +bigint club_id
        +string nom
        +integer statut
        +integer frais_mode
        +integer montant_min_cents
        +integer montant_max_cents
        +jsonb montants_suggeres
        +boolean libre
        +boolean visible_minisite
        +string association_nom
        +string association_rna
        +date date_debut
        +date date_fin
        +montant_collecte()
        +progression()
    }

    class LigneArticle {
        +string nom
        +integer prix_ttc_cents
        +float taux_tva
    }

    class Club {
        +bigint id
        +string nom
    }

    Article <|-- Don : STI (type = "Don")
    Don --> CampagneDon : belongs_to
    CampagneDon --> Club : belongs_to
    Article "1" --> "many" LigneArticle : has_many

Migrations

Création de la table campagne_dons et ajout des champs STI et campagne_don_id dans articles :

class CreateCampagneDons < ActiveRecord::Migration[7.2]
  def change
    create_table :campagne_dons do |t|
      t.references :club,            null: false, foreign_key: true
      t.string  :nom,                null: false
      t.text    :description
      t.integer :statut,             null: false, default: 0
      t.date    :date_debut
      t.date    :date_fin
      t.boolean :visible_minisite,   null: false, default: true
      t.string  :association_nom
      t.string  :association_rna
      t.string  :association_siren  
      t.text    :association_adresse
      t.integer :frais_mode,         null: false, default: 0
      ...

      t.timestamps
    end

    add_index :campagne_dons, :slug, unique: true
    add_index :campagne_dons, [:club_id, :statut]
    add_foreign_key :articles, :campagne_dons
  end
end
class AddStiAndCampagneDonToArticles < ActiveRecord::Migration[7.2]
  def change
    add_column :articles, :type, :string
    add_column :articles, :campagne_don_id, :bigint, null: true
    add_index :articles, :type
    add_index :articles, :campagne_don_id
  end
end

Modèle Don < Article

class Don < Article
  belongs_to :campagne_don
  # modele_article est nil pour les dons
  belongs_to :modele_article, -> { with_discarded }, optional: true, validate: false

  validates :campagne_don, presence: true

  after_create :initialiser_don

  # on override generer_billet! pour ne rien faire pas de billet pour un don ou return si don dans article.rb
  def generer_billet!(*) = nil

  private

    def initialiser_don
      ligne_articles.create!(
        nom:            "Don — #{campagne_don.nom}",
        prix_ht_cents:  total_ttc_cents,
        tva_cents:      0,
        prix_ttc_cents: total_ttc_cents,
        taux_tva:       0,
        tva_label:      "0%"
      )
      # les dons sont exonérés de TVA
      update_columns(
        total_ht_cents:       total_ttc_cents,
        total_tva_cents:      0,
        total_club_ht_cents:  total_ttc_cents,
        total_club_tva_cents: 0,
        total_club_ttc_cents: total_ttc_cents
      )
    end
end

Ce qu'il faut modifier dans Article

Les validators et callbacks d'Article ne font pas sens pour un Don (pas de tarif, pas de place, pas d'événement). Il faut les bypasser :

# app/models/article.rb

before_validation :set_place_infos, unless: -> { is_a?(Don) }
before_validation :set_events,      unless: -> { vendu || is_a?(Don) }

validate unless: -> { skip_tarif_is_available || is_a?(Don) } do
  tarif_is_available
end

validates_with TarifValidator,                  unless: -> { is_a?(Don) }
validates_with SoldOutValidator,                unless: -> { is_a?(Don) }
validates_with LimitePlaceParPersonneValidator, unless: -> { is_a?(Don) }
validates_with PlaceLogiqueValidator,           unless: -> { is_a?(Don) }

Modèle CampagneDon

à définir, mais l'idée c'est que ça contient les infos sur la campagne de dons (nom, description, association bénéficiaire, dates, montants suggérés, etc.) et que chaque Don y est rattaché via campagne_don_id. Ça permettra aussi de faire des stats par campagne.


Gestion des frais Stripe

Sulf paie les frais Stripe sur le montant total du PaymentIntent y compris la portion dons. Le don doit quand même être transféré intégralement au club ? à confirmer

Trois modes configurables par campagne via frais_mode.

frais_plateforme (actuellement avec la bricole en place)

Sulf absorbe. Rien à faire de particulier.

frais_club

Sulf avance les frais et les récupère au versement suivant. Voilà comment ça marche :

Identifier les paiements avec dons

Au moment de créer le PaymentIntent, on renseigne les metadata :

metadata.contient_dons = "true"
metadata.montant_dons_cents = "1000"

Ça permet de filtrer en base ensuite sans avoir à parcourir tous les paiements.

Récupérer les frais Stripe

Deux options :

Option A — webhook charge.updated (temps réel)

Stripe envoie un événement charge.updated quelques secondes après le paiement, quand la balance_transaction est créée. Le webhook récupère les frais et les stocke dans payment_handler_payload :

{
  "charge_id": "ch_xxx",
  "balance_transaction_id": "txn_xxx",
  "frais_stripe_cents": 145,
  "net_stripe_cents": 7855
}

Au versement, on ventile au prorata :

frais_dons = frais_stripe_total × (montant_dons / montant_total_commande)

Option B — batch au versement

On stocke uniquement le charge_id au paiement (dispo dans finalize via payment_intent["latest_charge"]). Au versement, on récupère la balance_transaction à la volée pour chaque paiement concerné.

Attention : charge.balance_transaction retourne juste un ID string, pas l'objet. Il faut soit utiliser expand, soit faire deux appels :

# En un seul appel — expand doit être dans le premier argument (pas le deuxième)
charge = Stripe::Charge.retrieve({ id: charge_id, expand: ["balance_transaction"] })
frais_dons = charge.balance_transaction.fee * (montant_dons / montant_total)

# Ou en deux appels
charge = Stripe::Charge.retrieve(charge_id)
bt = Stripe::BalanceTransaction.retrieve(charge.balance_transaction)
frais_dons = bt.fee * (montant_dons / montant_total)

Stripe::BalanceTransaction.list ne peut pas filtrer par contenu ou metadata — impossible de récupérer directement les BT liées aux dons sans passer par la base.

Avantage de l'option B : pas de dépendance au webhook charge.updated. Inconvénient : N appels API au moment du versement.

Si on a la tarification IC+ : la multicapture permettrait deux captures séparées sur le même PaymentIntent (une pour les billets, une pour les dons), chacune avec sa propre balance_transaction et ses frais exacts — plus besoin de pro-rata. Ça nécessite capture_method: manual et la tarif IC+. Incompatible avec Klarna (cartes uniquement).

Au versement, les frais dons de la période sont déduits comme ligne débit :

Versement club
  recettes_web_ttc     :  5 000,00€
  frais_paiement       :   -150,00€
  frais_stripe_dons    :    -23,50€  ← frais avancés par Sulf sur les dons
  remboursements       :      0,00€
  ────────────────────────────────
  total_ttc            :  4 826,50€

frais_donateur

Le donateur peut cocher une case pour couvrir lui-même les frais :

Je couvre les frais de traitement pour que le club reçoive l'intégralité de mon don.

On estime les frais avant de créer le Don et on majore le montant :

Implémentation dans Payment::Handlers::Stripe

Il y a une question sur le moment du transfert des fonds au club. Deux approches :

Hypothèse A — transfert immédiat au finalize

Le don est transféré au club dès que le paiement est confirmé, via un Stripe::Transfer avec source_transaction. C'est la plus propre côté club (ils voient l'argent arriver tout de suite), mais ça ajoute un appel Stripe supplémentaire au moment du paiement et source_transaction est incompatible avec la multicapture IC+.

def finalize(opts = {})
  if opts[:payment_intent].is_a?(::Stripe::PaymentIntent)
    payment_intent = opts[:payment_intent]
    create_transfer(payment_intent["latest_charge"]) if club.club_option.transfer_enabled?
    create_don_transfer(payment_intent["latest_charge"]) if don_amount.positive?
    merge!(payment_intent_id: payment_intent.id, client_secret: payment_intent["client_secret"])
  else
    payment_intent = ::Stripe::PaymentIntent.retrieve(opts[:payment_intent])
    create_transfer(payment_intent["latest_charge"]) if club.club_option.transfer_enabled?
    create_don_transfer(payment_intent["latest_charge"]) if don_amount.positive?
    merge!(payment_intent_id: opts[:payment_intent], client_secret: payment_intent["client_secret"])
  end
end

def don_amount
  Money.from_cents(
    @store.commande.articles.where(type: "Don").sum(:total_club_ttc_cents)
  )
end

def create_don_transfer(charge_id)
  total_commande = @store.commande.total_ttc_cents
  total_dons     = don_amount.cents

  charge      = ::Stripe::Charge.retrieve({ id: charge_id, expand: ["balance_transaction"] })
  frais_total = charge.balance_transaction.fee
  frais_dons  = (frais_total * total_dons.to_f / total_commande).round

  transfer = ::Stripe::Transfer.create(
    amount:             total_dons,
    currency:           "eur",
    destination:        club.stripe_account_id,
    source_transaction: charge_id,
    metadata:           { type: "donation" }
  )

  merge!({
    "don_transfer_id"   => transfer.id,
    "frais_stripe_dons" => frais_dons
  })
end

source_transaction est incompatible avec la multicapture — à adapter si on passe en IC+.

Hypothèse B — transfert via le versement

Le don transite par le versement comme n'importe quel autre article. Pas de transfert immédiat. Les dons s'accumulent dans les recettes du club et sont reversés selon le cycle de versement normal.

Avantage : aucun appel Stripe supplémentaire au paiement, pas de contrainte source_transaction, compatible multicapture.

Inconvénient : le club attend le versement pour recevoir les fonds du don — potentiellement plusieurs semaines.

Dans ce cas, les Don restent dans get_articles_paiement (on ne les exclut pas), et create_don_transfer n'existe pas. Les frais Stripe sur les dons sont déjà couverts par le calcul de frais_paiement existant (pro-rata automatique sur le total de la commande).


Operation + MonteurOperations

Ajouter don: 9 dans l'enum

enum :genre_operation, {
  frais: 0, commission: 1, element_produit_compose: 2,
  produit_compose_frais_envoi: 3, frais_generaux_commande: 4,
  frais_reglement: 5, entree_simple: 6, formule: 7, autre: 8,
  don: 9
}
# app/models/monteur_operations.rb
def extraire_genre_operation(article)
  return :don           if article.is_a?(Don)
  return :entree_simple if article.modele_article.event_simple?
  return :formule       if article.modele_article.formule?
  :autre
end