telegram-bot-essentials/billing
Billing essentials for Telegram bots in Laravel/PHP applications. This package helps you add payment and subscription support, track charges, and manage billing flows alongside your bot workflows. It is designed to fit into existing bot projects. Part of the telegram-bot-essentials ecosystem.
1. Install and pin hard. The package is on a 0.0.x line with no human-readable changelog and zero dependents, so treat every upgrade as a potential breaking change:
composer require telegram-bot-essentials/billing:0.0.45
Pin the exact patch version (no ^, no ~). Add a composer.lock review step to your PR template so a composer update can't silently move this dependency.
2. Publish and read before you run.
php artisan vendor:publish --provider="TelegramBotEssentials\Billing\BillingServiceProvider"
Publish the config and migrations, then read both files before running migrate. On a pre-1.0 package this is your only reliable documentation. Look for:
invoices, payments, plans, subscriptions are common names — check for prefixes).3. Where to look first, in this order:
| Look at | Why |
|---|---|
src/Contracts/* |
The gateway contract is the seam you'll actually integrate against — payment provider, webhook handling, and probably refund/status querying. Read this before anything else. |
src/BillingServiceProvider.php |
Shows what's bound in the container, what's configurable, and whether models are swappable. |
config/billing.php |
Gateway registry, default driver, currency, plan definitions if any. |
| Migrations | The real data model. Descriptions lie; schemas don't. |
| Existing tests in the repo | Best available substitute for documentation. Read the feature tests to learn the intended happy path. |
4. Your first use case: gate one premium command. Do not start with subscriptions. Start with the narrowest possible slice:
false before payment and true after.If you can't get from "invoice created" to "entitlement granted" in under a day, that's your go/no-go signal.
5. Wire the entitlement check into the bot handler, not into the controller. Whatever middleware or guard the package ships, your command handler should call one internal method — $billing->userCan($telegramUser, 'premium_feature') — so you have a single place to swap implementations later.
This is the single most important pattern given the maturity level. Do not let TelegramBotEssentials\Billing\Models\Subscription appear in your application code, your API resources, or your bot handlers.
// app/Billing/BillingGateway.php (your interface, not theirs)
interface BillingGateway
{
public function startCheckout(int $telegramUserId, string $planKey): CheckoutResult;
public function isEntitled(int $telegramUserId, string $feature): bool;
public function handleProviderCallback(array $payload): void;
public function cancel(int $telegramUserId): void;
}
Then a single adapter class imports the package. Everything else — controllers, commands, jobs, tests — depends on your interface. When 0.1.0 renames Subscription to Entitlement (or removes the gateway registry entirely), you change one file.
Keep the commercial definition of a plan in your own config or a domain enum, and treat the package's Plan record as a projection of it. You want pricing changes to go through code review and a deploy, not through someone editing a row in production.
// config/plans.php
return [
'premium_monthly' => [
'price' => 500, // minor units — confirm package convention
'currency' => 'XTR', // Telegram Stars, or your fiat code
'interval' => 'month',
'features' => ['premium_commands', 'priority_queue'],
],
];
A seeder or an artisan billing:sync-plans command reconciles config → package rows idempotently. This also makes tests deterministic.
Whatever status strings the package uses, define your own enum and map to it. Billing bugs are almost always "an invoice got confirmed twice" or "a paid invoice got reverted by a late callback."
enum InvoiceState: string {
case Draft = 'draft';
case AwaitingPayment = 'awaiting_payment';
case Paid = 'paid';
case Expired = 'expired';
case Refunded = 'refunded';
}
Guard transitions in one place and log every transition with the provider payload attached. When something goes wrong at 2am, that log is the only thing that will save you.
Telegram will re-deliver updates. It also sends pre_checkout_query (which you must answer within 10 seconds or the payment is cancelled) separately from successful_payment. Structure your handler as:
public function handleProviderCallback(array $payload): void
{
DB::transaction(function () use ($payload) {
$event = $this->recordEventOnce($payload['id'] ?? null, $payload); // unique index on provider event id
if ($event->already_processed) {
return;
}
$invoice = $this->invoices->findByProviderReference($payload['invoice_ref']);
$invoice->transitionTo(InvoiceState::Paid, $payload); // no-op if already Paid
$this->entitlements->grant($invoice->telegram_user_id, $invoice->plan_key);
$event->markProcessed();
});
}
Also answer pre_checkout_query before doing any database work — acknowledge first, reconcile after. Telegram's timeout is unforgiving and a cancelled checkout looks like a bug to the user.
In a Telegram bot, the natural choke point is the command/handler dispatcher, not HTTP middleware.
// In your command registry
$this->register('/export', ExportCommand::class)->requires('premium_commands');
Resolution order in your adapter: explicit grant → active subscription → trial window → false. Cache the result per user per request (not across requests — revocation must be immediate when you cancel a subscription).
Implement your own BillingGateway interface with an in-memory fake for the test suite. If the package's contracts are clean, you can also implement their gateway interface with a fake and register it through the registry — that gives you end-to-end coverage of their invoice logic without touching a payment provider.
$this->app->instance(BillingGateway::class, new FakeBillingGateway());
$this->post('/telegram/webhook', $this->successfulPaymentUpdate());
$this->assertTrue($billing->isEntitled($userId, 'premium_commands'));
Also write contract tests around exactly the flows you depend on (create invoice, confirm payment, grant entitlement, expire). These are what will catch an unannounced behavior change in 0.0.46.
Do not assume the package runs its own scheduler. Register a command yourself and make it idempotent:
// app/Console/Kernel.php
$schedule->command('billing:sweep-entitlements')->hourly()->withoutOverlapping();
Sweep both directions: expire stale subscriptions, and expire invoices that have been AwaitingPayment past the provider's window. Otherwise your invoice table fills with zombie rows and your reporting is meaningless.
Maturity is the headline risk. Zero stars, zero dependents, 0.0.x, and a release note that is literally just a compare link. Read the actual diff before every bump (git diff v0.0.44..v0.0.45 -- src/) — the changelog will not tell you anything. Budget for one surprise per bump.
Don't put it on a revenue-critical path yet. This is fine for a pilot tier, an internal tool, or a "premium commands" toggle. It is not fine for contracted SLAs, enterprise invoicing, or anything your finance team reconciles. There is no evidence of tax handling, multi-currency, dunning, refunds, or chargeback flows — and those are exactly the things that turn billing from a weekend project into a quarter.
Money types. Verify whether amounts are integer minor units or floats. If floats anywhere, coerce to integers at your boundary and never do arithmetic on the package's values directly. Also confirm the currency column's width — XTR (Telegram Stars) is three characters, but fiat codes plus symbols sometimes overflow naive string(3) columns.
Telegram-specific traps:
pre_checkout_query must be answered in 10 seconds. Acknowledge first, persist later.successful_payment can arrive more than once. Idempotency is not optional (see Pattern 4).Migration collisions. Generic table names (invoices, payments, plans) will collide with anything you already have. Check before running migrate, and if there's no configurable table prefix, either add one in a follow-up migration or accept the names and document them.
Timestamp and timezone drift. Expiry comparisons across Carbon instances in different timezones are a classic silent bug. Store UTC, compare UTC, and assert in tests that an entitlement expires at the boundary you expect — not one hour early.
Revocation must beat caching. If you cache entitlement checks (you should, for bot throughput), cache with a short TTL and bust it explicitly on cancel/refund. Users notice within seconds when a cancelled subscription still works, and they notice loudly when a paid one stops working.
Don't trust the models to be final. If the package uses Laravel models, they may not have $fillable guards, casts, or relationships you'd expect. Don't extend them; wrap them. And don't let Subscription::where(...) leak into your controllers.
Extension points to look for, in priority order: (1) a gateway contract you can implement — this is the escape hatch that makes the package worth adopting at all; (2) a config-driven driver registry so you can register a fake in tests; (3) swappable model bindings in the service provider. If (1) or (2) is missing, the exit cost is high and the spike should probably fail.
Debugging playbook. When an entitlement doesn't grant: log the raw provider payload first, then the invoice row, then the resolved entitlement call. Nine times out of ten it's a callback that returned 200 but threw after the response was sent, or an idempotency key that was never written because the transaction rolled back.
Have an exit plan written down before you ship. One page: which tables you own, which are the package's, and what the migration to a homegrown or Stripe-based implementation looks like. With a 0.0.x dependency and no maintainer track record, "we'll deal with it later" is not a plan — it's a future incident.
How can I help you explore Laravel packages today?