Skip to content

feat: affitor setup polar + onboard Polar auto-inject (one-command non-Stripe payments)#24

Open
sonpiaz wants to merge 7 commits into
mainfrom
feat/polar-setup
Open

feat: affitor setup polar + onboard Polar auto-inject (one-command non-Stripe payments)#24
sonpiaz wants to merge 7 commits into
mainfrom
feat/polar-setup

Conversation

@sonpiaz

@sonpiaz sonpiaz commented Jul 6, 2026

Copy link
Copy Markdown
Member

Why

Mandeck's ship-paid-saas skill ships paid SaaS on Polar (merchant of record — no Stripe) with every sale reported to Affitor via POST /api/v1/track/sale. Until now only Stripe had first-class CLI tooling; for Polar the CLI degraded to printing snippets. This PR gives Polar the same one-command experience — and structurally paves the path for other self-hosted-webhook providers.

What

affitor setup polar (new, cli 0.5.0)

  • Creates the webhook endpoint on the advertiser's Polar org (POST /v1/webhooks/endpoints, format: raw, events order.paid + order.refunded) pointing at their app (default https://<domain>/api/polar/webhook, --url to override). --sandbox targets sandbox-api.polar.sh. Token via --token / POLAR_ACCESS_TOKEN / masked prompt (needs webhooks:write scope).
  • Persists the signing secret to .affitor/.env (gitignored) + writes POLAR_WEBHOOK_SECRET into the app's .env(.local) (never overwrites a different value).
  • Generates the glue route from the recipes registry (Next.js App Router; src/app honored). New-file-only — an existing route is never overwritten.
  • Idempotent: re-runs match the endpoint by URL (Polar's list API returns the secret, so it's recovered), PATCH missing events as a union, and early-exit already_connected on a same-URL+env re-run.
  • --json agent mode mirrors onboard's contract: no app-source writes; output carries route: {path, source, deps, env}, steps[], next_actions[].

onboard extended (auto-inject beyond Stripe)

  • wireServerSale now routes per provider. Polar: detects the webhook route; auto-injects trackSale ONLY into the unambiguous @polar-sh/nextjs Webhooks({ onOrderPaid }) shape (diff + confirm; conservative bail-outs mirror the Stripe transform); raw validateEvent handlers get the exact printed patch; no route at all → points at affitor setup polar. Lemon Squeezy/Paddle keep the degrade-to-print path.

@affitor/recipes 0.2.0

  • Contract fix: the Polar sale snippet read raw-JSON snake_case (order.total_amount, order.subscription_id, order.customer_id) — those fields are undefined on the SDK-parsed payloads our handlers target (Webhooks()/validateEvent parse through Zod $inboundSchema → camelCase), silently losing amount + attribution. Now: totalAmount/subscriptionId/customerId, plus clickId carrier (metadata.affitor_click_id ?? metadata.reference_id — Polar propagates a checkout link's ?reference_id= into order metadata, renewals included), customer.externalId fallback, isRecurring: billingReason === 'subscription_cycle' (exact enum; Boolean(subscriptionId) mislabeled first payments).
  • New getWebhookRoute(framework, provider): the full self-hosted glue route (Polar × next-app), composed from the SAME sale-body constant so CLI/MCP/docs can't drift. POLAR_WEBHOOK_EVENTS exported.

Stripe bug fix (pre-existing, found during the doc-bug investigation this mission ordered)

  • setup stripe registered advertiser webhook endpoints at ${apiUrl}/webhooks/stripe/${program_id}that route does not exist in the CMS (only POST /api/webhook-distributor/stripe is served; verified in affiliate-cms routes). Every CLI-created endpoint 404'd and events were silently lost. Now points at the real global ingest route. ⚠️ CMS follow-up (out of scope here): the distributor verifies signatures against env secrets only; it should also try the per-program webhook_secret that /v1/cli/stripe-connect already stores in tracking_settings.

Verification

  • 133 tests green across the workspace (30 recipes / 94 cli / 9 mcp), tsc clean, build clean.
  • E2E against a mock Polar API (create/reuse/PATCH-events paths): --json (no file writes, route in payload), interactive (route + env written), idempotent re-run (already_connected), endpoint-reuse with secret recovery.
  • Contract proof: both the generated route AND the onboard-injected route typecheck clean under tsc --strict against the real published @polar-sh/nextjs@0.9.6 + @affitor/sdk@2.1.0 — the camelCase field contract is machine-verified, not assumed.
  • Management-API contract verified against live polar.sh/docs + api.polar.sh/openapi.json (2026-07-05); payload parsing verified from polar-js source ($inboundSchema).
  • affitor test sale passes against the real pipeline (isolated is_test event, program 430).

Blocked / not done

  • No live Polar sandbox run: no Polar sandbox org/token exists on this machine (human signup required). The command is mock-verified + type-proven as above; first real-token run should be npx affitor setup polar --sandbox in a scratch Next app.
  • Hosted relay (/webhook-distributor/polar) intentionally out of scope — advertiser self-hosts the route (per mission).
  • CMS-side Stripe per-program secret verification (noted above).

Notes

  • PR targets main: this repo has no uat branch and no deploy-on-merge; all 23 prior PRs target main (the uat→main flow applies to cms/dashboard/docs).
  • Versions: recipes 0.2.0, cli 0.5.0, mcp 0.2.1 (republish against recipes ^0.2.0). npm publish is a separate manual step after merge.
  • Docs PR in affitor-docs accompanies this one (setup-polar reference + integrations/polar guide + 3 runtime-verified doc bug fixes).

🤖 Generated with Claude Code

https://claude.ai/code/session_017mS1Kn4D45SX2wVaVmfwUE

sonpiaz and others added 7 commits July 5, 2026 17:54
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mS1Kn4D45SX2wVaVmfwUE
…ansform

- 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 = <param>.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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mS1Kn4D45SX2wVaVmfwUE
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mS1Kn4D45SX2wVaVmfwUE
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mS1Kn4D45SX2wVaVmfwUE
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mS1Kn4D45SX2wVaVmfwUE
…contract)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mS1Kn4D45SX2wVaVmfwUE
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mS1Kn4D45SX2wVaVmfwUE
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant