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"
|
||||
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({
|
||||
key,
|
||||
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
|
||||
await db
|
||||
@@ -140,12 +146,19 @@ license.post("/activate-checkout", async (c) => {
|
||||
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({
|
||||
key: customerKey.key,
|
||||
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
|
||||
await db
|
||||
@@ -161,6 +174,14 @@ license.post("/activate-checkout", async (c) => {
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,69 +1,44 @@
|
||||
import { form, query, getRequestEvent } from '$app/server';
|
||||
import { form, command, getRequestEvent } from '$app/server';
|
||||
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 * as v from 'valibot';
|
||||
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();
|
||||
if (!locals.user) return null;
|
||||
if (!locals.user) throw new Error('Not authenticated');
|
||||
|
||||
const rows = await db
|
||||
.select({ plan: user.plan, polarLicenseKey: user.polarLicenseKey })
|
||||
.from(user)
|
||||
.where(eq(user.id, locals.user.id))
|
||||
.limit(1);
|
||||
const serverUrl = env.SERVER_URL || 'http://localhost:8080';
|
||||
const internalSecret = env.INTERNAL_SECRET || '';
|
||||
|
||||
const row = rows[0];
|
||||
return {
|
||||
activated: !!row?.plan,
|
||||
plan: row?.plan ?? null,
|
||||
licenseKey: row?.polarLicenseKey ?? null
|
||||
};
|
||||
const res = await fetch(`${serverUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'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) => {
|
||||
const { locals } = getRequestEvent();
|
||||
if (!locals.user) return;
|
||||
|
||||
const serverUrl = env.SERVER_URL || 'http://localhost:8080';
|
||||
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) {
|
||||
await serverFetch('/license/activate', { key: data.key });
|
||||
redirect(303, '/dashboard');
|
||||
});
|
||||
|
||||
export const activateFromCheckout = command(
|
||||
v.object({ checkoutId: v.string() }),
|
||||
async ({ checkoutId }) => {
|
||||
const result = await serverFetch('/license/activate-checkout', { checkoutId });
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
export const activateFromCheckout = form(activateCheckoutSchema, async (data) => {
|
||||
const { locals } = getRequestEvent();
|
||||
if (!locals.user) return;
|
||||
|
||||
const serverUrl = env.SERVER_URL || 'http://localhost:8080';
|
||||
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,17 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { activateLicense, activateFromCheckout } from '$lib/api/license.remote';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { LICENSE_ACTIVATE_CHECKOUT, LICENSE_ACTIVATE_MANUAL, LICENSE_PURCHASE_CLICK } from '$lib/analytics/events';
|
||||
|
||||
const checkoutId = page.url.searchParams.get('checkout_id');
|
||||
|
||||
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>
|
||||
|
||||
{#if checkoutId}
|
||||
<!-- Auto-activate from Polar checkout -->
|
||||
<div class="mx-auto max-w-md pt-20">
|
||||
{#if checkoutStatus === 'activating'}
|
||||
<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">
|
||||
<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.
|
||||
</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>
|
||||
|
||||
<form {...activateFromCheckout} class="space-y-4">
|
||||
<input type="hidden" {...activateFromCheckout.fields.checkoutId.as('text')} value={checkoutId} />
|
||||
<button
|
||||
type="submit"
|
||||
onclick={activateCheckout}
|
||||
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"
|
||||
>
|
||||
<Icon icon="ph:seal-check-duotone" class="h-4 w-4" />
|
||||
Activate Now
|
||||
<Icon icon="ph:arrow-clockwise-duotone" class="h-4 w-4" />
|
||||
Retry
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 rounded-xl border border-neutral-200 bg-neutral-50 p-4">
|
||||
<p class="text-center text-sm text-neutral-500">
|
||||
|
||||
Reference in New Issue
Block a user