'#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> $bills * @param list $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); } }