Skip to content

Commit 5b899f2

Browse files
docs(invitations): module README — referral substrate + reward architectures (#5 prep) (#3837)
* docs(invitations): module README — referral substrate + reward architectures * docs(invitations): rewards are config-driven — downstream never edits billing.init.js User feedback: a downstream wiring its own listener in billing.init.js would be stack-file drift (gate-blocked, ISO-merge-clobbered). Reworked: the standard grant ships upstream in the stack, gated by config.billing.referral {enabled, referrerUnits, refereeUnits} (downstream flips config only); custom rewards (cashback/webhooks) live in a project-only glob-discovered module listening to the same event. billing.init.js TODO(#5) updated to prescribe exactly that. * docs(invitations): readme accuracy fixes from review (7 threads)
1 parent ff6625d commit 5b899f2

2 files changed

Lines changed: 160 additions & 1 deletion

File tree

modules/billing/billing.init.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,14 @@ export default async (app) => {
6060
*/
6161
// eslint-disable-next-line no-unused-vars
6262
invitationEvents.on('invitation.accepted', (payload) => {
63-
// TODO(#5): grant referral credits to payload.invitedBy (skip when invitedBy is null).
63+
// TODO(#5): implement the STANDARD grant HERE, in the stack, entirely CONFIG-GATED:
64+
// config.billing.referral = { enabled: false, referrerUnits: 0, refereeUnits: 0 }
65+
// if (!config.billing?.referral?.enabled) return; → grant idempotently
66+
// (key on `referral:${payload.invitationId}:referrer|referee`).
67+
// Downstream projects NEVER edit this file (drift gate + ISO-merge) — they flip the
68+
// config in their {project}.config.js. Custom rewards (cashback, webhooks) live in a
69+
// project-only module listening to the same event. See modules/invitations/README.md.
70+
// TODO(#5): grant credits to payload.invitedBy (skip null) + optionally acceptedUserId.
6471
// TODO(#5): async grant listener must self-guard rejections — the emit-site try/catch only catches sync throws.
6572
// No-op for P8a — the seam is the deliverable, not the grant.
6673
});

modules/invitations/README.md

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Invitations module
2+
3+
Optional, standalone module owning the **platform invitation** concept: invite a contact by
4+
email → single-use token + `invitedBy` + beta-gate eligibility. Depends only on `auth`
5+
(via the `registerSignupEligibility` hook — `auth` never imports this module). Knows nothing
6+
about organizations: getting an invited person into an org is the 2-step flow
7+
*platform invite → `org.addMember(userId)`*.
8+
9+
- Routes: `/api/invitations` (admin list/create + revoke via `DELETE /:invitationId` — no
10+
update endpoint) + `/api/invitations/verify/:token` (public).
11+
- Model `Invitation` → collection `invitations` (`email`, `token`, `invitedBy`, `status`,
12+
`expiresAt`, `consumingAt`, `acceptedAt`, `acceptedUserId`, `revokedAt`, `usedAt`).
13+
- Signup gate: two-phase claim/finalize (`consumingAt` CAS + lazy 15-min stale sweep),
14+
email pin, soft revoke. See `services/invitations.service.js`.
15+
16+
## The referral substrate (what "Referral rewards — coming soon" hooks into)
17+
18+
The reward **seam** ships; the reward **logic** is deliberately deferred (#5, gates in #3833).
19+
Two primitives are written on every accepted invite — on **both** the token-signup path and
20+
the OAuth path (shared `accept(invite, userId)`):
21+
22+
1. **`user.referredBy`** — the inviter's userId, stamped server-side on the created account
23+
(never client-writable: absent from the Zod schemas + update whitelists; written via the
24+
raw repository path only). This is the durable referral edge — it supports
25+
*compute-on-read* forever, even if an event was missed.
26+
2. **`invitation.accepted` event** — emitted by this module's singleton
27+
(`lib/events.js`):
28+
29+
```js
30+
invitationEvents.emit('invitation.accepted', {
31+
invitationId, // ← natural IDEMPOTENCY KEY for any grant
32+
email, // invitee email (lowercased)
33+
invitedBy, // inviter userId — the admin API always stamps the creating admin;
34+
// null only for actor-less inserts (legacy/scripted data)
35+
acceptedUserId, // the REFEREE — double-sided rewards need no schema change
36+
});
37+
```
38+
39+
Both the **referrer** (`invitedBy`) and the **referee** (`acceptedUserId`) are identifiable
40+
from the payload — reward either side, or both.
41+
42+
## Implementing rewards — two architectures (pick per product)
43+
44+
> **⚠️ Downstream rule first:** stack files (`modules/billing/**`, this module, `lib/`)
45+
> stay **byte-identical** downstream — the drift gate blocks edits and `/update-stack`
46+
> would clobber them. A downstream project therefore NEVER wires a listener by editing
47+
> `billing.init.js`. The two sanctioned channels are: **config** (deep-merged
48+
> `{project}.config.js` — for the standard reward below) and **project-only modules**
49+
> (glob-discovered, e.g. `modules/trawl-rewards/` — for custom logic).
50+
51+
### A. Standard grant — ships IN the stack, downstream enables it by CONFIG
52+
53+
The grant listener is implemented **once, upstream, in `billing.init.js`** (#5 — the
54+
no-op seam with the `TODO(#5)` is already there), entirely gated by config:
55+
56+
```js
57+
// modules/billing/config/billing.development.config.js — stack default: OFF
58+
// (this block does NOT exist yet — #5 adds it together with the listener impl)
59+
billing: { referral: { enabled: false, referrerUnits: 0, refereeUnits: 0 } }
60+
```
61+
62+
```js
63+
// a downstream's config/defaults/{project}.config.js — the ONLY thing it touches:
64+
billing: { referral: { enabled: true, referrerUnits: 500, refereeUnits: 200 } }
65+
```
66+
67+
The stack listener (#5 implementation sketch — lives upstream, never downstream):
68+
69+
```js
70+
invitationEvents.on('invitation.accepted', async ({ invitationId, invitedBy, acceptedUserId }) => {
71+
try {
72+
const cfg = config.billing?.referral;
73+
if (!cfg?.enabled) return; // downstream flips this
74+
if (invitedBy && cfg.referrerUnits) await grantCredits({ userId: invitedBy, units: cfg.referrerUnits, key: `referral:${invitationId}:referrer` });
75+
if (cfg.refereeUnits) await grantCredits({ userId: acceptedUserId, units: cfg.refereeUnits, key: `referral:${invitationId}:referee` });
76+
} catch (err) {
77+
// ⚠️ MANDATORY self-guard: EventEmitter.emit is synchronous — the emit-site
78+
// try/catch in invitations.service only catches SYNC throws. An async
79+
// listener's rejection escapes as an unhandledRejection. Never let it.
80+
logger.error('[billing] referral grant failed', { err: err?.message, stack: err?.stack });
81+
}
82+
});
83+
```
84+
85+
Rules that make this production-grade:
86+
87+
- **Idempotency** — key every grant on `invitationId` (unique index on the ledger `key`):
88+
a replayed/duplicate event can never double-credit.
89+
- **Reconcile cron (safety net)** — EventEmitter is in-process fire-and-forget; a crash
90+
between accept and grant loses the event. Pair the listener with a periodic script
91+
(k8s CronJob, pattern: `modules/billing/crons/`) that scans
92+
ALL `invitations { status:'accepted' }` vs the grant ledger keys and back-fills misses
93+
(scan all accepted, not just `invitedBy:{$ne:null}` — referee grants exist even when
94+
`invitedBy` is null, so a referrer-only scan would miss referee-only back-fills).
95+
The listener is latency; the cron is truth.
96+
97+
### A'. Custom rewards (cashback, Stripe credit note, partner webhook) — project-only module
98+
99+
When a downstream needs logic beyond units (cashback %, coupons, external payouts), it
100+
ships its OWN module — glob discovery means zero stack edits:
101+
102+
```js
103+
// modules/{project}-rewards/{project}-rewards.init.js (downstream-only module)
104+
import invitationEvents from '../invitations/lib/events.js';
105+
106+
export default async () => {
107+
invitationEvents.on('invitation.accepted', async (payload) => {
108+
try { await myCashbackFlow(payload); } // same idempotency key rule
109+
catch (err) { logger.error('[rewards] cashback failed', { err: err?.message, stack: err?.stack }); }
110+
});
111+
};
112+
```
113+
114+
Multiple listeners on the shared emitter are fine (the stack's standard grant + a
115+
project's custom one can coexist); each owns its own failure handling.
116+
117+
### B. Compute-on-read (no writes, simplest)
118+
119+
Derive the reward at quota/entitlement time instead of granting:
120+
121+
```js
122+
// illustrative — a countAccepted helper does not exist yet; #5 adds it (or an equivalent query)
123+
const accepted = await InvitationRepository.countAccepted({ invitedBy: userId });
124+
const bonus = accepted * config.billing.referral.referrerUnits;
125+
```
126+
127+
Always consistent (survives missed events), zero ledger. Costs a query on the hot
128+
entitlement path (index `invitations.invitedBy` first — the field this query hits;
129+
`users.referredBy` gets its own index only if referral lists query it),
130+
and hard to cap/expire/audit ("when was this credited?"). Good for simple boosts
131+
(e.g. "+1 project slot per referral"), wrong for money-shaped balances.
132+
133+
> Recommended: **A + reconcile cron** for credit/cashback economies; **B** for static
134+
> entitlement boosts. The substrate supports both simultaneously.
135+
136+
## Gates before shipping rewards (#3833 — read it first)
137+
138+
1. **Scope the list**: `GET /api/invitations` is platform-global today (admin-only by
139+
CASL). Before widening `create Invitation` to regular users, add an `invitedBy`-scoped
140+
`/mine` (PII: invitee emails).
141+
2. **Self-referral guard** — alternate-email self-invites become valuable once credits exist.
142+
3. **Open-signup hole** — claim/finalize are gated on `!config.sign.up`: with public signup
143+
open the event never fires. Resolve (finalize-without-claim) or hide the Referrals tab
144+
before any open deployment enables rewards.
145+
4. **Index `referredBy`** alongside the first real referral query.
146+
147+
## UI
148+
149+
The Vue module (`src/modules/invitations/` in Devkit Vue) ships the admin beta-gate tab and
150+
the account **Referrals** tab (invite a contact, my invites + status chips, a referral
151+
summary, and the "Referral rewards — coming soon" placeholder where #5's balance lands —
152+
the placeholder is contractually digit-free until real numbers exist).

0 commit comments

Comments
 (0)