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 :
Ç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 :
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.listne 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_transactionet ses frais exacts — plus besoin de pro-rata. Ça nécessitecapture_method: manualet 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_transactionest 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