> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Referrals — Double-Sided Rewards and Anti-Fraud

> After this ships, a member can invite someone, and both get rewarded — when the invitation turns into a real customer, not before.

## Context

Referrals are the highest-leverage acquisition mechanism a loyalty program has, and the easiest to
farm. Every design choice here is really an anti-fraud choice.

The one that matters most: **the reward is released by a qualifying event, not by signup**. If
signing up pays, the program pays for fake accounts, and it will — reliably, within days of
launch. Tying release to a first *paid* purchase means the fraudster must spend real money to
extract a reward worth less than what they spent.

DEC-H7 constrains the qualifying event too: it is configured per tenant from their installed
taxonomy, because "became a real customer" means an invoice paid for a subscription business and a
first order for retail.

## Scope *(normative)*

* `loyalty.referral_codes`: one durable code per member per program, with a shareable link.
* `loyalty.referral_conversions`: the referred contact, its state, and the qualifying event.
* Double-sided reward: configurable amounts for referrer and referred, released on qualification.
* Anti-fraud: self-referral detection, velocity limits, and device/IP heuristics recorded as flags.
* `loyalty.referral_fraud_flags` for review rather than silent rejection.
* Attribution window between click and qualification.

## Non-scope *(normative)*

* Multi-level or pyramid structures. Explicitly never — a referrer earns from their own referrals
  only. This is a product decision, not a phase.
* Sending the invitation. The link is produced here; delivering it is `messaging`.
* Landing pages and the sharing UI, which belong to `frontend/portal` and the widget.

## Behaviour *(normative)*

1. A member's referral code is **stable for the life of the program**. Regenerating it would break
   every link already shared.
2. A conversion moves `pending → qualified → rewarded`, or `pending → rejected`. Rewards are
   released **only** on the tenant-configured qualifying event, which defaults to a first paid
   purchase and is never a signup.
3. Self-referral is blocked at conversion, matched on the identity signals `core` already resolves
   — never on email string equality alone, which is trivially defeated.
4. Velocity limits are per referrer and per window, configurable. Exceeding one **flags for review;
   it does not silently reject.** A false positive that silently swallows a legitimate referral is
   worse than one that queues it for a human.
5. Device and IP heuristics are recorded as flags with their evidence, never as an automatic
   verdict. A shared household IP is normal.
6. Both rewards are released in **one transaction**. Rewarding one side only is FORBIDDEN.
7. The attribution window is configurable per program. A conversion outside it is recorded and
   rejected with that reason, not dropped.
8. A rejected or flagged conversion is never deleted — the evidence is the point.

## Data *(normative)*

| Table                          | Key invariants                                                                                                                                                              |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loyalty.referral_codes`       | unique per (program, contact); `code` unique per tenant; immutable once issued                                                                                              |
| `loyalty.referral_conversions` | FK referral code; `referred_contact_id`; `state_code`; `qualifying_event_id` nullable; `attributed_at`, `qualified_at`, `rewarded_at`; unique per (code, referred\_contact) |
| `loyalty.referral_fraud_flags` | FK conversion; `flag_code` parametric; evidence JSONB; `reviewed_by`, `reviewed_at`, `resolution`; append-only                                                              |

## API *(normative)*

| Endpoint                                 | Class      | Permission                    | Budget       |
| ---------------------------------------- | ---------- | ----------------------------- | ------------ |
| `GET /v1/loyalty/members/me/referral`    | Runtime    | member token                  | p95 \<150 ms |
| `POST /v1/loyalty/referrals/attribute`   | Runtime    | `loyalty.referrals.attribute` | p95 \<300 ms |
| `GET /v1/loyalty/referrals`              | Management | `loyalty.referrals.read`      | p95 \<1 s    |
| `POST /v1/loyalty/referrals/{id}/review` | Management | `loyalty.referrals.review`    | p95 \<1 s    |

## Events *(normative)*

`loyalty.referral.link_created` and `loyalty.referral.converted`, both available as outgoing
webhooks. Reward release emits `loyalty.points.earned` through the ledger service.

## Acceptance criteria *(normative)*

1. A signup with no qualifying event releases **no** reward, and the conversion sits at `pending`.
2. The qualifying event releases both rewards in one transaction; an induced failure on the second
   rolls back the first.
3. Self-referral via a matching resolved identity is blocked even when the email strings differ.
4. Exceeding the velocity limit flags for review and leaves the conversion `pending` — it is not
   rejected and not silently dropped.
5. A conversion arriving after the attribution window is recorded with reason `WINDOW_EXPIRED`.
6. Replaying the qualifying event does not release a second reward.
7. **Negative:** no configuration allows a referrer to earn from a referral of a referral.

## Execution

Asynchronous pipeline: attribution is synchronous and cheap, qualification is driven by the rules
engine consuming the qualifying event. Fraud heuristics run in the worker, never on the hot path.

## Open questions

None outstanding. Every question this spec carried was answered in the consolidated register
([`../../../design/open-questions-v1.md`](/design/open-questions-v1), v1.1) and folded
into the normative sections above.

## Changelog

| Version | Date       | Change                                                                     | Why                                      | Author                 |
| ------- | ---------- | -------------------------------------------------------------------------- | ---------------------------------------- | ---------------------- |
| 0.2.0   | 2026-08-17 | Open questions resolved (OQ-LOY-\*) and folded into the normative sections | Owner answered the consolidated register | daniel + claude-opus-5 |
| 0.1.0   | 2026-08-17 | Initial draft                                                              | —                                        | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
