Skip to content

Affiliates ​

Current version: v0.0.16

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 migrate

Everything 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 on referrals means each user can only ever be referred once; a duplicate Referral::create() throws a QueryException that'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. Set affiliates.allow_existing_users to 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()->id is 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):

SettingPaid toSkipped if
affiliates.referrer_signup_bonusThe referrer (A)Amount is 0/unset
affiliates.referred_signup_bonusThe 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-pay

Both 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 ​

ModelPurpose
AffiliateOne row per user who has opened the affiliate menu at least once; holds the unique referral_code
ReferralOne row per successfully attributed signup — links a new BotUser to the Affiliate that referred them (unique(bot_id, bot_user_id))
AffiliateTransactionLedger 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 someone

Member 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}.

Since v0.0.15 the same menu is also reachable by the /affiliation command — an essence Command subclass (Telegram/Commands/Member/AffiliationCommand.php) the package registers with commandBus()->addCommands([...]) in its service provider (see Commands).

The share message's marketing tagline is admin-configurable via the affiliates.share_tagline setting, falling back to the package's own lang default (affiliation.share.tagline) when unset.

Settings registered ​

KeyTypeDefault
affiliatesDIRECTORY—
affiliates.affiliates_statusCHECKBOXfalse
affiliates.allow_existing_usersCHECKBOXfalse
affiliates.share_percentageNUMBER10
affiliates.referrer_signup_bonusNUMBER0
affiliates.referred_signup_bonusNUMBER0
affiliates.share_taglineTEXT— (falls back to lang default)