Crowdfunding
Une campagne où des centaines de donateurs (par exemple pour reconstruire une école à Kenema) contribuent vers un objectif, avec une règle « tout ou rien » ou une collecte libre.
Architecture
[Donateur 1] paie Le 500.00 ──┐
[Donateur 2] paie Le 200.00 ──┼──> [SOAMTAR] ──> vous agrégez par campagne
[Donateur N] paie ... ──┘
|
Fin de campagne
┌───────────┴───────────┐
Objectif atteint Objectif manqué
| |
Versement au porteur Remboursement de chaque donateur (/v1/refunds)Modèles
php
Schema::create('campaigns', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('title');
$table->text('description');
$table->foreignUuid('beneficiary_user_id'); // qui reçoit les fonds
$table->decimal('goal_sle', 15, 2);
$table->decimal('raised_sle', 15, 2)->default(0);
$table->date('ends_at');
$table->enum('mode', ['all_or_nothing', 'flexible']);
$table->enum('status', ['active', 'goal_reached', 'goal_missed', 'paid_out', 'refunded']);
$table->timestamps();
});
Schema::create('contributions', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('campaign_id');
$table->string('donor_name')->nullable();
$table->string('donor_phone'); // +232XXXXXXXX
$table->string('donor_email')->nullable();
$table->decimal('amount_sle', 15, 2);
$table->uuid('soamtar_id')->nullable();
$table->string('soamtar_reference')->nullable();
$table->enum('status', ['pending', 'confirmed', 'failed', 'refunded']);
$table->boolean('is_anonymous')->default(false);
$table->text('message')->nullable();
$table->timestamps();
});Contribution d'un donateur
php
class CampaignController
{
public function contribute(Campaign $campaign, ContributeRequest $request, SoamtarClient $soamtar)
{
abort_if($campaign->status !== 'active', 422, 'Campagne fermée');
abort_if($campaign->ends_at->isPast(), 422, 'Campagne expirée');
$contribution = Contribution::create([
'campaign_id' => $campaign->id,
'donor_name' => $request->is_anonymous ? null : $request->donor_name,
'donor_phone' => $request->donor_phone,
'donor_email' => $request->donor_email,
'amount_sle' => $request->amount,
'is_anonymous' => $request->is_anonymous,
'message' => $request->message,
'status' => 'pending',
]);
$payment = $soamtar->payments()->create([
'merchant_ref' => "CONTRIB-{$contribution->id}",
'amount' => $contribution->amount_sle,
'currency' => 'SLE',
'provider' => 'orange_money_sl',
'payer' => [
'phone' => $contribution->donor_phone,
'email' => $contribution->donor_email,
'name' => $contribution->donor_name,
],
'description' => "Don pour {$campaign->title}",
'return_url' => route('campaigns.thanks', [$campaign, $contribution]),
'cancel_url' => route('campaigns.show', $campaign),
'metadata' => [
'campaign_id' => (string) $campaign->id,
'contribution_id' => (string) $contribution->id,
],
], idempotencyKey: $contribution->id);
$contribution->update([
'soamtar_id' => $payment['data']['id'],
'soamtar_reference' => $payment['data']['reference'],
]);
return redirect()->away($payment['data']['payment_url']);
}
}Webhook handler
php
public function handle(Request $request, WebhookVerifier $verifier)
{
$event = $verifier->verify($request);
$contribId = $event['data']['metadata']['contribution_id'] ?? null;
$contrib = Contribution::find($contribId);
if (! $contrib) return response('skip');
DB::transaction(function () use ($contrib, $event) {
match ($event['event']) {
'payment.success' => $this->onConfirmed($contrib),
'payment.failed', 'payment.expired', 'payment.cancelled' => $contrib->update(['status' => 'failed']),
default => null,
};
});
return response('ok');
}
private function onConfirmed(Contribution $contrib): void
{
if ($contrib->status === 'confirmed') return; // idempotent
$contrib->update(['status' => 'confirmed']);
// Verrouiller la ligne de la campagne pour que deux dons simultanés ne perdent pas un incrément
$campaign = Campaign::whereKey($contrib->campaign_id)->lockForUpdate()->first();
$campaign->increment('raised_sle', $contrib->amount_sle);
if ($campaign->raised_sle >= $campaign->goal_sle && $campaign->status === 'active') {
$campaign->update(['status' => 'goal_reached']);
BeneficiaryNotifier::dispatch($campaign);
}
}Clôture des campagnes
Un cron quotidien clôture les campagnes :
php
class CloseExpiredCampaigns extends Command
{
public function handle(SoamtarClient $soamtar)
{
Campaign::where('status', 'active')
->whereDate('ends_at', '<=', today())
->each(function (Campaign $campaign) use ($soamtar) {
$reachedGoal = $campaign->raised_sle >= $campaign->goal_sle;
if ($reachedGoal || $campaign->mode === 'flexible') {
// mode flexible : on verse ce qui a été levé même si l'objectif n'est pas atteint
$this->payOutToBeneficiary($campaign, $soamtar);
} else {
$this->refundAllDonors($campaign, $soamtar);
}
});
}
private function payOutToBeneficiary(Campaign $campaign, SoamtarClient $soamtar): void
{
$beneficiary = $campaign->beneficiary;
$soamtar->payouts()->create([
'merchant_ref' => "CAMPAIGN-{$campaign->id}",
'amount' => round($campaign->raised_sle * 0.95, 2), // 5 % de commission plateforme
'currency' => 'SLE',
'provider' => 'orange_money_sl',
'recipient' => [
'phone' => $beneficiary->phone,
'name' => $beneficiary->full_name,
],
'description' => "Versement campagne {$campaign->title}",
], idempotencyKey: "payout:{$campaign->id}");
$campaign->update(['status' => 'paid_out']);
}
private function refundAllDonors(Campaign $campaign, SoamtarClient $soamtar): void
{
$campaign->update(['status' => 'goal_missed']);
// Chaque donateur est remboursé sur son paiement d'origine (recrédité sur son portefeuille Orange Money)
$campaign->contributions()->where('status', 'confirmed')->each(function (Contribution $c) use ($soamtar) {
$soamtar->refunds()->create([
'transaction_id' => $c->soamtar_id, // full refund: no amount
'reason' => 'campaign_goal_missed',
'description' => 'Remboursement : objectif de la campagne non atteint',
], idempotencyKey: "refund:{$c->id}");
$c->update(['status' => 'refunded']);
});
$campaign->update(['status' => 'refunded']);
}
}Recommandations UX
- Affichage en temps réel de
raised_sle(mise à jour par websocket à l'arrivée d'un webhook) - Page « Merci » qui appelle
verifyen secours si le webhook n'est pas encore arrivé - Reçu automatique par email
- Anonymisation stricte si
is_anonymous = true - Montants affichés comme à Freetown :
Le 1,250.00