diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 8d0c8879..5c304604 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -113,6 +113,7 @@ import { invalidateKnowledgeIndex } from '@x/core/dist/knowledge/knowledge_index import { versionHistory, voice } from '@x/core'; import { classifySchedule, processRowboatInstruction } from '@x/core/dist/knowledge/inline_tasks.js'; import { getBillingInfo } from '@x/core/dist/billing/billing.js'; +import { claimReferralCode, getCreditsState, maybeActivateCredit, subscribeCreditActivations } from '@x/core/dist/billing/credits.js'; import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; @@ -662,6 +663,11 @@ export function emitOAuthEvent(event: { provider: string; success: boolean; erro // prompt, so any OAuth state change must rebuild it. invalidateCopilotInstructionsCache(); broadcastToWindows('oauth:didConnect', event); + // Google connect (both BYOK and rowboat-mode paths funnel through here) is + // the "connected Gmail" first-time reward. + if (event.provider === 'google' && event.success) { + void maybeActivateCredit('first_gmail_connected'); + } } async function requireCodeSession(sessionId: string): Promise { @@ -850,6 +856,10 @@ export function setupIpcHandlers() { // Forward knowledge commit events to renderer for panel refresh versionHistory.onCommit(() => emitKnowledgeCommitEvent()); + // Relay backend-confirmed credit grants (first-time-action rewards) to all + // windows so the UI can update balances and celebrate. + subscribeCreditActivations((event) => broadcastToWindows('credits:didActivate', event)); + // Pre-warm the Gmail contact indices so the first compose-box keystroke is instant. // - warmContactIndex(): synchronous local-snapshot fallback (instant, narrow coverage). // - warmSentContacts(): kicks off a background Gmail API sync of the SENT label @@ -992,7 +1002,11 @@ export function setupIpcHandlers() { return {}; }, 'gmail:sendReply': async (_event, args) => { - return sendThreadReply(args); + const result = await sendThreadReply(args); + if (!result.error) { + void maybeActivateCredit('first_email_sent'); + } + return result; }, 'gmail:saveDraft': async (_event, args) => { return saveThreadDraft(args); @@ -1819,6 +1833,7 @@ export function setupIpcHandlers() { 'apps:create': async (_event, args) => { const app = await appsIndexer.createApp(args); capture('app_created', { folder: app.folder }); + void maybeActivateCredit('first_app_built'); return { app }; }, 'apps:delete': async (_event, args) => { @@ -2192,6 +2207,9 @@ export function setupIpcHandlers() { }, 'meeting:summarize': async (_event, args) => { const notes = await summarizeMeeting(args.transcript, args.meetingStartTime, args.calendarEventJson); + if (notes && notes.trim()) { + void maybeActivateCredit('first_meeting_note'); + } return { notes }; }, 'meeting-prep:resolve': async (_event, args) => { @@ -2483,6 +2501,7 @@ export function setupIpcHandlers() { ...(args.model ? { model: args.model } : {}), ...(args.provider ? { provider: args.provider } : {}), }); + void maybeActivateCredit('first_bg_agent'); return { success: true, slug }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) }; @@ -2519,6 +2538,13 @@ export function setupIpcHandlers() { 'billing:getInfo': async () => { return await getBillingInfo(); }, + // First-time-action credit rewards + 'credits:getState': async () => { + return await getCreditsState(); + }, + 'referral:claim': async (_event, args) => { + return await claimReferralCode(args.code); + }, 'notifications:getSettings': async () => { return loadNotificationSettings(); }, diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 75050533..692a055a 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -81,6 +81,7 @@ import { Button } from "@/components/ui/button" import { Toaster } from "@/components/ui/sonner" import { UpdateCard } from "@/components/update-card" import { BillingErrorDialog } from "@/components/billing-error-dialog" +import { CreditCelebration } from "@/components/credit-celebration" import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error" import { dispatchCreditExhausted, dispatchCreditReplenished } from "@/lib/credit-status" import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' @@ -7213,6 +7214,7 @@ function App() { + { + const t = Math.sin(seed * 997 + i * 131) * 0.5 + 0.5 + const u = Math.sin(seed * 613 + i * 379) * 0.5 + 0.5 + const angle = (i / 28) * Math.PI * 2 + const distance = 90 + t * 140 + return { + id: i, + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance * 0.75 - 40 - u * 60, + rotate: (t - 0.5) * 720, + color: COLORS[i % COLORS.length], + size: 5 + u * 5, + round: i % 3 === 0, + delay: t * 0.12, + } + }) +} + +/** + * Full-window celebration shown when the backend confirms a first-time-action + * credit grant (`credits:didActivate`): a confetti burst plus a card naming + * the action and the amount earned. Mount once near the app root. + */ +export function CreditCelebration() { + const [event, setEvent] = useState(null) + const [burst, setBurst] = useState(0) + + useEffect(() => { + return window.ipc.on("credits:didActivate", (e) => { + setEvent(e) + setBurst((n) => n + 1) + }) + }, []) + + useEffect(() => { + if (!event) return + const timer = window.setTimeout(() => setEvent(null), 4500) + return () => window.clearTimeout(timer) + }, [event]) + + const pieces = useMemo(() => confettiPieces(burst), [burst]) + + return ( + + {event && ( +
+ {/* confetti burst */} +
+ {pieces.map((p) => ( + + ))} +
+ + {/* reward card */} + + + + +
+

+ {formatCreditsAsDollars(event.credits)} in credits earned! +

+

{event.title}

+
+
+
+ )} +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/invite-code-claim.tsx b/apps/x/apps/renderer/src/components/invite-code-claim.tsx new file mode 100644 index 00000000..93c3d815 --- /dev/null +++ b/apps/x/apps/renderer/src/components/invite-code-claim.tsx @@ -0,0 +1,90 @@ +"use client" + +import { useState } from "react" +import { CheckCircle2, Loader2, Ticket } from "lucide-react" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { useCreditsState } from "@/hooks/use-credits-state" +import { formatCreditsAsDollars } from "@x/shared/dist/credits.js" + +/** + * "Have an invite code?" entry: redeem another user's referral code — both + * sides earn credits (the backend enforces one lifetime claim per account, + * new accounts only). Self-gating: renders nothing unless the credit-rewards + * feature is on, the user is eligible (signed-in, free tier), and this + * account hasn't already redeemed a code. Shown in onboarding and in + * settings → Account. + */ +export function InviteCodeClaim() { + const state = useCreditsState() + const [code, setCode] = useState("") + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const [granted, setGranted] = useState(null) + + // referral state doubles as the claimed-by-me source; without it we can't + // tell whether the entry still applies, so stay hidden + if (!state || !state.enabled || !state.eligible || !state.referral) return null + if (state.referral.claimedByMe && granted === null) return null + + const submit = async () => { + if (!code.trim() || submitting) return + setSubmitting(true) + setError(null) + try { + const result = await window.ipc.invoke("referral:claim", { code }) + if (result.ok) { + setGranted(result.creditsGranted) + } else { + setError(result.message) + } + } catch (err) { + console.error("Failed to claim invite code:", err) + setError("Could not apply the invite code. Please try again.") + } finally { + setSubmitting(false) + } + } + + if (granted !== null) { + return ( +
+ +

+ Invite code applied — {formatCreditsAsDollars(granted)} in credits added to your account. +

+
+ ) + } + + return ( +
+
+ +

Have an invite code?

+
+
+ { + setCode(e.target.value) + setError(null) + }} + onKeyDown={(e) => { + if (e.key === "Enter") submit() + }} + placeholder="ABC-DEF-GHJ" + className="h-8 font-mono text-sm uppercase" + maxLength={16} + /> + +
+

+ Enter a friend's code and you both earn {formatCreditsAsDollars(state.referral.refereeCredits)} in credits. +

+ {error &&

{error}

} +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index fea6c6b3..bc9ff541 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -23,6 +23,7 @@ import { } from "@/components/ui/select" import { cn } from "@/lib/utils" import { GoogleClientIdModal } from "@/components/google-client-id-modal" +import { InviteCodeClaim } from "@/components/invite-code-claim" import { setGoogleCredentials } from "@/lib/google-credentials-store" import { toast } from "sonner" import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" @@ -1414,6 +1415,11 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { )} + {/* invite-code redemption (self-gating: signed-in, free-tier, unclaimed) */} +
+ +
+ diff --git a/apps/x/apps/renderer/src/components/settings/account-settings.tsx b/apps/x/apps/renderer/src/components/settings/account-settings.tsx index b24eb38a..9833d50b 100644 --- a/apps/x/apps/renderer/src/components/settings/account-settings.tsx +++ b/apps/x/apps/renderer/src/components/settings/account-settings.tsx @@ -16,6 +16,7 @@ import { } from "@/components/ui/alert-dialog" import { Separator } from "@/components/ui/separator" import { useBilling } from "@/hooks/useBilling" +import { CreditRewards } from "@/components/settings/credit-rewards" import { toast } from "sonner" import { getBillingPlanData, type BillingUsageBucket } from "@x/shared/dist/billing.js" @@ -56,7 +57,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) { const [disconnecting, setDisconnecting] = useState(false) const [connecting, setConnecting] = useState(false) const [appUrl, setAppUrl] = useState(null) - const { billing, isLoading: billingLoading } = useBilling(isRowboatConnected) + const { billing, isLoading: billingLoading, refresh: refreshBilling } = useBilling(isRowboatConnected) const currentPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null const hasPaidSubscription = currentPlan?.category === 'starter' || currentPlan?.category === 'pro' @@ -100,6 +101,14 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) { return cleanup }, []) + // A confirmed reward grant changes the bonus-credit balance; refetch so the + // Earn-credits section shows the updated number while the dialog is open. + useEffect(() => { + return window.ipc.on('credits:didActivate', () => { + refreshBilling() + }) + }, [refreshBilling]) + const handleConnect = useCallback(async () => { try { setConnecting(true) @@ -229,6 +238,11 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) { + {/* Earn Credits Section */} + + + + {/* Payment Section */}
diff --git a/apps/x/apps/renderer/src/components/settings/credit-rewards.tsx b/apps/x/apps/renderer/src/components/settings/credit-rewards.tsx new file mode 100644 index 00000000..3dd654a0 --- /dev/null +++ b/apps/x/apps/renderer/src/components/settings/credit-rewards.tsx @@ -0,0 +1,166 @@ +"use client" + +import { useState } from "react" +import { Check, Copy, Gift, UserPlus } from "lucide-react" +import { cn } from "@/lib/utils" +import { CREDIT_ACTIVITY_ICONS, useCreditsState } from "@/hooks/use-credits-state" +import { InviteCodeClaim } from "@/components/invite-code-claim" +import { formatCreditsAsDollars } from "@x/shared/dist/credits.js" +import type { BillingStoreBucket } from "@x/shared/dist/billing.js" + +interface CreditRewardsProps { + // bonus-credit balance from billing info; null while billing is loading + store: BillingStoreBucket | null +} + +/** + * "Earn credits" settings section: the first-time actions that grant bonus + * credits, which are done, and the current bonus balance. Claimed state + * refreshes live when a `credits:didActivate` event arrives (the parent + * refreshes the balance itself). + */ +export function CreditRewards({ store }: CreditRewardsProps) { + const state = useCreditsState() + const [copied, setCopied] = useState(false) + + // Hidden while loading, when the feature flag is off, when not eligible + // (rewards are for signed-in free-tier users — not BYOK, not paid plans), + // or when there is nothing to show (no reward catalog and no referral). + if (!state || !state.enabled || !state.eligible) return null + if (state.activities.length === 0 && !state.referral) return null + + const referral = state.referral + const inviteSlotsLeft = referral ? Math.max(0, referral.maxClaims - referral.claimsUsed) : 0 + // invites count as one list item, earned once every claim slot is used + const totalCount = state.activities.length + (referral ? 1 : 0) + const earnedCount = + state.activities.filter((a) => a.claimed).length + (referral && inviteSlotsLeft === 0 ? 1 : 0) + + const copyInviteCode = async () => { + if (!referral) return + try { + await navigator.clipboard.writeText(referral.code) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch (error) { + console.error("Failed to copy invite code:", error) + } + } + + return ( +
+
+ +

Earn credits

+
+

+ Try these for the first time and we'll add bonus credits to your account. +

+
+
+

+ {earnedCount} of {totalCount} earned +

+ {store && ( +

+ {formatCreditsAsDollars(store.availableCredits)} bonus credits available +

+ )} +
+
+ {state.activities.map((activity) => { + const Icon = CREDIT_ACTIVITY_ICONS[activity.code] ?? Gift + return ( +
+
+ {activity.claimed ? ( + + ) : ( + + )} +
+
+

+ {activity.title} +

+ {activity.description && ( +

{activity.description}

+ )} +
+ + {activity.claimed ? "Earned" : `+${formatCreditsAsDollars(activity.credits)}`} + +
+ ) + })} + {referral && ( +
+
+ {inviteSlotsLeft === 0 ? ( + + ) : ( + + )} +
+
+

+ Invite friends +

+

+ {inviteSlotsLeft === 0 + ? `All ${referral.maxClaims} invites used` + : `You each get ${formatCreditsAsDollars(referral.referrerCredits)} when a friend signs up with your code · ${referral.claimsUsed} of ${referral.maxClaims} joined`} +

+ {inviteSlotsLeft > 0 && ( +
+ + {referral.code} + + +
+ )} +
+ + {inviteSlotsLeft === 0 + ? "Earned" + : `+${formatCreditsAsDollars(referral.referrerCredits)} each`} + +
+ )} +
+
+ +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 8dfd79de..c873b1bc 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -83,6 +83,7 @@ import { cn } from "@/lib/utils" import { getPinnedApps, onPinnedAppsChanged, unpinApp } from "@/lib/pinned-apps" import { isOutOfCredits, CREDIT_EXHAUSTED_EVENT, CREDIT_REPLENISHED_EVENT } from "@/lib/credit-status" import { SettingsDialog } from "@/components/settings-dialog" +import { SidebarCreditRewards } from "@/components/sidebar-credit-rewards" import { MascotFaceIcon } from "@/components/talking-head" import { extractConferenceLink } from "@/lib/calendar-event" import { useBilling } from "@/hooks/useBilling" @@ -1191,6 +1192,14 @@ export function SidebarContentPanel({ + {/* First-time-action credit rewards (feature-flagged, signed-in only) */} + setConnectionsSettingsOpen(true)} + /> {/* Billing / upgrade CTA or Log in CTA */} {isRowboatConnected && billing ? (() => { const upgradeLabel = !billing.subscriptionPlanId || currentBillingPlan?.category === 'free' || currentBillingPlan?.category === 'starter' ? 'Upgrade' : 'Manage' diff --git a/apps/x/apps/renderer/src/components/sidebar-credit-rewards.tsx b/apps/x/apps/renderer/src/components/sidebar-credit-rewards.tsx new file mode 100644 index 00000000..db5d8ec9 --- /dev/null +++ b/apps/x/apps/renderer/src/components/sidebar-credit-rewards.tsx @@ -0,0 +1,234 @@ +"use client" + +import { useCallback, useState } from "react" +import { Check, ChevronRight, Copy, Gift, UserPlus, X } from "lucide-react" +import { cn } from "@/lib/utils" +import { Popover, PopoverArrow, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { CREDIT_ACTIVITY_ICONS, useCreditsState } from "@/hooks/use-credits-state" +import { formatCreditsAsDollars, type CreditActivityCode } from "@x/shared/dist/credits.js" + +const DISMISSED_KEY = "rowboat.credit-rewards-card-dismissed" + +interface SidebarCreditRewardsProps { + onOpenEmail?: () => void + onOpenMeetings?: () => void + onOpenAgents?: () => void + onOpenApps?: () => void + onConnectAccounts: () => void +} + +/** + * Persistent sidebar entry for first-time-action credit rewards: a compact + * "Earn $X in credits" pill above the plan card that opens the checklist in + * a popover. Rows navigate to where the action happens. Shown only when the + * credit-rewards feature flag is on and the user is an eligible (signed-in, + * free-tier) account; gone for good once every reward is earned or the user + * dismisses it from the popover. + */ +export function SidebarCreditRewards({ + onOpenEmail, + onOpenMeetings, + onOpenAgents, + onOpenApps, + onConnectAccounts, +}: SidebarCreditRewardsProps) { + const state = useCreditsState() + const [dismissed, setDismissed] = useState(() => localStorage.getItem(DISMISSED_KEY) === "1") + const [open, setOpen] = useState(false) + const [copied, setCopied] = useState(false) + + const handleDismiss = useCallback(() => { + localStorage.setItem(DISMISSED_KEY, "1") + setDismissed(true) + setOpen(false) + }, []) + + if (dismissed || !state || !state.enabled || !state.eligible) return null + + const referral = state.referral + const inviteSlotsLeft = referral ? Math.max(0, referral.maxClaims - referral.claimsUsed) : 0 + const remaining = state.activities.filter((a) => !a.claimed) + if (remaining.length === 0 && inviteSlotsLeft === 0) return null + + // invites count as one checklist item, earned once every claim slot is used + const totalCount = state.activities.length + (referral ? 1 : 0) + const earnedCount = + state.activities.length - remaining.length + (referral && inviteSlotsLeft === 0 ? 1 : 0) + const remainingTotal = + remaining.reduce((sum, a) => sum + a.credits, 0) + + (referral ? inviteSlotsLeft * referral.referrerCredits : 0) + + const copyInviteCode = async () => { + if (!referral) return + try { + await navigator.clipboard.writeText(referral.code) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch (error) { + console.error("Failed to copy invite code:", error) + } + } + + const actionFor = (code: CreditActivityCode): (() => void) | undefined => { + switch (code) { + case "first_gmail_connected": return onConnectAccounts + case "first_email_sent": return onOpenEmail + case "first_meeting_note": return onOpenMeetings + case "first_bg_agent": return onOpenAgents + case "first_app_built": return onOpenApps + } + } + + return ( +
+ + + + + + +
+
+ + + +
+

Earn {formatCreditsAsDollars(remainingTotal)} in credits

+

+ Try these firsts and we'll credit your account +

+
+
+ +
+
+ {state.activities.map((activity) => { + const Icon = CREDIT_ACTIVITY_ICONS[activity.code] ?? Gift + const action = actionFor(activity.code) + return ( + + ) + })} +
+ {referral && ( +
+
+
+ {inviteSlotsLeft === 0 ? ( + + ) : ( + + )} +
+
+ Invite friends + + {referral.claimsUsed} of {referral.maxClaims} joined + +
+ + {inviteSlotsLeft === 0 + ? "Earned" + : `+${formatCreditsAsDollars(referral.referrerCredits)} each`} + +
+ {inviteSlotsLeft > 0 && ( +
+ + {referral.code} + + +
+ )} + {inviteSlotsLeft > 0 && ( +

+ Share your code — you each get {formatCreditsAsDollars(referral.referrerCredits)} when a + friend signs up and enters it. +

+ )} +
+ )} +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/hooks/use-credits-state.ts b/apps/x/apps/renderer/src/hooks/use-credits-state.ts new file mode 100644 index 00000000..dce05f8b --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/use-credits-state.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useState } from 'react' +import type { ElementType } from 'react' +import { AtSign, Bot, LayoutGrid, NotebookPen, Send } from 'lucide-react' +import type { CreditActivityCode, CreditsState } from '@x/shared/dist/credits.js' + +export const CREDIT_ACTIVITY_ICONS: Record = { + first_gmail_connected: AtSign, + first_email_sent: Send, + first_meeting_note: NotebookPen, + first_bg_agent: Bot, + first_app_built: LayoutGrid, +} + +/** + * Credit-rewards state shared by every rewards surface (sidebar pill, + * settings section). Fetches once on mount and refreshes when a grant is + * confirmed (`credits:didActivate`) or when a connect event could change + * eligibility/claimed state — rowboat sign-in/out and Google connects; other + * providers can't affect rewards, so their events are ignored. + */ +export function useCreditsState() { + const [state, setState] = useState(null) + + const refresh = useCallback(async () => { + try { + setState(await window.ipc.invoke('credits:getState', null)) + } catch (error) { + console.error('Failed to fetch credit rewards state:', error) + } + }, []) + + useEffect(() => { + refresh() + const offActivated = window.ipc.on('credits:didActivate', () => { + refresh() + }) + const offOAuth = window.ipc.on('oauth:didConnect', (event) => { + if (event.provider === 'rowboat' || event.provider === 'google') { + refresh() + } + }) + return () => { + offActivated() + offOAuth() + } + }, [refresh]) + + return state +} diff --git a/apps/x/packages/core/src/analytics/posthog.ts b/apps/x/packages/core/src/analytics/posthog.ts index 326049d6..81e24eb8 100644 --- a/apps/x/packages/core/src/analytics/posthog.ts +++ b/apps/x/packages/core/src/analytics/posthog.ts @@ -91,6 +91,24 @@ export function reset(): void { identifiedUserId = null; } +/** + * Evaluate a PostHog feature flag for the current identity (rowboat user id + * once identified, installation id before that). `defaultValue` is returned + * when the flag can't be definitively evaluated — analytics disabled, flags + * API unreachable, or the flag doesn't exist in the PostHog project. + */ +export async function isFeatureEnabled(key: string, defaultValue = false): Promise { + const ph = getClient(); + if (!ph) return defaultValue; + try { + const result = await ph.isFeatureEnabled(key, activeDistinctId()); + return result === undefined ? defaultValue : result; + } catch (err) { + console.error(`[Analytics] feature flag ${key} check failed:`, err); + return defaultValue; + } +} + export async function shutdown(): Promise { if (!client) return; try { diff --git a/apps/x/packages/core/src/auth/jwt.ts b/apps/x/packages/core/src/auth/jwt.ts new file mode 100644 index 00000000..0ac76ecb --- /dev/null +++ b/apps/x/packages/core/src/auth/jwt.ts @@ -0,0 +1,18 @@ +/** + * Decode a JWT's payload segment without verifying the signature — for + * reading claims out of tokens we already trust (or only use as local cache + * keys), never for authentication decisions. + */ +export function decodeJwtPayload(token: string): Record | null { + try { + const parts = token.split('.'); + if (parts.length < 2) return null; + const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4)); + const json = Buffer.from(padded + pad, 'base64').toString('utf-8'); + const parsed = JSON.parse(json); + return typeof parsed === 'object' && parsed !== null ? parsed as Record : null; + } catch { + return null; + } +} diff --git a/apps/x/packages/core/src/billing/billing.ts b/apps/x/packages/core/src/billing/billing.ts index 1b421acf..7a7fb679 100644 --- a/apps/x/packages/core/src/billing/billing.ts +++ b/apps/x/packages/core/src/billing/billing.ts @@ -33,6 +33,10 @@ export async function getBillingInfo(): Promise { availableCredits: number; usageDay: string; }; + // credit-store bucket; absent on API deployments that predate grants + store?: { + availableCredits: number; + }; }; }; }; @@ -45,5 +49,8 @@ export async function getBillingInfo(): Promise { catalog: config.billing, monthly: body.billing.usage.monthly, daily: body.billing.usage.daily, + store: { + availableCredits: body.billing.usage.store?.availableCredits ?? 0, + }, }; } diff --git a/apps/x/packages/core/src/billing/credits.test.ts b/apps/x/packages/core/src/billing/credits.test.ts new file mode 100644 index 00000000..cb44a4c3 --- /dev/null +++ b/apps/x/packages/core/src/billing/credits.test.ts @@ -0,0 +1,284 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// A JWT whose payload decodes to { sub: "user-1" } (signature irrelevant — +// the service only reads the sub claim as a local cache key). +function fakeJwt(sub: string): string { + const payload = Buffer.from(JSON.stringify({ sub }), "utf-8").toString("base64url"); + return `header.${payload}.sig`; +} + +let tmpDir: string; +let fetchMock: ReturnType; + +const CATALOG = { + plans: [ + { id: "free-1", category: "free", displayName: "Free", monthlyCredits: 0, dailyCredits: 0, monthlyPriceCents: null }, + { id: "pro-1", category: "pro", displayName: "Pro", monthlyCredits: 0, dailyCredits: 0, monthlyPriceCents: 2000 }, + ], +}; + +// the activation catalog as served by GET /v1/config — display metadata is +// backend-owned; the app only knows the codes +const ACTIVATIONS = [ + { code: "first_gmail_connected", displayName: "Connected Gmail", description: "Link your Google account", credits: 100 }, + { code: "first_email_sent", displayName: "First email sent", credits: 100 }, + { code: "first_bg_agent", displayName: "First background agent", credits: 100 }, + { code: "first_app_built", displayName: "First app built", credits: 100 }, + { code: "some_future_code", displayName: "Unknown to this app version", credits: 100 }, +]; + +function mockBilling(planId: string | null) { + vi.doMock("./billing.js", () => ({ + getBillingInfo: vi.fn(async () => ({ subscriptionPlanId: planId, catalog: CATALOG })), + })); +} + +// pass null to omit the creditActivations field entirely (pre-catalog API) +function mockConfig(creditActivations: unknown[] | null = ACTIVATIONS) { + vi.doMock("../config/rowboat.js", () => ({ + getRowboatConfig: vi.fn(async () => ({ + appUrl: "https://app.example", + websocketApiUrl: "", + supabaseUrl: "", + billing: CATALOG, + ...(creditActivations ? { creditActivations } : {}), + })), + })); +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rowboat-credits-test-")); + process.env.ROWBOAT_WORKDIR = tmpDir; + // enable the feature flag via the dev override (no PostHog in tests) + process.env.ROWBOAT_CREDITS = "1"; + vi.resetModules(); + vi.doMock("../account/account.js", () => ({ + isSignedIn: vi.fn(async () => true), + })); + vi.doMock("../auth/tokens.js", () => ({ + getAccessToken: vi.fn(async () => fakeJwt("user-1")), + })); + mockBilling("free-1"); + mockConfig(); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + delete process.env.ROWBOAT_WORKDIR; + delete process.env.ROWBOAT_CREDITS; + vi.doUnmock("../account/account.js"); + vi.doUnmock("../auth/tokens.js"); + vi.doUnmock("./billing.js"); + vi.doUnmock("../config/rowboat.js"); + vi.unstubAllGlobals(); + vi.resetModules(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +async function loadCredits() { + return import("./credits.js"); +} + +function grantResponse(amount: number) { + return new Response(JSON.stringify({ grant: { amount } }), { status: 200 }); +} + +describe("maybeActivateCredit", () => { + it("activates once, notifies subscribers, and skips repeat attempts locally", async () => { + const credits = await loadCredits(); + const seen: unknown[] = []; + credits.subscribeCreditActivations((event) => seen.push(event)); + fetchMock.mockResolvedValueOnce(grantResponse(100)); + + const first = await credits.maybeActivateCredit("first_email_sent"); + // display name comes from the API catalog, not app code + expect(first).toMatchObject({ code: "first_email_sent", title: "First email sent", credits: 100 }); + expect(seen).toHaveLength(1); + + // second attempt short-circuits on the local claim — no network call + const second = await credits.maybeActivateCredit("first_email_sent"); + expect(second).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const state = await credits.getCreditsState(); + expect(state.activities.find((a) => a.code === "first_email_sent")?.claimed).toBe(true); + expect(state.activities.find((a) => a.code === "first_bg_agent")?.claimed).toBe(false); + // catalog codes this app version doesn't know are dropped + expect(state.activities.map((a) => a.code)).not.toContain("some_future_code"); + }); + + it("shows no activities when the API serves no reward catalog", async () => { + mockConfig(null); + const credits = await loadCredits(); + const state = await credits.getCreditsState(); + expect(state.eligible).toBe(true); + expect(state.activities).toEqual([]); + }); + + it("treats 409 as already claimed without notifying", async () => { + const credits = await loadCredits(); + const seen: unknown[] = []; + credits.subscribeCreditActivations((event) => seen.push(event)); + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ error: "Already activated" }), { status: 409 })); + + expect(await credits.maybeActivateCredit("first_gmail_connected")).toBeNull(); + expect(seen).toHaveLength(0); + const state = await credits.getCreditsState(); + expect(state.activities.find((a) => a.code === "first_gmail_connected")?.claimed).toBe(true); + }); + + it("leaves the code unclaimed on 404/network failure so the next action retries", async () => { + const credits = await loadCredits(); + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ error: "Unknown activation code" }), { status: 404 })); + expect(await credits.maybeActivateCredit("first_app_built")).toBeNull(); + + fetchMock.mockRejectedValueOnce(new Error("offline")); + expect(await credits.maybeActivateCredit("first_app_built")).toBeNull(); + + // both failures left it retryable — a later attempt succeeds + fetchMock.mockResolvedValueOnce(grantResponse(100)); + expect(await credits.maybeActivateCredit("first_app_built")).toMatchObject({ credits: 100 }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("is enabled by default when no flag source is available", async () => { + // no env override, no PostHog client in tests -> defaults on + delete process.env.ROWBOAT_CREDITS; + const credits = await loadCredits(); + const state = await credits.getCreditsState(); + expect(state.enabled).toBe(true); + }); + + it("does nothing when the feature flag is off", async () => { + process.env.ROWBOAT_CREDITS = "0"; + const credits = await loadCredits(); + expect(await credits.maybeActivateCredit("first_email_sent")).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + + const state = await credits.getCreditsState(); + expect(state.enabled).toBe(false); + }); + + it("does nothing for paid plans — free tier only", async () => { + mockBilling("pro-1"); + const credits = await loadCredits(); + expect(await credits.maybeActivateCredit("first_email_sent")).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + + const state = await credits.getCreditsState(); + expect(state.eligible).toBe(false); + }); + + it("does nothing when signed out", async () => { + vi.doMock("../account/account.js", () => ({ + isSignedIn: vi.fn(async () => false), + })); + const credits = await loadCredits(); + expect(await credits.maybeActivateCredit("first_email_sent")).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + + const state = await credits.getCreditsState(); + expect(state.eligible).toBe(false); + expect(state.activities.every((a) => !a.claimed)).toBe(true); + }); + + it("scopes claims to the signed-in user", async () => { + const credits = await loadCredits(); + fetchMock.mockResolvedValueOnce(grantResponse(100)); + await credits.maybeActivateCredit("first_email_sent"); + + // switch accounts: same install, different sub + vi.doMock("../auth/tokens.js", () => ({ + getAccessToken: vi.fn(async () => fakeJwt("user-2")), + })); + vi.resetModules(); + const creditsUser2 = await loadCredits(); + const state = await creditsUser2.getCreditsState(); + expect(state.activities.find((a) => a.code === "first_email_sent")?.claimed).toBe(false); + }); +}); + +const REFERRAL_STATUS = { + code: "ABC-DEF-GHJ", + claimsUsed: 1, + maxClaims: 3, + referrerCredits: 500, + refereeCredits: 500, +}; + +// route fetches by URL: GET /v1/referral -> status, POST /v1/referral/claims -> claim +function mockReferralApi(claim: () => Response, status: () => Response = () => new Response(JSON.stringify(REFERRAL_STATUS), { status: 200 })) { + fetchMock.mockImplementation(async (url: string, init?: RequestInit) => { + if (String(url).endsWith("/v1/referral")) return status(); + if (String(url).endsWith("/v1/referral/claims") && init?.method === "POST") return claim(); + throw new Error(`unexpected fetch: ${url}`); + }); +} + +describe("referrals", () => { + it("includes the referral status in state", async () => { + mockReferralApi(() => new Response(null, { status: 500 })); + const credits = await loadCredits(); + const state = await credits.getCreditsState(); + expect(state.referral).toEqual({ ...REFERRAL_STATUS, claimedByMe: false }); + }); + + it("omits referral when the status fetch fails", async () => { + mockReferralApi( + () => new Response(null, { status: 500 }), + () => new Response(JSON.stringify({ error: "boom" }), { status: 500 }), + ); + const credits = await loadCredits(); + const state = await credits.getCreditsState(); + expect(state.eligible).toBe(true); + expect(state.referral).toBeUndefined(); + }); + + it("claims an invite code once, notifies, and marks the account", async () => { + mockReferralApi(() => new Response(JSON.stringify({ creditsGranted: 500 }), { status: 200 })); + const credits = await loadCredits(); + const seen: unknown[] = []; + credits.subscribeCreditActivations((event) => seen.push(event)); + + const result = await credits.claimReferralCode(" abc-def-ghj "); + expect(result).toEqual({ ok: true, creditsGranted: 500 }); + expect(seen).toEqual([{ code: "referral_claimed", title: "Invite code applied", credits: 500 }]); + + const state = await credits.getCreditsState(); + expect(state.referral?.claimedByMe).toBe(true); + + // a second attempt is refused locally, without a network call + const fetchCalls = fetchMock.mock.calls.length; + const second = await credits.claimReferralCode("XYZ-XYZ-XYZ"); + expect(second.ok).toBe(false); + expect(fetchMock.mock.calls.length).toBe(fetchCalls); + }); + + it("surfaces backend claim errors and stays retryable", async () => { + mockReferralApi(() => new Response(JSON.stringify({ error: "Unknown referral code" }), { status: 404 })); + const credits = await loadCredits(); + + const bad = await credits.claimReferralCode("BAD-BAD-BAD"); + expect(bad).toEqual({ ok: false, message: "Unknown referral code" }); + + // rejected claims don't mark the account — a valid code still works + mockReferralApi(() => new Response(JSON.stringify({ creditsGranted: 500 }), { status: 200 })); + expect((await credits.claimReferralCode("ABC-DEF-GHJ")).ok).toBe(true); + }); + + it("marks the account claimed on a definitive already-claimed answer", async () => { + mockReferralApi(() => + new Response(JSON.stringify({ error: "This account has already claimed a referral code" }), { status: 409 }), + ); + const credits = await loadCredits(); + + const result = await credits.claimReferralCode("ABC-DEF-GHJ"); + expect(result.ok).toBe(false); + const state = await credits.getCreditsState(); + expect(state.referral?.claimedByMe).toBe(true); + }); +}); diff --git a/apps/x/packages/core/src/billing/credits.ts b/apps/x/packages/core/src/billing/credits.ts new file mode 100644 index 00000000..725a3122 --- /dev/null +++ b/apps/x/packages/core/src/billing/credits.ts @@ -0,0 +1,380 @@ +import fs from 'fs'; +import path from 'path'; +import { WorkDir } from '../config/config.js'; +import { getAccessToken } from '../auth/tokens.js'; +import { decodeJwtPayload } from '../auth/jwt.js'; +import { isSignedIn } from '../account/account.js'; +import { isFeatureEnabled } from '../analytics/posthog.js'; +import { API_URL } from '../config/env.js'; +import { getBillingInfo } from './billing.js'; +import { getRowboatConfig } from '../config/rowboat.js'; +import { getBillingPlanData } from '@x/shared/dist/billing.js'; +import { + CreditActivityCodeSchema, + type CreditActivationCatalogEntry, + type CreditActivityCode, + type CreditsState, + type ReferralClaimResult, + type ReferralState, +} from '@x/shared/dist/credits.js'; + +// The reward catalog (names, descriptions, amounts) is owned by the backend +// and served via GET /v1/config `creditActivations` — the app hardcodes only +// the activity codes it knows how to trigger. Entries with codes this app +// version doesn't recognize are dropped (nothing here can fire them). +type KnownCatalogEntry = CreditActivationCatalogEntry & { code: CreditActivityCode }; + +async function getActivityCatalog(): Promise { + const config = await getRowboatConfig(); + return (config.creditActivations ?? []).filter( + (entry): entry is KnownCatalogEntry => CreditActivityCodeSchema.safeParse(entry.code).success, + ); +} + +const CLAIMED_FILE = path.join(WorkDir, 'config', 'credit_activations.json'); + +const CREDITS_FLAG_KEY = 'credit-rewards'; +const FLAG_CACHE_TTL_MS = 5 * 60 * 1000; + +let flagCache: { value: boolean; fetchedAt: number } | null = null; + +/** + * Whether the credit-rewards feature is on for this user. Defaults ON; the + * PostHog `credit-rewards` flag is a remote kill switch / rollout control — + * create it and set release conditions to turn the feature off (or ramp it) + * for non-matching users. Evaluated against the identified rowboat user and + * cached briefly so reward triggers don't add a network hop. ROWBOAT_CREDITS + * env var (1/0) overrides everything for development. + */ +export async function isCreditsEnabled(): Promise { + const override = process.env.ROWBOAT_CREDITS; + if (override === '1' || override === 'true') return true; + if (override === '0' || override === 'false') return false; + + if (flagCache && Date.now() - flagCache.fetchedAt < FLAG_CACHE_TTL_MS) { + return flagCache.value; + } + const value = await isFeatureEnabled(CREDITS_FLAG_KEY, true); + flagCache = { value, fetchedAt: Date.now() }; + return value; +} + +const PLAN_CACHE_TTL_MS = 60 * 1000; + +let planCache: { userId: string; paid: boolean; fetchedAt: number } | null = null; + +// Rewards target free-tier users; paid (starter/pro, including trials of +// those plans) neither see the UI nor receive grants. Cached briefly since +// UI refreshes can arrive in bursts and /v1/me isn't free; keyed by user so +// an account switch on the same install can't reuse the previous account's +// answer. Throws on network failure — callers treat that as "not eligible +// right now" and retry later. +async function hasPaidPlan(userId: string): Promise { + if (planCache && planCache.userId === userId && Date.now() - planCache.fetchedAt < PLAN_CACHE_TTL_MS) { + return planCache.paid; + } + const billing = await getBillingInfo(); + const category = getBillingPlanData(billing.catalog, billing.subscriptionPlanId)?.category; + const paid = category === 'starter' || category === 'pro'; + planCache = { userId, paid, fetchedAt: Date.now() }; + return paid; +} + +// Claimed-state cache, keyed by Rowboat user id so switching accounts on the +// same install doesn't hide unclaimed rewards. This is only a cache: the +// backend enforces once-per-customer via the grants table, and a lost file +// merely means the next occurrence of the action re-attempts activation and +// gets a 409 (which re-marks it claimed here). Besides activity codes, the +// store holds the pseudo-code below marking that this account has redeemed +// someone's invite code (the backend allows one lifetime claim per account). +const REFERRAL_CLAIMED_KEY = 'referral_claimed'; + +interface ClaimedStore { + [userId: string]: { + [code: string]: { claimedAt: string }; + }; +} + +function readClaimedStore(): ClaimedStore { + try { + if (fs.existsSync(CLAIMED_FILE)) { + return JSON.parse(fs.readFileSync(CLAIMED_FILE, 'utf-8')) as ClaimedStore; + } + } catch (err) { + console.warn('[Credits] Failed to read credit_activations.json:', err); + } + return {}; +} + +function markClaimed(userId: string, code: CreditActivityCode | typeof REFERRAL_CLAIMED_KEY): void { + try { + const store = readClaimedStore(); + store[userId] = store[userId] ?? {}; + if (store[userId][code]) return; + store[userId][code] = { claimedAt: new Date().toISOString() }; + const dir = path.dirname(CLAIMED_FILE); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(CLAIMED_FILE, JSON.stringify(store, null, 2)); + } catch (err) { + console.warn('[Credits] Failed to persist claimed state:', err); + } +} + +/** + * Extract the Supabase user id (`sub` claim) from the Rowboat access token + * without verification — we only use it as a local cache key. + */ +function userIdFromToken(accessToken: string): string | null { + const sub = decodeJwtPayload(accessToken)?.sub; + return typeof sub === 'string' ? sub : null; +} + +export interface CreditActivationSuccess { + code: CreditActivityCode | typeof REFERRAL_CLAIMED_KEY; + title: string; + // granted amount as confirmed by the backend + credits: number; +} + +type CreditActivationListener = (event: CreditActivationSuccess) => void; +const activationListeners = new Set(); + +/** + * Be notified whenever the backend confirms a new credit grant, regardless of + * which call site triggered it. Main subscribes once and relays the event to + * all renderer windows (`credits:didActivate`). + */ +export function subscribeCreditActivations(listener: CreditActivationListener): () => void { + activationListeners.add(listener); + return () => activationListeners.delete(listener); +} + +function notifyActivation(event: CreditActivationSuccess): void { + for (const listener of activationListeners) { + try { + listener(event); + } catch (err) { + console.warn('[Credits] Activation listener failed:', err); + } + } +} + +/** + * Activate a first-time-action reward with the backend, at most once per user. + * + * Returns the granted amount when the backend confirms a new grant, and null + * in every other case (not signed in, already claimed, unknown code, network + * failure). Never throws — reward activation must not break the action that + * triggered it. Failures other than "already claimed" leave the local state + * untouched, so the next occurrence of the action retries; the backend's + * uniqueness guarantee makes retries safe. + */ +export async function maybeActivateCredit(code: CreditActivityCode): Promise { + try { + if (!(await isSignedIn())) return null; + const accessToken = await getAccessToken(); + const userId = userIdFromToken(accessToken); + if (!userId) return null; + + // decisive local short-circuit first: after the one-time claim, repeat + // actions (every email send, agent create, …) must not pay the feature + // flag or /v1/me lookups below + if (readClaimedStore()[userId]?.[code]) return null; + + if (!(await isCreditsEnabled())) return null; + if (await hasPaidPlan(userId)) return null; + + const response = await fetch(`${API_URL}/v1/billing/credit-activations`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code }), + }); + + if (response.status === 409) { + // already granted on the backend (e.g. fresh install, second device) + markClaimed(userId, code); + return null; + } + if (!response.ok) { + // 404 = code not in the backend catalog yet; anything else is transient. + // Either way, don't mark claimed — retry on the next occurrence. + console.warn(`[Credits] Activation of ${code} failed: ${response.status}`); + return null; + } + + // validate the response shape BEFORE persisting the claim — a malformed + // 200 must stay retryable (the retry then gets a 409 and reconciles) + const body = await response.json() as { grant?: { amount?: number } }; + const amount = body?.grant?.amount; + if (typeof amount !== 'number') { + console.warn(`[Credits] Activation of ${code} returned an unexpected body`); + return null; + } + markClaimed(userId, code); + console.log(`[Credits] Activated ${code}: +${amount} credits`); + // display name from the backend catalog; the code is a serviceable + // fallback if the config fetch fails right now + const title = await getActivityCatalog() + .then((catalog) => catalog.find((entry) => entry.code === code)?.displayName) + .catch(() => undefined); + const success: CreditActivationSuccess = { code, title: title ?? code, credits: amount }; + notifyActivation(success); + return success; + } catch (err) { + console.warn(`[Credits] Activation of ${code} errored:`, err); + return null; + } +} + +const REFERRAL_CACHE_TTL_MS = 60 * 1000; + +type ReferralApiStatus = Omit; + +let referralCache: { userId: string; status: ReferralApiStatus; fetchedAt: number } | null = null; + +/** + * The user's own invite code and claim-slot usage (GET /v1/referral — the + * backend creates the permanent code lazily on first fetch). Cached briefly + * because state refreshes arrive in bursts; busted after a successful claim. + * Returns null on any failure — the UI simply omits the invite row. + */ +async function fetchReferralStatus(accessToken: string, userId: string): Promise { + if (referralCache && referralCache.userId === userId && Date.now() - referralCache.fetchedAt < REFERRAL_CACHE_TTL_MS) { + return referralCache.status; + } + try { + const response = await fetch(`${API_URL}/v1/referral`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!response.ok) { + console.warn(`[Credits] Referral status fetch failed: ${response.status}`); + return null; + } + const body = await response.json() as Partial; + const { code, claimsUsed, maxClaims, referrerCredits, refereeCredits } = body ?? {}; + if ( + typeof code !== 'string' || + typeof claimsUsed !== 'number' || + typeof maxClaims !== 'number' || + typeof referrerCredits !== 'number' || + typeof refereeCredits !== 'number' + ) { + console.warn('[Credits] Referral status has an unexpected shape'); + return null; + } + const status: ReferralApiStatus = { code, claimsUsed, maxClaims, referrerCredits, refereeCredits }; + referralCache = { userId, status, fetchedAt: Date.now() }; + return status; + } catch (err) { + console.warn('[Credits] Referral status fetch errored:', err); + return null; + } +} + +/** + * Redeem another user's invite code (POST /v1/referral/claims); the backend + * grants both sides credits in one transaction and enforces all the rules + * (one lifetime claim per account, new accounts only, per-code cap, no + * self-claims). Failure messages are user-presentable — the backend's own + * copy where it provided one. + */ +export async function claimReferralCode(rawCode: string): Promise { + const fallback: ReferralClaimResult = { ok: false, message: 'Could not apply the invite code. Please try again.' }; + try { + const code = rawCode.trim(); + if (!code) return { ok: false, message: 'Enter an invite code.' }; + if (!(await isSignedIn())) return { ok: false, message: 'Sign in to Rowboat to use an invite code.' }; + const accessToken = await getAccessToken(); + const userId = userIdFromToken(accessToken); + if (!userId) return { ok: false, message: 'Sign in to Rowboat to use an invite code.' }; + if (readClaimedStore()[userId]?.[REFERRAL_CLAIMED_KEY]) { + return { ok: false, message: 'This account has already used an invite code.' }; + } + if (!(await isCreditsEnabled())) return { ok: false, message: 'Invite codes are not available right now.' }; + + const response = await fetch(`${API_URL}/v1/referral/claims`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code }), + }); + const body = await response.json().catch(() => null) as { creditsGranted?: number; error?: string } | null; + + if (response.ok) { + const amount = body?.creditsGranted; + if (typeof amount !== 'number') { + console.warn('[Credits] Referral claim returned an unexpected body'); + return fallback; + } + markClaimed(userId, REFERRAL_CLAIMED_KEY); + // the inviter-side status (claimsUsed) is now stale for the invitee's + // own code too — cheap to just refetch next time + referralCache = null; + console.log(`[Credits] Referral claim succeeded: +${amount} credits`); + notifyActivation({ code: REFERRAL_CLAIMED_KEY, title: 'Invite code applied', credits: amount }); + return { ok: true, creditsGranted: amount }; + } + + const message = typeof body?.error === 'string' ? body.error : undefined; + // a definitive "already claimed" answer means the entry UI can hide for + // good on this account (e.g. claimed earlier on another device) + if (response.status === 409 && message?.toLowerCase().includes('already claimed')) { + markClaimed(userId, REFERRAL_CLAIMED_KEY); + } + return { ok: false, message: message ?? fallback.message }; + } catch (err) { + console.warn('[Credits] Referral claim errored:', err); + return fallback; + } +} + +/** + * Backend reward catalog joined with the current user's claimed flags, for + * the UI. Claimed flags come from the local cache (the /v1/me grants list + * doesn't expose activation codes), so a fresh install shows an action as + * unclaimed until it is performed again and the backend answers 409. + */ +export async function getCreditsState(): Promise { + const enabled = await isCreditsEnabled(); + let userId: string | null = null; + let eligible = false; + let catalog: KnownCatalogEntry[] = []; + let referral: ReferralApiStatus | null = null; + try { + if (enabled && (await isSignedIn())) { + const accessToken = await getAccessToken(); + userId = userIdFromToken(accessToken); + // billing-fetch failures fall through to eligible=false — the UI + // refreshes on the next credits/oauth event anyway + eligible = userId != null && !(await hasPaidPlan(userId)); + if (eligible && userId) { + catalog = await getActivityCatalog(); + referral = await fetchReferralStatus(accessToken, userId); + } + } + } catch { + // treat token/billing/config errors as not eligible right now + eligible = false; + } + const claimed = userId ? readClaimedStore()[userId] ?? {} : {}; + return { + enabled, + eligible, + // API order is preserved — the backend catalog decides display order too + activities: catalog.map((entry) => ({ + code: entry.code, + title: entry.displayName, + ...(entry.description !== undefined ? { description: entry.description } : {}), + credits: entry.credits, + claimed: !!claimed[entry.code], + })), + ...(referral ? { referral: { ...referral, claimedByMe: !!claimed[REFERRAL_CLAIMED_KEY] } } : {}), + }; +} diff --git a/apps/x/packages/core/src/code-mode/status.ts b/apps/x/packages/core/src/code-mode/status.ts index 40ab0c1e..2bbc4c4b 100644 --- a/apps/x/packages/core/src/code-mode/status.ts +++ b/apps/x/packages/core/src/code-mode/status.ts @@ -5,6 +5,7 @@ import path from 'path'; import fs from 'fs/promises'; import { CodeModeAgentStatus } from './types.js'; import { isEngineProvisioned } from './acp/engine-provisioner.js'; +import { decodeJwtPayload } from '../auth/jwt.js'; const execAsync = promisify(exec); @@ -40,20 +41,6 @@ export function commonInstallPaths(binary: string): string[] { ].map(dir => path.join(dir, binary)); } -function decodeJwtPayload(token: string): Record | null { - try { - const parts = token.split('.'); - if (parts.length < 2) return null; - const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/'); - const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4)); - const json = Buffer.from(padded + pad, 'base64').toString('utf-8'); - const parsed = JSON.parse(json); - return typeof parsed === 'object' && parsed !== null ? parsed as Record : null; - } catch { - return null; - } -} - // Given the raw credentials JSON (from a file or the macOS Keychain), decide // whether it represents a usable signed-in state: a valid API key, an unexpired // access token, or a refresh token (which can mint a new access token). diff --git a/apps/x/packages/core/src/runtime/tools/domains/background-tasks.ts b/apps/x/packages/core/src/runtime/tools/domains/background-tasks.ts index e2dca1b3..f032d82b 100644 --- a/apps/x/packages/core/src/runtime/tools/domains/background-tasks.ts +++ b/apps/x/packages/core/src/runtime/tools/domains/background-tasks.ts @@ -91,6 +91,8 @@ export const backgroundTaskTools: z.infer = { ...(input.model ? { model: input.model } : {}), ...(input.provider ? { provider: input.provider } : {}), }); + const { maybeActivateCredit } = await import("../../../billing/credits.js"); + void maybeActivateCredit('first_bg_agent'); return { success: true, slug: result.slug, ...(warning ? { warning } : {}) }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) }; diff --git a/apps/x/packages/shared/src/billing.ts b/apps/x/packages/shared/src/billing.ts index fb140166..d065e0ef 100644 --- a/apps/x/packages/shared/src/billing.ts +++ b/apps/x/packages/shared/src/billing.ts @@ -1,5 +1,9 @@ import { z } from 'zod'; +// Mirrors the backend's shared billing constant — credits are denominated so +// that 100M credits == $1 of usage. +export const CREDITS_PER_DOLLAR = 100_000_000; + export const BillingPlanCategorySchema = z.enum(['free', 'starter', 'pro']); export type BillingPlanCategory = z.infer; @@ -29,6 +33,13 @@ export const BillingUsageBucketSchema = z.object({ }); export type BillingUsageBucket = z.infer; +// Bonus/promotional credits granted outside the plan buckets (credit store). +// Not sanctioned per period, so it carries a plain balance instead of a quota. +export const BillingStoreBucketSchema = z.object({ + availableCredits: z.number(), +}); +export type BillingStoreBucket = z.infer; + export const BillingInfoSchema = z.object({ userEmail: z.string().nullable(), userId: z.string().nullable(), @@ -40,6 +51,7 @@ export const BillingInfoSchema = z.object({ daily: BillingUsageBucketSchema.extend({ usageDay: z.string(), }), + store: BillingStoreBucketSchema, }); export type BillingInfo = z.infer; diff --git a/apps/x/packages/shared/src/credits.ts b/apps/x/packages/shared/src/credits.ts new file mode 100644 index 00000000..e25421b2 --- /dev/null +++ b/apps/x/packages/shared/src/credits.ts @@ -0,0 +1,97 @@ +import { z } from 'zod'; +import { CREDITS_PER_DOLLAR } from './billing.js'; + +// First-time-action credit rewards. +// +// Only the activity CODES are known to the app — they anchor the trigger call +// sites (which action fires which code) and per-code UI like icons and +// navigation. Everything else (display name, description, credit amount) is +// the backend catalog's to define and comes from GET /v1/config +// `creditActivations`; the app never hardcodes amounts or copy, so the +// catalog can change without an app release. Codes present in the config but +// unknown to this app version are ignored (nothing here can trigger them); +// known codes absent from the config are treated as retired and hidden. +export const CreditActivityCodeSchema = z.enum([ + 'first_gmail_connected', + 'first_email_sent', + 'first_meeting_note', + 'first_bg_agent', + 'first_app_built', +]); +export type CreditActivityCode = z.infer; + +// One catalog entry as served by GET /v1/config — `code` is an open string +// because the backend may serve codes this app version doesn't know yet. +export const CreditActivationCatalogEntrySchema = z.object({ + code: z.string(), + displayName: z.string(), + description: z.string().optional(), + credits: z.number(), +}); +export type CreditActivationCatalogEntry = z.infer; + +export const CreditActivityStateSchema = z.object({ + code: CreditActivityCodeSchema, + title: z.string(), + description: z.string().optional(), + credits: z.number(), + claimed: z.boolean(), +}); +export type CreditActivityState = z.infer; + +// Referral ("invite friends") state, from GET /v1/referral. The code is the +// caller's permanent invite code; the backend grants both sides credits per +// successful claim, up to maxClaims claims of one code. `claimedByMe` is +// whether THIS account has redeemed someone else's code (from the local +// claimed cache — the backend rejects repeats either way). +export const ReferralStateSchema = z.object({ + // canonical display form, e.g. ABC-DEF-GHJ + code: z.string(), + claimsUsed: z.number(), + maxClaims: z.number(), + // what the inviter earns per claim / what the invited person earns + referrerCredits: z.number(), + refereeCredits: z.number(), + claimedByMe: z.boolean(), +}); +export type ReferralState = z.infer; + +// Result of redeeming an invite code (`referral:claim`). Failure messages are +// already user-presentable (the backend's own copy where available). +export const ReferralClaimResultSchema = z.union([ + z.object({ ok: z.literal(true), creditsGranted: z.number() }), + z.object({ ok: z.literal(false), message: z.string() }), +]); +export type ReferralClaimResult = z.infer; + +export const CreditsStateSchema = z.object({ + // Rewards are feature-flagged (PostHog `credit-rewards`, ROWBOAT_CREDITS + // env override in dev); when off, no UI shows and no activation fires. + enabled: z.boolean(), + // signed in to Rowboat AND on the free tier — rewards target free users, + // so BYOK-only setups and paid plans (starter/pro) are excluded. All UI + // surfaces gate on this. + eligible: z.boolean(), + activities: z.array(CreditActivityStateSchema), + // absent when not eligible or the referral status fetch failed + referral: ReferralStateSchema.optional(), +}); +export type CreditsState = z.infer; + +// Payload of the main → renderer `credits:didActivate` event, emitted after +// the backend confirms a grant — either a first-time-action activation or a +// redeemed invite code (`referral_claimed`). +export const CreditActivatedEventSchema = z.object({ + code: z.union([CreditActivityCodeSchema, z.literal('referral_claimed')]), + title: z.string(), + // actual granted amount, from the backend response + credits: z.number(), +}); +export type CreditActivatedEvent = z.infer; + +/** Format a raw credit amount as a dollar string, e.g. 100_000_000 → "$1". */ +export function formatCreditsAsDollars(credits: number): string { + const dollars = credits / CREDITS_PER_DOLLAR; + const rounded = Math.round(dollars * 100) / 100; + return Number.isInteger(rounded) ? `$${rounded}` : `$${rounded.toFixed(2)}`; +} diff --git a/apps/x/packages/shared/src/index.ts b/apps/x/packages/shared/src/index.ts index aaf107e3..a40a893a 100644 --- a/apps/x/packages/shared/src/index.ts +++ b/apps/x/packages/shared/src/index.ts @@ -17,6 +17,7 @@ export * as frontmatter from './frontmatter.js'; export * as bases from './bases.js'; export * as browserControl from './browser-control.js'; export * as billing from './billing.js'; +export * as credits from './credits.js'; export * as notificationSettings from './notification-settings.js'; export * as codeSessions from './code-sessions.js'; export * as channels from './channels.js'; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 2d657b77..e2ce9a62 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -21,6 +21,7 @@ import { ZListToolkitsResponse } from './composio.js'; import { AppSummarySchema, RegistryRecordSchema, RowboatAppManifestSchema } from './rowboat-app.js'; import { BrowserStateSchema, DisplayMediaRequestSchema, HttpAuthRequestSchema } from './browser-control.js'; import { BillingInfoSchema } from './billing.js'; +import { CreditActivatedEventSchema, CreditsStateSchema, ReferralClaimResultSchema } from './credits.js'; import { EmailBlockSchema, GmailThreadSchema } from './blocks.js'; import { PermissionDecision, ApprovalPolicy, CodingAgent, type CodeRunFeedEvent } from './code-mode.js'; import { NotificationSettingsSchema } from './notification-settings.js'; @@ -2426,6 +2427,23 @@ const ipcSchemas = { req: z.null(), res: BillingInfoSchema, }, + // First-time-action credit rewards (see shared/src/credits.ts) + 'credits:getState': { + req: z.null(), + res: CreditsStateSchema, + }, + // Main → renderer: the backend confirmed a credit grant. All activation + // triggers live in main/core (oauth success, gmail send, meeting summarize, + // bg-task create, app create); the renderer only listens and celebrates. + 'credits:didActivate': { + req: CreditActivatedEventSchema, + res: z.null(), + }, + // Redeem another user's invite (referral) code — both sides earn credits. + 'referral:claim': { + req: z.object({ code: z.string() }), + res: ReferralClaimResultSchema, + }, // Notification settings channels 'notifications:getSettings': { req: z.null(), diff --git a/apps/x/packages/shared/src/rowboat-account.ts b/apps/x/packages/shared/src/rowboat-account.ts index 2f5a32c7..efa484e3 100644 --- a/apps/x/packages/shared/src/rowboat-account.ts +++ b/apps/x/packages/shared/src/rowboat-account.ts @@ -1,9 +1,14 @@ import { z } from 'zod'; import { BillingCatalogSchema } from './billing.js'; +import { CreditActivationCatalogEntrySchema } from './credits.js'; export const RowboatApiConfig = z.object({ appUrl: z.string(), websocketApiUrl: z.string(), supabaseUrl: z.string(), billing: BillingCatalogSchema, + // first-time-action reward catalog (non-archived entries); optional so the + // app keeps working against API deployments that predate it — the rewards + // UI just stays empty until the backend serves the catalog + creditActivations: z.array(CreditActivationCatalogEntrySchema).optional(), });