total(), vend $r->amount(). total() is the product price plus * your convenience fee. Debit amount() and you never collect the fee. * 2. Validate the PIN against $r->customerMsisdn() — the payer. For airtime * and data, $r->msisdn() is the recipient and may be someone else. * 3. Never store or log the PIN. * * Idempotency is already handled: VendStack.php makes sure vend() runs once per * reference, even if we retry. You don't need to check for duplicates. */ class VendStackHandler { /** * COMPLETE THE PURCHASE. This is the only required method. * * Validate the PIN, debit the wallet, deliver the product, and say what * happened. Return VendStack::done(...) or VendStack::failed(...). */ public function vend(VendStackRequest $r): array { // ------------------------------------------------------------------ // 1. Find the payer's wallet and check their PIN. // ------------------------------------------------------------------ // TODO: replace with your own model / table. $wallet = Wallet::where('phone', $r->customerMsisdn())->first(); if ($wallet === null) { // Tells VendStack to offer this customer sign-up (see register()). return VendStack::failed('No account found for this number.', 'account_not_found'); } // A scheduled or recurring order arrives with NO pin — the customer // isn't there to type one. They pre-authorised it, and the request's // signature is your proof it is really from VendStack. // // Don't want unattended debits? Replace this block with: // return VendStack::failed('Scheduled orders are not supported.'); if (! $r->isScheduled()) { // TODO: replace with your own PIN check. if (! Hash::check((string) $r->pin(), $wallet->pin_hash)) { // "invalid_pin" lets the customer try again instead of the chat ending. return VendStack::failed('Incorrect PIN.', 'invalid_pin'); } } // ------------------------------------------------------------------ // 2. Check the balance against TOTAL, not amount. // ------------------------------------------------------------------ if ($wallet->balance < $r->total()) { return VendStack::failed('Insufficient balance.', 'insufficient_funds'); } // ------------------------------------------------------------------ // 3. Deliver the product, then debit. // ------------------------------------------------------------------ // $r->service() is one of: airtime, data, cable, power, betting. // Electricity always arrives as "power". // // TODO: call whatever you already use to vend. A sketch: // // $result = match ($r->service()) { // 'airtime' => $vendor->airtime($r->msisdn(), $r->amount()), // 'data' => $vendor->data($r->msisdn(), $r->packageId()), // 'cable' => $vendor->cable($r->biller(), $r->customerId(), $r->packageId()), // 'power' => $vendor->power($r->biller(), $r->customerId(), $r->meterType(), $r->amount()), // 'betting' => $vendor->betting($r->biller(), $r->customerId(), $r->amount()), // }; // // if (! $result->successful) { // return VendStack::failed($result->message); // nothing debited // } // // $wallet->decrement('balance', $r->total()); // debit TOTAL // // return VendStack::done( // 'Delivered', // balance: $wallet->fresh()->balance, // shown to the customer // token: $result->token, // prepaid meter token, if any // units: $result->units, // e.g. "238.1 kWh" // ); throw new \LogicException('Fill in VendStackHandler::vend() with your vending logic.'); } /** * YOUR PROVIDERS for cable, electricity and betting. * * Delete this method if you only sell airtime and data. * * Keep the labels recognizable — on WhatsApp the customer types "Ikeja * Electric" or "DStv" and VendStack matches that against these labels to * work out which id to send you. */ public function billers(VendStackRequest $r): array { return VendStack::billers(match ($r->service()) { 'cable' => [ ['id' => 'dstv', 'label' => 'DStv'], ['id' => 'gotv', 'label' => 'GOtv'], ], 'power' => [ ['id' => 'ekedc', 'label' => 'Eko Electric (EKEDC)'], ['id' => 'ikedc', 'label' => 'Ikeja Electric (IKEDC)'], ], 'betting' => [ ['id' => 'bet9ja', 'label' => 'Bet9ja'], ], default => [], }); } /** * YOUR PRICE LIST for data bundles and cable bouquets. * * Delete this method if you only sell airtime, electricity and betting. * * For data you get $r->msisdn() and work out the network yourself. * For cable you get $r->biller(). Amounts are whole naira — the price the * customer pays. The id you return comes back as $r->packageId() on vend(). */ public function packages(VendStackRequest $r): array { if ($r->service() === 'data') { // TODO: your own bundles for this number's network. return VendStack::packages([ ['id' => 'd1gb', 'label' => '1GB / 30 days', 'amount' => 300], ['id' => 'd5gb', 'label' => '5GB / 30 days', 'amount' => 2500], ], network: 'MTN'); } // TODO: your own bouquets for $r->biller(). return VendStack::packages([ ['id' => 'compact', 'label' => 'DStv Compact', 'amount' => 10500], ]); } /** * CONFIRM A METER / SMARTCARD / BETTING ID before the customer pays. * * Delete this method if you only sell airtime and data. * * VendStack shows the name you return so the customer can check they're * paying the right account. $r->customerId() is the meter or card number. */ public function validate(VendStackRequest $r): array { // TODO: ask your provider who owns this identifier. // $account = $vendor->lookup($r->service(), $r->biller(), $r->customerId()); // // if (! $account) { // return VendStack::invalid(); // } // // return VendStack::valid( // $account->name, // address: $account->address, // optional, printed on the receipt // minAmount: $account->minVend, // optional vend limits; VendStack // maxAmount: $account->maxVend, // enforces them before calling you // ); return VendStack::invalid(); } /** * RECONCILE A TIMED-OUT PURCHASE. Optional, but strongly recommended. * * If our vend() call to you times out, the purchase may still have gone * through on your side. Rather than guess, we ask you here with the same * reference. Look it up and tell us what really happened. * * Without this method a timed-out order is reported to the customer as * "couldn't confirm" — never a false failure, but no confirmation either. */ public function status(VendStackRequest $r): array { // TODO: look the order up by reference. // $order = \App\Models\Order::where('reference', $r->reference())->first(); // // return VendStack::status(match ($order?->status) { // 'completed' => 'completed', // it went through // 'failed', null => 'failed', // it definitively did not // default => 'pending', // still settling; we'll tell the customer to check // }, balance: $order?->wallet_balance_after, token: $order?->token); return VendStack::status('pending'); } /** * THE CUSTOMER'S WALLET BALANCE. Optional. * * Implement it and WhatsApp shows customers their balance before they pay. * Note this one uses $r->msisdn(), not customerMsisdn(). */ public function balance(VendStackRequest $r): array { // TODO: your own lookup. return VendStack::balance( (int) Wallet::where('phone', $r->msisdn())->value('balance') ); } /** * WHERE CUSTOMERS TRANSFER TO TOP UP. Optional. * * Powers the "Fund Wallet" option. VendStack only displays these accounts — * you credit the wallet yourself when the transfer lands, from your own * bank webhook. Delete this and Fund Wallet reports it's unavailable. */ public function accounts(VendStackRequest $r): array { // TODO: the customer's dedicated/virtual account. return VendStack::accounts([ // ['bank' => 'Wema Bank', 'account_number' => '7020001111', 'account_name' => 'Your Brand / Ada Okafor'], ]); } /** * DOES THIS NUMBER HAVE AN ACCOUNT? Optional, recommended. * * Answer this and an unrecognized customer is sent to sign-up at the START * of the conversation, instead of hitting a dead end after choosing a * product and typing a PIN. * * Be precise: only an explicit false diverts them. Anything else is treated * as "don't know" and the conversation carries on as normal. */ public function authenticate(VendStackRequest $r): array { return VendStack::accountExists( Wallet::where('phone', $r->phone())->exists() ); } /** * SIGN A NEW CUSTOMER UP IN CHAT. Optional. * * When a number has no account, VendStack collects a name and a PIN over * WhatsApp and posts them here. Create the account with $r->phone() as the * identity and $r->pin() as their transaction PIN. * * Delete this and sign-up simply isn't offered. */ public function register(VendStackRequest $r): array { // TODO: create the account. // \App\Models\Wallet::create([ // 'phone' => $r->phone(), // 'name' => $r->name(), // 'pin_hash' => \Illuminate\Support\Facades\Hash::make((string) $r->pin()), // 'balance' => 0, // ]); return VendStack::registered(false, 'Sign-up is not available yet.'); } }