Compare commits

...

34 Commits

Author SHA1 Message Date
hackerESQ 6d6f968f42 Merge pull request #76 from investbrainapp/dividend-splits-should-be-unique
fix: add unique constraint to split and dividends
2025-03-19 16:17:01 -05:00
hackerESQ 261c848ffd fix: add unique constraint to split and dividends
to prevent duplicate records
2025-03-19 16:16:38 -05:00
hackerESQ 9bcc80078e Update 2021_09_06_014744_create_holdings_table.php 2025-03-19 15:32:38 -05:00
hackerESQ c4b7d399ea Update SECURITY.md 2025-03-17 18:19:12 -05:00
hackerESQ ffe53e91c0 Merge pull request #75 from investbrainapp/simplify-asset-url
feat: simplify self host install by removing asset_url env
2025-03-17 18:18:32 -05:00
hackerESQ aeb1b12afe feat: simplify self host install by removing asset_url env 2025-03-17 18:18:12 -05:00
hackerESQ fe81ec7ee7 fix: adds reinvest column back to holdings table 2025-03-13 20:45:00 -05:00
hackerESQ f0ecc0fd3d fix: create profile photo disk for jetstream 2025-03-12 12:02:34 -05:00
hackerESQ 03b75fb683 adds sentry log driver 2025-03-11 17:55:51 -05:00
hackerESQ dc93621547 simplify example.env 2025-03-10 22:59:15 -05:00
hackerESQ 7ab6f79e56 feat: adds pgsql compatibility (#72) 2025-03-10 21:17:24 -05:00
hackerESQ 9e48f21c8d fix: better pgsql support 2025-03-07 19:30:06 -06:00
hackerESQ 10e6de8df4 chore: clean up market data seed 2025-03-07 19:15:10 -06:00
hackerESQ 00fbdec6f1 fix: improve seeder (and remove symbol dupes) 2025-03-07 18:43:55 -06:00
hackerESQ 730903c383 fix: compatible with pgsql 2025-03-07 17:45:54 -06:00
hackerESQ 5fc9455908 fix: longer exception 2025-03-07 17:27:08 -06:00
hackerESQ 28e0ad68fc fix: truncate exception so meaningful data shows first 2025-03-07 17:20:15 -06:00
hackerESQ ca48d702a7 chore: simplify .env file 2025-03-07 17:07:47 -06:00
hackerESQ 812b9ed075 chore: critical security update for livewire/volt 2025-03-06 19:51:06 -06:00
hackerESQ 93a0595652 Merge pull request #70 from investbrainapp/use-correct-storage-path
refactor: use correct storage path for app key check
2025-03-06 18:36:23 -06:00
hackerESQ 8a357e8cab refactor: use correct storage path for app key check 2025-03-06 18:35:57 -06:00
hackerESQ 22e12977f8 Merge pull request #69 from investbrainapp/save-app-key-to-file
refactor: storage scaffolding and save generated app key to file
2025-03-06 16:57:00 -06:00
hackerESQ 732cf02317 refactor: storage directory scaffoling and save generated app key to file 2025-03-06 16:55:54 -06:00
hackerESQ 6dea75651b fix: version response will be an object 2025-02-25 20:16:13 -06:00
Oscar Padilla 6cff252813 fix: support mariadb in sync:daily-change (#64)
* fix: support mariadb in sync:daily-change

* use version() instead of system variables

---------

Co-authored-by: hackerESQ <corey@coreyvarma.com>
2025-02-25 20:09:03 -06:00
Karjack182 0d06ca6a04 Updated Dockerfile (#65)
Optimized Dockerfile

* Updated Dockerfile to utilize a multi-stage build. This will make the built image lighter, by about 300mb.
Also safer, as 8.3-fpm has 55 known vulnerabilities, while 8.3-fpm-alpine has 0.

* Removed runtime dependencies from the build stage.

* remove unneeded php extensions from stage 1 build step
2025-02-25 19:04:02 -06:00
hackerESQ a3f875270b fix: quiet redis logs 2025-02-01 10:52:15 -06:00
hackerESQ 00a1312ee3 fix: move storage:link to dockerfile 2025-01-30 19:16:33 -06:00
hackerESQ 1195faca0f docs: add more helpful comments 2025-01-30 19:00:55 -06:00
hackerESQ a39f255e52 fix: ensure permissions are set and storage dir is scaffolded 2025-01-30 18:48:34 -06:00
hackerESQ cac2460153 fix: predis always the default 2025-01-30 18:13:16 -06:00
hackerESQ 894da4ef9b fix: make redis default 2025-01-30 18:13:01 -06:00
hackerESQ a705b794fd docs: add note about "broken styling" 2025-01-29 23:13:28 -06:00
hackerESQ 37da6885ee fix: use laravel up health endpoint 2025-01-29 23:06:42 -06:00
34 changed files with 677 additions and 579 deletions
+1 -1
View File
@@ -13,4 +13,4 @@ storage/framework/cache/*
storage/framework/sessions/*
storage/framework/testing/*
storage/framework/views/*
storage/framework/logs/*
storage/logs/*
+4 -22
View File
@@ -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}"
+8
View File
@@ -214,6 +214,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
View File
@@ -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
+4 -2
View File
@@ -9,9 +9,11 @@ use Illuminate\Support\Carbon;
class Quote extends MarketDataType
{
public function setName(string $name): self
public function setName($name): self
{
$this->items['name'] = (string) $name;
if (! empty($name)) {
$this->items['name'] = (string) $name;
}
return $this;
}
+23 -16
View File
@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class Dividend extends Model
@@ -67,7 +68,7 @@ class Dividend extends Model
// nope, refresh forward looking only
if ($dividends_meta->total_dividends) {
$start_date = $dividends_meta->last_dividend_update->addHours(24);
$start_date = $dividends_meta->last_dividend_update;
}
// skip refresh if there's already recent data
@@ -89,7 +90,7 @@ class Dividend extends Model
}
// insert records
(new self)->insert($dividend_data->toArray());
(new self)->insertOrIgnore($dividend_data->toArray());
// sync to holdings
self::syncHoldings($symbol);
@@ -109,22 +110,28 @@ 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')
$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', 'total_received')
->havingRaw('total_received > 0')
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount');
$dividends = DB::table(DB::raw("({$subQuery->toSql()}) as sub"))
->mergeBindings($subQuery->getQuery())
->where('total_received', '>', 0)
->get();
// iterate through holdings and update
+102 -50
View File
@@ -7,6 +7,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class Holding extends Model
@@ -62,8 +63,8 @@ class Holding extends Model
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
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
@@ -71,22 +72,22 @@ class Holding extends Model
) AS purchased")
->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
(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")
@@ -99,7 +100,25 @@ class Holding extends Model
->whereRaw("transactions.portfolio_id = '$this->portfolio_id'")
->whereRaw("transactions.symbol = '$this->symbol'");
})
->having('total_received', '>', 0);
->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 > 0");
}
/**
@@ -147,7 +166,7 @@ class Holding extends Model
{
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)
@@ -191,10 +210,10 @@ class Holding extends Model
$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`')
])->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();
$total_quantity = round($query->qty_purchases - $query->qty_sales, 3);
@@ -202,9 +221,8 @@ class Holding extends Model
$average_cost_basis = (
$query->qty_purchases > 0
&& $total_quantity > 0
)
? $query->total_cost_basis / $query->qty_purchases
: 0;
) ? $query->total_cost_basis / $query->qty_purchases
: 0;
// update holding
$this->fill([
@@ -246,49 +264,83 @@ class Holding extends Model
$end_date = now();
}
// 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')";
} else {
DB::statement('SET cte_max_recursion_depth=1000000;');
}
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
// Default CTE time series query (for MySQL and SQLite)
$timeSeriesQuery = 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
) as date_series")
)
WHERE date < '{$end_date->format('Y-m-d')}'
)
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->format('Y-m-d')}',
date '{$end_date->format('Y-m-d')}',
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("
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`
"),
{$quantityQuery} 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`"),
CASE
WHEN ({$quantityQuery}) = 0 THEN 0
ELSE SUM(CASE
WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis
ELSE 0
END)
END 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')
+5
View File
@@ -211,6 +211,11 @@ class Portfolio extends Model
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'],
+4 -4
View File
@@ -73,7 +73,7 @@ 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());
@@ -101,7 +101,7 @@ class Split extends Model
->where([
'splits.symbol' => $symbol,
])
->whereDate('splits.date', '>', DB::raw('IFNULL(holdings.splits_synced_at, "0000-00-00")'))
->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')
@@ -115,8 +115,8 @@ class Split extends Model
'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')
->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) {
+3 -3
View File
@@ -19,7 +19,7 @@ class Spotlight
}
$portfolios = $request->user()->portfolios()
->where('title', 'LIKE', '%'.$request->input('search').'%')
->whereFullText('title', $request->input('search'))
->limit(5)
->get();
$portfolios->each(function ($portfolio) use ($results) {
@@ -35,8 +35,8 @@ class Spotlight
$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();
Generated
+261 -275
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -100,7 +100,8 @@ return [
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'key' => env('APP_KEY')
?: when(file_exists(storage_path('app/.key')), fn () => trim(file_get_contents(storage_path('app/.key')))),
'previous_keys' => [
...array_filter(
+1 -1
View File
@@ -17,7 +17,7 @@ return [
|
*/
'default' => env('CACHE_STORE', 'database'),
'default' => env('CACHE_STORE', 'redis'),
/*
|--------------------------------------------------------------------------
+1 -1
View File
@@ -77,6 +77,6 @@ return [
|
*/
'profile_photo_disk' => 'public',
'profile_photo_disk' => env('JETSTREAM_PROFILE_PHOTO_DISK', 'public'),
];
+1 -1
View File
@@ -116,7 +116,7 @@ return [
|
*/
'inject_assets' => true,
'inject_assets' => false,
/*
|---------------------------------------------------------------------------
+5
View File
@@ -96,6 +96,11 @@ return [
'processors' => [PsrLogMessageProcessor::class],
],
'sentry' => [
'driver' => 'sentry',
'level' => env('LOG_LEVEL', 'error'),
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
+1 -1
View File
@@ -15,7 +15,7 @@ return [
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
'default' => env('QUEUE_CONNECTION', 'redis'),
/*
|--------------------------------------------------------------------------
+1 -1
View File
@@ -20,7 +20,7 @@ return [
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
'driver' => env('SESSION_DRIVER', 'redis'),
/*
|--------------------------------------------------------------------------
@@ -17,7 +17,7 @@ return new class extends Migration
{
Schema::create('portfolios', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('title');
$table->string('title')->when(config('database.default') != 'sqlite', fn ($ctx) => $ctx->fulltext());
$table->text('notes')->nullable();
$table->boolean('wishlist')->default(false);
$table->timestamps();
@@ -18,8 +18,8 @@ class CreateMarketDataTable extends Migration
public function up()
{
Schema::create('market_data', function (Blueprint $table) {
$table->string('symbol', 15)->primary();
$table->string('name')->nullable();
$table->string('symbol', 25)->primary();
$table->string('name')->nullable()->when(config('database.default') != 'sqlite', fn ($ctx) => $ctx->fulltext());
$table->float('market_value', 12, 4)->nullable();
$table->float('fifty_two_week_low', 12, 4)->nullable();
$table->float('fifty_two_week_high', 12, 4)->nullable();
@@ -2,7 +2,6 @@
declare(strict_types=1);
use App\Models\MarketData;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@@ -19,9 +18,11 @@ class CreateDividendsTable extends Migration
Schema::create('dividends', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->date('date');
$table->foreignIdFor(MarketData::class, 'symbol');
$table->string('symbol', 25);
$table->float('dividend_amount', 12, 4);
$table->timestamps();
$table->unique(['date', 'symbol']);
});
}
@@ -2,7 +2,6 @@
declare(strict_types=1);
use App\Models\MarketData;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@@ -19,9 +18,11 @@ class CreateSplitsTable extends Migration
Schema::create('splits', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->date('date');
$table->foreignIdFor(MarketData::class, 'symbol');
$table->string('symbol', 25);
$table->float('split_amount', 12, 4);
$table->timestamps();
$table->unique(['date', 'symbol']);
});
}
@@ -2,7 +2,6 @@
declare(strict_types=1);
use App\Models\MarketData;
use App\Models\Portfolio;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
@@ -19,7 +18,7 @@ class CreateTransactionsTable extends Migration
{
Schema::create('transactions', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignIdFor(MarketData::class, 'symbol');
$table->string('symbol', 25);
$table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade');
$table->string('transaction_type', 15);
$table->float('quantity', 12, 4);
@@ -2,7 +2,6 @@
declare(strict_types=1);
use App\Models\MarketData;
use App\Models\Portfolio;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
@@ -20,7 +19,7 @@ class CreateHoldingsTable extends Migration
Schema::create('holdings', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade');
$table->foreignIdFor(MarketData::class, 'symbol');
$table->string('symbol', 25)->when(config('database.default') != 'sqlite', fn ($ctx) => $ctx->fulltext());
$table->float('quantity', 12, 4);
$table->float('average_cost_basis', 12, 4)->default(0);
$table->float('total_cost_basis', 12, 4)->default(0);
+33 -23
View File
@@ -12,6 +12,8 @@ class MarketDataSeeder extends Seeder
{
use WithoutModelEvents;
public array $rows = [];
/**
* Run the database seeds.
*/
@@ -26,7 +28,6 @@ class MarketDataSeeder extends Seeder
if (($handle = fopen($csvFilePath, 'r')) !== false) {
$header = null;
$rows = [];
$rowCount = 0;
while (($row = fgetcsv($handle, 0, ',')) !== false) {
@@ -38,46 +39,55 @@ class MarketDataSeeder extends Seeder
} else {
try {
$data = array_combine($header, $row);
$data = array_combine($header, $row);
$rows[] = [
'symbol' => $data['symbol'],
'name' => $data['name'],
'meta_data' => json_encode([
'country' => $data['country'],
'first_trade_year' => $data['first_trade_year'],
'sector' => $data['sector'],
'industry' => $data['industry'],
]),
];
$this->rows[] = [
'symbol' => $data['symbol'],
'name' => $data['name'],
'meta_data' => json_encode([
'country' => $data['country'],
'first_trade_year' => $data['first_trade_year'],
'sector' => $data['sector'],
'industry' => $data['industry'],
]),
];
$rowCount++;
$rowCount++;
if ($rowCount % $chunkSize == 0) {
DB::table('market_data')->insertOrIgnore($rows);
$rows = [];
}
} catch (\Throwable $e) {
if ($rowCount % $chunkSize == 0) {
throw new \Exception('Error: '.$e->getMessage());
$this->bulkInsert($this->rows);
}
}
}
// final clean up
if (! empty($rows)) {
DB::table('market_data')->insertOrIgnore($rows);
if (! empty($this->rows)) {
$this->bulkInsert($this->rows);
}
// Close the CSV file
fclose($handle);
echo "Imported $rowCount market data items successfully!\n";
echo "\n > Imported $rowCount market data items successfully!";
} else {
echo "Failed to open the CSV.\n";
}
}
public function bulkInsert(array $rows)
{
try {
DB::table('market_data')->insertOrIgnore($rows);
$this->rows = [];
} catch (\Throwable $e) {
throw new \Exception('Error: '.$e->getMessage());
}
}
}
-19
View File
@@ -241,8 +241,6 @@ AKA,a.k.a. Brands Holding Corp. Common Stock,,2021,Consumer Discretionary,Catalo
AKAM,Akamai Technologies Inc. Common Stock,United States,1999,Consumer Discretionary,Business Services
AKAN,Akanda Corp. Common Shares,United Kingdom,2022,Health Care, Medicinal Chemicals and Botanical Products
AKBA,Akebia Therapeutics Inc. Common Stock,United States,2014,Health Care,Biotechnology: Pharmaceutical Preparations
AKO-A,Embotelladora Andina S.A.,Chile,,,
AKO-B,Embotelladora Andina S.A.,Chile,,,
AKR,Acadia Realty Trust Common Stock,United States,,Real Estate,Real Estate Investment Trusts
AKRO,Akero Therapeutics Inc. Common Stock,United States,2019,Health Care,Biotechnology: Pharmaceutical Preparations
AKTS,Akoustis Technologies Inc. Common Stock,United States,,Utilities,Telecommunications Equipment
@@ -789,8 +787,6 @@ BERY,Berry Global Group Inc. Common Stock,United States,2012,Industrials,Plastic
BEST,BEST Inc. American Depositary Shares each representing twenty (20) Class A Ordinary Shares,Cayman Islands,2023,Industrials,Trucking Freight/Courier Services
BETR,Better Home & Finance Holding Company Class A Common Stock,United Kingdom,2021,Finance,Finance: Consumer Services
BETRW,Better Home & Finance Holding Company Warrant,United Kingdom,2021,Finance,Finance: Consumer Services
BF-A,Brown Forman Corporation,United States,,,
BF-B,Brown Forman Corporation,United States,,,
BFAC,Battery Future Acquisition Corp. Class A Ordinary Shares,,2022,Finance,Blank Checks
BFAM,Bright Horizons Family Solutions Inc. Common Stock,United States,2013,Consumer Discretionary,Other Consumer Services
BFC,Bank First Corporation Common Stock,United States,,Finance,Major Banks
@@ -1004,8 +1000,6 @@ BREA,Brera Holdings PLC Class B Ordinary Shares,Ireland,2023,Consumer Discretion
BRFH,Barfresh Food Group Inc. Common Stock,United States,,Consumer Staples,Packaged Foods
BRFS,BRF S.A.,Brazil,,Consumer Staples,Meat/Poultry/Fish
BRID,Bridgford Foods Corporation Common Stock,United States,,Consumer Staples,Specialty Foods
BRK-A,Berkshire Hathaway Inc.,United States,,,
BRK-B,Berkshire Hathaway Inc.,United States,,,
BRKH,BurTech Acquisition Corp. Class A Common Stock,United States,2022,Finance,Blank Checks
BRKHU,BurTech Acquisition Corp. Unit,United States,2021,Finance,Blank Checks
BRKHW,BurTech Acquisition Corp. Warrants,United States,2022,Finance,Blank Checks
@@ -1542,8 +1536,6 @@ CRBP,Corbus Pharmaceuticals Holdings Inc. Common Stock,United States,,Health Car
CRBU,Caribou Biosciences Inc. Common Stock,United States,2021,Health Care,Biotechnology: Biological Products (No Diagnostic Substances)
CRC,California Resources Corporation Common Stock,United States,2020,Energy,Oil & Gas Production
CRCT,Cricut Inc. Class A Common Stock,United States,2021,Technology,Industrial Machinery/Components
CRD-A,Crawford & Company,United States,,,
CRD-B,Crawford & Company,United States,,,
CRDF,Cardiff Oncology Inc. Common Stock,United States,,Health Care,Biotechnology: Biological Products (No Diagnostic Substances)
CRDL,Cardiol Therapeutics Inc. Class A Common Shares,Canada,,Health Care,Biotechnology: Biological Products (No Diagnostic Substances)
CRDO,Credo Technology Group Holding Ltd Ordinary Shares,United States,2022,Technology,Semiconductors
@@ -2850,7 +2842,6 @@ HE,Hawaiian Electric Industries Inc. Common Stock,United States,,Utilities,Elect
HEAR,Turtle Beach Corporation Common Stock,United States,,Telecommunications,Telecommunications Equipment
HEES,H&E Equipment Services Inc. Common Stock,United States,2006,Industrials,Misc Corporate Leasing Services
HEI,Heico Corporation Common Stock,United States,2000,Industrials,Aerospace
HEI-A,Heico Corporation,United States,,,
HELE,Helen of Troy Limited Common Stock,Bermuda,,Consumer Discretionary,Home Furnishings
HEPA,Hepion Pharmaceuticals Inc. Common Stock,United States,,Health Care,Biotechnology: Pharmaceutical Preparations
HEPS,D-Market Electronic Services & Trading American Depositary Shares,Turkey,2021,Consumer Discretionary,Catalog/Specialty Distribution
@@ -3011,7 +3002,6 @@ HUnited States,Houston American Energy Corporation Common Stock,United States,,E
HUT,Hut 8 Corp. Common Stock,United States,,Finance,Finance: Consumer Services
HUYA,HUYA Inc. American depositary shares each representing one Class A ordinary share,United States,2018,Technology,Computer Software: Programming Data Processing
HVT,Haverty Furniture Companies Inc. Common Stock,United States,,Consumer Discretionary,Other Specialty Stores
HVT-A,Haverty Furniture Companies Inc.,United States,,,
HWBK,Hawthorn Bancshares Inc. Common Stock,United States,,Finance,Major Banks
HWC,Hancock Whitney Corporation Common Stock,United States,,Finance,Major Banks
HWCPZ,Hancock Whitney Corporation 6.25% Subordinated Notes due 2060,United States,,Finance,Major Banks
@@ -11488,7 +11478,6 @@ NYNYR,Empire Resorts Inc.,United States,,,
LLEX,"Lilis Energy, Inc.",United States,,Independent Oil & Gas,
EZT,"Entergy Texas, Inc.",United States,,,
EXPN.L,Experian plc,United Kingdom,,Business Services,
ETX,Eaton Vance Municipal Income 2028 Term Trust,United States,,,
EMI,Eaton Vance Michigan Municipal Income Trust,United States,,,
EMG,"Emergent Capital, Inc.",United States,,,
EMCF,Emclaire Financial Corp.,United States,,Regional - Northeast Banks,
@@ -11531,7 +11520,6 @@ ENZY,Enzymotec Ltd.,United States,,Biotechnology,
ENBP,ENB Financial Corp,United States,,Regional - Northeast Banks,
EMP-A.TO,Empire Company Limited,Canada,,Gold,
ECCA,Eagle Point Credit Company Inc.,United States,,,
ECC,Eagle Point Credit Company Inc.,United States,,,
NESV,"National Energy Services, Inc.",United States,,,
YECO,Yulong Eco-Materials Limited,United States,,,
TIK,Tel-Instrument Electronics Corp.,United States,,Aerospace/Defense - Major Diversified,
@@ -13136,7 +13124,6 @@ HRG,"HRG Group, Inc.",United States,,Conglomerates,
GSS,Golden Star Resources Ltd.,United States,,Gold,
GIG,"GigPeak, Inc.",United States,,,
SGEN,"Seattle Genetics, Inc.",United States,,Biotechnology,
SAND,Sandstorm Gold Ltd.,United States,,Gold,
PGEM,"Ply Gem Holdings, Inc",United States,,General Building Materials,
ININ,"Interactive Intelligence Group, Inc.",United States,,,
SNR,New Senior Investment Group Inc.,United States,,,
@@ -13682,7 +13669,6 @@ VIG.VI,Vienna Insurance Group AG,Austria,,,
TACXF,TAC GOLD,United States,,,
VELA.L,Vela Technologies PLC,United Kingdom,,,
VEGPF,Vectura Group plc,United States,,,
United States.TO,Americas Silver Corporation,Canada,,Industrial Metals & Minerals,
URHG,"United Resource Holdings Group, Inc.",United States,,,
UQA.VI,UNIQA Insurance Group AG,Austria,,,
UIBGF,UIB Group Limited,United States,,,
@@ -20863,7 +20849,6 @@ USMT,US Metro Bank,United States,,,
USK.F,"Skullcandy, Inc.",France,,,
USF.AX,"US Select Private Opportunities Fund, LP",Australia,,,
USBL,"United States Basketball League, Inc.",United States,,Conglomerates,
United States.AX,Uraniumsa Limited,Australia,,Industrial Metals & Minerals,
US2.BE,"USG CORP. DL-,10",Germany,,,
US1.F,"Ultratech, Inc.",France,,,
URXZD,TENTH AVENUE PETROLEUM CORPORAT,United States,,,
@@ -22474,7 +22459,6 @@ O3X2.F,AAN Ventures Inc,France,,,
D94417.CR,DP04947-0011,Venezuela,,,
ENV.CR,ENVASES VENEZOLANOS S.A.,Venezuela,,,
BVF.DU,VALEANT PHARMACEUT. INTL,Germany,,,
CUnited StatesN.IS,Cuhadaroglu Metal Sanayi ve Pazarlama A.S.,Turkey,,,
PUM9.L,Puma VCT 9 plc,United Kingdom,,,
RAP1V.HE,Rapala VMC Corporation,Finland,,,
VIS.BE,"VISCOFAN SA INH. EO 0,70",Germany,,,
@@ -22540,7 +22524,6 @@ WWSG,Worldwide Strategies Incorporated,United States,,Diversified Communication
WWTH,"With, Inc.",United States,,,
WUMSF,Wumart Stores Inc.,United States,,,
WSSH,West Shore Bank Corp.,United States,,,
WSO-B,"Watsco, Inc.",United States,,,
WRCKF,WESC AB,United States,,,
WMSI,"Williams Industries, Incorporated",United States,,,
WLKR,Walker Innovation Inc.,United States,,,
@@ -29715,12 +29698,10 @@ AK1.SG,AMETEK INC. Registered Shares D,Germany,,,
SRT.DE,Sartorius Aktiengesellschaft,Germany,,Scientific & Technical Instruments,
0G29.L,Semperit Aktiengesellschaft Holding,United Kingdom,,,
0IPT.L,Akastor ASA,United Kingdom,,,
United StatesK.IS,United Statesk Seramik Sanayi A.S.,Turkey,,,
0NCV.L,Lenzing Aktiengesellschaft,United Kingdom,,,
0OK7.L,Akka Technologies,United Kingdom,,,
1AKA.BE,"AKER SOLUTIONS ASA NK1,08",Germany,,,
AK2.BE,"AK STEEL HLDG",Germany,,,
United StatesS.IS,United Statess Yatirimlar Holding A.S.,Turkey,,,
0QBM.L,Alior Bank S.A.,United Kingdom,,,
T1W.HM,Aktiengesellschaft Tokugawa,Germany,,,
0OIU.L,Arctic Paper S.A.,United Kingdom,,,
Can't render this file because it is too large.
+12 -10
View File
@@ -8,23 +8,22 @@ services:
restart: unless-stopped
tty: true
ports:
- "${APP_PORT:-8000}:80"
environment:
APP_KEY: "" # Generate a key using `echo base64:$(openssl rand -base64 32)`
- 8000:80
environment: # You can either use these properties OR an .env file. Do not use both!
APP_URL: "http://localhost:8000"
ASSET_URL: "http://localhost:8000"
DB_CONNECTION: mysql
DB_HOST: investbrain-mysql
DB_PORT: 3306
DB_DATABASE: ${DB_DATABASE:-investbrain}
DB_USERNAME: ${DB_USERNAME:-investbrain}
DB_PASSWORD: ${DB_PASSWORD:-investbrain}
DB_DATABASE: investbrain
DB_USERNAME: investbrain
DB_PASSWORD: investbrain
SESSION_DRIVER: redis
QUEUE_CONNECTION: redis
CACHE_STORE: redis
REDIS_HOST: investbrain-redis
volumes:
- ./storage:/var/app/storage
- investbrain-storage:/var/app/storage # You can use a volume...
# - /path/to/storage:/var/app/storage:delegated # ...or you can use a path on host
depends_on:
- mysql
- redis
@@ -35,10 +34,12 @@ services:
container_name: investbrain-redis
restart: unless-stopped
tty: true
networks:
- investbrain-network
command:
- --loglevel warning
volumes:
- investbrain-redis:/data
networks:
- investbrain-network
mysql:
image: mysql:8.0
container_name: investbrain-mysql
@@ -55,5 +56,6 @@ services:
networks:
- investbrain-network
volumes:
investbrain-storage:
investbrain-redis:
investbrain-mysql:
+52 -27
View File
@@ -1,19 +1,16 @@
FROM php:8.3-fpm
# Stage 1: Build stage
FROM php:8.3-fpm AS builder
ENV DEBIAN_FRONTEND=noninteractive
ENV APP_NAME=Investbrain
ENV VITE_APP_NAME=Investbrain
ENV APP_DEBUG=true
ENV SELF_HOSTED=true
# Set the working directory
COPY . /var/app
WORKDIR /var/app
# Install required packages
RUN apt-get update && apt-get upgrade -y \
&& apt-get install -y \
nginx \
libfreetype-dev \
libjpeg62-turbo-dev \
libpng-dev \
@@ -22,44 +19,72 @@ RUN apt-get update && apt-get upgrade -y \
libicu-dev \
libpq-dev \
binutils libc6-dev \
supervisor \
unzip curl git \
nodejs npm \
# Clean up APT
&& apt-get -y autoremove \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Install PHP extensions
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
gd pgsql zip pdo_mysql mysqli intl
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd zip
# Remove default nginx config
RUN rm /etc/nginx/sites-enabled/default \
&& rm -rf /var/www/html \
&& ln -s /var/app /var/www/app
# Set permissions and ensure www-data has a shell available
RUN chown -R www-data:www-data . \
&& chmod -R 775 ./storage \
&& chmod +x ./docker/entrypoint.sh \
&& usermod -s /bin/bash www-data
# Copy application files
COPY . .
# Install Composer and Node.js Install PHP dependencies and build front end assets
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
&& composer install --no-scripts --optimize-autoloader \
&& npm install && npm run build
&& npm install && npm run build \
&& rm -rf node_modules
# Stage 2: Production stage
FROM php:8.3-fpm-alpine
# Set the working directory
WORKDIR /var/app
# Copy necessary files from the builder stage
COPY --from=builder /var/app /var/app
COPY --from=builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d
COPY --from=builder /usr/local/bin/composer /usr/local/bin/composer
# Install required Alpine packages
RUN apk add --no-cache \
nginx \
supervisor \
libpng-dev \
libzip-dev \
icu-dev \
postgresql-dev \
freetype-dev \
libjpeg-turbo-dev \
bash \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
gd pgsql zip pdo_mysql mysqli intl
# Remove default nginx config
RUN rm -rf /var/www/html \
&& ln -s /var/app /var/www/app
# Create required directories for supervisord
RUN mkdir -p /var/log/supervisor /var/run/supervisor
# Copy over configs
COPY ./docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY ./docker/nginx.conf /etc/nginx/http.d/default.conf
COPY ./docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Set permissions and link storage
RUN php artisan storage:link \
&& chown -R www-data:www-data . \
&& chmod +x ./docker/entrypoint.sh
# Serve on port 80
EXPOSE 80
# Set up healthcheck
HEALTHCHECK --interval=30s --timeout=10s --retries=3 CMD curl -f http://localhost || exit 1
HEALTHCHECK --interval=30s --timeout=10s --retries=3 CMD curl -f http://localhost/up || exit 1
# Run everything else
ENTRYPOINT ["/bin/bash", "./docker/entrypoint.sh"]
ENTRYPOINT ["/bin/sh", "./docker/entrypoint.sh"]
+48 -26
View File
@@ -2,25 +2,29 @@
cd /var/app
# Starting Investbrain
echo "CiAgKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioKICAqICBJSUkgICBOICAgTiAgViAgIFYgIEVFRUVFICBTU1NTICBUVFRUVCAgQkJCQkIgICBSUlJSICAgIEFBQUFBICBJSUkgICBOICAgTiAgKgogICogICBJICAgIE5OICBOICBWICAgViAgRSAgICAgIFMgICAgICAgVCAgICBCICAgIEIgIFIgICBSICAgQSAgIEEgICBJICAgIE5OICBOICAqCiAgKiAgIEkgICAgTiBOIE4gIFYgICBWICBFRUVFICAgU1NTUyAgICBUICAgIEJCQkJCICAgUlJSUiAgICBBQUFBQSAgIEkgICAgTiBOIE4gICoKICAqICAgSSAgICBOICBOTiAgViAgIFYgIEUgICAgICAgICAgUyAgIFQgICAgQiAgICBCICBSICBSICAgIEEgICBBICAgSSAgICBOICBOTiAgKgogICogIElJSSAgIE4gICBOICAgVlZWICAgRUVFRUUgIFNTU1MgICAgVCAgICBCQkJCQiAgIFIgICBSICAgQSAgIEEgIElJSSAgIE4gICBOICAqCiAgKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioKICA=" | base64 -d
echo -e "\n====================== Validating environment... ====================== "
for dir in storage/framework/cache storage/framework/sessions storage/framework/views; do
if [ ! -d "$dir" ]; then
echo -e "\n > $dir is missing. Creating scaffold for storage directory... "
mkdir -p storage/framework/{cache,sessions,views}
chmod -R 775 storage
chown -R www-data:www-data storage
fi
done
if [ ! -L "public/storage" ]; then
echo -e "\n > Creating symbolic link for app public storage... "
php artisan storage:link
fi
# Ensure app storage directory is scaffolded
mkdir -p storage/framework/cache \
storage/framework/sessions \
storage/framework/views \
storage/app \
storage/logs
if [[ -z "$APP_KEY" ]]; then
echo -e "\n > Oops! The required APP_KEY configuration is missing in your environment! "
echo -e "\n > You should set this APP_KEY in your .env file! "
echo -e "\n > Storage directory scaffolding is OK... "
# Ensure storage directory is permissioned for www-data
chmod -R 775 storage
chown -R www-data:www-data storage
echo -e "\n > Permissions are OK... "
# Ensure app key exists / generate if required
KEY_FILE="storage/app/.key"
if [ -z "$APP_KEY" ] && [ ! -s "$KEY_FILE" ]; then
draw_box() {
local text="$1"
@@ -32,25 +36,43 @@ if [[ -z "$APP_KEY" ]]; then
echo "$border"
}
export APP_KEY=$(php artisan key:generate --show)
export APP_KEY="$(php artisan key:generate --show)"
echo -e "\n > Oops! The required APP_KEY configuration is missing! Generated app key and saved in $KEY_FILE"
echo "$APP_KEY" > "$KEY_FILE"
draw_box $APP_KEY
else
echo -e "\n > APP_KEY is OK... "
fi
echo -e "\n====================== Running migrations... ====================== "
run_migrations() {
php artisan migrate --force
}
RETRIES=12 # wait 60 seconds for database to be ready
# Wait 60 seconds for database to be ready
RETRIES=12
DELAY=5
run_migrations() {
sleep $DELAY
# php artisan migrate --force
output=$(php artisan migrate --force 2>/dev/null)
if [[ $? -eq 0 ]]; then
echo "$output"
return 0
else
return 1
fi
}
until run_migrations; do
RETRIES=$((RETRIES-1))
if [ $RETRIES -le 0 ]; then
echo -e "\n > Database is not ready after $RETRIES attempts. Exiting... "
if [[ $RETRIES -le 0 ]]; then
echo -e "\n > Database is not ready after one minute. Exiting... \n"
exit 1
fi
echo -e "\n > Waiting for database to be ready... retrying in $DELAY seconds. "
sleep $DELAY
echo -e "\n > Waiting for database to be ready... retrying in $DELAY seconds. \n"
done
echo -e "\n====================== Spinning up Supervisor daemon... ====================== \n"
exec supervisord -c /etc/supervisor/conf.d/supervisord.conf
exec supervisord -c /etc/supervisor/conf.d/supervisord.conf
+10 -5
View File
@@ -2,33 +2,38 @@
nodaemon=true
user=root
pidfile=/var/run/supervisord.pid
logfile=/var/log/supervisor/supervisord.log
[program:nginx]
command=nginx -g 'daemon off;'
autostart=true
autorestart=true
redirect_stderr=true
redirect_stdout=true
[program:php]
command=php-fpm -F
autostart=true
autorestart=true
redirect_stderr=true
redirect_stdout=true
[program:scheduler]
command=php artisan schedule:work
user=www-data
autorestart=true
redirect_stderr=true
redirect_stdout=true
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
[program:queue-worker]
command=php artisan queue:work --sleep=3 --tries=1 --memory=256 --timeout=3600
process_name=%(program_name)s_%(process_num)02d
command=php artisan queue:work --sleep=3 --tries=1 --memory=256 --timeout=3600
user=www-data
autorestart=true
redirect_stderr=true
redirect_stdout=true
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
numprocs=2
stopasgroup=true
killasgroup=true
[supervisorctl]
@@ -3,16 +3,16 @@
use App\Models\Holding;
use Livewire\Volt\Component;
new class extends Component {
new class extends Component
{
// props
public Holding $holding;
protected $listeners = [
'transaction-updated' => '$refresh',
'transaction-saved' => '$refresh'
'transaction-saved' => '$refresh',
];
// methods
}; ?>
@@ -1,33 +1,38 @@
<?php
use App\Models\Transaction;
use App\Models\Portfolio;
use App\Rules\SymbolValidationRule;
use App\Models\Transaction;
use App\Rules\QuantityValidationRule;
use Illuminate\Support\Collection;
use Livewire\Attributes\{Computed};
use App\Rules\SymbolValidationRule;
use App\Traits\WithTrimStrings;
use Livewire\Volt\Component;
use Mary\Traits\Toast;
use Illuminate\Validation\Rule;
use App\Traits\WithTrimStrings;
new class extends Component {
new class extends Component
{
use Toast;
use WithTrimStrings;
// props
public ?Portfolio $portfolio;
public ?Transaction $transaction;
public ?String $portfolio_id;
public String $symbol;
public String $transaction_type;
public String $date;
public Float $quantity;
public ?Float $cost_basis;
public ?Float $sale_price;
public ?string $portfolio_id;
public Bool $confirmingTransactionDeletion = false;
public string $symbol;
public string $transaction_type;
public string $date;
public float $quantity;
public ?float $cost_basis;
public ?float $sale_price;
public bool $confirmingTransactionDeletion = false;
// methods
public function rules()
@@ -36,19 +41,19 @@ new class extends Component {
'symbol' => ['required', 'string', new SymbolValidationRule],
'transaction_type' => 'required|string|in:BUY,SELL',
'portfolio_id' => 'required|exists:portfolios,id',
'date' => ['required', 'date_format:Y-m-d', 'before_or_equal:' . now()->format('Y-m-d')],
'date' => ['required', 'date_format:Y-m-d', 'before_or_equal:'.now()->format('Y-m-d')],
'quantity' => [
'required',
'numeric',
'min:0',
new QuantityValidationRule($this->portfolio, $this->symbol, $this->transaction_type, $this->date)
'required',
'numeric',
'min:0',
new QuantityValidationRule($this->portfolio, $this->symbol, $this->transaction_type, $this->date),
],
'cost_basis' => 'exclude_if:transaction_type,SELL|min:0|numeric',
'sale_price' => 'exclude_if:transaction_type,BUY|min:0|numeric',
];
}
public function mount()
public function mount()
{
if (isset($this->transaction)) {
@@ -59,7 +64,7 @@ new class extends Component {
$this->quantity = $this->transaction->quantity;
$this->cost_basis = $this->transaction->cost_basis;
$this->sale_price = $this->transaction->sale_price;
} else {
$this->transaction_type = 'BUY';
$this->portfolio_id = isset($this->portfolio) ? $this->portfolio->id : '';
@@ -70,9 +75,8 @@ new class extends Component {
public function update()
{
$this->authorize('fullAccess', $this->portfolio);
$this->transaction->update($this->validate());
// $this->transaction->owner_id = auth()->user()->id;
$this->transaction->save();
$this->success(__('Transaction updated'));
@@ -83,7 +87,7 @@ new class extends Component {
public function save()
{
if (!isset($this->portfolio)) {
if (! isset($this->portfolio)) {
$this->portfolio = Portfolio::find($this->portfolio_id);
}
@@ -107,6 +111,11 @@ new class extends Component {
$this->success(__('Transaction deleted'), redirectTo: route('holding.show', ['portfolio' => $this->portfolio->id, 'symbol' => $this->symbol]));
}
public function updatedSymbol($value)
{
$this->symbol = strtoupper($value);
}
}; ?>
<div class="" x-data="{ transaction_type: @entangle('transaction_type') }">
@@ -2,29 +2,31 @@
use App\Models\DailyChange;
use App\Models\Portfolio;
use Livewire\Attributes\{Title, Rule};
use Livewire\Volt\Component;
new class extends Component {
new class extends Component
{
// props
public ?Portfolio $portfolio;
public String $name = 'portfolio';
public String $scope = 'YTD';
public Array $scopeOptions = [
public string $name = 'portfolio';
public string $scope = 'YTD';
public array $scopeOptions = [
['id' => '1M', 'name' => '1 month', 'method' => 'subMonths', 'args' => [1]],
['id' => '3M', 'name' => '3 months', 'method' => 'subMonths', 'args' => [3]],
['id' => 'YTD', 'name' => 'Year to date', 'method' => 'startOfYear', 'args' => []],
['id' => '1Y', 'name' => '1 year', 'method' => 'subYears', 'args' => [1]],
['id' => '3Y', 'name' => '3 years', 'method' => 'subYears', 'args' => [3]],
['id' => 'ALL', 'name' => 'All time', 'method' => null]
['id' => 'ALL', 'name' => 'All time', 'method' => null],
];
// data
public Array $chartSeries;
public array $chartSeries;
// methods
public function mount()
public function mount()
{
$this->chartSeries = $this->generatePerformanceData();
}
@@ -33,52 +35,53 @@ new class extends Component {
{
$filterMethod = collect($this->scopeOptions)->where('id', $this->scope)->first();
$dailyChangeQuery = DailyChange::myDailyChanges();
$dailyChangeQuery = DailyChange::myDailyChanges()->selectRaw('
date,
SUM(total_market_value) as total_market_value,
SUM(total_cost_basis) as total_cost_basis,
SUM(total_gain) as total_gain
/* ,
SUM(realized_gains) as realized_gains,
SUM(total_dividends_earned) as total_dividends_earned
*/
');
if (isset($this->portfolio)) {
// portfolio
$dailyChangeQuery->portfolio($this->portfolio->id);
} else {
$dailyChangeQuery->selectRaw('
date,
SUM(total_market_value) as total_market_value,
SUM(total_cost_basis) as total_cost_basis,
SUM(total_gain) as total_gain
/* ,
SUM(realized_gains) as realized_gains,
SUM(total_dividends_earned) as total_dividends_earned
*/
')
->withoutWishlists()
->groupBy('date')
->orderBy('date');
// dashboard
$dailyChangeQuery->withoutWishlists();
}
if ($filterMethod['method']) {
$dailyChangeQuery->whereDate('date', '>=', now()->{$filterMethod['method']}(...$filterMethod['args']));
}
// dd($dailyChangeQuery->toSql());
$dailyChange = $dailyChangeQuery->get();
$dailyChange = $dailyChangeQuery
->orderBy('date')
->groupBy('date')
->get();
return [
'series' => [
[
'name' => __('Market Value'),
'data' => $dailyChange->map(fn($data) => [$data->date, $data->total_market_value])->toArray(),
'data' => $dailyChange->map(fn ($data) => [$data->date, $data->total_market_value])->toArray(),
],
[
'name' => __('Cost Basis'),
'data' => $dailyChange->map(fn($data) => [$data->date, $data->total_cost_basis])->toArray(),
'data' => $dailyChange->map(fn ($data) => [$data->date, $data->total_cost_basis])->toArray(),
],
[
'name' => __('Market Gain'),
'data' => $dailyChange->map(fn($data) => [$data->date, $data->total_gain])->toArray()
'data' => $dailyChange->map(fn ($data) => [$data->date, $data->total_gain])->toArray(),
],
// [
// 'name' => __('Dividends Earned'),
// 'data' => $dailyChange->map(fn($data) => [$data->date, $data->total_dividends_earned])->toArray()
@@ -87,7 +90,7 @@ new class extends Component {
// 'name' => __('Realized Gains'),
// 'data' => $dailyChange->map(fn($data) => [$data->date, $data->realized_gains])->toArray()
// ],
]
],
];
}
@@ -102,7 +105,6 @@ new class extends Component {
{
return collect($this->scopeOptions)->where('id', $scope)->first()['name'];
}
}; ?>
<x-card class="bg-slate-100 dark:bg-base-200 rounded-lg mb-6">
+8 -15
View File
@@ -58,23 +58,16 @@ class DividendsTest extends TestCase
$this->assertEqualsWithDelta(4.95, $dividendsReinvested * $market_data->market_value, 0.01);
}
public function test_do_not_duplicate_recent_dividends(): void
public function test_cannot_insert_duplicate_dividends(): void
{
$this->actingAs($user = User::factory()->create());
$portfolio = Portfolio::factory()->create();
Transaction::factory()->buy()->yearsAgo()->portfolio($portfolio->id)->symbol('ACME')->create();
$holding = Holding::query()->portfolio($portfolio->id)->symbol('ACME')->first();
Dividend::create([
'symbol' => 'ACME',
'date' => now()->subDay(2),
'dividend_amount' => .01,
]);
// first insert
Dividend::refreshDividendData('ACME');
$this->assertCount(1, $holding->dividends);
// try to duplicate
Dividend::refreshDividendData('ACME');
$dividend_count = Dividend::count();
$this->assertEquals(3, $dividend_count);
}
}