From 82d5691cbeeae1e39a0f57c26b76062b92d455a1 Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Sun, 5 Jul 2026 17:54:46 -0700 Subject: [PATCH 1/7] fix(cli): register Stripe webhooks at the real CMS ingest route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI created advertiser webhook endpoints pointing at ${apiUrl}/webhooks/stripe/${program_id} — a route that does not exist in the CMS (verified: only POST /api/webhook-distributor/stripe is served; there is no per-program path). Every endpoint registered by `affitor setup stripe` therefore 404'd and its events were silently lost. Point the endpoint at the global distributor route instead. The CMS routes events by content, not by a program id in the path. Known server-side follow-up (affiliate-cms, out of scope here): the distributor verifies signatures against its env secrets only; it should also try the per-program webhook_secret that /v1/cli/stripe-connect already stores in tracking_settings. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017mS1Kn4D45SX2wVaVmfwUE --- packages/cli/src/commands/setup-stripe.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/setup-stripe.ts b/packages/cli/src/commands/setup-stripe.ts index 24b8878..3ab9441 100644 --- a/packages/cli/src/commands/setup-stripe.ts +++ b/packages/cli/src/commands/setup-stripe.ts @@ -95,7 +95,11 @@ async function runSetupStripe( } const apiUrl = flags.apiUrl ?? config.api_url; - const webhookUrl = `${apiUrl}/webhooks/stripe/${config.program_id}`; + // The CMS ingests ALL Stripe webhooks at one global route (it routes events by + // their content, not by a program id in the path). The old per-program path + // (`/webhooks/stripe/:programId`) never existed server-side — endpoints + // registered against it 404'd and every event was silently lost. + const webhookUrl = `${apiUrl}/api/webhook-distributor/stripe`; try { const totalSteps = 3; From b4a4cc7a27be09af06f08d62004ac0cbe0b43fd7 Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Sun, 5 Jul 2026 17:59:50 -0700 Subject: [PATCH 2/7] feat(cli): Polar webhook detection + conservative trackSale inject transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - detectPolarWebhook: finds @polar-sh/nextjs Webhooks() routes (the shape onboard can auto-edit) and raw @polar-sh/sdk validateEvent handlers (print-only), preferring the helper route. - injectPolarTrackSale: pure transform mirroring injectStripeTrackSale's conservatism — only edits a single unambiguous block-bodied onOrderPaid callback, binds const order = .data, refuses destructured params, expression bodies, duplicate anchors, or existing order bindings. - AffitorSecrets/config: round-trip POLAR_WEBHOOK_SECRET through .affitor/.env; new polar_* fields on AffitorConfig. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017mS1Kn4D45SX2wVaVmfwUE --- packages/cli/src/lib/config.ts | 4 + packages/cli/src/lib/inject.ts | 101 +++++++++++ packages/cli/src/lib/webhook-detect.ts | 87 ++++++++++ packages/cli/src/types.ts | 8 + .../cli/tests/inject-polar-tracksale.test.ts | 161 ++++++++++++++++++ packages/cli/tests/webhook-detect.test.ts | 64 ++++++- 6 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 packages/cli/tests/inject-polar-tracksale.test.ts diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 510f2d0..d5ece26 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -89,6 +89,7 @@ export function readSecrets(cwd?: string): AffitorSecrets | null { api_key: vars.AFFITOR_API_KEY, program_id: vars.AFFITOR_PROGRAM_ID ?? "", stripe_account_id: vars.STRIPE_CONNECTED_ACCOUNT_ID, + polar_webhook_secret: vars.POLAR_WEBHOOK_SECRET, }; } @@ -106,6 +107,9 @@ export function writeSecrets(secrets: AffitorSecrets, cwd?: string): void { if (secrets.stripe_account_id) { lines.push(`STRIPE_CONNECTED_ACCOUNT_ID=${secrets.stripe_account_id}`); } + if (secrets.polar_webhook_secret) { + lines.push(`POLAR_WEBHOOK_SECRET=${secrets.polar_webhook_secret}`); + } lines.push(""); writeFileSync(getSecretsPath(cwd), lines.join("\n")); diff --git a/packages/cli/src/lib/inject.ts b/packages/cli/src/lib/inject.ts index 079a355..731a927 100644 --- a/packages/cli/src/lib/inject.ts +++ b/packages/cli/src/lib/inject.ts @@ -185,6 +185,107 @@ function affitorImportLine(importSpecifier: string): string { * Returns `already` when a `affitor.trackSale(` / `@affitor/sdk/server` marker is * already present (idempotent re-runs do nothing). */ +// ── Server-side: inject `affitor.trackSale(...)` into a Polar webhook ── +// +// Same conservatism as the Stripe transform: we only auto-edit the ONE shape we +// can place with certainty — the `@polar-sh/nextjs` `Webhooks({ onOrderPaid })` +// route factory, whose callback receives an already-signature-verified payload. +// Raw `validateEvent` handlers are free-form → `unrecognized` (the caller +// prints the exact patch instead of guessing an edit site). + +export interface InjectPolarOpts { + /** + * The polar sale snippet from `@affitor/recipes` (`getRecipe(fw, 'polar', + * 's2s').sale!.snippet`). The `@affitor/sdk/server` import line is stripped; + * the body reads `order.*`, so the transform binds `const order = + * .data;` from the callback parameter before it. + */ + saleSnippet: string; + /** Module specifier for the `affitor` client import (same as Stripe's). */ + importSpecifier?: string; + /** Optional indent override for the inserted block (defaults to callback body). */ + indent?: string; +} + +/** + * Insert `affitor.trackSale(...)` into a `@polar-sh/nextjs` webhook route, + * inside the `onOrderPaid` callback (the payload is signature-verified there). + * + * CONSERVATIVE BY DESIGN — injects only when ALL of these hold (else + * `unrecognized`, and the caller prints the patch): + * 1. The file uses the `@polar-sh/nextjs` `Webhooks(` factory (verified + * route). Raw `validateEvent` handlers are never auto-edited. + * 2. Exactly one `onOrderPaid` reference (unambiguous anchor). + * 3. The callback is a block-bodied arrow with a single simple parameter — + * `onOrderPaid: async (payload) => {` — so we can bind + * `const order = payload.data;` at a known indent. Destructured params or + * expression bodies → unrecognized. + * 4. The file doesn't already bind `const order` (we'd shadow/collide). + * + * Returns `already` when a trackSale / server-import / client-import marker is + * present (idempotent re-runs are no-ops). + */ +export function injectPolarTrackSale(content: string, opts: InjectPolarOpts): InjectResult { + const clientImportLine = opts.importSpecifier ? affitorImportLine(opts.importSpecifier) : null; + if ( + content.includes(TRACK_SALE_MARKER) || + content.includes(SERVER_IMPORT_MARKER) || + (clientImportLine && content.includes(clientImportLine)) + ) { + return { content, status: "already", added: [] }; + } + + // (1) Must be the @polar-sh/nextjs Webhooks() factory (signature-verified). + if (!content.includes("@polar-sh/nextjs") || !content.includes("Webhooks(")) { + return { content, status: "unrecognized", added: [] }; + } + + // (2) Exactly one onOrderPaid reference to anchor on. + const anchors = content.match(/onOrderPaid/g); + if (!anchors || anchors.length !== 1) { + return { content, status: "unrecognized", added: [] }; + } + + // (3) A block-bodied arrow callback with one simple parameter (optionally + // typed inside the parens: `(payload: OrderPaidPayload) => {`). + const cbRe = + /^([ \t]*)onOrderPaid:\s*(?:async\s*)?\(?\s*([A-Za-z_$][\w$]*)\s*(?::\s*[^)=]+)?\)?\s*=>\s*\{/m; + const cb = content.match(cbRe); + if (!cb || cb.index === undefined) { + return { content, status: "unrecognized", added: [] }; + } + const param = cb[2]; + + // (4) Never shadow/collide with an existing `order` binding. + if (content.includes("const order")) { + return { content, status: "unrecognized", added: [] }; + } + + const body = saleCallBody(opts.saleSnippet); + if (!body.includes(TRACK_SALE_MARKER)) { + return { content, status: "unrecognized", added: [] }; + } + + // Insert right after the callback's opening brace, one level deeper than the + // `onOrderPaid:` property line. + const braceEnd = cb.index + cb[0].length; + const indent = opts.indent ?? `${cb[1] ?? ""} `; + const block = indentBody(body, indent); + const insertion = + `\n${indent}// Affitor: report the sale (auto-added by \`affitor onboard\`)` + + `\n${indent}const order = ${param}.data;\n${block}`; + const withSale = content.slice(0, braceEnd) + insertion + content.slice(braceEnd); + + let finalContent = withSale; + const added = insertion.split("\n").filter((l) => l.trim().length > 0); + if (clientImportLine) { + finalContent = addImport(withSale, clientImportLine); + added.unshift(clientImportLine); + } + + return { content: finalContent, status: "injected", added }; +} + export function injectStripeTrackSale(content: string, opts: InjectStripeOpts): InjectResult { // Idempotent: never double-inject. // Check for trackSale marker, server import marker, OR the affitor client import diff --git a/packages/cli/src/lib/webhook-detect.ts b/packages/cli/src/lib/webhook-detect.ts index e2e4c59..6175a7d 100644 --- a/packages/cli/src/lib/webhook-detect.ts +++ b/packages/cli/src/lib/webhook-detect.ts @@ -128,3 +128,90 @@ export function detectStripeWebhook( return null; } + +// ─── Polar ──────────────────────────────────────────────────────────── + +/** + * The two ways a Polar webhook route validates events (Standard Webhooks): + * - "nextjs_helper" — the `Webhooks({ ... })` route factory from + * `@polar-sh/nextjs` (validates the signature internally, exposes typed + * `onOrderPaid`-style callbacks). The only shape `onboard` auto-injects. + * - "raw_validate" — a hand-rolled handler calling `validateEvent` from + * `@polar-sh/sdk/webhooks`. Too free-form to edit safely → print the patch. + */ +export type PolarWebhookKind = "nextjs_helper" | "raw_validate"; + +export interface PolarWebhookLocation { + /** Path to the file containing the handler, relative to projectRoot. */ + file: string; + /** 1-based line number of the validation call. */ + line: number; + framework: Framework | "unknown"; + kind: PolarWebhookKind; + /** Human-readable description of where the trackSale call belongs. */ + handlerHint: string; +} + +/** The @polar-sh/nextjs route-factory usage (signature validation built in). */ +const POLAR_HELPER_IMPORT = "@polar-sh/nextjs"; +const POLAR_HELPER_NEEDLE = "Webhooks("; +/** The raw Standard-Webhooks validation helper from @polar-sh/sdk/webhooks. */ +const POLAR_RAW_NEEDLE = "validateEvent("; + +function polarHintFor(kind: PolarWebhookKind): string { + return kind === "nextjs_helper" + ? "inside the Webhooks({ onOrderPaid }) callback — the payload is already signature-verified there" + : "after validateEvent verifies the payload, in your order.paid branch"; +} + +/** + * Scan the project for a Polar webhook handler. Prefers the `@polar-sh/nextjs` + * `Webhooks()` factory (the shape `onboard` can auto-edit); falls back to a raw + * `validateEvent` call. Returns null when neither is found. + */ +export function detectPolarWebhook( + projectRoot: string, + framework: Framework | "unknown" = "unknown", +): PolarWebhookLocation | null { + const files: string[] = []; + collectSourceFiles(projectRoot, files); + + let raw: PolarWebhookLocation | null = null; + + for (const file of files) { + let content: string; + try { + content = readFileSync(file, "utf8"); + } catch { + continue; // unreadable file — skip + } + + const helperIdx = + content.includes(POLAR_HELPER_IMPORT) ? content.indexOf(POLAR_HELPER_NEEDLE) : -1; + if (helperIdx !== -1) { + return { + file: relative(projectRoot, file), + line: content.slice(0, helperIdx).split("\n").length, + framework, + kind: "nextjs_helper", + handlerHint: polarHintFor("nextjs_helper"), + }; + } + + // Remember the first raw handler, but keep scanning for a helper route. + if (!raw) { + const rawIdx = content.indexOf(POLAR_RAW_NEEDLE); + if (rawIdx !== -1) { + raw = { + file: relative(projectRoot, file), + line: content.slice(0, rawIdx).split("\n").length, + framework, + kind: "raw_validate", + handlerHint: polarHintFor("raw_validate"), + }; + } + } + } + + return raw; +} diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 231df68..afc446a 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -15,6 +15,12 @@ export interface AffitorConfig { ref_param: string; stripe_connected?: boolean; stripe_account_id?: string; + polar_connected?: boolean; + /** "production" | "sandbox" — which Polar environment the webhook lives in. */ + polar_environment?: string; + polar_webhook_endpoint_id?: string; + /** The advertiser-app URL the Polar webhook endpoint delivers to. */ + polar_webhook_url?: string; api_url: string; created_at: string; // v1 only — removed in v2 (moved to .affitor/.env) @@ -25,6 +31,8 @@ export interface AffitorSecrets { api_key: string; program_id: string; stripe_account_id?: string; + /** Polar webhook signing secret (returned once at endpoint creation). */ + polar_webhook_secret?: string; } export interface UserCredentials { diff --git a/packages/cli/tests/inject-polar-tracksale.test.ts b/packages/cli/tests/inject-polar-tracksale.test.ts new file mode 100644 index 0000000..936b7bb --- /dev/null +++ b/packages/cli/tests/inject-polar-tracksale.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import { getRecipe } from "@affitor/recipes"; +import { injectPolarTrackSale } from "../src/lib/inject"; + +// The canonical Polar sale snippet, sourced from the recipe registry (the same +// thing `affitor onboard` injects). Body reads `order.*`. +const SALE_SNIPPET = getRecipe("next-app", "polar", "s2s").sale!.snippet; + +const IMPORT_SPECIFIER = "@/lib/affitor"; +const IMPORT_LINE = `import { affitor } from '${IMPORT_SPECIFIER}';`; + +// A clean @polar-sh/nextjs webhook route — the only shape we auto-edit. +const CLEAN_HELPER_ROUTE = `import { Webhooks } from '@polar-sh/nextjs'; + +export const POST = Webhooks({ + webhookSecret: process.env.POLAR_WEBHOOK_SECRET!, + onOrderPaid: async (payload) => { + console.log('paid', payload.data.id); + }, +}); +`; + +describe("injectPolarTrackSale — injected (clean helper route)", () => { + it("binds `const order = payload.data` and inserts trackSale inside onOrderPaid", () => { + const r = injectPolarTrackSale(CLEAN_HELPER_ROUTE, { saleSnippet: SALE_SNIPPET }); + expect(r.status).toBe("injected"); + expect(r.content).toContain("const order = payload.data;"); + expect(r.content).toContain("await affitor.trackSale({"); + expect(r.content).toContain("Affitor: report the sale"); + + // Binding lands inside the callback, before the existing body. + const cbIdx = r.content.indexOf("onOrderPaid:"); + const bindIdx = r.content.indexOf("const order = payload.data;"); + const saleIdx = r.content.indexOf("await affitor.trackSale({"); + const existingIdx = r.content.indexOf("console.log('paid'"); + expect(cbIdx).toBeLessThan(bindIdx); + expect(bindIdx).toBeLessThan(saleIdx); + expect(saleIdx).toBeLessThan(existingIdx); + + // Indented one level deeper than the `onOrderPaid:` property (2 → 4 spaces). + expect(r.content).toMatch(/\n {4}const order = payload\.data;/); + }); + + it("uses the callback's own parameter name for the binding", () => { + const renamed = CLEAN_HELPER_ROUTE.replace(/payload/g, "evt"); + const r = injectPolarTrackSale(renamed, { saleSnippet: SALE_SNIPPET }); + expect(r.status).toBe("injected"); + expect(r.content).toContain("const order = evt.data;"); + }); + + it("handles a typed parameter — (payload: OrderPaidPayload) => {", () => { + const typed = CLEAN_HELPER_ROUTE.replace( + "async (payload) =>", + "async (payload: WebhookOrderPaidPayload) =>", + ); + const r = injectPolarTrackSale(typed, { saleSnippet: SALE_SNIPPET }); + expect(r.status).toBe("injected"); + expect(r.content).toContain("const order = payload.data;"); + }); + + it("adds the affitor client import when importSpecifier is given", () => { + const r = injectPolarTrackSale(CLEAN_HELPER_ROUTE, { + saleSnippet: SALE_SNIPPET, + importSpecifier: IMPORT_SPECIFIER, + }); + expect(r.status).toBe("injected"); + expect(r.content).toContain(IMPORT_LINE); + expect(r.content.indexOf(IMPORT_LINE)).toBeLessThan(r.content.indexOf("await affitor.trackSale({")); + expect(r.added[0]).toBe(IMPORT_LINE); + }); + + it("does not strip the original code", () => { + const r = injectPolarTrackSale(CLEAN_HELPER_ROUTE, { saleSnippet: SALE_SNIPPET }); + expect(r.content).toContain("webhookSecret: process.env.POLAR_WEBHOOK_SECRET!"); + expect(r.content).toContain("console.log('paid', payload.data.id);"); + }); +}); + +describe("injectPolarTrackSale — already (idempotent)", () => { + it("is a no-op on a second run", () => { + const once = injectPolarTrackSale(CLEAN_HELPER_ROUTE, { + saleSnippet: SALE_SNIPPET, + importSpecifier: IMPORT_SPECIFIER, + }).content; + const twice = injectPolarTrackSale(once, { + saleSnippet: SALE_SNIPPET, + importSpecifier: IMPORT_SPECIFIER, + }); + expect(twice.status).toBe("already"); + expect(twice.content).toBe(once); + expect(twice.added).toHaveLength(0); + // Exactly one trackSale call and one import line survived. + expect((twice.content.match(/affitor\.trackSale\(/g) ?? []).length).toBe(1); + }); + + it("treats an @affitor/sdk/server import as already-wired", () => { + const withImport = `import { Affitor } from '@affitor/sdk/server';\n${CLEAN_HELPER_ROUTE}`; + expect(injectPolarTrackSale(withImport, { saleSnippet: SALE_SNIPPET }).status).toBe("already"); + }); +}); + +describe("injectPolarTrackSale — unrecognized (conservative, prints patch)", () => { + it("bails on a raw validateEvent handler (not the helper factory)", () => { + const raw = `import { validateEvent } from '@polar-sh/sdk/webhooks'; +export async function POST(req) { + const event = validateEvent(await req.text(), headers, secret); + if (event.type === 'order.paid') handle(event.data); +} +`; + const r = injectPolarTrackSale(raw, { saleSnippet: SALE_SNIPPET }); + expect(r.status).toBe("unrecognized"); + expect(r.content).toBe(raw); + }); + + it("bails when there is no onOrderPaid callback", () => { + const noPaid = `import { Webhooks } from '@polar-sh/nextjs'; +export const POST = Webhooks({ + webhookSecret: process.env.POLAR_WEBHOOK_SECRET!, + onSubscriptionCreated: async (payload) => {}, +}); +`; + expect(injectPolarTrackSale(noPaid, { saleSnippet: SALE_SNIPPET }).status).toBe("unrecognized"); + }); + + it("bails when onOrderPaid appears more than once (ambiguous)", () => { + const dup = CLEAN_HELPER_ROUTE + "\n// see onOrderPaid above\n"; + expect(injectPolarTrackSale(dup, { saleSnippet: SALE_SNIPPET }).status).toBe("unrecognized"); + }); + + it("bails on a destructured callback parameter", () => { + const destructured = CLEAN_HELPER_ROUTE.replace("async (payload) =>", "async ({ data }) =>"); + expect(injectPolarTrackSale(destructured, { saleSnippet: SALE_SNIPPET }).status).toBe( + "unrecognized", + ); + }); + + it("bails on an expression-bodied callback (no block to insert into)", () => { + const expr = `import { Webhooks } from '@polar-sh/nextjs'; +export const POST = Webhooks({ + webhookSecret: process.env.POLAR_WEBHOOK_SECRET!, + onOrderPaid: async (payload) => console.log(payload.data.id), +}); +`; + expect(injectPolarTrackSale(expr, { saleSnippet: SALE_SNIPPET }).status).toBe("unrecognized"); + }); + + it("bails when the file already binds `const order` (would collide)", () => { + const withOrder = CLEAN_HELPER_ROUTE.replace( + "console.log('paid', payload.data.id);", + "const order = payload.data;\n console.log('paid', order.id);", + ); + expect(injectPolarTrackSale(withOrder, { saleSnippet: SALE_SNIPPET }).status).toBe( + "unrecognized", + ); + }); + + it("bails when the provided snippet is not a trackSale call", () => { + const r = injectPolarTrackSale(CLEAN_HELPER_ROUTE, { saleSnippet: "console.log('nope');" }); + expect(r.status).toBe("unrecognized"); + }); +}); diff --git a/packages/cli/tests/webhook-detect.test.ts b/packages/cli/tests/webhook-detect.test.ts index 6de2430..78b6467 100644 --- a/packages/cli/tests/webhook-detect.test.ts +++ b/packages/cli/tests/webhook-detect.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { detectStripeWebhook } from "../src/lib/webhook-detect"; +import { detectPolarWebhook, detectStripeWebhook } from "../src/lib/webhook-detect"; const dirs: string[] = []; @@ -94,3 +94,65 @@ describe("detectStripeWebhook", () => { expect(detectStripeWebhook(dir, "node")).toBeNull(); }); }); + +// A @polar-sh/nextjs helper route — the shape onboard can auto-edit. +const POLAR_HELPER_ROUTE = `import { Webhooks } from '@polar-sh/nextjs'; + +export const POST = Webhooks({ + webhookSecret: process.env.POLAR_WEBHOOK_SECRET!, + onOrderPaid: async (payload) => { + console.log('paid', payload.data.id); + }, +}); +`; + +// A raw handler validating with @polar-sh/sdk/webhooks — print-only. +const POLAR_RAW_ROUTE = `import { validateEvent } from '@polar-sh/sdk/webhooks'; + +export async function POST(req: Request) { + const event = validateEvent(await req.text(), Object.fromEntries(req.headers), process.env.POLAR_WEBHOOK_SECRET!); + if (event.type === 'order.paid') handle(event.data); + return new Response('', { status: 202 }); +} +`; + +describe("detectPolarWebhook", () => { + it("finds the @polar-sh/nextjs Webhooks() route (kind nextjs_helper)", () => { + const dir = project({ "app/api/polar/webhook/route.ts": POLAR_HELPER_ROUTE }); + const hit = detectPolarWebhook(dir, "next-app"); + expect(hit).not.toBeNull(); + expect(hit!.kind).toBe("nextjs_helper"); + expect(hit!.file).toBe(join("app", "api", "polar", "webhook", "route.ts")); + expect(hit!.line).toBe(3); // line of the Webhooks( call + expect(hit!.handlerHint).toContain("onOrderPaid"); + }); + + it("finds a raw validateEvent handler (kind raw_validate)", () => { + const dir = project({ "app/api/polar/webhook/route.ts": POLAR_RAW_ROUTE }); + const hit = detectPolarWebhook(dir, "next-app"); + expect(hit).not.toBeNull(); + expect(hit!.kind).toBe("raw_validate"); + expect(hit!.line).toBe(4); // line of the validateEvent call + }); + + it("prefers the helper route over an earlier raw handler", () => { + const dir = project({ + "app/api/other/route.ts": POLAR_RAW_ROUTE, + "src/polar/webhook.ts": POLAR_HELPER_ROUTE, + }); + const hit = detectPolarWebhook(dir, "next-app"); + expect(hit!.kind).toBe("nextjs_helper"); + }); + + it("does not treat a Webhooks( call without the @polar-sh/nextjs import as a helper route", () => { + const dir = project({ + "src/other.ts": "export const POST = Webhooks({});\n", // some other lib + }); + expect(detectPolarWebhook(dir, "next-app")).toBeNull(); + }); + + it("returns null when no Polar webhook exists", () => { + const dir = project({ "src/server.ts": "export const x = 1;\n" }); + expect(detectPolarWebhook(dir, "next-app")).toBeNull(); + }); +}); From 83c5ed10cb6d4ad978713a81c2fa54a6741ec1d8 Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Sun, 5 Jul 2026 18:01:28 -0700 Subject: [PATCH 3/7] feat(cli): onboard auto-injects the Polar sale call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wireServerSale now routes per provider: Stripe (unchanged) and Polar get the auto-edit path (detect → pure transform → diff + confirm → write, --json stays no-auto-edit); everything else keeps the printed recipe. Polar specifics: only the @polar-sh/nextjs Webhooks({ onOrderPaid }) helper route is edited (payload is signature-verified inside it); raw validateEvent handlers get the exact printed patch; when no webhook route exists the CLI points at `affitor setup polar` instead of a paste. Shared tail (already/unrecognized/diff/confirm/write) extracted to applyInjectResult; import-specifier math to computeImportSpecifier. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017mS1Kn4D45SX2wVaVmfwUE --- packages/cli/src/commands/onboard.ts | 142 ++++++++++++++++++++++----- 1 file changed, 119 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 7ee599a..308a4ef 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -10,8 +10,8 @@ import { resolveApiKey } from "../lib/config.js"; import { confirmAction } from "../lib/prompts.js"; import { detectStack, type Framework } from "../lib/stack-detect.js"; import { detectPaymentProvider } from "../lib/server-tracking.js"; -import { detectStripeWebhook } from "../lib/webhook-detect.js"; -import { injectStripeTrackSale } from "../lib/inject.js"; +import { detectPolarWebhook, detectStripeWebhook } from "../lib/webhook-detect.js"; +import { injectPolarTrackSale, injectStripeTrackSale, type InjectResult } from "../lib/inject.js"; import { runInstallWizard } from "../lib/wizard.js"; import { DEFAULT_API_URL, type CLIFlags, type ReadinessResult, type TestChainStatus } from "../types.js"; @@ -164,30 +164,39 @@ interface WireSaleArgs { } /** - * The safety-critical part: locate the Stripe webhook, run the PURE - * `injectStripeTrackSale` transform, and: + * The safety-critical part: locate the provider webhook, run the PURE inject + * transform, and: * - `injected` → show the DIFF and confirm before writing. * - `already` → no-op (idempotent). * - `unrecognized` → PRINT the exact patch (snippet + inject_target + file:line). - * - no webhook → PRINT the full recipe. - * NEVER auto-edits when the structure isn't cleanly recognized. + * - no webhook → PRINT the full recipe (Polar: + point at `setup polar`). + * NEVER auto-edits when the structure isn't cleanly recognized. Stripe and + * Polar have auto-edit paths; other providers print the recipe. */ async function wireServerSale(args: WireSaleArgs): Promise { - const { cwd, framework, provider, interactive, autoConfirm, json } = args; + const { framework, provider, json } = args; - // Only Stripe has the auto-edit path (the recipe's sale snippet is keyed to - // the verified checkout.session.completed event object). Other providers → - // print the recipe (no reliable inject site). // Use 's2s' mode so a sale snippet exists (Connect mode = metadata only). const mode: Mode = "s2s"; - const recipe = getRecipe(toRecipeFramework(framework), provider === "stripe" ? "stripe" : "unknown", mode); + const recipeProvider = + provider === "stripe" || provider === "polar" ? provider : "unknown"; + const recipe = getRecipe(toRecipeFramework(framework), recipeProvider, mode); - if (provider !== "stripe") { - if (!json) { - printRecipe(recipe, null, "Server-side sale (printed — review and add)"); - } - return { step: "server_sale", status: "manual", detail: `provider=${provider}: printed recipe` }; + if (provider === "stripe") return wireStripeSale(args, recipe); + if (provider === "polar") return wirePolarSale(args, recipe); + + // Other providers → print the recipe (no reliable inject site). + if (!json) { + printRecipe(recipe, null, "Server-side sale (printed — review and add)"); } + return { step: "server_sale", status: "manual", detail: `provider=${provider}: printed recipe` }; +} + +async function wireStripeSale( + args: WireSaleArgs, + recipe: ReturnType, +): Promise { + const { cwd, framework, json } = args; const hook = detectStripeWebhook(cwd, framework); @@ -207,9 +216,82 @@ async function wireServerSale(args: WireSaleArgs): Promise { return { step: "server_sale", status: "manual", detail: `unreadable ${hook.file}: printed recipe` }; } - // Compute the import specifier from the webhook file's directory to the - // scaffolded server client (lib/affitor.ts or src/lib/affitor.ts). - // Mirror the path logic from wizard.ts: prefer src/lib when src/ exists. + const importSpecifier = computeImportSpecifier(cwd, fileAbs); + const saleSnippet = recipe.sale?.snippet ?? ""; + const result = injectStripeTrackSale(original, { saleSnippet, importSpecifier }); + + return applyInjectResult(args, recipe, hook, fileAbs, result, "Stripe"); +} + +/** + * Polar mirror of the Stripe path. The auto-edit only targets the + * `@polar-sh/nextjs` `Webhooks({ onOrderPaid })` helper route (the payload is + * signature-verified inside it); raw `validateEvent` handlers get the printed + * patch. When NO webhook route exists at all, the fix isn't a paste — it's + * `affitor setup polar` (creates the Polar endpoint + generates the route), so + * point there. + */ +async function wirePolarSale( + args: WireSaleArgs, + recipe: ReturnType, +): Promise { + const { cwd, framework, json } = args; + + const hook = detectPolarWebhook(cwd, framework); + + if (!hook) { + if (!json) { + printRecipe(recipe, null, "Polar sale (no webhook route found)"); + logger.info( + ` ${format.bold("Tip:")} run ${format.cyan("npx affitor setup polar")} — it creates the Polar webhook`, + ); + logger.info(` endpoint on your org and generates this route wired for Affitor.`); + logger.newline(); + } + return { + step: "server_sale", + status: "manual", + detail: "no polar webhook route found: run `affitor setup polar`", + }; + } + + if (hook.kind === "raw_validate") { + // A hand-rolled validateEvent handler — too free-form to edit payment code. + if (!json) { + logger.warn( + `Found your Polar webhook (${format.green(`${hook.file}:${hook.line}`)}) — a raw validateEvent handler we won't auto-edit.`, + ); + printRecipe(recipe, hook, "Add the sale call yourself (exact patch)"); + } + return { + step: "server_sale", + status: "manual", + detail: `raw validateEvent handler in ${hook.file}: printed patch`, + }; + } + + const fileAbs = join(cwd, hook.file); + let original: string; + try { + original = readFileSync(fileAbs, "utf8"); + } catch { + if (!json) printRecipe(recipe, hook, "Polar sale (couldn't read the webhook file — add manually)"); + return { step: "server_sale", status: "manual", detail: `unreadable ${hook.file}: printed recipe` }; + } + + const importSpecifier = computeImportSpecifier(cwd, fileAbs); + const saleSnippet = recipe.sale?.snippet ?? ""; + const result = injectPolarTrackSale(original, { saleSnippet, importSpecifier }); + + return applyInjectResult(args, recipe, hook, fileAbs, result, "Polar"); +} + +/** + * Compute the import specifier from the webhook file's directory to the + * scaffolded server client (lib/affitor.ts or src/lib/affitor.ts). + * Mirror the path logic from wizard.ts: prefer src/lib when src/ exists. + */ +function computeImportSpecifier(cwd: string, fileAbs: string): string { const clientDir = existsSync(join(cwd, "src")) ? join(cwd, "src", "lib") : join(cwd, "lib"); const clientAbs = join(clientDir, "affitor.ts"); const webhookDir = dirname(fileAbs); @@ -217,9 +299,23 @@ async function wireServerSale(args: WireSaleArgs): Promise { // Ensure POSIX separators and a leading ./ or ../ importSpecifier = importSpecifier.replace(/\\/g, "/"); if (!importSpecifier.startsWith(".")) importSpecifier = `./${importSpecifier}`; + return importSpecifier; +} - const saleSnippet = recipe.sale?.snippet ?? ""; - const result = injectStripeTrackSale(original, { saleSnippet, importSpecifier }); +/** + * Shared tail of the Stripe/Polar auto-edit paths: handle the transform verdict + * (already / unrecognized / injected → DIFF + confirm → write). `--json` mode + * never edits files — the step is reported `manual` so agents drive the edit. + */ +async function applyInjectResult( + args: WireSaleArgs, + recipe: ReturnType, + hook: { file: string; line: number; handlerHint: string }, + fileAbs: string, + result: InjectResult, + providerLabel: string, +): Promise { + const { interactive, autoConfirm, json } = args; if (result.status === "already") { if (!json) logger.step(`${hook.file} already reports the sale — skipped`); @@ -230,7 +326,7 @@ async function wireServerSale(args: WireSaleArgs): Promise { // NEVER force-edit payment code we can't place confidently — print the patch. if (!json) { logger.warn( - `Found your Stripe webhook (${format.green(`${hook.file}:${hook.line}`)}) but couldn't place the sale call safely.`, + `Found your ${providerLabel} webhook (${format.green(`${hook.file}:${hook.line}`)}) but couldn't place the sale call safely.`, ); printRecipe(recipe, hook, "Add the sale call yourself (exact patch)"); } @@ -250,7 +346,7 @@ async function wireServerSale(args: WireSaleArgs): Promise { if (!ok) { if (!json) { logger.step("Skipped. Add the sale call when ready:"); - printRecipe(recipe, hook, "Stripe sale (skipped — add manually)"); + printRecipe(recipe, hook, `${providerLabel} sale (skipped — add manually)`); } return { step: "server_sale", status: "skipped", detail: `${hook.file}: user declined` }; } From b4d3693dacbdfc943070ab0abe3cf7c1b80a342d Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Sun, 5 Jul 2026 18:06:32 -0700 Subject: [PATCH 4/7] feat(recipes): Polar SDK-parsed contract + generated webhook glue route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix the Polar sale snippet's field contract: the @polar-sh helpers (Webhooks()/validateEvent) deliver SDK-parsed camelCase objects, so the old raw-JSON snake_case reads (order.total_amount, order.subscription_id, order.customer_id) were undefined in exactly the handlers we target — losing amount and attribution. Now reads order.totalAmount / subscriptionId / customerId, adds the clickId carrier (metadata.affitor_click_id ?? metadata.reference_id — the zero-code checkout-link param Polar propagates to orders and renewals), currency, isRecurring + subscriptionId for subscription orders. - Metadata step documents both carriers (server-side metadata and ?reference_id= checkout links). - New getWebhookRoute(framework, provider): the full self-hosted Polar × next-app glue route (app/api/polar/webhook/route.ts) composed from the SAME canonical sale body — Standard-Webhooks validation via @polar-sh/nextjs, /bin/zsh guard, 409-tolerant redeliveries, order.refunded → trackRefund. POLAR_WEBHOOK_EVENTS exported for the CLI. - Tests pin the camelCase contract and the route invariants; updated the CLI fixture that encoded the old snake_case contract. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017mS1Kn4D45SX2wVaVmfwUE --- packages/cli/tests/server-tracking.test.ts | 8 +- packages/recipes/src/index.ts | 143 ++++++++++++++++++++- packages/recipes/tests/recipes.test.ts | 97 +++++++++++++- 3 files changed, 239 insertions(+), 9 deletions(-) diff --git a/packages/cli/tests/server-tracking.test.ts b/packages/cli/tests/server-tracking.test.ts index 7a9ae16..362e908 100644 --- a/packages/cli/tests/server-tracking.test.ts +++ b/packages/cli/tests/server-tracking.test.ts @@ -65,11 +65,15 @@ describe("serverTrackingSnippets", () => { expect(s.lead).toContain("trackLead({ customerExternalId: user.id, clickId: cookies.affitor_click_id })"); }); - it("Polar → order.paid + total_amount + order.id", () => { + it("Polar → order.paid + SDK camelCase totalAmount + order.id", () => { + // The @polar-sh SDK helpers (Webhooks()/validateEvent) deliver camelCase + // parsed objects — order.totalAmount, NOT the raw JSON's total_amount. const s = serverTrackingSnippets("polar"); expect(s.saleContext).toContain("order.paid"); - expect(s.sale).toContain("amount: order.total_amount"); + expect(s.sale).toContain("amount: order.totalAmount"); expect(s.sale).toContain("invoiceId: order.id"); + expect(s.sale).toContain("order.metadata?.reference_id"); + expect(s.sale).not.toContain("total_amount"); }); it("Lemon Squeezy → order_created + data.attributes.total + meta.custom_data", () => { diff --git a/packages/recipes/src/index.ts b/packages/recipes/src/index.ts index 00e15c4..fd04fbc 100644 --- a/packages/recipes/src/index.ts +++ b/packages/recipes/src/index.ts @@ -107,13 +107,19 @@ const PROVIDER_METADATA: Record, { why: string; snip polar: { why: "Plant attribution on the Polar checkout/order so the webhook (order.paid) " + - "can resolve the customer. Pass your user id as order metadata at checkout creation.", + "can resolve the customer. Pass your user id as order metadata at checkout " + + "creation — or, with zero server code, carry the click id on the checkout LINK: " + + "Polar copies a `?reference_id=` query param into order.metadata.reference_id " + + "on every order, renewals included.", snippet: [ - "// When creating the Polar checkout, attach your user id + click id as metadata:", + "// When creating the Polar checkout server-side, attach metadata:", "metadata: {", " affitor_click_id: affitorClickId, // from the `affitor_click_id` cookie", " user_id: user.id, // resolved back as order.metadata.user_id", "}", + "// OR (checkout links, no server code): append the cookie value to the link —", + "// /checkout?products=&reference_id=", + "// Polar propagates it to order.metadata.reference_id (and to subscription renewals).", ].join("\n"), }, lemonsqueezy: { @@ -160,12 +166,24 @@ const SDK_IMPORT = "import { Affitor } from '@affitor/sdk/server';"; * registry is the canonical home; the CLI now sources its `sale` from here. */ const SALE_SNIPPET_BODY: Record = { + // Field names are the TypeScript SDK's camelCase: the @polar-sh/nextjs + // Webhooks() helper and @polar-sh/sdk validateEvent both parse the raw + // snake_case webhook JSON into typed camelCase objects (order.totalAmount, + // not order.total_amount). Amounts are already integer cents. + // `order` is the verified payload's data (e.g. `payload.data` in onOrderPaid). polar: [ "await affitor.trackSale({", - " customerExternalId: order.metadata.user_id ?? order.customer_id,", - " amount: order.total_amount, // integer cents", - " invoiceId: order.id,", - " saleType: order.subscription_id ? 'subscription' : 'payment',", + " // user_id metadata is planted at checkout creation; customerId is Polar's fallback.", + " customerExternalId: (order.metadata?.user_id as string | undefined) ?? order.customerId,", + " // reference_id = the zero-server-code checkout-link carrier (?reference_id=).", + " clickId: (order.metadata?.affitor_click_id ?? order.metadata?.reference_id) as string | undefined,", + " amount: order.totalAmount, // integer cents", + " currency: order.currency,", + " invoiceId: order.id, // idempotency key — 409 = already recorded", + " saleType: order.subscriptionId ? 'subscription' : 'payment',", + " // Renewals also arrive as order.paid (metadata propagates) — same handler covers them.", + " isRecurring: Boolean(order.subscriptionId),", + " subscriptionId: order.subscriptionId ?? undefined,", "});", ].join("\n"), lemonsqueezy: [ @@ -317,6 +335,119 @@ export function getRecipe(framework: Framework, provider: Provider, mode: Mode): }; } +// ─── Self-hosted webhook glue routes ───────────────────────────────── +// +// For providers WITHOUT an Affitor-hosted relay (Polar today), the advertiser +// hosts a small webhook route that validates the provider's signature and +// reports the sale/refund to Affitor. `getWebhookRoute` returns that file, +// generated from the SAME snippet constants as the printed recipes, so the +// contract can never drift between `setup polar`, `onboard`, MCP and docs. + +/** The Polar webhook events Affitor tracking needs. */ +export const POLAR_WEBHOOK_EVENTS = ["order.paid", "order.refunded"] as const; + +export interface WebhookRouteEnvVar { + name: string; + why: string; +} + +export interface WebhookRoute { + provider: Provider; + framework: Framework; + /** + * Project-relative file path (App Router path; callers living under `src/` + * prefix it themselves — the CLI does). + */ + path: string; + /** Full file source, ready to write as a NEW file. */ + source: string; + /** Packages the route imports. */ + deps: string[]; + /** Env vars the route reads (set in the app's .env, never committed). */ + env: WebhookRouteEnvVar[]; + /** Provider webhook events the endpoint must subscribe to. */ + events: string[]; +} + +/** Polar × Next.js App Router glue route (the only generated shape today). */ +function polarNextAppRoute(): string { + // The sale body is the canonical SALE_SNIPPET_BODY.polar, indented into the + // onOrderPaid callback where `order` is bound from the verified payload. + const saleBody = SALE_SNIPPET_BODY.polar + .split("\n") + .map((l) => (l.length ? ` ${l}` : l)) + .join("\n"); + + return [ + "import { Webhooks } from '@polar-sh/nextjs';", + "import { Affitor } from '@affitor/sdk/server';", + "", + "/**", + " * Polar → Affitor webhook glue — generated by `affitor setup polar`.", + " *", + " * The Webhooks() helper validates the Standard-Webhooks signature with", + " * POLAR_WEBHOOK_SECRET before any callback runs, and parses the payload", + " * into the SDK's typed camelCase objects. Renewals need nothing extra:", + " * Polar fires order.paid every billing cycle and checkout metadata", + " * (incl. a checkout link's ?reference_id=) propagates to those orders.", + " */", + "const affitor = new Affitor({ apiKey: process.env.AFFITOR_API_KEY ?? '' });", + "", + "export const POST = Webhooks({", + " webhookSecret: process.env.POLAR_WEBHOOK_SECRET!,", + " onOrderPaid: async (payload) => {", + " const order = payload.data;", + " // Skip $0 orders (free tiers, 100%-off) — no revenue to attribute.", + " if (!order.totalAmount || order.totalAmount <= 0) return;", + " // Report the sale. trackSale resolves an {ok} envelope (it does not", + " // throw on API errors) — never fail the payment webhook over tracking.", + saleBody.replace(/^\s{4}await affitor\.trackSale\(\{/m, " const res = await affitor.trackSale({"), + " if (!res.ok && res.status !== 409) {", + " // 409 = duplicate invoiceId (webhook redelivery) — already recorded.", + " console.error('[affitor] trackSale failed', res.status, res.error);", + " }", + " },", + " onOrderRefunded: async (payload) => {", + " const order = payload.data;", + " // Full refund → Affitor reverses the commission (idempotent by invoiceId).", + " const res = await affitor.trackRefund({ invoiceId: order.id });", + " if (!res.ok) {", + " console.error('[affitor] trackRefund failed', res.status, res.error);", + " }", + " },", + "});", + "", + ].join("\n"); +} + +/** + * The self-hosted webhook glue route for a stack, or null when no generated + * shape exists (caller falls back to the printed recipe). Polar × next-app + * only today — the Ship Kit / `affitor setup polar` target. + */ +export function getWebhookRoute(framework: Framework, provider: Provider): WebhookRoute | null { + if (provider !== "polar" || framework !== "next-app") return null; + + return { + provider, + framework, + path: "app/api/polar/webhook/route.ts", + source: polarNextAppRoute(), + deps: ["@polar-sh/nextjs", "@affitor/sdk"], + env: [ + { + name: "POLAR_WEBHOOK_SECRET", + why: "Signing secret returned when the webhook endpoint is created — validates every delivery.", + }, + { + name: "AFFITOR_API_KEY", + why: "Program API key (server-only) — authenticates trackSale/trackRefund.", + }, + ], + events: [...POLAR_WEBHOOK_EVENTS], + }; +} + /** * Build an ordered, human + agent readable integration plan for a stack. * Mode defaults to "stripe_connect". Pure. diff --git a/packages/recipes/tests/recipes.test.ts b/packages/recipes/tests/recipes.test.ts index 85ef0fd..426e71b 100644 --- a/packages/recipes/tests/recipes.test.ts +++ b/packages/recipes/tests/recipes.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { getRecipe, getIntegrationPlan } from "../src/index.js"; +import { + getRecipe, + getIntegrationPlan, + getWebhookRoute, + POLAR_WEBHOOK_EVENTS, +} from "../src/index.js"; describe("getRecipe — install + verify (invariant)", () => { it("install is always `npm i @affitor/sdk` and verify mentions the synthetic chain + readiness", () => { @@ -148,3 +153,93 @@ describe("getRecipe — subscription renewals (#3 invoice.paid)", () => { expect(plan.steps.some((s) => s.startsWith) && plan.steps.some((s) => /Renewals:/.test(s))).toBe(true); }); }); + +describe("getRecipe — Polar sale snippet (SDK-parsed camelCase contract)", () => { + // Field names verified against the @polar-sh SDK-parsed payload (the + // Webhooks()/validateEvent helpers deliver camelCase objects — the raw + // webhook JSON's snake_case names would be undefined there). + const sale = getRecipe("next-app", "polar", "s2s").sale!.snippet; + + it("reads the SDK's camelCase order fields, never raw snake_case", () => { + expect(sale).toContain("order.totalAmount"); + expect(sale).toContain("order.subscriptionId"); + expect(sale).toContain("order.customerId"); + expect(sale).not.toContain("total_amount"); + expect(sale).not.toContain("subscription_id ?"); + expect(sale).not.toContain("customer_id"); + }); + + it("resolves the customer from the planted metadata, falling back to Polar's customerId", () => { + expect(sale).toContain("order.metadata?.user_id"); + expect(sale).toContain("?? order.customerId"); + }); + + it("carries the click id — planted metadata first, checkout-link reference_id fallback", () => { + expect(sale).toContain("order.metadata?.affitor_click_id"); + expect(sale).toContain("order.metadata?.reference_id"); + }); + + it("marks subscription orders as recurring with the subscription id (renewals ride order.paid)", () => { + expect(sale).toContain("saleType: order.subscriptionId ? 'subscription' : 'payment'"); + expect(sale).toContain("isRecurring: Boolean(order.subscriptionId)"); + expect(sale).toContain("invoiceId: order.id"); + }); + + it("metadata step documents BOTH carriers: server-side metadata and ?reference_id= links", () => { + const metadata = getRecipe("next-app", "polar", "s2s").metadata; + expect(metadata.snippet).toContain("affitor_click_id: affitorClickId"); + expect(metadata.snippet).toContain("user_id: user.id"); + expect(metadata.snippet).toContain("reference_id="); + expect(metadata.why).toContain("order.metadata.reference_id"); + }); +}); + +describe("getWebhookRoute — Polar × next-app glue route", () => { + it("returns the generated route for polar/next-app only", () => { + expect(getWebhookRoute("next-app", "polar")).not.toBeNull(); + expect(getWebhookRoute("next-pages", "polar")).toBeNull(); + expect(getWebhookRoute("fastify", "polar")).toBeNull(); + expect(getWebhookRoute("next-app", "stripe")).toBeNull(); + expect(getWebhookRoute("unknown", "polar")).toBeNull(); + }); + + it("route metadata: App Router path, deps, env vars, events", () => { + const route = getWebhookRoute("next-app", "polar")!; + expect(route.path).toBe("app/api/polar/webhook/route.ts"); + expect(route.deps).toContain("@polar-sh/nextjs"); + expect(route.deps).toContain("@affitor/sdk"); + expect(route.env.map((e) => e.name)).toEqual(["POLAR_WEBHOOK_SECRET", "AFFITOR_API_KEY"]); + expect(route.events).toEqual(["order.paid", "order.refunded"]); + expect([...POLAR_WEBHOOK_EVENTS]).toEqual(["order.paid", "order.refunded"]); + }); + + it("source validates the signature via Webhooks() and reads env, not literals", () => { + const src = getWebhookRoute("next-app", "polar")!.source; + expect(src).toContain("import { Webhooks } from '@polar-sh/nextjs';"); + expect(src).toContain("import { Affitor } from '@affitor/sdk/server';"); + expect(src).toContain("webhookSecret: process.env.POLAR_WEBHOOK_SECRET!"); + expect(src).toContain("process.env.AFFITOR_API_KEY"); + }); + + it("source embeds the SAME canonical sale body as the printed recipe (no drift)", () => { + const src = getWebhookRoute("next-app", "polar")!.source; + // Spot-check the contract-bearing lines from SALE_SNIPPET_BODY.polar. + expect(src).toContain("order.metadata?.affitor_click_id ?? order.metadata?.reference_id"); + expect(src).toContain("amount: order.totalAmount"); + expect(src).toContain("saleType: order.subscriptionId ? 'subscription' : 'payment'"); + }); + + it("source guards $0 orders, tolerates 409 redeliveries, and handles refunds", () => { + const src = getWebhookRoute("next-app", "polar")!.source; + expect(src).toContain("if (!order.totalAmount || order.totalAmount <= 0) return;"); + expect(src).toContain("res.status !== 409"); + expect(src).toContain("onOrderRefunded"); + expect(src).toContain("trackRefund({ invoiceId: order.id })"); + }); + + it("source contains the trackSale marker onboard uses for idempotency detection", () => { + // `injectPolarTrackSale` / onboard treat `affitor.trackSale(` as already-wired. + const src = getWebhookRoute("next-app", "polar")!.source; + expect(src).toContain("affitor.trackSale({"); + }); +}); From 6e13d7137dbefddc1398bdd450b51789cee954d1 Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Sun, 5 Jul 2026 19:15:56 -0700 Subject: [PATCH 5/7] =?UTF-8?q?feat(cli):=20affitor=20setup=20polar=20?= =?UTF-8?q?=E2=80=94=20one-command=20Polar=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `affitor setup polar` (registered on the setup group): - Creates the webhook endpoint on the advertiser's Polar org (POST /v1/webhooks/endpoints, format raw, events order.paid + order.refunded) via a lean fetch client — no @polar-sh/sdk dependency for three REST calls. --sandbox targets sandbox-api.polar.sh (separate tokens); token from --token / POLAR_ACCESS_TOKEN / masked prompt. - Idempotent: an endpoint already delivering to the target URL is reused (the list response includes the signing secret, so re-runs recover it); missing events are PATCHed in as a union; same-URL+env re-run early-exits already_connected like setup stripe. - Persists the signing secret to .affitor/.env (gitignored) and the connection to config.json (polar_connected/environment/endpoint/url); writes POLAR_WEBHOOK_SECRET into the app's .env/.env.local (never overwrites a different value). - Generates the glue route from @affitor/recipes getWebhookRoute (new-file-only; an existing route is never overwritten — onboard's inject path handles those). --json performs no app-source writes and ships {route: {path, source, deps, env}, steps, next_actions} for agents, mirroring onboard's contract. Verified end-to-end against a mock Polar API (create/reuse/PATCH paths, json + interactive modes, idempotent re-runs) and the generated AND onboard-injected routes typecheck clean (tsc strict) against the real published @polar-sh/nextjs@0.9.6 + @affitor/sdk@2.1.0 types. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017mS1Kn4D45SX2wVaVmfwUE --- packages/cli/src/commands/setup-polar.ts | 414 ++++++++++++++++++++++ packages/cli/src/commands/setup-stripe.ts | 5 +- packages/cli/src/lib/polar-api.ts | 136 +++++++ packages/cli/src/lib/prompts.ts | 11 +- packages/cli/tests/polar-api.test.ts | 137 +++++++ 5 files changed, 701 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/commands/setup-polar.ts create mode 100644 packages/cli/src/lib/polar-api.ts create mode 100644 packages/cli/tests/polar-api.test.ts diff --git a/packages/cli/src/commands/setup-polar.ts b/packages/cli/src/commands/setup-polar.ts new file mode 100644 index 0000000..fb14c06 --- /dev/null +++ b/packages/cli/src/commands/setup-polar.ts @@ -0,0 +1,414 @@ +import type { Command } from "commander"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { + getRecipe, + getWebhookRoute, + POLAR_WEBHOOK_EVENTS, + type WebhookRoute, +} from "@affitor/recipes"; +import * as logger from "../lib/logger.js"; +import { format } from "../lib/logger.js"; +import { + readConfig, + updateConfig, + readSecrets, + writeSecrets, + resolveApiKey, + ConfigNotFoundError, +} from "../lib/config.js"; +import { + PolarAPI, + PolarAPIError, + POLAR_API_PROD, + POLAR_API_SANDBOX, + type PolarWebhookEndpoint, +} from "../lib/polar-api.js"; +import { detectStack } from "../lib/stack-detect.js"; +import { promptPolarToken } from "../lib/prompts.js"; +import { getFlags } from "../lib/flags.js"; +import type { CLIFlags } from "../types.js"; + +interface SetupPolarOpts { + token?: string; + sandbox?: boolean; + url?: string; +} + +/** A machine-readable step in the --json summary (mirrors onboard's shape). */ +interface SetupStep { + step: string; + status: "ok" | "skipped" | "manual" | "already" | "failed"; + detail?: string; +} + +export function registerSetupPolarCommand(setup: Command) { + setup + .command("polar") + .description( + "Connect Polar: create the webhook endpoint on your org + generate the Affitor glue route", + ) + .option("--token ", "Polar Organization Access Token (or POLAR_ACCESS_TOKEN env)") + .option("--sandbox", "Use the Polar sandbox environment (sandbox-api.polar.sh)", false) + .option( + "--url ", + "Webhook delivery URL (default: https:///api/polar/webhook)", + ) + .action(async (opts: SetupPolarOpts, cmd) => { + await runSetupPolar(opts, getFlags(cmd)); + }); +} + +async function runSetupPolar(opts: SetupPolarOpts, flags: CLIFlags) { + const cwd = process.cwd(); + + // ── Config (program identity) ── + let config; + try { + config = readConfig(); + } catch (err) { + if (err instanceof ConfigNotFoundError) { + logger.error(err.message); + if (flags.json) logger.json({ error: "no_config" }); + process.exit(1); + } + throw err; + } + + const environment = opts.sandbox ? "sandbox" : "production"; + const webhookUrl = opts.url ?? `https://${config.domain}/api/polar/webhook`; + + // ── Idempotent early-exit (mirrors `setup stripe`) ── + // Same URL + same environment + a stored secret → nothing to do. + const secrets = readSecrets(); + if ( + config.polar_connected && + config.polar_environment === environment && + config.polar_webhook_url === webhookUrl && + secrets?.polar_webhook_secret + ) { + logger.warn("Polar is already connected."); + logger.info(` Endpoint: ${config.polar_webhook_endpoint_id} (${environment})`); + logger.info(` Delivers to: ${webhookUrl}`); + logger.info(" Re-run with --url to point somewhere else."); + if (flags.json) { + logger.json({ + status: "already_connected", + environment, + webhook_endpoint_id: config.polar_webhook_endpoint_id, + webhook_url: webhookUrl, + }); + } + process.exit(0); + } + + // ── Token (flag > env > prompt; agents must pass it explicitly) ── + let token = opts.token ?? process.env.POLAR_ACCESS_TOKEN; + if (!token && !flags.json && !flags.noInteractive) { + token = await promptPolarToken(!!opts.sandbox); + } + if (!token) { + logger.error( + "Polar access token not provided.\n" + + " Pass --token or set POLAR_ACCESS_TOKEN.\n" + + " Create an Organization Access Token in the Polar dashboard\n" + + " (Settings → Developers). Sandbox tokens are separate (--sandbox).", + ); + if (flags.json) logger.json({ error: "missing_polar_token" }); + process.exit(1); + } + + // POLAR_API_BASE is a test seam (mock server) — not documented in --help. + const baseUrl = + process.env.POLAR_API_BASE ?? (opts.sandbox ? POLAR_API_SANDBOX : POLAR_API_PROD); + const polar = new PolarAPI(token, baseUrl); + const steps: SetupStep[] = []; + const totalSteps = 3; + + try { + logger.newline(); + + // ── (1) Create (or reuse) the webhook endpoint on the advertiser's org ── + const { endpoint, created } = await ensureWebhookEndpoint(polar, webhookUrl); + steps.push({ + step: "webhook_endpoint", + status: created ? "ok" : "already", + detail: `${endpoint.id} → ${webhookUrl} [${endpoint.events.join(", ")}]`, + }); + logger.progressStep(1, totalSteps, created ? "Webhook endpoint created" : "Webhook endpoint reused", true); + + // ── (2) Persist the signing secret + connection state ── + const secretStep = persistSecret(endpoint.secret, config.program_id, flags); + steps.push(secretStep); + + updateConfig({ + polar_connected: true, + polar_environment: environment, + polar_webhook_endpoint_id: endpoint.id, + polar_webhook_url: webhookUrl, + }); + logger.progressStep(2, totalSteps, "Connection saved", true); + + // ── (3) Generate the glue route (new file only; --json never writes) ── + const stack = detectStack(cwd); + const route = getWebhookRoute(stack.framework, "polar"); + const routeStep = route + ? writeGlueRoute(cwd, route, flags) + : ({ step: "glue_route", status: "manual", detail: `framework=${stack.framework}: printed recipe` } as SetupStep); + steps.push(routeStep); + + // The app runtime needs the secret too (the route reads POLAR_WEBHOOK_SECRET). + const envStep = writeAppEnvVar(cwd, "POLAR_WEBHOOK_SECRET", endpoint.secret, flags); + steps.push(envStep); + logger.progressStep(3, totalSteps, "Glue route + env", true); + + // ── Summary ── + if (flags.json) { + logger.json({ + status: "connected", + environment, + webhook_endpoint_id: endpoint.id, + webhook_url: webhookUrl, + events: endpoint.events, + // The secret is NOT echoed; it is written to .affitor/.env (gitignored). + secret_persisted: secretStep.status === "ok" || secretStep.status === "already", + steps, + ...(route + ? { + route: { + path: routeStep.detail?.startsWith("src/") ? routeStep.detail : route.path, + source: route.source, + deps: route.deps, + env: route.env, + }, + } + : {}), + next_actions: nextActions(route, routeStep, stack.framework), + }); + return; + } + + printSummary({ environment, webhookUrl, endpoint, route, routeStep, cwd }); + } catch (err) { + if (err instanceof PolarAPIError) { + logger.error(err.message); + if (flags.json) logger.json({ error: "polar_api_error", status: err.status, message: err.message }); + } else { + logger.error(`Unexpected error: ${(err as Error).message}`); + if (flags.json) logger.json({ error: "unexpected_error", message: (err as Error).message }); + } + process.exit(1); + } +} + +/** + * Find an endpoint already delivering to `webhookUrl` (idempotent re-runs — + * the list response includes the signing secret, so we can recover it), else + * create one. An existing endpoint missing our events gets them PATCHed in + * (union — never drops events the advertiser added for other purposes). + */ +async function ensureWebhookEndpoint( + polar: PolarAPI, + webhookUrl: string, +): Promise<{ endpoint: PolarWebhookEndpoint; created: boolean }> { + const existing = (await polar.listWebhookEndpoints()).find((e) => e.url === webhookUrl); + + if (!existing) { + const endpoint = await polar.createWebhookEndpoint({ + url: webhookUrl, + events: [...POLAR_WEBHOOK_EVENTS], + }); + return { endpoint, created: true }; + } + + const missing = POLAR_WEBHOOK_EVENTS.filter((e) => !existing.events.includes(e)); + if (missing.length === 0) return { endpoint: existing, created: false }; + + const updated = await polar.updateWebhookEndpointEvents(existing.id, [ + ...existing.events, + ...missing, + ]); + // PATCH responses include the (unchanged) secret; fall back to the listed one. + return { endpoint: { ...updated, secret: updated.secret ?? existing.secret }, created: false }; +} + +/** + * Store the signing secret in `.affitor/.env` (gitignored — never committed). + * Merges into existing secrets; when no secrets file exists yet, creates one + * with the resolved API key so `readSecrets` keeps working. If no API key can + * be resolved at all, we don't write a broken file — the caller still gets the + * secret via the app-env write + summary. + */ +function persistSecret(secret: string, programId: string, flags: CLIFlags): SetupStep { + const existing = readSecrets(); + if (existing) { + if (existing.polar_webhook_secret === secret) { + return { step: "affitor_secret", status: "already", detail: ".affitor/.env" }; + } + writeSecrets({ ...existing, polar_webhook_secret: secret }); + return { step: "affitor_secret", status: "ok", detail: ".affitor/.env" }; + } + + const apiKey = resolveApiKey({ apiKey: flags.apiKey }); + if (apiKey) { + writeSecrets({ api_key: apiKey, program_id: String(programId), polar_webhook_secret: secret }); + return { step: "affitor_secret", status: "ok", detail: ".affitor/.env (created)" }; + } + + return { + step: "affitor_secret", + status: "manual", + detail: "no .affitor/.env and no API key resolved — secret written to app env only", + }; +} + +/** + * Write the generated webhook route as a NEW file (never overwrites — an + * existing file is the advertiser's payment code; `affitor onboard` handles + * injecting into it). Honors the App Router living under `src/`. In --json + * mode nothing is written (the route ships in the JSON for the agent to apply). + */ +function writeGlueRoute(cwd: string, route: WebhookRoute, flags: CLIFlags): SetupStep { + const relPath = existsSync(join(cwd, "src", "app")) ? join("src", route.path) : route.path; + const absPath = join(cwd, relPath); + + if (existsSync(absPath)) { + const content = safeRead(absPath); + if (content.includes("affitor.trackSale(")) { + if (!flags.json) logger.step(`${relPath} already reports the sale — skipped`); + return { step: "glue_route", status: "already", detail: relPath }; + } + if (!flags.json) { + logger.warn(`${relPath} exists but doesn't report the sale to Affitor.`); + logger.step(`Run ${format.cyan("npx affitor onboard")} to inject the trackSale call.`); + } + return { step: "glue_route", status: "manual", detail: `${relPath} exists: run onboard to inject` }; + } + + if (flags.json) { + return { step: "glue_route", status: "manual", detail: `${relPath}: json mode (route in payload)` }; + } + + mkdirSync(dirname(absPath), { recursive: true }); + writeFileSync(absPath, route.source, "utf8"); + logger.success(`Created ${relPath}`); + return { step: "glue_route", status: "ok", detail: relPath }; +} + +/** + * Persist an env var into the app's `.env.local` / `.env` (the runtime needs + * POLAR_WEBHOOK_SECRET). Mirrors onboard's writeApiKeyToEnv contract: never + * overwrites a different existing value, never writes in --json mode. + */ +function writeAppEnvVar(cwd: string, name: string, value: string, flags: CLIFlags): SetupStep { + const envName = existsSync(join(cwd, ".env.local")) ? ".env.local" : ".env"; + const envPath = join(cwd, envName); + const line = `${name}=${value}`; + + let content = ""; + if (existsSync(envPath)) { + content = safeRead(envPath); + const existing = content.match(new RegExp(`^${name}=(.*)$`, "m")); + if (existing) { + if (existing[1].trim() === value) { + if (!flags.json) logger.step(`${envName} already has ${name} — skipped`); + return { step: "app_env", status: "already", detail: envName }; + } + if (!flags.json) { + logger.warn(`${envName} already has a different ${name} — left unchanged.`); + } + return { step: "app_env", status: "manual", detail: `${envName}: existing value kept` }; + } + } + + if (flags.json) { + return { step: "app_env", status: "manual", detail: `${envName}: json mode (no auto-edit)` }; + } + + const needsNewline = content.length > 0 && !content.endsWith("\n"); + writeFileSync(envPath, `${content}${needsNewline ? "\n" : ""}${line}\n`, "utf8"); + logger.success(`Wrote ${name} to ${envName}`); + return { step: "app_env", status: "ok", detail: envName }; +} + +function safeRead(path: string): string { + try { + return readFileSync(path, "utf8"); + } catch { + return ""; + } +} + +/** Agent-facing follow-ups for the --json payload. */ +function nextActions( + route: WebhookRoute | null, + routeStep: SetupStep, + framework: string, +): string[] { + const actions: string[] = []; + if (route && routeStep.status === "manual" && routeStep.detail?.includes("json mode")) { + actions.push(`Write route.source to ${routeStep.detail.split(":")[0]} (new file).`); + } + if (route && routeStep.detail?.includes("run onboard")) { + actions.push("Run `affitor onboard` to inject trackSale into the existing route."); + } + if (route) { + actions.push(`Install route deps if missing: npm i ${route.deps.join(" ")}`); + } else { + actions.push( + `No generated route for framework=${framework} — follow the printed recipe / ` + + "affitor_get_integration_plan(framework, 'polar', 's2s').", + ); + } + actions.push("Set POLAR_WEBHOOK_SECRET and AFFITOR_API_KEY in the app's runtime env."); + actions.push("Verify: `affitor test sale`, then poll readiness until integration_verified."); + return actions; +} + +function printSummary(args: { + environment: string; + webhookUrl: string; + endpoint: PolarWebhookEndpoint; + route: WebhookRoute | null; + routeStep: SetupStep; + cwd: string; +}): void { + const { environment, webhookUrl, endpoint, route, routeStep } = args; + + logger.titledBox("Polar Connected", [ + "", + ` Environment: ${format.bold(environment)}`, + ` Endpoint: ${format.bold(endpoint.id)}`, + ` Delivers to: ${format.cyan(webhookUrl)}`, + "", + ` ${format.bold("Events subscribed:")}`, + ` ${format.green("✓")} order.paid ${format.dim("→ sale + renewal tracking")}`, + ` ${format.green("✓")} order.refunded ${format.dim("→ auto clawback")}`, + "", + ` Signing secret saved to ${format.cyan(".affitor/.env")} ${format.dim("(gitignored)")}`, + "", + ]); + + if (route) { + if (routeStep.status === "ok") { + logger.info(` Glue route created: ${format.green(routeStep.detail ?? route.path)}`); + logger.info(` Install its deps if missing: ${format.dim("$")} npm i ${route.deps.join(" ")}`); + } + } else { + // No generated route for this framework — print the canonical snippets. + const recipe = getRecipe("unknown", "polar", "s2s"); + const lines: string[] = ["", ` ${format.dim("1) At checkout creation — plant attribution metadata:")}`]; + for (const l of recipe.metadata.snippet.split("\n")) lines.push(` ${format.cyan(l)}`); + if (recipe.sale) { + lines.push("", ` ${format.dim("2) In your order.paid webhook handler — report the sale:")}`); + for (const l of recipe.sale.snippet.split("\n")) lines.push(` ${format.cyan(l)}`); + } + lines.push(""); + logger.titledBox("Wire the webhook route (no generated route for this stack)", lines); + } + + logger.newline(); + logger.info(` Next: make sure your deploy env has ${format.cyan("POLAR_WEBHOOK_SECRET")} + ${format.cyan("AFFITOR_API_KEY")}.`); + logger.info(` Test it: ${format.dim("$")} npx affitor test sale`); + logger.newline(); +} diff --git a/packages/cli/src/commands/setup-stripe.ts b/packages/cli/src/commands/setup-stripe.ts index 3ab9441..a77d023 100644 --- a/packages/cli/src/commands/setup-stripe.ts +++ b/packages/cli/src/commands/setup-stripe.ts @@ -5,12 +5,13 @@ import { readConfig, updateConfig, writeSecrets, readSecrets, ConfigNotFoundErro import { runStripeOAuth, StripeOAuthError } from "../lib/stripe-oauth.js"; import { AffitorAPI, APIError, NetworkError } from "../lib/api-client.js"; import { getFlags } from "../lib/flags.js"; +import { registerSetupPolarCommand } from "./setup-polar.js"; import type { CLIFlags } from "../types.js"; export function registerSetupCommand(program: Command) { const setup = program .command("setup") - .description("Set up integrations (stripe, dns)"); + .description("Set up integrations (stripe, polar, dns)"); setup .command("stripe") @@ -21,6 +22,8 @@ export function registerSetupCommand(program: Command) { await runSetupStripe(opts, getFlags(cmd)); }); + registerSetupPolarCommand(setup); + setup .command("dns") .description("Set up DNS CNAME tracking (coming soon)") diff --git a/packages/cli/src/lib/polar-api.ts b/packages/cli/src/lib/polar-api.ts new file mode 100644 index 0000000..703d033 --- /dev/null +++ b/packages/cli/src/lib/polar-api.ts @@ -0,0 +1,136 @@ +/** + * Minimal Polar management-API client for `affitor setup polar`. Raw fetch — + * the CLI doesn't take a dependency on @polar-sh/sdk for three REST calls. + * + * Contract (verified against polar.sh/docs 2026-07-05): + * - Auth: `Authorization: Bearer `. + * - POST /v1/webhooks/endpoints {url, format:'raw', events[]} → 201 incl. + * the server-generated signing `secret`. + * - GET /v1/webhooks/endpoints?page&limit → {items[] (each incl. secret), + * pagination:{max_page}} — re-runs recover the secret from here. + * - PATCH /v1/webhooks/endpoints/{id} {events} — extend an existing endpoint. + * - Sandbox is a separate environment (own tokens): sandbox-api.polar.sh. + */ + +export const POLAR_API_PROD = "https://api.polar.sh"; +export const POLAR_API_SANDBOX = "https://sandbox-api.polar.sh"; + +export interface PolarWebhookEndpoint { + id: string; + url: string; + name?: string | null; + format: string; + /** Signing secret — returned by create AND list/get (recoverable on re-run). */ + secret: string; + events: string[]; + enabled?: boolean; + organization_id?: string; +} + +interface ListResponse { + items: PolarWebhookEndpoint[]; + pagination?: { total_count?: number; max_page?: number }; +} + +export class PolarAPIError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + this.name = "PolarAPIError"; + } +} + +export class PolarAPI { + constructor( + private token: string, + private baseUrl: string = POLAR_API_PROD, + ) {} + + /** All webhook endpoints on the org (paginates; endpoint count is tiny). */ + async listWebhookEndpoints(): Promise { + const items: PolarWebhookEndpoint[] = []; + let page = 1; + let maxPage = 1; + do { + const res = await this.request( + `/v1/webhooks/endpoints?page=${page}&limit=100`, + ); + items.push(...(res.items ?? [])); + maxPage = res.pagination?.max_page ?? 1; + page += 1; + } while (page <= maxPage); + return items; + } + + async createWebhookEndpoint(opts: { + url: string; + events: string[]; + }): Promise { + return this.request("/v1/webhooks/endpoints", { + method: "POST", + body: { url: opts.url, format: "raw", events: opts.events }, + }); + } + + /** Extend an existing endpoint's event subscriptions (secret is untouched). */ + async updateWebhookEndpointEvents( + id: string, + events: string[], + ): Promise { + return this.request(`/v1/webhooks/endpoints/${id}`, { + method: "PATCH", + body: { events }, + }); + } + + private async request( + path: string, + opts: { method?: string; body?: Record } = {}, + ): Promise { + let res: Response; + try { + res = await fetch(`${this.baseUrl}${path}`, { + method: opts.method ?? "GET", + headers: { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + "User-Agent": "affitor-cli", + }, + body: opts.body ? JSON.stringify(opts.body) : undefined, + }); + } catch (err) { + throw new PolarAPIError(0, `Network error calling Polar: ${(err as Error).message}`); + } + + const body = (await res.json().catch(() => ({}))) as Record; + + if (!res.ok) { + throw new PolarAPIError(res.status, polarErrorMessage(res.status, body)); + } + + return body as T; + } +} + +/** Human-readable message from Polar's error body (401/403/422 shapes vary). */ +function polarErrorMessage(status: number, body: Record): string { + if (status === 401) { + return ( + "Polar rejected the access token (401). Use an Organization Access Token " + + "(Polar dashboard → Settings → Developers). Sandbox and production tokens " + + "are separate — pass --sandbox for a sandbox token." + ); + } + const detail = body.detail ?? body.error ?? body.message; + if (typeof detail === "string") return `Polar API ${status}: ${detail}`; + if (Array.isArray(detail)) { + // 422 validation errors: [{loc, msg, type}, ...] + const msgs = detail + .map((d) => (d && typeof d === "object" && "msg" in d ? (d as { msg: string }).msg : null)) + .filter(Boolean); + if (msgs.length) return `Polar API ${status}: ${msgs.join("; ")}`; + } + return `Polar API returned ${status}`; +} diff --git a/packages/cli/src/lib/prompts.ts b/packages/cli/src/lib/prompts.ts index 3844513..9a2e3b0 100644 --- a/packages/cli/src/lib/prompts.ts +++ b/packages/cli/src/lib/prompts.ts @@ -1,6 +1,15 @@ -import { input, select, confirm } from "@inquirer/prompts"; +import { input, select, confirm, password } from "@inquirer/prompts"; import type { CommissionType } from "../types.js"; +/** Masked prompt for a Polar Organization Access Token (never echoed). */ +export async function promptPolarToken(sandbox: boolean): Promise { + return password({ + message: `Polar Organization Access Token${sandbox ? " (sandbox)" : ""}:`, + mask: "*", + validate: (v) => (v.trim().length > 0 ? true : "Token is required"), + }); +} + export async function promptProgramName(): Promise { return input({ message: "Program name:", diff --git a/packages/cli/tests/polar-api.test.ts b/packages/cli/tests/polar-api.test.ts new file mode 100644 index 0000000..e0d5d39 --- /dev/null +++ b/packages/cli/tests/polar-api.test.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PolarAPI, PolarAPIError, POLAR_API_SANDBOX } from "../src/lib/polar-api"; + +interface Call { + url: string; + init: RequestInit; +} + +/** Stub fetch with a queue of responses; records every call. */ +function stubFetch(responses: Array<{ status: number; body: unknown }>): Call[] { + const calls: Call[] = []; + let i = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: RequestInit) => { + calls.push({ url, init }); + const r = responses[Math.min(i, responses.length - 1)]; + i += 1; + return { + ok: r.status >= 200 && r.status < 300, + status: r.status, + json: async () => r.body, + }; + }), + ); + return calls; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const ENDPOINT = { + id: "wh_1", + url: "https://example.com/api/polar/webhook", + format: "raw", + secret: "polar_whs_test", + events: ["order.paid", "order.refunded"], +}; + +describe("PolarAPI.createWebhookEndpoint", () => { + it("POSTs /v1/webhooks/endpoints with format raw + events, Bearer auth; returns the secret", async () => { + const calls = stubFetch([{ status: 201, body: ENDPOINT }]); + const api = new PolarAPI("tok_123", POLAR_API_SANDBOX); + const res = await api.createWebhookEndpoint({ + url: ENDPOINT.url, + events: ["order.paid", "order.refunded"], + }); + + expect(res.secret).toBe("polar_whs_test"); + expect(calls[0].url).toBe(`${POLAR_API_SANDBOX}/v1/webhooks/endpoints`); + expect(calls[0].init.method).toBe("POST"); + expect((calls[0].init.headers as Record).Authorization).toBe("Bearer tok_123"); + const body = JSON.parse(calls[0].init.body as string); + expect(body).toEqual({ + url: ENDPOINT.url, + format: "raw", + events: ["order.paid", "order.refunded"], + }); + }); +}); + +describe("PolarAPI.listWebhookEndpoints", () => { + it("paginates until max_page and flattens items (each item carries the secret)", async () => { + const calls = stubFetch([ + { status: 200, body: { items: [ENDPOINT], pagination: { max_page: 2 } } }, + { status: 200, body: { items: [{ ...ENDPOINT, id: "wh_2" }], pagination: { max_page: 2 } } }, + ]); + const api = new PolarAPI("tok", POLAR_API_SANDBOX); + const items = await api.listWebhookEndpoints(); + + expect(items.map((i) => i.id)).toEqual(["wh_1", "wh_2"]); + expect(items[0].secret).toBe("polar_whs_test"); + expect(calls[0].url).toContain("page=1&limit=100"); + expect(calls[1].url).toContain("page=2&limit=100"); + }); +}); + +describe("PolarAPI.updateWebhookEndpointEvents", () => { + it("PATCHes the endpoint with the merged events list", async () => { + const calls = stubFetch([{ status: 200, body: ENDPOINT }]); + const api = new PolarAPI("tok", POLAR_API_SANDBOX); + await api.updateWebhookEndpointEvents("wh_1", ["order.paid", "order.refunded"]); + + expect(calls[0].url).toBe(`${POLAR_API_SANDBOX}/v1/webhooks/endpoints/wh_1`); + expect(calls[0].init.method).toBe("PATCH"); + expect(JSON.parse(calls[0].init.body as string)).toEqual({ + events: ["order.paid", "order.refunded"], + }); + }); +}); + +describe("PolarAPI errors", () => { + it("401 → actionable Organization Access Token guidance", async () => { + stubFetch([{ status: 401, body: {} }]); + const api = new PolarAPI("bad", POLAR_API_SANDBOX); + await expect(api.listWebhookEndpoints()).rejects.toMatchObject({ + status: 401, + message: expect.stringContaining("Organization Access Token"), + }); + }); + + it("422 → joins validation detail messages", async () => { + stubFetch([ + { + status: 422, + body: { detail: [{ loc: ["body", "url"], msg: "invalid url", type: "value_error" }] }, + }, + ]); + const api = new PolarAPI("tok", POLAR_API_SANDBOX); + await expect( + api.createWebhookEndpoint({ url: "nope", events: ["order.paid"] }), + ).rejects.toMatchObject({ status: 422, message: expect.stringContaining("invalid url") }); + }); + + it("string detail bodies surface directly", async () => { + stubFetch([{ status: 403, body: { detail: "Missing scope webhooks:write" } }]); + const api = new PolarAPI("tok", POLAR_API_SANDBOX); + await expect(api.listWebhookEndpoints()).rejects.toMatchObject({ + status: 403, + message: expect.stringContaining("webhooks:write"), + }); + }); + + it("network failure → PolarAPIError with status 0", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("ECONNREFUSED"); + }), + ); + const api = new PolarAPI("tok", POLAR_API_SANDBOX); + await expect(api.listWebhookEndpoints()).rejects.toSatisfy( + (e: unknown) => e instanceof PolarAPIError && e.status === 0, + ); + }); +}); From fc8ea1c93913ad1c4151a109fbdcb97da8160352 Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Sun, 5 Jul 2026 19:16:04 -0700 Subject: [PATCH 6/7] feat(recipes): precise Polar customer + renewal resolution (verified contract) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against the live openapi.json + polar-js source (Zod $inboundSchema confirms snake_case wire → camelCase SDK objects): - customerExternalId falls back metadata.user_id → customer.externalId (the advertiser's own user id when checkout passes customerExternalId) → customerId. - isRecurring now uses billingReason === 'subscription_cycle' (exact OrderBillingReason enum) instead of inferring from subscriptionId, which mislabeled first subscription payments as renewals. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017mS1Kn4D45SX2wVaVmfwUE --- packages/recipes/src/index.ts | 8 +++++--- packages/recipes/tests/recipes.test.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/recipes/src/index.ts b/packages/recipes/src/index.ts index fd04fbc..6acba6d 100644 --- a/packages/recipes/src/index.ts +++ b/packages/recipes/src/index.ts @@ -173,8 +173,10 @@ const SALE_SNIPPET_BODY: Record = { // `order` is the verified payload's data (e.g. `payload.data` in onOrderPaid). polar: [ "await affitor.trackSale({", - " // user_id metadata is planted at checkout creation; customerId is Polar's fallback.", - " customerExternalId: (order.metadata?.user_id as string | undefined) ?? order.customerId,", + " // user_id metadata is planted at checkout creation; customer.externalId is", + " // YOUR user id if you pass customerExternalId at checkout; customerId is Polar's id.", + " customerExternalId: (order.metadata?.user_id as string | undefined)", + " ?? order.customer?.externalId ?? order.customerId,", " // reference_id = the zero-server-code checkout-link carrier (?reference_id=).", " clickId: (order.metadata?.affitor_click_id ?? order.metadata?.reference_id) as string | undefined,", " amount: order.totalAmount, // integer cents", @@ -182,7 +184,7 @@ const SALE_SNIPPET_BODY: Record = { " invoiceId: order.id, // idempotency key — 409 = already recorded", " saleType: order.subscriptionId ? 'subscription' : 'payment',", " // Renewals also arrive as order.paid (metadata propagates) — same handler covers them.", - " isRecurring: Boolean(order.subscriptionId),", + " isRecurring: order.billingReason === 'subscription_cycle',", " subscriptionId: order.subscriptionId ?? undefined,", "});", ].join("\n"), diff --git a/packages/recipes/tests/recipes.test.ts b/packages/recipes/tests/recipes.test.ts index 426e71b..d29b427 100644 --- a/packages/recipes/tests/recipes.test.ts +++ b/packages/recipes/tests/recipes.test.ts @@ -169,8 +169,9 @@ describe("getRecipe — Polar sale snippet (SDK-parsed camelCase contract)", () expect(sale).not.toContain("customer_id"); }); - it("resolves the customer from the planted metadata, falling back to Polar's customerId", () => { + it("resolves the customer: planted metadata → customer.externalId → Polar's customerId", () => { expect(sale).toContain("order.metadata?.user_id"); + expect(sale).toContain("order.customer?.externalId"); expect(sale).toContain("?? order.customerId"); }); @@ -179,9 +180,11 @@ describe("getRecipe — Polar sale snippet (SDK-parsed camelCase contract)", () expect(sale).toContain("order.metadata?.reference_id"); }); - it("marks subscription orders as recurring with the subscription id (renewals ride order.paid)", () => { + it("marks renewals precisely via billingReason (renewals ride order.paid)", () => { expect(sale).toContain("saleType: order.subscriptionId ? 'subscription' : 'payment'"); - expect(sale).toContain("isRecurring: Boolean(order.subscriptionId)"); + // billing_reason enum (openapi): purchase | subscription_create | + // subscription_cycle | subscription_update — only _cycle is a renewal. + expect(sale).toContain("isRecurring: order.billingReason === 'subscription_cycle'"); expect(sale).toContain("invoiceId: order.id"); }); From 1d7766730fcab7283bd810b97a52d8e430717e01 Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Sun, 5 Jul 2026 19:20:44 -0700 Subject: [PATCH 7/7] chore: bump recipes 0.2.0, cli 0.5.0, mcp 0.2.1 for the Polar release recipes 0.2.0 (getWebhookRoute + fixed Polar contract), cli 0.5.0 (setup polar + onboard Polar inject + Stripe ingest-URL fix), mcp 0.2.1 (republish against recipes ^0.2.0 so affitor_get_integration_plan serves the corrected Polar snippet). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017mS1Kn4D45SX2wVaVmfwUE --- package-lock.json | 10 +++++----- packages/cli/package.json | 6 +++--- packages/mcp/package.json | 6 +++--- packages/recipes/package.json | 4 ++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 620958b..7539c82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3407,10 +3407,10 @@ }, "packages/cli": { "name": "affitor", - "version": "0.3.1", + "version": "0.5.0", "license": "MIT", "dependencies": { - "@affitor/recipes": "*", + "@affitor/recipes": "^0.2.0", "@inquirer/prompts": "^7.5.0", "commander": "^13.1.0", "open": "^10.1.0", @@ -3431,10 +3431,10 @@ }, "packages/mcp": { "name": "@affitor/mcp", - "version": "0.1.0", + "version": "0.2.1", "license": "MIT", "dependencies": { - "@affitor/recipes": "*", + "@affitor/recipes": "^0.2.0", "@affitor/sdk": "^2.1.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^3.23.8" @@ -3453,7 +3453,7 @@ }, "packages/recipes": { "name": "@affitor/recipes", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 3f00982..aab3e79 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "affitor", - "version": "0.4.1", - "description": "CLI-native affiliate tracking. Connect Stripe, track commissions, manage partners — all from your terminal.", + "version": "0.5.0", + "description": "CLI-native affiliate tracking. Connect Stripe, track commissions, manage partners \u2014 all from your terminal.", "type": "module", "bin": { "affitor": "./dist/cli.js" @@ -45,7 +45,7 @@ "picocolors": "^1.1.1", "open": "^10.1.0", "stripe": "^17.7.0", - "@affitor/recipes": "^0.1.0" + "@affitor/recipes": "^0.2.0" }, "devDependencies": { "typescript": "^5.7.0", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 3c3882b..06ec18b 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,7 +1,7 @@ { "name": "@affitor/mcp", - "version": "0.2.0", - "description": "Model Context Protocol (MCP) stdio server for Affitor — lets AI agents (Claude Desktop, Cursor, …) call Affitor affiliate-tracking capabilities as tools.", + "version": "0.2.1", + "description": "Model Context Protocol (MCP) stdio server for Affitor \u2014 lets AI agents (Claude Desktop, Cursor, \u2026) call Affitor affiliate-tracking capabilities as tools.", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -49,7 +49,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@affitor/sdk": "^2.1.0", - "@affitor/recipes": "^0.1.0", + "@affitor/recipes": "^0.2.0", "zod": "^3.23.8" }, "devDependencies": { diff --git a/packages/recipes/package.json b/packages/recipes/package.json index 4a82d7b..c291a26 100644 --- a/packages/recipes/package.json +++ b/packages/recipes/package.json @@ -1,7 +1,7 @@ { "name": "@affitor/recipes", - "version": "0.1.0", - "description": "Canonical per-stack payment-tracking recipe registry for Affitor — the single source of truth read by the CLI, the MCP server, and the docs. Pure data, no runtime deps.", + "version": "0.2.0", + "description": "Canonical per-stack payment-tracking recipe registry for Affitor \u2014 the single source of truth read by the CLI, the MCP server, and the docs. Pure data, no runtime deps.", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts",