002cdf312b
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
417 lines
20 KiB
HTML
417 lines
20 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Stripe Setup Wizard — DocuElevate Admin{% endblock %}
|
|
|
|
{% block content %}
|
|
<main id="main-content" class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"
|
|
x-data="stripeWizard()"
|
|
x-init="init()">
|
|
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-gray-900">
|
|
<i class="fab fa-stripe text-indigo-600 mr-2" aria-hidden="true"></i>Stripe Setup Wizard
|
|
</h1>
|
|
<p class="text-sm text-gray-500 mt-1">Configure Stripe billing for DocuElevate in three steps.</p>
|
|
</div>
|
|
<a href="/admin/plans"
|
|
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400"
|
|
>
|
|
<i class="fas fa-arrow-left mr-2 text-gray-400" aria-hidden="true"></i>Back to Plans
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div x-show="loading" class="text-center py-12 text-gray-400">
|
|
<i class="fas fa-spinner fa-spin text-2xl" aria-hidden="true"></i>
|
|
<p class="mt-2 text-sm">Checking Stripe status…</p>
|
|
</div>
|
|
|
|
<!-- Main wizard content -->
|
|
<div x-show="!loading" x-cloak>
|
|
|
|
<!-- Step progress bar -->
|
|
<nav aria-label="Wizard steps" class="mb-8">
|
|
<ol class="flex items-center space-x-2">
|
|
<template x-for="(step, i) in steps" :key="i">
|
|
<li class="flex items-center">
|
|
<button
|
|
@click="currentStep = i"
|
|
class="flex items-center focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400 rounded"
|
|
:aria-current="currentStep === i ? 'step' : null"
|
|
:aria-label="'Step ' + (i + 1) + ': ' + step.title"
|
|
>
|
|
<span
|
|
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold border-2 transition"
|
|
:class="currentStep === i
|
|
? 'bg-indigo-600 text-white border-indigo-600'
|
|
: stepDone(i)
|
|
? 'bg-green-100 text-green-700 border-green-400'
|
|
: 'bg-white text-gray-400 border-gray-300'"
|
|
x-text="stepDone(i) ? '✓' : (i + 1)"
|
|
></span>
|
|
<span class="ml-2 text-sm font-medium hidden sm:block"
|
|
:class="currentStep === i ? 'text-indigo-700' : 'text-gray-500'"
|
|
x-text="step.title"></span>
|
|
</button>
|
|
<template x-if="i < steps.length - 1">
|
|
<div class="flex-1 h-px bg-gray-200 mx-3 hidden sm:block" style="min-width:2rem;"></div>
|
|
</template>
|
|
</li>
|
|
</template>
|
|
</ol>
|
|
</nav>
|
|
|
|
<!-- ── Step 0: Verify API Keys ──────────────────────────────────────── -->
|
|
<section x-show="currentStep === 0" class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 space-y-6">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-gray-900 mb-1">Step 1 — Verify Stripe API Keys</h2>
|
|
<p class="text-sm text-gray-600">
|
|
Set your Stripe keys in your environment (or <code>.env</code> file) then click
|
|
<strong>Check Connection</strong> to verify.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Config snippet -->
|
|
<div class="bg-gray-50 rounded-md p-4 text-sm font-mono text-gray-700 border border-gray-200 space-y-1" role="note">
|
|
<p>STRIPE_SECRET_KEY=<span class="text-indigo-600">sk_test_…</span></p>
|
|
<p>STRIPE_PUBLISHABLE_KEY=<span class="text-indigo-600">pk_test_…</span></p>
|
|
<p>STRIPE_WEBHOOK_SECRET=<span class="text-indigo-600">whsec_…</span></p>
|
|
</div>
|
|
|
|
<!-- Status display -->
|
|
<div x-show="status !== null">
|
|
<!-- Not configured -->
|
|
<div x-show="status && !status.configured"
|
|
class="rounded-md bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700" role="alert">
|
|
<i class="fas fa-times-circle mr-1" aria-hidden="true"></i>
|
|
<strong>STRIPE_SECRET_KEY</strong> is not set.
|
|
Add it to your environment and restart the server.
|
|
</div>
|
|
|
|
<!-- Configured — connection result -->
|
|
<div x-show="status && status.configured">
|
|
<div x-show="status && status.connection === 'ok'"
|
|
class="rounded-md bg-green-50 border border-green-200 px-4 py-3 text-sm text-green-700" role="status">
|
|
<i class="fas fa-check-circle mr-1" aria-hidden="true"></i>
|
|
Connected to Stripe
|
|
<span class="font-semibold" x-text="status && status.mode === 'test' ? '(Test mode)' : '(Live mode)'"></span>.
|
|
</div>
|
|
<div x-show="status && status.connection !== 'ok'"
|
|
class="rounded-md bg-yellow-50 border border-yellow-200 px-4 py-3 text-sm text-yellow-800" role="alert">
|
|
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
|
|
<span x-text="'Connection error: ' + (status && status.connection)"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
@click="checkStatus()"
|
|
:disabled="checking"
|
|
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 disabled:opacity-50"
|
|
style="min-height:44px"
|
|
>
|
|
<i class="fas fa-plug mr-2" aria-hidden="true"></i>
|
|
<span x-show="!checking">Check Connection</span>
|
|
<span x-show="checking"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Checking…</span>
|
|
</button>
|
|
<button
|
|
x-show="status && status.configured && status.connection === 'ok'"
|
|
@click="currentStep = 1"
|
|
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-green-500"
|
|
style="min-height:44px"
|
|
>
|
|
Next <i class="fas fa-arrow-right ml-2" aria-hidden="true"></i>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── Step 1: Sync Plans ───────────────────────────────────────────── -->
|
|
<section x-show="currentStep === 1" class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 space-y-6">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-gray-900 mb-1">Step 2 — Sync Plans to Stripe</h2>
|
|
<p class="text-sm text-gray-600">
|
|
DocuElevate can auto-create Stripe <strong>Products</strong> and <strong>Prices</strong> for each
|
|
paid plan. Free plans are skipped. Plans that already have a Stripe price ID are left unchanged.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Plan grid -->
|
|
<div x-show="status && status.plans && status.plans.length > 0">
|
|
<table class="min-w-full text-sm divide-y divide-gray-200">
|
|
<caption class="sr-only">Plan sync status</caption>
|
|
<thead class="bg-gray-50">
|
|
<tr>
|
|
<th scope="col" class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Plan</th>
|
|
<th scope="col" class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Monthly Price ID</th>
|
|
<th scope="col" class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Yearly Price ID</th>
|
|
<th scope="col" class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="bg-white divide-y divide-gray-100">
|
|
<template x-for="plan in (status && status.plans || [])" :key="plan.plan_id">
|
|
<tr>
|
|
<td class="px-3 py-2">
|
|
<span class="font-medium text-gray-900" x-text="plan.name"></span>
|
|
<span class="block text-xs text-gray-400" x-text="plan.plan_id"></span>
|
|
</td>
|
|
<td class="px-3 py-2 text-right font-mono text-xs text-gray-600">
|
|
<span x-show="plan.stripe_price_id_monthly" x-text="plan.stripe_price_id_monthly" class="text-green-700"></span>
|
|
<span x-show="!plan.stripe_price_id_monthly && plan.price_monthly > 0" class="text-red-500">not set</span>
|
|
<span x-show="!plan.stripe_price_id_monthly && plan.price_monthly === 0" class="text-gray-400">—</span>
|
|
</td>
|
|
<td class="px-3 py-2 text-right font-mono text-xs text-gray-600">
|
|
<span x-show="plan.stripe_price_id_yearly" x-text="plan.stripe_price_id_yearly" class="text-green-700"></span>
|
|
<span x-show="!plan.stripe_price_id_yearly && plan.price_yearly > 0" class="text-red-500">not set</span>
|
|
<span x-show="!plan.stripe_price_id_yearly && plan.price_yearly === 0" class="text-gray-400">—</span>
|
|
</td>
|
|
<td class="px-3 py-2 text-center">
|
|
<span
|
|
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
|
:class="plan.synced ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'"
|
|
x-text="plan.synced ? 'Synced' : 'Needs sync'"
|
|
></span>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Sync result -->
|
|
<div x-show="syncResults !== null" class="space-y-2" aria-live="polite">
|
|
<template x-for="r in (syncResults || [])" :key="r.plan_id">
|
|
<div class="flex items-center gap-2 text-sm">
|
|
<i class="fas"
|
|
:class="r.status === 'created' ? 'fa-check-circle text-green-500'
|
|
: r.status === 'already_synced' ? 'fa-check text-gray-400'
|
|
: r.status === 'skipped_free' ? 'fa-minus text-gray-300'
|
|
: 'fa-times-circle text-red-500'"
|
|
aria-hidden="true"></i>
|
|
<span class="font-medium" x-text="r.name"></span>
|
|
<span class="text-gray-500"
|
|
x-text="r.status === 'created' ? '— created in Stripe'
|
|
: r.status === 'already_synced' ? '— already synced'
|
|
: r.status === 'skipped_free' ? '— free plan, skipped'
|
|
: ('— error: ' + r.detail)"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
@click="currentStep = 0"
|
|
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-gray-400"
|
|
style="min-height:44px"
|
|
>
|
|
<i class="fas fa-arrow-left mr-2" aria-hidden="true"></i> Back
|
|
</button>
|
|
<button
|
|
@click="syncPlans()"
|
|
:disabled="syncing || !(status && status.configured)"
|
|
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 disabled:opacity-50"
|
|
style="min-height:44px"
|
|
>
|
|
<i class="fas fa-sync-alt mr-2" aria-hidden="true"></i>
|
|
<span x-show="!syncing">Sync Plans to Stripe</span>
|
|
<span x-show="syncing"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Syncing…</span>
|
|
</button>
|
|
<button
|
|
x-show="syncResults !== null"
|
|
@click="currentStep = 2; checkStatus()"
|
|
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-green-500"
|
|
style="min-height:44px"
|
|
>
|
|
Next <i class="fas fa-arrow-right ml-2" aria-hidden="true"></i>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── Step 2: Webhook ──────────────────────────────────────────────── -->
|
|
<section x-show="currentStep === 2" class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 space-y-6">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-gray-900 mb-1">Step 3 — Configure Stripe Webhook</h2>
|
|
<p class="text-sm text-gray-600">
|
|
Webhooks let Stripe notify DocuElevate of subscription changes in real time.
|
|
Add the endpoint below in your
|
|
<a href="https://dashboard.stripe.com/webhooks" target="_blank" rel="noopener noreferrer"
|
|
class="text-indigo-600 hover:text-indigo-800 underline focus:outline-none focus:ring-1 focus:ring-indigo-400 rounded">
|
|
Stripe Dashboard → Developers → Webhooks
|
|
</a>.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Webhook URL -->
|
|
<div>
|
|
<label for="webhook-url" class="block text-sm font-medium text-gray-700 mb-1">Webhook Endpoint URL</label>
|
|
<div class="flex items-center gap-2">
|
|
<input
|
|
id="webhook-url"
|
|
type="text"
|
|
:value="status && status.webhook_endpoint"
|
|
readonly
|
|
class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm bg-gray-50 font-mono focus:outline-none"
|
|
aria-label="Webhook endpoint URL to register in Stripe"
|
|
/>
|
|
<button
|
|
@click="copyWebhook()"
|
|
class="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400"
|
|
style="min-height:44px"
|
|
aria-label="Copy webhook URL to clipboard"
|
|
>
|
|
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" aria-hidden="true"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Events list -->
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-700 mb-2">Subscribe to these events:</p>
|
|
<ul class="list-none space-y-1 text-sm font-mono text-gray-600">
|
|
<li><i class="fas fa-check-circle text-green-500 mr-2" aria-hidden="true"></i>checkout.session.completed</li>
|
|
<li><i class="fas fa-check-circle text-green-500 mr-2" aria-hidden="true"></i>customer.subscription.updated</li>
|
|
<li><i class="fas fa-check-circle text-green-500 mr-2" aria-hidden="true"></i>customer.subscription.deleted</li>
|
|
<li><i class="fas fa-check-circle text-green-500 mr-2" aria-hidden="true"></i>invoice.payment_failed</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Webhook secret instructions -->
|
|
<div class="rounded-md bg-blue-50 border border-blue-200 px-4 py-3 text-sm text-blue-800" role="note">
|
|
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
|
|
After adding the endpoint, copy the <strong>Signing secret</strong> from Stripe and set
|
|
<code class="bg-blue-100 px-1 rounded">STRIPE_WEBHOOK_SECRET=whsec_…</code> in your environment.
|
|
This protects against spoofed webhook events.
|
|
</div>
|
|
|
|
<!-- Webhook secret status -->
|
|
<div x-show="status">
|
|
<div x-show="status && status.webhook_secret_set"
|
|
class="rounded-md bg-green-50 border border-green-200 px-4 py-3 text-sm text-green-700" role="status">
|
|
<i class="fas fa-check-circle mr-1" aria-hidden="true"></i>
|
|
<strong>STRIPE_WEBHOOK_SECRET</strong> is configured. Webhook signatures are verified.
|
|
</div>
|
|
<div x-show="status && !status.webhook_secret_set"
|
|
class="rounded-md bg-yellow-50 border border-yellow-200 px-4 py-3 text-sm text-yellow-800" role="alert">
|
|
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
|
|
<strong>STRIPE_WEBHOOK_SECRET</strong> is not set. Webhook events are accepted without
|
|
signature verification. Set it in your environment for production security.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Local testing -->
|
|
<details class="border border-gray-200 rounded-md">
|
|
<summary class="px-4 py-3 text-sm font-medium text-gray-700 cursor-pointer hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400 rounded-md">
|
|
<i class="fas fa-terminal mr-1" aria-hidden="true"></i> Local testing with Stripe CLI
|
|
</summary>
|
|
<div class="px-4 pb-4 pt-2 bg-gray-50 rounded-b-md">
|
|
<pre class="text-xs text-gray-700 overflow-x-auto space-y-1"><code># Install Stripe CLI and log in
|
|
stripe login
|
|
|
|
# Forward webhooks to your local server
|
|
stripe listen --forward-to http://localhost:8000/api/billing/webhook
|
|
|
|
# Trigger test events
|
|
stripe trigger checkout.session.completed
|
|
stripe trigger customer.subscription.updated
|
|
stripe trigger customer.subscription.deleted</code></pre>
|
|
</div>
|
|
</details>
|
|
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
@click="currentStep = 1"
|
|
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-gray-400"
|
|
style="min-height:44px"
|
|
>
|
|
<i class="fas fa-arrow-left mr-2" aria-hidden="true"></i> Back
|
|
</button>
|
|
<a href="/admin/plans"
|
|
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-green-500"
|
|
style="min-height:44px"
|
|
>
|
|
<i class="fas fa-check mr-2" aria-hidden="true"></i> Done — Go to Plan Designer
|
|
</a>
|
|
</div>
|
|
</section>
|
|
|
|
</div><!-- /!loading -->
|
|
</main>
|
|
|
|
<script>
|
|
function stripeWizard() {
|
|
return {
|
|
loading: true,
|
|
checking: false,
|
|
syncing: false,
|
|
currentStep: 0,
|
|
status: null,
|
|
syncResults: null,
|
|
copied: false,
|
|
|
|
steps: [
|
|
{ title: 'Verify API Keys' },
|
|
{ title: 'Sync Plans' },
|
|
{ title: 'Configure Webhook' },
|
|
],
|
|
|
|
stepDone(i) {
|
|
if (i === 0) return this.status && this.status.configured && this.status.connection === 'ok';
|
|
if (i === 1) return this.syncResults !== null;
|
|
return false;
|
|
},
|
|
|
|
async init() {
|
|
await this.checkStatus();
|
|
this.loading = false;
|
|
},
|
|
|
|
async checkStatus() {
|
|
this.checking = true;
|
|
try {
|
|
const resp = await fetch('/api/billing/stripe/status');
|
|
if (!resp.ok) throw new Error('Failed to fetch Stripe status');
|
|
this.status = await resp.json();
|
|
// Determine if webhook secret is set by checking the status returned from the API
|
|
this.status.webhook_secret_set = this.status.webhook_secret_configured ?? false;
|
|
} catch (e) {
|
|
this.status = { configured: false, connection: e.message, mode: null, plans: [] };
|
|
} finally {
|
|
this.checking = false;
|
|
}
|
|
},
|
|
|
|
async syncPlans() {
|
|
this.syncing = true;
|
|
this.syncResults = null;
|
|
try {
|
|
const resp = await fetch('/api/billing/stripe/sync-plans', { method: 'POST' });
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
|
throw new Error(err.detail || 'Sync failed');
|
|
}
|
|
const data = await resp.json();
|
|
this.syncResults = data.results || [];
|
|
// Refresh status to update price IDs shown in the table
|
|
await this.checkStatus();
|
|
} catch (e) {
|
|
this.syncResults = [{ plan_id: '_error', name: 'Error', status: 'error', detail: e.message }];
|
|
} finally {
|
|
this.syncing = false;
|
|
}
|
|
},
|
|
|
|
copyWebhook() {
|
|
const url = this.status && this.status.webhook_endpoint;
|
|
if (!url) return;
|
|
navigator.clipboard.writeText(url).then(() => {
|
|
this.copied = true;
|
|
setTimeout(() => { this.copied = false; }, 2000);
|
|
}).catch(() => {});
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock %}
|