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:
@@ -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)
|
||||||
await polar.licenseKeys.activate({
|
try {
|
||||||
key,
|
await polar.licenseKeys.activate({
|
||||||
organizationId: env.POLAR_ORGANIZATION_ID,
|
key,
|
||||||
label: `${currentUser.email}`,
|
organizationId: env.POLAR_ORGANIZATION_ID,
|
||||||
});
|
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)
|
||||||
await polar.licenseKeys.activate({
|
try {
|
||||||
key: customerKey.key,
|
await polar.licenseKeys.activate({
|
||||||
organizationId: env.POLAR_ORGANIZATION_ID,
|
key: customerKey.key,
|
||||||
label: `${currentUser.email}`,
|
organizationId: env.POLAR_ORGANIZATION_ID,
|
||||||
});
|
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 || '';
|
redirect(303, '/dashboard');
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|||||||
@@ -1,38 +1,66 @@
|
|||||||
<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">
|
||||||
<div class="mb-8 text-center">
|
{#if checkoutStatus === 'activating'}
|
||||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-100">
|
<div class="mb-8 text-center">
|
||||||
<Icon icon="ph:spinner-duotone" class="h-6 w-6 animate-spin text-neutral-600" />
|
<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" />
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold">Activating your license...</h2>
|
||||||
|
<p class="mt-1 text-neutral-500">
|
||||||
|
We're setting up your account. This will only take a moment.
|
||||||
|
</p>
|
||||||
|
</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>
|
</div>
|
||||||
<h2 class="text-2xl font-bold">Activating your license...</h2>
|
|
||||||
<p class="mt-1 text-neutral-500">
|
|
||||||
We're setting up your account. This will only take a moment.
|
|
||||||
</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">
|
||||||
|
|||||||
Reference in New Issue
Block a user