> For the complete documentation index, see [llms.txt](https://docs.layeronecloud.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.layeronecloud.com/platform/billing/referrals-and-promotions.md).

# Referrals, affiliates and promotions

Two growth programs, one attribution table, in apps/referrals.

## 4.14 Referrals and affiliates <a href="#id-414-referrals-and-affiliates" id="id-414-referrals-and-affiliates"></a>

Two growth programs, one attribution table, in `apps/referrals`. Billing delegates accrual to it the way it delegates the low-credit escalation to `apps/campaigns`.

|       | Refer a friend                                                       | Affiliate                                           |
| ----- | -------------------------------------------------------------------- | --------------------------------------------------- |
| Who   | Any client, self-join                                                | Anyone with an account, self-join                   |
| Pays  | One-off `invite_bonus_amount` ($20.00)                               | `affiliate_commission_rate` (15%) of every deposit  |
| When  | Invitee's cleared deposits reach `invite_qualifying_deposit` ($5.00) | Every deposit, for the life of the account          |
| Bound | `invite_max_bonuses_per_partner` (3) per UTC calendar year → $60/yr  | None                                                |
| Form  | Hosting credit only, auto-converted                                  | Partner balance: convert to credit, or request cash |

**A partner is on one program, and `ReferralPartner.program` is the whole answer.** It replaced an `is_affiliate` boolean sitting beside an implicit "everyone is also on the invite program", which could not express the rule at all — under it an affiliate was *also* an invite partner whose bonuses simply never got picked. `is_affiliate` survives as a property derived from `program`, because a second column for one fact drifts the first time a row is written outside the service layer.

**Blank is a real third state** — created the row, has not chosen. A partner row is created just by opening the referrals page, so treating that as a choice is how somebody who once glanced at the page discovers a year later that a page view locked them out. Therefore:

* **`attribution_program_for` reads the field and nothing else. No fallback to invite.** A partner who joined nothing earns nothing, and an affiliate whose program was switched off does not quietly start collecting bonuses on terms they never agreed to.
* **`partner_for_code` still resolves a blank one**, so a link shared before choosing pays from the moment they choose.

`join_program` is the only client-facing way onto one and refuses to move somebody already on the other — "join the second and keep what the first already paid" is exactly how one signup gets paid for twice. The escape hatch is staff-only: `set_partner_program`, `super_admin_required`, at `referrals:admin-partner-program`, off `SUPPORT_CONSOLE_ROUTES`. It is **forward-looking**: already-attributed referrals carry their own program and terms, so moving a partner converts nothing already paid and changes only what the *next* signup through their link is worth.

**Cash is affiliate-only** (`request_payout` asks `is_affiliate`). The invite bonus is accrued **and immediately converted** to credit, so there is no balance to withdraw.

**One attribution per referred client.** `Referral.referred_user` is a `OneToOneField`, in the database, because the alternative failure — a second attribution row — surfaces as a double payout months later rather than as an error at the time.

**Terms are snapshotted, never re-read.** `Referral` carries its own `bonus_amount`, `qualifying_deposit_threshold` and `commission_rate`, written once at attribution. A client moved onto the affiliate program does not retroactively convert their earlier referrals into lifetime commission; a rate change does not reach an existing referral either way; a per-partner negotiated rate applies from the next attribution onward. (Same reasoning as `LET_PROMO_TERMS_METADATA_KEY`.) The **annual partner cap remains a live program limit.**

Repricing the bonus is therefore safe in a way repricing a `Plan` is not, which is why the migration writes $20 onto the live settings row — and only if it is still on the old default, because a figure an admin typed is a decision. Both values are edited in one place: **Settings → Referrals**.

**Accrual: commission is a share of money paid, not of credit posted.** `_accrue_referral_earnings_for_deposit` (`apps/billing/services.py`, called from `record_account_credit_for_paid_invoice`) passes **`invoice.total`**. That function posts several ledger rows for one payment — the deposit (real money), the LET match (free credit), the recovery make-up entry (free credit) — and paying 15% of the *credit* would have the platform paying a share of its own promotional spend. For the same reason `cleared_deposit_total` reads **paid invoices**, not the credit ledger: a staff goodwill adjustment posts to the ledger a deposit does, so reading the ledger would let free credit unlock a $20 payout.

The hook **swallows and logs its exceptions**: a referral accrual that cannot be written is worth an error report, but it must not turn a customer's successful payment into a 500 and leave the webhook retrying a deposit already credited.

**Idempotency.** A deposit's confirmation arrives twice on a regular basis, so `PartnerLedgerEntry` carries two partial unique constraints — `unique_commission_per_referral_invoice` on `(referral, source_invoice)` where `entry_type = commission`, and `unique_invite_bonus_per_referral` on `referral` where `entry_type = invite_bonus`. Both paths catch `IntegrityError` and treat the existing row as the answer. The invite path additionally takes the **partner row lock**, because the constraint stops a double bonus on *one* referral while the lock is what stops the per-partner cap being exceeded across *different* referrals clearing at the same moment.

The qualifying threshold is **cumulative**: $3 twice qualifies exactly like $5 once, and there is no version of "they have paid us $6" that should not count.

**Why the invite bonus needs a cap.** $20 for a $5 deposit is a 400% payout; uncapped, one person making throwaway accounts mints credit indefinitely. Three things bound it and only the cap closes it:

1. `invite_max_bonuses_per_partner`, default 3 per **UTC calendar year**, resetting at midnight UTC on January 1 and counting invite-bonus ledger entries created within the current year. Bonuses from previous years stay in lifetime earnings but do not consume this year's allowance; a referral still receives its bonus at most once across all years; a referral reaching the threshold while its partner is at the cap is rejected **permanently**, and the annual reset does not revive it. `ReferralProgramSettings.clean` refuses to save an unlimited cap while the bonus exceeds its own qualifying deposit, and the form runs it, so the guard cannot be edited around.
2. **Self-referral rejection** — a code pointing at its own owner is written as `REJECTED`, not dropped, so the console can see the attempt.
3. **Same-address rejection** (`block_duplicate_signup_ip`, on by default), comparing a **salted HMAC** of the signup address — never the address in clear — against the referrer's own signup and their other referrals. One signal, not a fraud system; switch it off for customers behind a shared office NAT.

A rejected referral never accrues and a later deposit does not un-reject it.

**The partner ledger** is append-only, for the reason `AccountLedgerEntry` is. Amounts are always positive and the direction lives in `entry_type`. `partner_balance()` returns three numbers that are **not interchangeable**:

* `lifetime_earned` — bonuses plus commission, ever; never decreases.
* `balance` — what is left after conversions and paid payouts.
* `available` — `balance` minus anything held by a payout request asked for but not yet paid. **Approving a payout does not debit** (staff record the cash sent, and that debits), so without the reservation an affiliate could convert the same money into hosting credit while staff were preparing to wire it. `available` is floored at zero, or a reversal on a fully-converted balance renders as `-$0.00`.

Money leaves in exactly two ways, both debit rows: `CREDIT_CONVERSION` (`convert_balance_to_credit` writes the debit **and** an `AccountLedgerEntry` of type `ADJUSTMENT`, never `CREDIT`, which is reserved by `unique_credit_ledger_entry_per_invoice`) and `PAYOUT` (written only by `mark_payout_paid`, under the partner lock, with the request status re-read inside it so two admins clicking "mark paid" cannot debit twice). `REVERSAL` is the clawback for a refunded or charged-back deposit; the original accrual row stays, because it happened. There is no automatic chargeback handling.

**Payouts are recorded, never executed.** Nothing in this app moves money out of the business. `PayoutRequestStatus.APPROVED` is labelled **"Approved to pay"** — which also keeps the chip colour honest, since `status_tone` is one global map and a bare `approved` belongs to termination reviews where approval is destructive and renders red. `payout_details` is Fernet-encrypted on both the partner and the request (the request copies the details it was made against, because the partner can edit theirs afterwards and staff need to pay the ones they were asked to); plaintext is rendered in exactly one place, the payout queue row being settled.

**Attribution is a cookie, not a form field.** `/r/<code>/` sets `l1_ref` (httponly, `SameSite=Lax`, `attribution_cookie_days` default 90) and redirects to the **home page**, not the register form: a shared link is an introduction, and a visitor who meets a password field before they know what we sell mostly leaves. `ClientRegistrationForm` has no `referral_code` field and a posted code is ignored — it would be a second, untrusted source for the same fact. `?ref=` is honoured on the register page and beats the cookie, so clicking a second partner's link attributes to the one the visitor actually arrived through. The code is never validated at capture (a stale link in someone's signature must not cost us the account): `attribute_referral` returns `None` for a meaningless code and `_attribute_referral_if_any` swallows and logs anything worse. Attribution runs on both registration branches, including a claimed placeholder. The cookie is cleared once used.

