Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 24 additions & 91 deletions docs/commercial/checkout-migration.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 15 additions & 22 deletions landing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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_...`)
Expand All @@ -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
Expand All @@ -73,5 +66,5 @@ Rate limits and early access:
## Tests

```bash
node --test landing/tests/
node --test landing/tests/billing.test.js
```
44 changes: 33 additions & 11 deletions landing/api/buy.js
Original file line number Diff line number Diff line change
@@ -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 = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Checkout unavailable · Dictx</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="shell">
<section class="panel hero">
<h1>Checkout is temporarily unavailable</h1>
<p class="lede">Please try again in a few minutes.</p>
<p><a class="btn ghost" href="/">Back to Dictx</a></p>
</section>
</main>
</body>
</html>
`;

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;
Loading
Loading