Skip to content

Commit c24d695

Browse files
committed
fix(referrals): enforce Kilo Pass referral rules
1 parent 97556e8 commit c24d695

8 files changed

Lines changed: 295 additions & 6 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { NextResponse } from 'next/server';
2+
3+
import { CRON_SECRET } from '@/lib/config.server';
4+
import { expirePendingKiloPassReferralRewards } from '@/lib/impact/kilo-pass-referrals';
5+
import { sentryLogger } from '@/lib/utils.server';
6+
7+
if (!CRON_SECRET) {
8+
throw new Error('CRON_SECRET is not configured in environment variables');
9+
}
10+
11+
export async function GET(request: Request) {
12+
const authHeader = request.headers.get('authorization');
13+
const expectedAuth = `Bearer ${CRON_SECRET}`;
14+
if (authHeader !== expectedAuth) {
15+
sentryLogger(
16+
'cron',
17+
'warning'
18+
)(
19+
'SECURITY: Invalid CRON job authorization attempt: ' +
20+
(authHeader ? 'Invalid authorization header' : 'Missing authorization header')
21+
);
22+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
23+
}
24+
25+
const summary = await expirePendingKiloPassReferralRewards();
26+
27+
return NextResponse.json(
28+
{
29+
success: true,
30+
summary,
31+
timestamp: new Date().toISOString(),
32+
},
33+
{ status: 200 }
34+
);
35+
}

apps/web/src/lib/impact/advocate.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,35 @@ describe('impact advocate', () => {
198198
);
199199
});
200200

201+
it('rejects Kilo Pass Advocate config that reuses KiloClaw program or widget IDs', async () => {
202+
process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'fallback-account';
203+
process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'fallback-secret';
204+
process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699';
205+
process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'fallback-tenant';
206+
process.env.IMPACT_ADVOCATE_WIDGET_ID = 'p/51699/w/referrerWidget';
207+
process.env.IMPACT_ADVOCATE_KILO_PASS_ACCOUNT_SID = 'kilo-pass-account';
208+
process.env.IMPACT_ADVOCATE_KILO_PASS_AUTH_TOKEN = 'kilo-pass-secret';
209+
process.env.IMPACT_ADVOCATE_KILO_PASS_TENANT_ALIAS = 'kilo-pass-tenant';
210+
211+
const scope = { product: 'kilo_pass' as const };
212+
213+
process.env.IMPACT_ADVOCATE_KILO_PASS_PROGRAM_ID = '51699';
214+
process.env.IMPACT_ADVOCATE_KILO_PASS_WIDGET_ID = 'p/52766/w/referrerWidget';
215+
jest.resetModules();
216+
let advocate = await import('@/lib/impact/advocate');
217+
expect(advocate.isImpactAdvocateConfigured(scope)).toBe(false);
218+
expect(advocate.getImpactAdvocateProgramId(scope)).toBeNull();
219+
expect(advocate.getImpactAdvocateWidgetId(scope)).toBeNull();
220+
221+
process.env.IMPACT_ADVOCATE_KILO_PASS_PROGRAM_ID = '52766';
222+
process.env.IMPACT_ADVOCATE_KILO_PASS_WIDGET_ID = 'p/51699/w/referrerWidget';
223+
jest.resetModules();
224+
advocate = await import('@/lib/impact/advocate');
225+
expect(advocate.isImpactAdvocateConfigured(scope)).toBe(false);
226+
expect(advocate.getImpactAdvocateProgramId(scope)).toBeNull();
227+
expect(advocate.getImpactAdvocateWidgetId(scope)).toBeNull();
228+
});
229+
201230
it('logs debug data without tokens, credentials, authorization headers, cookie values, or email identities', async () => {
202231
process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699';
203232
process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias';

apps/web/src/lib/impact/advocate.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,45 @@ function getImpactAdvocateWidgetPath(widgetId: string, programId: string): strin
232232
return `p/${trimmedWidgetId}/w/${IMPACT_ADVOCATE_WIDGET_NAME}`;
233233
}
234234

235+
function getKiloClawAdvocateIdentifiersForComparison(): {
236+
programIds: Set<string>;
237+
widgetIds: Set<string>;
238+
} {
239+
const programIds = new Set(
240+
[
241+
configuredValue(IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID),
242+
configuredValue(IMPACT_ADVOCATE_PROGRAM_ID),
243+
IMPACT_ADVOCATE_DEFAULT_PROGRAM_ID,
244+
].filter(Boolean)
245+
);
246+
const widgetIds = new Set<string>();
247+
for (const programId of programIds) {
248+
widgetIds.add(getImpactAdvocateWidgetPath('', programId));
249+
}
250+
for (const rawWidgetId of [
251+
configuredValue(IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID),
252+
configuredValue(IMPACT_ADVOCATE_WIDGET_ID),
253+
IMPACT_ADVOCATE_DEFAULT_WIDGET_ID,
254+
].filter(Boolean)) {
255+
for (const programId of programIds) {
256+
widgetIds.add(getImpactAdvocateWidgetPath(rawWidgetId, programId));
257+
}
258+
}
259+
260+
return { programIds, widgetIds };
261+
}
262+
263+
function kiloPassAdvocateConfigReusesKiloClawIdentifiers(params: {
264+
programId: string;
265+
widgetId: string;
266+
}): boolean {
267+
const kiloClawIdentifiers = getKiloClawAdvocateIdentifiersForComparison();
268+
return (
269+
kiloClawIdentifiers.programIds.has(params.programId) ||
270+
kiloClawIdentifiers.widgetIds.has(params.widgetId)
271+
);
272+
}
273+
235274
function getImpactAdvocateConfig(scope?: ImpactAdvocateConfigScope): ImpactAdvocateConfig | null {
236275
const programKey = resolveImpactAdvocateProgramKey(scope);
237276

@@ -265,6 +304,14 @@ function getImpactAdvocateConfig(scope?: ImpactAdvocateConfigScope): ImpactAdvoc
265304
}
266305
: null;
267306

307+
const requiresExplicitScopedProgramAndWidget = programKey === ImpactAdvocateProgramKey.KiloPass;
308+
if (
309+
requiresExplicitScopedProgramAndWidget &&
310+
(!scopedValues.programId || !scopedValues.widgetId)
311+
) {
312+
return null;
313+
}
314+
268315
const accountSid = scopedValues.accountSid || fallbackValues?.accountSid || '';
269316
const authToken = scopedValues.authToken || fallbackValues?.authToken || '';
270317
const tenantAlias = scopedValues.tenantAlias || fallbackValues?.tenantAlias || '';
@@ -276,6 +323,13 @@ function getImpactAdvocateConfig(scope?: ImpactAdvocateConfigScope): ImpactAdvoc
276323
return null;
277324
}
278325

