Skip to content

Plateforme de paie ​

Verser des centaines de salaires ou d'indemnités directement sur des portefeuilles Orange Money en automatisant la boucle sur POST/v1/payouts. Pour les très gros volumes (batch), contactez le support pour activer la fonctionnalité dédiée sur votre compte.

Architecture ​

[Votre paie]
    |
    | Boucle POST /v1/payouts (1 requête par salaire, avec Idempotency-Key)
    v
[SOAMTAR]
    |
    |-> payout 1 : portefeuille Orange Money SL
    |-> payout 2 : portefeuille Orange Money SL
    |-> payout 3 : portefeuille Orange Money SL
    '-> ...

Modèles ​

php
Schema::create('payroll_runs', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->string('reference');        // PAY-2026-04
    $table->date('period');
    $table->decimal('total_sle', 15, 2);
    $table->string('status');           // draft, processing, completed, failed
    $table->timestamps();
});

Schema::create('payroll_items', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->foreignUuid('payroll_run_id');
    $table->foreignUuid('employee_id');
    $table->string('phone');            // +232XXXXXXXX
    $table->decimal('amount_sle', 15, 2);
    $table->unsignedInteger('attempt')->default(1);   // une nouvelle tentative après un échec confirmé a une nouvelle clé
    $table->string('soamtar_payout_id')->nullable();
    $table->string('status')->default('queued');      // queued, unknown, pending, success, failed, rejected
    $table->text('error')->nullable();
    $table->timestamps();
});

Création des payouts ​

Chaque item garde la même Idempotency-Key tant que son issue n'est pas connue : run() peut donc être relancé après un crash ou un timeout sans payer deux fois. Relancez-le dans les 24 heures (les clés expirent après 24 h) ; au-delà, vérifiez le payout dans le dashboard avant de le renvoyer.

php
use Soamtar\Exceptions\ApiException;
use Soamtar\Exceptions\SoamtarException;

class PayrollService
{
    public function __construct(private SoamtarClient $soamtar) {}

    /** Peut être rappelé sans risque : n'envoie que les items dont l'issue est inconnue. */
    public function run(PayrollRun $run): void
    {
        $run->update(['status' => 'processing']);

        foreach ($run->items()->whereIn('status', ['queued', 'unknown'])->get() as $item) {
            try {
                $response = $this->soamtar->payouts()->create([
                    'merchant_ref' => "PAY-{$run->id}-{$item->id}-{$item->attempt}",
                    'amount' => $item->amount_sle,
                    'currency' => 'SLE',
                    'provider' => 'orange_money_sl',   // seul canal en Sierra Leone
                    'recipient' => [
                        'phone' => $item->phone,
                        'name' => $item->employee->full_name,
                    ],
                    'description' => "Salaire {$run->period->format('Y-m')} pour {$item->employee->full_name}",
                    'metadata' => ['payroll_item_id' => (string) $item->id],
                ], idempotencyKey: "payroll:{$item->id}:{$item->attempt}");   // même clé pour chaque réessai de cette tentative

                $item->update([
                    'status' => 'pending',             // le résultat final arrive par webhook
                    'soamtar_payout_id' => $response['data']['id'],
                    'error' => null,
                ]);
            } catch (ApiException $e) {
                // 429 / 5xx : le payout a peut-être été créé. L'item reste "unknown" et run()
                // sera rappelé plus tard : la même Idempotency-Key ne peut jamais payer deux fois.
                // Autre 4xx : refusé, rien n'a été envoyé (numéro, solde...) : intervention humaine.
                $item->update([
                    'status' => $e->isRetryable() ? 'unknown' : 'rejected',
                    'error' => $e->getErrorCode().': '.$e->getMessage(),
                ]);
            } catch (SoamtarException $e) {
                // Erreur réseau ou timeout : issue inconnue, comme pour un 5xx.
                $item->update(['status' => 'unknown', 'error' => $e->getMessage()]);
            }
        }
    }
}

Handler webhook ​

php
public function handle(Request $request, WebhookVerifier $verifier)
{
    $event = $verifier->verify($request);

    $itemId = $event['data']['metadata']['payroll_item_id'] ?? null;
    $item = PayrollItem::find($itemId);
    if (! $item || $item->soamtar_payout_id !== $event['data']['id']) return response('skip');

    // Les livraisons se répètent et peuvent arriver dans le désordre : un état final ne change plus.
    if (in_array($item->status, ['success', 'failed'], true)) return response('ok');

    match ($event['event']) {
        'payout.success' => $item->update(['status' => 'success']),
        'payout.failed' => $item->update(['status' => 'failed', 'error' => 'Payout échoué']),
        default => null,
    };

    $run = $item->run;
    if ($run->items()->whereIn('status', ['queued', 'unknown', 'pending'])->doesntExist()) {
        $run->update([
            'status' => $run->items()->whereIn('status', ['failed', 'rejected'])->exists()
                ? 'partially_completed'
                : 'completed',
        ]);
    }

    return response('ok');
}

Reprise sur échec ​

Ne relancez que les payouts que SOAMTAR a confirmés en échec (payout.failed) : aucun argent n'est parti. Ne relancez jamais un item après un timeout ou un 5xx avec une nouvelle clé : le premier payout est peut-être passé. Ces items restent unknown et sont renvoyés par run() avec leur clé d'origine.

php
class RetryFailedPayouts extends Command
{
    public function handle(PayrollService $payroll)
    {
        PayrollItem::where('status', 'failed')                // confirmé par un webhook payout.failed
            ->where('updated_at', '<', now()->subMinutes(30))
            ->each(fn (PayrollItem $item) => $item->update([
                'status' => 'queued',
                'attempt' => $item->attempt + 1,              // nouveau merchant_ref et nouvelle Idempotency-Key
                'soamtar_payout_id' => null,
            ]));

        PayrollRun::whereHas('items', fn ($q) => $q->whereIn('status', ['queued', 'unknown']))
            ->each(fn (PayrollRun $run) => $payroll->run($run));
    }
}

Audit ​

Export pour la comptabilité :

php
PayrollItem::with(['employee', 'run'])
    ->where('payroll_run_id', $run->id)
    ->get()
    ->map(fn ($i) => [
        'Employé' => $i->employee->full_name,
        'Téléphone' => $i->phone,
        'Montant SLE' => $i->amount_sle,
        'Statut' => $i->status,
        'Référence SOAMTAR' => $i->soamtar_payout_id,
        'Erreur' => $i->error,
    ]);

Recommandations ​

  • Approval workflow : deux personnes valident chaque run au-delà d'un seuil interne
  • Programmer le run un jour ouvré, le matin (heure de Freetown)
  • Retry quotidien sur les items failed
  • Vérifier que votre solde SOAMTAR couvre tout le run avant de le lancer
  • Dashboard : taux de succès par période

SOAMTAR Payment API Documentation.