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
4 changes: 2 additions & 2 deletions docs/commercial/checkout-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Entitlement grants:
Activation flow in app:

- User opens **Settings -> About -> Upgrade to Dictx Pro**
- User enters purchase email + Polar checkout ID (`polar_cl_...`)
- User enters license key (`polar_cl_...`)
- App verifies against `https://dictx.splitlabs.io/api/pro/verify`
- On success, app stores active entitlement and enables updater checks

Expand Down Expand Up @@ -91,7 +91,7 @@ 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 paid checkout + matching email
- `landing/api/pro/verify` returns `{ active: true }` only for valid paid checkout key
- Customer portal access works from receipt email
- Purchase links from app + README resolve to `https://dictx.splitlabs.io/buy`

Expand Down
2 changes: 1 addition & 1 deletion landing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Attach `dictx.splitlabs.io` to this Vercel project.

- `/` serves `landing/index.html`
- `/buy` redirects to Polar checkout
- `/api/pro/verify` validates a Polar checkout/email pair for in-app Pro activation
- `/api/pro/verify` validates a Polar license key (`polar_cl_...`) for in-app Pro activation

## Environment Variables (Vercel)

Expand Down
21 changes: 5 additions & 16 deletions landing/api/pro/verify.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ const ALLOWED_PRODUCT_IDS = (process.env.POLAR_DICTX_PRODUCT_IDS || "")
.map((value) => value.trim())
.filter(Boolean);

const normalizeEmail = (value) => value.trim().toLowerCase();