**Client-facing privacy.** The invite page shows dates and statuses, **never the invitee's email address** — a referral link is shared publicly, so the page must not become a way to confirm that a particular person holds an account here. The affiliate tab's commission rows *do* name the referred client: those are the partner's own referrals and they need to reconcile them.

**Routes and access.** `/r/<code>/` public; `/client/referrals/` (the only place a program is joined, describing both side by side) plus `invite/` and `affiliate/` dashboards, each redirecting anyone not on that program back to the programs page (a redirect, not a 403 — being on the other program is a normal state); `/console/referrals/`, `attributions/`, `payouts/` under `@admin_required`, with adjust/status/rate/payout-decision POSTs under `@super_admin_required`. **No referrals route is on `SUPPORT_CONSOLE_ROUTES`** — a partner balance becomes hosting credit and a payout becomes cash, which is the credit-adjustment class of power. `/r/` is mounted before the CMS catch-all.

Gotcha: `partner_search_queryset` is ordered explicitly in the view, not from `Meta.ordering`, because the `Count` annotations put it through a `GROUP BY` which drops model ordering and makes pager boundaries non-deterministic. `commission_for_deposit` **rounds down**, like `hourly_price_from_monthly`, and keeps four decimals so a long tail of small deposits does not round to nothing; rounding to cents happens once, at payout.

