Skip to content

Billing

Current version: v0.0.23

telegram-bot-essentials/billing (namespace TelegramBotEssentials\Billing) adds invoices, payment attempts, a pluggable gateway registry, and currency conversion on top of Essence. It does not talk to any payment provider itself — that's what Gateway: Card and Gateway: Zibal are for. Billing defines the contract they implement, plus the invoice lifecycle and admin UI that everything else plugs into.

Installation

bash
composer require telegram-bot-essentials/billing
php artisan migrate

Publish config if you need to customize supported currencies:

bash
php artisan vendor:publish --tag=tbe-billing-config
php
// config/tbe-billing.php
return [
    'supported_currencies' => [
        ['name' => 'USD', 'symbol' => '$'],
        ['name' => 'IRR', 'symbol' => '﷼'],
        ['name' => 'IRT', 'symbol' => 'تومان'],
    ],
];

Depends on Settings — it registers a billing.currency setting (per-bot) using this list.

Core Concepts

Order (abstract)

Your own sellable thing — a subscription, a product purchase, whatever — extends TelegramBotEssentials\Billing\Models\Abstract\Order. It brings in HasInvoice and requires you to implement:

php
abstract class Order extends Model
{
    abstract public function getPaidAtAttribute(): ?Carbon;
    abstract public function getAmountAttribute(): string;
    abstract public function getDescriptionAttribute(): string;
}

HasInvoice (a trait, also usable standalone) adds the polymorphic invoice() relation and two hook methods you must implement plus two optional ones:

php
trait HasInvoice
{
    public function invoice(): MorphOne; // -> Invoice, latest()

    abstract public function invoicePaidHook(): void;   // required
    abstract public function cancelOrderHook(): void;   // required — invoice was revoked (e.g. un-paid after being paid)

    public function invoicePendingHook(): void {}        // optional
    public function invoiceFailedHook(): void {}          // optional
}

Invoice

TelegramBotEssentials\Billing\Models\Invoice — tenant-scoped, soft-deletes, morphs to both a payable (your Order) and a paymentAttempt (a gateway's attempt model). Statuses: pending, paid, failed. A public, unguessable public_token is lazily generated via uniqid() for building payment-return URLs.

Creating a new invoice for the same payable soft-deletes any prior undeleted invoice for it (see Invoice::booted()), so a payable never has more than one live invoice at a time.

php
$invoice = billing()->createInvoice($order); // order->invoice()->create(['bot_user_id' => ..., 'price' => $order->amount])

Status transitions go through dedicated methods, never a raw update(['status' => ...]), because each one fires an event and dispatches inside wHook()->runForUser() so the webhook context is correct even if called from a webhook callback for a different bot user (e.g. a payment gateway callback):

php
$invoice->markAsPaid();     // fires InvoicePaid (no-op if already paid)
$invoice->markAsFailed();   // fires InvoiceFailed
$invoice->markAsPending();  // fires InvoicePending

Setting status back away from 'paid' directly (e.g. via a raw attribute set) fires InvoiceRevoked with the previous status — this is how a paid order gets automatically cancelled if its invoice is later marked unpaid.

PaymentAttempt (abstract)

Each gateway module (Card, Zibal, ...) defines its own concrete PaymentAttempt model extending TelegramBotEssentials\Billing\Models\Abstract\PaymentAttempt, morph-related back to Invoice:

php
abstract class PaymentAttempt extends Model
{
    public function invoice(): MorphOne;

    public function attemptSucceed(): void;  // calls attemptSucceedHook(), sets status='succeed', invoice->markAsPaid()
    public function attemptFailed(): void;   // calls attemptFailedHook(),  sets status='failed',  invoice->markAsFailed()

    abstract protected function attemptSucceedHook(): void; // gateway-specific bookkeeping
    abstract protected function attemptFailedHook(): void;
}

Link an attempt to an invoice with:

php
billing()->attemptPayment($invoice, $paymentAttempt);
// $invoice->paymentAttempt()->associate($paymentAttempt); $invoice->save();

Invoice status → Order hooks

You never call $order->invoicePaidHook() yourself. TelegramBotEssentials\Billing\Listeners\Invoices\InvokeInvoiceHooks listens for all four status events and calls the matching hook on the invoice's payable automatically:

EventHook called on the Order
InvoicePaidinvoicePaidHook()
InvoiceFailedinvoiceFailedHook()
InvoicePendinginvoicePendingHook()
InvoiceRevokedcancelOrderHook()

Separately, DispatchInvoice{Paid,Failed,Pending}Hooks / DispatchInvoiceRevokedHooks listeners send the corresponding Telegram message to the buyer (translated via tbe-billing::invoice.hooks.*) and update any locked invoice_view-tagged MessageMeta to reflect the new status. Both sets of listeners are wired in Providers/EventServiceProvider.php — you get user notification and order fulfillment for free just by extending Order/HasInvoice correctly.

Gateways registry

Gateways (helper: gateways()) is a simple keyed registry of TelegramBotEssentials\Billing\DTOs\Gateway — Billing itself registers none; each gateway package registers its own in its service provider:

php
gateways()->addGateway(new Gateway(
    key: 'zibal',
    label: 'Zibal',
    inlineButtonGenerator: fn (Invoice $invoice) => Keyboard::inlineButton([
        'text' => 'Pay with Zibal',
        'url'  => route('gateway-zibal.pay', $invoice->public_token),
    ]),
));

gateways()->getGateways();          // Collection<string, Gateway>
gateways()->getGateway('zibal');
gateways()->getGateway('zibal')->getInlineKeyboard($invoice); // calls inlineButtonGenerator($invoice)

This is what lets InvoiceFeature render a "choose your payment method" screen without knowing which gateway packages happen to be installed — see Gateway: Card and Gateway: Zibal for concrete registrations.

Currency

Two related but distinct helpers:

  • currency()Currency service (per-bot currency helpers backed by the billing.currency setting)
  • priceIn($price)CurrencyFather::from(settings()->get('billing.currency'))->amount($price) — a fluent converter

CurrencyFather converts between USD, IRR, and IRT using a live exchange-rate API (currency.servicefather.ir), caching each (from, to, amount) triple for 6 hours:

php
priceIn('10')->toIRT();   // convert 10 units of the bot's configured currency to Tomans
CurrencyFather::USD()->amount('5')->toIRR();

Admin UI

ManageInvoicesQuery / ManageInvoicesAnswer / ManageInvoicesFeature / ManageInvoicesKey give admins an invoice list/detail screen out of the box. InvoiceFeature (member-facing) renders the invoice + gateway choice screen shown to the buyer.

Scheduled Maintenance

MarkOverdueInvoicesAsFailed runs hourly (registered against Laravel's scheduler in TbeBillingServiceProvider::boot()) to fail invoices that were never completed within the expected window — make sure php artisan schedule:work (or your server's cron → schedule:run) is actually running in production.

Settings registered

KeyTypeNotes
billingDIRECTORYGroups the settings below in the admin UI
billing.gatewaysDIRECTORYGroups gateway-specific settings (populated by gateway packages)
billing.currencyENUMOptions from config('tbe-billing.supported_currencies') + USD; default USD