@profullstack/affiliates 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Profullstack, Inc. (moshcoder)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,249 @@
1
- # Temporary Holding Version
1
+ # @profullstack/affiliates
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Scarce invite codes plus open affiliate links, for every Profullstack property.
4
+
5
+ - **Invite codes** are scarce on purpose. Each user gets a small quota (default 3). Each code
6
+ works once, expires (default 30 days), cannot be redeemed by the person who made it, and records
7
+ who invited whom. Codes look like `INV-7K3M-QX9D-2F8H` (60 random bits, Crockford base32) and are
8
+ stored only as sha256 hashes, so a database leak does not leak working codes.
9
+ - **Affiliate codes** are open: one stable public code per user, unlimited use, first-touch
10
+ attribution that is never overwritten.
11
+ - **Commission math** is not reimplemented here. It comes from
12
+ [`@profullstack/referrals`](https://www.npmjs.com/package/@profullstack/referrals)
13
+ (`calculateReferral`, `DEFAULT_SPLIT` = 60% affiliate, 20% customer discount), and its
14
+ `buildReferralUrl` / `extractCode` are re-exported.
15
+
16
+ Stores: in-memory, SQLite (`bun:sqlite`, `node:sqlite`, `better-sqlite3`), Postgres (anything
17
+ with a node-postgres style `query`) and Supabase (a supabase-js client, through `rpc()` only). SQL
18
+ stores create their own tables (prefix `aff_`); the Supabase store ships its schema as a migration.
19
+
20
+ ```sh
21
+ npm i @profullstack/affiliates
22
+ ```
23
+
24
+ ## Quick start
25
+
26
+ ```ts
27
+ import { Database } from 'bun:sqlite'; // or: import { DatabaseSync } from 'node:sqlite'
28
+ import { Affiliates, sqliteStore, buildReferralUrl, extractCode } from '@profullstack/affiliates';
29
+
30
+ const aff = new Affiliates(sqliteStore(new Database('app.db')));
31
+
32
+ // invites: scarce, single use
33
+ const invite = await aff.createInvite('user_alice', 'for Carol');
34
+ invite.code; // 'INV-7K3M-QX9D-2F8H' (shown once, only the hash is stored)
35
+ await aff.quota('user_alice'); // { total: 3, used: 1, left: 2 }
36
+ await aff.redeemInvite(invite.code, 'user_carol'); // { ok: true, inviterId: 'user_alice', inviteId: '...' }
37
+ await aff.redeemInvite(invite.code, 'user_dave'); // { ok: false, reason: 'used' }
38
+ await aff.invitedBy('user_carol'); // 'user_alice'
39
+ await aff.setQuota('user_alice', 10); // admin: hand out more
40
+
41
+ // affiliate links: open, unlimited, first touch wins
42
+ const code = await aff.affiliateCode('user_alice'); // 'E6KETYBK' (stable)
43
+ const link = buildReferralUrl('https://example.com/signup', code); // ...?ref=E6KETYBK
44
+ await aff.attribute(extractCode(link)!, 'user_erin'); // { ok: true, affiliateId: 'user_alice' }
45
+ aff.commission(10_000); // { discountCents: 2000, commissionCents: 6000 }
46
+ ```
47
+
48
+ Postgres:
49
+
50
+ ```ts
51
+ import pg from 'pg';
52
+ import { Affiliates, postgresStore } from '@profullstack/affiliates';
53
+ const aff = new Affiliates(postgresStore(new pg.Pool({ connectionString: process.env.DATABASE_URL })));
54
+ ```
55
+
56
+ ## On Supabase
57
+
58
+ For apps that only have a supabase-js client (service role key, no direct `DATABASE_URL`). Every
59
+ operation is one `client.rpc()` call to a Postgres function, so the scarce parts are atomic in the
60
+ database, not read-modify-write in JS: the quota check and insert run under a per-inviter advisory
61
+ lock (the same logic as `postgresStore`'s `aff_insert_invite()`), redemption is
62
+ `UPDATE ... WHERE used_by IS NULL`, and attribution is `INSERT ... ON CONFLICT DO NOTHING`.
63
+ `@supabase/supabase-js` is not a dependency; anything with `rpc(fn, args) -> { data, error }` works.
64
+
65
+ 1. Add the migration (tables, RLS on with no policies, `SECURITY DEFINER` functions with a pinned
66
+ `search_path`, executable only by `service_role`):
67
+
68
+ ```sh
69
+ cp node_modules/@profullstack/affiliates/migrations/supabase.sql supabase/migrations/20261026000000_affiliates.sql
70
+ # other prefix: node -e "import('@profullstack/affiliates').then(m => process.stdout.write(m.supabaseMigrationSql('gc_')))" > ...
71
+ ```
72
+
73
+ 2. Use a service-role client on the server only:
74
+
75
+ ```ts
76
+ // lib/affiliates.ts (server only)
77
+ import { createClient } from '@supabase/supabase-js';
78
+ import { Affiliates, supabaseStore } from '@profullstack/affiliates';
79
+ let aff: Affiliates | undefined;
80
+ export const getAffiliates = () => (aff ??= new Affiliates(supabaseStore(createClient(
81
+ process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, { auth: { persistSession: false } }))));
82
+
83
+ // app/auth/callback/route.ts, after exchangeCodeForSession() gave you `user`
84
+ import { refFromRequest } from '@profullstack/affiliates';
85
+ const ref = refFromRequest(request); // ?ref= or the referral_code cookie
86
+ if (ref) await getAffiliates().attribute(ref, user.id); // { external: true } = not ours, see below
87
+ ```
88
+
89
+ Build the client lazily (as above), not at module top level: Next.js inlines `process.env.X` at
90
+ build time, and a secret that is only present at runtime would compile in as `undefined`.
91
+
92
+ The tables are the same as `postgresStore`'s, so a script with a direct Postgres URL can use
93
+ `postgresStore` on the same data. `supabaseStore(client, { prefix })` must match the prefix the
94
+ migration was generated with. The anon and authenticated keys can neither read the tables nor call
95
+ the functions.
96
+
97
+ ## Already using @profullstack/referrals
98
+
99
+ Sites that already ship `@profullstack/referrals` keep it: its tables, its `trackReferralCode`
100
+ middleware and its single `referral_code` cookie. Affiliates reads that cookie instead of setting
101
+ its own, so one sign-up is never counted twice.
102
+
103
+ - **Do not set a second cookie.** In the signup or auth callback, take the code with
104
+ `refFromRequest(request)` (it reads `?ref=` and then `REF_COOKIE`, which is `'referral_code'`, the
105
+ name `@profullstack/referrals/next` sets) and pass it to `aff.attribute(code, newUserId)`.
106
+ - **Codes that are theirs come back `{ ok: false, external: true }`** and nothing is recorded; hand
107
+ them to the existing referrals flow (`applyReferral`, the site's own usage table). Our codes come
108
+ back `{ ok: true, affiliateId }`. Unknown codes come back `{ ok: false }`.
109
+ - How "theirs" is decided: `supabaseStore` checks the `referral_codes` table from
110
+ `20260602000000_referrals.sql` when it exists. Other stores take
111
+ `new Affiliates(store, { externalCodes })`, where `externalCodes` is your @profullstack/referrals
112
+ `ReferralStore` (its `getCode` is used) or a function `(code) => boolean | Promise<boolean>`.
113
+
114
+ ```ts
115
+ const code = refFromRequest(request);
116
+ if (code) {
117
+ const r = await aff.attribute(code, user.id);
118
+ if (r.external) await applyReferral({ code, newUserId: user.id, amountCents, store: referralStore });
119
+ }
120
+ ```
121
+
122
+ ## Invite-only signup in 5 lines
123
+
124
+ ```ts
125
+ const aff = new Affiliates(sqliteStore(db));
126
+ const r = await aff.redeemInvite(body.inviteCode, newUserId); // in your signup handler, before creating the account
127
+ if (!r.ok) return new Response(`Invite ${r.reason}`, { status: 403 });
128
+ await createUser(newUserId, { invitedBy: r.inviterId });
129
+ // later, on the user's settings page: const { code } = await aff.createInvite(userId);
130
+ ```
131
+
132
+ Allocate the user id before redeeming (or redeem inside the same transaction you create the user in)
133
+ so a failed account creation does not silently burn the invite. If it does, `setQuota` the inviter
134
+ one more.
135
+
136
+ ## API
137
+
138
+ ```ts
139
+ export type Invite = {
140
+ code: string; // plaintext, only on create
141
+ id: string;
142
+ inviterId: string;
143
+ createdAt: number; // ms
144
+ expiresAt: number | null;
145
+ usedBy: string | null;
146
+ usedAt: number | null;
147
+ note: string;
148
+ };
149
+ export type RedeemInviteResult =
150
+ | { ok: true; inviterId: string; inviteId: string }
151
+ | { ok: false; reason: 'invalid' | 'expired' | 'used' | 'self' | 'revoked' };
152
+
153
+ export interface AffiliateStore { /* see src/store.ts: quota, invite, claim, attribution primitives */ }
154
+ export function memoryStore(): AffiliateStore;
155
+ export function sqliteStore(db: SqliteLike, opts?: { prefix?: string }): AffiliateStore;
156
+ export function postgresStore(
157
+ pool: { query(sql: string, params?: unknown[]): Promise<{ rows: any[] }> },
158
+ opts?: { prefix?: string },
159
+ ): AffiliateStore;
160
+ export function supabaseStore(
161
+ client: { rpc(fn: string, args?: Record<string, unknown>): PromiseLike<{ data: any; error: any }> },
162
+ opts?: { prefix?: string },
163
+ ): AffiliateStore;
164
+ export function supabaseMigrationSql(prefix?: string): string; // = migrations/supabase.sql for 'aff_'
165
+
166
+ export const REF_COOKIE = 'referral_code'; // @profullstack/referrals' cookie
167
+ export const REF_PARAM = 'ref';
168
+ export function refFromRequest(
169
+ req: Request | { headers: Headers; url: string },
170
+ opts?: { param?: string; cookieName?: string },
171
+ ): string | null; // ?ref= first, then the cookie
172
+
173
+ export class Affiliates {
174
+ constructor(store: AffiliateStore, opts?: {
175
+ now?: () => number; // clock, default Date.now
176
+ defaultQuota?: number; // default 3
177
+ inviteTtlMs?: number | null; // default 30 days, null = never expires
178
+ split?: ReferralSplit; // default DEFAULT_SPLIT from @profullstack/referrals
179
+ externalCodes?: ReferralStore | ((code: string) => boolean | Promise<boolean>);
180
+ });
181
+ quota(userId: string): Promise<{ total: number; used: number; left: number }>;
182
+ setQuota(userId: string, total: number): Promise<void>;
183
+ createInvite(inviterId: string, note?: string): Promise<Invite>; // throws Error('no invites left')
184
+ redeemInvite(code: string, newUserId: string): Promise<RedeemInviteResult>;
185
+ invitedBy(userId: string): Promise<string | null>;
186
+ invitees(inviterId: string): Promise<{ userId: string; at: number }[]>;
187
+ invites(inviterId: string): Promise<Omit<Invite, 'code'>[]>; // newest first; also carries revokedAt
188
+ revokeInvite(inviteId: string): Promise<void>;
189
+ affiliateCode(userId: string): Promise<string>;
190
+ attribute(code: string, newUserId: string): Promise<{ ok: boolean; affiliateId?: string; external?: boolean }>;
191
+ isExternalCode(code: string): Promise<boolean>;
192
+ affiliateOf(userId: string): Promise<string | null>;
193
+ commission(amountCents: number): { discountCents: number; commissionCents: number };
194
+ }
195
+ export { buildReferralUrl, extractCode, calculateReferral, DEFAULT_SPLIT } from '@profullstack/referrals';
196
+ export function normalizeCode(input: string): string;
197
+ ```
198
+
199
+ Semantics worth knowing:
200
+
201
+ - **Quota** counts every invite a user created except ones revoked before use, so revoking an unused
202
+ invite refunds the slot. `setQuota` sets the total, not an increment.
203
+ - **Single use** is enforced in the store with `UPDATE ... WHERE used_by IS NULL AND revoked_at IS NULL`;
204
+ under any number of concurrent redeems exactly one wins and the rest get `used`.
205
+ - **Quota under concurrency**: SQLite serializes writers, and on Postgres the check and insert run in
206
+ a small `aff_insert_invite()` function under a per-inviter advisory lock, so parallel
207
+ `createInvite` calls cannot overshoot.
208
+ - **Redeem order**: `invalid`, `revoked`, `used`, `self`, `expired`. A self-redeem does not burn the
209
+ invite. If a user redeems two different invites, `invitedBy` returns the first inviter.
210
+ - **normalizeCode** uppercases, drops spaces and punctuation, and maps the Crockford look-alikes
211
+ `O` to `0` and `I`/`L` to `1`. Invite codes come back as `INV-XXXX-XXXX-XXXX` (the `INV` prefix is
212
+ optional on input), so `inv 7k3m qx9d 2f8h` redeems.
213
+ - **Affiliate attribution** is first touch: a second `attribute` for the same user returns
214
+ `{ ok: false, affiliateId: <the original> }`. Users cannot attribute themselves. A code that is not
215
+ ours but belongs to the site's @profullstack/referrals setup returns `{ ok: false, external: true }`.
216
+
217
+ Tables (prefix configurable): `aff_invites`, `aff_quotas`, `aff_codes`, `aff_attributions`.
218
+
219
+ ## CLI
220
+
221
+ ```sh
222
+ npx @profullstack/affiliates --db app.db invite create user_alice --note "for Carol"
223
+ affiliates --db app.db invite list user_alice
224
+ affiliates --db app.db invite revoke <inviteId>
225
+ affiliates --db app.db invite redeem INV-7K3M-QX9D-2F8H user_carol
226
+ affiliates --db app.db quota set user_alice 10
227
+ affiliates --db app.db quota get user_alice
228
+ affiliates --db app.db invitedby user_carol
229
+ affiliates --db app.db invitees user_alice
230
+ affiliates --db app.db code user_alice
231
+ affiliates --db app.db --json commission 10000
232
+ ```
233
+
234
+ Works on a SQLite file through `node:sqlite` under Node (22.13+) and `bun:sqlite` under Bun. Every
235
+ command takes `--json`. Other options: `--prefix`, `--default-quota`, `--ttl-days` (0 = no expiry).
236
+ `--db` defaults to `$AFFILIATES_DB`, then `./affiliates.db`.
237
+
238
+ ## Development
239
+
240
+ ```sh
241
+ npm test # node --experimental-strip-types --test (memory + node:sqlite)
242
+ AFFILIATES_TEST_PG_URL=postgres://postgres:x@127.0.0.1:55432/postgres npm test # adds real Postgres + supabaseStore over rpc()
243
+ # adds real PostgREST too (see .github/workflows and test/setup-postgrest.ts):
244
+ # AFFILIATES_TEST_POSTGREST_URL=http://127.0.0.1:3000 AFFILIATES_TEST_POSTGREST_JWT_SECRET=... npm test
245
+ npm run gen:migration # rewrite migrations/supabase.sql after changing supabaseMigrationSql()
246
+ npm run build # tsc to dist/
247
+ ```
248
+
249
+ MIT, Profullstack, Inc.
@@ -0,0 +1,101 @@
1
+ import { type ReferralSplit } from '@profullstack/referrals';
2
+ import type { AffiliateStore } from './store.js';
3
+ export type Invite = {
4
+ /** Plaintext code. Only returned by createInvite; never stored. */
5
+ code: string;
6
+ id: string;
7
+ inviterId: string;
8
+ createdAt: number;
9
+ expiresAt: number | null;
10
+ usedBy: string | null;
11
+ usedAt: number | null;
12
+ note: string;
13
+ };
14
+ export type RedeemInviteResult = {
15
+ ok: true;
16
+ inviterId: string;
17
+ inviteId: string;
18
+ } | {
19
+ ok: false;
20
+ reason: 'invalid' | 'expired' | 'used' | 'self' | 'revoked';
21
+ };
22
+ /** Result of `attribute`. `external: true` means the code belongs to the site's other referral system. */
23
+ export type AttributeResult = {
24
+ ok: boolean;
25
+ affiliateId?: string;
26
+ external?: boolean;
27
+ };
28
+ /** A check for codes owned by another referral system: a function, or an @profullstack/referrals ReferralStore. */
29
+ export type ExternalCodes = ((code: string) => boolean | Promise<boolean>) | {
30
+ getCode(code: string): Promise<unknown | null>;
31
+ };
32
+ export interface AffiliatesOptions {
33
+ /** Clock in ms. Default Date.now. */
34
+ now?: () => number;
35
+ /** Invites each user may create unless setQuota says otherwise. Default 3. */
36
+ defaultQuota?: number;
37
+ /** Invite lifetime in ms; null for no expiry. Default 30 days. */
38
+ inviteTtlMs?: number | null;
39
+ /** Commission split passed to @profullstack/referrals. Default DEFAULT_SPLIT (60% affiliate, 20% customer). */
40
+ split?: ReferralSplit;
41
+ /**
42
+ * Codes that belong to an existing referral system (sites already on @profullstack/referrals).
43
+ * When `attribute()` gets a code that is not ours and this says it is theirs, it returns
44
+ * `{ ok: false, external: true }` and records nothing, so the existing flow handles it.
45
+ * Default: the store's `isExternalCode` when it has one (supabaseStore checks `referral_codes`).
46
+ */
47
+ externalCodes?: ExternalCodes;
48
+ }
49
+ /** Scarce invite codes plus open affiliate links over any AffiliateStore. */
50
+ export declare class Affiliates {
51
+ readonly store: AffiliateStore;
52
+ readonly now: () => number;
53
+ readonly defaultQuota: number;
54
+ readonly inviteTtlMs: number | null;
55
+ readonly split: ReferralSplit;
56
+ private readonly externalCodes;
57
+ constructor(store: AffiliateStore, opts?: AffiliatesOptions);
58
+ /** True when `code` belongs to the site's other referral system (see `externalCodes`). */
59
+ isExternalCode(code: string): Promise<boolean>;
60
+ private total;
61
+ /** Invites a user may create, has created (revoked-before-use ones are refunded), and has left. */
62
+ quota(userId: string): Promise<{
63
+ total: number;
64
+ used: number;
65
+ left: number;
66
+ }>;
67
+ /** Admin: set a user's invite allowance (total, not additional). */
68
+ setQuota(userId: string, total: number): Promise<void>;
69
+ /** Mint one invite. Throws Error('no invites left') when the quota is spent. */
70
+ createInvite(inviterId: string, note?: string): Promise<Invite>;
71
+ /** Spend an invite on a new user. Single use; the inviter cannot redeem their own. */
72
+ redeemInvite(code: string, newUserId: string): Promise<RedeemInviteResult>;
73
+ /** Who invited this user (first redeemed invite), or null. */
74
+ invitedBy(userId: string): Promise<string | null>;
75
+ /** Users who joined on this inviter's invites, oldest first. */
76
+ invitees(inviterId: string): Promise<{
77
+ userId: string;
78
+ at: number;
79
+ }[]>;
80
+ /** Invites this user created, newest first, without codes (only hashes are stored). Includes revokedAt. */
81
+ invites(inviterId: string): Promise<(Omit<Invite, 'code'> & {
82
+ revokedAt: number | null;
83
+ })[]>;
84
+ /** Revoke an unused invite; it stops working and its quota slot is refunded. */
85
+ revokeInvite(inviteId: string): Promise<void>;
86
+ /** The user's public affiliate code: stable, created on first call, unlimited use. */
87
+ affiliateCode(userId: string): Promise<string>;
88
+ /**
89
+ * First-touch attribution of a new user to the affiliate who owns `code`. Never overwrites.
90
+ * A code that is not ours but belongs to the site's existing referral system returns
91
+ * `{ ok: false, external: true }` and records nothing.
92
+ */
93
+ attribute(code: string, newUserId: string): Promise<AttributeResult>;
94
+ /** The affiliate a user is attributed to, or null. */
95
+ affiliateOf(userId: string): Promise<string | null>;
96
+ /** Discount for the new customer and commission for the affiliate, via @profullstack/referrals. */
97
+ commission(amountCents: number): {
98
+ discountCents: number;
99
+ commissionCents: number;
100
+ };
101
+ }
@@ -0,0 +1,180 @@
1
+ import { calculateReferral, DEFAULT_SPLIT } from '@profullstack/referrals';
2
+ import { generateAffiliateCode, generateInviteCode, hashCode, newId, normalizeCode } from "./codes.js";
3
+ const DAY = 24 * 60 * 60 * 1000;
4
+ function publicInvite(r) {
5
+ return {
6
+ id: r.id,
7
+ inviterId: r.inviterId,
8
+ createdAt: r.createdAt,
9
+ expiresAt: r.expiresAt,
10
+ usedBy: r.usedBy,
11
+ usedAt: r.usedAt,
12
+ note: r.note,
13
+ revokedAt: r.revokedAt,
14
+ };
15
+ }
16
+ /** Scarce invite codes plus open affiliate links over any AffiliateStore. */
17
+ export class Affiliates {
18
+ store;
19
+ now;
20
+ defaultQuota;
21
+ inviteTtlMs;
22
+ split;
23
+ externalCodes;
24
+ constructor(store, opts = {}) {
25
+ this.store = store;
26
+ this.now = opts.now ?? Date.now;
27
+ this.defaultQuota = opts.defaultQuota ?? 3;
28
+ this.inviteTtlMs = opts.inviteTtlMs === undefined ? 30 * DAY : opts.inviteTtlMs;
29
+ this.split = opts.split ?? DEFAULT_SPLIT;
30
+ this.externalCodes = opts.externalCodes;
31
+ }
32
+ /** True when `code` belongs to the site's other referral system (see `externalCodes`). */
33
+ async isExternalCode(code) {
34
+ const raw = String(code ?? '').trim();
35
+ if (!raw)
36
+ return false;
37
+ const ext = this.externalCodes;
38
+ if (typeof ext === 'function')
39
+ return Boolean(await ext(raw));
40
+ // @profullstack/referrals looks codes up upper-cased and trimmed (validateCode)
41
+ if (ext)
42
+ return (await ext.getCode(raw.toUpperCase())) != null;
43
+ if (this.store.isExternalCode)
44
+ return this.store.isExternalCode(raw);
45
+ return false;
46
+ }
47
+ async total(userId) {
48
+ const q = await this.store.getQuota(userId);
49
+ return q ?? this.defaultQuota;
50
+ }
51
+ /** Invites a user may create, has created (revoked-before-use ones are refunded), and has left. */
52
+ async quota(userId) {
53
+ const [total, used] = await Promise.all([this.total(userId), this.store.countInvites(userId)]);
54
+ return { total, used, left: Math.max(0, total - used) };
55
+ }
56
+ /** Admin: set a user's invite allowance (total, not additional). */
57
+ async setQuota(userId, total) {
58
+ if (!Number.isInteger(total) || total < 0)
59
+ throw new Error('quota must be a non-negative integer');
60
+ await this.store.setQuota(userId, total);
61
+ }
62
+ /** Mint one invite. Throws Error('no invites left') when the quota is spent. */
63
+ async createInvite(inviterId, note = '') {
64
+ if (!inviterId)
65
+ throw new Error('inviterId required');
66
+ const limit = await this.total(inviterId);
67
+ const createdAt = this.now();
68
+ for (let attempt = 0; attempt < 5; attempt++) {
69
+ const code = generateInviteCode();
70
+ const row = {
71
+ id: newId(),
72
+ codeHash: hashCode(code),
73
+ inviterId,
74
+ createdAt,
75
+ expiresAt: this.inviteTtlMs === null ? null : createdAt + this.inviteTtlMs,
76
+ usedBy: null,
77
+ usedAt: null,
78
+ note: String(note ?? ''),
79
+ revokedAt: null,
80
+ };
81
+ let inserted;
82
+ try {
83
+ inserted = await this.store.insertInvite(row, limit);
84
+ }
85
+ catch (err) {
86
+ // 60-bit code collision: astronomically unlikely, retry with a fresh code
87
+ if (/unique|collision|constraint/i.test(String(err?.message)))
88
+ continue;
89
+ throw err;
90
+ }
91
+ if (!inserted)
92
+ throw new Error('no invites left');
93
+ const { revokedAt: _r, ...pub } = publicInvite(row);
94
+ return { code, ...pub };
95
+ }
96
+ throw new Error('could not mint a unique invite code');
97
+ }
98
+ /** Spend an invite on a new user. Single use; the inviter cannot redeem their own. */
99
+ async redeemInvite(code, newUserId) {
100
+ if (!code || !newUserId)
101
+ return { ok: false, reason: 'invalid' };
102
+ const row = await this.store.getInviteByHash(hashCode(code));
103
+ if (!row)
104
+ return { ok: false, reason: 'invalid' };
105
+ if (row.revokedAt !== null)
106
+ return { ok: false, reason: 'revoked' };
107
+ if (row.usedBy !== null)
108
+ return { ok: false, reason: 'used' };
109
+ if (row.inviterId === newUserId)
110
+ return { ok: false, reason: 'self' };
111
+ const at = this.now();
112
+ if (row.expiresAt !== null && at >= row.expiresAt)
113
+ return { ok: false, reason: 'expired' };
114
+ const won = await this.store.claimInvite(row.id, newUserId, at);
115
+ if (!won) {
116
+ const again = await this.store.getInvite(row.id);
117
+ return { ok: false, reason: again && again.revokedAt !== null && again.usedBy === null ? 'revoked' : 'used' };
118
+ }
119
+ return { ok: true, inviterId: row.inviterId, inviteId: row.id };
120
+ }
121
+ /** Who invited this user (first redeemed invite), or null. */
122
+ invitedBy(userId) {
123
+ return this.store.invitedBy(userId);
124
+ }
125
+ /** Users who joined on this inviter's invites, oldest first. */
126
+ invitees(inviterId) {
127
+ return this.store.invitees(inviterId);
128
+ }
129
+ /** Invites this user created, newest first, without codes (only hashes are stored). Includes revokedAt. */
130
+ async invites(inviterId) {
131
+ return (await this.store.listInvites(inviterId)).map(publicInvite);
132
+ }
133
+ /** Revoke an unused invite; it stops working and its quota slot is refunded. */
134
+ async revokeInvite(inviteId) {
135
+ await this.store.revokeInvite(inviteId, this.now());
136
+ }
137
+ /** The user's public affiliate code: stable, created on first call, unlimited use. */
138
+ async affiliateCode(userId) {
139
+ if (!userId)
140
+ throw new Error('userId required');
141
+ const existing = await this.store.getAffiliateCode(userId);
142
+ if (existing)
143
+ return existing;
144
+ for (let attempt = 0; attempt < 8; attempt++) {
145
+ const got = await this.store.putAffiliateCode(userId, generateAffiliateCode(), this.now());
146
+ if (got)
147
+ return got;
148
+ }
149
+ throw new Error('could not mint a unique affiliate code');
150
+ }
151
+ /**
152
+ * First-touch attribution of a new user to the affiliate who owns `code`. Never overwrites.
153
+ * A code that is not ours but belongs to the site's existing referral system returns
154
+ * `{ ok: false, external: true }` and records nothing.
155
+ */
156
+ async attribute(code, newUserId) {
157
+ if (!code || !newUserId)
158
+ return { ok: false };
159
+ const norm = normalizeCode(code);
160
+ const affiliateId = norm ? await this.store.affiliateByCode(norm) : null;
161
+ if (!affiliateId)
162
+ return (await this.isExternalCode(code)) ? { ok: false, external: true } : { ok: false };
163
+ if (affiliateId === newUserId)
164
+ return { ok: false };
165
+ const inserted = await this.store.attribute({ userId: newUserId, affiliateId, code: norm, at: this.now() });
166
+ if (inserted)
167
+ return { ok: true, affiliateId };
168
+ const prior = await this.store.getAttribution(newUserId);
169
+ return { ok: false, ...(prior ? { affiliateId: prior.affiliateId } : {}) };
170
+ }
171
+ /** The affiliate a user is attributed to, or null. */
172
+ async affiliateOf(userId) {
173
+ return (await this.store.getAttribution(userId))?.affiliateId ?? null;
174
+ }
175
+ /** Discount for the new customer and commission for the affiliate, via @profullstack/referrals. */
176
+ commission(amountCents) {
177
+ const r = calculateReferral(amountCents, this.split);
178
+ return { discountCents: r.discountCents, commissionCents: r.commissionCents };
179
+ }
180
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};