feat: affitor setup polar + onboard Polar auto-inject (one-command non-Stripe payments)#24
Open
sonpiaz wants to merge 7 commits into
Open
feat: affitor setup polar + onboard Polar auto-inject (one-command non-Stripe payments)#24sonpiaz wants to merge 7 commits into
sonpiaz wants to merge 7 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Mandeck's
ship-paid-saasskill ships paid SaaS on Polar (merchant of record — no Stripe) with every sale reported to Affitor viaPOST /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)POST /v1/webhooks/endpoints,format: raw, eventsorder.paid+order.refunded) pointing at their app (defaulthttps://<domain>/api/polar/webhook,--urlto override).--sandboxtargetssandbox-api.polar.sh. Token via--token/POLAR_ACCESS_TOKEN/ masked prompt (needswebhooks:writescope)..affitor/.env(gitignored) + writesPOLAR_WEBHOOK_SECRETinto the app's.env(.local)(never overwrites a different value).src/apphonored). New-file-only — an existing route is never overwritten.already_connectedon a same-URL+env re-run.--jsonagent mode mirrorsonboard's contract: no app-source writes; output carriesroute: {path, source, deps, env},steps[],next_actions[].onboardextended (auto-inject beyond Stripe)wireServerSalenow routes per provider. Polar: detects the webhook route; auto-injectstrackSaleONLY into the unambiguous@polar-sh/nextjsWebhooks({ onOrderPaid })shape (diff + confirm; conservative bail-outs mirror the Stripe transform); rawvalidateEventhandlers get the exact printed patch; no route at all → points ataffitor setup polar. Lemon Squeezy/Paddle keep the degrade-to-print path.@affitor/recipes0.2.0order.total_amount,order.subscription_id,order.customer_id) — those fields areundefinedon the SDK-parsed payloads our handlers target (Webhooks()/validateEventparse through Zod$inboundSchema→ camelCase), silently losing amount + attribution. Now:totalAmount/subscriptionId/customerId, plusclickIdcarrier (metadata.affitor_click_id ?? metadata.reference_id— Polar propagates a checkout link's?reference_id=into order metadata, renewals included),customer.externalIdfallback,isRecurring: billingReason === 'subscription_cycle'(exact enum;Boolean(subscriptionId)mislabeled first payments).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_EVENTSexported.Stripe bug fix (pre-existing, found during the doc-bug investigation this mission ordered)
setup striperegistered advertiser webhook endpoints at${apiUrl}/webhooks/stripe/${program_id}— that route does not exist in the CMS (onlyPOST /api/webhook-distributor/stripeis 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.webhook_secretthat/v1/cli/stripe-connectalready stores intracking_settings.Verification
tscclean, build clean.--json(no file writes, route in payload), interactive (route + env written), idempotent re-run (already_connected), endpoint-reuse with secret recovery.tsc --strictagainst the real published@polar-sh/nextjs@0.9.6+@affitor/sdk@2.1.0— the camelCase field contract is machine-verified, not assumed.api.polar.sh/openapi.json(2026-07-05); payload parsing verified from polar-js source ($inboundSchema).affitor test salepasses against the real pipeline (isolatedis_testevent, program 430).Blocked / not done
npx affitor setup polar --sandboxin a scratch Next app./webhook-distributor/polar) intentionally out of scope — advertiser self-hosts the route (per mission).Notes
main: this repo has nouatbranch and no deploy-on-merge; all 23 prior PRs targetmain(the uat→main flow applies to cms/dashboard/docs).🤖 Generated with Claude Code
https://claude.ai/code/session_017mS1Kn4D45SX2wVaVmfwUE