326+
if (
327+
programKey === ImpactAdvocateProgramKey.KiloPass &&
328+
kiloPassAdvocateConfigReusesKiloClawIdentifiers({ programId, widgetId })
329+
) {
330+
return null;
331+
}
332+
279333
return {
280334
accountSid,
281335
authToken,

apps/web/src/lib/impact/kilo-pass-referrals.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { cleanupDbForTest, db } from '@/lib/drizzle';
3030
import type { isImpactConfigured, sendImpactConversionPayload } from '@/lib/impact';
3131
import type { isImpactAdvocateConfigured } from '@/lib/impact/advocate';
3232
import {
33+
expirePendingKiloPassReferralRewards,
3334
markPersonalKiloPassReferralPaymentAdverse,
3435
processPersonalKiloPassStripePaidConversion,
3536
} from '@/lib/impact/kilo-pass-referrals';
@@ -393,6 +394,50 @@ describe('Kilo Pass Impact referral conversions', () => {
393394
);
394395
});
395396

397+
test('missing or expired attribution suppresses affiliate SALE reporting', async () => {
398+
const noTouchReferee = await insertTestUser({ created_at: '2026-01-02T00:00:00.000Z' });
399+
const noTouchSubscriptionId = await insertKiloPassSubscription({
400+
userId: noTouchReferee.id,
401+
});
402+
403+
const noTouchDisposition = await processInvoice({
404+
refereeId: noTouchReferee.id,
405+
subscriptionId: noTouchSubscriptionId,
406+
});
407+
408+
expect(noTouchDisposition).toEqual(
409+
expect.objectContaining({
410+
shouldEnqueueAffiliateSale: false,
411+
winningTouchType: ImpactReferralWinningTouchType.None,
412+
disqualificationReason: 'referral_no_valid_attribution',
413+
})
414+
);
415+
416+
await cleanupDbForTest();
417+
const expiredTouchReferee = await insertTestUser({ created_at: '2026-01-02T00:00:00.000Z' });
418+
await insertTouch({
419+
userId: expiredTouchReferee.id,
420+
type: 'affiliate',
421+
touchedAt: '2025-12-01T00:00:00.000Z',
422+
});
423+
const expiredTouchSubscriptionId = await insertKiloPassSubscription({
424+
userId: expiredTouchReferee.id,
425+
});
426+
427+
const expiredTouchDisposition = await processInvoice({
428+
refereeId: expiredTouchReferee.id,
429+
subscriptionId: expiredTouchSubscriptionId,
430+
});
431+
432+
expect(expiredTouchDisposition).toEqual(
433+
expect.objectContaining({
434+
shouldEnqueueAffiliateSale: false,
435+
winningTouchType: ImpactReferralWinningTouchType.None,
436+
disqualificationReason: 'referral_no_valid_attribution',
437+
})
438+
);
439+
});
440+
396441
test('only referral attribution grants double-sided pending rewards', async () => {
397442
const referrer = await insertTestUser({ created_at: '2025-12-01T00:00:00.000Z' });
398443
const referee = await insertTestUser({ created_at: '2026-01-02T00:00:00.000Z' });
@@ -593,6 +638,54 @@ describe('Kilo Pass Impact referral conversions', () => {
593638
expect(await db.select().from(impact_referral_rewards)).toHaveLength(2);
594639
});
595640

641+
test('expires stale pending and earned Kilo Pass referral rewards independently of issuance', async () => {
642+
const { rewardIds } = await seedKiloPassReferralRewardsForAdversePayment({
643+
statuses: [ImpactReferralRewardStatus.Pending, ImpactReferralRewardStatus.Earned],
644+
});
645+
await db
646+
.update(impact_referral_rewards)
647+
.set({ expires_at: '2026-01-02T00:00:00.000Z' })
648+
.where(eq(impact_referral_rewards.id, rewardIds[0] ?? ''));
649+
await db
650+
.update(impact_referral_rewards)
651+
.set({ expires_at: '2026-01-02T00:00:00.000Z' })
652+
.where(eq(impact_referral_rewards.id, rewardIds[1] ?? ''));
653+
654+
const firstSummary = await expirePendingKiloPassReferralRewards({
655+
now: new Date('2026-01-03T00:00:00.000Z'),
656+
});
657+
const retrySummary = await expirePendingKiloPassReferralRewards({
658+
now: new Date('2026-01-03T00:00:00.000Z'),
659+
});
660+
661+
expect(firstSummary).toEqual({ expiredRewards: 2 });
662+
expect(retrySummary).toEqual({ expiredRewards: 0 });
663+
const rewards = await db
664+
.select({
665+
status: impact_referral_rewards.status,
666+
reviewReason: impact_referral_rewards.review_reason,
667+
reversedAt: impact_referral_rewards.reversed_at,
668+
})
669+
.from(impact_referral_rewards);
670+
expect(
671+
rewards.map(reward => ({
672+
...reward,
673+
reversedAt: new Date(reward.reversedAt ?? '').toISOString(),
674+
}))
675+
).toEqual([
676+
{
677+
status: ImpactReferralRewardStatus.Expired,
678+
reviewReason: 'expired_kilo_pass_referral_reward',
679+
reversedAt: '2026-01-03T00:00:00.000Z',
680+
},
681+
{
682+
status: ImpactReferralRewardStatus.Expired,
683+
reviewReason: 'expired_kilo_pass_referral_reward',
684+
reversedAt: '2026-01-03T00:00:00.000Z',
685+
},
686+
]);
687+
});
688+
596689
test('adverse Stripe payment cancels pending and earned Kilo Pass referral rewards idempotently', async () => {
597690
const { invoiceId, conversionId } = await seedKiloPassReferralRewardsForAdversePayment({
598691
statuses: [ImpactReferralRewardStatus.Pending, ImpactReferralRewardStatus.Earned],

apps/web/src/lib/impact/kilo-pass-referrals.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import 'server-only';
22

33
import { addMonths } from 'date-fns';
4-
import { and, asc, count, eq, inArray, lte, ne, sql } from 'drizzle-orm';
4+
import { and, asc, count, eq, inArray, isNull, lte, ne, sql } from 'drizzle-orm';
55

66
import { db, type DrizzleTransaction } from '@/lib/drizzle';
77
import {
@@ -63,6 +63,10 @@ export type KiloPassAdverseReferralPaymentSummary = {
6363
reviewRequiredRewards: number;
6464
};
6565

66+
export type KiloPassReferralRewardExpirationSummary = {
67+
expiredRewards: number;
68+
};
69+
6670
const KILO_PASS_REFERRER_REWARD_CAP = 5;
6771
const KILO_PASS_REFERRAL_REWARD_PERCENT = 0.5;
6872
const SIGNUP_REFERRAL_TOUCH_CAPTURE_GRACE_MS = 10 * 60 * 1000;
@@ -298,7 +302,41 @@ function getRewardAmountUsd(sourceTier: KiloPassTier): number {
298302
}
299303

300304
function shouldPreserveAffiliateSale(winningTouchType: string): boolean {
301-
return winningTouchType !== ImpactReferralWinningTouchType.Referral;
305+
return winningTouchType === ImpactReferralWinningTouchType.Affiliate;
306+
}
307+
308+
export async function expirePendingKiloPassReferralRewards(params?: {
309+
now?: Date;
310+
database?: DatabaseClient;
311+
}): Promise<KiloPassReferralRewardExpirationSummary> {
312+
const now = params?.now ?? new Date();
313+
const database = params?.database ?? db;
314+
const nowIso = now.toISOString();
315+
316+
const expiredRewards = await database
317+
.update(impact_referral_rewards)
318+
.set({
319+
status: ImpactReferralRewardStatus.Expired,
320+
reversed_at: nowIso,
321+
review_reason: 'expired_kilo_pass_referral_reward',
322+
})
323+
.where(
324+
and(
325+
eq(impact_referral_rewards.product, ImpactReferralProduct.KiloPass),
326+
eq(impact_referral_rewards.reward_kind, ImpactReferralRewardKind.KiloPassBonus),
327+
inArray(impact_referral_rewards.status, [
328+
ImpactReferralRewardStatus.Pending,
329+
ImpactReferralRewardStatus.Earned,
330+
]),
331+
isNull(impact_referral_rewards.applied_at),
332+
isNull(impact_referral_rewards.consumed_kilo_pass_issuance_id),
333+
sql`${impact_referral_rewards.expires_at} IS NOT NULL`,
334+
lte(impact_referral_rewards.expires_at, nowIso)
335+
)
336+
)
337+
.returning({ id: impact_referral_rewards.id });
338+
339+
return { expiredRewards: expiredRewards.length };
302340
}
303341

304342
export async function markPersonalKiloPassReferralPaymentAdverse(params: {
@@ -503,7 +541,7 @@ export async function processPersonalKiloPassStripePaidConversion(params: {
503541
.returning({ id: impact_referral_conversions.id });
504542

505543
return {
506-
shouldEnqueueAffiliateSale: true,
544+
shouldEnqueueAffiliateSale: false,
507545
winningTouchType: ImpactReferralWinningTouchType.None,
508546
conversionId: conversion?.id ?? null,
509547
disqualificationReason: referralDisqualificationReason('no_valid_attribution'),

apps/web/src/routers/kilo-pass-router.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3106,6 +3106,39 @@ describe('kiloPassRouter', () => {
31063106
})
31073107
);
31083108
});
3109+
3110+
it('does not count expired pending rewards as pending future rewards', async () => {
3111+
const user = await insertTestUser({
3112+
google_user_email: 'kilo-pass-referral-expired-pending@example.com',
3113+
});
3114+
3115+
await insertKiloPassReferralReward({
3116+
beneficiaryUserId: user.id,
3117+
role: ImpactReferralBeneficiaryRole.Referrer,
3118+
status: ImpactReferralRewardStatus.Pending,
3119+
rewardAmountUsd: 24.5,
3120+
sourceTier: KiloPassTier.Tier49,
3121+
earnedAt: '2025-01-01T00:00:00.000Z',
3122+
expiresAt: '2025-12-31T00:00:00.000Z',
3123+
});
3124+
3125+
const caller = await createCallerForUser(user.id);
3126+
const result = await caller.kiloPass.getReferralRewardSummary();
3127+
3128+
expect(result.totals).toEqual(
3129+
expect.objectContaining({
3130+
totalRewards: 1,
3131+
pendingRewards: 0,
3132+
pendingRewardAmountUsd: 0,
3133+
})
3134+
);
3135+
expect(result.rewards[0]).toEqual(
3136+
expect.objectContaining({
3137+
status: ImpactReferralRewardStatus.Pending,
3138+
expiresAt: '2025-12-31T00:00:00.000Z',
3139+
})
3140+
);
3141+
});
31093142
});
31103143

31113144
describe('createCheckoutSession', () => {

apps/web/src/routers/kilo-pass-router.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -915,9 +915,12 @@ export const kiloPassRouter = createTRPCRouter({
915915
reviewReason: row.reviewReason,
916916
}));
917917

918-
const pendingRewards = rewards.filter(reward =>
919-
KILO_PASS_PENDING_REFERRAL_REWARD_STATUSES.has(reward.status)
920-
);
918+
const nowMs = Date.now();
919+
const pendingRewards = rewards.filter(reward => {
920+
if (!KILO_PASS_PENDING_REFERRAL_REWARD_STATUSES.has(reward.status)) return false;
921+
if (!reward.expiresAt) return true;
922+
return new Date(reward.expiresAt).getTime() > nowMs;
923+
});
921924
const appliedRewards = rewards.filter(
922925
reward => reward.status === ImpactReferralRewardStatus.Applied
923926
);

apps/web/vercel.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
"path": "/api/cron/kilo-pass-store-subscription-reconcile",
2929
"schedule": "*/15 * * * *"
3030
},
31+
{
32+
"path": "/api/cron/kilo-pass-expire-referral-rewards",
33+
"schedule": "0 * * * *"
34+
},
3135
{
3236
"path": "/api/cron/deployment-threat-scan",
3337
"schedule": "*/5 * * * *"

0 commit comments

Comments
 (0)