feat(referrals): rework reward engine + email-verified landing
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Reward engine: - Recurring milestones (every 3 → +7d, every 5 → +14d) instead of one-time tiers capped at 5; idempotent + transactional grants serialised per referrer so concurrent qualifications can't double-count. - Rewards now gated on the referred user's email verification (afterEmailVerification hook); already-verified referees (OAuth) qualify at apply time. - Reward days banked as users.referral_credit_days when the referrer has no live subscription, consumed on next trial start / activation (no more silently lost rewards). - Accurate cumulative rewardDays in stats; getMyReferrals returns referee name/masked email/status. Hardening / cleanup: - onConflictDoNothing makes apply idempotent (no unhandled unique violation). - Anti-fraud: normalizeEmail blocks self-referral via gmail dot/+tag aliases. - Collision-safe referral code generation at signup. - Single apply path (welcome onboarding modal); removed duplicate calls in register + subscription pages. Input validation on the apply code. Email verification UX: - Verification link now lands on a dedicated /email-verified confirmation page instead of the deep-linked VIN/search page. Schema: referrals.status + qualified_at, users.referral_credit_days (0009). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -28,9 +28,36 @@ export const PLANS = {
|
||||
/** Sentinel value representing unlimited brand access for display purposes. */
|
||||
export const FULL_PLAN_BRAND_LIMIT = 999;
|
||||
|
||||
/**
|
||||
* Referrer reward milestones. Each time the referrer's *qualified* referral
|
||||
* count reaches a multiple of `every`, they earn `days` of subscription time.
|
||||
* Milestones are independent and recurring — they stack on a shared multiple
|
||||
* (e.g. count 15 fires both the every-3 and every-5 milestone). A referral
|
||||
* qualifies only once the referred user verifies their email.
|
||||
*/
|
||||
export const REFERRAL_REWARDS = {
|
||||
TIER_1: { count: 3, extensionDays: 7 },
|
||||
TIER_2: { count: 5, extensionDays: 30 },
|
||||
MILESTONES: [
|
||||
{ every: 3, days: 7 },
|
||||
{ every: 5, days: 14 },
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** Reward days earned exactly when the qualified count reaches `count`. */
|
||||
export function referralRewardForCount(count: number): number {
|
||||
if (count <= 0) return 0;
|
||||
return REFERRAL_REWARDS.MILESTONES.reduce(
|
||||
(sum, m) => (count % m.every === 0 ? sum + m.days : sum),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/** Cumulative reward days earned across all referrals up to `count`. */
|
||||
export function referralTotalRewardDays(count: number): number {
|
||||
if (count <= 0) return 0;
|
||||
return REFERRAL_REWARDS.MILESTONES.reduce(
|
||||
(sum, m) => sum + Math.floor(count / m.every) * m.days,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export const CURRENCY = "TRY" as const;
|
||||
|
||||
@@ -24,6 +24,9 @@ import {
|
||||
formatDateTime,
|
||||
slugify,
|
||||
generateReferralCode,
|
||||
normalizeEmail,
|
||||
referralRewardForCount,
|
||||
referralTotalRewardDays,
|
||||
// Constants
|
||||
VIN_REGEX,
|
||||
EMAIL_REGEX,
|
||||
@@ -583,6 +586,71 @@ describe("generateReferralCode", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeEmail", () => {
|
||||
it("lowercases and trims", () => {
|
||||
expect(normalizeEmail(" Foo@Example.COM ")).toBe("foo@example.com");
|
||||
});
|
||||
|
||||
it("strips +tag from the local part", () => {
|
||||
expect(normalizeEmail("user+promo@outlook.com")).toBe("user@outlook.com");
|
||||
});
|
||||
|
||||
it("keeps dots for non-gmail domains", () => {
|
||||
expect(normalizeEmail("a.b@outlook.com")).toBe("a.b@outlook.com");
|
||||
});
|
||||
|
||||
it("strips dots and +tag for gmail", () => {
|
||||
expect(normalizeEmail("a.b+x@Gmail.com")).toBe("ab@gmail.com");
|
||||
expect(normalizeEmail("ab@googlemail.com")).toBe("ab@googlemail.com");
|
||||
});
|
||||
|
||||
it("collapses gmail dot/+ variants to the same identity", () => {
|
||||
expect(normalizeEmail("john.doe+a@gmail.com")).toBe(normalizeEmail("johndoe@gmail.com"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("referralRewardForCount (per-milestone, every 3 → 7d, every 5 → 14d)", () => {
|
||||
it("grants nothing below the first milestone", () => {
|
||||
expect(referralRewardForCount(0)).toBe(0);
|
||||
expect(referralRewardForCount(1)).toBe(0);
|
||||
expect(referralRewardForCount(2)).toBe(0);
|
||||
});
|
||||
|
||||
it("grants 7 on every third referral", () => {
|
||||
expect(referralRewardForCount(3)).toBe(7);
|
||||
expect(referralRewardForCount(6)).toBe(7);
|
||||
expect(referralRewardForCount(9)).toBe(7);
|
||||
});
|
||||
|
||||
it("grants 14 on every fifth referral", () => {
|
||||
expect(referralRewardForCount(5)).toBe(14);
|
||||
expect(referralRewardForCount(10)).toBe(14);
|
||||
});
|
||||
|
||||
it("stacks both milestones on a shared multiple", () => {
|
||||
expect(referralRewardForCount(15)).toBe(21);
|
||||
expect(referralRewardForCount(30)).toBe(21);
|
||||
});
|
||||
|
||||
it("grants nothing between milestones", () => {
|
||||
expect(referralRewardForCount(4)).toBe(0);
|
||||
expect(referralRewardForCount(7)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("referralTotalRewardDays (cumulative)", () => {
|
||||
it("is 0 at zero referrals", () => {
|
||||
expect(referralTotalRewardDays(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("accumulates across milestones", () => {
|
||||
expect(referralTotalRewardDays(3)).toBe(7);
|
||||
expect(referralTotalRewardDays(5)).toBe(21); // 1×7 + 1×14
|
||||
expect(referralTotalRewardDays(6)).toBe(28); // 2×7 + 1×14
|
||||
expect(referralTotalRewardDays(15)).toBe(77); // 5×7 + 3×14
|
||||
});
|
||||
});
|
||||
|
||||
// --------------- constants/regex ---------------
|
||||
|
||||
describe("VIN_REGEX", () => {
|
||||
@@ -656,14 +724,11 @@ describe("FULL_PLAN_BRAND_LIMIT", () => {
|
||||
});
|
||||
|
||||
describe("REFERRAL_REWARDS", () => {
|
||||
it("TIER_1 requires 3 referrals for 7 extension days", () => {
|
||||
expect(REFERRAL_REWARDS.TIER_1.count).toBe(3);
|
||||
expect(REFERRAL_REWARDS.TIER_1.extensionDays).toBe(7);
|
||||
});
|
||||
|
||||
it("TIER_2 requires 5 referrals for 30 extension days", () => {
|
||||
expect(REFERRAL_REWARDS.TIER_2.count).toBe(5);
|
||||
expect(REFERRAL_REWARDS.TIER_2.extensionDays).toBe(30);
|
||||
it("defines recurring milestones: every 3 → 7 days, every 5 → 14 days", () => {
|
||||
expect(REFERRAL_REWARDS.MILESTONES).toEqual([
|
||||
{ every: 3, days: 7 },
|
||||
{ every: 5, days: 14 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -44,7 +44,14 @@ export { paginationSchema } from "./schemas/pagination.js";
|
||||
|
||||
// Constants
|
||||
export { VIN_REGEX, EMAIL_REGEX, OEM_CODE_REGEX } from "./constants/regex.js";
|
||||
export { PLANS, REFERRAL_REWARDS, CURRENCY, FULL_PLAN_BRAND_LIMIT } from "./constants/plans.js";
|
||||
export {
|
||||
PLANS,
|
||||
REFERRAL_REWARDS,
|
||||
CURRENCY,
|
||||
FULL_PLAN_BRAND_LIMIT,
|
||||
referralRewardForCount,
|
||||
referralTotalRewardDays,
|
||||
} from "./constants/plans.js";
|
||||
export { ERROR_CODES } from "./constants/error-codes.js";
|
||||
export type { ErrorCode } from "./constants/error-codes.js";
|
||||
|
||||
@@ -62,4 +69,5 @@ export {
|
||||
formatDateTime,
|
||||
slugify,
|
||||
generateReferralCode,
|
||||
normalizeEmail,
|
||||
} from "./utils/formatters.js";
|
||||
|
||||
@@ -35,6 +35,25 @@ export function slugify(text: string): string {
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalises an email for "same person" comparison (referral abuse checks).
|
||||
* Lowercases, and for Gmail/Googlemail strips dots and any `+tag` from the
|
||||
* local part — so `a.b+x@gmail.com` and `ab@gmail.com` collapse to one identity.
|
||||
*/
|
||||
export function normalizeEmail(email: string): string {
|
||||
const trimmed = email.trim().toLowerCase();
|
||||
const at = trimmed.lastIndexOf("@");
|
||||
if (at === -1) return trimmed;
|
||||
let local = trimmed.slice(0, at);
|
||||
const domain = trimmed.slice(at + 1);
|
||||
const plus = local.indexOf("+");
|
||||
if (plus !== -1) local = local.slice(0, plus);
|
||||
if (domain === "gmail.com" || domain === "googlemail.com") {
|
||||
local = local.replace(/\./g, "");
|
||||
}
|
||||
return `${local}@${domain}`;
|
||||
}
|
||||
|
||||
export function generateReferralCode(): string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
let code = "";
|
||||
|
||||
Reference in New Issue
Block a user