Une classe à copier, trois appels à placer. Tout tient dans un fichier et une variable d'environnement.
Lisez d'abord Brancher une application en 5 minutes : la forme de l'événement, les codes de retour et la règle de l'external_ref y sont expliqués. Cette page-ci est le code.
external_ref
.env :
.env
INOSHOP_ENDPOINT=https://api.inovertix.com/api/inoshop/ingest INOSHOP_KEY=INOSHP_ING_votre_cle
config/services.php :
config/services.php
'inoshop' => [ 'endpoint' => rtrim((string) env('INOSHOP_ENDPOINT', ''), '/'), 'key' => env('INOSHOP_KEY'), // Court, et c'est le point le plus important de cette configuration : cet appel ne // doit jamais devenir la raison pour laquelle une souscription échoue. 'timeout' => (int) env('INOSHOP_TIMEOUT', 4), ],
app/Services/InoShopPush.php
<?php namespace App\Services; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; /** * Pousse les ventes vers INOSHOP. * * ═══ LA RÈGLE QUI GOUVERNE TOUT CE FICHIER ═══ * * Elle ne lève JAMAIS. Une remontée statistique ne doit pas pouvoir faire échouer * l'encaissement qu'elle documente : si INOSHOP est injoignable, le client doit quand même * être abonné. L'échec est journalisé, il n'est pas propagé. * * Et elle ne BLOQUE jamais : l'appel part en file quand il y en a une, avec un délai * d'attente court quand il n'y en a pas. Un intégrateur qui attend trois secondes devant * un formulaire finit par retirer l'appel. * * Rejouer est SANS RISQUE : INOSHOP est idempotent par `external_ref`. C'est ce qui rend * cette classe simple — pas de suivi d'état, pas de table locale, pas de « déjà envoyé ? ». */ class InoShopPush { /** Une vente. `$order` suit la forme documentée dans le guide de démarrage. */ public function created(string $externalRef, array $order): void { $this->send(['external_ref' => $externalRef, 'event' => 'created', 'order' => $order]); } /** Un changement d'état : payée, remboursée, annulée. */ public function statusChanged(string $externalRef, string $status, array $order = []): void { $this->send([ 'external_ref' => $externalRef, 'event' => 'status_changed', 'order' => array_merge($order, ['status' => $status]), ]); } /** Une correction (nom, montant) qui ne change PAS l'état de la vente. */ public function updated(string $externalRef, array $order): void { $this->send(['external_ref' => $externalRef, 'event' => 'updated', 'order' => $order]); } /** * Le catalogue. `$replace` DÉPUBLIE ce que la charge utile ne contient pas : à * n'utiliser que si elle est complète. */ public function products(array $products, bool $replace = false): void { $this->send(['products' => $products, 'replace' => $replace], 'products'); } // ----------------------------------------------------------------------- private function send(array $payload, string $path = 'orders'): void { if (blank(config('services.inoshop.key')) || blank(config('services.inoshop.endpoint'))) { return; // non configuré : on ne fait rien, et surtout on n'échoue pas } // En file quand il y en a une : la réponse au client ne dépend alors plus du tout // du réseau. `dispatch()` sur une file `sync` retombe sur l'appel direct, qui // reste borné par le délai d'attente ci-dessous — les deux chemins sont sûrs. dispatch(function () use ($payload, $path) { $this->call($payload, $path); })->afterResponse(); } private function call(array $payload, string $path, int $attempt = 1): void { try { $response = Http::withToken(config('services.inoshop.key')) ->timeout((int) config('services.inoshop.timeout', 4)) ->acceptJson() ->post(config('services.inoshop.endpoint').'/'.$path, $payload); if ($response->successful()) { // Un 200 peut contenir des refus par élément : les journaliser est la // seule façon de voir qu'un statut n'est pas mappé. foreach ((array) $response->json('results', []) as $result) { if (($result['result'] ?? '') === 'error') { Log::warning('INOSHOP: vente refusée', $result); } } return; } // 401 / 403 / 422 : réessayer ne changerait rien. On le dit, une fois. if ($response->status() < 429) { Log::warning('INOSHOP: refus définitif', [ 'status' => $response->status(), 'code' => $response->json('code'), ]); return; } throw new \RuntimeException('INOSHOP: '.$response->status()); } catch (\Throwable $e) { // Un retry simple, et un seul. Rejouer est sans risque (idempotence), mais // s'acharner ferait attendre un worker pour une donnée qu'on peut rattraper // à froid — le cockpit sait dire ce qu'il n'a jamais reçu. if ($attempt < 3) { sleep($attempt * 2); $this->call($payload, $path, $attempt + 1); return; } Log::warning('INOSHOP: vente non poussée — '.$e->getMessage(), [ 'external_ref' => $payload['external_ref'] ?? null, ]); } } }
C'est ce qui décide de la qualité du rapprochement Meta, et cela ne se rattrape pas après coup : au moment où votre serveur nous appelle, le navigateur du client n'est plus là.
// Contrôleur qui reçoit la souscription — là où le client est encore en ligne. $subscription->fill([ 'meta_fbp' => $request->cookie('_fbp'), // `_fbc` n'existe que si le visiteur est arrivé par une publicité. Quand le cookie // manque mais que l'URL porte `?fbclid=`, on le RECONSTRUIT au format Meta : c'est le // signal le plus précieux, et le laisser filer est la perte la plus fréquente. 'meta_fbc' => $request->cookie('_fbc') ?: $this->rebuildFbc($request), // Le cookie de session INOTRACK, si vous mesurez votre site avec nous. Il permet de // retrouver les signaux que vous n'avez pas — et de rattacher la vente au parcours. 'inotrack_session' => $request->cookie('ino_sid'), 'client_ip' => $request->ip(), 'client_user_agent' => substr((string) $request->userAgent(), 0, 400), 'source_url' => $request->headers->get('referer'), ])->save();
/** `?fbclid=XYZ` → `fb.1.{timestamp_ms}.XYZ`, le format que Meta attend. */ private function rebuildFbc(Request $request): ?string { $clickId = $request->query('fbclid'); return filled($clickId) ? 'fb.1.'.(int) (microtime(true) * 1000).'.'.$clickId : null; }
private function tracking(Subscription $subscription): array { return array_filter([ 'session_uid' => $subscription->inotrack_session, 'fbp' => $subscription->meta_fbp, 'fbc' => $subscription->meta_fbc, 'client_ip' => $subscription->client_ip, 'client_user_agent' => $subscription->client_user_agent, 'event_source_url' => $subscription->source_url, ]); }
Les mêmes valeurs voyagent avec le renouvellement : elles décrivent l'acquisition, et c'est l'acquisition que Meta cherche à reconnaître.
// Là où le paiement est acquitté — jamais avant. $push->created("sub_{$subscription->id}_".now()->format('Y-m'), [ 'number' => $subscription->reference, // « FIA-1042 » : ce que le client lit 'kind' => 'abonnement_nouveau', 'plan_name' => $subscription->plan->name, 'billing_period' => $subscription->plan->period, // « mensuel » / « annuel » 'subscription_ref' => (string) $subscription->id, 'customer' => [ 'name' => $subscription->user->name, 'email' => $subscription->user->email, 'phone' => $subscription->user->phone, 'city' => $subscription->user->city, 'country' => 'TG', ], 'amount_total' => $subscription->amount, 'currency' => 'XOF', 'lines' => [[ 'label' => $subscription->plan->name.' — '.now()->translatedFormat('F Y'), 'qty' => 1, 'unit_price' => $subscription->amount, ]], 'status' => 'paid', 'occurred_at' => now()->toIso8601String(), // AVEC le fuseau 'tracking' => $this->tracking($subscription), ]);
Le seul changement qui compte : une référence différente, et le type abonnement_renouvellement. La même référence chaque mois ferait disparaître onze ventes sur douze — c'est l'erreur qu'on voit le plus souvent.
abonnement_renouvellement
$push->created("sub_{$subscription->id}_".$period->format('Y-m'), [ 'number' => $invoice->reference, 'kind' => 'abonnement_renouvellement', // ← et non « nouveau » 'plan_name' => $subscription->plan->name, 'billing_period' => $subscription->plan->period, 'subscription_ref' => (string) $subscription->id, // ← le MÊME abonnement 'customer' => [ /* … */ ], 'amount_total' => $invoice->amount, 'currency' => 'XOF', 'status' => 'paid', 'occurred_at' => $invoice->paid_at->toIso8601String(), 'tracking' => $this->tracking($subscription), // les signaux d'origine ]);
Placez-le là où l'encaissement est confirmé (le webhook de votre prestataire de paiement, l'acquittement du prélèvement), jamais dans la tâche qui ÉMET l'échéance : une facture émise n'est pas une vente.
$push->statusChanged("sub_{$subscription->id}_".$period->format('Y-m'), 'refunded');
Vous pouvez rejouer cet appel autant de fois que nécessaire.
Une commande Artisan, mensuelle ou à chaque changement de tarif :
// app/Console/Commands/PushPlansToInoShop.php $push->products( Plan::where('is_active', true)->get()->map(fn (Plan $plan) => [ 'ref' => 'plan_'.$plan->slug, 'name' => $plan->name, 'price' => $plan->price, 'billing_period' => $plan->period, 'short_description' => $plan->tagline, 'url' => route('tarifs').'#'.$plan->slug, 'is_published' => true, ])->all(), // La liste est COMPLÈTE : on peut donc dépublier ce qui n'y est plus. replace: true, );
Un test qui ne dépend d'aucun réseau, et qui fige la seule chose vraiment fragile — l'unicité de la référence d'une échéance :
public function test_un_renouvellement_porte_une_reference_differente(): void { Http::fake(); $subscription = Subscription::factory()->create(); app(SubscriptionBilling::class)->renew($subscription, CarbonImmutable::parse('2026-09-01')); app(SubscriptionBilling::class)->renew($subscription, CarbonImmutable::parse('2026-10-01')); $refs = collect(Http::recorded()) ->map(fn ($pair) => $pair[0]->data()['external_ref']) ->all(); $this->assertSame(["sub_{$subscription->id}_2026-09", "sub_{$subscription->id}_2026-10"], $refs); }
La suite : Recette et dépannage.