Files
budget/app/OnboardingService.php
Ty Clifford 6686388834 - Implemented the complete first-run experience:
Account creation and guarded onboarding flow.
Current-cash starting balance with automatic rollover enabled.
Dynamic bill generation, shared/custom categories, recurring targets, 
and optional same-day onboarding transactions.
Optional dormant reserve and additional categories.
Mailgun email or authenticator-app 2FA configuration.
Responsive dark/light UI with no overflow at 390px.
Fixed administrator session-ID creation bug.
Core implementation: [OnboardingService.php (line 
59)](/Users/tyemeclifford/Documents/GH/budget/app/OnboardingService.php:59), 
[onboarding.php (line 
19)](/Users/tyemeclifford/Documents/GH/budget/views/onboarding.php:19), 
and [index.php (line 
143)](/Users/tyemeclifford/Documents/GH/budget/public/index.php:143).
Verification passed: financial self-test, onboarding integration test, 
full browser walkthrough, mobile layout, routing guards, and zero 
browser console errors. Live email delivery requires actual Mailgun 
credentials and was not sent during testing.
2026-06-15 14:51:44 -04:00

356 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace Budget;
use PDO;
use RuntimeException;
final class OnboardingService
{
private const COLORS = [
'Utilities' => '#49a8ff',
'Housing' => '#8b5cf6',
'Communications' => '#ff5b9d',
'Subscriptions' => '#a98bff',
'Insurance' => '#ffad5c',
'Transportation' => '#ff6473',
'Debt' => '#ff6473',
'Food' => '#49e8a4',
'Health' => '#ff5b9d',
'Childcare' => '#ffad5c',
'Education' => '#49a8ff',
'TV' => '#ff5b9d',
'Cellular' => '#49a8ff',
'Internet' => '#49e8a4',
'Disney+' => '#8b5cf6',
'Netflix' => '#ff6473',
'Luxury' => '#ffad5c',
];
public function __construct(private PDO $pdo, private Settings $settings)
{
}
public function hasFinancialData(): bool
{
$tables = [
'monthly_plans',
'income_entries',
'categories',
'budget_items',
'transactions',
'dead_account_entries',
];
foreach ($tables as $table) {
if ((int) $this->pdo->query('SELECT COUNT(*) FROM ' . $table)->fetchColumn() > 0) {
return true;
}
}
return false;
}
/**
* @param list<array<string, mixed>> $bills
* @param list<string> $extraCategories
* @return array{bills:int, paid:int, categories:int, reserved:float}
*/
public function complete(
string $month,
float $cash,
array $bills,
array $extraCategories,
float $deadAmount
): array {
if ((bool) $this->settings->get('onboarding.completed', false)) {
throw new RuntimeException('Onboarding has already been completed.');
}
$month = Support::month($month);
$cash = round($cash, 2);
$deadAmount = round($deadAmount, 2);
if ($cash < 0) {
throw new RuntimeException('Current cash cannot be negative.');
}
if ($deadAmount < 0) {
throw new RuntimeException('Dormant reserve cannot be negative.');
}
if (count($bills) > 50) {
throw new RuntimeException('Onboarding supports up to 50 bills at a time.');
}
$normalizedBills = [];
$paidTotal = 0.0;
$deadPullTotal = 0.0;
foreach ($bills as $index => $bill) {
if (!is_array($bill)) {
throw new RuntimeException('Bill ' . ($index + 1) . ' is not valid.');
}
$name = trim((string) ($bill['name'] ?? ''));
$amount = round((float) ($bill['amount'] ?? 0), 2);
$dueDate = trim((string) ($bill['due_date'] ?? ''));
$recurrence = (string) ($bill['recurrence'] ?? 'monthly');
$paidNow = !empty($bill['paid_now']);
$category = trim((string) ($bill['category'] ?? ''));
if ($name === '') {
throw new RuntimeException('Bill ' . ($index + 1) . ' needs a name.');
}
if ($amount < 0) {
throw new RuntimeException($name . ' cannot have a negative target.');
}
if ($paidNow && $amount <= 0) {
throw new RuntimeException($name . ' needs an amount before it can be marked paid.');
}
if ($dueDate !== '' && !$this->dateIsInMonth($dueDate, $month)) {
throw new RuntimeException($name . ' needs a due date within ' . date('F Y', strtotime($month . '-01')) . '.');
}
if (!in_array($recurrence, ['none', 'monthly', 'yearly'], true)) {
$recurrence = 'monthly';
}
if ($category === '' || $category === '__bill__') {
$category = $name;
}
if ($category === '__custom__') {
$category = trim((string) ($bill['custom_category'] ?? ''));
}
if ($category === '') {
throw new RuntimeException($name . ' needs a category.');
}
$deadPull = $paidNow ? $this->automaticTransactionPull($amount) : 0.0;
if ($paidNow) {
$paidTotal += $amount;
$deadPullTotal += $deadPull;
}
$normalizedBills[] = [
'name' => $this->truncate($name, 255),
'amount' => $amount,
'due_date' => $dueDate ?: null,
'recurrence' => $recurrence,
'paid_now' => $paidNow,
'category' => $this->truncate($category, 191),
'dead_pull' => $deadPull,
];
}
$availableAfterPayments = $cash - $paidTotal - $deadPullTotal;
if ($availableAfterPayments < -0.0001) {
throw new RuntimeException('Bills marked paid exceed the cash available right now.');
}
if ($deadAmount > $availableAfterPayments + 0.0001) {
throw new RuntimeException('The dormant reserve is larger than the cash left after onboarding payments.');
}
$extraCategories = array_values(array_unique(array_filter(array_map(
static fn (mixed $name): string => trim((string) $name),
$extraCategories
))));
$createdCategories = [];
$paidCount = 0;
$this->pdo->beginTransaction();
try {
$planId = $this->upsertPlan($month, $cash);
foreach ($extraCategories as $categoryName) {
$categoryName = $this->truncate($categoryName, 191);
$categoryId = $this->findOrCreateCategory($categoryName);
$createdCategories[$categoryId] = true;
}
foreach ($normalizedBills as $bill) {
$categoryId = $this->findOrCreateCategory((string) $bill['category']);
$createdCategories[$categoryId] = true;
$seriesKey = $bill['recurrence'] === 'none' ? null : bin2hex(random_bytes(12));
$statement = $this->pdo->prepare(
'INSERT INTO budget_items
(month, category_id, name, planned_amount, due_date, recurrence, series_key, notes)
VALUES
(:month, :category_id, :name, :planned_amount, :due_date, :recurrence, :series_key, :notes)'
);
$statement->execute([
'month' => $month,
'category_id' => $categoryId,
'name' => $bill['name'],
'planned_amount' => $bill['amount'],
'due_date' => $bill['due_date'],
'recurrence' => $bill['recurrence'],
'series_key' => $seriesKey,
'notes' => 'Created during onboarding',
]);
$itemId = (int) $this->pdo->lastInsertId();
if (!$bill['paid_now']) {
continue;
}
$statement = $this->pdo->prepare(
'INSERT INTO transactions
(month, budget_item_id, category_id, merchant, amount, transacted_on, dead_pull_amount, notes)
VALUES
(:month, :budget_item_id, :category_id, :merchant, :amount, :transacted_on, :dead_pull, :notes)'
);
$statement->execute([
'month' => $month,
'budget_item_id' => $itemId,
'category_id' => $categoryId,
'merchant' => $bill['name'],
'amount' => $bill['amount'],
'transacted_on' => date('Y-m-d'),
'dead_pull' => $bill['dead_pull'],
'notes' => 'onboarding',
]);
$transactionId = (int) $this->pdo->lastInsertId();
$paidCount++;
if ((float) $bill['dead_pull'] > 0) {
$this->insertDeadEntry(
$month,
'transaction_pull',
(float) $bill['dead_pull'],
'transaction',
$transactionId,
'Automatic dormant transfer for ' . $bill['name']
);
}
}
if ($deadAmount > 0) {
$this->insertDeadEntry(
$month,
'manual_deposit',
$deadAmount,
'onboarding',
$planId,
'Dormant reserve created during onboarding'
);
}
$this->settings->set('onboarding.completed', true);
$this->settings->set('onboarding.completed_at', date(DATE_ATOM));
$this->pdo->commit();
} catch (\Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $exception;
}
return [
'bills' => count($normalizedBills),
'paid' => $paidCount,
'categories' => count($createdCategories),
'reserved' => $deadAmount,
];
}
private function upsertPlan(string $month, float $cash): int
{
$statement = $this->pdo->prepare('SELECT id FROM monthly_plans WHERE month = :month');
$statement->execute(['month' => $month]);
$planId = $statement->fetchColumn();
if ($planId !== false) {
$statement = $this->pdo->prepare(
'UPDATE monthly_plans
SET starting_income = :cash, rollover_in = 0, rolling_enabled = 1,
rollover_source_month = NULL, notes = :notes, updated_at = CURRENT_TIMESTAMP
WHERE id = :id'
);
$statement->execute([
'cash' => $cash,
'notes' => 'Initial balance captured during onboarding',
'id' => $planId,
]);
return (int) $planId;
}
$statement = $this->pdo->prepare(
'INSERT INTO monthly_plans
(month, starting_income, rollover_in, rolling_enabled, rollover_source_month, notes)
VALUES
(:month, :cash, 0, 1, NULL, :notes)'
);
$statement->execute([
'month' => $month,
'cash' => $cash,
'notes' => 'Initial balance captured during onboarding',
]);
return (int) $this->pdo->lastInsertId();
}
private function findOrCreateCategory(string $name): int
{
$statement = $this->pdo->prepare('SELECT id FROM categories WHERE LOWER(name) = LOWER(:name) LIMIT 1');
$statement->execute(['name' => $name]);
$id = $statement->fetchColumn();
if ($id !== false) {
$reactivate = $this->pdo->prepare('UPDATE categories SET active = 1 WHERE id = :id');
$reactivate->execute(['id' => $id]);
return (int) $id;
}
$statement = $this->pdo->prepare(
'INSERT INTO categories (name, color, monthly_limit, active) VALUES (:name, :color, 0, 1)'
);
$statement->execute([
'name' => $name,
'color' => $this->categoryColor($name),
]);
return (int) $this->pdo->lastInsertId();
}
private function categoryColor(string $name): string
{
if (isset(self::COLORS[$name])) {
return self::COLORS[$name];
}
$palette = ['#8b5cf6', '#49a8ff', '#ff5b9d', '#49e8a4', '#ffad5c', '#ff6473'];
return $palette[abs((int) crc32(strtolower($name))) % count($palette)];
}
private function automaticTransactionPull(float $amount): float
{
$mode = (string) $this->settings->get('dead.transaction_mode', 'fixed');
$value = max(0, (float) $this->settings->get('dead.transaction_value', 0));
$pull = $mode === 'percent' ? $amount * $value / 100 : $value;
return round(min($amount, max(0, $pull)), 2);
}
private function insertDeadEntry(
string $month,
string $kind,
float $amount,
?string $sourceType,
?int $sourceId,
string $notes
): void {
$statement = $this->pdo->prepare(
'INSERT INTO dead_account_entries (month, kind, amount, source_type, source_id, notes)
VALUES (:month, :kind, :amount, :source_type, :source_id, :notes)'
);
$statement->execute([
'month' => $month,
'kind' => $kind,
'amount' => round($amount, 2),
'source_type' => $sourceType,
'source_id' => $sourceId,
'notes' => $notes,
]);
}
private function dateIsInMonth(string $date, string $month): bool
{
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $matches)) {
return false;
}
return checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1])
&& substr($date, 0, 7) === $month;
}
private function truncate(string $value, int $length): string
{
return function_exists('mb_substr') ? mb_substr($value, 0, $length) : substr($value, 0, $length);
}
}