const readBody = (req) => {
if (!req.body) return {};
if (typeof req.body === "string") {
Expand Down Expand Up @@ -42,17 +40,16 @@ module.exports = async (req, res) => {
}

const body = readBody(req);
const checkoutId = (body.checkoutId || "").trim();
const email = normalizeEmail(body.email || "");
const licenseKey = (body.licenseKey || body.checkoutId || "").trim();

if (!checkoutId || !email) {
res.status(400).json({ error: "checkoutId_and_email_required" });
if (!licenseKey) {
res.status(400).json({ error: "licenseKey_required" });
return;
}

try {
const response = await fetch(
`${POLAR_API_BASE}/checkouts/${encodeURIComponent(checkoutId)}`,
`${POLAR_API_BASE}/checkouts/${encodeURIComponent(licenseKey)}`,
{
method: "GET",
headers: {
Expand All @@ -74,21 +71,13 @@ module.exports = async (req, res) => {
}

const checkout = await response.json();
const checkoutEmail = normalizeEmail(
checkout.customer_email ||
checkout.customer?.email ||
checkout.metadata?.customer_email ||
"",
);

const productId =
checkout.product_id == null ? "" : String(checkout.product_id);
const productAllowed =
ALLOWED_PRODUCT_IDS.length === 0 || ALLOWED_PRODUCT_IDS.includes(productId);
const emailMatches = checkoutEmail !== "" && checkoutEmail === email;
const paid = statusLooksPaid(checkout.status, checkout.paid);

res.status(200).json({ active: Boolean(productAllowed && emailMatches && paid) });
res.status(200).json({ active: Boolean(productAllowed && paid) });
} catch (error) {
res.status(500).json({
error: "internal_error",
Expand Down
5 changes: 3 additions & 2 deletions landing/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,9 @@ <h2>FAQ</h2>
<details>
<summary>How do I activate Pro inside the app?</summary>
<p>
Open Settings, go to About, and enter your purchase email and
Polar checkout ID (starts with <code>polar_cl_</code>).
Open Settings, go to About, and enter your Pro license key
(currently your Polar checkout key, starts with
<code>polar_cl_</code>).
</p>
</details>
<details>
Expand Down
44 changes: 20 additions & 24 deletions src-tauri/src/commands/pro.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ const DEFAULT_PRO_VERIFY_URL: &str = "https://dictx.splitlabs.io/api/pro/verify"
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct VerifyRequest {
checkout_id: String,
email: String,
license_key: String,
}

#[derive(Debug, Deserialize)]
Expand All @@ -23,18 +22,13 @@ fn get_verify_url() -> String {
std::env::var("DICTX_PRO_VERIFY_URL").unwrap_or_else(|_| DEFAULT_PRO_VERIFY_URL.to_string())
}

fn normalize_email(email: &str) -> String {
email.trim().to_lowercase()
}

async fn verify_checkout(checkout_id: &str, email: &str) -> Result<bool, String> {
async fn verify_checkout(license_key: &str) -> Result<bool, String> {
let payload = VerifyRequest {
checkout_id: checkout_id.trim().to_string(),
email: normalize_email(email),
license_key: license_key.trim().to_string(),
};

if payload.checkout_id.is_empty() || payload.email.is_empty() {
return Err("Checkout ID and email are required".to_string());
if payload.license_key.is_empty() {
return Err("License key is required".to_string());
}

let client = reqwest::Client::new();
Expand Down Expand Up @@ -87,20 +81,20 @@ pub fn clear_pro_entitlement(app: AppHandle) -> Result<ProEntitlement, String> {
#[specta::specta]
pub async fn activate_pro_entitlement(
app: AppHandle,
checkout_id: String,
email: String,
license_key: String,
) -> Result<ProEntitlement, String> {
let active = verify_checkout(&checkout_id, &email).await?;
let active = verify_checkout(&license_key).await?;
if !active {
return Err("No active Dictx Pro entitlement found for this checkout/email".to_string());
return Err("No active Dictx Pro entitlement found for this license key".to_string());
}

let now = Utc::now().timestamp();
let mut app_settings = settings::get_settings(&app);
app_settings.pro_entitlement = ProEntitlement {
active: true,
email: Some(normalize_email(&email)),
checkout_id: Some(checkout_id.trim().to_string()),
license_key: Some(license_key.trim().to_string()),
email: None,
checkout_id: None,
activated_at: Some(now),
last_verified_at: Some(now),
verification_error: None,
Expand All @@ -117,20 +111,22 @@ pub async fn activate_pro_entitlement(
pub async fn refresh_pro_entitlement(app: AppHandle) -> Result<ProEntitlement, String> {
let mut app_settings = settings::get_settings(&app);

let checkout_id = match app_settings.pro_entitlement.checkout_id.clone() {
Some(value) if !value.trim().is_empty() => value,
_ => return Ok(app_settings.pro_entitlement),
};

let email = match app_settings.pro_entitlement.email.clone() {
// Support legacy activations by falling back to checkout_id if license_key is missing.
let license_key = match app_settings
.pro_entitlement
.license_key
.clone()
.or_else(|| app_settings.pro_entitlement.checkout_id.clone())
{
Some(value) if !value.trim().is_empty() => value,
_ => return Ok(app_settings.pro_entitlement),
};

let now = Utc::now().timestamp();
match verify_checkout(&checkout_id, &email).await {
match verify_checkout(&license_key).await {
Ok(true) => {
app_settings.pro_entitlement.active = true;
app_settings.pro_entitlement.license_key = Some(license_key);
app_settings.pro_entitlement.last_verified_at = Some(now);
app_settings.pro_entitlement.verification_error = None;
}
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,12 @@ pub struct ProEntitlement {
#[serde(default)]
pub active: bool,
#[serde(default)]
pub license_key: Option<String>,
#[serde(default)]
// Legacy field kept for backward compatibility with older local settings.
pub email: Option<String>,
#[serde(default)]
// Legacy field kept for backward compatibility with older local settings.
pub checkout_id: Option<String>,
#[serde(default)]
pub activated_at: Option<i64>,
Expand Down
63 changes: 13 additions & 50 deletions src/components/settings/about/AboutSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,12 @@ import { useProEntitlement } from "@/hooks/useProEntitlement";
export const AboutSettings: React.FC = () => {
const { t } = useTranslation();
const [version, setVersion] = useState("");
const [checkoutId, setCheckoutId] = useState("");
const [email, setEmail] = useState("");
const [licenseKey, setLicenseKey] = useState("");
const {
entitlement,
isLoading: proLoading,
isSubmitting: proSubmitting,
error: proError,
activate,
refresh,
clear,
} = useProEntitlement();

useEffect(() => {
Expand All @@ -42,9 +38,9 @@ export const AboutSettings: React.FC = () => {
}, []);

const handleActivate = async () => {
const ok = await activate(checkoutId, email);
const ok = await activate(licenseKey);
if (ok) {
setCheckoutId("");
setLicenseKey("");
}
};

Expand Down Expand Up @@ -76,24 +72,6 @@ export const AboutSettings: React.FC = () => {
>
{t("settings.about.supportDevelopment.button")}
</Button>
<Button
variant="secondary"
size="md"
onClick={() => void refresh()}
disabled={proLoading || proSubmitting}
>
{t("settings.about.proActivation.refresh")}
</Button>
{entitlement?.active && (
<Button
variant="secondary"
size="md"
onClick={() => void clear()}
disabled={proSubmitting}
>
{t("settings.about.proActivation.clear")}
</Button>
)}
</div>

<div className="rounded-md border border-mid-gray/20 p-3 space-y-2">
Expand All @@ -102,38 +80,23 @@ export const AboutSettings: React.FC = () => {
? t("settings.about.proActivation.active")
: t("settings.about.proActivation.inactive")}
</p>
{entitlement?.email && (
<p className="text-xs text-text/60">
{t("settings.about.proActivation.email")}: {entitlement.email}
</p>
)}
{entitlement?.checkout_id && (
{(entitlement?.license_key || entitlement?.checkout_id) && (
<p className="text-xs text-text/60">
{t("settings.about.proActivation.checkoutId")}:{" "}
{entitlement.checkout_id}
{t("settings.about.proActivation.licenseKey")}:{" "}
{entitlement.license_key ?? entitlement.checkout_id}
</p>
)}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Input
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder={t("settings.about.proActivation.emailPlaceholder")}
disabled={proSubmitting}
/>
<Input
value={checkoutId}
onChange={(event) => setCheckoutId(event.target.value)}
placeholder={t(
"settings.about.proActivation.checkoutIdPlaceholder",
)}
disabled={proSubmitting}
/>
</div>
<Input
value={licenseKey}
onChange={(event) => setLicenseKey(event.target.value)}
placeholder={t("settings.about.proActivation.licenseKeyPlaceholder")}
disabled={proSubmitting}
/>
<Button
variant="primary"
size="md"
onClick={() => void handleActivate()}
disabled={proSubmitting || !email.trim() || !checkoutId.trim()}
disabled={proSubmitting || !licenseKey.trim()}
>
{t("settings.about.proActivation.activate")}
</Button>
Expand Down
8 changes: 3 additions & 5 deletions src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -541,14 +541,12 @@
},
"proActivation": {
"active": "Dictx Pro is active on this device.",
"inactive": "Dictx Pro is not active. Enter your purchase email and checkout ID to activate updater entitlement.",
"inactive": "Dictx Pro is not active. Enter your license key to activate updater entitlement.",
"activate": "Activate Dictx Pro",
"refresh": "Re-check Entitlement",
"clear": "Remove Activation",
"email": "Email",
"checkoutId": "Checkout ID",
"emailPlaceholder": "Purchase email",
"checkoutIdPlaceholder": "polar_cl_..."
"licenseKey": "License Key",
"licenseKeyPlaceholder": "polar_cl_..."
},
"acknowledgments": {
"title": "Acknowledgments",
Expand Down
8 changes: 4 additions & 4 deletions src/stores/proEntitlementStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ interface ProEntitlementStore {
isSubmitting: boolean;
error: string | null;
initialize: () => Promise<void>;
activate: (checkoutId: string, email: string) => Promise<boolean>;
activate: (licenseKey: string) => Promise<boolean>;
refresh: () => Promise<void>;
clear: () => Promise<void>;
}
Expand All @@ -31,7 +31,7 @@ export const useProEntitlementStore = create<ProEntitlementStore>(
}
try {
let entitlement = await getProEntitlement();
if (entitlement.checkout_id && entitlement.email) {
if (entitlement.license_key || entitlement.checkout_id) {
try {
entitlement = await refreshProEntitlement();
} catch (_error) {
Expand All @@ -46,10 +46,10 @@ export const useProEntitlementStore = create<ProEntitlementStore>(
}
},

activate: async (checkoutId: string, email: string) => {
activate: async (licenseKey: string) => {
set({ isSubmitting: true, error: null });
try {
const entitlement = await activateProEntitlement(checkoutId, email);
const entitlement = await activateProEntitlement(licenseKey);
set({ entitlement });
return true;
} catch (error) {
Expand Down
7 changes: 3 additions & 4 deletions src/utils/proEntitlement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";

export interface ProEntitlement {
active: boolean;
license_key?: string | null;
email?: string | null;
checkout_id?: string | null;
activated_at?: number | null;
Expand All @@ -14,12 +15,10 @@ export const getProEntitlement = async (): Promise<ProEntitlement> => {
};

export const activateProEntitlement = async (
checkoutId: string,
email: string,
licenseKey: string,
): Promise<ProEntitlement> => {
return await invoke<ProEntitlement>("activate_pro_entitlement", {
checkoutId,
email,
license_key: licenseKey,
});
};

Expand Down
Loading