From 5bfb0c606669505300fefa4bba9739078fe89618 Mon Sep 17 00:00:00 2001 From: 0xNyk <0xnykcd@googlemail.com> Date: Tue, 15 Sep 2026 13:23:07 +0700 Subject: [PATCH] feat(billing): retire Polar; Stripe Managed Payments is the only checkout - /buy redirects to the Stripe Payment Link, or answers 503 "checkout unavailable" when Stripe is not fully configured. No Polar fallback. - /api/pro/verify checks dxp- keys against Stripe only. lk_ and polar_cl_ keys from the retired Polar checkout answer 410: installed apps treat that as an error and keep an existing entitlement, so customers who already activated Polar keys are not switched off, while new activations with those keys fail visibly. Other keys are inactive. - The success page drops the Polar URL-key branch; the DataFast tag comment now describes Stripe session attribution. - FAQ, all 17 locale placeholders (dxp-...), landing README, and the commerce doc describe the Stripe flow. The Polar webhook example is removed. - Tests: 8/8, including 410 for retired keys with no network call and /buy failing closed to 503. Claude-Session: https://claude.ai/code/session_01CvRJfFeFvF96jhBFujxvHN --- docs/commercial/checkout-migration.md | 115 ++-------- landing/README.md | 37 ++- landing/api/buy.js | 44 +++- landing/api/pro/verify.js | 265 +++------------------- landing/buy/success/index.html | 19 +- landing/index.html | 3 +- landing/tests/billing.test.js | 99 ++++---- scripts/commerce/polar-webhook-example.ts | 115 ---------- src/i18n/locales/ar/translation.json | 2 +- src/i18n/locales/cs/translation.json | 2 +- src/i18n/locales/de/translation.json | 2 +- src/i18n/locales/en/translation.json | 2 +- src/i18n/locales/es/translation.json | 2 +- src/i18n/locales/fr/translation.json | 2 +- src/i18n/locales/it/translation.json | 2 +- src/i18n/locales/ja/translation.json | 2 +- src/i18n/locales/ko/translation.json | 2 +- src/i18n/locales/pl/translation.json | 2 +- src/i18n/locales/pt/translation.json | 2 +- src/i18n/locales/ru/translation.json | 2 +- src/i18n/locales/tr/translation.json | 2 +- src/i18n/locales/uk/translation.json | 2 +- src/i18n/locales/vi/translation.json | 2 +- src/i18n/locales/zh-TW/translation.json | 2 +- src/i18n/locales/zh/translation.json | 2 +- 25 files changed, 190 insertions(+), 541 deletions(-) delete mode 100644 scripts/commerce/polar-webhook-example.ts diff --git a/docs/commercial/checkout-migration.md b/docs/commercial/checkout-migration.md index f213da1..0df07bc 100644 --- a/docs/commercial/checkout-migration.md +++ b/docs/commercial/checkout-migration.md @@ -1,105 +1,38 @@ -# Dictx Commerce Migration (Gumroad -> Professional Checkout) +# Dictx Commerce: Stripe Managed Payments -This runbook moves Dictx Pro sales to `https://dictx.splitlabs.io/buy` while keeping the OSS/free path unchanged. +Dictx Pro is sold at `https://dictx.splitlabs.io/buy` through Stripe Managed Payments, where Link is the seller of record. The OSS/free path is unchanged. Polar was retired on 2026-09-15. ## Scope -- Product: Dictx Pro (signed binaries + auto-updates) -- Price: `$29` one-time +- Product: Dictx Pro (signed binaries, in-app auto-updates, priority support) +- Price: `$29` one-time (tax added at checkout where applicable) - Free version: unchanged (GPL source build) -## 1) Domain + Checkout +## Checkout -1. Create `dictx.splitlabs.io` as your Vercel custom domain and serve the landing site there. -2. Deploy the dedicated Vercel landing project from `landing/`: +- `/buy` redirects to the live Payment Link, or shows "checkout unavailable" if Stripe is not fully configured. +- After payment Stripe redirects to `https://dictx.splitlabs.io/buy/success?session_id={CHECKOUT_SESSION_ID}`. +- The success page calls `/api/pro/license`, which verifies the Checkout Session and shows the `dxp-` license key. -- Root Directory: `landing` -- Framework Preset: `Other` -- Build Command: empty -- Output Directory: empty +Setup, live object ids, and environment variables: [landing/README.md](../../landing/README.md). -3. Configure branded checkout: +## License + Entitlements -- Product name: `Dictx Pro` -- Offer copy: `Signed binaries, auto-updates, and direct support` -- Price: `USD 29 one-time` -- Success URL: `https://dictx.splitlabs.io/buy/success?checkout_id={CHECKOUT_ID}` +Activation flow in the app: -3. Enable customer portal for: +- User opens **Settings -> About -> Activate Dictx Pro** and enters the `dxp-` key. +- The app verifies against `https://dictx.splitlabs.io/api/pro/verify`. +- On success, the app stores the entitlement and enables updater checks. +- The app re-verifies on refresh; a refund or dispute turns Pro off, a Stripe outage keeps the current state. +- Keys from the retired Polar checkout (`lk_...`, `polar_cl_...`) answer `410`, so apps that already activated one keep Pro. New activations with them fail. +- Early-adopter promo: the first 100 unique installs can auto-claim free Pro via `POST /api/pro/early-access/claim`. -- Receipts/invoices -- Download access -- Billing/profile management +No webhook is needed: entitlement comes from re-verifying the live Checkout Session. -## 2) License + Entitlements +## Validation Checklist -Define one entitlement key: - -- `dictx_pro` - -Entitlement grants: - -- Access to release downloads -- Auto-update eligibility -- Priority support queue (if enabled) - -Activation flow in app: - -- User opens **Settings -> About -> Upgrade to Dictx Pro** -- User enters Polar license key (`lk_...`) -- App verifies against `https://dictx.splitlabs.io/api/pro/verify` -- On success, app stores active entitlement and enables updater checks -- Legacy checkout keys (`polar_cl_...`) are supported temporarily for migration -- Early-adopter promo: the first 100 unique installs can auto-claim free Pro via `POST /api/pro/early-access/claim` - -## 3) Webhook Processing - -Use a webhook endpoint to sync purchases to your entitlement store. - -Events to handle: - -- `order.paid` (grant entitlement) -- `subscription.active` (if you add annual support plans) -- `order.refunded` or `subscription.canceled` (revoke entitlement) - -Implementation reference: - -- [scripts/commerce/polar-webhook-example.ts](/Users/nyk/repos/dictx/scripts/commerce/polar-webhook-example.ts) - -## 4) App + Repo Link Updates - -Completed in this repo: - -- `README` Pro links now point to `https://dictx.splitlabs.io/buy` -- In-app CTA links point to a shared `PRO_PURCHASE_URL` -- GitHub funding link points to `https://dictx.splitlabs.io/buy` - -## 5) Migration Messaging - -1. Send announcement to existing buyers. -2. Publish FAQ with key points: - -- Existing licenses remain honored -- New purchases go through `https://dictx.splitlabs.io/buy` (redirect target managed in `landing/vercel.json`) -- Support contact stays unchanged - -3. Use template: - -- [customer-migration-email.md](/Users/nyk/repos/dictx/docs/commercial/customer-migration-email.md) - -## 6) Validation Checklist - -Before launch: - -- Checkout success flow creates receipt + customer record -- Webhook signature validation works in production -- `dictx_pro` entitlement is granted/revoked correctly -- `landing/api/pro/verify` returns `{ active: true }` only for valid granted license key (`lk_...`) -- `landing/api/pro/early-access/claim` enforces the first-100 cap using durable Redis storage -- Customer portal access works from receipt email -- Purchase links from app + README resolve to `https://dictx.splitlabs.io/buy` - -After launch: - -- Track conversion rate from in-app CTA -- Track support tickets tagged `billing` and `license` +- `/buy` redirects to the Stripe Payment Link (307). +- A completed checkout lands on `/buy/success` and shows a `dxp-` key. +- `/api/pro/verify` returns `{ active: true }` for that key and `{ active: false }` for a tampered one. +- `/api/pro/license` returns `404` for an unknown session and `410` for a refunded one. +- `landing/tests/billing.test.js` passes. diff --git a/landing/README.md b/landing/README.md index 052350c..e798928 100644 --- a/landing/README.md +++ b/landing/README.md @@ -16,31 +16,32 @@ Attach `dictx.splitlabs.io` to this Vercel project. ## Routes - `/` serves `landing/index.html` -- `/buy` runs `api/buy.js`: the Stripe Payment Link once Stripe is fully configured, otherwise the Polar checkout -- `/buy/success` shows the license key. Stripe purchases fetch it from `/api/pro/license`; earlier Polar purchases read it from the URL +- `/buy` runs `api/buy.js`: redirects to the Stripe Payment Link, or shows "checkout unavailable" if Stripe is not fully configured +- `/buy/success?session_id=cs_live_...` shows the license key, fetched from `/api/pro/license` - `/api/pro/license?session_id=cs_live_...` issues the `dxp-` license key for a verified Stripe purchase -- `/api/pro/verify` validates `dxp-` keys against Stripe and `lk_...` / `polar_cl_...` keys against Polar +- `/api/pro/verify` validates `dxp-` keys against Stripe. Keys from the retired Polar checkout (`lk_...`, `polar_cl_...`) answer `410`, so apps that already activated one keep Pro - `/api/pro/early-access/claim` grants free Pro for the first 100 unique installs -- `/js/script.cookieless.js` and `/api/events` proxy DataFast first-party; `middleware.ts` reports AI crawler requests +- `/js/script.cookieless.js` and `/api/events` proxy DataFast first-party; `middleware.ts` reports AI crawler requests (Edge runtime: the Node.js runtime served every page as 500 on this project) -## Stripe Managed Payments setup +## Stripe Managed Payments -Link is the seller of record, as for the other SplitLabs products on the same Stripe account. +Link is the seller of record, as for the other SplitLabs products on the same Stripe account (`acct_1U0p1zIOmsupAvc3`). -1. Create the product **Dictx Pro** with a tax code Stripe labels "Eligible for Managed Payments" (downloadable software), and a one-time price of $29 USD. -2. Create a Payment Link for that price with Managed Payments on and quantity fixed at 1. -3. In the Payment Link, set **After payment** to redirect to `https://dictx.splitlabs.io/buy/success?session_id={CHECKOUT_SESSION_ID}`. -4. Create a restricted live key (`rk_live_...`) with read access to Checkout Sessions, Payment Intents, and Charges only. -5. Set the Stripe environment variables below in Vercel production. -6. Redeploy production once all four are set: Vercel applies environment variables only at deploy time. `/buy` then sends buyers to the Payment Link. +Live objects: + +- Product `prod_VGAwMgXh0UgB0w` "Dictx Pro", tax code `txcd_10202000` (Downloadable Software) +- Price `price_1UFeb2IOmsupAvc3VoCin47h`, $29 USD one-time +- Payment Link `plink_1UFebaIOmsupAvc3m6DV45ul`, Managed Payments on, after payment redirects to `https://dictx.splitlabs.io/buy/success?session_id={CHECKOUT_SESSION_ID}` A purchase earns a key only when the session is live, complete and paid (or fully discounted), holds exactly one Dictx Pro price at quantity 1, and its charge is neither refunded nor disputed. The app re-verifies keys, so a refund or dispute turns Pro off on its next check. A Stripe outage returns an error, which the app treats as "keep current state". If a buyer loses the key, find their Checkout Session id in Stripe and send them `https://dictx.splitlabs.io/buy/success?session_id=`. It reissues the same key. +To set up again from scratch: create the product with a Managed Payments–eligible tax code, a one-time price, and a Payment Link with Managed Payments on and the redirect above; create a restricted live key with read access to Checkout Sessions, Payment Intents, and Charges; set the variables below; then **redeploy production**, because Vercel applies environment variables only at deploy time. + ## Environment Variables (Vercel) -Stripe (all required before `/buy` switches): +Stripe (all required, otherwise `/buy` shows "checkout unavailable"): - `STRIPE_SECRET_KEY`: restricted live read key (`rk_live_...`), Sensitive - `DICTX_STRIPE_PRICE_ID`: the Dictx Pro price id (`price_...`) @@ -52,14 +53,6 @@ DataFast: - `DATAFAST_WEBSITE_ID`: the public website id; enables crawler tracking in `middleware.ts` -Polar (verifies keys from earlier purchases; keep until those customers are migrated): - -- `POLAR_ACCESS_TOKEN`: Polar API token -- `POLAR_ORGANIZATION_ID`: Polar organization id (`org_...`) used by license-key validation -- `POLAR_DICTX_BENEFIT_IDS`: optional comma-separated benefit IDs allowed for Dictx Pro activation -- `POLAR_DICTX_PRODUCT_IDS`: optional legacy fallback for checkout-key migration (`polar_cl_...`) -- `POLAR_API_BASE`: optional override (defaults to `https://api.polar.sh/v1`) - Rate limits and early access: - `PRO_VERIFY_RATE_LIMIT_WINDOW_MS`: optional API rate-limit window @@ -73,5 +66,5 @@ Rate limits and early access: ## Tests ```bash -node --test landing/tests/ +node --test landing/tests/billing.test.js ``` diff --git a/landing/api/buy.js b/landing/api/buy.js index 4673f92..4b80810 100644 --- a/landing/api/buy.js +++ b/landing/api/buy.js @@ -1,27 +1,49 @@ /** * GET /buy (rewritten here by vercel.json) * - * Sends buyers to the Stripe Managed Payments checkout once Stripe is fully - * configured: a canonical live Payment Link, a valid restricted key, the - * Dictx Pro price, and the license signing secret. Until all four are in - * place it keeps sending buyers to the existing Polar checkout, so deploying - * this change never breaks sales and a half-configured Stripe setup never - * takes money it cannot turn into a license. + * Sends buyers to the Stripe Managed Payments checkout. It fails closed: if + * the restricted key, the Dictx Pro price, the license signing secret, or a + * canonical live Payment Link is missing or malformed, buyers see a short + * "checkout unavailable" page instead of a checkout that could take money it + * cannot turn into a license. */ const { stripeConfig } = require("./_lib/stripe-purchase"); -const POLAR_CHECKOUT_URL = - "https://buy.polar.sh/polar_cl_lchYpu4Y5BWTc1AbO05evqEZu3dXBAgvdenEy1PECGt"; +const UNAVAILABLE_HTML = ` + + + + + + Checkout unavailable · Dictx + + + +
+
+

