This commit is contained in:
Toy Rik 2025-12-22 12:54:34 +03:00
commit ad32756484
8229 changed files with 1239162 additions and 0 deletions

15
.env.example Normal file
View File

@ -0,0 +1,15 @@
PROJECT_NAME=portfolio-tracker
NGINX_PORT=8015
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=portfolio-tracker
DB_USER=user
DB_PASSWORD=password
DB_ROOT_PASSWORD=init
REDIS_PORT=6380
HMR_PORT=8081

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.idea
.env

118
compose.yml Normal file
View File

@ -0,0 +1,118 @@
services:
redis:
container_name: ${PROJECT_NAME}-redis-local
image: redis:latest
ports:
- ${REDIS_PORT}:6379
networks:
redis_net:
aliases:
- redis
restart: unless-stopped
backend:
container_name: ${PROJECT_NAME}-backend-local
build:
context: ./docker/backend
dockerfile: Dockerfile
args:
- UID=${UID:-1000}
- GID=${GID:-1000}
- USER=${USER-laravel}
volumes:
- ./src:/app
working_dir: /app
networks:
back_net:
aliases:
- backend
redis_net:
aliases:
- backend
nginx:
container_name: ${PROJECT_NAME}-nginx-local
build:
context: ./docker/nginx
dockerfile: Dockerfile
args:
- UID=${UID:-1000}
- GID=${GID:-1000}
- USER=${USER:-laravel}
restart: unless-stopped
ports:
- ${NGINX_PORT}:8000
volumes:
- ./src:/app
depends_on:
- backend
networks:
back_net:
aliases:
- nginx
db:
image: mariadb:10.6
restart: unless-stopped
tty: true
ports:
- ${DB_PORT}:3306
environment:
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
SERVICE_TAGS: dev
SERVICE_NAME: db
volumes:
- mysql-data:/var/lib/mysql
networks:
back_net:
aliases:
- db
phpmyadmin:
image: phpmyadmin:5.2.0
environment:
PMA_ARBITRARY: 1
PMA_HOST: db
PMA_PORT: 3306
PMA_USER: ${DB_USER}
PMA_PASSWORD: ${DB_PASSWORD}
depends_on:
- db
ports:
- 8888:80
networks:
back_net:
aliases:
- pma
node:
image: node:18-alpine
container_name: ${PROJECT_NAME}-node
working_dir: /app
volumes:
- ./src:/app
- /app/node_modules
ports:
- "${HMR_PORT}:8080"
networks:
back_net:
aliases:
- node
environment:
- NODE_ENV=development
command: sh -c "npm install && npm run dev"
networks:
back_net:
name: ${PROJECT_NAME}_back_net_local
driver: bridge
redis_net:
name: ${PROJECT_NAME}_redis_net_local
driver: bridge
volumes:
mysql-data:

49
docker/backend/Dockerfile Normal file
View File

@ -0,0 +1,49 @@
FROM php:8.1-fpm-alpine
ARG UID
ARG GID
ARG USER
ENV UID=${UID}
ENV GID=${GID}
ENV USER=${USER}
# Удаляем группу, которая мешает созданию пользователя с нужным GID
RUN delgroup dialout
# Создаём системного пользователя
RUN addgroup -g ${GID} --system ${USER}
RUN adduser -G ${USER} --system -D -s /bin/sh -u ${UID} ${USER}
# Настраиваем PHP-FPM под нашего пользователя
RUN sed -i "s/user = www-data/user = ${USER}/g" /usr/local/etc/php-fpm.d/www.conf
RUN sed -i "s/group = www-data/group = ${USER}/g" /usr/local/etc/php-fpm.d/www.conf
RUN echo "php_admin_flag[log_errors] = on" >> /usr/local/etc/php-fpm.d/www.conf
# Устанавливаем зависимости для MySQL
RUN apk add --no-cache mariadb-connector-c-dev
# Устанавливаем build-зависимости временно
RUN apk add --no-cache --virtual .build-deps \
$PHPIZE_DEPS \
mariadb-dev \
linux-headers
# Устанавливаем PHP-расширения
RUN docker-php-ext-install pdo pdo_mysql bcmath
# Устанавливаем Redis
RUN pecl install redis \
&& docker-php-ext-enable redis
# Удаляем временные build-зависимости
RUN apk del --no-cache .build-deps
# Устанавливаем Composer
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
# Переключаемся на непривилегированного пользователя
USER ${USER}
WORKDIR /app
CMD ["php-fpm", "-y", "/usr/local/etc/php-fpm.conf", "-R"]

25
docker/nginx/Dockerfile Normal file
View File

