init
This commit is contained in:
commit
ad32756484
15
.env.example
Normal file
15
.env.example
Normal 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
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
.idea
|
||||||
|
.env
|
||||||
118
compose.yml
Normal file
118
compose.yml
Normal 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
49
docker/backend/Dockerfile
Normal 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
25
docker/nginx/Dockerfile
Normal 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
25
docker/nginx/default.conf
Normal 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
BIN
src/.DS_Store
vendored
Normal file
Binary file not shown.
59
src/.env.example
Normal file
59
src/.env.example
Normal 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
35
src/README.md
Normal 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
BIN
src/app/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
src/app/Actions/.DS_Store
vendored
Normal file
BIN
src/app/Actions/.DS_Store
vendored
Normal file
Binary file not shown.
32
src/app/Actions/Dividend/ImportDividendPayouts.php
Normal file
32
src/app/Actions/Dividend/ImportDividendPayouts.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app/Actions/Dividend/UpdateYearlyDividends.php
Normal file
22
src/app/Actions/Dividend/UpdateYearlyDividends.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/app/Actions/Dividend/UpsertDividendPayout.php
Normal file
23
src/app/Actions/Dividend/UpsertDividendPayout.php
Normal 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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/Actions/Holding/CreateHoldingsFromTransactions.php
Normal file
37
src/app/Actions/Holding/CreateHoldingsFromTransactions.php
Normal 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,
|
||||||
|
]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app/Actions/Holding/UpdateMarketValues.php
Normal file
22
src/app/Actions/Holding/UpdateMarketValues.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/app/Actions/Portfolio/SyncAggregatePortfolios.php
Normal file
31
src/app/Actions/Portfolio/SyncAggregatePortfolios.php
Normal 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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/app/Actions/Portfolio/UpdatePortfolioValues.php
Normal file
17
src/app/Actions/Portfolio/UpdatePortfolioValues.php
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/app/Actions/Transaction/ImportTransactions.php
Normal file
34
src/app/Actions/Transaction/ImportTransactions.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/app/Actions/Transaction/UpsertTransaction.php
Normal file
26
src/app/Actions/Transaction/UpsertTransaction.php
Normal 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
BIN
src/app/Builders/.DS_Store
vendored
Normal file
Binary file not shown.
67
src/app/Builders/Dividend/DividendPayoutBuilder.php
Normal file
67
src/app/Builders/Dividend/DividendPayoutBuilder.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/app/Builders/Holding/HoldingBuilder.php
Normal file
13
src/app/Builders/Holding/HoldingBuilder.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/app/Builders/Portfolio/PortfolioBuilder.php
Normal file
18
src/app/Builders/Portfolio/PortfolioBuilder.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/app/Builders/Transaction/TransactionBuilder.php
Normal file
26
src/app/Builders/Transaction/TransactionBuilder.php
Normal 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
BIN
src/app/Collections/.DS_Store
vendored
Normal file
Binary file not shown.
21
src/app/Collections/Holding/HoldingCollection.php
Normal file
21
src/app/Collections/Holding/HoldingCollection.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/app/Collections/Transaction/TransactionCollection.php
Normal file
31
src/app/Collections/Transaction/TransactionCollection.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/app/Console/Commands/ImportDividendPayoutsCommand.php
Normal file
24
src/app/Console/Commands/ImportDividendPayoutsCommand.php
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/app/Console/Commands/ImportTransactionsCommand.php
Normal file
38
src/app/Console/Commands/ImportTransactionsCommand.php
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app/Console/Commands/SyncPortfoliosCommand.php
Normal file
22
src/app/Console/Commands/SyncPortfoliosCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app/Console/Commands/UpdateMarketValuesCommand.php
Normal file
22
src/app/Console/Commands/UpdateMarketValuesCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/app/Console/Commands/UpdateYearlyDividendsCommands.php
Normal file
19
src/app/Console/Commands/UpdateYearlyDividendsCommands.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app/Console/Kernel.php
Normal file
22
src/app/Console/Kernel.php
Normal 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
BIN
src/app/DataTransferObjects/.DS_Store
vendored
Normal file
Binary file not shown.
15
src/app/DataTransferObjects/Casts/MoneyCast.php
Normal file
15
src/app/DataTransferObjects/Casts/MoneyCast.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/app/DataTransferObjects/Casts/MonthCast.php
Normal file
15
src/app/DataTransferObjects/Casts/MonthCast.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/app/DataTransferObjects/Casts/PercentCast.php
Normal file
15
src/app/DataTransferObjects/Casts/PercentCast.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
18
src/app/DataTransferObjects/Dividend/DividendPayoutData.php
Normal file
18
src/app/DataTransferObjects/Dividend/DividendPayoutData.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
18
src/app/DataTransferObjects/Dividend/MonthlyDividendData.php
Normal file
18
src/app/DataTransferObjects/Dividend/MonthlyDividendData.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
27
src/app/DataTransferObjects/Portfolio/HoldingData.php
Normal file
27
src/app/DataTransferObjects/Portfolio/HoldingData.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
28
src/app/DataTransferObjects/Portfolio/PortfolioData.php
Normal file
28
src/app/DataTransferObjects/Portfolio/PortfolioData.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
24
src/app/DataTransferObjects/Transaction/TransactionData.php
Normal file
24
src/app/DataTransferObjects/Transaction/TransactionData.php
Normal 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;
|
||||||
|
}
|
||||||
9
src/app/Enums/TransactionTypes.php
Normal file
9
src/app/Enums/TransactionTypes.php
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum TransactionTypes: string
|
||||||
|
{
|
||||||
|
case SELL = 'SELL';
|
||||||
|
case BUY = 'BUY';
|
||||||
|
}
|
||||||
41
src/app/Exceptions/Handler.php
Normal file
41
src/app/Exceptions/Handler.php
Normal 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) {
|
||||||
|
//
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/app/Filters/DateFilter.php
Normal file
25
src/app/Filters/DateFilter.php
Normal 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
BIN
src/app/Http/.DS_Store
vendored
Normal file
Binary file not shown.
@ -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('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/app/Http/Controllers/Auth/NewPasswordController.php
Normal file
73
src/app/Http/Controllers/Auth/NewPasswordController.php
Normal 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)],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
55
src/app/Http/Controllers/Auth/RegisteredUserController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
30
src/app/Http/Controllers/Auth/VerifyEmailController.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/app/Http/Controllers/Controller.php
Normal file
13
src/app/Http/Controllers/Controller.php
Normal 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;
|
||||||
|
}
|
||||||
17
src/app/Http/Controllers/DashboardController.php
Normal file
17
src/app/Http/Controllers/DashboardController.php
Normal 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()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/app/Http/Controllers/InvestedCapitalController.php
Normal file
17
src/app/Http/Controllers/InvestedCapitalController.php
Normal 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()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/app/Http/Controllers/PortfolioController.php
Normal file
20
src/app/Http/Controllers/PortfolioController.php
Normal 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
69
src/app/Http/Kernel.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
21
src/app/Http/Middleware/Authenticate.php
Normal file
21
src/app/Http/Middleware/Authenticate.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/app/Http/Middleware/EncryptCookies.php
Normal file
17
src/app/Http/Middleware/EncryptCookies.php
Normal 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 = [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
42
src/app/Http/Middleware/HandleInertiaRequests.php
Normal file
42
src/app/Http/Middleware/HandleInertiaRequests.php
Normal 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(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/app/Http/Middleware/PreventRequestsDuringMaintenance.php
Normal file
17
src/app/Http/Middleware/PreventRequestsDuringMaintenance.php
Normal 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 = [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
32
src/app/Http/Middleware/RedirectIfAuthenticated.php
Normal file
32
src/app/Http/Middleware/RedirectIfAuthenticated.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/app/Http/Middleware/TrimStrings.php
Normal file
19
src/app/Http/Middleware/TrimStrings.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
20
src/app/Http/Middleware/TrustHosts.php
Normal file
20
src/app/Http/Middleware/TrustHosts.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/app/Http/Middleware/TrustProxies.php
Normal file
28
src/app/Http/Middleware/TrustProxies.php
Normal 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;
|
||||||
|
}
|
||||||
17
src/app/Http/Middleware/VerifyCsrfToken.php
Normal file
17
src/app/Http/Middleware/VerifyCsrfToken.php
Normal 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 = [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
93
src/app/Http/Requests/Auth/LoginRequest.php
Normal file
93
src/app/Http/Requests/Auth/LoginRequest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/app/Models/DividendPayout.php
Normal file
33
src/app/Models/DividendPayout.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/app/Models/Holding.php
Normal file
73
src/app/Models/Holding.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/app/Models/Portfolio.php
Normal file
78
src/app/Models/Portfolio.php
Normal 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
30
src/app/Models/Stock.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/app/Models/Transaction.php
Normal file
48
src/app/Models/Transaction.php
Normal 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
45
src/app/Models/User.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/app/Providers/AppServiceProvider.php
Normal file
36
src/app/Providers/AppServiceProvider.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/app/Providers/AuthServiceProvider.php
Normal file
30
src/app/Providers/AuthServiceProvider.php
Normal 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();
|
||||||
|
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/app/Providers/BroadcastServiceProvider.php
Normal file
21
src/app/Providers/BroadcastServiceProvider.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/app/Providers/EventServiceProvider.php
Normal file
32
src/app/Providers/EventServiceProvider.php
Normal 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()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/app/Providers/MarketStackServiceProvider.php
Normal file
23
src/app/Providers/MarketStackServiceProvider.php
Normal 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'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/app/Providers/RouteServiceProvider.php
Normal file
45
src/app/Providers/RouteServiceProvider.php
Normal 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());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/app/Services/CsvService.php
Normal file
53
src/app/Services/CsvService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/app/Services/MarketStack/MarketStackService.php
Normal file
58
src/app/Services/MarketStack/MarketStackService.php
Normal 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
BIN
src/app/ValueObjects/.DS_Store
vendored
Normal file
Binary file not shown.
25
src/app/ValueObjects/Date/EndDate.php
Normal file
25
src/app/ValueObjects/Date/EndDate.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/app/ValueObjects/Date/StartDate.php
Normal file
25
src/app/ValueObjects/Date/StartDate.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/app/ValueObjects/Numbers/Decimal.php
Normal file
20
src/app/ValueObjects/Numbers/Decimal.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/app/ValueObjects/Numbers/Money.php
Normal file
24
src/app/ValueObjects/Numbers/Money.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/app/ValueObjects/Numbers/Percent.php
Normal file
24
src/app/ValueObjects/Numbers/Percent.php
Normal 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) . '%';
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/app/ViewModels/GetDashboardViewModel.php
Normal file
62
src/app/ViewModels/GetDashboardViewModel.php
Normal 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',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/app/ViewModels/GetInvestedCapitalViewModel.php
Normal file
23
src/app/ViewModels/GetInvestedCapitalViewModel.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/app/ViewModels/GetPortfolioViewModel.php
Normal file
36
src/app/ViewModels/GetPortfolioViewModel.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app/ViewModels/ViewModel.php
Normal file
22
src/app/ViewModels/ViewModel.php
Normal 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
53
src/artisan
Normal 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
Loading…
x
Reference in New Issue
Block a user