## 4.15 Promotions <a href="#id-415-promotions" id="id-415-promotions"></a>

**Every promotion has to survive the same arithmetic.** A plan order on this platform is "buy `total` dollars of account credit", which metering then drains at the plan's hourly rate. **So a discount that only lowers `Order.total` is worth exactly nothing**: the customer is charged 15% less *and receives 15% less credit*, the server stops roughly four and a half days earlier, and the saving is zero. Every discount therefore **charges less and credits in full**, posting the difference as a **separate promotional `AccountLedgerEntry`** — deliberately not attached to the invoice, because `unique_credit_ledger_entry_per_invoice` allows only one credit entry per invoice and the main one holds that slot.

**Abandoned-order recovery** (`apps/billing/order_recovery.py`, `billing.run_order_recovery` every `ORDER_RECOVERY_INTERVAL_SECONDS`=900) — a two-step email sequence that chases unpaid checkouts, offers 15% off the second time, tracks clicks and attributes recovered revenue.

* `abandoned_orders()` filters, each for a reason: `AWAITING_PAYMENT` only (an eligible hourly order needs nothing up front and is born PAID); `total > 0` and a plan set; newer than `abandoned_order_max_age_days` (**without this the first deployment mails every customer who ever walked away from a checkout**); older than `MIN_ABANDONED_AGE` (30 minutes — someone who opened checkout ninety seconds ago is still typing their card number); not suspended; `order_is_superseded` drops orders whose customer has since paid for the same plan.
* `OrderRecoveryOffer` writes `full_amount` **once**, on creation, and every recalculation works from it — recomputing from the live total would compound, taking 15% off 85% and selling the plan at 72%. `campaign_send_id` is a plain integer, not an FK, because billing must work with the campaigns app absent.
* Reachable only through a signed token in the email (`signing.dumps(offer_id)`, not the bare id, because the URL ends up in access logs, proxy logs and `Referer` headers). There is no code to type, so there is nothing to leak onto a forum.
* Two seeded steps: 1 hour with `discount_percent=0` (a plain nudge — most orders it recovers would have converted anyway, and paying 15% for them is waste) and 24 hours at 15%. `CampaignStep.discount_percent` is capped at `MAX_STEP_DISCOUNT_PERCENT` (90), which is a **spend limit**, not a formatting rule. `ThresholdUnit.HOURS` + `select_elapsed_step`: the low-credit selector wants the *lowest* threshold at or above a **falling** balance, while an elapsed-hours threshold is a bar a **rising** age climbs past, so this one wants the highest threshold at or below it — reusing `select_threshold_step` sent the 24-hour discount copy one hour in and the plain reminder a day later.
* `apply_recovery_offer` **voids the stale invoice**: `_open_invoice_for_order` reuses any OPEN invoice, so one raised at the full price before the customer walked away would be handed straight back to the payment session and charged in full. `pricing_snapshot.credit_amount` does **not** move while `upfront_amount` drops — that divergence *is* the feature.
* `expire_recovery_offers` puts the full price back once the window closes and runs **unconditionally at the top of the task**, even when the campaign is paused: nothing else re-reads `expires_at`, so without the sweep an order discounted at hour 24 stays payable at 85% months later. `redeem_recovery_offer` consumes the offer under `select_for_update` and refuses when `invoice.total` is not the discounted figure.
* `GET /client/orders/recover/<token>/` is deliberately **not** `@login_required` — the click has to be counted for signed-out recipients, who are most of the audience. Applying on a GET is safe here: the view creates nothing and charges nothing, it lowers the price of an existing order idempotently and reverts on expiry, so a mail-scanner prefetch grants the discount the customer was being offered anyway. **Nothing about the order is rendered**; the receipt page is login-gated and ownership-checked.
* Tracking: `CampaignSend.first_clicked_at`/`last_clicked_at`/`click_count`/ `converted_at`/`conversion_amount`. `record_send_click` and `record_send_conversion` are best-effort and never raise. Conversion is attributed when the recovered order is paid, **first-write-wins**, so a retried webhook cannot inflate recovered revenue. Click rates are scoped to `TRACKED_LINK_AUTOMATION_KEYS`, because computing one across campaigns whose copy contains no tracked link reports catastrophic engagement where the truth is "nothing to measure".
* **Marketing, not lifecycle**: a promotional discount is not a service notice, so it honours the suppression list, the daily cap, the cooldown and quiet hours, and carries the unsubscribe footer. Consequently there is **no flat-text fallback** (a promo that went out because the campaign was paused, or to a suppressed address, is precisely the mail that must not be sent) and it **seeds as DRAFT** — enabling marketing is not the same decision as deciding to start giving away 15% of an order.
* **No open-tracking pixel.** Apple Mail Privacy Protection pre-fetches images, which makes an open count a measure of mail clients rather than of readers.