Checkout is temporarily unavailable

+

Please try again in a few minutes.

+

Back to Dictx

+
+
+ + +`; const checkoutUrl = (config = stripeConfig()) => - config.ready && config.paymentLink ? config.paymentLink : POLAR_CHECKOUT_URL; + config.ready && config.paymentLink ? config.paymentLink : ""; const handler = (_req, res) => { res.setHeader("cache-control", "no-store"); - res.redirect(307, checkoutUrl()); + const url = checkoutUrl(); + if (url) { + res.redirect(307, url); + return null; + } + res.setHeader("content-type", "text/html; charset=utf-8"); + res.status(503).send(UNAVAILABLE_HTML); return null; }; module.exports = handler; module.exports.checkoutUrl = checkoutUrl; -module.exports.POLAR_CHECKOUT_URL = POLAR_CHECKOUT_URL; diff --git a/landing/api/pro/verify.js b/landing/api/pro/verify.js index d0d4a59..8b01f15 100644 --- a/landing/api/pro/verify.js +++ b/landing/api/pro/verify.js @@ -1,20 +1,21 @@ -const { - purchaseForSession, - stripeConfig, -} = require("../_lib/stripe-purchase"); +/** + * POST /api/pro/verify { licenseKey } + * + * The in-app activation and refresh check. Installed app versions read only + * `{ active }` on 200, treat 401/404 as inactive, and treat every other status + * as an error that keeps the current entitlement. + * + * - dxp- keys (Stripe Managed Payments) are verified against the live + * Checkout Session: refunds and disputes turn Pro off on the next check. + * - lk_ / polar_cl_ keys came from the retired Polar checkout. They answer + * 410 so apps that already activated one keep Pro instead of being + * switched off, while new activations with those keys fail visibly. + * - Anything else is not a Dictx Pro key and is inactive. + */ +const { purchaseForSession, stripeConfig } = require("../_lib/stripe-purchase"); const { isStripeLicenseKey, parseLicenseKey } = require("../_lib/license"); +const { createRateLimiter, getClientId, sendJson } = require("../_lib/http"); -const POLAR_API_BASE = process.env.POLAR_API_BASE || "https://api.polar.sh/v1"; -const POLAR_ACCESS_TOKEN = process.env.POLAR_ACCESS_TOKEN || ""; -const POLAR_ORGANIZATION_ID = process.env.POLAR_ORGANIZATION_ID || ""; -const ALLOWED_PRODUCT_IDS = (process.env.POLAR_DICTX_PRODUCT_IDS || "") - .split(",") - .map((value) => value.trim()) - .filter(Boolean); -const ALLOWED_BENEFIT_IDS = (process.env.POLAR_DICTX_BENEFIT_IDS || "") - .split(",") - .map((value) => value.trim()) - .filter(Boolean); const RATE_LIMIT_WINDOW_MS = Number.parseInt( process.env.PRO_VERIFY_RATE_LIMIT_WINDOW_MS || "60000", 10, @@ -23,23 +24,18 @@ const RATE_LIMIT_MAX = Number.parseInt( process.env.PRO_VERIFY_RATE_LIMIT_MAX || "20", 10, ); -const MAX_LICENSE_KEY_LENGTH = 128; -const LICENSE_KEY_PATTERN = /^[A-Za-z0-9_-]{8,128}$/; -const LEGACY_CHECKOUT_ID_PATTERN = /^polar_cl_[A-Za-z0-9]+$/; -const requestBuckets = new Map(); +const MAX_LICENSE_KEY_LENGTH = 256; +const RETIRED_POLAR_KEY = /^(?:lk_|polar_cl_)/; -const getHeader = (req, name) => { - if (!req || !req.headers) return ""; - if (typeof req.headers.get === "function") { - return req.headers.get(name) || ""; - } - const lower = name.toLowerCase(); - return req.headers[lower] || req.headers[name] || ""; -}; +const isRateLimited = createRateLimiter(RATE_LIMIT_WINDOW_MS, RATE_LIMIT_MAX); + +// Positive verifications are cached briefly so an app refresh storm does not +// become a Stripe request storm. Refusals are never cached. +const ACTIVE_TTL_MS = 10 * 60 * 1000; +const activeUntil = new Map(); const readBody = async (req) => { if (!req) return {}; - if (req.body !== undefined) { if (typeof req.body === "string") { try { @@ -48,12 +44,8 @@ const readBody = async (req) => { return {}; } } - - if (typeof req.body === "object" && req.body !== null) { - return req.body; - } + if (typeof req.body === "object" && req.body !== null) return req.body; } - if (typeof req.json === "function") { try { return await req.json(); @@ -61,98 +53,9 @@ const readBody = async (req) => { return {}; } } - return {}; }; -const statusLooksPaid = (status, paid) => { - if (paid === true) return true; - const value = (status || "").toLowerCase(); - return ( - value === "succeeded" || - value === "confirmed" || - value === "paid" || - value === "completed" - ); -}; - -const isLicenseGranted = (payload) => { - const status = (payload?.status || "").toLowerCase(); - if (status !== "granted") { - return false; - } - - const expiresAt = payload?.expires_at; - if (!expiresAt) { - return true; - } - - const expiresAtMs = Date.parse(expiresAt); - if (Number.isNaN(expiresAtMs)) { - return false; - } - - return expiresAtMs > Date.now(); -}; - -const getClientId = (req) => { - const forwarded = getHeader(req, "x-forwarded-for"); - if (Array.isArray(forwarded)) { - return forwarded[0] || "unknown"; - } - if (forwarded.length > 0) { - const [first] = forwarded.split(","); - return first.trim() || "unknown"; - } - return req?.socket?.remoteAddress || "unknown"; -}; - -const isRateLimited = (clientId) => { - const now = Date.now(); - if (requestBuckets.size > 5000) { - for (const [key, bucket] of requestBuckets.entries()) { - if (now > bucket.resetAt) { - requestBuckets.delete(key); - } - } - } - - const existing = requestBuckets.get(clientId); - if (!existing || now > existing.resetAt) { - requestBuckets.set(clientId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); - return false; - } - - existing.count += 1; - requestBuckets.set(clientId, existing); - return existing.count > RATE_LIMIT_MAX; -}; - -const sendJson = (res, statusCode, payload) => { - if (res && typeof res.status === "function") { - if (typeof res.setHeader === "function") { - res.setHeader("cache-control", "no-store"); - res.setHeader("pragma", "no-cache"); - res.setHeader("expires", "0"); - } - res.status(statusCode).json(payload); - return null; - } - - return new Response(JSON.stringify(payload), { - status: statusCode, - headers: { - "content-type": "application/json", - "cache-control": "no-store", - }, - }); -}; - -// Positive Stripe verifications are cached briefly so an app refresh storm -// does not become a Stripe request storm. Refusals are never cached. -const STRIPE_ACTIVE_TTL_MS = 10 * 60 * 1000; -const stripeActiveUntil = new Map(); - const verifyStripeLicense = async (res, licenseKey) => { const config = stripeConfig(); if (!config.ready) { @@ -162,12 +65,12 @@ const verifyStripeLicense = async (res, licenseKey) => { if (!parsed) { return sendJson(res, 200, { active: false, mode: "stripe_license" }); } - if ((stripeActiveUntil.get(parsed.sessionId) || 0) > Date.now()) { + if ((activeUntil.get(parsed.sessionId) || 0) > Date.now()) { return sendJson(res, 200, { active: true, mode: "stripe_license" }); } const purchase = await purchaseForSession(parsed.sessionId, config); if (purchase.ok) { - stripeActiveUntil.set(parsed.sessionId, Date.now() + STRIPE_ACTIVE_TTL_MS); + activeUntil.set(parsed.sessionId, Date.now() + ACTIVE_TTL_MS); return sendJson(res, 200, { active: true, mode: "stripe_license" }); } if ( @@ -177,7 +80,7 @@ const verifyStripeLicense = async (res, licenseKey) => { // An outage must not revoke Pro: the app keeps its entitlement on errors. return sendJson(res, 502, { error: "stripe_api_error" }); } - stripeActiveUntil.delete(parsed.sessionId); + activeUntil.delete(parsed.sessionId); return sendJson(res, 200, { active: false, mode: "stripe_license", @@ -189,124 +92,26 @@ const handler = async (req, res) => { if (req.method !== "POST") { return sendJson(res, 405, { error: "method_not_allowed" }); } - - const clientId = getClientId(req); - if (isRateLimited(clientId)) { + if (isRateLimited(getClientId(req))) { return sendJson(res, 429, { error: "rate_limited" }); } const body = await readBody(req); - const licenseKey = (body.licenseKey || body.checkoutId || "").trim(); + const licenseKey = String(body.licenseKey || body.checkoutId || "").trim(); if (!licenseKey) { return sendJson(res, 400, { error: "licenseKey_required" }); } - - // Stripe Managed Payments keys (dxp-...) verify without any Polar config. - // Everything below is the Polar path, kept so earlier purchases still work. - if (isStripeLicenseKey(licenseKey)) { - return verifyStripeLicense(res, licenseKey); - } - - if (!POLAR_ACCESS_TOKEN) { - return sendJson(res, 500, { error: "missing_polar_access_token" }); - } - - if (!POLAR_ORGANIZATION_ID) { - return sendJson(res, 500, { error: "missing_polar_organization_id" }); - } - if (licenseKey.length > MAX_LICENSE_KEY_LENGTH) { - console.warn("pro_verify_invalid_key_length", { clientId }); return sendJson(res, 400, { error: "invalid_license_key" }); } - - if (!LICENSE_KEY_PATTERN.test(licenseKey) && !LEGACY_CHECKOUT_ID_PATTERN.test(licenseKey)) { - console.warn("pro_verify_invalid_key_format", { clientId }); - return sendJson(res, 400, { error: "invalid_license_key" }); + if (isStripeLicenseKey(licenseKey)) { + return verifyStripeLicense(res, licenseKey); } - - try { - if (LEGACY_CHECKOUT_ID_PATTERN.test(licenseKey)) { - const checkoutResponse = await fetch( - `${POLAR_API_BASE}/checkouts/${encodeURIComponent(licenseKey)}`, - { - method: "GET", - headers: { - Authorization: `Bearer ${POLAR_ACCESS_TOKEN}`, - "Content-Type": "application/json", - }, - }, - ); - - if (checkoutResponse.status === 404) { - return sendJson(res, 404, { active: false }); - } - - if (!checkoutResponse.ok) { - const text = await checkoutResponse.text(); - console.warn("pro_verify_polar_checkout_error", { - status: checkoutResponse.status, - clientId, - }); - return sendJson(res, 502, { error: "polar_api_error", detail: text }); - } - - const checkout = await checkoutResponse.json(); - const productId = - checkout.product_id == null ? "" : String(checkout.product_id); - const productAllowed = - ALLOWED_PRODUCT_IDS.length === 0 || ALLOWED_PRODUCT_IDS.includes(productId); - const paid = statusLooksPaid(checkout.status, checkout.paid); - - return sendJson(res, 200, { - active: Boolean(productAllowed && paid), - mode: "legacy_checkout", - }); - } - - const validateResponse = await fetch(`${POLAR_API_BASE}/license-keys/validate`, { - method: "POST", - headers: { - Authorization: `Bearer ${POLAR_ACCESS_TOKEN}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - key: licenseKey, - organization_id: POLAR_ORGANIZATION_ID, - }), - }); - - if (validateResponse.status === 404 || validateResponse.status === 422) { - return sendJson(res, 200, { active: false, mode: "license_key" }); - } - - if (!validateResponse.ok) { - const text = await validateResponse.text(); - console.warn("pro_verify_polar_license_error", { - status: validateResponse.status, - clientId, - }); - return sendJson(res, 502, { error: "polar_api_error", detail: text }); - } - - const license = await validateResponse.json(); - const benefitId = license?.benefit_id == null ? "" : String(license.benefit_id); - const benefitAllowed = - ALLOWED_BENEFIT_IDS.length === 0 || ALLOWED_BENEFIT_IDS.includes(benefitId); - const granted = isLicenseGranted(license); - - return sendJson(res, 200, { - active: Boolean(benefitAllowed && granted), - mode: "license_key", - }); - } catch (error) { - console.warn("pro_verify_internal_error", { clientId }); - return sendJson(res, 500, { - error: "internal_error", - detail: error instanceof Error ? error.message : String(error), - }); + if (RETIRED_POLAR_KEY.test(licenseKey)) { + return sendJson(res, 410, { error: "polar_retired" }); } + return sendJson(res, 200, { active: false, mode: "unknown_key" }); }; module.exports = handler; diff --git a/landing/buy/success/index.html b/landing/buy/success/index.html index 90a03d1..b0eb5b1 100644 --- a/landing/buy/success/index.html +++ b/landing/buy/success/index.html @@ -12,8 +12,8 @@ + attributes the purchase: the tag reads the Stripe session id from the URL + and DataFast joins it to the payment via its Stripe connection. -->