diff --git a/app/Http/ApiControllers/Controller.php b/app/Http/ApiControllers/Controller.php
new file mode 100644
index 0000000..bc0e11d
--- /dev/null
+++ b/app/Http/ApiControllers/Controller.php
@@ -0,0 +1,8 @@
+setQuery(Holding::query());
+ $filters->setScopes(['myHoldings']);
+ $filters->setEagerRelations(['market_data', 'transactions']);
+ $filters->setSearchableColumns(['symbol']);
+
+ return HoldingResource::collection($filters->paginated());
+ }
+
+ public function show(Portfolio $portfolio, string $symbol)
+ {
+
+ Gate::authorize('readOnly', $portfolio);
+
+ $holding = $portfolio->holdings()->symbol($symbol)->firstOrFail();
+
+ return HoldingResource::make($holding);
+ }
+
+ public function update(HoldingRequest $request, Portfolio $portfolio, string $symbol)
+ {
+
+ Gate::authorize('fullAccess', $portfolio);
+
+ $holding = $portfolio->holdings()->symbol($symbol)->firstOrFail();
+
+ $holding->update($request->validated());
+
+ return HoldingResource::make($holding);
+ }
+}
\ No newline at end of file
diff --git a/app/Http/ApiControllers/MarketDataController.php b/app/Http/ApiControllers/MarketDataController.php
new file mode 100644
index 0000000..31346e9
--- /dev/null
+++ b/app/Http/ApiControllers/MarketDataController.php
@@ -0,0 +1,21 @@
+setQuery(Portfolio::query());
+ $filters->setScopes(['myPortfolios']);
+ $filters->setEagerRelations(['users', 'transactions', 'holdings']);
+ $filters->setFilterableRelations(['holdings.symbol']);
+ $filters->setSearchableColumns(['title', 'notes']);
+
+ return PortfolioResource::collection($filters->paginated());
+ }
+
+ public function store(PortfolioRequest $request)
+ {
+ $portfolio = Portfolio::create($request->validated());
+
+ return PortfolioResource::make($portfolio);
+ }
+
+ public function show(Portfolio $portfolio)
+ {
+ Gate::authorize('readOnly', $portfolio);
+
+ return PortfolioResource::make($portfolio);
+ }
+
+ public function update(PortfolioRequest $request, Portfolio $portfolio)
+ {
+ Gate::authorize('fullAccess', $portfolio);
+
+ $portfolio->update($request->validated());
+
+ return PortfolioResource::make($portfolio);
+ }
+
+ public function destroy(Portfolio $portfolio)
+ {
+ Gate::authorize('fullAccess', $portfolio);
+
+ $portfolio->delete();
+
+ return response()->noContent();
+ }
+}
\ No newline at end of file
diff --git a/app/Http/ApiControllers/TransactionController.php b/app/Http/ApiControllers/TransactionController.php
new file mode 100644
index 0000000..64924bb
--- /dev/null
+++ b/app/Http/ApiControllers/TransactionController.php
@@ -0,0 +1,58 @@
+setQuery(Transaction::query());
+ $filters->setScopes(['myTransactions']);
+ $filters->setSearchableColumns(['symbol']);
+
+ return TransactionResource::collection($filters->paginated());
+ }
+
+ public function store(TransactionRequest $request)
+ {
+ Gate::authorize('fullAccess', $request->portfolio);
+
+ $transaction = Transaction::create($request->validated());
+
+ return TransactionResource::make($transaction);
+ }
+
+ public function show(Transaction $transaction)
+ {
+ Gate::authorize('readOnly', $transaction->portfolio);
+
+ return TransactionResource::make($transaction);
+ }
+
+ public function update(TransactionRequest $request, Transaction $transaction)
+ {
+ Gate::authorize('fullAccess', $transaction->portfolio);
+
+ $transaction->update($request->validated());
+
+ return TransactionResource::make($transaction);
+ }
+
+ public function destroy(Transaction $transaction)
+ {
+ Gate::authorize('fullAccess', $transaction->portfolio);
+
+ $transaction->delete();
+
+ return response()->noContent();
+ }
+}
\ No newline at end of file
diff --git a/app/Http/ApiControllers/UserController.php b/app/Http/ApiControllers/UserController.php
new file mode 100644
index 0000000..468ca52
--- /dev/null
+++ b/app/Http/ApiControllers/UserController.php
@@ -0,0 +1,15 @@
+user());
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/ConnectedAccountController.php b/app/Http/Controllers/ConnectedAccountController.php
index 8333b01..0551b89 100644
--- a/app/Http/Controllers/ConnectedAccountController.php
+++ b/app/Http/Controllers/ConnectedAccountController.php
@@ -6,7 +6,6 @@
use App\Models\User;
use App\Models\ConnectedAccount;
use Illuminate\Support\MessageBag;
-use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Laravel\Socialite\Facades\Socialite;
diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php
index 8677cd5..71116b2 100644
--- a/app/Http/Controllers/Controller.php
+++ b/app/Http/Controllers/Controller.php
@@ -1,8 +1,8 @@
user()->cannot('readOnly', $portfolio)) {
- abort(403);
- }
+ Gate::authorize('readOnly', $portfolio);
$portfolio->load(['transactions', 'holdings']);
diff --git a/app/Http/Requests/FormRequest.php b/app/Http/Requests/FormRequest.php
new file mode 100644
index 0000000..d0f8334
--- /dev/null
+++ b/app/Http/Requests/FormRequest.php
@@ -0,0 +1,14 @@
+request->get($key) ?? $this->{$model}?->{$key};
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Requests/HoldingRequest.php b/app/Http/Requests/HoldingRequest.php
new file mode 100644
index 0000000..a75d82d
--- /dev/null
+++ b/app/Http/Requests/HoldingRequest.php
@@ -0,0 +1,24 @@
+|string>
+ */
+ public function rules(): array
+ {
+
+ $rules = [
+ 'reinvest_dividends' => ['sometimes', 'boolean']
+ ];
+
+ return $rules;
+ }
+}
diff --git a/app/Http/Requests/PortfolioRequest.php b/app/Http/Requests/PortfolioRequest.php
new file mode 100644
index 0000000..ab94472
--- /dev/null
+++ b/app/Http/Requests/PortfolioRequest.php
@@ -0,0 +1,30 @@
+|string>
+ */
+ public function rules(): array
+ {
+
+ $rules = [
+ 'title' => ['required', 'string', 'min:5', 'max:255'],
+ 'notes' => ['sometimes', 'nullable', 'string'],
+ 'wishlist' => ['sometimes', 'nullable', 'boolean'],
+ ];
+
+ if (!is_null($this->portfolio)) {
+ $rules['title'][0] = 'sometimes';
+ }
+
+ return $rules;
+ }
+}
diff --git a/app/Http/Requests/TransactionRequest.php b/app/Http/Requests/TransactionRequest.php
new file mode 100644
index 0000000..c0412d3
--- /dev/null
+++ b/app/Http/Requests/TransactionRequest.php
@@ -0,0 +1,65 @@
+|string>
+ */
+ public function rules(): array
+ {
+ $this->portfolio = Portfolio::findOrFail($this->requestOrModelValue('portfolio_id', 'transaction'));
+
+ $rules = [
+ 'portfolio_id' => [], // validated by findOrFail() above
+ 'symbol' => ['required', 'string', new SymbolValidationRule],
+ 'transaction_type' => ['required', 'string', 'in:BUY,SELL'],
+ 'date' => ['required', 'date_format:Y-m-d', 'before_or_equal:' . now()->format('Y-m-d')],
+ 'quantity' => [
+ 'required',
+ 'numeric',
+ 'min:0',
+ new QuantityValidationRule(
+ $this->portfolio,
+ $this->requestOrModelValue('symbol', 'transaction'),
+ $this->requestOrModelValue('transaction_type', 'transaction'),
+ $this->requestOrModelValue('date', 'transaction')
+ )
+ ],
+ 'cost_basis' => ['exclude_if:transaction_type,SELL', 'min:0', 'numeric'],
+ 'sale_price' => ['exclude_if:transaction_type,BUY', 'min:0', 'numeric'],
+ ];
+
+ if (!is_null($this->transaction)) {
+ $rules['symbol'][0] = 'sometimes';
+ $rules['transaction_type'][0] = 'sometimes';
+ $rules['date'][0] = 'sometimes';
+ $rules['quantity'][0] = 'sometimes';
+
+ if (
+ $this->requestOrModelValue('transaction_type', 'transaction') == 'SELL'
+ && $this->requestOrModelValue('sale_price', 'transaction') == null
+ ) {
+ $rules['sale_price'][0] = 'required';
+ } elseif (
+ $this->requestOrModelValue('transaction_type', 'transaction') == 'BUY'
+ && $this->requestOrModelValue('cost_basis', 'transaction') == null
+ ) {
+ $rules['cost_basis'][0] = 'required';
+ }
+ }
+
+ return $rules;
+ }
+}
diff --git a/app/Http/Resources/HoldingResource.php b/app/Http/Resources/HoldingResource.php
new file mode 100644
index 0000000..57cd293
--- /dev/null
+++ b/app/Http/Resources/HoldingResource.php
@@ -0,0 +1,37 @@
+
+ */
+ public function toArray(Request $request): array
+ {
+ return [
+ 'id' => $this->id,
+ 'portfolio_id' => $this->portfolio_id,
+ 'symbol' => $this->symbol,
+ 'quantity' => $this->quantity,
+ 'reinvest_dividends' => $this->reinvest_dividends,
+ 'average_cost_basis' => $this->average_cost_basis,
+ 'total_cost_basis' => $this->total_cost_basis,
+ 'realized_gain_dollars' => $this->realized_gain_dollars,
+ 'dividends_earned' => $this->dividends_earned,
+ 'splits_synced_at' => $this->splits_synced_at,
+ 'total_market_value' => $this->total_market_value,
+ 'market_gain_dollars' => $this->market_gain_dollars,
+ 'market_gain_percent' => $this->market_gain_percent,
+ 'created_at' => $this->created_at,
+ 'updated_at' => $this->updated_at
+ ];
+ }
+}
diff --git a/app/Http/Resources/MarketDataResource.php b/app/Http/Resources/MarketDataResource.php
new file mode 100644
index 0000000..b9744aa
--- /dev/null
+++ b/app/Http/Resources/MarketDataResource.php
@@ -0,0 +1,36 @@
+
+ */
+ public function toArray(Request $request): array
+ {
+ return [
+ 'symbol' => $this->symbol,
+ 'name' => $this->name,
+ 'market_value' => $this->market_value,
+ 'fifty_two_week_low' => $this->fifty_two_week_low,
+ 'fifty_two_week_high' => $this->fifty_two_week_high,
+ 'last_dividend_date' => $this->last_dividend_date,
+ 'last_dividend_amount' => $this->last_dividend_amount,
+ 'dividend_yield' => $this->dividend_yield,
+ 'market_cap' => $this->market_cap,
+ 'trailing_pe' => $this->trailing_pe,
+ 'forward_pe' => $this->forward_pe,
+ 'book_value' => $this->book_value,
+ 'created_at' => $this->created_at,
+ 'updated_at' => $this->updated_at,
+ ];
+ }
+}
diff --git a/app/Http/Resources/PortfolioResource.php b/app/Http/Resources/PortfolioResource.php
new file mode 100644
index 0000000..19d3b8a
--- /dev/null
+++ b/app/Http/Resources/PortfolioResource.php
@@ -0,0 +1,31 @@
+
+ */
+ public function toArray(Request $request): array
+ {
+ return [
+ 'id' => $this->id,
+ 'title' => $this->title,
+ 'notes' => $this->notes,
+ 'wishlist' => $this->wishlist,
+ 'owner' => UserResource::make($this->owner),
+ 'transactions' => TransactionResource::collection($this->whenLoaded('transactions')),
+ 'holdings' => HoldingResource::collection($this->whenLoaded('holdings')),
+ 'created_at' => $this->created_at,
+ 'updated_at' => $this->updated_at,
+ ];
+ }
+}
diff --git a/app/Http/Resources/TransactionResource.php b/app/Http/Resources/TransactionResource.php
new file mode 100644
index 0000000..49ec5ad
--- /dev/null
+++ b/app/Http/Resources/TransactionResource.php
@@ -0,0 +1,34 @@
+
+ */
+ public function toArray(Request $request): array
+ {
+ return [
+ 'id' => $this->id,
+ 'symbol' => $this->symbol,
+ 'portfolio_id' => $this->portfolio_id,
+ 'transaction_type' => $this->transaction_type,
+ 'quantity' => $this->quantity,
+ 'cost_basis' => $this->cost_basis,
+ 'sale_price' => $this->sale_price,
+ 'split' => $this->split,
+ 'reinvested_dividend' => $this->reinvested_dividend,
+ 'date' => $this->date,
+ 'created_at' => $this->created_at,
+ 'updated_at' => $this->updated_at,
+ ];
+ }
+}
diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php
new file mode 100644
index 0000000..41eeff5
--- /dev/null
+++ b/app/Http/Resources/UserResource.php
@@ -0,0 +1,29 @@
+
+ */
+ public function toArray(Request $request): array
+ {
+
+ return [
+ 'id' => $this->id,
+ 'name' => $this->name,
+ 'email' => $this->email,
+ 'profile_photo_url' => $this->profile_photo_url,
+ 'created_at' => $this->created_at,
+ 'updated_at' => $this->updated_at,
+ ];
+ }
+}
diff --git a/app/Models/Holding.php b/app/Models/Holding.php
index b8c7d87..6cb4d43 100644
--- a/app/Models/Holding.php
+++ b/app/Models/Holding.php
@@ -36,11 +36,6 @@ class Holding extends Model
'reinvest_dividends' => 'boolean'
];
- protected $attributes = [
- 'realized_gain_dollars' => 0,
- 'dividends_earned' => 0,
- ];
-
/**
* Market data for holding
*
diff --git a/app/Models/Portfolio.php b/app/Models/Portfolio.php
index 0660745..2195fdf 100644
--- a/app/Models/Portfolio.php
+++ b/app/Models/Portfolio.php
@@ -5,11 +5,13 @@
use App\Models\AiChat;
use Carbon\CarbonPeriod;
use Illuminate\Support\Arr;
+use Illuminate\Support\Str;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Eloquent\Model;
use App\Interfaces\MarketData\MarketDataInterface;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
+use App\Notifications\InvitedOnboardingNotification;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Portfolio extends Model
@@ -129,6 +131,7 @@ public static function ensurePortfolioHasOwner(self $portfolio)
// save
$portfolio->users()->sync($owner);
+ static::$owner_id = null;
}
}
@@ -253,4 +256,44 @@ public function getFormattedHoldings()
}
return $formattedHoldings;
}
+
+ /**
+ * Share a portfolio with a user
+ *
+ * @param string $email
+ * @param boolean $fullAccess
+ * @return void
+ */
+ public function share(string $email, bool $fullAccess = false): void
+ {
+ $user = User::firstOrCreate([
+ 'email' => $email
+ ], [
+ 'name' => Str::title(Str::before($email, '@'))
+ ]);
+
+ $permissions[$user->id] = [
+ 'full_access' => $fullAccess
+ ];
+
+ $sync = $this->users()->syncWithoutDetaching($permissions);
+
+ if (!empty($sync['attached'])) {
+
+ foreach($sync['attached'] as $newUserId) {
+ User::find($newUserId)->notify(new InvitedOnboardingNotification($this, auth()->user()));
+ };
+ }
+ }
+
+ /**
+ * Un-share a portfolio
+ *
+ * @param string $userId
+ * @return void
+ */
+ public function unShare(string $userId): void
+ {
+ $this->users()->detach($userId);
+ }
}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 0450ca0..39f79b0 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -3,6 +3,7 @@
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
+use Illuminate\Http\Resources\Json\JsonResource;
class AppServiceProvider extends ServiceProvider
{
@@ -22,6 +23,6 @@ public function register(): void
*/
public function boot(): void
{
- //
+ JsonResource::withoutWrapping();
}
}
diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php
index f931bf3..f7a2ed0 100644
--- a/app/Providers/JetstreamServiceProvider.php
+++ b/app/Providers/JetstreamServiceProvider.php
@@ -43,13 +43,8 @@ public function boot(): void
*/
protected function configurePermissions(): void
{
- Jetstream::defaultApiTokenPermissions(['read']);
-
- Jetstream::permissions([
- 'create',
- 'read',
- 'update',
- 'delete',
- ]);
+ Jetstream::defaultApiTokenPermissions([]);
+
+ Jetstream::permissions([]);
}
}
diff --git a/app/Rules/QuantityValidationRule.php b/app/Rules/QuantityValidationRule.php
index 0dea105..74027bc 100644
--- a/app/Rules/QuantityValidationRule.php
+++ b/app/Rules/QuantityValidationRule.php
@@ -13,10 +13,10 @@ class QuantityValidationRule implements ValidationRule
* @return void
*/
public function __construct(
- protected Portfolio $portfolio,
- protected string $symbol,
- protected string $transactionType,
- protected string $date
+ protected ?Portfolio $portfolio,
+ protected ?string $symbol,
+ protected ?string $transactionType,
+ protected ?string $date
) {
$this->portfolio = $portfolio;
$this->symbol = $symbol;
@@ -34,6 +34,11 @@ public function __construct(
*/
public function validate(string $attribute, mixed $value, \Closure $fail): void
{
+ if (is_null($this->portfolio) || is_null($this->symbol) || is_null($this->transactionType) || is_null($this->date)) {
+ //
+ $fail(__('The quantity must not be greater than the available quantity.'));
+ }
+
if ($this->transactionType == 'SELL') {
$purchase_qty = $this->portfolio->transactions()
diff --git a/composer.json b/composer.json
index f8eb719..4995b6e 100644
--- a/composer.json
+++ b/composer.json
@@ -24,7 +24,8 @@
"robsontenorio/mary": "^1.35",
"scheb/yahoo-finance-api": "^4.11",
"staudenmeir/eloquent-has-many-deep": "^1.20",
- "tschucki/alphavantage-laravel": "^0.0"
+ "tschucki/alphavantage-laravel": "^0.0",
+ "hackeresq/filter-models": "dev-main"
},
"require-dev": {
"fakerphp/faker": "^1.23",
@@ -37,6 +38,12 @@
"repositories": [
{
"type": "vcs",
+ "no-api": true,
+ "url": "https://github.com/hackeresq/filter-models"
+ },
+ {
+ "type": "vcs",
+ "no-api": true,
"url": "https://github.com/investbrainapp/finnhub-php"
}
],
diff --git a/composer.lock b/composer.lock
index 1192fc7..4067694 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": "d1b7456f149ebd4a89f5666f931c03fd",
+ "content-hash": "7b8a88dbb7545ee8284282a6dda2ab3f",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -62,16 +62,16 @@
},
{
"name": "aws/aws-sdk-php",
- "version": "3.338.2",
+ "version": "3.339.0",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
- "reference": "7a52364e053d74363f9976dfb4473bace5b7790e"
+ "reference": "41bcd4a555649d276c8fbc0bc1738e59fda2221d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7a52364e053d74363f9976dfb4473bace5b7790e",
- "reference": "7a52364e053d74363f9976dfb4473bace5b7790e",
+ "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/41bcd4a555649d276c8fbc0bc1738e59fda2221d",
+ "reference": "41bcd4a555649d276c8fbc0bc1738e59fda2221d",
"shasum": ""
},
"require": {
@@ -154,9 +154,9 @@
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
- "source": "https://github.com/aws/aws-sdk-php/tree/3.338.2"
+ "source": "https://github.com/aws/aws-sdk-php/tree/3.339.0"
},
- "time": "2025-01-24T19:09:22+00:00"
+ "time": "2025-01-27T19:25:50+00:00"
},
{
"name": "bacon/bacon-qr-code",
@@ -491,6 +491,85 @@
],
"time": "2024-02-09T16:56:22+00:00"
},
+ {
+ "name": "composer/pcre",
+ "version": "3.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/pcre.git",
+ "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+ "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan": "<1.11.10"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.12 || ^2",
+ "phpstan/phpstan-strict-rules": "^1 || ^2",
+ "phpunit/phpunit": "^8 || ^9"
+ },
+ "type": "library",
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ },
+ "branch-alias": {
+ "dev-main": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Composer\\Pcre\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "http://seld.be"
+ }
+ ],
+ "description": "PCRE wrapping library that offers type-safe preg_* replacements.",
+ "keywords": [
+ "PCRE",
+ "preg",
+ "regex",
+ "regular expression"
+ ],
+ "support": {
+ "issues": "https://github.com/composer/pcre/issues",
+ "source": "https://github.com/composer/pcre/tree/3.3.2"
+ },
+ "funding": [
+ {
+ "url": "https://packagist.com",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/composer",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-11-12T16:29:46+00:00"
+ },
{
"name": "composer/semver",
"version": "3.4.3",
@@ -1063,7 +1142,7 @@
"version": "dev-master",
"source": {
"type": "git",
- "url": "https://github.com/investbrainapp/finnhub-php.git",
+ "url": "https://github.com/investbrainapp/finnhub-php",
"reference": "1f1b35a0c0a6a68f9a791e3ac5cdb6f44ff69d80"
},
"dist": {
@@ -1115,9 +1194,6 @@
"rest",
"sdk"
],
- "support": {
- "source": "https://github.com/investbrainapp/finnhub-php/tree/master"
- },
"time": "2024-09-13T01:29:18+00:00"
},
{
@@ -1727,6 +1803,41 @@
],
"time": "2023-12-03T19:50:20+00:00"
},
+ {
+ "name": "hackeresq/filter-models",
+ "version": "dev-main",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/hackeresq/filter-models",
+ "reference": "565537120ea01bd73f49051ecde90d05e4127c6b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/hackeresq/filter-models/zipball/565537120ea01bd73f49051ecde90d05e4127c6b",
+ "reference": "565537120ea01bd73f49051ecde90d05e4127c6b",
+ "shasum": ""
+ },
+ "require": {
+ "laravel/framework": "^11.9",
+ "php": "^8.2"
+ },
+ "default-branch": true,
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "HackerEsq\\FilterModels\\FilterModelsServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "HackerEsq\\FilterModels\\": "src/"
+ }
+ },
+ "description": "Simple package to filter your Laravel models with query parameters",
+ "time": "2025-01-25T04:44:58+00:00"
+ },
{
"name": "jfcherng/php-color-output",
"version": "3.0.0",
@@ -3546,31 +3657,32 @@
},
{
"name": "maennchen/zipstream-php",
- "version": "3.1.1",
+ "version": "3.1.2",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
- "reference": "6187e9cc4493da94b9b63eb2315821552015fca9"
+ "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/6187e9cc4493da94b9b63eb2315821552015fca9",
- "reference": "6187e9cc4493da94b9b63eb2315821552015fca9",
+ "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f",
+ "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
- "php-64bit": "^8.1"
+ "php-64bit": "^8.2"
},
"require-dev": {
+ "brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.16",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
- "phpunit/phpunit": "^10.0",
- "vimeo/psalm": "^5.0"
+ "phpunit/phpunit": "^11.0",
+ "vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
@@ -3611,7 +3723,7 @@
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
- "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.1"
+ "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2"
},
"funding": [
{
@@ -3619,7 +3731,7 @@
"type": "github"
}
],
- "time": "2024-10-10T12:33:01+00:00"
+ "time": "2025-01-27T12:07:53+00:00"
},
{
"name": "markbaker/complex",
@@ -4706,19 +4818,20 @@
},
{
"name": "phpoffice/phpspreadsheet",
- "version": "1.29.8",
+ "version": "1.29.9",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
- "reference": "089ffdfc04b5fcf25a3503d81a4e589f247e20e3"
+ "reference": "ffb47b639649fc9c8a6fa67977a27b756592ed85"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/089ffdfc04b5fcf25a3503d81a4e589f247e20e3",
- "reference": "089ffdfc04b5fcf25a3503d81a4e589f247e20e3",
+ "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/ffb47b639649fc9c8a6fa67977a27b756592ed85",
+ "reference": "ffb47b639649fc9c8a6fa67977a27b756592ed85",
"shasum": ""
},
"require": {
+ "composer/pcre": "^3.3",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
@@ -4805,9 +4918,9 @@
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
- "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.29.8"
+ "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.29.9"
},
- "time": "2025-01-12T03:16:27+00:00"
+ "time": "2025-01-26T04:55:00+00:00"
},
{
"name": "phpoption/phpoption",
@@ -10963,15 +11076,13 @@
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {
- "finnhub/client": 20
+ "finnhub/client": 20,
+ "hackeresq/filter-models": 20
},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
- "php": "^8.3",
- "ext-gd": "*",
- "ext-mbstring": "*",
- "ext-zip": "*"
+ "php": "^8.2"
},
"platform-dev": [],
"plugin-api-version": "2.3.0"
diff --git a/config/jetstream.php b/config/jetstream.php
index 7b9d0f6..b1f2c73 100644
--- a/config/jetstream.php
+++ b/config/jetstream.php
@@ -60,7 +60,7 @@
'features' => [
Features::termsAndPrivacyPolicy(),
Features::profilePhotos(),
- // Features::api(),
+ Features::api(),
// Features::teams(['invitations' => true]),
Features::accountDeletion(),
],
diff --git a/database/factories/PortfolioFactory.php b/database/factories/PortfolioFactory.php
index 85ac7bd..175631d 100644
--- a/database/factories/PortfolioFactory.php
+++ b/database/factories/PortfolioFactory.php
@@ -17,7 +17,7 @@ class PortfolioFactory extends Factory
public function definition(): array
{
return [
- 'title' => $this->faker->word,
+ 'title' => $this->faker->words(4, true),
'created_at' => now(),
'updated_at' => now(),
];
diff --git a/database/migrations/2021_02_25_041257_create_transactions_table.php b/database/migrations/2021_02_25_041257_create_transactions_table.php
index 7ed2184..f5a7497 100644
--- a/database/migrations/2021_02_25_041257_create_transactions_table.php
+++ b/database/migrations/2021_02_25_041257_create_transactions_table.php
@@ -23,7 +23,7 @@ public function up()
$table->float('quantity', 12, 4);
$table->float('cost_basis', 12, 4);
$table->float('sale_price', 12, 4)->nullable();
- $table->boolean('split')->nullable();
+ $table->boolean('split')->default(false);
$table->date('date');
$table->timestamps();
});
diff --git a/database/migrations/2021_09_06_014744_create_holdings_table.php b/database/migrations/2021_09_06_014744_create_holdings_table.php
index c4a9692..e43f690 100644
--- a/database/migrations/2021_09_06_014744_create_holdings_table.php
+++ b/database/migrations/2021_09_06_014744_create_holdings_table.php
@@ -20,10 +20,10 @@ public function up()
$table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade');
$table->foreignIdFor(MarketData::class, 'symbol');
$table->float('quantity', 12, 4);
- $table->float('average_cost_basis', 12, 4);
- $table->float('total_cost_basis', 12, 4)->nullable();
- $table->float('realized_gain_dollars', 12, 4)->nullable();
- $table->float('dividends_earned', 12, 4)->nullable();
+ $table->float('average_cost_basis', 12, 4)->default(0);
+ $table->float('total_cost_basis', 12, 4)->default(0);
+ $table->float('realized_gain_dollars', 12, 4)->default(0);
+ $table->float('dividends_earned', 12, 4)->default(0);
$table->timestamp('splits_synced_at')->nullable();
$table->timestamps();
});
diff --git a/database/migrations/2024_10_18_000001_add_reinvestment_columns.php b/database/migrations/2024_10_18_000001_add_reinvestment_columns.php
index 28defcc..da2293f 100644
--- a/database/migrations/2024_10_18_000001_add_reinvestment_columns.php
+++ b/database/migrations/2024_10_18_000001_add_reinvestment_columns.php
@@ -12,11 +12,11 @@
public function up(): void
{
Schema::table('holdings', function (Blueprint $table) {
- $table->boolean('reinvest_dividends')->nullable()->after('quantity');
+ $table->boolean('reinvest_dividends')->default(false)->after('quantity');
});
Schema::table('transactions', function (Blueprint $table) {
- $table->boolean('reinvested_dividend')->nullable()->after('split');
+ $table->boolean('reinvested_dividend')->default(false)->after('split');
});
}
diff --git a/lang/en.json b/lang/en.json
index f40abc4..c2dead1 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -70,7 +70,7 @@
"API Token": "API Token",
"Please copy your new API token. For your security, it won\\'t be shown again.": "Please copy your new API token. For your security, it won\\'t be shown again.",
"API Token Permissions": "API Token Permissions",
- "API tokens allow third-party services to authenticate with our application on your behalf.": "API tokens allow third-party services to authenticate with our application on your behalf.",
+ "API tokens allow third-party services to authenticate with Investbrain on your behalf.": "API tokens allow third-party services to authenticate with Investbrain on your behalf.",
"Delete API Token": "Delete API Token",
"Are you sure you would like to delete this API token?": "Are you sure you would like to delete this API token?",
"This is a secure area of the application. Please confirm your password before continuing.": "This is a secure area of the application. Please confirm your password before continuing.",
diff --git a/lang/es.json b/lang/es.json
index 13a81b2..04ef548 100644
--- a/lang/es.json
+++ b/lang/es.json
@@ -70,7 +70,7 @@
"API Token": "Token API",
"Please copy your new API token. For your security, it won't be shown again.": "Por favor, copia tu nuevo token API. Por seguridad, no se mostrará nuevamente.",
"API Token Permissions": "Permisos del Token API",
- "API tokens allow third-party services to authenticate with our application on your behalf.": "Los tokens API permiten que servicios de terceros se autentiquen con nuestra aplicación en tu nombre.",
+ "API tokens allow third-party services to authenticate with Investbrain on your behalf.": "Los tokens API permiten que servicios de terceros se autentiquen con Investbrain en tu nombre.",
"Delete API Token": "Eliminar Token API",
"Are you sure you would like to delete this API token?": "¿Estás seguro de que deseas eliminar este token API?",
"This is a secure area of the application. Please confirm your password before continuing.": "Esta es un área segura de la aplicación. Por favor, confirma tu contraseña antes de continuar.",
diff --git a/resources/views/api/api-token-manager.blade.php b/resources/views/api/api-token-manager.blade.php
index d161ca0..aad3ece 100644
--- a/resources/views/api/api-token-manager.blade.php
+++ b/resources/views/api/api-token-manager.blade.php
@@ -6,7 +6,7 @@
- {{ __('API tokens allow third-party services to authenticate with our application on your behalf.') }}
+ {{ __('API tokens allow third-party services to authenticate with Investbrain on your behalf.') }}
diff --git a/resources/views/livewire/share-portfolio-form.blade.php b/resources/views/livewire/share-portfolio-form.blade.php
index f80b021..6f2a55c 100644
--- a/resources/views/livewire/share-portfolio-form.blade.php
+++ b/resources/views/livewire/share-portfolio-form.blade.php
@@ -7,7 +7,6 @@
use Livewire\Volt\Component;
use Illuminate\Support\Collection;
use Mary\Traits\Toast;
-use App\Notifications\InvitedOnboardingNotification;
new class extends Component {
@@ -75,7 +74,7 @@ public function deleteUser(string $userId, bool $confirmed = false)
unset($this->permissions[$userId]);
- $this->portfolio->users()->sync($this->permissions);
+ $this->portfolio->unShare($userId);
$this->portfolio->refresh();
@@ -92,24 +91,7 @@ public function addUser()
$this->validate();
- $user = User::firstOrCreate([
- 'email' => $this->emailAddress
- ], [
- 'name' => Str::title(Str::before($this->emailAddress, '@'))
- ]);
-
- $this->permissions[$user->id] = [
- 'full_access' => $this->fullAccess
- ];
-
- $sync = $this->portfolio->users()->sync($this->permissions);
-
- if (!empty($sync['attached'])) {
-
- foreach($sync['attached'] as $newUserId) {
- User::find($newUserId)->notify(new InvitedOnboardingNotification($this->portfolio, auth()->user()));
- };
- }
+ $this->portfolio->share($this->emailAddress, $this->fullAccess);
$this->success(__('Shared portfolio with user'));
$this->portfolio->refresh();
diff --git a/routes/api.php b/routes/api.php
index ccc387f..32395b5 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -1,8 +1,28 @@
user();
-})->middleware('auth:sanctum');
+Route::middleware(['auth:sanctum'])->name('api.')->group(function () {
+
+ // user
+ Route::get('/me', [UserController::class, 'me'])->name('me');
+
+ // portfolio
+ Route::apiResource('/portfolio', PortfolioController::class);
+
+ // transaction
+ Route::apiResource('/transaction', TransactionController::class);
+
+ // holding
+ Route::get('/holding', [HoldingController::class, 'index'])->name('holding.index');
+ Route::get('/holding/{portfolio}/{symbol}', [HoldingController::class, 'show'])->name('holding.show')->scopeBindings();
+ Route::put('/holding/{portfolio}/{symbol}', [HoldingController::class, 'update'])->name('holding.update')->scopeBindings();
+
+ // market data
+ Route::get('/market-data/{symbol}', [MarketDataController::class, 'show'])->name('market-data.show');
+});
\ No newline at end of file
diff --git a/routes/web.php b/routes/web.php
index d961542..0c60cd7 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -5,8 +5,8 @@
use App\Http\Controllers\HoldingController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\PortfolioController;
-use App\Http\Controllers\ConnectedAccountController;
use App\Http\Controllers\TransactionController;
+use App\Http\Controllers\ConnectedAccountController;
use App\Http\Controllers\InvitedOnboardingController;
use Laravel\Jetstream\Http\Controllers\Livewire\PrivacyPolicyController;
use Laravel\Jetstream\Http\Controllers\Livewire\TermsOfServiceController;
diff --git a/storage/app/.gitignore b/storage/app/.gitignore
index 5d91b1f..8f4803c 100644
--- a/storage/app/.gitignore
+++ b/storage/app/.gitignore
@@ -1,4 +1,3 @@
*
!public/
!.gitignore
-!market_data_seed.csv
diff --git a/tests/Api/HoldingsTest.php b/tests/Api/HoldingsTest.php
new file mode 100644
index 0000000..ef1a24b
--- /dev/null
+++ b/tests/Api/HoldingsTest.php
@@ -0,0 +1,117 @@
+user = User::factory()->create();
+ }
+
+ public function test_can_list_holdings()
+ {
+ $this->actingAs($this->user);
+
+ Transaction::factory(10)->create();
+
+ $this->actingAs($this->user)
+ ->getJson(route('api.holding.index', ['page' => 1, 'itemsPerPage' => 5]))
+ ->assertOk()
+ ->assertJsonStructure([
+ 'data' => [['id', 'symbol', 'portfolio_id', 'total_market_value', 'dividends_earned']],
+ 'meta' => ['current_page', 'last_page', 'total'],
+ 'links' => ['first', 'last', 'prev', 'next']
+ ]);
+ }
+
+ public function test_cannot_list_others_holdings()
+ {
+ // create transactions with existing user
+ $this->actingAs($this->user);
+ Transaction::factory(10)->create();
+
+ // Create a new user
+ $this->actingAs($user = User::factory()->create());
+ Transaction::factory(1)->create();
+ $this->actingAs($user)
+ ->getJson(route('api.holding.index', ['page' => 1, 'itemsPerPage' => 5]))
+ ->assertOk()
+ ->assertJsonCount(1, 'data');
+ }
+
+ public function test_cannot_access_holdings_when_unauthenticated()
+ {
+ $this->getJson(route('api.holding.index'))->assertUnauthorized();
+ }
+
+ public function test_can_show_a_holding()
+ {
+ $this->actingAs($this->user);
+
+ $transaction = Transaction::factory()->create();
+
+ $holding = Holding::where(['portfolio_id' => $transaction->portfolio->id, 'symbol' => $transaction->symbol])->firstOrFail();
+
+ $this->getJson(route('api.holding.show', ['portfolio' => $transaction->portfolio_id, 'symbol' => $transaction->symbol]))
+ ->assertOk()
+ ->assertJsonFragment([
+ 'id' => $holding->id,
+ ]);
+ }
+
+ public function test_cannot_show_nonexistent_holdings()
+ {
+ $this->actingAs($this->user)
+ ->getJson(route('api.holding.show', ['portfolio' => 'abc-123-foo-BAR', 'symbol' => 'AAPL']))
+ ->assertNotFound();
+ }
+
+ public function test_can_update_holding_options()
+ {
+ $this->actingAs($this->user);
+ $transaction = Transaction::factory()->create();
+
+ $data = [
+ 'reinvest_dividends' => true
+ ];
+
+ $this->actingAs($this->user)
+ ->putJson(route('api.holding.update', ['portfolio' => $transaction->portfolio_id, 'symbol' => $transaction->symbol]), $data)
+ ->assertOk()
+ ->assertJsonFragment([
+ 'reinvest_dividends' => true
+ ]);
+ }
+
+ public function test_cannot_update_holding_without_permission()
+ {
+ $this->actingAs($this->user);
+ $transaction = Transaction::factory()->create();
+
+ $data = [
+ 'reinvest_dividends' => true
+ ];
+
+ $otherUser = User::factory()->create();
+ $this->actingAs($otherUser)
+ ->putJson(route('api.holding.update', ['portfolio' => $transaction->portfolio_id, 'symbol' => $transaction->symbol]), $data)
+ ->assertForbidden();
+ }
+
+}
\ No newline at end of file
diff --git a/tests/Api/PortfoliosTest.php b/tests/Api/PortfoliosTest.php
new file mode 100644
index 0000000..44dfd5b
--- /dev/null
+++ b/tests/Api/PortfoliosTest.php
@@ -0,0 +1,202 @@
+user = User::factory()->create();
+ }
+
+ public function test_can_list_own_portfolios_with_pagination()
+ {
+ $this->actingAs($this->user);
+
+ Portfolio::factory(10)->create();
+
+ $this->actingAs($this->user)
+ ->getJson(route('api.portfolio.index', ['page' => 1, 'itemsPerPage' => 5]))
+ ->assertOk()
+ ->assertJsonStructure([
+ 'data' => [['id', 'title', 'owner', 'holdings', 'transactions']],
+ 'meta' => ['current_page', 'last_page', 'total'],
+ 'links' => ['first', 'last', 'prev', 'next']
+ ]);
+ }
+
+ public function test_cannot_list_others_portfolios()
+ {
+ // create portfolios with existing user
+ $this->actingAs($this->user);
+ Portfolio::factory(10)->create();
+
+ // Create a new user
+ $this->actingAs($user = User::factory()->create());
+ Portfolio::factory(1)->create();
+ $this->actingAs($user)
+ ->getJson(route('api.portfolio.index', ['page' => 1, 'itemsPerPage' => 5]))
+ ->assertOk()
+ ->assertJsonCount(1, 'data');
+ }
+
+ public function test_cannot_access_portfolios_when_unauthenticated()
+ {
+ $this->getJson(route('api.portfolio.index'))->assertUnauthorized();
+ }
+
+ public function test_can_create_a_portfolio()
+ {
+ $data = Portfolio::factory()->make()->toArray();
+
+ $this->actingAs($this->user)
+ ->postJson(route('api.portfolio.store'), $data)
+ ->assertCreated()
+ ->assertJsonStructure(['id', 'title', 'owner']);
+
+ $this->assertDatabaseHas('portfolios', ['title' => $data['title']]);
+ }
+
+ public function test_cannot_create_portfolio_without_required_fields()
+ {
+ $this->actingAs($this->user)
+ ->postJson(route('api.portfolio.store'), [])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors(['title']);
+ }
+
+ public function test_can_show_a_portfolio()
+ {
+ $this->actingAs($this->user);
+ $portfolio = Portfolio::factory()->create();
+
+ $this->actingAs($this->user)
+ ->getJson(route('api.portfolio.show', $portfolio))
+ ->assertOk()
+ ->assertJsonStructure(['id', 'title', 'owner']);
+ }
+
+ public function test_cannot_show_nonexistent_portfolio()
+ {
+ $this->actingAs($this->user)
+ ->getJson(route('api.portfolio.show', ['portfolio' => 999]))
+ ->assertNotFound();
+ }
+
+ public function test_can_update_a_portfolio()
+ {
+ $updatedData = ['title' => 'Updated Portfolio Title'];
+
+ $this->actingAs($this->user);
+ $portfolio = Portfolio::factory()->create();
+
+ $this->actingAs($this->user)
+ ->putJson(route('api.portfolio.update', $portfolio), $updatedData)
+ ->assertOk()
+ ->assertJson($updatedData);
+
+ $this->assertDatabaseHas('portfolios', $updatedData);
+ }
+
+ public function test_shared_user_can_update_portfolio()
+ {
+ // create portfolio
+ $this->actingAs($this->user);
+ $portfolio = Portfolio::factory()->create();
+
+ // share it
+ $otherUser = User::factory()->create();
+ $portfolio->share($otherUser->email, true);
+
+ // shared user tries to update it
+ $this->actingAs($otherUser)
+ ->putJson(route('api.portfolio.update', $portfolio), ['title' => 'A brand new updated title'])
+ ->assertOk()
+ ->assertJsonFragment([
+ 'title' => 'A brand new updated title'
+ ]);
+ }
+
+ public function test_removed_user_cannot_update_portfolio()
+ {
+ // create portfolio
+ $this->actingAs($this->user);
+ $portfolio = Portfolio::factory()->create();
+
+ // share it
+ $otherUser = User::factory()->create();
+ $portfolio->share($otherUser->email, true);
+
+ // unshare it
+ $otherUser = User::factory()->create();
+ $portfolio->unShare($otherUser->id);
+
+ // shared user tries to update it
+ $this->actingAs($otherUser)
+ ->putJson(route('api.portfolio.update', $portfolio), ['Title' => 'A brand new updated title'])
+ ->assertForbidden();
+ }
+
+ public function test_read_only_user_cannot_update_portfolio()
+ {
+ // create portfolio
+ $this->actingAs($this->user);
+ $portfolio = Portfolio::factory()->create();
+
+ // share it
+ $otherUser = User::factory()->create();
+ $portfolio->share($otherUser->email, false);
+
+ // shared user tries to update it
+ $this->actingAs($otherUser)
+ ->putJson(route('api.portfolio.update', $portfolio), ['Title' => 'A brand new updated title'])
+ ->assertForbidden();
+ }
+
+ public function test_cannot_update_portfolio_without_permission()
+ {
+ $this->actingAs($this->user);
+ $portfolio = Portfolio::factory()->create();
+
+ $otherUser = User::factory()->create();
+ $this->actingAs($otherUser)
+ ->putJson(route('api.portfolio.update', $portfolio), ['title' => 'New Title'])
+ ->assertForbidden();
+ }
+
+ public function test_can_delete_a_portfolio()
+ {
+ $this->actingAs($this->user);
+ $portfolio = Portfolio::factory()->create();
+
+ $this->actingAs($this->user)
+ ->deleteJson(route('api.portfolio.destroy', $portfolio))
+ ->assertNoContent();
+
+ $this->assertDatabaseMissing('portfolios', ['id' => $portfolio->id]);
+ }
+
+ public function test_cannot_delete_portfolio_without_permission()
+ {
+ $this->actingAs($this->user);
+ $portfolio = Portfolio::factory()->create();
+
+ $otherUser = User::factory()->create();
+ $this->actingAs($otherUser)
+ ->deleteJson(route('api.portfolio.destroy', $portfolio))
+ ->assertForbidden();
+ }
+}
\ No newline at end of file
diff --git a/tests/Api/TransactionsTest.php b/tests/Api/TransactionsTest.php
new file mode 100644
index 0000000..c0cfbba
--- /dev/null
+++ b/tests/Api/TransactionsTest.php
@@ -0,0 +1,200 @@
+user = User::factory()->create();
+
+ // make portfolio
+ $this->portfolio = Portfolio::factory()->makeOne();
+ $this->portfolio->setOwnerIdAttribute($this->user->id);
+ $this->portfolio->save();
+ }
+
+ public function test_can_list_transactions()
+ {
+ $this->actingAs($this->user);
+
+ Transaction::factory(10)->create();
+
+ $this->actingAs($this->user)
+ ->getJson(route('api.transaction.index', ['page' => 1, 'itemsPerPage' => 5]))
+ ->assertOk()
+ ->assertJsonStructure([
+ 'data' => [['id', 'symbol', 'transaction_type', 'portfolio_id', 'date']],
+ 'meta' => ['current_page', 'last_page', 'total'],
+ 'links' => ['first', 'last', 'prev', 'next']
+ ]);
+ }
+
+ public function test_cannot_list_others_transactions()
+ {
+ // create transactions with existing user
+ $this->actingAs($this->user);
+ Transaction::factory(10)->create();
+
+ // Create a new user
+ $this->actingAs($user = User::factory()->create());
+ Transaction::factory(1)->create();
+ $this->actingAs($user)
+ ->getJson(route('api.transaction.index', ['page' => 1, 'itemsPerPage' => 5]))
+ ->assertOk()
+ ->assertJsonCount(1, 'data');
+ }
+
+ public function test_cannot_access_transactions_when_unauthenticated()
+ {
+ $this->getJson(route('api.transaction.index'))->assertUnauthorized();
+ }
+
+ public function test_can_create_transaction()
+ {
+ $this->actingAs($this->user);
+
+ $data = [
+ 'symbol' => 'AAPL',
+ 'portfolio_id' => $this->portfolio->id,
+ 'transaction_type' => 'BUY',
+ 'quantity' => 10,
+ 'date' => now()->toDateString(),
+ 'cost_basis' => 150,
+ ];
+
+ $this->postJson(route('api.transaction.store'), $data)
+ ->assertCreated()
+ ->assertJsonStructure([
+ 'id',
+ 'symbol',
+ 'portfolio_id',
+ 'transaction_type',
+ 'quantity',
+ 'date',
+ 'cost_basis',
+ 'sale_price'
+ ]);
+ }
+
+ public function test_cannot_create_transaction_without_required_fields()
+ {
+ $this->actingAs($this->user)
+ ->postJson(route('api.transaction.store'), [
+ 'portfolio_id' => $this->portfolio->id,
+ 'symbol' => null
+ ])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors(['symbol']);
+ }
+
+ public function test_can_show_a_transaction()
+ {
+ $this->actingAs($this->user);
+
+ $transaction = Transaction::factory()->create();
+
+ $this->getJson(route('api.transaction.show', $transaction))
+ ->assertOk()
+ ->assertJsonFragment([
+ 'id' => $transaction->id,
+ ]);
+ }
+
+ public function test_cannot_show_nonexistent_transactions()
+ {
+ $this->actingAs($this->user)
+ ->getJson(route('api.transaction.show', ['transaction' => 999]))
+ ->assertNotFound();
+ }
+
+ public function test_can_update_a_transaction()
+ {
+ $this->actingAs($this->user);
+
+ $transaction = Transaction::factory()->create();
+
+ $data = [
+ 'symbol' => 'ZZZ',
+ 'transaction_type' => 'BUY',
+ 'cost_basis' => 200.19,
+ 'quantity' => 5
+ ];
+
+ $this->actingAs($this->user)
+ ->putJson(route('api.transaction.update', $transaction), $data)
+ ->assertOk()
+ ->assertJsonFragment([
+ 'symbol' => 'ZZZ',
+ 'transaction_type' => 'BUY',
+ 'cost_basis' => 200.19,
+ 'quantity' => 5,
+ ]);
+ }
+
+ public function test_shared_user_can_update_transaction()
+ {
+ // create transaction (and portfolio)
+ $this->actingAs($this->user);
+ $transaction = Transaction::factory()->create();
+
+ // share it
+ $otherUser = User::factory()->create();
+ $transaction->portfolio->share($otherUser->email, true);
+
+ // shared user tries to update it
+ $this->actingAs($otherUser)
+ ->putJson(route('api.transaction.update', $transaction), ['symbol' => 'ZZZ'])
+ ->assertOk()
+ ->assertJsonFragment([
+ 'symbol' => 'ZZZ'
+ ]);
+ }
+
+ public function test_cannot_update_transaction_without_permission()
+ {
+ $this->actingAs($this->user);
+ $transaction = Transaction::factory()->create();
+
+ $otherUser = User::factory()->create();
+ $this->actingAs($otherUser)
+ ->putJson(route('api.transaction.update', $transaction), ['symbol' => 'AAPL'])
+ ->assertForbidden();
+ }
+
+ public function test_can_delete_a_transaction()
+ {
+ $this->actingAs($this->user);
+ $transaction = Transaction::factory()->create();
+
+ $this->deleteJson(route('api.transaction.destroy', $transaction))
+ ->assertNoContent();
+
+ $this->assertDatabaseMissing('transactions', ['id' => $transaction->id]);
+ }
+
+ public function test_cannot_delete_transaction_without_permission()
+ {
+ $this->actingAs($this->user);
+ $transaction = Transaction::factory()->create();
+
+ $otherUser = User::factory()->create();
+ $this->actingAs($otherUser)
+ ->deleteJson(route('api.transaction.destroy', $transaction))
+ ->assertForbidden();
+ }
+}
\ No newline at end of file
diff --git a/tests/ApiTokenPermissionsTest.php b/tests/ApiTokenPermissionsTest.php
index 70ac458..9afd10d 100644
--- a/tests/ApiTokenPermissionsTest.php
+++ b/tests/ApiTokenPermissionsTest.php
@@ -14,50 +14,45 @@ class ApiTokenPermissionsTest extends TestCase
{
use RefreshDatabase;
- // public function test_api_tokens_can_be_deleted(): void
- // {
- // if (! Features::hasApiFeatures()) {
- // $this->markTestSkipped('API support is not enabled.');
- // }
-
- // $this->actingAs($user = User::factory()->create());
-
- // $token = $user->tokens()->create([
- // 'name' => 'Test Token',
- // 'token' => Str::random(40),
- // 'abilities' => ['create', 'read'],
- // ]);
-
- // Livewire::test(ApiTokenManager::class)
- // ->set(['apiTokenIdBeingDeleted' => $token->id])
- // ->call('deleteApiToken');
-
- // $this->assertCount(0, $user->fresh()->tokens);
- // }
-
- // public function test_api_tokens_can_be_created(): void
- // {
- // if (! Features::hasApiFeatures()) {
- // $this->markTestSkipped('API support is not enabled.');
- // }
-
- // $this->actingAs($user = User::factory()->create());
-
- // Livewire::test(ApiTokenManager::class)
- // ->set(['createApiTokenForm' => [
- // 'name' => 'Test Token',
- // 'permissions' => [
- // 'read',
- // 'update',
- // ],
- // ]])
- // ->call('createApiToken');
-
- // $this->assertCount(1, $user->fresh()->tokens);
- // $this->assertEquals('Test Token', $user->fresh()->tokens->first()->name);
- // $this->assertTrue($user->fresh()->tokens->first()->can('read'));
- // $this->assertFalse($user->fresh()->tokens->first()->can('delete'));
- // }
+ public function test_api_tokens_can_be_deleted(): void
+ {
+ if (! Features::hasApiFeatures()) {
+ $this->markTestSkipped('API support is not enabled.');
+ }
+
+ $this->actingAs($user = User::factory()->create());
+
+ $token = $user->tokens()->create([
+ 'name' => 'Test Token',
+ 'token' => Str::random(40),
+ 'abilities' => [],
+ ]);
+
+ Livewire::test(ApiTokenManager::class)
+ ->set(['apiTokenIdBeingDeleted' => $token->id])
+ ->call('deleteApiToken');
+
+ $this->assertCount(0, $user->fresh()->tokens);
+ }
+
+ public function test_api_tokens_can_be_created(): void
+ {
+ if (! Features::hasApiFeatures()) {
+ $this->markTestSkipped('API support is not enabled.');
+ }
+
+ $this->actingAs($user = User::factory()->create());
+
+ Livewire::test(ApiTokenManager::class)
+ ->set(['createApiTokenForm' => [
+ 'name' => 'Test Token',
+ 'permissions' => [],
+ ]])
+ ->call('createApiToken');
+
+ $this->assertCount(1, $user->fresh()->tokens);
+ $this->assertEquals('Test Token', $user->fresh()->tokens->first()->name);
+ }
// public function test_api_token_permissions_can_be_updated(): void
// {
@@ -76,10 +71,7 @@ class ApiTokenPermissionsTest extends TestCase
// Livewire::test(ApiTokenManager::class)
// ->set(['managingPermissionsFor' => $token])
// ->set(['updateApiTokenForm' => [
- // 'permissions' => [
- // 'delete',
- // 'missing-permission',
- // ],
+ // 'permissions' => [],
// ]])
// ->call('updateApiToken');