Skip to content

Commit 4820d06

Browse files
feat(referrals): add Kilo Pass referrals (#3465)
1 parent c20472a commit 4820d06

55 files changed

Lines changed: 6437 additions & 320 deletions

File tree

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: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Updated 2026-05-06 -- require Impact Advocate reward redemption after local Kilo
1818
Updated 2026-05-12 -- note price-versioned KiloClaw billing preserves referral semantics.
1919
Updated 2026-05-22 -- renamed to `.specs/impact-referrals.md` and expanded to Kilo Pass referrals.
2020
Updated 2026-05-28 -- classify enforced Stripe EFW refunds as adverse payments.
21+
Updated 2026-05-29 -- name the Impact-facing Kilo Pass reward unit `Kilo Pass Bonus Credits`.
2122

2223
## Conventions
2324

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

147150
## Rules
148151

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

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

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

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

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

632646
159. Rewards from a qualifying Stripe payment MUST be treated as adverse when Stripe reports a chargeback or when
@@ -762,6 +776,10 @@ conversion, local referral rewards are authoritative and affiliate SALE reportin
762776

763777
## Changelog
764778

779+
### 2026-05-29 -- Name the Kilo Pass Impact reward unit
780+
781+
Kilo Pass reward synchronization sends the `Kilo Pass Bonus Credits` unit to Impact Advocate while retaining the USD-denominated local reward amount.
782+
765783
### 2026-05-28 -- Enforced EFW refunds are adverse payments
766784

767785
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.
@@ -773,6 +791,16 @@ conversions. The first positively paid settlement using a supported fingerprinta
773791
instrument opportunity; reused instruments retain ordinary monthly-ramp bonus behavior but do not receive the
774792
introductory promo or create Kilo Pass referral rewards. Annual behavior remains outside this restriction.
775793

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

778806
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+
}

