Appearance
User Wallet
Current version: v0.0.15
telegram-bot-essentials/user-wallet (namespace TelegramBotEssentials\UserWallet) gives every bot user a per-bot balance/credit wallet: top up via Billing invoices, spend as a payment gateway on other invoices, and admin-adjust manually. It's also the reference implementation for extending User Management and for Billing's gateway registry.
Installation
bash
composer require telegram-bot-essentials/user-wallet
php artisan migrateEnable it per-bot via the billing.user_wallet.status setting (a CHECKBOX, default false) — every wallet action is gated behind it.
Core Concepts
The wallet relation
The package attaches a wallet relation to Essence's BotUser model at runtime via resolveRelationUsing() — no need to modify or extend BotUser yourself:
php
BotUser::resolveRelationUsing('wallet', function (BotUser $model) {
return $model->hasOne(BotUserWallet::class, 'bot_user_id')
->withDefault(function (BotUserWallet $wallet, BotUser $user) {
$wallet->bot_id = wHook()->bot()->id;
$wallet->bot_user_id = $user->id;
$wallet->balance = 0;
$wallet->save();
});
});The withDefault() closure means $botUser->wallet always returns a real, saved BotUserWallet row — the first access for a new user silently creates the zero-balance row rather than returning a null/empty model. One wallet row per (bot_id, bot_user_id) pair.
The Wallet service
Helper: wallet(). All balance mutations go through it — never touch $botUser->wallet->balance directly, since every method here row-locks the wallet inside a DB transaction to keep concurrent mutations (e.g. two invoices paying out at once) from racing:
php
wallet()->currentUserWalletBalance(): BigDecimal; // read (no lock)
wallet()->addAmount(BigDecimal|string $amount): void; // top up + notifies the user
wallet()->takeAmount(BigDecimal|string $amount): void; // spend + notifies the user, throws InsufficientBalanceException if short
wallet()->setAmount(BigDecimal|string $amount): void; // hard-set balance + notifies the user
wallet()->adjustBalance(BigDecimal|string $amount, bool $allowNegative = false): void;addAmount() / takeAmount() / setAmount() all call validateMethodAllowed() first, which throws FeatureIsDisabled (via the dependsOn() helper) if billing.user_wallet.status is off — these three are the user-facing entry points (manual top-up, spending on an invoice, admin manual set).
adjustBalance() is different: it skips the feature-flag check and sends no notification. It's meant for system-initiated mutations — affiliate commissions, referral bonuses, refund reversals — where the wallet ledger needs to keep working even if the user-facing wallet feature (manual top-up/spend) is toggled off, and where the caller (e.g. Affiliates) wants to control its own notification copy. Pass allowNegative: true deliberately if a negative balance is an acceptable outcome for that mutation; otherwise it throws InsufficientBalanceException just like takeAmount().
Amounts are Brick\Math\BigDecimal|string throughout — pass strings for precision-sensitive amounts rather than floats.
Topping up: CreditOrder
CreditOrder extends Billing's abstract Order (see Billing) — a wallet top-up is just an Order whose invoicePaidHook()/cancelOrderHook() add/subtract the wallet balance:
MyWalletQuery::addCredit() — MYWALLET#addCredit, prompts for an amount
▼
MyWalletAnswer::addCredit() — validates 0.01–100,000,000, creates CreditOrder + Invoice
▼
InvoiceFeature::invoice($invoice)->send() — buyer picks a gateway (Card / Zibal / Wallet-as-gateway, etc.)
▼
CreditOrder::invoicePaidHook() — wallet()->adjustBalance($this->amount) [wrapped in wHook()->runForUser()]
(or ::cancelOrderHook() to reverse it if the invoice is later revoked)Both hooks wrap their work in wHook()->runForUser($this->botUser, fn () => ...) because the invoice can be marked paid from a context where the webhook user isn't the buyer (e.g. a payment gateway's server-to-server callback) — see Gateway: Zibal for why that matters.
Spending: Wallet as a Billing gateway
The wallet also registers itself into Billing's Gateways registry, so users can pay other invoices (subscriptions, services, anything with an Order) straight from their balance:
php
gateways()->addGateway(new Gateway(
key: 'wallet',
label: 'Wallet',
inlineButtonGenerator: function (Invoice $invoice) {
if ($invoice->payable instanceof CreditOrder) return null; // can't pay a top-up with the wallet itself
if (!settings()->get('billing.user_wallet.status')) return null;
return Keyboard::inlineButton([
'text' => __('tbe-user-wallet::invoice.by_wallet.keys.pay', ['price' => currency()->priceFormat($invoice->price)]),
'callback_data' => encodeCallback('MYWALLET', 'byWallet', [$invoice->id]),
]);
},
));MyWalletQuery::byWallet(Invoice $invoice) then does wallet()->takeAmount($invoice->price), creates a ByWalletAttempt (Billing's PaymentAttempt subclass for this gateway), and calls attemptSucceed() — same lifecycle as every other gateway.
Admin adjustment
AdminWalletQuery::adjust(BotUser $botUser) starts a state flow prompting an admin to type a new balance for a given user, ending in wallet()->setAmount(...) for that user (wHook()->runForUser()-style context switch applies here too).
Extending User Management
If User Management is installed, this package plugs directly into it (both integrations are wrapped in class_exists(...) guards so User Wallet works standalone too):
php
// A sortable/displayable column in the admin user list
botUserSorts()->addSort(new BotUserSort(
key: 'wallet_balance',
label: __('tbe-user-wallet::bot_users.sorts.wallet_balance'),
apply: fn ($query, $direction) => $query
->leftJoin('bot_user_wallets', fn ($join) => $join
->on('bot_user_wallets.bot_user_id', '=', 'bot_users.id')
->where('bot_user_wallets.bot_id', wHook()->bot()->id))
->select('bot_users.*', DB::raw('COALESCE(bot_user_wallets.balance, 0) as wallet_balance'))
->orderBy('bot_user_wallets.balance', $direction),
display: fn (BotUser $user) => currency()->priceFormat($user->wallet_balance ?? 0),
active: fn () => (bool) settings()->get('billing.user_wallet.status'),
));
// A button on the per-user admin detail screen, linking to AdminWalletQuery::adjust()
userManagementSections()->addSection(new UserSection(
key: 'wallet',
order: 10,
mode: SectionMode::BUTTON,
label: fn (BotUser $user) => __('tbe-user-wallet::admin_wallet.main.text.sectionLabel', ['balance' => currency()->priceFormat($user->wallet->balance)]),
target: fn (BotUser $user) => encodeCallback(AdminWalletQuery::TYPE, 'adjust', [$user->id]),
));Both use a LEFT JOIN + COALESCE (not withDefault()) for the sort specifically so listing/sorting all users doesn't create a wallet row per user as a side effect — see User Management for why active must stay a Closure here.
Settings registered
| Key | Type | Default |
|---|---|---|
billing.user_wallet | DIRECTORY | — |
billing.user_wallet.status | CHECKBOX | false |