Skip to content

Commit b6f4759

Browse files
committed
feat(referrals): launch Kilo Pass advocate rewards
1 parent 242cdf0 commit b6f4759

31 files changed

Lines changed: 4457 additions & 174 deletions

.specs/impact-referrals.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ not stack with, the normal Kilo Pass monthly/promo bonus for that issuance.
143143
Existing Impact Performance conversion events drive Impact Advocate conversion state. The system uses `Sale (71659)` as
144144
the paid-conversion event for referral conversion and renewal reporting. When referral wins attribution for a paid
145145
conversion, local referral rewards are authoritative and affiliate SALE reporting for the same conversion is suppressed.
146+
Impact Advocate reward redemption is used only for reporting synchronization: KiloClaw redeems after local free-month
147+
application, and Kilo Pass redeems after local referral bonus allocation.
146148

147149
## Rules
148150

@@ -157,8 +159,9 @@ conversion, local referral rewards are authoritative and affiliate SALE reportin
157159
- UTT UUID: `A7138521-9724-4b8f-95f4-1db2fbae81141`
158160
- Advocate widget ID: `p/51699/w/referrerWidget`
159161

160-
3. Existing unscoped Impact Advocate configuration MAY remain as KiloClaw fallback configuration only. Kilo Pass MUST
161-
require explicit Kilo Pass Advocate program/widget configuration and MUST NOT fall back to KiloClaw configuration.
162+
3. Impact Advocate account SID, auth token, and tenant alias MAY be shared across Advocate programs. KiloClaw and Kilo
163+
Pass MUST each require explicit product-scoped Advocate program ID and widget ID configuration. Products MUST NOT
164+
fall back to unscoped or other-product program/widget configuration.
162165

163166
4. Kilo Pass MUST use a different Impact Advocate program ID and widget ID than KiloClaw.
164167

@@ -627,6 +630,16 @@ conversion, local referral rewards are authoritative and affiliate SALE reportin
627630
158. Impact reward redemption state is for reporting and reconciliation only. It MUST NOT be the source of truth for
628631
local reward eligibility, application, cancellation, or reversal.
629632

633+
158a. For Kilo Pass, when a local referral bonus reward is allocated/granted, the system MUST queue asynchronous Impact
634+
Advocate reward lookup and single-reward redemption using the reward amount and USD unit so Impact reporting
635+
matches Kilo allocation state.
636+
637+
158b. Kilo Pass Impact Advocate reward redemption MUST be idempotently queued per local reward and MUST NOT block paid
638+
conversion processing, reward ledger creation, reward application, billing settlement, or user access.
639+
640+
158c. Kilo Pass Impact reward lookup and redemption state is for reporting and reconciliation only. It MUST NOT be the
641+
source of truth for local reward eligibility, application, cancellation, or reversal.
642+
630643
### Refunds, Reversals, and Fraud
631644

632645
159. Rewards from a qualifying Stripe payment MUST be treated as adverse when Stripe reports a chargeback or when
@@ -773,6 +786,16 @@ conversions. The first positively paid settlement using a supported fingerprinta
773786
instrument opportunity; reused instruments retain ordinary monthly-ramp bonus behavior but do not receive the
774787
introductory promo or create Kilo Pass referral rewards. Annual behavior remains outside this restriction.
775788

789+
### 2026-05-26 -- Redeem allocated Kilo Pass rewards in Impact Advocate
790+
791+
Kilo Pass referral bonus allocation now queues Impact Advocate reward lookup and redemption using the USD reward amount,
792+
for reporting synchronization only. The local reward ledger remains authoritative.
793+
794+
### 2026-05-25 -- Require product-scoped Advocate program/widget configuration
795+
796+
Removed KiloClaw fallback to unscoped Impact Advocate program/widget configuration. KiloClaw and Kilo Pass now both
797+
require explicit product-scoped Advocate program ID and widget ID while sharing account SID, auth token, and tenant alias.
798+
776799
### 2026-05-22 -- Rename and expand to Kilo Pass
777800

