Skip to content

Commit 17e2949

Browse files
Christian Shearerclaude
andcommitted
Implement free-month referral retirements, referral bonuses, and dashboard referral section
- Free-month referrals ($0 invoice) now trigger retirements as if the subscriber paid their plan amount, capped at $5. Stripe fee deduction applied for consistency with paid months. - Referrer gets an immediate bonus retirement at half the value (capped at $2.50) plus half the burn credit. Both funded from ops budget. - Referral bonus email sent to referrer with project details, dashboard link, and referral link. - Daily rate limit: >10 referrals/day triggers hold + Telegram alert. Admin endpoints to list/approve held rewards. - Dashboard: contributions table shows "Referral bonus" with "Executed" status (purple). New "Invite Friends, Protect Wildlife" section with referral counter, top-referrer badge, copy-able referral link, and share buttons. - Startup backfill for subscriber regen_address from retirement records. - DB migrations: 'held' status on referral_rewards, 'referral_bonus' type on transactions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a74dbfc commit 17e2949

File tree

4 files changed

+553
-27
lines changed

4 files changed

+553
-27
lines changed

src/server/dashboard.ts

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import {
3838
type CommunityStats,
3939
type CommunityGoal,
4040
type Subscriber,
41+
getReferralCount,
42+
getMedianReferralCount,
4143
} from "./db.js";
4244
import { PROJECTS, getProjectForBatch, type ProjectInfo } from "./project-metadata.js";
4345
import { createSessionToken, getSessionEmail } from "./magic-link.js";
@@ -250,12 +252,15 @@ function renderDashboardPage(opts: {
250252
batchDenomMap: Map<string, string>;
251253
totalRetiredCents: number;
252254
subscriptions: Array<{ plan: string; amountCents: number; billingInterval: "monthly" | "yearly" }>;
255+
referralCode: string;
256+
referralCount: number;
257+
isTopReferrer: boolean;
253258
}): string {
254259
const {
255260
email, plan, memberSince, cumulative, monthly, badges, manageUrl,
256261
amountCents, billingInterval, baseUrl, nextRetirementDate, transactions, communityStats,
257262
regenAddress, projectCards, communityGoal, communityTotalCredits, communitySubscriberCount,
258-
batchDenomMap, totalRetiredCents, subscriptions,
263+
batchDenomMap, totalRetiredCents, subscriptions, referralCode, referralCount, isTopReferrer,
259264
} = opts;
260265
const isYearly = billingInterval === "yearly";
261266

@@ -264,11 +269,6 @@ function renderDashboardPage(opts: {
264269
const retiredCents = totalRetiredCents > 0
265270
? totalRetiredCents
266271
: (cumulative.total_contribution_cents > 0 ? cumulative.total_contribution_cents : 0);
267-
const shareText = encodeURIComponent(
268-
`I'm funding ecological regeneration through my AI usage with @RegenCompute by @regen_network. Join the community and make your AI sessions count.`
269-
);
270-
const shareUrl = encodeURIComponent(baseUrl);
271-
272272
// Profile link
273273
const profileUrl = regenAddress
274274
? `https://app.regen.network/profiles/${regenAddress}/portfolio`
@@ -568,12 +568,13 @@ function renderDashboardPage(opts: {
568568
<tbody>
569569
${transactions.map(t => {
570570
const date = new Date(t.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
571-
const typeLabel = t.type === "subscription" ? "Subscription" : t.type === "topup" ? "One-time boost" : "Retirement";
572-
const typeColor = t.type === "subscription" ? "var(--regen-teal)" : t.type === "topup" ? "var(--regen-green)" : "var(--regen-navy)";
571+
const isReferralBonus = t.type === "referral_bonus";
572+
const typeLabel = isReferralBonus ? "Referral bonus" : t.type === "subscription" ? "Subscription" : t.type === "topup" ? "One-time boost" : "Retirement";
573+
const typeColor = isReferralBonus ? "#7c3aed" : t.type === "subscription" ? "var(--regen-teal)" : t.type === "topup" ? "var(--regen-green)" : "var(--regen-navy)";
573574
const hasRetirementTx = !!t.retirement_tx_hash;
574-
const statusLabel = hasRetirementTx ? "Retired" : "Paid";
575-
const statusBg = hasRetirementTx ? "#f0f7f2" : "#eff6ff";
576-
const statusColor = hasRetirementTx ? "#2d6a4f" : "#1e40af";
575+
const statusLabel = isReferralBonus ? (hasRetirementTx ? "Executed" : "Pending") : hasRetirementTx ? "Retired" : "Paid";
576+
const statusBg = isReferralBonus ? (hasRetirementTx ? "#f5f3ff" : "#fef3c7") : hasRetirementTx ? "#f0f7f2" : "#eff6ff";
577+
const statusColor = isReferralBonus ? (hasRetirementTx ? "#5b21b6" : "#92400e") : hasRetirementTx ? "#2d6a4f" : "#1e40af";
577578
const proofLink = hasRetirementTx
578579
? ` <a href="https://www.mintscan.io/regen/tx/${escapeHtml(t.retirement_tx_hash!)}" target="_blank" rel="noopener" style="font-size:11px;">proof</a>`
579580
: "";
@@ -590,16 +591,68 @@ function renderDashboardPage(opts: {
590591
</div>
591592
` : ""}
592593
593-
<!-- Share -->
594+
<!-- Referrals -->
594595
<div style="margin-bottom:32px;">
595-
<div style="background:var(--regen-white);border:1px solid var(--regen-gray-200);border-radius:var(--regen-radius);padding:24px;text-align:center;">
596-
<p style="font-size:14px;color:var(--regen-gray-500);margin:0 0 12px;">Invite others to make their AI usage regenerative.</p>
597-
<div class="regen-share-btns">
598-
<a class="regen-share-btn regen-share-btn--x" href="https://twitter.com/intent/tweet?text=${shareText}&url=${shareUrl}" target="_blank" rel="noopener">Post on X</a>
599-
<a class="regen-share-btn regen-share-btn--linkedin" href="https://www.linkedin.com/sharing/share-offsite/?url=${shareUrl}" target="_blank" rel="noopener">Share on LinkedIn</a>
596+
<h2 class="regen-section-title" style="font-size:20px;">Invite Friends, Protect Wildlife</h2>
597+
<div style="background:var(--regen-white);border:1px solid var(--regen-gray-200);border-radius:var(--regen-radius);overflow:hidden;">
598+
<div style="padding:24px 24px 0;">
599+
<p style="font-size:15px;color:var(--regen-gray-700);margin:0 0 8px;line-height:1.6;">
600+
Your referrals directly fund jaguar conservation, support indigenous-led stewardship, and sequester carbon. Every person you invite amplifies your ecological impact.
601+
</p>
602+
<p style="font-size:15px;color:var(--regen-gray-700);margin:0 0 16px;line-height:1.6;">
603+
Your friend gets their <strong>first month free</strong>. You earn a <strong>bonus credit retirement</strong>.
604+
</p>
605+
</div>
606+
607+
<!-- Stats + encouragement -->
608+
<div style="padding:0 24px 16px;display:flex;align-items:center;gap:16px;flex-wrap:wrap;">
609+
<div style="display:flex;align-items:center;gap:8px;">
610+
<span style="display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;background:#f5f3ff;border-radius:50%;font-size:16px;font-weight:800;color:#7c3aed;">${referralCount}</span>
611+
<span style="font-size:14px;color:var(--regen-gray-600);font-weight:600;">${referralCount === 1 ? "referral" : "referrals"}</span>
612+
</div>
613+
${isTopReferrer ? `
614+
<span style="display:inline-block;font-size:12px;font-weight:700;background:#f0fdf4;color:#166534;padding:4px 12px;border-radius:10px;">
615+
Top referrer — keep it up!
616+
</span>
617+
` : referralCount > 0 ? `
618+
<span style="display:inline-block;font-size:12px;font-weight:600;color:var(--regen-gray-500);">
619+
Share more to join the top referrers
620+
</span>
621+
` : `
622+
<span style="display:inline-block;font-size:12px;font-weight:600;color:var(--regen-gray-500);">
623+
Share your link below to get started
624+
</span>
625+
`}
626+
</div>
627+
628+
<!-- Referral link -->
629+
<div style="padding:16px 24px;background:#f0fdf4;border-top:1px solid #bbf7d0;">
630+
<p style="margin:0 0 8px;font-size:13px;font-weight:600;color:#166534;">Your referral link</p>
631+
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
632+
<code id="refLink" style="flex:1;min-width:200px;padding:8px 12px;background:#fff;border:1px solid #d1d5db;border-radius:6px;font-size:13px;color:var(--regen-navy);word-break:break-all;">${baseUrl}/r/${escapeHtml(referralCode)}</code>
633+
<button onclick="copyRefLink()" style="padding:8px 16px;background:#4FB573;color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;">Copy</button>
634+
</div>
635+
</div>
636+
637+
<!-- Share buttons -->
638+
<div style="padding:16px 24px;text-align:center;border-top:1px solid var(--regen-gray-200);">
639+
<div class="regen-share-btns">
640+
<a class="regen-share-btn regen-share-btn--x" href="https://twitter.com/intent/tweet?text=${encodeURIComponent("I use @RegenCompute to make my AI sessions fund ecological regeneration. Use my link for a free first month:")}&url=${encodeURIComponent(`${baseUrl}/r/${referralCode}`)}" target="_blank" rel="noopener">Post on X</a>
641+
<a class="regen-share-btn regen-share-btn--linkedin" href="https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(`${baseUrl}/r/${referralCode}`)}" target="_blank" rel="noopener">Share on LinkedIn</a>
642+
</div>
600643
</div>
601644
</div>
602645
</div>
646+
<script>
647+
function copyRefLink() {
648+
var el = document.getElementById('refLink');
649+
navigator.clipboard.writeText(el.textContent).then(function() {
650+
var orig = el.textContent;
651+
el.textContent = 'Copied!';
652+
setTimeout(function() { el.textContent = orig; }, 2000);
653+
});
654+
}
655+
</script>
603656
604657
<!-- Community goal / stats -->
605658
${goalHtml}
@@ -949,6 +1002,12 @@ export function createDashboardRoutes(
9491002
billingInterval: (s.billing_interval === "yearly" ? "yearly" : "monthly") as "monthly" | "yearly",
9501003
}));
9511004

1005+
// Referral stats
1006+
const referralCode = viewUser?.referral_code ?? user.referral_code;
1007+
const referralCount = getReferralCount(db, viewUser?.id ?? user.id);
1008+
const medianReferrals = getMedianReferralCount(db);
1009+
const isTopReferrer = referralCount > 0 && referralCount >= medianReferrals;
1010+
9521011
res.setHeader("Content-Type", "text/html");
9531012
res.send(renderDashboardPage({
9541013
email,
@@ -972,6 +1031,9 @@ export function createDashboardRoutes(
9721031
batchDenomMap,
9731032
totalRetiredCents,
9741033
subscriptions,
1034+
referralCode,
1035+
referralCount,
1036+
isTopReferrer,
9751037
}));
9761038
});
9771039

src/server/db.ts

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ export function getDb(dbPath = "data/regen-compute.db"): Database.Database {
272272
referrer_user_id INTEGER NOT NULL REFERENCES users(id),
273273
referred_user_id INTEGER NOT NULL REFERENCES users(id),
274274
reward_type TEXT NOT NULL DEFAULT 'extra_credit_retirement',
275-
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'fulfilled', 'expired')),
275+
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'fulfilled', 'expired', 'held')),
276276
retirement_tx_hash TEXT,
277277
created_at TEXT NOT NULL DEFAULT (datetime('now')),
278278
fulfilled_at TEXT
@@ -517,6 +517,54 @@ export function getDb(dbPath = "data/regen-compute.db"): Database.Database {
517517
console.log("Migration: added org_id column to subscribers");
518518
}
519519

520+
// Migration: add 'referral_bonus' to transactions type CHECK constraint
521+
if (tableInfo && !tableInfo.sql.includes("referral_bonus")) {
522+
_db.exec(`
523+
CREATE TABLE transactions_v3 (
524+
id INTEGER PRIMARY KEY AUTOINCREMENT,
525+
user_id INTEGER NOT NULL REFERENCES users(id),
526+
type TEXT NOT NULL CHECK(type IN ('topup', 'subscription', 'retirement', 'referral_bonus')),
527+
amount_cents INTEGER NOT NULL,
528+
description TEXT,
529+
stripe_session_id TEXT,
530+
retirement_tx_hash TEXT,
531+
credit_class TEXT,
532+
credits_retired REAL,
533+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
534+
billing_interval TEXT CHECK(billing_interval IN ('monthly', 'yearly')),
535+
stripe_subscription_id TEXT
536+
);
537+
INSERT INTO transactions_v3 SELECT * FROM transactions;
538+
DROP TABLE transactions;
539+
ALTER TABLE transactions_v3 RENAME TO transactions;
540+
CREATE INDEX IF NOT EXISTS idx_transactions_user_id ON transactions(user_id);
541+
`);
542+
console.log("Migration: added 'referral_bonus' type to transactions CHECK constraint");
543+
}
544+
545+
// Migration: add 'held' status to referral_rewards CHECK constraint
546+
const rrCheckInfo = _db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='referral_rewards'").get() as { sql: string } | undefined;
547+
if (rrCheckInfo?.sql && !rrCheckInfo.sql.includes("held")) {
548+
_db.exec(`
549+
CREATE TABLE referral_rewards_new (
550+
id INTEGER PRIMARY KEY AUTOINCREMENT,
551+
referrer_user_id INTEGER NOT NULL REFERENCES users(id),
552+
referred_user_id INTEGER NOT NULL REFERENCES users(id),
553+
reward_type TEXT NOT NULL DEFAULT 'extra_credit_retirement',
554+
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'fulfilled', 'expired', 'held')),
555+
retirement_tx_hash TEXT,
556+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
557+
fulfilled_at TEXT
558+
);
559+
INSERT INTO referral_rewards_new SELECT * FROM referral_rewards;
560+
DROP TABLE referral_rewards;
561+
ALTER TABLE referral_rewards_new RENAME TO referral_rewards;
562+
CREATE INDEX IF NOT EXISTS idx_referral_rewards_referrer ON referral_rewards(referrer_user_id);
563+
CREATE INDEX IF NOT EXISTS idx_referral_rewards_status ON referral_rewards(status);
564+
`);
565+
console.log("Migration: added 'held' status to referral_rewards CHECK constraint");
566+
}
567+
520568
return _db;
521569
}
522570

@@ -544,7 +592,7 @@ export interface User {
544592
export interface Transaction {
545593
id: number;
546594
user_id: number;
547-
type: "topup" | "subscription" | "retirement";
595+
type: "topup" | "subscription" | "retirement" | "referral_bonus";
548596
amount_cents: number;
549597
description: string | null;
550598
stripe_session_id: string | null;
@@ -655,6 +703,19 @@ export function debitBalance(
655703
return result;
656704
}
657705

706+
export function insertReferralBonusTransaction(
707+
db: Database.Database,
708+
userId: number,
709+
amountCents: number,
710+
retirementTxHash: string | null,
711+
creditsRetired: number | null,
712+
description: string = "Referral bonus retirement",
713+
): void {
714+
db.prepare(
715+
"INSERT INTO transactions (user_id, type, amount_cents, description, retirement_tx_hash, credits_retired) VALUES (?, 'referral_bonus', ?, ?, ?, ?)"
716+
).run(userId, amountCents, description, retirementTxHash, creditsRetired);
717+
}
718+
658719
export function getTransactions(db: Database.Database, userId: number, limit = 20): Transaction[] {
659720
return db.prepare(
660721
"SELECT * FROM transactions WHERE user_id = ? ORDER BY created_at DESC LIMIT ?"
@@ -1409,13 +1470,62 @@ export function fulfillReferralReward(
14091470
).run(retirementTxHash, rewardId);
14101471
}
14111472

1473+
export function getPendingReferralRewardForReferred(db: Database.Database, referredUserId: number): ReferralReward | undefined {
1474+
return db.prepare(
1475+
"SELECT * FROM referral_rewards WHERE referred_user_id = ? AND status = 'pending' ORDER BY id DESC LIMIT 1"
1476+
).get(referredUserId) as ReferralReward | undefined;
1477+
}
1478+
1479+
export function getTodayReferralCount(db: Database.Database): number {
1480+
const row = db.prepare(
1481+
"SELECT COUNT(*) AS count FROM referral_rewards WHERE created_at >= date('now')"
1482+
).get() as { count: number } | undefined;
1483+
return row?.count ?? 0;
1484+
}
1485+
1486+
export function holdReferralReward(db: Database.Database, rewardId: number): void {
1487+
db.prepare(
1488+
"UPDATE referral_rewards SET status = 'held' WHERE id = ?"
1489+
).run(rewardId);
1490+
}
1491+
1492+
export function getHeldReferralRewards(db: Database.Database): ReferralReward[] {
1493+
return db.prepare(
1494+
"SELECT * FROM referral_rewards WHERE status = 'held' ORDER BY created_at"
1495+
).all() as ReferralReward[];
1496+
}
1497+
1498+
export function approveReferralReward(db: Database.Database, rewardId: number): void {
1499+
db.prepare(
1500+
"UPDATE referral_rewards SET status = 'pending' WHERE id = ? AND status = 'held'"
1501+
).run(rewardId);
1502+
}
1503+
14121504
export function getReferralCount(db: Database.Database, userId: number): number {
14131505
const row = db.prepare(
14141506
"SELECT COUNT(*) AS count FROM users WHERE referred_by = ?"
14151507
).get(userId) as { count: number } | undefined;
14161508
return row?.count ?? 0;
14171509
}
14181510

1511+
export function getMedianReferralCount(db: Database.Database): number {
1512+
// Get all referrer counts (only users who have at least 1 referral)
1513+
const rows = db.prepare(
1514+
"SELECT COUNT(*) AS cnt FROM users WHERE referred_by IS NOT NULL GROUP BY referred_by ORDER BY cnt"
1515+
).all() as { cnt: number }[];
1516+
if (rows.length === 0) return 0;
1517+
const mid = Math.floor(rows.length / 2);
1518+
return rows.length % 2 === 0
1519+
? Math.floor((rows[mid - 1].cnt + rows[mid].cnt) / 2)
1520+
: rows[mid].cnt;
1521+
}
1522+
1523+
export function getFulfilledReferralRewardsForUser(db: Database.Database, userId: number): ReferralReward[] {
1524+
return db.prepare(
1525+
"SELECT * FROM referral_rewards WHERE referrer_user_id = ? AND status = 'fulfilled' ORDER BY fulfilled_at DESC"
1526+
).all(userId) as ReferralReward[];
1527+
}
1528+
14191529
// --- Magic link helpers ---
14201530

14211531
export function createMagicLinkToken(db: Database.Database, email: string, ttlMinutes: number): string {

0 commit comments

Comments
 (0)