Appearance
Gateway: Card
Current version: v0.0.11
telegram-bot-essentials/gateway-card (namespace TelegramBotEssentials\GatewayCard) is a manual "pay by bank card transfer" gateway for Billing. There's no external payment processor — the buyer transfers money to a card number shown in the bot, sends a screenshot/receipt back, and an admin manually accepts or rejects the payment.
This is the simplest possible gateway implementation and a good reference for writing your own manual/offline gateway.
Installation
bash
composer require telegram-bot-essentials/gateway-card
php artisan migrateConfigure via the bot's Settings admin menu (or directly with settings()->set(...)) — there's no separate config file, everything lives in per-bot Settings:
| Setting key | Type | Purpose |
|---|---|---|
billing.gateways.card.status | CHECKBOX | Master on/off switch |
billing.gateways.card.card_number | TEXT | Card number shown to buyers |
billing.gateways.card.card_name | TEXT | Cardholder name shown to buyers |
billing.gateways.card.transactions_chat_id | TEXT | Chat where admins review submitted receipts |
All four must be set for the gateway to appear — CardPaymentFeature::isCardPaymentEnabled() checks all of them, and both CardPaymentQuery and ManageCardPaymentQuery gate every method behind it via isEnabled().
Flow
Invoice screen (Billing)
│ gateways()->getGateway('card') → inline button, only if isCardPaymentEnabled()
▼
CardPaymentQuery::toCard(Invoice) (CARD_PAYMENT#toCard)
│ creates a ToCardAttempt, billing()->attemptPayment($invoice, $attempt)
│ shows the card number/name, sets state → awaiting receipt
▼
CardPaymentAnswer::payToCard(Invoice) (state: CARD_PAYMENT#pay_to_card)
│ stores the buyer's text/photo receipt on the ToCardAttempt (photo → base64 on the model)
│ forwards/copies the receipt to `transactions_chat_id` with Accept/Reject buttons
▼
ManageCardPaymentQuery::acceptCardPayment(ToCardAttempt) (MANAGE_CARD_PAYMENT#accept_card_payment)
→ $toCardAttempt->attemptSucceed() → Invoice::markAsPaid() → Billing's InvoicePaid hooks fire
or
ManageCardPaymentQuery::rejectCardPayment(ToCardAttempt) (MANAGE_CARD_PAYMENT#reject_card_payment)
→ asks the admin for a rejection reason, then $toCardAttempt->attemptFailed() → Invoice::markAsFailed()ToCardAttempt
Extends Billing's abstract PaymentAttempt. Notable columns: card_number, amount, info_text (receipt caption/text), info_photo (base64-encoded JPEG downloaded from Telegram), received_at, rejected_at.
php
protected function attemptSucceedHook(): void {} // no-op — Invoice::markAsPaid() does the real work
protected function attemptFailedHook(): void
{
$this->setAttribute('rejected_at', now());
$this->save();
}Registering with Billing
Like every gateway, it registers itself into Billing's registry from its own service provider — nothing in Billing itself knows this gateway exists:
php
gateways()->addGateway(new Gateway(
key: 'card',
label: __('tbe-gateway-card::invoice.to_card.labels.gateway'),
inlineButtonGenerator: function (Invoice $invoice) {
if (!CardPaymentFeature::isCardPaymentEnabled()) return null;
return Keyboard::inlineButton([
'text' => __('tbe-gateway-card::invoice.to_card.keys.member-card_payment'),
'callback_data' => encodeCallback('CARD_PAYMENT', 'toCard', [$invoice->id]),
]);
},
));Returning null from inlineButtonGenerator hides the payment option entirely — this is the standard way a gateway opts itself out when unconfigured.
Why this is a good template
If you're writing your own manual gateway, copy this package's shape:
- A
*Attempt extends PaymentAttemptmodel with your own bookkeeping columns. - A member-facing
CallbackQuerythat creates the attempt and callsbilling()->attemptPayment(). - A member-facing
StateAnswerthat collects proof of payment and hands off to an admin chat. - An admin-facing
CallbackQuerythat calls$attempt->attemptSucceed()/attemptFailed(). - A
Feature::isEnabled()-style guard checked from every handler'sisEnabled()and from the gateway'sinlineButtonGenerator. - Settings under
billing.gateways.<your-key>.*, plus the sharedbilling/billing.gatewaysDIRECTORYsettings (re-declared per package —Settings::addSetting()overwrites by key, so this is safe and keeps each package self-contained).