**Never-purchased win-back** — one private offer of 15% off the first month of any available public VPS plan, once per client per campaign.

* Audience fixed to billing accounts with **no `PAID` order**; unpaid checkout artifacts do not disqualify, a completed zero-upfront hourly deployment does. Contact-list and ad-hoc recipients are disabled because they have no billing account to own the offer.
* `{{ win_back_url }}` is generated per `CampaignSend`; the signed token identifies the delivered send without exposing an address. A valid link records its click before sign-in, then carries an internal marker through authentication so the continuation does not double-count. Opened by a different client account it returns 403.
* The offer is **claimed when checkout creates an order**, deliberately earlier than payment: double submits and selecting another plan return the first discounted order rather than producing multiple coupons. `WinBackRedemption` has a unique constraint on `(billing_account, campaign_id)` and the account row is locked while the first-purchase check and redemption are created.
* Only active public VPS plans are eligible; editing the signed route to name a hidden promo or custom plan is rejected by both the view and the order service. Win-back orders are excluded from abandoned-order recovery so the two discounts cannot stack.
* Pricing: monthly = 15% off the monthly price; annual = the normal annual total minus 15% of one monthly price; hourly = 15% off the first one-month deposit. `post_win_back_discount_credit` posts the remaining 15% after payment clears, guarded by `WinBackRedemption.credited_at`. The unpaid-order receipt projects that make-up credit so the customer sees the full post-payment credit before choosing a payment method. Referral and tenure calculations use the money actually collected.

**The LowEndTalk offer** ([2.6](/platform/public-site.md#id-26-promotional-landing-pages)) is the worked example of the repricing rule in [4.2](/platform/billing/model-and-catalog.md#id-42-catalog-products-plans-and-the-pricing-rules). Current generation 5: the same 14 sizes as generation 4 at exactly 5% more (monthly 63% of public = 37% off; annual 52.5% of public = 47.5% off, and `_percent_off` **floors**, so the page's constant is 47, not 48). Every earlier generation's rows are frozen in the legacy list, so servers already running keep their old rate for as long as they live, and retired slugs forward to the matching current checkout. Monthly is sold as the **hourly cadence at a promo rate**, not `BillingCadence.MONTHLY`: a `?cadence=monthly` link would have been coerced to hourly and sold at the full hourly rate (2.6× the promo), and MONTHLY subscriptions are acted on by `_process_scheduled_cancellations`, which destroys VMs by `filter(subscription=...)` — NULL on every self-serve order — so it would cancel the billing record and leave the server running. A promo clone therefore carries **its own** `monthly_price` and `hourly_price`; inheriting made the promo price a function of the list price, so a list rise moved live promo customers. The generic `/promo/` offer (`GENERIC_PROMO_PLAN_OVERRIDES`, generation 1) is the same 14 sizes at 90% of the current LET prices, also as dedicated hidden rows.

**The first-deposit match** (`apps/billing/promotions.py`) is a rate, a cap and a snapshot. `let_promo_code_is_valid` reads `PromotionSettings.let_promo_code_enabled`, which is the switch that retires the match without retiring the discounted plans ([2.6](/platform/public-site.md#id-26-promotional-landing-pages)); bonuses already granted are never reversed by it. Beyond that switch, the terms are: `LET_PROMO_MATCH_RATE` and `LET_PROMO_BONUS_CAP` are explicit constants (the 100% match used to be encoded as the *absence* of a multiplier, with the rate existing only in prose in three customer-facing strings), every statement of the terms is built from `match_offer_label`, `max_match_deposit` says the number the reader actually cares about outright, and the terms are **stamped onto the order** (`LET_PROMO_TERMS_METADATA_KEY`) when the code is applied and read back at payment — an order can sit `AWAITING_PAYMENT` across a change to the campaign, and the receipt page has already told that customer what their deposit will be matched by.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.layeronecloud.com/platform/billing/referrals-and-promotions.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
