@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 +21 -0
- package/README.md +248 -2
- package/dist/affiliates.d.ts +101 -0
- package/dist/affiliates.js +180 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +169 -0
- package/dist/codes.d.ts +19 -0
- package/dist/codes.js +48 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +8 -0
- package/dist/memory.d.ts +3 -0
- package/dist/memory.js +105 -0
- package/dist/postgres.d.ts +10 -0
- package/dist/postgres.js +8 -0
- package/dist/ref.d.ts +22 -0
- package/dist/ref.js +49 -0
- package/dist/sql.d.ts +12 -0
- package/dist/sql.js +172 -0
- package/dist/sqlite.d.ts +26 -0
- package/dist/sqlite.js +30 -0
- package/dist/store.d.ts +61 -0
- package/dist/store.js +1 -0
- package/dist/supabase.d.ts +31 -0
- package/dist/supabase.js +226 -0
- package/migrations/supabase.sql +319 -0
- package/package.json +65 -4
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
|
-
#
|
|
1
|
+
# @profullstack/affiliates
|
|
2
2
|
|
|
3
|
-
|
|
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