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
8 changes: 8 additions & 0 deletions docs/commercial/checkout-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ Entitlement grants:
- Auto-update eligibility
- Priority support queue (if enabled)

Activation flow in app:

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

## 3) Webhook Processing

Use a webhook endpoint to sync purchases to your entitlement store.
Expand Down Expand Up @@ -84,6 +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
- Customer portal access works from receipt email
- Purchase links from app + README resolve to `https://dictx.splitlabs.io/buy`

Expand Down
9 changes: 8 additions & 1 deletion landing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,11 @@ Attach `dictx.splitlabs.io` to this Vercel project.
## Routes

- `/` serves `landing/index.html`
- `/buy` redirects to `https://buy.splitlabs.io`
- `/buy` redirects to Polar checkout
- `/api/pro/verify` validates a Polar checkout/email pair for in-app Pro activation

## Environment Variables (Vercel)

- `POLAR_ACCESS_TOKEN`: Polar API token
- `POLAR_DICTX_PRODUCT_IDS`: optional comma-separated product IDs allowed for Dictx Pro activation
- `POLAR_API_BASE`: optional override (defaults to `https://api.polar.sh/v1`)
98 changes: 98 additions & 0 deletions landing/api/pro/verify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
const POLAR_API_BASE = process.env.POLAR_API_BASE || "https://api.polar.sh/v1";
const POLAR_ACCESS_TOKEN = process.env.POLAR_ACCESS_TOKEN || "";
const ALLOWED_PRODUCT_IDS = (process.env.POLAR_DICTX_PRODUCT_IDS || "")
.split(",")
.map((value) => value.trim())
.filter(Boolean);

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

const readBody = (req) => {
if (!req.body) return {};
if (typeof req.body === "string") {
try {
return JSON.parse(req.body);
} catch (_error) {
return {};
}
}
return req.body;
};

const statusLooksPaid = (status, paid) => {
if (paid === true) return true;
const value = (status || "").toLowerCase();
return (
value === "succeeded" ||
value === "confirmed" ||
value === "paid" ||
value === "completed"
);
};

module.exports = async (req, res) => {
if (req.method !== "POST") {
res.status(405).json({ error: "method_not_allowed" });
return;
}

if (!POLAR_ACCESS_TOKEN) {
res.status(500).json({ error: "missing_polar_access_token" });
return;
}

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

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

try {
const response = await fetch(
`${POLAR_API_BASE}/checkouts/${encodeURIComponent(checkoutId)}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${POLAR_ACCESS_TOKEN}`,
"Content-Type": "application/json",
},
},
);

if (response.status === 404) {
res.status(404).json({ active: false });
return;
}

if (!response.ok) {
const text = await response.text();
res.status(502).json({ error: "polar_api_error", detail: text });
return;
}

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) });
} catch (error) {
res.status(500).json({
error: "internal_error",
detail: error instanceof Error ? error.message : String(error),
});
}
};
7 changes: 7 additions & 0 deletions landing/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,13 @@ <h2>FAQ</h2>
workflow.
</p>
</details>
<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>).
</p>
</details>
<details>
<summary>Where does /buy go?</summary>
<p>
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod audio;
pub mod history;
pub mod models;
pub mod pro;
pub mod transcription;

use crate::settings::{get_settings, write_settings, AppSettings, LogLevel};
Expand Down
154 changes: 154 additions & 0 deletions src-tauri/src/commands/pro.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
use crate::settings::{self, ProEntitlement};
use chrono::Utc;
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use tauri::AppHandle;

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,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VerifyResponse {
active: bool,
}

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> {
let payload = VerifyRequest {
checkout_id: checkout_id.trim().to_string(),
email: normalize_email(email),
};

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

let client = reqwest::Client::new();
let response = client
.post(get_verify_url())
.json(&payload)
.send()
.await
.map_err(|e| format!("Verification request failed: {}", e))?;

if response.status() == StatusCode::OK {
let body: VerifyResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse verification response: {}", e))?;
Ok(body.active)
} else if response.status() == StatusCode::UNAUTHORIZED
|| response.status() == StatusCode::NOT_FOUND
{
Ok(false)
} else {
let status = response.status();
let text = response
.text()
.await
.unwrap_or_else(|_| "unknown error".to_string());
Err(format!("Verification API error ({}): {}", status, text))
}
}

#[tauri::command]
#[specta::specta]
pub fn get_pro_entitlement(app: AppHandle) -> Result<ProEntitlement, String> {
let settings = settings::get_settings(&app);
Ok(settings.pro_entitlement)
}

#[tauri::command]
#[specta::specta]
pub fn clear_pro_entitlement(app: AppHandle) -> Result<ProEntitlement, String> {
let mut app_settings = settings::get_settings(&app);
app_settings.pro_entitlement = ProEntitlement::default();
app_settings.update_checks_enabled = false;
settings::write_settings(&app, app_settings.clone());
crate::tray::update_tray_menu(&app, &crate::tray::TrayIconState::Idle, None);
Ok(app_settings.pro_entitlement)
}

