Skip to content

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 migrate

Enable 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

KeyTypeDefault
billing.user_walletDIRECTORY
billing.user_wallet.statusCHECKBOXfalse