Skip to content

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 verify en 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

SOAMTAR Payment API Documentation.