From 85972f950c2e4e09a10629049ca3c53d3c11b291 Mon Sep 17 00:00:00 2001 From: henry yo Date: Thu, 25 Jun 2026 14:19:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=E5=88=86=E9=9B=A2+?= =?UTF-8?q?=E5=8A=9F=E8=83=BDapi=E5=8C=96=201.=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=B3=87=E7=94=A2=E7=AE=A1=E7=90=86CRUD=E3=80=81googleOAuth?= =?UTF-8?q?=E7=99=BB=E5=85=A5=202.=20=E6=9B=B4=E6=94=B9=E5=8E=9F=E8=A8=98?= =?UTF-8?q?=E5=B8=B3controller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Console/Commands/DebugGmailJob.php | 23 + app/Console/Commands/FetchGmailInvoices.php | 162 ++++ .../Commands/RefreshAllAssetPrices.php | 40 + app/Console/Commands/TestGmailFetch.php | 115 +++ .../Controllers/Api/AccountController.php | 122 +++ .../{ => Api}/CategoryController.php | 31 +- .../Api/CategoryRuleController.php | 50 ++ .../Controllers/Api/DashboardController.php | 114 +++ .../Controllers/Api/ExpenseController.php | 136 ++++ .../Api/InvestmentHoldingController.php | 123 +++ .../Api/InvestmentTransactionController.php | 98 +++ .../Api/InvoiceImportController.php | 292 +++++++ .../Controllers/Api/SecurityController.php | 48 ++ .../Api/SecurityPriceController.php | 269 +++++++ .../Controllers/Auth/GoogleAuthController.php | 51 ++ .../Controllers/CategoryRuleController.php | 37 - app/Http/Controllers/DashboardController.php | 57 -- app/Http/Controllers/ExpenseController.php | 114 --- .../Controllers/InvoiceImportController.php | 258 ------ app/Jobs/FetchAndParseGmailInvoices.php | 140 ++++ app/Jobs/UpdateAssetPriceJob.php | 148 ++++ app/Models/Account.php | 54 ++ app/Models/AccountRule.php | 10 + app/Models/Expense.php | 29 +- app/Models/InvestmentHolding.php | 32 + app/Models/InvestmentTransaction.php | 30 + app/Models/Security.php | 33 + app/Models/SecurityPriceHistories.php | 29 + app/Models/User.php | 14 +- app/Services/GmailParserService.php | 180 +++++ bootstrap/app.php | 6 + composer.json | 3 + composer.lock | 744 +++++++++++++++++- config/app.php | 2 +- config/cors.php | 38 + config/sanctum.php | 87 ++ config/services.php | 6 + ...026_03_07_133326_create_expenses_table.php | 1 + ...12_101228_add_google_id_to_users_table.php | 29 + ...11_create_personal_access_tokens_table.php | 33 + ...026_06_12_191420_create_accounts_table.php | 35 + ...91421_create_investment_holdings_table.php | 33 + ...2_create_investment_transactions_table.php | 37 + ...eign_key_account_id_to_expenses_table.php} | 4 +- .../2026_06_16_183426_securities.php | 39 + ..._06_16_183615_security_price_histories.php | 36 + ...083548_add_gmail_tokens_to_users_table.php | 30 + ...amortization_fields_to_expenses_table.php} | 8 +- ...6_25_013906_create_account_rules_table.php | 31 + database/seeders/AccountSeeder.php | 188 +++++ database/seeders/DatabaseSeeder.php | 16 +- database/seeders/InvestmentHoldingSeeder.php | 52 ++ .../seeders/InvestmentTransactionSeeder.php | 301 +++++++ resources/js/Components/DropdownLink.vue | 8 +- resources/js/Components/NavLink.vue | 6 +- resources/js/Components/ResponsiveNavLink.vue | 6 +- resources/js/Layouts/AuthenticatedLayout.vue | 141 ++-- resources/js/Layouts/GuestLayout.vue | 6 +- resources/js/Pages/Auth/Login.vue | 7 +- resources/js/Pages/Auth/Register.vue | 13 +- resources/js/Pages/Auth/ResetPassword.vue | 6 +- resources/js/Pages/Auth/VerifyEmail.vue | 13 +- resources/js/Pages/Dashboard.vue | 215 ++--- resources/js/Pages/Expenses/Import.vue | 35 +- resources/js/Pages/Expenses/Index.vue | 611 +++++--------- resources/js/Pages/Welcome.vue | 386 --------- routes/api.php | 67 ++ routes/auth.php | 5 + routes/console.php | 22 + routes/web.php | 129 +++ 70 files changed, 4754 insertions(+), 1520 deletions(-) create mode 100644 app/Console/Commands/DebugGmailJob.php create mode 100644 app/Console/Commands/FetchGmailInvoices.php create mode 100644 app/Console/Commands/RefreshAllAssetPrices.php create mode 100644 app/Console/Commands/TestGmailFetch.php create mode 100644 app/Http/Controllers/Api/AccountController.php rename app/Http/Controllers/{ => Api}/CategoryController.php (56%) create mode 100644 app/Http/Controllers/Api/CategoryRuleController.php create mode 100644 app/Http/Controllers/Api/DashboardController.php create mode 100644 app/Http/Controllers/Api/ExpenseController.php create mode 100644 app/Http/Controllers/Api/InvestmentHoldingController.php create mode 100644 app/Http/Controllers/Api/InvestmentTransactionController.php create mode 100644 app/Http/Controllers/Api/InvoiceImportController.php create mode 100644 app/Http/Controllers/Api/SecurityController.php create mode 100644 app/Http/Controllers/Api/SecurityPriceController.php create mode 100644 app/Http/Controllers/Auth/GoogleAuthController.php delete mode 100644 app/Http/Controllers/CategoryRuleController.php delete mode 100644 app/Http/Controllers/DashboardController.php delete mode 100644 app/Http/Controllers/ExpenseController.php delete mode 100644 app/Http/Controllers/InvoiceImportController.php create mode 100644 app/Jobs/FetchAndParseGmailInvoices.php create mode 100644 app/Jobs/UpdateAssetPriceJob.php create mode 100644 app/Models/Account.php create mode 100644 app/Models/AccountRule.php create mode 100644 app/Models/InvestmentHolding.php create mode 100644 app/Models/InvestmentTransaction.php create mode 100644 app/Models/Security.php create mode 100644 app/Models/SecurityPriceHistories.php create mode 100644 app/Services/GmailParserService.php create mode 100644 config/cors.php create mode 100644 config/sanctum.php create mode 100644 database/migrations/2026_06_12_101228_add_google_id_to_users_table.php create mode 100644 database/migrations/2026_06_12_181511_create_personal_access_tokens_table.php create mode 100644 database/migrations/2026_06_12_191420_create_accounts_table.php create mode 100644 database/migrations/2026_06_12_191421_create_investment_holdings_table.php create mode 100644 database/migrations/2026_06_12_191422_create_investment_transactions_table.php rename database/migrations/{2026_03_07_184750_add_external_id_to_expenses_table.php => 2026_06_14_123245_add_foreign_key_account_id_to_expenses_table.php} (76%) create mode 100644 database/migrations/2026_06_16_183426_securities.php create mode 100644 database/migrations/2026_06_16_183615_security_price_histories.php create mode 100644 database/migrations/2026_06_21_083548_add_gmail_tokens_to_users_table.php rename database/migrations/{2026_03_07_182526_add_subcategory_and_type_to_expenses_table.php => 2026_06_22_003822_add_amortization_fields_to_expenses_table.php} (54%) create mode 100644 database/migrations/2026_06_25_013906_create_account_rules_table.php create mode 100644 database/seeders/AccountSeeder.php create mode 100644 database/seeders/InvestmentHoldingSeeder.php create mode 100644 database/seeders/InvestmentTransactionSeeder.php delete mode 100644 resources/js/Pages/Welcome.vue create mode 100644 routes/api.php diff --git a/app/Console/Commands/DebugGmailJob.php b/app/Console/Commands/DebugGmailJob.php new file mode 100644 index 0000000..6cfda46 --- /dev/null +++ b/app/Console/Commands/DebugGmailJob.php @@ -0,0 +1,23 @@ +info("🔄 正在讀取 User ID 1 的 Google 憑證..."); + $user = User::find(1); + + app(FetchAndParseGmailInvoices::class, ['user' => $user])->handle(new \App\Services\GmailParserService()); + } +} diff --git a/app/Console/Commands/FetchGmailInvoices.php b/app/Console/Commands/FetchGmailInvoices.php new file mode 100644 index 0000000..3ee4b7b --- /dev/null +++ b/app/Console/Commands/FetchGmailInvoices.php @@ -0,0 +1,162 @@ +user = $user; + } + + public function handle(GmailParserService $parserService) + { + Log::info("====== [DEBUG START] 開始執行 Gmail 抓信任務 ======"); + + if (!$this->user->google_refresh_token) { + Log::error("❌ 失敗:User ID {$this->user->id} 缺乏 google_refresh_token"); + return; + } + + try { + Log::info("🔄 正在初始化 Google Client & 刷新 Token..."); + $client = new GoogleClient(); + $client->setClientId(config('services.google.client_id')); + $client->setClientSecret(config('services.google.client_secret')); + $client->refreshToken($this->user->google_refresh_token); + $gmailService = new Gmail($client); + + $afterDate = Carbon::now()->subDays(3)->format('Y/m/d'); + $searchQuery = "from:cathaybk.com.tw subject:消費彙整通知 after:{$afterDate}"; + Log::info("🔍 正在向 Google API 發送搜尋 Query: [{$searchQuery}]"); + + $response = $gmailService->users_messages->listUsersMessages('me', [ + 'q' => $searchQuery, + 'maxResults' => 10 + ]); + + $messages = $response->getMessages(); + + if (empty($messages)) { + Log::warning("⚠️ 警告:Google API 回傳空陣列!"); + return; + } + + Log::info("🎯 成功拿到信件清單!總共發現 " . count($messages) . " 封潛在信件,開始逐封處理..."); + + foreach ($messages as $index => $msgItem) { + $msgId = $msgItem->getId(); + Log::info("--- [處理第 " . ($index + 1) . " 封信] ID: {$msgId} ---"); + + $message = $gmailService->users_messages->get('me', $msgId, ['format' => 'full']); + $payload = $message->getPayload(); + + $subject = ''; + foreach ($payload->getHeaders() as $header) { + if ($header->getName() === 'Subject') { + $subject = $header->getValue(); + break; + } + } + Log::info("📬 信件主旨: [{$subject}]"); + + $body = $this->getDecodedBody($payload); + Log::info("📄 內文解碼完成,準備丟入 Parser..."); + + // 1. 執行解析(現在國泰會回報陣列,其餘單筆通知我們手動把它包成陣列) + $parsedResult = $parserService->parse($subject, $body); + + if (!$parsedResult) { + Log::error("❌ 失敗:GmailParserService 無法解析此信件內容"); + continue; + } + + // 💡 智慧防呆:如果回傳的是單筆消費(一維陣列),我們把它包成二維陣列,這樣下面統一用 foreach 處理 + $expensesList = isset($parsedResult['amount']) ? [$parsedResult] : $parsedResult; + + Log::info("✨ Parser 解析成功!共發現 " . count($expensesList) . " 筆刷卡消費項目,開始遍歷寫入..."); + + // 🔄 遍歷這封信裡面的每一筆刷卡紀錄 + foreach ($expensesList as $parsedData) { + + // 🟢 2. 智慧資產帳戶對照 + $matchedAccountRule = \App\Models\AccountRule::where('user_id', $this->user->id) + ->get() + ->first(function ($rule) use ($subject) { + return Str::contains($subject, $rule->keyword); + }); + $accountId = $matchedAccountRule ? $matchedAccountRule->account_id : null; + + // 🟢 3. 智慧消費分類對照 + $matchedCategoryRule = \App\Models\CategoryRule::where('user_id', $this->user->id) + ->get() + ->first(function ($rule) use ($parsedData) { + return Str::contains(strtoupper($parsedData['seller_name']), strtoupper($rule->keyword)); + }); + $categoryId = $matchedCategoryRule ? $matchedCategoryRule->category_id : null; + + // 4. 💾 嘗試寫入資料庫 + $expense = Expense::firstOrCreate( + [ + 'user_id' => $this->user->id, + 'amount' => $parsedData['amount'], + 'seller_name' => $parsedData['seller_name'], + 'date' => $parsedData['date'], + ], + [ + 'item_name' => $parsedData['item_name'], + 'account_id' => $accountId, + 'category_id' => $categoryId, + 'note' => 'Gmail自動同步(多筆完全體)', + ] + ); + + if ($expense->wasRecentlyCreated) { + Log::info("✅ 成功!建立全新消費: [{$parsedData['seller_name']}] 額度: [{$parsedData['amount']}], ID: {$expense->id}"); + } else { + Log::info("ℹ️ 提示:[{$parsedData['seller_name']}] 額度: [{$parsedData['amount']}] 已存在,跳過。"); + } + } + } + + Log::info("====== [DEBUG END] Gmail 抓信任務順利結束 ======"); + } catch (\Exception $e) { + Log::error("💥 崩潰!handle 內發生致命錯誤: " . $e->getMessage()); + } + } + + private function getDecodedBody($part) + { + $body = $part->getBody(); + if ($body && $body->getData()) { + return base64_decode(str_replace(['-', '_'], ['+', '/'], $body->getData())); + } + $parts = $part->getParts(); + if ($parts) { + foreach ($parts as $subPart) { + $result = $this->getDecodedBody($subPart); + if ($result) return $result; + } + } + return ''; + } +} diff --git a/app/Console/Commands/RefreshAllAssetPrices.php b/app/Console/Commands/RefreshAllAssetPrices.php new file mode 100644 index 0000000..97c3cd7 --- /dev/null +++ b/app/Console/Commands/RefreshAllAssetPrices.php @@ -0,0 +1,40 @@ +groupBy('code', 'type', 'name') // 去除重複 + ->get(); + + $this->info("全系統共發現 " . $uniqueHoldings->count() . " 檔不重複的現役標的,開始指派更新任務..."); + + $delaySeconds = 0; + + foreach ($uniqueHoldings as $holding) { + UpdateAssetPriceJob::dispatch( + $holding->code, + $holding->type, + $holding->name + )->delay(now()->addSeconds($delaySeconds)); + + $delaySeconds += 2; + } + + $this->info("所有任務皆已成功排入背景 Queue,預計花費 {$delaySeconds} 秒消化完畢。"); + } +} diff --git a/app/Console/Commands/TestGmailFetch.php b/app/Console/Commands/TestGmailFetch.php new file mode 100644 index 0000000..8b53c94 --- /dev/null +++ b/app/Console/Commands/TestGmailFetch.php @@ -0,0 +1,115 @@ +google_refresh_token) { + $this->error('找不到使用者,或該使用者尚未擁有 google_refresh_token!'); + return; + } + + $this->info("🔄 正在初始化 Google Client 并刷新 Token..."); + + // 2. 初始化 Google Client + $client = new GoogleClient(); + $client->setClientId(config('services.google.client_id')); + $client->setClientSecret(config('services.google.client_secret')); + + // 用 refresh_token 去跟 Google 交換新的短期 access_token + $client->refreshToken($user->google_refresh_token); + $accessToken = $client->getAccessToken(); + + // 順手把新的 token 蓋回資料庫(防呆) + $user->update([ + 'google_access_token' => $accessToken['access_token'], + 'google_token_expires_at' => now()->addSeconds($accessToken['expires_in']), + ]); + + // 3. 呼叫 Gmail 服務 + $gmailService = new Gmail($client); + + $this->info("🔍 正在透過標籤精準搜尋信用卡消費通知..."); + + $searchQuery = 'label:信用卡 -is:spam -is:trash'; + + $response = $gmailService->users_messages->listUsersMessages('me', [ + 'maxResults' => 1, // 先抓一筆來看內文 + 'q' => $searchQuery + ]); + + $messages = $response->getMessages(); + + if (empty($messages)) { + $this->warn('❌ 找不到任何符合關鍵字的信件!請確認你的 Gmail 裡有這種類型的信。'); + return; + } + + $msgId = $messages[0]->getId(); + $this->info("🎯 成功找到信件 ID: {$msgId},正在下載詳細內容..."); + + // 4. 抓取信件完整細節 + $message = $gmailService->users_messages->get('me', $msgId, ['format' => 'full']); + $payload = $message->getPayload(); + $headers = $payload->getHeaders(); + + // 解析主旨 + $subject = ''; + foreach ($headers as $header) { + if ($header->getName() === 'Subject') { + $subject = $header->getValue(); + break; + } + } + + // 解析內文 (處理可能存在的多重 Multipart 結構) + $body = $this->getDecodedBody($payload); + + // 5. 🟢 核心現形:把內容原封不動地噴進 Log + Log::info("=== 【GMAIL 測試抓取成功】 ==="); + Log::info("主旨: " . $subject); + Log::info("信件 ID: " . $msgId); + Log::info("=== 原始內文開始 ==="); + Log::info($body); + Log::info("=== 原始內文結束 ==="); + + $this->info("✅ 抓取完畢!真實內容已成功寫入 storage/logs/laravel.log,快去打開瞧瞧吧!"); + } + + /** + * 輔助方法:遞迴解析 Gmail 的 Body 內容 + */ + private function getDecodedBody($part) + { + $body = $part->getBody(); + if ($body && $body->getData()) { + // Gmail 的 base64 比較特殊,需要把 - 和 _ 換回來 + $sanitizedData = str_replace(['-', '_'], ['+', '/'], $body->getData()); + return base64_decode($sanitizedData); + } + + $parts = $part->getParts(); + if ($parts) { + foreach ($parts as $subPart) { + $result = $this->getDecodedBody($subPart); + if ($result) return $result; + } + } + + return ''; + } +} diff --git a/app/Http/Controllers/Api/AccountController.php b/app/Http/Controllers/Api/AccountController.php new file mode 100644 index 0000000..2c15eee --- /dev/null +++ b/app/Http/Controllers/Api/AccountController.php @@ -0,0 +1,122 @@ +user()->accounts() + ->with(['holdings.transactions', 'holdings.security']) // 👈 確保能直接拿到最新價格 + ->get() + ->map(function ($account) { + + $data = $account->toArray(); + + // 🟢 2. 處理股票與基金的成本與估計現值計算 + if (in_array($account->type, ['stock', 'fund'])) { + + $totalCost = 0; + $totalValue = 0; + + foreach ($account->holdings as $holding) { + + // 🟢 修正 1:直接拿資料庫存好的庫存股數,不用再去 transactions 加減計算 + $remainingShares = floatval($holding->shares); + + if ($remainingShares > 0) { + + // 🟢 修正 2:拿到最新價格 (如果沒抓到就先給一個暫時的預設值,例如 10,方便你 debug 畫面) + $currentPrice = floatval($holding->security?->latest_price ?? 0); + + if ($currentPrice == 0) { + // 💡 Debug 觀測:如果還是 0,我們先用 avg_cost 頂替,或者去手動確認 securities 表有沒有真的更新到價格 + $currentPrice = floatval($holding->avg_cost ?? 0); + } + + // 總現值 = 剩餘單位 * 最新價格 + $totalValue += ($remainingShares * $currentPrice); + + // 總成本:直接拿你畫面上已經有的 avg_cost * shares 算最快最準! + $avgCost = floatval($holding->avg_cost ?? 0); + $totalCost += ($remainingShares * $avgCost); + } + } + + // 🟢 捨入並塞入回傳陣列 + $data['total_cost'] = round($totalCost); // 持倉成本 + $data['total_value'] = round($totalValue); // 目前市值 + + // 💡 關鍵補強:計算總損益與總報酬率並塞回陣列,消滅前端的 NaN%! + $totalProfit = $totalValue - $totalCost; + $totalRate = $totalCost > 0 ? ($totalProfit / $totalCost) * 100 : 0; + + // 🚨 請確認這裡的 Key 名稱(例如 total_profit 或是 unrealized_profit), + // 要跟你在 Vue 前端 account.xxx 綁定的欄位名稱一模一樣喔! + $data['total_profit'] = round($totalProfit); + $data['total_rate'] = round($totalRate, 2); + } + + // 處理信用卡 + if ($account->type === 'credit_card') { + $data['unpaid_amount'] = $account->unpaidAmount(); + } + + return $data; + }); + + return response()->json($accounts); + } + + public function store(Request $request) + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'type' => 'required|in:cash,bank,stock,fund,wallet,credit_card', + 'currency' => 'nullable|string|max:10', + 'balance' => 'nullable|numeric', + 'note' => 'nullable|string', + 'billing_day' => 'nullable|integer|min:1|max:31', + 'payment_day' => 'nullable|integer|min:1|max:31', + ]); + + $account = $request->user()->accounts()->create($validated); + return response()->json($account, 201); + } + + public function show(Request $request, string $id) + { + $account = $request->user()->accounts()->findOrFail($id); + return response()->json($account); + } + + public function update(Request $request, string $id) + { + $account = $request->user()->accounts()->findOrFail($id); + + $validated = $request->validate([ + 'name' => 'sometimes|string|max:255', + 'type' => 'sometimes|in:cash,bank,stock,fund,wallet,credit_card', + 'currency' => 'nullable|string|max:10', + 'balance' => 'nullable|numeric', + 'note' => 'nullable|string', + 'billing_day' => 'nullable|integer|min:1|max:31', + 'payment_day' => 'nullable|integer|min:1|max:31', + ]); + + $account->update($validated); + return response()->json($account); + } + + public function destroy(Request $request, string $id) + { + $account = $request->user()->accounts()->findOrFail($id); + $account->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/CategoryController.php b/app/Http/Controllers/Api/CategoryController.php similarity index 56% rename from app/Http/Controllers/CategoryController.php rename to app/Http/Controllers/Api/CategoryController.php index 17ad9bd..07c88bc 100644 --- a/app/Http/Controllers/CategoryController.php +++ b/app/Http/Controllers/Api/CategoryController.php @@ -1,7 +1,8 @@ validate([ @@ -18,14 +23,19 @@ class CategoryController extends Controller 'icon' => 'nullable|string|max:50', ]); - Category::create([ + $category = Category::create([ ...$validated, - 'user_id' => auth()->id(), + 'user_id' => $request->user()->id, ]); - return redirect()->back(); + // 🟢 修正:回傳剛剛建立的 JSON 資料與 201 狀態碼給前端 push 入陣列 + return response()->json($category, 201); } + /** + * 修改分類 + * PUT /api/categories/{category} + */ public function update(Request $request, Category $category) { $this->authorize('update', $category); @@ -36,13 +46,20 @@ class CategoryController extends Controller ]); $category->update($validated); - return redirect()->back(); + + // 🟢 修正:回傳修改後的最新資料,讓前端動態替換 + return response()->json($category); } + /** + * 刪除分類 + * DELETE /api/categories/{category} + */ public function destroy(Category $category) { $this->authorize('delete', $category); $category->delete(); - return redirect()->back(); + + return response()->json(['message' => '分類刪除成功']); } -} +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/CategoryRuleController.php b/app/Http/Controllers/Api/CategoryRuleController.php new file mode 100644 index 0000000..14a953d --- /dev/null +++ b/app/Http/Controllers/Api/CategoryRuleController.php @@ -0,0 +1,50 @@ +validate([ + 'keyword' => 'required|string|max:255', + 'category_id' => 'required|exists:categories,id', + ]); + + $rule = CategoryRule::create([ + ...$validated, + 'user_id' => $request->user()->id, + ]); + + // 🟢 核心優化:必須預先載入 category 關聯,否則前端拿到這筆規則物件時,會無法渲染它的分類標籤 + $rule->load('category'); + + return response()->json($rule, 201); + } + + /** + * 刪除自動分類規則 + * DELETE /api/category-rules/{categoryRule} + */ + public function destroy(CategoryRule $categoryRule) + { + if ($categoryRule->user_id !== $request->user()->id) { + return response()->json(['error' => '未經授權'], 403); + } + + $categoryRule->delete(); + + return response()->json(['message' => '自動分類規則刪除成功']); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/DashboardController.php b/app/Http/Controllers/Api/DashboardController.php new file mode 100644 index 0000000..5a49ba0 --- /dev/null +++ b/app/Http/Controllers/Api/DashboardController.php @@ -0,0 +1,114 @@ +whereYear('date', $now->year) + ->whereMonth('date', $now->month) + ->selectRaw('SUM(amount) as total_amount, COUNT(id) as total_count') + ->first(); + + $monthlyTotal = (float) ($monthStats->total_amount ?? 0); + $monthlyCount = (int) ($monthStats->total_count ?? 0); + + // 2. 本月分類統計(圓餅圖)- 保持集合操作即可 + $categoryStats = Expense::with('category') + ->where('user_id', $userId) + ->whereYear('date', $now->year) + ->whereMonth('date', $now->month) + ->get() + ->groupBy(fn($e) => $e->category?->name ?? '未分類') + ->map(fn($group, $name) => [ + 'name' => $name, + 'total' => (float) $group->sum('amount'), + 'color' => $group->first()->category?->color ?? '#6B7280', + ]) + ->values(); + + // 3. 近 6 個月趨勢優化:一次性撈出半年內的所有資料,避免 6 次 SQL 查詢 + $sixMonthsAgo = $now->copy()->subMonths(5)->startOfMonth(); + $endOfCurrentMonth = $now->copy()->endOfMonth(); + + // 建立近 6 個月的初始月份清單(例如 ['2026/01' => 0.0, '2026/02' => 0.0 ... ]) + $monthlyTrendData = collect(range(5, 0))->mapWithKeys(function ($i) use ($now) { + $monthStr = $now->copy()->subMonths($i)->format('Y/m'); + return [$monthStr => 0.0]; + })->toArray(); + + // ➔ (A) 撈取一般消費 (未開啟分攤) + $isPostgres = DB::connection()->getDriverName() === 'pgsql'; + $dateGroupRaw = $isPostgres ? "to_char(date, 'YYYY/MM')" : "DATE_FORMAT(date, '%Y/%m')"; + + $normalExpenses = Expense::where('user_id', $userId) + ->where('is_amortized', false) + ->where('date', '>=', $sixMonthsAgo) + ->selectRaw("{$dateGroupRaw} as month, SUM(amount) as total") + ->groupBy('month') + ->get(); + + foreach ($normalExpenses as $expense) { + if (array_key_exists($expense->month, $monthlyTrendData)) { + $monthlyTrendData[$expense->month] += (float) $expense->total; + } + } + + // ➔ (B) 智慧分攤消費 (處理跨月、跨年、半年繳大魔王 🎯) + // 撈出所有歸屬期間有交集到這 6 個月範圍內的分攤費用 + $amortizedExpenses = Expense::where('user_id', $userId) + ->where('is_amortized', true) + ->where(function ($query) use ($sixMonthsAgo, $endOfCurrentMonth) { + $query->where('period_start', '<=', $endOfCurrentMonth) + ->where('period_end', '>=', $sixMonthsAgo); + }) + ->get(); + + foreach ($amortizedExpenses as $expense) { + $start = Carbon::parse($expense->period_start)->startOfMonth(); + $end = Carbon::parse($expense->period_end)->endOfMonth(); + + // 計算這筆費用的總跨月份數 (至少為 1 個月) + $totalMonths = max(1, $start->diffInMonths($end) + 1); + // 算出每月平均攤提金額 + $monthlyShare = (float) $expense->amount / $totalMonths; + + // 跑這 6 個月的迴圈,看有沒有落在這筆費用的歸屬區間內 + foreach ($monthlyTrendData as $monthStr => $currentTotal) { + $currentMonthObj = Carbon::createFromFormat('Y/m', $monthStr)->startOfMonth(); + + // 如果圖表上的這個月份,剛好在費用的歸屬起訖之內,就把每月平均金額灌進去! + if ($currentMonthObj->betweenIncluded($start, $end)) { + $monthlyTrendData[$monthStr] += $monthlyShare; + } + } + } + + // 轉回前端圖表要的格式 + $monthlyTrend = collect($monthlyTrendData)->map(function ($total, $monthStr) { + return [ + 'month' => $monthStr, + 'total' => round($total, 2), // 四捨五入到小數點兩位 + ]; + })->values(); + + return response()->json([ + 'monthlyTotal' => $monthlyTotal, + 'monthlyCount' => $monthlyCount, + 'categoryStats' => $categoryStats, + 'monthlyTrend' => $monthlyTrend, + ]); + } +} diff --git a/app/Http/Controllers/Api/ExpenseController.php b/app/Http/Controllers/Api/ExpenseController.php new file mode 100644 index 0000000..a2f1978 --- /dev/null +++ b/app/Http/Controllers/Api/ExpenseController.php @@ -0,0 +1,136 @@ +where('user_id', auth()->id()) + ->orderBy('date', 'desc') + ->get(); + + return response()->json($expenses); + } + + /** + * 2. 取得使用者所有的消費分類 + * GET /api/categories + */ + public function categories() + { + $categories = Category::where('user_id', auth()->id())->get(); + return response()->json($categories); + } + + /** + * 3. 取得使用者所有的自動分類規則 + * GET /api/category-rules + */ + public function categoryRules() + { + $rules = CategoryRule::with('category') + ->where('user_id', auth()->id()) + ->get(); + + return response()->json($rules); + } + + /** + * 4. 手動新增支出明細 + * POST /api/expenses + */ + public function store(Request $request) + { + $validated = $request->validate([ + 'amount' => 'required|numeric|min:0', + 'category_id' => 'nullable|exists:categories,id', + 'item_name' => 'nullable|string|max:255', + 'seller_name' => 'nullable|string|max:255', + 'is_amortized' => 'boolean', + 'period_start' => 'nullable|date', + 'period_end' => 'nullable|date|after_or_equal:period_start', + 'date' => 'required|date', + 'note' => 'nullable|string|max:255', + ]); + + $expense = Expense::create([ + ...$validated, + 'user_id' => $request->user()->id, + ]); + + // 🟢 核心優化:載入分類關聯,這樣前端拿到的 response.data 才能立刻顯示分類顏色 + $expense->load('category'); + + return response()->json($expense, 201); + } + + /** + * 5. 修改單筆支出 + * PUT /api/expenses/{expense} + */ + public function update(Request $request, Expense $expense) + { + $this->authorize('update', $expense); + + $validated = $request->validate([ + 'amount' => 'required|numeric|min:0', + 'category_id' => 'nullable|exists:categories,id', + 'item_name' => 'nullable|string|max:255', + 'date' => 'required|date', + 'note' => 'nullable|string|max:255', + 'seller_name' => 'nullable|string|max:255', + 'is_amortized' => 'boolean', + 'period_start' => 'nullable|date', + 'period_end' => 'nullable|date|after_or_equal:period_start', + ]); + + $expense->update($validated); + + // 🟢 核心優化:同樣載入分類關聯 + $expense->load('category'); + + return response()->json($expense); + } + + /** + * 6. 刪除單筆支出 + * DELETE /api/expenses/{expense} + */ + public function destroy(Expense $expense) + { + $this->authorize('delete', $expense); + $expense->delete(); + + return response()->json(['message' => '刪除成功']); + } + + /** + * 7. 批量刪除支出 + * POST /api/expenses/destroy-batch + */ + public function destroyBatch(Request $request) + { + $request->validate(['ids' => 'required|array']); + + Expense::whereIn('id', $request->ids) + ->where('user_id', $request->user()->id) + ->delete(); + + return response()->json(['message' => '批量刪除成功']); + } +} diff --git a/app/Http/Controllers/Api/InvestmentHoldingController.php b/app/Http/Controllers/Api/InvestmentHoldingController.php new file mode 100644 index 0000000..87e1f39 --- /dev/null +++ b/app/Http/Controllers/Api/InvestmentHoldingController.php @@ -0,0 +1,123 @@ +user()->accounts()->findOrFail($accountId); + + // 🟢 1. 預先載入最新價格字典表關聯,避免 N+1 問題 + $holdings = $account->holdings()->with('security')->get(); + + // 🟢 2. 透過 map 幫每檔持倉加工算出市值與報酬率 + $processedHoldings = $holdings->map(function ($holding) { + + $data = $holding->toArray(); + + $remainingShares = floatval($holding->shares); + $avgCost = floatval($holding->avg_cost); + + // 預設給 0 或拿平均成本防呆 + $currentPrice = floatval($holding->security?->latest_price ?? 0); + if ($currentPrice == 0) { + $currentPrice = $avgCost; + } + + if ($remainingShares > 0) { + // 單一標的總成本 + $totalCost = $remainingShares * $avgCost; + // 單一標目前市值 + $currentValue = $remainingShares * $currentPrice; + // 單一標的未實現損益 + $profit = $currentValue - $totalCost; + // 單一標的報酬率 + $profitRate = $totalCost > 0 ? ($profit / $totalCost) * 100 : 0; + + // 🟢 3. 塞入擴充數據欄位 + $data['latest_price'] = $currentPrice; + $data['current_value'] = round($currentValue); + $data['profit'] = round($profit); + $data['profit_rate'] = round($profitRate, 2); + } else { + // 庫存為 0 的防呆 + $data['latest_price'] = $currentPrice; + $data['current_value'] = 0; + $data['profit'] = 0; + $data['profit_rate'] = 0; + } + + return $data; + }); + + // 傳回包含豐富績效數據的持倉清單 + return response()->json($processedHoldings); + } + + public function store(Request $request, string $accountId) + { + $account = $request->user()->accounts()->findOrFail($accountId); + + $validated = $request->validate([ + 'type' => 'required|in:stock,fund', + 'symbol' => 'required|string|max:20', + 'name' => 'required|string|max:255', + ]); + + $holding = $account->holdings()->create($validated); + + // 檢查security: 如果之前有資料不create, 如果之前沒有資料create + $security = Security::firstOrCreate( + ['code' => $validated['symbol']], // 尋找條件 + [ + 'type' => $validated['type'], + 'name' => $validated['name'], + 'source' => $request->input('source') ?? 'twse', + ] + ); + + // 立即派發更新價格的任務 + UpdateAssetPriceJob::dispatch($security->code, $security->type, $security->name); + + return response()->json($holding, 201); + } + + public function show(Request $request, string $accountId, string $id) + { + $account = $request->user()->accounts()->findOrFail($accountId); + $holding = $account->holdings()->findOrFail($id); + return response()->json($holding); + } + + public function update(Request $request, string $accountId, string $id) + { + $account = $request->user()->accounts()->findOrFail($accountId); + $holding = $account->holdings()->findOrFail($id); + + $validated = $request->validate([ + 'name' => 'sometimes|string|max:255', + ]); + + $holding->update($validated); + + return response()->json($holding); + } + + public function destroy(Request $request, string $accountId, string $id) + { + $account = $request->user()->accounts()->findOrFail($accountId); + $holding = $account->holdings()->findOrFail($id); + $holding->delete(); + return response()->json(null, 204); + } + + +} diff --git a/app/Http/Controllers/Api/InvestmentTransactionController.php b/app/Http/Controllers/Api/InvestmentTransactionController.php new file mode 100644 index 0000000..f786996 --- /dev/null +++ b/app/Http/Controllers/Api/InvestmentTransactionController.php @@ -0,0 +1,98 @@ +where('user_id', $request->user()->id); + })->findOrFail($holdingId); + + return response()->json($holding->transactions()->orderBy('traded_at', 'desc')->get()); + } + + public function store(Request $request, string $holdingId) + { + $holding = InvestmentHolding::whereHas('account', function ($q) use ($request) { + $q->where('user_id', $request->user()->id); + })->findOrFail($holdingId); + + $validated = $request->validate([ + 'action' => 'required|in:buy,sell', + 'shares' => 'nullable|numeric|min:0', + 'price' => 'nullable|numeric|min:0', + 'fee' => 'nullable|numeric|min:0', + 'tax' => 'nullable|numeric|min:0', + 'traded_at' => 'required|date', + 'note' => 'nullable|string', + 'amount' => 'nullable|numeric|min:0', // 實際投入金額(基金用) + 'nav' => 'nullable|numeric|min:0', // 淨值(基金用) + ]); + + $transaction = $holding->transactions()->create($validated); + + // 更新持倉的 shares 和 avg_cost + $this->recalculateHolding($holding); + + return response()->json($transaction, 201); + } + + public function destroy(Request $request, string $holdingId, string $id) + { + $holding = InvestmentHolding::whereHas('account', function ($q) use ($request) { + $q->where('user_id', $request->user()->id); + })->findOrFail($holdingId); + + $transaction = $holding->transactions()->findOrFail($id); + $transaction->delete(); + + $this->recalculateHolding($holding); + + return response()->json(null, 204); + } + + // 每次買賣後重新計算平均成本和持有股數 + private function recalculateHolding(InvestmentHolding $holding): void + { + $transactions = $holding->transactions()->orderBy('traded_at')->get(); + + $totalUnits = 0; + $totalCost = 0; + $presentUnits = 0; + + + foreach ($transactions as $tx) { + if ($tx->action === 'buy') { + if ($holding->type === 'fund') { + $units = $tx->nav > 0 ? $tx->amount / $tx->nav : 0; + $totalCost += $tx->amount + $tx->fee; + $presentUnits += $units; + $totalUnits += $units; + } else { + $totalCost += $tx->shares * $tx->price + $tx->fee; + $presentUnits += $tx->shares; + $totalUnits += $tx->shares; + } + } else { + if ($holding->type === 'fund') { + $units = $tx->nav > 0 ? $tx->amount / $tx->nav : 0; + $totalUnits -= $units; + } else { + $totalUnits -= $tx->shares; + } + } + + } + + $holding->update([ + 'shares' => max(0, $totalUnits), // 總持有股數 + 'avg_cost' => $presentUnits > 0 ? $totalCost / $presentUnits : 0, + ]); + } +} diff --git a/app/Http/Controllers/Api/InvoiceImportController.php b/app/Http/Controllers/Api/InvoiceImportController.php new file mode 100644 index 0000000..f81e743 --- /dev/null +++ b/app/Http/Controllers/Api/InvoiceImportController.php @@ -0,0 +1,292 @@ +validate([ + 'file' => 'required|file', + ]); + + $file = $request->file('file'); + + $content = file_get_contents($file->getPathname()); + $encoding = mb_detect_encoding($content, ['UTF-8', 'BIG-5', 'UTF-16LE', 'CP950'], true); + + if ($encoding && $encoding !== 'UTF-8') { + $content = mb_convert_encoding($content, 'UTF-8', $encoding); + } + $content = preg_replace('/^\xEF\xBB\xBF/', '', $content); + + $rules = CategoryRule::with('category') + ->where('user_id', auth()->id()) + ->get(); + + // 智慧判定財政部官方載具格式 + if (str_contains($content, '表頭=M') || str_contains($content, 'M|')) { + [$imported, $updated] = $this->importPipeInvoice($content, $rules); + $formatName = '財政部官方載具發票'; + } else { + $handle = fopen('php://memory', 'r+'); + fwrite($handle, $content); + rewind($handle); + + $firstRow = fgetcsv($handle); + $format = $this->detectFormat($firstRow); + rewind($handle); + + if ($format === 'andromoney') { + [$imported, $updated] = $this->importAndroMoney($handle); + $formatName = 'AndroMoney 帳本'; + } else { + [$imported, $updated] = $this->importOldCommaInvoice($handle, $rules); + $formatName = '傳統扁平發票 CSV'; + } + fclose($handle); + } + + return response()->json([ + 'success' => true, + 'imported' => $imported, + 'updated' => $updated, + 'message' => "【{$formatName}】匯入完成!成功新增 {$imported} 筆紀錄,自動跳過重複/負值金額共 {$updated} 筆。" + ]); + } + + /** + * 2. 財政部官網 Pipe 格式解析 + */ + private function importPipeInvoice(string $content, $rules): array + { + $imported = 0; + $updated = 0; + + $lines = preg_split('/\r\n|\r|\n/', $content); + $invoices = []; + + foreach ($lines as $line) { + if (empty($line) || str_contains($line, '表頭=')) continue; + + $parts = explode('|', $line); + $type = $parts[0] ?? ''; + + if ($type === 'M') { + $invNum = trim($parts[6] ?? ''); + if (!$invNum) continue; + + $invoices[$invNum] = [ + 'date' => trim($parts[3] ?? ''), + 'seller' => trim($parts[5] ?? ''), + 'amount' => (float)trim($parts[7] ?? 0), + 'items' => [] + ]; + } elseif ($type === 'D') { + $invNum = trim($parts[1] ?? ''); + $itemName = trim($parts[3] ?? ''); + if ($invNum && isset($invoices[$invNum]) && $itemName) { + $invoices[$invNum]['items'][] = $itemName; + } + } + } + + DB::transaction(function () use ($invoices, $rules, &$imported, &$updated) { + foreach ($invoices as $invoiceNumber => $info) { + if ($info['amount'] < 0) { + $updated++; + continue; + } + + $exists = Expense::where('user_id', auth()->id()) + ->where('invoice_number', $invoiceNumber) + ->exists(); + + if ($exists) { + $updated++; + continue; + } + + $d = $info['date']; + $formattedDate = (strlen($d) === 8) ? substr($d, 0, 4) . '-' . substr($d, 4, 2) . '-' . substr($d, 6, 2) : now()->toDateString(); + + $itemName = implode(', ', $info['items']); + if (mb_strlen($itemName) > 255) { + $itemName = mb_substr($itemName, 0, 252) . '...'; + } + + $categoryId = $this->guessCategory($info['seller'], $rules); + + $expense = Expense::updateOrCreate( + [ + 'user_id' => auth()->id(), + 'invoice_number' => $invoiceNumber, + ], + [ + 'amount' => $info['amount'], + 'date' => $formattedDate, + 'seller_name' => $info['seller'], + 'item_name' => $itemName ?: $info['seller'], + 'note' => $info['seller'], + 'category_id' => $categoryId, + ] + ); + + if ($expense->wasRecentlyCreated) { + $imported++; + } else { + $updated++; + } + } + }); + + return [$imported, $updated]; + } + + /** + * 3. 舊版扁平逗號 CSV 匯入 + */ + private function importOldCommaInvoice($handle, $rules): array + { + $imported = 0; $updated = 0; + + while (($row = fgetcsv($handle)) !== false) { + if (count($row) < 8) continue; + + $invoiceNumber = trim($row[2] ?? ''); + $date = trim($row[1] ?? ''); + $amount = trim($row[3] ?? '0'); + $sellerName = trim($row[7] ?? ''); + $itemName = trim($row[13] ?? ''); + + if (empty($invoiceNumber)) continue; + if ((float)$amount < 0) { $updated++; continue; } + + $formattedDate = null; + if (strlen($date) === 8) { + $formattedDate = substr($date, 0, 4) . '-' . substr($date, 4, 2) . '-' . substr($date, 6, 2); + } + + $exists = Expense::where('user_id', auth()->id())->where('invoice_number', $invoiceNumber)->exists(); + if ($exists) { $updated++; continue; } + + $categoryId = $this->guessCategory($sellerName, $rules); + + // 🟢 校正:移除所有舊外鍵欄位,對齊 Migration + $expense = Expense::updateOrCreate( + [ + 'user_id' => auth()->id(), + 'invoice_number' => $invoiceNumber + ], + [ + 'amount' => (float)$amount, + 'date' => $formattedDate ?: now()->toDateString(), + 'seller_name' => $sellerName, + 'item_name' => $itemName, + 'note' => $sellerName, + 'category_id' => $categoryId, + ] + ); + if ($expense->wasRecentlyCreated) { $imported++; } else { $updated++; } + } + return [$imported, $updated]; + } + + /** + * 4. AndroMoney 格式匯入 + */ + private function importAndroMoney($handle): array + { + $imported = 0; $updated = 0; + fgetcsv($handle); + + while (($row = fgetcsv($handle)) !== false) { + $row = array_map(fn($val) => mb_convert_encoding($val, 'UTF-8', 'Big5'), $row); + if (count($row) < 6) continue; + + $amount = trim($row[2] ?? '0'); + $catName = trim($row[3] ?? ''); + $subCatName = trim($row[4] ?? ''); + $date = trim($row[5] ?? ''); + $note = trim($row[8] ?? ''); + $sellerName = trim($row[13] ?? ''); + + if (preg_match('/^[0-9a-f]{20,}$/i', $note)) { $note = ''; } + if (empty($amount) || empty($date) || (float)$amount == 0) continue; + + $formattedDate = null; + if (strlen($date) === 8 && is_numeric($date)) { + $formattedDate = substr($date, 0, 4) . '-' . substr($date, 4, 2) . '-' . substr($date, 6, 2); + } + if (!$formattedDate) { $updated++; continue; } + + $categoryId = !empty($catName) ? $this->findOrCreateCategory($catName) : null; + $subcategoryId = (!empty($subCatName) && $categoryId) ? $this->findOrCreateCategory($subCatName, $categoryId) : null; + + // 🟢 校正:AndroMoney 沒有發票號碼,所以單純用使用者、日期、金額與備註作防重判斷 + $exists = Expense::where('user_id', auth()->id()) + ->where('date', $formattedDate) + ->where('amount', abs((float)$amount)) + ->where('note', $note ?: $sellerName) + ->exists(); + + if ($exists) { + $updated++; + continue; + } + + Expense::create([ + 'user_id' => auth()->id(), + 'amount' => abs((float)$amount), + 'date' => $formattedDate, + 'category_id' => $categoryId, + 'subcategory_id' => $subcategoryId, + 'note' => $note ?: $sellerName, + 'seller_name' => $sellerName, + ]); + + $imported++; + } + return [$imported, $updated]; + } + + private function detectFormat(array $firstRow): string + { + if (isset($firstRow[1]) && str_contains($firstRow[1], 'AndroMoney')) { return 'andromoney'; } + return 'invoice'; + } + + private function findOrCreateCategory(string $name, ?int $parentId = null): int + { + $category = Category::firstOrCreate( + ['user_id' => auth()->id(), 'name' => $name, 'parent_id' => $parentId], + ['color' => $this->randomColor()] + ); + return $category->id; + } + + private function randomColor(): string + { + $colors = ['#EF4444', '#F59E0B', '#10B981', '#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316']; + return $colors[array_rand($colors)]; + } + + private function guessCategory($sellerName, $rules) + { + foreach ($rules as $rule) { + if (str_contains($sellerName, $rule->keyword)) { return $rule->category_id; } + } + return null; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php new file mode 100644 index 0000000..f20eaec --- /dev/null +++ b/app/Http/Controllers/Api/SecurityController.php @@ -0,0 +1,48 @@ +validate([ + 'type' => ['required', 'in:stock,etf,fund'], + 'keyword' => ['required', 'string', 'min:1'], + ]); + + $results = Security::query() + ->where('type', $validated['type']) + ->where(function ($query) use ($validated) { + $query->where('code', 'ilike', "%{$validated['keyword']}%") + ->orWhere('name', 'ilike', "%{$validated['keyword']}%"); + }) + ->orderBy('code') + ->limit(10) + ->get(['code', 'name']); + + if ($results->isEmpty()) { + if ($validated['type'] === 'fund') { + $res = Http::post("https://www.anuefund.com/anuefundApi/Search/Light", [ + 'keyword' => $validated['keyword'], + ]); + if ($res->ok()) { + $data = $res->json(); + foreach ($data['data']['result'] as $item) { + $results->push(new Security([ + 'code' => $item['fundID'], + 'name' => $item['fundName'], + ])); + } + } + } + } + + return response()->json($results); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/SecurityPriceController.php b/app/Http/Controllers/Api/SecurityPriceController.php new file mode 100644 index 0000000..7febb07 --- /dev/null +++ b/app/Http/Controllers/Api/SecurityPriceController.php @@ -0,0 +1,269 @@ +where('user_id', $request->user()->id); + })->findOrFail($holdingId); + + // 2. 從本地字典表撈出紀錄 + $security = DB::table('securities')->where('code', $holding->symbol)->first(); + + // 💡 關鍵判定:如果字典表根本沒這檔標的,或者資料「過期了」,就即時去戳外網更新 + $needUpdate = false; + + if (!$security) { + $needUpdate = true; + } else { + $lastUpdate = Carbon::parse($security->updated_at); + + // 情境 A:最新價格的日期不是今天 + if (!$lastUpdate->isToday()) { + $needUpdate = true; + } + // 情境 B:如果是今天的台股交易時間 (09:00 - 14:00),且本地資料超過 15 分鐘沒更新,就幫他刷新 + elseif (now()->between('09:00', '14:00') && now()->diffInMinutes($lastUpdate) > 15) { + $needUpdate = true; + } + } + + if ($needUpdate) { + // 當場發動外網即時救援 + $security = $this->fetchAndSaveRealtime($holding); + } + + if (!$security) { + return response()->json(['error' => '無法取得該標的的最新價格資料'], 404); + } + + // 3. 統一格式回傳給前端 + return response()->json([ + 'symbol' => $holding->symbol, + 'name' => $security->name, + 'type' => $holding->type, + 'latest_price' => (float) $security->latest_price, + 'change_amount' => (float) ($security->change_amount ?? 0), + 'change_rate' => (float) ($security->change_rate ?? 0), + 'updated_at' => $security->updated_at, + ]); + } + + /** + * 內部方法:即時抓取外部市價並洗回資料庫字典表 + */ + private function fetchAndSaveRealtime(InvestmentHolding $holding) + { + $priceData = [ + 'name' => $holding->name ?? $holding->symbol, + 'latest_price' => 0.0, + 'change_amount' => 0.0, + 'change_rate' => 0.0, + ]; + + // ---------------------------------------- + // A. 股票實時抓取 + // ---------------------------------------- + if ($holding->type === 'stock') { + try { + $exchange = (str_starts_with($holding->symbol, '00') || strlen($holding->symbol) === 4) ? 'tse' : 'tse'; // 可依需求擴充 otc + $res = Http::timeout(5)->get("https://mis.twse.com.tw/stock/api/getStockInfo.jsp", [ + 'ex_ch' => "{$exchange}_{$holding->symbol}.tw", + 'json' => 1, + 'delay' => 0, + ]); + + if ($res->ok() && !empty($res->json('msgArray'))) { + $stock = $res->json('msgArray')[0]; + if (isset($stock['^']) && !Carbon::parse($stock['^'])->isToday()) { + $existing = DB::table('securities')->where('code', $holding->symbol)->first(); + if ($existing) return $existing; + } + $priceData['name'] = $stock['n'] ?? $priceData['name']; + + $currentPrice = $stock['z'] !== '-' ? (float) $stock['z'] : (float) $stock['y']; + $prevPrice = (float) $stock['y']; + + $priceData['latest_price'] = $currentPrice; + $priceData['change_amount'] = $currentPrice - $prevPrice; + $priceData['change_rate'] = $prevPrice > 0 ? (($currentPrice - $prevPrice) / $prevPrice) * 100 : 0; + } + } catch (\Exception $e) { + // 網路超時防呆,保留不崩潰 + } + } + // ---------------------------------------- + // B. 基金實時抓取 + // ---------------------------------------- + elseif ($holding->type === 'fund') { + try { + $res = Http::timeout(5)->get("https://www.anuefund.com/anuefundApi/FundDetail/FundInfo", [ + 'fundDetailEnum' => 'FundINFO', + 'FundID' => $holding->symbol, + ]); + + if ($res->ok() && !empty($res->json('data.hearder'))) { + $header = $res->json('data.hearder'); + if (isset($header['navDate']) && !Carbon::parse($header['navDate'])->isToday()) { + $existing = DB::table('securities')->where('code', $holding->symbol)->first(); + if ($existing) return $existing; + } + $priceData['name'] = $header['fundName'] ?? $priceData['name']; + $priceData['latest_price'] = (float) $header['nav']; + // 鉅亨網實時 API 沒有給當日漲跌幅的話,這裡預設留空或 0 + } + } catch (\Exception $e) { + // 防呆 + } + } + + // 如果連外部都抓不到且本地完全沒資料,就防呆返回 null + if ($priceData['latest_price'] == 0) { + $existing = DB::table('securities')->where('code', $holding->symbol)->first(); + if ($existing) return $existing; + } + + // 更新或新增進字典表 + DB::table('securities')->updateOrInsert( + ['code' => $holding->symbol], + [ + 'name' => $priceData['name'], + 'type' => $holding->type, + 'source' => $holding->type === 'stock' ? 'twse' : 'anue', + 'latest_price' => $priceData['latest_price'], + 'change_amount' => $priceData['change_amount'], + 'change_rate' => $priceData['change_rate'], + 'updated_at' => now(), + 'created_at' => now(), // updateOrInsert 在更新時會自動忽略 created_at + ] + ); + + return DB::table('securities')->where('code', $holding->symbol)->first(); + } + + /** + * 整合後的歷史紀錄 API (股票、基金共用) + * GET /api/holdings/{holdingId}/history + */ + public function history(Request $request, string $holdingId) + { + $holding = InvestmentHolding::whereHas('account', function ($q) use ($request) { + $q->where('user_id', $request->user()->id); + })->with(['transactions'])->findOrFail($holdingId); + + // 找出最早的買入點作為時間起點 + $firstTx = $holding->transactions->where('action', 'buy')->sortBy('traded_at')->first(); + if (!$firstTx) return response()->json(['history' => [], 'buy_points' => []]); + + $startDate = Carbon::parse($firstTx->traded_at)->toDateString(); + + // 拿持倉的 symbol 去字典表查出對應的歷史主鍵 id + $securityId = DB::table('securities')->where('code', $holding->symbol)->value('id'); + if (!$securityId) { + return response()->json(['history' => [], 'buy_points' => []]); + } + + // 從本地歷史資料庫撈取趨勢 + $history = DB::table('security_price_histories') + ->where('security_id', $securityId) + ->where('price_date', '>=', $startDate) + ->orderBy('price_date', 'asc') + ->get(['price_date as date', 'price as close']) + ->map(function ($item) { + return [ + 'date' => $item->date, + 'close' => (float) $item->close + ]; + }) + ->toArray(); + + // 整合買賣點標記 + $buyPoints = $holding->transactions + ->where('action', 'buy') + ->map(fn($tx) => [ + 'date' => Carbon::parse($tx->traded_at)->toDateString(), + 'price' => (float) ($holding->type === 'fund' + ? ($tx->nav ?? $tx->price ?? 0) + : ($tx->price ?? $tx->nav ?? 0)), + 'note' => $tx->note ?? '買入', + ])->values(); + + return response()->json([ + 'history' => $history, + 'buy_points' => $buyPoints, + ]); + } + + public function accountTrend(Request $request, string $accountId) + { + $account = $request->user()->accounts()->findOrFail($accountId); + + // 1. 撈出所有目前有庫存的持倉 + $holdings = $account->holdings()->where('shares', '>', 0)->get(); + + // 🟢 關鍵修正:找出所有有庫存標的裡,最古老的一筆買入交易日期 + $earliestTxDate = DB::table('investment_transactions') + ->whereIn('holding_id', $holdings->pluck('id')) + ->where('action', 'buy') + ->orderBy('traded_at', 'asc') + ->value('traded_at'); + + // 防呆:如果真的完全沒有交易紀錄,預設才走 30 天;有的話就以最古老日期為起點 + $startDate = $earliestTxDate + ? Carbon::parse($earliestTxDate)->toDateString() + : now()->subDays(30)->format('Y-m-d'); + + $securityIds = DB::table('securities')->whereIn('code', $holdings->pluck('symbol'))->pluck('id', 'code'); + + // 2. 撈取歷史紀錄 (此時起點已經完美回溯到 4 月甚至更早了) + $historyPrices = DB::table('security_price_histories') + ->whereIn('security_id', $securityIds->values()) + ->where('price_date', '>=', $startDate) + ->orderBy('price_date', 'asc') + ->get() + ->groupBy('security_id'); + + $chartData = []; + + foreach ($holdings as $holding) { + $sid = $securityIds->get($holding->symbol); + if (!$sid) continue; + + $prices = $historyPrices->get($sid) ?? collect(); + $avgCost = floatval($holding->avg_cost); + if ($avgCost <= 0) continue; + + $seriesData = []; + foreach ($prices as $hp) { + $dailyRate = ((floatval($hp->price) - $avgCost) / $avgCost) * 100; + $seriesData[] = [ + 'date' => $hp->price_date, + 'rate' => round($dailyRate, 2) + ]; + } + + $chartData[] = [ + 'symbol' => $holding->symbol, + 'name' => $holding->name, + 'data' => $seriesData + ]; + } + + return response()->json($chartData); + } +} diff --git a/app/Http/Controllers/Auth/GoogleAuthController.php b/app/Http/Controllers/Auth/GoogleAuthController.php new file mode 100644 index 0000000..14a5fe9 --- /dev/null +++ b/app/Http/Controllers/Auth/GoogleAuthController.php @@ -0,0 +1,51 @@ +scopes(['https://www.googleapis.com/auth/gmail.readonly']) + ->with([ + 'access_type' => 'offline', // 💡 極其重要:強制要求 Google 回傳長期有效的 refresh_token + 'prompt' => 'consent' // 💡 強制跳出授權提示,確保每次都能順利拿到憑證 + ]) + ->redirect(); + } + + // Google 打回來後處理 + public function callback() + { + $googleUser = Socialite::driver('google')->stateless()->user(); + + $user = User::updateOrCreate( + ['email' => $googleUser->getEmail()], + [ + 'name' => $googleUser->getName(), + 'google_id' => $googleUser->getId(), + 'avatar' => $googleUser->getAvatar(), + 'password' => bcrypt(str()->random(24)), + 'google_access_token' => $googleUser->token, + 'google_refresh_token' => $googleUser->refreshToken, // 💡 只有第一次授權或加了 prompt=consent 才會給 + 'google_token_expires_at' => now()->addSeconds($googleUser->expiresIn), + ] + ); + + if ($googleUser->refreshToken) { + $user->update(['google_refresh_token' => $googleUser->refreshToken]); + } + + $token = $user->createToken('auth_token')->plainTextToken; + + // 把 token 帶回前端 + return redirect("http://localhost:5173/#/auth/callback?token={$token}&name=" . urlencode($user->name)); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/CategoryRuleController.php b/app/Http/Controllers/CategoryRuleController.php deleted file mode 100644 index 5ed9ce1..0000000 --- a/app/Http/Controllers/CategoryRuleController.php +++ /dev/null @@ -1,37 +0,0 @@ -validate([ - 'keyword' => 'required|string|max:255', - 'category_id' => 'required|exists:categories,id', - ]); - - CategoryRule::create([ - ...$validated, - 'user_id' => auth()->id(), - ]); - - return redirect()->back(); - } - - public function destroy(CategoryRule $categoryRule) - { - if ($categoryRule->user_id !== auth()->id()) { - abort(403); - } - - $categoryRule->delete(); - return redirect()->back(); - } -} \ No newline at end of file diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php deleted file mode 100644 index 3c00e8d..0000000 --- a/app/Http/Controllers/DashboardController.php +++ /dev/null @@ -1,57 +0,0 @@ -whereYear('date', $now->year) - ->whereMonth('date', $now->month) - ->sum('amount'); - - // 本月分類統計(圓餅圖) - $categoryStats = Expense::with('category') - ->where('user_id', $userId) - ->whereYear('date', $now->year) - ->whereMonth('date', $now->month) - ->get() - ->groupBy(fn($e) => $e->category?->name ?? '未分類') - ->map(fn($group, $name) => [ - 'name' => $name, - 'total' => $group->sum('amount'), - 'color' => $group->first()->category?->color ?? '#6B7280', - ]) - ->values(); - - // 近6個月趨勢 - $monthlyTrend = collect(range(5, 0))->map(function ($i) use ($userId, $now) { - $month = $now->copy()->subMonths($i); - $total = Expense::where('user_id', $userId) - ->whereYear('date', $month->year) - ->whereMonth('date', $month->month) - ->sum('amount'); - - return [ - 'month' => $month->format('Y/m'), - 'total' => (float) $total, - ]; - }); - - return Inertia::render('Dashboard', [ - 'monthlyTotal' => (float) $monthlyTotal, - 'categoryStats' => $categoryStats, - 'monthlyTrend' => $monthlyTrend, - ]); - } -} \ No newline at end of file diff --git a/app/Http/Controllers/ExpenseController.php b/app/Http/Controllers/ExpenseController.php deleted file mode 100644 index f5323e3..0000000 --- a/app/Http/Controllers/ExpenseController.php +++ /dev/null @@ -1,114 +0,0 @@ -where('user_id', auth()->id()) - ->orderBy('date', 'desc') - ->get(); - - $categories = Category::where('user_id', auth()->id())->get(); - - $rules = CategoryRule::with('category') - ->where('user_id', auth()->id()) - ->get(); - - return Inertia::render('Expenses/Index', [ - 'expenses' => $expenses, - 'categories' => $categories, - 'rules' => $rules, - ]); - } - - - /** - * Store a newly created resource in storage. - */ - public function store(Request $request) - { - $validated = $request->validate([ - 'amount' => 'required|numeric|min:0', - 'category_id' => 'nullable|exists:categories,id', - 'note' => 'nullable|string|max:255', - 'date' => 'required|date', - ]); - - Expense::create([ - ...$validated, - 'user_id' => auth()->id(), - ]); - - return redirect()->back(); - } - - /** - * Display the specified resource. - */ - public function show(Expense $expense) - { - // - } - - /** - * Show the form for editing the specified resource. - */ - public function edit(Expense $expense) - { - // - } - - /** - * Update the specified resource in storage. - */ - public function update(Request $request, Expense $expense) - { - $this->authorize('update', $expense); - - $validated = $request->validate([ - 'amount' => 'required|numeric|min:0', - 'category_id' => 'nullable|exists:categories,id', - 'note' => 'nullable|string|max:255', - 'date' => 'required|date', - ]); - - $expense->update($validated); - - return redirect()->back(); - } - - /** - * Remove the specified resource from storage. - */ - public function destroy(Expense $expense) - { - $this->authorize('delete', $expense); - $expense->delete(); - return redirect()->back(); - } - - public function destroyBatch(Request $request) - { - $request->validate(['ids' => 'required|array']); - - Expense::whereIn('id', $request->ids) - ->where('user_id', auth()->id()) - ->delete(); - - return redirect()->back(); - } -} diff --git a/app/Http/Controllers/InvoiceImportController.php b/app/Http/Controllers/InvoiceImportController.php deleted file mode 100644 index 3c1b5a1..0000000 --- a/app/Http/Controllers/InvoiceImportController.php +++ /dev/null @@ -1,258 +0,0 @@ - auth()->id(), - 'name' => $name, - 'parent_id' => $parentId, - ], - [ - 'color' => $this->randomColor(), - ] - ); - return $category->id; - } - - private function randomColor(): string - { - $colors = [ - '#EF4444', - '#F59E0B', - '#10B981', - '#3B82F6', - '#8B5CF6', - '#EC4899', - '#14B8A6', - '#F97316', - ]; - return $colors[array_rand($colors)]; - } - - // ── 發票格式匯入 ── - private function importInvoice($handle, $rules): array - { - $imported = 0; - $updated = 0; - - while (($row = fgetcsv($handle)) !== false) { - if (count($row) < 8) continue; - - $invoiceNumber = trim($row[2] ?? ''); - $date = trim($row[1] ?? ''); - $amount = trim($row[3] ?? '0'); - $sellerName = trim($row[7] ?? ''); - $itemName = trim($row[13] ?? ''); - - if (empty($invoiceNumber)) continue; - if ((float)$amount < 0) { - $updated++; - continue; - } - - $formattedDate = null; - if (strlen($date) === 8) { - $formattedDate = substr($date, 0, 4) . '-' - . substr($date, 4, 2) . '-' - . substr($date, 6, 2); - } - - $exists = Expense::where('user_id', auth()->id()) - ->where('invoice_number', $invoiceNumber) - ->exists(); - - if ($exists) { - $updated++; - continue; - } - - $categoryId = $this->guessCategory($sellerName, $rules); - - $expense = Expense::updateOrCreate( - [ - 'user_id' => auth()->id(), - 'external_id' => $invoiceNumber, - ], - [ - 'type' => 'expense', - 'amount' => (float)$amount, - 'date' => $formattedDate, - 'seller_name' => $sellerName, - 'item_name' => $itemName, - 'invoice_number' => $invoiceNumber, - 'note' => $sellerName, - 'category_id' => $categoryId, - ] - ); - if ($expense->wasRecentlyCreated) { - $imported++; - } else { - $updated++; - } - } - - return [$imported, $updated]; - } - - // ── AndroMoney 格式匯入 ── - private function importAndroMoney($handle): array - { - $imported = 0; - $updated = 0; - - // 跳過標題列(第二行) - fgetcsv($handle); - - while (($row = fgetcsv($handle)) !== false) { - // 轉換 Big5 → UTF-8 - $row = array_map(fn($val) => mb_convert_encoding($val, 'UTF-8', 'Big5'), $row); - - if (count($row) < 6) continue; - // 欄位對應 - // A=0:id, B=1:幣別, C=2:金額, D=3:分類, E=4:子分類 - // F=5:日期, G=6:付款, H=7:收款, I=8:備註, M=12:商家, N=13:uid - $amount = trim($row[2] ?? '0'); - $catName = trim($row[3] ?? ''); - $subCatName = trim($row[4] ?? ''); - $date = trim($row[5] ?? ''); - $payOut = trim($row[6] ?? ''); - $payIn = trim($row[7] ?? ''); - $note = trim($row[8] ?? ''); - $sellerName = trim($row[13] ?? ''); - - // 過濾掉看起來像 uid 的值(32碼以上的hex字串) - if (preg_match('/^[0-9a-f]{20,}$/i', $note)) { - $note = ''; - } - - if (empty($amount) || empty($date)) continue; - if ((float)$amount == 0) continue; - - // 日期格式轉換 20260106 → 2026-01-06 - $formattedDate = null; - if (strlen($date) === 8 && is_numeric($date)) { - $formattedDate = substr($date, 0, 4) . '-' - . substr($date, 4, 2) . '-' - . substr($date, 6, 2); - } - - // 日期無效就跳過 - if (!$formattedDate) { - $updated++; - continue; - } - - // 判斷收入/支出 - // 付款欄有值 = 支出,收款欄有值 = 收入 - $type = !empty($payIn) ? 'income' : 'expense'; - - // 取得或建立主分類 - $categoryId = null; - if (!empty($catName)) { - $categoryId = $this->findOrCreateCategory($catName); - } - - // 取得或建立子分類(掛在主分類下) - $subcategoryId = null; - if (!empty($subCatName) && $categoryId) { - $subcategoryId = $this->findOrCreateCategory($subCatName, $categoryId); - } - - // uid 是 row[12] - $uid = trim($row[12] ?? ''); - - $expense = Expense::updateOrCreate( - [ - 'user_id' => auth()->id(), - 'external_id' => $uid ?: null, - ], - [ - 'type' => $type, - 'amount' => abs((float)$amount), - 'date' => $formattedDate, - 'category_id' => $categoryId, - 'subcategory_id' => $subcategoryId, - 'note' => $note ?: $sellerName, - 'seller_name' => $sellerName, - ] - ); - - if ($expense->wasRecentlyCreated) { - $imported++; - } else { - $updated++; - } - } - - return [$imported, $updated]; - } - - // ── 自動分類(發票用)── - private function guessCategory($sellerName, $rules) - { - foreach ($rules as $rule) { - if (str_contains($sellerName, $rule->keyword)) { - return $rule->category_id; - } - } - return null; - } - - public function index() - { - return inertia('Expenses/Import'); - } - - public function store(Request $request) - { - $request->validate([ - 'file' => 'required|file|mimes:csv,txt', - ]); - - $file = $request->file('file'); - $handle = fopen($file->getPathname(), 'r'); - - // 讀第一行偵測格式 - $firstRow = fgetcsv($handle); - $format = $this->detectFormat($firstRow); - - $rules = CategoryRule::with('category') - ->where('user_id', auth()->id()) - ->get(); - - if ($format === 'andromoney') { - [$imported, $updated] = $this->importAndroMoney($handle); - } else { - [$imported, $updated] = $this->importInvoice($handle, $rules); - } - - fclose($handle); - - $formatName = $format === 'andromoney' ? 'AndroMoney' : '發票'; - - return redirect()->route('expenses.index')->with([ - 'message' => "【{$formatName}】匯入完成!新增 {$imported} 筆,更新 {$updated} 筆", - ]); - } -} diff --git a/app/Jobs/FetchAndParseGmailInvoices.php b/app/Jobs/FetchAndParseGmailInvoices.php new file mode 100644 index 0000000..456dfdf --- /dev/null +++ b/app/Jobs/FetchAndParseGmailInvoices.php @@ -0,0 +1,140 @@ +user = $user; + } + + public function handle(GmailParserService $parserService) + { + // 1. 檢查使用者憑證 + if (!$this->user->google_refresh_token) { + Log::warning("User ID {$this->user->id} 試圖執行抓信任務,但缺乏 refresh_token。"); + return; + } + + try { + // 2. 初始化 Google Client 並刷新 Token + $client = new GoogleClient(); + $client->setClientId(config('services.google.client_id')); + $client->setClientSecret(config('services.google.client_secret')); + $client->refreshToken($this->user->google_refresh_token); + + $gmailService = new Gmail($client); + + // 3. 搜尋帶有專屬標籤且「未讀」的信件 + $searchQuery = 'label:FinBuddy-Card is:unread'; + $response = $gmailService->users_messages->listUsersMessages('me', [ + 'q' => $searchQuery, + 'maxResults' => 20 // 每次最多處理 20 筆,防爆 + ]); + + $messages = $response->getMessages(); + + if (empty($messages)) { + Log::info("User ID {$this->user->id}: 沒發現符合條件的未讀刷卡信件。"); + return; + } + + // 4. 逐封拆解信件 + foreach ($messages as $msgItem) { + $msgId = $msgItem->getId(); + $message = $gmailService->users_messages->get('me', $msgId, ['format' => 'full']); + $payload = $message->getPayload(); + + // 抓取主旨 + $subject = ''; + foreach ($payload->getHeaders() as $header) { + if ($header->getName() === 'Subject') { + $subject = $header->getValue(); + break; + } + } + + // 遞迴拿到內文 + $body = $this->getDecodedBody($payload); + + // 5. 丟給 Parser 進行 Regex 解析 + $parsedData = $parserService->parse($subject, $body); + + if ($parsedData) { + // 🟢 智慧分類:根據商家名稱反查資料庫裡的自動分類規則 + // 假設你的規則表有關鍵字比對 (例如: keyword = 'PCHOME', category_id = 3) + $matchedRule = CategoryRule::where('user_id', $this->user->id) + ->get() + ->first(function ($rule) use ($parsedData) { + return Str::contains(strtoupper($parsedData['seller_name']), strtoupper($rule->keyword)); + }); + + $categoryId = $matchedRule ? $matchedRule->category_id : null; + + // 6. 寫入消費紀錄(我們已經補上了 $fillable,絕對暢行無阻!) + Expense::create([ + 'user_id' => $this->user->id, + 'amount' => $parsedData['amount'], + 'item_name' => $parsedData['item_name'], + 'seller_name' => $parsedData['seller_name'], + 'date' => $parsedData['date'], + 'category_id' => $categoryId, + 'note' => 'Gmail背景自動記帳', + ]); + + Log::info("User ID {$this->user->id}: 成功自動記帳一筆金額 {$parsedData['amount']},商家: {$parsedData['seller_name']}"); + } + + // 7. 🟢 防重複防線:記帳成功後,立刻把信件在 Gmail 標記為「已讀」 + $mods = new ModifyMessageRequest(); + $mods->setRemoveLabelIds(['UNREAD']); // 移除未讀標籤 = 標記已讀 + $gmailService->users_messages->modify('me', $msgId, $mods); + } + + } catch (\Exception $e) { + Log::error("User ID {$this->user->id} Gmail 自動抓帳 Job 發生崩潰: " . $e->getMessage()); + } + } + + /** + * 遞迴解析內文的輔助方法 + */ + private function getDecodedBody($part) + { + $body = $part->getBody(); + if ($body && $body->getData()) { + $sanitizedData = str_replace(['-', '_'], ['+', '/'], $body->getData()); + return base64_decode($sanitizedData); + } + $parts = $part->getParts(); + if ($parts) { + foreach ($parts as $subPart) { + $result = $this->getDecodedBody($subPart); + if ($result) return $result; + } + } + return ''; + } +} diff --git a/app/Jobs/UpdateAssetPriceJob.php b/app/Jobs/UpdateAssetPriceJob.php new file mode 100644 index 0000000..1c84a21 --- /dev/null +++ b/app/Jobs/UpdateAssetPriceJob.php @@ -0,0 +1,148 @@ +symbol = $symbol; + $this->type = $type; + $this->defaultName = $defaultName; + } + + public function handle(): void + { + try { + Log::info("🔄 開始從持倉清單發動爬蟲: [{$this->symbol}] {$this->defaultName}"); + + $isRealPrice = true; + $latestPrice = 0; + $latestPriceDate = null; + $name = $this->defaultName; + $changeAmount = 0; + $changeRate = 0; + $error = null; + + // 股票去爬證交所 + if ($this->type === 'stock') { + Log::info("開始抓取{$this->type}:{$this->symbol}價格"); + $response = Http::timeout(10)->get("https://mis.twse.com.tw/stock/api/getStockInfo.jsp?ex_ch=tse_{$this->symbol}.tw"); + + if ($response->successful()) { + $json = $response->json(); + $info = $json['msgArray'][0] ?? null; + if (isset($info['^']) && !Carbon::parse($info['^'])->isToday()) { + $isRealPrice = false; + } + + if ($info) { + $latestPrice = floatval($info['z'] ?? $info['o'] ?? 0); + $latestPriceDate = isset($info['d']) ? Carbon::parse($info['d'])->format('Y-m-d') : now()->format('Y-m-d'); + $name = $info['n'] ?? $this->defaultName; + + if (isset($info['y']) && $latestPrice > 0) { + $changeAmount = floatval($latestPrice - $info['y']); + $changeRate = ($changeAmount / $info['y']) * 100; + } + } + } + } elseif ($this->type === 'fund') { + Log::info("開始抓取{$this->type}:{$this->symbol}價格"); + // 去鉅亨網抓 + $response = Http::timeout(10) + ->withHeaders([ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer' => 'https://www.anuefund.com/', + 'Accept' => 'application/json, text/plain, */*', + ]) + ->get("https://www.anuefund.com/anuefundApi/FundDetail/FundInfo", [ + 'fundDetailEnum' => 'FundINFO', + 'FundID' => $this->symbol + ]); + + if ($response->successful()) { + $json = $response->json(); + $headerInfo = $json['data']['hearder'] ?? null; + + if (isset($headerInfo['navDate']) && !Carbon::parse($headerInfo['navDate'])->isToday()) { + //不是當天資料不更新 + $isRealPrice = false; + } + + if ($headerInfo) { + $latestPriceDate = isset($headerInfo['navDate']) ? substr($headerInfo['navDate'], 0, 10) : now()->format('Y-m-d'); + $latestPrice = floatval($headerInfo['nav'] ?? 0); + + if ($latestPriceDate !== now()->format('Y-m-d')) { + //不是當天資料不更新 + $isRealPrice = false; + $latestPrice = 0; + } + + $name = $headerInfo['fundName'] ?? $this->defaultName; + $changeAmount = floatval($headerInfo['upDown'] ?? 0); + $changeRate = floatval($headerInfo['upDownRate'] ?? 0); + } + } + Log::info("抓取{$this->type}:{$this->symbol}資料結束"); + } + + // 爬蟲成功拿到有效股價,幫新表補資料 + if ($latestPrice > 0 && $isRealPrice) { + Log::info("開始寫入Security和SecurityPriceHistories表,標的: [{$this->symbol}],價格: {$latestPrice}---"); + $security = Security::updateOrCreate( + ['code' => $this->symbol], + [ + 'type' => $this->type, + 'name' => $name, + 'latest_price' => $latestPrice, + 'latest_price_at' => $latestPriceDate, + 'change_amount' => $changeAmount, + 'change_rate' => $changeRate, + 'source' => 'twse', + ] + ); + + SecurityPriceHistories::updateOrCreate( + [ + 'security_id' => $security->id, + 'price_date' => now()->format('Y-m-d'), // 記錄今天的日期 + ], + [ + 'price' => $latestPrice, + 'change_amount' => $changeAmount, + 'change_rate' => $changeRate, + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + Log::info("✅ Security & SecurityPriceHistories 標的 [{$this->symbol}] 補齊並更新成功: {$latestPrice}"); + } elseif (!$isRealPrice) { + Log::info("ℹ️ 標的 [{$this->symbol}] 抓到的價格是非即時的,不更新價格資訊。"); + } else { + Log::warning("⚠️ 標的 [{$this->symbol}] 未能從證交所抓到有效價格,{$error}"); + } + } catch (\Exception $e) { + Log::error("❌ 更新標的 [{$this->symbol}] 價格時發生異常: " . $e->getMessage()); + $this->release(60); + } + } +} diff --git a/app/Models/Account.php b/app/Models/Account.php new file mode 100644 index 0000000..262de92 --- /dev/null +++ b/app/Models/Account.php @@ -0,0 +1,54 @@ +belongsTo(User::class); + } + + public function holdings() + { + return $this->hasMany(InvestmentHolding::class); + } + + public function expenses() + { + return $this->hasMany(Expense::class); + } + + public function unpaidAmount() + { + $billingDay = $this->billing_day ?? 1; + + $now = now(); + + // 如果今天還沒到結帳日,帳單週期是上個月結帳日到這個月結帳日 + if ($now->day < $billingDay) { + $start = $now->copy()->subMonth()->setDay($billingDay); + $end = $now->copy()->setDay($billingDay)->subDay(); + } else { + $start = $now->copy()->setDay($billingDay); + $end = $now->copy()->addMonth()->setDay($billingDay)->subDay(); + } + + return $this->expenses() + ->whereBetween('date', [$start->toDateString(), $end->toDateString()]) + ->sum('amount'); + } +} diff --git a/app/Models/AccountRule.php b/app/Models/AccountRule.php new file mode 100644 index 0000000..8f09c1a --- /dev/null +++ b/app/Models/AccountRule.php @@ -0,0 +1,10 @@ + 'date:Y-m-d', @@ -39,4 +39,9 @@ class Expense extends Model { return $this->belongsTo(Category::class, 'subcategory_id'); } + + public function account() + { + return $this->belongsTo(Account::class); + } } diff --git a/app/Models/InvestmentHolding.php b/app/Models/InvestmentHolding.php new file mode 100644 index 0000000..62dbded --- /dev/null +++ b/app/Models/InvestmentHolding.php @@ -0,0 +1,32 @@ +belongsTo(Account::class); + } + + public function transactions() + { + return $this->hasMany(InvestmentTransaction::class, 'holding_id'); + } + + public function security() + { + return $this->hasOne(Security::class, 'code', 'symbol'); + } +} \ No newline at end of file diff --git a/app/Models/InvestmentTransaction.php b/app/Models/InvestmentTransaction.php new file mode 100644 index 0000000..0db07e9 --- /dev/null +++ b/app/Models/InvestmentTransaction.php @@ -0,0 +1,30 @@ + 'date', + ]; + + public function holding() + { + return $this->belongsTo(InvestmentHolding::class, 'holding_id'); + } +} \ No newline at end of file diff --git a/app/Models/Security.php b/app/Models/Security.php new file mode 100644 index 0000000..1d28fd5 --- /dev/null +++ b/app/Models/Security.php @@ -0,0 +1,33 @@ + 'datetime', + 'latest_price' => 'decimal:4', + 'change_amount' => 'decimal:4', + 'change_rate' => 'decimal:4', + 'meta' => 'array', + ]; + + public function historicPrices() + { + return $this->hasMany(SecurityPriceHistories::class, 'security_id', 'id'); + } +} \ No newline at end of file diff --git a/app/Models/SecurityPriceHistories.php b/app/Models/SecurityPriceHistories.php new file mode 100644 index 0000000..d458c3b --- /dev/null +++ b/app/Models/SecurityPriceHistories.php @@ -0,0 +1,29 @@ + 'decimal:4', + 'price_date' => 'datetime', + 'change_amount' => 'decimal:4', + 'change_rate' => 'decimal:4', + ]; + + public function security() + { + return $this->belongsTo(Security::class, 'security_id', 'id'); + } +} \ No newline at end of file diff --git a/app/Models/User.php b/app/Models/User.php index 749c7b7..0e1a897 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,12 +5,14 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; +use App\Models\Account; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasFactory, Notifiable, HasApiTokens; /** * The attributes that are mass assignable. @@ -21,6 +23,11 @@ class User extends Authenticatable 'name', 'email', 'password', + 'google_id', + 'avatar', + 'google_access_token', + 'google_refresh_token', + 'google_token_expires_at', ]; /** @@ -45,4 +52,9 @@ class User extends Authenticatable 'password' => 'hashed', ]; } + + public function accounts() + { + return $this->hasMany(Account::class); + } } diff --git a/app/Services/GmailParserService.php b/app/Services/GmailParserService.php new file mode 100644 index 0000000..0ecc935 --- /dev/null +++ b/app/Services/GmailParserService.php @@ -0,0 +1,180 @@ + ['國泰世華銀行消費彙整通知'], + 'method' => 'parseCathaySummary' + ], + [ + 'keywords' => ['永豐銀行信用卡消費通知'], + 'method' => 'parseSinoPacNotification' + ], + [ + 'keywords' => ['電子發票', '載具', '發票開立通知'], + 'method' => 'parseElectronicInvoice' + ], + [ + 'keywords' => ['消費通知', '刷卡', '卡片消費'], + 'method' => 'parseCreditCardNotification' + ], + ]; + + public function parse(string $subject, string $body): ?array + { + // 🔄 遍歷矩陣,尋找第一個命中的規則 + foreach ($this->parserMatrix as $rule) { + if (Str::contains($subject, $rule['keywords'])) { + $method = $rule['method']; + + // 動態呼叫對應的方法 (例如 $this->parseCathaySummary($body) ) + return $this->{$method}($body); + } + } + + Log::info("【Parser】忽略信件,主旨未命中任何對照規則: [{$subject}]"); + return null; + } + + /** + * 🟢 國泰世華消費彙整信件專屬解析 + */ + private function parseCathaySummary(string $body): ?array + { + $cleanBody = preg_replace('/\s+/', ' ', $body); + + if (preg_match('/(\d{4}\/\d{2}\/\d{2})/u', $cleanBody, $dateMatches)) { + $date = Carbon::createFromFormat('Y/m/d', $dateMatches[1])->format('Y-m-d'); + } else { + $date = now()->format('Y-m-d'); + } + + $pattern = '/NT\$\s*([0-9,]+)\s*<\/td>\s*]*>\s*([^<\s&]+)\s*<\/td>/u'; + + if (!preg_match_all($pattern, $cleanBody, $matches, PREG_SET_ORDER)) { + Log::error("【Parser】國泰信件未命中消費明細。"); + return null; + } + + $results = []; + foreach ($matches as $match) { + $sellerName = $this->convertFullWidthToHalfWidth(trim($match[2])); + $results[] = [ + 'amount' => floatval(str_replace(',', '', $match[1])), + 'item_name' => "信用卡消費: {$sellerName}", + 'seller_name' => $sellerName, + 'date' => $date, + 'category_id' => null, + ]; + } + + return $results; + } + + /** + * 永豐銀行信用卡消費通知(單筆制) + */ + private function parseSinoPacNotification(string $body): ?array + { + $cleanBody = preg_replace('/\s+/', ' ', $body); + $amount = 0; + $sellerName = '永豐刷卡消費'; + + if (preg_match('/(?:新臺幣|TWD)\s*([0-9,]+)/u', $cleanBody, $amountMatches)) { + $amount = floatval(str_replace(',', '', $amountMatches[1])); + } + + if (preg_match('/特約商店[::]\s*([^<\s]+)/u', $cleanBody, $merchantMatches)) { + $sellerName = trim($merchantMatches[1]); + } + + if ($amount === 0) return null; + + if (preg_match('/(\d{4}\/\d{2}\/\d{2})/u', $cleanBody, $dateMatches)) { + $date = Carbon::createFromFormat('Y/m/d', $dateMatches[1])->format('Y-m-d'); + } else { + $date = now()->format('Y-m-d'); + } + + $sellerName = $this->convertFullWidthToHalfWidth($sellerName); + + return [ + 'amount' => $amount, + 'item_name' => "信用卡消費: {$sellerName}", + 'seller_name' => $sellerName, + 'date' => $date, + 'category_id' => null, + ]; + } + + /** + * 解析電子發票信件 + */ + private function parseElectronicInvoice(string $body): ?array + { + $amount = 0; + $sellerName = '未知商家'; + + if (preg_match('/(總計|金額|總金額)[::]\s*?\$?([0-9,]+)/u', $body, $matches)) { + $amount = floatval(str_replace(',', '', $matches[2])); + } + if (preg_match('/(賣方|開立店家|商店名稱)[::]\s*?([^\n<\s]+)/u', $body, $matches)) { + $sellerName = trim($matches[2]); + } + + if ($amount === 0) return null; + + return [ + 'amount' => $amount, + 'item_name' => '電子發票消費', + 'seller_name' => $sellerName, + 'date' => now()->format('Y-m-d'), + 'category_id' => null, + ]; + } + + /** + * 解析信用卡消費通知(泛用型備援) + */ + private function parseCreditCardNotification(string $body): ?array + { + $amount = 0; + $sellerName = '一般刷卡消費'; + + if (preg_match('/(NT\$|元|TWD)\s*?([0-9,]+)/u', $body, $matches)) { + $amount = floatval(str_replace(',', '', $matches[2])); + } elseif (preg_match('/([0-9,]+)\s*?(元)/u', $body, $matches)) { + $amount = floatval(str_replace(',', '', $matches[1])); + } + + if (preg_match('/於\s*?([^\n\]((]+?)\s*?消費/u', $body, $matches)) { + $sellerName = trim($matches[1]); + } elseif (preg_match('/(特約商店|消費商店|商店)[::]\s*?([^\n<\s]+)/u', $body, $matches)) { + $sellerName = trim($matches[2]); + } + + if ($amount === 0) return null; + + return [ + 'amount' => $amount, + 'item_name' => "刷卡消費: {$sellerName}", + 'seller_name' => $sellerName, + 'date' => now()->format('Y-m-d'), + 'category_id' => null, + ]; + } + + private function convertFullWidthToHalfWidth(string $str): string + { + $str = str_replace(' ', '', $str); + return mb_convert_kana($str, 'asKV', 'UTF-8'); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 5c02a59..a882bc7 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,12 +3,18 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Support\Facades\Route; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', + then: function () { + Route::middleware('web') + ->group(base_path('routes/auth.php')); + } ) ->withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [ diff --git a/composer.json b/composer.json index f732c63..aac93b0 100644 --- a/composer.json +++ b/composer.json @@ -7,10 +7,13 @@ "license": "MIT", "require": { "php": "^8.2", + "google/apiclient": "^2.19", "inertiajs/inertia-laravel": "^2.0", "laravel/framework": "^12.0", "laravel/sanctum": "^4.0", + "laravel/socialite": "^5.27", "laravel/tinker": "^2.10.1", + "orangehill/iseed": "^3.8", "tightenco/ziggy": "^2.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 542cc68..ef60e1e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "aa715b4183fb30d40dbf87a9f5da20c6", + "content-hash": "27f692f281e65d361351a19f032320f1", "packages": [ { "name": "brick/math", @@ -508,6 +508,72 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.1.0", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", + "phpseclib/phpseclib": "~3.0", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", + "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/googleapis/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.1.0" + }, + "time": "2026-06-11T17:54:14+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -579,6 +645,183 @@ ], "time": "2025-12-03T09:33:47+00:00" }, + { + "name": "google/apiclient", + "version": "v2.19.3", + "source": { + "type": "git", + "url": "https://github.com/googleapis/google-api-php-client.git", + "reference": "a1f02761994fd9defb20f6f1449205fd66f450de" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/google-api-php-client/zipball/a1f02761994fd9defb20f6f1449205fd66f450de", + "reference": "a1f02761994fd9defb20f6f1449205fd66f450de", + "shasum": "" + }, + "require": { + "firebase/php-jwt": "^6.0||^7.0", + "google/apiclient-services": "~0.350", + "google/auth": "^1.37", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.6", + "monolog/monolog": "^2.9||^3.0", + "php": "^8.1" + }, + "require-dev": { + "cache/filesystem-adapter": "^1.1", + "composer/composer": "^2.9", + "phpcompatibility/php-compatibility": "^9.2", + "phpspec/prophecy-phpunit": "^2.1", + "phpunit/phpunit": "^9.6", + "squizlabs/php_codesniffer": "^3.8", + "symfony/css-selector": "~2.1", + "symfony/dom-crawler": "~2.1" + }, + "suggest": { + "cache/filesystem-adapter": "For caching certs and tokens (using Google\\Client::setCache)" + }, + "type": "library", + "extra": { + "component": { + "entry": "src/Client.php" + }, + "branch-alias": { + "dev-main": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/aliases.php" + ], + "psr-4": { + "Google\\": "src/" + }, + "classmap": [ + "src/aliases.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Client library for Google APIs", + "homepage": "http://developers.google.com/api-client-library/php", + "keywords": [ + "google" + ], + "support": { + "issues": "https://github.com/googleapis/google-api-php-client/issues", + "source": "https://github.com/googleapis/google-api-php-client/tree/v2.19.3" + }, + "time": "2026-05-04T21:00:36+00:00" + }, + { + "name": "google/apiclient-services", + "version": "v0.445.0", + "source": { + "type": "git", + "url": "https://github.com/googleapis/google-api-php-client-services.git", + "reference": "d76b09227d898db351457010c88f39eedfb815aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/google-api-php-client-services/zipball/d76b09227d898db351457010c88f39eedfb815aa", + "reference": "d76b09227d898db351457010c88f39eedfb815aa", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "type": "library", + "autoload": { + "files": [ + "autoload.php" + ], + "psr-4": { + "Google\\Service\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Client library for Google APIs", + "homepage": "http://developers.google.com/api-client-library/php", + "keywords": [ + "google" + ], + "support": { + "issues": "https://github.com/googleapis/google-api-php-client-services/issues", + "source": "https://github.com/googleapis/google-api-php-client-services/tree/v0.445.0" + }, + "time": "2026-06-15T01:58:30+00:00" + }, + { + "name": "google/auth", + "version": "v1.51.0", + "source": { + "type": "git", + "url": "https://github.com/googleapis/google-auth-library-php.git", + "reference": "4c4776e398ff255e81b3b8c4373983f5e1b765bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/google-auth-library-php/zipball/4c4776e398ff255e81b3b8c4373983f5e1b765bf", + "reference": "4c4776e398ff255e81b3b8c4373983f5e1b765bf", + "shasum": "" + }, + "require": { + "firebase/php-jwt": "^6.0||^7.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.4.5", + "php": "^8.1", + "psr/cache": "^2.0||^3.0", + "psr/http-message": "^1.1||^2.0", + "psr/log": "^2.0||^3.0" + }, + "require-dev": { + "guzzlehttp/promises": "^2.0", + "kelvinmo/simplejwt": "^1.1.0", + "phpseclib/phpseclib": "^3.0.35", + "phpspec/prophecy-phpunit": "^2.1", + "phpunit/phpunit": "^9.6", + "sebastian/comparator": ">=1.2.3", + "squizlabs/php_codesniffer": "^4.0", + "symfony/filesystem": "^6.3||^7.3", + "symfony/process": "^6.0||^7.0", + "webmozart/assert": "^1.11||^2.0" + }, + "suggest": { + "phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2." + }, + "type": "library", + "autoload": { + "psr-4": { + "Google\\Auth\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Google Auth Library for PHP", + "homepage": "https://github.com/google/google-auth-library-php", + "keywords": [ + "Authentication", + "google", + "oauth2" + ], + "support": { + "docs": "https://cloud.google.com/php/docs/reference/auth/latest", + "issues": "https://github.com/googleapis/google-auth-library-php/issues", + "source": "https://github.com/googleapis/google-auth-library-php/tree/v1.51.0" + }, + "time": "2026-06-10T00:39:33+00:00" + }, { "name": "graham-campbell/result-type", "version": "v1.1.4", @@ -1405,16 +1648,16 @@ }, { "name": "laravel/sanctum", - "version": "v4.3.1", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76" + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", - "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e", "shasum": "" }, "require": { @@ -1464,7 +1707,7 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2026-02-07T17:19:31+00:00" + "time": "2026-04-30T11:46:25+00:00" }, { "name": "laravel/serializable-closure", @@ -1527,6 +1770,78 @@ }, "time": "2026-02-20T19:59:49+00:00" }, + { + "name": "laravel/socialite", + "version": "v5.27.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/socialite.git", + "reference": "40e0757a75637c7b2dff05d3286b0d8fc25e5c0e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/socialite/zipball/40e0757a75637c7b2dff05d3286b0d8fc25e5c0e", + "reference": "40e0757a75637c7b2dff05d3286b0d8fc25e5c0e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "firebase/php-jwt": "^6.4|^7.0", + "guzzlehttp/guzzle": "^6.0|^7.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "league/oauth1-client": "^1.11", + "php": "^7.2|^8.0", + "phpseclib/phpseclib": "^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.12.23", + "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5|^12.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Socialite": "Laravel\\Socialite\\Facades\\Socialite" + }, + "providers": [ + "Laravel\\Socialite\\SocialiteServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Socialite\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.", + "homepage": "https://laravel.com", + "keywords": [ + "laravel", + "oauth" + ], + "support": { + "issues": "https://github.com/laravel/socialite/issues", + "source": "https://github.com/laravel/socialite" + }, + "time": "2026-04-24T14:05:47+00:00" + }, { "name": "laravel/tinker", "version": "v2.11.1", @@ -1970,6 +2285,82 @@ ], "time": "2024-09-21T08:32:55+00:00" }, + { + "name": "league/oauth1-client", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth1-client.git", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^6.0|^7.0", + "guzzlehttp/psr7": "^1.7|^2.0", + "php": ">=7.1||>=8.0" + }, + "require-dev": { + "ext-simplexml": "*", + "friendsofphp/php-cs-fixer": "^2.17", + "mockery/mockery": "^1.3.3", + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5||9.5" + }, + "suggest": { + "ext-simplexml": "For decoding XML-based responses." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev", + "dev-develop": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth1\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Corlett", + "email": "bencorlett@me.com", + "homepage": "http://www.webcomm.com.au", + "role": "Developer" + } + ], + "description": "OAuth 1.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "bitbucket", + "identity", + "idp", + "oauth", + "oauth1", + "single sign on", + "trello", + "tumblr", + "twitter" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth1-client/issues", + "source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0" + }, + "time": "2024-12-10T19:59:05+00:00" + }, { "name": "league/uri", "version": "7.8.0", @@ -2663,6 +3054,188 @@ ], "time": "2026-02-16T23:10:27+00:00" }, + { + "name": "orangehill/iseed", + "version": "v3.8.0", + "source": { + "type": "git", + "url": "https://github.com/orangehill/iseed.git", + "reference": "11ee04a43330b5241be9f7b97dcd9d44bc5745f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/orangehill/iseed/zipball/11ee04a43330b5241be9f7b97dcd9d44bc5745f2", + "reference": "11ee04a43330b5241be9f7b97dcd9d44bc5745f2", + "shasum": "" + }, + "require": { + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0.2" + }, + "require-dev": { + "illuminate/filesystem": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "laravel/framework": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.0.0", + "phpunit/phpunit": "^9.0|^10.0|^11.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Orangehill\\Iseed\\IseedServiceProvider" + ] + } + }, + "autoload": { + "psr-0": { + "Orangehill\\Iseed": "src/" + }, + "classmap": [ + "src/Orangehill/Iseed/Exceptions.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Tihomir Opacic", + "email": "tihomir.opacic@orangehilldev.com" + } + ], + "description": "Generate a new Laravel database seed file based on data from the existing database table.", + "keywords": [ + "artisan", + "generators", + "laravel", + "seed" + ], + "support": { + "issues": "https://github.com/orangehill/iseed/issues", + "source": "https://github.com/orangehill/iseed/tree/v3.8.0" + }, + "time": "2026-02-23T07:40:35+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -2738,6 +3311,165 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.53", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "511ddc8e352d5d1f1e33bea468b6f4ef48438cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/511ddc8e352d5d1f1e33bea468b6f4ef48438cf9", + "reference": "511ddc8e352d5d1f1e33bea468b6f4ef48438cf9", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.53" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-06-09T18:08:26+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, { "name": "psr/clock", "version": "1.0.0", diff --git a/config/app.php b/config/app.php index 423eed5..cb0ed2d 100644 --- a/config/app.php +++ b/config/app.php @@ -65,7 +65,7 @@ return [ | */ - 'timezone' => 'UTC', + 'timezone' => 'Asia/Taipei', /* |-------------------------------------------------------------------------- diff --git a/config/cors.php b/config/cors.php new file mode 100644 index 0000000..74618f0 --- /dev/null +++ b/config/cors.php @@ -0,0 +1,38 @@ + ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'], + + // 🟢 修正 2:允許前端傳入的 HTTP 方法(GET, POST, PUT, DELETE 都要開) + 'allowed_methods' => ['*'], + + // 🟢 修正 3:精準允許你的 Vue 前端開發網址(看你前端是在 5173 還是其他埠號) + // 你也可以直接寫 ['*'] 偷懶大開,但在本地端寫特定網址更安全: + 'allowed_origins' => ['http://localhost:5173', 'http://127.0.0.1:5173'], + + 'allowed_origins_patterns' => [], + + // 🟢 修正 4:上傳 FormData 時會帶有 Content-Type 等 Header,必須全開 + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 0, + + // 🟢 修正 5:非常關鍵!因為我們用了 Sanctum 驗證狀態,必須允許傳遞 Cookie/憑證 + 'supports_credentials' => true, + +]; \ No newline at end of file diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..cde73cf --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,87 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/config/services.php b/config/services.php index 6a90eb8..7af9ec1 100644 --- a/config/services.php +++ b/config/services.php @@ -28,6 +28,12 @@ return [ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ], + 'google' => [ + 'client_id' => env('GOOGLE_CLIENT_ID'), + 'client_secret' => env('GOOGLE_CLIENT_SECRET'), + 'redirect' => env('GOOGLE_REDIRECT_URI'), + ], + 'slack' => [ 'notifications' => [ 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), diff --git a/database/migrations/2026_03_07_133326_create_expenses_table.php b/database/migrations/2026_03_07_133326_create_expenses_table.php index df4f87b..ebbe5c6 100644 --- a/database/migrations/2026_03_07_133326_create_expenses_table.php +++ b/database/migrations/2026_03_07_133326_create_expenses_table.php @@ -14,6 +14,7 @@ return new class extends Migration Schema::create('expenses', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->unsignedBigInteger('account_id')->nullable()->after('user_id'); $table->foreignId('category_id')->nullable()->constrained()->onDelete('set null'); $table->decimal('amount', 10, 2); $table->string('note')->nullable(); diff --git a/database/migrations/2026_06_12_101228_add_google_id_to_users_table.php b/database/migrations/2026_06_12_101228_add_google_id_to_users_table.php new file mode 100644 index 0000000..4e67ddd --- /dev/null +++ b/database/migrations/2026_06_12_101228_add_google_id_to_users_table.php @@ -0,0 +1,29 @@ +string('google_id')->nullable()->unique()->after('id'); + $table->string('avatar')->nullable()->after('google_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + // + }); + } +}; diff --git a/database/migrations/2026_06_12_181511_create_personal_access_tokens_table.php b/database/migrations/2026_06_12_181511_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/database/migrations/2026_06_12_181511_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2026_06_12_191420_create_accounts_table.php b/database/migrations/2026_06_12_191420_create_accounts_table.php new file mode 100644 index 0000000..ca3ce5b --- /dev/null +++ b/database/migrations/2026_06_12_191420_create_accounts_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('type', 20); + $table->string('currency', 10)->default('TWD'); + $table->decimal('balance', 15, 2)->default(0); + $table->string('note')->nullable(); + $table->integer('billing_day')->nullable(); + $table->integer('payment_day')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('accounts'); + } +}; diff --git a/database/migrations/2026_06_12_191421_create_investment_holdings_table.php b/database/migrations/2026_06_12_191421_create_investment_holdings_table.php new file mode 100644 index 0000000..6f0d6e6 --- /dev/null +++ b/database/migrations/2026_06_12_191421_create_investment_holdings_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('account_id')->constrained()->cascadeOnDelete(); + $table->enum('type', ['stock', 'fund']); + $table->string('symbol'); + $table->string('name'); + $table->decimal('shares', 10, 2)->default(0); + $table->decimal('avg_cost', 10, 2)->default(0); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('investment_holdings'); + } +}; diff --git a/database/migrations/2026_06_12_191422_create_investment_transactions_table.php b/database/migrations/2026_06_12_191422_create_investment_transactions_table.php new file mode 100644 index 0000000..32a5cc5 --- /dev/null +++ b/database/migrations/2026_06_12_191422_create_investment_transactions_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('holding_id')->constrained('investment_holdings')->cascadeOnDelete(); + $table->enum('action', ['buy', 'sell']); + $table->decimal('shares', 10, 2)->nullable(); + $table->decimal('price', 10, 2)->nullable(); + $table->decimal('amount', 15, 2)->nullable(); + $table->decimal('nav', 10, 4)->nullable(); + $table->decimal('fee', 10, 2)->default(0); + $table->decimal('tax', 10, 2)->default(0); + $table->date('traded_at'); + $table->string('note')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('investment_transactions'); + } +}; diff --git a/database/migrations/2026_03_07_184750_add_external_id_to_expenses_table.php b/database/migrations/2026_06_14_123245_add_foreign_key_account_id_to_expenses_table.php similarity index 76% rename from database/migrations/2026_03_07_184750_add_external_id_to_expenses_table.php rename to database/migrations/2026_06_14_123245_add_foreign_key_account_id_to_expenses_table.php index f745187..e3ed237 100644 --- a/database/migrations/2026_03_07_184750_add_external_id_to_expenses_table.php +++ b/database/migrations/2026_06_14_123245_add_foreign_key_account_id_to_expenses_table.php @@ -12,14 +12,14 @@ return new class extends Migration public function up(): void { Schema::table('expenses', function (Blueprint $table) { - + $table->foreign('account_id')->references('id')->on('accounts')->nullOnDelete(); }); } public function down(): void { Schema::table('expenses', function (Blueprint $table) { - $table->dropColumn('external_id'); + $table->dropForeign(['account_id']); }); } }; diff --git a/database/migrations/2026_06_16_183426_securities.php b/database/migrations/2026_06_16_183426_securities.php new file mode 100644 index 0000000..1bae0c5 --- /dev/null +++ b/database/migrations/2026_06_16_183426_securities.php @@ -0,0 +1,39 @@ +id(); + + $table->string('code', 20); + $table->enum('type', ['stock', 'etf', 'fund']); + $table->string('source', 20); // twse, tpex, cnyes + $table->string('name'); // 中文名稱/簡稱 + + $table->decimal('latest_price', 15, 4)->nullable(); // 最新報價/淨值 + $table->timestamp('latest_price_at')->nullable(); // 最新報價/淨值時間 + $table->decimal('change_amount', 15, 4)->nullable(); // 漲跌 + $table->decimal('change_rate', 8, 4)->nullable(); // 漲跌幅(%) + + $table->jsonb('meta')->nullable(); // 基金專屬額外資訊(category、riskLevel、isinCode...) + + $table->timestamps(); + + $table->unique(['code', 'type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('securities'); + } +}; diff --git a/database/migrations/2026_06_16_183615_security_price_histories.php b/database/migrations/2026_06_16_183615_security_price_histories.php new file mode 100644 index 0000000..fb2f50b --- /dev/null +++ b/database/migrations/2026_06_16_183615_security_price_histories.php @@ -0,0 +1,36 @@ +id(); + + $table->foreignId('security_id') + ->constrained('securities') + ->cascadeOnDelete(); + + $table->date('price_date'); // 該筆報價/淨值所屬日期 + $table->decimal('price', 15, 4); // 收盤價/淨值 + $table->decimal('change_amount', 15, 4)->nullable(); // 漲跌 + $table->decimal('change_rate', 8, 4)->nullable(); // 漲跌幅(%) + + $table->timestamps(); + + $table->unique(['security_id', 'price_date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('security_price_histories'); + } +}; diff --git a/database/migrations/2026_06_21_083548_add_gmail_tokens_to_users_table.php b/database/migrations/2026_06_21_083548_add_gmail_tokens_to_users_table.php new file mode 100644 index 0000000..d005dbb --- /dev/null +++ b/database/migrations/2026_06_21_083548_add_gmail_tokens_to_users_table.php @@ -0,0 +1,30 @@ +text('google_access_token')->nullable(); + $table->text('google_refresh_token')->nullable(); + $table->timestamp('google_token_expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + // + }); + } +}; diff --git a/database/migrations/2026_03_07_182526_add_subcategory_and_type_to_expenses_table.php b/database/migrations/2026_06_22_003822_add_amortization_fields_to_expenses_table.php similarity index 54% rename from database/migrations/2026_03_07_182526_add_subcategory_and_type_to_expenses_table.php rename to database/migrations/2026_06_22_003822_add_amortization_fields_to_expenses_table.php index aebea6b..27e01df 100644 --- a/database/migrations/2026_03_07_182526_add_subcategory_and_type_to_expenses_table.php +++ b/database/migrations/2026_06_22_003822_add_amortization_fields_to_expenses_table.php @@ -12,15 +12,17 @@ return new class extends Migration public function up(): void { Schema::table('expenses', function (Blueprint $table) { - $table->foreignId('subcategory_id')->nullable()->after('category_id') - ->constrained('categories')->onDelete('set null'); + // 🟢 新增分攤控制欄位 + $table->boolean('is_amortized')->default(false); // 是否開啟分攤 + $table->date('period_start')->nullable(); // 費用歸屬開始日期 + $table->date('period_end')->nullable(); // 費用歸屬結束日期 }); } public function down(): void { Schema::table('expenses', function (Blueprint $table) { - $table->dropConstrainedForeignId('subcategory_id'); + $table->dropColumn(['is_amortized', 'period_start', 'period_end']); }); } }; diff --git a/database/migrations/2026_06_25_013906_create_account_rules_table.php b/database/migrations/2026_06_25_013906_create_account_rules_table.php new file mode 100644 index 0000000..c5fda3d --- /dev/null +++ b/database/migrations/2026_06_25_013906_create_account_rules_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('keyword'); // 🔍 關鍵字,例如: 'cathaybk' 或 '國泰' 或 '永豐' + $table->string('match_type'); // 💡 類型,例如: 'email_from' (比對寄件者) 或 'subject' (比對主旨) + $table->foreignId('account_id')->constrained('accounts')->onDelete('cascade'); // 🎯 要掛勾的資產帳戶ID + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('account_rules'); + } +}; diff --git a/database/seeders/AccountSeeder.php b/database/seeders/AccountSeeder.php new file mode 100644 index 0000000..b3b92b4 --- /dev/null +++ b/database/seeders/AccountSeeder.php @@ -0,0 +1,188 @@ +insert([ + [ + 'id' => 1, + 'user_id' => 1, + 'name' => '凱基銀行', + 'type' => 'bank', + 'currency' => 'TWD', + 'balance' => 123028.00, + 'note' => null, + 'billing_day' => null, + 'payment_day' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'id' => 2, + 'user_id' => 1, + 'name' => '國泰證券', + 'type' => 'stock', + 'currency' => 'TWD', + 'balance' => 0.00, + 'note' => null, + 'billing_day' => null, + 'payment_day' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'id' => 3, + 'user_id' => 1, + 'name' => '國泰銀行', + 'type' => 'bank', + 'currency' => 'TWD', + 'balance' => 19067.00, + 'note' => null, + 'billing_day' => null, + 'payment_day' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'id' => 4, + 'user_id' => 1, + 'name' => '永豐銀行', + 'type' => 'cash', + 'currency' => 'TWD', + 'balance' => 14138.00, + 'note' => null, + 'billing_day' => null, + 'payment_day' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'id' => 5, + 'user_id' => 1, + 'name' => '永豐證券', + 'type' => 'stock', + 'currency' => 'TWD', + 'balance' => 0.00, + 'note' => null, + 'billing_day' => null, + 'payment_day' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'id' => 11, + 'user_id' => 1, + 'name' => '國泰信用卡', + 'type' => 'credit_card', + 'currency' => 'TWD', + 'balance' => 0.00, + 'note' => null, + 'billing_day' => 17, + 'payment_day' => 2, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'id' => 12, + 'user_id' => 1, + 'name' => '麥當勞點點卡', + 'type' => 'wallet', + 'currency' => 'TWD', + 'balance' => 299.00, + 'note' => null, + 'billing_day' => null, + 'payment_day' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'id' => 13, + 'user_id' => 1, + 'name' => '國泰基金', + 'type' => 'fund', + 'currency' => 'TWD', + 'balance' => 0.00, + 'note' => null, + 'billing_day' => null, + 'payment_day' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'id' => 14, + 'user_id' => 1, + 'name' => '手機悠遊卡', + 'type' => 'wallet', + 'currency' => 'TWD', + 'balance' => 1.00, + 'note' => null, + 'billing_day' => null, + 'payment_day' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'id' => 15, + 'user_id' => 1, + 'name' => '玉山金融卡(悠遊卡)', + 'type' => 'wallet', + 'currency' => 'TWD', + 'balance' => 222.00, + 'note' => null, + 'billing_day' => null, + 'payment_day' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'id' => 16, + 'user_id' => 1, + 'name' => '凱基信用卡(悠遊卡)', + 'type' => 'wallet', + 'currency' => 'TWD', + 'balance' => 154.00, + 'note' => null, + 'billing_day' => null, + 'payment_day' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'id' => 17, + 'user_id' => 1, + 'name' => '東華學生證(悠遊卡)', + 'type' => 'wallet', + 'currency' => 'TWD', + 'balance' => 85.00, + 'note' => null, + 'billing_day' => null, + 'payment_day' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'id' => 18, + 'user_id' => 1, + 'name' => '安聯基金', + 'type' => 'fund', + 'currency' => 'TWD', + 'balance' => 0.00, + 'note' => null, + 'billing_day' => null, + 'payment_day' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + ]); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..d190ed2 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,11 +15,19 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); + \App\Models\User::updateOrCreate( + ['email' => 'henry194557@gmail.com'], // 換成你的 Google 登入 email + [ + 'name' => 'Henry', + 'google_id' => '', // 可以先隨便填 + 'password' => bcrypt('password'), + ] + ); - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + $this->call([ + AccountSeeder::class, + InvestmentHoldingSeeder::class, + InvestmentTransactionSeeder::class, ]); } } diff --git a/database/seeders/InvestmentHoldingSeeder.php b/database/seeders/InvestmentHoldingSeeder.php new file mode 100644 index 0000000..c0444d6 --- /dev/null +++ b/database/seeders/InvestmentHoldingSeeder.php @@ -0,0 +1,52 @@ +insert([ + [ + 'id' => 1, + 'account_id' => 2, + 'type' => 'stock', + 'symbol' => '0050', + 'name' => '元大台灣50', + 'shares' => 304.00, + 'avg_cost' => 76.27, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'id' => 2, + 'account_id' => 13, + 'type' => 'fund', + 'symbol' => '10350113', + 'name' => '國泰台灣高股息台幣月配', + 'shares' => 229.70, + 'avg_cost' => 43.53, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'id' => 3, + 'account_id' => 18, + 'type' => 'fund', + 'symbol' => 'A36004', + 'name' => '安聯台灣科技基金', + 'shares' => 51.11, + 'avg_cost' => 782.69, + 'created_at' => now(), + 'updated_at' => now() + ], + ]); + } +} diff --git a/database/seeders/InvestmentTransactionSeeder.php b/database/seeders/InvestmentTransactionSeeder.php new file mode 100644 index 0000000..251f25a --- /dev/null +++ b/database/seeders/InvestmentTransactionSeeder.php @@ -0,0 +1,301 @@ +insert([ + // 國泰基金 holding_id=2 + [ + 'holding_id' => 2, + 'action' => 'buy', + 'shares' => 35.70, + 'price' => 56.11, + 'amount' => null, + 'nav' => null, + 'fee' => 0, + 'tax' => 0, + 'traded_at' => '2026-06-05', + 'note' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'holding_id' => 2, + 'action' => 'buy', + 'shares' => 20.10, + 'price' => 49.74, + 'amount' => null, + 'nav' => null, + 'fee' => 0, + 'tax' => 0, + 'traded_at' => '2026-05-20', + 'note' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'holding_id' => 2, + 'action' => 'buy', + 'shares' => 29.00, + 'price' => 34.48, + 'amount' => null, + 'nav' => null, + 'fee' => 0, + 'tax' => 0, + 'traded_at' => '2026-02-05', + 'note' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'holding_id' => 2, + 'action' => 'buy', + 'shares' => 27.30, + 'price' => 36.59, + 'amount' => null, + 'nav' => null, + 'fee' => 0, + 'tax' => 0, + 'traded_at' => '2026-02-23', + 'note' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'holding_id' => 2, + 'action' => 'buy', + 'shares' => 27.10, + 'price' => 36.89, + 'amount' => null, + 'nav' => null, + 'fee' => 0, + 'tax' => 0, + 'traded_at' => '2026-03-05', + 'note' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'holding_id' => 2, + 'action' => 'buy', + 'shares' => 25.10, + 'price' => 39.79, + 'amount' => null, + 'nav' => null, + 'fee' => 0, + 'tax' => 0, + 'traded_at' => '2026-03-20', + 'note' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'holding_id' => 2, + 'action' => 'buy', + 'shares' => 25.40, + 'price' => 39.39, + 'amount' => null, + 'nav' => null, + 'fee' => 0, + 'tax' => 0, + 'traded_at' => '2026-04-07', + 'note' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'holding_id' => 2, + 'action' => 'buy', + 'shares' => 21.10, + 'price' => 47.36, + 'amount' => null, + 'nav' => null, + 'fee' => 0, + 'tax' => 0, + 'traded_at' => '2026-04-20', + 'note' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'holding_id' => 2, + 'action' => 'buy', + 'shares' => 18.90, + 'price' => 52.89, + 'amount' => null, + 'nav' => null, + 'fee' => 0, + 'tax' => 0, + 'traded_at' => '2026-05-05', + 'note' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + // 0050 holding_id=1 + [ + 'holding_id' => 1, + 'action' => 'buy', + 'shares' => 1.00, + 'price' => 70.05, + 'amount' => null, + 'nav' => null, + 'fee' => 1, + 'tax' => 0, + 'traded_at' => '2026-01-09', + 'note' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'holding_id' => 1, + 'action' => 'buy', + 'shares' => 50.00, + 'price' => 70.05, + 'amount' => null, + 'nav' => null, + 'fee' => 1, + 'tax' => 0, + 'traded_at' => '2026-01-09', + 'note' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'holding_id' => 1, + 'action' => 'buy', + 'shares' => 14.00, + 'price' => 104.49, + 'amount' => null, + 'nav' => null, + 'fee' => 1, + 'tax' => 0, + 'traded_at' => '2026-06-05', + 'note' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'holding_id' => 1, + 'action' => 'buy', + 'shares' => 14.00, + 'price' => 100.54, + 'amount' => null, + 'nav' => null, + 'fee' => 1, + 'tax' => 0, + 'traded_at' => '2026-05-25', + 'note' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'holding_id' => 1, + 'action' => 'buy', + 'shares' => 15.00, + 'price' => 94.39, + 'amount' => null, + 'nav' => null, + 'fee' => 1, + 'tax' => 0, + 'traded_at' => '2026-05-05', + 'note' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'holding_id' => 1, + 'action' => 'buy', + 'shares' => 17.00, + 'price' => 86.59, + 'amount' => null, + 'nav' => null, + 'fee' => 1, + 'tax' => 0, + 'traded_at' => '2026-04-23', + 'note' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'holding_id' => 1, + 'action' => 'buy', + 'shares' => 33.00, + 'price' => 75.14, + 'amount' => null, + 'nav' => null, + 'fee' => 1, + 'tax' => 0, + 'traded_at' => '2026-04-07', + 'note' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'holding_id' => 1, + 'action' => 'buy', + 'shares' => 20.00, + 'price' => 74.02, + 'amount' => null, + 'nav' => null, + 'fee' => 1, + 'tax' => 0, + 'traded_at' => '2026-03-23', + 'note' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'holding_id' => 1, + 'action' => 'buy', + 'shares' => 45.00, + 'price' => 77.53, + 'amount' => null, + 'nav' => null, + 'fee' => 1, + 'tax' => 0, + 'traded_at' => '2026-03-05', + 'note' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'holding_id' => 1, + 'action' => 'buy', + 'shares' => 48.00, + 'price' => 72.02, + 'amount' => null, + 'nav' => null, + 'fee' => 1, + 'tax' => 0, + 'traded_at' => '2026-02-05', + 'note' => '定期定額', + 'created_at' => now(), + 'updated_at' => now() + ], + [ + 'holding_id' => 1, + 'action' => 'buy', + 'shares' => 47.00, + 'price' => 62.54, + 'amount' => null, + 'nav' => null, + 'fee' => 1, + 'tax' => 0, + 'traded_at' => '2025-12-05', + 'note' => null, + 'created_at' => now(), + 'updated_at' => now() + ], + ]); + } +} diff --git a/resources/js/Components/DropdownLink.vue b/resources/js/Components/DropdownLink.vue index e5ab50b..49c6676 100644 --- a/resources/js/Components/DropdownLink.vue +++ b/resources/js/Components/DropdownLink.vue @@ -1,5 +1,5 @@ diff --git a/resources/js/Components/NavLink.vue b/resources/js/Components/NavLink.vue index e669803..b9cba50 100644 --- a/resources/js/Components/NavLink.vue +++ b/resources/js/Components/NavLink.vue @@ -1,6 +1,6 @@ diff --git a/resources/js/Components/ResponsiveNavLink.vue b/resources/js/Components/ResponsiveNavLink.vue index f6c4566..cc3c57b 100644 --- a/resources/js/Components/ResponsiveNavLink.vue +++ b/resources/js/Components/ResponsiveNavLink.vue @@ -1,6 +1,6 @@ diff --git a/resources/js/Layouts/AuthenticatedLayout.vue b/resources/js/Layouts/AuthenticatedLayout.vue index 966b0cb..7c99c80 100644 --- a/resources/js/Layouts/AuthenticatedLayout.vue +++ b/resources/js/Layouts/AuthenticatedLayout.vue @@ -5,66 +5,76 @@ import Dropdown from "@/Components/Dropdown.vue"; import DropdownLink from "@/Components/DropdownLink.vue"; import NavLink from "@/Components/NavLink.vue"; import ResponsiveNavLink from "@/Components/ResponsiveNavLink.vue"; -import { Link } from "@inertiajs/vue3"; +import { RouterLink, useRoute } from "vue-router"; // 引入 useRoute 來判斷當前頁面 const showingNavigationDropdown = ref(false); +const route = useRoute(); + +// 這裡先寫死使用者資訊,等之後 API 串接好再改用 Pinia 或 API 資料 +const user = { + name: "Henry", + email: "henry@example.com", +}; + +// 模擬登出功能 +const logout = () => { + console.log("執行登出邏輯"); + // 這裡之後要改為呼叫 API 並跳轉回登入頁 +};