apps/web/src/app/admin/components/KiloclawReferralsInvestigation.test.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,26 @@ function referralRow(params: {
1111
qualified: boolean;
1212
disqualificationReason: string | null;
1313
impactReportState: string;
14+
product?: 'kiloclaw' | 'kilo_pass';
15+
rewardStatus?: string;
1416
}) {
17+
const product = params.product ?? 'kiloclaw';
18+
const productLabel = product === 'kilo_pass' ? 'Kilo Pass' : 'KiloClaw';
19+
1520
return {
1621
referral: {
1722
id: params.referralId,
23+
product,
24+
productLabel,
1825
impactReferralId: 'RS-SUPPORT',
1926
createdAt: '2026-04-01T00:00:00.000Z',
2027
},
2128
referee: { id: `${params.referralId}-referee`, email: params.refereeEmail, name: null },
2229
sourceTouch: null,
2330
conversion: {
2431
id: `${params.referralId}-conversion`,
32+
product,
33+
paymentProvider: product === 'kilo_pass' ? 'stripe' : 'credits',
2534
winningTouchType: 'referral',
2635
sourcePaymentId: params.paymentId,
2736
qualified: params.qualified,
@@ -33,21 +42,52 @@ function referralRow(params: {
3342
id: `${params.referralId}-decision`,
3443
beneficiaryUserId: 'referrer-1',
3544
beneficiaryRole: 'referrer',
45+
product,
3646
outcome: params.qualified ? 'granted' : 'disqualified',
3747
reason: params.disqualificationReason,
38-
monthsGranted: params.qualified ? 1 : 0,
48+
rewardKind: product === 'kilo_pass' ? 'kilo_pass_bonus' : 'kiloclaw_free_month',
49+
monthsGranted: product === 'kiloclaw' && params.qualified ? 1 : 0,
50+
rewardPercent: product === 'kilo_pass' ? 0.5 : null,
51+
sourceTier: product === 'kilo_pass' ? 'tier_49' : null,
52+
rewardAmountUsd: product === 'kilo_pass' && params.qualified ? 24.5 : null,
3953
createdAt: '2026-04-10T00:00:00.000Z',
4054
},
4155
],
42-
rewards: [],
56+
rewards: params.qualified
57+
? [
58+
{
59+
id: `${params.referralId}-reward`,
60+
product,
61+
beneficiaryUserId: 'referrer-1',
62+
beneficiaryRole: 'referrer',
63+
rewardKind: product === 'kilo_pass' ? 'kilo_pass_bonus' : 'kiloclaw_free_month',
64+
status: params.rewardStatus ?? 'applied',
65+
monthsGranted: product === 'kiloclaw' ? 1 : 0,
66+
rewardPercent: product === 'kilo_pass' ? 0.5 : null,
67+
sourceTier: product === 'kilo_pass' ? 'tier_49' : null,
68+
rewardAmountUsd: product === 'kilo_pass' ? 24.5 : null,
69+
earnedAt: '2026-04-10T00:00:00.000Z',
70+
appliedAt: params.rewardStatus === 'pending' ? null : '2026-04-10T00:05:00.000Z',
71+
expiresAt: '2027-04-10T00:00:00.000Z',
72+
reviewReason:
73+
params.rewardStatus === 'review_required' ? 'referral_payment_chargeback' : null,
74+
appliesToKiloPassSubscriptionId: null,
75+
consumedKiloPassIssuanceId: null,
76+
consumedKiloPassIssuanceItemId: null,
77+
},
78+
]
79+
: [],
4380
rewardApplications: params.qualified
4481
? [
4582
{
4683
id: `${params.referralId}-application`,
4784
beneficiaryUserId: 'referrer-1',
4885
subscriptionId: '55555555-5555-4555-8555-555555555555',
4986
previousRenewalBoundary: '2026-05-01T12:00:00.000Z',
87+
product,
5088
newRenewalBoundary: '2026-06-01T12:00:00.000Z',
89+
localOperationId: null,
90+
stripeOperationId: null,
5191
appliedAt: '2026-04-10T00:05:00.000Z',
5292
},
5393
]
@@ -63,11 +103,32 @@ function referralRow(params: {
63103
responseStatusCode: params.impactReportState === 'failed' ? 400 : null,
64104
},
65105
],
106+
impactRewardRedemptions: [],
66107
};
67108
}
68109

69110
const result = {
111+
product: 'kiloclaw' as const,
112+
productLabel: 'KiloClaw',
70113
referrer: { id: 'referrer-1', email: 'referrer@example.com', name: 'Referrer' },
114+
participantRegistrations: [
115+
{
116+
id: '55555555-5555-4555-8555-555555555556',
117+
programKey: 'kiloclaw' as const,
118+
registrationState: 'pending',
119+
registeredAt: null,
120+
lastRegistrationAttemptAt: null,
121+
lastErrorCode: null,
122+
lastErrorMessage: null,
123+
latestAttempt: {
124+
id: '55555555-5555-4555-8555-555555555557',
125+
deliveryState: 'queued',
126+
responseStatusCode: null,
127+
nextRetryAt: '2026-04-11T00:00:00.000Z',
128+
createdAt: '2026-04-10T00:00:00.000Z',
129+
},
130+
},
131+
],
71132
referrals: [
72133
referralRow({
73134
referralId: 'qualified-referral',
@@ -95,10 +156,14 @@ describe('KiloclawReferralsInvestigationResults', () => {
95156
);
96157

97158
expect(html).toContain('referrer@example.com');
159+
expect(html).toContain('KiloClaw referrer');
160+
expect(html).toContain('kiloclaw: pending');
161+
expect(html).toContain('Latest attempt: queued');
98162
expect(html).toContain('Qualified');
99163
expect(html).toContain('Disqualified');
100164
expect(html).toContain('referral_self_referral');
101165
expect(html).toContain('granted');
166+
expect(html).toContain('applied, 1 month');
102167
expect(html).toContain('delivered, tracker 71659, order qualified-payment');
103168
expect(html).toContain('failed, tracker 71659, order disqualified-payment, HTTP 400');
104169
expect(html).toContain('May 1, 2026 to');

0 commit comments

Comments
 (0)