From 2d88970426ef38d619df01f20c861d43672c6aa9 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Thu, 28 May 2026 14:46:41 +0200 Subject: [PATCH 1/4] feat(referrals): route Kilo Pass Impact attribution --- .../app/api/impact-advocate/token/route.ts | 25 +- .../api/marketing-tags/impact/route.test.ts | 33 +++ .../src/app/users/after-sign-in/route.test.ts | 85 +++++++ .../web/src/app/users/after-sign-in/route.tsx | 1 + .../web/src/components/ImpactIdentify.test.ts | 117 ++++++++++ apps/web/src/lib/config.server.ts | 10 +- apps/web/src/lib/getSignInCallbackUrl.test.ts | 20 ++ apps/web/src/lib/getSignInCallbackUrl.ts | 2 + apps/web/src/lib/impact/advocate.test.ts | 160 ++++++++++++- apps/web/src/lib/impact/advocate.ts | 169 ++++++++++---- .../web/src/lib/impact/referral-utils.test.ts | 44 ++++ apps/web/src/lib/impact/referral-utils.ts | 42 ++++ apps/web/src/lib/impact/referral.test.ts | 221 +++++++++++++++++- apps/web/src/lib/impact/referral.ts | 87 ++++++- apps/web/src/lib/referral.ts | 11 +- apps/web/src/lib/referrals.test.ts | 7 +- apps/web/src/lib/user/server.ts | 10 +- 17 files changed, 969 insertions(+), 75 deletions(-) create mode 100644 apps/web/src/app/api/marketing-tags/impact/route.test.ts create mode 100644 apps/web/src/components/ImpactIdentify.test.ts diff --git a/apps/web/src/app/api/impact-advocate/token/route.ts b/apps/web/src/app/api/impact-advocate/token/route.ts index a34c049662..587370840e 100644 --- a/apps/web/src/app/api/impact-advocate/token/route.ts +++ b/apps/web/src/app/api/impact-advocate/token/route.ts @@ -1,10 +1,11 @@ import { headers } from 'next/headers'; -import { NextResponse } from 'next/server'; +import { type NextRequest, NextResponse } from 'next/server'; import { referral_codes } from '@kilocode/db/schema'; import { db } from '@/lib/drizzle'; import { getUserFromAuth } from '@/lib/user/server'; import { + getImpactAdvocateProgramKeyForProduct, getImpactAdvocateWidgetId, issueImpactAdvocateVerifiedAccessToken, } from '@/lib/impact/advocate'; @@ -13,6 +14,7 @@ import { localeFromHeaders, queueImpactAdvocateSelfRegistration, } from '@/lib/impact/referral'; +import { ImpactReferralProduct } from '@kilocode/db/schema-types'; /** * Internal Kilo referral code (kept for legacy/internal attribution flows in @@ -28,7 +30,15 @@ async function ensureInternalReferralCode(userId: string): Promise { .onConflictDoNothing({ target: [referral_codes.kilo_user_id] }); } -export async function GET() { +function parseRequestedProduct(request: NextRequest): ImpactReferralProduct | null { + const product = request.nextUrl.searchParams.get('product')?.trim(); + if (!product) return ImpactReferralProduct.KiloClaw; + if (product === ImpactReferralProduct.KiloClaw) return ImpactReferralProduct.KiloClaw; + if (product === ImpactReferralProduct.KiloPass) return ImpactReferralProduct.KiloPass; + return null; +} + +export async function GET(request: NextRequest) { const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); if (authFailedResponse) { return authFailedResponse; @@ -38,7 +48,13 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const token = issueImpactAdvocateVerifiedAccessToken(user); + const product = parseRequestedProduct(request); + if (!product) { + return NextResponse.json({ error: 'Unsupported Impact Advocate product' }, { status: 400 }); + } + const programKey = getImpactAdvocateProgramKeyForProduct(product); + + const token = issueImpactAdvocateVerifiedAccessToken(user, new Date(), { programKey }); if (!token) { return NextResponse.json({ error: 'Impact Advocate is not configured' }, { status: 503 }); } @@ -53,6 +69,7 @@ export async function GET() { // page loads via dedupe key. const requestHeaders = await headers(); await queueImpactAdvocateSelfRegistration({ + programKey, user, locale: localeFromHeaders(requestHeaders), countryCode: countryCodeFromHeaders(requestHeaders), @@ -70,6 +87,6 @@ export async function GET() { return NextResponse.json({ token, - widgetId: getImpactAdvocateWidgetId(), + widgetId: getImpactAdvocateWidgetId({ programKey }), }); } diff --git a/apps/web/src/app/api/marketing-tags/impact/route.test.ts b/apps/web/src/app/api/marketing-tags/impact/route.test.ts new file mode 100644 index 0000000000..226c8d4f7f --- /dev/null +++ b/apps/web/src/app/api/marketing-tags/impact/route.test.ts @@ -0,0 +1,33 @@ +import { GET } from './route'; + +describe('GET /api/marketing-tags/impact', () => { + const originalImpactUttId = process.env.NEXT_PUBLIC_IMPACT_UTT_ID; + + afterEach(() => { + if (originalImpactUttId === undefined) { + delete process.env.NEXT_PUBLIC_IMPACT_UTT_ID; + } else { + process.env.NEXT_PUBLIC_IMPACT_UTT_ID = originalImpactUttId; + } + }); + + it('returns the Impact UTT bootstrap script when the public UTT id is configured', async () => { + process.env.NEXT_PUBLIC_IMPACT_UTT_ID = 'A-KILO-PASS-UTT'; + + const response = GET(); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('application/javascript; charset=utf-8'); + const script = await response.text(); + expect(script).toContain('utt.impactcdn.com'); + expect(script).toContain('A-KILO-PASS-UTT'); + }); + + it('does not serve an Impact UTT script when the public UTT id is unconfigured', () => { + delete process.env.NEXT_PUBLIC_IMPACT_UTT_ID; + + const response = GET(); + + expect(response.status).toBe(404); + }); +}); diff --git a/apps/web/src/app/users/after-sign-in/route.test.ts b/apps/web/src/app/users/after-sign-in/route.test.ts index 7983c0b543..e4ade47914 100644 --- a/apps/web/src/app/users/after-sign-in/route.test.ts +++ b/apps/web/src/app/users/after-sign-in/route.test.ts @@ -42,6 +42,11 @@ jest.mock('@/lib/credit-campaigns', () => ({ import { getAffiliateAttribution } from '@/lib/affiliate-attribution'; import { recordAffiliateAttributionAndQueueParentEvent } from '@/lib/impact/affiliate-events'; +import { + queueImpactAdvocateParticipantRegistration, + recordImpactAffiliateTouch, + recordImpactReferralTouch, +} from '@/lib/impact/referral'; import { getUserFromAuth } from '@/lib/user/server'; import { GET } from './route'; @@ -50,6 +55,11 @@ const mockRecordAffiliateAttributionAndQueueParentEvent = jest.mocked( recordAffiliateAttributionAndQueueParentEvent ); const mockGetUserFromAuth = jest.mocked(getUserFromAuth); +const mockQueueImpactAdvocateParticipantRegistration = jest.mocked( + queueImpactAdvocateParticipantRegistration +); +const mockRecordImpactAffiliateTouch = jest.mocked(recordImpactAffiliateTouch); +const mockRecordImpactReferralTouch = jest.mocked(recordImpactReferralTouch); describe('GET /users/after-sign-in', () => { beforeEach(() => { @@ -64,6 +74,81 @@ describe('GET /users/after-sign-in', () => { } as Awaited>); }); + it('records and queues Kilo Pass referral touches from Kilo Pass referral-page callback paths', async () => { + const response = await GET( + new NextRequest( + 'http://localhost:3000/users/after-sign-in?callbackPath=%2Fsubscriptions%2Fkilo-pass%2Frefer&_saasquatch=pass-cookie&rsCode=PASSCODE' + ) + ); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/subscriptions/kilo-pass/refer' + ); + expect(mockRecordImpactReferralTouch).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-after-sign-in', + touch: expect.objectContaining({ + product: 'kilo_pass', + programKey: 'kilo_pass', + opaqueTrackingValue: 'pass-cookie', + }), + }) + ); + expect(mockQueueImpactAdvocateParticipantRegistration).toHaveBeenCalledWith( + expect.objectContaining({ + user: expect.objectContaining({ id: 'user-after-sign-in' }), + referralTouch: expect.objectContaining({ + product: 'kilo_pass', + programKey: 'kilo_pass', + opaqueTrackingValue: 'pass-cookie', + }), + }) + ); + }); + + it('records Kilo Pass affiliate touches from Kilo Pass callback paths', async () => { + const response = await GET( + new NextRequest( + 'http://localhost:3000/users/after-sign-in?callbackPath=%2Fsubscriptions%2Fkilo-pass&im_ref=impact-click' + ) + ); + + expect(response.status).toBe(307); + expect(mockRecordImpactAffiliateTouch).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-after-sign-in', + product: 'kilo_pass', + touch: expect.objectContaining({ + product: 'kilo_pass', + trackingId: 'impact-click', + }), + }) + ); + }); + + it('preserves Impact tracking parameters through unauthenticated OAuth redirects', async () => { + mockGetUserFromAuth.mockResolvedValueOnce({ user: null } as Awaited< + ReturnType + >); + + const response = await GET( + new NextRequest( + 'http://localhost:3000/users/after-sign-in?callbackPath=%2Fsubscriptions%2Fkilo-pass%2Frefer&signup=true&_saasquatch=pass-cookie&rsCode=PASSCODE&im_ref=impact-click&utm_campaign=launch' + ) + ); + + expect(response.status).toBe(307); + const location = new URL(response.headers.get('location') ?? ''); + expect(location.pathname).toBe('/users/sign_in'); + expect(location.searchParams.get('callbackPath')).toBe('/subscriptions/kilo-pass/refer'); + expect(location.searchParams.get('signup')).toBe('true'); + expect(location.searchParams.get('_saasquatch')).toBe('pass-cookie'); + expect(location.searchParams.get('rsCode')).toBe('PASSCODE'); + expect(location.searchParams.get('im_ref')).toBe('impact-click'); + expect(location.searchParams.get('utm_campaign')).toBe('launch'); + }); + it('continues redirect flow when affiliate attribution lookup fails', async () => { const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); mockGetAffiliateAttribution.mockRejectedValueOnce(new Error('affiliate lookup unavailable')); diff --git a/apps/web/src/app/users/after-sign-in/route.tsx b/apps/web/src/app/users/after-sign-in/route.tsx index cc86d557d7..d1f6202916 100644 --- a/apps/web/src/app/users/after-sign-in/route.tsx +++ b/apps/web/src/app/users/after-sign-in/route.tsx @@ -199,6 +199,7 @@ export async function GET(request: NextRequest) { isTrackingValueAccepted: affiliateTouch.isTrackingValueAccepted, }); await recordImpactAffiliateTouch({ + product: affiliateTouch.product, userId: user.id, touch: affiliateTouch, }); diff --git a/apps/web/src/components/ImpactIdentify.test.ts b/apps/web/src/components/ImpactIdentify.test.ts new file mode 100644 index 0000000000..8b05acadae --- /dev/null +++ b/apps/web/src/components/ImpactIdentify.test.ts @@ -0,0 +1,117 @@ +import type { User } from '@kilocode/db/schema'; + +const mockUseEffect = jest.fn((effect: () => void | (() => void), _deps?: unknown[]) => effect()); +const mockUseUser = jest.fn(); + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useEffect: (effect: () => void | (() => void), deps?: unknown[]) => mockUseEffect(effect, deps), +})); + +jest.mock('@/hooks/useUser', () => ({ + useUser: () => mockUseUser(), +})); + +jest.mock('@/lib/impact/debug', () => ({ + logImpactReferralDebug: jest.fn(), +})); + +import { ImpactIdentify } from './ImpactIdentify'; + +const TEST_USER = { + id: 'user_123', + google_user_email: ' Logged.In@Example.COM ', +} as User; + +function createLocalStorage() { + const values = new Map(); + return { + getItem: jest.fn((key: string) => values.get(key) ?? null), + setItem: jest.fn((key: string, value: string) => { + values.set(key, value); + }), + }; +} + +async function waitForIreCalls(ire: jest.Mock, expectedCallCount: number) { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (ire.mock.calls.length >= expectedCallCount) return; + await new Promise(resolve => setTimeout(resolve, 10)); + } +} + +describe('ImpactIdentify', () => { + let originalWindow: typeof globalThis.window | undefined; + let originalCrypto: Crypto; + + beforeEach(() => { + jest.clearAllMocks(); + originalWindow = globalThis.window; + originalCrypto = globalThis.crypto; + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { + ...originalCrypto, + randomUUID: jest.fn(() => 'anonymous-profile-id'), + subtle: originalCrypto.subtle, + }, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: originalCrypto, + }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: originalWindow, + }); + }); + + it('identifies anonymous visitors with empty customer fields and a stable first-party profile id', async () => { + const ire = jest.fn(); + const localStorage = createLocalStorage(); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { ire, localStorage }, + }); + mockUseUser.mockReturnValue({ data: null }); + + ImpactIdentify(); + await waitForIreCalls(ire, 1); + ImpactIdentify(); + await waitForIreCalls(ire, 2); + + expect(ire).toHaveBeenNthCalledWith(1, 'identify', { + customerId: '', + customerEmail: '', + customProfileId: 'kilo-anon:anonymous-profile-id', + }); + expect(ire).toHaveBeenNthCalledWith(2, 'identify', { + customerId: '', + customerEmail: '', + customProfileId: 'kilo-anon:anonymous-profile-id', + }); + expect(localStorage.setItem).toHaveBeenCalledTimes(1); + }); + + it('identifies logged-in users with Kilo user id, SHA-1 email hash, and user-derived profile id', async () => { + const ire = jest.fn(); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { ire, localStorage: createLocalStorage() }, + }); + mockUseUser.mockReturnValue({ data: TEST_USER }); + + ImpactIdentify(); + await waitForIreCalls(ire, 1); + + expect(ire).toHaveBeenCalledWith('identify', { + customerId: 'user_123', + customerEmail: '155b33cbec67ea77560d6ad79d7245d9b7c285e3', + customProfileId: 'kilo-user:user_123', + }); + }); +}); diff --git a/apps/web/src/lib/config.server.ts b/apps/web/src/lib/config.server.ts index d69640eb1d..28dced06b6 100644 --- a/apps/web/src/lib/config.server.ts +++ b/apps/web/src/lib/config.server.ts @@ -45,10 +45,16 @@ export const IMPACT_ACCOUNT_SID = getEnvVariable('IMPACT_ACCOUNT_SID') || ''; export const IMPACT_AUTH_TOKEN = getEnvVariable('IMPACT_AUTH_TOKEN') || ''; export const IMPACT_CAMPAIGN_ID = getEnvVariable('IMPACT_CAMPAIGN_ID') || ''; export const IMPACT_ADVOCATE_TENANT_ALIAS = getEnvVariable('IMPACT_ADVOCATE_TENANT_ALIAS') || ''; -export const IMPACT_ADVOCATE_PROGRAM_ID = getEnvVariable('IMPACT_ADVOCATE_PROGRAM_ID') || ''; export const IMPACT_ADVOCATE_ACCOUNT_SID = getEnvVariable('IMPACT_ADVOCATE_ACCOUNT_SID') || ''; export const IMPACT_ADVOCATE_AUTH_TOKEN = getEnvVariable('IMPACT_ADVOCATE_AUTH_TOKEN') || ''; -export const IMPACT_ADVOCATE_WIDGET_ID = getEnvVariable('IMPACT_ADVOCATE_WIDGET_ID') || ''; +export const IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID = + getEnvVariable('IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID') || ''; +export const IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID = + getEnvVariable('IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID') || ''; +export const IMPACT_ADVOCATE_KILO_PASS_PROGRAM_ID = + getEnvVariable('IMPACT_ADVOCATE_KILO_PASS_PROGRAM_ID') || ''; +export const IMPACT_ADVOCATE_KILO_PASS_WIDGET_ID = + getEnvVariable('IMPACT_ADVOCATE_KILO_PASS_WIDGET_ID') || ''; export const IMPACT_ADVOCATE_API_BASE_URL = getEnvVariable('IMPACT_ADVOCATE_API_BASE_URL') || 'https://app.referralsaasquatch.com'; export const IMPACT_ADVOCATE_DEBUG_LOGGING = diff --git a/apps/web/src/lib/getSignInCallbackUrl.test.ts b/apps/web/src/lib/getSignInCallbackUrl.test.ts index 285db50534..39ca045cdf 100644 --- a/apps/web/src/lib/getSignInCallbackUrl.test.ts +++ b/apps/web/src/lib/getSignInCallbackUrl.test.ts @@ -98,6 +98,11 @@ describe('getSignInCallbackUrl', () => { ) ).toBe(true); }); + + test('accepts Kilo Pass referral paths', () => { + expect(isValidCallbackPath('/subscriptions/kilo-pass')).toBe(true); + expect(isValidCallbackPath('/subscriptions/kilo-pass/refer')).toBe(true); + }); }); describe('invalid paths', () => { @@ -276,6 +281,21 @@ describe('getSignInCallbackUrl', () => { '/users/after-sign-in?_saasquatch=opaque-referral-cookie&rsCode=ref-code&utm_source=invite&utm_medium=link&utm_campaign=saasquatch&callbackPath=%2Fclaw%2Fnew' ); }); + + test('preserves Kilo Pass callback paths and referral UTM metadata', () => { + const result = getSignInCallbackUrl({ + callbackPath: '/subscriptions/kilo-pass/refer', + _saasquatch: 'opaque-referral-cookie', + rsCode: 'ref-code', + utm_source: 'invite', + utm_medium: 'link', + utm_campaign: 'saasquatch', + }); + + expect(result).toBe( + '/users/after-sign-in?_saasquatch=opaque-referral-cookie&rsCode=ref-code&utm_source=invite&utm_medium=link&utm_campaign=saasquatch&callbackPath=%2Fsubscriptions%2Fkilo-pass%2Frefer' + ); + }); }); describe('stripHost', () => { diff --git a/apps/web/src/lib/getSignInCallbackUrl.ts b/apps/web/src/lib/getSignInCallbackUrl.ts index 6b4f2a317a..0bcc16f37e 100644 --- a/apps/web/src/lib/getSignInCallbackUrl.ts +++ b/apps/web/src/lib/getSignInCallbackUrl.ts @@ -23,6 +23,8 @@ export function isValidCallbackPath(path: string): boolean { path === '/claw' || path.startsWith('/claw/') || path.startsWith('/cloud') || + path === '/subscriptions/kilo-pass' || + path.startsWith('/subscriptions/kilo-pass/') || path.startsWith('/integrations/') || // Admin-managed URL bonus campaigns. Stricter shape enforcement // (slug format, prefix-match guard) happens in diff --git a/apps/web/src/lib/impact/advocate.test.ts b/apps/web/src/lib/impact/advocate.test.ts index 912a86b2f9..b690f7da96 100644 --- a/apps/web/src/lib/impact/advocate.test.ts +++ b/apps/web/src/lib/impact/advocate.test.ts @@ -9,6 +9,10 @@ describe('impact advocate', () => { IMPACT_ADVOCATE_PROGRAM_ID: process.env.IMPACT_ADVOCATE_PROGRAM_ID, IMPACT_ADVOCATE_TENANT_ALIAS: process.env.IMPACT_ADVOCATE_TENANT_ALIAS, IMPACT_ADVOCATE_WIDGET_ID: process.env.IMPACT_ADVOCATE_WIDGET_ID, + IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID: process.env.IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID, + IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID: process.env.IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID, + IMPACT_ADVOCATE_KILO_PASS_PROGRAM_ID: process.env.IMPACT_ADVOCATE_KILO_PASS_PROGRAM_ID, + IMPACT_ADVOCATE_KILO_PASS_WIDGET_ID: process.env.IMPACT_ADVOCATE_KILO_PASS_WIDGET_ID, IMPACT_ACCOUNT_SID: process.env.IMPACT_ACCOUNT_SID, }; @@ -20,12 +24,19 @@ describe('impact advocate', () => { process.env.IMPACT_ADVOCATE_PROGRAM_ID = originalEnv.IMPACT_ADVOCATE_PROGRAM_ID; process.env.IMPACT_ADVOCATE_TENANT_ALIAS = originalEnv.IMPACT_ADVOCATE_TENANT_ALIAS; process.env.IMPACT_ADVOCATE_WIDGET_ID = originalEnv.IMPACT_ADVOCATE_WIDGET_ID; + process.env.IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID = + originalEnv.IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID; + process.env.IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID = originalEnv.IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID; + process.env.IMPACT_ADVOCATE_KILO_PASS_PROGRAM_ID = + originalEnv.IMPACT_ADVOCATE_KILO_PASS_PROGRAM_ID; + process.env.IMPACT_ADVOCATE_KILO_PASS_WIDGET_ID = + originalEnv.IMPACT_ADVOCATE_KILO_PASS_WIDGET_ID; process.env.IMPACT_ACCOUNT_SID = originalEnv.IMPACT_ACCOUNT_SID; jest.resetModules(); }); it('builds register participant payloads with exact cookie attribution', async () => { - process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID = '51699'; process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'kilo'; process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'account-sid'; @@ -51,22 +62,153 @@ describe('impact advocate', () => { }); it('normalizes bare widget IDs to the full Impact embed widget path', async () => { - process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID = '51699'; process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-account-sid'; - process.env.IMPACT_ADVOCATE_WIDGET_ID = '51699'; + process.env.IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID = '51699'; const { getImpactAdvocateWidgetId } = await import('@/lib/impact/advocate'); expect(getImpactAdvocateWidgetId()).toBe('p/51699/w/referrerWidget'); }); - it('logs debug data without tokens, credentials, authorization headers, cookie values, or email identities', async () => { + it('uses KiloClaw-scoped Advocate config for widget and token issuance', async () => { + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'kiloclaw-account'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'kiloclaw-secret'; + process.env.IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID = '51699-scoped'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'kiloclaw-tenant'; + process.env.IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID = '51699-scoped'; + + const { + getImpactAdvocateProgramId, + getImpactAdvocateWidgetId, + issueImpactAdvocateVerifiedAccessToken, + } = await import('@/lib/impact/advocate'); + + expect(getImpactAdvocateProgramId()).toBe('51699-scoped'); + expect(getImpactAdvocateWidgetId()).toBe('p/51699-scoped/w/referrerWidget'); + + const token = issueImpactAdvocateVerifiedAccessToken( + { id: 'user_123', google_user_email: 'referrer@example.com' }, + new Date('2026-04-23T12:00:00.000Z') + ); + const decoded = jwt.decode(token ?? '', { complete: true }); + expect(decoded && typeof decoded === 'object' ? decoded.header.kid : null).toBe( + 'kiloclaw-account' + ); + }); + + it('does not configure KiloClaw from legacy unscoped program/widget config', async () => { + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'legacy-account'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'legacy-secret'; process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'legacy-tenant'; + process.env.IMPACT_ADVOCATE_WIDGET_ID = '51699'; + + const { + getImpactAdvocateProgramId, + getImpactAdvocateWidgetId, + isImpactAdvocateConfigured, + issueImpactAdvocateVerifiedAccessToken, + } = await import('@/lib/impact/advocate'); + + expect(isImpactAdvocateConfigured()).toBe(false); + expect(getImpactAdvocateProgramId()).toBeNull(); + expect(getImpactAdvocateWidgetId()).toBeNull(); + expect( + issueImpactAdvocateVerifiedAccessToken( + { id: 'user_123', google_user_email: 'referrer@example.com' }, + new Date('2026-04-23T12:00:00.000Z') + ) + ).toBeNull(); + }); + + it('does not use KiloClaw config for Kilo Pass', async () => { + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'fallback-account'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'fallback-secret'; + process.env.IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'fallback-tenant'; + process.env.IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID = 'p/51699/w/referrerWidget'; + + const { + getImpactAdvocateWidgetId, + isImpactAdvocateConfigured, + issueImpactAdvocateVerifiedAccessToken, + } = await import('@/lib/impact/advocate'); + + const scope = { product: 'kilo_pass' as const }; + expect(isImpactAdvocateConfigured(scope)).toBe(false); + expect(getImpactAdvocateWidgetId(scope)).toBeNull(); + expect( + issueImpactAdvocateVerifiedAccessToken( + { id: 'user_123', google_user_email: 'referrer@example.com' }, + new Date('2026-04-23T12:00:00.000Z'), + scope + ) + ).toBeNull(); + }); + + it('uses Kilo Pass-scoped program and widget config for token issuance', async () => { + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'shared-account'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'shared-secret'; + process.env.IMPACT_ADVOCATE_KILO_PASS_PROGRAM_ID = '52766'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'shared-tenant'; + process.env.IMPACT_ADVOCATE_KILO_PASS_WIDGET_ID = 'p/52766/w/referrerWidget'; + + const { + getImpactAdvocateProgramId, + getImpactAdvocateWidgetId, + issueImpactAdvocateVerifiedAccessToken, + } = await import('@/lib/impact/advocate'); + + const scope = { product: 'kilo_pass' as const }; + expect(getImpactAdvocateProgramId(scope)).toBe('52766'); + expect(getImpactAdvocateWidgetId(scope)).toBe('p/52766/w/referrerWidget'); + + const token = issueImpactAdvocateVerifiedAccessToken( + { id: 'user_123', google_user_email: 'referrer@example.com' }, + new Date('2026-04-23T12:00:00.000Z'), + scope + ); + const decoded = jwt.decode(token ?? '', { complete: true }); + expect(decoded && typeof decoded === 'object' ? decoded.header.kid : null).toBe( + 'shared-account' + ); + }); + + it('rejects Kilo Pass Advocate config that reuses KiloClaw program or widget IDs', async () => { + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'shared-account'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'shared-secret'; + process.env.IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'shared-tenant'; + process.env.IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID = 'p/51699/w/referrerWidget'; + + const scope = { product: 'kilo_pass' as const }; + + process.env.IMPACT_ADVOCATE_KILO_PASS_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_KILO_PASS_WIDGET_ID = 'p/52766/w/referrerWidget'; + jest.resetModules(); + let advocate = await import('@/lib/impact/advocate'); + expect(advocate.isImpactAdvocateConfigured(scope)).toBe(false); + expect(advocate.getImpactAdvocateProgramId(scope)).toBeNull(); + expect(advocate.getImpactAdvocateWidgetId(scope)).toBeNull(); + + process.env.IMPACT_ADVOCATE_KILO_PASS_PROGRAM_ID = '52766'; + process.env.IMPACT_ADVOCATE_KILO_PASS_WIDGET_ID = 'p/51699/w/referrerWidget'; + jest.resetModules(); + advocate = await import('@/lib/impact/advocate'); + expect(advocate.isImpactAdvocateConfigured(scope)).toBe(false); + expect(advocate.getImpactAdvocateProgramId(scope)).toBeNull(); + expect(advocate.getImpactAdvocateWidgetId(scope)).toBeNull(); + }); + + it('logs debug data without tokens, credentials, authorization headers, cookie values, or email identities', async () => { + process.env.IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID = '51699'; process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-account-sid'; + process.env.IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID = 'p/51699/w/referrerWidget'; process.env.IMPACT_ADVOCATE_DEBUG_LOGGING = 'true'; const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); @@ -98,11 +240,11 @@ describe('impact advocate', () => { }); it('issues verified access JWTs with the account sid in the kid header', async () => { - process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID = '51699'; process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-account-sid'; - process.env.IMPACT_ADVOCATE_WIDGET_ID = 'p/51699/w/referrerWidget'; + process.env.IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID = 'p/51699/w/referrerWidget'; const { getImpactAdvocateWidgetId, issueImpactAdvocateVerifiedAccessToken } = await import('@/lib/impact/advocate'); @@ -133,10 +275,11 @@ describe('impact advocate', () => { }); it('looks up account rewards with account and user filters', async () => { - process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID = '51699'; process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-account-sid'; + process.env.IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID = 'p/51699/w/referrerWidget'; process.env.IMPACT_ADVOCATE_DEBUG_LOGGING = 'true'; const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); const fetchMock = jest.fn().mockResolvedValue( @@ -177,10 +320,11 @@ describe('impact advocate', () => { }); it('redeems a credit reward with amount and unit', async () => { - process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID = '51699'; process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-account-sid'; + process.env.IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID = 'p/51699/w/referrerWidget'; const fetchMock = jest .fn() .mockResolvedValue(new Response('{"ok":true}', { status: 200 })); diff --git a/apps/web/src/lib/impact/advocate.ts b/apps/web/src/lib/impact/advocate.ts index a033bc53a3..055a9e57b1 100644 --- a/apps/web/src/lib/impact/advocate.ts +++ b/apps/web/src/lib/impact/advocate.ts @@ -4,15 +4,17 @@ import jwt from 'jsonwebtoken'; import type { SignOptions } from 'jsonwebtoken'; import type { User } from '@kilocode/db/schema'; import { - IMPACT_ACCOUNT_SID, IMPACT_ADVOCATE_ACCOUNT_SID, IMPACT_ADVOCATE_API_BASE_URL, IMPACT_ADVOCATE_AUTH_TOKEN, - IMPACT_ADVOCATE_PROGRAM_ID, + IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID, + IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID, + IMPACT_ADVOCATE_KILO_PASS_PROGRAM_ID, + IMPACT_ADVOCATE_KILO_PASS_WIDGET_ID, IMPACT_ADVOCATE_TENANT_ALIAS, - IMPACT_ADVOCATE_WIDGET_ID, } from '@/lib/config.server'; import { logImpactReferralDebug, truncateForLog } from '@/lib/impact/debug'; +import { ImpactAdvocateProgramKey, ImpactReferralProduct } from '@kilocode/db/schema-types'; /** * SaaSquatch / Impact Advocate expects locale tags formatted as `en_US`, @@ -25,11 +27,39 @@ function normalizeAdvocateLocale(locale: string | null | undefined): string | nu return trimmed.replace(/-/g, '_'); } -export const IMPACT_ADVOCATE_DEFAULT_PROGRAM_ID = '51699'; -export const IMPACT_ADVOCATE_DEFAULT_WIDGET_ID = 'p/51699/w/referrerWidget'; const IMPACT_ADVOCATE_WIDGET_NAME = 'referrerWidget'; const IMPACT_ADVOCATE_VERIFIED_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; +type ImpactAdvocateConfigScope = { + product?: ImpactReferralProduct; + programKey?: ImpactAdvocateProgramKey; +}; + +type ImpactAdvocateConfig = { + accountSid: string; + authToken: string; + tenantAlias: string; + programId: string; + widgetId: string; + programKey: ImpactAdvocateProgramKey; +}; + +export function getImpactAdvocateProgramKeyForProduct( + product: ImpactReferralProduct +): ImpactAdvocateProgramKey { + return product === ImpactReferralProduct.KiloPass + ? ImpactAdvocateProgramKey.KiloPass + : ImpactAdvocateProgramKey.KiloClaw; +} + +function resolveImpactAdvocateProgramKey( + scope?: ImpactAdvocateConfigScope +): ImpactAdvocateProgramKey { + if (scope?.programKey) return scope.programKey; + if (scope?.product) return getImpactAdvocateProgramKeyForProduct(scope.product); + return ImpactAdvocateProgramKey.KiloClaw; +} + export type ImpactAdvocateIdentityPayload = { id: string; accountId: string; @@ -179,6 +209,11 @@ function getDebuggableVerifiedAccessTokenPayload( }; } +function configuredValue(value: string | null | undefined): string { + const trimmed = value?.trim(); + return trimmed && trimmed !== 'undefined' ? trimmed : ''; +} + function getImpactAdvocateWidgetPath(widgetId: string, programId: string): string { const trimmedWidgetId = widgetId.trim(); if (!trimmedWidgetId) return `p/${programId}/w/${IMPACT_ADVOCATE_WIDGET_NAME}`; @@ -186,14 +221,70 @@ function getImpactAdvocateWidgetPath(widgetId: string, programId: string): strin return `p/${trimmedWidgetId}/w/${IMPACT_ADVOCATE_WIDGET_NAME}`; } -function getImpactAdvocateConfig() { - const accountSid = IMPACT_ADVOCATE_ACCOUNT_SID || IMPACT_ACCOUNT_SID; - const authToken = IMPACT_ADVOCATE_AUTH_TOKEN; - const tenantAlias = IMPACT_ADVOCATE_TENANT_ALIAS; - const programId = IMPACT_ADVOCATE_PROGRAM_ID || IMPACT_ADVOCATE_DEFAULT_PROGRAM_ID; - const widgetId = getImpactAdvocateWidgetPath(IMPACT_ADVOCATE_WIDGET_ID, programId); +function getKiloClawAdvocateIdentifiersForComparison(): { + programIds: Set; + widgetIds: Set; +} { + const programId = configuredValue(IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID); + const rawWidgetId = configuredValue(IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID); + const programIds = new Set(); + const widgetIds = new Set(); + + if (programId) { + programIds.add(programId); + } + if (programId && rawWidgetId) { + widgetIds.add(getImpactAdvocateWidgetPath(rawWidgetId, programId)); + } + + return { programIds, widgetIds }; +} + +function kiloPassAdvocateConfigReusesKiloClawIdentifiers(params: { + programId: string; + widgetId: string; +}): boolean { + const kiloClawIdentifiers = getKiloClawAdvocateIdentifiersForComparison(); + return ( + kiloClawIdentifiers.programIds.has(params.programId) || + kiloClawIdentifiers.widgetIds.has(params.widgetId) + ); +} + +function getImpactAdvocateConfig(scope?: ImpactAdvocateConfigScope): ImpactAdvocateConfig | null { + const programKey = resolveImpactAdvocateProgramKey(scope); + + const scopedValues = + programKey === ImpactAdvocateProgramKey.KiloPass + ? { + programId: configuredValue(IMPACT_ADVOCATE_KILO_PASS_PROGRAM_ID), + widgetId: configuredValue(IMPACT_ADVOCATE_KILO_PASS_WIDGET_ID), + } + : { + programId: configuredValue(IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID), + widgetId: configuredValue(IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID), + }; + + const accountSid = configuredValue(IMPACT_ADVOCATE_ACCOUNT_SID); + const authToken = configuredValue(IMPACT_ADVOCATE_AUTH_TOKEN); + const tenantAlias = configuredValue(IMPACT_ADVOCATE_TENANT_ALIAS); + const programId = scopedValues.programId; + const rawWidgetId = scopedValues.widgetId; + + if (!accountSid || !authToken || !tenantAlias || !programId || !rawWidgetId) { + return null; + } - if (!accountSid || !authToken || !tenantAlias) { + const widgetId = getImpactAdvocateWidgetPath(rawWidgetId, programId); + + if (!widgetId) { + return null; + } + + if ( + programKey === ImpactAdvocateProgramKey.KiloPass && + kiloPassAdvocateConfigReusesKiloClawIdentifiers({ programId, widgetId }) + ) { return null; } @@ -203,19 +294,22 @@ function getImpactAdvocateConfig() { tenantAlias, programId, widgetId, + programKey, }; } -export function isImpactAdvocateConfigured(): boolean { - return getImpactAdvocateConfig() !== null; +export function isImpactAdvocateConfigured(scope?: ImpactAdvocateConfigScope): boolean { + return getImpactAdvocateConfig(scope) !== null; } -export function getImpactAdvocateWidgetId(): string { - return getImpactAdvocateConfig()?.widgetId ?? IMPACT_ADVOCATE_DEFAULT_WIDGET_ID; +export function getImpactAdvocateWidgetId(scope?: ImpactAdvocateConfigScope): string | null { + const programKey = resolveImpactAdvocateProgramKey(scope); + return getImpactAdvocateConfig({ programKey })?.widgetId ?? null; } -export function getImpactAdvocateProgramId(): string { - return getImpactAdvocateConfig()?.programId ?? IMPACT_ADVOCATE_DEFAULT_PROGRAM_ID; +export function getImpactAdvocateProgramId(scope?: ImpactAdvocateConfigScope): string | null { + const programKey = resolveImpactAdvocateProgramKey(scope); + return getImpactAdvocateConfig({ programKey })?.programId ?? null; } /** @@ -282,9 +376,7 @@ export function buildImpactAdvocateRegisterParticipantPayload(params: { return payload; } -function getImpactAdvocateAuthorizationHeader( - config: NonNullable> -): string { +function getImpactAdvocateAuthorizationHeader(config: ImpactAdvocateConfig): string { return `Basic ${Buffer.from(`${config.accountSid}:${config.authToken}`).toString('base64')}`; } @@ -346,7 +438,7 @@ export function extractImpactAdvocateRewards(responseBody: string | null | undef * integration spec; we URL-encode them because the path segment contains '@'. */ function getImpactAdvocateRegisterParticipantUrl( - config: NonNullable>, + config: ImpactAdvocateConfig, payload: ImpactAdvocateRegisterParticipantPayload ): string { const base = trimTrailingSlashes(IMPACT_ADVOCATE_API_BASE_URL); @@ -356,16 +448,14 @@ function getImpactAdvocateRegisterParticipantUrl( return `${base}/api/v1/${tenant}/open/account/${accountId}/user/${userId}`; } -function getDebuggableImpactAdvocateRegisterParticipantUrl( - config: NonNullable> -): string { +function getDebuggableImpactAdvocateRegisterParticipantUrl(config: ImpactAdvocateConfig): string { const base = trimTrailingSlashes(IMPACT_ADVOCATE_API_BASE_URL); const tenant = encodeURIComponent(config.tenantAlias); return `${base}/api/v1/${tenant}/open/account/[redacted-account-id]/user/[redacted-user-id]`; } function getImpactAdvocateRewardsUrl( - config: NonNullable>, + config: ImpactAdvocateConfig, payload: ImpactAdvocateRewardLookupPayload ): string { const base = trimTrailingSlashes(IMPACT_ADVOCATE_API_BASE_URL); @@ -378,7 +468,7 @@ function getImpactAdvocateRewardsUrl( } function getDebuggableImpactAdvocateRewardsUrl( - config: NonNullable>, + config: ImpactAdvocateConfig, payload: ImpactAdvocateRewardLookupPayload ): string { const base = trimTrailingSlashes(IMPACT_ADVOCATE_API_BASE_URL); @@ -390,19 +480,17 @@ function getDebuggableImpactAdvocateRewardsUrl( return url.toString(); } -function getImpactAdvocateRedeemRewardUrl( - config: NonNullable>, - rewardId: string -): string { +function getImpactAdvocateRedeemRewardUrl(config: ImpactAdvocateConfig, rewardId: string): string { const base = trimTrailingSlashes(IMPACT_ADVOCATE_API_BASE_URL); const tenant = encodeURIComponent(config.tenantAlias); return `${base}/api/v1/${tenant}/credit/${encodeURIComponent(rewardId)}/redeem`; } export async function sendImpactAdvocateRegisterParticipantPayload( - payload: ImpactAdvocateRegisterParticipantPayload + payload: ImpactAdvocateRegisterParticipantPayload, + scope?: ImpactAdvocateConfigScope ): Promise { - const config = getImpactAdvocateConfig(); + const config = getImpactAdvocateConfig(scope); if (!config) { return { ok: false, @@ -474,9 +562,10 @@ export async function sendImpactAdvocateRegisterParticipantPayload( } export async function sendImpactAdvocateRewardLookupPayload( - payload: ImpactAdvocateRewardLookupPayload + payload: ImpactAdvocateRewardLookupPayload, + scope?: ImpactAdvocateConfigScope ): Promise { - const config = getImpactAdvocateConfig(); + const config = getImpactAdvocateConfig(scope); if (!config) { return { ok: false, @@ -539,9 +628,10 @@ export async function sendImpactAdvocateRewardLookupPayload( } export async function sendImpactAdvocateRewardRedemptionPayload( - payload: ImpactAdvocateRewardRedemptionPayload + payload: ImpactAdvocateRewardRedemptionPayload, + scope?: ImpactAdvocateConfigScope ): Promise { - const config = getImpactAdvocateConfig(); + const config = getImpactAdvocateConfig(scope); if (!config) { return { ok: false, @@ -610,9 +700,10 @@ export async function sendImpactAdvocateRewardRedemptionPayload( export function issueImpactAdvocateVerifiedAccessToken( user: Pick, - now: Date = new Date() + now: Date = new Date(), + scope?: ImpactAdvocateConfigScope ): string | null { - const config = getImpactAdvocateConfig(); + const config = getImpactAdvocateConfig(scope); if (!config) return null; const header: ImpactAdvocateJwtHeaderInput = { diff --git a/apps/web/src/lib/impact/referral-utils.test.ts b/apps/web/src/lib/impact/referral-utils.test.ts index 54898c23e6..9cebefaa28 100644 --- a/apps/web/src/lib/impact/referral-utils.test.ts +++ b/apps/web/src/lib/impact/referral-utils.test.ts @@ -49,6 +49,8 @@ describe('impact referral utils', () => { ); expect(touch).toEqual({ + product: 'kiloclaw', + programKey: 'kiloclaw', opaqueTrackingValue: 'sq-cookie', trackingValueLength: 9, isTrackingValueAccepted: true, @@ -67,6 +69,16 @@ describe('impact referral utils', () => { }); }); + it('marks Kilo Pass callback paths as Kilo Pass referral touches', () => { + const touch = parseImpactReferralTouchFromUrl( + 'https://kilo.ai/users/after-sign-in?callbackPath=%2Fsubscriptions%2Fkilo-pass&_saasquatch=pass-cookie' + ); + + expect(touch?.product).toBe('kilo_pass'); + expect(touch?.programKey).toBe('kilo_pass'); + expect(touch?.opaqueTrackingValue).toBe('pass-cookie'); + }); + it('keeps referral metadata for diagnostics when _saasquatch is missing', () => { const touch = parseImpactReferralTouchFromUrl( 'https://kilo.ai/get-started?rsCode=abc&rsShareMedium=email' @@ -78,11 +90,43 @@ describe('impact referral utils', () => { expect(touch?.rsCode).toBe('abc'); }); + it('ignores over-limit referral values for attribution and metadata', () => { + const tooLongValue = 'x'.repeat(IMPACT_OPAQUE_TRACKING_VALUE_MAX_LENGTH + 1); + const touch = parseImpactReferralTouchFromUrl( + `https://kilo.ai/get-started?_saasquatch=${tooLongValue}&rsCode=${tooLongValue}&rsShareMedium=email` + ); + + expect(touch?.opaqueTrackingValue).toBeNull(); + expect(touch?.trackingValueLength).toBe(tooLongValue.length); + expect(touch?.isTrackingValueAccepted).toBe(false); + expect(touch?.rsCode).toBeNull(); + expect(touch?.rsShareMedium).toBe('email'); + }); + it('parses affiliate touches from im_ref and override cookies', () => { const fromQuery = parseImpactAffiliateTouchFromUrl('https://kilo.ai/?im_ref=impact-click'); expect(fromQuery?.trackingId).toBe('impact-click'); + expect(fromQuery?.product).toBe('kiloclaw'); const fromCookie = parseImpactAffiliateTouchFromUrl('https://kilo.ai/', 'impact-cookie-click'); expect(fromCookie?.trackingId).toBe('impact-cookie-click'); }); + + it('marks Kilo Pass callback paths as Kilo Pass affiliate touches', () => { + const touch = parseImpactAffiliateTouchFromUrl( + 'https://kilo.ai/users/after-sign-in?callbackPath=%2Fsubscriptions%2Fkilo-pass&im_ref=impact-click' + ); + + expect(touch?.product).toBe('kilo_pass'); + expect(touch?.trackingId).toBe('impact-click'); + }); + + it('ignores over-limit affiliate values for attribution', () => { + const tooLongValue = 'x'.repeat(IMPACT_OPAQUE_TRACKING_VALUE_MAX_LENGTH + 1); + const touch = parseImpactAffiliateTouchFromUrl(`https://kilo.ai/?im_ref=${tooLongValue}`); + + expect(touch?.trackingId).toBeNull(); + expect(touch?.trackingValueLength).toBe(tooLongValue.length); + expect(touch?.isTrackingValueAccepted).toBe(false); + }); }); diff --git a/apps/web/src/lib/impact/referral-utils.ts b/apps/web/src/lib/impact/referral-utils.ts index e557d90b88..c44c770b74 100644 --- a/apps/web/src/lib/impact/referral-utils.ts +++ b/apps/web/src/lib/impact/referral-utils.ts @@ -1,3 +1,5 @@ +import { ImpactAdvocateProgramKey, ImpactReferralProduct } from '@kilocode/db/schema-types'; + export const IMPACT_OPAQUE_TRACKING_VALUE_MAX_LENGTH = 512; export const IMPACT_REFERRAL_TOUCH_VALIDITY_MS = 30 * 24 * 60 * 60 * 1000; export const IMPACT_CUSTOM_PROFILE_ID_STORAGE_KEY = 'impact_custom_profile_id'; @@ -9,6 +11,8 @@ export type SanitizedOpaqueTrackingValue = { }; export type ParsedImpactReferralTouch = { + product?: ImpactReferralProduct; + programKey?: ImpactAdvocateProgramKey; opaqueTrackingValue: string | null; trackingValueLength: number; isTrackingValueAccepted: boolean; @@ -26,6 +30,7 @@ export type ParsedImpactReferralTouch = { }; export type ParsedImpactAffiliateTouch = { + product?: ImpactReferralProduct; trackingId: string | null; trackingValueLength: number; isTrackingValueAccepted: boolean; @@ -57,6 +62,38 @@ function landingPathFromUrl(url: URL): string | null { return path ? path : null; } +function pathTargetsKiloPass(path: string | null | undefined): boolean { + const pathname = path?.split(/[?#]/, 1)[0]; + return ( + pathname === '/subscriptions/kilo-pass' || + Boolean(pathname?.startsWith('/subscriptions/kilo-pass/')) + ); +} + +function getCallbackPath(url: URL): string | null { + const callbackPath = url.searchParams.get('callbackPath')?.trim(); + if (!callbackPath?.startsWith('/')) return null; + return callbackPath; +} + +function resolveImpactTouchScope(url: URL): { + product: ImpactReferralProduct; + programKey: ImpactAdvocateProgramKey; +} { + const callbackPath = getCallbackPath(url); + if (pathTargetsKiloPass(url.pathname) || pathTargetsKiloPass(callbackPath)) { + return { + product: ImpactReferralProduct.KiloPass, + programKey: ImpactAdvocateProgramKey.KiloPass, + }; + } + + return { + product: ImpactReferralProduct.KiloClaw, + programKey: ImpactAdvocateProgramKey.KiloClaw, + }; +} + export function sanitizeOpaqueTrackingValue( value: string | null | undefined ): SanitizedOpaqueTrackingValue { @@ -131,8 +168,10 @@ export function parseImpactReferralTouchFromUrl( } const trackingValue = sanitizeOpaqueTrackingValue(searchParams.get('_saasquatch')); + const scope = resolveImpactTouchScope(url); return { + ...scope, opaqueTrackingValue: trackingValue.acceptedValue, trackingValueLength: trackingValue.originalLength, isTrackingValueAccepted: trackingValue.isAccepted, @@ -166,7 +205,10 @@ export function parseImpactAffiliateTouchFromUrl( return null; } + const scope = resolveImpactTouchScope(url); + return { + product: scope.product, trackingId: trackingValue.acceptedValue, trackingValueLength: trackingValue.originalLength, isTrackingValueAccepted: trackingValue.isAccepted, diff --git a/apps/web/src/lib/impact/referral.test.ts b/apps/web/src/lib/impact/referral.test.ts index cbc51c841c..f0c90751ae 100644 --- a/apps/web/src/lib/impact/referral.test.ts +++ b/apps/web/src/lib/impact/referral.test.ts @@ -9,6 +9,7 @@ import { insertTestUser } from '@/tests/helpers/user.helper'; import { impact_advocate_participants, impact_advocate_registration_attempts, + impact_attribution_touches, kilocode_users, } from '@kilocode/db/schema'; @@ -19,14 +20,18 @@ describe('impact referral participant registration dispatch', () => { process.env.IMPACT_ACCOUNT_SID = 'impact-account-sid'; process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-advocate-account-sid'; process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'impact-advocate-auth-token'; - process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; + process.env.IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID = 'p/51699/w/referrerWidget'; + delete process.env.IMPACT_ADVOCATE_KILO_PASS_PROGRAM_ID; + delete process.env.IMPACT_ADVOCATE_KILO_PASS_WIDGET_ID; }); afterEach(async () => { jest.restoreAllMocks(); await db.delete(impact_advocate_registration_attempts).where(sql`true`); await db.delete(impact_advocate_participants).where(sql`true`); + await db.delete(impact_attribution_touches).where(sql`true`); await db.delete(kilocode_users).where(sql`true`); }); @@ -131,6 +136,220 @@ describe('impact referral participant registration dispatch', () => { }); }); + it('delivers Kilo Pass participant registrations through Kilo Pass-scoped config', async () => { + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'kilo-pass-account-sid'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'kilo-pass-auth-token'; + process.env.IMPACT_ADVOCATE_KILO_PASS_PROGRAM_ID = '52766'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'kilo-pass-tenant'; + process.env.IMPACT_ADVOCATE_KILO_PASS_WIDGET_ID = 'p/52766/w/referrerWidget'; + + const fetchMock = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: 'sq-kilo-pass-id', + email: 'pass-participant@example.com', + referralCodes: { '52766': 'PASS9001' }, + referable: true, + }), + { status: 200 } + ) + ); + global.fetch = fetchMock; + + const user = await insertTestUser({ + google_user_email: 'pass-participant@example.com', + normalized_email: 'pass-participant@example.com', + }); + + const { + dispatchQueuedImpactAdvocateRegistrationAttempts, + queueImpactAdvocateParticipantRegistration, + } = await import('@/lib/impact/referral'); + + await queueImpactAdvocateParticipantRegistration({ + programKey: 'kilo_pass', + user, + referralTouch: { + opaqueTrackingValue: 'pass-sq-cookie', + trackingValueLength: 14, + isTrackingValueAccepted: true, + rsCode: 'pass-ref-code', + rsShareMedium: 'email', + rsEngagementMedium: 'link', + landingPath: '/subscriptions/kilo-pass?_saasquatch=pass-sq-cookie', + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmTerm: null, + utmContent: null, + touchedAt: new Date('2026-05-23T00:00:00.000Z'), + expiresAt: new Date('2026-06-22T00:00:00.000Z'), + }, + }); + + const summary = await dispatchQueuedImpactAdvocateRegistrationAttempts(); + expect(summary).toEqual({ claimed: 1, delivered: 1, retried: 0, failed: 0 }); + + const [participant] = await db.select().from(impact_advocate_participants); + expect(participant.program_key).toBe('kilo_pass'); + expect(participant.registration_state).toBe('registered'); + expect(participant.opaque_referral_identifier).toBe('PASS9001'); + + const [attempt] = await db.select().from(impact_advocate_registration_attempts); + expect(attempt.program_key).toBe('kilo_pass'); + expect(attempt.delivery_state).toBe('succeeded'); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'https://app.referralsaasquatch.com/api/v1/kilo-pass-tenant/open/account/pass-participant%40example.com/user/pass-participant%40example.com' + ); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + headers: expect.objectContaining({ + Authorization: + 'Basic ' + Buffer.from('kilo-pass-account-sid:kilo-pass-auth-token').toString('base64'), + }), + }); + }); + + it('allows the same SaaSquatch referral code in different Advocate programs', async () => { + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'kilo-pass-account-sid'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'kilo-pass-auth-token'; + process.env.IMPACT_ADVOCATE_KILO_PASS_PROGRAM_ID = '52766'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'kilo-pass-tenant'; + process.env.IMPACT_ADVOCATE_KILO_PASS_WIDGET_ID = 'p/52766/w/referrerWidget'; + + const clawUser = await insertTestUser({ + google_user_email: 'claw-holder@example.com', + normalized_email: 'claw-holder@example.com', + }); + await db.insert(impact_advocate_participants).values({ + program_key: 'kiloclaw', + user_id: clawUser.id, + advocate_id: clawUser.google_user_email, + advocate_account_id: clawUser.google_user_email, + opaque_referral_identifier: 'SHARED_CODE', + registration_state: 'registered', + }); + + const fetchMock = jest + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ referralCodes: { '52766': 'SHARED_CODE' } }), { status: 200 }) + ); + global.fetch = fetchMock; + + const passUser = await insertTestUser({ + google_user_email: 'pass-holder@example.com', + normalized_email: 'pass-holder@example.com', + }); + + const { + dispatchQueuedImpactAdvocateRegistrationAttempts, + queueImpactAdvocateParticipantRegistration, + } = await import('@/lib/impact/referral'); + + await queueImpactAdvocateParticipantRegistration({ + programKey: 'kilo_pass', + user: passUser, + referralTouch: { + opaqueTrackingValue: 'pass-cookie', + trackingValueLength: 11, + isTrackingValueAccepted: true, + rsCode: null, + rsShareMedium: null, + rsEngagementMedium: null, + landingPath: '/subscriptions/kilo-pass', + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmTerm: null, + utmContent: null, + touchedAt: new Date('2026-05-23T00:00:00.000Z'), + expiresAt: new Date('2026-06-22T00:00:00.000Z'), + }, + }); + + await dispatchQueuedImpactAdvocateRegistrationAttempts(); + + const passParticipant = await db.query.impact_advocate_participants.findFirst({ + where: eq(impact_advocate_participants.user_id, passUser.id), + }); + expect(passParticipant?.program_key).toBe('kilo_pass'); + expect(passParticipant?.opaque_referral_identifier).toBe('SHARED_CODE'); + }); + + it('records Kilo Pass-scoped affiliate and referral touches without KiloClaw defaults', async () => { + const user = await insertTestUser({ + google_user_email: 'kilo-pass-touch@example.com', + normalized_email: 'kilo-pass-touch@example.com', + }); + + const { recordImpactAffiliateTouch, recordImpactReferralTouch } = + await import('@/lib/impact/referral'); + + await recordImpactAffiliateTouch({ + product: 'kilo_pass', + userId: user.id, + touch: { + product: 'kilo_pass', + trackingId: 'impact-click-pass', + trackingValueLength: 17, + isTrackingValueAccepted: true, + landingPath: '/subscriptions/kilo-pass?im_ref=impact-click-pass', + utmSource: 'impact', + utmMedium: null, + utmCampaign: null, + utmTerm: null, + utmContent: null, + touchedAt: new Date('2026-05-23T00:00:00.000Z'), + expiresAt: new Date('2026-06-22T00:00:00.000Z'), + }, + }); + await recordImpactReferralTouch({ + userId: user.id, + touch: { + product: 'kilo_pass', + programKey: 'kilo_pass', + opaqueTrackingValue: 'pass-cookie', + trackingValueLength: 11, + isTrackingValueAccepted: true, + rsCode: 'PASSCODE', + rsShareMedium: 'email', + rsEngagementMedium: 'link', + landingPath: '/subscriptions/kilo-pass?_saasquatch=pass-cookie&rsCode=PASSCODE', + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmTerm: null, + utmContent: null, + touchedAt: new Date('2026-05-23T00:01:00.000Z'), + expiresAt: new Date('2026-06-22T00:01:00.000Z'), + }, + }); + + const touches = await db + .select() + .from(impact_attribution_touches) + .orderBy(impact_attribution_touches.touch_type); + expect(touches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + product: 'kilo_pass', + program_key: null, + touch_type: 'affiliate', + provider: 'impact_performance', + im_ref: 'impact-click-pass', + }), + expect.objectContaining({ + product: 'kilo_pass', + program_key: 'kilo_pass', + touch_type: 'referral', + provider: 'impact_advocate', + rs_code: 'PASSCODE', + }), + ]) + ); + }); + it('keeps transient failures retryable until a later dispatch succeeds', async () => { const fetchMock = jest .fn() diff --git a/apps/web/src/lib/impact/referral.ts b/apps/web/src/lib/impact/referral.ts index ca544a4893..af8f715be5 100644 --- a/apps/web/src/lib/impact/referral.ts +++ b/apps/web/src/lib/impact/referral.ts @@ -29,6 +29,7 @@ import { ImpactAdvocateRegistrationState, ImpactAttributionTouchProvider, ImpactAttributionTouchType, + ImpactReferralProduct, } from '@kilocode/db/schema-types'; import { and, asc, eq, lte, ne, or, sql } from 'drizzle-orm'; @@ -52,6 +53,28 @@ function getDatabaseClient(database?: DatabaseClient): DatabaseClient { return database ?? db; } +function productForProgramKey(programKey: ImpactAdvocateProgramKey): ImpactReferralProduct { + return programKey === ImpactAdvocateProgramKey.KiloPass + ? ImpactReferralProduct.KiloPass + : ImpactReferralProduct.KiloClaw; +} + +function resolveReferralProgramKey(params: { + product?: ImpactReferralProduct | null; + programKey?: ImpactAdvocateProgramKey | null; +}): ImpactAdvocateProgramKey { + if (params.programKey) return params.programKey; + if (params.product === ImpactReferralProduct.KiloPass) return ImpactAdvocateProgramKey.KiloPass; + return ImpactAdvocateProgramKey.KiloClaw; +} + +function resolveReferralProduct(params: { + product?: ImpactReferralProduct | null; + programKey?: ImpactAdvocateProgramKey | null; +}): ImpactReferralProduct { + return params.product ?? productForProgramKey(resolveReferralProgramKey(params)); +} + function buildHashedDedupeKey(parts: Array): string { const normalized = parts.map(part => part?.trim() ?? '').join('|'); return createHash('sha256').update(normalized, 'utf8').digest('hex'); @@ -93,12 +116,15 @@ export function hashNormalizedEmailForDeletionTombstone(normalizedEmail: string) export async function recordImpactAffiliateTouch(params: { database?: DatabaseClient; + product?: ImpactReferralProduct | null; userId?: string | null; anonymousId?: string | null; touch: ParsedImpactAffiliateTouch; }): Promise { const database = getDatabaseClient(params.database); + const product = resolveReferralProduct({ product: params.product ?? params.touch.product }); const dedupeKey = buildHashedDedupeKey([ + product, touchIdentity(params), ImpactAttributionTouchType.Affiliate, ImpactAttributionTouchProvider.ImpactPerformance, @@ -111,6 +137,8 @@ export async function recordImpactAffiliateTouch(params: { .insert(impact_attribution_touches) .values({ dedupe_key: dedupeKey, + product, + program_key: null, anonymous_id: params.anonymousId ?? null, user_id: params.userId ?? null, touch_type: ImpactAttributionTouchType.Affiliate, @@ -148,12 +176,24 @@ export async function recordImpactAffiliateTouch(params: { export async function recordImpactReferralTouch(params: { database?: DatabaseClient; + product?: ImpactReferralProduct | null; + programKey?: ImpactAdvocateProgramKey | null; userId?: string | null; anonymousId?: string | null; touch: ParsedImpactReferralTouch; }): Promise { const database = getDatabaseClient(params.database); + const programKey = resolveReferralProgramKey({ + product: params.product ?? params.touch.product ?? null, + programKey: params.programKey ?? params.touch.programKey ?? null, + }); + const product = resolveReferralProduct({ + product: params.product ?? params.touch.product ?? null, + programKey, + }); const dedupeKey = buildHashedDedupeKey([ + product, + programKey, touchIdentity(params), ImpactAttributionTouchType.Referral, ImpactAttributionTouchProvider.ImpactAdvocate, @@ -167,6 +207,8 @@ export async function recordImpactReferralTouch(params: { .insert(impact_attribution_touches) .values({ dedupe_key: dedupeKey, + product, + program_key: programKey, anonymous_id: params.anonymousId ?? null, user_id: params.userId ?? null, touch_type: ImpactAttributionTouchType.Referral, @@ -207,18 +249,21 @@ export async function recordImpactReferralTouch(params: { export async function ensureImpactAdvocateParticipantProfile(params: { database?: DatabaseClient; + programKey?: ImpactAdvocateProgramKey | null; user: Pick; locale?: string | null; countryCode?: string | null; opaqueReferralIdentifier?: string | null; }): Promise<{ id: string }> { const database = getDatabaseClient(params.database); + const programKey = params.programKey ?? ImpactAdvocateProgramKey.KiloClaw; - const isConfigured = isImpactAdvocateConfigured(); + const isConfigured = isImpactAdvocateConfigured({ programKey }); const [insertedParticipant] = await database .insert(impact_advocate_participants) .values({ + program_key: programKey, user_id: params.user.id, advocate_id: params.user.google_user_email, advocate_account_id: params.user.google_user_email, @@ -241,7 +286,7 @@ export async function ensureImpactAdvocateParticipantProfile(params: { insertedParticipant ?? (await database.query.impact_advocate_participants.findFirst({ where: and( - eq(impact_advocate_participants.program_key, ImpactAdvocateProgramKey.KiloClaw), + eq(impact_advocate_participants.program_key, programKey), eq(impact_advocate_participants.user_id, params.user.id) ), columns: { id: true }, @@ -270,6 +315,8 @@ export async function ensureImpactAdvocateParticipantProfile(params: { export async function queueImpactAdvocateParticipantRegistration(params: { database?: DatabaseClient; + product?: ImpactReferralProduct | null; + programKey?: ImpactAdvocateProgramKey | null; user: Pick; referralTouch: ParsedImpactReferralTouch; locale?: string | null; @@ -287,6 +334,10 @@ export async function queueImpactAdvocateParticipantRegistration(params: { } const database = getDatabaseClient(params.database); + const programKey = resolveReferralProgramKey({ + product: params.product ?? params.referralTouch.product ?? null, + programKey: params.programKey ?? params.referralTouch.programKey ?? null, + }); const payload = buildImpactAdvocateRegisterParticipantPayload({ user: params.user, referralCookieValue: params.referralTouch.opaqueTrackingValue, @@ -294,9 +345,10 @@ export async function queueImpactAdvocateParticipantRegistration(params: { countryCode: params.countryCode, }); const nowIso = new Date().toISOString(); - const isConfigured = isImpactAdvocateConfigured(); + const isConfigured = isImpactAdvocateConfigured({ programKey }); const participant = await ensureImpactAdvocateParticipantProfile({ database, + programKey, user: params.user, locale: params.locale, countryCode: params.countryCode, @@ -304,6 +356,7 @@ export async function queueImpactAdvocateParticipantRegistration(params: { const attemptDedupeKey = buildHashedDedupeKey([ 'impact-advocate-registration', + programKey, params.user.id, params.referralTouch.opaqueTrackingValue, ]); @@ -311,6 +364,7 @@ export async function queueImpactAdvocateParticipantRegistration(params: { const [insertedAttempt] = await database .insert(impact_advocate_registration_attempts) .values({ + program_key: programKey, participant_id: participant.id, dedupe_key: attemptDedupeKey, opaque_cookie_value: params.referralTouch.opaqueTrackingValue, @@ -335,6 +389,7 @@ export async function queueImpactAdvocateParticipantRegistration(params: { userId: params.user.id, participantId: participant.id, attemptId: insertedAttempt?.id ?? null, + programKey, impactAdvocateConfigured: isConfigured, trackingValueLength: params.referralTouch.trackingValueLength, localePresent: Boolean(params.locale?.trim()), @@ -380,12 +435,18 @@ export async function queueImpactAdvocateParticipantRegistration(params: { */ export async function queueImpactAdvocateSelfRegistration(params: { database?: DatabaseClient; + product?: ImpactReferralProduct | null; + programKey?: ImpactAdvocateProgramKey | null; user: Pick; locale?: string | null; countryCode?: string | null; }): Promise { const database = getDatabaseClient(params.database); - const isConfigured = isImpactAdvocateConfigured(); + const programKey = resolveReferralProgramKey({ + product: params.product ?? null, + programKey: params.programKey ?? null, + }); + const isConfigured = isImpactAdvocateConfigured({ programKey }); const nowIso = new Date().toISOString(); // Empty cookie envelope — advocate-only users have no inbound attribution. @@ -401,6 +462,7 @@ export async function queueImpactAdvocateSelfRegistration(params: { const participant = await ensureImpactAdvocateParticipantProfile({ database, + programKey, user: params.user, locale: params.locale, countryCode: params.countryCode, @@ -426,12 +488,14 @@ export async function queueImpactAdvocateSelfRegistration(params: { const attemptDedupeKey = buildHashedDedupeKey([ 'impact-advocate-self-registration', + programKey, params.user.id, ]); const [insertedAttempt] = await database .insert(impact_advocate_registration_attempts) .values({ + program_key: programKey, participant_id: participant.id, dedupe_key: attemptDedupeKey, opaque_cookie_value: null, @@ -456,6 +520,7 @@ export async function queueImpactAdvocateSelfRegistration(params: { userId: params.user.id, participantId: participant.id, attemptId: insertedAttempt?.id ?? null, + programKey, impactAdvocateConfigured: isConfigured, localePresent: Boolean(params.locale?.trim()), countryCode: params.countryCode ?? null, @@ -580,7 +645,9 @@ async function dispatchImpactAdvocateRegistrationAttemptById( attemptCount: attempt.attempt_count, }); - const result = await sendImpactAdvocateRegisterParticipantPayload(payload); + const result = await sendImpactAdvocateRegisterParticipantPayload(payload, { + programKey: participant.program_key, + }); const attemptCount = attempt.attempt_count + 1; const completedAt = new Date().toISOString(); @@ -603,16 +670,16 @@ async function dispatchImpactAdvocateRegistrationAttemptById( // (vanishingly unlikely — SaaSquatch issues unique codes per tenant — but // a violation here would otherwise roll back the whole success transaction // and put us in a retry loop). - const programId = getImpactAdvocateProgramId(); - const advocateCode = extractAdvocateReferralCodeFromUpsertResponse( - result.responseBody, - programId - ); + const programId = getImpactAdvocateProgramId({ programKey: participant.program_key }); + const advocateCode = programId + ? extractAdvocateReferralCodeFromUpsertResponse(result.responseBody, programId) + : null; let advocateCodeToPersist: string | null = null; if (advocateCode) { const conflicting = await db.query.impact_advocate_participants.findFirst({ where: and( + eq(impact_advocate_participants.program_key, participant.program_key), eq(impact_advocate_participants.opaque_referral_identifier, advocateCode), ne(impact_advocate_participants.id, participant.id) ), diff --git a/apps/web/src/lib/referral.ts b/apps/web/src/lib/referral.ts index 93330137b7..5b46e0c5e7 100644 --- a/apps/web/src/lib/referral.ts +++ b/apps/web/src/lib/referral.ts @@ -7,7 +7,7 @@ import { } from '@kilocode/db/schema'; import { ImpactReferralProduct } from '@kilocode/db/schema-types'; import { db } from '@/lib/drizzle'; -import { eq, and, count, sql, isNull, isNotNull } from 'drizzle-orm'; +import { eq, and, count, sql, isNull, isNotNull, inArray } from 'drizzle-orm'; import { captureMessage } from '@sentry/nextjs'; import { grantCreditForCategory } from '@/lib/promotionalCredits'; import { @@ -57,17 +57,20 @@ const redeemingReferralPromoCode = referralRedeemingBonus.credit_category; const referringReferralPromoCode = referralReferringBonus.credit_category; export async function processReferralTopUp(redeemingKiloUserId: string) { - const [kiloclawReferralConversion] = await db + const [impactGovernedReferralConversion] = await db .select({ id: impact_referral_conversions.id }) .from(impact_referral_conversions) .where( and( - eq(impact_referral_conversions.product, ImpactReferralProduct.KiloClaw), + inArray(impact_referral_conversions.product, [ + ImpactReferralProduct.KiloClaw, + ImpactReferralProduct.KiloPass, + ]), eq(impact_referral_conversions.referee_user_id, redeemingKiloUserId) ) ) .limit(1); - if (kiloclawReferralConversion) { + if (impactGovernedReferralConversion) { return; } diff --git a/apps/web/src/lib/referrals.test.ts b/apps/web/src/lib/referrals.test.ts index a95f78e3cd..e0c4235575 100644 --- a/apps/web/src/lib/referrals.test.ts +++ b/apps/web/src/lib/referrals.test.ts @@ -328,7 +328,7 @@ describe('referrals', () => { expect(creditTransactions).toHaveLength(0); }); - it('grants legacy referral-code credits when only a Kilo Pass referral conversion exists', async () => { + it('does not grant legacy referral-code credits when a Kilo Pass Impact referral conversion exists', async () => { const redeemingUser = await insertTestUser({ google_user_email: 'kilo-pass-referee@example.com', google_user_name: 'Kilo Pass Referee', @@ -364,14 +364,13 @@ describe('referrals', () => { .select() .from(credit_transactions) .where(eq(credit_transactions.kilo_user_id, redeemingUser.id)); - expect(legacyCredits).toHaveLength(1); - expect(legacyCredits[0].credit_category).toBe(referralRedeemingBonus.credit_category); + expect(legacyCredits).toHaveLength(0); const [usage] = await db .select() .from(referral_code_usages) .where(eq(referral_code_usages.redeeming_kilo_user_id, redeemingUser.id)); - expect(usage?.paid_at).not.toBeNull(); + expect(usage?.paid_at).toBeNull(); }); it('does not grant legacy referral-code credits when a kiloclaw referral conversion exists', async () => { diff --git a/apps/web/src/lib/user/server.ts b/apps/web/src/lib/user/server.ts index 71fb3a6b13..a449a7f374 100644 --- a/apps/web/src/lib/user/server.ts +++ b/apps/web/src/lib/user/server.ts @@ -428,14 +428,18 @@ async function getImpactTrackingContextFromAuthFlow(requestHeaders?: Headers): P const ignoreUrlImRefForReferralTouch = Boolean( referralTouch?.opaqueTrackingValue && urlImRefParam ); - const fallbackUrl = new URL('http://localhost/users/after-sign-in'); + const affiliateCookieFallbackUrl = new URL('http://localhost/users/after-sign-in'); + const callbackPath = callbackUrl.searchParams.get('callbackPath')?.trim(); + if (callbackPath) { + affiliateCookieFallbackUrl.searchParams.set('callbackPath', callbackPath); + } const affiliateTouch = ignoreUrlImRefForReferralTouch ? cookieTrackingId && cookieTrackingId !== urlImRefParam - ? parseImpactAffiliateTouchFromUrl(fallbackUrl, cookieTrackingId) + ? parseImpactAffiliateTouchFromUrl(affiliateCookieFallbackUrl, cookieTrackingId) : null : (parseImpactAffiliateTouchFromUrl(callbackUrl) ?? (cookieTrackingId - ? parseImpactAffiliateTouchFromUrl(fallbackUrl, cookieTrackingId) + ? parseImpactAffiliateTouchFromUrl(affiliateCookieFallbackUrl, cookieTrackingId) : null)); logImpactReferralDebug('Auth flow parsed Impact tracking context from callback URL cookie', { From 0a52d4799a868414497f74e75612d7e57899cbe8 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Thu, 28 May 2026 14:46:53 +0200 Subject: [PATCH 2/4] feat(referrals): launch Kilo Pass advocate rewards --- .specs/impact-referrals.md | 38 +- .../billing/PlanSelectionDialog.tsx | 25 +- .../claw/components/billing/WelcomePage.tsx | 25 +- .../(app)/subscriptions/kilo-pass/page.tsx | 16 +- .../subscriptions/kilo-pass/refer/page.tsx | 24 + .../route.ts | 45 + .../profile/ProfileKiloPassSection.tsx | 12 +- .../KiloPassActiveSubscriptionCard.logic.ts | 1 - .../KiloPassActiveSubscriptionCard.tsx | 13 +- .../kilo-pass/KiloPassBonusRampDialog.tsx | 34 +- .../kilo-pass/KiloPassSubscribeCard.tsx | 16 +- .../profile/kilo-pass/KiloPassTierCard.tsx | 21 +- .../ImpactAdvocateReferralCard.test.ts | 15 + .../referrals/ImpactAdvocateReferralCard.tsx | 142 ++- .../ImpactAdvocateReferralCard.utils.ts | 6 + .../referrals/KiloPassReferralButton.tsx | 19 + .../KiloPassReferralPageContent.test.ts | 135 +++ .../referrals/KiloPassReferralPageContent.tsx | 370 ++++++ .../subscriptions/PersonalSubscriptions.tsx | 13 +- .../kilo-pass/KiloPassDetail.tsx | 17 +- .../subscriptions/kilo-pass/KiloPassGroup.tsx | 12 +- .../lib/impact/kilo-pass-referrals.test.ts | 1043 +++++++++++++++++ .../web/src/lib/impact/kilo-pass-referrals.ts | 975 +++++++++++++++ .../src/lib/impact/kiloclaw-referrals.test.ts | 60 +- apps/web/src/lib/impact/kiloclaw-referrals.ts | 132 ++- apps/web/src/lib/kilo-pass/affiliate-sale.ts | 2 +- apps/web/src/lib/kilo-pass/bonus.test.ts | 89 +- apps/web/src/lib/kilo-pass/bonus.ts | 22 +- apps/web/src/lib/kilo-pass/constants.ts | 7 - apps/web/src/lib/kilo-pass/issuance.test.ts | 353 +++++- apps/web/src/lib/kilo-pass/issuance.ts | 277 ++++- .../stripe-handlers-invoice-paid.test.ts | 82 ++ .../kilo-pass/stripe-handlers-invoice-paid.ts | 96 +- .../kilo-pass/usage-triggered-bonus.test.ts | 112 +- .../lib/kilo-pass/usage-triggered-bonus.ts | 3 +- .../usage-triggered-bonus.unit.test.ts | 11 +- apps/web/src/lib/stripe/index.test.ts | 214 ++++ apps/web/src/lib/stripe/index.ts | 72 +- apps/web/src/routers/kilo-pass-router.test.ts | 360 +++++- apps/web/src/routers/kilo-pass-router.ts | 148 ++- apps/web/vercel.json | 4 + 41 files changed, 4519 insertions(+), 542 deletions(-) create mode 100644 apps/web/src/app/(app)/subscriptions/kilo-pass/refer/page.tsx create mode 100644 apps/web/src/app/api/cron/kilo-pass-expire-referral-rewards/route.ts create mode 100644 apps/web/src/components/referrals/ImpactAdvocateReferralCard.test.ts create mode 100644 apps/web/src/components/referrals/ImpactAdvocateReferralCard.utils.ts create mode 100644 apps/web/src/components/referrals/KiloPassReferralButton.tsx create mode 100644 apps/web/src/components/referrals/KiloPassReferralPageContent.test.ts create mode 100644 apps/web/src/components/referrals/KiloPassReferralPageContent.tsx create mode 100644 apps/web/src/lib/impact/kilo-pass-referrals.test.ts create mode 100644 apps/web/src/lib/impact/kilo-pass-referrals.ts diff --git a/.specs/impact-referrals.md b/.specs/impact-referrals.md index ff8ffc3ba7..3df3a60628 100644 --- a/.specs/impact-referrals.md +++ b/.specs/impact-referrals.md @@ -120,9 +120,6 @@ BCP 14 [RFC 2119] [RFC 8174] keywords apply only when they appear in all capital after the referral reward was earned. Annual subscription issuances and already-created issuances are not eligible. - **Kilo Pass bonus-like issuance item**: Kilo Pass issuance item of kind `bonus`, `promo_first_month_50pct`, or `referral_bonus`. At most one bonus-like item may exist for an issuance. -- **Referral launch cutoff**: UTC instant configured for the Kilo Pass referral launch. First-time monthly subscribers - who started before the cutoff keep legacy month-2 welcome promo behavior; subscribers starting at or after the cutoff - receive only the first-month welcome promo. ## Overview @@ -143,6 +140,8 @@ not stack with, the normal Kilo Pass monthly/promo bonus for that issuance. Existing Impact Performance conversion events drive Impact Advocate conversion state. The system uses `Sale (71659)` as the paid-conversion event for referral conversion and renewal reporting. When referral wins attribution for a paid conversion, local referral rewards are authoritative and affiliate SALE reporting for the same conversion is suppressed. +Impact Advocate reward redemption is used only for reporting synchronization: KiloClaw redeems after local free-month +application, and Kilo Pass redeems after local referral bonus allocation. ## Rules @@ -157,8 +156,9 @@ conversion, local referral rewards are authoritative and affiliate SALE reportin - UTT UUID: `A7138521-9724-4b8f-95f4-1db2fbae81141` - Advocate widget ID: `p/51699/w/referrerWidget` -3. Existing unscoped Impact Advocate configuration MAY remain as KiloClaw fallback configuration only. Kilo Pass MUST - require explicit Kilo Pass Advocate program/widget configuration and MUST NOT fall back to KiloClaw configuration. +3. Impact Advocate account SID, auth token, and tenant alias MAY be shared across Advocate programs. KiloClaw and Kilo + Pass MUST each require explicit product-scoped Advocate program ID and widget ID configuration. Products MUST NOT + fall back to unscoped or other-product program/widget configuration. 4. Kilo Pass MUST use a different Impact Advocate program ID and widget ID than KiloClaw. @@ -543,9 +543,9 @@ conversion, local referral rewards are authoritative and affiliate SALE reportin rewards but only an annual Kilo Pass subscription, rewards remain pending until an eligible monthly subscription is available or the rewards expire. -132. Kilo Pass welcome bonus behavior MUST change at referral launch: first-time monthly subscribers who started before - the referral launch cutoff keep legacy month-2 promo eligibility; subscribers starting at or after the cutoff MUST - receive only the first-month welcome promo. +132. Kilo Pass welcome bonus behavior MUST grant the 50% promo only for the first monthly streak month of a + first-time subscriber. Streak month 2 and later MUST use the normal monthly ramp unless a referral bonus replaces + the bonus for that issuance. ### Shared Reward Granting @@ -627,6 +627,16 @@ conversion, local referral rewards are authoritative and affiliate SALE reportin 158. Impact reward redemption state is for reporting and reconciliation only. It MUST NOT be the source of truth for local reward eligibility, application, cancellation, or reversal. +158a. For Kilo Pass, when a local referral bonus reward is allocated/granted, the system MUST queue asynchronous Impact + Advocate reward lookup and single-reward redemption using the reward amount and USD unit so Impact reporting + matches Kilo allocation state. + +158b. Kilo Pass Impact Advocate reward redemption MUST be idempotently queued per local reward and MUST NOT block paid + conversion processing, reward ledger creation, reward application, billing settlement, or user access. + +158c. Kilo Pass Impact reward lookup and redemption state is for reporting and reconciliation only. It MUST NOT be the + source of truth for local reward eligibility, application, cancellation, or reversal. + ### Refunds, Reversals, and Fraud 159. Rewards from a qualifying Stripe payment MUST be treated as adverse when Stripe reports a chargeback or when @@ -773,6 +783,16 @@ conversions. The first positively paid settlement using a supported fingerprinta instrument opportunity; reused instruments retain ordinary monthly-ramp bonus behavior but do not receive the introductory promo or create Kilo Pass referral rewards. Annual behavior remains outside this restriction. +### 2026-05-26 -- Redeem allocated Kilo Pass rewards in Impact Advocate + +Kilo Pass referral bonus allocation now queues Impact Advocate reward lookup and redemption using the USD reward amount, +for reporting synchronization only. The local reward ledger remains authoritative. + +### 2026-05-25 -- Require product-scoped Advocate program/widget configuration + +Removed KiloClaw fallback to unscoped Impact Advocate program/widget configuration. KiloClaw and Kilo Pass now both +require explicit product-scoped Advocate program ID and widget ID while sharing account SID, auth token, and tenant alias. + ### 2026-05-22 -- Rename and expand to Kilo Pass Renamed `.specs/kiloclaw-referrals.md` to `.specs/impact-referrals.md`. Generalized shared Impact Advocate referral @@ -780,7 +800,7 @@ rules and added Kilo Pass referral requirements: separate program/widget config, monthly Stripe launch scope, referral-vs-affiliate priority using the KiloClaw resolver model, first-time Kilo Pass subscriber eligibility, 5-referrer-reward cap, double-sided 50% rewards snapshotted from the referee's monthly tier, 12-month pending reward expiry, base-issuance `referral_bonus` fulfillment, monthly bonus replacement, adverse-payment -handling, server-side Performance SALE reporting for Advocate state, and welcome-bonus cutoff behavior. +handling, server-side Performance SALE reporting for Advocate state, and first-month welcome-bonus behavior. ### 2026-05-12 -- Price-versioned KiloClaw billing preserves referral semantics diff --git a/apps/web/src/app/(app)/claw/components/billing/PlanSelectionDialog.tsx b/apps/web/src/app/(app)/claw/components/billing/PlanSelectionDialog.tsx index b7e5388276..680d96b4ba 100644 --- a/apps/web/src/app/(app)/claw/components/billing/PlanSelectionDialog.tsx +++ b/apps/web/src/app/(app)/claw/components/billing/PlanSelectionDialog.tsx @@ -20,9 +20,6 @@ import { type KiloClawSignupDisplay, type KiloPassUpsellActivationPreview, } from './billing-types'; -import { KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF } from '@/lib/kilo-pass/constants'; -import { dayjs } from '@/lib/kilo-pass/dayjs'; - type Cadence = 'monthly' | 'yearly'; type Tier = '19' | '49' | '199'; @@ -137,7 +134,7 @@ function TierCard({ Up to 40% free bonus credits
- First 2 months: +50% free bonus credits + First month: +50% free bonus credits
@@ -293,8 +290,6 @@ function HostingOnlyPlanCard({ function CreditsHowItWorks() { const [open, setOpen] = useState(false); - const showTwoMonthPromo = dayjs().utc().isBefore(KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF); - return (
- {showTwoMonthPromo ? ( -
- - - First-time subscribers receive 50% free - bonus credits for the first two months. - -
- ) : null} +
+ + + First-time subscribers receive 50% free + bonus credits for the first month. + +
)} @@ -519,7 +512,7 @@ export function PlanSelectionDialog({ open, onOpenChange }: PlanSelectionDialogP const kiloPassUpsell = useMutation( trpc.kiloclaw.createKiloPassUpsellCheckout.mutationOptions({ onSuccess: data => { - if (data.url) window.location.href = data.url; + if (data.url) window.location.assign(data.url); }, }) ); diff --git a/apps/web/src/app/(app)/claw/components/billing/WelcomePage.tsx b/apps/web/src/app/(app)/claw/components/billing/WelcomePage.tsx index 18e981cf29..68a568ff9b 100644 --- a/apps/web/src/app/(app)/claw/components/billing/WelcomePage.tsx +++ b/apps/web/src/app/(app)/claw/components/billing/WelcomePage.tsx @@ -19,9 +19,6 @@ import { type KiloClawSignupDisplay, type KiloPassUpsellActivationPreview, } from './billing-types'; -import { KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF } from '@/lib/kilo-pass/constants'; -import { dayjs } from '@/lib/kilo-pass/dayjs'; - type Cadence = 'monthly' | 'yearly'; type Tier = '19' | '49' | '199'; @@ -130,7 +127,7 @@ function TierCard({ Up to 40% free bonus credits
- First 2 months: +50% free bonus credits + First month: +50% free bonus credits
@@ -286,8 +283,6 @@ function HostingOnlyPlanCard({ function CreditsHowItWorks() { const [open, setOpen] = useState(false); - const showTwoMonthPromo = dayjs().utc().isBefore(KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF); - return (
- {showTwoMonthPromo ? ( -
- - - First-time subscribers receive 50% free - bonus credits for the first two months. - -
- ) : null} +
+ + + First-time subscribers receive 50% free + bonus credits for the first month. + +
)} @@ -512,7 +505,7 @@ export function WelcomePage() { const kiloPassUpsell = useMutation( trpc.kiloclaw.createKiloPassUpsellCheckout.mutationOptions({ onSuccess: data => { - if (data.url) window.location.href = data.url; + if (data.url) window.location.assign(data.url); }, }) ); diff --git a/apps/web/src/app/(app)/subscriptions/kilo-pass/page.tsx b/apps/web/src/app/(app)/subscriptions/kilo-pass/page.tsx index 640508cad6..b904d3ad0b 100644 --- a/apps/web/src/app/(app)/subscriptions/kilo-pass/page.tsx +++ b/apps/web/src/app/(app)/subscriptions/kilo-pass/page.tsx @@ -1,7 +1,21 @@ +import { redirect } from 'next/navigation'; + import { PageContainer } from '@/components/layouts/PageContainer'; import { KiloPassDetail } from '@/components/subscriptions/kilo-pass/KiloPassDetail'; +import { db } from '@/lib/drizzle'; +import { getKiloPassStateForUser } from '@/lib/kilo-pass/state'; +import { getUserFromAuthOrRedirect } from '@/lib/user/server'; + +export default async function KiloPassSubscriptionPage() { + const user = await getUserFromAuthOrRedirect( + '/users/sign_in?callbackPath=/subscriptions/kilo-pass' + ); + const subscription = await getKiloPassStateForUser(db, user.id); + + if (!subscription) { + redirect('/subscriptions'); + } -export default function KiloPassSubscriptionPage() { return ( diff --git a/apps/web/src/app/(app)/subscriptions/kilo-pass/refer/page.tsx b/apps/web/src/app/(app)/subscriptions/kilo-pass/refer/page.tsx new file mode 100644 index 0000000000..7edb08e707 --- /dev/null +++ b/apps/web/src/app/(app)/subscriptions/kilo-pass/refer/page.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; + +import { ImpactAdvocateReferralWidget } from '@/components/referrals/ImpactAdvocateReferralCard'; +import { KiloPassReferralPageContent } from '@/components/referrals/KiloPassReferralPageContent'; +import { useTRPC } from '@/lib/trpc/utils'; + +export default function KiloPassReferralPage() { + const trpc = useTRPC(); + const rewardSummary = useQuery(trpc.kiloPass.getReferralRewardSummary.queryOptions()); + + return ( + + + + ); +} diff --git a/apps/web/src/app/api/cron/kilo-pass-expire-referral-rewards/route.ts b/apps/web/src/app/api/cron/kilo-pass-expire-referral-rewards/route.ts new file mode 100644 index 0000000000..c04cd2850f --- /dev/null +++ b/apps/web/src/app/api/cron/kilo-pass-expire-referral-rewards/route.ts @@ -0,0 +1,45 @@ +import { timingSafeEqual } from 'node:crypto'; +import { NextResponse } from 'next/server'; + +import { CRON_SECRET } from '@/lib/config.server'; +import { expirePendingKiloPassReferralRewards } from '@/lib/impact/kilo-pass-referrals'; +import { sentryLogger } from '@/lib/utils.server'; + +if (!CRON_SECRET) { + throw new Error('CRON_SECRET is not configured in environment variables'); +} + +function isExpectedCronAuthorization(authHeader: string | null): boolean { + if (!authHeader) return false; + + const authHeaderBuffer = Buffer.from(authHeader); + const expectedAuthBuffer = Buffer.from(`Bearer ${CRON_SECRET}`); + if (authHeaderBuffer.length !== expectedAuthBuffer.length) return false; + + return timingSafeEqual(authHeaderBuffer, expectedAuthBuffer); +} + +export async function GET(request: Request) { + const authHeader = request.headers.get('authorization'); + if (!isExpectedCronAuthorization(authHeader)) { + sentryLogger( + 'cron', + 'warning' + )( + 'SECURITY: Invalid CRON job authorization attempt: ' + + (authHeader ? 'Invalid authorization header' : 'Missing authorization header') + ); + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const summary = await expirePendingKiloPassReferralRewards(); + + return NextResponse.json( + { + success: true, + summary, + timestamp: new Date().toISOString(), + }, + { status: 200 } + ); +} diff --git a/apps/web/src/components/profile/ProfileKiloPassSection.tsx b/apps/web/src/components/profile/ProfileKiloPassSection.tsx index 2c777fdd4a..85e9e0f055 100644 --- a/apps/web/src/components/profile/ProfileKiloPassSection.tsx +++ b/apps/web/src/components/profile/ProfileKiloPassSection.tsx @@ -11,14 +11,6 @@ import { KiloPassLoadingCard } from '@/components/profile/kilo-pass/KiloPassLoad import { KiloPassSubscribeCard } from '@/components/profile/kilo-pass/KiloPassSubscribeCard'; import { isStripeSubscriptionEnded } from '@/lib/kilo-pass/stripe-subscription-status'; import { recommendKiloPassTierFromAverageMonthlyUsageUsd } from '@/lib/kilo-pass/recommend-tier'; -import { KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF } from '@/lib/kilo-pass/constants'; -import { dayjs } from '@/lib/kilo-pass/dayjs'; - -function getShowKiloPassTwoMonthPromo(showFirstMonthPromo: boolean): boolean { - return ( - showFirstMonthPromo && dayjs().utc().isBefore(KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF) - ); -} export function ProfileKiloPassSection() { const trpc = useTRPC(); @@ -42,7 +34,7 @@ export function ProfileKiloPassSection() { toast.error('Failed to create Stripe checkout session'); return; } - window.location.href = result.url; + window.location.assign(result.url); }, onError: error => { toast.error(error.message || 'Failed to start checkout'); @@ -65,7 +57,6 @@ export function ProfileKiloPassSection() { if (!activeSubscription) { const pending = checkoutMutation.isPending; const showFirstMonthPromo = query.data.isEligibleForFirstMonthPromo; - const showSecondMonthPromo = getShowKiloPassTwoMonthPromo(showFirstMonthPromo); const averageMonthlyUsageUsd = averageMonthlyUsageQuery.data?.averageMonthlyUsageUsd; const recommendedTier = typeof averageMonthlyUsageUsd === 'number' @@ -78,7 +69,6 @@ export function ProfileKiloPassSection() { setCadence={setCadence} pending={pending} showFirstMonthPromo={showFirstMonthPromo} - showSecondMonthPromo={showSecondMonthPromo} recommendedTier={recommendedTier} onSelectTier={tier => checkoutMutation.mutate({ tier, cadence })} /> diff --git a/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.logic.ts b/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.logic.ts index 3527b7ce7a..c1d60e68b5 100644 --- a/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.logic.ts +++ b/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.logic.ts @@ -130,7 +130,6 @@ function computeRefillRowModel(params: { tier: baseTier, streakMonths: Math.max(1, params.subscription.currentStreakMonths + 1), isFirstTimeSubscriberEver: params.subscription.isFirstTimeSubscriberEver, - subscriptionStartedAtIso: params.subscription.startedAt, }) : null; if (typeof bonusUsd !== 'number' || bonusUsd <= 0) return null; diff --git a/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.tsx b/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.tsx index 390ec5c1f4..93bc00d67f 100644 --- a/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.tsx +++ b/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.tsx @@ -5,7 +5,7 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { Badge } from '@/components/ui/badge'; +import { SubscriptionStatusBadge } from '@/components/subscriptions/SubscriptionStatusBadge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { formatDollars, formatIsoDateString_UsaDateOnlyFormat } from '@/lib/utils'; @@ -14,6 +14,8 @@ import { dayjs } from '@/lib/kilo-pass/dayjs'; import { useTRPC } from '@/lib/trpc/utils'; import { getMonthlyPriceUsd } from '@/lib/kilo-pass/bonus'; +import { KiloPassReferralButton } from '@/components/referrals/KiloPassReferralButton'; + import { KiloPassSubscriptionSettingsModal } from './KiloPassSubscriptionSettingsModal'; import type { KiloPassSubscription } from './kiloPassSubscription'; import { @@ -140,15 +142,18 @@ function HeaderRow() { - Kilo Pass + + Kilo Pass + + {view.header.tierLabel} • {view.header.cadenceLabel} -
- {view.status.label} +
+ {providerManagement.externalManagementAction ? ( + ); +} diff --git a/apps/web/src/components/referrals/KiloPassReferralPageContent.test.ts b/apps/web/src/components/referrals/KiloPassReferralPageContent.test.ts new file mode 100644 index 0000000000..fcf9e2451e --- /dev/null +++ b/apps/web/src/components/referrals/KiloPassReferralPageContent.test.ts @@ -0,0 +1,135 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from '@jest/globals'; + +import { KiloPassReferralPageContent } from './KiloPassReferralPageContent'; +import type { KiloPassReferralRewardSummary } from './KiloPassReferralPageContent'; + +const emptySummary: KiloPassReferralRewardSummary = { + totals: { + totalRewards: 0, + pendingRewards: 0, + appliedRewards: 0, + totalRewardAmountUsd: 0, + pendingRewardAmountUsd: 0, + appliedRewardAmountUsd: 0, + }, + referrerCap: { + grantedRewards: 0, + limit: 5, + reached: false, + }, + rewards: [], +}; + +describe('KiloPassReferralPageContent', () => { + it('renders Kilo Pass-specific empty copy and an accessible widget region', () => { + const html = renderToStaticMarkup( + React.createElement( + KiloPassReferralPageContent, + { summary: emptySummary }, + React.createElement('div', { 'data-testid': 'share-widget' }, 'widget body') + ) + ); + + expect(html).toContain('Earn Kilo Pass referral bonuses'); + expect(html).toContain('50% monthly Kilo Pass bonus'); + expect(html).toContain('No Kilo Pass referral rewards yet.'); + expect(html).toContain('aria-label="Kilo Pass referral sharing"'); + expect(html).toContain('data-testid="share-widget"'); + expect(html).not.toContain('Share your Kilo Pass referral link'); + expect(html).not.toContain('Use the Kilo Pass referral widget'); + expect(html).not.toContain('KiloClaw'); + expect(html).not.toContain('free month'); + }); + + it('renders loading and non-sensitive error states without color-only messaging', () => { + const loadingHtml = renderToStaticMarkup( + React.createElement(KiloPassReferralPageContent, { + summary: null, + isLoading: true, + }) + ); + const errorHtml = renderToStaticMarkup( + React.createElement(KiloPassReferralPageContent, { + summary: null, + errorMessage: 'Rewards are temporarily unavailable. Try again in a minute.', + }) + ); + + expect(loadingHtml).toContain('Loading Kilo Pass referral rewards…'); + expect(loadingHtml).toContain(' { + const html = renderToStaticMarkup( + React.createElement(KiloPassReferralPageContent, { + summary: { + totals: { + totalRewards: 3, + pendingRewards: 1, + appliedRewards: 1, + totalRewardAmountUsd: 58.5, + pendingRewardAmountUsd: 24.5, + appliedRewardAmountUsd: 9.5, + }, + referrerCap: { + grantedRewards: 5, + limit: 5, + reached: true, + }, + rewards: [ + { + id: 'reward-pending', + role: 'referrer', + status: 'pending', + rewardAmountUsd: 24.5, + earnedAt: '2026-05-10T00:00:00.000Z', + appliedAt: null, + expiresAt: '2027-05-10T00:00:00.000Z', + sourceTier: 'tier_49', + reviewReason: null, + }, + { + id: 'reward-applied', + role: 'referee', + status: 'applied', + rewardAmountUsd: 9.5, + earnedAt: '2026-05-11T00:00:00.000Z', + appliedAt: '2026-06-01T00:00:00.000Z', + expiresAt: null, + sourceTier: 'tier_19', + reviewReason: null, + }, + { + id: 'reward-review-required', + role: 'referrer', + status: 'review_required', + rewardAmountUsd: 24.5, + earnedAt: '2026-05-12T00:00:00.000Z', + appliedAt: null, + expiresAt: null, + sourceTier: 'tier_49', + reviewReason: 'payment_refunded', + }, + ], + }, + }) + ); + + expect(html).toContain('$58.50'); + expect(html).toContain('$24.50'); + expect(html).toContain('$9.50'); + expect(html).toContain('Cap reached'); + expect(html).toContain('5 of 5 referrer rewards'); + expect(html).toContain('Waiting for a future eligible monthly issuance'); + expect(html).toContain('Applied'); + expect(html).toContain('Needs review'); + expect(html).toContain('May 10, 2026'); + expect(html).toContain('Jun 1, 2026'); + }); +}); diff --git a/apps/web/src/components/referrals/KiloPassReferralPageContent.tsx b/apps/web/src/components/referrals/KiloPassReferralPageContent.tsx new file mode 100644 index 0000000000..b695991a28 --- /dev/null +++ b/apps/web/src/components/referrals/KiloPassReferralPageContent.tsx @@ -0,0 +1,370 @@ +'use client'; + +import React, { type ReactNode } from 'react'; +import Link from 'next/link'; +import { CalendarDays, Gift, History, Info, Sparkles } from 'lucide-react'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { formatIsoDateString_UsaDateOnlyFormat } from '@/lib/utils'; + +const SHARE_WIDGET_ANCHOR_ID = 'kilo-pass-referral-share'; + +export type KiloPassReferralRewardStatus = + | 'pending' + | 'earned' + | 'applied' + | 'expired' + | 'canceled' + | 'reversed' + | 'review_required'; + +export type KiloPassReferralRewardSummary = { + totals: { + totalRewards: number; + pendingRewards: number; + appliedRewards: number; + totalRewardAmountUsd: number; + pendingRewardAmountUsd: number; + appliedRewardAmountUsd: number; + }; + referrerCap: { + grantedRewards: number; + limit: number; + reached: boolean; + }; + rewards: Array<{ + id: string; + role: 'referrer' | 'referee'; + status: KiloPassReferralRewardStatus; + rewardAmountUsd: number; + earnedAt: string; + appliedAt: string | null; + expiresAt: string | null; + sourceTier: string | null; + reviewReason: string | null; + }>; +}; + +type KiloPassReferralPageContentProps = { + summary: KiloPassReferralRewardSummary | null; + isLoading?: boolean; + errorMessage?: string | null; + children?: ReactNode; +}; + +type StatusPresentation = { + label: string; + className: string; +}; + +const usdFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +function formatUsd(amount: number): string { + return usdFormatter.format(amount); +} + +function formatTier(tier: string | null): string { + switch (tier) { + case 'tier_19': + return '$19 monthly tier'; + case 'tier_49': + return '$49 monthly tier'; + case 'tier_199': + return '$199 monthly tier'; + default: + return 'monthly tier'; + } +} + +function roleLabel(role: 'referrer' | 'referee'): string { + return role === 'referrer' ? 'Referral you shared' : 'Referral you used'; +} + +function rewardStatusPresentation(status: KiloPassReferralRewardStatus): StatusPresentation { + switch (status) { + case 'applied': + return { + label: 'Applied', + className: 'bg-emerald-500/20 text-emerald-400 ring-emerald-500/20', + }; + case 'earned': + case 'pending': + return { + label: 'Waiting for a future eligible monthly issuance', + className: 'bg-yellow-500/20 text-yellow-400 ring-yellow-500/20', + }; + case 'expired': + return { + label: 'Expired', + className: 'bg-zinc-500/20 text-zinc-400 ring-zinc-500/20', + }; + case 'canceled': + return { + label: 'Canceled', + className: 'bg-zinc-500/20 text-zinc-400 ring-zinc-500/20', + }; + case 'reversed': + return { + label: 'Reversed', + className: 'bg-red-500/20 text-red-400 ring-red-500/20', + }; + case 'review_required': + return { + label: 'Needs review', + className: 'bg-orange-500/20 text-orange-400 ring-orange-500/20', + }; + } +} + +export function KiloPassReferralPageContent({ + summary, + isLoading = false, + errorMessage, + children, +}: KiloPassReferralPageContentProps) { + return ( +
+
+
+
+
+
+

Earn Kilo Pass referral bonuses

+

+ Share Kilo Pass with someone else and when their first eligible monthly payment is + confirmed, you both earn a 50% monthly Kilo Pass bonus based on their tier. +

+
+ +
+
+ + + +
+ {children ?? ( + + Loading Kilo Pass referral sharing… + + )} +
+ + {isLoading ? ( + + Loading Kilo Pass referral rewards… + + ) : errorMessage ? ( +
+ + Kilo Pass referral rewards are unavailable + {errorMessage || 'Try again in a minute.'} + +
+ ) : summary ? ( + + ) : null} +
+
+
+ ); +} + +function KiloPassReferralSummary({ summary }: { summary: KiloPassReferralRewardSummary }) { + return ( +
+
+

+ Reward summary +

+

+ Track pending referral bonuses and previous Kilo Pass referral reward history. +

+
+ + {summary.referrerCap.reached ? ( +
+
+
Cap reached
+
+ {summary.referrerCap.grantedRewards} of {summary.referrerCap.limit} referrer rewards + granted. Referee rewards do not count toward this cap. +
+
+
+ ) : null} + +
+ + 0 ? 'warning' : undefined} + /> + + + + +
+ +
+

+ Reward history +

+ {summary.rewards.length === 0 ? ( +
+ ) : ( +
+ {summary.rewards.map(reward => ( + + ))} +
+ )} +
+
+ ); +} + +type IndicatorTone = 'warning'; + +function SummaryTile({ + label, + value, + info, + indicator, +}: { + label: string; + value: string; + info?: string; + indicator?: IndicatorTone; +}) { + return ( +
+
+ {indicator === 'warning' ? ( +
+
+ {value} +
+
+ ); +} + +function RewardRow({ reward }: { reward: KiloPassReferralRewardSummary['rewards'][number] }) { + const status = rewardStatusPresentation(reward.status); + return ( +
+
+
{roleLabel(reward.role)}
+
+
+
+
+ + {status.label} + + + + {formatUsd(reward.rewardAmountUsd)} + +
+
+
+
+ {reward.appliedAt ? ( +
+
+ ) : reward.expiresAt ? ( +
+ Expires{' '} + + {formatIsoDateString_UsaDateOnlyFormat(reward.expiresAt)} + +
+ ) : reward.status === 'review_required' ? ( +
Support review required before this reward changes.
+ ) : ( +
Application details appear after the referral bonus is issued.
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/subscriptions/PersonalSubscriptions.tsx b/apps/web/src/components/subscriptions/PersonalSubscriptions.tsx index c8638ecba9..0250a140ad 100644 --- a/apps/web/src/components/subscriptions/PersonalSubscriptions.tsx +++ b/apps/web/src/components/subscriptions/PersonalSubscriptions.tsx @@ -12,9 +12,12 @@ import { KiloClawGroup } from './kiloclaw/KiloClawGroup'; import { CodingPlansGroup } from './coding-plans/CodingPlansGroup'; import { ENABLE_CODING_PLAN_SUBSCRIPTIONS } from '@/lib/constants'; +const defaultExpandedSections = ENABLE_CODING_PLAN_SUBSCRIPTIONS + ? ['kilo-pass', 'kiloclaw', 'coding-plans'] + : ['kilo-pass', 'kiloclaw']; + export function PersonalSubscriptions() { const [showTerminal, setShowTerminal] = useState(false); - const [expandedSection, setExpandedSection] = useState('kilo-pass'); const trpc = useTRPC(); const kiloPassQuery = useQuery(trpc.kiloPass.getState.queryOptions()); const kiloClawQuery = useQuery(trpc.kiloclaw.listPersonalSubscriptions.queryOptions()); @@ -41,13 +44,7 @@ export function PersonalSubscriptions() { ) : null } > - + {ENABLE_CODING_PLAN_SUBSCRIPTIONS ? ( diff --git a/apps/web/src/components/subscriptions/kilo-pass/KiloPassDetail.tsx b/apps/web/src/components/subscriptions/kilo-pass/KiloPassDetail.tsx index c2352203f6..c67ba2fbcd 100644 --- a/apps/web/src/components/subscriptions/kilo-pass/KiloPassDetail.tsx +++ b/apps/web/src/components/subscriptions/kilo-pass/KiloPassDetail.tsx @@ -21,6 +21,7 @@ import { cn } from '@/lib/utils'; import { useRawTRPCClient, useTRPC } from '@/lib/trpc/utils'; import { formatDollars, formatIsoDateString_UsaDateOnlyFormat } from '@/lib/utils'; import { DetailPageHeader } from '@/components/subscriptions/DetailPageHeader'; +import { KiloPassReferralButton } from '@/components/referrals/KiloPassReferralButton'; import { BillingHistoryTable } from '@/components/subscriptions/BillingHistoryTable'; import { CreditHistory } from './CreditHistory'; import { @@ -110,23 +111,10 @@ export function KiloPassDetail() { tier: subscription.tier, streakMonths: Math.max(1, subscription.currentStreakMonths), isFirstTimeSubscriberEver: subscription.isFirstTimeSubscriberEver, - subscriptionStartedAtIso: subscription.startedAt, }); return promoPercent === KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT; }, [subscription]); - const showSecondMonthPromoInDialog = useMemo(() => { - if (!subscription || subscription.cadence !== 'monthly') return false; - if (subscription.currentStreakMonths > 2) return false; - const month2Percent = computeMonthlyCadenceBonusPercent({ - tier: subscription.tier, - streakMonths: 2, - isFirstTimeSubscriberEver: subscription.isFirstTimeSubscriberEver, - subscriptionStartedAtIso: subscription.startedAt, - }); - return month2Percent === KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT; - }, [subscription]); - async function refreshData() { await Promise.all([ queryClient.invalidateQueries({ queryKey: trpc.kiloPass.getState.queryKey() }), @@ -189,6 +177,7 @@ export function KiloPassDetail() { backLabel="Back to subscriptions" title="Kilo Pass" status={subscriptionDisplay.status} + actions={isKiloPassTerminal(subscription.status) ? null : } /> {subscriptionDisplay.detailAlert ? ( @@ -262,9 +251,7 @@ export function KiloPassDetail() { diff --git a/apps/web/src/components/subscriptions/kilo-pass/KiloPassGroup.tsx b/apps/web/src/components/subscriptions/kilo-pass/KiloPassGroup.tsx index 1fed8b70d8..987dae2caf 100644 --- a/apps/web/src/components/subscriptions/kilo-pass/KiloPassGroup.tsx +++ b/apps/web/src/components/subscriptions/kilo-pass/KiloPassGroup.tsx @@ -5,8 +5,6 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { Crown } from 'lucide-react'; import { useTRPC } from '@/lib/trpc/utils'; -import { KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF } from '@/lib/kilo-pass/constants'; -import { dayjs } from '@/lib/kilo-pass/dayjs'; import { KiloPassCadence } from '@/lib/kilo-pass/enums'; import type { KiloPassTier } from '@/lib/kilo-pass/enums'; import { recommendKiloPassTierFromAverageMonthlyUsageUsd } from '@/lib/kilo-pass/recommend-tier'; @@ -27,12 +25,6 @@ import { getKiloPassSubscriptionDisplayModel, } from './KiloPassDetail.logic'; -function getShowKiloPassTwoMonthPromo(showFirstMonthPromo: boolean): boolean { - return ( - showFirstMonthPromo && dayjs().utc().isBefore(KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF) - ); -} - export function KiloPassGroup({ showTerminal, accordionValue, @@ -60,7 +52,7 @@ export function KiloPassGroup({ toast.error('Failed to create Stripe checkout session'); return; } - window.location.href = result.url; + window.location.assign(result.url); }, onError: error => { toast.error(error.message || 'Failed to start checkout'); @@ -73,7 +65,6 @@ export function KiloPassGroup({ } const showFirstMonthPromo = query.data?.isEligibleForFirstMonthPromo ?? false; - const showSecondMonthPromo = getShowKiloPassTwoMonthPromo(showFirstMonthPromo); const averageMonthlyUsageUsd = averageMonthlyUsageQuery.data?.averageMonthlyUsageUsd; const recommendedTier = typeof averageMonthlyUsageUsd === 'number' @@ -134,7 +125,6 @@ export function KiloPassGroup({ setCadence={setCadence} pending={checkout.isPending} showFirstMonthPromo={showFirstMonthPromo} - showSecondMonthPromo={showSecondMonthPromo} recommendedTier={recommendedTier} onSelectTier={tier => void startCheckout(tier)} showHeader={false} diff --git a/apps/web/src/lib/impact/kilo-pass-referrals.test.ts b/apps/web/src/lib/impact/kilo-pass-referrals.test.ts new file mode 100644 index 0000000000..d575219140 --- /dev/null +++ b/apps/web/src/lib/impact/kilo-pass-referrals.test.ts @@ -0,0 +1,1043 @@ +import { randomUUID } from 'crypto'; +import { eq } from 'drizzle-orm'; + +jest.mock('@/lib/impact', () => { + const actual = jest.requireActual('@/lib/impact'); + return { + ...actual, + isImpactConfigured: jest.fn(() => true), + sendImpactConversionPayload: jest.fn(async () => ({ ok: true, delivery: 'accepted' })), + }; +}); + +jest.mock('@/lib/impact/advocate', () => { + const actual = jest.requireActual('@/lib/impact/advocate'); + return { + ...actual, + isImpactAdvocateConfigured: jest.fn(() => true), + sendImpactAdvocateRewardLookupPayload: jest.fn(async () => ({ + ok: true, + statusCode: 200, + rewards: [{ id: 'impact-kilo-pass-reward', type: 'CREDIT', amount: 24.5, unit: 'USD' }], + responseBody: + '{"rewards":[{"id":"impact-kilo-pass-reward","type":"CREDIT","amount":24.5,"unit":"USD"}]}', + })), + sendImpactAdvocateRewardRedemptionPayload: jest.fn(async () => ({ + ok: true, + statusCode: 200, + responseBody: '{}', + })), + }; +}); + +jest.mock('@/lib/stripe-client', () => ({ + client: { + subscriptions: { + update: jest.fn(async () => ({})), + }, + }, +})); + +import { cleanupDbForTest, db } from '@/lib/drizzle'; +import type { isImpactConfigured, sendImpactConversionPayload } from '@/lib/impact'; +import type { + isImpactAdvocateConfigured, + sendImpactAdvocateRewardLookupPayload, + sendImpactAdvocateRewardRedemptionPayload, +} from '@/lib/impact/advocate'; +import { + expirePendingKiloPassReferralRewards, + markPersonalKiloPassReferralPaymentAdverse, + processPersonalKiloPassStripePaidConversion, +} from '@/lib/impact/kilo-pass-referrals'; +import { dispatchQueuedImpactAdvocateRewardRedemptions } from '@/lib/impact/kiloclaw-referrals'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { + deleted_user_email_tombstones, + impact_advocate_participants, + impact_advocate_reward_redemptions, + impact_attribution_touches, + impact_conversion_reports, + impact_referral_conversions, + impact_referral_reward_decisions, + impact_referral_rewards, + kilo_pass_issuances, + kilo_pass_subscriptions, + user_affiliate_attributions, +} from '@kilocode/db/schema'; +import { + ImpactAdvocateProgramKey, + ImpactAttributionTouchProvider, + ImpactAttributionTouchType, + ImpactConversionReportState, + ImpactReferralBeneficiaryRole, + ImpactReferralDecisionOutcome, + ImpactReferralPaymentProvider, + ImpactReferralProduct, + ImpactReferralRewardKind, + ImpactReferralRewardStatus, + ImpactReferralWinningTouchType, + KiloPassCadence, + KiloPassIssuanceSource, + KiloPassPaymentProvider, + KiloPassTier, + KiloPassWelcomePromoEligibilityReason, +} from '@kilocode/db/schema-types'; + +const impactMock = jest.requireMock('@/lib/impact') as { + isImpactConfigured: jest.MockedFunction; + sendImpactConversionPayload: jest.MockedFunction; +}; +const advocateMock = jest.requireMock('@/lib/impact/advocate') as { + isImpactAdvocateConfigured: jest.MockedFunction; + sendImpactAdvocateRewardLookupPayload: jest.MockedFunction< + typeof sendImpactAdvocateRewardLookupPayload + >; + sendImpactAdvocateRewardRedemptionPayload: jest.MockedFunction< + typeof sendImpactAdvocateRewardRedemptionPayload + >; +}; +const mockIsImpactConfigured = impactMock.isImpactConfigured; +const mockIsImpactAdvocateConfigured = advocateMock.isImpactAdvocateConfigured; +const mockSendImpactAdvocateRewardLookupPayload = + advocateMock.sendImpactAdvocateRewardLookupPayload; +const mockSendImpactAdvocateRewardRedemptionPayload = + advocateMock.sendImpactAdvocateRewardRedemptionPayload; +const mockSendImpactConversionPayload = impactMock.sendImpactConversionPayload; + +beforeEach(async () => { + await cleanupDbForTest(); + jest.clearAllMocks(); + mockIsImpactConfigured.mockReturnValue(true); + mockIsImpactAdvocateConfigured.mockReturnValue(true); + mockSendImpactConversionPayload.mockResolvedValue({ ok: true, delivery: 'accepted' }); + mockSendImpactAdvocateRewardLookupPayload.mockResolvedValue({ + ok: true, + statusCode: 200, + rewards: [{ id: 'impact-kilo-pass-reward', type: 'CREDIT', amount: 24.5, unit: 'USD' }], + responseBody: + '{"rewards":[{"id":"impact-kilo-pass-reward","type":"CREDIT","amount":24.5,"unit":"USD"}]}', + }); + mockSendImpactAdvocateRewardRedemptionPayload.mockResolvedValue({ + ok: true, + statusCode: 200, + responseBody: '{}', + }); +}); + +async function insertKiloPassSubscription(params: { + userId: string; + tier?: KiloPassTier; + cadence?: KiloPassCadence; + stripeSubscriptionId?: string; +}) { + const stripeSubscriptionId = params.stripeSubscriptionId ?? `sub_${randomUUID()}`; + const [subscription] = await db + .insert(kilo_pass_subscriptions) + .values({ + kilo_user_id: params.userId, + payment_provider: KiloPassPaymentProvider.Stripe, + provider_subscription_id: stripeSubscriptionId, + stripe_subscription_id: stripeSubscriptionId, + tier: params.tier ?? KiloPassTier.Tier49, + cadence: params.cadence ?? KiloPassCadence.Monthly, + status: 'active', + started_at: '2026-01-02T00:00:00.000Z', + }) + .returning({ id: kilo_pass_subscriptions.id }); + if (!subscription) throw new Error('Failed to insert Kilo Pass subscription'); + return subscription.id; +} + +async function seedCurrentIssuance(subscriptionId: string, invoiceId: string): Promise { + await db.insert(kilo_pass_issuances).values({ + kilo_pass_subscription_id: subscriptionId, + issue_month: '2026-01-01', + source: KiloPassIssuanceSource.StripeInvoice, + stripe_invoice_id: invoiceId, + }); +} + +async function insertParticipant(userId: string, referralCode = `code_${randomUUID()}`) { + await db.insert(impact_advocate_participants).values({ + program_key: ImpactAdvocateProgramKey.KiloPass, + user_id: userId, + advocate_id: `${userId}@example.com`, + advocate_account_id: `${userId}@example.com`, + contact_email: `${userId}@example.com`, + opaque_referral_identifier: referralCode, + registration_state: 'registered', + registered_at: '2026-01-01T00:00:00.000Z', + }); + return referralCode; +} + +async function insertTouch(params: { + userId: string; + type: 'referral' | 'affiliate'; + referralCode?: string; + touchedAt?: string; + saleAttributedAt?: string | null; +}) { + const touchedAt = params.touchedAt ?? '2026-01-01T00:00:00.000Z'; + const [touch] = await db + .insert(impact_attribution_touches) + .values({ + product: ImpactReferralProduct.KiloPass, + program_key: params.type === 'referral' ? ImpactAdvocateProgramKey.KiloPass : null, + dedupe_key: randomUUID(), + user_id: params.userId, + touch_type: + params.type === 'referral' + ? ImpactAttributionTouchType.Referral + : ImpactAttributionTouchType.Affiliate, + provider: + params.type === 'referral' + ? ImpactAttributionTouchProvider.ImpactAdvocate + : ImpactAttributionTouchProvider.ImpactPerformance, + opaque_tracking_value: + params.type === 'referral' ? 'opaque-referral-cookie' : 'impact-click-id', + tracking_value_length: 20, + is_tracking_value_accepted: true, + rs_code: params.type === 'referral' ? params.referralCode : null, + im_ref: params.type === 'affiliate' ? 'impact-click-id' : null, + touched_at: touchedAt, + expires_at: '2026-01-31T00:00:00.000Z', + sale_attributed_at: params.saleAttributedAt ?? null, + }) + .returning({ id: impact_attribution_touches.id }); + if (!touch) throw new Error('Failed to insert touch'); + return touch.id; +} + +async function processInvoice(params: { + refereeId: string; + subscriptionId: string; + invoiceId?: string; + tier?: KiloPassTier; + cadence?: KiloPassCadence; + amount?: number; + welcomePromoEligibilityReason?: KiloPassWelcomePromoEligibilityReason; +}) { + const invoiceId = params.invoiceId ?? `inv_${randomUUID()}`; + await seedCurrentIssuance(params.subscriptionId, invoiceId); + return await processPersonalKiloPassStripePaidConversion({ + userId: params.refereeId, + kiloPassSubscriptionId: params.subscriptionId, + sourcePaymentId: invoiceId, + orderId: invoiceId, + amount: params.amount ?? 49, + currencyCode: 'usd', + itemCategory: 'kilo-pass-tier-49-monthly', + itemName: 'Kilo Pass Tier 49 Monthly', + itemSku: 'price_kilo_pass_49_monthly', + sourceTier: params.tier ?? KiloPassTier.Tier49, + cadence: params.cadence ?? KiloPassCadence.Monthly, + welcomePromoEligibilityReason: params.welcomePromoEligibilityReason, + convertedAt: new Date('2026-01-03T00:00:00.000Z'), + }); +} + +async function seedKiloPassReferralRewardsForAdversePayment(params: { + invoiceId?: string; + statuses: Array<(typeof ImpactReferralRewardStatus)[keyof typeof ImpactReferralRewardStatus]>; +}) { + const invoiceId = params.invoiceId ?? `inv_adverse_${randomUUID()}`; + const referrer = await insertTestUser({ created_at: '2025-12-01T00:00:00.000Z' }); + const referee = await insertTestUser({ created_at: '2026-01-02T00:00:00.000Z' }); + const [conversion] = await db + .insert(impact_referral_conversions) + .values({ + product: ImpactReferralProduct.KiloPass, + referee_user_id: referee.id, + referrer_user_id: referrer.id, + winning_touch_type: ImpactReferralWinningTouchType.Referral, + payment_provider: ImpactReferralPaymentProvider.Stripe, + source_payment_id: invoiceId, + qualified: true, + converted_at: '2026-01-03T00:00:00.000Z', + }) + .returning({ id: impact_referral_conversions.id }); + if (!conversion) throw new Error('Failed to insert conversion'); + + const roles = [ImpactReferralBeneficiaryRole.Referee, ImpactReferralBeneficiaryRole.Referrer]; + const beneficiaries = [referee.id, referrer.id]; + const rewards = []; + for (const [index, status] of params.statuses.entries()) { + const [decision] = await db + .insert(impact_referral_reward_decisions) + .values({ + product: ImpactReferralProduct.KiloPass, + conversion_id: conversion.id, + beneficiary_user_id: beneficiaries[index] ?? referee.id, + beneficiary_role: roles[index] ?? ImpactReferralBeneficiaryRole.Referee, + outcome: ImpactReferralDecisionOutcome.Granted, + reward_kind: ImpactReferralRewardKind.KiloPassBonus, + months_granted: 0, + reward_percent: 0.5, + source_tier: KiloPassTier.Tier49, + reward_amount_usd: 24.5, + }) + .returning({ id: impact_referral_reward_decisions.id }); + if (!decision) throw new Error('Failed to insert decision'); + + const [reward] = await db + .insert(impact_referral_rewards) + .values({ + product: ImpactReferralProduct.KiloPass, + conversion_id: conversion.id, + decision_id: decision.id, + beneficiary_user_id: beneficiaries[index] ?? referee.id, + beneficiary_role: roles[index] ?? ImpactReferralBeneficiaryRole.Referee, + reward_kind: ImpactReferralRewardKind.KiloPassBonus, + months_granted: 0, + reward_percent: 0.5, + source_tier: KiloPassTier.Tier49, + reward_amount_usd: 24.5, + status, + earned_at: '2026-01-03T00:00:00.000Z', + applied_at: + status === ImpactReferralRewardStatus.Applied ? '2026-02-01T00:00:00.000Z' : null, + expires_at: '2027-01-03T00:00:00.000Z', + }) + .returning({ id: impact_referral_rewards.id }); + if (!reward) throw new Error('Failed to insert reward'); + rewards.push(reward); + } + + return { invoiceId, conversionId: conversion.id, rewardIds: rewards.map(reward => reward.id) }; +} + +describe('Kilo Pass Impact referral conversions', () => { + test('referral winner grants double-sided pending rewards, queues Impact SALE and reward redemptions, and suppresses affiliate SALE', async () => { + const referrer = await insertTestUser({ + google_user_email: 'referrer@example.com', + normalized_email: 'referrer@example.com', + created_at: '2025-12-01T00:00:00.000Z', + }); + const referee = await insertTestUser({ + google_user_email: 'referee@example.com', + created_at: '2026-01-02T00:00:00.000Z', + normalized_email: 'referee@example.com', + }); + const referralCode = await insertParticipant(referrer.id); + await insertTouch({ + userId: referee.id, + type: 'affiliate', + touchedAt: '2026-01-01T00:00:00.000Z', + }); + await insertTouch({ + userId: referee.id, + type: 'referral', + referralCode, + touchedAt: '2026-01-01T01:00:00.000Z', + }); + const subscriptionId = await insertKiloPassSubscription({ userId: referee.id }); + + const disposition = await processInvoice({ refereeId: referee.id, subscriptionId }); + + expect(disposition).toEqual( + expect.objectContaining({ + shouldEnqueueAffiliateSale: false, + winningTouchType: ImpactReferralWinningTouchType.Referral, + disqualificationReason: null, + }) + ); + + const rewards = await db + .select() + .from(impact_referral_rewards) + .where(eq(impact_referral_rewards.conversion_id, disposition.conversionId ?? '')); + expect(rewards).toHaveLength(2); + expect(rewards).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + product: ImpactReferralProduct.KiloPass, + beneficiary_user_id: referee.id, + beneficiary_role: ImpactReferralBeneficiaryRole.Referee, + reward_kind: ImpactReferralRewardKind.KiloPassBonus, + reward_amount_usd: 24.5, + status: ImpactReferralRewardStatus.Pending, + }), + expect.objectContaining({ + product: ImpactReferralProduct.KiloPass, + beneficiary_user_id: referrer.id, + beneficiary_role: ImpactReferralBeneficiaryRole.Referrer, + reward_amount_usd: 24.5, + status: ImpactReferralRewardStatus.Pending, + }), + ]) + ); + + const redemptions = await db.select().from(impact_advocate_reward_redemptions); + expect(redemptions).toHaveLength(2); + expect(redemptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + beneficiary_user_id: referee.id, + state: 'queued', + request_payload: expect.objectContaining({ + programKey: ImpactAdvocateProgramKey.KiloPass, + lookup: expect.objectContaining({ + accountId: 'referee@example.com', + userId: 'referee@example.com', + rewardTypeFilter: 'CREDIT', + }), + redemption: { amount: 24.5, unit: 'USD' }, + }), + }), + expect.objectContaining({ + beneficiary_user_id: referrer.id, + state: 'queued', + request_payload: expect.objectContaining({ + programKey: ImpactAdvocateProgramKey.KiloPass, + lookup: expect.objectContaining({ + accountId: 'referrer@example.com', + userId: 'referrer@example.com', + rewardTypeFilter: 'CREDIT', + }), + redemption: { amount: 24.5, unit: 'USD' }, + }), + }), + ]) + ); + + const redemptionSummary = await dispatchQueuedImpactAdvocateRewardRedemptions(); + expect(redemptionSummary).toEqual({ claimed: 2, redeemed: 2, retried: 0, failed: 0 }); + expect(mockSendImpactAdvocateRewardLookupPayload).toHaveBeenCalledWith( + expect.objectContaining({ accountId: 'referee@example.com' }), + { programKey: ImpactAdvocateProgramKey.KiloPass } + ); + expect(mockSendImpactAdvocateRewardLookupPayload).toHaveBeenCalledWith( + expect.objectContaining({ accountId: 'referrer@example.com' }), + { programKey: ImpactAdvocateProgramKey.KiloPass } + ); + expect(mockSendImpactAdvocateRewardRedemptionPayload).toHaveBeenCalledWith( + { rewardId: 'impact-kilo-pass-reward', amount: 24.5, unit: 'USD' }, + { programKey: ImpactAdvocateProgramKey.KiloPass } + ); + + const report = await db.query.impact_conversion_reports.findFirst({ + where: eq(impact_conversion_reports.conversion_id, disposition.conversionId ?? ''), + }); + expect(report).toEqual( + expect.objectContaining({ + action_tracker_id: 71659, + order_id: expect.stringMatching(/^inv_/), + state: ImpactConversionReportState.Delivered, + request_payload: expect.objectContaining({ + ItemCategory1: 'kilo-pass-tier-49-monthly', + ItemSubTotal1: '49.00', + }), + }) + ); + }); + + test('affiliate sale-attributed before referral wins and is marked sale-attributed for Kilo Pass', async () => { + const referrer = await insertTestUser(); + const referee = await insertTestUser({ created_at: '2026-01-02T00:00:00.000Z' }); + const referralCode = await insertParticipant(referrer.id); + const affiliateTouchId = await insertTouch({ + userId: referee.id, + type: 'affiliate', + touchedAt: '2026-01-01T00:00:00.000Z', + saleAttributedAt: '2026-01-01T00:30:00.000Z', + }); + await insertTouch({ + userId: referee.id, + type: 'referral', + referralCode, + touchedAt: '2026-01-01T01:00:00.000Z', + }); + const subscriptionId = await insertKiloPassSubscription({ userId: referee.id }); + + const disposition = await processInvoice({ refereeId: referee.id, subscriptionId }); + + expect(disposition).toEqual( + expect.objectContaining({ + shouldEnqueueAffiliateSale: true, + winningTouchType: ImpactReferralWinningTouchType.Affiliate, + disqualificationReason: 'referral_affiliate_won', + }) + ); + const touch = await db.query.impact_attribution_touches.findFirst({ + where: eq(impact_attribution_touches.id, affiliateTouchId), + }); + expect(new Date(touch?.sale_attributed_at ?? '').toISOString()).toBe( + '2026-01-01T00:30:00.000Z' + ); + }); + + test('only affiliate attribution preserves affiliate SALE and creates no referral rewards', async () => { + const referee = await insertTestUser({ created_at: '2026-01-02T00:00:00.000Z' }); + const affiliateTouchId = await insertTouch({ userId: referee.id, type: 'affiliate' }); + const subscriptionId = await insertKiloPassSubscription({ userId: referee.id }); + + const disposition = await processInvoice({ refereeId: referee.id, subscriptionId }); + + expect(disposition).toEqual( + expect.objectContaining({ + shouldEnqueueAffiliateSale: true, + winningTouchType: ImpactReferralWinningTouchType.Affiliate, + disqualificationReason: 'referral_affiliate_won', + }) + ); + expect(await db.select().from(impact_referral_rewards)).toHaveLength(0); + const touch = await db.query.impact_attribution_touches.findFirst({ + where: eq(impact_attribution_touches.id, affiliateTouchId), + }); + expect(new Date(touch?.sale_attributed_at ?? '').toISOString()).toBe( + '2026-01-03T00:00:00.000Z' + ); + }); + + test('historical affiliate attribution without product-scoped touch preserves affiliate SALE', async () => { + const referee = await insertTestUser({ created_at: '2026-01-02T00:00:00.000Z' }); + await db.insert(user_affiliate_attributions).values({ + user_id: referee.id, + provider: 'impact', + tracking_id: '', + }); + const subscriptionId = await insertKiloPassSubscription({ userId: referee.id }); + + const disposition = await processInvoice({ refereeId: referee.id, subscriptionId }); + + expect(disposition).toEqual( + expect.objectContaining({ + shouldEnqueueAffiliateSale: true, + winningTouchType: ImpactReferralWinningTouchType.Affiliate, + disqualificationReason: 'referral_affiliate_won', + }) + ); + expect(await db.select().from(impact_referral_rewards)).toHaveLength(0); + }); + + test('missing attribution and expired product-scoped touches suppress affiliate SALE reporting', async () => { + const noTouchReferee = await insertTestUser({ created_at: '2026-01-02T00:00:00.000Z' }); + const noTouchSubscriptionId = await insertKiloPassSubscription({ + userId: noTouchReferee.id, + }); + + const noTouchDisposition = await processInvoice({ + refereeId: noTouchReferee.id, + subscriptionId: noTouchSubscriptionId, + }); + + expect(noTouchDisposition).toEqual( + expect.objectContaining({ + shouldEnqueueAffiliateSale: false, + winningTouchType: ImpactReferralWinningTouchType.None, + disqualificationReason: 'referral_no_valid_attribution', + }) + ); + + await cleanupDbForTest(); + const expiredAffiliateReferee = await insertTestUser({ + created_at: '2026-01-02T00:00:00.000Z', + }); + await db.insert(user_affiliate_attributions).values({ + user_id: expiredAffiliateReferee.id, + provider: 'impact', + tracking_id: 'historical-affiliate-click', + }); + await insertTouch({ + userId: expiredAffiliateReferee.id, + type: 'affiliate', + touchedAt: '2025-12-01T00:00:00.000Z', + }); + const expiredAffiliateSubscriptionId = await insertKiloPassSubscription({ + userId: expiredAffiliateReferee.id, + }); + + const expiredAffiliateDisposition = await processInvoice({ + refereeId: expiredAffiliateReferee.id, + subscriptionId: expiredAffiliateSubscriptionId, + }); + + expect(expiredAffiliateDisposition).toEqual( + expect.objectContaining({ + shouldEnqueueAffiliateSale: false, + winningTouchType: ImpactReferralWinningTouchType.None, + disqualificationReason: 'referral_no_valid_attribution', + }) + ); + + await cleanupDbForTest(); + const expiredReferralReferee = await insertTestUser({ + created_at: '2026-01-02T00:00:00.000Z', + }); + await db.insert(user_affiliate_attributions).values({ + user_id: expiredReferralReferee.id, + provider: 'impact', + tracking_id: 'historical-affiliate-click', + }); + await insertTouch({ + userId: expiredReferralReferee.id, + type: 'referral', + touchedAt: '2025-12-01T00:00:00.000Z', + }); + const expiredReferralSubscriptionId = await insertKiloPassSubscription({ + userId: expiredReferralReferee.id, + }); + + const expiredReferralDisposition = await processInvoice({ + refereeId: expiredReferralReferee.id, + subscriptionId: expiredReferralSubscriptionId, + }); + + expect(expiredReferralDisposition).toEqual( + expect.objectContaining({ + shouldEnqueueAffiliateSale: false, + winningTouchType: ImpactReferralWinningTouchType.None, + disqualificationReason: 'referral_no_valid_attribution', + }) + ); + }); + + test('only referral attribution grants double-sided pending rewards', async () => { + const referrer = await insertTestUser({ created_at: '2025-12-01T00:00:00.000Z' }); + const referee = await insertTestUser({ created_at: '2026-01-02T00:00:00.000Z' }); + const referralCode = await insertParticipant(referrer.id); + await insertTouch({ userId: referee.id, type: 'referral', referralCode }); + const subscriptionId = await insertKiloPassSubscription({ userId: referee.id }); + + const disposition = await processInvoice({ refereeId: referee.id, subscriptionId }); + + expect(disposition).toEqual( + expect.objectContaining({ + shouldEnqueueAffiliateSale: false, + winningTouchType: ImpactReferralWinningTouchType.Referral, + disqualificationReason: null, + }) + ); + const rewards = await db + .select() + .from(impact_referral_rewards) + .where(eq(impact_referral_rewards.conversion_id, disposition.conversionId ?? '')); + expect(rewards).toHaveLength(2); + expect(rewards).toEqual( + expect.arrayContaining([ + expect.objectContaining({ beneficiary_user_id: referee.id }), + expect.objectContaining({ beneficiary_user_id: referrer.id }), + ]) + ); + }); + + test('reused payment fingerprint does not grant referral rewards', async () => { + const referrer = await insertTestUser({ created_at: '2025-12-01T00:00:00.000Z' }); + const referee = await insertTestUser({ created_at: '2026-01-02T00:00:00.000Z' }); + const referralCode = await insertParticipant(referrer.id); + await insertTouch({ userId: referee.id, type: 'referral', referralCode }); + const subscriptionId = await insertKiloPassSubscription({ userId: referee.id }); + + const disposition = await processInvoice({ + refereeId: referee.id, + subscriptionId, + welcomePromoEligibilityReason: + KiloPassWelcomePromoEligibilityReason.FingerprintPreviouslyClaimed, + }); + + expect(disposition).toEqual( + expect.objectContaining({ + shouldEnqueueAffiliateSale: false, + winningTouchType: ImpactReferralWinningTouchType.Referral, + disqualificationReason: 'referral_payment_fingerprint_previously_claimed', + }) + ); + expect(await db.select().from(impact_referral_rewards)).toHaveLength(0); + }); + + test('renewal, prior subscription, deleted tombstone, and self-referral do not grant rewards', async () => { + const cases = ['renewal', 'prior_subscription', 'deleted_tombstone', 'self_referral'] as const; + + for (const scenario of cases) { + await cleanupDbForTest(); + const referrer = await insertTestUser({ created_at: '2025-12-01T00:00:00.000Z' }); + const referee = + scenario === 'self_referral' + ? referrer + : await insertTestUser({ + created_at: '2026-01-02T00:00:00.000Z', + normalized_email: `${scenario}@example.com`, + }); + const referralCode = await insertParticipant(referrer.id); + await insertTouch({ userId: referee.id, type: 'referral', referralCode }); + const subscriptionId = await insertKiloPassSubscription({ userId: referee.id }); + if (scenario === 'renewal') { + await db.insert(kilo_pass_issuances).values({ + kilo_pass_subscription_id: subscriptionId, + issue_month: '2025-12-01', + source: KiloPassIssuanceSource.StripeInvoice, + stripe_invoice_id: `inv_prior_${randomUUID()}`, + }); + } + if (scenario === 'prior_subscription') { + await insertKiloPassSubscription({ userId: referee.id }); + } + if (scenario === 'deleted_tombstone') { + await db.insert(deleted_user_email_tombstones).values({ + normalized_email_hash: '3c19ee1212333d8548ac77b54240971338dd8e4c3d5b6723b1c219666e74eac3', + }); + } + + const disposition = await processInvoice({ refereeId: referee.id, subscriptionId }); + const rewards = await db.select().from(impact_referral_rewards); + expect({ scenario, rewardCount: rewards.length }).toEqual({ scenario, rewardCount: 0 }); + expect(disposition.winningTouchType).toBe(ImpactReferralWinningTouchType.Referral); + expect(disposition.disqualificationReason).toMatch(/^referral_/); + } + }); + + test('referrer cap limits only referrer reward and invoice retry is idempotent', async () => { + const referrer = await insertTestUser({ created_at: '2025-12-01T00:00:00.000Z' }); + const referee = await insertTestUser({ created_at: '2026-01-02T00:00:00.000Z' }); + const referralCode = await insertParticipant(referrer.id); + for (let i = 0; i < 5; i++) { + const [conversion] = await db + .insert(impact_referral_conversions) + .values({ + product: ImpactReferralProduct.KiloPass, + referee_user_id: referee.id, + referrer_user_id: referrer.id, + winning_touch_type: ImpactReferralWinningTouchType.Referral, + payment_provider: ImpactReferralPaymentProvider.Stripe, + source_payment_id: `seed_inv_${i}`, + qualified: true, + converted_at: '2025-12-15T00:00:00.000Z', + }) + .returning({ id: impact_referral_conversions.id }); + if (!conversion) throw new Error('seed conversion missing'); + await db.insert(impact_referral_reward_decisions).values({ + product: ImpactReferralProduct.KiloPass, + conversion_id: conversion.id, + beneficiary_user_id: referrer.id, + beneficiary_role: ImpactReferralBeneficiaryRole.Referrer, + outcome: ImpactReferralDecisionOutcome.Granted, + reward_kind: ImpactReferralRewardKind.KiloPassBonus, + months_granted: 0, + }); + } + await insertTouch({ userId: referee.id, type: 'referral', referralCode }); + const subscriptionId = await insertKiloPassSubscription({ userId: referee.id }); + const invoiceId = `inv_${randomUUID()}`; + + const first = await processInvoice({ refereeId: referee.id, subscriptionId, invoiceId }); + const second = await processPersonalKiloPassStripePaidConversion({ + userId: referee.id, + kiloPassSubscriptionId: subscriptionId, + sourcePaymentId: invoiceId, + orderId: invoiceId, + amount: 49, + currencyCode: 'usd', + itemCategory: 'kilo-pass-tier-49-monthly', + itemName: 'Kilo Pass Tier 49 Monthly', + sourceTier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + convertedAt: new Date('2026-01-03T00:00:00.000Z'), + }); + + expect(second.conversionId).toBe(first.conversionId); + const decisions = await db + .select() + .from(impact_referral_reward_decisions) + .where(eq(impact_referral_reward_decisions.conversion_id, first.conversionId ?? '')); + expect(decisions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + beneficiary_role: ImpactReferralBeneficiaryRole.Referee, + outcome: ImpactReferralDecisionOutcome.Granted, + }), + expect.objectContaining({ + beneficiary_role: ImpactReferralBeneficiaryRole.Referrer, + outcome: ImpactReferralDecisionOutcome.CapLimited, + }), + ]) + ); + const rewards = await db + .select() + .from(impact_referral_rewards) + .where(eq(impact_referral_rewards.conversion_id, first.conversionId ?? '')); + expect(rewards).toHaveLength(1); + expect(rewards[0]?.beneficiary_user_id).toBe(referee.id); + expect(mockSendImpactConversionPayload).toHaveBeenCalledTimes(1); + }); + + test('missing configuration fails closed and Impact network failures leave retryable reports', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + const referrer = await insertTestUser({ created_at: '2025-12-01T00:00:00.000Z' }); + const missingConfigReferee = await insertTestUser({ created_at: '2026-01-02T00:00:00.000Z' }); + const referralCode = await insertParticipant(referrer.id); + await insertTouch({ userId: missingConfigReferee.id, type: 'referral', referralCode }); + const missingConfigSubscriptionId = await insertKiloPassSubscription({ + userId: missingConfigReferee.id, + }); + mockIsImpactAdvocateConfigured.mockReturnValue(false); + + const missingConfigDisposition = await processInvoice({ + refereeId: missingConfigReferee.id, + subscriptionId: missingConfigSubscriptionId, + }); + + expect(missingConfigDisposition.disqualificationReason).toBe('referral_missing_configuration'); + expect(await db.select().from(impact_referral_rewards)).toHaveLength(0); + const failedReport = await db.query.impact_conversion_reports.findFirst({ + where: eq( + impact_conversion_reports.conversion_id, + missingConfigDisposition.conversionId ?? '' + ), + }); + expect(failedReport?.state).toBe(ImpactConversionReportState.Failed); + consoleErrorSpy.mockRestore(); + + await cleanupDbForTest(); + mockIsImpactAdvocateConfigured.mockReturnValue(true); + mockSendImpactConversionPayload.mockResolvedValue({ + ok: false, + failureKind: 'network', + error: 'network down', + }); + const retryReferrer = await insertTestUser({ created_at: '2025-12-01T00:00:00.000Z' }); + const retryReferee = await insertTestUser({ created_at: '2026-01-02T00:00:00.000Z' }); + const retryReferralCode = await insertParticipant(retryReferrer.id); + await insertTouch({ + userId: retryReferee.id, + type: 'referral', + referralCode: retryReferralCode, + }); + const retrySubscriptionId = await insertKiloPassSubscription({ userId: retryReferee.id }); + + const retryDisposition = await processInvoice({ + refereeId: retryReferee.id, + subscriptionId: retrySubscriptionId, + }); + + const retryReport = await db.query.impact_conversion_reports.findFirst({ + where: eq(impact_conversion_reports.conversion_id, retryDisposition.conversionId ?? ''), + }); + expect(retryReport?.state).toBe(ImpactConversionReportState.Retrying); + expect(await db.select().from(impact_referral_rewards)).toHaveLength(2); + }); + + test('redemption dispatcher backfills missing Kilo Pass reward redemption rows', async () => { + const { rewardIds } = await seedKiloPassReferralRewardsForAdversePayment({ + statuses: [ImpactReferralRewardStatus.Pending, ImpactReferralRewardStatus.Earned], + }); + + expect(await db.select().from(impact_advocate_reward_redemptions)).toHaveLength(0); + + const summary = await dispatchQueuedImpactAdvocateRewardRedemptions(); + + expect(summary).toEqual({ claimed: 2, redeemed: 2, retried: 0, failed: 0 }); + const redemptions = await db.select().from(impact_advocate_reward_redemptions); + expect(redemptions).toEqual( + expect.arrayContaining( + rewardIds.map(rewardId => + expect.objectContaining({ + reward_id: rewardId, + state: 'redeemed', + request_payload: expect.objectContaining({ + programKey: ImpactAdvocateProgramKey.KiloPass, + redemption: { amount: 24.5, unit: 'USD' }, + }), + }) + ) + ) + ); + }); + + test('expires stale pending and earned Kilo Pass referral rewards independently of issuance', async () => { + const { rewardIds } = await seedKiloPassReferralRewardsForAdversePayment({ + statuses: [ImpactReferralRewardStatus.Pending, ImpactReferralRewardStatus.Earned], + }); + await db + .update(impact_referral_rewards) + .set({ expires_at: '2026-01-02T00:00:00.000Z' }) + .where(eq(impact_referral_rewards.id, rewardIds[0] ?? '')); + await db + .update(impact_referral_rewards) + .set({ expires_at: '2026-01-02T00:00:00.000Z' }) + .where(eq(impact_referral_rewards.id, rewardIds[1] ?? '')); + + const firstSummary = await expirePendingKiloPassReferralRewards({ + now: new Date('2026-01-03T00:00:00.000Z'), + }); + const retrySummary = await expirePendingKiloPassReferralRewards({ + now: new Date('2026-01-03T00:00:00.000Z'), + }); + + expect(firstSummary).toEqual({ expiredRewards: 2 }); + expect(retrySummary).toEqual({ expiredRewards: 0 }); + const rewards = await db + .select({ + status: impact_referral_rewards.status, + reviewReason: impact_referral_rewards.review_reason, + reversedAt: impact_referral_rewards.reversed_at, + }) + .from(impact_referral_rewards); + expect( + rewards.map(reward => ({ + ...reward, + reversedAt: new Date(reward.reversedAt ?? '').toISOString(), + })) + ).toEqual([ + { + status: ImpactReferralRewardStatus.Expired, + reviewReason: 'expired_kilo_pass_referral_reward', + reversedAt: '2026-01-03T00:00:00.000Z', + }, + { + status: ImpactReferralRewardStatus.Expired, + reviewReason: 'expired_kilo_pass_referral_reward', + reversedAt: '2026-01-03T00:00:00.000Z', + }, + ]); + }); + + test('adverse Stripe payment cancels pending and earned Kilo Pass referral rewards idempotently', async () => { + const { invoiceId, conversionId } = await seedKiloPassReferralRewardsForAdversePayment({ + statuses: [ImpactReferralRewardStatus.Pending, ImpactReferralRewardStatus.Earned], + }); + + const firstSummary = await markPersonalKiloPassReferralPaymentAdverse({ + sourcePaymentId: invoiceId, + reason: 'refund', + occurredAt: new Date('2026-01-10T00:00:00.000Z'), + }); + + expect(firstSummary).toEqual({ + conversionId, + canceledRewards: 2, + reviewRequiredRewards: 0, + }); + const rewardsAfterFirst = await db + .select({ + status: impact_referral_rewards.status, + reviewReason: impact_referral_rewards.review_reason, + reversedAt: impact_referral_rewards.reversed_at, + }) + .from(impact_referral_rewards) + .where(eq(impact_referral_rewards.conversion_id, conversionId)); + expect( + rewardsAfterFirst.map(reward => ({ + ...reward, + reversedAt: new Date(reward.reversedAt ?? '').toISOString(), + })) + ).toEqual([ + { + status: ImpactReferralRewardStatus.Canceled, + reviewReason: 'referral_payment_refund', + reversedAt: '2026-01-10T00:00:00.000Z', + }, + { + status: ImpactReferralRewardStatus.Canceled, + reviewReason: 'referral_payment_refund', + reversedAt: '2026-01-10T00:00:00.000Z', + }, + ]); + + const retrySummary = await markPersonalKiloPassReferralPaymentAdverse({ + sourcePaymentId: invoiceId, + reason: 'refund', + occurredAt: new Date('2026-01-10T00:00:00.000Z'), + }); + + expect(retrySummary).toEqual({ + conversionId, + canceledRewards: 0, + reviewRequiredRewards: 0, + }); + }); + + test('adverse Stripe dispute moves applied Kilo Pass referral rewards to support review without clawback', async () => { + const { invoiceId, conversionId, rewardIds } = + await seedKiloPassReferralRewardsForAdversePayment({ + statuses: [ImpactReferralRewardStatus.Applied], + }); + + const summary = await markPersonalKiloPassReferralPaymentAdverse({ + sourcePaymentId: invoiceId, + reason: 'chargeback', + occurredAt: new Date('2026-01-12T00:00:00.000Z'), + }); + + expect(summary).toEqual({ + conversionId, + canceledRewards: 0, + reviewRequiredRewards: 1, + }); + const reward = await db.query.impact_referral_rewards.findFirst({ + where: eq(impact_referral_rewards.id, rewardIds[0] ?? ''), + }); + expect(reward).toEqual( + expect.objectContaining({ + status: ImpactReferralRewardStatus.ReviewRequired, + review_reason: 'referral_payment_chargeback', + }) + ); + expect(new Date(reward?.reversed_at ?? '').toISOString()).toBe('2026-01-12T00:00:00.000Z'); + expect(new Date(reward?.applied_at ?? '').toISOString()).toBe('2026-02-01T00:00:00.000Z'); + }); + + test('Kilo Pass adverse payment lookup is scoped to Stripe invoice conversion identity', async () => { + await seedKiloPassReferralRewardsForAdversePayment({ + invoiceId: 'shared-payment-id', + statuses: [ImpactReferralRewardStatus.Pending], + }); + const otherUser = await insertTestUser(); + const [creditsConversion] = await db + .insert(impact_referral_conversions) + .values({ + product: ImpactReferralProduct.KiloPass, + referee_user_id: otherUser.id, + winning_touch_type: ImpactReferralWinningTouchType.Referral, + payment_provider: ImpactReferralPaymentProvider.Credits, + source_payment_id: 'shared-payment-id', + qualified: true, + converted_at: '2026-01-03T00:00:00.000Z', + }) + .returning({ id: impact_referral_conversions.id }); + + const summary = await markPersonalKiloPassReferralPaymentAdverse({ + sourcePaymentId: 'shared-payment-id', + reason: 'fraud', + occurredAt: new Date('2026-01-14T00:00:00.000Z'), + }); + + expect(summary.conversionId).not.toBe(creditsConversion?.id); + const rewards = await db.select().from(impact_referral_rewards); + expect(rewards).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: ImpactReferralRewardStatus.Canceled, + review_reason: 'referral_payment_fraud', + }), + ]) + ); + }); + + test('annual invoices are ineligible for referral rewards', async () => { + const referrer = await insertTestUser({ created_at: '2025-12-01T00:00:00.000Z' }); + const referee = await insertTestUser({ created_at: '2026-01-02T00:00:00.000Z' }); + const referralCode = await insertParticipant(referrer.id); + await insertTouch({ userId: referee.id, type: 'referral', referralCode }); + const subscriptionId = await insertKiloPassSubscription({ + userId: referee.id, + cadence: KiloPassCadence.Yearly, + }); + + const disposition = await processInvoice({ + refereeId: referee.id, + subscriptionId, + cadence: KiloPassCadence.Yearly, + amount: 588, + }); + + expect(disposition.disqualificationReason).toBe('referral_non_monthly_kilo_pass_subscription'); + expect( + await db + .select() + .from(impact_referral_rewards) + .where(eq(impact_referral_rewards.conversion_id, disposition.conversionId ?? '')) + ).toHaveLength(0); + }); +}); diff --git a/apps/web/src/lib/impact/kilo-pass-referrals.ts b/apps/web/src/lib/impact/kilo-pass-referrals.ts new file mode 100644 index 0000000000..d9b0118e13 --- /dev/null +++ b/apps/web/src/lib/impact/kilo-pass-referrals.ts @@ -0,0 +1,975 @@ +import 'server-only'; + +import { addMonths } from 'date-fns'; +import { and, asc, count, eq, inArray, isNull, lte, ne, sql } from 'drizzle-orm'; + +import { db, type DrizzleTransaction } from '@/lib/drizzle'; +import { + IMPACT_ACTION_TRACKER_IDS, + buildSalePayload, + hashEmailForImpact, + isImpactConfigured, +} from '@/lib/impact'; +import { isImpactAdvocateConfigured } from '@/lib/impact/advocate'; +import { + dispatchImpactConversionReportById, + queueImpactAdvocateRewardRedemption, + resolveWinningAttributionTouch, + type AdverseReferralPaymentReason, +} from '@/lib/impact/kiloclaw-referrals'; +import { hashNormalizedEmailForDeletionTombstone } from '@/lib/impact/referral'; +import { logImpactReferralDebug } from '@/lib/impact/debug'; +import { KILO_PASS_TIER_CONFIG } from '@/lib/kilo-pass/constants'; +import { + deleted_user_email_tombstones, + impact_advocate_participants, + impact_attribution_touches, + impact_conversion_reports, + impact_referral_conversions, + impact_referral_reward_decisions, + impact_referral_rewards, + impact_referrals, + kilo_pass_issuances, + kilo_pass_subscriptions, + kilocode_users, + user_affiliate_attributions, + type ImpactAttributionTouch, +} from '@kilocode/db/schema'; +import { + ImpactAdvocateProgramKey, + ImpactAttributionTouchType, + ImpactConversionReportState, + ImpactReferralBeneficiaryRole, + ImpactReferralDecisionOutcome, + ImpactReferralPaymentProvider, + ImpactReferralProduct, + ImpactReferralRewardKind, + ImpactReferralRewardStatus, + ImpactReferralWinningTouchType, + KiloPassCadence, + KiloPassWelcomePromoEligibilityReason, + type KiloPassTier, +} from '@kilocode/db/schema-types'; + +type DatabaseClient = typeof db | DrizzleTransaction; + +export type KiloPassPaidConversionDisposition = { + shouldEnqueueAffiliateSale: boolean; + winningTouchType: 'referral' | 'affiliate' | 'none'; + conversionId: string | null; + disqualificationReason: string | null; +}; + +export type KiloPassAdverseReferralPaymentSummary = { + conversionId: string | null; + canceledRewards: number; + reviewRequiredRewards: number; +}; + +export type KiloPassReferralRewardExpirationSummary = { + expiredRewards: number; +}; + +export const KILO_PASS_REFERRER_REWARD_CAP = 5; +const KILO_PASS_REFERRAL_REWARD_PERCENT = 0.5; +const SIGNUP_REFERRAL_TOUCH_CAPTURE_GRACE_MS = 10 * 60 * 1000; + +function referralDisqualificationReason(reason: string): string { + return `referral_${reason}`; +} + +function getAdversePaymentReason(reason: AdverseReferralPaymentReason): string { + return `referral_payment_${reason}`; +} + +function getKiloPassReferralConfigurationState() { + const impactPerformanceConfigured = isImpactConfigured(); + const impactAdvocateConfigured = isImpactAdvocateConfigured({ + product: ImpactReferralProduct.KiloPass, + }); + + return { + impactPerformanceConfigured, + impactAdvocateConfigured, + isConfigured: impactPerformanceConfigured && impactAdvocateConfigured, + }; +} + +function logKiloPassReferralConfigurationFailure(params: { + sourcePaymentId?: string; + conversionId?: string; + userId?: string; +}): void { + const configurationState = getKiloPassReferralConfigurationState(); + console.error('[kilo-pass-referrals] reward-bearing referral configuration is incomplete', { + ...params, + impactPerformanceConfigured: configurationState.impactPerformanceConfigured, + impactAdvocateConfigured: configurationState.impactAdvocateConfigured, + }); +} + +function buildImpactReferralId(touch: ImpactAttributionTouch): string | null { + return touch.rs_code?.trim() || touch.opaque_tracking_value?.trim() || null; +} + +async function findAcceptedUserTouches(params: { + userId: string; + convertedAt: Date; + database: DatabaseClient; +}): Promise { + return await params.database + .select() + .from(impact_attribution_touches) + .where( + and( + eq(impact_attribution_touches.product, ImpactReferralProduct.KiloPass), + eq(impact_attribution_touches.user_id, params.userId), + lte(impact_attribution_touches.touched_at, params.convertedAt.toISOString()) + ) + ) + .orderBy( + asc(impact_attribution_touches.touched_at), + asc(impact_attribution_touches.created_at) + ); +} + +async function hasHistoricalImpactAffiliateAttribution(params: { + userId: string; + database: DatabaseClient; +}): Promise { + const [attribution] = await params.database + .select({ id: user_affiliate_attributions.id }) + .from(user_affiliate_attributions) + .where( + and( + eq(user_affiliate_attributions.user_id, params.userId), + eq(user_affiliate_attributions.provider, 'impact') + ) + ) + .limit(1); + + return Boolean(attribution); +} + +async function markAffiliateTouchSaleAttributed(params: { + database: DatabaseClient; + affiliateTouchId: string; + convertedAt: Date; +}): Promise { + await params.database + .update(impact_attribution_touches) + .set({ + sale_attributed_at: sql`COALESCE(${impact_attribution_touches.sale_attributed_at}, ${params.convertedAt.toISOString()}::timestamptz)`, + }) + .where(eq(impact_attribution_touches.id, params.affiliateTouchId)); +} + +async function resolveReferrerUserIdFromReferralTouch(params: { + referralTouch: ImpactAttributionTouch; + database: DatabaseClient; +}): Promise { + const opaqueReferralIdentifier = buildImpactReferralId(params.referralTouch)?.trim(); + if (!opaqueReferralIdentifier) return null; + + const [participant] = await params.database + .select({ userId: impact_advocate_participants.user_id }) + .from(impact_advocate_participants) + .where( + and( + eq(impact_advocate_participants.program_key, ImpactAdvocateProgramKey.KiloPass), + eq(impact_advocate_participants.opaque_referral_identifier, opaqueReferralIdentifier) + ) + ) + .limit(1); + + return participant?.userId ?? null; +} + +async function upsertReferralRelationship(params: { + refereeUserId: string; + referrerUserId: string | null; + sourceTouchId: string; + impactReferralId: string | null; + database: DatabaseClient; +}): Promise { + await params.database + .insert(impact_referrals) + .values({ + product: ImpactReferralProduct.KiloPass, + referee_user_id: params.refereeUserId, + referrer_user_id: params.referrerUserId, + source_touch_id: params.sourceTouchId, + impact_referral_id: params.impactReferralId, + }) + .onConflictDoUpdate({ + target: [impact_referrals.product, impact_referrals.referee_user_id], + set: { + referrer_user_id: params.referrerUserId, + source_touch_id: params.sourceTouchId, + impact_referral_id: params.impactReferralId, + }, + }); +} + +function wasReferralTouchCapturedDuringSignup(params: { + userCreatedAt: string; + referralTouch: ImpactAttributionTouch; +}): boolean { + if (!params.referralTouch.landing_path) return false; + + const touchTime = new Date(params.referralTouch.touched_at).getTime(); + const userCreatedTime = new Date(params.userCreatedAt).getTime(); + if (touchTime < userCreatedTime) return false; + if (touchTime - userCreatedTime > SIGNUP_REFERRAL_TOUCH_CAPTURE_GRACE_MS) return false; + + try { + const landingUrl = new URL(params.referralTouch.landing_path, 'http://localhost'); + return landingUrl.searchParams.get('signup') === 'true'; + } catch { + return false; + } +} + +async function hasDeletedUserEmailTombstone(params: { + normalizedEmail: string | null; + database: DatabaseClient; +}): Promise { + if (!params.normalizedEmail) return false; + + const [row] = await params.database + .select({ hash: deleted_user_email_tombstones.normalized_email_hash }) + .from(deleted_user_email_tombstones) + .where( + eq( + deleted_user_email_tombstones.normalized_email_hash, + hashNormalizedEmailForDeletionTombstone(params.normalizedEmail) + ) + ) + .limit(1); + + return Boolean(row); +} + +async function lockReferrerRewardCapacity( + referrerUserId: string, + database: DatabaseClient +): Promise { + await database.execute( + sql`SELECT ${kilocode_users.id} FROM ${kilocode_users} WHERE ${kilocode_users.id} = ${referrerUserId} FOR UPDATE` + ); +} + +async function getGrantedKiloPassReferrerRewardCount( + referrerUserId: string, + database: DatabaseClient +): Promise { + const [result] = await database + .select({ rewardCount: count() }) + .from(impact_referral_reward_decisions) + .where( + and( + eq(impact_referral_reward_decisions.product, ImpactReferralProduct.KiloPass), + eq(impact_referral_reward_decisions.reward_kind, ImpactReferralRewardKind.KiloPassBonus), + eq(impact_referral_reward_decisions.beneficiary_user_id, referrerUserId), + eq( + impact_referral_reward_decisions.beneficiary_role, + ImpactReferralBeneficiaryRole.Referrer + ), + eq(impact_referral_reward_decisions.outcome, ImpactReferralDecisionOutcome.Granted) + ) + ); + + return result?.rewardCount ?? 0; +} + +async function hasPriorKiloPassSubscriptionHistory(params: { + userId: string; + currentSubscriptionId: string; + currentStripeInvoiceId: string; + database: DatabaseClient; +}): Promise { + const [priorSubscription] = await params.database + .select({ id: kilo_pass_subscriptions.id }) + .from(kilo_pass_subscriptions) + .where( + and( + eq(kilo_pass_subscriptions.kilo_user_id, params.userId), + ne(kilo_pass_subscriptions.id, params.currentSubscriptionId) + ) + ) + .limit(1); + if (priorSubscription) return true; + + const [priorIssuance] = await params.database + .select({ id: kilo_pass_issuances.id }) + .from(kilo_pass_issuances) + .where( + and( + eq(kilo_pass_issuances.kilo_pass_subscription_id, params.currentSubscriptionId), + ne(kilo_pass_issuances.stripe_invoice_id, params.currentStripeInvoiceId) + ) + ) + .limit(1); + + return Boolean(priorIssuance); +} + +function getRewardAmountUsd(sourceTier: KiloPassTier): number { + return ( + Math.round( + KILO_PASS_TIER_CONFIG[sourceTier].monthlyPriceUsd * KILO_PASS_REFERRAL_REWARD_PERCENT * 100 + ) / 100 + ); +} + +function shouldPreserveAffiliateSale(winningTouchType: string): boolean { + return winningTouchType === ImpactReferralWinningTouchType.Affiliate; +} + +export async function expirePendingKiloPassReferralRewards(params?: { + now?: Date; + database?: DatabaseClient; +}): Promise { + const now = params?.now ?? new Date(); + const database = params?.database ?? db; + const nowIso = now.toISOString(); + + const expiredRewards = await database + .update(impact_referral_rewards) + .set({ + status: ImpactReferralRewardStatus.Expired, + reversed_at: nowIso, + review_reason: 'expired_kilo_pass_referral_reward', + }) + .where( + and( + eq(impact_referral_rewards.product, ImpactReferralProduct.KiloPass), + eq(impact_referral_rewards.reward_kind, ImpactReferralRewardKind.KiloPassBonus), + inArray(impact_referral_rewards.status, [ + ImpactReferralRewardStatus.Pending, + ImpactReferralRewardStatus.Earned, + ]), + isNull(impact_referral_rewards.applied_at), + isNull(impact_referral_rewards.consumed_kilo_pass_issuance_id), + sql`${impact_referral_rewards.expires_at} IS NOT NULL`, + lte(impact_referral_rewards.expires_at, nowIso) + ) + ) + .returning({ id: impact_referral_rewards.id }); + + return { expiredRewards: expiredRewards.length }; +} + +export async function markPersonalKiloPassReferralPaymentAdverse(params: { + sourcePaymentId: string; + reason: AdverseReferralPaymentReason; + occurredAt: Date; + paymentProvider?: ImpactReferralPaymentProvider; +}): Promise { + const paymentProvider = params.paymentProvider ?? ImpactReferralPaymentProvider.Stripe; + const reviewReason = getAdversePaymentReason(params.reason); + const occurredAt = params.occurredAt.toISOString(); + + return await db.transaction(async tx => { + const conversion = await tx.query.impact_referral_conversions.findFirst({ + where: and( + eq(impact_referral_conversions.product, ImpactReferralProduct.KiloPass), + eq(impact_referral_conversions.payment_provider, paymentProvider), + eq(impact_referral_conversions.source_payment_id, params.sourcePaymentId) + ), + columns: { id: true }, + }); + + if (!conversion) { + return { + conversionId: null, + canceledRewards: 0, + reviewRequiredRewards: 0, + } satisfies KiloPassAdverseReferralPaymentSummary; + } + + const canceledRewards = await tx + .update(impact_referral_rewards) + .set({ + status: ImpactReferralRewardStatus.Canceled, + review_reason: reviewReason, + reversed_at: occurredAt, + }) + .where( + and( + eq(impact_referral_rewards.product, ImpactReferralProduct.KiloPass), + eq(impact_referral_rewards.reward_kind, ImpactReferralRewardKind.KiloPassBonus), + eq(impact_referral_rewards.conversion_id, conversion.id), + inArray(impact_referral_rewards.status, [ + ImpactReferralRewardStatus.Pending, + ImpactReferralRewardStatus.Earned, + ]), + sql`${impact_referral_rewards.applied_at} IS NULL`, + sql`${impact_referral_rewards.consumed_kilo_pass_issuance_id} IS NULL` + ) + ) + .returning({ id: impact_referral_rewards.id }); + + const reviewRequiredRewards = await tx + .update(impact_referral_rewards) + .set({ + status: ImpactReferralRewardStatus.ReviewRequired, + review_reason: reviewReason, + reversed_at: occurredAt, + }) + .where( + and( + eq(impact_referral_rewards.product, ImpactReferralProduct.KiloPass), + eq(impact_referral_rewards.reward_kind, ImpactReferralRewardKind.KiloPassBonus), + eq(impact_referral_rewards.conversion_id, conversion.id), + eq(impact_referral_rewards.status, ImpactReferralRewardStatus.Applied) + ) + ) + .returning({ id: impact_referral_rewards.id }); + + return { + conversionId: conversion.id, + canceledRewards: canceledRewards.length, + reviewRequiredRewards: reviewRequiredRewards.length, + } satisfies KiloPassAdverseReferralPaymentSummary; + }); +} + +export async function processPersonalKiloPassStripePaidConversion(params: { + userId: string; + kiloPassSubscriptionId: string; + sourcePaymentId: string; + orderId: string; + amount: number; + currencyCode: string; + itemCategory: string; + itemName: string; + itemSku?: string; + sourceTier: KiloPassTier; + cadence: KiloPassCadence; + welcomePromoEligibilityReason?: KiloPassWelcomePromoEligibilityReason | null; + convertedAt: Date; +}): Promise { + const paymentProvider = ImpactReferralPaymentProvider.Stripe; + const referralSaleDedupeKey = `impact-referral-sale:${ImpactReferralProduct.KiloPass}:${paymentProvider}:${params.sourcePaymentId}`; + + logImpactReferralDebug('Processing personal Kilo Pass paid conversion for Impact referrals', { + userId: params.userId, + sourcePaymentId: params.sourcePaymentId, + orderId: params.orderId, + amount: params.amount, + currencyCode: params.currencyCode, + itemCategory: params.itemCategory, + cadence: params.cadence, + sourceTier: params.sourceTier, + welcomePromoEligibilityReason: params.welcomePromoEligibilityReason ?? null, + }); + + let impactReportId: string | null = null; + const disposition = await db.transaction(async tx => { + const existingConversion = await tx.query.impact_referral_conversions.findFirst({ + where: and( + eq(impact_referral_conversions.product, ImpactReferralProduct.KiloPass), + eq(impact_referral_conversions.payment_provider, paymentProvider), + eq(impact_referral_conversions.source_payment_id, params.sourcePaymentId) + ), + }); + + if (existingConversion) { + const existingReport = await tx.query.impact_conversion_reports.findFirst({ + where: eq(impact_conversion_reports.conversion_id, existingConversion.id), + columns: { id: true, state: true }, + }); + const reportIsRetryable = + existingReport?.state === ImpactConversionReportState.Queued || + existingReport?.state === ImpactConversionReportState.Retrying; + impactReportId = + existingConversion.qualified && + existingConversion.winning_touch_type === ImpactReferralWinningTouchType.Referral && + reportIsRetryable + ? (existingReport?.id ?? null) + : null; + + return { + shouldEnqueueAffiliateSale: shouldPreserveAffiliateSale( + existingConversion.winning_touch_type + ), + winningTouchType: existingConversion.winning_touch_type, + conversionId: existingConversion.id, + disqualificationReason: existingConversion.disqualification_reason, + } satisfies KiloPassPaidConversionDisposition; + } + + const [user] = await tx + .select({ + id: kilocode_users.id, + createdAt: kilocode_users.created_at, + email: kilocode_users.google_user_email, + normalizedEmail: kilocode_users.normalized_email, + }) + .from(kilocode_users) + .where(eq(kilocode_users.id, params.userId)) + .limit(1); + + if (!user) { + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: ImpactReferralWinningTouchType.None, + conversionId: null, + disqualificationReason: 'user_missing', + } satisfies KiloPassPaidConversionDisposition; + } + + const touches = await findAcceptedUserTouches({ + userId: params.userId, + convertedAt: params.convertedAt, + database: tx, + }); + const resolution = resolveWinningAttributionTouch({ + product: ImpactReferralProduct.KiloPass, + touches, + convertedAt: params.convertedAt, + }); + + logImpactReferralDebug('Resolved Kilo Pass Impact attribution touches for paid conversion', { + userId: params.userId, + sourcePaymentId: params.sourcePaymentId, + touchCount: touches.length, + affiliateTouchCount: touches.filter( + touch => touch.touch_type === ImpactAttributionTouchType.Affiliate + ).length, + referralTouchCount: touches.filter( + touch => touch.touch_type === ImpactAttributionTouchType.Referral + ).length, + winner: resolution.winner, + affiliateTouchId: resolution.affiliateTouch?.id ?? null, + referralTouchId: resolution.referralTouch?.id ?? null, + }); + + // Preserve legacy first-touch Impact affiliate attribution for Kilo Pass SALE reporting + // when no product-scoped touch exists, per the affiliate spec. Expired scoped touches + // still suppress this fallback so they cannot bypass the referral attribution window. + if ( + resolution.winner === 'none' && + touches.length === 0 && + (await hasHistoricalImpactAffiliateAttribution({ userId: params.userId, database: tx })) + ) { + const [conversion] = await tx + .insert(impact_referral_conversions) + .values({ + product: ImpactReferralProduct.KiloPass, + referee_user_id: params.userId, + referrer_user_id: null, + source_touch_id: null, + winning_touch_type: ImpactReferralWinningTouchType.Affiliate, + source_payment_id: params.sourcePaymentId, + payment_provider: paymentProvider, + qualified: false, + disqualification_reason: referralDisqualificationReason('affiliate_won'), + converted_at: params.convertedAt.toISOString(), + }) + .returning({ id: impact_referral_conversions.id }); + + return { + shouldEnqueueAffiliateSale: true, + winningTouchType: ImpactReferralWinningTouchType.Affiliate, + conversionId: conversion?.id ?? null, + disqualificationReason: referralDisqualificationReason('affiliate_won'), + } satisfies KiloPassPaidConversionDisposition; + } + + if (resolution.winner === 'none') { + const [conversion] = await tx + .insert(impact_referral_conversions) + .values({ + product: ImpactReferralProduct.KiloPass, + referee_user_id: params.userId, + referrer_user_id: null, + source_touch_id: null, + winning_touch_type: ImpactReferralWinningTouchType.None, + source_payment_id: params.sourcePaymentId, + payment_provider: paymentProvider, + qualified: false, + disqualification_reason: referralDisqualificationReason('no_valid_attribution'), + converted_at: params.convertedAt.toISOString(), + }) + .returning({ id: impact_referral_conversions.id }); + + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: ImpactReferralWinningTouchType.None, + conversionId: conversion?.id ?? null, + disqualificationReason: referralDisqualificationReason('no_valid_attribution'), + } satisfies KiloPassPaidConversionDisposition; + } + + if (resolution.winner === 'affiliate') { + await markAffiliateTouchSaleAttributed({ + database: tx, + affiliateTouchId: resolution.affiliateTouch.id, + convertedAt: params.convertedAt, + }); + + const [conversion] = await tx + .insert(impact_referral_conversions) + .values({ + product: ImpactReferralProduct.KiloPass, + referee_user_id: params.userId, + referrer_user_id: null, + source_touch_id: resolution.affiliateTouch.id, + winning_touch_type: ImpactReferralWinningTouchType.Affiliate, + source_payment_id: params.sourcePaymentId, + payment_provider: paymentProvider, + qualified: false, + disqualification_reason: referralDisqualificationReason('affiliate_won'), + converted_at: params.convertedAt.toISOString(), + }) + .returning({ id: impact_referral_conversions.id }); + + return { + shouldEnqueueAffiliateSale: true, + winningTouchType: ImpactReferralWinningTouchType.Affiliate, + conversionId: conversion?.id ?? null, + disqualificationReason: referralDisqualificationReason('affiliate_won'), + } satisfies KiloPassPaidConversionDisposition; + } + + const referrerUserId = await resolveReferrerUserIdFromReferralTouch({ + referralTouch: resolution.referralTouch, + database: tx, + }); + await upsertReferralRelationship({ + refereeUserId: params.userId, + referrerUserId, + sourceTouchId: resolution.referralTouch.id, + impactReferralId: buildImpactReferralId(resolution.referralTouch), + database: tx, + }); + + const isYearly = params.cadence === KiloPassCadence.Yearly; + const isFreeOrComped = params.amount <= 0; + const hasPreviouslyClaimedPaymentFingerprint = + params.welcomePromoEligibilityReason === + KiloPassWelcomePromoEligibilityReason.FingerprintPreviouslyClaimed; + const hasPriorSubscriptionHistory = await hasPriorKiloPassSubscriptionHistory({ + userId: params.userId, + currentSubscriptionId: params.kiloPassSubscriptionId, + currentStripeInvoiceId: params.sourcePaymentId, + database: tx, + }); + const deletedUser = await hasDeletedUserEmailTombstone({ + normalizedEmail: user.normalizedEmail, + database: tx, + }); + const userExistedBeforeReferral = + new Date(user.createdAt).getTime() < + new Date(resolution.referralTouch.touched_at).getTime() && + !wasReferralTouchCapturedDuringSignup({ + userCreatedAt: user.createdAt, + referralTouch: resolution.referralTouch, + }); + const isSelfReferral = referrerUserId !== null && referrerUserId === params.userId; + + const disqualificationReason = isYearly + ? referralDisqualificationReason('non_monthly_kilo_pass_subscription') + : isFreeOrComped + ? referralDisqualificationReason('fully_comped_payment') + : hasPreviouslyClaimedPaymentFingerprint + ? referralDisqualificationReason('payment_fingerprint_previously_claimed') + : hasPriorSubscriptionHistory + ? referralDisqualificationReason('prior_kilo_pass_subscription') + : deletedUser + ? referralDisqualificationReason('deleted_user_tombstone') + : userExistedBeforeReferral + ? referralDisqualificationReason('existing_user_before_touch') + : !referrerUserId + ? referralDisqualificationReason('referrer_unresolved') + : isSelfReferral + ? referralDisqualificationReason('self_referral') + : null; + + if (disqualificationReason) { + const [conversion] = await tx + .insert(impact_referral_conversions) + .values({ + product: ImpactReferralProduct.KiloPass, + referee_user_id: params.userId, + referrer_user_id: referrerUserId, + source_touch_id: resolution.referralTouch.id, + winning_touch_type: ImpactReferralWinningTouchType.Referral, + source_payment_id: params.sourcePaymentId, + payment_provider: paymentProvider, + qualified: false, + disqualification_reason: disqualificationReason, + converted_at: params.convertedAt.toISOString(), + }) + .returning({ id: impact_referral_conversions.id }); + + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: ImpactReferralWinningTouchType.Referral, + conversionId: conversion?.id ?? null, + disqualificationReason, + } satisfies KiloPassPaidConversionDisposition; + } + + if (!referrerUserId) { + throw new Error('Kilo Pass referral referrer unexpectedly missing after eligibility checks'); + } + + if (!getKiloPassReferralConfigurationState().isConfigured) { + const missingConfigReason = referralDisqualificationReason('missing_configuration'); + logKiloPassReferralConfigurationFailure({ + sourcePaymentId: params.sourcePaymentId, + userId: params.userId, + }); + + const [conversion] = await tx + .insert(impact_referral_conversions) + .values({ + product: ImpactReferralProduct.KiloPass, + referee_user_id: params.userId, + referrer_user_id: referrerUserId, + source_touch_id: resolution.referralTouch.id, + winning_touch_type: ImpactReferralWinningTouchType.Referral, + source_payment_id: params.sourcePaymentId, + payment_provider: paymentProvider, + qualified: false, + disqualification_reason: missingConfigReason, + converted_at: params.convertedAt.toISOString(), + }) + .returning({ id: impact_referral_conversions.id }); + + if (!conversion) { + throw new Error( + `Failed to create Kilo Pass referral conversion for ${params.sourcePaymentId}` + ); + } + + await tx.insert(impact_referral_reward_decisions).values([ + { + product: ImpactReferralProduct.KiloPass, + conversion_id: conversion.id, + beneficiary_user_id: params.userId, + beneficiary_role: ImpactReferralBeneficiaryRole.Referee, + outcome: ImpactReferralDecisionOutcome.Disqualified, + reason: missingConfigReason, + reward_kind: ImpactReferralRewardKind.KiloPassBonus, + months_granted: 0, + reward_percent: KILO_PASS_REFERRAL_REWARD_PERCENT, + source_tier: params.sourceTier, + reward_amount_usd: 0, + }, + { + product: ImpactReferralProduct.KiloPass, + conversion_id: conversion.id, + beneficiary_user_id: referrerUserId, + beneficiary_role: ImpactReferralBeneficiaryRole.Referrer, + outcome: ImpactReferralDecisionOutcome.Disqualified, + reason: missingConfigReason, + reward_kind: ImpactReferralRewardKind.KiloPassBonus, + months_granted: 0, + reward_percent: KILO_PASS_REFERRAL_REWARD_PERCENT, + source_tier: params.sourceTier, + reward_amount_usd: 0, + }, + ]); + + const payload = buildSalePayload({ + customerId: params.userId, + customerEmailHash: hashEmailForImpact(user.email), + eventDate: params.convertedAt, + orderId: params.orderId, + amount: params.amount, + currencyCode: params.currencyCode, + itemCategory: params.itemCategory, + itemName: params.itemName, + itemSku: params.itemSku, + trackingId: null, + }); + + await tx + .insert(impact_conversion_reports) + .values({ + conversion_id: conversion.id, + dedupe_key: referralSaleDedupeKey, + action_tracker_id: IMPACT_ACTION_TRACKER_IDS.sale, + order_id: params.orderId, + state: ImpactConversionReportState.Failed, + request_payload: payload satisfies Record, + response_payload: { + error: 'missing_reward_bearing_referral_configuration', + } satisfies Record, + }) + .onConflictDoNothing({ target: [impact_conversion_reports.dedupe_key] }); + + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: ImpactReferralWinningTouchType.Referral, + conversionId: conversion.id, + disqualificationReason: missingConfigReason, + } satisfies KiloPassPaidConversionDisposition; + } + + await lockReferrerRewardCapacity(referrerUserId, tx); + const referrerGrantedRewardCount = await getGrantedKiloPassReferrerRewardCount( + referrerUserId, + tx + ); + const referrerAtCap = referrerGrantedRewardCount >= KILO_PASS_REFERRER_REWARD_CAP; + const rewardAmountUsd = getRewardAmountUsd(params.sourceTier); + const earnedAt = params.convertedAt.toISOString(); + const expiresAt = addMonths(params.convertedAt, 12).toISOString(); + + const [conversion] = await tx + .insert(impact_referral_conversions) + .values({ + product: ImpactReferralProduct.KiloPass, + referee_user_id: params.userId, + referrer_user_id: referrerUserId, + source_touch_id: resolution.referralTouch.id, + winning_touch_type: ImpactReferralWinningTouchType.Referral, + source_payment_id: params.sourcePaymentId, + payment_provider: paymentProvider, + qualified: true, + disqualification_reason: null, + converted_at: earnedAt, + }) + .returning({ id: impact_referral_conversions.id }); + + if (!conversion) { + throw new Error( + `Failed to create Kilo Pass referral conversion for ${params.sourcePaymentId}` + ); + } + + const decisions = await tx + .insert(impact_referral_reward_decisions) + .values([ + { + product: ImpactReferralProduct.KiloPass, + conversion_id: conversion.id, + beneficiary_user_id: params.userId, + beneficiary_role: ImpactReferralBeneficiaryRole.Referee, + outcome: ImpactReferralDecisionOutcome.Granted, + reason: null, + reward_kind: ImpactReferralRewardKind.KiloPassBonus, + months_granted: 0, + reward_percent: KILO_PASS_REFERRAL_REWARD_PERCENT, + source_tier: params.sourceTier, + reward_amount_usd: rewardAmountUsd, + }, + { + product: ImpactReferralProduct.KiloPass, + conversion_id: conversion.id, + beneficiary_user_id: referrerUserId, + beneficiary_role: ImpactReferralBeneficiaryRole.Referrer, + outcome: referrerAtCap + ? ImpactReferralDecisionOutcome.CapLimited + : ImpactReferralDecisionOutcome.Granted, + reason: referrerAtCap ? referralDisqualificationReason('referrer_cap_reached') : null, + reward_kind: ImpactReferralRewardKind.KiloPassBonus, + months_granted: 0, + reward_percent: KILO_PASS_REFERRAL_REWARD_PERCENT, + source_tier: params.sourceTier, + reward_amount_usd: referrerAtCap ? 0 : rewardAmountUsd, + }, + ]) + .returning({ + id: impact_referral_reward_decisions.id, + beneficiary_user_id: impact_referral_reward_decisions.beneficiary_user_id, + beneficiary_role: impact_referral_reward_decisions.beneficiary_role, + outcome: impact_referral_reward_decisions.outcome, + reward_amount_usd: impact_referral_reward_decisions.reward_amount_usd, + }); + + const grantedRewards = decisions + .filter(decision => decision.outcome === ImpactReferralDecisionOutcome.Granted) + .map(decision => ({ + product: ImpactReferralProduct.KiloPass, + conversion_id: conversion.id, + decision_id: decision.id, + beneficiary_user_id: decision.beneficiary_user_id, + beneficiary_role: decision.beneficiary_role, + reward_kind: ImpactReferralRewardKind.KiloPassBonus, + months_granted: 0, + reward_percent: KILO_PASS_REFERRAL_REWARD_PERCENT, + source_tier: params.sourceTier, + reward_amount_usd: decision.reward_amount_usd, + status: ImpactReferralRewardStatus.Pending, + applies_to_kilo_pass_subscription_id: null, + consumed_kilo_pass_issuance_id: null, + consumed_kilo_pass_issuance_item_id: null, + earned_at: earnedAt, + expires_at: expiresAt, + })); + + if (grantedRewards.length > 0) { + const insertedRewards = await tx + .insert(impact_referral_rewards) + .values(grantedRewards) + .returning({ id: impact_referral_rewards.id }); + + for (const reward of insertedRewards) { + await queueImpactAdvocateRewardRedemption({ rewardId: reward.id, database: tx }); + } + } + + const payload = buildSalePayload({ + customerId: params.userId, + customerEmailHash: hashEmailForImpact(user.email), + eventDate: params.convertedAt, + orderId: params.orderId, + amount: params.amount, + currencyCode: params.currencyCode, + itemCategory: params.itemCategory, + itemName: params.itemName, + itemSku: params.itemSku, + trackingId: null, + }); + + const [report] = await tx + .insert(impact_conversion_reports) + .values({ + conversion_id: conversion.id, + dedupe_key: referralSaleDedupeKey, + action_tracker_id: IMPACT_ACTION_TRACKER_IDS.sale, + order_id: params.orderId, + state: ImpactConversionReportState.Queued, + request_payload: payload satisfies Record, + }) + .onConflictDoNothing({ target: [impact_conversion_reports.dedupe_key] }) + .returning({ id: impact_conversion_reports.id }); + + const existingReport = + report ?? + (await tx.query.impact_conversion_reports.findFirst({ + where: eq(impact_conversion_reports.dedupe_key, referralSaleDedupeKey), + columns: { id: true }, + })); + impactReportId = existingReport?.id ?? null; + + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: ImpactReferralWinningTouchType.Referral, + conversionId: conversion.id, + disqualificationReason: null, + } satisfies KiloPassPaidConversionDisposition; + }); + + logImpactReferralDebug('Processed personal Kilo Pass paid conversion for Impact referrals', { + userId: params.userId, + sourcePaymentId: params.sourcePaymentId, + shouldEnqueueAffiliateSale: disposition.shouldEnqueueAffiliateSale, + winningTouchType: disposition.winningTouchType, + conversionId: disposition.conversionId, + disqualificationReason: disposition.disqualificationReason, + impactReportId, + }); + + if (impactReportId) { + await dispatchImpactConversionReportById(impactReportId); + } + + return disposition; +} diff --git a/apps/web/src/lib/impact/kiloclaw-referrals.test.ts b/apps/web/src/lib/impact/kiloclaw-referrals.test.ts index 53eb697740..6b941c593d 100644 --- a/apps/web/src/lib/impact/kiloclaw-referrals.test.ts +++ b/apps/web/src/lib/impact/kiloclaw-referrals.test.ts @@ -486,7 +486,8 @@ describe('kiloclaw referrals', () => { id: 'affiliate-touch', touch_type: 'affiliate', im_ref: 'im-ref', - expires_at: '2026-04-05T00:00:00.000Z', + touched_at: '2026-03-01T00:00:00.000Z', + expires_at: '2026-03-31T00:00:00.000Z', }); const invalidReferralTouch = makeTouch({ id: 'referral-touch', @@ -507,6 +508,63 @@ describe('kiloclaw referrals', () => { referralTouch: null, }); }); + + it('filters touches by product for mirrored Kilo Pass attribution', () => { + const kiloClawReferralTouch = makeTouch({ + id: 'kiloclaw-referral-touch', + product: 'kiloclaw', + touch_type: 'referral', + touched_at: '2026-04-01T00:00:00.000Z', + rs_code: 'claw-ref-code', + }); + const kiloPassAffiliateTouch = makeTouch({ + id: 'kilo-pass-affiliate-touch', + product: 'kilo_pass', + program_key: null, + touch_type: 'affiliate', + touched_at: '2026-04-02T00:00:00.000Z', + im_ref: 'pass-im-ref', + }); + + expect( + resolveWinningAttributionTouch({ + product: 'kilo_pass', + touches: [kiloClawReferralTouch, kiloPassAffiliateTouch], + convertedAt, + }) + ).toMatchObject({ + winner: 'affiliate', + affiliateTouch: { id: 'kilo-pass-affiliate-touch' }, + referralTouch: null, + }); + }); + + it('uses the exact 30-day UTC expiration boundary', () => { + const referralTouch = makeTouch({ + id: 'boundary-referral-touch', + touch_type: 'referral', + touched_at: '2026-04-01T00:00:00.000Z', + expires_at: '2026-06-01T00:00:00.000Z', + rs_code: 'ref-code', + }); + + expect( + resolveWinningAttributionTouch({ + touches: [referralTouch], + convertedAt: new Date('2026-04-30T23:59:59.999Z'), + }) + ).toMatchObject({ winner: 'referral' }); + expect( + resolveWinningAttributionTouch({ + touches: [referralTouch], + convertedAt: new Date('2026-05-01T00:00:00.000Z'), + }) + ).toEqual({ + winner: 'none', + affiliateTouch: null, + referralTouch: null, + }); + }); }); describe('processPersonalKiloClawPaidConversion', () => { diff --git a/apps/web/src/lib/impact/kiloclaw-referrals.ts b/apps/web/src/lib/impact/kiloclaw-referrals.ts index 59d47c8fd7..4328da0d53 100644 --- a/apps/web/src/lib/impact/kiloclaw-referrals.ts +++ b/apps/web/src/lib/impact/kiloclaw-referrals.ts @@ -22,6 +22,7 @@ import { } from '@/lib/impact/advocate'; import { logImpactReferralDebug } from '@/lib/impact/debug'; import { hashNormalizedEmailForDeletionTombstone } from '@/lib/impact/referral'; +import { IMPACT_REFERRAL_TOUCH_VALIDITY_MS } from '@/lib/impact/referral-utils'; import { resolveCurrentPersonalSubscriptionRow } from '@/lib/kiloclaw/current-personal-subscription'; import { client as stripe } from '@/lib/stripe-client'; import { insertKiloClawSubscriptionChangeLog } from '@kilocode/db'; @@ -51,6 +52,7 @@ import { ImpactReferralPaymentProvider, ImpactReferralProduct, ImpactReferralRewardKind, + ImpactReferralRewardStatus, ImpactAttributionTouchType, KiloClawReferralBeneficiaryRole, KiloClawReferralDecisionOutcome, @@ -126,7 +128,8 @@ const REFERRAL_REWARD_ACTOR = { } as const; const SIGNUP_REFERRAL_TOUCH_CAPTURE_GRACE_MS = 10 * 60 * 1000; -const IMPACT_ADVOCATE_REWARD_UNIT = 'MONTH'; +const IMPACT_ADVOCATE_KILOCLAW_REWARD_UNIT = 'MONTH'; +const IMPACT_ADVOCATE_KILO_PASS_REWARD_UNIT = 'USD'; function getDatabaseClient(database?: DatabaseClient): DatabaseClient { return database ?? db; @@ -155,25 +158,32 @@ function hasAcceptedTrackingValue(touch: ImpactAttributionTouch): boolean { } function isTouchValidAtConversion(touch: ImpactAttributionTouch, convertedAt: Date): boolean { + const touchedAt = new Date(touch.touched_at).getTime(); + const convertedAtMs = convertedAt.getTime(); + // Qualification intentionally ignores the denormalized expires_at column: the referral + // spec defines validity as touched_at + 30 * 24h using server UTC timestamps. + const exactExpiration = touchedAt + IMPACT_REFERRAL_TOUCH_VALIDITY_MS; return ( - hasAcceptedTrackingValue(touch) && - new Date(touch.touched_at).getTime() <= convertedAt.getTime() && - convertedAt.getTime() < new Date(touch.expires_at).getTime() + hasAcceptedTrackingValue(touch) && touchedAt <= convertedAtMs && convertedAtMs < exactExpiration ); } export function resolveWinningAttributionTouch(params: { + product?: ImpactReferralProduct | null; touches: ImpactAttributionTouch[]; convertedAt: Date; }): WinningAttributionResolution { - const validReferralTouches = params.touches + const scopedTouches = params.product + ? params.touches.filter(touch => touch.product === params.product) + : params.touches; + const validReferralTouches = scopedTouches .filter( touch => touch.touch_type === ImpactAttributionTouchType.Referral && isTouchValidAtConversion(touch, params.convertedAt) ) .sort((a, b) => new Date(a.touched_at).getTime() - new Date(b.touched_at).getTime()); - const validAffiliateTouches = params.touches + const validAffiliateTouches = scopedTouches .filter( touch => touch.touch_type === ImpactAttributionTouchType.Affiliate && @@ -990,7 +1000,7 @@ export async function processQueuedKiloClawReferralRewards(params?: { return summary; } -async function queueImpactAdvocateRewardRedemption(params: { +export async function queueImpactAdvocateRewardRedemption(params: { rewardId: string; database: DatabaseClient; }): Promise { @@ -999,6 +1009,7 @@ async function queueImpactAdvocateRewardRedemption(params: { id: impact_referral_rewards.id, beneficiaryUserId: impact_referral_rewards.beneficiary_user_id, monthsGranted: impact_referral_rewards.months_granted, + rewardAmountUsd: impact_referral_rewards.reward_amount_usd, status: impact_referral_rewards.status, product: impact_referral_rewards.product, rewardKind: impact_referral_rewards.reward_kind, @@ -1009,18 +1020,40 @@ async function queueImpactAdvocateRewardRedemption(params: { .where(eq(impact_referral_rewards.id, params.rewardId)) .limit(1); + if (!reward) { + return; + } + + let programKey: ImpactAdvocateProgramKey | null = null; + let amount: number | null = null; + let unit: string | null = null; + if ( - !reward || - reward.status !== KiloClawReferralRewardStatus.Applied || - reward.product !== ImpactReferralProduct.KiloClaw || - reward.rewardKind !== ImpactReferralRewardKind.KiloClawFreeMonth + reward.status === KiloClawReferralRewardStatus.Applied && + reward.product === ImpactReferralProduct.KiloClaw && + reward.rewardKind === ImpactReferralRewardKind.KiloClawFreeMonth ) { + amount = reward.monthsGranted; + unit = IMPACT_ADVOCATE_KILOCLAW_REWARD_UNIT; + } else if ( + reward.product === ImpactReferralProduct.KiloPass && + reward.rewardKind === ImpactReferralRewardKind.KiloPassBonus && + (reward.status === ImpactReferralRewardStatus.Pending || + reward.status === ImpactReferralRewardStatus.Earned || + reward.status === ImpactReferralRewardStatus.Applied) && + reward.rewardAmountUsd !== null && + reward.rewardAmountUsd > 0 + ) { + programKey = ImpactAdvocateProgramKey.KiloPass; + amount = reward.rewardAmountUsd; + unit = IMPACT_ADVOCATE_KILO_PASS_REWARD_UNIT; + } else { return; } const accountId = reward.email.trim(); if (!accountId) { - console.error('[kiloclaw-referrals] missing beneficiary email for Impact reward redemption', { + console.error('[impact-referrals] missing beneficiary email for Impact reward redemption', { rewardId: params.rewardId, beneficiaryUserId: reward.beneficiaryUserId, }); @@ -1035,14 +1068,15 @@ async function queueImpactAdvocateRewardRedemption(params: { beneficiary_user_id: reward.beneficiaryUserId, state: ImpactAdvocateRewardRedemptionState.Queued, request_payload: { + ...(programKey ? { programKey } : {}), lookup: { accountId, userId: accountId, rewardTypeFilter: 'CREDIT', }, redemption: { - amount: reward.monthsGranted, - unit: IMPACT_ADVOCATE_REWARD_UNIT, + amount, + unit, }, } satisfies Record, }) @@ -1050,6 +1084,7 @@ async function queueImpactAdvocateRewardRedemption(params: { } type ImpactAdvocateRewardRedemptionRequestPayload = { + programKey?: ImpactAdvocateProgramKey; lookup: { accountId: string; userId: string; @@ -1077,11 +1112,15 @@ function isRewardRedemptionRequestPayload( ): payload is ImpactAdvocateRewardRedemptionRequestPayload { const lookup = getObjectProperty(payload, 'lookup'); const redemption = getObjectProperty(payload, 'redemption'); + const programKey = getObjectProperty(payload, 'programKey'); return ( typeof lookup === 'object' && lookup !== null && typeof redemption === 'object' && redemption !== null && + (programKey === undefined || + programKey === ImpactAdvocateProgramKey.KiloClaw || + programKey === ImpactAdvocateProgramKey.KiloPass) && typeof getObjectProperty(lookup, 'accountId') === 'string' && typeof getObjectProperty(lookup, 'userId') === 'string' && getObjectProperty(lookup, 'rewardTypeFilter') === 'CREDIT' && @@ -1090,6 +1129,12 @@ function isRewardRedemptionRequestPayload( ); } +function getRewardRedemptionProgramKey( + payload: ImpactAdvocateRewardRedemptionRequestPayload +): ImpactAdvocateProgramKey { + return payload.programKey ?? ImpactAdvocateProgramKey.KiloClaw; +} + function buildFailurePayload(result: ImpactAdvocateDispatchResult): Record { return { failureKind: result.ok ? null : result.failureKind, @@ -1162,9 +1207,13 @@ async function dispatchImpactAdvocateRewardRedemptionById( return 'failed'; } - const lookupResult = await sendImpactAdvocateRewardLookupPayload( - redemption.request_payload.lookup - ); + const programKey = getRewardRedemptionProgramKey(redemption.request_payload); + const advocateScope = + programKey === ImpactAdvocateProgramKey.KiloClaw ? undefined : { programKey }; + + const lookupResult = advocateScope + ? await sendImpactAdvocateRewardLookupPayload(redemption.request_payload.lookup, advocateScope) + : await sendImpactAdvocateRewardLookupPayload(redemption.request_payload.lookup); if (!lookupResult.ok) { return await persistRewardRedemptionFailure({ redemptionId: redemption.id, @@ -1212,10 +1261,13 @@ async function dispatchImpactAdvocateRewardRedemptionById( .where(eq(impact_advocate_reward_redemptions.id, redemption.id)); } - const redeemResult = await sendImpactAdvocateRewardRedemptionPayload({ + const redemptionPayload = { rewardId: impactRewardId, ...redemption.request_payload.redemption, - }); + }; + const redeemResult = advocateScope + ? await sendImpactAdvocateRewardRedemptionPayload(redemptionPayload, advocateScope) + : await sendImpactAdvocateRewardRedemptionPayload(redemptionPayload); const isIdempotentAlreadyRedeemed = !redeemResult.ok && persistedImpactRewardId === impactRewardId && @@ -1254,10 +1306,49 @@ async function dispatchImpactAdvocateRewardRedemptionById( return 'redeemed'; } +async function queueMissingImpactAdvocateRewardRedemptions(limit: number): Promise { + const rows = await db + .select({ id: impact_referral_rewards.id }) + .from(impact_referral_rewards) + .where( + and( + or( + and( + eq(impact_referral_rewards.product, ImpactReferralProduct.KiloClaw), + eq(impact_referral_rewards.reward_kind, ImpactReferralRewardKind.KiloClawFreeMonth), + eq(impact_referral_rewards.status, ImpactReferralRewardStatus.Applied) + ), + and( + eq(impact_referral_rewards.product, ImpactReferralProduct.KiloPass), + eq(impact_referral_rewards.reward_kind, ImpactReferralRewardKind.KiloPassBonus), + inArray(impact_referral_rewards.status, [ + ImpactReferralRewardStatus.Pending, + ImpactReferralRewardStatus.Earned, + ImpactReferralRewardStatus.Applied, + ]), + sql`${impact_referral_rewards.reward_amount_usd} > 0` + ) + ), + sql`NOT EXISTS ( + SELECT 1 + FROM ${impact_advocate_reward_redemptions} + WHERE ${impact_advocate_reward_redemptions.reward_id} = ${impact_referral_rewards.id} + )` + ) + ) + .orderBy(asc(impact_referral_rewards.earned_at), asc(impact_referral_rewards.created_at)) + .limit(limit); + + for (const row of rows) { + await queueImpactAdvocateRewardRedemption({ rewardId: row.id, database: db }); + } +} + export async function dispatchQueuedImpactAdvocateRewardRedemptions(params?: { limit?: number; }): Promise { const limit = params?.limit ?? 100; + await queueMissingImpactAdvocateRewardRedemptions(limit); const nowIso = new Date().toISOString(); const rows = await db .update(impact_advocate_reward_redemptions) @@ -1584,7 +1675,7 @@ async function persistImpactConversionReportResult(params: { .where(eq(impact_conversion_reports.id, params.reportId)); } -async function dispatchImpactConversionReportById( +export async function dispatchImpactConversionReportById( reportId: string ): Promise<'delivered' | 'retried' | 'failed'> { logImpactReferralDebug('Dispatching Impact referral conversion report', { @@ -1847,6 +1938,7 @@ export async function processPersonalKiloClawPaidConversion(params: { database: tx, }); const resolution = resolveWinningAttributionTouch({ + product: ImpactReferralProduct.KiloClaw, touches, convertedAt: params.convertedAt, }); diff --git a/apps/web/src/lib/kilo-pass/affiliate-sale.ts b/apps/web/src/lib/kilo-pass/affiliate-sale.ts index a81f50cfbe..aaf1ff8513 100644 --- a/apps/web/src/lib/kilo-pass/affiliate-sale.ts +++ b/apps/web/src/lib/kilo-pass/affiliate-sale.ts @@ -54,7 +54,7 @@ const KILO_PASS_AFFILIATE_SALE_REPORTING = { Record >; -function getKiloPassAffiliateSaleReportingFields(context: KiloPassAffiliateSaleContext) { +export function getKiloPassAffiliateSaleReportingFields(context: KiloPassAffiliateSaleContext) { const reportingFields = KILO_PASS_AFFILIATE_SALE_REPORTING[context.tier][context.cadence]; return context.itemSku ? { ...reportingFields, itemSku: context.itemSku } : reportingFields; } diff --git a/apps/web/src/lib/kilo-pass/bonus.test.ts b/apps/web/src/lib/kilo-pass/bonus.test.ts index 42803933b3..64155a8a3d 100644 --- a/apps/web/src/lib/kilo-pass/bonus.test.ts +++ b/apps/web/src/lib/kilo-pass/bonus.test.ts @@ -8,7 +8,6 @@ import { import { KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT, - KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF, KILO_PASS_MONTHLY_RAMP_BASE_BONUS_PERCENT, KILO_PASS_MONTHLY_RAMP_CAP_BONUS_PERCENT, KILO_PASS_MONTHLY_RAMP_STEP_BONUS_PERCENT, @@ -165,19 +164,12 @@ describe('kilo pass bonus utilities', () => { }); describe('computeMonthlyCadenceBonusPercent', () => { - it('keeps the second-month grandfather cutoff at midnight May 7 UTC', () => { - expect(KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF.toISOString()).toBe( - '2026-05-07T00:00:00.000Z' - ); - }); - - it('applies the 50% promo for streak months 1 and 2 when eligible (strictly before cutoff)', () => { + it('applies the 50% promo only for first-time subscribers in streak month 1', () => { expect( computeMonthlyCadenceBonusPercent({ tier: KiloPassTier.Tier19, streakMonths: 1, isFirstTimeSubscriberEver: true, - subscriptionStartedAtIso: '2026-01-26T23:59:59.000Z', }) ).toBeCloseTo(KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT); @@ -186,37 +178,14 @@ describe('kilo pass bonus utilities', () => { tier: KiloPassTier.Tier19, streakMonths: 2, isFirstTimeSubscriberEver: true, - subscriptionStartedAtIso: '2026-01-26T23:59:59.000Z', - }) - ).toBeCloseTo(KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT); - - expect( - computeMonthlyCadenceBonusPercent({ - tier: KiloPassTier.Tier19, - streakMonths: 3, - isFirstTimeSubscriberEver: true, - subscriptionStartedAtIso: '2026-01-26T23:59:59.000Z', }) ).toBeCloseTo( KILO_PASS_TIER_CONFIG.tier_19.monthlyBaseBonusPercent + - KILO_PASS_TIER_CONFIG.tier_19.monthlyStepBonusPercent * 2 + KILO_PASS_TIER_CONFIG.tier_19.monthlyStepBonusPercent ); }); - it('applies the first-month promo for first-time subscribers after the grandfather cutoff', () => { - expect( - computeMonthlyCadenceBonusPercent({ - tier: KiloPassTier.Tier19, - streakMonths: 1, - isFirstTimeSubscriberEver: true, - subscriptionStartedAtIso: new Date( - KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF.valueOf() + 1 - ).toISOString(), - }) - ).toBeCloseTo(KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT); - }); - - it('does not apply the override when isFirstTimeSubscriberEver is false', () => { + it('does not apply the first-month promo when isFirstTimeSubscriberEver is false', () => { expect( computeMonthlyCadenceBonusPercent({ tier: KiloPassTier.Tier49, @@ -225,57 +194,15 @@ describe('kilo pass bonus utilities', () => { }) ).toBeCloseTo(KILO_PASS_TIER_CONFIG.tier_49.monthlyBaseBonusPercent); }); - }); - - describe('computeMonthlyCadenceBonusPercent (promo cutoff behavior)', () => { - const tier = KiloPassTier.Tier49; - - const computeFallback = (params: { - streakMonths: number; - isFirstTimeSubscriberEver: boolean; - }): number => { - return computeMonthlyCadenceBonusPercent({ - tier, - streakMonths: params.streakMonths, - isFirstTimeSubscriberEver: params.isFirstTimeSubscriberEver, - subscriptionStartedAtIso: KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF.toISOString(), - }); - }; - - it('applies the first-month promo at the second-month grandfather cutoff', () => { - expect( - computeMonthlyCadenceBonusPercent({ - tier, - streakMonths: 1, - isFirstTimeSubscriberEver: true, - subscriptionStartedAtIso: KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF.toISOString(), - }) - ).toBe(KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT); - }); - it('does not apply the second-month promo at the grandfather cutoff', () => { - expect( + it('throws when streakMonths is below 1', () => { + expect(() => computeMonthlyCadenceBonusPercent({ - tier, - streakMonths: 2, + tier: KiloPassTier.Tier19, + streakMonths: 0, isFirstTimeSubscriberEver: true, - subscriptionStartedAtIso: KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF.toISOString(), - }) - ).toBeCloseTo( - KILO_PASS_TIER_CONFIG.tier_49.monthlyBaseBonusPercent + - KILO_PASS_TIER_CONFIG.tier_49.monthlyStepBonusPercent - ); - }); - - it('does not apply promo when isFirstTimeSubscriberEver is false', () => { - expect( - computeMonthlyCadenceBonusPercent({ - tier, - streakMonths: 1, - isFirstTimeSubscriberEver: false, - subscriptionStartedAtIso: '2026-01-26T23:59:59.000Z', }) - ).toBe(computeFallback({ streakMonths: 1, isFirstTimeSubscriberEver: false })); + ).toThrow('streakMonths must be >= 1'); }); }); diff --git a/apps/web/src/lib/kilo-pass/bonus.ts b/apps/web/src/lib/kilo-pass/bonus.ts index 2d66e5c889..02a9d677ab 100644 --- a/apps/web/src/lib/kilo-pass/bonus.ts +++ b/apps/web/src/lib/kilo-pass/bonus.ts @@ -1,12 +1,9 @@ import { KiloPassCadence, type KiloPassTier } from '@/lib/kilo-pass/enums'; import { KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT, - KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_BONUS_PERCENT, - KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF, KILO_PASS_TIER_CONFIG, KILO_PASS_YEARLY_MONTHLY_BONUS_PERCENT, } from '@/lib/kilo-pass/constants'; -import { dayjs } from '@/lib/kilo-pass/dayjs'; export const getMonthlyPriceUsd = (tier: KiloPassTier): number => { return KILO_PASS_TIER_CONFIG[tier].monthlyPriceUsd; @@ -28,9 +25,8 @@ export const computeMonthlyCadenceBonusPercent = (params: { tier: KiloPassTier; streakMonths: number; isFirstTimeSubscriberEver: boolean; - subscriptionStartedAtIso?: string | null; }): number => { - const { tier, streakMonths, isFirstTimeSubscriberEver, subscriptionStartedAtIso } = params; + const { tier, streakMonths, isFirstTimeSubscriberEver } = params; if (streakMonths < 1) { throw new Error('streakMonths must be >= 1'); @@ -40,22 +36,6 @@ export const computeMonthlyCadenceBonusPercent = (params: { return KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT; } - // Limited-time grandfathered promo: first-time subscribers who started strictly before the - // cutoff keep the 50% bonus for streak month 2. - if (streakMonths === 2 && isFirstTimeSubscriberEver) { - const startedAt = subscriptionStartedAtIso ?? null; - if (startedAt != null) { - const startedAtUtc = dayjs(startedAt).utc(); - - if ( - startedAtUtc.isValid() && - startedAtUtc.isBefore(KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF) - ) { - return KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_BONUS_PERCENT; - } - } - } - const config = KILO_PASS_TIER_CONFIG[tier]; const nMinus1 = streakMonths - 1; const uncapped = config.monthlyBaseBonusPercent + config.monthlyStepBonusPercent * nMinus1; diff --git a/apps/web/src/lib/kilo-pass/constants.ts b/apps/web/src/lib/kilo-pass/constants.ts index 720573548f..87405fc7fd 100644 --- a/apps/web/src/lib/kilo-pass/constants.ts +++ b/apps/web/src/lib/kilo-pass/constants.ts @@ -1,4 +1,3 @@ -import { dayjs } from '@/lib/kilo-pass/dayjs'; export { KILO_PASS_MONTHLY_RAMP_BASE_BONUS_PERCENT, KILO_PASS_MONTHLY_RAMP_CAP_BONUS_PERCENT, @@ -8,9 +7,3 @@ export { } from '@kilocode/worker-utils/kilo-pass-bonus-projection'; export const KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT = 0.5; - -// First-time subscribers receive a 50% bonus for month 2 only if they started -// strictly before this grandfather cutoff. Month 1 remains 50% for new subscribers. -export const KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF = dayjs('2026-05-07T00:00:00Z').utc(); - -export const KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_BONUS_PERCENT = 0.5; diff --git a/apps/web/src/lib/kilo-pass/issuance.test.ts b/apps/web/src/lib/kilo-pass/issuance.test.ts index 1669d74ac9..5b07e34059 100644 --- a/apps/web/src/lib/kilo-pass/issuance.test.ts +++ b/apps/web/src/lib/kilo-pass/issuance.test.ts @@ -5,6 +5,10 @@ import { insertTestUser } from '@/tests/helpers/user.helper'; import { dayjs } from '@/lib/kilo-pass/dayjs'; import { credit_transactions, + impact_referral_conversions, + impact_referral_reward_applications, + impact_referral_reward_decisions, + impact_referral_rewards, kilo_pass_audit_log, kilo_pass_issuance_items, kilo_pass_subscriptions, @@ -16,10 +20,20 @@ import { KiloPassIssuanceItemKind } from './enums'; import { KiloPassIssuanceSource } from './enums'; import { KiloPassCadence } from './enums'; import { KiloPassTier } from '@/lib/kilo-pass/enums'; -import { and, eq } from 'drizzle-orm'; +import { + ImpactReferralBeneficiaryRole, + ImpactReferralDecisionOutcome, + ImpactReferralPaymentProvider, + ImpactReferralProduct, + ImpactReferralRewardKind, + ImpactReferralRewardStatus, + ImpactReferralWinningTouchType, +} from '@kilocode/db/schema-types'; +import { and, eq, inArray } from 'drizzle-orm'; import { forceImmediateExpirationRecomputation } from '@/lib/balanceCache'; import { + applyPendingKiloPassReferralBonusForIssuance, computeIssueMonth, createOrGetIssuanceHeader, issueBaseCreditsForIssuance, @@ -61,6 +75,75 @@ async function createTestSubscription(params: { return row; } +async function createPendingKiloPassReferralReward(params: { + beneficiaryUserId: string; + beneficiaryRole?: ImpactReferralBeneficiaryRole; + earnedAt: string; + expiresAt?: string | null; + rewardAmountUsd?: number; + sourcePaymentId?: string; +}): Promise<{ rewardId: string; conversionId: string }> { + const referee = await insertTestUser({ total_microdollars_acquired: 0, microdollars_used: 0 }); + const sourcePaymentId = params.sourcePaymentId ?? `inv-referral-source-${crypto.randomUUID()}`; + const beneficiaryRole = params.beneficiaryRole ?? ImpactReferralBeneficiaryRole.Referrer; + + const [conversion] = await db + .insert(impact_referral_conversions) + .values({ + product: ImpactReferralProduct.KiloPass, + referee_user_id: referee.id, + referrer_user_id: + beneficiaryRole === ImpactReferralBeneficiaryRole.Referrer + ? params.beneficiaryUserId + : null, + winning_touch_type: ImpactReferralWinningTouchType.Referral, + payment_provider: ImpactReferralPaymentProvider.Stripe, + source_payment_id: sourcePaymentId, + qualified: true, + converted_at: params.earnedAt, + }) + .returning({ id: impact_referral_conversions.id }); + if (!conversion) throw new Error('Failed to create impact_referral_conversion'); + + const [decision] = await db + .insert(impact_referral_reward_decisions) + .values({ + product: ImpactReferralProduct.KiloPass, + conversion_id: conversion.id, + beneficiary_user_id: params.beneficiaryUserId, + beneficiary_role: beneficiaryRole, + outcome: ImpactReferralDecisionOutcome.Granted, + reward_kind: ImpactReferralRewardKind.KiloPassBonus, + reward_percent: 0.5, + source_tier: KiloPassTier.Tier49, + reward_amount_usd: params.rewardAmountUsd ?? 24.5, + }) + .returning({ id: impact_referral_reward_decisions.id }); + if (!decision) throw new Error('Failed to create impact_referral_reward_decision'); + + const [reward] = await db + .insert(impact_referral_rewards) + .values({ + product: ImpactReferralProduct.KiloPass, + conversion_id: conversion.id, + decision_id: decision.id, + beneficiary_user_id: params.beneficiaryUserId, + beneficiary_role: beneficiaryRole, + reward_kind: ImpactReferralRewardKind.KiloPassBonus, + months_granted: 0, + reward_percent: 0.5, + source_tier: KiloPassTier.Tier49, + reward_amount_usd: params.rewardAmountUsd ?? 24.5, + status: ImpactReferralRewardStatus.Pending, + earned_at: params.earnedAt, + expires_at: params.expiresAt ?? '2027-01-01T00:00:00.000Z', + }) + .returning({ id: impact_referral_rewards.id }); + if (!reward) throw new Error('Failed to create impact_referral_reward'); + + return { rewardId: reward.id, conversionId: conversion.id }; +} + test('base issuance is idempotent: calling twice only creates one credit_transaction', async () => { const user = await insertTestUser({ total_microdollars_acquired: 0, microdollars_used: 0 }); const { subscriptionId } = await createTestSubscription({ @@ -391,6 +474,274 @@ test('bonus issuance skips when a referral bonus item already exists', async () ); }); +test('monthly issuance consumes the oldest pending Kilo Pass referral reward and blocks normal bonus', async () => { + const user = await insertTestUser({ total_microdollars_acquired: 0, microdollars_used: 123 }); + const { subscriptionId } = await createTestSubscription({ + kiloUserId: user.id, + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + startedAt: '2026-02-01T00:00:00.000Z', + }); + + const newerReward = await createPendingKiloPassReferralReward({ + beneficiaryUserId: user.id, + earnedAt: '2026-01-15T00:00:00.000Z', + rewardAmountUsd: 10, + }); + const olderReward = await createPendingKiloPassReferralReward({ + beneficiaryUserId: user.id, + earnedAt: '2026-01-10T00:00:00.000Z', + rewardAmountUsd: 24.5, + }); + + const { issuanceId } = await db.transaction(async tx => { + return await createOrGetIssuanceHeader(tx, { + subscriptionId, + issueMonth: '2026-02-01', + source: KiloPassIssuanceSource.StripeInvoice, + stripeInvoiceId: `inv-referral-application-${crypto.randomUUID()}`, + }); + }); + + const result = await db.transaction(async tx => { + return await applyPendingKiloPassReferralBonusForIssuance(tx, { + issuanceId, + subscriptionId, + kiloUserId: user.id, + }); + }); + await forceImmediateExpirationRecomputation(user.id); + + expect(result).toEqual( + expect.objectContaining({ + wasIssued: true, + rewardId: olderReward.rewardId, + amountUsd: 24.5, + amountMicrodollars: 24_500_000, + }) + ); + + const [referralItem] = await db + .select({ + id: kilo_pass_issuance_items.id, + creditTransactionId: kilo_pass_issuance_items.credit_transaction_id, + amountUsd: kilo_pass_issuance_items.amount_usd, + bonusPercentApplied: kilo_pass_issuance_items.bonus_percent_applied, + }) + .from(kilo_pass_issuance_items) + .where( + and( + eq(kilo_pass_issuance_items.kilo_pass_issuance_id, issuanceId), + eq(kilo_pass_issuance_items.kind, KiloPassIssuanceItemKind.ReferralBonus) + ) + ); + expect(referralItem).toEqual( + expect.objectContaining({ + creditTransactionId: result.creditTransactionId, + amountUsd: 24.5, + bonusPercentApplied: 0.5, + }) + ); + + const credit = await db.query.credit_transactions.findFirst({ + where: eq(credit_transactions.id, result.creditTransactionId ?? ''), + }); + expect(credit).toEqual( + expect.objectContaining({ + is_free: true, + amount_microdollars: 24_500_000, + expiration_baseline_microdollars_used: 123, + original_baseline_microdollars_used: 123, + }) + ); + expect(new Date(credit?.expiry_date ?? '').toISOString()).toBe('2026-03-01T00:00:00.000Z'); + + const appliedReward = await db.query.impact_referral_rewards.findFirst({ + where: eq(impact_referral_rewards.id, olderReward.rewardId), + }); + expect(appliedReward).toEqual( + expect.objectContaining({ + status: ImpactReferralRewardStatus.Applied, + applies_to_kilo_pass_subscription_id: subscriptionId, + consumed_kilo_pass_issuance_id: issuanceId, + consumed_kilo_pass_issuance_item_id: referralItem?.id, + }) + ); + expect(appliedReward?.applied_at).toBeTruthy(); + + const application = await db.query.impact_referral_reward_applications.findFirst({ + where: eq(impact_referral_reward_applications.reward_id, olderReward.rewardId), + }); + expect(application).toEqual( + expect.objectContaining({ + product: ImpactReferralProduct.KiloPass, + beneficiary_user_id: user.id, + subscription_id: subscriptionId, + local_operation_id: result.creditTransactionId, + }) + ); + + const pendingNewerReward = await db.query.impact_referral_rewards.findFirst({ + where: eq(impact_referral_rewards.id, newerReward.rewardId), + }); + expect(pendingNewerReward?.status).toBe(ImpactReferralRewardStatus.Pending); + + const normalBonusResult = await db.transaction(async tx => { + return await issueBonusCreditsForIssuance(tx, { + issuanceId, + subscriptionId, + kiloUserId: user.id, + baseAmountUsd: KILO_PASS_TIER_CONFIG.tier_49.monthlyPriceUsd, + bonusPercentApplied: 0.1, + description: `kilo-pass-normal-bonus-after-referral-${crypto.randomUUID()}`, + }); + }); + expect(normalBonusResult.wasIssued).toBe(false); +}); + +test('monthly issuance expires stale rewards, consumes one unexpired reward per issuance, and retries idempotently', async () => { + const user = await insertTestUser({ total_microdollars_acquired: 0, microdollars_used: 0 }); + const { subscriptionId } = await createTestSubscription({ + kiloUserId: user.id, + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + startedAt: '2026-02-01T00:00:00.000Z', + }); + + const expiredReward = await createPendingKiloPassReferralReward({ + beneficiaryUserId: user.id, + earnedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-31T00:00:00.000Z', + }); + const firstReward = await createPendingKiloPassReferralReward({ + beneficiaryUserId: user.id, + earnedAt: '2026-01-02T00:00:00.000Z', + rewardAmountUsd: 7, + }); + const secondReward = await createPendingKiloPassReferralReward({ + beneficiaryUserId: user.id, + earnedAt: '2026-01-03T00:00:00.000Z', + rewardAmountUsd: 8, + }); + + const { issuanceId: issuanceId1 } = await db.transaction(async tx => { + return await createOrGetIssuanceHeader(tx, { + subscriptionId, + issueMonth: '2026-02-01', + source: KiloPassIssuanceSource.StripeInvoice, + stripeInvoiceId: `inv-referral-stack-1-${crypto.randomUUID()}`, + }); + }); + + const firstApply = await db.transaction(async tx => { + return await applyPendingKiloPassReferralBonusForIssuance(tx, { + issuanceId: issuanceId1, + subscriptionId, + kiloUserId: user.id, + }); + }); + expect(firstApply.wasIssued).toBe(true); + expect(firstApply.rewardId).toBe(firstReward.rewardId); + expect(firstApply.expiredRewardIds).toContain(expiredReward.rewardId); + + const retry = await db.transaction(async tx => { + return await applyPendingKiloPassReferralBonusForIssuance(tx, { + issuanceId: issuanceId1, + subscriptionId, + kiloUserId: user.id, + }); + }); + expect(retry.wasIssued).toBe(false); + + const { issuanceId: issuanceId2 } = await db.transaction(async tx => { + return await createOrGetIssuanceHeader(tx, { + subscriptionId, + issueMonth: '2026-03-01', + source: KiloPassIssuanceSource.StripeInvoice, + stripeInvoiceId: `inv-referral-stack-2-${crypto.randomUUID()}`, + }); + }); + + const secondApply = await db.transaction(async tx => { + return await applyPendingKiloPassReferralBonusForIssuance(tx, { + issuanceId: issuanceId2, + subscriptionId, + kiloUserId: user.id, + }); + }); + expect(secondApply.wasIssued).toBe(true); + expect(secondApply.rewardId).toBe(secondReward.rewardId); + + const rewards = await db + .select({ id: impact_referral_rewards.id, status: impact_referral_rewards.status }) + .from(impact_referral_rewards) + .where( + inArray(impact_referral_rewards.id, [ + expiredReward.rewardId, + firstReward.rewardId, + secondReward.rewardId, + ]) + ); + expect(rewards).toEqual( + expect.arrayContaining([ + { id: expiredReward.rewardId, status: ImpactReferralRewardStatus.Expired }, + { id: firstReward.rewardId, status: ImpactReferralRewardStatus.Applied }, + { id: secondReward.rewardId, status: ImpactReferralRewardStatus.Applied }, + ]) + ); + + const items1 = await db + .select({ id: kilo_pass_issuance_items.id }) + .from(kilo_pass_issuance_items) + .where( + and( + eq(kilo_pass_issuance_items.kilo_pass_issuance_id, issuanceId1), + eq(kilo_pass_issuance_items.kind, KiloPassIssuanceItemKind.ReferralBonus) + ) + ); + expect(items1).toHaveLength(1); +}); + +test('monthly issuance does not apply a referral reward to its source conversion issuance', async () => { + const user = await insertTestUser({ total_microdollars_acquired: 0, microdollars_used: 0 }); + const { subscriptionId } = await createTestSubscription({ + kiloUserId: user.id, + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + startedAt: '2026-02-01T00:00:00.000Z', + }); + const sourceInvoiceId = `inv-referral-source-${crypto.randomUUID()}`; + const reward = await createPendingKiloPassReferralReward({ + beneficiaryUserId: user.id, + earnedAt: '2026-01-01T00:00:00.000Z', + sourcePaymentId: sourceInvoiceId, + }); + + const { issuanceId } = await db.transaction(async tx => { + return await createOrGetIssuanceHeader(tx, { + subscriptionId, + issueMonth: '2026-02-01', + source: KiloPassIssuanceSource.StripeInvoice, + stripeInvoiceId: sourceInvoiceId, + }); + }); + + const result = await db.transaction(async tx => { + return await applyPendingKiloPassReferralBonusForIssuance(tx, { + issuanceId, + subscriptionId, + kiloUserId: user.id, + stripeInvoiceId: sourceInvoiceId, + }); + }); + + expect(result.wasIssued).toBe(false); + const rewardAfter = await db.query.impact_referral_rewards.findFirst({ + where: eq(impact_referral_rewards.id, reward.rewardId), + }); + expect(rewardAfter?.status).toBe(ImpactReferralRewardStatus.Pending); +}); + test('monthly cadence: bonus expiry is end of the subscription month (period end), not month end', async () => { const user = await insertTestUser({ total_microdollars_acquired: 0, microdollars_used: 0 }); const startedAt = '2025-04-04T00:00:00.000Z'; diff --git a/apps/web/src/lib/kilo-pass/issuance.ts b/apps/web/src/lib/kilo-pass/issuance.ts index 5728e03316..fdb37b60ad 100644 --- a/apps/web/src/lib/kilo-pass/issuance.ts +++ b/apps/web/src/lib/kilo-pass/issuance.ts @@ -1,5 +1,8 @@ import { credit_transactions, + impact_referral_conversions, + impact_referral_reward_applications, + impact_referral_rewards, kilo_pass_audit_log, kilo_pass_issuance_items, kilo_pass_issuances, @@ -7,6 +10,11 @@ import { kilocode_users, } from '@kilocode/db/schema'; import type { User } from '@kilocode/db/schema'; +import { + ImpactReferralProduct, + ImpactReferralRewardKind, + ImpactReferralRewardStatus, +} from '@kilocode/db/schema-types'; import { KiloPassAuditLogResult } from './enums'; import { KiloPassAuditLogAction } from './enums'; import { KiloPassCadence } from './enums'; @@ -17,7 +25,7 @@ import type { db as defaultDb } from '@/lib/drizzle'; import { processTopUp } from '@/lib/credits'; import { grantCreditForCategory, type GrantCreditOptions } from '@/lib/promotionalCredits'; import { toMicrodollars } from '@/lib/utils'; -import { and, eq } from 'drizzle-orm'; +import { and, asc, eq, gt, inArray, isNull, lt, lte, ne, sql } from 'drizzle-orm'; import type { DrizzleTransaction } from '@/lib/drizzle'; import { dayjs } from '@/lib/kilo-pass/dayjs'; @@ -442,6 +450,273 @@ export async function issueBaseCreditsForIssuance( }; } +async function getExistingBonusLikeIssuanceItem( + tx: DrizzleTransaction, + params: { issuanceId: string } +): Promise<{ + issuanceItemId: string; + creditTransactionId: string; + kind: KiloPassIssuanceItemKind; +} | null> { + const rows = await tx + .select({ + issuanceItemId: kilo_pass_issuance_items.id, + creditTransactionId: kilo_pass_issuance_items.credit_transaction_id, + kind: kilo_pass_issuance_items.kind, + }) + .from(kilo_pass_issuance_items) + .where( + and( + eq(kilo_pass_issuance_items.kilo_pass_issuance_id, params.issuanceId), + inArray(kilo_pass_issuance_items.kind, [ + KiloPassIssuanceItemKind.Bonus, + KiloPassIssuanceItemKind.PromoFirstMonth50Pct, + KiloPassIssuanceItemKind.ReferralBonus, + ]) + ) + ) + .limit(1); + + return rows[0] ?? null; +} + +export type KiloPassReferralBonusApplicationResult = IssueCreditResult & { + rewardId: string | null; + expiredRewardIds: string[]; +}; + +export async function applyPendingKiloPassReferralBonusForIssuance( + tx: DrizzleTransaction, + params: { + issuanceId: string; + subscriptionId: string; + kiloUserId: string; + stripeInvoiceId?: string | null; + } +): Promise { + const { issuanceId, subscriptionId, kiloUserId, stripeInvoiceId } = params; + + await lockIssuanceRow(tx, issuanceId); + + const issuanceRows = await tx + .select({ + issueMonth: kilo_pass_issuances.issue_month, + stripeInvoiceId: kilo_pass_issuances.stripe_invoice_id, + createdAt: kilo_pass_issuances.created_at, + }) + .from(kilo_pass_issuances) + .where(eq(kilo_pass_issuances.id, issuanceId)) + .limit(1); + + const issuance = issuanceRows[0]; + if (!issuance) { + throw new Error(`Issuance not found: ${issuanceId}`); + } + + const applicationCutoff = issuance.createdAt; + const sourcePaymentId = stripeInvoiceId ?? issuance.stripeInvoiceId; + + const expiredRewards = await tx + .update(impact_referral_rewards) + .set({ + status: ImpactReferralRewardStatus.Expired, + reversed_at: applicationCutoff, + review_reason: 'expired_before_kilo_pass_referral_bonus_application', + }) + .where( + and( + eq(impact_referral_rewards.product, ImpactReferralProduct.KiloPass), + eq(impact_referral_rewards.reward_kind, ImpactReferralRewardKind.KiloPassBonus), + eq(impact_referral_rewards.beneficiary_user_id, kiloUserId), + inArray(impact_referral_rewards.status, [ + ImpactReferralRewardStatus.Pending, + ImpactReferralRewardStatus.Earned, + ]), + sql`${impact_referral_rewards.expires_at} IS NOT NULL`, + lte(impact_referral_rewards.expires_at, applicationCutoff), + isNull(impact_referral_rewards.applied_at), + isNull(impact_referral_rewards.consumed_kilo_pass_issuance_id) + ) + ) + .returning({ id: impact_referral_rewards.id }); + + const existingBonusLikeItem = await getExistingBonusLikeIssuanceItem(tx, { issuanceId }); + if (existingBonusLikeItem) { + return { + wasIssued: false, + rewardId: null, + expiredRewardIds: expiredRewards.map(reward => reward.id), + issuanceItemId: existingBonusLikeItem.issuanceItemId, + creditTransactionId: existingBonusLikeItem.creditTransactionId, + amountUsd: 0, + amountMicrodollars: 0, + }; + } + + const rewardRows = await tx + .select({ + id: impact_referral_rewards.id, + rewardAmountUsd: impact_referral_rewards.reward_amount_usd, + rewardPercent: impact_referral_rewards.reward_percent, + sourcePaymentId: impact_referral_conversions.source_payment_id, + }) + .from(impact_referral_rewards) + .innerJoin( + impact_referral_conversions, + eq(impact_referral_conversions.id, impact_referral_rewards.conversion_id) + ) + .where( + and( + eq(impact_referral_rewards.product, ImpactReferralProduct.KiloPass), + eq(impact_referral_rewards.reward_kind, ImpactReferralRewardKind.KiloPassBonus), + eq(impact_referral_rewards.beneficiary_user_id, kiloUserId), + inArray(impact_referral_rewards.status, [ + ImpactReferralRewardStatus.Pending, + ImpactReferralRewardStatus.Earned, + ]), + isNull(impact_referral_rewards.applied_at), + isNull(impact_referral_rewards.consumed_kilo_pass_issuance_id), + lt(impact_referral_rewards.earned_at, applicationCutoff), + gt(impact_referral_rewards.expires_at, applicationCutoff), + sourcePaymentId + ? ne(impact_referral_conversions.source_payment_id, sourcePaymentId) + : undefined + ) + ) + .orderBy(asc(impact_referral_rewards.earned_at), asc(impact_referral_rewards.created_at)) + .for('update') + .limit(1); + + const reward = rewardRows[0]; + if (!reward || reward.rewardAmountUsd == null) { + return { + wasIssued: false, + rewardId: null, + expiredRewardIds: expiredRewards.map(expiredReward => expiredReward.id), + issuanceItemId: null, + creditTransactionId: null, + amountUsd: 0, + amountMicrodollars: 0, + }; + } + + const user = await getUserForCreditMutations(tx, kiloUserId); + const rewardCents = roundUsdToCents(reward.rewardAmountUsd); + const rewardAmountUsd = centsToUsd(rewardCents); + const rewardAmountMicrodollars = toMicrodollars(rewardAmountUsd); + const bonusPercentApplied = reward.rewardPercent ?? 0.5; + + const creditExpiryDate = await computeKiloPassBonusExpiryDate(tx, { + issuanceId, + subscriptionId, + }); + + const creditCategory = 'kilo-pass-bonus'; + const grantResult = await grantCreditForCategory(user, { + credit_category: creditCategory, + counts_as_selfservice: false, + amount_usd: rewardAmountUsd, + description: `Kilo Pass referral bonus (${issuance.issueMonth})`, + credit_expiry_date: creditExpiryDate ?? undefined, + dbOrTx: tx, + }); + if (!grantResult.success) { + throw new Error(`Failed to grant Kilo Pass referral bonus credits: ${grantResult.message}`); + } + + const creditTransactionId = grantResult.credit_transaction_id; + const issuanceItemInsert = await tx + .insert(kilo_pass_issuance_items) + .values({ + kilo_pass_issuance_id: issuanceId, + kind: KiloPassIssuanceItemKind.ReferralBonus, + credit_transaction_id: creditTransactionId, + amount_usd: rewardAmountUsd, + bonus_percent_applied: bonusPercentApplied, + }) + .returning({ issuanceItemId: kilo_pass_issuance_items.id }); + + const issuanceItemId = issuanceItemInsert[0]?.issuanceItemId; + if (!issuanceItemId) { + throw new Error('Failed to insert issuance item for referral bonus credits'); + } + + const appliedAt = new Date().toISOString(); + const appliedRows = await tx + .update(impact_referral_rewards) + .set({ + status: ImpactReferralRewardStatus.Applied, + applies_to_kilo_pass_subscription_id: subscriptionId, + consumed_kilo_pass_issuance_id: issuanceId, + consumed_kilo_pass_issuance_item_id: issuanceItemId, + applied_at: appliedAt, + review_reason: null, + }) + .where( + and( + eq(impact_referral_rewards.id, reward.id), + inArray(impact_referral_rewards.status, [ + ImpactReferralRewardStatus.Pending, + ImpactReferralRewardStatus.Earned, + ]), + isNull(impact_referral_rewards.applied_at), + isNull(impact_referral_rewards.consumed_kilo_pass_issuance_id) + ) + ) + .returning({ id: impact_referral_rewards.id }); + + if (!appliedRows[0]) { + throw new Error(`Failed to mark Kilo Pass referral reward applied: ${reward.id}`); + } + + const existingApplication = await tx.query.impact_referral_reward_applications.findFirst({ + columns: { id: true }, + where: eq(impact_referral_reward_applications.reward_id, reward.id), + }); + + if (!existingApplication) { + const issueMonthStart = `${issuance.issueMonth}T00:00:00.000Z`; + await tx.insert(impact_referral_reward_applications).values({ + product: ImpactReferralProduct.KiloPass, + reward_id: reward.id, + beneficiary_user_id: kiloUserId, + subscription_id: subscriptionId, + previous_renewal_boundary: issueMonthStart, + new_renewal_boundary: creditExpiryDate?.toISOString() ?? issueMonthStart, + local_operation_id: creditTransactionId, + stripe_operation_id: sourcePaymentId ?? null, + applied_at: appliedAt, + }); + } + + await appendKiloPassAuditLog(tx, { + action: KiloPassAuditLogAction.BonusCreditsIssued, + result: KiloPassAuditLogResult.Success, + kiloUserId, + kiloPassSubscriptionId: subscriptionId, + stripeInvoiceId: sourcePaymentId ?? null, + relatedCreditTransactionId: creditTransactionId, + relatedMonthlyIssuanceId: issuanceId, + payload: { + kind: KiloPassIssuanceItemKind.ReferralBonus, + rewardId: reward.id, + bonusPercentApplied, + bonusAmountUsd: rewardAmountUsd, + creditCategory, + }, + }); + + return { + wasIssued: true, + rewardId: reward.id, + expiredRewardIds: expiredRewards.map(expiredReward => expiredReward.id), + issuanceItemId, + creditTransactionId, + amountUsd: rewardAmountUsd, + amountMicrodollars: rewardAmountMicrodollars, + }; +} + export async function issueBonusCreditsForIssuance( tx: DrizzleTransaction, params: { diff --git a/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.test.ts b/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.test.ts index 5e69ef72ac..935eea699f 100644 --- a/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.test.ts +++ b/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.test.ts @@ -465,6 +465,88 @@ describe('handleKiloPassInvoicePaid', () => { ); }); + test('monthly: referral conversion processor suppresses affiliate SALE when referral wins', async () => { + const processPersonalKiloPassStripePaidConversion = jest.fn(async (_params: unknown) => ({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'referral', + conversionId: 'conversion_referral_winner', + disqualificationReason: null, + })); + + try { + jest.resetModules(); + jest.doMock('@/lib/impact/kilo-pass-referrals', () => ({ + __esModule: true, + processPersonalKiloPassStripePaidConversion, + })); + + const { handleKiloPassInvoicePaid } = + await import('@/lib/kilo-pass/stripe-handlers-invoice-paid'); + const user = await insertTestUser({ total_microdollars_acquired: 0, microdollars_used: 0 }); + await seedDeliveredImpactSignupEvent(user.id, user.google_user_email); + const stripeSubId = `sub_referral_suppresses_affiliate_${Math.random()}`; + const meta = kiloPassMetadata({ + kiloUserId: user.id, + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + }); + const subscription = makeStripeSubscription({ + id: stripeSubId, + start_date_seconds: 1_767_225_600, + metadata: meta, + }); + const priceId = await getKiloPassPriceId({ + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + }); + const invoiceId = `inv_referral_suppresses_affiliate_${Math.random()}`; + + await handleKiloPassInvoicePaid({ + eventId: 'evt_referral_suppresses_affiliate', + invoice: makeStripeInvoice({ + id: invoiceId, + amount_paid_cents: 4900, + period_start_seconds: 1_767_225_600, + created_seconds: 1_767_225_600, + paid_seconds: 1_767_225_660, + priceId, + subscriptionIdOrExpanded: stripeSubId, + metadata: meta, + }), + stripe: { + subscriptions: { + retrieve: jest.fn(async () => subscription), + }, + } as unknown as Stripe, + }); + + expect(processPersonalKiloPassStripePaidConversion).toHaveBeenCalledWith( + expect.objectContaining({ + userId: user.id, + sourcePaymentId: invoiceId, + amount: 49, + itemCategory: 'kilo-pass-tier-49-monthly', + sourceTier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + welcomePromoEligibilityReason: KiloPassWelcomePromoEligibilityReason.SettlementUnresolved, + }) + ); + const saleEvents = await db + .select() + .from(user_affiliate_events) + .where( + and( + eq(user_affiliate_events.user_id, user.id), + eq(user_affiliate_events.event_type, 'sale') + ) + ); + expect(saleEvents).toHaveLength(0); + } finally { + jest.dontMock('@/lib/impact/kilo-pass-referrals'); + jest.resetModules(); + } + }); + test('monthly: sale fallback occurrence time preserves recoverable Stripe charge identity', async () => { const observedBeforeHandling = Date.now(); const { handleKiloPassInvoicePaid } = diff --git a/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts b/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts index 393f671a97..bdfe758303 100644 --- a/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts +++ b/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts @@ -17,6 +17,7 @@ import { KILO_PASS_TIER_CONFIG } from '@/lib/kilo-pass/constants'; import { KiloPassError } from '@/lib/kilo-pass/errors'; import { appendKiloPassAuditLog, + applyPendingKiloPassReferralBonusForIssuance, createOrGetIssuanceHeader, issueBaseCreditsForIssuance, } from '@/lib/kilo-pass/issuance'; @@ -54,8 +55,10 @@ import { } from '@/lib/kilo-pass/subscription-accounting'; import { enqueueKiloPassAffiliateSaleForInvoice, + getKiloPassAffiliateSaleReportingFields, type KiloPassAffiliateSaleContext, } from '@/lib/kilo-pass/affiliate-sale'; +import { processPersonalKiloPassStripePaidConversion } from '@/lib/impact/kilo-pass-referrals'; import type { SupportedReusablePaymentMethodType } from '@/lib/kilo-pass/stripe-handlers-utils'; function getPaymentFingerprintType( @@ -355,8 +358,26 @@ export async function handleKiloPassInvoicePaid(params: { let didMutateBalance = false; let kiloUserIdForCache: string | null = null; - const affiliateSaleState: { context: KiloPassAffiliateSaleContext | null } = { + const affiliateSaleState: { + context: KiloPassAffiliateSaleContext | null; + shouldEnqueueAffiliateSale: boolean; + } = { context: null, + shouldEnqueueAffiliateSale: true, + }; + const referralConversionState: { + kiloPassSubscriptionId: string | null; + userId: string | null; + tier: KiloPassAffiliateSaleContext['tier'] | null; + cadence: KiloPassAffiliateSaleContext['cadence'] | null; + welcomePromoEligibilityReason: KiloPassWelcomePromoEligibilityReason | null; + itemSku?: string; + } = { + kiloPassSubscriptionId: null, + userId: null, + tier: null, + cadence: null, + welcomePromoEligibilityReason: null, }; // Track context for failure audit logging @@ -484,6 +505,13 @@ export async function handleKiloPassInvoicePaid(params: { const kiloPassSubscriptionId = row.id; kiloPassSubscriptionIdForAudit = kiloPassSubscriptionId; + referralConversionState.kiloPassSubscriptionId = kiloPassSubscriptionId; + referralConversionState.userId = kiloUserId; + referralConversionState.tier = tier; + referralConversionState.cadence = cadence; + if (priceMetadata) { + referralConversionState.itemSku = priceMetadata.priceId; + } const priorStatus = existingSubscription?.status ?? null; const issuanceHeader = await createOrGetIssuanceHeader(tx, { @@ -497,6 +525,7 @@ export async function handleKiloPassInvoicePaid(params: { cadence === KiloPassCadence.Monthly && hasPositiveSettlement ? await claimMonthlyPaymentFingerprint({ tx, stripe, invoice }) : null; + referralConversionState.welcomePromoEligibilityReason = positiveSettlementReason; const initialWelcomePromoEligibilityReason = cadence === KiloPassCadence.Monthly ? await getOrCreateInitialMonthlyWelcomePromoReason({ @@ -546,7 +575,20 @@ export async function handleKiloPassInvoicePaid(params: { }); didMutateBalance ||= baseCreditsResult.wasIssued; - if (baseCreditsResult.wasIssued) { + let referralBonusBlocksNormalBonus = false; + if (cadence === KiloPassCadence.Monthly) { + const referralBonusResult = await applyPendingKiloPassReferralBonusForIssuance(tx, { + issuanceId: issuanceHeader.issuanceId, + subscriptionId: kiloPassSubscriptionId, + kiloUserId, + stripeInvoiceId: invoice.id, + }); + referralBonusBlocksNormalBonus = + referralBonusResult.wasIssued || referralBonusResult.issuanceItemId !== null; + didMutateBalance ||= referralBonusResult.wasIssued; + } + + if (baseCreditsResult.wasIssued && !referralBonusBlocksNormalBonus) { await updateKiloPassThresholdAfterBaseCredits(tx, { kiloUserId, baseAmountUsd: tierConfig.monthlyPriceUsd, @@ -614,12 +656,50 @@ export async function handleKiloPassInvoicePaid(params: { throw error; } - await enqueueKiloPassAffiliateSaleForInvoice({ - eventId, - invoice, - stripe, - context: affiliateSaleState.context, - }); + if ( + affiliateSaleState.context && + referralConversionState.kiloPassSubscriptionId && + referralConversionState.userId && + referralConversionState.tier && + referralConversionState.cadence + ) { + const convertedAt = + invoice.status_transitions?.paid_at != null + ? new Date(invoice.status_transitions.paid_at * 1000) + : invoice.created != null + ? new Date(invoice.created * 1000) + : invoice.period_start != null + ? new Date(invoice.period_start * 1000) + : new Date(); + const reportingFields = getKiloPassAffiliateSaleReportingFields(affiliateSaleState.context); + const referralDisposition = await processPersonalKiloPassStripePaidConversion({ + userId: referralConversionState.userId, + kiloPassSubscriptionId: referralConversionState.kiloPassSubscriptionId, + sourcePaymentId: invoice.id, + orderId: invoice.id, + amount: invoice.amount_paid / 100, + currencyCode: invoice.currency ?? 'usd', + itemCategory: reportingFields.itemCategory, + itemName: reportingFields.itemName, + ...('itemSku' in reportingFields && reportingFields.itemSku + ? { itemSku: reportingFields.itemSku } + : {}), + sourceTier: referralConversionState.tier, + cadence: referralConversionState.cadence, + welcomePromoEligibilityReason: referralConversionState.welcomePromoEligibilityReason, + convertedAt, + }); + affiliateSaleState.shouldEnqueueAffiliateSale = referralDisposition.shouldEnqueueAffiliateSale; + } + + if (affiliateSaleState.shouldEnqueueAffiliateSale) { + await enqueueKiloPassAffiliateSaleForInvoice({ + eventId, + invoice, + stripe, + context: affiliateSaleState.context, + }); + } if (didMutateBalance && kiloUserIdForCache !== null) { await forceImmediateExpirationRecomputation(kiloUserIdForCache); diff --git a/apps/web/src/lib/kilo-pass/usage-triggered-bonus.test.ts b/apps/web/src/lib/kilo-pass/usage-triggered-bonus.test.ts index f4c9d30d28..5ece3c4bf0 100644 --- a/apps/web/src/lib/kilo-pass/usage-triggered-bonus.test.ts +++ b/apps/web/src/lib/kilo-pass/usage-triggered-bonus.test.ts @@ -19,7 +19,6 @@ import { KiloPassWelcomePromoEligibilityReason, } from '@/lib/kilo-pass/enums'; import { computeMonthlyCadenceBonusPercent, getMonthlyPriceUsd } from '@/lib/kilo-pass/bonus'; -import { KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF } from '@/lib/kilo-pass/constants'; import { maybeIssueKiloPassBonusFromUsageThreshold } from '@/lib/kilo-pass/usage-triggered-bonus'; import { and, eq } from 'drizzle-orm'; @@ -129,10 +128,7 @@ describe('maybeIssueKiloPassBonusFromUsageThreshold', () => { stripeInvoiceId: 'inv_test_monthly', currentStreakMonths: 2, nextYearlyIssueAt: null, - // Ensure this test remains a "regular ramp" case, not eligible for the month-2 grandfathered promo. - startedAtIso: new Date( - KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF.valueOf() + 1 - ).toISOString(), + startedAtIso: '2026-01-01T00:00:00.000Z', }); await maybeIssueKiloPassBonusFromUsageThreshold({ @@ -162,7 +158,7 @@ describe('maybeIssueKiloPassBonusFromUsageThreshold', () => { expect(userRow?.kilo_pass_threshold).toBeNull(); }); - test('monthly: first-2-months promo eligible => 50% bonus (tier_49, streak=2)', async () => { + test('monthly: skips usage-triggered bonus when referral_bonus item already exists and clears threshold', async () => { const user = await insertTestUser({ microdollars_used: 55_000_000, kilo_pass_threshold: 49_000_000, @@ -172,78 +168,35 @@ describe('maybeIssueKiloPassBonusFromUsageThreshold', () => { kiloUserId: user.id, cadence: KiloPassCadence.Monthly, tier: KiloPassTier.Tier49, - issueMonth: '2026-02-01', - stripeInvoiceId: 'inv_test_monthly_month2_grandfathered_eligible', - currentStreakMonths: 2, + issueMonth: '2026-01-01', + stripeInvoiceId: 'inv_test_monthly_referral_bonus_blocks_usage_bonus', + currentStreakMonths: 1, nextYearlyIssueAt: null, - startedAtIso: '2026-01-26T23:59:59.000Z', - }); - - await maybeIssueKiloPassBonusFromUsageThreshold({ - kiloUserId: user.id, - nowIso: new Date('2026-02-15T00:00:00.000Z').toISOString(), - db, - }); - - const bonusItem = await db.query.kilo_pass_issuance_items.findFirst({ - where: and( - eq(kilo_pass_issuance_items.kilo_pass_issuance_id, issuanceId), - eq(kilo_pass_issuance_items.kind, KiloPassIssuanceItemKind.Bonus) - ), }); - expect(bonusItem).toBeTruthy(); - - const bonusTx = await db.query.credit_transactions.findFirst({ - where: eq(credit_transactions.id, bonusItem?.credit_transaction_id ?? ''), - }); - // tier_49 monthly price is $49, 50% => $24.50. - expect(bonusTx?.amount_microdollars).toBe(24_500_000); - - const auditRows = await db - .select({ payload: kilo_pass_audit_log.payload_json }) - .from(kilo_pass_audit_log) - .where( - and( - eq(kilo_pass_audit_log.action, KiloPassAuditLogAction.BonusCreditsIssued), - eq(kilo_pass_audit_log.related_monthly_issuance_id, issuanceId) - ) - ); - const payload = auditRows[0]?.payload ?? null; - expect(isRecord(payload)).toBe(true); - if (!isRecord(payload)) - throw new Error('Expected bonus issuance audit payload to be an object'); - - const decision = payload.monthlyBonusDecision; - expect(isRecord(decision)).toBe(true); - if (!isRecord(decision)) { - throw new Error('Expected audit payload to include monthlyBonusDecision object'); - } - - expect(decision.streakMonths).toBe(2); - expect(decision.issueMonth).toBe('2026-02-01'); - }); - - test('monthly: first-2-months promo ineligible at cutoff => ramp applies (not 50%)', async () => { - const user = await insertTestUser({ - microdollars_used: 55_000_000, - kilo_pass_threshold: 49_000_000, - }); + const [referralCredit] = await db + .insert(credit_transactions) + .values({ + kilo_user_id: user.id, + amount_microdollars: 24_500_000, + is_free: true, + description: 'seed referral bonus credits', + credit_category: `test-kilo-pass-referral-bonus-${crypto.randomUUID()}`, + }) + .returning({ id: credit_transactions.id }); + if (!referralCredit) throw new Error('Failed to insert referral credit transaction'); - const { issuanceId } = await seedBaseIssuance({ - kiloUserId: user.id, - cadence: KiloPassCadence.Monthly, - tier: KiloPassTier.Tier49, - issueMonth: '2026-02-01', - stripeInvoiceId: 'inv_test_monthly_month2_grandfathered_ineligible_cutoff', - currentStreakMonths: 2, - nextYearlyIssueAt: null, - startedAtIso: KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF.toISOString(), + await db.insert(kilo_pass_issuance_items).values({ + kilo_pass_issuance_id: issuanceId, + kind: KiloPassIssuanceItemKind.ReferralBonus, + credit_transaction_id: referralCredit.id, + amount_usd: 24.5, + bonus_percent_applied: 0.5, }); await maybeIssueKiloPassBonusFromUsageThreshold({ kiloUserId: user.id, - nowIso: new Date('2026-02-15T00:00:00.000Z').toISOString(), + nowIso: new Date('2026-01-15T00:00:00.000Z').toISOString(), db, }); @@ -253,16 +206,15 @@ describe('maybeIssueKiloPassBonusFromUsageThreshold', () => { eq(kilo_pass_issuance_items.kind, KiloPassIssuanceItemKind.Bonus) ), }); - expect(bonusItem).toBeTruthy(); + expect(bonusItem).toBeFalsy(); - const bonusTx = await db.query.credit_transactions.findFirst({ - where: eq(credit_transactions.id, bonusItem?.credit_transaction_id ?? ''), + const userRow = await db.query.kilocode_users.findFirst({ + where: eq(kilocode_users.id, user.id), }); - // tier_49 at streak=2 => base 5% + step 5% * 1 = 10% of $49.00 = $4.90. - expect(bonusTx?.amount_microdollars).toBe(4_900_000); + expect(userRow?.kilo_pass_threshold).toBeNull(); }); - test('monthly: first-2-months promo started AFTER cutoff => ramp applies (not 50%)', async () => { + test('monthly: first-time month 2 uses ramp (not 50%)', async () => { const user = await insertTestUser({ microdollars_used: 55_000_000, kilo_pass_threshold: 49_000_000, @@ -270,16 +222,14 @@ describe('maybeIssueKiloPassBonusFromUsageThreshold', () => { const tier = KiloPassTier.Tier49; const streakMonths = 2; - const startedAtIso = new Date( - KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF.valueOf() + 1 - ).toISOString(); + const startedAtIso = '2026-01-01T00:00:00.000Z'; const { issuanceId } = await seedBaseIssuance({ kiloUserId: user.id, cadence: KiloPassCadence.Monthly, tier, issueMonth: '2026-02-01', - stripeInvoiceId: 'inv_test_monthly_month2_grandfathered_ineligible_after_cutoff', + stripeInvoiceId: 'inv_test_monthly_month2_first_time_ramp', currentStreakMonths: streakMonths, nextYearlyIssueAt: null, startedAtIso, @@ -342,7 +292,7 @@ describe('maybeIssueKiloPassBonusFromUsageThreshold', () => { expect(decision.issueMonth).toBe('2026-02-01'); }); - test('monthly: month-3 regression (started before cutoff) => ramp applies', async () => { + test('monthly: month 3 uses ramp', async () => { const user = await insertTestUser({ microdollars_used: 55_000_000, kilo_pass_threshold: 49_000_000, diff --git a/apps/web/src/lib/kilo-pass/usage-triggered-bonus.ts b/apps/web/src/lib/kilo-pass/usage-triggered-bonus.ts index b7c8f84ccc..c6291b886f 100644 --- a/apps/web/src/lib/kilo-pass/usage-triggered-bonus.ts +++ b/apps/web/src/lib/kilo-pass/usage-triggered-bonus.ts @@ -64,10 +64,9 @@ export function computeUsageTriggeredMonthlyBonusDecision(params: { tier: params.tier, streakMonths, isFirstTimeSubscriberEver: isEligibleForFirstMonthPromo, - subscriptionStartedAtIso: params.startedAtIso, }); - const shouldIssueFirstMonthPromo = bonusPercentApplied === 0.5 && streakMonths <= 2; + const shouldIssueFirstMonthPromo = bonusPercentApplied === 0.5 && streakMonths === 1; const decisionWithContext = { monthlyBonusDecision: { diff --git a/apps/web/src/lib/kilo-pass/usage-triggered-bonus.unit.test.ts b/apps/web/src/lib/kilo-pass/usage-triggered-bonus.unit.test.ts index 2038e456e9..6a6349242d 100644 --- a/apps/web/src/lib/kilo-pass/usage-triggered-bonus.unit.test.ts +++ b/apps/web/src/lib/kilo-pass/usage-triggered-bonus.unit.test.ts @@ -5,7 +5,6 @@ import { computeUsageTriggeredMonthlyBonusDecision, computeUsageTriggeredYearlyIssueMonth, } from '@/lib/kilo-pass/usage-triggered-bonus'; -import { KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF } from '@/lib/kilo-pass/constants'; describe('usage-triggered-bonus (unit)', () => { describe('computeUsageTriggeredMonthlyBonusDecision', () => { @@ -28,12 +27,10 @@ describe('usage-triggered-bonus (unit)', () => { ); }); - test('eligible promo => shouldIssueFirstMonthPromo=true, bonusKind=promo-50pct, and promo description', () => { + test('first-time month 1 promo => shouldIssueFirstMonthPromo=true, bonusKind=promo-50pct, and promo description', () => { const d = computeUsageTriggeredMonthlyBonusDecision({ tier: KiloPassTier.Tier19, - startedAtIso: new Date( - KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF.valueOf() - 1 - ).toISOString(), + startedAtIso: '2026-01-01T00:00:00.000Z', currentStreakMonths: 1, isFirstTimeSubscriberEver: true, issueMonth: '2026-01-01', @@ -116,10 +113,10 @@ describe('usage-triggered-bonus (unit)', () => { } ); - test('ineligible at promo cutoff => uses ramp (not 50%) and bonusKind=monthly-ramp', () => { + test('first-time month 2 uses ramp (not 50%) and bonusKind=monthly-ramp', () => { const d = computeUsageTriggeredMonthlyBonusDecision({ tier: KiloPassTier.Tier49, - startedAtIso: KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF.toISOString(), + startedAtIso: '2026-01-01T00:00:00.000Z', currentStreakMonths: 2, isFirstTimeSubscriberEver: true, issueMonth: '2026-02-01', diff --git a/apps/web/src/lib/stripe/index.test.ts b/apps/web/src/lib/stripe/index.test.ts index 5b5c85f217..fdaa92f4fd 100644 --- a/apps/web/src/lib/stripe/index.test.ts +++ b/apps/web/src/lib/stripe/index.test.ts @@ -78,6 +78,9 @@ import { stripe_early_fraud_warning_cases, user_affiliate_attributions, user_affiliate_events, + impact_referral_conversions, + impact_referral_reward_decisions, + impact_referral_rewards, } from '@kilocode/db/schema'; import { db, auto_deleted_at } from '@/lib/drizzle'; import { insertTestUser } from '@/tests/helpers/user.helper'; @@ -92,6 +95,15 @@ import { KiloPassScheduledChangeStatus, KiloPassTier, } from '@/lib/kilo-pass/enums'; +import { + ImpactReferralBeneficiaryRole, + ImpactReferralDecisionOutcome, + ImpactReferralPaymentProvider, + ImpactReferralProduct, + ImpactReferralRewardKind, + ImpactReferralRewardStatus, + ImpactReferralWinningTouchType, +} from '@kilocode/db/schema-types'; import type * as kiloclawStripeHandlersModule from '@/lib/kiloclaw/stripe-handlers'; import type * as kiloPassStripeHandlersModule from '@/lib/kilo-pass/stripe-handlers'; import { cleanupDbForTest } from '@/lib/drizzle'; @@ -212,6 +224,90 @@ async function mockChargeRetrieveForKiloClaw(priceId: string) { } as unknown as Stripe.Response); } +async function mockChargeRetrieveForKiloPass(invoiceId: string, userId: string) { + const { client } = await import('@/lib/stripe-client'); + const invoice = { + id: invoiceId, + object: 'invoice', + parent: { + subscription_details: { + metadata: { + type: 'kilo-pass', + kiloUserId: userId, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }, + }, + }, + lines: { data: [] }, + } as unknown as Stripe.Invoice; + return jest.spyOn(client.charges, 'retrieve').mockResolvedValue({ + invoice, + lastResponse: { headers: {}, requestId: 'req_test', statusCode: 200 }, + } as unknown as Stripe.Response); +} + +async function seedKiloPassReferralReward(params: { + userId: string; + invoiceId: string; + status: ImpactReferralRewardStatus; +}) { + const [conversion] = await db + .insert(impact_referral_conversions) + .values({ + product: ImpactReferralProduct.KiloPass, + referee_user_id: params.userId, + winning_touch_type: ImpactReferralWinningTouchType.Referral, + payment_provider: ImpactReferralPaymentProvider.Stripe, + source_payment_id: params.invoiceId, + qualified: true, + converted_at: '2026-01-03T00:00:00.000Z', + }) + .returning({ id: impact_referral_conversions.id }); + if (!conversion) throw new Error('Failed to insert Kilo Pass referral conversion'); + + const [decision] = await db + .insert(impact_referral_reward_decisions) + .values({ + product: ImpactReferralProduct.KiloPass, + conversion_id: conversion.id, + beneficiary_user_id: params.userId, + beneficiary_role: ImpactReferralBeneficiaryRole.Referee, + outcome: ImpactReferralDecisionOutcome.Granted, + reward_kind: ImpactReferralRewardKind.KiloPassBonus, + months_granted: 0, + reward_percent: 0.5, + source_tier: KiloPassTier.Tier19, + reward_amount_usd: 9.5, + }) + .returning({ id: impact_referral_reward_decisions.id }); + if (!decision) throw new Error('Failed to insert Kilo Pass referral decision'); + + const [reward] = await db + .insert(impact_referral_rewards) + .values({ + product: ImpactReferralProduct.KiloPass, + conversion_id: conversion.id, + decision_id: decision.id, + beneficiary_user_id: params.userId, + beneficiary_role: ImpactReferralBeneficiaryRole.Referee, + reward_kind: ImpactReferralRewardKind.KiloPassBonus, + months_granted: 0, + reward_percent: 0.5, + source_tier: KiloPassTier.Tier19, + reward_amount_usd: 9.5, + status: params.status, + earned_at: '2026-01-03T00:00:00.000Z', + applied_at: + params.status === ImpactReferralRewardStatus.Applied ? '2026-02-01T00:00:00.000Z' : null, + expires_at: '2027-01-03T00:00:00.000Z', + }) + .returning({ id: impact_referral_rewards.id }); + if (!reward) throw new Error('Failed to insert Kilo Pass referral reward'); + + return reward.id; +} + const sampleStripeDispute = ( overrides: Partial & Pick ): Stripe.Dispute => { @@ -1523,6 +1619,124 @@ describe('processStripePaymentEventHook', () => { expect(reversalEvents[0]?.payload_json.disputeId).toBe('dp_kilo_pass_metadata_partial'); }); + test('charge.dispute.created marks applied Kilo Pass referral rewards for support review', async () => { + await cleanupDbForTest(); + testUser = await insertTestUser(); + const invoiceId = 'in_kilo_pass_referral_dispute'; + const rewardId = await seedKiloPassReferralReward({ + userId: testUser.id, + invoiceId, + status: ImpactReferralRewardStatus.Applied, + }); + const retrieveSpy = await mockChargeRetrieveForKiloPass(invoiceId, testUser.id); + + const event: Stripe.Event = { + ...baseStripeEvent(), + id: 'evt_dispute_kilo_pass_referral_reward', + type: 'charge.dispute.created', + data: { + object: sampleStripeDispute({ + id: 'dp_kilo_pass_referral_reward', + charge: 'ch_kilo_pass_referral_dispute', + }), + previous_attributes: {}, + }, + }; + + await processStripePaymentEventHook(event); + retrieveSpy.mockRestore(); + + const reward = await db.query.impact_referral_rewards.findFirst({ + where: eq(impact_referral_rewards.id, rewardId), + }); + expect(reward).toEqual( + expect.objectContaining({ + status: ImpactReferralRewardStatus.ReviewRequired, + review_reason: 'referral_payment_chargeback', + }) + ); + }); + + test('charge.refunded cancels pending Kilo Pass referral rewards by Stripe invoice identity', async () => { + await cleanupDbForTest(); + testUser = await insertTestUser(); + const invoiceId = 'in_kilo_pass_referral_refund'; + const rewardId = await seedKiloPassReferralReward({ + userId: testUser.id, + invoiceId, + status: ImpactReferralRewardStatus.Pending, + }); + const retrieveSpy = await mockChargeRetrieveForKiloPass(invoiceId, testUser.id); + + const event: Stripe.Event = { + ...baseStripeEvent(), + id: 'evt_refund_kilo_pass_referral_reward', + type: 'charge.refunded', + data: { + object: { + id: 'ch_kilo_pass_referral_refund', + object: 'charge', + amount_refunded: 1900, + created: 1712743200, + } as unknown as Stripe.Charge, + previous_attributes: {}, + }, + }; + + await processStripePaymentEventHook(event); + retrieveSpy.mockRestore(); + + const reward = await db.query.impact_referral_rewards.findFirst({ + where: eq(impact_referral_rewards.id, rewardId), + }); + expect(reward).toEqual( + expect.objectContaining({ + status: ImpactReferralRewardStatus.Canceled, + review_reason: 'referral_payment_refund', + }) + ); + }); + + test('charge.updated fraud marking cancels earned Kilo Pass referral rewards by Stripe invoice identity', async () => { + await cleanupDbForTest(); + testUser = await insertTestUser(); + const invoiceId = 'in_kilo_pass_referral_fraud'; + const rewardId = await seedKiloPassReferralReward({ + userId: testUser.id, + invoiceId, + status: ImpactReferralRewardStatus.Earned, + }); + const retrieveSpy = await mockChargeRetrieveForKiloPass(invoiceId, testUser.id); + + const event: Stripe.Event = { + ...baseStripeEvent(), + id: 'evt_fraud_kilo_pass_referral_reward', + type: 'charge.updated', + data: { + object: { + id: 'ch_kilo_pass_referral_fraud', + object: 'charge', + created: 1712743200, + fraud_details: { stripe_report: 'fraudulent' }, + } as unknown as Stripe.Charge, + previous_attributes: {}, + }, + }; + + await processStripePaymentEventHook(event); + retrieveSpy.mockRestore(); + + const reward = await db.query.impact_referral_rewards.findFirst({ + where: eq(impact_referral_rewards.id, rewardId), + }); + expect(reward).toEqual( + expect.objectContaining({ + status: ImpactReferralRewardStatus.Canceled, + review_reason: 'referral_payment_fraud', + }) + ); + }); + test('charge.dispute.created persists pending row for unmatched KiloClaw charge', async () => { await cleanupDbForTest(); testUser = await insertTestUser(); diff --git a/apps/web/src/lib/stripe/index.ts b/apps/web/src/lib/stripe/index.ts index 5844891302..844e61fc55 100644 --- a/apps/web/src/lib/stripe/index.ts +++ b/apps/web/src/lib/stripe/index.ts @@ -57,6 +57,7 @@ import { } from '@/lib/kiloclaw/stripe-handlers'; import { enqueueImpactSaleReversalForCharge } from '@/lib/impact/affiliate-events'; import { markPersonalKiloClawReferralPaymentAdverse } from '@/lib/impact/kiloclaw-referrals'; +import { markPersonalKiloPassReferralPaymentAdverse } from '@/lib/impact/kilo-pass-referrals'; import { ImpactReferralPaymentProvider } from '@kilocode/db/schema-types'; import { invoiceLooksLikeKiloClawByPriceId } from '@/lib/kiloclaw/stripe-invoice-classifier.server'; import { reportEvents } from '@/lib/ai-gateway/abuse-service'; @@ -89,20 +90,6 @@ type AffiliateDisputeChargeContext = KiloClawChargeContext & { saleKind: AffiliateDisputeSaleKind; }; -async function getKiloClawChargeContext(chargeId: string): Promise { - const charge: Stripe.Charge & { invoice?: string | Stripe.Invoice | null } = - await client.charges.retrieve(chargeId, { expand: ['invoice'] }); - const invoice = charge.invoice; - if (!invoice || typeof invoice === 'string' || !invoiceLooksLikeKiloClawByPriceId(invoice)) { - return null; - } - - return { - chargeId, - invoiceId: invoice.id, - }; -} - function getAffiliateDisputeSaleKind(invoice: Stripe.Invoice): AffiliateDisputeSaleKind | null { if (invoiceLooksLikeKiloClawByPriceId(invoice)) { return 'kiloclaw'; @@ -986,6 +973,13 @@ export async function processStripePaymentEventHook(event: Stripe.Event) { reason: 'chargeback', occurredAt: new Date(dispute.created * 1000), }); + } else { + await markPersonalKiloPassReferralPaymentAdverse({ + sourcePaymentId: affiliateDisputeCharge.invoiceId, + paymentProvider: ImpactReferralPaymentProvider.Stripe, + reason: 'chargeback', + occurredAt: new Date(dispute.created * 1000), + }); } break; } @@ -996,17 +990,26 @@ export async function processStripePaymentEventHook(event: Stripe.Event) { break; } - const kiloClawCharge = await getKiloClawChargeContext(charge.id); - if (!kiloClawCharge) { + const referralAdverseCharge = await getAffiliateDisputeChargeContext(charge.id); + if (!referralAdverseCharge) { break; } - await markPersonalKiloClawReferralPaymentAdverse({ - sourcePaymentId: kiloClawCharge.invoiceId, - paymentProvider: ImpactReferralPaymentProvider.Stripe, - reason: 'refund', - occurredAt: new Date(charge.created * 1000), - }); + if (referralAdverseCharge.saleKind === 'kiloclaw') { + await markPersonalKiloClawReferralPaymentAdverse({ + sourcePaymentId: referralAdverseCharge.invoiceId, + paymentProvider: ImpactReferralPaymentProvider.Stripe, + reason: 'refund', + occurredAt: new Date(charge.created * 1000), + }); + } else { + await markPersonalKiloPassReferralPaymentAdverse({ + sourcePaymentId: referralAdverseCharge.invoiceId, + paymentProvider: ImpactReferralPaymentProvider.Stripe, + reason: 'refund', + occurredAt: new Date(charge.created * 1000), + }); + } break; } @@ -1019,17 +1022,26 @@ export async function processStripePaymentEventHook(event: Stripe.Event) { break; } - const kiloClawCharge = await getKiloClawChargeContext(charge.id); - if (!kiloClawCharge) { + const referralAdverseCharge = await getAffiliateDisputeChargeContext(charge.id); + if (!referralAdverseCharge) { break; } - await markPersonalKiloClawReferralPaymentAdverse({ - sourcePaymentId: kiloClawCharge.invoiceId, - paymentProvider: ImpactReferralPaymentProvider.Stripe, - reason: 'fraud', - occurredAt: new Date(charge.created * 1000), - }); + if (referralAdverseCharge.saleKind === 'kiloclaw') { + await markPersonalKiloClawReferralPaymentAdverse({ + sourcePaymentId: referralAdverseCharge.invoiceId, + paymentProvider: ImpactReferralPaymentProvider.Stripe, + reason: 'fraud', + occurredAt: new Date(charge.created * 1000), + }); + } else { + await markPersonalKiloPassReferralPaymentAdverse({ + sourcePaymentId: referralAdverseCharge.invoiceId, + paymentProvider: ImpactReferralPaymentProvider.Stripe, + reason: 'fraud', + occurredAt: new Date(charge.created * 1000), + }); + } break; } diff --git a/apps/web/src/routers/kilo-pass-router.test.ts b/apps/web/src/routers/kilo-pass-router.test.ts index 4cfdd6784b..a137e6466a 100644 --- a/apps/web/src/routers/kilo-pass-router.test.ts +++ b/apps/web/src/routers/kilo-pass-router.test.ts @@ -6,6 +6,9 @@ import { kilo_pass_issuance_items, kilo_pass_issuances, kilo_pass_pause_events, + impact_referral_conversions, + impact_referral_reward_decisions, + impact_referral_rewards, kilo_pass_scheduled_changes, kilo_pass_store_purchases, kilo_pass_subscriptions, @@ -22,6 +25,15 @@ import { KiloPassTier, KiloPassWelcomePromoEligibilityReason, } from '@/lib/kilo-pass/enums'; +import { + ImpactReferralBeneficiaryRole, + ImpactReferralDecisionOutcome, + ImpactReferralPaymentProvider, + ImpactReferralProduct, + ImpactReferralRewardKind, + ImpactReferralRewardStatus, + ImpactReferralWinningTouchType, +} from '@kilocode/db/schema-types'; import { and, eq, isNull } from 'drizzle-orm'; import crypto from 'crypto'; import { @@ -29,10 +41,7 @@ import { computeYearlyCadenceMonthlyBonusUsd, getMonthlyPriceUsd, } from '@/lib/kilo-pass/bonus'; -import { - KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_BONUS_PERCENT, - KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF, -} from '@/lib/kilo-pass/constants'; +import { KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT } from '@/lib/kilo-pass/constants'; import { insertTestUser } from '@/tests/helpers/user.helper'; import type { insertMicrodollarUsageWithDailyRollup as insertMicrodollarUsageWithDailyRollupType } from '@/tests/helpers/microdollar-usage.helper'; @@ -43,8 +52,8 @@ import type dayjsType from 'dayjs'; import type utcType from 'dayjs/plugin/utc'; import type * as Sentry from '@sentry/nextjs'; -const PROMO_OFFER_ACTIVE_TEST_TIME = '2026-05-06T12:00:00.000Z'; -const PROMO_OFFER_EXPIRED_TEST_TIME = '2026-05-07T00:00:00.000Z'; +const FIRST_MONTH_PROMO_TEST_TIME = '2026-05-25T00:00:00.000Z'; +const LATER_FIRST_MONTH_PROMO_TEST_TIME = '2026-05-26T00:00:00.000Z'; let mockKiloPassNowIso: string | null = null; @@ -183,6 +192,31 @@ type KiloPassCaller = { hasMore: boolean; cursor: string | null; }>; + getReferralRewardSummary: () => Promise<{ + totals: { + totalRewards: number; + pendingRewards: number; + appliedRewards: number; + totalRewardAmountUsd: number; + pendingRewardAmountUsd: number; + appliedRewardAmountUsd: number; + }; + referrerCap: { + grantedRewards: number; + limit: number; + reached: boolean; + }; + rewards: Array<{ + role: 'referrer' | 'referee'; + status: string; + rewardAmountUsd: number; + earnedAt: string; + appliedAt: string | null; + expiresAt: string | null; + sourceTier: string | null; + reviewReason: string | null; + }>; + }>; }; type Caller = { kiloPass: KiloPassCaller }; @@ -401,6 +435,75 @@ async function insertBaseCreditsIssuance(params: { } } +async function insertKiloPassReferralReward(params: { + beneficiaryUserId: string; + role: ImpactReferralBeneficiaryRole; + status: ImpactReferralRewardStatus; + rewardAmountUsd: number; + sourceTier: KiloPassTier; + earnedAt: string; + appliedAt?: string | null; + expiresAt?: string | null; + sourcePaymentId?: string; +}): Promise { + const otherUser = await insertTestUser(); + const isReferrerReward = params.role === ImpactReferralBeneficiaryRole.Referrer; + const [conversion] = await db + .insert(impact_referral_conversions) + .values({ + product: ImpactReferralProduct.KiloPass, + referee_user_id: isReferrerReward ? otherUser.id : params.beneficiaryUserId, + referrer_user_id: isReferrerReward ? params.beneficiaryUserId : otherUser.id, + winning_touch_type: ImpactReferralWinningTouchType.Referral, + payment_provider: ImpactReferralPaymentProvider.Stripe, + source_payment_id: params.sourcePaymentId ?? `in_referral_${crypto.randomUUID()}`, + qualified: true, + converted_at: params.earnedAt, + }) + .returning({ id: impact_referral_conversions.id }); + + if (!conversion) { + throw new Error('Failed to insert impact_referral_conversions row for test'); + } + + const [decision] = await db + .insert(impact_referral_reward_decisions) + .values({ + product: ImpactReferralProduct.KiloPass, + conversion_id: conversion.id, + beneficiary_user_id: params.beneficiaryUserId, + beneficiary_role: params.role, + outcome: ImpactReferralDecisionOutcome.Granted, + reward_kind: ImpactReferralRewardKind.KiloPassBonus, + months_granted: 0, + reward_percent: 0.5, + source_tier: params.sourceTier, + reward_amount_usd: params.rewardAmountUsd, + }) + .returning({ id: impact_referral_reward_decisions.id }); + + if (!decision) { + throw new Error('Failed to insert impact_referral_reward_decisions row for test'); + } + + await db.insert(impact_referral_rewards).values({ + product: ImpactReferralProduct.KiloPass, + conversion_id: conversion.id, + decision_id: decision.id, + beneficiary_user_id: params.beneficiaryUserId, + beneficiary_role: params.role, + reward_kind: ImpactReferralRewardKind.KiloPassBonus, + months_granted: 0, + reward_percent: 0.5, + source_tier: params.sourceTier, + reward_amount_usd: params.rewardAmountUsd, + status: params.status, + earned_at: params.earnedAt, + applied_at: params.appliedAt ?? null, + expires_at: params.expiresAt ?? null, + }); +} + describe('kiloPassRouter', () => { beforeAll(async () => { // Delay importing the tRPC caller factory until after mocks are registered, @@ -556,7 +659,7 @@ describe('kiloPassRouter', () => { describe('getState', () => { it('returns null subscription when user has no Kilo Pass subscription', async () => { - freezeKiloPassClock(PROMO_OFFER_ACTIVE_TEST_TIME); + freezeKiloPassClock(FIRST_MONTH_PROMO_TEST_TIME); const user = await insertTestUser({ google_user_email: 'kilo-pass-get-state-empty@example.com', @@ -854,7 +957,6 @@ describe('kiloPassRouter', () => { tier: KiloPassTier.Tier19, streakMonths: 1, isFirstTimeSubscriberEver: true, - subscriptionStartedAtIso: '2026-01-01T00:00:00.000Z', }); const currentBonusUsd = Math.round(baseAmountUsd * currentBonusPercent * 100) / 100; @@ -1102,13 +1204,13 @@ describe('kiloPassRouter', () => { ); }); - it('keeps App Store month-2 grandfather bonus after a post-cutoff renewal', async () => { + it('uses the App Store month-2 ramp bonus after renewal', async () => { freezeKiloPassClock('2026-06-15T00:00:00.000Z'); const user = await insertTestUser({ - google_user_email: 'kilo-pass-get-state-app-store-grandfathered-renewal@example.com', + google_user_email: 'kilo-pass-get-state-app-store-month2_ramp-renewal@example.com', }); - const providerSubscriptionId = 'orig_get_state_app_store_grandfathered_renewal'; + const providerSubscriptionId = 'orig_get_state_app_store_month2_ramp_renewal'; const renewalExpiresAt = '2026-07-01T00:00:00.000Z'; const { id: subscriptionId } = await insertSubscription({ kiloUserId: user.id, @@ -1129,7 +1231,7 @@ describe('kiloPassRouter', () => { payment_provider: KiloPassPaymentProvider.AppStore, product_id: 'kilo_pass_tier_19_monthly', provider_subscription_id: providerSubscriptionId, - provider_transaction_id: 'tx_get_state_app_store_grandfathered_initial', + provider_transaction_id: 'tx_get_state_app_store_month2_ramp_initial', provider_original_transaction_id: providerSubscriptionId, app_account_token: user.app_store_account_token, environment: 'Sandbox', @@ -1143,7 +1245,7 @@ describe('kiloPassRouter', () => { payment_provider: KiloPassPaymentProvider.AppStore, product_id: 'kilo_pass_tier_19_monthly', provider_subscription_id: providerSubscriptionId, - provider_transaction_id: 'tx_get_state_app_store_grandfathered_renewal', + provider_transaction_id: 'tx_get_state_app_store_month2_ramp_renewal', provider_original_transaction_id: providerSubscriptionId, app_account_token: user.app_store_account_token, environment: 'Sandbox', @@ -1157,10 +1259,12 @@ describe('kiloPassRouter', () => { const result = await caller.kiloPass.getState(); const baseAmountUsd = getMonthlyPriceUsd(KiloPassTier.Tier19); - const expectedCurrentBonusUsd = - Math.round( - Math.round(baseAmountUsd * 100) * KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_BONUS_PERCENT - ) / 100; + const expectedPercent = computeMonthlyCadenceBonusPercent({ + tier: KiloPassTier.Tier19, + streakMonths: 2, + isFirstTimeSubscriberEver: true, + }); + const expectedCurrentBonusUsd = Math.round(baseAmountUsd * expectedPercent * 100) / 100; expect(result.subscription).toEqual( expect.objectContaining({ @@ -1173,12 +1277,12 @@ describe('kiloPassRouter', () => { ); }); - it('predicts monthly nextBonusCreditsUsd as 50% for promo month 2 (streak=1 -> predicted=2)', async () => { + it('predicts monthly nextBonusCreditsUsd using the month-2 ramp (streak=1 -> predicted=2)', async () => { const stripeMock = getStripeMock(); const currentPeriodEndSeconds = 1_700_123_456; const currentPeriodStartSeconds = currentPeriodEndSeconds - 2_592_000; stripeMock.subscriptions.retrieve.mockResolvedValue({ - id: 'sub_test_monthly_grandfathered_month2_next', + id: 'sub_test_monthly_month2_ramp_month2_next', status: 'active', items: { data: [ @@ -1191,12 +1295,12 @@ describe('kiloPassRouter', () => { }); const user = await insertTestUser({ - google_user_email: 'kilo-pass-get-state-monthly-grandfathered-month2-next@example.com', + google_user_email: 'kilo-pass-get-state-monthly-month2_ramp-month2-next@example.com', }); await insertSubscription({ kiloUserId: user.id, - stripeSubscriptionId: 'sub_test_monthly_grandfathered_month2_next', + stripeSubscriptionId: 'sub_test_monthly_month2_ramp_month2_next', tier: KiloPassTier.Tier19, cadence: KiloPassCadence.Monthly, status: 'active', @@ -1208,19 +1312,22 @@ describe('kiloPassRouter', () => { const result = await caller.kiloPass.getState(); const baseAmountUsd = getMonthlyPriceUsd(KiloPassTier.Tier19); - const baseCents = Math.round(baseAmountUsd * 100); - const expectedNextBonusUsd = - Math.round(baseCents * KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_BONUS_PERCENT) / 100; + const expectedPercent = computeMonthlyCadenceBonusPercent({ + tier: KiloPassTier.Tier19, + streakMonths: 2, + isFirstTimeSubscriberEver: true, + }); + const expectedNextBonusUsd = Math.round(baseAmountUsd * expectedPercent * 100) / 100; expect(result.subscription?.nextBonusCreditsUsd).toBe(expectedNextBonusUsd); }); - it('computes monthly currentPeriodBonusCreditsUsd as 50% for promo month 2 (streak=2)', async () => { + it('computes monthly currentPeriodBonusCreditsUsd using the month-2 ramp (streak=2)', async () => { const stripeMock = getStripeMock(); const currentPeriodEndSeconds = 1_700_123_456; const currentPeriodStartSeconds = currentPeriodEndSeconds - 2_592_000; stripeMock.subscriptions.retrieve.mockResolvedValue({ - id: 'sub_test_monthly_grandfathered_month2_current', + id: 'sub_test_monthly_month2_ramp_month2_current', status: 'active', items: { data: [ @@ -1233,12 +1340,12 @@ describe('kiloPassRouter', () => { }); const user = await insertTestUser({ - google_user_email: 'kilo-pass-get-state-monthly-grandfathered-month2-current@example.com', + google_user_email: 'kilo-pass-get-state-monthly-month2_ramp-month2-current@example.com', }); await insertSubscription({ kiloUserId: user.id, - stripeSubscriptionId: 'sub_test_monthly_grandfathered_month2_current', + stripeSubscriptionId: 'sub_test_monthly_month2_ramp_month2_current', tier: KiloPassTier.Tier19, cadence: KiloPassCadence.Monthly, status: 'active', @@ -1250,20 +1357,22 @@ describe('kiloPassRouter', () => { const result = await caller.kiloPass.getState(); const baseAmountUsd = getMonthlyPriceUsd(KiloPassTier.Tier19); - const expectedCurrentBonusUsd = - Math.round( - Math.round(baseAmountUsd * 100) * KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_BONUS_PERCENT - ) / 100; + const expectedPercent = computeMonthlyCadenceBonusPercent({ + tier: KiloPassTier.Tier19, + streakMonths: 2, + isFirstTimeSubscriberEver: true, + }); + const expectedCurrentBonusUsd = Math.round(baseAmountUsd * expectedPercent * 100) / 100; expect(result.subscription?.currentPeriodBonusCreditsUsd).toBe(expectedCurrentBonusUsd); }); - it('does not apply 50% month-2 promo when started_at is at/after the cutoff (streak=2)', async () => { + it('does not apply the 50% first-month promo in streak month 2', async () => { const stripeMock = getStripeMock(); const currentPeriodEndSeconds = 1_700_123_456; const currentPeriodStartSeconds = currentPeriodEndSeconds - 2_592_000; stripeMock.subscriptions.retrieve.mockResolvedValue({ - id: 'sub_test_monthly_grandfathered_month2_cutoff_ineligible', + id: 'sub_test_monthly_month2_ramp_month2_ineligible', status: 'active', items: { data: [ @@ -1276,17 +1385,17 @@ describe('kiloPassRouter', () => { }); const user = await insertTestUser({ - google_user_email: 'kilo-pass-get-state-monthly-grandfathered-month2-cutoff@example.com', + google_user_email: 'kilo-pass-get-state-monthly-month2-ramp@example.com', }); await insertSubscription({ kiloUserId: user.id, - stripeSubscriptionId: 'sub_test_monthly_grandfathered_month2_cutoff_ineligible', + stripeSubscriptionId: 'sub_test_monthly_month2_ramp_month2_ineligible', tier: KiloPassTier.Tier19, cadence: KiloPassCadence.Monthly, status: 'active', currentStreakMonths: 2, - startedAt: KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF.toISOString(), + startedAt: '2026-01-01T00:00:00.000Z', }); const caller = await createCallerForUser(user.id); @@ -1302,18 +1411,17 @@ describe('kiloPassRouter', () => { expect(result.subscription?.currentPeriodBonusCreditsUsd).toBe(expectedCurrentBonusUsd); expect(result.subscription?.currentPeriodBonusCreditsUsd).not.toBe( - Math.round( - Math.round(baseAmountUsd * 100) * KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_BONUS_PERCENT - ) / 100 + Math.round(Math.round(baseAmountUsd * 100) * KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT) / + 100 ); }); - it('keeps month 3+ bonus ramp unchanged even for grandfathered subscriptions (streak=3)', async () => { + it('keeps month 3+ bonus ramp unchanged (streak=3)', async () => { const stripeMock = getStripeMock(); const currentPeriodEndSeconds = 1_700_123_456; const currentPeriodStartSeconds = currentPeriodEndSeconds - 2_592_000; stripeMock.subscriptions.retrieve.mockResolvedValue({ - id: 'sub_test_monthly_grandfathered_month3_regression', + id: 'sub_test_monthly_month2_ramp_month3_regression', status: 'active', items: { data: [ @@ -1326,12 +1434,12 @@ describe('kiloPassRouter', () => { }); const user = await insertTestUser({ - google_user_email: 'kilo-pass-get-state-monthly-grandfathered-month3@example.com', + google_user_email: 'kilo-pass-get-state-monthly-month2_ramp-month3@example.com', }); await insertSubscription({ kiloUserId: user.id, - stripeSubscriptionId: 'sub_test_monthly_grandfathered_month3_regression', + stripeSubscriptionId: 'sub_test_monthly_month2_ramp_month3_regression', tier: KiloPassTier.Tier19, cadence: KiloPassCadence.Monthly, status: 'active', @@ -1443,7 +1551,7 @@ describe('kiloPassRouter', () => { describe('isEligibleForFirstMonthPromo in getState', () => { it('returns isEligibleForFirstMonthPromo=true when user has no subscriptions', async () => { - freezeKiloPassClock(PROMO_OFFER_ACTIVE_TEST_TIME); + freezeKiloPassClock(FIRST_MONTH_PROMO_TEST_TIME); const user = await insertTestUser({ google_user_email: 'kilo-pass-promo-eligible-no-sub@example.com', @@ -1456,25 +1564,25 @@ describe('kiloPassRouter', () => { expect(result.subscription).toBeNull(); }); - it('returns isEligibleForFirstMonthPromo=false after the promo cutoff', async () => { - freezeKiloPassClock(PROMO_OFFER_EXPIRED_TEST_TIME); + it('keeps first-month promo eligibility after the first-month promo check', async () => { + freezeKiloPassClock(LATER_FIRST_MONTH_PROMO_TEST_TIME); const user = await insertTestUser({ - google_user_email: 'kilo-pass-promo-expired-no-sub@example.com', + google_user_email: 'kilo-pass-promo-after-first-month-promo-no-sub@example.com', }); const caller = await createCallerForUser(user.id); const result = await caller.kiloPass.getState(); - expect(result.isEligibleForFirstMonthPromo).toBe(false); + expect(result.isEligibleForFirstMonthPromo).toBe(true); expect(result.subscription).toBeNull(); }); it('keeps isEligibleForFirstMonthPromo=true for a never-subscribed user', async () => { - freezeKiloPassClock(PROMO_OFFER_ACTIVE_TEST_TIME); + freezeKiloPassClock(FIRST_MONTH_PROMO_TEST_TIME); const user = await insertTestUser({ - google_user_email: 'kilo-pass-promo-cutoff-still-eligible@example.com', + google_user_email: 'kilo-pass-promo-first-month-promo-check-still-eligible@example.com', }); const caller = await createCallerForUser(user.id); @@ -2940,6 +3048,156 @@ describe('kiloPassRouter', () => { }); }); + describe('getReferralRewardSummary', () => { + it('returns an empty Kilo Pass referral summary without KiloClaw fallback state', async () => { + const user = await insertTestUser({ + google_user_email: 'kilo-pass-referral-empty@example.com', + }); + const caller = await createCallerForUser(user.id); + + const result = await caller.kiloPass.getReferralRewardSummary(); + + expect(result).toEqual({ + totals: { + totalRewards: 0, + pendingRewards: 0, + appliedRewards: 0, + totalRewardAmountUsd: 0, + pendingRewardAmountUsd: 0, + appliedRewardAmountUsd: 0, + }, + referrerCap: { + grantedRewards: 0, + limit: 5, + reached: false, + }, + rewards: [], + }); + }); + + it('summarizes pending, applied, history, and cap-reached Kilo Pass rewards', async () => { + const user = await insertTestUser({ + google_user_email: 'kilo-pass-referral-summary@example.com', + }); + + await insertKiloPassReferralReward({ + beneficiaryUserId: user.id, + role: ImpactReferralBeneficiaryRole.Referrer, + status: ImpactReferralRewardStatus.Pending, + rewardAmountUsd: 24.5, + sourceTier: KiloPassTier.Tier49, + earnedAt: '2026-05-10T00:00:00.000Z', + expiresAt: '2027-05-10T00:00:00.000Z', + }); + await insertKiloPassReferralReward({ + beneficiaryUserId: user.id, + role: ImpactReferralBeneficiaryRole.Referee, + status: ImpactReferralRewardStatus.Applied, + rewardAmountUsd: 9.5, + sourceTier: KiloPassTier.Tier19, + earnedAt: '2026-05-11T00:00:00.000Z', + appliedAt: '2026-06-01T00:00:00.000Z', + }); + await insertKiloPassReferralReward({ + beneficiaryUserId: user.id, + role: ImpactReferralBeneficiaryRole.Referrer, + status: ImpactReferralRewardStatus.ReviewRequired, + rewardAmountUsd: 24.5, + sourceTier: KiloPassTier.Tier49, + earnedAt: '2026-05-12T00:00:00.000Z', + }); + await insertKiloPassReferralReward({ + beneficiaryUserId: user.id, + role: ImpactReferralBeneficiaryRole.Referrer, + status: ImpactReferralRewardStatus.Expired, + rewardAmountUsd: 99.5, + sourceTier: KiloPassTier.Tier199, + earnedAt: '2026-05-13T00:00:00.000Z', + }); + await insertKiloPassReferralReward({ + beneficiaryUserId: user.id, + role: ImpactReferralBeneficiaryRole.Referrer, + status: ImpactReferralRewardStatus.Canceled, + rewardAmountUsd: 99.5, + sourceTier: KiloPassTier.Tier199, + earnedAt: '2026-05-14T00:00:00.000Z', + }); + await insertKiloPassReferralReward({ + beneficiaryUserId: user.id, + role: ImpactReferralBeneficiaryRole.Referrer, + status: ImpactReferralRewardStatus.Reversed, + rewardAmountUsd: 0, + sourceTier: KiloPassTier.Tier19, + earnedAt: '2026-05-15T00:00:00.000Z', + }); + + const caller = await createCallerForUser(user.id); + const result = await caller.kiloPass.getReferralRewardSummary(); + + expect(result.totals).toEqual({ + totalRewards: 6, + pendingRewards: 1, + appliedRewards: 1, + totalRewardAmountUsd: 257.5, + pendingRewardAmountUsd: 24.5, + appliedRewardAmountUsd: 9.5, + }); + expect(result.referrerCap).toEqual({ + grantedRewards: 5, + limit: 5, + reached: true, + }); + expect(result.rewards.map(reward => reward.status)).toEqual([ + 'reversed', + 'canceled', + 'expired', + 'review_required', + 'applied', + 'pending', + ]); + expect(result.rewards[1]).toEqual( + expect.objectContaining({ + role: 'referrer', + rewardAmountUsd: 99.5, + sourceTier: 'tier_199', + }) + ); + }); + + it('does not count expired pending rewards as pending future rewards', async () => { + const user = await insertTestUser({ + google_user_email: 'kilo-pass-referral-expired-pending@example.com', + }); + + await insertKiloPassReferralReward({ + beneficiaryUserId: user.id, + role: ImpactReferralBeneficiaryRole.Referrer, + status: ImpactReferralRewardStatus.Pending, + rewardAmountUsd: 24.5, + sourceTier: KiloPassTier.Tier49, + earnedAt: '2025-01-01T00:00:00.000Z', + expiresAt: '2025-12-31T00:00:00.000Z', + }); + + const caller = await createCallerForUser(user.id); + const result = await caller.kiloPass.getReferralRewardSummary(); + + expect(result.totals).toEqual( + expect.objectContaining({ + totalRewards: 1, + pendingRewards: 0, + pendingRewardAmountUsd: 0, + }) + ); + expect(result.rewards[0]).toEqual( + expect.objectContaining({ + status: ImpactReferralRewardStatus.Pending, + expiresAt: '2025-12-31T00:00:00.000Z', + }) + ); + }); + }); + describe('createCheckoutSession', () => { it('rejects when an active/pending subscription already exists', async () => { const user = await insertTestUser({ diff --git a/apps/web/src/routers/kilo-pass-router.ts b/apps/web/src/routers/kilo-pass-router.ts index 35882048b7..6da155abdf 100644 --- a/apps/web/src/routers/kilo-pass-router.ts +++ b/apps/web/src/routers/kilo-pass-router.ts @@ -6,9 +6,12 @@ import { client as stripe } from '@/lib/stripe-client'; import { getStripePriceIdForKiloPass } from '@/lib/kilo-pass/stripe-price-ids.server'; import { getAffiliateAttribution } from '@/lib/affiliate-attribution'; import { APP_URL } from '@/lib/constants'; +import { KILO_PASS_REFERRER_REWARD_CAP } from '@/lib/impact/kilo-pass-referrals'; import { TRPCError } from '@trpc/server'; import { credit_transactions, + impact_referral_reward_decisions, + impact_referral_rewards, kilo_pass_issuance_items, kilo_pass_issuances, kilo_pass_scheduled_changes, @@ -26,6 +29,13 @@ import { KiloPassPaymentProvider, KiloPassWelcomePromoEligibilityReason, } from '@/lib/kilo-pass/enums'; +import { + ImpactReferralBeneficiaryRole, + ImpactReferralDecisionOutcome, + ImpactReferralProduct, + ImpactReferralRewardKind, + ImpactReferralRewardStatus, +} from '@kilocode/db/schema-types'; import { KiloPassIssuanceItemKind } from '@/lib/kilo-pass/enums'; import { and, asc, desc, eq, inArray, isNull, ne, sql, sum } from 'drizzle-orm'; import * as z from 'zod'; @@ -38,10 +48,7 @@ import { KiloPassError } from '@/lib/kilo-pass/errors'; import { isStripeSubscriptionEnded } from '@/lib/kilo-pass/stripe-subscription-status'; import { releaseScheduledChangeForSubscription } from '@/lib/kilo-pass/scheduled-change-release'; import { appendKiloPassAuditLog } from '@/lib/kilo-pass/issuance'; -import { - KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF, - KILO_PASS_TIER_CONFIG, -} from '@/lib/kilo-pass/constants'; +import { KILO_PASS_TIER_CONFIG } from '@/lib/kilo-pass/constants'; import { fromMicrodollars } from '@/lib/utils'; import { timedUsageQuery } from '@/lib/usage-query'; import { @@ -143,6 +150,35 @@ const GetAverageMonthlyUsageLast3MonthsOutputSchema = z.object({ averageMonthlyUsageUsd: z.number(), }); +const KiloPassReferralRewardSummaryOutputSchema = z.object({ + totals: z.object({ + totalRewards: z.number(), + pendingRewards: z.number(), + appliedRewards: z.number(), + totalRewardAmountUsd: z.number(), + pendingRewardAmountUsd: z.number(), + appliedRewardAmountUsd: z.number(), + }), + referrerCap: z.object({ + grantedRewards: z.number(), + limit: z.number(), + reached: z.boolean(), + }), + rewards: z.array( + z.object({ + id: z.string(), + role: z.enum(ImpactReferralBeneficiaryRole), + status: z.enum(ImpactReferralRewardStatus), + rewardAmountUsd: z.number(), + earnedAt: z.string(), + appliedAt: z.string().nullable(), + expiresAt: z.string().nullable(), + sourceTier: z.string().nullable(), + reviewReason: z.string().nullable(), + }) + ), +}); + const CompleteStorePurchaseOutputSchema = z.object({ subscriptionId: z.string(), tier: KiloPassTierSchema, @@ -165,6 +201,11 @@ type StripeManagedKiloPassSubscription = KiloPassSubscriptionState & { stripeSubscriptionId: string; }; +const KILO_PASS_PENDING_REFERRAL_REWARD_STATUSES = new Set([ + ImpactReferralRewardStatus.Pending, + ImpactReferralRewardStatus.Earned, +]); + const APP_STORE_ACCOUNT_TOKEN_MISMATCH_MESSAGE = 'App Store purchase account token does not match the signed-in user.'; const APP_STORE_PURCHASE_NOT_LINKED_TO_ACCOUNT_MESSAGE = @@ -231,10 +272,6 @@ function mapAppStoreCompletionError(error: unknown, userId: string): TRPCError { }); } -function isTwoMonthPromoOfferActive(): boolean { - return dayjs().utc().isBefore(KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF); -} - function roundToCents(usd: number): number { return Math.round(usd * 100) / 100; } @@ -275,7 +312,6 @@ function getNextKiloPassBonusCreditsUsd(params: { tier: params.subscription.tier, streakMonths: predictedStreakMonths, isFirstTimeSubscriberEver: params.isFirstTimeSubscriberEver, - subscriptionStartedAtIso: params.subscription.startedAt, }); const baseCents = Math.round(params.baseAmountUsd * 100); @@ -297,7 +333,6 @@ function getCurrentKiloPassBonusCreditsUsd(params: { tier: params.subscription.tier, streakMonths, isFirstTimeSubscriberEver: params.isFirstTimeSubscriberEver, - subscriptionStartedAtIso: params.subscription.startedAt, }); const cents = Math.round(params.baseAmountUsd * bonusPercentApplied * 100); return cents / 100; @@ -823,10 +858,101 @@ export const kiloPassRouter = createTRPCRouter({ return { averageMonthlyUsageUsd }; }), + getReferralRewardSummary: baseProcedure + .output(KiloPassReferralRewardSummaryOutputSchema) + .query(async ({ ctx }) => { + const [rewardRows, capRows] = await Promise.all([ + db + .select({ + id: impact_referral_rewards.id, + role: impact_referral_rewards.beneficiary_role, + status: impact_referral_rewards.status, + rewardAmountUsd: impact_referral_rewards.reward_amount_usd, + earnedAt: impact_referral_rewards.earned_at, + appliedAt: impact_referral_rewards.applied_at, + expiresAt: impact_referral_rewards.expires_at, + sourceTier: impact_referral_rewards.source_tier, + reviewReason: impact_referral_rewards.review_reason, + }) + .from(impact_referral_rewards) + .where( + and( + eq(impact_referral_rewards.product, ImpactReferralProduct.KiloPass), + eq(impact_referral_rewards.reward_kind, ImpactReferralRewardKind.KiloPassBonus), + eq(impact_referral_rewards.beneficiary_user_id, ctx.user.id) + ) + ) + .orderBy( + desc(impact_referral_rewards.earned_at), + desc(impact_referral_rewards.created_at) + ), + db + .select({ grantedRewards: sql`COUNT(*)::int` }) + .from(impact_referral_reward_decisions) + .where( + and( + eq(impact_referral_reward_decisions.product, ImpactReferralProduct.KiloPass), + eq( + impact_referral_reward_decisions.reward_kind, + ImpactReferralRewardKind.KiloPassBonus + ), + eq(impact_referral_reward_decisions.beneficiary_user_id, ctx.user.id), + eq( + impact_referral_reward_decisions.beneficiary_role, + ImpactReferralBeneficiaryRole.Referrer + ), + eq(impact_referral_reward_decisions.outcome, ImpactReferralDecisionOutcome.Granted) + ) + ), + ]); + + const rewards = rewardRows.map(row => ({ + id: row.id, + role: row.role, + status: row.status, + rewardAmountUsd: row.rewardAmountUsd ?? 0, + earnedAt: normalizeTimestampToIso(row.earnedAt) ?? row.earnedAt, + appliedAt: normalizeTimestampToIso(row.appliedAt), + expiresAt: normalizeTimestampToIso(row.expiresAt), + sourceTier: row.sourceTier, + reviewReason: row.reviewReason, + })); + + const nowMs = Date.now(); + const pendingRewards = rewards.filter(reward => { + if (!KILO_PASS_PENDING_REFERRAL_REWARD_STATUSES.has(reward.status)) return false; + if (!reward.expiresAt) return true; + return new Date(reward.expiresAt).getTime() > nowMs; + }); + const appliedRewards = rewards.filter( + reward => reward.status === ImpactReferralRewardStatus.Applied + ); + const sumRewardAmounts = (items: typeof rewards) => + roundToCents(items.reduce((total, reward) => total + reward.rewardAmountUsd, 0)); + const grantedRewards = capRows[0]?.grantedRewards ?? 0; + + return { + totals: { + totalRewards: rewards.length, + pendingRewards: pendingRewards.length, + appliedRewards: appliedRewards.length, + totalRewardAmountUsd: sumRewardAmounts(rewards), + pendingRewardAmountUsd: sumRewardAmounts(pendingRewards), + appliedRewardAmountUsd: sumRewardAmounts(appliedRewards), + }, + referrerCap: { + grantedRewards, + limit: KILO_PASS_REFERRER_REWARD_CAP, + reached: grantedRewards >= KILO_PASS_REFERRER_REWARD_CAP, + }, + rewards, + }; + }), + getState: baseProcedure.output(GetStateOutputSchema).query(async ({ ctx }) => { const subscriptionBase = await getKiloPassStateForUser(db, ctx.user.id); if (!subscriptionBase) { - return { subscription: null, isEligibleForFirstMonthPromo: isTwoMonthPromoOfferActive() }; + return { subscription: null, isEligibleForFirstMonthPromo: true }; } if (subscriptionBase.paymentProvider !== KiloPassPaymentProvider.Stripe) { diff --git a/apps/web/vercel.json b/apps/web/vercel.json index cc4cf54be0..6a71f1ba18 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -28,6 +28,10 @@ "path": "/api/cron/kilo-pass-store-subscription-reconcile", "schedule": "*/15 * * * *" }, + { + "path": "/api/cron/kilo-pass-expire-referral-rewards", + "schedule": "0 * * * *" + }, { "path": "/api/cron/deployment-threat-scan", "schedule": "*/5 * * * *" From fcc9a0b1f661d1708380ce785e7e13d7d8a64d68 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Thu, 28 May 2026 14:47:00 +0200 Subject: [PATCH 3/4] feat(referrals): add Kilo Pass operations tooling --- .../KiloclawReferralsInvestigation.test.ts | 69 +++++- .../KiloclawReferralsInvestigation.tsx | 185 ++++++++++++++- apps/web/src/lib/user/index.test.ts | 70 ++++-- .../admin/kiloclaw-referrals-router.test.ts | 223 ++++++++++++++++-- .../admin/kiloclaw-referrals-router.ts | 208 ++++++++++++++-- .../scripts/dev-apply-kilo-pass-referral.ts | 210 +++++++++++++++++ dev/seed/app/user-id.ts | 80 +++++++ 7 files changed, 975 insertions(+), 70 deletions(-) create mode 100644 apps/web/src/scripts/dev-apply-kilo-pass-referral.ts create mode 100644 dev/seed/app/user-id.ts diff --git a/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.test.ts b/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.test.ts index c4ce6d9b60..2ce1d73ee0 100644 --- a/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.test.ts +++ b/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.test.ts @@ -11,10 +11,17 @@ function referralRow(params: { qualified: boolean; disqualificationReason: string | null; impactReportState: string; + product?: 'kiloclaw' | 'kilo_pass'; + rewardStatus?: string; }) { + const product = params.product ?? 'kiloclaw'; + const productLabel = product === 'kilo_pass' ? 'Kilo Pass' : 'KiloClaw'; + return { referral: { id: params.referralId, + product, + productLabel, impactReferralId: 'RS-SUPPORT', createdAt: '2026-04-01T00:00:00.000Z', }, @@ -22,6 +29,8 @@ function referralRow(params: { sourceTouch: null, conversion: { id: `${params.referralId}-conversion`, + product, + paymentProvider: product === 'kilo_pass' ? 'stripe' : 'credits', winningTouchType: 'referral', sourcePaymentId: params.paymentId, qualified: params.qualified, @@ -33,13 +42,41 @@ function referralRow(params: { id: `${params.referralId}-decision`, beneficiaryUserId: 'referrer-1', beneficiaryRole: 'referrer', + product, outcome: params.qualified ? 'granted' : 'disqualified', reason: params.disqualificationReason, - monthsGranted: params.qualified ? 1 : 0, + rewardKind: product === 'kilo_pass' ? 'kilo_pass_bonus' : 'kiloclaw_free_month', + monthsGranted: product === 'kiloclaw' && params.qualified ? 1 : 0, + rewardPercent: product === 'kilo_pass' ? 0.5 : null, + sourceTier: product === 'kilo_pass' ? 'tier_49' : null, + rewardAmountUsd: product === 'kilo_pass' && params.qualified ? 24.5 : null, createdAt: '2026-04-10T00:00:00.000Z', }, ], - rewards: [], + rewards: params.qualified + ? [ + { + id: `${params.referralId}-reward`, + product, + beneficiaryUserId: 'referrer-1', + beneficiaryRole: 'referrer', + rewardKind: product === 'kilo_pass' ? 'kilo_pass_bonus' : 'kiloclaw_free_month', + status: params.rewardStatus ?? 'applied', + monthsGranted: product === 'kiloclaw' ? 1 : 0, + rewardPercent: product === 'kilo_pass' ? 0.5 : null, + sourceTier: product === 'kilo_pass' ? 'tier_49' : null, + rewardAmountUsd: product === 'kilo_pass' ? 24.5 : null, + earnedAt: '2026-04-10T00:00:00.000Z', + appliedAt: params.rewardStatus === 'pending' ? null : '2026-04-10T00:05:00.000Z', + expiresAt: '2027-04-10T00:00:00.000Z', + reviewReason: + params.rewardStatus === 'review_required' ? 'referral_payment_chargeback' : null, + appliesToKiloPassSubscriptionId: null, + consumedKiloPassIssuanceId: null, + consumedKiloPassIssuanceItemId: null, + }, + ] + : [], rewardApplications: params.qualified ? [ { @@ -47,7 +84,10 @@ function referralRow(params: { beneficiaryUserId: 'referrer-1', subscriptionId: '55555555-5555-4555-8555-555555555555', previousRenewalBoundary: '2026-05-01T00:00:00.000Z', + product, newRenewalBoundary: '2026-06-01T00:00:00.000Z', + localOperationId: null, + stripeOperationId: null, appliedAt: '2026-04-10T00:05:00.000Z', }, ] @@ -63,11 +103,32 @@ function referralRow(params: { responseStatusCode: params.impactReportState === 'failed' ? 400 : null, }, ], + impactRewardRedemptions: [], }; } const result = { + product: 'kiloclaw' as const, + productLabel: 'KiloClaw', referrer: { id: 'referrer-1', email: 'referrer@example.com', name: 'Referrer' }, + participantRegistrations: [ + { + id: '55555555-5555-4555-8555-555555555556', + programKey: 'kiloclaw' as const, + registrationState: 'pending', + registeredAt: null, + lastRegistrationAttemptAt: null, + lastErrorCode: null, + lastErrorMessage: null, + latestAttempt: { + id: '55555555-5555-4555-8555-555555555557', + deliveryState: 'queued', + responseStatusCode: null, + nextRetryAt: '2026-04-11T00:00:00.000Z', + createdAt: '2026-04-10T00:00:00.000Z', + }, + }, + ], referrals: [ referralRow({ referralId: 'qualified-referral', @@ -95,10 +156,14 @@ describe('KiloclawReferralsInvestigationResults', () => { ); expect(html).toContain('referrer@example.com'); + expect(html).toContain('KiloClaw referrer'); + expect(html).toContain('kiloclaw: pending'); + expect(html).toContain('Latest attempt: queued'); expect(html).toContain('Qualified'); expect(html).toContain('Disqualified'); expect(html).toContain('referral_self_referral'); expect(html).toContain('granted'); + expect(html).toContain('applied, 1 month'); expect(html).toContain('delivered, tracker 71659, order qualified-payment'); expect(html).toContain('failed, tracker 71659, order disqualified-payment, HTTP 400'); expect(html).toContain('May 1, 2026 to'); diff --git a/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.tsx b/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.tsx index 6abb2b5521..55a4f2160d 100644 --- a/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.tsx +++ b/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.tsx @@ -8,12 +8,45 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { useTRPC } from '@/lib/trpc/utils'; +type ReferralProduct = 'kiloclaw' | 'kilo_pass'; + type InvestigationResult = { + product: ReferralProduct; + productLabel: string; referrer: { id: string; email: string | null; name: string | null }; + participantRegistrations: Array<{ + id: string; + programKey: ReferralProduct; + registrationState: string; + registeredAt: string | null; + lastRegistrationAttemptAt: string | null; + lastErrorCode: string | null; + lastErrorMessage: string | null; + latestAttempt: { + id: string; + deliveryState: string; + responseStatusCode: number | null; + nextRetryAt: string | null; + createdAt: string; + } | null; + }>; referrals: Array<{ - referral: { id: string; impactReferralId: string | null; createdAt: string }; + referral: { + id: string; + product: ReferralProduct; + productLabel: string; + impactReferralId: string | null; + createdAt: string; + }; referee: { id: string; email: string | null; name: string | null }; sourceTouch: { id: string; @@ -27,6 +60,8 @@ type InvestigationResult = { } | null; conversion: { id: string; + product: ReferralProduct; + paymentProvider: string; winningTouchType: string; sourcePaymentId: string; qualified: boolean; @@ -35,30 +70,46 @@ type InvestigationResult = { } | null; rewardDecisions: Array<{ id: string; + product: ReferralProduct; beneficiaryUserId: string; beneficiaryRole: string; outcome: string; reason: string | null; + rewardKind: string; monthsGranted: number; + rewardPercent: number | null; + sourceTier: string | null; + rewardAmountUsd: number | null; createdAt: string; }>; rewards: Array<{ id: string; + product: ReferralProduct; beneficiaryUserId: string; beneficiaryRole: string; + rewardKind: string; status: string; monthsGranted: number; + rewardPercent: number | null; + sourceTier: string | null; + rewardAmountUsd: number | null; earnedAt: string; appliedAt: string | null; expiresAt: string | null; reviewReason: string | null; + appliesToKiloPassSubscriptionId: string | null; + consumedKiloPassIssuanceId: string | null; + consumedKiloPassIssuanceItemId: string | null; }>; rewardApplications: Array<{ id: string; + product: ReferralProduct; beneficiaryUserId: string; subscriptionId: string | null; previousRenewalBoundary: string; newRenewalBoundary: string; + localOperationId: string | null; + stripeOperationId: string | null; appliedAt: string; }>; impactReports: Array<{ @@ -70,9 +121,24 @@ type InvestigationResult = { nextRetryAt: string | null; responseStatusCode: number | null; }>; + impactRewardRedemptions: Array<{ + id: string; + rewardId: string; + beneficiaryUserId: string; + state: string; + impactRewardId: string | null; + redeemedAt: string | null; + nextRetryAt: string | null; + responseStatusCode: number | null; + }>; }>; }; +const PRODUCT_OPTIONS: Array<{ value: ReferralProduct; label: string }> = [ + { value: 'kiloclaw', label: 'KiloClaw' }, + { value: 'kilo_pass', label: 'Kilo Pass' }, +]; + type ResultsProps = { result: InvestigationResult; }; @@ -90,6 +156,17 @@ function outcomeLabel(qualified: boolean): string { return qualified ? 'Qualified' : 'Disqualified'; } +function formatRewardValue(row: { + monthsGranted?: number; + rewardAmountUsd?: number | null; +}): string { + if (row.rewardAmountUsd != null) return `$${row.rewardAmountUsd.toFixed(2)}`; + if (row.monthsGranted != null) { + return `${row.monthsGranted} month${row.monthsGranted === 1 ? '' : 's'}`; + } + return '—'; +} + export function KiloclawReferralsInvestigationResults({ result }: ResultsProps) { return (
@@ -97,7 +174,7 @@ export function KiloclawReferralsInvestigationResults({ result }: ResultsProps) Referrer - Support investigation details for this KiloClaw referrer. + Support investigation details for this {result.productLabel} referrer. @@ -109,7 +186,50 @@ export function KiloclawReferralsInvestigationResults({ result }: ResultsProps) - Referees + Participant registration + + Impact Advocate registration state for the selected referral program. + + + + {result.participantRegistrations.length === 0 ? ( +
+ No participant registration found for this {result.productLabel} program. +
+ ) : ( + result.participantRegistrations.map(participant => ( +
+
+ {participant.programKey}: {participant.registrationState} +
+
+ Latest attempt:{' '} + {participant.latestAttempt + ? `${participant.latestAttempt.deliveryState}${ + participant.latestAttempt.responseStatusCode + ? `, HTTP ${participant.latestAttempt.responseStatusCode}` + : '' + }` + : 'none'} + {participant.latestAttempt?.nextRetryAt + ? `, retries ${formatDate(participant.latestAttempt.nextRetryAt)}` + : ''} +
+ {participant.lastErrorCode ? ( +
+ Last failure: {participant.lastErrorCode} + {participant.lastErrorMessage ? `, ${participant.lastErrorMessage}` : ''} +
+ ) : null} +
+ )) + )} +
+
+ + + + {result.productLabel} referees Includes qualified and disqualified referrals, reward decisions, applications, and Impact report state. @@ -136,7 +256,9 @@ function ReferralDiagnosticsRow({ row }: { row: InvestigationResult['referrals']
{row.referee.email ?? row.referee.id}
-
{row.referee.id}
+
+ {row.referral.productLabel} · {row.referee.id} +
{conversion ? ( + @@ -178,8 +301,8 @@ function ReferralDiagnosticsRow({ row }: { row: InvestigationResult['referrals']
{row.rewardDecisions.map(decision => (
- {decision.beneficiaryRole}: {decision.outcome}, {decision.monthsGranted} month - {decision.monthsGranted === 1 ? '' : 's'} + {decision.beneficiaryRole}: {decision.outcome}, {formatRewardValue(decision)} + {decision.sourceTier ? `, ${decision.sourceTier}` : ''} {decision.reason ? ` (${decision.reason})` : ''}
))} @@ -188,6 +311,26 @@ function ReferralDiagnosticsRow({ row }: { row: InvestigationResult['referrals']
+
+

+ Rewards +

+ {row.rewards.length === 0 ? ( +
No rewards.
+ ) : ( +
+ {row.rewards.map(reward => ( +
+ {reward.beneficiaryRole}: {reward.status}, {formatRewardValue(reward)} + {reward.appliedAt ? `, applied ${formatDate(reward.appliedAt)}` : ''} + {reward.expiresAt ? `, expires ${formatDate(reward.expiresAt)}` : ''} + {reward.reviewReason ? ` (${reward.reviewReason})` : ''} +
+ ))} +
+ )} +
+

@@ -237,10 +380,12 @@ function Detail({ label, value }: { label: string; value: string }) { export function KiloclawReferralsInvestigation() { const trpc = useTRPC(); const [search, setSearch] = useState(''); + const [product, setProduct] = useState('kiloclaw'); const [submittedSearch, setSubmittedSearch] = useState(null); + const [submittedProduct, setSubmittedProduct] = useState('kiloclaw'); const query = useQuery( trpc.admin.kiloclawReferrals.investigateReferrer.queryOptions( - { search: submittedSearch ?? '' }, + { search: submittedSearch ?? '', product: submittedProduct }, { enabled: submittedSearch !== null } ) ); @@ -249,9 +394,10 @@ export function KiloclawReferralsInvestigation() {
- KiloClaw referral investigation + Impact referral investigation - Search by referrer user ID or email to inspect referee conversion and reward state. + Search by referrer user ID or email to inspect product-specific conversion and reward + state. @@ -262,9 +408,30 @@ export function KiloclawReferralsInvestigation() { const trimmedSearch = search.trim(); if (trimmedSearch) { setSubmittedSearch(trimmedSearch); + setSubmittedProduct(product); } }} > +
+ + +
{ }); const touchId = randomUUID(); const participantId = randomUUID(); + const kiloPassParticipantId = randomUUID(); const conversionId = randomUUID(); const decisionId = randomUUID(); const rewardId = randomUUID(); @@ -669,21 +670,46 @@ describe('User', () => { touched_at: '2026-04-23T00:00:00.000Z', expires_at: '2026-05-23T00:00:00.000Z', }); - await db.insert(impact_advocate_participants).values({ - id: participantId, - user_id: user.id, - advocate_id: user.id, - advocate_account_id: user.id, - contact_email: user.google_user_email, - registration_state: 'pending', - }); - await db.insert(impact_advocate_registration_attempts).values({ - participant_id: participantId, - dedupe_key: 'registration-dedupe', - opaque_cookie_value: 'sq-cookie', - cookie_value_length: 9, - delivery_state: 'queued', - }); + await db.insert(impact_advocate_participants).values([ + { + id: participantId, + program_key: 'kiloclaw', + user_id: user.id, + advocate_id: user.google_user_email, + advocate_account_id: user.google_user_email, + contact_email: user.google_user_email, + registration_state: 'pending', + }, + { + id: kiloPassParticipantId, + program_key: 'kilo_pass', + user_id: user.id, + advocate_id: user.google_user_email, + advocate_account_id: user.google_user_email, + contact_email: user.google_user_email, + registration_state: 'pending', + }, + ]); + await db.insert(impact_advocate_registration_attempts).values([ + { + program_key: 'kiloclaw', + participant_id: participantId, + dedupe_key: 'registration-dedupe-kiloclaw', + opaque_cookie_value: 'sq-cookie', + cookie_value_length: 9, + delivery_state: 'queued', + request_payload: { id: user.google_user_email, email: user.google_user_email }, + }, + { + program_key: 'kilo_pass', + participant_id: kiloPassParticipantId, + dedupe_key: 'registration-dedupe-kilo-pass', + opaque_cookie_value: 'sq-cookie-kilo-pass', + cookie_value_length: 19, + delivery_state: 'queued', + request_payload: { id: user.google_user_email, email: user.google_user_email }, + }, + ]); await db.insert(impact_referrals).values({ referee_user_id: user.id, referrer_user_id: referrer.id, @@ -771,6 +797,11 @@ describe('User', () => { .where(eq(impact_advocate_participants.user_id, user.id)); expect(participantCount.count).toBe(0); + const [registrationAttemptCount] = await db + .select({ count: count() }) + .from(impact_advocate_registration_attempts); + expect(registrationAttemptCount.count).toBe(0); + const [redemptionCount] = await db .select({ count: count() }) .from(impact_advocate_reward_redemptions) @@ -782,6 +813,15 @@ describe('User', () => { .from(impact_referral_conversions) .where(eq(impact_referral_conversions.referee_user_id, user.id)); expect(conversionCount.count).toBe(0); + + expect((await db.select({ count: count() }).from(impact_referrals))[0].count).toBe(0); + expect((await db.select({ count: count() }).from(impact_referral_rewards))[0].count).toBe(0); + expect( + (await db.select({ count: count() }).from(impact_referral_reward_applications))[0].count + ).toBe(0); + expect((await db.select({ count: count() }).from(impact_conversion_reports))[0].count).toBe( + 0 + ); }); it('falls back to google_user_email when normalized_email is null', async () => { diff --git a/apps/web/src/routers/admin/kiloclaw-referrals-router.test.ts b/apps/web/src/routers/admin/kiloclaw-referrals-router.test.ts index 7a910e9f15..bfc0d0d8f6 100644 --- a/apps/web/src/routers/admin/kiloclaw-referrals-router.test.ts +++ b/apps/web/src/routers/admin/kiloclaw-referrals-router.test.ts @@ -5,6 +5,8 @@ import { cleanupDbForTest, db } from '@/lib/drizzle'; import { createCallerForUser } from '@/routers/test-utils'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { + impact_advocate_participants, + impact_advocate_registration_attempts, impact_advocate_reward_redemptions, impact_conversion_reports, impact_attribution_touches, @@ -35,13 +37,50 @@ beforeEach(async () => { }); }); +async function insertParticipantRegistration(params: { + product: 'kiloclaw' | 'kilo_pass'; + state: 'pending' | 'registered' | 'failed'; + attemptState: 'queued' | 'succeeded' | 'failed'; +}) { + const [participant] = await db + .insert(impact_advocate_participants) + .values({ + program_key: params.product, + user_id: referrer.id, + advocate_id: `${params.product}:${referrer.google_user_email}`, + advocate_account_id: `${params.product}:${referrer.google_user_email}`, + contact_email: referrer.google_user_email, + opaque_referral_identifier: `${params.product}-RS-SUPPORT`, + registration_state: params.state, + last_error_code: params.state === 'failed' ? 'invalid_payload' : null, + last_error_message: params.state === 'failed' ? 'Payload rejected' : null, + }) + .returning({ id: impact_advocate_participants.id }); + + await db.insert(impact_advocate_registration_attempts).values({ + program_key: params.product, + participant_id: participant.id, + dedupe_key: `${params.product}-registration-attempt`, + opaque_cookie_value: `${params.product}-opaque-cookie`, + cookie_value_length: 23, + delivery_state: params.attemptState, + response_status_code: params.attemptState === 'failed' ? 400 : null, + next_retry_at: params.attemptState === 'queued' ? '2026-04-11T00:00:00.000Z' : null, + }); +} + async function insertReferralInvestigationRow(params: { + product?: 'kiloclaw' | 'kilo_pass'; refereeEmail: string; sourcePaymentId: string; qualified: boolean; disqualificationReason: string | null; - reportState: 'delivered' | 'failed'; + reportState: 'queued' | 'delivered' | 'failed'; + rewardStatus?: 'pending' | 'applied' | 'canceled' | 'review_required'; }) { + const product = params.product ?? 'kiloclaw'; + const rewardKind = product === 'kilo_pass' ? 'kilo_pass_bonus' : 'kiloclaw_free_month'; + const rewardStatus = params.rewardStatus ?? 'applied'; const referee = await insertTestUser({ google_user_email: params.refereeEmail, normalized_email: params.refereeEmail, @@ -49,7 +88,9 @@ async function insertReferralInvestigationRow(params: { const [touch] = await db .insert(impact_attribution_touches) .values({ - dedupe_key: `touch-${params.sourcePaymentId}`, + product, + program_key: product, + dedupe_key: `touch-${product}-${params.sourcePaymentId}`, user_id: referee.id, touch_type: 'referral', provider: 'impact_advocate', @@ -62,6 +103,7 @@ async function insertReferralInvestigationRow(params: { }) .returning({ id: impact_attribution_touches.id }); await db.insert(impact_referrals).values({ + product, referee_user_id: referee.id, referrer_user_id: referrer.id, source_touch_id: touch.id, @@ -70,10 +112,12 @@ async function insertReferralInvestigationRow(params: { const [conversion] = await db .insert(impact_referral_conversions) .values({ + product, referee_user_id: referee.id, referrer_user_id: referrer.id, source_touch_id: touch.id, winning_touch_type: 'referral', + payment_provider: product === 'kilo_pass' ? 'stripe' : 'credits', source_payment_id: params.sourcePaymentId, qualified: params.qualified, disqualification_reason: params.disqualificationReason, @@ -83,12 +127,17 @@ async function insertReferralInvestigationRow(params: { const [decision] = await db .insert(impact_referral_reward_decisions) .values({ + product, conversion_id: conversion.id, beneficiary_user_id: referrer.id, beneficiary_role: 'referrer', outcome: params.qualified ? 'granted' : 'disqualified', reason: params.disqualificationReason, - months_granted: params.qualified ? 1 : 0, + reward_kind: rewardKind, + months_granted: product === 'kiloclaw' && params.qualified ? 1 : 0, + reward_percent: product === 'kilo_pass' ? 0.5 : null, + source_tier: product === 'kilo_pass' ? 'tier_49' : null, + reward_amount_usd: product === 'kilo_pass' && params.qualified ? 24.5 : null, }) .returning({ id: impact_referral_reward_decisions.id }); @@ -96,37 +145,49 @@ async function insertReferralInvestigationRow(params: { const [reward] = await db .insert(impact_referral_rewards) .values({ + product, conversion_id: conversion.id, decision_id: decision.id, beneficiary_user_id: referrer.id, beneficiary_role: 'referrer', - months_granted: 1, - status: 'applied', + reward_kind: rewardKind, + months_granted: product === 'kiloclaw' ? 1 : 0, + reward_percent: product === 'kilo_pass' ? 0.5 : null, + source_tier: product === 'kilo_pass' ? 'tier_49' : null, + reward_amount_usd: product === 'kilo_pass' ? 24.5 : null, + status: rewardStatus, earned_at: '2026-04-10T00:00:00.000Z', - applied_at: '2026-04-10T00:05:00.000Z', + applied_at: rewardStatus === 'applied' ? '2026-04-10T00:05:00.000Z' : null, + expires_at: '2027-04-10T00:00:00.000Z', + review_reason: rewardStatus === 'review_required' ? 'referral_payment_chargeback' : null, }) .returning({ id: impact_referral_rewards.id }); - await db.insert(impact_referral_reward_applications).values({ - reward_id: reward.id, - beneficiary_user_id: referrer.id, - subscription_id: crypto.randomUUID(), - previous_renewal_boundary: '2026-05-01T00:00:00.000Z', - new_renewal_boundary: '2026-06-01T00:00:00.000Z', - applied_at: '2026-04-10T00:05:00.000Z', - }); - await db.insert(impact_advocate_reward_redemptions).values({ - reward_id: reward.id, - dedupe_key: `reward-redemption-${params.sourcePaymentId}`, - beneficiary_user_id: referrer.id, - state: 'redeemed', - impact_reward_id: `impact-reward-${params.sourcePaymentId}`, - redeemed_at: '2026-04-10T00:06:00.000Z', - }); + if (rewardStatus === 'applied') { + await db.insert(impact_referral_reward_applications).values({ + product, + reward_id: reward.id, + beneficiary_user_id: referrer.id, + subscription_id: crypto.randomUUID(), + previous_renewal_boundary: '2026-05-01T00:00:00.000Z', + new_renewal_boundary: '2026-06-01T00:00:00.000Z', + applied_at: '2026-04-10T00:05:00.000Z', + }); + } + if (product === 'kiloclaw') { + await db.insert(impact_advocate_reward_redemptions).values({ + reward_id: reward.id, + dedupe_key: `reward-redemption-${params.sourcePaymentId}`, + beneficiary_user_id: referrer.id, + state: 'redeemed', + impact_reward_id: `impact-reward-${params.sourcePaymentId}`, + redeemed_at: '2026-04-10T00:06:00.000Z', + }); + } } await db.insert(impact_conversion_reports).values({ conversion_id: conversion.id, - dedupe_key: `impact-report-${params.sourcePaymentId}`, + dedupe_key: `impact-report-${product}-${params.sourcePaymentId}`, action_tracker_id: 71659, order_id: params.sourcePaymentId, state: params.reportState, @@ -167,6 +228,9 @@ describe('admin kiloclaw referrals investigation', () => { search: referrer.google_user_email, }); + expect(result.product).toBe('kiloclaw'); + expect(result.productLabel).toBe('KiloClaw'); + expect(result.participantRegistrations).toEqual([]); expect(result.referrer).toEqual( expect.objectContaining({ id: referrer.id, email: referrer.google_user_email }) ); @@ -177,8 +241,21 @@ describe('admin kiloclaw referrals investigation', () => { id: qualifiedReferee.id, email: qualifiedReferee.google_user_email, }), - conversion: expect.objectContaining({ qualified: true, disqualificationReason: null }), - rewardDecisions: [expect.objectContaining({ outcome: 'granted', monthsGranted: 1 })], + referral: expect.objectContaining({ product: 'kiloclaw', productLabel: 'KiloClaw' }), + conversion: expect.objectContaining({ + product: 'kiloclaw', + paymentProvider: 'credits', + qualified: true, + disqualificationReason: null, + }), + rewardDecisions: [ + expect.objectContaining({ + product: 'kiloclaw', + rewardKind: 'kiloclaw_free_month', + outcome: 'granted', + monthsGranted: 1, + }), + ], rewardApplications: [ expect.objectContaining({ previousRenewalBoundary: '2026-05-01T00:00:00.000Z', @@ -212,4 +289,100 @@ describe('admin kiloclaw referrals investigation', () => { .where(eq(impact_conversion_reports.state, 'failed')); expect(reports).toHaveLength(1); }); + + it('filters Kilo Pass referrals and exposes operations states', async () => { + await insertParticipantRegistration({ + product: 'kilo_pass', + state: 'pending', + attemptState: 'queued', + }); + await insertReferralInvestigationRow({ + product: 'kiloclaw', + refereeEmail: `claw-referee-${Math.random()}@example.com`, + sourcePaymentId: 'claw-payment', + qualified: true, + disqualificationReason: null, + reportState: 'delivered', + }); + const pendingReferee = await insertReferralInvestigationRow({ + product: 'kilo_pass', + refereeEmail: `kilo-pass-pending-${Math.random()}@example.com`, + sourcePaymentId: 'kp-pending-invoice', + qualified: true, + disqualificationReason: null, + reportState: 'queued', + rewardStatus: 'pending', + }); + const reviewReferee = await insertReferralInvestigationRow({ + product: 'kilo_pass', + refereeEmail: `kilo-pass-review-${Math.random()}@example.com`, + sourcePaymentId: 'kp-review-invoice', + qualified: true, + disqualificationReason: null, + reportState: 'failed', + rewardStatus: 'review_required', + }); + + const caller = await createCallerForUser(admin.id); + const result = await caller.admin.kiloclawReferrals.investigateReferrer({ + search: referrer.id, + product: 'kilo_pass', + }); + + expect(result.product).toBe('kilo_pass'); + expect(result.productLabel).toBe('Kilo Pass'); + expect(result.participantRegistrations).toEqual([ + expect.objectContaining({ + programKey: 'kilo_pass', + registrationState: 'pending', + latestAttempt: expect.objectContaining({ + deliveryState: 'queued', + nextRetryAt: '2026-04-11T00:00:00.000Z', + }), + }), + ]); + expect(result.referrals).toHaveLength(2); + expect(result.referrals).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + referee: expect.objectContaining({ id: pendingReferee.id }), + referral: expect.objectContaining({ product: 'kilo_pass', productLabel: 'Kilo Pass' }), + conversion: expect.objectContaining({ + product: 'kilo_pass', + paymentProvider: 'stripe', + qualified: true, + }), + rewardDecisions: [ + expect.objectContaining({ + rewardKind: 'kilo_pass_bonus', + rewardPercent: 0.5, + sourceTier: 'tier_49', + rewardAmountUsd: 24.5, + }), + ], + rewards: [ + expect.objectContaining({ + rewardKind: 'kilo_pass_bonus', + status: 'pending', + rewardAmountUsd: 24.5, + expiresAt: '2027-04-10T00:00:00.000Z', + }), + ], + rewardApplications: [], + impactReports: [expect.objectContaining({ state: 'queued' })], + impactRewardRedemptions: [], + }), + expect.objectContaining({ + referee: expect.objectContaining({ id: reviewReferee.id }), + rewards: [ + expect.objectContaining({ + status: 'review_required', + reviewReason: 'referral_payment_chargeback', + }), + ], + impactReports: [expect.objectContaining({ state: 'failed' })], + }), + ]) + ); + }); }); diff --git a/apps/web/src/routers/admin/kiloclaw-referrals-router.ts b/apps/web/src/routers/admin/kiloclaw-referrals-router.ts index f7ae532f1e..3c994e12c7 100644 --- a/apps/web/src/routers/admin/kiloclaw-referrals-router.ts +++ b/apps/web/src/routers/admin/kiloclaw-referrals-router.ts @@ -5,6 +5,8 @@ import { and, desc, eq, inArray, or } from 'drizzle-orm'; import { adminProcedure, createTRPCRouter } from '@/lib/trpc/init'; import { db } from '@/lib/drizzle'; import { + impact_advocate_participants, + impact_advocate_registration_attempts, impact_advocate_reward_redemptions, impact_conversion_reports, impact_attribution_touches, @@ -17,22 +19,52 @@ import { } from '@kilocode/db/schema'; import { ImpactReferralProduct, ImpactReferralRewardKind } from '@kilocode/db/schema-types'; +const ReferralProductSchema = z.enum([ + ImpactReferralProduct.KiloClaw, + ImpactReferralProduct.KiloPass, +]); + const ReferralInvestigationInputSchema = z.object({ search: z.string().trim().min(1), + product: ReferralProductSchema.default(ImpactReferralProduct.KiloClaw), }); const NullableString = z.string().nullable(); const ReferralInvestigationOutputSchema = z.object({ + product: ReferralProductSchema, + productLabel: z.string(), referrer: z.object({ id: z.string(), email: NullableString, name: NullableString, }), + participantRegistrations: z.array( + z.object({ + id: z.string().uuid(), + programKey: ReferralProductSchema, + registrationState: z.string(), + registeredAt: NullableString, + lastRegistrationAttemptAt: NullableString, + lastErrorCode: NullableString, + lastErrorMessage: NullableString, + latestAttempt: z + .object({ + id: z.string().uuid(), + deliveryState: z.string(), + responseStatusCode: z.number().nullable(), + nextRetryAt: NullableString, + createdAt: z.string(), + }) + .nullable(), + }) + ), referrals: z.array( z.object({ referral: z.object({ id: z.string().uuid(), + product: ReferralProductSchema, + productLabel: z.string(), impactReferralId: NullableString, createdAt: z.string(), }), @@ -56,6 +88,8 @@ const ReferralInvestigationOutputSchema = z.object({ conversion: z .object({ id: z.string().uuid(), + product: ReferralProductSchema, + paymentProvider: z.string(), winningTouchType: z.string(), sourcePaymentId: z.string(), qualified: z.boolean(), @@ -66,34 +100,50 @@ const ReferralInvestigationOutputSchema = z.object({ rewardDecisions: z.array( z.object({ id: z.string().uuid(), + product: ReferralProductSchema, beneficiaryUserId: z.string(), beneficiaryRole: z.string(), outcome: z.string(), reason: NullableString, + rewardKind: z.string(), monthsGranted: z.number(), + rewardPercent: z.number().nullable(), + sourceTier: NullableString, + rewardAmountUsd: z.number().nullable(), createdAt: z.string(), }) ), rewards: z.array( z.object({ id: z.string().uuid(), + product: ReferralProductSchema, beneficiaryUserId: z.string(), beneficiaryRole: z.string(), + rewardKind: z.string(), status: z.string(), monthsGranted: z.number(), + rewardPercent: z.number().nullable(), + sourceTier: NullableString, + rewardAmountUsd: z.number().nullable(), earnedAt: z.string(), appliedAt: NullableString, expiresAt: NullableString, reviewReason: NullableString, + appliesToKiloPassSubscriptionId: z.string().uuid().nullable(), + consumedKiloPassIssuanceId: z.string().uuid().nullable(), + consumedKiloPassIssuanceItemId: z.string().uuid().nullable(), }) ), rewardApplications: z.array( z.object({ id: z.string().uuid(), + product: ReferralProductSchema, beneficiaryUserId: z.string(), subscriptionId: z.string().uuid().nullable(), previousRenewalBoundary: z.string(), newRenewalBoundary: z.string(), + localOperationId: NullableString, + stripeOperationId: NullableString, appliedAt: z.string(), }) ), @@ -139,6 +189,22 @@ function listByConversionId( return rows.filter(row => row.conversionId === conversionId); } +function getProductLabel(product: ImpactReferralProduct): string { + return product === ImpactReferralProduct.KiloPass ? 'Kilo Pass' : 'KiloClaw'; +} + +function getRewardKindForProduct(product: ImpactReferralProduct): ImpactReferralRewardKind { + return product === ImpactReferralProduct.KiloPass + ? ImpactReferralRewardKind.KiloPassBonus + : ImpactReferralRewardKind.KiloClawFreeMonth; +} + +function latestAttemptForParticipant< + T extends { participantId: string; createdAt: string | null | undefined }, +>(attempts: T[], participantId: string): T | null { + return attempts.find(attempt => attempt.participantId === participantId) ?? null; +} + async function findReferrer(search: string) { const normalizedSearch = search.trim().toLowerCase(); const [referrer] = await db @@ -161,15 +227,62 @@ async function findReferrer(search: string) { return referrer ?? null; } -async function investigateReferrer(search: string): Promise { +async function investigateReferrer( + search: string, + product: ImpactReferralProduct +): Promise { const referrer = await findReferrer(search); if (!referrer) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Referrer not found.' }); } + const productLabel = getProductLabel(product); + const rewardKind = getRewardKindForProduct(product); + + const participantRows = await db + .select({ + id: impact_advocate_participants.id, + programKey: impact_advocate_participants.program_key, + registrationState: impact_advocate_participants.registration_state, + registeredAt: impact_advocate_participants.registered_at, + lastRegistrationAttemptAt: impact_advocate_participants.last_registration_attempt_at, + lastErrorCode: impact_advocate_participants.last_error_code, + lastErrorMessage: impact_advocate_participants.last_error_message, + }) + .from(impact_advocate_participants) + .where( + and( + eq(impact_advocate_participants.program_key, product), + eq(impact_advocate_participants.user_id, referrer.id) + ) + ) + .orderBy(desc(impact_advocate_participants.created_at)); + + const participantIds = participantRows.map(participant => participant.id); + const participantAttempts = participantIds.length + ? await db + .select({ + participantId: impact_advocate_registration_attempts.participant_id, + id: impact_advocate_registration_attempts.id, + deliveryState: impact_advocate_registration_attempts.delivery_state, + responseStatusCode: impact_advocate_registration_attempts.response_status_code, + nextRetryAt: impact_advocate_registration_attempts.next_retry_at, + createdAt: impact_advocate_registration_attempts.created_at, + }) + .from(impact_advocate_registration_attempts) + .where( + and( + eq(impact_advocate_registration_attempts.program_key, product), + inArray(impact_advocate_registration_attempts.participant_id, participantIds) + ) + ) + .orderBy(desc(impact_advocate_registration_attempts.created_at)) + : []; + const referralRows = await db .select({ referralId: impact_referrals.id, + referralProduct: impact_referrals.product, impactReferralId: impact_referrals.impact_referral_id, referralCreatedAt: impact_referrals.created_at, refereeId: kilocode_users.id, @@ -191,17 +304,16 @@ async function investigateReferrer(search: string): Promise { + const latestAttempt = latestAttemptForParticipant(participantAttempts, participant.id); + return { + id: participant.id, + programKey: participant.programKey, + registrationState: participant.registrationState, + registeredAt: normalizeTimestamp(participant.registeredAt), + lastRegistrationAttemptAt: normalizeTimestamp(participant.lastRegistrationAttemptAt), + lastErrorCode: participant.lastErrorCode, + lastErrorMessage: participant.lastErrorMessage, + latestAttempt: latestAttempt + ? { + id: latestAttempt.id, + deliveryState: latestAttempt.deliveryState, + responseStatusCode: latestAttempt.responseStatusCode, + nextRetryAt: normalizeTimestamp(latestAttempt.nextRetryAt), + createdAt: normalizeTimestamp(latestAttempt.createdAt) ?? latestAttempt.createdAt, + } + : null, + }; + }), referrals: referralRows.map(referral => { const conversion = conversions.find(row => row.refereeUserId === referral.refereeId) ?? null; const conversionId = conversion?.id ?? null; @@ -350,6 +500,8 @@ async function investigateReferrer(search: string): Promise ({ id: decision.id, + product: decision.product, beneficiaryUserId: decision.beneficiaryUserId, beneficiaryRole: decision.beneficiaryRole, outcome: decision.outcome, reason: decision.reason, + rewardKind: decision.rewardKind, monthsGranted: decision.monthsGranted, + rewardPercent: decision.rewardPercent, + sourceTier: decision.sourceTier, + rewardAmountUsd: decision.rewardAmountUsd, createdAt: normalizeTimestamp(decision.createdAt) ?? decision.createdAt, })) : [], rewards: conversionId ? listByConversionId(rewards, conversionId).map(reward => ({ id: reward.id, + product: reward.product, beneficiaryUserId: reward.beneficiaryUserId, beneficiaryRole: reward.beneficiaryRole, + rewardKind: reward.rewardKind, status: reward.status, monthsGranted: reward.monthsGranted, + rewardPercent: reward.rewardPercent, + sourceTier: reward.sourceTier, + rewardAmountUsd: reward.rewardAmountUsd, earnedAt: normalizeTimestamp(reward.earnedAt) ?? reward.earnedAt, appliedAt: normalizeTimestamp(reward.appliedAt), expiresAt: normalizeTimestamp(reward.expiresAt), reviewReason: reward.reviewReason, + appliesToKiloPassSubscriptionId: reward.appliesToKiloPassSubscriptionId, + consumedKiloPassIssuanceId: reward.consumedKiloPassIssuanceId, + consumedKiloPassIssuanceItemId: reward.consumedKiloPassIssuanceItemId, })) : [], rewardApplications: conversionId ? listByConversionId(rewardApplications, conversionId).map(application => ({ id: application.id, + product: application.product, beneficiaryUserId: application.beneficiaryUserId, subscriptionId: application.subscriptionId, previousRenewalBoundary: @@ -415,6 +583,8 @@ async function investigateReferrer(search: string): Promise { - return await investigateReferrer(input.search); + return await investigateReferrer(input.search, input.product); }), }); diff --git a/apps/web/src/scripts/dev-apply-kilo-pass-referral.ts b/apps/web/src/scripts/dev-apply-kilo-pass-referral.ts new file mode 100644 index 0000000000..9184227e82 --- /dev/null +++ b/apps/web/src/scripts/dev-apply-kilo-pass-referral.ts @@ -0,0 +1,210 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import process from 'node:process'; + +type ScriptOptions = { + email: string; + issueMonth?: string; + invoiceId: string; + includeBaseCredits: boolean; +}; + +function loadLocalEnv(): void { + for (const envPath of [ + resolve(process.cwd(), '../../.env.local'), + resolve(process.cwd(), '.env.local'), + ]) { + if (existsSync(envPath)) { + process.loadEnvFile(envPath); + break; + } + } + + Object.assign(process.env, { NODE_ENV: 'development' }); + process.env.IS_IN_AUTOMATED_TEST ??= 'true'; + process.env.POSTGRES_CONNECT_TIMEOUT ??= '30000'; + process.env.POSTGRES_MAX_QUERY_TIME ??= '30000'; + process.env.POSTGRES_SCRIPT_URL ??= process.env.POSTGRES_URL; + process.env.NEXT_PUBLIC_GASTOWN_URL ??= 'http://localhost:8787'; + process.env.NEXT_PUBLIC_KILO_CHAT_URL ??= 'http://localhost:8788'; + process.env.NEXT_PUBLIC_EVENT_SERVICE_URL ??= 'http://localhost:8789'; + process.env.NEXT_PUBLIC_WASTELAND_URL ??= 'http://localhost:8790'; +} + +function printUsage(): void { + console.log(`Usage: + pnpm --filter web script src/scripts/dev-apply-kilo-pass-referral.ts [--issue-month YYYY-MM-01] [--invoice-id in_local_...] [--include-base] + +Examples: + pnpm --filter web script src/scripts/dev-apply-kilo-pass-referral.ts kilopass-referee1@example.com + pnpm --filter web script src/scripts/dev-apply-kilo-pass-referral.ts kilopass-referrer@example.com --include-base +`); +} + +function parseArgs(argv: string[]): ScriptOptions { + const email = argv.find(arg => !arg.startsWith('--')); + if (!email) { + printUsage(); + throw new Error('Missing email argument'); + } + + const getFlagValue = (flag: string): string | undefined => { + const index = argv.indexOf(flag); + if (index === -1) return undefined; + const value = argv[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${flag} requires a value`); + } + return value; + }; + + return { + email, + issueMonth: getFlagValue('--issue-month'), + invoiceId: getFlagValue('--invoice-id') ?? `in_local_referral_bonus_${Date.now()}`, + includeBaseCredits: argv.includes('--include-base'), + }; +} + +function assertIssueMonth(issueMonth: string): void { + if (!/^\d{4}-\d{2}-01$/.test(issueMonth)) { + throw new Error(`Invalid --issue-month ${issueMonth}; expected YYYY-MM-01`); + } +} + +async function main(): Promise { + loadLocalEnv(); + + const [ + drizzleOrm, + balanceCache, + drizzle, + constants, + dayjsModule, + enums, + issuance, + state, + schema, + ] = await Promise.all([ + import('drizzle-orm'), + import('@/lib/balanceCache'), + import('@/lib/drizzle'), + import('@/lib/kilo-pass/constants'), + import('@/lib/kilo-pass/dayjs'), + import('@/lib/kilo-pass/enums'), + import('@/lib/kilo-pass/issuance'), + import('@/lib/kilo-pass/state'), + import('@kilocode/db/schema'), + ]); + + const { eq, desc } = drizzleOrm; + const { forceImmediateExpirationRecomputation } = balanceCache; + const { db, closeAllDrizzleConnections } = drizzle; + const { KILO_PASS_TIER_CONFIG } = constants; + const { dayjs } = dayjsModule; + const { KiloPassCadence, KiloPassIssuanceSource } = enums; + const { + applyPendingKiloPassReferralBonusForIssuance, + computeIssueMonth, + createOrGetIssuanceHeader, + issueBaseCreditsForIssuance, + } = issuance; + const { getKiloPassStateForUser } = state; + const { kilo_pass_issuances, kilocode_users } = schema; + + try { + const options = parseArgs(process.argv.slice(2)); + const user = await db.query.kilocode_users.findFirst({ + where: eq(kilocode_users.google_user_email, options.email), + }); + + if (!user) { + throw new Error(`User not found: ${options.email}`); + } + + const subscription = await getKiloPassStateForUser(db, user.id); + if (!subscription) { + throw new Error(`No Kilo Pass subscription for ${options.email}`); + } + if (subscription.cadence !== KiloPassCadence.Monthly) { + throw new Error(`Referral bonus application is monthly-only; got ${subscription.cadence}`); + } + if (subscription.status !== 'active') { + throw new Error(`Kilo Pass subscription must be active; got ${subscription.status}`); + } + + const getDefaultNextIssueMonth = async (): Promise => { + const latestIssuance = await db.query.kilo_pass_issuances.findFirst({ + where: eq(kilo_pass_issuances.kilo_pass_subscription_id, subscription.subscriptionId), + orderBy: desc(kilo_pass_issuances.issue_month), + }); + + const baseMonth = latestIssuance?.issue_month + ? dayjs(`${latestIssuance.issue_month}T00:00:00.000Z`).utc() + : dayjs(subscription.startedAt ?? new Date().toISOString()).utc(); + + return computeIssueMonth(baseMonth.add(1, 'month')); + }; + + const issueMonth = options.issueMonth ?? (await getDefaultNextIssueMonth()); + assertIssueMonth(issueMonth); + + const result = await db.transaction(async tx => { + const createdIssuance = await createOrGetIssuanceHeader(tx, { + subscriptionId: subscription.subscriptionId, + issueMonth, + source: KiloPassIssuanceSource.StripeInvoice, + stripeInvoiceId: options.invoiceId, + }); + + const baseCreditsResult = options.includeBaseCredits + ? await issueBaseCreditsForIssuance(tx, { + issuanceId: createdIssuance.issuanceId, + subscriptionId: subscription.subscriptionId, + kiloUserId: user.id, + amountUsd: KILO_PASS_TIER_CONFIG[subscription.tier].monthlyPriceUsd, + stripeInvoiceId: options.invoiceId, + description: `Local Kilo Pass base credits (${subscription.tier}, ${subscription.cadence})`, + }) + : null; + + const referralBonusResult = await applyPendingKiloPassReferralBonusForIssuance(tx, { + issuanceId: createdIssuance.issuanceId, + subscriptionId: subscription.subscriptionId, + kiloUserId: user.id, + stripeInvoiceId: options.invoiceId, + }); + + return { + issuance: createdIssuance, + baseCreditsResult, + referralBonusResult, + }; + }); + + await forceImmediateExpirationRecomputation(user.id); + + console.log( + JSON.stringify( + { + email: options.email, + userId: user.id, + subscriptionId: subscription.subscriptionId, + issueMonth, + invoiceId: options.invoiceId, + includeBaseCredits: options.includeBaseCredits, + result, + }, + null, + 2 + ) + ); + } finally { + await closeAllDrizzleConnections(); + } +} + +main().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/dev/seed/app/user-id.ts b/dev/seed/app/user-id.ts new file mode 100644 index 0000000000..502a05af8c --- /dev/null +++ b/dev/seed/app/user-id.ts @@ -0,0 +1,80 @@ +import { kilocode_users } from '@kilocode/db/schema'; +import { eq, or } from 'drizzle-orm'; + +import { getSeedDb } from '../lib/db'; +import { normalizeSeedEmail } from '../lib/email'; +import type { SeedResult } from '../index'; + +export const usage = ''; + +function printUsage(): void { + console.log(`Usage: pnpm dev:seed app:user-id ${usage}`); + console.log(''); + console.log('Prints the Kilo Code user id for a local development user by email.'); + console.log('Matches either google_user_email exactly or normalized_email.'); + console.log(''); + console.log('Examples:'); + console.log(' pnpm dev:seed app:user-id ada@example.com'); + console.log(' pnpm -s dev:seed app:user-id ada@example.com --json | jq -r .userId'); +} + +function isValidEmail(email: string): boolean { + // Intentionally permissive; we only guard against obvious nonsense in dev. + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +export async function run(...args: string[]): Promise { + if (args.includes('--help') || args.includes('-h')) { + printUsage(); + return; + } + + const [email, ...rest] = args; + if (!email) { + printUsage(); + throw new Error('email is required'); + } + if (rest.length > 0) { + printUsage(); + throw new Error(`Unexpected extra arguments: ${rest.join(' ')}`); + } + + const trimmedEmail = email.trim(); + if (!isValidEmail(trimmedEmail)) { + throw new Error(`email is not a valid address: ${trimmedEmail}`); + } + + const normalizedEmail = normalizeSeedEmail(trimmedEmail); + const db = getSeedDb(); + const matches = await db + .select({ + userId: kilocode_users.id, + email: kilocode_users.google_user_email, + normalizedEmail: kilocode_users.normalized_email, + }) + .from(kilocode_users) + .where( + or( + eq(kilocode_users.google_user_email, trimmedEmail), + eq(kilocode_users.normalized_email, normalizedEmail) + ) + ); + + if (matches.length === 0) { + throw new Error(`No user found for email ${trimmedEmail}`); + } + + const exactMatches = matches.filter(match => match.email === trimmedEmail); + const resolvedMatches = exactMatches.length > 0 ? exactMatches : matches; + if (resolvedMatches.length > 1) { + const matchList = resolvedMatches.map(match => `${match.email} (${match.userId})`).join(', '); + throw new Error(`Multiple users matched ${trimmedEmail}: ${matchList}`); + } + + const [user] = resolvedMatches; + return { + userId: user.userId, + email: user.email, + normalizedEmail: user.normalizedEmail ?? null, + }; +} From 8cbf3ce6cc9e34d88fb9955231272319a1016e0e Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Fri, 29 May 2026 14:57:01 +0200 Subject: [PATCH 4/4] fix(referrals): name Kilo Pass Impact reward unit --- .specs/impact-referrals.md | 9 ++++-- .../lib/impact/kilo-pass-referrals.test.ts | 30 ++++++++++++++----- apps/web/src/lib/impact/kiloclaw-referrals.ts | 2 +- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/.specs/impact-referrals.md b/.specs/impact-referrals.md index 3df3a60628..5125168806 100644 --- a/.specs/impact-referrals.md +++ b/.specs/impact-referrals.md @@ -18,6 +18,7 @@ Updated 2026-05-06 -- require Impact Advocate reward redemption after local Kilo Updated 2026-05-12 -- note price-versioned KiloClaw billing preserves referral semantics. Updated 2026-05-22 -- renamed to `.specs/impact-referrals.md` and expanded to Kilo Pass referrals. Updated 2026-05-28 -- classify enforced Stripe EFW refunds as adverse payments. +Updated 2026-05-29 -- name the Impact-facing Kilo Pass reward unit `Kilo Pass Bonus Credits`. ## Conventions @@ -628,8 +629,8 @@ application, and Kilo Pass redeems after local referral bonus allocation. local reward eligibility, application, cancellation, or reversal. 158a. For Kilo Pass, when a local referral bonus reward is allocated/granted, the system MUST queue asynchronous Impact - Advocate reward lookup and single-reward redemption using the reward amount and USD unit so Impact reporting - matches Kilo allocation state. + Advocate reward lookup and single-reward redemption using the USD-denominated reward amount and the + `Kilo Pass Bonus Credits` unit so Impact reporting matches Kilo allocation state. 158b. Kilo Pass Impact Advocate reward redemption MUST be idempotently queued per local reward and MUST NOT block paid conversion processing, reward ledger creation, reward application, billing settlement, or user access. @@ -772,6 +773,10 @@ application, and Kilo Pass redeems after local referral bonus allocation. ## Changelog +### 2026-05-29 -- Name the Kilo Pass Impact reward unit + +Kilo Pass reward synchronization sends the `Kilo Pass Bonus Credits` unit to Impact Advocate while retaining the USD-denominated local reward amount. + ### 2026-05-28 -- Enforced EFW refunds are adverse payments Classified an enforced Stripe Early Fraud Warning refund as an adverse qualifying payment for both covered products. Pending or earned-but-unapplied rewards cancel, already-applied rewards require support review, and later refund or chargeback delivery must remain idempotent. diff --git a/apps/web/src/lib/impact/kilo-pass-referrals.test.ts b/apps/web/src/lib/impact/kilo-pass-referrals.test.ts index d575219140..bc7101886f 100644 --- a/apps/web/src/lib/impact/kilo-pass-referrals.test.ts +++ b/apps/web/src/lib/impact/kilo-pass-referrals.test.ts @@ -18,9 +18,16 @@ jest.mock('@/lib/impact/advocate', () => { sendImpactAdvocateRewardLookupPayload: jest.fn(async () => ({ ok: true, statusCode: 200, - rewards: [{ id: 'impact-kilo-pass-reward', type: 'CREDIT', amount: 24.5, unit: 'USD' }], + rewards: [ + { + id: 'impact-kilo-pass-reward', + type: 'CREDIT', + amount: 24.5, + unit: 'Kilo Pass Bonus Credits', + }, + ], responseBody: - '{"rewards":[{"id":"impact-kilo-pass-reward","type":"CREDIT","amount":24.5,"unit":"USD"}]}', + '{"rewards":[{"id":"impact-kilo-pass-reward","type":"CREDIT","amount":24.5,"unit":"Kilo Pass Bonus Credits"}]}', })), sendImpactAdvocateRewardRedemptionPayload: jest.fn(async () => ({ ok: true, @@ -114,9 +121,16 @@ beforeEach(async () => { mockSendImpactAdvocateRewardLookupPayload.mockResolvedValue({ ok: true, statusCode: 200, - rewards: [{ id: 'impact-kilo-pass-reward', type: 'CREDIT', amount: 24.5, unit: 'USD' }], + rewards: [ + { + id: 'impact-kilo-pass-reward', + type: 'CREDIT', + amount: 24.5, + unit: 'Kilo Pass Bonus Credits', + }, + ], responseBody: - '{"rewards":[{"id":"impact-kilo-pass-reward","type":"CREDIT","amount":24.5,"unit":"USD"}]}', + '{"rewards":[{"id":"impact-kilo-pass-reward","type":"CREDIT","amount":24.5,"unit":"Kilo Pass Bonus Credits"}]}', }); mockSendImpactAdvocateRewardRedemptionPayload.mockResolvedValue({ ok: true, @@ -383,7 +397,7 @@ describe('Kilo Pass Impact referral conversions', () => { userId: 'referee@example.com', rewardTypeFilter: 'CREDIT', }), - redemption: { amount: 24.5, unit: 'USD' }, + redemption: { amount: 24.5, unit: 'Kilo Pass Bonus Credits' }, }), }), expect.objectContaining({ @@ -396,7 +410,7 @@ describe('Kilo Pass Impact referral conversions', () => { userId: 'referrer@example.com', rewardTypeFilter: 'CREDIT', }), - redemption: { amount: 24.5, unit: 'USD' }, + redemption: { amount: 24.5, unit: 'Kilo Pass Bonus Credits' }, }), }), ]) @@ -413,7 +427,7 @@ describe('Kilo Pass Impact referral conversions', () => { { programKey: ImpactAdvocateProgramKey.KiloPass } ); expect(mockSendImpactAdvocateRewardRedemptionPayload).toHaveBeenCalledWith( - { rewardId: 'impact-kilo-pass-reward', amount: 24.5, unit: 'USD' }, + { rewardId: 'impact-kilo-pass-reward', amount: 24.5, unit: 'Kilo Pass Bonus Credits' }, { programKey: ImpactAdvocateProgramKey.KiloPass } ); @@ -837,7 +851,7 @@ describe('Kilo Pass Impact referral conversions', () => { state: 'redeemed', request_payload: expect.objectContaining({ programKey: ImpactAdvocateProgramKey.KiloPass, - redemption: { amount: 24.5, unit: 'USD' }, + redemption: { amount: 24.5, unit: 'Kilo Pass Bonus Credits' }, }), }) ) diff --git a/apps/web/src/lib/impact/kiloclaw-referrals.ts b/apps/web/src/lib/impact/kiloclaw-referrals.ts index 4328da0d53..c7056a67a1 100644 --- a/apps/web/src/lib/impact/kiloclaw-referrals.ts +++ b/apps/web/src/lib/impact/kiloclaw-referrals.ts @@ -129,7 +129,7 @@ const REFERRAL_REWARD_ACTOR = { const SIGNUP_REFERRAL_TOUCH_CAPTURE_GRACE_MS = 10 * 60 * 1000; const IMPACT_ADVOCATE_KILOCLAW_REWARD_UNIT = 'MONTH'; -const IMPACT_ADVOCATE_KILO_PASS_REWARD_UNIT = 'USD'; +const IMPACT_ADVOCATE_KILO_PASS_REWARD_UNIT = 'Kilo Pass Bonus Credits'; function getDatabaseClient(database?: DatabaseClient): DatabaseClient { return database ?? db;