Appearance
Affiliates
Current version: v0.0.7
telegram-bot-essentials/affiliates (namespace TelegramBotEssentials\Affiliates) is a referral program. Every user gets a shareable deep link, signups through it are tracked, and both the referrer and (optionally) the new user can be paid a signup bonus. Referrers also earn an ongoing commission on their referred user's purchases. Bonuses and commissions are paid straight into User Wallet.
It depends on Essence's deep-link handling, Settings, Billing events, and User Wallet.
Installation
bash
composer require telegram-bot-essentials/affiliates
php artisan migrateEverything is configured per-bot through Settings — there's no config file.
How referral attribution works
The whole feature is deep-link based, using the exact mechanism described in State & StateData → Pattern 4 and Essence's BotDeepLinkReceived event (see Events & Listeners):
User A opens Affiliation menu → AffiliationFeature::menu()
│ creates an Affiliate row for A on first visit (referral_code: uniqid())
│ builds https://t.me/{bot}?start={referral_code}
▼
User A shares the link. User B taps it → sends /start {referral_code}
▼
Essence fires BotDeepLinkReceived(payload: referral_code)
▼
HandleAffiliateReferral (listens for BotDeepLinkReceived)
│ looks up Affiliate::where('referral_code', payload)
│ creates a Referral row linking B → A's Affiliate
│ pays signup bonuses (below)Attribution rules (HandleAffiliateReferral)
- Gated entirely on
affiliates.affiliates_status— off by default. - First-touch wins: a
unique(bot_id, bot_user_id)DB constraint onreferralsmeans each user can only ever be referred once; a duplicateReferral::create()throws aQueryExceptionthat's caught and silently ignored, rather than re-attributing. - New users only, by default: attribution only happens if
wHook()->user()->wasRecentlyCreated— i.e. this is the user's very first/start. Setaffiliates.allow_existing_usersto allow already-registered users to be attributed too (still subject to first-touch-wins). - A user can't refer themselves (
$affiliate->bot_user_id === wHook()->user()->idis rejected).
Signup bonuses
Both are optional flat amounts, paid via wallet()->adjustBalance() (see User Wallet — this is the deliberate "system-initiated mutation" entry point, bypassing the wallet feature toggle and sending its own notification copy):
| Setting | Paid to | Skipped if |
|---|---|---|
affiliates.referrer_signup_bonus | The referrer (A) | Amount is 0/unset |
affiliates.referred_signup_bonus | The new user (B) | Amount is 0/unset |
Paying the referrer happens inside wHook()->runForUser($referrer, ...) because the request context at this point belongs to the new user (B), not the referrer (A) — see Events → Queued Listeners for why that context switch matters when sending a Telegram message.
Purchase commissions (HandleInvoicePaid / HandleInvoiceRevoked)
Beyond the one-time signup bonus, a referrer earns an ongoing percentage of everything their referred user buys — driven entirely by Billing's invoice events, so it works for any Order type (subscriptions, wallet gateways, whatever your app sells), with one deliberate exception:
php
// Wallet top-ups don't count as a sale — otherwise a referred user topping
// up their own wallet would hand the referrer a cut of nothing bought.
if ($event->invoice->payable instanceof CreditOrder) {
return;
}Commission math (HandleInvoicePaid):
php
$commission = BigDecimal::of($invoice->price)
->multipliedBy($sharePercentage) // affiliates.share_percentage, default 10
->dividedBy(100, 10, RoundingMode::DOWN); // rounds DOWN — never over-payBoth listeners implement ShouldQueue (queue: billing) and call $event->context->apply() first to restore the webhook context — required because InvoicePaid/InvoiceRevoked can fire from contexts with no "current user" of their own (e.g. a payment gateway callback). See Events → Queued Listeners.
If an invoice is later revoked (unpaid after being paid — see Billing → Invoice), HandleInvoiceRevoked claws the commission back:
php
// Not gated on affiliates_status: if a commission was already paid, revoking
// the invoice must claw it back regardless of whether the program has since
// been turned off.
wallet()->adjustBalance($transaction->amount->negated(), allowNegative: true);
$transaction->update(['status' => AffiliateTransaction::STATUS_REVERSED]);allowNegative: true is deliberate here — a clawback must succeed even if the referrer has since spent the commission, driving their balance negative rather than silently failing.
Data model
| Model | Purpose |
|---|---|
Affiliate | One row per user who has opened the affiliate menu at least once; holds the unique referral_code |
Referral | One row per successfully attributed signup — links a new BotUser to the Affiliate that referred them (unique(bot_id, bot_user_id)) |
AffiliateTransaction | Ledger of every bonus/commission paid, with type (purchase_commission, referrer_signup_bonus, referred_signup_bonus) and status (credited, reversed) |
The package also attaches two convenience relations to BotUser via resolveRelationUsing():
php
$botUser->affiliate; // hasOne Affiliate — this user's own affiliate record, if any
$botUser->referredBy; // hasOne Referral — the Referral row if this user was referred by someoneMember UI
AffiliationKey (reply keyboard entry) → AffiliationFeature::menu() shows the user's referral link, referral count, and total earned, plus a "Share" button (AffiliationQuery::getInviteLink) that sends a pre-formatted invite message (AffiliationFeature::shareText()) ready to forward. The referral link itself is always https://t.me/{bot_username}?start={referral_code}.
Settings registered
| Key | Type | Default |
|---|---|---|
affiliates | DIRECTORY | — |
affiliates.affiliates_status | CHECKBOX | false |
affiliates.allow_existing_users | CHECKBOX | false |
affiliates.share_percentage | NUMBER | 10 |
affiliates.referrer_signup_bonus | NUMBER | 0 |
affiliates.referred_signup_bonus | NUMBER | 0 |