feat(referrals): rework reward engine + email-verified landing
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:
2026-05-25 13:28:32 +03:00
parent 125c6fc431
commit 3926e276fd
18 changed files with 6260 additions and 392 deletions

View File

@@ -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;

View File

@@ -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 },
]);
});
});

View File

@@ -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";

View File

@@ -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 = "";