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
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.
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
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.
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é :
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