diff --git a/bin.ts b/bin.ts
index a61e4cc..803fdf0 100644
--- a/bin.ts
+++ b/bin.ts
@@ -504,6 +504,49 @@ yargs(hideBin(process.argv))
.demandCommand(1, 'You must specify a subcommand (add or remove)')
.help();
})
+ .command(
+ 'setup-revenue-analytics',
+ 'Set up Stripe revenue analytics with PostHog',
+ (yargs) => {
+ return yargs.options({
+ 'install-dir': {
+ describe:
+ 'Directory of the project to set up\nenv: POSTHOG_WIZARD_INSTALL_DIR',
+ type: 'string',
+ },
+ });
+ },
+ (argv) => {
+ const options = { ...argv };
+
+ void (async () => {
+ // Always use LoggingUI for this subcommand (simple linear flow)
+ setUI(new LoggingUI());
+
+ const { readApiKeyFromEnv } = await import(
+ './src/utils/env-api-key.js'
+ );
+ const { buildSession } = await import('./src/lib/wizard-session.js');
+ const { runSetupRevenueAnalytics } = await import(
+ './src/setup-revenue-analytics/index.js'
+ );
+
+ const apiKey =
+ (options.apiKey as string | undefined) || readApiKeyFromEnv();
+
+ const session = buildSession({
+ debug: options.debug as boolean | undefined,
+ installDir: options.installDir as string | undefined,
+ ci: options.ci as boolean | undefined,
+ localMcp: options.localMcp as boolean | undefined,
+ apiKey,
+ projectId: options.projectId as string | undefined,
+ });
+
+ await runSetupRevenueAnalytics(session);
+ })();
+ },
+ )
.help()
.alias('help', 'h')
.version()
diff --git a/src/lib/agent-interface.ts b/src/lib/agent-interface.ts
index 1dca0cc..860b880 100644
--- a/src/lib/agent-interface.ts
+++ b/src/lib/agent-interface.ts
@@ -1188,7 +1188,6 @@ function handleSDKMessage(
const statusMatch = block.text.match(statusRegex);
if (statusMatch) {
const statusText = statusMatch[1].trim();
- getUI().pushStatus(statusText);
spinner.message(statusText);
}
}
diff --git a/src/setup-revenue-analytics/__tests__/language-detection.test.ts b/src/setup-revenue-analytics/__tests__/language-detection.test.ts
new file mode 100644
index 0000000..c770fbd
--- /dev/null
+++ b/src/setup-revenue-analytics/__tests__/language-detection.test.ts
@@ -0,0 +1,151 @@
+import * as path from 'path';
+import * as fs from 'fs';
+import * as os from 'os';
+import {
+ detectLanguageFromFiles,
+ languageFromIntegration,
+} from '../language-detection';
+import { Integration } from '../../lib/constants';
+
+describe('languageFromIntegration', () => {
+ const cases: [Integration, string | null][] = [
+ [Integration.nextjs, 'node'],
+ [Integration.nuxt, 'node'],
+ [Integration.vue, 'node'],
+ [Integration.reactRouter, 'node'],
+ [Integration.tanstackStart, 'node'],
+ [Integration.tanstackRouter, 'node'],
+ [Integration.reactNative, 'node'],
+ [Integration.angular, 'node'],
+ [Integration.astro, 'node'],
+ [Integration.sveltekit, 'node'],
+ [Integration.javascript_web, 'node'],
+ [Integration.javascriptNode, 'node'],
+ [Integration.django, 'python'],
+ [Integration.flask, 'python'],
+ [Integration.fastapi, 'python'],
+ [Integration.python, 'python'],
+ [Integration.laravel, 'php'],
+ [Integration.rails, 'ruby'],
+ [Integration.ruby, 'ruby'],
+ [Integration.swift, null],
+ [Integration.android, null],
+ ];
+
+ test.each(cases)('%s → %s', (integration, expected) => {
+ expect(languageFromIntegration(integration)).toBe(expected);
+ });
+});
+
+describe('detectLanguageFromFiles', () => {
+ let tmpDir: string;
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-lang-'));
+ });
+
+ afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ test('detects node from package.json', async () => {
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{}');
+ expect(await detectLanguageFromFiles(tmpDir)).toBe('node');
+ });
+
+ test('detects python from requirements.txt', async () => {
+ fs.writeFileSync(path.join(tmpDir, 'requirements.txt'), 'stripe==5.0.0');
+ expect(await detectLanguageFromFiles(tmpDir)).toBe('python');
+ });
+
+ test('detects python from pyproject.toml', async () => {
+ fs.writeFileSync(path.join(tmpDir, 'pyproject.toml'), '[project]');
+ expect(await detectLanguageFromFiles(tmpDir)).toBe('python');
+ });
+
+ test('detects ruby from Gemfile', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, 'Gemfile'),
+ "source 'https://rubygems.org'",
+ );
+ expect(await detectLanguageFromFiles(tmpDir)).toBe('ruby');
+ });
+
+ test('detects php from composer.json', async () => {
+ fs.writeFileSync(path.join(tmpDir, 'composer.json'), '{}');
+ expect(await detectLanguageFromFiles(tmpDir)).toBe('php');
+ });
+
+ test('detects go from go.mod', async () => {
+ fs.writeFileSync(path.join(tmpDir, 'go.mod'), 'module myapp');
+ expect(await detectLanguageFromFiles(tmpDir)).toBe('go');
+ });
+
+ test('detects java from build.gradle', async () => {
+ fs.writeFileSync(path.join(tmpDir, 'build.gradle'), 'plugins {}');
+ expect(await detectLanguageFromFiles(tmpDir)).toBe('java');
+ });
+
+ test('detects java from pom.xml', async () => {
+ fs.writeFileSync(path.join(tmpDir, 'pom.xml'), '');
+ expect(await detectLanguageFromFiles(tmpDir)).toBe('java');
+ });
+
+ test('detects dotnet from .csproj', async () => {
+ fs.writeFileSync(path.join(tmpDir, 'MyApp.csproj'), '');
+ expect(await detectLanguageFromFiles(tmpDir)).toBe('dotnet');
+ });
+
+ test('detects dotnet from .sln', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, 'MyApp.sln'),
+ 'Microsoft Visual Studio Solution',
+ );
+ expect(await detectLanguageFromFiles(tmpDir)).toBe('dotnet');
+ });
+
+ test('returns null for empty directory', async () => {
+ expect(await detectLanguageFromFiles(tmpDir)).toBeNull();
+ });
+
+ test('returns first match when multiple indicators present', async () => {
+ // node (package.json) comes before python in indicator order
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{}');
+ fs.writeFileSync(path.join(tmpDir, 'requirements.txt'), 'stripe');
+ expect(await detectLanguageFromFiles(tmpDir)).toBe('node');
+ });
+
+ describe('monorepo support', () => {
+ test('detects node from subdirectory package.json', async () => {
+ fs.mkdirSync(path.join(tmpDir, 'backend'), { recursive: true });
+ fs.writeFileSync(path.join(tmpDir, 'backend', 'package.json'), '{}');
+ expect(await detectLanguageFromFiles(tmpDir)).toBe('node');
+ });
+
+ test('detects python from subdirectory requirements.txt', async () => {
+ fs.mkdirSync(path.join(tmpDir, 'server'), { recursive: true });
+ fs.writeFileSync(
+ path.join(tmpDir, 'server', 'requirements.txt'),
+ 'django',
+ );
+ expect(await detectLanguageFromFiles(tmpDir)).toBe('python');
+ });
+
+ test('detects ruby from nested Gemfile', async () => {
+ fs.mkdirSync(path.join(tmpDir, 'api'), { recursive: true });
+ fs.writeFileSync(path.join(tmpDir, 'api', 'Gemfile'), "gem 'rails'");
+ expect(await detectLanguageFromFiles(tmpDir)).toBe('ruby');
+ });
+
+ test('ignores node_modules subdirectories', async () => {
+ fs.mkdirSync(path.join(tmpDir, 'node_modules', 'stripe'), {
+ recursive: true,
+ });
+ fs.writeFileSync(
+ path.join(tmpDir, 'node_modules', 'stripe', 'package.json'),
+ '{}',
+ );
+ expect(await detectLanguageFromFiles(tmpDir)).toBeNull();
+ });
+ });
+});
diff --git a/src/setup-revenue-analytics/__tests__/posthog-detection.test.ts b/src/setup-revenue-analytics/__tests__/posthog-detection.test.ts
new file mode 100644
index 0000000..c983d8e
--- /dev/null
+++ b/src/setup-revenue-analytics/__tests__/posthog-detection.test.ts
@@ -0,0 +1,400 @@
+import * as path from 'path';
+import * as fs from 'fs';
+import * as os from 'os';
+import { detectPostHogDistinctId } from '../posthog-detection';
+
+function createFixture(files: Record): {
+ dir: string;
+ cleanup: () => void;
+} {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-posthog-'));
+ for (const [filePath, content] of Object.entries(files)) {
+ const fullPath = path.join(dir, filePath);
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
+ fs.writeFileSync(fullPath, content);
+ }
+ return {
+ dir,
+ cleanup: () => fs.rmSync(dir, { recursive: true, force: true }),
+ };
+}
+
+describe('detectPostHogDistinctId', () => {
+ describe('Node.js / JavaScript', () => {
+ test('finds distinct_id from posthog.identify() — frontend SDK', async () => {
+ const { dir, cleanup } = createFixture({
+ 'src/analytics.ts': `import posthog from 'posthog-js';
+posthog.identify(user.id, { name: user.name });`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'node');
+ expect(result.distinctIdExpression).toBe('user.id');
+ expect(result.sourceFile).toBe('src/analytics.ts');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds distinct_id from client.capture() — backend Node SDK', async () => {
+ const { dir, cleanup } = createFixture({
+ 'src/events.ts': `client.capture({
+ distinctId: req.user.id,
+ event: 'purchase',
+});`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'node');
+ expect(result.distinctIdExpression).toBe('req.user.id');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds distinct_id from client.alias() — backend Node SDK', async () => {
+ const { dir, cleanup } = createFixture({
+ 'src/auth.ts': `client.alias({
+ distinctId: user.frontendId,
+ alias: user.backendId,
+});`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'node');
+ expect(result.distinctIdExpression).toBe('user.frontendId');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds distinct_id from variable assignment', async () => {
+ const { dir, cleanup } = createFixture({
+ 'src/posthog.ts': `const distinctId = session.user.id;`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'node');
+ expect(result.distinctIdExpression).toBe('session.user.id');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds distinct_id from get_distinct_id() assignment', async () => {
+ const { dir, cleanup } = createFixture({
+ 'src/tracking.ts': `const userId = posthog.get_distinct_id();`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'node');
+ expect(result.distinctIdExpression).toBe('userId');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('returns null when no PostHog usage found', async () => {
+ const { dir, cleanup } = createFixture({
+ 'src/app.ts': `console.log('hello');`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'node');
+ expect(result.distinctIdExpression).toBeNull();
+ } finally {
+ cleanup();
+ }
+ });
+ });
+
+ describe('Python', () => {
+ test('finds distinct_id from posthog.identify()', async () => {
+ const { dir, cleanup } = createFixture({
+ 'analytics.py': `import posthog
+posthog.identify(request.user.pk)`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'python');
+ expect(result.distinctIdExpression).toBe('request.user.pk');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds distinct_id from posthog.capture() — positional arg', async () => {
+ const { dir, cleanup } = createFixture({
+ 'events.py': `posthog.capture(user.id, 'purchase')`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'python');
+ expect(result.distinctIdExpression).toBe('user.id');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds distinct_id from keyword argument', async () => {
+ const { dir, cleanup } = createFixture({
+ 'track.py': `posthog.capture(distinct_id=user.email, event='sign_up')`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'python');
+ expect(result.distinctIdExpression).toBe('user.email');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds distinct_id from posthog.alias()', async () => {
+ const { dir, cleanup } = createFixture({
+ 'alias.py': `posthog.alias(previous_id=old_id, distinct_id=user.uuid)`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'python');
+ expect(result.distinctIdExpression).toBe('user.uuid');
+ } finally {
+ cleanup();
+ }
+ });
+ });
+
+ describe('Ruby', () => {
+ test('finds distinct_id from capture() hash', async () => {
+ const { dir, cleanup } = createFixture({
+ 'app/services/tracking.rb': `posthog.capture({
+ distinct_id: current_user.id,
+ event: 'purchase'
+})`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'ruby');
+ expect(result.distinctIdExpression).toBe('current_user.id');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds distinct_id from identify() hash', async () => {
+ const { dir, cleanup } = createFixture({
+ 'app/controllers/sessions_controller.rb': `posthog.identify({
+ distinct_id: @user.id,
+ properties: { email: @user.email }
+})`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'ruby');
+ expect(result.distinctIdExpression).toBe('@user.id');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds distinct_id from alias() hash', async () => {
+ const { dir, cleanup } = createFixture({
+ 'app/services/alias.rb': `posthog.alias({
+ distinct_id: user.frontend_id,
+ alias: user.backend_id,
+})`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'ruby');
+ expect(result.distinctIdExpression).toBe('user.frontend_id');
+ } finally {
+ cleanup();
+ }
+ });
+ });
+
+ describe('PHP', () => {
+ test('finds distinct_id from PostHog::capture()', async () => {
+ const { dir, cleanup } = createFixture({
+ 'app/Http/Controllers/EventController.php': `PostHog::capture([
+ 'distinctId' => $user->id,
+ 'event' => 'purchase',
+]);`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'php');
+ expect(result.distinctIdExpression).toBe('$user->id');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds distinct_id from PostHog::identify()', async () => {
+ const { dir, cleanup } = createFixture({
+ 'app/Listeners/LoginListener.php': `PostHog::identify([
+ 'distinctId' => Auth::id(),
+]);`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'php');
+ expect(result.distinctIdExpression).toBe('Auth::id()');
+ } finally {
+ cleanup();
+ }
+ });
+ });
+
+ describe('Go', () => {
+ test('finds DistinctId from posthog.Capture struct', async () => {
+ const { dir, cleanup } = createFixture({
+ 'analytics/track.go': `client.Enqueue(posthog.Capture{
+ DistinctId: user.ID,
+ Event: "purchase",
+})`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'go');
+ expect(result.distinctIdExpression).toBe('user.ID');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds DistinctId from posthog.Identify struct', async () => {
+ const { dir, cleanup } = createFixture({
+ 'analytics/identify.go': `client.Enqueue(posthog.Identify{
+ DistinctId: req.UserID,
+})`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'go');
+ expect(result.distinctIdExpression).toBe('req.UserID');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds DistinctId from posthog.Alias struct', async () => {
+ const { dir, cleanup } = createFixture({
+ 'analytics/alias.go': `client.Enqueue(posthog.Alias{
+ DistinctId: user.FrontendID,
+ Alias: user.BackendID,
+})`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'go');
+ expect(result.distinctIdExpression).toBe('user.FrontendID');
+ } finally {
+ cleanup();
+ }
+ });
+ });
+
+ describe('Java / Kotlin', () => {
+ test('finds distinct_id from posthog.capture() — Java', async () => {
+ const { dir, cleanup } = createFixture({
+ 'src/main/java/Analytics.java': `posthog.capture(user.getId(), "purchase");`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'java');
+ expect(result.distinctIdExpression).toBe('user.getId()');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds distinct_id from posthog.identify() — Java', async () => {
+ const { dir, cleanup } = createFixture({
+ 'src/main/java/Auth.java': `posthog.identify(session.getUserId());`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'java');
+ expect(result.distinctIdExpression).toBe('session.getUserId()');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds distinctId from PostHog.identify() — Kotlin/Android SDK', async () => {
+ const { dir, cleanup } = createFixture({
+ 'app/src/main/kotlin/Analytics.kt': `PostHog.identify(distinctId = currentUser.uid)`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'java');
+ expect(result.distinctIdExpression).toBe('currentUser.uid');
+ } finally {
+ cleanup();
+ }
+ });
+ });
+
+ describe('.NET', () => {
+ test('finds DistinctId from property assignment', async () => {
+ const { dir, cleanup } = createFixture({
+ 'Services/Analytics.cs': `var options = new CaptureOptions
+{
+ DistinctId = user.Id,
+ Event = "purchase",
+};`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'dotnet');
+ expect(result.distinctIdExpression).toBe('user.Id');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds distinct_id from Capture() call', async () => {
+ const { dir, cleanup } = createFixture({
+ 'Services/Tracking.cs': `await posthog.CaptureAsync(userId, "purchase");`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'dotnet');
+ expect(result.distinctIdExpression).toBe('userId');
+ } finally {
+ cleanup();
+ }
+ });
+ });
+
+ describe('filtering', () => {
+ test('ignores string literal distinct_ids', async () => {
+ const { dir, cleanup } = createFixture({
+ 'test.ts': `posthog.identify("hardcoded-id");`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'node');
+ expect(result.distinctIdExpression).toBeNull();
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('ignores placeholder values from docs examples', async () => {
+ const { dir, cleanup } = createFixture({
+ 'example.ts': `posthog.identify(distinct_id, { email: 'test@test.com' });`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'node');
+ // "distinct_id" is a placeholder, should be skipped
+ expect(result.distinctIdExpression).toBeNull();
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('ignores test files', async () => {
+ const { dir, cleanup } = createFixture({
+ 'src/__tests__/analytics.test.ts': `posthog.identify(testUser.id);`,
+ 'src/analytics.spec.ts': `posthog.identify(mockUser.id);`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'node');
+ expect(result.distinctIdExpression).toBeNull();
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('prefers identify() over capture() when both present', async () => {
+ const { dir, cleanup } = createFixture({
+ 'src/posthog.ts': `posthog.identify(auth.user.id, { name: user.name });
+posthog.capture('page_view', { url: window.location.href });`,
+ });
+ try {
+ const result = await detectPostHogDistinctId(dir, 'node');
+ expect(result.distinctIdExpression).toBe('auth.user.id');
+ } finally {
+ cleanup();
+ }
+ });
+ });
+});
diff --git a/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts b/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts
new file mode 100644
index 0000000..8fe5183
--- /dev/null
+++ b/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts
@@ -0,0 +1,177 @@
+import { buildRevenueAnalyticsPrompt } from '../prompt-builder';
+import type { StripeDetectionResult, PostHogDistinctIdResult } from '../types';
+import { STRIPE_DOCS_FALLBACK } from '../stripe-docs-fallback';
+
+function makeContext(overrides?: {
+ language?: StripeDetectionResult['language'];
+ customerCalls?: StripeDetectionResult['customerCreationCalls'];
+ chargeCalls?: StripeDetectionResult['chargeCalls'];
+ distinctId?: string | null;
+ usesCheckout?: boolean;
+}) {
+ const language = overrides?.language ?? 'node';
+ return {
+ language,
+ stripeDetection: {
+ sdkPackage: 'package.json',
+ sdkVersion: '14.21.0',
+ language,
+ customerCreationCalls: overrides?.customerCalls ?? [
+ {
+ file: 'src/billing.ts',
+ line: 10,
+ snippet: 'stripe.customers.create({',
+ },
+ ],
+ chargeCalls: overrides?.chargeCalls ?? [
+ {
+ file: 'src/payments.ts',
+ line: 20,
+ snippet: 'stripe.paymentIntents.create({',
+ type: 'payment_intent' as const,
+ },
+ ],
+ usesCheckoutSessions: overrides?.usesCheckout ?? false,
+ } satisfies StripeDetectionResult,
+ posthogDetection: {
+ distinctIdExpression:
+ overrides && 'distinctId' in overrides
+ ? overrides.distinctId ?? null
+ : 'user.id',
+ sourceFile: overrides?.distinctId === null ? null : 'src/analytics.ts',
+ sourceLine:
+ overrides?.distinctId === null ? null : 'posthog.identify(user.id)',
+ } satisfies PostHogDistinctIdResult,
+ stripeDocs: STRIPE_DOCS_FALLBACK[language],
+ };
+}
+
+describe('buildRevenueAnalyticsPrompt', () => {
+ test('includes language and Stripe SDK info', () => {
+ const prompt = buildRevenueAnalyticsPrompt(makeContext());
+ expect(prompt).toContain('node');
+ expect(prompt).toContain('v14.21.0');
+ });
+
+ test('includes detected distinct_id expression and source line', () => {
+ const prompt = buildRevenueAnalyticsPrompt(makeContext());
+ expect(prompt).toContain('`user.id`');
+ expect(prompt).toContain('src/analytics.ts');
+ expect(prompt).toContain('posthog.identify(user.id)');
+ });
+
+ test('instructs agent to search codebase when distinct_id not detected', () => {
+ const prompt = buildRevenueAnalyticsPrompt(
+ makeContext({ distinctId: null }),
+ );
+ expect(prompt).toContain('Not automatically detected');
+ expect(prompt).toContain('Search the codebase');
+ });
+
+ test('explains how to find distinct_id from posthog.identify', () => {
+ const prompt = buildRevenueAnalyticsPrompt(makeContext());
+ expect(prompt).toContain('posthog.identify(');
+ expect(prompt).toContain('FIRST ARGUMENT is the distinct_id');
+ });
+
+ test('uses placeholder in code examples', () => {
+ const prompt = buildRevenueAnalyticsPrompt(makeContext());
+ expect(prompt).toContain('');
+ expect(prompt).toContain('Replace `` with the actual');
+ });
+
+ describe('guiding tenets', () => {
+ test('includes never-fabricate tenet', () => {
+ const prompt = buildRevenueAnalyticsPrompt(makeContext());
+ expect(prompt).toContain('Never fabricate the value');
+ expect(prompt).toContain('A wrong value is worse than no value');
+ });
+
+ test('includes thread-the-value tenet', () => {
+ const prompt = buildRevenueAnalyticsPrompt(makeContext());
+ expect(prompt).toContain("Thread the value, don't invent it");
+ expect(prompt).toContain('optional parameter');
+ });
+
+ test('includes minimize-api-calls tenet', () => {
+ const prompt = buildRevenueAnalyticsPrompt(makeContext());
+ expect(prompt).toContain('Minimize extra API calls');
+ expect(prompt).toContain('network round-trip');
+ });
+
+ test('includes follow-abstractions tenet', () => {
+ const prompt = buildRevenueAnalyticsPrompt(makeContext());
+ expect(prompt).toContain('Follow existing Stripe abstraction patterns');
+ expect(prompt).toContain('utility/service layer');
+ });
+
+ test('warns against fabricating user.posthogDistinctId', () => {
+ const prompt = buildRevenueAnalyticsPrompt(makeContext());
+ expect(prompt).toContain('user.posthogDistinctId');
+ expect(prompt).toContain('does not exist');
+ });
+ });
+
+ test('includes customer creation locations', () => {
+ const prompt = buildRevenueAnalyticsPrompt(makeContext());
+ expect(prompt).toContain('src/billing.ts:10');
+ });
+
+ test('includes charge call locations with types', () => {
+ const prompt = buildRevenueAnalyticsPrompt(makeContext());
+ expect(prompt).toContain('src/payments.ts:20 [payment_intent]');
+ });
+
+ test('handles no customer creation calls found', () => {
+ const prompt = buildRevenueAnalyticsPrompt(
+ makeContext({ customerCalls: [] }),
+ );
+ expect(prompt).toContain('none found');
+ expect(prompt).toContain('search the codebase yourself');
+ });
+
+ test('includes checkout session note when detected', () => {
+ const prompt = buildRevenueAnalyticsPrompt(
+ makeContext({
+ usesCheckout: true,
+ chargeCalls: [
+ {
+ file: 'src/checkout.ts',
+ line: 5,
+ snippet: 'stripe.checkout.sessions.create({',
+ type: 'checkout_session',
+ },
+ ],
+ }),
+ );
+ expect(prompt).toContain('Stripe Checkout detected');
+ expect(prompt).toContain('checkout.session.completed webhook');
+ });
+
+ test('does not include checkout note when not using checkout', () => {
+ const prompt = buildRevenueAnalyticsPrompt(makeContext());
+ expect(prompt).not.toContain('Stripe Checkout detected');
+ });
+
+ test('includes Stripe docs code examples', () => {
+ const prompt = buildRevenueAnalyticsPrompt(makeContext());
+ expect(prompt).toContain('stripe.customers.create');
+ expect(prompt).toContain('stripe.customers.update');
+ expect(prompt).toContain('posthog_person_distinct_id');
+ });
+
+ test('includes constraints section', () => {
+ const prompt = buildRevenueAnalyticsPrompt(makeContext());
+ expect(prompt).toContain('Do NOT modify charge/payment logic');
+ expect(prompt).toContain('Do NOT remove any existing code');
+ });
+
+ test.each(['python', 'ruby', 'php', 'go', 'java', 'dotnet'] as const)(
+ 'generates prompt for %s',
+ (language) => {
+ const prompt = buildRevenueAnalyticsPrompt(makeContext({ language }));
+ expect(prompt).toContain(language);
+ expect(prompt).toContain('posthog_person_distinct_id');
+ },
+ );
+});
diff --git a/src/setup-revenue-analytics/__tests__/stripe-detection.test.ts b/src/setup-revenue-analytics/__tests__/stripe-detection.test.ts
new file mode 100644
index 0000000..bb8e183
--- /dev/null
+++ b/src/setup-revenue-analytics/__tests__/stripe-detection.test.ts
@@ -0,0 +1,383 @@
+import * as path from 'path';
+import * as fs from 'fs';
+import * as os from 'os';
+import { detectStripe } from '../stripe-detection';
+
+function createFixture(files: Record): {
+ dir: string;
+ cleanup: () => void;
+} {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-stripe-'));
+ for (const [filePath, content] of Object.entries(files)) {
+ const fullPath = path.join(dir, filePath);
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
+ fs.writeFileSync(fullPath, content);
+ }
+ return {
+ dir,
+ cleanup: () => fs.rmSync(dir, { recursive: true, force: true }),
+ };
+}
+
+describe('detectStripe', () => {
+ describe('Node.js', () => {
+ test('detects Stripe from package.json', () => {
+ const { dir, cleanup } = createFixture({
+ 'package.json': JSON.stringify({
+ dependencies: { stripe: '^14.0.0' },
+ }),
+ });
+ try {
+ const result = detectStripe(dir, 'node');
+ expect(result).not.toBeNull();
+ expect(result!.sdkPackage).toBe('package.json');
+ expect(result!.language).toBe('node');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('extracts version from package-lock.json', () => {
+ const { dir, cleanup } = createFixture({
+ 'package.json': JSON.stringify({
+ dependencies: { stripe: '^14.0.0' },
+ }),
+ 'package-lock.json': JSON.stringify({
+ packages: { 'node_modules/stripe': { version: '14.21.0' } },
+ }),
+ });
+ try {
+ const result = detectStripe(dir, 'node');
+ expect(result).not.toBeNull();
+ expect(result!.sdkVersion).toBe('14.21.0');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds customer creation calls', () => {
+ const { dir, cleanup } = createFixture({
+ 'package.json': JSON.stringify({
+ dependencies: { stripe: '^14.0.0' },
+ }),
+ 'src/billing.ts': `import Stripe from 'stripe';
+const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
+
+const customer = await stripe.customers.create({
+ email: user.email,
+});`,
+ });
+ try {
+ const result = detectStripe(dir, 'node');
+ expect(result).not.toBeNull();
+ expect(result!.customerCreationCalls).toHaveLength(1);
+ expect(result!.customerCreationCalls[0].file).toBe('src/billing.ts');
+ expect(result!.customerCreationCalls[0].line).toBe(4);
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds charge patterns', () => {
+ const { dir, cleanup } = createFixture({
+ 'package.json': JSON.stringify({
+ dependencies: { stripe: '^14.0.0' },
+ }),
+ 'src/payments.ts': `const intent = await stripe.paymentIntents.create({
+ amount: 1000,
+ currency: 'usd',
+});
+
+const sub = await stripe.subscriptions.create({
+ customer: customerId,
+});
+
+const session = await stripe.checkout.sessions.create({
+ mode: 'payment',
+});`,
+ });
+ try {
+ const result = detectStripe(dir, 'node');
+ expect(result).not.toBeNull();
+ expect(result!.chargeCalls).toHaveLength(3);
+ expect(result!.chargeCalls.map((c) => c.type)).toEqual([
+ 'payment_intent',
+ 'subscription',
+ 'checkout_session',
+ ]);
+ expect(result!.usesCheckoutSessions).toBe(true);
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('returns null when Stripe is not installed', () => {
+ const { dir, cleanup } = createFixture({
+ 'package.json': JSON.stringify({
+ dependencies: { express: '^4.0.0' },
+ }),
+ });
+ try {
+ expect(detectStripe(dir, 'node')).toBeNull();
+ } finally {
+ cleanup();
+ }
+ });
+ });
+
+ describe('Python', () => {
+ test('detects Stripe from requirements.txt', () => {
+ const { dir, cleanup } = createFixture({
+ 'requirements.txt': 'stripe>=5.0.0\nflask',
+ });
+ try {
+ const result = detectStripe(dir, 'python');
+ expect(result).not.toBeNull();
+ expect(result!.sdkPackage).toBe('requirements.txt');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('detects Stripe from uv.lock', () => {
+ const { dir, cleanup } = createFixture({
+ 'uv.lock': `[[package]]
+name = "stripe"
+version = "11.4.1"
+source = { registry = "https://pypi.org/simple" }`,
+ });
+ try {
+ const result = detectStripe(dir, 'python');
+ expect(result).not.toBeNull();
+ expect(result!.sdkPackage).toBe('uv.lock');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('detects Stripe from poetry.lock', () => {
+ const { dir, cleanup } = createFixture({
+ 'poetry.lock': `[[package]]
+name = "stripe"
+version = "11.4.1"`,
+ });
+ try {
+ const result = detectStripe(dir, 'python');
+ expect(result).not.toBeNull();
+ expect(result!.sdkPackage).toBe('poetry.lock');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds Python customer creation', () => {
+ const { dir, cleanup } = createFixture({
+ 'requirements.txt': 'stripe>=5.0.0',
+ 'billing.py': `import stripe
+
+customer = stripe.Customer.create(
+ email=user.email,
+)`,
+ });
+ try {
+ const result = detectStripe(dir, 'python');
+ expect(result).not.toBeNull();
+ expect(result!.customerCreationCalls).toHaveLength(1);
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds Python charge patterns', () => {
+ const { dir, cleanup } = createFixture({
+ 'requirements.txt': 'stripe>=5.0.0',
+ 'payments.py': `import stripe
+
+intent = stripe.PaymentIntent.create(
+ amount=1000,
+ currency="usd",
+)
+
+sub = stripe.Subscription.create(
+ customer=customer_id,
+)`,
+ });
+ try {
+ const result = detectStripe(dir, 'python');
+ expect(result).not.toBeNull();
+ expect(result!.chargeCalls).toHaveLength(2);
+ } finally {
+ cleanup();
+ }
+ });
+ });
+
+ describe('Ruby', () => {
+ test('detects Stripe from Gemfile', () => {
+ const { dir, cleanup } = createFixture({
+ Gemfile: "gem 'stripe'",
+ });
+ try {
+ const result = detectStripe(dir, 'ruby');
+ expect(result).not.toBeNull();
+ expect(result!.sdkPackage).toBe('Gemfile');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds Ruby customer creation', () => {
+ const { dir, cleanup } = createFixture({
+ Gemfile: "gem 'stripe'",
+ 'app/services/billing.rb': `Stripe::Customer.create({
+ email: user.email,
+})`,
+ });
+ try {
+ const result = detectStripe(dir, 'ruby');
+ expect(result!.customerCreationCalls).toHaveLength(1);
+ } finally {
+ cleanup();
+ }
+ });
+ });
+
+ describe('PHP', () => {
+ test('detects Stripe from composer.json', () => {
+ const { dir, cleanup } = createFixture({
+ 'composer.json': JSON.stringify({
+ require: { 'stripe/stripe-php': '^12.0' },
+ }),
+ });
+ try {
+ const result = detectStripe(dir, 'php');
+ expect(result).not.toBeNull();
+ expect(result!.sdkPackage).toBe('composer.json');
+ } finally {
+ cleanup();
+ }
+ });
+ });
+
+ describe('Go', () => {
+ test('detects Stripe from go.mod', () => {
+ const { dir, cleanup } = createFixture({
+ 'go.mod': `module myapp
+
+require github.com/stripe/stripe-go/v76 v76.0.0`,
+ });
+ try {
+ const result = detectStripe(dir, 'go');
+ expect(result).not.toBeNull();
+ expect(result!.sdkPackage).toBe('go.mod');
+ } finally {
+ cleanup();
+ }
+ });
+ });
+
+ describe('Java', () => {
+ test('detects Stripe from build.gradle', () => {
+ const { dir, cleanup } = createFixture({
+ 'build.gradle': `dependencies {
+ implementation 'com.stripe:stripe-java:24.0.0'
+}`,
+ });
+ try {
+ const result = detectStripe(dir, 'java');
+ expect(result).not.toBeNull();
+ expect(result!.sdkPackage).toBe('build.gradle');
+ } finally {
+ cleanup();
+ }
+ });
+ });
+
+ describe('.NET', () => {
+ test('detects Stripe from .csproj', () => {
+ const { dir, cleanup } = createFixture({
+ 'MyApp.csproj': `
+
+
+
+`,
+ });
+ try {
+ const result = detectStripe(dir, 'dotnet');
+ expect(result).not.toBeNull();
+ expect(result!.sdkVersion).toBe('43.0.0');
+ } finally {
+ cleanup();
+ }
+ });
+ });
+
+ describe('monorepo support', () => {
+ test('detects Stripe from subdirectory package.json', () => {
+ const { dir, cleanup } = createFixture({
+ 'backend/package.json': JSON.stringify({
+ dependencies: { stripe: '^14.0.0' },
+ }),
+ });
+ try {
+ const result = detectStripe(dir, 'node');
+ expect(result).not.toBeNull();
+ expect(result!.sdkPackage).toBe('backend/package.json');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('extracts version from subdirectory lockfile', () => {
+ const { dir, cleanup } = createFixture({
+ 'backend/package.json': JSON.stringify({
+ dependencies: { stripe: '^14.0.0' },
+ }),
+ 'backend/package-lock.json': JSON.stringify({
+ packages: { 'node_modules/stripe': { version: '14.21.0' } },
+ }),
+ });
+ try {
+ const result = detectStripe(dir, 'node');
+ expect(result).not.toBeNull();
+ expect(result!.sdkVersion).toBe('14.21.0');
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('finds customer creation calls in subdirectory', () => {
+ const { dir, cleanup } = createFixture({
+ 'backend/package.json': JSON.stringify({
+ dependencies: { stripe: '^14.0.0' },
+ }),
+ 'backend/src/billing.ts': `const customer = await stripe.customers.create({
+ email: user.email,
+});`,
+ });
+ try {
+ const result = detectStripe(dir, 'node');
+ expect(result).not.toBeNull();
+ expect(result!.customerCreationCalls).toHaveLength(1);
+ expect(result!.customerCreationCalls[0].file).toBe(
+ 'backend/src/billing.ts',
+ );
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('detects Python Stripe in subdirectory', () => {
+ const { dir, cleanup } = createFixture({
+ 'server/requirements.txt': 'stripe>=5.0.0\nflask',
+ });
+ try {
+ const result = detectStripe(dir, 'python');
+ expect(result).not.toBeNull();
+ expect(result!.sdkPackage).toBe('server/requirements.txt');
+ } finally {
+ cleanup();
+ }
+ });
+ });
+});
diff --git a/src/setup-revenue-analytics/__tests__/stripe-docs-fetcher.test.ts b/src/setup-revenue-analytics/__tests__/stripe-docs-fetcher.test.ts
new file mode 100644
index 0000000..251c252
--- /dev/null
+++ b/src/setup-revenue-analytics/__tests__/stripe-docs-fetcher.test.ts
@@ -0,0 +1,81 @@
+/**
+ * We only test decodeHtmlEntities here — the fetch logic is tested
+ * via integration tests against the live Stripe docs site.
+ */
+
+// decodeHtmlEntities is not exported, so we reach it through the module internals.
+// Re-implement as a standalone to test the logic in isolation.
+// This mirrors the implementation in stripe-docs-fetcher.ts exactly.
+function decodeHtmlEntities(text: string): string {
+ let stripped = text;
+ let prev: string;
+ do {
+ prev = stripped;
+ stripped = stripped.replace(/<[^>]+>/g, '');
+ } while (stripped !== prev);
+
+ return stripped
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/'/g, "'")
+ .replace(/&/g, '&');
+}
+
+describe('decodeHtmlEntities', () => {
+ test('decodes basic HTML entities', () => {
+ expect(decodeHtmlEntities('a & b')).toBe('a & b');
+ expect(decodeHtmlEntities('a < b')).toBe('a < b');
+ expect(decodeHtmlEntities('a > b')).toBe('a > b');
+ expect(decodeHtmlEntities('"hello"')).toBe('"hello"');
+ });
+
+ test('decodes apostrophe entities', () => {
+ expect(decodeHtmlEntities('it's')).toBe("it's");
+ expect(decodeHtmlEntities('it's')).toBe("it's");
+ });
+
+ test('strips HTML tags', () => {
+ expect(decodeHtmlEntities('hello')).toBe('hello');
+ expect(decodeHtmlEntities('link')).toBe('link');
+ expect(decodeHtmlEntities('before
after')).toBe('beforeafter');
+ });
+
+ test('strips nested/reconstructed tags via loop', () => {
+ // ipt> is matched as one tag, leaving harmless "ipt>" text.
+ // The key point: no executable ');
+ expect(result).not.toContain('',
+ );
+ });
+
+ test('handles double-encoded entities', () => {
+ // < → strip tags (none present) → decode < → < (NO)
+ // We intentionally decode & last, so <script> becomes
+ // <script> rather than