778801
Renamed `.specs/kiloclaw-referrals.md` to `.specs/impact-referrals.md`. Generalized shared Impact Advocate referral

apps/web/src/app/(app)/claw/components/billing/PlanSelectionDialog.tsx

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,6 @@ import {
2020
type KiloClawSignupDisplay,
2121
type KiloPassUpsellActivationPreview,
2222
} from './billing-types';
23-
import { KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF } from '@/lib/kilo-pass/constants';
24-
import { dayjs } from '@/lib/kilo-pass/dayjs';
25-
2623
type Cadence = 'monthly' | 'yearly';
2724
type Tier = '19' | '49' | '199';
2825

@@ -137,7 +134,7 @@ function TierCard({
137134
Up to <span className="text-emerald-300">40%</span> free bonus credits
138135
</div>
139136
<div className="text-xs leading-relaxed text-emerald-300">
140-
First 2 months: +50% free bonus credits
137+
First month: +50% free bonus credits
141138
</div>
142139
</div>
143140

@@ -293,8 +290,6 @@ function HostingOnlyPlanCard({
293290

294291
function CreditsHowItWorks() {
295292
const [open, setOpen] = useState(false);
296-
const showTwoMonthPromo = dayjs().utc().isBefore(KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF);
297-
298293
return (
299294
<div className="border-border/50 mb-3.5 overflow-hidden rounded-lg border">
300295
<button
@@ -320,15 +315,13 @@ function CreditsHowItWorks() {
320315
expire monthly.
321316
</span>
322317
</div>
323-
{showTwoMonthPromo ? (
324-
<div className="text-muted-foreground flex items-start gap-2 py-0.5 text-xs">
325-
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-400" />
326-
<span>
327-
First-time subscribers receive <span className="text-emerald-300">50%</span> free
328-
bonus credits for the first two months.
329-
</span>
330-
</div>
331-
) : null}
318+
<div className="text-muted-foreground flex items-start gap-2 py-0.5 text-xs">
319+
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-400" />
320+
<span>
321+
First-time subscribers receive <span className="text-emerald-300">50%</span> free
322+
bonus credits for the first month.
323+
</span>
324+
</div>
332325
</div>
333326
)}
334327
</div>
@@ -519,7 +512,7 @@ export function PlanSelectionDialog({ open, onOpenChange }: PlanSelectionDialogP
519512
const kiloPassUpsell = useMutation(
520513
trpc.kiloclaw.createKiloPassUpsellCheckout.mutationOptions({
521514
onSuccess: data => {
522-
if (data.url) window.location.href = data.url;
515+
if (data.url) window.location.assign(data.url);
523516
},
524517
})
525518
);

apps/web/src/app/(app)/claw/components/billing/WelcomePage.tsx

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,6 @@ import {
1919
type KiloClawSignupDisplay,
2020
type KiloPassUpsellActivationPreview,
2121
} from './billing-types';
22-
import { KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF } from '@/lib/kilo-pass/constants';
23-
import { dayjs } from '@/lib/kilo-pass/dayjs';
24-
2522
type Cadence = 'monthly' | 'yearly';
2623
type Tier = '19' | '49' | '199';
2724

@@ -130,7 +127,7 @@ function TierCard({
130127
Up to <span className="text-emerald-300">40%</span> free bonus credits
131128
</div>
132129
<div className="text-xs leading-relaxed text-emerald-300">
133-
First 2 months: +50% free bonus credits
130+
First month: +50% free bonus credits
134131
</div>
135132
</div>
136133

@@ -286,8 +283,6 @@ function HostingOnlyPlanCard({
286283

287284
function CreditsHowItWorks() {
288285
const [open, setOpen] = useState(false);
289-
const showTwoMonthPromo = dayjs().utc().isBefore(KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF);
290-
291286
return (
292287
<div className="border-border/50 mb-3.5 overflow-hidden rounded-lg border">
293288
<button
@@ -313,15 +308,13 @@ function CreditsHowItWorks() {
313308
expire monthly.
314309
</span>
315310
</div>
316-
{showTwoMonthPromo ? (
317-
<div className="text-muted-foreground flex items-start gap-2 py-0.5 text-xs">
318-
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-400" />
319-
<span>
320-
First-time subscribers receive <span className="text-emerald-300">50%</span> free
321-
bonus credits for the first two months.
322-
</span>
323-
</div>
324-
) : null}
311+
<div className="text-muted-foreground flex items-start gap-2 py-0.5 text-xs">
312+
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-400" />
313+
<span>
314+
First-time subscribers receive <span className="text-emerald-300">50%</span> free
315+
bonus credits for the first month.
316+
</span>
317+
</div>
325318
</div>
326319
)}
327320
</div>
@@ -512,7 +505,7 @@ export function WelcomePage() {
512505
const kiloPassUpsell = useMutation(
513506
trpc.kiloclaw.createKiloPassUpsellCheckout.mutationOptions({
514507
onSuccess: data => {
515-
if (data.url) window.location.href = data.url;
508+
if (data.url) window.location.assign(data.url);
516509
},
517510
})
518511
);

apps/web/src/app/(app)/subscriptions/kilo-pass/page.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
1+
import { redirect } from 'next/navigation';
2+
13
import { PageContainer } from '@/components/layouts/PageContainer';
24
import { KiloPassDetail } from '@/components/subscriptions/kilo-pass/KiloPassDetail';
5+
import { db } from '@/lib/drizzle';
6+
import { getKiloPassStateForUser } from '@/lib/kilo-pass/state';
7+
import { getUserFromAuthOrRedirect } from '@/lib/user/server';
8+
9+
export default async function KiloPassSubscriptionPage() {
10+
const user = await getUserFromAuthOrRedirect(
11+
'/users/sign_in?callbackPath=/subscriptions/kilo-pass'
12+
);
13+
const subscription = await getKiloPassStateForUser(db, user.id);
14+
15+
if (!subscription) {
16+
redirect('/subscriptions');
17+
}
318

4-
export default function KiloPassSubscriptionPage() {
519
return (
620
<PageContainer>
721
<KiloPassDetail />
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
'use client';
2+
3+
import { useQuery } from '@tanstack/react-query';
4+
5+
import { ImpactAdvocateReferralWidget } from '@/components/referrals/ImpactAdvocateReferralCard';
6+
import { KiloPassReferralPageContent } from '@/components/referrals/KiloPassReferralPageContent';
7+
import { useTRPC } from '@/lib/trpc/utils';
8+
9+
export default function KiloPassReferralPage() {
10+
const trpc = useTRPC();
11+
const rewardSummary = useQuery(trpc.kiloPass.getReferralRewardSummary.queryOptions());
12+
13+
return (
14+
<KiloPassReferralPageContent
15+
summary={rewardSummary.data ?? null}
16+
isLoading={rewardSummary.isLoading}
17+
errorMessage={
18+
rewardSummary.isError ? 'Rewards are temporarily unavailable. Try again in a minute.' : null
19+
}
20+
>
21+
<ImpactAdvocateReferralWidget product="kilo_pass" />
22+
</KiloPassReferralPageContent>
23+
);
24+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { timingSafeEqual } from 'node:crypto';
2+
import { NextResponse } from 'next/server';
3+
4+
import { CRON_SECRET } from '@/lib/config.server';
5+
import { expirePendingKiloPassReferralRewards } from '@/lib/impact/kilo-pass-referrals';
6+
import { sentryLogger } from '@/lib/utils.server';
7+
8+
if (!CRON_SECRET) {
9+
throw new Error('CRON_SECRET is not configured in environment variables');
10+
}
11+
12+
function isExpectedCronAuthorization(authHeader: string | null): boolean {
13+
if (!authHeader) return false;
14+
15+
const authHeaderBuffer = Buffer.from(authHeader);
16+
const expectedAuthBuffer = Buffer.from(`Bearer ${CRON_SECRET}`);
17+
if (authHeaderBuffer.length !== expectedAuthBuffer.length) return false;
18+
19+
return timingSafeEqual(authHeaderBuffer, expectedAuthBuffer);
20+
}
21+
22+
export async function GET(request: Request) {
23+
const authHeader = request.headers.get('authorization');
24+
if (!isExpectedCronAuthorization(authHeader)) {
25+
sentryLogger(
26+
'cron',
27+
'warning'
28+
)(
29+
'SECURITY: Invalid CRON job authorization attempt: ' +
30+
(authHeader ? 'Invalid authorization header' : 'Missing authorization header')
31+
);
32+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
33+
}
34+
35+
const summary = await expirePendingKiloPassReferralRewards();
36+
37+
return NextResponse.json(
38+
{
39+
success: true,
40+
summary,
41+
timestamp: new Date().toISOString(),
42+
},
43+
{ status: 200 }
44+
);
45+
}

apps/web/src/components/profile/ProfileKiloPassSection.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export function ProfileKiloPassSection() {
4242
toast.error('Failed to create Stripe checkout session');
4343
return;
4444
}
45-
window.location.href = result.url;
45+
window.location.assign(result.url);
4646
},
4747
onError: error => {
4848
toast.error(error.message || 'Failed to start checkout');

apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useState } from 'react';
55
import { useQuery } from '@tanstack/react-query';
66

77
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
8-
import { Badge } from '@/components/ui/badge';
8+
import { SubscriptionStatusBadge } from '@/components/subscriptions/SubscriptionStatusBadge';
99
import { Button } from '@/components/ui/button';
1010
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
1111
import { formatDollars, formatIsoDateString_UsaDateOnlyFormat } from '@/lib/utils';
@@ -14,6 +14,8 @@ import { dayjs } from '@/lib/kilo-pass/dayjs';
1414
import { useTRPC } from '@/lib/trpc/utils';
1515
import { getMonthlyPriceUsd } from '@/lib/kilo-pass/bonus';
1616

17+
import { KiloPassReferralButton } from '@/components/referrals/KiloPassReferralButton';
18+
1719
import { KiloPassSubscriptionSettingsModal } from './KiloPassSubscriptionSettingsModal';
1820
import type { KiloPassSubscription } from './kiloPassSubscription';
1921
import {
@@ -140,15 +142,18 @@ function HeaderRow() {
140142
<Coins className="h-5 w-5 text-amber-300" />
141143
</span>
142144
<span className="leading-none">
143-
<span className="block text-base">Kilo Pass</span>
145+
<span className="flex flex-wrap items-center gap-2">
146+
<span className="block text-base">Kilo Pass</span>
147+
<SubscriptionStatusBadge status={view.status.kind} />
148+
</span>
144149
<span className="text-muted-foreground block text-sm font-normal">
145150
{view.header.tierLabel}{view.header.cadenceLabel}
146151
</span>
147152
</span>
148153
</CardTitle>
149154

150-
<div className="flex items-center gap-2">
151-
<Badge variant={view.status.badgeVariant}>{view.status.label}</Badge>
155+
<div className="flex flex-wrap items-center justify-end gap-2">
156+
<KiloPassReferralButton />
152157
{providerManagement.externalManagementAction ? (
153158
<Button asChild variant="outline" size="icon" className="h-9 w-9">
154159
<a
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { describe, expect, it } from '@jest/globals';
2+
3+
import { buildImpactAdvocateTokenUrl } from './ImpactAdvocateReferralCard.utils';
4+
5+
describe('buildImpactAdvocateTokenUrl', () => {
6+
it('defaults to the KiloClaw Advocate token endpoint for existing callers', () => {
7+
expect(buildImpactAdvocateTokenUrl()).toBe('/api/impact-advocate/token');
8+
});
9+
10+
it('requests the Kilo Pass Advocate token without falling back to KiloClaw config', () => {
11+
expect(buildImpactAdvocateTokenUrl('kilo_pass')).toBe(
12+
'/api/impact-advocate/token?product=kilo_pass'
13+
);
14+
});
15+
});

0 commit comments

Comments
 (0)