From be3eafcd9c6f99ebfad5f5b68148b02dda87c667 Mon Sep 17 00:00:00 2001 From: nix-dev Date: Thu, 12 Mar 2026 00:35:21 -0400 Subject: [PATCH] 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 --- public/index.php | 2 + src/controllers/admin.php | 108 ++++++++++++ src/models/subscriptions.php | 117 ++++++++++++- src/views/admin/index.twig | 3 + src/views/admin/subscriptions/add.twig | 53 ++++++ src/views/admin/subscriptions/index.twig | 213 +++++++++++++++++++++++ 6 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 src/views/admin/subscriptions/add.twig create mode 100644 src/views/admin/subscriptions/index.twig diff --git a/public/index.php b/public/index.php index 849a664..b130e80 100644 --- a/public/index.php +++ b/public/index.php @@ -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), diff --git a/src/controllers/admin.php b/src/controllers/admin.php index c5a6a4f..17d6132 100644 --- a/src/controllers/admin.php +++ b/src/controllers/admin.php @@ -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'); + } } diff --git a/src/models/subscriptions.php b/src/models/subscriptions.php index 65f64a9..bbbb9fd 100644 --- a/src/models/subscriptions.php +++ b/src/models/subscriptions.php @@ -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)) { diff --git a/src/views/admin/index.twig b/src/views/admin/index.twig index a8d22e6..8b5b857 100644 --- a/src/views/admin/index.twig +++ b/src/views/admin/index.twig @@ -23,6 +23,9 @@ Transactions + + Subscriptions + INDEX diff --git a/src/views/admin/subscriptions/add.twig b/src/views/admin/subscriptions/add.twig new file mode 100644 index 0000000..4a42a4d --- /dev/null +++ b/src/views/admin/subscriptions/add.twig @@ -0,0 +1,53 @@ +
+ {% include 'lib/alert.twig' %} +
+ {% include 'lib/inputs/text.twig' with { + type: 'text', + name: 'user_identifier', + label: 'User Identifier', + placeholder: 'Enter email or user ID', + required: true + } %} + + + {% 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', + } %} +
+
diff --git a/src/views/admin/subscriptions/index.twig b/src/views/admin/subscriptions/index.twig new file mode 100644 index 0000000..c10d83e --- /dev/null +++ b/src/views/admin/subscriptions/index.twig @@ -0,0 +1,213 @@ +
+ {% include 'lib/alert.twig' %} + + + {% include 'lib/buttons/submit.twig' with { + label: 'Create a New Subscription', + } %} + + +

+ Revenue +

+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ Metric + + Value +
+ Active Subscriptions + + {{ active_count }} +
+ Total Revenue (Cents) + + {{ revenue.total_cents }} +
+ Total Revenue (Sats) + + {{ revenue.total_sats }} +
+ Total Completed + + {{ revenue.total_count }} +
+ +

+ Projected Revenue (No New Signups, No Cancelations) +

+ + + + + + + + + + {% for period, totals in projected_revenue %} + + + + + + {% endfor %} + +
+ Period + + Cents + + Sats +
+ {{ period }} + + {{ totals.cents }} + + {{ totals.sats }} +
+ +

+ Upcoming Events +

+ + + + + + + + + + + + {% if upcoming is not empty %} + {% for sub in upcoming %} + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
+ ID + + User + + Product + + State + + Renews At +
+ {{ sub.subscription_id }} + + {{ sub.user_email }} + + {{ sub.product_title }} + + {{ sub.state }} + + {{ sub.renews_at }} +
+ No upcoming subscription events. +
+ +

+ Recent Subscription Events +

+ + + + + + + + + + + + + + {% if recent is not empty %} + {% for sub in recent %} + + + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
+ ID + + User + + Product + + Status + + State + + Start Date + + Renews At +
+ {{ sub.subscription_id }} + + {{ sub.user_email }} + + {{ sub.product_title }} + + {{ sub.status }} + + {{ sub.state }} + + {{ sub.start_date }} + + {{ sub.renews_at }} +
+ No subscription events yet. +
+