Skip to content

Commit 50685c2

Browse files
committed
feat(referrals): launch Kilo Pass advocate rewards
1 parent 930df42 commit 50685c2

41 files changed

Lines changed: 4519 additions & 542 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.specs/impact-referrals.md

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,6 @@ BCP 14 [RFC 2119] [RFC 8174] keywords apply only when they appear in all capital
120120
after the referral reward was earned. Annual subscription issuances and already-created issuances are not eligible.
121121
- **Kilo Pass bonus-like issuance item**: Kilo Pass issuance item of kind `bonus`, `promo_first_month_50pct`, or
122122
`referral_bonus`. At most one bonus-like item may exist for an issuance.
123-
- **Referral launch cutoff**: UTC instant configured for the Kilo Pass referral launch. First-time monthly subscribers
124-
who started before the cutoff keep legacy month-2 welcome promo behavior; subscribers starting at or after the cutoff
125-
receive only the first-month welcome promo.
126123

127124
## Overview
128125

@@ -143,6 +140,8 @@ not stack with, the normal Kilo Pass monthly/promo bonus for that issuance.
143140
Existing Impact Performance conversion events drive Impact Advocate conversion state. The system uses `Sale (71659)` as
144141
the paid-conversion event for referral conversion and renewal reporting. When referral wins attribution for a paid
145142
conversion, local referral rewards are authoritative and affiliate SALE reporting for the same conversion is suppressed.
143+
Impact Advocate reward redemption is used only for reporting synchronization: KiloClaw redeems after local free-month
144+
application, and Kilo Pass redeems after local referral bonus allocation.
146145

147146
## Rules
148147

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

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.
159+
3. Impact Advocate account SID, auth token, and tenant alias MAY be shared across Advocate programs. KiloClaw and Kilo
160+
Pass MUST each require explicit product-scoped Advocate program ID and widget ID configuration. Products MUST NOT
161+
fall back to unscoped or other-product program/widget configuration.
162162

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

@@ -543,9 +543,9 @@ conversion, local referral rewards are authoritative and affiliate SALE reportin
543543
rewards but only an annual Kilo Pass subscription, rewards remain pending until an eligible monthly subscription is
544544
available or the rewards expire.
545545

546-
132. Kilo Pass welcome bonus behavior MUST change at referral launch: first-time monthly subscribers who started before
547-
the referral launch cutoff keep legacy month-2 promo eligibility; subscribers starting at or after the cutoff MUST
548-
receive only the first-month welcome promo.
546+
132. Kilo Pass welcome bonus behavior MUST grant the 50% promo only for the first monthly streak month of a
547+
first-time subscriber. Streak month 2 and later MUST use the normal monthly ramp unless a referral bonus replaces
548+
the bonus for that issuance.
549549

550550
### Shared Reward Granting
551551

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

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

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

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

778798
Renamed `.specs/kiloclaw-referrals.md` to `.specs/impact-referrals.md`. Generalized shared Impact Advocate referral
779799
rules and added Kilo Pass referral requirements: separate program/widget config, `/subscriptions/kilo-pass/refer`,
780800
monthly Stripe launch scope, referral-vs-affiliate priority using the KiloClaw resolver model, first-time Kilo Pass
781801
subscriber eligibility, 5-referrer-reward cap, double-sided 50% rewards snapshotted from the referee's monthly tier,
782802
12-month pending reward expiry, base-issuance `referral_bonus` fulfillment, monthly bonus replacement, adverse-payment
783-
handling, server-side Performance SALE reporting for Advocate state, and welcome-bonus cutoff behavior.
803+
handling, server-side Performance SALE reporting for Advocate state, and first-month welcome-bonus behavior.
784804

785805
### 2026-05-12 -- Price-versioned KiloClaw billing preserves referral semantics
786806

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 & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,6 @@ import { KiloPassLoadingCard } from '@/components/profile/kilo-pass/KiloPassLoad
1111
import { KiloPassSubscribeCard } from '@/components/profile/kilo-pass/KiloPassSubscribeCard';
1212
import { isStripeSubscriptionEnded } from '@/lib/kilo-pass/stripe-subscription-status';
1313
import { recommendKiloPassTierFromAverageMonthlyUsageUsd } from '@/lib/kilo-pass/recommend-tier';
14-
import { KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF } from '@/lib/kilo-pass/constants';
15-
import { dayjs } from '@/lib/kilo-pass/dayjs';
16-
17-
function getShowKiloPassTwoMonthPromo(showFirstMonthPromo: boolean): boolean {
18-
return (
19-
showFirstMonthPromo && dayjs().utc().isBefore(KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF)
20-
);
21-
}
2214

2315
export function ProfileKiloPassSection() {
2416
const trpc = useTRPC();
@@ -42,7 +34,7 @@ export function ProfileKiloPassSection() {
4234
toast.error('Failed to create Stripe checkout session');
4335
return;
4436
}
45-
window.location.href = result.url;
37+
window.location.assign(result.url);
4638
},
4739
onError: error => {
4840
toast.error(error.message || 'Failed to start checkout');
@@ -65,7 +57,6 @@ export function ProfileKiloPassSection() {
6557
if (!activeSubscription) {
6658
const pending = checkoutMutation.isPending;
6759
const showFirstMonthPromo = query.data.isEligibleForFirstMonthPromo;
68-
const showSecondMonthPromo = getShowKiloPassTwoMonthPromo(showFirstMonthPromo);
6960
const averageMonthlyUsageUsd = averageMonthlyUsageQuery.data?.averageMonthlyUsageUsd;
7061
const recommendedTier =
7162
typeof averageMonthlyUsageUsd === 'number'
@@ -78,7 +69,6 @@ export function ProfileKiloPassSection() {
7869
setCadence={setCadence}
7970
pending={pending}
8071
showFirstMonthPromo={showFirstMonthPromo}
81-
showSecondMonthPromo={showSecondMonthPromo}
8272
recommendedTier={recommendedTier}
8373
onSelectTier={tier => checkoutMutation.mutate({ tier, cadence })}
8474
/>

apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.logic.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,6 @@ function computeRefillRowModel(params: {
130130
tier: baseTier,
131131
streakMonths: Math.max(1, params.subscription.currentStreakMonths + 1),
132132
isFirstTimeSubscriberEver: params.subscription.isFirstTimeSubscriberEver,
133-
subscriptionStartedAtIso: params.subscription.startedAt,
134133
})
135134
: null;
136135
if (typeof bonusUsd !== 'number' || bonusUsd <= 0) return null;

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

0 commit comments

Comments
 (0)