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
verifyas 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