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:
nix-dev 2026-03-12 00:35:21 -04:00
parent 4eb1d59230
commit be3eafcd9c
6 changed files with 495 additions and 1 deletions

View file

@ -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/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/transactions/reset' => $defaults['is_admin'] ? admin::transactions_reset($defaults) : lost::index($defaults),
'/admin/returns' => $defaults['is_admin'] ? admin::returns($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(), '/magic-link' => magic_link::index(),
'/checkout/confirmed' => checkout::confirmed($defaults), '/checkout/confirmed' => checkout::confirmed($defaults),
'/checkout/review-pay' => checkout::review_pay($defaults), '/checkout/review-pay' => checkout::review_pay($defaults),

View file

@ -4,6 +4,7 @@ namespace app\controllers;
use app\models\categories; use app\models\categories;
use app\models\emails; use app\models\emails;
use app\models\products; use app\models\products;
use app\models\subscriptions;
use app\models\transactions; use app\models\transactions;
use app\models\users; use app\models\users;
@ -354,4 +355,111 @@ class admin
header('Location: /admin/transactions/add'); header('Location: /admin/transactions/add');
exit; 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');
}
} }

View file

@ -13,6 +13,10 @@ class subscriptions
'COMPLETED', 'CANCELED', 'COMPLETED', 'CANCELED',
]; ];
const FREQUENCIES = [
'minute', 'hour', 'day', 'week', 'month', 'year',
];
public static function init() public static function init()
{ {
app::$db->exec("CREATE TABLE IF NOT EXISTS subscriptions ( 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::validateState($state);
self::validateStatus($status); self::validateStatus($status);
@ -83,6 +87,117 @@ class subscriptions
return $stmt->fetchAll(); 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) private static function validateState(string $state)
{ {
if (! in_array($state, self::STATES, true)) { if (! in_array($state, self::STATES, true)) {

View file

@ -23,6 +23,9 @@
<a href="/admin/transactions"> <a href="/admin/transactions">
Transactions Transactions
</a> </a>
<a href="/admin/subscriptions">
Subscriptions
</a>
INDEX INDEX
</section> </section>

View 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>

View 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>