#[tauri::command]
#[specta::specta]
pub async fn activate_pro_entitlement(
app: AppHandle,
checkout_id: String,
email: String,
) -> Result<ProEntitlement, String> {
let active = verify_checkout(&checkout_id, &email).await?;
if !active {
return Err("No active Dictx Pro entitlement found for this checkout/email".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()),
activated_at: Some(now),
last_verified_at: Some(now),
verification_error: None,
};
app_settings.update_checks_enabled = true;
settings::write_settings(&app, app_settings.clone());
crate::tray::update_tray_menu(&app, &crate::tray::TrayIconState::Idle, None);

Ok(app_settings.pro_entitlement)
}

#[tauri::command]
#[specta::specta]
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() {
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 {
Ok(true) => {
app_settings.pro_entitlement.active = true;
app_settings.pro_entitlement.last_verified_at = Some(now);
app_settings.pro_entitlement.verification_error = None;
}
Ok(false) => {
app_settings.pro_entitlement.active = false;
app_settings.pro_entitlement.last_verified_at = Some(now);
app_settings.pro_entitlement.verification_error =
Some("Entitlement no longer active".to_string());
app_settings.update_checks_enabled = false;
}
Err(err) => {
app_settings.pro_entitlement.verification_error = Some(err.clone());
settings::write_settings(&app, app_settings.clone());
return Err(err);
}
}

settings::write_settings(&app, app_settings.clone());
crate::tray::update_tray_menu(&app, &crate::tray::TrayIconState::Idle, None);
Ok(app_settings.pro_entitlement)
}
8 changes: 6 additions & 2 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ fn initialize_core_logic(app_handle: &AppHandle) {
}
"check_updates" => {
let settings = settings::get_settings(app);
if settings.update_checks_enabled {
if settings.update_checks_enabled && settings.pro_entitlement.active {
show_main_window(app);
let _ = app.emit("check-for-updates", ());
}
Expand Down Expand Up @@ -250,7 +250,7 @@ fn initialize_core_logic(app_handle: &AppHandle) {
#[specta::specta]
fn trigger_update_check(app: AppHandle) -> Result<(), String> {
let settings = settings::get_settings(&app);
if !settings.update_checks_enabled {
if !settings.update_checks_enabled || !settings.pro_entitlement.active {
return Ok(());
}
app.emit("check-for-updates", ())
Expand Down Expand Up @@ -358,6 +358,10 @@ pub fn run(cli_args: CliArgs) {
commands::history::delete_history_entry,
commands::history::update_history_limit,
commands::history::update_recording_retention_period,
commands::pro::get_pro_entitlement,
commands::pro::activate_pro_entitlement,
commands::pro::refresh_pro_entitlement,
commands::pro::clear_pro_entitlement,
helpers::clamshell::is_laptop,
]);

Expand Down
24 changes: 24 additions & 0 deletions src-tauri/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,22 @@ impl Default for TypingTool {
}
}

#[derive(Serialize, Deserialize, Debug, Clone, Type, Default)]
pub struct ProEntitlement {
#[serde(default)]
pub active: bool,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub checkout_id: Option<String>,
#[serde(default)]
pub activated_at: Option<i64>,
#[serde(default)]
pub last_verified_at: Option<i64>,
#[serde(default)]
pub verification_error: Option<String>,
}

/* still handy for composing the initial JSON in the store ------------- */
#[derive(Serialize, Deserialize, Debug, Clone, Type)]
pub struct AppSettings {
Expand Down Expand Up @@ -368,6 +384,8 @@ pub struct AppSettings {
pub obsidian_export_subfolder: Option<String>,
#[serde(default)]
pub obsidian_append_to_daily: bool,
#[serde(default)]
pub pro_entitlement: ProEntitlement,
}

fn default_model() -> String {
Expand Down Expand Up @@ -740,6 +758,7 @@ pub fn get_default_settings() -> AppSettings {
obsidian_vault_path: None,
obsidian_export_subfolder: default_obsidian_export_subfolder(),
obsidian_append_to_daily: false,
pro_entitlement: ProEntitlement::default(),
}
}

Expand Down Expand Up @@ -868,6 +887,11 @@ pub fn get_history_limit(app: &AppHandle) -> usize {
settings.history_limit
}

pub fn is_pro_active(app: &AppHandle) -> bool {
let settings = get_settings(app);
settings.pro_entitlement.active
}

pub fn get_recording_retention_period(app: &AppHandle) -> RecordingRetentionPeriod {
let settings = get_settings(app);
settings.recording_retention_period
Expand Down
Loading
Loading