Skip to content

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

Settings registered

KeyTypeDefault
affiliatesDIRECTORY
affiliates.affiliates_statusCHECKBOXfalse
affiliates.allow_existing_usersCHECKBOXfalse
affiliates.share_percentageNUMBER10
affiliates.referrer_signup_bonusNUMBER0
affiliates.referred_signup_bonusNUMBER0