fix: handle Polar activation limit gracefully + switch checkout to command pattern

- Server: wrap licenseKeys.activate() in try/catch — if activation limit
  reached, treat as already-activated and proceed to store the key
- Web: switch activateFromCheckout from form() to command() pattern for
  programmatic invocation with proper error handling
- Activate page: auto-fires on mount, shows spinner/error states, retry button
This commit is contained in:
Sanju Sivalingam
2026-02-18 23:28:19 +05:30
parent e9d1c863e1
commit ce6d1e320b
3 changed files with 104 additions and 80 deletions

View File

@@ -55,12 +55,18 @@ license.post("/activate", async (c) => {
// Determine plan from benefit ID or default to "ltd" // Determine plan from benefit ID or default to "ltd"
const plan = "ltd"; const plan = "ltd";
// Activate the key (tracks activation count on Polar's side) // Activate the key (may fail if already activated from previous attempt)
try {
await polar.licenseKeys.activate({ await polar.licenseKeys.activate({
key, key,
organizationId: env.POLAR_ORGANIZATION_ID, organizationId: env.POLAR_ORGANIZATION_ID,
label: `${currentUser.email}`, label: `${currentUser.email}`,
}); });
} catch (activateErr) {
const msg = activateErr instanceof Error ? activateErr.message : String(activateErr);
if (!msg.includes("limit")) throw activateErr;
console.log(`[License] Key already activated for ${currentUser.email}, storing anyway`);
}
// Store on user record // Store on user record
await db await db
@@ -140,12 +146,19 @@ license.post("/activate-checkout", async (c) => {
return c.json({ error: "No license key found for this purchase" }, 400); return c.json({ error: "No license key found for this purchase" }, 400);
} }
// 3. Activate the key // 3. Activate the key (may fail if already activated from previous attempt)
try {
await polar.licenseKeys.activate({ await polar.licenseKeys.activate({
key: customerKey.key, key: customerKey.key,
organizationId: env.POLAR_ORGANIZATION_ID, organizationId: env.POLAR_ORGANIZATION_ID,
label: `${currentUser.email}`, label: `${currentUser.email}`,
}); });
} catch (activateErr) {
const msg = activateErr instanceof Error ? activateErr.message : String(activateErr);
if (!msg.includes("limit")) throw activateErr;
// Limit reached = key was already activated, that's fine — proceed to store
console.log(`[License] Key already activated for ${currentUser.email}, storing anyway`);
}
// 4. Store on user record // 4. Store on user record
await db await db
@@ -161,6 +174,14 @@ license.post("/activate-checkout", async (c) => {
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
console.error(`[License] Checkout activation failed for ${currentUser.email}:`, message); console.error(`[License] Checkout activation failed for ${currentUser.email}:`, message);
if (message.includes("limit")) {
return c.json({ error: "License key activation limit reached" }, 400);
}
if (message.includes("not found") || message.includes("invalid")) {
return c.json({ error: "Invalid or expired checkout" }, 400);
}
return c.json({ error: "Failed to activate from checkout" }, 500); return c.json({ error: "Failed to activate from checkout" }, 500);
} }
}); });

View File

@@ -1,69 +1,44 @@
import { form, query, getRequestEvent } from '$app/server'; import { form, command, getRequestEvent } from '$app/server';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import * as v from 'valibot';
import { activateLicenseSchema, activateCheckoutSchema } from '$lib/schema/license'; import { activateLicenseSchema, activateCheckoutSchema } from '$lib/schema/license';
export const getLicenseStatus = query(async () => { /** Forward a request to the DroidClaw server with internal auth */
async function serverFetch(path: string, body: Record<string, unknown>) {
const { locals } = getRequestEvent(); const { locals } = getRequestEvent();
if (!locals.user) return null; if (!locals.user) throw new Error('Not authenticated');
const rows = await db const serverUrl = env.SERVER_URL || 'http://localhost:8080';
.select({ plan: user.plan, polarLicenseKey: user.polarLicenseKey }) const internalSecret = env.INTERNAL_SECRET || '';
.from(user)
.where(eq(user.id, locals.user.id))
.limit(1);
const row = rows[0]; const res = await fetch(`${serverUrl}${path}`, {
return { method: 'POST',
activated: !!row?.plan, headers: {
plan: row?.plan ?? null, 'Content-Type': 'application/json',
licenseKey: row?.polarLicenseKey ?? null 'x-internal-secret': internalSecret,
}; 'x-internal-user-id': locals.user.id
}); },
body: JSON.stringify(body)
});
const data = await res.json().catch(() => ({ error: 'Unknown error' }));
if (!res.ok) throw new Error(data.error ?? `Error ${res.status}`);
return data;
}
export const activateLicense = form(activateLicenseSchema, async (data) => { export const activateLicense = form(activateLicenseSchema, async (data) => {
const { locals } = getRequestEvent(); const { locals } = getRequestEvent();
if (!locals.user) return; if (!locals.user) return;
const serverUrl = env.SERVER_URL || 'http://localhost:8080'; await serverFetch('/license/activate', { key: data.key });
const internalSecret = env.INTERNAL_SECRET || '';
const res = await fetch(`${serverUrl}/license/activate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-internal-secret': internalSecret,
'x-internal-user-id': locals.user.id
},
body: JSON.stringify({ key: data.key })
});
if (res.ok) {
redirect(303, '/dashboard'); redirect(303, '/dashboard');
}
}); });
export const activateFromCheckout = form(activateCheckoutSchema, async (data) => { export const activateFromCheckout = command(
const { locals } = getRequestEvent(); v.object({ checkoutId: v.string() }),
if (!locals.user) return; async ({ checkoutId }) => {
const result = await serverFetch('/license/activate-checkout', { checkoutId });
const serverUrl = env.SERVER_URL || 'http://localhost:8080'; return result;
const internalSecret = env.INTERNAL_SECRET || '';
const res = await fetch(`${serverUrl}/license/activate-checkout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-internal-secret': internalSecret,
'x-internal-user-id': locals.user.id
},
body: JSON.stringify({ checkoutId: data.checkoutId })
});
if (res.ok) {
redirect(303, '/dashboard');
} }
}); );

