ghanem / basata
A Laravel package that provides an interface to the Basata payment services API.
Requires
- php: ^8.1
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.5|^11.0|^12.0
README
A Laravel package that provides an interface to the Basata Cash Collector Channel API (spec v3.0.8) payment services.
Requirements
- PHP 8.1+
- Laravel 10, 11, 12, or 13
Installation
composer require ghanem/basata
Publish the configuration file:
php artisan vendor:publish --provider="Ghanem\Basata\BasataServiceProvider" --tag="basata-config"
Configuration
Add the following to your .env file:
BASATA_USERNAME=your-username BASATA_PASSWORD=your-password BASATA_URL=https://your-basata-api-url.com/ BASATA_TERMINAL_ID=your-terminal-id
BASATA_TERMINAL_ID is required. The Basata API requires a unique
External Terminal ID per terminal (spec FAQ Q3) — it identifies which
physical/logical terminal is making the request, not a login credential. If
it is empty, every request throws immediately with API error code 1024
(TerminalIdRequired) rather than being silently sent without one.
Coming from
ghanem/bee? This field used to be hardcoded to the literal string'1'for every request, for every installation. That was a bug — see Migrating fromghanem/beebelow.
Usage
You can use the Basata facade or resolve BasataService from the container.
Service & Category Information
use Ghanem\Basata\Facades\Basata; // Get all categories $categories = Basata::getCategoryList(); // Get category service list $categoryServices = Basata::getCategoryServiceList(); // Get the provider list (spec 5.1 takes no filter — the old $categoryId // argument was accepted and silently ignored, and is gone in this release) $providers = Basata::getProviderList(); // Get all services $services = Basata::getServiceList(); // Get service input/output parameters $inputParams = Basata::getServiceInputParameterList(); $outputParams = Basata::getServiceOutputParameterList();
Transactions
// Transaction inquiry — account_number and service_id are required (plus // service_version, auto-filled below); a missing one throws // BasataValidationException (code 1008) instead of silently defaulting. $inquiry = Basata::transactionInquiry([ 'account_number' => '12345', 'service_id' => 10, 'input_parameter_list' => [ ['key' => 'phone', 'value' => '0912345678'], ], ]); // Transaction payment — account_number, service_id, external_id, amount, // total_amount and quantity are all required (plus service_version, // auto-filled below). $payment = Basata::transactionPayment([ 'account_number' => '12345', 'service_id' => 10, 'external_id' => 'order-001', 'amount' => 100, 'service_charge' => 5, 'total_amount' => 105, 'quantity' => 1, 'inquiry_transaction_id' => $inquiry['data']['transaction_id'], 'input_parameter_list' => [], ]); // Get transaction details by ID $transaction = Basata::getTransaction(123); // Get transaction by external ID $transaction = Basata::getTransaction('order-001', 'external_id');
Basata::transactionInquiry()/Basata::transactionPayment() fill in
service_version automatically from getProviderList() before sending the
request, so you don't need to pass it yourself. This auto-fill only happens
through the facade/BasataService — getBillsAmount() and any direct
ApiClient usage do not get it and must supply service_version explicitly.
Pass your own service_version when you have one. GetProviderList
sends service_version: 0, which spec 5.1 defines as "force update the
service list" and FAQ A1 explicitly tells terminals not to do routinely
("store this value… check it periodically"). So:
- a
service_versionyou pass in the$dataarray wins and skips the lookup entirely; - otherwise the lookup runs, but the response is cached like the other
catalogue calls (
provider_list_{lang}), so it is not one force-refresh per transaction. CallBasata::clearCache('provider_list_en')(or the API's error 1025 "Incorrect service version") to refresh it.
Prepaid Card Recharge Confirmation
Per spec 5.9, a successful transactionPayment() for a service whose input
parameters include a card_data record must be confirmed afterwards:
use Ghanem\Basata\Enums\OperationStatus; Basata::confirmPrepaidCardRecharge( paymentTransactionId: $payment['data']['transaction_id'], status: OperationStatus::Success, // or OperationStatus::Fail );
Account & Billing
// Get account info $account = Basata::getAccountInfo(); // Get bills amount (performs an inquiry and returns the amount). // Unlike transactionInquiry(), getBillsAmount() does NOT auto-fill // service_version from getProviderList() — pass it yourself, or it throws // BasataValidationException (code 1008). $bills = Basata::getBillsAmount([ 'service_version' => 0, 'service_id' => 10, 'account_number' => '12345', ]);
Service Charge Calculation
// Calculate service charge for an amount $result = Basata::calculateServiceCharge([ 'service_id' => 10, 'amount' => 100, ]); // Returns: ['service_id' => 10, 'amount' => 100, 'service_charge' => 5, 'total_amount' => 105] // Reverse calculate (from total amount back to base amount) $result = Basata::calculateServiceChargeReverse([ 'service_id' => 10, 'amount' => 105, // total amount including charge ]); // Returns: ['service_id' => 10, 'amount' => 95.45, 'service_charge' => 9.55, 'total_amount' => 105]
Both are client-side calculations over the service's service_charge_list
(from the cached getServiceList()), and both fail loudly rather than
guessing:
- an unknown
service_idthrowsBasataNotFoundException(code 1018); - an amount outside every charge band throws
BasataValidationException(code 1022) instead of returning a zero charge that would then be posted.
calculateServiceChargeReverse() honours the band's percentage flag: a
percentage charge is extracted out of the total, a fixed charge is subtracted
from it. The band itself is matched on the resulting net amount, and
total_amount always round-trips back to the total you passed in.
All actions at a glance
| Method | Maps to API action |
|---|---|
getCategoryList() |
GetCategoryList |
getCategoryServiceList() |
GetCategoryServiceList |
getProviderList() |
GetProviderList |
getServiceList() |
GetServiceList |
getServiceInputParameterList() |
GetServiceInputParameterList |
getServiceOutputParameterList() |
GetServiceOutputParameterList |
getTransaction($id, 'id') |
GetTransactionDetails |
getTransaction($id, 'external_id') |
GetTransactionByExternalId |
getAccountInfo() |
GetAccountInfo |
transactionInquiry() |
TransactionInquiry |
transactionPayment() |
TransactionPayment |
confirmPrepaidCardRecharge() |
ConfirmPrepaidCardRecharge |
calculateServiceCharge() / calculateServiceChargeReverse() |
client-side calculation built on getServiceList() — not a separate API action |
getBillsAmount() |
client-side helper built on transactionInquiry() — not a separate API action |
confirmPrepaidCardRecharge() is new in this release; every other method
existed already and was re-verified field-by-field against the v3.0.8 spec.
Language Support
Most methods accept a language parameter (defaults to 'en', or
BASATA_LANGUAGE if set):
$categories = Basata::getCategoryList('ar'); $services = Basata::getServiceList('ar');
DTOs (Typed Responses)
Use *Dto methods for typed response objects instead of raw arrays/collections:
use Ghanem\Basata\DTOs\ApiResponse; use Ghanem\Basata\DTOs\TransactionResult; use Ghanem\Basata\DTOs\ServiceChargeResult; // API response DTO $response = Basata::getCategoryListDto(); // returns ApiResponse $response->success; // bool $response->data; // array $response->statusCode; // int $response->get('categories.0.name'); // dot notation access // Transaction DTO $tx = Basata::getTransactionDto(123); // returns TransactionResult $tx->transactionId; // int|string|null — the spec types transaction_id as a // String, so it is passed through verbatim (never cast) $tx->amount; // ?float $tx->serviceCharge; // ?float $tx->totalAmount; // ?float $tx->raw; // the full `data` block // GetTransactionDetails / GetTransactionByExternalId nest the record under // `data.transaction_details` (a Transaction Detail, PDF 4.10) while // TransactionInquiry / TransactionPayment return their fields flat in `data`. // TransactionResult reads the nested record first and falls back to the flat // level, so both shapes populate the same DTO. A Transaction Detail carries no // transaction_id or service_charge, so those are null for the report actions — // read the rest of the detail (status, provider_name, details_list, …) off // `$tx->raw['transaction_details']`. $inquiry = Basata::transactionInquiryDto($data); // TransactionResult $payment = Basata::transactionPaymentDto($data); // TransactionResult // Service charge DTO $charge = Basata::calculateServiceChargeDto([ 'service_id' => 10, 'amount' => 100, ]); // returns ServiceChargeResult $charge->serviceId; // int $charge->amount; // float $charge->serviceCharge; // float $charge->totalAmount; // float
Error Handling
The Basata API returns HTTP 200 even for a business failure — for
example insufficient balance or a transaction already in progress. Success is
never inferred from the HTTP status; it's read from the response body
("success": true). Anything else — "success": false, a missing success
key, an empty body, a scalar body, or a non-JSON body — is treated as a
failure.
Exactly one thing means success: an HTTP 2xx whose body says
"success": true. Everything else — a business failure, and a transport
or server failure (any non-2xx: 401, 404, 502, 504, …) — goes through the same
error layer and obeys the same basata.errors.throw setting. There is no path
where a 502 quietly returns an array that reads like a response, so
$payment['data']['transaction_id'] can never be silently null because the
gateway died — which matters most on transactionPayment(), where a 5xx is
exactly the case where the payment may already have executed.
For a non-2xx the exception's apiCode is the API's own error code when the
body carries one, and otherwise the HTTP status (e.g. 502); a bare status
matches no documented code, so it surfaces as BasataServerException. The
payload always includes status_code, link, and the request params
with login/password stripped.
By default, a failure throws a typed exception carrying the error code, message, and full payload:
use Ghanem\Basata\Exceptions\BasataException; use Ghanem\Basata\Exceptions\BasataInsufficientBalanceException; try { Basata::transactionPayment($data); } catch (BasataInsufficientBalanceException $e) { // $e->apiCode — int, e.g. 1016 // $e->getMessage() — the API's message text // $e->payload — array, the raw response body } catch (BasataException $e) { // catches every Basata exception — they all extend this base class }
Set basata.errors.throw to false (env BASATA_ERRORS_THROW=false) to get
the raw response payload back instead of an exception — useful for call
sites written against the old array-return contract:
BASATA_ERRORS_THROW=false
basata.errors.throw governs how a failed request is handled — a business
failure in the body, a non-2xx transport/server failure, or the client-side
rate limiter. It does
not cover pre-flight validation that runs before a request is ever sent —
a missing BASATA_TERMINAL_ID (code 1024) or a missing required field on
transactionInquiry()/transactionPayment() (code 1008/1017) always throws,
regardless of this setting, because there is no API response to fall back
to.
Exception classes
Every documented error code (spec section 6) maps to one of these, via
Ghanem\Basata\Enums\ErrorCode::exceptionClass(). An undocumented/unknown
code falls back to BasataServerException rather than being swallowed.
| Exception | Example codes |
|---|---|
BasataAuthenticationException |
1001 login required, 1002 password required, 1003 incorrect credentials, 1010 invalid user, 1012 change password required, 1013 permission denied |
BasataValidationException |
1004–1009, 1011 (language required), 1017 (wrong amount), 1020, 1022, 1024 (terminal_id required), 1025, 1019, 1028, 1029, 2001–2005 |
BasataDuplicateTransactionIdException (extends BasataValidationException) |
1023 — see Ambiguous payments |
BasataInsufficientBalanceException |
1016 |
BasataRateLimitException |
1033 |
BasataTransactionInProgressException |
1034 |
BasataNotFoundException |
1014 account not found, 1015 receiver account not found, 1018 unknown service, 1021 inquiry transaction not found, 1026 transaction not found, 1027 Beecard not found |
BasataServerException |
2000, 20000, and any code not in the table above |
Codes 1027–1029 refer to "Beecard" — the API's own product name for a physical prepaid card. That naming is kept verbatim rather than renamed to "Basatacard".
Ambiguous payments: retries, error 1023, and FAQ A10
Requests are retried on connection failure (see
Retry Mechanism) — including payments. If the connection
drops after Basata processed the payment, the retry re-posts the same
external_id and the API answers 1023 "Duplicate transaction ID". That
looks like a validation error but very often means your payment succeeded.
Per spec FAQ A10 (p.21), resolve the ambiguity by asking for the transaction by your own ID rather than assuming either outcome:
use Ghanem\Basata\Exceptions\BasataDuplicateTransactionIdException; try { $payment = Basata::transactionPayment($data); // $data['external_id'] = 'order-001' } catch (BasataDuplicateTransactionIdException $e) { // Do NOT re-submit with a new external_id — ask what happened first. $existing = Basata::getTransaction('order-001', 'external_id'); $status = $existing['data']['transaction_details']['status'] ?? null; // SUCCESS -> the payment went through; ERROR/DEPOSIT_ERROR -> it did not. }
BasataDuplicateTransactionIdException extends BasataValidationException,
so existing catch (BasataValidationException) blocks still catch it — but
catching it on its own lets you run the reconciliation above instead of
treating it as a caller bug. The same lookup is the right response to any
payment whose outcome you are unsure of (a timeout, a 5xx, a lost response):
always use a stable, caller-generated external_id so it stays answerable.
Main error codes
| Code | Name | Meaning |
|---|---|---|
| 1008 | DataRequired | A required data field is missing (used by the client-side validation on transactionInquiry/transactionPayment) |
| 1011 | LanguageRequired | language was not sent — this package always sends it |
| 1016 | InsufficientBalance | Terminal balance too low for the transaction |
| 1017 | WrongAmount | amount/total_amount missing or invalid |
| 1022 | WrongServiceCharge | Submitted service_charge doesn't match the server's calculation |
| 1023 | DuplicateTransactionId | external_id was already used — may mean the payment succeeded, see Ambiguous payments |
| 1024 | TerminalIdRequired | terminal_id missing — thrown client-side before the request is even sent if BASATA_TERMINAL_ID is unset |
| 1026 | TransactionNotFound | No transaction matches the given ID |
| 1033 | RateLimitExceeded | Client-side rate limiter tripped (see Rate Limiting) |
| 1034 | TransactionInProgress | The transaction is still processing; retry the inquiry later |
| 2000 / 20000 | InternalServerError / AmbiguousServerError | Basata-side failure |
See Ghanem\Basata\Enums\ErrorCode for the full list of ~35 codes and their
exact spec wording.
Transaction Status
Ghanem\Basata\Enums\TransactionStatus models the transaction lifecycle:
use Ghanem\Basata\Enums\TransactionStatus; $transaction = Basata::getTransaction(123); // GetTransactionDetails nests the record under `transaction_details` // (spec 5.11) — NOT directly under `data`. $status = TransactionStatus::from($transaction['data']['transaction_details']['status']); if ($status->isFinal()) { // stop polling }
This enum models the string statuses only — the ones returned by
GetTransactionDetails/GetTransactionByExternalIdand byTransactionPayment(spec 4.9/4.10/5.11). Spec §5.7 types theTransactionInquiryresponse'sstatusas an Integer (0, 1, 2, 3, 5, 6), soTransactionStatus::from()will not parse an inquiry response. Don't pass one to it.
| Status | Final? |
|---|---|
NEW |
No |
IN_PROGRESS |
No |
SUCCESS |
Yes |
ERROR |
Yes |
DEPOSIT_ERROR |
Yes |
CANCELLED |
Yes (not enumerated in the spec's finality table, but a cancelled transaction will not progress further) |
Use isFinal() to decide whether to keep polling getTransaction() for a
pending transaction.
Retry Mechanism
Failed API requests are automatically retried with exponential backoff:
BASATA_RETRY_TRIES=3 # Number of retry attempts BASATA_RETRY_DELAY=100 # Initial delay in milliseconds BASATA_RETRY_MULTIPLIER=2 # Backoff multiplier
Request/Response Logging
Enable logging to debug API calls. login/password are dropped from every
log line and from the error payload returned to the caller:
BASATA_LOG_ENABLED=true BASATA_LOG_CHANNEL=stack # Optional: specific log channel
Secrets in the payload itself are masked, on both the request and the response
side — GetTransactionDetails returns the voucher PIN and expiry date in
details_list (FAQ A10) and input_parameter_list can carry card_data
(§5.9). The value is replaced with [REDACTED], the field itself stays, so the
log is still useful. Matching is a case-insensitive substring test on the key
name (and on the key of a {"key": …, "value": …} pair), driven by
config('basata.logging.redact'):
// config/basata.php 'logging' => [ 'redact' => ['pin', 'card', 'voucher', 'serial', 'secret', 'password', 'expiry', 'account_number'], ],
Add your service's own parameter names to that list; remove an entry to un-redact it.
Caching
The provider, service and category lists are automatically cached to reduce API calls (transactions and reports never are):
BASATA_CACHE_ENABLED=true # Enabled by default BASATA_CACHE_TTL=3600 # Cache lifetime in seconds BASATA_CACHE_STORE=redis # Optional: specific cache store
// Clear all cached data Basata::clearCache(); // Clear specific cache key Basata::clearCache('category_list_en');
Rate Limiting
Limit the number of API requests per minute:
BASATA_RATE_LIMIT_ENABLED=true BASATA_RATE_LIMIT_MAX=60 # Max requests per minute
When the limit is hit, the request throws BasataRateLimitException (code
1033) instead of hitting the network — unless basata.errors.throw is
false, in which case the same shape payload is returned as an array.
Webhooks
Receive transaction status updates via webhooks:
BASATA_WEBHOOK_ENABLED=true BASATA_WEBHOOK_PATH=basata/webhook BASATA_WEBHOOK_SECRET=your-secret # Optional: signature validation
Listen for webhook events in your application:
use Ghanem\Basata\Events\BasataWebhookReceived; use Ghanem\Basata\Events\TransactionStatusUpdated; // Listen to all webhook events Event::listen(BasataWebhookReceived::class, function ($event) { // $event->event - event name (e.g. 'transaction.completed') // $event->payload - full webhook payload }); // Listen specifically to transaction status changes Event::listen(TransactionStatusUpdated::class, function ($event) { // $event->transactionId // $event->status // $event->payload });
Async / Queue Support
Process transactions asynchronously using Laravel queues:
BASATA_QUEUE_CONNECTION=redis # Optional: queue connection BASATA_QUEUE_NAME=payments # Optional: queue name
The queued jobs run the same validation as the synchronous calls, so every required field must be present or the job throws on the worker:
// Dispatch a single payment to the queue Basata::transactionPaymentAsync([ 'account_number' => '12345', 'service_id' => 10, 'external_id' => 'order-001', 'amount' => 100, 'service_charge' => 5, 'total_amount' => 105, 'quantity' => 1, ]); // Batch multiple transactions $batch = Basata::batchTransactions([ ['action' => 'payment', 'data' => [ 'account_number' => '12345', 'service_id' => 10, 'external_id' => 'order-002', 'amount' => 100, 'total_amount' => 105, 'quantity' => 1, ]], ['action' => 'inquiry', 'data' => [ 'account_number' => '123', 'service_id' => 11, ]], ['action' => 'payment', 'data' => [ 'account_number' => '12345', 'service_id' => 12, 'external_id' => 'order-003', 'amount' => 200, 'total_amount' => 210, 'quantity' => 1, ], 'lang' => 'ar'], ]); // Batch with callback event Basata::batchTransactions($transactions, App\Events\TransactionProcessed::class);
transactionPaymentAsync() dispatches TransactionStatusUpdated carrying the
API's actual status (SUCCESS, IN_PROGRESS, ERROR, DEPOSIT_ERROR) —
success: true only means the request was accepted, so listeners must check
$event->status (see Transaction Status) before
treating a payment as done. A response with no status at all is reported as
IN_PROGRESS.
Migrating from ghanem/bee
ghanem/basata is a republish, not a drop-in upgrade — Packagist names are
permanent, and this package renames every symbol to match the product's
actual name (the spec itself says "Bee" was only ever an internal codename).
There are no backwards-compatibility aliases. Update every reference below
deliberately.
Rename map
Old (ghanem/bee) |
New (ghanem/basata) |
|---|---|
composer require ghanem/bee |
composer require ghanem/basata |
Ghanem\Bee\ |
Ghanem\Basata\ |
Ghanem\Bee\BeeService |
Ghanem\Basata\BasataService |
Ghanem\Bee\BeeServiceProvider |
Ghanem\Basata\BasataServiceProvider |
Ghanem\Bee\Facades\Bee / Bee:: |
Ghanem\Basata\Facades\Basata / Basata:: |
Ghanem\Bee\Http\BeeWebhookController |
Ghanem\Basata\Http\BasataWebhookController |
Ghanem\Bee\Events\BeeWebhookReceived |
Ghanem\Basata\Events\BasataWebhookReceived |
config/bee.php, config('bee.*') |
config/basata.php, config('basata.*') |
Cache key prefix bee_ |
basata_ |
Webhook path bee/webhook |
basata/webhook |
Webhook signature header X-Bee-Signature |
X-Basata-Signature |
Environment variables
| Old | New |
|---|---|
BEE_USERNAME |
BASATA_USERNAME |
BEE_PASSWORD |
BASATA_PASSWORD |
BEE_URL |
BASATA_URL |
BEE_TERMINAL_ID (config key existed but was ignored — see below) |
BASATA_TERMINAL_ID (now required and actually used) |
BEE_LANGUAGE |
BASATA_LANGUAGE |
BEE_RETRY_TRIES / _DELAY / _MULTIPLIER |
BASATA_RETRY_TRIES / _DELAY / _MULTIPLIER |
BEE_LOG_ENABLED / _CHANNEL |
BASATA_LOG_ENABLED / _CHANNEL |
BEE_CACHE_ENABLED / _TTL / _STORE |
BASATA_CACHE_ENABLED / _TTL / _STORE |
BEE_RATE_LIMIT_ENABLED / _MAX |
BASATA_RATE_LIMIT_ENABLED / _MAX |
BEE_WEBHOOK_ENABLED / _PATH / _SECRET |
BASATA_WEBHOOK_ENABLED / _PATH / _SECRET |
BEE_QUEUE_CONNECTION / _NAME |
BASATA_QUEUE_CONNECTION / _NAME |
| — (did not exist) | BASATA_ERRORS_THROW (new, default true) |
⚠️ terminal_id is now required — read this before upgrading
ghanem/bee shipped a BEE_TERMINAL_ID config key, but the actual request
code never read it — every action method hardcoded terminal_id to the
literal string '1', on every single request, for every installation,
regardless of what you set. That was a bug, not a default: the spec requires
a unique External Terminal ID per terminal (FAQ Q3), and sending '1' from
every installation is indistinguishable from not identifying your terminal
at all.
ghanem/basata removes the hardcoded value. You must set
BASATA_TERMINAL_ID in your .env to your actual terminal ID before
upgrading — if it is empty, every API call now throws
BasataValidationException (API code 1024) instead of silently sending 1.
⚠️ Business failures now throw
ghanem/bee only checked the HTTP status code. A 200 OK response with
"success": false in the body (e.g. insufficient balance, transaction in
progress) was returned to your code as if it had succeeded. Any code that
inspected $result['success'] or relied on exceptions never being thrown
for these cases must be updated — see Error Handling, or
set BASATA_ERRORS_THROW=false to keep the old array-return behavior while
you migrate call sites incrementally.
⚠️ getProviderList() no longer takes a category ID
getProviderList(int $categoryId = 2, ?string $lang = null) is now
getProviderList(?string $lang = null). Spec 5.1 defines the action as taking
service_version only — the argument was accepted, documented, and never sent
anywhere. Drop it from your call sites; a positional getProviderList(2) now
passes 2 as the language.
⚠️ Missing required transaction fields now throw
ghanem/bee's transactionInquiry()/transactionPayment() silently
defaulted a missing amount to 1.5 and a missing service_id to 14 if
the caller forgot to pass them — meaning a bug in caller code could submit a
real 1.5 EGP payment against the wrong service instead of failing loudly.
ghanem/basata throws BasataValidationException for any missing required
field instead.
Testing
composer test
Sponsor
License
MIT