Merge pull request #56 from investbrainapp/api-wip
feat: Add Investbrain API capabilities
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\ApiControllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\ApiControllers;
|
||||
|
||||
use App\Models\Holding;
|
||||
use App\Models\Portfolio;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use App\Http\Requests\HoldingRequest;
|
||||
use App\Http\Resources\HoldingResource;
|
||||
use HackerEsq\FilterModels\FilterModels;
|
||||
use App\Http\ApiControllers\Controller as ApiController;
|
||||
|
||||
class HoldingController extends ApiController
|
||||
{
|
||||
public function index(FilterModels $filters)
|
||||
{
|
||||
|
||||
$filters->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\ApiControllers;
|
||||
|
||||
use App\Models\MarketData;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Resources\MarketDataResource;
|
||||
use App\Http\ApiControllers\Controller as ApiController;
|
||||
|
||||
class MarketDataController extends ApiController
|
||||
{
|
||||
public function show(Request $request, string $symbol)
|
||||
{
|
||||
|
||||
return MarketDataResource::make(
|
||||
MarketData::getMarketData($symbol)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\ApiControllers;
|
||||
|
||||
use App\Models\Portfolio;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use HackerEsq\FilterModels\FilterModels;
|
||||
use App\Http\Resources\PortfolioResource;
|
||||
use App\Http\Requests\PortfolioRequest;
|
||||
use App\Http\ApiControllers\Controller as ApiController;
|
||||
|
||||
class PortfolioController extends ApiController
|
||||
{
|
||||
public function index(FilterModels $filters)
|
||||
{
|
||||
$filters->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\ApiControllers;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use HackerEsq\FilterModels\FilterModels;
|
||||
use App\Http\Requests\TransactionRequest;
|
||||
use App\Http\Resources\TransactionResource;
|
||||
use App\Http\ApiControllers\Controller as ApiController;
|
||||
|
||||
class TransactionController extends ApiController
|
||||
{
|
||||
public function index(FilterModels $filters)
|
||||
{
|
||||
|
||||
$filters->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\ApiControllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Http\ApiControllers\Controller as ApiController;
|
||||
|
||||
class UserController extends ApiController
|
||||
{
|
||||
public function me(Request $request)
|
||||
{
|
||||
return UserResource::make($request->user());
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ use Exception;
|
||||
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;
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Http\Controllers;
|
||||
use App\Models\User;
|
||||
use App\Models\Portfolio;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
class InvitedOnboardingController extends Controller
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||
use App\Models\Holding;
|
||||
use App\Models\Portfolio;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class PortfolioController extends Controller
|
||||
{
|
||||
@@ -22,9 +23,7 @@ class PortfolioController extends Controller
|
||||
*/
|
||||
public function show(Request $request, Portfolio $portfolio)
|
||||
{
|
||||
if ($request->user()->cannot('readOnly', $portfolio)) {
|
||||
abort(403);
|
||||
}
|
||||
Gate::authorize('readOnly', $portfolio);
|
||||
|
||||
$portfolio->load(['transactions', 'holdings']);
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest as BaseFormRequest;
|
||||
|
||||
class FormRequest extends BaseFormRequest
|
||||
{
|
||||
|
||||
public function requestOrModelValue($key, $model): mixed
|
||||
{
|
||||
return $this->request->get($key) ?? $this->{$model}?->{$key};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Http\Requests\FormRequest;
|
||||
|
||||
class HoldingRequest extends FormRequest
|
||||
{
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
|
||||
$rules = [
|
||||
'reinvest_dividends' => ['sometimes', 'boolean']
|
||||
];
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Http\Requests\FormRequest;
|
||||
|
||||
class PortfolioRequest extends FormRequest
|
||||
{
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Portfolio;
|
||||
use App\Http\Requests\FormRequest;
|
||||
use App\Rules\SymbolValidationRule;
|
||||
use App\Rules\QuantityValidationRule;
|
||||
|
||||
class TransactionRequest extends FormRequest
|
||||
{
|
||||
|
||||
public ?Portfolio $portfolio;
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class HoldingResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class MarketDataResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class PortfolioResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class TransactionResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class UserResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -36,11 +36,6 @@ class Holding extends Model
|
||||
'reinvest_dividends' => 'boolean'
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'realized_gain_dollars' => 0,
|
||||
'dividends_earned' => 0,
|
||||
];
|
||||
|
||||
/**
|
||||
* Market data for holding
|
||||
*
|
||||
|
||||
@@ -5,11 +5,13 @@ namespace App\Models;
|
||||
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 @@ class Portfolio extends Model
|
||||
|
||||
// save
|
||||
$portfolio->users()->sync($owner);
|
||||
static::$owner_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,4 +256,44 @@ class Portfolio extends Model
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
JsonResource::withoutWrapping();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,13 +43,8 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
*/
|
||||
protected function configurePermissions(): void
|
||||
{
|
||||
Jetstream::defaultApiTokenPermissions(['read']);
|
||||
Jetstream::defaultApiTokenPermissions([]);
|
||||
|
||||
Jetstream::permissions([
|
||||
'create',
|
||||
'read',
|
||||
'update',
|
||||
'delete',
|
||||
]);
|
||||
Jetstream::permissions([]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@ class QuantityValidationRule implements ValidationRule
|
||||
*/
|
||||
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()
|
||||
|
||||
+8
-1
@@ -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"
|
||||
}
|
||||
],
|
||||
|
||||
Generated
+142
-31
@@ -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"
|
||||
|
||||
@@ -60,7 +60,7 @@ return [
|
||||
'features' => [
|
||||
Features::termsAndPrivacyPolicy(),
|
||||
Features::profilePhotos(),
|
||||
// Features::api(),
|
||||
Features::api(),
|
||||
// Features::teams(['invitations' => true]),
|
||||
Features::accountDeletion(),
|
||||
],
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -23,7 +23,7 @@ class CreateTransactionsTable extends Migration
|
||||
$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();
|
||||
});
|
||||
|
||||
@@ -20,10 +20,10 @@ class CreateHoldingsTable extends Migration
|
||||
$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();
|
||||
});
|
||||
|
||||
@@ -12,11 +12,11 @@ return new class extends Migration
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="description">
|
||||
{{ __('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.') }}
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="form">
|
||||
|
||||
@@ -7,7 +7,6 @@ use Livewire\Attributes\Rule;
|
||||
use Livewire\Volt\Component;
|
||||
use Illuminate\Support\Collection;
|
||||
use Mary\Traits\Toast;
|
||||
use App\Notifications\InvitedOnboardingNotification;
|
||||
|
||||
new class extends Component {
|
||||
|
||||
@@ -75,7 +74,7 @@ new class extends Component {
|
||||
|
||||
unset($this->permissions[$userId]);
|
||||
|
||||
$this->portfolio->users()->sync($this->permissions);
|
||||
$this->portfolio->unShare($userId);
|
||||
|
||||
$this->portfolio->refresh();
|
||||
|
||||
@@ -92,24 +91,7 @@ new class extends Component {
|
||||
|
||||
$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();
|
||||
|
||||
+24
-4
@@ -1,8 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\ApiControllers\UserController;
|
||||
use App\Http\ApiControllers\HoldingController;
|
||||
use App\Http\ApiControllers\PortfolioController;
|
||||
use App\Http\ApiControllers\MarketDataController;
|
||||
use App\Http\ApiControllers\TransactionController;
|
||||
|
||||
Route::get('/user', function (Request $request) {
|
||||
return $request->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');
|
||||
});
|
||||
+1
-1
@@ -5,8 +5,8 @@ use Illuminate\Support\Facades\Route;
|
||||
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;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
*
|
||||
!public/
|
||||
!.gitignore
|
||||
!market_data_seed.csv
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Api;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Models\User;
|
||||
use App\Models\Holding;
|
||||
use App\Models\Portfolio;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
class HoldingsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $user;
|
||||
protected Portfolio $portfolio;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// make user
|
||||
$this->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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Api;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Models\User;
|
||||
use App\Models\Portfolio;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
class PortfoliosTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $user;
|
||||
protected Portfolio $portfolio;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// make user
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Api;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Models\User;
|
||||
use App\Models\Portfolio;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
class TransactionsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $user;
|
||||
protected Portfolio $portfolio;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// make user
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
// }
|
||||
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());
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
|
||||
// $token = $user->tokens()->create([
|
||||
// 'name' => 'Test Token',
|
||||
// 'token' => Str::random(40),
|
||||
// 'abilities' => ['create', 'read'],
|
||||
// ]);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'Test Token',
|
||||
'token' => Str::random(40),
|
||||
'abilities' => [],
|
||||
]);
|
||||
|
||||
// Livewire::test(ApiTokenManager::class)
|
||||
// ->set(['apiTokenIdBeingDeleted' => $token->id])
|
||||
// ->call('deleteApiToken');
|
||||
Livewire::test(ApiTokenManager::class)
|
||||
->set(['apiTokenIdBeingDeleted' => $token->id])
|
||||
->call('deleteApiToken');
|
||||
|
||||
// $this->assertCount(0, $user->fresh()->tokens);
|
||||
// }
|
||||
$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.');
|
||||
// }
|
||||
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());
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
|
||||
// Livewire::test(ApiTokenManager::class)
|
||||
// ->set(['createApiTokenForm' => [
|
||||
// 'name' => 'Test Token',
|
||||
// 'permissions' => [
|
||||
// 'read',
|
||||
// 'update',
|
||||
// ],
|
||||
// ]])
|
||||
// ->call('createApiToken');
|
||||
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);
|
||||
// $this->assertTrue($user->fresh()->tokens->first()->can('read'));
|
||||
// $this->assertFalse($user->fresh()->tokens->first()->can('delete'));
|
||||
// }
|
||||
$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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user