Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80b043219a | |||
| de54b6843d | |||
| 17e5d8b665 | |||
| bd9c828c68 | |||
| f72cd6f5a7 | |||
| 3593697cce | |||
| d53e71dcd5 | |||
| 71e79cfb40 | |||
| 38a65f99c9 | |||
| 26e54fb357 | |||
| 224ed104b9 | |||
| 2702fe27e4 | |||
| dd21227f8f | |||
| 1ef8dd9378 | |||
| eae345f243 | |||
| 6d6f968f42 | |||
| 261c848ffd | |||
| 9bcc80078e | |||
| c4b7d399ea | |||
| ffe53e91c0 | |||
| aeb1b12afe | |||
| fe81ec7ee7 | |||
| f0ecc0fd3d | |||
| 03b75fb683 | |||
| dc93621547 | |||
| 7ab6f79e56 | |||
| 9e48f21c8d | |||
| 10e6de8df4 | |||
| 00fbdec6f1 | |||
| 730903c383 | |||
| 5fc9455908 | |||
| 28e0ad68fc | |||
| ca48d702a7 | |||
| 812b9ed075 | |||
| 93a0595652 | |||
| 8a357e8cab | |||
| 22e12977f8 | |||
| 732cf02317 | |||
| 6dea75651b | |||
| 6cff252813 | |||
| 0d06ca6a04 | |||
| a3f875270b | |||
| 00a1312ee3 | |||
| 1195faca0f | |||
| a39f255e52 | |||
| cac2460153 | |||
| 894da4ef9b | |||
| a705b794fd | |||
| 37da6885ee | |||
| 219018b1d9 | |||
| 4b780fd6d2 | |||
| 1faa22897b | |||
| 7e1899d8ff | |||
| 878c668696 | |||
| 8c94fbf299 | |||
| 4ece09368e | |||
| 0f135f4024 | |||
| eac5de0d4a | |||
| 399858d09b | |||
| 7694d8a241 | |||
| 9bd406c5b1 | |||
| d23d28afd8 | |||
| 0a6b2d844f | |||
| be325d31b6 | |||
| e08c1880c6 | |||
| 5f9f6f01c5 | |||
| 65388238c3 | |||
| cdce46b6df | |||
| 8320b54332 | |||
| e8ef0921ad | |||
| c4736fae70 | |||
| 1748f49ee6 | |||
| c32641ec34 | |||
| 53ebe28b14 | |||
| 465686dbaf | |||
| 58604c1e5a | |||
| 3e4f055a4a | |||
| 92586d7466 | |||
| 94c90b8a7c | |||
| f866baa37a | |||
| da72c17cd0 | |||
| 1c5c4af477 | |||
| 83d5ad213b | |||
| ea22c27710 | |||
| 32bf256c84 | |||
| e498e7668e | |||
| f58fbf9d6d | |||
| 5e56c97bf9 | |||
| ea4602abc7 | |||
| 169eabd800 | |||
| 62dcae48bb | |||
| b8f24d4b67 | |||
| 6d9e0008b8 | |||
| b9d41f9ac0 | |||
| f724f450f2 | |||
| cc447c5fb0 | |||
| b3f0f89d16 |
+1
-1
@@ -13,4 +13,4 @@ storage/framework/cache/*
|
||||
storage/framework/sessions/*
|
||||
storage/framework/testing/*
|
||||
storage/framework/views/*
|
||||
storage/framework/logs/*
|
||||
storage/logs/*
|
||||
+4
-22
@@ -40,13 +40,10 @@ LINKEDIN_CLIENT_SECRET=
|
||||
FACEBOOK_CLIENT_ID=
|
||||
FACEBOOK_CLIENT_SECRET=
|
||||
|
||||
APP_NAME=Investbrain
|
||||
APP_TIMEZONE=UTC
|
||||
APP_ENV=production
|
||||
APP_DEBUG=true
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
SELF_HOSTED=true
|
||||
FILESYSTEM_DISK=local
|
||||
SESSION_DRIVER=redis
|
||||
QUEUE_CONNECTION=redis
|
||||
CACHE_STORE=redis
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=investbrain-mysql
|
||||
@@ -55,20 +52,7 @@ DB_DATABASE=investbrain
|
||||
DB_USERNAME=investbrain
|
||||
DB_PASSWORD=investbrain
|
||||
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
CACHE_STORE=redis
|
||||
|
||||
REDIS_CLIENT=predis
|
||||
REDIS_HOST=investbrain-redis
|
||||
REDIS_PATH=/tmp/database_server.sock
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
@@ -86,5 +70,3 @@ AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
@@ -43,7 +43,16 @@ jobs:
|
||||
- name: Extract version from tag
|
||||
id: extract-version
|
||||
run: |
|
||||
echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
|
||||
TAGS="investbrainapp/investbrain:${VERSION},ghcr.io/investbrainapp/investbrain:${VERSION}"
|
||||
|
||||
# Conditionally add 'latest' tags unless 'pre-release' is in the version
|
||||
if [[ "${GITHUB_REF_NAME}" != *alpha* && "${GITHUB_REF_NAME}" != *beta* && "${GITHUB_REF_NAME}" != *rc* ]]; then
|
||||
TAGS="$TAGS,investbrainapp/investbrain:latest,ghcr.io/investbrainapp/investbrain:latest"
|
||||
fi
|
||||
|
||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -51,8 +60,5 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
investbrainapp/investbrain:latest
|
||||
investbrainapp/investbrain:${{ env.version }}
|
||||
ghcr.io/investbrainapp/investbrain:latest
|
||||
ghcr.io/investbrainapp/investbrain:${{ env.version }}
|
||||
tags: ${{ steps.extract-version.outputs.tags }}
|
||||
|
||||
|
||||
@@ -38,21 +38,23 @@ Before getting started, you should already have [Docker Engine](https://docs.doc
|
||||
|
||||
Ready? Let's get started!
|
||||
|
||||
**1. Copy Docker Compose file**
|
||||
**1. Download copy of Docker Compose file**
|
||||
|
||||
Grab a copy of the [docker-compose.yml](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml)** file and paste the contents into the directory where you plan to install Investbrain.
|
||||
Grab a copy of the [docker-compose.yml](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml) using `wget`, `curl` or similar:
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/investbrainapp/investbrain/main/docker-compose.yml
|
||||
```
|
||||
|
||||
**2. Set your environment**
|
||||
|
||||
Adjust the `environment` properties in the Docker Compose file to your preferences. Alternatively, create a .env file in the same directory as your compose file, then reference the .env file using the `env_file` property.
|
||||
Adjust the `environment` properties in the compose file to your preferences.
|
||||
|
||||
_Importantly_, you need to set the `APP_KEY` value to a complex random value. If you're unsure, Investbrain will generate an `APP_KEY` for you on first run - but you must **manually** update your environment configuration with this generated value!
|
||||
|
||||
> Tip: Want to know what other configuration options are available? You can reference the [.env.example](https://github.com/investbrainapp/investbrain/blob/main/.env.example) file in this respository for available environment configurations.
|
||||
**Importantly**, you need to set the `APP_KEY` value. If you're unsure, Investbrain will generate an `APP_KEY` for you on first run, but it will not persist. You must _manually_ update your environment configuration with this generated value!
|
||||
|
||||
**3. Run `docker compose up`**
|
||||
|
||||
This might take a few minutes to pull the Docker images. But assuming everything worked as expected, you should now be able to access Investbrain in the browser by visiting:
|
||||
It might take a few minutes to pull the Docker images. But assuming everything worked as expected, you should now be able to access Investbrain in the browser by visiting:
|
||||
|
||||
```bash
|
||||
http://localhost:8000/register
|
||||
@@ -191,6 +193,7 @@ Just to be safe, we recommend backing up your portfolios before using these comm
|
||||
| refresh:market-data | Refreshes market data with your configured market data provider. |
|
||||
| refresh:dividend-data | Refreshes dividend data with your configured market data provider. Will also re-calculate your total dividends earned for each holding. |
|
||||
| refresh:split-data | Refreshes splits data with your configured market data provider. Will also create new transactions to account for any splits. |
|
||||
| refresh:currency-data | Grabs the latest daily currency exchange rate data and persists to the database. |
|
||||
| capture:daily-change | Captures a snapshot of each portfolio's daily performance. |
|
||||
| sync:daily-change | Re-calculates daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) |
|
||||
| sync:holdings | Re-calculates performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
|
||||
@@ -212,6 +215,14 @@ docker exec -it investbrain-app tail -f storage/logs/laravel.log
|
||||
|
||||
<details>
|
||||
|
||||
**<summary>Application styling is broken and images are too big</summary>**
|
||||
|
||||
If you're serving Investbrain from a DNS name (e.g. example.com), it's likely that you haven't updated the `ASSET_URL` environment yet. The URL provided there will be used to generate absolute URLs for images, JS, and CSS assets on the front end of the application.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
**<summary>Market data not refreshing on fresh install</summary>**
|
||||
|
||||
If you're unable to refresh market data out of the box (i.e. your market data provider is set to Yahoo), there is a chance Yahoo is being blocked by a firewall or adblocker. Pihole is known to block `fc.yahoo.com` which is the domain used to query Yahoo.
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 1.0.x | :white_check_mark: |
|
||||
| 1.1.x | :white_check_mark: |
|
||||
| 1.0.x | :x: |
|
||||
| < 1.0.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\Currency;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ConvertToMarketDataCurrency
|
||||
{
|
||||
public function __invoke(Model $model, callable $next)
|
||||
{
|
||||
if (is_null($model?->market_data)) {
|
||||
|
||||
$model->loadMarketData();
|
||||
}
|
||||
|
||||
if (! is_null($model->currency) && $model->currency !== $model->market_data->currency) {
|
||||
|
||||
// convert to market data currency
|
||||
$model->cost_basis = Currency::convert(
|
||||
value: $model->cost_basis,
|
||||
from: $model->currency,
|
||||
to: $model->market_data->currency,
|
||||
date: $model->date
|
||||
);
|
||||
|
||||
if ($model->transaction_type == 'SELL') {
|
||||
|
||||
$model->sale_price = Currency::convert(
|
||||
value: $model->sale_price,
|
||||
from: $model->currency,
|
||||
to: $model->market_data->currency,
|
||||
date: $model->date
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// currency cannot be saved to the database - we already know market_data.currency anyway
|
||||
unset($model->currency);
|
||||
|
||||
return $next($model);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Casts\BaseCurrency;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CopyToBaseCurrency
|
||||
{
|
||||
public function __invoke(Model $model, callable $next)
|
||||
{
|
||||
foreach ($model->getCasts() as $key => $value) {
|
||||
if ($value === BaseCurrency::class) {
|
||||
|
||||
$model[$key] = $model[Str::beforeLast($key, '_base')];
|
||||
}
|
||||
}
|
||||
|
||||
return $next($model);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class EnsureCostBasisAddedToSale
|
||||
{
|
||||
public function __invoke(Model $model, callable $next)
|
||||
{
|
||||
// cost basis is required for sales to calculate realized gains
|
||||
if ($model->transaction_type == 'SELL') {
|
||||
|
||||
$average_cost_basis = Transaction::where([
|
||||
'portfolio_id' => $model->portfolio_id,
|
||||
'symbol' => $model->symbol,
|
||||
'transaction_type' => 'BUY',
|
||||
])->whereDate('date', '<=', $model->date)
|
||||
->average('cost_basis');
|
||||
|
||||
$model->cost_basis = $average_cost_basis ?? 0;
|
||||
}
|
||||
|
||||
return $next($model);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
use function Illuminate\Support\defer;
|
||||
|
||||
class EnsureDailyChangeIsSynced
|
||||
{
|
||||
public function __invoke(Model $model, callable $next)
|
||||
{
|
||||
if (config('app.env') != 'testing') {
|
||||
|
||||
$cacheKey = 'daily_change_synced'.$model->portfolio_id;
|
||||
|
||||
if (
|
||||
! Cache::has($cacheKey)
|
||||
&& $model->date->lessThan(now())
|
||||
&& ($model->date->lessThan($model->portfolio->daily_change()->min('date') ?? now())
|
||||
|| $model->date->lessThan($model->portfolio->transactions()->where('id', '!=', $model->id)->max('date') ?? now())
|
||||
)
|
||||
) {
|
||||
defer(fn () => $model->portfolio->syncDailyChanges());
|
||||
|
||||
Cache::put($cacheKey, now(), now()->addMinutes(5));
|
||||
}
|
||||
}
|
||||
|
||||
return $next($model);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\WithTrimStrings;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
@@ -30,13 +31,22 @@ class CreateNewUser implements CreatesNewUsers
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
|
||||
'password' => $this->passwordRules(),
|
||||
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
|
||||
'terms' => config('investbrain.self_hosted') ? '' : ['accepted', 'required'],
|
||||
])->validate();
|
||||
|
||||
return User::create([
|
||||
$user = User::make([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
]);
|
||||
|
||||
// ensure first user is flagged as an admin
|
||||
if (User::count() === 0) {
|
||||
$user->admin = true;
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\WithTrimStrings;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||
|
||||
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
{
|
||||
use WithTrimStrings;
|
||||
|
||||
|
||||
/**
|
||||
* Validate and update the given user's profile information.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Casts;
|
||||
|
||||
use App\Models\Currency;
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BaseCurrency implements CastsAttributes
|
||||
{
|
||||
/**
|
||||
* Cast the given value to user's display currency
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
|
||||
{
|
||||
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the given value for storage in base currency
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
|
||||
{
|
||||
|
||||
// for market data and transactions the `currency` attribute is available...
|
||||
// but for dividends and other types, need to make sure `market_data` is loaded
|
||||
if (is_null($model?->currency)) {
|
||||
|
||||
$model->loadMarketData();
|
||||
}
|
||||
|
||||
return Currency::convert(
|
||||
(float) $value,
|
||||
$model?->currency ?? $model->market_data?->currency,
|
||||
config('investbrain.base_currency'),
|
||||
$model?->date
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Holding;
|
||||
use App\Models\Portfolio;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
@@ -38,27 +41,24 @@ class CaptureDailyChange extends Command
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
Portfolio::with('holdings.market_data')->get()->each(function($portfolio){
|
||||
Portfolio::with('holdings.market_data')->get()->each(function ($portfolio) {
|
||||
|
||||
$this->line('Capturing daily change for ' . $portfolio->title);
|
||||
$this->line('Capturing daily change for '.$portfolio->title);
|
||||
|
||||
$total_cost_basis = $portfolio->holdings->sum('total_cost_basis');
|
||||
$metrics = Holding::query()
|
||||
->portfolio($portfolio->id)
|
||||
->getPortfolioMetrics(config('investbrain.base_currency'));
|
||||
|
||||
$total_dividends = $portfolio->holdings->sum('dividends_earned');
|
||||
|
||||
$realized_gains = $portfolio->holdings->sum('realized_gain_dollars');
|
||||
|
||||
$total_market_value = $portfolio->holdings->sum(function($holding) {
|
||||
return $holding->market_data->market_value * $holding->quantity;
|
||||
});
|
||||
$total_cost_basis = $metrics->get('total_cost_basis');
|
||||
$total_market_value = $metrics->get('total_market_value');
|
||||
|
||||
$portfolio->daily_change()->create([
|
||||
'date' => now(),
|
||||
'total_market_value' => $total_market_value,
|
||||
'total_cost_basis' => $total_cost_basis,
|
||||
'total_gain' => $total_market_value - $total_cost_basis,
|
||||
'total_dividends_earned' => $total_dividends,
|
||||
'realized_gains' => $realized_gains
|
||||
'total_dividends_earned' => $metrics->get('total_dividends_earned'),
|
||||
'realized_gains' => $metrics->get('realized_gain_dollars'),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\CurrencyRate;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RefreshCurrencyData extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'refresh:currency-data
|
||||
{--force : Refresh of currency data}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Refresh currency data from data provider';
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
|
||||
CurrencyRate::refreshCurrencyData($this->option('force') ?? false);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Holding;
|
||||
use App\Models\Dividend;
|
||||
use App\Models\Holding;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RefreshDividendData extends Command
|
||||
@@ -43,17 +45,17 @@ class RefreshDividendData extends Command
|
||||
{
|
||||
$holdings = Holding::distinct();
|
||||
|
||||
if (!($this->option('force') ?? false)) {
|
||||
if (! ($this->option('force') ?? false)) {
|
||||
$holdings->where('quantity', '>', 0);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->option('user')) {
|
||||
$holdings->myHoldings($this->option('user'));
|
||||
}
|
||||
|
||||
foreach ($holdings->get(['symbol']) as $holding) {
|
||||
$this->line('Refreshing ' . $holding->symbol);
|
||||
|
||||
$this->line('Refreshing '.$holding->symbol);
|
||||
|
||||
Dividend::refreshDividendData($holding->symbol);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Holding;
|
||||
@@ -42,20 +44,24 @@ class RefreshMarketData extends Command
|
||||
public function handle()
|
||||
{
|
||||
$force = $this->option('force') ?? false;
|
||||
|
||||
|
||||
// get all symbols from market data
|
||||
$holdings = Holding::where('quantity', '>', 0)
|
||||
->select(['symbol'])
|
||||
->distinct();
|
||||
|
||||
->select(['symbol'])
|
||||
->distinct();
|
||||
|
||||
if ($this->option('user')) {
|
||||
$holdings->myHoldings($this->option('user'));
|
||||
}
|
||||
|
||||
foreach ($holdings->get() as $holding) {
|
||||
$this->line('Refreshing ' . $holding->symbol);
|
||||
$this->line('Refreshing '.$holding->symbol);
|
||||
|
||||
MarketData::getMarketData($holding->symbol, $force);
|
||||
try {
|
||||
MarketData::getMarketData($holding->symbol, $force);
|
||||
} catch (\Throwable $e) {
|
||||
$this->line('Could not refresh '.$holding->symbol.' ('.$e->getMessage().')');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Split;
|
||||
use App\Models\Holding;
|
||||
use App\Models\Split;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RefreshSplitData extends Command
|
||||
@@ -42,14 +44,14 @@ class RefreshSplitData extends Command
|
||||
{
|
||||
$holdings = Holding::distinct();
|
||||
|
||||
if (!($this->option('force') ?? false)) {
|
||||
if (! ($this->option('force') ?? false)) {
|
||||
$holdings->where('quantity', '>', 0);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($holdings->get(['symbol']) as $holding) {
|
||||
$this->line('Refreshing ' . $holding->symbol);
|
||||
|
||||
$this->line('Refreshing '.$holding->symbol);
|
||||
|
||||
Split::refreshSplitData($holding->symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Portfolio;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Console\PromptsForMissingInput;
|
||||
|
||||
use function Laravel\Prompts\search;
|
||||
|
||||
class SyncDailyChange extends Command implements PromptsForMissingInput
|
||||
@@ -61,14 +64,14 @@ class SyncDailyChange extends Command implements PromptsForMissingInput
|
||||
public function handle()
|
||||
{
|
||||
try {
|
||||
|
||||
|
||||
$portfolio = Portfolio::findOrFail($this->argument('portfolio_id'));
|
||||
|
||||
$this->line('Syncing daily change history... This may take a moment.');
|
||||
|
||||
$portfolio->syncDailyChanges();
|
||||
|
||||
$this->line('Awesome! Daily change history for '. $portfolio->title .' has been completed.');
|
||||
$this->line('Awesome! Daily change history for '.$portfolio->title.' has been completed.');
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Holding;
|
||||
@@ -47,7 +49,7 @@ class SyncHoldingData extends Command
|
||||
}
|
||||
|
||||
foreach ($holdings->get() as $holding) {
|
||||
$this->line('Refreshing ' . $holding->symbol);
|
||||
$this->line('Refreshing '.$holding->symbol);
|
||||
|
||||
$holding->syncTransactionsAndDividends();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Exports\Sheets\ConfigSheet;
|
||||
use App\Exports\Sheets\DailyChangesSheet;
|
||||
use App\Exports\Sheets\PortfoliosSheet;
|
||||
use App\Exports\Sheets\TransactionsSheet;
|
||||
@@ -14,18 +17,15 @@ class BackupExport implements WithMultipleSheets
|
||||
|
||||
public function __construct(
|
||||
public bool $empty = false
|
||||
)
|
||||
{ }
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function sheets(): array
|
||||
{
|
||||
return [
|
||||
new PortfoliosSheet($this->empty),
|
||||
new TransactionsSheet($this->empty),
|
||||
new DailyChangesSheet($this->empty)
|
||||
];
|
||||
return [
|
||||
new PortfoliosSheet($this->empty),
|
||||
new TransactionsSheet($this->empty),
|
||||
new DailyChangesSheet($this->empty),
|
||||
new ConfigSheet($this->empty),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exports\Sheets;
|
||||
|
||||
use App\Models\Holding;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
|
||||
class ConfigSheet implements FromCollection, WithHeadings, WithTitle
|
||||
{
|
||||
public function __construct(
|
||||
public bool $empty = false
|
||||
) {}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'Key',
|
||||
'Value',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function collection()
|
||||
{
|
||||
$configs = collect();
|
||||
|
||||
if ($this->empty) {
|
||||
return $configs;
|
||||
}
|
||||
|
||||
// collect user settings
|
||||
$configs->push([
|
||||
'key' => 'name',
|
||||
'value' => auth()->user()->name,
|
||||
], [
|
||||
'key' => 'locale',
|
||||
'value' => auth()->user()->getLocale(),
|
||||
], [
|
||||
'key' => 'display_currency',
|
||||
'value' => auth()->user()->getCurrency(),
|
||||
]);
|
||||
|
||||
// reinvested holdings
|
||||
$reinvested_holdings = Holding::myHoldings()->where('reinvest_dividends', true)->get(['portfolio_id', 'symbol']);
|
||||
if ($reinvested_holdings->isNotEmpty()) {
|
||||
$configs->push([
|
||||
'key' => 'reinvested_dividends',
|
||||
'value' => $reinvested_holdings->toJson(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $configs;
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return 'Config';
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exports\Sheets;
|
||||
|
||||
use App\Models\DailyChange;
|
||||
@@ -11,7 +13,7 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
|
||||
{
|
||||
public function __construct(
|
||||
public bool $empty = false
|
||||
) { }
|
||||
) {}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
@@ -23,21 +25,18 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
|
||||
'Total Gain',
|
||||
'Total Dividends Earned',
|
||||
'Realized Gains',
|
||||
'Annotation'
|
||||
'Annotation',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function collection()
|
||||
{
|
||||
return $this->empty ? collect() : DailyChange::myDailyChanges()->get();
|
||||
return $this->empty ? collect() : DailyChange::myDailyChanges()->withDailyPerformance()->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function title(): string
|
||||
{
|
||||
return 'Daily Changes';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exports\Sheets;
|
||||
|
||||
use App\Models\Portfolio;
|
||||
@@ -11,8 +13,8 @@ class PortfoliosSheet implements FromCollection, WithHeadings, WithTitle
|
||||
{
|
||||
public function __construct(
|
||||
public bool $empty = false
|
||||
) { }
|
||||
|
||||
) {}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
@@ -21,21 +23,18 @@ class PortfoliosSheet implements FromCollection, WithHeadings, WithTitle
|
||||
'Notes',
|
||||
'Wishlist',
|
||||
'Created',
|
||||
'Updated'
|
||||
'Updated',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function collection()
|
||||
{
|
||||
return $this->empty ? collect() : Portfolio::myPortfolios()->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function title(): string
|
||||
{
|
||||
return 'Portfolios';
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exports\Sheets;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
|
||||
class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
|
||||
{
|
||||
public function __construct(
|
||||
public bool $empty = false
|
||||
) { }
|
||||
) {}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
@@ -23,25 +25,46 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
|
||||
'Quantity',
|
||||
'Cost Basis',
|
||||
'Sale Price',
|
||||
'Currency',
|
||||
'Split',
|
||||
'Reinvested Dividend',
|
||||
'Date',
|
||||
'Created',
|
||||
'Updated'
|
||||
'Updated',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function collection()
|
||||
{
|
||||
return $this->empty ? collect() : Transaction::myTransactions()->get();
|
||||
if ($this->empty) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Transaction::myTransactions()
|
||||
->withMarketData()
|
||||
->get()
|
||||
->map(function ($transaction) {
|
||||
return [
|
||||
'id' => $transaction->id,
|
||||
'symbol' => $transaction->symbol,
|
||||
'portfolio_id' => $transaction->portfolio_id,
|
||||
'transaction_type' => $transaction->transaction_type,
|
||||
'quantity' => $transaction->quantity,
|
||||
'cost_basis' => $transaction->cost_basis,
|
||||
'sale_price' => $transaction->sale_price,
|
||||
'currency' => $transaction->market_data_currency,
|
||||
'split' => $transaction->split,
|
||||
'reinvested_dividend' => $transaction->reinvested_dividend,
|
||||
'date' => $transaction->date,
|
||||
'created_at' => $transaction->created_at,
|
||||
'updated_at' => $transaction->updated_at,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function title(): string
|
||||
{
|
||||
return 'Transactions';
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\ApiControllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\ApiControllers;
|
||||
|
||||
use App\Http\ApiControllers\Controller as ApiController;
|
||||
use App\Http\Requests\HoldingRequest;
|
||||
use App\Http\Resources\HoldingResource;
|
||||
use App\Models\Holding;
|
||||
use App\Models\Portfolio;
|
||||
use HackerEsq\FilterModels\FilterModels;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
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,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\ApiControllers;
|
||||
|
||||
use App\Http\ApiControllers\Controller as ApiController;
|
||||
use App\Http\Resources\MarketDataResource;
|
||||
use App\Models\MarketData;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MarketDataController extends ApiController
|
||||
{
|
||||
public function show(Request $request, string $symbol)
|
||||
{
|
||||
|
||||
try {
|
||||
|
||||
return MarketDataResource::make(
|
||||
MarketData::getMarketData($symbol)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
return response([
|
||||
'message' => 'Symbol '.$symbol.' not found.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\ApiControllers;
|
||||
|
||||
use App\Http\ApiControllers\Controller as ApiController;
|
||||
use App\Http\Requests\PortfolioRequest;
|
||||
use App\Http\Resources\PortfolioResource;
|
||||
use App\Models\Portfolio;
|
||||
use HackerEsq\FilterModels\FilterModels;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
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,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\ApiControllers;
|
||||
|
||||
use App\Http\ApiControllers\Controller as ApiController;
|
||||
use App\Http\Requests\TransactionRequest;
|
||||
use App\Http\Resources\TransactionResource;
|
||||
use App\Models\Transaction;
|
||||
use HackerEsq\FilterModels\FilterModels;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class TransactionController extends ApiController
|
||||
{
|
||||
public function index(FilterModels $filters)
|
||||
{
|
||||
|
||||
$filters->setQuery(Transaction::query());
|
||||
$filters->setScopes(['myTransactions']);
|
||||
$filters->setEagerRelations(['market_data']);
|
||||
$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,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\ApiControllers;
|
||||
|
||||
use App\Http\ApiControllers\Controller as ApiController;
|
||||
use App\Http\Resources\UserResource;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserController extends ApiController
|
||||
{
|
||||
public function me(Request $request)
|
||||
{
|
||||
return UserResource::make($request->user());
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Exception;
|
||||
use App\Models\User;
|
||||
use App\Models\ConnectedAccount;
|
||||
use Illuminate\Support\MessageBag;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Notifications\VerifyConnectedAccountNotification;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\MessageBag;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use App\Notifications\VerifyConnectedAccountNotification;
|
||||
|
||||
class ConnectedAccountController extends Controller
|
||||
{
|
||||
|
||||
/**
|
||||
* Redirect the user to the GitHub authentication page.
|
||||
*
|
||||
*/
|
||||
public function redirectToProvider(string $provider)
|
||||
{
|
||||
@@ -28,7 +27,6 @@ class ConnectedAccountController extends Controller
|
||||
|
||||
/**
|
||||
* Obtain the user information from GitHub.
|
||||
*
|
||||
*/
|
||||
public function handleProviderCallback(string $provider)
|
||||
{
|
||||
@@ -45,21 +43,21 @@ class ConnectedAccountController extends Controller
|
||||
}
|
||||
|
||||
// check if this account is already linked
|
||||
$connected_account = ConnectedAccount::firstOrNew([
|
||||
$connected_account = ConnectedAccount::firstOrNew([
|
||||
'provider' => $provider,
|
||||
'provider_id' => $providerUser->id
|
||||
'provider_id' => $providerUser->id,
|
||||
], [
|
||||
'token' => $providerUser->token,
|
||||
'secret' => $providerUser->tokenSecret,
|
||||
'refresh_token' => $providerUser->refreshToken,
|
||||
'expires_at' => $providerUser->expiresIn,
|
||||
'verified_at' => false
|
||||
'verified_at' => false,
|
||||
]);
|
||||
|
||||
// already linked and verified, let's go login!
|
||||
if (
|
||||
$connected_account->exists
|
||||
&& !is_null($connected_account->verified_at)
|
||||
$connected_account->exists
|
||||
&& ! is_null($connected_account->verified_at)
|
||||
) {
|
||||
|
||||
Auth::login($connected_account->user, true);
|
||||
@@ -68,20 +66,20 @@ class ConnectedAccountController extends Controller
|
||||
}
|
||||
|
||||
// new user, let's create one
|
||||
if (!$user = User::where('email', $providerUser->email)->first()) {
|
||||
if (! $user = User::where('email', $providerUser->email)->first()) {
|
||||
|
||||
$user = User::create([
|
||||
'name' => $providerUser->name,
|
||||
'email' => $providerUser->email,
|
||||
'email_verified_at' => now()
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
|
||||
$connected_account->user_id = $user->id;
|
||||
$connected_account->verified_at = now();
|
||||
$connected_account->save();
|
||||
|
||||
|
||||
Auth::login($user, true);
|
||||
|
||||
|
||||
return redirect(route('dashboard'));
|
||||
}
|
||||
|
||||
@@ -92,23 +90,23 @@ class ConnectedAccountController extends Controller
|
||||
$user->notify(new VerifyConnectedAccountNotification($connected_account->id));
|
||||
|
||||
return redirect(route('login'))
|
||||
->with('status', __(
|
||||
'Account already exists. Check your email to connect your :provider account.',
|
||||
['provider' => config("services.$provider.name")]
|
||||
));
|
||||
->with('status', __(
|
||||
'Account already exists. Check your email to connect your :provider account.',
|
||||
['provider' => config("services.$provider.name")]
|
||||
));
|
||||
}
|
||||
|
||||
protected function validateProvider($provider): void
|
||||
{
|
||||
if (!in_array($provider, explode(',', config('services.enabled_login_providers')))) {
|
||||
|
||||
if (! in_array($provider, explode(',', config('services.enabled_login_providers')))) {
|
||||
|
||||
throw new Exception('Please provide a valid social provider.');
|
||||
}
|
||||
}
|
||||
|
||||
public function verify(ConnectedAccount $connected_account)
|
||||
{
|
||||
if (!$connected_account->verified_at) {
|
||||
if (! $connected_account->verified_at) {
|
||||
|
||||
// mark request as verified
|
||||
$connected_account->verified_at = now();
|
||||
@@ -128,8 +126,8 @@ class ConnectedAccountController extends Controller
|
||||
'css' => 'alert-success',
|
||||
'icon' => Blade::render("<x-mary-icon class='w-7 h-7' name='o-check-circle' />"),
|
||||
'position' => 'toast-top toast-end',
|
||||
'timeout' => '5000'
|
||||
]
|
||||
'timeout' => '5000',
|
||||
],
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Holding;
|
||||
@@ -15,16 +17,14 @@ class DashboardController extends Controller
|
||||
$user = $request->user()->load(['portfolios', 'holdings', 'transactions']);
|
||||
|
||||
// get portfolio metrics
|
||||
$metrics = cache()->remember(
|
||||
'dashboard-metrics-' . $user->id,
|
||||
10,
|
||||
$metrics = cache()->tags(['metrics-'.$user->id])->remember(
|
||||
'dashboard-metrics-'.$user->id,
|
||||
10,
|
||||
function () {
|
||||
return
|
||||
Holding::query()
|
||||
return Holding::query()
|
||||
->myHoldings()
|
||||
->withoutWishlists()
|
||||
->withPortfolioMetrics()
|
||||
->first();
|
||||
->getPortfolioMetrics();
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Holding;
|
||||
@@ -8,21 +10,20 @@ use Illuminate\Http\Request;
|
||||
|
||||
class HoldingController extends Controller
|
||||
{
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(Request $request, Portfolio $portfolio, String $symbol)
|
||||
public function show(Request $request, Portfolio $portfolio, string $symbol)
|
||||
{
|
||||
$holding = Holding::with([
|
||||
'market_data',
|
||||
'transactions' => function ($query) use ($symbol) {
|
||||
$query->where('transactions.symbol', $symbol);
|
||||
}
|
||||
])
|
||||
->symbol($symbol)
|
||||
->portfolio($portfolio->id)
|
||||
->firstOrFail();
|
||||
'market_data',
|
||||
'transactions' => function ($query) use ($symbol) {
|
||||
$query->where('transactions.symbol', $symbol);
|
||||
},
|
||||
])
|
||||
->symbol($symbol)
|
||||
->portfolio($portfolio->id)
|
||||
->firstOrFail();
|
||||
|
||||
$formattedTransactions = $holding->getFormattedTransactions();
|
||||
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Portfolio;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
class InvitedOnboardingController extends Controller
|
||||
{
|
||||
|
||||
/**
|
||||
* Check if the invited user needs a password?
|
||||
*
|
||||
*/
|
||||
public function __invoke(Request $request, Portfolio $portfolio, User $user)
|
||||
{
|
||||
|
||||
if (!$request->hasValidSignature()) {
|
||||
if (! $request->hasValidSignature()) {
|
||||
abort(401, 'Invalid signature');
|
||||
}
|
||||
|
||||
@@ -27,7 +26,7 @@ class InvitedOnboardingController extends Controller
|
||||
// route to create password form
|
||||
return view('auth.invited-onboarding', [
|
||||
'portfolio' => $portfolio,
|
||||
'user' => $user
|
||||
'user' => $user,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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
|
||||
{
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*/
|
||||
@@ -22,26 +24,23 @@ 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']);
|
||||
|
||||
|
||||
// get portfolio metrics
|
||||
$metrics = cache()->remember(
|
||||
'portfolio-metrics-' . $portfolio->id,
|
||||
60,
|
||||
$metrics = cache()->tags(['metrics-'.$request->user()->id])->remember(
|
||||
'portfolio-metrics-'.$portfolio->id,
|
||||
60,
|
||||
function () use ($portfolio) {
|
||||
return Holding::query()
|
||||
->portfolio($portfolio->id)
|
||||
->withPortfolioMetrics()
|
||||
->first();
|
||||
->portfolio($portfolio->id)
|
||||
->getPortfolioMetrics();
|
||||
}
|
||||
);
|
||||
|
||||
$formattedHoldings = $portfolio->getFormattedHoldings();
|
||||
|
||||
|
||||
return view('portfolio.show', compact(['portfolio', 'metrics', 'formattedHoldings']));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
class TransactionController extends Controller
|
||||
{
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Number;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Foundation\Events\LocaleUpdated;
|
||||
|
||||
class LocalizationMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
if (Auth::check()) {
|
||||
|
||||
$locale = auth()->user()->getLocale();
|
||||
|
||||
config(['app.locale' => $locale]);
|
||||
app('translator')->setLocale(Str::before($locale, '_'));
|
||||
app('events')->dispatch(new LocaleUpdated($locale));
|
||||
|
||||
Number::useLocale($locale);
|
||||
Number::useCurrency(auth()->user()->getCurrency());
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SetLocale
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
if (!session()->has('locale')) {
|
||||
session()->put('locale', $request->getPreferredLanguage(
|
||||
config('app.available_locales')
|
||||
));
|
||||
}
|
||||
|
||||
app()->setLocale(session('locale'));
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
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,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
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,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Portfolio;
|
||||
use App\Rules\QuantityValidationRule;
|
||||
use App\Rules\SymbolValidationRule;
|
||||
|
||||
class TransactionRequest extends FormRequest
|
||||
{
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
|
||||
$this->merge([
|
||||
'portfolio' => Portfolio::find($this->requestOrModelValue('portfolio_id', 'transaction')),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
|
||||
$rules = [
|
||||
'portfolio_id' => ['required', 'exists:portfolios,id'],
|
||||
'symbol' => ['required', 'string', new SymbolValidationRule],
|
||||
'transaction_type' => ['required', 'string', 'in:BUY,SELL'],
|
||||
'date' => ['required', 'date_format:Y-m-d', 'before_or_equal:'.now()->toDateString()],
|
||||
'quantity' => [
|
||||
'required',
|
||||
'numeric',
|
||||
'gt:0',
|
||||
new QuantityValidationRule(
|
||||
$this->input('portfolio'),
|
||||
$this->requestOrModelValue('symbol', 'transaction'),
|
||||
$this->requestOrModelValue('transaction_type', 'transaction'),
|
||||
$this->requestOrModelValue('date', 'transaction')
|
||||
),
|
||||
],
|
||||
'currency' => ['required', 'exists:currencies,currency'],
|
||||
'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['portfolio_id'][0] = 'sometimes';
|
||||
$rules['symbol'][0] = 'sometimes';
|
||||
$rules['transaction_type'][0] = 'sometimes';
|
||||
$rules['currency'][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,38 @@
|
||||
<?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,
|
||||
'currency' => $this->market_data->currency,
|
||||
'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,35 @@
|
||||
<?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,
|
||||
'currency' => $this->market_data->currency,
|
||||
'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,33 @@
|
||||
<?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,
|
||||
'options' => [
|
||||
'display_currency' => $this->getCurrency(),
|
||||
'locale' => $this->getLocale(),
|
||||
],
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Imports;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Imports\Sheets\PortfoliosSheet;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use App\Console\Commands\RefreshDividendData;
|
||||
use App\Console\Commands\RefreshMarketData;
|
||||
use App\Console\Commands\SyncDailyChange;
|
||||
use App\Console\Commands\SyncHoldingData;
|
||||
use App\Imports\Sheets\ConfigSheet;
|
||||
use App\Imports\Sheets\DailyChangesSheet;
|
||||
use App\Imports\Sheets\PortfoliosSheet;
|
||||
use App\Imports\Sheets\TransactionsSheet;
|
||||
use Maatwebsite\Excel\Events\AfterImport;
|
||||
use App\Models\BackupImport as BackupImportModel;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Maatwebsite\Excel\Concerns\Importable;
|
||||
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||
use Maatwebsite\Excel\Events\AfterImport;
|
||||
use Maatwebsite\Excel\Events\BeforeImport;
|
||||
use Maatwebsite\Excel\Events\ImportFailed;
|
||||
use App\Console\Commands\RefreshMarketData;
|
||||
use App\Console\Commands\RefreshDividendData;
|
||||
use App\Models\BackupImport as BackupImportModel;
|
||||
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||
|
||||
class BackupImport implements WithMultipleSheets, WithEvents
|
||||
class BackupImport implements WithEvents, WithMultipleSheets
|
||||
{
|
||||
|
||||
use Importable;
|
||||
|
||||
public function __construct(
|
||||
public BackupImportModel $backupImportModel
|
||||
) { }
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function registerEvents(): array
|
||||
{
|
||||
return [
|
||||
BeforeImport::class => fn() => $this->backupImportModel->update([
|
||||
BeforeImport::class => fn () => $this->backupImportModel->update([
|
||||
'status' => 'in_progress',
|
||||
'message' => __('Import is in progress...'),
|
||||
]),
|
||||
@@ -43,24 +42,24 @@ class BackupImport implements WithMultipleSheets, WithEvents
|
||||
$this->backupImportModel->update([
|
||||
'status' => 'success',
|
||||
'message' => 'Import completed successfully!',
|
||||
'completed_at' => now()
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
|
||||
Artisan::queue(RefreshMarketData::class, ['--user' => $this->backupImportModel->user_id, '--force' => true])
|
||||
->chain([
|
||||
fn() => Artisan::call(RefreshDividendData::class, ['--user' => $this->backupImportModel->user_id, '--force' => true]),
|
||||
fn() => Artisan::call(SyncHoldingData::class, ['--user' => $this->backupImportModel->user_id]),
|
||||
fn() => User::find($this->backupImportModel->user_id)->portfolios->each(function($portfolio) {
|
||||
fn () => Artisan::call(RefreshDividendData::class, ['--user' => $this->backupImportModel->user_id, '--force' => true]),
|
||||
fn () => Artisan::call(SyncHoldingData::class, ['--user' => $this->backupImportModel->user_id]),
|
||||
fn () => User::find($this->backupImportModel->user_id)->portfolios->each(function ($portfolio) {
|
||||
|
||||
Artisan::queue(SyncDailyChange::class, ['portfolio_id' => $portfolio->id]);
|
||||
})
|
||||
}),
|
||||
]);
|
||||
},
|
||||
ImportFailed::class => fn(ImportFailed $event) => $this->backupImportModel->update([
|
||||
ImportFailed::class => fn (ImportFailed $event) => $this->backupImportModel->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Error: '. substr($event->getException()->getMessage(), 0, 220),
|
||||
'message' => 'Error: '.substr($event->getException()->getMessage(), 0, 220),
|
||||
'has_errors' => true,
|
||||
'completed_at' => now()
|
||||
'completed_at' => now(),
|
||||
]),
|
||||
];
|
||||
}
|
||||
@@ -71,6 +70,7 @@ class BackupImport implements WithMultipleSheets, WithEvents
|
||||
'Portfolios' => new PortfoliosSheet($this->backupImportModel),
|
||||
'Transactions' => new TransactionsSheet($this->backupImportModel),
|
||||
'Daily Changes' => new DailyChangesSheet($this->backupImportModel),
|
||||
'Config' => new ConfigSheet($this->backupImportModel),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Imports\Sheets;
|
||||
|
||||
use App\Models\BackupImport;
|
||||
use App\Models\Holding;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||
use Maatwebsite\Excel\Events\BeforeSheet;
|
||||
|
||||
class ConfigSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
|
||||
{
|
||||
public function __construct(
|
||||
public BackupImport $backupImport
|
||||
) {}
|
||||
|
||||
public function registerEvents(): array
|
||||
{
|
||||
return [
|
||||
BeforeSheet::class => function (BeforeSheet $event) {
|
||||
DB::commit();
|
||||
$this->backupImport->update([
|
||||
'message' => __('Importing configurations...'),
|
||||
]);
|
||||
DB::beginTransaction();
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public function collection(Collection $configs)
|
||||
{
|
||||
foreach ($configs as $config) {
|
||||
|
||||
switch ($config['key']) {
|
||||
case 'name':
|
||||
$this->backupImport->user->setAttribute('name', $config['value']);
|
||||
$this->backupImport->user->save();
|
||||
break;
|
||||
|
||||
case 'locale':
|
||||
$this->backupImport->user->setOption('locale', $config['value']);
|
||||
$this->backupImport->user->save();
|
||||
break;
|
||||
|
||||
case 'display_currency':
|
||||
$this->backupImport->user->setOption('display_currency', $config['value']);
|
||||
$this->backupImport->user->save();
|
||||
break;
|
||||
|
||||
case 'reinvested_dividends':
|
||||
if (json_validate($config['value'])) {
|
||||
foreach (json_decode($config['value'], true) as $reinvest) {
|
||||
Holding::myHoldings($this->backupImport->user->id)
|
||||
->where('portfolio_id', $reinvest['portfolio_id'])
|
||||
->where('symbol', $reinvest['symbol'])
|
||||
->update([
|
||||
'reinvest_dividends' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'key' => ['required', 'string'],
|
||||
'value' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,62 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Imports\Sheets;
|
||||
|
||||
use App\Imports\ValidatesPortfolioAccess;
|
||||
use App\Models\DailyChange;
|
||||
use App\Models\BackupImport;
|
||||
use App\Models\DailyChange;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Maatwebsite\Excel\Events\BeforeSheet;
|
||||
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||
use Maatwebsite\Excel\Events\BeforeSheet;
|
||||
|
||||
class DailyChangesSheet implements ToCollection, WithHeadingRow, WithValidation, SkipsEmptyRows, WithEvents
|
||||
class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
|
||||
{
|
||||
use ValidatesPortfolioAccess;
|
||||
|
||||
public function __construct(
|
||||
public BackupImport $backupImport
|
||||
) { }
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function registerEvents(): array
|
||||
{
|
||||
return [
|
||||
BeforeSheet::class => function(BeforeSheet $event) {
|
||||
BeforeSheet::class => function (BeforeSheet $event) {
|
||||
DB::commit();
|
||||
$this->backupImport->update([
|
||||
'message' => __('Importing daily changes...'),
|
||||
'message' => __('Preparing to import daily changes...'),
|
||||
]);
|
||||
DB::beginTransaction();
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
public function collection(Collection $dailyChanges)
|
||||
{
|
||||
$dailyChanges->chunk($this->batchSize())->each(function ($chunk) {
|
||||
$totalBatches = count($dailyChanges) / $this->batchSize();
|
||||
|
||||
$dailyChanges->chunk($this->batchSize())->each(function ($chunk, $index) use ($totalBatches) {
|
||||
|
||||
$this->validatePortfolioAccess($chunk);
|
||||
|
||||
$this->backupImport->update([
|
||||
'message' => __('Importing daily changes (Batch :currentBatch of :totalBatches)...', ['currentBatch' => $index + 1, 'totalBatches' => $totalBatches]),
|
||||
]);
|
||||
|
||||
// have to cast to native values
|
||||
$chunk = $chunk->map(function ($dailyChange) {
|
||||
|
||||
return [
|
||||
'total_market_value' => $dailyChange['total_market_value'],
|
||||
'total_cost_basis' => $dailyChange['total_cost_basis'],
|
||||
'total_gain' => $dailyChange['total_gain'],
|
||||
'total_dividends_earned' => $dailyChange['total_dividends_earned'],
|
||||
'realized_gains' => $dailyChange['realized_gains'],
|
||||
'annotation' => $dailyChange['annotation'],
|
||||
'portfolio_id' => $dailyChange['portfolio_id'],
|
||||
'date' => Carbon::parse($dailyChange['date'])->format('Y-m-d')
|
||||
'date' => Carbon::parse($dailyChange['date'])->toDateString(),
|
||||
];
|
||||
});
|
||||
|
||||
@@ -64,14 +64,9 @@ class DailyChangesSheet implements ToCollection, WithHeadingRow, WithValidation,
|
||||
$chunk->toArray(),
|
||||
['portfolio_id', 'date'],
|
||||
[
|
||||
'total_market_value',
|
||||
'total_cost_basis',
|
||||
'total_gain',
|
||||
'total_dividends_earned',
|
||||
'realized_gains',
|
||||
'annotation',
|
||||
'portfolio_id',
|
||||
'date'
|
||||
'date',
|
||||
]
|
||||
);
|
||||
});
|
||||
@@ -85,13 +80,8 @@ class DailyChangesSheet implements ToCollection, WithHeadingRow, WithValidation,
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'portfolio_id' => ['required', 'uuid'],
|
||||
'portfolio_id' => ['required', 'uuid'],
|
||||
'date' => ['required', 'date'],
|
||||
'total_market_value' => ['sometimes', 'nullable', 'numeric'],
|
||||
'total_cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
||||
'total_gain' => ['sometimes', 'nullable', 'numeric'],
|
||||
'total_dividends_earned' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
||||
'realized_gains' => ['sometimes', 'nullable', 'numeric'],
|
||||
'annotation' => ['sometimes', 'nullable', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,37 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Imports\Sheets;
|
||||
|
||||
use App\Models\Portfolio;
|
||||
use App\Models\BackupImport;
|
||||
use App\Models\Portfolio;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Maatwebsite\Excel\Events\BeforeSheet;
|
||||
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||
use Maatwebsite\Excel\Events\BeforeSheet;
|
||||
|
||||
class PortfoliosSheet implements ToCollection, WithValidation, WithHeadingRow, SkipsEmptyRows, WithEvents
|
||||
class PortfoliosSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
|
||||
{
|
||||
public function __construct(
|
||||
public BackupImport $backupImport
|
||||
) { }
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
) {}
|
||||
|
||||
public function registerEvents(): array
|
||||
{
|
||||
return [
|
||||
BeforeSheet::class => function(BeforeSheet $event) {
|
||||
BeforeSheet::class => function (BeforeSheet $event) {
|
||||
DB::commit();
|
||||
$this->backupImport->update([
|
||||
'message' => __('Importing portfolios...'),
|
||||
]);
|
||||
DB::beginTransaction();
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -42,7 +41,7 @@ class PortfoliosSheet implements ToCollection, WithValidation, WithHeadingRow, S
|
||||
Portfolio::unguard(); // ensures we can set an owner for the portfolio
|
||||
|
||||
$portfolio = Portfolio::fullAccess($this->backupImport->user_id)->updateOrCreate([
|
||||
'id' => $portfolio['portfolio_id']
|
||||
'id' => $portfolio['portfolio_id'],
|
||||
], [
|
||||
'id' => $portfolio['portfolio_id'] ?? null,
|
||||
'title' => $portfolio['title'],
|
||||
|
||||
@@ -1,57 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Imports\Sheets;
|
||||
|
||||
use App\Imports\ValidatesPortfolioAccess;
|
||||
use App\Models\BackupImport;
|
||||
use App\Models\Currency;
|
||||
use App\Models\CurrencyRate;
|
||||
use App\Models\Holding;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\BackupImport;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Maatwebsite\Excel\Events\BeforeSheet;
|
||||
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||
use Illuminate\Support\Str;
|
||||
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||
use Maatwebsite\Excel\Events\BeforeSheet;
|
||||
|
||||
class TransactionsSheet implements ToCollection, WithHeadingRow, WithValidation, SkipsEmptyRows, WithEvents
|
||||
class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
|
||||
{
|
||||
|
||||
use ValidatesPortfolioAccess;
|
||||
|
||||
public function __construct(
|
||||
public BackupImport $backupImport
|
||||
) { }
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function registerEvents(): array
|
||||
{
|
||||
return [
|
||||
BeforeSheet::class => function(BeforeSheet $event) {
|
||||
BeforeSheet::class => function (BeforeSheet $event) {
|
||||
DB::commit();
|
||||
$this->backupImport->update([
|
||||
'message' => __('Importing transactions...'),
|
||||
'message' => __('Preparing to import transactions...'),
|
||||
]);
|
||||
DB::beginTransaction();
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public function collection(Collection $transactions)
|
||||
{
|
||||
|
||||
$transactions->chunk($this->batchSize())->each(function ($chunk) {
|
||||
// if has any transactions not in base currency, need to sync timeseries conversion rates
|
||||
if ($transactions->where('currency', '!=', config('investbrain.base_currency'))->isNotEmpty()) {
|
||||
|
||||
CurrencyRate::timeSeriesRates('', $transactions->min('date'), $transactions->max('date'));
|
||||
}
|
||||
|
||||
$totalBatches = count($transactions) / $this->batchSize();
|
||||
|
||||
// chunk transactions
|
||||
$transactions->chunk($this->batchSize())->each(function ($chunk, $index) use ($totalBatches) {
|
||||
|
||||
$this->backupImport->update([
|
||||
'message' => __('Importing transactions (Batch :currentBatch of :totalBatches)...', ['currentBatch' => $index + 1, 'totalBatches' => $totalBatches]),
|
||||
]);
|
||||
|
||||
$this->validatePortfolioAccess($chunk);
|
||||
|
||||
// have to cast to native values
|
||||
$chunk = $chunk->map(function ($transaction) {
|
||||
|
||||
$date = Carbon::parse($transaction['date'])->toDateString();
|
||||
|
||||
// if transaction not in base currency, need to convert
|
||||
if ($transaction['currency'] == config('investbrain.base_currency')) {
|
||||
$cost_basis_base = $transaction['cost_basis'] ?? 0;
|
||||
$sale_price_base = $transaction['sale_price'];
|
||||
} else {
|
||||
$cost_basis_base = Currency::convert($transaction['cost_basis'], $transaction['currency'], date: $date);
|
||||
$sale_price_base = Currency::convert($transaction['sale_price'], $transaction['currency'], date: $date);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $transaction['transaction_id'] ?? Str::uuid()->toString(),
|
||||
'symbol' => strtoupper($transaction['symbol']),
|
||||
@@ -60,9 +84,11 @@ class TransactionsSheet implements ToCollection, WithHeadingRow, WithValidation,
|
||||
'quantity' => $transaction['quantity'],
|
||||
'cost_basis' => $transaction['cost_basis'] ?? 0,
|
||||
'sale_price' => $transaction['sale_price'],
|
||||
'cost_basis_base' => $cost_basis_base,
|
||||
'sale_price_base' => $sale_price_base,
|
||||
'split' => boolval($transaction['split']) ? 1 : 0,
|
||||
'reinvested_dividend' => boolval($transaction['reinvested_dividend']) ? 1 : 0,
|
||||
'date' => Carbon::parse($transaction['date'])->format('Y-m-d')
|
||||
'date' => $date,
|
||||
];
|
||||
});
|
||||
|
||||
@@ -79,23 +105,23 @@ class TransactionsSheet implements ToCollection, WithHeadingRow, WithValidation,
|
||||
'sale_price',
|
||||
'split',
|
||||
'reinvested_dividend',
|
||||
'date'
|
||||
'date',
|
||||
]
|
||||
);
|
||||
|
||||
// stub out related holdings
|
||||
$chunk->unique(fn($item) => $item['symbol'] . $item['portfolio_id'])
|
||||
->each(function($holding) {
|
||||
|
||||
Holding::firstOrCreate([
|
||||
'symbol' => $holding['symbol'],
|
||||
'portfolio_id' => $holding['portfolio_id']
|
||||
], [
|
||||
'quantity' => 0,
|
||||
'average_cost_basis' => 0,
|
||||
'splits_synced_at' => now(),
|
||||
]);
|
||||
});
|
||||
// get unique symbol/portfolio id combination and stub out related holdings
|
||||
$chunk->unique(fn ($item) => $item['symbol'].$item['portfolio_id'])
|
||||
->each(function ($holding) {
|
||||
|
||||
Holding::firstOrCreate([
|
||||
'symbol' => $holding['symbol'],
|
||||
'portfolio_id' => $holding['portfolio_id'],
|
||||
], [
|
||||
'quantity' => 0,
|
||||
'average_cost_basis' => 0,
|
||||
'splits_synced_at' => now(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,6 +140,7 @@ class TransactionsSheet implements ToCollection, WithHeadingRow, WithValidation,
|
||||
'transaction_type' => ['required', 'in:BUY,SELL'],
|
||||
'date' => ['required', 'date'],
|
||||
'quantity' => ['required', 'min:0', 'numeric'],
|
||||
'currency' => ['required', 'string'],
|
||||
'split' => ['sometimes', 'nullable', 'boolean'],
|
||||
'reinvested_dividend' => ['sometimes', 'nullable', 'boolean'],
|
||||
'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Imports;
|
||||
|
||||
use App\Models\Portfolio;
|
||||
|
||||
trait ValidatesPortfolioAccess
|
||||
{
|
||||
|
||||
public function validatePortfolioAccess($collection)
|
||||
{
|
||||
|
||||
$uniquePortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id');
|
||||
$countPortfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id)
|
||||
->whereIn('id', $uniquePortfolios)
|
||||
->count();
|
||||
$importingPortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id');
|
||||
$portfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id)
|
||||
->whereIn('id', $importingPortfolios)
|
||||
->count();
|
||||
|
||||
if (
|
||||
$countPortfoliosWithAccess < $uniquePortfolios->count()
|
||||
$importingPortfolios->count() > $portfoliosWithAccess
|
||||
) {
|
||||
throw new \Exception(__("You do not have access to that portfolio."));
|
||||
throw new \Exception(__('You do not have access to that portfolio.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Interfaces\MarketData;
|
||||
|
||||
use App\Interfaces\MarketData\Types\Dividend;
|
||||
use App\Interfaces\MarketData\Types\Ohlc;
|
||||
use App\Interfaces\MarketData\Types\Quote;
|
||||
use App\Interfaces\MarketData\Types\Split;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use App\Interfaces\MarketData\Types\Quote;
|
||||
use App\Interfaces\MarketData\Types\Split;
|
||||
use App\Interfaces\MarketData\Types\Dividend;
|
||||
use App\Interfaces\MarketData\Types\Ohlc;
|
||||
use Tschucki\Alphavantage\Facades\Alphavantage;
|
||||
|
||||
class AlphaVantageMarketData implements MarketDataInterface
|
||||
{
|
||||
public function exists(String $symbol): Bool
|
||||
public function exists(string $symbol): bool
|
||||
{
|
||||
|
||||
return $this->quote($symbol)->isNotEmpty();
|
||||
return (bool) $this->quote($symbol);
|
||||
}
|
||||
|
||||
public function quote(String $symbol): Quote
|
||||
public function quote(string $symbol): Quote
|
||||
{
|
||||
|
||||
$search = Alphavantage::core()->search($symbol);
|
||||
$search = Arr::get($search, 'bestMatches.0', null);
|
||||
|
||||
if (Arr::get($search, '9. matchScore') !== '1.0000') {
|
||||
throw new \Exception('Could not find ticker on Alphavantage');
|
||||
}
|
||||
|
||||
$quote = Alphavantage::core()->quoteEndpoint($symbol);
|
||||
$quote = Arr::get($quote, 'Global Quote', []);
|
||||
|
||||
if (empty($quote)) return new Quote();
|
||||
|
||||
$fundamental = cache()->remember(
|
||||
'av-symbol-'.$symbol,
|
||||
1440,
|
||||
function () use ($symbol) {
|
||||
return Alphavantage::fundamentals()->overview($symbol);
|
||||
'av-symbol-'.$symbol,
|
||||
1440,
|
||||
function () use ($symbol, $search) {
|
||||
if (Arr::get($search, '3. type') === 'Equity') {
|
||||
|
||||
$fundamental = (array) Alphavantage::fundamentals()->overview($symbol);
|
||||
} else {
|
||||
|
||||
$fundamental = (array) Alphavantage::fundamentals()->etfProfile($symbol);
|
||||
|
||||
Arr::set($fundamental, 'DividendYield', Arr::get($fundamental, 'dividend_yield'));
|
||||
Arr::set($fundamental, 'MarketCapitalization', Arr::get($fundamental, 'net_assets'));
|
||||
Arr::set($fundamental, 'InceptionDate', Arr::get($fundamental, 'inception_date'));
|
||||
}
|
||||
|
||||
return $fundamental;
|
||||
}
|
||||
);
|
||||
|
||||
return new Quote([
|
||||
'name' => Arr::get($fundamental, 'Name'),
|
||||
'symbol' => Arr::get($fundamental, 'Symbol'),
|
||||
'market_value' => Arr::get($quote, '05. price'),
|
||||
'fifty_two_week_high' => Arr::get($fundamental, '52WeekHigh'),
|
||||
'fifty_two_week_low' => Arr::get($fundamental, '52WeekLow'),
|
||||
'name' => Arr::get($search, '2. name'),
|
||||
'symbol' => $symbol,
|
||||
'market_value' => (float) Arr::get($quote, '05. price'),
|
||||
'currency' => Arr::get($search, '8. currency'),
|
||||
'fifty_two_week_high' => (float) Arr::get($fundamental, '52WeekHigh'),
|
||||
'fifty_two_week_low' => (float) Arr::get($fundamental, '52WeekLow'),
|
||||
'forward_pe' => Arr::get($fundamental, 'ForwardPE'),
|
||||
'trailing_pe' => Arr::get($fundamental, 'TrailingPE'),
|
||||
'market_cap' => Arr::get($fundamental, 'MarketCapitalization'),
|
||||
@@ -48,72 +69,84 @@ class AlphaVantageMarketData implements MarketDataInterface
|
||||
? Arr::get($fundamental, 'DividendDate')
|
||||
: null,
|
||||
'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None'
|
||||
? Arr::get($fundamental, 'DividendYield')
|
||||
: null
|
||||
]);
|
||||
? Arr::get($fundamental, 'DividendYield') * 100
|
||||
: null,
|
||||
'meta_data' => [
|
||||
'industry' => Arr::get($fundamental, 'Industry'),
|
||||
'country' => Arr::get($search, '4. region'),
|
||||
'exchange' => Arr::get($fundamental, 'Exchange'),
|
||||
'description' => Arr::get($fundamental, 'Description'),
|
||||
'asset_type' => Arr::get($search, '3. type'),
|
||||
'sector' => Arr::get($fundamental, 'Sector'),
|
||||
'first_trade_year' => Arr::get($fundamental, 'InceptionDate')
|
||||
? Carbon::parse(Arr::get($fundamental, 'InceptionDate'))->format('Y')
|
||||
: null,
|
||||
'source' => 'alphavantage',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function dividends(String $symbol, $startDate, $endDate): Collection
|
||||
public function dividends(string $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
$dividends = Alphavantage::fundamentals()->dividends($symbol);
|
||||
$dividends = Arr::get($dividends, 'data', []);
|
||||
|
||||
return collect($dividends)
|
||||
->filter(function($dividend) use ($startDate, $endDate) {
|
||||
|
||||
return Carbon::parse(Arr::get($dividend, 'ex_dividend_date'))->between($startDate, $endDate);
|
||||
})
|
||||
->map(function($dividend) use ($symbol) {
|
||||
|
||||
return new Dividend([
|
||||
'symbol' => $symbol,
|
||||
'date' => Carbon::parse(Arr::get($dividend, 'ex_dividend_date')),
|
||||
'dividend_amount' => Arr::get($dividend, 'amount'),
|
||||
]);
|
||||
});
|
||||
->filter(function ($dividend) use ($startDate, $endDate) {
|
||||
|
||||
return Carbon::parse(Arr::get($dividend, 'ex_dividend_date'))->between($startDate, $endDate);
|
||||
})
|
||||
->map(function ($dividend) use ($symbol) {
|
||||
|
||||
return new Dividend([
|
||||
'symbol' => $symbol,
|
||||
'date' => Carbon::parse(Arr::get($dividend, 'ex_dividend_date')),
|
||||
'dividend_amount' => Arr::get($dividend, 'amount'),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function splits(String $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
public function splits(string $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
$splits = Alphavantage::fundamentals()->splits($symbol);
|
||||
$splits = Arr::get($splits, 'data', []);
|
||||
|
||||
return collect($splits)
|
||||
->filter(function($split) use ($startDate, $endDate) {
|
||||
|
||||
return Carbon::parse(Arr::get($split, 'effective_date'))->between($startDate, $endDate);
|
||||
})
|
||||
->map(function($split) use ($symbol) {
|
||||
|
||||
return new Split([
|
||||
'symbol' => $symbol,
|
||||
'date' => Carbon::parse(Arr::get($split, 'effective_date')),
|
||||
'split_amount' => Arr::get($split, 'split_factor'),
|
||||
]);
|
||||
});
|
||||
->filter(function ($split) use ($startDate, $endDate) {
|
||||
|
||||
return Carbon::parse(Arr::get($split, 'effective_date'))->between($startDate, $endDate);
|
||||
})
|
||||
->map(function ($split) use ($symbol) {
|
||||
|
||||
return new Split([
|
||||
'symbol' => $symbol,
|
||||
'date' => Carbon::parse(Arr::get($split, 'effective_date')),
|
||||
'split_amount' => Arr::get($split, 'split_factor'),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function history(String $symbol, $startDate, $endDate): Collection
|
||||
public function history(string $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
|
||||
$history = Alphavantage::timeSeries()->daily($symbol, 'full');
|
||||
|
||||
$history = Arr::get($history, 'Time Series (Daily)', []);
|
||||
|
||||
|
||||
return collect($history)
|
||||
->filter(function ($history, $date) use ($startDate, $endDate) {
|
||||
->filter(function ($history, $date) use ($startDate, $endDate) {
|
||||
|
||||
return Carbon::parse($date)->between($startDate, $endDate);
|
||||
})
|
||||
->mapWithKeys(function($history, $date) use ($symbol) {
|
||||
return Carbon::parse($date)->between($startDate, $endDate);
|
||||
})
|
||||
->mapWithKeys(function ($history, $date) use ($symbol) {
|
||||
|
||||
$date = Carbon::parse($date)->format('Y-m-d');
|
||||
|
||||
return [ $date => new Ohlc([
|
||||
'symbol' => $symbol,
|
||||
'date' => $date,
|
||||
'close' => Arr::get($history, '4. close')
|
||||
]) ];
|
||||
});
|
||||
$date = Carbon::parse($date)->toDateString();
|
||||
|
||||
return [$date => new Ohlc([
|
||||
'symbol' => $symbol,
|
||||
'date' => $date,
|
||||
'close' => Arr::get($history, '4. close'),
|
||||
])];
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Interfaces\MarketData;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use App\Interfaces\MarketData\Types\Quote;
|
||||
use App\Interfaces\MarketData\Types\Dividend;
|
||||
use App\Interfaces\MarketData\Types\Ohlc;
|
||||
use App\Interfaces\MarketData\Types\Quote;
|
||||
use App\Interfaces\MarketData\Types\Split;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class FakeMarketData implements MarketDataInterface
|
||||
{
|
||||
public function exists(String $symbol): Bool
|
||||
public function exists(string $symbol): bool
|
||||
{
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function quote(String $symbol): Quote
|
||||
public function quote(string $symbol): Quote
|
||||
{
|
||||
|
||||
return new Quote([
|
||||
'name' => 'ACME Company Ltd',
|
||||
'symbol' => $symbol,
|
||||
'currency' => 'USD',
|
||||
'market_value' => 230.19,
|
||||
'fifty_two_week_high' => 512.90,
|
||||
'fifty_two_week_low' => 341.20,
|
||||
@@ -31,11 +35,12 @@ class FakeMarketData implements MarketDataInterface
|
||||
'market_cap' => 9800700600,
|
||||
'book_value' => 4.7,
|
||||
'last_dividend_date' => now()->subDays(45),
|
||||
'dividend_yield' => 0.033
|
||||
'dividend_yield' => 0.033,
|
||||
'meta_data' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function dividends(String $symbol, $startDate, $endDate): Collection
|
||||
public function dividends(string $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
|
||||
return collect([
|
||||
@@ -57,33 +62,44 @@ class FakeMarketData implements MarketDataInterface
|
||||
]);
|
||||
}
|
||||
|
||||
public function splits(String $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
public function splits(string $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
|
||||
return collect([
|
||||
new Split([
|
||||
'symbol' => $symbol,
|
||||
'date' => now()->subMonths(36),
|
||||
'date' => now()->subMonths(12),
|
||||
'split_amount' => 10,
|
||||
])
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function history(String $symbol, $startDate, $endDate): Collection
|
||||
public function history(string $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
$numDays = Carbon::parse($startDate)->diffInDays($endDate, true);
|
||||
$endDate = now()->isBefore(Carbon::parse(config('investbrain.daily_change_time_of_day')))
|
||||
? now()->subDay()
|
||||
: now();
|
||||
|
||||
for ($i = 0; $i < $numDays; $i++) {
|
||||
$days = CarbonPeriod::create($startDate, $endDate)->filter('isWeekday');
|
||||
|
||||
$date = now()->subDays($i)->format('Y-m-d');
|
||||
$countOfDays = $days->count();
|
||||
|
||||
foreach ($days as $index => $date) {
|
||||
|
||||
$date = $date->toDateString();
|
||||
|
||||
$series[$date] = new Ohlc([
|
||||
'symbol' => $symbol,
|
||||
'date' => $date,
|
||||
'close' => rand(150, 400),
|
||||
'open' => rand(150, 400),
|
||||
'high' => rand(150, 400),
|
||||
'low' => rand(150, 400),
|
||||
'close' => $index == $countOfDays - 1
|
||||
? 230.19 // most recent close should match current market value
|
||||
: rand(150, 400),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
return collect($series);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Interfaces\MarketData;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class FallbackInterface
|
||||
{
|
||||
|
||||
protected string $latest_error;
|
||||
|
||||
public function __call($method, $arguments)
|
||||
{
|
||||
|
||||
$providers = explode(',', config('investbrain.provider', 'yahoo'));
|
||||
|
||||
|
||||
foreach ($providers as $provider) {
|
||||
|
||||
$provider = trim($provider);
|
||||
$symbol = $arguments[0];
|
||||
|
||||
try {
|
||||
Log::info("Calling method {$method} for {$symbol} ({$provider})");
|
||||
|
||||
if (!in_array($provider, array_keys(config('investbrain.interfaces', [])))) {
|
||||
if (! in_array($provider, array_keys(config('investbrain.interfaces', [])))) {
|
||||
|
||||
throw new \Exception("Provider [{$provider}] is not a valid market data interface.");
|
||||
}
|
||||
@@ -30,13 +33,20 @@ class FallbackInterface
|
||||
return app()->make($provider_class_name)->$method(...$arguments);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
|
||||
$this->latest_error = $e->getMessage();
|
||||
|
||||
Log::warning("Failed calling method {$method} ({$provider}): {$this->latest_error}");
|
||||
Log::error("Failed calling method {$method} for {$symbol} ({$provider}): {$this->latest_error}");
|
||||
}
|
||||
}
|
||||
|
||||
throw new \Exception("Could not get market data: {$this->latest_error}");
|
||||
// don't need to throw error if calling exists method...
|
||||
if ($method == 'exists') {
|
||||
|
||||
// symbol prob just doesn't exist
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new \Exception("Could not get market data calling method {$method}: {$this->latest_error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Interfaces\MarketData;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use App\Interfaces\MarketData\Types\Dividend;
|
||||
use App\Interfaces\MarketData\Types\Ohlc;
|
||||
use App\Interfaces\MarketData\Types\Quote;
|
||||
use App\Interfaces\MarketData\Types\Split;
|
||||
use App\Interfaces\MarketData\Types\Dividend;
|
||||
use Finnhub\ObjectSerializer;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class FinnhubMarketData implements MarketDataInterface
|
||||
{
|
||||
@@ -16,53 +19,66 @@ class FinnhubMarketData implements MarketDataInterface
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
|
||||
|
||||
$this->client = new \Finnhub\Api\DefaultApi(
|
||||
new \GuzzleHttp\Client(),
|
||||
new \GuzzleHttp\Client,
|
||||
\Finnhub\Configuration::getDefaultConfiguration()->setApiKey('token', config('finnhub.key'))
|
||||
);
|
||||
}
|
||||
public function exists(String $symbol): Bool
|
||||
|
||||
public function exists(string $symbol): bool
|
||||
{
|
||||
|
||||
return $this->quote($symbol)->isNotEmpty();
|
||||
return (bool) $this->quote($symbol);
|
||||
}
|
||||
|
||||
public function quote(string $symbol): Quote
|
||||
{
|
||||
$quote = $this->client->quote($symbol);
|
||||
|
||||
if (empty($quote)) return new Quote();
|
||||
|
||||
if (is_null(Arr::get($quote, 'd'))) {
|
||||
throw new \Exception('Could not find ticker on Finnhub');
|
||||
}
|
||||
|
||||
$fundamental = cache()->remember(
|
||||
'fh-symbol-'.$symbol,
|
||||
1440,
|
||||
'fh-symbol-'.$symbol,
|
||||
1440,
|
||||
function () use ($symbol) {
|
||||
return $this->client->companyBasicFinancials($symbol, "all");
|
||||
|
||||
return array_merge(
|
||||
(array) ObjectSerializer::sanitizeForSerialization($this->client->companyProfile2($symbol)),
|
||||
(array) ObjectSerializer::sanitizeForSerialization($this->client->companyBasicFinancials($symbol, 'all')),
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
return new Quote([
|
||||
'name' => Arr::get($fundamental, 'metric.name'),
|
||||
'name' => Arr::get($fundamental, 'name'),
|
||||
'symbol' => $symbol,
|
||||
'market_value' => Arr::get($quote, 'c'),
|
||||
'currency' => Arr::get($fundamental, 'currency'),
|
||||
'market_value' => Arr::get($quote, 'c'),
|
||||
'fifty_two_week_high' => Arr::get($fundamental, 'metric.52WeekHigh'),
|
||||
'fifty_two_week_low' => Arr::get($fundamental, 'metric.52WeekLow'),
|
||||
'forward_pe' => Arr::get($fundamental, 'metric.forwardPE'), // confirm
|
||||
'trailing_pe' => Arr::get($fundamental, 'metric.trailingPE'), // confirm
|
||||
'market_cap' => Arr::get($fundamental, 'metric.marketCapitalization'), // confirm
|
||||
'book_value' => Arr::get($fundamental, 'metric.bookValuePerShare'), // confirm
|
||||
'last_dividend_date' => Arr::get($fundamental, 'metric.lastDivDate'), // confirm
|
||||
'dividend_yield' => Arr::get($fundamental, 'metric.dividendYield'), // confirm
|
||||
]);
|
||||
'forward_pe' => Arr::get($fundamental, 'metric.peAnnual'),
|
||||
'trailing_pe' => Arr::get($fundamental, 'metric.peTTM'),
|
||||
'market_cap' => Arr::get($fundamental, 'metric.marketCapitalization', 0) * 1000000,
|
||||
'book_value' => Arr::get($fundamental, 'metric.bookValuePerShareAnnual'),
|
||||
'dividend_yield' => Arr::get($fundamental, 'metric.dividendYieldIndicatedAnnual'),
|
||||
'meta_data' => [
|
||||
'country' => Arr::get($fundamental, 'country'),
|
||||
'exchange' => Arr::get($fundamental, 'exchange'),
|
||||
'first_trade_year' => Arr::get($fundamental, 'ipo') ? Carbon::parse(Arr::get($fundamental, 'ipo'))->format('Y') : null,
|
||||
'source' => 'finnhub',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function dividends($symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
$dividends = $this->client->stockDividends($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d'));
|
||||
|
||||
return collect($dividends)->map(function($dividend) use ($symbol) {
|
||||
|
||||
$dividends = $this->client->stockDividends($symbol, $startDate->toDateString(), $endDate->toDateString());
|
||||
|
||||
return collect($dividends)->map(function ($dividend) use ($symbol) {
|
||||
|
||||
return new Dividend([
|
||||
'symbol' => $symbol,
|
||||
'date' => Carbon::parse(Arr::get($dividend, 'date')),
|
||||
@@ -72,12 +88,12 @@ class FinnhubMarketData implements MarketDataInterface
|
||||
}
|
||||
|
||||
public function splits($symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
{
|
||||
|
||||
$splits = $this->client->stockSplits($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d'));
|
||||
$splits = $this->client->stockSplits($symbol, $startDate->toDateString(), $endDate->toDateString());
|
||||
|
||||
return collect($splits)->map(function ($split) use ($symbol) {
|
||||
|
||||
return collect($splits)->map(function($split) use ($symbol) {
|
||||
|
||||
return new Split([
|
||||
'symbol' => $symbol,
|
||||
'date' => Carbon::parse(Arr::get($split, 'date')),
|
||||
@@ -89,18 +105,19 @@ class FinnhubMarketData implements MarketDataInterface
|
||||
public function history($symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
|
||||
$history = $this->client->stockCandles($symbol, "D", $startDate->timestamp, $endDate->timestamp);
|
||||
$history = $this->client->stockCandles($symbol, 'D', $startDate->timestamp, $endDate->timestamp);
|
||||
|
||||
$timestamps = Arr::get($history, 't', []);
|
||||
$closes = Arr::get($history, 'c', []);
|
||||
|
||||
return collect($timestamps)->mapWithKeys(function ($timestamp, $index) use ($symbol, $closes) {
|
||||
$date = Carbon::createFromTimestamp($timestamp)->format('Y-m-d');
|
||||
return [ $date => new Ohlc([
|
||||
$date = Carbon::createFromTimestamp($timestamp)->toDateString();
|
||||
|
||||
return [$date => new Ohlc([
|
||||
'symbol' => $symbol,
|
||||
'date' => $date,
|
||||
'close' => $closes[$index],
|
||||
]) ];
|
||||
])];
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Interfaces\MarketData;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use App\Interfaces\MarketData\Types\Quote;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
interface MarketDataInterface
|
||||
{
|
||||
/**
|
||||
* Does this symbol actually exist?
|
||||
*
|
||||
* @param String $symbol
|
||||
*
|
||||
* @return Bool
|
||||
*/
|
||||
public function exists(String $symbol): Bool;
|
||||
public function exists(string $symbol): bool;
|
||||
|
||||
/**
|
||||
* Get quote data
|
||||
*
|
||||
* @param String $symbol
|
||||
*
|
||||
* @return Quote
|
||||
*/
|
||||
public function quote(String $symbol): Quote;
|
||||
public function quote(string $symbol): Quote;
|
||||
|
||||
/**
|
||||
* Get dividend data
|
||||
*
|
||||
* @param String $symbol
|
||||
* @param \DateTimeInterface $startDate
|
||||
* @param \DateTimeInterface $endDate
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function dividends(String $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
|
||||
public function dividends(string $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
|
||||
|
||||
/**
|
||||
* Get split data
|
||||
*
|
||||
* @param String $symbol
|
||||
* @param \DateTimeInterface $startDate
|
||||
* @param \DateTimeInterface $endDate
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function splits(String $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
|
||||
public function splits(string $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
|
||||
|
||||
/**
|
||||
* Get historical close data
|
||||
*
|
||||
* @param String $symbol
|
||||
* @param \DateTimeInterface $startDate
|
||||
* @param \DateTimeInterface $endDate
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function history(String $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
|
||||
public function history(string $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Interfaces\MarketData\Types;
|
||||
|
||||
use DateTime;
|
||||
use Illuminate\Support\Carbon;
|
||||
use App\Interfaces\MarketData\Types\MarketDataType;
|
||||
|
||||
class Dividend extends MarketDataType
|
||||
{
|
||||
public function setSymbol(string $symbol): self
|
||||
{
|
||||
$this->items['symbol'] = $symbol;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -19,9 +21,10 @@ class Dividend extends MarketDataType
|
||||
return $this->items['symbol'] ?? '';
|
||||
}
|
||||
|
||||
public function setDividendAmount($dividendAmount): self
|
||||
public function setDividendAmount(int|float $dividendAmount): self
|
||||
{
|
||||
$this->items['dividend_amount'] = (float) $dividendAmount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -30,9 +33,10 @@ class Dividend extends MarketDataType
|
||||
return $this->items['dividend_amount'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setDate(String|DateTime $date): self
|
||||
public function setDate(string|DateTime $date): self
|
||||
{
|
||||
$this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s');
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -40,4 +44,4 @@ class Dividend extends MarketDataType
|
||||
{
|
||||
return $this->items['date'] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Interfaces\MarketData\Types;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MarketDataType extends Collection
|
||||
{
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public function __construct($items = [])
|
||||
{
|
||||
|
||||
foreach($this->getArrayableItems($items) as $key => $value) {
|
||||
$items = $this->getArrayableItems($items);
|
||||
|
||||
$this->{$key} = $value;
|
||||
foreach ($items as $key => $value) {
|
||||
|
||||
$this->validateRequiredTypes($key, $value);
|
||||
|
||||
if (! is_null($value)) {
|
||||
$this->{$key} = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function toArray()
|
||||
{
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
public function __set($key, $value)
|
||||
{
|
||||
$this->{'set'.Str::studly($key)}($value);
|
||||
|
||||
$this->{$this->getSetMethodName($key)}($value);
|
||||
}
|
||||
|
||||
public function __get($key)
|
||||
{
|
||||
return $this->items[$key] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getSetMethodName($key): string
|
||||
{
|
||||
return 'set'.Str::studly($key);
|
||||
}
|
||||
|
||||
protected function validateRequiredTypes($key, $value, $type = null): void
|
||||
{
|
||||
$method = new \ReflectionMethod($this, $this->getSetMethodName($key));
|
||||
$params = $method->getParameters();
|
||||
|
||||
// no required type
|
||||
if (is_null($type) && is_null($type = $params[0]->getType())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// can`t validate a mixed type
|
||||
if ($type == 'mixed') {
|
||||
return;
|
||||
}
|
||||
|
||||
// has a union type, let's iterate
|
||||
if ($type instanceof \ReflectionUnionType) {
|
||||
|
||||
foreach ($type->getTypes() as $subType) {
|
||||
$expected[] = $subType;
|
||||
|
||||
try {
|
||||
$this->validateRequiredTypes($key, $value, $subType);
|
||||
|
||||
return;
|
||||
} catch (\InvalidArgumentException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check type
|
||||
if ($type instanceof \ReflectionNamedType) {
|
||||
$expected = $type->getName();
|
||||
|
||||
if (get_debug_type($value) == $expected || ($type->allowsNull() && $value === null)) {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (class_exists($expected) && is_subclass_of(get_debug_type($value), $expected)) {
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException("Invalid type for {$key}. Expected ".implode('|', array_map(fn ($t) => $t, Arr::wrap($expected))).' but got '.get_debug_type($value));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Interfaces\MarketData\Types;
|
||||
|
||||
use DateTime;
|
||||
use Illuminate\Support\Carbon;
|
||||
use App\Interfaces\MarketData\Types\MarketDataType;
|
||||
|
||||
class Ohlc extends MarketDataType
|
||||
{
|
||||
public function setSymbol(string $symbol): self
|
||||
{
|
||||
$this->items['symbol'] = $symbol;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -19,9 +21,10 @@ class Ohlc extends MarketDataType
|
||||
return $this->items['symbol'] ?? '';
|
||||
}
|
||||
|
||||
public function setOpen($open): self
|
||||
public function setOpen(int|float $open): self
|
||||
{
|
||||
$this->items['open'] = (float) $open;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -30,9 +33,10 @@ class Ohlc extends MarketDataType
|
||||
return $this->items['open'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setHigh($high): self
|
||||
public function setHigh(int|float $high): self
|
||||
{
|
||||
$this->items['high'] = (float) $high;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -41,9 +45,10 @@ class Ohlc extends MarketDataType
|
||||
return $this->items['high'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setLow($low): self
|
||||
public function setLow(int|float $low): self
|
||||
{
|
||||
$this->items['low'] = (float) $low;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -52,9 +57,10 @@ class Ohlc extends MarketDataType
|
||||
return $this->items['low'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setClose($close): self
|
||||
public function setClose(int|float $close): self
|
||||
{
|
||||
$this->items['close'] = (float) $close;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -63,9 +69,10 @@ class Ohlc extends MarketDataType
|
||||
return $this->items['close'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setDate(String|DateTime $date): self
|
||||
public function setDate(string|DateTime $date): self
|
||||
{
|
||||
$this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s');
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -73,4 +80,4 @@ class Ohlc extends MarketDataType
|
||||
{
|
||||
return $this->items['date'] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Interfaces\MarketData\Types;
|
||||
|
||||
use DateTime;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use App\Interfaces\MarketData\Types\MarketDataType;
|
||||
|
||||
class Quote extends MarketDataType
|
||||
{
|
||||
{
|
||||
public function setName($name): self
|
||||
{
|
||||
$this->items['name'] = (string) $name;
|
||||
if (! empty($name)) {
|
||||
$this->items['name'] = (string) $name;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -19,9 +24,10 @@ class Quote extends MarketDataType
|
||||
return $this->items['name'] ?? '';
|
||||
}
|
||||
|
||||
public function setSymbol($symbol): self
|
||||
public function setSymbol(string $symbol): self
|
||||
{
|
||||
$this->items['symbol'] = (string) $symbol;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -30,9 +36,22 @@ class Quote extends MarketDataType
|
||||
return $this->items['symbol'] ?? '';
|
||||
}
|
||||
|
||||
public function setMarketValue($marketValue): self
|
||||
public function setCurrency(string $currency): self
|
||||
{
|
||||
$this->items['currency'] = strtoupper((string) $currency);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCurrency(): string
|
||||
{
|
||||
return $this->items['currency'] ?? '';
|
||||
}
|
||||
|
||||
public function setMarketValue(int|float $marketValue): self
|
||||
{
|
||||
$this->items['market_value'] = (float) $marketValue;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -41,9 +60,10 @@ class Quote extends MarketDataType
|
||||
return $this->items['market_value'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setFiftyTwoWeekHigh($high): self
|
||||
public function setFiftyTwoWeekHigh($high): self
|
||||
{
|
||||
$this->items['fifty_two_week_high'] = (float) $high;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -52,9 +72,10 @@ class Quote extends MarketDataType
|
||||
return $this->items['fifty_two_week_high'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setFiftyTwoWeekLow($low): self
|
||||
public function setFiftyTwoWeekLow($low): self
|
||||
{
|
||||
$this->items['fifty_two_week_low'] = (float) $low;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -63,9 +84,10 @@ class Quote extends MarketDataType
|
||||
return $this->items['fifty_two_week_low'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setForwardPE($pe): self
|
||||
public function setForwardPE($pe): self
|
||||
{
|
||||
$this->items['forward_pe'] = (float) $pe;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -74,9 +96,10 @@ class Quote extends MarketDataType
|
||||
return $this->items['forward_pe'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setTrailingPE($pe): self
|
||||
public function setTrailingPE($pe): self
|
||||
{
|
||||
$this->items['trailing_pe'] = (float) $pe;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -87,7 +110,9 @@ class Quote extends MarketDataType
|
||||
|
||||
public function setMarketCap($cap): self
|
||||
{
|
||||
// return $this;
|
||||
$this->items['market_cap'] = (int) $cap;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -96,9 +121,10 @@ class Quote extends MarketDataType
|
||||
return $this->items['market_cap'] ?? 0;
|
||||
}
|
||||
|
||||
public function setBookValue($value): self
|
||||
public function setBookValue($value): self
|
||||
{
|
||||
$this->items['book_value'] = (float) $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -107,9 +133,22 @@ class Quote extends MarketDataType
|
||||
return $this->items['book_value'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setLastDividendAmount($value): self
|
||||
{
|
||||
$this->items['last_dividend_amount'] = (float) $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastDividendAmount(): float
|
||||
{
|
||||
return $this->items['last_dividend_amount'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setLastDividendDate(mixed $date): self
|
||||
{
|
||||
$this->items['last_dividend_date'] = is_null($date) ? null : Carbon::parse($date)->format('Y-m-d H:i:s');
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -118,9 +157,10 @@ class Quote extends MarketDataType
|
||||
return $this->items['last_dividend_date'] ?? null;
|
||||
}
|
||||
|
||||
public function setDividendYield($yield): self
|
||||
public function setDividendYield($yield): self
|
||||
{
|
||||
$this->items['dividend_yield'] = (float) $yield;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -128,4 +168,28 @@ class Quote extends MarketDataType
|
||||
{
|
||||
return $this->items['dividend_yield'] ?? 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
public function setMetaData(array $meta_data): self
|
||||
{
|
||||
$defaults = [
|
||||
'sector' => null,
|
||||
'industry' => null,
|
||||
'country' => null,
|
||||
'exchange' => null,
|
||||
'description' => null,
|
||||
'asset_type' => null,
|
||||
'first_trade_year' => null,
|
||||
'source' => null,
|
||||
];
|
||||
|
||||
// merges the NEW values with highest priority over previous values and defaults
|
||||
$this->items['meta_data'] = array_merge($defaults, $this->items['meta_data'] ?? [], Arr::skipEmptyValues($meta_data));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMetaData(): array
|
||||
{
|
||||
return $this->items['meta_data'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Interfaces\MarketData\Types;
|
||||
|
||||
use DateTime;
|
||||
use Illuminate\Support\Carbon;
|
||||
use App\Interfaces\MarketData\Types\MarketDataType;
|
||||
|
||||
class Split extends MarketDataType
|
||||
{
|
||||
public function setSymbol(string $symbol): self
|
||||
{
|
||||
$this->items['symbol'] = $symbol;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -19,9 +21,10 @@ class Split extends MarketDataType
|
||||
return $this->items['symbol'] ?? '';
|
||||
}
|
||||
|
||||
public function setSplitAmount($splitAmount): self
|
||||
public function setSplitAmount(int|float $splitAmount): self
|
||||
{
|
||||
$this->items['split_amount'] = (float) $splitAmount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -30,9 +33,10 @@ class Split extends MarketDataType
|
||||
return $this->items['split_amount'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setDate(String|DateTime $date): self
|
||||
public function setDate(string|DateTime $date): self
|
||||
{
|
||||
$this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s');
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -40,4 +44,4 @@ class Split extends MarketDataType
|
||||
{
|
||||
return $this->items['date'] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Interfaces\MarketData;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Scheb\YahooFinanceApi\ApiClient;
|
||||
use App\Interfaces\MarketData\Types\Dividend;
|
||||
use App\Interfaces\MarketData\Types\Ohlc;
|
||||
use App\Interfaces\MarketData\Types\Quote;
|
||||
use App\Interfaces\MarketData\Types\Split;
|
||||
use App\Interfaces\MarketData\Types\Dividend;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Scheb\YahooFinanceApi\ApiClient;
|
||||
use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance;
|
||||
|
||||
class YahooMarketData implements MarketDataInterface
|
||||
{
|
||||
public ApiClient $client;
|
||||
|
||||
public function __construct() {
|
||||
public function __construct()
|
||||
{
|
||||
|
||||
// create yahoo finance client factory
|
||||
$this->client = YahooFinance::createApiClient();
|
||||
}
|
||||
|
||||
public function exists(String $symbol): Bool
|
||||
public function exists(string $symbol): bool
|
||||
{
|
||||
|
||||
return $this->quote($symbol)->isNotEmpty();
|
||||
return (bool) $this->quote($symbol);
|
||||
}
|
||||
|
||||
public function quote(String $symbol): Quote
|
||||
public function quote(string $symbol): Quote
|
||||
{
|
||||
|
||||
$quote = $this->client->getQuote($symbol);
|
||||
|
||||
if (empty($quote)) return collect();
|
||||
if (is_null($quote?->getRegularMarketPrice())) {
|
||||
throw new \Exception('Could not find ticker on Yahoo');
|
||||
}
|
||||
|
||||
return new Quote([
|
||||
'name' => $quote->getLongName() ?? $quote->getShortName(),
|
||||
'symbol' => $quote->getSymbol(),
|
||||
'market_value' => $quote->getRegularMarketPrice(),
|
||||
'fifty_two_week_high' => $quote->getFiftyTwoWeekHigh(),
|
||||
'fifty_two_week_low' => $quote->getFiftyTwoWeekLow(),
|
||||
'forward_pe' => $quote->getForwardPE(),
|
||||
'trailing_pe' => $quote->getTrailingPE(),
|
||||
'market_cap' => $quote->getMarketCap(),
|
||||
'book_value' => $quote->getBookValue(),
|
||||
'last_dividend_date' => $quote->getDividendDate(),
|
||||
'dividend_yield' => $quote->getTrailingAnnualDividendYield() * 100
|
||||
'name' => $quote?->getLongName() ?? $quote?->getShortName(),
|
||||
'symbol' => $symbol,
|
||||
'currency' => $quote?->getCurrency(),
|
||||
'market_value' => $quote?->getRegularMarketPrice(),
|
||||
'fifty_two_week_high' => $quote?->getFiftyTwoWeekHigh(),
|
||||
'fifty_two_week_low' => $quote?->getFiftyTwoWeekLow(),
|
||||
'forward_pe' => $quote?->getForwardPE(),
|
||||
'trailing_pe' => $quote?->getTrailingPE(),
|
||||
'market_cap' => $quote?->getMarketCap(),
|
||||
'book_value' => $quote?->getBookValue(),
|
||||
'last_dividend_date' => $quote?->getDividendDate(),
|
||||
'dividend_yield' => $quote?->getTrailingAnnualDividendYield() * 100,
|
||||
'meta_data' => [
|
||||
'exchange' => $quote?->getExchange(),
|
||||
'asset_type' => $quote?->getQuoteType(),
|
||||
'source' => 'yahoo',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function dividends(String $symbol, $startDate, $endDate): Collection
|
||||
public function dividends(string $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
|
||||
return collect($this->client->getHistoricalDividendData($symbol, $startDate, $endDate))
|
||||
->map(function($dividend) use ($symbol) {
|
||||
|
||||
return new Dividend([
|
||||
'symbol' => $symbol,
|
||||
'date' => $dividend->getDate(),
|
||||
'dividend_amount' => $dividend->getDividends(),
|
||||
]);
|
||||
});
|
||||
->map(function ($dividend) use ($symbol) {
|
||||
|
||||
return new Dividend([
|
||||
'symbol' => $symbol,
|
||||
'date' => $dividend->getDate(),
|
||||
'dividend_amount' => $dividend->getDividends(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function splits(String $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
public function splits(string $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
|
||||
return collect($this->client->getHistoricalSplitData($symbol, $startDate, $endDate))
|
||||
->map(function($split) use ($symbol) {
|
||||
$split_amount = explode(':', $split->getStockSplits());
|
||||
->map(function ($split) use ($symbol) {
|
||||
$split_amount = explode(':', $split->getStockSplits());
|
||||
|
||||
return new Split([
|
||||
'symbol' => $symbol,
|
||||
'date' => $split->getDate(),
|
||||
'split_amount' => $split_amount[0] / $split_amount[1],
|
||||
]);
|
||||
});
|
||||
return new Split([
|
||||
'symbol' => $symbol,
|
||||
'date' => $split->getDate(),
|
||||
'split_amount' => $split_amount[0] / $split_amount[1],
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function history(String $symbol, $startDate, $endDate): Collection
|
||||
public function history(string $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
|
||||
return collect($this->client->getHistoricalQuoteData($symbol, ApiClient::INTERVAL_1_DAY, $startDate, $endDate))
|
||||
->mapWithKeys(function($history) use ($symbol) {
|
||||
->mapWithKeys(function ($history) use ($symbol) {
|
||||
|
||||
$date = $history->getDate()->format('Y-m-d');
|
||||
$date = Carbon::parse($history->getDate())->toDateString();
|
||||
|
||||
return [ $date => new Ohlc([
|
||||
'symbol' => $symbol,
|
||||
'date' => $date,
|
||||
'close' => $history->getClose(),
|
||||
]) ];
|
||||
return [$date => new Ohlc([
|
||||
'symbol' => $symbol,
|
||||
'date' => $date,
|
||||
'close' => $history->getClose(),
|
||||
])];
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use Throwable;
|
||||
use App\Models\User;
|
||||
use App\Models\BackupImport;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use App\Notifications\ImportSucceededNotification;
|
||||
use App\Notifications\ImportFailedNotification;
|
||||
use App\Imports\BackupImport as BackupImportExcel;
|
||||
use App\Models\BackupImport;
|
||||
use App\Models\User;
|
||||
use App\Notifications\ImportFailedNotification;
|
||||
use App\Notifications\ImportSucceededNotification;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Throwable;
|
||||
|
||||
class BackupImportJob implements ShouldQueue
|
||||
{
|
||||
@@ -19,7 +21,7 @@ class BackupImportJob implements ShouldQueue
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
public $tries = 1;
|
||||
public $tries = 1;
|
||||
|
||||
/**
|
||||
* The number of seconds the job can run before timing out.
|
||||
@@ -42,7 +44,7 @@ class BackupImportJob implements ShouldQueue
|
||||
*/
|
||||
public function __construct(
|
||||
public BackupImport $backupImport
|
||||
) {
|
||||
) {
|
||||
$this->user = User::find($this->backupImport->user_id);
|
||||
}
|
||||
|
||||
@@ -50,7 +52,7 @@ class BackupImportJob implements ShouldQueue
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
{
|
||||
Excel::import(new BackupImportExcel($this->backupImport), $this->backupImport->path, config('livewire.temporary_file_upload.disk', null));
|
||||
|
||||
$this->user->notify(new ImportSucceededNotification);
|
||||
@@ -63,9 +65,9 @@ class BackupImportJob implements ShouldQueue
|
||||
{
|
||||
$this->backupImport->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Error: '. substr($e->getMessage(), 0, 220),
|
||||
'message' => 'Error: '.substr($e->getMessage(), 0, 220),
|
||||
'has_errors' => true,
|
||||
'completed_at' => now()
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$this->user->notify(new ImportFailedNotification($e->getMessage()));
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\CurrencyRate;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class QueuedCurrencyRateInsertJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
public $tries = 3;
|
||||
|
||||
public function __construct(
|
||||
protected array $chunk
|
||||
) {
|
||||
$this->chunk = $chunk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
|
||||
CurrencyRate::insertOrIgnore($this->chunk);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AiChat extends Model
|
||||
{
|
||||
@@ -11,7 +13,7 @@ class AiChat extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'role',
|
||||
'content'
|
||||
'content',
|
||||
];
|
||||
|
||||
protected $hidden = [];
|
||||
@@ -26,7 +28,8 @@ class AiChat extends Model
|
||||
});
|
||||
}
|
||||
|
||||
public function user() {
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Imports\BackupImport as BackupImportExcel;
|
||||
use App\Jobs\BackupImportJob;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BackupImport extends Model
|
||||
{
|
||||
@@ -20,7 +20,7 @@ class BackupImport extends Model
|
||||
'status', // pending, in_progress, success, failed
|
||||
'message', // Import starting, Import is in progress, Importing portfolios, Importing transactions, Importing daily changes, Import completed successfully
|
||||
'has_errors',
|
||||
'completed_at'
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
@@ -32,9 +32,9 @@ class BackupImport extends Model
|
||||
$import->status = 'pending';
|
||||
$import->message = __('Import starting...');
|
||||
});
|
||||
|
||||
|
||||
static::created(function ($import) {
|
||||
|
||||
|
||||
BackupImportJob::dispatch($import);
|
||||
});
|
||||
}
|
||||
@@ -47,7 +47,12 @@ class BackupImport extends Model
|
||||
{
|
||||
return [
|
||||
'has_errors' => 'boolean',
|
||||
'completed_at' => 'datetime'
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasTimestamps;
|
||||
@@ -29,7 +31,7 @@ class ConnectedAccount extends Model
|
||||
];
|
||||
|
||||
protected $with = [
|
||||
'user'
|
||||
'user',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -52,4 +54,4 @@ class ConnectedAccount extends Model
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Number;
|
||||
|
||||
class Currency extends Model
|
||||
{
|
||||
protected $hidden = [];
|
||||
|
||||
protected $primaryKey = 'currency';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $fillable = [
|
||||
'currency',
|
||||
'label',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public static function forHumans(int|float $number, ?string $currency = null, ?string $locale = null): string
|
||||
{
|
||||
$symbol = Number::currencySymbol($currency, $locale);
|
||||
|
||||
return $symbol.Number::forHumans($number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of supported currencies
|
||||
*
|
||||
* @param bool|null $withAliases Whether to include aliases in list of currencies
|
||||
*/
|
||||
public static function list(?bool $withAliases = true): Collection
|
||||
{
|
||||
$aliases = $withAliases ? collect(config('investbrain.currency_aliases'))->map(function ($value, $currency) {
|
||||
return [
|
||||
'currency' => $currency,
|
||||
'label' => $value['label'],
|
||||
];
|
||||
})->values() : collect();
|
||||
|
||||
return $aliases->merge(self::get()->map->only(['currency', 'label']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts between supported currencies
|
||||
*
|
||||
* @param string|null $to (defaults to base currency)
|
||||
*/
|
||||
public static function convert(?float $value, string $from, ?string $to = null, mixed $date = null): float
|
||||
{
|
||||
if (empty($value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Assume converting to base
|
||||
if (empty($to)) {
|
||||
$to = config('investbrain.base_currency');
|
||||
}
|
||||
|
||||
// Get rate
|
||||
[$from, $to] = [
|
||||
cache()->remember($from.'_rate_'.$date, 10, function () use ($from, $date) {
|
||||
return CurrencyRate::historic($from, $date);
|
||||
}),
|
||||
cache()->remember($to.'_rate_'.$date, 10, function () use ($to, $date) {
|
||||
return CurrencyRate::historic($to, $date);
|
||||
}),
|
||||
];
|
||||
|
||||
// get from rate
|
||||
$rate_to_base = 1 / $from;
|
||||
|
||||
// get value in base currency
|
||||
$base_currency_value = $value * $rate_to_base;
|
||||
|
||||
return (float) $base_currency_value * $to;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Jobs\QueuedCurrencyRateInsertJob;
|
||||
use Carbon\CarbonInterface;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Investbrain\Frankfurter\Frankfurter;
|
||||
|
||||
class CurrencyRate extends Model
|
||||
{
|
||||
protected $hidden = [];
|
||||
|
||||
protected $primaryKey = 'currency';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $fillable = [
|
||||
'date',
|
||||
'currency',
|
||||
'rate',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'rate' => 'float',
|
||||
'date' => 'date',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public static function current(string $currency): float
|
||||
{
|
||||
return (float) self::historic($currency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historic rate for symbol
|
||||
*/
|
||||
public static function historic(string $currency, mixed $date = null): float
|
||||
{
|
||||
// No need to convert
|
||||
if ($currency === config('investbrain.base_currency')) {
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// If we don't need historic, let's use current rate
|
||||
if (empty($date)) {
|
||||
|
||||
$date = now();
|
||||
}
|
||||
|
||||
// Make sure we have a Carbon date
|
||||
$date = Carbon::parse($date);
|
||||
|
||||
// Handle aliases
|
||||
[$currency, $adjustment] = self::getCurrencyAliasAdjustments($currency);
|
||||
|
||||
// Get or create historic rate
|
||||
$rate = self::select('rate')
|
||||
->whereDate('date', $date->toDateString())
|
||||
->where(['currency' => $currency])
|
||||
->firstOr(function () use ($date, $currency) {
|
||||
|
||||
$currencies = Currency::all()->pluck('currency')->toArray();
|
||||
|
||||
$rates = Frankfurter::setSymbols($currencies)->historical($date);
|
||||
|
||||
$date = Arr::get($rates, 'date');
|
||||
|
||||
$updates = Arr::map(Arr::get($rates, 'rates', []), function ($rate, $curr) use ($date) {
|
||||
|
||||
return [
|
||||
'currency' => $curr,
|
||||
'date' => $date,
|
||||
'rate' => $rate,
|
||||
'updated_at' => now()->toDateTimeString(),
|
||||
'created_at' => now()->toDateTimeString(),
|
||||
];
|
||||
});
|
||||
|
||||
// persist
|
||||
self::chunkInsert($updates);
|
||||
|
||||
return new CurrencyRate(Arr::first($updates, fn ($update) => $update['currency'] == $currency) ?? ['rate' => 1]);
|
||||
});
|
||||
|
||||
return (float) $rate->rate * $adjustment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rates for range of dates
|
||||
*
|
||||
* @return array<string, float>
|
||||
*/
|
||||
public static function timeSeriesRates(string|array $currency, mixed $start = null, mixed $end = null): array
|
||||
{
|
||||
if (empty($start)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$end = $end ?? now();
|
||||
|
||||
$period = CarbonPeriod::create($start, $end);
|
||||
|
||||
// No need to send network request - just generate 1s
|
||||
if ($currency === config('investbrain.base_currency')) {
|
||||
|
||||
$dateRange = [];
|
||||
foreach ($period as $date) {
|
||||
$dateRange[$date->toDateString()] = 1;
|
||||
}
|
||||
|
||||
return $dateRange;
|
||||
}
|
||||
|
||||
[$currency, $adjustment] = self::getCurrencyAliasAdjustments($currency);
|
||||
|
||||
if (! empty($currency)) {
|
||||
|
||||
$currencies = Arr::wrap($currency);
|
||||
|
||||
} else {
|
||||
|
||||
$currencies = Currency::all()->pluck('currency')->toArray();
|
||||
}
|
||||
|
||||
// get rates
|
||||
$rates = Frankfurter::setSymbols($currencies)->timeSeries($period->first(), $period->last());
|
||||
|
||||
$rates = collect(Arr::get($rates, 'rates', []))->sortKeys()->toArray();
|
||||
|
||||
$datesOnly = array_keys($rates);
|
||||
|
||||
// loop through each date
|
||||
$updates = [];
|
||||
foreach ($period as $date) {
|
||||
|
||||
$lookupDate = self::getNearestPastDate($date, $datesOnly, $rates);
|
||||
|
||||
if (is_null($lookupDate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// loop through each rate
|
||||
foreach ($rates[$lookupDate->toDateString()] as $curr => $rate) {
|
||||
|
||||
// add to updates
|
||||
$updates[] = [
|
||||
'currency' => $curr,
|
||||
'date' => $date->toDateString(),
|
||||
'rate' => $rate,
|
||||
'updated_at' => now()->toDateTimeString(),
|
||||
'created_at' => now()->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// persist
|
||||
self::chunkInsert($updates);
|
||||
|
||||
return collect($updates)
|
||||
->whereBetween('date', [$start, $end ?? now()])
|
||||
->where('currency', $currency)
|
||||
->mapWithKeys(fn ($rate) => [
|
||||
$rate['date'] => $rate['rate'] * $adjustment,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
private static function getNearestPastDate(CarbonInterface $date, array $datesOnly, array $rates): ?CarbonInterface
|
||||
{
|
||||
|
||||
// if no dates, nothing to do...
|
||||
if (empty($datesOnly)) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$mutableDate = $date->copy();
|
||||
$weekAgo = $date->copy()->subWeek();
|
||||
$firstDate = Carbon::parse($datesOnly[0]);
|
||||
|
||||
// get rates or find closest valid rate (handles missing weekend rates)
|
||||
while (! isset($rates[$mutableDate->toDateString()])) {
|
||||
|
||||
// prevent runaway infinite loops
|
||||
if ($mutableDate->lessThan($weekAgo)) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// is this the start of a range that falls on a weekend?
|
||||
if ($mutableDate->lessThan($firstDate)) {
|
||||
|
||||
return $firstDate;
|
||||
}
|
||||
|
||||
// try the day before then
|
||||
$mutableDate = $mutableDate->subDay();
|
||||
}
|
||||
|
||||
return $mutableDate;
|
||||
}
|
||||
|
||||
public static function refreshCurrencyData($force = false): void
|
||||
{
|
||||
$currencies = Currency::all()->pluck('currency')->toArray();
|
||||
|
||||
$rates = Frankfurter::setBaseCurrency(config('investbrain.base_currency'))
|
||||
->setSymbols($currencies)
|
||||
->latest();
|
||||
|
||||
$updates = [];
|
||||
foreach (Arr::get($rates, 'rates', []) as $currency => $rate) {
|
||||
|
||||
// update currency
|
||||
$updates[] = [
|
||||
'date' => now()->toDateString(),
|
||||
'currency' => $currency,
|
||||
'rate' => $rate,
|
||||
];
|
||||
}
|
||||
|
||||
// nothing to update
|
||||
if (empty($updates)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($force) {
|
||||
|
||||
// force overwrite existing rates
|
||||
CurrencyRate::upsert($updates, ['currency', 'date'], ['rate']);
|
||||
} else {
|
||||
|
||||
// only insert new rates
|
||||
CurrencyRate::insertOrIgnore($updates);
|
||||
}
|
||||
}
|
||||
|
||||
public static function chunkInsert(array $updates): void
|
||||
{
|
||||
|
||||
foreach (array_chunk($updates, 500) as $chunk) {
|
||||
|
||||
QueuedCurrencyRateInsertJob::dispatch($chunk);
|
||||
}
|
||||
}
|
||||
|
||||
protected static function getCurrencyAliasAdjustments($currency)
|
||||
{
|
||||
$adjustment = 1;
|
||||
|
||||
if (array_key_exists($currency, config('investbrain.currency_aliases', []))) {
|
||||
|
||||
$config = config('investbrain.currency_aliases.'.$currency);
|
||||
|
||||
$adjustment = $config['adjustment'];
|
||||
$currency = $config['alias_of'];
|
||||
}
|
||||
|
||||
return [$currency, $adjustment];
|
||||
}
|
||||
}
|
||||
+151
-11
@@ -1,14 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasCompositePrimaryKey;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DailyChange extends Model
|
||||
{
|
||||
use HasFactory, HasCompositePrimaryKey;
|
||||
use HasCompositePrimaryKey, HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
@@ -20,10 +23,6 @@ class DailyChange extends Model
|
||||
'portfolio_id',
|
||||
'date',
|
||||
'total_market_value',
|
||||
'total_cost_basis',
|
||||
'total_gain',
|
||||
'total_dividends_earned',
|
||||
'realized_gains',
|
||||
'notes',
|
||||
];
|
||||
|
||||
@@ -31,14 +30,19 @@ class DailyChange extends Model
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'datetime',
|
||||
'total_market_value' => 'float',
|
||||
'total_cost_basis' => 'float',
|
||||
'total_gain' => 'float',
|
||||
'realized_gain_dollars' => 'float',
|
||||
'total_dividends_earned' => 'float',
|
||||
];
|
||||
|
||||
|
||||
public function scopePortfolio($query, $portfolio)
|
||||
{
|
||||
return $query->where('portfolio_id', $portfolio);
|
||||
return $query->where('daily_change.portfolio_id', $portfolio);
|
||||
}
|
||||
|
||||
public function scopeMyDailyChanges()
|
||||
public function scopeMyDailyChanges()
|
||||
{
|
||||
return $this->whereHas('portfolio', function ($query) {
|
||||
$query->whereHas('users', function ($query) {
|
||||
@@ -47,12 +51,148 @@ class DailyChange extends Model
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeWithoutWishlists($query) {
|
||||
public function scopeWithoutWishlists($query)
|
||||
{
|
||||
return $query->whereHas('portfolio', function ($query) {
|
||||
$query->where('portfolios.wishlist', 0);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
public function scopeWithDailyPerformance($query)
|
||||
{
|
||||
$currency = auth()->user()?->getCurrency() ?? config('investbrain.base_currency');
|
||||
|
||||
$dividendSub = DB::table('holdings')
|
||||
->join('dividends', 'dividends.symbol', '=', 'holdings.symbol')
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join->on('cr.date', '=', 'dividends.date')
|
||||
->where('cr.currency', '=', $currency);
|
||||
})
|
||||
->join('transactions as tx', function ($join) {
|
||||
$join->on('tx.symbol', '=', 'holdings.symbol')
|
||||
->on('tx.portfolio_id', '=', 'holdings.portfolio_id')
|
||||
->whereColumn('tx.date', '<=', 'dividends.date');
|
||||
})
|
||||
->select(['holdings.portfolio_id', 'dividends.date'])
|
||||
->selectRaw("
|
||||
((CASE WHEN tx.transaction_type = 'BUY'
|
||||
THEN tx.quantity ELSE 0 END)
|
||||
- (CASE WHEN tx.transaction_type = 'SELL'
|
||||
THEN tx.quantity ELSE 0 END))
|
||||
* SUM(
|
||||
dividends.dividend_amount_base
|
||||
* COALESCE(cr.rate, 1)
|
||||
)
|
||||
AS total_dividends_earned")
|
||||
->groupBy(['holdings.portfolio_id', 'dividends.date', 'tx.transaction_type', 'tx.quantity']);
|
||||
|
||||
$totalCostBasisSub = DB::table('transactions as tx1')
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join->on('cr.date', '=', 'tx1.date')
|
||||
->where('cr.currency', '=', $currency);
|
||||
})
|
||||
->select([
|
||||
'tx1.portfolio_id',
|
||||
'tx1.date',
|
||||
'tx1.symbol',
|
||||
'tx1.transaction_type',
|
||||
'tx1.quantity',
|
||||
])
|
||||
->selectRaw("(CASE
|
||||
WHEN tx1.transaction_type = 'BUY'
|
||||
THEN COALESCE(cr.rate, 1)
|
||||
ELSE (
|
||||
SELECT
|
||||
SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base)
|
||||
/ SUM(buy.cost_basis_base)
|
||||
FROM transactions as buy
|
||||
LEFT JOIN currency_rates as cr2
|
||||
ON cr2.date = buy.date
|
||||
AND cr2.currency = '{$currency}'
|
||||
WHERE buy.symbol = tx1.symbol
|
||||
AND buy.portfolio_id = tx1.portfolio_id
|
||||
AND buy.transaction_type = 'BUY'
|
||||
AND buy.date <= tx1.date
|
||||
) END)
|
||||
AS rate")
|
||||
->selectRaw(
|
||||
"(CASE
|
||||
WHEN tx1.transaction_type = 'BUY'
|
||||
THEN AVG(tx1.cost_basis_base)
|
||||
ELSE (
|
||||
SELECT
|
||||
AVG(-buy.cost_basis_base)
|
||||
FROM transactions as buy
|
||||
WHERE buy.symbol = tx1.symbol
|
||||
AND buy.portfolio_id = tx1.portfolio_id
|
||||
AND buy.transaction_type = 'BUY'
|
||||
AND buy.date <= tx1.date
|
||||
) END)
|
||||
AS cost_basis_base")
|
||||
->selectRaw(
|
||||
"(CASE
|
||||
WHEN tx1.transaction_type = 'SELL'
|
||||
THEN tx1.sale_price_base - tx1.cost_basis_base
|
||||
ELSE 0 END)
|
||||
* tx1.quantity
|
||||
* COALESCE(cr.rate, 1)
|
||||
AS realized_gain_dollars")
|
||||
->groupBy([
|
||||
'tx1.portfolio_id',
|
||||
'tx1.date',
|
||||
'tx1.symbol',
|
||||
'tx1.transaction_type',
|
||||
'tx1.cost_basis_base',
|
||||
'tx1.quantity',
|
||||
'cr.rate',
|
||||
'tx1.sale_price_base',
|
||||
]);
|
||||
|
||||
return $query
|
||||
->select(['daily_change.date', 'daily_change.portfolio_id'])
|
||||
->leftJoinSub($totalCostBasisSub, 'cost_basis_display', function ($join) {
|
||||
$join->on('daily_change.date', '>=', 'cost_basis_display.date')
|
||||
->whereColumn('daily_change.portfolio_id', '=', 'cost_basis_display.portfolio_id');
|
||||
})
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join->on('cr.date', '=', 'daily_change.date')
|
||||
->where('cr.currency', '=', $currency);
|
||||
})
|
||||
->selectRaw('
|
||||
SUM(
|
||||
cost_basis_display.cost_basis_base
|
||||
* cost_basis_display.quantity
|
||||
* cost_basis_display.rate
|
||||
) as total_cost_basis')
|
||||
->selectRaw('(
|
||||
daily_change.total_market_value * COALESCE(cr.rate, 1)
|
||||
) - SUM(
|
||||
cost_basis_display.cost_basis_base
|
||||
* cost_basis_display.quantity
|
||||
* cost_basis_display.rate
|
||||
) as total_gain')
|
||||
->selectRaw('(
|
||||
daily_change.total_market_value * COALESCE(cr.rate, 1)
|
||||
) as total_market_value')
|
||||
->selectRaw('
|
||||
SUM(
|
||||
cost_basis_display.realized_gain_dollars
|
||||
) as realized_gain_dollars')
|
||||
->selectSub(function ($query) use ($dividendSub) {
|
||||
$query->fromSub($dividendSub, 'd')
|
||||
->selectRaw('SUM(d.total_dividends_earned)')
|
||||
->whereColumn('d.date', '<=', 'daily_change.date')
|
||||
->whereColumn('d.portfolio_id', '=', 'daily_change.portfolio_id');
|
||||
}, 'total_dividends_earned')
|
||||
->groupBy([
|
||||
'daily_change.date',
|
||||
'cr.rate',
|
||||
'daily_change.total_market_value',
|
||||
'daily_change.portfolio_id',
|
||||
])
|
||||
->orderBy('daily_change.date');
|
||||
}
|
||||
|
||||
public function portfolio()
|
||||
{
|
||||
return $this->belongsTo(Portfolio::class);
|
||||
|
||||
+104
-65
@@ -1,20 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Holding;
|
||||
use App\Models\MarketData;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Actions\CopyToBaseCurrency;
|
||||
use App\Casts\BaseCurrency;
|
||||
use App\Interfaces\MarketData\MarketDataInterface;
|
||||
use App\Traits\HasMarketData;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Pipeline;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Dividend extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasMarketData;
|
||||
use HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
@@ -26,19 +33,33 @@ class Dividend extends Model
|
||||
protected $hidden = [];
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'datetime',
|
||||
'last_dividend_update' => 'datetime',
|
||||
'date' => 'date',
|
||||
'last_dividend_update' => 'date',
|
||||
'dividend_amount' => 'float',
|
||||
'dividend_amount_base' => BaseCurrency::class,
|
||||
];
|
||||
|
||||
public function marketData() {
|
||||
return $this->belongsTo(MarketData::class, 'symbol', 'symbol');
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::saving(function ($dividend) {
|
||||
|
||||
$dividend = Pipeline::send($dividend)
|
||||
->through([
|
||||
CopyToBaseCurrency::class,
|
||||
])
|
||||
->then(fn (Dividend $dividend) => $dividend);
|
||||
});
|
||||
}
|
||||
|
||||
public function holdings() {
|
||||
public function holdings(): HasMany
|
||||
{
|
||||
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
||||
}
|
||||
|
||||
public function transactions() {
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
|
||||
}
|
||||
|
||||
@@ -49,7 +70,6 @@ class Dividend extends Model
|
||||
|
||||
/**
|
||||
* Grab new dividend data
|
||||
*
|
||||
*/
|
||||
public static function refreshDividendData(string $symbol): void
|
||||
{
|
||||
@@ -64,11 +84,11 @@ class Dividend extends Model
|
||||
$end_date = now();
|
||||
|
||||
// nope, refresh forward looking only
|
||||
if ( $dividends_meta->total_dividends ) {
|
||||
|
||||
$start_date = $dividends_meta->last_dividend_update->addHours(24);
|
||||
if ($dividends_meta->total_dividends) {
|
||||
|
||||
$start_date = $dividends_meta->last_dividend_update;
|
||||
}
|
||||
|
||||
|
||||
// skip refresh if there's already recent data
|
||||
if ($start_date->greaterThan($end_date)) {
|
||||
|
||||
@@ -82,20 +102,32 @@ class Dividend extends Model
|
||||
|
||||
// ah, we found some dividends...
|
||||
if ($dividend_data->isNotEmpty()) {
|
||||
// create mass insert
|
||||
foreach ($dividend_data as $index => $dividend){
|
||||
$dividend_data[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
|
||||
}
|
||||
|
||||
// insert records
|
||||
(new self)->insert($dividend_data->toArray());
|
||||
$market_data = MarketData::getMarketData($symbol);
|
||||
|
||||
$dividend_data
|
||||
->chunk(10)
|
||||
->each(function ($chunk) use ($market_data) {
|
||||
|
||||
// get historic conversion rates
|
||||
$rate_to_base = CurrencyRate::timeSeriesRates($market_data->currency, $chunk->min('date'), $chunk->max('date'));
|
||||
|
||||
// create mass insert
|
||||
foreach ($chunk as $index => $dividend) {
|
||||
$rate_to_base_date = 1 / Arr::get($rate_to_base, Carbon::parse(Arr::get($dividend, 'date'))->toDateString(), 1);
|
||||
|
||||
$dividend['dividend_amount_base'] = $dividend['dividend_amount'] * $rate_to_base_date;
|
||||
|
||||
$chunk[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
|
||||
}
|
||||
|
||||
// insert records
|
||||
(new self)->insertOrIgnore($chunk->toArray());
|
||||
});
|
||||
|
||||
// sync to holdings
|
||||
self::syncHoldings($symbol);
|
||||
|
||||
// get market data
|
||||
$market_data = MarketData::firstOrNew(['symbol' => $symbol]);
|
||||
|
||||
// re-invest dividends
|
||||
self::reinvestDividends($dividend_data, $market_data);
|
||||
|
||||
@@ -108,33 +140,39 @@ class Dividend extends Model
|
||||
public static function syncHoldings(string $symbol): void
|
||||
{
|
||||
// group by holdings
|
||||
$dividends = self::select(['holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount'])
|
||||
->selectRaw('
|
||||
(COALESCE(CASE WHEN transactions.transaction_type = "BUY"
|
||||
AND date(transactions.date) <= date(dividends.date)
|
||||
THEN transactions.quantity ELSE 0 END, 0)
|
||||
- COALESCE(CASE WHEN transactions.transaction_type = "SELL"
|
||||
AND date(transactions.date) <= date(dividends.date)
|
||||
THEN transactions.quantity ELSE 0 END, 0))
|
||||
* dividends.dividend_amount
|
||||
AS total_received
|
||||
')
|
||||
->join('transactions', 'transactions.symbol', '=', 'dividends.symbol')
|
||||
->join('holdings', 'transactions.portfolio_id', '=', 'holdings.portfolio_id')
|
||||
->where('dividends.symbol', $symbol)
|
||||
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'total_received')
|
||||
->havingRaw('total_received > 0')
|
||||
->get();
|
||||
$subQuery = self::select([
|
||||
'holdings.portfolio_id',
|
||||
'dividends.date',
|
||||
'dividends.symbol',
|
||||
'dividends.dividend_amount',
|
||||
])->selectRaw("
|
||||
(COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY'
|
||||
AND date(transactions.date) <= date(dividends.date)
|
||||
THEN transactions.quantity ELSE 0 END), 0)
|
||||
- COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL'
|
||||
AND date(transactions.date) <= date(dividends.date)
|
||||
THEN transactions.quantity ELSE 0 END), 0))
|
||||
* dividends.dividend_amount
|
||||
AS total_received
|
||||
")->join('transactions', 'transactions.symbol', '=', 'dividends.symbol')
|
||||
->join('holdings', 'transactions.portfolio_id', '=', 'holdings.portfolio_id')
|
||||
->where('dividends.symbol', $symbol)
|
||||
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'dividends.dividend_amount_base');
|
||||
|
||||
// iterate through holdings and update
|
||||
$dividends = DB::table(DB::raw("({$subQuery->toSql()}) as sub"))
|
||||
->mergeBindings($subQuery->getQuery())
|
||||
->where('total_received', '>', 0)
|
||||
->get();
|
||||
|
||||
// iterate through holdings and update
|
||||
Holding::where(['symbol' => $symbol])
|
||||
->get()
|
||||
->each(function ($holding) use ($dividends) {
|
||||
$holding->update([
|
||||
'dividends_earned' => $dividends->where('portfolio_id', $holding->portfolio_id)
|
||||
->sum('total_received')
|
||||
]);
|
||||
});
|
||||
->get()
|
||||
->each(function ($holding) use ($dividends) {
|
||||
$holding->update([
|
||||
'dividends_earned' => $dividends->where('portfolio_id', $holding->portfolio_id)
|
||||
->sum('total_received'),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public static function reinvestDividends(iterable $dividend_data, MarketData $market_data): void
|
||||
@@ -144,21 +182,22 @@ class Dividend extends Model
|
||||
'symbol' => $market_data->symbol,
|
||||
'reinvest_dividends' => true,
|
||||
])
|
||||
->get()
|
||||
->each(function($holding) use ($dividend_data, $market_data) {
|
||||
->get()
|
||||
->each(function ($holding) use ($dividend_data, $market_data) {
|
||||
|
||||
foreach($dividend_data as $dividend) {
|
||||
foreach ($dividend_data as $dividend) {
|
||||
|
||||
Transaction::create([
|
||||
'date' => $dividend['date'],
|
||||
'portfolio_id' => $holding->portfolio_id,
|
||||
'symbol' => $holding->symbol,
|
||||
'transaction_type' => "BUY",
|
||||
'reinvested_dividend' => true,
|
||||
'cost_basis' => 0,
|
||||
'quantity' => ($dividend['dividend_amount'] * $holding->qtyOwned(Carbon::parse($dividend['date']))) / $market_data->market_value,
|
||||
]);
|
||||
}
|
||||
});
|
||||
Transaction::create([
|
||||
'date' => $dividend['date'],
|
||||
'portfolio_id' => $holding->portfolio_id,
|
||||
'symbol' => $holding->symbol,
|
||||
'currency' => $holding->market_data->currency,
|
||||
'transaction_type' => 'BUY',
|
||||
'reinvested_dividend' => true,
|
||||
'cost_basis' => 0,
|
||||
'quantity' => ($dividend['dividend_amount'] * $holding->qtyOwned(Carbon::parse($dividend['date']))) / $market_data->market_value,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+382
-151
@@ -1,21 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Split;
|
||||
use App\Models\AiChat;
|
||||
use App\Models\Dividend;
|
||||
use App\Models\Portfolio;
|
||||
use App\Models\MarketData;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Traits\HasMarketData;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Holding extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasMarketData;
|
||||
use HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
@@ -27,36 +27,34 @@ class Holding extends Model
|
||||
'realized_gain_dollars',
|
||||
'dividends_earned',
|
||||
'splits_synced_at',
|
||||
'reinvest_dividends'
|
||||
'reinvest_dividends',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'reinvest_dividends' => 'boolean',
|
||||
'splits_synced_at' => 'datetime',
|
||||
'first_transaction_date' => 'datetime',
|
||||
'reinvest_dividends' => 'boolean'
|
||||
'quantity' => 'float',
|
||||
'average_cost_basis' => 'float',
|
||||
'total_cost_basis' => 'float',
|
||||
'realized_gain_dollars' => 'float',
|
||||
'dividends_earned' => 'float',
|
||||
'total_gain_dollars' => 'float',
|
||||
'market_gain_dollars' => 'float',
|
||||
'total_market_value' => 'float',
|
||||
'total_dividends_earned' => 'float',
|
||||
'market_data_market_value' => 'float',
|
||||
'market_data_fifty_two_week_low' => 'float',
|
||||
'market_data_fifty_two_week_high' => 'float',
|
||||
'market_gain_percent' => 'float',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'realized_gain_dollars' => 0,
|
||||
'dividends_earned' => 0,
|
||||
];
|
||||
|
||||
/**
|
||||
* Market data for holding
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function market_data()
|
||||
{
|
||||
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
|
||||
}
|
||||
|
||||
/**
|
||||
* Related transactions for holding
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function transactions()
|
||||
public function transactions()
|
||||
{
|
||||
return $this->hasManyThrough(Transaction::class, Portfolio::class, 'id', 'portfolio_id', 'portfolio_id', 'id')->orderBy('date', 'DESC');
|
||||
}
|
||||
@@ -66,49 +64,80 @@ class Holding extends Model
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function dividends()
|
||||
public function dividends()
|
||||
{
|
||||
return $this->hasMany(Dividend::class, 'symbol', 'symbol')
|
||||
->select(['dividends.symbol','dividends.date','dividends.dividend_amount'])
|
||||
->selectRaw("SUM(
|
||||
CASE WHEN transaction_type = 'BUY'
|
||||
AND transactions.symbol = dividends.symbol
|
||||
->select(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount', 'dividends.dividend_amount_base'])
|
||||
->selectRaw("SUM(
|
||||
CASE WHEN transaction_type = 'BUY'
|
||||
AND transactions.symbol = dividends.symbol
|
||||
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||
AND date(dividends.date) >= date(transactions.date)
|
||||
THEN transactions.quantity
|
||||
ELSE 0 END
|
||||
) AS purchased")
|
||||
->selectRaw("SUM(
|
||||
->selectRaw("SUM(
|
||||
CASE WHEN transaction_type = 'SELL'
|
||||
AND transactions.symbol = dividends.symbol
|
||||
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||
AND transactions.symbol = dividends.symbol
|
||||
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||
AND date(dividends.date) >= date(transactions.date)
|
||||
THEN transactions.quantity
|
||||
ELSE 0 END
|
||||
) AS sold")
|
||||
->selectRaw("SUM(
|
||||
(CASE WHEN transaction_type = 'BUY'
|
||||
AND transactions.symbol = dividends.symbol
|
||||
->selectRaw("SUM(
|
||||
(CASE WHEN transaction_type = 'BUY'
|
||||
AND transactions.symbol = dividends.symbol
|
||||
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||
AND date(transactions.date) <= date(dividends.date)
|
||||
AND date(transactions.date) <= date(dividends.date)
|
||||
THEN transactions.quantity ELSE 0 END
|
||||
- CASE WHEN transaction_type = 'SELL'
|
||||
AND transactions.symbol = dividends.symbol
|
||||
- CASE WHEN transaction_type = 'SELL'
|
||||
AND transactions.symbol = dividends.symbol
|
||||
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||
AND date(transactions.date) <= date(dividends.date)
|
||||
AND date(transactions.date) <= date(dividends.date)
|
||||
THEN transactions.quantity ELSE 0 END)
|
||||
* dividends.dividend_amount
|
||||
) AS total_received")
|
||||
->join('transactions', 'transactions.symbol', 'dividends.symbol')
|
||||
->groupBy(['dividends.symbol','dividends.date','dividends.dividend_amount'])
|
||||
->orderBy('dividends.date', 'DESC')
|
||||
->where('dividends.date', '>=', function ($query) {
|
||||
$query->selectRaw('min(transactions.date)')
|
||||
->from('transactions')
|
||||
->whereRaw("transactions.portfolio_id = '$this->portfolio_id'")
|
||||
->whereRaw("transactions.symbol = '$this->symbol'");
|
||||
})
|
||||
->having('total_received', '>', 0);
|
||||
->selectRaw("SUM(
|
||||
(CASE WHEN transaction_type = 'BUY'
|
||||
AND transactions.symbol = dividends.symbol
|
||||
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||
AND date(transactions.date) <= date(dividends.date)
|
||||
THEN transactions.quantity ELSE 0 END
|
||||
- CASE WHEN transaction_type = 'SELL'
|
||||
AND transactions.symbol = dividends.symbol
|
||||
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||
AND date(transactions.date) <= date(dividends.date)
|
||||
THEN transactions.quantity ELSE 0 END)
|
||||
* dividends.dividend_amount_base
|
||||
) AS total_received_base")
|
||||
->join('transactions', 'transactions.symbol', 'dividends.symbol')
|
||||
->groupBy(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount', 'dividends.dividend_amount_base'])
|
||||
->orderBy('dividends.date', 'DESC')
|
||||
->where('dividends.date', '>=', function ($query) {
|
||||
$query->selectRaw('min(transactions.date)')
|
||||
->from('transactions')
|
||||
->whereRaw("transactions.portfolio_id = '$this->portfolio_id'")
|
||||
->whereRaw("transactions.symbol = '$this->symbol'");
|
||||
})
|
||||
->havingRaw("SUM(
|
||||
(CASE
|
||||
WHEN transaction_type = 'BUY'
|
||||
AND transactions.symbol = dividends.symbol
|
||||
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||
AND transactions.date <= dividends.date
|
||||
THEN transactions.quantity
|
||||
ELSE 0
|
||||
END)
|
||||
-
|
||||
(CASE
|
||||
WHEN transaction_type = 'SELL'
|
||||
AND transactions.symbol = dividends.symbol
|
||||
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||
AND transactions.date <= dividends.date
|
||||
THEN transactions.quantity
|
||||
ELSE 0
|
||||
END)
|
||||
) * dividends.dividend_amount_base > 0");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,7 +145,7 @@ class Holding extends Model
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function portfolio()
|
||||
public function portfolio()
|
||||
{
|
||||
return $this->belongsTo(Portfolio::class);
|
||||
}
|
||||
@@ -126,7 +155,7 @@ class Holding extends Model
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function splits()
|
||||
public function splits()
|
||||
{
|
||||
return $this->hasMany(Split::class, 'symbol', 'symbol')
|
||||
->orderBy('date', 'DESC');
|
||||
@@ -145,18 +174,22 @@ class Holding extends Model
|
||||
public function scopeWithMarketData($query)
|
||||
{
|
||||
return $query->withAggregate('market_data', 'name')
|
||||
->withAggregate('market_data', 'market_value')
|
||||
->withAggregate('market_data', 'fifty_two_week_low')
|
||||
->withAggregate('market_data', 'fifty_two_week_high')
|
||||
->withAggregate('market_data', 'updated_at')
|
||||
->join('market_data', 'holdings.symbol', 'market_data.symbol');
|
||||
->withAggregate('market_data', 'market_value')
|
||||
->withAggregate('market_data', 'market_value_base')
|
||||
->withAggregate('market_data', 'fifty_two_week_low')
|
||||
->withAggregate('market_data', 'fifty_two_week_high')
|
||||
->withAggregate('market_data', 'updated_at')
|
||||
->join('market_data', 'holdings.symbol', 'market_data.symbol');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate performance for holding in its local currency
|
||||
*/
|
||||
public function scopeWithPerformance($query)
|
||||
{
|
||||
return $query->selectRaw('COALESCE(market_data.market_value * holdings.quantity, 0) AS total_market_value')
|
||||
->selectRaw('COALESCE((market_data.market_value - holdings.average_cost_basis) * holdings.quantity, 0) AS market_gain_dollars')
|
||||
->selectRaw('COALESCE(((market_data.market_value - holdings.average_cost_basis) / holdings.average_cost_basis) * 100, 0) AS market_gain_percent');
|
||||
->selectRaw('COALESCE(((market_data.market_value - holdings.average_cost_basis) / NULLIF(holdings.average_cost_basis, 0)) * 100, 0) AS market_gain_percent');
|
||||
}
|
||||
|
||||
public function scopePortfolio($query, $portfolio)
|
||||
@@ -169,68 +202,222 @@ class Holding extends Model
|
||||
return $query->where('holdings.symbol', $symbol);
|
||||
}
|
||||
|
||||
public function scopeWithoutWishlists($query) {
|
||||
public function scopeWithoutWishlists($query)
|
||||
{
|
||||
return $query->whereHas('portfolio', function ($query) {
|
||||
$query->where('portfolios.wishlist', 0);
|
||||
});
|
||||
$query->where('portfolios.wishlist', 0);
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeMyHoldings($query, $userId = null)
|
||||
{
|
||||
return $query->whereHas('portfolio', function($query) use ($userId) {
|
||||
return $query->whereHas('portfolio', function ($query) use ($userId) {
|
||||
$query->whereRelation('users', 'id', $userId ?? auth()->user()->id);
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeWithPortfolioMetrics($query)
|
||||
/**
|
||||
* Scope which returns collection of performance metrics for holdings
|
||||
*
|
||||
* @param string $currency Allows casting to specified currency
|
||||
*/
|
||||
public function scopeGetPortfolioMetrics($query, $currency = null): Collection
|
||||
{
|
||||
return $query->selectRaw('COALESCE(SUM(holdings.dividends_earned), 0) AS total_dividends_earned')
|
||||
->selectRaw('COALESCE(SUM(holdings.realized_gain_dollars), 0) AS realized_gain_dollars')
|
||||
->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) AS total_market_value')
|
||||
->selectRaw('COALESCE(SUM(holdings.total_cost_basis), 0) AS total_cost_basis')
|
||||
->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) - COALESCE(SUM(holdings.total_cost_basis), 0) AS total_gain_dollars')
|
||||
// ->selectRaw('COALESCE((@total_gain_dollars / @sum_total_cost_basis) * 100,0) AS total_gain_percent')
|
||||
->join('market_data', 'market_data.symbol', '=', 'holdings.symbol');
|
||||
$result = $query->withPortfolioMetrics($currency)->get();
|
||||
|
||||
return collect([
|
||||
'total_cost_basis' => $result->sum('total_cost_basis'),
|
||||
'total_market_value' => $result->sum('total_market_value'),
|
||||
'total_gain_dollars' => $result->sum('total_gain_dollars'),
|
||||
'realized_gain_dollars' => $result->sum('realized_gain_dollars'),
|
||||
'total_dividends_earned' => $result->sum('total_dividends_earned'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to collect performance metrics for holdings
|
||||
*
|
||||
* @param string $currency Allows casting to specified currency
|
||||
*/
|
||||
public function scopeWithPortfolioMetrics($query, $currency = null): mixed
|
||||
{
|
||||
$currency = $currency ?? auth()->user()->getCurrency();
|
||||
|
||||
return $query->select([
|
||||
'holdings.symbol',
|
||||
'holdings.portfolio_id',
|
||||
'transactions_display.total_cost_basis',
|
||||
'transactions_display.realized_gain_dollars',
|
||||
'dividends_display.total_dividends_earned',
|
||||
])
|
||||
->groupBy([
|
||||
'holdings.symbol',
|
||||
'holdings.quantity',
|
||||
'holdings.portfolio_id',
|
||||
'cr.rate',
|
||||
'transactions_display.total_cost_basis',
|
||||
'transactions_display.realized_gain_dollars',
|
||||
'dividends_display.total_dividends_earned',
|
||||
'market_data.market_value_base',
|
||||
])
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join->where('cr.currency', '=', $currency);
|
||||
|
||||
if (config('database.default') === 'sqlite') {
|
||||
|
||||
$join->whereRaw("strftime('%Y-%m-%d', cr.date) = ?", [now()->toDateString()]);
|
||||
} else {
|
||||
|
||||
$join->on('cr.date', '=', DB::raw("'".now()->toDateString()."'"));
|
||||
}
|
||||
})
|
||||
->leftJoin('market_data', function ($join) {
|
||||
$join->on('market_data.symbol', '=', 'holdings.symbol');
|
||||
})
|
||||
->selectRaw(
|
||||
'holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1) AS total_market_value'
|
||||
)
|
||||
->selectRaw('(
|
||||
holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1)
|
||||
) - transactions_display.total_cost_basis as total_gain_dollars')
|
||||
->leftJoinSub(
|
||||
DB::table('transactions')
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join->on('cr.date', '=', 'transactions.date')
|
||||
->where('cr.currency', '=', $currency);
|
||||
})
|
||||
->select(['transactions.symbol', 'transactions.portfolio_id'])
|
||||
->leftJoinSub(
|
||||
DB::table('transactions')
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join
|
||||
->on('cr.date', '=', 'transactions.date')
|
||||
->where('cr.currency', '=', $currency);
|
||||
})
|
||||
->select([
|
||||
'transactions.symbol',
|
||||
'transactions.portfolio_id',
|
||||
'transactions.quantity',
|
||||
'transactions.date',
|
||||
])
|
||||
->selectRaw(
|
||||
"(CASE
|
||||
WHEN transactions.transaction_type = 'BUY'
|
||||
THEN COALESCE(cr.rate, 1)
|
||||
ELSE (
|
||||
SELECT
|
||||
SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base)
|
||||
/ SUM(buy.cost_basis_base)
|
||||
FROM transactions as buy
|
||||
LEFT JOIN currency_rates as cr2
|
||||
ON cr2.date = buy.date
|
||||
AND cr2.currency = '{$currency}'
|
||||
WHERE buy.symbol = transactions.symbol
|
||||
AND buy.portfolio_id = transactions.portfolio_id
|
||||
AND buy.transaction_type = 'BUY'
|
||||
AND buy.date <= transactions.date
|
||||
) END)
|
||||
AS rate"
|
||||
)
|
||||
->selectRaw(
|
||||
"(CASE
|
||||
WHEN transactions.transaction_type = 'BUY'
|
||||
THEN AVG(transactions.cost_basis_base)
|
||||
ELSE (
|
||||
SELECT
|
||||
AVG(-buy.cost_basis_base)
|
||||
FROM transactions as buy
|
||||
WHERE buy.symbol = transactions.symbol
|
||||
AND buy.portfolio_id = transactions.portfolio_id
|
||||
AND buy.transaction_type = 'BUY'
|
||||
AND buy.date <= transactions.date
|
||||
) END)
|
||||
AS cost_basis_base"
|
||||
)
|
||||
->groupBy([
|
||||
'transactions.symbol',
|
||||
'transactions.date',
|
||||
'transactions.portfolio_id',
|
||||
'transactions.transaction_type',
|
||||
'transactions.quantity',
|
||||
'cr.rate',
|
||||
]), 'cost_basis_display', function ($join) {
|
||||
$join->on('transactions.symbol', '=', 'cost_basis_display.symbol')
|
||||
->on('transactions.portfolio_id', '=', 'cost_basis_display.portfolio_id')
|
||||
->on('transactions.date', '=', 'cost_basis_display.date');
|
||||
})
|
||||
->selectRaw(
|
||||
"SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN (transactions.sale_price_base - transactions.cost_basis_base) * transactions.quantity * COALESCE(cr.rate, 1) ELSE 0 END) AS realized_gain_dollars"
|
||||
)
|
||||
->selectRaw(
|
||||
'SUM(cost_basis_display.cost_basis_base * cost_basis_display.quantity * cost_basis_display.rate) AS total_cost_basis'
|
||||
)
|
||||
->groupBy(['transactions.symbol', 'transactions.portfolio_id']),
|
||||
'transactions_display',
|
||||
function ($join) {
|
||||
$join->on('holdings.symbol', '=', 'transactions_display.symbol')
|
||||
->on('holdings.portfolio_id', '=', 'transactions_display.portfolio_id');
|
||||
}
|
||||
)
|
||||
->leftJoinSub(
|
||||
DB::table('dividends')
|
||||
->join('transactions as tx', function ($join) {
|
||||
$join->on('tx.symbol', '=', 'dividends.symbol')
|
||||
->on('tx.date', '<=', 'dividends.date');
|
||||
})
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join->on('cr.date', '=', 'dividends.date')
|
||||
->where('cr.currency', '=', $currency);
|
||||
})
|
||||
->select(['dividends.symbol'])
|
||||
->selectRaw(
|
||||
"SUM(((CASE WHEN transaction_type = 'BUY' THEN tx.quantity ELSE 0 END) - (CASE WHEN transaction_type = 'SELL' THEN tx.quantity ELSE 0 END)) * dividends.dividend_amount_base * COALESCE(cr.rate, 1)) AS total_dividends_earned"
|
||||
)
|
||||
->groupBy(['dividends.symbol']),
|
||||
'dividends_display',
|
||||
function ($join) {
|
||||
$join->on('holdings.symbol', '=', 'dividends_display.symbol');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public function syncTransactionsAndDividends()
|
||||
{
|
||||
// pull existing transaction data
|
||||
$query = Transaction::where([
|
||||
'portfolio_id' => $this->portfolio_id,
|
||||
'symbol' => $this->symbol,
|
||||
])->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN quantity ELSE 0 END) AS `qty_purchases`')
|
||||
->selectRaw('SUM(CASE WHEN transaction_type = "SELL" THEN quantity ELSE 0 END) AS `qty_sales`')
|
||||
->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN (quantity * cost_basis) ELSE 0 END) AS `total_cost_basis`')
|
||||
->selectRaw('SUM(CASE WHEN transaction_type = "SELL" THEN (quantity * sale_price) ELSE 0 END) AS `total_sale_price`')
|
||||
->first();
|
||||
'portfolio_id' => $this->portfolio_id,
|
||||
'transactions.symbol' => $this->symbol,
|
||||
])->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN quantity ELSE 0 END) AS qty_purchases")
|
||||
->selectRaw("SUM(CASE WHEN transaction_type = 'SELL' THEN quantity ELSE 0 END) AS qty_sales")
|
||||
->selectRaw("SUM(CASE WHEN transaction_type = 'SELL' THEN (sale_price - cost_basis) * quantity ELSE 0 END) AS realized_gain_dollars")
|
||||
->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN (quantity * cost_basis) ELSE 0 END) AS total_cost_basis")
|
||||
->first();
|
||||
|
||||
$total_quantity = round($query->qty_purchases - $query->qty_sales, 3);
|
||||
$total_quantity = round($query->qty_purchases - $query->qty_sales, 4);
|
||||
|
||||
$average_cost_basis = (
|
||||
$query->qty_purchases > 0
|
||||
&& $total_quantity > 0
|
||||
)
|
||||
? $query->total_cost_basis / $query->qty_purchases
|
||||
: 0;
|
||||
$query->qty_purchases > 0
|
||||
&& $total_quantity > 0
|
||||
) ? $query->total_cost_basis / $query->qty_purchases
|
||||
: 0;
|
||||
|
||||
// update holding
|
||||
$this->fill([
|
||||
'quantity' => $total_quantity,
|
||||
'average_cost_basis' => $average_cost_basis,
|
||||
'total_cost_basis' => $total_quantity * $average_cost_basis,
|
||||
'realized_gain_dollars' => $query->qty_purchases > 0 && $query->total_sale_price > 0
|
||||
? $query->total_sale_price - ($query->qty_sales * ($query->total_cost_basis / $query->qty_purchases))
|
||||
: 0,
|
||||
'dividends_earned' => $this->dividends->sum('total_received')
|
||||
'realized_gain_dollars' => $query->realized_gain_dollars ?? 0,
|
||||
'dividends_earned' => $this->dividends->sum('total_received'),
|
||||
]);
|
||||
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function qtyOwned(\Illuminate\Support\Carbon $date = null)
|
||||
public function qtyOwned(?\Illuminate\Support\Carbon $date = null)
|
||||
{
|
||||
if ($date == null) $date = now();
|
||||
if ($date == null) {
|
||||
$date = now();
|
||||
}
|
||||
|
||||
$transactions = $this->transactions->where('date', '<=', $date);
|
||||
|
||||
@@ -241,78 +428,122 @@ class Holding extends Model
|
||||
return $purchases - $sales;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method that enables calculating daily performance for a given holding
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function dailyPerformance(
|
||||
\Illuminate\Support\Carbon $start_date = null,
|
||||
\Illuminate\Support\Carbon $end_date = null,
|
||||
?\Illuminate\Support\Carbon $start_date = null,
|
||||
?\Illuminate\Support\Carbon $end_date = null,
|
||||
) {
|
||||
if ($start_date == null) $start_date = now();
|
||||
if ($end_date == null) $end_date = now();
|
||||
|
||||
$date_interval = "DATE_ADD(date, INTERVAL 1 DAY)";
|
||||
|
||||
if (config('database.default') === 'sqlite') {
|
||||
|
||||
$date_interval = "date(date, '+1 day')";
|
||||
} else {
|
||||
|
||||
DB::statement('SET cte_max_recursion_depth=1000000;');
|
||||
if ($start_date == null) {
|
||||
$start_date = now();
|
||||
}
|
||||
if ($end_date == null) {
|
||||
$end_date = now();
|
||||
}
|
||||
|
||||
return DB::table(DB::raw("(
|
||||
WITH RECURSIVE date_series AS (
|
||||
SELECT '{$start_date->format('Y-m-d')}' AS date
|
||||
UNION ALL
|
||||
SELECT $date_interval
|
||||
FROM date_series
|
||||
WHERE date < '{$end_date->format('Y-m-d')}'
|
||||
)
|
||||
SELECT date_series.date
|
||||
// MySQL default interval
|
||||
$date_interval = 'DATE_ADD(date, INTERVAL 1 DAY)';
|
||||
$castNumberType = 'decimal';
|
||||
|
||||
// Use SQLite interval grammar
|
||||
if (config('database.default') === 'sqlite') {
|
||||
|
||||
$date_interval = "date(date, '+1 day')";
|
||||
}
|
||||
|
||||
// Default CTE time series query (for MySQL and SQLite)
|
||||
$timeSeriesQuery = DB::table(DB::raw("(
|
||||
WITH RECURSIVE date_series AS (
|
||||
SELECT '{$start_date->toDateString()}' AS date
|
||||
UNION ALL
|
||||
SELECT $date_interval
|
||||
FROM date_series
|
||||
) as date_series")
|
||||
)
|
||||
->select([
|
||||
'date_series.date',
|
||||
DB::raw("
|
||||
ROUND(
|
||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END), 0) -
|
||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END), 0), 3) AS `owned`
|
||||
"),
|
||||
DB::raw("
|
||||
COALESCE(CASE
|
||||
WHEN (
|
||||
ROUND(
|
||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END), 0) -
|
||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END), 0), 3)
|
||||
) = 0 THEN 0
|
||||
ELSE SUM(CASE
|
||||
WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis
|
||||
ELSE 0
|
||||
END)
|
||||
END, 0) AS cost_basis
|
||||
"),
|
||||
DB::raw("COALESCE(SUM(CASE WHEN transaction_type = 'SELL' THEN ((sale_price - cost_basis) * quantity) ELSE 0 END), 0) AS `realized_gains`")
|
||||
])
|
||||
->leftJoin('transactions', function ($join) {
|
||||
$join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date')
|
||||
->where('transactions.symbol', '=', $this->symbol)
|
||||
->where('transactions.portfolio_id', '=', $this->portfolio_id);
|
||||
})
|
||||
->groupBy('date_series.date')
|
||||
->orderBy('date_series.date')
|
||||
->get()
|
||||
->keyBy('date');
|
||||
WHERE date < '{$end_date->toDateString()}'
|
||||
)
|
||||
SELECT date_series.date
|
||||
FROM date_series
|
||||
) as date_series"));
|
||||
|
||||
// PGSql time series query
|
||||
if (config('database.default') === 'pgsql') {
|
||||
|
||||
$timeSeriesQuery = DB::table(DB::raw("
|
||||
generate_series(
|
||||
date '{$start_date->toDateString()}',
|
||||
date '{$end_date->toDateString()}',
|
||||
interval '1 day'
|
||||
) as date_series"));
|
||||
|
||||
$castNumberType = 'numeric';
|
||||
}
|
||||
|
||||
// Set MySQL-like query CTE max iterations
|
||||
if (config('database.default') === 'mysql') {
|
||||
|
||||
// MySQL default
|
||||
$max_recursion_var_name = 'cte_max_recursion_depth';
|
||||
|
||||
// Determine if running MySQL or MariaDB
|
||||
$versionString = Arr::get(
|
||||
DB::select('SELECT VERSION() as version;'),
|
||||
'0', new \stdClass
|
||||
)->version;
|
||||
if (stripos($versionString, 'MariaDB') !== false) {
|
||||
$max_recursion_var_name = 'max_recursive_iterations'; // Must be MariaDB
|
||||
}
|
||||
|
||||
DB::statement("SET $max_recursion_var_name=1000000;");
|
||||
}
|
||||
|
||||
// Extracted query for counting QTY owned
|
||||
$quantityQuery = "ROUND(CAST(COALESCE(
|
||||
SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END)
|
||||
- SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END),
|
||||
0
|
||||
) AS {$castNumberType}), 3)";
|
||||
|
||||
return $timeSeriesQuery
|
||||
->select([
|
||||
'date_series.date',
|
||||
DB::raw("
|
||||
{$quantityQuery} AS owned
|
||||
"),
|
||||
DB::raw("
|
||||
CASE
|
||||
WHEN ({$quantityQuery}) = 0 THEN 0
|
||||
ELSE SUM(CASE
|
||||
WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis_base
|
||||
ELSE 0
|
||||
END)
|
||||
END AS cost_basis
|
||||
"),
|
||||
DB::raw("COALESCE(SUM(CASE WHEN transaction_type = 'SELL' THEN ((sale_price_base - cost_basis_base) * quantity) ELSE 0 END), 0) AS realized_gains"),
|
||||
])
|
||||
->leftJoin('transactions', function ($join) {
|
||||
$join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date')
|
||||
->where('transactions.symbol', '=', $this->symbol)
|
||||
->where('transactions.portfolio_id', '=', $this->portfolio_id);
|
||||
})
|
||||
->groupBy('date_series.date')
|
||||
->orderBy('date_series.date')
|
||||
->get()
|
||||
->keyBy('date');
|
||||
}
|
||||
|
||||
public function getFormattedTransactions()
|
||||
{
|
||||
$formattedTransactions = '';
|
||||
foreach($this->transactions->sortByDesc('date') as $transaction) {
|
||||
$formattedTransactions .= " * ".$transaction->date->format('Y-m-d')
|
||||
." ". $transaction->transaction_type
|
||||
." ". $transaction->quantity
|
||||
." @ ". $transaction->cost_basis
|
||||
foreach ($this->transactions->sortByDesc('date') as $transaction) {
|
||||
$formattedTransactions .= ' * '.$transaction->date->toDateString()
|
||||
.' '.$transaction->transaction_type
|
||||
.' '.$transaction->quantity
|
||||
.' @ '.$transaction->cost_basis
|
||||
." each \n\n";
|
||||
}
|
||||
|
||||
return $formattedTransactions;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+39
-11
@@ -1,23 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Actions\CopyToBaseCurrency;
|
||||
use App\Casts\BaseCurrency;
|
||||
use App\Interfaces\MarketData\MarketDataInterface;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Pipeline;
|
||||
|
||||
class MarketData extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $primaryKey = 'symbol';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $fillable = [
|
||||
'symbol',
|
||||
'name',
|
||||
'currency',
|
||||
'market_value',
|
||||
'market_value_base',
|
||||
'fifty_two_week_high',
|
||||
'fifty_two_week_low',
|
||||
'forward_pe',
|
||||
@@ -25,22 +34,41 @@ class MarketData extends Model
|
||||
'market_cap',
|
||||
'book_value',
|
||||
'last_dividend_date',
|
||||
'dividend_yield'
|
||||
'last_dividend_amount',
|
||||
'dividend_yield',
|
||||
'meta_data',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_dividend_date' => 'datetime',
|
||||
'market_value' => 'float',
|
||||
'market_value_base' => BaseCurrency::class,
|
||||
'fifty_two_week_high' => 'float',
|
||||
'fifty_two_week_low' => 'float',
|
||||
'forward_pe' => 'float',
|
||||
'trailing_pe' => 'float',
|
||||
'market_cap' => 'float',
|
||||
'market_cap' => 'integer',
|
||||
'book_value' => 'float',
|
||||
'dividend_yield' => 'float'
|
||||
'last_dividend_date' => 'datetime',
|
||||
'last_dividend_amount' => 'float',
|
||||
'dividend_yield' => 'float',
|
||||
'meta_data' => 'json',
|
||||
];
|
||||
|
||||
public function holdings()
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::saving(function ($market_data) {
|
||||
|
||||
$market_data = Pipeline::send($market_data)
|
||||
->through([
|
||||
CopyToBaseCurrency::class,
|
||||
])
|
||||
->then(fn (MarketData $market_data) => $market_data);
|
||||
});
|
||||
}
|
||||
|
||||
public function holdings()
|
||||
{
|
||||
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
||||
}
|
||||
@@ -50,20 +78,20 @@ class MarketData extends Model
|
||||
return $query->where('symbol', $symbol);
|
||||
}
|
||||
|
||||
public static function getMarketData($symbol, $force = false)
|
||||
public static function getMarketData($symbol, $force = false): self
|
||||
{
|
||||
$market_data = self::firstOrNew([
|
||||
'symbol' => $symbol
|
||||
'symbol' => $symbol,
|
||||
]);
|
||||
|
||||
// check if new or stale
|
||||
if (
|
||||
$force
|
||||
|| !$market_data->exists
|
||||
|| ! $market_data->exists
|
||||
|| is_null($market_data->updated_at)
|
||||
|| $market_data->updated_at->diffInMinutes(now()) >= config('investbrain.refresh')
|
||||
) {
|
||||
|
||||
|
||||
// get quote
|
||||
$quote = app(MarketDataInterface::class)->quote($symbol);
|
||||
|
||||
@@ -76,4 +104,4 @@ class MarketData extends Model
|
||||
|
||||
return $market_data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+102
-71
@@ -1,16 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\AiChat;
|
||||
use App\Interfaces\MarketData\MarketDataInterface;
|
||||
use App\Notifications\InvitedOnboardingNotification;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
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 Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Portfolio extends Model
|
||||
{
|
||||
@@ -28,7 +31,7 @@ class Portfolio extends Model
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
|
||||
static::saved(function ($portfolio) {
|
||||
|
||||
self::ensurePortfolioHasOwner($portfolio);
|
||||
@@ -38,7 +41,7 @@ class Portfolio extends Model
|
||||
protected $hidden = [];
|
||||
|
||||
protected $casts = [
|
||||
'wishlist' => 'boolean'
|
||||
'wishlist' => 'boolean',
|
||||
];
|
||||
|
||||
protected $with = ['users', 'transactions'];
|
||||
@@ -51,8 +54,8 @@ class Portfolio extends Model
|
||||
public function holdings()
|
||||
{
|
||||
return $this->hasMany(Holding::class, 'portfolio_id')
|
||||
->withMarketData()
|
||||
->withPerformance();
|
||||
->withMarketData()
|
||||
->withPerformance();
|
||||
}
|
||||
|
||||
public function transactions()
|
||||
@@ -75,25 +78,25 @@ class Portfolio extends Model
|
||||
return $this->morphMany(AiChat::class, 'chatable')->where('user_id', auth()->user()->id);
|
||||
}
|
||||
|
||||
public function scopeMyPortfolios()
|
||||
public function scopeMyPortfolios()
|
||||
{
|
||||
return $this->whereHas('users', function ($query) {
|
||||
$query->where('user_id', auth()->user()->id);
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeFullAccess($query, $user_id = null)
|
||||
public function scopeFullAccess($query, $user_id = null)
|
||||
{
|
||||
return $query->whereHas('users', function ($query) use ($user_id) {
|
||||
$query->where('user_id', $user_id ?? auth()->user()->id)
|
||||
->where(function ($query) {
|
||||
$query->where('full_access', true)
|
||||
->orWhere('owner', true);
|
||||
});
|
||||
->where(function ($query) {
|
||||
$query->where('full_access', true)
|
||||
->orWhere('owner', true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeWithoutWishlists()
|
||||
public function scopeWithoutWishlists()
|
||||
{
|
||||
return $this->where(['wishlist' => false]);
|
||||
}
|
||||
@@ -101,7 +104,7 @@ class Portfolio extends Model
|
||||
public function setOwnerIdAttribute($value)
|
||||
{
|
||||
// enable queued jobs to create portfolios with owners
|
||||
if (!auth()->user()?->id && !$this->owner_id) {
|
||||
if (! auth()->user()?->id && ! $this->owner_id) {
|
||||
static::$owner_id = $value;
|
||||
}
|
||||
}
|
||||
@@ -113,109 +116,103 @@ class Portfolio extends Model
|
||||
|
||||
public function getOwnerAttribute()
|
||||
{
|
||||
if (!$this->relationLoaded('user')) {
|
||||
|
||||
if (! $this->relationLoaded('user')) {
|
||||
|
||||
$this->load('users');
|
||||
}
|
||||
|
||||
return $this->users->where('pivot.owner', true)->first();
|
||||
}
|
||||
|
||||
public static function ensurePortfolioHasOwner(self $portfolio)
|
||||
public static function ensurePortfolioHasOwner(self $portfolio)
|
||||
{
|
||||
// make sure we don't remove owner access
|
||||
if (!$portfolio->owner_id) {
|
||||
if (! $portfolio->owner_id) {
|
||||
$owner[static::$owner_id ?? auth()->user()->id] = ['owner' => true];
|
||||
|
||||
// save
|
||||
$portfolio->users()->sync($owner);
|
||||
static::$owner_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes daily change history for a portfolio to the database
|
||||
*/
|
||||
public function syncDailyChanges(): void
|
||||
{
|
||||
$holdings = $this->holdings()
|
||||
->join('transactions', function($join) {
|
||||
$join->on('transactions.symbol', '=', 'holdings.symbol')
|
||||
->where('transactions.portfolio_id', '=', $this->id);
|
||||
})
|
||||
->select('holdings.symbol', 'holdings.portfolio_id', DB::raw('min(transactions.date) as first_transaction_date')) // get first transaction date
|
||||
->groupBy(['holdings.symbol', 'holdings.portfolio_id'])
|
||||
->get();
|
||||
->join('transactions', function ($join) {
|
||||
$join->on('transactions.symbol', '=', 'holdings.symbol')
|
||||
->where('transactions.portfolio_id', '=', $this->id);
|
||||
})
|
||||
->select('holdings.symbol', 'holdings.portfolio_id', DB::raw('min(transactions.date) as first_transaction_date')) // get first transaction date
|
||||
->groupBy(['holdings.symbol', 'holdings.portfolio_id'])
|
||||
->get();
|
||||
|
||||
$dividends = Dividend::whereIn('symbol', $holdings->pluck('symbol'))->get();
|
||||
|
||||
$total_performance = [];
|
||||
|
||||
$holdings->each(function($holding) use (&$total_performance, $dividends) {
|
||||
// get unique currencies for holdings
|
||||
foreach ($holdings->groupBy('market_data.currency')->keys() as $currency) {
|
||||
$currency_rates[$currency] = CurrencyRate::timeSeriesRates($currency, $holdings->min('first_transaction_date'), now());
|
||||
}
|
||||
|
||||
$holdings->each(function ($holding) use (&$total_performance, $currency_rates) {
|
||||
|
||||
$period = CarbonPeriod::create(
|
||||
$holding->first_transaction_date,
|
||||
now()->isBefore(Carbon::parse(config('investbrain.daily_change_time_of_day')))
|
||||
? now()->subDay()
|
||||
$holding->first_transaction_date,
|
||||
now()->isBefore(Carbon::parse(config('investbrain.daily_change_time_of_day')))
|
||||
? now()->subDay()
|
||||
: now()
|
||||
);
|
||||
|
||||
$holding->setRelation('dividends', $dividends->where('symbol', $holding->symbol));
|
||||
|
||||
$daily_performance = $holding->dailyPerformance($holding->first_transaction_date, now());
|
||||
$dividends = $holding->dividends->keyBy(function ($dividend, $key) {
|
||||
return $dividend['date']->format('Y-m-d');
|
||||
});
|
||||
$all_history = app(MarketDataInterface::class)->history($holding->symbol, $holding->first_transaction_date, now());
|
||||
|
||||
$dividends_earned = 0;
|
||||
$holding_performance = [];
|
||||
|
||||
foreach($period as $date) {
|
||||
$date = $date->format('Y-m-d');
|
||||
foreach ($period as $date) {
|
||||
$date = $date->toDateString();
|
||||
|
||||
$close = $this->getMostRecentCloseData($all_history, $date);
|
||||
|
||||
|
||||
$total_market_value = $daily_performance->get($date)->owned * $close;
|
||||
$dividends_earned += $daily_performance->get($date)->owned * ($dividends->get($date)?->dividend_amount ?? 0);
|
||||
|
||||
if (Carbon::parse($date)->isWeekday()) {
|
||||
|
||||
$holding_performance[$date] = [
|
||||
'date' => $date,
|
||||
'portfolio_id' => $this->id,
|
||||
'total_market_value' => $total_market_value,
|
||||
'total_cost_basis' => $daily_performance->get($date)->cost_basis,
|
||||
'total_gain' => $total_market_value - $daily_performance->get($date)->cost_basis,
|
||||
'realized_gains' => $daily_performance->get($date)->realized_gains,
|
||||
'total_dividends_earned' => $dividends_earned
|
||||
'total_market_value' => $total_market_value * (1 / Arr::get($currency_rates[$holding->market_data->currency], $date, 1)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($holding_performance as $date => $performance) {
|
||||
if (Arr::get($total_performance, $date) == null) {
|
||||
|
||||
|
||||
$total_performance[$date] = $performance;
|
||||
|
||||
} else {
|
||||
|
||||
$total_performance[$date]['total_market_value'] += $performance['total_market_value'];
|
||||
$total_performance[$date]['total_cost_basis'] += $performance['total_cost_basis'];
|
||||
$total_performance[$date]['total_gain'] += $performance['total_gain'];
|
||||
$total_performance[$date]['realized_gains'] += $performance['realized_gains'];
|
||||
$total_performance[$date]['total_dividends_earned'] += $performance['total_dividends_earned'];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!empty($total_performance)) {
|
||||
if (! empty($total_performance)) {
|
||||
DB::transaction(function () use ($total_performance) {
|
||||
|
||||
|
||||
// delete old history
|
||||
$firstDate = array_keys($total_performance)[0];
|
||||
$this->daily_change()->where('date', '<', $firstDate)->delete();
|
||||
|
||||
// upsert new history
|
||||
$this->daily_change()->upsert(
|
||||
$total_performance,
|
||||
['date', 'portfolio_id'],
|
||||
[
|
||||
'total_market_value',
|
||||
'total_cost_basis',
|
||||
'total_gain',
|
||||
'realized_gains',
|
||||
'total_dividends_earned'
|
||||
]
|
||||
);
|
||||
});
|
||||
@@ -226,11 +223,11 @@ class Portfolio extends Model
|
||||
{
|
||||
$close = Arr::get($history, "$date.close", 0);
|
||||
|
||||
if (!$close && $i < $max_attempts) {
|
||||
if (! $close && $i < $max_attempts) {
|
||||
|
||||
$i++;
|
||||
|
||||
$date = Carbon::parse($date)->subDay()->format('Y-m-d');
|
||||
|
||||
$date = Carbon::parse($date)->subDay()->toDateString();
|
||||
|
||||
return $this->getMostRecentCloseData($history, $date, $i);
|
||||
}
|
||||
@@ -241,16 +238,50 @@ class Portfolio extends Model
|
||||
public function getFormattedHoldings()
|
||||
{
|
||||
$formattedHoldings = '';
|
||||
foreach($this->holdings as $holding) {
|
||||
$formattedHoldings .= " * Holding of ".$holding->market_data->name." (".$holding->symbol.")"
|
||||
."; with ". ($holding->quantity > 0 ? $holding->quantity : 'ZERO') . " shares"
|
||||
."; avg cost basis ". $holding->average_cost_basis
|
||||
."; curr market value ". $holding->market_data->market_value
|
||||
."; unrealized gains ". $holding->market_gain_dollars
|
||||
."; realized gains ". $holding->realized_gain_dollars
|
||||
."; dividends earned ". $holding->dividends_earned
|
||||
foreach ($this->holdings as $holding) {
|
||||
$formattedHoldings .= ' * Holding of '.$holding->market_data->name.' ('.$holding->symbol.')'
|
||||
.'; with '.($holding->quantity > 0 ? $holding->quantity : 'ZERO').' shares'
|
||||
.'; avg cost basis '.$holding->average_cost_basis
|
||||
.'; curr market value '.$holding->market_data->market_value
|
||||
.'; unrealized gains '.$holding->market_gain_dollars
|
||||
.'; realized gains '.$holding->realized_gain_dollars
|
||||
.'; dividends earned '.$holding->dividends_earned
|
||||
."\n\n";
|
||||
}
|
||||
|
||||
return $formattedHoldings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a portfolio with a user
|
||||
*/
|
||||
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
|
||||
*/
|
||||
public function unShare(string $userId): void
|
||||
{
|
||||
$this->users()->detach($userId);
|
||||
}
|
||||
}
|
||||
|
||||
+44
-39
@@ -1,18 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Interfaces\MarketData\MarketDataInterface;
|
||||
use App\Traits\HasMarketData;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Split extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasMarketData;
|
||||
use HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
@@ -28,22 +32,23 @@ class Split extends Model
|
||||
'last_date' => 'datetime',
|
||||
];
|
||||
|
||||
public function holdings() {
|
||||
public function holdings(): HasMany
|
||||
{
|
||||
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
||||
}
|
||||
|
||||
public function transactions() {
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
|
||||
}
|
||||
|
||||
/**
|
||||
* Grab new split data
|
||||
*
|
||||
* @param string $symbol
|
||||
* @param \DateTimeInterface|null $start_date
|
||||
* @param \DateTimeInterface|null $start_date
|
||||
* @return void
|
||||
*/
|
||||
public static function refreshSplitData(string $symbol)
|
||||
public static function refreshSplitData(string $symbol)
|
||||
{
|
||||
// dates for split data
|
||||
$splits_meta = self::where(['symbol' => $symbol])
|
||||
@@ -58,9 +63,9 @@ class Split extends Model
|
||||
|
||||
// nope, need to populate newer split data
|
||||
if ($splits_meta->total_splits) {
|
||||
|
||||
|
||||
$start_date = $splits_meta->last_date->addHours(48);
|
||||
$end_date = now();
|
||||
$end_date = now();
|
||||
}
|
||||
|
||||
// get some data
|
||||
@@ -71,10 +76,10 @@ class Split extends Model
|
||||
if ($split_data->isNotEmpty()) {
|
||||
|
||||
// insert records
|
||||
(new self)->insert($split_data->map(function($split) {
|
||||
(new self)->insertOrIgnore($split_data->map(function ($split) {
|
||||
|
||||
return [...$split, ...['id' => Str::uuid()->toString()]];
|
||||
})->toArray());
|
||||
})->toArray());
|
||||
}
|
||||
|
||||
// sync to transactions
|
||||
@@ -84,39 +89,39 @@ class Split extends Model
|
||||
/**
|
||||
* Syncs all transactions of symbol with split data
|
||||
*
|
||||
* @param string $symbol
|
||||
* @param string $symbol
|
||||
* @return void
|
||||
*/
|
||||
public static function syncToTransactions($symbol)
|
||||
public static function syncToTransactions($symbol)
|
||||
{
|
||||
// get splits joined with matching holdings
|
||||
$splits = self::select([
|
||||
'splits.date',
|
||||
'splits.symbol',
|
||||
'splits.split_amount',
|
||||
'holdings.portfolio_id'
|
||||
])
|
||||
->where([
|
||||
'splits.symbol' => $symbol,
|
||||
])
|
||||
->whereDate('splits.date', '>', DB::raw('IFNULL(holdings.splits_synced_at, "0000-00-00")'))
|
||||
->where('holdings.quantity', '>', 0)
|
||||
->join('holdings', 'splits.symbol', 'holdings.symbol')
|
||||
->orderBy('splits.date', 'ASC')
|
||||
->get();
|
||||
'splits.date',
|
||||
'splits.symbol',
|
||||
'splits.split_amount',
|
||||
'holdings.portfolio_id',
|
||||
])
|
||||
->where([
|
||||
'splits.symbol' => $symbol,
|
||||
])
|
||||
->whereDate('splits.date', '>', DB::raw("COALESCE(holdings.splits_synced_at, '1901-01-01')"))
|
||||
->where('holdings.quantity', '>', 0)
|
||||
->join('holdings', 'splits.symbol', 'holdings.symbol')
|
||||
->orderBy('splits.date', 'ASC')
|
||||
->get();
|
||||
|
||||
foreach($splits as $split) {
|
||||
foreach ($splits as $split) {
|
||||
|
||||
// get qty owned when split was issued
|
||||
$qty_owned = Transaction::where([
|
||||
'symbol' => $split->symbol,
|
||||
'portfolio_id' => $split->portfolio_id
|
||||
])
|
||||
->whereDate('transactions.date', '<', $split->date->format('Y-m-d'))
|
||||
->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN quantity ELSE 0 END) -
|
||||
SUM(CASE WHEN transaction_type = "SELL" THEN quantity ELSE 0 END) AS qty_owned')
|
||||
'symbol' => $split->symbol,
|
||||
'portfolio_id' => $split->portfolio_id,
|
||||
])
|
||||
->whereDate('transactions.date', '<', $split->date->toDateString())
|
||||
->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN quantity ELSE 0 END) -
|
||||
SUM(CASE WHEN transaction_type = 'SELL' THEN quantity ELSE 0 END) AS qty_owned")
|
||||
->value('qty_owned');
|
||||
|
||||
|
||||
if ($qty_owned > 0) {
|
||||
|
||||
Transaction::create([
|
||||
@@ -128,14 +133,14 @@ class Split extends Model
|
||||
'cost_basis' => 0,
|
||||
'split' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now()
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
Holding::where([
|
||||
'symbol' => $split->symbol,
|
||||
'portfolio_id' => $split->portfolio_id
|
||||
'portfolio_id' => $split->portfolio_id,
|
||||
])->update([
|
||||
'splits_synced_at' => now()
|
||||
'splits_synced_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
+57
-67
@@ -1,17 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\MarketData;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Actions\ConvertToMarketDataCurrency;
|
||||
use App\Actions\CopyToBaseCurrency;
|
||||
use App\Actions\EnsureCostBasisAddedToSale;
|
||||
use App\Actions\EnsureDailyChangeIsSynced;
|
||||
use App\Casts\BaseCurrency;
|
||||
use App\Traits\HasMarketData;
|
||||
use Illuminate\Contracts\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Pipeline;
|
||||
|
||||
class Transaction extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasMarketData;
|
||||
use HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
@@ -19,11 +30,12 @@ class Transaction extends Model
|
||||
'date',
|
||||
'portfolio_id',
|
||||
'transaction_type',
|
||||
'currency',
|
||||
'quantity',
|
||||
'cost_basis',
|
||||
'sale_price',
|
||||
'split',
|
||||
'reinvested_dividend'
|
||||
'reinvested_dividend',
|
||||
];
|
||||
|
||||
protected $hidden = [];
|
||||
@@ -31,7 +43,12 @@ class Transaction extends Model
|
||||
protected $casts = [
|
||||
'date' => 'datetime',
|
||||
'split' => 'boolean',
|
||||
'reinvested_dividend' => 'boolean'
|
||||
'reinvested_dividend' => 'boolean',
|
||||
'quantity' => 'float',
|
||||
'cost_basis' => 'float',
|
||||
'sale_price' => 'float',
|
||||
'cost_basis_base' => BaseCurrency::class,
|
||||
'sale_price_base' => BaseCurrency::class,
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
@@ -40,26 +57,33 @@ class Transaction extends Model
|
||||
|
||||
static::saving(function ($transaction) {
|
||||
|
||||
if ($transaction->transaction_type == 'SELL') {
|
||||
|
||||
$transaction->ensureCostBasisIsAddedToSale();
|
||||
}
|
||||
$transaction = Pipeline::send($transaction)
|
||||
->through([
|
||||
ConvertToMarketDataCurrency::class,
|
||||
EnsureCostBasisAddedToSale::class,
|
||||
CopyToBaseCurrency::class,
|
||||
])
|
||||
->then(fn (Transaction $transaction) => $transaction);
|
||||
});
|
||||
|
||||
static::saved(function ($transaction) {
|
||||
|
||||
$transaction->syncToHolding();
|
||||
|
||||
$transaction->refreshMarketData();
|
||||
$transaction = Pipeline::send($transaction)
|
||||
->through([
|
||||
EnsureDailyChangeIsSynced::class,
|
||||
])
|
||||
->then(fn (Transaction $transaction) => $transaction);
|
||||
|
||||
cache()->forget('portfolio-metrics-' . $transaction->portfolio_id);
|
||||
cache()->forget('portfolio-metrics-'.$transaction->portfolio_id);
|
||||
});
|
||||
|
||||
static::deleted(function ($transaction) {
|
||||
|
||||
$transaction->syncToHolding();
|
||||
|
||||
cache()->forget('portfolio-metrics-' . $transaction->portfolio_id);
|
||||
cache()->forget('portfolio-metrics-'.$transaction->portfolio_id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,62 +97,53 @@ class Transaction extends Model
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Related market data for transaction
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function market_data()
|
||||
{
|
||||
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
|
||||
}
|
||||
|
||||
/**
|
||||
* Related portfolio
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function portfolio()
|
||||
public function portfolio(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Portfolio::class);
|
||||
}
|
||||
|
||||
public function scopeWithMarketData($query)
|
||||
public function scopeWithMarketData($query): Builder
|
||||
{
|
||||
return $query->withAggregate('market_data', 'name')
|
||||
->withAggregate('market_data', 'market_value')
|
||||
->withAggregate('market_data', 'fifty_two_week_low')
|
||||
->withAggregate('market_data', 'fifty_two_week_high')
|
||||
->withAggregate('market_data', 'updated_at')
|
||||
->join('market_data', 'transactions.symbol', 'market_data.symbol');
|
||||
->withAggregate('market_data', 'market_value')
|
||||
->withAggregate('market_data', 'currency')
|
||||
->withAggregate('market_data', 'fifty_two_week_low')
|
||||
->withAggregate('market_data', 'fifty_two_week_high')
|
||||
->withAggregate('market_data', 'updated_at')
|
||||
->join('market_data', 'transactions.symbol', 'market_data.symbol');
|
||||
}
|
||||
|
||||
public function scopePortfolio($query, $portfolio)
|
||||
|
||||
public function scopePortfolio($query, $portfolio): Builder
|
||||
{
|
||||
return $query->where('portfolio_id', $portfolio);
|
||||
}
|
||||
|
||||
public function scopeSymbol($query, $symbol)
|
||||
public function scopeSymbol($query, $symbol): Builder
|
||||
{
|
||||
return $query->where('symbol', $symbol);
|
||||
}
|
||||
|
||||
public function scopeBuy($query)
|
||||
public function scopeBuy($query): Builder
|
||||
{
|
||||
return $query->where('transaction_type', 'BUY');
|
||||
}
|
||||
|
||||
public function scopeSell($query)
|
||||
public function scopeSell($query): Builder
|
||||
{
|
||||
return $query->where('transaction_type', 'SELL');
|
||||
}
|
||||
|
||||
public function scopeBeforeDate($query, $date)
|
||||
public function scopeBeforeDate($query, $date): Builder
|
||||
{
|
||||
return $query->whereDate('date', '<=', $date);
|
||||
}
|
||||
|
||||
public function scopeMyTransactions()
|
||||
public function scopeMyTransactions(): Builder
|
||||
{
|
||||
return $this->whereHas('portfolio', function ($query) {
|
||||
$query->whereHas('users', function ($query) {
|
||||
@@ -137,36 +152,11 @@ class Transaction extends Model
|
||||
});
|
||||
}
|
||||
|
||||
public function refreshMarketData()
|
||||
{
|
||||
return MarketData::getMarketData($this->attributes['symbol']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes average cost basis to a sale transaction
|
||||
*
|
||||
* @return Transaction
|
||||
*/
|
||||
public function ensureCostBasisIsAddedToSale()
|
||||
{
|
||||
$average_cost_basis = Transaction::where([
|
||||
'portfolio_id' => $this->portfolio_id,
|
||||
'symbol' => $this->symbol,
|
||||
'transaction_type' => 'BUY',
|
||||
])->whereDate('date', '<=', $this->date)
|
||||
->average('cost_basis');
|
||||
|
||||
$this->cost_basis = $average_cost_basis ?? 0;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs the holding related to this transaction
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function syncToHolding() {
|
||||
public function syncToHolding(): void
|
||||
{
|
||||
|
||||
// if symbol name changed, sync previous symbol too
|
||||
if (Arr::has($this->changes, 'symbol')) {
|
||||
@@ -181,14 +171,14 @@ class Transaction extends Model
|
||||
// get the holding for a symbol and portfolio (or create one)
|
||||
Holding::firstOrNew([
|
||||
'portfolio_id' => $this->portfolio_id,
|
||||
'symbol' => $this->symbol
|
||||
'symbol' => $this->symbol,
|
||||
], [
|
||||
'portfolio_id' => $this->portfolio_id,
|
||||
'symbol' => $this->symbol,
|
||||
'quantity' => $this->quantity,
|
||||
'average_cost_basis' => $this->cost_basis,
|
||||
'total_cost_basis' => $this->quantity * $this->cost_basis,
|
||||
'average_cost_basis' => $this->cost_basis_base,
|
||||
'total_cost_basis' => $this->quantity * $this->cost_basis_base,
|
||||
'splits_synced_at' => now(),
|
||||
])->syncTransactionsAndDividends();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
-12
@@ -1,34 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasConnectedAccounts;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Laravel\Jetstream\HasProfilePhoto;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Staudenmeir\EloquentHasManyDeep\HasManyDeep;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Staudenmeir\EloquentHasManyDeep\HasRelationships;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Arr;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Jetstream\HasProfilePhoto;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Staudenmeir\EloquentHasManyDeep\HasManyDeep;
|
||||
use Staudenmeir\EloquentHasManyDeep\HasRelationships;
|
||||
|
||||
class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
use HasApiTokens;
|
||||
use HasConnectedAccounts;
|
||||
use HasFactory;
|
||||
use HasProfilePhoto;
|
||||
use HasRelationships;
|
||||
use HasUuids;
|
||||
use Notifiable;
|
||||
use TwoFactorAuthenticatable;
|
||||
use HasUuids;
|
||||
use HasRelationships;
|
||||
use HasConnectedAccounts;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -48,6 +52,8 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'admin' => 'boolean',
|
||||
'options' => 'json',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -65,7 +71,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
return $this->hasManyDeep(Holding::class, ['portfolio_user', Portfolio::class])
|
||||
->withMarketData()
|
||||
->withPerformance();
|
||||
->withPerformance();
|
||||
}
|
||||
|
||||
public function transactions(): HasManyDeep
|
||||
@@ -78,6 +84,28 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
WHEN transaction_type = \'SELL\'
|
||||
THEN COALESCE(transactions.sale_price - transactions.cost_basis, 0)
|
||||
ELSE COALESCE(market_data.market_value - transactions.cost_basis, 0)
|
||||
END AS gain_dollars');
|
||||
END AS gain_dollars');
|
||||
}
|
||||
|
||||
public function getCurrency(): string
|
||||
{
|
||||
return Arr::get($this->options, 'display_currency') ?? config('investbrain.base_currency');
|
||||
}
|
||||
|
||||
public function getLocale(): string
|
||||
{
|
||||
$available_locales = Arr::pluck(config('app.available_locales'), 'locale');
|
||||
|
||||
return Arr::get($this->options, 'locale') ?? request()->getPreferredLanguage($available_locales) ?? config('app.locale');
|
||||
}
|
||||
|
||||
public function setOption(mixed $key, string $value): self
|
||||
{
|
||||
|
||||
$options = is_array($key) ? $key : [$key => $value];
|
||||
|
||||
$this->options = array_merge($this->options ?? [], $options);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ImportFailedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
@@ -16,7 +18,7 @@ class ImportFailedNotification extends Notification implements ShouldQueue
|
||||
*/
|
||||
public function __construct(
|
||||
public string $errorMessage
|
||||
) { }
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
@@ -34,12 +36,12 @@ class ImportFailedNotification extends Notification implements ShouldQueue
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->greeting('Oh no!')
|
||||
->subject("Your Investbrain import failed!")
|
||||
->line("Heads up, your Investbrain import was unable to successfully complete. There were errors which caused the import to fail.")
|
||||
->action("Try again?", route('import-export'))
|
||||
->line("**Technical details:**")
|
||||
->line($this->errorMessage);
|
||||
->greeting('Oh no!')
|
||||
->subject('Your Investbrain import failed!')
|
||||
->line('Heads up, your Investbrain import was unable to successfully complete. There were errors which caused the import to fail.')
|
||||
->action('Try again?', route('import-export'))
|
||||
->line('**Technical details:**')
|
||||
->line($this->errorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Portfolio;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ImportSucceededNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
@@ -16,7 +16,7 @@ class ImportSucceededNotification extends Notification implements ShouldQueue
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct() { }
|
||||
public function __construct() {}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
@@ -34,10 +34,10 @@ class ImportSucceededNotification extends Notification implements ShouldQueue
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->greeting('Woot! 🎉')
|
||||
->subject("Your Investbrain import was successful!")
|
||||
->line("Just a heads up that your Investbrain import succeeded! Your portfolios, transactions, and daily changes are now available in your account.")
|
||||
->action("Get Started", route('dashboard'));
|
||||
->greeting('Woot! 🎉')
|
||||
->subject('Your Investbrain import was successful!')
|
||||
->line('Just a heads up that your Investbrain import succeeded! Your portfolios, transactions, and daily changes are now available in your account.')
|
||||
->action('Get Started', route('dashboard'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Portfolio;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class InvitedOnboardingNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
@@ -19,7 +21,7 @@ class InvitedOnboardingNotification extends Notification implements ShouldQueue
|
||||
public function __construct(
|
||||
public Portfolio $portfolio,
|
||||
public User $sender,
|
||||
) { }
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
@@ -40,14 +42,14 @@ class InvitedOnboardingNotification extends Notification implements ShouldQueue
|
||||
$url = url()->signedRoute('invited_onboarding', ['portfolio' => $this->portfolio->id, 'user' => $notifiable->id], now()->addDays(90));
|
||||
|
||||
return (new MailMessage)
|
||||
->replyTo($this->sender->email, $this->sender->name)
|
||||
->greeting('Hey there! 👋')
|
||||
->subject("You've been invited to {$this->portfolio->title} on Investbrain!")
|
||||
->line("{$this->sender->name} has invited you to **{$this->portfolio->title}** on Investbrain, a smart open-source investment tracker that consolidates and monitors market performance across your different brokerages.")
|
||||
->line("Once you're in, you'll be able to see all the holdings, dividends, market performance and more for {$this->portfolio->title}!")
|
||||
->action("Get Started", $url)
|
||||
->line("If you have any questions, you can reply to this email.")
|
||||
->salutation("See you there,\n". e($this->sender->name));
|
||||
->replyTo($this->sender->email, $this->sender->name)
|
||||
->greeting('Hey there! 👋')
|
||||
->subject("You've been invited to {$this->portfolio->title} on Investbrain!")
|
||||
->line("{$this->sender->name} has invited you to **{$this->portfolio->title}** on Investbrain, a smart open-source investment tracker that consolidates and monitors market performance across your different brokerages.")
|
||||
->line("Once you're in, you'll be able to see all the holdings, dividends, market performance and more for {$this->portfolio->title}!")
|
||||
->action('Get Started', $url)
|
||||
->line('If you have any questions, you can reply to this email.')
|
||||
->salutation("See you there,\n".e($this->sender->name));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use App\Models\ConnectedAccount;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class VerifyConnectedAccountNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
@@ -17,7 +19,7 @@ class VerifyConnectedAccountNotification extends Notification implements ShouldQ
|
||||
*/
|
||||
public function __construct(
|
||||
public string $connected_account_id
|
||||
) { }
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
@@ -40,11 +42,11 @@ class VerifyConnectedAccountNotification extends Notification implements ShouldQ
|
||||
$url = url()->signedRoute('oauth.verify_connected_account', ['connected_account' => $this->connected_account_id], now()->days($days = 7));
|
||||
|
||||
return (new MailMessage)
|
||||
->greeting('Welcome back!')
|
||||
->subject("Connect your $provider account with Investbrain")
|
||||
->line("You recently attempted to log into an existing Investbrain account using $provider. To safeguard your Investbrain account, please confirm this was you by pressing the 'Connect $provider' button below:")
|
||||
->action("Connect $provider", $url)
|
||||
->line("If you do not recognize this activity, we recommend [changing your password](".route('profile.show').") as soon as possible. Otherwise, you can disregard this message. This link will expire in {$days} days.");
|
||||
->greeting('Welcome back!')
|
||||
->subject("Connect your $provider account with Investbrain")
|
||||
->line("You recently attempted to log into an existing Investbrain account using $provider. To safeguard your Investbrain account, please confirm this was you by pressing the 'Connect $provider' button below:")
|
||||
->action("Connect $provider", $url)
|
||||
->line('If you do not recognize this activity, we recommend [changing your password]('.route('profile.show').") as soon as possible. Otherwise, you can disregard this message. This link will expire in {$days} days.");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Portfolio;
|
||||
use App\Models\User;
|
||||
|
||||
class PortfolioPolicy
|
||||
{
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public function readOnly(User $user, Portfolio $portfolio)
|
||||
{
|
||||
$pivot = $portfolio->users()->where('user_id', $user->id)->first();
|
||||
|
||||
return !!$pivot;
|
||||
return (bool) $pivot;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public function fullAccess(User $user, Portfolio $portfolio)
|
||||
{
|
||||
$pivot = $portfolio->users()->where('user_id', $user->id)->first();
|
||||
@@ -28,9 +23,6 @@ class PortfolioPolicy
|
||||
return $pivot && ($pivot->pivot->full_access || $pivot->pivot->owner);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public function owner(User $user, Portfolio $portfolio)
|
||||
{
|
||||
$pivot = $portfolio->users()->where('user_id', $user->id)->first();
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Number;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use NumberFormatter;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -22,6 +28,29 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
JsonResource::withoutWrapping();
|
||||
|
||||
Arr::macro('skipEmptyValues', function (array $array) {
|
||||
|
||||
return Arr::mapWithKeys($array, function (mixed $value, mixed $key) {
|
||||
$result = [];
|
||||
if (! empty($value)) {
|
||||
$result[$key] = $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
});
|
||||
});
|
||||
|
||||
Number::macro('currencySymbol', function (?string $currency = null, ?string $locale = null) {
|
||||
|
||||
$currency = $currency ?? Number::defaultCurrency();
|
||||
|
||||
$locale = $locale ?? Number::defaultLocale();
|
||||
|
||||
$formatter = new NumberFormatter($locale."@currency=$currency", NumberFormatter::CURRENCY);
|
||||
|
||||
return $formatter->getSymbol(NumberFormatter::CURRENCY_SYMBOL);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Laravel\Jetstream\Features;
|
||||
use App\Actions\Jetstream\DeleteUser;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
|
||||
@@ -28,14 +27,6 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
$this->configurePermissions();
|
||||
|
||||
Jetstream::deleteUsersUsing(DeleteUser::class);
|
||||
|
||||
if ( config('investbrain.self_hosted', false) ) {
|
||||
|
||||
Config::set(
|
||||
'jetstream.features',
|
||||
array_keys(Arr::except(array_values(config('jetstream.features')), Features::termsAndPrivacyPolicy()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,13 +34,22 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
*/
|
||||
protected function configurePermissions(): void
|
||||
{
|
||||
Jetstream::defaultApiTokenPermissions(['read']);
|
||||
Jetstream::defaultApiTokenPermissions([
|
||||
// 'portfolio:read',
|
||||
// 'portfolio:write',
|
||||
// 'holding:read',
|
||||
// 'holding:write',
|
||||
// 'transaction:read',
|
||||
// 'transaction:write',
|
||||
]);
|
||||
|
||||
Jetstream::permissions([
|
||||
'create',
|
||||
'read',
|
||||
'update',
|
||||
'delete',
|
||||
// 'Read Portfolios' => 'portfolio:read',
|
||||
// 'Create Portfolios' => 'portfolio:write',
|
||||
// 'Read Holdings' => 'holding:read',
|
||||
// 'Update Holdings' => 'holding:write',
|
||||
// 'Read Transactions' => 'transaction:read',
|
||||
// 'Create Transactions' => 'transaction:write',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
@@ -21,8 +23,13 @@ class VoltServiceProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
Volt::mount([
|
||||
config('livewire.view_path', resource_path('views/livewire')),
|
||||
resource_path('views/pages'),
|
||||
// config('livewire.view_path', resource_path('views/livewire')),
|
||||
resource_path('views/components'),
|
||||
resource_path('views/profile'),
|
||||
resource_path('views/holding'),
|
||||
resource_path('views/transaction'),
|
||||
resource_path('views/portfolio'),
|
||||
resource_path('views/auth'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Models\Portfolio;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class QuantityValidationRule implements ValidationRule
|
||||
@@ -13,44 +17,39 @@ class QuantityValidationRule implements ValidationRule
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected Portfolio $portfolio,
|
||||
protected string $symbol,
|
||||
protected string $transactionType,
|
||||
protected string $date
|
||||
) {
|
||||
$this->portfolio = $portfolio;
|
||||
$this->symbol = $symbol;
|
||||
$this->transactionType = $transactionType;
|
||||
$this->date = $date;
|
||||
}
|
||||
protected ?Portfolio $portfolio,
|
||||
protected ?string $symbol,
|
||||
protected ?string $transactionType,
|
||||
protected string|Carbon|null $date
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Validate the attribute.
|
||||
*
|
||||
* @param string $attribute
|
||||
* @param mixed $value
|
||||
* @param \Closure $fail
|
||||
* @return void
|
||||
*/
|
||||
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()
|
||||
->symbol($this->symbol)
|
||||
->buy()
|
||||
->beforeDate($this->date)
|
||||
->sum('quantity');
|
||||
$purchase_qty = (float) $this->portfolio->transactions()
|
||||
->symbol($this->symbol)
|
||||
->buy()
|
||||
->whereDate('date', '<', $this->date)
|
||||
->sum('quantity');
|
||||
|
||||
$sales_qty = (float) $this->portfolio->transactions()
|
||||
->symbol($this->symbol)
|
||||
->sell()
|
||||
->whereDate('date', '<', $this->date)
|
||||
->sum('quantity');
|
||||
|
||||
$sales_qty = $this->portfolio->transactions()
|
||||
->symbol($this->symbol)
|
||||
->sell()
|
||||
->beforeDate($this->date)
|
||||
->sum('quantity');
|
||||
|
||||
$maxQuantity = $purchase_qty - $sales_qty;
|
||||
|
||||
if (round($value, 3) > round($maxQuantity, 3)) {
|
||||
if (round($value, 4) > round($maxQuantity, 4)) {
|
||||
$fail(__('The quantity must not be greater than the available quantity.'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Interfaces\MarketData\MarketDataInterface;
|
||||
@@ -22,24 +24,20 @@ class SymbolValidationRule implements ValidationRule
|
||||
|
||||
/**
|
||||
* Validate the attribute.
|
||||
*
|
||||
* @param string $attribute
|
||||
* @param mixed $value
|
||||
* @param \Closure $fail
|
||||
* @return void
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, \Closure $fail): void
|
||||
{
|
||||
$this->symbol = $value;
|
||||
|
||||
// Check if the symbol exists in the Market Data table first (avoid API call)
|
||||
if (MarketData::find($this->symbol)) {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the symbol exists in the Market Data table first (avoid API call)
|
||||
if (!app(MarketDataInterface::class)->exists($value)) {
|
||||
$fail('The symbol provided (' . $this->symbol . ') is not valid');
|
||||
// Then check against market data provider
|
||||
if (! app(MarketDataInterface::class)->exists($value)) {
|
||||
$fail('The symbol provided ('.$this->symbol.') is not valid');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-13
@@ -1,15 +1,16 @@
|
||||
<?php
|
||||
|
||||
// if (!function_exists('formatMoney')) {
|
||||
// /**
|
||||
// * Returns a formatted string for currency
|
||||
// *
|
||||
// * @param int|float $amount
|
||||
// *
|
||||
// * */
|
||||
// function formatMoney(int|float $amount) {
|
||||
// $formatter = new NumberFormatter('en_US', NumberFormatter::CURRENCY);
|
||||
|
||||
// return $formatter->formatCurrency((float) $amount, 'USD');
|
||||
// }
|
||||
// }
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Currency;
|
||||
|
||||
if (! function_exists('currency')) {
|
||||
|
||||
// /**
|
||||
// * Returns an instance of the currency model
|
||||
// * */
|
||||
// function currency(): Currency
|
||||
// {
|
||||
// return new Currency;
|
||||
// }
|
||||
}
|
||||
|
||||
+14
-14
@@ -1,55 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Holding;
|
||||
use App\Models\Portfolio;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
|
||||
class Spotlight
|
||||
{
|
||||
public function search(Request $request)
|
||||
{
|
||||
|
||||
|
||||
$results = collect();
|
||||
|
||||
if (!$request->user()) {
|
||||
if (! $request->user()) {
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
$portfolios = $request->user()->portfolios()
|
||||
->where('title', 'LIKE', '%'.$request->input('search').'%')
|
||||
->whereFullText('title', $request->input('search'))
|
||||
->limit(5)
|
||||
->get();
|
||||
$portfolios->each(function($portfolio) use ($results) {
|
||||
$portfolios->each(function ($portfolio) use ($results) {
|
||||
|
||||
$results->push([
|
||||
'name' => 'Portfolio: '. $portfolio->title,
|
||||
'name' => 'Portfolio: '.$portfolio->title,
|
||||
'description' => null,
|
||||
'link' => route('portfolio.show', ['portfolio' => $portfolio->id]),
|
||||
'avatar' => null
|
||||
'avatar' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
$holdings = $request->user()->holdings()
|
||||
->where('holdings.quantity', '>', 0)
|
||||
->where(function ($query) use ($request) {
|
||||
return $query->where('holdings.symbol', 'LIKE', '%'.$request->input('search').'%')
|
||||
->orWhere('market_data.name', 'LIKE', '%'.$request->input('search').'%');
|
||||
return $query->whereFullText('holdings.symbol', $request->input('search'))
|
||||
->orWhereFullText('market_data.name', $request->input('search'));
|
||||
})
|
||||
->limit(5)
|
||||
->get();
|
||||
$holdings->each(function($holding) use ($results) {
|
||||
$holdings->each(function ($holding) use ($results) {
|
||||
|
||||
$results->push([
|
||||
'name' => 'Holding: '.$holding->market_data->name.' ('.$holding->symbol.')',
|
||||
'description' => $holding->portfolio->title,
|
||||
'link' => route('holding.show', ['portfolio' => $holding->portfolio->id, 'symbol' => $holding->symbol]),
|
||||
'avatar' => null
|
||||
'avatar' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
trait HasCompositePrimaryKey
|
||||
{
|
||||
@@ -17,17 +19,18 @@ trait HasCompositePrimaryKey
|
||||
/**
|
||||
* Set the keys for a save update query.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
protected function setKeysForSaveQuery($query)
|
||||
{
|
||||
foreach ($this->getKeyName() as $key) {
|
||||
// UPDATE: Added isset() per devflow's comment.
|
||||
if (isset($this->$key))
|
||||
if (isset($this->$key)) {
|
||||
$query->where($key, '=', $this->$key);
|
||||
else
|
||||
throw new \Exception(__METHOD__ . 'Missing part of the primary key: ' . $key);
|
||||
} else {
|
||||
throw new \Exception(__METHOD__.'Missing part of the primary key: '.$key);
|
||||
}
|
||||
}
|
||||
|
||||
return $query;
|
||||
@@ -37,7 +40,7 @@ trait HasCompositePrimaryKey
|
||||
/**
|
||||
* Execute a query for a single record by ID.
|
||||
*
|
||||
* @param array $ids Array of keys, like [column => value].
|
||||
* @param array $ids Array of keys, like [column => value].
|
||||
* @param array $columns
|
||||
* @return mixed|static
|
||||
*/
|
||||
@@ -48,6 +51,7 @@ trait HasCompositePrimaryKey
|
||||
foreach ($me->getKeyName() as $key) {
|
||||
$query->where($key, '=', $ids[$key]);
|
||||
}
|
||||
|
||||
return $query->first($columns);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\ConnectedAccount;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @property Collection $connectedAccounts
|
||||
@@ -63,4 +65,4 @@ trait HasConnectedAccounts
|
||||
{
|
||||
return $this->hasMany(ConnectedAccount::class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\MarketData;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
trait HasMarketData
|
||||
{
|
||||
/**
|
||||
* Related market data for model
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function market_data(): HasOne
|
||||
{
|
||||
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully loads related market data as relationship (creates if doesn't exist)
|
||||
*/
|
||||
public function loadMarketData(): void
|
||||
{
|
||||
if (is_null($this->market_data)) {
|
||||
|
||||
$this->setRelation('market_data', MarketData::getMarketData($this->attributes['symbol']));
|
||||
}
|
||||
}
|
||||
|
||||
public function scopeNotBaseCurrency($query): void
|
||||
{
|
||||
$query->with('market_data')
|
||||
->whereRelation(
|
||||
'market_data',
|
||||
'currency',
|
||||
'!=',
|
||||
config('investbrain.base_currency')
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user