feat: add admin subscriptions dashboard and add form
- /admin/subscriptions: shows revenue stats, projected revenue (1mo/3mo/6mo/1yr), upcoming renewal events, and recent subscription history - /admin/subscriptions/add: form to create subscriptions by user email/ID with product selection, frequency override, and initial state - Fix bug in createSubscription() where $status was undefined parameter - Add model methods: getRecent, getUpcoming, getTotalRevenue, getActiveCount, getProjectedRevenue, getSubscriptionProducts - Add nav link on admin dashboard Closes #17
This commit is contained in:
parent
4eb1d59230
commit
be3eafcd9c
6 changed files with 495 additions and 1 deletions
|
|
@ -124,6 +124,8 @@ if (preg_match('/^\/(address(?:\/edit|\/delete)?|transaction|user|order|quote|pr
|
|||
'/admin/transactions/add' => $defaults['is_admin'] ? admin::transactions_add($defaults) : lost::index($defaults),
|
||||
'/admin/transactions/reset' => $defaults['is_admin'] ? admin::transactions_reset($defaults) : lost::index($defaults),
|
||||
'/admin/returns' => $defaults['is_admin'] ? admin::returns($defaults) : lost::index($defaults),
|
||||
'/admin/subscriptions' => $defaults['is_admin'] ? admin::subscriptions($defaults) : lost::index($defaults),
|
||||
'/admin/subscriptions/add' => $defaults['is_admin'] ? admin::subscriptions_add($defaults) : lost::index($defaults),
|
||||
'/magic-link' => magic_link::index(),
|
||||
'/checkout/confirmed' => checkout::confirmed($defaults),
|
||||
'/checkout/review-pay' => checkout::review_pay($defaults),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace app\controllers;
|
|||
use app\models\categories;
|
||||
use app\models\emails;
|
||||
use app\models\products;
|
||||
use app\models\subscriptions;
|
||||
use app\models\transactions;
|
||||
use app\models\users;
|
||||
|
||||
|
|
@ -354,4 +355,111 @@ class admin
|
|||
header('Location: /admin/transactions/add');
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function subscriptions($defaults)
|
||||
{
|
||||
echo $GLOBALS['twig']->render('lib/pages/index.twig', array_merge($defaults, [
|
||||
'child_template' => 'admin/subscriptions/index.twig',
|
||||
'page_title' => 'Subscriptions',
|
||||
'recent' => subscriptions::getRecent(20),
|
||||
'upcoming' => subscriptions::getUpcoming(20),
|
||||
'revenue' => subscriptions::getTotalRevenue(),
|
||||
'active_count' => subscriptions::getActiveCount(),
|
||||
'projected_revenue' => subscriptions::getProjectedRevenue(),
|
||||
'breadcrumbs' => [
|
||||
[
|
||||
'url' => '/admin',
|
||||
'title' => 'Admin',
|
||||
],
|
||||
[
|
||||
'url' => '/admin/subscriptions',
|
||||
'title' => 'Subscriptions',
|
||||
],
|
||||
],
|
||||
]));
|
||||
}
|
||||
|
||||
public static function subscriptions_add($defaults)
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
|
||||
$_SESSION['last_post'] = $_POST;
|
||||
|
||||
$user_identifier = $_POST['user_identifier'] ?? null;
|
||||
$product_id = $_POST['product_id'] ?? null;
|
||||
$frequency = $_POST['frequency'] ?? null;
|
||||
$state = $_POST['state'] ?? 'START';
|
||||
|
||||
if (! $user_identifier || ! $product_id) {
|
||||
$_SESSION['error'] = 'User identifier and subscription product are required.';
|
||||
header('Location: /admin/subscriptions/add');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Resolve user
|
||||
$user = null;
|
||||
if (strpos($user_identifier, '@') !== false && strpos($user_identifier, '.') !== false) {
|
||||
$user = users::getByEmail($user_identifier);
|
||||
} elseif (is_numeric($user_identifier)) {
|
||||
$user = users::getById((int) $user_identifier);
|
||||
}
|
||||
|
||||
if (! $user) {
|
||||
$_SESSION['error'] = 'User not found. Please enter a valid email or user ID.';
|
||||
header('Location: /admin/subscriptions/add');
|
||||
exit;
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$renewAt = self::calculateRenewDate($now, $frequency ?: 'month');
|
||||
|
||||
$subId = subscriptions::createSubscription(
|
||||
$user['id'],
|
||||
(int) $product_id,
|
||||
$state,
|
||||
$now,
|
||||
$renewAt,
|
||||
$now
|
||||
);
|
||||
|
||||
$_SESSION['success'] = 'Subscription created successfully.';
|
||||
unset($_SESSION['last_post']);
|
||||
header('Location: /admin/subscriptions');
|
||||
exit;
|
||||
}
|
||||
|
||||
echo $GLOBALS['twig']->render('lib/pages/index.twig', array_merge($defaults, [
|
||||
'child_template' => 'admin/subscriptions/add.twig',
|
||||
'page_title' => 'Add Subscription',
|
||||
'subscription_products' => subscriptions::getSubscriptionProducts(),
|
||||
'breadcrumbs' => [
|
||||
[
|
||||
'url' => '/admin',
|
||||
'title' => 'Admin',
|
||||
],
|
||||
[
|
||||
'url' => '/admin/subscriptions',
|
||||
'title' => 'Subscriptions',
|
||||
],
|
||||
[
|
||||
'url' => '/admin/subscriptions/add',
|
||||
'title' => 'Add',
|
||||
],
|
||||
],
|
||||
]));
|
||||
}
|
||||
|
||||
private static function calculateRenewDate(string $startDate, string $frequency): string
|
||||
{
|
||||
$date = new \DateTime($startDate);
|
||||
switch ($frequency) {
|
||||
case 'minute': $date->modify('+1 minute'); break;
|
||||
case 'hour': $date->modify('+1 hour'); break;
|
||||
case 'day': $date->modify('+1 day'); break;
|
||||
case 'week': $date->modify('+1 week'); break;
|
||||
case 'month': $date->modify('+1 month'); break;
|
||||
case 'year': $date->modify('+1 year'); break;
|
||||
default: $date->modify('+1 month'); break;
|
||||
}
|
||||
return $date->format('Y-m-d H:i:s');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ class subscriptions
|
|||
'COMPLETED', 'CANCELED',
|
||||
];
|
||||
|
||||
const FREQUENCIES = [
|
||||
'minute', 'hour', 'day', 'week', 'month', 'year',
|
||||
];
|
||||
|
||||
public static function init()
|
||||
{
|
||||
app::$db->exec("CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
|
|
@ -27,7 +31,7 @@ class subscriptions
|
|||
);");
|
||||
}
|
||||
|
||||
public static function createSubscription($userId, $productId, $state, $startDate, $renewAt, $invoiceDate)
|
||||
public static function createSubscription($userId, $productId, $state, $startDate, $renewAt, $invoiceDate, $status = 'COMPLETED')
|
||||
{
|
||||
self::validateState($state);
|
||||
self::validateStatus($status);
|
||||
|
|
@ -83,6 +87,117 @@ class subscriptions
|
|||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public static function getRecent(int $limit = 20): array
|
||||
{
|
||||
$stmt = app::$db->prepare("
|
||||
SELECT s.*, u.email as user_email, p.title as product_title,
|
||||
p.price_cents, p.price_sats, p.subscription_frequency
|
||||
FROM subscriptions s
|
||||
LEFT JOIN users u ON s.user_id = u.id
|
||||
LEFT JOIN products p ON s.product_id = p.id
|
||||
ORDER BY s.start_date DESC
|
||||
LIMIT :limit
|
||||
");
|
||||
$stmt->bindValue(':limit', $limit, \PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public static function getUpcoming(int $limit = 20): array
|
||||
{
|
||||
$stmt = app::$db->prepare("
|
||||
SELECT s.*, u.email as user_email, p.title as product_title,
|
||||
p.price_cents, p.price_sats, p.subscription_frequency
|
||||
FROM subscriptions s
|
||||
LEFT JOIN users u ON s.user_id = u.id
|
||||
LEFT JOIN products p ON s.product_id = p.id
|
||||
WHERE s.status = 'COMPLETED'
|
||||
AND s.renews_at >= datetime('now')
|
||||
ORDER BY s.renews_at ASC
|
||||
LIMIT :limit
|
||||
");
|
||||
$stmt->bindValue(':limit', $limit, \PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public static function getTotalRevenue(): array
|
||||
{
|
||||
$stmt = app::$db->query("
|
||||
SELECT COALESCE(SUM(p.price_cents), 0) as total_cents,
|
||||
COALESCE(SUM(p.price_sats), 0) as total_sats,
|
||||
COUNT(*) as total_count
|
||||
FROM subscriptions s
|
||||
LEFT JOIN products p ON s.product_id = p.id
|
||||
WHERE s.status = 'COMPLETED'
|
||||
");
|
||||
return $stmt->fetch(\PDO::FETCH_ASSOC) ?: ['total_cents' => 0, 'total_sats' => 0, 'total_count' => 0];
|
||||
}
|
||||
|
||||
public static function getActiveCount(): int
|
||||
{
|
||||
$stmt = app::$db->query("
|
||||
SELECT COUNT(*) as cnt
|
||||
FROM subscriptions
|
||||
WHERE status = 'COMPLETED'
|
||||
AND renews_at >= datetime('now')
|
||||
");
|
||||
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||
return (int) ($row['cnt'] ?? 0);
|
||||
}
|
||||
|
||||
public static function getProjectedRevenue(): array
|
||||
{
|
||||
$stmt = app::$db->query("
|
||||
SELECT p.price_cents, p.price_sats, p.subscription_frequency
|
||||
FROM subscriptions s
|
||||
LEFT JOIN products p ON s.product_id = p.id
|
||||
WHERE s.status = 'COMPLETED'
|
||||
AND s.renews_at >= datetime('now')
|
||||
");
|
||||
$active = $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
$projections = [
|
||||
'1mo' => ['cents' => 0, 'sats' => 0],
|
||||
'3mo' => ['cents' => 0, 'sats' => 0],
|
||||
'6mo' => ['cents' => 0, 'sats' => 0],
|
||||
'1yr' => ['cents' => 0, 'sats' => 0],
|
||||
];
|
||||
|
||||
$multipliers = [
|
||||
'minute' => ['1mo' => 43200, '3mo' => 129600, '6mo' => 259200, '1yr' => 525600],
|
||||
'hour' => ['1mo' => 720, '3mo' => 2160, '6mo' => 4320, '1yr' => 8760],
|
||||
'day' => ['1mo' => 30, '3mo' => 90, '6mo' => 180, '1yr' => 365],
|
||||
'week' => ['1mo' => 4, '3mo' => 13, '6mo' => 26, '1yr' => 52],
|
||||
'month' => ['1mo' => 1, '3mo' => 3, '6mo' => 6, '1yr' => 12],
|
||||
'year' => ['1mo' => 0, '3mo' => 0, '6mo' => 0, '1yr' => 1],
|
||||
];
|
||||
|
||||
foreach ($active as $sub) {
|
||||
$freq = $sub['subscription_frequency'] ?? 'month';
|
||||
$mults = $multipliers[$freq] ?? $multipliers['month'];
|
||||
|
||||
foreach ($projections as $period => &$totals) {
|
||||
$totals['cents'] += (int) ($sub['price_cents'] ?? 0) * $mults[$period];
|
||||
$totals['sats'] += (int) ($sub['price_sats'] ?? 0) * $mults[$period];
|
||||
}
|
||||
}
|
||||
|
||||
return $projections;
|
||||
}
|
||||
|
||||
public static function getSubscriptionProducts(): array
|
||||
{
|
||||
$stmt = app::$db->query("
|
||||
SELECT id, title, price_cents, price_sats, subscription_frequency
|
||||
FROM products
|
||||
WHERE is_subscription = 1
|
||||
AND is_enabled = 1
|
||||
ORDER BY title
|
||||
");
|
||||
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
private static function validateState(string $state)
|
||||
{
|
||||
if (! in_array($state, self::STATES, true)) {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@
|
|||
<a href="/admin/transactions">
|
||||
Transactions
|
||||
</a>
|
||||
<a href="/admin/subscriptions">
|
||||
Subscriptions
|
||||
</a>
|
||||
|
||||
INDEX
|
||||
</section>
|
||||
|
|
|
|||
53
src/views/admin/subscriptions/add.twig
Normal file
53
src/views/admin/subscriptions/add.twig
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<section class="flex flex-col gap-4">
|
||||
{% include 'lib/alert.twig' %}
|
||||
<form action="/admin/subscriptions/add" method="post" class="flex flex-col gap-4">
|
||||
{% include 'lib/inputs/text.twig' with {
|
||||
type: 'text',
|
||||
name: 'user_identifier',
|
||||
label: 'User Identifier',
|
||||
placeholder: 'Enter email or user ID',
|
||||
required: true
|
||||
} %}
|
||||
<label for="product_id" class="block text-sm font-medium mt-2">
|
||||
Subscription Product
|
||||
<span class="{{ colors.error.text }} ml-4">*</span>
|
||||
</label>
|
||||
<select id="product_id" name="product_id" class="border rounded-lg p-2 w-full" required>
|
||||
<option value="">-- Select Product --</option>
|
||||
{% for product in subscription_products %}
|
||||
<option value="{{ product.id }}">
|
||||
{{ product.title }} ({{ product.subscription_frequency }} - {{ product.price_cents }} cents / {{ product.price_sats }} sats)
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% include 'lib/inputs/select.twig' with {
|
||||
id: 'frequency',
|
||||
name: 'frequency',
|
||||
label: 'Frequency Override',
|
||||
options: [
|
||||
{ 'label': 'Per Minute', 'value': 'minute' },
|
||||
{ 'label': 'Hourly', 'value': 'hour' },
|
||||
{ 'label': 'Daily', 'value': 'day' },
|
||||
{ 'label': 'Weekly', 'value': 'week' },
|
||||
{ 'label': 'Monthly', 'value': 'month' },
|
||||
{ 'label': 'Yearly', 'value': 'year' }
|
||||
],
|
||||
value: 'month',
|
||||
no_option: { 'label': '-- Use Product Default --', 'value': '' }
|
||||
} %}
|
||||
{% include 'lib/inputs/select.twig' with {
|
||||
id: 'state',
|
||||
name: 'state',
|
||||
label: 'Initial State',
|
||||
options: [
|
||||
{ 'label': 'Trial', 'value': 'TRIAL' },
|
||||
{ 'label': 'Start', 'value': 'START' },
|
||||
{ 'label': 'Renewal', 'value': 'RENEWAL' }
|
||||
],
|
||||
value: 'START'
|
||||
} %}
|
||||
{% include 'lib/buttons/submit.twig' with {
|
||||
label: 'Create Subscription',
|
||||
} %}
|
||||
</form>
|
||||
</section>
|
||||
213
src/views/admin/subscriptions/index.twig
Normal file
213
src/views/admin/subscriptions/index.twig
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
<section class="flex flex-col gap-4">
|
||||
{% include 'lib/alert.twig' %}
|
||||
|
||||
<a href="/admin/subscriptions/add">
|
||||
{% include 'lib/buttons/submit.twig' with {
|
||||
label: 'Create a New Subscription',
|
||||
} %}
|
||||
</a>
|
||||
|
||||
<h3 class="text-2xl font-semibold">
|
||||
Revenue
|
||||
</h3>
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="py-2">
|
||||
Metric
|
||||
</th>
|
||||
<th class="py-2">
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="border px-4 py-2">
|
||||
Active Subscriptions
|
||||
</td>
|
||||
<td class="border px-4 py-2">
|
||||
{{ active_count }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border px-4 py-2">
|
||||
Total Revenue (Cents)
|
||||
</td>
|
||||
<td class="border px-4 py-2">
|
||||
{{ revenue.total_cents }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border px-4 py-2">
|
||||
Total Revenue (Sats)
|
||||
</td>
|
||||
<td class="border px-4 py-2">
|
||||
{{ revenue.total_sats }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border px-4 py-2">
|
||||
Total Completed
|
||||
</td>
|
||||
<td class="border px-4 py-2">
|
||||
{{ revenue.total_count }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 class="text-2xl font-semibold">
|
||||
Projected Revenue (No New Signups, No Cancelations)
|
||||
</h3>
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="py-2">
|
||||
Period
|
||||
</th>
|
||||
<th class="py-2">
|
||||
Cents
|
||||
</th>
|
||||
<th class="py-2">
|
||||
Sats
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for period, totals in projected_revenue %}
|
||||
<tr>
|
||||
<td class="border px-4 py-2">
|
||||
{{ period }}
|
||||
</td>
|
||||
<td class="border px-4 py-2">
|
||||
{{ totals.cents }}
|
||||
</td>
|
||||
<td class="border px-4 py-2">
|
||||
{{ totals.sats }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 class="text-2xl font-semibold">
|
||||
Upcoming Events
|
||||
</h3>
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="py-2">
|
||||
ID
|
||||
</th>
|
||||
<th class="py-2">
|
||||
User
|
||||
</th>
|
||||
<th class="py-2">
|
||||
Product
|
||||
</th>
|
||||
<th class="py-2">
|
||||
State
|
||||
</th>
|
||||
<th class="py-2">
|
||||
Renews At
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if upcoming is not empty %}
|
||||
{% for sub in upcoming %}
|
||||
<tr>
|
||||
<td class="border px-4 py-2">
|
||||
{{ sub.subscription_id }}
|
||||
</td>
|
||||
<td class="border px-4 py-2">
|
||||
{{ sub.user_email }}
|
||||
</td>
|
||||
<td class="border px-4 py-2">
|
||||
{{ sub.product_title }}
|
||||
</td>
|
||||
<td class="border px-4 py-2">
|
||||
{{ sub.state }}
|
||||
</td>
|
||||
<td class="border px-4 py-2">
|
||||
{{ sub.renews_at }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td class="border px-4 py-2" colspan="5">
|
||||
No upcoming subscription events.
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 class="text-2xl font-semibold">
|
||||
Recent Subscription Events
|
||||
</h3>
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="py-2">
|
||||
ID
|
||||
</th>
|
||||
<th class="py-2">
|
||||
User
|
||||
</th>
|
||||
<th class="py-2">
|
||||
Product
|
||||
</th>
|
||||
<th class="py-2">
|
||||
Status
|
||||
</th>
|
||||
<th class="py-2">
|
||||
State
|
||||
</th>
|
||||
<th class="py-2">
|
||||
Start Date
|
||||
</th>
|
||||
<th class="py-2">
|
||||
Renews At
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if recent is not empty %}
|
||||
{% for sub in recent %}
|
||||
<tr>
|
||||
<td class="border px-4 py-2">
|
||||
{{ sub.subscription_id }}
|
||||
</td>
|
||||
<td class="border px-4 py-2">
|
||||
{{ sub.user_email }}
|
||||
</td>
|
||||
<td class="border px-4 py-2">
|
||||
{{ sub.product_title }}
|
||||
</td>
|
||||
<td class="border px-4 py-2">
|
||||
{{ sub.status }}
|
||||
</td>
|
||||
<td class="border px-4 py-2">
|
||||
{{ sub.state }}
|
||||
</td>
|
||||
<td class="border px-4 py-2">
|
||||
{{ sub.start_date }}
|
||||
</td>
|
||||
<td class="border px-4 py-2">
|
||||
{{ sub.renews_at }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td class="border px-4 py-2" colspan="7">
|
||||
No subscription events yet.
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
Loading…
Add table
Add a link
Reference in a new issue