@ -0,0 +1,25 @@
FROM nginx:stable-alpine
# environment arguments
ARG UID
ARG GID
ARG USER
ENV UID=${UID}
ENV GID=${GID}
ENV USER=${USER}
# Dialout group in alpine linux conflicts with MacOS staff group's gid, whis is 20. So we remove it.
RUN delgroup dialout
# Creating user and group
RUN addgroup -g ${GID} --system ${USER}
RUN adduser -G ${USER} --system -D -s /bin/sh -u ${UID} ${USER}
# Modify nginx configuration to use the new user's priviledges for starting it.
RUN sed -i "s/user nginx/user '${USER}'/g" /etc/nginx/nginx.conf
# Copies nginx configurations to override the default.
ADD ./*.conf /etc/nginx/conf.d/
WORKDIR /app

25
docker/nginx/default.conf Normal file
View File

@ -0,0 +1,25 @@
server {
listen 8000;
index index.php index.html;
root /app/public;
error_log stderr warn;
access_log /dev/stdout main;
# error_log /var/log/nginx/error.log;
# access_log /var/log/nginx/access.log;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass backend:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
}

BIN
src/.DS_Store vendored Normal file

Binary file not shown.

59
src/.env.example Normal file
View File

@ -0,0 +1,59 @@
APP_NAME=PortfolioTracker
APP_ENV=local
APP_KEY=base64:Q2zMoud0kHTcm2GCZtJpYKeM/ySc0uSKCrVe/L5ffpw=
APP_DEBUG=true
APP_URL=http://localhost
LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=portfolio-tracker
DB_USERNAME=user
DB_PASSWORD=password
BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DRIVER=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=mailhog
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=null
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
IMPORTS_TRANSACTION_STORAGE_RELATIVE_PATH="imports/transactions.csv"
IMPORTS_DIVIDEND_STORAGE_RELATIVE_PATH="imports/transactions.csv"
#MARKET_STACK_ACCESS_KEY=0b22bde97b121cc0838ac354eccb1549
#MARKET_STACK_URI=http://api.marketstack.com/v1/
#MARKET_STACK_TIMEOUT=5

35
src/README.md Normal file
View File

@ -0,0 +1,35 @@
## Install and run:
- `cp .env.example .env`
- `composer install`
- Set up your database
- You can create a free MarketStack account (if you want up-to-date market data) and fill out these values
- `MARKET_STACK_ACCESS_KEY`
- `MARKET_STACK_URI`
- `MARKET_STACK_TIMEOUT`
- `php artisan migrate`
- `php artisan db:seed`. It will import the sample data from `storage/imports/transactions.csv`
- `php artisan serve`
- `npm install'
- `npm run dev`
- Login with `demo@portfolio.com` and `password`
It will import a dummy CSV full of demo transactions, so you have a lot of sample data. If you run the seeder you don't need to do anything else.
If you register a free Market Stack account and set the .env variables (see .env.example) it will also update the market data.
However, you can also run the imports manually.
## Imports:
`php artisan transaction:import`
- Import transactions from `storage/transactions.csv`
- Updates the holdings
- Updates the value of portfolios
- Also updates the special "aggregate" portfolios
`php artisan dividend:import`
- Imports only the dividend payouts from `storage/transactions.csv`
`php artisan market-stack:update-market-values`
- Updates every holding's market value from Market Stack using the current market price
`php artisan market-stack:update-dividends`
- Updates every stock's dividend data from Market Stack using the current dividend

BIN
src/app/.DS_Store vendored Normal file

Binary file not shown.

BIN
src/app/Actions/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,32 @@
<?php
namespace App\Actions\Dividend;
use App\DataTransferObjects\Dividend\DividendPayoutData;
use App\Models\Holding;
use App\Models\User;
use App\Services\CsvService;
use Illuminate\Support\Collection;
class ImportDividendPayouts
{
public function __construct(
private readonly string $csvPath,
private readonly CsvService $csvService,
private readonly UpsertDividendPayout $upsertDividendPayout,
) {}
public function execute(User $user): Collection
{
return $this->csvService->read($this->csvPath)
->filter(fn (array $data) => $data['type'] === 'DIVIDEND')
->map(fn (array $data) => [
...$data,
'holding' => Holding::whereTicker($data['ticker'])->whereBelongsTo($user)->first(),
'user' => $user,
])
->filter(fn (array $data) => $data['holding'])
->map(fn (array $data) => DividendPayoutData::from($data))
->map(fn (DividendPayoutData $data) => $this->upsertDividendPayout->execute($data));
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Actions\Dividend;
use App\Models\Stock;
use App\Services\MarketStack\MarketStackService;
class UpdateYearlyDividends
{
public function __construct(private readonly MarketStackService $marketStackService)
{
}
public function execute(): void
{
foreach (Stock::where('dividend_times_per_year', '!=', 0)->get() as $stock) {
$dividends = $this->marketStackService->dividends($stock->ticker);
$stock->dividend_amount_per_year = $dividends->sumOfLast($stock->dividend_times_per_year);
$stock->save();
}
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Actions\Dividend;
use App\DataTransferObjects\Dividend\DividendPayoutData;
use App\Models\DividendPayout;
class UpsertDividendPayout
{
public function execute(DividendPayoutData $data): DividendPayout
{
return DividendPayout::updateOrCreate(
[
'paid_at' => $data->date,
'user_id' => $data->user->id,
'holding_id' => $data->holding?->id,
],
[
'amount' => $data->total_amount,
],
);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Actions\Holding;
use App\Collections\Transaction\TransactionCollection;
use App\Models\Holding;
use App\Models\User;
use Illuminate\Support\Collection;
class CreateHoldingsFromTransactions
{
public function execute(TransactionCollection $transactions, User $user): Collection
{
return $transactions->groupBy('stock_id')
->filter(function (TransactionCollection $transactions, int $stockId) use ($user) {
// To handle rounding problems
if ($transactions->sumQuantity() <= 0.001 || $transactions->sum('total_price') <= 0.001) {
Holding::whereBelongsTo($user)->whereStockId($stockId)->delete();
return false;
}
return true;
})
->map(fn (TransactionCollection $transactions, int $stockId) => Holding::updateOrCreate(
[
'stock_id' => $stockId,
'user_id' => $user->id,
],
[
'average_cost' => $transactions->weightedPricePerShare(),
'quantity' => $transactions->sumQuantity(),
'invested_capital' => $transactions->sumTotalPrice(),
'ticker' => $transactions->first()->stock->ticker,
]
));
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Actions\Holding;
use App\Models\Holding;
use App\Services\MarketStack\MarketStackService;
class UpdateMarketValues
{
public function __construct(private readonly MarketStackService $marketStackService)
{
}
public function execute()
{
foreach (Holding::with('stock')->get() as $holding) {
$price = $this->marketStackService->price($holding->stock->ticker);
$holding->market_value = $holding->quantity * $price;
$holding->save();
}
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Actions\Portfolio;
use App\Models\Portfolio;
use Illuminate\Support\Collection;
class SyncAggregatePortfolios
{
public function execute(): void
{
Portfolio::whereName(Portfolio::AGGREGATE_NAME)->delete();
Portfolio::all()
->groupBy('user_id')
->map(function (Collection $portfolios) {
return [
'invested_capital' => $portfolios->reduce(fn (float $sumInvestedCapital, Portfolio $portfolio) => $sumInvestedCapital + $portfolio->invested_capital, 0),
'market_value' => $portfolios->reduce(fn (float $sumMarketValue, Portfolio $portfolio) => $sumMarketValue + $portfolio->market_value, 0),
];
})
->each(function (array $data, int $userId) {
Portfolio::create([
'name' => Portfolio::AGGREGATE_NAME,
'invested_capital' => $data['invested_capital'],
'market_value' => $data['market_value'],
'user_id' => $userId,
]);
});
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Actions\Portfolio;
use App\Models\Portfolio;
class UpdatePortfolioValues
{
public function execute()
{
Portfolio::all()->each(function (Portfolio $portfolio) {
$portfolio->invested_capital = $portfolio->holdings()->sum('invested_capital');
$portfolio->market_value = $portfolio->holdings()->sum('market_value');
$portfolio->save();
});
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Actions\Transaction;
use App\Collections\Transaction\TransactionCollection;
use App\DataTransferObjects\Transaction\TransactionData;
use App\Enums\TransactionTypes;
use App\Models\Stock;
use App\Models\User;
use App\Services\CsvService;
class ImportTransactions
{
public function __construct(
private string $csvPath,
private CsvService $csvService,
private UpsertTransaction $upsertTransaction,
) {}
public function execute(User $user): TransactionCollection
{
$transactions = $this->csvService->read($this->csvPath)
->filter(fn (array $data) => $data['type'] === TransactionTypes::SELL->value || $data['type'] === TransactionTypes::BUY->value)
->map(fn (array $data) => [
...$data,
'stock' => Stock::firstOrCreate(['ticker' => $data['ticker']]),
'user' => $user,
])
->map(fn (array $data) => TransactionData::from($data))
->map(fn (TransactionData $data) => $this->upsertTransaction->execute($data));
return new TransactionCollection($transactions);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Actions\Transaction;
use App\DataTransferObjects\Transaction\TransactionData;
use App\Models\Transaction;
class UpsertTransaction
{
public function execute(TransactionData $data): Transaction
{
return Transaction::updateOrCreate(
[
'import_id' => $data->import_id,
'user_id' => $data->user->id,
],
[
'type' => $data->type->value,
'quantity' => $data->quantity,
'price_per_share' => $data->price_per_share,
'stock_id' => $data->stock->id,
'date' => $data->date,
],
);
}
}

BIN
src/app/Builders/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,67 @@
<?php
namespace App\Builders\Dividend;
use App\DataTransferObjects\Dividend\MonthlyDividendData;
use App\Models\User;
use App\Filters\DateFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class DividendPayoutBuilder extends Builder
{
public function wherePayDateBetween(?DateFilter $dates): self
{
if ($dates) {
return $this->whereBetween('paid_at', [$dates->startDate, $dates->endDate]);
}
return $this;
}
/**
* @return Collection<MonthlyDividendData>
*/
public function monthly(User $user): Collection
{
return DB::table('dividend_payouts')
->select(DB::raw("date_format(paid_at, '%Y-%m') as month, sum(amount) as amount"))
->whereUserId($user->id)
->groupByRaw("date_format(paid_at, '%Y-%m')")
->orderByDesc('paid_at')
->get()
->map(fn (object $data) => MonthlyDividendData::from((array) $data));
}
public function allTime(User $user): float
{
$dates = DateFilter::fromCarbons(now()->startOfCentury(), today());
return $this->sumByDate($dates, $user);
}
public function thisWeek(User $user): float
{
$dates = DateFilter::fromCarbons(now()->startOfWeek(), now()->endOfWeek());
return $this->sumByDate($dates, $user);
}
public function thisMonth(User $user): float
{
$dates = DateFilter::fromCarbons(now()->startOfMonth(), now()->endOfMonth());
return $this->sumByDate($dates, $user);
}
public function thisYear(User $user): float
{
$dates = DateFilter::fromCarbons(now()->startOfYear(), now()->endOfYear());
return $this->sumByDate($dates, $user);
}
public function sumByDate(DateFilter $dates, User $user): float
{
return $this->whereBelongsTo($user)
->wherePayDateBetween($dates)
->sum('amount');
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Builders\Holding;
use Illuminate\Database\Eloquent\Builder;
class HoldingBuilder extends Builder
{
public function whereTicker(string $ticker): self
{
return $this->whereRelation('stock', 'ticker', $ticker);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Builders\Portfolio;
use App\Models\Holding;
use Illuminate\Database\Eloquent\Builder;
class PortfolioBuilder extends Builder
{
public function yieldOnCost(): ?float
{
$holdings = $this->model->is_aggregate
? Holding::whereBelongsTo($this->model->user)->get()
: $this->model->holdings;
return $holdings->yieldOnCost();
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Builders\Transaction;
use App\DataTransferObjects\Portfolio\InvestedCapitalData;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class TransactionBuilder extends Builder
{
/**
* @return Collection<InvestedCapitalData>
*/
public function monthly(User $user): Collection
{
return DB::table('transactions')
->select(DB::raw("date_format(date, '%Y-%m') as month, sum(total_price) as amount"))
->whereUserId($user->id)
->groupByRaw("date_format(date, '%Y-%m')")
->orderByDesc('date')
->get()
->map(fn (object $data) => InvestedCapitalData::from((array) $data));
}
}

BIN
src/app/Collections/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,21 @@
<?php
namespace App\Collections\Holding;
use App\Models\Holding;
use Illuminate\Database\Eloquent\Collection;
class HoldingCollection extends Collection
{
public function yieldOnCost(): ?float
{
$sumOfProducts = $this
->sum(fn (Holding $holding) => $holding->yield_on_cost * $holding->invested_capital);
if ($sumOfProducts === 0.0) {
return null;
}
return $sumOfProducts / $this->sum('invested_capital');
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Collections\Transaction;
use App\Models\Transaction;
use Illuminate\Database\Eloquent\Collection;
class TransactionCollection extends Collection
{
public function sumQuantity(): float
{
return $this->sum('quantity');
}
public function sumTotalPrice(): float
{
return $this->sum('total_price');
}
public function weightedPricePerShare(): float
{
$sumOfProducts = $this
->sum(fn (Transaction $transaction) => $transaction->quantity * $transaction->price_per_share);
if ($this->sumQuantity() === 0.00) {
return 0;
}
return $sumOfProducts / $this->sumQuantity();
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Console\Commands;
use App\Actions\Dividend\ImportDividendPayouts;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class ImportDividendPayoutsCommand extends Command
{
protected $signature = 'dividend:import {user}';
protected $description = 'Import dividend payouts from CSV';
public function handle(ImportDividendPayouts $importDividendPayouts)
{
DB::transaction(function () use ($importDividendPayouts) {
$user = User::findOrFail($this->argument('user'));
$importDividendPayouts->execute($user);
return Command::SUCCESS;
});
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Console\Commands;
use App\Actions\Holding\CreateHoldingsFromTransactions;
use App\Actions\Portfolio\SyncAggregatePortfolios;
use App\Actions\Portfolio\UpdatePortfolioValues;
use App\Actions\Transaction\ImportTransactions;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class ImportTransactionsCommand extends Command
{
protected $signature = 'transaction:import {user}';
protected $description = 'Import transactions from CSV';
public function handle(
ImportTransactions $importTransactions,
CreateHoldingsFromTransactions $createHoldingsFromTransactions,
UpdatePortfolioValues $updatePortfolioValues,
SyncAggregatePortfolios $syncAggregatePortfolios
): int {
return DB::transaction(function () use ($importTransactions, $createHoldingsFromTransactions, $updatePortfolioValues, $syncAggregatePortfolios) {
$user = User::findOrFail($this->argument('user'));
$newTransactions = $importTransactions->execute($user);
$transactions = Transaction::with('stock')->whereIn('stock_id', $newTransactions->pluck('stock_id'))->get();
$createHoldingsFromTransactions->execute($transactions, $user);
$updatePortfolioValues->execute();
$syncAggregatePortfolios->execute();
return Command::SUCCESS;
});
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Console\Commands;
use App\Actions\Portfolio\SyncAggregatePortfolios;
use App\Actions\Portfolio\UpdatePortfolioValues;
use Illuminate\Console\Command;
class SyncPortfoliosCommand extends Command
{
protected $signature = 'portfolio:sync';
protected $description = "Sync portolios' invested capital with holdings and recreates aggregate portolios";
public function handle(UpdatePortfolioValues $updatePortfolioValues, SyncAggregatePortfolios $syncAggregatePortfolios)
{
$updatePortfolioValues->execute();
$syncAggregatePortfolios->execute();
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Console\Commands;
use App\Actions\Holding\UpdateMarketValues;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
class UpdateMarketValuesCommand extends Command
{
protected $signature = 'market-stack:update-market-values';
protected $description = "Update every holding's market value from Market Stack";
public function handle(UpdateMarketValues $updateMarketValues)
{
$updateMarketValues->execute();
Artisan::call('portfolio:sync');
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Console\Commands;
use App\Actions\Dividend\UpdateYearlyDividends;
use Illuminate\Console\Command;
class UpdateYearlyDividendsCommands extends Command
{
protected $signature = 'market-stack:update-dividends';
protected $description = "Update every stock's dividend per year value from Market Stack";
public function handle(UpdateYearlyDividends $updateYearlyDividends)
{
$updateYearlyDividends->execute();
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
protected function schedule(Schedule $schedule)
{
$schedule->command('market-stack:update-dividends')->monthly();
$schedule->command('market-stack:update-market-values')->monthly();
}
protected function commands()
{
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
}
}

BIN
src/app/DataTransferObjects/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,15 @@
<?php
namespace App\DataTransferObjects\Casts;
use App\ValueObjects\Numbers\Money;
use Spatie\LaravelData\Casts\Cast;
use Spatie\LaravelData\Support\DataProperty;
class MoneyCast implements Cast
{
public function cast(DataProperty $property, mixed $value): string
{
return Money::from($value)->format();
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\DataTransferObjects\Casts;
use Carbon\Carbon;
use Spatie\LaravelData\Casts\Cast;
use Spatie\LaravelData\Support\DataProperty;
class MonthCast implements Cast
{
public function cast(DataProperty $property, mixed $value): string
{
return Carbon::parse($value)->format('M, Y');
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\DataTransferObjects\Casts;
use App\ValueObjects\Numbers\Percent;
use Spatie\LaravelData\Casts\Cast;
use Spatie\LaravelData\Support\DataProperty;
class PercentCast implements Cast
{
public function cast(DataProperty $property, mixed $value): string
{
return Percent::from($value)->format();
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\DataTransferObjects\Dividend\DividendIncome;
use Spatie\LaravelData\Data;
class DividendIncomeSummary extends Data
{
public function __construct(
public readonly DividendIncomeSummaryItem $thisWeek,
public readonly DividendIncomeSummaryItem $thisMonth,
public readonly DividendIncomeSummaryItem $thisYear,
public readonly DividendIncomeSummaryItem $allTime,
) {}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\DataTransferObjects\Dividend\DividendIncome;
use Spatie\LaravelData\Data;
class DividendIncomeSummaryItem extends Data
{
public function __construct(
public readonly string $value,
public readonly string $label,
) {}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\DataTransferObjects\Dividend;
use App\Models\Holding;
use App\Models\User;
use Carbon\Carbon;
use Spatie\LaravelData\Data;
class DividendPayoutData extends Data
{
public function __construct(
public readonly Carbon $date,
public readonly Holding $holding,
public readonly float $total_amount,
public readonly User $user,
) {}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\DataTransferObjects\Dividend;
use App\DataTransferObjects\Casts\MoneyCast;
use App\DataTransferObjects\Casts\MonthCast;
use Spatie\LaravelData\Attributes\WithCast;
use Spatie\LaravelData\Data;
class MonthlyDividendData extends Data
{
public function __construct(
#[WithCast(MoneyCast::class)]
public readonly string $amount,
#[WithCast(MonthCast::class)]
public readonly string $month,
) {}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\DataTransferObjects\Portfolio;
use App\DataTransferObjects\Casts\MoneyCast;
use App\DataTransferObjects\Casts\PercentCast;
use Spatie\LaravelData\Attributes\WithCast;
use Spatie\LaravelData\Data;
class HoldingData extends Data
{
public function __construct(
public readonly string $id,
public readonly string $ticker,
public readonly string $quantity,
#[WithCast(MoneyCast::class)]
public readonly string $average_cost,
#[WithCast(MoneyCast::class)]
public readonly string $invested_capital,
#[WithCast(MoneyCast::class)]
public readonly string $market_value,
#[WithCast(PercentCast::class)]
public readonly string $yield,
#[WithCast(PercentCast::class)]
public readonly string $yield_on_cost,
) {}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\DataTransferObjects\Portfolio;
use App\DataTransferObjects\Casts\MoneyCast;
use App\DataTransferObjects\Casts\MonthCast;
use Spatie\LaravelData\Attributes\WithCast;
use Spatie\LaravelData\Data;
class InvestedCapitalData extends Data
{
public function __construct(
#[WithCast(MoneyCast::class)]
public readonly string $amount,
#[WithCast(MonthCast::class)]
public readonly string $month,
) {}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\DataTransferObjects\Portfolio;
use App\DataTransferObjects\Casts\MoneyCast;
use App\DataTransferObjects\Casts\PercentCast;
use Spatie\LaravelData\Attributes\WithCast;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\DataCollection;
class PortfolioData extends Data
{
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly string $slug,
#[WithCast(MoneyCast::class)]
public readonly string $invested_capital,
#[WithCast(MoneyCast::class)]
public readonly string $market_value,
#[WithCast(PercentCast::class)]
public readonly ?string $yield_on_cost,
#[WithCast(PercentCast::class)]
public readonly ?string $yield,
/** @var DataCollection<HoldingData> */
public readonly DataCollection $holdings,
) {}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\DataTransferObjects\Transaction;
use App\Models\Stock;
use App\Models\User;
use App\Enums\TransactionTypes;
use Carbon\Carbon;
use Spatie\LaravelData\Attributes\WithCast;
use Spatie\LaravelData\Casts\EnumCast;
use Spatie\LaravelData\Data;
class TransactionData extends Data
{
public string $ticker;
public float $quantity;
public float $price_per_share;
public int $import_id;
public Carbon $date;
public Stock $stock;
public User $user;
#[WithCast(EnumCast::class, TransactionTypes::class)]
public TransactionTypes $type;
}

View File

@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum TransactionTypes: string
{
case SELL = 'SELL';
case BUY = 'BUY';
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* A list of the exception types that are not reported.
*
* @var string[]
*/
protected $dontReport = [
//
];
/**
* A list of the inputs that are never flashed for validation exceptions.
*
* @var string[]
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
/**
* Register the exception handling callbacks for the application.
*
* @return void
*/
public function register()
{
$this->reportable(function (Throwable $e) {
//
});
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Filters;
use App\ValueObjects\Date\EndDate;
use App\ValueObjects\Date\StartDate;
use Carbon\Carbon;
class DateFilter
{
public function __construct(
public StartDate $startDate,
public EndDate $endDate
) {}
public static function fromCarbons(
Carbon $startDate,
Carbon $endDate
): self {
return new static(
StartDate::fromString($startDate->toString()),
EndDate::fromString($endDate->toString())
);
}
}

BIN
src/app/Http/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*
* @return \Inertia\Response
*/
public function create()
{
return Inertia::render('Auth/Login', [
'canResetPassword' => Route::has('password.request'),
'status' => session('status'),
]);
}
/**
* Handle an incoming authentication request.
*
* @param \App\Http\Requests\Auth\LoginRequest $request
* @return \Illuminate\Http\RedirectResponse
*/
public function store(LoginRequest $request)
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(RouteServiceProvider::HOME);
}
/**
* Destroy an authenticated session.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse
*/
public function destroy(Request $request)
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*
* @return \Illuminate\View\View
*/
public function show()
{
return Inertia::render('Auth/ConfirmPassword');
}
/**
* Confirm the user's password.
*
* @param \Illuminate\Http\Request $request
* @return mixed
*/
public function store(Request $request)
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(RouteServiceProvider::HOME);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(RouteServiceProvider::HOME);
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\Request;
use Inertia\Inertia;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*
* @param \Illuminate\Http\Request $request
* @return mixed
*/
public function __invoke(Request $request)
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(RouteServiceProvider::HOME)
: Inertia::render('Auth/VerifyEmail', ['status' => session('status')]);
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\View\View
*/
public function create(Request $request)
{
return Inertia::render('Auth/ResetPassword', [
'email' => $request->email,
'token' => $request->route('token'),
]);
}
/**
* Handle an incoming new password request.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request)
{
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
if ($status == Password::PASSWORD_RESET) {
return redirect()->route('login')->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => [trans($status)],
]);
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*
* @return \Illuminate\View\View
*/
public function create()
{
return Inertia::render('Auth/ForgotPassword', [
'status' => session('status'),
]);
}
/**
* Handle an incoming password reset link request.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request)
{
$request->validate([
'email' => 'required|email',
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
if ($status == Password::RESET_LINK_SENT) {
return back()->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => [trans($status)],
]);
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Inertia\Inertia;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*
* @return \Illuminate\View\View
*/
public function create()
{
return Inertia::render('Auth/Register');
}
/**
* Handle an incoming registration request.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect(RouteServiceProvider::HOME);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*
* @param \Illuminate\Foundation\Auth\EmailVerificationRequest $request
* @return \Illuminate\Http\RedirectResponse
*/
public function __invoke(EmailVerificationRequest $request)
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(RouteServiceProvider::HOME.'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(RouteServiceProvider::HOME.'?verified=1');
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use AuthorizesRequests, DispatchesJobs, ValidatesRequests;
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers;
use App\ViewModels\GetDashboardViewModel;
use Illuminate\Http\Request;
use Inertia\Inertia;
class DashboardController extends Controller
{
public function index(Request $request)
{
return Inertia::render('Dashboard', [
'viewModel' => new GetDashboardViewModel($request->user()),
]);
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers;
use App\ViewModels\GetInvestedCapitalViewModel;
use Illuminate\Http\Request;
use Inertia\Inertia;
class InvestedCapitalController extends Controller
{
public function index(Request $request)
{
return Inertia::render('InvestedCapital/Index', [
'viewModel' => new GetInvestedCapitalViewModel($request->user()),
]);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers;
use App\Models\Portfolio;
use App\ViewModels\GetPortfolioViewModel;
use Illuminate\Http\Request;
use Inertia\Inertia;
class PortfolioController extends Controller
{
public function index(Request $request, Portfolio $portfolio)
{
$portfolio->load('holdings');
return Inertia::render('Portfolio/Index', [
'viewModel' => new GetPortfolioViewModel($request->user(), $portfolio),
]);
}
}

69
src/app/Http/Kernel.php Normal file
View File

@ -0,0 +1,69 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Fruitcake\Cors\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
];
/**
* The application's route middleware groups.
*
* @var array
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\App\Http\Middleware\HandleInertiaRequests::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**
* The application's route middleware.
*
* These middleware may be assigned to groups or used individually.
*
* @var array
*/
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
];
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
class Authenticate extends Middleware
{
/**
* Get the path the user should be redirected to when they are not authenticated.
*
* @param \Illuminate\Http\Request $request
* @return string|null
*/
protected function redirectTo($request)
{
if (! $request->expectsJson()) {
return route('login');
}
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
class EncryptCookies extends Middleware
{
/**
* The names of the cookies that should not be encrypted.
*
* @var array
*/
protected $except = [
//
];
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that is loaded on the first page visit.
*
* @var string
*/
protected $rootView = 'app';
/**
* Determine the current asset version.
*
* @param \Illuminate\Http\Request $request
* @return string|null
*/
public function version(Request $request)
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
public function share(Request $request)
{
return array_merge(parent::share($request), [
'auth' => [
'user' => $request->user(),
],
]);
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance as Middleware;
class PreventRequestsDuringMaintenance extends Middleware
{
/**
* The URIs that should be reachable while maintenance mode is enabled.
*
* @var array
*/
protected $except = [
//
];
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|null ...$guards
* @return mixed
*/
public function handle(Request $request, Closure $next, ...$guards)
{
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
}
}
return $next($request);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\TrimStrings as Middleware;
class TrimStrings extends Middleware
{
/**
* The names of the attributes that should not be trimmed.
*
* @var array
*/
protected $except = [
'current_password',
'password',
'password_confirmation',
];
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
class TrustHosts extends Middleware
{
/**
* Get the host patterns that should be trusted.
*
* @return array
*/
public function hosts()
{
return [
$this->allSubdomainsOfApplicationUrl(),
];
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request;
class TrustProxies extends Middleware
{
/**
* The trusted proxies for this application.
*
* @var array|string|null
*/
protected $proxies;
/**
* The headers that should be used to detect proxies.
*
* @var int
*/
protected $headers =
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB;
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array
*/
protected $except = [
//
];
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @return void
*
* @throws \Illuminate\Validation\ValidationException
*/
public function authenticate()
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @return void
*
* @throws \Illuminate\Validation\ValidationException
*/
public function ensureIsNotRateLimited()
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*
* @return string
*/
public function throttleKey()
{
return Str::lower($this->input('email')).'|'.$this->ip();
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use App\Builders\Dividend\DividendPayoutBuilder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DividendPayout extends Model
{
use HasFactory;
protected $guarded = [];
protected $dates = [
'paid_at',
];
public function holding(): BelongsTo
{
return $this->belongsTo(Holding::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function newEloquentBuilder($query): DividendPayoutBuilder
{
return new DividendPayoutBuilder($query);
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Models;
use App\Builders\Holding\HoldingBuilder;
use App\Collections\Holding\HoldingCollection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Holding extends Model
{
use HasFactory;
use SoftDeletes;
protected $guarded = [];
protected $with = ['stock'];
public function stock(): BelongsTo
{
return $this->belongsTo(Stock::class);
}
public function dividendPayouts(): HasMany
{
return $this->hasMany(DividendPayout::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function portfolio(): BelongsTo
{
return $this->belongsTo(Portfolio::class);
}
public function newEloquentBuilder($query): HoldingBuilder
{
return new HoldingBuilder($query);
}
public function newCollection(array $models = []): HoldingCollection
{
return new HoldingCollection($models);
}
public function getYieldOnCostAttribute(): float
{
if (!$this->stock->dividend_amount_per_year) {
return 0;
}
return $this->stock->dividend_amount_per_year / $this->average_cost;
}
public function getYieldAttribute(): float
{
return $this->market_value / $this->invested_capital - 1;
}
public function toArray(): array
{
return [
...parent::toArray(),
'yield' => $this->yield,
'yield_on_cost' => $this->yield_on_cost,
];
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Models;
use App\Builders\Portfolio\PortfolioBuilder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Support\Str;
class Portfolio extends Model
{
use HasFactory;
public const AGGREGATE_NAME = 'All';
protected $guarded = [];
protected $with = ['holdings'];
protected static function booted()
{
static::saving(function (Portfolio $portfolio) {
$portfolio->slug = Str::slug($portfolio->name);
});
}
public function getRouteKeyName(): string
{
return 'slug';
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function holdings(): HasMany
{
return $this->hasMany(Holding::class);
}
public function dividendPayouts(): HasManyThrough
{
return $this->hasManyThrough(DividendPayout::class, Holding::class);
}
public function newEloquentBuilder($query): PortfolioBuilder
{
return new PortfolioBuilder($query);
}
public function getYieldOnCostAttribute(): ?float
{
return $this->yieldOnCost();
}
public function getYieldAttribute(): ?float
{
return $this->market_value / $this->invested_capital - 1;
}
public function getIsAggregateAttribute(): bool
{
return $this->name === self::AGGREGATE_NAME;
}
public function toArray(): array
{
return [
...parent::toArray(),
'yield' => $this->yield,
'yield_on_cost' => $this->yield_on_cost,
'is_aggregate' => $this->is_aggregate,
];
}
}

30
src/app/Models/Stock.php Normal file
View File

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Stock extends Model
{
use HasFactory;
protected $guarded = [];
public function transactions(): HasMany
{
return $this->hasMany(Transaction::class);
}
public function holdings(): HasMany
{
return $this->hasMany(Holding::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use App\Builders\Transaction\TransactionBuilder;
use App\Enums\TransactionTypes;
use App\Collections\Transaction\TransactionCollection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Transaction extends Model
{
use HasFactory;
protected $guarded = [];
public function stock(): BelongsTo
{
return $this->belongsTo(Stock::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function newEloquentBuilder($query): TransactionBuilder
{
return new TransactionBuilder($query);
}
public function newCollection(array $models = []): TransactionCollection
{
return new TransactionCollection($models);
}
protected static function booted()
{
self::saving(function (Transaction $transaction) {
if ($transaction->type === TransactionTypes::SELL->value && $transaction->quantity > 0) {
$transaction->quantity *= -1;
}
$transaction->total_price = $transaction->price_per_share * $transaction->quantity;
});
}
}

45
src/app/Models/User.php Normal file
View File

@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
];
protected $casts = [
'email_verified_at' => 'datetime',
];
public function portfolios(): HasMany
{
return $this->hasMany(Portfolio::class);
}
public function holdings(): HasMany
{
return $this->hasMany(Holding::class);
}
public function dividendPayouts(): HasMany
{
return $this->hasMany(DividendPayout::class);
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Providers;
use App\Actions\Dividend\ImportDividendPayouts;
use App\Actions\Transaction\ImportTransactions;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register()
{
//
}
public function boot()
{
$this->app->when(ImportTransactions::class)
->needs('$csvPath')
->give(storage_path(config('imports.transaction.storage_path')));
$this->app->when(ImportDividendPayouts::class)
->needs('$csvPath')
->give(storage_path(config('imports.dividend.storage_path')));
// DB::listen(function ($query) {
// logger(Str::replaceArray('?', $query->bindings, $query->sql));
// });
JsonResource::wrap(null);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Providers;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
class AuthServiceProvider extends ServiceProvider
{
/**
* The policy mappings for the application.
*
* @var array
*/
protected $policies = [
// 'App\Models\Model' => 'App\Policies\ModelPolicy',
];
/**
* Register any authentication / authorization services.
*
* @return void
*/
public function boot()
{
$this->registerPolicies();
//
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;
class BroadcastServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
Broadcast::routes();
require base_path('routes/channels.php');
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Providers;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;
class EventServiceProvider extends ServiceProvider
{
/**
* The event listener mappings for the application.
*
* @var array
*/
protected $listen = [
Registered::class => [
SendEmailVerificationNotification::class,
],
];
/**
* Register any events for your application.
*
* @return void
*/
public function boot()
{
//
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Providers;
use App\Services\MarketStack\MarketStackService;
use Illuminate\Support\ServiceProvider;
class MarketStackServiceProvider extends ServiceProvider
{
public function register()
{
//
}
public function boot()
{
$this->app->singleton(MarketStackService::class, fn () => new MarketStackService(
config('services.market_stack.uri'),
config('services.market_stack.access_key'),
config('services.market_stack.timeout'),
));
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Providers;
use App\Models\Portfolio;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends ServiceProvider
{
public const HOME = '/dashboard';
public function boot()
{
$this->configureRateLimiting();
$this->routes(function () {
Route::middleware('web')
->namespace($this->namespace)
->group(base_path('routes/web.php'));
});
Route::bind('portfolio', function(string $slug) {
return Portfolio::query()
->whereBelongsTo(request()->user())
->whereSlug($slug)
->firstOrFail();
});
}
/**
* Configure the rate limiters for the application.
*
* @return void
*/
protected function configureRateLimiting()
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip());
});
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Services;
use Exception;
use Illuminate\Support\Collection;
class CsvService
{
/**
* @throws Exception
* @return Collection<string, string>
*/
public function read(string $path): Collection
{
$stream = $this->openFile($path);
$rows = [];
$rowIdx = -1;
$columns = [];
while (($data = fgetcsv($stream, 1000, ',')) !== false) {
$rowIdx++;
if ($rowIdx === 0) {
$columns = $data;
continue;
}
$row = [];
foreach ($data as $idx => $value) {
$row[$columns[$idx]] = $value;
}
$rows[] = $row;
}
fclose($stream);
return collect($rows);
}
/**
* @throws Exception
* @return resource
*/
private function openFile(string $path)
{
$stream = fopen($path, 'r');
if ($stream === false) {
throw new Exception('Unable to open csv file at ' . $path);
}
return $stream;
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Services\MarketStack\Collections;
use Illuminate\Support\Collection;
class DividendCollection extends Collection
{
public function sumOfLast(int $months): float
{
return $this->take($months)->sum('dividend');
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Services\MarketStack\DataTransferObjects;
use Carbon\Carbon;
class DividendData
{
public function __construct(
public readonly Carbon $date,
public readonly float $dividend,
) {}
public static function fromArray(array $data): self
{
return new static(
Carbon::parse($data['date']),
$data['dividend'],
);
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Services\MarketStack;
use App\Services\MarketStack\Collections\DividendCollection;
use App\Services\MarketStack\DataTransferObjects\DividendData;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
class MarketStackService
{
public function __construct(
private readonly string $uri,
private readonly string $accessKey,
private readonly int $timeout
) {}
public function dividends(string $ticker): DividendCollection
{
$response = $this->buildRequest()
->get($this->uri . 'dividends', $this->buildQuery($ticker))
->throw();
$items = collect($response->json('data'))
->map(fn (array $item) => DividendData::fromArray($item))
->toArray();
return new DividendCollection($items);
}
public function price(string $ticker): float
{
$response = $this->buildRequest()
->get($this->uri . 'eod', [
'limit' => 1,
...$this->buildQuery($ticker),
])
->throw()
->json('data');
return (float) $response[0]['close'];
}
private function buildRequest(): PendingRequest
{
return Http::withHeaders([
'Accept' => 'application/json',
])->timeout($this->timeout);
}
private function buildQuery(string $ticker): array
{
return [
'access_key' => $this->accessKey,
'symbols' => $ticker,
];
}
}

BIN
src/app/ValueObjects/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,25 @@
<?php
namespace App\ValueObjects\Date;
use Carbon\Carbon;
class EndDate
{
public Carbon $date;
public function __construct(Carbon $date)
{
$this->date = $date->endOfDay();
}
public static function fromString(string $date): self
{
return new static(Carbon::parse($date));
}
public function __toString(): string
{
return $this->date->format('Y-m-d H:i:s');
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\ValueObjects\Date;
use Carbon\Carbon;
class StartDate
{
public Carbon $date;
public function __construct(Carbon $date)
{
$this->date = $date->startOfDay();
}
public static function fromString(string $date): self
{
return new static(Carbon::parse($date));
}
public function __toString(): string
{
return $this->date->format('Y-m-d H:i:s');
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\ValueObjects\Numbers;
class Decimal
{
public function __construct(private readonly float $value)
{
}
public static function from(float $value): self
{
return new static($value);
}
public function format(): string
{
return number_format($this->value, 2);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\ValueObjects\Numbers;
class Money
{
public function __construct(private readonly ?float $value)
{
}
public static function from(?float $value): self
{
return new static($value);
}
public function format(): string
{
if (!$this->value) {
return '$0';
}
return '$' . number_format($this->value, 2);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\ValueObjects\Numbers;
class Percent
{
public function __construct(private readonly ?float $value)
{
}
public static function from(?float $value): self
{
return new static($value);
}
public function format(string $defaultValue = ''): string
{
if ($this->value === null) {
return $defaultValue;
}
return number_format($this->value * 100, 2) . '%';
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\ViewModels;
use App\DataTransferObjects\Dividend\DividendIncome\DividendIncomeSummary;
use App\DataTransferObjects\Dividend\DividendIncome\DividendIncomeSummaryItem;
use App\DataTransferObjects\Dividend\MonthlyDividendData;
use App\DataTransferObjects\Portfolio\PortfolioData;
use App\Models\DividendPayout;
use App\Models\Portfolio;
use App\Models\User;
use App\ValueObjects\Numbers\Money;
use Spatie\LaravelData\DataCollection;
class GetDashboardViewModel extends ViewModel
{
public function __construct(private User $user)
{
}
/**
* @return DataCollection<MonthlyDividendData>
*/
public function monthlyDividendIncome(): DataCollection
{
return MonthlyDividendData::collection(DividendPayout::monthly($this->user));
}
/**
* @return DataCollection<PortfolioData>
*/
public function portfolios(): DataCollection
{
return PortfolioData::collection(
Portfolio::whereBelongsTo($this->user)
->orderByDesc('invested_capital')
->get()
);
}
public function dividendIncomeSummary(): DividendIncomeSummary
{
return new DividendIncomeSummary(
thisWeek: new DividendIncomeSummaryItem(
value: Money::from(DividendPayout::thisWeek($this->user))->format(),
label: 'This Week',
),
thisMonth: new DividendIncomeSummaryItem(
value: Money::from(DividendPayout::thisMonth($this->user))->format(),
label: 'This Month',
),
thisYear: new DividendIncomeSummaryItem(
value: Money::from(DividendPayout::thisYear($this->user))->format(),
label: 'This Year',
),
allTime: new DividendIncomeSummaryItem(
value: Money::from(DividendPayout::allTime($this->user))->format(),
label: 'All Time',
),
);
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\ViewModels;
use App\DataTransferObjects\Portfolio\InvestedCapitalData;
use App\Models\Transaction;
use App\Models\User;
use Spatie\LaravelData\DataCollection;
class GetInvestedCapitalViewModel extends ViewModel
{
public function __construct(private User $user)
{
}
/**
* @return DataCollection<InvestedCapitalData>
*/
public function monthly(): DataCollection
{
return InvestedCapitalData::collection(Transaction::monthly($this->user));
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\ViewModels;
use App\DataTransferObjects\Portfolio\HoldingData;
use App\DataTransferObjects\Portfolio\PortfolioData;
use App\Models\Holding;
use App\Models\Portfolio;
use App\Models\User;
use Spatie\LaravelData\DataCollection;
class GetPortfolioViewModel extends ViewModel
{
public function __construct(private User $user, private Portfolio $portfolio)
{
}
public function portfolio(): PortfolioData
{
return PortfolioData::from($this->portfolio);
}
/**
* @return DataCollection<HoldingData>
*/
public function holdings(): DataCollection
{
if ($this->portfolio->is_aggregate) {
return HoldingData::collection(
Holding::whereBelongsTo($this->user)->orderByDesc('invested_capital')->get()
);
}
return HoldingData::collection($this->portfolio->holdings()->orderByDesc('invested_capital')->get());
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\ViewModels;
use Illuminate\Contracts\Support\Arrayable;
use Reflection;
use ReflectionClass;
use ReflectionMethod;
use Illuminate\Support\Str;
abstract class ViewModel implements Arrayable
{
public function toArray(): array
{
return collect((new ReflectionClass($this))->getMethods())
->reject(fn (ReflectionMethod $method) => in_array($method->getName(), ['__construct', 'toArray']))
->filter(fn (ReflectionMethod $method) => in_array('public', Reflection::getModifierNames($method->getModifiers())))
->mapWithKeys(fn (ReflectionMethod $method) => [
Str::snake($method->getName()) => $this->{$method->getName()}()
])->toArray();
}
}

53
src/artisan Normal file
View File

@ -0,0 +1,53 @@
#!/usr/bin/env php
<?php
define('LARAVEL_START', microtime(true));
/*
|--------------------------------------------------------------------------
| Register The Auto Loader
|--------------------------------------------------------------------------
|
| Composer provides a convenient, automatically generated class loader
| for our application. We just need to utilize it! We'll require it
| into the script here so that we do not have to worry about the
| loading of any of our classes manually. It's great to relax.
|
*/
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
/*
|--------------------------------------------------------------------------
| Run The Artisan Application
|--------------------------------------------------------------------------
|
| When we run the console application, the current CLI command will be
| executed in this console and the response sent back to a terminal
| or another output device for the developers. Here goes nothing!
|
*/
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$status = $kernel->handle(
$input = new Symfony\Component\Console\Input\ArgvInput,
new Symfony\Component\Console\Output\ConsoleOutput
);
/*
|--------------------------------------------------------------------------
| Shutdown The Application
|--------------------------------------------------------------------------
|
| Once Artisan has finished running, we will fire off the shutdown events
| so that any final work may be done by the application before we shut
| down the process. This is the last thing to happen to the request.
|
*/
$kernel->terminate($input, $status);
exit($status);

Some files were not shown because too many files have changed in this diff Show More