Skip to content

Crowdfunding ​

A campaign where hundreds of donors (for example to rebuild a school in Kenema) contribute towards a goal, with an "all-or-nothing" rule or open collection.

Architecture ​

[Donor 1] pays Le 500.00 ──┐
[Donor 2] pays Le 200.00 ──┼──> [SOAMTAR] ──> you aggregate per campaign
[Donor N] pays ...        ──┘
                                     |
                              End of campaign
                         ┌───────────┴───────────┐
                   Goal reached             Goal missed
                         |                       |
             Payout to the organiser    Refund every donor (/v1/refunds)

Models ​

php
Schema::create('campaigns', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->string('title');
    $table->text('description');
    $table->foreignUuid('beneficiary_user_id');           // who receives the funds
    $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();
});

A donor's contribution ​

php
class CampaignController
{
    public function contribute(Campaign $campaign, ContributeRequest $request, SoamtarClient $soamtar)
    {
        abort_if($campaign->status !== 'active', 422, 'Campaign closed');
        abort_if($campaign->ends_at->isPast(), 422, 'Campaign expired');

        $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' => "Donation to {$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']);

    // Lock the campaign row so two simultaneous donations cannot lose an increment
    $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);
    }
}

End-of-campaign processor ​

A daily cron closes the campaigns:

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') {
                    // flexible mode: pay out what was raised even if the goal was missed
                    $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% platform commission
            'currency' => 'SLE',
            'provider' => 'orange_money_sl',
            'recipient' => [
                'phone' => $beneficiary->phone,
                'name' => $beneficiary->full_name,
            ],
            'description' => "Campaign payout {$campaign->title}",
        ], idempotencyKey: "payout:{$campaign->id}");

        $campaign->update(['status' => 'paid_out']);
    }

    private function refundAllDonors(Campaign $campaign, SoamtarClient $soamtar): void
    {
        $campaign->update(['status' => 'goal_missed']);

        // Each donor is refunded on their original payment (credited back to their Orange Money wallet)
        $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' => 'Refund: campaign goal not reached',
            ], idempotencyKey: "refund:{$c->id}");

            $c->update(['status' => 'refunded']);
        });

        $campaign->update(['status' => 'refunded']);
    }
}

UX recommendations ​

  • Real-time display of raised_sle (websocket update when a webhook arrives)
  • A "Thank you" page that calls verify as a fallback if the webhook has not arrived yet
  • Automatic email receipt
  • Strict anonymisation when is_anonymous = true
  • Show amounts the Freetown way: Le 1,250.00

SOAMTAR Payment API Documentation.