View File

@@ -1,17 +1,39 @@
<script lang="ts"> <script lang="ts">
import { activateLicense, activateFromCheckout } from '$lib/api/license.remote'; import { activateLicense, activateFromCheckout } from '$lib/api/license.remote';
import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { onMount } from 'svelte';
import Icon from '@iconify/svelte'; import Icon from '@iconify/svelte';
import { LICENSE_ACTIVATE_CHECKOUT, LICENSE_ACTIVATE_MANUAL, LICENSE_PURCHASE_CLICK } from '$lib/analytics/events'; import { LICENSE_ACTIVATE_CHECKOUT, LICENSE_ACTIVATE_MANUAL, LICENSE_PURCHASE_CLICK } from '$lib/analytics/events';
const checkoutId = page.url.searchParams.get('checkout_id'); const checkoutId = page.url.searchParams.get('checkout_id');
let showKeyInput = $state(false); let showKeyInput = $state(false);
let checkoutStatus = $state<'activating' | 'error' | 'idle'>('idle');
let checkoutError = $state('');
async function activateCheckout() {
if (!checkoutId) return;
checkoutStatus = 'activating';
checkoutError = '';
try {
await activateFromCheckout({ checkoutId });
goto('/dashboard');
} catch (e: any) {
checkoutError = e.message ?? 'Failed to activate from checkout';
checkoutStatus = 'error';
}
}
onMount(() => {
if (checkoutId) activateCheckout();
});
</script> </script>
{#if checkoutId} {#if checkoutId}
<!-- Auto-activate from Polar checkout --> <!-- Auto-activate from Polar checkout -->
<div class="mx-auto max-w-md pt-20"> <div class="mx-auto max-w-md pt-20">
{#if checkoutStatus === 'activating'}
<div class="mb-8 text-center"> <div class="mb-8 text-center">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-100"> <div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-100">
<Icon icon="ph:spinner-duotone" class="h-6 w-6 animate-spin text-neutral-600" /> <Icon icon="ph:spinner-duotone" class="h-6 w-6 animate-spin text-neutral-600" />
@@ -21,18 +43,24 @@
We're setting up your account. This will only take a moment. We're setting up your account. This will only take a moment.
</p> </p>
</div> </div>
{:else if checkoutStatus === 'error'}
<div class="mb-8 text-center">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-red-50">
<Icon icon="ph:warning-duotone" class="h-6 w-6 text-red-500" />
</div>
<h2 class="text-2xl font-bold">Activation failed</h2>
<p class="mt-1 text-neutral-500">{checkoutError}</p>
</div>
<form {...activateFromCheckout} class="space-y-4">
<input type="hidden" {...activateFromCheckout.fields.checkoutId.as('text')} value={checkoutId} />
<button <button
type="submit" onclick={activateCheckout}
data-umami-event={LICENSE_ACTIVATE_CHECKOUT} data-umami-event={LICENSE_ACTIVATE_CHECKOUT}
class="flex w-full items-center justify-center gap-2 rounded-xl bg-neutral-900 px-4 py-2.5 font-medium text-white hover:bg-neutral-800" class="flex w-full items-center justify-center gap-2 rounded-xl bg-neutral-900 px-4 py-2.5 font-medium text-white hover:bg-neutral-800"
> >
<Icon icon="ph:seal-check-duotone" class="h-4 w-4" /> <Icon icon="ph:arrow-clockwise-duotone" class="h-4 w-4" />
Activate Now Retry
</button> </button>
</form> {/if}
<div class="mt-6 rounded-xl border border-neutral-200 bg-neutral-50 p-4"> <div class="mt-6 rounded-xl border border-neutral-200 bg-neutral-50 p-4">
<p class="text-center text-sm text-neutral-500"> <p class="text-center text-sm text-neutral-500">